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 keks = Keks(angemeldet); // Ohne Kopfzeile — genau so, wie ein 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)); } /// 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 */ } } } /// Nimmt den Namen und den Wert aus der Set-Cookie-Kopfzeile. private static string Keks(HttpResponseMessage antwort) => antwort.Headers.GetValues("Set-Cookie").Single().Split(';')[0]; }