Compare commits

...

31 commits

Author SHA1 Message Date
renovate[bot]
ccbd739cfe
Merge d489cfee13 into d3b6ba01c7 2026-09-28 19:28:32 +00:00
renovate[bot]
d489cfee13
chore(deps): update github actions 2026-09-28 19:28:25 +00:00
Alexandre Brandizzi
d3b6ba01c7
Merge pull request #193 from Sea-Haven-Industries/fix/ab/sh-403-reset-hardening-2
Some checks failed
Backend CI / Build and test (push) Has been cancelled
Backend CI / architecture (push) Has been cancelled
Backend CI / review (push) Has been cancelled
Backend CI / ci-complete (push) Has been cancelled
fix(auth): cap reset abuse per account, key reset-code hashes, send reset email off the request path
2026-09-25 23:11:26 +00:00
Alexandre Brandizzi
74d5aa17eb fix(auth): trace a reset email the provider rejects as an error
A send that the mail provider did not accept finished its background
transaction as ok, so rejected reset emails looked delivered in tracing.
It now finishes as an error with a fixed message that names no recipient.
2026-09-25 20:01:58 -03:00
Alexandre Brandizzi
1077d489a8
Merge pull request #197 from Sea-Haven-Industries/fix/ab/sh-407-cancel-wo-cancels-uplift
Cancelling a work order cancels its pending uplift
2026-09-25 23:01:24 +00:00
Alexandre Brandizzi
e941e055b7 Merge remote-tracking branch 'origin/main' into fix/ab/sh-407-cancel-wo-cancels-uplift 2026-09-25 19:49:50 -03:00
Alexandre Brandizzi
c985e9423d Merge remote-tracking branch 'origin/main' into fix/ab/sh-403-reset-hardening-2
# Conflicts:
#	Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs
#	Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs
#	SeaHaven.Services/Implementation/AuthenticationService.cs
2026-09-25 19:49:24 -03:00
Alexandre Brandizzi
de0e767930 fix(auth): refuse a reset email when the queue is full instead of dropping it
The channel used DropWrite, under which TryWrite reports success and
discards the email, so a full queue still counted the request and never
sent the code. Wait makes TryWrite return false when the queue is full,
without blocking, so the request is released and the user can ask again.
2026-09-25 19:38:53 -03:00
Alexandre Brandizzi
63465cc083
Merge pull request #198 from Sea-Haven-Industries/fix/ab/sh-409-invalidate-sessions-on-reset
End earlier sign-in sessions after a password reset, password change, deactivation or deletion
2026-09-25 22:38:44 +00:00
Alexandre Brandizzi
a32471d4c0 Serialize sync cancels and vendor uplift requests on the work order lock
The legacy ingest batch holds the per-work-order lock, taken in id order,
for every work order it may cancel until the batch commits. A vendor
uplift request now runs under the same lock. Uplift creation on both
routes refuses a work order cancelled by either lifecycle or status text,
so a request can neither slip past a cancel nor land after one.
2026-09-25 19:37:08 -03:00
Alexandre Brandizzi
0298fc75bd Merge remote-tracking branch 'origin/main' into fix/ab/sh-403-reset-hardening-2 2026-09-25 19:27:01 -03:00
Alexandre Brandizzi
498f49a2d8 fix(auth): end earlier sessions when a user's role or account changes
A token carries the user's roles and account, so a demoted admin kept admin
claims until the token expired. The team member update and the admin user
edit now rotate the security stamp and evict the cached value when the role,
account or user name changes. Permission overrides are read per request and
are not in the token.
2026-09-25 19:26:25 -03:00
Alexandre Brandizzi
987ec455f2 Merge remote-tracking branch 'origin/main' into fix/ab/sh-409-invalidate-sessions-on-reset
# Conflicts:
#	Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs
#	Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs
#	SeaHaven.Services/Implementation/AuthenticationService.cs
2026-09-25 19:22:33 -03:00
Alexandre Brandizzi
77dc3d85c3 Serialize a CRM cancel with uplift creation on the work order lock
The per-work-order gate moves into a shared data-layer helper. A CRM
mutation that cancels an existing work order now runs under it, so a
create in flight either commits first and is cancelled, or sees the
cancelled work order.
2026-09-25 19:15:38 -03:00
Alexandre Brandizzi
cc46950254 test(auth): format the password change request 2026-09-25 19:10:46 -03:00
Alexandre Brandizzi
b7be07411e fix(auth): end earlier sessions when a password or account status changes
Tokens now carry a keyed hash of the account's security stamp, and every
authenticated request compares it with the stored stamp (cached for 60 s,
evicted in-process on change). A password reset or change, a deactivation
and a deletion all rotate or remove the stamp, so tokens issued before them
get 401. Tokens without the claim get 401 too.
2026-09-25 19:08:33 -03:00
Alexandre Brandizzi
306ab159cc Cancel pending uplifts when the legacy ingest cancels a work order
The ingest writes only the status text, so the shared helper gains a
status-text form of the same rule and the ingest stages the same
sync-attributed cancellation in its batch save.
2026-09-25 19:01:37 -03:00
Alexandre Brandizzi
e993e1b5fc refactor(auth): compose bearer authentication through one registration 2026-09-25 18:54:10 -03:00
Alexandre Brandizzi
99499c3281 Cancel pending uplifts when the CRM cancels a work order
The webhook and reconciliation saves now stage the same pending-uplift
cancellation, with its own sync audit row, as the board cancel. The rule
and the write live in one data-layer helper so the paths cannot drift.
2026-09-25 18:48:44 -03:00
Alexandre Brandizzi
9bad363930 fix(work-orders): cancelling from the board cancels the pending uplift
The board and slide-over cancel a work order through the lifecycle status
patch, which set Canceled without touching uplifts, so a pending uplift
stayed in the approval queue. A patch to Canceled now withdraws pending
uplifts in the same save, each with its own uplift_cancel audit entry, and
runs under the per-work-order gate uplift create uses.
2026-09-25 18:37:56 -03:00
Alexandre Brandizzi
ca9322fa60 fix(auth): queue the reset email only after its code is stored 2026-09-25 18:29:33 -03:00
Alexandre Brandizzi
2f23f6fadc test(team-members): register reset email delivery in the invite test host 2026-09-25 18:10:10 -03:00
Alexandre Brandizzi
35e79a276d Merge branch 'fix/ab/sh-403-reset-code-hardening' into fix/ab/sh-403-reset-hardening-2 2026-09-25 17:54:13 -03:00
Alexandre Brandizzi
5eb1323419 test(auth): give the reset test host's helpers names of their own 2026-09-25 17:51:26 -03:00
Alexandre Brandizzi
f0dcba63fd fix(auth): give back reset check and request slots when a data call fails or is cancelled 2026-09-25 17:38:51 -03:00
Alexandre Brandizzi
1277642ede Merge branch 'fix/ab/sh-403-reset-code-hardening' into fix/ab/sh-403-reset-hardening-2
# Conflicts:
#	SeaHaven.Services/Implementation/AuthenticationService.cs
2026-09-25 16:48:57 -03:00
Alexandre Brandizzi
fe5f27c8e1 test(auth): build the password-policy service with the reset queue, throttle and keyed hash 2026-09-25 14:08:30 -03:00
Alexandre Brandizzi
613bfa48d8 Merge branch 'fix/ab/sh-403-reset-code-hardening' into fix/ab/sh-403-reset-hardening-2 2026-09-25 14:04:17 -03:00
Alexandre Brandizzi
57af8a1206 fix(auth): store an unmatchable code when the reset email cannot be queued, keeping data calls identical 2026-09-25 13:38:11 -03:00
Alexandre Brandizzi
a6dd40b972 fix(auth): only count real guesses, drop codes that cannot be emailed, trace reset email sends 2026-09-25 13:12:49 -03:00
Alexandre Brandizzi
77a10e38ca fix(auth): cap reset abuse per account, key code hashes, send reset email off the request path
- Forgot Password is limited to 3 codes an hour and 10 a day per email, and
  an account gets 10 failed code checks a day across every code it is sent,
  so new client addresses and new codes no longer buy more guesses. Refused
  requests answer exactly like accepted ones.
- The reset email is queued to a background sender, and unregistered
  addresses store a row no code can match, so both paths do the same work
  and return without waiting on the mail provider. Each request also clears
  expired codes.
- Code hashes are HMAC-SHA256 under a key derived with HKDF from the JWT
  signing secret; rows in the previous unkeyed format stop matching.
- Email and code are read only from the JSON body.
2026-09-25 13:00:03 -03:00
66 changed files with 3374 additions and 335 deletions

View file

@ -22,7 +22,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
- name: Set up .NET
uses: actions/setup-dotnet@v6
@ -30,7 +30,7 @@ jobs:
dotnet-version: "8.0.x"
- name: Cache NuGet packages
uses: actions/cache@v4
uses: actions/cache@v4.3.0
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }}
@ -52,7 +52,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
@ -62,7 +62,7 @@ jobs:
dotnet-version: "8.0.x"
- name: Cache NuGet packages
uses: actions/cache@v4
uses: actions/cache@v4.3.0
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }}
@ -88,7 +88,7 @@ jobs:
review:
name: review
if: github.event_name != 'push'
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
ci-complete:
name: ci-complete

View file

@ -176,7 +176,7 @@ jobs:
- name: Configure AWS credentials using OIDC
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1

View file

@ -237,8 +237,8 @@ public class AuthenticationControllerTests
var service = new Mock<IAuthenticationService>();
var controller = NewController(service);
var registered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None);
var unregistered = await controller.ForgetPassword(null, "x@y.com", CancellationToken.None);
var registered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, CancellationToken.None);
var unregistered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "x@y.com" }, CancellationToken.None);
var ok = registered.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<Response>().Subject;
@ -259,7 +259,7 @@ public class AuthenticationControllerTests
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var controller = new AuthenticationController(service.Object, logger.Object);
var result = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None);
var result = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, CancellationToken.None);
var response = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
@ -283,7 +283,7 @@ public class AuthenticationControllerTests
var controller = NewController(service);
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "123456" }, null, null, CancellationToken.None);
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "123456" }, CancellationToken.None);
if (matched)
{
@ -302,16 +302,16 @@ public class AuthenticationControllerTests
}
[Fact]
public async Task VerificationCode_QueryOnlyCode_PassesNoEmailToTheService()
public async Task VerificationCode_WithoutABody_PassesNoEmailOrCodeToTheService()
{
var service = new Mock<IAuthenticationService>();
var controller = NewController(service);
var result = await controller.VerificationCode(null, null, "123456", CancellationToken.None);
var result = await controller.VerificationCode(null, CancellationToken.None);
result.Should().BeOfType<BadRequestObjectResult>().Subject.Value.Should().BeOfType<Response>()
.Which.Message.Should().Be("Code Not Matched");
service.Verify(s => s.VerifyCodeAsync(null, "123456", It.IsAny<CancellationToken>()), Times.Once);
service.Verify(s => s.VerifyCodeAsync(null, null, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
@ -322,7 +322,7 @@ public class AuthenticationControllerTests
.ThrowsAsync(new InvalidOperationException("SECRET-internal-stack-detail"));
var controller = NewController(service);
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "1" }, null, null, CancellationToken.None);
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "1" }, CancellationToken.None);
Json(result.Should().BeOfType<BadRequestObjectResult>().Subject.Value)
.Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" }));

View file

@ -19,14 +19,16 @@ public class AuthenticationServiceTests
private static AuthenticationService NewService(
Mock<IUserDataService> userData,
Mock<IForgetPasswordDataService> forget,
Mock<IEmailSender> email,
Mock<IPasswordResetEmailQueue> email,
out Mock<IUserRoleStore<ApplicationUser>> store,
out Mock<IPasswordHasher<ApplicationUser>> hasher)
{
var (manager, s, h) = IdentityTestHelpers.CreateUserManager();
store = s;
hasher = h;
return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object, TimeProvider.System);
var jwtOptions = Microsoft.Extensions.Options.Options.Create(JwtOptions);
var sessionStamps = new SessionStampService(userData.Object, new InMemorySessionStampCache(TimeProvider.System), jwtOptions);
return new AuthenticationService(manager, jwtOptions, userData.Object, forget.Object, email.Object, new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System, sessionStamps);
}
private static JwtOptions JwtOptions => new()
@ -39,7 +41,7 @@ public class AuthenticationServiceTests
[Fact]
public async Task Login_UnknownUser_ReturnsNull()
{
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out _);
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out _);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
var result = await service.LoginAsync("nobody", "pw", CancellationToken.None);
@ -51,7 +53,7 @@ public class AuthenticationServiceTests
public async Task Login_DeletedUser_RejectedBeforePasswordCheck()
{
var deletedUser = IdentityTestHelpers.User(isDeleted: true);
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out var hasher);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(deletedUser);
store.Setup(s => s.GetRolesAsync(deletedUser, It.IsAny<CancellationToken>())).ReturnsAsync(new List<string>());
@ -65,7 +67,7 @@ public class AuthenticationServiceTests
public async Task Login_ValidUser_ReturnsTokenFirstRoleAndIdentity()
{
var user = IdentityTestHelpers.User();
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out var hasher);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
store.Setup(s => s.GetRolesAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync(new List<string> { "Admin", "Manager" });
store.As<Microsoft.AspNetCore.Identity.IUserPasswordStore<ApplicationUser>>()
@ -88,7 +90,7 @@ public class AuthenticationServiceTests
public async Task Login_BadPassword_ReturnsNull()
{
var user = IdentityTestHelpers.User();
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out var hasher);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
store.As<Microsoft.AspNetCore.Identity.IUserPasswordStore<ApplicationUser>>()
.Setup(s => s.GetPasswordHashAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("hash");
@ -99,22 +101,24 @@ public class AuthenticationServiceTests
result.Should().BeNull();
}
private static byte[] ResetKey => PasswordResetCodeSecrets.DeriveKey(JwtOptions.Secret);
[Fact]
public async Task ForgetPassword_RegisteredEmail_StoresOnlyASaltedHashAndEmailsTheCode()
public async Task ForgetPassword_RegisteredEmail_StoresOnlyAKeyedHashAndQueuesTheCode()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IEmailSender>();
var email = new Mock<IPasswordResetEmailQueue>();
string? stored = null, salt = null, body = null;
DateTime expires = default;
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, DateTime, CancellationToken>((_, _, h, s, e, _) => { stored = h; salt = s; expires = e; })
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, DateTime, DateTime, CancellationToken>((_, _, h, s, e, _, _) => { stored = h; salt = s; expires = e; })
.Returns(Task.CompletedTask);
email.Setup(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.IsAny<string>()))
email.Setup(e => e.TryEnqueue(user.Email!, "Forget Password Request.", It.IsAny<string>()))
.Callback<string, string, string>((_, _, b) => body = b)
.ReturnsAsync(true);
.Returns(true);
var service = NewService(userData, forget, email, out _, out _);
var before = DateTime.UtcNow;
@ -124,25 +128,103 @@ public class AuthenticationServiceTests
var code = System.Text.RegularExpressions.Regex.Match(body!, @"Your Password Reset Code is: (\d{6})").Groups[1].Value;
code.Should().HaveLength(6);
stored.Should().NotBe(code).And.MatchRegex("^[0-9a-f]{64}$");
PasswordResetCodeSecrets.Matches(salt!, code, stored!).Should().BeTrue();
PasswordResetCodeSecrets.Matches(ResetKey, salt!, code, stored!).Should().BeTrue();
expires.Should().BeCloseTo(before.AddMinutes(15), TimeSpan.FromSeconds(5));
}
[Fact]
public async Task ForgetPassword_UnknownEmail_SendsNothingButStillDoesTheDatabaseRoundTrip()
public async Task ForgetPassword_UnknownEmail_MakesTheSameDataCallsAndQueuesNothing()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(IdentityTestHelpers.User());
var registered = new Mock<IForgetPasswordDataService>();
var unregistered = new Mock<IForgetPasswordDataService>();
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(true);
await NewService(userData, registered, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None);
await NewService(userData, unregistered, email, out _, out _).ForgetPasswordAsync("nope@example.com", CancellationToken.None);
registered.Invocations.Select(call => call.Method.Name)
.Should().Equal(unregistered.Invocations.Select(call => call.Method.Name))
.And.Equal(nameof(IForgetPasswordDataService.ReplaceCodeAsync));
unregistered.Verify(f => f.ReplaceCodeAsync("nope@example.com", string.Empty, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Once);
email.Verify(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task ForgetPassword_EmailThatCannotBeQueued_DoesNotCountTheRequest()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IEmailSender>();
var stored = new List<(string Hash, string Salt)>();
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, DateTime, DateTime, CancellationToken>((_, _, h, s, _, _, _) => stored.Add((h, s)))
.Returns(Task.CompletedTask);
var email = new Mock<IPasswordResetEmailQueue>();
var bodies = new List<string>();
// The queue is full for the first three requests.
email.Setup(e => e.TryEnqueue(user.Email!, It.IsAny<string>(), It.IsAny<string>()))
.Returns<string, string, string>((_, _, b) =>
{
bodies.Add(b);
return bodies.Count > 3;
});
var service = NewService(userData, forget, email, out _, out _);
for (var request = 0; request < 4; request++)
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
// The three undelivered requests did not use up the hourly limit of three, and every
// email carries the code stored just before it was queued.
email.Verify(e => e.TryEnqueue(user.Email!, It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(4));
stored.Should().HaveCount(4);
for (var request = 0; request < 4; request++)
{
var code = System.Text.RegularExpressions.Regex.Match(bodies[request], @"Your Password Reset Code is: (\d{6})").Groups[1].Value;
PasswordResetCodeSecrets.Matches(ResetKey, stored[request].Salt, code, stored[request].Hash).Should().BeTrue();
}
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
forget.Verify(f => f.RemoveByEmailAsync("nope@example.com", It.IsAny<CancellationToken>()), Times.Once);
forget.Verify(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ForgetPassword_WriteThatFailsOrIsCancelled_QueuesNoEmail(bool cancelled)
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")));
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(true);
var act = () => NewService(userData, forget, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
email.Verify(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ForgetPassword_EmailThatCannotBeQueued_MakesTheSameDataCallsAsAnUnknownEmail()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(IdentityTestHelpers.User());
var registered = new Mock<IForgetPasswordDataService>();
var unregistered = new Mock<IForgetPasswordDataService>();
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(false);
await NewService(userData, registered, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None);
await NewService(userData, unregistered, email, out _, out _).ForgetPasswordAsync("nope@example.com", CancellationToken.None);
registered.Invocations.Select(call => call.Method.Name)
.Should().Equal(unregistered.Invocations.Select(call => call.Method.Name))
.And.Equal(nameof(IForgetPasswordDataService.ReplaceCodeAsync));
}
[Fact]
@ -152,13 +234,13 @@ public class AuthenticationServiceTests
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(IdentityTestHelpers.User(isDeleted: true));
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IEmailSender>();
var email = new Mock<IPasswordResetEmailQueue>();
var service = NewService(userData, forget, email, out _, out _);
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
email.Verify(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Theory]
@ -171,7 +253,7 @@ public class AuthenticationServiceTests
{
var forget = new Mock<IForgetPasswordDataService>(MockBehavior.Strict);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out _, out _);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
var result = await service.VerifyCodeAsync(emailAddress, code, CancellationToken.None);
@ -184,7 +266,7 @@ public class AuthenticationServiceTests
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.GetByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ForgetPasswordCode?)null);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out var store, out _);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out var store, out _);
var result = await service.ResetPasswordAsync("a@b.com", "999999", "new", CancellationToken.None);
@ -196,12 +278,12 @@ public class AuthenticationServiceTests
[Fact]
public async Task ResetPassword_AttemptBudgetSpent_DeletesTheCodeAndFails()
{
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash("s", "123456"), FailedAttempts = 5 };
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456"), FailedAttempts = 5 };
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(pending);
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out var store, out _);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out var store, out _);
var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None);
@ -210,6 +292,84 @@ public class AuthenticationServiceTests
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task VerifyCode_FailedOrCancelledLookups_LeaveTheAccountCheckBudgetUntouched(bool cancelled)
{
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456") };
var forget = new Mock<IForgetPasswordDataService>();
var lookups = 0;
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>()))
.Returns(() => ++lookups <= InMemoryPasswordResetThrottle.FailedChecksPerDay
? Task.FromException<ForgetPasswordCode?>(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
: Task.FromResult<ForgetPasswordCode?>(pending));
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>())).ReturnsAsync(true);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++)
{
var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
(await service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None)).Should().BeTrue();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task VerifyCode_FailedOrCancelledAttemptConsumes_LeaveTheAccountCheckBudgetUntouched(bool cancelled)
{
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456") };
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(pending);
var consumes = 0;
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(() => ++consumes <= InMemoryPasswordResetThrottle.FailedChecksPerDay
? Task.FromException<bool>(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
: Task.FromResult(true));
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++)
{
var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
(await service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None)).Should().BeTrue();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ForgetPassword_FailedOrCancelledWrites_DoNotUseUpTheEmailRequestLimit(bool cancelled)
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var writes = 0;
forget.Setup(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(() => ++writes <= InMemoryPasswordResetThrottle.CodeRequestsPerHour
? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
: Task.CompletedTask);
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(true);
var service = NewService(userData, forget, email, out _, out _);
for (var request = 0; request < InMemoryPasswordResetThrottle.CodeRequestsPerHour; request++)
{
var act = () => service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
writes.Should().Be(InMemoryPasswordResetThrottle.CodeRequestsPerHour + 1);
forget.Verify(f => f.PurgeExpiredAsync(It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Theory]
[InlineData("123456", "123456", true)]
[InlineData("123456", " 123456 ", true)]
@ -217,11 +377,11 @@ public class AuthenticationServiceTests
public void ResetCodeHash_IsSaltedAndComparedByValue(string issued, string candidate, bool expected)
{
var salt = PasswordResetCodeSecrets.NewSalt();
var hash = PasswordResetCodeSecrets.Hash(salt, issued);
var hash = PasswordResetCodeSecrets.Hash(ResetKey, salt, issued);
PasswordResetCodeSecrets.Matches(salt, candidate, hash).Should().Be(expected);
PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.NewSalt(), issued).Should().NotBe(hash);
PasswordResetCodeSecrets.Matches("", issued, hash).Should().BeFalse();
PasswordResetCodeSecrets.Matches(salt, issued, "").Should().BeFalse();
PasswordResetCodeSecrets.Matches(ResetKey, salt, candidate, hash).Should().Be(expected);
PasswordResetCodeSecrets.Hash(ResetKey, PasswordResetCodeSecrets.NewSalt(), issued).Should().NotBe(hash);
PasswordResetCodeSecrets.Matches(ResetKey, "", issued, hash).Should().BeFalse();
PasswordResetCodeSecrets.Matches(ResetKey, salt, issued, "").Should().BeFalse();
}
}

View file

@ -133,8 +133,10 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
Mock.Of<IForgetPasswordDataService>(),
Mock.Of<IEmailSender>(),
TimeProvider.System);
Mock.Of<IPasswordResetEmailQueue>(),
new InMemoryPasswordResetThrottle(TimeProvider.System),
TimeProvider.System,
Mock.Of<ISessionStampService>());
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
@ -179,7 +181,7 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
Email = user.Email!,
UserId = user.Id,
CodeSalt = salt,
CodeHash = PasswordResetCodeSecrets.Hash(salt, "123456"),
CodeHash = PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.DeriveKey(new string('x', 64)), salt, "123456"),
ExpiresAtUtc = DateTime.UtcNow.AddMinutes(10)
});
forget.Setup(f => f.TryConsumeAttemptAsync(7, It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
@ -220,8 +222,10 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
Mock.Of<IEmailSender>(),
TimeProvider.System);
Mock.Of<IPasswordResetEmailQueue>(),
new InMemoryPasswordResetThrottle(TimeProvider.System),
TimeProvider.System,
Mock.Of<ISessionStampService>());
public async ValueTask DisposeAsync()
{

View file

@ -0,0 +1,43 @@
using Api.SeaHavenIndustries.HostedServices;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class PasswordResetEmailChannelTests
{
[Fact]
public void A_full_queue_refuses_the_email_instead_of_dropping_it_silently()
{
var channel = new PasswordResetEmailChannel(NullLogger<PasswordResetEmailChannel>.Instance);
for (var i = 0; i < PasswordResetEmailChannel.Capacity; i++)
channel.TryEnqueue($"user{i}@example.com", "subject", "body").Should().BeTrue();
var accepted = channel.TryEnqueue("late@example.com", "subject", "body");
accepted.Should().BeFalse();
channel.Pending.Should().Be(PasswordResetEmailChannel.Capacity);
}
[Fact]
public async Task A_refused_email_is_never_delivered_and_the_queue_accepts_again_once_drained()
{
var channel = new PasswordResetEmailChannel(NullLogger<PasswordResetEmailChannel>.Instance);
for (var i = 0; i < PasswordResetEmailChannel.Capacity; i++)
channel.TryEnqueue($"user{i}@example.com", "subject", "body");
channel.TryEnqueue("late@example.com", "subject", "body").Should().BeFalse();
var delivered = new List<string>();
while (channel.Reader.TryRead(out var email))
{
delivered.Add(email.EmailTo);
channel.MarkHandled();
}
delivered.Should().HaveCount(PasswordResetEmailChannel.Capacity).And.NotContain("late@example.com");
channel.Pending.Should().Be(0);
channel.TryEnqueue("retry@example.com", "subject", "body").Should().BeTrue();
(await channel.Reader.ReadAsync()).EmailTo.Should().Be("retry@example.com");
}
}

View file

@ -0,0 +1,55 @@
using Api.SeaHavenIndustries.HostedServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Sentry;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class PasswordResetEmailSenderTracingTests
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Each_send_finishes_its_transaction_as_ok_only_when_the_provider_accepts_it(bool accepted)
{
var transaction = new Mock<ITransactionTracer>();
var hub = new Mock<IHub>();
hub.Setup(h => h.PushScope()).Returns(Mock.Of<IDisposable>());
hub.Setup(h => h.StartTransaction(It.IsAny<ITransactionContext>(), It.IsAny<IReadOnlyDictionary<string, object?>>()))
.Returns(transaction.Object);
var sender = new Mock<IEmailSender>();
sender.Setup(s => s.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(accepted);
var services = new ServiceCollection().AddSingleton(sender.Object).BuildServiceProvider();
var channel = new PasswordResetEmailChannel(NullLogger<PasswordResetEmailChannel>.Instance);
var worker = new PasswordResetEmailSenderHostedService(
channel,
services.GetRequiredService<IServiceScopeFactory>(),
NullLogger<PasswordResetEmailSenderHostedService>.Instance,
hub.Object);
await worker.StartAsync(CancellationToken.None);
Assert.True(channel.TryEnqueue("user@example.com", "subject", "Your code is 123456"));
var deadline = DateTime.UtcNow.AddSeconds(10);
while (channel.Pending > 0 && DateTime.UtcNow < deadline)
await Task.Delay(10);
await worker.StopAsync(CancellationToken.None);
Assert.Equal(0, channel.Pending);
if (accepted)
{
transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Once);
transaction.Verify(t => t.Finish(It.IsAny<Exception>(), It.IsAny<SpanStatus>()), Times.Never);
}
else
{
transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Never);
transaction.Verify(t => t.Finish(
It.Is<Exception>(ex => !ex.Message.Contains("user@example.com") && !ex.Message.Contains("123456")),
SpanStatus.InternalError), Times.Once);
}
}
}

