Phase 10: Mandantenfähigkeit (#15)

This commit was merged in pull request #15.
This commit is contained in:
2026-09-05 13:47:46 +00:00
parent 2b53556175
commit 91742a0dd7
98 changed files with 3810 additions and 201 deletions
+91 -1
View File
@@ -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");
});
});
+8 -2
View File
@@ -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",
+280
View File
@@ -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();
}
});
});