1
44import { afterAll, beforeAll, describe, expect, it } from "vitest";
45import path from "node:path";
46import os from "node:os";
47import { mkdtemp, readFile } from "node:fs/promises";
48import { fileURLToPath, pathToFileURL } from "node:url";
49import { S3Client as StorageS3Client, type S3Config as StorageS3Config } from "@factory/storage";
50import { SearchClient } from "@factory/search";
51import { PrismaClient } from "@prisma/client";
52import { Redis } from "ioredis";
53import { createTestApp, type TestAppHandle } from "./helpers/api";
54import { getDatabaseUrl, getRedisUrl } from "./helpers/containers";
55import { authHeaders, createUser, createWorkspace, addMemberViaSeam } from "./helpers/seed";
56
57const HERE = path.dirname(fileURLToPath(import.meta.url));
58
59
60
61
62const { runFilesPipeline } = await import(
63
64 fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "files", "processor.ts"))
65);
66const { S3Client: WorkerS3Client } = await import(
67
68 fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "lib", "s3.ts"))
69);
70const { handleSearchEvent, runReindex } = await import(
71
72 fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "search-index", "processor.ts"))
73);
74const { runOrphanSweep } = await import(
75
76 fileUrl(path.resolve(HERE, "..", "..", "apps", "worker", "src", "processors", "files", "cleanup.ts"))
77);
78
79function fileUrl(p: string): string {
80 return `file://${p.startsWith("/") ? p : `/${p}`}`;
81}
82
83
84
85const S3_ENDPOINT = (process.env.S3_ENDPOINT ?? "http://localhost:9000").replace(/\/+$/, "");
86const SEARCH_URL = process.env.SEARCH_URL ?? "http://localhost:9200";
87const S3_ACCESS = process.env.S3_ACCESS_KEY ?? "minioadmin";
88const S3_SECRET = process.env.S3_SECRET_KEY ?? "minioadmin";
89const FILES_BUCKET = process.env.S3_BUCKET_FILES ?? "factory-files";
90const THUMBS_BUCKET = process.env.S3_BUCKET_THUMBS ?? "factory-thumbs";
91
92async function httpUp(url: string): Promise<boolean> {
93 try {
94 const r = await fetch(url, { signal: AbortSignal.timeout(3_000) });
95 return r.ok;
96 } catch {
97 return false;
98 }
99}
100const minioReady = await httpUp(`${S3_ENDPOINT}/minio/health/live`);
101const searchReady = await httpUp(`${SEARCH_URL}/_cluster/health`);
102
103
104
105
106const PNG_BYTES = new Uint8Array([
107 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
108 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
109 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
110 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
111 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
112 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
113 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
114 0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
115 0x44, 0xae, 0x42, 0x60,
116]);
117
120const FIX = path.join(HERE, "fixtures", "files");
121
122
123
124let app: TestAppHandle;
125let prisma: PrismaClient;
126let redis: Redis;
127let workerFiles: InstanceType<typeof WorkerS3Client>;
128let workerThumbs: InstanceType<typeof WorkerS3Client>;
129let storageFiles: InstanceType<typeof StorageS3Client>;
130let storageThumbs: InstanceType<typeof StorageS3Client>;
131let search: SearchClient | null;
132let env: import("../../apps/worker/src/config").WorkerEnv;
133let tempDir: string;
134let HERO_BYTES: Uint8Array;
135
136
137
138const json = async (res: Response): Promise<Record<string, unknown>> =>
139 (await res.json().catch(() => ({}))) as Record<string, unknown>;
140const errCode = (body: Record<string, unknown>): string =>
141 ((body.error as { code?: string } | undefined)?.code ?? "");
142
143interface Ws {
144 headers: Record<string, string>;
145 workspaceId: string;
146 userId: string;
147}
148async function newWorkspace(name = "Lifecycle WS"): Promise<Ws> {
149 const user = await createUser(app.request, { name: "LC Owner" });
150 const { workspaceId } = await createWorkspace(app.request, authHeaders(user.accessToken!), name);
151 return {
152 headers: { ...authHeaders(user.accessToken!), "x-factory-workspace": workspaceId },
153 workspaceId,
154 userId: user.userId,
155 };
156}
157
158interface Upload {
159 file: { id: string; name: string; status?: string };
160 uploadUrl: string;
161 method: string;
162 headers: Record<string, string>;
163 expiresAt: string;
164}
165async function openUpload(
166 headers: Record<string, string>,
167 body: Record<string, unknown>,
168): Promise<{ status: number; upload?: Upload; raw: Record<string, unknown> }> {
169 const res = await app.request("POST", "/api/v1/files/uploads", { headers, body });
170 const raw = await json(res);
171 if (res.status !== 201) return { status: res.status, raw };
172 return { status: 201, upload: raw.upload as Upload, raw };
173}
174async function putToUrl(url: string, bytes: Uint8Array, headers: Record<string, string>): Promise<Response> {
175 return fetch(url, { method: "PUT", headers, body: bytes as unknown as BodyInit });
176}
177async function complete(headers: Record<string, string>, uploadId: string) {
178 const res = await app.request("PUT", `/api/v1/files/uploads/${uploadId}/complete`, { headers, body: {} });
179 return { status: res.status, body: await json(res) };
180}
181async function fileRow(fileId: string) {
182 return app.prisma.file.findUnique({ where: { id: fileId } });
183}
184
185
188async function toReady(fileId: string, version: number, workspaceId: string): Promise<void> {
189 const res = await runFilesPipeline(
190 { prisma, s3: workerFiles, s3Thumbs: workerThumbs, redis, env },
191 { fileId, version, workspaceId },
192 );
193 expect(res.kind).toBe("ready");
194 const row = await fileRow(fileId);
195 expect(row?.status).toBe("READY");
196}
197
198
199async function uploadPng(headers: Record<string, string>, name = "lifecycle.png", bytes = PNG_BYTES): Promise<string> {
200 const { status, upload } = await openUpload(headers, {
201 name,
202 mimeType: "image/png",
203 size: bytes.length,
204 });
205 expect(status).toBe(201);
206 const put = await putToUrl(upload!.uploadUrl, bytes, upload!.headers);
207 expect(put.status).toBe(200);
208 const c = await complete(headers, upload!.file.id);
209 expect(c.status).toBe(200);
210 await toReady(upload!.file.id, 1, headers["x-factory-workspace"]!);
211 return upload!.file.id;
212}
213
214
215async function keysIn(client: InstanceType<typeof StorageS3Client>, prefix: string): Promise<string[]> {
216 const { keys } = await client.listObjects(prefix, 1000);
217 return keys;
218}
219
220
221async function searchEvent(event: string, payload: Record<string, unknown>): Promise<void> {
222 if (!search) return;
223 await handleSearchEvent(
224 { search, prisma, connection: redis, env },
225 { event, payload },
226 );
227}
228async function docInSearch(docId: string): Promise<boolean> {
229 if (!search) return false;
230 const present = await search.docExists([docId]);
231 return present.has(docId);
232}
233
235function searchOps() {
236 return { search: search!, prisma, connection: redis, env };
237}
238
241async function streamHasEvent(event: string, resourceId: string): Promise<boolean> {
242 const entries = (await redis.xrange("factory:events", "-", "+")) as [string, unknown[]][];
243 return entries.some(([, fields]) => {
244 const arr = fields as unknown[];
245 const get = (k: string): unknown => arr[arr.indexOf(k) + 1];
246 if (get("event") !== event) return false;
247 const payload = get("payload");
248 if (typeof payload !== "string") return false;
249 try {
250 return (JSON.parse(payload) as { resourceId?: string }).resourceId === resourceId;
251 } catch {
252 return false;
253 }
254 });
255}
256
257
258
259describe.skipIf(!minioReady)(
260 "Files lifecycle integration (TASK-0104 — versions/audit/trash/quota/permissions)",
261 () => {
262 beforeAll(async () => {
263 app = await createTestApp();
264 const [dbUrl, redisUrl] = await Promise.all([getDatabaseUrl(), getRedisUrl()]);
265 prisma = new PrismaClient({ datasources: { db: { url: dbUrl } } });
266 redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
267 const cfg = {
268 endpoint: S3_ENDPOINT,
269 region: process.env.S3_REGION ?? "us-east-1",
270 accessKey: S3_ACCESS,
271 secretKey: S3_SECRET,
272 };
273 workerFiles = new WorkerS3Client({ ...cfg, bucket: FILES_BUCKET });
274 workerThumbs = new WorkerS3Client({ ...cfg, bucket: THUMBS_BUCKET });
275 const scfg: StorageS3Config = {
276 endpoint: S3_ENDPOINT,
277 region: process.env.S3_REGION ?? "us-east-1",
278 bucket: FILES_BUCKET,
279 accessKeyId: S3_ACCESS,
280 secretAccessKey: S3_SECRET,
281 forcePathStyle: true,
282 };
283 storageFiles = new StorageS3Client({ config: scfg });
284 storageThumbs = new StorageS3Client({ config: { ...scfg, bucket: THUMBS_BUCKET } });
285 if (searchReady) search = new SearchClient({ baseUrl: SEARCH_URL });
286 tempDir = await mkdtemp(path.join(os.tmpdir(), "factory-lifecycle-it-"));
287 HERO_BYTES = new Uint8Array(await readFile(path.join(FIX, "hero-1400.png")));
288 env = {
289 NODE_ENV: "test",
290 REDIS_URL: "ignored",
291 DATABASE_URL: "ignored",
292 S3_ENDPOINT,
293 S3_REGION: process.env.S3_REGION ?? "us-east-1",
294 S3_BUCKET_FILES: FILES_BUCKET,
295 S3_BUCKET_THUMBS: THUMBS_BUCKET,
296 S3_BUCKET: FILES_BUCKET,
297 S3_ACCESS_KEY: S3_ACCESS,
298 S3_SECRET_KEY: S3_SECRET,
299 S3_PUBLIC_BASE: "http://localhost:9001",
300 SEARCH_URL,
301 SEARCH_USERNAME: "admin",
302 SEARCH_PASSWORD: "changeme",
303 SEARCH_VECTOR_DIMENSION: 1536,
304 SEARCH_KNN_M: 16,
305 SEARCH_KNN_EF_CONSTRUCTION: 100,
306 MASTER_ENCRYPTION_KEY: "",
307 AI_API_KEY: "",
308 AI_PROVIDER: "openai",
309 AI_BASE_URL: "",
310 AI_MODEL: "gpt-4o-mini",
311 AI_EMBEDDING_PROVIDER: "",
312 AI_EMBEDDING_MODEL: "text-embedding-3-small",
313 SMTP_HOST: "",
314 SMTP_PORT: 587,
315 SMTP_USER: "",
316 SMTP_PASS: "",
317 SMTP_FROM: "noreply@factory.local",
318 SMTP_STARTTLS: true,
319 CLAMAV_HOST: "",
320 CLAMAV_PORT: 3310,
321 CLAMD_SOCKET: "",
322 FILES_WORKER_CONCURRENCY: 4,
323 CLAMAV_TIMEOUT_MS: 30_000,
324 FILES_TEMP_DIR: tempDir,
325 FILES_TEMP_MAX_AGE_MS: 600_000,
326 FILES_TEMP_FREE_MIN_BYTES: 1,
327 PUBLIC_URL: "http://localhost:3000",
328 WORKER_CONCURRENCY: 10,
329 WORKER_ID: "lifecycle-test-worker",
330 WORKER_RETENTION_EXECUTE: false,
331 HEALTH_PORT: 4003,
332 } as unknown as import("../../apps/worker/src/config").WorkerEnv;
333 }, 180_000);
334 afterAll(async () => {
335 await redis?.quit().catch(() => {});
336 await prisma?.$disconnect().catch(() => {});
337 await app?.close();
338 });
339
340
341
342 it("1: re-upload → v2 (FileVersion v2, both keys in bucket, F→v2); rollback→v1; GET /versions lists both (bare array, no key)", async () => {
343 const ws = await newWorkspace("Versions WS");
344 const f1 = await uploadPng(ws.headers, "v1.png", PNG_BYTES);
345 const row1 = (await fileRow(f1))!;
346 const v1key = row1.key;
347
348
349 const { status, upload } = await openUpload(ws.headers, {
350 name: "v2.png",
351 mimeType: "image/png",
352 size: HERO_BYTES.length,
353 replaceFileId: f1,
354 });
355 expect(status).toBe(201);
356 expect(upload!.file.id).toBe(f1);
357 const put = await putToUrl(upload!.uploadUrl, HERO_BYTES, upload!.headers);
358 expect(put.status).toBe(200);
359 const c = await complete(ws.headers, f1);
360 expect(c.status).toBe(200);
361 await toReady(f1, 2, ws.workspaceId);
362
363 const row2 = (await fileRow(f1))!;
364 const v2key = row2.key;
365 expect(v2key).not.toBe(v1key);
366
367
368 const bucketKeys = new Set(await keysIn(storageFiles, `files/${ws.workspaceId}/${f1}/`));
369 expect(bucketKeys.has(v1key)).toBe(true);
370 expect(bucketKeys.has(v2key)).toBe(true);
371 expect(row2.key).toBe(v2key);
372
373
374 const versions = await app.prisma.fileVersion.findMany({
375 where: { fileId: f1 },
376 orderBy: { version: "asc" },
377 });
378 expect(versions.map((v) => v.version)).toEqual([1, 2]);
379
380
381 const res = await app.request("GET", `/api/v1/files/${f1}/versions`, { headers: ws.headers });
382 expect(res.status).toBe(200);
383 const list = (await res.json()) as unknown[];
384 expect(Array.isArray(list)).toBe(true);
385 expect(list).toHaveLength(2);
386 const items = list as Array<Record<string, unknown>>;
387 expect(items.every((i) => !("key" in i))).toBe(true);
388 expect(items.map((i) => i.version)).toEqual([1, 2]);
389 expect(items.find((i) => i.version === 2)?.current).toBe(true);
390 expect(items.find((i) => i.version === 1)?.current).toBe(false);
391
392
393
394 const rb = await app.request("POST", `/api/v1/files/${f1}/rollback`, {
395 headers: ws.headers,
396 body: { version: 1 },
397 });
398 expect(rb.status).toBe(201);
399 const row3 = (await fileRow(f1))!;
400 expect(row3.key).toBe(v1key);
401
402 expect(new Set(await keysIn(storageFiles, `files/${ws.workspaceId}/${f1}/`)).size).toBe(2);
403 });
404
405
406
407 it("2: non-member download → 403 + file.download_denied (actorId+ip); member download → 302 + file.downloaded", async () => {
408 const ws = await newWorkspace("Audit WS");
409 const fileId = await uploadPng(ws.headers, "audit.png");
410
411
412 const stranger = await createUser(app.request, { name: "Stranger" });
413 const strangerHeaders = { ...authHeaders(stranger.accessToken!), "x-factory-workspace": ws.workspaceId };
414
415
416 const denied = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: strangerHeaders });
417 expect(denied.status).toBe(403);
418 expect(errCode(await json(denied))).toBe("FORBIDDEN");
419 const denAudit = await app.prisma.auditLog.findFirst({
420 where: { action: "file.download_denied", resourceId: fileId },
421 });
422 expect(denAudit).not.toBeNull();
423 expect(denAudit?.actorId).toBe(stranger.userId);
424 expect(denAudit?.ipAddress).toBeTruthy();
425
426
427 const dl = await app.request("GET", `/api/v1/files/${fileId}/download`, {
428 headers: ws.headers,
429 redirect: "manual",
430 });
431 expect(dl.status).toBe(302);
432 expect(dl.headers.get("location")).toContain("X-Amz-Signature=");
433 const okAudit = await app.prisma.auditLog.findFirst({
434 where: { action: "file.downloaded", resourceId: fileId },
435 });
436 expect(okAudit).not.toBeNull();
437 expect(okAudit?.actorId).toBe(ws.userId);
438 });
439
440
441
442 it("3: NONE grant on a file → 403 (beats inherited EDIT); VIEW grant to a non-member → 302 (grant grants)", async () => {
443 const ws = await newWorkspace("Perm WS");
444 const fileId = await uploadPng(ws.headers, "perm.png");
445
446
447 const member = await createUser(app.request, { name: "Member" });
448 await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER");
449 const memberHeaders = { ...authHeaders(member.accessToken!), "x-factory-workspace": ws.workspaceId };
450
451 const before = await app.request("GET", `/api/v1/files/${fileId}`, { headers: memberHeaders });
452 expect(before.status).toBe(200);
453
454 const putNone = await app.request("PUT", `/api/v1/files/${fileId}/permissions`, {
455 headers: ws.headers,
456 body: { grants: [{ granteeType: "user", granteeId: member.userId, level: "NONE" }] },
457 });
458 expect(putNone.status).toBe(200);
459 const after = await app.request("GET", `/api/v1/files/${fileId}/download`, {
460 headers: memberHeaders,
461 redirect: "manual",
462 });
463 expect(after.status).toBe(403);
464
465
466 const outsider = await createUser(app.request, { name: "Outsider" });
467 const outHeaders = { ...authHeaders(outsider.accessToken!), "x-factory-workspace": ws.workspaceId };
468
469 const noGrant = await app.request("GET", `/api/v1/files/${fileId}`, { headers: outHeaders });
470 expect(noGrant.status).toBe(403);
471
472 await app.request("PUT", `/api/v1/files/${fileId}/permissions`, {
473 headers: ws.headers,
474 body: {
475 grants: [
476 { granteeType: "user", granteeId: member.userId, level: "NONE" },
477 { granteeType: "user", granteeId: outsider.userId, level: "VIEW" },
478 ],
479 },
480 });
481 const viewOk = await app.request("GET", `/api/v1/files/${fileId}`, { headers: outHeaders });
482 expect(viewOk.status).toBe(200);
483 });
484
485
486
487 it("4+6: delete→deletedAt+absent+doc-removed; restore→back+doc; purge→objects/rows/thumbs gone, audit in order, 0 orphans", async () => {
488 const ws = await newWorkspace("Trash WS");
489
490 const f1 = await uploadPng(ws.headers, "trash.png", PNG_BYTES);
491 const { upload } = await openUpload(ws.headers, {
492 name: "trash-v2.png",
493 mimeType: "image/png",
494 size: HERO_BYTES.length,
495 replaceFileId: f1,
496 });
497 await putToUrl(upload!.uploadUrl, HERO_BYTES, upload!.headers);
498 await complete(ws.headers, f1);
499 await toReady(f1, 2, ws.workspaceId);
500 const versionRows = await app.prisma.fileVersion.findMany({ where: { fileId: f1 } });
501 const versionKeys = versionRows.map((v) => v.key).sort();
502 const versionSizesSum = BigInt(versionRows.reduce((s, v) => s + v.size, 0));
503 expect(versionKeys.length).toBe(2);
504
505 const counterAfterUpload = (await app.prisma.workspace.findUniqueOrThrow({ where: { id: ws.workspaceId } })).usageStorageBytes;
506 expect(counterAfterUpload).toBe(versionSizesSum);
507
508 if (searchReady) await searchEvent("file.uploaded", { workspaceId: ws.workspaceId, fileId: f1 });
509 const docBefore = await docInSearch(`file:${f1}`);
510
511
512 const del = await app.request("DELETE", `/api/v1/files/${f1}`, { headers: ws.headers });
513 expect(del.status).toBe(200);
514 const delBody = await json(del);
515 expect(delBody.trashed).toBe(true);
516 expect(delBody.deletedAt).toBeTruthy();
517 const trashed = (await fileRow(f1))!;
518 expect(trashed.deletedAt).not.toBeNull();
519
520 const listRes = await app.request("GET", "/api/v1/files", { headers: ws.headers });
521 const listBody = await json(listRes);
522 const listed = (listBody.data as Array<{ id: string }> | undefined) ?? [];
523 expect(listed.find((f) => f.id === f1)).toBeUndefined();
524
525 const trashRes = await app.request("GET", "/api/v1/files/trash", { headers: ws.headers });
526 const trashList = (await trashRes.json()) as unknown[];
527 expect(Array.isArray(trashList)).toBe(true);
528 expect(trashList.find((f) => (f as { id?: string }).id === f1)).toBeTruthy();
529
530 if (searchReady) {
531 await searchEvent("file.deleted", { workspaceId: ws.workspaceId, fileId: f1 });
532 const docAfterDel = await docInSearch(`file:${f1}`);
533 expect(docBefore).toBe(true);
534 expect(docAfterDel).toBe(false);
535 }
536
537
538 const restore = await app.request("POST", `/api/v1/files/${f1}/restore`, { headers: ws.headers });
539 expect(restore.status).toBe(201);
540 const restored = (await fileRow(f1))!;
541 expect(restored.deletedAt).toBeNull();
542 const list2 = await json(await app.request("GET", "/api/v1/files", { headers: ws.headers }));
543 expect(((list2.data as Array<{ id: string }>) ?? []).find((f) => f.id === f1)).toBeTruthy();
544
545 if (searchReady) {
546 await searchEvent("file.uploaded", { workspaceId: ws.workspaceId, fileId: f1, restored: true });
547 expect(await docInSearch(`file:${f1}`)).toBe(true);
548 }
549
550 const counterAfterRestore = (await app.prisma.workspace.findUniqueOrThrow({ where: { id: ws.workspaceId } })).usageStorageBytes;
551 expect(counterAfterRestore).toBe(counterAfterUpload);
552
553
554 const purge = await app.request("DELETE", `/api/v1/files/${f1}?purge=true`, { headers: ws.headers });
555 expect(purge.status).toBe(200);
556 const purgeBody = await json(purge);
557 expect(purgeBody.purged).toBe(true);
558
559 expect(await fileRow(f1)).toBeNull();
560 expect(await app.prisma.fileVersion.count({ where: { fileId: f1 } })).toBe(0);
561
562 const filesLeft = await keysIn(storageFiles, `files/${ws.workspaceId}/${f1}/`);
563 expect(filesLeft.length).toBe(0);
564 for (const k of versionKeys) expect(filesLeft).not.toContain(k);
565 const thumbsLeft = await keysIn(storageThumbs, `thumbs/${ws.workspaceId}/${f1}/`);
566 expect(thumbsLeft.length).toBe(0);
567
568 const counterAfterPurge = (await app.prisma.workspace.findUniqueOrThrow({ where: { id: ws.workspaceId } })).usageStorageBytes;
569 expect(counterAfterPurge).toBe(counterAfterUpload - versionSizesSum);
570
571
572 const trail = await app.prisma.auditLog.findMany({
573 where: {
574 resourceId: f1,
575 action: { in: ["file.deleted", "file.restored", "file.purged"] },
576 },
577 orderBy: { createdAt: "asc" },
578 });
579 expect(trail.map((t) => t.action)).toEqual(["file.deleted", "file.restored", "file.purged"]);
580
581
582 const report = await runOrphanSweep({
583 s3: storageFiles,
584 prisma,
585 dryRun: true,
586 fileId: f1,
587 });
588 expect(report.orphansFound).toBe(0);
589 expect(report.keys).toEqual([]);
590 });
591
592
593
594 it("5: 1 GB quota, 950 MB used → 60 MB session 413 QUOTA_EXCEEDED; 40 MB 201 + column = 990 MB (assert the column)", async () => {
595 const ws = await newWorkspace("Quota WS");
596
597
598
599
600
601
602
603
604 await app.prisma.workspace.update({
605 where: { id: ws.workspaceId },
606 data: { billingPlan: "paid", usageLimits: { storageQuota: 1_000_000_000 } },
607 });
608 await app.prisma.workspace.update({
609 where: { id: ws.workspaceId },
610 data: { usageStorageBytes: BigInt(950_000_000) },
611 });
612
613
614 const over = await openUpload(ws.headers, {
615 name: "big.bin",
616 mimeType: "application/octet-stream",
617 size: 60_000_000,
618 });
619 expect(over.status).toBe(413);
620 expect(errCode(over.raw)).toBe("QUOTA_EXCEEDED");
621
622
623 const ok = await openUpload(ws.headers, {
624 name: "small.bin",
625 mimeType: "application/octet-stream",
626 size: 40_000_000,
627 });
628 expect(ok.status).toBe(201);
629
630 const zeros = new Uint8Array(40_000_000);
631 const put = await putToUrl(ok.upload!.uploadUrl, zeros, ok.upload!.headers);
632 expect(put.status).toBe(200);
633 const c = await complete(ws.headers, ok.upload!.file.id);
634 expect(c.status).toBe(200);
635
636
637 const wsRow = await app.prisma.workspace.findUniqueOrThrow({ where: { id: ws.workspaceId } });
638 expect(wsRow.usageStorageBytes).toBe(BigInt(990_000_000));
639 });
640
641
642
643 it("7: add a grant → GET /permissions shows it + audit file.permission_changed + permission.changed published + doc re-indexes", async () => {
644 const ws = await newWorkspace("Reindex WS");
645 const fileId = await uploadPng(ws.headers, "reindex.png");
646 const team = await app.prisma.team.create({
647 data: { workspaceId: ws.workspaceId, name: "LC Team", slug: `lc-team-${Date.now()}` },
648 });
649
650
651 const empty = await app.request("GET", `/api/v1/files/${fileId}/permissions`, { headers: ws.headers });
652 expect(empty.status).toBe(200);
653 expect(Array.isArray(await empty.json())).toBe(true);
654
655
656 const put = await app.request("PUT", `/api/v1/files/${fileId}/permissions`, {
657 headers: ws.headers,
658 body: { grants: [{ granteeType: "team", granteeId: team.id, level: "VIEW" }] },
659 });
660 expect(put.status).toBe(200);
661
662 const got = await app.request("GET", `/api/v1/files/${fileId}/permissions`, { headers: ws.headers });
663 const grants = (await got.json()) as Array<Record<string, unknown>>;
664 expect(grants).toHaveLength(1);
665 expect(grants[0]?.granteeType).toBe("team");
666 expect(grants[0]?.granteeId).toBe(team.id);
667 expect(grants[0]?.level).toBe("VIEW");
668
669 const audit = await app.prisma.auditLog.findFirst({
670 where: { action: "file.permission_changed", resourceId: fileId },
671 });
672 expect(audit).not.toBeNull();
673
674
675
676
677
678
679
680
681 expect(await streamHasEvent("permission.changed", fileId)).toBe(true);
682
683
684
685
686 if (searchReady) {
687 const counts = await runReindex(searchOps(), ws.workspaceId);
688 expect(counts.docs).toBeGreaterThanOrEqual(1);
689 expect(await docInSearch(`file:${fileId}`)).toBe(true);
690 const mirror = await app.prisma.searchDocument.findUnique({
691 where: { type_refId: { type: "file", refId: fileId } },
692 });
693 expect(mirror).not.toBeNull();
694 }
695 });
696
697
698
699 it("8: PATCH changes only the name (size/mime/metadata unchanged) + file.updated published + doc title re-indexed", async () => {
700 const ws = await newWorkspace("Rename WS");
701 const fileId = await uploadPng(ws.headers, "before.png");
702 const before = (await fileRow(fileId))!;
703
704 if (searchReady) await searchEvent("file.uploaded", { workspaceId: ws.workspaceId, fileId });
705 expect(await docInSearch(`file:${fileId}`)).toBe(searchReady ? true : false);
706
707 const patch = await app.request("PATCH", `/api/v1/files/${fileId}`, {
708 headers: ws.headers,
709 body: { name: "after.png" },
710 });
711 expect(patch.status).toBe(200);
712 const afterRow = (await fileRow(fileId))!;
713 expect(afterRow.name).toBe("after.png");
714
715 expect(afterRow.size).toBe(before.size);
716 expect(afterRow.mimeType).toBe(before.mimeType);
717 expect(afterRow.metadata).toEqual(before.metadata);
718 expect(afterRow.key).toBe(before.key);
719
720
721 const audit = await app.prisma.auditLog.findFirst({
722 where: { action: "file.renamed", resourceId: fileId },
723 });
724 expect(audit).not.toBeNull();
725
726
727
728 if (searchReady) {
729 await searchEvent("file.updated", { workspaceId: ws.workspaceId, fileId, name: "after.png" });
730 const mirror = await app.prisma.searchDocument.findUnique({
731 where: { type_refId: { type: "file", refId: fileId } },
732 });
733 expect(mirror?.title).toBe("after.png");
734 }
735 });
736 },
737);
738