View file

@ -61,6 +61,7 @@ public class SentryPipelineContractTests
[InlineData("Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs", "workorders.week-rolled-job")]
[InlineData("Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs", "workorders.past-due-cache-job")]
[InlineData("Api.SeaHavenIndustries/HostedServices/UpliftLifecycleHostedService.cs", "uplifts.lifecycle-sweep")]
[InlineData("Api.SeaHavenIndustries/HostedServices/PasswordResetEmailDelivery.cs", "auth.password-reset-email")]
public void Workers_UseSharedBackgroundTransactionHelper_WithStableNames(string relativePath, string transactionName)
{
var source = File.ReadAllText(Path.Combine(RepoRoot(), relativePath));

View file

@ -72,4 +72,19 @@ public class ServiceRegistrationTests
AssertScopedConventionRegistrations(services, candidates, "SeaHaven.DataServices");
}
[Fact]
public void SessionStampCache_IsOneInstanceForTheWholeProcess()
{
// A scoped cache would never be hit, and a stamp change on one request would
// never evict the value another request cached.
using var provider = BuildConventionServices().BuildServiceProvider();
using var first = provider.CreateScope();
using var second = provider.CreateScope();
var cache = first.ServiceProvider.GetRequiredService<SeaHaven.Services.Interfaces.ISessionStampCache>();
cache.Should().BeOfType<InMemorySessionStampCache>();
second.ServiceProvider.GetRequiredService<SeaHaven.Services.Interfaces.ISessionStampCache>().Should().BeSameAs(cache);
}
}

View file

@ -0,0 +1,138 @@
using FluentAssertions;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class SessionStampServiceTests
{
private const string UserId = "user-1";
private readonly Mock<IUserDataService> _users = new();
private readonly SteppedTimeProvider _time = new();
private readonly InMemorySessionStampCache _cache;
public SessionStampServiceTests()
{
_cache = new InMemorySessionStampCache(_time);
}
private SessionStampService NewService(string secret = "0123456789abcdef0123456789abcdef0123456789abcdef") =>
new(_users.Object, _cache, Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = secret }));
private void StoredStamp(string? stamp) =>
_users.Setup(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny<CancellationToken>())).ReturnsAsync(stamp);
[Fact]
public void The_token_carries_a_keyed_hash_never_the_stamp_itself()
{
var value = NewService().ClaimValueFor("STAMP-ONE");
value.Should().NotContain("STAMP-ONE");
value.Should().Be(NewService().ClaimValueFor("STAMP-ONE"));
value.Should().NotBe(NewService().ClaimValueFor("STAMP-TWO"));
value.Should().NotBe(NewService(new string('z', 64)).ClaimValueFor("STAMP-ONE"));
}
[Fact]
public async Task A_matching_stamp_is_read_once_per_cache_lifetime()
{
StoredStamp("STAMP-ONE");
var service = NewService();
var issued = service.ClaimValueFor("STAMP-ONE");
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
(await NewService().IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
_users.Verify(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task A_stamp_changed_by_another_instance_is_enforced_once_the_cached_value_expires()
{
StoredStamp("STAMP-ONE");
var service = NewService();
var issued = service.ClaimValueFor("STAMP-ONE");
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
StoredStamp("STAMP-TWO");
_time.Advance(InMemorySessionStampCache.Lifetime - TimeSpan.FromSeconds(1));
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
_time.Advance(TimeSpan.FromSeconds(1));
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse();
}
[Fact]
public async Task A_stamp_changed_by_this_instance_is_enforced_at_once()
{
StoredStamp("STAMP-ONE");
var service = NewService();
var issued = service.ClaimValueFor("STAMP-ONE");
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
StoredStamp("STAMP-TWO");
service.Forget(UserId);
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse();
}
[Fact]
public async Task A_read_that_races_a_change_is_not_cached()
{
var service = NewService();
var issued = service.ClaimValueFor("STAMP-ONE");
var reads = 0;
_users.Setup(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
// The first read returns the old stamp while this instance saves a new one.
if (reads++ == 0)
{
service.Forget(UserId);
return "STAMP-ONE";
}
return "STAMP-TWO";
});
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
public async Task A_missing_deleted_or_stampless_account_matches_nothing(string? stored)
{
StoredStamp(stored);
var service = NewService();
(await service.IsCurrentAsync(UserId, service.ClaimValueFor("STAMP-ONE"), CancellationToken.None)).Should().BeFalse();
(await service.IsCurrentAsync(UserId, "", CancellationToken.None)).Should().BeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
public async Task A_token_without_a_stamp_is_refused_without_a_lookup(string? claimValue)
{
StoredStamp("STAMP-ONE");
(await NewService().IsCurrentAsync(UserId, claimValue, CancellationToken.None)).Should().BeFalse();
_users.Verify(users => users.GetActiveSecurityStampAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
private sealed class SteppedTimeProvider : TimeProvider
{
private DateTimeOffset _now = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
}

View file

@ -29,7 +29,8 @@ public sealed class TeamMemberServiceTests
Mock.Of<ITeamPermissionOverrideDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<ITeamPermissionService>(),
Mock.Of<ITeamMemberInviteService>());
Mock.Of<ITeamMemberInviteService>(),
Mock.Of<ISessionStampService>());
var result = await service.CreateAsync(
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
@ -410,7 +411,8 @@ public sealed class TeamMemberServiceTests
overrides.Object,
userData.Object,
permissions.Object,
Mock.Of<ITeamMemberInviteService>());
Mock.Of<ITeamMemberInviteService>(),
Mock.Of<ISessionStampService>());
}
private static Mock<UserManager<ApplicationUser>> UserManager()

View file

@ -230,6 +230,11 @@ public sealed class UpliftWorkflowTests
.Returns(Task.CompletedTask);
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException());
upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync(
It.IsAny<int>(),
It.IsAny<Func<CancellationToken, Task<DispatchUpliftRequest>>>(),
It.IsAny<CancellationToken>()))
.Returns((int _, Func<CancellationToken, Task<DispatchUpliftRequest>> work, CancellationToken ct) => work(ct));
var service = NewPortalService(context, new FakeEmailSender(deliver: true), upliftData: upliftData.Object);
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
@ -1041,4 +1046,69 @@ public sealed class UpliftWorkflowTests
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
}
// --- A cancelled work order takes no new uplift request ---
[Theory]
[InlineData(true, "Canceled")]
[InlineData(false, "Cancelled")]
public async Task RequestUplift_OnCancelledWorkOrder_IsRefusedAndCreatesNothing(bool lifecycleCanceled, string statusText)
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
workOrder.Status = statusText;
if (lifecycleCanceled)
workOrder.LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled;
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 3500m, "reason", null, 1, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*cancelled work order*");
context.DispatchUpliftRequests.Should().BeEmpty();
}
[Fact]
public async Task RequestUplift_WaitsForAWorkOrderCancelInFlightAndIsThenRefused()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
using var context = new ApplicationDbContext(options);
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
// A work order cancel holding the work order's lock, committing only after the
// vendor's request has started.
var cancelEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseCancel = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var cancelContext = new ApplicationDbContext(options);
var cancel = new UpliftDataService(cancelContext).ExecuteWorkOrderMutationAsync(workOrder.Id, async ct =>
{
cancelEntered.SetResult();
await releaseCancel.Task;
var tracked = await cancelContext.workOrders.SingleAsync(w => w.Id == workOrder.Id, ct);
tracked.LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled;
await cancelContext.SaveChangesAsync(ct);
return true;
}, CancellationToken.None);
await cancelEntered.Task;
var request = service.RequestUpliftAsync(session!, dispatch.Id, 3500m, "reason", null, 1, CancellationToken.None);
var finishedFirst = await Task.WhenAny(request, Task.Delay(TimeSpan.FromSeconds(2)));
finishedFirst.Should().NotBeSameAs(request, "the request must wait for the cancel holding the work order lock");
releaseCancel.SetResult();
await cancel;
await FluentActions.Awaiting(() => request).Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*cancelled work order*");
using var verify = new ApplicationDbContext(options);
verify.DispatchUpliftRequests.Should().BeEmpty();
}
}

View file

@ -26,7 +26,8 @@ public class UserServiceTests
Mock<IUserDataService> userData,
Mock<IEmailSender> email,
out Mock<IUserRoleStore<ApplicationUser>> store,
Mock<IAccountDataService>? accounts = null)
Mock<IAccountDataService>? accounts = null,
Mock<ISessionStampService>? sessions = null)
{
var (manager, s, _) = IdentityTestHelpers.CreateUserManager();
store = s;
@ -34,7 +35,8 @@ public class UserServiceTests
manager,
userData.Object,
(accounts ?? new Mock<IAccountDataService>()).Object,
email.Object);
email.Object,
(sessions ?? new Mock<ISessionStampService>()).Object);
}
[Fact]
@ -429,4 +431,42 @@ public class UserServiceTests
&& password.Any(char.IsDigit)
&& password.Any(character => !char.IsLetterOrDigit(character));
}
[Theory]
[InlineData(2, "alice@example.com", "Dispatcher", true)]
[InlineData(1, "alice@example.com", "Scheduler", true)]
[InlineData(1, "alice.new@example.com", "Dispatcher", true)]
[InlineData(1, "ALICE@example.com", "dispatcher", false)]
public async Task EditUser_EndsEarlierSessionsOnlyWhenTheTokenClaimsChange(
int accountId, string email, string role, bool endsSessions)
{
var existing = IdentityTestHelpers.User("u1", userName: "alice@example.com");
existing.AccountId = 1;
var stampBefore = existing.SecurityStamp;
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(existing);
var accounts = new Mock<IAccountDataService>();
accounts.Setup(a => a.ExistsAsync(It.IsAny<int>())).ReturnsAsync(true);
var sessions = new Mock<ISessionStampService>();
var service = NewService(userData, new Mock<IEmailSender>(), out var store, accounts, sessions);
store.Setup(s => s.GetRolesAsync(existing, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "Dispatcher" });
var outcome = await service.EditUserAsync(
new EditUserRequestDTO { Id = "u1", Name = "Alice", Email = email, Role = role, AccountId = accountId },
Principal("Admin"),
CancellationToken.None);
outcome.Success.Should().BeTrue();
if (endsSessions)
{
existing.SecurityStamp.Should().NotBe(stampBefore);
sessions.Verify(s => s.Forget("u1"), Times.Once);
}
else
{
existing.SecurityStamp.Should().Be(stampBefore);
sessions.Verify(s => s.Forget(It.IsAny<string>()), Times.Never);
}
}
}

View file

