feat(seed): sieben Vorlagen, Beispielbestand und Demo-Kunden
Die sieben Ablaeufe des Hauses liegen nach dem Seed als veroeffentlichte Version 1 bereit: 36 Abschnitte, 155 Schritte, 28 Variablen, jeder Schritt mit Hilfetext. Sie stehen als JSON im Austauschformat unter prisma/seed-daten/vorlagen/ und werden beim Einrichten gegen dasselbe Schema geprueft wie ein Import. Der Seed richtet ein, er raeumt nicht auf: jede Zeile hat einen sprechenden Schluessel, und was es schon gibt, bleibt unberuehrt. Erst Entwurf, dann veroeffentlichen - die Trigger aus #010 lassen keinen anderen Weg zu. Im Modus DIENSTLEISTUNG haengt der Beispielbestand an der Musterfirma, LZ-7K3F9Q fuehrt also auf MUS-PC-001. Dazu zwei Zusagen eingeloest, die niemand deckte: {{ou_gesperrt}} kommt jetzt wirklich aus den Einstellungen, und Titel und Hilfetext eines Laufs bekommen dasselbe generierte Passwort wie der Befehl daneben - vorher stand im Befehl A und im Hilfetext B. Closes #17 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ae290345b7
commit
5c81a62cfd
@@ -0,0 +1,694 @@
|
||||
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("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("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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user