Phase 10: Mandantenfähigkeit (#15)
This commit was merged in pull request #15.
This commit is contained in:
@@ -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