@ -115,20 +115,19 @@ namespace Api.SeaHavenIndustries.Controllers
public const string ForgetPasswordMessage =
"If that email belongs to an account, a reset code has been sent to it.";
// Email and code are read from the JSON body so they stay out of URLs and proxy
// access logs; the query-string form is still accepted for older clients.
// Email and code are read only from the JSON body, so they never appear in URLs
// or in proxy and load balancer access logs.
[AllowAnonymous]
[HttpPost()]
[Route("ForgetPassword")]
[EnableRateLimiting(PasswordResetRateLimiting.ForgetPasswordPolicy)]
public async Task<IActionResult> ForgetPassword(
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ForgetPasswordRequest_Dto? body,
[FromQuery(Name = "Email")] string? email,
CancellationToken cancellationToken)
{
try
{
await _authenticationService.ForgetPasswordAsync(body?.Email ?? email, cancellationToken);
await _authenticationService.ForgetPasswordAsync(body?.Email, cancellationToken);
}
catch (Exception ex)
{
@ -146,13 +145,11 @@ namespace Api.SeaHavenIndustries.Controllers
[EnableRateLimiting(PasswordResetRateLimiting.VerificationCodePolicy)]
public async Task<IActionResult> VerificationCode(
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] VerificationCode_Dto? body,
[FromQuery] string? email,
[FromQuery] string? code,
CancellationToken cancellationToken)
{
try
{
if (await _authenticationService.VerifyCodeAsync(body?.Email ?? email, body?.Code ?? code, cancellationToken))
if (await _authenticationService.VerifyCodeAsync(body?.Email, body?.Code, cancellationToken))
{
return Ok(new Response { Status = "Success ", Message = "Code Matched" });

View file

@ -0,0 +1,129 @@
using System.Threading.Channels;
using Api.SeaHavenIndustries.Observability;
using SeaHaven.Services.Interfaces;
using Sentry;
namespace Api.SeaHavenIndustries.HostedServices
{
public static class PasswordResetEmailDelivery
{
/// <summary>
/// Registers the process-wide reset email queue and the background service that
/// drains it. Both must be singletons: the request and the sender share one channel.
/// </summary>
public static IServiceCollection AddPasswordResetEmailDelivery(this IServiceCollection services)
{
services.AddSingleton<PasswordResetEmailChannel>();
services.AddSingleton<IPasswordResetEmailQueue>(provider => provider.GetRequiredService<PasswordResetEmailChannel>());
services.AddHostedService<PasswordResetEmailSenderHostedService>();
return services;
}
}
public sealed record PasswordResetEmail(string EmailTo, string Subject, string Body);
public sealed class PasswordResetEmailChannel : IPasswordResetEmailQueue
{
public const int Capacity = 1000;
private readonly Channel<PasswordResetEmail> _channel = Channel.CreateBounded<PasswordResetEmail>(
new BoundedChannelOptions(Capacity)
{
// Wait, not DropWrite: with DropWrite, TryWrite reports success and discards
// the email, so a full queue would still count the request. TryWrite never
// blocks; under Wait it returns false when the queue is full.
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true
});
private readonly ILogger<PasswordResetEmailChannel> _logger;
private int _pending;
public PasswordResetEmailChannel(ILogger<PasswordResetEmailChannel> logger)
{
_logger = logger;
}
public ChannelReader<PasswordResetEmail> Reader => _channel.Reader;
/// <summary>Emails accepted and not yet handed to the mail provider.</summary>
public int Pending => Volatile.Read(ref _pending);
public bool TryEnqueue(string emailTo, string subject, string body)
{
Interlocked.Increment(ref _pending);
if (_channel.Writer.TryWrite(new PasswordResetEmail(emailTo, subject, body)))
return true;
Interlocked.Decrement(ref _pending);
_logger.LogWarning("Password reset email queue is full; an email was dropped.");
return false;
}
public void MarkHandled() => Interlocked.Decrement(ref _pending);
}
public sealed class PasswordResetEmailSenderHostedService : BackgroundService
{
private readonly PasswordResetEmailChannel _channel;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<PasswordResetEmailSenderHostedService> _logger;
private readonly IHub _sentryHub;
public PasswordResetEmailSenderHostedService(
PasswordResetEmailChannel channel,
IServiceScopeFactory scopeFactory,
ILogger<PasswordResetEmailSenderHostedService> logger,
IHub sentryHub)
{
_channel = channel;
_scopeFactory = scopeFactory;
_logger = logger;
_sentryHub = sentryHub;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var email in _channel.Reader.ReadAllAsync(stoppingToken))
{
using var transaction = SentryObservability.BeginBackgroundTransaction(
_sentryHub,
"auth.password-reset-email",
$"{nameof(PasswordResetEmailSenderHostedService)}.{nameof(SendAsync)}");
try
{
if (await SendAsync(email))
transaction.FinishOk();
else
transaction.FinishError(new InvalidOperationException("The mail provider did not accept the password reset email."));
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
transaction.FinishCancelled();
throw;
}
catch (Exception ex)
{
// The message can echo the recipient or the body, so only the type is logged.
_logger.LogError("Password reset email failed with {ExceptionType}.", ex.GetType().FullName);
transaction.FinishError(ex);
}
finally
{
_channel.MarkHandled();
}
}
}
/// <summary>True when the mail provider accepted the email.</summary>
private async Task<bool> SendAsync(PasswordResetEmail email)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
if (await sender.SendEmailAsync(email.EmailTo, email.Subject, email.Body))
return true;
_logger.LogWarning("Password reset email was not accepted by the mail provider.");
return false;
}
}
}

View file

@ -0,0 +1,67 @@
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Infrastructure
{
public static class JwtAuthenticationRegistration
{
/// <summary>
/// Registers bearer-token authentication as the default scheme. Program.cs and the
/// behavior tests both compose authentication through this method so the token
/// rules under test are the rules that run.
/// </summary>
public static IServiceCollection AddSeaHavenJwtAuthentication(this IServiceCollection services, IConfiguration configuration)
{
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.SaveToken = true;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = configuration["JWT:ValidAudience"],
ValidIssuer = configuration["JWT:ValidIssuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
configuration["JWT:Secret"]
?? throw new InvalidOperationException("JWT:Secret configuration is required")))
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = RejectEndedSessionAsync
};
});
return services;
}
/// <summary>
/// Refuses a correctly signed token whose session stamp no longer matches the
/// account: the password was reset or changed, or the account was deactivated or
/// deleted, after the token was issued. A token without a stamp is refused too.
/// </summary>
private static async Task RejectEndedSessionAsync(TokenValidatedContext context)
{
var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
var claimValue = context.Principal?.FindFirstValue(SeaHavenClaimTypes.SessionStamp);
var sessions = context.HttpContext.RequestServices.GetRequiredService<ISessionStampService>();
if (string.IsNullOrEmpty(userId)
|| !await sessions.IsCurrentAsync(userId, claimValue, context.HttpContext.RequestAborted))
{
// The handler logs this text; it names no account, token or stamp.
context.Fail("The session has ended.");
}
}
}
}

View file

@ -5,15 +5,12 @@ using Api.SeaHavenIndustries.Middleware;
using Api.SeaHavenIndustries.Observability;
using Api.SeaHavenIndustries.Options;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Sentry.AspNetCore;
using Sentry.Extensibility;
using System.Text;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Implementation;
@ -60,6 +57,7 @@ builder.Services.AddResponseCompression(opts =>
new[] { "application/octet-stream" });
});
builder.Services.AddPasswordResetRateLimiting();
builder.Services.AddPasswordResetEmailDelivery();
builder.Services.AddCors(option =>
option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
@ -144,27 +142,7 @@ builder.Services.AddOptions<SeaHaven.Services.Implementation.WorkOrderOpsHealthO
health.LegacySunsetDate = legacy.Value.SunsetDate;
});
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.SaveToken = true;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = configuration["JWT:ValidAudience"],
ValidIssuer = configuration["JWT:ValidIssuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
configuration["JWT:Secret"]
?? throw new InvalidOperationException("JWT:Secret configuration is required")))
};
});
builder.Services.AddSeaHavenJwtAuthentication(configuration);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{

View file

@ -0,0 +1,97 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
namespace SeaHaven.DataServices.Helpers
{
/// <summary>
/// The single rule and write for "a work order that becomes Canceled cancels its pending
/// uplifts in the same action". Every path that can cancel a work order (board update,
/// CRM webhook and reconciliation, legacy ingest) asks <see cref="Applies"/> and
/// stages the cancellation into its own unit of work, so the uplift change and its own
/// audit row commit, or roll back, with the work order's status change.
/// </summary>
public static class PendingUpliftCancellation
{
public const string AuditAction = "uplift_cancel";
public const string CancelledStatus = "Withdrawn";
private static readonly string[] PendingStatuses = { "Pending", "ChangesRequested" };
/// <summary>True when a lifecycle change moves a work order into Canceled.</summary>
public static bool Applies(LifecycleStatus? before, LifecycleStatus? after)
=> before != LifecycleStatus.Canceled && after == LifecycleStatus.Canceled;
/// <summary>
/// True when a status-text change moves a work order into Cancelled. The legacy ingest
/// sync writes only the status text, never the lifecycle status.
/// </summary>
public static bool AppliesToStatusText(string? before, string? after)
=> !IsCancelledText(before) && IsCancelledText(after);
/// <summary>
/// True when a work order is cancelled by either signal: its lifecycle status, or the
/// status text a sync cancel writes. No new uplift may be requested on it.
/// </summary>
public static bool IsCancelled(LifecycleStatus? lifecycle, string? statusText)
=> lifecycle == LifecycleStatus.Canceled || IsCancelledText(statusText);
private static bool IsCancelledText(string? status)
=> string.Equals(status, "Cancelled", StringComparison.OrdinalIgnoreCase)
|| string.Equals(status, "Canceled", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Uplift requests that belong to a work order: on a live dispatch that the work
/// order owns or is linked to.
/// </summary>
internal static IQueryable<DispatchUpliftRequest> ForWorkOrder(ApplicationDbContext context, int workOrderId)
=> context.DispatchUpliftRequests.Where(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == workOrderId
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)));
/// <summary>
/// Stages (does not save) the cancellation of every pending uplift on the work order,
/// with one audit row per uplift. Returns how many uplifts were cancelled.
/// </summary>
internal static async Task<int> StageAsync(
ApplicationDbContext context,
int workOrderId,
string? actorId,
string actorType,
DateTime now,
CancellationToken cancellationToken)
{
var pending = await ForWorkOrder(context, workOrderId)
.Include(u => u.Dispatch)
.Where(u => PendingStatuses.Contains(u.Status))
.ToListAsync(cancellationToken);
foreach (var request in pending)
{
var previous = request.Status;
request.Status = CancelledStatus;
request.DecidedAt = now;
request.DecidedByUserId = actorId;
request.LastModificationTime = now;
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
UserId = actorId,
FieldName = $"Dispatch {request.Dispatch!.DispatchNumber} Uplift",
OldValue = previous,
NewValue = CancelledStatus,
Action = AuditAction,
ActorType = actorType,
CreatedAt = now,
});
}
return pending.Count;
}
}
}

View file

@ -0,0 +1,102 @@
using System.Collections.Concurrent;
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
namespace SeaHaven.DataServices.Helpers
{
/// <summary>
/// The per-work-order gate that serializes work-order-scoped invariants (uplift create,
/// decide, revoke, and cancelling a work order's pending uplifts): an in-process gate per
/// work order, a transaction, and an update lock on the work order row on SQL Server.
/// Every caller shares the same gates, so a cancel and a create on one work order never
/// interleave.
/// </summary>
public static class WorkOrderMutationLock
{
private static readonly ConcurrentDictionary<int, SemaphoreSlim> Gates = new();
/// <summary>
/// Runs <paramref name="work"/> under the work order's gate and row lock. The
/// transaction commits when the work returns and <paramref name="commitWhen"/> (when
/// given) accepts its result; otherwise, or when the work throws, it rolls back.
/// </summary>
public static Task<T> RunAsync<T>(
ApplicationDbContext context,
int workOrderId,
Func<CancellationToken, Task<T>> work,
CancellationToken cancellationToken,
Func<T, bool>? commitWhen = null)
=> RunManyAsync(context, new[] { workOrderId }, work, cancellationToken, commitWhen);
/// <summary>
/// <see cref="RunAsync{T}"/> for a unit of work that spans several work orders (a sync
/// batch). Gates and row locks are taken in ascending id order, so two batches never
/// wait on each other in a cycle; an empty set runs the work in a plain transaction.
/// </summary>
public static async Task<T> RunManyAsync<T>(
ApplicationDbContext context,
IEnumerable<int> workOrderIds,
Func<CancellationToken, Task<T>> work,
CancellationToken cancellationToken,
Func<T, bool>? commitWhen = null)
{
var ids = workOrderIds.Distinct().OrderBy(id => id).ToList();
var held = new List<SemaphoreSlim>(ids.Count);
try
{
foreach (var id in ids)
{
var gate = Gates.GetOrAdd(id, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken);
held.Add(gate);
}
await using var transaction = context.Database.IsRelational()
? await context.Database.BeginTransactionAsync(cancellationToken)
: null;
try
{
foreach (var id in ids)
await LockRowAsync(context, id, cancellationToken);
var result = await work(cancellationToken);
if (transaction is not null)
{
if (commitWhen is null || commitWhen(result))
await transaction.CommitAsync(cancellationToken);
else
await transaction.RollbackAsync(cancellationToken);
}
return result;
}
catch
{
if (transaction is not null)
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
finally
{
foreach (var gate in held)
gate.Release();
}
}
private static async Task LockRowAsync(
ApplicationDbContext context,
int workOrderId,
CancellationToken cancellationToken)
{
if (context.Database.ProviderName?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) != true)
return;
await context.workOrders
.FromSqlRaw(
"SELECT * FROM [workOrders] WITH (UPDLOCK, ROWLOCK, HOLDLOCK) WHERE [Id] = {0}",
workOrderId)
.Select(workOrder => workOrder.Id)
.FirstOrDefaultAsync(cancellationToken);
}
}
}

View file

@ -13,14 +13,17 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public async Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken)
public async Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, DateTime nowUtc, CancellationToken cancellationToken)
{
var normalizedEmail = Normalize(email);
var existing = await _context.ForgetPasswordCodes
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
.ToListAsync(cancellationToken);
_context.ForgetPasswordCodes.RemoveRange(existing);
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
// Every request also clears expired codes of any email, which keeps the
// table bounded and gives each request the same database work.
await _context.ForgetPasswordCodes
.Where(u => u.Email.ToLower().Trim() == normalizedEmail || u.ExpiresAtUtc <= nowUtc)
.ExecuteDeleteAsync(cancellationToken);
_context.ForgetPasswordCodes.Add(new ForgetPasswordCode
{
@ -33,6 +36,14 @@ namespace SeaHaven.DataServices.Implementation
FailedAttempts = 0
});
await _context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
public async Task PurgeExpiredAsync(DateTime nowUtc, CancellationToken cancellationToken)
{
await _context.ForgetPasswordCodes
.Where(u => u.ExpiresAtUtc <= nowUtc)
.ExecuteDeleteAsync(cancellationToken);
}
public async Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken)

View file

@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
@ -10,8 +9,6 @@ namespace SeaHaven.DataServices.Implementation
{
public class UpliftDataService : IUpliftDataService
{
private static readonly ConcurrentDictionary<int, SemaphoreSlim> WorkOrderGates = new();
// The Rejected queue also surfaces the legacy "Denied" spelling, which reads as Rejected.
private static readonly string[] RejectedStatuses = { "Rejected", "Denied" };
private readonly ApplicationDbContext _context;
@ -455,14 +452,28 @@ namespace SeaHaven.DataServices.Implementation
.SumAsync(cancellationToken);
}
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
public Task<int> StageCancelPendingForWorkOrderAsync(
int workOrderId,
string? actorId,
DateTime now,
CancellationToken cancellationToken)
=> PendingUpliftCancellation.StageAsync(
_context,
workOrderId,
actorId,
actorType: "internal",
now,
cancellationToken);
public async Task<bool> IsWorkOrderCancelledAsync(int workOrderId, CancellationToken cancellationToken)
{
return ForWorkOrder(workOrderId)
.Include(u => u.Dispatch)
.Where(u => u.Status == "Pending" || u.Status == "ChangesRequested")
.ToListAsync(cancellationToken);
var workOrder = await _context.workOrders
.AsNoTracking()
.Where(w => w.Id == workOrderId)
.Select(w => new { w.LifecycleStatus, w.Status })
.FirstOrDefaultAsync(cancellationToken);
return workOrder is not null
&& PendingUpliftCancellation.IsCancelled(workOrder.LifecycleStatus, workOrder.Status);
}
public async Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
@ -524,15 +535,7 @@ namespace SeaHaven.DataServices.Implementation
}
private IQueryable<DispatchUpliftRequest> ForWorkOrder(int workOrderId)
{
return _context.DispatchUpliftRequests.Where(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == workOrderId
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)));
}
=> PendingUpliftCancellation.ForWorkOrder(_context, workOrderId);
public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken)
{
@ -595,50 +598,10 @@ namespace SeaHaven.DataServices.Implementation
return message.Contains(activeDispatchIndex, StringComparison.OrdinalIgnoreCase);
}
public async Task<T> ExecuteWorkOrderMutationAsync<T>(
public Task<T> ExecuteWorkOrderMutationAsync<T>(
int workOrderId,
Func<CancellationToken, Task<T>> work,
CancellationToken cancellationToken)
{
var gate = WorkOrderGates.GetOrAdd(workOrderId, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken);
try
{
await using var transaction = _context.Database.IsRelational()
? await _context.Database.BeginTransactionAsync(cancellationToken)
: null;
try
{
await LockWorkOrderRowAsync(workOrderId, cancellationToken);
var result = await work(cancellationToken);
if (transaction is not null)
await transaction.CommitAsync(cancellationToken);
return result;
}
catch
{
if (transaction is not null)
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
finally
{
gate.Release();
}
}
private async Task LockWorkOrderRowAsync(int workOrderId, CancellationToken cancellationToken)
{
if (_context.Database.ProviderName?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) != true)
return;
await _context.workOrders
.FromSqlRaw(
"SELECT * FROM [workOrders] WITH (UPDLOCK, ROWLOCK, HOLDLOCK) WHERE [Id] = {0}",
workOrderId)
.Select(workOrder => workOrder.Id)
.FirstOrDefaultAsync(cancellationToken);
}
=> WorkOrderMutationLock.RunAsync(_context, workOrderId, work, cancellationToken);
}
}

View file

@ -189,6 +189,15 @@ namespace SeaHaven.DataServices.Implementation
}
}
public async Task<string?> GetActiveSecurityStampAsync(string userId, CancellationToken cancellationToken)
{
return await _context.Users
.AsNoTracking()
.Where(user => user.Id == userId && user.IsDeleted != true)
.Select(user => user.SecurityStamp)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<string?> GetEmailByIdAsync(
string userId, CancellationToken cancellationToken)
{

View file

@ -1,5 +1,7 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -31,25 +33,37 @@ namespace SeaHaven.DataServices.Implementation
await _context.SaveChangesAsync(cancellationToken);
}
public async Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken)
{
await using var transaction = _context.Database.IsRelational()
? await _context.Database.BeginTransactionAsync(cancellationToken)
: null;
public Task<int> StageCancelPendingUpliftsAsync(int workOrderId, CancellationToken cancellationToken)
=> PendingUpliftCancellation.StageAsync(
_context,
workOrderId,
actorId: null,
actorType: AuditActorType.Sync.ToString(),
DateTime.UtcNow,
cancellationToken);
try
{
await work(cancellationToken);
if (transaction is not null)
await transaction.CommitAsync(cancellationToken);
}
catch
{
if (transaction is not null)
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
public async Task<IReadOnlyList<int>> GetWorkOrderIdsByExternalIdsAsync(
IReadOnlyCollection<string> externalWorkOrderIds,
CancellationToken cancellationToken)
=> await _context.workOrders
.AsNoTracking()
.Where(w => w.ExternalWorkOrderId != null && externalWorkOrderIds.Contains(w.ExternalWorkOrderId))
.Select(w => w.Id)
.ToListAsync(cancellationToken);
public Task ExecuteTransactionalAsync(
IReadOnlyCollection<int> lockedWorkOrderIds,
Func<CancellationToken, Task> work,
CancellationToken cancellationToken)
=> WorkOrderMutationLock.RunManyAsync(
_context,
lockedWorkOrderIds,
async ct =>
{
await work(ct);
return true;
},
cancellationToken);
public Task SaveAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken);

View file

@ -1,6 +1,8 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -18,6 +20,32 @@ namespace SeaHaven.DataServices.Implementation
public async Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
WorkOrderWebhookMutation mutation,
CancellationToken cancellationToken)
{
// A mutation that cancels an existing work order also cancels its pending uplifts,
// so it runs under the same per-work-order lock as uplift creation: a create in
// flight either commits first (and is cancelled here) or sees the cancelled order.
var current = mutation.IsStateEvent && mutation.LifecycleStatus.HasValue
? await _context.workOrders
.AsNoTracking()
.Where(w => w.ExternalWorkOrderId == mutation.ExternalWorkOrderId)
.Select(w => new { w.Id, w.LifecycleStatus })
.SingleOrDefaultAsync(cancellationToken)
: null;
if (current is null || !PendingUpliftCancellation.Applies(current.LifecycleStatus, mutation.LifecycleStatus))
return await ApplyUnlockedAsync(mutation, cancellationToken);
return await WorkOrderMutationLock.RunAsync(
_context,
current.Id,
ct => ApplyUnlockedAsync(mutation, ct),
cancellationToken,
commitWhen: result => result.Status == WorkOrderWebhookPersistenceStatus.Applied);
}
private async Task<WorkOrderWebhookPersistenceResult> ApplyUnlockedAsync(
WorkOrderWebhookMutation mutation,
CancellationToken cancellationToken)
{
var prior = await _context.WorkOrderWebhookDeliveries
.AsNoTracking()
@ -112,8 +140,22 @@ namespace SeaHaven.DataServices.Implementation
workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description;
workOrder.Description = mutation.Description;
workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status;
var lifecycleBefore = workOrder.LifecycleStatus;
if (mutation.LifecycleStatus.HasValue)
workOrder.LifecycleStatus = mutation.LifecycleStatus.Value;
// A CRM cancellation cancels the work order's pending uplifts in this same save.
if (workOrder.Id > 0
&& PendingUpliftCancellation.Applies(lifecycleBefore, workOrder.LifecycleStatus))
{
await PendingUpliftCancellation.StageAsync(
_context,
workOrder.Id,
actorId: null,
actorType: AuditActorType.Sync.ToString(),
mutation.ProcessedAt.UtcDateTime,
cancellationToken);
}
workOrder.Severity = mutation.Severity;
workOrder.Priority = mutation.Priority;
workOrder.ExternalAssignedTo = mutation.AssignedTo;

View file

@ -4,8 +4,13 @@ namespace SeaHaven.DataServices.Interfaces
{
public interface IForgetPasswordDataService
{
/// <summary>Deletes every pending code for the email, then stores the new one.</summary>
Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken);
/// <summary>
/// In one transaction, deletes every pending code for the email and every expired
/// code, then stores the new one.
/// </summary>
Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, DateTime nowUtc, CancellationToken cancellationToken);
Task PurgeExpiredAsync(DateTime nowUtc, CancellationToken cancellationToken);
/// <summary>Returns the pending code for exactly this email, or null.</summary>
Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken);

