import { execFileSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "@playwright/test"; import "dotenv/config"; import { prisma } from "@/lib/db"; import { PASSWORT_GROSS, PASSWORT_IM_DRUCK, PASSWORT_KLEIN, PASSWORT_LAENGE, PASSWORT_SONDER, PASSWORT_ZIFFERN, } from "@/lib/passwort"; import { anmelden } from "./helfer"; /** * Uebersicht und Druckansicht am gebauten Stand (#015). * * Beide Seiten leben von ihrer Darstellung: die Uebersicht sortiert nach * Dringlichkeit, die Druckansicht gehoert auf Papier. Was davon stimmt, zeigt * erst der Browser — die Lesemodelle dahinter pruefen `tests/uebersicht.test.ts` * und `tests/druckansicht.test.ts`. * * Voraussetzungen prueft `tests/e2e/global-setup.ts`. Jeden Lauf, den diese * Ablaeufe brauchen, legen sie selbst an und raeumen ihn hinterher weg. Vorher * holten sie sich mit `findFirstOrThrow` irgendeinen vorhandenen Lauf und * erwarteten, dass „Zuletzt bearbeitet“ gefuellt ist — gegen eine frische * Datenbank scheiterte beides, das eine mit „No record was found“, das andere * mit einem Zeitablauf auf einen Verweis, den es nicht gab (#018). */ const STEMPEL = Date.now(); /** Fortlaufend, damit die Laeufe eines Durchgangs sich nicht verwechseln. */ let laufende = 0; /** Name und Titel tragen diesen Praefix – nur daran raeumt der Nachlauf auf. */ const PRAEFIX = "ZZ E2E Druck"; /** * Ein Lauf aus einer Vorlage, die `{{generiertes_passwort}}` wirklich enthaelt. * * Direkt ueber Prisma: geprueft werden soll die **Druckansicht**, nicht der Weg * zum Lauf — den prueft `tests/e2e/laeufe.spec.ts` vom Anlegen bis zum Archiv. */ async function laufMitPasswortschritt(): Promise<{ id: string; titel: string }> { laufende += 1; const marke = `${PRAEFIX} ${STEMPEL}-${laufende}`; // Ueber `istEigeneOrganisation` und nicht ueber den ersten Treffer: im Modus // DIENSTLEISTUNG waere das ein beliebiger Demo-Kunde. const kunde = await prisma.kunde.findFirstOrThrow({ where: { istEigeneOrganisation: true }, select: { id: true }, }); const vorlage = await prisma.vorlage.create({ data: { name: marke, kategorie: "ZZ Test" } }); const version = await prisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" }, }); const abschnitt = await prisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: "Konto" }, }); await prisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Konto angelegt", typ: "BEFEHL", konfig: { shell: "powershell", befehl: 'New-ADUser -Password "{{generiertes_passwort}}"' }, }, }); await prisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() }, }); const lauf = await prisma.lauf.create({ data: { vorlagenVersionId: version.id, kundeId: kunde.id, titel: marke }, }); return { id: lauf.id, titel: marke }; } test.describe("Übersicht", () => { test("zeigt die vier Blöcke in ihrer Reihenfolge", async ({ page }) => { await anmelden(page); const ueberschriften = await page.getByRole("heading", { level: 2 }).allInnerTexts(); expect(ueberschriften).toEqual(["Überfällig", "Mir zugewiesen", "Zuletzt bearbeitet", "Schnellstart"]); }); test("führt von einer Zeile in den Lauf", async ({ page }) => { /* * Der Ablauf bringt die Zeile mit, auf die er klickt. „Zuletzt bearbeitet“ * sortiert nach `geaendertAm` absteigend, der eben angelegte Lauf steht also * oben — und weil der Klick auf **seinen** Titel geht, prueft der Test einen * Weg und nicht die Datenlage. */ const lauf = await laufMitPasswortschritt(); await anmelden(page); const zuletzt = page .locator("section") .filter({ has: page.getByRole("heading", { name: "Zuletzt bearbeitet" }) }); await zuletzt.getByRole("link", { name: new RegExp(lauf.titel) }).click(); await page.waitForURL(`**/laeufe/${lauf.id}`); }); test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => { test.skip(!isMobile, "Nur im Projekt „handy“."); await anmelden(page); const masse = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, sichtbar: document.documentElement.clientWidth, })); expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar); }); }); test.describe("Druckansicht", () => { test("ist hell, ohne Navigation und passt auf A4", async ({ page }) => { test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät."); // Der eigene Lauf statt `findFirstOrThrow`: gegen eine frische Datenbank gab // es keinen, und die Meldung sprach von „No record was found“ (#018). const lauf = await laufMitPasswortschritt(); await anmelden(page); await page.goto(`/drucken/lauf/${lauf.id}`); // Keine Hülle: weder Seitenleiste noch der Scannen-Knopf gehören aufs Blatt. await expect(page.getByRole("navigation", { name: "Hauptnavigation" })).toHaveCount(0); await expect(page.getByRole("link", { name: "Scannen" })).toHaveCount(0); // Hell heisst hell: der Grund des Belegs ist keine dunkle Fläche. const grund = await page .locator(".druck-hell") .first() .evaluate((element) => getComputedStyle(element).backgroundColor); expect(grund).toBe("rgb(255, 255, 255)"); /* * A4 kommt aus `@page`, und nur ein echtes PDF zeigt, ob die Regel greift: * 210 × 297 mm sind 595 × 842 Punkte. */ const pdf = await page.pdf({ preferCSSPageSize: true }); const text = pdf.toString("latin1"); const treffer = /\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/u.exec(text); expect(treffer).not.toBeNull(); expect(Number(treffer?.[3]) - Number(treffer?.[1])).toBeCloseTo(595, 0); expect(Number(treffer?.[4]) - Number(treffer?.[2])).toBeCloseTo(842, 0); }); test("ist vom Lauf aus erreichbar und erzeugt kein Passwort", async ({ page }) => { /* * Der Lauf bringt seine eigene Vorlage mit — eine **mit** * `{{generiertes_passwort}}` im Befehl. Vorher lief dieser Test gegen * NB-001, dessen Vorlage den Platzhalter gar nicht enthält: er hätte auch * dann bestanden, wenn die Druckansicht Passwörter erzeugte (Reviewbefund * zu #015). */ const lauf = await laufMitPasswortschritt(); await anmelden(page); await page.goto(`/laeufe/${lauf.id}`); await page.getByRole("link", { name: "Drucken" }).click(); await page.waitForURL(/\/drucken\/lauf\//); const blatt = await page.locator("body").innerText(); expect(blatt).toContain("[Passwort – nicht gespeichert]"); expect(blatt).not.toContain("generiertes_passwort"); /* * Und der zweite Beweis, der ohne Kenntnis des Textes auskommt: ein * erzeugtes Passwort wäre bei jedem Aufbau ein anderes. Sind beide Blätter * Zeichen für Zeichen gleich, kann keines darin stecken. */ await page.reload(); const nochmal = await page.locator("body").innerText(); expect(nochmal.replace(/Gedruckt am[^\n]*/u, "")).toBe(blatt.replace(/Gedruckt am[^\n]*/u, "")); }); }); /** * Ob `pdftotext` da ist – ohne es laesst sich der Text eines PDF nicht lesen. * * Der Inhalt eines PDF ist komprimiert. In den rohen Bytes nach einem Passwort * zu suchen und nichts zu finden wuerde **nichts** beweisen: ein Passwort, das * drinsteht, stuende dort auch nicht im Klartext. Entweder richtig extrahieren * oder den Test ueberspringen und das laut sagen – wie bei `ldap` und `mailpit` * im Commit-Tor. */ const MIT_PDFTOTEXT = (() => { try { execFileSync("pdftotext", ["-v"], { stdio: "ignore" }); return true; } catch { process.stderr.write( "\n[uebersicht-druck] pdftotext fehlt – der Text des PDF wird nicht geprueft.\n" + " Auf Debian: apt-get install poppler-utils\n\n", ); return false; } })(); /** Der Text eines PDF, ueber `pdftotext`. */ function pdfText(bytes: Buffer): string { const ordner = mkdtempSync(join(tmpdir(), "laufzettel-pdf-")); try { const datei = join(ordner, "blatt.pdf"); writeFileSync(datei, bytes); execFileSync("pdftotext", ["-layout", datei, join(ordner, "blatt.txt")]); return readFileSync(join(ordner, "blatt.txt"), "utf8"); } finally { rmSync(ordner, { recursive: true, force: true }); } } test.describe("Das PDF-Übergabeprotokoll", () => { /** * Holt das PDF eines Laufs oder ueberspringt den Ablauf. * * **Der Knopf ist das Signal.** Er erscheint nur, wenn der Server Chromium * findet (#041); fehlt er, ist das keine Stoerung, sondern eine Installation * ohne PDF-Erzeugung. Dann hat dieser Ablauf nichts zu pruefen und sagt es. */ async function pdfHolen(seite: import("@playwright/test").Page, laufId: string): Promise { const knopf = seite.getByRole("link", { name: "Als PDF" }); test.skip( (await knopf.count()) === 0, "Dieser Server hat kein Chromium – der PDF-Knopf erscheint deshalb nicht. " + "Siehe docs/betrieb/pdf.md.", ); const antwort = await seite.request.get(`/api/laeufe/${laufId}/protokoll.pdf`); expect(antwort.status(), await antwort.text()).toBe(200); expect(antwort.headers()["content-type"]).toBe("application/pdf"); const bytes = await antwort.body(); // Ein PDF, das keines ist, waere ein leeres Blatt mit richtigem Kopf. expect(bytes.subarray(0, 5).toString("latin1")).toBe("%PDF-"); return bytes; } test("entsteht aus dem Lauf heraus und traegt das ganze Blatt", async ({ page }) => { test.skip(test.info().project.name !== "desktop", "Das Blatt haengt nicht am Geraet."); const lauf = await laufMitPasswortschritt(); await anmelden(page); await page.goto(`/laeufe/${lauf.id}`); const bytes = await pdfHolen(page, lauf.id); test.skip(!MIT_PDFTOTEXT, "Ohne pdftotext ist der Text des PDF nicht lesbar."); const text = pdfText(bytes); // Titel, Vorlage mit Version, der Abschnitt und der Schritt – das Blatt ist // vollstaendig und nicht nur ein gueltiges PDF. expect(text).toContain(lauf.titel); expect(text).toContain("Version 1"); expect(text).toContain("Konto"); expect(text).toContain("Konto angelegt"); // Und das Unterschriftenfeld, das dieses Blatt zum Uebergabeprotokoll macht. expect(text).toContain("Übergeben von"); expect(text).toContain("Übernommen von"); }); test("traegt kein erzeugtes Passwort", async ({ page }) => { /* * **Die Zusage, die am teuersten waere, wenn sie bricht.** Die Vorlage * dieses Laufs enthaelt `{{generiertes_passwort}}` im Befehl; auf dem Blatt * muss der Ersatztext stehen und nirgends ein Passwort. * * Geprueft wird am extrahierten Text und nicht an den Bytes – siehe * `MIT_PDFTOTEXT`. */ test.skip(test.info().project.name !== "desktop", "Das Blatt haengt nicht am Geraet."); const lauf = await laufMitPasswortschritt(); await anmelden(page); await page.goto(`/laeufe/${lauf.id}`); const bytes = await pdfHolen(page, lauf.id); test.skip(!MIT_PDFTOTEXT, "Ohne pdftotext ist der Text des PDF nicht lesbar."); const text = pdfText(bytes); expect(text).toContain(PASSWORT_IM_DRUCK); // Der Platzhalter selbst darf auch nicht stehenbleiben. expect(text).not.toContain("generiertes_passwort"); /* * Und der Beweis, der ohne Kenntnis des Textes auskommt: irgendeine Folge * aus genau den Zeichen, aus denen `passwortErzeugen` baut, in voller * Laenge. Ein echtes Passwort traegt immer eines aus jeder Gruppe, also * verlangt das Muster das auch – sonst traefe es Fliesstext. */ const alphabet = PASSWORT_GROSS + PASSWORT_KLEIN + PASSWORT_ZIFFERN + PASSWORT_SONDER; /* * Der Bindestrich geht ans Ende der Zeichenklasse, wo er sich selbst meint. * Ihn zu maskieren ist der naheliegende Weg und war der falsche: in einer * Vorlagenzeichenkette wird aus `\\-` ein `\-`, und mit dem `u`-Schalter * ist das eine ungueltige Maskierung – der Ausdruck wirft dann schon beim * Bauen. Keines der uebrigen Sonderzeichen hat in einer Klasse Bedeutung. */ const klasse = alphabet.replace("-", "") + "-"; const kandidaten = (text.match(new RegExp(`[${klasse}]{${PASSWORT_LAENGE}}`, "gu")) ?? []).filter( // Eine Kennung aus der Datenbank ist 25 Zeichen aus einem aehnlichen // Alphabet; sie steht im Dateinamen und ist kein Passwort. (treffer) => !lauf.id.includes(treffer), ); expect(kandidaten, `Passwortverdacht auf dem Blatt: ${kandidaten.join(", ")}`).toEqual([]); }); test("gibt den Lauf eines fremden Kunden nicht heraus", async ({ page }) => { /* * Dieselbe Zusage wie an der Druckansicht, nur ueber den neuen Weg. Ein * Endpunkt, der ein ganzes Blatt ausgibt, waere der bequemste Zugang zu * einem fremden Lauf. */ test.skip(test.info().project.name !== "desktop", "Die Mandantengrenze haengt nicht am Geraet."); await anmelden(page); const antwort = await page.request.get("/api/laeufe/gibtesnicht/protokoll.pdf"); expect(antwort.status()).toBe(404); expect(antwort.headers()["content-type"]).toContain("application/json"); }); }); test.afterAll(async () => { const laeufe = await prisma.lauf.findMany({ where: { titel: { startsWith: PRAEFIX } }, select: { id: true }, }); const kennungen = laeufe.map((lauf) => lauf.id); if (kennungen.length > 0) { await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } }); await prisma.protokoll.deleteMany({ where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] }, }); await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } }); } // Die Vorlagen zuletzt: an ihnen hingen gerade noch die Laeufe. await prisma.vorlage.deleteMany({ where: { name: { startsWith: PRAEFIX } } }); await prisma.$disconnect(); });