/** * TASK-0133 — the ORG-SCOPED retention engine against a REAL Postgres * (Testcontainers). This is the spec's "retention engine (dry/execute/hold/ * isolation)" criterion. * * No MinIO: the purge core is prisma+redis only (S3 object deletion is the * files lifecycle policy's job, asserted in the files-lifecycle spec). We * call the worker's `runRetentionScope` / `runOrgRetention` directly (the * same functions the daily cron + the admin run-now job invoke) with a real * PrismaClient and a real Redis. * * The seed is direct-DB (state the API cannot create on demand — backdated * trashedAt/deletedAt/createdAt). Every delete is org-scoped: a SECOND org's * old rows must survive (org isolation). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { PrismaClient } from "@prisma/client"; import { Redis } from "ioredis"; import { getDatabaseUrl, getRedisUrl } from "./helpers/containers"; import { deploySchema } from "./helpers/api"; import { runRetentionScope, runOrgRetention, RETENTION_SCOPES } from "../../apps/worker/src/processors/retention.js"; let prisma: PrismaClient; let redis: Redis; const OLD = new Date(Date.now() - 120 * 86_400_000); // 120 days ago const RECENT = new Date(Date.now() - 1 * 3_600_000); // 1h ago describe("TASK-0133 retention engine (real Postgres)", () => { beforeAll(async () => { const [dbUrl, redisUrl] = await Promise.all([getDatabaseUrl(), getRedisUrl()]); await deploySchema(dbUrl); prisma = new PrismaClient({ datasources: { db: { url: dbUrl } } }); await prisma.$connect(); redis = new Redis(redisUrl, { maxRetriesPerRequest: null }); }, 180_000); afterAll(async () => { await redis?.quit().catch(() => {}); await prisma?.$disconnect().catch(() => {}); }); async function seedOrg(name: string) { const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const owner = await prisma.user.create({ data: { email: `ret-${name.toLowerCase()}-${suffix}@factory.local`, name: `Ret ${name}` } }); const org = await prisma.organization.create({ data: { name: `Ret ${name} ${suffix}`, slug: `ret-${suffix}`, ownerId: owner.id } }); const ws = await prisma.workspace.create({ data: { name: "ws", slug: `ret-ws-${suffix}`, organizationId: org.id, ownerId: owner.id } }); return { orgId: org.id, wsId: ws.id, ownerId: owner.id }; } async function seedTrash(wsId: string, trashedAt: Date) { const page = await prisma.page.create({ data: { workspaceId: wsId, position: `a${Math.random().toString(36).slice(2, 8)}`, title: "trashed", trashedAt, deletedAt: trashedAt } }); await prisma.block.create({ data: { workspaceId: wsId, pageId: page.id, position: "a0", type: "PARAGRAPH" } }); return page.id; } async function seedFile(wsId: string, deletedAt: Date | null, size: number, userId: string) { const key = `ret/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; return prisma.file.create({ data: { workspaceId: wsId, name: "f.bin", key, size, mimeType: "application/octet-stream", status: "READY", uploadedBy: userId, deletedAt } }); } async function seedComment(wsId: string, createdAt: Date, userId: string) { const c = await prisma.comment.create({ data: { workspaceId: wsId, body: "old", createdBy: userId, createdAt } }); return c.id; } async function auditCount(orgId: string, action: string): Promise { return prisma.auditLog.count({ where: { OR: [{ workspaceId: null, resourceType: "organization", resourceId: orgId, action }] } }); } it("trash: EXECUTE purges old trashed pages (+blocks), keeps recent; dry-run deletes nothing", async () => { const a = await seedOrg("A"); const oldPage = await seedTrash(a.wsId, OLD); const recentPage = await seedTrash(a.wsId, RECENT); // DRY first: counts, deletes nothing. const dry = await runRetentionScope({ prisma: prisma as never, redis, orgId: a.orgId, scope: "trash", mode: "dry", actor: { type: "SYSTEM", id: "worker:test" } }); expect(dry.status).toBe("dry_run"); expect((dry.counts as { pages: number }).pages).toBe(1); expect(await prisma.page.count({ where: { id: { in: [oldPage, recentPage] } } })).toBe(2); // EXECUTE: old gone (page + its block), recent kept. const exec = await runRetentionScope({ prisma: prisma as never, redis, orgId: a.orgId, scope: "trash", mode: "execute", actor: { type: "SYSTEM", id: "worker:test" } }); expect(exec.status).toBe("purged"); expect(await prisma.page.findUnique({ where: { id: oldPage } })).toBeNull(); expect(await prisma.block.count({ where: { pageId: oldPage } })).toBe(0); expect(await prisma.page.findUnique({ where: { id: recentPage } })).toBeTruthy(); // The purge was audited (org-scoped SYSTEM audit). expect(await auditCount(a.orgId, "retention.purged")).toBeGreaterThanOrEqual(1); }); it("trash: ORG isolation — another org's old trashed page survives", async () => { const a = await seedOrg("B"); const b = await seedOrg("C"); const bOld = await seedTrash(b.wsId, OLD); await runRetentionScope({ prisma: prisma as never, redis, orgId: a.orgId, scope: "trash", mode: "execute", actor: { type: "SYSTEM", id: "worker:test" } }); // b's old page is untouched by a's run (different org). expect(await prisma.page.findUnique({ where: { id: bOld } })).toBeTruthy(); }); it("audit_logs: HOLD (default) → NEVER purged, even in execute mode", async () => { const a = await seedOrg("D"); const n = await prisma.auditLog.createMany({ data: [0, 1, 2].map(() => ({ actorType: "USER", actorId: "u", workspaceId: a.wsId, resourceType: "page", resourceId: "p", action: "page.created", createdAt: OLD })) }); expect(n.count).toBe(3); const report = await runRetentionScope({ prisma: prisma as never, redis, orgId: a.orgId, scope: "audit_logs", mode: "execute", actor: { type: "SYSTEM", id: "worker:test" } }); expect(report.status).toBe("skipped"); expect(report.reason).toBe("hold"); // The 3 old rows remain (legal hold). expect(await prisma.auditLog.count({ where: { workspaceId: a.wsId, createdAt: OLD } })).toBe(3); // + a hold_skipped audit. expect(await auditCount(a.orgId, "retention.hold_skipped")).toBeGreaterThanOrEqual(1); }); it("audit_logs: PURGE 30d policy purges old rows (the org's own scope)", async () => { const a = await seedOrg("E"); const other = await seedOrg("F"); await prisma.retentionPolicy.create({ data: { organizationId: a.orgId, scope: "audit_logs", type: "PURGE", retainDays: 30 } }); await prisma.auditLog.createMany({ data: [ // a's old row (purge target) + a's recent row (kept). { actorType: "USER", actorId: "u", workspaceId: a.wsId, resourceType: "page", resourceId: "p", action: "page.created", createdAt: OLD }, { actorType: "USER", actorId: "u", workspaceId: a.wsId, resourceType: "page", resourceId: "p", action: "page.created", createdAt: RECENT }, // other's old row (org isolation — must survive). { actorType: "USER", actorId: "u", workspaceId: other.wsId, resourceType: "page", resourceId: "p", action: "page.created", createdAt: OLD } ] }); await runRetentionScope({ prisma: prisma as never, redis, orgId: a.orgId, scope: "audit_logs", mode: "execute", actor: { type: "SYSTEM", id: "worker:test" } }); // a: old gone, recent kept. expect(await prisma.auditLog.count({ where: { workspaceId: a.wsId, createdAt: OLD } })).toBe(0); expect(await prisma.auditLog.count({ where: { workspaceId: a.wsId, createdAt: RECENT } })).toBe(1); // other's old row survives (org isolation). expect(await prisma.auditLog.count({ where: { workspaceId: other.wsId, createdAt: OLD } })).toBe(1); }); it("files: PURGE 30d purges old deleted files + versions, bytesFreed = sum(sizes)", async () => { const a = await seedOrg("G"); await prisma.retentionPolicy.create({ data: { organizationId: a.orgId, scope: "files", type: "PURGE", retainDays: 30 } }); const oldFile = await seedFile(a.wsId, OLD, 1000, a.ownerId); await prisma.fileVersion.create({ data: { fileId: oldFile.id, version: 1, key: oldFile.key, size: 1000, createdBy: a.ownerId } }); await seedFile(a.wsId, RECENT, 500, a.ownerId); const report = await runRetentionScope({ prisma: prisma as never, redis, orgId: a.orgId, scope: "files", mode: "execute", actor: { type: "SYSTEM", id: "worker:test" } }); expect(report.status).toBe("purged"); const counts = report.counts as { files: number; bytesFreed: number }; expect(counts.files).toBe(1); expect(counts.bytesFreed).toBe(1000); expect(await prisma.file.findUnique({ where: { id: oldFile.id } })).toBeNull(); expect(await prisma.fileVersion.count({ where: { fileId: oldFile.id } })).toBe(0); // recent file kept. expect(await prisma.file.count({ where: { workspaceId: a.wsId, deletedAt: RECENT } })).toBe(1); }); it("comments: a thread with a RECENT reply survives; an old thread with no recent reply is purged", async () => { const a = await seedOrg("H"); await prisma.retentionPolicy.create({ data: { organizationId: a.orgId, scope: "comments", type: "PURGE", retainDays: 30 } }); const kept = await seedComment(a.wsId, OLD, a.ownerId); const purged = await seedComment(a.wsId, OLD, a.ownerId); // kept has a RECENT reply (survives); purged has none. await prisma.commentReply.create({ data: { commentId: kept, body: "recent reply", createdBy: a.ownerId, createdAt: RECENT } }); const report = await runRetentionScope({ prisma: prisma as never, redis, orgId: a.orgId, scope: "comments", mode: "execute", actor: { type: "SYSTEM", id: "worker:test" } }); expect(report.status).toBe("purged"); expect(await prisma.comment.findUnique({ where: { id: purged } })).toBeNull(); expect(await prisma.comment.findUnique({ where: { id: kept } })).toBeTruthy(); }); it("runOrgRetention: all 4 scopes run, per-scope isolation (a throw in one does not stop the others)", async () => { const a = await seedOrg("I"); // Default policies: trash PURGE 30, comments PURGE 90, audit HOLD, files PURGE 0. // Seed an old trashed page (purged) + an old file (no-purge skip). await seedTrash(a.wsId, OLD); await seedFile(a.wsId, OLD, 10, a.ownerId); const out = await runOrgRetention({ prisma: prisma as never, redis, orgId: a.orgId, mode: "execute", actor: { type: "SYSTEM", id: "worker:test" } }); expect(Object.keys(out).sort()).toEqual([...RETENTION_SCOPES].sort()); expect((out["trash"] as { status: string }).status).toBe("purged"); expect((out["comments"] as { status: string }).status).toBe("purged"); expect((out["audit_logs"] as { status: string }).status).toBe("skipped"); // HOLD expect((out["files"] as { status: string }).status).toBe("skipped"); // no_purge }); it("writes the admin run report to Redis (worker:retention:report:{runId})", async () => { const a = await seedOrg("J"); const runId = `run-${Date.now()}`; await runRetentionScope({ prisma: prisma as never, redis, orgId: a.orgId, scope: "trash", mode: "dry", actor: { type: "USER", id: "admin" }, runId }); const raw = await redis.get(`worker:retention:report:${runId}`); expect(raw).toBeTruthy(); const report = JSON.parse(raw!) as { scope: string; mode: string; status: string }; expect(report.scope).toBe("trash"); expect(report.mode).toBe("dry"); expect(report.status).toBe("dry_run"); await redis.del(`worker:retention:report:${runId}`); }); });