diff --git a/Azaion.AdminApi/BusinessExceptionHandler.cs b/Azaion.AdminApi/BusinessExceptionHandler.cs index 18ab6a9..b100b6f 100644 --- a/Azaion.AdminApi/BusinessExceptionHandler.cs +++ b/Azaion.AdminApi/BusinessExceptionHandler.cs @@ -10,19 +10,36 @@ public class BusinessExceptionHandler(ILogger logger) { public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { - if (exception is not BusinessException ex) - return false; - - logger.LogWarning(exception, ex.Message); - httpContext.Response.StatusCode = StatusCodes.Status409Conflict; - httpContext.Response.ContentType = "application/json"; - - var err = JsonConvert.SerializeObject(new + if (exception is BusinessException ex) { - ErrorCode = ex.ExceptionEnum, - ex.Message - }); - await httpContext.Response.WriteAsync(err, cancellationToken).ConfigureAwait(false); - return true; + logger.LogWarning(exception, ex.Message); + httpContext.Response.StatusCode = StatusCodes.Status409Conflict; + httpContext.Response.ContentType = "application/json"; + + var err = JsonConvert.SerializeObject(new + { + ErrorCode = ex.ExceptionEnum, + ex.Message + }); + await httpContext.Response.WriteAsync(err, cancellationToken).ConfigureAwait(false); + return true; + } + + if (exception is BadHttpRequestException badReq) + { + logger.LogWarning(exception, badReq.Message); + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + httpContext.Response.ContentType = "application/json"; + + var err = JsonConvert.SerializeObject(new + { + ErrorCode = 0, + badReq.Message + }); + await httpContext.Response.WriteAsync(err, cancellationToken).ConfigureAwait(false); + return true; + } + + return false; } } \ No newline at end of file diff --git a/Azaion.AdminApi/Program.cs b/Azaion.AdminApi/Program.cs index fe24899..232b5e7 100644 --- a/Azaion.AdminApi/Program.cs +++ b/Azaion.AdminApi/Program.cs @@ -24,7 +24,9 @@ Log.Logger = new LoggerConfiguration() .CreateLogger(); var builder = WebApplication.CreateBuilder(args); -builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 209715200); //increase upload limit up to 200mb +builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 209715200); +builder.Services.Configure(o => + o.MultipartBodyLengthLimit = 209715200); var jwtConfig = builder.Configuration.GetSection(nameof(JwtConfig)).Get(); if (jwtConfig == null || string.IsNullOrEmpty(jwtConfig.Secret)) @@ -139,8 +141,15 @@ app.MapPost("/login", .WithSummary("Login"); app.MapPost("/users", - async (RegisterUserRequest registerUserRequest, IUserService userService, CancellationToken cancellationToken) - => await userService.RegisterUser(registerUserRequest, cancellationToken)) + async (RegisterUserRequest registerUserRequest, IValidator validator, + IUserService userService, CancellationToken cancellationToken) => + { + var validation = await validator.ValidateAsync(registerUserRequest, cancellationToken); + if (!validation.IsValid) + return Results.ValidationProblem(validation.ToDictionary()); + await userService.RegisterUser(registerUserRequest, cancellationToken); + return Results.Ok(); + }) .RequireAuthorization(apiAdminPolicy) .WithSummary("Creates a new user"); @@ -188,8 +197,12 @@ app.MapDelete("/users/{email}", async (string email, IUserService userService, C .WithSummary("Remove user"); app.MapPost("/resources/{dataFolder?}", - async ([FromRoute]string? dataFolder, IFormFile data, IResourcesService resourceService, CancellationToken ct) - => await resourceService.SaveResource(dataFolder, data, ct)) + async ([FromRoute]string? dataFolder, IFormFile? data, IResourcesService resourceService, CancellationToken ct) => + { + if (data is null) + throw new BusinessException(ExceptionEnum.NoFileProvided); + await resourceService.SaveResource(dataFolder, data, ct); + }) .Accepts("multipart/form-data") .RequireAuthorization() .WithSummary("Upload resource") diff --git a/Azaion.Common/BusinessException.cs b/Azaion.Common/BusinessException.cs index 1b7d0de..3dbfb64 100644 --- a/Azaion.Common/BusinessException.cs +++ b/Azaion.Common/BusinessException.cs @@ -28,7 +28,7 @@ public enum ExceptionEnum [Description("Passwords do not match.")] WrongPassword = 30, - [Description("Password should be at least 8 characters.")] + [Description("Password should be at least 12 characters.")] PasswordLengthIncorrect = 32, [Description("Email is empty or invalid.")] diff --git a/Azaion.Common/Entities/User.cs b/Azaion.Common/Entities/User.cs index a5e6f57..e23c7cd 100644 --- a/Azaion.Common/Entities/User.cs +++ b/Azaion.Common/Entities/User.cs @@ -1,9 +1,12 @@ -namespace Azaion.Common.Entities; +using System.Text.Json.Serialization; + +namespace Azaion.Common.Entities; public class User { public Guid Id { get; set; } public string Email { get; set; } = null!; + [JsonIgnore] public string PasswordHash { get; set; } = null!; public string? Hardware { get; set; } public RoleEnum Role { get; set; } diff --git a/Azaion.Common/Requests/RegisterUserRequest.cs b/Azaion.Common/Requests/RegisterUserRequest.cs index 0760225..8bd9b82 100644 --- a/Azaion.Common/Requests/RegisterUserRequest.cs +++ b/Azaion.Common/Requests/RegisterUserRequest.cs @@ -19,5 +19,5 @@ public class RegisterUserValidator : AbstractValidator .EmailAddress().WithErrorCode(ExceptionEnum.WrongEmail.ToString()).WithMessage("Email address is not valid."); RuleFor(r => r.Password) - .MinimumLength(8).WithErrorCode(ExceptionEnum.PasswordLengthIncorrect.ToString()).WithMessage("Password should be at least 8 characters."); + .MinimumLength(12).WithErrorCode(ExceptionEnum.PasswordLengthIncorrect.ToString()).WithMessage("Password should be at least 12 characters."); } } \ No newline at end of file diff --git a/e2e/Azaion.E2E/Tests/ResilienceTests.cs b/e2e/Azaion.E2E/Tests/ResilienceTests.cs index 5c768c5..fa4308b 100644 --- a/e2e/Azaion.E2E/Tests/ResilienceTests.cs +++ b/e2e/Azaion.E2E/Tests/ResilienceTests.cs @@ -165,7 +165,7 @@ public sealed class ResilienceTests p95.Should().BeLessThan(500); } - [Fact(Skip = "API bug: MultipartBodyLengthLimit defaults to 128MB while Kestrel MaxRequestBodySize is 200MB — FormOptions not configured")] + [Fact] [Trait("Category", "ResourceLimit")] public async Task Max_file_upload_200_mb_accepted() { diff --git a/e2e/Azaion.E2E/Tests/ResourceTests.cs b/e2e/Azaion.E2E/Tests/ResourceTests.cs index 1b45da7..349952a 100644 --- a/e2e/Azaion.E2E/Tests/ResourceTests.cs +++ b/e2e/Azaion.E2E/Tests/ResourceTests.cs @@ -119,7 +119,7 @@ public sealed class ResourceTests var folder = $"restest-{Guid.NewGuid():N}"; const string fileName = "roundtrip.bin"; var original = Enumerable.Range(0, 128).Select(i => (byte)i).ToArray(); - const string password = "RoundTrip1!"; + const string password = "RoundTrip123"; const string hardware = "RT-HW-CPU-001-GPU-002"; string? email = null; @@ -174,7 +174,7 @@ public sealed class ResourceTests } } - [Fact(Skip = "API bug: missing file upload returns 500 instead of 400/409 — unhandled BadHttpRequestException")] + [Fact] public async Task Upload_without_file_is_rejected_with_400_or_409_and_60_on_conflict() { // Arrange diff --git a/e2e/Azaion.E2E/Tests/SecurityTests.cs b/e2e/Azaion.E2E/Tests/SecurityTests.cs index 02e4e06..ceac5a6 100644 --- a/e2e/Azaion.E2E/Tests/SecurityTests.cs +++ b/e2e/Azaion.E2E/Tests/SecurityTests.cs @@ -76,7 +76,7 @@ public sealed class SecurityTests // Act & Assert using (var r = await client.PostAsync("/users", - new { email = targetEmail, password = "TestPwd1234", role = 10 })) + new { email = targetEmail, password = "TestPwd12345", role = 10 })) r.StatusCode.Should().Be(HttpStatusCode.Forbidden); using (var r = await client.GetAsync("/users")) @@ -89,7 +89,7 @@ public sealed class SecurityTests r.StatusCode.Should().Be(HttpStatusCode.Forbidden); } - [Fact(Skip = "API bug: GET /users exposes passwordHash field with actual hash values")] + [Fact] public async Task Users_list_must_not_expose_non_empty_password_hash_in_json() { // Arrange @@ -150,7 +150,7 @@ public sealed class SecurityTests var payload = Encoding.UTF8.GetBytes($"secret-{Guid.NewGuid()}"); var email1 = $"{Guid.NewGuid():N}@sectest.example.com"; var email2 = $"{Guid.NewGuid():N}@sectest.example.com"; - const string password = "TestPwd1234"; + const string password = "TestPwd12345"; var hw1 = $"hw-{Guid.NewGuid():N}"; var hw2 = $"hw-{Guid.NewGuid():N}"; @@ -207,7 +207,7 @@ public sealed class SecurityTests { // Arrange var email = $"{Guid.NewGuid():N}@sectest.example.com"; - const string password = "TestPwd1234"; + const string password = "TestPwd12345"; try { var reg = JsonSerializer.Serialize(new { email, password, role = 10 }, JsonOptions); diff --git a/e2e/Azaion.E2E/Tests/UserManagementTests.cs b/e2e/Azaion.E2E/Tests/UserManagementTests.cs index 9c0ac08..a5f027f 100644 --- a/e2e/Azaion.E2E/Tests/UserManagementTests.cs +++ b/e2e/Azaion.E2E/Tests/UserManagementTests.cs @@ -32,7 +32,7 @@ public sealed class UserManagementTests try { // Arrange - var body = new { email, password = "SecurePass1", role = 10 }; + var body = new { email, password = "SecurePass1!", role = 10 }; // Act using var response = await client.PostAsync("/users", body); @@ -87,7 +87,7 @@ public sealed class UserManagementTests try { // Arrange - using (var createResp = await client.PostAsync("/users", new { email, password = "SecurePass1", role = 10 })) + using (var createResp = await client.PostAsync("/users", new { email, password = "SecurePass1!", role = 10 })) { createResp.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); } @@ -112,7 +112,7 @@ public sealed class UserManagementTests try { // Arrange - using (var createResp = await client.PostAsync("/users", new { email, password = "SecurePass1", role = 10 })) + using (var createResp = await client.PostAsync("/users", new { email, password = "SecurePass1!", role = 10 })) { createResp.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); } @@ -137,7 +137,7 @@ public sealed class UserManagementTests try { // Arrange - using (var createResp = await client.PostAsync("/users", new { email, password = "SecurePass1", role = 10 })) + using (var createResp = await client.PostAsync("/users", new { email, password = "SecurePass1!", role = 10 })) { createResp.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent); } @@ -160,7 +160,7 @@ public sealed class UserManagementTests } } - [Fact(Skip = "API bug: no email length validation — returns 200 instead of 400")] + [Fact] public async Task Registration_rejects_short_email_with_400() { // Arrange @@ -168,13 +168,13 @@ public sealed class UserManagementTests // Act using var response = await client.PostAsync("/users", - new { email = "ab@c.de", password = "ValidPass1", role = 10 }); + new { email = "ab@c.de", password = "ValidPass123", role = 10 }); // Assert response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } - [Fact(Skip = "API bug: no email format validation — returns 200 instead of 400")] + [Fact] public async Task Registration_rejects_invalid_email_format_with_400() { // Arrange @@ -182,13 +182,13 @@ public sealed class UserManagementTests // Act using var response = await client.PostAsync("/users", - new { email = "notavalidemail", password = "ValidPass1", role = 10 }); + new { email = "notavalidemail", password = "ValidPass123", role = 10 }); // Assert response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } - [Fact(Skip = "API bug: no password length validation — returns 200 instead of 400")] + [Fact] public async Task Registration_rejects_short_password_with_400() { // Arrange @@ -210,7 +210,7 @@ public sealed class UserManagementTests // Act using var response = await client.PostAsync("/users", - new { email = _fixture.AdminEmail, password = "DuplicateP1", role = 10 }); + new { email = _fixture.AdminEmail, password = "DuplicateP1!", role = 10 }); // Assert response.StatusCode.Should().Be(HttpStatusCode.Conflict);