/** * AI Software Factory — Integration: Files (Phase 9, TASK-0101) * * The upload contract against the REAL stack: NestJS API + Testcontainers * Postgres/Redis + the host MinIO (default S3_ENDPOINT http://localhost:9000, * buckets `factory-files` / `factory-thumbs`). Per the spec (docs/tasks/ * phase-09-files.md, TASK-0101 §Acceptance) every scenario below is an * acceptance criterion — not an implementation detail: * * A. plan boundary — 60 MB → 413 FILE_TOO_LARGE (free) vs 201 (paid) * B. size mismatch — PUT ≠ declared size → 422 UPLOAD_INCOMPLETE, no READY row * C. content-type mismatch — the presigned PUT is SIGNED with the claimed * content-type; a different Content-Type → S3 403s the PUT before any * object exists → completion reports 410 UPLOAD_EXPIRED. * (REQUIREMENT CONFLICT — see the task file, recorded 2026-09-29: the * spec text says "wrong Content-Type … fails completion with * UPLOAD_INCOMPLETE/422"; because content-type is part of the * signature, S3 rejects first and completion can only observe an * absent object. The security property — the wrong-typed bytes never * become stored/downloadable — holds under either status.) * D. MIME spoof — text bytes under an image/png CLAIM pass S3 (the body * is UNSIGNED-PAYLOAD), then completion's authoritative magic-byte * sniff → 415 MIME_MISMATCH + the object is DELETED from the bucket * (asserted via an independent storage-client list) + row FAILED. * E. happy path — 1 KB PNG claimed image/png → 201 → SCANNING + * `file.uploaded` audit + spec key layout. (READY is TASK-0102.) * F. signed GET — member: 302 → URL that (a) works, (b) 403s with a * tampered signature, (c) 403s when reused against ANOTHER object's * key (key binding); non-member: 403 FORBIDDEN + `file.download_denied`. * G. signed-URL expiry — a REAL shortened signed window: PUT after expiry * → S3 403, completion → 410 UPLOAD_EXPIRED (the 900 s value is the * default; 2 s exercises the identical server-side path). * H. bucket privacy — unsigned GETs on raw keys → 403 in BOTH buckets. * I. safeName — the key for `../../etc/passwd` is the sanitized `passwd` * (the full matrix is unit-tested in @factory/storage keys.test.ts). * * The worker pipeline (SCANNING → READY) is TASK-0102; this spec asserts up * to the SCANNING hand-off, which is this task's contract. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { S3Client, type S3Config } from "@factory/storage"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { authHeaders, createWorkspace, createUser } from "./helpers/seed"; const HERE = path.dirname(fileURLToPath(import.meta.url)); // Env singleton reset (the API memoizes loadEnv(); test G needs a fresh // read with a shortened sign expiry). Import the SOURCE file via an // absolute file:// URL — the same pattern helpers/api.ts uses for the app // module — so vite resolves the SAME module instance the app uses. const { resetEnvCache } = await import( /* webpackIgnore: true */ pathToFileURL(path.resolve(HERE, "..", "..", "apps", "api", "src", "config", "env.ts")).href ); // ── MinIO gate ────────────────────────────────────────────────────────────── /** The object store the API talks to (the API's S3_ENDPOINT default). */ const S3_ENDPOINT = (process.env.S3_ENDPOINT ?? "http://localhost:9000").replace(/\/+$/, ""); async function minioUp(url: string): Promise { try { const res = await fetch(`${url}/minio/health/live`, { signal: AbortSignal.timeout(4_000) }); return res.ok; } catch { return false; } } /** Collection-time gate: the acceptance criteria are untestable without the * object store, so (like the search spec) the whole suite skips cleanly. */ const minioReady = await minioUp(S3_ENDPOINT); // ── storage client (test-side; independent of the API's own client) ──────── const filesConfig: S3Config = { endpoint: S3_ENDPOINT, region: process.env.S3_REGION ?? "us-east-1", bucket: process.env.S3_BUCKET_FILES ?? "factory-files", accessKeyId: process.env.S3_ACCESS_KEY ?? "minioadmin", secretAccessKey: process.env.S3_SECRET_KEY ?? "minioadmin", forcePathStyle: true, }; const thumbsConfig: S3Config = { ...filesConfig, bucket: process.env.S3_BUCKET_THUMBS ?? "factory-thumbs" }; const filesClient = new S3Client({ config: filesConfig }); // ── fixtures ──────────────────────────────────────────────────────────────── /** 1×1 red pixel, 67 bytes — a REAL png signature (magic bytes verify). */ const PNG_BYTES = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // signature 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND 0x44, 0xae, 0x42, 0x60, ]); /** 1 KB of plain text — NO magic signature (the spoof fixture). */ const TEXT_BYTES = new TextEncoder().encode( "this is definitely a text file, not a png ".repeat(16).slice(0, 1024), ); const FREE_LIMIT = 50 * 1024 * 1024; // 50 MB const OVER_FREE = 60 * 1024 * 1024; // 60 MB — over free, under paid (500 MB) // ── helpers ───────────────────────────────────────────────────────────────── let app: TestAppHandle; const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; const errCode = (body: Record): string => ((body.error as { code?: string } | undefined)?.code ?? ""); /** A fresh user + workspace (owner = OWNER, billingPlan = "free"). * * `headers` binds to THIS workspace via `x-factory-workspace`: the auth * guard resolves the workspace from that header when present, otherwise from * `claims.workspaces[0]` — which is the FIRST membership a user accumulates * across a test run. Without the header, a second `newWorkspace()` for the * same user would silently route uploads to the first (free) workspace, * defeating plan-boundary tests. */ async function newWorkspace(name = "Files WS"): Promise<{ headers: Record; workspaceId: string; userId: string; }> { const user = await createUser(app.request, { name: "File Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(user.accessToken!), name); return { headers: { ...authHeaders(user.accessToken!), "x-factory-workspace": workspaceId }, workspaceId, userId: user.userId, }; } /** Flip a workspace to a billing plan (test-side; the billing admin * endpoints are out of scope for TASK-0101). */ async function setPlan(workspaceId: string, plan: string): Promise { await app.prisma.workspace.update({ where: { id: workspaceId }, data: { billingPlan: plan } }); } interface Upload { file: { id: string; name: string; status?: string }; uploadUrl: string; method: string; headers: Record; expiresAt: string; } /** Open an upload session; asserts 201 and returns the payload. */ async function openUpload( headers: Record, body: Record, ): Promise<{ status: number; upload?: Upload; raw: Record }> { const res = await app.request("POST", "/api/v1/files/uploads", { headers, body }); const raw = await json(res); if (res.status !== 201) return { status: res.status, raw }; return { status: 201, upload: raw.upload as Upload, raw }; } /** PUT bytes to a presigned URL with EXACTLY the headers the API returned * (content-type is signed — do not add or change headers). */ async function putToUrl(url: string, bytes: Uint8Array, headers: Record): Promise { return fetch(url, { method: "PUT", headers: headers as Record, body: bytes as unknown as BodyInit, }); } async function complete(headers: Record, uploadId: string): Promise<{ status: number; body: Record }> { const res = await app.request("PUT", `/api/v1/files/uploads/${uploadId}/complete`, { headers, body: {} }); return { status: res.status, body: await json(res) }; } async function fileRow(fileId: string) { return app.prisma.file.findUnique({ where: { id: fileId } }); } async function cancel(headers: Record, uploadId: string): Promise { const res = await app.request("POST", `/api/v1/files/uploads/${uploadId}/cancel`, { headers, body: {} }); return res.status; } /** Does the key exist in the files bucket? */ async function objectExists(key: string): Promise { const { keys } = await filesClient.listObjects(key, 5); return keys.includes(key); } /** Upload + PUT + complete a real PNG; returns the file id (the happy path). */ async function uploadPng(headers: Record, name = "pixel.png"): Promise { const { status, upload } = await openUpload(headers, { name, mimeType: "image/png", size: PNG_BYTES.length, }); expect(status).toBe(201); const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers); expect(put.status).toBe(200); const c = await complete(headers, upload!.file.id); expect(c.status).toBe(200); return upload!.file.id; } describe.skipIf(!minioReady)( "Files integration (TASK-0101 — storage contract)", () => { beforeAll(async () => { app = await createTestApp(); // The API reads S3_* from the environment; the defaults already point // at the host MinIO with the two spec buckets (minio-init bootstrap). }, 180_000); afterAll(async () => { await app?.close(); }); // ── A. plan boundary (free 50 MB vs paid 500 MB) ──────────────────── it("A: 60 MB session → 413 FILE_TOO_LARGE (free), 201 (paid) — plan boundary proven", async () => { const { headers, workspaceId } = await newWorkspace("Plan Boundary WS"); const free = await openUpload(headers, { name: "big.bin", mimeType: "application/octet-stream", size: OVER_FREE, }); expect(free.status).toBe(413); expect(errCode(free.raw)).toBe("FILE_TOO_LARGE"); const details = (free.raw.error as { details?: { maxBytes?: number }[] } | undefined)?.details ?? []; expect(details[0]?.maxBytes).toBe(FREE_LIMIT); // names 50 MB const row = await app.prisma.file.findFirst({ where: { workspaceId, name: "big.bin" } }); expect(row).toBeNull(); // no row for a rejected session await setPlan(workspaceId, "paid"); const paid = await openUpload(headers, { name: "big.bin", mimeType: "application/octet-stream", size: OVER_FREE, }); expect(paid.status).toBe(201); expect(paid.upload?.file.name).toBe("big.bin"); await cancel(headers, paid.upload!.file.id); // session never completed }); // ── B. size mismatch → 422 UPLOAD_INCOMPLETE ──────────────────────── it("B: PUT smaller than declared → 422 UPLOAD_INCOMPLETE; row never READY", async () => { const { headers } = await newWorkspace(); const { status, upload } = await openUpload(headers, { name: "declared-10kb.png", mimeType: "image/png", size: PNG_BYTES.length + 5_000, // declare more than we send }); expect(status).toBe(201); const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers); expect(put.status).toBe(200); // S3 accepts; the size is re-checked at complete const c = await complete(headers, upload!.file.id); expect(c.status).toBe(422); expect(errCode(c.body)).toBe("UPLOAD_INCOMPLETE"); const row = await fileRow(upload!.file.id); expect(row?.status).toBe("UPLOADING"); // never advanced to SCANNING/READY }); // ── C. content-type mismatch (SIGNED) — requirement-conflict case ─── it("C: PUT with a WRONG Content-Type → S3 403 (signature) → 410 UPLOAD_EXPIRED; object never lands", async () => { const { headers } = await newWorkspace(); const { status, upload } = await openUpload(headers, { name: "ct.png", mimeType: "image/png", size: PNG_BYTES.length, }); expect(status).toBe(201); // The URL is signed for content-type: image/png. A different // Content-Type breaks the SigV4 verification BEFORE any object exists. const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, { ...upload!.headers, "Content-Type": "text/plain", }); expect(put.status).toBe(403); const key = (await fileRow(upload!.file.id))!.key; expect(await objectExists(key)).toBe(false); // nothing stored const c = await complete(headers, upload!.file.id); // CONFLICT RECORDED in the task file (2026-09-29): the spec text // expects 422 UPLOAD_INCOMPLETE here; because content-type is signed // into the PUT, completion can only observe the absent object → 410 // UPLOAD_EXPIRED. The security property (no wrong-typed object) holds. expect(c.status).toBe(410); expect(errCode(c.body)).toBe("UPLOAD_EXPIRED"); }); // ── D. MIME spoof → 415 MIME_MISMATCH + object deleted ────────────── it("D: text bytes claimed image/png → 415 MIME_MISMATCH; object DELETED from bucket; row FAILED", async () => { const { headers } = await newWorkspace(); const { status, upload } = await openUpload(headers, { name: "really-text.png", mimeType: "image/png", size: TEXT_BYTES.length, }); expect(status).toBe(201); const put = await putToUrl(upload!.uploadUrl, TEXT_BYTES, upload!.headers); expect(put.status).toBe(200); // body is UNSIGNED-PAYLOAD — S3 accepts const c = await complete(headers, upload!.file.id); expect(c.status).toBe(415); expect(errCode(c.body)).toBe("MIME_MISMATCH"); const row = await fileRow(upload!.file.id); expect(await objectExists(row!.key)).toBe(false); // deleted from the bucket expect(row?.status).toBe("FAILED"); const scan = (row?.scanReport ?? {}) as Record; expect(scan.result).toBe("mismatch"); // security-sensitive rejection → audit row (AGENTS.md §27) const audit = await app.prisma.auditLog.findFirst({ where: { action: "file.mime_mismatch", resourceId: upload!.file.id }, }); expect(audit).not.toBeNull(); }); // ── E. happy path → SCANNING + audit + spec key layout ────────────── it("E: 1 KB PNG claimed image/png → SCANNING + file.uploaded audit + files/{ws}/{fileId}/1/{name} key", async () => { const { headers } = await newWorkspace(); const { status, upload } = await openUpload(headers, { name: "pixel.png", mimeType: "image/png", size: PNG_BYTES.length, }); expect(status).toBe(201); // session URL shape: PUT method, signed headers, 900 s expiry expect(upload!.method).toBe("PUT"); expect(upload!.headers["Content-Type"]).toBe("image/png"); expect(new URL(upload!.uploadUrl).searchParams.get("X-Amz-Expires")).toBe("900"); const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers); expect(put.status).toBe(200); const c = await complete(headers, upload!.file.id); expect(c.status).toBe(200); expect((c.body as { status: string }).status).toBe("SCANNING"); const row = await fileRow(upload!.file.id); expect(row?.status).toBe("SCANNING"); expect(row?.size).toBe(PNG_BYTES.length); expect(await objectExists(row!.key)).toBe(true); expect(row!.key).toBe(`files/${row!.workspaceId}/${row!.id}/1/pixel.png`); const audit = await app.prisma.auditLog.findFirst({ where: { action: "file.uploaded", resourceId: upload!.file.id }, }); expect(audit).not.toBeNull(); expect((audit?.metadata as Record).size).toBe(PNG_BYTES.length); }); // ── F. signed GET: works + sig tamper + key binding + authorization ── it("F: member 302→signed GET works; tampered sig 403; foreign key 403; non-member 403 + audit", async () => { const owner = await newWorkspace("Signed GET WS"); // a completely different tenant (no membership → no VIEW) const stranger = await createUser(app.request, { name: "Outsider" }); const strangerHeaders = authHeaders(stranger.accessToken!); const fileId = await uploadPng(owner.headers, "signed.png"); // 1) member: 302 + a working signed GET (200, same bytes). // redirect:"manual" — the helper's fetch follows redirects by // default and would otherwise chase the 302 straight to MinIO // (reporting 200) before we can inspect the Location. const dl = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: owner.headers, redirect: "manual", }); expect(dl.status).toBe(302); const location = dl.headers.get("location")!; expect(location).toContain("X-Amz-Signature="); expect(location).toContain("X-Amz-Expires=900"); const got = await fetch(location, { redirect: "manual" }); expect(got.status).toBe(200); const body = new Uint8Array(await got.arrayBuffer()); expect(body.length).toBe(PNG_BYTES.length); expect(body[0]).toBe(0x89); // still a png // 2) tamper the signature → S3 rejects (the URL is bound to its sig) const tampered = location.replace(/X-Amz-Signature=[0-9a-f]+/, `X-Amz-Signature=${"0".repeat(64)}`); const bad = await fetch(tampered, { redirect: "manual" }); expect(bad.status).toBe(403); // 3) KEY BINDING: the signature covers the exact object key — reuse // the SAME signature against a different object's key → 403. const otherKey = (await fileRow(fileId))!.key; const second = await uploadPng(owner.headers, "second.png"); const secondKey = (await fileRow(second))!.key; expect(secondKey).not.toBe(otherKey); const u = new URL(location); // Object keys are raw path segments (not URI-encoded) in the signed // URL, so swap them verbatim — the signature still covers the OLD key. u.pathname = u.pathname.replace(otherKey, secondKey); const cross = await fetch(u.toString(), { redirect: "manual" }); expect(cross.status).toBe(403); // 4) non-member → 403 FORBIDDEN + file.download_denied audit const denied = await app.request("GET", `/api/v1/files/${fileId}/download`, { headers: strangerHeaders }); expect(denied.status).toBe(403); expect(errCode(await json(denied))).toBe("FORBIDDEN"); const audit = await app.prisma.auditLog.findFirst({ where: { action: "file.download_denied", resourceId: fileId }, }); expect(audit).not.toBeNull(); expect(audit?.actorId).toBe(stranger.userId); }); // ── G. real signed-URL expiry (the 900 s contract, 2 s in the test) ── it("G: PUT after the signed window closes → S3 403 → completion 410 UPLOAD_EXPIRED", async () => { // Shorten the expiry for this ONE session: the API reads // S3_SIGN_EXPIRY_SECONDS at session-creation time, and loadEnv() is // memoized — reset the cache so the new value is seen. 2 s exercises // the identical server-side path the 900 s default would. const prev = process.env.S3_SIGN_EXPIRY_SECONDS; process.env.S3_SIGN_EXPIRY_SECONDS = "2"; resetEnvCache(); try { const { headers } = await newWorkspace("Expiry WS"); const { upload } = (await openUpload(headers, { name: "expiring.png", mimeType: "image/png", size: PNG_BYTES.length, })) as { upload?: Upload }; expect(new URL(upload!.uploadUrl).searchParams.get("X-Amz-Expires")).toBe("2"); // wait out the signed window (+1 s clock-skew margin) await new Promise((r) => setTimeout(r, 3_000)); const put = await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers); expect(put.status).toBe(403); // RequestExpired — the signature is dead const c = await complete(headers, upload!.file.id); expect(c.status).toBe(410); expect(errCode(c.body)).toBe("UPLOAD_EXPIRED"); } finally { if (prev === undefined) delete process.env.S3_SIGN_EXPIRY_SECONDS; else process.env.S3_SIGN_EXPIRY_SECONDS = prev; resetEnvCache(); } }); // ── H. bucket privacy (both buckets) ───────────────────────────────── it("H: unsigned GETs on raw keys → 403 in BOTH the files and thumbs buckets", async () => { const { headers } = await newWorkspace("Privacy WS"); const fileId = await uploadPng(headers, "private.png"); const key = (await fileRow(fileId))!.key; const wsId = (await fileRow(fileId))!.workspaceId; // files bucket: anonymous GET on a REAL object key → 403 (private) const rawFiles = await fetch(`${filesConfig.endpoint}/${filesConfig.bucket}/${key}`, { redirect: "manual" }); expect(rawFiles.status).toBe(403); // thumbs bucket: no thumb exists yet (the worker writes them — // TASK-0102), but a private bucket 403s anonymous callers on any key, // including the exact key layout a 160-px thumb would use // (stem + same-format ext: private.png). const thumbKey = `thumbs/${wsId}/${fileId}/160/private.png`; const rawThumbs = await fetch(`${thumbsConfig.endpoint}/${thumbsConfig.bucket}/${thumbKey}`, { redirect: "manual" }); expect(rawThumbs.status).toBe(403); }); // ── I. safeName in the real key (traversal is squashed) ────────────── it("I: a traversal name gets the SANITIZED key (…/etc/passwd → …/1/passwd)", async () => { const { headers } = await newWorkspace("Traversal WS"); const { upload } = (await openUpload(headers, { name: "../../etc/passwd", mimeType: "text/plain", size: 100, })) as { upload?: Upload }; expect(upload).toBeDefined(); const row = await fileRow(upload!.file.id); // the stored NAME keeps the client's string; the KEY never does. expect(row!.name).toBe("../../etc/passwd"); expect(row!.key).toBe(`files/${row!.workspaceId}/${row!.id}/1/passwd`); expect(row!.key).not.toContain(".."); // (the full 12-case safeName matrix is unit-tested in @factory/storage // keys.test.ts — unit vs integration split, AGENTS.md §9) await cancel(headers, upload!.file.id); }); // ── cancel (session hygiene) ───────────────────────────────────────── it("cancel: deletes the partial object AND the row", async () => { const { headers } = await newWorkspace(); const { upload } = (await openUpload(headers, { name: "partial.png", mimeType: "image/png", size: PNG_BYTES.length, })) as { upload?: Upload }; await putToUrl(upload!.uploadUrl, PNG_BYTES, upload!.headers); const key = (await fileRow(upload!.file.id))!.key; const status = await cancel(headers, upload!.file.id); expect(status).toBe(200); expect(await objectExists(key)).toBe(false); expect(await fileRow(upload!.file.id)).toBeNull(); }); // ── J. list endpoint (TASK-0103: filters + enrichment + keyset cursor) ── it("J: list — kind/status/search filters, page scope, uploader/page-name enrichment, keyset cursor (name + size), workspace isolation", async () => { const owner = await newWorkspace("List WS"); // a second, independent tenant (workspace isolation) const stranger = await newWorkspace("List WS — other tenant"); // two real pages (page-scope + pageName enrichment) const pageA = await app.prisma.page.create({ data: { workspaceId: owner.workspaceId, title: "Page A" }, }); const pageB = await app.prisma.page.create({ data: { workspaceId: owner.workspaceId, title: "Page B" }, }); // 6 PNGs: a0..a3 on page A, b0 on page B, n0 on no page — distinct // sizes (100..105) so the size-sort cursor is deterministic. const names = ["a0.png", "a1.png", "a2.png", "a3.png", "b0.png", "n0.png"]; const ids: string[] = []; for (let i = 0; i < names.length; i++) { const fileId = await uploadPng(owner.headers, names[i]); ids.push(fileId); await app.prisma.file.update({ where: { id: fileId }, data: { pageId: names[i].startsWith("a") ? pageA.id : names[i].startsWith("b") ? pageB.id : null, size: 100 + i, }, }); } const list = async (qs: string, headers = owner.headers) => { const res = await app.request("GET", `/api/v1/files?${qs}`, { headers }); expect(res.status).toBe(200); return (await res.json()) as { data: Record[]; nextCursor: string | null }; }; // 1) kind filter — all 6 are pngs: image → 6, video → 0 expect((await list("scope=workspace&kind=image&limit=50")).data).toHaveLength(6); expect((await list("scope=workspace&kind=video&limit=50")).data).toHaveLength(0); // 2) status filter — all rows are SCANNING (no worker in this app) expect((await list("status=SCANNING&limit=50")).data).toHaveLength(6); expect((await list("status=READY&limit=50")).data).toHaveLength(0); // 3) search — case-insensitive contains const searched = await list("search=A2&limit=50"); expect(searched.data).toHaveLength(1); expect(searched.data[0].name).toBe("a2.png"); // 4) page scope — page A returns exactly its 4 files (not page B, not no-page) const pageAScope = await list(`scope=page&pageId=${pageA.id}&limit=50`); expect(pageAScope.data).toHaveLength(4); expect(pageAScope.data.every((d) => d.pageId === pageA.id)).toBe(true); // 5) enrichment — uploader name + page name + version count const one = (await list("search=a0&limit=50")).data[0]; expect(one.uploadedByName).toBe("File Owner"); expect(one.pageName).toBe("Page A"); expect(one.versionCount).toBe(1); // 6) keyset cursor (name asc, limit 3 → 2 pages: no overlap, no omission) const p1 = await list("sort=name&limit=3"); expect(p1.data).toHaveLength(3); expect(p1.nextCursor).toBeTruthy(); const p2 = await list(`sort=name&limit=3&cursor=${encodeURIComponent(p1.nextCursor!)}`); expect(p2.data).toHaveLength(3); const page1Names = p1.data.map((d) => d.name as string); const page2Names = p2.data.map((d) => d.name as string); expect(page1Names.filter((n) => page2Names.includes(n))).toHaveLength(0); // disjoint expect([...page1Names, ...page2Names].sort()).toEqual([...names].sort()); // complete expect(page1Names).toEqual([...page1Names].sort()); // ordered // 7) keyset cursor on the Int (size) column — desc, limit 2 → pages strictly decrease const s1 = await list("sort=size&limit=2"); expect(s1.data).toHaveLength(2); expect(s1.nextCursor).toBeTruthy(); const s2 = await list(`sort=size&limit=2&cursor=${encodeURIComponent(s1.nextCursor!)}`); expect(s2.data).toHaveLength(2); expect(Math.min(...(s1.data.map((d) => d.size as number)))).toBeGreaterThan( Math.max(...(s2.data.map((d) => d.size as number))) ); // 8) workspace isolation — the stranger's list (their own active // workspace) never shows the owner's 6 files, and vice versa. const strangerList = await list("scope=workspace&limit=50", stranger.headers); expect(strangerList.data).toHaveLength(0); expect(strangerList.data.some((d) => ids.includes(d.id as string))).toBe(false); const ownerList = await list("scope=workspace&limit=50"); expect(ownerList.data.every((d) => (d.workspaceId as string) === owner.workspaceId)).toBe(true); }); }, );