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>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
// 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);
|
||||
Reference in New Issue
Block a user