370 lines
12 KiB
TypeScript
370 lines
12 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 getAdminOverview: (typeof import("../src/app/api/v1/admin/overview/route.ts"))["GET"];
|
|
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-overview-"));
|
|
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
|
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
|
|
|
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/overview/route.ts"),
|
|
]);
|
|
data = dataModule;
|
|
authCookie = authModule.AUTH_SESSION_COOKIE;
|
|
getAdminOverview = routeModule.GET;
|
|
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,
|
|
lastLoginAt: now,
|
|
},
|
|
{
|
|
id: "account-dev",
|
|
account: "dev@acme.com",
|
|
passwordHash: "secret",
|
|
displayName: "开发同事",
|
|
role: "member",
|
|
primaryDeviceId: "win-1",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
];
|
|
state.authSessions = [];
|
|
state.devices = [
|
|
{
|
|
id: "mac-1",
|
|
name: "Acme Mac Studio",
|
|
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 },
|
|
},
|
|
},
|
|
{
|
|
id: "win-1",
|
|
name: "Acme Windows",
|
|
avatar: "W",
|
|
account: "dev@acme.com",
|
|
source: "production",
|
|
status: "offline",
|
|
projects: ["project-acme"],
|
|
quota5h: 0,
|
|
quota7d: 0,
|
|
lastSeenAt: "2026-04-27T08:00:00+08:00",
|
|
preferredExecutionMode: "gui",
|
|
capabilities: {
|
|
gui: { connected: false },
|
|
cli: { connected: false },
|
|
browserAutomation: { connected: false },
|
|
computerUse: { connected: false },
|
|
},
|
|
},
|
|
];
|
|
state.projects = [
|
|
{
|
|
id: "project-acme",
|
|
name: "Acme 生产项目",
|
|
pinned: false,
|
|
deviceIds: ["mac-1", "win-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: "win-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: "Windows 节点心跳失败",
|
|
suggestedNextAction: "检查 local-agent",
|
|
autoRepairable: true,
|
|
},
|
|
];
|
|
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",
|
|
},
|
|
];
|
|
state.masterAgentTasks = [
|
|
{
|
|
taskId: "task-failed-1",
|
|
projectId: "project-acme",
|
|
taskType: "conversation_reply",
|
|
requestMessageId: "msg-1",
|
|
requestText: "帮我继续开发",
|
|
executionPrompt: "prompt",
|
|
requestedBy: "owner@acme.com",
|
|
requestedByAccount: "owner@acme.com",
|
|
deviceId: "mac-1",
|
|
status: "failed",
|
|
requestedAt: "2026-04-27T09:10:00+08:00",
|
|
completedAt: "2026-04-27T09:12:00+08:00",
|
|
errorMessage: "Master Codex Node 执行失败",
|
|
},
|
|
];
|
|
state.accountDeviceGrants = [
|
|
{
|
|
grantId: "grant-expired",
|
|
account: "dev@acme.com",
|
|
deviceId: "mac-1",
|
|
permissions: ["device.view"],
|
|
grantedBy: "owner@acme.com",
|
|
grantedAt: "2026-04-20T10:00:00+08:00",
|
|
expiresAt: "2026-04-21T10:00:00+08:00",
|
|
},
|
|
];
|
|
state.accountProjectGrants = [];
|
|
state.accountSkillGrants = [];
|
|
await data.writeState(state);
|
|
});
|
|
|
|
async function authedRequest(account: string, role: "member" | "admin" | "highest_admin") {
|
|
const session = await data.createAuthSession({
|
|
account,
|
|
role,
|
|
displayName: account,
|
|
loginMethod: "password",
|
|
});
|
|
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/overview", {
|
|
headers: { cookie: `${authCookie}=${session.sessionToken}` },
|
|
});
|
|
}
|
|
|
|
test("admin overview requires highest admin", async () => {
|
|
await setup();
|
|
const unauthorized = await getAdminOverview(
|
|
new NextRequest("http://127.0.0.1:3000/api/v1/admin/overview"),
|
|
);
|
|
assert.equal(unauthorized.status, 401);
|
|
|
|
const forbidden = await getAdminOverview(await authedRequest("dev@acme.com", "member"));
|
|
assert.equal(forbidden.status, 403);
|
|
});
|
|
|
|
test("highest admin can read companies devices risks and grant summary", async () => {
|
|
await setup();
|
|
const response = await getAdminOverview(await authedRequest("owner@acme.com", "highest_admin"));
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("cache-control"), "private, no-store, max-age=0");
|
|
|
|
const payload = await response.json();
|
|
assert.equal(payload.ok, true);
|
|
assert.equal(payload.summary.companies, 2);
|
|
assert.equal(payload.summary.accounts, 3);
|
|
assert.equal(payload.summary.devices, 2);
|
|
assert.equal(payload.summary.onlineDevices, 1);
|
|
assert.equal(payload.summary.openRisks, 4);
|
|
assert.equal(payload.summary.criticalRisks, 2);
|
|
|
|
const acme = payload.companies.find((company: { companyId: string }) => company.companyId === "acme.com");
|
|
assert.equal(acme.name, "acme.com");
|
|
assert.equal(acme.accountCount, 2);
|
|
assert.equal(acme.adminCount, 1);
|
|
assert.equal(acme.deviceCount, 2);
|
|
assert.equal(acme.onlineDeviceCount, 1);
|
|
assert.equal(acme.openRiskCount, 4);
|
|
|
|
assert.equal(payload.accounts[0].passwordHash, undefined);
|
|
const offlineRisk = payload.risks.find((risk: { kind: string }) => risk.kind === "device_offline");
|
|
assert.equal(offlineRisk.deviceId, "win-1");
|
|
assert.equal(offlineRisk.companyId, "acme.com");
|
|
assert.match(offlineRisk.title, /设备离线/);
|
|
|
|
const failedTaskRisk = payload.risks.find((risk: { kind: string }) => risk.kind === "master_agent_task_failed");
|
|
assert.match(failedTaskRisk.detail, /Master Codex Node/);
|
|
|
|
const winDevice = payload.devices.find((device: { id: string }) => device.id === "win-1");
|
|
assert.equal(winDevice.projectCount, 1);
|
|
assert.equal(winDevice.codexGuiOnline, false);
|
|
assert.equal(winDevice.codexCliOnline, false);
|
|
assert.equal(winDevice.openRiskCount, 2);
|
|
|
|
assert.deepEqual(payload.grantsSummary, {
|
|
deviceGrants: 1,
|
|
projectGrants: 0,
|
|
skillGrants: 0,
|
|
expiredGrants: 1,
|
|
});
|
|
});
|
|
|
|
test("admin overview folds repeated master-agent task failures for the same device and project", async () => {
|
|
const state = await data.readState();
|
|
const baseTask = state.masterAgentTasks[0];
|
|
assert.ok(baseTask);
|
|
state.masterAgentTasks = [
|
|
baseTask,
|
|
{
|
|
...baseTask,
|
|
taskId: "task-failed-2",
|
|
requestMessageId: "msg-2",
|
|
requestedAt: "2026-04-27T09:14:00+08:00",
|
|
completedAt: "2026-04-27T09:15:00+08:00",
|
|
errorMessage: "Master Codex Node 执行失败",
|
|
},
|
|
{
|
|
...baseTask,
|
|
taskId: "task-failed-3",
|
|
requestMessageId: "msg-3",
|
|
requestedAt: "2026-04-27T09:16:00+08:00",
|
|
completedAt: "2026-04-27T09:17:00+08:00",
|
|
errorMessage: "Master Codex Node 执行失败",
|
|
},
|
|
];
|
|
await data.writeState(state);
|
|
|
|
const response = await getAdminOverview(await authedRequest("owner@acme.com", "highest_admin"));
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
const masterTaskRisks = payload.risks.filter((risk: { kind: string }) => risk.kind === "master_agent_task_failed");
|
|
|
|
assert.equal(masterTaskRisks.length, 1);
|
|
assert.match(masterTaskRisks[0].detail, /已折叠 2 条同类失败/);
|
|
assert.equal(payload.summary.openRisks, 4);
|
|
});
|
|
|
|
test("admin overview prefers explicit company assignment over account domain", async () => {
|
|
const state = await data.readState();
|
|
const now = "2026-04-27T11:00:00+08:00";
|
|
state.adminCompanies = [
|
|
{
|
|
companyId: "tenant-hyzq",
|
|
name: "环宇智擎客户",
|
|
ownerAccount: "owner@acme.com",
|
|
status: "active",
|
|
note: "企业客户",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
];
|
|
state.authAccounts = state.authAccounts.map((account) => ({
|
|
...account,
|
|
companyId: account.account === "dev@acme.com" ? "tenant-hyzq" : account.companyId,
|
|
}));
|
|
state.devices = state.devices.map((device) => ({
|
|
...device,
|
|
companyId: device.id === "win-1" ? "tenant-hyzq" : device.companyId,
|
|
}));
|
|
await data.writeState(state);
|
|
|
|
const response = await getAdminOverview(await authedRequest("owner@acme.com", "highest_admin"));
|
|
assert.equal(response.status, 200);
|
|
|
|
const payload = await response.json();
|
|
const tenant = payload.companies.find((company: { companyId: string }) => company.companyId === "tenant-hyzq");
|
|
assert.equal(tenant.name, "环宇智擎客户");
|
|
assert.equal(tenant.accountCount, 1);
|
|
assert.equal(tenant.deviceCount, 1);
|
|
|
|
const devAccount = payload.accounts.find((account: { account: string }) => account.account === "dev@acme.com");
|
|
assert.equal(devAccount.companyId, "tenant-hyzq");
|
|
assert.equal(devAccount.companyName, "环宇智擎客户");
|
|
|
|
const winDevice = payload.devices.find((device: { id: string }) => device.id === "win-1");
|
|
assert.equal(winDevice.companyId, "tenant-hyzq");
|
|
assert.equal(winDevice.companyName, "环宇智擎客户");
|
|
|
|
const offlineRisk = payload.risks.find((risk: { kind: string }) => risk.kind === "device_offline");
|
|
assert.equal(offlineRisk.companyId, "tenant-hyzq");
|
|
});
|