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);
}
}
}