Files
Flaro/server/tests/Flaro.Api.Tests/SignInTests.cs
T
ScarriffleandClaude Opus 5 91eb934048 Die Weboberfläche — im Zuschnitt von Plex, vom Server selbst ausgeliefert
Einrichtung, Anmeldung, Mediathek, Detailseite mit Abspielen und
Herunterladen, Zuordnung korrigieren, Verwaltung. Die Oberfläche aus der
Jellyfin-Zeit ist dabei vollständig ersetzt: `types.ts`, `prefs.ts`,
`contrast.ts`, `views/cards.ts`, `views/player.ts` und `views/settings.ts`
sprachen Jellyfins Schnittstelle und hatten hier nichts mehr zu suchen.

Seitenleiste links, waagerechte Reihen auf der Startseite, Plakate im
Verhältnis 2:3, unscharfer Hintergrund hinter dem Kopf der Detailseite —
das ist der Zuschnitt von Plex, weil er gefällt. **Die Farbe bleibt Flaros
Orchidee**, nicht Plex' Orange; der Aufbau ist das Vorbild, nicht das
Aussehen.

Welche Bibliothek gezeigt wird, entscheidet die Seitenleiste. Eine zweite
Umschaltung innerhalb der Seite wäre dieselbe Entscheidung an zwei Orten.

---

**Der Zugang liegt jetzt auch in einem Cookie.** Ein `<img>`, ein `<video>`
und ein Download-Link senden keine eigenen Kopfzeilen — eine App kann ihr
Token in jede Anfrage legen, ein Browser nicht. Das Token stattdessen 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.

`HttpOnly`, damit kein eingeschlichenes Skript herankommt. `SameSite=Strict`
gegen Anfragen von fremden Seiten. `Secure` nur über https — sonst liesse
sich die Oberfläche im Heimnetz über http gar nicht benutzen. Die Apps
benutzen weiterhin die Kopfzeile: ein zweiter Weg zum selben Token, kein
zweites Verfahren.

---

**Der Fehler, den erst der echte Container zeigte:** eine weisse Seite,
ohne Hinweis worauf.

Die Rückfallregel verlangt für jede Anfrage ohne eigenen Endpunkt einen
angemeldeten Benutzer, und `/assets/index.js` hat keinen. Die statischen
Dateien standen unter `UseAuthorization` — ein neuer Besucher bekam 401 auf
genau das Skript, das ihm die Anmeldemaske zeichnen sollte. `/` kam durch,
weil der SPA-Rückfall `AllowAnonymous` trägt; alles mit Dateiendung nicht.
Jetzt liegen sie davor. Mit Prüfung.

Beim Bau desselben Images der zweite: seit der Kontext die Projektwurzel
ist, überschrieb `COPY server/` die `obj/`-Verzeichnisse aus dem Container
mit denen vom Arbeitsrechner — die zeigen auf osx-arm64, gebaut wird
linux-arm64, `dotnet publish` brach mit NETSDK1047 ab. Dafür gibt es jetzt
eine `.dockerignore` in der Wurzel; die unter `server/` sucht Docker seit
dem Umzug nicht mehr.

---

Vier Prüfungen schlugen danach fehl, und zwar zu Recht: sie hiessen „ohne
Anmeldung gibt es nichts", benutzten aber den Client, der nach dem Anmelden
ein Cookie hält. Sie prüften also den Angemeldeten. Jetzt sagen sie
ausdrücklich, dass sie ohne Zugang fragen.

313 Tests grün. Im Container nachgewiesen: Anmelden, Plakate, Abspielen,
Staffeln und Folgen, Verwaltung — und ein Neuladen auf `/verwaltung`, das
nicht in einem 404 endet.

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

