Compare commits

...

23 commits

Author SHA1 Message Date
Alexandre Brandizzi
ca9322fa60 fix(auth): queue the reset email only after its code is stored 2026-09-25 18:29:33 -03:00
Alexandre Brandizzi
2f23f6fadc test(team-members): register reset email delivery in the invite test host 2026-09-25 18:10:10 -03:00
Alexandre Brandizzi
35e79a276d Merge branch 'fix/ab/sh-403-reset-code-hardening' into fix/ab/sh-403-reset-hardening-2 2026-09-25 17:54:13 -03:00
Alexandre Brandizzi
c860936d07 test(auth): give the reset test host's helpers names of their own 2026-09-25 17:52:14 -03:00
Alexandre Brandizzi
9198c5cd5d Merge remote-tracking branch 'origin/main' into fix/ab/sh-403-reset-code-hardening 2026-09-25 17:52:04 -03:00
Alexandre Brandizzi
5eb1323419 test(auth): give the reset test host's helpers names of their own 2026-09-25 17:51:26 -03:00
Alexandre Brandizzi
cf32dd2698
Merge pull request #184 from Sea-Haven-Industries/feat/ab/sh-322-overdue-type
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
feat(work-orders): add Overdue work order type
2026-09-25 19:52:19 +00:00
Alexandre Brandizzi
c6dcad8395
Merge pull request #189 from Sea-Haven-Industries/feat/ab/sh-385-invite-registration
feat(team-members): invite registration with emailed code confirmation (SH-385)
2026-09-25 19:52:15 +00:00
Alexandre Brandizzi
9084b7f642 fix(team-members): answer invalid_invite when send-code finds the invite closed
SendCodeAsync validates the invite, then starts the code with a conditional
update that also requires the invite to still be open. When an admin revoked
the link (or it was used) between those two reads, the refusal was reported as
resend_too_soon with a Retry-After, although the link was already dead.

On a refused start the invite is now read again: a closed invite gets the same
generic invalid_invite response as any other dead link, and a cooldown or send
limit refusal is computed from the fresh row.
2026-09-25 16:40:54 -03:00
Alexandre Brandizzi
fc3a29f399 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	SeaHaven.Services/Implementation/WorkOrderCompletionService.cs
2026-09-25 16:40:36 -03:00
Alexandre Brandizzi
47060f6a73 fix(work-orders): never return a severity on an Overdue board row
A standalone severity patch on an Overdue WO still stores the value,
because the board patches severity before type when correcting Overdue
to Emergency/Reactive and that write must not be dropped or rejected.
Project the severity as null for Overdue in the board row mapping, which
also feeds the PATCH response, search results and the detail view, so a
stored value never surfaces on a type that carries no severity.
2026-09-25 13:41:19 -03:00
Alexandre Brandizzi
bb41bfd8ed Merge remote-tracking branch 'origin/main' into HEAD 2026-09-25 13:40:06 -03:00
Alexandre Brandizzi
cc328ce22a fix(team-members): do not report an invite email for a deactivated member 2026-09-25 13:13:50 -03:00
Alexandre Brandizzi
afc2330184 fix(team-members): report only password-rule failures at finish as a rejected password 2026-09-25 13:05:35 -03:00
Alexandre Brandizzi
e53415de39 Merge remote-tracking branch 'origin/feat/ab/sh-386-password-policy' into feat/ab/sh-385-invite-registration 2026-09-25 13:04:18 -03:00
Alexandre Brandizzi
b50cd5f5df feat(team-members): report whether the re-invite after an email change was emailed 2026-09-25 12:54:27 -03:00
Alexandre Brandizzi
f491d4c721 fix(team-members): delete invites with their member, re-invite on email change, trust only 2xx SendGrid responses 2026-09-25 12:45:04 -03:00
Alexandre Brandizzi
e09ef061d1 Merge remote-tracking branch 'origin/main' into feat/ab/sh-385-invite-registration
# Conflicts:
#	Api.SeaHavenIndustries/Controllers/TeamMemberController.cs
2026-09-25 12:45:03 -03:00
Alexandre Brandizzi
385229c64d fix(team-members): keep omitted phone, report invite email failures, never echo invite errors 2026-09-25 12:11:52 -03:00
Alexandre Brandizzi
c0ae8479ce feat(team-members): invite registration with emailed code confirmation (SH-385) 2026-09-25 11:49:10 -03:00
Alexandre Brandizzi
c1910e5310 test(work-orders): pin severity-then-type correction from Overdue
The board sends one PATCH per field, severity before workOrderType, so
correcting an Overdue work order to Emergency or Reactive patches the
severity while the stored type is still Overdue. Dropping or rejecting a
severity patch for the current type would leave the corrected Emergency
work order with no severity. Overdue's no-severity rule is enforced when
the type changes, not on the severity patch.
2026-09-25 11:41:33 -03:00
Alexandre Brandizzi
d82fb18a3d fix(work-orders): map Overdue to PM catalog in service and template lists 2026-09-25 11:30:34 -03:00
Alexandre Brandizzi
b545d4a4fe feat(work-orders): add Overdue work order type
Overdue (8) is a dispatcher-assigned type, separate from the derived
past-due overlay. It takes no severity, resolves services and
completion-doc templates from the PM catalog, and filters as its own
type. The past-due flag now narrows a type filter instead of widening it,
and the dashboard breakdown partitions by stored type.
2026-09-25 10:57:34 -03:00
57 changed files with 7605 additions and 172 deletions

View file

@ -151,7 +151,7 @@ public class AuthenticationServiceTests
}
[Fact]
public async Task ForgetPassword_EmailThatCannotBeQueued_DropsTheCodeAndDoesNotCountTheRequest()
public async Task ForgetPassword_EmailThatCannotBeQueued_DoesNotCountTheRequest()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
@ -175,19 +175,38 @@ public class AuthenticationServiceTests
for (var request = 0; request < 4; request++)
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
// The three undelivered requests did not use up the hourly limit of three.
// The three undelivered requests did not use up the hourly limit of three, and every
// email carries the code stored just before it was queued.
email.Verify(e => e.TryEnqueue(user.Email!, It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(4));
stored.Should().HaveCount(4);
for (var request = 0; request < 3; request++)
for (var request = 0; request < 4; request++)
{
var code = System.Text.RegularExpressions.Regex.Match(bodies[request], @"Your Password Reset Code is: (\d{6})").Groups[1].Value;
PasswordResetCodeSecrets.Matches(ResetKey, stored[request].Salt, code, stored[request].Hash).Should().BeFalse();
PasswordResetCodeSecrets.Matches(ResetKey, stored[request].Salt, code, stored[request].Hash).Should().BeTrue();
}
var delivered = System.Text.RegularExpressions.Regex.Match(bodies[3], @"Your Password Reset Code is: (\d{6})").Groups[1].Value;
PasswordResetCodeSecrets.Matches(ResetKey, stored[3].Salt, delivered, stored[3].Hash).Should().BeTrue();
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ForgetPassword_WriteThatFailsOrIsCancelled_QueuesNoEmail(bool cancelled)
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable")));
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(true);
var act = () => NewService(userData, forget, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
email.Verify(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ForgetPassword_EmailThatCannotBeQueued_MakesTheSameDataCallsAsAnUnknownEmail()
{

View file

@ -213,10 +213,10 @@ public class DashboardServiceTests
AccountUser(1), query, CancellationToken.None);
stats.Total.Should().Be(4);
stats.Breakdown.Overdue.Should().Be(1);
stats.Breakdown.Overdue.Should().Be(0);
stats.Breakdown.Other.Should().Be(0);
stats.Breakdown.PM.Should().Be(2);
stats.Breakdown.Emergency.Should().Be(0);
stats.Breakdown.Emergency.Should().Be(1);
stats.Breakdown.Reactive.Should().Be(1);
(stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive
+ stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total);

View file

@ -0,0 +1,105 @@
using System.Net;
using Api.SeaHavenIndustries.Helper;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using SendGrid;
using SendGrid.Helpers.Mail;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class SendMessageTests
{
private const string Recipient = "taylor@example.com";
private const string Subject = "You're invited to Seahaven";
private const string RejectionBody = "{\"errors\":[{\"message\":\"taylor@example.com does not exist\"}]}";
[Theory]
[InlineData(HttpStatusCode.OK, true)]
[InlineData(HttpStatusCode.Accepted, true)]
[InlineData(HttpStatusCode.BadRequest, false)]
[InlineData(HttpStatusCode.Unauthorized, false)]
[InlineData(HttpStatusCode.TooManyRequests, false)]
[InlineData(HttpStatusCode.InternalServerError, false)]
public async Task EverySendPath_ReportsDeliveryOnlyForASuccessStatus(HttpStatusCode status, bool delivered)
{
var sender = new StubbedSendMessage(status, new CapturingLogger());
Assert.Equal(delivered, await sender.SendEMail(Recipient, Subject, "<p>Hi</p>"));
Assert.Equal(delivered, await sender.SendEmailAsync(Recipient, Subject, "<p>Hi</p>"));
Assert.Equal(delivered, await sender.SendEMailAttachment(Recipient, Subject, new byte[] { 1, 2, 3 }));
Assert.Equal(delivered, await sender.SendDispatchEmail(Recipient, Subject, "<p>Hi</p>", null));
}
[Fact]
public async Task ARejectedSend_LogsOnlyTheStatusCode()
{
var logger = new CapturingLogger();
var sender = new StubbedSendMessage(HttpStatusCode.BadRequest, logger);
Assert.False(await sender.SendEMail(Recipient, Subject, "<p>secret link</p>"));
var entry = Assert.Single(logger.Entries);
Assert.Contains("400", entry);
Assert.DoesNotContain(Recipient, entry);
Assert.DoesNotContain(Subject, entry);
Assert.DoesNotContain("secret link", entry);
Assert.DoesNotContain("errors", entry);
}
[Fact]
public async Task AFailedSend_LogsOnlyTheExceptionType()
{
var logger = new CapturingLogger();
var sender = new StubbedSendMessage(
new HttpRequestException($"could not reach SendGrid for {Recipient}"),
logger);
Assert.False(await sender.SendEMail(Recipient, Subject, "<p>Hi</p>"));
var entry = Assert.Single(logger.Entries);
Assert.Contains(nameof(HttpRequestException), entry);
Assert.DoesNotContain(Recipient, entry);
}
private sealed class StubbedSendMessage : SendMessage
{
private readonly Mock<ISendGridClient> _client = new();
public StubbedSendMessage(HttpStatusCode status, ILogger<SendMessage> logger)
: base(new ConfigurationBuilder().Build(), logger)
{
_client
.Setup(client => client.SendEmailAsync(It.IsAny<SendGridMessage>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new Response(status, new StringContent(RejectionBody), null));
}
public StubbedSendMessage(Exception failure, ILogger<SendMessage> logger)
: base(new ConfigurationBuilder().Build(), logger)
{
_client
.Setup(client => client.SendEmailAsync(It.IsAny<SendGridMessage>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(failure);
}
protected override ISendGridClient CreateClient(string? apiKey) => _client.Object;
}
private sealed class CapturingLogger : ILogger<SendMessage>
{
public List<string> Entries { get; } = new();
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) =>
Entries.Add($"{formatter(state, exception)} {exception}");
}
}

View file

@ -0,0 +1,171 @@
using System.Reflection;
using System.Text.Json;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Filters;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging.Abstractions;
using FluentAssertions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class TeamMemberInviteControllerTests
{
[Fact]
public void RegistrationRequests_CarryNoUserIdentifier()
{
var requestTypes = new[]
{
typeof(TeamMemberInviteTokenRequestDTO),
typeof(VerifyTeamMemberInviteCodeRequestDTO),
typeof(CompleteTeamMemberRegistrationRequestDTO)
};
var properties = requestTypes
.SelectMany(type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
.Select(property => property.Name)
.Distinct()
.OrderBy(name => name);
properties.Should().Equal("Code", "Password", "Phone", "Token");
}
[Fact]
public void RegistrationEndpoints_AreAnonymousAndNeverCached()
{
var type = typeof(TeamMemberInviteController);
type.GetCustomAttribute<AllowAnonymousAttribute>().Should().NotBeNull();
var cache = type.GetCustomAttribute<ResponseCacheAttribute>();
cache.Should().NotBeNull();
cache!.NoStore.Should().BeTrue();
}
[Fact]
public void RegistrationEndpoints_UseTheControllerScopedExceptionFilter()
{
var filter = typeof(TeamMemberInviteController).GetCustomAttribute<TypeFilterAttribute>();
filter.Should().NotBeNull();
filter!.ImplementationType.Should().Be(typeof(InviteRegistrationExceptionFilter));
}
[Fact]
public void ExceptionFilter_ReturnsAFixedBodyWithoutExceptionDetail()
{
var context = ExceptionContextFor(new ArgumentException(
"SELECT [Id] FROM [TeamMemberInvites] WHERE [TokenHash] = 'leak-me'"));
new InviteRegistrationExceptionFilter(NullLogger<InviteRegistrationExceptionFilter>.Instance)
.OnException(context);
context.ExceptionHandled.Should().BeTrue();
var result = context.Result.Should().BeOfType<ObjectResult>().Subject;
result.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
var body = JsonSerializer.Serialize(result.Value);
body.Should().Be(
"{\"code\":\"server_error\",\"message\":\"Something went wrong. Try again in a minute.\",\"retryAfterSeconds\":null}");
body.Should().NotContain("SELECT").And.NotContain("leak-me");
}
[Fact]
public void ExceptionFilter_LeavesCancellationToTheHost()
{
var context = ExceptionContextFor(new OperationCanceledException());
new InviteRegistrationExceptionFilter(NullLogger<InviteRegistrationExceptionFilter>.Instance)
.OnException(context);
context.ExceptionHandled.Should().BeFalse();
context.Result.Should().BeNull();
}
private static ExceptionContext ExceptionContextFor(Exception exception)
{
var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor());
return new ExceptionContext(actionContext, new List<IFilterMetadata>()) { Exception = exception };
}
[Theory]
[InlineData(TeamMemberRegistrationStatus.InvalidInvite)]
[InlineData(TeamMemberRegistrationStatus.Ok)]
public async Task Complete_WithoutASession_ReturnsTheGenericInviteError(TeamMemberRegistrationStatus status)
{
var service = new Mock<ITeamMemberRegistrationService>();
service.Setup(x => x.CompleteAsync(It.IsAny<CompleteTeamMemberRegistrationRequestDTO>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberRegistrationOutcomeDTO { Status = status });
var controller = new TeamMemberInviteController(service.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Complete(new CompleteTeamMemberRegistrationRequestDTO(), CancellationToken.None);
var failure = result.Should().BeOfType<ObjectResult>().Subject;
failure.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
failure.Value.Should().BeEquivalentTo(new
{
code = "invalid_invite",
message = TeamMemberInviteController.InvalidInviteMessage
});
}
[Fact]
public void ResendInvite_RequiresAuthenticatedCaller()
{
typeof(TeamMemberController).GetCustomAttribute<AuthorizeAttribute>().Should().NotBeNull();
typeof(TeamMemberController).GetMethod(nameof(TeamMemberController.ResendInvite))!
.GetCustomAttribute<AllowAnonymousAttribute>().Should().BeNull();
}
[Fact]
public async Task ResendInvite_Success_ReturnsInviteSent()
{
var invites = new Mock<ITeamMemberInviteService>();
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = true });
var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None);
result.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeEquivalentTo(new { message = "Invite sent" });
}
[Theory]
[InlineData("Forbidden", typeof(ForbidResult))]
[InlineData("Team member not found.", typeof(NotFoundObjectResult))]
[InlineData("Only pending team members can be re-invited.", typeof(BadRequestObjectResult))]
[InlineData("The invite could not be emailed. Try again.", typeof(BadRequestObjectResult))]
public async Task ResendInvite_Failure_MapsToHttpResult(string error, Type expected)
{
var invites = new Mock<ITeamMemberInviteService>();
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = false, Error = error });
var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None);
result.Should().BeOfType(expected);
}
private static TeamMemberController NewTeamMemberController(Mock<ITeamMemberInviteService> invites)
{
return new TeamMemberController(Mock.Of<ITeamMemberService>(), Mock.Of<ITeamPermissionService>(), invites.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test"))
}
}
};
}
}

View file

