feat: harden enterprise control plane
This commit is contained in:
193
tests/device-revocation-auth.test.ts
Normal file
193
tests/device-revocation-auth.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
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 deviceHeartbeatRoute: (typeof import("../src/app/api/device-heartbeat/route"))["POST"];
|
||||
let claimMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/claim/route"))["POST"];
|
||||
let getBossAgentOtaRoute: (typeof import("../src/app/api/v1/boss-agent/ota/route"))["GET"];
|
||||
let postAdminAccessRoute: (typeof import("../src/app/api/v1/admin/access/route"))["POST"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-revocation-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [dataModule, authModule, heartbeatModule, claimModule, otaModule, adminAccessModule] =
|
||||
await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/device-heartbeat/route.ts"),
|
||||
import("../src/app/api/v1/master-agent/tasks/claim/route.ts"),
|
||||
import("../src/app/api/v1/boss-agent/ota/route.ts"),
|
||||
import("../src/app/api/v1/admin/access/route.ts"),
|
||||
]);
|
||||
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
deviceHeartbeatRoute = heartbeatModule.POST;
|
||||
claimMasterTaskRoute = claimModule.POST;
|
||||
getBossAgentOtaRoute = otaModule.GET;
|
||||
postAdminAccessRoute = adminAccessModule.POST;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function heartbeatRequest(body: Record<string, unknown>) {
|
||||
return new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deviceId: "mac-studio",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 80,
|
||||
quota7d: 90,
|
||||
projects: ["Boss"],
|
||||
...body,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function highestAdminRequest(body: Record<string, unknown>) {
|
||||
const session = await data.createAuthSession({
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/access", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
test("existing device heartbeat requires its device token and cannot be kept alive by an empty token request", async () => {
|
||||
const state = await data.readState();
|
||||
const device = state.devices.find((item) => item.id === "mac-studio");
|
||||
assert.ok(device);
|
||||
device.status = "offline";
|
||||
device.lastSeenAt = "2026-05-17T01:00:00.000Z";
|
||||
device.projects = ["原项目"];
|
||||
await data.writeState(state);
|
||||
|
||||
const response = await deviceHeartbeatRoute(heartbeatRequest({ token: undefined }));
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.message, "DEVICE_TOKEN_REQUIRED");
|
||||
|
||||
const nextState = await data.readState();
|
||||
const nextDevice = nextState.devices.find((item) => item.id === "mac-studio");
|
||||
assert.equal(nextDevice?.status, "offline");
|
||||
assert.equal(nextDevice?.lastSeenAt, "2026-05-17T01:00:00.000Z");
|
||||
assert.deepEqual(nextDevice?.projects, ["原项目"]);
|
||||
});
|
||||
|
||||
test("revoked device token is rejected by heartbeat, master task claim, and boss-agent ota", async () => {
|
||||
const initial = await data.readState();
|
||||
const device = initial.devices.find((item) => item.id === "mac-studio");
|
||||
assert.ok(device?.token);
|
||||
const oldToken = device.token;
|
||||
|
||||
const revokeResponse = await postAdminAccessRoute(
|
||||
await highestAdminRequest({
|
||||
action: "revoke_device",
|
||||
deviceId: "mac-studio",
|
||||
reason: "客户解绑设备",
|
||||
}),
|
||||
);
|
||||
assert.equal(revokeResponse.status, 200);
|
||||
const revokePayload = await revokeResponse.json();
|
||||
assert.equal(revokePayload.device.id, "mac-studio");
|
||||
assert.ok(revokePayload.device.revokedAt);
|
||||
assert.equal(revokePayload.device.status, "offline");
|
||||
|
||||
assert.equal(await data.verifyDeviceToken("mac-studio", oldToken), false);
|
||||
|
||||
const queued = await data.queueMasterAgentTask({
|
||||
taskId: "revoked-device-task",
|
||||
requestMessageId: "message-revoked",
|
||||
requestText: "打开浏览器",
|
||||
executionPrompt: "打开浏览器",
|
||||
requestedBy: "krisolo",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
});
|
||||
assert.equal(queued.status, "queued");
|
||||
|
||||
const claimResponse = await claimMasterTaskRoute(
|
||||
new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/tasks/claim", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-boss-device-token": oldToken,
|
||||
},
|
||||
body: JSON.stringify({ deviceId: "mac-studio" }),
|
||||
}),
|
||||
);
|
||||
assert.equal(claimResponse.status, 401);
|
||||
|
||||
const heartbeatResponse = await deviceHeartbeatRoute(heartbeatRequest({ token: oldToken }));
|
||||
assert.equal(heartbeatResponse.status, 403);
|
||||
assert.equal((await heartbeatResponse.json()).message, "DEVICE_REVOKED");
|
||||
|
||||
const otaResponse = await getBossAgentOtaRoute(
|
||||
new NextRequest("http://127.0.0.1:3000/api/v1/boss-agent/ota?deviceId=mac-studio", {
|
||||
method: "GET",
|
||||
headers: { "x-boss-device-token": oldToken },
|
||||
}),
|
||||
);
|
||||
assert.equal(otaResponse.status, 401);
|
||||
|
||||
const nextState = await data.readState();
|
||||
const nextTask = nextState.masterAgentTasks.find((item) => item.taskId === "revoked-device-task");
|
||||
const audit = nextState.permissionAuditLogs.find((item) => item.action === "device.revoked");
|
||||
assert.equal(nextTask?.status, "queued");
|
||||
assert.equal(nextState.devices.find((item) => item.id === "mac-studio")?.status, "offline");
|
||||
assert.equal(audit?.deviceId, "mac-studio");
|
||||
assert.equal(audit?.actorAccount, "krisolo");
|
||||
});
|
||||
|
||||
test("a new device cannot self-register through heartbeat without a prepared enrollment", async () => {
|
||||
const response = await deviceHeartbeatRoute(
|
||||
new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deviceId: "rogue-mac",
|
||||
token: "rogue-token",
|
||||
name: "Rogue Mac",
|
||||
avatar: "R",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 1,
|
||||
quota7d: 1,
|
||||
projects: ["Boss"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.equal((await response.json()).message, "DEVICE_ENROLLMENT_REQUIRED");
|
||||
assert.equal((await data.readState()).devices.some((item) => item.id === "rogue-mac"), false);
|
||||
});
|
||||
Reference in New Issue
Block a user