diff --git a/.github/workflows/CI-CT-CD.yml b/.github/workflows/CI-CT-CD.yml index d7714d0..ab4f0de 100644 --- a/.github/workflows/CI-CT-CD.yml +++ b/.github/workflows/CI-CT-CD.yml @@ -1,4 +1,4 @@ -name: CI - CT - CD +name: Continuous Integration - Testing - Deploy on: push: @@ -91,6 +91,7 @@ jobs: if ($coverageFiles.Count -eq 0) { "## Coverage gate`n`nNo Coverlet XML was found under the test-results GUID subdirectories." | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + Write-Error "Coverage data was not produced." return } @@ -99,27 +100,21 @@ jobs: $lineRate = [double]$coverage.coverage.'line-rate' * 100 $branchRate = [double]$coverage.coverage.'branch-rate' * 100 - @" + @" ## Coverage gate | Metric | Result | Minimum | Status | | --- | ---: | ---: | --- | + | Line coverage | $([math]::Round($lineRate, 2))% | 80% | $(if ($lineRate -ge 80) { '✅' } else { '❌' }) | + | Branch coverage | $([math]::Round($branchRate, 2))% | 80% | $(if ($branchRate -ge 80) { '✅' } else { '❌' }) | "@ | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + "LINE_COVERAGE=$([math]::Round($lineRate, 2))" | Out-File artifacts/line-rate.txt -Encoding utf8 + "BRANCH_COVERAGE=$([math]::Round($branchRate, 2))" | Out-File artifacts/branch-rate.txt -Encoding utf8 - if ($lineRate -ge 80) { - "| Line coverage | $([math]::Round($lineRate, 2))% | 80% | ✅ |" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } else { - "| Line coverage | $([math]::Round($lineRate, 2))% | 80% | ❌ |" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if ($lineRate -lt 80 -or $branchRate -lt 80) { + Write-Error "Coverage gate failed. Line coverage: $([math]::Round($lineRate, 2))%; Branch coverage: $([math]::Round($branchRate, 2))%; minimum: 80%." } - if ($branchRate -ge 80) { - "| Branch coverage | $([math]::Round($branchRate, 2))% | 80% | ✅ |" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } else { - "| Branch coverage | $([math]::Round($branchRate, 2))% | 80% | ❌ |" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } - - - - name: Append test execution report if: always() shell: pwsh diff --git a/src/server/RemoteTickets.Application/Common/System/SystemContracts.cs b/src/server/RemoteTickets.Application/Common/System/SystemContracts.cs new file mode 100644 index 0000000..34e3552 --- /dev/null +++ b/src/server/RemoteTickets.Application/Common/System/SystemContracts.cs @@ -0,0 +1,29 @@ +namespace RemoteTickets.Application.Common.System; + +/// Represents the master database setup payload. +/// The SQL Server connection string used by the tenant catalog. +/// The value (in seconds) for use for operations timeout in SQL Server. +public sealed record MasterDatabaseSetupRequest( + string ConnectionString, + int CommandTimeout); + +/// Represents the first-time setup administrator credentials. +/// The administrator email address. +/// The administrator password. +public sealed record InitializeSetupRequest(string Email, string Password); + +/// +/// Contract for System Service +/// +public interface ISystemService +{ + /// + /// Performs the setup of master database + /// + /// Connection string of master database + /// Timeout for operations on master database + /// Cancellation token for the operation + /// An with the status of setup operation + Task PerformMasterDatabaseSetup(string connectionString, int commandTimeout, CancellationToken cancellationToken); +} + diff --git a/src/server/RemoteTickets.Application/Common/Tenancy/TenancyContracts.cs b/src/server/RemoteTickets.Application/Common/Tenancy/TenancyContracts.cs new file mode 100644 index 0000000..7a6d287 --- /dev/null +++ b/src/server/RemoteTickets.Application/Common/Tenancy/TenancyContracts.cs @@ -0,0 +1,93 @@ +namespace RemoteTickets.Application.Common.Tenancy; + +/// Contains the application roles used by the tenant-aware authorization model. +public static class TenantRoles +{ + /// Gets the role that can administer the complete installation and every tenant. + public const string SysAdmin = "sysadmin"; + /// Gets the role that can administer a single tenant. + public const string TenantAdmin = "tenadmin"; + /// Gets the role that can operate the cash register and other non-configuration business functions. + public const string TenantOperator = "tenop"; + /// Gets the role that can perform attendance and non-critical business operations. + public const string TenantAttendant = "tenat"; +} + +/// Contains claim names used by the tenant-aware security model. +public static class TenantClaimTypes +{ + /// Gets the claim containing the tenant identifier assigned to a user. + public const string TenantId = "tenant_id"; +} + +/// Contains authorization policy names used by tenant-aware endpoints. +public static class TenantPolicies +{ + /// Gets the policy requiring system administration privileges. + public const string SysAdmin = "tenant.sysadmin"; + /// Gets the policy requiring tenant administration privileges. + public const string TenantAdmin = "tenant.admin"; + /// Gets the policy requiring operator privileges. + public const string TenantOperator = "tenant.operator"; + /// Gets the policy requiring attendant privileges. + public const string TenantAttendant = "tenant.attendant"; +} + +/// Represents the setup state of a tenant database. +/// Indicates whether tenant setup is still required. +/// Indicates whether tenant setup has completed. +public sealed record TenantSetupStatusResponse(bool IsSetupRequired, bool IsSetupComplete) : IResponse; + +/// Represents a tenant exposed to system administrators. +/// The stable tenant identifier used in routes. +/// The tenant display name. +/// The database name assigned to the tenant. +/// Indicates whether the tenant accepts normal operations. +/// Indicates whether tenant setup has completed. +public sealed record TenantResponse(string Id, string Name, string DatabaseName, bool IsActive, bool IsSetupComplete) : IResponse; + +/// Represents a request to provision a tenant and its first administrator. +/// The tenant identifier. +/// The tenant display name. +/// The SQL Server database name. +/// The connection string for the tenant database. +/// The first tenant administrator email. +/// The first tenant administrator password. +public sealed record CreateTenantRequest(string Id, string Name, string DatabaseName, string ConnectionString, string AdminEmail, string AdminPassword) +{ + public CreateTenantCommand ToCommand() + => new ((string.IsNullOrEmpty(Name) || string.IsNullOrWhiteSpace(Name) ? throw new ArgumentNullException(nameof(Name)) : Name), + (string.IsNullOrEmpty(DatabaseName) || string.IsNullOrWhiteSpace(DatabaseName)? throw new ArgumentNullException(nameof(DatabaseName)) : DatabaseName), + (string.IsNullOrEmpty(ConnectionString) || string.IsNullOrWhiteSpace(ConnectionString)? throw new ArgumentNullException(nameof(ConnectionString)) : ConnectionString), + (string.IsNullOrEmpty(AdminEmail) || string.IsNullOrWhiteSpace(AdminEmail)? throw new ArgumentNullException(nameof(AdminEmail)) : AdminEmail), + (string.IsNullOrEmpty(AdminPassword) || string.IsNullOrWhiteSpace(AdminPassword)? throw new ArgumentNullException(nameof(AdminPassword)) : AdminPassword)); +} + +/// Provides tenant registration and setup operations. +public interface ITenantManagementService +{ + /// Gets a tenant by its route identifier. + /// The tenant route identifier. + /// The token used to cancel the operation. + /// The tenant, or when it does not exist. + Task GetAsync(string tenantId, CancellationToken cancellationToken); + + /// Creates a tenant database and its first tenant administrator. + /// The tenant provisioning request. + /// The token used to cancel the operation. + /// The provisioned tenant. + Task CreateAsync(CreateTenantRequest request, CancellationToken cancellationToken); + + /// Gets the setup state stored in a tenant database. + /// The tenant route identifier. + /// The token used to cancel the operation. + /// The tenant setup state. + Task GetSetupStatusAsync(string tenantId, CancellationToken cancellationToken); + + /// Completes tenant setup after all tenant initialization checks have succeeded. + /// The tenant route identifier. + /// The token used to cancel the operation. + /// The resulting setup state. + Task CompleteSetupAsync(string tenantId, CancellationToken cancellationToken); +} + diff --git a/src/server/RemoteTickets.Application/Common/Util/RemoteTicketsConstants.cs b/src/server/RemoteTickets.Application/Common/Util/RemoteTicketsConstants.cs new file mode 100644 index 0000000..f908e76 --- /dev/null +++ b/src/server/RemoteTickets.Application/Common/Util/RemoteTicketsConstants.cs @@ -0,0 +1,7 @@ +namespace RemoteTickets.Application.Common.Util; + +public static class RemoteTicketsConstants +{ + public const string SystemSetupId = "01a00d95-87a3-7d90-b45c-759bf28c5d50"; + public const string TenantSetupId = "01a00e8b-ffe7-7186-aa29-46980b4cdbd7"; +} diff --git a/src/server/RemoteTickets.Application/GlobalUsings.cs b/src/server/RemoteTickets.Application/GlobalUsings.cs index a4a7c89..648656f 100644 --- a/src/server/RemoteTickets.Application/GlobalUsings.cs +++ b/src/server/RemoteTickets.Application/GlobalUsings.cs @@ -1,13 +1,19 @@ global using System; global using System.Collections.Generic; +global using System.Data.Common; global using System.Linq; global using System.Security.Claims; global using System.Threading; global using System.Threading.Tasks; global using FluentValidation; global using FluentValidation.Results; +global using FluentValidation.Validators; global using Mediator.Net.Context; global using Mediator.Net.Contracts; global using Mediator.Net.Pipeline; global using RemoteTickets.Application.Common.Identity; +global using RemoteTickets.Application.Common.System; +global using RemoteTickets.Application.Common.Tenancy; global using RemoteTickets.Application.Identity.Requests; +global using RemoteTickets.Application.System.Requests; +global using RemoteTickets.Application.Tenant.Requests; \ No newline at end of file diff --git a/src/server/RemoteTickets.Application/Identity/Handlers/IdentityHandlers.cs b/src/server/RemoteTickets.Application/Identity/Handlers/IdentityHandlers.cs index e3d27f7..607c553 100644 --- a/src/server/RemoteTickets.Application/Identity/Handlers/IdentityHandlers.cs +++ b/src/server/RemoteTickets.Application/Identity/Handlers/IdentityHandlers.cs @@ -1,8 +1,8 @@ namespace RemoteTickets.Application.Identity.Handlers; /// Handles the identity requests. -/// The identity service that performs registration. -public sealed class IdentityHandlers(IIdentityService identityService) : +/// The identity service that performs registration and authentication. +public sealed class IdentityHandlers(IIdentityService identityService) : IRequestHandler, IRequestHandler>, IRequestHandler>, @@ -18,104 +18,47 @@ public sealed class IdentityHandlers(IIdentityService identityService) : IRequestHandler { /// Executes the registration request. - /// The message context containing the registration request. - /// The token used to cancel the operation. - /// The registration result. - public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => - identityService.RegisterAsync(context.Message.Email, context.Message.Password, cancellationToken); - - /// Executes the login request and converts authentication failure to an application response. - /// The message context containing the login request. - /// The token used to cancel the operation. - /// The authentication response containing tokens when successful. + public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.RegisterAsync(context.Message.Email, context.Message.Password, cancellationToken); + /// Executes the login request. public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) { - var result = await identityService.LoginAsync(context.Message.Email, context.Message.Password, context.Message.TwoFactorCode, context.Message.TwoFactorRecoveryCode, cancellationToken); + LoginCommand message = context.Message; + TokenResponse? result = message.TenantId is null ? await identityService.LoginAsync(message.Email, message.Password, message.TwoFactorCode, message.TwoFactorRecoveryCode, cancellationToken) : await identityService.LoginAsync(message.TenantId, message.Email, message.Password, message.TwoFactorCode, message.TwoFactorRecoveryCode, cancellationToken); return result is null ? Response.Failure(["Invalid credentials."]) : Response.Success(result); } - /// Executes a refresh-token exchange. - /// The message context containing the refresh token. - /// The token used to cancel the operation. - /// The refreshed token response. + /// Executes a tenant-bound refresh-token exchange. public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) { - var result = await identityService.RefreshAsync(context.Message.RefreshToken, cancellationToken); + RefreshTokenCommand message = context.Message; + TokenResponse? result = message.TenantId is null ? await identityService.RefreshAsync(message.RefreshToken, cancellationToken) : await identityService.RefreshAsync(message.TenantId, message.RefreshToken, cancellationToken); return result is null ? Response.Failure(["Invalid refresh token."]) : Response.Success(result); } /// Executes token revocation. - /// The message context containing the access token. - /// The token used to cancel the operation. - /// A response indicating whether revocation succeeded. - public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) => - Response.Success(await identityService.RevokeAsync(context.Message.AccessToken, cancellationToken)); - + public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) => Response.Success(await identityService.RevokeAsync(context.Message.AccessToken, cancellationToken)); /// Executes email confirmation. - /// The message context containing the confirmation request. - /// The token used to cancel the operation. - /// A response indicating whether confirmation succeeded. - public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) => - Response.Success(await identityService.ConfirmEmailAsync(context.Message.UserId, context.Message.Code, context.Message.ChangedEmail, cancellationToken)); - + public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) => Response.Success(await identityService.ConfirmEmailAsync(context.Message.UserId, context.Message.Code, context.Message.ChangedEmail, cancellationToken)); /// Executes a confirmation-email resend request. - /// The message context containing the email address. - /// The token used to cancel the operation. - /// The resend result. - public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => - identityService.ResendConfirmationEmailAsync(context.Message.Email, cancellationToken); - + public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.ResendConfirmationEmailAsync(context.Message.Email, cancellationToken); /// Executes a password-recovery request. - /// The message context containing the email address. - /// The token used to cancel the operation. - /// The recovery result. - public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => - identityService.ForgotPasswordAsync(context.Message.Email, cancellationToken); - + public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.ForgotPasswordAsync(context.Message.Email, cancellationToken); /// Executes a password reset. - /// The message context containing the reset request. - /// The token used to cancel the operation. - /// The reset result. - public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => - identityService.ResetPasswordAsync(context.Message.Email, context.Message.ResetCode, context.Message.NewPassword, cancellationToken); - + public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.ResetPasswordAsync(context.Message.Email, context.Message.ResetCode, context.Message.NewPassword, cancellationToken); /// Executes an identity information query. - /// The message context containing the user identifier. - /// The token used to cancel the operation. - /// The identity information response. public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) { - var result = await identityService.GetInfoAsync(context.Message.UserId, cancellationToken); + IdentityInfoResponse? result = await identityService.GetInfoAsync(context.Message.UserId, cancellationToken); return result is null ? Response.Failure(["User not found."]) : Response.Success(result); } - /// Executes an identity information update. - /// The message context containing the update request. - /// The token used to cancel the operation. - /// The update result. - public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => - identityService.UpdateInfoAsync(context.Message.UserId, context.Message.NewEmail, context.Message.NewPassword, context.Message.OldPassword, cancellationToken); - + public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.UpdateInfoAsync(context.Message.UserId, context.Message.NewEmail, context.Message.NewPassword, context.Message.OldPassword, cancellationToken); /// Executes two-factor configuration. - /// The message context containing the configuration request. - /// The token used to cancel the operation. - /// The resulting two-factor configuration. public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) { - var result = await identityService.ConfigureTwoFactorAsync(context.Message.UserId, context.Message.Enable, context.Message.TwoFactorCode, context.Message.ResetRecoveryCodes, context.Message.ResetSharedKey, context.Message.ForgetMachine, cancellationToken); + TwoFactorResponse? result = await identityService.ConfigureTwoFactorAsync(context.Message.UserId, context.Message.Enable, context.Message.TwoFactorCode, context.Message.ResetRecoveryCodes, context.Message.ResetSharedKey, context.Message.ForgetMachine, cancellationToken); return result is null ? Response.Failure(["The two-factor configuration request is invalid."]) : Response.Success(result); } - /// Reads the current setup status. - /// The message context containing the query. - /// The token used to cancel the operation. - /// The current setup status. - public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => - identityService.GetSetupStatusAsync(cancellationToken); - - + public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.GetSetupStatusAsync(cancellationToken); /// Executes first-time setup initialization. - /// The message context containing setup credentials. - /// The token used to cancel the operation. - /// The setup result. - public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => - identityService.InitializeSetupAsync(context.Message.Email, context.Message.Password, cancellationToken); + public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.InitializeSetupAsync(context.Message.Email, context.Message.Password, cancellationToken); } diff --git a/src/server/RemoteTickets.Application/Identity/IIdentityService.cs b/src/server/RemoteTickets.Application/Identity/IIdentityService.cs index be2c2e8..b263b25 100644 --- a/src/server/RemoteTickets.Application/Identity/IIdentityService.cs +++ b/src/server/RemoteTickets.Application/Identity/IIdentityService.cs @@ -4,96 +4,35 @@ namespace RemoteTickets.Application.Identity; public interface IIdentityService { /// Registers a user and starts email confirmation. - /// The user's email address. - /// The user's password. - /// The token used to cancel the operation. - /// The registration result. Task RegisterAsync(string email, string password, CancellationToken cancellationToken); - /// Authenticates a user and issues JWT tokens when all required factors are valid. - /// The user's email address. - /// The user's password. - /// The authenticator code, when two-factor authentication is enabled. - /// The recovery code, when a recovery code is used instead of an authenticator code. - /// The token used to cancel the operation. - /// The token pair, or when authentication fails. Task LoginAsync(string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken); - + /// Authenticates a user against a tenant route and rejects users assigned to another tenant. + Task LoginAsync(string tenantId, string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken); + /// Creates the first administrator account for a tenant in the central identity store. + Task CreateTenantAdminAsync(string tenantId, string email, string password, CancellationToken cancellationToken); /// Exchanges a valid refresh token for a new token pair. - /// The refresh token to exchange. - /// The token used to cancel the operation. - /// The new token pair, or when the refresh token is invalid. Task RefreshAsync(string refreshToken, CancellationToken cancellationToken); - + /// Exchanges a refresh token while enforcing its tenant route binding. + Task RefreshAsync(string tenantId, string refreshToken, CancellationToken cancellationToken); /// Revokes an access token until its natural expiration. - /// The access token to revoke. - /// The token used to cancel the operation. - /// when revocation succeeds; otherwise . Task RevokeAsync(string accessToken, CancellationToken cancellationToken); - /// Confirms a user's email address or a changed email address. - /// The user's identifier. - /// The confirmation token. - /// The replacement email address when confirming an email change. - /// The token used to cancel the operation. - /// when confirmation succeeds; otherwise . Task ConfirmEmailAsync(string userId, string code, string? changedEmail, CancellationToken cancellationToken); - /// Resends email confirmation when required. - /// The user's email address. - /// The token used to cancel the operation. - /// The resend operation result. Task ResendConfirmationEmailAsync(string email, CancellationToken cancellationToken); - /// Starts password recovery for a password-bearing user. - /// The user's email address. - /// The token used to cancel the operation. - /// A result that does not reveal whether an email is registered. Task ForgotPasswordAsync(string email, CancellationToken cancellationToken); - /// Resets a user's password with a valid reset token. - /// The user's email address. - /// The password reset token. - /// The replacement password. - /// The token used to cancel the operation. - /// The reset operation result. Task ResetPasswordAsync(string email, string resetCode, string newPassword, CancellationToken cancellationToken); - /// Gets basic identity information for a user. - /// The user's identifier. - /// The token used to cancel the operation. - /// The identity information, or when the user does not exist. Task GetInfoAsync(string userId, CancellationToken cancellationToken); - /// Updates identity information after validating the current password. - /// The user's identifier. - /// The replacement email address, or to keep the current address. - /// The replacement password, or to keep the current password. - /// The current password. - /// The token used to cancel the operation. - /// The update operation result. Task UpdateInfoAsync(string userId, string? newEmail, string? newPassword, string oldPassword, CancellationToken cancellationToken); - /// Configures authenticator-based two-factor authentication and recovery material. - /// The user's identifier. - /// Whether to enable or disable two-factor authentication. - /// The authenticator code used to validate enabling two-factor authentication. - /// Whether recovery codes should be regenerated. - /// Whether the authenticator shared key should be regenerated. - /// Whether the current machine should be forgotten. - /// The token used to cancel the operation. - /// The current configuration, or when the operation cannot be completed. Task ConfigureTwoFactorAsync(string userId, bool? enable, string? twoFactorCode, bool resetRecoveryCodes, bool resetSharedKey, bool forgetMachine, CancellationToken cancellationToken); - /// Gets the current first-time setup status. - /// The token used to cancel the operation. - /// The setup status. Task GetSetupStatusAsync(CancellationToken cancellationToken); - /// Creates the initial administrator account when setup has not yet completed. - /// The administrator email address. - /// The administrator password. - /// The token used to cancel the operation. - /// The setup operation result. Task InitializeSetupAsync(string email, string password, CancellationToken cancellationToken); } diff --git a/src/server/RemoteTickets.Application/Identity/Requests/IdentityRequests.cs b/src/server/RemoteTickets.Application/Identity/Requests/IdentityRequests.cs index 8a79fef..d3fccec 100644 --- a/src/server/RemoteTickets.Application/Identity/Requests/IdentityRequests.cs +++ b/src/server/RemoteTickets.Application/Identity/Requests/IdentityRequests.cs @@ -10,74 +10,47 @@ public sealed record RegisterCommand(string Email, string Password) : IRequest; /// The user's password. /// The authenticator code, when required. /// The recovery code, when used instead of an authenticator code. -public sealed record LoginCommand( - string Email, - string Password, - string? TwoFactorCode = null, - string? TwoFactorRecoveryCode = null) : IRequest; +/// The tenant route identifier. +public sealed record LoginCommand(string Email, string Password, string? TwoFactorCode = null, string? TwoFactorRecoveryCode = null, string? TenantId = null) : IRequest; /// Requests exchange of a refresh token. /// The refresh token to exchange. -public sealed record RefreshTokenCommand(string RefreshToken) : IRequest; +/// The tenant route identifier used to validate token scope. +public sealed record RefreshTokenCommand(string RefreshToken, string? TenantId = null) : IRequest; /// Requests revocation of an access token. /// The access token to revoke. public sealed record RevokeTokenCommand(string AccessToken) : IRequest; - /// Requests confirmation of a user's email address. /// The user identifier. /// The confirmation token. /// The replacement email address when confirming an email change. public sealed record ConfirmEmailCommand(string UserId, string Code, string? ChangedEmail = null) : IRequest; - /// Requests that an email confirmation link be sent again. /// The user's email address. public sealed record ResendConfirmationEmailCommand(string Email) : IRequest; - /// Starts password recovery for an email address. /// The email address to recover. public sealed record ForgotPasswordCommand(string Email) : IRequest; - /// Requests a password reset. /// The user's email address. /// The password reset token. /// The replacement password. public sealed record ResetPasswordCommand(string Email, string ResetCode, string NewPassword) : IRequest; - /// Requests basic identity information for a user. /// The user identifier. public sealed record GetIdentityInfoQuery(string UserId) : IRequest; - /// Requests an update to identity information. /// The user identifier. -/// The replacement email address, or to keep the current address. -/// The replacement password, or to keep the current password. -/// The current password used to authorize the change. -public sealed record UpdateIdentityInfoCommand( - string UserId, - string? NewEmail, - string? NewPassword, - string OldPassword) : IRequest; - +/// The replacement email address. +/// The replacement password. +/// The current password. +public sealed record UpdateIdentityInfoCommand(string UserId, string? NewEmail, string? NewPassword, string OldPassword) : IRequest; /// Requests a change to authenticator-based two-factor authentication. /// The user identifier. /// Whether to enable or disable two-factor authentication. -/// The authenticator code used when enabling two-factor authentication. +/// The authenticator code. /// Whether recovery codes should be regenerated. -/// Whether the authenticator shared key should be regenerated. -/// Whether the remembered machine state should be cleared. -public sealed record ConfigureTwoFactorCommand( - string UserId, - bool? Enable, - string? TwoFactorCode, - bool ResetRecoveryCodes, - bool ResetSharedKey, - bool ForgetMachine) : IRequest; - -/// Requests the current first-time setup status. -public sealed record GetSetupStatusQuery : IRequest; - -/// Requests creation of the initial administrator account. -/// The administrator email address. -/// The administrator password. -public sealed record InitializeSetupCommand(string Email, string Password) : IRequest; +/// Whether the shared key should be regenerated. +/// Whether remembered-machine state should be cleared. +public sealed record ConfigureTwoFactorCommand(string UserId, bool? Enable, string? TwoFactorCode, bool ResetRecoveryCodes, bool ResetSharedKey, bool ForgetMachine) : IRequest; diff --git a/src/server/RemoteTickets.Application/System/Handlers/SystemHandlers.cs b/src/server/RemoteTickets.Application/System/Handlers/SystemHandlers.cs new file mode 100644 index 0000000..d19000a --- /dev/null +++ b/src/server/RemoteTickets.Application/System/Handlers/SystemHandlers.cs @@ -0,0 +1,7 @@ +namespace RemoteTickets.Application.System.Handlers; + +public class SystemHandlers(ISystemService service) : IRequestHandler +{ + public Task Handle(IReceiveContext context, CancellationToken cancellationToken) + => service.PerformMasterDatabaseSetup(context.Message.Request.ConnectionString, context.Message.Request.CommandTimeout, cancellationToken); +} diff --git a/src/server/RemoteTickets.Application/System/Requests/SystemRequests.cs b/src/server/RemoteTickets.Application/System/Requests/SystemRequests.cs new file mode 100644 index 0000000..f9bc8d5 --- /dev/null +++ b/src/server/RemoteTickets.Application/System/Requests/SystemRequests.cs @@ -0,0 +1,17 @@ +namespace RemoteTickets.Application.System.Requests; + +/// Requests the current first-time setup status. +public sealed record GetSetupStatusQuery : IRequest; +/// Requests creation of the initial administrator account. +/// The administrator email address. +/// The administrator password. +public sealed record InitializeSetupCommand(string Email, string Password) : IRequest; + +/// Represents the command for perform setup of master database. +/// The request for this command. +public sealed record MasterDatabaseSetupCommand(MasterDatabaseSetupRequest Request) : IRequest; + +/// Response for a command. +/// The status of command execution. +/// Optional message about the result.. +public sealed record MasterDatabaseSetupResponse(bool Status, string Message) : IResponse; diff --git a/src/server/RemoteTickets.Application/System/Validators/ConnectionStringPropertyValidator.cs b/src/server/RemoteTickets.Application/System/Validators/ConnectionStringPropertyValidator.cs new file mode 100644 index 0000000..13d70d8 --- /dev/null +++ b/src/server/RemoteTickets.Application/System/Validators/ConnectionStringPropertyValidator.cs @@ -0,0 +1,42 @@ +namespace RemoteTickets.Application.System.Validators; + +public class ConnectionStringPropertyValidator : PropertyValidator +{ + private readonly HashSet _allowedKeys; + + public ConnectionStringPropertyValidator(IEnumerable allowedKeys) + { + _allowedKeys = new HashSet(allowedKeys, StringComparer.OrdinalIgnoreCase); + } + + public override string Name => "ConnectionStringValidator"; + + public override bool IsValid(ValidationContext context, string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) { return true; } + + try + { + // The DbConnectionStringBuilder parses any generic connection string + var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + + foreach (string key in builder.Keys) + { + if (!_allowedKeys.Contains(key)) + { + context.MessageFormatter.AppendArgument("InvalidKey", key); + return false; + } + } + return true; + } + catch (Exception) + { + return false; + } + } + + protected override string GetDefaultMessageTemplate(string errorCode) + => "The connection string have an not supported property: '{InvalidKey}'"; +} + \ No newline at end of file diff --git a/src/server/RemoteTickets.Application/System/Validators/ConnectionStringValidationExtensions.cs b/src/server/RemoteTickets.Application/System/Validators/ConnectionStringValidationExtensions.cs new file mode 100644 index 0000000..3ab6ba3 --- /dev/null +++ b/src/server/RemoteTickets.Application/System/Validators/ConnectionStringValidationExtensions.cs @@ -0,0 +1,16 @@ +namespace RemoteTickets.Application.System.Validators; + +public static class ConnectionStringValidationExtensions +{ + /// + /// Extension method to be used into ab AbstractValidator to validate connection strings. + /// + /// Rule Builder from abstract validator + /// Allowed keys of connections string according to desired db engine. + /// an instance to be validated + /// + public static IRuleBuilderOptions IsValidConnectionString( + this IRuleBuilder ruleBuilder, IEnumerable allowedKeys) + => ruleBuilder.SetValidator( + new ConnectionStringPropertyValidator(allowedKeys)); +} diff --git a/src/server/RemoteTickets.Application/System/Validators/MasterDatabaseSetupCommandValidator..cs b/src/server/RemoteTickets.Application/System/Validators/MasterDatabaseSetupCommandValidator..cs new file mode 100644 index 0000000..04f89d1 --- /dev/null +++ b/src/server/RemoteTickets.Application/System/Validators/MasterDatabaseSetupCommandValidator..cs @@ -0,0 +1,34 @@ +namespace RemoteTickets.Application.System.Validators; + +public class MasterDatabaseSetupCommandValidator : AbstractValidator +{ + private static readonly string[] SqlServerKeys = + { + "Data Source", "Server", "Initial Catalog", "Database", + "Integrated Security", "User ID", "Password", "Encrypt", + "TrustServerCertificate", "Connection Timeout" + }; + public MasterDatabaseSetupCommandValidator() + { + RuleFor(x => x) + .NotNull() + .NotEmpty() + .WithErrorCode("BAD_REQUEST") + .WithMessage("The request cannot be null or empty."); + + RuleFor(x => x.Request) + .NotNull() + .NotEmpty() + .WithErrorCode("BAD_REQUEST") + .WithMessage("The request data cannot be null or empty."); + + RuleFor(x => x.Request.ConnectionString) + .NotNull() + .NotEmpty() + .WithErrorCode("UNPROCESSABLE_ENTITY") + .WithMessage("The connection string cannot be null or empty") + .IsValidConnectionString(SqlServerKeys) + .WithErrorCode("UNPROCESSABLE_ENTITY") + .WithMessage("The connection string isn't valid"); + } +} diff --git a/src/server/RemoteTickets.Application/Tenant/Handlers/TenantHandlers.cs b/src/server/RemoteTickets.Application/Tenant/Handlers/TenantHandlers.cs new file mode 100644 index 0000000..10bfd14 --- /dev/null +++ b/src/server/RemoteTickets.Application/Tenant/Handlers/TenantHandlers.cs @@ -0,0 +1,33 @@ +namespace RemoteTickets.Application.Tenant.Handlers; + +/// +/// Class that implement all Tenant queries and commands handlers +/// +/// An injected instance. +public class TenantHandlers(ITenantManagementService service) : +IRequestHandler, +IRequestHandler, +IRequestHandler, +IRequestHandler +{ + public async Task Handle( + IReceiveContext context, + CancellationToken cancellationToken) + => await service.GetSetupStatusAsync( + context.Message.TenantId, + cancellationToken); + public async Task Handle( + IReceiveContext context, + CancellationToken cancellationToken) + => await service.CompleteSetupAsync( + context.Message.TenantId, + cancellationToken); + public async Task Handle( + IReceiveContext context, + CancellationToken cancellationToken) + => await service.CreateAsync(context.Message.ToRequest() , cancellationToken); + public async Task Handle( + IReceiveContext context, + CancellationToken cancellationToken) + => await service.GetAsync(context.Message.TenantId, cancellationToken); +} \ No newline at end of file diff --git a/src/server/RemoteTickets.Application/Tenant/Requests/TenantRequests.cs b/src/server/RemoteTickets.Application/Tenant/Requests/TenantRequests.cs new file mode 100644 index 0000000..9deb8d2 --- /dev/null +++ b/src/server/RemoteTickets.Application/Tenant/Requests/TenantRequests.cs @@ -0,0 +1,32 @@ +namespace RemoteTickets.Application.Tenant.Requests; + +/// Query to retrieve tenant setup status +/// Id of tenant to retrieve setup status +public sealed record GetTenantSetupStatusQuery(string TenantId) : IRequest; + +/// Command to complete tenant setup +/// Id of tenant to complete setup +public sealed record CompleteTenantSetupCommand(string TenantId) : IRequest; + +/// Represents a command to provision a tenant and its first administrator. +/// The tenant display name. +/// The SQL Server database name. +/// The connection string for the tenant database. +/// The first tenant administrator email. +/// The first tenant administrator password. +public sealed record CreateTenantCommand(string Name, string DatabaseName, string ConnectionString, string AdminEmail, string AdminPassword) : IRequest +{ + public CreateTenantRequest ToRequest() + => new (null!,(string.IsNullOrEmpty(Name) || string.IsNullOrWhiteSpace(Name) ? throw new ArgumentNullException(nameof(Name)) : Name), + (string.IsNullOrEmpty(DatabaseName) || string.IsNullOrWhiteSpace(DatabaseName)? throw new ArgumentNullException(nameof(DatabaseName)) : DatabaseName), + (string.IsNullOrEmpty(ConnectionString) || string.IsNullOrWhiteSpace(ConnectionString)? throw new ArgumentNullException(nameof(ConnectionString)) : ConnectionString), + (string.IsNullOrEmpty(AdminEmail) || string.IsNullOrWhiteSpace(AdminEmail)? throw new ArgumentNullException(nameof(AdminEmail)) : AdminEmail), + (string.IsNullOrEmpty(AdminPassword) || string.IsNullOrWhiteSpace(AdminPassword)? throw new ArgumentNullException(nameof(AdminPassword)) : AdminPassword)); +} + +/// Query to retrieve an tenant by id +/// The id of tenant to retrieve. +public sealed record GetTenantQuery(string TenantId) : IRequest; + +/// Query to retrieve all tenant +public sealed record GetTenantsQuery() : IRequest; \ No newline at end of file diff --git a/src/server/RemoteTickets.Composition/GlobalUsings.cs b/src/server/RemoteTickets.Composition/GlobalUsings.cs index 4b9d898..a49c65f 100644 --- a/src/server/RemoteTickets.Composition/GlobalUsings.cs +++ b/src/server/RemoteTickets.Composition/GlobalUsings.cs @@ -5,12 +5,17 @@ global using System.Threading.Tasks; global using FluentValidation; global using RemoteTickets.Application.Common.Identity; +global using RemoteTickets.Application.Common.Tenancy; +global using RemoteTickets.Application.Common.System; global using RemoteTickets.Application.Identity; global using RemoteTickets.Application.Identity.Handlers; global using RemoteTickets.Application.Identity.Validators; global using RemoteTickets.Application.Pipeline.Validation; global using RemoteTickets.Infrastructure.Persistence; +global using RemoteTickets.Infrastructure.Identity.Models; global using RemoteTickets.Infrastructure.Identity.Services; +global using RemoteTickets.Infrastructure.Setup; +global using RemoteTickets.Infrastructure.Tenancy; global using Mediator.Net.MicrosoftDependencyInjection; global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.AspNetCore.Builder; @@ -21,3 +26,4 @@ global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; global using Microsoft.IdentityModel.Tokens; +global using MudBlazor.Services; \ No newline at end of file diff --git a/src/server/RemoteTickets.Composition/InvisibleSPCompositionExtensions.cs b/src/server/RemoteTickets.Composition/InvisibleSPCompositionExtensions.cs deleted file mode 100644 index 17b6292..0000000 --- a/src/server/RemoteTickets.Composition/InvisibleSPCompositionExtensions.cs +++ /dev/null @@ -1,140 +0,0 @@ -namespace RemoteTickets.Composition; - -/// Provides extension methods that compose RemoteTickets application services and HTTP middleware. -public static class RemoteTicketsCompositionExtensions -{ - extension (WebApplicationBuilder builder) - { - /// Registers all application, persistence, identity, validation, authentication, and authorization services. - /// The same application builder for fluent composition. - /// Thrown when required JWT configuration is missing or its secret key is too short. - public WebApplicationBuilder AddRemoteTickets() - { - var services = builder.Services; - var configuration = builder.Configuration; - - services.AddRazorComponents() - .AddInteractiveServerComponents(); - services.AddControllers(); - - services.Configure(configuration.GetSection(JwtOptions.SectionName)); - - services.AddDbContext(options => - options.UseSqlServer(configuration.GetConnectionString("Identity"))); - - services.AddIdentityCore(options => - { - options.User.RequireUniqueEmail = true; - options.SignIn.RequireConfirmedEmail = false; - options.Password.RequiredLength = 8; - options.Password.RequireDigit = true; - options.Password.RequireUppercase = true; - options.Password.RequireLowercase = true; - options.Password.RequireNonAlphanumeric = true; - options.Lockout.MaxFailedAccessAttempts = 5; - options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); - }).AddRoles() - .AddEntityFrameworkStores() - .AddSignInManager() - .AddDefaultTokenProviders(); - - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddValidatorsFromAssemblyContaining(); - - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - var jwt = configuration.GetSection(JwtOptions.SectionName).Get() - ?? throw new InvalidOperationException("JWT configuration is missing."); - - if (Encoding.UTF8.GetByteCount(jwt.SecretKey) < 32) - { - throw new InvalidOperationException("Authentication:Jwt:SecretKey must contain at least 256 bits."); - } - - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SecretKey)), - ValidateIssuer = true, - ValidIssuer = jwt.Issuer, - ValidateAudience = true, - ValidAudience = jwt.Audience, - ValidateLifetime = true, - ClockSkew = TimeSpan.FromSeconds(30) - }; - - options.Events = new JwtBearerEvents - { - OnTokenValidated = context => - { - var token = context.SecurityToken as JwtSecurityToken; - var tokenType = token?.Claims.FirstOrDefault(x => x.Type == JwtRegisteredClaimNames.Typ)?.Value; - if (!string.Equals(tokenType, "access", StringComparison.Ordinal)) - { - context.Fail("The supplied token is not an access token."); - return Task.CompletedTask; - } - - if (token is not null && context.HttpContext.RequestServices.GetRequiredService().IsRevoked(token.Id)) - { - context.Fail("The supplied token has been revoked."); - } - - return Task.CompletedTask; - } - }; - }); - - services.AddAuthorizationBuilder() - .AddPolicy(IdentityPolicies.Administrator, policy => - policy.RequireClaim(IdentityClaimTypes.Permission, AdministratorPermission)); - var mb = new Mediator.Net.MediatorBuilder(); - mb.RegisterHandlers(typeof(IdentityHandlers).Assembly) - .ConfigureCommandReceivePipe(pipe => pipe.UseValidation()) - .ConfigureRequestPipe(pipe => pipe.UseValidation()); - - services.RegisterMediator(mb); - - return builder; - } - } - - extension (WebApplication app) - { - /// Configures middleware, authentication, authorization, controllers, static assets, and Blazor endpoints. - /// The Blazor root component used by the application. - /// The same web application for fluent startup composition. - public WebApplication UseRemoteTickets() where TApp : IComponent - { - if (!app.Environment.IsDevelopment()) - { - app.UseExceptionHandler("/Error", createScopeForErrors: true); - app.UseHsts(); - } - else - { - app.UseDeveloperExceptionPage(); - } - - app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); - app.UseHttpsRedirection(); - app.UseAuthentication(); - app.UseAuthorization(); - app.UseAntiforgery(); - - app.MapControllers(); - app.MapStaticAssets(); - app.MapRazorComponents() - .AddInteractiveServerRenderMode(); - - return app; - } - } - - private const string AdministratorPermission = "system.admin"; -} diff --git a/src/server/RemoteTickets.Composition/RemoteTickets.Composition.csproj b/src/server/RemoteTickets.Composition/RemoteTickets.Composition.csproj index 7cc596d..c26ee4a 100644 --- a/src/server/RemoteTickets.Composition/RemoteTickets.Composition.csproj +++ b/src/server/RemoteTickets.Composition/RemoteTickets.Composition.csproj @@ -1,5 +1,8 @@ + + + diff --git a/src/server/RemoteTickets.Composition/WebApplicationBuilderExtensions.cs b/src/server/RemoteTickets.Composition/WebApplicationBuilderExtensions.cs new file mode 100644 index 0000000..48c265e --- /dev/null +++ b/src/server/RemoteTickets.Composition/WebApplicationBuilderExtensions.cs @@ -0,0 +1,119 @@ +namespace RemoteTickets.Composition; + +/// Provides extension methods that compose RemoteTickets application services and HTTP middleware. +public static class WebApplicationBuilderExtensions +{ + extension (WebApplicationBuilder builder) + { + /// Registers all application, persistence, identity, validation, authentication, authorization, and tenant services. + /// The same application builder for fluent composition. + /// Thrown when required JWT configuration is missing or its secret key is too short. + public async Task RunRemoteTicketsAsync() where T : IComponent + { + IServiceCollection services = builder.Services; + ConfigurationManager configuration = builder.Configuration; + + services.AddRazorComponents() + .AddInteractiveServerComponents(); + services.AddMudServices(); + services.AddControllers(); + services.AddHttpClient(); + services.AddHttpContextAccessor(); + services.Configure(configuration.GetSection(JwtOptions.SectionName)); + services.AddSingleton(); + services.AddDbContext((sp, options) => + { + ISetupConfigurationStore setup = sp.GetRequiredService(); + string connectionString = setup.GetMasterConnectionString() ?? configuration.GetConnectionString("Identity") ?? configuration.GetConnectionString("Master") ?? ""; + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException("A master database connection string is required."); + } + + options.UseSqlServer(connectionString); + }); + + services.AddIdentityCore(options => + { + options.User.RequireUniqueEmail = true; + options.SignIn.RequireConfirmedEmail = false; + options.Password.RequiredLength = 8; + options.Password.RequireDigit = true; + options.Password.RequireUppercase = true; + options.Password.RequireLowercase = true; + options.Password.RequireNonAlphanumeric = true; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + }) + .AddRoles() + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddValidatorsFromAssemblyContaining(); + + _ = services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => + { + JwtOptions jwt = configuration.GetSection(JwtOptions.SectionName).Get() ?? throw new InvalidOperationException("JWT configuration is missing."); + if (Encoding.UTF8.GetByteCount(jwt.SecretKey) < 32) + { + throw new InvalidOperationException("Authentication:Jwt:SecretKey must contain at least 256 bits."); + } + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SecretKey)), + ValidateIssuer = true, + ValidIssuer = jwt.Issuer, + ValidateAudience = true, + ValidAudience = jwt.Audience, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30) + }; + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + var token = context.SecurityToken as JwtSecurityToken; + string? tokenType = token?.Claims.FirstOrDefault(x => x.Type == JwtRegisteredClaimNames.Typ)?.Value; + if (!string.Equals(tokenType, JwtTokenTypes.Access, StringComparison.Ordinal)) + { + context.Fail("The supplied token is not an access token."); + return Task.CompletedTask; + } + if (token is not null && context.HttpContext.RequestServices.GetRequiredService().IsRevoked(token.Id)) + { + context.Fail("The supplied token has been revoked."); + } + + return Task.CompletedTask; + } + }; + }); + + services.AddAuthorizationBuilder() + .AddPolicy(TenantPolicies.SysAdmin, policy => policy.RequireRole(TenantRoles.SysAdmin)) + .AddPolicy(TenantPolicies.TenantAdmin, policy => policy.RequireRole(TenantRoles.SysAdmin, TenantRoles.TenantAdmin)) + .AddPolicy(TenantPolicies.TenantOperator, policy => policy.RequireRole(TenantRoles.SysAdmin, TenantRoles.TenantAdmin, TenantRoles.TenantOperator)) + .AddPolicy(TenantPolicies.TenantAttendant, policy => policy.RequireRole(TenantRoles.SysAdmin, TenantRoles.TenantAdmin, TenantRoles.TenantOperator, TenantRoles.TenantAttendant)) + .AddPolicy(IdentityPolicies.Administrator, policy => policy.RequireRole(TenantRoles.SysAdmin)); + + var mb = new Mediator.Net.MediatorBuilder(); + mb.RegisterHandlers(typeof(IdentityHandlers).Assembly) + .ConfigureCommandReceivePipe(pipe => pipe.UseValidation()) + .ConfigureRequestPipe(pipe => pipe.UseValidation()); + services.RegisterMediator(mb); + + await builder.Build().RunRemoteTickets(); + } + } +} diff --git a/src/server/RemoteTickets.Composition/WebApplicationExtensions.cs b/src/server/RemoteTickets.Composition/WebApplicationExtensions.cs new file mode 100644 index 0000000..0ab8e93 --- /dev/null +++ b/src/server/RemoteTickets.Composition/WebApplicationExtensions.cs @@ -0,0 +1,37 @@ +namespace RemoteTickets.Composition; + +/// Provides extension methods that compose RemoteTickets application services and HTTP middleware. +public static class WebApplicationExtensions +{ + extension (WebApplication app) + { + /// Configures middleware, authentication, tenant isolation, authorization, controllers, static assets, and Blazor endpoints. + /// The Blazor root component used by the application. + /// The same web application for fluent startup composition. + public async Task RunRemoteTickets() where TApp : IComponent + { + if (!app.Environment.IsDevelopment()) + { + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); + } + else + { + app.UseDeveloperExceptionPage(); + } + + app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); + app.UseHttpsRedirection(); + app.UseRouting(); + app.UseAuthentication(); + app.UseMiddleware(); + app.UseAuthorization(); + app.UseAntiforgery(); + app.MapControllers(); + app.MapStaticAssets(); + app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + await app.RunAsync(); + } + } +} diff --git a/src/server/RemoteTickets.Domain/Common/EntityAuditable.cs b/src/server/RemoteTickets.Domain/Common/EntityAuditable.cs new file mode 100644 index 0000000..8ecd62b --- /dev/null +++ b/src/server/RemoteTickets.Domain/Common/EntityAuditable.cs @@ -0,0 +1,28 @@ +namespace RemoteTickets.Domain.Common; + +/// Defines creation metadata that is persisted with an auditable entity. +public interface IEntityAuditable +{ + /// Gets the UTC timestamp at which the entity was created. + DateTimeOffset CreatedAt { get; init; } + + /// Gets the identifier of the actor that created the entity. + string CreatedBy { get; init; } +} + +/// Defines the state transition recorded for an auditable entity. +/// The auditable entity contract represented by the history record. +public interface IEntityUpdateHistory where T : IEntityAuditable +{ + /// Gets the serialized state after the operation. + string CurrentEntityState { get; init; } + + /// Gets the serialized state before the operation. + string PreviousEntityState { get; init; } + + /// Gets the UTC timestamp at which the state transition was recorded. + DateTimeOffset UpdatedAt { get; init; } + + /// Gets or sets the identifier of the actor that caused the state transition. + string UpdatedBy { get; set; } +} diff --git a/src/server/RemoteTickets.Domain/Common/EntityBase.cs b/src/server/RemoteTickets.Domain/Common/EntityBase.cs new file mode 100644 index 0000000..4bf6c13 --- /dev/null +++ b/src/server/RemoteTickets.Domain/Common/EntityBase.cs @@ -0,0 +1,15 @@ +namespace RemoteTickets.Domain.Common; + +/// Interface for abstrace base entity +public interface IEntityBase +{ + string Id { get; set; } +} + +/// Abstract base entity for all entities tracked by DbContext. +public abstract class EntityBase(string id = "") : IEntityBase +{ + public string Id { get; set; } = id == "" ? Guid.CreateVersion7().ToString() : id; +} + + \ No newline at end of file diff --git a/src/server/RemoteTickets.Infrastructure/GlobalUsings.cs b/src/server/RemoteTickets.Infrastructure/GlobalUsings.cs index 5d848bd..11d1d41 100644 --- a/src/server/RemoteTickets.Infrastructure/GlobalUsings.cs +++ b/src/server/RemoteTickets.Infrastructure/GlobalUsings.cs @@ -2,19 +2,35 @@ global using System.Collections.Concurrent; global using System.Collections.Generic; global using System.IdentityModel.Tokens.Jwt; +global using System.IO; global using System.Linq; global using System.Linq.Expressions; global using System.Security.Claims; global using System.Text; +global using System.Text.Json; global using System.Threading; global using System.Threading.Tasks; global using RemoteTickets.Application.Common.Identity; +global using RemoteTickets.Application.Common.Tenancy; +global using RemoteTickets.Application.Common.System; +global using RemoteTickets.Application.Common.Util; +global using RemoteTickets.Application.System.Requests; global using RemoteTickets.Application.Identity; global using RemoteTickets.Domain.Common; global using RemoteTickets.Infrastructure.Identity.Models; +global using RemoteTickets.Infrastructure.Persistence; +global using RemoteTickets.Infrastructure.Persistence.Auditing; +global using RemoteTickets.Infrastructure.Setup; +global using RemoteTickets.Infrastructure.Tenancy.Configurations; +global using RemoteTickets.Infrastructure.Tenancy.Models; +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Identity; global using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +global using Microsoft.Data.SqlClient; global using Microsoft.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore.ChangeTracking; +global using Microsoft.EntityFrameworkCore.Metadata.Builders; global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Options; global using Microsoft.IdentityModel.Tokens; diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Models/Role.cs b/src/server/RemoteTickets.Infrastructure/Identity/Models/Role.cs index 04b9c22..bc8b490 100644 --- a/src/server/RemoteTickets.Infrastructure/Identity/Models/Role.cs +++ b/src/server/RemoteTickets.Infrastructure/Identity/Models/Role.cs @@ -1,21 +1,21 @@ namespace RemoteTickets.Infrastructure.Identity.Models; -/// -/// Represents a role in the RemoteTickets application. -/// -public class Role : IdentityRole, ISoftDeletable + +/// Represents a role in the RemoteTickets application. +public class Role : IdentityRole, ISoftDeletable, IEntityAuditable { /// Indicates whether the role is deleted. public bool IsDeleted { get; set; } /// Gets or sets the date and time when the role was deleted. public DateTimeOffset? DeletedAt { get; set; } - /// Role parameterless constructor. - public Role() : base() - { - Id = Guid.CreateVersion7().ToString(); - } - /// Role constructor. - public Role(string name) : base(name) - { - Id = Guid.CreateVersion7().ToString(); - } + /// + public DateTimeOffset CreatedAt { get; init; } + /// + public string CreatedBy { get; init; } = string.Empty; + + /// Initializes a role with a new identifier. + public Role() : base() => Id = Guid.CreateVersion7().ToString(); + + /// Initializes a role with the supplied name. + /// The role name. + public Role(string name) : base(name) => Id = Guid.CreateVersion7().ToString(); } diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Models/User.cs b/src/server/RemoteTickets.Infrastructure/Identity/Models/User.cs index 6670520..68783fd 100644 --- a/src/server/RemoteTickets.Infrastructure/Identity/Models/User.cs +++ b/src/server/RemoteTickets.Infrastructure/Identity/Models/User.cs @@ -1,9 +1,7 @@ namespace RemoteTickets.Infrastructure.Identity.Models; -/// -/// Represents a user in the RemoteTickets application. -/// -public class User : IdentityUser, ISoftDeletable +/// Represents a user in the RemoteTickets application. +public class User : IdentityUser, ISoftDeletable, IEntityAuditable { /// User name displayed in the application. public string? DisplayName { get; set; } = string.Empty; @@ -11,21 +9,21 @@ public class User : IdentityUser, ISoftDeletable public string? FirstName { get; set; } = string.Empty; /// Surname of the user. public string? SurName { get; set; } = string.Empty; + /// Gets or sets the tenant to which the user belongs. A null value identifies a system administrator. + public string? TenantId { get; set; } + /// + public DateTimeOffset CreatedAt { get; init; } + /// + public string CreatedBy { get; init; } = string.Empty; /// Indicates whether the user is deleted. - public bool IsDeleted { get; set; } + public bool IsDeleted { get; set; } /// Gets or sets the date and time when the user was deleted. - public DateTimeOffset? DeletedAt { get; set; } = null!; + public DateTimeOffset? DeletedAt { get; set; } - /// - /// Initializes a new instance of the class. - /// - public User() : base() - { - Id = Guid.CreateVersion7().ToString(); - } - /// - public User(string name) : base(name) - { - Id = Guid.CreateVersion7().ToString(); - } + /// Initializes a new instance of the class. + public User() : base() => Id = Guid.CreateVersion7().ToString(); + + /// Initializes a new user with the supplied user name. + /// The user name. + public User(string name) : base(name) => Id = Guid.CreateVersion7().ToString(); } diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Refresh.cs b/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Refresh.cs new file mode 100644 index 0000000..fee94a7 --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Refresh.cs @@ -0,0 +1,57 @@ +namespace RemoteTickets.Infrastructure.Identity.Services; + +/// Provides tenant-bound refresh-token handling for . +public sealed partial class IdentityService +{ + /// + public async Task RefreshAsync(string tenantId, string refreshToken, CancellationToken cancellationToken) + { + ClaimsPrincipal? principal = tokenService.ValidateToken(refreshToken); + if (principal is null || !string.Equals(principal.FindFirstValue("token_type"), JwtTokenTypes.Refresh, StringComparison.Ordinal)) + { + return null; + } + + string[] roles = principal.FindAll(ClaimTypes.Role).Select(x => x.Value).ToArray(); + if (!roles.Contains(TenantRoles.SysAdmin, StringComparer.OrdinalIgnoreCase) && !string.Equals(principal.FindFirst(TenantClaimTypes.TenantId)?.Value, tenantId, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + string? userId = principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + { + return null; + } + + User? user = await userManager.FindByIdAsync(userId); + if (user is null) + { + return null; + } + + IList claims = await userManager.GetClaimsAsync(user); + foreach (string? roleName in roles) + { + Role? role = await roleManager.FindByNameAsync(roleName); + if (role is not null) + { + claims = claims.Concat(await roleManager.GetClaimsAsync(role)).ToList(); + } + } + if (user.TenantId is not null) + { + claims = claims.Append(new Claim(TenantClaimTypes.TenantId, user.TenantId)).ToList(); + } + + string? tokenId = tokenService.GetTokenId(refreshToken); + DateTimeOffset? expiration = tokenService.GetExpiration(refreshToken); + TokenResponse tokens = tokenService.CreateTokens(user.Id, user.Email ?? user.UserName ?? user.Id, roles, claims); + if (!string.IsNullOrWhiteSpace(tokenId) && expiration.HasValue) + { + revokedTokenStore.Revoke(tokenId, expiration.Value); + } + + return tokens; + } +} diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Tenancy.cs b/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Tenancy.cs index cb518a1..c13dbb7 100644 --- a/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Tenancy.cs +++ b/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Tenancy.cs @@ -4,63 +4,106 @@ namespace RemoteTickets.Infrastructure.Identity.Services; public sealed partial class IdentityService { /// - public async Task LoginAsync(string tenantId, string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken) + public async Task LoginAsync( + string tenantId, + string email, + string password, + string? twoFactorCode, + string? twoFactorRecoveryCode, + CancellationToken cancellationToken) { - var user = await userManager.FindByEmailAsync(email); - if (user is null) return null; - var roles = await userManager.GetRolesAsync(user); - var sysAdmin = roles.Contains(TenantRoles.SysAdmin, StringComparer.OrdinalIgnoreCase); - if (!sysAdmin && !string.Equals(user.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) return null; - var passwordResult = await signInManager.CheckPasswordSignInAsync(user, password, true); - if (passwordResult.IsLockedOut || passwordResult.IsNotAllowed || !passwordResult.Succeeded) return null; + User? user = await userManager.FindByEmailAsync(email); + if (user is null || (user.TenantId is not null && !string.Equals(user.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) && !await userManager.IsInRoleAsync(user, TenantRoles.SysAdmin))) + { + return null; + } + + if (user.TenantId is null && !await userManager.IsInRoleAsync(user, TenantRoles.SysAdmin)) + { + return null; + } + + if (user.TenantId is not null && !string.Equals(user.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) && !await userManager.IsInRoleAsync(user, TenantRoles.SysAdmin)) + { + return null; + } + + SignInResult passwordResult = await signInManager.CheckPasswordSignInAsync(user, password, true); + if (passwordResult.IsLockedOut || passwordResult.IsNotAllowed || !passwordResult.Succeeded) + { + return null; + } + if (await userManager.GetTwoFactorEnabledAsync(user)) { - var valid = !string.IsNullOrWhiteSpace(twoFactorCode) + bool valid = !string.IsNullOrWhiteSpace(twoFactorCode) ? await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, twoFactorCode) : !string.IsNullOrWhiteSpace(twoFactorRecoveryCode) && (await userManager.RedeemTwoFactorRecoveryCodeAsync(user, twoFactorRecoveryCode)).Succeeded; - if (!valid) return null; + if (!valid) + { + return null; + } + } + + IList roles = await userManager.GetRolesAsync(user); + IList claims = await userManager.GetClaimsAsync(user); + foreach (string roleName in roles) + { + Role? role = await roleManager.FindByNameAsync(roleName); + if (role is not null) + { + claims = claims.Concat(await roleManager.GetClaimsAsync(role)).ToList(); + } } - var claims = await GetTokenClaimsAsync(user, roles); + + if (!roles.Contains(TenantRoles.SysAdmin, StringComparer.OrdinalIgnoreCase)) + { + claims = claims.Append(new Claim(TenantClaimTypes.TenantId, tenantId)).ToList(); + } + else if (user.TenantId is not null) + { + claims = claims.Append(new Claim(TenantClaimTypes.TenantId, user.TenantId)).ToList(); + } + return tokenService.CreateTokens(user.Id, user.Email ?? user.UserName ?? user.Id, roles, claims); } /// public async Task CreateTenantAdminAsync(string tenantId, string email, string password, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(tenantId)) return IdentityResultResponse.Failure(["A tenant identifier is required."]); - if (await userManager.FindByEmailAsync(email) is not null) return IdentityResultResponse.Failure(["The email address is already registered."]); - var role = await roleManager.FindByNameAsync(TenantRoles.TenantAdmin); - if (role is null) + if (string.IsNullOrWhiteSpace(tenantId)) { - var roleResult = await roleManager.CreateAsync(new Role(TenantRoles.TenantAdmin)); - if (!roleResult.Succeeded) return IdentityResultResponse.Failure(roleResult.Errors.Select(x => x.Description)); + return IdentityResultResponse.Failure(["A tenant identifier is required."]); } - var user = new User(email) { Email = email, EmailConfirmed = true, TenantId = tenantId }; - var userResult = await userManager.CreateAsync(user, password); - if (!userResult.Succeeded) return IdentityResultResponse.Failure(userResult.Errors.Select(x => x.Description)); - var membership = await userManager.AddToRoleAsync(user, TenantRoles.TenantAdmin); - return membership.Succeeded ? IdentityResultResponse.Success() : IdentityResultResponse.Failure(membership.Errors.Select(x => x.Description)); - } - /// - public async Task RefreshAsync(string tenantId, string refreshToken, CancellationToken cancellationToken) - { - var principal = tokenService.ValidateToken(refreshToken); - if (principal is null || !string.Equals(principal.FindFirstValue("token_type"), JwtTokenTypes.Refresh, StringComparison.Ordinal)) return null; - var roles = principal.FindAll(ClaimTypes.Role).Select(x => x.Value).ToArray(); - if (!roles.Contains(TenantRoles.SysAdmin, StringComparer.OrdinalIgnoreCase) && !string.Equals(principal.FindFirst(TenantClaimTypes.TenantId)?.Value, tenantId, StringComparison.OrdinalIgnoreCase)) return null; - return await RefreshAsync(refreshToken, cancellationToken); - } + if (await userManager.FindByEmailAsync(email) is not null) + { + return IdentityResultResponse.Failure(["The email address is already registered."]); + } - private async Task> GetTokenClaimsAsync(User user, IReadOnlyCollection roles) - { - var claims = (await userManager.GetClaimsAsync(user)).ToList(); - foreach (var roleName in roles) + Role? role = await roleManager.FindByNameAsync(TenantRoles.TenantAdmin); + if (role is null) { - var role = await roleManager.FindByNameAsync(roleName); - if (role is not null) claims.AddRange(await roleManager.GetClaimsAsync(role)); + IdentityResult roleResult = await roleManager.CreateAsync(new Role(TenantRoles.TenantAdmin)); + if (!roleResult.Succeeded) + { + return IdentityResultResponse.Failure(roleResult.Errors.Select(x => x.Description)); + } } - if (user.TenantId is not null) claims.Add(new Claim(TenantClaimTypes.TenantId, user.TenantId)); - return claims; + + var user = new User(email) + { + Email = email, + EmailConfirmed = true, + TenantId = tenantId + }; + IdentityResult userResult = await userManager.CreateAsync(user, password); + if (!userResult.Succeeded) + { + return IdentityResultResponse.Failure(userResult.Errors.Select(x => x.Description)); + } + + IdentityResult membership = await userManager.AddToRoleAsync(user, TenantRoles.TenantAdmin); + return membership.Succeeded ? IdentityResultResponse.Success() : IdentityResultResponse.Failure(membership.Errors.Select(x => x.Description)); } } diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.cs b/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.cs index 464f47d..d1f27c2 100644 --- a/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.cs +++ b/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.cs @@ -10,74 +10,44 @@ namespace RemoteTickets.Infrastructure.Identity.Services; /// The store used to track revoked tokens. /// The service used to send identity-related email messages. /// The logger used to record operational identity events. -public sealed class IdentityService( +public sealed partial class IdentityService( UserManager userManager, RoleManager roleManager, SignInManager signInManager, IJwtTokenService tokenService, IRevokedTokenStore revokedTokenStore, IIdentityEmailSender emailSender, + ISetupConfigurationStore configurationStore, ILogger logger) : IIdentityService { private const string AdministratorRole = "Administrator"; private const string AdministratorPermission = "system.admin"; - /// - /// Registers a new user and sends an email confirmation link. - /// - /// The email address of the user. - /// The password to assign to the user. - /// The token used to cancel the operation. - /// The result of the registration operation. + /// Registers a new user and sends an email confirmation link. public async Task RegisterAsync(string email, string password, CancellationToken cancellationToken) { - var user = new User(email) - { - Email = email, - DisplayName = "", - FirstName = "", - SurName = "", - EmailConfirmed = false - }; - - var result = await userManager.CreateAsync(user, password); + var user = new User(email) { Email = email, DisplayName = "", FirstName = "", SurName = "", EmailConfirmed = false }; + IdentityResult result = await userManager.CreateAsync(user, password); if (!result.Succeeded) { return Failure(result); } - var code = await userManager.GenerateEmailConfirmationTokenAsync(user); - await emailSender.SendConfirmationAsync( - email, - $"/confirmEmail?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(code)}", - cancellationToken); - + string code = await userManager.GenerateEmailConfirmationTokenAsync(user); + await emailSender.SendConfirmationAsync(email, $"/confirmEmail?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(code)}", cancellationToken); return IdentityResultResponse.Success(); } - /// - /// Authenticates a user and issues JWT access and refresh tokens when all required authentication factors are valid. - /// - /// The user's email address. - /// The user's password. - /// The authenticator application code, when two-factor authentication is enabled. - /// The recovery code, when two-factor authentication is enabled and a recovery code is used. - /// The token used to cancel the operation. - /// The issued tokens, or when authentication fails. - public async Task LoginAsync( - string email, - string password, - string? twoFactorCode, - string? twoFactorRecoveryCode, - CancellationToken cancellationToken) + /// Authenticates a user and issues JWT access and refresh tokens when all required authentication factors are valid. + public async Task LoginAsync(string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken) { - var user = await userManager.FindByEmailAsync(email); + User? user = await userManager.FindByEmailAsync(email); if (user is null) { return null; } - var passwordResult = await signInManager.CheckPasswordSignInAsync(user, password, true); + SignInResult passwordResult = await signInManager.CheckPasswordSignInAsync(user, password, true); if (passwordResult.IsLockedOut || passwordResult.IsNotAllowed || !passwordResult.Succeeded) { return null; @@ -85,58 +55,41 @@ await emailSender.SendConfirmationAsync( if (await userManager.GetTwoFactorEnabledAsync(user)) { - var valid = !string.IsNullOrWhiteSpace(twoFactorCode) - ? await userManager.VerifyTwoFactorTokenAsync( - user, - userManager.Options.Tokens.AuthenticatorTokenProvider, - twoFactorCode) - : !string.IsNullOrWhiteSpace(twoFactorRecoveryCode) && - (await userManager.RedeemTwoFactorRecoveryCodeAsync(user, twoFactorRecoveryCode)).Succeeded; - + bool valid = !string.IsNullOrWhiteSpace(twoFactorCode) + ? await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, twoFactorCode) + : !string.IsNullOrWhiteSpace(twoFactorRecoveryCode) && (await userManager.RedeemTwoFactorRecoveryCodeAsync(user, twoFactorRecoveryCode)).Succeeded; if (!valid) { return null; } } - return await CreateUserTokensAsync(user, cancellationToken); } - /// - /// Exchanges a valid refresh token for a new access and refresh token pair and revokes the previous refresh token. - /// - /// The refresh token to exchange. - /// The token used to cancel the operation. - /// The new token pair, or when the refresh token is invalid. + /// Exchanges a valid refresh token for a new access and refresh token pair and revokes the previous refresh token. public async Task RefreshAsync(string refreshToken, CancellationToken cancellationToken) { - var principal = tokenService.ValidateToken(refreshToken); + ClaimsPrincipal? principal = tokenService.ValidateToken(refreshToken); if (principal is null || !string.Equals(principal.FindFirstValue("token_type"), JwtTokenTypes.Refresh, StringComparison.Ordinal)) { return null; } - var userId = principal.FindFirstValue(JwtRegisteredClaimNames.Sub); - if (string.IsNullOrWhiteSpace(userId)) - { - userId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - } - + string? userId = principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? principal.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userId)) { return null; } - var user = await userManager.FindByIdAsync(userId); + User? user = await userManager.FindByIdAsync(userId); if (user is null) { return null; } - var tokenId = tokenService.GetTokenId(refreshToken); - var expiration = tokenService.GetExpiration(refreshToken); - var tokens = await CreateUserTokensAsync(user, cancellationToken); - + string? tokenId = tokenService.GetTokenId(refreshToken); + DateTimeOffset? expiration = tokenService.GetExpiration(refreshToken); + TokenResponse tokens = await CreateUserTokensAsync(user, cancellationToken); if (!string.IsNullOrWhiteSpace(tokenId) && expiration.HasValue) { revokedTokenStore.Revoke(tokenId, expiration.Value); @@ -145,18 +98,12 @@ await emailSender.SendConfirmationAsync( return tokens; } - /// - /// Revokes a valid access token until its natural expiration. - /// - /// The access token to revoke. - /// The token used to cancel the operation. - /// when the token was revoked; otherwise, . + /// Revokes a valid access token until its natural expiration. public Task RevokeAsync(string accessToken, CancellationToken cancellationToken) { - var principal = tokenService.ValidateToken(accessToken); - var tokenId = tokenService.GetTokenId(accessToken); - var expiration = tokenService.GetExpiration(accessToken); - + ClaimsPrincipal? principal = tokenService.ValidateToken(accessToken); + string? tokenId = tokenService.GetTokenId(accessToken); + DateTimeOffset? expiration = tokenService.GetExpiration(accessToken); if (principal is null || string.IsNullOrWhiteSpace(tokenId) || !expiration.HasValue) { return Task.FromResult(false); @@ -166,121 +113,71 @@ public Task RevokeAsync(string accessToken, CancellationToken cancellation return Task.FromResult(true); } - /// - /// Confirms a user's email address or confirms a changed email address using a supplied Identity token. - /// - /// The identifier of the user. - /// The confirmation token. - /// The new email address when confirming an email change; otherwise . - /// The token used to cancel the operation. - /// when confirmation succeeds; otherwise, . + /// Confirms a user's email address or confirms a changed email address using a supplied Identity token. public async Task ConfirmEmailAsync(string userId, string code, string? changedEmail, CancellationToken cancellationToken) { - var user = await userManager.FindByIdAsync(userId); + User? user = await userManager.FindByIdAsync(userId); if (user is null) { return false; } - var result = !string.IsNullOrWhiteSpace(changedEmail) - ? await userManager.ChangeEmailAsync(user, changedEmail, code) - : await userManager.ConfirmEmailAsync(user, code); - + IdentityResult result = !string.IsNullOrWhiteSpace(changedEmail) ? await userManager.ChangeEmailAsync(user, changedEmail, code) : await userManager.ConfirmEmailAsync(user, code); return result.Succeeded; } - /// - /// Resends an email confirmation link when the specified user exists and remains unconfirmed. - /// - /// The email address of the user. - /// The token used to cancel the operation. - /// The result of the resend operation. + /// Resends an email confirmation link when the specified user exists and remains unconfirmed. public async Task ResendConfirmationEmailAsync(string email, CancellationToken cancellationToken) { - var user = await userManager.FindByEmailAsync(email); + User? user = await userManager.FindByEmailAsync(email); if (user is null || await userManager.IsEmailConfirmedAsync(user)) { return IdentityResultResponse.Success(); } - var code = await userManager.GenerateEmailConfirmationTokenAsync(user); - await emailSender.SendConfirmationAsync( - email, - $"/confirmEmail?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(code)}", - cancellationToken); - + string code = await userManager.GenerateEmailConfirmationTokenAsync(user); + await emailSender.SendConfirmationAsync(email, $"/confirmEmail?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(code)}", cancellationToken); return IdentityResultResponse.Success(); } - /// - /// Starts a password recovery operation for a user with a password. - /// - /// The email address of the user. - /// The token used to cancel the operation. - /// A successful result whether or not a matching password-bearing user exists. + /// Starts a password recovery operation for a user with a password. public async Task ForgotPasswordAsync(string email, CancellationToken cancellationToken) { - var user = await userManager.FindByEmailAsync(email); + User? user = await userManager.FindByEmailAsync(email); if (user is null || !await userManager.HasPasswordAsync(user)) { return IdentityResultResponse.Success(); } - var code = await userManager.GeneratePasswordResetTokenAsync(user); - await emailSender.SendPasswordResetAsync( - email, - $"/resetPassword?email={Uri.EscapeDataString(email)}&code={Uri.EscapeDataString(code)}", - cancellationToken); - + string code = await userManager.GeneratePasswordResetTokenAsync(user); + await emailSender.SendPasswordResetAsync(email, $"/resetPassword?email={Uri.EscapeDataString(email)}&code={Uri.EscapeDataString(code)}", cancellationToken); return IdentityResultResponse.Success(); } - /// - /// Resets a user's password using a valid password-reset token. - /// - /// The email address of the user. - /// The password-reset token. - /// The new password. - /// The token used to cancel the operation. - /// The result of the password reset operation. + /// Resets a user's password using a valid password-reset token. public async Task ResetPasswordAsync(string email, string resetCode, string newPassword, CancellationToken cancellationToken) { - var user = await userManager.FindByEmailAsync(email); + User? user = await userManager.FindByEmailAsync(email); if (user is null) { return IdentityResultResponse.Failure(["Invalid password reset request."]); } - var result = await userManager.ResetPasswordAsync(user, resetCode, newPassword); + IdentityResult result = await userManager.ResetPasswordAsync(user, resetCode, newPassword); return result.Succeeded ? IdentityResultResponse.Success() : Failure(result); } - /// - /// Gets basic identity information for a user. - /// - /// The identifier of the user. - /// The token used to cancel the operation. - /// The user's identity information, or when the user does not exist. + /// Gets basic identity information for a user. public async Task GetInfoAsync(string userId, CancellationToken cancellationToken) { - var user = await userManager.FindByIdAsync(userId); - return user is null - ? null - : new IdentityInfoResponse(user.Email ?? string.Empty, await userManager.IsEmailConfirmedAsync(user)); + User? user = await userManager.FindByIdAsync(userId); + return user is null ? null : new IdentityInfoResponse(user.Email ?? string.Empty, await userManager.IsEmailConfirmedAsync(user)); } - /// - /// Updates a user's email address and password after validating the current password. - /// - /// The identifier of the user. - /// The new email address, or to leave it unchanged. - /// The new password, or to leave it unchanged. - /// The current password. - /// The token used to cancel the operation. - /// The result of the update operation. + /// Updates a user's email address and password after validating the current password. public async Task UpdateInfoAsync(string userId, string? newEmail, string? newPassword, string oldPassword, CancellationToken cancellationToken) { - var user = await userManager.FindByIdAsync(userId); + User? user = await userManager.FindByIdAsync(userId); if (user is null || !await userManager.CheckPasswordAsync(user, oldPassword)) { return IdentityResultResponse.Failure(["The current credentials are invalid."]); @@ -288,44 +185,29 @@ public async Task UpdateInfoAsync(string userId, string? if (!string.IsNullOrWhiteSpace(newEmail) && !string.Equals(user.Email, newEmail, StringComparison.OrdinalIgnoreCase)) { - var emailCode = await userManager.GenerateChangeEmailTokenAsync(user, newEmail); - var emailResult = await userManager.ChangeEmailAsync(user, newEmail, emailCode); + IdentityResult emailResult = await userManager.ChangeEmailAsync(user, newEmail, await userManager.GenerateChangeEmailTokenAsync(user, newEmail)); if (!emailResult.Succeeded) { return Failure(emailResult); } } - if (!string.IsNullOrWhiteSpace(newPassword)) { - var passwordResult = await userManager.ChangePasswordAsync(user, oldPassword, newPassword); + IdentityResult passwordResult = await userManager.ChangePasswordAsync(user, oldPassword, newPassword); if (!passwordResult.Succeeded) { return Failure(passwordResult); } } - return IdentityResultResponse.Success(); } - /// - /// Configures authenticator-based two-factor authentication and optionally rotates its recovery material. - /// - /// The identifier of the user. - /// Whether to enable or disable two-factor authentication. - /// The authenticator code used to validate enabling two-factor authentication. - /// Whether new recovery codes should be generated. - /// Whether the authenticator shared key should be regenerated. - /// Whether the current machine should be forgotten; reserved for parity with the Identity API contract. - /// The token used to cancel the operation. - /// The current two-factor configuration, or when the user does not exist or an enable operation fails. + /// Configures authenticator-based two-factor authentication and optionally rotates its recovery material. public async Task ConfigureTwoFactorAsync(string userId, bool? enable, string? twoFactorCode, bool resetRecoveryCodes, bool resetSharedKey, bool forgetMachine, CancellationToken cancellationToken) { - var user = await userManager.FindByIdAsync(userId); - if (user is null) - { - return null; - } + User user = await userManager.FindByIdAsync(userId) ?? null!; + + if (user == null) { return null; } if (enable == true) { @@ -333,7 +215,6 @@ public async Task UpdateInfoAsync(string userId, string? { return null; } - await userManager.SetTwoFactorEnabledAsync(user, true); } else if (enable == false || resetSharedKey) @@ -352,35 +233,19 @@ public async Task UpdateInfoAsync(string userId, string? recoveryCodes = (await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10))?.ToArray(); } - var key = await userManager.GetAuthenticatorKeyAsync(user); - if (string.IsNullOrWhiteSpace(key)) - { - await userManager.ResetAuthenticatorKeyAsync(user); - key = await userManager.GetAuthenticatorKeyAsync(user); - } - - var recoveryCodesLeft = await userManager.CountRecoveryCodesAsync(user); - return new TwoFactorResponse(key, recoveryCodesLeft, recoveryCodes, await userManager.GetTwoFactorEnabledAsync(user), false); + string? key = await userManager.GetAuthenticatorKeyAsync(user); + if (string.IsNullOrWhiteSpace(key)) { await userManager.ResetAuthenticatorKeyAsync(user); key = await userManager.GetAuthenticatorKeyAsync(user); } + return new TwoFactorResponse(key, await userManager.CountRecoveryCodesAsync(user), recoveryCodes, await userManager.GetTwoFactorEnabledAsync(user), false); } - /// - /// Gets whether initial system setup is required or has already been completed. - /// - /// The token used to cancel the operation. - /// The current setup status. + /// Gets whether initial system setup is required or has already been completed. public async Task GetSetupStatusAsync(CancellationToken cancellationToken) { - var hasUsers = await userManager.Users.AsNoTracking().AnyAsync(cancellationToken); + bool hasUsers = await userManager.Users.AsNoTracking().AnyAsync(cancellationToken); return new SetupStatusResponse(!hasUsers, hasUsers); } - /// - /// Creates the initial administrator account when system setup has not yet been completed. - /// - /// The administrator email address. - /// The administrator password. - /// The token used to cancel the operation. - /// The result of the setup operation. + /// Creates the initial system administrator account when setup has not yet been completed. public async Task InitializeSetupAsync(string email, string password, CancellationToken cancellationToken) { if (await userManager.Users.AsNoTracking().AnyAsync(cancellationToken)) @@ -388,93 +253,107 @@ public async Task InitializeSetupAsync(string email, str return IdentityResultResponse.Failure(["The system setup has already been completed."]); } - var role = await roleManager.FindByNameAsync(AdministratorRole); + await EnsureRoleAsync(TenantRoles.SysAdmin, cancellationToken); + await EnsureRoleAsync(TenantRoles.TenantAdmin, cancellationToken); + await EnsureRoleAsync(TenantRoles.TenantOperator, cancellationToken); + await EnsureRoleAsync(TenantRoles.TenantAttendant, cancellationToken); + Role? role = await roleManager.FindByNameAsync(AdministratorRole); if (role is null) { role = new Role(AdministratorRole); - var roleResult = await roleManager.CreateAsync(role); + IdentityResult roleResult = await roleManager.CreateAsync(role); if (!roleResult.Succeeded) { return Failure(roleResult); } - var claimResult = await roleManager.AddClaimAsync(role, new Claim(IdentityClaimTypes.Permission, AdministratorPermission)); + IdentityResult claimResult = await roleManager.AddClaimAsync(role, new Claim(IdentityClaimTypes.Permission, AdministratorPermission)); if (!claimResult.Succeeded) { return Failure(claimResult); } } - - var user = new User(email) - { - Email = email, - EmailConfirmed = true - }; - - var userResult = await userManager.CreateAsync(user, password); + var user = new User(email) { Email = email, EmailConfirmed = true }; + IdentityResult userResult = await userManager.CreateAsync(user, password); if (!userResult.Succeeded) { return Failure(userResult); } - var membershipResult = await userManager.AddToRoleAsync(user, AdministratorRole); + IdentityResult membershipResult = await userManager.AddToRoleAsync(user, TenantRoles.SysAdmin); + if (!membershipResult.Succeeded) + { + return Failure(membershipResult); + } + + membershipResult = await userManager.AddToRoleAsync(user, AdministratorRole); if (!membershipResult.Succeeded) { return Failure(membershipResult); } + string? connectionString = configurationStore.GetMasterConnectionString(); + if (!string.IsNullOrWhiteSpace(connectionString)) + { + DbContextOptions options = new DbContextOptionsBuilder().UseSqlServer(connectionString).Options; + await using var dbContext = new RemoteTicketsDbContext(options); + SystemSetupState? state = await dbContext.SystemSetup.SingleOrDefaultAsync(x => x.Id == RemoteTicketsConstants.SystemSetupId, cancellationToken); + if (state is null) + { + state = new SystemSetupState { Id = RemoteTicketsConstants.SystemSetupId }; + dbContext.SystemSetup.Add(state); + } + state.IsComplete = true; + state.CompletedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + } logger.LogInformation("Initial system setup completed for user {UserId}.", user.Id); return IdentityResultResponse.Success(); } private async Task CreateUserTokensAsync(User user, CancellationToken cancellationToken) { - var roles = await userManager.GetRolesAsync(user); - var claims = await userManager.GetClaimsAsync(user); - - foreach (var roleName in roles) + IList roles = await userManager.GetRolesAsync(user); + IList claims = await userManager.GetClaimsAsync(user); + foreach (string roleName in roles) { - var role = await roleManager.FindByNameAsync(roleName); - if (role is null) + Role? role = await roleManager.FindByNameAsync(roleName); + if (role is not null) { - continue; + claims = claims.Concat(await roleManager.GetClaimsAsync(role)).ToList(); } + } + return tokenService.CreateTokens(user.Id, user.Email ?? user.UserName ?? user.Id, roles, claims); + } - claims = claims.Concat(await roleManager.GetClaimsAsync(role)).ToList(); + private async Task EnsureRoleAsync(string roleName, CancellationToken cancellationToken) + { + if (await roleManager.RoleExistsAsync(roleName)) + { + return; } - return tokenService.CreateTokens(user.Id, user.Email ?? user.UserName ?? user.Id, roles, claims); + IdentityResult result = await roleManager.CreateAsync(new Role(roleName)); + if (!result.Succeeded) + { + throw new InvalidOperationException(string.Join(" ", result.Errors.Select(x => x.Description))); + } } private static IdentityResultResponse Failure(IdentityResult result) => IdentityResultResponse.Failure(result.Errors.Select(error => error.Description)); } -/// -/// Logs identity email messages instead of delivering them through an external email provider. -/// -/// The logger used to record email messages. +/// Logs identity email messages instead of delivering them through an external email provider. public sealed class LoggingIdentityEmailSender(ILogger logger) : IIdentityEmailSender { - /// - /// Logs an email confirmation message. - /// - /// The recipient email address. - /// The confirmation link. - /// The token used to cancel the operation. - /// A completed task. + /// Logs an email confirmation message. public Task SendConfirmationAsync(string email, string confirmationLink, CancellationToken cancellationToken) { logger.LogInformation("Identity confirmation link for {Email}: {Link}", email, confirmationLink); return Task.CompletedTask; } - /// - /// Logs a password reset message. - /// - /// The recipient email address. - /// The password reset link. - /// The token used to cancel the operation. - /// A completed task. + /// Logs a password reset message. public Task SendPasswordResetAsync(string email, string resetLink, CancellationToken cancellationToken) { logger.LogInformation("Identity password reset link for {Email}: {Link}", email, resetLink); diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Services/JwtTokenService.cs b/src/server/RemoteTickets.Infrastructure/Identity/Services/JwtTokenService.cs index ed649df..de64d58 100644 --- a/src/server/RemoteTickets.Infrastructure/Identity/Services/JwtTokenService.cs +++ b/src/server/RemoteTickets.Infrastructure/Identity/Services/JwtTokenService.cs @@ -10,15 +10,11 @@ public sealed class JwtTokenService(IOptions options, IRevokedTokenS /// public TokenResponse CreateTokens(string userId, string email, IEnumerable roles, IEnumerable claims) { - var now = DateTimeOffset.UtcNow; - var access = CreateToken(userId, email, roles, claims, JwtTokenTypes.Access, now, _options.AccessTokenLifetime); - var refresh = CreateToken(userId, email, roles, Array.Empty(), JwtTokenTypes.Refresh, now, _options.RefreshTokenLifetime); - - return new TokenResponse( - "Bearer", - new JwtSecurityTokenHandler().WriteToken(access), - (int)_options.AccessTokenLifetime.TotalSeconds, - new JwtSecurityTokenHandler().WriteToken(refresh)); + DateTimeOffset now = DateTimeOffset.UtcNow; + Claim[] claimSet = claims.ToArray(); + JwtSecurityToken access = CreateToken(userId, email, roles, claimSet, JwtTokenTypes.Access, now, _options.AccessTokenLifetime); + JwtSecurityToken refresh = CreateToken(userId, email, roles, claimSet, JwtTokenTypes.Refresh, now, _options.RefreshTokenLifetime); + return new TokenResponse("Bearer", new JwtSecurityTokenHandler().WriteToken(access), (int)_options.AccessTokenLifetime.TotalSeconds, new JwtSecurityTokenHandler().WriteToken(refresh)); } /// @@ -40,39 +36,25 @@ public TokenResponse CreateTokens(string userId, string email, IEnumerable public string? GetTokenId(string token) { - try - { - return new JwtSecurityTokenHandler().ReadJwtToken(token).Id; - } - catch (ArgumentException) - { - return null; - } + try { return new JwtSecurityTokenHandler().ReadJwtToken(token).Id; } + catch (ArgumentException) { return null; } } /// @@ -80,23 +62,13 @@ public TokenResponse CreateTokens(string userId, string email, IEnumerable roles, - IEnumerable claims, - string tokenType, - DateTimeOffset issuedAt, - TimeSpan lifetime) + private JwtSecurityToken CreateToken(string userId, string email, IEnumerable roles, IEnumerable claims, string tokenType, DateTimeOffset issuedAt, TimeSpan lifetime) { var tokenClaims = new List { @@ -105,24 +77,14 @@ private JwtSecurityToken CreateToken( new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")), new("token_type", tokenType) }; - tokenClaims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); tokenClaims.AddRange(claims); - - var credentials = new SigningCredentials(CreateSecurityKey(), SecurityAlgorithms.HmacSha256); - - return new JwtSecurityToken( - issuer: _options.Issuer, - audience: _options.Audience, - claims: tokenClaims, - notBefore: issuedAt.UtcDateTime, - expires: issuedAt.Add(lifetime).UtcDateTime, - signingCredentials: credentials); + return new JwtSecurityToken(issuer: _options.Issuer, audience: _options.Audience, claims: tokenClaims, notBefore: issuedAt.UtcDateTime, expires: issuedAt.Add(lifetime).UtcDateTime, signingCredentials: new SigningCredentials(CreateSecurityKey(), SecurityAlgorithms.HmacSha256)); } private SymmetricSecurityKey CreateSecurityKey() { - var bytes = Encoding.UTF8.GetBytes(_options.SecretKey); + byte[] bytes = Encoding.UTF8.GetBytes(_options.SecretKey); if (bytes.Length < 32) { throw new InvalidOperationException("Authentication:Jwt:SecretKey must contain at least 256 bits."); @@ -136,11 +98,10 @@ private SymmetricSecurityKey CreateSecurityKey() public sealed class RevokedTokenStore : IRevokedTokenStore { private readonly ConcurrentDictionary _tokens = new(); - /// public bool IsRevoked(string tokenId) { - if (!_tokens.TryGetValue(tokenId, out var expiresAt)) + if (!_tokens.TryGetValue(tokenId, out DateTimeOffset expiresAt)) { return false; } @@ -153,7 +114,6 @@ public bool IsRevoked(string tokenId) _tokens.TryRemove(tokenId, out _); return false; } - /// public void Revoke(string tokenId, DateTimeOffset expiresAt) { diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/Auditing/EntityAuditRecord.cs b/src/server/RemoteTickets.Infrastructure/Persistence/Auditing/EntityAuditRecord.cs new file mode 100644 index 0000000..9e32646 --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Persistence/Auditing/EntityAuditRecord.cs @@ -0,0 +1,29 @@ +namespace RemoteTickets.Infrastructure.Persistence.Auditing; + +/// Represents a persisted state transition for an auditable entity. +public sealed class EntityAuditRecord : IEntityUpdateHistory +{ + /// Gets or sets the audit record identifier. + public string Id { get; set; } = Guid.CreateVersion7().ToString(); + + /// Gets or sets the CLR entity type name represented by the record. + public string EntityType { get; set; } = string.Empty; + + /// Gets or sets the identifier of the audited entity. + public string EntityId { get; set; } = string.Empty; + + /// Gets or sets the operation that caused the audit entry. + public string Operation { get; set; } = string.Empty; + + /// + public string CurrentEntityState { get; init; } = string.Empty; + + /// + public string PreviousEntityState { get; init; } = string.Empty; + + /// + public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow; + + /// + public string UpdatedBy { get; set; } = string.Empty; +} diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Auditing/EntityAuditRecordConfiguration.cs b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Auditing/EntityAuditRecordConfiguration.cs new file mode 100644 index 0000000..370a682 --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Auditing/EntityAuditRecordConfiguration.cs @@ -0,0 +1,21 @@ +namespace RemoteTickets.Infrastructure.Persistence.Configurations.Auditing; + +/// Configures the SQL representation of persisted entity audit history. +public sealed class EntityAuditRecordConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("EntityHistory", "audit"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).HasMaxLength(36).IsRequired(); + builder.Property(x => x.EntityType).HasMaxLength(256).IsRequired(); + builder.Property(x => x.EntityId).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Operation).HasMaxLength(32).IsRequired(); + builder.Property(x => x.UpdatedBy).HasMaxLength(256).IsRequired(); + builder.Property(x => x.UpdatedAt).IsRequired(); + builder.Property(x => x.PreviousEntityState).IsRequired(); + builder.Property(x => x.CurrentEntityState).IsRequired(); + builder.HasIndex(x => new { x.EntityType, x.EntityId, x.UpdatedAt }); + } +} diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Identity/IdentityConfigurations.cs b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Identity/IdentityConfigurations.cs new file mode 100644 index 0000000..542e31f --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Identity/IdentityConfigurations.cs @@ -0,0 +1,25 @@ +namespace RemoteTickets.Infrastructure.Persistence.Configurations.Identity; + +internal sealed class IdentityConfigurations : +IEntityTypeConfiguration, +IEntityTypeConfiguration, +IEntityTypeConfiguration> +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Users"); + builder.HasKey(u => u.Id); + builder.HasIndex(u => new { u.Id, u.TenantId }) + .IsUnique() + .HasDatabaseName("IX_UQ_UID_TID"); + } + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Roles"); + builder.HasKey(r => r.Id); + } + + public void Configure(EntityTypeBuilder> builder) + => builder.ToTable("UserRoles"); +} diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Identity/IdentityrConfigurations.cs b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Identity/IdentityrConfigurations.cs deleted file mode 100644 index a478ef1..0000000 --- a/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Identity/IdentityrConfigurations.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace RemoteTickets.Infrastructure.Persistence.Configurations.Identity; - -internal sealed class IdentityrConfigurations : IEntityTypeConfiguration, IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("Users"); - } - - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("Roles"); - } -} diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Tenancy/TenancyConfigurations.cs b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Tenancy/TenancyConfigurations.cs new file mode 100644 index 0000000..fe59faf --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Tenancy/TenancyConfigurations.cs @@ -0,0 +1,23 @@ +namespace RemoteTickets.Infrastructure.Persistence.Configurations.Tenancy; + +internal sealed class TenancyConfigurations : +IEntityTypeConfiguration, +IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Tenants"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).HasMaxLength(128); + builder.Property(x => x.Name).HasMaxLength(256).IsRequired(); + builder.Property(x => x.DatabaseName).HasMaxLength(128).IsRequired(); + builder.Property(x => x.ConnectionString).IsRequired(); + builder.HasIndex(x => x.DatabaseName).IsUnique(); + } + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SystemSetupState"); + builder.HasKey(x => x.Id); + } +} diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/InvisibleSPDbContext.cs b/src/server/RemoteTickets.Infrastructure/Persistence/InvisibleSPDbContext.cs deleted file mode 100644 index ab37dd2..0000000 --- a/src/server/RemoteTickets.Infrastructure/Persistence/InvisibleSPDbContext.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Microsoft.EntityFrameworkCore.ChangeTracking; - -namespace RemoteTickets.Infrastructure.Persistence; - -/// -/// Represents the database context for the RemoteTickets application. -/// -public sealed class RemoteTicketsDbContext( - DbContextOptions options) : IdentityDbContext(options) -{ - /// - protected override void OnModelCreating(ModelBuilder builder) - { - base.OnModelCreating(builder); - - foreach (var entityType in builder.Model.GetEntityTypes()) - { - if (!typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType)) - { - continue; - } - - var parameter = Expression.Parameter(entityType.ClrType, "entity"); - var property = Expression.Property(parameter, nameof(ISoftDeletable.IsDeleted)); - var filter = Expression.Lambda(Expression.Not(property), parameter); - builder.Entity(entityType.ClrType).HasQueryFilter(filter); - } - builder.ApplyConfigurationsFromAssembly(typeof(RemoteTicketsDbContext).Assembly); - } - - /// - public override int SaveChanges(bool acceptAllChangesOnSuccess) - { - ApplySoftDelete(); - return base.SaveChanges(acceptAllChangesOnSuccess); - } - - /// - public override int SaveChanges() - { - ApplySoftDelete(); - return base.SaveChanges(); - } - - /// - public override Task SaveChangesAsync( - bool acceptAllChangesOnSuccess, - CancellationToken cancellationToken = default) - { - ApplySoftDelete(); - return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); - } - - /// - public override Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - ApplySoftDelete(); - return base.SaveChangesAsync(cancellationToken); - } - - private void ApplySoftDelete() - { - foreach (EntityEntry entry in ChangeTracker.Entries()) - { - if (entry.State != EntityState.Deleted) - { - continue; - } - - entry.State = EntityState.Modified; - entry.Entity.IsDeleted = true; - entry.Entity.DeletedAt = DateTimeOffset.UtcNow; - } - } -} diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/RemoteTicketsDbContext.cs b/src/server/RemoteTickets.Infrastructure/Persistence/RemoteTicketsDbContext.cs new file mode 100644 index 0000000..3dff1ec --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Persistence/RemoteTicketsDbContext.cs @@ -0,0 +1,159 @@ +using Microsoft.EntityFrameworkCore.Metadata; + +namespace RemoteTickets.Infrastructure.Persistence; + +/// Represents the central database context for RemoteTickets identity, setup, tenant catalog, and audit data. +public sealed class RemoteTicketsDbContext(DbContextOptions options, IHttpContextAccessor? httpContextAccessor = null) : IdentityDbContext(options) +{ + private readonly IHttpContextAccessor? _httpContextAccessor = httpContextAccessor; + + /// Gets the registered tenants. + public DbSet Tenants => Set(); + + /// Gets the singleton system setup state. + public DbSet SystemSetup => Set(); + + /// Gets the persisted entity state transitions stored in the audit schema. + public DbSet EntityAuditHistory => Set(); + + /// + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + foreach (IMutableEntityType entityType in builder.Model.GetEntityTypes()) + { + if (!typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType)) + { + continue; + } + + ParameterExpression parameter = Expression.Parameter(entityType.ClrType, "entity"); + MemberExpression property = Expression.Property(parameter, nameof(ISoftDeletable.IsDeleted)); + LambdaExpression filter = Expression.Lambda(Expression.Not(property), parameter); + builder.Entity(entityType.ClrType).HasQueryFilter(filter); + } + + builder.ApplyConfigurationsFromAssembly(typeof(RemoteTicketsDbContext).Assembly); + } + + /// + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + PreparePersistenceChanges(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + /// + public override int SaveChanges() + => SaveChanges(true); + + /// + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + PreparePersistenceChanges(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + /// + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + => SaveChangesAsync(true, cancellationToken); + + private void PreparePersistenceChanges() + { + ApplySoftDelete(); + ApplyCreationMetadata(); + AddAuditEntries(); + } + + private void ApplyCreationMetadata() + { + string actor = GetActor(); + DateTimeOffset now = DateTimeOffset.UtcNow; + + foreach (EntityEntry entry in ChangeTracker.Entries().Where(x => x.State == EntityState.Added)) + { + entry.Property(nameof(IEntityAuditable.CreatedAt)).CurrentValue = now; + entry.Property(nameof(IEntityAuditable.CreatedBy)).CurrentValue = actor; + } + } + + private void AddAuditEntries() + { + string actor = GetActor(); + DateTimeOffset now = DateTimeOffset.UtcNow; + List auditEntries = new(); + + foreach (EntityEntry entry in ChangeTracker.Entries().ToArray()) + { + if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) + { + continue; + } + + PropertyEntry? idProperty = entry.Properties.FirstOrDefault(x => string.Equals(x.Metadata.Name, "Id", StringComparison.OrdinalIgnoreCase)); + if (idProperty is null || idProperty.CurrentValue is null) + { + continue; + } + + string operation = entry.State switch + { + EntityState.Added => "Created", + EntityState.Modified when IsSoftDeleteTransition(entry) => "Deleted", + EntityState.Modified => "Updated", + EntityState.Deleted => "Deleted", + _ => throw new InvalidOperationException("Unsupported audit state.") + }; + + object previousState = entry.State == EntityState.Added ? new { } : entry.OriginalValues.ToObject(); + object currentState = entry.State == EntityState.Deleted ? entry.OriginalValues.ToObject() : entry.CurrentValues.ToObject(); + auditEntries.Add(new EntityAuditRecord + { + EntityType = entry.Metadata.ClrType.FullName ?? entry.Metadata.ClrType.Name, + EntityId = idProperty.CurrentValue.ToString() ?? string.Empty, + Operation = operation, + PreviousEntityState = JsonSerializer.Serialize(previousState), + CurrentEntityState = JsonSerializer.Serialize(currentState), + UpdatedAt = now, + UpdatedBy = actor + }); + } + + if (auditEntries.Count != 0) + { + EntityAuditHistory.AddRange(auditEntries); + } + } + + private static bool IsSoftDeleteTransition(EntityEntry entry) + { + if (!entry.Metadata.ClrType.GetInterfaces().Contains(typeof(ISoftDeletable))) + { + return false; + } + + PropertyEntry? deletedProperty = entry.Properties.FirstOrDefault(x => string.Equals(x.Metadata.Name, nameof(ISoftDeletable.IsDeleted), StringComparison.OrdinalIgnoreCase)); + return deletedProperty?.CurrentValue is true && deletedProperty.OriginalValue is false; + } + + private string GetActor() + => _httpContextAccessor?.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? _httpContextAccessor?.HttpContext?.User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? _httpContextAccessor?.HttpContext?.User.FindFirstValue(ClaimTypes.Email) + ?? "system"; + + private void ApplySoftDelete() + { + foreach (EntityEntry entry in ChangeTracker.Entries()) + { + if (entry.State != EntityState.Deleted) + { + continue; + } + + entry.State = EntityState.Modified; + entry.Entity.IsDeleted = true; + entry.Entity.DeletedAt = DateTimeOffset.UtcNow; + } + } +} diff --git a/src/server/RemoteTickets.Infrastructure/Setup/SetupConfigurationStore.cs b/src/server/RemoteTickets.Infrastructure/Setup/SetupConfigurationStore.cs new file mode 100644 index 0000000..87e1ece --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Setup/SetupConfigurationStore.cs @@ -0,0 +1,55 @@ +namespace RemoteTickets.Infrastructure.Setup; + +/// Persists the installation master-database connection string used by the tenant catalog. +public interface ISetupConfigurationStore +{ + /// Gets the configured master-database connection string. + /// The configured connection string, or when setup has not stored one. + string? GetMasterConnectionString(); + + /// Validates and persists the master-database connection string. + /// The SQL Server connection string. + /// The cancellation token. + /// A completed task when persistence succeeds. + Task SetMasterConnectionStringAsync(string connectionString, CancellationToken cancellationToken); +} + +/// Stores setup configuration in an application-local file until a dedicated secrets store is configured. +public sealed class SetupConfigurationStore : ISetupConfigurationStore +{ + private readonly string _path = Path.Combine(AppContext.BaseDirectory, "data", "setup.json"); + + /// + public string? GetMasterConnectionString() + { + if (!File.Exists(_path)) + { + return null; + } + + try + { + State? state = JsonSerializer.Deserialize(File.ReadAllText(_path)); + return state?.MasterConnectionString; + } + catch (JsonException) + { + return null; + } + } + + /// + public async Task SetMasterConnectionStringAsync(string connectionString, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException("A master database connection string is required.", nameof(connectionString)); + } + + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + string json = JsonSerializer.Serialize(new State(connectionString)); + await File.WriteAllTextAsync(_path, json, cancellationToken); + } + + private sealed record State(string MasterConnectionString); +} diff --git a/src/server/RemoteTickets.Infrastructure/Setup/SystemService.cs b/src/server/RemoteTickets.Infrastructure/Setup/SystemService.cs new file mode 100644 index 0000000..16b50b2 --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Setup/SystemService.cs @@ -0,0 +1,20 @@ +namespace RemoteTickets.Infrastructure.Setup; + +/// System service implementation. +public sealed class SystemService(ISetupConfigurationStore configurationStore) : ISystemService +{ + /// + public async Task PerformMasterDatabaseSetup(string connectionString, int commandTimeout, CancellationToken cancellationToken) + { + await configurationStore.SetMasterConnectionStringAsync(connectionString, cancellationToken); + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString, options => + { + options.CommandTimeout(commandTimeout); + }) + .Options; + await using RemoteTicketsDbContext dbContext = new(options); + bool status = await dbContext.Database.EnsureCreatedAsync(cancellationToken); + return new(status, status? "Master Database Successfully set up!" : "Master Database not set up!"); + } +} diff --git a/src/server/RemoteTickets.Infrastructure/Setup/SystemSetupState.cs b/src/server/RemoteTickets.Infrastructure/Setup/SystemSetupState.cs new file mode 100644 index 0000000..34a51be --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Setup/SystemSetupState.cs @@ -0,0 +1,16 @@ +namespace RemoteTickets.Infrastructure.Setup; + +/// Stores the singleton installation setup state in the master database. +public sealed class SystemSetupState : IEntityAuditable +{ + /// Gets or sets the singleton setup-state identifier. + public string Id { get; set; } = RemoteTicketsConstants.SystemSetupId; + /// Gets or sets whether the installation is ready for normal use. + public bool IsComplete { get; set; } + /// Gets or sets the completion timestamp. + public DateTimeOffset? CompletedAt { get; set; } + /// + public DateTimeOffset CreatedAt { get; init; } + /// + public string CreatedBy { get; init; } = string.Empty; +} diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/Configurations/TenancyConfigurations.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/Configurations/TenancyConfigurations.cs new file mode 100644 index 0000000..ee62419 --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Tenancy/Configurations/TenancyConfigurations.cs @@ -0,0 +1,13 @@ +namespace RemoteTickets.Infrastructure.Tenancy.Configurations; + +internal sealed class TenantSetupStateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TenantSetupState"); + builder.HasKey(x => x.Id); + builder.Property(x => x.IsComplete).IsRequired(); + } + + +} diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/Models/Tenant.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/Models/Tenant.cs new file mode 100644 index 0000000..2b18976 --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Tenancy/Models/Tenant.cs @@ -0,0 +1,26 @@ +namespace RemoteTickets.Infrastructure.Tenancy.Models; + +/// Represents a tenant registered in the installation catalog. +public sealed class Tenant : IEntityBase, ISoftDeletable, IEntityAuditable +{ + /// Gets or sets the stable tenant identifier used by routes and tokens. + public string Id { get; set; } = string.Empty; + /// Gets or sets the display name of the tenant. + public string Name { get; set; } = string.Empty; + /// Gets or sets the SQL Server database name assigned to the tenant. + public string DatabaseName { get; set; } = string.Empty; + /// Gets or sets the connection string used to access the tenant database. + public string ConnectionString { get; set; } = string.Empty; + /// Gets or sets whether the tenant accepts normal requests. + public bool IsActive { get; set; } = true; + /// Gets or sets whether tenant setup has completed. + public bool IsSetupComplete { get; set; } + /// + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + /// + public string CreatedBy { get; init; } = string.Empty; + /// Flag to indicate if the entity is deleted. + public bool IsDeleted { get; set; } + /// DateTimeOffset (UTC) of when the entity is deleted. + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/Models/TenantSetupState.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/Models/TenantSetupState.cs new file mode 100644 index 0000000..3714808 --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Tenancy/Models/TenantSetupState.cs @@ -0,0 +1,12 @@ +namespace RemoteTickets.Infrastructure.Tenancy.Models; + +/// Stores the initialization state of a tenant database. +public sealed class TenantSetupState : IEntityBase +{ + /// Gets or sets the singleton setup-state identifier. + public string Id { get; set; } = RemoteTicketsConstants.TenantSetupId; + /// Gets or sets whether the tenant database setup has completed. + public bool IsComplete { get; set; } + /// Gets or sets the last setup validation timestamp. + public DateTimeOffset? CompletedAt { get; set; } +} diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs index 6204f4e..345c373 100644 --- a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs +++ b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs @@ -1,40 +1,57 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using RemoteTickets.Application.Common.Tenancy; +using Microsoft.AspNetCore.Mvc; namespace RemoteTickets.Infrastructure.Tenancy; /// Enforces tenant route isolation and mandatory setup state for HTTP requests. -public sealed class TenantAccessMiddleware(RequestDelegate next, ITenantManagementService tenants, IIdentityService identityService) +public sealed class TenantAccessMiddleware(RequestDelegate next, ITenantManagementService tenants, RemoteTicketsDbContext masterDb) { /// Processes the current request and enforces tenant isolation when a tenant route is present. /// The current HTTP context. /// The asynchronous middleware operation. public async Task InvokeAsync(HttpContext context) { - var endpoint = context.GetEndpoint(); - var anonymous = endpoint?.Metadata.GetMetadata() is not null; - var systemStatus = await identityService.GetSetupStatusAsync(context.RequestAborted); - if (systemStatus.IsSetupRequired) + if (IsInfrastructurePath(context.Request.Path)) { await next(context); return; } + Endpoint? endpoint = context.GetEndpoint(); + bool anonymous = endpoint?.Metadata.GetMetadata() is not null; + SystemSetupState? setupState = await masterDb.SystemSetup.AsNoTracking().SingleOrDefaultAsync(x => x.Id == RemoteTicketsConstants.SystemSetupId, context.RequestAborted); + bool systemSetupRequired = setupState is null || !setupState.IsComplete; + if (systemSetupRequired) { if (IsSystemSetupEndpoint(context)) { await next(context); return; } await RejectOrRedirectAsync(context, "/setup", StatusCodes.Status503ServiceUnavailable); return; } - if (!context.Request.RouteValues.TryGetValue("tenantId", out var value) || string.IsNullOrWhiteSpace(value?.ToString())) + if (IsSystemSetupEndpoint(context) && HttpMethods.IsPost(context.Request.Method)) { - if (anonymous) await next(context); else await RejectOrRedirectAsync(context, "/setup", StatusCodes.Status503ServiceUnavailable); + context.Response.StatusCode = StatusCodes.Status409Conflict; + await context.Response.WriteAsJsonAsync(new { error = "System setup is already complete." }, context.RequestAborted); return; } - var tenantId = value.ToString()!; + if (!context.Request.RouteValues.TryGetValue("tenantId", out object? value) || string.IsNullOrWhiteSpace(value?.ToString())) + { + if (anonymous) + { + await next(context); + } + else + { + await RejectOrRedirectAsync(context, "/setup", StatusCodes.Status503ServiceUnavailable); + } + + return; + } + string tenantId = value.ToString()!; if (string.Equals(tenantId, "system", StringComparison.OrdinalIgnoreCase)) { if (context.User.Identity?.IsAuthenticated != true || !context.User.IsInRole(TenantRoles.SysAdmin)) { context.Response.StatusCode = StatusCodes.Status403Forbidden; return; } await next(context); return; } - var tenant = await tenants.GetAsync(tenantId, context.RequestAborted); - if (tenant is null || !tenant.IsActive) { await RejectOrRedirectAsync(context, "/not-found", StatusCodes.Status404NotFound); return; } + TenantResponse? tenant = await tenants.GetAsync(tenantId, context.RequestAborted); + if (tenant is null || !tenant.IsActive) + { + await RejectOrRedirectAsync(context, "/not-found", StatusCodes.Status404NotFound); return; + } if (anonymous) { if (!tenant.IsSetupComplete && !IsTenantSetupEndpoint(context) && !IsTenantLoginEndpoint(context)) { await RejectOrRedirectAsync(context, $"/{tenantId}/setup", StatusCodes.Status409Conflict); return; } @@ -44,27 +61,67 @@ public async Task InvokeAsync(HttpContext context) if (context.User.Identity?.IsAuthenticated != true) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } if (!context.User.IsInRole(TenantRoles.SysAdmin)) { - var assignedTenant = context.User.FindFirst(TenantClaimTypes.TenantId)?.Value; + string? assignedTenant = context.User.FindFirst(TenantClaimTypes.TenantId)?.Value; if (!string.Equals(assignedTenant, tenantId, StringComparison.OrdinalIgnoreCase)) { await RejectOrRedirectAsync(context, $"/{assignedTenant ?? tenantId}", StatusCodes.Status403Forbidden); return; } } if (!tenant.IsSetupComplete && !IsTenantSetupEndpoint(context)) { await RejectOrRedirectAsync(context, $"/{tenantId}/setup", StatusCodes.Status409Conflict); return; } await next(context); } - private static bool IsSystemSetupEndpoint(HttpContext context) => context.Request.Path.StartsWithSegments("/api/setup") || string.Equals(context.Request.Path.Value, "/setup", StringComparison.OrdinalIgnoreCase); - private static bool IsTenantLoginEndpoint(HttpContext context) => string.Equals(context.Request.Path.Value, $"/api/v1/{context.Request.RouteValues["tenantId"]}/login", StringComparison.OrdinalIgnoreCase); + private static bool IsInfrastructurePath(PathString path) + => path.StartsWithSegments("/_blazor") || + path.StartsWithSegments("/_framework") || + path.StartsWithSegments("/_content") || + path.StartsWithSegments("/favicon") || + path.StartsWithSegments("/css") || + path.StartsWithSegments("/js") || + path.StartsWithSegments("/lib"); + + private static bool IsSystemSetupEndpoint(HttpContext context) + => context.Request.Path.StartsWithSegments("/api/v1/setup") || + string.Equals(context.Request.Path.Value, "/setup", + StringComparison.OrdinalIgnoreCase); + + private static bool IsTenantLoginEndpoint(HttpContext context) + => string.Equals(context.Request.Path.Value, $"/api/v1/{ + context.Request.RouteValues["tenantId"]}/login", + StringComparison.OrdinalIgnoreCase); + private static bool IsTenantSetupEndpoint(HttpContext context) { - var tenantId = context.Request.RouteValues.TryGetValue("tenantId", out var value) ? value?.ToString() : null; - if (string.IsNullOrWhiteSpace(tenantId)) return false; - var path = context.Request.Path.Value; - return string.Equals(path, $"/{tenantId}/setup", StringComparison.OrdinalIgnoreCase) || string.Equals(path, $"/api/v1/{tenantId}/setup", StringComparison.OrdinalIgnoreCase) || string.Equals(path, $"/api/v1/{tenantId}/setup/complete", StringComparison.OrdinalIgnoreCase); + string? tenantId = context + .Request + .RouteValues + .TryGetValue("tenantId", out object? value) ? + value?.ToString() + : null; + if (string.IsNullOrWhiteSpace(tenantId ) || + string.IsNullOrEmpty(tenantId) || + Guid.TryParse(tenantId, out Guid _)) { return false; } + string? path = context.Request.Path.Value; + return string.Equals(path, + $"/{tenantId}/setup", + StringComparison.OrdinalIgnoreCase) || + string.Equals(path, + $"/api/v1/{tenantId}/setup", + StringComparison.OrdinalIgnoreCase) || string.Equals(path, + $"/api/v1/{tenantId}/setup/complete", + StringComparison.OrdinalIgnoreCase); } private static async Task RejectOrRedirectAsync(HttpContext context, string location, int statusCode) { - if (HttpMethods.IsGet(context.Request.Method) && context.Request.Headers.Accept.Any(x => x.Contains("text/html", StringComparison.OrdinalIgnoreCase))) { context.Response.Redirect(location); return; } + if (HttpMethods.IsGet(context.Request.Method) && + context.Request.Headers.Accept.Any( + x => x != null && x.Contains("text/html", StringComparison.OrdinalIgnoreCase))) + { context.Response.Redirect(location); return; } context.Response.StatusCode = statusCode; context.Response.ContentType = "application/problem+json"; - await context.Response.WriteAsJsonAsync(new { title = "Setup required", detail = "Complete the required setup before using this endpoint.", setup = location }, context.RequestAborted); + await context.Response.WriteAsJsonAsync( + new ProblemDetails + { + Title = "Setup required", + Detail = $"Complete the required setup at {location} before using this endpoint." + }, + context.RequestAborted); } } diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantManagementService.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantManagementService.cs new file mode 100644 index 0000000..b6a7b21 --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantManagementService.cs @@ -0,0 +1,136 @@ +namespace RemoteTickets.Infrastructure.Tenancy; + +/// Implements tenant catalog, database provisioning, and tenant setup operations. +public sealed class TenantManagementService( + RemoteTicketsDbContext catalog, + IIdentityService identityService, + ILogger logger) : ITenantManagementService +{ + /// + public async Task GetAsync(string tenantId, CancellationToken cancellationToken) + { + Tenant? tenant = await catalog.Tenants.AsNoTracking().SingleOrDefaultAsync(x => x.Id == tenantId, cancellationToken); + return tenant is null ? null : Map(tenant); + } + + /// + public async Task CreateAsync(CreateTenantRequest request, CancellationToken cancellationToken) + { + Validate(request); + if (await catalog.Tenants.AnyAsync(x => x.Id == request.Id, cancellationToken)) + { + throw new InvalidOperationException($"Tenant '{request.Id}' already exists."); + } + + await CreateDatabaseAsync(request.ConnectionString, request.DatabaseName, cancellationToken); + await InitializeTenantDatabaseAsync(request.ConnectionString, cancellationToken); + + var tenant = new Tenant + { + Id = Guid.CreateVersion7().ToString(), + Name = request.Name, + DatabaseName = request.DatabaseName, + ConnectionString = request.ConnectionString, + IsActive = true, + IsSetupComplete = false + }; + + catalog.Tenants.Add(tenant); + await catalog.SaveChangesAsync(cancellationToken); + + IdentityResultResponse adminResult = await identityService.CreateTenantAdminAsync(request.Id, request.AdminEmail, request.AdminPassword, cancellationToken); + if (!adminResult.Succeeded) + { + catalog.Tenants.Remove(tenant); + await catalog.SaveChangesAsync(cancellationToken); + throw new InvalidOperationException(string.Join(" ", adminResult.Errors)); + } + + logger.LogInformation("Tenant {TenantId} was provisioned with database {DatabaseName}.", request.Id, request.DatabaseName); + return Map(tenant); + } + + /// + public async Task GetSetupStatusAsync(string tenantId, CancellationToken cancellationToken) + { + Tenant tenant = await GetTenantEntityAsync(tenantId, cancellationToken); + await using TenantSetupDbContext context = CreateSetupContext(tenant.ConnectionString); + await context.Database.EnsureCreatedAsync(cancellationToken); + TenantSetupState? state = await context.SetupStates.SingleOrDefaultAsync(x => x.Id == RemoteTicketsConstants.TenantSetupId, cancellationToken); + return new TenantSetupStatusResponse(state is null || !state.IsComplete, state?.IsComplete == true); + } + + /// + public async Task CompleteSetupAsync(string tenantId, CancellationToken cancellationToken) + { + Tenant tenant = await GetTenantEntityAsync(tenantId, cancellationToken); + await using TenantSetupDbContext context = CreateSetupContext(tenant.ConnectionString); + await context.Database.EnsureCreatedAsync(cancellationToken); + TenantSetupState? state = await context.SetupStates.SingleOrDefaultAsync(x => x.Id == RemoteTicketsConstants.TenantSetupId, cancellationToken); + if (state is null) + { + state = new TenantSetupState { Id = RemoteTicketsConstants.TenantSetupId }; + context.SetupStates.Add(state); + } + + state.IsComplete = true; + state.CompletedAt = DateTimeOffset.UtcNow; + await context.SaveChangesAsync(cancellationToken); + + tenant.IsSetupComplete = true; + await catalog.SaveChangesAsync(cancellationToken); + return new TenantSetupStatusResponse(false, true); + } + + private async Task GetTenantEntityAsync(string tenantId, CancellationToken cancellationToken) + { + return await catalog.Tenants.SingleOrDefaultAsync(x => x.Id == tenantId, cancellationToken) + ?? throw new KeyNotFoundException($"Tenant '{tenantId}' was not found."); + } + + private static TenantSetupDbContext CreateSetupContext(string connectionString) + { + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options; + return new TenantSetupDbContext(options); + } + + private static async Task CreateDatabaseAsync(string connectionString, string databaseName, CancellationToken cancellationToken) + { + var builder = new SqlConnectionStringBuilder(connectionString); + builder.InitialCatalog = "master"; + await using var connection = new SqlConnection(builder.ConnectionString); + await connection.OpenAsync(cancellationToken); + string escaped = $"[{databaseName.Replace("]", "]]", StringComparison.Ordinal)}]"; + await using SqlCommand command = connection.CreateCommand(); + command.CommandText = $"IF DB_ID(@databaseName) IS NULL CREATE DATABASE {escaped};"; + command.Parameters.AddWithValue("@databaseName", databaseName); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private static async Task InitializeTenantDatabaseAsync(string connectionString, CancellationToken cancellationToken) + { + await using TenantSetupDbContext context = CreateSetupContext(connectionString); + // The repository currently has no EF migration history. EnsureCreated creates the foundation schema; + // the same tenant connection is used by the future migration runner when migrations are introduced. + await context.Database.MigrateAsync(cancellationToken); + await context.Database.EnsureCreatedAsync(cancellationToken); + } + + private static void Validate(CreateTenantRequest request) + { + if (string.IsNullOrWhiteSpace(request.Id) || string.IsNullOrWhiteSpace(request.DatabaseName)) + { + throw new ArgumentException("Tenant id and database name are required."); + } + + if (string.IsNullOrWhiteSpace(request.ConnectionString)) + { + throw new ArgumentException("A tenant database connection string is required."); + } + } + + private static TenantResponse Map(Tenant tenant) => + new(tenant.Id, tenant.Name, tenant.DatabaseName, tenant.IsActive, tenant.IsSetupComplete); +} diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantSetupDbContext.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantSetupDbContext.cs new file mode 100644 index 0000000..4cb2b2c --- /dev/null +++ b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantSetupDbContext.cs @@ -0,0 +1,14 @@ +namespace RemoteTickets.Infrastructure.Tenancy; + +/// Provides persistence for tenant-database initialization state. +public sealed class TenantSetupDbContext(DbContextOptions options) : DbContext(options) +{ + /// Gets the singleton tenant setup state. + public DbSet SetupStates => Set(); + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(TenantSetupStateConfiguration).Assembly); + } +} diff --git a/src/server/RemoteTickets/Components/App.razor b/src/server/RemoteTickets/Components/App.razor index 7b5551b..29d8ce7 100644 --- a/src/server/RemoteTickets/Components/App.razor +++ b/src/server/RemoteTickets/Components/App.razor @@ -1,23 +1,22 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/server/RemoteTickets/Components/Layout/MainLayout.razor b/src/server/RemoteTickets/Components/Layout/MainLayout.razor index 9e253ba..b687392 100644 --- a/src/server/RemoteTickets/Components/Layout/MainLayout.razor +++ b/src/server/RemoteTickets/Components/Layout/MainLayout.razor @@ -1,23 +1,35 @@ -@inherits LayoutComponentBase - -
- - -
-
- About -
- -
- @Body -
-
-
- -
- An unhandled error has occurred. - Reload - 🗙 -
+@inherits LayoutComponentBase + + + + + + + + + + RemoteTickets + + Tenant-aware ticket management + + + + + + + + @Body + + + + + +@code { + private bool DrawerOpen = true; + + private void ToggleDrawer() => DrawerOpen = !DrawerOpen; +} diff --git a/src/server/RemoteTickets/Components/Layout/NavMenu.razor b/src/server/RemoteTickets/Components/Layout/NavMenu.razor index 83de3a1..5693cb6 100644 --- a/src/server/RemoteTickets/Components/Layout/NavMenu.razor +++ b/src/server/RemoteTickets/Components/Layout/NavMenu.razor @@ -1,30 +1,26 @@ - - - - - - +@inject NavigationManager Navigation + + + RemoteTickets + + + + Home + Counter + Weather + System setup + + + +@code { + private string HomeHref => TenantHref(string.Empty); + private string CounterHref => TenantHref("counter"); + private string WeatherHref => TenantHref("weather"); + + private string TenantHref(string suffix) + { + string relativePath = Navigation.ToBaseRelativePath(Navigation.Uri).Trim('/'); + string tenantId = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; + return string.IsNullOrWhiteSpace(tenantId) ? "/" : $"/{tenantId}/{suffix}".TrimEnd('/'); + } +} diff --git a/src/server/RemoteTickets/Components/Layout/ReconnectModal.razor b/src/server/RemoteTickets/Components/Layout/ReconnectModal.razor index e06eb2f..bce8e41 100644 --- a/src/server/RemoteTickets/Components/Layout/ReconnectModal.razor +++ b/src/server/RemoteTickets/Components/Layout/ReconnectModal.razor @@ -1,31 +1,24 @@ - - - -
- -

