mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
Merge branch 'fix/ab/sh-403-reset-code-hardening' into fix/ab/sh-403-reset-hardening-2
This commit is contained in:
commit
35e79a276d
54 changed files with 7550 additions and 142 deletions
|
|
@ -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);
|
||||
|
|
|
|||
105
Api.SeaHavenIndustries.Tests/SendMessageTests.cs
Normal file
105
Api.SeaHavenIndustries.Tests/SendMessageTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
171
Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs
Normal file
171
Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs
Normal 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"))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
24
Api.SeaHavenIndustries/DTOs/LoginPayload.cs
Normal file
24
Api.SeaHavenIndustries/DTOs/LoginPayload.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
4289
Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs
generated
Normal file
4289
Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
44
Data.SeaHavenIndustries/Models/TeamMemberInvite.cs
Normal file
44
Data.SeaHavenIndustries/Models/TeamMemberInvite.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
36
SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs
Normal file
36
SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
71
SeaHaven.Services/DTOs/TeamMemberInviteDTOs.cs
Normal file
71
SeaHaven.Services/DTOs/TeamMemberInviteDTOs.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
62
SeaHaven.Services/Helpers/TeamMemberInviteSecrets.cs
Normal file
62
SeaHaven.Services/Helpers/TeamMemberInviteSecrets.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
17
SeaHaven.Services/Helpers/WorkOrderCatalogType.cs
Normal file
17
SeaHaven.Services/Helpers/WorkOrderCatalogType.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
132
SeaHaven.Services/Implementation/TeamMemberInviteService.cs
Normal file
132
SeaHaven.Services/Implementation/TeamMemberInviteService.cs
Normal 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 };
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
31
SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs
Normal file
31
SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
139
SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs
Normal file
139
SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs
Normal 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));
|
||||
}
|
||||
675
SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs
Normal file
675
SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs
Normal 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('/', '_');
|
||||
}
|
||||
}
|
||||
262
SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs
Normal file
262
SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Security.Claims;
|
||||
using System.Text.RegularExpressions;
|
||||
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);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
509
SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs
Normal file
509
SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs
Normal 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));
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue