„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>
310 lines
13 KiB
C#
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><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 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();
|
|
}
|