1
41import { afterAll, beforeAll, describe, expect, it } from "vitest";
42import path from "node:path";
43import os from "node:os";
44import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
45import { fileURLToPath } from "node:url";
46import {
47 S3Client as StorageS3Client,
48 buildFileKey,
49 sanitizeFilename,
50 presignGet,
51 type S3Config as StorageS3Config,
52} from "@factory/storage";
53import { SearchClient } from "@factory/search";
54import { PrismaClient } from "@prisma/client";
55import { Redis } from "ioredis";
56import { createTestApp, type TestAppHandle } from "./helpers/api";
57import { getDatabaseUrl, getRedisUrl } from "./helpers/containers";
58import { authHeaders, createUser, createWorkspace, addMemberViaSeam } from "./helpers/seed";
59import { makeSparse500, SPARSE_500_MB } from "../fixtures/files/make-sparse";
60
61const HERE = path.dirname(fileURLToPath(import.meta.url));
62const REPORT_DIR = path.resolve(HERE, "..", "e2e", "reports");
63const REPORT_PATH = path.join(REPORT_DIR, "files-security.json");
64
65
66
67const { runFilesPipeline } = await import(
68
69 fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "files", "processor.ts"))
70);
71const { S3Client: WorkerS3Client } = await import(
72
73 fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "lib", "s3.ts"))
74);
75const { handleSearchEvent, runReindex } = await import(
76
77 fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "search-index", "processor.ts"))
78);
79
80function fileUrl(p: string): string {
81 return `file://${p.startsWith("/") ? p : `/${p}`}`;
82}
83
84
85
86const S3_ENDPOINT = (process.env.S3_ENDPOINT ?? "http://localhost:9000").replace(/\/+$/, "");
87const SEARCH_URL = process.env.SEARCH_URL ?? "http://localhost:9200";
88const S3_ACCESS = process.env.S3_ACCESS_KEY ?? "minioadmin";
89const S3_SECRET = process.env.S3_SECRET_KEY ?? "minioadmin";
90const S3_REGION = process.env.S3_REGION ?? "us-east-1";
91const FILES_BUCKET = process.env.S3_BUCKET_FILES ?? "factory-files";
92const THUMBS_BUCKET = process.env.S3_BUCKET_THUMBS ?? "factory-thumbs";
93
94async function httpUp(url: string): Promise<boolean> {
95 try {
96 const r = await fetch(url, { signal: AbortSignal.timeout(3_000) });
97 return r.ok;
98 } catch {
99 return false;
100 }
101}
102const minioReady = await httpUp(`${S3_ENDPOINT}/minio/health/live`);
103const searchReady = await httpUp(`${SEARCH_URL}/_cluster/health`);
104
105
106
107
108const PNG_BYTES = new Uint8Array([
109 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
110 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
111 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
112 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
113 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
114 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
115 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
116 0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
117 0x44, 0xae, 0x42, 0x60,
118]);
119const TXT_BYTES = new Uint8Array(
120 Buffer.from("this is not a png — plain ascii text for the spoof/traversal tests", "utf8"),
121);
122
123
124
125let app: TestAppHandle;
126let prisma: PrismaClient;
127let redis: Redis;
128let workerFiles: InstanceType<typeof WorkerS3Client>;
129let workerThumbs: InstanceType<typeof WorkerS3Client>;
130let storageFiles: InstanceType<typeof StorageS3Client>;
131let search: SearchClient | null;
132let env: import("../../apps/worker/src/config").WorkerEnv;
133let tempDir: string;
134
135
136
137interface ScenarioResult {
138 scenario: string;
139 passed: boolean;
140 durationMs: number;
141}
142const scenarioResults: ScenarioResult[] = [];
143const suiteStart = Date.now();
144
145function scenario(id: string, fn: () => Promise<void>): () => Promise<void> {
146 return async () => {
147 const t0 = Date.now();
148 let passed = false;
149 try {
150 await fn();
151 passed = true;
152 } finally {
153 scenarioResults.push({ scenario: id, passed, durationMs: Date.now() - t0 });
154 }
155 };
156}
157
158
159
160const json = async (res: Response): Promise<Record<string, unknown>> =>
161 (await res.json().catch(() => ({}))) as Record<string, unknown>;
162const errCode = (body: Record<string, unknown>): string =>
163 ((body.error as { code?: string } | undefined)?.code ?? "");
164
165interface Ws {
166 headers: Record<string, string>;
167 workspaceId: string;
168 userId: string;
169}
170async function newWorkspace(name = "Sec WS"): Promise<Ws> {
171 const user = await createUser(app.request, { name: "Sec Owner" });
172 const { workspaceId } = await createWorkspace(app.request, authHeaders(user.accessToken!), name);
173 return {
174 headers: { ...authHeaders(user.accessToken!), "x-factory-workspace": workspaceId },
175 workspaceId,
176 userId: user.userId,
177 };
178}
179
180interface Upload {
181 file: { id: string; name: string; status?: string };
182 uploadUrl: string;
183 method: string;
184 headers: Record<string, string>;
185 expiresAt: string;
186}
187async function openUpload(
188 headers: Record<string, string>,
189 body: Record<string, unknown>,
190): Promise<{ status: number; upload?: Upload; raw: Record<string, unknown> }> {
191 const res = await app.request("POST", "/api/v1/files/uploads", { headers, body });
192 const raw = await json(res);
193 if (res.status !== 201) return { status: res.status, raw };
194 return { status: 201, upload: raw.upload as Upload, raw };
195}
196async function putToUrl(url: string, bytes: Uint8Array, headers: Record<string, string>): Promise<Response> {
197 return fetch(url, { method: "PUT", headers, body: bytes as unknown as BodyInit });
198}
199async function complete(headers: Record<string, string>, uploadId: string) {
200 const res = await app.request("PUT", `/api/v1/files/uploads/${uploadId}/complete`, { headers, body: {} });
201 return { status: res.status, body: await json(res) };
202}
203async function fileRow(fileId: string) {
204 return app.prisma.file.findUnique({ where: { id: fileId } });
205}
206
207
211async function runPipeline(fileId: string, version: number, workspaceId: string) {
212 return runFilesPipeline(
213 { prisma, s3: workerFiles, s3Thumbs: workerThumbs, redis, env },
214 { fileId, version, workspaceId },
215 );
216}
217async function toReady(fileId: string, version: number, workspaceId: string): Promise<void> {
218 const res = await runPipeline(fileId, version, workspaceId);
219 expect(res.kind, `pipeline ended ${res.kind}`).toBe("ready");
220 const row = await fileRow(fileId);
221 expect(row?.status).toBe("READY");
222}
223
224
225async function uploadToReady(
226 headers: Record<string, string>,
227 name: string,
228 mimeType: string,
229 bytes: Uint8Array,
230 extra: Record<string, unknown> = {},
231): Promise<string> {
232 const { status, upload } = await openUpload(headers, { name, mimeType, size: bytes.length, ...extra });
233 expect(status, `session: ${JSON.stringify(extra)}`).toBe(201);
234 const put = await putToUrl(upload!.uploadUrl, bytes, upload!.headers);
235 expect(put.status).toBe(200);
236 const c = await complete(headers, upload!.file.id);
237 expect(c.status).toBe(200);
238 await toReady(upload!.file.id, 1, headers["x-factory-workspace"]!);
239 return upload!.file.id;
240}
241
242
243async function keysIn(prefix: string): Promise<string[]> {
244 const { keys } = await storageFiles.listObjects(prefix, 1000);
245 return keys;
246}
247
248
250async function assertAudit(action: string, resourceId: string, actorId?: string): Promise<void> {
251 const row = await app.prisma.auditLog.findFirst({
252 where: { action, resourceId },
253 orderBy: { createdAt: "desc" },
254 });
255 expect(row, `audit row ${action} for ${resourceId}`).not.toBeNull();
256 expect(row!.actorId, `${action}: actorId`).toBeTruthy();
257 expect(row!.actorId).not.toBe("system");
258 if (actorId) expect(row!.actorId, `${action}: actor is the requesting user`).toBe(actorId);
259 expect(row!.ipAddress, `${action}: ip present`).toBeTruthy();
260}
261
262async function searchEvent(event: string, payload: Record<string, unknown>): Promise<void> {
263 if (!search) return;
264 await handleSearchEvent({ search, prisma, connection: redis, env }, { event, payload });
265
266
267 await search.refresh();
268}
269async function docInSearch(docId: string): Promise<boolean> {
270 if (!search) return false;
271 const present = await search.docExists([docId]);
272 return present.has(docId);
273}
274function searchOps() {
275 return { search: search!, prisma, connection: redis, env };
276}
277
278
280function mintSignedGet(key: string, expiresSec: number, now = new Date()): string {
281 const config: StorageS3Config = {
282 endpoint: S3_ENDPOINT,
283 region: S3_REGION,
284 bucket: FILES_BUCKET,
285 accessKeyId: S3_ACCESS,
286 secretAccessKey: S3_SECRET,
287 forcePathStyle: true,
288 };
289 return presignGet({ config, key, expiresSec, now, responseContentDisposition: `attachment; filename="x"` }).url;
290}
291
292
297function hostileNames(count: number): string[] {
298 const base = [
299 "../../../etc/cron.d/x",
300 "..\\..\\win.ini",
301 "a/../../b.png",
302 "bad\x00name.png",
303 ];
304
305 let a = 0x9e3779b9;
306 const rnd = () => {
307 a |= 0;
308 a = (a + 0x6d2b79f5) | 0;
309 let t = Math.imul(a ^ (a >>> 15), 1 | a);
310 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
311 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
312 };
313 const pick = <T>(arr: readonly T[]): T => arr[Math.floor(rnd() * arr.length)]!;
314 const dirs = ["..", ".", "/", "\\", "C:\\", "etc", "tmp", "\\\\server\\share"];
315 const words = ["win", "ini", "passwd", "payload", "secret", "data", "cron"];
316 const exts = [".png", ".txt", ".bin", "", ".", "..", ".PNG", ".dmp", ".tmp"];
317 const controls = ["", "\x00", "\x01\x02", "\r\n", "\t", "\x7f", "\u00e9\u00fc", "\x1b[31m"];
318 const out: string[] = [...base];
319 while (out.length < count) {
320 const depth = 1 + Math.floor(rnd() * 5);
321 let n = "";
322 for (let d = 0; d < depth; d++) n += pick(dirs);
323 n += pick(words) + pick(controls);
324 if (rnd() < 0.5) n += pick(["/", "\\"]);
325 n += pick(words) + pick(controls) + pick(exts);
326 if (!out.includes(n)) out.push(n);
327 }
328 return out.slice(0, count);
329}
330
331
336function assertSafeKey(key: string, workspaceId: string, name: string): void {
337 expect(key.startsWith(`files/${workspaceId}/`), `key escapes the workspace prefix: ${name} → ${key}`).toBe(true);
338 expect(key.includes("\x00"), `key contains NUL: ${name} → ${key}`).toBe(false);
339 expect(key.startsWith("/"), `key is absolute: ${name} → ${key}`).toBe(false);
340 expect(key.includes("\\"), `key contains backslash: ${name} → ${key}`).toBe(false);
341 const tail = key.slice(`files/${workspaceId}/`.length);
342 const segs = tail.split("/");
343 expect(segs.length, `key segment count for ${name}`).toBe(3);
344 for (const s of segs) {
345 expect(s, `empty segment for ${name}`).not.toBe("");
346 expect(s, `traversal segment for ${name} → ${key}`).not.toBe("..");
347 expect(s, `self-reference segment for ${name} → ${key}`).not.toBe(".");
348 expect(/[\u0000-\u001f\u007f]/.test(s), `control char in segment for ${name} → ${key}`).toBe(false);
349 }
350 const nameSeg = segs[2]!;
351 expect(nameSeg.length).toBeGreaterThan(0);
352 expect(nameSeg.length).toBeLessThanOrEqual(180);
353}
354
355
356
357describe.skipIf(!minioReady)(
358 "Files security integration (TASK-0105 — 500MB, MIME spoof, traversal, unauthorized, signed URLs)",
359 () => {
360 beforeAll(async () => {
361
362 app = await createTestApp();
363 const [dbUrl, redisUrl] = await Promise.all([getDatabaseUrl(), getRedisUrl()]);
364 prisma = new PrismaClient({ datasources: { db: { url: dbUrl } } });
365 redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
366 const cfg = { endpoint: S3_ENDPOINT, region: S3_REGION, accessKey: S3_ACCESS, secretKey: S3_SECRET };
367 workerFiles = new WorkerS3Client({ ...cfg, bucket: FILES_BUCKET });
368 workerThumbs = new WorkerS3Client({ ...cfg, bucket: THUMBS_BUCKET });
369 const scfg: StorageS3Config = {
370 endpoint: S3_ENDPOINT,
371 region: S3_REGION,
372 bucket: FILES_BUCKET,
373 accessKeyId: S3_ACCESS,
374 secretAccessKey: S3_SECRET,
375 forcePathStyle: true,
376 };
377 storageFiles = new StorageS3Client({ config: scfg });
378 if (searchReady) search = new SearchClient({ baseUrl: SEARCH_URL });
379 tempDir = await mkdtemp(path.join(os.tmpdir(), "factory-files-sec-it-"));
380
381 env = {
382 NODE_ENV: "test",
383 REDIS_URL: "ignored",
384 DATABASE_URL: "ignored",
385 S3_ENDPOINT,
386 S3_REGION,
387 S3_BUCKET_FILES: FILES_BUCKET,
388 S3_BUCKET_THUMBS: THUMBS_BUCKET,
389 S3_BUCKET: FILES_BUCKET,
390 S3_ACCESS_KEY: S3_ACCESS,
391 S3_SECRET_KEY: S3_SECRET,
392 S3_PUBLIC_BASE: "http://localhost:9001",
393 SEARCH_URL,
394 SEARCH_USERNAME: "admin",
395 SEARCH_PASSWORD: "changeme",
396 SEARCH_VECTOR_DIMENSION: 1536,
397 SEARCH_KNN_M: 16,
398 SEARCH_KNN_EF_CONSTRUCTION: 100,
399 MASTER_ENCRYPTION_KEY: "",
400 AI_API_KEY: "",
401 AI_PROVIDER: "openai",
402 AI_BASE_URL: "",
403 AI_MODEL: "gpt-4o-mini",
404 AI_EMBEDDING_PROVIDER: "",
405 AI_EMBEDDING_MODEL: "text-embedding-3-small",
406 SMTP_HOST: "",
407 SMTP_PORT: 587,
408 SMTP_USER: "",
409 SMTP_PASS: "",
410 SMTP_FROM: "noreply@factory.local",
411 SMTP_STARTTLS: true,
412 CLAMAV_HOST: "",
413 CLAMAV_PORT: 3310,
414 CLAMD_SOCKET: "",
415 FILES_WORKER_CONCURRENCY: 4,
416 CLAMAV_TIMEOUT_MS: 30_000,
417 FILES_TEMP_DIR: tempDir,
418 FILES_TEMP_MAX_AGE_MS: 600_000,
419 FILES_TEMP_FREE_MIN_BYTES: 1024 ** 3,
420 PUBLIC_URL: "http://localhost:3000",
421 WORKER_CONCURRENCY: 10,
422 WORKER_ID: "files-security-test-worker",
423 WORKER_RETENTION_EXECUTE: false,
424 HEALTH_PORT: 4003,
425 } as unknown as import("../../apps/worker/src/config").WorkerEnv;
426 }, 240_000);
427 afterAll(async () => {
428
429 await mkdir(REPORT_DIR, { recursive: true }).catch(() => {});
430 const report = {
431 suite: "files-security (TASK-0105)",
432 generatedAt: new Date().toISOString(),
433 totalDurationMs: Date.now() - suiteStart,
434 budgetMs: 15 * 60 * 1000,
435 withinBudget: Date.now() - suiteStart <= 15 * 60 * 1000,
436 scenarios: scenarioResults,
437 passed: scenarioResults.length > 0 && scenarioResults.every((s) => s.passed),
438 };
439 await writeFile(REPORT_PATH, JSON.stringify(report, null, 2), "utf8").catch(() => {});
440 await rm(tempDir, { recursive: true, force: true }).catch(() => {});
441 await redis?.quit().catch(() => {});
442 await prisma?.$disconnect().catch(() => {});
443 await app?.close();
444 }, 60_000);
445
446
447
448 it(
449 "S1: 500 MB sparse upload on the paid plan → READY with exact size (multipart-friendly presigned PUT)",
450 scenario("S1-500MB-paid-upload", async () => {
451 const ws = await newWorkspace("500MB WS");
452
453
454 await app.prisma.workspace.update({
455 where: { id: ws.workspaceId },
456 data: { billingPlan: "paid" },
457 });
458
459
460
461 const sparse = await makeSparse500();
462 try {
463 expect(sparse.logicalSize).toBe(SPARSE_500_MB);
464 expect(sparse.allocatedBytes, "sparse file allocates ~one block, not 500 MB").toBeLessThan(1024 * 1024);
465
466
467 const { status, upload } = await openUpload(ws.headers, {
468 name: "big.bin",
469 mimeType: "application/octet-stream",
470 size: SPARSE_500_MB,
471 });
472 expect(status, "the paid plan accepts a 500,000,000-byte session").toBe(201);
473 const fileId = upload!.file.id;
474
475
476
477 const put = await putToUrl(upload!.uploadUrl, sparse.bytes, upload!.headers);
478 expect(put.status, `presigned PUT returned ${put.status}`).toBe(200);
479
480 const c = await complete(ws.headers, fileId);
481 expect(c.status).toBe(200);
482 await toReady(fileId, 1, ws.workspaceId);
483
484
485 const row = (await fileRow(fileId))!;
486 expect(row.size).toBe(SPARSE_500_MB);
487 expect(row.status).toBe("READY");
488 const head = await workerFiles.headObject(row.key);
489 expect(head.size).toBe(SPARSE_500_MB);
490
491 assertSafeKey(row.key, ws.workspaceId, row.name);
492
493 await assertAudit("file.upload_session_created", fileId, ws.userId);
494 const uploaded = await app.prisma.auditLog.findFirst({
495 where: { action: "file.uploaded", resourceId: fileId },
496 });
497 expect(uploaded, "file.uploaded audit").not.toBeNull();
498 expect((uploaded!.metadata as { size?: number }).size).toBe(SPARSE_500_MB);
499 } finally {
500 await rm(sparse.path, { force: true }).catch(() => {});
501 }
502 }),
503 900_000,
504 );
505
506
507
508 it(
509 "S2: MIME spoof (text bytes declared image/png) → MIME_MISMATCH, FAILED, object deleted from the bucket",
510 scenario("S2-mime-spoof-deleted", async () => {
511 const ws = await newWorkspace("Spoof WS");
512
513
514 const { status, upload } = await openUpload(ws.headers, {
515 name: "evil.png",
516 mimeType: "image/png",
517 size: TXT_BYTES.length,
518 });
519 expect(status).toBe(201);
520 const fileId = upload!.file.id;
521 const key = (await fileRow(fileId))!.key;
522 expect(key).toBe(buildFileKey({ workspaceId: ws.workspaceId, fileId, version: 1, name: "evil.png" }));
523
524 const put = await putToUrl(upload!.uploadUrl, TXT_BYTES, upload!.headers);
525 expect(put.status).toBe(200);
526 expect(await keysIn(`files/${ws.workspaceId}/${fileId}/`), "object landed (pre-complete)").toEqual([key]);
527
528
529 const c = await complete(ws.headers, fileId);
530 expect(c.status, `complete returned ${c.status}: ${JSON.stringify(c.body)}`).toBe(415);
531 expect(errCode(c.body)).toBe("MIME_MISMATCH");
532
533
534
535
536 const row = (await fileRow(fileId))!;
537 expect(row.status).toBe("FAILED");
538 expect(row.metadata?.pipeline).toMatchObject({ error: "mime_mismatch" });
539 expect(await keysIn(`files/${ws.workspaceId}/${fileId}/`), "object deleted from the bucket").toEqual([]);
540
541 await assertAudit("file.mime_mismatch", fileId, ws.userId);
542
543 const detail = await app.request("GET", `/api/v1/files/${fileId}`, { headers: ws.headers });
544 expect(detail.status).toBe(200);
545 expect((await detail.json()) as Record<string, unknown>).not.toHaveProperty("key");
546 }),
547 120_000,
548 );
549
550
551
552 it(
553 "S3: 200 malicious names → 100% keys within files/{ws}/ (no .., NUL, absolute paths) + real uploads",
554 scenario("S3-traversal-200-names", async () => {
555 const ws = await newWorkspace("Traversal WS");
556 const fileId = "fil_trav_test";
557
558
559
560
561
562
563
564
565
566
567 const names = hostileNames(200);
568 expect(names).toHaveLength(200);
569 let recognized = 0;
570 for (const name of names) {
571 const key = buildFileKey({ workspaceId: ws.workspaceId, fileId, version: 1, name });
572 assertSafeKey(key, ws.workspaceId, name);
573 const lastSeg = key.split("/").pop()!;
574 const lastSep = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\"));
575 const basename = lastSep === -1 ? name : name.slice(lastSep + 1);
576 const runs = basename.match(/[A-Za-z0-9]+/g) ?? [];
577 const longest = runs.reduce((m, r) => (r.length > m.length ? r : m), "");
578 const ok =
579 longest.length >= 3
580 ? lastSeg.includes(longest)
581 : lastSeg === sanitizeFilename(basename);
582 if (ok) recognized++;
583 }
584 expect(recognized, "every name stays recognizable as its basename in the key").toBe(200);
585
586
587 expect(buildFileKey({ workspaceId: "w1", fileId: "f1", version: 1, name: "..\\..\\win.ini" })).toBe(
588 "files/w1/f1/1/win.ini",
589 );
590 expect(buildFileKey({ workspaceId: "w1", fileId: "f1", version: 1, name: "a/../../b.png" })).toBe(
591 "files/w1/f1/1/b.png",
592 );
593 const nulKey = buildFileKey({ workspaceId: "w1", fileId: "f1", version: 1, name: "bad\x00name.png" });
594 expect(nulKey).toBe("files/w1/f1/1/bad_name.png");
595 expect(nulKey).not.toContain("\x00");
596
597
598
599
600 const hostile = ["../../../etc/cron.d/x.png", "C:\\..\\..\\win.ini.png"];
601 for (let i = 0; i < hostile.length; i++) {
602 const name = hostile[i]!;
603 const { status, upload } = await openUpload(ws.headers, {
604 name,
605 mimeType: "image/png",
606 size: PNG_BYTES.length,
607 });
608 expect(status, `session for hostile name ${JSON.stringify(name)}`).toBe(201);
609 const fId = upload!.file.id;
610 await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers);
611 const c = await complete(ws.headers, fId);
612 expect(c.status).toBe(200);
613 await toReady(fId, 1, ws.workspaceId);
614
615 const row = (await fileRow(fId))!;
616
617 expect(row.key).toBe(buildFileKey({ workspaceId: ws.workspaceId, fileId: fId, version: 1, name }));
618 assertSafeKey(row.key, ws.workspaceId, name);
619
620 expect(await keysIn(`files/${ws.workspaceId}/${fId}/`)).toEqual([row.key]);
621
622 expect(row.name).toBe(name);
623 const detail = (await json(
624 await app.request("GET", `/api/v1/files/${fId}`, { headers: ws.headers }),
625 )) as { name?: string };
626 expect(detail.name).toBe(name);
627 }
628 }),
629 180_000,
630 );
631
632
633
634 it(
635 "S4: unauthorized matrix — 6 cases with audit (non-member, page-scoped, grant, revoke, expiry, key-binding)",
636 scenario("S4-unauthorized-matrix", async () => {
637 const ws = await newWorkspace("Matrix WS");
638
639
640 const page = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, {
641 headers: ws.headers,
642 body: { title: "Matrix Page", parentId: null },
643 });
644 expect(page.status).toBe(201);
645 const pageId = ((await page.json()) as { id: string }).id;
646
647 const fileId = await uploadToReady(
648 { ...ws.headers },
649 "matrix.png",
650 "image/png",
651 PNG_BYTES,
652 { pageId },
653 );
654 const ownerHeaders = ws.headers;
655 if (searchReady) {
656 await searchEvent("file.uploaded", { workspaceId: ws.workspaceId, fileId, pageId });
657 expect(await docInSearch(`file:${fileId}`)).toBe(true);
658 }
659
660 const stranger = await createUser(app.request, { name: "Stranger" });
661 const strangerHeaders = { ...authHeaders(stranger.accessToken!), "x-factory-workspace": ws.workspaceId };
662 const member = await createUser(app.request, { name: "Member" });
663 await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER");
664 const memberHeaders = { ...authHeaders(member.accessToken!), "x-factory-workspace": ws.workspaceId };
665
666
667
668
669
670
671 await prisma.permission.create({
672 data: {
673 workspaceId: ws.workspaceId,
674 granteeType: "user",
675 granteeId: member.userId,
676 resourceType: "page",
677 resourceId: pageId,
678 level: "NONE",
679 },
680 });
681
682
683 const a = await app.request("GET", `/api/v1/files/${fileId}`, { headers: strangerHeaders });
684 expect(a.status).toBe(403);
685 expect(errCode(await json(a))).toBe("FORBIDDEN");
686
687
688 const b = await app.request("GET", `/api/v1/files/${fileId}/download`, {
689 headers: strangerHeaders,
690 redirect: "manual",
691 });
692 expect(b.status).toBe(403);
693 await assertAudit("file.download_denied", fileId, stranger.userId);
694
695
696
697 const c = await app.request("GET", `/api/v1/files/${fileId}`, { headers: memberHeaders });
698 expect(c.status, "page NONE hard-stops the member's inherited EDIT").toBe(403);
699 const c2 = await app.request("GET", `/api/v1/files/${fileId}/download`, {
700 headers: memberHeaders,
701 redirect: "manual",
702 });
703 expect(c2.status).toBe(403);
704 const cAudit = await app.prisma.auditLog.findFirst({
705 where: { action: "file.download_denied", resourceId: fileId },
706 orderBy: { createdAt: "desc" },
707 });
708 expect(cAudit?.actorId).toBe(member.userId);
709
710
711
712
713 const putGrant = await app.request("PUT", `/api/v1/files/${fileId}/permissions`, {
714 headers: ownerHeaders,
715 body: { grants: [{ granteeType: "user", granteeId: stranger.userId, level: "VIEW" }] },
716 });
717 expect(putGrant.status).toBe(200);
718 const d = await app.request("GET", `/api/v1/files/${fileId}`, { headers: strangerHeaders });
719 expect(d.status, "file VIEW grant lets the non-member read").toBe(200);
720 const d2 = await app.request("GET", `/api/v1/files/${fileId}/download`, {
721 headers: strangerHeaders,
722 redirect: "manual",
723 });
724 expect(d2.status).toBe(302);
725 expect(d2.headers.get("location")).toContain("X-Amz-Signature=");
726 await assertAudit("file.downloaded", fileId, stranger.userId);
727
728
729
730
731 const putRevoke = await app.request("PUT", `/api/v1/files/${fileId}/permissions`, {
732 headers: ownerHeaders,
733 body: { grants: [] },
734 });
735 expect(putRevoke.status).toBe(200);
736 const e = await app.request("GET", `/api/v1/files/${fileId}`, { headers: strangerHeaders });
737 expect(e.status).toBe(403);
738 const e2 = await app.request("GET", `/api/v1/files/${fileId}/download`, {
739 headers: strangerHeaders,
740 redirect: "manual",
741 });
742 expect(e2.status).toBe(403);
743 const eAudit = await app.prisma.auditLog.findFirst({
744 where: { action: "file.download_denied", resourceId: fileId },
745 orderBy: { createdAt: "desc" },
746 });
747 expect(eAudit?.actorId, "revoked download denial audited to the requester").toBe(stranger.userId);
748 if (searchReady) {
749
750
751 await runReindex(searchOps(), ws.workspaceId);
752 await search.refresh();
753 expect(await docInSearch(`file:${fileId}`), "search doc survives the revoke (read-time model)").toBe(true);
754 }
755
756
757
758 const row = (await fileRow(fileId))!;
759
760 const fresh = mintSignedGet(row.key, 900);
761 const f0 = await fetch(fresh);
762 expect(f0.status).toBe(200);
763
764 const expired = mintSignedGet(row.key, 900, new Date(Date.now() - 901_000));
765 const f1 = await fetch(expired);
766 expect(f1.status, `expired URL → ${f1.status}`).toBe(403);
767 expect(await f1.text()).toMatch(/AccessDenied|ExpiredToken|SignatureDoesNotMatch/i);
768
769
770
771
772
773 const bFileId = await uploadToReady(ownerHeaders, "other.png", "image/png", PNG_BYTES, { pageId: null });
774 const bRow = (await fileRow(bFileId))!;
775 const aUrl = mintSignedGet(row.key, 900);
776 const cross = aUrl.replace(`/${row.key}?`, `/${bRow.key}?`);
777 expect(cross, "the path now carries file B's key").not.toBe(aUrl);
778 const f2 = await fetch(cross);
779 expect(f2.status, `cross-key URL → ${f2.status}`).toBe(403);
780 expect(await f2.text()).toMatch(/SignatureDoesNotMatch/i);
781
782 const f3 = await fetch(mintSignedGet(bRow.key, 900));
783 expect(f3.status).toBe(200);
784 }),
785 300_000,
786 );
787
788
789
790 it(
791 "S5: EICAR → VIRUS_DETECTED (ClamAV socket), no thumbs, no search doc, download 403 FILE_QUARANTINED + audit, detail 200 unsigned",
792 scenario("S5-eicar-quarantine", async () => {
793
794
795 const EICAR = Buffer.from(
796 "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*",
797 "latin1",
798 );
799 const ws = await newWorkspace("EICAR WS");
800 const { status, upload } = await openUpload(ws.headers, {
801 name: "eicar.txt",
802 mimeType: "text/plain",
803 size: EICAR.length,
804 });
805 expect(status).toBe(201);
806 const fileId = upload!.file.id;
807 await putToUrl(upload!.uploadUrl, new Uint8Array(EICAR), upload!.headers);
808 const c = await complete(ws.headers, fileId);
809 expect(c.status).toBe(200);
810
811
812
813
814 const clamEnv = {
815 ...env,
816 CLAMD_SOCKET: "/var/run/clamav/clamd.ctl",
817 CLAMAV_HOST: "",
818 CLAMAV_PORT: 3310,
819 } as unknown as typeof env;
820 const res = await runFilesPipeline(
821 { prisma, s3: workerFiles, s3Thumbs: workerThumbs, redis, env: clamEnv },
822 { fileId, version: 1, workspaceId: ws.workspaceId },
823 );
824 expect(res.kind, `pipeline ended ${res.kind}`).toBe("virus");
825
826
827
828
829 const row = (await fileRow(fileId))!;
830 expect(row.status).toBe("VIRUS_DETECTED");
831 const scan = (row.scanReport as Record<string, unknown> | null) ?? undefined;
832 expect(scan, "scanReport recorded (top-level File column)").toBeTruthy();
833 expect(scan?.engine).toBe("clamav");
834 expect(scan?.infected).toBe(true);
835 expect(String(scan?.signature ?? "")).toMatch(/Eicar/i);
836 expect(row.metadata).toMatchObject({ pipeline: { error: "virus_detected" } });
837
838
839 const virusAudit = await app.prisma.auditLog.findFirst({
840 where: { action: "file.virus_detected", resourceId: fileId },
841 });
842 expect(virusAudit, "file.virus_detected audit").not.toBeNull();
843
844
845
846
847 const thumbCfg = {
848 endpoint: S3_ENDPOINT,
849 region: S3_REGION,
850 bucket: THUMBS_BUCKET,
851 accessKeyId: S3_ACCESS,
852 secretAccessKey: S3_SECRET,
853 forcePathStyle: true,
854 } as StorageS3Config;
855 const thumbsClient = new StorageS3Client({ config: thumbCfg });
856 const { keys: thumbKeys } = await thumbsClient.listObjects(
857 `thumbs/${ws.workspaceId}/${fileId}/`,
858 100,
859 );
860 expect(thumbKeys, "no thumbnail keys for a quarantined file").toEqual([]);
861
862
863
864
865 if (searchReady) {
866 expect(await docInSearch(`file:${fileId}`), "no search doc for a quarantined file").toBe(false);
867 }
868
869
870
871
872 const dl = await app.request("GET", `/api/v1/files/${fileId}/download`, {
873 headers: ws.headers,
874 redirect: "manual",
875 });
876 expect(dl.status, `quarantined download → ${dl.status}`).toBe(403);
877 expect(errCode(await json(dl))).toBe("FILE_QUARANTINED");
878 const denied = await app.prisma.auditLog.findFirst({
879 where: { action: "file.download_denied", resourceId: fileId },
880 orderBy: { createdAt: "desc" },
881 });
882 expect(denied, "file.download_denied audit").not.toBeNull();
883 expect(denied!.actorId).toBe(ws.userId);
884 expect((denied!.metadata as { reason?: string }).reason).toBe("quarantined");
885 const detailRes = await app.request("GET", `/api/v1/files/${fileId}`, { headers: ws.headers });
886
887
888 expect(detailRes.status, `quarantined detail → ${detailRes.status}`).toBe(200);
889 const detail = (await detailRes.json()) as { downloadUrl?: string; previewUrl?: string; status?: string };
890 expect(detail.status).toBe("VIRUS_DETECTED");
891 expect(detail.downloadUrl, "no signed download URL for a quarantined file").toBeUndefined();
892 expect(detail.previewUrl, "no signed preview URL for a quarantined file").toBeUndefined();
893 }),
894 120_000,
895 );
896
897
898
899 it(
900 "PERF: total suite runtime within the 15-minute budget (500 MB upload included)",
901 scenario("perf-15min-budget", async () => {
902 const total = Date.now() - suiteStart;
903 expect(
904 total,
905 `suite took ${Math.round(total / 1000)} s — the 15-minute CI budget is a regression canary`,
906 ).toBeLessThanOrEqual(15 * 60 * 1000);
907 }),
908 30_000,
909 );
910 },
911);
912