1
44import * as fs from "node:fs";
45import * as os from "node:os";
46import * as path from "node:path";
47import { test, expect } from "../fixtures";
48import {
49 uploadFile,
50 createPage,
51 createBlock,
52 getFile,
53 openUploadSession,
54 putPresigned,
55 workspaceHeader,
56} from "../utils/api";
57
58
59function fixture(name: string): Buffer {
60 return fs.readFileSync(path.join(__dirname, "..", "fixtures", "files", name));
61}
62
63
64const TINY_PNG = Buffer.from(
65 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
66 "base64",
67);
68
69
70const TINY_PDF = Buffer.from(
71 "%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
72 "2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
73 "3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n" +
74 "xref\n0 4\n0000000000 65535 f \n" +
75 "trailer<</Size 4/Root 1 0 R>>\nstartxref\n9\n%%EOF",
76 "latin1",
77);
78
79
80
81const EICAR = Buffer.from(
82 "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*",
83 "latin1",
84);
85
86test.describe("Files — Phase 9 (TASK-0103)", () => {
87 test("image renders in a file block (picker input present for upload path c)", async ({
88 authPage,
89 api,
90 freshWorkspace,
91 }) => {
92 const ws = await freshWorkspace("Image Space");
93 const page = await createPage(api, ws.id, { title: "Image Page" });
94 const file = await uploadFile(api, "pixel.png", "image/png", TINY_PNG, ws.id);
95 expect(file.status).toBe("READY");
96
97
98
99
100
101 await createBlock(api, page.id, { type: "FILE", properties: { fileId: file.id } });
102 await createBlock(api, page.id, { type: "FILE", properties: {} });
103
104 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
105 const card = authPage.getByTestId(`file-card-${file.id}`);
106 await expect(card).toBeVisible({ timeout: 45_000 });
107 const img = card.locator("img").first();
108 await expect(img).toBeVisible();
109 await expect(img).toHaveAttribute("src", /.+/);
110
111
112 await expect(authPage.getByTestId("file-input")).toBeAttached({ timeout: 10_000 });
113 });
114
115 test("upload PDF via API → file block renders the preview iframe", async ({
116 authPage,
117 api,
118 freshWorkspace,
119 }) => {
120 const ws = await freshWorkspace("Pdf Space");
121 const page = await createPage(api, ws.id, { title: "Pdf Page" });
122 const file = await uploadFile(api, "spec.pdf", "application/pdf", TINY_PDF, ws.id);
123 expect(file.status).toBe("READY");
124 await createBlock(api, page.id, {
125 type: "file",
126 properties: { fileId: file.id },
127 });
128
129 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
130 const card = authPage.getByTestId(`file-card-${file.id}`);
131 await expect(card).toBeVisible({ timeout: 45_000 });
132 const frame = card.locator("iframe").first();
133 await expect(frame).toBeVisible();
134 await expect(frame).toHaveAttribute("src", /.+/);
135 });
136
137 test("oversized file (declared size above plan limit) → 413 FILE_TOO_LARGE", async ({
138 api,
139 freshWorkspace,
140 }) => {
141 const ws = await freshWorkspace("Oversize Space");
142
143
144 const res = await openUploadSession(
145 api,
146 ws.id,
147 "huge.bin",
148 "application/octet-stream",
149 120 * 1024 * 1024,
150 );
151 expect(res.status).toBe(413);
152 expect(res.code).toBe("FILE_TOO_LARGE");
153 });
154
155 test("MIME spoof (declared png, text bytes) → 415 MIME_MISMATCH at complete", async ({
156 api,
157 freshWorkspace,
158 }) => {
159 const ws = await freshWorkspace("Spoof Space");
160
161
162 const spoof = Buffer.from("this is not a png, just plain text bytes for the spoof test");
163 const res = await openUploadSession(api, ws.id, "spoof.png", "image/png", spoof.length);
164 expect(res.status).toBeLessThan(300);
165 const fileId = (res.body as { upload: { file: { id: string } } }).upload.file.id;
166 const putUrl = (res.body as { upload: { uploadUrl: string } }).upload.uploadUrl;
167 const ct =
168 (res.body as { upload: { headers: Record<string, string> } }).upload.headers[
169 "Content-Type"
170 ] ?? "image/png";
171 const putStatus = await putPresigned(putUrl, spoof, ct);
172 expect(putStatus, `presigned PUT returned ${putStatus}`).toBeLessThan(300);
173 const complete = await api.put(`/api/v1/files/uploads/${fileId}/complete`, {
174 data: {},
175 });
176 expect(complete.status()).toBe(415);
177 const err = (await complete.json()) as { error: { code: string } };
178 expect(err.error.code).toBe("MIME_MISMATCH");
179 });
180
181 test("non-member cannot read or download the file (403)", async ({
182 api,
183 secondUser,
184 freshWorkspace,
185 }) => {
186 const ws = await freshWorkspace("Private Files Space");
187 const file = await uploadFile(api, "secret.png", "image/png", TINY_PNG, ws.id);
188 expect(file.status).toBe("READY");
189
190
191
192 const { api: outsiderApi } = await secondUser();
193 expect((await outsiderApi.get(`/api/v1/files/${file.id}`)).status()).toBe(403);
194 const dl = await outsiderApi.get(`/api/v1/files/${file.id}/download`);
195 expect(dl.status()).toBe(403);
196 const listForeign = await outsiderApi.get("/api/v1/files", {
197 headers: { "x-factory-workspace": ws.id },
198 });
199 expect(listForeign.status()).toBe(403);
200
201
202 const detail = await getFile(api, file.id, ws.id);
203 expect(detail.downloadUrl).toBeTruthy();
204 expect(detail.previewUrl).toBeTruthy();
205 });
206
207 test("video (webm/VP9) → file block renders a native <video> that decodes", async ({
208 authPage,
209 api,
210 freshWorkspace,
211 }) => {
212 const ws = await freshWorkspace("Video Space");
213 const page = await createPage(api, ws.id, { title: "Video Page" });
214
215
216
217
218
219 const file = await uploadFile(api, "clip.webm", "video/webm", fixture("clip.webm"), ws.id);
220 expect(file.status).toBe("READY");
221 await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
222
223 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
224 const card = authPage.getByTestId(`file-card-${file.id}`);
225 await expect(card).toBeVisible({ timeout: 45_000 });
226 const video = card.locator("video").first();
227 await expect(video).toBeVisible();
228 await expect(video).toHaveAttribute("src", /.+/);
229
230
231
232 await video.evaluate(async (v: HTMLVideoElement) => {
233 if (v.readyState < 1) {
234 await new Promise<void>((resolve, reject) => {
235 const t = setTimeout(() => reject(new Error("video metadata timeout")), 20_000);
236 v.addEventListener(
237 "loadedmetadata",
238 () => {
239 clearTimeout(t);
240 resolve();
241 },
242 { once: true },
243 );
244 });
245 }
246 });
247 expect(await video.evaluate((v: HTMLVideoElement) => v.videoWidth)).toBeGreaterThan(0);
248 });
249
250 test("audio wav → file block renders a native <audio> with the name", async ({
251 authPage,
252 api,
253 freshWorkspace,
254 }) => {
255 const ws = await freshWorkspace("Audio Space");
256 const page = await createPage(api, ws.id, { title: "Audio Page" });
257 const file = await uploadFile(api, "clip.wav", "audio/wav", fixture("clip.wav"), ws.id);
258 expect(file.status).toBe("READY");
259 await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
260
261 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
262 const card = authPage.getByTestId(`file-card-${file.id}`);
263 await expect(card).toBeVisible({ timeout: 45_000 });
264 const audio = card.locator("audio").first();
265 await expect(audio).toBeVisible();
266 await expect(audio).toHaveAttribute("src", /.+/);
267
268 await expect(audio).toHaveAttribute("aria-label", /clip\.wav/);
269 });
270
271 test("document (text/plain) → download card with name + download button", async ({
272 authPage,
273 api,
274 freshWorkspace,
275 }) => {
276 const ws = await freshWorkspace("Docs Space");
277 const page = await createPage(api, ws.id, { title: "Docs Page" });
278 const file = await uploadFile(api, "note.txt", "text/plain", fixture("note.txt"), ws.id);
279 expect(file.status).toBe("READY");
280 await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
281
282 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
283 const card = authPage.getByTestId(`file-card-${file.id}`);
284 await expect(card).toBeVisible({ timeout: 45_000 });
285 await expect(card.getByTestId(`file-name-${file.id}`)).toHaveText("note.txt");
286
287 await expect(card).toContainText("text/plain");
288
289 const dl = card.getByTestId(`file-download-${file.id}`);
290 await expect(dl).toBeVisible();
291 await expect(dl).toBeAttached();
292 expect(await dl.evaluate((el) => el instanceof HTMLButtonElement)).toBe(true);
293 });
294
295 test("EICAR → red \"Blocked by virus scan\" card, no download affordance", async ({
296 authPage,
297 api,
298 freshWorkspace,
299 }) => {
300 const ws = await freshWorkspace("Virus Space");
301 const page = await createPage(api, ws.id, { title: "Virus Page" });
302 const file = await uploadFile(api, "eicar.txt", "text/plain", EICAR, ws.id);
303 expect(file.status).toBe("VIRUS_DETECTED");
304 await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
305
306 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
307
308 const blocked = authPage.getByText("Blocked by virus scan");
309 await expect(blocked).toBeVisible({ timeout: 45_000 });
310
311 await expect(authPage.getByTestId(`file-card-${file.id}`)).toHaveCount(0);
312 await expect(authPage.getByTestId(`file-download-${file.id}`)).toHaveCount(0);
313 });
314
315 test("file block \"Add file\" button (upload path c) — browser upload → renders", async ({
316 authPage,
317 api,
318 freshWorkspace,
319 }) => {
320
321
322 test.setTimeout(120_000);
323 const ws = await freshWorkspace("Button Upload Space");
324 const page = await createPage(api, ws.id, { title: "Button Upload Page" });
325
326 const picker = await createBlock(api, page.id, { type: "FILE", properties: {} });
327 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
328
329 const input = authPage.getByTestId("file-input");
330 await expect(input).toBeAttached({ timeout: 15_000 });
331
332
333
334
335
336
337
338 await input.setInputFiles({ name: "pixel.png", mimeType: "image/png", buffer: TINY_PNG });
339
340
341
342 const deadline = Date.now() + 60_000;
343 let fileId: string | undefined;
344 for (;;) {
345 const body = (await (
346 await api.get(`/api/v1/pages/${page.id}/blocks`)
347 ).json()) as { data: { id: string; properties: { fileId?: string } }[] };
348 const props = body.data.find((b) => b.id === picker.id)?.properties ?? {};
349 if (props.fileId) {
350 fileId = props.fileId as string;
351 const detail = await getFile(api, fileId, ws.id);
352 if (detail.status === "READY") break;
353 if (detail.status === "FAILED" || detail.status === "VIRUS_DETECTED") {
354 throw new Error(`path c: upload ended ${detail.status}`);
355 }
356 }
357 if (Date.now() > deadline) throw new Error("path c: upload did not settle in 60s");
358 await new Promise((r) => setTimeout(r, 1000));
359 }
360 expect(fileId).toBeTruthy();
361
362
363
364 await authPage.reload();
365 const card = authPage.getByTestId(`file-card-${fileId}`);
366 await expect(card).toBeVisible({ timeout: 45_000 });
367 await expect(card.locator("img").first()).toBeVisible();
368 });
369
370
371
372
373 test("paste an image from the clipboard → uploads + renders a new file block", async ({
374 authPage,
375 api,
376 freshWorkspace,
377 }) => {
378 test.setTimeout(90_000);
379 const ws = await freshWorkspace("Paste Space");
380 const page = await createPage(api, ws.id, { title: "Paste Page" });
381 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
382
383
384 await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 30_000 });
385
386 expect(await authPage.getByTestId(/^file-card-/).count()).toBe(0);
387
388
389
390
391
392
393
394
395
396 await authPage.evaluate((b64: string) => {
397 const bin = atob(b64);
398 const bytes = new Uint8Array(bin.length);
399 for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
400 const file = new File([bytes], "pasted.png", { type: "image/png" });
401 const dt = new DataTransfer();
402 dt.items.add(file);
403 const ev = new ClipboardEvent("paste", { bubbles: true, cancelable: true });
404 Object.defineProperty(ev, "clipboardData", { value: dt, configurable: true });
405 window.dispatchEvent(ev);
406 }, TINY_PNG.toString("base64"));
407
408
409
410 const card = authPage.getByTestId(/^file-card-/);
411 await expect(card.first()).toBeVisible({ timeout: 60_000 });
412 await expect(card.first().locator("img").first()).toBeVisible();
413 await expect(card.first().locator("img").first()).toHaveAttribute("src", /.+/);
414 });
415
416 test("DOCX → download card (name + button) and Download delivers the exact bytes", async ({
417 authPage,
418 api,
419 freshWorkspace,
420 }) => {
421 test.setTimeout(90_000);
422 const ws = await freshWorkspace("Docx Space");
423 const page = await createPage(api, ws.id, { title: "Docx Page" });
424 const docx = fixture("note.docx");
425 const file = await uploadFile(
426 api,
427 "note.docx",
428 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
429 docx,
430 ws.id,
431 );
432 expect(file.status).toBe("READY");
433 await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
434
435 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
436 const card = authPage.getByTestId(`file-card-${file.id}`);
437 await expect(card).toBeVisible({ timeout: 45_000 });
438
439 await expect(card.getByTestId(`file-name-${file.id}`)).toHaveText("note.docx");
440 await expect(card).toContainText("application/vnd.openxmlformats-officedocument");
441 const dl = card.getByTestId(`file-download-${file.id}`);
442 await expect(dl).toBeVisible();
443
444
445
446
447
448 const [download] = await Promise.all([authPage.waitForEvent("download"), dl.click()]);
449 expect(download.suggestedFilename()).toBe("note.docx");
450 const path = await download.path();
451 const delivered = await fs.promises.readFile(path!);
452 expect(delivered.equals(docx)).toBe(true);
453 });
454
455 test("60 MB file on the free plan → client-side \"File too large\" toast, no session opened", async ({
456 authPage,
457 api,
458 freshWorkspace,
459 }) => {
460 test.setTimeout(90_000);
461 const ws = await freshWorkspace("Free 60MB Space");
462 const page = await createPage(api, ws.id, { title: "60MB Page" });
463
464 await createBlock(api, page.id, { type: "FILE", properties: {} });
465 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
466 const input = authPage.getByTestId("file-input");
467 await expect(input).toBeAttached({ timeout: 30_000 });
468
469
470
471
472
473
474 const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "e2e-big-"));
475 const bigPath = path.join(dir, "big.bin");
476 const fd = await fs.promises.open(bigPath, "w");
477 try {
478 await fd.truncate(60 * 1024 * 1024);
479 } finally {
480 await fd.close();
481 }
482 try {
483
484
485
486 await input.setInputFiles(bigPath);
487
488 const toast = authPage.getByRole("status").filter({ hasText: /File too large/ });
489 await expect(toast).toBeVisible({ timeout: 25_000 });
490 await expect(toast).toContainText(/60|50 MB per-file limit/);
491
492
493
494
495 expect(await authPage.getByTestId(/^file-card-/).count()).toBe(0);
496 const list = (await (await api.get("/api/v1/files", { headers: workspaceHeader(ws.id) })).json()) as {
497 data: unknown[];
498 };
499 expect(list.data ?? []).toHaveLength(0);
500 } finally {
501 await fs.promises.rm(dir, { recursive: true, force: true });
502 }
503 });
504
505 test("unauthorized: a non-member cannot see the file block (block state)", async ({
506 secondUser,
507 api,
508 freshWorkspace,
509 }) => {
510 const ws = await freshWorkspace("Unauthorized UI Space");
511 const page = await createPage(api, ws.id, { title: "Private Page" });
512 const file = await uploadFile(api, "locked.png", "image/png", TINY_PNG, ws.id);
513 expect(file.status).toBe("READY");
514 await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
515
516
517
518
519
520
521 const { page: outsider } = await secondUser();
522 await outsider.goto(`/w/${ws.id}/page/${page.id}`);
523
524
525 await expect.poll(() => outsider.url(), {
526 timeout: 20_000,
527 message: "non-member is bounced off the private page",
528 }).not.toContain(`/page/${page.id}`);
529
530 await expect(outsider.getByTestId(`file-card-${file.id}`)).toHaveCount(0);
531 });
532
533 test("file picker shows the uploaded image as a thumbnail", async ({
534 authPage,
535 api,
536 freshWorkspace,
537 }) => {
538 const ws = await freshWorkspace("Picker Thumb Space");
539 const page = await createPage(api, ws.id, { title: "Picker Page" });
540
541
542
543 const file = await uploadFile(api, "hero.png", "image/png", fixture("hero-1400.png"), ws.id);
544 expect(file.status).toBe("READY");
545 expect(file.thumbnailUrl, "an image gets a thumbnail").toBeTruthy();
546 await createBlock(api, page.id, { type: "file", properties: { fileId: file.id } });
547
548 await authPage.goto(`/w/${ws.id}/page/${page.id}`);
549 await expect(authPage.getByTestId("files-picker-toggle")).toBeVisible({ timeout: 30_000 });
550
551
552 await authPage.getByTestId("files-picker-toggle").click();
553 const panel = authPage.getByRole("dialog", { name: "Files" });
554 await expect(panel).toBeVisible({ timeout: 15_000 });
555
556
557
558
559
560 await panel.getByRole("button", { name: "In workspace" }).click();
561
562
563
564
565 const tile = panel.getByRole("option", { name: /hero\.png/ });
566 await expect(tile).toBeVisible({ timeout: 15_000 });
567 const thumb = tile.locator("img").first();
568 await expect(thumb).toBeVisible();
569 await expect(thumb).toHaveAttribute("src", /.+/);
570 });
571});
572