mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 19:32:10 +00:00
Compare commits
31 commits
7076f4acf3
...
ccbd739cfe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccbd739cfe | ||
|
|
d489cfee13 | ||
|
|
d3b6ba01c7 | ||
|
|
74d5aa17eb | ||
|
|
1077d489a8 | ||
|
|
e941e055b7 | ||
|
|
c985e9423d | ||
|
|
de0e767930 | ||
|
|
63465cc083 | ||
|
|
a32471d4c0 | ||
|
|
0298fc75bd | ||
|
|
498f49a2d8 | ||
|
|
987ec455f2 | ||
|
|
77dc3d85c3 | ||
|
|
cc46950254 | ||
|
|
b7be07411e | ||
|
|
306ab159cc | ||
|
|
e993e1b5fc | ||
|
|
99499c3281 | ||
|
|
9bad363930 | ||
|
|
ca9322fa60 | ||
|
|
2f23f6fadc | ||
|
|
35e79a276d | ||
|
|
5eb1323419 | ||
|
|
f0dcba63fd | ||
|
|
1277642ede | ||
|
|
fe5f27c8e1 | ||
|
|
613bfa48d8 | ||
|
|
57af8a1206 | ||
|
|
a6dd40b972 | ||
|
|
77a10e38ca |
66 changed files with 3374 additions and 335 deletions
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/deploy.yaml
vendored
2
.github/workflows/deploy.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs
Normal file
138
Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =>
|
||||
{
|
||||
|
|
|
|||
97
SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs
Normal file
97
SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs
Normal file
102
SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>());
|
||||
|
||||
|
|
|
|||
130
SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs
Normal file
130
SeaHaven.Services/Helpers/InMemoryPasswordResetThrottle.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
109
SeaHaven.Services/Implementation/SessionStampService.cs
Normal file
109
SeaHaven.Services/Implementation/SessionStampService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
13
SeaHaven.Services/Interfaces/IPasswordResetEmailQueue.cs
Normal file
13
SeaHaven.Services/Interfaces/IPasswordResetEmailQueue.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
30
SeaHaven.Services/Interfaces/IPasswordResetThrottle.cs
Normal file
30
SeaHaven.Services/Interfaces/IPasswordResetThrottle.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
20
SeaHaven.Services/Interfaces/ISessionStampCache.cs
Normal file
20
SeaHaven.Services/Interfaces/ISessionStampCache.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
21
SeaHaven.Services/Interfaces/ISessionStampService.cs
Normal file
21
SeaHaven.Services/Interfaces/ISessionStampService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
269
SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs
Normal file
269
SeaHavenIndustries.Tests/PasswordResetAbuseLimitsTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
259
SeaHavenIndustries.Tests/SessionRevocationTests.cs
Normal file
259
SeaHavenIndustries.Tests/SessionRevocationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
280
SeaHavenIndustries.Tests/SessionTestHost.cs
Normal file
280
SeaHavenIndustries.Tests/SessionTestHost.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
255
SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs
Normal file
255
SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
381
SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs
Normal file
381
SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue