Test: Audit-Request-Kontext und Mail-Absender je Mandant

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:21:34 +02:00
co-authored by Claude Opus 5
parent b8b8bddefe
commit ee27af7cd4
2 changed files with 76 additions and 1 deletions
+75
View File
@@ -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 <GmbH>', 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("<no-reply@craftvia.test>"), 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(/</g) ?? []).length === 1, s.from);
check("tenant reply-to used", s.replyTo === "buero@musterbau.test", s.replyTo);
const platformLog = await prisma.mailLog.create({ data: { tenantId: null, scope: "platform", to: "a@b.test", template: "test" } });
const p = await tenantSender(platformLog.id, config);
check("platform mail falls back to default name", p.from === "Craftvia <no-reply@craftvia.test>", 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);
});