Files
Laufzettel/tests/seed.test.ts
T
florian.wilde 0060a55387
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(mitarbeiter): Vorgesetzter auch fuer Personen ohne Verzeichnis (#43)
2026-10-04 15:10:34 +00:00

777 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<readonly [string, string]> {
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("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);
}
});
});