mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Merge remote-tracking branch 'origin/main' into fix/ab/sh-407-cancel-wo-cancels-uplift
This commit is contained in:
commit
e941e055b7
44 changed files with 7548 additions and 197 deletions
|
|
@ -232,35 +232,45 @@ public class AuthenticationControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_Found_ReturnsCheckEmailMessage()
|
||||
public async Task ForgetPassword_ReturnsTheSameSuccessWhetherOrNotTheEmailIsRegistered()
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.ForgetPassword("a@b.com", CancellationToken.None);
|
||||
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 ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var ok = registered.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var response = ok.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Status.Should().Be("Success ");
|
||||
response.Message.Should().Be("Please check your email for code");
|
||||
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
|
||||
Json(unregistered.Should().BeOfType<OkObjectResult>().Subject.Value).Should().Be(Json(ok.Value));
|
||||
service.Verify(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>()), Times.Once);
|
||||
service.Verify(s => s.ForgetPasswordAsync("x@y.com", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_NotFound_ReturnsNoSuchEmailMessage()
|
||||
public async Task ForgetPassword_WhenServiceThrows_StillAnswersTheSameSuccessAndLogsNoDetail()
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("SECRET a@b.com"));
|
||||
var logger = new Mock<ILogger<AuthenticationController>>();
|
||||
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
|
||||
var controller = new AuthenticationController(service.Object, logger.Object);
|
||||
|
||||
var controller = NewController(service);
|
||||
var result = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None);
|
||||
|
||||
var result = await controller.ForgetPassword("x@y.com", CancellationToken.None);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Status.Should().Be("Error");
|
||||
response.Message.Should().Be("No such email is registered");
|
||||
var response = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
|
||||
logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((state, _) => !state.ToString()!.Contains("a@b.com") && !state.ToString()!.Contains("SECRET")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -269,11 +279,11 @@ public class AuthenticationControllerTests
|
|||
public async Task VerificationCode_MapsServiceResult(bool matched, string expectedStatus, string expectedMessage)
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny<CancellationToken>())).ReturnsAsync(matched);
|
||||
service.Setup(s => s.VerifyCodeAsync("a@b.com", "123456", It.IsAny<CancellationToken>())).ReturnsAsync(matched);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.VerificationCode("123456", CancellationToken.None);
|
||||
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "123456" }, null, null, CancellationToken.None);
|
||||
|
||||
if (matched)
|
||||
{
|
||||
|
|
@ -291,6 +301,33 @@ public class AuthenticationControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationCode_QueryOnlyCode_PassesNoEmailToTheService()
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.VerificationCode(null, null, "123456", 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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationCode_WhenServiceThrows_AnswersTheGenericWrongCodeError()
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
service.Setup(s => s.VerifyCodeAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.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);
|
||||
|
||||
Json(result.Should().BeOfType<BadRequestObjectResult>().Subject.Value)
|
||||
.Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Moq;
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
|
@ -25,7 +26,9 @@ public class AuthenticationServiceTests
|
|||
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);
|
||||
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, TimeProvider.System, sessionStamps);
|
||||
}
|
||||
|
||||
private static JwtOptions JwtOptions => new()
|
||||
|
|
@ -99,31 +102,36 @@ public class AuthenticationServiceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_RegisteredEmail_ReplacesCodeAndSendsEmail()
|
||||
public async Task ForgetPassword_RegisteredEmail_StoresOnlyASaltedHashAndEmailsTheCode()
|
||||
{
|
||||
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>();
|
||||
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; })
|
||||
.Returns(Task.CompletedTask);
|
||||
email.Setup(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.IsAny<string>()))
|
||||
.Callback<string, string, string>((_, _, b) => body = b)
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var service = NewService(userData, forget, email, out _, out _);
|
||||
var before = DateTime.UtcNow;
|
||||
|
||||
var found = await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
|
||||
found.Should().BeTrue();
|
||||
forget.Verify(
|
||||
f => f.ReplaceCodeAsync(
|
||||
user.Email!,
|
||||
user.Id,
|
||||
It.Is<string>(code => code.Length == 6 && code.All(char.IsDigit)),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is<string>(b => b.Contains("Your Password Reset Code is:"))), Times.Once);
|
||||
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();
|
||||
expires.Should().BeCloseTo(before.AddMinutes(15), TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_UnknownEmail_DoesNotEmailOrStoreCode()
|
||||
public async Task ForgetPassword_UnknownEmail_SendsNothingButStillDoesTheDatabaseRoundTrip()
|
||||
{
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
|
||||
|
|
@ -132,40 +140,90 @@ public class AuthenticationServiceTests
|
|||
|
||||
var service = NewService(userData, forget, email, out _, out _);
|
||||
|
||||
var found = await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
|
||||
await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_DeletedAccount_IsTreatedAsUnregistered()
|
||||
{
|
||||
var userData = new Mock<IUserDataService>();
|
||||
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 service = NewService(userData, forget, email, out _, out _);
|
||||
|
||||
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||
|
||||
found.Should().BeFalse();
|
||||
forget.Verify(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task VerifyCode_ForwardsDataServiceResult(bool exists)
|
||||
[InlineData(null, "123456")]
|
||||
[InlineData("", "123456")]
|
||||
[InlineData(" ", "123456")]
|
||||
[InlineData("a@b.com", null)]
|
||||
[InlineData("a@b.com", "")]
|
||||
public async Task VerifyCode_WithoutEmailOrCode_FailsWithoutTouchingStoredCodes(string? emailAddress, string? code)
|
||||
{
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny<CancellationToken>())).ReturnsAsync(exists);
|
||||
var forget = new Mock<IForgetPasswordDataService>(MockBehavior.Strict);
|
||||
|
||||
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out _, out _);
|
||||
|
||||
var result = await service.VerifyCodeAsync("abc", CancellationToken.None);
|
||||
var result = await service.VerifyCodeAsync(emailAddress, code, CancellationToken.None);
|
||||
|
||||
result.Should().Be(exists);
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetPassword_NoMatchingCode_ReturnsFalseWithoutReset()
|
||||
public async Task ResetPassword_NoPendingCodeForEmail_ReturnsFalseWithoutReset()
|
||||
{
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||
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 result = await service.ResetPasswordAsync("a@b.com", "999", "new", CancellationToken.None);
|
||||
var result = await service.ResetPasswordAsync("a@b.com", "999999", "new", CancellationToken.None);
|
||||
|
||||
result.Should().BeFalse();
|
||||
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
forget.Verify(f => f.TryConsumeAttemptAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[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 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 result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None);
|
||||
|
||||
result.Should().BeFalse();
|
||||
forget.Verify(f => f.RemoveIssuedThroughAsync("a@b.com", 7, It.IsAny<CancellationToken>()), Times.Once);
|
||||
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("123456", "123456", true)]
|
||||
[InlineData("123456", " 123456 ", true)]
|
||||
[InlineData("123456", "123457", false)]
|
||||
public void ResetCodeHash_IsSaltedAndComparedByValue(string issued, string candidate, bool expected)
|
||||
{
|
||||
var salt = PasswordResetCodeSecrets.NewSalt();
|
||||
var hash = PasswordResetCodeSecrets.Hash(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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using Moq;
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
|
@ -132,7 +133,9 @@ 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>());
|
||||
Mock.Of<IEmailSender>(),
|
||||
TimeProvider.System,
|
||||
Mock.Of<ISessionStampService>());
|
||||
|
||||
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
|
||||
|
||||
|
|
@ -169,10 +172,19 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
|
|||
{
|
||||
var user = await CreateUserAsync();
|
||||
var forget = new Mock<IForgetPasswordDataService>();
|
||||
forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
var salt = PasswordResetCodeSecrets.NewSalt();
|
||||
forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" });
|
||||
.ReturnsAsync(new ForgetPasswordCode
|
||||
{
|
||||
Id = 7,
|
||||
Email = user.Email!,
|
||||
UserId = user.Id,
|
||||
CodeSalt = salt,
|
||||
CodeHash = PasswordResetCodeSecrets.Hash(salt, "123456"),
|
||||
ExpiresAtUtc = DateTime.UtcNow.AddMinutes(10)
|
||||
});
|
||||
forget.Setup(f => f.TryConsumeAttemptAsync(7, It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
var service = NewAuthenticationService(forget);
|
||||
|
||||
var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None);
|
||||
|
|
@ -180,6 +192,7 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
|
|||
reset.Should().BeFalse();
|
||||
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
|
||||
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
forget.Verify(f => f.RefundAttemptAsync(7, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<IdentityError>> ValidateAsync(ApplicationUser user, string password)
|
||||
|
|
@ -208,7 +221,9 @@ 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>());
|
||||
Mock.Of<IEmailSender>(),
|
||||
TimeProvider.System,
|
||||
Mock.Of<ISessionStampService>());
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
276
Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs
Normal file
276
Api.SeaHavenIndustries.Tests/UpliftRevokeEndpointRulesTests.cs
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Revoke rules exercised end to end through both revoke endpoints (the Uplift
|
||||
/// Approvals route and the work-order route) against the real services, so a
|
||||
/// refusal is observed as the HTTP result and the unchanged stored state.
|
||||
/// </summary>
|
||||
public sealed class UpliftRevokeEndpointRulesTests
|
||||
{
|
||||
private const int WorkOrderId = 1;
|
||||
private const int DispatchId = 10;
|
||||
private const int AutoApprovedId = 100;
|
||||
private const int AdminApprovedId = 101;
|
||||
|
||||
public enum RevokeRoute
|
||||
{
|
||||
UpliftApprovals,
|
||||
WorkOrder,
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal OrgWideUser(string userId, string role) =>
|
||||
new(new ClaimsIdentity(
|
||||
new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||
new Claim(ClaimTypes.Role, role),
|
||||
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
|
||||
},
|
||||
"test"));
|
||||
|
||||
private static async Task SeedAsync(ApplicationDbContext context)
|
||||
{
|
||||
context.Accounts.Add(new Accounts { Id = 1, Name = "Acme Corp", IsDeleted = false });
|
||||
context.Users.Add(new ApplicationUser { Id = "admin-1", UserName = "admin-1", FirstName = "Ada", LastName = "Admin" });
|
||||
context.Users.Add(new ApplicationUser { Id = "dispatcher-1", UserName = "dispatcher-1", FirstName = "Dee", LastName = "Dispatcher" });
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
Id = DispatchId,
|
||||
VendorId = 1,
|
||||
WorkOrderId = WorkOrderId,
|
||||
NTEAmount = 2200m,
|
||||
DispatchNumber = "DIS-10",
|
||||
Status = "Scheduled",
|
||||
});
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = WorkOrderId,
|
||||
InternalWONumber = "10000000001",
|
||||
PrimaryDispatchId = DispatchId,
|
||||
AccountId = 1,
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
});
|
||||
// The admin filed this one themselves and it auto-approved within the allowance.
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
Id = AutoApprovedId,
|
||||
DispatchId = DispatchId,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 400m,
|
||||
Status = UpliftStatus.NoApprovalRequired,
|
||||
RequiredTier = 0,
|
||||
NotificationStatus = "Sent",
|
||||
createdby = "admin-1",
|
||||
CreatedDate = DateTime.UtcNow.AddHours(-2),
|
||||
});
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
Id = AdminApprovedId,
|
||||
DispatchId = DispatchId,
|
||||
CurrentNTE = 1400m,
|
||||
RequestedNTE = 800m,
|
||||
Status = UpliftStatus.Approved,
|
||||
RequiredTier = 1,
|
||||
NotificationStatus = "Sent",
|
||||
createdby = "dispatcher-1",
|
||||
CreatedDate = DateTime.UtcNow.AddHours(-1),
|
||||
DecidedAt = DateTime.UtcNow.AddMinutes(-30),
|
||||
DecidedByUserId = "admin-1",
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) =>
|
||||
new(
|
||||
new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
new WorkOrderDetailDataService(context),
|
||||
new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context)),
|
||||
new UserDataService(context),
|
||||
new TeamPermissionOverrideDataService(context),
|
||||
new TeamPermissionPolicy(),
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()));
|
||||
|
||||
private static async Task<IActionResult> RevokeAsync(
|
||||
ApplicationDbContext context,
|
||||
RevokeRoute route,
|
||||
ClaimsPrincipal user,
|
||||
int upliftId,
|
||||
string? reason)
|
||||
{
|
||||
var workOrderFlow = NewWorkOrderUpliftService(context);
|
||||
var httpContext = new DefaultHttpContext { User = user };
|
||||
|
||||
if (route == RevokeRoute.WorkOrder)
|
||||
{
|
||||
var controller = new WorkOrderDetailController(
|
||||
Mock.Of<IWorkOrderDetailService>(),
|
||||
Mock.Of<IWorkOrderCommentService>(),
|
||||
workOrderFlow,
|
||||
Mock.Of<ILogger<WorkOrderDetailController>>())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = httpContext },
|
||||
};
|
||||
return await controller.RevokeUplift(
|
||||
WorkOrderId,
|
||||
upliftId,
|
||||
new RevokeWorkOrderUpliftRequestDto { Reason = reason },
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
var upliftService = new UpliftService(
|
||||
new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
Mock.Of<IVendorDocumentStoragePort>(),
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
|
||||
workOrderFlow);
|
||||
var approvals = new UpliftController(upliftService, Mock.Of<ILogger<UpliftController>>())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = httpContext },
|
||||
};
|
||||
return await approvals.Revoke(
|
||||
upliftId,
|
||||
new UpliftController.DecisionRequest { Note = reason },
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task AssertUnchangedAsync(ApplicationDbContext context, int upliftId, string status)
|
||||
{
|
||||
var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
|
||||
stored.Status.Should().Be(status);
|
||||
stored.DecisionNote.Should().BeNull();
|
||||
(await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(2200m);
|
||||
(await context.WorkOrderAuditLogs.AsNoTracking().AnyAsync(a => a.Action == "uplift_revoke")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkOrderRoute_AdminRevokingAutoApprovedUpliftTheyRequested_IsForbiddenAndChangesNothing()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedAsync(context);
|
||||
|
||||
var result = await RevokeAsync(
|
||||
context,
|
||||
RevokeRoute.WorkOrder,
|
||||
OrgWideUser("admin-1", "Admin"),
|
||||
AutoApprovedId,
|
||||
"Wrong quote attached");
|
||||
|
||||
var refused = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
refused.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
refused.Value.Should().BeOfType<Response>().Which.Message
|
||||
.Should().StartWith("You are not authorized to perform this action");
|
||||
await AssertUnchangedAsync(context, AutoApprovedId, UpliftStatus.NoApprovalRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApprovalsRoute_AdminRevokingAutoApprovedUplift_IsRefusedAndChangesNothing()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedAsync(context);
|
||||
|
||||
var result = await RevokeAsync(
|
||||
context,
|
||||
RevokeRoute.UpliftApprovals,
|
||||
OrgWideUser("admin-1", "Admin"),
|
||||
AutoApprovedId,
|
||||
"Wrong quote attached");
|
||||
|
||||
var refused = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
refused.Value.Should().BeOfType<Response>().Which.Message
|
||||
.Should().StartWith("This uplift request cannot be revoked");
|
||||
await AssertUnchangedAsync(context, AutoApprovedId, UpliftStatus.NoApprovalRequired);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RevokeRoute.UpliftApprovals)]
|
||||
[InlineData(RevokeRoute.WorkOrder)]
|
||||
public async Task AdminRevokingAdminApprovedUpliftWithReason_Succeeds(RevokeRoute route)
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedAsync(context);
|
||||
|
||||
var result = await RevokeAsync(
|
||||
context,
|
||||
route,
|
||||
OrgWideUser("admin-1", "Admin"),
|
||||
AdminApprovedId,
|
||||
" Approved against the wrong quote ");
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == AdminApprovedId);
|
||||
stored.Status.Should().Be(UpliftStatus.Revoked);
|
||||
stored.DecisionNote.Should().Be("Approved against the wrong quote");
|
||||
(await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(1400m);
|
||||
var audit = await context.WorkOrderAuditLogs.AsNoTracking().SingleAsync(a => a.Action == "uplift_revoke");
|
||||
audit.OldValue.Should().Be(UpliftStatus.Approved);
|
||||
audit.NewValue.Should().Be(UpliftStatus.Revoked);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RevokeRoute.UpliftApprovals)]
|
||||
[InlineData(RevokeRoute.WorkOrder)]
|
||||
public async Task AdminRevokingAdminApprovedUpliftWithoutReason_IsRefused(RevokeRoute route)
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedAsync(context);
|
||||
|
||||
var result = await RevokeAsync(context, route, OrgWideUser("admin-1", "Admin"), AdminApprovedId, " ");
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
await AssertUnchangedAsync(context, AdminApprovedId, UpliftStatus.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkOrderRoute_DispatcherRevokingOwnAutoApprovedUpliftWithoutReason_Succeeds()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedAsync(context);
|
||||
var own = await context.DispatchUpliftRequests.SingleAsync(u => u.Id == AutoApprovedId);
|
||||
own.createdby = "dispatcher-1";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await RevokeAsync(
|
||||
context,
|
||||
RevokeRoute.WorkOrder,
|
||||
OrgWideUser("dispatcher-1", "Dispatcher"),
|
||||
AutoApprovedId,
|
||||
null);
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == AutoApprovedId);
|
||||
stored.Status.Should().Be(UpliftStatus.Revoked);
|
||||
stored.DecisionNote.Should().BeNull();
|
||||
(await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(1000m);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
using Api.SeaHavenIndustries.DTOs;
|
||||
using Api.SeaHavenIndustries.Helper;
|
||||
using Api.SeaHavenIndustries.Infrastructure;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
|
|
@ -108,30 +112,47 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
|
||||
#region Forget Password Area
|
||||
|
||||
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.
|
||||
[AllowAnonymous]
|
||||
[HttpPost()]
|
||||
[Route("ForgetPassword")]
|
||||
public async Task<IActionResult> ForgetPassword(string Email, CancellationToken cancellationToken)
|
||||
{
|
||||
var found = await _authenticationService.ForgetPasswordAsync(Email, cancellationToken);
|
||||
if (found)
|
||||
{
|
||||
return Ok(new Response { Status = "Success ", Message = "Please check your email for code" });
|
||||
}
|
||||
else
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = "No such email is registered" });
|
||||
}
|
||||
}
|
||||
//need email and code
|
||||
[AllowAnonymous]
|
||||
[HttpPost()]
|
||||
[Route("VerificationCode")]
|
||||
public async Task<IActionResult> VerificationCode(string code, CancellationToken cancellationToken)
|
||||
[EnableRateLimiting(PasswordResetRateLimiting.ForgetPasswordPolicy)]
|
||||
public async Task<IActionResult> ForgetPassword(
|
||||
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ForgetPasswordRequest_Dto? body,
|
||||
[FromQuery(Name = "Email")] string? email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await _authenticationService.VerifyCodeAsync(code, cancellationToken))
|
||||
await _authenticationService.ForgetPasswordAsync(body?.Email ?? email, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Same answer as success: a failure only a registered address can hit
|
||||
// must not reveal that the address is registered.
|
||||
LogResetFailure(ex);
|
||||
}
|
||||
|
||||
return Ok(new Response { Status = "Success ", Message = ForgetPasswordMessage });
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost()]
|
||||
[Route("VerificationCode")]
|
||||
[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))
|
||||
{
|
||||
|
||||
return Ok(new Response { Status = "Success ", Message = "Code Matched" });
|
||||
|
|
@ -143,14 +164,15 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
LogResetFailure(ex);
|
||||
return BadRequest(new Response { Status = "Error", Message = "Code Not Matched" });
|
||||
}
|
||||
}
|
||||
|
||||
// need email, password and code
|
||||
[AllowAnonymous]
|
||||
[HttpPost()]
|
||||
[Route("ResetPassword")]
|
||||
[EnableRateLimiting(PasswordResetRateLimiting.ResetPasswordPolicy)]
|
||||
public async Task<IActionResult> ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
|
|
@ -166,11 +188,21 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
|
||||
LogResetFailure(ex);
|
||||
return BadRequest(new Response { Status = "Error", Message = "Your email or code not found please check" });
|
||||
}
|
||||
}
|
||||
|
||||
// These endpoints answer failures exactly like a wrong code or an unknown email, so
|
||||
// an error only a registered account can trigger reveals nothing. Exception
|
||||
// messages here can echo the email or code, so only the type is logged.
|
||||
private void LogResetFailure(Exception exception, [CallerMemberName] string operation = "")
|
||||
{
|
||||
_logger.LogError(
|
||||
"Password reset {Operation} failed with {ExceptionType}.",
|
||||
operation,
|
||||
exception.GetType().FullName);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,20 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
{
|
||||
public class ForgetPassword_Dto
|
||||
{
|
||||
public string Email { get; set; }
|
||||
public string? Email { get; set; }
|
||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
public string? Password { get; set; }
|
||||
public string? Code { get; set; }
|
||||
}
|
||||
|
||||
public class ForgetPasswordRequest_Dto
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
|
||||
public class VerificationCode_Dto
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
public string? Code { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
using System.Net;
|
||||
using System.Threading.RateLimiting;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Per-client limits on the anonymous password reset endpoints, plus the
|
||||
/// forwarded-header trust that makes "per client" mean the caller and not the proxy.
|
||||
/// </summary>
|
||||
public static class PasswordResetRateLimiting
|
||||
{
|
||||
public const string ForgetPasswordPolicy = "password-reset-request";
|
||||
public const string VerificationCodePolicy = "password-reset-verify";
|
||||
public const string ResetPasswordPolicy = "password-reset-confirm";
|
||||
|
||||
public const int PermitLimit = 10;
|
||||
public static readonly TimeSpan Window = TimeSpan.FromMinutes(15);
|
||||
|
||||
public const string RejectedMessage = "Too many requests. Please try again later.";
|
||||
|
||||
/// <summary>
|
||||
/// The API runs on Elastic Beanstalk behind an application load balancer and the
|
||||
/// instance's nginx, so every request reaches Kestrel from loopback. X-Forwarded-For
|
||||
/// is read right to left through loopback and private (VPC) hops only; the first
|
||||
/// public address is the client. Entries a caller writes further left are ignored.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPasswordResetRateLimiting(this IServiceCollection services)
|
||||
{
|
||||
services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
|
||||
options.ForwardLimit = null;
|
||||
options.KnownNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("127.0.0.0"), 8));
|
||||
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("10.0.0.0"), 8));
|
||||
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("172.16.0.0"), 12));
|
||||
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("192.168.0.0"), 16));
|
||||
options.KnownProxies.Add(IPAddress.IPv6Loopback);
|
||||
});
|
||||
|
||||
services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.OnRejected = async (context, cancellationToken) =>
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(
|
||||
new Response { Status = "Error", Message = RejectedMessage },
|
||||
cancellationToken);
|
||||
};
|
||||
|
||||
AddPerClientPolicy(options, ForgetPasswordPolicy);
|
||||
AddPerClientPolicy(options, VerificationCodePolicy);
|
||||
AddPerClientPolicy(options, ResetPasswordPolicy);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static string ClientPartitionKey(HttpContext context)
|
||||
{
|
||||
var address = context.Connection.RemoteIpAddress;
|
||||
if (address == null)
|
||||
return "unknown";
|
||||
return (address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address).ToString();
|
||||
}
|
||||
|
||||
private static void AddPerClientPolicy(RateLimiterOptions options, string policyName)
|
||||
{
|
||||
options.AddPolicy(policyName, context => RateLimitPartition.GetFixedWindowLimiter(
|
||||
ClientPartitionKey(context),
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = PermitLimit,
|
||||
Window = Window,
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -59,6 +56,7 @@ builder.Services.AddResponseCompression(opts =>
|
|||
opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
|
||||
new[] { "application/octet-stream" });
|
||||
});
|
||||
builder.Services.AddPasswordResetRateLimiting();
|
||||
builder.Services.AddCors(option =>
|
||||
option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
|
||||
|
||||
|
|
@ -143,27 +141,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 =>
|
||||
{
|
||||
|
|
@ -195,6 +173,9 @@ builder.Services.AddSwaggerGen(c =>
|
|||
|
||||
var app = builder.Build();
|
||||
|
||||
// First, so every later middleware and the rate limiter see the real client address.
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment() || app.Environment.IsProduction())
|
||||
{
|
||||
|
|
@ -212,6 +193,7 @@ app.UseStaticFiles();
|
|||
app.UseCors();
|
||||
app.UseMiddleware<LegacyDeprecationMiddleware>();
|
||||
app.UseRouting();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<SentryRequestMetadataMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
|
|
|||
4282
Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.Designer.cs
generated
Normal file
4282
Data.SeaHavenIndustries/Migrations/20260925153843_HashPasswordResetCodes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,70 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class HashPasswordResetCodes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Pending codes were stored in plaintext with no expiry. They are
|
||||
// short-lived, so they are discarded rather than migrated; affected users
|
||||
// request a new code.
|
||||
migrationBuilder.Sql("DELETE FROM [ForgetPasswordCodes];");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CodeHash",
|
||||
table: "ForgetPasswordCodes",
|
||||
type: "nvarchar(64)",
|
||||
maxLength: 64,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CodeSalt",
|
||||
table: "ForgetPasswordCodes",
|
||||
type: "nvarchar(32)",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ExpiresAtUtc",
|
||||
table: "ForgetPasswordCodes",
|
||||
type: "datetime2",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "FailedAttempts",
|
||||
table: "ForgetPasswordCodes",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CodeHash",
|
||||
table: "ForgetPasswordCodes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CodeSalt",
|
||||
table: "ForgetPasswordCodes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExpiresAtUtc",
|
||||
table: "ForgetPasswordCodes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FailedAttempts",
|
||||
table: "ForgetPasswordCodes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1527,10 +1527,26 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CodeHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("CodeSalt")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("FailedAttempts")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
|
@ -11,6 +12,23 @@ namespace Data.SeaHavenIndustries
|
|||
public int Id { get; set; }
|
||||
public string UserId { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Legacy plaintext column. It is always written empty; only
|
||||
/// <see cref="CodeHash"/> is compared.
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Lowercase hex SHA-256 of "<see cref="CodeSalt"/>:code".</summary>
|
||||
[MaxLength(64)]
|
||||
public string CodeHash { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(32)]
|
||||
public string CodeSalt { get; set; } = string.Empty;
|
||||
|
||||
public DateTime ExpiresAtUtc { get; set; }
|
||||
|
||||
/// <summary>Checks consumed against this code, including concurrent ones still in flight.</summary>
|
||||
public int FailedAttempts { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs
Normal file
26
SeaHaven.DataServices/Helpers/LiveUpliftStatus.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
namespace SeaHaven.DataServices.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Which stored uplift statuses still count as a live uplift on a work order.
|
||||
/// Cancelled (legacy "Cancelled", "Withdrawn", "Expired") and revoked uplifts do not;
|
||||
/// pending, approved, auto-approved and rejected ones do. The advanced-search
|
||||
/// "Has uplift" filter and the board Uplift column both use this, so they cannot drift.
|
||||
/// </summary>
|
||||
public static class LiveUpliftStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Stored statuses that are not live. Kept as an array so EF translates
|
||||
/// <c>NonLive.Contains(u.Status)</c> inside query predicates.
|
||||
/// </summary>
|
||||
public static readonly string[] NonLive =
|
||||
{
|
||||
"Withdrawn",
|
||||
"Cancelled",
|
||||
"Expired",
|
||||
"Revoked",
|
||||
};
|
||||
|
||||
public static bool IsLive(string? status) =>
|
||||
status == null || !NonLive.Contains(status, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -238,11 +238,13 @@ namespace SeaHaven.DataServices.Helpers
|
|||
CancellationToken cancellationToken)
|
||||
{
|
||||
var workOrderIds = workOrders.Select(w => w.Id);
|
||||
var nonLive = LiveUpliftStatus.NonLive;
|
||||
|
||||
var live = context.DispatchUpliftRequests
|
||||
.AsNoTracking()
|
||||
.Where(u =>
|
||||
(u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& !nonLive.Contains(u.Status)
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false));
|
||||
|
||||
|
|
|
|||
|
|
@ -15,12 +15,6 @@ namespace SeaHaven.DataServices.Helpers
|
|||
WorkOrderType.Reactive,
|
||||
};
|
||||
|
||||
private static readonly string[] RevokedUpliftStatuses =
|
||||
{
|
||||
"Withdrawn",
|
||||
"Cancelled",
|
||||
};
|
||||
|
||||
public static IQueryable<WorkOrder> ApplySeverityFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<int>? severities)
|
||||
|
|
@ -125,18 +119,21 @@ namespace SeaHaven.DataServices.Helpers
|
|||
if (!hasUplift)
|
||||
return query;
|
||||
|
||||
// No sub-filter: any live uplift. An explicit sub-filter matches exactly the
|
||||
// statuses it names, so "cancelled" / "revoked" still find those work orders.
|
||||
var mappedStatuses = MapUpliftStatuses(upliftStatuses);
|
||||
var anyLive = mappedStatuses.Count == 0;
|
||||
var nonLive = LiveUpliftStatus.NonLive;
|
||||
|
||||
return query.Where(w => context.DispatchUpliftRequests.Any(u =>
|
||||
(u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Status != "Expired"
|
||||
&& !RevokedUpliftStatuses.Contains(u.Status)
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||
&& (
|
||||
u.Dispatch.WorkOrderId == w.Id
|
||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))
|
||||
&& (mappedStatuses.Count == 0 || mappedStatuses.Contains(u.Status))));
|
||||
&& ((anyLive && !nonLive.Contains(u.Status))
|
||||
|| (!anyLive && mappedStatuses.Contains(u.Status)))));
|
||||
}
|
||||
|
||||
internal static List<string> MapUpliftStatuses(IReadOnlyList<string>? upliftStatuses)
|
||||
|
|
@ -166,6 +163,14 @@ namespace SeaHaven.DataServices.Helpers
|
|||
mapped.Add("Rejected");
|
||||
mapped.Add("Denied");
|
||||
break;
|
||||
case "cancelled":
|
||||
mapped.Add("Withdrawn");
|
||||
mapped.Add("Cancelled");
|
||||
mapped.Add("Expired");
|
||||
break;
|
||||
case "revoked":
|
||||
mapped.Add("Revoked");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,71 +13,76 @@ namespace SeaHaven.DataServices.Implementation
|
|||
_context = context;
|
||||
}
|
||||
|
||||
public async Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken)
|
||||
public async Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = email.ToLower().Trim();
|
||||
var normalizedEmail = Normalize(email);
|
||||
|
||||
var existing = await _context.ForgetPasswordCodes
|
||||
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
.ToListAsync(cancellationToken);
|
||||
_context.ForgetPasswordCodes.RemoveRange(existing);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
_context.ForgetPasswordCodes.Remove(existing);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var forgetPasswordCode = new ForgetPasswordCode
|
||||
_context.ForgetPasswordCodes.Add(new ForgetPasswordCode
|
||||
{
|
||||
Email = email,
|
||||
UserId = userId,
|
||||
Code = code
|
||||
};
|
||||
|
||||
_context.ForgetPasswordCodes.Add(forgetPasswordCode);
|
||||
Code = string.Empty,
|
||||
CodeHash = codeHash,
|
||||
CodeSalt = codeSalt,
|
||||
ExpiresAtUtc = expiresAtUtc,
|
||||
FailedAttempts = 0
|
||||
});
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> CodeExistsAsync(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.ForgetPasswordCodes
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Code == code)
|
||||
.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = email.ToLower().Trim();
|
||||
var trimmedCode = code.Trim();
|
||||
|
||||
return await _context.ForgetPasswordCodes
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Email.ToLower().Trim() == normalizedEmail && u.Code == trimmedCode)
|
||||
.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = email.ToLower().Trim();
|
||||
var normalizedEmail = Normalize(email);
|
||||
|
||||
return await _context.ForgetPasswordCodes
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
|
||||
.OrderByDescending(u => u.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
// A single conditional UPDATE, so concurrent checks can never consume
|
||||
// more than maxAttempts between them.
|
||||
var updated = await _context.ForgetPasswordCodes
|
||||
.Where(u => u.Id == id && u.FailedAttempts < maxAttempts && u.ExpiresAtUtc > nowUtc)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(u => u.FailedAttempts, u => u.FailedAttempts + 1),
|
||||
cancellationToken);
|
||||
return updated == 1;
|
||||
}
|
||||
|
||||
public async Task RefundAttemptAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.ForgetPasswordCodes
|
||||
.Where(u => u.Id == id && u.FailedAttempts > 0)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(u => u.FailedAttempts, u => u.FailedAttempts - 1),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RemoveByEmailAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = email.ToLower().Trim();
|
||||
var records = await _context.ForgetPasswordCodes
|
||||
var normalizedEmail = Normalize(email);
|
||||
await _context.ForgetPasswordCodes
|
||||
.Where(code => code.Email.ToLower().Trim() == normalizedEmail)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (records.Count == 0)
|
||||
return;
|
||||
public async Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = Normalize(email);
|
||||
await _context.ForgetPasswordCodes
|
||||
.Where(code => code.Email.ToLower().Trim() == normalizedEmail && code.Id <= throughId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
_context.ForgetPasswordCodes.RemoveRange(records);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
private static string Normalize(string email) => email.ToLower().Trim();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,10 +4,27 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
public interface IForgetPasswordDataService
|
||||
{
|
||||
Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken);
|
||||
Task<bool> CodeExistsAsync(string code, CancellationToken cancellationToken);
|
||||
Task<bool> ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken);
|
||||
/// <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>Returns the pending code for exactly this email, or null.</summary>
|
||||
Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically consumes one attempt when the code is unexpired and has fewer
|
||||
/// than <paramref name="maxAttempts"/> consumed. Returns false otherwise.
|
||||
/// </summary>
|
||||
Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Gives back an attempt consumed by a check that matched.</summary>
|
||||
Task RefundAttemptAsync(int id, CancellationToken cancellationToken);
|
||||
|
||||
Task RemoveByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the email's codes issued up to and including <paramref name="throughId"/>,
|
||||
/// leaving any code issued after it by a concurrent request.
|
||||
/// </summary>
|
||||
Task RemoveIssuedThroughAsync(string email, int throughId, 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
40
SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs
Normal file
40
SeaHaven.Services/Helpers/PasswordResetCodeSecrets.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
public static class PasswordResetCodeSecrets
|
||||
{
|
||||
public static string NewCode()
|
||||
{
|
||||
return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static string NewSalt()
|
||||
{
|
||||
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string Hash(string salt, string code)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(salt);
|
||||
ArgumentNullException.ThrowIfNull(code);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt + ":" + code))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static bool Matches(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 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>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ using SeaHaven.Services.Helpers;
|
|||
using SeaHaven.Services.Interfaces;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
|
|
@ -21,18 +20,28 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IUserDataService _userDataService;
|
||||
private readonly IForgetPasswordDataService _forgetPasswordDataService;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ISessionStampService _sessionStamps;
|
||||
|
||||
public static readonly TimeSpan ResetCodeLifetime = TimeSpan.FromMinutes(15);
|
||||
public const int MaxCodeAttempts = 5;
|
||||
|
||||
public AuthenticationService(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IOptions<JwtOptions> jwtOptions,
|
||||
IUserDataService userDataService,
|
||||
IForgetPasswordDataService forgetPasswordDataService,
|
||||
IEmailSender emailSender)
|
||||
IEmailSender emailSender,
|
||||
TimeProvider timeProvider,
|
||||
ISessionStampService sessionStamps)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_jwtOptions = jwtOptions.Value;
|
||||
_userDataService = userDataService;
|
||||
_forgetPasswordDataService = forgetPasswordDataService;
|
||||
_emailSender = emailSender;
|
||||
_timeProvider = timeProvider;
|
||||
_sessionStamps = sessionStamps;
|
||||
}
|
||||
|
||||
public async Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken)
|
||||
|
|
@ -49,12 +58,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)
|
||||
{
|
||||
|
|
@ -98,9 +117,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
|
||||
|
|
@ -131,48 +154,104 @@ namespace SeaHaven.Services.Implementation
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken)
|
||||
public async Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userDataService.GetByEmailNormalizedAsync(email, cancellationToken);
|
||||
if (user == null) return false;
|
||||
// Registered and unregistered addresses take the same path up to the
|
||||
// email send: one user lookup, one code generated and hashed, one write.
|
||||
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);
|
||||
|
||||
var code = GenerateRandomNo();
|
||||
await _forgetPasswordDataService.ReplaceCodeAsync(user.Email ?? "", user.Id, code, cancellationToken);
|
||||
var body = $"Your Password Reset Code is: " + code;
|
||||
await _emailSender.SendEmailAsync(user.Email ?? email, "Forget Password Request.", body);
|
||||
if (user == null || user.IsDeleted == true || string.IsNullOrWhiteSpace(user.Email))
|
||||
{
|
||||
await _forgetPasswordDataService.RemoveByEmailAsync(requested, 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);
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken)
|
||||
{
|
||||
var pending = await CheckCodeAsync(email, code, cancellationToken);
|
||||
if (pending == null)
|
||||
return false;
|
||||
|
||||
// Verifying is a preview step; a correct code keeps all of its attempts.
|
||||
await _forgetPasswordDataService.RefundAttemptAsync(pending.Id, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyCodeAsync(string code, CancellationToken cancellationToken)
|
||||
public async Task<bool> ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _forgetPasswordDataService.CodeExistsAsync(code, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return false;
|
||||
|
||||
var pending = await CheckCodeAsync(email, code, cancellationToken);
|
||||
if (pending == null)
|
||||
return false;
|
||||
|
||||
var user = await _userManager.FindByIdAsync(pending.UserId);
|
||||
if (user == null || user.IsDeleted == true)
|
||||
{
|
||||
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(password))
|
||||
return false;
|
||||
|
||||
var matched = await _forgetPasswordDataService.ExistsByEmailAndCodeAsync(email, code, cancellationToken);
|
||||
if (!matched) return false;
|
||||
|
||||
var record = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken);
|
||||
if (record == null)
|
||||
return false;
|
||||
|
||||
var user = await _userManager.FindByIdAsync(record.UserId);
|
||||
if (user == null)
|
||||
return false;
|
||||
|
||||
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)
|
||||
{
|
||||
// The code was right and the new password was rejected: the user can
|
||||
// try another password without spending an attempt.
|
||||
await _forgetPasswordDataService.RefundAttemptAsync(pending.Id, cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
await _forgetPasswordDataService.RemoveByEmailAsync(email, cancellationToken);
|
||||
_sessionStamps.Forget(user.Id);
|
||||
await _forgetPasswordDataService.RemoveByEmailAsync(pending.Email, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pending code record when <paramref name="code"/> matches the one
|
||||
/// issued to <paramref name="email"/>. Every check consumes an attempt before
|
||||
/// comparing; a check that uses the last attempt without matching deletes the code.
|
||||
/// </summary>
|
||||
private async Task<ForgetPasswordCode?> CheckCodeAsync(string? email, string? code, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(code))
|
||||
return null;
|
||||
|
||||
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(pending.CodeSalt, code, pending.CodeHash))
|
||||
return pending;
|
||||
|
||||
if (pending.FailedAttempts + 1 >= MaxCodeAttempts)
|
||||
await _forgetPasswordDataService.RemoveIssuedThroughAsync(pending.Email, pending.Id, cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
private JwtSecurityToken GetToken(List<Claim> authClaims)
|
||||
{
|
||||
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret));
|
||||
|
|
@ -185,10 +264,5 @@ namespace SeaHaven.Services.Implementation
|
|||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
private static string GenerateRandomNo()
|
||||
{
|
||||
return RandomNumberGenerator.GetInt32(1_000_000).ToString("D6");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -278,6 +278,11 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
if (canonical == UpliftStatus.NoApprovalRequired)
|
||||
{
|
||||
// An admin revoke overturns a human decision, and an auto-approval has none,
|
||||
// so admins are refused here even when they filed the request themselves.
|
||||
if (user.IsInRole("Admin"))
|
||||
throw new UpliftForbiddenException("Admins can revoke only admin-approved uplifts");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId)
|
||||
|| !string.Equals(req.createdby, userId, StringComparison.Ordinal))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<LoginResultDTO> CreateSessionAsync(Data.SeaHavenIndustries.ApplicationUser user, CancellationToken cancellationToken);
|
||||
Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken);
|
||||
Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken);
|
||||
Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken);
|
||||
Task<bool> VerifyCodeAsync(string code, CancellationToken cancellationToken);
|
||||
Task<bool> ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Emails a reset code when the address belongs to an active account. Gives no
|
||||
/// indication either way, so callers cannot learn which emails are registered.
|
||||
/// </summary>
|
||||
Task ForgetPasswordAsync(string? email, CancellationToken cancellationToken);
|
||||
/// <summary>Checks a code against the one issued to this email only.</summary>
|
||||
Task<bool> VerifyCodeAsync(string? email, string? code, CancellationToken cancellationToken);
|
||||
Task<bool> ResetPasswordAsync(string? email, string? code, string? password, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
406
SeaHavenIndustries.Tests/PasswordResetFlowTests.cs
Normal file
406
SeaHavenIndustries.Tests/PasswordResetFlowTests.cs
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Api.SeaHavenIndustries.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The anonymous Forgot Password flow over HTTP, against the real controller,
|
||||
/// services, Identity and a relational database.
|
||||
/// </summary>
|
||||
public sealed class PasswordResetFlowTests
|
||||
{
|
||||
private const string Alice = "alice@example.com";
|
||||
private const string Bob = "bob@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\"}";
|
||||
|
||||
[Fact]
|
||||
public async Task Registered_user_resets_password_end_to_end_and_signs_in_with_the_new_one()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
|
||||
var requested = await host.ForgetPasswordAsync(Alice);
|
||||
Assert.Equal(HttpStatusCode.OK, requested.StatusCode);
|
||||
var code = host.Sent.LatestCodeFor(Alice);
|
||||
Assert.Matches("^[0-9]{6}$", code);
|
||||
|
||||
var verified = await host.VerifyAsync(Alice, code);
|
||||
Assert.Equal(HttpStatusCode.OK, verified.StatusCode);
|
||||
Assert.Contains("Code Matched", await verified.Content.ReadAsStringAsync());
|
||||
|
||||
var reset = await host.ResetAsync(Alice, code, NewPassword);
|
||||
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
|
||||
|
||||
// A used code cannot be replayed.
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "Other@24680")).Content.ReadAsStringAsync());
|
||||
Assert.Empty(await host.PendingCodesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_answers_registered_and_unregistered_emails_identically_and_emails_only_the_registered_one()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
|
||||
var registered = await host.ForgetPasswordAsync(Alice);
|
||||
var unregistered = await host.ForgetPasswordAsync("nobody@example.com");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, registered.StatusCode);
|
||||
Assert.Equal(registered.StatusCode, unregistered.StatusCode);
|
||||
Assert.Equal(await registered.Content.ReadAsStringAsync(), await unregistered.Content.ReadAsStringAsync());
|
||||
Assert.Contains(AuthenticationController.ForgetPasswordMessage, await registered.Content.ReadAsStringAsync());
|
||||
|
||||
var message = Assert.Single(host.Sent.Messages);
|
||||
Assert.Equal(Alice, message.To);
|
||||
Assert.Single(await host.PendingCodesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgetPassword_hides_an_email_delivery_failure_behind_the_same_response()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync(new ThrowingEmailSender());
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
|
||||
var registered = await host.ForgetPasswordAsync(Alice);
|
||||
var unregistered = await host.ForgetPasswordAsync("nobody@example.com");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, registered.StatusCode);
|
||||
var body = await registered.Content.ReadAsStringAsync();
|
||||
Assert.Equal(await unregistered.Content.ReadAsStringAsync(), body);
|
||||
Assert.DoesNotContain("smtp-internal-detail", body);
|
||||
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(Alice, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stored_code_is_a_salted_hash_and_the_plaintext_is_only_in_the_email()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var code = host.Sent.LatestCodeFor(Alice);
|
||||
var row = Assert.Single(await host.PendingCodesAsync());
|
||||
|
||||
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);
|
||||
Assert.DoesNotContain(code, string.Join("|", row.Code, row.CodeHash, row.CodeSalt, row.Email, row.UserId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Code_expires_fifteen_minutes_after_it_is_issued()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var code = host.Sent.LatestCodeFor(Alice);
|
||||
|
||||
host.Time.Advance(TimeSpan.FromMinutes(15) - TimeSpan.FromSeconds(1));
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode);
|
||||
|
||||
host.Time.Advance(TimeSpan.FromSeconds(1));
|
||||
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync());
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync());
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Requesting_a_new_code_invalidates_the_previous_one()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var first = host.Sent.LatestCodeFor(Alice);
|
||||
string second;
|
||||
do
|
||||
{
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
second = host.Sent.LatestCodeFor(Alice);
|
||||
}
|
||||
while (second == first);
|
||||
|
||||
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, first)).Content.ReadAsStringAsync());
|
||||
Assert.Single(await host.PendingCodesAsync());
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Five_wrong_attempts_invalidate_the_code_until_a_new_one_is_requested()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var code = host.Sent.LatestCodeFor(Alice);
|
||||
var wrong = WrongCode(code);
|
||||
|
||||
// Verify and reset share one attempt budget.
|
||||
for (var attempt = 0; attempt < 3; attempt++)
|
||||
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, wrong)).Content.ReadAsStringAsync());
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, wrong, NewPassword)).Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync());
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync());
|
||||
Assert.Empty(await host.PendingCodesAsync());
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode);
|
||||
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, host.Sent.LatestCodeFor(Alice), NewPassword)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exhausting_a_code_invalidates_every_pending_code_for_that_email()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
var user = await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var older = host.Sent.LatestCodeFor(Alice);
|
||||
var newest = await host.AddSiblingCodeAsync(Alice, user.Id);
|
||||
|
||||
for (var attempt = 0; attempt < 5; attempt++)
|
||||
await host.VerifyAsync(Alice, WrongCode(newest));
|
||||
|
||||
Assert.Empty(await host.PendingCodesAsync());
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, NewPassword)).Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_successful_reset_invalidates_every_pending_code_for_that_email()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
var user = await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var older = host.Sent.LatestCodeFor(Alice);
|
||||
var newest = await host.AddSiblingCodeAsync(Alice, user.Id);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, newest, NewPassword)).StatusCode);
|
||||
|
||||
Assert.Empty(await host.PendingCodesAsync());
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, "Other@24680")).Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Four_wrong_attempts_then_the_right_code_still_resets()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var code = host.Sent.LatestCodeFor(Alice);
|
||||
|
||||
for (var attempt = 0; attempt < 4; attempt++)
|
||||
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, WrongCode(code))).Content.ReadAsStringAsync());
|
||||
|
||||
// A correct verify does not spend an attempt, so the reset after it still succeeds.
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rejected_new_password_does_not_spend_an_attempt_or_the_code()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var code = host.Sent.LatestCodeFor(Alice);
|
||||
|
||||
for (var attempt = 0; attempt < 6; attempt++)
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "weakpassword")).Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_code_is_checked_only_against_the_email_it_was_issued_to()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.AddUserAsync(Bob, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
await host.ForgetPasswordAsync(Bob);
|
||||
var aliceCode = host.Sent.LatestCodeFor(Alice);
|
||||
var bobCode = host.Sent.LatestCodeFor(Bob);
|
||||
|
||||
if (aliceCode != bobCode)
|
||||
{
|
||||
Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Bob, aliceCode)).Content.ReadAsStringAsync());
|
||||
Assert.Equal(ResetFailed, await (await host.ResetAsync(Bob, aliceCode, NewPassword)).Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
// Exhausting Bob's attempts leaves Alice's code and budget untouched.
|
||||
for (var attempt = 0; attempt < 5; attempt++)
|
||||
await host.VerifyAsync(Bob, WrongCode(bobCode));
|
||||
|
||||
var alice = Assert.Single(await host.PendingCodesAsync());
|
||||
Assert.Equal(Alice, alice.Email);
|
||||
Assert.Equal(0, alice.FailedAttempts);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, aliceCode, NewPassword)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Bob, OldPassword)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_checks_can_never_consume_more_than_five_attempts()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var row = Assert.Single(await host.PendingCodesAsync());
|
||||
var now = host.Time.GetUtcNow().UtcDateTime;
|
||||
|
||||
var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ =>
|
||||
{
|
||||
await using var scope = host.Services.CreateAsyncScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<IForgetPasswordDataService>()
|
||||
.TryConsumeAttemptAsync(row.Id, 5, now, CancellationToken.None);
|
||||
}));
|
||||
|
||||
Assert.Equal(5, results.Count(consumed => consumed));
|
||||
Assert.Equal(5, Assert.Single(await host.PendingCodesAsync()).FailedAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationCode_without_an_email_fails_generically_and_never_matches_another_users_code()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
var code = host.Sent.LatestCodeFor(Alice);
|
||||
|
||||
// The old client contract: code in the query string, no body, no content type.
|
||||
var legacy = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/VerificationCode?code={code}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, legacy.StatusCode);
|
||||
Assert.Equal(CodeNotMatched, await legacy.Content.ReadAsStringAsync());
|
||||
Assert.Equal(0, Assert.Single(await host.PendingCodesAsync()).FailedAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationCode_and_ForgetPassword_still_accept_the_query_string_form()
|
||||
{
|
||||
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 verified = await host.Client.SendAsync(PasswordResetTestHost.Post(
|
||||
$"api/Authentication/VerificationCode?email={Uri.EscapeDataString(Alice)}&code={code}"));
|
||||
Assert.Equal(HttpStatusCode.OK, verified.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_endpoint_allows_ten_requests_per_client_per_window_then_answers_429()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
const string client = "203.0.113.5";
|
||||
|
||||
for (var request = 0; request < PasswordResetRateLimiting.PermitLimit; request++)
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ForgetPasswordAsync($"n{request}@example.com", client)).StatusCode);
|
||||
|
||||
var limited = await host.ForgetPasswordAsync("n@example.com", client);
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode);
|
||||
Assert.Equal(
|
||||
"{\"status\":\"Error\",\"message\":\"Too many requests. Please try again later.\"}",
|
||||
await limited.Content.ReadAsStringAsync());
|
||||
|
||||
// A left-hand X-Forwarded-For entry written by the caller does not buy a new bucket.
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, (await host.ForgetPasswordAsync("n@example.com", "198.51.100.1, " + client)).StatusCode);
|
||||
|
||||
// Another client, and the same client on another endpoint, are unaffected.
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ForgetPasswordAsync("n@example.com", "203.0.113.6")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync("n@example.com", "000000", client)).StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("api/Authentication/VerificationCode")]
|
||||
[InlineData("api/Authentication/ResetPassword")]
|
||||
public async Task Verify_and_reset_are_rate_limited_per_client(string path)
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
const string client = "203.0.113.9";
|
||||
var body = new { email = Alice, code = "000000", password = NewPassword };
|
||||
|
||||
for (var request = 0; request < PasswordResetRateLimiting.PermitLimit; request++)
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await host.Client.SendAsync(PasswordResetTestHost.Post(path, body, client))).StatusCode);
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Client.SendAsync(PasswordResetTestHost.Post(path, body, client))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_code_or_email_reaches_the_logs_during_the_whole_flow()
|
||||
{
|
||||
await using var host = await PasswordResetTestHost.StartAsync();
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
|
||||
await host.ForgetPasswordAsync(Alice);
|
||||
await host.ForgetPasswordAsync("nobody@example.com");
|
||||
var code = host.Sent.LatestCodeFor(Alice);
|
||||
await host.VerifyAsync(Alice, WrongCode(code));
|
||||
await host.VerifyAsync(Alice, code);
|
||||
await host.ResetAsync(Alice, code, NewPassword);
|
||||
|
||||
Assert.NotEmpty(host.Logged.Lines);
|
||||
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(code, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(WrongCode(code), StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains("@example.com", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(NewPassword, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Api_host_registers_forwarded_headers_and_rate_limiting_in_the_right_order()
|
||||
{
|
||||
var program = File.ReadAllText(Path.Combine(RepoRoot(), "Api.SeaHavenIndustries", "Program.cs"));
|
||||
|
||||
Assert.Contains("builder.Services.AddPasswordResetRateLimiting();", 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);
|
||||
var routing = program.IndexOf("app.UseRouting()", StringComparison.Ordinal);
|
||||
var limiter = program.IndexOf("app.UseRateLimiter()", StringComparison.Ordinal);
|
||||
var controllers = program.IndexOf("app.MapControllers()", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(forwarded > build && forwarded == firstMiddleware, "UseForwardedHeaders must be the first middleware.");
|
||||
Assert.True(routing < limiter, "UseRateLimiter must run after UseRouting so endpoint policies apply.");
|
||||
Assert.True(limiter < controllers);
|
||||
}
|
||||
|
||||
private static string WrongCode(string code) =>
|
||||
((int.Parse(code) + 1) % 1_000_000).ToString("D6");
|
||||
|
||||
private static string Sha256Hex(string value) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null
|
||||
&& !File.Exists(Path.Combine(directory.FullName, "Api.SeaHavenIndustries", "Program.cs")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Could not locate the repository root from " + AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
private sealed class ThrowingEmailSender : IEmailSender
|
||||
{
|
||||
public Task<bool> SendEmailAsync(string emailTo, string subject, string body) =>
|
||||
throw new InvalidOperationException("smtp-internal-detail " + emailTo);
|
||||
}
|
||||
}
|
||||
118
SeaHavenIndustries.Tests/PasswordResetRaceTests.cs
Normal file
118
SeaHavenIndustries.Tests/PasswordResetRaceTests.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
using System.Net;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A new code requested while a check of the previous code is in flight. The
|
||||
/// request runs for real, between the check's steps, through the same host.
|
||||
/// </summary>
|
||||
public sealed class PasswordResetRaceTests
|
||||
{
|
||||
private const string Alice = "alice@example.com";
|
||||
private const string OldPassword = "Old@12345";
|
||||
private const string NewPassword = "New@67890";
|
||||
|
||||
private static int _nextClient;
|
||||
|
||||
private static string NextClient()
|
||||
{
|
||||
var n = Interlocked.Increment(ref _nextClient);
|
||||
return $"192.0.{n / 250 % 250}.{n % 250 + 1}";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_code_requested_while_the_previous_one_is_being_checked_still_resets()
|
||||
{
|
||||
var race = new CheckRace();
|
||||
await using var host = await PasswordResetTestHost.StartAsync(configureServices: race.Register);
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice, NextClient());
|
||||
var first = host.Sent.LatestCodeFor(Alice);
|
||||
|
||||
// The check has read the first code; the new request replaces it before the attempt is counted.
|
||||
race.BeforeNextConsume = () => host.ForgetPasswordAsync(Alice, NextClient());
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync(Alice, first, NextClient())).StatusCode);
|
||||
|
||||
Assert.Equal(2, host.Sent.Messages.Count);
|
||||
var second = host.Sent.LatestCodeFor(Alice);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword, NextClient())).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_code_requested_while_the_last_wrong_attempt_is_being_checked_still_resets()
|
||||
{
|
||||
var race = new CheckRace();
|
||||
await using var host = await PasswordResetTestHost.StartAsync(configureServices: race.Register);
|
||||
await host.AddUserAsync(Alice, OldPassword);
|
||||
await host.ForgetPasswordAsync(Alice, NextClient());
|
||||
var wrong = host.Sent.LatestCodeFor(Alice) == "000000" ? "111111" : "000000";
|
||||
for (var attempt = 1; attempt < 5; attempt++)
|
||||
await host.VerifyAsync(Alice, wrong, NextClient());
|
||||
|
||||
// The fifth wrong attempt is counted, then the new request lands before the used-up code is deleted.
|
||||
race.AfterNextConsume = () => host.ForgetPasswordAsync(Alice, NextClient());
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync(Alice, wrong, NextClient())).StatusCode);
|
||||
|
||||
Assert.Equal(2, host.Sent.Messages.Count);
|
||||
var second = host.Sent.LatestCodeFor(Alice);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword, NextClient())).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode);
|
||||
}
|
||||
|
||||
private sealed class CheckRace
|
||||
{
|
||||
private Func<Task>? _beforeNextConsume;
|
||||
private Func<Task>? _afterNextConsume;
|
||||
|
||||
public Func<Task>? BeforeNextConsume { set => _beforeNextConsume = value; }
|
||||
public Func<Task>? AfterNextConsume { set => _afterNextConsume = value; }
|
||||
|
||||
public void Register(IServiceCollection services) =>
|
||||
services.Replace(ServiceDescriptor.Scoped<IForgetPasswordDataService>(provider =>
|
||||
new RacingForgetPasswordDataService(new ForgetPasswordDataService(provider.GetRequiredService<ApplicationDbContext>()), this)));
|
||||
|
||||
public Task RunBeforeConsumeAsync() => Interlocked.Exchange(ref _beforeNextConsume, null)?.Invoke() ?? Task.CompletedTask;
|
||||
public Task RunAfterConsumeAsync() => Interlocked.Exchange(ref _afterNextConsume, null)?.Invoke() ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class RacingForgetPasswordDataService : IForgetPasswordDataService
|
||||
{
|
||||
private readonly IForgetPasswordDataService _inner;
|
||||
private readonly CheckRace _race;
|
||||
|
||||
public RacingForgetPasswordDataService(IForgetPasswordDataService inner, CheckRace race)
|
||||
{
|
||||
_inner = inner;
|
||||
_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<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken) =>
|
||||
_inner.GetByEmailAsync(email, cancellationToken);
|
||||
|
||||
public async Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await _race.RunBeforeConsumeAsync();
|
||||
var consumed = await _inner.TryConsumeAttemptAsync(id, maxAttempts, nowUtc, cancellationToken);
|
||||
await _race.RunAfterConsumeAsync();
|
||||
return consumed;
|
||||
}
|
||||
|
||||
public Task RefundAttemptAsync(int id, CancellationToken cancellationToken) =>
|
||||
_inner.RefundAttemptAsync(id, cancellationToken);
|
||||
|
||||
public Task RemoveByEmailAsync(string email, CancellationToken cancellationToken) =>
|
||||
_inner.RemoveByEmailAsync(email, cancellationToken);
|
||||
|
||||
public Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken) =>
|
||||
_inner.RemoveIssuedThroughAsync(email, throughId, cancellationToken);
|
||||
}
|
||||
}
|
||||
298
SeaHavenIndustries.Tests/PasswordResetTestHost.cs
Normal file
298
SeaHavenIndustries.Tests/PasswordResetTestHost.cs
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
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.ApplicationParts;
|
||||
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 SeaHaven.DataServices.DependencyInjection;
|
||||
using SeaHaven.Services.DependencyInjection;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts the real <see cref="AuthenticationController"/> on Kestrel over a SQLite file
|
||||
/// database, with the real authentication service, data services, Identity, and the
|
||||
/// same rate limiting and forwarded-header registration the API host uses. Email goes
|
||||
/// to an in-memory sender, the clock is manual, and every log line is captured.
|
||||
/// </summary>
|
||||
internal sealed class PasswordResetTestHost : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _app;
|
||||
private readonly string _databasePath;
|
||||
|
||||
private PasswordResetTestHost(WebApplication app, string databasePath, HttpClient client)
|
||||
{
|
||||
_app = app;
|
||||
_databasePath = databasePath;
|
||||
Client = client;
|
||||
}
|
||||
|
||||
public HttpClient Client { get; }
|
||||
public ResetEmailOutbox Sent { get; private init; } = null!;
|
||||
public ResetTestClock Time { get; private init; } = null!;
|
||||
public ResetLogCapture Logged { get; private init; } = null!;
|
||||
public IServiceProvider Services => _app.Services;
|
||||
|
||||
public static async Task<PasswordResetTestHost> StartAsync(IEmailSender? emailSender = null, Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
var databasePath = Path.Combine(Path.GetTempPath(), $"password-reset-{Guid.NewGuid():N}.db");
|
||||
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString();
|
||||
|
||||
var sent = new ResetEmailOutbox();
|
||||
var time = new ResetTestClock();
|
||||
var logged = new ResetLogCapture();
|
||||
|
||||
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"] = new string('k', 64),
|
||||
["JWT:ValidIssuer"] = "issuer",
|
||||
["JWT:ValidAudience"] = "audience"
|
||||
});
|
||||
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 SqlitePasswordResetDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
|
||||
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
||||
{
|
||||
options.User.RequireUniqueEmail = false;
|
||||
})
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
builder.Services.AddSingleton<TimeProvider>(time);
|
||||
builder.Services.AddSingleton<IEmailSender>(emailSender ?? sent);
|
||||
builder.Services.AddDataServices();
|
||||
configureServices?.Invoke(builder.Services);
|
||||
builder.Services.AddBusinessServices(builder.Configuration);
|
||||
builder.Services.AddControllers()
|
||||
.AddApplicationPart(typeof(AuthenticationController).Assembly)
|
||||
.ConfigureApplicationPartManager(manager =>
|
||||
{
|
||||
manager.FeatureProviders.Clear();
|
||||
manager.FeatureProviders.Add(new OnlyAuthenticationController());
|
||||
});
|
||||
builder.Services.AddPasswordResetRateLimiting();
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseForwardedHeaders();
|
||||
app.UseRouting();
|
||||
app.UseRateLimiter();
|
||||
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();
|
||||
|
||||
return new PasswordResetTestHost(app, databasePath, new HttpClient { BaseAddress = new Uri(address) })
|
||||
{
|
||||
Sent = sent,
|
||||
Time = time,
|
||||
Logged = logged
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApplicationUser> AddUserAsync(string email, string password)
|
||||
{
|
||||
await using var scope = _app.Services.CreateAsyncScope();
|
||||
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = email,
|
||||
Email = email,
|
||||
FirstName = "Alice",
|
||||
LastName = "Q",
|
||||
EmailConfirmed = true,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
var result = await users.CreateAsync(user, password);
|
||||
Assert.True(result.Succeeded, string.Join("; ", result.Errors.Select(error => error.Description)));
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task<List<ForgetPasswordCode>> PendingCodesAsync()
|
||||
{
|
||||
await using var scope = _app.Services.CreateAsyncScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>()
|
||||
.ForgetPasswordCodes.AsNoTracking().OrderBy(code => code.Id).ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
const string code = "424242";
|
||||
const string salt = "0123456789abcdef0123456789abcdef";
|
||||
await using var scope = _app.Services.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
context.ForgetPasswordCodes.Add(new ForgetPasswordCode
|
||||
{
|
||||
Email = email,
|
||||
UserId = userId,
|
||||
CodeSalt = salt,
|
||||
CodeHash = SeaHaven.Services.Helpers.PasswordResetCodeSecrets.Hash(salt, code),
|
||||
ExpiresAtUtc = Time.GetUtcNow().UtcDateTime.AddMinutes(15)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
return code;
|
||||
}
|
||||
|
||||
public static HttpRequestMessage Post(string path, object? json = null, string? clientIp = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, path);
|
||||
if (json != null)
|
||||
request.Content = JsonContent.Create(json);
|
||||
if (clientIp != null)
|
||||
request.Headers.Add("X-Forwarded-For", clientIp);
|
||||
return request;
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> ForgetPasswordAsync(string email, string? clientIp = null) =>
|
||||
Client.SendAsync(Post("api/Authentication/ForgetPassword", new { email }, clientIp));
|
||||
|
||||
public Task<HttpResponseMessage> VerifyAsync(string email, string code, string? clientIp = null) =>
|
||||
Client.SendAsync(Post("api/Authentication/VerificationCode", new { email, code }, clientIp));
|
||||
|
||||
public Task<HttpResponseMessage> ResetAsync(string email, string code, string password, string? clientIp = null) =>
|
||||
Client.SendAsync(Post("api/Authentication/ResetPassword", new { email, code, password }, clientIp));
|
||||
|
||||
public Task<HttpResponseMessage> LoginAsync(string username, string password) =>
|
||||
Client.SendAsync(Post("api/Authentication/login", new { username, password }));
|
||||
|
||||
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 OnlyAuthenticationController : ControllerFeatureProvider
|
||||
{
|
||||
protected override bool IsController(System.Reflection.TypeInfo typeInfo) =>
|
||||
typeInfo.AsType() == typeof(AuthenticationController);
|
||||
}
|
||||
|
||||
private sealed class SqlitePasswordResetDbContext : ApplicationDbContext
|
||||
{
|
||||
public SqlitePasswordResetDbContext(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ResetTestClock : 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);
|
||||
}
|
||||
|
||||
internal sealed class ResetEmailOutbox : IEmailSender
|
||||
{
|
||||
private static readonly Regex CodePattern = new(@"Your Password Reset Code is: (\d{6})", RegexOptions.CultureInvariant);
|
||||
|
||||
public ConcurrentQueue<(string To, string Subject, string Body)> Messages { get; } = new();
|
||||
|
||||
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
|
||||
{
|
||||
Messages.Enqueue((emailTo, subject, body));
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public string LatestCodeFor(string email)
|
||||
{
|
||||
var message = Messages.Last(sent => string.Equals(sent.To, email, StringComparison.OrdinalIgnoreCase));
|
||||
return CodePattern.Match(message.Body).Groups[1].Value;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ResetLogCapture : ILoggerProvider
|
||||
{
|
||||
public ConcurrentQueue<string> Lines { get; } = new();
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new CapturingLogger(this, categoryName);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger : ILogger
|
||||
{
|
||||
private readonly ResetLogCapture _owner;
|
||||
private readonly string _category;
|
||||
|
||||
public CapturingLogger(ResetLogCapture owner, string category)
|
||||
{
|
||||
_owner = owner;
|
||||
_category = category;
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
var values = state is IEnumerable<KeyValuePair<string, object?>> pairs
|
||||
? string.Join(" ", pairs.Select(pair => $"{pair.Key}={pair.Value}"))
|
||||
: string.Empty;
|
||||
_owner.Lines.Enqueue($"{logLevel} {_category} {formatter(state, exception)} {values} {exception}");
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
276
SeaHavenIndustries.Tests/SessionTestHost.cs
Normal file
276
SeaHavenIndustries.Tests/SessionTestHost.cs
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
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.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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -211,7 +211,7 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests
|
|||
row.Id,
|
||||
created!.Id,
|
||||
new RevokeWorkOrderUpliftRequestDto(),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
WorkOrderAccountTestHelpers.AccountUser("actor-1", 1, "Dispatcher"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("revoked", revoked!.Status);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
// Relational (SQLite) coverage for the live-uplift predicate. The in-memory provider
|
||||
// evaluates LINQ in memory and cannot prove the "Has uplift" filter or the board uplift
|
||||
// aggregate translate to SQL, so this runs the advanced search against a real provider,
|
||||
// scoped to one account, with a live uplift on another account's work order.
|
||||
public sealed class WorkOrderUpliftLiveStatusRelationalTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AdvancedSearch_HasUplift_CountsOnlyLiveUpliftsWithinAccount()
|
||||
{
|
||||
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new SqliteLiveUpliftTestDbContext(options);
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
var accountA = new Accounts { Name = "Account A" };
|
||||
var accountB = new Accounts { Name = "Account B" };
|
||||
var vendor = new Vendor { CompanyName = "Acme HVAC", IsActive = true };
|
||||
context.AddRange(accountA, accountB, vendor);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var revokedOnly = NewWorkOrder("WO-REVOKED", accountA.Id);
|
||||
var cancelledOnly = NewWorkOrder("WO-CANCELLED", accountA.Id);
|
||||
var revokedPlusPending = NewWorkOrder("WO-MIXED", accountA.Id);
|
||||
var otherAccountPending = NewWorkOrder("WO-OTHER", accountB.Id);
|
||||
context.AddRange(revokedOnly, cancelledOnly, revokedPlusPending, otherAccountPending);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var revokedDispatch = new Dispatch { VendorId = vendor.Id, DispatchNumber = "DIS-R", Status = "Scheduled" };
|
||||
var cancelledDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = cancelledOnly.Id, DispatchNumber = "DIS-C", Status = "Scheduled" };
|
||||
var mixedDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = revokedPlusPending.Id, DispatchNumber = "DIS-M", Status = "Scheduled" };
|
||||
var mixedPendingDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = revokedPlusPending.Id, DispatchNumber = "DIS-M2", Status = "Scheduled" };
|
||||
var otherDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = otherAccountPending.Id, DispatchNumber = "DIS-O", Status = "Scheduled" };
|
||||
context.Dispatches.AddRange(revokedDispatch, cancelledDispatch, mixedDispatch, mixedPendingDispatch, otherDispatch);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// The revoked-only work order reaches its uplift through a multi-WO dispatch link.
|
||||
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = revokedDispatch.Id, WorkOrderId = revokedOnly.Id });
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
NewUplift(revokedDispatch.Id, "Revoked", new DateTime(2026, 6, 20, 9, 0, 0)),
|
||||
NewUplift(cancelledDispatch.Id, "Withdrawn", new DateTime(2026, 6, 20, 9, 0, 0)),
|
||||
NewUplift(mixedDispatch.Id, "Revoked", new DateTime(2026, 6, 20, 9, 0, 0)),
|
||||
NewUplift(mixedPendingDispatch.Id, "Pending", new DateTime(2026, 6, 20, 11, 0, 0)),
|
||||
NewUplift(otherDispatch.Id, "Pending", new DateTime(2026, 6, 20, 9, 0, 0)));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var data = new WorkOrderAdvancedSearchDataService(context);
|
||||
|
||||
var hasUplift = await data.SearchAsync(Query(accountA.Id, hasUplift: true, upliftStatuses: null));
|
||||
var row = Assert.Single(hasUplift.Rows);
|
||||
Assert.Equal(revokedPlusPending.Id, row.Id);
|
||||
Assert.True(row.HasUplift);
|
||||
Assert.Equal("Pending", row.PrimaryUpliftStatus);
|
||||
|
||||
var revoked = await data.SearchAsync(Query(accountA.Id, hasUplift: true, upliftStatuses: new[] { "revoked" }));
|
||||
Assert.Equal(
|
||||
new[] { revokedOnly.Id, revokedPlusPending.Id }.OrderBy(id => id),
|
||||
revoked.Rows.Select(r => r.Id).OrderBy(id => id));
|
||||
|
||||
var all = await data.SearchAsync(Query(accountA.Id, hasUplift: false, upliftStatuses: null));
|
||||
Assert.Equal(3, all.TotalCount);
|
||||
Assert.DoesNotContain(all.Rows, r => r.Id == otherAccountPending.Id);
|
||||
Assert.False(all.Rows.Single(r => r.Id == revokedOnly.Id).HasUplift);
|
||||
Assert.Null(all.Rows.Single(r => r.Id == revokedOnly.Id).PrimaryUpliftStatus);
|
||||
Assert.False(all.Rows.Single(r => r.Id == cancelledOnly.Id).HasUplift);
|
||||
Assert.True(all.Rows.Single(r => r.Id == revokedPlusPending.Id).HasUplift);
|
||||
}
|
||||
|
||||
private static WorkOrder NewWorkOrder(string number, int accountId) => new()
|
||||
{
|
||||
InternalWONumber = number,
|
||||
WorkerOrderTitle = "Repair",
|
||||
AccountId = accountId,
|
||||
ScheduledDate = new DateTime(2026, 6, 23, 8, 0, 0),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
};
|
||||
|
||||
private static DispatchUpliftRequest NewUplift(int dispatchId, string status, DateTime createdDate) => new()
|
||||
{
|
||||
DispatchId = dispatchId,
|
||||
Status = status,
|
||||
CreatedDate = createdDate,
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 750m,
|
||||
NotificationStatus = "Pending",
|
||||
};
|
||||
|
||||
private static WorkOrderAdvancedSearchQuery Query(
|
||||
int accountId,
|
||||
bool hasUplift,
|
||||
IReadOnlyList<string>? upliftStatuses) => new(
|
||||
Search: null,
|
||||
DateFrom: new DateOnly(2026, 6, 22),
|
||||
DateTo: new DateOnly(2026, 6, 28),
|
||||
UnscheduledOnly: false,
|
||||
Sites: null,
|
||||
Regions: null,
|
||||
Types: null,
|
||||
Overdue: false,
|
||||
Dispatchers: null,
|
||||
Statuses: null,
|
||||
PmTypes: null,
|
||||
VendorIds: null,
|
||||
DocStatuses: null,
|
||||
Severities: null,
|
||||
Rescheduled: false,
|
||||
CarriedOver: false,
|
||||
AddOn: false,
|
||||
AvetaOnly: false,
|
||||
FlagColors: null,
|
||||
InternalOnly: false,
|
||||
HasUplift: hasUplift,
|
||||
UpliftStatuses: upliftStatuses,
|
||||
MyWorkOrders: false,
|
||||
CurrentUserId: null,
|
||||
Page: 0,
|
||||
PageSize: 50,
|
||||
SortBy: "scheduledDate",
|
||||
SortDir: "asc",
|
||||
AccountId: accountId);
|
||||
|
||||
private sealed class SqliteLiveUpliftTestDbContext : ApplicationDbContext
|
||||
{
|
||||
public SqliteLiveUpliftTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
// SQL Server filtered index syntax is invalid on SQLite.
|
||||
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
|
||||
{
|
||||
if (index.GetFilter() != null)
|
||||
index.SetFilter(null);
|
||||
}
|
||||
|
||||
// SQLite has no rowversion type; treat as plain nullable blobs.
|
||||
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
|
||||
{
|
||||
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
|
||||
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
|
||||
property.IsConcurrencyToken = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
266
SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs
Normal file
266
SeaHavenIndustries.Tests/WorkOrderUpliftLiveStatusTests.cs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// "Has uplift" (advanced filter) and the board Uplift column count only live uplifts:
|
||||
/// a work order whose uplifts were all cancelled, withdrawn, expired or revoked has none.
|
||||
/// </summary>
|
||||
public class WorkOrderUpliftLiveStatusTests
|
||||
{
|
||||
private static readonly DateOnly WeekStart = new(2026, 6, 22);
|
||||
|
||||
private const int WithdrawnOnly = 1;
|
||||
private const int LegacyCancelledOnly = 2;
|
||||
private const int RevokedOnlyViaLink = 3;
|
||||
private const int RevokedPlusPending = 4;
|
||||
private const int RejectedOnly = 5;
|
||||
private const int ExpiredOnly = 6;
|
||||
private const int NoUplift = 7;
|
||||
private const int NewerRevokedOlderAutoApproved = 8;
|
||||
|
||||
private static readonly int[] LiveUpliftWorkOrders =
|
||||
{
|
||||
RevokedPlusPending,
|
||||
RejectedOnly,
|
||||
NewerRevokedOlderAutoApproved,
|
||||
};
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static async Task<ApplicationDbContext> SeedAsync()
|
||||
{
|
||||
var context = CreateContext();
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||
|
||||
for (var id = WithdrawnOnly; id <= NewerRevokedOlderAutoApproved; id++)
|
||||
{
|
||||
// The revoked-only work order reaches its uplift through a multi-WO dispatch link.
|
||||
var linkedOnly = id == RevokedOnlyViaLink;
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
Id = 10 + id,
|
||||
VendorId = 1,
|
||||
WorkOrderId = linkedOnly ? null : id,
|
||||
});
|
||||
if (linkedOnly)
|
||||
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = 10 + id, WorkOrderId = id });
|
||||
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = id,
|
||||
InternalWONumber = $"1000000000{id}",
|
||||
ScheduledDate = new DateTime(2026, 6, 23),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = linkedOnly ? null : 10 + id,
|
||||
});
|
||||
}
|
||||
|
||||
var older = new DateTime(2026, 6, 20, 9, 0, 0, DateTimeKind.Utc);
|
||||
var newer = older.AddHours(2);
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Uplift(101, WithdrawnOnly, "Withdrawn", older),
|
||||
Uplift(102, LegacyCancelledOnly, "Cancelled", older),
|
||||
Uplift(103, RevokedOnlyViaLink, "Revoked", older),
|
||||
Uplift(104, RevokedPlusPending, "Revoked", older),
|
||||
Uplift(105, RevokedPlusPending, "Pending", newer),
|
||||
Uplift(106, RejectedOnly, "Rejected", older),
|
||||
Uplift(107, ExpiredOnly, "Expired", older),
|
||||
Uplift(108, NewerRevokedOlderAutoApproved, "NoApprovalRequired", older),
|
||||
Uplift(109, NewerRevokedOlderAutoApproved, "Revoked", newer));
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static DispatchUpliftRequest Uplift(int id, int workOrderId, string status, DateTime createdDate) => new()
|
||||
{
|
||||
Id = id,
|
||||
DispatchId = 10 + workOrderId,
|
||||
RequestedNTE = 1000m + id,
|
||||
Status = status,
|
||||
RequiredTier = 1,
|
||||
NotificationStatus = "Pending",
|
||||
CreatedDate = createdDate,
|
||||
};
|
||||
|
||||
private static async Task<int[]> SearchIdsAsync(
|
||||
ApplicationDbContext context,
|
||||
bool hasUplift,
|
||||
List<string>? upliftStatuses = null)
|
||||
{
|
||||
var service = new WorkOrderAdvancedSearchService(
|
||||
new WorkOrderAdvancedSearchDataService(context),
|
||||
WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
var result = await service.SearchAsync(
|
||||
new WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||||
DateFrom = WeekStart,
|
||||
DateTo = WeekStart.AddDays(6),
|
||||
HasUplift = hasUplift,
|
||||
UpliftStatuses = upliftStatuses,
|
||||
PageSize = 50,
|
||||
},
|
||||
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||
null);
|
||||
|
||||
return result.Items.Select(i => i.Id).OrderBy(id => id).ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasUplift_ExcludesWorkOrdersWhoseOnlyUpliftsAreCancelledOrRevoked()
|
||||
{
|
||||
await using var context = await SeedAsync();
|
||||
|
||||
var ids = await SearchIdsAsync(context, hasUplift: true);
|
||||
|
||||
Assert.Equal(LiveUpliftWorkOrders, ids);
|
||||
Assert.DoesNotContain(WithdrawnOnly, ids);
|
||||
Assert.DoesNotContain(LegacyCancelledOnly, ids);
|
||||
Assert.DoesNotContain(RevokedOnlyViaLink, ids);
|
||||
Assert.DoesNotContain(ExpiredOnly, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasUplift_IncludesWorkOrderWithRevokedAndPendingUplift()
|
||||
{
|
||||
await using var context = await SeedAsync();
|
||||
|
||||
Assert.Contains(RevokedPlusPending, await SearchIdsAsync(context, hasUplift: true));
|
||||
Assert.Equal(
|
||||
new[] { RevokedPlusPending },
|
||||
await SearchIdsAsync(context, hasUplift: true, new List<string> { "pending" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasUplift_RejectedUpliftStillCounts()
|
||||
{
|
||||
await using var context = await SeedAsync();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { RejectedOnly },
|
||||
await SearchIdsAsync(context, hasUplift: true, new List<string> { "rejected" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasUplift_ExplicitCancelledStatus_FindsCancelledWithdrawnAndExpiredUplifts()
|
||||
{
|
||||
await using var context = await SeedAsync();
|
||||
|
||||
var ids = await SearchIdsAsync(context, hasUplift: true, new List<string> { "cancelled" });
|
||||
|
||||
Assert.Equal(new[] { WithdrawnOnly, LegacyCancelledOnly, ExpiredOnly }, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasUplift_ExplicitRevokedStatus_FindsRevokedUpliftsOnPrimaryAndLinkedDispatches()
|
||||
{
|
||||
await using var context = await SeedAsync();
|
||||
|
||||
var ids = await SearchIdsAsync(context, hasUplift: true, new List<string> { "revoked" });
|
||||
|
||||
Assert.Equal(new[] { RevokedOnlyViaLink, RevokedPlusPending, NewerRevokedOlderAutoApproved }, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BoardUpliftColumn_AgreesWithHasUpliftFilter()
|
||||
{
|
||||
await using var context = await SeedAsync();
|
||||
var filtered = await SearchIdsAsync(context, hasUplift: true);
|
||||
|
||||
var boardService = new WorkOrderBoardService(
|
||||
new WorkOrderBoardDataService(context),
|
||||
WorkOrderAccountTestHelpers.Resolver(context));
|
||||
var board = await boardService.GetBoardAsync(
|
||||
new WorkOrderBoardQueryDto { WeekStart = WeekStart },
|
||||
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||
null);
|
||||
|
||||
Assert.Equal(8, board.Scheduled.Count);
|
||||
foreach (var row in board.Scheduled)
|
||||
{
|
||||
Assert.Equal(filtered.Contains(row.Id), row.UpliftSummary!.HasUplift);
|
||||
}
|
||||
|
||||
var searchService = new WorkOrderAdvancedSearchService(
|
||||
new WorkOrderAdvancedSearchDataService(context),
|
||||
WorkOrderAccountTestHelpers.Resolver(context));
|
||||
var unfiltered = await searchService.SearchAsync(
|
||||
new WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||||
DateFrom = WeekStart,
|
||||
DateTo = WeekStart.AddDays(6),
|
||||
PageSize = 50,
|
||||
},
|
||||
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||
null);
|
||||
Assert.Equal(8, unfiltered.Items.Count());
|
||||
foreach (var item in unfiltered.Items)
|
||||
{
|
||||
Assert.Equal(filtered.Contains(item.Id), item.UpliftSummary!.HasUplift);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BoardUpliftColumn_ShowsNewestLiveUpliftAndNoStatusForNonLiveOnly()
|
||||
{
|
||||
await using var context = await SeedAsync();
|
||||
|
||||
var boardService = new WorkOrderBoardService(
|
||||
new WorkOrderBoardDataService(context),
|
||||
WorkOrderAccountTestHelpers.Resolver(context));
|
||||
var board = await boardService.GetBoardAsync(
|
||||
new WorkOrderBoardQueryDto { WeekStart = WeekStart },
|
||||
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||
null);
|
||||
var byId = board.Scheduled.ToDictionary(r => r.Id, r => r.UpliftSummary!);
|
||||
|
||||
foreach (var id in new[] { WithdrawnOnly, LegacyCancelledOnly, RevokedOnlyViaLink, ExpiredOnly, NoUplift })
|
||||
{
|
||||
Assert.False(byId[id].HasUplift);
|
||||
Assert.Null(byId[id].PrimaryStatus);
|
||||
Assert.Null(byId[id].Amount);
|
||||
}
|
||||
|
||||
Assert.Equal("pending", byId[RevokedPlusPending].PrimaryStatus);
|
||||
Assert.Equal(1, byId[RevokedPlusPending].PendingCount);
|
||||
Assert.Equal("rejected", byId[RejectedOnly].PrimaryStatus);
|
||||
Assert.Equal("auto_approved", byId[NewerRevokedOlderAutoApproved].PrimaryStatus);
|
||||
Assert.Equal(1108m, byId[NewerRevokedOlderAutoApproved].Amount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(UpliftStatus.Pending)]
|
||||
[InlineData(UpliftStatus.Approved)]
|
||||
[InlineData(UpliftStatus.Rejected)]
|
||||
[InlineData(UpliftStatus.ChangesRequested)]
|
||||
[InlineData(UpliftStatus.Withdrawn)]
|
||||
[InlineData(UpliftStatus.Expired)]
|
||||
[InlineData(UpliftStatus.NoApprovalRequired)]
|
||||
[InlineData(UpliftStatus.Revoked)]
|
||||
[InlineData(UpliftStatus.LegacyDenied)]
|
||||
[InlineData(UpliftStatus.LegacyCancelled)]
|
||||
public void LiveUpliftStatus_MatchesFrontendCancelledAndRevokedStatuses(string storedStatus)
|
||||
{
|
||||
var frontendStatus = WorkOrderUpliftContractMapper.ToFrontendStatus(storedStatus);
|
||||
var expectedLive = frontendStatus is not ("cancelled" or "revoked");
|
||||
|
||||
Assert.Equal(expectedLive, LiveUpliftStatus.IsLive(storedStatus));
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +48,11 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
|
||||
=> WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
|
||||
|
||||
// Same user as Dispatcher(), without the Admin role: the only kind of caller that
|
||||
// may revoke an auto-approved uplift, and only their own.
|
||||
private static ClaimsPrincipal DispatcherRoleOnly(string userId = "dispatcher-1")
|
||||
=> WorkOrderAccountTestHelpers.AccountUser(userId, 1, "Dispatcher");
|
||||
|
||||
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
|
||||
ApplicationDbContext context,
|
||||
WorkOrderType type = WorkOrderType.PM,
|
||||
|
|
@ -585,7 +590,7 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
workOrder.Id,
|
||||
created.Id,
|
||||
new RevokeWorkOrderUpliftRequestDto(),
|
||||
Dispatcher(),
|
||||
DispatcherRoleOnly(),
|
||||
CancellationToken.None);
|
||||
|
||||
// SH-196: revoking frees the allowance, so it must release the NTE too. Otherwise
|
||||
|
|
@ -744,7 +749,7 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
workOrder.Id,
|
||||
created!.Id,
|
||||
new RevokeWorkOrderUpliftRequestDto(),
|
||||
Dispatcher(),
|
||||
DispatcherRoleOnly(),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("revoked", revoked!.Status);
|
||||
|
|
@ -753,6 +758,35 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
.SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeAsync_AdminOwnerRevokingAutoApproved_IsForbiddenAndKeepsAllowanceConsumed()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var (workOrder, _) = await SeedWorkOrderAsync(context);
|
||||
var service = NewService(context);
|
||||
|
||||
var created = await service.CreateAsync(
|
||||
workOrder.Id,
|
||||
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" },
|
||||
Dispatcher(),
|
||||
CancellationToken.None);
|
||||
Assert.Equal("auto_approved", created!.Status);
|
||||
|
||||
await Assert.ThrowsAsync<UpliftForbiddenException>(() => service.RevokeAsync(
|
||||
workOrder.Id,
|
||||
created.Id,
|
||||
new RevokeWorkOrderUpliftRequestDto { Reason = "Wrong quote" },
|
||||
Dispatcher(),
|
||||
CancellationToken.None));
|
||||
|
||||
var stored = context.DispatchUpliftRequests.Single(u => u.Id == created.Id);
|
||||
Assert.Equal(UpliftStatus.NoApprovalRequired, stored.Status);
|
||||
Assert.Null(stored.DecisionNote);
|
||||
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
|
||||
Assert.Equal(400m, await new UpliftDataService(context)
|
||||
.SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LifecycleStatus.Completed)]
|
||||
[InlineData(LifecycleStatus.Canceled)]
|
||||
|
|
@ -780,7 +814,7 @@ public sealed class WorkOrderUpliftServiceTests
|
|||
workOrder.Id,
|
||||
100,
|
||||
new RevokeWorkOrderUpliftRequestDto(),
|
||||
Dispatcher(),
|
||||
DispatcherRoleOnly(),
|
||||
CancellationToken.None));
|
||||
Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue