T-HUB/apps/archive-bot/src/r2-export.test.ts

113 lines
4.5 KiB
TypeScript

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<void>)[] = [];
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/<room>/<timestamp>.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<void>((r) => server.once("listening", r));
cleanup.push(async () => await new Promise<void>((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<Uint8Array>,
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<void>((r) => server.once("listening", r));
cleanup.push(async () => await new Promise<void>((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<Uint8Array>, 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"));
});
});