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; /// /// 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. /// public sealed class MigrationHttpErrorMappingTests { public static TheoryData 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 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( 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( body, MigrationHttpHarness.Json); payload.Should().NotBeNull(); return (context.Response.StatusCode, payload!.Error, payload.Message); } }