194 lines
6.9 KiB
TypeScript
194 lines
6.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 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);
|
|
});
|