1
40import { afterAll, beforeAll, describe, expect, it } from "vitest";
41import path from "node:path";
42import { fileURLToPath, pathToFileURL } from "node:url";
43import { S3Client, type S3Config } from "@factory/storage";
44import { createTestApp, type TestAppHandle } from "./helpers/api";
45import { authHeaders, createWorkspace, createUser } from "./helpers/seed";
46
47const HERE = path.dirname(fileURLToPath(import.meta.url));
48
49
50
51
52
53const { resetEnvCache } = await import(
54
55 pathToFileURL(path.resolve(HERE, "..", "..", "apps", "api", "src", "config", "env.ts")).href
56);
57
58
59
60
61const S3_ENDPOINT = (process.env.S3_ENDPOINT ?? "http://localhost:9000").replace(/\/+$/, "");
62
63async function minioUp(url: string): Promise<boolean> {
64 try {
65 const res = await fetch(`${url}/minio/health/live`, { signal: AbortSignal.timeout(4_000) });
66 return res.ok;
67 } catch {
68 return false;
69 }
70}
71
72
74const minioReady = await minioUp(S3_ENDPOINT);
75
76
77
78const filesConfig: S3Config = {
79 endpoint: S3_ENDPOINT,
80 region: process.env.S3_REGION ?? "us-east-1",
81 bucket: process.env.S3_BUCKET_FILES ?? "factory-files",
82 accessKeyId: process.env.S3_ACCESS_KEY ?? "minioadmin",
83 secretAccessKey: process.env.S3_SECRET_KEY ?? "minioadmin",
84 forcePathStyle: true,
85};
86const thumbsConfig: S3Config = { ...filesConfig, bucket: process.env.S3_BUCKET_THUMBS ?? "factory-thumbs" };
87const filesClient = new S3Client({ config: filesConfig });
88
89
90
91
92const PNG_BYTES = new Uint8Array([
93 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
94 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
95 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
96 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
97 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
98 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
99 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
100 0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
101 0x44, 0xae, 0x42, 0x60,
102]);
103
104
105const TEXT_BYTES = new TextEncoder().encode(
106 "this is definitely a text file, not a png ".repeat(16).slice(0, 1024),
107);
108
109const FREE_LIMIT = 50 * 1024 * 1024;
110const OVER_FREE = 60 * 1024 * 1024;
111
112
113
114let app: TestAppHandle;
115
116const json = async (res: Response): Promise<Record<string, unknown>> =>
117 (await res.json().catch(() => ({}))) as Record<string, unknown>;
118
119const errCode = (body: Record<string, unknown>): string =>
120 ((body.error as { code?: string } | undefined)?.code ?? "");
121
122
130async function newWorkspace(name = "Files WS"): Promise<{
131 headers: Record<string, string>;
132 workspaceId: string;
133 userId: string;
134}> {
135 const user = await createUser(app.request, { name: "File Owner" });
136 const { workspaceId } = await createWorkspace(app.request, authHeaders(user.accessToken!), name);
137 return {
138 headers: { ...authHeaders(user.accessToken!), "x-factory-workspace": workspaceId },
139 workspaceId,
140 userId: user.userId,
141 };
142}
143
144
146async function setPlan(workspaceId: string, plan: string): Promise<void> {
147 await app.prisma.workspace.update({ where: { id: workspaceId }, data: { billingPlan: plan } });
148}
149
150interface Upload {
151 file: { id: string; name: string; status?: string };
152 uploadUrl: string;
153 method: string;
154 headers: Record<string, string>;
155 expiresAt: string;
156}
157
158
159async function openUpload(
160 headers: Record<string, string>,
161 body: Record<string, unknown>,
162): Promise<{ status: number; upload?: Upload; raw: Record<string, unknown> }> {
163 const res = await app.request("POST", "/api/v1/files/uploads", { headers, body });
164 const raw = await json(res);
165 if (res.status !== 201) return { status: res.status, raw };
166 return { status: 201, upload: raw.upload as Upload, raw };
167}
168
169
171async function putToUrl(url: string, bytes: Uint8Array, headers: Record<string, string>): Promise<Response> {
172 return fetch(url, {
173 method: "PUT",
174 headers: headers as Record<string, string>,
175 body: bytes as unknown as BodyInit,
176 });
177}
178
179async function complete(headers: Record<string, string>, uploadId: string): Promise<{ status: number; body: Record<string, unknown> }> {
180 const res = await app.request("PUT", `/api/v1/files/uploads/${uploadId}/complete`, { headers, body: {} });
181 return { status: res.status, body: await json(res) };
182}
183
184async function fileRow(fileId: string) {
185 return app.prisma.file.findUnique({ where: { id: fileId } });
186}
187
188async function cancel(headers: Record<string, string>, uploadId: string): Promise<number> {
189 const res = await app.request("POST", `/api/v1/files/uploads/${uploadId}/cancel`, { headers, body: {} });
190 return res.status;
191}
192
193
194async function objectExists(key: string): Promise<boolean> {
195 const { keys } = await filesClient.listObjects(key, 5);
196 return keys.includes(key);
197}
198
199
200async function uploadPng(headers: Record<string, string>, name = "pixel.png"): Promise<string> {
201 const { status, upload } = await openUpload(headers, {
202 name,
203 mimeType: "image/png",
204 size: PNG_BYTES.length,
205 });
206 expect(status).toBe(201);
207 const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers);
208 expect(put.status).toBe(200);
209 const c = await complete(headers, upload!.file.id);
210 expect(c.status).toBe(200);
211 return upload!.file.id;
212}
213
214describe.skipIf(!minioReady)(
215 "Files integration (TASK-0101 — storage contract)",
216 () => {
217 beforeAll(async () => {
218 app = await createTestApp();
219
220
221 }, 180_000);
222 afterAll(async () => {
223 await app?.close();
224 });
225
226
227
228 it("A: 60 MB session → 413 FILE_TOO_LARGE (free), 201 (paid) — plan boundary proven", async () => {
229 const { headers, workspaceId } = await newWorkspace("Plan Boundary WS");
230
231 const free = await openUpload(headers, {
232 name: "big.bin",
233 mimeType: "application/octet-stream",
234 size: OVER_FREE,
235 });
236 expect(free.status).toBe(413);
237 expect(errCode(free.raw)).toBe("FILE_TOO_LARGE");
238 const details = (free.raw.error as { details?: { maxBytes?: number }[] } | undefined)?.details ?? [];
239 expect(details[0]?.maxBytes).toBe(FREE_LIMIT);
240
241 const row = await app.prisma.file.findFirst({ where: { workspaceId, name: "big.bin" } });
242 expect(row).toBeNull();
243
244 await setPlan(workspaceId, "paid");
245 const paid = await openUpload(headers, {
246 name: "big.bin",
247 mimeType: "application/octet-stream",
248 size: OVER_FREE,
249 });
250 expect(paid.status).toBe(201);
251 expect(paid.upload?.file.name).toBe("big.bin");
252 await cancel(headers, paid.upload!.file.id);
253 });
254
255
256
257 it("B: PUT smaller than declared → 422 UPLOAD_INCOMPLETE; row never READY", async () => {
258 const { headers } = await newWorkspace();
259 const { status, upload } = await openUpload(headers, {
260 name: "declared-10kb.png",
261 mimeType: "image/png",
262 size: PNG_BYTES.length + 5_000,
263 });
264 expect(status).toBe(201);
265 const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers);
266 expect(put.status).toBe(200);
267
268 const c = await complete(headers, upload!.file.id);
269 expect(c.status).toBe(422);
270 expect(errCode(c.body)).toBe("UPLOAD_INCOMPLETE");
271
272 const row = await fileRow(upload!.file.id);
273 expect(row?.status).toBe("UPLOADING");
274 });
275
276
277
278 it("C: PUT with a WRONG Content-Type → S3 403 (signature) → 410 UPLOAD_EXPIRED; object never lands", async () => {
279 const { headers } = await newWorkspace();
280 const { status, upload } = await openUpload(headers, {
281 name: "ct.png",
282 mimeType: "image/png",
283 size: PNG_BYTES.length,
284 });
285 expect(status).toBe(201);
286
287
288
289 const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, {
290 ...upload!.headers,
291 "Content-Type": "text/plain",
292 });
293 expect(put.status).toBe(403);
294
295 const key = (await fileRow(upload!.file.id))!.key;
296 expect(await objectExists(key)).toBe(false);
297
298 const c = await complete(headers, upload!.file.id);
299
300
301
302
303 expect(c.status).toBe(410);
304 expect(errCode(c.body)).toBe("UPLOAD_EXPIRED");
305 });
306
307
308
309 it("D: text bytes claimed image/png → 415 MIME_MISMATCH; object DELETED from bucket; row FAILED", async () => {
310 const { headers } = await newWorkspace();
311 const { status, upload } = await openUpload(headers, {
312 name: "really-text.png",
313 mimeType: "image/png",
314 size: TEXT_BYTES.length,
315 });
316 expect(status).toBe(201);
317
318 const put = await putToUrl(upload!.uploadUrl, TEXT_BYTES, upload!.headers);
319 expect(put.status).toBe(200);
320
321 const c = await complete(headers, upload!.file.id);
322 expect(c.status).toBe(415);
323 expect(errCode(c.body)).toBe("MIME_MISMATCH");
324
325 const row = await fileRow(upload!.file.id);
326 expect(await objectExists(row!.key)).toBe(false);
327 expect(row?.status).toBe("FAILED");
328 const scan = (row?.scanReport ?? {}) as Record<string, unknown>;
329 expect(scan.result).toBe("mismatch");
330
331
332 const audit = await app.prisma.auditLog.findFirst({
333 where: { action: "file.mime_mismatch", resourceId: upload!.file.id },
334 });
335 expect(audit).not.toBeNull();
336 });
337
338
339
340 it("E: 1 KB PNG claimed image/png → SCANNING + file.uploaded audit + files/{ws}/{fileId}/1/{name} key", async () => {
341 const { headers } = await newWorkspace();
342 const { status, upload } = await openUpload(headers, {
343 name: "pixel.png",
344 mimeType: "image/png",
345 size: PNG_BYTES.length,
346 });
347 expect(status).toBe(201);
348
349
350 expect(upload!.method).toBe("PUT");
351 expect(upload!.headers["Content-Type"]).toBe("image/png");
352 expect(new URL(upload!.uploadUrl).searchParams.get("X-Amz-Expires")).toBe("900");
353
354 const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers);
355 expect(put.status).toBe(200);
356
357 const c = await complete(headers, upload!.file.id);
358 expect(c.status).toBe(200);
359 expect((c.body as { status: string }).status).toBe("SCANNING");
360
361 const row = await fileRow(upload!.file.id);
362 expect(row?.status).toBe("SCANNING");
363 expect(row?.size).toBe(PNG_BYTES.length);
364 expect(await objectExists(row!.key)).toBe(true);
365 expect(row!.key).toBe(`files/${row!.workspaceId}/${row!.id}/1/pixel.png`);
366
367 const audit = await app.prisma.auditLog.findFirst({
368 where: { action: "file.uploaded", resourceId: upload!.file.id },
369 });
370 expect(audit).not.toBeNull();
371 expect((audit?.metadata as Record<string, unknown>).size).toBe(PNG_BYTES.length);
372 });
373
374
375
376 it("F: member 302→signed GET works; tampered sig 403; foreign key 403; non-member 403 + audit", async () => {
377 const owner = await newWorkspace("Signed GET WS");
378
379 const stranger = await createUser(app.request, { name: "Outsider" });
380 const strangerHeaders = authHeaders(stranger.accessToken!);
381
382 const fileId = await uploadPng(owner.headers, "signed.png");
383
384
385
386
387
388 const dl = await app.request("GET", `/api/v1/files/${fileId}/download`, {
389 headers: owner.headers,
390 redirect: "manual",
391 });
392 expect(dl.status).toBe(302);
393 const location = dl.headers.get("location")!;
394 expect(location).toContain("X-Amz-Signature=");
395 expect(location).toContain("X-Amz-Expires=900");
396
397 const got = await fetch(location, { redirect: "manual" });
398 expect(got.status).toBe(200);
399 const body = new Uint8Array(await got.arrayBuffer());
400 expect(body.length).toBe(PNG_BYTES.length);
401 expect(body[0]).toBe(0x89);
402
403
404 const tampered = location.replace(/X-Amz-Signature=[0-9a-f]+/, `X-Amz-Signature=${"0".repeat(64)}`);
405 const bad = await fetch(tampered, { redirect: "manual" });
406 expect(bad.status).toBe(403);
407
408
409
410 const otherKey = (await fileRow(fileId))!.key;
411 const second = await uploadPng(owner.headers, "second.png");
412 const secondKey = (await fileRow(second))!.key;
413 expect(secondKey).not.toBe(otherKey);
414 const u = new URL(location);
415
416
417 u.pathname = u.pathname.replace(otherKey, secondKey);
418 const cross = await fetch(u.toString(), { redirect: "manual" });
419 expect(cross.status).toBe(403);
420
421
422 const denied = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: strangerHeaders });
423 expect(denied.status).toBe(403);
424 expect(errCode(await json(denied))).toBe("FORBIDDEN");
425 const audit = await app.prisma.auditLog.findFirst({
426 where: { action: "file.download_denied", resourceId: fileId },
427 });
428 expect(audit).not.toBeNull();
429 expect(audit?.actorId).toBe(stranger.userId);
430 });
431
432
433
434 it("G: PUT after the signed window closes → S3 403 → completion 410 UPLOAD_EXPIRED", async () => {
435
436
437
438
439 const prev = process.env.S3_SIGN_EXPIRY_SECONDS;
440 process.env.S3_SIGN_EXPIRY_SECONDS = "2";
441 resetEnvCache();
442 try {
443 const { headers } = await newWorkspace("Expiry WS");
444 const { upload } = (await openUpload(headers, {
445 name: "expiring.png",
446 mimeType: "image/png",
447 size: PNG_BYTES.length,
448 })) as { upload?: Upload };
449 expect(new URL(upload!.uploadUrl).searchParams.get("X-Amz-Expires")).toBe("2");
450
451
452 await new Promise((r) => setTimeout(r, 3_000));
453 const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers);
454 expect(put.status).toBe(403);
455
456 const c = await complete(headers, upload!.file.id);
457 expect(c.status).toBe(410);
458 expect(errCode(c.body)).toBe("UPLOAD_EXPIRED");
459 } finally {
460 if (prev === undefined) delete process.env.S3_SIGN_EXPIRY_SECONDS;
461 else process.env.S3_SIGN_EXPIRY_SECONDS = prev;
462 resetEnvCache();
463 }
464 });
465
466
467
468 it("H: unsigned GETs on raw keys → 403 in BOTH the files and thumbs buckets", async () => {
469 const { headers } = await newWorkspace("Privacy WS");
470 const fileId = await uploadPng(headers, "private.png");
471 const key = (await fileRow(fileId))!.key;
472 const wsId = (await fileRow(fileId))!.workspaceId;
473
474
475 const rawFiles = await fetch(`${filesConfig.endpoint}/${filesConfig.bucket}/${key}`, { redirect: "manual" });
476 expect(rawFiles.status).toBe(403);
477
478
479
480
481
482 const thumbKey = `thumbs/${wsId}/${fileId}/160/private.png`;
483 const rawThumbs = await fetch(`${thumbsConfig.endpoint}/${thumbsConfig.bucket}/${thumbKey}`, { redirect: "manual" });
484 expect(rawThumbs.status).toBe(403);
485 });
486
487
488
489 it("I: a traversal name gets the SANITIZED key (…/etc/passwd → …/1/passwd)", async () => {
490 const { headers } = await newWorkspace("Traversal WS");
491 const { upload } = (await openUpload(headers, {
492 name: "../../etc/passwd",
493 mimeType: "text/plain",
494 size: 100,
495 })) as { upload?: Upload };
496 expect(upload).toBeDefined();
497 const row = await fileRow(upload!.file.id);
498
499 expect(row!.name).toBe("../../etc/passwd");
500 expect(row!.key).toBe(`files/${row!.workspaceId}/${row!.id}/1/passwd`);
501 expect(row!.key).not.toContain("..");
502
503
504 await cancel(headers, upload!.file.id);
505 });
506
507
508
509 it("cancel: deletes the partial object AND the row", async () => {
510 const { headers } = await newWorkspace();
511 const { upload } = (await openUpload(headers, {
512 name: "partial.png",
513 mimeType: "image/png",
514 size: PNG_BYTES.length,
515 })) as { upload?: Upload };
516 await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers);
517 const key = (await fileRow(upload!.file.id))!.key;
518
519 const status = await cancel(headers, upload!.file.id);
520 expect(status).toBe(200);
521 expect(await objectExists(key)).toBe(false);
522 expect(await fileRow(upload!.file.id)).toBeNull();
523 });
524
525
526
527 it("J: list — kind/status/search filters, page scope, uploader/page-name enrichment, keyset cursor (name + size), workspace isolation", async () => {
528 const owner = await newWorkspace("List WS");
529
530 const stranger = await newWorkspace("List WS — other tenant");
531
532
533 const pageA = await app.prisma.page.create({
534 data: { workspaceId: owner.workspaceId, title: "Page A" },
535 });
536 const pageB = await app.prisma.page.create({
537 data: { workspaceId: owner.workspaceId, title: "Page B" },
538 });
539
540
541
542 const names = ["a0.png", "a1.png", "a2.png", "a3.png", "b0.png", "n0.png"];
543 const ids: string[] = [];
544 for (let i = 0; i < names.length; i++) {
545 const fileId = await uploadPng(owner.headers, names[i]);
546 ids.push(fileId);
547 await app.prisma.file.update({
548 where: { id: fileId },
549 data: {
550 pageId: names[i].startsWith("a") ? pageA.id : names[i].startsWith("b") ? pageB.id : null,
551 size: 100 + i,
552 },
553 });
554 }
555
556 const list = async (qs: string, headers = owner.headers) => {
557 const res = await app.request("GET", `/api/v1/files?${qs}`, { headers });
558 expect(res.status).toBe(200);
559 return (await res.json()) as { data: Record<string, unknown>[]; nextCursor: string | null };
560 };
561
562
563 expect((await list("scope=workspace&kind=image&limit=50")).data).toHaveLength(6);
564 expect((await list("scope=workspace&kind=video&limit=50")).data).toHaveLength(0);
565
566
567 expect((await list("status=SCANNING&limit=50")).data).toHaveLength(6);
568 expect((await list("status=READY&limit=50")).data).toHaveLength(0);
569
570
571 const searched = await list("search=A2&limit=50");
572 expect(searched.data).toHaveLength(1);
573 expect(searched.data[0].name).toBe("a2.png");
574
575
576 const pageAScope = await list(`scope=page&pageId=${pageA.id}&limit=50`);
577 expect(pageAScope.data).toHaveLength(4);
578 expect(pageAScope.data.every((d) => d.pageId === pageA.id)).toBe(true);
579
580
581 const one = (await list("search=a0&limit=50")).data[0];
582 expect(one.uploadedByName).toBe("File Owner");
583 expect(one.pageName).toBe("Page A");
584 expect(one.versionCount).toBe(1);
585
586
587 const p1 = await list("sort=name&limit=3");
588 expect(p1.data).toHaveLength(3);
589 expect(p1.nextCursor).toBeTruthy();
590 const p2 = await list(`sort=name&limit=3&cursor=${encodeURIComponent(p1.nextCursor!)}`);
591 expect(p2.data).toHaveLength(3);
592 const page1Names = p1.data.map((d) => d.name as string);
593 const page2Names = p2.data.map((d) => d.name as string);
594 expect(page1Names.filter((n) => page2Names.includes(n))).toHaveLength(0);
595 expect([...page1Names, ...page2Names].sort()).toEqual([...names].sort());
596 expect(page1Names).toEqual([...page1Names].sort());
597
598
599 const s1 = await list("sort=size&limit=2");
600 expect(s1.data).toHaveLength(2);
601 expect(s1.nextCursor).toBeTruthy();
602 const s2 = await list(`sort=size&limit=2&cursor=${encodeURIComponent(s1.nextCursor!)}`);
603 expect(s2.data).toHaveLength(2);
604 expect(Math.min(...(s1.data.map((d) => d.size as number)))).toBeGreaterThan(
605 Math.max(...(s2.data.map((d) => d.size as number)))
606 );
607
608
609
610 const strangerList = await list("scope=workspace&limit=50", stranger.headers);
611 expect(strangerList.data).toHaveLength(0);
612 expect(strangerList.data.some((d) => ids.includes(d.id as string))).toBe(false);
613 const ownerList = await list("scope=workspace&limit=50");
614 expect(ownerList.data.every((d) => (d.workspaceId as string) === owner.workspaceId)).toBe(true);
615 });
616 },
617);
618