Aufräumpunkt k (Spec §31): - Aufbewahrung: services/lotse/retention.ts leert input/output und createdById von AiGeneration-Einträgen älter als AI_GENERATION_RETENTION_DAYS (Default 180), Metadaten bleiben, Audit je Mandant. Queue/Processor ai-retention, täglicher BullMQ-Job-Scheduler beim Start des craftvia-worker. - Kontingent: services/lotse/budget.ts (Tokens ein+aus je Kalendermonat, TenantSettings-Wert vor Env AI_MONTHLY_TOKEN_LIMIT, 0 = unbegrenzt). Lotse-Entwurf und Sprachnotiz-Zusammenfassung → blocked budget_exceeded mit Klartext; Import-Extraktion fällt auf manuelle Erfassung zurück (Hinweis ai_budget_exceeded). /settings/lotse: Kontingent setzen, Verbrauch anzeigen. - scripts/test-betrieb-audit.ts: Audit nach Commit/Rollback/verschachtelt, Merge atomar und in äußerer Transaktion, Audit „read", Aufbewahrung (Frist, Metadaten, Idempotenz, Mandant B), Kontingent (Mandant/Env/Vormonat/unbegrenzt, Rollen, Audit). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
210 lines
15 KiB
TypeScript
210 lines
15 KiB
TypeScript
// Lane L10b „Betrieb & Aufräumen" — Transaktionen, Audit und Lotse-Betrieb:
|
|
// h) Audit-Einträge innerhalb von inTransaction erst nach dem Commit (Rollback → keine Einträge,
|
|
// außer „denied"; verschachtelt; aufgeschoben)
|
|
// d) mergeCustomers über inTransaction (atomar, in äußere Transaktion einbettbar)
|
|
// e) Audit-Aktion „read" für die Notdienst-Kundensuche
|
|
// k) KI-Protokoll: Aufbewahrungsfrist (Pseudonymisierung) + monatliches Token-Kontingent je Mandant
|
|
// Jeweils mit Mandantentrennung (B) und Rollen (Monteur → forbidden).
|
|
//
|
|
// Lauf: npx tsx scripts/test-betrieb-audit.ts (lokale Postgres-DB aus .env)
|
|
|
|
import "dotenv/config";
|
|
import { prisma, dbForTenant } from "../src/server/db";
|
|
import { writeAuditLog } from "../src/server/audit";
|
|
import { inTransaction } from "../src/server/services/context";
|
|
import { mergeCustomers } from "../src/server/services/customers/merge";
|
|
import { searchCustomersForEmergency } from "../src/server/services/emergency/lookup";
|
|
import { aiGenerationRetentionDays, purgeExpiredAiGenerations } from "../src/server/services/lotse/retention";
|
|
import { assertTokenBudget, getTokenBudget } from "../src/server/services/lotse/budget";
|
|
import { getLotseSettings, updateLotseSettings } from "../src/server/services/lotse/settings";
|
|
import { PROCESSORS } from "../src/server/jobs/processors";
|
|
import { ctxFor, expectCode, failures, ok } from "./lib/einsatz-fixture";
|
|
|
|
const SLUG_A = "zz-l10b-audit-a";
|
|
const SLUG_B = "zz-l10b-audit-b";
|
|
const DOMAIN = "@zz-l10b-audit.test";
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
|
|
async function cleanup() {
|
|
for (const slug of [SLUG_A, SLUG_B]) {
|
|
const tenant = await prisma.tenant.findUnique({ where: { slug }, select: { id: true } });
|
|
if (!tenant) continue;
|
|
const where = { tenantId: tenant.id };
|
|
await prisma.auditLog.deleteMany({ where });
|
|
await prisma.aiGeneration.deleteMany({ where });
|
|
await prisma.workOrder.deleteMany({ where });
|
|
await prisma.site.deleteMany({ where });
|
|
await prisma.contact.deleteMany({ where });
|
|
await prisma.customer.deleteMany({ where });
|
|
await prisma.tenantModule.deleteMany({ where });
|
|
await prisma.tenantSettings.deleteMany({ where });
|
|
await prisma.user.deleteMany({ where });
|
|
await prisma.tenant.delete({ where: { id: tenant.id } });
|
|
}
|
|
await prisma.identity.deleteMany({ where: { email: { endsWith: DOMAIN }, memberships: { none: {} } } });
|
|
}
|
|
|
|
async function user(tenantId: string, local: string) {
|
|
const email = `${local}${DOMAIN}`;
|
|
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
|
|
return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name: local } });
|
|
}
|
|
|
|
const auditCount = (tenantId: string, entity: string, entityId?: string, action?: string) =>
|
|
prisma.auditLog.count({ where: { tenantId, entity, ...(entityId ? { entityId } : {}), ...(action ? { action } : {}) } });
|
|
|
|
async function main() {
|
|
await cleanup();
|
|
const tenantA = await prisma.tenant.create({ data: { name: "L10b Audit A", slug: SLUG_A } });
|
|
const tenantB = await prisma.tenant.create({ data: { name: "L10b Audit B", slug: SLUG_B } });
|
|
const adminA = await user(tenantA.id, "admin-a");
|
|
const techA = await user(tenantA.id, "tech-a");
|
|
const adminB = await user(tenantB.id, "admin-b");
|
|
const ctxAdminA = ctxFor(tenantA.id, adminA.id, "tenant-admin");
|
|
const ctxTechA = ctxFor(tenantA.id, techA.id, "technician");
|
|
const ctxAdminB = ctxFor(tenantB.id, adminB.id, "tenant-admin");
|
|
await prisma.tenantSettings.create({ data: { tenantId: tenantA.id, orgName: "A" } });
|
|
await prisma.tenantSettings.create({ data: { tenantId: tenantB.id, orgName: "B" } });
|
|
|
|
const customer = (tenantId: string, companyName: string) => prisma.customer.create({ data: { tenantId, companyName, city: "Kiel" } });
|
|
const site = (tenantId: string, customerId: string) =>
|
|
prisma.site.create({ data: { tenantId, customerId, name: "Halle", street: "Weg", houseNumber: "1", postalCode: "24103", city: "Kiel" } });
|
|
|
|
console.log("\n— h) Audit nach Commit —");
|
|
const probe = await customer(tenantA.id, "Probe GmbH");
|
|
await inTransaction(ctxAdminA, async (tx) => {
|
|
await tx.db.customer.update({ where: { id: probe.id }, data: { city: "Lübeck" } });
|
|
await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "update", entity: "zz_l10b_tx", entityId: "commit" });
|
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "commit")) === 0, "innerhalb der Transaktion noch kein Audit-Eintrag (aufgeschoben)");
|
|
});
|
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "commit")) === 1, "nach Commit: Audit-Eintrag geschrieben");
|
|
|
|
await inTransaction(ctxAdminA, async (tx) => {
|
|
await tx.db.customer.update({ where: { id: probe.id }, data: { city: "Flensburg" } });
|
|
await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "update", entity: "zz_l10b_tx", entityId: "rollback" });
|
|
await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "denied", entity: "zz_l10b_tx", entityId: "rollback-denied" });
|
|
throw new Error("zz rollback");
|
|
}).catch(() => undefined);
|
|
ok((await prisma.customer.findUniqueOrThrow({ where: { id: probe.id } })).city === "Lübeck", "Rollback: Fachänderung verworfen");
|
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "rollback")) === 0, "Rollback: kein Audit-Eintrag für die verworfene Änderung");
|
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "rollback-denied", "denied")) === 1, "Rollback: „denied\"-Eintrag bleibt (Sicherheitsereignis)");
|
|
|
|
await inTransaction(ctxAdminA, async (outer) => {
|
|
await inTransaction(outer, async (inner) => {
|
|
await writeAuditLog({ tenantId: inner.tenantId, action: "update", entity: "zz_l10b_tx", entityId: "nested" });
|
|
});
|
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "nested")) === 0, "verschachtelt: innere Transaktion schreibt nicht vorzeitig");
|
|
throw new Error("zz outer rollback");
|
|
}).catch(() => undefined);
|
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "nested")) === 0, "verschachtelt: äußerer Rollback verwirft auch innere Audit-Einträge");
|
|
await writeAuditLog({ tenantId: tenantA.id, action: "update", entity: "zz_l10b_tx", entityId: "direct" });
|
|
ok((await auditCount(tenantA.id, "zz_l10b_tx", "direct")) === 1, "außerhalb einer Transaktion: sofort geschrieben");
|
|
|
|
console.log("\n— d) mergeCustomers atomar —");
|
|
const src = await customer(tenantA.id, "Quelle GmbH");
|
|
const tgt = await customer(tenantA.id, "Ziel GmbH");
|
|
const srcSite = await site(tenantA.id, src.id);
|
|
await expectCode(() => mergeCustomers(ctxTechA, { sourceId: src.id, targetId: tgt.id, confirm: true }), "forbidden", "Monteur darf nicht zusammenführen");
|
|
await expectCode(() => mergeCustomers(ctxAdminB, { sourceId: src.id, targetId: tgt.id, confirm: true }), "not_found", "Mandant B kann Kunden von A nicht zusammenführen");
|
|
await inTransaction(ctxAdminA, async (tx) => {
|
|
await mergeCustomers(tx, { sourceId: src.id, targetId: tgt.id, confirm: true });
|
|
throw new Error("zz merge rollback");
|
|
}).catch(() => undefined);
|
|
ok((await prisma.customer.findUniqueOrThrow({ where: { id: src.id } })).status !== "merged", "Merge in äußerer Transaktion + Rollback → Quelle nicht zusammengeführt");
|
|
ok((await prisma.site.findUniqueOrThrow({ where: { id: srcSite.id } })).customerId === src.id, "… und Objekt nicht umgehängt");
|
|
ok((await auditCount(tenantA.id, "customer", src.id)) === 0, "… und keine Merge-Audit-Einträge");
|
|
const merged = await mergeCustomers(ctxAdminA, { sourceId: src.id, targetId: tgt.id, confirm: true });
|
|
ok(merged.moved.sites === 1 && (await prisma.site.findUniqueOrThrow({ where: { id: srcSite.id } })).customerId === tgt.id, "Merge: Objekt umgehängt");
|
|
const srcAfter = await prisma.customer.findUniqueOrThrow({ where: { id: src.id } });
|
|
ok(srcAfter.status === "merged" && srcAfter.mergedIntoId === tgt.id, "Merge: Quelle merged + mergedIntoId");
|
|
ok((await auditCount(tenantA.id, "customer", src.id, "update")) === 1 && (await auditCount(tenantA.id, "customer", tgt.id, "update")) === 1, "Merge: Audit für Quelle und Ziel nach Commit");
|
|
await expectCode(() => mergeCustomers(ctxAdminA, { sourceId: src.id, targetId: tgt.id, confirm: true }), "conflict", "zweites Zusammenführen → conflict");
|
|
|
|
console.log("\n— e) Audit-Aktion „read\" —");
|
|
const hits = await searchCustomersForEmergency(ctxTechA, "Ziel");
|
|
ok(hits.some((h) => h.id === tgt.id), "Notdienst-Suche findet Kunden");
|
|
const searchAudit = await prisma.auditLog.findFirst({ where: { tenantId: tenantA.id, entity: "emergency_customer_search" }, orderBy: { createdAt: "desc" } });
|
|
ok(searchAudit?.action === "read" && searchAudit.actorId === techA.id, "Suchzugriff als Aktion „read\" protokolliert");
|
|
ok((await searchCustomersForEmergency(ctxFor(tenantB.id, adminB.id, "technician"), "Ziel")).length === 0, "Mandant B findet keine Kunden von A");
|
|
await expectCode(() => searchCustomersForEmergency(ctxFor(tenantA.id, adminA.id, "backoffice"), "Ziel"), "forbidden", "ohne emergency:create → forbidden");
|
|
|
|
console.log("\n— k) Aufbewahrung KI-Protokoll —");
|
|
const now = new Date();
|
|
const gen = (tenantId: string, createdAt: Date, tokens = 10, createdById: string | null = null) =>
|
|
prisma.aiGeneration.create({
|
|
data: { tenantId, kind: "report_draft", provider: "fake", model: "fake-1", input: { text: "Kunde ruft an" }, output: { workPerformed: "x" }, inputTokens: tokens, outputTokens: tokens, createdById, createdAt },
|
|
});
|
|
const oldA = await gen(tenantA.id, new Date(now.getTime() - 200 * DAY), 10, techA.id);
|
|
const newA = await gen(tenantA.id, new Date(now.getTime() - 10 * DAY), 10, techA.id);
|
|
const oldB = await gen(tenantB.id, new Date(now.getTime() - 200 * DAY), 10, adminB.id);
|
|
ok(aiGenerationRetentionDays() === 180, "Default-Aufbewahrung 180 Tage");
|
|
process.env.AI_GENERATION_RETENTION_DAYS = "30";
|
|
ok(aiGenerationRetentionDays() === 30, "AI_GENERATION_RETENTION_DAYS überschreibt den Default");
|
|
delete process.env.AI_GENERATION_RETENTION_DAYS;
|
|
ok(typeof PROCESSORS["ai-retention"] === "function", "Job ai-retention im Worker registriert");
|
|
|
|
const r1 = await purgeExpiredAiGenerations({ now, tenantIds: [tenantA.id] });
|
|
const oldAAfter = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: oldA.id } });
|
|
ok(r1.pseudonymised === 1 && oldAAfter.input === null && oldAAfter.output === null && oldAAfter.createdById === null, "abgelaufener Eintrag: Inhalte gelöscht, Personenbezug entfernt");
|
|
ok(oldAAfter.inputTokens === 10 && oldAAfter.model === "fake-1" && oldAAfter.kind === "report_draft", "Metadaten (Tokens, Modell, Art) bleiben");
|
|
const newAAfter = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: newA.id } });
|
|
ok(newAAfter.input !== null && newAAfter.createdById === techA.id, "junger Eintrag unverändert");
|
|
ok((await prisma.aiGeneration.findUniqueOrThrow({ where: { id: oldB.id } })).input !== null, "Lauf für Mandant A lässt Mandant B unberührt");
|
|
ok((await auditCount(tenantA.id, "ai_generation_retention", undefined, "delete")) === 1, "Aufbewahrungslauf auditiert");
|
|
ok((await purgeExpiredAiGenerations({ now, tenantIds: [tenantA.id] })).pseudonymised === 0, "zweiter Lauf idempotent");
|
|
ok((await purgeExpiredAiGenerations({ now, days: 365, tenantIds: [tenantB.id] })).pseudonymised === 0, "längere Frist → nichts gelöscht");
|
|
ok((await purgeExpiredAiGenerations({ now, tenantIds: [tenantB.id] })).pseudonymised === 1, "Mandant B eigener Lauf");
|
|
|
|
console.log("\n— k) Monatliches Token-Kontingent —");
|
|
await prisma.aiGeneration.deleteMany({ where: { tenantId: { in: [tenantA.id, tenantB.id] } } });
|
|
delete process.env.AI_MONTHLY_TOKEN_LIMIT;
|
|
await gen(tenantA.id, now, 600);
|
|
let budget = await getTokenBudget(ctxTechA, now);
|
|
ok(budget.limit === 0 && !budget.exceeded && budget.used === 1200, "ohne Limit: unbegrenzt, Verbrauch = Tokens ein+aus des Monats");
|
|
await expectCode(() => updateLotseSettings(ctxTechA, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 1000 }), "forbidden", "Monteur darf das Kontingent nicht setzen");
|
|
await updateLotseSettings(ctxAdminA, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 1000 });
|
|
budget = await getTokenBudget(ctxTechA, now);
|
|
ok(budget.limit === 1000 && budget.source === "tenant" && budget.exceeded, "Mandanten-Limit 1000 bei 1200 Verbrauch → aufgebraucht");
|
|
await expectCode(() => assertTokenBudget(ctxTechA, now), "blocked", "assertTokenBudget → blocked");
|
|
try {
|
|
await assertTokenBudget(ctxTechA, now);
|
|
} catch (err) {
|
|
ok((err as { details?: { reason?: string } }).details?.reason === "budget_exceeded", "… mit reason budget_exceeded");
|
|
}
|
|
const settingsAudit = await prisma.auditLog.findFirst({ where: { tenantId: tenantA.id, entity: "lotse_settings" }, orderBy: { createdAt: "desc" } });
|
|
ok((settingsAudit?.after as { monthlyTokenLimit?: number } | null)?.monthlyTokenLimit === 1000, "Limit-Änderung auditiert");
|
|
ok((await getLotseSettings(ctxAdminA)).budget.tenantLimit === 1000, "Einstellungsseite liefert Limit und Verbrauch");
|
|
ok(!(await getTokenBudget(ctxAdminB, now)).exceeded && (await getTokenBudget(ctxAdminB, now)).used === 0, "Mandant B: eigener Verbrauch, nicht betroffen");
|
|
ok((await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: tenantB.id } })).aiMonthlyTokenLimit === null, "Mandant B: Limit unverändert");
|
|
|
|
await gen(tenantB.id, new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) - DAY), 5000);
|
|
process.env.AI_MONTHLY_TOKEN_LIMIT = "50";
|
|
budget = await getTokenBudget(ctxAdminB, now);
|
|
ok(budget.limit === 50 && budget.source === "env" && budget.used === 0 && !budget.exceeded, "Env-Default greift; Verbrauch des Vormonats zählt nicht");
|
|
await gen(tenantB.id, now, 30);
|
|
ok((await getTokenBudget(ctxAdminB, now)).exceeded, "Env-Limit 50 bei 60 Tokens → aufgebraucht");
|
|
await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 0 });
|
|
ok(!(await getTokenBudget(ctxAdminB, now)).exceeded, "Mandanten-Limit 0 = unbegrenzt überschreibt Env");
|
|
await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral" });
|
|
ok((await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: tenantB.id } })).aiMonthlyTokenLimit === 0, "Speichern ohne Limit-Feld lässt das Limit unverändert");
|
|
await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral", monthlyTokenLimit: null });
|
|
ok((await getTokenBudget(ctxAdminB, now)).source === "env", "null → wieder Plattform-Vorgabe");
|
|
delete process.env.AI_MONTHLY_TOKEN_LIMIT;
|
|
|
|
// guard: tenant db cannot read the other tenant's usage
|
|
const crossUsage = await dbForTenant(tenantB.id).aiGeneration.count({ where: { tenantId: tenantA.id } }).catch(() => 0);
|
|
ok(crossUsage === 0, "Mandanten-Client von B sieht keine KI-Nutzung von A");
|
|
}
|
|
|
|
main()
|
|
.catch((err) => {
|
|
console.error(err);
|
|
ok(false, `unerwarteter Fehler: ${(err as Error).message}`);
|
|
})
|
|
.finally(async () => {
|
|
await cleanup().catch((e) => console.error("cleanup failed", e));
|
|
await prisma.$disconnect();
|
|
console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Audit-/Transaktions-/Lotse-Betriebsprüfungen grün");
|
|
process.exit(failures ? 1 : 0);
|
|
});
|