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>
179 lines
7.4 KiB
C#
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><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 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];
|
|
}
|