feat(datenmodell): Schema, tagCodes und Geheimnisse (#20)
Vollständiges Datenmodell in deutscher Benennung: 20 Tabellen, 9 Aufzählungen, eine Migration, dazu die framework-freien Helfer für tagCodes und Geheimnisse. Regeln stehen zusätzlich in der Datenbank, nicht nur im Service: 12 CHECKs, 2 partielle Eindeutigkeitsindizes, 4 zusammengesetzte Fremdschlüssel. Die Mandantengrenze ist damit strukturell erzwungen. Schrittergebnisse, Kommentare und Protokolleinträge kaskadieren bewusst nicht — das Archiv ist das Gedächtnis der Anwendung. 106 Tests, davon 42 gegen die echten Datenbankregeln. Closes #6 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #20.
This commit is contained in:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user