Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.
Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.
Schwer:
- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
(/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.
Mittel:
- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
Graph-Leser sich im Kommentar darauf berief.
Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.
Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.
1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
989 lines
39 KiB
TypeScript
989 lines
39 KiB
TypeScript
import { createServer, type Server } from "node:http";
|
||
import { existsSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
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 {
|
||
MELDUNGS_PFADE,
|
||
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("zeigt mit jedem Pfad auf eine Seite, die es wirklich gibt", () => {
|
||
/*
|
||
* **Der Test, der gefehlt hat.** Oben wird geprüft, dass der *übergebene*
|
||
* Link im Rumpf steht — das ist richtig und sagt nichts darüber, ob der
|
||
* Pfad existiert. `/verwaltung/verzeichnis` stand im Dienst und war ein
|
||
* 404: `verwaltung` ist der Name des Rechts, die Seite liegt unter
|
||
* `/admin/verzeichnis` (Reviewbefund zu #029).
|
||
*
|
||
* Ein Pfad in einer Mail wird nicht nachgesehen, sondern geklickt — und
|
||
* genau diese eine Meldung geht an **alle** Administratoren, im Moment
|
||
* eines Ausfalls. Geprüft wird deshalb gegen das Dateisystem: zu jedem Pfad
|
||
* muss unter `src/app/(app)/` eine `page.tsx` liegen.
|
||
*/
|
||
const wurzel = new URL("../src/app/(app)", import.meta.url).pathname;
|
||
for (const pfad of Object.values(MELDUNGS_PFADE)) {
|
||
const teile = pfad.replace(/^\//u, "").split("/");
|
||
// Ein Pfad, der auf eine Kennung endet, liegt unter `[id]`.
|
||
const kandidaten = [join(wurzel, ...teile, "page.tsx"), join(wurzel, ...teile, "[id]", "page.tsx")];
|
||
expect(
|
||
kandidaten.some((datei) => existsSync(datei)),
|
||
`${pfad} zeigt auf keine Seite – gesucht: ${kandidaten.join(" oder ")}`,
|
||
).toBe(true);
|
||
}
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|