@ -85,7 +85,10 @@ public sealed class TeamMemberPermissionsEndpointTests
private static TeamMemberController Controller(
ITeamPermissionOverrideDataService data,
ClaimsPrincipal user) =>
new(Mock.Of<ITeamMemberService>(), new TeamPermissionService(data, new TeamPermissionPolicy()))
new(
Mock.Of<ITeamMemberService>(),
new TeamPermissionService(data, new TeamPermissionPolicy()),
Mock.Of<ITeamMemberInviteService>())
{
ControllerContext = new ControllerContext
{

View file

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

View file

@ -37,16 +37,7 @@ namespace Api.SeaHavenIndustries.Controllers
var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken);
if (result != null)
{
return Ok(new
{
token = result.Token,
expiration = result.Expiration,
email = result.Email,
userRoles = result.UserRole,
phoneNumber = result.PhoneNumber,
fullname = result.Fullname,
id = result.Id
});
return Ok(LoginPayload.From(result));
}
return Unauthorized();

View file

@ -12,13 +12,16 @@ public sealed class TeamMemberController : ControllerBase
{
private readonly ITeamMemberService _teamMemberService;
private readonly ITeamPermissionService _permissionService;
private readonly ITeamMemberInviteService _inviteService;
public TeamMemberController(
ITeamMemberService teamMemberService,
ITeamPermissionService permissionService)
ITeamPermissionService permissionService,
ITeamMemberInviteService inviteService)
{
_teamMemberService = teamMemberService;
_permissionService = permissionService;
_inviteService = inviteService;
}
[HttpGet("me/permissions")]
@ -28,6 +31,21 @@ public sealed class TeamMemberController : ControllerBase
return result.IsSuccess ? Ok(result.Value) : Unauthorized();
}
[HttpPost("{userId}/invite")]
public async Task<IActionResult> ResendInvite(string userId, CancellationToken cancellationToken)
{
var outcome = await _inviteService.ResendAsync(userId, User, cancellationToken);
if (outcome.Success)
return Ok(new { message = "Invite sent" });
return outcome.Error switch
{
"Forbidden" => Forbid(),
"Team member not found." => NotFound(new { message = outcome.Error }),
_ => BadRequest(new { message = outcome.Error })
};
}
[HttpPost]
public async Task<IActionResult> Create(
CreateTeamMemberRequestDTO request,

View file

@ -0,0 +1,95 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Filters;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers;
/// <summary>
/// Anonymous invite registration. The invite token travels only in request bodies,
/// and every failure maps to a fixed public message.
/// </summary>
[AllowAnonymous]
[ApiController]
[Route("api/team-member-invites")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
[TypeFilter(typeof(InviteRegistrationExceptionFilter))]
public sealed class TeamMemberInviteController : ControllerBase
{
public const string InvalidInviteMessage =
"This invite link is invalid or has expired. Ask your admin to send a new invite.";
private readonly ITeamMemberRegistrationService _registrationService;
public TeamMemberInviteController(ITeamMemberRegistrationService registrationService)
{
_registrationService = registrationService;
}
[HttpPost("resolve")]
public async Task<IActionResult> Resolve(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken)
{
var outcome = await _registrationService.ResolveAsync(request.Token, cancellationToken);
return outcome.Status == TeamMemberRegistrationStatus.Ok ? Ok(outcome.Details) : Failure(outcome);
}
[HttpPost("send-code")]
public async Task<IActionResult> SendCode(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken)
{
var outcome = await _registrationService.SendCodeAsync(request.Token, cancellationToken);
return outcome.Status == TeamMemberRegistrationStatus.Ok
? Ok(new { message = "Code sent" })
: Failure(outcome);
}
[HttpPost("verify-code")]
public async Task<IActionResult> VerifyCode(VerifyTeamMemberInviteCodeRequestDTO request, CancellationToken cancellationToken)
{
var outcome = await _registrationService.VerifyCodeAsync(request.Token, request.Code, cancellationToken);
return outcome.Status == TeamMemberRegistrationStatus.Ok
? Ok(new { message = "Email confirmed" })
: Failure(outcome);
}
[HttpPost("complete")]
public async Task<IActionResult> Complete(CompleteTeamMemberRegistrationRequestDTO request, CancellationToken cancellationToken)
{
var outcome = await _registrationService.CompleteAsync(request, cancellationToken);
return outcome.Status == TeamMemberRegistrationStatus.Ok && outcome.Session is not null
? Ok(LoginPayload.From(outcome.Session))
: Failure(outcome);
}
private IActionResult Failure(TeamMemberRegistrationOutcomeDTO outcome)
{
var (statusCode, code, message) = outcome.Status switch
{
TeamMemberRegistrationStatus.CodeIncorrect =>
(StatusCodes.Status400BadRequest, "code_incorrect", "Incorrect code — check your email and try again"),
TeamMemberRegistrationStatus.CodeExpired =>
(StatusCodes.Status400BadRequest, "code_expired", "This code has expired — request a new code"),
TeamMemberRegistrationStatus.CodeLocked =>
(StatusCodes.Status400BadRequest, "code_locked", "Too many incorrect attempts — request a new code"),
TeamMemberRegistrationStatus.ResendTooSoon =>
(StatusCodes.Status429TooManyRequests, "resend_too_soon", "Please wait a moment before requesting another code"),
TeamMemberRegistrationStatus.ResendLimitReached =>
(StatusCodes.Status429TooManyRequests, "resend_limit_reached", "Too many codes were requested. Ask your admin to send a new invite."),
TeamMemberRegistrationStatus.CodeDeliveryFailed =>
(StatusCodes.Status503ServiceUnavailable, "code_delivery_failed", "We couldn't send the code. Try again in a minute."),
TeamMemberRegistrationStatus.EmailNotConfirmed =>
(StatusCodes.Status400BadRequest, "email_not_confirmed", "Confirm your email before finishing registration"),
TeamMemberRegistrationStatus.PasswordRejected =>
(StatusCodes.Status400BadRequest, "password_rejected", "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."),
TeamMemberRegistrationStatus.InvalidPhone =>
(StatusCodes.Status400BadRequest, "invalid_phone", "Enter a valid phone number"),
_ => (StatusCodes.Status400BadRequest, "invalid_invite", InvalidInviteMessage)
};
if (outcome.RetryAfterSeconds is int retryAfter)
Response.Headers.RetryAfter = retryAfter.ToString(System.Globalization.CultureInfo.InvariantCulture);
return StatusCode(statusCode, new { code, message, retryAfterSeconds = outcome.RetryAfterSeconds });
}
}

View file

@ -0,0 +1,24 @@
using SeaHaven.Services.DTOs;
namespace Api.SeaHavenIndustries.DTOs
{
/// <summary>The session payload the web app stores after sign-in.</summary>
public static class LoginPayload
{
public static object From(LoginResultDTO result)
{
ArgumentNullException.ThrowIfNull(result);
return new
{
token = result.Token,
expiration = result.Expiration,
email = result.Email,
userRoles = result.UserRole,
phoneNumber = result.PhoneNumber,
fullname = result.Fullname,
id = result.Id
};
}
}
}

View file

@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
namespace Api.SeaHavenIndustries.Filters
{
/// <summary>
/// Anonymous invite endpoints never echo an exception: controller-scoped exception
/// filters run before the global ones, so no message, stack or SQL reaches the caller.
/// Cancellation is left to the host.
/// </summary>
public sealed class InviteRegistrationExceptionFilter : IExceptionFilter
{
public const string Message = "Something went wrong. Try again in a minute.";
private readonly ILogger<InviteRegistrationExceptionFilter> _logger;
public InviteRegistrationExceptionFilter(ILogger<InviteRegistrationExceptionFilter> logger)
{
_logger = logger;
}
public void OnException(ExceptionContext context)
{
if (context.Exception is OperationCanceledException)
return;
_logger.LogError(context.Exception, "Invite registration request failed.");
context.Result = new ObjectResult(new { code = "server_error", message = Message, retryAfterSeconds = (int?)null })
{
StatusCode = StatusCodes.Status500InternalServerError
};
context.ExceptionHandled = true;
}
}
}

View file

@ -9,14 +9,37 @@ namespace Api.SeaHavenIndustries.Helper
public class SendMessage : IEmailSender
{
private IConfiguration _configuration;
private readonly ILogger<SendMessage> _logger;
//string keysapi = "";
public SendMessage(IConfiguration configuration)
public SendMessage(IConfiguration configuration, ILogger<SendMessage> logger)
{
_configuration = configuration;
_logger = logger;
//keysapi = _configuration.GetValue<string>("SendGrid:ApiKey");
}
protected virtual ISendGridClient CreateClient(string? apiKey) => new SendGridClient(apiKey);
/// <summary>
/// SendGrid reports a rejected message through the status code, not an exception.
/// Only the status is logged: never the recipient, subject or response body.
/// </summary>
private bool Delivered(Response response)
{
if (response.IsSuccessStatusCode)
return true;
_logger.LogWarning("SendGrid rejected an email with status {StatusCode}.", (int)response.StatusCode);
return false;
}
private bool Failed(Exception ex)
{
_logger.LogWarning("SendGrid email send failed with {ExceptionType}.", ex.GetType().Name);
return false;
}
public async Task<bool> SendEMail(string emailTo, string subject, string body)
{
try
@ -41,7 +64,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
//var apiKey = "<SENDGRID_API_KEY>";
var client = new SendGridClient(apiKey);
var client = CreateClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries");
//var subject = "Sending with SendGrid is Fun";
var to = new EmailAddress(emailTo, "");
@ -51,12 +74,11 @@ namespace Api.SeaHavenIndustries.Helper
var htmlContent = body;
var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent);
var response = await client.SendEmailAsync(msg);
var dd = response.Body.ReadAsStringAsync();
return true;
return Delivered(response);
}
catch (Exception ex)
{
return false;
return Failed(ex);
}
}
public async Task<bool> SendEmailAsync(string emailTo, string subject, string htmlBody)
@ -69,7 +91,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
//var apiKey = "<SENDGRID_API_KEY>";
var client = new SendGridClient(apiKey);
var client = CreateClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries");
//var subject = "Sending with SendGrid is Fun";
var to = new EmailAddress(emailTo, "");
@ -92,13 +114,11 @@ namespace Api.SeaHavenIndustries.Helper
msg.Attachments = new List<SendGrid.Helpers.Mail.Attachment> { attachment };
var response = await client.SendEmailAsync(msg);
var dd = response.Body.ReadAsStringAsync();
return true;
return Delivered(response);
}
catch (Exception ex)
{
return false;
return Failed(ex);
}
}
public async Task<bool> SendDispatchEmail(string emailTo, string subject, string htmlBody, string? replyTo)
@ -106,7 +126,7 @@ namespace Api.SeaHavenIndustries.Helper
try
{
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
var client = new SendGridClient(apiKey);
var client = CreateClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea Haven Industries");
var to = new EmailAddress(emailTo, "");
@ -116,11 +136,11 @@ namespace Api.SeaHavenIndustries.Helper
msg.SetReplyTo(new EmailAddress(replyTo));
var response = await client.SendEmailAsync(msg);
return true;
return Delivered(response);
}
catch (Exception ex)
{
return false;
return Failed(ex);
}
}
}

View file

