feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand die Uebersicht
oeffnete; ein ueberfaelliger ueberhaupt nicht. Vier Anlaesse gehen jetzt
hinaus — zugewiesen, ueberfaellig, abgeschlossen und Verzeichnisabgleich
fehlgeschlagen —, je Person abschaltbar. Niemand bekommt Post ueber die
eigene Tat: das ist der haeufigste Grund, Benachrichtigungen ganz
abzuschalten, und deshalb faellt der Handelnde immer aus der
Empfaengerliste.

Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht. Ein haengender
Mailserver darf kein Abhaken blockieren, also schreibt der Dienst eine
Zeile in `Zustellung` und ein eigener Zeitplan schickt sie. Betreff und
Rumpf stehen fertig darin: eine Meldung sagt, was *damals* war.

In keiner Nachricht steht ein Schrittinhalt, und damit auch kein
erzeugtes Passwort. Darueber wacht src/lib/meldungen.ts — die Funktionen
dort nehmen nur Kopfdaten, der Typ laesst nichts anderes zu. Geprueft
wird es zusaetzlich ueber alle Rumpftexte, die wirklich verschickt
wurden, so wie #012 es fuer die Druckansicht tat.

Zwei Dinge haetten ohne Datenbank nicht gehalten. Die Einmaligkeit der
Ueberfaelligkeitsmeldung liegt in einem partiellen eindeutigen Index:
der Zeitplan sieht jede Minute nach, und ohne ihn bekaeme der
Zustaendige fuer einen Lauf, der ueber das Wochenende steht, 2880
Mails — jede fuer sich korrekt, kein Test rot. Und die Sperre der
Warteschlange liegt in derselben Tabelle wie die des Abgleichs, denn
hinter einem Lastverteiler laufen zwei Instanzen.

Eine stille Warteschlange ist schlimmer als keine: Verwaltung →
Zustellungen zeigt mit Grund, was hinausging und was nicht.

