Files
craftvia/scripts/test-incident-inbound.ts
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

180 lines
7.4 KiB
TypeScript

// Reiner Test der Inbound-Mail-Logik für Vorfälle (IM-D, Fachkonzept §2/§14.3).
//
// Prüft parse.ts + route.ts OHNE Netz/DB/IMAP (Fixtures):
// 1. Token wird aus Delivered-To/X-Envelope-To gezogen — NICHT aus To (Weiterleitung).
// 2. Weiterleitung: SPF-Fail wird toleriert, DKIM-pass + Allowlist tragen → Vorfall.
// 3. Auto-Reply/Bounce (Auto-Submitted/Precedence/leerer Return-Path) → ignorieren.
// 4. Dedupe per Message-ID (alreadySeen) → ignorieren.
// 5. Unbekannter Token → Betreiber-Review (nicht droppen).
// 6. Kein Token → Betreiber-Review.
// 7. Absender nicht in Allowlist → Review (fail-closed).
// 8. DKIM=fail → Review (auch bei getroffener Allowlist).
//
// Lauf: npx tsx scripts/test-incident-inbound.ts (rein, keine DB).
import {
parseInbound,
deliveryRecipients,
tokenFromAddress,
isAutoSubmitted,
authResults,
intakeAddress,
type RawHeaders,
} from "../src/server/incident-inbound/parse";
import { decideRoute, generateIntakeToken, type IntakeConfigView } from "../src/server/incident-inbound/route";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const DOMAIN = "in.certvia.de";
const TOKEN = "abc123def456"; // passt auf [a-z0-9]{8,64}
const INTAKE = intakeAddress(TOKEN, DOMAIN); // vorfall-abc123def456@in.certvia.de
const config: IntakeConfigView = {
tenantId: "tenant-1",
token: TOKEN,
allowlistDomains: ["kunde.de"],
sourceAddress: "vorfall@kunde.de",
};
// Hilfsbau einer Fixture-Mail.
function mail(headers: RawHeaders, extra: Partial<{ from: string; subject: string; text: string; messageId: string }> = {}) {
return parseInbound({ headers, ...extra }, DOMAIN);
}
// --- 1) Token aus Delivered-To / X-Envelope-To, nicht aus To -------------------
{
const parsed = mail(
{
"To": "vorfall@kunde.de", // Kundenadresse — hier steht KEIN Token
"Delivered-To": INTAKE, // Zustelladresse trägt den Token
"From": "Melder <melder@kunde.de>",
"Message-ID": "<m1@kunde.de>",
"Authentication-Results": "mx.certvia.de; dkim=pass header.d=kunde.de; spf=fail",
},
{ subject: "Serverausfall", text: "Bitte prüfen." },
);
ok(parsed.token === TOKEN, "Token wird aus Delivered-To gezogen");
ok(parsed.recipientForToken === INTAKE, "recipientForToken = Intake-Adresse");
ok(tokenFromAddress("vorfall@kunde.de", DOMAIN) === null, "To-Adresse liefert keinen Token");
ok(deliveryRecipients({ To: "vorfall@kunde.de" }).length === 0, "To zählt nicht als Zustell-Empfänger");
}
// --- 2) Weiterleitung: SPF-Fail toleriert, DKIM+Allowlist → Vorfall ------------
{
const parsed = mail(
{
"X-Envelope-To": INTAKE,
"From": "Alice <alice@kunde.de>",
"Message-ID": "<m2@kunde.de>",
"Authentication-Results": "mx; dkim=pass; spf=fail", // Weiterleitung bricht SPF
},
{ subject: "Phishing gemeldet", text: "Verdächtige Mail erhalten." },
);
ok(parsed.spf === "fail" && parsed.dkim === "pass", "SPF=fail, DKIM=pass erkannt");
const d = decideRoute(parsed, { config, alreadySeen: false });
ok(d.action === "incident", "trotz SPF-Fail → Vorfall (DKIM+Allowlist tragen)");
if (d.action === "incident") {
ok(d.incident.tenantId === "tenant-1", "Vorfall im richtigen Mandanten");
ok(d.incident.title === "Phishing gemeldet", "Betreff → Titel");
ok(d.incident.reporterContact === "alice@kunde.de", "Absender → Melder-Kontakt");
ok(d.spfWarning === true, "SPF-Fail als Warnung markiert (nicht blockierend)");
ok(d.incident.inboundMessageId === "m2@kunde.de", "Message-ID normalisiert übernommen");
}
}
// --- 3) Auto-Reply / Bounce → ignorieren --------------------------------------
{
ok(isAutoSubmitted({ "Auto-Submitted": "auto-replied" }), "Auto-Submitted erkannt");
ok(isAutoSubmitted({ "Precedence": "bulk" }), "Precedence: bulk erkannt");
ok(isAutoSubmitted({ "Return-Path": "<>" }), "Leerer Return-Path (Bounce) erkannt");
ok(!isAutoSubmitted({ "Auto-Submitted": "no" }), "Auto-Submitted: no ist normal");
const parsed = mail({
"Delivered-To": INTAKE,
"From": "mailer@kunde.de",
"Auto-Submitted": "auto-replied",
"Message-ID": "<auto1@kunde.de>",
"Authentication-Results": "mx; dkim=pass; spf=pass",
});
const d = decideRoute(parsed, { config, alreadySeen: false });
ok(d.action === "ignore" && d.reason === "auto_submitted", "Auto-Reply → ignore");
}
// --- 4) Dedupe per Message-ID → ignorieren ------------------------------------
{
const parsed = mail({
"Delivered-To": INTAKE,
"From": "alice@kunde.de",
"Message-ID": "<dup@kunde.de>",
"Authentication-Results": "mx; dkim=pass; spf=pass",
});
const d = decideRoute(parsed, { config, alreadySeen: true });
ok(d.action === "ignore" && d.reason === "duplicate", "bereits gesehene Message-ID → ignore");
}
// --- 5) Unbekannter Token → Review --------------------------------------------
{
const parsed = mail({
"Delivered-To": intakeAddress("unknowntoken99", DOMAIN),
"From": "alice@kunde.de",
"Message-ID": "<u1@kunde.de>",
"Authentication-Results": "mx; dkim=pass; spf=pass",
});
ok(parsed.token === "unknowntoken99", "Token geparst, aber unbekannt");
const d = decideRoute(parsed, { config: null, alreadySeen: false });
ok(d.action === "review" && d.review.reason === "unknown_token", "unbekannter Token → Review");
ok(d.action === "review" && d.review.tenantId === null, "kein Mandant bei unbekanntem Token");
}
// --- 6) Kein Token → Review ---------------------------------------------------
{
const parsed = mail({
"Delivered-To": "hallo@in.certvia.de", // kein vorfall-<token>
"From": "alice@kunde.de",
"Message-ID": "<n1@kunde.de>",
});
ok(parsed.token === null, "kein Token erkannt");
const d = decideRoute(parsed, { config: null, alreadySeen: false });
ok(d.action === "review" && d.review.reason === "no_token", "kein Token → Review");
}
// --- 7) Absender nicht in Allowlist → Review (fail-closed) --------------------
{
const parsed = mail({
"Delivered-To": INTAKE,
"From": "fremd@boese.example", // nicht in Allowlist (kunde.de)
"Message-ID": "<a1@boese.example>",
"Authentication-Results": "mx; dkim=pass; spf=pass",
});
const d = decideRoute(parsed, { config, alreadySeen: false });
ok(d.action === "review" && d.review.reason === "allowlist_failed", "Absender außerhalb Allowlist → Review");
ok(d.action === "review" && d.review.tenantId === "tenant-1", "Mandant trotzdem aufgelöst (Token bekannt)");
}
// --- 8) DKIM=fail → Review (auch bei getroffener Allowlist) -------------------
{
const parsed = mail({
"Delivered-To": INTAKE,
"From": "alice@kunde.de",
"Message-ID": "<dk1@kunde.de>",
"Authentication-Results": "mx; dkim=fail; spf=pass",
});
ok(authResults({ "Authentication-Results": "mx; dkim=fail" }).dkim === "fail", "DKIM=fail geparst");
const d = decideRoute(parsed, { config, alreadySeen: false });
ok(d.action === "review" && d.review.reason === "dkim_failed", "DKIM-Fail → Review");
}
// --- 9) Token-Generator liefert gültiges Format -------------------------------
{
const tok = generateIntakeToken();
ok(/^[a-z0-9]{8,64}$/.test(tok), "generateIntakeToken erfüllt Token-Format");
ok(tokenFromAddress(intakeAddress(tok, DOMAIN), DOMAIN) === tok, "Round-Trip Token↔Adresse");
}
console.log(failures === 0 ? "\nAlle Inbound-Tests grün." : `\n${failures} Test(s) fehlgeschlagen.`);
process.exit(failures === 0 ? 0 : 1);