From ee27af7cd46a292cedec6b080b6c5b7c5887cd08 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:21:34 +0200 Subject: [PATCH] Test: Audit-Request-Kontext und Mail-Absender je Mandant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 Prüfungen: IP/User-Agent null außerhalb eines Requests, Anzeigename und Reply-To des Mandanten, Plattform-Adresse bleibt, Header-Injection bereinigt, Rückfall auf Plattform-Defaults für Plattform-Mails. Co-Authored-By: Claude Opus 5 --- scripts/test-audit-mail-context.ts | 75 ++++++++++++++++++++++++++++++ src/server/mail/deliver.ts | 2 +- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 scripts/test-audit-mail-context.ts diff --git a/scripts/test-audit-mail-context.ts b/scripts/test-audit-mail-context.ts new file mode 100644 index 0000000..e2d2fa5 --- /dev/null +++ b/scripts/test-audit-mail-context.ts @@ -0,0 +1,75 @@ +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { writeAuditLog } from "../src/server/audit"; +import { tenantSender } from "../src/server/mail/deliver"; +import type { MailConfig } from "../src/server/mail/config"; + +/** + * Foundation fixes: + * (a) writeAuditLog outside a request scope stores ip/userAgent = null (no crash) + * (b) mail sender uses the tenant display name + reply-to, keeps the platform address, + * strips header-injection characters, and falls back for platform mails. + */ + +let failures = 0; +function check(name: string, cond: boolean, detail?: unknown) { + if (cond) console.log(` ✓ ${name}`); + else { + failures++; + console.log(` ✗ ${name}`, detail ?? ""); + } +} + +const config: MailConfig = { + host: "localhost", + port: 1025, + secure: false, + from: "no-reply@craftvia.test", + fromName: "Craftvia", + replyTo: "support@craftvia.test", + baseUrl: "http://localhost:3000", +}; + +async function main() { + const slug = `zz-amc-${Date.now()}`; + const tenant = await prisma.tenant.create({ data: { name: "ZZ Audit Mail", slug } }); + try { + // (a) audit outside request + await writeAuditLog({ tenantId: tenant.id, action: "update", entity: "zz_test", entityId: "x1" }); + const row = await prisma.auditLog.findFirst({ where: { tenantId: tenant.id, entity: "zz_test" } }); + check("audit row written outside request", !!row); + check("ipAddress null outside request", row?.ipAddress === null, row?.ipAddress); + check("userAgent null outside request", row?.userAgent === null, row?.userAgent); + + // (b) tenant sender + await prisma.tenantSettings.create({ + data: { tenantId: tenant.id, orgName: "ZZ", mailFromName: 'Musterbau "Evil"\r\nBcc: x@y.z ', mailReplyTo: "buero@musterbau.test" }, + }); + const log = await prisma.mailLog.create({ data: { tenantId: tenant.id, to: "a@b.test", template: "test" } }); + const s = await tenantSender(log.id, config); + check("platform address kept", s.from.endsWith(""), s.from); + check("tenant display name used", s.from.startsWith("Musterbau Evil"), s.from); + check("no CR/LF or quotes in From", !/[\r\n"]/.test(s.from), JSON.stringify(s.from)); + check("only one angle-bracket address", (s.from.match(/", p.from); + check("platform mail falls back to default reply-to", p.replyTo === "support@craftvia.test", p.replyTo); + await prisma.mailLog.delete({ where: { id: platformLog.id } }); + } finally { + await prisma.mailLog.deleteMany({ where: { tenantId: tenant.id } }); + await prisma.auditLog.deleteMany({ where: { tenantId: tenant.id } }); + await prisma.tenantSettings.deleteMany({ where: { tenantId: tenant.id } }); + await prisma.tenant.delete({ where: { id: tenant.id } }); + await prisma.$disconnect(); + } + console.log(failures ? `\n${failures} Fehler` : "\nOK"); + process.exit(failures ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/server/mail/deliver.ts b/src/server/mail/deliver.ts index 60b4e4e..77241e7 100644 --- a/src/server/mail/deliver.ts +++ b/src/server/mail/deliver.ts @@ -96,7 +96,7 @@ export async function markMailFailed(mailLogId: string, error: string): Promise< * the platform address (SPF/DKIM alignment); only the display name and Reply-To vary. * Raw client on purpose: the worker has no tenant context; the MailLog row carries the tenant. */ -async function tenantSender(mailLogId: string, config: MailConfig): Promise<{ from: string; replyTo?: string }> { +export async function tenantSender(mailLogId: string, config: MailConfig): Promise<{ from: string; replyTo?: string }> { const log = await prisma.mailLog.findUnique({ where: { id: mailLogId }, select: { tenantId: true } }); if (!log?.tenantId) return { from: mailFrom(config), replyTo: config.replyTo }; const settings = await prisma.tenantSettings.findUnique({