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>
281 lines
9.3 KiB
TypeScript
281 lines
9.3 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|