diff --git a/server/src/Flaro.Api/Einwand.cs b/server/src/Flaro.Api/Einwand.cs new file mode 100644 index 0000000..11fad3d --- /dev/null +++ b/server/src/Flaro.Api/Einwand.cs @@ -0,0 +1,48 @@ +namespace Flaro.Api; + +/// Ein abgelehnter Versuch — mit einem Satz, den die Clients auch lesen. +/// +/// +/// +/// Es gab drei Formen, und zwei davon kamen nie an. Gemessen: die +/// Oberfläche las bei jedem abgelehnten Versuch „Die Anfrage war so nicht zu +/// gebrauchen" — ihren eigenen Ersatzsatz für eine 400 ohne Begründung. Dabei +/// hatte der Server sehr wohl einen deutschen Satz geschrieben, nur an eine +/// Stelle, an der niemand nachsieht. +/// +/// +/// Results.ValidationProblem legt ihn unter errors ab, +/// Results.BadRequest(new { fehler = … }) unter fehler. Beide +/// Clients lesen detail — der Browser in api.ts, die Apps in +/// FlaroClient.swift —, und zwar aus gutem Grund: in title und in +/// errors stehen sonst die englischen Prüftexte des Rahmenwerks, und +/// „Not Found" ist keine Auskunft. Nur stand in detail an 28 Stellen +/// eben gar nichts. +/// +/// +/// Also eine Form. Results.Problem(detail, …) füllt genau das +/// Feld, das gelesen wird. Dass es hier durch drei benannte Türen geht statt +/// durch einen Aufruf mit Zahl, hat einen Grund: so lässt sich nachsehen, wo +/// ein Server etwas ablehnt, und man sieht am Wort, ob es eine Bitte um eine +/// bessere Eingabe war, etwas Fehlendes oder ein Zusammenstoss. +/// +/// +/// Nicht für Ausnahmen. Was unerwartet schiefgeht, übersetzt +/// . Hier geht es um Absichten, die der Server +/// zurückweist — nicht um Unfälle. +/// +/// +public static class Einwand +{ + /// 400 — so war die Eingabe nicht zu gebrauchen. + public static IResult Bitte(string satz) => + Results.Problem(satz, statusCode: StatusCodes.Status400BadRequest); + + /// 404 — das, worauf es sich bezieht, gibt es nicht. + public static IResult Fehlt(string satz) => + Results.Problem(satz, statusCode: StatusCodes.Status404NotFound); + + /// 409 — es gibt schon etwas, das im Weg steht. + public static IResult Belegt(string satz) => + Results.Problem(satz, statusCode: StatusCodes.Status409Conflict); +} diff --git a/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs b/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs index c0b212b..cbe1be5 100644 --- a/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs @@ -65,7 +65,7 @@ public static class ArtworkEndpoints FlaroContext db, IImageStore ablage, CancellationToken ct) => { if (anfrage.Kind is not ("poster" or "backdrop" or "logo")) - return Results.BadRequest(new { fehler = "Erwartet: poster, backdrop oder logo." }); + return Einwand.Bitte("Erwartet: poster, backdrop oder logo."); // Das Bild wandert in den Cache, bevor irgendetwas gesetzt wird — // sonst zeigt der Eintrag auf etwas, das es hier nicht gibt. @@ -88,15 +88,15 @@ public static class ArtworkEndpoints FlaroContext db, IImageStore ablage, CancellationToken ct) => { if (kind is not ("poster" or "backdrop" or "logo")) - return Results.BadRequest(new { fehler = "Erwartet: poster, backdrop oder logo." }); - if (!anfrage.HasFormContentType) return Results.BadRequest(new { fehler = "Kein Formular." }); + return Einwand.Bitte("Erwartet: poster, backdrop oder logo."); + if (!anfrage.HasFormContentType) return Einwand.Bitte("Kein Formular."); var form = await anfrage.ReadFormAsync(ct); var datei = form.Files.FirstOrDefault(); if (datei is null || datei.Length == 0) - return Results.BadRequest(new { fehler = "Keine Datei dabei." }); + return Einwand.Bitte("Keine Datei dabei."); if (datei.Length > HoechstensBytes) - return Results.BadRequest(new { fehler = "Höchstens 10 MB." }); + return Einwand.Bitte("Höchstens 10 MB."); await using var strom = datei.OpenReadStream(); var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct); diff --git a/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs b/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs index 640ba12..3f2ee69 100644 --- a/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs @@ -160,7 +160,7 @@ public static class AuthEndpoints c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt); if (offen is null) - return Results.Problem("Dieser Code gilt nicht mehr.", statusCode: StatusCodes.Status404NotFound); + return Einwand.Fehlt("Dieser Code gilt nicht mehr."); offen.ApprovedByUserId = benutzerId; await db.SaveChangesAsync(); diff --git a/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs b/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs index e326795..ecb98ac 100644 --- a/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs @@ -183,7 +183,7 @@ public static class FreigabeEndpoints FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) => { if (anfrage.Ids is null or { Count: 0 }) - return Results.BadRequest(new { fehler = "Ohne Kennungen gibt es nichts zurückzuziehen." }); + return Einwand.Bitte("Ohne Kennungen gibt es nichts zurückzuziehen."); var ids = anfrage.Ids; var filme = await db.Movies.Where(m => ids.Contains(m.Id)) diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index 56ec182..a7155cb 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -23,13 +23,10 @@ public static class ManagementEndpoints { var name = anfrage.UserName.Trim(); if (name.Length == 0 || anfrage.Password.Length < 8) - return Results.ValidationProblem(new Dictionary - { - ["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."], - }); + return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."); if (await db.Users.AnyAsync(u => u.UserName == name)) - return Results.Problem($"„{name}“ gibt es schon.", statusCode: StatusCodes.Status409Conflict); + return Einwand.Belegt($"„{name}“ gibt es schon."); var neu = new User { @@ -175,7 +172,7 @@ public static class ManagementEndpoints ReihenfolgeRequest anfrage, FlaroContext db, CancellationToken ct) => { if (anfrage.Ids is null or { Count: 0 }) - return Results.BadRequest(new { fehler = "Ohne Reihenfolge gibt es nichts zu ordnen." }); + return Einwand.Bitte("Ohne Reihenfolge gibt es nichts zu ordnen."); // Ab 1 zählen: die 0 bleibt denen, die nie umsortiert wurden. for (var i = 0; i < anfrage.Ids.Count; i++) @@ -362,20 +359,14 @@ public static class ManagementEndpoints bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) => { if (!Enum.TryParse(anfrage.Kind, ignoreCase: true, out var art)) - return Results.ValidationProblem(new Dictionary - { - ["kind"] = [$"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows."], - }); + return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows."); // **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad // führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und // die Ursache stünde allenfalls im Protokoll. if (!Directory.Exists(anfrage.Path)) - return Results.ValidationProblem(new Dictionary - { - ["path"] = [$"„{anfrage.Path}“ gibt es im Container nicht. " + - "Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt."], - }); + return Einwand.Bitte($"„{anfrage.Path}“ gibt es im Container nicht. " + + "Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt."); var neu = new Library { @@ -399,10 +390,7 @@ public static class ManagementEndpoints { var name = anfrage.Name.Trim(); if (name.Length == 0) - return Results.ValidationProblem(new Dictionary - { - ["name"] = ["Ein Name darf nicht leer sein."], - }); + return Einwand.Bitte("Ein Name darf nicht leer sein."); var vorhanden = await db.Libraries.FindAsync(id); if (vorhanden is null) return Results.NotFound(); diff --git a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs index 324becf..0c008e9 100644 --- a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs @@ -40,16 +40,10 @@ public static class MatchEndpoints var titel = anfrage.Title?.Trim(); if (string.IsNullOrEmpty(titel)) { - return Results.ValidationProblem(new Dictionary - { - ["title"] = ["Ein Titel darf nicht leer sein."], - }); + return Einwand.Bitte("Ein Titel darf nicht leer sein."); } if (titel.Length > 512) - return Results.ValidationProblem(new Dictionary - { - ["title"] = ["Höchstens 512 Zeichen."], - }); + return Einwand.Bitte("Höchstens 512 Zeichen."); // **Der Sortiertitel bleibt, wenn er von Hand gesetzt wurde.** // Sonst wäre „Harry Potter 3" nach jeder Titeländerung wieder weg. @@ -84,8 +78,7 @@ public static class MatchEndpoints statusCode: StatusCodes.Status400BadRequest); } if (sortiert.Length > 512) - return Results.Problem("Höchstens 512 Zeichen.", - statusCode: StatusCodes.Status400BadRequest); + return Einwand.Bitte("Höchstens 512 Zeichen."); var betroffen = art == "movies" ? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s @@ -283,8 +276,7 @@ public static class MatchEndpoints var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId); if (gefunden is null) - return Results.Problem($"TMDB kennt den Film {anfrage.TmdbId} nicht.", - statusCode: StatusCodes.Status404NotFound); + return Einwand.Fehlt($"TMDB kennt den Film {anfrage.TmdbId} nicht."); // **Hat die Kennung schon jemand, ist es derselbe Film.** // @@ -343,8 +335,7 @@ public static class MatchEndpoints var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId); if (gefunden is null) - return Results.Problem($"TMDB kennt die Serie {anfrage.TmdbId} nicht.", - statusCode: StatusCodes.Status404NotFound); + return Einwand.Fehlt($"TMDB kennt die Serie {anfrage.TmdbId} nicht."); // Siehe bei den Filmen: dieselbe Kennung heisst dieselbe Serie, // und zwei Ordner derselben Serie gehören zusammengelegt. diff --git a/server/src/Flaro.Api/Endpoints/MeldungEndpoints.cs b/server/src/Flaro.Api/Endpoints/MeldungEndpoints.cs index aced4a0..8b5c7dc 100644 --- a/server/src/Flaro.Api/Endpoints/MeldungEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/MeldungEndpoints.cs @@ -51,20 +51,17 @@ public static class MeldungEndpoints var kommentar = anfrage.Comment?.Trim() ?? ""; if (kommentar.Length == 0) - return Results.BadRequest(new { fehler = "Ohne Beschreibung lässt sich nichts nachsehen." }); + return Einwand.Bitte("Ohne Beschreibung lässt sich nichts nachsehen."); if (kommentar.Length > HoechstensZeichen) - return Results.BadRequest(new { fehler = $"Höchstens {HoechstensZeichen} Zeichen." }); + return Einwand.Bitte($"Höchstens {HoechstensZeichen} Zeichen."); if (AlsArt(anfrage.Kind) is not { } art) { - return Results.BadRequest(new - { - fehler = "Unbekannte Art. Möglich sind: bild, ton, untertitel, daten, sonstiges.", - }); + return Einwand.Bitte("Unbekannte Art. Möglich sind: bild, ton, untertitel, daten, sonstiges."); } if (!AlsStelle(anfrage.Position, out var stelle)) - return Results.BadRequest(new { fehler = "Die Stelle muss wie 0:34:12 aussehen." }); + return Einwand.Bitte("Die Stelle muss wie 0:34:12 aussehen."); // **Der Freigabefilter erledigt die Rechteprüfung.** Wer einen // Titel nicht sehen darf, findet ihn hier nicht — ohne ein @@ -74,15 +71,15 @@ public static class MeldungEndpoints if (await db.Movies.AnyAsync(m => m.Id == id, ct)) film = id; else if (await db.Episodes.AnyAsync(f => f.Id == id, ct)) folge = id; else if (await db.Series.AnyAsync(s => s.Id == id, ct)) serie = id; - else return Results.BadRequest(new { fehler = "Diesen Titel gibt es nicht." }); + else return Einwand.Bitte("Diesen Titel gibt es nicht."); int? staffel = null; if (anfrage.SeasonNumber is { } nummer) { if (serie is null) - return Results.BadRequest(new { fehler = "Eine Staffel gibt es nur zu einer Serie." }); + return Einwand.Bitte("Eine Staffel gibt es nur zu einer Serie."); if (!await db.Episodes.AnyAsync(f => f.SeriesId == serie && f.SeasonNumber == nummer, ct)) - return Results.BadRequest(new { fehler = "Diese Staffel gibt es in der Serie nicht." }); + return Einwand.Bitte("Diese Staffel gibt es in der Serie nicht."); staffel = nummer; } @@ -157,7 +154,7 @@ public static class MeldungEndpoints { var meldung = await db.Meldungen.FirstOrDefaultAsync(m => m.Id == id, ct); if (meldung is null) - return Results.NotFound(new { fehler = "Diese Meldung gibt es nicht mehr." }); + return Einwand.Fehlt("Diese Meldung gibt es nicht mehr."); meldung.ErledigtAm = wann; meldung.ErledigtVon = wann is null ? null : Kennung(wer); diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index a5b1536..8e1f02b 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -235,8 +235,7 @@ public static class PlaybackEndpoints if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); var regeln = await einstellungen.RegelnAsync(); if (anfrage.Rating is { } wert && wert is < 1 or > 10) - return Results.Problem("Eine Bewertung liegt zwischen 1 und 10.", - statusCode: StatusCodes.Status400BadRequest); + return Einwand.Bitte("Eine Bewertung liegt zwischen 1 und 10."); var zustand = await Zustand(db, benutzer, id); if (zustand is null) return Results.NotFound(); diff --git a/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs b/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs index f526b41..6a5336d 100644 --- a/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs @@ -55,14 +55,14 @@ public static class ProfilEndpoints { if (Kennung(wer) is not { } id) return Results.Unauthorized(); if (!anfrage.HasFormContentType) - return Results.BadRequest(new { fehler = "Kein Formular." }); + return Einwand.Bitte("Kein Formular."); var form = await anfrage.ReadFormAsync(ct); var datei = form.Files.FirstOrDefault(); if (datei is null || datei.Length == 0) - return Results.BadRequest(new { fehler = "Keine Datei dabei." }); + return Einwand.Bitte("Keine Datei dabei."); if (datei.Length > HoechstensBytes) - return Results.BadRequest(new { fehler = "Höchstens 5 MB." }); + return Einwand.Bitte("Höchstens 5 MB."); await using var strom = datei.OpenReadStream(); var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct); diff --git a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs index 8885fbc..2951ad4 100644 --- a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs @@ -81,9 +81,7 @@ public static class SettingsEndpoints return gestartet > 0 ? Results.Accepted(value: new { started = gestartet, language = serversprache }) - : Results.Problem( - "In jeder Mediathek läuft bereits etwas. Warte, bis es durch ist.", - statusCode: StatusCodes.Status409Conflict); + : Einwand.Belegt("In jeder Mediathek läuft bereits etwas. Warte, bis es durch ist."); }).RequireAuthorization(FlaroPolicies.Administrator); app.MapPut("/api/v1/settings/metadata", async ( @@ -96,10 +94,7 @@ public static class SettingsEndpoints // Form wie „de-DE". Alles andere nähme TMDB stillschweigend als // Englisch an, und niemand wüsste, warum. if (!System.Text.RegularExpressions.Regex.IsMatch(sprache, "^[a-z]{2}(-[A-Z]{2})?$")) - return Results.ValidationProblem(new Dictionary - { - ["language"] = [$"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“."], - }); + return Einwand.Bitte($"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“."); await Setzen(db, DatabaseTmdbSettings.LanguageName, sprache); } diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs index a81f80f..1848fdf 100644 --- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs @@ -88,10 +88,7 @@ public static class SystemEndpoints app.MapGet("/api/v1/system/folders", (string? path) => Ordnerbaum.Lesen(path) is { } baum ? Results.Ok(baum) - : Results.Problem( - title: "Ordner nicht gefunden", - detail: $"Den Ordner {path} gibt es nicht, oder es ist keiner.", - statusCode: StatusCodes.Status404NotFound)) + : Einwand.Fehlt($"Den Ordner {path} gibt es nicht, oder es ist keiner.")) .RequireAuthorization(FlaroPolicies.Administrator); // **Dubletten von Hand zusammenlegen.** Nach einem Scan passiert das @@ -122,7 +119,7 @@ public static class SystemEndpoints if (!DateOnly.TryParseExact(tag, "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var tagesdatum)) { - return Results.BadRequest(new { fehler = "Erwartet wird ein Datum als JJJJ-MM-TT." }); + return Einwand.Bitte("Erwartet wird ein Datum als JJJJ-MM-TT."); } var datei = tagebuch.DateiFuer(tagesdatum); @@ -170,7 +167,7 @@ public static class SystemEndpoints { var sprache = anfrage.Language?.Trim(); if (string.IsNullOrEmpty(sprache)) - return Results.BadRequest(new { fehler = "Ohne Sprache gibt es nichts zu holen." }); + return Einwand.Bitte("Ohne Sprache gibt es nichts zu holen."); tagebuch.Schreiben($"Übersetzungen für {sprache} werden geholt."); var bericht = await uebersetzer.HolenAsync(sprache, null, ct); @@ -183,10 +180,7 @@ public static class SystemEndpoints { var name = anfrage.Name.Trim(); if (name.Length == 0) - return Results.ValidationProblem(new Dictionary - { - ["name"] = ["Ein Name darf nicht leer sein."], - }); + return Einwand.Bitte("Ein Name darf nicht leer sein."); var vorhanden = await db.Settings.FirstOrDefaultAsync(e => e.Key == NameSchlüssel); if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = NameSchlüssel, Value = name }); @@ -199,10 +193,7 @@ public static class SystemEndpoints app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) => { if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8) - return Results.ValidationProblem(new Dictionary - { - ["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."], - }); + return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."); await EinrichtungsSchloss.WaitAsync(); try @@ -210,8 +201,7 @@ public static class SystemEndpoints // Noch einmal prüfen, innerhalb des Schlosses. Draussen wäre die // Antwort schon veraltet, wenn sie ankommt. if (await db.Users.AnyAsync()) - return Results.Problem( - "Dieser Server ist bereits eingerichtet.", statusCode: StatusCodes.Status409Conflict); + return Einwand.Belegt("Dieser Server ist bereits eingerichtet."); var administrator = new User { diff --git a/server/tests/Flaro.Api.Tests/FehlersatzTests.cs b/server/tests/Flaro.Api.Tests/FehlersatzTests.cs new file mode 100644 index 0000000..3801976 --- /dev/null +++ b/server/tests/Flaro.Api.Tests/FehlersatzTests.cs @@ -0,0 +1,144 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Flaro.Api.Endpoints; + +namespace Flaro.Api.Tests; + +/// Ein abgelehnter Versuch sagt, warum — und zwar dort, wo gelesen wird. +/// +/// +/// +/// Was hier schiefging, war unsichtbar. Der Server schrieb seine +/// deutschen Sätze in errors (bei ValidationProblem) oder in ein +/// selbstgebautes Feld fehler. Beide Clients lesen detail — und +/// nur das —, weil in title und errors sonst die englischen +/// Prüftexte des Rahmenwerks stehen. Also las man im Browser bei jedem +/// abgelehnten Versuch denselben Ersatzsatz: „Die Anfrage war so nicht zu +/// gebrauchen." +/// +/// +/// Auffallen konnte das nicht: die vorhandenen Tests prüften den Rumpf als +/// Zeichenkette (Does.Contain("eingeh")), und darin stand der Satz ja. +/// Nur eben an der falschen Stelle. Deshalb prüft dieser Test nicht, dass +/// ein Satz im Rumpf steht, sondern in welchem Feld. +/// +/// +public class FehlersatzTests +{ + private static async Task<(HttpStatusCode Code, string? Detail)> Lesen(HttpResponseMessage antwort) + { + var roh = await antwort.Content.ReadAsStringAsync(); + using var papier = JsonDocument.Parse(roh); + return (antwort.StatusCode, + papier.RootElement.TryGetProperty("detail", out var d) ? d.GetString() : null); + } + + [Test] + public async Task Ein_zu_kurzes_Passwort_sagt_warum() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + + var versuch = await alsAdmin.PostAsJsonAsync("/api/v1/users", + new CreateUserRequest("kurz", "1234", false)); + var (code, detail) = await Lesen(versuch); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(detail, Does.Contain("8 Zeichen"), + "Der Satz muss in `detail` stehen — nur das lesen die Clients."); + }); + } + + [Test] + public async Task Ein_vergebener_Name_sagt_welcher() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + + await alsAdmin.PostAsJsonAsync("/api/v1/users", + new CreateUserRequest("doppelt", "einlangespasswort", false)); + var zweites = await alsAdmin.PostAsJsonAsync("/api/v1/users", + new CreateUserRequest("doppelt", "einlangespasswort", false)); + var (code, detail) = await Lesen(zweites); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(HttpStatusCode.Conflict)); + Assert.That(detail, Does.Contain("doppelt")); + }); + } + + /// Die zweite der beiden alten Formen — der selbstgebaute Rumpf. + [Test] + public async Task Eine_Meldung_ohne_Text_sagt_warum() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + + // Die Kennung gibt es nicht — das ist hier gerade recht: auch dieser + // Satz ging früher den unsichtbaren Weg. + var versuch = await alsAdmin.PostAsJsonAsync( + $"/api/v1/items/{Guid.CreateVersion7()}/reports", + new MeldungRequest("bild", "Etwas stimmt nicht.")); + var (code, detail) = await Lesen(versuch); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(detail, Is.EqualTo("Diesen Titel gibt es nicht.")); + }); + } + + /// Damit die beiden Formen nicht zurückkommen. + /// + /// + /// Ein Wächter am Quelltext, kein Test am Verhalten. Die nächste + /// Ablehnung wird irgendwann neu geschrieben, und dann liegt + /// Results.BadRequest(new { fehler = … }) näher als + /// — es sieht ja richtig aus. Ein Test, der + /// jede Ablehnung einzeln abklappert, wäre dagegen genau so vollständig wie + /// die Liste, die jemand pflegt. Dieselbe Art Wächter steht in + /// FreigabeTests über den Inhaltsrouten. + /// + [Test] + public void Keine_Ablehnung_geht_mehr_an_detail_vorbei() + { + var wurzel = Quellordner(); + var endpunkte = Directory.GetFiles( + Path.Combine(wurzel, "src", "Flaro.Api"), "*.cs", SearchOption.AllDirectories); + + var ertappt = new List(); + foreach (var datei in endpunkte) + { + if (Path.GetFileName(datei) is "Einwand.cs") continue; + var inhalt = File.ReadAllText(datei); + if (inhalt.Contains("ValidationProblem")) + ertappt.Add($"{Path.GetFileName(datei)}: ValidationProblem"); + // Auf die Form, nicht auf das Wort: `fehler` heisst auch manche + // örtliche Variable, etwa im Ausnahmebehandler in `Program.cs`. + if (System.Text.RegularExpressions.Regex.IsMatch(inhalt, @"new\s*\{\s*fehler")) + ertappt.Add($"{Path.GetFileName(datei)}: new {{ fehler = … }}"); + } + + Assert.That(ertappt, Is.Empty, + "Diese Form legt den Satz in ein Feld, das kein Client liest. " + + "Einwand.Bitte/Fehlt/Belegt schreibt ihn nach `detail`."); + } + + /// Findet `server/` von den Testbinärdateien aus. + private static string Quellordner() + { + var ordner = new DirectoryInfo(AppContext.BaseDirectory); + while (ordner is not null && !Directory.Exists(Path.Combine(ordner.FullName, "src", "Flaro.Api"))) + ordner = ordner.Parent; + + Assert.That(ordner, Is.Not.Null, "Der Quellordner war von den Tests aus nicht zu finden."); + return ordner!.FullName; + } +} diff --git a/web/app/src/dialog.ts b/web/app/src/dialog.ts index 4e404e1..a7b3878 100644 --- a/web/app/src/dialog.ts +++ b/web/app/src/dialog.ts @@ -204,6 +204,12 @@ export function dialog(optionen: { // obwohl man sichtbar etwas geschrieben hat. if (e instanceof HTMLTextAreaElement) { gesammelt.set(name, e.value.trim()); return; } if (!(e instanceof HTMLInputElement)) { gesammelt.set(name, e.wert); return; } + // **Ein Passwort wird nicht beschnitten.** Überall sonst sind Ränder + // ein Versehen; in einem Passwort sind sie Inhalt. Getrimmt legte der + // Verwaltungsdialog ein Konto an, in das sich niemand anmelden konnte — + // `login.ts` sendet es ungeschoren, und die beiden Zeichenketten waren + // dann verschieden. + if (e.type === 'password') { gesammelt.set(name, e.value); return; } gesammelt.set(name, e.type === 'checkbox' ? String(e.checked) : e.value.trim()); }); diff --git a/web/app/src/views/einstellungen/benutzer.ts b/web/app/src/views/einstellungen/benutzer.ts index 9bb43cc..e62feea 100644 --- a/web/app/src/views/einstellungen/benutzer.ts +++ b/web/app/src/views/einstellungen/benutzer.ts @@ -24,7 +24,16 @@ export function benutzerbereich(): HTMLElement { ], bestaetigen: 'Anlegen', tun: async wert => { - await benutzerAnlegen(wert('name'), wert('passwort'), wert('admin') === 'true'); + // **Hier, nicht erst am Server.** Der Dialog bleibt bei einem Wurf offen + // und zeigt den Satz; einmal hin und zurück über die Leitung wäre dafür + // Verschwendung. Der Server prüft es trotzdem. + const name = wert('name').trim(); + if (name.length === 0) throw new Error('Ohne Namen geht es nicht.'); + if (wert('passwort').length < 8) { + throw new Error('Das Passwort braucht mindestens acht Zeichen.'); + } + + await benutzerAnlegen(name, wert('passwort'), wert('admin') === 'true'); await laden(); }, }); diff --git a/web/app/src/views/setup.ts b/web/app/src/views/setup.ts index 6be99b7..5d10591 100644 --- a/web/app/src/views/setup.ts +++ b/web/app/src/views/setup.ts @@ -25,7 +25,7 @@ export function einrichtung(fertig: () => void): HTMLElement { karte.append(titel, text, name, passwort, los); async function absenden(): Promise { - karte.querySelector('.error')?.remove(); + karte.querySelector('.fehler')?.remove(); los.disabled = true; try { await einrichten(name.value.trim(), passwort.value);