View file

@ -32,7 +32,16 @@ namespace SeaHaven.DataServices.Interfaces
// SH-207: queue-wide pending exposure for the approvals header (sum of
// RequestedNTE over non-deleted Pending requests on non-deleted dispatches).
Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken);
Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
// Stages (does not save) the cancellation of the work order's pending uplifts, each
// with its own audit row, for the caller's unit of work. See PendingUpliftCancellation.
Task<int> StageCancelPendingForWorkOrderAsync(
int workOrderId,
string? actorId,
DateTime now,
CancellationToken cancellationToken);
// True when the work order is cancelled (PendingUpliftCancellation.IsCancelled), so no
// new uplift may be requested on it.
Task<bool> IsWorkOrderCancelledAsync(int workOrderId, CancellationToken cancellationToken);
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);

View file

@ -20,6 +20,9 @@ namespace SeaHaven.DataServices.Interfaces
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> callback, CancellationToken cancellationToken);
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
/// <summary>The security stamp of an account that exists and is not deleted; otherwise null.</summary>
Task<string?> GetActiveSecurityStampAsync(string userId, CancellationToken cancellationToken);
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
}
}

View file

@ -9,7 +9,18 @@ namespace SeaHaven.DataServices.Interfaces
void TrackWorkOrder(WorkOrder workOrder);
Task<Locations?> FindLocationAsync(string name, CancellationToken cancellationToken);
Task AddAndSaveLocationAsync(Locations location, CancellationToken cancellationToken);
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken);
// Stages (does not save) the sync-attributed cancellation of the work order's pending
// uplifts, each with its own audit row. See PendingUpliftCancellation.
Task<int> StageCancelPendingUpliftsAsync(int workOrderId, CancellationToken cancellationToken);
Task<IReadOnlyList<int>> GetWorkOrderIdsByExternalIdsAsync(
IReadOnlyCollection<string> externalWorkOrderIds,
CancellationToken cancellationToken);
// Runs the batch in one transaction, holding the per-work-order lock on
// lockedWorkOrderIds (see WorkOrderMutationLock) until it commits or rolls back.
Task ExecuteTransactionalAsync(
IReadOnlyCollection<int> lockedWorkOrderIds,
Func<CancellationToken, Task> work,
CancellationToken cancellationToken);
Task SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Reflection;
@ -15,6 +16,7 @@ namespace SeaHaven.Services.DependencyInjection
public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
{
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<ISessionStampCache, InMemorySessionStampCache>();
services.Configure<FrontendOptions>(configuration);
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<ApprovalsOptions>(configuration.GetSection(ApprovalsOptions.SectionName));
@ -75,6 +77,9 @@ namespace SeaHaven.Services.DependencyInjection
services.AddValidatorsFromAssembly(assembly);
// Process-wide counters: a scoped instance would start empty on every request.
services.AddSingleton<IPasswordResetThrottle, Helpers.InMemoryPasswordResetThrottle>();
services.AddScoped<IWorkOrderReconciliationRunner>(
sp => (IWorkOrderReconciliationRunner)sp.GetRequiredService<IWorkOrderReconciliationService>());

View file

@ -0,0 +1,130 @@
using System.Security.Cryptography;
using System.Text;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Process-wide sliding-window counters for <see cref="IPasswordResetThrottle"/>.
/// Registered as a singleton; the API runs as a single instance, and a restart
/// clears the windows. Emails are held only as SHA-256 digests.
/// </summary>
public sealed class InMemoryPasswordResetThrottle : IPasswordResetThrottle
{
public const int CodeRequestsPerHour = 3;
public const int CodeRequestsPerDay = 10;
public const int FailedChecksPerDay = 10;
private static readonly TimeSpan Hour = TimeSpan.FromHours(1);
private static readonly TimeSpan Day = TimeSpan.FromDays(1);
private const int SweepEvery = 1024;
private readonly TimeProvider _timeProvider;
private readonly object _gate = new();
private readonly Dictionary<string, Account> _accounts = new(StringComparer.Ordinal);
private int _operations;
public InMemoryPasswordResetThrottle(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public bool TryAcceptCodeRequest(string email)
{
var now = _timeProvider.GetUtcNow();
lock (_gate)
{
var account = AccountFor(email, now);
if (account.Requests.Count >= CodeRequestsPerDay
|| account.Requests.Count(at => at > now - Hour) >= CodeRequestsPerHour)
{
return false;
}
account.Requests.Add(now);
return true;
}
}
public void ReleaseCodeRequest(string email)
{
var now = _timeProvider.GetUtcNow();
lock (_gate)
{
var account = AccountFor(email, now);
if (account.Requests.Count > 0)
account.Requests.RemoveAt(account.Requests.Count - 1);
}
}
public bool TryReserveCheck(string email)
{
var now = _timeProvider.GetUtcNow();
lock (_gate)
{
var account = AccountFor(email, now);
if (account.FailedChecks.Count >= FailedChecksPerDay)
return false;
account.FailedChecks.Add(now);
return true;
}
}
public void ReleaseCheck(string email)
{
var now = _timeProvider.GetUtcNow();
lock (_gate)
{
var account = AccountFor(email, now);
if (account.FailedChecks.Count > 0)
account.FailedChecks.RemoveAt(account.FailedChecks.Count - 1);
}
}
/// <summary>The same normalization the user lookup applies: trimmed, invariant upper case.</summary>
public static string KeyFor(string email)
{
var normalized = (email ?? string.Empty).Trim().ToUpperInvariant();
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
}
private Account AccountFor(string email, DateTimeOffset now)
{
if (++_operations % SweepEvery == 0)
Sweep(now);
var key = KeyFor(email);
if (!_accounts.TryGetValue(key, out var account))
{
account = new Account();
_accounts[key] = account;
}
account.Prune(now - Day);
return account;
}
private void Sweep(DateTimeOffset now)
{
foreach (var (key, account) in _accounts.ToList())
{
account.Prune(now - Day);
if (account.Requests.Count == 0 && account.FailedChecks.Count == 0)
_accounts.Remove(key);
}
}
private sealed class Account
{
public List<DateTimeOffset> Requests { get; } = new();
public List<DateTimeOffset> FailedChecks { get; } = new();
public void Prune(DateTimeOffset cutoff)
{
Requests.RemoveAll(at => at <= cutoff);
FailedChecks.RemoveAll(at => at <= cutoff);
}
}
}
}

View file

@ -6,10 +6,24 @@ namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Generation and hashing for emailed password reset codes. The raw code exists
/// only in memory and in the email sent to the account holder.
/// only in memory and in the email sent to the account holder. Hashes are keyed
/// with a server-side key, so a copy of the database alone cannot be used to
/// brute-force the six-digit codes offline.
/// </summary>
public static class PasswordResetCodeSecrets
{
private static readonly byte[] KeyInfo = Encoding.UTF8.GetBytes("password-reset-code-v1");
/// <summary>
/// Derives the code-hashing key from an existing server secret with HKDF, so no
/// new secret is needed and the derived key is useless for anything else.
/// </summary>
public static byte[] DeriveKey(string serverSecret)
{
ArgumentException.ThrowIfNullOrEmpty(serverSecret);
return HKDF.DeriveKey(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(serverSecret), 32, Array.Empty<byte>(), KeyInfo);
}
public static string NewCode()
{
return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture);
@ -20,19 +34,26 @@ namespace SeaHaven.Services.Helpers
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
}
public static string Hash(string salt, string code)
/// <summary>A value shaped like a hash that no code can match.</summary>
public static string NewUnmatchableHash()
{
ArgumentNullException.ThrowIfNull(salt);
ArgumentNullException.ThrowIfNull(code);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant();
return Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
}
public static bool Matches(string salt, string candidate, string expectedHash)
public static string Hash(byte[] key, string salt, string code)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(salt);
ArgumentNullException.ThrowIfNull(code);
return Convert.ToHexString(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant();
}
public static bool Matches(byte[] key, string salt, string candidate, string expectedHash)
{
if (string.IsNullOrEmpty(salt) || string.IsNullOrEmpty(expectedHash))
return false;
var actual = Encoding.ASCII.GetBytes(Hash(salt, candidate.Trim()));
var actual = Encoding.ASCII.GetBytes(Hash(key, salt, candidate.Trim()));
var expected = Encoding.ASCII.GetBytes(expectedHash);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}

View file

@ -11,6 +11,12 @@ namespace SeaHaven.Services.Helpers
/// <summary>Signed org-wide elevation (Admin without AccountId).</summary>
public const string OrgScopeAll = "all";
/// <summary>
/// A keyed hash of the account's security stamp at sign-in. Never the stamp
/// itself: the token is readable by whoever holds it.
/// </summary>
public const string SessionStamp = "session_stamp";
}
/// <summary>Resolved media tenant scope from signed claims (fail-closed when Missing).</summary>

View file

@ -19,28 +19,40 @@ namespace SeaHaven.Services.Implementation
private readonly JwtOptions _jwtOptions;
private readonly IUserDataService _userDataService;
private readonly IForgetPasswordDataService _forgetPasswordDataService;
private readonly IEmailSender _emailSender;
private readonly IPasswordResetEmailQueue _resetEmails;
private readonly IPasswordResetThrottle _resetThrottle;
private readonly TimeProvider _timeProvider;
private readonly ISessionStampService _sessionStamps;
private byte[]? _resetCodeKey;
public static readonly TimeSpan ResetCodeLifetime = TimeSpan.FromMinutes(15);
public const int MaxCodeAttempts = 5;
/// <summary>Longest address stored for a reset request; Identity caps emails at 256.</summary>
public const int MaxResetEmailLength = 256;
public AuthenticationService(
UserManager<ApplicationUser> userManager,
IOptions<JwtOptions> jwtOptions,
IUserDataService userDataService,
IForgetPasswordDataService forgetPasswordDataService,
IEmailSender emailSender,
TimeProvider timeProvider)
IPasswordResetEmailQueue resetEmails,
IPasswordResetThrottle resetThrottle,
TimeProvider timeProvider,
ISessionStampService sessionStamps)
{
_userManager = userManager;
_jwtOptions = jwtOptions.Value;
_userDataService = userDataService;
_forgetPasswordDataService = forgetPasswordDataService;
_emailSender = emailSender;
_resetEmails = resetEmails;
_resetThrottle = resetThrottle;
_timeProvider = timeProvider;
_sessionStamps = sessionStamps;
}
private byte[] ResetCodeKey => _resetCodeKey ??= PasswordResetCodeSecrets.DeriveKey(_jwtOptions.Secret);
public async Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken)
{
var user = await _userManager.FindByNameAsync(username ?? "");
@ -55,12 +67,22 @@ namespace SeaHaven.Services.Implementation
ArgumentNullException.ThrowIfNull(user);
cancellationToken.ThrowIfCancellationRequested();
// Every request checks the token's stamp against the account's, so an account
// without one would get a token that never works.
if (string.IsNullOrEmpty(user.SecurityStamp))
{
var stamped = await _userManager.UpdateSecurityStampAsync(user);
if (!stamped.Succeeded)
throw new InvalidOperationException("The account's security stamp could not be set.");
}
var userRoles = await _userManager.GetRolesAsync(user);
var authClaims = new List<Claim>
{
new Claim(ClaimTypes.Name, user.UserName ?? ""),
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(SeaHavenClaimTypes.SessionStamp, _sessionStamps.ClaimValueFor(user.SecurityStamp!))
};
foreach (var userRole in userRoles)
{
@ -104,9 +126,13 @@ namespace SeaHaven.Services.Implementation
if (!await _userManager.CheckPasswordAsync(user, currentPassword ?? ""))
return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect);
// A changed password rotates the security stamp, which ends every earlier session.
var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", newPassword ?? "");
if (result.Succeeded)
{
_sessionStamps.Forget(user.Id);
return ChangePasswordResult(ChangePasswordStatus.Succeeded);
}
return ChangePasswordResult(IdentityPasswordPolicy.IsPolicyRejection(result)
? ChangePasswordStatus.PasswordRejected
@ -139,26 +165,58 @@ namespace SeaHaven.Services.Implementation
public async Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken)
{
// Registered and unregistered addresses take the same path up to the
// email send: one user lookup, one code generated and hashed, one write.
// Registered and unregistered addresses do the same work: one user lookup,
// one code generated and hashed, and the same replace in the database. An
// unregistered address gets a row no code can match. The email itself is
// queued, so the response never waits on the mail provider.
var requested = email?.Trim() ?? string.Empty;
var user = requested.Length == 0
? null
: await _userDataService.GetByEmailNormalizedAsync(requested, cancellationToken);
var code = PasswordResetCodeSecrets.NewCode();
var salt = PasswordResetCodeSecrets.NewSalt();
var hash = PasswordResetCodeSecrets.Hash(salt, code);
if (requested.Length == 0 || requested.Length > MaxResetEmailLength)
return;
if (user == null || user.IsDeleted == true || string.IsNullOrWhiteSpace(user.Email))
var user = await _userDataService.GetByEmailNormalizedAsync(requested, cancellationToken);
var nowUtc = _timeProvider.GetUtcNow().UtcDateTime;
if (!_resetThrottle.TryAcceptCodeRequest(requested))
{
await _forgetPasswordDataService.RemoveByEmailAsync(requested, cancellationToken);
// Over the per-email limit: keep the current code and send nothing.
await _forgetPasswordDataService.PurgeExpiredAsync(nowUtc, cancellationToken);
return;
}
var expiresAtUtc = _timeProvider.GetUtcNow().UtcDateTime.Add(ResetCodeLifetime);
await _forgetPasswordDataService.ReplaceCodeAsync(user.Email, user.Id, hash, salt, expiresAtUtc, cancellationToken);
var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes.";
await _emailSender.SendEmailAsync(user.Email, "Forget Password Request.", body);
var code = PasswordResetCodeSecrets.NewCode();
var salt = PasswordResetCodeSecrets.NewSalt();
var hash = PasswordResetCodeSecrets.Hash(ResetCodeKey, salt, code);
var active = user != null && user.IsDeleted != true && !string.IsNullOrWhiteSpace(user.Email);
// The email is queued only after its code is stored, so a failed or cancelled
// write never sends a code that cannot be used. The request stays counted only
// when the row is stored and, for an account, its email was queued; when the
// queue is full the stored code is left unsent and the user can ask again.
var counted = false;
try
{
await _forgetPasswordDataService.ReplaceCodeAsync(
active ? user!.Email! : requested,
active ? user!.Id : string.Empty,
active ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(),
salt,
nowUtc.Add(ResetCodeLifetime),
nowUtc,
cancellationToken);
var queued = false;
if (active)
{
var body = $"Your Password Reset Code is: {code}. It expires in {(int)ResetCodeLifetime.TotalMinutes} minutes.";
queued = _resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body);
}
counted = queued || !active;
}
finally
{
if (!counted)
_resetThrottle.ReleaseCodeRequest(requested);
}
}
public async Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken)
@ -189,6 +247,7 @@ namespace SeaHaven.Services.Implementation
}
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
// A reset rotates the security stamp, which ends every earlier session.
var result = await _userManager.ResetPasswordAsync(user, token, password);
if (!result.Succeeded)
{
@ -198,6 +257,7 @@ namespace SeaHaven.Services.Implementation
return false;
}
_sessionStamps.Forget(user.Id);
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
return true;
}
@ -212,25 +272,43 @@ namespace SeaHaven.Services.Implementation
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(code))
return null;
var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
if (pending == null)
// The per-account budget spans every code the account is sent, so asking for
// new codes does not buy more guesses. A slot is reserved before comparing and
// given back when the code matches or there is no live code to guess at.
if (!_resetThrottle.TryReserveCheck(email))
return null;
var nowUtc = _timeProvider.GetUtcNow().UtcDateTime;
// Deletes below stop at this code's id: a code issued by a concurrent request
// after this one was read belongs to that request and must survive.
if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken))
// Only a wrong code actually compared keeps the slot. No live code, an expired or
// used-up code, a match, and a failed or cancelled call all give it back.
var wrongGuess = false;
try
{
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
var pending = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
if (pending == null)
return null;
var nowUtc = _timeProvider.GetUtcNow().UtcDateTime;
// Deletes below stop at this code's id: a code issued by a concurrent request
// after this one was read belongs to that request and must survive.
if (!await _forgetPasswordDataService.TryConsumeAttemptAsync(pending.Id, MaxCodeAttempts, nowUtc, cancellationToken))
{
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return null;
}
if (PasswordResetCodeSecrets.Matches(ResetCodeKey, pending.CodeSalt, code, pending.CodeHash))
return pending;
wrongGuess = true;
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return null;
}
if (PasswordResetCodeSecrets.Matches(pending.CodeSalt, code, pending.CodeHash))
return pending;
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
return null;
finally
{
if (!wrongGuess)
_resetThrottle.ReleaseCheck(email);
}
}
private JwtSecurityToken GetToken(List<Claim> authClaims)

View file

