Files
ScarriffleandClaude Opus 5 693a6cc867 Cookie heisst Cookie
„Keks", „Keksglas", „Kekse" — eingedeutscht, wo es nichts zu
verdeutschen gibt. Ein Cookie ist ein Cookie; wer danach sucht, sucht
nicht nach Keksen, und wer den Quelltext liest, liest daneben die
Schnittstelle, in der es `HTTPCookieStorage` heisst.

Betrifft Kommentare und Prüfungsnamen, kein Verhalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:42:29 +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 cookie = Cookie(angemeldet);
// Ohne Kopfzeile — genau so, wie ein <img> fragt.
var anfrage = new HttpRequestMessage(HttpMethod.Get, "/api/v1/movies");
anfrage.Headers.Add("Cookie", cookie);
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 cookie = Cookie(angemeldet);
var abmelden = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/logout");
abmelden.Headers.Add("Cookie", cookie);
(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", cookie);
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(GanzesCookie(dauerhaft), Does.Contain("max-age=31536000").IgnoreCase);
Assert.That(GanzesCookie(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(GanzesCookie(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 Cookie(HttpResponseMessage antwort) =>
GanzesCookie(antwort).Split(';')[0];
/// <summary>Die ganze Kopfzeile — samt Lebensdauer und Schutzangaben.</summary>
private static string GanzesCookie(HttpResponseMessage antwort) =>
antwort.Headers.GetValues("Set-Cookie").Single();
}