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.
71 lines
3.8 KiB
TypeScript
71 lines
3.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { RoomMapping } from "./config.js";
|
|
import { classifyEvent, parseMxc, redactionTarget, type MatrixEvent } from "./events.js";
|
|
|
|
const mapping: RoomMapping = {
|
|
roomId: "!room:example.org",
|
|
workspaceId: "11111111-1111-4111-8111-111111111111",
|
|
groupId: "22222222-2222-4222-8222-222222222222",
|
|
};
|
|
const BOT = "@archive:example.org";
|
|
|
|
function message(content: Record<string, unknown>, extra: Partial<MatrixEvent> = {}): MatrixEvent {
|
|
return { type: "m.room.message", event_id: "$ev1", sender: "@alice:example.org", content, ...extra };
|
|
}
|
|
const image = { msgtype: "m.image", body: "cat.png", url: "mxc://example.org/abc123" };
|
|
|
|
describe("classifyEvent", () => {
|
|
it("accepts an unencrypted m.image with an mxc URI", () => {
|
|
const result = classifyEvent(message(image), mapping, "unencrypted", BOT);
|
|
expect(result.kind).toBe("attachment");
|
|
if (result.kind !== "attachment") return;
|
|
expect(result.candidate).toMatchObject({ roomId: mapping.roomId, eventId: "$ev1", msgtype: "m.image", mxcUri: image.url });
|
|
expect(result.displayName).toBe("cat.png");
|
|
});
|
|
|
|
it("prefers content.filename over body (caption) for the display name", () => {
|
|
const result = classifyEvent(message({ ...image, filename: "real.png", body: "a caption" }), mapping, "unencrypted", BOT);
|
|
expect(result.kind === "attachment" && result.displayName).toBe("real.png");
|
|
});
|
|
|
|
it.each(["m.file", "m.audio", "m.video"])("accepts %s", (msgtype) => {
|
|
expect(classifyEvent(message({ ...image, msgtype }), mapping, "unencrypted", BOT).kind).toBe("attachment");
|
|
});
|
|
|
|
it.each([
|
|
["own-event", message(image, { sender: BOT }), "unencrypted"],
|
|
["room-encrypted", message(image), "encrypted"],
|
|
["room-unknown", message(image), "unknown"],
|
|
["encrypted-event", { ...message(image), type: "m.room.encrypted" }, "unencrypted"],
|
|
["encrypted-file", message({ msgtype: "m.file", body: "x", file: { url: "mxc://example.org/enc" } }), "unencrypted"],
|
|
["edit", message({ ...image, "m.relates_to": { rel_type: "m.replace", event_id: "$old" } }), "unencrypted"],
|
|
["edit", message({ ...image, "m.new_content": image }), "unencrypted"],
|
|
["redacted", message(image, { unsigned: { redacted_because: {} } }), "unencrypted"],
|
|
["not-an-attachment", message({ msgtype: "m.text", body: "hi" }), "unencrypted"],
|
|
["not-a-message", { ...message(image), type: "m.sticker" }, "unencrypted"],
|
|
["ineligible", message({ ...image, url: "https://evil.example/x.png" }), "unencrypted"],
|
|
["ineligible", message({ ...image, url: "mxc://example.org/a/b" }), "unencrypted"],
|
|
["ineligible", message({ msgtype: "m.image", body: "x" }), "unencrypted"],
|
|
] as const)("skips %s", (reason, event, encryption) => {
|
|
const result = classifyEvent(event, mapping, encryption, BOT);
|
|
expect(result).toMatchObject({ kind: "skip", reason });
|
|
});
|
|
|
|
it("classifies redactions regardless of room encryption (v11 content.redacts and legacy top-level)", () => {
|
|
const v11: MatrixEvent = { type: "m.room.redaction", event_id: "$r", sender: "@mod:x", content: { redacts: "$ev1" } };
|
|
const legacy: MatrixEvent = { type: "m.room.redaction", event_id: "$r", sender: "@mod:x", content: {}, redacts: "$ev2" };
|
|
expect(classifyEvent(v11, mapping, "encrypted", BOT)).toMatchObject({ kind: "redaction", redacts: "$ev1" });
|
|
expect(redactionTarget(legacy)).toBe("$ev2");
|
|
expect(classifyEvent({ ...v11, content: {} }, mapping, "unencrypted", BOT).kind).toBe("skip");
|
|
});
|
|
});
|
|
|
|
describe("parseMxc", () => {
|
|
it("splits server and media id", () => {
|
|
expect(parseMxc("mxc://example.org/abc")).toEqual({ serverName: "example.org", mediaId: "abc" });
|
|
});
|
|
it("rejects anything else", () => {
|
|
expect(() => parseMxc("https://example.org/abc")).toThrow();
|
|
expect(() => parseMxc("mxc://example.org/../x")).toThrow();
|
|
});
|
|
});
|