@ -0,0 +1,109 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class SessionStampService : ISessionStampService
{
private readonly IUserDataService _userDataService;
private readonly ISessionStampCache _cache;
private readonly byte[] _key;
public SessionStampService(
IUserDataService userDataService,
ISessionStampCache cache,
IOptions<JwtOptions> jwtOptions)
{
_userDataService = userDataService;
_cache = cache;
_key = HKDF.DeriveKey(
HashAlgorithmName.SHA256,
Encoding.UTF8.GetBytes(jwtOptions.Value.Secret),
32,
info: Encoding.UTF8.GetBytes("session-stamp-v1"));
}
public string ClaimValueFor(string securityStamp)
{
ArgumentException.ThrowIfNullOrEmpty(securityStamp);
return Base64UrlEncoder.Encode(HMACSHA256.HashData(_key, Encoding.UTF8.GetBytes(securityStamp)));
}
public async Task<bool> IsCurrentAsync(string userId, string? claimValue, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(claimValue))
return false;
if (!_cache.TryGet(userId, out var expected))
{
var generation = _cache.Generation;
var stamp = await _userDataService.GetActiveSecurityStampAsync(userId, cancellationToken);
// A missing, deleted or stampless account has no current value: nothing matches it.
expected = string.IsNullOrEmpty(stamp) ? null : ClaimValueFor(stamp);
_cache.Set(userId, expected, generation);
}
return expected != null && CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(claimValue));
}
public void Forget(string userId) => _cache.Remove(userId);
}
/// <summary>
/// Holds each expected value for <see cref="Lifetime"/>, so a change saved by another
/// instance is enforced here within that time; a change saved by this instance is
/// enforced at once through <see cref="Remove"/>.
/// </summary>
public sealed class InMemorySessionStampCache : ISessionStampCache
{
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(60);
private readonly ConcurrentDictionary<string, Entry> _entries = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
private long _generation;
public InMemorySessionStampCache(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public long Generation => Interlocked.Read(ref _generation);
public bool TryGet(string userId, out string? expected)
{
if (_entries.TryGetValue(userId, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow())
{
expected = entry.Expected;
return true;
}
expected = null;
return false;
}
public void Set(string userId, string? expected, long generation)
{
var entry = new Entry(expected, _timeProvider.GetUtcNow().Add(Lifetime));
_entries[userId] = entry;
// A removal since the read may have raced it: drop the value rather than keep it.
if (Generation != generation)
_entries.TryRemove(new KeyValuePair<string, Entry>(userId, entry));
}
public void Remove(string userId)
{
Interlocked.Increment(ref _generation);
_entries.TryRemove(userId, out _);
}
private sealed record Entry(string? Expected, DateTimeOffset ExpiresAt);
}
}

View file

@ -21,6 +21,7 @@ public sealed class TeamMemberService : ITeamMemberService
private readonly IUserDataService _userDataService;
private readonly ITeamPermissionService _permissionService;
private readonly ITeamMemberInviteService _inviteService;
private readonly ISessionStampService _sessionStamps;
public TeamMemberService(
UserManager<ApplicationUser> userManager,
@ -29,8 +30,10 @@ public sealed class TeamMemberService : ITeamMemberService
ITeamPermissionOverrideDataService permissionDataService,
IUserDataService userDataService,
ITeamPermissionService permissionService,
ITeamMemberInviteService inviteService)
ITeamMemberInviteService inviteService,
ISessionStampService sessionStamps)
{
_sessionStamps = sessionStamps;
_userManager = userManager;
_roleManager = roleManager;
_areaDataService = areaDataService;
@ -197,6 +200,8 @@ public sealed class TeamMemberService : ITeamMemberService
user.PhoneNumber = candidate.Phone?.Trim();
user.Color = color;
user.UniqueName = request.IsActive ? ActiveStatus : "Inactive";
var activeChanged = request.IsActive == (user.IsDeleted == true);
var deactivated = activeChanged && !request.IsActive;
user.IsDeleted = !request.IsActive;
var emailChanged = !string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase);
@ -229,7 +234,15 @@ public sealed class TeamMemberService : ITeamMemberService
return OperationFailure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to update role.");
}
// A new stamp ends the member's earlier sessions: a deactivated member's stay
// ended if the member is reactivated later, and a member whose role changed
// signs in again to get a token with the new role.
if (deactivated || roleChanged)
user.SecurityStamp = Guid.NewGuid().ToString("N");
await _userDataService.UpdateUserAsync(user, cancellationToken);
if (activeChanged || emailChanged || roleChanged)
_sessionStamps.Forget(user.Id);
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
if (roleChanged || request.PermissionOverrides is not null)
{

View file

@ -15,17 +15,20 @@ namespace SeaHaven.Services.Implementation
private readonly IUserDataService _userDataService;
private readonly IAccountDataService _accountDataService;
private readonly IEmailSender _emailSender;
private readonly ISessionStampService _sessionStamps;
public UserService(
UserManager<ApplicationUser> userManager,
IUserDataService userDataService,
IAccountDataService accountDataService,
IEmailSender emailSender)
IEmailSender emailSender,
ISessionStampService sessionStamps)
{
_userManager = userManager;
_userDataService = userDataService;
_accountDataService = accountDataService;
_emailSender = emailSender;
_sessionStamps = sessionStamps;
}
public async Task<IEnumerable<UserListRowDTO>> GetUsersAsync(CancellationToken cancellationToken)
@ -103,6 +106,10 @@ namespace SeaHaven.Services.Implementation
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
var existingRole = await _userManager.GetRolesAsync(exist1);
var claimsChanged = exist1.AccountId != dto.AccountId
|| existingRole == null
|| existingRole.Count != 1
|| !string.Equals(existingRole[0], dto.Role, StringComparison.OrdinalIgnoreCase);
if (existingRole != null && existingRole.Any() && existingRole.FirstOrDefault() != exist1.PhoneNumber)
{
@ -114,11 +121,15 @@ namespace SeaHaven.Services.Implementation
exist1.Contact = model.Contact;
exist1.PhoneNumber = model.PhoneNumber;
exist1.AccountId = dto.AccountId;
if (claimsChanged)
exist1.SecurityStamp = Guid.NewGuid().ToString("N");
await _userDataService.UpdateUserAsync(exist1, cancellationToken);
await _userManager.AddToRoleAsync(exist1, dto.Role ?? "");
await _userManager.UpdateAsync(exist1);
if (claimsChanged)
_sessionStamps.Forget(exist1.Id);
return new AddUserOutcomeDTO { Success = true };
}
}
@ -147,6 +158,15 @@ namespace SeaHaven.Services.Implementation
if (string.IsNullOrWhiteSpace(dto.Email))
throw new ArgumentException("Email is required.", nameof(dto));
// The token carries the user name, roles and account, so a change to any of
// them needs a fresh sign-in.
var existingRole = await _userManager.GetRolesAsync(exist);
var claimsChanged = exist.AccountId != dto.AccountId
|| !string.Equals(exist.UserName, dto.Email, StringComparison.OrdinalIgnoreCase)
|| existingRole == null
|| existingRole.Count != 1
|| !string.Equals(existingRole[0], dto.Role, StringComparison.OrdinalIgnoreCase);
exist.EmailConfirmed = true;
exist.UserName = dto.Email;
exist.Email = dto.Email;
@ -157,14 +177,17 @@ namespace SeaHaven.Services.Implementation
exist.PhoneNumber = dto.Role;
exist.AccountId = dto.AccountId;
var existingRole = await _userManager.GetRolesAsync(exist);
if (existingRole != null && existingRole.Any())
{
await _userManager.RemoveFromRolesAsync(exist, existingRole);
}
await _userManager.AddToRoleAsync(exist, dto.Role ?? "");
if (claimsChanged)
exist.SecurityStamp = Guid.NewGuid().ToString("N");
await _userDataService.UpdateUserAsync(exist, cancellationToken);
if (claimsChanged)
_sessionStamps.Forget(exist.Id);
return new AddUserOutcomeDTO { Success = true };
}
@ -185,6 +208,7 @@ namespace SeaHaven.Services.Implementation
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken);
_sessionStamps.Forget(data.Id);
return new AddUserOutcomeDTO { Success = true };
}
@ -193,8 +217,11 @@ namespace SeaHaven.Services.Implementation
var exist = await _userDataService.GetForEditAsync(userId, cancellationToken);
if (exist != null && !await _userDataService.IsAccountOwnerAsync(exist.Id, cancellationToken))
{
// A new stamp keeps this account's earlier sessions ended even if it is restored.
exist.IsDeleted = true;
exist.SecurityStamp = Guid.NewGuid().ToString("N");
await _userDataService.UpdateUserAsync(exist, cancellationToken);
_sessionStamps.Forget(exist.Id);
}
}

View file

@ -670,54 +670,75 @@ namespace SeaHaven.Services.Implementation
}
}
// At most one active (Pending or ChangesRequested) request per dispatch.
var activeExists = await _upliftData.HasActiveAsync(id, cancellationToken);
if (activeExists)
var vendorReason = reason.Trim();
var evidenceId = evidence.Id;
// The active-request check and the insert run under the per-work-order lock a work
// order cancel takes: a cancel either sees this request and cancels it, or this
// request sees the cancelled work order and is refused.
async Task<DispatchUpliftRequest> PersistRequestAsync(CancellationToken ct)
{
throw new InvalidOperationException("An active uplift request already exists for this dispatch");
if (dispatch.WorkOrderId is int workOrderId
&& await _upliftData.IsWorkOrderCancelledAsync(workOrderId, ct))
{
throw new InvalidOperationException("Cannot request uplift on a cancelled work order");
}
// At most one active (Pending or ChangesRequested) request per dispatch.
var activeExists = await _upliftData.HasActiveAsync(id, ct);
if (activeExists)
{
throw new InvalidOperationException("An active uplift request already exists for this dispatch");
}
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
var delta = requestedNTE - current;
var requiredTier = delta > tier1Max ? 2 : 1;
var expiresAt = now + _approvalsOptions.EffectiveExpiration;
var request = new DispatchUpliftRequest
{
DispatchId = id,
CurrentNTE = current,
RequestedNTE = requestedNTE,
VendorReason = vendorReason,
Status = UpliftStatus.Pending,
RequiredTier = requiredTier,
RequestedByVendorName = session.CompanyName,
EvidenceDocumentId = evidenceId,
RequestKey = string.IsNullOrWhiteSpace(requestKey) ? null : requestKey.Trim(),
ExpiresAt = expiresAt,
NotificationStatus = UpliftNotificationStatus.Pending,
CreatedDate = now
};
await _upliftData.StageAsync(request, ct);
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = $"${current:F2}",
NewValue = $"${requestedNTE:F2}",
Action = "uplift_requested",
ActorType = "vendor",
CreatedAt = now
}, ct);
try
{
await _upliftData.SaveChangesAsync(ct);
}
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
{
throw new UpliftConflictException();
}
return request;
}
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
var delta = requestedNTE - current;
var requiredTier = delta > tier1Max ? 2 : 1;
var expiresAt = now + _approvalsOptions.EffectiveExpiration;
var req = new DispatchUpliftRequest
{
DispatchId = id,
CurrentNTE = current,
RequestedNTE = requestedNTE,
VendorReason = reason.Trim(),
Status = UpliftStatus.Pending,
RequiredTier = requiredTier,
RequestedByVendorName = session.CompanyName,
EvidenceDocumentId = evidence.Id,
RequestKey = string.IsNullOrWhiteSpace(requestKey) ? null : requestKey.Trim(),
ExpiresAt = expiresAt,
NotificationStatus = UpliftNotificationStatus.Pending,
CreatedDate = now
};
await _upliftData.StageAsync(req, cancellationToken);
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = $"${current:F2}",
NewValue = $"${requestedNTE:F2}",
Action = "uplift_requested",
ActorType = "vendor",
CreatedAt = now
}, cancellationToken);
try
{
await _upliftData.SaveChangesAsync(cancellationToken);
}
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
{
throw new UpliftConflictException();
}
var req = dispatch.WorkOrderId is int lockedWorkOrderId
? await _upliftData.ExecuteWorkOrderMutationAsync(lockedWorkOrderId, PersistRequestAsync, cancellationToken)
: await PersistRequestAsync(cancellationToken);
// Notification is best-effort and persisted separately from workflow state: a failure
// never destroys the actionable request (the lifecycle sweep retries by sentinel).

View file

@ -16,17 +16,23 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderBoardDataService _boardDataService;
private readonly IWorkOrderBoardMutationDataService _mutationData;
private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderUpliftService _upliftService;
private readonly IUpliftDataService _upliftData;
private readonly IServicesRegistryService? _servicesRegistryService;
public WorkOrderBoardUpdateService(
IWorkOrderBoardDataService boardDataService,
IWorkOrderBoardMutationDataService mutationData,
IWorkOrderAuditService auditService,
IWorkOrderUpliftService upliftService,
IUpliftDataService upliftData,
IServicesRegistryService? servicesRegistryService = null)
{
_boardDataService = boardDataService;
_mutationData = mutationData;
_auditService = auditService;
_upliftService = upliftService;
_upliftData = upliftData;
_servicesRegistryService = servicesRegistryService;
}
@ -35,7 +41,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardPatchRequestDto request,
string? actorId)
{
await _mutationData.ExecuteTransactionalAsync(async ct =>
Func<CancellationToken, Task> patch = async ct =>
{
if (string.IsNullOrWhiteSpace(request.Field))
throw new WorkOrderBoardValidationException("InvalidField", "Field is required.");
@ -127,7 +133,16 @@ namespace SeaHaven.Services.Implementation
}
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
var lifecycleBefore = workOrder.LifecycleStatus;
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField, ct);
// Cancelling the work order cancels its pending uplift in the same action: the
// withdrawal and its own audit entry are staged here and committed by the same
// save as the status change.
if (PendingUpliftCancellation.Applies(lifecycleBefore, workOrder.LifecycleStatus))
{
await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, ct);
}
if (resolved is { Created: true, Dispatch: var createdDispatch }
&& canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase))
{
@ -173,12 +188,40 @@ namespace SeaHaven.Services.Implementation
}
await SaveTrackedOrThrowAsync(workOrderId, ct);
}, CancellationToken.None);
};
// A cancel runs under the per-work-order gate uplift create uses, so a create that
// is in flight cannot commit a new pending uplift onto the work order being cancelled.
if (IsCancelRequest(request))
{
await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
async ct =>
{
await patch(ct);
return true;
},
CancellationToken.None);
}
else
{
await _mutationData.ExecuteTransactionalAsync(patch, CancellationToken.None);
}
var row = await LoadBoardRowAsync(workOrderId);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
private static bool IsCancelRequest(WorkOrderBoardPatchRequestDto request)
{
if (string.IsNullOrWhiteSpace(request.Field))
return false;
var canonicalField = WorkOrderBoardFieldNames.Canonicalize(request.Field.Trim());
return string.Equals(canonicalField, WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase)
&& LifecycleStatusMapper.ParseLifecycleStatus(request.Value) == LifecycleStatus.Canceled;
}
private async Task<List<FieldChange>> ApplyFieldMutationAsync(
string field,
WorkOrder workOrder,

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
@ -36,7 +37,20 @@ namespace SeaHaven.Services.Implementation
if (items.Count == 0)
return result;
await _ingestData.ExecuteTransactionalAsync(async cancellationToken =>
// Existing work orders this batch may cancel hold the per-work-order lock until the
// batch commits, so an uplift create on one of them either commits first (and is
// cancelled below) or waits and then sees the work order cancelled.
var cancellingExternalIds = items
.Where(item => !string.IsNullOrWhiteSpace(item.ExternalWorkOrderId)
&& PendingUpliftCancellation.IsCancelled(null, WorkOrderIngestFieldMapper.MapStatus(item.WoStatus)))
.Select(item => item.ExternalWorkOrderId)
.Distinct()
.ToList();
var lockedWorkOrderIds = cancellingExternalIds.Count == 0
? Array.Empty<int>()
: await _ingestData.GetWorkOrderIdsByExternalIdsAsync(cancellingExternalIds, cancellationToken);
await _ingestData.ExecuteTransactionalAsync(lockedWorkOrderIds, async cancellationToken =>
{
var nextSeed = await AllocateNextWoSeedAsync(cancellationToken);
@ -134,8 +148,15 @@ namespace SeaHaven.Services.Implementation
var mappedStatus = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus);
if (mappedStatus != null)
{
var statusBefore = existing.Status;
await _mergePolicy.TryApplyAsync(syncContext, "Status", mappedStatus);
// A sync cancellation cancels the work order's pending uplifts in the same save.
if (PendingUpliftCancellation.AppliesToStatusText(statusBefore, existing.Status))
await _ingestData.StageCancelPendingUpliftsAsync(existing.Id, cancellationToken);
}
var priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity);
if (priority != null)
await _mergePolicy.TryApplyAsync(syncContext, "Priority", priority);

View file

@ -2,6 +2,7 @@ using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
@ -139,10 +140,14 @@ namespace SeaHaven.Services.Implementation
if (workOrder == null)
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled)
if (workOrder.LifecycleStatus == LifecycleStatus.Completed
|| PendingUpliftCancellation.IsCancelled(workOrder.LifecycleStatus, workOrder.Status))
{
var closedAs = workOrder.LifecycleStatus == LifecycleStatus.Completed
? LifecycleStatus.Completed
: LifecycleStatus.Canceled;
throw new InvalidOperationException(
$"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order");
$"Cannot create an uplift on a '{closedAs}' work order");
}
// SH-393: write to a dispatch the work order's uplift reads resolve back to it.
@ -350,37 +355,11 @@ namespace SeaHaven.Services.Implementation
string? actorId,
CancellationToken cancellationToken)
{
var pending = await _upliftData.GetPendingForWorkOrderAsync(workOrderId, cancellationToken);
if (pending.Count == 0)
return;
var now = _timeProvider.GetUtcNow().UtcDateTime;
foreach (var req in pending)
{
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
continue;
var dispatch = req.Dispatch ?? await _dispatchData.GetByIdAsync(req.DispatchId);
if (dispatch == null)
continue;
var previous = UpliftStatus.ToCanonical(req.Status);
req.Status = UpliftStatus.Withdrawn;
req.DecidedAt = now;
req.DecidedByUserId = actorId;
req.LastModificationTime = now;
await StageAuditAsync(
dispatch,
workOrderId,
actorId,
previous,
UpliftStatus.Withdrawn,
"uplift_cancel",
now,
cancellationToken,
isStatusTransition: true);
}
await _upliftData.StageCancelPendingForWorkOrderAsync(
workOrderId,
actorId,
_timeProvider.GetUtcNow().UtcDateTime,
cancellationToken);
}
private async Task<WorkOrderUpliftDto> PersistCreatedAsync(

View file

@ -0,0 +1,13 @@
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Hands a password reset email to a background sender, so the request that asked
/// for it does not wait on the mail provider and cannot be timed against one that
/// sent nothing.
/// </summary>
public interface IPasswordResetEmailQueue
{
/// <summary>Returns false when the queue is full and the email was dropped.</summary>
bool TryEnqueue(string emailTo, string subject, string body);
}
}

View file

@ -0,0 +1,30 @@
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Per-account limits on the anonymous password reset flow, keyed on the
/// normalized email so they hold however many client addresses an attacker uses.
/// </summary>
public interface IPasswordResetThrottle
{
/// <summary>
/// Counts a code request for the email and returns true while it is within the
/// hourly and daily limits. A refused request is not counted.
/// </summary>
bool TryAcceptCodeRequest(string email);
/// <summary>Gives back an accepted code request whose email could not be queued.</summary>
void ReleaseCodeRequest(string email);
/// <summary>
/// Reserves one failed check for the email before a code is compared. Returns
/// false once the account has used its failed checks for the window.
/// </summary>
bool TryReserveCheck(string email);
/// <summary>
/// Gives back a reservation that did not become a failed guess: the code matched,
/// or there was no live code to compare it with.
/// </summary>
void ReleaseCheck(string email);
}
}

View file

