feat: add backup verification and restore preview
This commit is contained in:
@@ -1,14 +1,28 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { jsonNoStore } from "@/lib/api-response";
|
import { jsonNoStore } from "@/lib/api-response";
|
||||||
|
import { buildRequestAuditMeta } from "@/lib/boss-audit";
|
||||||
import { requireRequestSession } from "@/lib/boss-auth";
|
import { requireRequestSession } from "@/lib/boss-auth";
|
||||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||||
|
import { appendPermissionAuditLog, type PermissionAuditLog } from "@/lib/boss-data";
|
||||||
import {
|
import {
|
||||||
createBossStateBackup,
|
createBossStateBackup,
|
||||||
getBossStateBackupStatus,
|
getBossStateBackupStatus,
|
||||||
listBossStateBackups,
|
listBossStateBackupProjections,
|
||||||
|
previewBossStateRestore,
|
||||||
|
projectBossStateBackupSnapshot,
|
||||||
restoreBossStateBackup,
|
restoreBossStateBackup,
|
||||||
|
verifyBossStateBackup,
|
||||||
} from "@/lib/boss-state-backups";
|
} from "@/lib/boss-state-backups";
|
||||||
|
|
||||||
|
type BackupAuditAction = Extract<
|
||||||
|
PermissionAuditLog["action"],
|
||||||
|
| "backup.snapshot_created"
|
||||||
|
| "backup.snapshot_verified"
|
||||||
|
| "backup.restore_previewed"
|
||||||
|
| "backup.restore_dry_run"
|
||||||
|
| "backup.snapshot_restored"
|
||||||
|
>;
|
||||||
|
|
||||||
function forbidden() {
|
function forbidden() {
|
||||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||||
}
|
}
|
||||||
@@ -17,6 +31,21 @@ function stringValue(value: unknown) {
|
|||||||
return typeof value === "string" ? value.trim() : "";
|
return typeof value === "string" ? value.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function appendBackupAuditLog(request: NextRequest, input: {
|
||||||
|
actorAccount: string;
|
||||||
|
action: BackupAuditAction;
|
||||||
|
snapshotId: string;
|
||||||
|
detail?: string;
|
||||||
|
}) {
|
||||||
|
await appendPermissionAuditLog({
|
||||||
|
actorAccount: input.actorAccount,
|
||||||
|
action: input.action,
|
||||||
|
detail: input.detail ?? `snapshot:${input.snapshotId}`,
|
||||||
|
afterJson: { snapshotId: input.snapshotId },
|
||||||
|
...buildRequestAuditMeta(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function requireHighestAdmin(request: NextRequest) {
|
async function requireHighestAdmin(request: NextRequest) {
|
||||||
const session = await requireRequestSession(request);
|
const session = await requireRequestSession(request);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -33,7 +62,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (auth.response) return auth.response;
|
if (auth.response) return auth.response;
|
||||||
|
|
||||||
const status = await getBossStateBackupStatus();
|
const status = await getBossStateBackupStatus();
|
||||||
const snapshots = await listBossStateBackups(50);
|
const snapshots = await listBossStateBackupProjections(50);
|
||||||
return jsonNoStore({ ok: true, status, snapshots });
|
return jsonNoStore({ ok: true, status, snapshots });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +82,44 @@ export async function POST(request: NextRequest) {
|
|||||||
actorAccount: auth.session.account,
|
actorAccount: auth.session.account,
|
||||||
reason: stringValue(body.reason) || "manual",
|
reason: stringValue(body.reason) || "manual",
|
||||||
});
|
});
|
||||||
|
const projectedSnapshot = await projectBossStateBackupSnapshot(snapshot);
|
||||||
|
await appendBackupAuditLog(request, {
|
||||||
|
actorAccount: auth.session.account,
|
||||||
|
action: "backup.snapshot_created",
|
||||||
|
snapshotId: snapshot.snapshotId,
|
||||||
|
});
|
||||||
const status = await getBossStateBackupStatus();
|
const status = await getBossStateBackupStatus();
|
||||||
return jsonNoStore({ ok: true, action, snapshot, status });
|
return jsonNoStore({ ok: true, action, snapshot: projectedSnapshot, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "verify_snapshot") {
|
||||||
|
const snapshotId = stringValue(body.snapshotId);
|
||||||
|
if (!snapshotId) {
|
||||||
|
return jsonNoStore({ ok: false, message: "BACKUP_SNAPSHOT_ID_REQUIRED" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const verification = await verifyBossStateBackup(snapshotId);
|
||||||
|
await appendBackupAuditLog(request, {
|
||||||
|
actorAccount: auth.session.account,
|
||||||
|
action: "backup.snapshot_verified",
|
||||||
|
snapshotId,
|
||||||
|
detail: `snapshot:${snapshotId}; ok:${verification.ok}`,
|
||||||
|
});
|
||||||
|
return jsonNoStore({ ok: true, action, verification });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "preview_restore" || action === "dry_run_restore") {
|
||||||
|
const snapshotId = stringValue(body.snapshotId);
|
||||||
|
if (!snapshotId) {
|
||||||
|
return jsonNoStore({ ok: false, message: "BACKUP_SNAPSHOT_ID_REQUIRED" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const preview = await previewBossStateRestore({ snapshotId, willWriteState: false });
|
||||||
|
await appendBackupAuditLog(request, {
|
||||||
|
actorAccount: auth.session.account,
|
||||||
|
action: action === "preview_restore" ? "backup.restore_previewed" : "backup.restore_dry_run",
|
||||||
|
snapshotId,
|
||||||
|
detail: `snapshot:${snapshotId}; projects:${preview.impact.projects.current}->${preview.impact.projects.after}`,
|
||||||
|
});
|
||||||
|
return jsonNoStore({ ok: true, action, preview });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "restore_snapshot") {
|
if (action === "restore_snapshot") {
|
||||||
@@ -66,6 +131,11 @@ export async function POST(request: NextRequest) {
|
|||||||
snapshotId,
|
snapshotId,
|
||||||
actorAccount: auth.session.account,
|
actorAccount: auth.session.account,
|
||||||
});
|
});
|
||||||
|
await appendBackupAuditLog(request, {
|
||||||
|
actorAccount: auth.session.account,
|
||||||
|
action: "backup.snapshot_restored",
|
||||||
|
snapshotId,
|
||||||
|
});
|
||||||
const status = await getBossStateBackupStatus();
|
const status = await getBossStateBackupStatus();
|
||||||
return jsonNoStore({ ok: true, action, ...result, status });
|
return jsonNoStore({ ok: true, action, ...result, status });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,59 @@ export interface BossStateBackupStatus {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BossBackupScope = "global" | "company" | "project" | "conversation" | "config" | "skill" | "task";
|
||||||
|
|
||||||
|
export interface BossStateBackupBusinessSummary {
|
||||||
|
companyCount: number;
|
||||||
|
accountCount: number;
|
||||||
|
deviceCount: number;
|
||||||
|
projectCount: number;
|
||||||
|
messageCount: number;
|
||||||
|
taskCount: number;
|
||||||
|
skillCount: number;
|
||||||
|
auditLogCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BossStateBackupVerification {
|
||||||
|
snapshotId: string;
|
||||||
|
checkedAt: string;
|
||||||
|
ok: boolean;
|
||||||
|
sha256: string;
|
||||||
|
expectedSha256?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BossStateBackupProjection extends BossStateBackupSnapshot {
|
||||||
|
scope: BossBackupScope;
|
||||||
|
storageKind: "file";
|
||||||
|
status: "ready" | "error";
|
||||||
|
verification: BossStateBackupVerification;
|
||||||
|
businessSummary: BossStateBackupBusinessSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BossStateRestoreImpactCounter {
|
||||||
|
current: number;
|
||||||
|
after: number;
|
||||||
|
delta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BossStateRestorePreview {
|
||||||
|
snapshotId: string;
|
||||||
|
willWriteState: boolean;
|
||||||
|
generatedAt: string;
|
||||||
|
summary: string;
|
||||||
|
impact: {
|
||||||
|
companies: BossStateRestoreImpactCounter;
|
||||||
|
accounts: BossStateRestoreImpactCounter;
|
||||||
|
devices: BossStateRestoreImpactCounter;
|
||||||
|
projects: BossStateRestoreImpactCounter;
|
||||||
|
messages: BossStateRestoreImpactCounter;
|
||||||
|
tasks: BossStateRestoreImpactCounter;
|
||||||
|
skills: BossStateRestoreImpactCounter;
|
||||||
|
auditLogs: BossStateRestoreImpactCounter;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function stateFilePath() {
|
function stateFilePath() {
|
||||||
const configuredStateFile = process.env.BOSS_STATE_FILE?.trim();
|
const configuredStateFile = process.env.BOSS_STATE_FILE?.trim();
|
||||||
if (configuredStateFile) {
|
if (configuredStateFile) {
|
||||||
@@ -65,6 +118,17 @@ function snapshotPath(snapshotId: string) {
|
|||||||
return path.join(backupDirPath(), `${snapshotId}.json`);
|
return path.join(backupDirPath(), `${snapshotId}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readSnapshotText(snapshotId: string) {
|
||||||
|
try {
|
||||||
|
return await fs.readFile(snapshotPath(snapshotId), "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
throw new Error("BACKUP_SNAPSHOT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function readStateText() {
|
async function readStateText() {
|
||||||
const state = await readState();
|
const state = await readState();
|
||||||
return `${JSON.stringify(state, null, 2)}\n`;
|
return `${JSON.stringify(state, null, 2)}\n`;
|
||||||
@@ -91,6 +155,114 @@ async function readMeta(snapshotId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyBusinessSummary(): BossStateBackupBusinessSummary {
|
||||||
|
return {
|
||||||
|
companyCount: 0,
|
||||||
|
accountCount: 0,
|
||||||
|
deviceCount: 0,
|
||||||
|
projectCount: 0,
|
||||||
|
messageCount: 0,
|
||||||
|
taskCount: 0,
|
||||||
|
skillCount: 0,
|
||||||
|
auditLogCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeBusinessState(state: BossState): BossStateBackupBusinessSummary {
|
||||||
|
const skillIds = new Set([
|
||||||
|
...state.skillCatalog.map((skill) => skill.skillId),
|
||||||
|
...state.deviceSkills.map((skill) => skill.skillId),
|
||||||
|
]);
|
||||||
|
const businessProjects = state.projects.filter((project) => project.id !== "master-agent");
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyCount: state.adminCompanies.length,
|
||||||
|
accountCount: state.authAccounts.length,
|
||||||
|
deviceCount: state.devices.length,
|
||||||
|
projectCount: businessProjects.length,
|
||||||
|
messageCount: businessProjects.reduce((total, project) => total + project.messages.length, 0),
|
||||||
|
taskCount: state.masterAgentTasks.length,
|
||||||
|
skillCount: skillIds.size,
|
||||||
|
auditLogCount: state.permissionAuditLogs.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function impactCounter(current: number, after: number): BossStateRestoreImpactCounter {
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
after,
|
||||||
|
delta: after - current,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRestorePreviewSummary(impact: BossStateRestorePreview["impact"]) {
|
||||||
|
const labels: Array<[keyof BossStateRestorePreview["impact"], string]> = [
|
||||||
|
["companies", "公司"],
|
||||||
|
["accounts", "账号"],
|
||||||
|
["devices", "设备"],
|
||||||
|
["projects", "项目"],
|
||||||
|
["messages", "消息"],
|
||||||
|
["tasks", "任务"],
|
||||||
|
["skills", "Skill"],
|
||||||
|
["auditLogs", "审计日志"],
|
||||||
|
];
|
||||||
|
const changed = labels
|
||||||
|
.map(([key, label]) => ({ label, counter: impact[key] }))
|
||||||
|
.filter(({ counter }) => counter.delta !== 0)
|
||||||
|
.map(({ label, counter }) => `${label} ${counter.current} -> ${counter.after}`);
|
||||||
|
return changed.length > 0 ? `恢复预览:${changed.join(",")}` : "恢复预览:业务计数不变";
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifySnapshotText(snapshot: BossStateBackupSnapshot, text: string): BossStateBackupVerification {
|
||||||
|
const sha256 = createHash("sha256").update(text).digest("hex");
|
||||||
|
const checksumOk = snapshot.sha256 === sha256;
|
||||||
|
let stateOk = true;
|
||||||
|
try {
|
||||||
|
parseStateText(text, snapshot.absolutePath);
|
||||||
|
} catch {
|
||||||
|
stateOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshotId: snapshot.snapshotId,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
ok: checksumOk && stateOk,
|
||||||
|
sha256,
|
||||||
|
expectedSha256: snapshot.sha256,
|
||||||
|
message: checksumOk && stateOk
|
||||||
|
? "checksum verified"
|
||||||
|
: stateOk
|
||||||
|
? "checksum mismatch"
|
||||||
|
: "snapshot state is invalid",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBossStateBackupSnapshot(snapshotId: string): Promise<BossStateBackupSnapshot> {
|
||||||
|
const absolutePath = snapshotPath(snapshotId);
|
||||||
|
const text = await readSnapshotText(snapshotId);
|
||||||
|
const stat = await fs.stat(absolutePath);
|
||||||
|
const meta = await readMeta(snapshotId);
|
||||||
|
let schemaVersion: number | undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as Partial<BossState>;
|
||||||
|
schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : undefined;
|
||||||
|
} catch {
|
||||||
|
schemaVersion = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshotId,
|
||||||
|
fileName: `${snapshotId}.json`,
|
||||||
|
absolutePath,
|
||||||
|
bytes: typeof meta.bytes === "number" ? meta.bytes : stat.size,
|
||||||
|
sha256: typeof meta.sha256 === "string" ? meta.sha256 : createHash("sha256").update(text).digest("hex"),
|
||||||
|
createdAt: typeof meta.createdAt === "string" ? meta.createdAt : stat.mtime.toISOString(),
|
||||||
|
actorAccount: typeof meta.actorAccount === "string" ? meta.actorAccount : undefined,
|
||||||
|
reason: typeof meta.reason === "string" ? meta.reason : undefined,
|
||||||
|
schemaVersion: typeof meta.schemaVersion === "number" ? meta.schemaVersion : schemaVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function createBossStateBackup(input: {
|
export async function createBossStateBackup(input: {
|
||||||
actorAccount: string;
|
actorAccount: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -130,6 +302,32 @@ export async function createBossStateBackup(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function verifyBossStateBackup(snapshotId: string): Promise<BossStateBackupVerification> {
|
||||||
|
const snapshot = await loadBossStateBackupSnapshot(snapshotId);
|
||||||
|
const text = await readSnapshotText(snapshotId);
|
||||||
|
return verifySnapshotText(snapshot, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function projectBossStateBackupSnapshot(snapshot: BossStateBackupSnapshot): Promise<BossStateBackupProjection> {
|
||||||
|
const text = await readSnapshotText(snapshot.snapshotId);
|
||||||
|
const verification = verifySnapshotText(snapshot, text);
|
||||||
|
let businessSummary = emptyBusinessSummary();
|
||||||
|
try {
|
||||||
|
businessSummary = summarizeBusinessState(parseStateText(text, snapshot.absolutePath));
|
||||||
|
} catch {
|
||||||
|
businessSummary = emptyBusinessSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
scope: "global",
|
||||||
|
storageKind: "file",
|
||||||
|
status: verification.ok ? "ready" : "error",
|
||||||
|
verification,
|
||||||
|
businessSummary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function listBossStateBackups(limit = 20): Promise<BossStateBackupSnapshot[]> {
|
export async function listBossStateBackups(limit = 20): Promise<BossStateBackupSnapshot[]> {
|
||||||
const dir = backupDirPath();
|
const dir = backupDirPath();
|
||||||
const entries = await fs.readdir(dir).catch(() => []);
|
const entries = await fs.readdir(dir).catch(() => []);
|
||||||
@@ -167,6 +365,11 @@ export async function listBossStateBackups(limit = 20): Promise<BossStateBackupS
|
|||||||
.slice(0, Math.max(1, Math.min(100, limit)));
|
.slice(0, Math.max(1, Math.min(100, limit)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listBossStateBackupProjections(limit = 20): Promise<BossStateBackupProjection[]> {
|
||||||
|
const snapshots = await listBossStateBackups(limit);
|
||||||
|
return Promise.all(snapshots.map((snapshot) => projectBossStateBackupSnapshot(snapshot)));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBossStateBackupStatus(): Promise<BossStateBackupStatus> {
|
export async function getBossStateBackupStatus(): Promise<BossStateBackupStatus> {
|
||||||
try {
|
try {
|
||||||
const snapshots = await listBossStateBackups(100);
|
const snapshots = await listBossStateBackups(100);
|
||||||
@@ -191,6 +394,34 @@ export async function getBossStateBackupStatus(): Promise<BossStateBackupStatus>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function previewBossStateRestore(input: {
|
||||||
|
snapshotId: string;
|
||||||
|
willWriteState?: boolean;
|
||||||
|
}): Promise<BossStateRestorePreview> {
|
||||||
|
const snapshot = await loadBossStateBackupSnapshot(input.snapshotId);
|
||||||
|
const snapshotState = parseStateText(await readSnapshotText(input.snapshotId), snapshot.absolutePath);
|
||||||
|
const currentSummary = summarizeBusinessState(await readState());
|
||||||
|
const afterSummary = summarizeBusinessState(snapshotState);
|
||||||
|
const impact = {
|
||||||
|
companies: impactCounter(currentSummary.companyCount, afterSummary.companyCount),
|
||||||
|
accounts: impactCounter(currentSummary.accountCount, afterSummary.accountCount),
|
||||||
|
devices: impactCounter(currentSummary.deviceCount, afterSummary.deviceCount),
|
||||||
|
projects: impactCounter(currentSummary.projectCount, afterSummary.projectCount),
|
||||||
|
messages: impactCounter(currentSummary.messageCount, afterSummary.messageCount),
|
||||||
|
tasks: impactCounter(currentSummary.taskCount, afterSummary.taskCount),
|
||||||
|
skills: impactCounter(currentSummary.skillCount, afterSummary.skillCount),
|
||||||
|
auditLogs: impactCounter(currentSummary.auditLogCount, afterSummary.auditLogCount),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshotId: input.snapshotId,
|
||||||
|
willWriteState: Boolean(input.willWriteState),
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
summary: buildRestorePreviewSummary(impact),
|
||||||
|
impact,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function restoreBossStateBackup(input: {
|
export async function restoreBossStateBackup(input: {
|
||||||
snapshotId: string;
|
snapshotId: string;
|
||||||
actorAccount: string;
|
actorAccount: string;
|
||||||
|
|||||||
@@ -112,6 +112,21 @@ async function requestFor(account: string, role: "member" | "admin" | "highest_a
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 () => {
|
test("admin backups require highest admin", async () => {
|
||||||
await setup();
|
await setup();
|
||||||
const unauthenticated = await getBackups(new NextRequest("http://127.0.0.1:3000/api/v1/admin/backups"));
|
const unauthenticated = await getBackups(new NextRequest("http://127.0.0.1:3000/api/v1/admin/backups"));
|
||||||
@@ -134,6 +149,7 @@ test("highest admin can create, list, and restore a state snapshot", async () =>
|
|||||||
assert.equal(createPayload.snapshot.reason, "before risky operation");
|
assert.equal(createPayload.snapshot.reason, "before risky operation");
|
||||||
assert.equal(createPayload.snapshot.actorAccount, "owner@boss.com");
|
assert.equal(createPayload.snapshot.actorAccount, "owner@boss.com");
|
||||||
assert.match(createPayload.snapshot.snapshotId, /^state-snapshot-/);
|
assert.match(createPayload.snapshot.snapshotId, /^state-snapshot-/);
|
||||||
|
assertBackupProjection(createPayload.snapshot);
|
||||||
|
|
||||||
const mutated = await data.readState();
|
const mutated = await data.readState();
|
||||||
mutated.projects[0]!.name = "误操作后的项目";
|
mutated.projects[0]!.name = "误操作后的项目";
|
||||||
@@ -144,7 +160,11 @@ test("highest admin can create, list, and restore a state snapshot", async () =>
|
|||||||
const listPayload = await listResponse.json();
|
const listPayload = await listResponse.json();
|
||||||
assert.equal(listPayload.ok, true);
|
assert.equal(listPayload.ok, true);
|
||||||
assert.equal(listPayload.status.restorePointCount >= 1, true);
|
assert.equal(listPayload.status.restorePointCount >= 1, true);
|
||||||
assert.equal(listPayload.snapshots.some((snapshot: { snapshotId: string }) => snapshot.snapshotId === createPayload.snapshot.snapshotId), true);
|
const listedSnapshot = listPayload.snapshots.find(
|
||||||
|
(snapshot: { snapshotId: string }) => snapshot.snapshotId === createPayload.snapshot.snapshotId,
|
||||||
|
);
|
||||||
|
assert.ok(listedSnapshot);
|
||||||
|
assertBackupProjection(listedSnapshot);
|
||||||
|
|
||||||
const restoreResponse = await postBackups(
|
const restoreResponse = await postBackups(
|
||||||
await requestFor("owner@boss.com", "highest_admin", {
|
await requestFor("owner@boss.com", "highest_admin", {
|
||||||
@@ -160,6 +180,84 @@ test("highest admin can create, list, and restore a state snapshot", async () =>
|
|||||||
|
|
||||||
const restored = await data.readState();
|
const restored = await data.readState();
|
||||||
assert.equal(restored.projects.find((project) => project.id === "project-before")?.name, "备份前项目");
|
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 () => {
|
test("state writes create automatic restore points", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user