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>; 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, ); });