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
+4 -2
View File
@@ -49,10 +49,12 @@ describe("Betriebsstand: docker-compose.yml", () => {
});
describe("Betriebsstand: Entwicklungsdatenbank", () => {
it("laeuft auf 5433, damit ein lokales 5432 frei bleibt", () => {
it("bindet nur auf localhost und auf einen eigenen, umstellbaren Port", () => {
const stand = compose("docker-compose.dev.yml") as unknown as {
services: Record<string, { ports?: string[] }>;
};
expect(stand.services["postgres"]?.ports).toEqual(["127.0.0.1:5433:5432"]);
// Der Standard ist 5434, weil 5432 lokal und 5433 von einem anderen Projekt
// belegt sein koennen. Umstellbar ueber DEV_DB_PORT.
expect(stand.services["postgres"]?.ports).toEqual(["127.0.0.1:${DEV_DB_PORT:-5434}:5432"]);
});
});
+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();
});
});
});
+168
View File
@@ -0,0 +1,168 @@
import { afterEach, describe, expect, it } from "vitest";
import { GeheimnisFehler, entschluesseln, verschluesseln } from "@/lib/geheimnisse";
// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen
// ihn danach wieder her, damit die Reihenfolge der Tests nichts aendert.
const APP_SECRET_VORHER = process.env["APP_SECRET"];
function appSecretSetzen(wert: string): void {
process.env["APP_SECRET"] = wert;
}
afterEach(() => {
if (APP_SECRET_VORHER === undefined) {
delete process.env["APP_SECRET"];
} else {
process.env["APP_SECRET"] = APP_SECRET_VORHER;
}
});
/** Aendert ein Byte im angegebenen Teil des Geheimtexts, ohne das Format zu verletzen. */
function teilVerfaelschen(geheimtext: string, index: number): string {
const teile = geheimtext.split(".");
const teil = teile[index];
if (teil === undefined || teil.length === 0) {
throw new Error(`Teil ${index} fehlt im Geheimtext.`);
}
// Das erste base64-Zeichen tragt die oberen Bits des ersten Bytes, eine
// Aenderung dort wirkt also sicher -- anders als am gepolsterten Ende.
const ersetzt = teil.startsWith("A") ? "B" : "A";
teile[index] = ersetzt + teil.slice(1);
return teile.join(".");
}
describe("Geheimnisse verschluesseln und entschluesseln", () => {
it("ist verlustfrei fuer einfachen Text", () => {
const klartext = "bind-passwort-123";
expect(entschluesseln(verschluesseln(klartext))).toBe(klartext);
});
it.each([
["Umlaute und Eszett", "Grueße aus Bad Münstereifel: ÄÖÜäöüß"],
["Emoji", "Schlüssel 🔐 im Tresor 🗄️ mit Häkchen ✅"],
["leerer Text", ""],
["nur Leerzeichen", " "],
["Zeilenumbrueche", "erste Zeile\nzweite Zeile\r\ndritte"],
["Punkte im Klartext", "v1.das.sieht.aus.wie.ein.geheimtext"],
["base64-artiger Text", "YWJjZGVmZ2g="],
])("ist verlustfrei fuer %s", (_fall, klartext) => {
expect(entschluesseln(verschluesseln(klartext))).toBe(klartext);
});
it("ist verlustfrei fuer lange Texte", () => {
const lang = "Zertifikat-".repeat(10_000);
const geheim = verschluesseln(lang);
expect(geheim.length).toBeGreaterThan(lang.length / 2);
expect(entschluesseln(geheim)).toBe(lang);
});
it("traegt das Versionspraefix und vier durch Punkt getrennte Teile", () => {
const teile = verschluesseln("etwas").split(".");
expect(teile).toHaveLength(4);
expect(teile[0]).toBe("v1");
// 12 Byte IV und 16 Byte Auth-Tag, base64 kodiert.
expect(Buffer.from(teile[1] ?? "", "base64")).toHaveLength(12);
expect(Buffer.from(teile[2] ?? "", "base64")).toHaveLength(16);
});
it("liefert fuer denselben Klartext zwei verschiedene Geheimtexte", () => {
// Jeder Aufruf zieht einen eigenen IV; sonst waere an gleichen Geheimtexten
// ablesbar, welche Konten dasselbe Passwort benutzen.
const klartext = "immer-dasselbe-passwort";
const erster = verschluesseln(klartext);
const zweiter = verschluesseln(klartext);
expect(erster).not.toBe(zweiter);
expect(erster.split(".")[1]).not.toBe(zweiter.split(".")[1]);
expect(entschluesseln(erster)).toBe(klartext);
expect(entschluesseln(zweiter)).toBe(klartext);
});
it("enthaelt den Klartext nicht im Ergebnis", () => {
const klartext = "geheimes-bind-passwort";
expect(verschluesseln(klartext)).not.toContain(klartext);
});
});
describe("Geheimnisse gegen Verfaelschung", () => {
it("wirft, wenn ein Byte im Geheimtext veraendert wurde", () => {
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 3);
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
expect(() => entschluesseln(verfaelscht)).toThrow(/Auth-Tag passt nicht/);
});
it("wirft, wenn das Auth-Tag veraendert wurde", () => {
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 2);
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
});
it("wirft, wenn der IV veraendert wurde", () => {
const verfaelscht = teilVerfaelschen(verschluesseln("bind-passwort-123"), 1);
expect(() => entschluesseln(verfaelscht)).toThrow(GeheimnisFehler);
});
it("wirft, wenn der Geheimtext abgeschnitten wurde", () => {
const geheim = verschluesseln("bind-passwort-123");
const ohneLetztenTeil = geheim.split(".").slice(0, 3).join(".");
expect(() => entschluesseln(ohneLetztenTeil)).toThrow(GeheimnisFehler);
expect(() => entschluesseln(ohneLetztenTeil)).toThrow(/unbekanntes Format/);
});
it.each([
["leerer Wert", ""],
["Klartext ohne Trenner", "einfach-nur-text"],
["zu wenige Teile", "v1.abc"],
["zu viele Teile", "v1.a.b.c.d"],
["unbekannte Version", "v9.AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"],
["IV zu kurz", "v1.AAAA.AAAAAAAAAAAAAAAAAAAAAA==.AAAA"],
["Auth-Tag zu kurz", "v1.AAAAAAAAAAAAAAAA.AAAA.AAAA"],
])("wirft mit verstaendlicher Meldung bei %s", (_fall, geheimtext) => {
let gefangen: unknown;
try {
entschluesseln(geheimtext);
} catch (fehler) {
gefangen = fehler;
}
expect(gefangen).toBeInstanceOf(GeheimnisFehler);
expect((gefangen as GeheimnisFehler).message).toMatch(/v1\.<iv>\.<authTag>\.<geheimtext>/);
});
it("wirft, wenn APP_SECRET zwischen Verschluesseln und Entschluesseln wechselt", () => {
const geheim = verschluesseln("bind-passwort-123");
appSecretSetzen("ein-voellig-anderes-geheimnis-mit-genug-zeichen");
expect(() => entschluesseln(geheim)).toThrow(GeheimnisFehler);
});
});
describe("APP_SECRET pruefen", () => {
it.each([
["fehlendes", ""],
["viel zu kurzes", "kurz"],
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
])("wirft bei %s APP_SECRET mit Hinweis auf openssl", (_fall, wert) => {
appSecretSetzen(wert);
expect(() => verschluesseln("etwas")).toThrow(GeheimnisFehler);
expect(() => verschluesseln("etwas")).toThrow(/openssl rand -base64 48/);
expect(() => verschluesseln("etwas")).toThrow(/mindestens 32 Zeichen/);
});
it("nimmt genau 32 Zeichen an", () => {
appSecretSetzen("b".repeat(32));
expect(entschluesseln(verschluesseln("grenzfall"))).toBe("grenzfall");
});
it("stellt den Standardwert aus setup-env wieder her", () => {
// Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die
// Tests je nach Reihenfolge unterschiedlich ausgehen.
expect(process.env["APP_SECRET"]).toBe(APP_SECRET_VORHER);
expect(entschluesseln(verschluesseln("nach dem Aufraeumen"))).toBe("nach dem Aufraeumen");
});
});
+12 -1
View File
@@ -3,7 +3,18 @@ import { PrismaClient } from "@/generated/prisma/client";
const url = process.env["TEST_DATABASE_URL"] ?? process.env["DATABASE_URL"];
/** Ohne Testdatenbank ueberspringen sich die Datenbanktests statt fehlzuschlagen. */
/**
* Ohne Testdatenbank ueberspringen sich die Datenbanktests, damit ein schneller
* Durchlauf ohne Docker moeglich bleibt.
*
* In CI waere das gefaehrlich: `pnpm test` ist das Tor vor jedem Commit, und ein
* lautloses Ueberspringen saehe genauso gruen aus wie ein bestandener Lauf.
* Deshalb dort lieber hart abbrechen.
*/
if (!url && process.env["CI"]) {
throw new Error("TEST_DATABASE_URL fehlt. In CI duerfen die Datenbanktests nicht uebersprungen werden.");
}
export const datenbankVerfuegbar = Boolean(url);
export const testPrisma = new PrismaClient({
+152
View File
@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from "vitest";
import {
TAGCODE_ALPHABET,
TAGCODE_HOECHSTVERSUCHE,
TAGCODE_MUSTER,
TagCodeFehler,
istTagCode,
tagCodeEindeutigErzeugen,
tagCodeErzeugen,
} from "@/lib/tagcode";
function zieheCodes(anzahl: number): string[] {
return Array.from({ length: anzahl }, () => tagCodeErzeugen());
}
describe("tagCode erzeugen", () => {
it("haelt Praefix und Laenge ein", () => {
for (const code of zieheCodes(200)) {
expect(code).toMatch(TAGCODE_MUSTER);
expect(code).toHaveLength(3 + 6);
expect(code.startsWith("LZ-")).toBe(true);
}
});
it("benutzt nur das erlaubte Alphabet", () => {
for (const code of zieheCodes(5000)) {
for (const zeichen of code.slice(3)) {
expect(TAGCODE_ALPHABET).toContain(zeichen);
}
}
});
it("enthaelt nie die verwechselbaren Zeichen 0, O, 1 oder I", () => {
// Genau das ist der Grund fuer das eingeschraenkte Alphabet: die Codes werden
// von Aufklebern abgetippt und am Telefon vorgelesen. Das L ist bewusst nicht
// dabei: ohne es bliebe das Alphabet bei 31 Zeichen statt der gewuenschten 32,
// und das Praefix `LZ-` enthaelt es ohnehin.
for (const code of zieheCodes(5000)) {
expect(code.slice(3)).not.toMatch(/[0O1I]/);
}
});
it("liefert bei 10 000 Ziehungen praktisch keine Dubletten", () => {
const anzahl = 10_000;
const eindeutige = new Set(zieheCodes(anzahl));
// Bei 32^6 rund 1,07 Mrd. Moeglichkeiten sind nach dem Geburtstagsproblem
// etwa 0,05 Dubletten zu erwarten -- meistens also keine. Ein strikter
// Vergleich auf 10 000 waere deshalb flatterhaft: er wuerde selten, aber
// regelmaessig rot, ohne dass etwas kaputt ist. Die verbindliche
// Eindeutigkeit sichert der Unique-Index in der Datenbank.
expect(eindeutige.size).toBeGreaterThanOrEqual(anzahl - 5);
});
it("verteilt die Zeichen grob gleichmaessig ueber das Alphabet", () => {
const codes = zieheCodes(5000);
const haeufigkeit = new Map<string, number>();
for (const code of codes) {
for (const zeichen of code.slice(3)) {
haeufigkeit.set(zeichen, (haeufigkeit.get(zeichen) ?? 0) + 1);
}
}
const gezogeneZeichen = codes.length * 6;
const erwartet = gezogeneZeichen / TAGCODE_ALPHABET.length;
expect(haeufigkeit.size).toBe(TAGCODE_ALPHABET.length);
for (const zeichen of TAGCODE_ALPHABET) {
const anzahl = haeufigkeit.get(zeichen) ?? 0;
// Grosszuegige Schranken (rund 15 Standardabweichungen), damit der Test
// eine echte Schieflage findet, aber nicht am Zufall scheitert.
expect(anzahl).toBeGreaterThan(erwartet * 0.5);
expect(anzahl).toBeLessThan(erwartet * 1.5);
}
});
});
describe("istTagCode", () => {
it("nimmt einen frisch erzeugten Code an", () => {
expect(istTagCode(tagCodeErzeugen())).toBe(true);
expect(istTagCode("LZ-7K3F9Q")).toBe(true);
});
it.each([
["Kleinbuchstaben", "lz-7k3f9q"],
["gemischte Schreibweise", "LZ-7k3F9Q"],
["fehlendes Praefix", "7K3F9Q"],
["falsches Praefix", "XY-7K3F9Q"],
["Praefix ohne Bindestrich", "LZ7K3F9Q"],
["zu kurz", "LZ-7K3F9"],
["zu lang", "LZ-7K3F9QA"],
["verbotene Null", "LZ-7K3F90"],
["verbotenes O", "LZ-7K3F9O"],
["verbotene Eins", "LZ-7K3F91"],
["verbotenes I", "LZ-7K3F9I"],
["Sonderzeichen", "LZ-7K3F9-"],
["Leerzeichen", "LZ-7K3F9 "],
["leer", ""],
["nur Praefix", "LZ-"],
["Zeilenumbruch angehaengt", "LZ-7K3F9Q\n"],
["Text davor", "xLZ-7K3F9Q"],
])("weist %s zurueck", (_fall, wert) => {
expect(istTagCode(wert)).toBe(false);
});
it("urteilt bei mehrfachem Aufruf gleich", () => {
// Das Muster wird geteilt; ein globales Flag darauf wuerde den Zustand
// zwischen Aufrufen mitschleppen.
expect(istTagCode("LZ-7K3F9Q")).toBe(true);
expect(istTagCode("LZ-7K3F9Q")).toBe(true);
});
});
describe("tagCodeEindeutigErzeugen", () => {
it("liefert den ersten Code, wenn er frei ist", async () => {
const istVergeben = vi.fn(async () => false);
const code = await tagCodeEindeutigErzeugen(istVergeben);
expect(istTagCode(code)).toBe(true);
expect(istVergeben).toHaveBeenCalledTimes(1);
expect(istVergeben).toHaveBeenCalledWith(code);
});
it("zieht erneut, wenn der erste Code vergeben ist", async () => {
const gesehen: string[] = [];
const istVergeben = vi.fn(async (code: string) => {
gesehen.push(code);
return gesehen.length === 1;
});
const code = await tagCodeEindeutigErzeugen(istVergeben);
expect(istVergeben).toHaveBeenCalledTimes(2);
expect(gesehen).toHaveLength(2);
expect(gesehen[0]).not.toBe(code);
expect(gesehen[1]).toBe(code);
});
it("wirft nach zehn vergeblichen Versuchen einen TagCodeFehler", async () => {
const istVergeben = vi.fn(async () => true);
await expect(tagCodeEindeutigErzeugen(istVergeben)).rejects.toThrow(TagCodeFehler);
expect(istVergeben).toHaveBeenCalledTimes(TAGCODE_HOECHSTVERSUCHE);
});
it("gibt einen Fehler der Vergabepruefung unveraendert weiter", async () => {
const istVergeben = vi.fn(async () => {
throw new Error("Datenbank nicht erreichbar");
});
await expect(tagCodeEindeutigErzeugen(istVergeben)).rejects.toThrow("Datenbank nicht erreichbar");
expect(istVergeben).toHaveBeenCalledTimes(1);
});
});