using System.Net; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Nostos.Backend.Data; using Nostos.Backend.Data.Models; using Nostos.Backend.Endpoints; using Nostos.Backend.Services.Portability; using Nostos.Backend.Services.Portability.Transfers; using Nostos.Backend.Tests.Support; using Xunit; namespace Nostos.Backend.Tests.Portability; /// /// Slice 10 download contract over a real Kestrel socket: the sealed artifact /// is served by the framework's range-enabled physical-file result, so full, /// ranged, suffix, open-ended, If-Range, and 416 responses are exercised /// through real HTTP. Unknown/foreign identifiers and unavailable jobs return /// 404; expired artifacts return 410 with a typed code. /// public sealed class MigrationExportDownloadHttpTests { [Fact] public async Task Full_download_streams_the_artifact_as_an_attachment() { using var factory = new LibraryEndpointFactory(); factory.UseKestrel(0); using var host = factory; var content = DeterministicBytes(2 * 1024 * 1024 + 123); var jobId = await SeedArtifactAsync(host, content); using var client = host.CreateClient(); using var response = await client.GetAsync( $"/api/portability/migration/jobs/{jobId}/export-download", HttpCompletionOption.ResponseHeadersRead); response.StatusCode.Should().Be(HttpStatusCode.OK); response.Content.Headers.ContentType?.MediaType .Should().Be(PortabilityEndpoints.ArchiveContentType); response.Content.Headers.ContentDisposition?.DispositionType.Should().Be("attachment"); response.Content.Headers.ContentDisposition?.FileName.Should().Be("library.nostos"); response.Content.Headers.ContentLength.Should().Be(content.LongLength); response.Headers.AcceptRanges.Should().ContainSingle("bytes"); response.Headers.ETag.Should().NotBeNull(); (await response.Content.ReadAsByteArrayAsync()).Should().Equal(content); } [Theory] [InlineData("bytes=0-9")] [InlineData("bytes=100-199")] [InlineData("bytes=1048570-")] [InlineData("bytes=-32")] public async Task Valid_ranges_return_206_with_exact_slices(string range) { using var factory = new LibraryEndpointFactory(); factory.UseKestrel(0); using var host = factory; var content = DeterministicBytes(2 * 1024 * 1024 + 123); var jobId = await SeedArtifactAsync(host, content); using var client = host.CreateClient(); using var request = new HttpRequestMessage( HttpMethod.Get, $"/api/portability/migration/jobs/{jobId}/export-download"); request.Headers.TryAddWithoutValidation("Range", range); using var response = await client.SendAsync( request, HttpCompletionOption.ResponseHeadersRead); response.StatusCode.Should().Be(HttpStatusCode.PartialContent); response.Content.Headers.ContentRange.Should().NotBeNull(); var contentRange = response.Content.Headers.ContentRange!; contentRange.Length.Should().Be(content.LongLength); var start = contentRange.From!.Value; var end = contentRange.To!.Value; var body = await response.Content.ReadAsByteArrayAsync(); body.Should().Equal(content[(int)start..(int)(end + 1)]); } [Fact] public async Task If_range_with_matching_etag_returns_206_and_a_stale_etag_returns_the_full_file() { using var factory = new LibraryEndpointFactory(); factory.UseKestrel(0); using var host = factory; var content = DeterministicBytes(512 * 1024); var jobId = await SeedArtifactAsync(host, content); using var client = host.CreateClient(); using var full = await client.GetAsync( $"/api/portability/migration/jobs/{jobId}/export-download"); var entityTag = full.Headers.ETag!.ToString(); async Task RangeWithIfRangeAsync(string ifRange) { using var request = new HttpRequestMessage( HttpMethod.Get, $"/api/portability/migration/jobs/{jobId}/export-download"); request.Headers.Range = new RangeHeaderValue(0, 15); request.Headers.TryAddWithoutValidation("If-Range", ifRange); return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); } using var matching = await RangeWithIfRangeAsync(entityTag); matching.StatusCode.Should().Be(HttpStatusCode.PartialContent); (await matching.Content.ReadAsByteArrayAsync()).Should().Equal(content[..16]); using var stale = await RangeWithIfRangeAsync("\"0000000000000000000000000000000000000000000000000000000000000000\""); stale.StatusCode.Should().Be(HttpStatusCode.OK); (await stale.Content.ReadAsByteArrayAsync()).Should().Equal(content); } [Fact] public async Task Invalid_range_returns_416() { using var factory = new LibraryEndpointFactory(); factory.UseKestrel(0); using var host = factory; var content = DeterministicBytes(64 * 1024); var jobId = await SeedArtifactAsync(host, content); using var client = host.CreateClient(); using var request = new HttpRequestMessage( HttpMethod.Get, $"/api/portability/migration/jobs/{jobId}/export-download"); request.Headers.Range = new RangeHeaderValue(content.LongLength + 1, null); using var response = await client.SendAsync(request); response.StatusCode.Should().Be(HttpStatusCode.RequestedRangeNotSatisfiable); } [Fact] public async Task Unknown_and_import_identifiers_return_404() { using var factory = new LibraryEndpointFactory(); factory.UseKestrel(0); using var host = factory; using var client = host.CreateClient(); using var unknown = await client.GetAsync( $"/api/portability/migration/jobs/{Guid.NewGuid()}/export-download"); unknown.StatusCode.Should().Be(HttpStatusCode.NotFound); (await ErrorCodeAsync(unknown)).Should().Be("migration_not_found"); Guid importJobId; using (var scope = host.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); importJobId = Guid.NewGuid(); db.MigrationJobRecords.Add(NewJob(importJobId, MigrationJobState.Pending, MigrationDirection.Import)); await db.SaveChangesAsync(); } using var import = await client.GetAsync( $"/api/portability/migration/jobs/{importJobId}/export-download"); import.StatusCode.Should().Be(HttpStatusCode.NotFound); (await ErrorCodeAsync(import)).Should().Be("migration_not_found"); } [Fact] public async Task Not_completed_job_returns_404_and_expired_artifact_returns_410() { using var factory = new LibraryEndpointFactory(); factory.UseKestrel(0); using var host = factory; using var client = host.CreateClient(); // An artifact that exists for a job that has not reached Completed. var active = await SeedArtifactAsync(host, DeterministicBytes(128), state: MigrationJobState.Validating); using var notCompleted = await client.GetAsync( $"/api/portability/migration/jobs/{active}/export-download"); notCompleted.StatusCode.Should().Be(HttpStatusCode.NotFound); (await ErrorCodeAsync(notCompleted)).Should().Be("migration_export_not_available"); var expired = await SeedArtifactAsync( host, DeterministicBytes(128), artifactExpired: true); using var expiredResponse = await client.GetAsync( $"/api/portability/migration/jobs/{expired}/export-download"); expiredResponse.StatusCode.Should().Be(HttpStatusCode.Gone); (await ErrorCodeAsync(expiredResponse)).Should().Be("migration_export_expired"); } [Fact] public async Task Download_headers_arrive_before_the_body_is_read_and_partial_reads_stream() { using var factory = new LibraryEndpointFactory(); factory.UseKestrel(0); using var host = factory; var content = DeterministicBytes(12 * 1024 * 1024); var jobId = await SeedArtifactAsync(host, content); using var client = host.CreateClient(); using var response = await client.GetAsync( $"/api/portability/migration/jobs/{jobId}/export-download", HttpCompletionOption.ResponseHeadersRead); response.StatusCode.Should().Be(HttpStatusCode.OK); response.Content.Headers.ContentLength.Should().Be(content.LongLength); // Read only a prefix of the declared body; the response must stream // (SendFileAsync) rather than require the whole artifact in memory. await using var stream = await response.Content.ReadAsStreamAsync(); var prefix = new byte[4096]; var read = await stream.ReadAtLeastAsync(prefix, prefix.Length); read.Should().Be(prefix.Length); prefix.Should().Equal(content[..prefix.Length]); } [Fact] public async Task Head_returns_the_file_headers_without_a_body() { using var factory = new LibraryEndpointFactory(); factory.UseKestrel(0); using var host = factory; var content = DeterministicBytes(256 * 1024); var jobId = await SeedArtifactAsync(host, content); using var client = host.CreateClient(); using var request = new HttpRequestMessage( HttpMethod.Head, $"/api/portability/migration/jobs/{jobId}/export-download"); using var response = await client.SendAsync(request); response.StatusCode.Should().Be(HttpStatusCode.OK); response.Content.Headers.ContentLength.Should().Be(content.LongLength); response.Headers.AcceptRanges.Should().ContainSingle("bytes"); response.Content.Headers.ContentDisposition?.DispositionType.Should().Be("attachment"); (await response.Content.ReadAsByteArrayAsync()).Should().BeEmpty(); } [Fact] public async Task An_in_flight_download_survives_the_sweep_deleting_the_artifact() { using var factory = new LibraryEndpointFactory(); factory.UseKestrel(0); using var host = factory; var content = DeterministicBytes(2 * 1024 * 1024 + 7); var jobId = await SeedArtifactAsync(host, content); using var client = host.CreateClient(); using var response = await client.GetAsync( $"/api/portability/migration/jobs/{jobId}/export-download", HttpCompletionOption.ResponseHeadersRead); response.StatusCode.Should().Be(HttpStatusCode.OK); await using var stream = await response.Content.ReadAsStreamAsync(); var prefix = new byte[4096]; (await stream.ReadAtLeastAsync(prefix, prefix.Length)).Should().Be(prefix.Length); // Expire the artifact and run the sweep while the response is open. The // endpoint serves from the opened handle, so the rest of the body still // arrives even though the row is now expired/deleted. await using (var scope = host.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var clock = scope.ServiceProvider.GetRequiredService(); await db.MigrationExportArtifactRecords .Where(a => a.JobId == jobId) .ExecuteUpdateAsync(s => s.SetProperty( a => a.ExpiresAtUtc, clock.GetUtcNow().AddHours(-1).UtcDateTime)); var cleanup = scope.ServiceProvider .GetRequiredService(); await cleanup.SweepAsync(default); } using var rest = new MemoryStream(); await stream.CopyToAsync(rest); rest.ToArray().Should().Equal(content[prefix.Length..]); } private static byte[] DeterministicBytes(int count) { var bytes = new byte[count]; new Random(20261004).NextBytes(bytes); return bytes; } private static async Task SeedArtifactAsync( LibraryEndpointFactory host, byte[] content, MigrationJobState state = MigrationJobState.Completed, bool artifactExpired = false) { using var scope = host.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var paths = scope.ServiceProvider.GetRequiredService(); var clock = scope.ServiceProvider.GetRequiredService(); var now = clock.GetUtcNow().UtcDateTime; var jobId = Guid.NewGuid(); db.MigrationJobRecords.Add(NewJob(jobId, state, MigrationDirection.Export, now)); paths.EnsureDirectoryExists(paths.GetExportDirectory(jobId)); var storageKey = paths.GetExportAttemptArtifactStorageKey(jobId, 1, new string('d', 64)); var artifactPath = paths.ResolveStorageKey(storageKey); await File.WriteAllBytesAsync(artifactPath, content); db.MigrationExportArtifactRecords.Add(new MigrationExportArtifactRecord { JobId = jobId, State = (int)MigrationExportArtifactState.Available, StorageKey = storageKey, FileName = "library.nostos", ContentType = PortabilityEndpoints.ArchiveContentType, SizeBytes = content.LongLength, Sha256 = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant(), CreatedAtUtc = now, AvailableAtUtc = now, ExpiresAtUtc = artifactExpired ? now.AddHours(-1) : now.AddHours(24), }); await db.SaveChangesAsync(); return jobId; } private static MigrationJobRecord NewJob( Guid id, MigrationJobState state, MigrationDirection direction, DateTime? now = null) => new() { Id = id, Direction = (int)direction, State = (int)state, RecoveryStatus = (int)MigrationRecoveryStatus.NotRequired, ProgressPhase = (int)MigrationProgressPhase.Completed, CreatedAtUtc = now ?? DateTime.UtcNow, UpdatedAtUtc = now ?? DateTime.UtcNow, IdempotencyKey = Guid.NewGuid().ToString("N"), CreationPayloadHash = new string('a', 64), ExpiresAtUtc = (now ?? DateTime.UtcNow).AddHours(24), AttemptNumber = 1, }; private static async Task ErrorCodeAsync(HttpResponseMessage response) { using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); return document.RootElement.TryGetProperty("error", out var error) ? error.GetString() : null; } }