diff --git a/VigilCareRecordsAPI.Tests/UserManagementTests.cs b/VigilCareRecordsAPI.Tests/UserManagementTests.cs new file mode 100644 index 0000000..fde4719 --- /dev/null +++ b/VigilCareRecordsAPI.Tests/UserManagementTests.cs @@ -0,0 +1,254 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +[Collection("Database")] +public class UserManagementTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private HttpClient _client = null!; + + public UserManagementTests(ApiFixture fixture) => _fixture = fixture; + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + await DataSeeder.SeedAsync(db); + _client = await AuthHelper.LoginAsync(_fixture, "admin1"); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task CreateUser_ValidRequest_Returns201WithUser() + { + var request = new + { + Username = "newclerk1", + Password = "Secure1Pass", + FullName = "New Clerk", + Role = "DATA_ENTRY_CLERK" + }; + + var response = await _client.PostAsJsonAsync("/api/v1/users", request); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var body = await response.Content.ReadFromJsonAsync(); + var data = body.GetProperty("data"); + data.GetProperty("username").GetString().Should().Be("newclerk1"); + data.GetProperty("fullName").GetString().Should().Be("New Clerk"); + data.GetProperty("role").GetString().Should().Be("DATA_ENTRY_CLERK"); + data.GetProperty("id").GetGuid().Should().NotBeEmpty(); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == "newclerk1"); + user.Should().NotBeNull(); + user!.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task CreateUser_DuplicateUsername_Returns409() + { + var request = new + { + Username = "entry1", + Password = "Secure1Pass", + FullName = "Duplicate Entry", + Role = "DATA_ENTRY_CLERK" + }; + + var response = await _client.PostAsJsonAsync("/api/v1/users", request); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + + var body = await response.Content.ReadFromJsonAsync(); + body.GetProperty("error").GetProperty("code").GetString().Should().Be("USERNAME_TAKEN"); + } + + [Fact] + public async Task CreateUser_WeakPassword_Returns422() + { + var request = new + { + Username = "weakuser", + Password = "short", + FullName = "Weak Password User", + Role = "VERIFIER" + }; + + var response = await _client.PostAsJsonAsync("/api/v1/users", request); + + response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + } + + [Fact] + public async Task CreateUser_NonAdmin_Returns403() + { + var entryClient = await AuthHelper.LoginAsync(_fixture, "entry1"); + + var request = new + { + Username = "unauthorized", + Password = "Secure1Pass", + FullName = "Unauthorized Create", + Role = "VERIFIER" + }; + + var response = await entryClient.PostAsJsonAsync("/api/v1/users", request); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task UpdateUser_ChangeRoleAndName_Returns200() + { + // First create a user to update + var createReq = new + { + Username = "toupdate1", + Password = "Secure1Pass", + FullName = "Original Name", + Role = "DATA_ENTRY_CLERK" + }; + var createResp = await _client.PostAsJsonAsync("/api/v1/users", createReq); + createResp.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResp.Content.ReadFromJsonAsync(); + var userId = created.GetProperty("data").GetProperty("id").GetGuid(); + + // Update + var updateReq = new + { + FullName = "Updated Name", + Role = "VERIFIER" + }; + + var response = await _client.PatchAsJsonAsync($"/api/v1/users/{userId}", updateReq); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(); + var data = body.GetProperty("data"); + data.GetProperty("fullName").GetString().Should().Be("Updated Name"); + data.GetProperty("role").GetString().Should().Be("VERIFIER"); + } + + [Fact] + public async Task UpdateUser_Deactivate_Returns200() + { + var createReq = new + { + Username = "todeactivate1", + Password = "Secure1Pass", + FullName = "Will Be Deactivated", + Role = "INTAKE_CLERK" + }; + var createResp = await _client.PostAsJsonAsync("/api/v1/users", createReq); + createResp.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResp.Content.ReadFromJsonAsync(); + var userId = created.GetProperty("data").GetProperty("id").GetGuid(); + + var response = await _client.PatchAsJsonAsync( + $"/api/v1/users/{userId}", new { IsActive = false }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.FindAsync(userId); + user!.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task UpdateUser_NotFound_Returns404() + { + var response = await _client.PatchAsJsonAsync( + $"/api/v1/users/{Guid.NewGuid()}", new { FullName = "Ghost" }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ResetPassword_AdminResets_Returns204() + { + var createReq = new + { + Username = "resetme1", + Password = "OldPass1Word", + FullName = "Reset Target", + Role = "DATA_ENTRY_CLERK" + }; + var createResp = await _client.PostAsJsonAsync("/api/v1/users", createReq); + createResp.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResp.Content.ReadFromJsonAsync(); + var userId = created.GetProperty("data").GetProperty("id").GetGuid(); + + var response = await _client.PostAsJsonAsync( + $"/api/v1/users/{userId}/reset-password", + new { NewPassword = "NewPass1Word" }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify new password works by logging in + var loginClient = _fixture.CreateClient(); + var loginResp = await loginClient.PostAsJsonAsync("/api/v1/auth/login", + new { username = "resetme1", password = "NewPass1Word" }); + loginResp.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ChangePassword_CorrectCurrent_Returns204() + { + // Create a user, then log in as them and change their own password + var createReq = new + { + Username = "selfchange1", + Password = "Original1Pass", + FullName = "Self Changer", + Role = "VERIFIER" + }; + await _client.PostAsJsonAsync("/api/v1/users", createReq); + + var userClient = await AuthHelper.LoginAsync(_fixture, "selfchange1", "Original1Pass"); + + var response = await userClient.PostAsJsonAsync("/api/v1/users/me/change-password", + new { CurrentPassword = "Original1Pass", NewPassword = "Changed1Pass" }); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify new password works + var loginClient = _fixture.CreateClient(); + var loginResp = await loginClient.PostAsJsonAsync("/api/v1/auth/login", + new { username = "selfchange1", password = "Changed1Pass" }); + loginResp.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ChangePassword_WrongCurrent_Returns422() + { + var createReq = new + { + Username = "wrongpw1", + Password = "Correct1Pass", + FullName = "Wrong Password", + Role = "VERIFIER" + }; + await _client.PostAsJsonAsync("/api/v1/users", createReq); + + var userClient = await AuthHelper.LoginAsync(_fixture, "wrongpw1", "Correct1Pass"); + + var response = await userClient.PostAsJsonAsync("/api/v1/users/me/change-password", + new { CurrentPassword = "Wrong1Pass", NewPassword = "New1Pass" }); + + response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + + var body = await response.Content.ReadFromJsonAsync(); + body.GetProperty("error").GetProperty("code").GetString().Should().Be("INVALID_PASSWORD"); + } +} diff --git a/VigilCareRecordsAPI/Controllers/UsersController.cs b/VigilCareRecordsAPI/Controllers/UsersController.cs index 40c6c20..f4aa40d 100644 --- a/VigilCareRecordsAPI/Controllers/UsersController.cs +++ b/VigilCareRecordsAPI/Controllers/UsersController.cs @@ -1,8 +1,9 @@ +using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; /// -/// User directory for batch assignment and operational lookups. +/// User directory, management, and password operations. /// [ApiController] [Route("api/v1/users")] @@ -29,4 +30,61 @@ public class UsersController : ControllerBase var results = await _users.ListByRoleAsync(parsedRole); return Ok(ApiResponse>.Ok(results)); } + + /// + /// Creates a new user. Admin only. + /// + [HttpPost] + [Authorize(Roles = "ADMINISTRATOR")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task Create([FromBody] CreateUserRequest request) + { + var result = await _users.CreateAsync(request); + return StatusCode(201, ApiResponse.Created(result)); + } + + /// + /// Updates user details (fullName, role, isActive). Admin only. + /// + [HttpPatch("{id:guid}")] + [Authorize(Roles = "ADMINISTRATOR")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task Update(Guid id, [FromBody] UpdateUserRequest request) + { + var result = await _users.UpdateAsync(id, request); + return Ok(ApiResponse.Ok(result)); + } + + /// + /// Admin password reset for any user. + /// + [HttpPost("{id:guid}/reset-password")] + [Authorize(Roles = "ADMINISTRATOR")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task ResetPassword(Guid id, [FromBody] ResetPasswordRequest request) + { + await _users.ResetPasswordAsync(id, request.NewPassword); + return NoContent(); + } + + /// + /// Self-service password change. Requires current password. + /// + [HttpPost("me/change-password")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task ChangePassword([FromBody] ChangePasswordRequest request) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + await _users.ChangePasswordAsync(userId, request.CurrentPassword, request.NewPassword); + return NoContent(); + } } diff --git a/VigilCareRecordsAPI/Models/Records/User/ChangePasswordRequest.cs b/VigilCareRecordsAPI/Models/Records/User/ChangePasswordRequest.cs new file mode 100644 index 0000000..fe870ba --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/User/ChangePasswordRequest.cs @@ -0,0 +1 @@ +public record ChangePasswordRequest(string CurrentPassword, string NewPassword); diff --git a/VigilCareRecordsAPI/Models/Records/User/CreateUserRequest.cs b/VigilCareRecordsAPI/Models/Records/User/CreateUserRequest.cs new file mode 100644 index 0000000..c199dc1 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/User/CreateUserRequest.cs @@ -0,0 +1,6 @@ +public record CreateUserRequest( + string Username, + string Password, + string FullName, + string Role +); diff --git a/VigilCareRecordsAPI/Models/Records/User/ResetPasswordRequest.cs b/VigilCareRecordsAPI/Models/Records/User/ResetPasswordRequest.cs new file mode 100644 index 0000000..492ef68 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/User/ResetPasswordRequest.cs @@ -0,0 +1 @@ +public record ResetPasswordRequest(string NewPassword); diff --git a/VigilCareRecordsAPI/Models/Records/User/UpdateUserRequest.cs b/VigilCareRecordsAPI/Models/Records/User/UpdateUserRequest.cs new file mode 100644 index 0000000..a69636b --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/User/UpdateUserRequest.cs @@ -0,0 +1,5 @@ +public record UpdateUserRequest( + string? FullName, + string? Role, + bool? IsActive +); diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs index 4ba2bfa..a0fdd31 100644 --- a/VigilCareRecordsAPI/Program.cs +++ b/VigilCareRecordsAPI/Program.cs @@ -1,5 +1,6 @@ using System.Text; using System.Threading.RateLimiting; +using FluentValidation; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.RateLimiting; @@ -138,7 +139,10 @@ try "minio", tags: new[] { "ready" }); - builder.Services.AddControllers(); + builder.Services.AddValidatorsFromAssemblyContaining(); + builder.Services.AddScoped(); + builder.Services.AddControllers(options => + options.Filters.AddService()); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddVigilCareRecordsSwagger(); diff --git a/VigilCareRecordsAPI/Services/Interfaces/IUserDirectoryService.cs b/VigilCareRecordsAPI/Services/Interfaces/IUserDirectoryService.cs index 3c970f6..b520370 100644 --- a/VigilCareRecordsAPI/Services/Interfaces/IUserDirectoryService.cs +++ b/VigilCareRecordsAPI/Services/Interfaces/IUserDirectoryService.cs @@ -1,4 +1,8 @@ public interface IUserDirectoryService { Task> ListByRoleAsync(UserRole? role); + Task CreateAsync(CreateUserRequest request); + Task UpdateAsync(Guid userId, UpdateUserRequest request); + Task ResetPasswordAsync(Guid userId, string newPassword); + Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword); } diff --git a/VigilCareRecordsAPI/Services/UserDirectoryService.cs b/VigilCareRecordsAPI/Services/UserDirectoryService.cs index 2cf51be..35c4d61 100644 --- a/VigilCareRecordsAPI/Services/UserDirectoryService.cs +++ b/VigilCareRecordsAPI/Services/UserDirectoryService.cs @@ -22,4 +22,74 @@ public class UserDirectoryService : IUserDirectoryService u.Role.ToDbString())) .ToListAsync(); } + + public async Task CreateAsync(CreateUserRequest request) + { + var exists = await _db.Users.AnyAsync(u => u.Username == request.Username); + if (exists) + throw new ConflictException( + $"Username '{request.Username}' is already taken.", + "USERNAME_TAKEN"); + + var user = new User + { + Id = Guid.NewGuid(), + Username = request.Username, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), + FullName = request.FullName, + Role = UserRoleExtensions.FromDbString(request.Role), + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow + }; + + _db.Users.Add(user); + await _db.SaveChangesAsync(); + + return new UserSummaryResponse(user.Id, user.Username, user.FullName, user.Role.ToDbString()); + } + + public async Task UpdateAsync(Guid userId, UpdateUserRequest request) + { + var user = await _db.Users.FindAsync(userId); + if (user is null) + throw new NotFoundException("User not found.", "USER_NOT_FOUND"); + + if (request.FullName is not null) + user.FullName = request.FullName; + + if (request.Role is not null) + user.Role = UserRoleExtensions.FromDbString(request.Role); + + if (request.IsActive.HasValue) + user.IsActive = request.IsActive.Value; + + await _db.SaveChangesAsync(); + + return new UserSummaryResponse(user.Id, user.Username, user.FullName, user.Role.ToDbString()); + } + + public async Task ResetPasswordAsync(Guid userId, string newPassword) + { + var user = await _db.Users.FindAsync(userId); + if (user is null) + throw new NotFoundException("User not found.", "USER_NOT_FOUND"); + + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword); + await _db.SaveChangesAsync(); + } + + public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword) + { + var user = await _db.Users.FindAsync(userId); + if (user is null) + throw new NotFoundException("User not found.", "USER_NOT_FOUND"); + + if (!BCrypt.Net.BCrypt.Verify(currentPassword, user.PasswordHash)) + throw new ValidationException( + "Current password is incorrect.", + "INVALID_PASSWORD"); + + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword); + await _db.SaveChangesAsync(); + } } diff --git a/VigilCareRecordsAPI/Validators/RequestValidators.cs b/VigilCareRecordsAPI/Validators/RequestValidators.cs new file mode 100644 index 0000000..d62d440 --- /dev/null +++ b/VigilCareRecordsAPI/Validators/RequestValidators.cs @@ -0,0 +1,235 @@ +using FluentValidation; + +public class LoginRequestValidator : AbstractValidator +{ + public LoginRequestValidator() + { + RuleFor(x => x.Username).NotEmpty(); + RuleFor(x => x.Password).NotEmpty(); + } +} + +public class RefreshRequestValidator : AbstractValidator +{ + public RefreshRequestValidator() + { + RuleFor(x => x.RefreshToken).NotEmpty(); + } +} + +public class LogoutRequestValidator : AbstractValidator +{ + public LogoutRequestValidator() + { + RuleFor(x => x.RefreshToken).NotEmpty(); + } +} + +public class UpsertDraftPatientRequestValidator : AbstractValidator +{ + public UpsertDraftPatientRequestValidator() + { + RuleFor(x => x.FullName).MaximumLength(200).When(x => x.FullName is not null); + RuleFor(x => x.DateOfBirth).LessThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow)) + .WithMessage("Date of birth cannot be in the future.") + .When(x => x.DateOfBirth.HasValue); + RuleFor(x => x.Sex) + .Must(s => IsAllowedSex(s)) + .WithMessage("Sex must be 'male', 'female', 'other', or 'unknown'.") + .When(x => x.Sex is not null); + } + + private static bool IsAllowedSex(string? sex) + { + if (sex is null) return true; + + return sex.Trim().ToLowerInvariant() switch + { + "m" or "male" or "f" or "female" or "other" or "unknown" => true, + _ => false + }; + } +} + +public class UpsertDraftEncounterRequestValidator : AbstractValidator +{ + public UpsertDraftEncounterRequestValidator() + { + RuleFor(x => x.AdmissionDate).LessThanOrEqualTo(DateTimeOffset.UtcNow) + .WithMessage("Admission date cannot be in the future.") + .When(x => x.AdmissionDate.HasValue); + RuleFor(x => x.RoomBed).MaximumLength(50).When(x => x.RoomBed is not null); + RuleFor(x => x.AdmissionReason).MaximumLength(500).When(x => x.AdmissionReason is not null); + RuleFor(x => x.DischargeDiagnosis).MaximumLength(500).When(x => x.DischargeDiagnosis is not null); + } +} + +public class CreateDraftObservationRequestValidator : AbstractValidator +{ + public CreateDraftObservationRequestValidator() + { + RuleFor(x => x.ObservationCode).NotEmpty(); + RuleFor(x => x.Unit).NotEmpty(); + RuleFor(x => x.RecordedAt).NotEmpty() + .LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5)) + .WithMessage("RecordedAt cannot be in the future."); + } +} + +public class UpdateDraftObservationRequestValidator : AbstractValidator +{ + public UpdateDraftObservationRequestValidator() + { + RuleFor(x => x.ObservationCode).NotEmpty(); + RuleFor(x => x.Unit).NotEmpty(); + RuleFor(x => x.RecordedAt).NotEmpty() + .LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5)) + .WithMessage("RecordedAt cannot be in the future."); + } +} + +public class VerifyBatchRequestValidator : AbstractValidator +{ + private static readonly HashSet _validStatuses = new() { "ok", "warning", "error" }; + + public VerifyBatchRequestValidator() + { + RuleFor(x => x.FieldChecks).NotEmpty() + .WithMessage("At least one field check is required."); + RuleForEach(x => x.FieldChecks).ChildRules(check => + { + check.RuleFor(c => c.FieldName).NotEmpty(); + check.RuleFor(c => c.Status).NotEmpty() + .Must(s => _validStatuses.Contains(s)) + .WithMessage("Status must be 'ok', 'warning', or 'error'."); + }); + } +} + +public class RejectBatchRequestValidator : AbstractValidator +{ + public RejectBatchRequestValidator() + { + RuleFor(x => x.Reason) + .NotEmpty() + .WithErrorCode("REJECTION_REASON_REQUIRED") + .WithMessage("Rejection reason is required."); + + RuleFor(x => x.Reason) + .MinimumLength(10) + .When(x => !string.IsNullOrEmpty(x.Reason)) + .WithErrorCode("REJECTION_REASON_TOO_SHORT") + .WithMessage("Rejection reason must be at least 10 characters."); + } +} + +public class RecordObservationsRequestValidator : AbstractValidator +{ + public RecordObservationsRequestValidator() + { + RuleFor(x => x.Observations) + .NotEmpty() + .WithErrorCode("EMPTY_OBSERVATIONS") + .WithMessage("At least one observation is required.") + .Must(o => o.Count <= 10) + .WithMessage("Maximum 10 observations per request."); + RuleFor(x => x.ClinicianAttestation) + .Equal(true) + .WithErrorCode("ATTESTATION_REQUIRED") + .WithMessage("Clinician attestation is required."); + RuleFor(x => x.PasswordConfirm).NotEmpty(); + RuleForEach(x => x.Observations).SetValidator(new LiveCaptureObservationRequestValidator()); + } +} + +public class OpenEncounterWithVitalsRequestValidator : AbstractValidator +{ + public OpenEncounterWithVitalsRequestValidator() + { + RuleFor(x => x.PatientId).NotEmpty(); + RuleFor(x => x.Department).NotEmpty(); + RuleFor(x => x.AdmissionReason).NotEmpty(); + RuleFor(x => x.Observations) + .NotEmpty() + .WithErrorCode("EMPTY_OBSERVATIONS") + .WithMessage("At least one observation is required.") + .Must(o => o.Count <= 10) + .WithMessage("Maximum 10 observations per request."); + RuleFor(x => x.ClinicianAttestation) + .Equal(true) + .WithErrorCode("ATTESTATION_REQUIRED") + .WithMessage("Clinician attestation is required."); + RuleFor(x => x.PasswordConfirm).NotEmpty(); + RuleForEach(x => x.Observations).SetValidator(new LiveCaptureObservationRequestValidator()); + } +} + +public class LiveCaptureObservationRequestValidator : AbstractValidator +{ + public LiveCaptureObservationRequestValidator() + { + RuleFor(x => x.ObservationCode).NotEmpty(); + RuleFor(x => x.Unit).NotEmpty(); + RuleFor(x => x.RecordedAt).NotEmpty(); + } +} + +public class CreateUserRequestValidator : AbstractValidator +{ + private static readonly HashSet _validRoles = new() + { + "INTAKE_CLERK", "DATA_ENTRY_CLERK", "VERIFIER", + "CLINICAL_APPROVER", "CLINICIAN", "ADMINISTRATOR" + }; + + public CreateUserRequestValidator() + { + RuleFor(x => x.Username).NotEmpty().MaximumLength(50); + RuleFor(x => x.Password).NotEmpty().MinimumLength(8) + .Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter.") + .Matches("[0-9]").WithMessage("Password must contain at least one digit."); + RuleFor(x => x.FullName).NotEmpty().MaximumLength(200); + RuleFor(x => x.Role).NotEmpty() + .Must(r => _validRoles.Contains(r)) + .WithMessage("Role must be one of: INTAKE_CLERK, DATA_ENTRY_CLERK, VERIFIER, CLINICAL_APPROVER, CLINICIAN, ADMINISTRATOR."); + } +} + +public class UpdateUserRequestValidator : AbstractValidator +{ + private static readonly HashSet _validRoles = new() + { + "INTAKE_CLERK", "DATA_ENTRY_CLERK", "VERIFIER", + "CLINICAL_APPROVER", "CLINICIAN", "ADMINISTRATOR" + }; + + public UpdateUserRequestValidator() + { + RuleFor(x => x.FullName).MaximumLength(200).When(x => x.FullName is not null); + RuleFor(x => x.Role) + .Must(r => _validRoles.Contains(r!)) + .WithMessage("Role must be one of: INTAKE_CLERK, DATA_ENTRY_CLERK, VERIFIER, CLINICAL_APPROVER, CLINICIAN, ADMINISTRATOR.") + .When(x => x.Role is not null); + } +} + +public class ResetPasswordRequestValidator : AbstractValidator +{ + public ResetPasswordRequestValidator() + { + RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8) + .Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter.") + .Matches("[0-9]").WithMessage("Password must contain at least one digit."); + } +} + +public class ChangePasswordRequestValidator : AbstractValidator +{ + public ChangePasswordRequestValidator() + { + RuleFor(x => x.CurrentPassword).NotEmpty(); + RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8) + .Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter.") + .Matches("[0-9]").WithMessage("Password must contain at least one digit."); + } +} diff --git a/VigilCareRecordsAPI/Validators/ValidationFilter.cs b/VigilCareRecordsAPI/Validators/ValidationFilter.cs new file mode 100644 index 0000000..b16ba00 --- /dev/null +++ b/VigilCareRecordsAPI/Validators/ValidationFilter.cs @@ -0,0 +1,41 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +public class ValidationFilter : IAsyncActionFilter +{ + private readonly IServiceProvider _serviceProvider; + + public ValidationFilter(IServiceProvider serviceProvider) => _serviceProvider = serviceProvider; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + foreach (var argument in context.ActionArguments.Values) + { + if (argument is null) continue; + + var argumentType = argument.GetType(); + var validatorType = typeof(IValidator<>).MakeGenericType(argumentType); + var validator = _serviceProvider.GetService(validatorType) as IValidator; + + if (validator is null) continue; + + var validationContext = new ValidationContext(argument); + var result = await validator.ValidateAsync(validationContext); + + if (!result.IsValid) + { + var errors = string.Join("; ", result.Errors.Select(e => e.ErrorMessage)); + var errorCode = result.Errors + .Select(e => e.ErrorCode) + .FirstOrDefault(code => !string.IsNullOrWhiteSpace(code)) + ?? "VALIDATION_ERROR"; + context.Result = new UnprocessableEntityObjectResult( + ApiResponse.Fail(422, errors, errorCode)); + return; + } + } + + await next(); + } +} diff --git a/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj b/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj index 1066296..6d69cce 100644 --- a/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj +++ b/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj @@ -12,6 +12,7 @@ + diff --git a/docs/vigilcare-records-gap-analysis.md b/docs/vigilcare-records-gap-analysis.md index 619405c..e33ed27 100644 --- a/docs/vigilcare-records-gap-analysis.md +++ b/docs/vigilcare-records-gap-analysis.md @@ -807,8 +807,8 @@ A batch stuck in `APPROVED` with exhausted retries is invisible in Prometheus da | 10 | No rate limiting on auth | P3 | C | Done | | 11 | Credentials in plaintext config | P3 | C | Open | | 12 | No document access audit | P3 | C | Done | -| 13 | No FluentValidation | P2 | D | Open | -| 14 | No user management endpoints | P4 | D | Open | +| 13 | No FluentValidation | P2 | D | Done | +| 14 | No user management endpoints | P4 | D | Done | | 15 | No batch cancel/void | P4 | D | Open | | 16 | No sort parameters on lists | P4 | D | Open | | 17 | No clinical approval view | P2 | E | Open |