T-HUB/apps/api/src/group-files/group-files.service.test.ts
glass beadian d58db63fe6 Add group cloud: files index api, web UI, archive sync bot
The Telegram-like group file contract (packages/core shared-files.ts) becomes
a working subsystem, built in three parallel tracks:

API (NestJS): migration 029 group_files (chat_media_archive metadata-only
index with partial replay-unique + RLS, group_file_shares with sha256 token
persistence, archive_sync_state/outbox durable-claim seam incl. a unique
claim_key index), group-files module (list/search with cursor pagination,
direct upload ≤20 MiB streamed+hashed to oCIS WebDAV, download with ACL +
retention rechecks, share links with expiry, retention modes incl.
legalHold) — RBAC group-membership authz + audit chain events.

Web: Files section per group — group picker, drag-drop upload (20 MiB cap),
search + MIME/origin filters, cursor pagination, download via authenticated
blob fetch, share dialog with expiry + shown-once token, admin retention
dialog; bilingual EN/ES (wiki i18n pattern); nav sidebar/palette/bottom-nav
wiring; MIME classes aligned to the DB CHECK (document/other).

Archive bot (apps/archive-bot): plain sync-loop Matrix bot mirroring
attachments from explicitly-mapped unencrypted archive rooms — raw /sync
with durable since-cursor, outbox-claimed ingestion (hash-verified stream,
no temp files), magic-byte MIME sniffing, safe-name normalization, WebDAV
upload, indexed + replied with link, redaction→unavailable; health endpoint;
Dockerfile. Tests 74/74, typecheck clean across api/web/bot.
2026-10-11 08:21:11 +00:00

304 lines
15 KiB
TypeScript

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<string, unknown>[]; rowCount: number };
type Behavior = (sql: string, values: unknown[]) => QueryResult | Promise<QueryResult>;
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<unknown>) =>
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<unknown>) =>
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);
});
});