Ein Klick auf den Buchstabenkreis meldete ab. Keine Rückfrage, kein Menü, kein Weg zurück — hinter einer Geste, die überall sonst „mein Konto" heisst. Jetzt führt er auf `#/profil`; Abmelden steht dort als Knopf, mit Nachfrage. Auf der Seite: Bild wählen, tauschen, entfernen. Dazu „Meine Sprache" — **umgezogen, nicht kopiert.** Sie stand mitten in der Verwaltung, und die zeigt der Client nur Administratoren; der Endpunkt erlaubte sie längst jedem. Wer kein Admin war, konnte seine eigene Sprache nirgends ändern. Der Warnhinweis „weicht ab" bleibt in der Verwaltung, denn dort entsteht die Verwirrung — er verweist jetzt aufs Profil. **Vorher musste ein Fehler weg, der seit Monaten dasteht.** `ImageCache.StoreStreamAsync` baute den Schlüssel aus 32 Hexzeichen plus Dateiendung; `PfadFür` verlangt genau 64 Hexzeichen und nichts sonst. Es gab keinen Wert, den beide gelten liessen: **jeder Bildupload gab `null` zurück und endete in einem 502.** Kein Test fragte je danach — jetzt vier. Damit geht nebenbei auch „eigenes Bild hochladen" für Filme und Serien wieder. Weil der Schlüssel keine Endung mehr trägt, kommt der Medientyp beim Ausliefern aus den ersten Bytes der Datei. Vorher ging alles als `image/jpeg` hinaus — bei TMDB stimmte das, bei einem hochgeladenen PNG nicht. **Und die gemerkte Sitzung war eine Kopie, die nie nachzog.** Sie wurde bei der Anmeldung geschrieben und danach nie wieder. Wer sein Bild am Telefon setzt und den Browser am Schreibtisch öffnet, fand dort den alten Buchstaben — bis zum nächsten Anmelden. Gemessen und behoben: beim Hochfahren wird das Konto abgeglichen, und das Profil meldet Änderungen an die Kopfleiste, damit sie ohne Neuladen nachzieht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
8.8 KiB
C#
224 lines
8.8 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using Flaro.Core.Metadata;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace Flaro.Metadata.Tests;
|
|
|
|
public class ImageCacheTests
|
|
{
|
|
private string _wurzel = "";
|
|
|
|
[SetUp]
|
|
public void Anlegen() => _wurzel = Directory.CreateDirectory(
|
|
Path.Combine(Path.GetTempPath(), "flaro-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<ImageCache>.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);
|
|
}
|
|
|
|
/// <summary>Dasselbe Bild wird nicht zweimal geholt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Bei einer Serie mit zehn Staffeln hängt dasselbe Plakat an jeder — ohne
|
|
/// diese Prüfung lüde der Server es zehnmal.
|
|
/// </remarks>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Die Prüfung, ohne die die Adresszeile ein Weg in den Container wäre.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Die wichtigste Prüfung dieser Datei.</b> Der Schlüssel kommt aus
|
|
/// einer URL. Liesse er Pfadangaben durch, wäre
|
|
/// <c>/api/v1/images/../../config/flaro.db</c> ein Weg, die Datenbank
|
|
/// herunterzuladen — samt aller Token-Fingerabdrücke.
|
|
/// </remarks>
|
|
[TestCase("../../config/flaro.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);
|
|
}
|
|
|
|
/// <summary>Nach einem Abbruch darf keine halbe Datei unter dem Schlüssel liegen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Sie würde nie wieder nachgeladen — der Schlüssel ist ja vorhanden — und
|
|
/// das Plakat bliebe für immer kaputt.
|
|
/// </remarks>
|
|
[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();
|
|
}
|
|
|
|
/* ---------- Hochgeladene Bilder ---------- */
|
|
|
|
/// <summary>Ein hochgeladenes Bild landet und ist danach abrufbar.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Dieser Weg war monatelang kaputt und niemand prüfte ihn.</b>
|
|
/// <c>StoreStreamAsync</c> baute einen Schlüssel aus 32 Hexzeichen plus
|
|
/// Dateiendung, <c>PfadFür</c> verlangt genau 64 Hexzeichen — es gab
|
|
/// keinen Wert, den beide gelten liessen. Jeder Upload gab <c>null</c>
|
|
/// zurück, und der Endpunkt antwortete 502.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Ein_hochgeladenes_Bild_landet_da_wo_man_es_wiederfindet()
|
|
{
|
|
var ablage = Ablage(MitBild());
|
|
using var strom = new MemoryStream(Encoding.UTF8.GetBytes("ein eigenes bild"));
|
|
|
|
var schlüssel = await ablage.StoreStreamAsync(strom, "image/png");
|
|
|
|
Assert.That(schlüssel, Is.Not.Null, "ohne Schlüssel gibt es kein Bild");
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(schlüssel, Has.Length.EqualTo(64));
|
|
Assert.That(ablage.PfadFür(schlüssel!), Is.Not.Null,
|
|
"der Schlüssel muss durch dieselbe Prüfung passen, die ihn ausliefert");
|
|
Assert.That(File.Exists(ablage.PfadFür(schlüssel!)), Is.True);
|
|
});
|
|
}
|
|
|
|
/// <summary>Derselbe Inhalt ergibt dieselbe Datei.</summary>
|
|
[Test]
|
|
public async Task Zweimal_dasselbe_Bild_liegt_einmal_da()
|
|
{
|
|
var ablage = Ablage(MitBild());
|
|
var a = await ablage.StoreStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes("gleich")), "image/jpeg");
|
|
var b = await ablage.StoreStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes("gleich")), "image/png");
|
|
|
|
Assert.That(b, Is.EqualTo(a));
|
|
}
|
|
|
|
/// <summary>Leeres wird abgelehnt, nicht abgelegt.</summary>
|
|
[Test]
|
|
public async Task Eine_leere_Datei_wird_nicht_abgelegt()
|
|
{
|
|
var ablage = Ablage(MitBild());
|
|
Assert.That(await ablage.StoreStreamAsync(new MemoryStream([]), "image/jpeg"), Is.Null);
|
|
}
|
|
|
|
/// <summary>Der Medientyp kommt aus dem Inhalt, nicht aus dem Namen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Der Schlüssel trägt keine Endung — ohne diese Erkennung ginge jedes
|
|
/// Bild als <c>image/jpeg</c> hinaus, auch ein PNG.
|
|
/// </remarks>
|
|
[Test]
|
|
public void Der_Medientyp_steht_in_den_ersten_Bytes()
|
|
{
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(ImageCache.Medientyp([0x89, 0x50, 0x4E, 0x47, 0x0D]), Is.EqualTo("image/png"));
|
|
Assert.That(ImageCache.Medientyp([0x47, 0x49, 0x46, 0x38, 0x39]), Is.EqualTo("image/gif"));
|
|
Assert.That(ImageCache.Medientyp(
|
|
[0x52, 0x49, 0x46, 0x46, 1, 2, 3, 4, 0x57, 0x45, 0x42, 0x50]), Is.EqualTo("image/webp"));
|
|
Assert.That(ImageCache.Medientyp([0xFF, 0xD8, 0xFF, 0xE0]), Is.EqualTo("image/jpeg"));
|
|
Assert.That(ImageCache.Medientyp([]), Is.EqualTo("image/jpeg"), "im Zweifel das Häufigste");
|
|
});
|
|
}
|
|
}
|