T-HUB/apps/archive-bot/src/matrix.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

69 lines
3.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { PermanentArchiveError } from "./inspector.js";
import { parseSync, resolveRedirect, supportsAuthenticatedMedia, syncFilter } from "./matrix.js";
const HS = new URL("https://matrix.example.org");
describe("supportsAuthenticatedMedia", () => {
it("requires v1.11+ or the MSC3916 stable flag", () => {
expect(supportsAuthenticatedMedia({ versions: ["v1.10", "v1.11"] })).toBe(true);
expect(supportsAuthenticatedMedia({ versions: ["v1.12"] })).toBe(true);
expect(supportsAuthenticatedMedia({ versions: ["v1.9"], unstable_features: { "org.matrix.msc3916.stable": true } })).toBe(true);
expect(supportsAuthenticatedMedia({ versions: ["r0.6.1", "v1.10"] })).toBe(false);
expect(supportsAuthenticatedMedia(null)).toBe(false);
});
});
describe("resolveRedirect", () => {
it("keeps auth only for the homeserver origin", () => {
expect(resolveRedirect(HS, "/_matrix/media/x", HS, [])).toMatchObject({ sendAuth: true });
});
it("drops auth for allowlisted object-store hosts", () => {
const hop = resolveRedirect(HS, "https://r2.example.com/bucket/obj?sig=1", HS, ["r2.example.com"]);
expect(hop).toMatchObject({ sendAuth: false });
expect(hop.url.hostname).toBe("r2.example.com");
});
it("refuses unapproved hosts and http downgrades", () => {
expect(() => resolveRedirect(HS, "https://evil.example/x", HS, ["r2.example.com"])).toThrow(PermanentArchiveError);
expect(() => resolveRedirect(HS, "http://r2.example.com/x", HS, ["r2.example.com"])).toThrow(/non-https/);
expect(() => resolveRedirect(HS, "https://matrix.example.org:8448/x", HS, [])).toThrow(PermanentArchiveError);
});
});
describe("parseSync", () => {
it("extracts joined timelines, state and invites, dropping malformed events", () => {
const result = parseSync({
next_batch: "s2",
rooms: {
join: {
"!a:x": {
timeline: { events: [{ type: "m.room.message", event_id: "$1", sender: "@u:x", content: {} }, { junk: true }], limited: true, prev_batch: "p1" },
state: { events: [{ type: "m.room.encryption", event_id: "$s", sender: "@u:x", state_key: "", content: { algorithm: "m.megolm.v1.aes-sha2" } }] },
},
},
invite: { "!b:x": {} },
},
});
expect(result.nextBatch).toBe("s2");
expect(result.invited).toEqual(["!b:x"]);
const room = result.joined.get("!a:x");
expect(room?.timeline).toMatchObject({ limited: true, prevBatch: "p1" });
expect(room?.timeline.events).toHaveLength(1);
expect(room?.stateEvents[0]?.type).toBe("m.room.encryption");
});
it("rejects a response without next_batch", () => {
expect(() => parseSync({})).toThrow();
});
});
describe("syncFilter", () => {
it("restricts to mapped rooms and the needed event types", () => {
const filter = syncFilter(["!a:x"]) as { room: { rooms: string[]; timeline: { types: string[] } } };
expect(filter.room.rooms).toEqual(["!a:x"]);
expect(filter.room.timeline.types).toContain("m.room.redaction");
expect(filter.room.timeline.types).toContain("m.room.encryption");
});
});