347 lines
13 KiB
TypeScript
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,
|
|
);
|
|
});
|