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
@@ -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