import { createHash } from "node:crypto"; import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createServer, type Server } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as zlib from "node:zlib"; import { promisify } from "node:util"; import { R2ExportStore, exportObjectKey, R2ExportError } from "./r2-export.js"; /** Local S3-compatible stand-in: captures PUT headers/body, never dials out. */ function s3Stub(put: { key: string; length: number; body: Buffer }): Server { const server = createServer((req, res) => { const chunks: Buffer[] = []; req.on("data", (c: Buffer) => chunks.push(c)); req.on("end", () => { put.key = req.url ?? ""; put.body = Buffer.concat(chunks); put.length = Number(req.headers["content-length"] ?? "-1"); res.writeHead(200).end("{}"); }); }); server.listen(0, "127.0.0.1"); return server; } let cleanup: (() => Promise)[] = []; afterEach(async () => { vi.restoreAllMocks(); await Promise.all(cleanup.map((fn) => fn().catch(() => undefined))); cleanup = []; }); function store(dir: string, port: number): R2ExportStore { return new R2ExportStore({ endpoint: `http://127.0.0.1:${port}`, region: "auto", accessKeyId: "test", secretAccessKey: "test", bucket: "hub-matrix-media", spillDir: dir, allowInsecureEndpoint: true, }); } describe("exportObjectKey", () => { it("builds exports//.tar.zst.age", () => { const key = exportObjectKey("!room:example.org", new Date("2026-01-02T03:04:05.678Z")); expect(key).toBe("exports/!room:example.org/20260102T030405Z.tar.zst.age"); }); }); describe("R2ExportStore", () => { it("rejects non-https endpoints", () => { expect(() => new R2ExportStore({ endpoint: "http://r2.example", region: "auto", accessKeyId: "k", secretAccessKey: "s", })).toThrow(R2ExportError); expect(() => new R2ExportStore({ endpoint: "not a url", region: "auto", accessKeyId: "k", secretAccessKey: "s", })).toThrow(R2ExportError); }); it("PUTs ciphertext with an explicit content-length and cleans the spill file", async () => { const dir = await mkdtemp(join(tmpdir(), "r2-test-")); cleanup.push(() => rm(dir, { recursive: true, force: true })); const put: { key: string; length: number; body: Buffer } = { key: "", length: -1, body: Buffer.alloc(0) }; const server = s3Stub(put); await new Promise((r) => server.once("listening", r)); cleanup.push(async () => await new Promise((r) => server.close(() => r()))); const payload = Buffer.from("ciphertext-payload".repeat(100)); await store(dir, (server.address() as { port: number }).port).putBundle( "exports/!room:x/a.tar.zst.age", new Blob([payload]).stream() as ReadableStream, new AbortController().signal, ); expect(put.key.startsWith("/hub-matrix-media/exports/")).toBe(true); expect(put.length).toBe(payload.byteLength); expect(put.body.byteLength).toBe(payload.byteLength); expect(put.body).toEqual(payload); // spill directory empty again — no ciphertext residue expect(await readdir(dir)).toEqual([]); }); it("leaves no spill file when the PUT fails", async () => { const dir = await mkdtemp(join(tmpdir(), "r2-test-")); cleanup.push(() => rm(dir, { recursive: true, force: true })); const server = createServer((req, res) => { void req; res.writeHead(503).end("unavailable"); }); server.listen(0, "127.0.0.1"); await new Promise((r) => server.once("listening", r)); cleanup.push(async () => await new Promise((r) => server.close(() => r()))); const s = store(dir, (server.address() as { port: number }).port); await expect(s.putBundle( "k", new Blob([Buffer.alloc(10)]).stream() as ReadableStream, new AbortController().signal, )).rejects.toThrow(R2ExportError); expect(await readdir(dir)).toEqual([]); }); }); describe("zstd sanity", () => { it("node zlib round-trips zstd frames produced for bundles", async () => { const input = Buffer.from("abcabcabc".repeat(100)); const compressed = await promisify(zlib.zstdCompress)(input); const restored = await promisify(zlib.zstdDecompress)(compressed); expect(restored).toEqual(input); expect(createHash("sha256").update(restored).digest("hex")).toBe(createHash("sha256").update(input).digest("hex")); }); });