diff --git a/compose.yaml b/compose.yaml index 3eb9804..f38d0c1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,5 +13,15 @@ services: volumes: - distroflow_pgdata:/var/lib/postgresql + # Local email catcher: the API sends to localhost:1025; read the emails at http://localhost:8025. + # It never delivers to real inboxes, so it is for development only. + mailpit: + image: axllent/mailpit:v1.33.1 + container_name: distroflow-mailpit + restart: unless-stopped + ports: + - "127.0.0.1:1025:1025" # SMTP + - "127.0.0.1:8025:8025" # web UI + volumes: distroflow_pgdata: diff --git a/src/DistroFlow.Api/Controllers/AuthController.cs b/src/DistroFlow.Api/Controllers/AuthController.cs index 8702c48..2c883d8 100644 --- a/src/DistroFlow.Api/Controllers/AuthController.cs +++ b/src/DistroFlow.Api/Controllers/AuthController.cs @@ -50,6 +50,36 @@ public async Task Register( }; } + /// An invited employee sets their first password using the link from their invite email. + [AllowAnonymous] + [HttpPost("set-password")] + public async Task SetPassword( + [FromBody] SetPasswordRequest request, + CancellationToken cancellationToken) + { + var result = await _authService.SetPasswordAsync(request, cancellationToken); + + return result.Error switch + { + SetPasswordError.None => Ok( + ApiResponse.SuccessResponse(null, "Password set. You can now log in.")), + + SetPasswordError.InvalidLink => BadRequest( + ApiResponse.FailureResponse( + "This link is invalid or has expired. Ask your administrator to resend the invite.")), + + SetPasswordError.PasswordRejected => BadRequest( + ApiResponse.FailureResponse( + "Validation failed.", + new Dictionary + { + [nameof(SetPasswordRequest.NewPassword)] = result.Messages.ToArray() + })), + + _ => throw new InvalidOperationException($"Unhandled set-password error: {result.Error}") + }; + } + /// Exchanges email + password for a JWT access token. [AllowAnonymous] [HttpPost("login")] diff --git a/src/DistroFlow.Api/Controllers/EmployeesController.cs b/src/DistroFlow.Api/Controllers/EmployeesController.cs new file mode 100644 index 0000000..f5bce1b --- /dev/null +++ b/src/DistroFlow.Api/Controllers/EmployeesController.cs @@ -0,0 +1,209 @@ +using DistroFlow.Application.DTOs.Common; +using DistroFlow.Application.DTOs.Employees; +using DistroFlow.Application.Interfaces.Services; +using DistroFlow.Domain.Constants; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DistroFlow.Api.Controllers; + +/// Employee management for the caller's own distributor. Administrators only. +[ApiController] +[Authorize(Roles = EmployeeRoles.Administrator)] +[Route("api/v1/employees")] +public class EmployeesController : ControllerBase +{ + private readonly IEmployeeService _employeeService; + + public EmployeesController(IEmployeeService employeeService) + { + _employeeService = employeeService; + } + + [HttpGet] + public async Task List(CancellationToken cancellationToken) + { + var employees = await _employeeService.ListAsync(cancellationToken); + + return Ok(ApiResponse>.SuccessResponse( + employees, "Employees retrieved successfully.")); + } + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id, CancellationToken cancellationToken) + { + var employee = await _employeeService.GetByIdAsync(id, cancellationToken); + + if (employee is null) + { + return NotFound(ApiResponse.FailureResponse("Employee not found.")); + } + + return Ok(ApiResponse.SuccessResponse( + employee, "Employee retrieved successfully.")); + } + + /// Adds an employee and emails them a link to set their password. + [HttpPost] + public async Task Invite( + [FromBody] InviteEmployeeRequest request, + CancellationToken cancellationToken) + { + var result = await _employeeService.InviteAsync(request, cancellationToken); + + return result.Error switch + { + InviteEmployeeError.None => StatusCode( + StatusCodes.Status201Created, + ApiResponse.SuccessResponse( + result.Response, + result.Response!.InviteEmailSent + ? "Employee invited. A set-password email has been sent." + : "Employee added, but the invite email could not be sent. Try resending the invite.")), + + InviteEmployeeError.EmailInUse => Conflict( + ApiResponse.FailureResponse( + "This email can't be used right now. If the person worked for another distributor, " + + "ask them to check with their previous employer.")), + + InviteEmployeeError.EmployeeCodeInUse => Conflict( + ApiResponse.FailureResponse("Another employee already has this employee code.")), + + _ => throw new InvalidOperationException($"Unhandled invite error: {result.Error}") + }; + } + + /// Changes an employee's name, phone and employee code. + [HttpPut("{id:guid}")] + public async Task Update( + Guid id, + [FromBody] UpdateEmployeeRequest request, + CancellationToken cancellationToken) + { + var result = await _employeeService.UpdateAsync(id, request, cancellationToken); + + return result.Error switch + { + UpdateEmployeeError.None => Ok( + ApiResponse.SuccessResponse(result.Response, "Employee updated successfully.")), + + UpdateEmployeeError.NotFound => NotFound( + ApiResponse.FailureResponse("Employee not found.")), + + UpdateEmployeeError.NotEditable => Conflict( + ApiResponse.FailureResponse("This employee's personal data was erased and cannot be edited.")), + + UpdateEmployeeError.EmployeeCodeInUse => Conflict( + ApiResponse.FailureResponse("Another employee already has this employee code.")), + + _ => throw new InvalidOperationException($"Unhandled update error: {result.Error}") + }; + } + + /// Replaces an employee's roles. The last active Administrator cannot be demoted. + [HttpPut("{id:guid}/roles")] + public async Task ChangeRoles( + Guid id, + [FromBody] ChangeEmployeeRolesRequest request, + CancellationToken cancellationToken) + { + var result = await _employeeService.ChangeRolesAsync(id, request, cancellationToken); + + return result.Error switch + { + ChangeEmployeeRolesError.None => Ok( + ApiResponse.SuccessResponse(result.Response, "Roles updated successfully.")), + + ChangeEmployeeRolesError.NotFound => NotFound( + ApiResponse.FailureResponse("Employee not found.")), + + ChangeEmployeeRolesError.NotEditable => Conflict( + ApiResponse.FailureResponse("This employee has no login, so roles cannot be changed.")), + + ChangeEmployeeRolesError.LastAdministrator => Conflict( + ApiResponse.FailureResponse( + "This is the last active Administrator. Make someone else an Administrator first.")), + + _ => throw new InvalidOperationException($"Unhandled change-roles error: {result.Error}") + }; + } + + /// The employee left or is suspended: blocks their login. Safe to repeat. + [HttpPost("{id:guid}/deactivate")] + public async Task Deactivate(Guid id, CancellationToken cancellationToken) + { + var result = await _employeeService.DeactivateAsync(id, cancellationToken); + + return result.Error switch + { + EmployeeStatusChangeError.None => Ok( + ApiResponse.SuccessResponse(result.Response, "Employee deactivated.")), + + EmployeeStatusChangeError.NotFound => NotFound( + ApiResponse.FailureResponse("Employee not found.")), + + EmployeeStatusChangeError.NotEditable => Conflict( + ApiResponse.FailureResponse("This employee's personal data was erased; their status cannot change.")), + + EmployeeStatusChangeError.LastAdministrator => Conflict( + ApiResponse.FailureResponse( + "This is the last active Administrator. Make someone else an Administrator first.")), + + _ => throw new InvalidOperationException($"Unhandled deactivate error: {result.Error}") + }; + } + + /// A deactivated employee returns and can log in again. Safe to repeat. + [HttpPost("{id:guid}/reactivate")] + public async Task Reactivate(Guid id, CancellationToken cancellationToken) + { + var result = await _employeeService.ReactivateAsync(id, cancellationToken); + + return result.Error switch + { + EmployeeStatusChangeError.None => Ok( + ApiResponse.SuccessResponse(result.Response, "Employee reactivated.")), + + EmployeeStatusChangeError.NotFound => NotFound( + ApiResponse.FailureResponse("Employee not found.")), + + EmployeeStatusChangeError.NotEditable => Conflict( + ApiResponse.FailureResponse("This employee's personal data was erased; their status cannot change.")), + + EmployeeStatusChangeError.EmailInUse => Conflict( + ApiResponse.FailureResponse( + "This employee's email is now in use by another account, so they can't be reactivated. " + + "Invite them again with a different email.")), + + _ => throw new InvalidOperationException($"Unhandled reactivate error: {result.Error}") + }; + } + + /// Sends a new set-password link to an employee who has not accepted their invite yet. + [HttpPost("{id:guid}/resend-invite")] + public async Task ResendInvite(Guid id, CancellationToken cancellationToken) + { + var result = await _employeeService.ResendInviteAsync(id, cancellationToken); + + return result.Error switch + { + ResendInviteError.None => Ok( + ApiResponse.SuccessResponse( + result.Response, + result.Response!.InviteEmailSent + ? "Invite email sent." + : "The invite email could not be sent. Please try again later.")), + + ResendInviteError.NotFound => NotFound( + ApiResponse.FailureResponse("Employee not found.")), + + ResendInviteError.AlreadyAccepted => Conflict( + ApiResponse.FailureResponse("This employee has already set a password.")), + + ResendInviteError.NotActive => Conflict( + ApiResponse.FailureResponse("Only active employees can be invited.")), + + _ => throw new InvalidOperationException($"Unhandled resend-invite error: {result.Error}") + }; + } +} diff --git a/src/DistroFlow.Api/Extensions/ServiceCollectionExtensions.cs b/src/DistroFlow.Api/Extensions/ServiceCollectionExtensions.cs index 92fad09..2c387f6 100644 --- a/src/DistroFlow.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/DistroFlow.Api/Extensions/ServiceCollectionExtensions.cs @@ -38,6 +38,17 @@ public static IServiceCollection AddApiControllers(this IServiceCollection servi return services; } + /// Frontend base URL, used to build links in emails. Validated at startup. + public static IServiceCollection AddFrontendLinks(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(FrontendOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + return services; + } + /// Who is calling: the current tenant, read from the request's JWT. public static IServiceCollection AddCurrentTenant(this IServiceCollection services) { diff --git a/src/DistroFlow.Api/Program.cs b/src/DistroFlow.Api/Program.cs index 24365d9..464d6d3 100644 --- a/src/DistroFlow.Api/Program.cs +++ b/src/DistroFlow.Api/Program.cs @@ -12,6 +12,7 @@ builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddJwtAuthentication(); builder.Services.AddCurrentTenant(); +builder.Services.AddFrontendLinks(builder.Configuration); var app = builder.Build(); diff --git a/src/DistroFlow.Api/appsettings.Development.json b/src/DistroFlow.Api/appsettings.Development.json index 843b77b..69cd643 100644 --- a/src/DistroFlow.Api/appsettings.Development.json +++ b/src/DistroFlow.Api/appsettings.Development.json @@ -7,5 +7,13 @@ }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Port=5433;Database=distroflow_dev;Username=distroflow" + }, + "Smtp": { + "Host": "localhost", + "Port": 1025, + "UseTls": false + }, + "Frontend": { + "BaseUrl": "http://localhost:5173" } } diff --git a/src/DistroFlow.Api/appsettings.json b/src/DistroFlow.Api/appsettings.json index f856aa3..db00565 100644 --- a/src/DistroFlow.Api/appsettings.json +++ b/src/DistroFlow.Api/appsettings.json @@ -10,5 +10,11 @@ "Issuer": "DistroFlow.Api", "Audience": "DistroFlow.Web", "AccessTokenMinutes": 30 + }, + "Smtp": { + "Port": 587, + "UseTls": true, + "FromAddress": "no-reply@distroflow.local", + "FromName": "DistroFlow" } } diff --git a/src/DistroFlow.Application/Common/FrontendOptions.cs b/src/DistroFlow.Application/Common/FrontendOptions.cs new file mode 100644 index 0000000..c71bb35 --- /dev/null +++ b/src/DistroFlow.Application/Common/FrontendOptions.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace DistroFlow.Application.Common; + +/// +/// Where the React app runs, so emails can link to its pages (e.g. set-password). +/// Bound from the "Frontend" configuration section. +/// +public sealed class FrontendOptions +{ + public const string SectionName = "Frontend"; + + [Required] + [Url] + public string BaseUrl { get; init; } = string.Empty; +} diff --git a/src/DistroFlow.Application/Common/UniqueConstraintViolationException.cs b/src/DistroFlow.Application/Common/UniqueConstraintViolationException.cs new file mode 100644 index 0000000..a146a57 --- /dev/null +++ b/src/DistroFlow.Application/Common/UniqueConstraintViolationException.cs @@ -0,0 +1,19 @@ +namespace DistroFlow.Application.Common; + +/// +/// The database rejected a save because a unique index would be violated. +/// Thrown by the persistence layer so the Application layer can react to specific rules +/// (e.g. duplicate employee code) without depending on EF Core or PostgreSQL types. +/// +public class UniqueConstraintViolationException(string constraintName, Exception innerException) + : Exception($"Unique constraint '{constraintName}' was violated.", innerException) +{ + public string ConstraintName { get; } = constraintName; +} + +/// Names of database unique indexes the Application layer reacts to. +public static class ConstraintNames +{ + /// Employee code is unique within one distributor. + public const string EmployeeCodePerTenant = "IX_Employees_TenantId_EmployeeCode"; +} diff --git a/src/DistroFlow.Application/DTOs/Auth/SetPasswordRequest.cs b/src/DistroFlow.Application/DTOs/Auth/SetPasswordRequest.cs new file mode 100644 index 0000000..b2d03fb --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Auth/SetPasswordRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace DistroFlow.Application.DTOs.Auth; + +/// Sent by the frontend's set-password page, with userId and token taken from the invite link. +public class SetPasswordRequest +{ + [Required] + public Guid? UserId { get; set; } + + [Required] + [StringLength(2048)] + public string Token { get; set; } = string.Empty; + + /// Strength rules are enforced by Identity's password policy. + [Required] + [StringLength(128)] + public string NewPassword { get; set; } = string.Empty; +} diff --git a/src/DistroFlow.Application/DTOs/Employees/ChangeEmployeeRolesRequest.cs b/src/DistroFlow.Application/DTOs/Employees/ChangeEmployeeRolesRequest.cs new file mode 100644 index 0000000..817a1c7 --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Employees/ChangeEmployeeRolesRequest.cs @@ -0,0 +1,10 @@ +using DistroFlow.Application.Validators; + +namespace DistroFlow.Application.DTOs.Employees; + +/// The employee's complete new set of roles (replaces the current ones). +public class ChangeEmployeeRolesRequest +{ + [ValidEmployeeRoles] + public List Roles { get; set; } = []; +} diff --git a/src/DistroFlow.Application/DTOs/Employees/ChangeEmployeeRolesResult.cs b/src/DistroFlow.Application/DTOs/Employees/ChangeEmployeeRolesResult.cs new file mode 100644 index 0000000..24f1b5c --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Employees/ChangeEmployeeRolesResult.cs @@ -0,0 +1,28 @@ +namespace DistroFlow.Application.DTOs.Employees; + +public enum ChangeEmployeeRolesError +{ + None = 0, + + /// No such employee in the caller's distributor. + NotFound = 1, + + /// The employee has no login (anonymised), so there are no roles to change. + NotEditable = 2, + + /// The change would leave the distributor without an active Administrator. + LastAdministrator = 3 +} + +public sealed record ChangeEmployeeRolesResult +{ + public EmployeeResponse? Response { get; private init; } + + public ChangeEmployeeRolesError Error { get; private init; } + + public bool Succeeded => Error == ChangeEmployeeRolesError.None; + + public static ChangeEmployeeRolesResult Success(EmployeeResponse response) => new() { Response = response }; + + public static ChangeEmployeeRolesResult Failure(ChangeEmployeeRolesError error) => new() { Error = error }; +} diff --git a/src/DistroFlow.Application/DTOs/Employees/EmployeeResponse.cs b/src/DistroFlow.Application/DTOs/Employees/EmployeeResponse.cs new file mode 100644 index 0000000..112346e --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Employees/EmployeeResponse.cs @@ -0,0 +1,23 @@ +namespace DistroFlow.Application.DTOs.Employees; + +public class EmployeeResponse +{ + public Guid Id { get; set; } + + public string FullName { get; set; } = string.Empty; + + /// Login email. Null once the employee is anonymised (login deleted). + public string? Email { get; set; } + + public string? Phone { get; set; } + + public string? EmployeeCode { get; set; } + + public string Status { get; set; } = string.Empty; + + public IReadOnlyList Roles { get; set; } = []; + + public DateTime CreatedAtUtc { get; set; } + + public DateTime? UpdatedAtUtc { get; set; } +} diff --git a/src/DistroFlow.Application/DTOs/Employees/EmployeeStatusChangeResult.cs b/src/DistroFlow.Application/DTOs/Employees/EmployeeStatusChangeResult.cs new file mode 100644 index 0000000..f3ebd7e --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Employees/EmployeeStatusChangeResult.cs @@ -0,0 +1,32 @@ +namespace DistroFlow.Application.DTOs.Employees; + +/// Outcome of deactivating or reactivating an employee. +public enum EmployeeStatusChangeError +{ + None = 0, + + /// No such employee in the caller's distributor. + NotFound = 1, + + /// The employee is anonymised; their status can no longer change. + NotEditable = 2, + + /// Deactivating would leave the distributor without an active Administrator. + LastAdministrator = 3, + + /// Reactivating is impossible: the email is now active at another distributor. + EmailInUse = 4 +} + +public sealed record EmployeeStatusChangeResult +{ + public EmployeeResponse? Response { get; private init; } + + public EmployeeStatusChangeError Error { get; private init; } + + public bool Succeeded => Error == EmployeeStatusChangeError.None; + + public static EmployeeStatusChangeResult Success(EmployeeResponse response) => new() { Response = response }; + + public static EmployeeStatusChangeResult Failure(EmployeeStatusChangeError error) => new() { Error = error }; +} diff --git a/src/DistroFlow.Application/DTOs/Employees/InviteEmployeeRequest.cs b/src/DistroFlow.Application/DTOs/Employees/InviteEmployeeRequest.cs new file mode 100644 index 0000000..60708b5 --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Employees/InviteEmployeeRequest.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using DistroFlow.Application.Validators; + +namespace DistroFlow.Application.DTOs.Employees; + +/// +/// An Administrator adds an employee. No password: the employee sets it via the emailed link. +/// +public class InviteEmployeeRequest +{ + [Required] + [StringLength(150, MinimumLength = 2)] + public string FullName { get; set; } = string.Empty; + + [Required] + [EmailAddress] + [StringLength(256)] + public string Email { get; set; } = string.Empty; + + [RegularExpression(@"^\+[1-9]\d{7,14}$", + ErrorMessage = "Phone must be in E.164 format, e.g. +919876543210.")] + public string? Phone { get; set; } + + [StringLength(20)] + public string? EmployeeCode { get; set; } + + [ValidEmployeeRoles] + public List Roles { get; set; } = []; +} diff --git a/src/DistroFlow.Application/DTOs/Employees/InviteEmployeeResult.cs b/src/DistroFlow.Application/DTOs/Employees/InviteEmployeeResult.cs new file mode 100644 index 0000000..885ddb4 --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Employees/InviteEmployeeResult.cs @@ -0,0 +1,36 @@ +namespace DistroFlow.Application.DTOs.Employees; + +public enum InviteEmployeeError +{ + None = 0, + + /// The email already belongs to an active account (in any distributor). + EmailInUse = 1, + + /// The employee code is already used by another employee of this distributor. + EmployeeCodeInUse = 2 +} + +public class InviteEmployeeResponse +{ + public EmployeeResponse Employee { get; set; } = new(); + + /// + /// False if the employee was created but the invite email could not be sent; + /// the Administrator can resend the invite. + /// + public bool InviteEmailSent { get; set; } +} + +public sealed record InviteEmployeeResult +{ + public InviteEmployeeResponse? Response { get; private init; } + + public InviteEmployeeError Error { get; private init; } + + public bool Succeeded => Error == InviteEmployeeError.None; + + public static InviteEmployeeResult Success(InviteEmployeeResponse response) => new() { Response = response }; + + public static InviteEmployeeResult Failure(InviteEmployeeError error) => new() { Error = error }; +} diff --git a/src/DistroFlow.Application/DTOs/Employees/ResendInviteResult.cs b/src/DistroFlow.Application/DTOs/Employees/ResendInviteResult.cs new file mode 100644 index 0000000..cb70704 --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Employees/ResendInviteResult.cs @@ -0,0 +1,34 @@ +namespace DistroFlow.Application.DTOs.Employees; + +public enum ResendInviteError +{ + None = 0, + + /// No such employee in the caller's distributor. + NotFound = 1, + + /// The employee already set a password; there is no pending invite. + AlreadyAccepted = 2, + + /// Deactivated or anonymised employees cannot be invited. + NotActive = 3 +} + +public class ResendInviteResponse +{ + public bool InviteEmailSent { get; set; } +} + +public sealed record ResendInviteResult +{ + public ResendInviteResponse? Response { get; private init; } + + public ResendInviteError Error { get; private init; } + + public bool Succeeded => Error == ResendInviteError.None; + + public static ResendInviteResult Success(bool emailSent) => + new() { Response = new ResendInviteResponse { InviteEmailSent = emailSent } }; + + public static ResendInviteResult Failure(ResendInviteError error) => new() { Error = error }; +} diff --git a/src/DistroFlow.Application/DTOs/Employees/UpdateEmployeeRequest.cs b/src/DistroFlow.Application/DTOs/Employees/UpdateEmployeeRequest.cs new file mode 100644 index 0000000..f2118ef --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Employees/UpdateEmployeeRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace DistroFlow.Application.DTOs.Employees; + +/// +/// Full replacement of the editable profile fields (PUT): omitting phone or code clears it. +/// Email and roles are changed elsewhere (email is the login; roles have their own rules). +/// +public class UpdateEmployeeRequest +{ + [Required] + [StringLength(150, MinimumLength = 2)] + public string FullName { get; set; } = string.Empty; + + [RegularExpression(@"^\+[1-9]\d{7,14}$", + ErrorMessage = "Phone must be in E.164 format, e.g. +919876543210.")] + public string? Phone { get; set; } + + [StringLength(20)] + public string? EmployeeCode { get; set; } +} diff --git a/src/DistroFlow.Application/DTOs/Employees/UpdateEmployeeResult.cs b/src/DistroFlow.Application/DTOs/Employees/UpdateEmployeeResult.cs new file mode 100644 index 0000000..09af002 --- /dev/null +++ b/src/DistroFlow.Application/DTOs/Employees/UpdateEmployeeResult.cs @@ -0,0 +1,28 @@ +namespace DistroFlow.Application.DTOs.Employees; + +public enum UpdateEmployeeError +{ + None = 0, + + /// No such employee in the caller's distributor. + NotFound = 1, + + /// The employee is anonymised; their data must not be re-entered. + NotEditable = 2, + + /// Another employee of this distributor already has this employee code. + EmployeeCodeInUse = 3 +} + +public sealed record UpdateEmployeeResult +{ + public EmployeeResponse? Response { get; private init; } + + public UpdateEmployeeError Error { get; private init; } + + public bool Succeeded => Error == UpdateEmployeeError.None; + + public static UpdateEmployeeResult Success(EmployeeResponse response) => new() { Response = response }; + + public static UpdateEmployeeResult Failure(UpdateEmployeeError error) => new() { Error = error }; +} diff --git a/src/DistroFlow.Application/DependencyInjection.cs b/src/DistroFlow.Application/DependencyInjection.cs index 7cae76e..2482b08 100644 --- a/src/DistroFlow.Application/DependencyInjection.cs +++ b/src/DistroFlow.Application/DependencyInjection.cs @@ -11,6 +11,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services { services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/DistroFlow.Application/DistroFlow.Application.csproj b/src/DistroFlow.Application/DistroFlow.Application.csproj index a94d94e..749e48a 100644 --- a/src/DistroFlow.Application/DistroFlow.Application.csproj +++ b/src/DistroFlow.Application/DistroFlow.Application.csproj @@ -6,6 +6,8 @@ + + diff --git a/src/DistroFlow.Application/Emails/InvitationEmail.cs b/src/DistroFlow.Application/Emails/InvitationEmail.cs new file mode 100644 index 0000000..b57855e --- /dev/null +++ b/src/DistroFlow.Application/Emails/InvitationEmail.cs @@ -0,0 +1,37 @@ +using System.Net; +using DistroFlow.Application.Interfaces.Email; + +namespace DistroFlow.Application.Emails; + +/// The email an invited employee receives, with the link to set their password. +public static class InvitationEmail +{ + public static EmailMessage Create(string to, string employeeName, string distributorName, string setPasswordLink) + { + // Names come from user input: encode them so they can never become HTML/script in the email. + var name = WebUtility.HtmlEncode(employeeName); + var distributor = WebUtility.HtmlEncode(distributorName); + var link = WebUtility.HtmlEncode(setPasswordLink); + + var subject = $"You're invited to {distributorName} on DistroFlow"; + + var html = $""" +

