From 4754eccb94bc20f02b15ad98b87e0872a583b15a Mon Sep 17 00:00:00 2001 From: Dariusz Koryto Date: Thu, 15 Oct 2026 03:23:18 +0200 Subject: [PATCH] fix(api): validate a restore page before writing; test JSON backup restore end to end (#570) * test(api): cover JSON backup restore end to end Export a workspace through GET /api/v1/backups/json and restore it into another installation through the restore routes, on SQLite with every migration and filesystem storage, the way the ZIP backup does: - notebooks, notes, revisions, tags, pinned state and files round-trip unchanged, and the restored notes are searchable and tagged; - restoring the same backup twice changes nothing and keeps one object per file, and re-restoring a file replaces and removes its old object; - restoring brings back notes and notebooks deleted after the backup and undoes later edits; - damaged or minimal content falls back safely (empty document, Markdown derived from JSON, default title, normalized tags); - ids used by another workspace, notebooks or notes pointing outside the workspace, revisions of another note, invalid file metadata, missing notes, empty files and another user's file id are rejected without writing; - only signed-in users can restore. Raises line coverage of backup-service.ts from 6% to 99% and of backup-routes.ts from 46% to 94%. * fix(api): validate a restore page before writing any note restoreJsonMemos checked that each revision belongs to its note inside the write loop, so a page whose second note carried another note's revision returned 400 after the first note had already been restored. The check now runs for the whole page before anything is written. --- apps/api/src/backup-restore.test.mjs | 448 +++++++++++++++++++++++++++ apps/api/src/backup-service.ts | 9 +- 2 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/backup-restore.test.mjs diff --git a/apps/api/src/backup-restore.test.mjs b/apps/api/src/backup-restore.test.mjs new file mode 100644 index 00000000..9e6b4a42 --- /dev/null +++ b/apps/api/src/backup-restore.test.mjs @@ -0,0 +1,448 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { globSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Hono } from "hono"; +import { DEFAULT_MEMO_TITLE, emptyDoc } from "@edgeever/shared"; +import { AppError } from "./app-error.ts"; +import { registerBackupRoutes } from "./backup-routes.ts"; +import { apiError } from "./http-errors.ts"; +import { restoreJsonMemos, restoreJsonNotebooks } from "./backup-service.ts"; +import { sha256, sha256Bytes } from "./hash-utils.ts"; +import { createMemoRecord, getMemoDetail, mapMemoDetail } from "./memo-service.ts"; +import { createImageResource } from "./resource-service.ts"; +import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts"; + +// Backups are exported from one installation and restored into another, the +// way the ZIP backup in Settings does: notebooks first, then notes with their +// revisions, then each file. +const WORKSPACE_ID = "ws_default"; +const userAuth = { + kind: "user", actorType: "user", actorId: "usr_owner", username: "admin", displayName: "Admin", + scopes: [], workspaceId: WORKSPACE_ID, role: "owner", +}; +const actor = { actorType: "user", actorId: "usr_owner" }; +const cleanups = []; +afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup())); + +const installation = () => { + const sqlite = new Database(":memory:"); + for (const file of globSync("migrations/*.sql").sort()) sqlite.exec(readFileSync(file, "utf8")); + const resourcesDirectory = mkdtempSync(join(tmpdir(), "edgeever-backup-")); + cleanups.push(() => { + sqlite.close(); + rmSync(resourcesDirectory, { recursive: true, force: true }); + }); + const storage = createSelfHostedStorageAdapter(sqlite, resourcesDirectory); + const app = new Hono(); + let auth = userAuth; + app.use("/api/v1/*", async (context, next) => { + context.set("auth", auth); + await next(); + }); + // Mirrors the application's error handler in index.ts. + app.onError((error, context) => { + if (error instanceof AppError) return apiError(context, error.code, error.message, error.status); + throw error; + }); + registerBackupRoutes(app, { + clampNumber: (value, min, max) => Math.min(Math.max(Number.isFinite(value) ? value : min, min), max), + getMemoDetail, + mapMemoDetail, + restoreJsonMemos, + restoreJsonNotebooks, + sha256Bytes, + initiateResourceRestoreUpload: async () => { throw new Error("not used"); }, + }); + const request = async (path, init = {}) => { + const response = await app.request(path, init, { storage }); + const text = await response.text(); + return { status: response.status, body: text ? JSON.parse(text) : null }; + }; + const postJson = (path, body) => request(path, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }); + const storedFiles = () => readdirSync(resourcesDirectory, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath ?? entry.path, entry.name)) + .sort(); + const readObject = async (objectKey) => { + const object = await storage.resources.get(objectKey); + return object ? new Uint8Array(await new Response(object.body).arrayBuffer()) : null; + }; + const context = { env: { storage }, get: (key) => (key === "auth" ? auth : undefined) }; + return { + sqlite, storage, context, request, postJson, storedFiles, readObject, + setAuth: (next) => { auth = next; }, + }; +}; + +const notebookBackups = (sqlite) => sqlite.query( + `SELECT id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at + FROM notebooks WHERE workspace_id = ? AND is_deleted = 0 ORDER BY parent_id IS NOT NULL, sort_order, id`, +).all(WORKSPACE_ID).map((row) => ({ + id: row.id, parentId: row.parent_id, name: row.name, slug: row.slug, icon: row.icon, color: row.color, + sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, +})); + +// Reads every page of GET /api/v1/backups/json and groups it per note, like the client. +const exportBackup = async (source, pageSize = 2) => { + const memos = []; + let offset = 0; + for (;;) { + const page = await source.request(`/api/v1/backups/json?limit=${pageSize}&offset=${offset}`); + expect(page.status).toBe(200); + for (const memo of page.body.memos) { + memos.push({ + memo, + revisions: page.body.revisions.filter((revision) => revision.memoId === memo.id), + resources: page.body.resources.filter((resource) => resource.memoId === memo.id) + .map(({ url: _url, ...resource }) => ({ ...resource, archivePath: `resources/${resource.id}` })), + }); + } + if (page.body.nextOffset === null) break; + offset = page.body.nextOffset; + } + const files = new Map(); + for (const { resources } of memos) { + for (const resource of resources) { + const row = source.sqlite.query("SELECT object_key FROM resources WHERE id = ?").get(resource.id); + files.set(resource.id, await source.readObject(row.object_key)); + } + } + return { notebooks: notebookBackups(source.sqlite), memos, files }; +}; + +const restoreResource = (target, resource, bytes, { id = resource.id } = {}) => { + const form = new FormData(); + form.set("metadata", JSON.stringify(resource)); + form.set("file", new File([bytes], resource.filename ?? "file", { type: resource.mimeType ?? "application/octet-stream" })); + return target.request(`/api/v1/restores/json/resources/${encodeURIComponent(id)}`, { method: "PUT", body: form }); +}; + +const restoreBackup = async (target, backup) => { + expect((await target.postJson("/api/v1/restores/json/notebooks", { notebooks: backup.notebooks })).status).toBe(200); + for (let index = 0; index < backup.memos.length; index += 2) { + const response = await target.postJson("/api/v1/restores/json/memos", { memos: backup.memos.slice(index, index + 2) }); + expect(response.status).toBe(200); + } + for (const { resources } of backup.memos) { + for (const resource of resources) { + expect((await restoreResource(target, resource, backup.files.get(resource.id))).status).toBe(200); + } + } +}; + +const PNG = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="), (c) => c.charCodeAt(0)); + +// A workspace with a nested notebook, pinned and tagged notes, revision +// history and an image referenced from the content. +const populate = async (source) => { + const { sqlite, storage, context } = source; + const inbox = sqlite.query("SELECT id FROM notebooks WHERE workspace_id = ? AND slug = 'inbox'").get(WORKSPACE_ID).id; + sqlite.query( + `INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at) + VALUES ('nb_trips', ?, ?, 'Trips', 'trips', 'notebook', '#0f766e', 7, '2026-01-02T10:00:00.000Z', '2026-01-03T10:00:00.000Z')`, + ).run(WORKSPACE_ID, inbox); + const plan = await createMemoRecord(storage.db, WORKSPACE_ID, { + notebookId: "nb_trips", title: "Trip plan", contentMarkdown: "Draft", tags: ["travel", "2026"], + createdAt: "2026-02-01T08:00:00.000Z", updatedAt: "2026-02-02T08:00:00.000Z", + }, actor, "admin"); + const image = await createImageResource(context, { + memoId: plan.id, filename: "map.png", mimeType: "image/png", bytes: PNG, actor, source: "upload", + }); + const markdown = `## Trip plan\n\n![map](/api/v1/resources/${image.id}/blob)\n\nBook the ferry.`; + const json = { + type: "doc", + content: [ + { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Trip plan" }] }, + { type: "image", attrs: { src: `/api/v1/resources/${image.id}/blob`, alt: "map" } }, + { type: "paragraph", content: [{ type: "text", text: "Book the ferry." }] }, + ], + }; + sqlite.query( + `UPDATE memo_contents SET content_markdown = ?, content_json = ?, content_text = 'Trip plan map Book the ferry.', + content_hash = ?, revision = 3 + WHERE memo_id = ?`, + ).run(markdown, JSON.stringify(json), await sha256(markdown + JSON.stringify(json)), plan.id); + sqlite.query("UPDATE memos SET is_pinned = 1 WHERE id = ?").run(plan.id); + for (const revision of [1, 2]) { + sqlite.query( + `INSERT INTO memo_revisions (id, memo_id, revision, title, content_json, content_markdown, content_hash, created_by, created_at, tags_json, content_text) + VALUES (?, ?, ?, ?, ?, ?, ?, 'admin', ?, '["travel"]', ?)`, + ).run(`rev_plan_${revision}`, plan.id, revision, `Trip plan v${revision}`, + JSON.stringify({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: `Version ${revision}` }] }] }), + `Version ${revision}`, `hash-${revision}`, `2026-02-0${revision}T09:00:00.000Z`, `Version ${revision}`); + } + const notes = []; + for (let index = 0; index < 3; index += 1) { + notes.push(await createMemoRecord(storage.db, WORKSPACE_ID, { + notebookId: inbox, title: `Note ${index}`, contentMarkdown: `Body ${index} with **bold** text`, tags: [`tag-${index}`], + }, actor, "admin")); + } + return { plan: await getMemoDetail(storage.db, WORKSPACE_ID, plan.id), image, notes }; +}; + +const workspaceSnapshot = async (installationUnderTest) => { + const { sqlite, storage } = installationUnderTest; + const memoIds = sqlite.query("SELECT id FROM memos WHERE workspace_id = ? AND is_deleted = 0 ORDER BY id").all(WORKSPACE_ID).map((row) => row.id); + const memos = []; + for (const id of memoIds) { + const memo = await getMemoDetail(storage.db, WORKSPACE_ID, id); + memos.push({ + id: memo.id, notebookId: memo.notebookId, title: memo.title, tags: memo.tags, isPinned: memo.isPinned, + isArchived: memo.isArchived, revision: memo.revision, createdAt: memo.createdAt, updatedAt: memo.updatedAt, + contentJson: memo.contentJson, contentMarkdown: memo.contentMarkdown, contentText: memo.contentText, contentHash: memo.contentHash, + }); + } + return { + notebooks: notebookBackups(sqlite), + memos, + revisions: sqlite.query("SELECT id, memo_id, revision, title, content_markdown, content_text, created_at, tags_json FROM memo_revisions ORDER BY id").all(), + resources: sqlite.query("SELECT id, memo_id, kind, mime_type, filename, byte_size, sha256 FROM resources WHERE is_deleted = 0 ORDER BY id").all(), + }; +}; + +// Rows that already exist in the target (the default notebooks and welcome +// note every installation seeds) keep their own creation time. +const withoutSeededCreationTimes = (snapshot) => ({ + ...snapshot, + notebooks: snapshot.notebooks.map((notebook) => notebook.id === "nb_trips" ? notebook : { ...notebook, createdAt: null }), + memos: snapshot.memos.map((memo) => memo.id === "memo_welcome" ? { ...memo, createdAt: null } : memo), +}); + +describe("JSON backup and restore", () => { + test("restores notebooks, notes, revisions and files into a new installation unchanged", async () => { + const source = installation(); + const { plan, image } = await populate(source); + const backup = await exportBackup(source); + expect(backup.memos.length).toBeGreaterThan(2); + + const target = installation(); + await restoreBackup(target, backup); + + expect(withoutSeededCreationTimes(await workspaceSnapshot(target))) + .toEqual(withoutSeededCreationTimes(await workspaceSnapshot(source))); + const restoredImage = target.sqlite.query("SELECT object_key, original_memo_id FROM resources WHERE id = ?").get(image.id); + expect(await target.readObject(restoredImage.object_key)).toEqual(PNG); + const restoredPlan = await getMemoDetail(target.storage.db, WORKSPACE_ID, plan.id); + expect(restoredPlan.contentMarkdown).toContain(`/api/v1/resources/${image.id}/blob`); + expect(target.sqlite.query("SELECT memo_id FROM memos_fts WHERE memos_fts MATCH 'ferry'").all().map((row) => row.memo_id)) + .toEqual([plan.id]); + expect(target.sqlite.query("SELECT name FROM memo_tags WHERE memo_id = ? ORDER BY name").all(plan.id).map((row) => row.name)) + .toEqual(["2026", "travel"]); + }); + + test("restoring the same backup twice changes nothing and keeps one copy of each file", async () => { + const source = installation(); + await populate(source); + const backup = await exportBackup(source); + const target = installation(); + + await restoreBackup(target, backup); + const once = { snapshot: await workspaceSnapshot(target), files: target.storedFiles().length }; + await restoreBackup(target, backup); + + expect(await workspaceSnapshot(target)).toEqual(once.snapshot); + expect(target.storedFiles()).toHaveLength(once.files); + expect(target.sqlite.query("SELECT COUNT(*) AS count FROM resources").get().count).toBe(once.snapshot.resources.length); + }); + + test("brings back notes and notebooks deleted after the backup and undoes later edits", async () => { + const source = installation(); + const { plan, notes } = await populate(source); + const backup = await exportBackup(source); + const before = await workspaceSnapshot(source); + + source.sqlite.query("UPDATE memos SET is_deleted = 1, deleted_at = '2026-03-01T00:00:00.000Z' WHERE id = ?").run(notes[0].id); + source.sqlite.query("UPDATE notebooks SET is_deleted = 1, deleted_at = '2026-03-01T00:00:00.000Z' WHERE id = 'nb_trips'").run(); + source.sqlite.query("UPDATE memo_contents SET content_markdown = 'Edited later', revision = 9 WHERE memo_id = ?").run(plan.id); + source.sqlite.query("UPDATE memos SET title = 'Renamed later' WHERE id = ?").run(plan.id); + await restoreBackup(source, backup); + + expect(await workspaceSnapshot(source)).toEqual(before); + }); + + test("falls back safely for damaged or minimal note content", async () => { + const target = installation(); + const inbox = target.sqlite.query("SELECT id FROM notebooks WHERE workspace_id = ? AND slug = 'inbox'").get(WORKSPACE_ID).id; + const memo = (id, fields) => ({ + memo: { + id, notebookId: inbox, title: "Title", excerpt: "", tags: [], isPinned: false, isArchived: false, isDeleted: false, + revision: 0, createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", deletedAt: null, + contentJson: { type: "doc", content: [] }, contentMarkdown: "", contentText: "", contentHash: "", + sourceMemoIds: [], mergeSourceCount: 0, mergedIntoMemoId: null, ...fields, + }, + revisions: [], + resources: [], + }); + + const response = await target.postJson("/api/v1/restores/json/memos", { + memos: [ + memo("memo_damaged_json", { contentJson: "not a document", contentMarkdown: "Kept **markdown**" }), + memo("memo_markdown_from_json", { + contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Only JSON" }] }] }, + contentMarkdown: "", + }), + memo("memo_untitled", { title: " ", tags: [" travel ", "travel", "", "Work"] }), + ], + }); + expect(response.status).toBe(200); + + const damaged = await getMemoDetail(target.storage.db, WORKSPACE_ID, "memo_damaged_json"); + expect(damaged.contentMarkdown).toBe("Kept **markdown**"); + expect(damaged.contentJson).toEqual(emptyDoc()); + expect((await getMemoDetail(target.storage.db, WORKSPACE_ID, "memo_markdown_from_json")).contentMarkdown).toBe("Only JSON"); + const untitled = await getMemoDetail(target.storage.db, WORKSPACE_ID, "memo_untitled"); + expect(untitled.title).toBe(DEFAULT_MEMO_TITLE); + expect(untitled.tags).toEqual(["travel", "Work"]); + }); + + test("rejects a backup that would take over another user's notebooks or notes", async () => { + const source = installation(); + await populate(source); + const backup = await exportBackup(source); + const target = installation(); + target.sqlite.exec(` + INSERT INTO workspaces (id, name, is_personal) VALUES ('ws_other', 'Other', 1); + INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, sort_order) VALUES ('nb_trips', 'ws_other', NULL, 'Theirs', 'theirs', 0); + `); + + const notebooks = await target.postJson("/api/v1/restores/json/notebooks", { notebooks: backup.notebooks }); + expect(notebooks.status).toBe(409); + expect(notebooks.body.error.code).toBe("cross_workspace_id_conflict"); + expect(target.sqlite.query("SELECT workspace_id, name FROM notebooks WHERE id = 'nb_trips'").get()) + .toEqual({ workspace_id: "ws_other", name: "Theirs" }); + + const [first] = backup.memos; + target.sqlite.query( + `INSERT INTO memos (id, workspace_id, notebook_id, title, excerpt, tags_json, created_by, updated_by) + VALUES (?, 'ws_other', 'nb_trips', 'Theirs', '', '[]', 'other', 'other')`, + ).run(first.memo.id); + const memos = await target.postJson("/api/v1/restores/json/memos", { memos: [first] }); + expect(memos.status).toBe(409); + expect(target.sqlite.query("SELECT workspace_id, title FROM memos WHERE id = ?").get(first.memo.id)) + .toEqual({ workspace_id: "ws_other", title: "Theirs" }); + }); + + test("rejects notes and notebooks that point outside the current workspace", async () => { + const source = installation(); + await populate(source); + const backup = await exportBackup(source); + const target = installation(); + const notesBefore = target.sqlite.query("SELECT COUNT(*) AS count FROM memos").get().count; + + const orphanNotebook = await target.postJson("/api/v1/restores/json/notebooks", { + notebooks: [{ ...backup.notebooks.find((notebook) => notebook.id === "nb_trips"), parentId: "nb_missing" }], + }); + expect(orphanNotebook.status).toBe(400); + expect(target.sqlite.query("SELECT COUNT(*) AS count FROM notebooks WHERE id = 'nb_trips'").get().count).toBe(0); + + // nb_trips was not restored, so the trip plan has no notebook here. + const plan = backup.memos.find(({ memo }) => memo.notebookId === "nb_trips"); + const orphanNote = await target.postJson("/api/v1/restores/json/memos", { memos: [plan] }); + expect(orphanNote.status).toBe(400); + expect(orphanNote.body.error.code).toBe("invalid_backup_workspace"); + expect(target.sqlite.query("SELECT COUNT(*) AS count FROM memos").get().count).toBe(notesBefore); + }); + + test("rejects a revision that belongs to another note", async () => { + const source = installation(); + await populate(source); + const backup = await exportBackup(source); + const target = installation(); + await target.postJson("/api/v1/restores/json/notebooks", { notebooks: backup.notebooks }); + const plan = backup.memos.find(({ revisions }) => revisions.length > 0); + + const response = await target.postJson("/api/v1/restores/json/memos", { + memos: [{ ...plan, revisions: [{ ...plan.revisions[0], memoId: "memo_someone_else" }] }], + }); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("invalid_backup"); + expect(target.sqlite.query("SELECT COUNT(*) AS count FROM memos WHERE id = ?").get(plan.memo.id).count).toBe(0); + }); + + test("writes nothing from a page in which any note has another note's revision", async () => { + const source = installation(); + await populate(source); + const backup = await exportBackup(source); + const target = installation(); + await target.postJson("/api/v1/restores/json/notebooks", { notebooks: backup.notebooks }); + const valid = backup.memos.find(({ memo, revisions }) => revisions.length === 0 && memo.id !== "memo_welcome"); + const plan = backup.memos.find(({ revisions }) => revisions.length > 0); + + const response = await target.postJson("/api/v1/restores/json/memos", { + memos: [valid, { ...plan, revisions: [{ ...plan.revisions[0], memoId: valid.memo.id }] }], + }); + + expect(response.status).toBe(400); + expect(target.sqlite.query("SELECT COUNT(*) AS count FROM memos WHERE id IN (?, ?)").get(valid.memo.id, plan.memo.id).count).toBe(0); + }); + + test("allows only signed-in users to restore", async () => { + const source = installation(); + await populate(source); + const backup = await exportBackup(source); + const target = installation(); + target.setAuth({ ...userAuth, kind: "agent", actorType: "agent", actorId: "tok_1", scopes: ["write:memos", "write:resources"] }); + + expect((await target.postJson("/api/v1/restores/json/notebooks", { notebooks: backup.notebooks })).status).toBe(403); + expect((await target.postJson("/api/v1/restores/json/memos", { memos: backup.memos })).status).toBe(403); + const withFile = backup.memos.find(({ resources }) => resources.length > 0); + expect((await restoreResource(target, withFile.resources[0], PNG)).status).toBe(403); + expect(target.sqlite.query("SELECT COUNT(*) AS count FROM memos WHERE created_by = 'restore'").get().count).toBe(0); + }); +}); + +describe("restoring a file of a backup", () => { + const prepared = async () => { + const source = installation(); + await populate(source); + const backup = await exportBackup(source); + const target = installation(); + await target.postJson("/api/v1/restores/json/notebooks", { notebooks: backup.notebooks }); + await target.postJson("/api/v1/restores/json/memos", { memos: backup.memos }); + const resource = backup.memos.flatMap(({ resources }) => resources)[0]; + return { target, resource, bytes: backup.files.get(resource.id) }; + }; + + test("replaces the stored object when the same file is restored again", async () => { + const { target, resource, bytes } = await prepared(); + expect((await restoreResource(target, resource, bytes)).status).toBe(200); + const first = target.sqlite.query("SELECT object_key FROM resources WHERE id = ?").get(resource.id).object_key; + + await Bun.sleep(2); + expect((await restoreResource(target, resource, bytes)).status).toBe(200); + const second = target.sqlite.query("SELECT object_key FROM resources WHERE id = ?").get(resource.id).object_key; + + expect(second).not.toBe(first); + expect(await target.readObject(first)).toBeNull(); + expect(await target.readObject(second)).toEqual(bytes); + expect(target.storedFiles()).toHaveLength(1); + }); + + test("rejects invalid metadata, a missing note, a bad size and another user's file id", async () => { + const { target, resource, bytes } = await prepared(); + + expect((await restoreResource(target, resource, bytes, { id: "res_other" })).status).toBe(400); + expect((await restoreResource(target, { ...resource, memoId: "memo_missing" }, bytes)).status).toBe(404); + expect((await restoreResource(target, resource, new Uint8Array())).status).toBe(413); + + target.sqlite.exec(` + INSERT INTO workspaces (id, name, is_personal) VALUES ('ws_other', 'Other', 1); + INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, sort_order) VALUES ('nb_other', 'ws_other', NULL, 'Theirs', 'theirs', 0); + INSERT INTO memos (id, workspace_id, notebook_id, title, excerpt, tags_json, created_by, updated_by) + VALUES ('memo_other', 'ws_other', 'nb_other', 'Theirs', '', '[]', 'other', 'other'); + `); + target.sqlite.query( + `INSERT INTO resources (id, memo_id, object_key, kind, byte_size) VALUES ('res_theirs', 'memo_other', 'theirs/key', 'image', 1)`, + ).run(); + const foreign = await restoreResource(target, { ...resource, id: "res_theirs" }, bytes); + expect(foreign.status).toBe(409); + expect(target.sqlite.query("SELECT memo_id, object_key FROM resources WHERE id = 'res_theirs'").get()) + .toEqual({ memo_id: "memo_other", object_key: "theirs/key" }); + expect(target.storedFiles()).toEqual([]); + }); +}); diff --git a/apps/api/src/backup-service.ts b/apps/api/src/backup-service.ts index 7cdaa57d..62d089c7 100644 --- a/apps/api/src/backup-service.ts +++ b/apps/api/src/backup-service.ts @@ -109,6 +109,11 @@ export const restoreJsonMemos = async ( ) => { await assertIdsAvailableInWorkspace(db, "memos", workspaceId, backups.map((backup) => backup.memo.id)); await assertNotebookIdsInWorkspace(db, workspaceId, backups.map((backup) => backup.memo.notebookId)); + // Validate the whole page before writing, so an invalid note never leaves + // the notes before it restored. + if (backups.some((backup) => backup.revisions.some((revision) => revision.memoId !== backup.memo.id))) { + throw new AppError("invalid_backup", "A backup revision belongs to a different memo.", 400); + } for (const backup of backups) { const memo = backup.memo; @@ -119,10 +124,6 @@ export const restoreJsonMemos = async ( const title = normalizeMemoTitle(memo.title); const tags = normalizeTags(memo.tags); - if (backup.revisions.some((revision) => revision.memoId !== memo.id)) { - throw new AppError("invalid_backup", "A backup revision belongs to a different memo.", 400); - } - await db.batch([ db.prepare( `INSERT INTO memos (