289 lines
9.9 KiB
TypeScript
289 lines
9.9 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 getAdminAccess: (typeof import("../src/app/api/v1/admin/access/route"))["GET"];
|
|
let postAdminAccess: (typeof import("../src/app/api/v1/admin/access/route"))["POST"];
|
|
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-company-"));
|
|
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/access/route.ts"),
|
|
]);
|
|
data = dataModule;
|
|
authCookie = authModule.AUTH_SESSION_COOKIE;
|
|
getAdminAccess = routeModule.GET;
|
|
postAdminAccess = 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);
|
|
const now = "2026-04-27T14:00:00+08:00";
|
|
state.authAccounts = [
|
|
{
|
|
id: "account-owner",
|
|
account: "owner@platform.com",
|
|
passwordHash: "secret",
|
|
displayName: "平台老板",
|
|
role: "highest_admin",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
{
|
|
id: "account-worker",
|
|
account: "worker@acme.com",
|
|
passwordHash: "secret",
|
|
displayName: "待分配成员",
|
|
role: "member",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
];
|
|
state.authSessions = [];
|
|
state.devices = [
|
|
{
|
|
id: "mac-1",
|
|
name: "客户 Mac",
|
|
avatar: "M",
|
|
account: "worker@acme.com",
|
|
source: "production",
|
|
status: "online",
|
|
projects: [],
|
|
quota5h: 0,
|
|
quota7d: 0,
|
|
lastSeenAt: now,
|
|
preferredExecutionMode: "cli",
|
|
capabilities: {
|
|
gui: { connected: true, lastSeenAt: now },
|
|
cli: { connected: true, lastSeenAt: now },
|
|
browserAutomation: { connected: false },
|
|
computerUse: { connected: false },
|
|
},
|
|
},
|
|
];
|
|
state.accountDeviceGrants = [
|
|
{
|
|
grantId: "grant-device",
|
|
account: "worker@acme.com",
|
|
deviceId: "mac-1",
|
|
permissions: ["device.view"],
|
|
grantedBy: "owner@platform.com",
|
|
grantedAt: now,
|
|
},
|
|
];
|
|
state.accountProjectGrants = [];
|
|
state.accountSkillGrants = [];
|
|
state.permissionAuditLogs = [];
|
|
await data.writeState(state);
|
|
});
|
|
|
|
async function authedRequest(body?: Record<string, unknown>, method = "POST") {
|
|
const session = await data.createAuthSession({
|
|
account: "owner@platform.com",
|
|
role: "highest_admin",
|
|
displayName: "平台老板",
|
|
loginMethod: "password",
|
|
});
|
|
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/access", {
|
|
method,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: `${authCookie}=${session.sessionToken}`,
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
async function adminPost(body: Record<string, unknown>) {
|
|
return postAdminAccess(await authedRequest(body));
|
|
}
|
|
|
|
test("highest admin can manage companies and assign accounts and devices", async () => {
|
|
const createCompany = await adminPost({
|
|
action: "upsert_company",
|
|
companyId: "acme",
|
|
name: "Acme 客户",
|
|
ownerAccount: "owner@acme.com",
|
|
successOwnerAccount: "cs@platform.com",
|
|
planTier: "enterprise",
|
|
contractExpiresAt: "2026-12-31T23:59:59+08:00",
|
|
note: "第一批 To B 客户",
|
|
});
|
|
assert.equal(createCompany.status, 200);
|
|
const createPayload = await createCompany.json();
|
|
assert.equal(createPayload.company.companyId, "acme");
|
|
assert.equal(createPayload.company.name, "Acme 客户");
|
|
assert.equal(createPayload.company.planTier, "enterprise");
|
|
assert.equal(createPayload.company.successOwnerAccount, "cs@platform.com");
|
|
assert.equal(createPayload.company.contractExpiresAt, "2026-12-31T23:59:59+08:00");
|
|
|
|
const assignAccount = await adminPost({
|
|
action: "assign_account_company",
|
|
account: "worker@acme.com",
|
|
companyId: "acme",
|
|
});
|
|
assert.equal(assignAccount.status, 200);
|
|
assert.equal((await assignAccount.json()).account.companyId, "acme");
|
|
|
|
const assignDevice = await adminPost({
|
|
action: "assign_device_company",
|
|
deviceId: "mac-1",
|
|
companyId: "acme",
|
|
});
|
|
assert.equal(assignDevice.status, 200);
|
|
assert.equal((await assignDevice.json()).device.companyId, "acme");
|
|
|
|
const getResponse = await getAdminAccess(await authedRequest(undefined, "GET"));
|
|
const getPayload = await getResponse.json();
|
|
assert.equal(getPayload.companies.some((company: { companyId: string }) => company.companyId === "acme"), true);
|
|
assert.equal(getPayload.companies.find((company: { companyId: string }) => company.companyId === "acme")?.planTier, "enterprise");
|
|
assert.equal(getPayload.accounts.find((account: { account: string }) => account.account === "worker@acme.com")?.companyId, "acme");
|
|
assert.equal(getPayload.devices.find((device: { id: string }) => device.id === "mac-1")?.companyId, "acme");
|
|
});
|
|
|
|
test("highest admin can toggle account MFA without exposing the secret in access GET", async () => {
|
|
const response = await adminPost({
|
|
action: "set_account_mfa_required",
|
|
account: "worker@acme.com",
|
|
required: true,
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
assert.equal(payload.account.mfaRequired, true);
|
|
assert.equal(typeof payload.mfaSetupSecret, "string");
|
|
assert.equal(payload.account.mfaSecret, undefined);
|
|
|
|
const getResponse = await getAdminAccess(await authedRequest(undefined, "GET"));
|
|
const getPayload = await getResponse.json();
|
|
const account = getPayload.accounts.find((item: { account: string }) => item.account === "worker@acme.com");
|
|
assert.equal(account.mfaRequired, true);
|
|
assert.equal(account.mfaSecret, undefined);
|
|
});
|
|
|
|
test("admin access GET returns safe project metadata without chat transcripts", async () => {
|
|
const state = await data.readState();
|
|
const now = "2026-04-27T14:05:00+08:00";
|
|
state.projects = [
|
|
{
|
|
id: "project-secret",
|
|
name: "敏感项目",
|
|
pinned: false,
|
|
deviceIds: ["mac-1"],
|
|
preview: "passwordHash should not leave through admin access",
|
|
updatedAt: now,
|
|
lastMessageAt: now,
|
|
isGroup: false,
|
|
threadMeta: {
|
|
projectId: "project-secret",
|
|
threadId: "thread-secret",
|
|
threadDisplayName: "敏感线程",
|
|
folderName: "secret",
|
|
activityIconCount: 1,
|
|
updatedAt: now,
|
|
},
|
|
groupMembers: [],
|
|
createdByAgent: false,
|
|
collaborationMode: "development",
|
|
approvalState: "not_required",
|
|
unreadCount: 0,
|
|
riskLevel: "low",
|
|
messages: [
|
|
{
|
|
id: "msg-secret",
|
|
role: "assistant",
|
|
author: "线程",
|
|
body: "passwordHash=secret should stay in project transcript only",
|
|
createdAt: now,
|
|
},
|
|
],
|
|
goals: [],
|
|
versions: [],
|
|
},
|
|
];
|
|
await data.writeState(state);
|
|
|
|
const response = await getAdminAccess(await authedRequest(undefined, "GET"));
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
const project = payload.projects.find((item: { id: string }) => item.id === "project-secret");
|
|
|
|
assert.equal(project.name, "敏感项目");
|
|
assert.deepEqual(project.deviceIds, ["mac-1"]);
|
|
assert.equal(project.messages, undefined);
|
|
assert.equal(project.preview, undefined);
|
|
assert.equal(JSON.stringify(payload.accounts).includes("passwordHash"), false);
|
|
assert.equal(JSON.stringify(payload.projects).includes("passwordHash"), false);
|
|
});
|
|
|
|
test("highest admin can bulk import accounts into a company", async () => {
|
|
await adminPost({ action: "upsert_company", companyId: "acme", name: "Acme 客户" });
|
|
|
|
const response = await adminPost({
|
|
action: "bulk_import_accounts",
|
|
companyId: "acme",
|
|
accounts: [
|
|
{ account: "pm@acme.com", displayName: "产品负责人", role: "admin", password: "pass-123" },
|
|
{ account: "dev2@acme.com", displayName: "开发二号", role: "member", password: "pass-456" },
|
|
],
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
assert.equal(payload.imported.length, 2);
|
|
assert.equal(payload.imported.every((account: { passwordHash?: string }) => account.passwordHash === undefined), true);
|
|
|
|
const state = await data.readState();
|
|
assert.deepEqual(
|
|
state.authAccounts
|
|
.filter((account) => account.companyId === "acme")
|
|
.map((account) => account.account)
|
|
.sort(),
|
|
["dev2@acme.com", "pm@acme.com"],
|
|
);
|
|
});
|
|
|
|
test("highest admin can reclaim a leaving account and clear its grants and sessions", async () => {
|
|
const workerSession = await data.createAuthSession({
|
|
account: "worker@acme.com",
|
|
role: "member",
|
|
displayName: "待分配成员",
|
|
loginMethod: "password",
|
|
});
|
|
|
|
const response = await adminPost({
|
|
action: "reclaim_account",
|
|
account: "worker@acme.com",
|
|
reason: "员工离职",
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
assert.equal(payload.account.status, "disabled");
|
|
assert.equal(payload.removedGrants.deviceGrants, 1);
|
|
|
|
const state = await data.readState();
|
|
assert.equal(state.authAccounts.find((account) => account.account === "worker@acme.com")?.status, "disabled");
|
|
assert.equal(state.accountDeviceGrants.some((grant) => grant.account === "worker@acme.com"), false);
|
|
assert.equal(
|
|
state.authSessions.find((session) => session.sessionToken === workerSession.sessionToken)?.revokedAt !== undefined,
|
|
true,
|
|
);
|
|
assert.equal(state.permissionAuditLogs.at(0)?.action, "account.reclaimed");
|
|
});
|