Files
boss/tests/admin-backups-route.test.ts

347 lines
13 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
import { NextRequest } from "next/server";
let runtimeRoot = "";
let data: typeof import("../src/lib/boss-data.ts");
let authCookie = "";
let getBackups: (typeof import("../src/app/api/v1/admin/backups/route.ts"))["GET"];
let postBackups: (typeof import("../src/app/api/v1/admin/backups/route.ts"))["POST"];
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data.ts")["readState"]>>;
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-admin-backups-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
process.env.BOSS_STATE_BACKUP_DIR = path.join(runtimeRoot, "state-backups");
process.env.BOSS_STATE_AUTO_BACKUP_ENABLED = "1";
process.env.BOSS_STATE_AUTO_BACKUP_INTERVAL_MS = "0";
const [dataModule, authModule, routeModule] = await Promise.all([
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
import("../src/app/api/v1/admin/backups/route.ts"),
]);
data = dataModule;
authCookie = authModule.AUTH_SESSION_COOKIE;
getBackups = routeModule.GET;
postBackups = routeModule.POST;
baseState = structuredClone(await data.readState());
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test.beforeEach(async () => {
await setup();
const state = structuredClone(baseState);
state.authAccounts = [
{
id: "account-owner",
account: "owner@boss.com",
passwordHash: "secret",
displayName: "平台管理员",
role: "highest_admin",
createdAt: "2026-05-16T10:00:00+08:00",
updatedAt: "2026-05-16T10:00:00+08:00",
},
{
id: "account-member",
account: "member@boss.com",
passwordHash: "secret",
displayName: "普通成员",
role: "member",
createdAt: "2026-05-16T10:00:00+08:00",
updatedAt: "2026-05-16T10:00:00+08:00",
},
];
state.projects = [
{
id: "project-before",
name: "备份前项目",
pinned: false,
deviceIds: [],
preview: "before",
updatedAt: "2026-05-16T10:00:00+08:00",
lastMessageAt: "2026-05-16T10:00:00+08:00",
isGroup: false,
threadMeta: {
projectId: "project-before",
threadId: "thread-before",
threadDisplayName: "备份前线程",
folderName: "before",
activityIconCount: 0,
updatedAt: "2026-05-16T10:00:00+08:00",
},
groupMembers: [],
createdByAgent: false,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 0,
riskLevel: "low",
messages: [],
goals: [],
versions: [],
},
];
state.authSessions = [];
await data.writeState(state);
});
async function requestFor(account: string, role: "member" | "admin" | "highest_admin", init: RequestInit = {}) {
const session = await data.createAuthSession({
account,
role,
displayName: account,
loginMethod: "password",
});
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/backups", {
...init,
headers: {
"content-type": "application/json",
...(init.headers ?? {}),
cookie: `${authCookie}=${session.sessionToken}`,
},
});
}
function assertBackupProjection(snapshot: {
scope?: string;
storageKind?: string;
status?: string;
verification?: { ok?: boolean };
businessSummary?: { projectCount?: unknown; accountCount?: unknown };
}) {
assert.equal(snapshot.scope, "global");
assert.equal(snapshot.storageKind, "file");
assert.equal(snapshot.status, "ready");
assert.equal(snapshot.verification?.ok, true);
assert.equal(typeof snapshot.businessSummary?.projectCount, "number");
assert.equal(typeof snapshot.businessSummary?.accountCount, "number");
assert.equal("absolutePath" in snapshot, false);
}
test("admin backups require highest admin", async () => {
await setup();
const unauthenticated = await getBackups(new NextRequest("http://127.0.0.1:3000/api/v1/admin/backups"));
assert.equal(unauthenticated.status, 401);
const forbidden = await getBackups(await requestFor("member@boss.com", "member"));
assert.equal(forbidden.status, 403);
});
test("highest admin can create, list, and restore a state snapshot", async () => {
const createResponse = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "create_snapshot", reason: "before risky operation" }),
}),
);
assert.equal(createResponse.status, 200);
const createPayload = await createResponse.json();
assert.equal(createPayload.ok, true);
assert.equal(createPayload.snapshot.reason, "before risky operation");
assert.equal(createPayload.snapshot.actorAccount, "owner@boss.com");
assert.match(createPayload.snapshot.snapshotId, /^state-snapshot-/);
assertBackupProjection(createPayload.snapshot);
const mutated = await data.readState();
mutated.projects[0]!.name = "误操作后的项目";
await data.writeState(mutated);
const listResponse = await getBackups(await requestFor("owner@boss.com", "highest_admin"));
assert.equal(listResponse.status, 200);
const listPayload = await listResponse.json();
assert.equal(listPayload.ok, true);
assert.equal(listPayload.status.restorePointCount >= 1, true);
const listedSnapshot = listPayload.snapshots.find(
(snapshot: { snapshotId: string }) => snapshot.snapshotId === createPayload.snapshot.snapshotId,
);
assert.ok(listedSnapshot);
assertBackupProjection(listedSnapshot);
const restoreResponse = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "restore_snapshot", snapshotId: createPayload.snapshot.snapshotId }),
}),
);
assert.equal(restoreResponse.status, 200);
const restorePayload = await restoreResponse.json();
assert.equal(restorePayload.ok, true);
assert.equal(restorePayload.restored.snapshotId, createPayload.snapshot.snapshotId);
assert.match(restorePayload.preRestoreSnapshot.snapshotId, /^state-snapshot-/);
const restored = await data.readState();
assert.equal(restored.projects.find((project) => project.id === "project-before")?.name, "备份前项目");
assert.equal(restored.permissionAuditLogs.some((log) => log.action === "backup.snapshot_restored"), true);
});
test("highest admin can verify a backup snapshot checksum", async () => {
const createResponse = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "create_snapshot", reason: "checksum verification" }),
}),
);
assert.equal(createResponse.status, 200);
const createPayload = await createResponse.json();
const verifyResponse = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "verify_snapshot", snapshotId: createPayload.snapshot.snapshotId }),
}),
);
assert.equal(verifyResponse.status, 200);
const verifyPayload = await verifyResponse.json();
assert.equal(verifyPayload.ok, true);
assert.equal(verifyPayload.action, "verify_snapshot");
assert.equal(verifyPayload.verification.snapshotId, createPayload.snapshot.snapshotId);
assert.equal(verifyPayload.verification.ok, true);
assert.equal(verifyPayload.verification.sha256, createPayload.snapshot.sha256);
});
test("backup actions reject snapshots with missing or mismatched metadata", async () => {
const createResponse = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "create_snapshot", reason: "tamper test" }),
}),
);
assert.equal(createResponse.status, 200);
const createPayload = await createResponse.json();
const snapshotId = createPayload.snapshot.snapshotId as string;
const snapshotPath = path.join(process.env.BOSS_STATE_BACKUP_DIR!, `${snapshotId}.json`);
const metaPath = path.join(process.env.BOSS_STATE_BACKUP_DIR!, `${snapshotId}.meta.json`);
await unlink(metaPath);
const missingMetaVerify = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "verify_snapshot", snapshotId }),
}),
);
assert.equal(missingMetaVerify.status, 200);
const missingMetaPayload = await missingMetaVerify.json();
assert.equal(missingMetaPayload.verification.ok, false);
assert.equal(missingMetaPayload.verification.message, "snapshot metadata is missing");
const listAfterMissingMeta = await getBackups(await requestFor("owner@boss.com", "highest_admin"));
assert.equal(listAfterMissingMeta.status, 200);
const listAfterMissingMetaPayload = await listAfterMissingMeta.json();
const missingMetaListedSnapshot = listAfterMissingMetaPayload.snapshots.find(
(snapshot: { snapshotId: string }) => snapshot.snapshotId === snapshotId,
);
assert.equal(missingMetaListedSnapshot.verification.ok, false);
assert.equal(missingMetaListedSnapshot.status, "error");
const missingMetaPreview = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "preview_restore", snapshotId }),
}),
);
assert.equal(missingMetaPreview.status, 400);
assert.equal((await missingMetaPreview.json()).message, "BACKUP_SNAPSHOT_VERIFICATION_FAILED");
const repaired = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "create_snapshot", reason: "tamper mismatch" }),
}),
);
const repairedPayload = await repaired.json();
const mismatchId = repairedPayload.snapshot.snapshotId as string;
const mismatchPath = path.join(process.env.BOSS_STATE_BACKUP_DIR!, `${mismatchId}.json`);
await writeFile(mismatchPath, (await readFile(mismatchPath, "utf8")).replace("备份前项目", "被篡改项目"), "utf8");
const mismatchRestore = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "restore_snapshot", snapshotId: mismatchId }),
}),
);
assert.equal(mismatchRestore.status, 400);
assert.equal((await mismatchRestore.json()).message, "BACKUP_SNAPSHOT_VERIFICATION_FAILED");
// Keep the local variable used so the test clearly documents both paths under the same backup dir.
assert.match(snapshotPath, /state-snapshot-/);
});
test("restore preview and dry run report impact without writing state", async () => {
const createResponse = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "create_snapshot", reason: "preview restore" }),
}),
);
assert.equal(createResponse.status, 200);
const createPayload = await createResponse.json();
const state = await data.readState();
state.projects.push({
...structuredClone(state.projects[0]!),
id: "project-after",
name: "备份后新增项目",
threadMeta: {
...state.projects[0]!.threadMeta,
projectId: "project-after",
threadId: "thread-after",
threadDisplayName: "备份后线程",
},
});
await data.writeState(state);
const previewResponse = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "preview_restore", snapshotId: createPayload.snapshot.snapshotId }),
}),
);
assert.equal(previewResponse.status, 200);
const previewPayload = await previewResponse.json();
assert.equal(previewPayload.ok, true);
assert.equal(previewPayload.preview.willWriteState, false);
assert.equal(previewPayload.preview.impact.projects.after, 1);
assert.equal(previewPayload.preview.impact.projects.current, 2);
assert.match(previewPayload.preview.summary, /项目/);
const dryRunResponse = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "dry_run_restore", snapshotId: createPayload.snapshot.snapshotId }),
}),
);
assert.equal(dryRunResponse.status, 200);
const dryRunPayload = await dryRunResponse.json();
assert.equal(dryRunPayload.preview.willWriteState, false);
const afterDryRun = await data.readState();
assert.equal(afterDryRun.projects.some((project) => project.id === "project-after"), true);
});
test("state writes create automatic restore points", async () => {
const state = await data.readState();
state.projects[0]!.name = "自动备份触发项目";
await data.writeState(state);
const listResponse = await getBackups(await requestFor("owner@boss.com", "highest_admin"));
assert.equal(listResponse.status, 200);
const listPayload = await listResponse.json();
assert.equal(listPayload.ok, true);
assert.equal(
listPayload.snapshots.some((snapshot: { reason?: string; actorAccount?: string }) =>
snapshot.reason === "auto:writeState" && snapshot.actorAccount === "system",
),
true,
);
});