- Rejoining the server... -

-

- Rejoin failed... trying again in seconds. -

-

- Failed to rejoin.
Please retry or reload the page. -

- -

- The session has been paused by the server. -

-

- Failed to resume the session.
Please retry or reload the page. -

- -
-
+ + + + + + + Rejoining the server... + + Rejoin failed... trying again in seconds. + + + Failed to rejoin. Please retry or reload the page. + + + Retry + + The session has been paused by the server. + Failed to resume the session. Please retry or reload the page. + + Resume + + + + diff --git a/src/server/RemoteTickets/Components/Pages/Counter.razor b/src/server/RemoteTickets/Components/Pages/Counter.razor index ba08d04..d3b3770 100644 --- a/src/server/RemoteTickets/Components/Pages/Counter.razor +++ b/src/server/RemoteTickets/Components/Pages/Counter.razor @@ -1,19 +1,22 @@ -@page "/counter" -@rendermode InteractiveServer - -Counter - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} +@page "/{TenantId}/counter" +@rendermode InteractiveServer + +Counter + + + + Counter + Tenant: @TenantId + + Current count: @currentCount + + Increment + + + + +@code { + [Parameter] public string TenantId { get; set; } = string.Empty; + private int currentCount; + private void IncrementCount() => currentCount++; +} diff --git a/src/server/RemoteTickets/Components/Pages/Error.razor b/src/server/RemoteTickets/Components/Pages/Error.razor index 9d7fda1..70ef0f6 100644 --- a/src/server/RemoteTickets/Components/Pages/Error.razor +++ b/src/server/RemoteTickets/Components/Pages/Error.razor @@ -1,35 +1,35 @@ -@page "/Error" - -Error - -