@ -0,0 +1,20 @@
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// The process-wide cache of expected session stamp values, keyed by user id.
/// Registered as a singleton.
/// </summary>
public interface ISessionStampCache
{
/// <summary>Changes on every removal; a read that spans one is not cached.</summary>
long Generation { get; }
/// <summary>True with the cached value (null: the account matches nothing) while it is fresh.</summary>
bool TryGet(string userId, out string? expected);
/// <summary>Caches a value read at <paramref name="generation"/>, unless a removal has happened since.</summary>
void Set(string userId, string? expected, long generation);
void Remove(string userId);
}
}

View file

@ -0,0 +1,21 @@
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Ties a sign-in token to the account's security stamp, so a password reset or
/// change, a deactivation, or a deletion ends every session issued before it.
/// </summary>
public interface ISessionStampService
{
/// <summary>The value a token carries for <paramref name="securityStamp"/>.</summary>
string ClaimValueFor(string securityStamp);
/// <summary>
/// True when <paramref name="claimValue"/> matches the stamp of an account that
/// exists and is not deleted. Stored stamps are cached briefly.
/// </summary>
Task<bool> IsCurrentAsync(string userId, string? claimValue, CancellationToken cancellationToken);
/// <summary>Drops the cached stamp; call after a change to the stamp or the account is saved.</summary>
void Forget(string userId);
}
}

View file

@ -0,0 +1,269 @@
using System.Net;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.HostedServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// Limits that hold per account rather than per client address, the email send
/// being off the request path, and the keyed code hash.
/// </summary>
public sealed class PasswordResetAbuseLimitsTests
{
private const string Alice = "alice@example.com";
private const string OldPassword = "Old@12345";
private const string NewPassword = "New@67890";
private const string CodeNotMatched = "{\"status\":\"Error\",\"message\":\"Code Not Matched\"}";
private const string ResetFailed = "{\"status\":\"Error\",\"message\":\"Your email or code not found please check\"}";
private static int _nextClient;
// A fresh client address per request, so the per-IP limit never masks the per-account one.
private static string NextClient()
{
var n = Interlocked.Increment(ref _nextClient);
return $"198.51.{n / 250 % 250}.{n % 250 + 1}";
}
[Fact]
public async Task Code_requests_are_capped_per_email_at_three_an_hour_and_ten_a_day()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
var responses = new List<string>();
for (var request = 0; request < 3; request++)
responses.Add(await (await host.ForgetPasswordAsync(Alice, NextClient())).Content.ReadAsStringAsync());
var liveCode = host.Sent.LatestCodeFor(Alice);
// A differently cased and padded address is the same account.
responses.Add(await (await host.ForgetPasswordAsync(" ALICE@Example.com ", NextClient())).Content.ReadAsStringAsync());
Assert.Equal(3, host.Sent.Messages.Count);
Assert.Single(responses.Distinct());
// The refused request neither sent a code nor replaced the one already sent.
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, liveCode, NextClient())).StatusCode);
for (var hour = 1; hour <= 3; hour++)
{
host.Time.Advance(TimeSpan.FromHours(1));
for (var request = 0; request < 3; request++)
await host.ForgetPasswordAsync(Alice, NextClient());
}
Assert.Equal(10, host.Sent.Messages.Count);
host.Time.Advance(TimeSpan.FromHours(20));
await host.ForgetPasswordAsync(Alice, NextClient());
Assert.Equal(10, host.Sent.Messages.Count);
host.Time.Advance(TimeSpan.FromHours(1));
await host.ForgetPasswordAsync(Alice, NextClient());
Assert.Equal(11, host.Sent.Messages.Count);
}
[Fact]
public async Task Failed_checks_are_capped_per_account_across_every_code_it_is_sent()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
for (var round = 0; round < 2; round++)
{
await host.ForgetPasswordAsync(Alice, NextClient());
var code = host.Sent.LatestCodeFor(Alice);
for (var attempt = 0; attempt < 5; attempt++)
await host.VerifyAsync(Alice, WrongCode(code), NextClient());
}
await host.ForgetPasswordAsync(Alice, NextClient());
var third = host.Sent.LatestCodeFor(Alice);
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, third, NextClient())).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, third, NewPassword, NextClient())).Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
host.Time.Advance(TimeSpan.FromDays(1));
await host.ForgetPasswordAsync(Alice, NextClient());
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, host.Sent.LatestCodeFor(Alice), NewPassword, NextClient())).StatusCode);
}
[Fact]
public async Task Checks_without_a_live_code_do_not_use_up_the_account_budget()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
// No code issued yet.
for (var check = 0; check < 10; check++)
{
await host.VerifyAsync(Alice, "123456", NextClient());
await host.ResetAsync(Alice, "123456", NewPassword, NextClient());
}
// A code that has expired.
await host.ForgetPasswordAsync(Alice, NextClient());
var expired = host.Sent.LatestCodeFor(Alice);
host.Time.Advance(TimeSpan.FromMinutes(16));
for (var check = 0; check < 5; check++)
await host.VerifyAsync(Alice, expired, NextClient());
await host.ForgetPasswordAsync(Alice, NextClient());
var live = host.Sent.LatestCodeFor(Alice);
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, live, NextClient())).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, live, NewPassword, NextClient())).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
}
[Fact]
public void Concurrent_checks_can_never_exceed_the_account_budget()
{
var throttle = new InMemoryPasswordResetThrottle(new ResetTestClock());
var reserved = 0;
Parallel.For(0, 64, _ =>
{
if (throttle.TryReserveCheck(Alice))
Interlocked.Increment(ref reserved);
});
Assert.Equal(InMemoryPasswordResetThrottle.FailedChecksPerDay, reserved);
}
[Fact]
public void A_matching_check_gives_its_reservation_back()
{
var throttle = new InMemoryPasswordResetThrottle(new ResetTestClock());
for (var check = 0; check < 50; check++)
{
Assert.True(throttle.TryReserveCheck(Alice));
throttle.ReleaseCheck(Alice);
}
Assert.True(throttle.TryReserveCheck(Alice.ToUpperInvariant()));
}
[Fact]
public async Task ForgetPassword_answers_before_the_email_is_sent()
{
var sender = new BlockingEmailSender();
await using var host = await PasswordResetTestHost.StartAsync(sender);
await host.AddUserAsync(Alice, OldPassword);
try
{
var response = await host.Client
.SendAsync(PasswordResetTestHost.Post("api/Authentication/ForgetPassword", new { email = Alice }))
.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains(AuthenticationController.ForgetPasswordMessage, await response.Content.ReadAsStringAsync());
Assert.False(sender.Completed);
}
finally
{
sender.Release();
}
await host.WaitForEmailDrainAsync();
Assert.True(sender.Completed);
}
[Fact]
public async Task An_unregistered_email_gets_the_same_database_write_but_a_code_nothing_can_match()
{
await using var host = await PasswordResetTestHost.StartAsync();
var alice = await host.AddUserAsync(Alice, OldPassword);
await host.ForgetPasswordAsync(Alice);
await host.ForgetPasswordAsync("nobody@example.com");
var rows = await host.PendingCodesAsync();
Assert.Equal(2, rows.Count);
var registered = Assert.Single(rows, row => row.Email == Alice);
var unregistered = Assert.Single(rows, row => row.Email == "nobody@example.com");
Assert.Equal(alice.Id, registered.UserId);
Assert.Equal(string.Empty, unregistered.UserId);
Assert.Matches("^[0-9a-f]{64}$", unregistered.CodeHash);
Assert.Equal(registered.ExpiresAtUtc, unregistered.ExpiresAtUtc);
Assert.Single(host.Sent.Messages);
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync("nobody@example.com", "000000")).Content.ReadAsStringAsync());
}
[Fact]
public async Task Each_request_clears_expired_codes_so_decoy_rows_do_not_accumulate()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.ForgetPasswordAsync("first@example.com");
await host.ForgetPasswordAsync("second@example.com");
host.Time.Advance(TimeSpan.FromMinutes(16));
await host.ForgetPasswordAsync("third@example.com");
var row = Assert.Single(await host.PendingCodesAsync());
Assert.Equal("third@example.com", row.Email);
}
[Fact]
public async Task A_code_stored_in_the_previous_unkeyed_format_no_longer_matches()
{
await using var host = await PasswordResetTestHost.StartAsync();
var alice = await host.AddUserAsync(Alice, OldPassword);
var code = await host.AddSiblingCodeAsync(Alice, alice.Id, keyedHash: false);
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync());
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync());
}
[Fact]
public void A_code_hashed_under_another_server_secret_does_not_match()
{
var salt = PasswordResetCodeSecrets.NewSalt();
var hash = PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.DeriveKey(new string('a', 64)), salt, "123456");
Assert.True(PasswordResetCodeSecrets.Matches(PasswordResetCodeSecrets.DeriveKey(new string('a', 64)), salt, "123456", hash));
Assert.False(PasswordResetCodeSecrets.Matches(PasswordResetCodeSecrets.DeriveKey(new string('b', 64)), salt, "123456", hash));
}
[Fact]
public async Task Reset_queue_and_throttle_are_process_wide_and_the_sender_runs()
{
await using var host = await PasswordResetTestHost.StartAsync();
object Resolve<T>() where T : notnull
{
using var scope = host.Services.CreateScope();
return scope.ServiceProvider.GetRequiredService<T>();
}
Assert.Same(Resolve<IPasswordResetEmailQueue>(), Resolve<IPasswordResetEmailQueue>());
Assert.Same(Resolve<IPasswordResetThrottle>(), Resolve<IPasswordResetThrottle>());
Assert.Same(Resolve<PasswordResetEmailChannel>(), Resolve<IPasswordResetEmailQueue>());
Assert.Contains(host.Services.GetServices<IHostedService>(), service => service is PasswordResetEmailSenderHostedService);
}
private static string WrongCode(string code) =>
((int.Parse(code) + 1) % 1_000_000).ToString("D6");
private sealed class BlockingEmailSender : IEmailSender
{
private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously);
public bool Completed { get; private set; }
public void Release() => _gate.TrySetResult();
public async Task<bool> SendEmailAsync(string emailTo, string subject, string body)
{
await _gate.Task;
Completed = true;
return true;
}
}
}

View file

@ -5,6 +5,7 @@ using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
@ -64,7 +65,7 @@ public sealed class PasswordResetFlowTests
var message = Assert.Single(host.Sent.Messages);
Assert.Equal(Alice, message.To);
Assert.Single(await host.PendingCodesAsync());
Assert.Single(await host.PendingCodesAsync(), row => row.UserId.Length > 0);
}
[Fact]
@ -84,7 +85,7 @@ public sealed class PasswordResetFlowTests
}
[Fact]
public async Task Stored_code_is_a_salted_hash_and_the_plaintext_is_only_in_the_email()
public async Task Stored_code_is_keyed_with_a_server_secret_and_the_plaintext_is_only_in_the_email()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
@ -96,8 +97,11 @@ public sealed class PasswordResetFlowTests
Assert.Equal(string.Empty, row.Code);
Assert.Matches("^[0-9a-f]{32}$", row.CodeSalt);
Assert.Matches("^[0-9a-f]{64}$", row.CodeHash);
Assert.NotEqual(Sha256Hex(code), row.CodeHash);
Assert.Equal(Sha256Hex(row.CodeSalt + ":" + code), row.CodeHash);
// Salt and hash from the row alone are not enough to test a guess offline.
Assert.NotEqual(Sha256Hex(row.CodeSalt + ":" + code), row.CodeHash);
Assert.Equal(
PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.DeriveKey(PasswordResetTestHost.JwtSecret), row.CodeSalt, code),
row.CodeHash);
Assert.DoesNotContain(code, string.Join("|", row.Code, row.CodeHash, row.CodeSalt, row.Email, row.UserId));
}
@ -289,18 +293,25 @@ public sealed class PasswordResetFlowTests
}
[Fact]
public async Task VerificationCode_and_ForgetPassword_still_accept_the_query_string_form()
public async Task Email_and_code_in_the_query_string_are_ignored()
{
await using var host = await PasswordResetTestHost.StartAsync();
await host.AddUserAsync(Alice, OldPassword);
var requested = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(Alice)}"));
Assert.Equal(HttpStatusCode.OK, requested.StatusCode);
var code = host.Sent.LatestCodeFor(Alice);
var queryRequest = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(Alice)}"));
await host.WaitForEmailDrainAsync();
var bodyRequest = await host.ForgetPasswordAsync("nobody@example.com");
Assert.Equal(HttpStatusCode.OK, queryRequest.StatusCode);
Assert.Equal(await bodyRequest.Content.ReadAsStringAsync(), await queryRequest.Content.ReadAsStringAsync());
Assert.Empty(host.Sent.Messages);
await host.ForgetPasswordAsync(Alice);
var code = host.Sent.LatestCodeFor(Alice);
var verified = await host.Client.SendAsync(PasswordResetTestHost.Post(
$"api/Authentication/VerificationCode?email={Uri.EscapeDataString(Alice)}&code={code}"));
Assert.Equal(HttpStatusCode.OK, verified.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, verified.StatusCode);
Assert.Equal(CodeNotMatched, await verified.Content.ReadAsStringAsync());
}
[Fact]
@ -367,6 +378,7 @@ public sealed class PasswordResetFlowTests
var program = File.ReadAllText(Path.Combine(RepoRoot(), "Api.SeaHavenIndustries", "Program.cs"));
Assert.Contains("builder.Services.AddPasswordResetRateLimiting();", program);
Assert.Contains("builder.Services.AddPasswordResetEmailDelivery();", program);
var build = program.IndexOf("builder.Build()", StringComparison.Ordinal);
var forwarded = program.IndexOf("app.UseForwardedHeaders()", StringComparison.Ordinal);
var firstMiddleware = program.IndexOf("app.Use", build, StringComparison.Ordinal);

View file

@ -92,8 +92,11 @@ public sealed class PasswordResetRaceTests
_race = race;
}
public Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken) =>
_inner.ReplaceCodeAsync(email, userId, codeHash, codeSalt, expiresAtUtc, cancellationToken);
public Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, DateTime nowUtc, CancellationToken cancellationToken) =>
_inner.ReplaceCodeAsync(email, userId, codeHash, codeSalt, expiresAtUtc, nowUtc, cancellationToken);
public Task PurgeExpiredAsync(DateTime nowUtc, CancellationToken cancellationToken) =>
_inner.PurgeExpiredAsync(nowUtc, cancellationToken);
public Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken) =>
_inner.GetByEmailAsync(email, cancellationToken);

View file

@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Builder;
@ -32,6 +33,8 @@ namespace SeaHavenIndustries.Tests;
/// </summary>
internal sealed class PasswordResetTestHost : IAsyncDisposable
{
public static readonly string JwtSecret = new('k', 64);
private readonly WebApplication _app;
private readonly string _databasePath;
@ -61,7 +64,7 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["JWT:Secret"] = new string('k', 64),
["JWT:Secret"] = JwtSecret,
["JWT:ValidIssuer"] = "issuer",
["JWT:ValidAudience"] = "audience"
});
@ -91,6 +94,8 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
manager.FeatureProviders.Add(new OnlyAuthenticationController());
});
builder.Services.AddPasswordResetRateLimiting();
builder.Services.AddSingleton<Sentry.IHub>(Sentry.Extensibility.HubAdapter.Instance);
builder.Services.AddPasswordResetEmailDelivery();
var app = builder.Build();
app.UseForwardedHeaders();
@ -142,7 +147,7 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
/// Stores a second pending code for the email directly, as two concurrent first
/// requests could, and returns it. The new row is the newest one.
/// </summary>
public async Task<string> AddSiblingCodeAsync(string email, string userId)
public async Task<string> AddSiblingCodeAsync(string email, string userId, bool keyedHash = true)
{
const string code = "424242";
const string salt = "0123456789abcdef0123456789abcdef";
@ -153,7 +158,9 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
Email = email,
UserId = userId,
CodeSalt = salt,
CodeHash = SeaHaven.Services.Helpers.PasswordResetCodeSecrets.Hash(salt, code),
CodeHash = keyedHash
? SeaHaven.Services.Helpers.PasswordResetCodeSecrets.Hash(SeaHaven.Services.Helpers.PasswordResetCodeSecrets.DeriveKey(JwtSecret), salt, code)
: Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant(),
ExpiresAtUtc = Time.GetUtcNow().UtcDateTime.AddMinutes(15)
});
await context.SaveChangesAsync();
@ -170,8 +177,25 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
return request;
}
public Task<HttpResponseMessage> ForgetPasswordAsync(string email, string? clientIp = null) =>
Client.SendAsync(Post("api/Authentication/ForgetPassword", new { email }, clientIp));
/// <summary>Requests a code and waits until any queued email has been handed to the sender.</summary>
public async Task<HttpResponseMessage> ForgetPasswordAsync(string email, string? clientIp = null)
{
var response = await Client.SendAsync(Post("api/Authentication/ForgetPassword", new { email }, clientIp));
await WaitForEmailDrainAsync();
return response;
}
public async Task WaitForEmailDrainAsync()
{
var channel = _app.Services.GetRequiredService<PasswordResetEmailChannel>();
var deadline = DateTime.UtcNow.AddSeconds(10);
while (channel.Pending > 0)
{
if (DateTime.UtcNow > deadline)
throw new TimeoutException("Queued password reset emails were not sent.");
await Task.Delay(10);
}
}
public Task<HttpResponseMessage> VerifyAsync(string email, string code, string? clientIp = null) =>
Client.SendAsync(Post("api/Authentication/VerificationCode", new { email, code }, clientIp));

View file

