feat(datenmodell): Schema, tagCodes und Geheimnisse
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Das vollständige Datenmodell in deutscher Benennung: 20 Tabellen, 9
Aufzählungen, eine Migration, dazu die beiden framework-freien Helfer für die
tagCodes der QR-Aufkleber und für verschlüsselte Geheimnisse.

Die tragende Entscheidung ist, wo die Regeln stehen. Die Service-Schicht ist
der richtige Ort für verständliche Meldungen, aber nicht der einzige Weg, auf
dem Daten entstehen — Migrationen, psql, Skripte und ab Phase 4 die Connectoren
schreiben an ihr vorbei. Was dabei stillschweigend falsch werden könnte, steht
deshalb zusätzlich in der Datenbank: zwölf CHECK-Bedingungen, zwei partielle
Eindeutigkeitsindizes und vier zusammengesetzte Fremdschlüssel.

Die Mandantengrenze ist einer davon. Lauf verweist mit (geraetId, kundeId) auf
Geraet(id, kundeId), analog für Person und Abteilung, und Geraet ebenso auf
seinen Besitzer. Ein Lauf, der ein Gerät eines fremden Kunden zieht, ist damit
nicht mehr bloß ein Fehler im Service, sondern strukturell unmöglich.
MATCH SIMPLE lässt einen Lauf ohne Gerät weiterhin zu.

Zwei Dinge kaskadieren bewusst nicht: ein Lauf nimmt seine Schrittergebnisse
und Kommentare nicht mit, und ein Protokolleintrag verliert seinen Urheber
nicht. Beides stünde gegen den Zweck eines Archivs. Aus demselben Grund gibt es
TagCodeVergabe: ohne dieses Buch wäre ein tagCode nach dem Löschen eines Geräts
wieder frei, und ein alter Aufkleber im Regal zeigte auf ein fremdes Objekt.

