361 lines
13 KiB
TypeScript
361 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, rm } from "node:fs/promises";
|
|
import { NextRequest } from "next/server";
|
|
|
|
let runtimeRoot = "";
|
|
let data: typeof import("../src/lib/boss-data");
|
|
let authCookie = "";
|
|
let postRiskAction: (typeof import("../src/app/api/v1/admin/risks/actions/route"))["POST"];
|
|
let subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"];
|
|
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
|
|
|
async function setup() {
|
|
if (runtimeRoot) return;
|
|
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-admin-risk-actions-"));
|
|
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
|
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
|
|
|
const [dataModule, authModule, routeModule, eventsModule] = await Promise.all([
|
|
import("../src/lib/boss-data.ts"),
|
|
import("../src/lib/boss-auth.ts"),
|
|
import("../src/app/api/v1/admin/risks/actions/route.ts"),
|
|
import("../src/lib/boss-events.ts"),
|
|
]);
|
|
data = dataModule;
|
|
authCookie = authModule.AUTH_SESSION_COOKIE;
|
|
postRiskAction = routeModule.POST;
|
|
subscribeBossEvents = eventsModule.subscribeBossEvents;
|
|
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);
|
|
const now = "2026-04-27T10:00:00+08:00";
|
|
|
|
state.authAccounts = [
|
|
{
|
|
id: "account-owner",
|
|
account: "owner@acme.com",
|
|
passwordHash: "secret",
|
|
displayName: "Acme 老板",
|
|
role: "highest_admin",
|
|
primaryDeviceId: "mac-1",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
{
|
|
id: "account-admin",
|
|
account: "admin@acme.com",
|
|
passwordHash: "secret",
|
|
displayName: "Acme 管理员",
|
|
role: "admin",
|
|
primaryDeviceId: "mac-1",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
];
|
|
state.authSessions = [];
|
|
state.devices = [
|
|
{
|
|
id: "mac-1",
|
|
name: "Acme Mac",
|
|
avatar: "A",
|
|
account: "owner@acme.com",
|
|
source: "production",
|
|
status: "online",
|
|
projects: ["project-acme"],
|
|
quota5h: 0,
|
|
quota7d: 0,
|
|
lastSeenAt: "2026-04-27T09:58:00+08:00",
|
|
preferredExecutionMode: "cli",
|
|
capabilities: {
|
|
gui: { connected: true, lastSeenAt: "2026-04-27T09:58:00+08:00" },
|
|
cli: { connected: true, lastSeenAt: "2026-04-27T09:58:00+08:00" },
|
|
browserAutomation: { connected: false },
|
|
computerUse: { connected: false },
|
|
},
|
|
},
|
|
];
|
|
state.projects = [
|
|
{
|
|
id: "project-acme",
|
|
name: "Acme 生产项目",
|
|
pinned: false,
|
|
deviceIds: ["mac-1"],
|
|
preview: "",
|
|
updatedAt: now,
|
|
lastMessageAt: now,
|
|
isGroup: false,
|
|
threadMeta: {
|
|
projectId: "project-acme",
|
|
threadId: "thread-acme",
|
|
threadDisplayName: "Acme 线程",
|
|
folderName: "acme",
|
|
activityIconCount: 0,
|
|
updatedAt: now,
|
|
},
|
|
groupMembers: [],
|
|
createdByAgent: false,
|
|
collaborationMode: "development",
|
|
approvalState: "not_required",
|
|
unreadCount: 0,
|
|
riskLevel: "low",
|
|
messages: [],
|
|
goals: [],
|
|
versions: [],
|
|
},
|
|
];
|
|
state.opsFaults = [
|
|
{
|
|
faultId: "fault-1",
|
|
faultKey: "LOCAL_AGENT.HEARTBEAT_FAILED",
|
|
severity: "critical",
|
|
status: "opened",
|
|
nodeId: "mac-1",
|
|
serviceName: "local-agent",
|
|
projectId: "project-acme",
|
|
traceId: "trace-1",
|
|
runbookId: "runbook-agent",
|
|
firstSeenAt: "2026-04-27T09:00:00+08:00",
|
|
lastSeenAt: "2026-04-27T09:30:00+08:00",
|
|
summary: "Mac 节点心跳失败",
|
|
suggestedNextAction: "检查 local-agent",
|
|
autoRepairable: true,
|
|
},
|
|
];
|
|
state.opsRepairTickets = [];
|
|
state.opsRepairVerifications = [];
|
|
state.threadContextAlerts = [
|
|
{
|
|
alertId: "alert-1",
|
|
projectId: "project-acme",
|
|
threadId: "thread-acme",
|
|
alertType: "context_critical",
|
|
alertStatus: "opened",
|
|
openedAt: "2026-04-27T09:20:00+08:00",
|
|
summary: "线程上下文接近耗尽",
|
|
masterActions: ["先固化版本记录"],
|
|
},
|
|
];
|
|
state.threadContextSnapshots = [
|
|
{
|
|
snapshotId: "snapshot-1",
|
|
projectId: "project-acme",
|
|
taskId: "task-context-1",
|
|
threadId: "thread-acme",
|
|
title: "Acme 线程",
|
|
summary: "上下文接近耗尽",
|
|
nodeId: "mac-1",
|
|
workerId: "worker-1",
|
|
sourceKind: "codex_sdk",
|
|
status: "running",
|
|
contextBudgetRemainingPct: 8,
|
|
contextBudgetLevel: "critical",
|
|
mustFinishBeforeCompaction: true,
|
|
estimatedRemainingTurns: 1,
|
|
estimatedRemainingLargeMessages: 0,
|
|
compactionCount: 0,
|
|
patchPending: true,
|
|
testsPending: true,
|
|
evidencePending: false,
|
|
checklist: ["固化项目目标"],
|
|
capturedAt: "2026-04-27T09:19:00+08:00",
|
|
},
|
|
];
|
|
|
|
await data.writeState(state);
|
|
});
|
|
|
|
async function authedRequest(
|
|
account: string,
|
|
role: "member" | "admin" | "highest_admin",
|
|
body: Record<string, unknown>,
|
|
) {
|
|
const session = await data.createAuthSession({
|
|
account,
|
|
role,
|
|
displayName: account,
|
|
loginMethod: "password",
|
|
});
|
|
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/risks/actions", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: `${authCookie}=${session.sessionToken}`,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function adminPost(body: Record<string, unknown>) {
|
|
return postRiskAction(await authedRequest("owner@acme.com", "highest_admin", body));
|
|
}
|
|
|
|
test("admin risk actions require highest admin", async () => {
|
|
await setup();
|
|
const unauthorized = await postRiskAction(
|
|
new NextRequest("http://127.0.0.1:3000/api/v1/admin/risks/actions", {
|
|
method: "POST",
|
|
body: JSON.stringify({ riskId: "ops-fault:fault-1", action: "ack" }),
|
|
}),
|
|
);
|
|
assert.equal(unauthorized.status, 401);
|
|
|
|
const forbidden = await postRiskAction(
|
|
await authedRequest("admin@acme.com", "admin", { riskId: "ops-fault:fault-1", action: "ack" }),
|
|
);
|
|
assert.equal(forbidden.status, 403);
|
|
});
|
|
|
|
test("highest admin can ack and resolve an ops fault", async () => {
|
|
const events: Array<{ event: string; payload: { projectId?: string; deviceId?: string; status?: string; note?: string } }> = [];
|
|
const unsubscribe = subscribeBossEvents((event, payload) => {
|
|
events.push({ event, payload });
|
|
});
|
|
|
|
const ackResponse = await adminPost({ riskId: "ops-fault:fault-1", action: "ack" });
|
|
assert.equal(ackResponse.status, 200);
|
|
const ackPayload = await ackResponse.json();
|
|
assert.equal(ackPayload.ok, true);
|
|
assert.equal(ackPayload.riskId, "ops-fault:fault-1");
|
|
assert.equal(ackPayload.action, "ack");
|
|
assert.equal(ackPayload.fault.status, "acked");
|
|
|
|
let state = await data.readState();
|
|
assert.equal(state.opsFaults.find((fault) => fault.faultId === "fault-1")?.status, "acked");
|
|
const ackEvent = events.at(-1);
|
|
assert.equal(ackEvent?.event, "project.context_risk.updated");
|
|
assert.equal(ackEvent?.payload.projectId, "project-acme");
|
|
assert.equal(ackEvent?.payload.deviceId, "mac-1");
|
|
assert.equal(ackEvent?.payload.status, "acked");
|
|
assert.equal(ackEvent?.payload.note, "ops-fault:fault-1");
|
|
|
|
const resolveResponse = await adminPost({ riskId: "ops-fault:fault-1", action: "resolve" });
|
|
unsubscribe();
|
|
assert.equal(resolveResponse.status, 200);
|
|
const resolvePayload = await resolveResponse.json();
|
|
assert.equal(resolvePayload.fault.status, "resolved");
|
|
|
|
state = await data.readState();
|
|
assert.equal(state.opsFaults.find((fault) => fault.faultId === "fault-1")?.status, "resolved");
|
|
});
|
|
|
|
test("highest admin can create and reuse an ops repair ticket for a fault", async () => {
|
|
const firstResponse = await adminPost({ riskId: "ops-fault:fault-1", action: "create_repair_ticket" });
|
|
assert.equal(firstResponse.status, 200);
|
|
const firstPayload = await firstResponse.json();
|
|
assert.equal(firstPayload.ok, true);
|
|
assert.equal(firstPayload.ticket.faultId, "fault-1");
|
|
assert.equal(firstPayload.ticket.approvalStatus, "pending");
|
|
assert.equal(firstPayload.ticket.executionStatus, "queued");
|
|
assert.equal(firstPayload.ticket.targetNodeId, "mac-1");
|
|
|
|
let state = await data.readState();
|
|
assert.equal(state.opsRepairTickets.length, 1);
|
|
assert.equal(state.opsRepairTickets[0]?.ticketId, firstPayload.ticket.ticketId);
|
|
|
|
const secondResponse = await adminPost({ riskId: "ops-fault:fault-1", action: "create_repair_ticket" });
|
|
assert.equal(secondResponse.status, 200);
|
|
const secondPayload = await secondResponse.json();
|
|
assert.equal(secondPayload.ticket.ticketId, firstPayload.ticket.ticketId);
|
|
|
|
state = await data.readState();
|
|
assert.equal(state.opsRepairTickets.length, 1);
|
|
});
|
|
|
|
test("highest admin can ack and resolve a thread context alert", async () => {
|
|
const ackResponse = await adminPost({ riskId: "thread-alert:alert-1", action: "ack" });
|
|
assert.equal(ackResponse.status, 200);
|
|
const ackPayload = await ackResponse.json();
|
|
assert.equal(ackPayload.alert.alertStatus, "acked");
|
|
assert.equal(ackPayload.alert.resolvedAt, undefined);
|
|
|
|
let state = await data.readState();
|
|
assert.equal(state.threadContextAlerts.find((alert) => alert.alertId === "alert-1")?.alertStatus, "acked");
|
|
|
|
const resolveResponse = await adminPost({ riskId: "thread-alert:alert-1", action: "resolve" });
|
|
assert.equal(resolveResponse.status, 200);
|
|
const resolvePayload = await resolveResponse.json();
|
|
assert.equal(resolvePayload.alert.alertStatus, "resolved");
|
|
assert.equal(typeof resolvePayload.alert.resolvedAt, "string");
|
|
|
|
state = await data.readState();
|
|
const alert = state.threadContextAlerts.find((item) => item.alertId === "alert-1");
|
|
assert.equal(alert?.alertStatus, "resolved");
|
|
assert.equal(typeof alert?.resolvedAt, "string");
|
|
});
|
|
|
|
test("highest admin can assign owners and SLA to risks", async () => {
|
|
const assignFaultResponse = await adminPost({
|
|
riskId: "ops-fault:fault-1",
|
|
action: "assign_owner",
|
|
ownerAccount: "admin@acme.com",
|
|
note: "请先处理心跳链路",
|
|
});
|
|
assert.equal(assignFaultResponse.status, 200);
|
|
const assignFaultPayload = await assignFaultResponse.json();
|
|
assert.equal(assignFaultPayload.ok, true);
|
|
assert.equal(assignFaultPayload.fault.ownerAccount, "admin@acme.com");
|
|
assert.equal(assignFaultPayload.fault.riskNote, "请先处理心跳链路");
|
|
|
|
const setFaultSlaResponse = await adminPost({
|
|
riskId: "ops-fault:fault-1",
|
|
action: "set_sla",
|
|
slaDueAt: "2026-04-27T18:00:00+08:00",
|
|
});
|
|
assert.equal(setFaultSlaResponse.status, 200);
|
|
const setFaultSlaPayload = await setFaultSlaResponse.json();
|
|
assert.equal(setFaultSlaPayload.fault.slaDueAt, "2026-04-27T18:00:00+08:00");
|
|
|
|
const assignAlertResponse = await adminPost({
|
|
riskId: "thread-alert:alert-1",
|
|
action: "assign_owner",
|
|
ownerAccount: "admin@acme.com",
|
|
});
|
|
assert.equal(assignAlertResponse.status, 200);
|
|
const assignAlertPayload = await assignAlertResponse.json();
|
|
assert.equal(assignAlertPayload.alert.ownerAccount, "admin@acme.com");
|
|
|
|
const state = await data.readState();
|
|
assert.equal(state.opsFaults.find((fault) => fault.faultId === "fault-1")?.ownerAccount, "admin@acme.com");
|
|
assert.equal(state.opsFaults.find((fault) => fault.faultId === "fault-1")?.slaDueAt, "2026-04-27T18:00:00+08:00");
|
|
assert.equal(state.threadContextAlerts.find((alert) => alert.alertId === "alert-1")?.ownerAccount, "admin@acme.com");
|
|
});
|
|
|
|
test("risk owner and SLA actions require target fields", async () => {
|
|
const assignResponse = await adminPost({ riskId: "ops-fault:fault-1", action: "assign_owner" });
|
|
assert.equal(assignResponse.status, 400);
|
|
const assignPayload = await assignResponse.json();
|
|
assert.equal(assignPayload.message, "RISK_OWNER_REQUIRED");
|
|
|
|
const slaResponse = await adminPost({ riskId: "ops-fault:fault-1", action: "set_sla" });
|
|
assert.equal(slaResponse.status, 400);
|
|
const slaPayload = await slaResponse.json();
|
|
assert.equal(slaPayload.message, "RISK_SLA_REQUIRED");
|
|
});
|
|
|
|
test("unsupported risk actions return 400", async () => {
|
|
const response = await adminPost({ riskId: "device-offline:mac-1", action: "ack" });
|
|
assert.equal(response.status, 400);
|
|
const payload = await response.json();
|
|
assert.equal(payload.ok, false);
|
|
assert.equal(payload.message, "RISK_ACTION_UNSUPPORTED");
|
|
});
|
|
|
|
test("missing risk targets return 404", async () => {
|
|
const response = await adminPost({ riskId: "ops-fault:missing", action: "ack" });
|
|
assert.equal(response.status, 404);
|
|
const payload = await response.json();
|
|
assert.equal(payload.ok, false);
|
|
});
|