@ -0,0 +1,259 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Security.Claims;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// A sign-in token must stop working once the account's password is reset or changed,
/// or once the account is deactivated or deleted. Every case goes through the real API
/// host: sign in over HTTP, change the account through its endpoint, then call an
/// authorized endpoint with the old and the new token.
/// </summary>
public sealed class SessionRevocationTests
{
private const string SessionStampClaim = "session_stamp";
private const string NewPassword = "Quiet-Tide-77!";
[Fact]
public async Task A_token_issued_before_a_password_reset_is_refused_and_the_new_sign_in_works()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("sam@example.com");
var before = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, before);
await host.ResetPasswordAsync("sam@example.com", NewPassword);
await AssertRefusedAsync(host, before);
var after = await host.SignInAsync("sam@example.com", NewPassword);
await AssertAcceptedAsync(host, after);
}
[Fact]
public async Task A_token_issued_before_a_password_change_is_refused_and_the_new_sign_in_works()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("sam@example.com");
var before = await host.SignInAsync("sam@example.com");
var other = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, other);
var change = new
{
currentpassword = SessionTestHost.Password,
newpassword = NewPassword,
confirmpassword = NewPassword
};
using (var changed = await host.SendAsync(HttpMethod.Post, "api/Authentication/ChangePassword", before, change))
Assert.Equal(HttpStatusCode.OK, changed.StatusCode);
await AssertRefusedAsync(host, before);
await AssertRefusedAsync(host, other);
var after = await host.SignInAsync("sam@example.com", NewPassword);
await AssertAcceptedAsync(host, after);
}
[Fact]
public async Task A_deactivated_member_is_refused_and_the_old_token_stays_refused_after_reactivation()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("admin@example.com", "Admin");
var member = await host.AddUserAsync("sam@example.com", "Scheduler");
var admin = await host.SignInAsync("admin@example.com");
var before = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, before);
await UpdateMemberAsync(host, admin, member.Id, isActive: false);
await AssertRefusedAsync(host, before);
await UpdateMemberAsync(host, admin, member.Id, isActive: true);
await AssertRefusedAsync(host, before);
var after = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, after);
}
[Fact]
public async Task A_token_for_an_account_its_owner_deleted_is_refused()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("sam@example.com");
var before = await host.SignInAsync("sam@example.com");
using (var deleted = await host.SendAsync(HttpMethod.Post, "api/User/DeleteCurrentUser", before))
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
await AssertRefusedAsync(host, before);
}
[Fact]
public async Task A_token_for_an_account_an_admin_deleted_is_refused()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("admin@example.com", "Admin");
var member = await host.AddUserAsync("sam@example.com");
var admin = await host.SignInAsync("admin@example.com");
var before = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, before);
using (var deleted = await host.SendAsync(HttpMethod.Delete, "api/User/DeleteUser", admin, new { id = member.Id }))
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
await AssertRefusedAsync(host, before);
}
[Fact]
public async Task A_correctly_signed_token_with_a_forged_session_stamp_is_refused()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("sam@example.com");
var issued = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, issued);
var forged = SessionTestHost.Resign(issued, claims =>
{
claims.RemoveAll(claim => claim.Type == SessionStampClaim);
claims.Add(new Claim(SessionStampClaim, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
});
var resignedUnchanged = SessionTestHost.Resign(issued, _ => { });
await AssertAcceptedAsync(host, resignedUnchanged);
await AssertRefusedAsync(host, forged);
}
[Fact]
public async Task A_correctly_signed_token_without_a_session_stamp_is_refused()
{
// The shape of every token issued before this check shipped.
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("sam@example.com");
var issued = await host.SignInAsync("sam@example.com");
var stampless = SessionTestHost.Resign(issued, claims => claims.RemoveAll(claim => claim.Type == SessionStampClaim));
await AssertRefusedAsync(host, stampless);
}
[Fact]
public async Task A_refused_token_gets_the_same_401_as_no_token_and_nothing_sensitive_is_logged()
{
await using var host = await SessionTestHost.StartAsync();
var user = await host.AddUserAsync("sam@example.com");
var before = await host.SignInAsync("sam@example.com");
var stampClaim = new JwtSecurityTokenHandler().ReadJwtToken(before).Claims
.SingleOrDefault(claim => claim.Type == SessionStampClaim)?.Value;
var oldStamp = await host.StoredSecurityStampAsync(user.Id);
await host.ResetPasswordAsync("sam@example.com", NewPassword);
using var refused = await host.ProfileAsync(before);
using var anonymous = await host.ProfileAsync(null);
Assert.Equal(HttpStatusCode.Unauthorized, refused.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
Assert.Equal(await anonymous.Content.ReadAsStringAsync(), await refused.Content.ReadAsStringAsync());
Assert.Empty(await refused.Content.ReadAsStringAsync());
var newStamp = await host.StoredSecurityStampAsync(user.Id);
var secrets = new[] { before, oldStamp, newStamp, stampClaim, "sam@example.com" }
.Where(secret => !string.IsNullOrEmpty(secret))
.ToList();
var leaks = host.Logged.Entries
.Where(entry => secrets.Any(secret => entry.Contains(secret!, StringComparison.OrdinalIgnoreCase)))
.ToList();
Assert.Empty(leaks);
}
[Fact]
public async Task A_member_demoted_on_the_team_page_is_refused_and_signs_in_again_with_the_new_role()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("admin@example.com", "Admin");
var member = await host.AddUserAsync("sam@example.com", "Admin");
await host.EnsureRoleAsync("Scheduler");
var admin = await host.SignInAsync("admin@example.com");
var before = await host.SignInAsync("sam@example.com");
Assert.Equal(new[] { "Admin" }, RolesIn(before));
await AssertAcceptedAsync(host, before);
await UpdateMemberAsync(host, admin, member.Id, isActive: true, role: "Scheduler");
await AssertRefusedAsync(host, before);
var after = await host.SignInAsync("sam@example.com");
Assert.Equal(new[] { "Scheduler" }, RolesIn(after));
await AssertAcceptedAsync(host, after);
}
[Fact]
public async Task A_user_whose_role_an_admin_edits_is_refused_and_signs_in_again_with_the_new_role()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("admin@example.com", "Admin");
var member = await host.AddUserAsync("sam@example.com", "Admin");
await host.EnsureRoleAsync("Dispatcher");
var admin = await host.SignInAsync("admin@example.com");
var before = await host.SignInAsync("sam@example.com");
await AssertAcceptedAsync(host, before);
var edit = new
{
id = member.Id,
name = "Sam",
email = "sam@example.com",
role = "Dispatcher"
};
using (var edited = await host.SendAsync(HttpMethod.Put, "api/User/EditUser", admin, edit))
Assert.Equal(HttpStatusCode.OK, edited.StatusCode);
await AssertRefusedAsync(host, before);
var after = await host.SignInAsync("sam@example.com");
Assert.Equal(new[] { "Dispatcher" }, RolesIn(after));
await AssertAcceptedAsync(host, after);
}
[Fact]
public async Task Editing_a_member_without_changing_role_status_or_email_keeps_their_session()
{
await using var host = await SessionTestHost.StartAsync();
await host.AddUserAsync("admin@example.com", "Admin");
var member = await host.AddUserAsync("sam@example.com", "Scheduler");
var admin = await host.SignInAsync("admin@example.com");
var before = await host.SignInAsync("sam@example.com");
await UpdateMemberAsync(host, admin, member.Id, isActive: true);
await AssertAcceptedAsync(host, before);
}
private static string[] RolesIn(string token) =>
new JwtSecurityTokenHandler().ReadJwtToken(token).Claims
.Where(claim => claim.Type == ClaimTypes.Role)
.Select(claim => claim.Value)
.ToArray();
private static async Task UpdateMemberAsync(SessionTestHost host, string adminToken, string userId, bool isActive, string role = "Scheduler")
{
using var response = await host.SendAsync(HttpMethod.Put, $"api/team-members/{userId}", adminToken, new
{
name = "Sam Lee",
role,
color = "#0D9488",
email = "sam@example.com",
phone = "555-0100",
serviceAreas = Array.Empty<string>(),
isActive
});
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync());
}
private static async Task AssertAcceptedAsync(SessionTestHost host, string token)
{
using var response = await host.ProfileAsync(token);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
private static async Task AssertRefusedAsync(SessionTestHost host, string token)
{
using var response = await host.ProfileAsync(token);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}

View file

@ -0,0 +1,280 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// Hosts the real sign-in, user and team member controllers on Kestrel over a SQLite
/// file database, with Identity and bearer authentication registered exactly as the
/// API host registers them. Email goes to an in-memory sender and every log line is
/// captured.
/// </summary>
internal sealed class SessionTestHost : IAsyncDisposable
{
public static readonly string JwtSecret = new('s', 64);
public const string Issuer = "issuer";
public const string Audience = "audience";
public const string Password = "Harbor-Light-42!";
private static readonly Regex ResetCode = new(@"Reset Code is: (\d{6})", RegexOptions.CultureInvariant);
private readonly WebApplication _app;
private readonly string _databasePath;
private SessionTestHost(WebApplication app, string databasePath, HttpClient client)
{
_app = app;
_databasePath = databasePath;
Client = client;
}
public HttpClient Client { get; }
public CapturingEmailSender Sent { get; private set; } = null!;
public CapturingLoggerProvider Logged { get; private set; } = null!;
public static async Task<SessionTestHost> StartAsync()
{
var databasePath = Path.Combine(Path.GetTempPath(), $"sessions-{Guid.NewGuid():N}.db");
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString();
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" });
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["JWT:Secret"] = JwtSecret,
["JWT:ValidIssuer"] = Issuer,
["JWT:ValidAudience"] = Audience
});
var sent = new CapturingEmailSender();
var logged = new CapturingLoggerProvider();
builder.Logging.ClearProviders();
builder.Logging.SetMinimumLevel(LogLevel.Trace);
builder.Logging.AddProvider(logged);
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite(connectionString));
builder.Services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
new SqliteSessionDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
builder.Services.AddSeaHavenIdentity();
builder.Services.AddSingleton<IEmailSender>(sent);
builder.Services.AddDataServices();
builder.Services.AddBusinessServices(builder.Configuration);
// Forgot Password queues its email; the API host registers the same delivery.
builder.Services.AddSingleton<Sentry.IHub>(Sentry.Extensibility.HubAdapter.Instance);
builder.Services.AddPasswordResetEmailDelivery();
builder.Services.AddSeaHavenJwtAuthentication(builder.Configuration);
builder.Services.AddControllers()
.AddApplicationPart(typeof(AuthenticationController).Assembly)
.ConfigureApplicationPartManager(manager =>
{
manager.FeatureProviders.Clear();
manager.FeatureProviders.Add(new SessionControllers());
});
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
await using (var scope = app.Services.CreateAsyncScope())
await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.EnsureCreatedAsync();
await app.StartAsync();
var address = app.Services.GetRequiredService<IServer>().Features
.Get<IServerAddressesFeature>()!.Addresses.Single();
var host = new SessionTestHost(app, databasePath, new HttpClient { BaseAddress = new Uri(address) });
host.Sent = sent;
host.Logged = logged;
return host;
}
public async Task<ApplicationUser> AddUserAsync(string email, string? role = null)
{
await using var scope = _app.Services.CreateAsyncScope();
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = new ApplicationUser
{
UserName = email,
Email = email,
FirstName = "Sam",
LastName = "Lee",
EmailConfirmed = true,
UniqueName = "Active",
CreatedDate = DateTime.UtcNow
};
var created = await users.CreateAsync(user, Password);
Assert.True(created.Succeeded, string.Join("; ", created.Errors.Select(error => error.Description)));
if (role != null)
{
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
if (!await roles.RoleExistsAsync(role))
await roles.CreateAsync(new IdentityRole(role));
await users.AddToRoleAsync(user, role);
}
return user;
}
public async Task EnsureRoleAsync(string role)
{
await using var scope = _app.Services.CreateAsyncScope();
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
if (!await roles.RoleExistsAsync(role))
await roles.CreateAsync(new IdentityRole(role));
}
public async Task<string> SignInAsync(string email, string password = Password)
{
using var response = await Client.PostAsJsonAsync("api/Authentication/login", new { username = email, password });
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return payload.RootElement.GetProperty("token").GetString()!;
}
public Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, string? token, object? json = null)
{
var request = new HttpRequestMessage(method, path);
if (token != null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (json != null)
request.Content = JsonContent.Create(json);
return Client.SendAsync(request);
}
/// <summary>An authorized read that only succeeds for a signed-in, accepted session.</summary>
public Task<HttpResponseMessage> ProfileAsync(string? token) =>
SendAsync(HttpMethod.Get, "api/User/UserProfile", token);
/// <summary>Runs Forgot Password end to end: request a code, read it from the email, reset.</summary>
public async Task ResetPasswordAsync(string email, string newPassword)
{
var before = Sent.Messages.Count;
using (var requested = await SendAsync(
HttpMethod.Post, $"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(email)}", null, new { email }))
Assert.Equal(System.Net.HttpStatusCode.OK, requested.StatusCode);
var deadline = DateTime.UtcNow.AddSeconds(10);
SentEmail? message;
while ((message = Sent.Messages.Skip(before).LastOrDefault(sent => sent.To == email && ResetCode.IsMatch(sent.Body))) == null)
{
if (DateTime.UtcNow > deadline)
throw new TimeoutException("No password reset email was sent.");
await Task.Delay(10);
}
var code = ResetCode.Match(message.Body).Groups[1].Value;
using var reset = await SendAsync(
HttpMethod.Post, "api/Authentication/ResetPassword", null, new { email, code, password = newPassword });
Assert.Equal(System.Net.HttpStatusCode.OK, reset.StatusCode);
}
public async Task<string?> StoredSecurityStampAsync(string userId)
{
await using var scope = _app.Services.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>()
.Users.AsNoTracking().Where(user => user.Id == userId).Select(user => user.SecurityStamp).SingleOrDefaultAsync();
}
/// <summary>
/// Re-signs <paramref name="token"/> with the host's real signing key after letting
/// <paramref name="edit"/> change its claims, so only the claims differ from a token
/// the API issued.
/// </summary>
public static string Resign(string token, Action<List<System.Security.Claims.Claim>> edit)
{
var original = new JwtSecurityTokenHandler().ReadJwtToken(token);
var claims = original.Claims
.Where(claim => claim.Type is not (JwtRegisteredClaimNames.Exp or JwtRegisteredClaimNames.Iss or JwtRegisteredClaimNames.Aud or JwtRegisteredClaimNames.Nbf or JwtRegisteredClaimNames.Iat))
.ToList();
edit(claims);
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret));
var resigned = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
expires: original.ValidTo,
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return new JwtSecurityTokenHandler().WriteToken(resigned);
}
public async ValueTask DisposeAsync()
{
Client.Dispose();
await _app.StopAsync();
await _app.DisposeAsync();
SqliteConnection.ClearAllPools();
foreach (var path in new[] { _databasePath, _databasePath + "-wal", _databasePath + "-shm", _databasePath + "-journal" })
{
if (File.Exists(path))
File.Delete(path);
}
}
private sealed class SessionControllers : ControllerFeatureProvider
{
protected override bool IsController(System.Reflection.TypeInfo typeInfo) =>
typeInfo.AsType() == typeof(AuthenticationController)
|| typeInfo.AsType() == typeof(UserController)
|| typeInfo.AsType() == typeof(TeamMemberController);
}
private sealed class SqliteSessionDbContext : ApplicationDbContext
{
public SqliteSessionDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
{
// SQL Server filter syntax does not carry over; a filtered unique index
// without its filter would wrongly reject a second user.
if (index.GetFilter() is not null)
{
index.SetFilter(null);
index.IsUnique = false;
}
}
foreach (var property in builder.Model.GetEntityTypes()
.SelectMany(entity => entity.GetProperties())
.Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[])))
{
property.ValueGenerated = ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
}

View file

@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using System.Text.RegularExpressions;
using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using Microsoft.Data.Sqlite;
@ -79,6 +80,9 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable
services.AddSingleton<IEmailSender>(fakes.Sent);
services.AddDataServices();
services.AddBusinessServices(configuration);
// Registration signs the member in through the authentication service, which
// queues password reset email; the API host registers the same delivery.
services.AddPasswordResetEmailDelivery();
configure?.Invoke(services);
return services.BuildServiceProvider();
}

View file

@ -15,6 +15,31 @@ internal static class WorkOrderAccountTestHelpers
new AccountDataService(context),
new LocationDataService(context));
/// <summary>Board update service over one context, with the real uplift cascade wired.</summary>
public static WorkOrderBoardUpdateService BoardUpdateService(
ApplicationDbContext context,
IWorkOrderAuditService audit,
IServicesRegistryService? servicesRegistry = null)
=> new(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
audit,
UpliftService(context),
new UpliftDataService(context),
servicesRegistry);
public static WorkOrderUpliftService UpliftService(ApplicationDbContext context)
=> new(
new UpliftDataService(context),
new DispatchDataService(context),
new WorkOrderDetailDataService(context),
Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(new SeaHaven.Services.Configuration.ApprovalsOptions()));
public static ClaimsPrincipal AccountUser(
string userId = "actor-1",
int accountId = 1,

View file

@ -28,7 +28,7 @@ public class WorkOrderBoardCancelServiceTests
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, new NoOpUpliftService(), new PassThroughUpliftData());
var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
var update = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
return (context, cancel, update);
}
@ -461,7 +461,8 @@ public class WorkOrderBoardCancelServiceTests
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<int> StageCancelPendingForWorkOrderAsync(int workOrderId, string? actorId, DateTime now, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> IsWorkOrderCancelledAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken) => throw new NotSupportedException();

View file

@ -25,7 +25,7 @@ public class WorkOrderBoardConcurrencyTests
var mutationData = new WorkOrderBoardMutationDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
return new WorkOrderBoardUpdateService(boardData, mutationData, audit);
return WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
}
[Fact]

View file

@ -0,0 +1,255 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using Xunit;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// Cancelling a work order from the board (the lifecycle status PATCH the board and
/// slide-over use) cancels its pending uplift in the same action and commit, with an
/// audit entry of its own.
/// </summary>
public sealed class WorkOrderBoardPatchCancelUpliftTests
{
private static readonly byte[] Version = { 1, 0, 0, 0, 0, 0, 0, 1 };
private static async Task<WorkOrderBoardPatchRequestDto> CancelPatchAsync(ApplicationDbContext context)
{
var stored = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == 1);
return new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Canceled",
WorkOrderVersion = Convert.ToBase64String(stored.RowVersion ?? Version),
};
}
private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
return WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
}
// Saved in stages: the work order and its primary dispatch reference each other, which a
// relational provider cannot insert in one statement batch.
internal static async Task SeedWorkOrderAsync(ApplicationDbContext context)
{
context.Accounts.Add(new Accounts { Id = 1, Name = "Acme Corp", IsDeleted = false });
context.Users.Add(new ApplicationUser { Id = "actor-1", UserName = "actor-1" });
context.Users.Add(new ApplicationUser { Id = "dispatcher-1", UserName = "dispatcher-1" });
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
var workOrder = new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
ExternalWorkOrderId = "123",
LifecycleStatus = LifecycleStatus.Incomplete,
Status = "Incomplete",
AccountId = 1,
RowVersion = Version,
};
context.workOrders.Add(workOrder);
await context.SaveChangesAsync();
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
NTEAmount = 1900m,
DispatchNumber = "DIS-10",
Status = "Scheduled",
});
await context.SaveChangesAsync();
workOrder.PrimaryDispatchId = 10;
await context.SaveChangesAsync();
}
internal static DispatchUpliftRequest Uplift(int id, string status, decimal amount) => new()
{
Id = id,
DispatchId = 10,
CurrentNTE = 1000m,
RequestedNTE = amount,
Status = status,
RequiredTier = status == "NoApprovalRequired" ? 0 : 1,
NotificationStatus = "Sent",
createdby = "dispatcher-1",
CreatedDate = DateTime.UtcNow.AddHours(-id),
};
[Fact]
public async Task PatchToCanceled_CancelsPendingUpliftWithItsOwnAuditAndLeavesDecidedUpliftsAlone()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using (var seed = new ApplicationDbContext(options))
{
await SeedWorkOrderAsync(seed);
seed.DispatchUpliftRequests.AddRange(
Uplift(100, "Pending", 700m),
Uplift(101, "Approved", 600m),
Uplift(102, "NoApprovalRequired", 300m),
Uplift(103, "Rejected", 900m));
await seed.SaveChangesAsync();
}
await using (var context = new ApplicationDbContext(options))
{
var row = await NewUpdateService(context).PatchFieldAsync(1, await CancelPatchAsync(context), "actor-1");
Assert.Equal(LifecycleStatus.Canceled, row.LifecycleStatus);
}
await using var verify = new ApplicationDbContext(options);
Assert.Equal(LifecycleStatus.Canceled, verify.workOrders.Single().LifecycleStatus);
var cancelled = verify.DispatchUpliftRequests.Single(u => u.Id == 100);
Assert.Equal(UpliftStatus.Withdrawn, cancelled.Status);
Assert.Equal("actor-1", cancelled.DecidedByUserId);
Assert.NotNull(cancelled.DecidedAt);
Assert.Equal(UpliftStatus.Approved, verify.DispatchUpliftRequests.Single(u => u.Id == 101).Status);
Assert.Equal(UpliftStatus.NoApprovalRequired, verify.DispatchUpliftRequests.Single(u => u.Id == 102).Status);
Assert.Equal(UpliftStatus.Rejected, verify.DispatchUpliftRequests.Single(u => u.Id == 103).Status);
Assert.Equal(1900m, verify.Dispatches.Single().NTEAmount);
var upliftAudit = Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
Assert.Equal(1, upliftAudit.WorkOrderId);
Assert.Equal("actor-1", upliftAudit.UserId);
Assert.Equal("Dispatch DIS-10 Uplift", upliftAudit.FieldName);
Assert.Equal(UpliftStatus.Pending, upliftAudit.OldValue);
Assert.Equal(UpliftStatus.Withdrawn, upliftAudit.NewValue);
var statusAudit = Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "StatusChanged");
Assert.Equal(LifecycleStatus.Canceled.ToString(), statusAudit.NewValue);
Assert.NotEqual(upliftAudit.Id, statusAudit.Id);
}
[Fact]
public async Task PatchToOtherStatus_LeavesPendingUpliftPending()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using (var seed = new ApplicationDbContext(options))
{
await SeedWorkOrderAsync(seed);
seed.DispatchUpliftRequests.Add(Uplift(100, "Pending", 700m));
await seed.SaveChangesAsync();
}
await using (var context = new ApplicationDbContext(options))
{
var patch = await CancelPatchAsync(context);
patch.Value = "Pending";
await NewUpdateService(context).PatchFieldAsync(1, patch, "actor-1");
}
await using var verify = new ApplicationDbContext(options);
Assert.Equal(LifecycleStatus.Pending, verify.workOrders.Single().LifecycleStatus);
Assert.Equal(UpliftStatus.Pending, verify.DispatchUpliftRequests.Single().Status);
Assert.DoesNotContain(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
}
[Fact]
public async Task PatchToCanceled_FailureAfterTheWrites_RollsBackWorkOrderAndUpliftTogether()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var failAfterSave = new FailAfterSaveInterceptor();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.AddInterceptors(failAfterSave)
.Options;
await using (var seed = new SqliteRowVersionDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
await SeedWorkOrderAsync(seed);
seed.DispatchUpliftRequests.Add(Uplift(100, "Pending", 700m));
await seed.SaveChangesAsync();
}
failAfterSave.Armed = true;
await using (var context = new SqliteRowVersionDbContext(options))
{
var patch = await CancelPatchAsync(context);
await Assert.ThrowsAsync<InvalidOperationException>(
() => NewUpdateService(context).PatchFieldAsync(1, patch, "actor-1"));
}
// The failing save did write both the cancelled work order and the withdrawn
// uplift inside the transaction; the failure after it must undo both.
Assert.True(failAfterSave.SawWithdrawnUpliftInSave);
failAfterSave.Armed = false;
await using var verify = new SqliteRowVersionDbContext(options);
Assert.Equal(LifecycleStatus.Incomplete, (await verify.workOrders.AsNoTracking().SingleAsync()).LifecycleStatus);
Assert.Equal(UpliftStatus.Pending, (await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync()).Status);
Assert.False(await verify.WorkOrderAuditLogs.AnyAsync());
}
private sealed class FailAfterSaveInterceptor : SaveChangesInterceptor
{
public bool Armed { get; set; }
public bool SawWithdrawnUpliftInSave { get; private set; }
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (Armed && eventData.Context is not null)
{
SawWithdrawnUpliftInSave = eventData.Context.ChangeTracker
.Entries<DispatchUpliftRequest>()
.Any(entry => entry.State == EntityState.Modified
&& entry.Entity.Status == UpliftStatus.Withdrawn);
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
public override ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
if (Armed)
throw new InvalidOperationException("Simulated failure after the rows were written");
return base.SavedChangesAsync(eventData, result, cancellationToken);
}
}
internal sealed class SqliteRowVersionDbContext : ApplicationDbContext
{
public SqliteRowVersionDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// SQLite has no rowversion type; store the seeded blobs as plain values.
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch), typeof(DispatchUpliftRequest) })
{
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
}

