Nostos.Backend.Tests/Portability/MigrationHttpErrorMappingTests.cs
Rama main · réplica de lectura del repositorio original.
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Nostos.Backend.Endpoints;
using Nostos.Backend.Services.Portability.Migration;
using Nostos.Backend.Services.Portability.Transfers;
using Xunit;
namespace Nostos.Backend.Tests.Portability;
/// <summary>
/// Every row of the plan's section 4.4 error table maps to one HTTP status and
/// one stable code. Removing a mapping makes the corresponding case fail.
/// </summary>
public sealed class MigrationHttpErrorMappingTests
{
public static TheoryData<string, string, int, string> StoreCases => new()
{
{ MigrationJobStoreErrorCodes.NotFound, "job", 404, "migration_not_found" },
{ MigrationJobStoreErrorCodes.LeaseConflict, "job", 409, "migration_lease_conflict" },
{ MigrationJobStoreErrorCodes.InvalidState, "job", 409, "migration_invalid_state" },
{ MigrationJobStoreErrorCodes.CannotCancel, "job", 409, "migration_cannot_cancel" },
{ MigrationJobStoreErrorCodes.NotRetryable, "job", 409, "migration_not_retryable" },
};
public static TheoryData<string, int, string> TransferCases => new()
{
{ MigrationTransferException.InvalidRequest, 400, "migration_invalid_request" },
{ MigrationTransferException.MetadataRequired, 400, "migration_invalid_request" },
{ MigrationTransferException.InvalidState, 409, "migration_invalid_state" },
{ MigrationTransferException.ReservationRequired, 409, "migration_reservation_required" },
{ MigrationTransferException.Expired, 410, "migration_session_expired" },
{ MigrationTransferException.IdentityMismatch, 409, "migration_file_identity_mismatch" },
{ MigrationTransferException.RangeInvalid, 416, "migration_chunk_range_invalid" },
{ MigrationTransferException.HashMismatch, 422, "migration_chunk_hash_mismatch" },
{ MigrationTransferException.ChunkConflict, 409, "migration_chunk_conflict" },
{ MigrationTransferException.MissingChunks, 409, "migration_invalid_state" },
{ MigrationTransferException.StorageExhausted, 507, "migration_storage_exhausted" },
{ MigrationTransferException.ImportPreparationUnavailable, 409, "migration_import_preparation_unavailable" },
{ MigrationTransferException.ExportArtifactUnavailable, 409, "migration_export_artifact_unavailable" },
};
[Theory]
[MemberData(nameof(StoreCases))]
public async Task Store_errors_map_to_the_plan_status_and_code(
string storeCode,
string _,
int expectedStatus,
string expectedCode)
{
var exception = new MigrationJobStoreException(storeCode, "internal detail that must not leak");
var (status, code, message) = await ExecuteAsync(MigrationHttpErrors.From(exception));
status.Should().Be(expectedStatus);
code.Should().Be(expectedCode);
message.Should().NotContain("internal detail");
}
[Theory]
[MemberData(nameof(TransferCases))]
public async Task Engine_errors_map_to_the_plan_status_and_code(
string engineCode,
int expectedStatus,
string expectedCode)
{
var exception = new MigrationTransferException(engineCode, "engine detail that must not leak");
var (status, code, message) = await ExecuteAsync(MigrationHttpErrors.From(exception));
status.Should().Be(expectedStatus);
code.Should().Be(expectedCode);
message.Should().NotContain("engine detail");
}
[Fact]
public async Task Reservation_claim_failures_map_to_reservation_required()
{
var exception = new TransferReservationException(
TransferReservationConflictKind.AlreadyClaimed,
Guid.NewGuid());
var (status, code, _) = await ExecuteAsync(MigrationHttpErrors.From(exception));
status.Should().Be(409);
code.Should().Be("migration_reservation_required");
}
[Fact]
public async Task Admission_contention_maps_to_retryable_503_with_retry_after()
{
var context = NewContext(out var body);
await MigrationHttpErrors
.From(TransferReservationException.AdmissionContended(3))
.ExecuteAsync(context);
body.Position = 0;
var payload = await JsonSerializer.DeserializeAsync<MigrationErrorResponse>(
body,
MigrationHttpHarness.Json);
context.Response.StatusCode.Should().Be(503);
context.Response.Headers.RetryAfter.ToString().Should().Be("1");
payload!.Error.Should().Be("migration_storage_contended");
payload.Message.Should().NotContain("AdmissionContended");
}
[Fact]
public async Task Unknown_exception_types_fail_closed_without_leaking_detail()
{
var (status, code, message) = await ExecuteAsync(
MigrationHttpErrors.From(new InvalidOperationException(@"C:\secret\path\library.db")));
status.Should().Be(500);
code.Should().Be("unexpected_error");
message.Should().NotContain("secret").And.NotContain("library.db");
}
private static DefaultHttpContext NewContext(out MemoryStream body)
{
var services = new ServiceCollection();
services.AddOptions();
services.AddLogging();
var context = new DefaultHttpContext
{
RequestServices = services.BuildServiceProvider(),
};
body = new MemoryStream();
context.Response.Body = body;
return context;
}
private static async Task<(int Status, string Code, string Message)> ExecuteAsync(IResult result)
{
var context = NewContext(out var body);
await result.ExecuteAsync(context);
body.Position = 0;
var payload = await JsonSerializer.DeserializeAsync<MigrationErrorResponse>(
body,
MigrationHttpHarness.Json);
payload.Should().NotBeNull();
return (context.Response.StatusCode, payload!.Error, payload.Message);
}
}