Files
boss/tests/skill-lifecycle-route.test.ts

279 lines
10 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 getSkillRequests: (typeof import("../src/app/api/v1/admin/skills/requests/route"))["GET"];
let postSkillRequest: (typeof import("../src/app/api/v1/admin/skills/requests/route"))["POST"];
let claimSkillRequest: (typeof import("../src/app/api/v1/devices/[deviceId]/skill-requests/claim/route"))["POST"];
let completeSkillRequest: (typeof import("../src/app/api/v1/devices/[deviceId]/skill-requests/[requestId]/complete/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-skill-lifecycle-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [dataModule, authModule, routeModule, claimRouteModule, completeRouteModule] = await Promise.all([
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
import("../src/app/api/v1/admin/skills/requests/route.ts"),
import("../src/app/api/v1/devices/[deviceId]/skill-requests/claim/route.ts"),
import("../src/app/api/v1/devices/[deviceId]/skill-requests/[requestId]/complete/route.ts"),
]);
data = dataModule;
authCookie = authModule.AUTH_SESSION_COOKIE;
getSkillRequests = routeModule.GET;
postSkillRequest = routeModule.POST;
claimSkillRequest = claimRouteModule.POST;
completeSkillRequest = completeRouteModule.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);
state.skillLifecycleRequests = [];
state.deviceSkills = [
{
skillId: "mac-studio:boss-server-debug",
deviceId: "mac-studio",
name: "boss-server-debug",
description: "服务器调试",
path: "/Users/kris/.codex/skills/boss-server-debug/SKILL.md",
invocation: "$boss-server-debug",
category: "Mac Studio",
updatedAt: "2026-04-26T12:00:00+08:00",
},
];
await data.writeState(state);
});
async function authedRequest(
account: string,
role: "member" | "admin" | "highest_admin",
url: string,
init: RequestInit = {},
) {
const session = await data.createAuthSession({
account,
role,
displayName: account,
loginMethod: "password",
});
return new NextRequest(url, {
...init,
headers: {
...(init.headers ?? {}),
cookie: `${authCookie}=${session.sessionToken}`,
},
});
}
async function adminPost(body: Record<string, unknown>) {
return postSkillRequest(
await authedRequest("krisolo", "highest_admin", "http://127.0.0.1:3000/api/v1/admin/skills/requests", {
method: "POST",
body: JSON.stringify(body),
}),
);
}
async function devicePost(
deviceId: string,
url: string,
body: Record<string, unknown> = {},
) {
return new NextRequest(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-boss-device-token": deviceId === "mac-studio" ? "boss-mac-studio-token" : `${deviceId}-token`,
},
body: JSON.stringify(body),
});
}
test("member cannot create or list skill lifecycle requests", async () => {
const getResponse = await getSkillRequests(
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/admin/skills/requests"),
);
assert.equal(getResponse.status, 403);
const postResponse = await postSkillRequest(
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/admin/skills/requests", {
method: "POST",
body: JSON.stringify({
action: "install",
deviceId: "mac-studio",
sourceUrl: "https://git.example.com/org/skill.git",
}),
}),
);
assert.equal(postResponse.status, 403);
});
test("highest admin can create install update uninstall rollback and version lock requests", async () => {
const cases = [
{ action: "install", deviceId: "mac-studio", sourceUrl: "https://git.example.com/org/new-skill.git" },
{ action: "update", deviceId: "mac-studio", skillId: "mac-studio:boss-server-debug", targetVersion: "1.2.0" },
{ action: "uninstall", deviceId: "mac-studio", skillId: "mac-studio:boss-server-debug" },
{ action: "rollback", deviceId: "mac-studio", skillId: "mac-studio:boss-server-debug", rollbackToVersion: "1.1.0" },
{ action: "version_lock", deviceId: "mac-studio", skillId: "mac-studio:boss-server-debug", lockedVersion: "1.1.0" },
];
for (const item of cases) {
const response = await adminPost(item);
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.ok, true);
assert.equal(payload.request.status, "pending");
assert.equal(payload.request.deviceId, "mac-studio");
assert.equal(payload.request.requestedBy, "krisolo");
}
const listResponse = await getSkillRequests(
await authedRequest("krisolo", "highest_admin", "http://127.0.0.1:3000/api/v1/admin/skills/requests"),
);
assert.equal(listResponse.status, 200);
const listPayload = await listResponse.json();
assert.deepEqual(
listPayload.requests.map((request: { action: string }) => request.action),
["version_lock", "rollback", "uninstall", "update", "install"],
);
const state = await data.readState();
assert.equal(state.skillLifecycleRequests.length, 5);
assert.equal(
state.permissionAuditLogs.filter((log) => log.action === "skill.lifecycle.requested").length,
5,
);
});
test("skill lifecycle request must bind a device and a skill id or source url", async () => {
const missingDevice = await adminPost({
action: "install",
sourceUrl: "https://git.example.com/org/new-skill.git",
});
assert.equal(missingDevice.status, 400);
const missingTarget = await adminPost({
action: "install",
deviceId: "mac-studio",
});
assert.equal(missingTarget.status, 400);
const invalidAction = await adminPost({
action: "enable",
deviceId: "mac-studio",
skillId: "mac-studio:boss-server-debug",
});
assert.equal(invalidAction.status, 400);
});
test("skill lifecycle request preserves trusted source and checksum for device claim", async () => {
const createResponse = await adminPost({
action: "install",
deviceId: "mac-studio",
trustedSourceId: "company-skillhub",
expectedChecksum: "abc123",
});
assert.equal(createResponse.status, 200);
const createPayload = await createResponse.json();
assert.equal(createPayload.request.trustedSourceId, "company-skillhub");
assert.equal(createPayload.request.expectedChecksum, "abc123");
const claimResponse = await claimSkillRequest(
await devicePost("mac-studio", "http://127.0.0.1:3000/api/v1/devices/mac-studio/skill-requests/claim"),
{ params: Promise.resolve({ deviceId: "mac-studio" }) },
);
assert.equal(claimResponse.status, 200);
const claimPayload = await claimResponse.json();
assert.equal(claimPayload.request.trustedSourceId, "company-skillhub");
assert.equal(claimPayload.request.expectedChecksum, "abc123");
});
test("skill lifecycle request rejects unknown devices and existing skill mismatches", async () => {
const missingDevice = await adminPost({
action: "install",
deviceId: "missing-device",
sourceUrl: "https://git.example.com/org/new-skill.git",
});
assert.equal(missingDevice.status, 404);
assert.equal((await missingDevice.json()).message, "DEVICE_NOT_FOUND");
const missingSkill = await adminPost({
action: "update",
deviceId: "mac-studio",
skillId: "mac-studio:missing-skill",
});
assert.equal(missingSkill.status, 404);
assert.equal((await missingSkill.json()).message, "SKILL_NOT_FOUND");
});
test("device can claim and complete only its own skill lifecycle requests", async () => {
const createResponse = await adminPost({
action: "update",
deviceId: "mac-studio",
skillId: "mac-studio:boss-server-debug",
targetVersion: "1.2.0",
});
assert.equal(createResponse.status, 200);
const claimResponse = await claimSkillRequest(
await devicePost("mac-studio", "http://127.0.0.1:3000/api/v1/devices/mac-studio/skill-requests/claim"),
{ params: Promise.resolve({ deviceId: "mac-studio" }) },
);
assert.equal(claimResponse.status, 200);
const claimPayload = await claimResponse.json();
assert.equal(claimPayload.ok, true);
assert.equal(claimPayload.request.action, "update");
assert.equal(claimPayload.request.status, "running");
assert.equal(claimPayload.request.claimedByDeviceId, "mac-studio");
const emptyClaimResponse = await claimSkillRequest(
await devicePost("mac-studio", "http://127.0.0.1:3000/api/v1/devices/mac-studio/skill-requests/claim"),
{ params: Promise.resolve({ deviceId: "mac-studio" }) },
);
assert.equal(emptyClaimResponse.status, 200);
assert.equal((await emptyClaimResponse.json()).request, null);
const completeResponse = await completeSkillRequest(
await devicePost(
"mac-studio",
`http://127.0.0.1:3000/api/v1/devices/mac-studio/skill-requests/${claimPayload.request.requestId}/complete`,
{
status: "completed",
resultSummary: "Skill 已更新到 1.2.0",
},
),
{
params: Promise.resolve({
deviceId: "mac-studio",
requestId: claimPayload.request.requestId,
}),
},
);
assert.equal(completeResponse.status, 200);
const completePayload = await completeResponse.json();
assert.equal(completePayload.ok, true);
assert.equal(completePayload.request.status, "completed");
assert.equal(completePayload.request.resultSummary, "Skill 已更新到 1.2.0");
const state = await data.readState();
assert.equal(state.skillLifecycleRequests[0]?.status, "completed");
assert.equal(
state.permissionAuditLogs.some((log) => log.action === "skill.lifecycle.completed"),
true,
);
});