View file

@ -22,9 +22,8 @@ public class WorkOrderBoardPatchLifecycleRulesTests
var context = new ApplicationDbContext(options);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
var service = WorkOrderAccountTestHelpers.BoardUpdateService(
context,
audit);
return (context, service);
}

View file

@ -23,7 +23,7 @@ public class WorkOrderBoardUpdateServiceTests
var mutationData = new WorkOrderBoardMutationDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
var service = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
return (context, service);
}

View file

@ -22,9 +22,8 @@ public class WorkOrderCompletedSelectiveLockTests
var context = new ApplicationDbContext(options);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
var service = WorkOrderAccountTestHelpers.BoardUpdateService(
context,
audit);
return (context, service);
}

View file

@ -186,9 +186,8 @@ public class WorkOrderCompletionFreezeTests
private static WorkOrderBoardUpdateService CreateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
return WorkOrderAccountTestHelpers.BoardUpdateService(
context,
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
}

View file

@ -0,0 +1,381 @@
using System.Data.Common;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// A work order cancelled by the CRM (webhook or reconciliation, both persisted by the
/// webhook data service, or the legacy ingest) cancels its pending uplift in the same save,
/// with an audit entry of its own, exactly as a cancel from the board does.
/// </summary>
public sealed class WorkOrderCrmCancelUpliftTests
{
private static readonly DateTimeOffset ProcessedAt = new(2026, 7, 24, 12, 1, 0, TimeSpan.Zero);
private static WorkOrderWebhookMutation CrmMutation(string eventType, bool cancelled) => new()
{
DeliveryId = $"delivery-{eventType}",
EventType = eventType,
OccurredAt = ProcessedAt.AddMinutes(-1),
UpdatedAt = ProcessedAt.AddMinutes(-1),
ProcessedAt = ProcessedAt,
BodySha256 = $"hash-{eventType}",
VersionHash = $"hash-{eventType}",
ExternalWorkOrderId = "123",
WorkerOrderNumber = "00000000123",
Source = "procurement",
IsStateEvent = true,
Customer = "Acme Corp",
Status = "Open",
IsCancelled = cancelled,
LifecycleStatus = cancelled ? LifecycleStatus.Canceled : LifecycleStatus.Pending,
};
private static async Task<DbContextOptions<ApplicationDbContext>> SeedInMemoryAsync(
params DispatchUpliftRequest[] uplifts)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var seed = new ApplicationDbContext(options);
await WorkOrderBoardPatchCancelUpliftTests.SeedWorkOrderAsync(seed);
seed.DispatchUpliftRequests.AddRange(uplifts);
await seed.SaveChangesAsync();
return options;
}
[Fact]
public async Task CrmCancel_CancelsPendingUpliftWithItsOwnSyncAuditAndLeavesDecidedUpliftsAlone()
{
var options = await SeedInMemoryAsync(
WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m),
WorkOrderBoardPatchCancelUpliftTests.Uplift(101, "Approved", 600m),
WorkOrderBoardPatchCancelUpliftTests.Uplift(102, "NoApprovalRequired", 300m),
WorkOrderBoardPatchCancelUpliftTests.Uplift(103, "Rejected", 900m));
await using (var context = new ApplicationDbContext(options))
{
var result = await new WorkOrderWebhookDataService(context)
.ApplyAsync(CrmMutation("work_order.cancelled", cancelled: true), CancellationToken.None);
Assert.Equal(WorkOrderWebhookPersistenceStatus.Applied, result.Status);
}
await using var verify = new ApplicationDbContext(options);
Assert.Equal(LifecycleStatus.Canceled, verify.workOrders.Single().LifecycleStatus);
var cancelled = verify.DispatchUpliftRequests.Single(u => u.Id == 100);
Assert.Equal(UpliftStatus.Withdrawn, cancelled.Status);
Assert.Equal(ProcessedAt.UtcDateTime, cancelled.DecidedAt);
Assert.Null(cancelled.DecidedByUserId);
Assert.Equal(UpliftStatus.Approved, verify.DispatchUpliftRequests.Single(u => u.Id == 101).Status);
Assert.Equal(UpliftStatus.NoApprovalRequired, verify.DispatchUpliftRequests.Single(u => u.Id == 102).Status);
Assert.Equal(UpliftStatus.Rejected, verify.DispatchUpliftRequests.Single(u => u.Id == 103).Status);
Assert.Equal(1900m, verify.Dispatches.Single().NTEAmount);
var upliftAudit = Assert.Single(verify.WorkOrderAuditLogs);
Assert.Equal("uplift_cancel", upliftAudit.Action);
Assert.Equal(1, upliftAudit.WorkOrderId);
Assert.Equal(AuditActorType.Sync.ToString(), upliftAudit.ActorType);
Assert.Null(upliftAudit.UserId);
Assert.Equal("Dispatch DIS-10 Uplift", upliftAudit.FieldName);
Assert.Equal(UpliftStatus.Pending, upliftAudit.OldValue);
Assert.Equal(UpliftStatus.Withdrawn, upliftAudit.NewValue);
}
[Fact]
public async Task CrmCancel_WaitsForAnUpliftCreateInFlightAndCancelsWhatItCommitted()
{
var options = await SeedInMemoryAsync();
var createEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseCreate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// An uplift create holding the work order's lock, committing its Pending request
// only after the CRM cancel has started.
await using var createContext = new ApplicationDbContext(options);
var create = new UpliftDataService(createContext).ExecuteWorkOrderMutationAsync(1, async ct =>
{
createEntered.SetResult();
await releaseCreate.Task;
createContext.DispatchUpliftRequests.Add(WorkOrderBoardPatchCancelUpliftTests.Uplift(200, "Pending", 800m));
await createContext.SaveChangesAsync(ct);
return true;
}, CancellationToken.None);
await createEntered.Task;
await using var crmContext = new ApplicationDbContext(options);
var cancel = new WorkOrderWebhookDataService(crmContext)
.ApplyAsync(CrmMutation("work_order.cancelled", cancelled: true), CancellationToken.None);
var finishedFirst = await Task.WhenAny(cancel, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.NotSame(cancel, finishedFirst);
releaseCreate.SetResult();
await create;
await cancel;
await using var verify = new ApplicationDbContext(options);
Assert.Equal(LifecycleStatus.Canceled, verify.workOrders.Single().LifecycleStatus);
Assert.Equal(UpliftStatus.Withdrawn, verify.DispatchUpliftRequests.Single(u => u.Id == 200).Status);
Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
}
[Fact]
public async Task CrmUpdateThatDoesNotCancel_LeavesPendingUpliftPending()
{
var options = await SeedInMemoryAsync(
WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m));
await using (var context = new ApplicationDbContext(options))
{
await new WorkOrderWebhookDataService(context)
.ApplyAsync(CrmMutation("work_order.updated", cancelled: false), CancellationToken.None);
}
await using var verify = new ApplicationDbContext(options);
Assert.Equal(LifecycleStatus.Pending, verify.workOrders.Single().LifecycleStatus);
Assert.Equal(UpliftStatus.Pending, verify.DispatchUpliftRequests.Single().Status);
Assert.Empty(verify.WorkOrderAuditLogs);
}
[Fact]
public async Task ReconciliationCancel_CancelsPendingUplift()
{
var options = await SeedInMemoryAsync(
WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m));
await using (var context = new ApplicationDbContext(options))
{
var service = WorkOrderReconciliationTests.CreateService(
new[]
{
new ProcurementWorkOrder
{
WorkOrderId = "123",
WoStatus = "cancelled",
Customer = "Acme Corp",
UpdatedAt = ProcessedAt,
}
},
new WorkOrderWebhookDataService(context));
Assert.True(await service.RunPendingAsync(CancellationToken.None));
}
await using var verify = new ApplicationDbContext(options);
Assert.Equal(LifecycleStatus.Canceled, verify.workOrders.Single().LifecycleStatus);
Assert.Equal(UpliftStatus.Withdrawn, verify.DispatchUpliftRequests.Single().Status);
var upliftAudit = Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
Assert.Equal(AuditActorType.Sync.ToString(), upliftAudit.ActorType);
}
[Fact]
public async Task LegacyIngestCancel_CancelsPendingUpliftWithItsOwnSyncAuditAndLeavesDecidedUpliftsAlone()
{
var options = await SeedInMemoryAsync(
WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m),
WorkOrderBoardPatchCancelUpliftTests.Uplift(101, "Approved", 600m));
await using (var context = new ApplicationDbContext(options))
{
await NewIngestService(context).UpsertBatchAsync(new[]
{
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "123", WoStatus = "cancelled" }
});
}
await using var verify = new ApplicationDbContext(options);
Assert.Equal("Cancelled", verify.workOrders.Single().Status);
Assert.Equal(UpliftStatus.Withdrawn, verify.DispatchUpliftRequests.Single(u => u.Id == 100).Status);
Assert.Equal(UpliftStatus.Approved, verify.DispatchUpliftRequests.Single(u => u.Id == 101).Status);
var upliftAudit = Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
Assert.Equal(AuditActorType.Sync.ToString(), upliftAudit.ActorType);
Assert.Equal("Dispatch DIS-10 Uplift", upliftAudit.FieldName);
}
[Fact]
public async Task LegacyIngestUpdateThatDoesNotCancel_LeavesPendingUpliftPending()
{
var options = await SeedInMemoryAsync(
WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m));
await using (var context = new ApplicationDbContext(options))
{
await NewIngestService(context).UpsertBatchAsync(new[]
{
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "123", WoStatus = "in_progress" }
});
}
await using var verify = new ApplicationDbContext(options);
Assert.Equal(UpliftStatus.Pending, verify.DispatchUpliftRequests.Single().Status);
Assert.DoesNotContain(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
}
[Fact]
public async Task LegacyIngestCancel_WaitsForAnUpliftCreateInFlightAndCancelsWhatItCommitted()
{
var options = await SeedInMemoryAsync();
var createEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseCreate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var createContext = new ApplicationDbContext(options);
var create = new UpliftDataService(createContext).ExecuteWorkOrderMutationAsync(1, async ct =>
{
createEntered.SetResult();
await releaseCreate.Task;
createContext.DispatchUpliftRequests.Add(WorkOrderBoardPatchCancelUpliftTests.Uplift(200, "Pending", 800m));
await createContext.SaveChangesAsync(ct);
return true;
}, CancellationToken.None);
await createEntered.Task;
await using var ingestContext = new ApplicationDbContext(options);
var ingest = NewIngestService(ingestContext).UpsertBatchAsync(new[]
{
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "123", WoStatus = "cancelled" }
});
var finishedFirst = await Task.WhenAny(ingest, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.NotSame(ingest, finishedFirst);
releaseCreate.SetResult();
await create;
await ingest;
await using var verify = new ApplicationDbContext(options);
Assert.Equal("Cancelled", verify.workOrders.Single().Status);
Assert.Equal(UpliftStatus.Withdrawn, verify.DispatchUpliftRequests.Single(u => u.Id == 200).Status);
}
[Fact]
public async Task LegacyIngestCancel_OnARelationalStore_CancelsPendingUpliftInTheBatchTransaction()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using (var seed = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
await WorkOrderBoardPatchCancelUpliftTests.SeedWorkOrderAsync(seed);
seed.DispatchUpliftRequests.Add(WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m));
await seed.SaveChangesAsync();
}
await using (var context = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options))
{
await NewIngestService(context).UpsertBatchAsync(new[]
{
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "123", WoStatus = "cancelled" }
});
}
await using var verify = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options);
Assert.Equal("Cancelled", (await verify.workOrders.AsNoTracking().SingleAsync()).Status);
Assert.Equal(UpliftStatus.Withdrawn, (await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync()).Status);
Assert.Single(await verify.WorkOrderAuditLogs.Where(log => log.Action == "uplift_cancel").ToListAsync());
}
private static WorkOrderIngestService NewIngestService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
return new WorkOrderIngestService(
new WorkOrderIngestDataService(context),
new SyncFieldMergePolicy(fieldLocks),
fieldLocks,
audit,
WorkOrderAccountTestHelpers.Resolver(context));
}
[Fact]
public async Task CrmCancel_FailureBeforeCommit_RollsBackWorkOrderAndUpliftTogether()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var writes = new UpdateRecorder();
var failCommit = new FailCommitInterceptor();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.AddInterceptors(writes, failCommit)
.Options;
await using (var seed = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
await WorkOrderBoardPatchCancelUpliftTests.SeedWorkOrderAsync(seed);
seed.DispatchUpliftRequests.Add(WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m));
await seed.SaveChangesAsync();
}
writes.Armed = true;
failCommit.Armed = true;
await using (var context = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options))
{
await Assert.ThrowsAnyAsync<InvalidOperationException>(() => new WorkOrderWebhookDataService(context)
.ApplyAsync(CrmMutation("work_order.cancelled", cancelled: true), CancellationToken.None));
}
// The work order and uplift updates both ran inside the save's transaction before
// the commit failed; neither may survive it.
Assert.True(writes.UpdatedUplift);
Assert.True(writes.UpdatedWorkOrder);
writes.Armed = false;
failCommit.Armed = false;
await using var verify = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options);
Assert.Equal(LifecycleStatus.Incomplete, (await verify.workOrders.AsNoTracking().SingleAsync()).LifecycleStatus);
Assert.Equal(UpliftStatus.Pending, (await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync()).Status);
Assert.False(await verify.WorkOrderAuditLogs.AnyAsync());
}
private sealed class UpdateRecorder : DbCommandInterceptor
{
public bool Armed { get; set; }
public bool UpdatedUplift { get; private set; }
public bool UpdatedWorkOrder { get; private set; }
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
if (Armed && command.Transaction is not null)
{
UpdatedUplift |= command.CommandText.Contains("UPDATE \"DispatchUpliftRequests\"");
UpdatedWorkOrder |= command.CommandText.Contains("UPDATE \"workOrders\"");
}
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}
private sealed class FailCommitInterceptor : DbTransactionInterceptor
{
public bool Armed { get; set; }
public override ValueTask<InterceptionResult> TransactionCommittingAsync(
DbTransaction transaction,
TransactionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
{
if (Armed)
throw new InvalidOperationException("Simulated failure before the commit");
return base.TransactionCommittingAsync(transaction, eventData, result, cancellationToken);
}
}
}

View file

@ -68,9 +68,8 @@ public class WorkOrderOverdueTypeTests
private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
return WorkOrderAccountTestHelpers.BoardUpdateService(
context,
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
new ServicesRegistryService(new ServicesRegistryDataService(context)));
}

View file

@ -28,7 +28,7 @@ public class WorkOrderDocStatusPatchTests
var mutationData = new WorkOrderBoardMutationDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
var service = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
return (context, service);
}
@ -357,7 +357,7 @@ public class WorkOrderDetailServiceTests
var boardData = new WorkOrderBoardDataService(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
var update = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
await update.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{

View file

@ -595,9 +595,8 @@ public class WorkOrderPocServiceTests
private static WorkOrderBoardUpdateService CreateUpdateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
return WorkOrderAccountTestHelpers.BoardUpdateService(
context,
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
}

View file

@ -216,6 +216,16 @@ public sealed class WorkOrderReconciliationTests
Assert.Equal("winner", (await context.workOrders.SingleAsync()).WorkerOrderTitle);
}
internal static WorkOrderReconciliationService CreateService(
IReadOnlyList<ProcurementWorkOrder> workOrders,
IWorkOrderWebhookDataService data) =>
Create(
new MockClient(
new[] { new ProcurementPage<ProcurementWorkOrder>(workOrders, null) },
new()),
data,
new RecordingJobs());
private static WorkOrderReconciliationService Create(
IProcurementWorkOrderClient client,
IWorkOrderWebhookDataService workOrders,

View file

@ -59,9 +59,8 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests
private static WorkOrderBoardUpdateService NewBoardUpdateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
return WorkOrderAccountTestHelpers.BoardUpdateService(
context,
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
}

View file

@ -608,6 +608,26 @@ public sealed class WorkOrderUpliftServiceTests
Assert.Equal(1500m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
}
[Fact]
public async Task CreateAsync_WorkOrderCancelledBySyncStatusText_IsRefusedAndCreatesNothing()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context);
// The legacy ingest cancels a work order by its status text alone.
workOrder.Status = "Cancelled";
await context.SaveChangesAsync();
var service = NewService(context);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "After cancel" },
Dispatcher(),
CancellationToken.None));
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
}
[Theory]
[InlineData(LifecycleStatus.Completed)]
[InlineData(LifecycleStatus.Canceled)]