@ -328,6 +328,21 @@ namespace Data.SeaHavenIndustries
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
});
builder.Entity<TeamMemberInvite>(entity =>
{
entity.HasIndex(invite => invite.TokenHash)
.IsUnique()
.HasDatabaseName("IX_TeamMemberInvites_TokenHash");
entity.HasIndex(invite => invite.UserId)
.HasDatabaseName("IX_TeamMemberInvites_UserId");
entity.HasOne(invite => invite.User)
.WithMany()
.HasForeignKey(invite => invite.UserId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<UserServiceArea>(entity =>
{
entity.HasKey(area => new { area.UserId, area.Area });
@ -406,6 +421,7 @@ namespace Data.SeaHavenIndustries
public DbSet<Region> Regions { get; set; }
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
public DbSet<UserServiceArea> UserServiceAreas { get; set; }
public DbSet<TeamMemberInvite> TeamMemberInvites { get; set; }
public override int SaveChanges()
{

View file

@ -35,6 +35,8 @@ namespace Data.SeaHavenIndustries.Enums
Inspection = 5,
Reactive = 6,
AddOn = 7,
/// <summary>Client never responded after the due date passed. Distinct from the derived past-due overlay.</summary>
Overdue = 8,
Other = 99
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class AddTeamMemberInvites : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TeamMemberInvites",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
TokenHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UsedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
RevokedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CodeHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
CodeSalt = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
CodeExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CodeFailedAttempts = table.Column<int>(type: "int", nullable: false),
CodeSentAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CodeSendCount = table.Column<int>(type: "int", nullable: false),
EmailConfirmedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TeamMemberInvites", x => x.Id);
table.ForeignKey(
name: "FK_TeamMemberInvites_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_TeamMemberInvites_TokenHash",
table: "TeamMemberInvites",
column: "TokenHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TeamMemberInvites_UserId",
table: "TeamMemberInvites",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeamMemberInvites");
}
}
}

View file

@ -2120,6 +2120,71 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("TaskListTemplateItems");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CodeExpiresAt")
.HasColumnType("datetime2");
b.Property<int>("CodeFailedAttempts")
.HasColumnType("int");
b.Property<string>("CodeHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("CodeSalt")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<int>("CodeSendCount")
.HasColumnType("int");
b.Property<DateTime?>("CodeSentAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("EmailConfirmedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime2");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime?>("UsedAt")
.HasColumnType("datetime2");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("IX_TeamMemberInvites_TokenHash");
b.HasIndex("UserId")
.HasDatabaseName("IX_TeamMemberInvites_UserId");
b.ToTable("TeamMemberInvites");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
{
b.Property<int>("Id")
@ -3849,6 +3914,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Template");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b =>
{
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
{
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser")

View file

@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// A single-use registration invite for one pending team member. Only hashes of
/// the invite token and of the email confirmation code are stored.
/// </summary>
public class TeamMemberInvite
{
public int Id { get; set; }
[Required]
[MaxLength(450)]
public string UserId { get; set; } = string.Empty;
[ForeignKey(nameof(UserId))]
public virtual ApplicationUser? User { get; set; }
/// <summary>Lowercase hex SHA-256 of the raw invite token.</summary>
[Required]
[MaxLength(64)]
public string TokenHash { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime? UsedAt { get; set; }
public DateTime? RevokedAt { get; set; }
/// <summary>Lowercase hex SHA-256 of the salt followed by the confirmation code.</summary>
[MaxLength(64)]
public string? CodeHash { get; set; }
[MaxLength(32)]
public string? CodeSalt { get; set; }
public DateTime? CodeExpiresAt { get; set; }
public int CodeFailedAttempts { get; set; }
public DateTime? CodeSentAt { get; set; }
public int CodeSendCount { get; set; }
public DateTime? EmailConfirmedAt { get; set; }
}
}

View file

@ -0,0 +1,36 @@
namespace SeaHaven.DataServices.Dto
{
public sealed class TeamMemberInviteData
{
public required int Id { get; init; }
public required string UserId { get; init; }
public DateTime ExpiresAt { get; init; }
public DateTime? UsedAt { get; init; }
public DateTime? RevokedAt { get; init; }
public string? CodeHash { get; init; }
public string? CodeSalt { get; init; }
public DateTime? CodeExpiresAt { get; init; }
public int CodeFailedAttempts { get; init; }
public DateTime? CodeSentAt { get; init; }
public int CodeSendCount { get; init; }
public DateTime? EmailConfirmedAt { get; init; }
}
/// <summary>
/// Replaces the confirmation code on an open invite when the resend cooldown has
/// elapsed and the send limit has not been reached.
/// </summary>
public sealed class StartTeamMemberInviteCodeCommand
{
public required int InviteId { get; init; }
public required string CodeHash { get; init; }
public required string CodeSalt { get; init; }
public required DateTime Now { get; init; }
public required DateTime CodeExpiresAt { get; init; }
/// <summary>A code may be replaced only when the previous one was sent at or before this instant.</summary>
public required DateTime LastSentNoLaterThan { get; init; }
public required int MaxSends { get; init; }
}
}

View file

@ -58,9 +58,9 @@ namespace SeaHaven.DataServices.Helpers
}
/// <summary>
/// Filters by real <paramref name="types"/> (including <see cref="WorkOrderType.Other"/>)
/// and/or past-due rows when <paramref name="overdue"/> is true.
/// Types and overdue are combined with OR when both are present.
/// Filters by stored <paramref name="types"/> (including <see cref="WorkOrderType.Overdue"/> and
/// <see cref="WorkOrderType.Other"/>) and/or past-due rows when <paramref name="overdue"/> is true.
/// Past due is a status overlay, so types and overdue are combined with AND when both are present.
/// </summary>
public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter(
IQueryable<WorkOrder> query,
@ -94,8 +94,8 @@ namespace SeaHaven.DataServices.Helpers
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|| (includeAddOnFlag && w.IsAddOn));
&& ((w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|| (includeAddOnFlag && w.IsAddOn)));
}
return query.Where(w =>

View file

@ -57,6 +57,7 @@ namespace SeaHaven.DataServices.Helpers
|| (w.WorkOrderType == WorkOrderType.Project && "project".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Overdue && "overdue".Contains(s))
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.IsAddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))

View file

@ -0,0 +1,144 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class TeamMemberInviteDataService : ITeamMemberInviteDataService
{
private readonly ApplicationDbContext _context;
public TeamMemberInviteDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(invite);
_context.TeamMemberInvites.Add(invite);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task ReplaceOpenForUserAsync(
TeamMemberInvite invite,
DateTime now,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(invite);
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
await _context.TeamMemberInvites
.Where(existing => existing.UserId == invite.UserId
&& existing.UsedAt == null
&& existing.RevokedAt == null)
.ExecuteUpdateAsync(
setters => setters.SetProperty(existing => existing.RevokedAt, now),
cancellationToken);
_context.TeamMemberInvites.Add(invite);
await _context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
public Task<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken)
{
return _context.TeamMemberInvites
.AsNoTracking()
.Where(invite => invite.TokenHash == tokenHash)
.Select(invite => new TeamMemberInviteData
{
Id = invite.Id,
UserId = invite.UserId,
ExpiresAt = invite.ExpiresAt,
UsedAt = invite.UsedAt,
RevokedAt = invite.RevokedAt,
CodeHash = invite.CodeHash,
CodeSalt = invite.CodeSalt,
CodeExpiresAt = invite.CodeExpiresAt,
CodeFailedAttempts = invite.CodeFailedAttempts,
CodeSentAt = invite.CodeSentAt,
CodeSendCount = invite.CodeSendCount,
EmailConfirmedAt = invite.EmailConfirmedAt
})
.SingleOrDefaultAsync(cancellationToken);
}
public async Task<bool> TryStartCodeAsync(
StartTeamMemberInviteCodeCommand command,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(command);
var updated = await OpenInvite(command.InviteId, command.Now)
.Where(invite => invite.CodeSendCount < command.MaxSends
&& (invite.CodeSentAt == null || invite.CodeSentAt <= command.LastSentNoLaterThan))
.ExecuteUpdateAsync(
setters => setters
.SetProperty(invite => invite.CodeHash, command.CodeHash)
.SetProperty(invite => invite.CodeSalt, command.CodeSalt)
.SetProperty(invite => invite.CodeExpiresAt, command.CodeExpiresAt)
.SetProperty(invite => invite.CodeFailedAttempts, 0)
.SetProperty(invite => invite.CodeSentAt, command.Now)
.SetProperty(invite => invite.CodeSendCount, invite => invite.CodeSendCount + 1),
cancellationToken);
return updated == 1;
}
public async Task<bool> TryReserveCodeAttemptAsync(
int inviteId,
int maxAttempts,
DateTime now,
CancellationToken cancellationToken)
{
var updated = await OpenInvite(inviteId, now)
.Where(invite => invite.CodeHash != null
&& invite.CodeExpiresAt > now
&& invite.CodeFailedAttempts < maxAttempts)
.ExecuteUpdateAsync(
setters => setters.SetProperty(
invite => invite.CodeFailedAttempts,
invite => invite.CodeFailedAttempts + 1),
cancellationToken);
return updated == 1;
}
public async Task<bool> TryConfirmEmailAsync(
int inviteId,
string codeHash,
DateTime now,
CancellationToken cancellationToken)
{
var updated = await OpenInvite(inviteId, now)
.Where(invite => invite.CodeHash == codeHash && invite.CodeExpiresAt > now)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(invite => invite.EmailConfirmedAt, now)
.SetProperty(invite => invite.CodeHash, (string?)null)
.SetProperty(invite => invite.CodeSalt, (string?)null)
.SetProperty(invite => invite.CodeExpiresAt, (DateTime?)null)
.SetProperty(invite => invite.CodeFailedAttempts, 0),
cancellationToken);
return updated == 1;
}
public async Task<bool> TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken)
{
var updated = await OpenInvite(inviteId, now)
.Where(invite => invite.EmailConfirmedAt != null)
.ExecuteUpdateAsync(
setters => setters.SetProperty(invite => invite.UsedAt, now),
cancellationToken);
return updated == 1;
}
private IQueryable<TeamMemberInvite> OpenInvite(int inviteId, DateTime now)
{
return _context.TeamMemberInvites.Where(invite => invite.Id == inviteId
&& invite.UsedAt == null
&& invite.RevokedAt == null
&& invite.ExpiresAt > now);
}
}
}

View file

@ -159,6 +159,10 @@ namespace SeaHaven.DataServices.Implementation
.Where(permissionOverride => permissionOverride.UserId == id)
.ExecuteDeleteAsync(cancellationToken);
await _context.TeamMemberInvites
.Where(invite => invite.UserId == id)
.ExecuteDeleteAsync(cancellationToken);
await _context.Users
.Where(existingUser => existingUser.Id == id)
.ExecuteDeleteAsync(cancellationToken);

View file

@ -0,0 +1,30 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Dto;
namespace SeaHaven.DataServices.Interfaces
{
/// <summary>
/// Persistence for team member registration invites. Every state transition that
/// guards a security limit is a single conditional update, so concurrent requests
/// cannot exceed the limit; each returns whether the transition happened.
/// </summary>
public interface ITeamMemberInviteDataService
{
Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken);
/// <summary>Revokes every open invite for the invite's user and adds the new one, atomically.</summary>
Task ReplaceOpenForUserAsync(TeamMemberInvite invite, DateTime now, CancellationToken cancellationToken);
Task<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken);
Task<bool> TryStartCodeAsync(StartTeamMemberInviteCodeCommand command, CancellationToken cancellationToken);
/// <summary>Counts one verification attempt against a live code; false once the attempt limit is used up.</summary>
Task<bool> TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken);
Task<bool> TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken);
/// <summary>Marks an open, unexpired, email-confirmed invite as used; false when another request already did.</summary>
Task<bool> TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken);
}
}

View file

@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.DTOs;
@ -23,6 +24,13 @@ public class TeamMemberCreatedDTO
public string? Phone { get; init; }
public required IReadOnlyList<string> ServiceAreas { get; init; }
public bool PendingRegistration { get; init; }
/// <summary>
/// Set only when an invite was emailed: on create, and on an update that changed a pending member's email.
/// False means the invite email failed; re-invite from their details.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? InviteEmailSent { get; init; }
}
public sealed class TeamMemberDetailDTO : TeamMemberCreatedDTO

View file

@ -0,0 +1,71 @@
namespace SeaHaven.Services.DTOs;
/// <summary>A freshly issued invite. The raw token is never persisted and never printed.</summary>
public sealed class IssuedTeamMemberInvite
{
public IssuedTeamMemberInvite(string token, DateTime expiresAt)
{
Token = token;
ExpiresAt = expiresAt;
}
public string Token { get; }
public DateTime ExpiresAt { get; }
public override string ToString() => $"IssuedTeamMemberInvite {{ ExpiresAt = {ExpiresAt:O} }}";
}
public sealed class TeamMemberInviteResendOutcomeDTO
{
public bool Success { get; init; }
public string? Error { get; init; }
}
public enum TeamMemberRegistrationStatus
{
Ok,
InvalidInvite,
CodeIncorrect,
CodeExpired,
CodeLocked,
ResendTooSoon,
ResendLimitReached,
CodeDeliveryFailed,
EmailNotConfirmed,
PasswordRejected,
InvalidPhone
}
public sealed class TeamMemberInviteDetailsDTO
{
public required string Name { get; init; }
public required string Role { get; init; }
public string? Email { get; init; }
public string? Phone { get; init; }
}
public sealed class TeamMemberRegistrationOutcomeDTO
{
public TeamMemberRegistrationStatus Status { get; init; }
public TeamMemberInviteDetailsDTO? Details { get; init; }
public LoginResultDTO? Session { get; init; }
public int? RetryAfterSeconds { get; init; }
}
public sealed class TeamMemberInviteTokenRequestDTO
{
public string? Token { get; init; }
}
public sealed class VerifyTeamMemberInviteCodeRequestDTO
{
public string? Token { get; init; }
public string? Code { get; init; }
}
public sealed class CompleteTeamMemberRegistrationRequestDTO
{
public string? Token { get; init; }
public string? Password { get; init; }
public string? Phone { get; init; }
}

View file

@ -10,7 +10,7 @@ namespace SeaHaven.Services.DTOs
public List<string>? Dispatchers { get; set; }
public bool MyWorkOrders { get; set; }
public List<WorkOrderType>? Types { get; set; }
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>
/// <summary>When true, keep only past-due (isPastDue) rows. Combined with Types via AND.</summary>
public bool Overdue { get; set; }
public string? Search { get; set; }
}

View file

@ -98,7 +98,7 @@ namespace SeaHaven.Services.DTOs
public List<string>? Sites { get; set; }
public List<string>? Regions { get; set; }
public List<WorkOrderType>? Types { get; set; }
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>
/// <summary>When true, keep only past-due (isPastDue) rows. Combined with Types via AND.</summary>
public bool Overdue { get; set; }
public List<string>? Dispatchers { get; set; }
public List<LifecycleStatus>? Statuses { get; set; }

View file

@ -1,6 +1,7 @@
using FluentValidation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
using System.Reflection;
@ -13,6 +14,7 @@ namespace SeaHaven.Services.DependencyInjection
public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
{
services.TryAddSingleton(TimeProvider.System);
services.Configure<FrontendOptions>(configuration);
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<ApprovalsOptions>(configuration.GetSection(ApprovalsOptions.SectionName));

View file

@ -21,9 +21,7 @@ public static class DashboardMetrics
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus);
var completed = status == LifecycleStatus.Completed;
var canceled = status == LifecycleStatus.Canceled;
var pastDue = IsPastDue(workOrder, status, today);
AddBreakdownCount(breakdown, workOrder.WorkOrderType, pastDue);
AddBreakdownCount(breakdown, workOrder.WorkOrderType);
var due = completed || (workOrder.ScheduledDate is DateTime scheduled
&& DateOnly.FromDateTime(scheduled.Date) <= today);
@ -81,25 +79,11 @@ public static class DashboardMetrics
.ThenBy(bucket => bucket.Status, StringComparer.OrdinalIgnoreCase)
.ToList();
private static bool IsPastDue(
DashboardWorkOrder workOrder,
LifecycleStatus? status,
DateOnly today)
=> workOrder.ScheduledDate is DateTime scheduled
&& DateOnly.FromDateTime(scheduled.Date) < today
&& status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled;
// Partition by the stored type only; past due is an overlay on any type, not a bucket.
private static void AddBreakdownCount(
DashboardBreakdownDTO breakdown,
WorkOrderType? type,
bool pastDue)
WorkOrderType? type)
{
if (pastDue)
{
breakdown.Overdue++;
return;
}
switch (type)
{
case WorkOrderType.PM:
@ -111,6 +95,9 @@ public static class DashboardMetrics
case WorkOrderType.Reactive:
breakdown.Reactive++;
break;
case WorkOrderType.Overdue:
breakdown.Overdue++;
break;
default:
breakdown.Other++;
break;

View file

@ -0,0 +1,62 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Generation and hashing for invite tokens and email confirmation codes. Raw
/// values exist only in memory and in the email sent to the invitee.
/// </summary>
public static class TeamMemberInviteSecrets
{
/// <summary>256 bits from the OS CSPRNG.</summary>
public const int TokenBytes = 32;
/// <summary>Upper bound on an accepted token string; a 32-byte base64url token is 43 characters.</summary>
public const int MaxTokenLength = 128;
public static string NewToken()
{
return Convert.ToBase64String(RandomNumberGenerator.GetBytes(TokenBytes))
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
public static string HashToken(string token)
{
ArgumentNullException.ThrowIfNull(token);
return Sha256Hex(token);
}
public static string NewCode()
{
return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture);
}
public static string NewCodeSalt()
{
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
}
public static string HashCode(string salt, string code)
{
ArgumentNullException.ThrowIfNull(salt);
ArgumentNullException.ThrowIfNull(code);
return Sha256Hex(salt + ":" + code);
}
public static bool CodeMatches(string salt, string candidate, string expectedHash)
{
var actual = Encoding.ASCII.GetBytes(HashCode(salt, candidate));
var expected = Encoding.ASCII.GetBytes(expectedHash);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
private static string Sha256Hex(string value)
{
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
}
}
}

View file

@ -0,0 +1,17 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// The services registry and completion-doc templates are keyed on PM, Reactive and Emergency.
/// An Overdue work order is PM work the client never confirmed, so it uses the PM catalog.
/// </summary>
public static class WorkOrderCatalogType
{
public static WorkOrderType For(WorkOrderType type)
=> type == WorkOrderType.Overdue ? WorkOrderType.PM : type;
public static WorkOrderType? For(WorkOrderType? type)
=> type.HasValue ? For(type.Value) : null;
}
}

View file

@ -4,7 +4,7 @@ namespace SeaHaven.Services.Helpers
{
public static class WorkOrderTypeMapper
{
/// <summary>Board FE types. Overdue is not a type — use <see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>.</summary>
/// <summary>Board FE types. Overdue is a dispatcher-assigned type; past due is derived — see <see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>.</summary>
public static bool TryParseFeLabel(string? label, out WorkOrderType type)
{
type = default;
@ -20,7 +20,7 @@ namespace SeaHaven.Services.Helpers
"project" => Set(WorkOrderType.Project, out type),
"inspection" => Set(WorkOrderType.Inspection, out type),
"other" => Set(WorkOrderType.Other, out type),
"overdue" => false,
"overdue" => Set(WorkOrderType.Overdue, out type),
_ => Enum.TryParse(label, true, out type)
};
}
@ -30,6 +30,7 @@ namespace SeaHaven.Services.Helpers
WorkOrderType.PM => "PM",
WorkOrderType.Reactive => "Reactive",
WorkOrderType.Emergency => "Emergency",
WorkOrderType.Overdue => "Overdue",
WorkOrderType.AddOn => "Add-On",
WorkOrderType.PO => "PO",
WorkOrderType.Project => "Project",

View file

@ -54,47 +54,53 @@ namespace SeaHaven.Services.Implementation
{
var user = await _userManager.FindByNameAsync(username ?? "");
if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, password ?? ""))
{
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())
};
foreach (var userRole in userRoles)
{
authClaims.Add(new Claim(ClaimTypes.Role, userRole));
}
if (user.AccountId.HasValue)
{
authClaims.Add(new Claim(
SeaHavenClaimTypes.AccountId,
user.AccountId.Value.ToString()));
}
else if (userRoles.Contains("Admin"))
{
// Explicit signed org-wide elevation — never elevate via absence of account_id.
authClaims.Add(new Claim(
SeaHavenClaimTypes.OrgScope,
SeaHavenClaimTypes.OrgScopeAll));
}
var token = GetToken(authClaims);
return new LoginResultDTO
{
Token = new JwtSecurityTokenHandler().WriteToken(token),
Expiration = token.ValidTo,
Email = user.Email,
UserRole = userRoles.FirstOrDefault(),
PhoneNumber = user.PhoneNumber,
Fullname = user.FirstName + " " + user.LastName,
Id = user.Id
};
}
return await CreateSessionAsync(user, cancellationToken);
return null;
}
public async Task<LoginResultDTO> CreateSessionAsync(ApplicationUser user, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(user);
cancellationToken.ThrowIfCancellationRequested();
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())
};
foreach (var userRole in userRoles)
{
authClaims.Add(new Claim(ClaimTypes.Role, userRole));
}
if (user.AccountId.HasValue)
{
authClaims.Add(new Claim(
SeaHavenClaimTypes.AccountId,
user.AccountId.Value.ToString()));
}
else if (userRoles.Contains("Admin"))
{
// Explicit signed org-wide elevation — never elevate via absence of account_id.
authClaims.Add(new Claim(
SeaHavenClaimTypes.OrgScope,
SeaHavenClaimTypes.OrgScopeAll));
}
var token = GetToken(authClaims);
return new LoginResultDTO
{
Token = new JwtSecurityTokenHandler().WriteToken(token),
Expiration = token.ValidTo,
Email = user.Email,
UserRole = userRoles.FirstOrDefault(),
PhoneNumber = user.PhoneNumber,
Fullname = $"{user.FirstName} {user.LastName}".Trim(),
Id = user.Id
};
}
public async Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@ -164,12 +170,22 @@ namespace SeaHaven.Services.Implementation
var hash = PasswordResetCodeSecrets.Hash(ResetCodeKey, salt, code);
var active = user != null && user.IsDeleted != true && !string.IsNullOrWhiteSpace(user.Email);
// The request stays counted only once its row is stored. A code that cannot be
// emailed is stored unmatchable and not counted, and a failed or cancelled
// write is not counted either.
// The email is queued only after its code is stored, so a failed or cancelled
// write never sends a code that cannot be used. The request stays counted only
// when the row is stored and, for an account, its email was queued; when the
// queue is full the stored code is left unsent and the user can ask again.
var counted = false;
try
{
await _forgetPasswordDataService.ReplaceCodeAsync(
active ? user!.Email! : requested,
active ? user!.Id : string.Empty,
active ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(),
salt,
nowUtc.Add(ResetCodeLifetime),
nowUtc,
cancellationToken);
var queued = false;
if (active)
{
@ -177,14 +193,6 @@ namespace SeaHaven.Services.Implementation
queued = _resetEmails.TryEnqueue(user!.Email!, "Forget Password Request.", body);
}
await _forgetPasswordDataService.ReplaceCodeAsync(
active ? user!.Email! : requested,
active ? user!.Id : string.Empty,
queued ? hash : PasswordResetCodeSecrets.NewUnmatchableHash(),
salt,
nowUtc.Add(ResetCodeLifetime),
nowUtc,
cancellationToken);
counted = queued || !active;
}
finally

