279 lines
10 KiB
TypeScript
279 lines
10 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, rm } 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");
|
|
}
|
|
|
|
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("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,
|
|
);
|
|
});
|