Hi {name},

+

You've been added to {distributor} on DistroFlow.

+

Set your password to get started. This link can be used once and expires in 3 days.

+

If you weren't expecting this, you can ignore this email.

+ """; + + var text = $""" + Hi {employeeName}, + + You've been added to {distributorName} on DistroFlow. + Set your password to get started (the link can be used once and expires in 3 days): + {setPasswordLink} + + If you weren't expecting this, you can ignore this email. + """; + + return new EmailMessage(to, subject, html, text); + } +} diff --git a/src/DistroFlow.Application/Interfaces/Email/IEmailSender.cs b/src/DistroFlow.Application/Interfaces/Email/IEmailSender.cs new file mode 100644 index 0000000..b34d644 --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Email/IEmailSender.cs @@ -0,0 +1,18 @@ +namespace DistroFlow.Application.Interfaces.Email; + +/// +/// Sends an email. The Application layer only says *what* to send; how (SMTP, which +/// provider) is an Infrastructure and configuration decision. +/// +public interface IEmailSender +{ + /// Throws if the message could not be handed to the mail server. + Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default); +} + +/// Plain-text version, for mail clients that do not show HTML. +public sealed record EmailMessage( + string To, + string Subject, + string HtmlBody, + string TextBody); diff --git a/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs b/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs index e1ef274..871a4a0 100644 --- a/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs +++ b/src/DistroFlow.Application/Interfaces/Identity/IIdentityService.cs @@ -17,6 +17,59 @@ Task CreateUserAsync( string role, CancellationToken cancellationToken = default); + /// + /// Creates a login with no password (an invited employee sets it via an emailed link) + /// and assigns the roles. Saves immediately; wrap in a transaction with the employee row. + /// + Task CreateInvitedUserAsync( + Guid tenantId, + string email, + IReadOnlyCollection roles, + CancellationToken cancellationToken = default); + + /// + /// A signed, single-use, expiring token that lets the user set their password. + /// + Task GeneratePasswordSetupTokenAsync( + Guid userId, + CancellationToken cancellationToken = default); + + /// + /// An invited user sets their first password using the token from their invite email. + /// Never works for an account that already has a password (this is not a password reset). + /// + Task SetInitialPasswordAsync( + Guid userId, + string token, + string newPassword, + CancellationToken cancellationToken = default); + + /// Makes the user's roles exactly (adds missing, removes others). + Task ReplaceRolesAsync( + Guid userId, + IReadOnlyCollection roles, + CancellationToken cancellationToken = default); + + /// + /// Turns a login on or off. An inactive login cannot sign in and frees its email for reuse. + /// Deactivating also invalidates any outstanding invite links. + /// Saves immediately; wrap in a transaction with the employee change. + /// + Task SetLoginActiveAsync( + Guid userId, + bool isActive, + CancellationToken cancellationToken = default); + + /// Number of active logins in the tenant that have the Administrator role. + Task CountActiveAdministratorsAsync( + Guid tenantId, + CancellationToken cancellationToken = default); + + /// False for an invited user who has not set their password yet. + Task HasPasswordAsync( + Guid userId, + CancellationToken cancellationToken = default); + /// /// Checks email + password against the single active account with that email. /// Counts failed attempts and enforces lockout. @@ -25,4 +78,12 @@ Task CheckCredentialsAsync( string email, string password, CancellationToken cancellationToken = default); + + /// + /// Email and roles for many logins in a fixed number of queries (not one per employee). + /// Callers must pass ids of employees already loaded through the tenant filter. + /// + Task> GetLoginInfoAsync( + IReadOnlyCollection userIds, + CancellationToken cancellationToken = default); } diff --git a/src/DistroFlow.Application/Interfaces/Identity/LoginInfo.cs b/src/DistroFlow.Application/Interfaces/Identity/LoginInfo.cs new file mode 100644 index 0000000..818fef4 --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Identity/LoginInfo.cs @@ -0,0 +1,4 @@ +namespace DistroFlow.Application.Interfaces.Identity; + +/// The parts of an employee that live on their login account. +public sealed record LoginInfo(string? Email, IReadOnlyList Roles); diff --git a/src/DistroFlow.Application/Interfaces/Identity/SetLoginActiveResult.cs b/src/DistroFlow.Application/Interfaces/Identity/SetLoginActiveResult.cs new file mode 100644 index 0000000..1ab5749 --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Identity/SetLoginActiveResult.cs @@ -0,0 +1,12 @@ +namespace DistroFlow.Application.Interfaces.Identity; + +public enum SetLoginActiveResult +{ + Succeeded = 0, + + /// + /// Cannot activate: the email is now used by another active account (e.g. the person + /// joined another distributor after leaving this one). + /// + EmailInUse = 1 +} diff --git a/src/DistroFlow.Application/Interfaces/Identity/SetPasswordResult.cs b/src/DistroFlow.Application/Interfaces/Identity/SetPasswordResult.cs new file mode 100644 index 0000000..06bb67c --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Identity/SetPasswordResult.cs @@ -0,0 +1,29 @@ +namespace DistroFlow.Application.Interfaces.Identity; + +public enum SetPasswordError +{ + None = 0, + + /// + /// Unknown user, inactive account, password already set, or a used/expired/tampered token. + /// Deliberately one error, so the response reveals nothing about which case it was. + /// + InvalidLink = 1, + + /// The password does not meet the policy. The link stays valid. + PasswordRejected = 2 +} + +public sealed record SetPasswordResult +{ + public SetPasswordError Error { get; private init; } + + public IReadOnlyList Messages { get; private init; } = []; + + public bool Succeeded => Error == SetPasswordError.None; + + public static SetPasswordResult Success() => new(); + + public static SetPasswordResult Failure(SetPasswordError error, IReadOnlyList? messages = null) => + new() { Error = error, Messages = messages ?? [] }; +} diff --git a/src/DistroFlow.Application/Interfaces/Repositories/IEmployeeRepository.cs b/src/DistroFlow.Application/Interfaces/Repositories/IEmployeeRepository.cs index 4c905dd..87a6b7e 100644 --- a/src/DistroFlow.Application/Interfaces/Repositories/IEmployeeRepository.cs +++ b/src/DistroFlow.Application/Interfaces/Repositories/IEmployeeRepository.cs @@ -6,6 +6,22 @@ public interface IEmployeeRepository { void Add(Employee employee); + /// Employees of the current tenant (tenant filter applies), ordered by name. + Task> ListAsync( + CancellationToken cancellationToken = default); + + /// + /// An employee of the current tenant, tracked for changes (save with IUnitOfWork), or null. + /// + Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken = default); + + /// An employee of the current tenant, or null (also null for another tenant's id). + Task GetByIdReadOnlyAsync( + Guid id, + CancellationToken cancellationToken = default); + /// /// Login only: finds the employee for an authenticated login across all tenants /// (no tenant is known before login). Do not use for tenant-facing queries. diff --git a/src/DistroFlow.Application/Interfaces/Repositories/ITenantRepository.cs b/src/DistroFlow.Application/Interfaces/Repositories/ITenantRepository.cs index 8f39fc1..49792f9 100644 --- a/src/DistroFlow.Application/Interfaces/Repositories/ITenantRepository.cs +++ b/src/DistroFlow.Application/Interfaces/Repositories/ITenantRepository.cs @@ -14,6 +14,16 @@ public interface ITenantRepository Guid id, CancellationToken cancellationToken = default); + /// + /// Locks the tenant's row until the current transaction ends (SELECT ... FOR UPDATE). + /// Other transactions that lock the same tenant wait, so changes that must see each + /// other's results (e.g. "at least one administrator") run one at a time per tenant. + /// Must be called inside a transaction. + /// + Task LockForUpdateAsync( + Guid tenantId, + CancellationToken cancellationToken = default); + Task SaveChangesAsync( CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/DistroFlow.Application/Interfaces/Services/IAuthService.cs b/src/DistroFlow.Application/Interfaces/Services/IAuthService.cs index 923e032..a25af41 100644 --- a/src/DistroFlow.Application/Interfaces/Services/IAuthService.cs +++ b/src/DistroFlow.Application/Interfaces/Services/IAuthService.cs @@ -1,4 +1,5 @@ using DistroFlow.Application.DTOs.Auth; +using DistroFlow.Application.Interfaces.Identity; namespace DistroFlow.Application.Interfaces.Services; @@ -11,4 +12,8 @@ Task RegisterDistributorAsync( Task LoginAsync( LoginRequest request, CancellationToken cancellationToken = default); + + Task SetPasswordAsync( + SetPasswordRequest request, + CancellationToken cancellationToken = default); } diff --git a/src/DistroFlow.Application/Interfaces/Services/IEmployeeService.cs b/src/DistroFlow.Application/Interfaces/Services/IEmployeeService.cs new file mode 100644 index 0000000..355509e --- /dev/null +++ b/src/DistroFlow.Application/Interfaces/Services/IEmployeeService.cs @@ -0,0 +1,56 @@ +using DistroFlow.Application.DTOs.Employees; + +namespace DistroFlow.Application.Interfaces.Services; + +public interface IEmployeeService +{ + Task> ListAsync( + CancellationToken cancellationToken = default); + + Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken = default); + + /// + /// Adds an employee to the current distributor and emails them a set-password link. + /// + Task InviteAsync( + InviteEmployeeRequest request, + CancellationToken cancellationToken = default); + + /// Changes name, phone and employee code of an employee of the current distributor. + Task UpdateAsync( + Guid employeeId, + UpdateEmployeeRequest request, + CancellationToken cancellationToken = default); + + /// + /// Replaces an employee's roles. Refuses to leave the distributor without an active + /// Administrator, also when two such changes run at the same time. + /// + Task ChangeRolesAsync( + Guid employeeId, + ChangeEmployeeRolesRequest request, + CancellationToken cancellationToken = default); + + /// + /// The employee left or is suspended: they can no longer log in and their email becomes + /// reusable. Refuses to deactivate the last active Administrator. + /// + Task DeactivateAsync( + Guid employeeId, + CancellationToken cancellationToken = default); + + /// + /// A deactivated employee returns: they can log in again with their old password. + /// Fails if their email is now active at another distributor. + /// + Task ReactivateAsync( + Guid employeeId, + CancellationToken cancellationToken = default); + + /// Emails a fresh set-password link to an employee who has not accepted yet. + Task ResendInviteAsync( + Guid employeeId, + CancellationToken cancellationToken = default); +} diff --git a/src/DistroFlow.Application/Services/AuthService.cs b/src/DistroFlow.Application/Services/AuthService.cs index 84c3d49..d1aeebb 100644 --- a/src/DistroFlow.Application/Services/AuthService.cs +++ b/src/DistroFlow.Application/Services/AuthService.cs @@ -30,6 +30,12 @@ public AuthService( _unitOfWork = unitOfWork; } + public Task SetPasswordAsync( + SetPasswordRequest request, + CancellationToken cancellationToken = default) => + _identityService.SetInitialPasswordAsync( + request.UserId!.Value, request.Token, request.NewPassword, cancellationToken); + public async Task LoginAsync( LoginRequest request, CancellationToken cancellationToken = default) diff --git a/src/DistroFlow.Application/Services/EmployeeService.cs b/src/DistroFlow.Application/Services/EmployeeService.cs new file mode 100644 index 0000000..815fcb0 --- /dev/null +++ b/src/DistroFlow.Application/Services/EmployeeService.cs @@ -0,0 +1,379 @@ +using DistroFlow.Application.Common; +using DistroFlow.Application.DTOs.Employees; +using DistroFlow.Application.Emails; +using DistroFlow.Application.Interfaces.Email; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Application.Interfaces.Persistence; +using DistroFlow.Application.Interfaces.Repositories; +using DistroFlow.Application.Interfaces.Services; +using DistroFlow.Domain.Constants; +using DistroFlow.Domain.Entities; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DistroFlow.Application.Services; + +public class EmployeeService : IEmployeeService +{ + private readonly IEmployeeRepository _employeeRepository; + private readonly ITenantRepository _tenantRepository; + private readonly IIdentityService _identityService; + private readonly IUnitOfWork _unitOfWork; + private readonly ICurrentTenant _currentTenant; + private readonly IEmailSender _emailSender; + private readonly FrontendOptions _frontend; + private readonly ILogger _logger; + + public EmployeeService( + IEmployeeRepository employeeRepository, + ITenantRepository tenantRepository, + IIdentityService identityService, + IUnitOfWork unitOfWork, + ICurrentTenant currentTenant, + IEmailSender emailSender, + IOptions frontendOptions, + ILogger logger) + { + _employeeRepository = employeeRepository; + _tenantRepository = tenantRepository; + _identityService = identityService; + _unitOfWork = unitOfWork; + _currentTenant = currentTenant; + _emailSender = emailSender; + _frontend = frontendOptions.Value; + _logger = logger; + } + + public async Task InviteAsync( + InviteEmployeeRequest request, + CancellationToken cancellationToken = default) + { + var tenantId = _currentTenant.TenantId + ?? throw new InvalidOperationException("Inviting an employee requires an authenticated user."); + + var tenant = await _tenantRepository.GetByIdReadOnlyAsync(tenantId, cancellationToken) + ?? throw new InvalidOperationException($"Current tenant {tenantId} not found."); + + // 1. Login + employee + roles together: either all are saved or none. + Employee employee; + Guid userId; + await using (var transaction = await _unitOfWork.BeginTransactionAsync(cancellationToken)) + { + var login = await _identityService.CreateInvitedUserAsync( + tenantId, request.Email, request.Roles, cancellationToken); + + if (login.Error == CreateUserError.EmailInUse) + return InviteEmployeeResult.Failure(InviteEmployeeError.EmailInUse); + + if (!login.Succeeded) + throw new InvalidOperationException($"Unexpected login creation error: {login.Error}"); + + userId = login.UserId!.Value; + employee = new Employee + { + TenantId = tenantId, + UserId = userId, + FullName = request.FullName.Trim(), + Phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim(), + EmployeeCode = string.IsNullOrWhiteSpace(request.EmployeeCode) ? null : request.EmployeeCode.Trim(), + CreatedAtUtc = DateTime.UtcNow + }; + _employeeRepository.Add(employee); + + try + { + await _unitOfWork.SaveChangesAsync(cancellationToken); + } + catch (UniqueConstraintViolationException ex) + when (ex.ConstraintName == ConstraintNames.EmployeeCodePerTenant) + { + // Leaving without commit rolls back the login created above. + return InviteEmployeeResult.Failure(InviteEmployeeError.EmployeeCodeInUse); + } + + await transaction.CommitAsync(cancellationToken); + } + + // 2. Email after commit: the employee exists even if sending fails, + // and the Administrator can resend the invite. + var emailSent = await TrySendInvitationAsync(employee, userId, request.Email, tenant.Name, cancellationToken); + + var logins = await _identityService.GetLoginInfoAsync([userId], cancellationToken); + return InviteEmployeeResult.Success(new InviteEmployeeResponse + { + Employee = MapToResponse(employee, logins), + InviteEmailSent = emailSent + }); + } + + public async Task UpdateAsync( + Guid employeeId, + UpdateEmployeeRequest request, + CancellationToken cancellationToken = default) + { + // Tenant filter applies: another distributor's employee is "not found". + var employee = await _employeeRepository.GetByIdAsync(employeeId, cancellationToken); + if (employee is null) + return UpdateEmployeeResult.Failure(UpdateEmployeeError.NotFound); + + // Checked here to return a clear 409; the domain method enforces it regardless. + if (employee.Status == EmployeeStatus.Anonymised) + return UpdateEmployeeResult.Failure(UpdateEmployeeError.NotEditable); + + employee.UpdateProfile( + request.FullName.Trim(), + string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim(), + string.IsNullOrWhiteSpace(request.EmployeeCode) ? null : request.EmployeeCode.Trim(), + DateTime.UtcNow); + + try + { + await _unitOfWork.SaveChangesAsync(cancellationToken); + } + catch (UniqueConstraintViolationException ex) + when (ex.ConstraintName == ConstraintNames.EmployeeCodePerTenant) + { + return UpdateEmployeeResult.Failure(UpdateEmployeeError.EmployeeCodeInUse); + } + + var logins = await LoadLoginsAsync([employee], cancellationToken); + return UpdateEmployeeResult.Success(MapToResponse(employee, logins)); + } + + public async Task ChangeRolesAsync( + Guid employeeId, + ChangeEmployeeRolesRequest request, + CancellationToken cancellationToken = default) + { + var tenantId = _currentTenant.TenantId + ?? throw new InvalidOperationException("Changing roles requires an authenticated user."); + + await using var transaction = await _unitOfWork.BeginTransactionAsync(cancellationToken); + + // 1. Lock this distributor's row. A concurrent role change in the same distributor + // waits here until we commit, so the admin count below cannot be stale. + await _tenantRepository.LockForUpdateAsync(tenantId, cancellationToken); + + // 2. Load the employee (tenant filter: another distributor's employee is "not found"). + var employee = await _employeeRepository.GetByIdReadOnlyAsync(employeeId, cancellationToken); + if (employee is null) + return ChangeEmployeeRolesResult.Failure(ChangeEmployeeRolesError.NotFound); + + if (employee.UserId is not { } userId) + return ChangeEmployeeRolesResult.Failure(ChangeEmployeeRolesError.NotEditable); + + // 3. Check the rule: never remove the last active Administrator. + var currentRoles = (await _identityService.GetLoginInfoAsync([userId], cancellationToken))[userId].Roles; + var removesAdministrator = + currentRoles.Contains(EmployeeRoles.Administrator) && + !request.Roles.Contains(EmployeeRoles.Administrator); + + if (removesAdministrator && employee.IsActive && + await _identityService.CountActiveAdministratorsAsync(tenantId, cancellationToken) <= 1) + { + return ChangeEmployeeRolesResult.Failure(ChangeEmployeeRolesError.LastAdministrator); + } + + // 4. Apply and commit (which also releases the lock). + await _identityService.ReplaceRolesAsync(userId, request.Roles, cancellationToken); + await transaction.CommitAsync(cancellationToken); + + var logins = await _identityService.GetLoginInfoAsync([userId], cancellationToken); + return ChangeEmployeeRolesResult.Success(MapToResponse(employee, logins)); + } + + public async Task DeactivateAsync( + Guid employeeId, + CancellationToken cancellationToken = default) + { + var tenantId = _currentTenant.TenantId + ?? throw new InvalidOperationException("Deactivating an employee requires an authenticated user."); + + await using var transaction = await _unitOfWork.BeginTransactionAsync(cancellationToken); + + // Same protection as role changes: concurrent admin-affecting changes in one + // distributor run one at a time, so the admin count below is never stale. + await _tenantRepository.LockForUpdateAsync(tenantId, cancellationToken); + + var employee = await _employeeRepository.GetByIdAsync(employeeId, cancellationToken); + if (employee is null) + return EmployeeStatusChangeResult.Failure(EmployeeStatusChangeError.NotFound); + + if (employee.Status == EmployeeStatus.Anonymised || employee.UserId is not { } userId) + return EmployeeStatusChangeResult.Failure(EmployeeStatusChangeError.NotEditable); + + var logins = await _identityService.GetLoginInfoAsync([userId], cancellationToken); + + // Already deactivated: nothing to do (repeating the request is safe). + if (employee.Status == EmployeeStatus.Deactivated) + return EmployeeStatusChangeResult.Success(MapToResponse(employee, logins)); + + if (logins[userId].Roles.Contains(EmployeeRoles.Administrator) && + await _identityService.CountActiveAdministratorsAsync(tenantId, cancellationToken) <= 1) + { + return EmployeeStatusChangeResult.Failure(EmployeeStatusChangeError.LastAdministrator); + } + + // Employee status and login are switched off together, in this one transaction. + employee.Deactivate(DateTime.UtcNow); + await _identityService.SetLoginActiveAsync(userId, isActive: false, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + return EmployeeStatusChangeResult.Success(MapToResponse(employee, logins)); + } + + public async Task ReactivateAsync( + Guid employeeId, + CancellationToken cancellationToken = default) + { + // No tenant lock needed: reactivating can only add administrators, never remove the + // last one. The one race that matters (email taken meanwhile) is caught by the + // database's unique index on active emails. + await using var transaction = await _unitOfWork.BeginTransactionAsync(cancellationToken); + + var employee = await _employeeRepository.GetByIdAsync(employeeId, cancellationToken); + if (employee is null) + return EmployeeStatusChangeResult.Failure(EmployeeStatusChangeError.NotFound); + + if (employee.Status == EmployeeStatus.Anonymised || employee.UserId is not { } userId) + return EmployeeStatusChangeResult.Failure(EmployeeStatusChangeError.NotEditable); + + if (employee.Status == EmployeeStatus.Active) + { + // Already active: nothing to do (repeating the request is safe). + var current = await _identityService.GetLoginInfoAsync([userId], cancellationToken); + return EmployeeStatusChangeResult.Success(MapToResponse(employee, current)); + } + + employee.Reactivate(DateTime.UtcNow); + + if (await _identityService.SetLoginActiveAsync(userId, isActive: true, cancellationToken) + == SetLoginActiveResult.EmailInUse) + { + // Leaving without commit rolls back the employee change too. + return EmployeeStatusChangeResult.Failure(EmployeeStatusChangeError.EmailInUse); + } + + await _unitOfWork.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + var logins = await _identityService.GetLoginInfoAsync([userId], cancellationToken); + return EmployeeStatusChangeResult.Success(MapToResponse(employee, logins)); + } + + public async Task ResendInviteAsync( + Guid employeeId, + CancellationToken cancellationToken = default) + { + // Tenant filter applies: another distributor's employee is "not found". + var employee = await _employeeRepository.GetByIdReadOnlyAsync(employeeId, cancellationToken); + if (employee is null) + return ResendInviteResult.Failure(ResendInviteError.NotFound); + + if (!employee.IsActive || employee.UserId is not { } userId) + return ResendInviteResult.Failure(ResendInviteError.NotActive); + + if (await _identityService.HasPasswordAsync(userId, cancellationToken)) + return ResendInviteResult.Failure(ResendInviteError.AlreadyAccepted); + + var tenant = await _tenantRepository.GetByIdReadOnlyAsync(employee.TenantId, cancellationToken) + ?? throw new InvalidOperationException($"Tenant {employee.TenantId} not found."); + + var logins = await _identityService.GetLoginInfoAsync([userId], cancellationToken); + var email = logins[userId].Email + ?? throw new InvalidOperationException($"Login {userId} has no email."); + + var sent = await TrySendInvitationAsync(employee, userId, email, tenant.Name, cancellationToken); + return ResendInviteResult.Success(sent); + } + + private async Task TrySendInvitationAsync( + Employee employee, + Guid userId, + string email, + string distributorName, + CancellationToken cancellationToken) + { + try + { + var token = await _identityService.GeneratePasswordSetupTokenAsync(userId, cancellationToken); + var link = $"{_frontend.BaseUrl.TrimEnd('/')}/set-password" + + $"?userId={userId}&token={Uri.EscapeDataString(token)}"; + + await _emailSender.SendAsync( + InvitationEmail.Create(email, employee.FullName, distributorName, link), + cancellationToken); + + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Not swallowed: logged as an error and reported to the caller (InviteEmailSent = false). + // The employee id is logged, not the email (personal data). + _logger.LogError(ex, "Invitation email for employee {EmployeeId} could not be sent", employee.Id); + return false; + } + } + + public async Task> ListAsync( + CancellationToken cancellationToken = default) + { + // Tenant filter applies: only the caller's distributor's employees come back. + var employees = await _employeeRepository.ListAsync(cancellationToken); + + var logins = await LoadLoginsAsync(employees, cancellationToken); + + return employees.Select(e => MapToResponse(e, logins)).ToList(); + } + + public async Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken = default) + { + // Another distributor's id returns null too, so the API answers 404, never 403: + // it must not even reveal that the id exists. + var employee = await _employeeRepository.GetByIdReadOnlyAsync(id, cancellationToken); + if (employee is null) + return null; + + var logins = await LoadLoginsAsync([employee], cancellationToken); + + return MapToResponse(employee, logins); + } + + private Task> LoadLoginsAsync( + IReadOnlyCollection employees, + CancellationToken cancellationToken) + { + var userIds = employees + .Where(e => e.UserId is not null) + .Select(e => e.UserId!.Value) + .ToList(); + + return _identityService.GetLoginInfoAsync(userIds, cancellationToken); + } + + private static EmployeeResponse MapToResponse( + Employee employee, + IReadOnlyDictionary logins) + { + // Anonymised employees have no login, so no email or roles. + LoginInfo? login = null; + if (employee.UserId is { } userId) + logins.TryGetValue(userId, out login); + + return new EmployeeResponse + { + Id = employee.Id, + FullName = employee.FullName, + Email = login?.Email, + Phone = employee.Phone, + EmployeeCode = employee.EmployeeCode, + Status = employee.Status.ToString(), + Roles = login?.Roles ?? [], + CreatedAtUtc = employee.CreatedAtUtc, + UpdatedAtUtc = employee.UpdatedAtUtc + }; + } +} diff --git a/src/DistroFlow.Application/Validators/ValidEmployeeRolesAttribute.cs b/src/DistroFlow.Application/Validators/ValidEmployeeRolesAttribute.cs new file mode 100644 index 0000000..2429cdd --- /dev/null +++ b/src/DistroFlow.Application/Validators/ValidEmployeeRolesAttribute.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using DistroFlow.Domain.Constants; + +namespace DistroFlow.Application.Validators; + +/// At least one role, no duplicates, and each one of . +public class ValidEmployeeRolesAttribute : ValidationAttribute +{ + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if (value is not IEnumerable roles || !roles.Any()) + { + return new ValidationResult("At least one role is required."); + } + + var unknown = roles.Where(r => !EmployeeRoles.All.Contains(r)).ToList(); + if (unknown.Count > 0) + { + return new ValidationResult( + $"Unknown role(s): {string.Join(", ", unknown)}. Allowed: {string.Join(", ", EmployeeRoles.All)}."); + } + + if (roles.Distinct().Count() != roles.Count()) + { + return new ValidationResult("Roles must not contain duplicates."); + } + + return ValidationResult.Success; + } +} diff --git a/src/DistroFlow.Domain/Entities/Employee.cs b/src/DistroFlow.Domain/Entities/Employee.cs index 980a321..af7dca0 100644 --- a/src/DistroFlow.Domain/Entities/Employee.cs +++ b/src/DistroFlow.Domain/Entities/Employee.cs @@ -59,6 +59,23 @@ public void Deactivate(DateTime utcNow) UpdatedAtUtc = utcNow; } + /// + /// Changes the profile details. Not allowed once anonymised: that data was erased on + /// purpose and must not be re-entered. + /// + public void UpdateProfile(string fullName, string? phone, string? employeeCode, DateTime utcNow) + { + if (Status == EmployeeStatus.Anonymised) + { + throw new InvalidOperationException("An anonymised employee cannot be edited."); + } + + FullName = fullName; + Phone = phone; + EmployeeCode = employeeCode; + UpdatedAtUtc = utcNow; + } + public void Reactivate(DateTime utcNow) { if (Status == EmployeeStatus.Anonymised) diff --git a/src/DistroFlow.Infrastructure/DependencyInjection.cs b/src/DistroFlow.Infrastructure/DependencyInjection.cs index c2dad46..2a88b49 100644 --- a/src/DistroFlow.Infrastructure/DependencyInjection.cs +++ b/src/DistroFlow.Infrastructure/DependencyInjection.cs @@ -1,4 +1,6 @@ +using DistroFlow.Application.Interfaces.Email; using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Infrastructure.Email; using DistroFlow.Application.Interfaces.Persistence; using DistroFlow.Application.Interfaces.Repositories; using DistroFlow.Infrastructure.Identity; @@ -23,10 +25,21 @@ public static IServiceCollection AddInfrastructure( services.AddPersistence(configuration); services.AddIdentityServices(); services.AddTokenIssuing(configuration); + services.AddEmail(configuration); return services; } + private static void AddEmail(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(SmtpOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddScoped(); + } + private static void AddPersistence(this IServiceCollection services, IConfiguration configuration) { var connectionString = configuration.GetConnectionString("DefaultConnection") @@ -59,7 +72,13 @@ private static void AddIdentityServices(this IServiceCollection services) options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); }) .AddRoles>() - .AddEntityFrameworkStores(); + .AddEntityFrameworkStores() + // Signed, single-use tokens for invite / set-password links. + .AddDefaultTokenProviders(); + + // Invite links stay valid for 3 days. + services.Configure(options => + options.TokenLifespan = TimeSpan.FromDays(3)); services.AddScoped(); } diff --git a/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj b/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj index 9587d82..5d7ea6a 100644 --- a/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj +++ b/src/DistroFlow.Infrastructure/DistroFlow.Infrastructure.csproj @@ -6,13 +6,19 @@ + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - diff --git a/src/DistroFlow.Infrastructure/Email/SmtpEmailSender.cs b/src/DistroFlow.Infrastructure/Email/SmtpEmailSender.cs new file mode 100644 index 0000000..2ad6651 --- /dev/null +++ b/src/DistroFlow.Infrastructure/Email/SmtpEmailSender.cs @@ -0,0 +1,56 @@ +using DistroFlow.Application.Interfaces.Email; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; + +namespace DistroFlow.Infrastructure.Email; + +/// Sends email over SMTP with MailKit (works with Mailpit locally and any SMTP provider). +public class SmtpEmailSender : IEmailSender +{ + private readonly SmtpOptions _options; + + public SmtpEmailSender(IOptions options) + { + _options = options.Value; + } + + public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default) + { + var mime = new MimeMessage(); + mime.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress)); + mime.To.Add(MailboxAddress.Parse(message.To)); + mime.Subject = message.Subject; + mime.Body = new BodyBuilder + { + HtmlBody = message.HtmlBody, + TextBody = message.TextBody + }.ToMessageBody(); + + // A new connection per email keeps this simple; fine for low volume (invites). + using var client = new SmtpClient(); + + await client.ConnectAsync(_options.Host, _options.Port, SocketOptions(), cancellationToken); + + if (!string.IsNullOrEmpty(_options.Username)) + { + await client.AuthenticateAsync(_options.Username, _options.Password ?? string.Empty, cancellationToken); + } + + await client.SendAsync(mime, cancellationToken); + await client.DisconnectAsync(quit: true, cancellationToken); + } + + /// + /// TLS is required when enabled (never silently downgraded to plain text, which would + /// expose the SMTP password). Port 465 uses TLS from the start; others upgrade via STARTTLS. + /// + private SecureSocketOptions SocketOptions() + { + if (!_options.UseTls) + return SecureSocketOptions.None; + + return _options.Port == 465 ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTls; + } +} diff --git a/src/DistroFlow.Infrastructure/Email/SmtpOptions.cs b/src/DistroFlow.Infrastructure/Email/SmtpOptions.cs new file mode 100644 index 0000000..3209662 --- /dev/null +++ b/src/DistroFlow.Infrastructure/Email/SmtpOptions.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace DistroFlow.Infrastructure.Email; + +/// +/// Bound from the "Smtp" configuration section. +/// Development: Mailpit on localhost:1025 (no TLS, no login), view mail at http://localhost:8025. +/// Production: any SMTP provider; set Username/Password via user-secrets or env vars (Smtp__Password). +/// +public sealed class SmtpOptions +{ + public const string SectionName = "Smtp"; + + [Required] + public string Host { get; init; } = string.Empty; + + [Range(1, 65535)] + public int Port { get; init; } = 587; + + /// Use STARTTLS/TLS. Off only for a local catcher such as Mailpit. + public bool UseTls { get; init; } = true; + + public string? Username { get; init; } + + public string? Password { get; init; } + + [Required] + [EmailAddress] + public string FromAddress { get; init; } = string.Empty; + + [Required] + public string FromName { get; init; } = string.Empty; +} diff --git a/src/DistroFlow.Infrastructure/Identity/IdentityService.cs b/src/DistroFlow.Infrastructure/Identity/IdentityService.cs index 47397ec..4c044df 100644 --- a/src/DistroFlow.Infrastructure/Identity/IdentityService.cs +++ b/src/DistroFlow.Infrastructure/Identity/IdentityService.cs @@ -1,4 +1,5 @@ using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Domain.Constants; using DistroFlow.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -25,12 +26,180 @@ public IdentityService( _logger = logger; } - public async Task CreateUserAsync( + public Task CreateUserAsync( Guid tenantId, string email, string password, string role, + CancellationToken cancellationToken = default) => + CreateAsync(tenantId, email, password, [role]); + + public Task CreateInvitedUserAsync( + Guid tenantId, + string email, + IReadOnlyCollection roles, + CancellationToken cancellationToken = default) => + CreateAsync(tenantId, email, password: null, roles); + + public async Task GeneratePasswordSetupTokenAsync( + Guid userId, + CancellationToken cancellationToken = default) + { + var user = await _userManager.FindByIdAsync(userId.ToString()) + ?? throw new InvalidOperationException($"User {userId} not found."); + + // Identity's password-reset token: signed with Data Protection, tied to the user's + // security stamp (so it stops working once the password is set) and time-limited. + return await _userManager.GeneratePasswordResetTokenAsync(user); + } + + public async Task SetInitialPasswordAsync( + Guid userId, + string token, + string newPassword, + CancellationToken cancellationToken = default) + { + var user = await _userManager.FindByIdAsync(userId.ToString()); + + // Same answer for every "this link can't be used" case. + if (user is null || !user.IsActive || await _userManager.HasPasswordAsync(user)) + { + return SetPasswordResult.Failure(SetPasswordError.InvalidLink); + } + + // Verifies the token (signature, expiry, security stamp) and then the password policy. + // On success the security stamp changes, so this token, and any other outstanding + // invite link for this user, stops working. + var result = await _userManager.ResetPasswordAsync(user, token, newPassword); + + if (!result.Succeeded) + { + var invalidToken = result.Errors.Any(e => e.Code == _userManager.ErrorDescriber.InvalidToken().Code); + + return invalidToken + ? SetPasswordResult.Failure(SetPasswordError.InvalidLink) + : SetPasswordResult.Failure( + SetPasswordError.PasswordRejected, + result.Errors.Select(e => e.Description).ToList()); + } + + // Using the emailed link proves the employee controls this inbox. + user.EmailConfirmed = true; + var updated = await _userManager.UpdateAsync(user); + if (!updated.Succeeded) + { + throw new InvalidOperationException( + $"Could not confirm email for user {user.Id}: {string.Join("; ", updated.Errors.Select(e => e.Description))}"); + } + + return SetPasswordResult.Success(); + } + + public async Task ReplaceRolesAsync( + Guid userId, + IReadOnlyCollection roles, + CancellationToken cancellationToken = default) + { + var user = await _userManager.FindByIdAsync(userId.ToString()) + ?? throw new InvalidOperationException($"User {userId} not found."); + + var current = await _userManager.GetRolesAsync(user); + var toRemove = current.Except(roles).ToList(); + var toAdd = roles.Except(current).ToList(); + + if (toRemove.Count > 0) + { + EnsureSucceeded(await _userManager.RemoveFromRolesAsync(user, toRemove), "remove roles from", userId); + } + + if (toAdd.Count > 0) + { + EnsureSucceeded(await _userManager.AddToRolesAsync(user, toAdd), "add roles to", userId); + } + } + + public async Task SetLoginActiveAsync( + Guid userId, + bool isActive, CancellationToken cancellationToken = default) + { + var user = await _userManager.FindByIdAsync(userId.ToString()) + ?? throw new InvalidOperationException($"User {userId} not found."); + + if (user.IsActive == isActive) + { + return SetLoginActiveResult.Succeeded; + } + + user.IsActive = isActive; + + if (!isActive) + { + // A new security stamp invalidates outstanding invite / set-password links. + await _userManager.UpdateSecurityStampAsync(user); + } + + try + { + EnsureSucceeded(await _userManager.UpdateAsync(user), "update", userId); + } + catch (DbUpdateException ex) when (IsActiveEmailViolation(ex)) + { + // Activating would give the email two active accounts; the database refuses. + _logger.LogWarning( + "Login {UserId} not reactivated: email already active on another account", userId); + + _dbContext.Entry(user).State = EntityState.Detached; + return SetLoginActiveResult.EmailInUse; + } + + return SetLoginActiveResult.Succeeded; + } + + public async Task CountActiveAdministratorsAsync( + Guid tenantId, + CancellationToken cancellationToken = default) + { + var administrator = _userManager.NormalizeName(EmployeeRoles.Administrator); + + // Login accounts have no tenant query filter (see CLAUDE.md), so filter by tenant explicitly. + return await ( + from userRole in _dbContext.UserRoles + join role in _dbContext.Roles on userRole.RoleId equals role.Id + join user in _dbContext.Users on userRole.UserId equals user.Id + where user.TenantId == tenantId + && user.IsActive + && role.NormalizedName == administrator + select user.Id) + .CountAsync(cancellationToken); + } + + /// Identity reports failures as results; for these calls a failure is a bug. + private static void EnsureSucceeded(IdentityResult result, string action, Guid userId) + { + if (!result.Succeeded) + { + throw new InvalidOperationException( + $"Could not {action} user {userId}: {string.Join("; ", result.Errors.Select(e => e.Description))}"); + } + } + + public async Task HasPasswordAsync( + Guid userId, + CancellationToken cancellationToken = default) + { + var user = await _userManager.FindByIdAsync(userId.ToString()) + ?? throw new InvalidOperationException($"User {userId} not found."); + + return await _userManager.HasPasswordAsync(user); + } + + /// Null for an invited user, who sets it later via the emailed link. + private async Task CreateAsync( + Guid tenantId, + string email, + string? password, + IReadOnlyCollection roles) { var user = new ApplicationUser { @@ -45,7 +214,9 @@ public async Task CreateUserAsync( IdentityResult created; try { - created = await _userManager.CreateAsync(user, password); + created = password is null + ? await _userManager.CreateAsync(user) + : await _userManager.CreateAsync(user, password); } catch (DbUpdateException ex) when (IsActiveEmailViolation(ex)) { @@ -72,12 +243,13 @@ public async Task CreateUserAsync( created.Errors.Select(e => e.Description).ToList()); } - var assigned = await _userManager.AddToRoleAsync(user, role); + var assigned = await _userManager.AddToRolesAsync(user, roles); if (!assigned.Succeeded) { - // Roles are seeded by migration; a missing role is a bug, not user error. + // Roles are validated at the API boundary and seeded by migration; + // failing here is a bug, not user error. throw new InvalidOperationException( - $"Could not assign role '{role}': {string.Join("; ", assigned.Errors.Select(e => e.Description))}"); + $"Could not assign roles '{string.Join(", ", roles)}': {string.Join("; ", assigned.Errors.Select(e => e.Description))}"); } return CreateUserResult.Success(user.Id); @@ -124,6 +296,38 @@ public async Task CheckCredentialsAsync( return CredentialCheckResult.Success(user.Id, user.TenantId, roles.ToList()); } + public async Task> GetLoginInfoAsync( + IReadOnlyCollection userIds, + CancellationToken cancellationToken = default) + { + if (userIds.Count == 0) + { + return new Dictionary(); + } + + // Two queries in total, however many employees: emails, then all their role names. + var emails = await _dbContext.Users + .AsNoTracking() + .Where(u => userIds.Contains(u.Id)) + .Select(u => new { u.Id, u.Email }) + .ToListAsync(cancellationToken); + + var roles = await ( + from userRole in _dbContext.UserRoles + join role in _dbContext.Roles on userRole.RoleId equals role.Id + where userIds.Contains(userRole.UserId) + select new { userRole.UserId, role.Name }) + .ToListAsync(cancellationToken); + + var rolesByUser = roles + .GroupBy(r => r.UserId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.Select(r => r.Name!).Order().ToList()); + + return emails.ToDictionary( + u => u.Id, + u => new LoginInfo(u.Email, rolesByUser.GetValueOrDefault(u.Id, []))); + } + private static bool IsActiveEmailViolation(DbUpdateException ex) => ex.InnerException is PostgresException { diff --git a/src/DistroFlow.Infrastructure/Persistence/UnitOfWork.cs b/src/DistroFlow.Infrastructure/Persistence/UnitOfWork.cs index ac3f25c..71608d2 100644 --- a/src/DistroFlow.Infrastructure/Persistence/UnitOfWork.cs +++ b/src/DistroFlow.Infrastructure/Persistence/UnitOfWork.cs @@ -1,5 +1,8 @@ +using DistroFlow.Application.Common; using DistroFlow.Application.Interfaces.Persistence; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; +using Npgsql; namespace DistroFlow.Infrastructure.Persistence; @@ -14,7 +17,19 @@ public UnitOfWork(AppDbContext dbContext) public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - await _dbContext.SaveChangesAsync(cancellationToken); + try + { + await _dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException + { + SqlState: PostgresErrorCodes.UniqueViolation, + ConstraintName: { } constraintName + }) + { + // Translate to a provider-neutral exception; the original stays as InnerException. + throw new UniqueConstraintViolationException(constraintName, ex); + } } public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) diff --git a/src/DistroFlow.Infrastructure/Repositories/EmployeeRepository.cs b/src/DistroFlow.Infrastructure/Repositories/EmployeeRepository.cs index b86c499..9db9cc0 100644 --- a/src/DistroFlow.Infrastructure/Repositories/EmployeeRepository.cs +++ b/src/DistroFlow.Infrastructure/Repositories/EmployeeRepository.cs @@ -19,6 +19,32 @@ public void Add(Employee employee) _dbContext.Employees.Add(employee); } + public async Task> ListAsync( + CancellationToken cancellationToken = default) + { + return await _dbContext.Employees + .AsNoTracking() + .OrderBy(e => e.FullName) + .ToListAsync(cancellationToken); + } + + public async Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken = default) + { + return await _dbContext.Employees + .SingleOrDefaultAsync(e => e.Id == id, cancellationToken); + } + + public async Task GetByIdReadOnlyAsync( + Guid id, + CancellationToken cancellationToken = default) + { + return await _dbContext.Employees + .AsNoTracking() + .SingleOrDefaultAsync(e => e.Id == id, cancellationToken); + } + public async Task GetForLoginByUserIdAsync( Guid userId, CancellationToken cancellationToken = default) diff --git a/src/DistroFlow.Infrastructure/Repositories/TenantRepository.cs b/src/DistroFlow.Infrastructure/Repositories/TenantRepository.cs index db89211..ee3778d 100644 --- a/src/DistroFlow.Infrastructure/Repositories/TenantRepository.cs +++ b/src/DistroFlow.Infrastructure/Repositories/TenantRepository.cs @@ -41,6 +41,22 @@ public void Add(Tenant tenant) cancellationToken); } + public async Task LockForUpdateAsync( + Guid tenantId, + CancellationToken cancellationToken = default) + { + // Without a transaction the lock would be released as soon as the statement ends. + if (_dbContext.Database.CurrentTransaction is null) + { + throw new InvalidOperationException("LockForUpdateAsync must be called inside a transaction."); + } + + // Interpolated values become SQL parameters (no SQL injection). + await _dbContext.Database.ExecuteSqlAsync( + $"""SELECT 1 FROM "Tenants" WHERE "Id" = {tenantId} FOR UPDATE""", + cancellationToken); + } + public async Task SaveChangesAsync( CancellationToken cancellationToken = default) { diff --git a/tests/DistroFlow.IntegrationTests/Controllers/AuthSetPasswordTests.cs b/tests/DistroFlow.IntegrationTests/Controllers/AuthSetPasswordTests.cs new file mode 100644 index 0000000..ed1d866 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Controllers/AuthSetPasswordTests.cs @@ -0,0 +1,172 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Application.Common; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Domain.Constants; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace DistroFlow.IntegrationTests.Controllers; + +/// The full invite journey: invited → email link → set password → log in. +public class AuthSetPasswordTests : IClassFixture +{ + private const string SetPasswordUrl = "/api/v1/auth/set-password"; + private const string NewPassword = "Inv1ted!Passw0rd"; + private const string InvalidLinkMessage = "This link is invalid or has expired. Ask your administrator to resend the invite."; + + private readonly ApiIntegrationTestFactory _factory; + private readonly HttpClient _anonymous; + + public AuthSetPasswordTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + _anonymous = factory.CreateClient(); + } + + [Fact] + public async Task Invited_employee_sets_password_from_email_link_and_can_log_in() + { + var admin = await TestAuth.RegisterAndLoginAsync(_anonymous); + var invitee = await InviteAsync(admin, EmployeeRoles.DispatchStaff); + var (userId, token) = _factory.Emails.SetPasswordLinkTo(invitee.Email); + + var response = await SetPasswordAsync(userId, token, NewPassword); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var login = await _anonymous.PostAsJsonAsync("/api/v1/auth/login", new { email = invitee.Email, password = NewPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + var accessToken = new JsonWebToken((await login.Content.ReadFromJsonAsync()) + .GetProperty("data").GetProperty("accessToken").GetString()); + Assert.Equal(admin.TenantId.ToString(), accessToken.GetClaim(AppClaimTypes.TenantId).Value); + Assert.Equal(invitee.EmployeeId.ToString(), accessToken.GetClaim(AppClaimTypes.EmployeeId).Value); + Assert.Equal(EmployeeRoles.DispatchStaff, accessToken.GetClaim(AppClaimTypes.Role).Value); + } + + [Fact] + public async Task Setting_the_password_confirms_the_email() + { + var admin = await TestAuth.RegisterAndLoginAsync(_anonymous); + var invitee = await InviteAsync(admin, EmployeeRoles.SalesStaff); + var (userId, token) = _factory.Emails.SetPasswordLinkTo(invitee.Email); + + await SetPasswordAsync(userId, token, NewPassword); + + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.True((await db.Users.SingleAsync(u => u.Id == userId)).EmailConfirmed); + } + + [Fact] + public async Task Link_works_only_once() + { + var admin = await TestAuth.RegisterAndLoginAsync(_anonymous); + var invitee = await InviteAsync(admin, EmployeeRoles.SalesStaff); + var (userId, token) = _factory.Emails.SetPasswordLinkTo(invitee.Email); + + var first = await SetPasswordAsync(userId, token, NewPassword); + var second = await SetPasswordAsync(userId, token, "Sec0nd!Passw0rd"); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + Assert.Equal(InvalidLinkMessage, await MessageOf(second)); + } + + [Fact] + public async Task Tampered_token_and_unknown_user_give_the_same_invalid_link_response() + { + var admin = await TestAuth.RegisterAndLoginAsync(_anonymous); + var invitee = await InviteAsync(admin, EmployeeRoles.SalesStaff); + var (userId, token) = _factory.Emails.SetPasswordLinkTo(invitee.Email); + + var tampered = await SetPasswordAsync(userId, token[..^4] + "AAAA", NewPassword); + var unknownUser = await SetPasswordAsync(Guid.CreateVersion7(), token, NewPassword); + + Assert.Equal(HttpStatusCode.BadRequest, tampered.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, unknownUser.StatusCode); + Assert.Equal(await tampered.Content.ReadAsStringAsync(), await unknownUser.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Weak_password_is_rejected_and_the_link_stays_usable() + { + var admin = await TestAuth.RegisterAndLoginAsync(_anonymous); + var invitee = await InviteAsync(admin, EmployeeRoles.SalesStaff); + var (userId, token) = _factory.Emails.SetPasswordLinkTo(invitee.Email); + + var weak = await SetPasswordAsync(userId, token, "weakpassword"); + var retry = await SetPasswordAsync(userId, token, NewPassword); + + Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode); + var errors = (await weak.Content.ReadFromJsonAsync()).GetProperty("errors"); + Assert.True(errors.GetProperty("NewPassword").GetArrayLength() > 0); + Assert.Equal(HttpStatusCode.OK, retry.StatusCode); + } + + [Fact] + public async Task Cannot_be_used_to_reset_the_password_of_an_account_that_already_has_one() + { + // The owner registered with a password. Even with a valid token, this endpoint must refuse. + var owner = await TestAuth.RegisterAndLoginAsync(_anonymous); + string token; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + token = await scope.ServiceProvider.GetRequiredService() + .GeneratePasswordSetupTokenAsync(owner.UserId); + } + + var response = await SetPasswordAsync(owner.UserId, token, "Hijack3d!Passw0rd"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var login = await _anonymous.PostAsJsonAsync("/api/v1/auth/login", new { email = owner.Email, password = TestAuth.Password }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); // original password still works + } + + [Fact] + public async Task Deactivated_employee_cannot_use_their_invite_link() + { + var admin = await TestAuth.RegisterAndLoginAsync(_anonymous); + var invitee = await InviteAsync(admin, EmployeeRoles.SalesStaff); + var (userId, token) = _factory.Emails.SetPasswordLinkTo(invitee.Email); + + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var employee = await db.Employees.IgnoreQueryFilters().SingleAsync(e => e.Id == invitee.EmployeeId); + var user = await db.Users.SingleAsync(u => u.Id == userId); + employee.Deactivate(DateTime.UtcNow); + user.IsActive = false; + await db.SaveChangesAsync(); + } + + var response = await SetPasswordAsync(userId, token, NewPassword); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(InvalidLinkMessage, await MessageOf(response)); + } + + private sealed record Invitee(Guid EmployeeId, string Email); + + private async Task InviteAsync(RegisteredDistributor admin, string role) + { + var email = $"invitee-{Guid.NewGuid():N}@example.com"; + var response = await TestAuth.CreateClientWithToken(_factory, admin.AccessToken) + .PostAsJsonAsync("/api/v1/employees", new { fullName = "Anita Rao", email, roles = new[] { role } }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var employeeId = (await response.Content.ReadFromJsonAsync()) + .GetProperty("data").GetProperty("employee").GetProperty("id").GetGuid(); + return new Invitee(employeeId, email); + } + + private Task SetPasswordAsync(Guid userId, string token, string newPassword) => + _anonymous.PostAsJsonAsync(SetPasswordUrl, new { userId, token, newPassword }); + + private static async Task MessageOf(HttpResponseMessage response) => + (await response.Content.ReadFromJsonAsync()).GetProperty("message").GetString(); +} diff --git a/tests/DistroFlow.IntegrationTests/Controllers/AuthorizationDefaultsTests.cs b/tests/DistroFlow.IntegrationTests/Controllers/AuthorizationDefaultsTests.cs index f48cd01..14b15ca 100644 --- a/tests/DistroFlow.IntegrationTests/Controllers/AuthorizationDefaultsTests.cs +++ b/tests/DistroFlow.IntegrationTests/Controllers/AuthorizationDefaultsTests.cs @@ -43,7 +43,8 @@ public void Only_the_expected_endpoints_are_public() "GET health/database", "GET openapi/{documentName}.json", "POST api/v1/auth/login", - "POST api/v1/auth/register" + "POST api/v1/auth/register", + "POST api/v1/auth/set-password" // invited employee is not logged in yet; protected by the signed token ]; var endpoints = _factory.Services.GetRequiredService().Endpoints; diff --git a/tests/DistroFlow.IntegrationTests/Controllers/EmployeeRolesTests.cs b/tests/DistroFlow.IntegrationTests/Controllers/EmployeeRolesTests.cs new file mode 100644 index 0000000..591718f --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Controllers/EmployeeRolesTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Domain.Constants; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.IntegrationTests.Controllers; + +/// PUT /api/v1/employees/{id}/roles, including the "last administrator" rule under concurrency. +public class EmployeeRolesTests : IClassFixture +{ + private readonly ApiIntegrationTestFactory _factory; + private readonly HttpClient _anonymous; + + public EmployeeRolesTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + _anonymous = factory.CreateClient(); + } + + [Fact] + public async Task Change_roles_replaces_them() + { + var owner = await TestAuth.RegisterAndLoginAsync(_anonymous); + var employeeId = await InviteAsync(owner, EmployeeRoles.SalesStaff); + + var response = await ChangeRolesAsync(owner, employeeId, EmployeeRoles.DispatchStaff, EmployeeRoles.Approver); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var roles = (await response.Content.ReadFromJsonAsync()).GetProperty("data") + .GetProperty("roles").EnumerateArray().Select(r => r.GetString()!).ToArray(); + Assert.Equal([EmployeeRoles.Approver, EmployeeRoles.DispatchStaff], roles); + } + + [Fact] + public async Task Last_active_administrator_cannot_be_demoted() + { + var owner = await TestAuth.RegisterAndLoginAsync(_anonymous); + + // The owner is the only Administrator and tries to demote themselves. + var response = await ChangeRolesAsync(owner, owner.EmployeeId, EmployeeRoles.SalesStaff); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal(1, await CountActiveAdministratorsAsync(owner.TenantId)); + } + + [Fact] + public async Task An_administrator_can_be_demoted_when_another_one_exists() + { + var owner = await TestAuth.RegisterAndLoginAsync(_anonymous); + await InviteAsync(owner, EmployeeRoles.Administrator); + + var response = await ChangeRolesAsync(owner, owner.EmployeeId, EmployeeRoles.SalesStaff); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1, await CountActiveAdministratorsAsync(owner.TenantId)); + } + + [Fact] + public async Task Changing_roles_of_another_distributors_employee_returns_404() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var b = await TestAuth.RegisterAndLoginAsync(_anonymous); + await InviteAsync(b, EmployeeRoles.Administrator); // so the rule would not block it anyway + + var response = await ChangeRolesAsync(a, b.EmployeeId, EmployeeRoles.SalesStaff); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(2, await CountActiveAdministratorsAsync(b.TenantId)); + } + + [Fact] + public async Task Invalid_roles_return_400() + { + var owner = await TestAuth.RegisterAndLoginAsync(_anonymous); + + var response = await ChangeRolesAsync(owner, owner.EmployeeId, "Super Admin"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Two_administrators_demoting_each_other_at_the_same_time_leave_exactly_one() + { + // Several distributors at once, each with two admins, to give a race every chance to happen. + const int distributors = 5; + var setups = await Task.WhenAll(Enumerable.Range(0, distributors).Select(async _ => + { + var owner = await TestAuth.RegisterAndLoginAsync(_anonymous); + var secondAdmin = await InviteAsync(owner, EmployeeRoles.Administrator); + return (owner, secondAdmin); + })); + + // For every distributor, fire both demotions simultaneously. + var results = await Task.WhenAll(setups.Select(async s => + { + var responses = await Task.WhenAll( + ChangeRolesAsync(s.owner, s.owner.EmployeeId, EmployeeRoles.SalesStaff), + ChangeRolesAsync(s.owner, s.secondAdmin, EmployeeRoles.SalesStaff)); + return (s.owner.TenantId, Statuses: responses.Select(r => r.StatusCode).ToArray()); + })); + + foreach (var (tenantId, statuses) in results) + { + // Exactly one demotion wins; the other sees one admin left and is refused. + Assert.Single(statuses, HttpStatusCode.OK); + Assert.Single(statuses, HttpStatusCode.Conflict); + Assert.Equal(1, await CountActiveAdministratorsAsync(tenantId)); + } + } + + private Task ChangeRolesAsync(RegisteredDistributor admin, Guid employeeId, params string[] roles) => + TestAuth.CreateClientWithToken(_factory, admin.AccessToken) + .PutAsJsonAsync($"/api/v1/employees/{employeeId}/roles", new { roles }); + + private async Task InviteAsync(RegisteredDistributor admin, string role) + { + var response = await TestAuth.CreateClientWithToken(_factory, admin.AccessToken) + .PostAsJsonAsync("/api/v1/employees", new + { + fullName = "Anita Rao", + email = $"roles-{Guid.NewGuid():N}@example.com", + roles = new[] { role } + }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + return (await response.Content.ReadFromJsonAsync()) + .GetProperty("data").GetProperty("employee").GetProperty("id").GetGuid(); + } + + private async Task CountActiveAdministratorsAsync(Guid tenantId) + { + await using var scope = _factory.Services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().CountActiveAdministratorsAsync(tenantId); + } +} diff --git a/tests/DistroFlow.IntegrationTests/Controllers/EmployeeStatusTests.cs b/tests/DistroFlow.IntegrationTests/Controllers/EmployeeStatusTests.cs new file mode 100644 index 0000000..57580d2 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Controllers/EmployeeStatusTests.cs @@ -0,0 +1,213 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Domain.Constants; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.IntegrationTests.Controllers; + +/// Deactivating (and later reactivating) employees. +public class EmployeeStatusTests : IClassFixture +{ + private const string EmployeePassword = "Empl0yee!Passw0rd"; + + private readonly ApiIntegrationTestFactory _factory; + private readonly HttpClient _anonymous; + + public EmployeeStatusTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + _anonymous = factory.CreateClient(); + } + + [Fact] + public async Task Deactivated_employee_can_no_longer_log_in() + { + var owner = await TestAuth.RegisterAndLoginAsync(_anonymous); + var employee = await InviteAndAcceptAsync(owner, EmployeeRoles.SalesStaff); + Assert.Equal(HttpStatusCode.OK, (await LoginAsync(employee.Email)).StatusCode); + + var response = await PostAsync(owner, $"{employee.Id}/deactivate"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + Assert.Equal("Deactivated", data.GetProperty("status").GetString()); + Assert.Equal(HttpStatusCode.Unauthorized, (await LoginAsync(employee.Email)).StatusCode); + } + + [Fact] + public async Task Deactivating_twice_is_safe() + { + var owner = await TestAuth.RegisterAndLoginAsync(_anonymous); + var employee = await InviteAndAcceptAsync(owner, EmployeeRoles.SalesStaff); + + await PostAsync(owner, $"{employee.Id}/deactivate"); + var again = await PostAsync(owner, $"{employee.Id}/deactivate"); + + Assert.Equal(HttpStatusCode.OK, again.StatusCode); + } + + [Fact] + public async Task Deactivated_employees_email_can_be_used_by_another_distributor() + { + // Your rule: once someone leaves (deactivated), they can join another distributor. + var oldEmployer = await TestAuth.RegisterAndLoginAsync(_anonymous); + var employee = await InviteAndAcceptAsync(oldEmployer, EmployeeRoles.SalesStaff); + var newEmployer = await TestAuth.RegisterAndLoginAsync(_anonymous); + + var beforeLeaving = await InviteAsync(newEmployer, employee.Email, EmployeeRoles.SalesStaff); + await PostAsync(oldEmployer, $"{employee.Id}/deactivate"); + var afterLeaving = await InviteAsync(newEmployer, employee.Email, EmployeeRoles.SalesStaff); + + Assert.Equal(HttpStatusCode.Conflict, beforeLeaving.StatusCode); + Assert.Equal(HttpStatusCode.Created, afterLeaving.StatusCode); + } + + [Fact] + public async Task Last_active_administrator_cannot_be_deactivated() + { + var owner = await TestAuth.RegisterAndLoginAsync(_anonymous); + + var response = await PostAsync(owner, $"{owner.EmployeeId}/deactivate"); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, (await LoginAsync(owner.Email, TestAuth.Password)).StatusCode); + } + + [Fact] + public async Task Deactivating_another_distributors_employee_returns_404() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var b = await TestAuth.RegisterAndLoginAsync(_anonymous); + var employeeOfB = await InviteAndAcceptAsync(b, EmployeeRoles.SalesStaff); + + var response = await PostAsync(a, $"{employeeOfB.Id}/deactivate"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, (await LoginAsync(employeeOfB.Email)).StatusCode); + } + + [Fact] + public async Task Two_administrators_deactivating_each_other_at_the_same_time_leave_exactly_one() + { + const int distributors = 5; + var setups = await Task.WhenAll(Enumerable.Range(0, distributors).Select(async _ => + { + var owner = await TestAuth.RegisterAndLoginAsync(_anonymous); + var secondAdmin = await InviteAndAcceptAsync(owner, EmployeeRoles.Administrator); + return (owner, secondAdmin); + })); + + var results = await Task.WhenAll(setups.Select(async s => + { + var responses = await Task.WhenAll( + PostAsync(s.owner, $"{s.owner.EmployeeId}/deactivate"), + PostAsync(s.owner, $"{s.secondAdmin.Id}/deactivate")); + return (s.owner.TenantId, Statuses: responses.Select(r => r.StatusCode).ToArray()); + })); + + foreach (var (tenantId, statuses) in results) + { + Assert.Single(statuses, HttpStatusCode.OK); + Assert.Single(statuses, HttpStatusCode.Conflict); + Assert.Equal(1, await CountActiveAdministratorsAsync(tenantId)); + } + } + + [Fact] + public async Task Reactivated_employee_can_log_in_again_with_their_old_password() + { + var owner = await TestAuth.RegisterAndLoginAsync(_anonymous); + var employee = await InviteAndAcceptAsync(owner, EmployeeRoles.SalesStaff); + await PostAsync(owner, $"{employee.Id}/deactivate"); + + var response = await PostAsync(owner, $"{employee.Id}/reactivate"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + Assert.Equal("Active", data.GetProperty("status").GetString()); + Assert.Equal(HttpStatusCode.OK, (await LoginAsync(employee.Email)).StatusCode); + } + + [Fact] + public async Task Reactivating_an_active_employee_is_safe() + { + var owner = await TestAuth.RegisterAndLoginAsync(_anonymous); + var employee = await InviteAndAcceptAsync(owner, EmployeeRoles.SalesStaff); + + var response = await PostAsync(owner, $"{employee.Id}/reactivate"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Reactivating_fails_when_the_email_is_now_active_at_another_distributor() + { + // The person left the old job, joined another distributor, then the old one tries to bring them back. + var oldEmployer = await TestAuth.RegisterAndLoginAsync(_anonymous); + var employee = await InviteAndAcceptAsync(oldEmployer, EmployeeRoles.SalesStaff); + await PostAsync(oldEmployer, $"{employee.Id}/deactivate"); + var newEmployer = await TestAuth.RegisterAndLoginAsync(_anonymous); + Assert.Equal(HttpStatusCode.Created, (await InviteAsync(newEmployer, employee.Email, EmployeeRoles.SalesStaff)).StatusCode); + + var response = await PostAsync(oldEmployer, $"{employee.Id}/reactivate"); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + + // Nothing changed at the old employer: still deactivated (the employee change was rolled back). + var atOldEmployer = (await TestAuth.CreateClientWithToken(_factory, oldEmployer.AccessToken) + .GetFromJsonAsync($"/api/v1/employees/{employee.Id}")).GetProperty("data"); + Assert.Equal("Deactivated", atOldEmployer.GetProperty("status").GetString()); + } + + [Fact] + public async Task Reactivating_another_distributors_employee_returns_404() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var b = await TestAuth.RegisterAndLoginAsync(_anonymous); + var employeeOfB = await InviteAndAcceptAsync(b, EmployeeRoles.SalesStaff); + await PostAsync(b, $"{employeeOfB.Id}/deactivate"); + + var response = await PostAsync(a, $"{employeeOfB.Id}/reactivate"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await LoginAsync(employeeOfB.Email)).StatusCode); + } + + private sealed record AcceptedEmployee(Guid Id, string Email); + + /// Invites an employee and sets their password via the emailed link, like a real person would. + private async Task InviteAndAcceptAsync(RegisteredDistributor admin, string role) + { + var email = $"status-{Guid.NewGuid():N}@example.com"; + var invite = await InviteAsync(admin, email, role); + Assert.Equal(HttpStatusCode.Created, invite.StatusCode); + var employeeId = (await invite.Content.ReadFromJsonAsync()) + .GetProperty("data").GetProperty("employee").GetProperty("id").GetGuid(); + + var (userId, token) = _factory.Emails.SetPasswordLinkTo(email); + var set = await _anonymous.PostAsJsonAsync("/api/v1/auth/set-password", + new { userId, token, newPassword = EmployeePassword }); + Assert.Equal(HttpStatusCode.OK, set.StatusCode); + + return new AcceptedEmployee(employeeId, email); + } + + private Task InviteAsync(RegisteredDistributor admin, string email, string role) => + TestAuth.CreateClientWithToken(_factory, admin.AccessToken) + .PostAsJsonAsync("/api/v1/employees", new { fullName = "Anita Rao", email, roles = new[] { role } }); + + private Task PostAsync(RegisteredDistributor admin, string path) => + TestAuth.CreateClientWithToken(_factory, admin.AccessToken).PostAsync($"/api/v1/employees/{path}", null); + + private Task LoginAsync(string email, string password = EmployeePassword) => + _anonymous.PostAsJsonAsync("/api/v1/auth/login", new { email, password }); + + private async Task CountActiveAdministratorsAsync(Guid tenantId) + { + await using var scope = _factory.Services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().CountActiveAdministratorsAsync(tenantId); + } +} diff --git a/tests/DistroFlow.IntegrationTests/Controllers/EmployeesControllerTests.cs b/tests/DistroFlow.IntegrationTests/Controllers/EmployeesControllerTests.cs new file mode 100644 index 0000000..1cae4d6 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Controllers/EmployeesControllerTests.cs @@ -0,0 +1,443 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Domain.Constants; +using DistroFlow.Domain.Entities; +using DistroFlow.Infrastructure.Identity; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.IntegrationTests.Controllers; + +public class EmployeesControllerTests : IClassFixture +{ + private const string EmployeesUrl = "/api/v1/employees"; + + private readonly ApiIntegrationTestFactory _factory; + private readonly HttpClient _anonymous; + + public EmployeesControllerTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + _anonymous = factory.CreateClient(); + } + + [Fact] + public async Task List_returns_only_own_employees_with_email_and_roles() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var anita = await AddEmployeeAsync(a.TenantId, "Anita Rao", EmployeeRoles.SalesStaff); + var b = await TestAuth.RegisterAndLoginAsync(_anonymous); + + var response = await AsAdmin(a).GetAsync(EmployeesUrl); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var list = (await response.Content.ReadFromJsonAsync()).GetProperty("data") + .EnumerateArray().ToList(); + + // Ordered by name: "Anita Rao" before "Ravi Sharma" (the owner). + Assert.Equal([anita.EmployeeId, a.EmployeeId], list.Select(e => e.GetProperty("id").GetGuid())); + Assert.DoesNotContain(list, e => e.GetProperty("id").GetGuid() == b.EmployeeId); + + var owner = list[1]; + Assert.Equal(a.Email, owner.GetProperty("email").GetString()); + Assert.Equal([EmployeeRoles.Administrator], RolesOf(owner)); + Assert.Equal("Active", owner.GetProperty("status").GetString()); + + Assert.Equal(anita.Email, list[0].GetProperty("email").GetString()); + Assert.Equal([EmployeeRoles.SalesStaff], RolesOf(list[0])); + } + + [Fact] + public async Task Get_by_id_returns_employee_details() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var anita = await AddEmployeeAsync(a.TenantId, "Anita Rao", EmployeeRoles.DispatchStaff); + + var response = await AsAdmin(a).GetAsync($"{EmployeesUrl}/{anita.EmployeeId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + Assert.Equal("Anita Rao", data.GetProperty("fullName").GetString()); + Assert.Equal(anita.Email, data.GetProperty("email").GetString()); + Assert.Equal([EmployeeRoles.DispatchStaff], RolesOf(data)); + } + + [Fact] + public async Task Get_another_distributors_employee_returns_404_not_403() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var b = await TestAuth.RegisterAndLoginAsync(_anonymous); + + var response = await AsAdmin(a).GetAsync($"{EmployeesUrl}/{b.EmployeeId}"); + + // 404, not 403: A must not even learn that B's employee id exists. + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Non_administrator_cannot_list_employees() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var salesToken = TestAuth.IssueToken(_factory, a.TenantId, EmployeeRoles.SalesStaff); + + var response = await TestAuth.CreateClientWithToken(_factory, salesToken).GetAsync(EmployeesUrl); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task List_without_token_returns_401() + { + var response = await _anonymous.GetAsync(EmployeesUrl); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Invite_creates_employee_without_password_and_emails_a_valid_set_password_link() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var email = UniqueEmail(); + + var response = await AsAdmin(a).PostAsJsonAsync(EmployeesUrl, InviteBody(email, "Anita Rao", + [EmployeeRoles.SalesStaff, EmployeeRoles.Approver], employeeCode: "EMP-7")); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + Assert.True(data.GetProperty("inviteEmailSent").GetBoolean()); + var created = data.GetProperty("employee"); + var employeeId = created.GetProperty("id").GetGuid(); + Assert.Equal("Anita Rao", created.GetProperty("fullName").GetString()); + Assert.Equal(email, created.GetProperty("email").GetString()); + Assert.Equal("EMP-7", created.GetProperty("employeeCode").GetString()); + Assert.Equal([EmployeeRoles.Approver, EmployeeRoles.SalesStaff], RolesOf(created)); + + // Belongs to the inviting distributor. + var listed = await AsAdmin(a).GetAsync($"{EmployeesUrl}/{employeeId}"); + Assert.Equal(HttpStatusCode.OK, listed.StatusCode); + + // The email links to the frontend's set-password page with a token that Identity accepts. + var mail = _factory.Emails.SingleTo(email); + Assert.Contains("Anita Rao", mail.HtmlBody); + Assert.Contains(a.BusinessName, mail.Subject); + var (userId, token) = FakeEmailSender.ParseSetPasswordLink(mail.TextBody); + + await using var scope = _factory.Services.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByIdAsync(userId.ToString()); + Assert.NotNull(user); + Assert.False(await userManager.HasPasswordAsync(user)); + Assert.True(await userManager.VerifyUserTokenAsync( + user, + userManager.Options.Tokens.PasswordResetTokenProvider, + UserManager.ResetPasswordTokenPurpose, + token)); + } + + [Fact] + public async Task Invited_employee_cannot_log_in_before_setting_a_password() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var email = UniqueEmail(); + await AsAdmin(a).PostAsJsonAsync(EmployeesUrl, InviteBody(email, "Anita Rao", [EmployeeRoles.SalesStaff])); + + var login = await _anonymous.PostAsJsonAsync("/api/v1/auth/login", new { email, password = "" }); + var loginWithGuess = await _anonymous.PostAsJsonAsync("/api/v1/auth/login", new { email, password = "Str0ng!Passw0rd" }); + + Assert.Equal(HttpStatusCode.BadRequest, login.StatusCode); // empty password fails validation + Assert.Equal(HttpStatusCode.Unauthorized, loginWithGuess.StatusCode); + } + + [Fact] + public async Task Invite_with_email_active_at_another_distributor_returns_409_and_saves_nothing() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var b = await TestAuth.RegisterAndLoginAsync(_anonymous); + + // B's owner email is active, so A cannot invite it. + var response = await AsAdmin(a).PostAsJsonAsync(EmployeesUrl, InviteBody(b.Email, "Copied Person", [EmployeeRoles.SalesStaff])); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Single(await ListIdsAsync(a)); // still only A's owner + } + + [Fact] + public async Task Duplicate_employee_code_returns_409_and_rolls_back_the_login() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var first = await AsAdmin(a).PostAsJsonAsync(EmployeesUrl, InviteBody(UniqueEmail(), "First", [EmployeeRoles.SalesStaff], employeeCode: "EMP-1")); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + + var secondEmail = UniqueEmail(); + var second = await AsAdmin(a).PostAsJsonAsync(EmployeesUrl, InviteBody(secondEmail, "Second", [EmployeeRoles.SalesStaff], employeeCode: "EMP-1")); + + Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); + Assert.Equal(2, (await ListIdsAsync(a)).Count); // owner + first + + // The login created for the second invite was rolled back, so the email is free again. + var retry = await AsAdmin(a).PostAsJsonAsync(EmployeesUrl, InviteBody(secondEmail, "Second", [EmployeeRoles.SalesStaff], employeeCode: "EMP-2")); + Assert.Equal(HttpStatusCode.Created, retry.StatusCode); + } + + [Theory] + [InlineData("")] // no roles + [InlineData("Super Admin")] // unknown role + [InlineData("Sales Staff,Sales Staff")] // duplicate + public async Task Invite_with_invalid_roles_returns_400(string commaSeparatedRoles) + { + var roles = commaSeparatedRoles.Split(',', StringSplitOptions.RemoveEmptyEntries); + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + + var response = await AsAdmin(a).PostAsJsonAsync(EmployeesUrl, InviteBody(UniqueEmail(), "Anita Rao", roles)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var errors = (await response.Content.ReadFromJsonAsync()).GetProperty("errors"); + Assert.True(errors.TryGetProperty("Roles", out _)); + } + + [Fact] + public async Task Non_administrator_cannot_invite() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var salesToken = TestAuth.IssueToken(_factory, a.TenantId, EmployeeRoles.SalesStaff); + + var response = await TestAuth.CreateClientWithToken(_factory, salesToken) + .PostAsJsonAsync(EmployeesUrl, InviteBody(UniqueEmail(), "Anita Rao", [EmployeeRoles.SalesStaff])); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task When_email_cannot_be_sent_employee_is_still_created_and_response_says_so() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var email = $"{FakeEmailSender.FailingRecipientMarker}-{Guid.NewGuid():N}@example.com"; + + var response = await AsAdmin(a).PostAsJsonAsync(EmployeesUrl, InviteBody(email, "Anita Rao", [EmployeeRoles.SalesStaff])); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + Assert.False(data.GetProperty("inviteEmailSent").GetBoolean()); + Assert.Contains(data.GetProperty("employee").GetProperty("id").GetGuid(), await ListIdsAsync(a)); + } + + [Fact] + public async Task Resend_invite_emails_a_new_link_to_a_pending_employee() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var (employeeId, email) = await InviteAsync(a); + + var response = await AsAdmin(a).PostAsync($"{EmployeesUrl}/{employeeId}/resend-invite", null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + Assert.True(data.GetProperty("inviteEmailSent").GetBoolean()); + Assert.Equal(2, _factory.Emails.Sent.Count(m => m.To == email)); // original + resent + } + + [Fact] + public async Task Resend_invite_for_employee_who_already_set_a_password_returns_409() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + + // The owner registered with a password, so there is no pending invite. + var response = await AsAdmin(a).PostAsync($"{EmployeesUrl}/{a.EmployeeId}/resend-invite", null); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task Resend_invite_for_deactivated_employee_returns_409_and_sends_nothing() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var (employeeId, email) = await InviteAsync(a); + await DeactivateDirectlyAsync(employeeId); + + var response = await AsAdmin(a).PostAsync($"{EmployeesUrl}/{employeeId}/resend-invite", null); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal(1, _factory.Emails.Sent.Count(m => m.To == email)); // only the original invite + } + + [Fact] + public async Task Resend_invite_for_another_distributors_employee_returns_404() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var b = await TestAuth.RegisterAndLoginAsync(_anonymous); + var (employeeOfB, email) = await InviteAsync(b); + + var response = await AsAdmin(a).PostAsync($"{EmployeesUrl}/{employeeOfB}/resend-invite", null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(1, _factory.Emails.Sent.Count(m => m.To == email)); + } + + [Fact] + public async Task Update_changes_profile_and_omitted_fields_are_cleared() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var (employeeId, email) = await InviteAsync(a); + + var response = await AsAdmin(a).PutAsJsonAsync($"{EmployeesUrl}/{employeeId}", + new { fullName = " Anita R. Rao ", employeeCode = "EMP-9" }); // no phone: cleared + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + Assert.Equal("Anita R. Rao", data.GetProperty("fullName").GetString()); // trimmed + Assert.Equal(JsonValueKind.Null, data.GetProperty("phone").ValueKind); + Assert.Equal("EMP-9", data.GetProperty("employeeCode").GetString()); + Assert.Equal(email, data.GetProperty("email").GetString()); // email unchanged + Assert.NotEqual(JsonValueKind.Null, data.GetProperty("updatedAtUtc").ValueKind); + + var reloaded = (await AsAdmin(a).GetFromJsonAsync($"{EmployeesUrl}/{employeeId}")).GetProperty("data"); + Assert.Equal("Anita R. Rao", reloaded.GetProperty("fullName").GetString()); + } + + [Fact] + public async Task Update_keeping_the_same_employee_code_is_allowed() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var (employeeId, _) = await InviteAsync(a); + await AsAdmin(a).PutAsJsonAsync($"{EmployeesUrl}/{employeeId}", new { fullName = "Anita Rao", employeeCode = "EMP-5" }); + + var response = await AsAdmin(a).PutAsJsonAsync($"{EmployeesUrl}/{employeeId}", new { fullName = "Anita Rao K", employeeCode = "EMP-5" }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Update_to_an_employee_code_used_by_a_colleague_returns_409() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var (first, _) = await InviteAsync(a); + var (second, _) = await InviteAsync(a); + await AsAdmin(a).PutAsJsonAsync($"{EmployeesUrl}/{first}", new { fullName = "First", employeeCode = "EMP-1" }); + + var response = await AsAdmin(a).PutAsJsonAsync($"{EmployeesUrl}/{second}", new { fullName = "Second", employeeCode = "EMP-1" }); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task Same_employee_code_is_allowed_in_different_distributors() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var b = await TestAuth.RegisterAndLoginAsync(_anonymous); + + var inA = await AsAdmin(a).PutAsJsonAsync($"{EmployeesUrl}/{a.EmployeeId}", new { fullName = "Owner A", employeeCode = "EMP-1" }); + var inB = await AsAdmin(b).PutAsJsonAsync($"{EmployeesUrl}/{b.EmployeeId}", new { fullName = "Owner B", employeeCode = "EMP-1" }); + + Assert.Equal(HttpStatusCode.OK, inA.StatusCode); + Assert.Equal(HttpStatusCode.OK, inB.StatusCode); + } + + [Fact] + public async Task Update_another_distributors_employee_returns_404_and_changes_nothing() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var b = await TestAuth.RegisterAndLoginAsync(_anonymous); + + var response = await AsAdmin(a).PutAsJsonAsync($"{EmployeesUrl}/{b.EmployeeId}", new { fullName = "Hijacked" }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var ownerOfB = (await AsAdmin(b).GetFromJsonAsync($"{EmployeesUrl}/{b.EmployeeId}")).GetProperty("data"); + Assert.Equal("Ravi Sharma", ownerOfB.GetProperty("fullName").GetString()); + } + + [Fact] + public async Task Update_anonymised_employee_returns_409() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + var (employeeId, _) = await InviteAsync(a); + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var employee = await db.Employees.IgnoreQueryFilters().SingleAsync(e => e.Id == employeeId); + employee.Anonymise(DateTime.UtcNow); + await db.SaveChangesAsync(); + } + + var response = await AsAdmin(a).PutAsJsonAsync($"{EmployeesUrl}/{employeeId}", new { fullName = "Back Again" }); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task Update_with_invalid_body_returns_400() + { + var a = await TestAuth.RegisterAndLoginAsync(_anonymous); + + var response = await AsAdmin(a).PutAsJsonAsync($"{EmployeesUrl}/{a.EmployeeId}", + new { fullName = "X", phone = "98765" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var errors = (await response.Content.ReadFromJsonAsync()).GetProperty("errors"); + Assert.True(errors.TryGetProperty("FullName", out _)); + Assert.True(errors.TryGetProperty("Phone", out _)); + } + + private async Task<(Guid EmployeeId, string Email)> InviteAsync(RegisteredDistributor admin) + { + var email = UniqueEmail(); + var response = await AsAdmin(admin).PostAsJsonAsync(EmployeesUrl, InviteBody(email, "Anita Rao", [EmployeeRoles.SalesStaff])); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var data = (await response.Content.ReadFromJsonAsync()).GetProperty("data"); + return (data.GetProperty("employee").GetProperty("id").GetGuid(), email); + } + + /// Test setup until the deactivate endpoint exists. + private async Task DeactivateDirectlyAsync(Guid employeeId) + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var employee = await db.Employees.IgnoreQueryFilters().SingleAsync(e => e.Id == employeeId); + var user = await db.Users.SingleAsync(u => u.Id == employee.UserId); + employee.Deactivate(DateTime.UtcNow); + user.IsActive = false; + await db.SaveChangesAsync(); + } + + private static string UniqueEmail() => $"invitee-{Guid.NewGuid():N}@example.com"; + + private static object InviteBody(string email, string fullName, string[] roles, string? employeeCode = null) => + new { fullName, email, phone = "+919876543210", employeeCode, roles }; + + private async Task> ListIdsAsync(RegisteredDistributor distributor) + { + var list = await AsAdmin(distributor).GetFromJsonAsync(EmployeesUrl); + return list.GetProperty("data").EnumerateArray().Select(e => e.GetProperty("id").GetGuid()).ToList(); + } + + private HttpClient AsAdmin(RegisteredDistributor distributor) => + TestAuth.CreateClientWithToken(_factory, distributor.AccessToken); + + private static string[] RolesOf(JsonElement employee) => + employee.GetProperty("roles").EnumerateArray().Select(r => r.GetString()!).ToArray(); + + /// + /// Test setup until the invite endpoint exists: creates a login and an employee directly. + /// + private async Task<(Guid EmployeeId, string Email)> AddEmployeeAsync(Guid tenantId, string fullName, string role) + { + await using var scope = _factory.Services.CreateAsyncScope(); + var identity = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + var email = $"staff-{Guid.NewGuid():N}@example.com"; + var login = await identity.CreateUserAsync(tenantId, email, TestAuth.Password, role); + Assert.True(login.Succeeded); + + var employee = new Employee { TenantId = tenantId, UserId = login.UserId, FullName = fullName }; + db.Employees.Add(employee); + await db.SaveChangesAsync(); + + return (employee.Id, email); + } +} diff --git a/tests/DistroFlow.IntegrationTests/Email/SmtpEmailSenderTests.cs b/tests/DistroFlow.IntegrationTests/Email/SmtpEmailSenderTests.cs new file mode 100644 index 0000000..35a1037 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Email/SmtpEmailSenderTests.cs @@ -0,0 +1,76 @@ +using System.Net.Http.Json; +using System.Text.Json; +using DistroFlow.Application.Interfaces.Email; +using DistroFlow.Infrastructure.Email; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Microsoft.Extensions.Options; + +namespace DistroFlow.IntegrationTests.Email; + +/// +/// Sends real SMTP mail to a Mailpit container and reads it back through Mailpit's API. +/// +public class SmtpEmailSenderTests : IAsyncLifetime +{ + private const int SmtpPort = 1025; + private const int ApiPort = 8025; + + private readonly IContainer _mailpit = new ContainerBuilder("axllent/mailpit:v1.33.1") + .WithPortBinding(SmtpPort, assignRandomHostPort: true) + .WithPortBinding(ApiPort, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request.ForPort(ApiPort).ForPath("/api/v1/info"))) + .Build(); + + public Task InitializeAsync() => _mailpit.StartAsync(); + + public Task DisposeAsync() => _mailpit.DisposeAsync().AsTask(); + + [Fact] + public async Task Sends_html_and_text_email_to_the_smtp_server() + { + var sender = CreateSender(useTls: false); + + await sender.SendAsync(new EmailMessage( + To: "anita@example.com", + Subject: "You're invited to DistroFlow", + HtmlBody: "

Click here

", + TextBody: "Open https://example.com/set")); + + using var api = new HttpClient { BaseAddress = new Uri($"http://{_mailpit.Hostname}:{_mailpit.GetMappedPublicPort(ApiPort)}") }; + + var list = await api.GetFromJsonAsync("/api/v1/messages"); + Assert.Equal(1, list.GetProperty("total").GetInt32()); + var summary = list.GetProperty("messages")[0]; + Assert.Equal("You're invited to DistroFlow", summary.GetProperty("Subject").GetString()); + Assert.Equal("anita@example.com", summary.GetProperty("To")[0].GetProperty("Address").GetString()); + Assert.Equal("no-reply@distroflow.test", summary.GetProperty("From").GetProperty("Address").GetString()); + Assert.Equal("DistroFlow Tests", summary.GetProperty("From").GetProperty("Name").GetString()); + + var message = await api.GetFromJsonAsync($"/api/v1/message/{summary.GetProperty("ID").GetString()}"); + Assert.Contains("https://example.com/set", message.GetProperty("HTML").GetString()); + Assert.Contains("https://example.com/set", message.GetProperty("Text").GetString()); + } + + [Fact] + public async Task With_tls_enabled_it_refuses_a_server_without_tls_instead_of_sending_in_plain_text() + { + // Mailpit offers no STARTTLS. With UseTls = true, sending must fail rather than + // silently fall back to an unencrypted connection. + var sender = CreateSender(useTls: true); + + await Assert.ThrowsAnyAsync(() => sender.SendAsync( + new EmailMessage("anita@example.com", "Subject", "

Body

", "Body"))); + } + + private SmtpEmailSender CreateSender(bool useTls) => + new(Options.Create(new SmtpOptions + { + Host = _mailpit.Hostname, + Port = _mailpit.GetMappedPublicPort(SmtpPort), + UseTls = useTls, + FromAddress = "no-reply@distroflow.test", + FromName = "DistroFlow Tests" + })); +} diff --git a/tests/DistroFlow.IntegrationTests/Identity/IdentityServiceTests.cs b/tests/DistroFlow.IntegrationTests/Identity/IdentityServiceTests.cs index e31f684..d0cec9d 100644 --- a/tests/DistroFlow.IntegrationTests/Identity/IdentityServiceTests.cs +++ b/tests/DistroFlow.IntegrationTests/Identity/IdentityServiceTests.cs @@ -91,6 +91,156 @@ public async Task CreateUser_returns_ValidationFailed_for_weak_password_and_save Assert.Equal(0, await db.Users.CountAsync(u => u.TenantId == tenant.Id)); } + [Fact] + public async Task CreateInvitedUser_creates_login_without_password_and_with_all_roles() + { + var tenant = await CreateTenantAsync(); + + await using var scope = _factory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var result = await service.CreateInvitedUserAsync( + tenant.Id, UniqueEmail(), [EmployeeRoles.SalesStaff, EmployeeRoles.Approver]); + + Assert.True(result.Succeeded); + var user = await userManager.FindByIdAsync(result.UserId!.Value.ToString()); + Assert.NotNull(user); + Assert.False(await userManager.HasPasswordAsync(user)); + Assert.Equal( + [EmployeeRoles.Approver, EmployeeRoles.SalesStaff], + (await userManager.GetRolesAsync(user)).Order()); + } + + [Fact] + public async Task Password_setup_token_is_accepted_once_and_then_stops_working() + { + var tenant = await CreateTenantAsync(); + + await using var scope = _factory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var created = await service.CreateInvitedUserAsync(tenant.Id, UniqueEmail(), [EmployeeRoles.SalesStaff]); + var token = await service.GeneratePasswordSetupTokenAsync(created.UserId!.Value); + var user = (await userManager.FindByIdAsync(created.UserId.Value.ToString()))!; + + var first = await userManager.ResetPasswordAsync(user, token, ValidPassword); + var reused = await userManager.ResetPasswordAsync(user, token, "An0ther!Passw0rd"); + + Assert.True(first.Succeeded); + Assert.False(reused.Succeeded); // single use: setting the password invalidates the token + Assert.True(await userManager.CheckPasswordAsync(user, ValidPassword)); + } + + [Fact] + public async Task ReplaceRoles_adds_missing_and_removes_unlisted_roles() + { + var tenant = await CreateTenantAsync(); + + await using var scope = _factory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var created = await service.CreateInvitedUserAsync( + tenant.Id, UniqueEmail(), [EmployeeRoles.SalesStaff, EmployeeRoles.Approver]); + + await service.ReplaceRolesAsync(created.UserId!.Value, [EmployeeRoles.Approver, EmployeeRoles.DispatchStaff]); + + var user = (await userManager.FindByIdAsync(created.UserId.Value.ToString()))!; + Assert.Equal( + [EmployeeRoles.Approver, EmployeeRoles.DispatchStaff], + (await userManager.GetRolesAsync(user)).Order()); + } + + [Fact] + public async Task CountActiveAdministrators_counts_only_active_admins_of_that_tenant() + { + var tenant = await CreateTenantAsync(); + var otherTenant = await CreateTenantAsync(); + + await using var scope = _factory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + await service.CreateInvitedUserAsync(tenant.Id, UniqueEmail(), [EmployeeRoles.Administrator]); // counted + await service.CreateInvitedUserAsync(tenant.Id, UniqueEmail(), [EmployeeRoles.SalesStaff]); // not an admin + await service.CreateInvitedUserAsync(otherTenant.Id, UniqueEmail(), [EmployeeRoles.Administrator]); // other tenant + var inactive = await service.CreateInvitedUserAsync(tenant.Id, UniqueEmail(), [EmployeeRoles.Administrator]); + (await db.Users.SingleAsync(u => u.Id == inactive.UserId)).IsActive = false; // inactive + await db.SaveChangesAsync(); + + Assert.Equal(1, await service.CountActiveAdministratorsAsync(tenant.Id)); + } + + [Fact] + public async Task Deactivating_a_login_also_invalidates_its_invite_link() + { + var tenant = await CreateTenantAsync(); + + await using var scope = _factory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var created = await service.CreateInvitedUserAsync(tenant.Id, UniqueEmail(), [EmployeeRoles.SalesStaff]); + var token = await service.GeneratePasswordSetupTokenAsync(created.UserId!.Value); + + var result = await service.SetLoginActiveAsync(created.UserId.Value, isActive: false); + + Assert.Equal(SetLoginActiveResult.Succeeded, result); + var user = (await userManager.FindByIdAsync(created.UserId.Value.ToString()))!; + Assert.False(user.IsActive); + Assert.False(await userManager.VerifyUserTokenAsync( + user, + userManager.Options.Tokens.PasswordResetTokenProvider, + UserManager.ResetPasswordTokenPurpose, + token)); + } + + [Fact] + public async Task Reactivating_a_login_succeeds_when_the_email_is_free() + { + var tenant = await CreateTenantAsync(); + + await using var scope = _factory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + var created = await service.CreateInvitedUserAsync(tenant.Id, UniqueEmail(), [EmployeeRoles.SalesStaff]); + await service.SetLoginActiveAsync(created.UserId!.Value, isActive: false); + + var result = await service.SetLoginActiveAsync(created.UserId.Value, isActive: true); + + Assert.Equal(SetLoginActiveResult.Succeeded, result); + Assert.True((await db.Users.AsNoTracking().SingleAsync(u => u.Id == created.UserId)).IsActive); + } + + [Fact] + public async Task Reactivating_a_login_whose_email_is_active_elsewhere_returns_EmailInUse() + { + var oldEmployer = await CreateTenantAsync(); + var newEmployer = await CreateTenantAsync(); + var email = UniqueEmail(); + + Guid oldLogin; + await using (var setup = _factory.Services.CreateAsyncScope()) + { + var setupService = setup.ServiceProvider.GetRequiredService(); + oldLogin = (await setupService.CreateInvitedUserAsync(oldEmployer.Id, email, [EmployeeRoles.SalesStaff])).UserId!.Value; + await setupService.SetLoginActiveAsync(oldLogin, isActive: false); // left the old job + Assert.True((await setupService.CreateInvitedUserAsync(newEmployer.Id, email, [EmployeeRoles.SalesStaff])).Succeeded); + } + + await using var scope = _factory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + var result = await service.SetLoginActiveAsync(oldLogin, isActive: true); + + Assert.Equal(SetLoginActiveResult.EmailInUse, result); + Assert.False((await db.Users.AsNoTracking().SingleAsync(u => u.Id == oldLogin)).IsActive); + } + private static string UniqueEmail() => $"user-{Guid.NewGuid():N}@example.com"; private async Task CreateTenantAsync() diff --git a/tests/DistroFlow.IntegrationTests/Infrastructure/ApiIntegrationTestFactory.cs b/tests/DistroFlow.IntegrationTests/Infrastructure/ApiIntegrationTestFactory.cs index 41f2bd4..942e471 100644 --- a/tests/DistroFlow.IntegrationTests/Infrastructure/ApiIntegrationTestFactory.cs +++ b/tests/DistroFlow.IntegrationTests/Infrastructure/ApiIntegrationTestFactory.cs @@ -1,4 +1,4 @@ - +using DistroFlow.Application.Interfaces.Email; using DistroFlow.Infrastructure.Persistence; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -49,9 +49,16 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddDbContext(options => options.UseNpgsql(_postgres.GetConnectionString())); + + // Record emails in memory instead of sending them over SMTP. + services.RemoveAll(); + services.AddSingleton(Emails); }); } + /// Emails "sent" by the API during tests. + public FakeEmailSender Emails { get; } = new(); + public new async Task DisposeAsync() { await _postgres.DisposeAsync(); diff --git a/tests/DistroFlow.IntegrationTests/Infrastructure/FakeEmailSender.cs b/tests/DistroFlow.IntegrationTests/Infrastructure/FakeEmailSender.cs new file mode 100644 index 0000000..b1adb84 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Infrastructure/FakeEmailSender.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using DistroFlow.Application.Interfaces.Email; + +namespace DistroFlow.IntegrationTests.Infrastructure; + +/// +/// Records emails instead of sending them, so API tests need no mail server. +/// Any recipient containing simulates a mail server error. +/// +public sealed class FakeEmailSender : IEmailSender +{ + public const string FailingRecipientMarker = "mail-server-down"; + + private readonly ConcurrentQueue _sent = new(); + + public IReadOnlyCollection Sent => _sent; + + public Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default) + { + if (message.To.Contains(FailingRecipientMarker, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Simulated SMTP failure."); + } + + _sent.Enqueue(message); + return Task.CompletedTask; + } + + /// The single email sent to this address (tests share one sender, so filter by recipient). + public EmailMessage SingleTo(string recipient) => + Assert.Single(_sent, m => string.Equals(m.To, recipient, StringComparison.OrdinalIgnoreCase)); + + /// userId and token from the set-password link in the latest email to this address. + public (Guid UserId, string Token) SetPasswordLinkTo(string recipient) + { + var latest = _sent.Last(m => string.Equals(m.To, recipient, StringComparison.OrdinalIgnoreCase)); + return ParseSetPasswordLink(latest.TextBody); + } + + public static (Guid UserId, string Token) ParseSetPasswordLink(string text) + { + var match = Regex.Match(text, @"/set-password\?userId=(?[^&\s]+)&token=(?\S+)"); + Assert.True(match.Success, "Email does not contain a set-password link."); + return (Guid.Parse(match.Groups["userId"].Value), Uri.UnescapeDataString(match.Groups["token"].Value)); + } +} diff --git a/tests/DistroFlow.IntegrationTests/Persistence/TenantLockTests.cs b/tests/DistroFlow.IntegrationTests/Persistence/TenantLockTests.cs new file mode 100644 index 0000000..dcde5a4 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Persistence/TenantLockTests.cs @@ -0,0 +1,90 @@ +using DistroFlow.Application.Interfaces.Persistence; +using DistroFlow.Application.Interfaces.Repositories; +using DistroFlow.Domain.Entities; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace DistroFlow.IntegrationTests.Persistence; + +/// Proves the tenant row lock really makes a second transaction wait. +public class TenantLockTests : IClassFixture +{ + private readonly ApiIntegrationTestFactory _factory; + + public TenantLockTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Second_transaction_cannot_lock_the_same_tenant_until_the_first_ends() + { + var tenantId = await CreateTenantAsync(); + + await using var first = _factory.Services.CreateAsyncScope(); + await using var firstTx = await UnitOfWork(first).BeginTransactionAsync(); + await Tenants(first).LockForUpdateAsync(tenantId); + + // While the first holds the lock, the second gives up after a short timeout. + var ex = await Assert.ThrowsAsync(() => TryLockWithTimeoutAsync(tenantId)); + Assert.Equal(PostgresErrorCodes.LockNotAvailable, ex.SqlState); + + await firstTx.CommitAsync(); + + // Once the first transaction ends, the lock is free again. + await TryLockWithTimeoutAsync(tenantId); + } + + [Fact] + public async Task Locking_one_tenant_does_not_block_another() + { + var tenantA = await CreateTenantAsync(); + var tenantB = await CreateTenantAsync(); + + await using var first = _factory.Services.CreateAsyncScope(); + await using var firstTx = await UnitOfWork(first).BeginTransactionAsync(); + await Tenants(first).LockForUpdateAsync(tenantA); + + await TryLockWithTimeoutAsync(tenantB); // does not throw + } + + [Fact] + public async Task Locking_outside_a_transaction_is_rejected() + { + var tenantId = await CreateTenantAsync(); + await using var scope = _factory.Services.CreateAsyncScope(); + + await Assert.ThrowsAsync(() => Tenants(scope).LockForUpdateAsync(tenantId)); + } + + /// Tries to lock in a separate transaction, waiting at most 300 ms. + private async Task TryLockWithTimeoutAsync(Guid tenantId) + { + await using var scope = _factory.Services.CreateAsyncScope(); + await using var tx = await UnitOfWork(scope).BeginTransactionAsync(); + + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.ExecuteSqlRawAsync("SET LOCAL lock_timeout = '300ms'"); + + await Tenants(scope).LockForUpdateAsync(tenantId); + } + + private async Task CreateTenantAsync() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var tenant = new Tenant { Name = $"Distributor {Guid.NewGuid():N}" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + return tenant.Id; + } + + private static IUnitOfWork UnitOfWork(AsyncServiceScope scope) => + scope.ServiceProvider.GetRequiredService(); + + private static ITenantRepository Tenants(AsyncServiceScope scope) => + scope.ServiceProvider.GetRequiredService(); +} diff --git a/tests/DistroFlow.IntegrationTests/Persistence/UnitOfWorkTests.cs b/tests/DistroFlow.IntegrationTests/Persistence/UnitOfWorkTests.cs new file mode 100644 index 0000000..18199e7 --- /dev/null +++ b/tests/DistroFlow.IntegrationTests/Persistence/UnitOfWorkTests.cs @@ -0,0 +1,54 @@ +using DistroFlow.Application.Common; +using DistroFlow.Application.Interfaces.Identity; +using DistroFlow.Application.Interfaces.Persistence; +using DistroFlow.Domain.Constants; +using DistroFlow.Domain.Entities; +using DistroFlow.Infrastructure.Persistence; +using DistroFlow.IntegrationTests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace DistroFlow.IntegrationTests.Persistence; + +public class UnitOfWorkTests : IClassFixture +{ + private readonly ApiIntegrationTestFactory _factory; + + public UnitOfWorkTests(ApiIntegrationTestFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Unique_index_violation_is_translated_with_the_constraint_name() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var identity = scope.ServiceProvider.GetRequiredService(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + var tenant = new Tenant { Name = $"Distributor {Guid.NewGuid():N}" }; + db.Tenants.Add(tenant); + await unitOfWork.SaveChangesAsync(); + + // Two employees of the same distributor with the same employee code. + foreach (var name in new[] { "First", "Second" }) + { + var login = await identity.CreateUserAsync( + tenant.Id, $"uow-{Guid.NewGuid():N}@example.com", "Str0ng!Passw0rd", EmployeeRoles.SalesStaff); + db.Employees.Add(new Employee + { + TenantId = tenant.Id, + UserId = login.UserId, + FullName = name, + EmployeeCode = "EMP-1" + }); + + if (name == "First") + await unitOfWork.SaveChangesAsync(); + } + + var ex = await Assert.ThrowsAsync(() => unitOfWork.SaveChangesAsync()); + + Assert.Equal(ConstraintNames.EmployeeCodePerTenant, ex.ConstraintName); + } +} diff --git a/tests/DistroFlow.UnitTests/Domain/EmployeeTests.cs b/tests/DistroFlow.UnitTests/Domain/EmployeeTests.cs index 9efc068..dea739c 100644 --- a/tests/DistroFlow.UnitTests/Domain/EmployeeTests.cs +++ b/tests/DistroFlow.UnitTests/Domain/EmployeeTests.cs @@ -81,6 +81,43 @@ public void Anonymise_keeps_original_deactivation_time() Assert.Equal(Now, employee.DeactivatedAtUtc); } + [Fact] + public void UpdateProfile_changes_details_and_records_time() + { + var employee = CreateEmployee(); + + employee.UpdateProfile("Ravi K. Kumar", null, "EMP-002", Now); + + Assert.Equal("Ravi K. Kumar", employee.FullName); + Assert.Null(employee.Phone); + Assert.Equal("EMP-002", employee.EmployeeCode); + Assert.Equal(Now, employee.UpdatedAtUtc); + } + + [Fact] + public void Deactivated_employee_can_still_be_edited() + { + var employee = CreateEmployee(); + employee.Deactivate(Now); + + employee.UpdateProfile("Ravi Kumar", "+919000000000", "EMP-001", Now.AddDays(1)); + + Assert.Equal("+919000000000", employee.Phone); + } + + [Fact] + public void Anonymised_employee_cannot_be_edited() + { + var employee = CreateEmployee(); + employee.Anonymise(Now); + + Assert.Throws( + () => employee.UpdateProfile("Ravi Kumar", "+919876543210", "EMP-001", Now)); + + Assert.Equal(Employee.AnonymisedName, employee.FullName); + Assert.Null(employee.Phone); + } + [Fact] public void Anonymised_employee_cannot_be_reactivated() { diff --git a/tests/DistroFlow.UnitTests/Emails/InvitationEmailTests.cs b/tests/DistroFlow.UnitTests/Emails/InvitationEmailTests.cs new file mode 100644 index 0000000..a6fa014 --- /dev/null +++ b/tests/DistroFlow.UnitTests/Emails/InvitationEmailTests.cs @@ -0,0 +1,32 @@ +using DistroFlow.Application.Emails; + +namespace DistroFlow.UnitTests.Emails; + +public class InvitationEmailTests +{ + private const string Link = "https://app.example.com/set-password?userId=42&token=abc%2B1"; + + [Fact] + public void Contains_recipient_distributor_name_and_link() + { + var email = InvitationEmail.Create("anita@example.com", "Anita Rao", "Sharma Traders", Link); + + Assert.Equal("anita@example.com", email.To); + Assert.Contains("Sharma Traders", email.Subject); + Assert.Contains("Anita Rao", email.HtmlBody); + Assert.Contains(Link, email.TextBody); + Assert.Contains("https://app.example.com/set-password?userId=42&token=abc%2B1", email.HtmlBody); + } + + [Fact] + public void User_supplied_names_are_html_encoded() + { + var email = InvitationEmail.Create( + "anita@example.com", "", "Evil Traders", Link); + + Assert.DoesNotContain("