import type { PoolClient } from "pg"; import { GroupFilesService } from "./group-files.service.js"; import { hashShareToken } from "./share-token.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { BadRequestException, ConflictException, ForbiddenException, GoneException, NotFoundException } from "@nestjs/common"; /** Deterministic 64-hex helper: repeat a pattern to exactly 64 chars. */ function hex64(seed: string): string { let out = ""; while (out.length < 64) out += Buffer.from(seed).toString("hex"); return out.slice(0, 64); } // --------------------------------------------------------------------------- // Fake db surface: route by SQL shape, capture every statement. // --------------------------------------------------------------------------- type QueryResult = { rows: Record[]; rowCount: number }; type Behavior = (sql: string, values: unknown[]) => QueryResult | Promise; let behavior: Behavior = () => ({ rows: [], rowCount: 0 }); const statements: Array<{ sql: string; values: unknown[] }> = []; vi.mock("../db/client.js", () => ({ withTenant: async (_ws: string, fn: (client: PoolClient) => Promise) => fn({ query: async (sql: string, values: unknown[] = []) => { statements.push({ sql, values }); return behavior(sql, values); }, } as unknown as PoolClient), withSystem: async (fn: (client: PoolClient) => Promise) => fn({ query: async (sql: string, values: unknown[] = []) => { statements.push({ sql, values }); return behavior(sql, values); }, } as unknown as PoolClient), })); const row = { id: "11111111-1111-4111-8111-111111111111", workspace_id: "22222222-2222-4222-8222-222222222222", group_id: "33333333-3333-4333-8333-333333333333", room_id: "!r:example.org", event_id: "$e", origin: "matrix", sender_mxid: "@s:example.org", matrix_event_json: { mxcUri: "mxc://example.org/x" }, original_name: "clip.mp4", safe_name: "clip.mp4", mime: "video/mp4", mime_class: "video", byte_size: "10", sha256: hex64("z"), ocis_resource_id: "res-1", ocis_resource_version: "v1", created_at: new Date("2026-01-02T03:04:05Z"), indexed_at: new Date("2026-01-02T03:04:05Z"), available: true, retention_mode: "keep", legal_hold: false, retention_until: null, share_access: null, share_expires_at: null, share_revoked_at: null, }; /** sha256 hex of a string, computed with the real crypto module. */ function digestOf(input: string): string { // eslint-disable-next-line @typescript-eslint/no-require-imports const { createHash } = require("node:crypto") as typeof import("node:crypto"); return createHash("sha256").update(input, "utf8").digest("hex"); } function envReady(): void { process.env.FILES_OCID_WEBDAV_URL = "https://files.example.org"; process.env.FILES_OCID_USER = "svc"; process.env.FILES_OCID_PASSWORD = "pw"; process.env.FILES_PUBLIC_BASE = "https://files.example.org"; } beforeEach(() => { statements.length = 0; behavior = () => ({ rows: [], rowCount: 0 }); envReady(); }); describe("GroupFilesService invariants", () => { it("scopes listings to group membership for non-Owner/Admin, binding the caller id positionally", async () => { behavior = () => ({ rows: [row], rowCount: 1 }); const service = new GroupFilesService(); const page = await service.listFiles(row.workspace_id, "user-1", "Publisher", {}); const list = statements.find((q) => q.sql.includes("FROM chat_media_archive"))!; expect(list.sql).toContain("group_members"); expect(list.values[1]).toBe("user-1"); // caller id is a bound parameter… expect(list.sql).not.toContain("user-1"); // …never SQL text expect(list.sql).not.toContain("user-1"); // Owner/Admin skip the membership subquery but keep $2 bound await service.listFiles(row.workspace_id, "user-1", "Owner", {}); const ownerList = statements.filter((q) => q.sql.includes("FROM chat_media_archive")).at(-1)!; expect(ownerList.sql).not.toContain("group_members"); expect(ownerList.values[1]).toBe("user-1"); expect(page.items[0]?.status).toBe("available"); expect(page.items[0]?.shareLink.url).toBe("https://files.example.org/f/res-1"); }); it("filters facets positionally and paginates with a (createdAt,id) keyset cursor", async () => { behavior = () => ({ rows: [row], rowCount: 1 }); const service = new GroupFilesService(); const cursor = Buffer.from(JSON.stringify({ createdAt: "2026-01-01T00:00:00Z", id: row.id })).toString("base64url"); await service.listFiles(row.workspace_id, "u", "Owner", { groupId: row.group_id, senderMxid: "@s:example.org", mimeClass: "video", name: "clip", from: "2026-01-01", to: "2026-02-01", cursor, limit: "10", }); const q = statements.find((s) => s.sql.includes("FROM chat_media_archive"))!; expect(q.values).toEqual([row.workspace_id, "u", row.group_id, "@s:example.org", "video", "%clip%", "2026-01-01", "2026-02-01", "2026-01-01T00:00:00Z", row.id, 11]); expect(q.sql).toContain("(a.created_at, a.id) <"); expect(q.sql).toContain("ORDER BY a.created_at DESC, a.id DESC"); // bad facets are rejected before SQL await expect(service.listFiles(row.workspace_id, "u", "Owner", { mimeClass: "zip" })).rejects.toBeInstanceOf(BadRequestException); await expect(service.listFiles(row.workspace_id, "u", "Owner", { from: "nope" })).rejects.toBeInstanceOf(BadRequestException); await expect(service.listFiles(row.workspace_id, "u", "Owner", { from: "2026-03-01", to: "2026-01-01" })).rejects.toBeInstanceOf(BadRequestException); }); it("emits no presigned URL anywhere in a served payload", async () => { behavior = () => ({ rows: [row], rowCount: 1 }); const service = new GroupFilesService(); const page = await service.listFiles(row.workspace_id, "u", "Owner", {}); expect(JSON.stringify(page)).not.toContain("X-Amz-Signature"); expect(JSON.stringify(page)).not.toContain("presign"); expect(page.items[0]?.shareLink.url).toBe("https://files.example.org/f/res-1"); }); it("keeps retention independent from share expiry, and legalHold is an orthogonal flag", async () => { behavior = () => ({ rows: [{ ...row, share_access: "public", share_expires_at: new Date("2020-01-01T00:00:00Z") }], rowCount: 1, }); const service = new GroupFilesService(); const item = await service.getFile(row.workspace_id, "u", "Owner", row.id); // share link long gone; the FILE itself is still fully available expect(item.status).toBe("available"); expect(item.shareLink.expiresAt).toBe("2020-01-01T00:00:00.000Z"); expect(item.retention.mode).toBe("keep"); // legal hold survives alongside the mode (FileRetention contract) behavior = () => ({ rows: [{ ...row, legal_hold: true }], rowCount: 1 }); const held = await service.getFile(row.workspace_id, "u", "Owner", row.id); expect(held.retention).toEqual({ mode: "keep", legalHold: true }); expect(held.status).toBe("available"); }); it("reconstructs all four origin kinds from matrix_event_json", async () => { const cases = [ ["matrix", { mxcUri: "mxc://e/x" }, { kind: "matrix", mxcUri: "mxc://e/x" }], ["bridge", { mxcUri: "mxc://e/x", providerId: "telegram", remoteEventId: "9" }, { kind: "bridge", mxcUri: "mxc://e/x", providerId: "telegram", remoteEventId: "9" }], ["upload", null, { kind: "upload" }], ["pad-export", { padId: "p", revision: "3" }, { kind: "pad-export", padId: "p", revision: "3" }], ] as const; for (const [origin, json, expected] of cases) { behavior = () => ({ rows: [{ ...row, origin, matrix_event_json: json }], rowCount: 1 }); const service = new GroupFilesService(); const item = await service.getFile(row.workspace_id, "u", "Owner", row.id); expect(item.origin).toEqual(expected); } }); it("returns the share token exactly once and keeps it out of audit rows", async () => { let auditedDiff: unknown = null; behavior = (sql) => { if (sql.startsWith("SELECT audit_append")) return { rows: [{ id: "audit-1" }], rowCount: 1 }; if (sql.startsWith("INSERT INTO group_file_shares")) { return { rows: [{ id: "55555555-5555-4555-8555-555555555555", access: "public", expires_at: new Date(Date.now() + 3_600_000), revoked_at: null }], rowCount: 1, }; } return { rows: [row], rowCount: 1 }; }; const service = new GroupFilesService(); const share = await service.createShare(row.workspace_id, row.id, { access: "public", expiresInHours: 2 }, { userId: "u", role: "Owner" }); expect(share.token).toBeDefined(); expect(hashShareToken(share.token)).toMatch(/^[0-9a-f]{64}$/); expect(share.url).toContain("/files/s/"); const auditCall = statements.find((q) => q.sql.startsWith("SELECT audit_append")); expect(auditCall).toBeDefined(); auditedDiff = auditCall?.values[6]; expect(JSON.stringify(auditedDiff)).not.toContain(share.token); expect(JSON.stringify(auditCall?.values)).not.toContain(share.token); // shape errors before any write await expect(service.createShare(row.workspace_id, row.id, { access: "secret" }, { userId: "u", role: "Owner" })).rejects.toBeInstanceOf(BadRequestException); await expect(service.createShare(row.workspace_id, row.id, { expiresInHours: 0 }, { userId: "u", role: "Owner" })).rejects.toBeInstanceOf(BadRequestException); }); it("revoking stops serving immediately: item + every live share flipped", async () => { let flipped = false; behavior = (sql) => { if (sql.startsWith("UPDATE chat_media_archive SET available = false")) flipped = true; return { rows: [flipped ? { ...row, available: false } : row], rowCount: 1 }; }; const service = new GroupFilesService(); const revoked = await service.markRevoked(row.workspace_id, row.id, { userId: "u", role: "Owner" }); expect(revoked.status).toBe("revoked"); const shareRevoke = statements.find((q) => q.sql.startsWith("UPDATE group_file_shares SET revoked_at")); expect(shareRevoke).toBeDefined(); expect(shareRevoke?.values[0]).toBe(row.id); // already-revoked is a conflict, not a silent no-op behavior = () => ({ rows: [{ ...row, available: false }], rowCount: 1 }); await expect(service.markRevoked(row.workspace_id, row.id, { userId: "u", role: "Owner" })).rejects.toBeInstanceOf(ConflictException); }); it("replay of an attachment event is a conflict (partial unique (ws,room,event))", async () => { behavior = (sql) => { if (sql.startsWith("INSERT INTO chat_media_archive")) { throw Object.assign(new Error("dup"), { code: "23505" }); } return { rows: [], rowCount: 0 }; }; const service = new GroupFilesService(); await expect( service.registerArchiveEntry(row.workspace_id, { groupId: row.group_id, roomId: "!r:example.org", eventId: "$e", origin: {}, originKind: "matrix", senderMxid: "@s:example.org", originalName: "a.mp4", safeName: "a.mp4", mime: "video/mp4", mimeClass: "video", byteSize: 10, sha256: hex64("z"), ocisResourceVersion: null, ocisResourceId: "res", createdAt: new Date(), }), ).rejects.toBeInstanceOf(ConflictException); }); it("openDownload refuses revoked/expired items with Gone, missing rows with NotFound", async () => { behavior = () => ({ rows: [{ ...row, available: false }], rowCount: 1 }); const service = new GroupFilesService(); await expect(service.openDownload(row.workspace_id, "u", "Owner", row.id)).rejects.toBeInstanceOf(GoneException); behavior = () => ({ rows: [{ ...row, retention_mode: "until", retention_until: new Date("2020-01-01T00:00:00Z") }], rowCount: 1 }); await expect(service.openDownload(row.workspace_id, "u", "Owner", row.id)).rejects.toBeInstanceOf(GoneException); behavior = () => ({ rows: [], rowCount: 0 }); await expect(service.getFile(row.workspace_id, "u", "Owner", row.id)).rejects.toBeInstanceOf(NotFoundException); await expect(service.getFile(row.workspace_id, "u", "Owner", "not-a-uuid")).rejects.toBeInstanceOf(BadRequestException); }); it("membership gate denies non-members (Forbidden)", async () => { behavior = (sql) => { if (sql.includes("FROM group_members")) return { rows: [], rowCount: 0 }; if (sql.includes("FROM chat_media_archive")) return { rows: [row], rowCount: 1 }; return { rows: [], rowCount: 0 }; }; const service = new GroupFilesService(); await expect(service.getFile(row.workspace_id, "u", "Publisher", row.id)).rejects.toBeInstanceOf(ForbiddenException); }); it("public claim resolves via the SECURITY DEFINER lookup, then rechecks in-tenant", async () => { behavior = (sql) => { if (sql.includes("share_for_public_claim")) { return { rows: [{ id: "s1", workspace_id: row.workspace_id, item_id: row.id, access: "public", expires_at: new Date(Date.now() + 3600_000), revoked_at: null }], rowCount: 1 }; } return { rows: [{ ...row, sha256: digestOf("payload-bytes") }], rowCount: 1 }; }; const payload = Buffer.from("payload-bytes"); const { createHash } = await import("node:crypto"); const digest = createHash("sha256").update(payload).digest("hex"); const { Readable } = await import("node:stream"); const fetcher = (async () => new Response(Readable.toWeb(Readable.from([payload])) as unknown as ReadableStream, { status: 200, headers: { "content-type": "application/octet-stream", "content-length": String(payload.byteLength) }, })) as unknown as typeof fetch; const service = new GroupFilesService({ fetcher }); const handle = await service.claimPublicDownload("some-token"); expect(handle.item.id).toBe(row.id); expect(handle.item.checksum.value).toBe(digest); expect(handle.download.sizeBytes).toBe(payload.byteLength); const claim = statements.find((q) => q.sql.includes("share_for_public_claim")); expect(claim).toBeDefined(); // members-only tokens are rejected on the public surface behavior = (sql) => { if (sql.includes("share_for_public_claim")) { return { rows: [{ id: "s1", workspace_id: row.workspace_id, item_id: row.id, access: "members", expires_at: new Date(Date.now() + 3600_000), revoked_at: null }], rowCount: 1 }; } return { rows: [row], rowCount: 1 }; }; await expect(service.claimPublicDownload("some-token")).rejects.toBeInstanceOf(ForbiddenException); // unknown token: 404, no tenant entered behavior = () => ({ rows: [], rowCount: 0 }); await expect(service.claimPublicDownload("nope")).rejects.toBeInstanceOf(NotFoundException); }); });