feat(web): add local portfolio cover storage
This commit is contained in:
@@ -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}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user