using System.IO.Compression; using System.Net; using System.Text; using FluentAssertions; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Nostos.Backend.Data; using Nostos.Backend.Data.Models; using Nostos.Backend.Endpoints; using Nostos.Backend.Services; using Nostos.Backend.Tests.Support; using Xunit; namespace Nostos.Backend.Tests.Portability; /// /// The genuine #554 regression: the complete SelfHosted pipeline listening on a /// real Kestrel socket with /// left at its default (false). The in-memory TestServer does not enforce /// Kestrel's synchronous-IO prohibition, so only a real listener proves that /// the bounded capture adapter absorbs native ZIP finalization (data /// descriptors, Deflate purge, central directory) before the response body. /// /// Also proves the failure contract of the streaming export endpoint: an /// export that fails after the response has started must abort the connection, /// and the client must observe a failure rather than a clean 200 carrying a /// truncated archive. The mutation seam is the copy-pass media check that /// already exists in the product path. /// public sealed class PortableArchiveKestrelTests { [Fact] public async Task Export_over_real_kestrel_streams_a_complete_round_trippable_archive() { using var factory = new LibraryEndpointFactory(); factory.UseKestrel(0); using var host = factory; // Premise of the regression: real Kestrel forbids synchronous response IO. host.Services .GetRequiredService>() .Value.AllowSynchronousIO.Should().BeFalse(); Guid epubId; using (var scope = host.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var storage = scope.ServiceProvider.GetRequiredService(); var ids = await PortableArchiveTestSupport.PopulateRepresentativeAsync(db, storage); epubId = ids.EpubBookId; } using var client = host.CreateClient(); AssertRealKestrelListener(host, client); using var response = await client.GetAsync( "/api/portability/export", HttpCompletionOption.ResponseHeadersRead); response.StatusCode.Should().Be(HttpStatusCode.OK); response.Content.Headers.ContentType?.MediaType .Should().Be(PortabilityEndpoints.ArchiveContentType); response.Content.Headers.ContentDisposition.Should().NotBeNull(); response.Content.Headers.ContentDisposition!.DispositionType.Should().Be("attachment"); response.Content.Headers.ContentDisposition.FileName .Should().StartWith("nostos-export-").And.EndWith(".nostos"); using var bytes = new MemoryStream(); await response.Content.CopyToAsync(bytes); bytes.Length.Should().BeGreaterThan(0); bytes.Position = 0; using (var archive = new ZipArchive(bytes, ZipArchiveMode.Read, leaveOpen: true)) { archive.GetEntry("manifest.json").Should().NotBeNull(); archive.GetEntry("data/library.json").Should().NotBeNull(); archive.Entries.Should().Contain( entry => entry.FullName.StartsWith("media/books/", StringComparison.Ordinal)); } // Round-trip through import into an empty library. bytes.Position = 0; await using var target = await LocalPortableTestLibrary.CreateAsync(); var imported = await target.Portability().ImportAsync(bytes); imported.IntegrityVerified.Should().BeTrue(); imported.Counts.Books.Should().Be(4); imported.MediaFiles.Should().Be(5); (await PortableArchiveTestSupport.ReadBookAsync(target.Storage, epubId)) .Should().Equal(Encoding.UTF8.GetBytes("EPUB-CONTENT-PORTABLE")); } [Fact] public async Task Export_failure_after_real_kestrel_response_started_aborts_the_connection() { var probe = new CopyPassMutationStorage(); using var factory = new LibraryEndpointFactory(); using var host = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // The real host resolves the file storage through this seam so // the copy-pass revision check fails while the response streams. services.RemoveAll(); services.AddSingleton(sp => { probe.SetInner(sp.GetRequiredService()); return probe; }); }); }); host.UseKestrel(0); host.Services .GetRequiredService>() .Value.AllowSynchronousIO.Should().BeFalse(); using (var scope = host.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var storage = scope.ServiceProvider.GetRequiredService(); storage.Should().BeSameAs(probe); var bookId = Guid.NewGuid(); var work = new WorkModel { Id = Guid.NewGuid(), Title = "Kestrel Failure Work", NormalizedTitle = "KESTREL FAILURE WORK", NormalizedAuthor = string.Empty, CreatedAt = DateTime.UtcNow.AddDays(-1), }; var book = new EBookModel { Id = bookId, WorkId = work.Id, Work = work, Title = "Kestrel Failure Book", CreatedAt = DateTime.UtcNow.AddDays(-1), FileDetails = new FileInfoDetails { HasFile = true, FileName = "book.epub", }, }; db.Works.Add(work); db.Books.Add(book); await db.SaveChangesAsync(); var media = new byte[1024 * 1024]; new Random(4242).NextBytes(media); await using (var content = new MemoryStream(media, writable: false)) { await storage.SaveBookFileAsync(bookId, content, "book.epub"); } probe.TargetBookId = bookId; probe.MutatedContent = (byte[])media.Clone(); probe.MutatedContent[0] ^= 0xFF; // A background opener (for example book-text ingestion) can read // the file before the export starts. The failure seam must still // force the copy pass to observe a revision different from the one // the pin pass hashed, so provoke that ordering deterministically // instead of relying on the export being the first opener. await using var backgroundRead = await storage.OpenBookFileAsync(bookId); backgroundRead.Should().NotBeNull(); } using var client = host.CreateClient(); AssertRealKestrelListener(host, client); Exception? observed = null; try { using var response = await client.GetAsync( "/api/portability/export", HttpCompletionOption.ResponseHeadersRead); if (response.StatusCode != HttpStatusCode.OK) { throw new InvalidOperationException( $"Expected a started 200 response, observed {(int)response.StatusCode}."); } // The server aborts mid-body; a clean completion would be the bug. // Wait on the connection outcome itself with a bound rather than // sampling the response once: the read surfaces the abort as an // exception, and a server that never aborts fails the bound instead // of hanging the suite. _ = await response.Content.ReadAsByteArrayAsync() .WaitAsync(TimeSpan.FromSeconds(30)); } catch (Exception exception) when ( exception is HttpRequestException or IOException or OperationCanceledException) { observed = exception; } observed.Should().NotBeNull( "an export failure after the response started must be visible to the client " + "as an aborted connection, never a clean 200 with a truncated body"); } private static void AssertRealKestrelListener( Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory host, HttpClient client) { client.BaseAddress.Should().NotBeNull(); client.BaseAddress!.Port.Should().BeGreaterThan( 0, "a TestServer client has no bound socket; Kestrel on port 0 resolves a real port"); host.Services.GetRequiredService() .GetType().FullName.Should().Contain( "Kestrel", "the #554 regression is only genuine when Kestrel's synchronous-IO rules are enforced"); } /// /// Delegates every storage call to the real local store except that opens of /// the target book file after the first serve content distinct from every /// other open, same length, so the pin pass and the copy pass can never /// agree no matter how many other openers interleave. The pin pass still /// hashes whatever it reads and the copy-pass comparison fails with /// source_media_changed after archive output has begun. /// private sealed class CopyPassMutationStorage : DelegatingBookAssetStorage { private int _targetOpenCount; public void SetInner(FileStorageService inner) => Inner = inner; public Guid? TargetBookId { get; set; } public byte[]? MutatedContent { get; set; } public override async Task OpenBookFileAsync( Guid bookId, StorageByteRange? range = null, CancellationToken ct = default) { if (bookId == TargetBookId && range is null) { var open = Interlocked.Increment(ref _targetOpenCount); if (open > 1 && MutatedContent is not null) { var info = await Inner.GetBookFileInfoAsync(bookId, ct); if (info is not null) { // Every open after the first serves bytes no other open // served. A background worker (for example book-text // ingestion) may open the file before or between the // export's pin and copy passes; the copy pass must still // see a revision different from the one the pin pass // hashed. var content = (byte[])MutatedContent.Clone(); content[(open - 1) % content.Length] ^= 0xFF; return new StoredAssetRead( info, new MemoryStream(content, writable: false)); } } } return await Inner.OpenBookFileAsync(bookId, range, ct); } } }