Files
openclaw/src/commands/backup-sqlite.test.ts
Vincent Koc 2f25e9cba3 feat(backup): add verified SQLite snapshots (#105718)
* feat(backup): expose verified SQLite snapshots

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>

* feat(sqlite): add verified snapshot repository (#105525)

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>

* test(sqlite): use canonical snapshot schemas

* fix(backup): restrict SQLite snapshot roles

* fix(sqlite): harden snapshot staging paths

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>

* fix(sqlite): pin snapshot repository paths

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>

* fix(sqlite): secure Windows snapshot staging

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>

* fix(sqlite): validate macOS ACL captures

* test(sqlite): preserve stat uid type

* style(sqlite): satisfy snapshot lint

---------

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-07-13 06:27:14 +08:00

291 lines
9.8 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import type { RuntimeEnv } from "../runtime.js";
import { createLocalSqliteSnapshotProvider } from "../snapshot/local-repository.js";
import { OPENCLAW_AGENT_SCHEMA_VERSION } from "../state/openclaw-agent-db.js";
import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js";
import { OPENCLAW_AGENT_SCHEMA_SQL } from "../state/openclaw-agent-schema.generated.js";
import { OPENCLAW_STATE_SCHEMA_VERSION } from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { OPENCLAW_STATE_SCHEMA_SQL } from "../state/openclaw-state-schema.generated.js";
import {
backupSqliteCreateCommand,
backupSqliteListCommand,
backupSqliteRestoreCommand,
backupSqliteVerifyCommand,
} from "./backup-sqlite.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
let previousStateDir: string | undefined;
beforeEach(() => {
previousStateDir = process.env.OPENCLAW_STATE_DIR;
});
afterEach(() => {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
});
function createGlobalDatabase(databasePath: string): void {
const sqlite = requireNodeSqlite();
const database = new sqlite.DatabaseSync(databasePath);
try {
database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA wal_autocheckpoint = 0;
${OPENCLAW_STATE_SCHEMA_SQL}
PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION};
CREATE TABLE durable_entries (
id INTEGER PRIMARY KEY,
value TEXT NOT NULL
);
`);
database
.prepare(
`
INSERT INTO schema_meta (
meta_key,
role,
schema_version,
agent_id,
app_version,
created_at,
updated_at
) VALUES ('primary', 'global', ?, NULL, NULL, 1, 1)
`,
)
.run(OPENCLAW_STATE_SCHEMA_VERSION);
database
.prepare(
`
INSERT INTO delivery_queue_entries (
queue_name,
id,
status,
entry_json,
enqueued_at,
updated_at
) VALUES ('delivery', 'queued', 'pending', ?, 1, 1)
`,
)
.run('{"payload":"do-not-restore"}');
database.prepare("INSERT INTO durable_entries (value) VALUES (?)").run("checkpointed");
database.exec("PRAGMA wal_checkpoint(TRUNCATE);");
database.prepare("INSERT INTO durable_entries (value) VALUES (?)").run("committed-in-wal");
} finally {
database.close();
}
}
function createAgentDatabase(databasePath: string, agentId: string): void {
const sqlite = requireNodeSqlite();
const database = new sqlite.DatabaseSync(databasePath);
try {
database.exec(`
${OPENCLAW_AGENT_SCHEMA_SQL}
PRAGMA user_version = ${OPENCLAW_AGENT_SCHEMA_VERSION};
CREATE TABLE durable_entries (
id INTEGER PRIMARY KEY,
value TEXT NOT NULL
);
`);
database
.prepare(
`
INSERT INTO schema_meta (
meta_key,
role,
schema_version,
agent_id,
app_version,
created_at,
updated_at
) VALUES ('primary', 'agent', ?, ?, NULL, 1, 1)
`,
)
.run(OPENCLAW_AGENT_SCHEMA_VERSION, agentId);
database.prepare("INSERT INTO durable_entries (value) VALUES (?)").run("agent-state");
} finally {
database.close();
}
}
describe("SQLite backup commands", () => {
it("creates, lists, verifies, and fresh-restores the global database", async () => {
const tempDir = tempDirs.make("openclaw-backup-sqlite-");
const stateDir = path.join(tempDir, "state");
const repositoryPath = path.join(tempDir, "snapshots");
const scratchPath = path.join(tempDir, "scratch");
const restorePath = path.join(tempDir, "restore", "openclaw.sqlite");
process.env.OPENCLAW_STATE_DIR = stateDir;
const databasePath = resolveOpenClawStateSqlitePath();
await fs.mkdir(path.dirname(databasePath), { recursive: true });
await fs.mkdir(scratchPath, { mode: 0o700 });
await fs.chmod(scratchPath, 0o700);
createGlobalDatabase(databasePath);
const runtime = createRuntimeCapture();
const created = await backupSqliteCreateCommand(runtime, {
global: true,
repository: repositoryPath,
json: true,
});
expect(created.manifest.database).toMatchObject({
role: "global",
basename: "openclaw.sqlite",
userVersion: OPENCLAW_STATE_SCHEMA_VERSION,
});
expect(JSON.parse(runtime.logs.shift() ?? "{}")).toEqual(created);
const listed = await backupSqliteListCommand(runtime, {
repository: repositoryPath,
json: true,
});
expect(listed.snapshots).toHaveLength(1);
expect(listed.snapshots[0]?.manifest.snapshotId).toBe(created.manifest.snapshotId);
const verified = await backupSqliteVerifyCommand(runtime, created.snapshotPath, {
scratch: scratchPath,
json: true,
});
expect(verified.manifest).toEqual(created.manifest);
await expect(fs.readdir(scratchPath)).resolves.toEqual([]);
const restored = await backupSqliteRestoreCommand(runtime, created.snapshotPath, {
target: restorePath,
json: true,
});
expect(restored).toMatchObject({
ok: true,
snapshotPath: created.snapshotPath,
targetPath: restorePath,
});
expect(runtime.errors).toEqual([]);
const sqlite = requireNodeSqlite();
const restoredDatabase = new sqlite.DatabaseSync(restorePath, { readOnly: true });
try {
expect(
restoredDatabase.prepare("SELECT value FROM durable_entries ORDER BY id").all(),
).toEqual([{ value: "checkpointed" }, { value: "committed-in-wal" }]);
expect(
restoredDatabase.prepare("SELECT COUNT(*) AS count FROM delivery_queue_entries").get(),
).toEqual({ count: 0 });
} finally {
restoredDatabase.close();
}
});
it("creates a snapshot for a normalized per-agent database", async () => {
const tempDir = tempDirs.make("openclaw-backup-sqlite-");
const stateDir = path.join(tempDir, "state");
const repositoryPath = path.join(tempDir, "snapshots");
process.env.OPENCLAW_STATE_DIR = stateDir;
const databasePath = resolveOpenClawAgentSqlitePath({ agentId: "ops-team" });
await fs.mkdir(path.dirname(databasePath), { recursive: true });
createAgentDatabase(databasePath, "ops-team");
const runtime = createRuntimeCapture();
const created = await backupSqliteCreateCommand(runtime, {
agent: "Ops Team",
repository: repositoryPath,
});
expect(created.manifest.database).toEqual({
role: "agent",
agentId: "ops-team",
basename: "openclaw-agent.sqlite",
userVersion: OPENCLAW_AGENT_SCHEMA_VERSION,
});
expect(runtime.logs).toEqual([expect.stringContaining("Database: agent:ops-team")]);
expect(runtime.errors).toEqual([]);
});
it("requires exactly one named OpenClaw database source", async () => {
const runtime = createRuntimeCapture();
await expect(
backupSqliteCreateCommand(runtime, { repository: "/tmp/snapshots" }),
).rejects.toThrow("Choose a SQLite snapshot source");
await expect(
backupSqliteCreateCommand(runtime, {
global: true,
agent: "main",
repository: "/tmp/snapshots",
}),
).rejects.toThrow("Choose exactly one SQLite snapshot source");
});
it("requires repository, snapshot, and restore target paths", async () => {
const runtime = createRuntimeCapture();
await expect(backupSqliteCreateCommand(runtime, { global: true })).rejects.toThrow(
"Missing required --repository value",
);
await expect(backupSqliteVerifyCommand(runtime, " ", {})).rejects.toThrow(
"Missing required <snapshot> value",
);
await expect(backupSqliteRestoreCommand(runtime, "/tmp/snapshot", {})).rejects.toThrow(
"Missing required --target value",
);
});
it("rejects generic provider artifacts before verify or restore", async () => {
const tempDir = tempDirs.make("openclaw-backup-sqlite-");
const databasePath = path.join(tempDir, "generic.sqlite");
const repositoryPath = path.join(tempDir, "snapshots");
const restorePath = path.join(tempDir, "restore", "generic.sqlite");
const sqlite = requireNodeSqlite();
const database = new sqlite.DatabaseSync(databasePath);
try {
database.exec("CREATE TABLE entries (id INTEGER PRIMARY KEY);");
} finally {
database.close();
}
const snapshot = await createLocalSqliteSnapshotProvider({ repositoryPath }).create({
path: databasePath,
identity: { role: "generic", id: "generic-test" },
});
const runtime = createRuntimeCapture();
await expect(backupSqliteListCommand(runtime, { repository: repositoryPath })).rejects.toThrow(
/database role generic is not allowed/u,
);
await expect(backupSqliteVerifyCommand(runtime, snapshot.ref.path, {})).rejects.toThrow(
/database role generic is not allowed/u,
);
await expect(
backupSqliteRestoreCommand(runtime, snapshot.ref.path, { target: restorePath }),
).rejects.toThrow(/database role generic is not allowed/u);
await expect(fs.access(restorePath)).rejects.toMatchObject({ code: "ENOENT" });
});
});
function createRuntimeCapture(): RuntimeEnv & {
logs: string[];
errors: string[];
} {
const logs: string[] = [];
const errors: string[] = [];
return {
logs,
errors,
log(value) {
logs.push(String(value));
},
error(value) {
errors.push(String(value));
},
exit(code) {
throw new Error(`exit ${code}`);
},
};
}