feat(mandant): Betriebsmodus, Kundenverwaltung und Einstellungen (#22)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Zwei Betriebsmodi, ein Codepfad. src/server/mandant.ts ist die eine Stelle,
an der die Mandantenregel steht.

Der Review fand drei Blocker: die Lesemodelle wandten die Mandantensicht nicht
an, der Protokolleintrag des Moduswechsels stand außerhalb der Transaktion, und
Prüfung und Vollzug lagen so weit auseinander, dass zwei gleichzeitige Wechsel
beide durchliefen. Alle drei geschlossen, der letzte mit einem Advisory Lock.

336 Tests.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #22.
This commit is contained in:
2026-09-25 05:03:11 +00:00
co-authored by Claude Opus 5
parent 40e95dbe5f
commit ecc3c579df
43 changed files with 2729 additions and 53 deletions
+1 -1
View File
@@ -363,7 +363,7 @@ describe.runIf(datenbankVerfuegbar)("Anmeldung und Benutzerpflege", () => {
const frisch = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: ziel.id } });
expect(frisch.rolle).toBe("REDAKTEUR");
const protokoll = await testPrisma.protokoll.findFirst({
where: { entitaetId: ziel.id, aktion: "Rolle geaendert" },
where: { entitaetId: ziel.id, aktion: "Rolle geändert" },
});
expect(protokoll?.vorher).toEqual({ rolle: "LESER" });
expect(protokoll?.nachher).toEqual({ rolle: "REDAKTEUR" });
+359
View File
@@ -0,0 +1,359 @@
import { beforeEach, describe, expect, it } from "vitest";
import { kundeHolen, kundenListe, kundenZurAuswahl } from "@/server/queries/kunden";
import {
KundenFehler,
kuerzelPruefen,
kundeAnlegen,
kundeArchivieren,
kundeBearbeiten,
type KundenAenderung,
type NeuerKunde,
} from "@/server/services/kunden";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Die Lesemodelle verlangen einen Benutzer; ein Administrator sieht alles. */
const ADMIN = { id: "admin", rolle: "ADMIN" } as const;
/**
* Kundenverwaltung gegen die echte Datenbank (#008).
*
* Zwei Regeln haengen unmittelbar am Betriebsmodus und stehen deshalb hier fest:
* die eigene Organisation entsteht nicht ueber `kundeAnlegen`, und sie laesst
* sich nicht archivieren – sonst waere der Modus `INTERN` ohne Kunden.
*/
/** Urheber fuer die Protokolleintraege; ohne echten Benutzer greift der Fremdschluessel. */
async function adminAnlegen() {
return testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" },
});
}
async function eigeneOrganisationAnlegen() {
return testPrisma.kunde.create({
data: { name: "Eigene Firma", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
}
/** 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({
data: {
vorlageId: vorlage.id,
version: 1,
status: "VEROEFFENTLICHT",
veroeffentlichtAm: new Date(),
},
});
}
describe.runIf(datenbankVerfuegbar)("Kundenverwaltung", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Kuerzel", () => {
it("trimmt und schreibt gross", () => {
expect(kuerzelPruefen("mus")).toBe("MUS");
expect(kuerzelPruefen(" REI ")).toBe("REI");
expect(kuerzelPruefen("mus ")).toBe("MUS");
});
it("weist alles ab, was nicht 2 bis 6 Zeichen A-Z und 0-9 ist", () => {
// "MUeS" waere in Ordnung, ein Umlaut nicht: das Kuerzel steht auf
// Etiketten und in Hostnamen.
for (const falsch of ["X", "ZULANG7", "MU-S", "MU_S", "MÜS", "", " "]) {
expect(() => kuerzelPruefen(falsch)).toThrow(KundenFehler);
}
});
it("sagt in der Meldung, was erwartet wird", () => {
expect(() => kuerzelPruefen("X")).toThrow(/2 bis 6 Zeichen/);
});
it("nimmt das getrimmte Kuerzel beim Anlegen", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: " Musterfirma ", kuerzel: " mus " }, admin.id);
expect(kunde.kuerzel).toBe("MUS");
expect(kunde.name).toBe("Musterfirma");
});
it("weist ein doppeltes Kuerzel ab und benennt den vorhandenen Kunden", async () => {
const admin = await adminAnlegen();
await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
await expect(kundeAnlegen({ name: "Muster GmbH", kuerzel: "mus" }, admin.id)).rejects.toThrow(
/Musterfirma/,
);
expect(await testPrisma.kunde.count()).toBe(1);
});
it("weist ein doppeltes Kuerzel auch gegen einen archivierten Kunden ab", async () => {
// Sonst waere das Kuerzel zweimal im Umlauf, sobald jemand den alten
// Kunden zurueckholt – und der eindeutige Index kennt kein Archiv.
const admin = await adminAnlegen();
const alt = await kundeAnlegen({ name: "Alte Firma", kuerzel: "ALT" }, admin.id);
await kundeArchivieren(alt.id, true, admin.id);
await expect(kundeAnlegen({ name: "Neue Firma", kuerzel: "ALT" }, admin.id)).rejects.toBeInstanceOf(
KundenFehler,
);
});
it("weist ein doppeltes Kuerzel auch beim Bearbeiten ab", async () => {
const admin = await adminAnlegen();
await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
const rei = await kundeAnlegen({ name: "Baeckerei Reiter", kuerzel: "REI" }, admin.id);
await expect(kundeBearbeiten(rei.id, { kuerzel: "MUS" }, admin.id)).rejects.toBeInstanceOf(
KundenFehler,
);
const unveraendert = await testPrisma.kunde.findUniqueOrThrow({ where: { id: rei.id } });
expect(unveraendert.kuerzel).toBe("REI");
});
});
describe("Name", () => {
it("weist einen leeren Namen ab", async () => {
const admin = await adminAnlegen();
for (const name of ["", " "]) {
await expect(kundeAnlegen({ name, kuerzel: "MUS" }, admin.id)).rejects.toBeInstanceOf(KundenFehler);
}
expect(await testPrisma.kunde.count()).toBe(0);
});
it("weist einen leeren Namen auch beim Bearbeiten ab", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
await expect(kundeBearbeiten(kunde.id, { name: " " }, admin.id)).rejects.toBeInstanceOf(KundenFehler);
});
});
describe("Eigene Organisation", () => {
it("entsteht nie ueber kundeAnlegen, egal was in der Eingabe steht", async () => {
// Sie entsteht nur im Seed und beim Moduswechsel. Die Eingabe wird hier
// absichtlich ueber die Typgrenze hinaus befuellt, wie es ein Aufrufer aus
// einem Formular koennte.
const admin = await adminAnlegen();
const eingabe = { name: "Untergeschoben", kuerzel: "UGS", istEigeneOrganisation: true } as NeuerKunde;
const kunde = await kundeAnlegen(eingabe, admin.id);
expect(kunde.istEigeneOrganisation).toBe(false);
expect(await testPrisma.kunde.count({ where: { istEigeneOrganisation: true } })).toBe(0);
});
it("laesst sich nicht archivieren", async () => {
const admin = await adminAnlegen();
const eigene = await eigeneOrganisationAnlegen();
await expect(kundeArchivieren(eigene.id, true, admin.id)).rejects.toThrow(/einzige Kunde/);
const unveraendert = await testPrisma.kunde.findUniqueOrThrow({ where: { id: eigene.id } });
expect(unveraendert.archiviert).toBe(false);
expect(await testPrisma.protokoll.count({ where: { entitaetId: eigene.id } })).toBe(0);
});
it("laesst sich umbenennen, ohne ihre Rolle zu verlieren", async () => {
const admin = await adminAnlegen();
const eigene = await eigeneOrganisationAnlegen();
const geaendert = await kundeBearbeiten(eigene.id, { name: "Neuer Firmenname" }, admin.id);
expect(geaendert.name).toBe("Neuer Firmenname");
expect(geaendert.istEigeneOrganisation).toBe(true);
});
});
describe("Bearbeiten", () => {
it("aendert istEigeneOrganisation nicht", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
const aenderung = { name: "Musterfirma AG", istEigeneOrganisation: true } as KundenAenderung;
const geaendert = await kundeBearbeiten(kunde.id, aenderung, admin.id);
expect(geaendert.name).toBe("Musterfirma AG");
expect(geaendert.istEigeneOrganisation).toBe(false);
});
it("protokolliert das Umbenennen mit Vorher und Nachher", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
await kundeBearbeiten(kunde.id, { name: "Musterfirma AG" }, admin.id);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "Kunde", entitaetId: kunde.id, aktion: { contains: "geändert" } },
});
expect(eintraege).toHaveLength(1);
expect(eintraege[0]?.vorher).toEqual({ name: "Musterfirma" });
expect(eintraege[0]?.nachher).toEqual({ name: "Musterfirma AG" });
expect(eintraege[0]?.benutzerId).toBe(admin.id);
});
it("protokolliert eine Kuerzelaenderung mit Vorher und Nachher", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
await kundeBearbeiten(kunde.id, { kuerzel: "mu2" }, admin.id);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaetId: kunde.id, aktion: { contains: "Kürzel" } },
});
expect(eintraege).toHaveLength(1);
expect(eintraege[0]?.vorher).toEqual({ kuerzel: "MUS" });
expect(eintraege[0]?.nachher).toEqual({ kuerzel: "MU2" });
});
it("schreibt nichts, wenn sich nichts aendert", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen(
{ name: "Musterfirma", kuerzel: "MUS", notizen: "Zwei Standorte" },
admin.id,
);
await kundeBearbeiten(
kunde.id,
// Auch die Schreibweise des Kuerzels macht keinen Unterschied, weil es
// vor dem Vergleich normalisiert wird.
{ name: " Musterfirma ", kuerzel: "mus", notizen: "Zwei Standorte" },
admin.id,
);
expect(
await testPrisma.protokoll.count({
where: { entitaetId: kunde.id, aktion: { contains: "geändert" } },
}),
).toBe(0);
});
it("loescht Notizen mit null", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS", notizen: "Alt" }, admin.id);
const geaendert = await kundeBearbeiten(kunde.id, { notizen: null }, admin.id);
expect(geaendert.notizen).toBeNull();
});
it("weist eine unbekannte Kennung mit einer eigenen Meldung ab", async () => {
const admin = await adminAnlegen();
await expect(kundeBearbeiten("gibtsnicht", { name: "Egal" }, admin.id)).rejects.toBeInstanceOf(
KundenFehler,
);
});
});
describe("Archivieren", () => {
it("archiviert und gibt wieder frei, je mit Protokolleintrag", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
await kundeArchivieren(kunde.id, true, admin.id);
expect((await testPrisma.kunde.findUniqueOrThrow({ where: { id: kunde.id } })).archiviert).toBe(true);
await kundeArchivieren(kunde.id, false, admin.id);
expect((await testPrisma.kunde.findUniqueOrThrow({ where: { id: kunde.id } })).archiviert).toBe(false);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "Kunde", entitaetId: kunde.id, aktion: { contains: "Kunde " } },
orderBy: { erstelltAm: "asc" },
});
// Angelegt, archiviert, wieder freigegeben.
expect(eintraege.map((eintrag) => eintrag.aktion)).toEqual([
"Kunde angelegt",
"Kunde archiviert",
"Kunde wieder freigegeben",
]);
expect(eintraege[1]?.vorher).toEqual({ archiviert: false });
expect(eintraege[1]?.nachher).toEqual({ archiviert: true });
expect(eintraege[2]?.nachher).toEqual({ archiviert: false });
});
it("schreibt nichts, wenn der Zustand schon stimmt", async () => {
const admin = await adminAnlegen();
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
await kundeArchivieren(kunde.id, false, admin.id);
expect(await testPrisma.protokoll.count({ where: { entitaetId: kunde.id } })).toBe(1);
});
});
describe("Lesemodelle", () => {
/** Ein Kunde mit zwei Geraeten, einem Mitarbeiter und einem Lauf. */
async function kundeMitBestandAnlegen(adminId: string) {
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, adminId);
await testPrisma.geraet.create({ data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q" } });
await testPrisma.geraet.create({ data: { kundeId: kunde.id, tagCode: "LZ-M2XH4T" } });
await testPrisma.mitarbeiter.create({
data: { kundeId: kunde.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
});
const version = await veroeffentlichteVersionAnlegen();
await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "PC Neuinstallation",
kundeId: kunde.id,
erstelltVonId: adminId,
},
});
return kunde;
}
it("zaehlt Geraete, Mitarbeiter und Laeufe je Kunde", async () => {
const admin = await adminAnlegen();
const mus = await kundeMitBestandAnlegen(admin.id);
await kundeAnlegen({ name: "Baeckerei Reiter", kuerzel: "REI" }, admin.id);
const liste = await kundenListe(ADMIN);
const musZeile = liste.find((zeile) => zeile.id === mus.id);
expect(musZeile?.anzahlGeraete).toBe(2);
expect(musZeile?.anzahlMitarbeiter).toBe(1);
expect(musZeile?.anzahlLaeufe).toBe(1);
const reiZeile = liste.find((zeile) => zeile.kuerzel === "REI");
expect(reiZeile?.anzahlGeraete).toBe(0);
expect(reiZeile?.anzahlLaeufe).toBe(0);
});
it("sortiert nicht archivierte zuerst, dann nach Namen", async () => {
const admin = await adminAnlegen();
const zeta = await kundeAnlegen({ name: "Zeta Werke", kuerzel: "ZET" }, admin.id);
const alpha = await kundeAnlegen({ name: "Alpha AG", kuerzel: "ALP" }, admin.id);
const beta = await kundeAnlegen({ name: "Beta GmbH", kuerzel: "BET" }, admin.id);
await kundeArchivieren(alpha.id, true, admin.id);
const liste = await kundenListe(ADMIN, true);
expect(liste.map((zeile) => zeile.id)).toEqual([beta.id, zeta.id, alpha.id]);
});
it("blendet archivierte aus, solange nicht danach gefragt wird", async () => {
const admin = await adminAnlegen();
const mus = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
const alt = await kundeAnlegen({ name: "Alte Firma", kuerzel: "ALT" }, admin.id);
await kundeArchivieren(alt.id, true, admin.id);
expect((await kundenListe(ADMIN, false)).map((zeile) => zeile.id)).toEqual([mus.id]);
expect((await kundenListe(ADMIN)).map((zeile) => zeile.id)).toEqual([mus.id]);
expect((await kundenListe(ADMIN, true)).map((zeile) => zeile.id)).toContain(alt.id);
});
it("liefert einen einzelnen Kunden mit Zaehlungen, sonst undefined", async () => {
const admin = await adminAnlegen();
const mus = await kundeMitBestandAnlegen(admin.id);
const zeile = await kundeHolen(mus.id, ADMIN);
expect(zeile?.kuerzel).toBe("MUS");
expect(zeile?.anzahlGeraete).toBe(2);
expect(zeile?.istEigeneOrganisation).toBe(false);
expect(await kundeHolen("gibtsnicht", ADMIN)).toBeUndefined();
});
it("nennt im Auswahlfeld keine archivierten Kunden", async () => {
const admin = await adminAnlegen();
await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
const alt = await kundeAnlegen({ name: "Alte Firma", kuerzel: "ALT" }, admin.id);
await kundeArchivieren(alt.id, true, admin.id);
const auswahl = await kundenZurAuswahl(ADMIN);
expect(auswahl.map((eintrag) => eintrag.kuerzel)).toEqual(["MUS"]);
});
});
});
+504
View File
@@ -0,0 +1,504 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus } from "@/generated/prisma/enums";
import {
MandantFehler,
betriebsmodus,
eigeneOrganisation,
kundeErmitteln,
kundeZugriffPruefen,
kundenEbeneSichtbar,
sichtbareKundenIds,
} from "@/server/mandant";
import {
EinstellungsFehler,
einstellungenHolen,
einstellungenSpeichern,
modusWechseln,
modusWechselPruefen,
} from "@/server/services/einstellungen";
import { kundenListe, kundenZurAuswahl } from "@/server/queries/kunden";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Betriebsmodus und Mandantengrenze (#008).
*
* Hier haengen die Akzeptanzkriterien, die den ganzen Zwei-Modi-Entwurf tragen:
* dass im Modus INTERN eine mitgeschickte kundeId verworfen wird, dass der
* Wechsel alle Daten mitnimmt und dass der Rueckweg bei zwei Kunden abgelehnt
* wird.
*
* `betriebsmodus()` benutzt Reacts `cache`. Ausserhalb einer Anfrage merkt das
* nichts – nachgeprueft –, die Tests beeinflussen sich also nicht gegenseitig.
*/
const UMGEBUNG = { ...process.env };
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const organisation = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" },
});
return { organisation, admin };
}
describe.runIf(datenbankVerfuegbar)("Betriebsmodus und Mandantengrenze", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
afterEach(() => {
process.env = { ...UMGEBUNG };
});
describe("Modus lesen", () => {
it("ist INTERN, solange nichts eingestellt ist", async () => {
// Der sichere Standard: keine Kundenebene, nichts faellt auseinander.
expect(await betriebsmodus()).toBe(Betriebsmodus.INTERN);
});
it("liest den eingestellten Modus aus der Datenbank", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
expect(await betriebsmodus()).toBe(Betriebsmodus.DIENSTLEISTUNG);
});
it("zeigt die Kundenebene nur im Modus Dienstleistung", async () => {
await grundbestand(Betriebsmodus.INTERN);
expect(await kundenEbeneSichtbar()).toBe(false);
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
expect(await kundenEbeneSichtbar()).toBe(true);
});
it("sagt deutlich Bescheid, wenn die eigene Organisation fehlt", async () => {
await expect(eigeneOrganisation()).rejects.toThrow(/pnpm db:seed/);
});
});
describe("kundeErmitteln", () => {
/** Ein Administrator – die Sichtbarkeit schraenkt ihn nie ein. */
const ADMIN = { id: "admin", rolle: "ADMIN" } as const;
it("verwirft im Modus Intern eine mitgeschickte fremde kundeId", async () => {
// Das ist die Regel, die den Zwei-Modi-Entwurf sicher macht: im Modus
// INTERN gibt es keine Kundenwahl, also ist auch nichts zu entscheiden.
const { organisation } = await grundbestand(Betriebsmodus.INTERN);
const fremder = await testPrisma.kunde.create({
data: { name: "Fremdfirma", kuerzel: "FREMD" },
});
expect(await kundeErmitteln(fremder.id, ADMIN)).toBe(organisation.id);
expect(await kundeErmitteln("gibt-es-gar-nicht", ADMIN)).toBe(organisation.id);
expect(await kundeErmitteln(null, ADMIN)).toBe(organisation.id);
expect(await kundeErmitteln(undefined, ADMIN)).toBe(organisation.id);
});
it("verlangt im Modus Dienstleistung eine Wahl", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await expect(kundeErmitteln(undefined, ADMIN)).rejects.toBeInstanceOf(MandantFehler);
await expect(kundeErmitteln(" ", ADMIN)).rejects.toThrow(/Kunde gewählt/);
});
it("weist im Modus Dienstleistung einen unbekannten Kunden ab", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await expect(kundeErmitteln("gibt-es-nicht", ADMIN)).rejects.toThrow(/gibt es nicht/);
});
it("weist einen archivierten Kunden ab", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const archiviert = await testPrisma.kunde.create({
data: { name: "Alte Firma", kuerzel: "ALT", archiviert: true },
});
await expect(kundeErmitteln(archiviert.id, ADMIN)).rejects.toThrow(/archiviert/);
});
it("nimmt im Modus Dienstleistung einen gueltigen Kunden an", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const mus = await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
expect(await kundeErmitteln(mus.id, ADMIN)).toBe(mus.id);
});
it("weist einen Kunden ab, an den der Benutzer nicht gebunden ist", async () => {
// Die Sichtbarkeit steht in kundeErmitteln selbst, damit kein Aufrufer sie
// vergessen kann.
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: meiner.id },
});
expect(await kundeErmitteln(meiner.id, techniker)).toBe(meiner.id);
await expect(kundeErmitteln(fremder.id, techniker)).rejects.toThrow(/Berechtigung/);
});
});
describe("Sichtbare Kunden", () => {
async function technikerMitBindung() {
const { organisation } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const mus = await testPrisma.kunde.create({ data: { name: "Musterfirma", kuerzel: "MUS" } });
await testPrisma.kunde.create({ data: { name: "Baeckerei", kuerzel: "REI" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: mus.id },
});
return { organisation, mus, techniker };
}
it("schraenkt ohne gesetztes Praefix niemanden ein", async () => {
// Eine leere Bindung darf nicht als "sieht nichts" gelesen werden.
const { techniker } = await technikerMitBindung();
expect(await sichtbareKundenIds(techniker)).toBeUndefined();
});
it("schraenkt Techniker und Leser auf ihre Kunden ein", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const { mus, techniker } = await technikerMitBindung();
expect(await sichtbareKundenIds(techniker)).toEqual([mus.id]);
});
it("schraenkt Administrator und Redakteur nie ein", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await technikerMitBindung();
for (const rolle of ["ADMIN", "REDAKTEUR"] as const) {
expect(await sichtbareKundenIds({ id: "egal", rolle })).toBeUndefined();
}
});
it("schraenkt im Modus Intern nie ein", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await grundbestand(Betriebsmodus.INTERN);
const leser = await testPrisma.benutzer.create({
data: { email: "l@firma.example", name: "Leser", rolle: "LESER" },
});
expect(await sichtbareKundenIds(leser)).toBeUndefined();
});
it("weist den Zugriff auf einen fremden Kunden ab", async () => {
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const { organisation, mus, techniker } = await technikerMitBindung();
await expect(kundeZugriffPruefen(mus.id, techniker)).resolves.toBeUndefined();
await expect(kundeZugriffPruefen(organisation.id, techniker)).rejects.toBeInstanceOf(MandantFehler);
});
});
describe("Wechsel nach Dienstleistung", () => {
it("nimmt alle Daten auf den ersten Kunden mit", async () => {
const { organisation, admin } = await grundbestand(Betriebsmodus.INTERN);
await testPrisma.geraet.create({
data: {
kundeId: organisation.id,
tagCode: "LZ-7K3F9Q",
hostname: "PC-001",
registriertAm: new Date(),
},
});
await testPrisma.mitarbeiter.create({
data: {
kundeId: organisation.id,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
},
});
await testPrisma.abteilung.create({
data: { kundeId: organisation.id, name: "Buchhaltung" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
const version = await testPrisma.vorlagenVersion.create({
data: {
vorlageId: vorlage.id,
version: 1,
status: "VEROEFFENTLICHT",
veroeffentlichtAm: new Date(),
},
});
await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
titel: "PC-001 neu aufsetzen",
kundeId: organisation.id,
erstelltVonId: admin.id,
},
});
await modusWechseln(
Betriebsmodus.DIENSTLEISTUNG,
{ name: "Musterfirma GmbH", kuerzel: "mus" },
admin.id,
);
expect(await betriebsmodus()).toBe(Betriebsmodus.DIENSTLEISTUNG);
const danach = await testPrisma.kunde.findUniqueOrThrow({ where: { id: organisation.id } });
expect(danach.name).toBe("Musterfirma GmbH");
// Das Kuerzel wird in Grossbuchstaben normalisiert.
expect(danach.kuerzel).toBe("MUS");
// Es bleibt dieselbe Zeile, deshalb haengt nichts um.
expect(await testPrisma.geraet.count({ where: { kundeId: organisation.id } })).toBe(1);
expect(await testPrisma.mitarbeiter.count({ where: { kundeId: organisation.id } })).toBe(1);
expect(await testPrisma.abteilung.count({ where: { kundeId: organisation.id } })).toBe(1);
expect(await testPrisma.lauf.count({ where: { kundeId: organisation.id } })).toBe(1);
expect(await testPrisma.kunde.count()).toBe(1);
});
it("weist ein ungueltiges Kuerzel mit einer verstaendlichen Meldung ab", async () => {
const { admin } = await grundbestand(Betriebsmodus.INTERN);
for (const kuerzel of ["X", "ZULANG7", "MU-S"]) {
await expect(
modusWechseln(Betriebsmodus.DIENSTLEISTUNG, { name: "Firma", kuerzel }, admin.id),
).rejects.toThrow(/2 bis 6/);
}
// Nach einem abgelehnten Wechsel steht der Modus unveraendert.
expect(await betriebsmodus()).toBe(Betriebsmodus.INTERN);
});
it("weist ein bereits vergebenes Kuerzel ab", async () => {
const { admin } = await grundbestand(Betriebsmodus.INTERN);
await testPrisma.kunde.create({ data: { name: "Andere", kuerzel: "MUS" } });
await expect(
modusWechseln(Betriebsmodus.DIENSTLEISTUNG, { name: "Firma", kuerzel: "MUS" }, admin.id),
).rejects.toThrow(/gehört bereits/);
});
it("behaelt Name und Kuerzel, wenn nichts angegeben wird", async () => {
const { organisation, admin } = await grundbestand(Betriebsmodus.INTERN);
await modusWechseln(Betriebsmodus.DIENSTLEISTUNG, {}, admin.id);
const danach = await testPrisma.kunde.findUniqueOrThrow({ where: { id: organisation.id } });
expect(danach.name).toBe(organisation.name);
expect(danach.kuerzel).toBe(organisation.kuerzel);
});
it("haelt den Wechsel im Protokoll fest", async () => {
const { admin } = await grundbestand(Betriebsmodus.INTERN);
await modusWechseln(Betriebsmodus.DIENSTLEISTUNG, { name: "Firma", kuerzel: "FIR" }, admin.id);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { aktion: "Betriebsmodus gewechselt" },
});
expect(eintrag.benutzerId).toBe(admin.id);
expect(eintrag.vorher).toMatchObject({ modus: "INTERN" });
expect(eintrag.nachher).toMatchObject({ modus: "DIENSTLEISTUNG" });
});
});
describe("Wechsel zurueck nach Intern", () => {
it("wird bei zwei nicht archivierten Kunden abgelehnt", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await testPrisma.kunde.create({ data: { name: "Zweiter Kunde", kuerzel: "ZWEI" } });
const pruefung = await modusWechselPruefen(Betriebsmodus.INTERN);
expect(pruefung.moeglich).toBe(false);
expect(pruefung.grund).toMatch(/genau einem nicht archivierten Kunden/);
await expect(modusWechseln(Betriebsmodus.INTERN, {}, admin.id)).rejects.toBeInstanceOf(
EinstellungsFehler,
);
expect(await betriebsmodus()).toBe(Betriebsmodus.DIENSTLEISTUNG);
});
it("gelingt, sobald die uebrigen Kunden archiviert sind", async () => {
const { organisation, admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const zweiter = await testPrisma.kunde.create({
data: { name: "Zweiter Kunde", kuerzel: "ZWEI" },
});
await testPrisma.kunde.update({ where: { id: zweiter.id }, data: { archiviert: true } });
await modusWechseln(Betriebsmodus.INTERN, {}, admin.id);
expect(await betriebsmodus()).toBe(Betriebsmodus.INTERN);
expect((await eigeneOrganisation()).id).toBe(organisation.id);
// Archivierte Kunden bleiben archiviert und tauchen nicht wieder auf.
const archivierter = await testPrisma.kunde.findUniqueOrThrow({ where: { id: zweiter.id } });
expect(archivierter.archiviert).toBe(true);
});
it("haelt auch den Rueckweg im Protokoll fest", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await modusWechseln(Betriebsmodus.INTERN, {}, admin.id);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { aktion: "Betriebsmodus gewechselt" },
});
expect(eintrag.vorher).toMatchObject({ modus: "DIENSTLEISTUNG" });
expect(eintrag.nachher).toMatchObject({ modus: "INTERN" });
});
it("lehnt den Wechsel in den bereits eingestellten Modus ab", async () => {
const { admin } = await grundbestand(Betriebsmodus.INTERN);
const pruefung = await modusWechselPruefen(Betriebsmodus.INTERN);
expect(pruefung.moeglich).toBe(false);
await expect(modusWechseln(Betriebsmodus.INTERN, {}, admin.id)).rejects.toThrow(/bereits/);
});
});
describe("Der Wechsel ist unteilbar", () => {
it("nimmt den Moduswechsel zurueck, wenn das Protokoll nicht geschrieben werden kann", async () => {
// Frueher stand der Protokoll-Eintrag hinter der Transaktion: schlug er
// fehl, war der Modus schon gewechselt und der Vorgang stand nirgends.
await grundbestand(Betriebsmodus.INTERN);
await expect(
modusWechseln(
Betriebsmodus.DIENSTLEISTUNG,
{ name: "Firma", kuerzel: "FIR" },
"diesen-benutzer-gibt-es-nicht",
),
).rejects.toThrow();
expect(await betriebsmodus()).toBe(Betriebsmodus.INTERN);
expect(await testPrisma.protokoll.count()).toBe(0);
// Auch Name und Kuerzel duerfen nicht halb geaendert sein.
const organisation = await testPrisma.kunde.findFirstOrThrow({
where: { istEigeneOrganisation: true },
});
expect(organisation.name).toBe("Eigene Organisation");
expect(organisation.kuerzel).toBe("EIGEN");
});
it("laesst von zwei gleichzeitigen Wechseln nur einen durch", async () => {
// Ohne Schloss liefen beide durch, mit zwei Protokolleintraegen, die beide
// INTERN als Vorzustand behaupteten.
const { admin } = await grundbestand(Betriebsmodus.INTERN);
const ergebnisse = await Promise.allSettled([
modusWechseln(Betriebsmodus.DIENSTLEISTUNG, { name: "Erster", kuerzel: "AAA" }, admin.id),
modusWechseln(Betriebsmodus.DIENSTLEISTUNG, { name: "Zweiter", kuerzel: "BBB" }, admin.id),
]);
expect(ergebnisse.filter((e) => e.status === "fulfilled")).toHaveLength(1);
expect(await testPrisma.protokoll.count({ where: { aktion: "Betriebsmodus gewechselt" } })).toBe(1);
});
});
describe("Lesemodelle wenden die Mandantensicht selbst an", () => {
it("zeigt einem gebundenen Techniker nur seine Kunden", async () => {
// Ein vergessener Filter waere hier kein Schoenheitsfehler, deshalb steht
// die Einschraenkung im Lesemodell und nicht beim Aufrufer.
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: meiner.id },
});
const fuerTechniker = await kundenListe(techniker, true);
expect(fuerTechniker.map((z) => z.kuerzel)).toEqual(["MEIN"]);
expect((await kundenZurAuswahl(techniker)).map((z) => z.kuerzel)).toEqual(["MEIN"]);
// Der Administrator sieht weiterhin alles.
const fuerAdmin = await kundenListe({ id: "admin", rolle: "ADMIN" }, true);
expect(fuerAdmin).toHaveLength(3);
});
it("zeigt einem ungebundenen Techniker nichts, statt versehentlich alles", async () => {
// Der Unterschied zwischen "keine Einschraenkung" und "sieht nichts" ist
// der heikelste Teil dieser Regel.
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
expect(await kundenListe(techniker, true)).toEqual([]);
});
it("schraenkt ohne Praefix niemanden ein", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
expect(await kundenListe(techniker, true)).toHaveLength(2);
});
});
describe("Wechsel-Auskunft fuer den Bestaetigungsdialog", () => {
it("nennt die Folgen und fragt nach Name und Kuerzel", async () => {
await grundbestand(Betriebsmodus.INTERN);
const pruefung = await modusWechselPruefen(Betriebsmodus.DIENSTLEISTUNG);
expect(pruefung.moeglich).toBe(true);
expect(pruefung.brauchtAngaben).toBe(true);
expect(pruefung.folgen.length).toBeGreaterThan(2);
expect(pruefung.folgen.join(" ")).toMatch(/erste[nr] Kunden/);
});
it("braucht fuer den Rueckweg keine Angaben", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const pruefung = await modusWechselPruefen(Betriebsmodus.INTERN);
expect(pruefung.moeglich).toBe(true);
expect(pruefung.brauchtAngaben).toBe(false);
});
});
describe("Einstellungen", () => {
it("liefert Modus, Regeln und die eigene Organisation", async () => {
await grundbestand(Betriebsmodus.INTERN);
const einstellungen = await einstellungenHolen();
expect(einstellungen.modus).toBe(Betriebsmodus.INTERN);
expect(einstellungen.eigeneOrganisation.kuerzel).toBe("EIGEN");
expect(einstellungen.namensregeln).toEqual({});
});
it("speichert Namensregeln und Sperr-OU und protokolliert es", async () => {
const { admin } = await grundbestand();
await einstellungenSpeichern(
{
namensregeln: { upn: "{vorname}.{nachname}@firma.de", sam: "{v}{nachname}" },
gesperrteOu: "OU=Gesperrt,DC=firma,DC=local",
},
admin.id,
);
const danach = await einstellungenHolen();
expect(danach.namensregeln.upn).toBe("{vorname}.{nachname}@firma.de");
expect(danach.gesperrteOu).toBe("OU=Gesperrt,DC=firma,DC=local");
expect(await testPrisma.protokoll.count({ where: { aktion: "Einstellungen geändert" } })).toBe(1);
});
it("benennt die eigene Organisation um", async () => {
const { organisation, admin } = await grundbestand();
await einstellungenSpeichern({ eigeneOrganisationName: " Wilde IT " }, admin.id);
const danach = await testPrisma.kunde.findUniqueOrThrow({ where: { id: organisation.id } });
expect(danach.name).toBe("Wilde IT");
});
it("weist einen leeren Namen ab", async () => {
const { admin } = await grundbestand();
await expect(
einstellungenSpeichern({ eigeneOrganisationName: " " }, admin.id),
).rejects.toBeInstanceOf(EinstellungsFehler);
});
it("schreibt nichts, wenn sich nichts aendert", async () => {
const { admin } = await grundbestand();
await einstellungenSpeichern({ namensregeln: {}, gesperrteOu: null }, admin.id);
expect(await testPrisma.protokoll.count()).toBe(0);
});
it("uebersteht einen unbrauchbaren Json-Inhalt in den Namensregeln", async () => {
// Ein von Hand verbogener Wert darf die Einstellungsseite nicht sprengen.
await grundbestand();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { namensregeln: { upn: 42, unsinn: true } },
});
const einstellungen = await einstellungenHolen();
expect(einstellungen.namensregeln).toEqual({});
});
});
});
+4 -4
View File
@@ -25,9 +25,9 @@ describe("Seitenleiste nach Betriebsmodus", () => {
describe("Seitenleiste nach Rolle", () => {
it("zeigt Admin nur dem Administrator", () => {
expect(pfadeFuer("INTERN", "ADMIN")).toContain("/admin/benutzer");
expect(pfadeFuer("INTERN", "ADMIN")).toContain("/admin");
for (const rolle of ["REDAKTEUR", "TECHNIKER", "LESER"] as const) {
expect(pfadeFuer("INTERN", rolle), `Rolle ${rolle}`).not.toContain("/admin/benutzer");
expect(pfadeFuer("INTERN", rolle), `Rolle ${rolle}`).not.toContain("/admin");
}
});
@@ -36,13 +36,13 @@ describe("Seitenleiste nach Rolle", () => {
expect(pfade).toContain("/");
expect(pfade).toContain("/laeufe");
expect(pfade).toContain("/archiv");
expect(pfade).not.toContain("/admin/benutzer");
expect(pfade).not.toContain("/admin");
});
it("blendet ohne bekannte Rolle alles Rechtegebundene aus", () => {
// So sieht die Huelle aus, solange niemand angemeldet ist.
const pfade = navigationFuer("INTERN", () => false).map((e) => e.pfad);
expect(pfade).not.toContain("/admin/benutzer");
expect(pfade).not.toContain("/admin");
});
});