View file

@ -5,6 +5,7 @@ using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@ -34,7 +35,11 @@ namespace SeaHaven.Services.Implementation
WorkOrderType? workOrderType,
CancellationToken cancellationToken)
{
var rows = await _templateData.ListAsync(search, serviceKey, workOrderType, cancellationToken);
var rows = await _templateData.ListAsync(
search,
serviceKey,
WorkOrderCatalogType.For(workOrderType),
cancellationToken);
return rows.Select(Map).ToList();
}

View file

@ -5,6 +5,7 @@ using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@ -34,8 +35,9 @@ namespace SeaHaven.Services.Implementation
public async Task<IReadOnlyList<ServiceDto>> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken)
{
ValidateWorkOrderTypeFilter(workOrderType);
var rows = await _dataService.GetServiceRowsAsync(isActive, workOrderType, cancellationToken);
var catalogType = WorkOrderCatalogType.For(workOrderType);
ValidateWorkOrderTypeFilter(catalogType);
var rows = await _dataService.GetServiceRowsAsync(isActive, catalogType, cancellationToken);
return rows.Select(MapToDto).ToList();
}

View file

@ -0,0 +1,132 @@
using System.Net;
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation;
public sealed class TeamMemberInviteService : ITeamMemberInviteService
{
/// <summary>The prototype states no lifetime; seven days is the product decision.</summary>
public static readonly TimeSpan InviteLifetime = TimeSpan.FromDays(7);
private readonly ITeamMemberInviteDataService _inviteDataService;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IEmailSender _emailSender;
private readonly FrontendOptions _frontendOptions;
private readonly TimeProvider _timeProvider;
private readonly ILogger<TeamMemberInviteService> _logger;
public TeamMemberInviteService(
ITeamMemberInviteDataService inviteDataService,
UserManager<ApplicationUser> userManager,
IEmailSender emailSender,
IOptions<FrontendOptions> frontendOptions,
TimeProvider timeProvider,
ILogger<TeamMemberInviteService> logger)
{
_inviteDataService = inviteDataService;
_userManager = userManager;
_emailSender = emailSender;
_frontendOptions = frontendOptions.Value;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<IssuedTeamMemberInvite> CreateAsync(string userId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var (invite, issued) = NewInvite(userId);
await _inviteDataService.AddAsync(invite, cancellationToken);
return issued;
}
public async Task<bool> SendAsync(
IssuedTeamMemberInvite invite,
string email,
string name,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(invite);
cancellationToken.ThrowIfCancellationRequested();
var link = $"{_frontendOptions.FrontendBaseUrl?.TrimEnd('/')}/invite#{invite.Token}";
var body =
$"<p>Hi {WebUtility.HtmlEncode(name)},</p>" +
"<p>You've been added to Seahaven. Set your password and confirm your email to finish creating your account.</p>" +
// clicktracking=off stops SendGrid rewriting the link, so the token never passes through its redirect.
$"<p><a clicktracking=off href=\"{WebUtility.HtmlEncode(link)}\">Finish registration</a></p>" +
$"<p>This link expires in {InviteLifetime.Days} days and can be used once.</p>";
var sent = await _emailSender.SendEmailAsync(email, "You're invited to Seahaven", body);
if (!sent)
_logger.LogWarning("Team member invite email could not be sent for invite expiring {ExpiresAt}.", invite.ExpiresAt);
return sent;
}
public async Task<TeamMemberInviteResendOutcomeDTO> ResendAsync(
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
if (caller?.IsInRole("Admin") != true)
return ResendFailure("Forbidden");
cancellationToken.ThrowIfCancellationRequested();
var user = await _userManager.FindByIdAsync(userId);
if (user is null)
return ResendFailure("Team member not found.");
if (user.IsDeleted == true || user.PendingRegistration != true || string.IsNullOrWhiteSpace(user.Email))
return ResendFailure("Only pending team members can be re-invited.");
if (await ReissueAsync(user, cancellationToken) != true)
return ResendFailure("The invite could not be emailed. Try again.");
return new TeamMemberInviteResendOutcomeDTO { Success = true };
}
public async Task<bool?> ReissueAsync(ApplicationUser user, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(user);
ArgumentException.ThrowIfNullOrWhiteSpace(user.Email);
var (invite, issued) = NewInvite(user.Id);
await _inviteDataService.ReplaceOpenForUserAsync(invite, NowUtc(), cancellationToken);
// A deactivated member cannot register; the admin re-sends the invite after reactivating them.
if (user.IsDeleted == true)
return null;
var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim();
return await SendAsync(issued, user.Email, name, cancellationToken);
}
private (TeamMemberInvite Invite, IssuedTeamMemberInvite Issued) NewInvite(string userId)
{
var now = NowUtc();
var token = TeamMemberInviteSecrets.NewToken();
var invite = new TeamMemberInvite
{
UserId = userId,
TokenHash = TeamMemberInviteSecrets.HashToken(token),
CreatedAt = now,
ExpiresAt = now.Add(InviteLifetime)
};
return (invite, new IssuedTeamMemberInvite(token, invite.ExpiresAt));
}
private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime;
private static TeamMemberInviteResendOutcomeDTO ResendFailure(string error) =>
new() { Success = false, Error = error };
}

View file

@ -0,0 +1,270 @@
using System.Text.RegularExpressions;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation;
public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrationService
{
public static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(15);
public static readonly TimeSpan ResendCooldown = TimeSpan.FromSeconds(60);
public const int MaxCodeAttempts = 5;
public const int MaxCodeSends = 10;
private readonly ITeamMemberInviteDataService _inviteDataService;
private readonly IUserDataService _userDataService;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IAuthenticationService _authenticationService;
private readonly IEmailSender _emailSender;
private readonly TimeProvider _timeProvider;
private readonly ILogger<TeamMemberRegistrationService> _logger;
public TeamMemberRegistrationService(
ITeamMemberInviteDataService inviteDataService,
IUserDataService userDataService,
UserManager<ApplicationUser> userManager,
IAuthenticationService authenticationService,
IEmailSender emailSender,
TimeProvider timeProvider,
ILogger<TeamMemberRegistrationService> logger)
{
_inviteDataService = inviteDataService;
_userDataService = userDataService;
_userManager = userManager;
_authenticationService = authenticationService;
_emailSender = emailSender;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<TeamMemberRegistrationOutcomeDTO> ResolveAsync(string? token, CancellationToken cancellationToken)
{
var context = await LoadAsync(token, NowUtc(), cancellationToken);
if (context is null)
return Outcome(TeamMemberRegistrationStatus.InvalidInvite);
var roles = await _userManager.GetRolesAsync(context.User);
var role = roles.FirstOrDefault();
return new TeamMemberRegistrationOutcomeDTO
{
Status = TeamMemberRegistrationStatus.Ok,
Details = new TeamMemberInviteDetailsDTO
{
Name = $"{context.User.FirstName ?? ""} {context.User.LastName ?? ""}".Trim(),
Role = TeamMemberConstants.CanonicalRole(role) ?? role ?? "",
Email = context.User.Email,
Phone = context.User.Contact ?? context.User.PhoneNumber
}
};
}
public async Task<TeamMemberRegistrationOutcomeDTO> SendCodeAsync(string? token, CancellationToken cancellationToken)
{
var now = NowUtc();
var context = await LoadAsync(token, now, cancellationToken);
if (context is null || string.IsNullOrWhiteSpace(context.User.Email))
return Outcome(TeamMemberRegistrationStatus.InvalidInvite);
var code = TeamMemberInviteSecrets.NewCode();
var salt = TeamMemberInviteSecrets.NewCodeSalt();
var started = await _inviteDataService.TryStartCodeAsync(
new StartTeamMemberInviteCodeCommand
{
InviteId = context.Invite.Id,
CodeHash = TeamMemberInviteSecrets.HashCode(salt, code),
CodeSalt = salt,
Now = now,
CodeExpiresAt = now.Add(CodeLifetime),
LastSentNoLaterThan = now.Subtract(ResendCooldown),
MaxSends = MaxCodeSends
},
cancellationToken);
if (!started)
{
// The refusal may be the invite closing since it was read, not the cooldown or limit:
// read it again so a revoked or used link gets the same answer as any other dead link.
var current = await LoadAsync(token, now, cancellationToken);
return current is null
? Outcome(TeamMemberRegistrationStatus.InvalidInvite)
: ResendRefusal(current.Invite, now);
}
// Read the address at send time so an admin's correction is honoured.
var body =
$"<p>Your Seahaven confirmation code is <strong>{code}</strong>.</p>" +
$"<p>It expires in {CodeLifetime.TotalMinutes:0} minutes. If you didn't request it, you can ignore this email.</p>";
if (!await _emailSender.SendEmailAsync(context.User.Email, "Your Seahaven confirmation code", body))
{
_logger.LogWarning("Confirmation code email could not be sent for team member invite {InviteId}.", context.Invite.Id);
return Outcome(TeamMemberRegistrationStatus.CodeDeliveryFailed);
}
return Outcome(TeamMemberRegistrationStatus.Ok);
}
public async Task<TeamMemberRegistrationOutcomeDTO> VerifyCodeAsync(
string? token,
string? code,
CancellationToken cancellationToken)
{
var now = NowUtc();
var context = await LoadAsync(token, now, cancellationToken);
if (context is null)
return Outcome(TeamMemberRegistrationStatus.InvalidInvite);
var invite = context.Invite;
if (invite.CodeHash is null || invite.CodeSalt is null || invite.CodeExpiresAt is null || invite.CodeExpiresAt <= now)
return Outcome(TeamMemberRegistrationStatus.CodeExpired);
// Reserve the attempt before comparing, so parallel guesses cannot exceed the limit.
if (!await _inviteDataService.TryReserveCodeAttemptAsync(invite.Id, MaxCodeAttempts, now, cancellationToken))
{
return Outcome(invite.CodeFailedAttempts >= MaxCodeAttempts
? TeamMemberRegistrationStatus.CodeLocked
: TeamMemberRegistrationStatus.CodeExpired);
}
if (!TeamMemberInviteSecrets.CodeMatches(invite.CodeSalt, (code ?? "").Trim(), invite.CodeHash))
{
return Outcome(invite.CodeFailedAttempts + 1 >= MaxCodeAttempts
? TeamMemberRegistrationStatus.CodeLocked
: TeamMemberRegistrationStatus.CodeIncorrect);
}
return await _inviteDataService.TryConfirmEmailAsync(invite.Id, invite.CodeHash, now, cancellationToken)
? Outcome(TeamMemberRegistrationStatus.Ok)
: Outcome(TeamMemberRegistrationStatus.CodeExpired);
}
public async Task<TeamMemberRegistrationOutcomeDTO> CompleteAsync(
CompleteTeamMemberRegistrationRequestDTO request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var now = NowUtc();
var context = await LoadAsync(request.Token, now, cancellationToken);
if (context is null)
return Outcome(TeamMemberRegistrationStatus.InvalidInvite);
if (context.Invite.EmailConfirmedAt is null)
return Outcome(TeamMemberRegistrationStatus.EmailNotConfirmed);
// An omitted phone keeps what the admin entered; an explicit empty value clears it.
var updatePhone = request.Phone is not null;
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
if (phone is not null && !PhonePattern().IsMatch(phone))
return Outcome(TeamMemberRegistrationStatus.InvalidPhone);
var user = context.User;
try
{
await _userDataService.ExecuteTransactionalAsync(
async transactionCancellationToken =>
{
if (!await _inviteDataService.TryClaimAsync(context.Invite.Id, now, transactionCancellationToken))
throw new RegistrationAbortedException(TeamMemberRegistrationStatus.InvalidInvite);
user.EmailConfirmed = true;
user.PendingRegistration = false;
user.UniqueName = "Active";
if (updatePhone)
{
user.PhoneNumber = phone;
user.Contact = phone;
}
// Identity applies the shared password policy and persists the user.
var result = await _userManager.AddPasswordAsync(user, request.Password ?? "");
if (!result.Succeeded)
throw new RegistrationAbortedException(StatusFor(result));
},
cancellationToken);
}
catch (RegistrationAbortedException aborted)
{
return Outcome(aborted.Status);
}
_logger.LogInformation("Team member {UserId} completed invite registration.", user.Id);
return new TeamMemberRegistrationOutcomeDTO
{
Status = TeamMemberRegistrationStatus.Ok,
Session = await _authenticationService.CreateSessionAsync(user, cancellationToken)
};
}
private async Task<RegistrationContext?> LoadAsync(string? token, DateTime now, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(token) || token.Length > TeamMemberInviteSecrets.MaxTokenLength)
return null;
var invite = await _inviteDataService.GetByTokenHashAsync(
TeamMemberInviteSecrets.HashToken(token.Trim()),
cancellationToken);
if (invite is null || invite.UsedAt is not null || invite.RevokedAt is not null || invite.ExpiresAt <= now)
return null;
var user = await _userManager.FindByIdAsync(invite.UserId);
if (user is null || user.IsDeleted == true || user.PendingRegistration != true)
return null;
return new RegistrationContext(invite, user);
}
private static TeamMemberRegistrationOutcomeDTO ResendRefusal(TeamMemberInviteData invite, DateTime now)
{
if (invite.CodeSendCount >= MaxCodeSends)
return Outcome(TeamMemberRegistrationStatus.ResendLimitReached);
var allowedAt = (invite.CodeSentAt ?? now).Add(ResendCooldown);
return new TeamMemberRegistrationOutcomeDTO
{
Status = TeamMemberRegistrationStatus.ResendTooSoon,
RetryAfterSeconds = Math.Max(1, (int)Math.Ceiling((allowedAt - now).TotalSeconds))
};
}
private static TeamMemberRegistrationStatus StatusFor(IdentityResult result)
{
// A password already set means the account was registered by another route.
if (result.Errors.Any(error => error.Code == nameof(IdentityErrorDescriber.UserAlreadyHasPassword)))
return TeamMemberRegistrationStatus.InvalidInvite;
if (IdentityPasswordPolicy.IsPolicyRejection(result))
return TeamMemberRegistrationStatus.PasswordRejected;
// Anything else (a concurrency conflict, a store failure) is not the member's password:
// roll back and let the endpoint answer with its generic error. Only codes are recorded.
throw new InvalidOperationException(
"Identity refused the registration: " + string.Join(", ", result.Errors.Select(error => error.Code)));
}
private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime;
private static TeamMemberRegistrationOutcomeDTO Outcome(TeamMemberRegistrationStatus status) =>
new() { Status = status };
[GeneratedRegex(@"^[0-9+()\-.\s]{7,32}$")]
private static partial Regex PhonePattern();
private sealed record RegistrationContext(TeamMemberInviteData Invite, ApplicationUser User);
private sealed class RegistrationAbortedException : Exception
{
public RegistrationAbortedException(TeamMemberRegistrationStatus status)
: base(status.ToString())
{
Status = status;
}
public TeamMemberRegistrationStatus Status { get; }
}
}

View file

@ -20,6 +20,7 @@ public sealed class TeamMemberService : ITeamMemberService
private readonly ITeamPermissionOverrideDataService _permissionDataService;
private readonly IUserDataService _userDataService;
private readonly ITeamPermissionService _permissionService;
private readonly ITeamMemberInviteService _inviteService;
public TeamMemberService(
UserManager<ApplicationUser> userManager,
@ -27,7 +28,8 @@ public sealed class TeamMemberService : ITeamMemberService
IUserServiceAreaDataService areaDataService,
ITeamPermissionOverrideDataService permissionDataService,
IUserDataService userDataService,
ITeamPermissionService permissionService)
ITeamPermissionService permissionService,
ITeamMemberInviteService inviteService)
{
_userManager = userManager;
_roleManager = roleManager;
@ -35,6 +37,7 @@ public sealed class TeamMemberService : ITeamMemberService
_permissionDataService = permissionDataService;
_userDataService = userDataService;
_permissionService = permissionService;
_inviteService = inviteService;
}
public async Task<CreateTeamMemberOutcomeDTO> CreateAsync(
@ -68,6 +71,7 @@ public sealed class TeamMemberService : ITeamMemberService
PendingRegistrationCreatedDate = now
};
IssuedTeamMemberInvite? invite = null;
try
{
await _userDataService.ExecuteTransactionalAsync(
@ -100,6 +104,7 @@ public sealed class TeamMemberService : ITeamMemberService
await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken);
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken);
invite = await _inviteService.CreateAsync(user.Id, transactionCancellationToken);
},
cancellationToken);
}
@ -108,6 +113,10 @@ public sealed class TeamMemberService : ITeamMemberService
return Failure(exception.Message);
}
// The member and their invite commit together; the email goes out only after
// commit, so a rolled-back member never receives a link.
var inviteEmailSent = await _inviteService.SendAsync(invite!, user.Email!, user.FirstName!, cancellationToken);
return new CreateTeamMemberOutcomeDTO
{
Success = true,
@ -120,7 +129,8 @@ public sealed class TeamMemberService : ITeamMemberService
Email = user.Email,
Phone = user.PhoneNumber,
ServiceAreas = areas!,
PendingRegistration = true
PendingRegistration = true,
InviteEmailSent = inviteEmailSent
}
};
}
@ -189,7 +199,8 @@ public sealed class TeamMemberService : ITeamMemberService
user.UniqueName = request.IsActive ? ActiveStatus : "Inactive";
user.IsDeleted = !request.IsActive;
if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase))
var emailChanged = !string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase);
if (emailChanged)
{
var emailResult = await _userManager.SetEmailAsync(user, email!);
if (!emailResult.Succeeded)
@ -227,7 +238,12 @@ public sealed class TeamMemberService : ITeamMemberService
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
}
return await OperationSuccessAsync(user, caller, cancellationToken);
// An invite sent to the old address, and any code confirmed through it, must not register the new one.
bool? inviteEmailSent = null;
if (emailChanged && user.PendingRegistration == true)
inviteEmailSent = await _inviteService.ReissueAsync(user, cancellationToken);
return await OperationSuccessAsync(user, caller, cancellationToken, inviteEmailSent);
}
private static void ApplyName(ApplicationUser user, string fullName)
@ -262,7 +278,8 @@ public sealed class TeamMemberService : ITeamMemberService
private async Task<TeamMemberOperationOutcomeDTO> OperationSuccessAsync(
ApplicationUser user,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
bool? inviteEmailSent = null)
{
var roles = await _userManager.GetRolesAsync(user);
var profile = await _permissionService.GetProfileAsync(user.Id, caller, cancellationToken);
@ -280,6 +297,7 @@ public sealed class TeamMemberService : ITeamMemberService
Phone = user.Contact ?? user.PhoneNumber,
ServiceAreas = areas,
PendingRegistration = user.PendingRegistration == true,
InviteEmailSent = inviteEmailSent,
Status = user.UniqueName ?? "Active",
IsAccountOwner = user.IsAccountOwner,
Permissions = profile?.Value?.Permissions ?? Array.Empty<TeamPermissionValueDTO>()

View file

@ -298,7 +298,8 @@ namespace SeaHaven.Services.Implementation
return null;
}
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrderType))
var catalogType = WorkOrderCatalogType.For(workOrderType);
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(catalogType))
{
if (serviceId.HasValue)
throw new WorkOrderBoardValidationException(
@ -307,7 +308,7 @@ namespace SeaHaven.Services.Implementation
return null;
}
var services = await _servicesRegistryService.GetAllAsync(true, workOrderType, cancellationToken);
var services = await _servicesRegistryService.GetAllAsync(true, catalogType, cancellationToken);
if (serviceId.HasValue)
{
var selected = services.FirstOrDefault(service => service.Id == serviceId.Value);

View file

@ -147,7 +147,12 @@ namespace SeaHaven.Services.Implementation
MediaCount = row.MediaCount,
CompletedDate = row.CompletedDate,
FlagColor = row.FlagColor,
Severity = WorkOrderSeverityRules.ParseLevel(row.Severity),
// Overdue never carries a severity. A standalone severity patch can still
// store one (the board patches severity before type when correcting Overdue
// to Emergency/Reactive), so the row never returns it for an Overdue WO.
Severity = row.WorkOrderType == WorkOrderType.Overdue
? null
: WorkOrderSeverityRules.ParseLevel(row.Severity),
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion,

View file

@ -370,11 +370,6 @@ namespace SeaHaven.Services.Implementation
if (string.IsNullOrWhiteSpace(value))
throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required.");
if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase))
throw new WorkOrderBoardValidationException(
"InvalidValue",
"Overdue is not a work order type; it is derived from dueDate (isPastDue).");
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
@ -426,10 +421,11 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("ServiceInvalid", "serviceId must be a positive integer.");
var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken);
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM)
var catalogType = WorkOrderCatalogType.For(workOrder.WorkOrderType ?? WorkOrderType.PM);
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(catalogType)
|| service == null
|| !service.IsActive
|| !service.SupportedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM))
|| !service.SupportedWorkOrderTypes.Contains(catalogType))
{
throw new WorkOrderBoardValidationException(
"ServiceInvalid",
@ -458,7 +454,7 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("ServiceTypeInvalid", "Service registry is unavailable.");
var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken);
if (service == null || !service.SupportedWorkOrderTypes.Contains(workOrderType))
if (service == null || !service.SupportedWorkOrderTypes.Contains(WorkOrderCatalogType.For(workOrderType)))
throw new WorkOrderBoardValidationException(
"ServiceTypeInvalid",
"The selected service does not support this work order type.");
@ -467,7 +463,6 @@ namespace SeaHaven.Services.Implementation
private static WorkOrderType ParseWorkOrderType(string? value)
{
if (string.IsNullOrWhiteSpace(value)
|| string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase)
|| !WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
{
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");

View file

@ -93,7 +93,9 @@ namespace SeaHaven.Services.Implementation
await _completionData.SaveAsync(cancellationToken);
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType);
var template = await _templateData.ResolveForWorkOrderAsync(
workOrder.Trade,
WorkOrderCatalogType.For(workOrder.WorkOrderType));
var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId);
return new WorkOrderCompletionDto

View file

@ -48,7 +48,7 @@ namespace SeaHaven.Services.Implementation
var template = await _templateData.ResolveForWorkOrderAsync(
extended?.Trade ?? boardRow.Pm,
boardRow.WorkOrderType);
WorkOrderCatalogType.For(boardRow.WorkOrderType));
var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments);
var authorNames = await _userDataService.GetDisplayNamesByIdsAsync(authorIds);

