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 */ }
}
}
/// 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 */ }
}
}
/// 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];
}