Error.

-

An error occurred while processing your request.

- -@if (ShowRequestId) -{ -

- Request ID: @RequestId -

-} - -

Development Mode

-

- Swapping to Development environment will display more detailed information about the error that occurred. -

-

- The Development environment shouldn't be enabled for deployed applications. - It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development - and restarting the app. -

- -@code{ - [CascadingParameter] - private HttpContext? HttpContext { get; set; } - - private string? RequestId { get; set; } - private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); - - protected override void OnInitialized() => - RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; -} +@page "/Error" + +Error + + + + An error occurred while processing your request. + Please retry the operation or reload the application. + + + @if (ShowRequestId) + { + + Request ID + @RequestId + + } + + + + Development details are intentionally hidden from deployed applications because exceptions can expose sensitive information. + + + + +@code { + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() + => RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/src/server/RemoteTickets/Components/Pages/Home.razor b/src/server/RemoteTickets/Components/Pages/Home.razor index df05023..1d92aac 100644 --- a/src/server/RemoteTickets/Components/Pages/Home.razor +++ b/src/server/RemoteTickets/Components/Pages/Home.razor @@ -1,7 +1,18 @@ -@page "/" - -Home - -

Hello, world!

- -Welcome to your new app. +@page "/{TenantId}" + +Home + + + + RemoteTickets + Welcome to the tenant workspace. + + + Tenant: @TenantId + + + + +@code { + [Parameter] public string TenantId { get; set; } = string.Empty; +} diff --git a/src/server/RemoteTickets/Components/Pages/NotFound.razor b/src/server/RemoteTickets/Components/Pages/NotFound.razor index f74a7a3..6bd3df8 100644 --- a/src/server/RemoteTickets/Components/Pages/NotFound.razor +++ b/src/server/RemoteTickets/Components/Pages/NotFound.razor @@ -1,5 +1,14 @@ -@page "/not-found" -@layout MainLayout - -

Not Found

-

Sorry, the content you are looking for does not exist.

\ No newline at end of file +@page "/not-found" +@layout MainLayout + +Not Found + + + + + Not Found + + Sorry, the content you are looking for does not exist. + + + diff --git a/src/server/RemoteTickets/Components/Pages/Setup.razor b/src/server/RemoteTickets/Components/Pages/Setup.razor new file mode 100644 index 0000000..5c4bbf7 --- /dev/null +++ b/src/server/RemoteTickets/Components/Pages/Setup.razor @@ -0,0 +1,70 @@ +@page "/setup" +@using System.Net.Http.Json +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation + +System setup + + + + System setup + + Configure the master SQL Server database and create the first system administrator before using the application. + + + + + + + + @(Busy ? "Initializing..." : "Initialize") + + + + @if (!string.IsNullOrWhiteSpace(Message)) + { + + @Message + + } + + + +@code { + private bool Busy; + private bool SetupSucceeded; + private string ConnectionString = string.Empty; + private string Email = string.Empty; + private string Password = string.Empty; + private string Message = string.Empty; + + private async Task InitializeAsync() + { + Busy = true; + SetupSucceeded = false; + Message = string.Empty; + try + { + var client = HttpClientFactory.CreateClient(); + client.BaseAddress = new Uri(Navigation.BaseUri); + var configure = await client.PostAsJsonAsync("api/v1/setup/database", new { ConnectionString }); + if (!configure.IsSuccessStatusCode) + { + Message = "The master database configuration could not be stored."; + return; + } + + var initialize = await client.PostAsJsonAsync("api/v1/setup/initialize", new { Email, Password }); + SetupSucceeded = initialize.IsSuccessStatusCode; + Message = SetupSucceeded ? "Setup completed. You can now sign in." : await initialize.Content.ReadAsStringAsync(); + } + catch (Exception exception) + { + Message = exception.Message; + } + finally + { + Busy = false; + } + } +} diff --git a/src/server/RemoteTickets/Components/Pages/TenantSetup.razor b/src/server/RemoteTickets/Components/Pages/TenantSetup.razor new file mode 100644 index 0000000..8a2ae71 --- /dev/null +++ b/src/server/RemoteTickets/Components/Pages/TenantSetup.razor @@ -0,0 +1,62 @@ +@page "/{TenantId}/setup" +@using System.Net.Http.Json +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation + +Tenant setup + + + + Tenant setup + + Tenant @TenantId is not ready for normal operations. Run the database validation and complete setup. + + + + @(Busy ? "Validating..." : "Validate and complete setup") + + + @if (!string.IsNullOrWhiteSpace(Message)) + { + + @Message + + } + + + +@code { + [Parameter] public string TenantId { get; set; } = string.Empty; + private bool Busy; + private bool SetupSucceeded; + private string Message = string.Empty; + + private async Task CompleteAsync() + { + Busy = true; + SetupSucceeded = false; + Message = string.Empty; + try + { + var client = HttpClientFactory.CreateClient(); + client.BaseAddress = new Uri(Navigation.BaseUri); + var response = await client.PostAsync($"api/v1/{Uri.EscapeDataString(TenantId)}/setup/complete", null); + if (response.IsSuccessStatusCode) + { + SetupSucceeded = true; + Message = "Tenant setup completed."; + Navigation.NavigateTo($"/{TenantId}", true); + return; + } + Message = await response.Content.ReadAsStringAsync(); + } + catch (Exception exception) + { + Message = exception.Message; + } + finally + { + Busy = false; + } + } +} diff --git a/src/server/RemoteTickets/Components/Pages/Weather.razor b/src/server/RemoteTickets/Components/Pages/Weather.razor index d00febb..1b98f63 100644 --- a/src/server/RemoteTickets/Components/Pages/Weather.razor +++ b/src/server/RemoteTickets/Components/Pages/Weather.razor @@ -1,64 +1,63 @@ -@page "/weather" -@attribute [StreamRendering] - -Weather - -

Weather

- -

This component demonstrates showing data.

- -@if (forecasts == null) -{ -

Loading...

-} -else -{ - - - - - - - - - - - @foreach (var forecast in forecasts) - { - - - - - - - } - -
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
-} - -@code { - private WeatherForecast[]? forecasts; - - protected override async Task OnInitializedAsync() - { - // Simulate asynchronous loading to demonstrate streaming rendering - await Task.Delay(500); - - var startDate = DateOnly.FromDateTime(DateTime.Now); - var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; - forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = startDate.AddDays(index), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = summaries[Random.Shared.Next(summaries.Length)] - }).ToArray(); - } - - private class WeatherForecast - { - public DateOnly Date { get; set; } - public int TemperatureC { get; set; } - public string? Summary { get; set; } - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - } -} +@page "/{TenantId}/weather" +@attribute [StreamRendering] + +Weather + + + + Weather + Tenant: @TenantId + + + @if (forecasts == null) + { + + + Loading... + + } + else + { + + + Date + Temperature (C) + Temperature (F) + Summary + + + @context.Date.ToShortDateString() + @context.TemperatureC + @context.TemperatureF + @context.Summary + + + } + + + +@code { + [Parameter] public string TenantId { get; set; } = string.Empty; + private WeatherForecast[]? forecasts; + + protected override async Task OnInitializedAsync() + { + await Task.Delay(500); + var startDate = DateOnly.FromDateTime(DateTime.Now); + var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; + forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = startDate.AddDays(index), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = summaries[Random.Shared.Next(summaries.Length)] + }).ToArray(); + } + + private sealed class WeatherForecast + { + public DateOnly Date { get; set; } + public int TemperatureC { get; set; } + public string? Summary { get; set; } + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} diff --git a/src/server/RemoteTickets/Components/_Imports.razor b/src/server/RemoteTickets/Components/_Imports.razor index 7a23693..91a45d2 100644 --- a/src/server/RemoteTickets/Components/_Imports.razor +++ b/src/server/RemoteTickets/Components/_Imports.razor @@ -11,3 +11,4 @@ @using RemoteTickets.Components @using RemoteTickets.Components.Layout @using System.Diagnostics +@using MudBlazor \ No newline at end of file diff --git a/src/server/RemoteTickets/Controllers/SetupController.cs b/src/server/RemoteTickets/Controllers/SetupController.cs deleted file mode 100644 index 6d0d4e6..0000000 --- a/src/server/RemoteTickets/Controllers/SetupController.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace RemoteTickets.Controllers; - -/// Exposes anonymous endpoints used during first-time application setup. -/// The mediator used to dispatch setup requests. -[ApiController] -[Route("api/setup")] -public sealed class SetupController(IMediator mediator) : ControllerBase -{ - /// Gets the current first-time setup status. - /// The token used to cancel the request. - /// The current setup status. - [HttpGet("status")] - [AllowAnonymous] - public Task GetStatus(CancellationToken cancellationToken) => - mediator.RequestAsync(new GetSetupStatusQuery(), cancellationToken); - - /// Initializes the application with its first administrator account. - /// The initial administrator credentials. - /// The token used to cancel the request. - /// An HTTP response indicating whether initialization succeeded. - [HttpPost("initialize")] - [AllowAnonymous] - public async Task Initialize(InitializeSetupRequest request, CancellationToken cancellationToken) - { - var result = await mediator.RequestAsync( - new InitializeSetupCommand(request.Email, request.Password), cancellationToken); - - return result.Succeeded ? Ok(result) : Conflict(result); - } -} - -/// Represents the first-time setup administrator credentials. -/// The administrator email address. -/// The administrator password. -public sealed record InitializeSetupRequest(string Email, string Password); diff --git a/src/server/RemoteTickets/Controllers/IdentityController.cs b/src/server/RemoteTickets/Controllers/v1/Identity/IdentityController.cs similarity index 50% rename from src/server/RemoteTickets/Controllers/IdentityController.cs rename to src/server/RemoteTickets/Controllers/v1/Identity/IdentityController.cs index 6614883..76e4ea6 100644 --- a/src/server/RemoteTickets/Controllers/IdentityController.cs +++ b/src/server/RemoteTickets/Controllers/v1/Identity/IdentityController.cs @@ -1,164 +1,118 @@ namespace RemoteTickets.Controllers; -/// Exposes HTTP endpoints for registration, authentication, account management, and two-factor authentication. +/// Exposes HTTP endpoints for tenant-scoped registration, authentication, account management, and two-factor authentication. /// The mediator used to dispatch application requests. [ApiController] +[Route("/api/v1/{tenantId}")] public sealed class IdentityController(IMediator mediator) : ControllerBase { - /// Registers a new user. - /// The registration payload. - /// The token used to cancel the request. - /// An HTTP response representing the registration result. - [HttpPost("/register")] + /// Registers a new user in the requested tenant. + [HttpPost("register")] [AllowAnonymous] - public async Task Register(RegisterRequest request, CancellationToken cancellationToken) + public async Task Register([FromRoute] string tenantId, RegisterRequest request, CancellationToken cancellationToken) { - var result = await mediator.RequestAsync(new RegisterCommand(request.Email, request.Password)); + IdentityResultResponse result = await mediator.RequestAsync(new RegisterCommand(request.Email, request.Password), cancellationToken); return result.Succeeded ? Ok() : BadRequest(result); } - - /// Authenticates a user with a password and optional second factor. - /// The login payload. - /// The token used to cancel the request. - /// An HTTP response containing tokens when authentication succeeds. - [HttpPost("/login")] + /// Authenticates a user against the requested tenant. + [HttpPost("login")] [AllowAnonymous] - public async Task Login(LoginRequest request, CancellationToken cancellationToken) + public async Task Login([FromRoute] string tenantId, LoginRequest request, CancellationToken cancellationToken) { - var result = await mediator.RequestAsync>( - new LoginCommand(request.Email, request.Password, request.TwoFactorCode, request.TwoFactorRecoveryCode)); + Response result = await mediator.RequestAsync>(new LoginCommand(request.Email, request.Password, request.TwoFactorCode, request.TwoFactorRecoveryCode, tenantId), cancellationToken); return result.Succeeded ? Ok(result.Data) : Unauthorized(result); } - - /// Exchanges a refresh token for a new token pair. - /// The refresh-token payload. - /// The token used to cancel the request. - /// An HTTP response containing the refreshed tokens. - [HttpPost("/refresh")] + /// Exchanges a refresh token for a new token pair in the requested tenant context. + [HttpPost("refresh")] [AllowAnonymous] - public async Task Refresh(RefreshRequest request, CancellationToken cancellationToken) + public async Task Refresh([FromRoute] string tenantId, RefreshRequest request, CancellationToken cancellationToken) { - var result = await mediator.RequestAsync>(new RefreshTokenCommand(request.RefreshToken)); + Response result = await mediator.RequestAsync>(new RefreshTokenCommand(request.RefreshToken, tenantId), cancellationToken); return result.Succeeded ? Ok(result.Data) : Unauthorized(result); } - /// Revokes the access token supplied by the authenticated caller. - /// The token used to cancel the request. - /// An HTTP response indicating whether revocation succeeded. - [HttpPost("/revoke")] + [HttpPost("revoke")] [Authorize] public async Task Revoke(CancellationToken cancellationToken) { - var accessToken = Request.Headers.Authorization.ToString().Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase); - var result = await mediator.RequestAsync>(new RevokeTokenCommand(accessToken)); + string accessToken = Request.Headers.Authorization.ToString().Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase); + Response result = await mediator.RequestAsync>(new RevokeTokenCommand(accessToken), cancellationToken); return result.Data == true ? Ok() : Unauthorized(result); } - /// Confirms a user's email address. - /// The user identifier from the confirmation link. - /// The confirmation token. - /// The replacement email address when confirming an email change. - /// The token used to cancel the request. - /// An HTTP response indicating whether confirmation succeeded. - [HttpGet("/confirmEmail")] + [HttpGet("confirmEmail")] [AllowAnonymous] public async Task ConfirmEmail([FromQuery] string userId, [FromQuery] string code, [FromQuery] string? changedEmail, CancellationToken cancellationToken) { - var result = await mediator.RequestAsync>(new ConfirmEmailCommand(userId, code, changedEmail)); + Response result = await mediator.RequestAsync>(new ConfirmEmailCommand(userId, code, changedEmail), cancellationToken); return result.Data == true ? Ok("Thank you for confirming your email.") : BadRequest(result); } - /// Resends a user's email confirmation link. - /// The email payload. - /// The token used to cancel the request. - /// An HTTP response representing the resend result. - [HttpPost("/resendConfirmationEmail")] + [HttpPost("resendConfirmationEmail")] [AllowAnonymous] public async Task ResendConfirmationEmail(EmailRequest request, CancellationToken cancellationToken) { - var result = await mediator.RequestAsync(new ResendConfirmationEmailCommand(request.Email)); + IdentityResultResponse result = await mediator.RequestAsync(new ResendConfirmationEmailCommand(request.Email), cancellationToken); return result.Succeeded ? Ok() : BadRequest(result); } - /// Starts password recovery for an email address. - /// The email payload. - /// The token used to cancel the request. - /// An HTTP response representing the recovery result. - [HttpPost("/forgotPassword")] + [HttpPost("forgotPassword")] [AllowAnonymous] public async Task ForgotPassword(EmailRequest request, CancellationToken cancellationToken) { - var result = await mediator.RequestAsync(new ForgotPasswordCommand(request.Email)); + IdentityResultResponse result = await mediator.RequestAsync(new ForgotPasswordCommand(request.Email), cancellationToken); return result.Succeeded ? Ok() : BadRequest(result); } - /// Resets a user's password using a reset token. - /// The password reset payload. - /// The token used to cancel the request. - /// An HTTP response representing the reset result. - [HttpPost("/resetPassword")] + [HttpPost("resetPassword")] [AllowAnonymous] public async Task ResetPassword(ResetPasswordRequest request, CancellationToken cancellationToken) { - var result = await mediator.RequestAsync(new ResetPasswordCommand(request.Email, request.ResetCode, request.NewPassword)); + IdentityResultResponse result = await mediator.RequestAsync(new ResetPasswordCommand(request.Email, request.ResetCode, request.NewPassword), cancellationToken); return result.Succeeded ? Ok() : BadRequest(result); } - /// Gets identity information for the authenticated user. - /// The token used to cancel the request. - /// An HTTP response containing identity information. - [HttpGet("/manage/info")] + [HttpGet("manage/info")] [Authorize] public async Task GetInfo(CancellationToken cancellationToken) { - var userId = GetUserId(); + string? userId = GetUserId(); if (userId is null) { return Unauthorized(); } - var result = await mediator.RequestAsync>(new GetIdentityInfoQuery(userId)); + Response result = await mediator.RequestAsync>(new GetIdentityInfoQuery(userId), cancellationToken); return result.Succeeded ? Ok(result.Data) : NotFound(result); } - /// Updates identity information for the authenticated user. - /// The identity update payload. - /// The token used to cancel the request. - /// An HTTP response representing the update result. - [HttpPost("/manage/info")] + [HttpPost("manage/info")] [Authorize] public async Task UpdateInfo(InfoRequest request, CancellationToken cancellationToken) { - var userId = GetUserId(); + string? userId = GetUserId(); if (userId is null) { return Unauthorized(); } - var result = await mediator.RequestAsync( - new UpdateIdentityInfoCommand(userId, request.NewEmail, request.NewPassword, request.OldPassword)); + IdentityResultResponse result = await mediator.RequestAsync(new UpdateIdentityInfoCommand(userId, request.NewEmail, request.NewPassword, request.OldPassword), cancellationToken); return result.Succeeded ? Ok() : BadRequest(result); } - /// Configures authenticator-based two-factor authentication for the authenticated user. - /// The two-factor configuration payload. - /// The token used to cancel the request. - /// An HTTP response containing the resulting two-factor configuration. - [HttpPost("/manage/2fa")] + [HttpPost("manage/2fa")] [Authorize] public async Task ConfigureTwoFactor(TwoFactorRequest request, CancellationToken cancellationToken) { - var userId = GetUserId(); + string? userId = GetUserId(); if (userId is null) { return Unauthorized(); } - var result = await mediator.RequestAsync>( - new ConfigureTwoFactorCommand(userId, request.Enable, request.TwoFactorCode, request.ResetRecoveryCodes, request.ResetSharedKey, request.ForgetMachine)); + Response result = await mediator.RequestAsync>(new ConfigureTwoFactorCommand(userId, request.Enable, request.TwoFactorCode, request.ResetRecoveryCodes, request.ResetSharedKey, request.ForgetMachine), cancellationToken); return result.Succeeded ? Ok(result.Data) : BadRequest(result); } - private string? GetUserId() => User.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? User.FindFirstValue(ClaimTypes.NameIdentifier); } @@ -166,34 +120,28 @@ public async Task ConfigureTwoFactor(TwoFactorRequest request, Ca /// The user's email address. /// The user's password. public sealed record RegisterRequest(string Email, string Password); - /// Represents the login request payload. /// The user's email address. /// The user's password. /// The authenticator code, when required. /// The recovery code, when used instead of an authenticator code. public sealed record LoginRequest(string Email, string Password, string? TwoFactorCode = null, string? TwoFactorRecoveryCode = null); - /// Represents a refresh-token request payload. /// The refresh token to exchange. public sealed record RefreshRequest(string RefreshToken); - /// Represents an email-only request payload. /// The email address. public sealed record EmailRequest(string Email); - /// Represents a password reset request payload. /// The user's email address. /// The password reset token. /// The replacement password. public sealed record ResetPasswordRequest(string Email, string ResetCode, string NewPassword); - /// Represents an authenticated identity information update payload. /// The replacement email address. /// The replacement password. /// The current password. public sealed record InfoRequest(string? NewEmail, string? NewPassword, string OldPassword); - /// Represents an authenticator-based two-factor configuration request. /// Whether to enable or disable two-factor authentication. /// The authenticator code used when enabling two-factor authentication. diff --git a/src/server/RemoteTickets/Controllers/v1/System/SetupController.cs b/src/server/RemoteTickets/Controllers/v1/System/SetupController.cs new file mode 100644 index 0000000..5a6b340 --- /dev/null +++ b/src/server/RemoteTickets/Controllers/v1/System/SetupController.cs @@ -0,0 +1,46 @@ +namespace RemoteTickets.Controllers.v1.System; + +/// Exposes anonymous endpoints used during first-time application setup. +/// The mediator used to dispatch setup requests. +[ApiController] +[Route("api/v1/setup")] +[Tags("System", "Setup")] +public sealed class SetupController(IMediator mediator) : ControllerBase +{ + /// Gets the current first-time setup status. + [HttpGet("status")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetStatus(CancellationToken cancellationToken) + => Ok(await mediator.RequestAsync( + new GetSetupStatusQuery(), cancellationToken)); + + /// Persists the master database connection string and initializes its schema. + [HttpPost("database")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status405MethodNotAllowed)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task ConfigureMasterDatabase(MasterDatabaseSetupRequest request, CancellationToken cancellationToken) + { + MasterDatabaseSetupResponse response = await mediator.RequestAsync( + new MasterDatabaseSetupCommand(request), cancellationToken); + return response.Status ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response); + } + + /// Initializes the application with its first system administrator account. + [HttpPost("initialize")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status405MethodNotAllowed)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Initialize(InitializeSetupRequest request, CancellationToken cancellationToken) + { + IdentityResultResponse result = await mediator.RequestAsync( + new InitializeSetupCommand(request.Email, request.Password), + cancellationToken); + return result.Succeeded ? Ok(result) : Conflict(result); + } +} diff --git a/src/server/RemoteTickets/Controllers/v1/Tenant/TenantController.cs b/src/server/RemoteTickets/Controllers/v1/Tenant/TenantController.cs new file mode 100644 index 0000000..5bf71bb --- /dev/null +++ b/src/server/RemoteTickets/Controllers/v1/Tenant/TenantController.cs @@ -0,0 +1,55 @@ +namespace RemoteTickets.Controllers.v1.Tenant; + +/// Exposes system-level tenant provisioning endpoints. +/// The tenant management service. +[ApiController] +[Route("/api/v1/system/tenants")] +[Authorize(Policy = TenantPolicies.SysAdmin)] +public sealed class TenantController(IMediator mediator) : ControllerBase +{ + /// Provisions a tenant database and its first tenant administrator. + /// The tenant provisioning request. + /// The token used to cancel the request. + /// The newly provisioned tenant. + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status405MethodNotAllowed)] + public async Task Create(CreateTenantRequest request, CancellationToken cancellationToken) + { + try + { + TenantResponse tenant = + await mediator.RequestAsync( + request.ToCommand(), + cancellationToken); + return Created($"/api/v1/{tenant.Id}/setup", tenant); + } + catch (ArgumentException exception) + { + return BadRequest(new ProblemDetails{ Title="Argument Exception", Detail = exception.Message }); + } + catch (InvalidOperationException exception) + { + return Conflict(new ProblemDetails { Title = "Invalid Operation Exception", Detail = exception.Message }); + } + } + + /// Gets a tenant from the central catalog. + /// The tenant identifier. + /// The token used to cancel the request. + /// The tenant metadata. + [HttpGet("{tenantId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status405MethodNotAllowed)] + public async Task Get(string tenantId, CancellationToken cancellationToken) + { + TenantResponse? tenant = + await mediator.RequestAsync( + new GetTenantQuery(tenantId), + cancellationToken); + return tenant is null ? NotFound() : Ok(tenant); + } +} diff --git a/src/server/RemoteTickets/Controllers/v1/Tenant/TenantSetupController.cs b/src/server/RemoteTickets/Controllers/v1/Tenant/TenantSetupController.cs new file mode 100644 index 0000000..5b327e2 --- /dev/null +++ b/src/server/RemoteTickets/Controllers/v1/Tenant/TenantSetupController.cs @@ -0,0 +1,41 @@ +namespace RemoteTickets.Controllers.v1.Tenant; + +/// Exposes setup and validation operations for an individual tenant database. +/// The tenant management service. +[ApiController] +[Route("/api/v1/{tenantId}/setup")] +[Tags("Tenant", "Setup")] +public sealed class TenantSetupController(IMediator mediator) : ControllerBase +{ + /// Gets the current tenant setup state. + /// The tenant identifier. + /// The token used to cancel the request. + /// The tenant setup state. + [HttpGet] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetStatus(string tenantId, CancellationToken cancellationToken) + => Ok(await mediator.RequestAsync( + new GetTenantSetupStatusQuery(tenantId), + cancellationToken)); + + + /// Completes tenant setup after the required tenant validations have passed. + /// The tenant identifier. + /// The token used to cancel the request. + /// The resulting tenant setup state. + [HttpPost("complete")] + [Authorize(Policy = TenantPolicies.TenantAdmin)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status405MethodNotAllowed)] + [ProducesResponseType(StatusCodes.Status408RequestTimeout)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Complete(string tenantId, CancellationToken cancellationToken) + { + TenantSetupStatusResponse result = await mediator.RequestAsync( + new CompleteTenantSetupCommand(tenantId), cancellationToken); + return Ok(result); + } +} diff --git a/src/server/RemoteTickets/GlobalUsings.cs b/src/server/RemoteTickets/GlobalUsings.cs index 6bcf26f..5da70fb 100644 --- a/src/server/RemoteTickets/GlobalUsings.cs +++ b/src/server/RemoteTickets/GlobalUsings.cs @@ -4,10 +4,15 @@ global using System.Threading; global using System.Threading.Tasks; global using RemoteTickets.Application.Common.Identity; +global using RemoteTickets.Application.Common.System; +global using RemoteTickets.Application.Common.Tenancy; global using RemoteTickets.Application.Identity.Requests; +global using RemoteTickets.Application.System.Requests; +global using RemoteTickets.Application.Tenant.Requests; global using RemoteTickets.Composition; global using RemoteTickets.Components; global using Mediator.Net; global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Mvc; diff --git a/src/server/RemoteTickets/Program.cs b/src/server/RemoteTickets/Program.cs index 1b52a10..d206b18 100644 --- a/src/server/RemoteTickets/Program.cs +++ b/src/server/RemoteTickets/Program.cs @@ -1,6 +1,3 @@ await WebApplication .CreateBuilder(args) - .AddRemoteTickets() - .Build() - .UseRemoteTickets() - .RunAsync(); + .RunRemoteTicketsAsync(); \ No newline at end of file diff --git a/src/server/RemoteTickets/RemoteTickets.csproj b/src/server/RemoteTickets/RemoteTickets.csproj index 968a0f4..696f3c5 100644 --- a/src/server/RemoteTickets/RemoteTickets.csproj +++ b/src/server/RemoteTickets/RemoteTickets.csproj @@ -3,6 +3,11 @@
+ + + + + net10.0 enable diff --git a/tests/server/RemoteTickets.UnitTests/ApplicationFoundationTests.cs b/tests/server/RemoteTickets.UnitTests/ApplicationFoundationTests.cs new file mode 100644 index 0000000..028855d --- /dev/null +++ b/tests/server/RemoteTickets.UnitTests/ApplicationFoundationTests.cs @@ -0,0 +1,105 @@ +namespace RemoteTickets.UnitTests; + +/// Covers application contracts, tenant handlers, setup validation, and connection-string validation branches. +public sealed class ApplicationFoundationTests +{ + [Fact] + public void CreateTenantRequest_should_convert_valid_request_and_validate_required_fields() + { + var request = new CreateTenantRequest("id", "tenant", "database", "Server=.;Database=database", "admin@example.com", "Password1!"); + request.ToCommand().Name.Should().Be("tenant"); + AssertArgument(() => (request with { Name = string.Empty }).ToCommand()); + AssertArgument(() => (request with { Name = " " }).ToCommand()); + AssertArgument(() => (request with { DatabaseName = string.Empty }).ToCommand()); + AssertArgument(() => (request with { DatabaseName = " " }).ToCommand()); + AssertArgument(() => (request with { ConnectionString = string.Empty }).ToCommand()); + AssertArgument(() => (request with { ConnectionString = " " }).ToCommand()); + AssertArgument(() => (request with { AdminEmail = string.Empty }).ToCommand()); + AssertArgument(() => (request with { AdminEmail = " " }).ToCommand()); + AssertArgument(() => (request with { AdminPassword = string.Empty }).ToCommand()); + AssertArgument(() => (request with { AdminPassword = " " }).ToCommand()); + } + + [Fact] + public void CreateTenantCommand_should_convert_valid_command_and_validate_required_fields() + { + var command = new CreateTenantCommand("tenant", "database", "Server=.;Database=database", "admin@example.com", "Password1!"); + command.ToRequest().Name.Should().Be("tenant"); + AssertArgument(() => (command with { Name = string.Empty }).ToRequest()); + AssertArgument(() => (command with { Name = " " }).ToRequest()); + AssertArgument(() => (command with { DatabaseName = string.Empty }).ToRequest()); + AssertArgument(() => (command with { DatabaseName = " " }).ToRequest()); + AssertArgument(() => (command with { ConnectionString = string.Empty }).ToRequest()); + AssertArgument(() => (command with { ConnectionString = " " }).ToRequest()); + AssertArgument(() => (command with { AdminEmail = string.Empty }).ToRequest()); + AssertArgument(() => (command with { AdminEmail = " " }).ToRequest()); + AssertArgument(() => (command with { AdminPassword = string.Empty }).ToRequest()); + AssertArgument(() => (command with { AdminPassword = " " }).ToRequest()); + } + + [Fact] + public void Connection_string_validator_should_cover_empty_valid_invalid_and_malformed_values() + { + var validator = new ConnectionStringPropertyValidator(["Server", "Database"]); + var context = new ValidationContext(new object()); + validator.IsValid(context, string.Empty).Should().BeTrue(); + validator.IsValid(context, "Server=localhost;Database=master").Should().BeTrue(); + validator.IsValid(context, "Server=localhost;Password=secret").Should().BeFalse(); + validator.IsValid(context, "Server=\"unterminated").Should().BeFalse(); + validator.Name.Should().Be("ConnectionStringValidator"); + } + + [Fact] + public void Master_database_validator_should_accept_valid_connection_and_reject_invalid_values() + { + var validator = new MasterDatabaseSetupCommandValidator(); + validator.Validate(new MasterDatabaseSetupCommand(new MasterDatabaseSetupRequest("Server=localhost;Database=master;Integrated Security=True", 30))).IsValid.Should().BeTrue(); + validator.Validate(new MasterDatabaseSetupCommand(new MasterDatabaseSetupRequest(string.Empty, 30))).IsValid.Should().BeFalse(); + validator.Validate(new MasterDatabaseSetupCommand(new MasterDatabaseSetupRequest("Server=localhost;Unsupported=1", 30))).IsValid.Should().BeFalse(); + } + + [Fact] + public async Task Tenant_handlers_should_delegate_all_operations() + { + var service = new FakeTenantService(); + var handlers = new TenantHandlers(service); + var cancellationToken = TestContext.Current.CancellationToken; + (await handlers.Handle(new ReceiveContext(new("tenant")), cancellationToken)).IsSetupComplete.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("tenant")), cancellationToken)).IsSetupComplete.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("tenant", "db", "Server=.;Database=db", "admin@example.com", "Password1!")), cancellationToken)).Name.Should().Be("tenant"); + (await handlers.Handle(new ReceiveContext(new("tenant")), cancellationToken)).Should().NotBeNull(); + } + + [Fact] + public async Task System_handler_should_delegate_master_database_setup() + { + var service = new FakeSystemService(); + var handler = new SystemHandlers(service); + var response = await handler.Handle(new ReceiveContext(new(new MasterDatabaseSetupRequest("Server=.", 15))), TestContext.Current.CancellationToken); + response.Status.Should().BeTrue(); + service.ConnectionString.Should().Be("Server=."); + service.CommandTimeout.Should().Be(15); + } + + private static void AssertArgument(Action action) => action.Should().Throw(); + + private sealed class FakeTenantService : ITenantManagementService + { + public Task GetAsync(string tenantId, CancellationToken cancellationToken) => Task.FromResult(new TenantResponse(tenantId, "Tenant", "db", true, true)); + public Task CreateAsync(CreateTenantRequest request, CancellationToken cancellationToken) => Task.FromResult(new TenantResponse(request.Id ?? "tenant", request.Name, request.DatabaseName, true, false)); + public Task GetSetupStatusAsync(string tenantId, CancellationToken cancellationToken) => Task.FromResult(new TenantSetupStatusResponse(false, true)); + public Task CompleteSetupAsync(string tenantId, CancellationToken cancellationToken) => Task.FromResult(new TenantSetupStatusResponse(false, true)); + } + + private sealed class FakeSystemService : ISystemService + { + public string ConnectionString { get; private set; } = string.Empty; + public int CommandTimeout { get; private set; } + public Task PerformMasterDatabaseSetup(string connectionString, int commandTimeout, CancellationToken cancellationToken) + { + ConnectionString = connectionString; + CommandTimeout = commandTimeout; + return Task.FromResult(new MasterDatabaseSetupResponse(true, "ok")); + } + } +} diff --git a/tests/server/RemoteTickets.UnitTests/GlobalUsings.cs b/tests/server/RemoteTickets.UnitTests/GlobalUsings.cs index e35a1b2..eaffdd8 100644 --- a/tests/server/RemoteTickets.UnitTests/GlobalUsings.cs +++ b/tests/server/RemoteTickets.UnitTests/GlobalUsings.cs @@ -1,5 +1,7 @@ global using System; global using System.Collections.Generic; +global using System.IdentityModel.Tokens.Jwt; +global using System.IO; global using System.Linq; global using System.Net; global using System.Security.Claims; @@ -8,19 +10,33 @@ global using System.Threading.Tasks; global using FluentAssertions; global using FluentValidation; +global using FluentValidation.Results; global using RemoteTickets.Application.Common.Identity; -global using RemoteTickets.Application.Identity; +global using RemoteTickets.Application.Common.System; +global using RemoteTickets.Application.Common.Tenancy; +global using RemoteTickets.Application.Common.Util; +global using RemoteTickets.Application.Identity; global using RemoteTickets.Application.Identity.Handlers; global using RemoteTickets.Application.Identity.Requests; global using RemoteTickets.Application.Identity.Validators; global using RemoteTickets.Application.Pipeline.Validation; +global using RemoteTickets.Application.System.Handlers; +global using RemoteTickets.Application.System.Requests; +global using RemoteTickets.Application.System.Validators; +global using RemoteTickets.Application.Tenant.Handlers; +global using RemoteTickets.Application.Tenant.Requests; global using RemoteTickets.Infrastructure.Identity.Models; global using RemoteTickets.Infrastructure.Identity.Services; global using RemoteTickets.Infrastructure.Persistence; +global using RemoteTickets.Infrastructure.Persistence.Auditing; +global using RemoteTickets.Infrastructure.Setup; +global using RemoteTickets.Infrastructure.Tenancy; global using Mediator.Net.Context; +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Identity; global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Options; -global using Xunit; \ No newline at end of file +global using Xunit; diff --git a/tests/server/RemoteTickets.UnitTests/IdentityBranchTests.cs b/tests/server/RemoteTickets.UnitTests/IdentityBranchTests.cs index 9da907f..69dc686 100644 --- a/tests/server/RemoteTickets.UnitTests/IdentityBranchTests.cs +++ b/tests/server/RemoteTickets.UnitTests/IdentityBranchTests.cs @@ -12,10 +12,10 @@ public sealed class IdentityBranchTests [Fact] public async Task Register_should_return_identity_errors_for_duplicate_email() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); (await fixture.Service.RegisterAsync("user@example.com", "Password1!", CancellationToken.None)).Succeeded.Should().BeTrue(); - var result = await fixture.Service.RegisterAsync("user@example.com", "Password1!", CancellationToken.None); + IdentityResultResponse result = await fixture.Service.RegisterAsync("user@example.com", "Password1!", CancellationToken.None); result.Succeeded.Should().BeFalse(); result.Errors.Should().NotBeEmpty(); @@ -28,7 +28,7 @@ public async Task Register_should_return_identity_errors_for_duplicate_email() [Fact] public async Task Login_should_reject_bad_password_and_unconfirmed_user() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); await fixture.Service.RegisterAsync("user@example.com", "Password1!", CancellationToken.None); (await fixture.Service.LoginAsync("user@example.com", "WrongPassword1!", null, null, CancellationToken.None)).Should().BeNull(); @@ -42,12 +42,12 @@ public async Task Login_should_reject_bad_password_and_unconfirmed_user() [Fact] public async Task Login_should_reject_invalid_two_factor_code_and_recovery_code() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); await fixture.Service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); - var user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); + User? user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); user.Should().NotBeNull(); - var twoFactorSetup = await fixture.Service.ConfigureTwoFactorAsync( + TwoFactorResponse? twoFactorSetup = await fixture.Service.ConfigureTwoFactorAsync( user!.Id, null, null, @@ -59,10 +59,10 @@ public async Task Login_should_reject_invalid_two_factor_code_and_recovery_code( twoFactorSetup!.SharedKey.Should().NotBeNullOrWhiteSpace(); // AuthenticatorTokenProvider intentionally does not generate codes; the client authenticator generates TOTP from SharedKey. - var setupCode = GenerateTotp(twoFactorSetup.SharedKey!); + string setupCode = GenerateTotp(twoFactorSetup.SharedKey!); setupCode.Should().NotBeNullOrWhiteSpace(); - var configured = await fixture.Service.ConfigureTwoFactorAsync( + TwoFactorResponse? configured = await fixture.Service.ConfigureTwoFactorAsync( user.Id, true, setupCode, @@ -84,18 +84,18 @@ public async Task Login_should_reject_invalid_two_factor_code_and_recovery_code( [Fact] public async Task Refresh_should_reject_access_tokens_empty_subjects_and_unknown_users() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); await fixture.Service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); - var user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); + User? user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); user.Should().NotBeNull(); - var access = await fixture.Service.LoginAsync("admin@example.com", "Password1!", null, null, CancellationToken.None); + TokenResponse? access = await fixture.Service.LoginAsync("admin@example.com", "Password1!", null, null, CancellationToken.None); access.Should().NotBeNull(); (await fixture.Service.RefreshAsync(access!.AccessToken, CancellationToken.None)).Should().BeNull(); - var emptySubject = fixture.TokenService.CreateTokens(string.Empty, "empty@example.com", [], []).RefreshToken; + string emptySubject = fixture.TokenService.CreateTokens(string.Empty, "empty@example.com", [], []).RefreshToken; (await fixture.Service.RefreshAsync(emptySubject, CancellationToken.None)).Should().BeNull(); - var unknownUser = fixture.TokenService.CreateTokens("missing-user", "missing@example.com", [], []).RefreshToken; + string unknownUser = fixture.TokenService.CreateTokens("missing-user", "missing@example.com", [], []).RefreshToken; (await fixture.Service.RefreshAsync(unknownUser, CancellationToken.None)).Should().BeNull(); } @@ -106,17 +106,17 @@ public async Task Refresh_should_reject_access_tokens_empty_subjects_and_unknown [Fact] public async Task Confirm_email_should_support_changed_email_and_reject_invalid_code() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); await fixture.Service.RegisterAsync("old@example.com", "Password1!", CancellationToken.None); - var link = fixture.EmailSender.ConfirmationLinks.Single(); + string link = fixture.EmailSender.ConfirmationLinks.Single(); var query = new Uri("https://localhost" + link).Query.TrimStart('?') .Split('&', StringSplitOptions.RemoveEmptyEntries) .Select(part => part.Split('=', 2)) .ToDictionary(part => part[0], part => Uri.UnescapeDataString(part[1])); - var user = await fixture.UserManager.FindByIdAsync(query["userId"]); + User? user = await fixture.UserManager.FindByIdAsync(query["userId"]); user.Should().NotBeNull(); - var changeCode = await fixture.UserManager.GenerateChangeEmailTokenAsync(user!, "new@example.com"); + string changeCode = await fixture.UserManager.GenerateChangeEmailTokenAsync(user!, "new@example.com"); (await fixture.Service.ConfirmEmailAsync(user!.Id, changeCode, "new@example.com", CancellationToken.None)).Should().BeTrue(); (await fixture.Service.ConfirmEmailAsync(user.Id, "invalid", null, CancellationToken.None)).Should().BeFalse(); } @@ -128,7 +128,7 @@ public async Task Confirm_email_should_support_changed_email_and_reject_invalid_ [Fact] public async Task Forgot_password_should_not_send_for_a_user_without_password() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); var user = new User("external@example.com") { Email = "external@example.com", EmailConfirmed = true }; (await fixture.UserManager.CreateAsync(user)).Succeeded.Should().BeTrue(); @@ -143,10 +143,10 @@ public async Task Forgot_password_should_not_send_for_a_user_without_password() [Fact] public async Task Reset_password_should_report_invalid_token_for_existing_user() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); await fixture.Service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); - var result = await fixture.Service.ResetPasswordAsync("admin@example.com", "invalid", "Password2!", CancellationToken.None); + IdentityResultResponse result = await fixture.Service.ResetPasswordAsync("admin@example.com", "invalid", "Password2!", CancellationToken.None); result.Succeeded.Should().BeFalse(); result.Errors.Should().NotBeEmpty(); @@ -159,9 +159,9 @@ public async Task Reset_password_should_report_invalid_token_for_existing_user() [Fact] public async Task Update_info_should_cover_missing_user_wrong_password_and_no_changes() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); await fixture.Service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); - var user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); + User? user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); user.Should().NotBeNull(); (await fixture.Service.UpdateInfoAsync("missing", null, null, "Password1!", CancellationToken.None)).Succeeded.Should().BeFalse(); @@ -176,14 +176,14 @@ public async Task Update_info_should_cover_missing_user_wrong_password_and_no_ch [Fact] public async Task Setup_should_accept_an_existing_administrator_role_and_reject_invalid_password() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); var role = new Role("Administrator"); (await fixture.RoleManager.CreateAsync(role)).Succeeded.Should().BeTrue(); - var invalid = await fixture.Service.InitializeSetupAsync("admin@example.com", "weak", CancellationToken.None); + IdentityResultResponse invalid = await fixture.Service.InitializeSetupAsync("admin@example.com", "weak", CancellationToken.None); invalid.Succeeded.Should().BeFalse(); - var valid = await fixture.Service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); + IdentityResultResponse valid = await fixture.Service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); valid.Succeeded.Should().BeTrue(); } @@ -194,7 +194,7 @@ public async Task Setup_should_accept_an_existing_administrator_role_and_reject_ [Fact] public async Task Logging_email_sender_should_complete_both_messages() { - using var provider = new ServiceCollection().AddLogging().BuildServiceProvider(); + using ServiceProvider provider = new ServiceCollection().AddLogging().BuildServiceProvider(); var sender = new LoggingIdentityEmailSender(provider.GetRequiredService>()); await sender.SendConfirmationAsync("user@example.com", "/confirm", CancellationToken.None); @@ -211,12 +211,12 @@ private static string GenerateTotp(string sharedKey) { ArgumentException.ThrowIfNullOrWhiteSpace(sharedKey); - var key = DecodeBase32(sharedKey); - var counter = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30)); + byte[] key = DecodeBase32(sharedKey); + byte[] counter = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30)); using var hmac = new HMACSHA1(key); - var hash = hmac.ComputeHash(counter); - var offset = hash[^1] & 0x0F; - var binary = ((hash[offset] & 0x7F) << 24) + byte[] hash = hmac.ComputeHash(counter); + int offset = hash[^1] & 0x0F; + int binary = ((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16) | ((hash[offset + 2] & 0xFF) << 8) | (hash[offset + 3] & 0xFF); @@ -234,13 +234,13 @@ private static byte[] DecodeBase32(string value) { ArgumentException.ThrowIfNullOrWhiteSpace(value); const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - var buffer = 0; - var bits = 0; + int buffer = 0; + int bits = 0; var bytes = new List(); - foreach (var character in value.TrimEnd('=').ToUpperInvariant()) + foreach (char character in value.TrimEnd('=').ToUpperInvariant()) { - var index = alphabet.IndexOf(character); + int index = alphabet.IndexOf(character); if (index < 0) { throw new ArgumentException("The authenticator shared key is not valid Base32.", nameof(value)); diff --git a/tests/server/RemoteTickets.UnitTests/IdentityFailurePathTests.cs b/tests/server/RemoteTickets.UnitTests/IdentityFailurePathTests.cs index 4fee750..b78e72b 100644 --- a/tests/server/RemoteTickets.UnitTests/IdentityFailurePathTests.cs +++ b/tests/server/RemoteTickets.UnitTests/IdentityFailurePathTests.cs @@ -8,7 +8,7 @@ public sealed class IdentityFailurePathTests [Fact] public async Task Identity_service_should_fail_safely_for_invalid_credentials_and_tokens() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); (await fixture.Service.LoginAsync("missing@example.com", "Password1!", null, null, CancellationToken.None)).Should().BeNull(); (await fixture.Service.RefreshAsync("not-a-token", CancellationToken.None)).Should().BeNull(); @@ -23,7 +23,7 @@ public async Task Identity_service_should_fail_safely_for_invalid_credentials_an [Fact] public async Task Resend_confirmation_should_be_idempotent_for_unknown_or_confirmed_users() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); (await fixture.Service.ResendConfirmationEmailAsync("missing@example.com", CancellationToken.None)).Succeeded.Should().BeTrue(); await fixture.Service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); diff --git a/tests/server/RemoteTickets.UnitTests/IdentityHandlerTests.cs b/tests/server/RemoteTickets.UnitTests/IdentityHandlerTests.cs index 05fe20f..fa504f9 100644 --- a/tests/server/RemoteTickets.UnitTests/IdentityHandlerTests.cs +++ b/tests/server/RemoteTickets.UnitTests/IdentityHandlerTests.cs @@ -3,42 +3,63 @@ namespace RemoteTickets.UnitTests; /// Verifies that identity handlers delegate requests to the identity application service. public sealed class IdentityHandlerTests { - /// Verifies that every identity handler forwards its request and returns the service result. - /// A task representing the asynchronous test operation. [Fact] public async Task All_identity_handlers_should_delegate_to_identity_service() { var service = new FakeIdentityService(); + IdentityHandlers handlers = new(service); + (await handlers.Handle(new ReceiveContext(new("a@b.com", "Password1!")), CancellationToken.None)).Should().Be(IdentityResultResponse.Success()); + (await handlers.Handle(new ReceiveContext(new("a@b.com", "Password1!")), CancellationToken.None)).Succeeded.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("refresh")), CancellationToken.None)).Succeeded.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("access")), CancellationToken.None)).Data.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("id", "code")), CancellationToken.None)).Data.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("a@b.com")), CancellationToken.None)).Succeeded.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("a@b.com")), CancellationToken.None)).Succeeded.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("a@b.com", "code", "Password2!")), CancellationToken.None)).Succeeded.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("id")), CancellationToken.None)).Succeeded.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("id", null, null, "Password1!")), CancellationToken.None)).Succeeded.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("id", null, null, false, false, false)), CancellationToken.None)).Succeeded.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new()), CancellationToken.None)).IsSetupComplete.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("a@b.com", "Password1!")), CancellationToken.None)).Succeeded.Should().BeTrue(); + } + + [Fact] + public async Task Identity_handlers_should_cover_tenant_and_failure_responses() + { + var service = new FakeIdentityService + { + ReturnNullTokens = true, + ReturnNullIdentity = true, + ReturnNullTwoFactor = true + }; + IdentityHandlers handlers = new(service); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new("a@b.com", "Password1!")), CancellationToken.None)).Should().Be(IdentityResultResponse.Success()); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new("a@b.com", "Password1!")), CancellationToken.None)).Succeeded.Should().BeTrue(); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new("refresh")), CancellationToken.None)).Succeeded.Should().BeTrue(); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new("access")), CancellationToken.None)).Data.Should().BeTrue(); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new("id", "code")), CancellationToken.None)).Data.Should().BeTrue(); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new("a@b.com")), CancellationToken.None)).Succeeded.Should().BeTrue(); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new("a@b.com")), CancellationToken.None)).Succeeded.Should().BeTrue(); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new("a@b.com", "code", "Password2!")), CancellationToken.None)).Succeeded.Should().BeTrue(); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new("id")), CancellationToken.None)).Succeeded.Should().BeTrue(); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new("id", null, null, "Password1!")), CancellationToken.None)).Succeeded.Should().BeTrue(); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new("id", null, null, false, false, false)), CancellationToken.None)).Succeeded.Should().BeTrue(); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new()), CancellationToken.None)).IsSetupComplete.Should().BeTrue(); - (await new IdentityHandlers(service).Handle(new ReceiveContext(new("a@b.com", "Password1!")), CancellationToken.None)).Succeeded.Should().BeTrue(); + (await handlers.Handle(new ReceiveContext(new("tenant-a", "a@b.com", "Password1!", null, null)), CancellationToken.None)).Succeeded.Should().BeFalse(); + (await handlers.Handle(new ReceiveContext(new("tenant-a", "refresh")), CancellationToken.None)).Succeeded.Should().BeFalse(); + (await handlers.Handle(new ReceiveContext(new("missing")), CancellationToken.None)).Succeeded.Should().BeFalse(); + (await handlers.Handle(new ReceiveContext(new("id", null, null, false, false, false)), CancellationToken.None)).Succeeded.Should().BeFalse(); } private sealed class FakeIdentityService : IIdentityService { + public bool ReturnNullTokens { get; init; } + public bool ReturnNullIdentity { get; init; } + public bool ReturnNullTwoFactor { get; init; } public Task RegisterAsync(string email, string password, CancellationToken cancellationToken) => Task.FromResult(IdentityResultResponse.Success()); - public Task LoginAsync(string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken) => Task.FromResult(new("Bearer", "access", 900, "refresh")); - public Task RefreshAsync(string refreshToken, CancellationToken cancellationToken) => Task.FromResult(new("Bearer", "access", 900, "refresh")); + public Task LoginAsync(string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken) => Task.FromResult(ReturnNullTokens ? null : new("Bearer", "access", 900, "refresh")); + public Task RefreshAsync(string refreshToken, CancellationToken cancellationToken) => Task.FromResult(ReturnNullTokens ? null : new("Bearer", "access", 900, "refresh")); public Task RevokeAsync(string accessToken, CancellationToken cancellationToken) => Task.FromResult(true); public Task ConfirmEmailAsync(string userId, string code, string? changedEmail, CancellationToken cancellationToken) => Task.FromResult(true); public Task ResendConfirmationEmailAsync(string email, CancellationToken cancellationToken) => Task.FromResult(IdentityResultResponse.Success()); public Task ForgotPasswordAsync(string email, CancellationToken cancellationToken) => Task.FromResult(IdentityResultResponse.Success()); public Task ResetPasswordAsync(string email, string resetCode, string newPassword, CancellationToken cancellationToken) => Task.FromResult(IdentityResultResponse.Success()); - public Task GetInfoAsync(string userId, CancellationToken cancellationToken) => Task.FromResult(new("a@b.com", true)); + public Task GetInfoAsync(string userId, CancellationToken cancellationToken) => Task.FromResult(ReturnNullIdentity ? null : new("a@b.com", true)); public Task UpdateInfoAsync(string userId, string? newEmail, string? newPassword, string oldPassword, CancellationToken cancellationToken) => Task.FromResult(IdentityResultResponse.Success()); - public Task ConfigureTwoFactorAsync(string userId, bool? enable, string? twoFactorCode, bool resetRecoveryCodes, bool resetSharedKey, bool forgetMachine, CancellationToken cancellationToken) => Task.FromResult(new(null, 0, null, false, false)); + public Task ConfigureTwoFactorAsync(string userId, bool? enable, string? twoFactorCode, bool resetRecoveryCodes, bool resetSharedKey, bool forgetMachine, CancellationToken cancellationToken) => Task.FromResult(ReturnNullTwoFactor ? null : new(null, 0, null, false, false)); public Task GetSetupStatusAsync(CancellationToken cancellationToken) => Task.FromResult(new SetupStatusResponse(false, true)); public Task InitializeSetupAsync(string email, string password, CancellationToken cancellationToken) => Task.FromResult(IdentityResultResponse.Success()); + public Task LoginAsync(string tenantId, string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken) => Task.FromResult(ReturnNullTokens ? null : new("Bearer", "access", 900, "refresh")); + public Task CreateTenantAdminAsync(string tenantId, string email, string password, CancellationToken cancellationToken) => Task.FromResult(IdentityResultResponse.Success()); + public Task RefreshAsync(string tenantId, string refreshToken, CancellationToken cancellationToken) => Task.FromResult(ReturnNullTokens ? null : new("Bearer", "access", 900, "refresh")); } } diff --git a/tests/server/RemoteTickets.UnitTests/IdentityServiceTests.cs b/tests/server/RemoteTickets.UnitTests/IdentityServiceTests.cs index 74b2dde..7fc562a 100644 --- a/tests/server/RemoteTickets.UnitTests/IdentityServiceTests.cs +++ b/tests/server/RemoteTickets.UnitTests/IdentityServiceTests.cs @@ -1,3 +1,5 @@ +using RemoteTickets.Infrastructure.Setup; + namespace RemoteTickets.UnitTests; /// Verifies end-to-end identity service workflows using the test Identity infrastructure. @@ -8,28 +10,28 @@ public sealed class IdentityServiceTests [Fact] public async Task Setup_should_create_administrator_and_issue_revocable_tokens() { - await using var fixture = await IdentityFixture.CreateAsync(); - var service = fixture.Service; - var before = await service.GetSetupStatusAsync(CancellationToken.None); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); + IdentityService service = fixture.Service; + SetupStatusResponse before = await service.GetSetupStatusAsync(CancellationToken.None); before.IsSetupRequired.Should().BeTrue(); before.IsSetupComplete.Should().BeFalse(); - var setup = await service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); + IdentityResultResponse setup = await service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); setup.Succeeded.Should().BeTrue(); - var after = await service.GetSetupStatusAsync(CancellationToken.None); + SetupStatusResponse after = await service.GetSetupStatusAsync(CancellationToken.None); after.IsSetupRequired.Should().BeFalse(); after.IsSetupComplete.Should().BeTrue(); - var duplicate = await service.InitializeSetupAsync("second@example.com", "Password1!", CancellationToken.None); + IdentityResultResponse duplicate = await service.InitializeSetupAsync("second@example.com", "Password1!", CancellationToken.None); duplicate.Succeeded.Should().BeFalse(); - var tokens = await service.LoginAsync("admin@example.com", "Password1!", null, null, CancellationToken.None); + TokenResponse? tokens = await service.LoginAsync("admin@example.com", "Password1!", null, null, CancellationToken.None); tokens.Should().NotBeNull(); - var principal = fixture.TokenService.ValidateToken(tokens!.AccessToken); + ClaimsPrincipal? principal = fixture.TokenService.ValidateToken(tokens!.AccessToken); principal.Should().NotBeNull(); principal!.FindAll(ClaimTypes.Role).Select(x => x.Value).Should().Contain("Administrator"); principal.FindAll(IdentityClaimTypes.Permission).Select(x => x.Value).Should().Contain("system.admin"); - var refreshed = await service.RefreshAsync(tokens.RefreshToken, CancellationToken.None); + TokenResponse? refreshed = await service.RefreshAsync(tokens.RefreshToken, CancellationToken.None); refreshed.Should().NotBeNull(); fixture.TokenService.ValidateToken(tokens.RefreshToken).Should().BeNull(); - var revoked = await service.RevokeAsync(refreshed!.AccessToken, CancellationToken.None); + bool revoked = await service.RevokeAsync(refreshed!.AccessToken, CancellationToken.None); revoked.Should().BeTrue(); fixture.TokenService.ValidateToken(refreshed.AccessToken).Should().BeNull(); } @@ -39,23 +41,23 @@ public async Task Setup_should_create_administrator_and_issue_revocable_tokens() [Fact] public async Task Registration_confirmation_and_password_reset_should_work() { - await using var fixture = await IdentityFixture.CreateAsync(); - var registration = await fixture.Service.RegisterAsync("user@example.com", "Password1!", CancellationToken.None); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); + IdentityResultResponse registration = await fixture.Service.RegisterAsync("user@example.com", "Password1!", CancellationToken.None); registration.Succeeded.Should().BeTrue(); fixture.EmailSender.ConfirmationLinks.Should().ContainSingle(); - var confirmationQuery = ParseQuery(fixture.EmailSender.ConfirmationLinks.Single()); - var confirmed = await fixture.Service.ConfirmEmailAsync(confirmationQuery["userId"], confirmationQuery["code"], null, CancellationToken.None); + Dictionary confirmationQuery = ParseQuery(fixture.EmailSender.ConfirmationLinks.Single()); + bool confirmed = await fixture.Service.ConfirmEmailAsync(confirmationQuery["userId"], confirmationQuery["code"], null, CancellationToken.None); confirmed.Should().BeTrue(); - var info = await fixture.Service.GetInfoAsync(confirmationQuery["userId"], CancellationToken.None); + IdentityInfoResponse? info = await fixture.Service.GetInfoAsync(confirmationQuery["userId"], CancellationToken.None); info.Should().NotBeNull(); info!.IsEmailConfirmed.Should().BeTrue(); - var forgot = await fixture.Service.ForgotPasswordAsync("user@example.com", CancellationToken.None); + IdentityResultResponse forgot = await fixture.Service.ForgotPasswordAsync("user@example.com", CancellationToken.None); forgot.Succeeded.Should().BeTrue(); fixture.EmailSender.PasswordResetLinks.Should().ContainSingle(); - var resetQuery = ParseQuery(fixture.EmailSender.PasswordResetLinks.Single()); - var reset = await fixture.Service.ResetPasswordAsync("user@example.com", resetQuery["code"], "Password2!", CancellationToken.None); + Dictionary resetQuery = ParseQuery(fixture.EmailSender.PasswordResetLinks.Single()); + IdentityResultResponse reset = await fixture.Service.ResetPasswordAsync("user@example.com", resetQuery["code"], "Password2!", CancellationToken.None); reset.Succeeded.Should().BeTrue(); - var login = await fixture.Service.LoginAsync("user@example.com", "Password2!", null, null, CancellationToken.None); + TokenResponse? login = await fixture.Service.LoginAsync("user@example.com", "Password2!", null, null, CancellationToken.None); login.Should().NotBeNull(); } @@ -64,19 +66,19 @@ public async Task Registration_confirmation_and_password_reset_should_work() [Fact] public async Task Update_info_should_change_email_and_password_after_current_password_validation() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); await fixture.Service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); - var user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); + User? user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); user.Should().NotBeNull(); - var result = await fixture.Service.UpdateInfoAsync(user!.Id, "new-admin@example.com", "Password2!", "Password1!", CancellationToken.None); + IdentityResultResponse result = await fixture.Service.UpdateInfoAsync(user!.Id, "new-admin@example.com", "Password2!", "Password1!", CancellationToken.None); result.Succeeded.Should().BeTrue(); - var login = await fixture.Service.LoginAsync("new-admin@example.com", "Password2!", null, null, CancellationToken.None); + TokenResponse? login = await fixture.Service.LoginAsync("new-admin@example.com", "Password2!", null, null, CancellationToken.None); login.Should().NotBeNull(); } private static Dictionary ParseQuery(string uri) { - var query = new Uri("https://localhost" + uri).Query.TrimStart('?'); + string query = new Uri("https://localhost" + uri).Query.TrimStart('?'); return query.Split('&', StringSplitOptions.RemoveEmptyEntries) .Select(part => part.Split('=', 2)) .ToDictionary(part => part[0], part => Uri.UnescapeDataString(part[1])); @@ -92,7 +94,7 @@ private IdentityFixture(ServiceProvider provider, CapturingEmailSender emailSend _provider = provider; EmailSender = emailSender; } - + public ISetupConfigurationStore ConfigurationStore => _provider.GetRequiredService(); public IdentityService Service => _provider.GetRequiredService(); public IJwtTokenService TokenService => _provider.GetRequiredService(); public UserManager UserManager => _provider.GetRequiredService>(); @@ -133,8 +135,9 @@ public static async Task CreateAsync() services.AddSingleton(); services.AddScoped(); services.AddSingleton(emailSender); + services.AddScoped(); services.AddScoped(); - var provider = services.BuildServiceProvider(); + ServiceProvider provider = services.BuildServiceProvider(); await provider.GetRequiredService().Database.EnsureCreatedAsync(); return new IdentityFixture(provider, emailSender); } diff --git a/tests/server/RemoteTickets.UnitTests/IdentityTwoFactorTests.cs b/tests/server/RemoteTickets.UnitTests/IdentityTwoFactorTests.cs index d4bb4aa..ecd369b 100644 --- a/tests/server/RemoteTickets.UnitTests/IdentityTwoFactorTests.cs +++ b/tests/server/RemoteTickets.UnitTests/IdentityTwoFactorTests.cs @@ -8,28 +8,28 @@ public sealed class IdentityTwoFactorTests [Fact] public async Task Two_factor_should_be_configurable_and_used_during_login() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); await fixture.Service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); - var user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); + User? user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); user.Should().NotBeNull(); await fixture.UserManager.ResetAuthenticatorKeyAsync(user!); - var key = await fixture.UserManager.GetAuthenticatorKeyAsync(user); + string? key = await fixture.UserManager.GetAuthenticatorKeyAsync(user); key.Should().NotBeNullOrWhiteSpace(); - var code = CreateAuthenticatorCode(key!, DateTimeOffset.UtcNow); + string code = CreateAuthenticatorCode(key!, DateTimeOffset.UtcNow); - var enabled = await fixture.Service.ConfigureTwoFactorAsync(user!.Id, true, code, true, false, false, CancellationToken.None); + TwoFactorResponse? enabled = await fixture.Service.ConfigureTwoFactorAsync(user!.Id, true, code, true, false, false, CancellationToken.None); enabled.Should().NotBeNull(); enabled!.IsTwoFactorEnabled.Should().BeTrue(); enabled.RecoveryCodes.Should().NotBeNullOrEmpty(); - var loginKey = await fixture.UserManager.GetAuthenticatorKeyAsync(user); - var loginCode = CreateAuthenticatorCode(loginKey!, DateTimeOffset.UtcNow); - var login = await fixture.Service.LoginAsync("admin@example.com", "Password1!", loginCode, null, CancellationToken.None); + string? loginKey = await fixture.UserManager.GetAuthenticatorKeyAsync(user); + string loginCode = CreateAuthenticatorCode(loginKey!, DateTimeOffset.UtcNow); + TokenResponse? login = await fixture.Service.LoginAsync("admin@example.com", "Password1!", loginCode, null, CancellationToken.None); login.Should().NotBeNull(); - var disabled = await fixture.Service.ConfigureTwoFactorAsync(user.Id, false, null, false, true, false, CancellationToken.None); + TwoFactorResponse? disabled = await fixture.Service.ConfigureTwoFactorAsync(user.Id, false, null, false, true, false, CancellationToken.None); disabled.Should().NotBeNull(); disabled!.IsTwoFactorEnabled.Should().BeFalse(); } @@ -39,12 +39,14 @@ public async Task Two_factor_should_be_configurable_and_used_during_login() [Fact] public async Task Two_factor_should_reject_unknown_user_and_invalid_code() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); (await fixture.Service.ConfigureTwoFactorAsync("missing", true, "123456", false, false, false, CancellationToken.None)).Should().BeNull(); - await fixture.Service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); - var user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); + IdentityResultResponse setup = await fixture.Service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); + setup.Should().NotBeNull(); + setup.Succeeded.Should().BeTrue(); + User? user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); user.Should().NotBeNull(); (await fixture.Service.ConfigureTwoFactorAsync(user!.Id, true, "123456", false, false, false, CancellationToken.None)).Should().BeNull(); @@ -55,12 +57,12 @@ public async Task Two_factor_should_reject_unknown_user_and_invalid_code() [Fact] public async Task Two_factor_should_generate_recovery_codes_when_requested() { - await using var fixture = await IdentityFixture.CreateAsync(); + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); await fixture.Service.InitializeSetupAsync("admin@example.com", "Password1!", CancellationToken.None); - var user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); + User? user = await fixture.UserManager.FindByEmailAsync("admin@example.com"); user.Should().NotBeNull(); - var result = await fixture.Service.ConfigureTwoFactorAsync(user!.Id, null, null, true, false, true, CancellationToken.None); + TwoFactorResponse? result = await fixture.Service.ConfigureTwoFactorAsync(user!.Id, null, null, true, false, true, CancellationToken.None); result.Should().NotBeNull(); result!.RecoveryCodes.Should().NotBeNullOrEmpty(); result.IsTwoFactorEnabled.Should().BeFalse(); @@ -68,30 +70,30 @@ public async Task Two_factor_should_generate_recovery_codes_when_requested() private static string CreateAuthenticatorCode(string secret, DateTimeOffset timestamp) { - var key = Base32Decode(secret); - var counter = BitConverter.GetBytes(timestamp.ToUnixTimeSeconds() / 30); + byte[] key = Base32Decode(secret); + byte[] counter = BitConverter.GetBytes(timestamp.ToUnixTimeSeconds() / 30); if (BitConverter.IsLittleEndian) { Array.Reverse(counter); } using var hmac = new HMACSHA1(key); - var hash = hmac.ComputeHash(counter); - var offset = hash[^1] & 0x0F; - var binary = ((hash[offset] & 0x7F) << 24) | (hash[offset + 1] << 16) | (hash[offset + 2] << 8) | hash[offset + 3]; + byte[] hash = hmac.ComputeHash(counter); + int offset = hash[^1] & 0x0F; + int binary = ((hash[offset] & 0x7F) << 24) | (hash[offset + 1] << 16) | (hash[offset + 2] << 8) | hash[offset + 3]; return (binary % 1_000_000).ToString("D6"); } private static byte[] Base32Decode(string value) { - var alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - var buffer = 0; - var bitsLeft = 0; + string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + int buffer = 0; + int bitsLeft = 0; var result = new List(); - foreach (var character in value.TrimEnd('=').ToUpperInvariant()) + foreach (char character in value.TrimEnd('=').ToUpperInvariant()) { - var index = alphabet.IndexOf(character); + int index = alphabet.IndexOf(character); if (index < 0) { throw new FormatException("Invalid Base32 value."); diff --git a/tests/server/RemoteTickets.UnitTests/IdentityValidatorTests.cs b/tests/server/RemoteTickets.UnitTests/IdentityValidatorTests.cs index ce51dc0..d0522a3 100644 --- a/tests/server/RemoteTickets.UnitTests/IdentityValidatorTests.cs +++ b/tests/server/RemoteTickets.UnitTests/IdentityValidatorTests.cs @@ -8,7 +8,7 @@ public sealed class IdentityValidatorTests [Fact] public async Task Register_validator_should_reject_invalid_credentials() { - var result = await new RegisterCommandValidator().ValidateAsync(new RegisterCommand("bad", "short")); + ValidationResult result = await new RegisterCommandValidator().ValidateAsync(new RegisterCommand("bad", "short")); result.IsValid.Should().BeFalse(); result.Errors.Should().NotBeEmpty(); } @@ -18,7 +18,7 @@ public async Task Register_validator_should_reject_invalid_credentials() [Fact] public async Task Login_validator_should_reject_both_two_factor_codes() { - var result = await new LoginCommandValidator().ValidateAsync(new LoginCommand("user@example.com", "Password1!", "123456", "recovery")); + ValidationResult result = await new LoginCommandValidator().ValidateAsync(new LoginCommand("user@example.com", "Password1!", "123456", "recovery")); result.IsValid.Should().BeFalse(); } @@ -27,7 +27,7 @@ public async Task Login_validator_should_reject_both_two_factor_codes() [Fact] public async Task Reset_validator_should_require_email_code_and_password() { - var result = await new ResetPasswordCommandValidator().ValidateAsync(new ResetPasswordCommand("", "", "short")); + ValidationResult result = await new ResetPasswordCommandValidator().ValidateAsync(new ResetPasswordCommand("", "", "short")); result.IsValid.Should().BeFalse(); result.Errors.Select(x => x.PropertyName).Should().Contain(["Email", "ResetCode", "NewPassword"]); } @@ -37,7 +37,7 @@ public async Task Reset_validator_should_require_email_code_and_password() [Fact] public async Task Update_validator_should_allow_optional_email_and_password() { - var result = await new UpdateIdentityInfoCommandValidator().ValidateAsync(new UpdateIdentityInfoCommand("user-id", null, null, "Password1!")); + ValidationResult result = await new UpdateIdentityInfoCommandValidator().ValidateAsync(new UpdateIdentityInfoCommand("user-id", null, null, "Password1!")); result.IsValid.Should().BeTrue(); } diff --git a/tests/server/RemoteTickets.UnitTests/JwtTokenServiceTests.cs b/tests/server/RemoteTickets.UnitTests/JwtTokenServiceTests.cs index ac1d59d..71358b0 100644 --- a/tests/server/RemoteTickets.UnitTests/JwtTokenServiceTests.cs +++ b/tests/server/RemoteTickets.UnitTests/JwtTokenServiceTests.cs @@ -10,7 +10,7 @@ public void Token_service_should_reject_invalid_secret_length() var store = new RevokedTokenStore(); var service = new JwtTokenService(Options.Create(new JwtOptions { SecretKey = "short" }), store); - var action = () => service.CreateTokens("user", "user@example.com", [], []); + Func action = () => service.CreateTokens("user", "user@example.com", [], []); action.Should().Throw(); } @@ -31,10 +31,10 @@ public void Revoked_token_store_should_expire_entries() public void Token_service_should_round_trip_claims_and_metadata() { var store = new RevokedTokenStore(); - var service = CreateService(store); - var tokens = service.CreateTokens("user-id", "user@example.com", ["Administrator"], [new Claim(IdentityClaimTypes.Permission, "system.admin")]); + JwtTokenService service = CreateService(store); + TokenResponse tokens = service.CreateTokens("user-id", "user@example.com", ["Administrator"], [new Claim(IdentityClaimTypes.Permission, "system.admin")]); - var principal = service.ValidateToken(tokens.AccessToken); + ClaimsPrincipal? principal = service.ValidateToken(tokens.AccessToken); principal.Should().NotBeNull(); principal!.FindFirstValue(ClaimTypes.Role).Should().Be("Administrator"); principal.FindFirstValue(IdentityClaimTypes.Permission).Should().Be("system.admin"); @@ -51,9 +51,9 @@ public void Token_service_should_round_trip_claims_and_metadata() public void Token_service_should_reject_a_revoked_token() { var store = new RevokedTokenStore(); - var service = CreateService(store); - var tokens = service.CreateTokens("user-id", "user@example.com", [], []); - var tokenId = service.GetTokenId(tokens.AccessToken); + JwtTokenService service = CreateService(store); + TokenResponse tokens = service.CreateTokens("user-id", "user@example.com", [], []); + string? tokenId = service.GetTokenId(tokens.AccessToken); tokenId.Should().NotBeNull(); store.Revoke(tokenId!, DateTimeOffset.UtcNow.AddMinutes(1)); diff --git a/tests/server/RemoteTickets.UnitTests/PersistenceTests.cs b/tests/server/RemoteTickets.UnitTests/PersistenceTests.cs index 946ed6a..9c0682c 100644 --- a/tests/server/RemoteTickets.UnitTests/PersistenceTests.cs +++ b/tests/server/RemoteTickets.UnitTests/PersistenceTests.cs @@ -1,45 +1,117 @@ namespace RemoteTickets.UnitTests; -/// Verifies Entity Framework Core persistence behavior for soft-deletable identity entities. +/// Verifies Entity Framework Core persistence behavior for soft-deletable and auditable identity entities. public sealed class PersistenceTests { - /// Verifies that soft-deleted users are hidden by the configured query filter. - /// A task representing the asynchronous test operation. [Fact] public async Task Soft_deleted_users_should_be_hidden_by_query_filter() { - await using var fixture = await CreateContextAsync(); + await using ContextFixture fixture = await CreateContextAsync(); var user = new User("deleted@example.com") { Email = "deleted@example.com", EmailConfirmed = true }; fixture.Context.Users.Add(user); fixture.Context.SaveChanges(); - fixture.Context.Users.Remove(user); fixture.Context.SaveChanges(); - user.IsDeleted.Should().BeTrue(); user.DeletedAt.Should().NotBeNull(); (await fixture.Context.Users.SingleOrDefaultAsync(x => x.Id == user.Id)).Should().BeNull(); (await fixture.Context.Users.IgnoreQueryFilters().SingleAsync(x => x.Id == user.Id)).IsDeleted.Should().BeTrue(); + (await fixture.Context.EntityAuditHistory.Where(x => x.EntityId == user.Id).OrderBy(x => x.UpdatedAt).LastAsync(TestContext.Current.CancellationToken)).Operation.Should().Be("Deleted"); } - /// Verifies that asynchronous save operations apply the same soft-delete behavior. - /// A task representing the asynchronous test operation. [Fact] public async Task Async_soft_delete_should_apply_the_same_behavior() { - await using var fixture = await CreateContextAsync(); + await using ContextFixture fixture = await CreateContextAsync(); var user = new User("async@example.com") { Email = "async@example.com", EmailConfirmed = true }; fixture.Context.Users.Add(user); await fixture.Context.SaveChangesAsync(TestContext.Current.CancellationToken); - fixture.Context.Users.Remove(user); await fixture.Context.SaveChangesAsync(false, TestContext.Current.CancellationToken); - user.IsDeleted.Should().BeTrue(); user.DeletedAt.Should().NotBeNull(); } - /// Verifies that user and role constructors assign non-empty identity identifiers. + [Fact] + public async Task Auditable_entities_should_record_creation_and_update_states() + { + await using ContextFixture fixture = await CreateContextAsync(); + var user = new User("audit@example.com") { Email = "audit@example.com", EmailConfirmed = true }; + fixture.Context.Users.Add(user); + await fixture.Context.SaveChangesAsync(TestContext.Current.CancellationToken); + user.DisplayName = "Updated"; + await fixture.Context.SaveChangesAsync(TestContext.Current.CancellationToken); + List history = await fixture.Context.EntityAuditHistory.Where(x => x.EntityId == user.Id).OrderBy(x => x.UpdatedAt).ToListAsync(TestContext.Current.CancellationToken); + history.Should().HaveCount(2); + history[0].Operation.Should().Be("Created"); + history[0].PreviousEntityState.Should().Be("{}"); + history[0].CurrentEntityState.Should().Contain("audit@example.com"); + history[1].Operation.Should().Be("Updated"); + history[1].PreviousEntityState.Should().Contain("audit@example.com"); + history[1].CurrentEntityState.Should().Contain("Updated"); + user.CreatedAt.Should().NotBe(default); + user.CreatedBy.Should().Be("system"); + } + + [Fact] + public async Task Non_soft_deletable_auditable_state_should_record_hard_delete() + { + await using ContextFixture fixture = await CreateContextAsync(); + var state = new SystemSetupState { Id = Guid.CreateVersion7().ToString(), IsComplete = false }; + fixture.Context.SystemSetup.Add(state); + await fixture.Context.SaveChangesAsync(TestContext.Current.CancellationToken); + fixture.Context.SystemSetup.Remove(state); + await fixture.Context.SaveChangesAsync(TestContext.Current.CancellationToken); + + EntityAuditRecord history = await fixture.Context.EntityAuditHistory.Where(x => x.EntityId == state.Id).OrderBy(x => x.UpdatedAt).LastAsync(TestContext.Current.CancellationToken); + history.Operation.Should().Be("Deleted"); + history.CurrentEntityState.Should().Contain(state.Id); + } + + [Fact] + public async Task Audit_history_should_record_authenticated_actor_and_schema() + { + var accessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "actor-1")], "test")) } }; + await using ContextFixture fixture = await CreateContextAsync(accessor); + var user = new User("actor@example.com") { Email = "actor@example.com", EmailConfirmed = true }; + fixture.Context.Users.Add(user); + await fixture.Context.SaveChangesAsync(TestContext.Current.CancellationToken); + EntityAuditRecord history = await fixture.Context.EntityAuditHistory.SingleAsync(x => x.EntityId == user.Id, TestContext.Current.CancellationToken); + history.UpdatedBy.Should().Be("actor-1"); + var auditEntity = fixture.Context.Model.FindEntityType(typeof(EntityAuditRecord)); + auditEntity.Should().NotBeNull(); + auditEntity!.GetSchema().Should().Be("audit"); + auditEntity.GetTableName().Should().Be("EntityHistory"); + } + + [Fact] + public async Task Audit_actor_should_fallback_to_subject_email_and_system() + { + var subAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtRegisteredClaimNames.Sub, "subject-1")], "test")) } }; + await using (ContextFixture fixture = await CreateContextAsync(subAccessor)) + { + var user = new User("sub@example.com") { Email = "sub@example.com" }; + fixture.Context.Users.Add(user); + await fixture.Context.SaveChangesAsync(TestContext.Current.CancellationToken); + (await fixture.Context.EntityAuditHistory.SingleAsync(x => x.EntityId == user.Id, TestContext.Current.CancellationToken)).UpdatedBy.Should().Be("subject-1"); + } + var emailAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Email, "actor@example.com")], "test")) } }; + await using (ContextFixture fixture = await CreateContextAsync(emailAccessor)) + { + var user = new User("email@example.com") { Email = "email@example.com" }; + fixture.Context.Users.Add(user); + await fixture.Context.SaveChangesAsync(TestContext.Current.CancellationToken); + (await fixture.Context.EntityAuditHistory.SingleAsync(x => x.EntityId == user.Id, TestContext.Current.CancellationToken)).UpdatedBy.Should().Be("actor@example.com"); + } + await using (ContextFixture fixture = await CreateContextAsync()) + { + var user = new User("system@example.com") { Email = "system@example.com" }; + fixture.Context.Users.Add(user); + await fixture.Context.SaveChangesAsync(TestContext.Current.CancellationToken); + (await fixture.Context.EntityAuditHistory.SingleAsync(x => x.EntityId == user.Id, TestContext.Current.CancellationToken)).UpdatedBy.Should().Be("system"); + } + } + [Fact] public void User_and_role_constructors_should_initialize_identity_ids() { @@ -47,19 +119,16 @@ public void User_and_role_constructors_should_initialize_identity_ids() var namedUser = new User("user"); var role = new Role(); var namedRole = new Role("Administrator"); - user.Id.Should().NotBeNullOrWhiteSpace(); namedUser.Id.Should().NotBeNullOrWhiteSpace(); role.Id.Should().NotBeNullOrWhiteSpace(); namedRole.Id.Should().NotBeNullOrWhiteSpace(); } - private static async Task CreateContextAsync() + private static async Task CreateContextAsync(IHttpContextAccessor? accessor = null) { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) - .Options; - var context = new RemoteTicketsDbContext(options); + DbContextOptions options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString("N")).Options; + var context = new RemoteTicketsDbContext(options, accessor); await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); return new ContextFixture(context); } @@ -67,11 +136,6 @@ private static async Task CreateContextAsync() private sealed class ContextFixture(RemoteTicketsDbContext context) : IAsyncDisposable { public RemoteTicketsDbContext Context { get; } = context; - - public ValueTask DisposeAsync() - { - Context.Dispose(); - return ValueTask.CompletedTask; - } + public ValueTask DisposeAsync() { Context.Dispose(); return ValueTask.CompletedTask; } } } diff --git a/tests/server/RemoteTickets.UnitTests/SetupConfigurationStoreTests.cs b/tests/server/RemoteTickets.UnitTests/SetupConfigurationStoreTests.cs new file mode 100644 index 0000000..8bdb47e --- /dev/null +++ b/tests/server/RemoteTickets.UnitTests/SetupConfigurationStoreTests.cs @@ -0,0 +1,25 @@ +namespace RemoteTickets.UnitTests; + +/// Verifies setup configuration file persistence and malformed-state handling. +public sealed class SetupConfigurationStoreTests +{ + [Fact] + public async Task Setup_configuration_store_should_read_write_and_recover_from_invalid_state() + { + var path = Path.Combine(AppContext.BaseDirectory, "data", "setup.json"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.Delete(path); + var store = new SetupConfigurationStore(); + + store.GetMasterConnectionString().Should().BeNull(); + await store.SetMasterConnectionStringAsync("Server=localhost;Database=master", TestContext.Current.CancellationToken); + store.GetMasterConnectionString().Should().Be("Server=localhost;Database=master"); + + await File.WriteAllTextAsync(path, "{invalid", TestContext.Current.CancellationToken); + store.GetMasterConnectionString().Should().BeNull(); + await FluentActions.Awaiting(() => store.SetMasterConnectionStringAsync(string.Empty, TestContext.Current.CancellationToken)) + .Should().ThrowAsync(); + + File.Delete(path); + } +} diff --git a/tests/server/RemoteTickets.UnitTests/TenantAccessMiddlewareTests.cs b/tests/server/RemoteTickets.UnitTests/TenantAccessMiddlewareTests.cs new file mode 100644 index 0000000..4ea837f --- /dev/null +++ b/tests/server/RemoteTickets.UnitTests/TenantAccessMiddlewareTests.cs @@ -0,0 +1,283 @@ +namespace RemoteTickets.UnitTests; + +/// Verifies tenant routing, setup gates, authentication, and system-administrator bypass behavior. +public sealed class TenantAccessMiddlewareTests +{ + [Fact] + public async Task Infrastructure_paths_should_bypass_tenant_gate() + { + await using var fixture = await CreateFixtureAsync(false); + var context = CreateContext("/_framework/blazor.web.js", "GET"); + var tracker = new InvocationTracker(); + var middleware = CreateMiddleware(fixture, tracker); + + await middleware.InvokeAsync(context); + + tracker.Called.Should().BeTrue(); + } + + [Fact] + public async Task Incomplete_system_setup_should_redirect_html_and_reject_api_requests() + { + await using var fixture = await CreateFixtureAsync(false); + var html = CreateContext("/anything", "GET"); + html.Request.Headers.Accept = "text/html"; + var htmlTracker = new InvocationTracker(); + await CreateMiddleware(fixture, htmlTracker).InvokeAsync(html); + + htmlTracker.Called.Should().BeFalse(); + html.Response.StatusCode.Should().Be(StatusCodes.Status302Found); + html.Response.Headers.Location.ToString().Should().Be("/setup"); + + var api = CreateContext("/api/v1/foo", "POST"); + var apiTracker = new InvocationTracker(); + await CreateMiddleware(fixture, apiTracker).InvokeAsync(api); + + apiTracker.Called.Should().BeFalse(); + api.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + } + + [Fact] + public async Task Setup_endpoint_should_remain_available_before_initialization() + { + await using var fixture = await CreateFixtureAsync(false); + var context = CreateContext("/api/v1/setup/status", "GET"); + var tracker = new InvocationTracker(); + + await CreateMiddleware(fixture, tracker).InvokeAsync(context); + + tracker.Called.Should().BeTrue(); + } + + [Fact] + public async Task Completed_setup_should_reject_system_setup_post() + { + await using var fixture = await CreateFixtureAsync(true); + var context = CreateContext("/api/v1/setup/database", "POST"); + var tracker = new InvocationTracker(); + + await CreateMiddleware(fixture, tracker).InvokeAsync(context); + + tracker.Called.Should().BeFalse(); + context.Response.StatusCode.Should().Be(StatusCodes.Status409Conflict); + } + + [Fact] + public async Task System_route_should_require_sysadmin_and_allow_sysadmin() + { + await using var fixture = await CreateFixtureAsync(true); + var forbidden = CreateContext("/system", "GET"); + forbidden.Request.RouteValues["tenantId"] = "system"; + var forbiddenTracker = new InvocationTracker(); + await CreateMiddleware(fixture, forbiddenTracker).InvokeAsync(forbidden); + forbiddenTracker.Called.Should().BeFalse(); + forbidden.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + + var allowed = CreateContext("/system", "GET"); + allowed.Request.RouteValues["tenantId"] = "system"; + allowed.User = Principal("sys-1", TenantRoles.SysAdmin, null); + var allowedTracker = new InvocationTracker(); + await CreateMiddleware(fixture, allowedTracker).InvokeAsync(allowed); + allowedTracker.Called.Should().BeTrue(); + } + + [Fact] + public async Task Missing_or_inactive_tenant_should_be_not_found() + { + await using var fixture = await CreateFixtureAsync(true); + var missing = CreateContext("/missing", "GET"); + missing.Request.RouteValues["tenantId"] = "missing"; + var missingTracker = new InvocationTracker(); + await CreateMiddleware(fixture, missingTracker).InvokeAsync(missing); + missingTracker.Called.Should().BeFalse(); + missing.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound); + + fixture.Tenants.Items["inactive"] = new TenantResponse("inactive", "Inactive", "db", false, true); + var inactive = CreateContext("/inactive", "GET"); + inactive.Request.RouteValues["tenantId"] = "inactive"; + var inactiveTracker = new InvocationTracker(); + await CreateMiddleware(fixture, inactiveTracker).InvokeAsync(inactive); + inactiveTracker.Called.Should().BeFalse(); + inactive.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public async Task Anonymous_incomplete_tenant_should_be_restricted_except_setup_and_login() + { + await using var fixture = await CreateFixtureAsync(true); + fixture.Tenants.Items["tenant-a"] = new TenantResponse("tenant-a", "Tenant A", "db", true, false); + + var restricted = CreateContext("/tenant-a/products", "GET", true); + restricted.Request.RouteValues["tenantId"] = "tenant-a"; + var restrictedTracker = new InvocationTracker(); + await CreateMiddleware(fixture, restrictedTracker).InvokeAsync(restricted); + restrictedTracker.Called.Should().BeFalse(); + restricted.Response.StatusCode.Should().Be(StatusCodes.Status409Conflict); + + var setup = CreateContext("/tenant-a/setup", "GET", true); + setup.Request.RouteValues["tenantId"] = "tenant-a"; + var setupTracker = new InvocationTracker(); + await CreateMiddleware(fixture, setupTracker).InvokeAsync(setup); + setupTracker.Called.Should().BeTrue(); + + var login = CreateContext("/api/v1/tenant-a/login", "POST", true); + login.Request.RouteValues["tenantId"] = "tenant-a"; + var loginTracker = new InvocationTracker(); + await CreateMiddleware(fixture, loginTracker).InvokeAsync(login); + loginTracker.Called.Should().BeTrue(); + } + + [Fact] + public async Task Authenticated_user_without_tenant_should_be_rejected() + { + await using var fixture = await CreateFixtureAsync(true); + var context = CreateContext("/api/v1/products", "GET"); + context.User = Principal("user-1", TenantRoles.TenantOperator, "tenant-a"); + var tracker = new InvocationTracker(); + + await CreateMiddleware(fixture, tracker).InvokeAsync(context); + + tracker.Called.Should().BeFalse(); + context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + } + + [Fact] + public async Task Authenticated_user_from_another_tenant_should_be_forbidden() + { + await using var fixture = await CreateFixtureAsync(true); + var context = CreateContext("/tenant-b/products", "GET"); + context.Request.RouteValues["tenantId"] = "tenant-b"; + context.User = Principal("user-1", TenantRoles.TenantOperator, "tenant-a"); + var tracker = new InvocationTracker(); + + await CreateMiddleware(fixture, tracker).InvokeAsync(context); + + tracker.Called.Should().BeFalse(); + context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + } + + [Fact] + public async Task Sysadmin_should_bypass_tenant_mismatch() + { + await using var fixture = await CreateFixtureAsync(true); + var context = CreateContext("/tenant-b/products", "GET"); + context.Request.RouteValues["tenantId"] = "tenant-b"; + context.User = Principal("sys-1", TenantRoles.SysAdmin, null); + var tracker = new InvocationTracker(); + + await CreateMiddleware(fixture, tracker).InvokeAsync(context); + + tracker.Called.Should().BeTrue(); + } + + [Fact] + public async Task Authenticated_user_should_be_restricted_until_tenant_setup_is_complete() + { + await using var fixture = await CreateFixtureAsync(true); + fixture.Tenants.Items["tenant-a"] = new TenantResponse("tenant-a", "Tenant A", "db", true, false); + var context = CreateContext("/tenant-a/products", "GET"); + context.Request.RouteValues["tenantId"] = "tenant-a"; + context.User = Principal("user-1", TenantRoles.TenantOperator, "tenant-a"); + var tracker = new InvocationTracker(); + + await CreateMiddleware(fixture, tracker).InvokeAsync(context); + + tracker.Called.Should().BeFalse(); + context.Response.StatusCode.Should().Be(StatusCodes.Status409Conflict); + } + + [Fact] + public async Task Tenant_setup_endpoint_and_matching_complete_tenant_should_continue() + { + await using var fixture = await CreateFixtureAsync(true); + fixture.Tenants.Items["tenant-a"] = new TenantResponse("tenant-a", "Tenant A", "db", true, false); + var setup = CreateContext("/api/v1/tenant-a/setup/complete", "POST"); + setup.Request.RouteValues["tenantId"] = "tenant-a"; + setup.User = Principal("user-1", TenantRoles.TenantAdmin, "tenant-a"); + var setupTracker = new InvocationTracker(); + await CreateMiddleware(fixture, setupTracker).InvokeAsync(setup); + setupTracker.Called.Should().BeTrue(); + + fixture.Tenants.Items["tenant-a"] = new TenantResponse("tenant-a", "Tenant A", "db", true, true); + var normal = CreateContext("/tenant-a/products", "GET"); + normal.Request.RouteValues["tenantId"] = "tenant-a"; + normal.User = Principal("user-1", TenantRoles.TenantOperator, "tenant-a"); + var normalTracker = new InvocationTracker(); + await CreateMiddleware(fixture, normalTracker).InvokeAsync(normal); + normalTracker.Called.Should().BeTrue(); + } + + private static TenantAccessMiddleware CreateMiddleware(Fixture fixture, InvocationTracker tracker) + => new(_ => { tracker.Called = true; return Task.CompletedTask; }, fixture.Tenants, fixture.Context); + + private static DefaultHttpContext CreateContext(string path, string method, bool anonymous = false) + { + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.Request.Method = method; + if (anonymous) + { + context.SetEndpoint(new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(new AllowAnonymousAttribute()), "anonymous")); + } + return context; + } + + private static ClaimsPrincipal Principal(string id, string role, string? tenantId) + { + List claims = [new(ClaimTypes.NameIdentifier, id), new(ClaimTypes.Role, role)]; + if (tenantId is not null) + { + claims.Add(new Claim(TenantClaimTypes.TenantId, tenantId)); + } + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + + private static async Task CreateFixtureAsync(bool setupComplete) + { + DbContextOptions options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + var context = new RemoteTicketsDbContext(options); + context.SystemSetup.Add(new SystemSetupState { Id = RemoteTicketsConstants.SystemSetupId, IsComplete = setupComplete }); + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + return new Fixture(context, new FakeTenantManagementService()); + } + + private sealed class InvocationTracker + { + public bool Called { get; set; } + } + + private sealed class Fixture(RemoteTicketsDbContext context, FakeTenantManagementService tenants) : IAsyncDisposable + { + public RemoteTicketsDbContext Context { get; } = context; + public FakeTenantManagementService Tenants { get; } = tenants; + + public ValueTask DisposeAsync() + { + Context.Dispose(); + return ValueTask.CompletedTask; + } + } + + private sealed class FakeTenantManagementService : ITenantManagementService + { + public Dictionary Items { get; } = new(StringComparer.OrdinalIgnoreCase) + { + ["tenant-a"] = new TenantResponse("tenant-a", "Tenant A", "db", true, true), + ["tenant-b"] = new TenantResponse("tenant-b", "Tenant B", "db", true, true) + }; + + public Task GetAsync(string tenantId, CancellationToken cancellationToken) + => Task.FromResult(Items.GetValueOrDefault(tenantId)); + + public Task CreateAsync(CreateTenantRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task GetSetupStatusAsync(string tenantId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task CompleteSetupAsync(string tenantId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + } +} diff --git a/tests/server/RemoteTickets.UnitTests/TenantIdentityTests.cs b/tests/server/RemoteTickets.UnitTests/TenantIdentityTests.cs new file mode 100644 index 0000000..a7ecdfa --- /dev/null +++ b/tests/server/RemoteTickets.UnitTests/TenantIdentityTests.cs @@ -0,0 +1,68 @@ +namespace RemoteTickets.UnitTests; + +/// Verifies tenant isolation and system-administrator cross-tenant access. +public sealed class TenantIdentityTests +{ + /// Verifies that a tenant user cannot authenticate against another tenant by changing only the route tenant identifier. + [Fact] + public async Task Tenant_user_should_not_login_to_another_tenant() + { + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); + await fixture.Service.InitializeSetupAsync("sysadmin@example.com", "Password1!", CancellationToken.None); + IdentityResultResponse created = await fixture.Service.CreateTenantAdminAsync("tenant-a", "admin-a@example.com", "Password1!", CancellationToken.None); + created.Succeeded.Should().BeTrue(); + TokenResponse? valid = await fixture.Service.LoginAsync("tenant-a", "admin-a@example.com", "Password1!", null, null, CancellationToken.None); + valid.Should().NotBeNull(); + TokenResponse? wrongTenant = await fixture.Service.LoginAsync("tenant-b", "admin-a@example.com", "Password1!", null, null, CancellationToken.None); + wrongTenant.Should().BeNull(); + } + + /// Verifies that a system administrator can authenticate through any tenant route. + [Fact] + public async Task Sysadmin_should_login_through_any_tenant_route() + { + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); + await fixture.Service.InitializeSetupAsync("sysadmin@example.com", "Password1!", CancellationToken.None); + await fixture.Service.CreateTenantAdminAsync("tenant-a", "admin-a@example.com", "Password1!", CancellationToken.None); + TokenResponse? tokens = await fixture.Service.LoginAsync("tenant-b", "sysadmin@example.com", "Password1!", null, null, CancellationToken.None); + tokens.Should().NotBeNull(); + fixture.TokenService.ValidateToken(tokens!.AccessToken)!.IsInRole(TenantRoles.SysAdmin).Should().BeTrue(); + } + + /// Verifies that a refresh token cannot be replayed through another tenant route. + [Fact] + public async Task Refresh_token_should_remain_bound_to_its_tenant() + { + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); + await fixture.Service.InitializeSetupAsync("sysadmin@example.com", "Password1!", CancellationToken.None); + await fixture.Service.CreateTenantAdminAsync("tenant-a", "admin-a@example.com", "Password1!", CancellationToken.None); + TokenResponse? tokens = await fixture.Service.LoginAsync("tenant-a", "admin-a@example.com", "Password1!", null, null, CancellationToken.None); + tokens.Should().NotBeNull(); + TokenResponse? invalid = await fixture.Service.RefreshAsync("tenant-b", tokens!.RefreshToken, CancellationToken.None); + invalid.Should().BeNull(); + TokenResponse? refreshed = await fixture.Service.RefreshAsync("tenant-a", tokens.RefreshToken, CancellationToken.None); + refreshed.Should().NotBeNull(); + fixture.TokenService.ValidateToken(refreshed!.AccessToken)!.FindFirst(TenantClaimTypes.TenantId)!.Value.Should().Be("tenant-a"); + } + + /// Verifies that a non-tenant user cannot authenticate through a tenant route and that tenant administrator validation rejects missing tenants. + [Fact] + public async Task Non_tenant_non_sysadmin_and_missing_tenant_admin_should_be_rejected() + { + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); + User user = new("operator@example.com") { Email = "operator@example.com", EmailConfirmed = true }; + (await fixture.UserManager.CreateAsync(user, "Password1!" )).Succeeded.Should().BeTrue(); + (await fixture.Service.LoginAsync("tenant-a", "operator@example.com", "Password1!", null, null, TestContext.Current.CancellationToken)).Should().BeNull(); + (await fixture.Service.CreateTenantAdminAsync(string.Empty, "admin@example.com", "Password1!", TestContext.Current.CancellationToken)).Succeeded.Should().BeFalse(); + } + + /// Verifies that duplicate tenant administrator emails are rejected after the role is provisioned. + [Fact] + public async Task Duplicate_tenant_admin_email_should_be_rejected() + { + await using IdentityFixture fixture = await IdentityFixture.CreateAsync(); + (await fixture.Service.CreateTenantAdminAsync("tenant-a", "admin@example.com", "Password1!", TestContext.Current.CancellationToken)).Succeeded.Should().BeTrue(); + IdentityResultResponse duplicate = await fixture.Service.CreateTenantAdminAsync("tenant-b", "admin@example.com", "Password1!", TestContext.Current.CancellationToken); + duplicate.Succeeded.Should().BeFalse(); + } +} diff --git a/tests/server/RemoteTickets.UnitTests/TenantManagementValidationTests.cs b/tests/server/RemoteTickets.UnitTests/TenantManagementValidationTests.cs new file mode 100644 index 0000000..2869339 --- /dev/null +++ b/tests/server/RemoteTickets.UnitTests/TenantManagementValidationTests.cs @@ -0,0 +1,15 @@ +namespace RemoteTickets.UnitTests; + +/// Verifies validation performed before tenant database provisioning. +public sealed class TenantManagementValidationTests +{ + [Fact] + public async Task Tenant_provisioning_should_reject_missing_id_database_and_connection_string() + { + var service = new TenantManagementService(null!, null!, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + await FluentActions.Awaiting(() => service.CreateAsync(new CreateTenantRequest(string.Empty, "tenant", "database", "Server=.", "admin@example.com", "Password1!"), TestContext.Current.CancellationToken)).Should().ThrowAsync(); + await FluentActions.Awaiting(() => service.CreateAsync(new CreateTenantRequest("id", "tenant", string.Empty, "Server=.", "admin@example.com", "Password1!"), TestContext.Current.CancellationToken)).Should().ThrowAsync(); + await FluentActions.Awaiting(() => service.CreateAsync(new CreateTenantRequest("id", "tenant", "database", string.Empty, "admin@example.com", "Password1!"), TestContext.Current.CancellationToken)).Should().ThrowAsync(); + } +} diff --git a/tests/server/RemoteTickets.UnitTests/ValidationMiddlewareTests.cs b/tests/server/RemoteTickets.UnitTests/ValidationMiddlewareTests.cs index 3f8ef76..1613c59 100644 --- a/tests/server/RemoteTickets.UnitTests/ValidationMiddlewareTests.cs +++ b/tests/server/RemoteTickets.UnitTests/ValidationMiddlewareTests.cs @@ -10,13 +10,13 @@ public async Task Fluent_message_validator_should_execute_registered_validator() { var services = new ServiceCollection(); services.AddScoped, RegisterCommandValidator>(); - using var provider = services.BuildServiceProvider(); + using ServiceProvider provider = services.BuildServiceProvider(); var validator = new FluentMessageValidator(provider); - var valid = () => validator.ValidateAsync(new RegisterCommand("user@example.com", "Password1!"), CancellationToken.None); + Func valid = () => validator.ValidateAsync(new RegisterCommand("user@example.com", "Password1!"), CancellationToken.None); await valid(); - var invalid = () => validator.ValidateAsync(new RegisterCommand("invalid", "short"), CancellationToken.None); + Func invalid = () => validator.ValidateAsync(new RegisterCommand("invalid", "short"), CancellationToken.None); await invalid.Should().ThrowAsync(); } @@ -25,9 +25,47 @@ public async Task Fluent_message_validator_should_execute_registered_validator() [Fact] public async Task Fluent_message_validator_should_skip_unregistered_messages() { - using var provider = new ServiceCollection().BuildServiceProvider(); + using ServiceProvider provider = new ServiceCollection().BuildServiceProvider(); var validator = new FluentMessageValidator(provider); await validator.ValidateAsync(new object(), CancellationToken.None); } + + /// Verifies that null messages are rejected before validator resolution. + [Fact] + public async Task Fluent_message_validator_should_reject_null_messages() + { + using ServiceProvider provider = new ServiceCollection().BuildServiceProvider(); + var validator = new FluentMessageValidator(provider); + + await FluentActions.Awaiting(() => validator.ValidateAsync(null!, TestContext.Current.CancellationToken)) + .Should().ThrowAsync(); + } + + /// Verifies that the validation middleware delegates its lifecycle to the validator. + [Fact] + public async Task Validation_middleware_should_execute_all_lifecycle_stages() + { + var validator = new FakeMessageValidator(); + var middleware = new ValidationMiddleware>(validator); + var context = new ReceiveContext(new RegisterCommand("user@example.com", "Password1!")); + + middleware.ShouldExecute(context, TestContext.Current.CancellationToken).Should().BeTrue(); + await middleware.BeforeExecute(context, TestContext.Current.CancellationToken); + await middleware.Execute(context, TestContext.Current.CancellationToken); + await middleware.AfterExecute(context, TestContext.Current.CancellationToken); + await middleware.OnException(new InvalidOperationException(), context); + validator.Message.Should().Be(context.Message); + } + + private sealed class FakeMessageValidator : IMessageValidator + { + public object? Message { get; private set; } + + public Task ValidateAsync(object message, CancellationToken cancellationToken) + { + Message = message; + return Task.CompletedTask; + } + } }