View file

@ -5,6 +5,8 @@ namespace SeaHaven.Services.Interfaces
public interface IAuthenticationService
{
Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken);
/// <summary>Builds the same signed session payload that a successful login returns.</summary>
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);
/// <summary>

View file

@ -0,0 +1,31 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces;
public interface ITeamMemberInviteService
{
/// <summary>Persists a new invite for a pending member and returns its raw token.</summary>
Task<IssuedTeamMemberInvite> CreateAsync(string userId, CancellationToken cancellationToken);
/// <summary>Emails the invite link. Returns false when the email could not be handed off.</summary>
Task<bool> SendAsync(
IssuedTeamMemberInvite invite,
string email,
string name,
CancellationToken cancellationToken);
/// <summary>
/// Revokes the member's open invites, including any email confirmation made through them,
/// and emails a new invite to their current address unless they are deactivated.
/// Returns whether that email went out, or null when the member is deactivated and nothing was emailed.
/// </summary>
Task<bool?> ReissueAsync(ApplicationUser user, CancellationToken cancellationToken);
/// <summary>Admin-only: revokes a pending member's open invites and emails a new one.</summary>
Task<TeamMemberInviteResendOutcomeDTO> ResendAsync(
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
}

View file

@ -0,0 +1,23 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces;
/// <summary>
/// The anonymous invite-registration flow. Every operation is scoped by the invite
/// token alone; unknown, expired, used and revoked invites share one outcome.
/// </summary>
public interface ITeamMemberRegistrationService
{
Task<TeamMemberRegistrationOutcomeDTO> ResolveAsync(string? token, CancellationToken cancellationToken);
Task<TeamMemberRegistrationOutcomeDTO> SendCodeAsync(string? token, CancellationToken cancellationToken);
Task<TeamMemberRegistrationOutcomeDTO> VerifyCodeAsync(
string? token,
string? code,
CancellationToken cancellationToken);
Task<TeamMemberRegistrationOutcomeDTO> CompleteAsync(
CompleteTeamMemberRegistrationRequestDTO request,
CancellationToken cancellationToken);
}

View file

@ -123,7 +123,7 @@ public sealed class PasswordResetAbuseLimitsTests
[Fact]
public void Concurrent_checks_can_never_exceed_the_account_budget()
{
var throttle = new InMemoryPasswordResetThrottle(new ManualTimeProvider());
var throttle = new InMemoryPasswordResetThrottle(new ResetTestClock());
var reserved = 0;
Parallel.For(0, 64, _ =>
@ -138,7 +138,7 @@ public sealed class PasswordResetAbuseLimitsTests
[Fact]
public void A_matching_check_gives_its_reservation_back()
{
var throttle = new InMemoryPasswordResetThrottle(new ManualTimeProvider());
var throttle = new InMemoryPasswordResetThrottle(new ResetTestClock());
for (var check = 0; check < 50; check++)
{

View file

@ -46,9 +46,9 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
}
public HttpClient Client { get; }
public CapturingEmailSender Sent { get; private init; } = null!;
public ManualTimeProvider Time { get; private init; } = null!;
public CapturingLoggerProvider Logged { get; private init; } = null!;
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)
@ -56,9 +56,9 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
var databasePath = Path.Combine(Path.GetTempPath(), $"password-reset-{Guid.NewGuid():N}.db");
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString();
var sent = new CapturingEmailSender();
var time = new ManualTimeProvider();
var logged = new CapturingLoggerProvider();
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");
@ -258,7 +258,7 @@ internal sealed class PasswordResetTestHost : IAsyncDisposable
}
}
internal sealed class ManualTimeProvider : TimeProvider
internal sealed class ResetTestClock : TimeProvider
{
private DateTimeOffset _now = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero);
@ -267,7 +267,7 @@ internal sealed class ManualTimeProvider : TimeProvider
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
internal sealed class CapturingEmailSender : IEmailSender
internal sealed class ResetEmailOutbox : IEmailSender
{
private static readonly Regex CodePattern = new(@"Your Password Reset Code is: (\d{6})", RegexOptions.CultureInvariant);
@ -286,7 +286,7 @@ internal sealed class CapturingEmailSender : IEmailSender
}
}
internal sealed class CapturingLoggerProvider : ILoggerProvider
internal sealed class ResetLogCapture : ILoggerProvider
{
public ConcurrentQueue<string> Lines { get; } = new();
@ -298,10 +298,10 @@ internal sealed class CapturingLoggerProvider : ILoggerProvider
private sealed class CapturingLogger : ILogger
{
private readonly CapturingLoggerProvider _owner;
private readonly ResetLogCapture _owner;
private readonly string _category;
public CapturingLogger(CapturingLoggerProvider owner, string category)
public CapturingLogger(ResetLogCapture owner, string category)
{
_owner = owner;
_category = category;

View file

@ -35,7 +35,8 @@ public sealed class TeamMemberCreateTransactionTests
await using (var setup = new SqliteTeamMemberTestDbContext(options))
await setup.Database.EnsureCreatedAsync();
await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true);
var emailSender = new CapturingEmailSender();
await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true, emailSender);
ThrowingAfterPersistPermissionDataService failingPermissionData;
await using (var createScope = serviceProvider.CreateAsyncScope())
{
@ -66,6 +67,8 @@ public sealed class TeamMemberCreateTransactionTests
Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync());
Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync());
Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync());
Assert.Empty(await verify.TeamMemberInvites.AsNoTracking().ToListAsync());
Assert.Empty(emailSender.Messages);
}
[Fact]
@ -81,7 +84,8 @@ public sealed class TeamMemberCreateTransactionTests
await using (var setup = new SqliteTeamMemberTestDbContext(options))
await setup.Database.EnsureCreatedAsync();
await using var serviceProvider = BuildServiceProvider(connection);
var emailSender = new CapturingEmailSender();
await using var serviceProvider = BuildServiceProvider(connection, emailSender: emailSender);
await using (var createScope = serviceProvider.CreateAsyncScope())
{
var service = createScope.ServiceProvider.GetRequiredService<ITeamMemberService>();
@ -110,9 +114,18 @@ public sealed class TeamMemberCreateTransactionTests
var permissionOverride = await verify.UserPermissionOverrides.AsNoTracking()
.SingleAsync(permission => permission.UserId == user.Id);
Assert.Equal("deleteSites", permissionOverride.PermissionKey);
var invite = await verify.TeamMemberInvites.AsNoTracking().SingleAsync();
Assert.Equal(user.Id, invite.UserId);
var sent = Assert.Single(emailSender.Messages);
Assert.Equal("taylor@example.com", sent.To);
Assert.Equal("You're invited to Seahaven", sent.Subject);
}
private static ServiceProvider BuildServiceProvider(SqliteConnection connection, bool injectFailure = false)
private static ServiceProvider BuildServiceProvider(
SqliteConnection connection,
bool injectFailure = false,
CapturingEmailSender? emailSender = null)
{
var configuration = new ConfigurationBuilder().Build();
var services = new ServiceCollection();
@ -124,6 +137,7 @@ public sealed class TeamMemberCreateTransactionTests
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services.AddSingleton<IEmailSender>(emailSender ?? new CapturingEmailSender());
services.AddDataServices();
services.AddBusinessServices(configuration);

View file

@ -0,0 +1,139 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Data.SeaHavenIndustries;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>What happens to a member's invites when an admin edits or deletes that member.</summary>
public sealed class TeamMemberInviteLifecycleTests
{
private const string Email = "taylor@example.com";
private const string CorrectedEmail = "taylor.reed@example.com";
private const string Password = "Abc1!x";
[Fact]
public async Task DeletingAnInvitedMember_RemovesTheMemberAndTheirInvites()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.SendCodeAsync(token, Email);
Assert.True((await ResendInviteAsync(host, userId)).Success);
Assert.Equal(2, (await host.InvitesAsync(userId)).Count);
var deleted = await host.InScopeAsync(provider => provider.GetRequiredService<IUserService>()
.DeleteUserAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None));
Assert.True(deleted.Success, deleted.Error);
Assert.Empty(await host.InvitesAsync(userId));
Assert.False(await host.InScopeAsync(provider => provider.GetRequiredService<ApplicationDbContext>()
.Users.AnyAsync(user => user.Id == userId)));
}
[Fact]
public async Task ChangingAPendingMembersEmail_RevokesTheOldInviteAndItsConfirmation()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, oldToken) = await host.AddPendingMemberAsync(Email);
await host.ConfirmEmailAsync(oldToken, Email);
var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "admin");
Assert.True(updated.Success, updated.Error);
Assert.True(updated.Member!.InviteEmailSent);
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
(await host.RegistrationAsync(service => service.ResolveAsync(oldToken, CancellationToken.None))).Status);
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
(await host.RegistrationAsync(service => service.SendCodeAsync(oldToken, CancellationToken.None))).Status);
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, (await CompleteAsync(host, oldToken)).Status);
var user = await host.ReloadUserAsync(userId);
Assert.False(user.EmailConfirmed);
Assert.True(user.PendingRegistration);
// The new address gets its own invite, and it must be confirmed again before finishing.
var newToken = host.Sent.LatestTokenFor(CorrectedEmail);
Assert.NotEqual(oldToken, newToken);
Assert.Equal(TeamMemberRegistrationStatus.EmailNotConfirmed, (await CompleteAsync(host, newToken)).Status);
await host.ConfirmEmailAsync(newToken, CorrectedEmail);
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, newToken)).Status);
}
[Fact]
public async Task ChangingAPendingMembersEmail_ReportsWhenTheNewInviteCouldNotBeEmailed()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, _) = await host.AddPendingMemberAsync(Email);
host.Sent.Succeeds = false;
var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "dispatcher");
Assert.True(updated.Success, updated.Error);
Assert.Equal(CorrectedEmail, updated.Member!.Email);
Assert.False(updated.Member.InviteEmailSent);
}
[Fact]
public async Task DeactivatingAPendingMemberWhileChangingTheirEmail_RevokesTheInviteWithoutClaimingAnEmail()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, oldToken) = await host.AddPendingMemberAsync(Email);
var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "dispatcher", isActive: false);
Assert.True(updated.Success, updated.Error);
Assert.Null(updated.Member!.InviteEmailSent);
Assert.DoesNotContain(host.Sent.Messages, message => message.To == CorrectedEmail);
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
(await host.RegistrationAsync(service => service.ResolveAsync(oldToken, CancellationToken.None))).Status);
}
[Fact]
public async Task EditingAPendingMemberWithoutChangingTheirEmail_KeepsTheirInvite()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
var emailsBefore = host.Sent.Messages.Count;
var updated = await UpdateAsync(host, userId, Email.ToUpperInvariant(), role: "dispatcher");
Assert.True(updated.Success, updated.Error);
Assert.Null(updated.Member!.InviteEmailSent);
Assert.Equal(emailsBefore, host.Sent.Messages.Count);
var invite = Assert.Single(await host.InvitesAsync(userId));
Assert.Null(invite.RevokedAt);
Assert.Equal(TeamMemberRegistrationStatus.Ok,
(await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status);
}
private static Task<TeamMemberOperationOutcomeDTO> UpdateAsync(
TeamMemberInviteTestHost host,
string userId,
string email,
string role,
bool isActive = true) =>
host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberService>().UpdateAsync(
userId,
new UpdateTeamMemberRequestDTO
{
Name = "Taylor Reed",
Role = role,
Color = "#0D9488",
Email = email,
Phone = "555-0100",
ServiceAreas = role == "dispatcher" ? new[] { "East" } : Array.Empty<string>(),
IsActive = isActive
},
TeamMemberInviteTestHost.Admin(),
CancellationToken.None));
private static Task<TeamMemberRegistrationOutcomeDTO> CompleteAsync(TeamMemberInviteTestHost host, string token) =>
host.RegistrationAsync(service => service.CompleteAsync(
new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password },
CancellationToken.None));
private static Task<TeamMemberInviteResendOutcomeDTO> ResendInviteAsync(TeamMemberInviteTestHost host, string userId) =>
host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberInviteService>()
.ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None));
}

