fix: Reviewbefunde aus der Durchsicht von Phase 2 (#036, #027)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.

Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.

Schwer:

- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
  (/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
  alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
  jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
  dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
  eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.

Mittel:

- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
  durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
  Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
  jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
  exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
  Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
  Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
  Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
  der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
  angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
  Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
  Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
  Graph-Leser sich im Kommentar darauf berief.

Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.

Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.

1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-11 01:32:09 +02:00
co-authored by Claude Opus 5
parent fcd67018b8
commit 80d97467da
37 changed files with 1752 additions and 59 deletions
+22
View File
@@ -170,6 +170,28 @@ describe.runIf(datenbankVerfuegbar)("Abteilungsabschnitte am Lauf", () => {
expect(ergebnisse.map((e) => e.vorlagenSchritt.titel).sort()).toEqual(["Papiere", "Übergabe"]);
});
it("schreibt im Protokoll die Schritte dieses Laufs, nicht die der Version", async () => {
/*
* **Die Zahl war die der Version** (Reviewbefund zu #029). Bei einer Vorlage
* mit abteilungsgebundenen Abschnitten behauptete der Eintrag „Lauf angelegt“
* mehr Schritte, als der Lauf hat.
*
* Das Protokoll ist seit #023 ansehbar: eine Zahl darin, die nicht zur Liste
* darunter passt, schickt jemanden auf die Suche nach Schritten, die es nie
* gab.
*/
const { admin, vertrieb, versionId } = await aufbau(VORLAGE);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, abteilungId: vertrieb.id }, admin);
const wirklich = await testPrisma.schrittergebnis.count({ where: { laufId: lauf.id } });
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId: lauf.id, aktion: "Lauf angelegt" },
});
expect(wirklich).toBe(2);
expect(eintrag.nachher).toMatchObject({ anzahlSchritte: wirklich });
});
it("ändert die Abschnitte nicht, wenn später die Abteilung des Laufs wechselt", async () => {
/*
* Der Grund für das Festschreiben: ein archivierter Lauf muss genau die
+276
View File
@@ -0,0 +1,276 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { Betriebsmodus, VersionStatus } from "@/generated/prisma/enums";
import type { Rolle } from "@/generated/prisma/enums";
import { RechteFehler } from "@/server/auth/sitzungspflicht";
import { anhangSpeichern } from "@/server/services/anhaenge";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Die Adresse, die einen Anhang ausliefert (#032).
*
* **Warum eine eigene Datei, obwohl `tests/anhaenge.test.ts` schon den Dienst
* prüft:** Das Akzeptanzkriterium von #032 lautet „ein Anhang eines fremden
* Kunden ist **über seine Adresse** nicht abrufbar“. Geprüft war bis hierher nur
* `anhangLesen` — der Dienst. Die ganze Rechteprüfung im Route Handler ließ sich
* durch einen hartcodierten ADMIN ersetzen, ohne dass ein einziger Test rot
* wurde (Reviewbefund zu #029).
*
* Das ist die teuerste Lücke, die eine Prüfung haben kann: dieser Handler ist
* die **einzige** Stelle der Anwendung, die rohe Dateibytes ausliefert. Fällt
* die Prüfung bei einem Umbau heraus, gibt jedes Foto jedem heraus, der eine
* Kennung hat — und das Tor vor dem Commit bleibt grün.
*
* Gefahren wird deshalb der **Handler selbst**, wie bei den SCIM-Handlern und
* beim Ereignisstrom. Nur die Sitzung wird gestellt; alles andere ist echt, bis
* hinunter zur Datei auf der Platte.
*/
/* Der Handler wird echt gefahren; nur die Sitzung wird gestellt. */
vi.mock("@/server/auth/sitzungspflicht", async (original) => {
const echt = await original<typeof import("@/server/auth/sitzungspflicht")>();
return { ...echt, rechtErforderlich: vi.fn() };
});
const { rechtErforderlich } = await import("@/server/auth/sitzungspflicht");
const { GET } = await import("@/app/api/anhaenge/[id]/route");
const VORHER = process.env["ANHAENGE_PFAD"];
let ordner: string;
beforeAll(async () => {
ordner = await mkdtemp(join(tmpdir(), "laufzettel-anhang-route-"));
process.env["ANHAENGE_PFAD"] = ordner;
});
afterAll(async () => {
if (VORHER === undefined) delete process.env["ANHAENGE_PFAD"];
else process.env["ANHAENGE_PFAD"] = VORHER;
await rm(ordner, { recursive: true, force: true });
});
/** Ein winziges, gültiges PNG. */
const PNG = Buffer.from(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" +
"0000000a49444154789c6300010000050001" +
"0d0a2db40000000049454e44ae426082",
"hex",
);
/** Ein winziges PDF – es wird anders ausgeliefert als ein Bild. */
const PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n", "utf8");
async function aufbau() {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung", position: 0 },
});
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Typenschild fotografieren", position: 0 },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
const lauf = await testPrisma.lauf.create({
data: { vorlagenVersionId: version.id, titel: "Ein Lauf", kundeId: kunde.id },
});
await testPrisma.schrittergebnis.create({
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id },
});
return {
kunde,
admin: { id: admin.id, rolle: admin.rolle },
laufId: lauf.id,
schrittId: schritt.id,
};
}
/** Legt einen Anhang an und gibt seine Kennung zurück. */
async function anhangAnlegen(
laufId: string,
schrittId: string,
admin: { id: string; rolle: Rolle },
abweichend: { dateiname?: string; medientyp?: string; inhalt?: Buffer } = {},
): Promise<string> {
const { id } = await anhangSpeichern(
{
laufId,
vorlagenSchrittId: schrittId,
dateiname: abweichend.dateiname ?? "typenschild.png",
medientyp: abweichend.medientyp ?? "image/png",
inhalt: new Uint8Array(abweichend.inhalt ?? PNG),
},
admin,
);
return id;
}
const kennung = (id: string) => ({ params: Promise.resolve({ id }) });
const anfrage = () => new Request("http://127.0.0.1:3300/api/anhaenge/egal");
describe.runIf(datenbankVerfuegbar)("Ein Anhang über seine Adresse", () => {
beforeEach(async () => {
await datenbankLeeren();
vi.mocked(rechtErforderlich).mockReset();
});
it("weist ohne Anmeldung ab, bevor eine Datei gelesen wird", async () => {
const { admin, laufId, schrittId } = await aufbau();
const id = await anhangAnlegen(laufId, schrittId, admin);
vi.mocked(rechtErforderlich).mockRejectedValue(new RechteFehler("Nicht angemeldet."));
const antwort = await GET(anfrage(), kennung(id));
expect(antwort.status).toBe(401);
// Und kein Byte der Datei im Rumpf.
expect(await antwort.text()).not.toContain("PNG");
});
it("weist auch ab, wenn die Rolle das Recht lesen nicht hat", async () => {
/*
* `MITARBEITER` hat seit #035 kein `lesen` — die Selbstauskunft ist dessen
* **Ersatz** und kein Zusatz. Dass ein Anhang damit nicht erreichbar ist,
* entscheidet `rechtErforderlich`; hier wird gezeigt, dass der Handler sich
* daran hält und nicht selbst weiterläuft.
*/
const { admin, laufId, schrittId } = await aufbau();
const id = await anhangAnlegen(laufId, schrittId, admin);
vi.mocked(rechtErforderlich).mockRejectedValue(new RechteFehler("Dafür fehlt dir die Berechtigung."));
expect((await GET(anfrage(), kennung(id))).status).toBe(401);
});
it("liefert einem berechtigten Benutzer die Datei unverändert", async () => {
const { admin, laufId, schrittId } = await aufbau();
const id = await anhangAnlegen(laufId, schrittId, admin);
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
const antwort = await GET(anfrage(), kennung(id));
expect(antwort.status).toBe(200);
expect(antwort.headers.get("content-type")).toBe("image/png");
expect(antwort.headers.get("content-length")).toBe(String(PNG.length));
// Byte für Byte dasselbe – ein Bild, das unterwegs umkodiert wird, ist kein Beweismittel.
expect(Buffer.from(await antwort.arrayBuffer()).equals(PNG)).toBe(true);
});
it("zeigt ein Bild inline und lädt ein PDF herunter", async () => {
/*
* Ein PDF kann Skripte enthalten. Ein Anhang, den jemand aus einem fremden
* Lauf öffnet, soll nichts ausführen — deshalb `attachment` und
* `nosniff`.
*/
const { admin, laufId, schrittId } = await aufbau();
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
const bild = await anhangAnlegen(laufId, schrittId, admin);
const bildAntwort = await GET(anfrage(), kennung(bild));
expect(bildAntwort.headers.get("content-disposition")).toBe('inline; filename="typenschild.png"');
const pdf = await anhangAnlegen(laufId, schrittId, admin, {
dateiname: "abnahme.pdf",
medientyp: "application/pdf",
inhalt: PDF,
});
const pdfAntwort = await GET(anfrage(), kennung(pdf));
expect(pdfAntwort.headers.get("content-disposition")).toBe('attachment; filename="abnahme.pdf"');
expect(pdfAntwort.headers.get("x-content-type-options")).toBe("nosniff");
});
it("lässt keinen Zwischenspeicher die Datei weitergeben", async () => {
// Ein Anhang gehört einem Kunden. Ein öffentlicher Cache gäbe ihn an den
// nächsten Betrachter weiter, und hinter einem Reverse Proxy ist das real.
const { admin, laufId, schrittId } = await aufbau();
const id = await anhangAnlegen(laufId, schrittId, admin);
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
expect((await GET(anfrage(), kennung(id))).headers.get("cache-control")).toContain("private");
});
it("gibt den Anhang eines fremden Kunden nicht heraus – über seine Adresse", async () => {
/*
* **Das Akzeptanzkriterium von #032, zum ersten Mal an der Adresse
* geprüft.** Der Techniker ist auf einen anderen Kunden beschränkt; die
* Kennung des Anhangs kennt er. Ohne diese Prüfung reicht die Kennung.
*/
const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-";
try {
const { admin, laufId, schrittId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
const id = await anhangAnlegen(laufId, schrittId, admin);
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FRD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: fremd.id },
});
vi.mocked(rechtErforderlich).mockResolvedValue({
benutzer: { id: techniker.id, rolle: techniker.rolle },
} as never);
const antwort = await GET(anfrage(), kennung(id));
/*
* `404` und nicht `403`: nicht vorhanden und nicht sichtbar sind dieselbe
* Antwort. Die Unterscheidung wäre schon eine Auskunft über fremde Kunden.
*/
expect(antwort.status).toBe(404);
expect(antwort.headers.get("content-type")).toContain("application/json");
const daten = (await antwort.json()) as Record<string, unknown>;
// Und die Meldung nennt weder Dateiname noch Kunde.
expect(JSON.stringify(daten)).not.toContain("typenschild");
expect(JSON.stringify(daten)).not.toContain("Eigene Organisation");
} finally {
if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher;
}
});
it("antwortet auf eine Kennung, die es nicht gibt, mit 404", async () => {
const { admin } = await aufbau();
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
const antwort = await GET(anfrage(), kennung("gibt-es-nicht"));
expect(antwort.status).toBe(404);
});
it("sagt bei fehlender Datei, dass die Zeile steht und die Datei fehlt", async () => {
/*
* Der Fall nach einer Sicherung, die nur die Datenbank mitgenommen hat. Die
* Meldung muss das sagen und nicht „nicht gefunden“ behaupten — sonst sucht
* der Betreiber an der falschen Stelle (#032, und siehe
* `docs/betrieb/sicherung.md`).
*/
const { admin, laufId, schrittId } = await aufbau();
const id = await anhangAnlegen(laufId, schrittId, admin);
await rm(ordner, { recursive: true, force: true });
vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never);
const antwort = await GET(anfrage(), kennung(id));
expect(antwort.status).toBe(404);
expect(String((await antwort.json())["fehler"])).toMatch(/fehlt im Dateisystem/u);
});
});
+54
View File
@@ -506,6 +506,60 @@ describe.runIf(datenbankVerfuegbar)("Bausteine", () => {
]);
});
it("exportiert auch einen Entwurf mit seinen Baustein-Schritten", async () => {
/*
* **Der Fall, der still einen leeren Abschnitt lieferte** (Reviewbefund zu
* #029). Ein Baustein-Abschnitt im Entwurf hat keine eigenen Schritte —
* die entstehen erst beim Veröffentlichen. Der Export schrieb deshalb
* `{"titel":"wird ersetzt","schritte":[]}`, und der Import nahm das
* stillschweigend an: das Schema lässt einen leeren Abschnitt zu.
*
* Ein Redakteur bringt eine noch unveröffentlichte Vorlage in eine zweite
* Installation — genau der Zweck des Formats. Dort kam der geteilte
* Abschnitt unter dem Editor-Platzhalter „wird ersetzt“ und ohne einen
* einzigen Schritt an. Die Checkliste war unvollständig, und nichts sagte
* es.
*/
const { adminId } = await grundbestand();
const { versionId: bausteinVersionId } = await bausteinMitSchritten(
"Datensicherung prüfen",
"Datensicherung",
["Sicherung läuft", "Rücksicherung geprüft"],
adminId,
);
// Wie `vorlageMitBaustein`, aber **ohne** zu veröffentlichen.
const { vorlage } = await vorlageAnlegen({ name: "Noch Entwurf" }, adminId);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, adminId);
await entwurfSpeichern(entwurf.id, {
variablen: [],
abschnitte: [
{ titel: "Eigener Abschnitt", schritte: [{ titel: "Eigener Schritt" }] },
{ titel: "wird ersetzt", bausteinVersionId, schritte: [] },
],
});
const austausch = await vorlageExportieren(entwurf.id);
expect(
austausch.struktur.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
schritte: abschnitt.schritte.map((schritt) => schritt.titel),
})),
).toEqual([
{ titel: "Eigener Abschnitt", schritte: ["Eigener Schritt"] },
// Der Titel aus dem Baustein, nicht der Platzhalter – und die Schritte.
{ titel: "Datensicherung", schritte: ["Sicherung läuft", "Rücksicherung geprüft"] },
]);
// Und weiterhin kein Verweis: in der anderen Installation gibt es den
// Baustein vielleicht nicht.
expect(JSON.stringify(austausch)).not.toContain(bausteinVersionId);
// Kein Abschnitt ohne Schritte – das ist die Zusage, die vorher brach.
for (const abschnitt of austausch.struktur.abschnitte) {
expect(abschnitt.schritte.length, abschnitt.titel).toBeGreaterThan(0);
}
});
it("übernimmt beim Import keinen mitgeschickten Verweis", async () => {
const { adminId } = await grundbestand();
const { versionId: bausteinVersionId } = await bausteinMitSchritten(
+28
View File
@@ -1,4 +1,6 @@
import { createServer, type Server } from "node:http";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
Betriebsmodus,
@@ -12,6 +14,7 @@ import { abgleichMeldung, laufMeldung, webhookRumpf } from "@/lib/meldungen";
import { SIGNATUR_KOPF, signaturBauen, signaturPruefen } from "@/lib/signatur";
import { verschluesseln } from "@/lib/geheimnisse";
import {
MELDUNGS_PFADE,
WARTEZEITEN_MINUTEN,
abgleichMeldungEinreihen,
laufMeldungEinreihen,
@@ -199,6 +202,31 @@ describe("Die Texte der Meldungen", () => {
expect(abgleichMeldung("AD", "Grund", link).rumpf).toContain(link);
});
it("zeigt mit jedem Pfad auf eine Seite, die es wirklich gibt", () => {
/*
* **Der Test, der gefehlt hat.** Oben wird geprüft, dass der *übergebene*
* Link im Rumpf steht — das ist richtig und sagt nichts darüber, ob der
* Pfad existiert. `/verwaltung/verzeichnis` stand im Dienst und war ein
* 404: `verwaltung` ist der Name des Rechts, die Seite liegt unter
* `/admin/verzeichnis` (Reviewbefund zu #029).
*
* Ein Pfad in einer Mail wird nicht nachgesehen, sondern geklickt — und
* genau diese eine Meldung geht an **alle** Administratoren, im Moment
* eines Ausfalls. Geprüft wird deshalb gegen das Dateisystem: zu jedem Pfad
* muss unter `src/app/(app)/` eine `page.tsx` liegen.
*/
const wurzel = new URL("../src/app/(app)", import.meta.url).pathname;
for (const pfad of Object.values(MELDUNGS_PFADE)) {
const teile = pfad.replace(/^\//u, "").split("/");
// Ein Pfad, der auf eine Kennung endet, liegt unter `[id]`.
const kandidaten = [join(wurzel, ...teile, "page.tsx"), join(wurzel, ...teile, "[id]", "page.tsx")];
expect(
kandidaten.some((datei) => existsSync(datei)),
`${pfad} zeigt auf keine Seite – gesucht: ${kandidaten.join(" oder ")}`,
).toBe(true);
}
});
it("nimmt keinen Schrittinhalt auf — der Typ lässt es nicht zu", () => {
/*
* Die wichtigste Zusage dieses Meilensteins. Geprueft wird sie hier ueber
+58 -1
View File
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { readFileSync, readdirSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { E2E_PRAEFIX } from "./e2e/aufraeumen";
import { parse } from "yaml";
/**
@@ -83,6 +84,62 @@ describe("Betriebsstand: docker-compose.yml", () => {
});
});
describe("Die Ende-zu-Ende-Abläufe räumen auf", () => {
/*
* **Der Test, der die Lücke beim nächsten Meilenstein fängt** (#027).
*
* In der Entwicklungsdatenbank standen 105 Vorlagen mit Namen wie
* `ZZ E2E Editor 1790324192596 C`. Der Editor-Ablauf räumt seit #018 selbst
* auf — aber zwei Abläufe aus Phase 2 (`anhaenge`, `live`) legten Zeilen an
* und hatten keinen Nachlauf. Das fiel nicht auf, weil die Suite davon nichts
* sieht: Reste sind kein Testfehler, sie sind ein Schaden an allem, was
* danach in dieselbe Datenbank sieht.
*
* Geräumt wird jetzt zentral in `tests/e2e/aufraeumen.ts`, vor **und** nach
* dem Lauf. Damit das trägt, muss zweierlei gelten, und beides steht hier:
* jeder Ablauf benutzt das Präfix, und der Vor- und Nachlauf sind registriert.
*/
const ABLAEUFE = readdirSync("tests/e2e").filter((datei) => datei.endsWith(".spec.ts"));
it("findet überhaupt Abläufe – sonst prüft der Test darunter nichts", () => {
// Ohne diese Zeile wäre eine leere Liste ein grüner Test.
expect(ABLAEUFE.length).toBeGreaterThan(10);
});
it("benennt jede angelegte Zeile mit dem Präfix, an dem aufgeräumt wird", () => {
/*
* Geprüft wird die **Umkehrung**: ein Ablauf, der Daten anlegt, ohne das
* Präfix zu benutzen, ist von keinem Aufräumen erfasst. Erkennbar ist das
* daran, dass er ein Formularfeld „Name“ oder „Titel“ füllt.
*/
const ohnePraefix: string[] = [];
for (const datei of ABLAEUFE) {
const inhalt = readFileSync(`tests/e2e/${datei}`, "utf8");
const legtAn = /getByLabel\((?:"|')(?:Name|Titel|Bezeichnung)/u.test(inhalt);
if (legtAn && !inhalt.includes(E2E_PRAEFIX)) ohnePraefix.push(datei);
}
expect(
ohnePraefix,
`Diese Abläufe legen Zeilen an, ohne „${E2E_PRAEFIX}“ zu benutzen — das zentrale ` +
"Aufräumen findet sie nicht wieder.",
).toEqual([]);
});
it("hat Vorlauf und Nachlauf registriert", () => {
/*
* Beides, und das ist der Punkt: der Nachlauf läuft nach einem Testfehler
* und nach einem Zeitablauf, aber nicht nach `Strg+C`. Dann findet der
* Vorlauf des nächsten Durchgangs die Reste — und ein Vorlauf lässt sich
* nicht überspringen.
*/
const konfiguration = readFileSync("playwright.config.ts", "utf8");
expect(konfiguration).toContain("global-setup");
expect(konfiguration).toContain("global-teardown");
expect(readFileSync("tests/e2e/global-setup.ts", "utf8")).toContain("e2eAufraeumenMitBericht");
});
});
describe("Betriebsstand: Entwicklungsdatenbank", () => {
it("bindet nur auf localhost und auf einen eigenen, umstellbaren Port", () => {
const stand = compose("docker-compose.dev.yml") as unknown as {
+185
View File
@@ -0,0 +1,185 @@
import { prisma } from "@/lib/db";
/**
* Was die Ende-zu-Ende-Abläufe hinterlassen, wieder wegräumen (#027).
*
* **Alles, was ein Ablauf anlegt, beginnt mit `ZZ`.** Das Präfix sortiert
* die Zeilen in jeder Liste ans Ende und ist zugleich der Griff, an dem sie
* wieder herausgehen — ohne gemerkte Kennungen, denn ein Lauf, der mitten im
* Ablauf abbricht, merkt sich nichts.
*
* **Warum vor *und* nach dem Lauf geräumt wird.** Ein `afterAll` je Datei und
* ein `globalTeardown` laufen bei einem Testfehler und bei einem Zeitablauf —
* aber nicht, wenn jemand `Strg+C` drückt oder der Prozess hart stirbt. Dann
* bleiben die Zeilen stehen, und der nächste Lauf findet sie vor. Deshalb räumt
* derselbe Code auch im Vorlauf: was vom letzten Mal übrig ist, geht weg, bevor
* der erste Ablauf startet. Ein Vorlauf kann nicht übersprungen werden.
*
* Aufgefallen ist das Ganze, weil in der Entwicklungsdatenbank 105 Vorlagen mit
* Namen wie `ZZ E2E Editor 1790324192596 C` standen — aus einem Fenster von
* sechzehn Stunden, in dem noch kein Ablauf aufräumte. Der Schaden liegt nicht
* bei den Tests, sondern bei allem, was **danach** in dieselbe Datenbank sieht:
* die Vorlagenliste, die Screenshots, jede Abfrage von Hand. Ein Fehler, der
* „zu viele Treffer“ heißt, kostet dann eine Stunde.
*/
/**
* Das Präfix, das jeder Ablauf seinen Zeilen gibt.
*
* **`ZZ` und nicht `ZZ E2E`**, weil die Abläufe es in fünf Schreibweisen
* benutzen: `ZZ E2E Editor …`, `ZZ-E2E-<zeit>-PC-01` (Gerätenamen dürfen keine
* Leerzeichen haben), `ZZPruef…`, `ZZQuelle…` und `ZZ Abteilung …`. Alle fünf
* sind sinnvoll — ein Hostname sieht anders aus als ein Vorlagenname —, und
* alle fünf beginnen mit `ZZ`. Darauf zu greifen deckt sie ab, ohne sechzehn
* Dateien umzubenennen.
*
* Zwei Buchstaben sind kurz. Das ist hier in Ordnung: geräumt wird nur beim
* Ende-zu-Ende-Lauf und nur gegen die Entwicklungsdatenbank, und `ZZ` am
* **Anfang** eines Namens ist in echten Daten dieses Hauses nichts, was jemand
* tippt. Der Grund für die Wahl ist ohnehin derselbe: `ZZ` sortiert in jeder
* Liste ans Ende.
*/
export const E2E_PRAEFIX = "ZZ";
/** Was aufgeräumt wurde — damit der Aufrufer es melden kann. */
export type Aufgeraeumt = Readonly<Record<string, number>>;
/**
* Räumt alles mit `ZZ` weg und meldet, was es war.
*
* Die Reihenfolge ist nicht beliebig: erst was an einem Lauf hängt, dann die
* Läufe, dann woran ein Lauf hängen könnte. Ein Fremdschlüssel, der sich
* beschwert, ist dabei **kein** Fehler dieser Funktion, sondern die Auskunft,
* dass ein Ablauf etwas verknüpft hat, das er nicht aufräumen kann — dann soll
* es auffallen und nicht stillschweigend bleiben.
*/
export async function e2eRestlosAufraeumen(): Promise<Aufgeraeumt> {
const praefix = { startsWith: E2E_PRAEFIX };
const gezaehlt: Record<string, number> = {};
const merken = (was: string, anzahl: number): void => {
if (anzahl > 0) gezaehlt[was] = (gezaehlt[was] ?? 0) + anzahl;
};
/*
* `Protokoll.entitaetId` ist **kein** Fremdschlüssel: die Zeilen überleben
* ihren Gegenstand absichtlich (ein archivierter Lauf soll seine Geschichte
* behalten). Hier heißt das, dass sie als Waisen stehen bleiben, wenn sie
* nicht mitgenommen werden — darum werden die Kennungen vorher gesammelt.
*/
const laeufe = await prisma.lauf.findMany({ where: { titel: praefix }, select: { id: true } });
const vorlagen = await prisma.vorlage.findMany({
where: { name: praefix },
select: { id: true, versionen: { select: { id: true } } },
});
const bausteine = await prisma.baustein.findMany({
where: { name: praefix },
select: { id: true, versionen: { select: { id: true } } },
});
const geraete = await prisma.geraet.findMany({
where: { OR: [{ bezeichnung: praefix }, { hostname: praefix }] },
select: { id: true },
});
const personen = await prisma.mitarbeiter.findMany({
where: { anzeigename: praefix },
select: { id: true },
});
const abteilungen = await prisma.abteilung.findMany({
where: { name: praefix },
select: { id: true },
});
const quellen = await prisma.verzeichnisquelle.findMany({
where: { name: praefix },
select: { id: true },
});
const benutzer = await prisma.benutzer.findMany({
where: { name: praefix },
select: { id: true },
});
/*
* **Waisen werden nicht erraten.** Eine Protokollzeile, deren Gegenstand schon
* fort ist, bleibt stehen — ihre `entitaetId` ist hier nicht mehr zu finden.
* Sie über den Namen im Json zu greifen wäre naheliegend und falsch: derselbe
* Name steht auch in echter Historie einer Zeile, die es weiterhin gibt (etwa
* „Version veröffentlicht“ mit der Liste der eingesetzten Bausteine). Dass
* eine solche Zeile ihren Gegenstand überlebt, ist genau der Grund, warum
* `entitaetId` kein Fremdschlüssel ist.
*/
const laufIds = laeufe.map((zeile) => zeile.id);
const betroffen = [
...laufIds,
...vorlagen.flatMap((v) => [v.id, ...v.versionen.map((version) => version.id)]),
...bausteine.flatMap((b) => [b.id, ...b.versionen.map((version) => version.id)]),
...geraete.map((zeile) => zeile.id),
...personen.map((zeile) => zeile.id),
...abteilungen.map((zeile) => zeile.id),
...quellen.map((zeile) => zeile.id),
...benutzer.map((zeile) => zeile.id),
];
if (laufIds.length > 0) {
merken("Anhänge", (await prisma.anhang.deleteMany({ where: { laufId: { in: laufIds } } })).count);
merken("Kommentare", (await prisma.kommentar.deleteMany({ where: { laufId: { in: laufIds } } })).count);
merken(
"Schrittergebnisse",
(await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: laufIds } } })).count,
);
merken(
"Zustellungen",
(await prisma.zustellung.deleteMany({ where: { laufId: { in: laufIds } } })).count,
);
}
if (betroffen.length > 0) {
merken(
"Protokollzeilen",
(
await prisma.protokoll.deleteMany({
where: { OR: [{ entitaetId: { in: betroffen } }, { laufId: { in: laufIds } }] },
})
).count,
);
}
merken("Läufe", (await prisma.lauf.deleteMany({ where: { titel: praefix } })).count);
merken("Vorlagen", (await prisma.vorlage.deleteMany({ where: { name: praefix } })).count);
merken("Bausteine", (await prisma.baustein.deleteMany({ where: { name: praefix } })).count);
merken(
"Geräte",
(
await prisma.geraet.deleteMany({
where: { OR: [{ bezeichnung: praefix }, { hostname: praefix }] },
})
).count,
);
merken("Personen", (await prisma.mitarbeiter.deleteMany({ where: { anzeigename: praefix } })).count);
merken("Abteilungen", (await prisma.abteilung.deleteMany({ where: { name: praefix } })).count);
merken(
"Verzeichnisquellen",
(await prisma.verzeichnisquelle.deleteMany({ where: { name: praefix } })).count,
);
merken("Benutzer", (await prisma.benutzer.deleteMany({ where: { name: praefix } })).count);
return gezaehlt;
}
/** Dasselbe mit einer Zeile Ausgabe — für Vor- und Nachlauf. */
export async function e2eAufraeumenMitBericht(wann: "vor" | "nach"): Promise<void> {
const gezaehlt = await e2eRestlosAufraeumen();
const summe = Object.values(gezaehlt).reduce((a, b) => a + b, 0);
if (summe === 0) return;
const liste = Object.entries(gezaehlt)
.map(([was, anzahl]) => `${anzahl} ${was}`)
.join(", ");
/*
* Der Vorlauf **sagt es laut**: Zeilen, die er findet, sind der Rückstand
* eines abgebrochenen Durchgangs. Still wegzuräumen hieße, die Ursache zu
* verdecken — und ein Ablauf, der jedes Mal abbricht, fiele nie auf.
*/
console.info(
wann === "vor"
? `E2E-Aufräumen (Rückstand aus einem früheren Lauf): ${liste}.`
: `E2E-Aufräumen: ${liste}.`,
);
}
+41
View File
@@ -1,7 +1,9 @@
import { chromium } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { ABTEILUNGEN } from "@/../prisma/seed-daten/stammdaten";
import { anmelden, SEED_GERAET, SEED_TAG, SEED_VORLAGE } from "./helfer";
import { e2eAufraeumenMitBericht } from "./aufraeumen";
/**
* Was vor dem ersten Ablauf stimmen muss (#018).
@@ -23,6 +25,14 @@ function fehlen(was: string, abhilfe: string): never {
}
export default async function vorbereiten(): Promise<void> {
/*
* **Zuerst aufraeumen, dann pruefen** (#027). Was hier noch steht, ist der
* Rueckstand eines abgebrochenen Durchgangs: `globalTeardown` und die
* `afterAll`-Bloecke laufen bei einem Testfehler, aber nicht bei `Strg+C`.
* Ein Vorlauf laesst sich nicht ueberspringen — das ist der Unterschied.
*/
await e2eAufraeumenMitBericht("vor");
if (!process.env["SEED_ADMIN_EMAIL"] || !process.env["SEED_ADMIN_PASSWORD"]) {
fehlen(
"SEED_ADMIN_EMAIL oder SEED_ADMIN_PASSWORD fehlt.",
@@ -59,6 +69,37 @@ export default async function vorbereiten(): Promise<void> {
fehlen(`Das Beispielgerät ${SEED_GERAET} (${SEED_TAG}) fehlt.`, "pnpm db:seed laufen lassen.");
}
/*
* 2c. Stehen die **Werte** des Seed-Bestands noch, nicht nur die Zeilen?
*
* Der Onboarding-Ablauf prueft den `New-ADUser`-Befehl **zeichengenau**, und
* darin steckt die Ziel-OU der Abteilung. In der Entwicklungsdatenbank stand
* an „Buchhaltung“ `DC=firma,DC=de` statt `DC=firma,DC=local` — von Hand
* geaendert oder aus einer aelteren Seed-Fassung. Der Seed setzt bestehende
* Abteilungen absichtlich **nicht** zurueck (gepflegte Daten sollen bleiben),
* also blieb die Abweichung stehen.
*
* Der Ablauf scheiterte daran mit „expected … received …“ — einer Meldung, die
* nach einem Fehler im Befehlsbau aussieht und keiner war. Hier faellt es
* vorher auf, und die Meldung sagt, was zu tun ist (Reviewbefund zu #029).
*/
for (const vorgabe of ABTEILUNGEN) {
const abteilung = await prisma.abteilung.findFirst({
where: { name: vorgabe.name },
select: { zielOu: true, standardGruppen: true },
});
if (!abteilung) continue;
if (abteilung.zielOu !== vorgabe.zielOu) {
fehlen(
`Die Abteilung „${vorgabe.name}“ hat eine abweichende Ziel-OU: ` +
`„${abteilung.zielOu}“ statt „${vorgabe.zielOu}“.`,
"Der Onboarding-Ablauf prüft den erzeugten Befehl zeichengenau. Entweder die Ziel-OU " +
"unter Admin → Abteilungen zurückstellen, oder gegen eine frische Datenbank fahren: " +
"pnpm db:seed:reset.",
);
}
}
/*
* 2b. In welchem Betriebsmodus laeuft die Installation?
*
+18
View File
@@ -0,0 +1,18 @@
import { prisma } from "@/lib/db";
import { e2eAufraeumenMitBericht } from "./aufraeumen";
/**
* Nach dem letzten Ablauf: wegräumen, was die Abläufe angelegt haben (#027).
*
* Läuft auch dann, wenn Abläufe fehlgeschlagen sind — Playwright ruft den
* Nachlauf nach dem Lauf, nicht nach dem Erfolg. Was er **nicht** abdeckt, ist
* ein harter Abbruch (`Strg+C`, `kill`); dafür räumt derselbe Code im Vorlauf,
* siehe `aufraeumen.ts`.
*/
export default async function aufraeumen(): Promise<void> {
try {
await e2eAufraeumenMitBericht("nach");
} finally {
await prisma.$disconnect();
}
}
+12
View File
@@ -273,7 +273,19 @@ test.describe("Der Zustands-Endpunkt", () => {
expect(antwort.headers()["cache-control"]).toBe("no-store");
const zustand: unknown = await antwort.json();
/*
* Die Felder **namentlich** und vollständig: der Zustand ist die einzige
* Antwort, die alle zehn Sekunden (seit #033 bei jeder Änderung) hinausgeht,
* und ein Feld, das sich hier einschleicht, wandert ungesehen mit. Deshalb
* `toEqual` und nicht `toContain`.
*
* `anzahlAnhaenge` kam mit #033 dazu und fehlte hier — der Ablauf war seitdem
* rot, und niemand hat es gemerkt, weil `pnpm test:e2e` nicht im Tor vor dem
* Commit steht (Reviewbefund zu #029). Wer hier ein Feld ergänzt, trägt es
* ein; genau das ist der Zweck dieser Liste.
*/
expect(Object.keys(zustand as object).sort()).toEqual([
"anzahlAnhaenge",
"anzahlKommentare",
"anzahlPflichtOffen",
"geaendertAm",
+16
View File
@@ -184,6 +184,22 @@ const SEITEN: readonly Hauptseite[] = [
*/
{ name: "Admin: Verzeichnis", pfad: () => "/admin/verzeichnis", nurModus: "INTERN" },
{ name: "Kunden", pfad: () => "/kunden", nurModus: "DIENSTLEISTUNG" },
/*
* **Die Seiten aus Phase 2.** Sie fehlten hier, obwohl jedes der Issues eine
* Klickprobe bei 390 px von Hand berichtet — eine Probe von Hand ist kein
* Regressionstest (Reviewbefund zu #029). Gerade die Bausteinliste und das
* Zustellungs-Protokoll sind Tabellen, und Tabellen sind die Stellen, an denen
* horizontales Scrollen entsteht.
*
* Nicht dabei: `/meine-geraete` (#035) — es verlangt das Recht
* `selbstauskunft`, und das hat nur `MITARBEITER`. Dieser Ablauf meldet sich
* als Admin an, der dort „kein Zugriff“ sähe; gemessen würde die
* Verweigerungsseite. Dafür braucht es ein eigenes Konto, siehe #037.
*/
{ name: "Vorlagen: Bausteine", pfad: () => "/vorlagen/bausteine" },
{ name: "Admin: Schnellstart", pfad: () => "/admin/schnellstart" },
{ name: "Admin: Protokoll", pfad: () => "/admin/protokoll" },
{ name: "Admin: Zustellungen", pfad: () => "/admin/zustellungen" },
];
type Messung = {
+11 -1
View File
@@ -112,7 +112,17 @@ test.describe("Personen", () => {
await treffer.click();
await page.waitForURL(/\/mitarbeiter\/[^/]+$/);
await expect(page.getByText(/kommt aus dem Verzeichnis/)).toBeVisible();
/*
* **Der ganze Satz, nicht nur der Anfang.** „kommt aus dem Verzeichnis“
* allein traf seit #020 zwei Stellen: dieselbe Auskunft steht auch am
* Vorgesetzten-Feld. Playwright wies das als „strict mode violation“ ab, und
* der Ablauf war seitdem rot — unbemerkt, weil `pnpm test:e2e` nicht im Tor
* vor dem Commit steht (Reviewbefund zu #029).
*
* Hier zählt die Auskunft an den **Stammdaten**: dass sie dort gepflegt
* werden und eine Änderung hier der nächste Abgleich zurücksetzen würde.
*/
await expect(page.getByText(/kommt aus dem Verzeichnis\. Die Stammdaten werden dort/u)).toBeVisible();
await expect(page.getByLabel("Vorname")).toHaveAttribute("readonly", "");
await expect(page.getByRole("button", { name: "Stammdaten speichern" })).toHaveCount(0);
// Deaktivieren muss trotzdem gehen: das Offboarding wartet nicht auf den
+29 -3
View File
@@ -132,8 +132,15 @@ test.describe("Vorlagen-Editor", () => {
.evaluateAll((felder) => felder.map((feld) => (feld as HTMLInputElement).value));
expect(await werte()).toEqual(["Erster", "Zweiter"]);
// Der Anfasser ist mindestens 44 px hoch – sonst trifft ihn niemand.
/*
* **Erst in den Blick scrollen.** Am Handy stand der erste Anfasser nach dem
* Anlegen zweier Abschnitte bei `y = -115`, also oberhalb des Sichtfensters;
* der Touch-Start landete damit außerhalb und bewegte nichts. Das fiel
* nicht auf, weil `pnpm test:e2e` nicht im Tor vor dem Commit steht
* (Reviewbefund zu #029).
*/
const anfasser = page.getByRole("button", { name: "Verschieben" }).first();
await anfasser.scrollIntoViewIfNeeded();
const kasten = await anfasser.boundingBox();
expect(kasten?.height ?? 0).toBeGreaterThanOrEqual(44);
expect(kasten?.width ?? 0).toBeGreaterThanOrEqual(44);
@@ -142,13 +149,32 @@ test.describe("Vorlagen-Editor", () => {
// eine Wischgeste ein Bildlauf bleibt und kein Verschieben.
const x = kasten!.x + kasten!.width / 2;
const y = kasten!.y + kasten!.height / 2;
/*
* **Die Zugweite wird gemessen, nicht gesetzt.** Vorher waren es fest
* 12 × 25 px = 300; der Abstand zwischen den beiden Anfassern ist aber
* 532 px, seit mit #030 und #031 Bausteinverweis und Abteilungsfilter in
* jeden Abschnitt gekommen sind. Der Finger kam damit nicht mehr über den
* zweiten Abschnitt, und die Reihenfolge blieb stehen.
*
* Eine feste Weite in einem Sortiertest ist eine Zusage über das Layout,
* die niemand pflegt. Gemessen wird sie bei jedem Lauf richtig — und wenn
* der Abstand sich wieder ändert, bleibt der Test grün, statt eine
* Pixelzahl nachzuziehen.
*/
const zweiterAnfasser = await page.getByRole("button", { name: "Verschieben" }).nth(1).boundingBox();
// Ein Stück über die Mitte des zweiten hinaus, sonst entscheidet dnd-kit
// sich fuer „bleibt“.
const weite = (zweiterAnfasser?.y ?? y + 300) - kasten!.y + kasten!.height;
const schritte = 20;
const sitzung = await page.context().newCDPSession(page);
await sitzung.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
await page.waitForTimeout(350);
for (let schritt = 1; schritt <= 12; schritt += 1) {
for (let schritt = 1; schritt <= schritte; schritt += 1) {
await sitzung.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x, y: y + schritt * 25 }],
touchPoints: [{ x, y: y + (weite * schritt) / schritte }],
});
await page.waitForTimeout(40);
}
+28
View File
@@ -8,6 +8,7 @@ import {
benutzerZuordnen,
ersteAdresse,
graphEintraegeStroemen,
naechsteSeite,
} from "@/server/graph/leser";
import { GraphFehler, graphZugangAus, tokenHolen, tokenSpeicherLeeren } from "@/server/graph/zugang";
import { vorgesetzteAufloesen } from "@/server/ldap/zuordnung";
@@ -385,6 +386,33 @@ describe("Der Token-Austausch", () => {
});
describe("Der Seitenabruf", () => {
it("folgt einem nextLink nur auf denselben Host", () => {
/*
* **Jede Seite wird mit dem Zugangstoken geholt**, und das gilt mandantenweit
* (`User.Read.All`). Der Antwort von Graph über TLS zu trauen ist
* vertretbar — aber `GRAPH_API_URL` ist absichtlich einstellbar, für
* nationale Clouds und für den Doppelgänger in diesem Test. Ein Tippfehler
* darin oder ein aufbrechender Firmenproxy dazwischen schickte das Token
* sonst an eine fremde Adresse (Reviewbefund zu #029).
*/
const basis = "https://graph.microsoft.com";
expect(naechsteSeite(`${basis}/v1.0/users?$skiptoken=abc`, basis)).toBe(
`${basis}/v1.0/users?$skiptoken=abc`,
);
// Anderer Host: Abruf endet, statt das Token mitzuschicken.
expect(naechsteSeite("https://boese.example/v1.0/users", basis)).toBeUndefined();
// Auch ein anderes Schema oder ein anderer Port ist ein anderer Ursprung.
expect(naechsteSeite("http://graph.microsoft.com/v1.0/users", basis)).toBeUndefined();
expect(naechsteSeite("https://graph.microsoft.com:8443/v1.0/users", basis)).toBeUndefined();
// Und das Übliche: nichts, leer, kein gültiges URL.
expect(naechsteSeite(undefined, basis)).toBeUndefined();
expect(naechsteSeite("", basis)).toBeUndefined();
expect(naechsteSeite("kein-url", basis)).toBeUndefined();
expect(naechsteSeite(42, basis)).toBeUndefined();
});
it("folgt @odata.nextLink über drei Seiten", async () => {
aufzeichnung.seiten = [[nutzer(1), nutzer(2)], [nutzer(3), nutzer(4)], [nutzer(5)]];
+96 -3
View File
@@ -1,5 +1,6 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Betriebsmodus, LaufStatus, VersionStatus } from "@/generated/prisma/enums";
import type { Rolle } from "@/generated/prisma/enums";
import { HERZSCHLAG, KANAL, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse";
import {
STROM_GESCHLOSSEN,
@@ -7,6 +8,7 @@ import {
sollZurueckfallen,
} from "@/components/laeufe/lauf-aktualisierung";
import { RechteFehler } from "@/server/auth/sitzungspflicht";
import type { Sitzung } from "@/lib/sitzung";
import { aufLaufHoeren, ereignisseSchliessen, stroemeOffen } from "@/server/lauf-ereignisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
@@ -305,10 +307,29 @@ describe.runIf(datenbankVerfuegbar)("Der Route Handler", () => {
delete process.env["SSE_MAX_STROEME"];
});
function angemeldetAls(benutzer: { id: string; rolle: "ADMIN" }): void {
/**
* Die Sitzung wird mit ihrer **echten Form** gestellt, nicht als `{}`.
*
* Vorher stand hier ein leeres Objekt, und genau daran ist ein Befund
* vorbeigekommen: der Strom prueft die Sitzung seit #029 beim Herzschlag nach,
* und mit einer Attrappe ohne `benutzerId` liesse sich das nicht zeigen. Eine
* Attrappe, die nicht die Form der Wirklichkeit hat, prueft etwas anderes als
* die Wirklichkeit — dieselbe Lehre wie beim Graph-Doppelgaenger in #028.
*/
function sitzungFuer(benutzerId: string, ausgestelltAm = Math.floor(Date.now() / 1000)): Sitzung {
return {
benutzerId,
email: "wer@firma.example",
name: "Wer",
herkunft: "passwort",
ausgestelltAm,
};
}
function angemeldetAls(benutzer: { id: string; rolle: Rolle }): void {
vi.mocked(rechtErforderlich).mockResolvedValue({
benutzer: { id: benutzer.id, rolle: benutzer.rolle },
sitzung: {},
sitzung: sitzungFuer(benutzer.id),
} as unknown as Awaited<ReturnType<typeof rechtErforderlich>>);
}
@@ -365,7 +386,7 @@ describe.runIf(datenbankVerfuegbar)("Der Route Handler", () => {
});
vi.mocked(rechtErforderlich).mockResolvedValue({
benutzer: { id: leser.id, rolle: "LESER" },
sitzung: {},
sitzung: sitzungFuer(leser.id),
} as unknown as Awaited<ReturnType<typeof rechtErforderlich>>);
const antwort = await GET(anfrage("text/event-stream"), {
@@ -458,6 +479,78 @@ describe.runIf(datenbankVerfuegbar)("Der Route Handler", () => {
expect(gelesen.split(HERZSCHLAG).length - 1).toBeGreaterThanOrEqual(3);
});
it("beendet den Strom, wenn das Konto deaktiviert wird", async () => {
/*
* **Ein offener Strom hat das Deaktivieren überlebt** (Reviewbefund zu
* #029). Das Recht wurde genau einmal geprüft, beim Aufbau, und das Paar aus
* Kennung und Rolle für die Lebensdauer des Stroms festgehalten. Ein
* Administrator deaktivierte ein Konto — und eine offen gelassene Lasche auf
* dem Schreibtisch des Betroffenen bekam weiter live Status, Schrittzustände
* und die Zahl der Kommentare, bis jemand sie schloss.
*
* `docs/architektur.md` verspricht „ein deaktivierter Benutzer wird auch mit
* gültigem Cookie abgewiesen“. Für einen offenen Strom galt das nicht.
*/
process.env["SSE_HERZSCHLAG_MS"] = "40";
const { laufId, admin } = await aufbau();
angemeldetAls(admin as { id: string; rolle: "ADMIN" });
const antwort = await GET(anfrage("text/event-stream"), {
params: Promise.resolve({ id: laufId }),
});
await testPrisma.benutzer.update({ where: { id: admin.id }, data: { aktiv: false } });
const gelesen = await stromLesen(antwort, (text) => text.includes("nicht-angemeldet"));
expect(gelesen).toContain("nicht-angemeldet");
// Und der Strom ist auch wirklich freigegeben, nicht nur stillgelegt.
await warteAuf(() => stroemeOffen() === 0, "Der Strom nach dem Deaktivieren");
});
it("beendet den Strom, wenn die Sitzungen widerrufen werden", async () => {
/*
* Derselbe Weg, anderer Anlass: ein Administrator beendet die Sitzungen
* eines Benutzers (`sitzungenUngueltigAb`), ohne das Konto zu deaktivieren —
* etwa nach einem verlorenen Notebook. Dann muss der Strom genauso enden.
*/
process.env["SSE_HERZSCHLAG_MS"] = "40";
const { laufId, admin } = await aufbau();
angemeldetAls(admin as { id: string; rolle: "ADMIN" });
const antwort = await GET(anfrage("text/event-stream"), {
params: Promise.resolve({ id: laufId }),
});
await testPrisma.benutzer.update({
where: { id: admin.id },
// Eine Minute in der Zukunft: die Sitzung dieses Tests ist „von vorher“.
data: { sitzungenUngueltigAb: new Date(Date.now() + 60_000) },
});
expect(await stromLesen(antwort, (text) => text.includes("nicht-angemeldet"))).toContain(
"nicht-angemeldet",
);
});
it("hält den Strom, solange das Konto gilt", async () => {
/*
* Die Gegenrichtung, und sie ist nötig: eine Nachprüfung, die **immer**
* beendet, sähe im Test oben genauso aus. Hier bleibt der Strom über
* mehrere Herzschläge offen, ohne dass sich am Konto etwas ändert.
*/
process.env["SSE_HERZSCHLAG_MS"] = "40";
const { laufId, admin } = await aufbau();
angemeldetAls(admin as { id: string; rolle: "ADMIN" });
const antwort = await GET(anfrage("text/event-stream"), {
params: Promise.resolve({ id: laufId }),
});
const gelesen = await stromLesen(antwort, (text) => text.split(HERZSCHLAG).length - 1 >= 3);
expect(gelesen).not.toContain("nicht-angemeldet");
expect(gelesen.split(HERZSCHLAG).length - 1).toBeGreaterThanOrEqual(3);
});
it("fällt bei erreichter Obergrenze auf eine gewöhnliche Antwort zurück", async () => {
process.env["SSE_MAX_STROEME"] = "1";
const { laufId, admin } = await aufbau();
+36
View File
@@ -411,6 +411,42 @@ describe.runIf(datenbankVerfuegbar)("Zuordnung bei der Anmeldung", () => {
// Ohne Urheber: das Konto gibt es an der Stelle vielleicht noch nicht.
expect(protokoll[0]?.benutzerId).toBeNull();
expect(JSON.stringify(protokoll[0]?.nachher)).toContain("mhuber@firma.local");
expect(protokoll[0]?.nachher).toMatchObject({ treffer: 2 });
});
it("nennt im Protokoll die wirkliche Zahl der Treffer, nicht zwei", async () => {
/*
* **Die Zahl war geraten.** `personZuUpn` hatte `take: 2` — fuer die
* Entscheidung „mehrdeutig“ genuegt das, aber dieselbe Zahl ging in den
* Protokolleintrag und sagte dort immer „2“, auch bei vier Treffern
* (Reviewbefund zu #029).
*
* Wer das Protokoll liest, liest es, **um den Fall aufzuloesen** — er muss
* wissen, wie viele Zeilen er zu bereinigen hat. Eine Zahl, die nicht die
* Zahl ist, schickt ihn nach der zweiten Korrektur zurueck.
*/
await aufbau();
for (const kuerzel of ["ZWEI", "DREI", "VIER"]) {
const weiterer = await testPrisma.kunde.create({
data: { name: `${kuerzel} GmbH`, kuerzel },
});
await testPrisma.mitarbeiter.create({
data: {
kundeId: weiterer.id,
vorname: "Maria",
nachname: "Huber",
anzeigename: `Maria Huber (${kuerzel})`,
upn: "mhuber@firma.local",
},
});
}
await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toThrow();
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { aktion: { contains: "mehrdeutig" } },
});
expect(eintrag.nachher).toMatchObject({ treffer: 4 });
});
it("ordnet keine deaktivierte Person zu", async () => {
+51
View File
@@ -436,6 +436,57 @@ describe.runIf(datenbankVerfuegbar)("Verzeichnisquellen", () => {
);
});
it("weist einen Attributnamen ab, der keiner ist – auch am Formular vorbei", async () => {
/*
* **Die Regel sass eine Schicht ueber dem Code, der sich auf sie berief**
* (Reviewbefund zu #029). `src/server/graph/leser.ts` schrieb im Kommentar
* „die Werte selbst sind feste Feldnamen aus ATTRIBUT_MUSTER, also ist hier
* nichts zu maskieren“ — gepruefet wurde das aber nur in
* `zuordnungAusFormular`, also in der Server Action.
*
* Nachgewiesen wurde, dass genau dieser Name durch den Dienst kam und einen
* **zweiten `$filter`** in die Graph-Adresse schrieb. Hereinkommen konnte er
* nur mit `verwaltung` oder per Skript, Seed und `psql` — die
* Sicherheitswirkung ist also klein. Der Befund ist der Ort der Regel:
* CLAUDE.md verlangt sie in der Datenbank, und sie stand nicht einmal im
* Dienst.
*/
const { admin } = await grundbestand();
const boese = { abteilung: "department,id&$filter=boese eq 1" };
await expect(quelleAnlegen(quellenEingabe({ attributZuordnung: boese }), admin)).rejects.toThrow(
/kein Attributname/u,
);
// Und beim Bearbeiten genauso.
const quelle = await quelleAnlegen(quellenEingabe(), admin);
await expect(quelleBearbeiten(quelle.id, { attributZuordnung: boese }, admin)).rejects.toThrow(
/kein Attributname/u,
);
// Die gespeicherte Zeile ist unberührt.
expect((await quelleHolen(quelle.id, admin))?.attributZuordnung.abteilung).toBe(AD_ZUORDNUNG.abteilung);
});
it("verwirft beim Lesen einen unbrauchbaren Namen, statt die Liste zu sprengen", async () => {
/*
* Die Gegenrichtung: eine Zeile, die vor dieser Prüfung angelegt wurde oder
* von Hand in die Datenbank kam. Hier zu werfen wäre falsch — eine einzige
* kaputte Zeile machte die ganze Quellenliste unbenutzbar. Sie bekommt
* deshalb die Vorgabe.
*/
const { admin } = await grundbestand();
const quelle = await quelleAnlegen(quellenEingabe(), admin);
await testPrisma.verzeichnisquelle.update({
where: { id: quelle.id },
data: { attributZuordnung: { abteilung: "department,id&$filter=boese eq 1" } },
});
const gelesen = await quelleHolen(quelle.id, admin);
expect(gelesen?.attributZuordnung.abteilung).toBe(AD_ZUORDNUNG.abteilung);
expect(JSON.stringify(gelesen)).not.toContain("$filter");
});
it("weist ein Intervall ausserhalb der Grenzen ab", async () => {
const { admin } = await grundbestand();
await expect(quelleAnlegen(quellenEingabe({ abgleichIntervallMinuten: 1 }), admin)).rejects.toThrow(
+40 -5
View File
@@ -3,7 +3,7 @@ import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { SignJWT } from "jose";
import { sicheresZiel } from "@/server/auth/anmeldung";
import { istOeffentlich } from "@/proxy";
import { OEFFENTLICH, istOeffentlich } from "@/proxy";
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
import { sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
@@ -67,20 +67,55 @@ describe("Ruecksprungziel gegen Steuerzeichen", () => {
});
describe("Allowlist des Proxys", () => {
/**
* Jeder oeffentliche Pfad, einzeln aufgezaehlt — **und zwar absichtlich von
* Hand.** Diese Liste ist die Behauptung „genau das soll ohne Anmeldung
* gehen“; aus `OEFFENTLICH` abgeleitet wuerde sie nichts mehr behaupten.
* Dass sie vollstaendig ist, prueft der Test darunter.
*/
const ERWARTET_OEFFENTLICH = [
"/anmelden",
"/api/auth",
"/api/gesundheit",
// Der SCIM-Endpunkt (#029): ein Identitaetsanbieter hat kein Cookie, er
// weist sich mit einem Bearer-Token aus, und jeder Handler darunter prueft
// es selbst. Ohne diesen Eintrag leitet der Proxy jede Zustellung auf
// /anmelden um, und das Provisioning hoert still auf zu arbeiten.
"/api/scim",
"/manifest.webmanifest",
"/icon.svg",
] as const;
it("laesst genau die Pfade durch, die ohne Anmeldung gehen muessen", () => {
for (const pfad of [
"/anmelden",
...ERWARTET_OEFFENTLICH,
// Und die Unterpfade, auf die es im Betrieb ankommt.
"/api/auth/oidc/start",
"/api/auth/oidc/rueckruf",
"/api/auth/abmelden",
"/api/gesundheit",
"/manifest.webmanifest",
"/icon.svg",
"/api/scim/v2/Users",
"/api/scim/v2/ServiceProviderConfig",
]) {
expect(istOeffentlich(pfad), pfad).toBe(true);
}
});
it("hat keinen oeffentlichen Pfad, den dieser Test nicht kennt", () => {
/*
* **Der Test, der gefehlt hat.** `/api/scim` kam mit #029 in die Allowlist
* und stand in keiner Pruefung — die Zeile liess sich entfernen, ohne dass
* ein einziger Test rot wurde (Reviewbefund). Dabei ist der Eintrag beides:
* noetig, damit das Provisioning ueberhaupt ankommt, und eine
* Sicherheitsentscheidung, weil darunter jeder Handler sein Token selbst
* pruefen **muss**.
*
* Deshalb hier der Vergleich in die andere Richtung: wer `OEFFENTLICH`
* erweitert, muss es oben aufschreiben. Eine Liste, die nur in einer
* Richtung geprueft wird, laeuft dem Code hinterher.
*/
expect([...OEFFENTLICH].sort()).toEqual([...ERWARTET_OEFFENTLICH].sort());
});
it("laesst sich nicht durch einen angehaengten Namen austricksen", () => {
// Ein reiner startsWith-Vergleich haette /anmeldenX durchgelassen.
for (const pfad of [