Files
Flaro/server/tests/Flaro.Api.Tests/WebInterfaceTests.cs
T
ScarriffleandClaude Opus 5 67cf0fd1d9 HEAD auf den Download, und /api verschluckt nichts mehr
Zwei Fehler, derselbe Grund — und beide zeigte erst der Browser.

**Der Spieler bekam die Webseite statt des Films.** „MEDIA_ELEMENT_ERROR:
Format error", 531 Byte, `Content-Type: text/html`. Das ist genau die Grösse
von `index.html`.

`MapGet` nimmt HEAD nicht an. Die Oberfläche fragt aber mit HEAD nach, ob das
Umpacken fertig ist — einen halbfertigen Film herunterzuladen, nur um das zu
erfahren, wäre absurd. Die Anfrage fiel durch den Methodenabgleich hindurch
bis zum SPA-Rückfall, und der nimmt jeden Pfad ohne Dateiendung. Antwort:
200, mit der Startseite im Rumpf. Der Client hielt das Umpacken für fertig
und reichte HTML an `<video>` weiter.

Jetzt `MapMethods` mit GET und HEAD. Kestrel lässt den Rumpf bei HEAD selbst
weg.

**Und ein vertippter Aufruf sah aus wie ein Erfolg.** `GET /api/v1/gibtsnicht`
gab 200 und die Webseite zurück — derselbe Rückfall. Ein Fehler, der erst
dort auffällt, wo jemand HTML durch einen JSON-Parser schickt.

`app.Map("/api/{*rest}", 404)` davor. Buchstaben schlagen Platzhalter, also
gewinnt jeder echte Endpunkt; was übrig bleibt, ist nicht da und sagt das
auch.

350 Tests grün, die zwei neuen eingerechnet. Am Container nachgemessen: HEAD
auf den Download gibt 200 `video/mp4` mit 71 920 Byte, `/api/v1/gibtsnicht`
gibt 404, und ein Neuladen auf `/verwaltung` bekommt weiterhin die Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:33:56 +02:00

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