Files
Flaro/server/tests/Flaro.Api.Tests/WebInterfaceTests.cs
T
ScarriffleandClaude Opus 5 a7ec3a73ba Angemeldet bleiben — und eine 401 reisst nicht mehr die Sitzung ab
Erst die ehrliche Hälfte: dass es heute **andauernd** abgemeldet hat, geht
auf mein Konto. Ich habe die Datenbank dreimal geleert, um mit frischen
Metadaten zu bauen; jedes Mal verfallen alle Token. Kein Schalter der Welt
hätte das verhindert.

Die andere Hälfte ist ein echter Fehler, und er macht aus einem Aussetzer
eine Abmeldung:

**Eine einzelne 401 warf die Anmeldung weg** — und rief dabei
`/auth/logout`, der Server zog das Token also *aktiv zurück*. War die
Ablehnung nur ein Aussetzer, war eine intakte Anmeldung damit absichtlich
zerstört, und zwar unwiderruflich. Jetzt wird einmal nachgefragt: gilt das
Token wirklich nicht mehr, ist die Sitzung vorbei; antwortet der Server, war
es diese eine Anfrage. Mehrere gleichzeitige Anfragen laufen in dieselbe
Prüfung — sonst stellte eine Startseite mit acht Abfragen achtmal dieselbe
Frage. Antwortet der Server **gar nicht**, ist das kein Beleg gegen das
Token: jemanden abzumelden, weil das WLAN hakt, ist der falsche Schluss.

**Abmelden scheitert nie mehr.** Vorher verlangte der Weg eine gültige
Anmeldung — und genau dann, wenn man ihn am dringendsten braucht, gibt es
keine. Nach dem Leeren des Datenbestands zeigte das Cookie im Browser auf
ein Token, das es nicht mehr gab; es ging bei jedem Bild erneut hinaus, und
der Server lehnte es jedes Mal ab. Achtzig Ablehnungen in einer Stunde, und
nichts davon liess sich von aussen beenden. Jetzt wird das Cookie immer
gelöscht; widerrufen wird, was sich widerrufen lässt.

---

**Und der Schalter, um den es ging.** „Angemeldet bleiben" steht jetzt im
Anmeldefeld, mit Haken als Vorgabe — am eigenen Rechner will sich niemand
täglich neu anmelden.

Umgesetzt über die beiden Speicher, die der Browser ohnehin richtig
aufräumt: mit Haken `localStorage` und ein Cookie für ein Jahr, ohne Haken
`sessionStorage` und ein Sitzungscookie, das mit dem Fenster verschwindet.
Ein eigenes Verfallsdatum zu verwalten wäre die schlechtere Variante — eine
Uhr, die man stellen kann, gegen zwei Mechanismen, die es schon gibt.

Das Token selbst läuft weiterhin **nie** ab; es wird widerrufen, nicht
vergessen. Was hier entschieden wird, ist allein, wie lange der Browser sich
erinnert.

372 Tests grün, vier davon neu. Am Container nachgemessen: mit Haken
`max-age=31536000`, ohne Haken kein `max-age`, und ein Abmelden mit
ungültigem Cookie antwortet 204 statt 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 07:40:19 +02:00

310 lines
13 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>&lt;img&gt;</c>, <c>&lt;video&gt;</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>„Angemeldet bleiben" entscheidet über die Lebensdauer.</summary>
///
/// <remarks>
/// Ohne <c>max-age</c> ist es ein Sitzungscookie und verschwindet mit dem
/// Browserfenster — richtig an einem fremden Rechner, falsch am eigenen.
/// Deshalb wird gefragt statt geraten.
/// </remarks>
[Test]
public async Task Angemeldet_bleiben_setzt_ein_dauerhaftes_Cookie()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var client = server.OhneGedaechtnis();
var dauerhaft = await client.PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "ein-langes-passwort", "Browser", "b1", StaySignedIn: true));
var nurJetzt = await client.PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "ein-langes-passwort", "Browser", "b1", StaySignedIn: false));
Assert.Multiple(() =>
{
Assert.That(GanzerKeks(dauerhaft), Does.Contain("max-age=31536000").IgnoreCase);
Assert.That(GanzerKeks(nurJetzt), Does.Not.Contain("max-age").IgnoreCase);
});
}
/// <summary>Die Vorgabe ist „bleiben" — ein Client darf es weglassen.</summary>
[Test]
public async Task Ohne_Angabe_bleibt_man_angemeldet()
{
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", "b1"));
Assert.That(GanzerKeks(antwort), Does.Contain("max-age=31536000").IgnoreCase);
}
/// <summary>Abmelden scheitert nie — auch nicht ohne gültiges Token.</summary>
///
/// <remarks>
/// <b>Der Fehler dahinter.</b> Vorher verlangte dieser Weg eine gültige
/// Anmeldung, und genau dann, wenn man ihn am dringendsten braucht, gibt
/// es keine: nach einem geleerten Datenbestand zeigt das Cookie im
/// Browser auf ein Token, das es nicht mehr gibt. Es ging bei jedem Bild
/// erneut hinaus, der Server lehnte es jedes Mal ab — achtzig
/// Ablehnungen in einer Stunde —, und nichts davon liess sich von aussen
/// beenden.
/// </remarks>
[Test]
public async Task Abmelden_loescht_das_Cookie_auch_ohne_gueltiges_Token()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var client = server.OhneGedaechtnis();
var anfrage = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/logout");
anfrage.Headers.Add("Cookie", $"{TokenAuthenticationHandler.CookieName}=laengst-ungueltig");
var antwort = await client.SendAsync(anfrage);
Assert.Multiple(() =>
{
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
// Ein gelöschtes Cookie ist eines mit abgelaufenem Datum.
Assert.That(antwort.Headers.GetValues("Set-Cookie").Single(),
Does.Contain(TokenAuthenticationHandler.CookieName));
});
}
/// <summary>Ein gültiges Token wird dabei trotzdem widerrufen.</summary>
[Test]
public async Task Abmelden_zieht_ein_gueltiges_Token_weiterhin_zurueck()
{
using var server = new FlaroServer();
var anmeldung = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(anmeldung.Token);
(await client.PostAsync("/api/v1/auth/logout", null)).EnsureSuccessStatusCode();
Assert.That((await client.GetAsync("/api/v1/auth/me")).StatusCode,
Is.EqualTo(HttpStatusCode.Unauthorized));
}
/// <summary>Nimmt den Namen und den Wert aus der Set-Cookie-Kopfzeile.</summary>
private static string Keks(HttpResponseMessage antwort) =>
GanzerKeks(antwort).Split(';')[0];
/// <summary>Die ganze Kopfzeile — samt Lebensdauer und Schutzangaben.</summary>
private static string GanzerKeks(HttpResponseMessage antwort) =>
antwort.Headers.GetValues("Set-Cookie").Single();
}