279 lines
10 KiB
TypeScript
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,
|
|
);
|
|
});
|