using System.Net; using System.Text; using Halo.Core.Metadata; using Microsoft.Extensions.Logging.Abstractions; namespace Halo.Metadata.Tests; public class ImageCacheTests { private string _wurzel = ""; [SetUp] public void Anlegen() => _wurzel = Directory.CreateDirectory( Path.Combine(Path.GetTempPath(), "halo-bilder-" + Guid.CreateVersion7().ToString("N"))).FullName; [TearDown] public void Aufraeumen() { try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { } } private ImageCache Ablage(Attrappe attrappe) => new(new HttpClient(attrappe), new ImageCacheOptions(_wurzel), NullLogger.Instance); private static Attrappe MitBild(byte[]? inhalt = null) => new(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(inhalt ?? Encoding.UTF8.GetBytes("ein bild")), }); [Test] public async Task Ein_Bild_wird_abgelegt_und_liegt_danach_da() { var ablage = Ablage(MitBild()); var schlüssel = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")); Assert.That(schlüssel, Is.Not.Null); Assert.That(schlüssel, Has.Length.EqualTo(64)); Assert.That(File.Exists(ablage.PfadFür(schlüssel!)), Is.True); } /// Dasselbe Bild wird nicht zweimal geholt. /// /// /// Bei einer Serie mit zehn Staffeln hängt dasselbe Plakat an jeder — ohne /// diese Prüfung lüde der Server es zehnmal. /// [Test] public async Task Dieselbe_Adresse_laedt_nur_einmal() { var attrappe = MitBild(); var ablage = Ablage(attrappe); var erster = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")); var zweiter = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")); Assert.That(zweiter, Is.EqualTo(erster)); Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(1)); } [Test] public async Task Verschiedene_Adressen_ergeben_verschiedene_Schluessel() { var ablage = Ablage(MitBild()); var a = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")); var b = await ablage.StoreAsync(new RemoteImage("https://bild.example/b.jpg")); Assert.That(a, Is.Not.EqualTo(b)); } /// Die Prüfung, ohne die die Adresszeile ein Weg in den Container wäre. /// /// /// Die wichtigste Prüfung dieser Datei. Der Schlüssel kommt aus /// einer URL. Liesse er Pfadangaben durch, wäre /// /api/v1/images/../../config/halo.db ein Weg, die Datenbank /// herunterzuladen — samt aller Token-Fingerabdrücke. /// [TestCase("../../config/halo.db")] [TestCase("..%2f..%2fconfig")] [TestCase("/etc/passwd")] [TestCase("abc")] [TestCase("")] [TestCase("GROSSBUCHSTABEN0123456789012345678901234567890123456789012345678")] [TestCase("zzzz567890123456789012345678901234567890123456789012345678901234")] public void Ein_Schluessel_der_keiner_ist_fuehrt_nirgendwohin(string versuch) { Assert.That(Ablage(MitBild()).PfadFür(versuch), Is.Null); } [Test] public async Task Ein_echter_Schluessel_fuehrt_unter_die_Wurzel() { var ablage = Ablage(MitBild()); var schlüssel = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")); var pfad = Path.GetFullPath(ablage.PfadFür(schlüssel!)!); Assert.That(pfad, Does.StartWith(Path.GetFullPath(_wurzel))); } [Test] public async Task Ein_Fehlschlag_legt_nichts_ab() { var ablage = Ablage(Attrappe.MitStatus(HttpStatusCode.NotFound)); Assert.That(await ablage.StoreAsync(new RemoteImage("https://bild.example/weg.jpg")), Is.Null); } [Test] public async Task Kein_Netz_wirft_nicht() { var ablage = Ablage(Attrappe.DieWirft(new HttpRequestException("kein Netz"))); Assert.That(await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")), Is.Null); } /// Nach einem Abbruch darf keine halbe Datei unter dem Schlüssel liegen. /// /// /// Sie würde nie wieder nachgeladen — der Schlüssel ist ja vorhanden — und /// das Plakat bliebe für immer kaputt. /// [Test] public async Task Ein_abgebrochener_Download_hinterlaesst_keine_halbe_Datei() { var ablage = Ablage(new Attrappe(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(new AbbrechenderStrom()), })); var schlüssel = await ablage.StoreAsync(new RemoteImage("https://bild.example/kaputt.jpg")); Assert.That(schlüssel, Is.Null); var übrig = Directory.Exists(Path.Combine(_wurzel, "images")) ? Directory.GetFiles(Path.Combine(_wurzel, "images"), "*", SearchOption.AllDirectories) : []; Assert.That(übrig.Where(f => !f.EndsWith(".teil")), Is.Empty); } private sealed class AbbrechenderStrom : Stream { private int _gelesen; public override int Read(byte[] puffer, int versatz, int anzahl) { if (_gelesen++ > 0) throw new IOException("Verbindung abgebrochen"); puffer[versatz] = 1; return 1; } public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; public override long Length => throw new NotSupportedException(); public override long Position { get => 0; set => throw new NotSupportedException(); } public override void Flush() { } public override long Seek(long v, SeekOrigin o) => throw new NotSupportedException(); public override void SetLength(long v) => throw new NotSupportedException(); public override void Write(byte[] p, int v, int a) => throw new NotSupportedException(); } }