using PrivaPub.Models.Federation; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Memory; using MongoDB.Entities; using NetVips; using PrivaPub.Domain.Media; using PrivaPub.Domain.Statuses; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Media; using PrivaPub.Tests.Support; namespace PrivaPub.Tests.Domain { [Trait("Category", "Integration")] public sealed class MediaFlowTests : IAsyncLifetime { Harness _harness; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _harness = await Harness.Start(); } public async ValueTask DisposeAsync() { if (_harness != default) await _harness.DisposeAsync(); } static byte[] Png(int width, int height) { using var image = (Image.Black(width, height, bands: 3) + new double[] { 10, 120, 200 }).Cast(Enums.BandFormat.Uchar); return image.WriteToBuffer(".png"); } static IFormFile Upload(byte[] bytes, string contentType) => new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "picture") { Headers = new HeaderDictionary(), ContentType = contentType }; [Fact] public async Task An_upload_is_attached_once_and_federated_with_its_alt_text() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var (_, mallory) = await _harness.Persona("mallory"); var upload = await _harness.Media.Upload(alice, Upload(Png(300, 200), "image/png"), "a blue square", "0.25,-0.5", false, token); Assert.True(upload.Ok); Assert.True(File.Exists(Path.Combine(_harness.Media.Root, upload.Attachment.FilePath))); var stolen = await _harness.Statuses.Publish(mallory, new StatusDraft { Text = "mine", MediaIds = new[] { upload.Attachment.ID } }, token); Assert.False(stolen.Ok); var posted = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "look", MediaIds = new[] { upload.Attachment.ID } }, token); Assert.True(posted.Ok); Assert.Equal(posted.Post.ID, (await DB.Default.Find().OneAsync(upload.Attachment.ID, token)).PostId); var note = ActivityPubRenderer.Note(posted.Post, alice, default, default); var attachment = note["attachment"]![0]!; Assert.Equal("a blue square", attachment["name"]!.GetValue()); Assert.Equal(300, attachment["width"]!.GetValue()); Assert.Equal(-0.5f, attachment["focalPoint"]![1]!.GetValue()); Assert.StartsWith("https://privapub.test/media/files/", attachment["url"]!.GetValue()); var reused = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "again", MediaIds = new[] { upload.Attachment.ID } }, token); Assert.False(reused.Ok); } [Fact] public async Task Unsupported_files_are_refused() { var (_, alice) = await _harness.Persona("alice"); var outcome = await _harness.Media.Upload(alice, Upload(System.Text.Encoding.UTF8.GetBytes(""), "image/svg+xml"), default, default, false, TestContext.Current.CancellationToken); Assert.False(outcome.Ok); Assert.Equal(422, outcome.Status); } [Fact] public async Task The_proxy_serves_signed_remote_media_and_nothing_else() { var token = TestContext.Current.CancellationToken; var path = $"/files/{Guid.NewGuid():N}.png"; _harness.Peer.ServeFile(path, Png(10, 10), "image/png"); var proxy = new MediaProxy(_harness.Local, Peer.Http(), _harness.Media, new StaticOptions(new MediaOptions())); var wrapped = proxy.Wrap(_harness.Peer.A + path); var parts = new Uri(wrapped).AbsolutePath.Split('/'); var (file, contentType) = await proxy.Fetch(parts[3], parts[4], token); var (tampered, _) = await proxy.Fetch(parts[3].Replace(parts[3][0], parts[3][0] == 'A' ? 'B' : 'A'), parts[4], token); Assert.StartsWith("https://privapub.test/media/proxy/", wrapped); Assert.Equal("image/png", contentType); Assert.True(File.Exists(file)); Assert.StartsWith(_harness.Media.ProxyRoot, file); Assert.Null(tampered); } // a login over its quota uploads nothing more, whichever persona tries [Fact] public async Task A_login_over_its_quota_uploads_nothing_more() { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); var (_, sibling) = await _harness.Persona("sibling", root); var quota = new MediaService(new StaticOptions(new MediaOptions { Root = _harness.Media.Root, QuotaBytesPerRoot = 3000 }), _harness.Local, default, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); // noise: its PNG is about as big as its pixels, so two of them are over the quota and one is not static byte[] Noise() { var pixels = new byte[30 * 20 * 3]; Random.Shared.NextBytes(pixels); using var image = NetVips.Image.NewFromMemory(pixels, 30, 20, 3, NetVips.Enums.BandFormat.Uchar); return image.WriteToBuffer(".png"); } Assert.True((await quota.Upload(alice, Upload(Noise(), "image/png"), default, default, false, token)).Ok); var full = await quota.Upload(sibling, Upload(Noise(), "image/png"), default, default, false, token); Assert.False(full.Ok); Assert.Contains("storage is full", full.Error); } // nothing of a suspended server, or of one whose media are rejected, is proxied; blocking one purges what was cached [Fact] public async Task A_blocked_servers_media_are_not_proxied_and_their_cache_goes() { var token = TestContext.Current.CancellationToken; var path = $"/files/{Guid.NewGuid():N}.png"; _harness.Peer.ServeFile(path, Png(10, 10), "image/png"); var remote = _harness.Peer.A + path; var host = new Uri(remote).Host; var blocks = new Blocks(); var proxy = new MediaProxy(_harness.Local, Peer.Http(), _harness.Media, new StaticOptions(new MediaOptions()), blocks); Assert.Equal(ProxyOutcome.Cached, (await proxy.Download(remote, token)).Outcome); blocks.Blocked[host] = new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Silence, RejectMedia = true }; Assert.True(proxy.Refuses(remote)); Assert.True(proxy.Purge(host) >= 1); Assert.Equal(default, proxy.Cached(remote)); blocks.Blocked[host] = new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Silence }; Assert.False(proxy.Refuses(remote)); blocks.Blocked[host] = new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Suspend }; Assert.True(proxy.Refuses(remote)); } sealed class Blocks : PrivaPub.Federation.Moderation.IDomainBlocks { public Dictionary Blocked { get; } = new(); public DomainBlock Find(string host) => Blocked.GetValueOrDefault(host); public bool IsSuspended(string host) => Find(host)?.Severity == DomainBlockSeverity.Suspend; public Task Reload(CancellationToken token) => Task.CompletedTask; } } }