feat(web): add local portfolio cover storage

This commit is contained in:
2026-06-02 12:35:00 +03:00
parent 7d1489445e
commit e5945288ac
11 changed files with 575 additions and 1 deletions
@@ -0,0 +1,172 @@
using GmRelay.Web.Services.Portfolio.Covers;
namespace GmRelay.Bot.Tests.Web;
public sealed class LocalPortfolioCoverStorageTests : IDisposable
{
private readonly string _storagePath;
private readonly LocalPortfolioCoverStorage _storage;
public LocalPortfolioCoverStorageTests()
{
_storagePath = Path.Combine(
Path.GetTempPath(),
"gmrelay-portfolio-covers-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_storagePath);
_storage = new LocalPortfolioCoverStorage(new PortfolioCoverStorageOptions
{
StoragePath = _storagePath
});
}
public void Dispose()
{
if (Directory.Exists(_storagePath))
{
Directory.Delete(_storagePath, recursive: true);
}
GC.SuppressFinalize(this);
}
[Fact]
public async Task SaveAsync_ShouldPersistPngWithRandomProviderNeutralKey()
{
await using var stream = new MemoryStream(
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]);
var result = await _storage.SaveAsync(stream, "image/png");
Assert.EndsWith(".png", result.StorageKey, StringComparison.Ordinal);
Assert.StartsWith("/portfolio-covers/", _storage.GetPublicPath(result.StorageKey), StringComparison.Ordinal);
Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey)));
}
[Theory]
[InlineData("image/jpeg")]
[InlineData("image/png")]
[InlineData("image/webp")]
public async Task SaveAsync_ShouldRejectMismatchedSignature(string contentType)
{
await using var stream = new MemoryStream([0x00, 0x01, 0x02, 0x03]);
await Assert.ThrowsAsync<InvalidOperationException>(
() => _storage.SaveAsync(stream, contentType));
}
[Fact]
public async Task SaveAsync_ShouldPersistJpegWithCorrectSignature()
{
await using var stream = new MemoryStream(
[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46]);
var result = await _storage.SaveAsync(stream, "image/jpeg");
Assert.EndsWith(".jpg", result.StorageKey, StringComparison.Ordinal);
Assert.Equal("image/jpeg", result.ContentType);
Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey)));
}
[Fact]
public async Task SaveAsync_ShouldPersistWebpWithRiffWebpSignature()
{
await using var stream = new MemoryStream(
[0x52, 0x49, 0x46, 0x46, 0x1A, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38]);
var result = await _storage.SaveAsync(stream, "image/webp");
Assert.EndsWith(".webp", result.StorageKey, StringComparison.Ordinal);
Assert.Equal("image/webp", result.ContentType);
Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey)));
}
[Fact]
public async Task SaveAsync_ShouldRejectStreamLargerThanMaxBytes()
{
var oversized = new byte[LocalPortfolioCoverStorage.MaxBytes + 1];
await using var stream = new MemoryStream(oversized);
await Assert.ThrowsAsync<InvalidOperationException>(
() => _storage.SaveAsync(stream, "image/png"));
}
[Fact]
public async Task SaveAsync_ShouldRejectUnknownContentType()
{
await using var stream = new MemoryStream([0x89, 0x50, 0x4E, 0x47]);
await Assert.ThrowsAsync<InvalidOperationException>(
() => _storage.SaveAsync(stream, "application/octet-stream"));
}
[Fact]
public async Task DeleteIfExistsAsync_ShouldRemoveExistingKey()
{
await using var stream = new MemoryStream(
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
var result = await _storage.SaveAsync(stream, "image/png");
var path = Path.Combine(_storagePath, result.StorageKey);
Assert.True(File.Exists(path));
await _storage.DeleteIfExistsAsync(result.StorageKey);
Assert.False(File.Exists(path));
}
[Fact]
public async Task DeleteIfExistsAsync_ShouldBeNoOpForMissingKey()
{
var key = Guid.NewGuid().ToString("N") + ".png";
await _storage.DeleteIfExistsAsync(key);
}
[Fact]
public async Task DeleteIfExistsAsync_ShouldRejectPathTraversal()
{
await Assert.ThrowsAsync<InvalidOperationException>(
() => _storage.DeleteIfExistsAsync("../escape.png"));
}
[Fact]
public async Task DeleteIfExistsAsync_ShouldRejectKeyWithInvalidExtension()
{
await Assert.ThrowsAsync<InvalidOperationException>(
() => _storage.DeleteIfExistsAsync(Guid.NewGuid().ToString("N") + ".gif"));
}
[Fact]
public void GetPublicPath_ShouldEscapeSpecialCharacters()
{
var key = "0123456789abcdef0123456789abcdef" + ".png";
var path = _storage.GetPublicPath(key);
Assert.Equal("/portfolio-covers/" + Uri.EscapeDataString(key), path);
}
[Fact]
public async Task SaveAsync_ShouldGenerateUniqueKeys()
{
await using var stream1 = new MemoryStream(
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
await using var stream2 = new MemoryStream(
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
var first = await _storage.SaveAsync(stream1, "image/png");
var second = await _storage.SaveAsync(stream2, "image/png");
Assert.NotEqual(first.StorageKey, second.StorageKey);
}
[Fact]
public async Task SaveAsync_ShouldNotLeaveTempFileBehind()
{
await using var stream = new MemoryStream(
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
await _storage.SaveAsync(stream, "image/png");
var tempFiles = Directory.GetFiles(_storagePath, "*.tmp");
Assert.Empty(tempFiles);
}
}
@@ -0,0 +1,73 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class PortfolioCoverRuntimeWiringTests
{
[Fact]
public async Task Program_ShouldRegisterPortfolioCoverStorage()
{
var program = await ReadRepositoryFileAsync("src/GmRelay.Web/Program.cs");
Assert.Contains("AddPortfolioCoverStorage", program, StringComparison.Ordinal);
Assert.Contains("UsePortfolioCoverFiles", program, StringComparison.Ordinal);
}
[Fact]
public async Task Compose_ShouldMountPortfolioCoversVolumeAndPassStoragePath()
{
var compose = await ReadRepositoryFileAsync("compose.yaml");
Assert.Contains("PortfolioCovers__StoragePath=/app/portfolio-covers", compose, StringComparison.Ordinal);
Assert.Contains("portfolio_covers:/app/portfolio-covers", compose, StringComparison.Ordinal);
}
[Fact]
public async Task Dockerfile_ShouldCreateAndChownPortfolioCoversDirectory()
{
var dockerfile = await ReadRepositoryFileAsync("src/GmRelay.Web/Dockerfile");
Assert.Contains("mkdir -p /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal);
Assert.Contains("chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal);
}
[Fact]
public async Task DevelopmentSettings_ShouldConfigurePortfolioCoversStoragePath()
{
var developmentSettings = await ReadRepositoryFileAsync("src/GmRelay.Web/appsettings.Development.json");
Assert.Contains("../../artifacts/portfolio-covers", developmentSettings, StringComparison.Ordinal);
}
[Fact]
public async Task EnvExample_ShouldDocumentPortfolioCoversVolumeName()
{
var envExample = await ReadRepositoryFileAsync(".env.example");
Assert.Contains("PORTFOLIO_COVERS_VOLUME_NAME", envExample, StringComparison.Ordinal);
}
[Fact]
public async Task Compose_ShouldDeclarePortfolioCoversNamedVolume()
{
var compose = await ReadRepositoryFileAsync("compose.yaml");
Assert.Contains("portfolio_covers:", compose, StringComparison.Ordinal);
Assert.Contains("PORTFOLIO_COVERS_VOLUME_NAME", compose, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}