mirror of
https://github.com/azaion/admin.git
synced 2026-04-22 05:26:34 +00:00
[AZ-199] [AZ-200] [AZ-201] [AZ-202] Fix API bugs
Made-with: Cursor
This commit is contained in:
@@ -10,19 +10,36 @@ public class BusinessExceptionHandler(ILogger<BusinessExceptionHandler> logger)
|
|||||||
{
|
{
|
||||||
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
|
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (exception is not BusinessException ex)
|
if (exception is BusinessException ex)
|
||||||
return false;
|
|
||||||
|
|
||||||
logger.LogWarning(exception, ex.Message);
|
|
||||||
httpContext.Response.StatusCode = StatusCodes.Status409Conflict;
|
|
||||||
httpContext.Response.ContentType = "application/json";
|
|
||||||
|
|
||||||
var err = JsonConvert.SerializeObject(new
|
|
||||||
{
|
{
|
||||||
ErrorCode = ex.ExceptionEnum,
|
logger.LogWarning(exception, ex.Message);
|
||||||
ex.Message
|
httpContext.Response.StatusCode = StatusCodes.Status409Conflict;
|
||||||
});
|
httpContext.Response.ContentType = "application/json";
|
||||||
await httpContext.Response.WriteAsync(err, cancellationToken).ConfigureAwait(false);
|
|
||||||
return true;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,9 @@ Log.Logger = new LoggerConfiguration()
|
|||||||
.CreateLogger();
|
.CreateLogger();
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
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<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
|
||||||
|
o.MultipartBodyLengthLimit = 209715200);
|
||||||
|
|
||||||
var jwtConfig = builder.Configuration.GetSection(nameof(JwtConfig)).Get<JwtConfig>();
|
var jwtConfig = builder.Configuration.GetSection(nameof(JwtConfig)).Get<JwtConfig>();
|
||||||
if (jwtConfig == null || string.IsNullOrEmpty(jwtConfig.Secret))
|
if (jwtConfig == null || string.IsNullOrEmpty(jwtConfig.Secret))
|
||||||
@@ -139,8 +141,15 @@ app.MapPost("/login",
|
|||||||
.WithSummary("Login");
|
.WithSummary("Login");
|
||||||
|
|
||||||
app.MapPost("/users",
|
app.MapPost("/users",
|
||||||
async (RegisterUserRequest registerUserRequest, IUserService userService, CancellationToken cancellationToken)
|
async (RegisterUserRequest registerUserRequest, IValidator<RegisterUserRequest> validator,
|
||||||
=> await userService.RegisterUser(registerUserRequest, cancellationToken))
|
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)
|
.RequireAuthorization(apiAdminPolicy)
|
||||||
.WithSummary("Creates a new user");
|
.WithSummary("Creates a new user");
|
||||||
|
|
||||||
@@ -188,8 +197,12 @@ app.MapDelete("/users/{email}", async (string email, IUserService userService, C
|
|||||||
.WithSummary("Remove user");
|
.WithSummary("Remove user");
|
||||||
|
|
||||||
app.MapPost("/resources/{dataFolder?}",
|
app.MapPost("/resources/{dataFolder?}",
|
||||||
async ([FromRoute]string? dataFolder, IFormFile data, IResourcesService resourceService, CancellationToken ct)
|
async ([FromRoute]string? dataFolder, IFormFile? data, IResourcesService resourceService, CancellationToken ct) =>
|
||||||
=> await resourceService.SaveResource(dataFolder, data, ct))
|
{
|
||||||
|
if (data is null)
|
||||||
|
throw new BusinessException(ExceptionEnum.NoFileProvided);
|
||||||
|
await resourceService.SaveResource(dataFolder, data, ct);
|
||||||
|
})
|
||||||
.Accepts<IFormFile>("multipart/form-data")
|
.Accepts<IFormFile>("multipart/form-data")
|
||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
.WithSummary("Upload resource")
|
.WithSummary("Upload resource")
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public enum ExceptionEnum
|
|||||||
[Description("Passwords do not match.")]
|
[Description("Passwords do not match.")]
|
||||||
WrongPassword = 30,
|
WrongPassword = 30,
|
||||||
|
|
||||||
[Description("Password should be at least 8 characters.")]
|
[Description("Password should be at least 12 characters.")]
|
||||||
PasswordLengthIncorrect = 32,
|
PasswordLengthIncorrect = 32,
|
||||||
|
|
||||||
[Description("Email is empty or invalid.")]
|
[Description("Email is empty or invalid.")]
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
namespace Azaion.Common.Entities;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Azaion.Common.Entities;
|
||||||
|
|
||||||
public class User
|
public class User
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string Email { get; set; } = null!;
|
public string Email { get; set; } = null!;
|
||||||
|
[JsonIgnore]
|
||||||
public string PasswordHash { get; set; } = null!;
|
public string PasswordHash { get; set; } = null!;
|
||||||
public string? Hardware { get; set; }
|
public string? Hardware { get; set; }
|
||||||
public RoleEnum Role { get; set; }
|
public RoleEnum Role { get; set; }
|
||||||
|
|||||||
@@ -19,5 +19,5 @@ public class RegisterUserValidator : AbstractValidator<RegisterUserRequest>
|
|||||||
.EmailAddress().WithErrorCode(ExceptionEnum.WrongEmail.ToString()).WithMessage("Email address is not valid.");
|
.EmailAddress().WithErrorCode(ExceptionEnum.WrongEmail.ToString()).WithMessage("Email address is not valid.");
|
||||||
|
|
||||||
RuleFor(r => r.Password)
|
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.");
|
||||||
} }
|
} }
|
||||||
@@ -165,7 +165,7 @@ public sealed class ResilienceTests
|
|||||||
p95.Should().BeLessThan(500);
|
p95.Should().BeLessThan(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(Skip = "API bug: MultipartBodyLengthLimit defaults to 128MB while Kestrel MaxRequestBodySize is 200MB — FormOptions not configured")]
|
[Fact]
|
||||||
[Trait("Category", "ResourceLimit")]
|
[Trait("Category", "ResourceLimit")]
|
||||||
public async Task Max_file_upload_200_mb_accepted()
|
public async Task Max_file_upload_200_mb_accepted()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ public sealed class ResourceTests
|
|||||||
var folder = $"restest-{Guid.NewGuid():N}";
|
var folder = $"restest-{Guid.NewGuid():N}";
|
||||||
const string fileName = "roundtrip.bin";
|
const string fileName = "roundtrip.bin";
|
||||||
var original = Enumerable.Range(0, 128).Select(i => (byte)i).ToArray();
|
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";
|
const string hardware = "RT-HW-CPU-001-GPU-002";
|
||||||
string? email = null;
|
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()
|
public async Task Upload_without_file_is_rejected_with_400_or_409_and_60_on_conflict()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ public sealed class SecurityTests
|
|||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
using (var r = await client.PostAsync("/users",
|
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);
|
r.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||||
|
|
||||||
using (var r = await client.GetAsync("/users"))
|
using (var r = await client.GetAsync("/users"))
|
||||||
@@ -89,7 +89,7 @@ public sealed class SecurityTests
|
|||||||
r.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
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()
|
public async Task Users_list_must_not_expose_non_empty_password_hash_in_json()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -150,7 +150,7 @@ public sealed class SecurityTests
|
|||||||
var payload = Encoding.UTF8.GetBytes($"secret-{Guid.NewGuid()}");
|
var payload = Encoding.UTF8.GetBytes($"secret-{Guid.NewGuid()}");
|
||||||
var email1 = $"{Guid.NewGuid():N}@sectest.example.com";
|
var email1 = $"{Guid.NewGuid():N}@sectest.example.com";
|
||||||
var email2 = $"{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 hw1 = $"hw-{Guid.NewGuid():N}";
|
||||||
var hw2 = $"hw-{Guid.NewGuid():N}";
|
var hw2 = $"hw-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ public sealed class SecurityTests
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var email = $"{Guid.NewGuid():N}@sectest.example.com";
|
var email = $"{Guid.NewGuid():N}@sectest.example.com";
|
||||||
const string password = "TestPwd1234";
|
const string password = "TestPwd12345";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var reg = JsonSerializer.Serialize(new { email, password, role = 10 }, JsonOptions);
|
var reg = JsonSerializer.Serialize(new { email, password, role = 10 }, JsonOptions);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public sealed class UserManagementTests
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var body = new { email, password = "SecurePass1", role = 10 };
|
var body = new { email, password = "SecurePass1!", role = 10 };
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
using var response = await client.PostAsync("/users", body);
|
using var response = await client.PostAsync("/users", body);
|
||||||
@@ -87,7 +87,7 @@ public sealed class UserManagementTests
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Arrange
|
// 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);
|
createResp.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ public sealed class UserManagementTests
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Arrange
|
// 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);
|
createResp.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ public sealed class UserManagementTests
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Arrange
|
// 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);
|
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()
|
public async Task Registration_rejects_short_email_with_400()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -168,13 +168,13 @@ public sealed class UserManagementTests
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
using var response = await client.PostAsync("/users",
|
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
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
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()
|
public async Task Registration_rejects_invalid_email_format_with_400()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -182,13 +182,13 @@ public sealed class UserManagementTests
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
using var response = await client.PostAsync("/users",
|
using var response = await client.PostAsync("/users",
|
||||||
new { email = "notavalidemail", password = "ValidPass1", role = 10 });
|
new { email = "notavalidemail", password = "ValidPass123", role = 10 });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
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()
|
public async Task Registration_rejects_short_password_with_400()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -210,7 +210,7 @@ public sealed class UserManagementTests
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
using var response = await client.PostAsync("/users",
|
using var response = await client.PostAsync("/users",
|
||||||
new { email = _fixture.AdminEmail, password = "DuplicateP1", role = 10 });
|
new { email = _fixture.AdminEmail, password = "DuplicateP1!", role = 10 });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||||
|
|||||||
Reference in New Issue
Block a user