Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.
Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.
Schwer:
- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
(/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.
Mittel:
- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
Graph-Leser sich im Kommentar darauf berief.
Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.
Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.
1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
570 lines
21 KiB
TypeScript
570 lines
21 KiB
TypeScript
import { readFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { Betriebsmodus, LaufStatus, VersionStatus } from "@/generated/prisma/enums";
|
||
import { AnmeldeFehler, benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
|
||
import type { OidcAnspruecke } from "@/server/auth/oidc";
|
||
import { darf, startseite } from "@/server/auth/rollen";
|
||
import { navigationFuer } from "@/lib/navigation";
|
||
import { meineDaten, meineGeraete, meineLaeufeOhneGeraet } from "@/server/queries/selbstauskunft";
|
||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||
|
||
/**
|
||
* Mitarbeiter-Selbstauskunft (#035).
|
||
*
|
||
* Die wichtigste Zusage dieses Meilensteins ist eine **Verneinung**: eine
|
||
* Selbstauskunft ist keine Akteneinsicht. Geprueft wird sie hier nicht an der
|
||
* Seite, sondern an der **Antwort** des Lesemodells — eine Seite laesst sich
|
||
* umgehen, ein Lesemodell nicht.
|
||
*/
|
||
|
||
const UMGEBUNG = { ...process.env };
|
||
|
||
function anspruecke(ueberschreiben: Partial<OidcAnspruecke> = {}): OidcAnspruecke {
|
||
return {
|
||
sub: "anbieter-sub-mitarbeiter",
|
||
email: "maria.huber@firma.example",
|
||
emailBestaetigt: true,
|
||
name: "Maria Huber",
|
||
gruppen: [],
|
||
upn: "mhuber@firma.local",
|
||
ziel: "/",
|
||
...ueberschreiben,
|
||
};
|
||
}
|
||
|
||
async function aufbau() {
|
||
const kunde = await testPrisma.kunde.create({
|
||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||
});
|
||
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
|
||
|
||
const maria = await testPrisma.mitarbeiter.create({
|
||
data: {
|
||
kundeId: kunde.id,
|
||
vorname: "Maria",
|
||
nachname: "Huber",
|
||
anzeigename: "Maria Huber",
|
||
upn: "mhuber@firma.local",
|
||
mail: "maria.huber@firma.example",
|
||
abteilung: "Buchhaltung",
|
||
titel: "Sachbearbeiterin",
|
||
},
|
||
});
|
||
const kollege = await testPrisma.mitarbeiter.create({
|
||
data: {
|
||
kundeId: kunde.id,
|
||
vorname: "Ali",
|
||
nachname: "Demir",
|
||
anzeigename: "Ali Demir",
|
||
upn: "ademir@firma.local",
|
||
},
|
||
});
|
||
|
||
const meinNotebook = await testPrisma.geraet.create({
|
||
data: { kundeId: kunde.id, mitarbeiterId: maria.id, tagCode: "LZ-MEANBC", hostname: "NB-042" },
|
||
});
|
||
const fremdesNotebook = await testPrisma.geraet.create({
|
||
data: { kundeId: kunde.id, mitarbeiterId: kollege.id, tagCode: "LZ-FREMDA", hostname: "NB-099" },
|
||
});
|
||
|
||
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
|
||
const version = await testPrisma.vorlagenVersion.create({
|
||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||
});
|
||
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||
data: { versionId: version.id, titel: "Konto", position: 0 },
|
||
});
|
||
const schritt = await testPrisma.vorlagenSchritt.create({
|
||
data: {
|
||
abschnittId: abschnitt.id,
|
||
titel: "Konto anlegen",
|
||
typ: "BEFEHL",
|
||
position: 0,
|
||
konfig: {
|
||
shell: "powershell",
|
||
befehl: 'New-ADUser -AccountPassword "{{generiertes_passwort}}"',
|
||
},
|
||
},
|
||
});
|
||
await testPrisma.vorlagenVersion.update({
|
||
where: { id: version.id },
|
||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||
});
|
||
|
||
const admin = await testPrisma.benutzer.create({
|
||
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
|
||
});
|
||
|
||
/* Ein Lauf an **meinem** Geraet, mit Notiz, Kommentar und Anhang daran. */
|
||
const meinLauf = await testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
kundeId: kunde.id,
|
||
titel: "PC Neuinstallation – NB-042",
|
||
geraetId: meinNotebook.id,
|
||
mitarbeiterId: maria.id,
|
||
status: LaufStatus.ABGESCHLOSSEN,
|
||
gestartetAm: new Date("2026-09-01T08:00:00Z"),
|
||
abgeschlossenAm: new Date("2026-09-02T15:00:00Z"),
|
||
},
|
||
});
|
||
const ergebnis = await testPrisma.schrittergebnis.create({
|
||
data: {
|
||
laufId: meinLauf.id,
|
||
vorlagenSchrittId: schritt.id,
|
||
zustand: "ERLEDIGT",
|
||
notiz: "Nutzerin war unkooperativ, Gerät kam beschädigt zurück",
|
||
wert: "irgendein Wert",
|
||
aktualisiertVonId: admin.id,
|
||
},
|
||
});
|
||
await testPrisma.kommentar.create({
|
||
data: { laufId: meinLauf.id, benutzerId: admin.id, rumpf: "Rückfrage ans Team wegen der Lizenz" },
|
||
});
|
||
await testPrisma.anhang.create({
|
||
data: {
|
||
schrittergebnisId: ergebnis.id,
|
||
laufId: meinLauf.id,
|
||
kundeId: kunde.id,
|
||
dateiname: "schreibtisch.png",
|
||
medientyp: "image/png",
|
||
groesse: 10,
|
||
pruefsumme: "d".repeat(64),
|
||
},
|
||
});
|
||
|
||
/* Ein Lauf am Geraet des Kollegen – darf nie auftauchen. */
|
||
const fremderLauf = await testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
kundeId: kunde.id,
|
||
titel: "PC Neuinstallation – NB-099",
|
||
geraetId: fremdesNotebook.id,
|
||
mitarbeiterId: kollege.id,
|
||
status: LaufStatus.IN_ARBEIT,
|
||
gestartetAm: new Date(),
|
||
},
|
||
});
|
||
|
||
/* Ein Lauf auf meine Person, ohne Geraet: das Onboarding. */
|
||
const persoenlich = await testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
kundeId: kunde.id,
|
||
titel: "Onboarding Maria Huber",
|
||
mitarbeiterId: maria.id,
|
||
status: LaufStatus.ABGESCHLOSSEN,
|
||
gestartetAm: new Date("2026-08-01T08:00:00Z"),
|
||
abgeschlossenAm: new Date("2026-08-01T12:00:00Z"),
|
||
},
|
||
});
|
||
|
||
return { kunde, maria, kollege, meinNotebook, fremdesNotebook, meinLauf, fremderLauf, persoenlich };
|
||
}
|
||
|
||
describe("Die Rolle MITARBEITER", () => {
|
||
it("darf genau die Selbstauskunft und sonst nichts", () => {
|
||
expect(darf("MITARBEITER", "selbstauskunft")).toBe(true);
|
||
expect(darf("MITARBEITER", "lesen")).toBe(false);
|
||
expect(darf("MITARBEITER", "laeufeBearbeiten")).toBe(false);
|
||
expect(darf("MITARBEITER", "geraeteBearbeiten")).toBe(false);
|
||
expect(darf("MITARBEITER", "mitarbeiterBearbeiten")).toBe(false);
|
||
expect(darf("MITARBEITER", "vorlagenBearbeiten")).toBe(false);
|
||
expect(darf("MITARBEITER", "archivVerwalten")).toBe(false);
|
||
expect(darf("MITARBEITER", "verwaltung")).toBe(false);
|
||
});
|
||
|
||
it("landet nach der Anmeldung auf der eigenen Seite und nicht auf „kein Zugriff“", () => {
|
||
/*
|
||
* Beim Durchklicken gefunden: das Standardziel nach der Anmeldung ist `/`,
|
||
* und die Uebersicht verlangt `lesen`. Ein MITARBEITER sah deshalb als
|
||
* Erstes „Dafuer fehlt dir die Berechtigung“ — genau die leere Seite, die
|
||
* dieses Issue vermeiden will. Dieselbe Funktion richtet auch den Knopf auf
|
||
* `/kein-zugriff`, der vorher im Kreis fuehrte.
|
||
*/
|
||
expect(startseite("MITARBEITER")).toBe("/meine-geraete");
|
||
for (const rolle of ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"] as const) {
|
||
expect(startseite(rolle), `${rolle} faengt auf der Uebersicht an`).toBe("/");
|
||
}
|
||
});
|
||
|
||
it("sieht in der Navigation nur die eine Seite", () => {
|
||
/*
|
||
* „Alles andere ist weg, nicht grau“ — ein Eintrag, der auf
|
||
* `/kein-zugriff` fuehrt, ist schlimmer als keiner.
|
||
*/
|
||
const eintraege = navigationFuer("INTERN", (recht) => darf("MITARBEITER", recht));
|
||
expect(eintraege.map((e) => e.pfad)).toEqual(["/meine-geraete"]);
|
||
});
|
||
|
||
it("nimmt den anderen Rollen die Selbstauskunft nicht weg – sie brauchen sie nicht", () => {
|
||
for (const rolle of ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"] as const) {
|
||
const pfade = navigationFuer("INTERN", (recht) => darf(rolle, recht)).map((e) => e.pfad);
|
||
expect(pfade, `${rolle} sieht die Selbstauskunft nicht`).not.toContain("/meine-geraete");
|
||
expect(pfade.length, `${rolle} sieht die gewoehnliche Navigation`).toBeGreaterThan(1);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe.runIf(datenbankVerfuegbar)("Was ein Mitarbeiter sieht", () => {
|
||
beforeEach(async () => {
|
||
await datenbankLeeren();
|
||
});
|
||
|
||
it("sieht die Läufe seiner Geräte und keine anderen", async () => {
|
||
const { maria, meinLauf, fremderLauf } = await aufbau();
|
||
|
||
const geraete = await meineGeraete(maria.id);
|
||
expect(geraete).toHaveLength(1);
|
||
expect(geraete[0]?.bezeichnung).toBe("NB-042");
|
||
|
||
const laufIds = geraete.flatMap((g) => g.laeufe.map((l) => l.id));
|
||
expect(laufIds).toEqual([meinLauf.id]);
|
||
expect(laufIds).not.toContain(fremderLauf.id);
|
||
});
|
||
|
||
it("sieht die Läufe zu seiner Person ohne Gerät", async () => {
|
||
const { maria, persoenlich, meinLauf } = await aufbau();
|
||
const ohne = await meineLaeufeOhneGeraet(maria.id);
|
||
expect(ohne.map((l) => l.id)).toEqual([persoenlich.id]);
|
||
// Der Lauf am Geraet steht schon unter dem Geraet – hier waere er doppelt.
|
||
expect(ohne.map((l) => l.id)).not.toContain(meinLauf.id);
|
||
});
|
||
|
||
it("sieht keine Schritte, Notizen, Kommentare oder Anhänge – geprüft über die Antwort", async () => {
|
||
/*
|
||
* Das Kernkriterium. Geprueft wird die **Antwort** und nicht die Seite: im
|
||
* Aufbau haengen an dem Lauf eine Notiz („Nutzerin war unkooperativ“), ein
|
||
* Kommentar, ein Anhang und ein Befehlsschritt mit
|
||
* `{{generiertes_passwort}}`. Nichts davon darf durchkommen.
|
||
*/
|
||
const { maria } = await aufbau();
|
||
const antwort = JSON.stringify([
|
||
await meineGeraete(maria.id),
|
||
await meineLaeufeOhneGeraet(maria.id),
|
||
await meineDaten(maria.id),
|
||
]);
|
||
|
||
expect(antwort).not.toContain("unkooperativ");
|
||
expect(antwort).not.toContain("Rückfrage ans Team");
|
||
expect(antwort).not.toContain("schreibtisch.png");
|
||
expect(antwort).not.toContain("New-ADUser");
|
||
expect(antwort).not.toContain("{{");
|
||
expect(antwort).not.toContain("irgendein Wert");
|
||
expect(antwort).not.toContain("ERLEDIGT");
|
||
|
||
// Und was durchkommen **soll**, kommt durch.
|
||
expect(antwort).toContain("PC Neuinstallation – NB-042");
|
||
expect(antwort).toContain("ABGESCHLOSSEN");
|
||
});
|
||
|
||
it("holt die verbotenen Felder nicht einmal aus der Datenbank", () => {
|
||
/*
|
||
* **Gefunden durch eine Gegenprobe, die gruen blieb.** Der Test unten prueft
|
||
* die Antwort, und die ist sicher: `laufBauen` setzt sie aus den erlaubten
|
||
* Feldern zusammen, also kommt auch bei einem zu weiten `select` nichts
|
||
* durch. Genau deshalb blieb er gruen, als im `select` versuchsweise
|
||
* `ergebnisse: { notiz, wert }` stand — waehrend der Kopfkommentar der Datei
|
||
* behauptet, das `select` hole es „gar nicht erst".
|
||
*
|
||
* Diese Behauptung braucht ihre eigene Deckung. Sie ist nicht bloss Zierde:
|
||
* ein `select`, das Notizen mitliest, zieht sie durch Protokolle und
|
||
* Fehlerberichte, und der naechste, der `laufBauen` erweitert, hat sie schon
|
||
* zur Hand.
|
||
*/
|
||
const quelle = readFileSync(join(process.cwd(), "src/server/queries/selbstauskunft.ts"), "utf8");
|
||
for (const verboten of ["notiz", "wert", "ergebnisse", "kommentare", "anhaenge", "konfig"]) {
|
||
expect(quelle, `das Lesemodell darf "${verboten}" nicht abfragen`).not.toContain(`${verboten}: true`);
|
||
expect(quelle, `das Lesemodell darf "${verboten}" nicht einbinden`).not.toContain(`${verboten}: {`);
|
||
}
|
||
});
|
||
|
||
it("gibt je Lauf genau die erlaubten Felder heraus", async () => {
|
||
// Namentlich aufgezaehlt: ein zusaetzliches Feld im Lesemodell faellt hier
|
||
// auf und nicht erst, wenn es auf der Seite steht.
|
||
const { maria } = await aufbau();
|
||
const geraete = await meineGeraete(maria.id);
|
||
const lauf = geraete[0]?.laeufe[0];
|
||
expect(lauf).toBeDefined();
|
||
expect(Object.keys(lauf ?? {}).sort()).toEqual([
|
||
"abgeschlossenAm",
|
||
"begonnenAm",
|
||
"erstelltAm",
|
||
"id",
|
||
"status",
|
||
"titel",
|
||
"vorlagenName",
|
||
]);
|
||
});
|
||
|
||
it("zeigt archivierte Geräte weiter – „mein altes Notebook“ ist die Frage", async () => {
|
||
const { maria, meinNotebook } = await aufbau();
|
||
await testPrisma.geraet.update({ where: { id: meinNotebook.id }, data: { archiviert: true } });
|
||
|
||
const geraete = await meineGeraete(maria.id);
|
||
expect(geraete).toHaveLength(1);
|
||
expect(geraete[0]?.archiviert).toBe(true);
|
||
});
|
||
|
||
it("liefert die eigenen Stammdaten, aber keine fremden", async () => {
|
||
const { maria, kollege } = await aufbau();
|
||
const daten = await meineDaten(maria.id);
|
||
expect(daten).toMatchObject({ anzeigename: "Maria Huber", abteilung: "Buchhaltung" });
|
||
|
||
// Die Funktion nimmt eine Kennung – mit der des Kollegen bekaeme man dessen
|
||
// Daten. Genau deshalb kommt sie aus der Sitzung und nie aus einer Eingabe.
|
||
const fremd = await meineDaten(kollege.id);
|
||
expect(fremd?.anzeigename).toBe("Ali Demir");
|
||
});
|
||
|
||
it("gibt für jemanden ohne Geräte eine leere Liste und keinen Fehler", async () => {
|
||
const { kollege, fremdesNotebook } = await aufbau();
|
||
await testPrisma.lauf.deleteMany({ where: { geraetId: fremdesNotebook.id } });
|
||
await testPrisma.geraet.delete({ where: { id: fremdesNotebook.id } });
|
||
|
||
expect(await meineGeraete(kollege.id)).toEqual([]);
|
||
expect(await meineLaeufeOhneGeraet(kollege.id)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe.runIf(datenbankVerfuegbar)("Zuordnung bei der Anmeldung", () => {
|
||
beforeEach(async () => {
|
||
await datenbankLeeren();
|
||
process.env = { ...UMGEBUNG };
|
||
// Zuordnung aktiv – sonst greift die Selbstauskunft absichtlich gar nicht.
|
||
process.env["OIDC_GROUP_ADMIN"] = "lz-admin";
|
||
process.env["OIDC_GROUP_TECHNICIAN"] = "lz-technik";
|
||
});
|
||
|
||
it("macht aus einer Anmeldung ohne Rollengruppe einen MITARBEITER", async () => {
|
||
const { maria } = await aufbau();
|
||
|
||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke());
|
||
|
||
expect(benutzer.rolle).toBe("MITARBEITER");
|
||
expect(benutzer.mitarbeiterId).toBe(maria.id);
|
||
});
|
||
|
||
it("ordnet unabhängig von Groß- und Kleinschreibung zu", async () => {
|
||
const { maria } = await aufbau();
|
||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ upn: "MHuber@Firma.Local" }));
|
||
expect(benutzer.mitarbeiterId).toBe(maria.id);
|
||
});
|
||
|
||
it("lässt eine echte Rollengruppe vorgehen und ordnet dann keine Person zu", async () => {
|
||
await aufbau();
|
||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-technik"] }));
|
||
expect(benutzer.rolle).toBe("TECHNIKER");
|
||
// Wer eine echte Rolle hat, sieht ueber `lesen` ohnehin alles – eine
|
||
// Zuordnung daneben waere ein zweiter Weg zu denselben Daten.
|
||
expect(benutzer.mitarbeiterId).toBeNull();
|
||
});
|
||
|
||
it("weist ab, wenn es zu dem UPN keine Person gibt", async () => {
|
||
await aufbau();
|
||
await expect(benutzerAusOidcUebernehmen(anspruecke({ upn: "niemand@firma.local" }))).rejects.toThrow(
|
||
AnmeldeFehler,
|
||
);
|
||
|
||
// Und kein halb angelegtes Konto hinterlassen.
|
||
expect(await testPrisma.benutzer.count({ where: { email: "maria.huber@firma.example" } })).toBe(0);
|
||
});
|
||
|
||
it("nennt den Grund, damit die Anmeldeseite etwas Hilfreiches sagen kann", async () => {
|
||
await aufbau();
|
||
try {
|
||
await benutzerAusOidcUebernehmen(anspruecke({ upn: "niemand@firma.local" }));
|
||
throw new Error("haette werfen muessen");
|
||
} catch (fehler) {
|
||
expect(fehler).toBeInstanceOf(AnmeldeFehler);
|
||
expect((fehler as AnmeldeFehler).grund).toBe("keine-person");
|
||
}
|
||
});
|
||
|
||
it("weist einen mehrdeutigen UPN ab und protokolliert ihn", async () => {
|
||
const { kunde } = await aufbau();
|
||
// Zwei Personen mit demselben UPN – etwa zwei Mandanten derselben Firma.
|
||
const zweiter = await testPrisma.kunde.create({ data: { name: "Zweite GmbH", kuerzel: "ZWEI" } });
|
||
await testPrisma.mitarbeiter.create({
|
||
data: {
|
||
kundeId: zweiter.id,
|
||
vorname: "Maria",
|
||
nachname: "Huber",
|
||
anzeigename: "Maria Huber (zweite)",
|
||
upn: "mhuber@firma.local",
|
||
},
|
||
});
|
||
expect(kunde.id).toBeTruthy();
|
||
|
||
try {
|
||
await benutzerAusOidcUebernehmen(anspruecke());
|
||
throw new Error("haette werfen muessen");
|
||
} catch (fehler) {
|
||
expect((fehler as AnmeldeFehler).grund).toBe("upn-mehrdeutig");
|
||
}
|
||
|
||
const protokoll = await testPrisma.protokoll.findMany({
|
||
where: { aktion: { contains: "mehrdeutig" } },
|
||
});
|
||
expect(protokoll).toHaveLength(1);
|
||
// Ohne Urheber: das Konto gibt es an der Stelle vielleicht noch nicht.
|
||
expect(protokoll[0]?.benutzerId).toBeNull();
|
||
expect(JSON.stringify(protokoll[0]?.nachher)).toContain("mhuber@firma.local");
|
||
expect(protokoll[0]?.nachher).toMatchObject({ treffer: 2 });
|
||
});
|
||
|
||
it("nennt im Protokoll die wirkliche Zahl der Treffer, nicht zwei", async () => {
|
||
/*
|
||
* **Die Zahl war geraten.** `personZuUpn` hatte `take: 2` — fuer die
|
||
* Entscheidung „mehrdeutig“ genuegt das, aber dieselbe Zahl ging in den
|
||
* Protokolleintrag und sagte dort immer „2“, auch bei vier Treffern
|
||
* (Reviewbefund zu #029).
|
||
*
|
||
* Wer das Protokoll liest, liest es, **um den Fall aufzuloesen** — er muss
|
||
* wissen, wie viele Zeilen er zu bereinigen hat. Eine Zahl, die nicht die
|
||
* Zahl ist, schickt ihn nach der zweiten Korrektur zurueck.
|
||
*/
|
||
await aufbau();
|
||
for (const kuerzel of ["ZWEI", "DREI", "VIER"]) {
|
||
const weiterer = await testPrisma.kunde.create({
|
||
data: { name: `${kuerzel} GmbH`, kuerzel },
|
||
});
|
||
await testPrisma.mitarbeiter.create({
|
||
data: {
|
||
kundeId: weiterer.id,
|
||
vorname: "Maria",
|
||
nachname: "Huber",
|
||
anzeigename: `Maria Huber (${kuerzel})`,
|
||
upn: "mhuber@firma.local",
|
||
},
|
||
});
|
||
}
|
||
|
||
await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toThrow();
|
||
|
||
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||
where: { aktion: { contains: "mehrdeutig" } },
|
||
});
|
||
expect(eintrag.nachher).toMatchObject({ treffer: 4 });
|
||
});
|
||
|
||
it("ordnet keine deaktivierte Person zu", async () => {
|
||
const { maria } = await aufbau();
|
||
await testPrisma.mitarbeiter.update({ where: { id: maria.id }, data: { aktiv: false } });
|
||
|
||
await expect(benutzerAusOidcUebernehmen(anspruecke())).rejects.toThrow(AnmeldeFehler);
|
||
});
|
||
|
||
it("greift gar nicht, solange keine Gruppen-Zuordnung eingerichtet ist", async () => {
|
||
/*
|
||
* Die Wache gegen ein Update, das Leute aussperrt. Ohne abgebildete Gruppen
|
||
* kann die Anwendung nicht unterscheiden, ob jemand Techniker oder
|
||
* Mitarbeiter ist — dann bleibt alles wie vor #035.
|
||
*/
|
||
delete process.env["OIDC_GROUP_ADMIN"];
|
||
delete process.env["OIDC_GROUP_TECHNICIAN"];
|
||
await aufbau();
|
||
|
||
const benutzer = await benutzerAusOidcUebernehmen(anspruecke());
|
||
expect(benutzer.rolle).toBe("LESER");
|
||
expect(benutzer.mitarbeiterId).toBeNull();
|
||
});
|
||
|
||
it("stuft bei der nächsten Anmeldung um, wenn jemand eine Rollengruppe bekommt", async () => {
|
||
const { maria } = await aufbau();
|
||
const erst = await benutzerAusOidcUebernehmen(anspruecke());
|
||
expect(erst.rolle).toBe("MITARBEITER");
|
||
expect(erst.mitarbeiterId).toBe(maria.id);
|
||
|
||
const dann = await benutzerAusOidcUebernehmen(anspruecke({ gruppen: ["lz-admin"] }));
|
||
expect(dann.rolle).toBe("ADMIN");
|
||
// Die Zuordnung muss **mit** verschwinden, sonst verletzt sie den CHECK.
|
||
expect(dann.mitarbeiterId).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe.runIf(datenbankVerfuegbar)("Was die Datenbank selbst festhält", () => {
|
||
beforeEach(async () => {
|
||
await datenbankLeeren();
|
||
});
|
||
|
||
it("lässt die Rolle MITARBEITER nicht ohne Zuordnung zu", async () => {
|
||
await expect(
|
||
testPrisma.benutzer.create({
|
||
data: { email: "ohne@firma.example", name: "Ohne Person", rolle: "MITARBEITER" },
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("lässt eine Zuordnung nicht an einer anderen Rolle zu", async () => {
|
||
const { maria } = await aufbau();
|
||
await expect(
|
||
testPrisma.benutzer.create({
|
||
data: {
|
||
email: "admin2@firma.example",
|
||
name: "Admin mit Person",
|
||
rolle: "ADMIN",
|
||
mitarbeiterId: maria.id,
|
||
},
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("gibt eine Person an höchstens ein Konto", async () => {
|
||
const { maria } = await aufbau();
|
||
await testPrisma.benutzer.create({
|
||
data: {
|
||
email: "maria@firma.example",
|
||
name: "Maria Huber",
|
||
rolle: "MITARBEITER",
|
||
mitarbeiterId: maria.id,
|
||
},
|
||
});
|
||
await expect(
|
||
testPrisma.benutzer.create({
|
||
data: {
|
||
email: "maria.zweitkonto@firma.example",
|
||
name: "Maria Huber (zweites Konto)",
|
||
rolle: "MITARBEITER",
|
||
mitarbeiterId: maria.id,
|
||
},
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("zieht die Rolle mit, wenn die Person gelöscht wird", async () => {
|
||
/*
|
||
* `ON DELETE SET NULL` allein wuerde am CHECK scheitern: ein MITARBEITER
|
||
* ohne Zuordnung ist verboten. Der Trigger setzt deshalb beides zugleich —
|
||
* sonst waere das Loeschen eines Mitarbeiters unmoeglich statt folgerichtig.
|
||
*/
|
||
const { kunde } = await aufbau();
|
||
const person = await testPrisma.mitarbeiter.create({
|
||
data: {
|
||
kundeId: kunde.id,
|
||
vorname: "Weg",
|
||
nachname: "Damit",
|
||
anzeigename: "Weg Damit",
|
||
upn: "wdamit@firma.local",
|
||
},
|
||
});
|
||
const konto = await testPrisma.benutzer.create({
|
||
data: {
|
||
email: "weg@firma.example",
|
||
name: "Weg Damit",
|
||
rolle: "MITARBEITER",
|
||
mitarbeiterId: person.id,
|
||
},
|
||
});
|
||
|
||
await testPrisma.mitarbeiter.delete({ where: { id: person.id } });
|
||
|
||
const nachher = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: konto.id } });
|
||
expect(nachher.rolle).toBe("LESER");
|
||
expect(nachher.mitarbeiterId).toBeNull();
|
||
// Das Konto bleibt – nur ohne Person.
|
||
expect(nachher.aktiv).toBe(true);
|
||
});
|
||
});
|