113 lines
4.5 KiB
TypeScript
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"));
|
|
});
|
|
});
|