import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Betriebsmodus, Rolle, SchrittTyp, SchrittZustand, VersionStatus } from "@/generated/prisma/enums"; import { LaufBeleg } from "@/components/druck/lauf-beleg"; import { PASSWORT_IM_DRUCK } from "@/lib/passwort"; import { laufZumDrucken, type LaufZumDrucken } from "@/server/queries/druck"; import { kommentarHinzufuegen } from "@/server/services/kommentare"; import { laufAnlegen, laufZurBearbeitungOeffnen } from "@/server/services/laeufe"; import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Die Druckansicht eines Laufs (#015). * * Geprüft wird am **gerenderten Markup**, nicht am Lesemodell allein: die drei * Zusagen dieser Seite sind Aussagen darüber, was auf dem Papier steht, und * genau dort müssen sie gelten. * * 1. `{{generiertes_passwort}}` erscheint nie — es wird nicht gespeichert und * für den Ausdruck auch nicht neu erzeugt. * 2. Vom Mitarbeiter stehen nur Name, UPN und Abteilung darauf. * 3. Ein Lauf eines fremden Kunden ist nicht druckbar. */ const UMGEBUNG = { ...process.env }; /** * Sentinel-Werte: sie tauchen in keinem Vorlagentext auf und sind auch kein Teil * des Anzeigenamens. Steht einer davon im Markup, hat das Lesemodell ein Feld * mitgebracht, das auf keinen Ausdruck gehört. */ const GEHEIM = { vorname: "VornameNichtDrucken", nachname: "NachnameNichtDrucken", mail: "nichtdrucken@firma.example", sam: "samNichtDrucken", dn: "CN=DnNichtDrucken,OU=Benutzer,DC=firma,DC=local", titel: "TitelNichtDrucken", } as const; async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) { const eigene = await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global", modus } }); const technikerZeile = await testPrisma.benutzer.create({ data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER }, }); return { eigene, techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER } }; } /** * Eine veröffentlichte Version mit vier Schritten: ein Haken, ein Textschritt, * ein Befehl mit `{{generiertes_passwort}}` und ein Schritt, dessen **Titel** das * Passwort enthält. * * Der Titel ist kein Wunschfall, sondern der zweite Weg ins Markup: würde nur der * Befehl mit `{ art: "druck" }` aufgelöst, stünde das Passwort trotzdem auf dem * Blatt. */ async function vorlageMitVersion() { const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding", kundeId: null } }); const version = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 3, status: VersionStatus.ENTWURF }, }); const abschnitt = await testPrisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: "Konto im AD", position: 0 }, }); const haken = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Konto für {{mitarbeiter}} angelegt", pflicht: true, position: 0, }, }); const textSchritt = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Auffälligkeiten notiert", typ: SchrittTyp.TEXT, pflicht: false, position: 1, }, }); const befehl = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Konto anlegen", typ: SchrittTyp.BEFEHL, pflicht: false, position: 2, konfig: { shell: "powershell", befehl: 'New-ADUser -Name "{{mitarbeiter}}" -AccountPassword "{{generiertes_passwort}}"', }, }, }); const passwortImTitel = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Passwort {{generiertes_passwort}} übergeben", pflicht: false, position: 3, }, }); await testPrisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, }); return { version, schritte: { haken, textSchritt, befehl, passwortImTitel } }; } async function mitarbeiterAnlegen(kundeId: string) { return testPrisma.mitarbeiter.create({ data: { kundeId, anzeigename: "Maria Huber", upn: "maria.huber@firma.example", abteilung: "Buchhaltung", vorname: GEHEIM.vorname, nachname: GEHEIM.nachname, mail: GEHEIM.mail, sam: GEHEIM.sam, dn: GEHEIM.dn, titel: GEHEIM.titel, }, }); } function markup(daten: LaufZumDrucken): string { // Ohne JSX, damit der Test eine .ts-Datei bleiben kann: `createElement` baut // dieselbe Komponente, die die Seite rendert. return renderToStaticMarkup(createElement(LaufBeleg, { daten })); } describe.runIf(datenbankVerfuegbar)("Druckansicht eines Laufs", () => { beforeEach(async () => { await datenbankLeeren(); // Ohne Kundenbindung greift die Einschraenkung nie – die Mandantentests // setzen sie selbst. delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; }); afterEach(() => { process.env = { ...UMGEBUNG }; }); describe("Das generierte Passwort", () => { it("erscheint weder im Befehl noch im Titel", async () => { const { eigene, techniker } = await grundbestand(); const { version } = await vorlageMitVersion(); const person = await mitarbeiterAnlegen(eigene.id); const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, techniker); const daten = await laufZumDrucken(lauf.id, techniker); expect(daten).toBeDefined(); const html = markup(daten as LaufZumDrucken); // An beiden Stellen steht der Hinweis statt eines Passworts. expect(html).toContain("[Passwort – nicht gespeichert]"); expect(PASSWORT_IM_DRUCK).toBe("[Passwort – nicht gespeichert]"); expect(html.match(/\[Passwort – nicht gespeichert\]/gu)).toHaveLength(2); // Und der Platzhalter selbst bleibt nicht stehen. expect(html).not.toContain("generiertes_passwort"); }); it("macht zwei Ausdrucke desselben Laufs gleich", async () => { const { eigene, techniker } = await grundbestand(); const { version } = await vorlageMitVersion(); const person = await mitarbeiterAnlegen(eigene.id); const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, techniker); /* * Der eigentliche Nachweis: ein erzeugtes Passwort ist bei jedem Aufruf ein * anderes. Sind zwei Ausdrucke Zeichen für Zeichen gleich, steckt in * keinem von beiden eines. */ const ersteFassung = markup((await laufZumDrucken(lauf.id, techniker)) as LaufZumDrucken); const zweiteFassung = markup((await laufZumDrucken(lauf.id, techniker)) as LaufZumDrucken); expect(ersteFassung).toBe(zweiteFassung); }); }); describe("Der Mitarbeiter", () => { it("steht mit Name, UPN und Abteilung darauf – und mit nichts sonst", async () => { const { eigene, techniker } = await grundbestand(); const { version } = await vorlageMitVersion(); const person = await mitarbeiterAnlegen(eigene.id); const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, techniker); const html = markup((await laufZumDrucken(lauf.id, techniker)) as LaufZumDrucken); expect(html).toContain("Maria Huber"); expect(html).toContain("maria.huber@firma.example"); expect(html).toContain("Buchhaltung"); for (const [feld, wert] of Object.entries(GEHEIM)) { expect(html, `${feld} gehört auf kein Blatt, das herumliegt`).not.toContain(wert); } }); it("holt auch im Lesemodell nur diese drei Felder", async () => { const { eigene, techniker } = await grundbestand(); const { version } = await vorlageMitVersion(); const person = await mitarbeiterAnlegen(eigene.id); const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, techniker); const daten = await laufZumDrucken(lauf.id, techniker); // Nicht „alles holen und beim Anzeigen weglassen“: was nicht im Lesemodell // steht, kann auch keine künftige Komponente versehentlich zeigen. expect(Object.keys(daten?.kopf.person ?? {}).sort()).toEqual([ "abteilung", "name", "nurFreitext", "upn", ]); }); it("sagt bei einem Freitext-Namen, dass kein Konto dahintersteht", async () => { const { techniker } = await grundbestand(); const { version } = await vorlageMitVersion(); const lauf = await laufAnlegen( { vorlagenVersionId: version.id, mitarbeiterName: "Noch Nicht Im Ad" }, techniker, ); const daten = await laufZumDrucken(lauf.id, techniker); expect(daten?.kopf.person).toEqual({ name: "Noch Nicht Im Ad", upn: null, abteilung: null, nurFreitext: true, }); expect(markup(daten as LaufZumDrucken)).toContain("kein Konto im Verzeichnis"); }); }); describe("Der Beleg selbst", () => { it("trägt Zustand, Wert, Notiz, Bearbeiter und Kommentare", async () => { const { eigene, techniker } = await grundbestand(); const { version, schritte } = await vorlageMitVersion(); const person = await mitarbeiterAnlegen(eigene.id); const lauf = await laufAnlegen( { vorlagenVersionId: version.id, mitarbeiterId: person.id, ticket: "INC-4711" }, techniker, ); await laufZurBearbeitungOeffnen(lauf.id, techniker); await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.haken.id, zustand: SchrittZustand.ERLEDIGT }, techniker, ); const ergebnis = await schrittergebnisSetzen( { laufId: lauf.id, vorlagenSchrittId: schritte.textSchritt.id, zustand: SchrittZustand.NICHT_ZUTREFFEND, wert: "Lüfter läuft laut", notiz: "Gerät geht ohnehin in die Reparatur", }, techniker, ); await kommentarHinzufuegen( { laufId: lauf.id, schrittergebnisId: ergebnis.id, rumpf: "Rückfrage beim Hersteller läuft" }, techniker, ); await kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Übergabe am Montag" }, techniker); const daten = await laufZumDrucken(lauf.id, techniker); const html = markup(daten as LaufZumDrucken); expect(html).toContain("Onboarding"); expect(html).toContain("Version 3"); expect(html).toContain("INC-4711"); expect(html).toContain("Konto im AD"); // Der Titel des Hakenschritts mit aufgelöstem Platzhalter. expect(html).toContain("Konto für Maria Huber angelegt"); expect(html).toContain("erledigt"); expect(html).toContain("nicht zutreffend"); expect(html).toContain("Lüfter läuft laut"); expect(html).toContain("Gerät geht ohnehin in die Reparatur"); expect(html).toContain("Tina Technik"); expect(html).toContain("Rückfrage beim Hersteller läuft"); expect(html).toContain("Übergabe am Montag"); // Zwei vom Tisch, zwei offen – die Zählung auf dem Blatt. expect(html).toContain("2 von 4 erledigt oder nicht zutreffend"); expect(daten?.kommentare).toHaveLength(1); expect(daten?.abschnitte[0]?.schritte[1]?.kommentare).toHaveLength(1); }); it("zeigt den Kunden nur im Modus Dienstleistung", async () => { const intern = await grundbestand(Betriebsmodus.INTERN); const { version } = await vorlageMitVersion(); const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, intern.techniker); const daten = await laufZumDrucken(lauf.id, intern.techniker); // Im Modus INTERN gibt es keine Kundenebene – auch nicht auf Papier (#008). expect(daten?.kopf.kundeName).toBeNull(); expect(markup(daten as LaufZumDrucken)).not.toContain("Eigene Organisation"); }); }); describe("Die Mandantengrenze", () => { it("macht einen Lauf eines fremden Kunden nicht druckbar", async () => { await grundbestand(Betriebsmodus.DIENSTLEISTUNG); // Nur mit gepflegtem Präfix greift die Kundenbindung (siehe sichtbareKundenIds). process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-"; const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KUA" } }); const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KUB" } }); const { version } = await vorlageMitVersion(); const zeile = await testPrisma.benutzer.create({ data: { email: "a@dienst.example", name: "Techniker A", rolle: Rolle.TECHNIKER }, }); const nurKundeA = { id: zeile.id, rolle: Rolle.TECHNIKER }; await testPrisma.benutzerKunde.create({ data: { benutzerId: zeile.id, kundeId: kundeA.id } }); const adminZeile = await testPrisma.benutzer.create({ data: { email: "admin@dienst.example", name: "Alex Admin", rolle: Rolle.ADMIN }, }); const admin = { id: adminZeile.id, rolle: Rolle.ADMIN }; const eigener = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id }, admin); const fremder = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeB.id }, admin); expect(await laufZumDrucken(eigener.id, nurKundeA)).toBeDefined(); // „Gibt es nicht“ und „darfst du nicht“ sind dieselbe Antwort. expect(await laufZumDrucken(fremder.id, nurKundeA)).toBeUndefined(); // Für einen ungebundenen Admin bleiben beide erreichbar. expect(await laufZumDrucken(fremder.id, admin)).toBeDefined(); }); it("nennt im Modus Dienstleistung den Kunden auf dem Blatt", async () => { await grundbestand(Betriebsmodus.DIENSTLEISTUNG); const kunde = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KUA" } }); const { version } = await vorlageMitVersion(); const adminZeile = await testPrisma.benutzer.create({ data: { email: "admin@dienst.example", name: "Alex Admin", rolle: Rolle.ADMIN }, }); const admin = { id: adminZeile.id, rolle: Rolle.ADMIN }; const lauf = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kunde.id }, admin); const daten = await laufZumDrucken(lauf.id, admin); expect(daten?.kopf.kundeName).toBe("Kunde A"); expect(markup(daten as LaufZumDrucken)).toContain("Kunde A"); }); }); });