import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Betriebsmodus, GeraeteTyp, Herkunft, VersionStatus } from "@/generated/prisma/enums"; import { entschluesseln } from "@/lib/geheimnisse"; import { istTagCode } from "@/lib/tagcode"; import { platzhalterDerStruktur } from "@/lib/vorlagen-format"; import { unbekanntePlatzhalter, unbenutzteVariablen } from "@/lib/platzhalter"; import { demoKundenSaeen, DEMO_KUNDEN } from "../prisma/seed-daten/dienstleistung"; import { adminAnlegen, einstellungAnlegen, GESPERRTE_OU, modusLesen, namensregelnFuerModus, } from "../prisma/seed-daten/grundbestand"; import { ABTEILUNGEN, beispielQuelleSaeen, NB_TAG, OFFENE_TAGS, PC_TAG, PERSONEN, stammdatenSaeen, } from "../prisma/seed-daten/stammdaten"; import { TYP_VORGABEN, typVorgabenSaeen, vorlagenLesen, vorlagenSaeen } from "../prisma/seed-daten/vorlagen"; import { befehlFuellen } from "@/server/services/befehle"; import { platzhalterWerteDesLaufs } from "@/server/services/platzhalter"; import { namenVorschlagen } from "@/server/services/befehle"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Der Seed (#017). * * Zwei Arten von Pruefungen, und die Trennung ist Absicht: * * 1. **Ohne Datenbank**: die sieben Vorlagendateien selbst. Sie sind der * fachliche Kern des Auftrags — dass jeder Platzhalter eine Variable hat, dass * der `New-ADUser`-Befehl vollstaendig ist, dass nichts doppelt heisst. * 2. **Mit Datenbank**: dass ein zweiter Seed-Lauf nichts aendert. Das ist die * Zusage, die am leichtesten kaputtgeht — ein `create` ohne vorherige Pruefung * genuegt, und beim dritten Lauf stehen einundzwanzig Vorlagen in der Liste. */ const VORLAGEN = vorlagenLesen(); /** Jede Zeichenkette eines Dokuments mit ihrem Pfad – fuer Pruefungen ueber alle Texte. */ function* texteDurchlaufen(wert: unknown, pfad = ""): Generator { if (typeof wert === "string") { yield [pfad, wert]; return; } if (Array.isArray(wert)) { for (const [index, eintrag] of wert.entries()) yield* texteDurchlaufen(eintrag, `${pfad}[${index}]`); return; } if (typeof wert === "object" && wert !== null) { for (const [name, eintrag] of Object.entries(wert)) { yield* texteDurchlaufen(eintrag, pfad ? `${pfad}.${name}` : name); } } } describe("Die sieben Vorlagen", () => { it("sind vollzaehlig und heissen jede anders", () => { expect(VORLAGEN).toHaveLength(7); const namen = VORLAGEN.map((eintrag) => eintrag.dokument.name); expect(new Set(namen).size).toBe(7); expect(namen).toEqual([ "PC Neuinstallation", "Mitarbeiter-Onboarding", "Mitarbeiter-Offboarding", "Server-Inbetriebnahme Windows-VM", "Firewall-Rollout OPNsense", "Monatlicher Patchday", "Backup-Restore-Test", ]); }); it("haben Abschnitte und Schritte, und kein Abschnitt ist leer", () => { for (const { datei, dokument } of VORLAGEN) { expect(dokument.struktur.abschnitte.length, datei).toBeGreaterThan(0); for (const abschnitt of dokument.struktur.abschnitte) { expect(abschnitt.schritte.length, `${datei} / ${abschnitt.titel}`).toBeGreaterThan(0); } } }); it("tragen keine Nummer im Abschnittstitel", () => { // Die Nummer kommt aus der Position und wird nie gespeichert; stuende sie im // Titel, hiesse der erste Abschnitt nach dem Umsortieren „01 · 02 · …“. for (const { datei, dokument } of VORLAGEN) { for (const abschnitt of dokument.struktur.abschnitte) { expect(abschnitt.titel, datei).not.toMatch(/^\d+\s*·/u); } } }); it("benutzen nur Platzhalter, die es gibt", () => { for (const { datei, dokument } of VORLAGEN) { const verwendet = platzhalterDerStruktur(dokument.struktur); const schluessel = dokument.struktur.variablen.map((variable) => variable.schluessel); expect(unbekanntePlatzhalter(verwendet, schluessel), datei).toEqual([]); } }); it("nennt jede unbenutzte Variable – damit sie eine Entscheidung ist", () => { /* * Eine Variable, die in keinem Schritt vorkommt, wird beim Start abgefragt * und verschwindet dann. Genau eine bleibt so stehen: `vmid` in der * Server-Vorlage, weil der Auftrag sie nennt und in keinem Schritt braucht — * die Kennung der VM im Hypervisor ist beim Anlegen nun einmal bekannt und * taucht in keinem Befehl auf. Der Test haelt fest, dass es **diese eine** * bleibt; eine weitere waere ein Versehen (`eintrittsdatum` war eines und * steht jetzt im Hilfetext des ersten Schritts). */ const unbenutzt = VORLAGEN.flatMap(({ dokument }) => unbenutzteVariablen( platzhalterDerStruktur(dokument.struktur), dokument.struktur.variablen.map((variable) => variable.schluessel), ).map((schluessel) => `${dokument.name}: ${schluessel}`), ); expect(unbenutzt).toEqual(["Server-Inbetriebnahme Windows-VM: vmid"]); }); it("hat im Onboarding den vollstaendigen New-ADUser-Befehl", () => { // Wortgetreu aus dem Auftrag und ein Abnahmekriterium: ohne `-Path` legt der // Befehl das Konto im Standard-Container an, nicht in der Abteilungs-OU. const onboarding = VORLAGEN.find((eintrag) => eintrag.dokument.name === "Mitarbeiter-Onboarding"); const befehle = (onboarding?.dokument.struktur.abschnitte ?? []) .flatMap((abschnitt) => abschnitt.schritte) .filter((schritt) => schritt.typ === "BEFEHL") .map((schritt) => String(schritt.konfig["befehl"] ?? "")); const anlegen = befehle.find((befehl) => befehl.includes("New-ADUser")); expect(anlegen).toBeDefined(); for (const teil of [ '-Name "{{vorname}} {{nachname}}"', '-SamAccountName "{{sam}}"', '-UserPrincipalName "{{upn}}"', '-EmailAddress "{{mail}}"', '-Department "{{abteilung}}"', '-Path "{{ou}}"', "{{generiertes_passwort}}", "-ChangePasswordAtLogon $true", ]) { expect(anlegen, teil).toContain(teil); } const gruppen = befehle.find((befehl) => befehl.includes("Add-ADGroupMember")); expect(gruppen).toContain('"{{gruppen}}".Split(",")'); expect(gruppen).toContain('-Members "{{sam}}"'); }); it("enthaelt beide Konto-Abschnitte des Onboardings", () => { // Bedingte Abschnitte kommen erst in Phase 3; bis dahin wird der nicht // zutreffende als Ganzes auf „nicht zutreffend“ gesetzt. const onboarding = VORLAGEN.find((eintrag) => eintrag.dokument.name === "Mitarbeiter-Onboarding"); const titel = (onboarding?.dokument.struktur.abschnitte ?? []).map((abschnitt) => abschnitt.titel); expect(titel).toContain("Konto anlegen (Active Directory)"); expect(titel).toContain("Konto anlegen (Entra ID)"); }); it("speichert nirgends ein Passwort im Klartext", () => { /* * `{{generiertes_passwort}}` ist erlaubt – es ist ein Platzhalter, der je * Anzeige neu entsteht. Ein ausgeschriebenes Passwort waere es nicht. * * Geprueft wird jede **Zeichenkette einzeln** und nicht das JSON am Stueck: * in `JSON.stringify` steht vor jedem Anfuehrungszeichen ein Backslash, und * der Ausdruck lief daran vorbei. Ein eingebautes Testpasswort blieb * unbemerkt — die Zusicherung konnte gar nicht scheitern. */ // Auch ohne Anfuehrungszeichen: „Das Initialpasswort: Sommer2026! eintragen“ // waere genauso ein Klartextpasswort wie eines in Gaensefuesschen. const verdaechtig = /passwort[a-zäöüß]*\s*[:=]\s*\\?["'„]?\S{6,}/iu; for (const { datei, dokument } of VORLAGEN) { for (const [pfad, wert] of texteDurchlaufen(dokument)) { const ohnePlatzhalter = wert.replaceAll("{{generiertes_passwort}}", ""); expect(ohnePlatzhalter, `${datei} / ${pfad}`).not.toMatch(verdaechtig); } } }); it("nennt zu jedem Schritt einen Hilfetext", () => { // Der Auftrag verlangt die genannten Hilfetexte und kurze eigene fuer die // uebrigen. Ein Schritt ohne Hilfe ist im Zweifel der, bei dem jemand raet. for (const { datei, dokument } of VORLAGEN) { for (const abschnitt of dokument.struktur.abschnitte) { for (const schritt of abschnitt.schritte) { expect(schritt.hilfetext?.trim(), `${datei} / ${schritt.titel}`).toBeTruthy(); } } } }); it("gibt jedem Befehlsschritt eine Shell und einen Befehl", () => { for (const { datei, dokument } of VORLAGEN) { for (const abschnitt of dokument.struktur.abschnitte) { for (const schritt of abschnitt.schritte) { if (schritt.typ !== "BEFEHL") continue; expect(schritt.konfig["shell"], `${datei} / ${schritt.titel}`).toBeTruthy(); expect(String(schritt.konfig["befehl"] ?? "").trim(), datei).not.toBe(""); } } } }); it("gibt jedem Auswahlschritt Optionen", () => { for (const { datei, dokument } of VORLAGEN) { for (const abschnitt of dokument.struktur.abschnitte) { for (const schritt of abschnitt.schritte) { if (schritt.typ !== "AUSWAHL") continue; const optionen = schritt.konfig["optionen"]; expect(Array.isArray(optionen) && optionen.length > 0, `${datei} / ${schritt.titel}`).toBe(true); } } } }); }); describe("Die Tag-Codes des Seeds", () => { it("haben die richtige Form und kommen nur einmal vor", () => { const alle = [ PC_TAG, NB_TAG, ...OFFENE_TAGS, // Der Vollbestands-Kunde benutzt PC_TAG und NB_TAG; nur der kleine Satz // bringt einen eigenen Code mit. ...DEMO_KUNDEN.flatMap((kunde) => (kunde.pcTag === undefined ? [] : [kunde.pcTag])), ]; for (const code of alle) expect(istTagCode(code), code).toBe(true); expect(new Set(alle).size).toBe(alle.length); }); }); describe("Der Betriebsmodus aus der Umgebung", () => { const vorher = process.env["SEED_MODE"]; afterEach(() => { if (vorher === undefined) delete process.env["SEED_MODE"]; else process.env["SEED_MODE"] = vorher; }); it("nimmt INTERN als Vorgabe", () => { delete process.env["SEED_MODE"]; expect(modusLesen()).toBe(Betriebsmodus.INTERN); }); it("kennt SERVICE als Alias für DIENSTLEISTUNG", () => { // Der Auftrag dokumentiert `SEED_MODE=SERVICE`; der Alias steht auch in // CLAUDE.md. Ohne ihn liefe der dort genannte Befehl ins Leere. for (const wert of ["SERVICE", "service", " Dienstleistung "]) { process.env["SEED_MODE"] = wert; expect(modusLesen(), wert).toBe(Betriebsmodus.DIENSTLEISTUNG); } }); it("weist einen unbekannten Wert ab, statt still INTERN zu nehmen", () => { process.env["SEED_MODE"] = "KUNDE"; expect(() => modusLesen()).toThrow(/INTERN/u); }); it("gibt dem Modus DIENSTLEISTUNG die Hostname-Regel mit Kundenkürzel", () => { expect(namensregelnFuerModus(Betriebsmodus.INTERN).hostname).toBe("PC-{nnn}"); expect(namensregelnFuerModus(Betriebsmodus.DIENSTLEISTUNG).hostname).toBe("{kundenkuerzel}-PC-{nnn}"); }); }); describe.runIf(datenbankVerfuegbar)("Seed gegen die Datenbank", () => { beforeEach(datenbankLeeren); /** Die Einstellung samt Namensregeln, so wie der Seed sie anlegt. */ async function einstellungSetzen() { await testPrisma.einstellung.deleteMany({}); await einstellungAnlegen(testPrisma, Betriebsmodus.INTERN); const zeile = await testPrisma.einstellung.findUniqueOrThrow({ where: { id: "global" } }); expect(zeile.gesperrteOu).toBe(GESPERRTE_OU); return zeile; } async function eigeneOrganisation() { const kunde = await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } }); return kunde; } it("legt alle sieben als veroeffentlichte Version 1 an", async () => { const gesaet = await vorlagenSaeen(testPrisma); expect(gesaet).toHaveLength(7); expect(gesaet.every((eintrag) => eintrag.neu)).toBe(true); const versionen = await testPrisma.vorlagenVersion.findMany(); expect(versionen).toHaveLength(7); for (const version of versionen) { expect(version.version).toBe(1); expect(version.status).toBe(VersionStatus.VEROEFFENTLICHT); expect(version.veroeffentlichtAm).toBeInstanceOf(Date); // Kein Mensch hat veroeffentlicht – und keine erfundene Kennung. expect(version.veroeffentlichtVonId).toBeNull(); } expect(await testPrisma.vorlagenAbschnitt.count()).toBe( VORLAGEN.reduce((summe, eintrag) => summe + eintrag.dokument.struktur.abschnitte.length, 0), ); expect(await testPrisma.vorlagenSchritt.count()).toBe( VORLAGEN.reduce( (summe, eintrag) => summe + eintrag.dokument.struktur.abschnitte.reduce((s, abschnitt) => s + abschnitt.schritte.length, 0), 0, ), ); }); it("aendert beim zweiten Lauf nichts", async () => { const erst = await vorlagenSaeen(testPrisma); const zweit = await vorlagenSaeen(testPrisma); expect(zweit.every((eintrag) => !eintrag.neu)).toBe(true); // Dieselben Zeilen, nicht nur dieselbe Anzahl. expect(zweit.map((eintrag) => eintrag.versionId)).toEqual(erst.map((eintrag) => eintrag.versionId)); expect(await testPrisma.vorlage.count()).toBe(7); expect(await testPrisma.vorlagenVersion.count()).toBe(7); }); it("laesst eine bearbeitete Vorlage in Ruhe", async () => { // Der Fall, der den Unterschied macht: jemand hat die Vorlage im Betrieb // bearbeitet und eine zweite Version veroeffentlicht. Ein Seed-Lauf danach // darf weder eine dritte anlegen noch die Bearbeitung zuruecknehmen. const [erste] = await vorlagenSaeen(testPrisma); if (!erste) throw new Error("Der Seed hat keine Vorlage angelegt."); await testPrisma.vorlagenVersion.create({ data: { vorlageId: erste.vorlageId, version: 2, status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date(), aenderungsnotiz: "Von Hand angepasst", }, }); await vorlagenSaeen(testPrisma); expect(await testPrisma.vorlage.count()).toBe(7); expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId: erste.vorlageId } })).toBe(2); }); it("erkennt eine umbenannte Vorlage am Schluessel und legt keine zweite an", async () => { // Der Fall, fuer den das Feld da ist (#021). Vorher erkannte der Seed seine // Vorlagen am Namen: wer eine umbenannte, bekam beim naechsten Lauf eine // zweite - und der Schnellstart stand auf der Geraeteseite doppelt. const [erste] = await vorlagenSaeen(testPrisma); if (!erste) throw new Error("Der Seed hat keine Vorlage angelegt."); await testPrisma.vorlage.update({ where: { id: erste.vorlageId }, data: { name: "Von Hand umbenannt" }, }); const zweit = await vorlagenSaeen(testPrisma); expect(zweit.every((eintrag) => !eintrag.neu)).toBe(true); expect(await testPrisma.vorlage.count()).toBe(7); // Und der Name bleibt der, den jemand gewaehlt hat. const nachher = await testPrisma.vorlage.findUniqueOrThrow({ where: { id: erste.vorlageId } }); expect(nachher.name).toBe("Von Hand umbenannt"); }); it("legt zu einer umbenannten Vorlage keine zweite Schnellstart-Zeile an", async () => { const gesaet = await vorlagenSaeen(testPrisma); await typVorgabenSaeen(testPrisma, gesaet); const vorher = await testPrisma.geraetetypVorlage.count(); // „PC Neuinstallation“ steht im Schnellstart fuer PC und NOTEBOOK - genau die // Zeilen, die sich vorher verdoppelt haben. const pc = gesaet.find((eintrag) => eintrag.name === "PC Neuinstallation"); if (!pc) throw new Error("Die PC-Vorlage fehlt im Seed."); await testPrisma.vorlage.update({ where: { id: pc.vorlageId }, data: { name: "Arbeitsplatz neu aufsetzen" }, }); const nochmal = await vorlagenSaeen(testPrisma); expect(await typVorgabenSaeen(testPrisma, nochmal)).toBe(0); expect(await testPrisma.geraetetypVorlage.count()).toBe(vorher); }); it("traegt den Schluessel bei einer Installation nach, die ihn noch nicht hat", async () => { // Eine Installation von vor diesem Feld: die Zeilen stehen, der Schluessel // fehlt. Ohne den Rueckfall auf den Namen bekaeme sie beim ersten Lauf // danach genau die Dubletten, die das Feld verhindern soll. const gesaet = await vorlagenSaeen(testPrisma); await testPrisma.vorlage.updateMany({ data: { seedSchluessel: null } }); expect(await testPrisma.vorlage.count({ where: { seedSchluessel: null } })).toBe(7); const zweit = await vorlagenSaeen(testPrisma); expect(zweit.every((eintrag) => !eintrag.neu)).toBe(true); expect(await testPrisma.vorlage.count()).toBe(7); expect(await testPrisma.vorlage.count({ where: { seedSchluessel: null } })).toBe(0); expect(zweit.map((e) => e.vorlageId).sort()).toEqual(gesaet.map((e) => e.vorlageId).sort()); }); it("verknuepft die Schnellstart-Vorgaben mit den richtigen Vorlagen", async () => { const gesaet = await vorlagenSaeen(testPrisma); const angelegt = await typVorgabenSaeen(testPrisma, gesaet); expect(angelegt).toBe(TYP_VORGABEN.reduce((summe, eintrag) => summe + eintrag.vorlagen.length, 0)); const fuerServer = await testPrisma.geraetetypVorlage.findMany({ where: { geraeteTyp: GeraeteTyp.SERVER }, include: { vorlage: true }, orderBy: { position: "asc" }, }); expect(fuerServer.map((eintrag) => eintrag.vorlage.name)).toEqual([ "Server-Inbetriebnahme Windows-VM", "Backup-Restore-Test", ]); // Zweiter Lauf: keine Dubletten. expect(await typVorgabenSaeen(testPrisma, gesaet)).toBe(0); }); it("laesst den Schnellstart in Ruhe, sobald er in der Oberflaeche gepflegt wurde", async () => { // Der Fall aus #022: wer eine Kachel entfernt, soll sie nicht beim naechsten // Seed-Lauf zurueckbekommen. Eine fehlende Zeile sagt nicht, ob sie noch nie // da war oder bewusst entfernt wurde — die Marke sagt es. const gesaet = await vorlagenSaeen(testPrisma); expect(await typVorgabenSaeen(testPrisma, gesaet)).toBeGreaterThan(0); await testPrisma.geraetetypVorlage.deleteMany({ where: { geraeteTyp: GeraeteTyp.PC } }); // upsert, weil dieser Block die Einstellung nicht selbst anlegt - und genau // deshalb belegt der Test auch, dass typVorgabenSaeen ohne sie zurechtkommt. await testPrisma.einstellung.upsert({ where: { id: "global" }, create: { id: "global", schnellstartGepflegt: true }, update: { schnellstartGepflegt: true }, }); expect(await typVorgabenSaeen(testPrisma, gesaet)).toBe(0); expect(await testPrisma.geraetetypVorlage.count({ where: { geraeteTyp: GeraeteTyp.PC } })).toBe(0); }); it("legt die Vorgaben weiter an, solange niemand sie gepflegt hat", async () => { const gesaet = await vorlagenSaeen(testPrisma); await typVorgabenSaeen(testPrisma, gesaet); const vorher = await testPrisma.geraetetypVorlage.count({ where: { geraeteTyp: GeraeteTyp.PC } }); expect(vorher).toBeGreaterThan(0); await testPrisma.geraetetypVorlage.deleteMany({ where: { geraeteTyp: GeraeteTyp.PC } }); expect(await typVorgabenSaeen(testPrisma, gesaet)).toBe(vorher); }); it("legt Abteilungen, Personen, Geraete und freie Tags an", async () => { const eigene = await eigeneOrganisation(); const bestand = await stammdatenSaeen(testPrisma, eigene.id); expect(bestand).toEqual({ abteilungen: ABTEILUNGEN.length, personen: PERSONEN.length, geraete: 2, offeneTags: OFFENE_TAGS.length, }); const buchhaltung = await testPrisma.abteilung.findFirstOrThrow({ where: { name: "Buchhaltung" } }); expect(buchhaltung.zielOu).toBe("OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local"); expect(buchhaltung.standardGruppen).toEqual(["G-Buchhaltung", "G-Alle"]); const huber = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" } }); expect(huber.upn).toBe("m.huber@firma.example"); expect(huber.abteilung).toBe("Buchhaltung"); expect(huber.herkunft).toBe(Herkunft.MANUELL); // Von Hand angelegt heisst: keine Quelle und keine Verzeichniskennung. expect(huber.quelleId).toBeNull(); expect(huber.externeId).toBeNull(); const pc = await testPrisma.geraet.findUniqueOrThrow({ where: { tagCode: PC_TAG } }); expect(pc.hostname).toBe("PC-001"); expect(pc.typ).toBe(GeraeteTyp.PC); expect(pc.mitarbeiterId).toBe(huber.id); expect(pc.registriertAm).toBeInstanceOf(Date); // Die freien Tags sind Aufkleber ohne Geraet: kein Name, kein Datum. const frei = await testPrisma.geraet.findMany({ where: { registriertAm: null } }); expect(frei).toHaveLength(OFFENE_TAGS.length); expect(frei.every((tag) => tag.hostname === null && tag.bezeichnung === null)).toBe(true); // Jeder vergebene Code steht im Vergabebuch – auch der eines freien Tags. expect(await testPrisma.tagCodeVergabe.count()).toBe(OFFENE_TAGS.length + 2); }); it("macht Petra Klein zur Vorgesetzten von Maria Huber", async () => { // Steht so im Auftrag. Ging vor #020 nicht: eine von Hand angelegte Person // konnte keinen Vorgesetzten haben. const eigene = await eigeneOrganisation(); await stammdatenSaeen(testPrisma, eigene.id); const maria = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" }, include: { vorgesetzter: { select: { anzeigename: true, sam: true } } }, }); expect(maria.vorgesetzter).toEqual({ anzeigename: "Klein, Petra", sam: "pklein" }); }); it("gibt eine im Betrieb entfernte Vorgesetzten-Zuordnung nicht zurueck", async () => { // Dieselbe Regel wie fuer alles andere im Seed: er legt an, er korrigiert // nicht. const eigene = await eigeneOrganisation(); await stammdatenSaeen(testPrisma, eigene.id); const maria = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" } }); await testPrisma.mitarbeiter.update({ where: { id: maria.id }, data: { vorgesetzterId: null }, }); await stammdatenSaeen(testPrisma, eigene.id); const nachher = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { id: maria.id } }); expect(nachher.vorgesetzterId).toBeNull(); }); it("legt den Bestand beim zweiten Lauf nicht noch einmal an", async () => { const eigene = await eigeneOrganisation(); await stammdatenSaeen(testPrisma, eigene.id); const zweit = await stammdatenSaeen(testPrisma, eigene.id); expect(zweit).toEqual({ abteilungen: 0, personen: 0, geraete: 0, offeneTags: 0 }); expect(await testPrisma.mitarbeiter.count()).toBe(PERSONEN.length); expect(await testPrisma.geraet.count()).toBe(OFFENE_TAGS.length + 2); expect(await testPrisma.tagCodeVergabe.count()).toBe(OFFENE_TAGS.length + 2); }); it("legt die Beispielquelle deaktiviert und mit verschluesseltem Passwort an", async () => { const eigene = await eigeneOrganisation(); expect(await beispielQuelleSaeen(testPrisma, eigene.id)).toBe(true); expect(await beispielQuelleSaeen(testPrisma, eigene.id)).toBe(false); const quelle = await testPrisma.verzeichnisquelle.findFirstOrThrow(); expect(quelle.aktiv).toBe(false); expect(quelle.url.startsWith("ldaps://")).toBe(true); // Das Versionspraefix prueft auch die Datenbank; hier zaehlt, dass sich das // Feld ueberhaupt entschluesseln laesst und nichts Lesbares enthaelt. expect(quelle.bindPasswortVerschluesselt).toMatch(/^v[0-9]+\./u); expect(entschluesseln(quelle.bindPasswortVerschluesselt).length).toBeGreaterThan(16); }); it("erzeugt im Modus Dienstleistung die beiden Demo-Kunden mit MUS-PC-001", async () => { await eigeneOrganisation(); const kunden = await demoKundenSaeen(testPrisma); expect(kunden.map((kunde) => kunde.kuerzel)).toEqual(["MUS", "REI"]); expect(kunden[0]?.hostname).toBe("MUS-PC-001"); /* * **Stattdessen, nicht zusätzlich.** Der Beispielaufkleber `LZ-7K3F9Q` führt * in diesem Modus auf `MUS-PC-001` — genau das verlangt das * Abnahmekriterium des Auftrags. Läge er an der eigenen Organisation, liefe * der Hauptablauf gegen die falsche Zeile (Reviewbefund zu #017). */ const beispiel = await testPrisma.geraet.findUniqueOrThrow({ where: { tagCode: PC_TAG }, include: { kunde: true, mitarbeiter: true }, }); expect(beispiel.hostname).toBe("MUS-PC-001"); expect(beispiel.kunde.kuerzel).toBe("MUS"); expect(beispiel.kunde.istEigeneOrganisation).toBe(false); expect(beispiel.mitarbeiter?.sam).toBe("mhuber"); // Die Bäckerei bekommt den kleinen Satz und einen eigenen Code. const reiter = await testPrisma.geraet.findFirstOrThrow({ where: { hostname: "REI-PC-001" } }); expect(reiter.tagCode).not.toBe(PC_TAG); expect(await testPrisma.mitarbeiter.count({ where: { kundeId: reiter.kundeId } })).toBe(1); // Die eigene Organisation bleibt in diesem Modus ohne Beispielbestand. const eigene = await testPrisma.kunde.findFirstOrThrow({ where: { istEigeneOrganisation: true } }); expect(await testPrisma.geraet.count({ where: { kundeId: eigene.id } })).toBe(0); expect(await testPrisma.mitarbeiter.count({ where: { kundeId: eigene.id } })).toBe(0); const zweit = await demoKundenSaeen(testPrisma); expect(zweit.every((eintrag) => !eintrag.neu)).toBe(true); expect(await testPrisma.kunde.count()).toBe(3); // Zwei Beispielgeräte und fünf freie Tags bei MUS, eines bei REI. expect(await testPrisma.geraet.count()).toBe(OFFENE_TAGS.length + 3); }); it("lässt einen echten Kunden mit demselben Kürzel in Ruhe", async () => { /* * Ein Haus, das schon einen Kunden „Muster und Söhne KG“ mit dem Kürzel MUS * betreut, bekommt keine erfundene Person, keine erfundene Ziel-OU und kein * Gerät in seinen Bestand geschrieben (Reviewbefund zu #017). */ await eigeneOrganisation(); const echt = await testPrisma.kunde.create({ data: { name: "Muster und Söhne KG", kuerzel: "MUS", istEigeneOrganisation: false }, }); const kunden = await demoKundenSaeen(testPrisma); expect(kunden.map((kunde) => kunde.kuerzel)).toEqual(["REI"]); expect(await testPrisma.mitarbeiter.count({ where: { kundeId: echt.id } })).toBe(0); expect(await testPrisma.geraet.count({ where: { kundeId: echt.id } })).toBe(0); expect(await testPrisma.abteilung.count({ where: { kundeId: echt.id } })).toBe(0); }); it("löst den New-ADUser-Befehl mit echten Werten auf", async () => { /* * Der eine Test, der dem Abnahmekriterium wirklich entspricht: nicht der * Rohtext mit `-Path "{{ou}}"`, sondern der Befehl, den der Techniker * kopiert. Er hätte zwei Befunde des Reviews zu #017 sofort gezeigt — das * zweite, abweichende Passwort im Hilfetext und die Ziel-OU, die niemand * vorbelegt. */ const eigene = await eigeneOrganisation(); await stammdatenSaeen(testPrisma, eigene.id); const vorlagen = await vorlagenSaeen(testPrisma); const onboarding = vorlagen.find((eintrag) => eintrag.name === "Mitarbeiter-Onboarding"); if (!onboarding) throw new Error("Die Onboarding-Vorlage fehlt."); const huber = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" } }); const abteilung = await testPrisma.abteilung.findFirstOrThrow({ where: { name: "Buchhaltung" } }); const lauf = await testPrisma.lauf.create({ data: { vorlagenVersionId: onboarding.versionId, kundeId: eigene.id, titel: "Onboarding Lisa Bauer", mitarbeiterId: huber.id, abteilungId: abteilung.id, // Die Ziel-OU bleibt **leer** – so, wie der Start-Dialog sie abschickt. // Sie kommt dann aus der Abteilung. variablen: { konto_typ: "Active Directory", vorname: "Lisa", nachname: "Bauer", upn: "l.bauer@firma.example", sam: "lbauer", mail: "l.bauer@firma.example", abteilung: "Buchhaltung", eintrittsdatum: "2026-10-01", }, }, }); const werte = await platzhalterWerteDesLaufs({ laufId: lauf.id }); const schritt = await testPrisma.vorlagenSchritt.findFirstOrThrow({ where: { abschnitt: { versionId: onboarding.versionId }, titel: "Konto anlegen", typ: "BEFEHL" }, orderBy: { position: "asc" }, }); const konfig = schritt.konfig as { shell: "powershell"; befehl: string }; const passwort = "PROBEPASSWORT-1234"; const fertig = befehlFuellen( { titel: schritt.titel, shell: konfig.shell, befehl: konfig.befehl }, werte, passwort, ); expect(fertig.fehler).toBeUndefined(); expect(fertig.befehl).toContain('-Name "Lisa Bauer"'); expect(fertig.befehl).toContain('-SamAccountName "lbauer"'); expect(fertig.befehl).toContain('-UserPrincipalName "l.bauer@firma.example"'); // Die Ziel-OU kommt aus der Abteilung – genau der Wert aus dem Auftrag. expect(fertig.befehl).toContain('-Path "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local"'); expect(fertig.befehl).toContain(passwort); expect(fertig.befehl).not.toContain("{{"); // Und der Hilfetext daneben nennt **kein** zweites Passwort. const hilfe = schritt.hilfetext ?? ""; expect(hilfe).not.toContain("{{generiertes_passwort}}"); expect(hilfe).toContain("Abschnitt trifft nicht zu"); // Der Gruppen-Befehl trägt die Standardgruppen der Abteilung. const gruppen = await testPrisma.vorlagenSchritt.findFirstOrThrow({ where: { abschnitt: { versionId: onboarding.versionId }, titel: "Gruppen zuweisen", typ: "BEFEHL" }, orderBy: { position: "asc" }, }); const gruppenKonfig = gruppen.konfig as { shell: "powershell"; befehl: string }; const gefuellt = befehlFuellen( { titel: gruppen.titel, shell: gruppenKonfig.shell, befehl: gruppenKonfig.befehl }, werte, passwort, ); expect(gefuellt.befehl).toContain("G-Buchhaltung, G-Alle"); }); it("belegt die Sperr-OU im Offboarding aus den Einstellungen vor", async () => { /* * Die Einstellungsseite verspricht „Wird beim Offboarding vorbelegt“. Bis zum * Review zu #017 las das niemand: `Move-ADObject -TargetPath "{{ou_gesperrt}}"` * blieb leer, und ein deaktiviertes Konto wäre liegen geblieben, wo es war. */ const eigene = await eigeneOrganisation(); await einstellungSetzen(); const vorlagen = await vorlagenSaeen(testPrisma); const offboarding = vorlagen.find((eintrag) => eintrag.name === "Mitarbeiter-Offboarding"); if (!offboarding) throw new Error("Die Offboarding-Vorlage fehlt."); const lauf = await testPrisma.lauf.create({ data: { vorlagenVersionId: offboarding.versionId, kundeId: eigene.id, titel: "Offboarding Probe", mitarbeiterName: "Alte Kollegin", variablen: { austrittsdatum: "2026-10-31", postfach_verbleib: "Weiterleitung" }, }, }); const werte = await platzhalterWerteDesLaufs({ laufId: lauf.id }); expect(werte["ou_gesperrt"]).toBe(GESPERRTE_OU); const schritt = await testPrisma.vorlagenSchritt.findFirstOrThrow({ where: { abschnitt: { versionId: offboarding.versionId }, titel: "Konto sperren (AD)" }, }); const konfig = schritt.konfig as { shell: "powershell"; befehl: string }; const fertig = befehlFuellen({ titel: schritt.titel, shell: konfig.shell, befehl: konfig.befehl }, werte); expect(fertig.befehl).toContain(`-TargetPath "${GESPERRTE_OU}"`); // Eine ausdrückliche Angabe im Lauf gewinnt trotzdem. await testPrisma.lauf.update({ where: { id: lauf.id }, data: { variablen: { ou_gesperrt: "OU=Ausgeschieden,DC=firma,DC=local" } }, }); const eigene2 = await platzhalterWerteDesLaufs({ laufId: lauf.id }); expect(eigene2["ou_gesperrt"]).toBe("OU=Ausgeschieden,DC=firma,DC=local"); }); it("schlägt für Maria Huber genau ihre Kennungen vor", async () => { // Die Namensregeln des Seeds müssen zu seinen eigenen Daten passen — sonst // schlägt die Anwendung beim ersten Onboarding etwas anderes vor, als in der // Personenliste steht. const eigene = await eigeneOrganisation(); await einstellungSetzen(); const admin = await testPrisma.benutzer.create({ data: { email: "a@firma.example", name: "A", rolle: "ADMIN" }, }); const vorschlaege = await namenVorschlagen( eigene.id, { vorname: "Maria", nachname: "Huber" }, { id: admin.id, rolle: "ADMIN" }, ); expect(vorschlaege.upn).toBe("m.huber@firma.example"); expect(vorschlaege.sam).toBe("mhuber"); expect(vorschlaege.mail).toBe("m.huber@firma.example"); expect(vorschlaege.hostname).toBe("PC-001"); }); it("legt den Administrator nur als argon2-Hash an", async () => { // Das Abnahmekriterium ausdrücklich: kein Seed-Datensatz trägt ein // Klartext-Passwort, auch nicht das Admin-Passwort aus der Umgebung. const vorherEmail = process.env["SEED_ADMIN_EMAIL"]; const vorherPasswort = process.env["SEED_ADMIN_PASSWORD"]; process.env["SEED_ADMIN_EMAIL"] = "seed-admin@firma.example"; process.env["SEED_ADMIN_PASSWORD"] = "ein-langes-probe-passwort"; try { await adminAnlegen(testPrisma); const admin = await testPrisma.benutzer.findUniqueOrThrow({ where: { email: "seed-admin@firma.example" }, }); expect(admin.rolle).toBe("ADMIN"); expect(admin.passwortHash).toMatch(/^\$argon2id\$/u); expect(admin.passwortHash).not.toContain("ein-langes-probe-passwort"); // Zweiter Lauf: kein zweiter Administrator. await adminAnlegen(testPrisma); expect(await testPrisma.benutzer.count()).toBe(1); } finally { if (vorherEmail === undefined) delete process.env["SEED_ADMIN_EMAIL"]; else process.env["SEED_ADMIN_EMAIL"] = vorherEmail; if (vorherPasswort === undefined) delete process.env["SEED_ADMIN_PASSWORD"]; else process.env["SEED_ADMIN_PASSWORD"] = vorherPasswort; } }); it("vergibt einen verbrannten Tag-Code nicht neu", async () => { /* * Der Beispiel-PC lässt sich endgültig löschen — das ist die eine Ausnahme, * die es gibt. Der Code bleibt trotzdem verbrannt: ein alter Aufkleber im * Regal darf nie auf ein anderes Gerät zeigen (CLAUDE.md). */ const eigene = await eigeneOrganisation(); await stammdatenSaeen(testPrisma, eigene.id); await testPrisma.geraet.delete({ where: { tagCode: PC_TAG } }); const zweit = await stammdatenSaeen(testPrisma, eigene.id); expect(zweit.geraete).toBe(0); expect(await testPrisma.geraet.findUnique({ where: { tagCode: PC_TAG } })).toBeNull(); // Im Vergabebuch steht er weiter – genau darauf beruht die Prüfung. expect(await testPrisma.tagCodeVergabe.findUnique({ where: { tagCode: PC_TAG } })).not.toBeNull(); }); it("lässt eine Vorlage in Ruhe, die nur als Entwurf existiert", async () => { /* * Der Import legt absichtlich einen Entwurf an — wer eine der sieben Dateien * einspielt und noch nicht veröffentlicht hat, darf den Seed nicht sprengen. * Vorher brach er hier mit einem rohen Prisma-Fehler ab, und zwar **vor** * Stammdaten und Demo-Kunden (Reviewbefund zu #017). */ const vorlage = await testPrisma.vorlage.create({ data: { name: "Monatlicher Patchday", kategorie: "Wartung" }, }); await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF }, }); const gesaet = await vorlagenSaeen(testPrisma); expect(gesaet.map((eintrag) => eintrag.name)).not.toContain("Monatlicher Patchday"); expect(gesaet).toHaveLength(6); expect(await testPrisma.vorlage.count({ where: { name: "Monatlicher Patchday" } })).toBe(1); expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId: vorlage.id } })).toBe(1); }); it("gibt keinem Demo-Kunden eine Verzeichnisquelle", async () => { // An fremde Active Directories bindet sich diese Anwendung nicht (#008). await eigeneOrganisation(); await demoKundenSaeen(testPrisma); const kunden = await testPrisma.kunde.findMany({ where: { istEigeneOrganisation: false } }); for (const kunde of kunden) { expect(await testPrisma.verzeichnisquelle.count({ where: { kundeId: kunde.id } })).toBe(0); } }); });