/** * AI Software Factory — Integration: Files LIFECYCLE (Phase 9, TASK-0104) * * The file-management contract against the REAL stack: NestJS API * (Testcontainers Postgres/Redis + the host MinIO) + the worker's files * pipeline + search-index processor run IN-PROCESS against the container * DB + the host MinIO + (gated) the host OpenSearch. * * Why in-process? The API (test app) runs on ephemeral Testcontainers * Postgres/Redis on a RANDOM mapped port, while the host worker (:4003) reads * `localhost:5432` — a DIFFERENT database. So the host worker never sees the * test app's `files` queue jobs / `factory:events`. The repo's own TASK-0102 * pipeline spec establishes the pattern: drive the worker processors * in-process (runFilesPipeline / handleSearchEvent) against the container * DB. This spec follows it. * * Per docs/tasks/phase-09-files.md TASK-0104 §Acceptance, each block is an * acceptance criterion: * * 1. versions — re-upload → FileVersion v2 + both keys in the bucket + * File re-points to v2; rollback → v1's key; GET /versions * lists both (bare array, NO raw `key`). * 2. audit — non-member download → 403 + `file.download_denied` * (actorId + ip); member download → 302 + `file.downloaded`. * 3. permissions — explicit NONE on a file → 403 (beats inherited EDIT); * VIEW grant to a non-member → 302 (grant grants). * 4. trash — delete → deletedAt set + absent from GET /files + search * doc removed + `file.deleted`; restore → back + re-indexed; * purge → objects + rows + thumbs gone + audit in order. * 5. quota — 1 GB quota, 950 MB used → 60 MB session 413, 40 MB 201 + * column = 990 MB (assert the COLUMN, not a recompute). * 6. purge/orphan — purging a 2-version file removes exactly its object * keys + thumb; `runOrphanSweep({fileId})` reports 0. * 7. re-index — add a grant → `GET /permissions` shows it + audit * `file.permission_changed` + `permission.changed` published * + the doc re-indexes cleanly. (CONFLICT recorded: file- * level grants do NOT narrow search visibility — search is * workspace/page-scoped by design; file-level access is * enforced at read time. See the task file.) * 8. rename — PATCH changes only the name (size/mime/metadata * unchanged) + `file.updated` published + doc title * re-indexed. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import path from "node:path"; import os from "node:os"; import { mkdtemp, readFile } from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; import { S3Client as StorageS3Client, type S3Config as StorageS3Config } from "@factory/storage"; import { SearchClient } from "@factory/search"; import { PrismaClient } from "@prisma/client"; import { Redis } from "ioredis"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { getDatabaseUrl, getRedisUrl } from "./helpers/containers"; import { authHeaders, createUser, createWorkspace, addMemberViaSeam } from "./helpers/seed"; const HERE = path.dirname(fileURLToPath(import.meta.url)); // Import the worker's files pipeline + search-index processor + storage client // from SOURCE (the worker ships no build for the monorepo test path — the same // convention helpers/api.ts + the TASK-0102 pipeline spec use). const { runFilesPipeline } = await import( /* webpackIgnore: true */ fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "files", "processor.ts")) ); const { S3Client: WorkerS3Client } = await import( /* webpackIgnore: true */ fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "lib", "s3.ts")) ); const { handleSearchEvent, runReindex } = await import( /* webpackIgnore: true */ fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "search-index", "processor.ts")) ); const { runOrphanSweep } = await import( /* webpackIgnore: true */ fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "files", "cleanup.ts")) ); function fileUrl(p: string): string { return `file://${p.startsWith("/") ? p : `/${p}`}`; } // ── host gates ────────────────────────────────────────────────────────────── const S3_ENDPOINT = (process.env.S3_ENDPOINT ?? "http://localhost:9000").replace(/\/+$/, ""); const SEARCH_URL = process.env.SEARCH_URL ?? "http://localhost:9200"; const S3_ACCESS = process.env.S3_ACCESS_KEY ?? "minioadmin"; const S3_SECRET = process.env.S3_SECRET_KEY ?? "minioadmin"; const FILES_BUCKET = process.env.S3_BUCKET_FILES ?? "factory-files"; const THUMBS_BUCKET = process.env.S3_BUCKET_THUMBS ?? "factory-thumbs"; async function httpUp(url: string): Promise { try { const r = await fetch(url, { signal: AbortSignal.timeout(3_000) }); return r.ok; } catch { return false; } } const minioReady = await httpUp(`${S3_ENDPOINT}/minio/health/live`); const searchReady = await httpUp(`${SEARCH_URL}/_cluster/health`); // ── fixtures ──────────────────────────────────────────────────────────────── /** 1×1 red pixel, 67 bytes — a REAL png signature (magic bytes verify). */ const PNG_BYTES = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, ]); /** The real hero PNG fixture (1400×900, a VALID png the pipeline re-sniffs + * thumbnails cleanly) — used as the v2 re-upload body so the re-upload is a * genuine, byte-distinct new version. Loaded in beforeAll (HERO_BYTES). */ const FIX = path.join(HERE, "fixtures", "files"); // ── state ─────────────────────────────────────────────────────────────────── let app: TestAppHandle; let prisma: PrismaClient; let redis: Redis; let workerFiles: InstanceType; let workerThumbs: InstanceType; let storageFiles: InstanceType; // listObjects (orphan/bucket asserts) let storageThumbs: InstanceType; let search: SearchClient | null; let env: import("../../apps/worker/src/config").WorkerEnv; let tempDir: string; let HERO_BYTES: Uint8Array; // the v2 re-upload body (a real, valid PNG) // ── helpers ───────────────────────────────────────────────────────────────── const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; const errCode = (body: Record): string => ((body.error as { code?: string } | undefined)?.code ?? ""); interface Ws { headers: Record; workspaceId: string; userId: string; } async function newWorkspace(name = "Lifecycle WS"): Promise { const user = await createUser(app.request, { name: "LC Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(user.accessToken!), name); return { headers: { ...authHeaders(user.accessToken!), "x-factory-workspace": workspaceId }, workspaceId, userId: user.userId, }; } interface Upload { file: { id: string; name: string; status?: string }; uploadUrl: string; method: string; headers: Record; expiresAt: string; } async function openUpload( headers: Record, body: Record, ): Promise<{ status: number; upload?: Upload; raw: Record }> { const res = await app.request("POST", "/api/v1/files/uploads", { headers, body }); const raw = await json(res); if (res.status !== 201) return { status: res.status, raw }; return { status: 201, upload: raw.upload as Upload, raw }; } async function putToUrl(url: string, bytes: Uint8Array, headers: Record): Promise { return fetch(url, { method: "PUT", headers, body: bytes as unknown as BodyInit }); } async function complete(headers: Record, uploadId: string) { const res = await app.request("PUT", `/api/v1/files/uploads/${uploadId}/complete`, { headers, body: {} }); return { status: res.status, body: await json(res) }; } async function fileRow(fileId: string) { return app.prisma.file.findUnique({ where: { id: fileId } }); } /** Drive the in-process files pipeline to a terminal state (no scanner → * deterministic READY + FileVersion + thumbnails; the scan is covered by the * TASK-0102 pipeline spec). */ async function toReady(fileId: string, version: number, workspaceId: string): Promise { const res = await runFilesPipeline( { prisma, s3: workerFiles, s3Thumbs: workerThumbs, redis, env }, { fileId, version, workspaceId }, ); expect(res.kind).toBe("ready"); const row = await fileRow(fileId); expect(row?.status).toBe("READY"); } /** Full happy path: open + PUT + complete + pipeline→READY. Returns file id. */ async function uploadPng(headers: Record, name = "lifecycle.png", bytes = PNG_BYTES): Promise { const { status, upload } = await openUpload(headers, { name, mimeType: "image/png", size: bytes.length, }); expect(status).toBe(201); const put = await putToUrl(upload!.uploadUrl, bytes, upload!.headers); expect(put.status).toBe(200); const c = await complete(headers, upload!.file.id); expect(c.status).toBe(200); await toReady(upload!.file.id, 1, headers["x-factory-workspace"]!); return upload!.file.id; } /** All object keys under a prefix (files or thumbs bucket). */ async function keysIn(client: InstanceType, prefix: string): Promise { const { keys } = await client.listObjects(prefix, 1000); return keys; } /** Fire one search-index event in-process (mirrors the worker consumer). */ async function searchEvent(event: string, payload: Record): Promise { if (!search) return; await handleSearchEvent( { search, prisma, connection: redis, env }, { event, payload }, ); } async function docInSearch(docId: string): Promise { if (!search) return false; const present = await search.docExists([docId]); return present.has(docId); } /** The SearchIndexOps bundle (for runReindex — the convergence op a * permission.changed workspace-resync job performs). */ function searchOps() { return { search: search!, prisma, connection: redis, env }; } /** True if the `factory:events` stream carries an entry for `event` whose * payload.resourceId === resourceId (proves the API published it). The event * bus XADDs `[event, payload(JSON), ts]` per entry. */ async function streamHasEvent(event: string, resourceId: string): Promise { const entries = (await redis.xrange("factory:events", "-", "+")) as [string, unknown[]][]; return entries.some(([, fields]) => { const arr = fields as unknown[]; const get = (k: string): unknown => arr[arr.indexOf(k) + 1]; if (get("event") !== event) return false; const payload = get("payload"); if (typeof payload !== "string") return false; try { return (JSON.parse(payload) as { resourceId?: string }).resourceId === resourceId; } catch { return false; } }); } // ── suite ─────────────────────────────────────────────────────────────────── describe.skipIf(!minioReady)( "Files lifecycle integration (TASK-0104 — versions/audit/trash/quota/permissions)", () => { beforeAll(async () => { app = await createTestApp(); const [dbUrl, redisUrl] = await Promise.all([getDatabaseUrl(), getRedisUrl()]); prisma = new PrismaClient({ datasources: { db: { url: dbUrl } } }); redis = new Redis(redisUrl, { maxRetriesPerRequest: null }); const cfg = { endpoint: S3_ENDPOINT, region: process.env.S3_REGION ?? "us-east-1", accessKey: S3_ACCESS, secretKey: S3_SECRET, }; workerFiles = new WorkerS3Client({ ...cfg, bucket: FILES_BUCKET }); workerThumbs = new WorkerS3Client({ ...cfg, bucket: THUMBS_BUCKET }); const scfg: StorageS3Config = { endpoint: S3_ENDPOINT, region: process.env.S3_REGION ?? "us-east-1", bucket: FILES_BUCKET, accessKeyId: S3_ACCESS, secretAccessKey: S3_SECRET, forcePathStyle: true, }; storageFiles = new StorageS3Client({ config: scfg }); storageThumbs = new StorageS3Client({ config: { ...scfg, bucket: THUMBS_BUCKET } }); if (searchReady) search = new SearchClient({ baseUrl: SEARCH_URL }); tempDir = await mkdtemp(path.join(os.tmpdir(), "factory-lifecycle-it-")); HERO_BYTES = new Uint8Array(await readFile(path.join(FIX, "hero-1400.png"))); env = { NODE_ENV: "test", REDIS_URL: "ignored", DATABASE_URL: "ignored", S3_ENDPOINT, S3_REGION: process.env.S3_REGION ?? "us-east-1", S3_BUCKET_FILES: FILES_BUCKET, S3_BUCKET_THUMBS: THUMBS_BUCKET, S3_BUCKET: FILES_BUCKET, S3_ACCESS_KEY: S3_ACCESS, S3_SECRET_KEY: S3_SECRET, S3_PUBLIC_BASE: "http://localhost:9001", SEARCH_URL, SEARCH_USERNAME: "admin", SEARCH_PASSWORD: "changeme", SEARCH_VECTOR_DIMENSION: 1536, SEARCH_KNN_M: 16, SEARCH_KNN_EF_CONSTRUCTION: 100, MASTER_ENCRYPTION_KEY: "", AI_API_KEY: "", AI_PROVIDER: "openai", AI_BASE_URL: "", AI_MODEL: "gpt-4o-mini", AI_EMBEDDING_PROVIDER: "", AI_EMBEDDING_MODEL: "text-embedding-3-small", SMTP_HOST: "", SMTP_PORT: 587, SMTP_USER: "", SMTP_PASS: "", SMTP_FROM: "noreply@factory.local", SMTP_STARTTLS: true, CLAMAV_HOST: "", CLAMAV_PORT: 3310, CLAMD_SOCKET: "", // no scanner — deterministic READY (scan is TASK-0102's contract) FILES_WORKER_CONCURRENCY: 4, CLAMAV_TIMEOUT_MS: 30_000, FILES_TEMP_DIR: tempDir, FILES_TEMP_MAX_AGE_MS: 600_000, FILES_TEMP_FREE_MIN_BYTES: 1, PUBLIC_URL: "http://localhost:3000", WORKER_CONCURRENCY: 10, WORKER_ID: "lifecycle-test-worker", WORKER_RETENTION_EXECUTE: false, HEALTH_PORT: 4003, } as unknown as import("../../apps/worker/src/config").WorkerEnv; }, 180_000); afterAll(async () => { await redis?.quit().catch(() => {}); await prisma?.$disconnect().catch(() => {}); await app?.close(); }); // ── 1. versions ──────────────────────────────────────────────────────── it("1: re-upload → v2 (FileVersion v2, both keys in bucket, F→v2); rollback→v1; GET /versions lists both (bare array, no key)", async () => { const ws = await newWorkspace("Versions WS"); const f1 = await uploadPng(ws.headers, "v1.png", PNG_BYTES); const row1 = (await fileRow(f1))!; const v1key = row1.key; // Re-upload to the SAME file as a new version (replaceFileId). const { status, upload } = await openUpload(ws.headers, { name: "v2.png", mimeType: "image/png", size: HERO_BYTES.length, replaceFileId: f1, }); expect(status).toBe(201); expect(upload!.file.id).toBe(f1); // SAME file row re-pointed const put = await putToUrl(upload!.uploadUrl, HERO_BYTES, upload!.headers); expect(put.status).toBe(200); const c = await complete(ws.headers, f1); expect(c.status).toBe(200); await toReady(f1, 2, ws.workspaceId); const row2 = (await fileRow(f1))!; const v2key = row2.key; expect(v2key).not.toBe(v1key); // a NEW object key // Both keys exist in the bucket; the File points at v2. const bucketKeys = new Set(await keysIn(storageFiles, `files/${ws.workspaceId}/${f1}/`)); expect(bucketKeys.has(v1key)).toBe(true); expect(bucketKeys.has(v2key)).toBe(true); expect(row2.key).toBe(v2key); // FileVersion rows: v1 + v2. const versions = await app.prisma.fileVersion.findMany({ where: { fileId: f1 }, orderBy: { version: "asc" }, }); expect(versions.map((v) => v.version)).toEqual([1, 2]); // GET /versions → bare array, v2 current, NO raw `key`. const res = await app.request("GET", `/api/v1/files/${f1}/versions`, { headers: ws.headers }); expect(res.status).toBe(200); const list = (await res.json()) as unknown[]; expect(Array.isArray(list)).toBe(true); expect(list).toHaveLength(2); const items = list as Array>; expect(items.every((i) => !("key" in i))).toBe(true); // no raw key expect(items.map((i) => i.version)).toEqual([1, 2]); expect(items.find((i) => i.version === 2)?.current).toBe(true); expect(items.find((i) => i.version === 1)?.current).toBe(false); // Rollback to v1 → F points at v1's key (key swap, no new object). // @Post without @HttpCode → 201. const rb = await app.request("POST", `/api/v1/files/${f1}/rollback`, { headers: ws.headers, body: { version: 1 }, }); expect(rb.status).toBe(201); const row3 = (await fileRow(f1))!; expect(row3.key).toBe(v1key); // still exactly the same 2 objects (no copy on rollback) expect(new Set(await keysIn(storageFiles, `files/${ws.workspaceId}/${f1}/`)).size).toBe(2); }); // ── 2. download/preview audit ────────────────────────────────────────── it("2: non-member download → 403 + file.download_denied (actorId+ip); member download → 302 + file.downloaded", async () => { const ws = await newWorkspace("Audit WS"); const fileId = await uploadPng(ws.headers, "audit.png"); // A non-member of the workspace (a plain registered user). const stranger = await createUser(app.request, { name: "Stranger" }); const strangerHeaders = { ...authHeaders(stranger.accessToken!), "x-factory-workspace": ws.workspaceId }; // non-member → 403 FORBIDDEN + denial audit (actorId = stranger, ip set). const denied = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: strangerHeaders }); expect(denied.status).toBe(403); expect(errCode(await json(denied))).toBe("FORBIDDEN"); const denAudit = await app.prisma.auditLog.findFirst({ where: { action: "file.download_denied", resourceId: fileId }, }); expect(denAudit).not.toBeNull(); expect(denAudit?.actorId).toBe(stranger.userId); expect(denAudit?.ipAddress).toBeTruthy(); // member (owner) → 302 signed URL + success audit. const dl = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: ws.headers, redirect: "manual", }); expect(dl.status).toBe(302); expect(dl.headers.get("location")).toContain("X-Amz-Signature="); const okAudit = await app.prisma.auditLog.findFirst({ where: { action: "file.downloaded", resourceId: fileId }, }); expect(okAudit).not.toBeNull(); expect(okAudit?.actorId).toBe(ws.userId); }); // ── 3. file-level permission overrides ───────────────────────────────── it("3: NONE grant on a file → 403 (beats inherited EDIT); VIEW grant to a non-member → 302 (grant grants)", async () => { const ws = await newWorkspace("Perm WS"); const fileId = await uploadPng(ws.headers, "perm.png"); // A MEMBER (inherited EDIT via the MEMBER role) — grant the file NONE. const member = await createUser(app.request, { name: "Member" }); await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER"); const memberHeaders = { ...authHeaders(member.accessToken!), "x-factory-workspace": ws.workspaceId }; // Sanity: a MEMBER CAN read the file before the NONE grant (inherited EDIT ≥ VIEW). const before = await app.request("GET", `/api/v1/files/${fileId}`, { headers: memberHeaders }); expect(before.status).toBe(200); // Grant NONE (explicit deny) — replace the full grant set. const putNone = await app.request("PUT", `/api/v1/files/${fileId}/permissions`, { headers: ws.headers, body: { grants: [{ granteeType: "user", granteeId: member.userId, level: "NONE" }] }, }); expect(putNone.status).toBe(200); const after = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: memberHeaders, redirect: "manual", }); expect(after.status).toBe(403); // explicit NONE beats the MEMBER baseline EDIT // A NON-MEMBER with an explicit VIEW grant → can read (grant grants). const outsider = await createUser(app.request, { name: "Outsider" }); const outHeaders = { ...authHeaders(outsider.accessToken!), "x-factory-workspace": ws.workspaceId }; // No grant yet → denied. const noGrant = await app.request("GET", `/api/v1/files/${fileId}`, { headers: outHeaders }); expect(noGrant.status).toBe(403); // Grant VIEW (add to the existing set — keep the NONE for the member too). await app.request("PUT", `/api/v1/files/${fileId}/permissions`, { headers: ws.headers, body: { grants: [ { granteeType: "user", granteeId: member.userId, level: "NONE" }, { granteeType: "user", granteeId: outsider.userId, level: "VIEW" }, ], }, }); const viewOk = await app.request("GET", `/api/v1/files/${fileId}`, { headers: outHeaders }); expect(viewOk.status).toBe(200); // explicit VIEW grants the non-member }); // ── 4. trash → restore → purge ───────────────────────────────────────── it("4+6: delete→deletedAt+absent+doc-removed; restore→back+doc; purge→objects/rows/thumbs gone, audit in order, 0 orphans", async () => { const ws = await newWorkspace("Trash WS"); // A 2-version file (so purge must remove 2 object keys). const f1 = await uploadPng(ws.headers, "trash.png", PNG_BYTES); const { upload } = await openUpload(ws.headers, { name: "trash-v2.png", mimeType: "image/png", size: HERO_BYTES.length, replaceFileId: f1, }); await putToUrl(upload!.uploadUrl, HERO_BYTES, upload!.headers); await complete(ws.headers, f1); await toReady(f1, 2, ws.workspaceId); const versionRows = await app.prisma.fileVersion.findMany({ where: { fileId: f1 } }); const versionKeys = versionRows.map((v) => v.key).sort(); const versionSizesSum = BigInt(versionRows.reduce((s, v) => s + v.size, 0)); expect(versionKeys.length).toBe(2); // counter after both versions are complete (= Σ version bytes; fresh ws) const counterAfterUpload = (await app.prisma.workspace.findUniqueOrThrow({ where: { id: ws.workspaceId } })).usageStorageBytes; expect(counterAfterUpload).toBe(versionSizesSum); if (searchReady) await searchEvent("file.uploaded", { workspaceId: ws.workspaceId, fileId: f1 }); const docBefore = await docInSearch(`file:${f1}`); // ── delete (soft trash) ── const del = await app.request("DELETE", `/api/v1/files/${f1}`, { headers: ws.headers }); expect(del.status).toBe(200); const delBody = await json(del); expect(delBody.trashed).toBe(true); expect(delBody.deletedAt).toBeTruthy(); const trashed = (await fileRow(f1))!; expect(trashed.deletedAt).not.toBeNull(); // absent from GET /files (workspace scope) const listRes = await app.request("GET", "/api/v1/files", { headers: ws.headers }); const listBody = await json(listRes); const listed = (listBody.data as Array<{ id: string }> | undefined) ?? []; expect(listed.find((f) => f.id === f1)).toBeUndefined(); // present in the trash const trashRes = await app.request("GET", "/api/v1/files/trash", { headers: ws.headers }); const trashList = (await trashRes.json()) as unknown[]; expect(Array.isArray(trashList)).toBe(true); expect(trashList.find((f) => (f as { id?: string }).id === f1)).toBeTruthy(); // search doc removed if (searchReady) { await searchEvent("file.deleted", { workspaceId: ws.workspaceId, fileId: f1 }); const docAfterDel = await docInSearch(`file:${f1}`); expect(docBefore).toBe(true); expect(docAfterDel).toBe(false); } // ── restore ── ( @Post without @HttpCode → 201 ) const restore = await app.request("POST", `/api/v1/files/${f1}/restore`, { headers: ws.headers }); expect(restore.status).toBe(201); const restored = (await fileRow(f1))!; expect(restored.deletedAt).toBeNull(); const list2 = await json(await app.request("GET", "/api/v1/files", { headers: ws.headers })); expect(((list2.data as Array<{ id: string }>) ?? []).find((f) => f.id === f1)).toBeTruthy(); // search doc re-added if (searchReady) { await searchEvent("file.uploaded", { workspaceId: ws.workspaceId, fileId: f1, restored: true }); expect(await docInSearch(`file:${f1}`)).toBe(true); } // counter unchanged by trash→restore (bytes retained while trashed, ±0) const counterAfterRestore = (await app.prisma.workspace.findUniqueOrThrow({ where: { id: ws.workspaceId } })).usageStorageBytes; expect(counterAfterRestore).toBe(counterAfterUpload); // ── purge (hard) ── const purge = await app.request("DELETE", `/api/v1/files/${f1}?purge=true`, { headers: ws.headers }); expect(purge.status).toBe(200); const purgeBody = await json(purge); expect(purgeBody.purged).toBe(true); // File + FileVersion rows gone expect(await fileRow(f1)).toBeNull(); expect(await app.prisma.fileVersion.count({ where: { fileId: f1 } })).toBe(0); // BOTH version objects + the thumbnail set gone from the bucket const filesLeft = await keysIn(storageFiles, `files/${ws.workspaceId}/${f1}/`); expect(filesLeft.length).toBe(0); for (const k of versionKeys) expect(filesLeft).not.toContain(k); const thumbsLeft = await keysIn(storageThumbs, `thumbs/${ws.workspaceId}/${f1}/`); expect(thumbsLeft.length).toBe(0); // counter decremented by the purged version bytes (fresh ws → back to 0) const counterAfterPurge = (await app.prisma.workspace.findUniqueOrThrow({ where: { id: ws.workspaceId } })).usageStorageBytes; expect(counterAfterPurge).toBe(counterAfterUpload - versionSizesSum); // audit trail: deleted → restored → purged, in order (by createdAt) const trail = await app.prisma.auditLog.findMany({ where: { resourceId: f1, action: { in: ["file.deleted", "file.restored", "file.purged"] }, }, orderBy: { createdAt: "asc" }, }); expect(trail.map((t) => t.action)).toEqual(["file.deleted", "file.restored", "file.purged"]); // ── orphan check: a sweep scoped to this (now purged) file reports 0 ── const report = await runOrphanSweep({ s3: storageFiles, prisma, dryRun: true, fileId: f1, }); expect(report.orphansFound).toBe(0); expect(report.keys).toEqual([]); }); // ── 5. quota boundary (decimal numbers per the spec) ─────────────────── it("5: 1 GB quota, 950 MB used → 60 MB session 413 QUOTA_EXCEEDED; 40 MB 201 + column = 990 MB (assert the column)", async () => { const ws = await newWorkspace("Quota WS"); // DECIMAL 1 GB override — tightens BELOW the paid plan's 10 GiB default so // the effective quota is exactly 1,000,000,000 (effectiveStorageQuota = // min(plan, override)). The spec's numbers (950/60/40/990 MB) are decimal. // // `billingPlan: "paid"` is load-bearing: the free plan's per-file limit is // 50 MiB, which would trip FILE_TOO_LARGE (413) on the 60 MB file BEFORE // the quota check. Paid's 500 MiB per-file limit lets 60 MB through to the // quota gate — which is the contract under test. await app.prisma.workspace.update({ where: { id: ws.workspaceId }, data: { billingPlan: "paid", usageLimits: { storageQuota: 1_000_000_000 } }, }); await app.prisma.workspace.update({ where: { id: ws.workspaceId }, data: { usageStorageBytes: BigInt(950_000_000) }, }); // 60 MB > 1,000,000,000 − 950,000,000 = 50,000,000 → 413. const over = await openUpload(ws.headers, { name: "big.bin", mimeType: "application/octet-stream", size: 60_000_000, }); expect(over.status).toBe(413); expect(errCode(over.raw)).toBe("QUOTA_EXCEEDED"); // 40 MB < 50,000,000 → 201; PUT + complete bumps the COLUMN to 990 MB. const ok = await openUpload(ws.headers, { name: "small.bin", mimeType: "application/octet-stream", size: 40_000_000, }); expect(ok.status).toBe(201); // PUT exactly 40 MB of zeros (octet-stream has no magic signature). const zeros = new Uint8Array(40_000_000); const put = await putToUrl(ok.upload!.uploadUrl, zeros, ok.upload!.headers); expect(put.status).toBe(200); const c = await complete(ws.headers, ok.upload!.file.id); expect(c.status).toBe(200); // The COLUMN (not a recompute) is 990,000,000. const wsRow = await app.prisma.workspace.findUniqueOrThrow({ where: { id: ws.workspaceId } }); expect(wsRow.usageStorageBytes).toBe(BigInt(990_000_000)); }); // ── 7. permissions re-index + audit ──────────────────────────────────── it("7: add a grant → GET /permissions shows it + audit file.permission_changed + permission.changed published + doc re-indexes", async () => { const ws = await newWorkspace("Reindex WS"); const fileId = await uploadPng(ws.headers, "reindex.png"); const team = await app.prisma.team.create({ data: { workspaceId: ws.workspaceId, name: "LC Team", slug: `lc-team-${Date.now()}` }, }); // Start with no explicit grants. const empty = await app.request("GET", `/api/v1/files/${fileId}/permissions`, { headers: ws.headers }); expect(empty.status).toBe(200); expect(Array.isArray(await empty.json())).toBe(true); // Add a TEAM VIEW grant. const put = await app.request("PUT", `/api/v1/files/${fileId}/permissions`, { headers: ws.headers, body: { grants: [{ granteeType: "team", granteeId: team.id, level: "VIEW" }] }, }); expect(put.status).toBe(200); // GET /permissions now lists it (the dialog's source of truth). const got = await app.request("GET", `/api/v1/files/${fileId}/permissions`, { headers: ws.headers }); const grants = (await got.json()) as Array>; expect(grants).toHaveLength(1); expect(grants[0]?.granteeType).toBe("team"); expect(grants[0]?.granteeId).toBe(team.id); expect(grants[0]?.level).toBe("VIEW"); // audit const audit = await app.prisma.auditLog.findFirst({ where: { action: "file.permission_changed", resourceId: fileId }, }); expect(audit).not.toBeNull(); // The API published `permission.changed` to the event bus (the worker // consumes it and enqueues a workspace-resync). Assert the event landed // on the stream — the contract under test. (We do NOT consume the // BullMQ enqueue itself: it is a side effect of the worker consumer, and // file-level grants do NOT change search visibility by design — search // is workspace/page-scoped, file-level access is enforced at read time. // See the TASK-0104 rule-29 note in docs/tasks/phase-09-files.md.) expect(await streamHasEvent("permission.changed", fileId)).toBe(true); // The re-index convergence a permission.changed resync performs: a // full re-index leaves the file doc present (workspace-floor visibility // is unchanged) and the mirror row converged. if (searchReady) { const counts = await runReindex(searchOps(), ws.workspaceId); expect(counts.docs).toBeGreaterThanOrEqual(1); expect(await docInSearch(`file:${fileId}`)).toBe(true); const mirror = await app.prisma.searchDocument.findUnique({ where: { type_refId: { type: "file", refId: fileId } }, }); expect(mirror).not.toBeNull(); } }); // ── 8. rename ────────────────────────────────────────────────────────── it("8: PATCH changes only the name (size/mime/metadata unchanged) + file.updated published + doc title re-indexed", async () => { const ws = await newWorkspace("Rename WS"); const fileId = await uploadPng(ws.headers, "before.png"); const before = (await fileRow(fileId))!; if (searchReady) await searchEvent("file.uploaded", { workspaceId: ws.workspaceId, fileId }); expect(await docInSearch(`file:${fileId}`)).toBe(searchReady ? true : false); const patch = await app.request("PATCH", `/api/v1/files/${fileId}`, { headers: ws.headers, body: { name: "after.png" }, }); expect(patch.status).toBe(200); const afterRow = (await fileRow(fileId))!; expect(afterRow.name).toBe("after.png"); // only the name changed expect(afterRow.size).toBe(before.size); expect(afterRow.mimeType).toBe(before.mimeType); expect(afterRow.metadata).toEqual(before.metadata); expect(afterRow.key).toBe(before.key); // audit file.renamed const audit = await app.prisma.auditLog.findFirst({ where: { action: "file.renamed", resourceId: fileId }, }); expect(audit).not.toBeNull(); // file.updated published → doc title re-indexed (buildFileDoc reads the // fresh File.name). if (searchReady) { await searchEvent("file.updated", { workspaceId: ws.workspaceId, fileId, name: "after.png" }); const mirror = await app.prisma.searchDocument.findUnique({ where: { type_refId: { type: "file", refId: fileId } }, }); expect(mirror?.title).toBe("after.png"); } }); }, );