Phase 10: Mandantenfaehigkeit
Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehoerigkeiten. Eine Kreisebene, die zusieht. Datenmodell und RLS trugen seit Phase 0 mehrere Wehren. Was fehlte, war alles darueber: bis hierher war die Personenakte zugleich das Anmeldekonto, und die gehoert genau einer Wehr. Wer in zweien gefuehrt wurde, brauchte zwei Passwoerter, und die Anmeldung per E-Mail war mehrdeutig. Neu sind `Benutzer` (das Konto) und `Mitgliedschaft` (die Zugehoerigkeit). Das Konto traegt bewusst keine Mandantenkennung und kann keine tragen - es ist der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehoert. Geschuetzt wird es deshalb ueber Rechte statt ueber RLS, wie `Berechtigung` seit Phase 0. Was es in einer Wehr darf, entscheidet seine Mitgliedschaft. Die Sitzung behaelt ihre Form und entsteht nur anders - aus der aktiven Mitgliedschaft statt aus der Person. Von 414 Fundstellen in 61 Dateien musste dadurch keine angefasst werden. Die eine Ausnahme ist Absicht: `personId` ist nullbar geworden, weil eine Aufsichtssitzung keine Akte hat, und der Typechecker hat daraus genau die fuenf Stellen gemacht, an denen die Kreisaufsicht nichts verloren hat. Ihre Rechte werden gefiltert statt aufgezaehlt: alles, was `.lesen` heisst, minus Kasse, Audit-Log, KI-Protokoll und Personenexport. So bekommt sie neue Leserechte automatisch und neue Schreibrechte nie. Die Migration ist datenerhaltend und bricht ab, statt einen halben Zustand zu hinterlassen. Nebenbei behoben: `db:test-reset` liess die Nachbarwehr stehen, der Fehlversuch-Test sperrte das gemeinsame Testkonto, ein Testaufbau war nicht wiederholbar, und die Wartelogik der Bildschirmfotos konnte ausloesen, bevor eine Seite ueberhaupt geladen war. 581 Unit-Tests und 266 E2E-Tests gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2b53556175
commit
ff40437a1a
@@ -26,7 +26,17 @@ test.describe("Anmeldung", () => {
|
||||
}) => {
|
||||
await page.goto("/anmelden");
|
||||
|
||||
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||
// Bewusst eine Adresse, die es nicht gibt, statt der des Administrators.
|
||||
//
|
||||
// Ein Fehlversuch zählt auf das Konto, und nach fünf davon ist es fünf
|
||||
// Minuten gesperrt. Bei zwei Projekten und Wiederholungen kamen im vollen
|
||||
// Lauf genug zusammen, um andere Testdateien mitzureißen — sie fielen
|
||||
// dann mit „Anmeldung fehlgeschlagen“ aus, ohne dass es etwas mit dem zu
|
||||
// tun hatte, was sie prüfen.
|
||||
//
|
||||
// Die Meldung muss ohnehin dieselbe sein: sie darf nicht verraten, ob es
|
||||
// die Adresse gibt. Genau das prüft dieser Test mit.
|
||||
await page.getByLabel("E-Mail-Adresse").fill("gibt-es-nicht@teststelle.de");
|
||||
await page.getByLabel("Passwort").fill("definitiv-das-falsche-passwort");
|
||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { ADMIN_EMAIL, ADMIN_PASSWORT, anmelden } from "./wehr";
|
||||
|
||||
/**
|
||||
* E2E-Tests für Phase 10 — Mandantenfähigkeit.
|
||||
*
|
||||
* Geprüft wird das, wovon die Trennung abhängt:
|
||||
*
|
||||
* * wer nur zu einer Wehr gehört, sieht keinen Wähler
|
||||
* * wer zu mehreren gehört, landet in seiner eigenen und kann wechseln
|
||||
* * nach dem Wechsel stehen die Daten der anderen Wehr da, nicht die alten
|
||||
* * als Kreisaufsicht steht der Hinweis, und es gibt nichts zu ändern
|
||||
* * die Kasse der betreuten Wehr bleibt zu
|
||||
*
|
||||
* Voraussetzung: `npm run db:test-reset`. Der Seed legt dabei die Testwehr,
|
||||
* eine Nachbarwehr und ein Konto an, das in der einen Mitglied und in der
|
||||
* anderen Aufsicht ist.
|
||||
*/
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
/** Das Konto der Kreisebene — Mitglied hier, Aufsicht nebenan. */
|
||||
const KBI_EMAIL = process.env.E2E_KBI_EMAIL ?? "kbi@ff-teststelle.de";
|
||||
const KBI_PASSWORT = process.env.E2E_KBI_PASSWORT ?? "Feuerwehr2026";
|
||||
|
||||
/**
|
||||
* Öffnet die Seitenleiste, falls sie hinter dem Menüknopf steckt.
|
||||
*
|
||||
* Auf dem Tablet (Galaxy Tab S4 quer, 1138 px) liegt die Leiste unterhalb der
|
||||
* `md`-Grenze und ist eingeklappt. Der Wehrwähler sitzt darin — wer ihn dort
|
||||
* sucht, ohne vorher aufzuklappen, wartet vergeblich.
|
||||
*/
|
||||
async function seitenleisteOeffnen(page: import("@playwright/test").Page) {
|
||||
const knopf = page.getByRole("button", { name: "Navigation öffnen" });
|
||||
if (await knopf.isVisible()) await knopf.click();
|
||||
}
|
||||
|
||||
/** Meldet ein beliebiges Konto an. */
|
||||
async function anmeldenAls(page: import("@playwright/test").Page, email: string, passwort: string) {
|
||||
await page.goto("/anmelden");
|
||||
await page.getByLabel("E-Mail-Adresse").fill(email);
|
||||
await page.getByLabel("Passwort").fill(passwort);
|
||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||
await expect(page, `Anmeldung als ${email} fehlgeschlagen.`).toHaveURL("/");
|
||||
}
|
||||
|
||||
test.describe("Ein Konto, eine Wehr", () => {
|
||||
test("zeigt keinen Wehrwähler, wenn es nichts zu wählen gibt", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
|
||||
// Der Name der Wehr steht da — aber nicht als Auswahl. Ein Auswahlfeld mit
|
||||
// genau einem Eintrag ist eine Frage ohne Antwortmöglichkeit.
|
||||
await expect(page.getByRole("button", { name: /Wehr wechseln/ })).toHaveCount(0);
|
||||
await expect(page.getByRole("listbox", { name: "Wehr wechseln" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("sieht keinen Aufsichtshinweis", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await expect(page.getByText("Sie sehen diese Wehr als")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Ein Konto, zwei Wehren", () => {
|
||||
test("landet nach der Anmeldung in der eigenen Wehr, nicht in der betreuten", async ({
|
||||
page,
|
||||
}) => {
|
||||
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
|
||||
|
||||
// Die eigene vor der betreuten: wer eine Wehr führt und eine zweite
|
||||
// beaufsichtigt, will in seiner landen.
|
||||
await expect(page.getByText("Sie sehen diese Wehr als")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("bietet den Wehrwähler an", async ({ page }) => {
|
||||
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
|
||||
|
||||
await seitenleisteOeffnen(page);
|
||||
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
|
||||
|
||||
const liste = page.getByRole("listbox", { name: "Wehr wechseln" });
|
||||
await expect(liste).toBeVisible();
|
||||
await expect(liste.getByRole("option")).toHaveCount(2);
|
||||
|
||||
// Beide Arten stehen mit Klartext da — nicht nur mit einem Symbol.
|
||||
await expect(liste.getByText("Mitglied", { exact: true })).toBeVisible();
|
||||
await expect(liste.getByText("Aufsicht — nur lesen")).toBeVisible();
|
||||
});
|
||||
|
||||
test("wechselt in die betreute Wehr und sagt, dass dort nichts geht", async ({ page }) => {
|
||||
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
|
||||
|
||||
await seitenleisteOeffnen(page);
|
||||
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
|
||||
await page
|
||||
.getByRole("listbox", { name: "Wehr wechseln" })
|
||||
.getByRole("option")
|
||||
.filter({ hasText: "Aufsicht" })
|
||||
.click();
|
||||
|
||||
// Der Hinweis steht dauerhaft, nicht als kurz aufblitzende Meldung.
|
||||
await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible();
|
||||
await expect(page.getByRole("note").filter({ hasText: "Kreisaufsicht" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("zeigt nach dem Wechsel die Mitglieder der anderen Wehr", async ({ page }) => {
|
||||
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
|
||||
|
||||
await seitenleisteOeffnen(page);
|
||||
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
|
||||
await page
|
||||
.getByRole("listbox", { name: "Wehr wechseln" })
|
||||
.getByRole("option")
|
||||
.filter({ hasText: "Aufsicht" })
|
||||
.click();
|
||||
await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible();
|
||||
|
||||
await page.goto("/mitglieder");
|
||||
|
||||
// Die drei aus der Nachbarwehr — und nicht die fünfzehn der eigenen.
|
||||
await expect(page.getByRole("cell", { name: /Sailer/ })).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: /Ostermeier/ })).toBeVisible();
|
||||
|
||||
// Die Kreisaufsicht selbst steht in keiner Mitgliederliste: sie gehört
|
||||
// der Wehr nicht an und hat dort keine Akte.
|
||||
await expect(page.getByRole("cell", { name: /Huber/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("lässt die Kreisaufsicht in der betreuten Wehr nichts anlegen", async ({ page }) => {
|
||||
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
|
||||
|
||||
await seitenleisteOeffnen(page);
|
||||
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
|
||||
await page
|
||||
.getByRole("listbox", { name: "Wehr wechseln" })
|
||||
.getByRole("option")
|
||||
.filter({ hasText: "Aufsicht" })
|
||||
.click();
|
||||
await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible();
|
||||
|
||||
await page.goto("/mitglieder");
|
||||
|
||||
// Kein Knopf, der ins Leere führt. Das Recht fehlt serverseitig ohnehin —
|
||||
// die Oberfläche soll es aber gar nicht erst anbieten.
|
||||
await expect(page.getByRole("button", { name: /Mitglied anlegen/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("hält die Kasse der betreuten Wehr geschlossen", async ({ page }) => {
|
||||
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
|
||||
|
||||
await seitenleisteOeffnen(page);
|
||||
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
|
||||
await page
|
||||
.getByRole("listbox", { name: "Wehr wechseln" })
|
||||
.getByRole("option")
|
||||
.filter({ hasText: "Aufsicht" })
|
||||
.click();
|
||||
await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible();
|
||||
|
||||
// Sie gehört dem Verein oder der Kommune, nicht der Kreisaufsicht.
|
||||
await seitenleisteOeffnen(page);
|
||||
await expect(page.locator("aside").getByRole("link", { name: "Kasse" })).toHaveCount(0);
|
||||
|
||||
await page.goto("/kasse");
|
||||
await expect(page).not.toHaveURL(/\/kasse$/);
|
||||
});
|
||||
|
||||
test("kommt in die eigene Wehr zurück", async ({ page }) => {
|
||||
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
|
||||
|
||||
await seitenleisteOeffnen(page);
|
||||
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
|
||||
await page
|
||||
.getByRole("listbox", { name: "Wehr wechseln" })
|
||||
.getByRole("option")
|
||||
.filter({ hasText: "Aufsicht" })
|
||||
.click();
|
||||
await expect(page.getByText("Sie sehen diese Wehr als")).toBeVisible();
|
||||
|
||||
await seitenleisteOeffnen(page);
|
||||
await page.getByRole("button", { name: /Wehr wechseln/ }).click();
|
||||
await page
|
||||
.getByRole("listbox", { name: "Wehr wechseln" })
|
||||
.getByRole("option")
|
||||
.filter({ hasText: "Mitglied" })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText("Sie sehen diese Wehr als")).toHaveCount(0);
|
||||
await seitenleisteOeffnen(page);
|
||||
await expect(page.locator("aside").getByRole("link", { name: "Kasse" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Der Administrator", () => {
|
||||
test("bleibt nach der Umstellung anmeldbar", async ({ page }) => {
|
||||
// Die Migration hat sein Konto aus der Personenakte gelöst. Wäre dabei
|
||||
// etwas verlorengegangen, käme er hier nicht mehr hinein — und niemand
|
||||
// sonst auch.
|
||||
await anmeldenAls(page, ADMIN_EMAIL, ADMIN_PASSWORT);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Wehren und Konten", () => {
|
||||
test("steht nur dem Verwalter offen", async ({ page }) => {
|
||||
// Der KBI führt seine Wehr, verwaltet aber nicht die Installation.
|
||||
await anmeldenAls(page, KBI_EMAIL, KBI_PASSWORT);
|
||||
|
||||
await seitenleisteOeffnen(page);
|
||||
await expect(
|
||||
page.locator("aside").getByRole("link", { name: "Wehren und Konten" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Und die Seite selbst schickt ihn weg, nicht nur das Menü.
|
||||
await page.goto("/wehren");
|
||||
await expect(page).not.toHaveURL(/\/wehren$/);
|
||||
});
|
||||
|
||||
test("zeigt dem Verwalter die Wehren dieser Installation", async ({ page }) => {
|
||||
await anmeldenAls(page, ADMIN_EMAIL, ADMIN_PASSWORT);
|
||||
await seitenleisteOeffnen(page);
|
||||
await page.locator("aside").getByRole("link", { name: "Wehren und Konten" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Wehren und Konten", level: 1 })).toBeVisible();
|
||||
|
||||
// Die Testwehr und ihre Nachbarwehr aus dem Seed. Der Verwalter sieht
|
||||
// bewusst **alle** Wehren der Installation — auch die einer anderen
|
||||
// Kommune, wenn sie auf demselben Server liegt. Das ist der Unterschied
|
||||
// zwischen dem Betreiber und einer Wehr.
|
||||
await expect(page.getByRole("heading", { name: "FF Teststelle Nachbar" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "FF Teststelle", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("zeigt, welches Konto zu welchen Wehren gehört", async ({ page }) => {
|
||||
await anmeldenAls(page, ADMIN_EMAIL, ADMIN_PASSWORT);
|
||||
await page.goto("/wehren");
|
||||
|
||||
// Das Konto der Kreisebene steht mit beiden Zuordnungen da — und die
|
||||
// Aufsicht ist als solche gekennzeichnet, nicht als Mitgliedschaft.
|
||||
await expect(page.getByText(KBI_EMAIL)).toBeVisible();
|
||||
await expect(page.getByText("Kreisaufsicht — nur lesen").first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RolleSchluessel } from "@prisma/client";
|
||||
import {
|
||||
ALLE_RECHTE,
|
||||
KeinZugriffFehler,
|
||||
@@ -24,7 +25,10 @@ import {
|
||||
* Terminal nur das Nötigste kann.
|
||||
*/
|
||||
|
||||
function sitzung(rollen: Sitzung["rollen"], organisationId = "org-1"): Sitzung {
|
||||
function sitzung(
|
||||
rollen: Sitzung["rollen"],
|
||||
organisationId = "org-1",
|
||||
): Sitzung & { personId: string } {
|
||||
return {
|
||||
personId: "person-1",
|
||||
organisationId,
|
||||
@@ -34,6 +38,23 @@ function sitzung(rollen: Sitzung["rollen"], organisationId = "org-1"): Sitzung {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Sitzung der Kreisaufsicht: kein Konto in dieser Wehr, keine Akte.
|
||||
*
|
||||
* Seit Phase 10 gibt es diesen Zustand, und er ist der einzige, in dem
|
||||
* `personId` fehlt.
|
||||
*/
|
||||
function aufsicht(organisationId = "org-2"): Sitzung {
|
||||
return {
|
||||
personId: null,
|
||||
organisationId,
|
||||
name: "Kreisbrandinspektion",
|
||||
rollen: ["AUFSICHT"],
|
||||
anmeldeArt: "LOKAL",
|
||||
nurLesend: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Rollenkatalog", () => {
|
||||
it("hat für jede Rolle eine deutsche Bezeichnung", () => {
|
||||
for (const rolle of ROLLEN) {
|
||||
@@ -195,3 +216,72 @@ describe("alsRolle", () => {
|
||||
expect(alsRolle(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kreisaufsicht", () => {
|
||||
/**
|
||||
* Der wichtigste Test dieser Datei.
|
||||
*
|
||||
* Die Kreisebene sieht Wehren, denen sie nicht angehört. Bekäme sie dort
|
||||
* versehentlich ein Schreibrecht, wäre das kein Bedienfehler, sondern ein
|
||||
* Loch in der Mandantentrennung — und zwar eines, das niemandem auffällt,
|
||||
* weil in der Oberfläche alles normal aussieht.
|
||||
*
|
||||
* Die Zusicherung ist deshalb bewusst allgemein formuliert: nicht "diese
|
||||
* zwölf Rechte", sondern "kein einziges, das nicht lesen heißt". So gilt
|
||||
* sie auch für Module, die es noch gar nicht gibt.
|
||||
*/
|
||||
it("hat ausschließlich Leserechte — auch für künftige Module", () => {
|
||||
const rechte = [...rechteVon(["AUFSICHT"])];
|
||||
|
||||
expect(rechte.length).toBeGreaterThan(0);
|
||||
expect(rechte.filter((recht) => !recht.endsWith(".lesen"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("sieht die Kasse nicht", () => {
|
||||
// Sie gehört dem Verein oder der Kommune, nicht der Kreisaufsicht — und
|
||||
// wäre nur deshalb dabei, weil das Recht zufällig auf ".lesen" endet.
|
||||
expect(hatRecht(aufsicht(), "kasse.lesen")).toBe(false);
|
||||
});
|
||||
|
||||
it("sieht weder Audit-Log noch KI-Protokoll", () => {
|
||||
// Wer wann was getan hat, bleibt innerhalb der Wehr.
|
||||
expect(hatRecht(aufsicht(), "system.auditlog")).toBe(false);
|
||||
expect(hatRecht(aufsicht(), "ki.protokoll")).toBe(false);
|
||||
});
|
||||
|
||||
it("kann keine Personendaten exportieren", () => {
|
||||
// Das Auskunftsrecht nach DSGVO erfüllt die Wehr, die die Daten führt.
|
||||
expect(hatRecht(aufsicht(), "person.export")).toBe(false);
|
||||
});
|
||||
|
||||
it("sieht den Atemschutz — dafür gibt es sie", () => {
|
||||
expect(hatRecht(aufsicht(), "atemschutz.lesen")).toBe(true);
|
||||
expect(hatRecht(aufsicht(), "atemschutz.bearbeiten")).toBe(false);
|
||||
expect(hatRecht(aufsicht(), "atemschutz.ueberwachung")).toBe(false);
|
||||
});
|
||||
|
||||
it("hat in dieser Wehr keine eigene Akte", () => {
|
||||
// Daran hängt, dass sie in keiner Mitgliederliste auftaucht und als
|
||||
// Urheber keiner Änderung eingetragen werden kann.
|
||||
expect(aufsicht().personId).toBeNull();
|
||||
expect(aufsicht().nurLesend).toBe(true);
|
||||
});
|
||||
|
||||
it("sieht fremde Personen nur über person.lesen, nie über die eigene Akte", () => {
|
||||
// `darfPersonSehen` lässt jeden die eigenen Daten sehen. Ohne Akte darf
|
||||
// dieser Weg nicht versehentlich greifen: sonst entschiede ein Vergleich
|
||||
// von null mit null darüber, wer wen sieht.
|
||||
const kreis = aufsicht();
|
||||
expect(darfPersonSehen(kreis, "irgendeine-person")).toBe(true);
|
||||
expect(hatRecht(kreis, "person.lesen")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rollenkatalog und Mandantentrennung", () => {
|
||||
it("führt AUFSICHT nicht als vergebbare Rolle in der Datenbank", () => {
|
||||
// Sie ergibt sich aus der Mitgliedschaftsart, nicht aus einer Zuweisung.
|
||||
// Wäre sie vergebbar, könnte jemand innerhalb einer Wehr Rechte an einer
|
||||
// anderen verteilen.
|
||||
expect(RolleSchluessel).not.toHaveProperty("AUFSICHT");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,8 +60,14 @@ beforeAll(async () => {
|
||||
});
|
||||
personId = person.id;
|
||||
|
||||
const seite = await system.wikiseite.create({
|
||||
data: {
|
||||
// Wie Wehr und Person darüber: idempotent. Bleibt aus einem abgebrochenen
|
||||
// Lauf etwas stehen, scheiterte ein bloßes `create` hier an der
|
||||
// Eindeutigkeit — und der ganze Datei-Lauf mit ihm, aus einem Grund, der
|
||||
// mit dem Geprüften nichts zu tun hat.
|
||||
const seite = await system.wikiseite.upsert({
|
||||
where: { organisationId_slug: { organisationId, slug: "testseite" } },
|
||||
update: {},
|
||||
create: {
|
||||
organisationId,
|
||||
slug: "testseite",
|
||||
titel: "Testseite",
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Konto und Mitgliedschaft — geprüft dort, wo die Trennung durchgesetzt wird.
|
||||
*
|
||||
* Phase 10 hat das Anmeldekonto von der Personenakte gelöst. Damit steht zum
|
||||
* ersten Mal eine Tabelle über allen Mandanten (`benutzer`), und die Frage
|
||||
* „wer darf welche Wehr sehen" hängt nicht mehr an der Akte, sondern an einer
|
||||
* eigenen Zuordnung.
|
||||
*
|
||||
* Das ist der Punkt, an dem eine Mandantentrennung normalerweise leckt: eine
|
||||
* Zuordnung, die auf eine Akte aus einer anderen Wehr zeigt, und schon steht
|
||||
* in der Sitzung eine Personenkennung, die dort niemandem gehört. Genau
|
||||
* dagegen laufen die Prüfungen hier — und zwar über den Systemzugang: kommt
|
||||
* nicht einmal der Eigentümer der Datenbank daran vorbei, kommt niemand
|
||||
* vorbei.
|
||||
*
|
||||
* Der Test braucht eine laufende Datenbank. Ist keine erreichbar, überspringt
|
||||
* er sich selbst, damit "npm run test" auch ohne Docker durchläuft.
|
||||
*/
|
||||
|
||||
const APP_URL = process.env.DATABASE_URL ?? "";
|
||||
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL;
|
||||
|
||||
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
|
||||
|
||||
const SLUG_A = "test-mandant-a";
|
||||
const SLUG_B = "test-mandant-b";
|
||||
|
||||
let erreichbar = false;
|
||||
let wehrA = "";
|
||||
let wehrB = "";
|
||||
let akteA = "";
|
||||
let akteB = "";
|
||||
let konto = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
await system.$queryRaw`SELECT 1`;
|
||||
erreichbar = true;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const a = await system.organisation.upsert({
|
||||
where: { slug: SLUG_A },
|
||||
update: {},
|
||||
create: { slug: SLUG_A, name: "Mandant A", kurzname: "A" },
|
||||
});
|
||||
wehrA = a.id;
|
||||
|
||||
const b = await system.organisation.upsert({
|
||||
where: { slug: SLUG_B },
|
||||
update: {},
|
||||
create: { slug: SLUG_B, name: "Mandant B", kurzname: "B" },
|
||||
});
|
||||
wehrB = b.id;
|
||||
|
||||
const personA = await system.person.upsert({
|
||||
where: { organisationId_mitgliedsnummer: { organisationId: wehrA, mitgliedsnummer: "A-1" } },
|
||||
update: {},
|
||||
create: {
|
||||
organisationId: wehrA,
|
||||
vorname: "Anna",
|
||||
nachname: "Aktiv",
|
||||
mitgliedsnummer: "A-1",
|
||||
eintrittsdatum: new Date("2015-01-01"),
|
||||
},
|
||||
});
|
||||
akteA = personA.id;
|
||||
|
||||
const personB = await system.person.upsert({
|
||||
where: { organisationId_mitgliedsnummer: { organisationId: wehrB, mitgliedsnummer: "B-1" } },
|
||||
update: {},
|
||||
create: {
|
||||
organisationId: wehrB,
|
||||
vorname: "Bernd",
|
||||
nachname: "Beisitzer",
|
||||
mitgliedsnummer: "B-1",
|
||||
eintrittsdatum: new Date("2015-01-01"),
|
||||
},
|
||||
});
|
||||
akteB = personB.id;
|
||||
|
||||
const benutzer = await system.benutzer.upsert({
|
||||
where: { email: "doppel@test-mandant.de" },
|
||||
update: {},
|
||||
create: { email: "doppel@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" },
|
||||
});
|
||||
konto = benutzer.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (erreichbar) {
|
||||
await system.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||
await tx.benutzer.deleteMany({ where: { email: { endsWith: "@test-mandant.de" } } });
|
||||
await tx.organisation.deleteMany({ where: { slug: { in: [SLUG_A, SLUG_B] } } });
|
||||
});
|
||||
}
|
||||
await system.$disconnect();
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Doppelmitgliedschaft", () => {
|
||||
it("lässt ein Konto zu zwei Wehren gehören", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Das ist der ganze Zweck der Phase: ein Mensch, ein Passwort, zwei
|
||||
// Wehren. Vorher hätte es zwei Konten gebraucht, die auseinanderlaufen.
|
||||
await system.mitgliedschaft.create({
|
||||
data: { organisationId: wehrA, benutzerId: konto, personId: akteA, art: "MITGLIED" },
|
||||
});
|
||||
|
||||
await system.mitgliedschaft.create({
|
||||
data: { organisationId: wehrB, benutzerId: konto, art: "AUFSICHT" },
|
||||
});
|
||||
|
||||
const alle = await system.mitgliedschaft.findMany({ where: { benutzerId: konto } });
|
||||
expect(alle).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("nimmt ein Konto nur einmal je Wehr auf", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await expect(
|
||||
system.mitgliedschaft.create({
|
||||
data: { organisationId: wehrA, benutzerId: konto, personId: akteA, art: "MITGLIED" },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("hängt eine Personenakte an höchstens ein Konto", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Sonst wäre nicht entscheidbar, wer unter ihrem Namen gehandelt hat.
|
||||
const zweites = await system.benutzer.create({
|
||||
data: { email: "zweites@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.mitgliedschaft.create({
|
||||
data: { organisationId: wehrA, benutzerId: zweites.id, personId: akteA, art: "MITGLIED" },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Die Akte gehört zur Wehr", () => {
|
||||
it("weist eine Zuordnung über Wehrgrenzen hinweg ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Der eigentliche Mandantenschutz dieser Phase. Ein Fremdschlüssel prüft
|
||||
// nur, dass es die Akte gibt — nicht, wo sie liegt. Ohne den Trigger
|
||||
// ließe sich ein Konto über eine Akte aus Wehr A in Wehr B hängen, und
|
||||
// die Sitzung stünde dort mit einer Kennung da, die niemandem gehört.
|
||||
const fremd = await system.benutzer.create({
|
||||
data: { email: "fremd@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.mitgliedschaft.create({
|
||||
data: {
|
||||
organisationId: wehrB,
|
||||
benutzerId: fremd.id,
|
||||
// Die Akte liegt in Wehr A.
|
||||
personId: akteA,
|
||||
art: "MITGLIED",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/andere Wehr|gehoert/i);
|
||||
});
|
||||
|
||||
it("weist auch das nachträgliche Umhängen ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const eigene = await system.mitgliedschaft.findFirst({
|
||||
where: { benutzerId: konto, organisationId: wehrA },
|
||||
});
|
||||
expect(eigene).not.toBeNull();
|
||||
|
||||
await expect(
|
||||
system.mitgliedschaft.update({
|
||||
where: { id: eigene!.id },
|
||||
data: { personId: akteB },
|
||||
}),
|
||||
).rejects.toThrow(/andere Wehr|gehoert/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Was die Datenbank gar nicht erst annimmt", () => {
|
||||
it("keine Mitgliedschaft ohne Akte", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const ohne = await system.benutzer.create({
|
||||
data: { email: "ohneakte@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.mitgliedschaft.create({
|
||||
data: { organisationId: wehrA, benutzerId: ohne.id, art: "MITGLIED" },
|
||||
}),
|
||||
).rejects.toThrow(/akte_passend/i);
|
||||
});
|
||||
|
||||
it("keine Kreisaufsicht mit Akte", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Sie gehört der Wehr nicht an und soll in keiner Mitgliederliste stehen.
|
||||
const aufsicht = await system.benutzer.create({
|
||||
data: { email: "aufsicht@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.mitgliedschaft.create({
|
||||
data: { organisationId: wehrB, benutzerId: aufsicht.id, personId: akteB, art: "AUFSICHT" },
|
||||
}),
|
||||
).rejects.toThrow(/akte_passend/i);
|
||||
});
|
||||
|
||||
it("kein Konto ohne Anmeldeweg", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Weder Passwort noch OIDC — damit ist es eine Karteileiche, die erst
|
||||
// auffällt, wenn sich jemand damit anmelden will.
|
||||
await expect(
|
||||
system.benutzer.create({ data: { email: "leer@test-mandant.de" } }),
|
||||
).rejects.toThrow(/anmeldeweg_vorhanden/i);
|
||||
});
|
||||
|
||||
it("keine E-Mail, die keine ist", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await expect(
|
||||
system.benutzer.create({
|
||||
data: { email: "kein-at-zeichen", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" },
|
||||
}),
|
||||
).rejects.toThrow(/email_plausibel/i);
|
||||
});
|
||||
|
||||
it("keine zwei Konten mit derselben Adresse", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Global eindeutig — das ist der Unterschied zur E-Mail an der Akte, die
|
||||
// je Wehr eindeutig ist. Vorher meldete sich, wer in zwei Wehren stand,
|
||||
// in einer zufälligen an.
|
||||
await expect(
|
||||
system.benutzer.create({
|
||||
data: { email: "doppel@test-mandant.de", passwortHash: "$2a$04$abcdefghijklmnopqrstuv" },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der Zuordnungen", () => {
|
||||
it("zeigt einer Wehr nicht, in welchen anderen ein Konto noch geführt wird", async ({
|
||||
skip,
|
||||
}) => {
|
||||
if (!erreichbar) return skip();
|
||||
if (!APP_URL || APP_URL === SYSTEM_URL) return skip();
|
||||
|
||||
// `mitgliedschaft` trägt `organisationId` und unterliegt damit Row Level
|
||||
// Security wie jede andere Tabelle. Wer in Wehr A angemeldet ist, sieht
|
||||
// die Zuordnungen von Wehr A — und erfährt insbesondere nicht, dass
|
||||
// dasselbe Konto nebenan Kreisaufsicht ist.
|
||||
const app = new PrismaClient({ datasources: { db: { url: APP_URL } } });
|
||||
|
||||
try {
|
||||
const inA = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRawUnsafe(`SELECT set_config('app.current_organisation', '${wehrA}', true)`);
|
||||
return tx.mitgliedschaft.findMany({ where: { benutzerId: konto } });
|
||||
});
|
||||
|
||||
expect(inA).toHaveLength(1);
|
||||
expect(inA[0]?.organisationId).toBe(wehrA);
|
||||
} finally {
|
||||
await app.$disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user