feature: No user management endpoints (create, update, deactivate)

This commit is contained in:
voltsrage
2026-06-27 15:45:01 +08:00
parent 5a2e95c984
commit 299e6e1453
13 changed files with 684 additions and 4 deletions
@@ -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<AppDbContext>();
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<JsonElement>();
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<AppDbContext>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<AppDbContext>();
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<JsonElement>();
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<JsonElement>();
body.GetProperty("error").GetProperty("code").GetString().Should().Be("INVALID_PASSWORD");
}
}
@@ -1,8 +1,9 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
/// <summary> /// <summary>
/// User directory for batch assignment and operational lookups. /// User directory, management, and password operations.
/// </summary> /// </summary>
[ApiController] [ApiController]
[Route("api/v1/users")] [Route("api/v1/users")]
@@ -29,4 +30,61 @@ public class UsersController : ControllerBase
var results = await _users.ListByRoleAsync(parsedRole); var results = await _users.ListByRoleAsync(parsedRole);
return Ok(ApiResponse<IReadOnlyList<UserSummaryResponse>>.Ok(results)); return Ok(ApiResponse<IReadOnlyList<UserSummaryResponse>>.Ok(results));
} }
/// <summary>
/// Creates a new user. Admin only.
/// </summary>
[HttpPost]
[Authorize(Roles = "ADMINISTRATOR")]
[ProducesResponseType(typeof(ApiResponse<UserSummaryResponse>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
{
var result = await _users.CreateAsync(request);
return StatusCode(201, ApiResponse<UserSummaryResponse>.Created(result));
}
/// <summary>
/// Updates user details (fullName, role, isActive). Admin only.
/// </summary>
[HttpPatch("{id:guid}")]
[Authorize(Roles = "ADMINISTRATOR")]
[ProducesResponseType(typeof(ApiResponse<UserSummaryResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateUserRequest request)
{
var result = await _users.UpdateAsync(id, request);
return Ok(ApiResponse<UserSummaryResponse>.Ok(result));
}
/// <summary>
/// Admin password reset for any user.
/// </summary>
[HttpPost("{id:guid}/reset-password")]
[Authorize(Roles = "ADMINISTRATOR")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ResetPassword(Guid id, [FromBody] ResetPasswordRequest request)
{
await _users.ResetPasswordAsync(id, request.NewPassword);
return NoContent();
}
/// <summary>
/// Self-service password change. Requires current password.
/// </summary>
[HttpPost("me/change-password")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
await _users.ChangePasswordAsync(userId, request.CurrentPassword, request.NewPassword);
return NoContent();
}
} }
@@ -0,0 +1 @@
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
@@ -0,0 +1,6 @@
public record CreateUserRequest(
string Username,
string Password,
string FullName,
string Role
);
@@ -0,0 +1 @@
public record ResetPasswordRequest(string NewPassword);
@@ -0,0 +1,5 @@
public record UpdateUserRequest(
string? FullName,
string? Role,
bool? IsActive
);
+5 -1
View File
@@ -1,5 +1,6 @@
using System.Text; using System.Text;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -138,7 +139,10 @@ try
"minio", "minio",
tags: new[] { "ready" }); tags: new[] { "ready" });
builder.Services.AddControllers(); builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddScoped<ValidationFilter>();
builder.Services.AddControllers(options =>
options.Filters.AddService<ValidationFilter>());
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddVigilCareRecordsSwagger(); builder.Services.AddVigilCareRecordsSwagger();
@@ -1,4 +1,8 @@
public interface IUserDirectoryService public interface IUserDirectoryService
{ {
Task<IReadOnlyList<UserSummaryResponse>> ListByRoleAsync(UserRole? role); Task<IReadOnlyList<UserSummaryResponse>> ListByRoleAsync(UserRole? role);
Task<UserSummaryResponse> CreateAsync(CreateUserRequest request);
Task<UserSummaryResponse> UpdateAsync(Guid userId, UpdateUserRequest request);
Task ResetPasswordAsync(Guid userId, string newPassword);
Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword);
} }
@@ -22,4 +22,74 @@ public class UserDirectoryService : IUserDirectoryService
u.Role.ToDbString())) u.Role.ToDbString()))
.ToListAsync(); .ToListAsync();
} }
public async Task<UserSummaryResponse> 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<UserSummaryResponse> 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();
}
} }
@@ -0,0 +1,235 @@
using FluentValidation;
public class LoginRequestValidator : AbstractValidator<LoginRequest>
{
public LoginRequestValidator()
{
RuleFor(x => x.Username).NotEmpty();
RuleFor(x => x.Password).NotEmpty();
}
}
public class RefreshRequestValidator : AbstractValidator<RefreshRequest>
{
public RefreshRequestValidator()
{
RuleFor(x => x.RefreshToken).NotEmpty();
}
}
public class LogoutRequestValidator : AbstractValidator<LogoutRequest>
{
public LogoutRequestValidator()
{
RuleFor(x => x.RefreshToken).NotEmpty();
}
}
public class UpsertDraftPatientRequestValidator : AbstractValidator<UpsertDraftPatientRequest>
{
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<UpsertDraftEncounterRequest>
{
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<CreateDraftObservationRequest>
{
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<UpdateDraftObservationRequest>
{
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<VerifyBatchRequest>
{
private static readonly HashSet<string> _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<RejectBatchRequest>
{
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<RecordObservationsRequest>
{
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<OpenEncounterWithVitalsRequest>
{
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<LiveCaptureObservationRequest>
{
public LiveCaptureObservationRequestValidator()
{
RuleFor(x => x.ObservationCode).NotEmpty();
RuleFor(x => x.Unit).NotEmpty();
RuleFor(x => x.RecordedAt).NotEmpty();
}
}
public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
private static readonly HashSet<string> _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<UpdateUserRequest>
{
private static readonly HashSet<string> _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<ResetPasswordRequest>
{
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<ChangePasswordRequest>
{
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.");
}
}
@@ -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<object>(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<object>.Fail(422, errors, errorCode));
return;
}
}
await next();
}
}
@@ -12,6 +12,7 @@
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" /> <PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" /> <PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
+2 -2
View File
@@ -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 | | 10 | No rate limiting on auth | P3 | C | Done |
| 11 | Credentials in plaintext config | P3 | C | Open | | 11 | Credentials in plaintext config | P3 | C | Open |
| 12 | No document access audit | P3 | C | Done | | 12 | No document access audit | P3 | C | Done |
| 13 | No FluentValidation | P2 | D | Open | | 13 | No FluentValidation | P2 | D | Done |
| 14 | No user management endpoints | P4 | D | Open | | 14 | No user management endpoints | P4 | D | Done |
| 15 | No batch cancel/void | P4 | D | Open | | 15 | No batch cancel/void | P4 | D | Open |
| 16 | No sort parameters on lists | P4 | D | Open | | 16 | No sort parameters on lists | P4 | D | Open |
| 17 | No clinical approval view | P2 | E | Open | | 17 | No clinical approval view | P2 | E | Open |