Phase 11: Freigabereife (#16)
This commit was merged in pull request #16.
This commit is contained in:
@@ -296,6 +296,16 @@ test.describe("Benachrichtigungen", () => {
|
||||
await anmelden(page);
|
||||
await page.goto("/benachrichtigungen/vorlagen");
|
||||
|
||||
// Erst warten, bis die Seite im Browser lebendig ist.
|
||||
//
|
||||
// Die Platzhalter-Knöpfe entstehen client-seitig; sind sie da, ist
|
||||
// hydriert. Ohne diese Zusicherung wurde unter Last auf „Speichern“
|
||||
// geklickt, bevor React lief — dann verschickt das Formular sich selbst,
|
||||
// die Seite lädt neu, und die Fehlerfläche entsteht nie. Der Test fiel
|
||||
// dann mit „Element nicht gefunden“ aus, obwohl der Server richtig
|
||||
// ablehnte.
|
||||
await expect(page.getByRole("button", { name: "{titel}" })).toBeVisible();
|
||||
|
||||
const feld = page.getByLabel("Nachricht");
|
||||
await feld.fill("Am {datum} um {uhrzeit}. Und ein {tippfehler}.");
|
||||
await page.getByRole("button", { name: "Speichern" }).click();
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { auskunftSammeln, auskunftUmfang } from "@/lib/dsgvo/auskunft";
|
||||
import type { MandantenClient } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Die Auskunft nach Art. 15 DSGVO.
|
||||
*
|
||||
* Zwei Dinge müssen stimmen, und sie ziehen in verschiedene Richtungen.
|
||||
*
|
||||
* **Vollständigkeit.** Was über die Person gespeichert ist, muss drinstehen —
|
||||
* auch das Unangenehme. Eine Auskunft, die den gemahnten Beitrag oder die
|
||||
* nicht bestandene G26 auslässt, ist keine.
|
||||
*
|
||||
* **Begrenzung.** Was die Person *getan* hat, gehört zum Nachweisbestand der
|
||||
* Wehr. Stünde es vollständig drin, käme jeder über eine Selbstauskunft an
|
||||
* die Geräteakten — und die Auskunftspflicht wäre eine Hintertür.
|
||||
*
|
||||
* Der Test prüft deshalb beides: dass die Abschnitte da sind, und dass die
|
||||
* Tätigkeiten nur gezählt werden.
|
||||
*
|
||||
* Braucht eine laufende Datenbank; ohne sie überspringt er sich selbst.
|
||||
*/
|
||||
|
||||
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? "";
|
||||
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
|
||||
|
||||
const SLUG = "test-auskunft-wehr";
|
||||
|
||||
let erreichbar = false;
|
||||
let organisationId = "";
|
||||
let personId = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
await system.$queryRaw`SELECT 1`;
|
||||
erreichbar = true;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const organisation = await system.organisation.upsert({
|
||||
where: { slug: SLUG },
|
||||
update: {},
|
||||
create: { slug: SLUG, name: "Auskunfts-Testwehr", kurzname: "ATW" },
|
||||
});
|
||||
organisationId = organisation.id;
|
||||
|
||||
const person = await system.person.upsert({
|
||||
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "A-1" } },
|
||||
update: {},
|
||||
create: {
|
||||
organisationId,
|
||||
vorname: "Auskunft",
|
||||
nachname: "Suchend",
|
||||
mitgliedsnummer: "A-1",
|
||||
geburtsdatum: new Date("1985-04-12"),
|
||||
eintrittsdatum: new Date("2010-01-01"),
|
||||
email: "auskunft@test-auskunft.de",
|
||||
strasse: "Musterweg 1",
|
||||
plz: "91315",
|
||||
ort: "Musterhausen",
|
||||
abteilung: "Aktive",
|
||||
},
|
||||
});
|
||||
personId = person.id;
|
||||
|
||||
// Gesundheitsdaten — die besondere Kategorie, um die es geht.
|
||||
await system.g26Untersuchung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
personId,
|
||||
untersuchtAm: new Date("2025-03-01"),
|
||||
gueltigBis: new Date("2028-03-01"),
|
||||
ergebnis: "TAUGLICH",
|
||||
arzt: "Dr. Beispiel",
|
||||
},
|
||||
});
|
||||
|
||||
// Ein gemahnter Beitrag — das Unangenehme, das drinstehen muss.
|
||||
await system.beitrag.create({
|
||||
data: {
|
||||
organisationId,
|
||||
personId,
|
||||
jahr: 2025,
|
||||
betrag: "25.00",
|
||||
stand: "GEMAHNT",
|
||||
mahnstufe: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (erreichbar) {
|
||||
await system.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||
await tx.organisation.deleteMany({ where: { slug: SLUG } });
|
||||
});
|
||||
}
|
||||
await system.$disconnect();
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Auskunft nach Art. 15", () => {
|
||||
it("nennt Person, Wehr und Zeitpunkt", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||
|
||||
expect(auskunft.person.name).toBe("Auskunft Suchend");
|
||||
expect(auskunft.person.mitgliedsnummer).toBe("A-1");
|
||||
expect(auskunft.wehr.name).toBe("Auskunfts-Testwehr");
|
||||
expect(auskunft.erstelltAm).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("gibt zu jedem Abschnitt an, wozu die Daten vorliegen", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Art. 15 Abs. 1 verlangt nicht nur die Daten, sondern auch die Zwecke.
|
||||
// Eine Liste ohne sie sieht aus wie eine Auskunft und ist keine.
|
||||
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||
|
||||
expect(auskunft.abschnitte.length).toBeGreaterThan(5);
|
||||
for (const abschnitt of auskunft.abschnitte) {
|
||||
expect(abschnitt.titel.length).toBeGreaterThan(0);
|
||||
expect(abschnitt.zweck.length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
it("enthält die Stammdaten vollständig", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||
const stammdaten = auskunft.abschnitte.find((eintrag) => eintrag.titel === "Stammdaten");
|
||||
|
||||
expect(stammdaten?.eintraege[0]).toMatchObject({
|
||||
Vorname: "Auskunft",
|
||||
Nachname: "Suchend",
|
||||
Geburtsdatum: "1985-04-12",
|
||||
Anschrift: "Musterweg 1, 91315 Musterhausen",
|
||||
});
|
||||
});
|
||||
|
||||
it("kennzeichnet Gesundheitsdaten als besondere Kategorie", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||
const g26 = auskunft.abschnitte.find((eintrag) => eintrag.titel.includes("G 26"));
|
||||
|
||||
expect(g26?.besondereKategorie).toBe(true);
|
||||
expect(g26?.eintraege).toHaveLength(1);
|
||||
expect(g26?.eintraege[0]?.Ergebnis).toBe("TAUGLICH");
|
||||
});
|
||||
|
||||
it("lässt das Unangenehme nicht weg", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Ein gemahnter Beitrag ist ein Datum über diese Person. Eine Auskunft,
|
||||
// die auswählt, was gut aussieht, erfüllt die Pflicht nicht.
|
||||
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||
const beitraege = auskunft.abschnitte.find((eintrag) =>
|
||||
eintrag.titel.includes("Mitgliedsbeiträge"),
|
||||
);
|
||||
|
||||
expect(beitraege?.eintraege[0]).toMatchObject({ Stand: "GEMAHNT", Mahnstufe: "2" });
|
||||
});
|
||||
|
||||
it("behält leere Abschnitte — auch das ist eine Auskunft", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Diese Person war in keinem Einsatz. Der Abschnitt muss trotzdem
|
||||
// dastehen: ein weggelassener wirft die Frage auf, ob da doch etwas ist.
|
||||
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||
const einsaetze = auskunft.abschnitte.find((eintrag) =>
|
||||
eintrag.titel.includes("Einsatzbeteiligung"),
|
||||
);
|
||||
|
||||
expect(einsaetze).toBeDefined();
|
||||
expect(einsaetze?.eintraege).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("zählt Tätigkeiten, statt die Unterlagen mitzuliefern", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Die Grenze zwischen „über mich" und „von mir". Stünden hier die
|
||||
// Prüfprotokolle im Volltext, käme jeder über eine Selbstauskunft an die
|
||||
// Geräteakten der Wehr.
|
||||
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||
|
||||
expect(auskunft.taetigkeiten.length).toBeGreaterThan(0);
|
||||
for (const eintrag of auskunft.taetigkeiten) {
|
||||
expect(typeof eintrag.anzahl).toBe("number");
|
||||
expect(eintrag).not.toHaveProperty("eintraege");
|
||||
}
|
||||
});
|
||||
|
||||
it("zählt den Umfang über alle Abschnitte", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||
const gezaehlt = auskunft.abschnitte.reduce(
|
||||
(summe, abschnitt) => summe + abschnitt.eintraege.length,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(auskunftUmfang(auskunft)).toBe(gezaehlt);
|
||||
expect(auskunftUmfang(auskunft)).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it("weist eine unbekannte Person ab, statt eine leere Auskunft zu bauen", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Sonst bekäme jemand ein Schreiben mit lauter leeren Abschnitten und
|
||||
// dem Eindruck, es lägen keine Daten vor.
|
||||
await expect(
|
||||
auskunftSammeln(
|
||||
system as unknown as MandantenClient,
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
),
|
||||
).rejects.toThrow(/gibt es nicht/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { zugangAus } from "../../scripts/sicherung-zugang";
|
||||
import { SPERREN } from "../../scripts/sicherung-sperren";
|
||||
|
||||
/**
|
||||
* Das Zerlegen der Verbindungszeichenkette.
|
||||
*
|
||||
* Der unangenehmste Fehler, den ein Sicherungsskript machen kann, ist nicht
|
||||
* abzubrechen: es liefe durch, schriebe eine Datei, und die enthielte die
|
||||
* falsche Datenbank. Auffallen würde es beim Zurückspielen — also genau dann,
|
||||
* wenn man die Sicherung braucht.
|
||||
*
|
||||
* Deshalb prüft dieser Test nicht nur, dass richtige Adressen richtig zerlegt
|
||||
* werden, sondern vor allem, dass falsche laut scheitern.
|
||||
*/
|
||||
|
||||
describe("Verbindungszeichenkette zerlegen", () => {
|
||||
it("liest Benutzer, Passwort und Datenbank", () => {
|
||||
const zugang = zugangAus("postgresql://florian:geheim@localhost:5442/florian?schema=public");
|
||||
|
||||
expect(zugang).toEqual({
|
||||
benutzer: "florian",
|
||||
passwort: "geheim",
|
||||
datenbank: "florian",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignoriert die Prisma-Parameter am Ende", () => {
|
||||
// `?schema=public` ist der Grund, warum die URL nicht als Ganzes an
|
||||
// pg_dump gehen kann — es lehnt sie mit "invalid URI query parameter" ab.
|
||||
const zugang = zugangAus(
|
||||
"postgresql://florian:geheim@localhost:5442/florian?schema=public&connection_limit=5",
|
||||
);
|
||||
|
||||
expect(zugang.datenbank).toBe("florian");
|
||||
});
|
||||
|
||||
it("entschlüsselt Sonderzeichen im Passwort", () => {
|
||||
// Ein Passwort mit @ oder / muss in der URL kodiert stehen. Würde es hier
|
||||
// nicht dekodiert, meldete Postgres nur "Passwort falsch".
|
||||
const zugang = zugangAus("postgresql://florian:ge%40heim%2Fx@localhost:5432/florian");
|
||||
|
||||
expect(zugang.passwort).toBe("ge@heim/x");
|
||||
});
|
||||
|
||||
it("kommt mit einem leeren Passwort zurecht", () => {
|
||||
const zugang = zugangAus("postgresql://florian@localhost:5432/florian");
|
||||
expect(zugang.passwort).toBe("");
|
||||
expect(zugang.benutzer).toBe("florian");
|
||||
});
|
||||
|
||||
it("bricht ab, wenn die Adresse fehlt", () => {
|
||||
expect(() => zugangAus(undefined)).toThrow(/DIRECT_DATABASE_URL fehlt/);
|
||||
expect(() => zugangAus("")).toThrow(/DIRECT_DATABASE_URL fehlt/);
|
||||
});
|
||||
|
||||
it("bricht ab, wenn es gar keine URL ist", () => {
|
||||
expect(() => zugangAus("einfach nur text")).toThrow(/keine gültige URL/);
|
||||
});
|
||||
|
||||
it("bricht bei „localhost:5432“ ab — über die Protokollprüfung", () => {
|
||||
// Node zerlegt das klaglos: „localhost:“ gilt als Protokoll, „5432“ als
|
||||
// Pfad. Ohne die Protokollprüfung eine Zeile weiter käme hier ein
|
||||
// Zugang mit leerem Benutzer heraus, und die Sicherung liefe ins Leere.
|
||||
expect(() => zugangAus("localhost:5432")).toThrow(/postgresql/);
|
||||
});
|
||||
|
||||
it("bricht ab, wenn es nicht Postgres ist", () => {
|
||||
// Sonst würde eine falsch gesetzte Umgebungsvariable eine Sicherung
|
||||
// erzeugen, die nichts enthält.
|
||||
expect(() => zugangAus("mysql://florian:geheim@localhost:3306/florian")).toThrow(
|
||||
/postgresql/,
|
||||
);
|
||||
});
|
||||
|
||||
it("bricht ab, wenn der Datenbankname fehlt", () => {
|
||||
expect(() => zugangAus("postgresql://florian:geheim@localhost:5432/")).toThrow(
|
||||
/Datenbankname/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Die Sperrenliste stimmt mit der Datenbank überein", () => {
|
||||
/**
|
||||
* Der Abgleich gegen die Wirklichkeit.
|
||||
*
|
||||
* `scripts/sicherung-sperren.ts` führt auf, welche Rechte die
|
||||
* Anwendungsrolle nicht haben darf — und stellt sie nach einer
|
||||
* Wiederherstellung wieder her. Diese Liste steht neben den Migrationen,
|
||||
* die dieselben Sperren setzen, und zwei Listen laufen auseinander.
|
||||
*
|
||||
* Läuft sie auseinander, ist der Schaden lautlos: eine Wiederherstellung
|
||||
* würde eine Sperre nicht zurückbringen, von der niemand mehr weiß, dass es
|
||||
* sie gab. Deshalb fragt dieser Test die migrierte Datenbank, statt der
|
||||
* Liste zu glauben.
|
||||
*/
|
||||
it("nennt keine Sperre, die es in der Datenbank nicht gibt", async ({ skip }) => {
|
||||
const url = process.env.DIRECT_DATABASE_URL;
|
||||
if (!url) return skip();
|
||||
|
||||
const { PrismaClient } = await import("@prisma/client");
|
||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||
|
||||
try {
|
||||
const tabellen = await prisma.$queryRawUnsafe<Array<{ table_name: string }>>(
|
||||
`SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`,
|
||||
);
|
||||
const vorhanden = new Set(tabellen.map((zeile) => zeile.table_name));
|
||||
|
||||
const unbekannt = SPERREN.map((eintrag) => eintrag.tabelle).filter(
|
||||
(tabelle) => !vorhanden.has(tabelle),
|
||||
);
|
||||
|
||||
expect(unbekannt, `Diese Tabellen gibt es nicht (mehr): ${unbekannt.join(", ")}`).toEqual(
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("übersieht keine Sperre, die die Migrationen gesetzt haben", async ({ skip }) => {
|
||||
const url = process.env.DIRECT_DATABASE_URL;
|
||||
if (!url) return skip();
|
||||
|
||||
const { PrismaClient } = await import("@prisma/client");
|
||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||
|
||||
try {
|
||||
const rolle = await prisma.$queryRawUnsafe<Array<{ da: boolean }>>(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') AS da",
|
||||
);
|
||||
if (!rolle[0]?.da) return skip();
|
||||
|
||||
// Welche Rechte hat die Anwendungsrolle heute wirklich?
|
||||
const zeilen = await prisma.$queryRawUnsafe<
|
||||
Array<{ table_name: string; privilege_type: string }>
|
||||
>(
|
||||
`SELECT table_name, privilege_type FROM information_schema.role_table_grants
|
||||
WHERE grantee = 'florian_app'`,
|
||||
);
|
||||
const hat = new Set(zeilen.map((z) => `${z.table_name}:${z.privilege_type}`));
|
||||
|
||||
// Jede Tabelle, der ein Schreibrecht fehlt, muss in der Liste stehen —
|
||||
// sonst käme sie nach einer Wiederherstellung ungesperrt zurück.
|
||||
const alleTabellen = [...new Set(zeilen.map((z) => z.table_name))];
|
||||
const notiert = new Map(SPERREN.map((e) => [e.tabelle, new Set(e.rechte)]));
|
||||
|
||||
const fehlend: string[] = [];
|
||||
for (const tabelle of alleTabellen) {
|
||||
for (const recht of ["INSERT", "UPDATE", "DELETE"]) {
|
||||
if (!hat.has(`${tabelle}:${recht}`) && !notiert.get(tabelle)?.has(recht)) {
|
||||
fehlend.push(`${tabelle}: ${recht}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
fehlend,
|
||||
"Diese Sperren stehen in der Datenbank, aber nicht in scripts/sicherung-sperren.ts — "
|
||||
+ `nach einer Wiederherstellung wären sie weg: ${fehlend.join(", ")}`,
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user