feat: add backup verification and restore preview
This commit is contained in:
@@ -1,14 +1,28 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { buildRequestAuditMeta } from "@/lib/boss-audit";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import { appendPermissionAuditLog, type PermissionAuditLog } from "@/lib/boss-data";
|
||||
import {
|
||||
createBossStateBackup,
|
||||
getBossStateBackupStatus,
|
||||
listBossStateBackups,
|
||||
listBossStateBackupProjections,
|
||||
previewBossStateRestore,
|
||||
projectBossStateBackupSnapshot,
|
||||
restoreBossStateBackup,
|
||||
verifyBossStateBackup,
|
||||
} 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() {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
@@ -17,6 +31,21 @@ function stringValue(value: unknown) {
|
||||
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) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
@@ -33,7 +62,7 @@ export async function GET(request: NextRequest) {
|
||||
if (auth.response) return auth.response;
|
||||
|
||||
const status = await getBossStateBackupStatus();
|
||||
const snapshots = await listBossStateBackups(50);
|
||||
const snapshots = await listBossStateBackupProjections(50);
|
||||
return jsonNoStore({ ok: true, status, snapshots });
|
||||
}
|
||||
|
||||
@@ -53,8 +82,44 @@ export async function POST(request: NextRequest) {
|
||||
actorAccount: auth.session.account,
|
||||
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();
|
||||
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") {
|
||||
@@ -66,6 +131,11 @@ export async function POST(request: NextRequest) {
|
||||
snapshotId,
|
||||
actorAccount: auth.session.account,
|
||||
});
|
||||
await appendBackupAuditLog(request, {
|
||||
actorAccount: auth.session.account,
|
||||
action: "backup.snapshot_restored",
|
||||
snapshotId,
|
||||
});
|
||||
const status = await getBossStateBackupStatus();
|
||||
return jsonNoStore({ ok: true, action, ...result, status });
|
||||
}
|
||||
|
||||
@@ -25,6 +25,59 @@ export interface BossStateBackupStatus {
|
||||
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() {
|
||||
const configuredStateFile = process.env.BOSS_STATE_FILE?.trim();
|
||||
if (configuredStateFile) {
|
||||
@@ -65,6 +118,17 @@ function snapshotPath(snapshotId: string) {
|
||||
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() {
|
||||
const state = await readState();
|
||||
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: {
|
||||
actorAccount: 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[]> {
|
||||
const dir = backupDirPath();
|
||||
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)));
|
||||
}
|
||||
|
||||
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> {
|
||||
try {
|
||||
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: {
|
||||
snapshotId: 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 () => {
|
||||
await setup();
|
||||
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.actorAccount, "owner@boss.com");
|
||||
assert.match(createPayload.snapshot.snapshotId, /^state-snapshot-/);
|
||||
assertBackupProjection(createPayload.snapshot);
|
||||
|
||||
const mutated = await data.readState();
|
||||
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();
|
||||
assert.equal(listPayload.ok, 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(
|
||||
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();
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user