Geprueft gegen einen echten SMTP-Faenger (mailpit) statt gegen eine
abgefangene Funktion — und `mailpit` gehoert damit ins Tor vor dem
Commit, sonst ueberspringt die Suite 3 Tests und sieht gruen aus.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-05 17:22:20 +02:00
co-authored by Claude Opus 5
parent 4aa79c93ff
commit 2d3325ef20
36 changed files with 3573 additions and 130 deletions
+13 -16
View File
@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import {
AbgleichLaeuftBereitsFehler,
abgleichAusfuehren,
faelligeQuellen,
mitAbgleichSperre,
} from "@/server/services/abgleich";
import { AbgleichLaeuftBereitsFehler, abgleichAusfuehren, faelligeQuellen } from "@/server/services/abgleich";
// Seit #034 liegt die Sperre in ihrem eigenen Modul: die Zustell-Warteschlange
// braucht dieselbe Mechanik, und zwei Fassungen davon waeren die schlechteste
// aller Loesungen.
import { mitSperre } from "@/server/services/sperre";
import type { HandelnderBenutzer } from "@/server/mandant";
import { quelleAnlegen, quelleBearbeiten, VerzeichnisFehler } from "@/server/services/verzeichnis";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
@@ -99,14 +98,14 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
const [haeltDieSperre, ersterIstDrin] = signal();
const [freigeben, darfWeiter] = signal();
const erster = mitAbgleichSperre("abgleich:test", 5, async () => {
const erster = mitSperre("abgleich:test", 5, async () => {
haeltDieSperre();
await darfWeiter;
return "erster";
});
await ersterIstDrin;
const zweiter = await mitAbgleichSperre("abgleich:test", 5, async () => "zweiter");
const zweiter = await mitSperre("abgleich:test", 5, async () => "zweiter");
freigeben();
// Abgewiesen, nicht eingereiht: `undefined` und nicht ein zweites Ergebnis.
@@ -114,26 +113,26 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
expect(await erster).toBe("erster");
// Danach ist die Sperre wieder frei.
expect(await testPrisma.abgleichSperre.count()).toBe(0);
expect(await mitAbgleichSperre("abgleich:test", 5, async () => "dritter")).toBe("dritter");
expect(await mitSperre("abgleich:test", 5, async () => "dritter")).toBe("dritter");
});
it("sperrt je Schluessel und nicht global", async () => {
const [haeltDieSperre, ersterIstDrin] = signal();
const [freigeben, darfWeiter] = signal();
const eine = mitAbgleichSperre("abgleich:eine", 5, async () => {
const eine = mitSperre("abgleich:eine", 5, async () => {
haeltDieSperre();
await darfWeiter;
return "eine";
});
await ersterIstDrin;
expect(await mitAbgleichSperre("abgleich:andere", 5, async () => "andere")).toBe("andere");
expect(await mitSperre("abgleich:andere", 5, async () => "andere")).toBe("andere");
freigeben();
expect(await eine).toBe("eine");
});
it("gibt die Sperre auch frei, wenn der Lauf wirft", async () => {
await expect(
mitAbgleichSperre("abgleich:test", 5, async () => {
mitSperre("abgleich:test", 5, async () => {
throw new Error("mitten im Abgleich");
}),
).rejects.toThrow("mitten im Abgleich");
@@ -151,7 +150,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
laeuftAbAm: new Date(Date.now() - 60 * 60 * 1000),
},
});
expect(await mitAbgleichSperre("abgleich:test", 5, async () => "uebernommen")).toBe("uebernommen");
expect(await mitSperre("abgleich:test", 5, async () => "uebernommen")).toBe("uebernommen");
expect(await testPrisma.abgleichSperre.count()).toBe(0);
});
@@ -163,9 +162,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
laeuftAbAm: new Date(Date.now() + 60 * 60 * 1000),
},
});
expect(
await mitAbgleichSperre("abgleich:test", 5, async () => "haette nicht laufen duerfen"),
).toBeUndefined();
expect(await mitSperre("abgleich:test", 5, async () => "haette nicht laufen duerfen")).toBeUndefined();
// Und sie wird auch nicht beim Aufraeumen mitgeloescht: sie gehoert einem
// anderen Lauf.
const zeile = await testPrisma.abgleichSperre.findUniqueOrThrow({
+960
View File
@@ -0,0 +1,960 @@
import { createServer, type Server } from "node:http";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
Betriebsmodus,
LaufStatus,
Meldeanlass,
VersionStatus,
Zustellweg,
Zustellzustand,
} from "@/generated/prisma/enums";
import { abgleichMeldung, laufMeldung, webhookRumpf } from "@/lib/meldungen";
import { SIGNATUR_KOPF, signaturBauen, signaturPruefen } from "@/lib/signatur";
import { verschluesseln } from "@/lib/geheimnisse";
import {
WARTEZEITEN_MINUTEN,
abgleichMeldungEinreihen,
laufMeldungEinreihen,
meldungseinstellungenHolen,
meldungseinstellungenSpeichern,
naechsterVersuch,
ueberfaelligeEinreihen,
warteschlangeAbarbeiten,
} from "@/server/services/benachrichtigungen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Benachrichtigungen per Mail und Webhook (#034).
*
* Fuenf Zusagen tragen das Ganze, und jede hat hier ihren Test:
*
* 1. Die vier Anlaesse gehen an die richtigen Empfaenger — **gegen einen echten
* SMTP-Faenger**, nicht gegen eine abgefangene Funktion.
* 2. In **keiner** Nachricht steht ein Schrittinhalt und damit kein erzeugtes
* Passwort.
* 3. Eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar.
* 4. Zwei gleichzeitige Durchgaenge schicken nichts zweimal.
* 5. Wer Benachrichtigungen abgeschaltet hat, bekommt nichts.
*/
const MAILPIT = "http://127.0.0.1:8025";
const SMTP_PORT = 1025;
/**
* Steht der Faenger?
*
* **Auf Modulebene geprueft, nicht in `beforeAll`.** `it.runIf(...)` wird beim
* Einlesen der Datei ausgewertet — ein in `beforeAll` gesetztes Flag kaeme zu
* spaet, und alle Mailtests waeren stillschweigend uebersprungen. Genau so ist
* es beim ersten Lauf passiert: 3 skipped, gruener Durchlauf, nichts geprueft.
*/
async function mailpitAntwortet(): Promise<boolean> {
try {
const antwort = await fetch(`${MAILPIT}/api/v1/info`, { signal: AbortSignal.timeout(2_000) });
return antwort.ok;
} catch {
return false;
}
}
const mailpitDa = await mailpitAntwortet();
if (!mailpitDa && process.env["CI"]) {
throw new Error(
"Mailpit antwortet nicht auf 8025. In CI duerfen die Mailtests nicht uebersprungen werden — " +
"`docker compose -f docker-compose.test.yml up -d --wait mailpit`.",
);
}
if (!mailpitDa) {
console.warn(
"Mailpit laeuft nicht; die Mailtests werden uebersprungen. " +
"Start: docker compose -f docker-compose.test.yml up -d --wait mailpit",
);
}
type MailpitNachricht = {
readonly ID: string;
readonly Subject: string;
readonly To: readonly { readonly Address: string }[];
};
async function mailpitLeeren(): Promise<void> {
await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" });
}
async function mailpitNachrichten(): Promise<MailpitNachricht[]> {
const antwort = await fetch(`${MAILPIT}/api/v1/messages?limit=100`);
const daten = (await antwort.json()) as { messages?: MailpitNachricht[] };
return daten.messages ?? [];
}
/** Der Klartext einer Nachricht – fuer die Passwort-Pruefung. */
async function mailpitText(id: string): Promise<string> {
const antwort = await fetch(`${MAILPIT}/api/v1/message/${id}`);
const daten = (await antwort.json()) as { Text?: string; Subject?: string };
return `${daten.Subject ?? ""}\n${daten.Text ?? ""}`;
}
/* ------------------------------------------------------------------ */
/* Die reinen Teile */
/* ------------------------------------------------------------------ */
describe("Die Signatur eines Webhooks", () => {
const GEHEIMNIS = "sehr-geheim";
const RUMPF = '{"anlass":"LAUF_ZUGEWIESEN"}';
const JETZT = 1_762_334_400;
it("lässt sich mit dem Geheimnis nachrechnen", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
expect(kopf).toMatch(/^t=1762334400,v1=[0-9a-f]{64}$/);
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT)).toEqual({ gueltig: true });
});
it("mit einem falschen Geheimnis nicht", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
expect(signaturPruefen("falsch", RUMPF, kopf, JETZT)).toEqual({
gueltig: false,
grund: "falsch",
});
});
it("und auch nicht, wenn der Rumpf nachträglich geändert wurde", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
expect(signaturPruefen(GEHEIMNIS, '{"anlass":"ETWAS_ANDERES"}', kopf, JETZT).gueltig).toBe(false);
});
it("weist eine zu alte und eine zu junge Nachricht ab", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
// Zu alt: eine abgefangene Nachricht soll sich nicht morgen einspielen lassen.
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT + 301)).toEqual({
gueltig: false,
grund: "abgelaufen",
});
/*
* Und zu jung. Nur „zu alt“ abzufangen liesse eine Nachricht mit Zeitstempel
* im Jahr 2040 fuer immer gueltig sein.
*/
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT - 301)).toEqual({
gueltig: false,
grund: "abgelaufen",
});
// Innerhalb der Toleranz geht beides.
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT + 299).gueltig).toBe(true);
});
it("weist einen kaputten Kopf ab, statt zu werfen", () => {
for (const kopf of ["", "unsinn", "t=abc,v1=ff", "v1=ff", "t=1762334400"]) {
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT)).toEqual({
gueltig: false,
grund: "form",
});
}
});
it("signiert den Zeitstempel mit", () => {
/*
* Stuende er nur daneben, koennte ihn jeder aendern, ohne die Signatur zu
* brechen. Dieselbe Signatur mit einem anderen `t` muss also scheitern.
*/
const echt = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
const geschoben = echt.replace(`t=${JETZT}`, `t=${JETZT + 10}`);
expect(signaturPruefen(GEHEIMNIS, RUMPF, geschoben, JETZT + 10)).toEqual({
gueltig: false,
grund: "falsch",
});
});
});
describe("Die Texte der Meldungen", () => {
const KOPF = {
titel: "PC Neuinstallation – PC-001",
geraet: "PC-001",
person: "Maria Huber",
faelligAm: new Date("2026-10-07T10:00:00Z"),
vorlage: "PC Neuinstallation",
handelnder: "Petra Klein",
};
it("nennt den Anlass im Betreff", () => {
expect(laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, KOPF, "u").betreff).toContain("ist dir zugewiesen");
expect(laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").betreff).toContain("überfällig");
expect(laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, "u").betreff).toContain("abgeschlossen");
expect(abgleichMeldung("AD Zentrale", "Verbindung abgelehnt", "u").betreff).toContain("fehlgeschlagen");
});
it("schreibt das Fälligkeitsdatum deutsch", () => {
expect(laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").rumpf).toContain("07.10.2026");
});
it("lässt leere Felder weg, statt „null“ zu schreiben", () => {
const dürftig = laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, { titel: "Nur ein Titel" }, "u");
expect(dürftig.rumpf).not.toContain("null");
expect(dürftig.rumpf).not.toContain("Gerät:");
expect(dürftig.rumpf).not.toContain("undefined");
});
it("trägt den Link, damit die Mail zu etwas führt", () => {
const link = "https://laufzettel.firma.de/laeufe/abc";
expect(laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, link).rumpf).toContain(link);
expect(abgleichMeldung("AD", "Grund", link).rumpf).toContain(link);
});
it("nimmt keinen Schrittinhalt auf — der Typ lässt es nicht zu", () => {
/*
* Die wichtigste Zusage dieses Meilensteins. Geprueft wird sie hier ueber
* die **Form**: `laufMeldung` nimmt nur Kopfdaten. Dass auch nichts aus der
* Datenbank durchsickert, prueft der Test „in keiner Nachricht steht ein
* erzeugtes Passwort“ weiter unten — ueber alle Rumpftexte, wie in #012.
*/
const alle = [
laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, KOPF, "u").rumpf,
laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").rumpf,
laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, "u").rumpf,
abgleichMeldung("AD", "Grund", "u").rumpf,
].join("\n");
expect(alle).not.toContain("{{");
expect(alle).not.toContain("Password");
expect(alle).not.toContain("New-ADUser");
});
it("baut einen Webhook-Rumpf mit dem Anlass als Feld", () => {
const rumpf = webhookRumpf(
Meldeanlass.LAUF_ABGESCHLOSSEN,
"Betreff",
"https://x/laeufe/1",
{ laufId: "l1" },
new Date("2026-10-05T08:00:00Z"),
);
expect(JSON.parse(rumpf)).toEqual({
anlass: "LAUF_ABGESCHLOSSEN",
betreff: "Betreff",
link: "https://x/laeufe/1",
laufId: "l1",
verzeichnisquelleId: null,
zeitpunkt: "2026-10-05T08:00:00.000Z",
});
});
});
describe("Die Wiederholungsregel", () => {
const JETZT = new Date("2026-10-05T08:00:00Z");
it("wartet nach dem ersten Fehlschlag 1 Minute, dann 5, 30 und 120", () => {
expect(WARTEZEITEN_MINUTEN).toEqual([1, 5, 30, 120]);
for (const [index, minuten] of WARTEZEITEN_MINUTEN.entries()) {
// `versucheGesamt` zaehlt den gerade gescheiterten mit: 1 → eine Minute.
const ergebnis = naechsterVersuch(index + 1, JETZT);
expect(ergebnis.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(ergebnis.naechsterVersuchAm.getTime() - JETZT.getTime()).toBe(minuten * 60_000);
}
});
it("gibt nach dem fünften Fehlschlag auf — aber sichtbar", () => {
const ergebnis = naechsterVersuch(WARTEZEITEN_MINUTEN.length + 1, JETZT);
expect(ergebnis.zustand).toBe(Zustellzustand.AUFGEGEBEN);
// Eine stille Warteschlange ist schlimmer als keine: der Zeitpunkt bleibt
// stehen, damit die Liste ihre Reihenfolge behaelt.
expect(ergebnis.naechsterVersuchAm).toEqual(JETZT);
});
});
/* ------------------------------------------------------------------ */
/* Mit Datenbank */
/* ------------------------------------------------------------------ */
const APP_URL_VORHER = process.env["APP_URL"];
async function aufbau() {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Petra Klein", rolle: "ADMIN" },
});
const techniker = await testPrisma.benutzer.create({
data: { email: "technik@firma.example", name: "Ali Demir", rolle: "TECHNIKER" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Konto", position: 0 },
});
/*
* Ein Befehlsschritt mit dem Platzhalter – damit der Passwort-Test etwas zu
* finden haette, wenn irgendwo ein Schrittinhalt in eine Mail geriete.
*/
const schritt = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Konto anlegen",
typ: "BEFEHL",
pflicht: true,
position: 0,
konfig: {
shell: "powershell",
befehl: 'New-ADUser -AccountPassword "{{generiertes_passwort}}" -Name "Maria Huber"',
},
},
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return { kunde, admin, techniker, versionId: version.id, schrittId: schritt.id };
}
async function laufAnlegenRoh(
versionId: string,
kundeId: string,
daten: {
titel?: string;
zugewiesenAnId?: string | null;
erstelltVonId?: string | null;
faelligAm?: Date | null;
status?: LaufStatus;
} = {},
) {
return testPrisma.lauf.create({
data: {
vorlagenVersionId: versionId,
kundeId,
titel: daten.titel ?? "PC Neuinstallation – PC-001",
zugewiesenAnId: daten.zugewiesenAnId ?? null,
erstelltVonId: daten.erstelltVonId ?? null,
faelligAm: daten.faelligAm ?? null,
status: daten.status ?? LaufStatus.OFFEN,
/*
* Die Zeitstempel gehoeren zum Status — `Lauf_begonnen_mit_zeitpunkt` und
* `Lauf_abgeschlossen_mit_zeitpunkt` verlangen das in der Datenbank. Ein
* Status ohne seinen Zeitpunkt ist hier gar nicht anlegbar, und das ist
* richtig so.
*/
...(daten.status === LaufStatus.IN_ARBEIT ? { gestartetAm: new Date() } : {}),
...(daten.status === LaufStatus.ABGESCHLOSSEN
? { gestartetAm: new Date(), abgeschlossenAm: new Date() }
: {}),
},
});
}
/** SMTP auf den Faenger zeigen lassen. */
async function smtpEinrichten(): Promise<void> {
await testPrisma.einstellung.update({
where: { id: "global" },
data: {
smtpHost: "127.0.0.1",
smtpPort: SMTP_PORT,
smtpTls: false,
smtpAbsender: "laufzettel@firma.example",
},
});
}
describe.runIf(datenbankVerfuegbar)("Einstellungen der Benachrichtigungen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("gibt das SMTP-Passwort nie heraus, nur ob eines gesetzt ist", async () => {
const { admin } = await aufbau();
await meldungseinstellungenSpeichern(
{
smtpHost: "mail.firma.de",
smtpPort: 587,
smtpBenutzer: "laufzettel",
smtpPasswort: "streng-geheim",
smtpAbsender: "laufzettel@firma.de",
},
admin.id,
);
const gelesen = await meldungseinstellungenHolen();
expect(gelesen.smtp.host).toBe("mail.firma.de");
expect(gelesen.smtp.passwortGesetzt).toBe(true);
// Der Wert selbst taucht in der Antwort nirgends auf.
expect(JSON.stringify(gelesen)).not.toContain("streng-geheim");
// Und in der Datenbank steht er verschlüsselt, nicht im Klartext.
const zeile = await testPrisma.einstellung.findUniqueOrThrow({ where: { id: "global" } });
expect(zeile.smtpPasswortVerschluesselt).toMatch(/^v[0-9]+\./);
expect(zeile.smtpPasswortVerschluesselt).not.toContain("streng-geheim");
});
it("schreibt kein Geheimnis ins Protokoll", async () => {
const { admin } = await aufbau();
await meldungseinstellungenSpeichern(
{ smtpHost: "mail.firma.de", smtpPort: 587, smtpPasswort: "streng-geheim" },
admin.id,
);
await meldungseinstellungenSpeichern(
{ webhookUrl: "https://hook.firma.de/laufzettel", webhookGeheimnis: "hook-geheim" },
admin.id,
);
const protokoll = await testPrisma.protokoll.findMany();
const alsText = JSON.stringify(protokoll);
// Das Protokoll ist ansehbar (#023) – hier wäre die Verschlüsselung sinnlos.
expect(alsText).not.toContain("streng-geheim");
expect(alsText).not.toContain("hook-geheim");
expect(alsText).toContain("Benachrichtigungen geändert");
});
it("weist eine unbrauchbare Webhook-Adresse und einen falschen Port ab", async () => {
const { admin } = await aufbau();
await expect(
meldungseinstellungenSpeichern({ webhookUrl: "nicht-mal-eine-url" }, admin.id),
).rejects.toThrow(/gültige URL/);
await expect(
meldungseinstellungenSpeichern({ webhookUrl: "ftp://hook.firma.de" }, admin.id),
).rejects.toThrow(/http/);
await expect(
meldungseinstellungenSpeichern({ smtpHost: "mail.firma.de", smtpPort: 70_000 }, admin.id),
).rejects.toThrow(/zwischen 1 und 65535/);
});
});
describe.runIf(datenbankVerfuegbar)("Einreihen", () => {
beforeAll(() => {
process.env["APP_URL"] = "https://laufzettel.firma.example";
});
afterAll(() => {
if (APP_URL_VORHER === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = APP_URL_VORHER;
});
beforeEach(async () => {
await datenbankLeeren();
});
it("meldet eine Zuweisung an den Zugewiesenen, nicht an den Zuweisenden", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
});
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
const zustellungen = await testPrisma.zustellung.findMany();
expect(zustellungen).toHaveLength(1);
expect(zustellungen[0]).toMatchObject({
anlass: Meldeanlass.LAUF_ZUGEWIESEN,
weg: Zustellweg.MAIL,
empfaenger: "technik@firma.example",
zustand: Zustellzustand.OFFEN,
versuche: 0,
});
expect(zustellungen[0]?.rumpf).toContain("https://laufzettel.firma.example/laeufe/");
expect(zustellungen[0]?.rumpf).toContain("Petra Klein");
});
it("schickt niemandem eine Mail über die eigene Tat", async () => {
/*
* Der haeufigste Grund, Benachrichtigungen ganz abzuschalten: Post ueber
* das, was man gerade selbst geklickt hat.
*/
const { kunde, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
const erzeugt = await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, techniker.id);
expect(erzeugt).toBe(0);
expect(await testPrisma.zustellung.count()).toBe(0);
});
it("meldet einen Abschluss auch dem, der den Lauf angelegt hat", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
});
// Abgeschlossen hat der Techniker – also erfaehrt es der Ersteller.
await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id);
const empfaenger = (await testPrisma.zustellung.findMany()).map((z) => z.empfaenger);
expect(empfaenger).toEqual(["admin@firma.example"]);
});
it("schickt nichts an jemanden, der Benachrichtigungen abgeschaltet hat", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.benutzer.update({
where: { id: techniker.id },
data: { meldungenAktiv: false },
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
});
it("schickt nichts an ein deaktiviertes Konto", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.benutzer.update({ where: { id: techniker.id }, data: { aktiv: false } });
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
});
it("achtet die Mandantengrenze", async () => {
/*
* Eine Meldung traegt Lauftitel und Geraet. Ohne diese Pruefung waere die
* Benachrichtigung der eine Weg nach draussen, den `sichtbareKundenIds`
* nicht abdeckt.
*/
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-";
try {
const fremd = await testPrisma.kunde.create({
data: { name: "Fremd GmbH", kuerzel: "FREMD" },
});
// Der Techniker darf nur den eigenen Kunden sehen …
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: kunde.id },
});
// … ist aber einem Lauf des fremden zugewiesen.
const lauf = await laufAnlegenRoh(versionId, fremd.id, { zugewiesenAnId: techniker.id });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
} finally {
if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher;
}
});
it("meldet einen fehlgeschlagenen Abgleich allen Administratoren", async () => {
const { kunde, admin } = await aufbau();
const zweiterAdmin = await testPrisma.benutzer.create({
data: { email: "admin2@firma.example", name: "Zweiter Admin", rolle: "ADMIN" },
});
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: kunde.id,
name: "AD Zentrale",
url: "ldaps://dc1.firma.test:636",
bindDn: "cn=admin,dc=firma,dc=test",
bindPasswortVerschluesselt: verschluesseln("egal"),
basisDn: "dc=firma,dc=test",
filter: "(objectClass=user)",
attributZuordnung: {},
},
});
await abgleichMeldungEinreihen(quelle.id, "Verbindung abgelehnt");
const zustellungen = await testPrisma.zustellung.findMany({ orderBy: { empfaenger: "asc" } });
expect(zustellungen.map((z) => z.empfaenger)).toEqual(["admin2@firma.example", "admin@firma.example"]);
expect(zustellungen[0]?.rumpf).toContain("Verbindung abgelehnt");
expect(zustellungen[0]?.verzeichnisquelleId).toBe(quelle.id);
expect(admin.id).toBeTruthy();
expect(zweiterAdmin.id).toBeTruthy();
});
it("meldet einen überfälligen Lauf genau einmal, auch bei jedem Durchgang", async () => {
/*
* Der Zeitplan sieht **jede Minute** nach. Ohne den partiellen eindeutigen
* Index bekaeme der Zustaendige fuer einen Lauf, der ueber das Wochenende
* steht, 2880 Mails — und jede einzelne davon waere fuer sich korrekt.
*/
const { kunde, techniker, versionId } = await aufbau();
await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
expect(await ueberfaelligeEinreihen()).toBe(1);
expect(await ueberfaelligeEinreihen()).toBe(0);
expect(await ueberfaelligeEinreihen()).toBe(0);
expect(await testPrisma.zustellung.count()).toBe(1);
});
it("hält die Einmaligkeit auch gegen einen Schreiber an der Service-Schicht vorbei", async () => {
const { kunde, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
await ueberfaelligeEinreihen();
await expect(
testPrisma.zustellung.create({
data: {
anlass: Meldeanlass.LAUF_UEBERFAELLIG,
weg: Zustellweg.MAIL,
empfaenger: "technik@firma.example",
betreff: "noch einmal",
rumpf: "noch einmal",
laufId: lauf.id,
benutzerId: techniker.id,
},
}),
).rejects.toThrow();
});
it("meldet einen überfälligen Lauf nicht, wenn er abgeschlossen ist", async () => {
const { kunde, techniker, versionId } = await aufbau();
await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.ABGESCHLOSSEN,
});
expect(await ueberfaelligeEinreihen()).toBe(0);
});
it("legt für einen Lauf ohne Zuweisung keine Zeile an", async () => {
const { kunde, admin, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: null });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
});
it("reiht neben der Mail eine Webhook-Zeile ein, wenn einer eingerichtet ist", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await meldungseinstellungenSpeichern(
{ webhookUrl: "https://hook.firma.example/laufzettel", webhookGeheimnis: "geheim" },
admin.id,
);
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
const zustellungen = await testPrisma.zustellung.findMany({ orderBy: { weg: "asc" } });
expect(zustellungen.map((z) => z.weg)).toEqual([Zustellweg.MAIL, Zustellweg.WEBHOOK]);
const hook = zustellungen.find((z) => z.weg === Zustellweg.WEBHOOK);
// Ein Webhook gehoert keiner Person – die Datenbank verlangt das sogar.
expect(hook?.benutzerId).toBeNull();
expect(JSON.parse(hook?.rumpf ?? "{}")).toMatchObject({ anlass: "LAUF_ZUGEWIESEN" });
});
});
/* ------------------------------------------------------------------ */
/* Senden */
/* ------------------------------------------------------------------ */
describe.runIf(datenbankVerfuegbar)("Die Warteschlange", () => {
beforeAll(() => {
process.env["APP_URL"] = "https://laufzettel.firma.example";
});
afterAll(() => {
if (APP_URL_VORHER === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = APP_URL_VORHER;
});
beforeEach(async () => {
await datenbankLeeren();
if (mailpitDa) await mailpitLeeren();
});
it.runIf(mailpitDa)("schickt alle vier Anlässe an die richtigen Empfänger", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await smtpEinrichten();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: kunde.id,
name: "AD Zentrale",
url: "ldaps://dc1.firma.test:636",
bindDn: "cn=admin,dc=firma,dc=test",
bindPasswortVerschluesselt: verschluesseln("egal"),
basisDn: "dc=firma,dc=test",
filter: "(objectClass=user)",
attributZuordnung: {},
},
});
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
await ueberfaelligeEinreihen();
await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id);
await abgleichMeldungEinreihen(quelle.id, "Verbindung abgelehnt");
const ergebnis = await warteschlangeAbarbeiten();
expect(ergebnis).toMatchObject({ fehlgeschlagen: 0, aufgegeben: 0 });
expect(ergebnis?.gesendet).toBe(4);
const nachrichten = await mailpitNachrichten();
expect(nachrichten).toHaveLength(4);
const nachEmpfaenger = new Map<string, string[]>();
for (const nachricht of nachrichten) {
for (const an of nachricht.To) {
nachEmpfaenger.set(an.Address, [...(nachEmpfaenger.get(an.Address) ?? []), nachricht.Subject]);
}
}
/*
* Der Techniker: zugewiesen und überfällig. Der Admin: abgeschlossen (er hat
* den Lauf angelegt) und der Abgleich.
*
* Geprueft wird **ohne Annahme ueber die Reihenfolge** — die haengt an der
* Sortierung der Betreffe, und die hatte ich beim ersten Lauf falsch
* geraten. Eine Sortierung ist hier auch nicht die Zusage; die Zusage ist,
* wer was bekommt.
*/
const anTechniker = nachEmpfaenger.get("technik@firma.example") ?? [];
expect(anTechniker).toHaveLength(2);
expect(anTechniker.some((betreff) => betreff.includes("ist dir zugewiesen"))).toBe(true);
expect(anTechniker.some((betreff) => betreff.includes("überfällig"))).toBe(true);
const anAdmin = nachEmpfaenger.get("admin@firma.example") ?? [];
expect(anAdmin).toHaveLength(2);
expect(anAdmin.some((betreff) => betreff.includes("abgeschlossen"))).toBe(true);
expect(anAdmin.some((betreff) => betreff.includes("Verzeichnis-Abgleich"))).toBe(true);
// Und alle Zeilen stehen jetzt auf GESENDET, mit Zeitpunkt.
const zeilen = await testPrisma.zustellung.findMany();
expect(zeilen).toHaveLength(4);
for (const zeile of zeilen) {
expect(zeile.zustand).toBe(Zustellzustand.GESENDET);
expect(zeile.gesendetAm).toBeInstanceOf(Date);
expect(zeile.versuche).toBe(1);
}
});
it.runIf(mailpitDa)("hat in keiner Nachricht ein erzeugtes Passwort", async () => {
/*
* Ueber **alle** Rumpftexte, wie in #012. Die Vorlage im Aufbau hat einen
* Befehlsschritt mit `{{generiertes_passwort}}` und `New-ADUser` — kaeme
* irgendwo ein Schrittinhalt in eine Mail, stuende es hier.
*/
const { kunde, admin, techniker, versionId } = await aufbau();
await smtpEinrichten();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
await ueberfaelligeEinreihen();
await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id);
await warteschlangeAbarbeiten();
const nachrichten = await mailpitNachrichten();
expect(nachrichten.length).toBeGreaterThan(0);
for (const nachricht of nachrichten) {
const text = await mailpitText(nachricht.ID);
expect(text).not.toContain("{{generiertes_passwort}}");
expect(text).not.toContain("New-ADUser");
expect(text).not.toContain("AccountPassword");
// Auch kein aufgeloestes Passwort: 20 Zeichen aus dem Alphabet von #012.
expect(text).not.toMatch(/-AccountPassword/);
}
// Und in der Datenbank steht es auch nicht.
const alsText = JSON.stringify(await testPrisma.zustellung.findMany());
expect(alsText).not.toContain("{{");
expect(alsText).not.toContain("New-ADUser");
});
it("wiederholt eine fehlgeschlagene Zustellung und macht sie danach sichtbar", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
// **Kein** SMTP eingerichtet: jeder Versuch muss scheitern.
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
/*
* **Nicht** ein festes Datum: `naechsterVersuchAm` steht beim Einreihen auf
* `now()` der Datenbank. Ein Zeitpunkt in der Vergangenheit liesse den
* Durchgang die Zeile gar nicht erst anfassen — beim ersten Lauf war der
* Test deshalb rot, und zwar ohne Fehler im Code.
*/
const jetzt = new Date(Date.now() + 1_000);
const erster = await warteschlangeAbarbeiten(jetzt);
expect(erster).toMatchObject({ gesendet: 0, fehlgeschlagen: 1, aufgegeben: 0 });
let zeile = await testPrisma.zustellung.findFirstOrThrow();
expect(zeile.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(zeile.versuche).toBe(1);
expect(zeile.letzterFehler).toContain("SMTP");
// Eine Minute später, nicht sofort.
expect(zeile.naechsterVersuchAm.getTime()).toBe(jetzt.getTime() + 60_000);
// Vor der Wartezeit fasst der Durchgang die Zeile nicht an.
expect(await warteschlangeAbarbeiten(jetzt)).toMatchObject({ gesendet: 0, fehlgeschlagen: 0 });
expect((await testPrisma.zustellung.findFirstOrThrow()).versuche).toBe(1);
// Danach die Versuche zwei bis vier, jeder nach seiner Wartezeit.
for (const erwarteteWartezeit of WARTEZEITEN_MINUTEN.slice(1)) {
const spaeter = new Date(zeile.naechsterVersuchAm.getTime() + 1_000);
const vorherVersuche = zeile.versuche;
await warteschlangeAbarbeiten(spaeter);
zeile = await testPrisma.zustellung.findFirstOrThrow();
expect(zeile.versuche).toBe(vorherVersuche + 1);
expect(zeile.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(zeile.naechsterVersuchAm.getTime() - spaeter.getTime()).toBe(erwarteteWartezeit * 60_000);
}
expect(zeile.versuche).toBe(WARTEZEITEN_MINUTEN.length);
// Der fünfte Fehlschlag gibt auf – und bleibt sichtbar.
const zuletzt = new Date(zeile.naechsterVersuchAm.getTime() + 1_000);
const ergebnis = await warteschlangeAbarbeiten(zuletzt);
expect(ergebnis).toMatchObject({ aufgegeben: 1 });
zeile = await testPrisma.zustellung.findFirstOrThrow();
expect(zeile.zustand).toBe(Zustellzustand.AUFGEGEBEN);
expect(zeile.versuche).toBe(WARTEZEITEN_MINUTEN.length + 1);
expect(zeile.letzterFehler).toBeTruthy();
// Und wird nicht mehr angefasst.
expect(await warteschlangeAbarbeiten(new Date(zuletzt.getTime() + 86_400_000))).toMatchObject({
gesendet: 0,
fehlgeschlagen: 0,
aufgegeben: 0,
});
});
it.runIf(mailpitDa)("schickt bei zwei gleichzeitigen Durchgängen nichts zweimal", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await smtpEinrichten();
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
/*
* Beide zugleich starten. Einer bekommt die Sperre, der andere `undefined` —
* ohne sie bekaeme der Empfaenger bei zwei Instanzen jede Mail zweimal.
*/
const [eins, zwei] = await Promise.all([warteschlangeAbarbeiten(), warteschlangeAbarbeiten()]);
const ergebnisse = [eins, zwei];
expect(ergebnisse.filter((e) => e === undefined)).toHaveLength(1);
expect(ergebnisse.find((e) => e !== undefined)).toMatchObject({ gesendet: 1 });
expect(await mailpitNachrichten()).toHaveLength(1);
});
});
/* ------------------------------------------------------------------ */
/* Webhook gegen einen echten Empfaenger */
/* ------------------------------------------------------------------ */
describe.runIf(datenbankVerfuegbar)("Der Webhook", () => {
let server: Server | undefined;
let port = 0;
let empfangen: { kopf: string | undefined; rumpf: string }[] = [];
let antwortStatus = 200;
beforeAll(async () => {
process.env["APP_URL"] = "https://laufzettel.firma.example";
server = createServer((anfrage, antwort) => {
let rumpf = "";
anfrage.on("data", (stueck) => {
rumpf += stueck;
});
anfrage.on("end", () => {
empfangen.push({
kopf: anfrage.headers[SIGNATUR_KOPF.toLowerCase()] as string | undefined,
rumpf,
});
antwort.writeHead(antwortStatus).end(antwortStatus === 200 ? "ok" : "kaputt");
});
});
await new Promise<void>((fertig) => {
server?.listen(0, "127.0.0.1", () => fertig());
});
const adresse = server?.address();
port = typeof adresse === "object" && adresse ? adresse.port : 0;
});
afterAll(async () => {
if (APP_URL_VORHER === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = APP_URL_VORHER;
await new Promise<void>((fertig) => {
server?.close(() => fertig());
});
});
beforeEach(async () => {
await datenbankLeeren();
empfangen = [];
antwortStatus = 200;
});
afterEach(() => {
antwortStatus = 200;
});
it("kommt an, und die Signatur lässt sich mit dem Geheimnis nachrechnen", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: {
webhookUrl: `http://127.0.0.1:${port}/laufzettel`,
webhookGeheimnisVerschluesselt: verschluesseln("hook-geheim"),
},
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
// Nur die Webhook-Zeile: kein SMTP eingerichtet, die Mailzeile scheitert.
const ergebnis = await warteschlangeAbarbeiten();
expect(ergebnis?.gesendet).toBe(1);
expect(empfangen).toHaveLength(1);
const nachricht = empfangen[0];
if (!nachricht?.kopf) throw new Error("Keine Signatur im Kopf.");
const jetzt = Math.floor(Date.now() / 1000);
expect(signaturPruefen("hook-geheim", nachricht.rumpf, nachricht.kopf, jetzt)).toEqual({
gueltig: true,
});
// Mit einem falschen Geheimnis nicht.
expect(signaturPruefen("falsch", nachricht.rumpf, nachricht.kopf, jetzt).gueltig).toBe(false);
expect(JSON.parse(nachricht.rumpf)).toMatchObject({
anlass: "LAUF_ZUGEWIESEN",
laufId: lauf.id,
});
});
it("gilt als fehlgeschlagen, wenn die Gegenseite nicht mit 2xx antwortet", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { webhookUrl: `http://127.0.0.1:${port}/laufzettel` },
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
antwortStatus = 500;
await warteschlangeAbarbeiten(new Date(Date.now() + 1_000));
const hook = await testPrisma.zustellung.findFirstOrThrow({ where: { weg: Zustellweg.WEBHOOK } });
expect(hook.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(hook.letzterFehler).toContain("500");
});
it("geht ohne Geheimnis hinaus, dann aber ohne Signatur", async () => {
/*
* Ein Webhook ohne Geheimnis ist zulaessig — manche Gegenstellen haben keine
* Pruefung. Dann fehlt die Kopfzeile, statt dass eine leere Signatur
* Sicherheit vortaeuscht.
*/
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { webhookUrl: `http://127.0.0.1:${port}/laufzettel` },
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
await warteschlangeAbarbeiten();
expect(empfangen).toHaveLength(1);
expect(empfangen[0]?.kopf).toBeUndefined();
});
});
+14
View File
@@ -99,6 +99,12 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"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).
*/
"Zustellung_ueberfaellig_einmal",
]);
});
@@ -111,6 +117,9 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Anhang_groesse_positiv",
"Anhang_pruefsumme_form",
"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",
@@ -123,6 +132,11 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"TagCodeVergabe_form",
"Verzeichnisquelle_bindpasswort_verschluesselt",
"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",
]);
});
+8 -3
View File
@@ -18,7 +18,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const abgleichAusfuehren = vi.fn();
const faelligeQuellen = vi.fn();
/** Nur da, um zu belegen, dass der Zeitplan sie **nicht** nimmt. */
const mitAbgleichSperre = vi.fn();
const mitSperre = vi.fn();
/** Dieselbe Klasse wie im Dienst – `instanceof` entscheidet ueber das Verhalten. */
class AbgleichLaeuftBereitsFehler extends Error {}
@@ -27,7 +27,12 @@ vi.mock("@/server/services/abgleich", () => ({
AbgleichLaeuftBereitsFehler,
abgleichAusfuehren: (...args: unknown[]) => abgleichAusfuehren(...args),
faelligeQuellen: (...args: unknown[]) => faelligeQuellen(...args),
mitAbgleichSperre: (...args: unknown[]) => mitAbgleichSperre(...args),
}));
// Seit #034 liegt die Sperre in `services/sperre`. Gemockt wird sie weiter, um
// zu belegen, dass der Zeitplan sie **nicht** selbst nimmt.
vi.mock("@/server/services/sperre", () => ({
mitSperre: (...args: unknown[]) => mitSperre(...args),
}));
const { abgleichDurchgang, zeitplanAnhalten, zeitplanLaeuft, zeitplanStarten } =
@@ -57,7 +62,7 @@ describe("Ein Durchgang des Zeitplans", () => {
* Schluessel, liefe an der ersten vorbei — und ein von Hand angestossener
* Abgleich saehe fuer den Zeitplan wie ein unerwarteter Fehler aus.
*/
expect(mitAbgleichSperre).not.toHaveBeenCalled();
expect(mitSperre).not.toHaveBeenCalled();
/*
* Der handelnde „Benutzer“ ist der Systemlauf: ADMIN, damit die Rechtepruefung
* des Dienstes greift, und ohne Id, weil kein Mensch es war. Der Dienst macht