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({}); }); }); });