View file

@ -0,0 +1,675 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public sealed class TeamMemberInviteRegistrationTests
{
private const string Email = "taylor@example.com";
private const string Password = "Abc1!x";
[Fact]
public async Task CreatingPendingMember_EmailsHighEntropyInviteAndStoresOnlyItsHash()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
var invite = Assert.Single(await host.InvitesAsync(userId));
Assert.True(Base64UrlDecode(token).Length >= 16);
Assert.Equal(Sha256Hex(token), invite.TokenHash);
Assert.DoesNotContain(token, JsonSerializer.Serialize(invite));
Assert.Equal(host.Time.Now.UtcDateTime.AddDays(7), invite.ExpiresAt);
Assert.Null(invite.UsedAt);
var sent = Assert.Single(host.Sent.Messages);
Assert.Equal(Email, sent.To);
Assert.Contains($"{TeamMemberInviteTestHost.FrontendBaseUrl}/invite#{token}", sent.Body);
Assert.Contains("<a clicktracking=off href=", sent.Body);
}
[Fact]
public async Task FullFlow_FromEmailedToken_ActivatesMemberConfirmsEmailAndSignsIn()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email, "Taylor Reed");
var resolved = await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None));
Assert.Equal(TeamMemberRegistrationStatus.Ok, resolved.Status);
Assert.Equal("Taylor Reed", resolved.Details!.Name);
Assert.Equal("Dispatcher", resolved.Details.Role);
Assert.Equal(Email, resolved.Details.Email);
await host.ConfirmEmailAsync(token, Email);
var completed = await CompleteAsync(host, token, Password, "(555) 010-2000");
Assert.Equal(TeamMemberRegistrationStatus.Ok, completed.Status);
Assert.False(string.IsNullOrWhiteSpace(completed.Session!.Token));
Assert.Equal(Email, completed.Session.Email);
Assert.Equal(userId, completed.Session.Id);
Assert.Equal("Taylor Reed", completed.Session.Fullname);
Assert.Contains("Dispatcher", completed.Session.UserRole);
var user = await host.ReloadUserAsync(userId);
Assert.False(user.PendingRegistration);
Assert.True(user.EmailConfirmed);
Assert.Equal("Active", user.UniqueName);
Assert.Equal("(555) 010-2000", user.PhoneNumber);
Assert.Equal("(555) 010-2000", user.Contact);
Assert.True(await host.InScopeAsync(provider =>
provider.GetRequiredService<UserManager<ApplicationUser>>().CheckPasswordAsync(user, Password)));
Assert.NotNull(Assert.Single(await host.InvitesAsync(userId)).UsedAt);
}
[Fact]
public async Task UsedToken_CannotRegisterAgain()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (_, token) = await host.AddPendingMemberAsync(Email);
await host.ConfirmEmailAsync(token, Email);
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password)).Status);
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
(await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status);
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
(await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None))).Status);
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
(await CompleteAsync(host, token, "Xyz9?q")).Status);
}
[Fact]
public async Task UnknownExpiredUsedRevokedAndDeactivatedInvites_GetTheSameGenericResponse()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (_, used) = await host.AddPendingMemberAsync("used@example.com");
await host.ConfirmEmailAsync(used, "used@example.com");
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, used, Password)).Status);
var (revokedUserId, revoked) = await host.AddPendingMemberAsync("revoked@example.com");
Assert.True((await ResendInviteAsync(host, revokedUserId)).Success);
var (deactivatedUserId, deactivated) = await host.AddPendingMemberAsync("deactivated@example.com");
await host.InScopeAsync(provider => provider.GetRequiredService<ApplicationDbContext>().Users
.Where(user => user.Id == deactivatedUserId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(user => user.IsDeleted, true)
.SetProperty(user => user.UniqueName, "Inactive")));
var (_, expired) = await host.AddPendingMemberAsync("expired@example.com");
var responses = new List<string>
{
await ResolveResponseAsync(host, used),
await ResolveResponseAsync(host, revoked),
await ResolveResponseAsync(host, deactivated),
await ResolveResponseAsync(host, TeamMemberInviteSecretsForTests.UnknownToken()),
await ResolveResponseAsync(host, ""),
await ResolveResponseAsync(host, new string('a', 4096))
};
host.Time.Advance(TimeSpan.FromDays(7).Add(TimeSpan.FromSeconds(1)));
responses.Add(await ResolveResponseAsync(host, expired));
var expected =
"400 {\"code\":\"invalid_invite\",\"message\":\"This invite link is invalid or has expired. Ask your admin to send a new invite.\",\"retryAfterSeconds\":null}";
Assert.All(responses, response => Assert.Equal(expected, response));
}
[Fact]
public async Task Invite_ExpiresAfterSevenDays()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (_, token) = await host.AddPendingMemberAsync(Email);
host.Time.Advance(TimeSpan.FromDays(7).Subtract(TimeSpan.FromSeconds(1)));
Assert.Equal(TeamMemberRegistrationStatus.Ok,
(await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status);
host.Time.Advance(TimeSpan.FromSeconds(1));
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
(await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status);
}
[Fact]
public async Task Code_IsSixDigitsAndStoredOnlyAsSaltedHash()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
var code = await host.SendCodeAsync(token, Email);
Assert.Matches("^[0-9]{6}$", code);
var invite = Assert.Single(await host.InvitesAsync(userId));
Assert.NotNull(invite.CodeHash);
Assert.NotNull(invite.CodeSalt);
Assert.NotEqual(code, invite.CodeHash);
Assert.DoesNotContain(code, invite.CodeHash);
Assert.Equal(Sha256Hex($"{invite.CodeSalt}:{code}"), invite.CodeHash);
Assert.Equal(host.Time.Now.UtcDateTime.AddMinutes(15), invite.CodeExpiresAt);
}
[Fact]
public async Task WrongCodes_AreCountedAndLockAfterFive_UntilANewCodeIsSent()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
var code = await host.SendCodeAsync(token, Email);
var wrong = code == "000000" ? "111111" : "000000";
for (var attempt = 1; attempt <= 4; attempt++)
{
var outcome = await VerifyAsync(host, token, wrong);
Assert.Equal(TeamMemberRegistrationStatus.CodeIncorrect, outcome.Status);
Assert.Equal(attempt, Assert.Single(await host.InvitesAsync(userId)).CodeFailedAttempts);
}
Assert.Equal(TeamMemberRegistrationStatus.CodeLocked, (await VerifyAsync(host, token, wrong)).Status);
Assert.Equal(TeamMemberRegistrationStatus.CodeLocked, (await VerifyAsync(host, token, code)).Status);
Assert.Equal(5, Assert.Single(await host.InvitesAsync(userId)).CodeFailedAttempts);
Assert.Null(Assert.Single(await host.InvitesAsync(userId)).EmailConfirmedAt);
host.Time.Advance(TeamMemberRegistrationService.ResendCooldown);
var fresh = await host.SendCodeAsync(token, Email);
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await VerifyAsync(host, token, fresh)).Status);
Assert.NotNull(Assert.Single(await host.InvitesAsync(userId)).EmailConfirmedAt);
}
[Fact]
public async Task AttemptReservation_IsEnforcedByTheDatabaseGuard()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.SendCodeAsync(token, Email);
var inviteId = Assert.Single(await host.InvitesAsync(userId)).Id;
var granted = 0;
for (var i = 0; i < 8; i++)
{
if (await host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberInviteDataService>()
.TryReserveCodeAttemptAsync(inviteId, TeamMemberRegistrationService.MaxCodeAttempts, host.Time.Now.UtcDateTime, CancellationToken.None)))
granted++;
}
Assert.Equal(TeamMemberRegistrationService.MaxCodeAttempts, granted);
}
[Fact]
public async Task Code_ExpiresAfterFifteenMinutes()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (_, token) = await host.AddPendingMemberAsync(Email);
var code = await host.SendCodeAsync(token, Email);
host.Time.Advance(TeamMemberRegistrationService.CodeLifetime);
Assert.Equal(TeamMemberRegistrationStatus.CodeExpired, (await VerifyAsync(host, token, code)).Status);
}
[Fact]
public async Task Resend_IsRateLimitedAndReplacesThePreviousCode()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (_, token) = await host.AddPendingMemberAsync(Email);
var first = await host.SendCodeAsync(token, Email);
var tooSoon = await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None));
Assert.Equal(TeamMemberRegistrationStatus.ResendTooSoon, tooSoon.Status);
Assert.Equal(60, tooSoon.RetryAfterSeconds);
host.Time.Advance(TimeSpan.FromSeconds(45));
var stillTooSoon = await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None));
Assert.Equal(TeamMemberRegistrationStatus.ResendTooSoon, stillTooSoon.Status);
Assert.Equal(15, stillTooSoon.RetryAfterSeconds);
Assert.Single(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code");
host.Time.Advance(TimeSpan.FromSeconds(15));
var second = await host.SendCodeAsync(token, Email);
if (second != first)
Assert.Equal(TeamMemberRegistrationStatus.CodeIncorrect, (await VerifyAsync(host, token, first)).Status);
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await VerifyAsync(host, token, second)).Status);
}
[Fact]
public async Task Resend_StopsAfterTheSendLimit()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (_, token) = await host.AddPendingMemberAsync(Email);
for (var send = 0; send < TeamMemberRegistrationService.MaxCodeSends; send++)
{
await host.SendCodeAsync(token, Email);
host.Time.Advance(TeamMemberRegistrationService.ResendCooldown);
}
var refused = await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None));
Assert.Equal(TeamMemberRegistrationStatus.ResendLimitReached, refused.Status);
Assert.Equal(TeamMemberRegistrationService.MaxCodeSends,
host.Sent.Messages.Count(message => message.Subject == "Your Seahaven confirmation code"));
}
[Theory]
[InlineData("revoked")]
[InlineData("used")]
public async Task SendCode_ForAnInviteClosedMidRequest_GetsTheGenericInvalidInviteResponse(string closedBy)
{
await using var host = await TeamMemberInviteTestHost.CreateAsync(services =>
services.Replace(ServiceDescriptor.Scoped<ITeamMemberInviteDataService>(provider =>
new ClosesInviteBeforeCodeStart(
new TeamMemberInviteDataService(provider.GetRequiredService<ApplicationDbContext>()),
provider.GetRequiredService<ApplicationDbContext>(),
closedBy))));
var (userId, token) = await host.AddPendingMemberAsync(Email);
var response = await InvokeControllerAsync(host, controller => controller.SendCode(
new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None));
Assert.Equal(
"400 {\"code\":\"invalid_invite\",\"message\":\"This invite link is invalid or has expired. Ask your admin to send a new invite.\",\"retryAfterSeconds\":null}",
response.Body);
Assert.Null(response.RetryAfter);
Assert.DoesNotContain(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code");
Assert.Equal(0, Assert.Single(await host.InvitesAsync(userId)).CodeSendCount);
}
/// <summary>Closes the invite after the service has read it and before the conditional code start runs.</summary>
private sealed class ClosesInviteBeforeCodeStart : ITeamMemberInviteDataService
{
private readonly ITeamMemberInviteDataService _inner;
private readonly ApplicationDbContext _context;
private readonly string _closedBy;
public ClosesInviteBeforeCodeStart(ITeamMemberInviteDataService inner, ApplicationDbContext context, string closedBy)
{
_inner = inner;
_context = context;
_closedBy = closedBy;
}
public async Task<bool> TryStartCodeAsync(StartTeamMemberInviteCodeCommand command, CancellationToken cancellationToken)
{
var invite = _context.TeamMemberInvites.Where(existing => existing.Id == command.InviteId);
if (_closedBy == "revoked")
await invite.ExecuteUpdateAsync(setters => setters.SetProperty(existing => existing.RevokedAt, command.Now), cancellationToken);
else
await invite.ExecuteUpdateAsync(setters => setters.SetProperty(existing => existing.UsedAt, command.Now), cancellationToken);
return await _inner.TryStartCodeAsync(command, cancellationToken);
}
public Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken) =>
_inner.AddAsync(invite, cancellationToken);
public Task ReplaceOpenForUserAsync(TeamMemberInvite invite, DateTime now, CancellationToken cancellationToken) =>
_inner.ReplaceOpenForUserAsync(invite, now, cancellationToken);
public Task<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken) =>
_inner.GetByTokenHashAsync(tokenHash, cancellationToken);
public Task<bool> TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken) =>
_inner.TryReserveCodeAttemptAsync(inviteId, maxAttempts, now, cancellationToken);
public Task<bool> TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken) =>
_inner.TryConfirmEmailAsync(inviteId, codeHash, now, cancellationToken);
public Task<bool> TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken) =>
_inner.TryClaimAsync(inviteId, now, cancellationToken);
}
[Fact]
public async Task Complete_RequiresConfirmedEmail()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.SendCodeAsync(token, Email);
Assert.Equal(TeamMemberRegistrationStatus.EmailNotConfirmed, (await CompleteAsync(host, token, Password)).Status);
var user = await host.ReloadUserAsync(userId);
Assert.True(user.PendingRegistration);
Assert.Null(user.PasswordHash);
}
[Fact]
public async Task Complete_WeakPassword_RollsBackAndARetrySucceeds()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.ConfirmEmailAsync(token, Email);
Assert.Equal(TeamMemberRegistrationStatus.PasswordRejected, (await CompleteAsync(host, token, "abc12!")).Status);
var pending = await host.ReloadUserAsync(userId);
Assert.True(pending.PendingRegistration);
Assert.False(pending.EmailConfirmed);
Assert.Null(pending.PasswordHash);
Assert.Null(Assert.Single(await host.InvitesAsync(userId)).UsedAt);
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password)).Status);
Assert.False((await host.ReloadUserAsync(userId)).PendingRegistration);
}
[Fact]
public async Task Complete_NonPolicyIdentityFailure_IsNotReportedAsAWeakPasswordAndRollsBack()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync(services =>
services.AddScoped<IPasswordValidator<ApplicationUser>, FailingPasswordValidator>());
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.ConfirmEmailAsync(token, Email);
var failure = await Assert.ThrowsAsync<InvalidOperationException>(() => CompleteAsync(host, token, Password));
Assert.Contains(FailingPasswordValidator.Code, failure.Message);
Assert.DoesNotContain(Password, failure.Message);
var pending = await host.ReloadUserAsync(userId);
Assert.True(pending.PendingRegistration);
Assert.Null(pending.PasswordHash);
Assert.Null(Assert.Single(await host.InvitesAsync(userId)).UsedAt);
}
/// <summary>Stands in for any Identity failure that is not the password rule itself.</summary>
private sealed class FailingPasswordValidator : IPasswordValidator<ApplicationUser>
{
public const string Code = "ConcurrencyFailure";
public Task<IdentityResult> ValidateAsync(UserManager<ApplicationUser> manager, ApplicationUser user, string? password) =>
Task.FromResult(IdentityResult.Failed(new IdentityError { Code = Code, Description = "Optimistic concurrency failure." }));
}
[Fact]
public async Task Complete_WithoutPhone_KeepsThePhoneTheAdminEntered()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.ConfirmEmailAsync(token, Email);
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password, phone: null)).Status);
var user = await host.ReloadUserAsync(userId);
Assert.Equal("555-0100", user.PhoneNumber);
Assert.Equal("555-0100", user.Contact);
}
[Fact]
public async Task Complete_WithAnEmptyPhone_ClearsIt()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.ConfirmEmailAsync(token, Email);
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password, phone: "")).Status);
var user = await host.ReloadUserAsync(userId);
Assert.Null(user.PhoneNumber);
Assert.Null(user.Contact);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CreatingPendingMember_ReportsWhetherTheInviteEmailWentOut(bool delivered)
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
host.Sent.Succeeds = delivered;
var outcome = await host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberService>().CreateAsync(
new CreateTeamMemberRequestDTO
{
Name = "Taylor Reed",
Role = "dispatcher",
Color = "#0D9488",
Email = Email,
ServiceAreas = new[] { "East" }
},
TeamMemberInviteTestHost.Admin(),
CancellationToken.None));
Assert.True(outcome.Success);
Assert.Equal(delivered, outcome.Member!.InviteEmailSent);
Assert.Single(await host.InvitesAsync(outcome.Member.Id));
}
[Fact]
public async Task Complete_InvalidPhone_IsRejectedWithoutConsumingTheInvite()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.ConfirmEmailAsync(token, Email);
Assert.Equal(TeamMemberRegistrationStatus.InvalidPhone, (await CompleteAsync(host, token, Password, "call me")).Status);
Assert.Null(Assert.Single(await host.InvitesAsync(userId)).UsedAt);
}
[Fact]
public async Task Invite_OnlyEverChangesItsOwnMember()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (ownerId, ownerToken) = await host.AddPendingMemberAsync(Email, "Taylor Reed");
var (otherId, otherToken) = await host.AddPendingMemberAsync("jordan@example.com", "Jordan Lee");
var otherBefore = await host.ReloadUserAsync(otherId);
await host.ConfirmEmailAsync(ownerToken, Email);
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, ownerToken, Password, "555-123-4567")).Status);
Assert.DoesNotContain(host.Sent.Messages,
message => message.To == "jordan@example.com" && message.Subject == "Your Seahaven confirmation code");
var otherAfter = await host.ReloadUserAsync(otherId);
Assert.True(otherAfter.PendingRegistration);
Assert.False(otherAfter.EmailConfirmed);
Assert.Null(otherAfter.PasswordHash);
Assert.Equal(otherBefore.PhoneNumber, otherAfter.PhoneNumber);
Assert.Equal(otherBefore.SecurityStamp, otherAfter.SecurityStamp);
var otherInvite = Assert.Single(await host.InvitesAsync(otherId));
Assert.Null(otherInvite.UsedAt);
Assert.Null(otherInvite.EmailConfirmedAt);
var otherDetails = await host.RegistrationAsync(service => service.ResolveAsync(otherToken, CancellationToken.None));
Assert.Equal("Jordan Lee", otherDetails.Details!.Name);
Assert.False((await host.ReloadUserAsync(ownerId)).PendingRegistration);
}
[Fact]
public async Task Reinvite_RevokesOlderTokensAndOnlyAdminsCanSendIt()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, original) = await host.AddPendingMemberAsync(Email);
var forbidden = await host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberInviteService>()
.ResendAsync(userId, TeamMemberInviteTestHost.Dispatcher(), CancellationToken.None));
Assert.Equal("Forbidden", forbidden.Error);
Assert.True((await ResendInviteAsync(host, userId)).Success);
var replacement = host.Sent.LatestTokenFor(Email);
Assert.NotEqual(original, replacement);
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
(await host.RegistrationAsync(service => service.ResolveAsync(original, CancellationToken.None))).Status);
Assert.Equal(TeamMemberRegistrationStatus.Ok,
(await host.RegistrationAsync(service => service.ResolveAsync(replacement, CancellationToken.None))).Status);
var invites = await host.InvitesAsync(userId);
Assert.Equal(2, invites.Count);
Assert.NotNull(invites[0].RevokedAt);
Assert.Null(invites[1].RevokedAt);
}
[Fact]
public async Task Reinvite_IsRefusedOnceTheMemberIsActive()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.ConfirmEmailAsync(token, Email);
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password)).Status);
var refused = await ResendInviteAsync(host, userId);
Assert.False(refused.Success);
Assert.Equal("Only pending team members can be re-invited.", refused.Error);
}
[Fact]
public async Task TokensAndCodes_NeverReachTheLogs()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
var code = await host.SendCodeAsync(token, Email);
await VerifyAsync(host, token, code == "000000" ? "111111" : "000000");
host.Sent.Succeeds = false;
host.Time.Advance(TeamMemberRegistrationService.ResendCooldown);
Assert.Equal(TeamMemberRegistrationStatus.CodeDeliveryFailed,
(await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None))).Status);
Assert.False((await ResendInviteAsync(host, userId)).Success);
var failedInviteToken = host.Sent.LatestTokenFor(Email);
host.Sent.Succeeds = true;
Assert.True((await ResendInviteAsync(host, userId)).Success);
var liveToken = host.Sent.LatestTokenFor(Email);
await host.ConfirmEmailAsync(liveToken, Email);
var liveCode = host.Sent.LatestCodeFor(Email);
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, liveToken, Password)).Status);
Assert.NotEmpty(host.Logged.Entries);
Assert.Contains(host.Logged.Entries, entry => entry.Contains("could not be sent", StringComparison.Ordinal));
foreach (var secret in new[] { token, failedInviteToken, liveToken, code, liveCode, Password })
Assert.DoesNotContain(host.Logged.Entries, entry => entry.Contains(secret, StringComparison.Ordinal));
}
[Fact]
public async Task RequestObjects_DoNotPrintTheirSecrets()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (_, token) = await host.AddPendingMemberAsync(Email);
var printed = string.Join(" ",
new TeamMemberInviteTokenRequestDTO { Token = token }.ToString(),
new VerifyTeamMemberInviteCodeRequestDTO { Token = token, Code = "123456" }.ToString(),
new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password }.ToString(),
new IssuedTeamMemberInvite(token, DateTime.UtcNow).ToString());
Assert.DoesNotContain(token, printed);
Assert.DoesNotContain("123456", printed);
Assert.DoesNotContain(Password, printed);
}
[Fact]
public async Task CancelledRequests_StopBeforeChangingAnything()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
using var cancelled = new CancellationTokenSource();
await cancelled.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
host.RegistrationAsync(service => service.SendCodeAsync(token, cancelled.Token)));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => host.RegistrationAsync(service =>
service.CompleteAsync(new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password }, cancelled.Token)));
Assert.DoesNotContain(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code");
Assert.Equal(0, Assert.Single(await host.InvitesAsync(userId)).CodeSendCount);
Assert.True((await host.ReloadUserAsync(userId)).PendingRegistration);
}
[Fact]
public async Task Controller_MapsCodeFailuresToClearMessagesAndRetryAfter()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (_, token) = await host.AddPendingMemberAsync(Email);
var code = await host.SendCodeAsync(token, Email);
var wrong = await InvokeControllerAsync(host, controller => controller.VerifyCode(
new VerifyTeamMemberInviteCodeRequestDTO { Token = token, Code = code == "000000" ? "111111" : "000000" },
CancellationToken.None));
Assert.Equal(
"400 {\"code\":\"code_incorrect\",\"message\":\"Incorrect code \\u2014 check your email and try again\",\"retryAfterSeconds\":null}",
wrong.Body);
var tooSoon = await InvokeControllerAsync(host, controller => controller.SendCode(
new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None));
Assert.StartsWith("429 {\"code\":\"resend_too_soon\"", tooSoon.Body);
Assert.Equal("60", tooSoon.RetryAfter);
}
[Fact]
public async Task Controller_CompleteReturnsTheLoginPayloadShape()
{
await using var host = await TeamMemberInviteTestHost.CreateAsync();
var (userId, token) = await host.AddPendingMemberAsync(Email);
await host.ConfirmEmailAsync(token, Email);
var response = await InvokeControllerAsync(host, controller => controller.Complete(
new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password, Phone = "555-123-4567" },
CancellationToken.None));
Assert.StartsWith("200 ", response.Body);
using var payload = JsonDocument.Parse(response.Body[4..]);
var keys = payload.RootElement.EnumerateObject().Select(property => property.Name).ToArray();
Assert.Equal(new[] { "token", "expiration", "email", "userRoles", "phoneNumber", "fullname", "id" }, keys);
Assert.Equal(userId, payload.RootElement.GetProperty("id").GetString());
Assert.Equal("555-123-4567", payload.RootElement.GetProperty("phoneNumber").GetString());
}
private static Task<TeamMemberRegistrationOutcomeDTO> VerifyAsync(TeamMemberInviteTestHost host, string token, string code) =>
host.RegistrationAsync(service => service.VerifyCodeAsync(token, code, CancellationToken.None));
private static Task<TeamMemberRegistrationOutcomeDTO> CompleteAsync(
TeamMemberInviteTestHost host,
string token,
string password,
string? phone = null) =>
host.RegistrationAsync(service => service.CompleteAsync(
new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = password, Phone = phone },
CancellationToken.None));
private static Task<TeamMemberInviteResendOutcomeDTO> ResendInviteAsync(TeamMemberInviteTestHost host, string userId) =>
host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberInviteService>()
.ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None));
private static async Task<string> ResolveResponseAsync(TeamMemberInviteTestHost host, string token) =>
(await InvokeControllerAsync(host, controller => controller.Resolve(
new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None))).Body;
private static Task<(string Body, string? RetryAfter)> InvokeControllerAsync(
TeamMemberInviteTestHost host,
Func<TeamMemberInviteController, Task<IActionResult>> action) =>
host.InScopeAsync(async provider =>
{
var httpContext = new DefaultHttpContext();
var controller = new TeamMemberInviteController(provider.GetRequiredService<ITeamMemberRegistrationService>())
{
ControllerContext = new ControllerContext { HttpContext = httpContext }
};
var result = Assert.IsAssignableFrom<ObjectResult>(await action(controller));
var status = result.StatusCode ?? StatusCodes.Status200OK;
var retryAfter = httpContext.Response.Headers.RetryAfter.ToString();
return ($"{status} {JsonSerializer.Serialize(result.Value)}", string.IsNullOrEmpty(retryAfter) ? null : retryAfter);
});
private static byte[] Base64UrlDecode(string value)
{
var padded = value.Replace('-', '+').Replace('_', '/');
padded += new string('=', (4 - padded.Length % 4) % 4);
return Convert.FromBase64String(padded);
}
private static string Sha256Hex(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static class TeamMemberInviteSecretsForTests
{
public static string UnknownToken() =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}

View file

@ -0,0 +1,266 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using System.Text.RegularExpressions;
using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using Microsoft.Data.Sqlite;
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.DTOs;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// A SQLite-backed composition of the real team member, invite and registration
/// services, with Identity registered exactly as the API host registers it. Email
/// goes to an in-memory sender and every log line is captured.
/// </summary>
internal sealed class TeamMemberInviteTestHost : IAsyncDisposable
{
public const string FrontendBaseUrl = "https://shoc.test";
private readonly SqliteConnection _connection;
private ServiceProvider _provider = null!;
private TeamMemberInviteTestHost(SqliteConnection connection)
{
_connection = connection;
}
public CapturingEmailSender Sent { get; } = new();
public ManualTimeProvider Time { get; } = new();
public CapturingLoggerProvider Logged { get; } = new();
public static async Task<TeamMemberInviteTestHost> CreateAsync(Action<IServiceCollection>? configure = null)
{
var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
await using (var setup = new SqliteInviteTestDbContext(options))
await setup.Database.EnsureCreatedAsync();
var host = new TeamMemberInviteTestHost(connection);
host._provider = BuildProvider(connection, host, configure);
return host;
}
private static ServiceProvider BuildProvider(
SqliteConnection connection,
TeamMemberInviteTestHost fakes,
Action<IServiceCollection>? configure)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["FrontendBaseUrl"] = FrontendBaseUrl,
["JWT:Secret"] = new string('k', 64),
["JWT:ValidIssuer"] = "issuer",
["JWT:ValidAudience"] = "audience"
})
.Build();
var services = new ServiceCollection();
services.AddLogging(logging => logging
.SetMinimumLevel(LogLevel.Trace)
.AddProvider(fakes.Logged));
services.AddDbContext<ApplicationDbContext>(builder => builder.UseSqlite(connection));
services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
new SqliteInviteTestDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
services.AddSeaHavenIdentity();
services.AddSingleton<TimeProvider>(fakes.Time);
services.AddSingleton<IEmailSender>(fakes.Sent);
services.AddDataServices();
services.AddBusinessServices(configuration);
// Registration signs the member in through the authentication service, which
// queues password reset email; the API host registers the same delivery.
services.AddPasswordResetEmailDelivery();
configure?.Invoke(services);
return services.BuildServiceProvider();
}
public async Task<T> InScopeAsync<T>(Func<IServiceProvider, Task<T>> action)
{
await using var scope = _provider.CreateAsyncScope();
return await action(scope.ServiceProvider);
}
public Task<TeamMemberRegistrationOutcomeDTO> RegistrationAsync(
Func<ITeamMemberRegistrationService, Task<TeamMemberRegistrationOutcomeDTO>> action) =>
InScopeAsync(provider => action(provider.GetRequiredService<ITeamMemberRegistrationService>()));
/// <summary>Creates a pending member through the admin create path and returns the emailed token.</summary>
public async Task<(string UserId, string Token)> AddPendingMemberAsync(string email, string name = "Taylor Reed")
{
var outcome = await InScopeAsync(provider => provider.GetRequiredService<ITeamMemberService>().CreateAsync(
new CreateTeamMemberRequestDTO
{
Name = name,
Role = "dispatcher",
Color = "#0D9488",
Email = email,
Phone = "555-0100",
ServiceAreas = new[] { "East" }
},
Admin(),
CancellationToken.None));
Assert.True(outcome.Success, outcome.Error);
return (outcome.Member!.Id, Sent.LatestTokenFor(email));
}
public async Task<string> SendCodeAsync(string token, string email)
{
var outcome = await RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None));
Assert.Equal(TeamMemberRegistrationStatus.Ok, outcome.Status);
return Sent.LatestCodeFor(email);
}
public async Task ConfirmEmailAsync(string token, string email)
{
var code = await SendCodeAsync(token, email);
var verified = await RegistrationAsync(service => service.VerifyCodeAsync(token, code, CancellationToken.None));
Assert.Equal(TeamMemberRegistrationStatus.Ok, verified.Status);
}
public Task<ApplicationUser> ReloadUserAsync(string userId) =>
InScopeAsync(async provider => await provider.GetRequiredService<ApplicationDbContext>()
.Users.AsNoTracking().SingleAsync(user => user.Id == userId));
public Task<List<TeamMemberInvite>> InvitesAsync(string userId) =>
InScopeAsync(provider => provider.GetRequiredService<ApplicationDbContext>()
.TeamMemberInvites.AsNoTracking().Where(invite => invite.UserId == userId)
.OrderBy(invite => invite.Id).ToListAsync());
public static ClaimsPrincipal Admin() =>
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test"));
public static ClaimsPrincipal Dispatcher() =>
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Dispatcher") }, "Test"));
public async ValueTask DisposeAsync()
{
await _provider.DisposeAsync();
await _connection.DisposeAsync();
}
private sealed class SqliteInviteTestDbContext : ApplicationDbContext
{
public SqliteInviteTestDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Keep filtered unique indexes filtered: translate SQL Server identifier quoting.
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
{
if (index.GetFilter() is { } filter)
index.SetFilter(filter.Replace('[', '"').Replace(']', '"'));
}
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 partial class CapturingEmailSender : IEmailSender
{
private readonly ConcurrentQueue<SentEmail> _messages = new();
public bool Succeeds { get; set; } = true;
public IReadOnlyList<SentEmail> Messages => _messages.ToArray();
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
{
_messages.Enqueue(new SentEmail(emailTo, subject, body));
return Task.FromResult(Succeeds);
}
public string LatestTokenFor(string email)
{
var message = Messages.Last(sent => sent.To == email && sent.Subject == "You're invited to Seahaven");
return InviteLink().Match(message.Body).Groups["token"].Value;
}
public string LatestCodeFor(string email)
{
var message = Messages.Last(sent => sent.To == email && sent.Subject == "Your Seahaven confirmation code");
return Code().Match(message.Body).Groups["code"].Value;
}
[GeneratedRegex("https://shoc\\.test/invite#(?<token>[A-Za-z0-9_-]+)")]
public static partial Regex InviteLink();
[GeneratedRegex("<strong>(?<code>[0-9]{6})</strong>")]
private static partial Regex Code();
}
internal sealed record SentEmail(string To, string Subject, string Body);
internal sealed class ManualTimeProvider : TimeProvider
{
public DateTimeOffset Now { get; private set; } = 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 CapturingLoggerProvider : ILoggerProvider
{
private readonly ConcurrentQueue<string> _entries = new();
public IReadOnlyList<string> Entries => _entries.ToArray();
public ILogger CreateLogger(string categoryName) => new CapturingLogger(_entries);
public void Dispose()
{
}
private sealed class CapturingLogger : ILogger
{
private readonly ConcurrentQueue<string> _entries;
public CapturingLogger(ConcurrentQueue<string> entries) => _entries = entries;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
_entries.Enqueue(state.ToString() ?? "");
return 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}"))
: "";
_entries.Enqueue($"{formatter(state, exception)} {values} {exception}");
}
}
}

