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();
|
|
}
|
|
});
|
|
});
|