import { readFileSync } from "node:fs"; import { join } from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import { Betriebsmodus, LaufStatus, VersionStatus } from "@/generated/prisma/enums"; import { AnmeldeFehler, benutzerAusOidcUebernehmen } from "@/server/auth/benutzer"; import type { OidcAnspruecke } from "@/server/auth/oidc"; import { darf, startseite } from "@/server/auth/rollen"; import { navigationFuer } from "@/lib/navigation"; import { meineDaten, meineGeraete, meineLaeufeOhneGeraet } from "@/server/queries/selbstauskunft"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Mitarbeiter-Selbstauskunft (#035). * * Die wichtigste Zusage dieses Meilensteins ist eine **Verneinung**: eine * Selbstauskunft ist keine Akteneinsicht. Geprueft wird sie hier nicht an der * Seite, sondern an der **Antwort** des Lesemodells — eine Seite laesst sich * umgehen, ein Lesemodell nicht. */ const UMGEBUNG = { ...process.env }; function anspruecke(ueberschreiben: Partial = {}): OidcAnspruecke { return { sub: "anbieter-sub-mitarbeiter", email: "maria.huber@firma.example", emailBestaetigt: true, name: "Maria Huber", gruppen: [], upn: "mhuber@firma.local", ziel: "/", ...ueberschreiben, }; } 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 maria = await testPrisma.mitarbeiter.create({ data: { kundeId: kunde.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber", upn: "mhuber@firma.local", mail: "maria.huber@firma.example", abteilung: "Buchhaltung", titel: "Sachbearbeiterin", }, }); const kollege = await testPrisma.mitarbeiter.create({ data: { kundeId: kunde.id, vorname: "Ali", nachname: "Demir", anzeigename: "Ali Demir", upn: "ademir@firma.local", }, }); const meinNotebook = await testPrisma.geraet.create({ data: { kundeId: kunde.id, mitarbeiterId: maria.id, tagCode: "LZ-MEANBC", hostname: "NB-042" }, }); const fremdesNotebook = await testPrisma.geraet.create({ data: { kundeId: kunde.id, mitarbeiterId: kollege.id, tagCode: "LZ-FREMDA", hostname: "NB-099" }, }); 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: "Konto", position: 0 }, }); const schritt = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Konto anlegen", typ: "BEFEHL", position: 0, konfig: { shell: "powershell", befehl: 'New-ADUser -AccountPassword "{{generiertes_passwort}}"', }, }, }); await testPrisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, }); const admin = await testPrisma.benutzer.create({ data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" }, }); /* Ein Lauf an **meinem** Geraet, mit Notiz, Kommentar und Anhang daran. */ const meinLauf = await testPrisma.lauf.create({ data: { vorlagenVersionId: version.id, kundeId: kunde.id, titel: "PC Neuinstallation – NB-042", geraetId: meinNotebook.id, mitarbeiterId: maria.id, status: LaufStatus.ABGESCHLOSSEN, gestartetAm: new Date("2026-09-01T08:00:00Z"), abgeschlossenAm: new Date("2026-09-02T15:00:00Z"), }, }); const ergebnis = await testPrisma.schrittergebnis.create({ data: { laufId: meinLauf.id, vorlagenSchrittId: schritt.id, zustand: "ERLEDIGT", notiz: "Nutzerin war unkooperativ, Gerät kam beschädigt zurück", wert: "irgendein Wert", aktualisiertVonId: admin.id, }, }); await testPrisma.kommentar.create({ data: { laufId: meinLauf.id, benutzerId: admin.id, rumpf: "Rückfrage ans Team wegen der Lizenz" }, }); await testPrisma.anhang.create({ data: { schrittergebnisId: ergebnis.id, laufId: meinLauf.id, kundeId: kunde.id, dateiname: "schreibtisch.png", medientyp: "image/png", groesse: 10, pruefsumme: "d".repeat(64), }, }); /* Ein Lauf am Geraet des Kollegen – darf nie auftauchen. */ const fremderLauf = await testPrisma.lauf.create({ data: { vorlagenVersionId: version.id, kundeId: kunde.id, titel: "PC Neuinstallation – NB-099", geraetId: fremdesNotebook.id, mitarbeiterId: kollege.id, status: LaufStatus.IN_ARBEIT, gestartetAm: new Date(), }, }); /* Ein Lauf auf meine Person, ohne Geraet: das Onboarding. */ const persoenlich = await testPrisma.lauf.create({ data: { vorlagenVersionId: version.id, kundeId: kunde.id, titel: "Onboarding Maria Huber", mitarbeiterId: maria.id, status: LaufStatus.ABGESCHLOSSEN, gestartetAm: new Date("2026-08-01T08:00:00Z"), abgeschlossenAm: new Date("2026-08-01T12:00:00Z"), }, }); return { kunde, maria, kollege, meinNotebook, fremdesNotebook, meinLauf, fremderLauf, persoenlich }; } describe("Die Rolle MITARBEITER", () => { it("darf genau die Selbstauskunft und sonst nichts", () => { expect(darf("MITARBEITER", "selbstauskunft")).toBe(true); expect(darf("MITARBEITER", "lesen")).toBe(false); expect(darf("MITARBEITER", "laeufeBearbeiten")).toBe(false); expect(darf("MITARBEITER", "geraeteBearbeiten")).toBe(false); expect(darf("MITARBEITER", "mitarbeiterBearbeiten")).toBe(false); expect(darf("MITARBEITER", "vorlagenBearbeiten")).toBe(false); expect(darf("MITARBEITER", "archivVerwalten")).toBe(false); expect(darf("MITARBEITER", "verwaltung")).toBe(false); }); it("landet nach der Anmeldung auf der eigenen Seite und nicht auf „kein Zugriff“", () => { /* * Beim Durchklicken gefunden: das Standardziel nach der Anmeldung ist `/`, * und die Uebersicht verlangt `lesen`. Ein MITARBEITER sah deshalb als * Erstes „Dafuer fehlt dir die Berechtigung“ — genau die leere Seite, die * dieses Issue vermeiden will. Dieselbe Funktion richtet auch den Knopf auf * `/kein-zugriff`, der vorher im Kreis fuehrte. */ expect(startseite("MITARBEITER")).toBe("/meine-geraete"); for (const rolle of ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"] as const) { expect(startseite(rolle), `${rolle} faengt auf der Uebersicht an`).toBe("/"); } }); it("sieht in der Navigation nur die eine Seite", () => { /* * „Alles andere ist weg, nicht grau“ — ein Eintrag, der auf * `/kein-zugriff` fuehrt, ist schlimmer als keiner. */ const eintraege = navigationFuer("INTERN", (recht) => darf("MITARBEITER", recht)); expect(eintraege.map((e) => e.pfad)).toEqual(["/meine-geraete"]); }); it("nimmt den anderen Rollen die Selbstauskunft nicht weg – sie brauchen sie nicht", () => { for (const rolle of ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"] as const) { const pfade = navigationFuer("INTERN", (recht) => darf(rolle, recht)).map((e) => e.pfad); expect(pfade, `${rolle} sieht die Selbstauskunft nicht`).not.toContain("/meine-geraete"); expect(pfade.length, `${rolle} sieht die gewoehnliche Navigation`).toBeGreaterThan(1); } }); }); describe.runIf(datenbankVerfuegbar)("Was ein Mitarbeiter sieht", () => { beforeEach(async () => { await datenbankLeeren(); }); it("sieht die Läufe seiner Geräte und keine anderen", async () => { const { maria, meinLauf, fremderLauf } = await aufbau(); const geraete = await meineGeraete(maria.id); expect(geraete).toHaveLength(1); expect(geraete[0]?.bezeichnung).toBe("NB-042"); const laufIds = geraete.flatMap((g) => g.laeufe.map((l) => l.id)); expect(laufIds).toEqual([meinLauf.id]); expect(laufIds).not.toContain(fremderLauf.id); }); it("sieht die Läufe zu seiner Person ohne Gerät", async () => { const { maria, persoenlich, meinLauf } = await aufbau(); const ohne = await meineLaeufeOhneGeraet(maria.id); expect(ohne.map((l) => l.id)).toEqual([persoenlich.id]); // Der Lauf am Geraet steht schon unter dem Geraet – hier waere er doppelt. expect(ohne.map((l) => l.id)).not.toContain(meinLauf.id); }); it("sieht keine Schritte, Notizen, Kommentare oder Anhänge – geprüft über die Antwort", async () => { /* * Das Kernkriterium. Geprueft wird die **Antwort** und nicht die Seite: im * Aufbau haengen an dem Lauf eine Notiz („Nutzerin war unkooperativ“), ein * Kommentar, ein Anhang und ein Befehlsschritt mit * `{{generiertes_passwort}}`. Nichts davon darf durchkommen. */ const { maria } = await aufbau(); const antwort = JSON.stringify([ await meineGeraete(maria.id), await meineLaeufeOhneGeraet(maria.id), await meineDaten(maria.id), ]); expect(antwort).not.toContain("unkooperativ"); expect(antwort).not.toContain("Rückfrage ans Team"); expect(antwort).not.toContain("schreibtisch.png"); expect(antwort).not.toContain("New-ADUser"); expect(antwort).not.toContain("{{"); expect(antwort).not.toContain("irgendein Wert"); expect(antwort).not.toContain("ERLEDIGT"); // Und was durchkommen **soll**, kommt durch. expect(antwort).toContain("PC Neuinstallation – NB-042"); expect(antwort).toContain("ABGESCHLOSSEN"); }); it("holt die verbotenen Felder nicht einmal aus der Datenbank", () => { /* * **Gefunden durch eine Gegenprobe, die gruen blieb.** Der Test unten prueft * die Antwort, und die ist sicher: `laufBauen` setzt sie aus den erlaubten * Feldern zusammen, also kommt auch bei einem zu weiten `select` nichts * durch. Genau deshalb blieb er gruen, als im `select` versuchsweise * `ergebnisse: { notiz, wert }` stand — waehrend der Kopfkommentar der Datei * behauptet, das `select` hole es „gar nicht erst". * * Diese Behauptung braucht ihre eigene Deckung. Sie ist nicht bloss Zierde: * ein `select`, das Notizen mitliest, zieht sie durch Protokolle und * Fehlerberichte, und der naechste, der `laufBauen` erweitert, hat sie schon * zur Hand. */ const quelle = readFileSync(join(process.cwd(), "src/server/queries/selbstauskunft.ts"), "utf8"); for (const verboten of ["notiz", "wert", "ergebnisse", "kommentare", "anhaenge", "konfig"]) { expect(quelle, `das Lesemodell darf "${verboten}" nicht abfragen`).not.toContain(`${verboten}: true`); expect(quelle, `das Lesemodell darf "${verboten}" nicht einbinden`).not.toContain(`${verboten}: {`); } }); it("gibt je Lauf genau die erlaubten Felder heraus", async () => { // Namentlich aufgezaehlt: ein zusaetzliches Feld im Lesemodell faellt hier // auf und nicht erst, wenn es auf der Seite steht. const { maria } = await aufbau(); const geraete = await meineGeraete(maria.id); const lauf = geraete[0]?.laeufe[0]; expect(lauf).toBeDefined(); expect(Object.keys(lauf ?? {}).sort()).toEqual([ "abgeschlossenAm", "begonnenAm", "erstelltAm", "id", "status", "titel", "vorlagenName", ]); }); it("zeigt archivierte Geräte weiter – „mein altes Notebook“ ist die Frage", async () => { const { maria, meinNotebook } = await aufbau(); await testPrisma.geraet.update({ where: { id: meinNotebook.id }, data: { archiviert: true } }); const geraete = await meineGeraete(maria.id); expect(geraete).toHaveLength(1); expect(geraete[0]?.archiviert).toBe(true); }); it("liefert die eigenen Stammdaten, aber keine fremden", async () => { const { maria, kollege } = await aufbau(); const daten = await meineDaten(maria.id); expect(daten).toMatchObject({ anzeigename: "Maria Huber", abteilung: "Buchhaltung" }); // Die Funktion nimmt eine Kennung – mit der des Kollegen bekaeme man dessen // Daten. Genau deshalb kommt sie aus der Sitzung und nie aus einer Eingabe. const fremd = await meineDaten(kollege.id); expect(fremd?.anzeigename).toBe("Ali Demir"); }); it("gibt für jemanden ohne Geräte eine leere Liste und keinen Fehler", async () => { const { kollege, fremdesNotebook } = await aufbau(); await testPrisma.lauf.deleteMany({ where: { geraetId: fremdesNotebook.id } }); await testPrisma.geraet.delete({ where: { id: fremdesNotebook.id } }); expect(await meineGeraete(kollege.id)).toEqual([]); expect(await meineLaeufeOhneGeraet(kollege.id)).toEqual([]); }); }); describe.runIf(datenbankVerfuegbar)("Zuordnung bei der Anmeldung", () => { beforeEach(async () => { await datenbankLeeren(); process.env = { ...UMGEBUNG }; // Zuordnung aktiv – sonst greift die Selbstauskunft absichtlich gar nicht. process.env["OIDC_GROUP_ADMIN"] = "lz-admin"; process.env["OIDC_GROUP_TECHNICIAN"] = "lz-technik"; }); it("macht aus einer Anmeldung ohne Rollengruppe einen MITARBEITER", async () => { const { maria } = await aufbau(); const benutzer = await benutzerAusOidcUebernehmen(anspruecke()); expect(benutzer.rolle).toBe("MITARBEITER"); expect(benutzer.mitarbeiterId).toBe(maria.id); }); it("ordnet unabhängig von Groß- und Kleinschreibung zu", async () => { const { maria } = await aufbau(); const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ upn: "MHuber@Firma.Local" })); expect(benutzer.mitarbeiterId).toBe(maria.id); }); it("lässt eine echte Rollengruppe vorgehen und ordnet dann keine Person zu", async () => { await aufbau(); const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-technik"] })); expect(benutzer.rolle).toBe("TECHNIKER"); // Wer eine echte Rolle hat, sieht ueber `lesen` ohnehin alles – eine // Zuordnung daneben waere ein zweiter Weg zu denselben Daten. expect(benutzer.mitarbeiterId).toBeNull(); }); it("weist ab, wenn es zu dem UPN keine Person gibt", async () => { await aufbau(); await expect(benutzerAusOidcUebernehmen(anspruecke({ upn: "niemand@firma.local" }))).rejects.toThrow( AnmeldeFehler, ); // Und kein halb angelegtes Konto hinterlassen. expect(await testPrisma.benutzer.count({ where: { email: "maria.huber@firma.example" } })).toBe(0); }); it("nennt den Grund, damit die Anmeldeseite etwas Hilfreiches sagen kann", async () => { await aufbau(); try { await benutzerAusOidcUebernehmen(anspruecke({ upn: "niemand@firma.local" })); throw new Error("haette werfen muessen"); } catch (fehler) { expect(fehler).toBeInstanceOf(AnmeldeFehler); expect((fehler as AnmeldeFehler).grund).toBe("keine-person"); } }); it("weist einen mehrdeutigen UPN ab und protokolliert ihn", async () => { const { kunde } = await aufbau(); // Zwei Personen mit demselben UPN – etwa zwei Mandanten derselben Firma. const zweiter = await testPrisma.kunde.create({ data: { name: "Zweite GmbH", kuerzel: "ZWEI" } }); await testPrisma.mitarbeiter.create({ data: { kundeId: zweiter.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber (zweite)", upn: "mhuber@firma.local", }, }); expect(kunde.id).toBeTruthy(); try { await benutzerAusOidcUebernehmen(anspruecke()); throw new Error("haette werfen muessen"); } catch (fehler) { expect((fehler as AnmeldeFehler).grund).toBe("upn-mehrdeutig"); } const protokoll = await testPrisma.protokoll.findMany({ where: { aktion: { contains: "mehrdeutig" } }, }); expect(protokoll).toHaveLength(1); // 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 () => { const { maria } = await aufbau(); await testPrisma.mitarbeiter.update({ where: { id: maria.id }, data: { aktiv: false } }); await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toThrow(AnmeldeFehler); }); it("greift gar nicht, solange keine Gruppen-Zuordnung eingerichtet ist", async () => { /* * Die Wache gegen ein Update, das Leute aussperrt. Ohne abgebildete Gruppen * kann die Anwendung nicht unterscheiden, ob jemand Techniker oder * Mitarbeiter ist — dann bleibt alles wie vor #035. */ delete process.env["OIDC_GROUP_ADMIN"]; delete process.env["OIDC_GROUP_TECHNICIAN"]; await aufbau(); const benutzer = await benutzerAusOidcUebernehmen(anspruecke()); expect(benutzer.rolle).toBe("LESER"); expect(benutzer.mitarbeiterId).toBeNull(); }); it("stuft bei der nächsten Anmeldung um, wenn jemand eine Rollengruppe bekommt", async () => { const { maria } = await aufbau(); const erst = await benutzerAusOidcUebernehmen(anspruecke()); expect(erst.rolle).toBe("MITARBEITER"); expect(erst.mitarbeiterId).toBe(maria.id); const dann = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] })); expect(dann.rolle).toBe("ADMIN"); // Die Zuordnung muss **mit** verschwinden, sonst verletzt sie den CHECK. expect(dann.mitarbeiterId).toBeNull(); }); }); describe.runIf(datenbankVerfuegbar)("Was die Datenbank selbst festhält", () => { beforeEach(async () => { await datenbankLeeren(); }); it("lässt die Rolle MITARBEITER nicht ohne Zuordnung zu", async () => { await expect( testPrisma.benutzer.create({ data: { email: "ohne@firma.example", name: "Ohne Person", rolle: "MITARBEITER" }, }), ).rejects.toThrow(); }); it("lässt eine Zuordnung nicht an einer anderen Rolle zu", async () => { const { maria } = await aufbau(); await expect( testPrisma.benutzer.create({ data: { email: "admin2@firma.example", name: "Admin mit Person", rolle: "ADMIN", mitarbeiterId: maria.id, }, }), ).rejects.toThrow(); }); it("gibt eine Person an höchstens ein Konto", async () => { const { maria } = await aufbau(); await testPrisma.benutzer.create({ data: { email: "maria@firma.example", name: "Maria Huber", rolle: "MITARBEITER", mitarbeiterId: maria.id, }, }); await expect( testPrisma.benutzer.create({ data: { email: "maria.zweitkonto@firma.example", name: "Maria Huber (zweites Konto)", rolle: "MITARBEITER", mitarbeiterId: maria.id, }, }), ).rejects.toThrow(); }); it("zieht die Rolle mit, wenn die Person gelöscht wird", async () => { /* * `ON DELETE SET NULL` allein wuerde am CHECK scheitern: ein MITARBEITER * ohne Zuordnung ist verboten. Der Trigger setzt deshalb beides zugleich — * sonst waere das Loeschen eines Mitarbeiters unmoeglich statt folgerichtig. */ const { kunde } = await aufbau(); const person = await testPrisma.mitarbeiter.create({ data: { kundeId: kunde.id, vorname: "Weg", nachname: "Damit", anzeigename: "Weg Damit", upn: "wdamit@firma.local", }, }); const konto = await testPrisma.benutzer.create({ data: { email: "weg@firma.example", name: "Weg Damit", rolle: "MITARBEITER", mitarbeiterId: person.id, }, }); await testPrisma.mitarbeiter.delete({ where: { id: person.id } }); const nachher = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: konto.id } }); expect(nachher.rolle).toBe("LESER"); expect(nachher.mitarbeiterId).toBeNull(); // Das Konto bleibt – nur ohne Person. expect(nachher.aktiv).toBe(true); }); });