using System.Net; using System.Net.Http.Json; using Flaro.Api.Auth; using Flaro.Api.Endpoints; namespace Flaro.Api.Tests; /// Was der Browser braucht und die Apps nicht. /// /// /// Eine App legt ihr Token in jede Anfrage. Ein Browser kann das nicht: /// <img>, <video> 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. /// [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 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)); } /// Die Oberfläche kommt heraus, bevor jemand angemeldet ist. /// /// /// Der Fehler, den dieser Test festhält. Die Rückfallregel verlangt /// für jede Anfrage ohne eigenen Endpunkt einen angemeldeten Benutzer, und /// /assets/index.js hat keinen. Standen die statischen Dateien unter /// UseAuthorization, bekam ein neuer Besucher 401 auf genau das /// Skript, das ihm die Anmeldemaske zeichnen sollte: eine weisse Seite, /// ohne Hinweis worauf. /// [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"), "Flaro"); 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 */ } } } /// Die Seite darf nicht im Zwischenspeicher hängenbleiben. /// /// /// Der Fehler, den der Browser zeigte. Die Dateien unter /// assets/ tragen eine Prüfsumme im Namen; index.html 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 index-C0K3V8eg.js, ausgeliefert wurde /// längst index-DomHN4_b.js. /// [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"), "Flaro"); 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 */ } } } /// Ein unbekannter Pfad unter /api ist ein Fehler, keine Seite. /// /// /// Der Fund, der dahintersteckt. Der Rückfall für den Router im /// Browser nimmt jeden Pfad ohne Dateiendung — auch /// /api/v1/vertippt. Ein vertippter Aufruf bekam die Webseite mit /// Status 200 zurück und sah damit wie ein Erfolg aus. /// [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"), "Flaro"); 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 */ } } } /// „Angemeldet bleiben" entscheidet über die Lebensdauer. /// /// /// Ohne max-age ist es ein Sitzungscookie und verschwindet mit dem /// Browserfenster — richtig an einem fremden Rechner, falsch am eigenen. /// Deshalb wird gefragt statt geraten. /// [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); }); } /// Die Vorgabe ist „bleiben" — ein Client darf es weglassen. [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); } /// Abmelden scheitert nie — auch nicht ohne gültiges Token. /// /// /// Der Fehler dahinter. 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. /// [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)); }); } /// Ein gültiges Token wird dabei trotzdem widerrufen. [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)); } /// Nimmt den Namen und den Wert aus der Set-Cookie-Kopfzeile. private static string Cookie(HttpResponseMessage antwort) => GanzesCookie(antwort).Split(';')[0]; /// Die ganze Kopfzeile — samt Lebensdauer und Schutzangaben. private static string GanzesCookie(HttpResponseMessage antwort) => antwort.Headers.GetValues("Set-Cookie").Single(); }