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.
304 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|