View file

@ -601,7 +601,7 @@ public class WorkOrderAdvancedSearchServiceTests
}
[Fact]
public async Task SearchAsync_OtherPlusOverdue_ReturnsUnion()
public async Task SearchAsync_OtherPlusOverdue_ReturnsIntersection()
{
await using var context = CreateContext();
var today = DateTime.UtcNow.Date;
@ -610,7 +610,7 @@ public class WorkOrderAdvancedSearchServiceTests
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
WorkOrderType = WorkOrderType.Other,
DueDate = today.AddDays(5),
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
@ -648,10 +648,11 @@ public class WorkOrderAdvancedSearchServiceTests
Overdue = true
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(2, result.TotalCount);
Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue);
Assert.Contains(result.Items, i => i.Id == 2 && i.WorkOrderType == WorkOrderType.Other);
Assert.DoesNotContain(result.Items, i => i.Id == 3);
// Past due is a status overlay: it narrows the type filter instead of widening it.
var row = Assert.Single(result.Items);
Assert.Equal(1, row.Id);
Assert.True(row.IsPastDue);
Assert.Equal(WorkOrderType.Other, row.WorkOrderType);
}
[Fact]

View file

@ -581,7 +581,7 @@ public class WorkOrderBoardUpdateServiceTests
}
[Fact]
public async Task PatchField_RejectsOverdueAsWorkOrderType()
public async Task PatchField_AcceptsOverdueAsWorkOrderType()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
@ -594,17 +594,15 @@ public class WorkOrderBoardUpdateServiceTests
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("InvalidValue", ex.Code);
Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
Assert.Contains("dueDate", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Equal(WorkOrderType.Overdue, (await context.workOrders.SingleAsync(w => w.Id == 1)).WorkOrderType);
}
[Fact]