Ein unabhängiger Review-Agent hat drei Lücken gefunden, die alle behoben sind:
der im Auftrag geforderten Suchspalte Schrittergebnis.wert fehlte der
Trigramm-Index; die im Lauf gewählte Abteilung war nur ein Wert in variablen
statt ein Bezug, wodurch die Druckansicht eines archivierten Laufs nicht
reproduzierbar gewesen wäre; und für die Kundenbindung eines Benutzers (#008)
gab es keine Struktur, sie hätte also nur im Token stehen können — gegen die
eigene Regel, Rechte pro Anfrage aus der Datenbank zu lesen.

Das tagCode-Alphabet des Auftrags widerspricht sich: der wörtlich vorgegebene
Zeichensatz enthält ein L, die Klammer daneben verbietet es. Wir folgen dem
Wortlaut, weil die Klammer Verwechslungsgruppen benennt und keine
Ausschlussliste ist — aus {1, I, L} sind 1 und I gestrichen, wodurch L eindeutig
wird, und das Präfix LZ- enthält ohnehin in jedem Code ein L. Das
Akzeptanzkriterium in #006 ist richtiggestellt worden statt umgekehrt.

106 Tests, davon 42 gegen die echten Datenbankregeln.

Closes #6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 01:11:16 +02:00
co-authored by Claude Opus 5
parent 49ce06f206
commit 0e79bebd33
19 changed files with 2820 additions and 40 deletions
+666
View File
@@ -0,0 +1,666 @@
import { beforeEach, describe, expect, it } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Haelt die Regeln fest, die in der Datenbank selbst stehen (#006).
*
* Sie stehen dort und nicht nur in der Service-Schicht, damit sie auch dann
* greifen, wenn jemand an ihr vorbei schreibt – per Migration, per psql oder aus
* einem spaeteren Connector. Die Service-Schicht prueft dieselben Regeln noch
* einmal, aber mit verstaendlicheren Meldungen.
*/
async function kundeAnlegen(kuerzel = "TST", eigene = false) {
return testPrisma.kunde.create({
data: { name: `Kunde ${kuerzel}`, kuerzel, istEigeneOrganisation: eigene },
});
}
async function benutzerAnlegen(email = "techniker@firma.example") {
return testPrisma.benutzer.create({
data: { email, name: "Techniker", rolle: "TECHNIKER" },
});
}
/** Kleinste Vorlage, die ausreicht, um einen Lauf daran zu haengen. */
async function veroeffentlichteVersionAnlegen() {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } });
return testPrisma.vorlagenVersion.create({
// veroeffentlichtAm ist Pflicht, sobald der Status VEROEFFENTLICHT ist –
// eine Bedingung in der Datenbank haelt das fest.
data: {
vorlageId: vorlage.id,
version: 1,
status: "VEROEFFENTLICHT",
veroeffentlichtAm: new Date(),
},
});
}
describe.runIf(datenbankVerfuegbar)("Datenmodell: Regeln in der Datenbank", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Kunde", () => {
it("laesst nur eine eigene Organisation zu", async () => {
await kundeAnlegen("EIGEN", true);
// Gemeint ist der partielle Index, nicht irgendeine Ablehnung.
await expect(kundeAnlegen("ZWEIT", true)).rejects.toThrow(/Kunde_eigene_organisation_einmalig/);
});
it("laesst beliebig viele gewoehnliche Kunden zu", async () => {
await kundeAnlegen("EIGEN", true);
await kundeAnlegen("MUS");
await kundeAnlegen("REI");
expect(await testPrisma.kunde.count()).toBe(3);
});
it("weist ein Kuerzel ausserhalb von 2 bis 6 Zeichen zurueck", async () => {
await expect(kundeAnlegen("X")).rejects.toThrow(/Kunde_kuerzel_form/);
await expect(kundeAnlegen("ZULANG7")).rejects.toThrow(/Kunde_kuerzel_form/);
});
it("weist ein Kuerzel mit Kleinbuchstaben oder Sonderzeichen zurueck", async () => {
await expect(kundeAnlegen("mus")).rejects.toThrow();
await expect(kundeAnlegen("MU-S")).rejects.toThrow();
});
it("haelt das Kuerzel eindeutig", async () => {
await kundeAnlegen("MUS");
await expect(kundeAnlegen("MUS")).rejects.toThrow();
});
});
describe("Einstellung", () => {
it("laesst nur die eine Zeile mit der Kennung global zu", async () => {
await testPrisma.einstellung.create({ data: { id: "global" } });
await expect(testPrisma.einstellung.create({ data: { id: "zweite" } })).rejects.toThrow();
});
});
describe("Geraet", () => {
it("erlaubt einen vorgedruckten Tag ohne Hostname und ohne Bezeichnung", async () => {
const kunde = await kundeAnlegen();
const tag = await testPrisma.geraet.create({
data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q" },
});
expect(tag.registriertAm).toBeNull();
expect(tag.hostname).toBeNull();
});
it("verlangt beim Registrieren einen Hostname oder eine Bezeichnung", async () => {
const kunde = await kundeAnlegen();
await expect(
testPrisma.geraet.create({
data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q", registriertAm: new Date() },
}),
).rejects.toThrow(/Geraet_registriert_braucht_namen/);
});
it("wertet reine Leerzeichen nicht als Bezeichnung", async () => {
const kunde = await kundeAnlegen();
await expect(
testPrisma.geraet.create({
data: {
kundeId: kunde.id,
tagCode: "LZ-7K3F9Q",
bezeichnung: " ",
registriertAm: new Date(),
},
}),
).rejects.toThrow();
});
it("nimmt ein registriertes Geraet mit Bezeichnung statt Hostname an", async () => {
const kunde = await kundeAnlegen();
const raum = await testPrisma.geraet.create({
data: {
kundeId: kunde.id,
tagCode: "LZ-M2XH4T",
bezeichnung: "Serverschrank Keller",
typ: "RAUM",
registriertAm: new Date(),
},
});
expect(raum.bezeichnung).toBe("Serverschrank Keller");
});
it("weist einen tagCode in falscher Form zurueck", async () => {
const kunde = await kundeAnlegen();
for (const falsch of ["7K3F9Q", "LZ-7K3F9", "lz-7k3f9q", "LZ-7K3F9O", "LZ-7K3F91"]) {
await expect(
testPrisma.geraet.create({ data: { kundeId: kunde.id, tagCode: falsch } }),
).rejects.toThrow();
}
});
it("haelt den tagCode ueber alle Kunden hinweg eindeutig", async () => {
const einer = await kundeAnlegen("MUS");
const anderer = await kundeAnlegen("REI");
await testPrisma.geraet.create({ data: { kundeId: einer.id, tagCode: "LZ-7K3F9Q" } });
await expect(
testPrisma.geraet.create({ data: { kundeId: anderer.id, tagCode: "LZ-7K3F9Q" } }),
).rejects.toThrow();
});
});
describe("Mitarbeiter", () => {
it("haelt die externe Kennung je Quelle eindeutig", async () => {
const kunde = await kundeAnlegen();
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: kunde.id,
name: "Firmen-AD",
url: "ldaps://dc01.firma.local",
bindDn: "CN=Dienst,DC=firma,DC=local",
bindPasswortVerschluesselt: "v1.probe",
basisDn: "DC=firma,DC=local",
filter: "(objectClass=user)",
attributZuordnung: {},
},
});
const stammdaten = {
kundeId: kunde.id,
quelleId: quelle.id,
externeId: "guid-1",
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
herkunft: "ABGLEICH" as const,
};
await testPrisma.mitarbeiter.create({ data: stammdaten });
await expect(testPrisma.mitarbeiter.create({ data: stammdaten })).rejects.toThrow();
});
it("laesst beliebig viele manuell angelegte Personen ohne Quelle zu", async () => {
const kunde = await kundeAnlegen();
// Postgres behandelt NULL als verschieden, deshalb greift der eindeutige
// Index hier nicht – genau so ist es gewollt.
for (const name of ["Ada", "Grace", "Alan"]) {
await testPrisma.mitarbeiter.create({
data: { kundeId: kunde.id, vorname: name, nachname: "Test", anzeigename: name },
});
}
expect(await testPrisma.mitarbeiter.count()).toBe(3);
});
});
describe("Abteilung", () => {
it("haelt den Namen je Kunde eindeutig, aber nicht ueber Kunden hinweg", async () => {
const einer = await kundeAnlegen("MUS");
const anderer = await kundeAnlegen("REI");
await testPrisma.abteilung.create({ data: { kundeId: einer.id, name: "Buchhaltung" } });
await expect(
testPrisma.abteilung.create({ data: { kundeId: einer.id, name: "Buchhaltung" } }),
).rejects.toThrow();
const beimAnderen = await testPrisma.abteilung.create({
data: { kundeId: anderer.id, name: "Buchhaltung" },
});
expect(beimAnderen.name).toBe("Buchhaltung");
});
});
describe("Lauf", () => {
it("laesst einen Lauf ohne Mitarbeiter und ohne Geraet zu", async () => {
const kunde = await kundeAnlegen();
const benutzer = await benutzerAnlegen();
const version = await veroeffentlichteVersionAnlegen();
const lauf = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "Patchday 2026-10",
kundeId: kunde.id,
erstelltVonId: benutzer.id,
},
});
expect(lauf.status).toBe("OFFEN");
expect(lauf.gestartetAm).toBeNull();
});
it("weist Mitarbeiter und Freitextname zugleich zurueck", async () => {
const kunde = await kundeAnlegen();
const benutzer = await benutzerAnlegen();
const version = await veroeffentlichteVersionAnlegen();
const person = await testPrisma.mitarbeiter.create({
data: { kundeId: kunde.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
});
await expect(
testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "Onboarding",
kundeId: kunde.id,
erstelltVonId: benutzer.id,
mitarbeiterId: person.id,
mitarbeiterName: "Maria Huber",
},
}),
).rejects.toThrow();
});
it("verhindert das Loeschen eines Geraets, an dem ein Lauf haengt", async () => {
const kunde = await kundeAnlegen();
const benutzer = await benutzerAnlegen();
const version = await veroeffentlichteVersionAnlegen();
const geraet = await testPrisma.geraet.create({
data: {
kundeId: kunde.id,
tagCode: "LZ-7K3F9Q",
hostname: "PC-001",
typ: "PC",
registriertAm: new Date(),
},
});
await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "PC Neuinstallation",
kundeId: kunde.id,
erstelltVonId: benutzer.id,
geraetId: geraet.id,
},
});
await expect(testPrisma.geraet.delete({ where: { id: geraet.id } })).rejects.toThrow();
});
it("verhindert das Loeschen einer Version, auf die ein Lauf verweist", async () => {
const kunde = await kundeAnlegen();
const benutzer = await benutzerAnlegen();
const version = await veroeffentlichteVersionAnlegen();
await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "Probe",
kundeId: kunde.id,
erstelltVonId: benutzer.id,
},
});
await expect(testPrisma.vorlagenVersion.delete({ where: { id: version.id } })).rejects.toThrow();
});
});
describe("Schrittergebnis", () => {
it("laesst je Lauf und Schritt nur ein Ergebnis zu", async () => {
const kunde = await kundeAnlegen();
const benutzer = await benutzerAnlegen();
const version = await veroeffentlichteVersionAnlegen();
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung" },
});
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst", pflicht: true },
});
const lauf = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "Probe",
kundeId: kunde.id,
erstelltVonId: benutzer.id,
},
});
await testPrisma.schrittergebnis.create({
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id },
});
await expect(
testPrisma.schrittergebnis.create({
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id },
}),
).rejects.toThrow();
});
});
describe("Vorlagen-Version", () => {
it("haelt die Versionsnummer je Vorlage eindeutig", async () => {
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1 } });
await expect(
testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1 } }),
).rejects.toThrow();
});
it("raeumt Abschnitte, Schritte und Variablen mit der Version ab", async () => {
// Ein Entwurf darf als Ganzes verworfen werden; nur deshalb steht hier
// Cascade und nicht Restrict.
const version = await veroeffentlichteVersionAnlegen();
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung" },
});
await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
});
await testPrisma.vorlagenVariable.create({
data: { versionId: version.id, schluessel: "os_version", beschriftung: "Betriebssystem" },
});
await testPrisma.vorlagenVersion.delete({ where: { id: version.id } });
expect(await testPrisma.vorlagenAbschnitt.count()).toBe(0);
expect(await testPrisma.vorlagenSchritt.count()).toBe(0);
expect(await testPrisma.vorlagenVariable.count()).toBe(0);
});
});
});
/**
* Die Zusicherungen, die erst der unabhaengige Review sichtbar gemacht hat:
* Mandantengrenze, Unversehrtheit des Archivs und die Vorleistung fuer Phase 4.
*/
describe.runIf(datenbankVerfuegbar)("Datenmodell: Grenzen und Zusicherungen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Mandantengrenze", () => {
it("laesst einen Lauf kein Geraet eines fremden Kunden waehlen", async () => {
const einer = await kundeAnlegen("MUS");
const anderer = await kundeAnlegen("REI");
const benutzer = await benutzerAnlegen();
const version = await veroeffentlichteVersionAnlegen();
const fremdesGeraet = await testPrisma.geraet.create({
data: {
kundeId: anderer.id,
tagCode: "LZ-7K3F9Q",
hostname: "REI-PC-001",
registriertAm: new Date(),
},
});
await expect(
testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "Quer ueber Kunden",
kundeId: einer.id,
erstelltVonId: benutzer.id,
geraetId: fremdesGeraet.id,
},
}),
).rejects.toThrow(/Lauf_geraet_gleicher_kunde/);
});
it("laesst einen Lauf keine Person eines fremden Kunden waehlen", async () => {
const einer = await kundeAnlegen("MUS");
const anderer = await kundeAnlegen("REI");
const benutzer = await benutzerAnlegen();
const version = await veroeffentlichteVersionAnlegen();
const fremdePerson = await testPrisma.mitarbeiter.create({
data: { kundeId: anderer.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
});
await expect(
testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "Quer ueber Kunden",
kundeId: einer.id,
erstelltVonId: benutzer.id,
mitarbeiterId: fremdePerson.id,
},
}),
).rejects.toThrow(/Lauf_mitarbeiter_gleicher_kunde/);
});
it("laesst einem Geraet keinen Besitzer eines fremden Kunden geben", async () => {
const einer = await kundeAnlegen("MUS");
const anderer = await kundeAnlegen("REI");
const fremdePerson = await testPrisma.mitarbeiter.create({
data: { kundeId: anderer.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
});
await expect(
testPrisma.geraet.create({
data: {
kundeId: einer.id,
tagCode: "LZ-7K3F9Q",
hostname: "MUS-PC-001",
registriertAm: new Date(),
mitarbeiterId: fremdePerson.id,
},
}),
).rejects.toThrow(/Geraet_besitzer_gleicher_kunde/);
});
});
describe("Unversehrtheit des Archivs", () => {
it("verhindert, dass ein Lauf seine Schrittergebnisse mitnimmt", async () => {
const kunde = await kundeAnlegen();
const benutzer = await benutzerAnlegen();
const version = await veroeffentlichteVersionAnlegen();
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung" },
});
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
});
const lauf = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "Probe",
kundeId: kunde.id,
erstelltVonId: benutzer.id,
},
});
await testPrisma.schrittergebnis.create({
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id },
});
// Ohne diese Sperre verschwaende ein DELETE auf Lauf das halbe Archiv.
await expect(testPrisma.lauf.delete({ where: { id: lauf.id } })).rejects.toThrow();
expect(await testPrisma.schrittergebnis.count()).toBe(1);
});
it("verhindert, dass ein Protokolleintrag seinen Urheber verliert", async () => {
const benutzer = await benutzerAnlegen();
await testPrisma.protokoll.create({
data: {
entitaet: "Benutzer",
entitaetId: benutzer.id,
aktion: "angemeldet",
benutzerId: benutzer.id,
},
});
await expect(testPrisma.benutzer.delete({ where: { id: benutzer.id } })).rejects.toThrow();
});
});
describe("Vorlagen-Versionierung", () => {
it("laesst je Vorlage nur einen Entwurf zu", async () => {
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1 } });
await expect(
testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 2 } }),
).rejects.toThrow(/VorlagenVersion_ein_entwurf/);
});
it("verlangt beim Veroeffentlichen einen Zeitpunkt", async () => {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } });
await expect(
testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "VEROEFFENTLICHT" },
}),
).rejects.toThrow(/VorlagenVersion_veroeffentlicht_mit_zeitpunkt/);
});
});
describe("Status und Zeitstempel", () => {
it("verlangt einen Startzeitpunkt, sobald der Lauf nicht mehr offen ist", async () => {
const kunde = await kundeAnlegen();
const benutzer = await benutzerAnlegen();
const version = await veroeffentlichteVersionAnlegen();
await expect(
testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "Probe",
kundeId: kunde.id,
erstelltVonId: benutzer.id,
status: "IN_ARBEIT",
},
}),
).rejects.toThrow(/Lauf_begonnen_mit_zeitpunkt/);
});
it("verlangt beim Archivieren Abschluss- und Archivzeitpunkt", async () => {
const kunde = await kundeAnlegen();
const benutzer = await benutzerAnlegen();
const version = await veroeffentlichteVersionAnlegen();
await expect(
testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "Probe",
kundeId: kunde.id,
erstelltVonId: benutzer.id,
status: "ARCHIVIERT",
gestartetAm: new Date(),
},
}),
).rejects.toThrow(/Lauf_abgeschlossen_mit_zeitpunkt/);
});
});
describe("Schrittergebnis", () => {
it("verlangt eine Begruendung bei nicht zutreffend und fehlgeschlagen", async () => {
const kunde = await kundeAnlegen();
const benutzer = await benutzerAnlegen();
const version = await veroeffentlichteVersionAnlegen();
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung" },
});
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Ticket erfasst" },
});
const lauf = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "Probe",
kundeId: kunde.id,
erstelltVonId: benutzer.id,
},
});
for (const zustand of ["NICHT_ZUTREFFEND", "FEHLGESCHLAGEN"] as const) {
await expect(
testPrisma.schrittergebnis.create({
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id, zustand },
}),
).rejects.toThrow(/Schrittergebnis_begruendung_noetig/);
}
const mitNotiz = await testPrisma.schrittergebnis.create({
data: {
laufId: lauf.id,
vorlagenSchrittId: schritt.id,
zustand: "NICHT_ZUTREFFEND",
notiz: "Geraet hat kein TPM.",
},
});
expect(mitNotiz.notiz).toBe("Geraet hat kein TPM.");
});
it("nimmt ein Aktionsergebnis verlustfrei auf", async () => {
// Vorleistung fuer Phase 4: der Wert muss Log, Kennungen und Zeitstempel
// einer ausgefuehrten Aktion tragen koennen, nicht nur einen Haken.
const kunde = await kundeAnlegen();
const benutzer = await benutzerAnlegen();
const version = await veroeffentlichteVersionAnlegen();
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Konto anlegen" },
});
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "New-ADUser", typ: "BEFEHL" },
});
const lauf = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "Onboarding",
kundeId: kunde.id,
erstelltVonId: benutzer.id,
},
});
const ergebnis = {
connector: "ldap",
ausgefuehrtAm: "2026-09-25T08:15:00.000Z",
dauerMs: 842,
objekte: [{ typ: "Benutzer", dn: "CN=Maria Huber,OU=Buchhaltung,DC=firma,DC=local" }],
log: ["Verbindung aufgebaut", "Konto angelegt", "Gruppen zugewiesen"],
erfolgreich: true,
};
const gespeichert = await testPrisma.schrittergebnis.create({
data: {
laufId: lauf.id,
vorlagenSchrittId: schritt.id,
zustand: "ERLEDIGT",
wert: ergebnis,
},
});
expect(gespeichert.wert).toEqual(ergebnis);
});
});
describe("Vorleistung fuer Phase 4", () => {
it("laesst einen Lauf ohne erstellenden Menschen zu", async () => {
// Wiederkehrende Pruefungen und der Start per Webhook legen Laeufe an,
// hinter denen kein Benutzer steht.
const kunde = await kundeAnlegen();
const version = await veroeffentlichteVersionAnlegen();
const systemlauf = await testPrisma.lauf.create({
data: { vorlagenVersionId: version.id, titel: "Jaehrliche Pruefung", kundeId: kunde.id },
});
expect(systemlauf.erstelltVonId).toBeNull();
});
});
describe("Vergabebuch fuer tagCodes", () => {
it("haelt einen vergebenen Code fest, auch wenn das Geraet verschwindet", async () => {
const kunde = await kundeAnlegen();
await testPrisma.tagCodeVergabe.create({ data: { tagCode: "LZ-7K3F9Q" } });
const geraet = await testPrisma.geraet.create({
data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q" },
});
await testPrisma.geraet.delete({ where: { id: geraet.id } });
expect(await testPrisma.geraet.count()).toBe(0);
// Der Code bleibt vergeben – ein alter Aufkleber darf nie auf ein neues
// Objekt zeigen.
expect(await testPrisma.tagCodeVergabe.count()).toBe(1);
await expect(testPrisma.tagCodeVergabe.create({ data: { tagCode: "LZ-7K3F9Q" } })).rejects.toThrow();
});
it("weist einen Code in falscher Form zurueck", async () => {
await expect(testPrisma.tagCodeVergabe.create({ data: { tagCode: "LZ-7K3F9O" } })).rejects.toThrow(
/TagCodeVergabe_form/,
);
});
});
describe("Verzeichnisquelle", () => {
it("weist ein Bind-Passwort ohne Versionspraefix zurueck", async () => {
const kunde = await kundeAnlegen();
await expect(
testPrisma.verzeichnisquelle.create({
data: {
kundeId: kunde.id,
name: "Firmen-AD",
url: "ldaps://dc01.firma.local",
bindDn: "CN=Dienst,DC=firma,DC=local",
// So saehe ein versehentlich im Klartext gespeichertes Passwort aus.
bindPasswortVerschluesselt: "GeheimesPasswort123",
basisDn: "DC=firma,DC=local",
filter: "(objectClass=user)",
attributZuordnung: {},
},
}),
).rejects.toThrow(/Verzeichnisquelle_bindpasswort_verschluesselt/);
});
});
describe("Abgleich-Sperre", () => {
it("laesst dieselbe Sperre nicht zweimal halten", async () => {
const laeuftAbAm = new Date(Date.now() + 60_000);
await testPrisma.abgleichSperre.create({
data: { schluessel: "abgleich:quelle-1", gehaltenVon: "instanz-a", laeuftAbAm },
});
await expect(
testPrisma.abgleichSperre.create({
data: { schluessel: "abgleich:quelle-1", gehaltenVon: "instanz-b", laeuftAbAm },
}),
).rejects.toThrow();
});
});
});