/** * AI Software Factory — Integration: FILES SECURITY SUITE (Phase 9, TASK-0105) * * The Phase-9 exit criteria + the file security model, asserted against the * REAL stack the same way the TASK-0104 lifecycle spec does: NestJS API * (Testcontainers Postgres/Redis) + the worker's files pipeline + search-index * processor driven IN-PROCESS against the container DB + the host MinIO * (+ gated host OpenSearch). * * Scenarios (docs/tasks/phase-09-files.md TASK-0105, one `it` per requirement): * * S1 500 MB paid upload — a SPARSE 500,000,000-byte file (one 4 KB block * on disk, tests/fixtures/files/make-sparse.ts) uploads through the * real presigned PUT and reaches READY with the EXACT size (the ROADMAP * exit criterion "upload of a 500 MB file works (multipart)"). * S2 MIME spoof — text bytes declared `image/png` → `MIME_MISMATCH` at * complete, status FAILED, the object DELETED from the bucket, audit * `file.mime_mismatch`. (CONFLICT recorded in the task file: the spec * line said 422; the implemented contract is 415 — AGENTS.md rule 29.) * S3 Path traversal — a property-style sweep over 200 generated malicious * names: every stored object key is `files/{ws}/{fileId}/{v}/` * (no `..`, no leading `/`, no NUL, no backslash) + two REAL uploads * prove the DB key, the bucket key, and the wire (no raw key leak). * S4 Unauthorized-access matrix — 6 cases with per-case audit assertions * (actor is the requesting user, never "system"; IP present): * a. non-member GET metadata → 403 * b. non-member download → 403 + file.download_denied * c. member, no access to the page → 403 (+ download denial audit) * d. explicit file VIEW grant to a non-member → 200 / 302 * e. grant revoked → 403 + denial audit (search doc unaffected — * file-level grants are read-time only; rule-29 note) * f. signed URL after 900 s → 403 from storage; signed URL for file * A used against file B's key → 403 (key binding, TASK-0101) * * Reporting (TASK-0105 §7): the suite writes * `tests/e2e/reports/files-security.json` (scenarios, pass/fail, timing) for * CI artifacts. A performance guard fails the run when the total suite * runtime exceeds the 15-minute budget (CI-regression canary; the 500 MB * upload is included in the budget). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import path from "node:path"; import os from "node:os"; import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { S3Client as StorageS3Client, buildFileKey, sanitizeFilename, presignGet, 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"; import { makeSparse500, SPARSE_500_MB } from "../fixtures/files/make-sparse"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPORT_DIR = path.resolve(HERE, "..", "e2e", "reports"); const REPORT_PATH = path.join(REPORT_DIR, "files-security.json"); // The worker's files pipeline + storage client + search-index processor, from // SOURCE (same convention as the TASK-0104 lifecycle spec). 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")) ); 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 S3_REGION = process.env.S3_REGION ?? "us-east-1"; 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 (magic bytes verify on both sides). */ 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, ]); const TXT_BYTES = new Uint8Array( Buffer.from("this is not a png — plain ascii text for the spoof/traversal tests", "utf8"), ); // ── state ─────────────────────────────────────────────────────────────────── let app: TestAppHandle; let prisma: PrismaClient; let redis: Redis; let workerFiles: InstanceType; let workerThumbs: InstanceType; let storageFiles: InstanceType; let search: SearchClient | null; let env: import("../../apps/worker/src/config").WorkerEnv; let tempDir: string; // ── report collector (TASK-0105 §7) ───────────────────────────────────────── interface ScenarioResult { scenario: string; passed: boolean; durationMs: number; } const scenarioResults: ScenarioResult[] = []; const suiteStart = Date.now(); function scenario(id: string, fn: () => Promise): () => Promise { return async () => { const t0 = Date.now(); let passed = false; try { await fn(); passed = true; } finally { scenarioResults.push({ scenario: id, passed, durationMs: Date.now() - t0 }); } }; } // ── 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 = "Sec WS"): Promise { const user = await createUser(app.request, { name: "Sec 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 is * configured here (the CLAMAV half of S2's EICAR story is covered by the * TASK-0102 pipeline spec + the E2E virus card); this keeps the 500 MB run * deterministic — the download+metadata+finalize steps are what matter. */ async function runPipeline(fileId: string, version: number, workspaceId: string) { return runFilesPipeline( { prisma, s3: workerFiles, s3Thumbs: workerThumbs, redis, env }, { fileId, version, workspaceId }, ); } async function toReady(fileId: string, version: number, workspaceId: string): Promise { const res = await runPipeline(fileId, version, workspaceId); expect(res.kind, `pipeline ended ${res.kind}`).toBe("ready"); const row = await fileRow(fileId); expect(row?.status).toBe("READY"); } /** Full happy path: open + PUT + complete + pipeline → READY. */ async function uploadToReady( headers: Record, name: string, mimeType: string, bytes: Uint8Array, extra: Record = {}, ): Promise { const { status, upload } = await openUpload(headers, { name, mimeType, size: bytes.length, ...extra }); expect(status, `session: ${JSON.stringify(extra)}`).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 in the files bucket. */ async function keysIn(prefix: string): Promise { const { keys } = await storageFiles.listObjects(prefix, 1000); return keys; } /** An audit row for `action` + `resourceId`, asserting the ACTOR is the * requesting user (no "system" misattribution) and the IP is present. */ async function assertAudit(action: string, resourceId: string, actorId?: string): Promise { const row = await app.prisma.auditLog.findFirst({ where: { action, resourceId }, orderBy: { createdAt: "desc" }, }); expect(row, `audit row ${action} for ${resourceId}`).not.toBeNull(); expect(row!.actorId, `${action}: actorId`).toBeTruthy(); expect(row!.actorId).not.toBe("system"); if (actorId) expect(row!.actorId, `${action}: actor is the requesting user`).toBe(actorId); expect(row!.ipAddress, `${action}: ip present`).toBeTruthy(); } async function searchEvent(event: string, payload: Record): Promise { if (!search) return; await handleSearchEvent({ search, prisma, connection: redis, env }, { event, payload }); // The bulk index has no `refresh` and the index refresh_interval is 1 s — // force one so immediate `_mget` assertions are deterministic (rule 25). await search.refresh(); } async function docInSearch(docId: string): Promise { if (!search) return false; const present = await search.docExists([docId]); return present.has(docId); } function searchOps() { return { search: search!, prisma, connection: redis, env }; } /** A signed GET the API would mint for `key` (same signer/config as the * API's `signedUrls`, exposed here for the expiry + key-binding cases). */ function mintSignedGet(key: string, expiresSec: number, now = new Date()): string { const config: StorageS3Config = { endpoint: S3_ENDPOINT, region: S3_REGION, bucket: FILES_BUCKET, accessKeyId: S3_ACCESS, secretAccessKey: S3_SECRET, forcePathStyle: true, }; return presignGet({ config, key, expiresSec, now, responseContentDisposition: `attachment; filename="x"` }).url; } /** Deterministic 200 hostile names (the spec's four + a seeded generator). * Pure function of `count` — identical input sequence every run (the spec's * "200 generated malicious filenames" property). The generator emits names * whose SAFE key always ends in a recognizable word (the "recognizability" * assertion below is exact, not heuristic). */ function hostileNames(count: number): string[] { const base = [ "../../../etc/cron.d/x", "..\\..\\win.ini", "a/../../b.png", "bad\x00name.png", ]; // mulberry32 — a tiny deterministic PRNG (seeded: same 200 names every run). let a = 0x9e3779b9; const rnd = () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const pick = (arr: readonly T[]): T => arr[Math.floor(rnd() * arr.length)]!; const dirs = ["..", ".", "/", "\\", "C:\\", "etc", "tmp", "\\\\server\\share"]; const words = ["win", "ini", "passwd", "payload", "secret", "data", "cron"]; const exts = [".png", ".txt", ".bin", "", ".", "..", ".PNG", ".dmp", ".tmp"]; const controls = ["", "\x00", "\x01\x02", "\r\n", "\t", "\x7f", "\u00e9\u00fc", "\x1b[31m"]; const out: string[] = [...base]; while (out.length < count) { const depth = 1 + Math.floor(rnd() * 5); let n = ""; for (let d = 0; d < depth; d++) n += pick(dirs); n += pick(words) + pick(controls); if (rnd() < 0.5) n += pick(["/", "\\"]); n += pick(words) + pick(controls) + pick(exts); if (!out.includes(n)) out.push(n); } return out.slice(0, count); } /** The invariants every key must satisfy (spec req. 5). The traversal * property is SEGMENT-LEVEL: a `..` *path component* escapes the directory; * a name that merely contains the dot-dot SUBSTRING (`a..b`) is a single * harmless object name in our directory — S3 keys are flat, and S1–S4's * real-upload assertions prove the object physically lands in-bucket. */ function assertSafeKey(key: string, workspaceId: string, name: string): void { expect(key.startsWith(`files/${workspaceId}/`), `key escapes the workspace prefix: ${name} → ${key}`).toBe(true); expect(key.includes("\x00"), `key contains NUL: ${name} → ${key}`).toBe(false); expect(key.startsWith("/"), `key is absolute: ${name} → ${key}`).toBe(false); expect(key.includes("\\"), `key contains backslash: ${name} → ${key}`).toBe(false); const tail = key.slice(`files/${workspaceId}/`.length); const segs = tail.split("/"); expect(segs.length, `key segment count for ${name}`).toBe(3); // fileId / version / name for (const s of segs) { expect(s, `empty segment for ${name}`).not.toBe(""); expect(s, `traversal segment for ${name} → ${key}`).not.toBe(".."); expect(s, `self-reference segment for ${name} → ${key}`).not.toBe("."); expect(/[\u0000-\u001f\u007f]/.test(s), `control char in segment for ${name} → ${key}`).toBe(false); } const nameSeg = segs[2]!; expect(nameSeg.length).toBeGreaterThan(0); expect(nameSeg.length).toBeLessThanOrEqual(180); } // ── suite ─────────────────────────────────────────────────────────────────── describe.skipIf(!minioReady)( "Files security integration (TASK-0105 — 500MB, MIME spoof, traversal, unauthorized, signed URLs)", () => { beforeAll(async () => { // S3 must be configured BEFORE createTestApp (it copies process.env). 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: S3_REGION, 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: S3_REGION, bucket: FILES_BUCKET, accessKeyId: S3_ACCESS, secretAccessKey: S3_SECRET, forcePathStyle: true, }; storageFiles = new StorageS3Client({ config: scfg }); if (searchReady) search = new SearchClient({ baseUrl: SEARCH_URL }); tempDir = await mkdtemp(path.join(os.tmpdir(), "factory-files-sec-it-")); // ≥1 GB free before the 500 MB download lands (the disk guard). env = { NODE_ENV: "test", REDIS_URL: "ignored", DATABASE_URL: "ignored", S3_ENDPOINT, S3_REGION, 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: "", // 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: 1024 ** 3, PUBLIC_URL: "http://localhost:3000", WORKER_CONCURRENCY: 10, WORKER_ID: "files-security-test-worker", WORKER_RETENTION_EXECUTE: false, HEALTH_PORT: 4003, } as unknown as import("../../apps/worker/src/config").WorkerEnv; }, 240_000); afterAll(async () => { // Report artifact (TASK-0105 §7) — CI uploads tests/e2e/reports/**. await mkdir(REPORT_DIR, { recursive: true }).catch(() => {}); const report = { suite: "files-security (TASK-0105)", generatedAt: new Date().toISOString(), totalDurationMs: Date.now() - suiteStart, budgetMs: 15 * 60 * 1000, withinBudget: Date.now() - suiteStart <= 15 * 60 * 1000, scenarios: scenarioResults, passed: scenarioResults.length > 0 && scenarioResults.every((s) => s.passed), }; await writeFile(REPORT_PATH, JSON.stringify(report, null, 2), "utf8").catch(() => {}); await rm(tempDir, { recursive: true, force: true }).catch(() => {}); await redis?.quit().catch(() => {}); await prisma?.$disconnect().catch(() => {}); await app?.close(); }, 60_000); // ── S1. 500 MB paid upload (ROADMAP exit criterion) ──────────────────── it( "S1: 500 MB sparse upload on the paid plan → READY with exact size (multipart-friendly presigned PUT)", scenario("S1-500MB-paid-upload", async () => { const ws = await newWorkspace("500MB WS"); // Paid plan: 500 MiB per-file limit ≥ 500,000,000 bytes (the decimal // spec ceiling — the free plan's 50 MiB would trip FILE_TOO_LARGE). await app.prisma.workspace.update({ where: { id: ws.workspaceId }, data: { billingPlan: "paid" }, }); // The sparse fixture: 500,000,000 LOGICAL bytes, one 4 KB block on // disk (assert the sparseness — the whole point for CI runners). const sparse = await makeSparse500(); try { expect(sparse.logicalSize).toBe(SPARSE_500_MB); expect(sparse.allocatedBytes, "sparse file allocates ~one block, not 500 MB").toBeLessThan(1024 * 1024); // Open the session for the EXACT 500 MB size. const { status, upload } = await openUpload(ws.headers, { name: "big.bin", mimeType: "application/octet-stream", size: SPARSE_500_MB, }); expect(status, "the paid plan accepts a 500,000,000-byte session").toBe(201); const fileId = upload!.file.id; // Stream the real 500 MB to the presigned PUT (the "multipart" // story: one large signed object, no chunked-PUT API needed). const put = await putToUrl(upload!.uploadUrl, sparse.bytes, upload!.headers); expect(put.status, `presigned PUT returned ${put.status}`).toBe(200); const c = await complete(ws.headers, fileId); expect(c.status).toBe(200); await toReady(fileId, 1, ws.workspaceId); // EXACT size end to end (DB row + the object in the bucket). const row = (await fileRow(fileId))!; expect(row.size).toBe(SPARSE_500_MB); expect(row.status).toBe("READY"); const head = await workerFiles.headObject(row.key); expect(head.size).toBe(SPARSE_500_MB); // The key follows the contract even for the boundary object. assertSafeKey(row.key, ws.workspaceId, row.name); // Audit: the session + upload are attributed to the actor. await assertAudit("file.upload_session_created", fileId, ws.userId); const uploaded = await app.prisma.auditLog.findFirst({ where: { action: "file.uploaded", resourceId: fileId }, }); expect(uploaded, "file.uploaded audit").not.toBeNull(); expect((uploaded!.metadata as { size?: number }).size).toBe(SPARSE_500_MB); } finally { await rm(sparse.path, { force: true }).catch(() => {}); } }), 900_000, ); // ── S2. MIME spoof → FAILED + object deleted ─────────────────────────── it( "S2: MIME spoof (text bytes declared image/png) → MIME_MISMATCH, FAILED, object deleted from the bucket", scenario("S2-mime-spoof-deleted", async () => { const ws = await newWorkspace("Spoof WS"); // Declare a signature-bearing type (png), upload text bytes: the // completion-time sniff verdict is "mismatch". const { status, upload } = await openUpload(ws.headers, { name: "evil.png", mimeType: "image/png", size: TXT_BYTES.length, }); expect(status).toBe(201); const fileId = upload!.file.id; const key = (await fileRow(fileId))!.key; expect(key).toBe(buildFileKey({ workspaceId: ws.workspaceId, fileId, version: 1, name: "evil.png" })); const put = await putToUrl(upload!.uploadUrl, TXT_BYTES, upload!.headers); expect(put.status).toBe(200); expect(await keysIn(`files/${ws.workspaceId}/${fileId}/`), "object landed (pre-complete)").toEqual([key]); // complete → the API sniffs the first 8 KiB → mismatch. const c = await complete(ws.headers, fileId); expect(c.status, `complete returned ${c.status}: ${JSON.stringify(c.body)}`).toBe(415); expect(errCode(c.body)).toBe("MIME_MISMATCH"); // Row FAILED (never SCANNING — the pipeline never runs on spoofed // bytes) + the object is DELETED from the bucket (the bytes never // become downloadable). const row = (await fileRow(fileId))!; expect(row.status).toBe("FAILED"); expect(row.metadata?.pipeline).toMatchObject({ error: "mime_mismatch" }); expect(await keysIn(`files/${ws.workspaceId}/${fileId}/`), "object deleted from the bucket").toEqual([]); // Audit (actor = the uploader, IP present). await assertAudit("file.mime_mismatch", fileId, ws.userId); // The wire never exposed the key (the detail endpoint omits `key`). const detail = await app.request("GET", `/api/v1/files/${fileId}`, { headers: ws.headers }); expect(detail.status).toBe(200); expect((await detail.json()) as Record).not.toHaveProperty("key"); }), 120_000, ); // ── S3. Path traversal property test (200 names) ─────────────────────── it( "S3: 200 malicious names → 100% keys within files/{ws}/ (no .., NUL, absolute paths) + real uploads", scenario("S3-traversal-200-names", async () => { const ws = await newWorkspace("Traversal WS"); const fileId = "fil_trav_test"; // (a) property sweep over 200 generated hostile names: the stored key // (buildFileKey = exactly what create() stores) is always safe, and // the name segment keeps the BASENAME recognizable (spec: the key // "remains recognizable as the user's file", sanitized). The // basename is what a user perceives as the file name — the path // part IS the attack — so recognizability is asserted on it: // a 3+ char word of the basename survives into the safe segment, // or (a basename shorter than that, e.g. `x`) the safe segment // equals the fully sanitized basename. Deterministic, no sampling. const names = hostileNames(200); expect(names).toHaveLength(200); let recognized = 0; for (const name of names) { const key = buildFileKey({ workspaceId: ws.workspaceId, fileId, version: 1, name }); assertSafeKey(key, ws.workspaceId, name); const lastSeg = key.split("/").pop()!; const lastSep = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\")); const basename = lastSep === -1 ? name : name.slice(lastSep + 1); const runs = basename.match(/[A-Za-z0-9]+/g) ?? []; const longest = runs.reduce((m, r) => (r.length > m.length ? r : m), ""); const ok = longest.length >= 3 ? lastSeg.includes(longest) : lastSeg === sanitizeFilename(basename); if (ok) recognized++; } expect(recognized, "every name stays recognizable as its basename in the key").toBe(200); // The spec's explicit cases (pin the exact shapes): expect(buildFileKey({ workspaceId: "w1", fileId: "f1", version: 1, name: "..\\..\\win.ini" })).toBe( "files/w1/f1/1/win.ini", ); expect(buildFileKey({ workspaceId: "w1", fileId: "f1", version: 1, name: "a/../../b.png" })).toBe( "files/w1/f1/1/b.png", ); const nulKey = buildFileKey({ workspaceId: "w1", fileId: "f1", version: 1, name: "bad\x00name.png" }); expect(nulKey).toBe("files/w1/f1/1/bad_name.png"); expect(nulKey).not.toContain("\x00"); // (b) REAL uploads through the API: the DB key = the safe key, the // bucket object lives at that exact key, and the display name is // the original (sanitized key ≠ display name). const hostile = ["../../../etc/cron.d/x.png", "C:\\..\\..\\win.ini.png"]; for (let i = 0; i < hostile.length; i++) { const name = hostile[i]!; const { status, upload } = await openUpload(ws.headers, { name, mimeType: "image/png", size: PNG_BYTES.length, }); expect(status, `session for hostile name ${JSON.stringify(name)}`).toBe(201); const fId = upload!.file.id; await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers); const c = await complete(ws.headers, fId); expect(c.status).toBe(200); await toReady(fId, 1, ws.workspaceId); const row = (await fileRow(fId))!; // DB key = the safe key for this name (deterministic → comparable). expect(row.key).toBe(buildFileKey({ workspaceId: ws.workspaceId, fileId: fId, version: 1, name })); assertSafeKey(row.key, ws.workspaceId, name); // The object physically sits at that key in the bucket. expect(await keysIn(`files/${ws.workspaceId}/${fId}/`)).toEqual([row.key]); // Display name: the ORIGINAL name (recognizable), not the safe key. expect(row.name).toBe(name); const detail = (await json( await app.request("GET", `/api/v1/files/${fId}`, { headers: ws.headers }), )) as { name?: string }; expect(detail.name).toBe(name); } }), 180_000, ); // ── S4. Unauthorized-access matrix (6 cases) ─────────────────────────── it( "S4: unauthorized matrix — 6 cases with audit (non-member, page-scoped, grant, revoke, expiry, key-binding)", scenario("S4-unauthorized-matrix", async () => { const ws = await newWorkspace("Matrix WS"); // A page the member (B) is explicitly locked out of: a PAGE-level // NONE grant (files inherit the page scope — the spec's case c). const page = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: ws.headers, body: { title: "Matrix Page", parentId: null }, }); expect(page.status).toBe(201); const pageId = ((await page.json()) as { id: string }).id; const fileId = await uploadToReady( { ...ws.headers }, "matrix.png", "image/png", PNG_BYTES, { pageId }, ); const ownerHeaders = ws.headers; if (searchReady) { await searchEvent("file.uploaded", { workspaceId: ws.workspaceId, fileId, pageId }); expect(await docInSearch(`file:${fileId}`)).toBe(true); } const stranger = await createUser(app.request, { name: "Stranger" }); const strangerHeaders = { ...authHeaders(stranger.accessToken!), "x-factory-workspace": ws.workspaceId }; 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 }; // Lock member B out of the page (explicit NONE — the hard stop the // file-read inheritance chain walks). DIRECT DB WRITE (the seed.ts // convention: state the API cannot create — Phase 9 exposes only // FILE-level grants, `PUT /files/:id/permissions`; page-level grants // have no public route. The API's ENFORCEMENT of the row is what is // under test here, and nothing bypasses authorization.) await prisma.permission.create({ data: { workspaceId: ws.workspaceId, granteeType: "user", granteeId: member.userId, resourceType: "page", resourceId: pageId, level: "NONE", }, }); // (a) non-member GET metadata → 403. const a = await app.request("GET", `/api/v1/files/${fileId}`, { headers: strangerHeaders }); expect(a.status).toBe(403); expect(errCode(await json(a))).toBe("FORBIDDEN"); // (b) non-member download → 403 + file.download_denied (actor+ip). const b = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: strangerHeaders, redirect: "manual", }); expect(b.status).toBe(403); await assertAudit("file.download_denied", fileId, stranger.userId); // (c) member B, no access to the page → 403 (metadata) + a download // attempt audits the denial. const c = await app.request("GET", `/api/v1/files/${fileId}`, { headers: memberHeaders }); expect(c.status, "page NONE hard-stops the member's inherited EDIT").toBe(403); const c2 = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: memberHeaders, redirect: "manual", }); expect(c2.status).toBe(403); const cAudit = await app.prisma.auditLog.findFirst({ where: { action: "file.download_denied", resourceId: fileId }, orderBy: { createdAt: "desc" }, }); expect(cAudit?.actorId).toBe(member.userId); // (d) explicit FILE VIEW grant to the non-member → 200 (metadata) + // 302 (download). The file grant has the highest specificity — // it grants despite the user having no workspace role. const putGrant = await app.request("PUT", `/api/v1/files/${fileId}/permissions`, { headers: ownerHeaders, body: { grants: [{ granteeType: "user", granteeId: stranger.userId, level: "VIEW" }] }, }); expect(putGrant.status).toBe(200); const d = await app.request("GET", `/api/v1/files/${fileId}`, { headers: strangerHeaders }); expect(d.status, "file VIEW grant lets the non-member read").toBe(200); const d2 = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: strangerHeaders, redirect: "manual", }); expect(d2.status).toBe(302); expect(d2.headers.get("location")).toContain("X-Amz-Signature="); await assertAudit("file.downloaded", fileId, stranger.userId); // (e) grant revoked → 403 + denial audit. Search is UNCHANGED (the // rule-29 note: file-level grants are read-time only; search is // workspace/page-scoped — the doc stays indexed for members). const putRevoke = await app.request("PUT", `/api/v1/files/${fileId}/permissions`, { headers: ownerHeaders, body: { grants: [] }, }); expect(putRevoke.status).toBe(200); const e = await app.request("GET", `/api/v1/files/${fileId}`, { headers: strangerHeaders }); expect(e.status).toBe(403); const e2 = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: strangerHeaders, redirect: "manual", }); expect(e2.status).toBe(403); const eAudit = await app.prisma.auditLog.findFirst({ where: { action: "file.download_denied", resourceId: fileId }, orderBy: { createdAt: "desc" }, }); expect(eAudit?.actorId, "revoked download denial audited to the requester").toBe(stranger.userId); if (searchReady) { // The resync a permission.changed would run leaves the doc in // place (workspace-floor visibility is unchanged by design). await runReindex(searchOps(), ws.workspaceId); await search.refresh(); // deterministic _mget (refresh_interval 1 s) expect(await docInSearch(`file:${fileId}`), "search doc survives the revoke (read-time model)").toBe(true); } // (f) signed URLs — expiry + key binding (TASK-0101 contract, minted // with the same signer/config the API uses). const row = (await fileRow(fileId))!; // fresh URL → 200 (sanity: the mint works). const fresh = mintSignedGet(row.key, 900); const f0 = await fetch(fresh); expect(f0.status).toBe(200); // expired (minted 900 s in the PAST) → 403 from storage. const expired = mintSignedGet(row.key, 900, new Date(Date.now() - 901_000)); const f1 = await fetch(expired); expect(f1.status, `expired URL → ${f1.status}`).toBe(403); expect(await f1.text()).toMatch(/AccessDenied|ExpiredToken|SignatureDoesNotMatch/i); // cross-key: file A's URL against file B's key → 403 (the signature // covers the exact key — a leaked URL is useless on any other file). // The signer encodes the key segment-wise into the path // (`objectRequestParts`); cuid keys are unreserved, so the path // carries the raw key followed by `?` — swap exactly that span. const bFileId = await uploadToReady(ownerHeaders, "other.png", "image/png", PNG_BYTES, { pageId: null }); const bRow = (await fileRow(bFileId))!; const aUrl = mintSignedGet(row.key, 900); const cross = aUrl.replace(`/${row.key}?`, `/${bRow.key}?`); expect(cross, "the path now carries file B's key").not.toBe(aUrl); const f2 = await fetch(cross); expect(f2.status, `cross-key URL → ${f2.status}`).toBe(403); expect(await f2.text()).toMatch(/SignatureDoesNotMatch/i); // And the legitimate URL for B's key works (it was signed for B). const f3 = await fetch(mintSignedGet(bRow.key, 900)); expect(f3.status).toBe(200); }), 300_000, ); // ── S5. EICAR quarantine (Requirement 3 / DoD item 3) ────────────────── it( "S5: EICAR → VIRUS_DETECTED (ClamAV socket), no thumbs, no search doc, download 403 FILE_QUARANTINED + audit, detail 200 unsigned", scenario("S5-eicar-quarantine", async () => { // The 69-byte standard anti-virus test signature (SAFE — it is a // known, non-malicious marker string ClamAV is shipped to detect). const EICAR = Buffer.from( "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*", "latin1", ); const ws = await newWorkspace("EICAR WS"); const { status, upload } = await openUpload(ws.headers, { name: "eicar.txt", mimeType: "text/plain", size: EICAR.length, }); expect(status).toBe(201); const fileId = upload!.file.id; await putToUrl(upload!.uploadUrl, new Uint8Array(EICAR), upload!.headers); const c = await complete(ws.headers, fileId); expect(c.status).toBe(200); // Drive the pipeline WITH the ClamAV socket (zCONTSCAN over the unix // socket — the worker's production path; the host socket is the one // the running worker is configured with). const clamEnv = { ...env, CLAMD_SOCKET: "/var/run/clamav/clamd.ctl", CLAMAV_HOST: "", CLAMAV_PORT: 3310, } as unknown as typeof env; const res = await runFilesPipeline( { prisma, s3: workerFiles, s3Thumbs: workerThumbs, redis, env: clamEnv }, { fileId, version: 1, workspaceId: ws.workspaceId }, ); expect(res.kind, `pipeline ended ${res.kind}`).toBe("virus"); // Status + scan report (the engine REALLY scanned — not a dev-skip). // The virus finalize writes `scanReport` to the TOP-LEVEL File column // (not `metadata.pipeline`) + `metadata.pipeline.error = "virus_detected"`. const row = (await fileRow(fileId))!; expect(row.status).toBe("VIRUS_DETECTED"); const scan = (row.scanReport as Record | null) ?? undefined; expect(scan, "scanReport recorded (top-level File column)").toBeTruthy(); expect(scan?.engine).toBe("clamav"); expect(scan?.infected).toBe(true); expect(String(scan?.signature ?? "")).toMatch(/Eicar/i); expect(row.metadata).toMatchObject({ pipeline: { error: "virus_detected" } }); // Audit: the detection is attributed to the system (no actor) but the // resource + workspace are recorded. const virusAudit = await app.prisma.auditLog.findFirst({ where: { action: "file.virus_detected", resourceId: fileId }, }); expect(virusAudit, "file.virus_detected audit").not.toBeNull(); // No thumbnails were generated (the virus path never reaches the // thumbnail step) — assert the whole prefix is empty in the thumbs // bucket. const thumbCfg = { endpoint: S3_ENDPOINT, region: S3_REGION, bucket: THUMBS_BUCKET, accessKeyId: S3_ACCESS, secretAccessKey: S3_SECRET, forcePathStyle: true, } as StorageS3Config; const thumbsClient = new StorageS3Client({ config: thumbCfg }); const { keys: thumbKeys } = await thumbsClient.listObjects( `thumbs/${ws.workspaceId}/${fileId}/`, 100, ); expect(thumbKeys, "no thumbnail keys for a quarantined file").toEqual([]); // Absent from search: the virus path publishes `file.virus_detected` // (never `file.uploaded`/`file.finalized`), and the search processor // has no event that indexes a non-READY file — so no doc exists. if (searchReady) { expect(await docInSearch(`file:${fileId}`), "no search doc for a quarantined file").toBe(false); } // The quarantined file is never signed: download → 403 // FILE_QUARANTINED + audit (actor = the requester, reason=quarantined); // detail (allowQuarantined) → 200 with NO signed URLs. const dl = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: ws.headers, redirect: "manual", }); expect(dl.status, `quarantined download → ${dl.status}`).toBe(403); expect(errCode(await json(dl))).toBe("FILE_QUARANTINED"); const denied = await app.prisma.auditLog.findFirst({ where: { action: "file.download_denied", resourceId: fileId }, orderBy: { createdAt: "desc" }, }); expect(denied, "file.download_denied audit").not.toBeNull(); expect(denied!.actorId).toBe(ws.userId); expect((denied!.metadata as { reason?: string }).reason).toBe("quarantined"); const detailRes = await app.request("GET", `/api/v1/files/${fileId}`, { headers: ws.headers }); // The detail endpoint returns 200 for a quarantined file (allowQuarantined // = the client renders the blocked card) but with NO signed URLs. expect(detailRes.status, `quarantined detail → ${detailRes.status}`).toBe(200); const detail = (await detailRes.json()) as { downloadUrl?: string; previewUrl?: string; status?: string }; expect(detail.status).toBe("VIRUS_DETECTED"); expect(detail.downloadUrl, "no signed download URL for a quarantined file").toBeUndefined(); expect(detail.previewUrl, "no signed preview URL for a quarantined file").toBeUndefined(); }), 120_000, ); // ── performance guard (suite < 15 min, 500 MB included) ──────────────── it( "PERF: total suite runtime within the 15-minute budget (500 MB upload included)", scenario("perf-15min-budget", async () => { const total = Date.now() - suiteStart; expect( total, `suite took ${Math.round(total / 1000)} s — the 15-minute CI budget is a regression canary`, ).toBeLessThanOrEqual(15 * 60 * 1000); }), 30_000, ); }, );