Bisher gab es in der Seitenleiste „Filme" und „Serien" — zwei feste Zeilen, hinter denen alles zusammenfiel, was der Server kannte. Wer „Filme" und „Dokumentationen" getrennt angelegt hatte, sah sie wieder in einem Topf. Das nahm die Entscheidung zurück, sie zu trennen. Jetzt führt die Seitenleiste **jede Mediathek einzeln**, mit Namen und Zahl, wie bei Plex und Jellyfin. Die Startseite bekommt je Mediathek eine Reihe mit dem zuletzt Hinzugefügten; steht mehr drin, als die Reihe zeigt, führt „Alle zeigen" dorthin. Die Mediathekenseite trägt den Namen im Titel, sucht innerhalb der Mediathek und lässt sich sortieren. **Am Server dafür:** `?sort=` auf `/movies` und `/series` — `title`, `added`, `year`. Der Titel immer als letzter Vergleich, sonst hätten zwei Filme aus demselben Jahr keine feste Reihenfolge und Seite 2 zeigte denselben Film noch einmal oder gar nicht. Die Liste der Bibliotheken nennt je Zeile einen Zählwert. „Dokumentationen · 0" sagt mehr als ein Eintrag, hinter dem sich nach dem Klick nichts findet — da ist der Unterschied zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens sichtbar. `null` heisst „nicht gezählt", nicht „leer". Umbenennen und Entfernen, beides nur für Administratoren. **Umbenennen, mehr nicht:** die Art zu ändern hiesse, Filme in Serien zu verwandeln; den Pfad zu ändern hiesse, einen Bestand gegen einen anderen zu tauschen, während die Einträge stehenbleiben. Beides ist kein Bearbeiten, sondern eine neue Bibliothek. **Beim Entfernen bleiben die Mediendateien liegen.** Weg sind der Eintrag, die Zuordnungen und der Sehfortschritt aller Benutzer dazu; die Oberfläche sagt das vorher, und eine Prüfung hält fest, dass die Datei danach noch da ist. Ein Server, der beim Aufräumen in der Oberfläche fremde Filme löscht, ist einer, den man nicht noch einmal benutzt. --- **Der Fehler, den der Browser zeigte, und er betrifft jeden Betrieb:** nach einem Server-Update lief weiter die alte Oberfläche. Die Dateien unter `assets/` tragen eine Prüfsumme im Namen — ändert sich der Inhalt, ändert sich der Name. `index.html` trägt keine; sie nennt nur deren Namen. Lag sie im Zwischenspeicher, bekam der Browser weiter die alte App: nicht kaputt, sondern veraltet, und das fällt niemandem auf. Nachgemessen: geladen war `index-C0K3V8eg.js`, ausgeliefert wurde längst `index-DomHN4_b.js`. Jetzt zwei Lebensdauern — `no-cache` für die Seite, ein Jahr und `immutable` für die Bausteine. Mit Prüfung, und am Container nachgemessen. Der Beispielbestand bekommt zwei weitere Mediatheken, „Dokumentationen" und „Konzerte". Mit nur Filme und Serien liesse sich gar nicht prüfen, ob mehrere wirklich getrennt bleiben — zwei Bestände **derselben Art** sind der Fall, in dem es auffällt. 358 Tests grün. Am Container nachgewiesen: vier Mediatheken in der Seitenleiste mit Zählwerten, je eine Reihe auf der Startseite, und „Dokumentationen" zeigt zwei Titel statt sieben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
9.5 KiB
C#
222 lines
9.5 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using Flaro.Api.Auth;
|
|
using Flaro.Api.Endpoints;
|
|
|
|
namespace Flaro.Api.Tests;
|
|
|
|
/// <summary>Was der Browser braucht und die Apps nicht.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Eine App legt ihr Token in jede Anfrage. Ein Browser kann das nicht:
|
|
/// <c><img></c>, <c><video></c> und ein Download-Link senden keine
|
|
/// eigenen Kopfzeilen. Das Token in die Adresse zu schreiben wäre der
|
|
/// naheliegende Ausweg — und stünde danach in jedem Zugriffsprotokoll, in
|
|
/// jedem Verlauf und in jedem weitergegebenen Link. Also ein Cookie.
|
|
/// </remarks>
|
|
[TestFixture]
|
|
public sealed class WebInterfaceTests
|
|
{
|
|
[Test]
|
|
public async Task Anmeldung_legt_ein_Cookie_ab()
|
|
{
|
|
using var server = new FlaroServer();
|
|
await server.SetUpAndSignIn();
|
|
|
|
var antwort = await server.OhneGedaechtnis().PostAsJsonAsync("/api/v1/auth/login",
|
|
new LoginRequest("guido", "ein-langes-passwort", "Browser", "browser-1"));
|
|
|
|
var gesetzt = antwort.Headers.GetValues("Set-Cookie").Single();
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(gesetzt, Does.StartWith($"{TokenAuthenticationHandler.CookieName}="));
|
|
// Kein Skript kommt heran, auch keines, das sich eingeschlichen hat.
|
|
Assert.That(gesetzt, Does.Contain("httponly").IgnoreCase);
|
|
// Eine fremde Seite kann damit nichts auslösen.
|
|
Assert.That(gesetzt, Does.Contain("samesite=strict").IgnoreCase);
|
|
// Über http wäre „secure" ein Cookie, das der Browser nie schickt —
|
|
// und damit eine Oberfläche, die sich im Heimnetz nicht bedienen lässt.
|
|
Assert.That(gesetzt, Does.Not.Contain("secure").IgnoreCase);
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Cookie_allein_genuegt_fuer_eine_Anfrage()
|
|
{
|
|
using var server = new FlaroServer();
|
|
await server.SetUpAndSignIn();
|
|
|
|
var client = server.OhneGedaechtnis();
|
|
var angemeldet = await client.PostAsJsonAsync("/api/v1/auth/login",
|
|
new LoginRequest("guido", "ein-langes-passwort", "Browser", "browser-1"));
|
|
var keks = Keks(angemeldet);
|
|
|
|
// Ohne Kopfzeile — genau so, wie ein <img> fragt.
|
|
var anfrage = new HttpRequestMessage(HttpMethod.Get, "/api/v1/movies");
|
|
anfrage.Headers.Add("Cookie", keks);
|
|
var antwort = await client.SendAsync(anfrage);
|
|
|
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Ohne_Cookie_und_ohne_Kopfzeile_bleibt_es_bei_401()
|
|
{
|
|
using var server = new FlaroServer();
|
|
await server.SetUpAndSignIn();
|
|
|
|
var antwort = await server.OhneGedaechtnis().GetAsync("/api/v1/movies");
|
|
|
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Abmelden_entwertet_das_Cookie()
|
|
{
|
|
using var server = new FlaroServer();
|
|
await server.SetUpAndSignIn();
|
|
|
|
var client = server.OhneGedaechtnis();
|
|
var angemeldet = await client.PostAsJsonAsync("/api/v1/auth/login",
|
|
new LoginRequest("guido", "ein-langes-passwort", "Browser", "browser-1"));
|
|
var keks = Keks(angemeldet);
|
|
|
|
var abmelden = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/logout");
|
|
abmelden.Headers.Add("Cookie", keks);
|
|
(await client.SendAsync(abmelden)).EnsureSuccessStatusCode();
|
|
|
|
// **Das Token muss serverseitig weg sein, nicht nur das Cookie im
|
|
// Browser.** Ein gelöschtes Cookie schützt niemanden, der den Wert
|
|
// vorher mitgeschrieben hat.
|
|
var danach = new HttpRequestMessage(HttpMethod.Get, "/api/v1/movies");
|
|
danach.Headers.Add("Cookie", keks);
|
|
|
|
Assert.That((await client.SendAsync(danach)).StatusCode,
|
|
Is.EqualTo(HttpStatusCode.Unauthorized));
|
|
}
|
|
|
|
/// <summary>Die Oberfläche kommt heraus, bevor jemand angemeldet ist.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Der Fehler, den dieser Test festhält.</b> Die Rückfallregel verlangt
|
|
/// für jede Anfrage ohne eigenen Endpunkt einen angemeldeten Benutzer, und
|
|
/// <c>/assets/index.js</c> hat keinen. Standen die statischen Dateien unter
|
|
/// <c>UseAuthorization</c>, bekam ein neuer Besucher 401 auf genau das
|
|
/// Skript, das ihm die Anmeldemaske zeichnen sollte: eine weisse Seite,
|
|
/// ohne Hinweis worauf.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Die_Oberflaeche_braucht_keine_Anmeldung()
|
|
{
|
|
var wurzel = Path.Combine(Path.GetTempPath(), "flaro-www-" + Guid.CreateVersion7().ToString("N"));
|
|
Directory.CreateDirectory(Path.Combine(wurzel, "wwwroot", "assets"));
|
|
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "index.html"), "<!doctype html><title>Flaro</title>");
|
|
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "assets", "app.js"), "export const da = true;");
|
|
|
|
try
|
|
{
|
|
using var server = new FlaroServer(inhaltswurzel: wurzel);
|
|
var client = server.OhneGedaechtnis();
|
|
|
|
var seite = await client.GetAsync("/");
|
|
var skript = await client.GetAsync("/assets/app.js");
|
|
// Der Router läuft im Browser: ein Neuladen auf einer Unterseite
|
|
// darf nicht in einem 404 enden.
|
|
var unterseite = await client.GetAsync("/verwaltung");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(seite.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
|
Assert.That(skript.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
|
Assert.That(unterseite.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
|
});
|
|
|
|
Assert.That(await skript.Content.ReadAsStringAsync(), Is.EqualTo("export const da = true;"));
|
|
}
|
|
finally
|
|
{
|
|
try { Directory.Delete(wurzel, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>Die Seite darf nicht im Zwischenspeicher hängenbleiben.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Der Fehler, den der Browser zeigte.</b> Die Dateien unter
|
|
/// <c>assets/</c> tragen eine Prüfsumme im Namen; <c>index.html</c> nicht
|
|
/// — sie nennt nur deren Namen. Lag sie im Zwischenspeicher, bekam der
|
|
/// Browser nach einem Server-Update weiter die alte App: nicht kaputt,
|
|
/// sondern veraltet, und das fällt niemandem auf. Nachgemessen hat es
|
|
/// sich so: geladen war <c>index-C0K3V8eg.js</c>, ausgeliefert wurde
|
|
/// längst <c>index-DomHN4_b.js</c>.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Die_Seite_wird_nicht_zwischengespeichert_die_Bausteine_schon()
|
|
{
|
|
var wurzel = Path.Combine(Path.GetTempPath(), "flaro-www-" + Guid.CreateVersion7().ToString("N"));
|
|
Directory.CreateDirectory(Path.Combine(wurzel, "wwwroot", "assets"));
|
|
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "index.html"), "<!doctype html><title>Flaro</title>");
|
|
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "assets", "index-abc123.js"), "export const da = true;");
|
|
|
|
try
|
|
{
|
|
using var server = new FlaroServer(inhaltswurzel: wurzel);
|
|
var client = server.OhneGedaechtnis();
|
|
|
|
var seite = await client.GetAsync("/");
|
|
var unterseite = await client.GetAsync("/verwaltung");
|
|
var skript = await client.GetAsync("/assets/index-abc123.js");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(seite.Headers.CacheControl?.NoCache, Is.True);
|
|
Assert.That(unterseite.Headers.CacheControl?.NoCache, Is.True,
|
|
"der SPA-Rückfall liefert dieselbe Datei");
|
|
Assert.That(skript.Headers.CacheControl?.MaxAge, Is.EqualTo(TimeSpan.FromDays(365)));
|
|
Assert.That(skript.Headers.CacheControl?.ToString(), Does.Contain("immutable"));
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
try { Directory.Delete(wurzel, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>Ein unbekannter Pfad unter /api ist ein Fehler, keine Seite.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Der Fund, der dahintersteckt.</b> Der Rückfall für den Router im
|
|
/// Browser nimmt jeden Pfad ohne Dateiendung — auch
|
|
/// <c>/api/v1/vertippt</c>. Ein vertippter Aufruf bekam die Webseite mit
|
|
/// Status 200 zurück und sah damit wie ein Erfolg aus.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Ein_unbekannter_Schnittstellenpfad_gibt_404()
|
|
{
|
|
var wurzel = Path.Combine(Path.GetTempPath(), "flaro-www-" + Guid.CreateVersion7().ToString("N"));
|
|
Directory.CreateDirectory(Path.Combine(wurzel, "wwwroot"));
|
|
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "index.html"), "<!doctype html><title>Flaro</title>");
|
|
|
|
try
|
|
{
|
|
using var server = new FlaroServer(inhaltswurzel: wurzel);
|
|
var antwort = await server.OhneGedaechtnis().GetAsync("/api/v1/gibtsnicht");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
|
Assert.That(antwort.Content.Headers.ContentType?.MediaType, Is.Not.EqualTo("text/html"));
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
try { Directory.Delete(wurzel, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>Nimmt den Namen und den Wert aus der Set-Cookie-Kopfzeile.</summary>
|
|
private static string Keks(HttpResponseMessage antwort) =>
|
|
antwort.Headers.GetValues("Set-Cookie").Single().Split(';')[0];
|
|
}
|