Kein neues Modul. Zwoelf Phasen sind fertig, und die Wehr kann ab jetzt echte Daten hineingeben, ohne befuerchten zu muessen, sie beim naechsten Update zu verlieren. Neu dazu, weil beides zum Betrieb gehoert: update.sh aktualisiert auf einem Linux-Server in einem Befehl. Der Ablauf darf an jeder Stelle abbrechen, ohne eine halb aktualisierte Anlage zu hinterlassen: erst nachsehen ob es etwas Neues gibt, dann sichern, dann holen und bauen (schlaegt das fehl, laeuft die alte Fassung unveraendert weiter), dann umschalten, dann pruefen ob die Anwendung antwortet. Tut sie es nicht, stehen die Protokollzeilen da und die Befehle fuer den Rueckweg. docs/KURZANLEITUNG.md ist der Einstieg fuer die Wehr - nach Rollen sortiert, aufgabenorientiert statt nach Modulen, und bei jedem heiklen Punkt steht das Warum dabei. Die vollstaendige Anleitung hat 63 Kapitel; die liest niemand, bevor er das erste Geraet pruefen will. Die Roadmap haelt den Stand JEDER Freigabevoraussetzung fest, auch der offenen: Datenuebernahme entfaellt (die Wehr pflegt von Hand ein), Sicherungskonzept erledigt, Loeschkonzept technisch vollstaendig aber nicht abgenommen, Schulung steht aus, Atemschutzhinweis bewusst stehen gelassen. Eine Freigabe, die abhakt was nicht geprueft wurde, ist keine. Nebenbei die Ursache der Testflackerei gefunden, die sich durch mehrere Phasen zog: zwei Datenbanktests schalteten beim Aufraeumen ihre Trigger mit ALTER TABLE ... DISABLE TRIGGER ab - eine Schemaaenderung, die fuer die ganze Datenbank gilt. Vitest faehrt die Dateien parallel, also pruefte eine Datei gerade dass ein Trigger greift, waehrend eine andere ihn abgeraeumt hatte. Jetzt sitzungslokal ueber session_replication_role. Erstmals in diesem Projekt: fuenf Unit-Testlaeufe hintereinander 601 gruen und ein E2E-Lauf mit 268 gruen ohne Fehlschlag und ohne Flackern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
293 lines
9.8 KiB
TypeScript
293 lines
9.8 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;
|
|
}
|
|
|
|
// Erst aufraeumen, dann anlegen.
|
|
//
|
|
// Aufraeumen nur am Ende reicht nicht: bricht ein Lauf ab — abgebrochene
|
|
// Sitzung, Fehler in einer anderen Datei, geschlossenes Terminal —, bleiben
|
|
// die Datensaetze stehen und der naechste Lauf scheitert an einer
|
|
// Eindeutigkeit, die mit dem Geprueften nichts zu tun hat. Das kostet dann
|
|
// zehn Minuten Fehlersuche an der falschen Stelle.
|
|
await aufraeumen();
|
|
|
|
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;
|
|
});
|
|
|
|
/** Alles entfernen, was dieser Test anlegt. */
|
|
async function aufraeumen(): Promise<void> {
|
|
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] } } });
|
|
});
|
|
}
|
|
|
|
afterAll(async () => {
|
|
if (erreichbar) await aufraeumen();
|
|
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();
|
|
}
|
|
});
|
|
});
|