1// Portable backup archives cover configuration, events, site media and Git2// bytes, while refusing tampering, the wrong signer and non-fresh targets.3import { SELF, env, runInDurableObject } from "cloudflare:test";4import { describe, expect, it } from "vitest";5import { generateSecretKey } from "nostr-tools/pure";6import { BACKUP_MAX_BYTES, createBackup, restoreBackup } from "../../src/backups.ts";7import { storeBlob } from "../../src/blossom.ts";8import type { Relay } from "../../src/relay.ts";9import { ev, nip98, pk, rpc } from "../helpers/relay.ts";10import { WS } from "../helpers/ws.ts";1112describe("portable backups", () => {13 it("can omit Git payloads while retaining the legacy bounded archive format", async () => {14 const owner = generateSecretKey();15 const result = await runInDurableObject(env.RELAY.getByName("backup-no-git"), async (relay: Relay) => {16 relay.settings.update({ owner: pk(owner) });17 const created = await createBackup(relay, "no-git", { maxBytes: BACKUP_MAX_BYTES, maxEntries: 12_000, includeGit: false });18 if (typeof created === "string") throw new Error(created);19 const object = await relay.media.get(created.key);20 return JSON.parse(new TextDecoder().decode(await object!.arrayBuffer()));21 });22 expect(result.sqlGit).toBeUndefined();23 expect(result.manifest.git).toBe(0);24 });2526 it("serves owner-only download and keeps signed preview read-only", async () => {27 const source = "backup-http-source.bind.ws";28 const target = "backup-http-target.bind.ws";29 const owner = generateSecretKey();30 const stranger = generateSecretKey();31 await rpc(source, owner, "claim");32 const archive = await runInDurableObject(env.RELAY.getByName("backup-http-source"), async (relay: Relay) => {33 await relay.store.save(ev(owner, 1, "http backup"), 1);34 expect(typeof await createBackup(relay, "http-test")).not.toBe("string");35 const object = await relay.media.get("backup-http-source/backups/http-test.json");36 return new Uint8Array(await object!.arrayBuffer());37 });38 const downloadURL = `http://${source}/backups/http-test`;39 expect((await SELF.fetch(downloadURL)).status).toBe(401);40 expect((await SELF.fetch(downloadURL, { headers: { authorization: await nip98(stranger, downloadURL) } })).status).toBe(403);41 const downloaded = await SELF.fetch(downloadURL, { headers: { authorization: await nip98(owner, downloadURL) } });42 expect(downloaded.status).toBe(200);43 expect(new Uint8Array(await downloaded.arrayBuffer())).toEqual(archive);4445 const previewURL = `http://${target}/backups/preview`;46 const before = await SELF.fetch(`http://${target}/`, { headers: { accept: "application/nostr+json" } });47 const preview = await SELF.fetch(previewURL, { method: "POST", headers: { authorization: await nip98(owner, previewURL, "POST", JSON.parse(new TextDecoder().decode(archive))) }, body: new TextDecoder().decode(archive) });48 expect(preview.status).toBe(200);49 expect((await preview.clone().json<any>()).result).toMatchObject({ preview: true, targetIsFresh: true });50 expect((await preview.clone().json<any>()).result.events).toBeGreaterThanOrEqual(1);51 const after = await SELF.fetch(`http://${target}/`, { headers: { accept: "application/nostr+json" } });52 expect(await before.text()).toBe(await after.text());53 // The preview response is metadata, not a restore capability or archive.54 const previewBody = await preview.text();55 const restoreWithPreview = await SELF.fetch(`http://${target}/backups/restore`, { method: "POST", headers: { authorization: await nip98(owner, `http://${target}/backups/restore`, "POST", JSON.parse(previewBody)) }, body: previewBody });56 expect(restoreWithPreview.status).toBe(400);57 const restoreURL = `http://${target}/backups/restore`;58 const body = new TextDecoder().decode(archive);59 const previewToken = await nip98(owner, previewURL, "POST", JSON.parse(body));60 expect((await SELF.fetch(restoreURL, { method: "POST", headers: { authorization: previewToken }, body })).status).toBe(401);61 const restored = await SELF.fetch(restoreURL, { method: "POST", headers: { authorization: await nip98(owner, restoreURL, "POST", JSON.parse(body)) }, body });62 expect(restored.status, await restored.clone().text()).toBe(200);63 expect((await restored.json<any>()).result.restored).toBe(true);64 });6566 it("rejects a tampered signed body and a valid body signed by the wrong owner", async () => {67 const source = "backup-http-tamper-source.bind.ws";68 const target = "backup-http-tamper-target.bind.ws";69 const owner = generateSecretKey();70 const wrong = generateSecretKey();71 await rpc(source, owner, "claim");72 const archive = await runInDurableObject(env.RELAY.getByName("backup-http-tamper-source"), async (relay: Relay) => {73 await relay.store.save(ev(owner, 1, "tamper"), 1);74 await createBackup(relay, "tamper-http");75 const object = await relay.media.get("backup-http-tamper-source/backups/tamper-http.json");76 return new TextDecoder().decode(await object!.arrayBuffer());77 });78 const restoreURL = `http://${target}/backups/restore`;79 const changed = archive.slice(0, -2) + (archive.endsWith("}") ? " ]" : " }");80 const badAuth = await nip98(owner, restoreURL, "POST", JSON.parse(archive));81 expect((await SELF.fetch(restoreURL, { method: "POST", headers: { authorization: badAuth }, body: changed })).status).toBe(401);82 const wrongAuth = await nip98(wrong, restoreURL, "POST", JSON.parse(archive));83 const denied = await SELF.fetch(restoreURL, { method: "POST", headers: { authorization: wrongAuth }, body: archive });84 expect(denied.status).toBe(403);85 expect(await denied.text()).toContain("backup owner");86 });8788 it("caps chunked restore uploads without a Content-Length", async () => {89 const url = "http://backup-stream-cap.bind.ws/backups/restore";90 const response = await SELF.fetch(url, { method: "POST", headers: { authorization: await nip98(generateSecretKey(), url, "POST") }, body: new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(BACKUP_MAX_BYTES)); controller.enqueue(new Uint8Array(1)); controller.close(); } }) });91 expect(response.status).toBe(413);92 });9394 it("round trips configuration, events and a media blob, with preview and exclusions", async () => {95 const source = "backup-source.bind.ws";96 const owner = generateSecretKey(), writer = generateSecretKey();97 await rpc(source, owner, "claim");98 await rpc(source, owner, "setpolicy", { name: "Recovered", reads: "members" });99 const c = await WS.connect(source);100 await c.ok(ev(writer, 1, "retained"));101 const archive = await runInDurableObject(env.RELAY.getByName("backup-source"), async (relay: Relay) => {102 const oldList = ev(owner, 3, "older", [], 10);103 relay.store.save(oldList, 10);104 relay.store.save(ev(owner, 3, "latest", [], 11), 11);105 const held = ev(writer, 1, "hidden state");106 relay.store.save(held, 12);107 relay.settings.setEvent(held.id, "hide");108 relay.sql.exec(`INSERT INTO grasp_pending(id,until) VALUES(?,?)`, held.id, 9999999999);109 relay.sql.exec(`INSERT INTO grasp_pr_refs(repo,ref,until) VALUES(?,?,?)`, `pr:${pk(writer)}:repo`, `refs/nostr/${"a".repeat(64)}`, 9999999999);110 relay.sql.exec(`INSERT INTO grasp_pr_refs(repo,ref,until) VALUES(?,?,?)`, `pr:${pk(writer)}:repo`, `refs/nostr/${"b".repeat(64)}`, 0);111 await storeBlob(relay, new TextEncoder().encode("site bytes"), "text/plain", pk(writer), 10);112 await relay.store.save(ev(owner, 30390, '{"callback":"https://secret.invalid"}'), 10);113 const result = await createBackup(relay, "roundtrip");114 expect(typeof result).not.toBe("string");115 const object = await relay.media.get("backup-source/backups/roundtrip.json");116 const bytes = new Uint8Array(await object!.arrayBuffer());117 expect(new TextDecoder().decode(bytes)).not.toContain("secret.invalid");118 return bytes;119 });120 const preview = await runInDurableObject(env.RELAY.getByName("backup-target"), async (relay: Relay) => {121 relay.slug = "backup-target";122 const before = Number(relay.sql.exec(`SELECT count(*) AS n FROM events`).one().n ?? 0);123 const result = await restoreBackup(relay, archive, pk(owner));124 expect(typeof result).not.toBe("string");125 expect(relay.sql.exec(`SELECT count(*) AS n FROM events`).one().n).toBeGreaterThan(before);126 return result as { blobs: number; git: number };127 });128 expect(preview.blobs).toBe(1);129 expect(preview.git).toBe(0);130 await runInDurableObject(env.RELAY.getByName("backup-target"), async (relay: Relay) => {131 expect(relay.settings.policy.name).toBe("Recovered");132 expect(relay.sql.exec(`SELECT repo,ref,until FROM grasp_pr_refs ORDER BY ref`).toArray()).toEqual([{ repo: `pr:${pk(writer)}:repo`, ref: `refs/nostr/${"a".repeat(64)}`, until: 9999999999 }, { repo: `pr:${pk(writer)}:repo`, ref: `refs/nostr/${"b".repeat(64)}`, until: 0 }]);133 expect(relay.store.listHistory(pk(owner), Math.floor(Date.now() / 1000))).toHaveLength(1);134 expect(relay.settings.hiddenEvents.size).toBe(1);135 expect(relay.sql.exec(`SELECT count(*) n FROM grasp_pending`).one().n).toBe(1);136 expect((await relay.media.get("backup-target/" + ""))).toBeNull();137 });138 });139140 it("rejects tampering and wrong owner before changing a fresh target, and rejects a non-fresh target", async () => {141 const source = "backup-tamper.bind.ws";142 const owner = generateSecretKey(), wrong = generateSecretKey();143 await rpc(source, owner, "claim");144 const archive = await runInDurableObject(env.RELAY.getByName("backup-tamper"), async (relay: Relay) => {145 await relay.store.save(ev(owner, 1, "safe"), 1);146 await createBackup(relay, "tamper");147 const object = await relay.media.get("backup-tamper/backups/tamper.json");148 return new Uint8Array(await object!.arrayBuffer());149 });150 const damaged = archive.slice(); damaged[damaged.length - 4] ^= 1;151 const result = await runInDurableObject(env.RELAY.getByName("backup-tamper-target"), async (relay: Relay) => {152 relay.slug = "backup-tamper-target";153 expect(await restoreBackup(relay, damaged, pk(owner))).toMatch(/^invalid:/);154 expect(relay.settings.policy.owner).toBe("");155 expect(await restoreBackup(relay, archive, pk(wrong))).toContain("backup owner");156 expect(relay.settings.policy.owner).toBe("");157 return restoreBackup(relay, archive, pk(owner));158 });159 expect(typeof result).not.toBe("string");160 const nonfresh = await runInDurableObject(env.RELAY.getByName("backup-nonfresh"), async (relay: Relay) => {161 relay.settings.update({ owner: pk(owner) });162 return restoreBackup(relay, archive, pk(owner));163 });164 expect(nonfresh).toContain("fresh");165 });166});