Files
Laufzettel/tests/datenbankregeln.test.ts
Florian WildeandClaude Opus 5 d30bb6d235
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions
feat(laeufe): Vier-Augen-Freigabe (#040)
Verlangt eine Vorlagen-Version eine Freigabe, laesst sich ein Lauf erst
abschliessen, wenn ein Zweiter ihn freigegeben hat. Das braucht zweierlei, und
beides zusammen: das neue Recht `freigeben` (ADMIN und REDAKTEUR, nicht
TECHNIKER) und dass man nicht an dem Lauf gearbeitet hat.

Als gearbeitet zaehlt: angelegt, einen Schritt gesetzt oder abgeschlossen. Eine
Zuweisung allein nicht — waere die Regel hier strenger, blockierte sie kleine
Teams ohne Gewinn.

Auch ein Administrator kann sich nicht selbst freigeben, und das ist der ganze
Sinn. Die Pruefung sitzt in `hatDaranGearbeitet` an einer Stelle, wie
`laufMussVeraenderbarSein`; die wichtigste Haelfte — der Ersteller — haelt
zusaetzlich ein CHECK in der Datenbank.

Zurueckweisen verlangt eine Begruendung, setzt den Lauf auf IN_ARBEIT und nimmt
eine erteilte Freigabe zurueck. Kein fuenfter LaufStatus: ein neuer Enum-Wert
haette jeden Codepfad betroffen, der heute vier Faelle kennt.

Sobald jemand abzuschliessen versucht und die Freigabe fehlt, bekommen die
Freigabeberechtigten eine Mail — ein anderer Empfaengerkreis als bei jeder
anderen Meldung, und ohne die, die gearbeitet haben. Die Aufforderung entsteht
ausserhalb der Transaktion: innerhalb wuerde sie mit dem Fehler zurueckgerollt,
und dann wartet ein Lauf auf etwas, von dem niemand weiss. Ein partieller
Unique-Index haelt sie bei mehreren Versuchen auf einmal.

Die Pflicht haengt an der Version, nicht an der Vorlage: eine veroeffentlichte
Version ist unveraenderlich, ein laufender Lauf behaelt also seine Regel. Beim
Bearbeiten und Duplizieren wandert sie mit — das war die unmittelbare Lehre aus
#039, wo die tiefe Kopie zwei Felder stillschweigend verworfen hatte.

1526 Vitest-Tests (vorher 1496), 115 E2E-Ablaeufe gruen. Klickprobe am gebauten
Stand mit elf Pruefungen; drei rote Punkte darin waren meine eigenen
Testfehler, einer davon lehrreich: ein reload() direkt nach einer Server Action
trifft den Router-Cache von Next und zeigt kurz den alten Stand.

Closes #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-24 01:14:04 +02:00

210 lines
8.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Wächter über die Datenbankobjekte, die Prisma nicht kennt (#009).
*
* Zusammengesetzte Fremdschlüssel, Trigramm-Indizes und Trigger lassen sich im
* Prisma-Schema nicht ausdrücken. Deshalb erzeugt `prisma migrate dev` bei
* **jeder** künftigen Migration Löschbefehle für sie — sie sind aus Prismas
* Sicht Abweichungen von der Vorgabe. Wer die Löschbefehle einmal übersieht,
* entfernt stillschweigend die Mandantengrenze, die Archivsuche oder den Schutz
* der Unveränderlichkeit.
*
* Dieser Test macht das laut. Er prüft nicht, dass die Regeln *wirken* — das tun
* `tests/datenmodell.test.ts` und `tests/vorlagen.test.ts` —, sondern nur, dass
* die Objekte überhaupt noch da sind.
*/
async function namen(abfrage: string): Promise<string[]> {
const zeilen = await testPrisma.$queryRawUnsafe<{ name: string }[]>(abfrage);
return zeilen.map((zeile) => zeile.name).sort();
}
describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
it("hat die zusammengesetzten Fremdschlüssel der Mandantengrenze", async () => {
// Ohne sie kann ein Lauf ein Gerät eines fremden Kunden ziehen — und ein
// Anhang an einem Schritt hängen, der zu einem anderen Lauf gehört.
expect(
await namen(
`SELECT conname AS name FROM pg_constraint
WHERE conname LIKE '%gleicher_kunde' OR conname LIKE '%gleicher_lauf'`,
),
).toEqual([
"Anhang_ergebnis_gleicher_lauf",
"Anhang_lauf_gleicher_kunde",
"Geraet_besitzer_gleicher_kunde",
"Lauf_abteilung_gleicher_kunde",
"Lauf_geraet_gleicher_kunde",
"Lauf_mitarbeiter_gleicher_kunde",
"Mitarbeiter_vorgesetzter_gleicher_kunde",
]);
});
it("hat die Trigramm-Indizes für die Archivsuche", async () => {
// Ohne sie wird jede Archivsuche zum vollständigen Durchlauf der Tabelle.
expect(
await namen(
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%gin_trgm_ops%'`,
),
).toEqual([
"Kommentar_rumpf_trgm_idx",
"Lauf_titel_trgm_idx",
"Schrittergebnis_notiz_trgm_idx",
"Schrittergebnis_wert_trgm_idx",
]);
});
it("hat die Trigger, die eine veröffentlichte Version schützen", async () => {
expect(
await namen(
`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal AND tgname NOT LIKE '%_melden'`,
),
).toEqual([
"BausteinSchritt_unveraenderlich",
"BausteinVersion_status_einbahn",
/*
* Zieht die Rolle mit, wenn eine Person geloescht wird (#035). Ohne ihn
* scheiterte das `ON DELETE SET NULL` des Fremdschluessels am CHECK, und
* das Loeschen eines Mitarbeiters waere unmoeglich statt folgerichtig.
*/
"Mitarbeiter_konto_mitziehen",
"VorlagenAbschnitt_baustein_veroeffentlicht",
/*
* Eine Schritt-Bedingung zeigt auf einen Schritt **derselben Version**
* (#039). Ein CHECK kann das nicht: es verlangt einen Blick in zwei andere
* Tabellen. Ohne diese Pruefung zeigt eine Bedingung nach dem
* Veroeffentlichen ins Leere — „Bearbeiten“ erzeugt eine tiefe Kopie mit
* neuen Kennungen —, und der Abschnitt erscheint dann stillschweigend
* immer.
*/
"VorlagenAbschnitt_bedingung_gleiche_version",
"VorlagenAbschnitt_unveraenderlich",
"VorlagenSchritt_bedingung_gleiche_version",
"VorlagenSchritt_unveraenderlich",
"VorlagenVariable_unveraenderlich",
"VorlagenVersion_status_einbahn",
]);
});
it("hat die Trigger, die eine Änderung am Lauf melden", async () => {
/*
* Ohne sie bleibt die Live-Aktualisierung still, und zwar **lautlos**: die
* Seite sieht aus wie immer, nur sieht niemand mehr die Arbeit des anderen.
* Welche Tabellen dabei sind, haengt an dem, was `laufZustand` liefert —
* fehlte `Kommentar` oder `Anhang`, waere der Strom gegenueber dem alten
* Polling ein Rueckschritt (#033).
*/
expect(
await namen(`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal AND tgname LIKE '%_melden'`),
).toEqual([
"Anhang_geaendert_melden",
"Kommentar_geaendert_melden",
"Lauf_geaendert_melden",
"Schrittergebnis_geaendert_melden",
]);
});
it("hat die partiellen Eindeutigkeitsindizes", async () => {
expect(
await namen(
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%WHERE%'`,
),
).toEqual([
"BausteinVersion_ein_entwurf",
"Kunde_eigene_organisation_einmalig",
"Vorlage_seedSchluessel_einmalig",
"VorlagenVersion_ein_entwurf",
/*
* Ohne diesen bekaeme der Zustaendige fuer einen Lauf, der ueber das
* Wochenende ueberfaellig steht, 2880 Mails — und zwar ohne dass ein
* einziger Test rot waere, denn jede davon ist fuer sich korrekt (#034).
*/
/*
* Die Freigabe-Aufforderung geht je Lauf und Empfaenger einmal hinaus
* (#040). Sie entsteht bei einem gescheiterten Abschlussversuch, und
* jemand versucht es mehrmals — ohne den Index eine Mail je Versuch, und
* jede davon fuer sich korrekt.
*/
"Zustellung_freigabe_einmal",
"Zustellung_ueberfaellig_einmal",
]);
});
it("hat alle Prüfbedingungen", async () => {
const vorhanden = await namen(
`SELECT conname AS name FROM pg_constraint WHERE contype='c' AND connamespace='public'::regnamespace`,
);
// Namentlich aufgezählt, damit auch das Verschwinden einer einzelnen auffällt.
expect(vorhanden).toEqual([
"Anhang_groesse_positiv",
"Anhang_pruefsumme_form",
/*
* Die Rolle MITARBEITER und ihre Zuordnung gehoeren zusammen, in beide
* Richtungen (#035). Ohne das waere ein MITARBEITER ohne Person moeglich —
* ein Recht, das sich auf niemanden bezieht, und eine Einschraenkung, die
* je nach Formulierung „nichts“ oder „alles“ bedeutet.
*/
"Benutzer_mitarbeiter_nur_bei_rolle",
"Einstellung_einzelne_zeile",
"Einstellung_smtp_passwort_verschluesselt",
"Einstellung_smtp_port_bereich",
"Einstellung_webhook_geheimnis_verschluesselt",
"Geraet_registriert_braucht_namen",
"Geraet_tagcode_form",
"Kunde_kuerzel_form",
"Lauf_abgeschlossen_mit_zeitpunkt",
"Lauf_archiviert_mit_zeitpunkt",
"Lauf_begonnen_mit_zeitpunkt",
/*
* Die Vier-Augen-Regel in der Datenbank (#040): wer den Lauf angelegt hat,
* gibt ihn nicht frei. Der Dienst prueft strenger — er sieht auch nach, wer
* an den Schrittergebnissen gearbeitet hat —, aber diese Haelfte gilt auch
* dann, wenn jemand an der Dienstschicht vorbei schreibt.
*/
"Lauf_freigabe_mit_zeitpunkt",
"Lauf_freigabe_nicht_selbst",
"Lauf_mitarbeiter_eindeutig",
"Mitarbeiter_vorgesetzter_nicht_selbst",
"Schrittergebnis_begruendung_noetig",
"TagCodeVergabe_form",
"Verzeichnisquelle_bindpasswort_verschluesselt",
/*
* Seit #028 hat eine Quelle eine **Art**, und welche Felder sie braucht,
* haengt daran. Ohne diese Bedingung waere eine Graph-Quelle mit
* `basisDn` moeglich — eine Zeile, die zwei Dinge gleichzeitig behauptet.
*/
"Verzeichnisquelle_clientsecret_verschluesselt",
"Verzeichnisquelle_felder_je_art",
/*
* Das SCIM-Token steht nur als argon2-Hash da (#029). Ein Token, das
* jemand versehentlich roh speichert, faellt hier auf — und zwar in der
* Datenbank, also auch dann, wenn jemand am Dienst vorbei schreibt.
*/
"Verzeichnisquelle_scimtoken_gehasht",
// Und ein Token gehoert zu einem Zeitpunkt, in beide Richtungen.
"Verzeichnisquelle_scimtoken_mit_zeitpunkt",
/*
* Die Form einer Bedingung (#039). Eine mit unbekannter `art` wuerde beim
* Lesen als „keine Bedingung“ gelten — der Abschnitt waere sichtbar, und
* niemand koennte sagen, warum seine Bedingung nicht wirkt.
*/
"VorlagenAbschnitt_bedingung_form",
"VorlagenSchritt_bedingung_form",
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
"Zustellung_bezug_passt_zum_anlass",
"Zustellung_empfaenger_nicht_leer",
"Zustellung_gesendet_mit_zeitpunkt",
"Zustellung_versuche_nicht_negativ",
"Zustellung_webhook_ohne_benutzer",
]);
});
it("hat die Erweiterung für die Trigramm-Suche", async () => {
const zeilen = await testPrisma.$queryRaw<{ extname: string }[]>`
SELECT extname FROM pg_extension WHERE extname = 'pg_trgm'
`;
expect(zeilen).toHaveLength(1);
});
});