141 lines
5.5 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
namespace Flaro.Api.Tests;
public class SignInTests
{
[Test]
public async Task Mit_richtigem_Passwort_gibt_es_ein_Token()
{
using var server = new FlaroServer();
var anmeldung = await server.SetUpAndSignIn();
Assert.That(anmeldung.Token, Is.Not.Empty);
Assert.That(anmeldung.User.UserName, Is.EqualTo("guido"));
}
[Test]
public async Task Mit_falschem_Passwort_gibt_es_keines()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var antwort = await server.Client.PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "falsch-aber-lang", "iPhone", "geraet-1"));
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
/// <summary>Unbekannter Name und falsches Passwort sind nicht zu unterscheiden.</summary>
[Test]
public async Task Ein_unbekannter_Name_antwortet_wie_ein_falsches_Passwort()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var unbekannt = await server.Client.PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("gibtesnicht", "irgendein-passwort", "iPhone", "geraet-1"));
var falsch = await server.Client.PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "irgendein-passwort", "iPhone", "geraet-1"));
Assert.That(unbekannt.StatusCode, Is.EqualTo(falsch.StatusCode));
// Nur die Begründung vergleichen, nicht den ganzen Körper: darin steht
// je Anfrage eine neue Ablaufkennung, die nichts über das Konto sagt.
static async Task<string?> Begründung(HttpResponseMessage antwort) =>
(await antwort.Content.ReadFromJsonAsync<System.Text.Json.JsonDocument>())!
.RootElement.GetProperty("detail").GetString();
Assert.That(await Begründung(unbekannt), Is.EqualTo(await Begründung(falsch)));
}
[Test]
public async Task Mit_Token_beantwortet_der_Server_wer_man_ist()
{
using var server = new FlaroServer();
var anmeldung = await server.SetUpAndSignIn();
var ich = await server.AlsAngemeldeter(anmeldung.Token)
.GetFromJsonAsync<AuthenticatedUser>("/api/v1/auth/me");
Assert.That(ich!.Id, Is.EqualTo(anmeldung.User.Id));
Assert.That(ich.IsAdministrator, Is.True);
}
[Test]
public async Task Ohne_Token_antwortet_er_nicht()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
// den Angemeldeten — und bestünde nie wieder.
var antwort = await server.OhneGedaechtnis().GetAsync("/api/v1/auth/me");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
[Test]
public async Task Ein_erfundenes_Token_gilt_nicht()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var antwort = await server.AlsAngemeldeter("ausgedacht-aber-lang-genug").GetAsync("/api/v1/auth/me");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
/// <summary>Nach dem Abmelden gilt das Token sofort nicht mehr.</summary>
///
/// <remarks>
/// Der Grund, warum hier keine JWT stehen: ein signiertes Token gälte bis
/// zum Ablauf weiter, und für ein verlorenes Telefon ist das der falsche
/// Satz.
/// </remarks>
[Test]
public async Task Abmelden_entwertet_das_Token_augenblicklich()
{
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();
var danach = await client.GetAsync("/api/v1/auth/me");
Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
/// <summary>Dasselbe Gerät ersetzt seinen Eintrag, statt einen zweiten anzulegen.</summary>
[Test]
public async Task Eine_erneute_Anmeldung_desselben_Geraets_entwertet_das_alte_Token()
{
using var server = new FlaroServer();
var erste = await server.SetUpAndSignIn();
var zweite = await server.SignIn("guido", "ein-langes-passwort", "iPhone", "geraet-1");
Assert.That(zweite.Token, Is.Not.EqualTo(erste.Token));
var mitAltem = await server.AlsAngemeldeter(erste.Token).GetAsync("/api/v1/auth/me");
Assert.That(mitAltem.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
var mitNeuem = await server.AlsAngemeldeter(zweite.Token).GetAsync("/api/v1/auth/me");
Assert.That(mitNeuem.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
/// <summary>Ein zweites Gerät meldet das erste nicht ab.</summary>
[Test]
public async Task Ein_anderes_Geraet_laesst_das_erste_in_Ruhe()
{
using var server = new FlaroServer();
var telefon = await server.SetUpAndSignIn();
var fernseher = await server.SignIn("guido", "ein-langes-passwort", "Apple TV", "geraet-2");
foreach (var token in new[] { telefon.Token, fernseher.Token })
{
var antwort = await server.AlsAngemeldeter(token).GetAsync("/api/v1/auth/me");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
}
}