Nostos.Backend.Tests/Portability/PortableLibraryVerifierAssetStorageTests.cs
Rama main · réplica de lectura del repositorio original.
using System.Security.Cryptography;
using FluentAssertions;
using Nostos.Backend.Services;
using Nostos.Backend.Services.Portability;
using Xunit;
namespace Nostos.Backend.Tests.Portability;
/// <summary>
/// <c>VerifyMediaAsync</c> against the provider-neutral asset-storage abstraction:
/// presence, length and SHA-256 of every expected entry, streamed with the bounded
/// copy buffer. The abstraction exposes no inventory enumeration, so unexpected
/// objects are the caller's responsibility (documented on the interface).
/// </summary>
[Collection(PortableLibraryVerificationCollection.Name)]
public sealed class PortableLibraryVerifierAssetStorageTests
{
private readonly PortableLibraryVerifier _verifier = new();
[Fact]
public async Task Missing_stored_object_is_reported()
{
await using var library = await LocalPortableTestLibrary.CreateAsync();
var descriptor = BookDescriptor(Guid.NewGuid(), length: 12, sha256: new string('0', 64));
var report = await _verifier.VerifyMediaAsync(library.Storage, [descriptor]);
report.Passed.Should().BeFalse();
report.Failures.Should().Contain(failure =>
failure.Code == PortableLibraryVerificationErrorCodes.MediaMissing
&& failure.Entity == "media"
&& failure.EntityId == descriptor.BookId.ToString("D"));
report.MediaFilesVerified.Should().Be(0);
}
[Fact]
public async Task Wrong_stored_length_is_reported()
{
await using var library = await LocalPortableTestLibrary.CreateAsync();
var bookId = Guid.NewGuid();
var payload = "stored-payload"u8.ToArray();
await library.Storage.SaveBookFileAsync(bookId, new MemoryStream(payload), "book.epub");
var report = await _verifier.VerifyMediaAsync(
library.Storage,
[BookDescriptor(bookId, payload.LongLength + 1, Sha256Hex(payload))]);
report.Passed.Should().BeFalse();
report.Failures.Should().Contain(failure =>
failure.Code == PortableLibraryVerificationErrorCodes.MediaLengthMismatch);
report.MediaFilesVerified.Should().Be(0);
}
[Fact]
public async Task Wrong_stored_hash_is_reported()
{
await using var library = await LocalPortableTestLibrary.CreateAsync();
var bookId = Guid.NewGuid();
var payload = "stored-payload"u8.ToArray();
await library.Storage.SaveBookFileAsync(bookId, new MemoryStream(payload), "book.epub");
var report = await _verifier.VerifyMediaAsync(
library.Storage,
[BookDescriptor(bookId, payload.LongLength, new string('0', 64))]);
report.Passed.Should().BeFalse();
report.Failures.Should().Contain(failure =>
failure.Code == PortableLibraryVerificationErrorCodes.MediaHashMismatch);
report.MediaFilesVerified.Should().Be(0);
}
[Fact]
public async Task Matching_book_and_cover_objects_pass()
{
await using var library = await LocalPortableTestLibrary.CreateAsync();
var bookId = Guid.NewGuid();
var bookBytes = "book-payload"u8.ToArray();
var coverBytes = "cover-payload"u8.ToArray();
await library.Storage.SaveBookFileAsync(bookId, new MemoryStream(bookBytes), "book.epub");
await library.Storage.SaveBookCoverAsync(bookId, new MemoryStream(coverBytes), "cover.png");
var report = await _verifier.VerifyMediaAsync(
library.Storage,
[
BookDescriptor(bookId, bookBytes.LongLength, Sha256Hex(bookBytes)),
CoverDescriptor(bookId, coverBytes.LongLength, Sha256Hex(coverBytes)),
]);
report.Passed.Should().BeTrue(
string.Join("; ", report.Failures.Select(failure => $"{failure.Code}:{failure.Field}")));
report.Failures.Should().BeEmpty();
report.MediaFilesVerified.Should().Be(2);
report.MediaBytesVerified.Should().Be(bookBytes.LongLength + coverBytes.LongLength);
report.VerifiedKinds.Should().ContainSingle()
.Which.Should().Be(nameof(PortableArchiveMediaEntry));
}
[Fact]
public async Task Large_object_is_streamed_with_the_bounded_copy_buffer()
{
const long length = 48L * 1024 * 1024;
var sha256 = HashGenerated(length);
var storage = new SingleBookFileStorage(length);
var report = await _verifier.VerifyMediaAsync(
storage,
[BookDescriptor(storage.BookId, length, sha256)]);
report.Passed.Should().BeTrue(
string.Join("; ", report.Failures.Select(failure => $"{failure.Code}:{failure.Field}")));
report.MediaFilesVerified.Should().Be(1);
report.MediaBytesVerified.Should().Be(length);
storage.Stream.MaxRequestedBufferBytes.Should().BeGreaterThan(0);
storage.Stream.MaxRequestedBufferBytes.Should().BeLessThanOrEqualTo(
PortableArchiveLimits.CopyBufferBytes);
storage.Stream.Disposed.Should().BeTrue();
}
private static PortableArchiveMediaEntry BookDescriptor(Guid bookId, long length, string sha256) =>
new(
BookId: bookId,
Kind: PortableArchiveFormat.BookMediaKind,
Path: $"media/books/{bookId:N}/book.epub",
FileName: "book.epub",
ContentType: "application/epub+zip",
Length: length,
Sha256: sha256);
private static PortableArchiveMediaEntry CoverDescriptor(Guid bookId, long length, string sha256) =>
new(
BookId: bookId,
Kind: PortableArchiveFormat.CoverMediaKind,
Path: $"media/books/{bookId:N}/cover.png",
FileName: "cover.png",
ContentType: "image/png",
Length: length,
Sha256: sha256);
private static string Sha256Hex(byte[] content) =>
Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
private static string HashGenerated(long length)
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var buffer = new byte[4096];
long offset = 0;
while (offset < length)
{
var count = (int)Math.Min(buffer.Length, length - offset);
for (var index = 0; index < count; index++)
{
buffer[index] = GenerateByte(offset + index);
}
hash.AppendData(buffer, 0, count);
offset += count;
}
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
}
private static byte GenerateByte(long offset) => (byte)(offset % 251);
private sealed class GeneratedAssetStream(long length) : Stream
{
private long _offset;
internal int MaxRequestedBufferBytes { get; private set; }
internal bool Disposed { get; private set; }
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => length;
public override long Position
{
get => _offset;
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count) =>
Fill(buffer.AsSpan(offset, count));
public override int Read(Span<byte> buffer) => Fill(buffer);
public override ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
MaxRequestedBufferBytes = Math.Max(MaxRequestedBufferBytes, buffer.Length);
return ValueTask.FromResult(Fill(buffer.Span));
}
public override long Seek(long offset, SeekOrigin origin) =>
throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) =>
throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
Disposed = true;
base.Dispose(disposing);
}
private int Fill(Span<byte> buffer)
{
var remaining = length - _offset;
if (remaining <= 0)
{
return 0;
}
var count = (int)Math.Min(buffer.Length, remaining);
for (var index = 0; index < count; index++)
{
buffer[index] = GenerateByte(_offset + index);
}
_offset += count;
return count;
}
}
private sealed class SingleBookFileStorage : IBookAssetStorage
{
private readonly long _length;
internal SingleBookFileStorage(long length)
{
_length = length;
Stream = new GeneratedAssetStream(length);
}
internal Guid BookId { get; } = Guid.NewGuid();
internal GeneratedAssetStream Stream { get; }
public Task<StoredAssetInfo?> GetBookFileInfoAsync(
Guid bookId,
CancellationToken ct = default) =>
Task.FromResult<StoredAssetInfo?>(Info());
public Task<StoredAssetRead?> OpenBookFileAsync(
Guid bookId,
StorageByteRange? range = null,
CancellationToken ct = default) =>
Task.FromResult<StoredAssetRead?>(new StoredAssetRead(Info(), Stream));
private StoredAssetInfo Info() =>
new("book.epub", "application/epub+zip", _length, "etag", DateTimeOffset.UnixEpoch);
public Task<string> SaveBookFileAsync(Guid bookId, Stream content, string fileName, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<string> AdoptBookFileAsync(Guid bookId, string sourcePath, string fileName, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<bool> DeleteBookFileAsync(Guid bookId, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task DeleteBookFilesAsync(Guid bookId, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<string> SaveBookCoverAsync(Guid bookId, Stream content, string fileName, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<StoredAssetInfo?> GetBookCoverInfoAsync(Guid bookId, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<StoredAssetRead?> OpenBookCoverAsync(Guid bookId, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<StoredAssetInfo?> GetBookCoverThumbnailInfoAsync(Guid bookId, int width, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<StoredAssetRead?> OpenBookCoverThumbnailAsync(Guid bookId, int width, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<bool> DeleteCoverAsync(Guid bookId, CancellationToken ct = default) =>
throw new NotSupportedException();
}
}