- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds, Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity, MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin) - Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email) - TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt - RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions - Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports, emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts - Modul-Routen mit requireModule-Layout und Platzhalterseite - Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader - check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/ - Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt - Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/ NotificationPreference-Fixtures umgestellt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
6.1 KiB
TypeScript
147 lines
6.1 KiB
TypeScript
import "dotenv/config";
|
|
import { prisma } from "../src/server/db";
|
|
import { enqueueMail } from "../src/server/mail/service";
|
|
import { renderTemplate, TEMPLATE_KEYS, formatWhen } from "../src/server/mail/templates";
|
|
import { getMailConfig, resetMailConfigCache } from "../src/server/mail/config";
|
|
import { closeQueues, isQueueEnabled, isQueueReady } from "../src/server/mail/queue";
|
|
import { closeMailProvider } from "../src/server/mail/provider-smtp";
|
|
|
|
/**
|
|
* SEC1 — Abnahmetest der Mail-Strecke (`npx tsx scripts/test-mail.ts`).
|
|
*
|
|
* Prüft ohne laufende App:
|
|
* 1. Alle Templates rendern in de und en, HTML **und** Text, mit Certvia-
|
|
* Branding und Dachmarken-Fußzeile.
|
|
* 2. Transaktionsmails tragen KEINEN Abmelde-Hinweis, Benachrichtigungen schon.
|
|
* 3. Ein echter Versand landet im lokalen SMTP (Mailhog/Mailpit) und das
|
|
* MailLog steht auf `sent` mit providerMessageId.
|
|
* 4. Idempotenz: derselbe dedupeKey erzeugt nur eine Mail.
|
|
* 5. Fehlende SMTP-Konfiguration führt zu `pending` + Begründung, nicht zu
|
|
* einem scheinbar erfolgreichen Versand.
|
|
*
|
|
* Voraussetzung für 3./4.: lokaler SMTP auf SMTP_HOST/SMTP_PORT
|
|
* (`docker compose up -d mailhog` → localhost:1025).
|
|
*/
|
|
|
|
let failures = 0;
|
|
function check(name: string, ok: boolean, detail?: string) {
|
|
if (ok) {
|
|
console.log(` ✓ ${name}`);
|
|
} else {
|
|
failures++;
|
|
console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`);
|
|
}
|
|
}
|
|
|
|
const SAMPLE = {
|
|
invitation: { name: "Erika Muster", tenantName: "Muster GmbH", actionUrl: "https://example.test/a", expires: "morgen" },
|
|
password_reset: { name: "Erika Muster", actionUrl: "https://example.test/r", expires: "in 60 Minuten" },
|
|
password_changed: { name: "Erika Muster", when: "heute", ip: "203.0.113.7" },
|
|
email_change_verify: { name: "Erika Muster", actionUrl: "https://example.test/v", expires: "in 60 Minuten", newEmail: "neu@example.test" },
|
|
email_changed_notice: { name: "Erika Muster", newEmail: "neu@example.test", when: "heute" },
|
|
mfa_changed: { name: "Erika Muster", change: "aktiviert", when: "heute" },
|
|
notification: { name: "Erika Muster", subject: "Neuer Auftrag", body: "Text", actionUrl: "https://example.test/t", eventType: "work_order_assigned" },
|
|
incident_notification: { name: "Erika Muster", subject: "Neuer Vorfall gemeldet", body: "Text", actionUrl: "https://example.test/i", refNo: "INC-2026-0042" },
|
|
test: { name: "Erika Muster", when: "heute" },
|
|
} as const;
|
|
|
|
async function main() {
|
|
console.log("1) Template-Rendering (de/en, HTML + Text)");
|
|
for (const key of TEMPLATE_KEYS) {
|
|
for (const locale of ["de", "en"] as const) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const r = renderTemplate(key, locale, SAMPLE[key] as any);
|
|
const ok =
|
|
r.subject.length > 0 &&
|
|
r.html.includes("<!doctype html>") &&
|
|
r.html.includes("Certvia") &&
|
|
r.html.includes("Ein Produkt von GEFIM") &&
|
|
r.text.length > 0 &&
|
|
!r.text.includes("<");
|
|
check(`${key}/${locale}`, ok, `subject="${r.subject}"`);
|
|
}
|
|
}
|
|
|
|
console.log("2) Abmelde-Hinweis nur bei Benachrichtigungen");
|
|
const notif = renderTemplate("notification", "de", SAMPLE.notification);
|
|
const reset = renderTemplate("password_reset", "de", SAMPLE.password_reset);
|
|
check("notification trägt Präferenz-Hinweis", notif.text.includes("Einstellungen dazu"));
|
|
check("password_reset trägt KEINEN Abmelde-Hinweis", !reset.text.includes("Einstellungen dazu"));
|
|
|
|
const { config } = getMailConfig();
|
|
if (!config) {
|
|
console.log("3-4) übersprungen — keine SMTP-Konfiguration gesetzt.");
|
|
} else {
|
|
const mode = isQueueEnabled() && isQueueReady() ? "Queue (BullMQ)" : "inline";
|
|
console.log(`3) Versand über ${config.host}:${config.port} — Modus: ${mode}`);
|
|
const key = `sec1-test:${process.pid}`;
|
|
const first = await enqueueMail({
|
|
template: "test",
|
|
to: "sec1-abnahme@example.test",
|
|
tenantId: null,
|
|
locale: "de",
|
|
dedupeKey: key,
|
|
vars: { name: "Abnahme", when: formatWhen(new Date(), "de") },
|
|
});
|
|
check("Versand erfolgreich", first.status === "sent" || first.status === "queued", first.status);
|
|
|
|
if ("mailLogId" in first) {
|
|
const row = await prisma.mailLog.findUnique({ where: { id: first.mailLogId } });
|
|
check("MailLog-Status", row?.status === "sent" || row?.status === "pending", row?.status);
|
|
check("scope=platform bei tenantId=null", row?.scope === "platform");
|
|
if (row?.status === "sent") {
|
|
check("providerMessageId gesetzt", Boolean(row.providerMessageId));
|
|
check("keine Klartext-Secrets im Log", !JSON.stringify(row).includes("password"));
|
|
}
|
|
}
|
|
|
|
console.log("4) Idempotenz");
|
|
const second = await enqueueMail({
|
|
template: "test",
|
|
to: "sec1-abnahme@example.test",
|
|
tenantId: null,
|
|
locale: "de",
|
|
dedupeKey: key,
|
|
vars: { name: "Abnahme", when: formatWhen(new Date(), "de") },
|
|
});
|
|
check("zweiter Aufruf mit gleichem dedupeKey → duplicate", second.status === "duplicate", second.status);
|
|
|
|
// Aufräumen
|
|
if ("mailLogId" in first) {
|
|
await prisma.mailLog.delete({ where: { id: first.mailLogId } }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
console.log("5) Fehlende SMTP-Konfiguration");
|
|
const saved = process.env.SMTP_HOST;
|
|
delete process.env.SMTP_HOST;
|
|
resetMailConfigCache();
|
|
const missing = getMailConfig();
|
|
check("Konfiguration wird als unvollständig erkannt", missing.config === null);
|
|
check("Begründung vorhanden", Boolean(missing.reason?.includes("SMTP_HOST")), missing.reason);
|
|
if (saved) process.env.SMTP_HOST = saved;
|
|
resetMailConfigCache();
|
|
|
|
await cleanup();
|
|
if (failures > 0) {
|
|
console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`);
|
|
process.exit(1);
|
|
}
|
|
console.log("\n✓ SEC1-Mailstrecke: alle Prüfungen bestanden.");
|
|
// Explizit beenden: offene Sockets (SMTP-Pool, Redis) halten sonst den
|
|
// Event-Loop offen, obwohl alle Prüfungen durch sind.
|
|
process.exit(0);
|
|
}
|
|
|
|
async function cleanup() {
|
|
await prisma.$disconnect();
|
|
await closeMailProvider();
|
|
await closeQueues();
|
|
}
|
|
|
|
main().catch(async (err) => {
|
|
console.error(err);
|
|
await cleanup();
|
|
process.exit(1);
|
|
});
|