View file

@ -0,0 +1,509 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
namespace SeaHavenIndustries.Tests;
// Overdue is a dispatcher-assigned work-order type: no severity, PM catalog
// (services and completion-doc templates), and distinct from the derived
// past-due overlay.
public class WorkOrderOverdueTypeTests
{
private const int PmServiceId = 10;
private const int ReactiveServiceId = 11;
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static void SeedServices(ApplicationDbContext context)
{
context.Services.AddRange(
NewService(PmServiceId, "Backflow Preventers", WorkOrderType.PM),
NewService(ReactiveServiceId, "Leak Repair", WorkOrderType.Reactive));
}
private static Service NewService(int id, string name, WorkOrderType type) => new()
{
Id = id,
Name = name,
NormalizedName = name.ToUpperInvariant(),
Trade = "Plumbing & Water Systems",
IconKey = "plumbing-water-systems",
IsActive = true,
SupportedWorkOrderTypes = new List<ServiceWorkOrderType>
{
new() { WorkOrderType = type }
}
};
private static WorkOrderBoardCreateService NewCreateService(ApplicationDbContext context)
{
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
var boardData = new WorkOrderBoardDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardCreateService(
boardData,
new WorkOrderBoardMutationDataService(context),
new WorkOrderBoardService(boardData, resolver),
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
new WorkOrderBoardCreateValidation(),
resolver,
new WorkOrderPocDataService(context),
new ServicesRegistryService(new ServicesRegistryDataService(context)));
}
private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
new ServicesRegistryService(new ServicesRegistryDataService(context)));
}
private static string ToVersion(WorkOrder workOrder)
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
[Fact]
public async Task Create_Overdue_WithoutSeverity_PersistsOverdueType()
{
await using var context = NewContext();
var service = NewCreateService(context);
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Overdue,
SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Null(result.Severity);
var persisted = await context.workOrders.SingleAsync();
Assert.Equal(WorkOrderType.Overdue, persisted.WorkOrderType);
Assert.Equal(8, (int)persisted.WorkOrderType!.Value);
}
[Fact]
public async Task Create_Overdue_DropsSuppliedSeverity()
{
await using var context = NewContext();
var service = NewCreateService(context);
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Overdue,
SiteCode = "BK5",
LocationId = 1,
Severity = 2
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Null(result.Severity);
Assert.Null((await context.workOrders.SingleAsync()).Severity);
}
[Fact]
public async Task Create_Overdue_AcceptsPmCatalogService()
{
await using var context = NewContext();
SeedServices(context);
var service = NewCreateService(context);
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Overdue,
SiteCode = "BK5",
LocationId = 1,
ServiceId = PmServiceId
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(PmServiceId, result.ServiceId);
Assert.Equal("Backflow Preventers", (await context.workOrders.SingleAsync()).ServiceNameSnapshot);
}
[Fact]
public async Task Create_Overdue_RejectsReactiveOnlyService()
{
await using var context = NewContext();
SeedServices(context);
var service = NewCreateService(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Overdue,
SiteCode = "BK5",
LocationId = 1,
ServiceId = ReactiveServiceId
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("ServiceInvalid", ex.Code);
}
[Fact]
public async Task PatchType_ToOverdue_ClearsSeverity()
{
await using var context = NewContext();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Reactive,
Severity = "3",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Null(result.Severity);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(WorkOrderType.Overdue, persisted.WorkOrderType);
Assert.Null(persisted.Severity);
}
// The board sends one request per field, severity before type, so correcting
// Overdue to Emergency patches severity while the stored type is still Overdue.
// A severity patch must not be dropped or rejected for the current type; the
// no-severity invariant is enforced when the type itself changes.
[Fact]
public async Task PatchSeverityThenType_OverdueToEmergency_KeepsSeverity()
{
await using var context = NewContext();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Overdue,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var service = NewUpdateService(context);
var severityStep = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.Severity,
Value = "2",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(severityStep.Severity);
var updated = await context.workOrders.SingleAsync(w => w.Id == 1);
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Emergency",
WorkOrderVersion = ToVersion(updated)
}, "actor-1");
Assert.Equal(WorkOrderType.Emergency, result.WorkOrderType);
Assert.Equal(2, result.Severity);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(WorkOrderType.Emergency, persisted.WorkOrderType);
Assert.Equal("2", persisted.Severity);
}
// A severity patch with no following type change leaves a stored value on an
// Overdue WO; the board row still reports no severity.
[Fact]
public async Task PatchSeverity_OnOverdue_RowShowsNoSeverity()
{
await using var context = NewContext();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Overdue,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.Severity,
Value = "3",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Null(result.Severity);
}
[Fact]
public async Task PatchType_PmWithPmService_ToOverdue_KeepsService()
{
await using var context = NewContext();
SeedServices(context);
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.PM,
ServiceId = PmServiceId,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
Assert.Equal(PmServiceId, (await context.workOrders.SingleAsync(w => w.Id == 1)).ServiceId);
}
[Fact]
public async Task PatchServiceId_OnOverdue_AcceptsPmCatalogService()
{
await using var context = NewContext();
SeedServices(context);
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Overdue,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ServiceId,
Value = PmServiceId.ToString(),
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(PmServiceId, persisted.ServiceId);
Assert.Equal("Backflow Preventers", persisted.Trade);
}
[Fact]
public async Task GetDetail_Overdue_ResolvesPmCompletionTemplate()
{
await using var context = NewContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = DateTime.UtcNow.AddDays(1),
DocStatus = DocStatus.No,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.CompletionDocTemplates.Add(new CompletionDocTemplate
{
Name = "PM Completion",
ServiceKey = "pm-generic",
WorkOrderType = WorkOrderType.PM,
TemplateUrl = "https://example.com/pm.pdf",
IsActive = true
});
await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var service = new WorkOrderDetailService(
new WorkOrderBoardService(boardData, resolver),
new WorkOrderDetailDataService(context),
new CompletionDocTemplateDataService(context),
new UserDataService(context),
resolver);
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.Equal(DocStatus.No, detail!.Completion.DocStatus);
Assert.NotNull(detail.Completion.Template);
Assert.Equal("PM Completion", detail.Completion.Template!.Name);
}
[Fact]
public async Task ListServices_Overdue_ReturnsPmCatalog()
{
await using var context = NewContext();
SeedServices(context);
await context.SaveChangesAsync();
var services = await new ServicesRegistryService(new ServicesRegistryDataService(context))
.GetAllAsync(true, WorkOrderType.Overdue, CancellationToken.None);
Assert.Equal(new[] { PmServiceId }, services.Select(s => s.Id).ToArray());
}
[Fact]
public async Task ListTemplates_Overdue_ReturnsPmTemplates()
{
await using var context = NewContext();
context.CompletionDocTemplates.AddRange(
new CompletionDocTemplate
{
Name = "PM Completion",
ServiceKey = "pm-generic",
WorkOrderType = WorkOrderType.PM,
TemplateUrl = "https://example.com/pm.pdf",
IsActive = true
},
new CompletionDocTemplate
{
Name = "Reactive Completion",
ServiceKey = "reactive-generic",
WorkOrderType = WorkOrderType.Reactive,
TemplateUrl = "https://example.com/reactive.pdf",
IsActive = true
});
await context.SaveChangesAsync();
var service = new CompletionDocTemplateService(
new CompletionDocTemplateDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
WorkOrderAccountTestHelpers.Resolver(context));
var templates = await service.ListAsync(null, null, WorkOrderType.Overdue, CancellationToken.None);
Assert.Equal(new[] { "PM Completion" }, templates.Select(t => t.Name).ToArray());
}
[Fact]
public async Task Search_TypeOverdue_ReturnsOverdueTypeOnly_NotPastDueRows()
{
await using var context = NewContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = today.AddDays(2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = today.AddDays(-2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
await context.SaveChangesAsync();
var result = await NewSearchService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Overdue }
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id));
Assert.All(result.Items, i => Assert.Equal(WorkOrderType.Overdue, i.WorkOrderType));
}
[Fact]
public async Task Search_TypeOverdueWithPastDue_ReturnsIntersection()
{
await using var context = NewContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = today.AddDays(2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Overdue,
ScheduledDate = today.AddDays(-2),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
await context.SaveChangesAsync();
var result = await NewSearchService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Overdue },
Overdue = true
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
var row = Assert.Single(result.Items);
Assert.Equal(3, row.Id);
Assert.True(row.IsPastDue);
}
[Fact]
public void DashboardBreakdown_CountsByTypeOnly()
{
var today = new DateOnly(2026, 9, 25);
var pastDue = today.AddDays(-3).ToDateTime(TimeOnly.MinValue);
var future = today.AddDays(3).ToDateTime(TimeOnly.MinValue);
var rows = new List<DashboardWorkOrder>
{
new(1, null, WorkOrderType.Overdue, LifecycleStatus.Scheduled, null, future, null, null),
new(2, null, WorkOrderType.PM, LifecycleStatus.Scheduled, null, pastDue, null, null),
new(3, null, WorkOrderType.Overdue, LifecycleStatus.Scheduled, null, pastDue, null, null),
};
var result = DashboardMetrics.Calculate(rows, today);
Assert.Equal(2, result.Breakdown.Overdue);
Assert.Equal(1, result.Breakdown.PM);
Assert.Equal(0, result.Breakdown.Other);
}
private static WorkOrderAdvancedSearchService NewSearchService(ApplicationDbContext context)
=> new(new WorkOrderAdvancedSearchDataService(context), WorkOrderAccountTestHelpers.Resolver(context));
}

View file

@ -10,21 +10,18 @@ public class WorkOrderTypeMapperTests
[InlineData("Reactive", WorkOrderType.Reactive)]
[InlineData("Emergency", WorkOrderType.Emergency)]
[InlineData("Add-On", WorkOrderType.AddOn)]
[InlineData("Overdue", WorkOrderType.Overdue)]
[InlineData("overdue", WorkOrderType.Overdue)]
public void TryParseFeLabel_BoardTypes(string label, WorkOrderType expected)
{
Assert.True(WorkOrderTypeMapper.TryParseFeLabel(label, out var type));
Assert.Equal(expected, type);
}
[Fact]
public void TryParseFeLabel_Overdue_ReturnsFalse()
{
Assert.False(WorkOrderTypeMapper.TryParseFeLabel("Overdue", out _));
}
[Theory]
[InlineData(WorkOrderType.AddOn, "Add-On")]
[InlineData(WorkOrderType.Reactive, "Reactive")]
[InlineData(WorkOrderType.Overdue, "Overdue")]
public void ToFeLabel_ReturnsBoardLabel(WorkOrderType type, string expected)
{
Assert.Equal(expected, WorkOrderTypeMapper.ToFeLabel(type));