diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityEndpoint.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityEndpoint.cs new file mode 100644 index 00000000..3fe4abf0 --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityEndpoint.cs @@ -0,0 +1,46 @@ +using FastEndpoints; + +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity; + +public sealed class RegisterIdentityEndpoint : Endpoint +{ + private readonly RegisterIdentityHandler _handler; + + public RegisterIdentityEndpoint(RegisterIdentityHandler handler) + { + _handler = handler; + } + + public override void Configure() + { + Post("/api/identities"); + AllowAnonymous(); + } + + public override async Task HandleAsync(RegisterIdentityRequest req, CancellationToken ct) + { + try + { + var response = await _handler.HandleAsync(req, ct); + await SendAsync(response, 201, ct); + } + catch (ValidationException ex) + { + await SendProblemDetailsAsync(new ProblemDetails + { + Title = "Validation Error", + Detail = ex.Message, + Status = StatusCodes.Status400BadRequest + }, StatusCodes.Status400BadRequest, cancellation: ct); + } + catch (InvalidOperationException ex) + { + await SendProblemDetailsAsync(new ProblemDetails + { + Title = "Operation Error", + Detail = ex.Message, + Status = StatusCodes.Status409Conflict + }, StatusCodes.Status409Conflict, cancellation: ct); + } + } +} diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityHandler.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityHandler.cs new file mode 100644 index 00000000..5053116a --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityHandler.cs @@ -0,0 +1,47 @@ +using FastEndpoints; +using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain; + +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity; + +public sealed class RegisterIdentityHandler : IEndpointHandler +{ + private readonly IRegisterIdentitySql _sql; + + public RegisterIdentityHandler(IRegisterIdentitySql sql) + { + _sql = sql; + } + + public async Task HandleAsync(RegisterIdentityRequest req, CancellationToken ct) + { + var email = req.Email.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(email) || !email.Contains("@")) + throw new ValidationException("Invalid email format"); + + if (string.IsNullOrWhiteSpace(req.DisplayName) || req.DisplayName.Length > 255) + throw new ValidationException("Display name required, max 255 characters"); + + var emailExists = await _sql.EmailExistsAsync(email, ct); + if (emailExists) + throw new ValidationException("Email already registered"); + + var identityId = Guid.NewGuid(); + var correlationId = Guid.NewGuid().ToString(); + + var state = IdentityState.CreateUndefined(); + var registered = state.Register(); + + var createdId = await _sql.CreateIdentityAsync(identityId, email, req.DisplayName, correlationId, ct); + if (createdId == Guid.Empty) + throw new InvalidOperationException("Failed to create identity"); + + var (id, returnedEmail, displayName, currentState) = await _sql.GetIdentityAsync(createdId, ct); + + return new RegisterIdentityResponse + { + Id = id, + Email = returnedEmail, + State = currentState + }; + } +} diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityRequest.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityRequest.cs new file mode 100644 index 00000000..c6ca57f7 --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityRequest.cs @@ -0,0 +1,7 @@ +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity; + +public sealed record RegisterIdentityRequest +{ + public required string Email { get; init; } + public required string DisplayName { get; init; } +} diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityResponse.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityResponse.cs new file mode 100644 index 00000000..77f10c6c --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentityResponse.cs @@ -0,0 +1,8 @@ +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity; + +public sealed record RegisterIdentityResponse +{ + public required Guid Id { get; init; } + public required string Email { get; init; } + public required string State { get; init; } +} diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentitySql.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentitySql.cs new file mode 100644 index 00000000..f7f09b91 --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RegisterIdentity/RegisterIdentitySql.cs @@ -0,0 +1,68 @@ +using Dapper; +using Npgsql; + +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity; + +public interface IRegisterIdentitySql +{ + Task EmailExistsAsync(string email, CancellationToken ct); + Task CreateIdentityAsync(Guid id, string email, string displayName, string correlationId, CancellationToken ct); + Task<(Guid Id, string Email, string DisplayName, string State)> GetIdentityAsync(Guid id, CancellationToken ct); +} + +public sealed class RegisterIdentitySql : IRegisterIdentitySql +{ + private readonly Func> _connectionFactory; + + public RegisterIdentitySql(Func> connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task EmailExistsAsync(string email, CancellationToken ct) + { + using var conn = await _connectionFactory(); + const string sql = """ + SELECT EXISTS(SELECT 1 FROM identity.identity WHERE email = @email) + """; + return await conn.QuerySingleAsync(sql, new { email }, commandTimeout: 5); + } + + public async Task CreateIdentityAsync(Guid id, string email, string displayName, string correlationId, CancellationToken ct) + { + using var conn = await _connectionFactory(); + const string sql = """ + INSERT INTO identity.identity (id, email, display_name, state, created_at, updated_at, published_at, revision_version, correlation_id) + VALUES (@id, @email, @displayName, @state, NOW(), NOW(), NOW(), 1, @correlationId) + ON CONFLICT (email) DO NOTHING + RETURNING id; + """; + + var result = await conn.QuerySingleOrDefaultAsync(sql, new + { + id, + email, + displayName, + state = Domain.IdentityState.Active, + correlationId + }, commandTimeout: 5); + + return result ?? Guid.Empty; + } + + public async Task<(Guid Id, string Email, string DisplayName, string State)> GetIdentityAsync(Guid id, CancellationToken ct) + { + using var conn = await _connectionFactory(); + const string sql = """ + SELECT id, email, display_name, state + FROM identity.identity + WHERE id = @id + """; + + var row = await conn.QuerySingleOrDefaultAsync(sql, new { id }, commandTimeout: 5); + if (row is null) + throw new InvalidOperationException($"Identity {id} not found"); + + return ((Guid)row.id, (string)row.email, (string)row.display_name, (string)row.state); + } +} diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupEndpoint.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupEndpoint.cs new file mode 100644 index 00000000..b4850a54 --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupEndpoint.cs @@ -0,0 +1,46 @@ +using FastEndpoints; + +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup; + +public sealed class RequestMfaSetupEndpoint : Endpoint +{ + private readonly RequestMfaSetupHandler _handler; + + public RequestMfaSetupEndpoint(RequestMfaSetupHandler handler) + { + _handler = handler; + } + + public override void Configure() + { + Put("/api/identities/{identityId:guid}/request-mfa"); + AllowAnonymous(); + } + + public override async Task HandleAsync(RequestMfaSetupRequest req, CancellationToken ct) + { + try + { + var response = await _handler.HandleAsync(req, ct); + await SendAsync(response, 200, ct); + } + catch (ValidationException ex) + { + await SendProblemDetailsAsync(new ProblemDetails + { + Title = "Validation Error", + Detail = ex.Message, + Status = StatusCodes.Status400BadRequest + }, StatusCodes.Status400BadRequest, cancellation: ct); + } + catch (InvalidOperationException ex) + { + await SendProblemDetailsAsync(new ProblemDetails + { + Title = "Operation Error", + Detail = ex.Message, + Status = StatusCodes.Status409Conflict + }, StatusCodes.Status409Conflict, cancellation: ct); + } + } +} diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupHandler.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupHandler.cs new file mode 100644 index 00000000..be1497b5 --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupHandler.cs @@ -0,0 +1,34 @@ +using FastEndpoints; +using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain; + +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup; + +public sealed class RequestMfaSetupHandler : IEndpointHandler +{ + private readonly IRequestMfaSetupSql _sql; + + public RequestMfaSetupHandler(IRequestMfaSetupSql sql) + { + _sql = sql; + } + + public async Task HandleAsync(RequestMfaSetupRequest req, CancellationToken ct) + { + if (req.IdentityId == Guid.Empty) + throw new ValidationException("Identity ID required"); + + var (identityId, currentState, revision) = await _sql.GetIdentityAsync(req.IdentityId, ct); + + var state = IdentityState.Parse(currentState); + var nextState = state.RequestMfaSetup(); + + await _sql.UpdateIdentityStateAsync(identityId, nextState.Value, revision, ct); + + return new RequestMfaSetupResponse + { + IdentityId = identityId, + PreviousState = currentState, + NewState = nextState.Value + }; + } +} diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupRequest.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupRequest.cs new file mode 100644 index 00000000..5b3275a2 --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupRequest.cs @@ -0,0 +1,6 @@ +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup; + +public sealed record RequestMfaSetupRequest +{ + public required Guid IdentityId { get; init; } +} diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupResponse.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupResponse.cs new file mode 100644 index 00000000..aaefc96a --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupResponse.cs @@ -0,0 +1,8 @@ +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup; + +public sealed record RequestMfaSetupResponse +{ + public required Guid IdentityId { get; init; } + public required string PreviousState { get; init; } + public required string NewState { get; init; } +} diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupSql.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupSql.cs new file mode 100644 index 00000000..d814c599 --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/Features/RequestMfaSetup/RequestMfaSetupSql.cs @@ -0,0 +1,59 @@ +using Dapper; +using Npgsql; + +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup; + +public interface IRequestMfaSetupSql +{ + Task<(Guid Id, string State, int Revision)> GetIdentityAsync(Guid identityId, CancellationToken ct); + Task UpdateIdentityStateAsync(Guid identityId, string newState, int expectedRevision, CancellationToken ct); +} + +public sealed class RequestMfaSetupSql : IRequestMfaSetupSql +{ + private readonly Func> _connectionFactory; + + public RequestMfaSetupSql(Func> connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task<(Guid Id, string State, int Revision)> GetIdentityAsync(Guid identityId, CancellationToken ct) + { + using var conn = await _connectionFactory(); + const string sql = """ + SELECT id, state, revision_version + FROM identity.identity + WHERE id = @identityId + """; + + var row = await conn.QuerySingleOrDefaultAsync(sql, new { identityId }, commandTimeout: 5); + if (row is null) + throw new InvalidOperationException($"Identity {identityId} not found"); + + return ((Guid)row.id, (string)row.state, (int)row.revision_version); + } + + public async Task UpdateIdentityStateAsync(Guid identityId, string newState, int expectedRevision, CancellationToken ct) + { + using var conn = await _connectionFactory(); + const string sql = """ + UPDATE identity.identity + SET state = @newState, + revision_version = revision_version + 1, + updated_at = NOW(), + published_at = NOW() + WHERE id = @identityId AND revision_version = @expectedRevision + """; + + var rowsAffected = await conn.ExecuteAsync(sql, new + { + identityId, + newState, + expectedRevision + }, commandTimeout: 5); + + if (rowsAffected == 0) + throw new InvalidOperationException("Optimistic concurrency violation: state changed"); + } +} diff --git a/src/Modules/IdentityAccess/ManageIdentityAndRoles/ValidationException.cs b/src/Modules/IdentityAccess/ManageIdentityAndRoles/ValidationException.cs new file mode 100644 index 00000000..d501c577 --- /dev/null +++ b/src/Modules/IdentityAccess/ManageIdentityAndRoles/ValidationException.cs @@ -0,0 +1,6 @@ +namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles; + +public sealed class ValidationException : Exception +{ + public ValidationException(string message) : base(message) { } +} diff --git a/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/RegisterIdentityHandlerTests.cs b/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/RegisterIdentityHandlerTests.cs new file mode 100644 index 00000000..a21b4955 --- /dev/null +++ b/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/RegisterIdentityHandlerTests.cs @@ -0,0 +1,81 @@ +using Xunit; +using Moq; +using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity; + +namespace KArtSell.IdentityAccess.UnitTests.ManageIdentityAndRoles; + +public class RegisterIdentityHandlerTests +{ + private readonly Mock _sqlMock; + private readonly RegisterIdentityHandler _handler; + + public RegisterIdentityHandlerTests() + { + _sqlMock = new Mock(); + _handler = new RegisterIdentityHandler(_sqlMock.Object); + } + + [Fact] + public async Task HandleAsync_ValidRequest_CreatesIdentity() + { + var request = new RegisterIdentityRequest + { + Email = "test@example.com", + DisplayName = "Test User" + }; + + var identityId = Guid.NewGuid(); + _sqlMock.Setup(s => s.EmailExistsAsync("test@example.com", It.IsAny())) + .ReturnsAsync(false); + _sqlMock.Setup(s => s.CreateIdentityAsync(It.IsAny(), "test@example.com", "Test User", It.IsAny(), It.IsAny())) + .ReturnsAsync(identityId); + _sqlMock.Setup(s => s.GetIdentityAsync(identityId, It.IsAny())) + .ReturnsAsync((identityId, "test@example.com", "Test User", Domain.IdentityState.Active)); + + var response = await _handler.HandleAsync(request, CancellationToken.None); + + Assert.NotNull(response); + Assert.Equal(identityId, response.Id); + Assert.Equal("test@example.com", response.Email); + Assert.Equal(Domain.IdentityState.Active, response.State); + } + + [Fact] + public async Task HandleAsync_InvalidEmail_ThrowsValidationException() + { + var request = new RegisterIdentityRequest + { + Email = "invalid-email", + DisplayName = "Test User" + }; + + await Assert.ThrowsAsync(() => _handler.HandleAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task HandleAsync_EmailExists_ThrowsValidationException() + { + var request = new RegisterIdentityRequest + { + Email = "existing@example.com", + DisplayName = "Test User" + }; + + _sqlMock.Setup(s => s.EmailExistsAsync("existing@example.com", It.IsAny())) + .ReturnsAsync(true); + + await Assert.ThrowsAsync(() => _handler.HandleAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task HandleAsync_EmptyDisplayName_ThrowsValidationException() + { + var request = new RegisterIdentityRequest + { + Email = "test@example.com", + DisplayName = "" + }; + + await Assert.ThrowsAsync(() => _handler.HandleAsync(request, CancellationToken.None)); + } +} diff --git a/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/RequestMfaSetupHandlerTests.cs b/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/RequestMfaSetupHandlerTests.cs new file mode 100644 index 00000000..6140ccc8 --- /dev/null +++ b/tests/KArtSell.IdentityAccess.UnitTests/ManageIdentityAndRoles/RequestMfaSetupHandlerTests.cs @@ -0,0 +1,72 @@ +using Xunit; +using Moq; +using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup; +using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain; + +namespace KArtSell.IdentityAccess.UnitTests.ManageIdentityAndRoles; + +public class RequestMfaSetupHandlerTests +{ + private readonly Mock _sqlMock; + private readonly RequestMfaSetupHandler _handler; + + public RequestMfaSetupHandlerTests() + { + _sqlMock = new Mock(); + _handler = new RequestMfaSetupHandler(_sqlMock.Object); + } + + [Fact] + public async Task HandleAsync_ActiveIdentity_TransitionsToRequiresMfaSetup() + { + var identityId = Guid.NewGuid(); + var request = new RequestMfaSetupRequest { IdentityId = identityId }; + + _sqlMock.Setup(s => s.GetIdentityAsync(identityId, It.IsAny())) + .ReturnsAsync((identityId, IdentityState.Active, 1)); + _sqlMock.Setup(s => s.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, 1, It.IsAny())) + .Returns(Task.CompletedTask); + + var response = await _handler.HandleAsync(request, CancellationToken.None); + + Assert.Equal(identityId, response.IdentityId); + Assert.Equal(IdentityState.Active, response.PreviousState); + Assert.Equal(IdentityState.RequiresMfaSetup, response.NewState); + + _sqlMock.Verify(s => s.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, 1, It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_EmptyIdentityId_ThrowsValidationException() + { + var request = new RequestMfaSetupRequest { IdentityId = Guid.Empty }; + + await Assert.ThrowsAsync(() => _handler.HandleAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task HandleAsync_IdentityNotFound_ThrowsInvalidOperationException() + { + var identityId = Guid.NewGuid(); + var request = new RequestMfaSetupRequest { IdentityId = identityId }; + + _sqlMock.Setup(s => s.GetIdentityAsync(identityId, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Identity not found")); + + await Assert.ThrowsAsync(() => _handler.HandleAsync(request, CancellationToken.None)); + } + + [Fact] + public async Task HandleAsync_OptimisticConcurrencyViolation_ThrowsInvalidOperationException() + { + var identityId = Guid.NewGuid(); + var request = new RequestMfaSetupRequest { IdentityId = identityId }; + + _sqlMock.Setup(s => s.GetIdentityAsync(identityId, It.IsAny())) + .ReturnsAsync((identityId, IdentityState.Active, 1)); + _sqlMock.Setup(s => s.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, 1, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Optimistic concurrency violation: state changed")); + + await Assert.ThrowsAsync(() => _handler.HandleAsync(request, CancellationToken.None)); + } +}