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.
69 lines
3.1 KiB
TypeScript
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");
|
|
});
|
|
});
|