import pino from "pino"; import { beforeEach, describe, expect, it } from "vitest"; import { Archiver } from "./archiver.js"; import { BACKOFF_BASE_MS } from "./backoff.js"; import type { RoomMapping } from "./config.js"; import type { MatrixEvent, RoomEncryption } from "./events.js"; import { claimKey, replyTxnId } from "./keys.js"; import type { MatrixPort, MediaDownload, SyncResult } from "./matrix.js"; import { ArchiveProcessor } from "./processor.js"; import type { ArchiveRecord, ArchiveStore, OutboxRow, RoomCursorTarget } from "./store.js"; import type { IndexedFile } from "./archive-index.js"; import type { LibraryPort, LibraryResource } from "./webdav.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"; const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); function imageEvent(eventId: string, extra: Partial = {}): MatrixEvent { return { type: "m.room.message", event_id: eventId, sender: "@alice:example.org", origin_server_ts: 1_700_000_000_000, content: { msgtype: "m.image", body: "../cat", url: `mxc://example.org/${eventId.slice(1)}` }, ...extra, }; } class FakeMatrix implements MatrixPort { readonly userId = BOT; batches: SyncResult[] = []; syncedWith: (string | null)[] = []; encryption: RoomEncryption = "unencrypted"; notices: { roomId: string; txnId: string; content: Record }[] = []; failNotices = 0; joined: string[] = []; async sync(since: string | null): Promise { this.syncedWith.push(since); const next = this.batches.shift(); if (next === undefined) throw new Error("no batch queued"); return next; } async backfill(): Promise { return []; } async roomEncryption(): Promise { return this.encryption; } async joinRoom(roomId: string): Promise { this.joined.push(roomId); } async downloadMedia(): Promise { return { body: new ReadableStream({ start: (c) => { c.enqueue(PNG); c.close(); } }), contentLength: PNG.byteLength, }; } async sendNotice(roomId: string, txnId: string, content: Record): Promise { if (this.failNotices > 0) { this.failNotices -= 1; throw new Error("notice failed"); } this.notices.push({ roomId, txnId, content }); } powerLevels: Record = { users_default: 0, users: {} }; roomEncryptionByRoom: Map = new Map(); joinedRoomList: string[] = [mapping.roomId]; async roomPowerLevels(roomId: string): Promise> { void roomId; return this.powerLevels; } async joinedRooms(): Promise { return this.joinedRoomList; } } class FakeLibrary implements LibraryPort { failPuts = 0; committed: string[] = []; async putStaging(_dir: string[], _name: string, body: ReadableStream): Promise { for await (const chunk of body) void chunk; if (this.failPuts > 0) { this.failPuts -= 1; throw new Error("WebDAV PUT failed: 507"); } } async commit(dir: string[], _staging: string, finalName: string): Promise { const path = [...dir, finalName].join("/"); this.committed.push(path); return { path, url: `https://files/${path}`, resourceId: "rid", version: "v1" }; } /** files keyed by `${dir}/${name}` — the export read side. */ libraryFiles = new Map(); async getResource(dir: string[], finalName: string): Promise> { const key = [...dir, finalName].join("/"); const bytes = this.libraryFiles.get(key); if (bytes === undefined) throw new Error(`no such resource ${key}`); return new Blob([bytes]).stream() as ReadableStream; } } class FakeStore implements ArchiveStore { cursor: string | null = null; roomsSaved = new Set(); outbox = new Map(); archive: (ArchiveRecord & { available: boolean })[] = []; /** Live share rows per archive record — mirrors group_file_shares. */ shareLinks = new Map(); async loadCursor(_rooms: readonly RoomCursorTarget[]): Promise { return this.cursor; } async saveCursor(rooms: readonly RoomCursorTarget[], cursor: string): Promise { for (const room of rooms) this.roomsSaved.add(room.roomId); this.cursor = cursor; } async claim(_ws: string, _room: string, _event: string, key: string): Promise { const existing = this.outbox.get(key); if (existing !== undefined) return { ...existing }; const row = { id: `ob${this.outbox.size + 1}`, status: "pending" as const, attempts: 0, lastError: null, updatedAt: new Date(0), claimKey: key }; this.outbox.set(key, row); return { ...row }; } async commitArchived(record: ArchiveRecord, outboxId: string): Promise<{ id: string; indexedAt: Date }> { this.archive.push({ ...record, available: true }); const id = `file-${this.archive.length}`; this.shareLinks.set(id, { revokedAt: null }); this.byId(outboxId).status = "done"; return { id, indexedAt: new Date(0) }; } async recordFailure(_ws: string, outboxId: string, error: string, terminal: boolean): Promise { const row = this.byId(outboxId); row.attempts += 1; row.lastError = error; row.status = terminal ? "failed" : "pending"; row.updatedAt = clock.now; return { ...row }; } async markRedacted(_ws: string, _room: string, eventId: string, key: string): Promise { const affected = this.archive .map((r, i) => ({ r, id: `file-${i + 1}` })) .filter(({ r }) => r.eventId === eventId && r.available); for (const { r, id } of affected) { r.available = false; const share = this.shareLinks.get(id); if (share !== undefined) share.revokedAt = clock.now; } const claim = this.outbox.get(key); if (claim?.status === "pending") claim.status = "failed"; return affected.length; } async countPending(): Promise { return [...this.outbox.values()].filter((r) => r.status === "pending").length; } async listRoom(_ws: string, roomId: string): Promise { return this.archive.filter((r) => r.roomId === roomId).map((r, i) => ({ id: `file-${i + 1}`, eventId: r.eventId, mxcUri: "", originalName: r.originalName, safeName: r.safeName, mime: r.mime, mimeClass: r.mimeClass as IndexedFile["mimeClass"], byteSize: r.byteSize, sha256: r.sha256, sender: r.senderMxid, createdAt: r.createdAt.toISOString(), ocisResourceId: "rid", available: r.available, })); } private byId(id: string): OutboxRow { const row = [...this.outbox.values()].find((r) => r.id === id); if (row === undefined) throw new Error("no row"); return row; } } const clock = { now: new Date(1_000_000) }; function batch(nextBatch: string, events: MatrixEvent[], extra: { stateEvents?: MatrixEvent[]; invited?: string[] } = {}): SyncResult { return { nextBatch, invited: extra.invited ?? [], joined: new Map([[mapping.roomId, { timeline: { events, limited: false, prevBatch: null }, stateEvents: extra.stateEvents ?? [] }]]), }; } describe("ArchiveProcessor", () => { let matrix: FakeMatrix; let library: FakeLibrary; let store: FakeStore; let processor: ArchiveProcessor; const signal = new AbortController().signal; beforeEach(async () => { clock.now = new Date(1_000_000); matrix = new FakeMatrix(); library = new FakeLibrary(); store = new FakeStore(); processor = new ArchiveProcessor( matrix, store, new Archiver(matrix, library, store), [mapping], pino({ level: "silent" }), () => clock.now, ); await processor.init(); }); it("archives, indexes, replies once with the stable txn id, then advances the cursor", async () => { matrix.batches.push(batch("s1", [imageEvent("$a")])); expect(await processor.runBatch(signal)).toEqual({ advanced: true, retryAt: null }); expect(store.cursor).toBe("s1"); expect([...store.roomsSaved]).toEqual([mapping.roomId]); expect(store.archive).toHaveLength(1); const row = store.archive[0]; expect(row).toMatchObject({ mime: "image/png", mimeClass: "image", safeName: "cat.png", originalName: "../cat", byteSize: PNG.byteLength }); expect(library.committed[0]).toBe(`Library/${mapping.workspaceId}/${mapping.groupId}/${row?.sha256}-cat.png`); const key = claimKey(mapping.workspaceId, mapping.roomId, "$a"); expect(matrix.notices).toEqual([{ roomId: mapping.roomId, txnId: replyTxnId(key), content: expect.objectContaining({ body: "Archived to the group library: cat.png" }) as unknown, }]); // Replaying the same event (e.g. after a crash before cursor save) neither re-archives nor re-replies. matrix.batches.push(batch("s2", [imageEvent("$a")])); await processor.runBatch(signal); expect(store.archive).toHaveLength(1); expect(matrix.notices).toHaveLength(1); }); it("holds the cursor on a WebDAV failure, records attempts, and retries after backoff", async () => { library.failPuts = 1; matrix.batches.push(batch("s1", [imageEvent("$a")])); const first = await processor.runBatch(signal); expect(first.advanced).toBe(false); expect(first.retryAt?.getTime()).toBe(clock.now.getTime() + BACKOFF_BASE_MS); expect(store.cursor).toBeNull(); const row = [...store.outbox.values()][0]; expect(row).toMatchObject({ status: "pending", attempts: 1, lastError: "WebDAV PUT failed: 507" }); // Re-sync before the row is due: still blocked, nothing uploaded. matrix.batches.push(batch("s1b", [imageEvent("$a")])); expect((await processor.runBatch(signal)).advanced).toBe(false); expect(store.archive).toHaveLength(0); expect(matrix.syncedWith).toEqual([null, null]); clock.now = new Date(clock.now.getTime() + BACKOFF_BASE_MS); matrix.batches.push(batch("s1c", [imageEvent("$a")])); expect((await processor.runBatch(signal)).advanced).toBe(true); expect(store.cursor).toBe("s1c"); expect(store.archive).toHaveLength(1); }); it("retries a failed reply without re-archiving", async () => { matrix.failNotices = 1; matrix.batches.push(batch("s1", [imageEvent("$a")]), batch("s2", [imageEvent("$a")])); expect((await processor.runBatch(signal)).advanced).toBe(false); expect(store.archive).toHaveLength(1); expect((await processor.runBatch(signal)).advanced).toBe(true); expect(matrix.notices).toHaveLength(1); expect(store.archive).toHaveLength(1); }); it("marks permanent payload errors failed and lets the cursor advance", async () => { matrix.downloadMedia = async () => ({ body: new ReadableStream({ start: (c) => { c.enqueue(PNG); c.close(); } }), contentLength: PNG.byteLength + 10, }); matrix.batches.push(batch("s1", [imageEvent("$a")])); expect((await processor.runBatch(signal)).advanced).toBe(true); expect([...store.outbox.values()][0]).toMatchObject({ status: "failed", attempts: 1 }); expect(matrix.notices).toHaveLength(0); }); it("never mirrors when the room is encrypted or its state is unknown", async () => { matrix.encryption = "unknown"; matrix.batches.push(batch("s1", [imageEvent("$a")])); await processor.runBatch(signal); matrix.encryption = "unencrypted"; const encryptionEvent: MatrixEvent = { type: "m.room.encryption", event_id: "$enc", sender: "@admin:x", state_key: "", content: { algorithm: "m.megolm.v1.aes-sha2" }, }; matrix.batches.push(batch("s2", [imageEvent("$b"), encryptionEvent])); await processor.runBatch(signal); expect(store.outbox.size).toBe(0); expect(store.archive).toHaveLength(0); expect(store.cursor).toBe("s2"); }); it("ignores its own events", async () => { matrix.batches.push(batch("s1", [imageEvent("$a", { sender: BOT })])); await processor.runBatch(signal); expect(store.outbox.size).toBe(0); }); it("marks redacted attachments unavailable and revokes live links, without touching the library", async () => { matrix.batches.push(batch("s1", [imageEvent("$a")])); await processor.runBatch(signal); expect(store.shareLinks.get("file-1")).toEqual({ revokedAt: null }); const redaction: MatrixEvent = { type: "m.room.redaction", event_id: "$r", sender: "@mod:x", content: { redacts: "$a" } }; matrix.batches.push(batch("s2", [redaction])); await processor.runBatch(signal); expect(store.archive[0]?.available).toBe(false); // contract point 5: revoke links AND mark the index unavailable expect(store.shareLinks.get("file-1")?.revokedAt).toEqual(clock.now); expect(library.committed).toHaveLength(1); }); it("runs the encryption lint over joined rooms and reports violations in health", async () => { matrix.joinedRoomList = [mapping.roomId, "!stray:x"]; matrix.roomEncryptionByRoom = new Map([["!stray:x", "unencrypted"]]); const report = await processor.lintEncryption(signal); expect(report.issues.map((i) => i.kind)).toEqual(["unmapped-unencrypted"]); const health = await processor.health(); expect(health.encryption).toMatchObject({ checked: 2, violations: ["!stray:x"], mappedUnencrypted: [mapping.roomId] }); }); it("handles !archive export from a room admin: uploads ciphertext and posts the object key", async () => { const { createServer } = await import("node:http"); const { mkdtemp, rm } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); const age = await import("age-encryption"); const { BundleExporter } = await import("./exporter.js"); const { R2ExportStore } = await import("./r2-export.js"); const identity = await age.generateIdentity(); const recipient = await age.identityToRecipient(identity); const dir = await mkdtemp(join(tmpdir(), "proc-export-")); const puts: { key: string; body: Buffer }[] = []; const server = createServer((req, res) => { const chunks: Buffer[] = []; req.on("data", (c: Buffer) => chunks.push(c)); req.on("end", () => { puts.push({ key: req.url ?? "", body: Buffer.concat(chunks) }); res.writeHead(200).end("{}"); }); }); await new Promise((r) => server.listen(0, "127.0.0.1", r)); try { matrix.powerLevels = { users: { "@alice:example.org": 100 }, users_default: 0 }; const upload = new R2ExportStore({ endpoint: `http://127.0.0.1:${(server.address() as { port: number }).port}`, region: "auto", accessKeyId: "k", secretAccessKey: "s", spillDir: dir, allowInsecureEndpoint: true, }); const exportConfig = { enabled: true, every: 0, ageRecipients: [recipient], r2: { endpoint: "http://stub", region: "auto", accessKeyId: "k", secretAccessKey: "s", bucket: "hub-matrix-media", tmpDir: dir }, }; const exporter = new BundleExporter(store, library, upload, [recipient], "hub-matrix-media", pino({ level: "silent" })); const proc = new ArchiveProcessor( matrix, store, new Archiver(matrix, library, store), [mapping], pino({ level: "silent" }), () => clock.now, exporter, exportConfig, ); await proc.init(); // archive one attachment so the exporter has a payload to bundle matrix.batches.push(batch("s1", [imageEvent("$a")])); await proc.runBatch(signal); expect(store.archive).toHaveLength(1); const archived = store.archive[0]; if (archived === undefined) throw new Error("no archive row"); library.libraryFiles.set(`Library/${mapping.workspaceId}/${mapping.groupId}/${archived.sha256}-${archived.safeName}`, PNG); // admin command matrix.batches.push(batch("s2", [{ type: "m.room.message", event_id: "$cmd", sender: "@alice:example.org", origin_server_ts: 1, content: { msgtype: "m.text", body: "!archive export" }, }])); await proc.runBatch(signal); expect(puts).toHaveLength(1); expect(puts[0]?.key).toContain("/hub-matrix-media/exports/"); const notice = matrix.notices.at(-1); expect(String(notice?.content.body)).toContain("exports/"); // the uploaded bytes decrypt with the admin identity and contain the payload const dec = new age.Decrypter(); dec.addIdentity(identity); const plain = await dec.decrypt(new Blob([puts[0]?.body ?? Buffer.alloc(0)]).stream() as ReadableStream); const { readTarZstd, drainToBuffer } = await import("./bundle.js"); const names: string[] = []; for await (const entry of readTarZstd(plain)) { names.push(entry.name); await drainToBuffer(entry.stream); } expect(names).toContain("manifest.json"); expect(names).toContain(`files/$a/${archived.safeName}`); const health = await proc.health(); expect(health.export).toMatchObject({ enabled: true, lastObjectKey: expect.stringContaining("exports/") }); } finally { await rm(dir, { recursive: true, force: true }); await new Promise((r) => server.close(() => r())); } }); it("ignores !archive export when exports are not configured or the sender is not an admin", async () => { // not configured: no exporter attached matrix.batches.push(batch("s1", [{ type: "m.room.message", event_id: "$cmd", sender: "@alice:example.org", origin_server_ts: 1, content: { msgtype: "m.text", body: "!archive export" }, }])); await processor.runBatch(signal); expect(matrix.notices).toHaveLength(0); }); it("joins invites only for mapped rooms and reports health", async () => { matrix.batches.push({ nextBatch: "s1", joined: new Map(), invited: [mapping.roomId, "!other:x"] }); await processor.runBatch(signal); expect(matrix.joined).toEqual([mapping.roomId]); expect(await processor.health()).toMatchObject({ rooms: 1, cursor: "s1", pending: 0, lastProcessedAt: null }); }); });