L6 Benachrichtigungen & Audit: Audit-Viewer mit Filter und Diff
- /settings/audit (audit:read): Filter Zeitraum, Benutzer, Aktion, Objektart, Objekt-ID; Pagination; Detail-Popup mit before/after-Diff, Ergebnis, IP/User-Agent (derzeit nicht erfasst, Fundament-Bedarf). - Service services/audit/viewer.ts, strikt mandantengebunden über ctx.db. - Audit-Entity-Labels für alle Craftvia-Entitäten. - Test scripts/test-benachrichtigungen-inbox-audit.ts (46 Prüfungen): Posteingang, Mandantentrennung, Rollen/Scope, Mailkonfiguration, Audit-Viewer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
// L6 Benachrichtigungen & Audit — Posteingang (nur eigene, Mandantentrennung, Rollen),
|
||||
// Einstellungen je Nutzer, Mandanten-Mailkonfiguration (tenant:manage, Validierung),
|
||||
// Audit-Viewer (nur mit audit:read, mandantengebunden, Filter, Diff).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-benachrichtigungen-inbox-audit.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { ZodError } from "zod";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
import { provisionTenant } from "../src/server/provision";
|
||||
import { ROLE_DEFS, type RoleKey } from "../src/server/rbac";
|
||||
import { ServiceError, type ServiceCtx } from "../src/server/services/context";
|
||||
import { bellSummary, listNotifications, markAllRead, markRead, safeLink } from "../src/server/services/notifications/inbox";
|
||||
import { getPreferences } from "../src/server/services/notifications/preferences";
|
||||
import { getMailSettings, splitAddressList, updateMailSettings } from "../src/server/services/notifications/mail-settings";
|
||||
import { diffAudit, getAuditEntry, queryAuditLog } from "../src/server/services/audit/viewer";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
async function expectCode(fn: () => Promise<unknown>, code: ServiceError["code"] | "zod", msg: string) {
|
||||
try {
|
||||
await fn();
|
||||
ok(false, `${msg} — kein Fehler`);
|
||||
} catch (err) {
|
||||
const actual = err instanceof ServiceError ? err.code : err instanceof ZodError ? "zod" : (err as Error).message;
|
||||
ok(actual === code, `${msg}${actual === code ? "" : ` — erhalten: ${actual}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
const SLUG_A = "zz-l6-inbox-a";
|
||||
const SLUG_B = "zz-l6-inbox-b";
|
||||
const MAIL_DOMAIN = "zz-l6-inbox.test";
|
||||
|
||||
async function cleanup() {
|
||||
const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } });
|
||||
const ids = tenants.map((t) => t.id);
|
||||
if (ids.length) {
|
||||
const where = { tenantId: { in: ids } };
|
||||
await prisma.notification.deleteMany({ where });
|
||||
await prisma.notificationPreference.deleteMany({ where });
|
||||
await prisma.mailLog.deleteMany({ where });
|
||||
await prisma.auditLog.deleteMany({ where });
|
||||
await prisma.tenantModule.deleteMany({ where });
|
||||
await prisma.tenantSettings.deleteMany({ where });
|
||||
await prisma.user.deleteMany({ where });
|
||||
await prisma.role.deleteMany({ where });
|
||||
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
await prisma.identity.deleteMany({ where: { email: { endsWith: `@${MAIL_DOMAIN}` }, memberships: { none: {} } } });
|
||||
}
|
||||
|
||||
async function createUser(tenantId: string, local: string, name: string, role: RoleKey) {
|
||||
const email = `${local}@${MAIL_DOMAIN}`;
|
||||
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
|
||||
const roleRow = await prisma.role.findUniqueOrThrow({ where: { tenantId_key: { tenantId, key: role } } });
|
||||
return prisma.user.create({
|
||||
data: { tenantId, identityId: identity.id, email, name, status: "ACTIVE", userRoles: { create: [{ roleId: roleRow.id }] } },
|
||||
});
|
||||
}
|
||||
|
||||
const ctxFor = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({
|
||||
db: dbForTenant(tenantId),
|
||||
tenantId,
|
||||
userId,
|
||||
permissions: new Set(ROLE_DEFS[role].permissions),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
await cleanup();
|
||||
|
||||
const tA = await provisionTenant(prisma, { name: "L6 Inbox A", slug: SLUG_A, admin: { email: `admin-a@${MAIL_DOMAIN}`, name: "Admin A", password: "Zz-Test-1234!" } });
|
||||
const tB = await provisionTenant(prisma, { name: "L6 Inbox B", slug: SLUG_B, admin: { email: `admin-b@${MAIL_DOMAIN}`, name: "Admin B", password: "Zz-Test-1234!" } });
|
||||
const adminA = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: tA.id, email: `admin-a@${MAIL_DOMAIN}` } } });
|
||||
const adminB = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: tB.id, email: `admin-b@${MAIL_DOMAIN}` } } });
|
||||
const backoffice = await createUser(tA.id, "backoffice-a", "Bea Backoffice", "backoffice");
|
||||
const tech1 = await createUser(tA.id, "tech1-a", "Max Monteur", "technician");
|
||||
const tech2 = await createUser(tA.id, "tech2-a", "Ole Ohneteam", "technician");
|
||||
|
||||
const adminACtx = ctxFor(tA.id, adminA.id, "tenant-admin");
|
||||
const adminBCtx = ctxFor(tB.id, adminB.id, "tenant-admin");
|
||||
const boCtx = ctxFor(tA.id, backoffice.id, "backoffice");
|
||||
const tech1Ctx = ctxFor(tA.id, tech1.id, "technician");
|
||||
const tech2Ctx = ctxFor(tA.id, tech2.id, "technician");
|
||||
|
||||
const mk = (tenantId: string, userId: string, type: string, title: string, readAt: Date | null = null) =>
|
||||
prisma.notification.create({ data: { tenantId, userId, type, title, message: "Text", entityType: "work_order", entityId: "wo-zz", link: "/m/orders/wo-zz", readAt } });
|
||||
const n1 = await mk(tA.id, tech1.id, "work_order.assigned", "Neuer Auftrag A-1");
|
||||
await mk(tA.id, tech1.id, "work_order.changed", "Auftrag A-1 geändert");
|
||||
await mk(tA.id, tech1.id, "work_order.cancelled", "Auftrag A-2 storniert", new Date());
|
||||
const nB = await mk(tB.id, adminB.id, "work_order.assigned", "Fremd B-1");
|
||||
|
||||
// ---------- 1) Posteingang: nur eigene Benachrichtigungen ----------
|
||||
const list1 = await listNotifications(tech1Ctx, {});
|
||||
ok(list1.total === 3, "(1) Monteur sieht seine 3 Benachrichtigungen");
|
||||
ok((await listNotifications(tech1Ctx, { status: "unread" })).total === 2, "(1) Filter ungelesen");
|
||||
ok((await listNotifications(tech1Ctx, { status: "read" })).total === 1, "(1) Filter gelesen");
|
||||
ok((await listNotifications(tech1Ctx, { type: "work_order.changed" })).total === 1, "(1) Filter Typ");
|
||||
ok((await listNotifications(tech1Ctx, { type: "no.such_type", status: "bogus" })).total === 3, "(1) ungültige Filterwerte fallen auf Standard zurück");
|
||||
ok((await listNotifications(tech2Ctx, {})).total === 0, "(1) anderer Monteur sieht keine fremden Benachrichtigungen");
|
||||
ok((await listNotifications(adminACtx, {})).total === 0, "(1) auch der Mandantenadmin sieht keine fremden Benachrichtigungen");
|
||||
const bell = await bellSummary(tech1Ctx);
|
||||
ok(bell.unread === 2 && bell.latest.length === 3, "(1) Glocke: Zähler ungelesen + letzte Einträge");
|
||||
|
||||
// ---------- 2) Rollen/Scope: fremde Benachrichtigung → not_found ----------
|
||||
await expectCode(() => markRead(tech2Ctx, n1.id), "not_found", "(2) Monteur ohne Zuordnung kann fremde Benachrichtigung nicht als gelesen markieren");
|
||||
await expectCode(() => markRead(adminBCtx, n1.id), "not_found", "(2) Mandant B kann Benachrichtigung von A nicht ändern");
|
||||
await expectCode(() => markRead(tech1Ctx, nB.id), "not_found", "(2) Mandant A kann Benachrichtigung von B nicht ändern");
|
||||
ok((await prisma.notification.findUniqueOrThrow({ where: { id: n1.id } })).readAt === null, "(2) fremder Zugriff hat nichts verändert");
|
||||
await expectCode(() => listNotifications({ ...tech1Ctx, permissions: new Set() }, {}), "forbidden", "(2) ohne notification:read → forbidden");
|
||||
|
||||
// ---------- 3) Mandantentrennung markAllRead ----------
|
||||
await markAllRead(adminBCtx);
|
||||
ok((await prisma.notification.count({ where: { tenantId: tA.id, readAt: null } })) === 2, "(3) markAllRead in B lässt A unberührt");
|
||||
ok((await prisma.notification.findUniqueOrThrow({ where: { id: nB.id } })).readAt !== null, "(3) markAllRead in B markiert eigene");
|
||||
|
||||
// ---------- 4) markRead eigene + Audit + sicherer Link ----------
|
||||
const res = await markRead(tech1Ctx, n1.id);
|
||||
ok(res.link === "/m/orders/wo-zz", "(4) markRead liefert relativen Link");
|
||||
ok((await prisma.notification.findUniqueOrThrow({ where: { id: n1.id } })).readAt !== null, "(4) markRead setzt readAt");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "notification", entityId: n1.id, actorId: tech1.id } })) === 1, "(4) markRead schreibt Audit-Eintrag");
|
||||
ok(safeLink("https://evil.test") === null && safeLink("//evil.test") === null && safeLink("/\\evil") === null && safeLink("/work-orders/1") === "/work-orders/1",
|
||||
"(4) safeLink lässt nur relative Pfade zu (kein Open Redirect)");
|
||||
|
||||
// ---------- 5) Einstellungen je Nutzer ----------
|
||||
const prefs = await getPreferences(tech1Ctx);
|
||||
ok(prefs.every((p) => p.email) && prefs.filter((p) => p.mandatory).map((p) => p.type).sort().join() === "emergency.completed,emergency.created",
|
||||
"(5) Standard: alles an, Notdienst als Pflicht markiert");
|
||||
|
||||
// ---------- 6) Mandanten-Mailkonfiguration ----------
|
||||
await expectCode(() => updateMailSettings(tech1Ctx, { mailFromName: "X", mailReplyTo: "", emergencyRecipients: "", billingRecipients: "" }), "forbidden",
|
||||
"(6) Monteur darf Mailkonfiguration nicht ändern");
|
||||
await expectCode(() => updateMailSettings(boCtx, { mailFromName: "X", mailReplyTo: "", emergencyRecipients: "", billingRecipients: "" }), "forbidden",
|
||||
"(6) Backoffice ohne tenant:manage darf Mailkonfiguration nicht ändern");
|
||||
await expectCode(() => getMailSettings(tech1Ctx), "forbidden", "(6) Monteur darf Mailkonfiguration nicht lesen");
|
||||
ok(splitAddressList("A@x.de, b@x.de;\nA@x.de c@x.de").join() === "a@x.de,b@x.de,c@x.de", "(6) Adressliste: trennt, normalisiert, dedupliziert");
|
||||
const saved = await updateMailSettings(adminACtx, {
|
||||
mailFromName: "Musterbau Büro",
|
||||
mailReplyTo: "Buero@Musterbau.test",
|
||||
emergencyRecipients: "notdienst@musterbau.test\nchef@musterbau.test",
|
||||
billingRecipients: "abrechnung@musterbau.test",
|
||||
});
|
||||
ok(saved.mailReplyTo === "buero@musterbau.test" && saved.emergencyRecipients.length === 2, "(6) Admin speichert Mailkonfiguration");
|
||||
const rowA = await prisma.tenantSettings.findUniqueOrThrow({ where: { tenantId: tA.id } });
|
||||
ok(rowA.mailFromName === "Musterbau Büro" && rowA.billingRecipients.join() === "abrechnung@musterbau.test", "(6) Werte in tenant_settings persistiert");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "tenant_mail_settings", actorId: adminA.id } })) === 1, "(6) Audit-Eintrag mit before/after");
|
||||
const sB = await getMailSettings(adminBCtx);
|
||||
ok(sB.emergencyRecipients.length === 0 && sB.mailFromName === null, "(6) Mandant B sieht die Mailkonfiguration von A nicht");
|
||||
await expectCode(() => updateMailSettings(adminACtx, { mailFromName: "Evil\r\nBcc: x@y.z", mailReplyTo: "", emergencyRecipients: "", billingRecipients: "" }), "zod",
|
||||
"(6) Absendername mit Zeilenumbruch abgelehnt (Header-Injection)");
|
||||
await expectCode(() => updateMailSettings(adminACtx, { mailFromName: "", mailReplyTo: "", emergencyRecipients: "kein-mail", billingRecipients: "" }), "zod",
|
||||
"(6) ungültige Empfängeradresse abgelehnt");
|
||||
await expectCode(() => updateMailSettings(adminACtx, { mailFromName: "", mailReplyTo: "", emergencyRecipients: Array.from({ length: 21 }, (_, i) => `n${i}@x.de`).join("\n"), billingRecipients: "" }), "zod",
|
||||
"(6) mehr als 20 Empfänger abgelehnt");
|
||||
|
||||
// ---------- 7) Audit-Viewer ----------
|
||||
await prisma.auditLog.create({ data: { tenantId: tB.id, actorId: adminB.id, action: "update", entity: "zz_marker", entityId: "b-secret", before: { a: 1 }, after: { a: 2 } } });
|
||||
const markerA = await prisma.auditLog.create({
|
||||
data: { tenantId: tA.id, actorId: backoffice.id, action: "update", entity: "work_order", entityId: "wo-zz-audit", before: { status: "planned", title: "Alt" }, after: { status: "assigned", title: "Alt", team: "Nord" } },
|
||||
});
|
||||
await expectCode(() => queryAuditLog(tech1Ctx, {}), "forbidden", "(7) Monteur ohne audit:read → forbidden");
|
||||
await expectCode(() => queryAuditLog(ctxFor(tA.id, tech2.id, "team-lead"), {}), "forbidden", "(7) Teamleiter ohne audit:read → forbidden");
|
||||
await expectCode(() => getAuditEntry(tech1Ctx, markerA.id), "forbidden", "(7) Detail ohne audit:read → forbidden");
|
||||
const all = await queryAuditLog(boCtx, {});
|
||||
ok(all.total > 0, "(7) Backoffice mit audit:read sieht Einträge");
|
||||
const allIds = (await queryAuditLog(boCtx, { page: 1 })).rows.map((r) => r.id);
|
||||
const foreign = await prisma.auditLog.count({ where: { id: { in: allIds }, NOT: { tenantId: tA.id } } });
|
||||
ok(foreign === 0, "(7) Audit-Viewer liefert ausschließlich Einträge des eigenen Mandanten");
|
||||
ok((await queryAuditLog(boCtx, { entity: "zz_marker" })).total === 0, "(7) Eintrag von Mandant B ist in A nicht auffindbar");
|
||||
const bEntry = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: tB.id, entity: "zz_marker" } });
|
||||
await expectCode(() => getAuditEntry(boCtx, bEntry.id), "not_found", "(7) Detail eines B-Eintrags aus A → not_found");
|
||||
ok((await queryAuditLog(boCtx, { entity: "work_order", entityId: "wo-zz" })).total === 1, "(7) Filter Objektart + Objekt-ID");
|
||||
ok((await queryAuditLog(boCtx, { actorId: backoffice.id, action: "update" })).rows.every((r) => r.actorId === backoffice.id && r.action === "update"), "(7) Filter Benutzer + Aktion");
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
ok((await queryAuditLog(boCtx, { from: today, to: today, entity: "work_order" })).total === 1, "(7) Filter Zeitraum (heute) findet Eintrag");
|
||||
ok((await queryAuditLog(boCtx, { from: "2000-01-01", to: "2000-01-02" })).total === 0, "(7) Filter Zeitraum (Vergangenheit) leer");
|
||||
ok(all.facets.entities.includes("work_order") && !all.facets.entities.includes("zz_marker"), "(7) Objektart-Auswahl nur aus eigenem Mandanten");
|
||||
const detail = await getAuditEntry(boCtx, markerA.id);
|
||||
const byKey = Object.fromEntries(detail.diff.map((d) => [d.key, d]));
|
||||
ok(byKey.status?.changed === true && byKey.title?.changed === false && byKey.team?.before === null && byKey.team?.after === "Nord", "(7) Detail: before/after-Diff korrekt");
|
||||
ok(detail.actorName === "Bea Backoffice" && detail.ip === null && detail.userAgent === null, "(7) Detail: Akteurname, IP/User-Agent nicht erfasst");
|
||||
ok(diffAudit(undefined, undefined).length === 0 && diffAudit(null, { x: [1] })[0]?.after === "[1]", "(7) diffAudit: leere und verschachtelte Werte");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
failures++;
|
||||
})
|
||||
.finally(async () => {
|
||||
await cleanup().catch((e) => console.error("cleanup:", e));
|
||||
await prisma.$disconnect();
|
||||
console.log(failures ? `\n${failures} Fehler.` : "\nAlle Prüfungen bestanden.");
|
||||
process.exit(failures ? 1 : 0);
|
||||
});
|
||||
Reference in New Issue
Block a user