521 lines
17 KiB
TypeScript
521 lines
17 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 getDevices: (typeof import("../src/app/api/v1/devices/route"))["GET"];
|
|
let getConversations: (typeof import("../src/app/api/v1/conversations/route"))["GET"];
|
|
let getConversationHome: (typeof import("../src/app/api/v1/conversations/home/route"))["GET"];
|
|
let getProject: (typeof import("../src/app/api/v1/projects/[projectId]/route"))["GET"];
|
|
let getMessages: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["GET"];
|
|
let postMessages: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
|
|
let deleteMessage: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["DELETE"];
|
|
let getDeviceSkills: (typeof import("../src/app/api/v1/devices/[deviceId]/skills/route"))["GET"];
|
|
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-rbac-route-filtering-"));
|
|
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
|
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
|
const [dataModule, authModule, devicesRoute, conversationsRoute, conversationHomeRoute, projectRoute, messagesRoute, skillsRoute] =
|
|
await Promise.all([
|
|
import("../src/lib/boss-data.ts"),
|
|
import("../src/lib/boss-auth.ts"),
|
|
import("../src/app/api/v1/devices/route.ts"),
|
|
import("../src/app/api/v1/conversations/route.ts"),
|
|
import("../src/app/api/v1/conversations/home/route.ts"),
|
|
import("../src/app/api/v1/projects/[projectId]/route.ts"),
|
|
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
|
|
import("../src/app/api/v1/devices/[deviceId]/skills/route.ts"),
|
|
]);
|
|
data = dataModule;
|
|
authCookie = authModule.AUTH_SESSION_COOKIE;
|
|
getDevices = devicesRoute.GET;
|
|
getConversations = conversationsRoute.GET;
|
|
getConversationHome = conversationHomeRoute.GET;
|
|
getProject = projectRoute.GET;
|
|
getMessages = messagesRoute.GET;
|
|
postMessages = messagesRoute.POST;
|
|
deleteMessage = messagesRoute.DELETE;
|
|
getDeviceSkills = skillsRoute.GET;
|
|
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.projects.push({
|
|
id: "cloud-only-project",
|
|
name: "Cloud Only Project",
|
|
pinned: false,
|
|
systemPinned: false,
|
|
deviceIds: ["cloud-backup"],
|
|
preview: "只绑定未授权设备的项目。",
|
|
updatedAt: "2026-04-26T12:00:00+08:00",
|
|
lastMessageAt: "2026-04-26T12:00:00+08:00",
|
|
isGroup: false,
|
|
threadMeta: {
|
|
projectId: "cloud-only-project",
|
|
threadId: "thread-cloud-only",
|
|
threadDisplayName: "Cloud Only",
|
|
folderName: "Cloud",
|
|
activityIconCount: 0,
|
|
updatedAt: "2026-04-26T12:00:00+08:00",
|
|
codexThreadRef: "thread-cloud-only",
|
|
codexFolderRef: "cloud",
|
|
},
|
|
groupMembers: [],
|
|
createdByAgent: true,
|
|
collaborationMode: "development",
|
|
approvalState: "not_required",
|
|
unreadCount: 0,
|
|
riskLevel: "low",
|
|
messages: [
|
|
{
|
|
id: "cloud-only-message",
|
|
sender: "assistant",
|
|
senderLabel: "Codex",
|
|
body: "这个项目不应该被 worker 看到。",
|
|
sentAt: "2026-04-26T12:00:00+08:00",
|
|
kind: "text",
|
|
},
|
|
],
|
|
goals: [],
|
|
versions: [],
|
|
});
|
|
state.projects.push({
|
|
id: "rbac-thread",
|
|
name: "RBAC Authorized Thread",
|
|
pinned: false,
|
|
systemPinned: false,
|
|
deviceIds: ["mac-studio"],
|
|
preview: "授权线程。",
|
|
updatedAt: "2026-04-26T12:00:00+08:00",
|
|
lastMessageAt: "2026-04-26T12:00:00+08:00",
|
|
isGroup: false,
|
|
threadMeta: {
|
|
projectId: "rbac-thread",
|
|
threadId: "thread-rbac-authorized",
|
|
threadDisplayName: "RBAC Authorized Thread",
|
|
folderName: "RBAC",
|
|
activityIconCount: 0,
|
|
updatedAt: "2026-04-26T12:00:00+08:00",
|
|
codexThreadRef: "thread-rbac-authorized",
|
|
codexFolderRef: "rbac",
|
|
},
|
|
groupMembers: [],
|
|
createdByAgent: true,
|
|
collaborationMode: "development",
|
|
approvalState: "not_required",
|
|
unreadCount: 0,
|
|
riskLevel: "low",
|
|
messages: [
|
|
{
|
|
id: "rbac-thread-message",
|
|
sender: "assistant",
|
|
senderLabel: "Codex",
|
|
body: "这条消息用于验证授权删除。",
|
|
sentAt: "2026-04-26T12:00:00+08:00",
|
|
kind: "text",
|
|
},
|
|
],
|
|
goals: [],
|
|
versions: [],
|
|
});
|
|
state.accountDeviceGrants = [
|
|
{
|
|
grantId: "grant-worker-mac-view",
|
|
account: "worker@example.com",
|
|
deviceId: "mac-studio",
|
|
permissions: ["device.view"],
|
|
grantedBy: "krisolo",
|
|
grantedAt: "2026-04-26T12:00:00+08:00",
|
|
},
|
|
];
|
|
state.accountProjectGrants = [];
|
|
state.accountSkillGrants = [];
|
|
state.skillCatalog = [];
|
|
state.permissionAuditLogs = [];
|
|
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}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
test("device list only includes devices visible to member", async () => {
|
|
const response = await getDevices(
|
|
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/devices"),
|
|
);
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.deepEqual(
|
|
body.devices.map((device: { id: string }) => device.id),
|
|
["mac-studio"],
|
|
);
|
|
});
|
|
|
|
test("conversation list only includes projects visible to member", async () => {
|
|
const response = await getConversations(
|
|
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/conversations"),
|
|
);
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(
|
|
body.conversations.some((item: { projectId: string }) => item.projectId === "master-agent"),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
body.conversations.some((item: { projectId: string }) => item.projectId === "cloud-only-project"),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("member with master-agent permission on a non-master project sees master chat entry", async () => {
|
|
const state = await data.readState();
|
|
state.accountDeviceGrants = [
|
|
{
|
|
grantId: "grant-macbook-only-cloud-device",
|
|
account: "macbook-only@example.com",
|
|
deviceId: "cloud-backup",
|
|
permissions: ["device.view", "computer.control"],
|
|
grantedBy: "krisolo",
|
|
grantedAt: "2026-04-26T12:30:00+08:00",
|
|
},
|
|
];
|
|
state.accountProjectGrants = [
|
|
{
|
|
grantId: "grant-macbook-only-cloud-project",
|
|
account: "macbook-only@example.com",
|
|
projectId: "cloud-only-project",
|
|
permissions: ["project.view", "thread.chat", "master_agent.ask", "master_agent.takeover", "computer.control"],
|
|
grantedBy: "krisolo",
|
|
grantedAt: "2026-04-26T12:30:00+08:00",
|
|
},
|
|
];
|
|
await data.writeState(state);
|
|
|
|
const homeResponse = await getConversationHome(
|
|
await authedRequest("macbook-only@example.com", "member", "http://127.0.0.1:3000/api/v1/conversations/home"),
|
|
);
|
|
assert.equal(homeResponse.status, 200);
|
|
const homeBody = await homeResponse.json();
|
|
assert.equal(
|
|
homeBody.conversations.some((item: { projectId: string }) => item.projectId === "master-agent"),
|
|
true,
|
|
);
|
|
|
|
const messageResponse = await postMessages(
|
|
await authedRequest(
|
|
"macbook-only@example.com",
|
|
"member",
|
|
"http://127.0.0.1:3000/api/v1/projects/master-agent/messages",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ body: "帮我控制这台授权电脑打开浏览器" }),
|
|
},
|
|
),
|
|
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
|
);
|
|
assert.notEqual(messageResponse.status, 403);
|
|
});
|
|
|
|
test("member master-agent chat only returns messages scoped to the same account", async () => {
|
|
const state = await data.readState();
|
|
const masterProject = state.projects.find((project) => project.id === "master-agent");
|
|
assert.ok(masterProject, "expected seeded master-agent project");
|
|
masterProject.messages = [
|
|
{
|
|
id: "legacy-global-master-message",
|
|
sender: "master",
|
|
senderLabel: "Mac Studio · APP 日志",
|
|
body: "MAC_STUDIO_GLOBAL_LOG_SHOULD_NOT_LEAK",
|
|
sentAt: "2026-04-26T12:00:00+08:00",
|
|
kind: "text",
|
|
},
|
|
{
|
|
id: "other-account-master-message",
|
|
sender: "master",
|
|
senderLabel: "主 Agent",
|
|
body: "OTHER_ACCOUNT_MESSAGE_SHOULD_NOT_LEAK",
|
|
sentAt: "2026-04-26T12:01:00+08:00",
|
|
kind: "text",
|
|
account: "other@example.com",
|
|
},
|
|
{
|
|
id: "worker-master-message",
|
|
sender: "master",
|
|
senderLabel: "主 Agent",
|
|
body: "WORKER_VISIBLE_MASTER_MESSAGE",
|
|
sentAt: "2026-04-26T12:02:00+08:00",
|
|
kind: "text",
|
|
account: "worker@example.com",
|
|
},
|
|
];
|
|
state.accountProjectGrants.push({
|
|
grantId: "grant-worker-master-visible",
|
|
account: "worker@example.com",
|
|
projectId: "master-agent",
|
|
permissions: ["project.view", "master_agent.ask"],
|
|
grantedBy: "krisolo",
|
|
grantedAt: "2026-04-26T12:30:00+08:00",
|
|
});
|
|
await data.writeState(state);
|
|
|
|
const response = await getMessages(
|
|
await authedRequest(
|
|
"worker@example.com",
|
|
"member",
|
|
"http://127.0.0.1:3000/api/v1/projects/master-agent/messages",
|
|
),
|
|
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
|
);
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
const visibleBodies = body.project.messages.map((message: { body: string }) => message.body);
|
|
assert.deepEqual(visibleBodies, ["WORKER_VISIBLE_MASTER_MESSAGE"]);
|
|
});
|
|
|
|
test("project detail returns 403 when member lacks project view", async () => {
|
|
const response = await getProject(
|
|
await authedRequest(
|
|
"worker@example.com",
|
|
"member",
|
|
"http://127.0.0.1:3000/api/v1/projects/cloud-only-project",
|
|
),
|
|
{ params: Promise.resolve({ projectId: "cloud-only-project" }) },
|
|
);
|
|
assert.equal(response.status, 403);
|
|
});
|
|
|
|
test("messages GET requires project view", async () => {
|
|
const response = await getMessages(
|
|
await authedRequest(
|
|
"worker@example.com",
|
|
"member",
|
|
"http://127.0.0.1:3000/api/v1/projects/cloud-only-project/messages",
|
|
),
|
|
{ params: Promise.resolve({ projectId: "cloud-only-project" }) },
|
|
);
|
|
assert.equal(response.status, 403);
|
|
});
|
|
|
|
test("messages POST requires explicit thread.chat or master_agent.ask", async () => {
|
|
const denied = await postMessages(
|
|
await authedRequest(
|
|
"worker@example.com",
|
|
"member",
|
|
"http://127.0.0.1:3000/api/v1/projects/master-agent/messages",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ body: "总结一下当前进度" }),
|
|
},
|
|
),
|
|
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
|
);
|
|
assert.equal(denied.status, 403);
|
|
|
|
const state = await data.readState();
|
|
state.accountProjectGrants.push({
|
|
grantId: "grant-master-chat",
|
|
account: "worker@example.com",
|
|
projectId: "master-agent",
|
|
permissions: ["project.view", "thread.chat", "master_agent.ask"],
|
|
grantedBy: "krisolo",
|
|
grantedAt: "2026-04-26T12:30:00+08:00",
|
|
});
|
|
await data.writeState(state);
|
|
|
|
const allowed = await postMessages(
|
|
await authedRequest(
|
|
"worker@example.com",
|
|
"member",
|
|
"http://127.0.0.1:3000/api/v1/projects/master-agent/messages",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ body: "总结一下当前进度" }),
|
|
},
|
|
),
|
|
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
|
);
|
|
assert.notEqual(allowed.status, 403);
|
|
});
|
|
|
|
test("authorized single thread can chat and unauthorized single thread is rejected", async () => {
|
|
const state = await data.readState();
|
|
state.accountProjectGrants = [
|
|
{
|
|
grantId: "grant-thread-ui-chat",
|
|
account: "worker@example.com",
|
|
projectId: "rbac-thread",
|
|
permissions: ["project.view", "thread.chat"],
|
|
grantedBy: "krisolo",
|
|
grantedAt: "2026-04-26T12:30:00+08:00",
|
|
},
|
|
];
|
|
await data.writeState(state);
|
|
|
|
const allowed = await postMessages(
|
|
await authedRequest(
|
|
"worker@example.com",
|
|
"member",
|
|
"http://127.0.0.1:3000/api/v1/projects/rbac-thread/messages",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ body: "继续处理授权线程" }),
|
|
},
|
|
),
|
|
{ params: Promise.resolve({ projectId: "rbac-thread" }) },
|
|
);
|
|
assert.notEqual(allowed.status, 403);
|
|
const allowedPayload = await allowed.json();
|
|
assert.equal(allowedPayload.ok, true);
|
|
assert.equal(allowedPayload.message.body, "继续处理授权线程");
|
|
|
|
const denied = await postMessages(
|
|
await authedRequest(
|
|
"worker@example.com",
|
|
"member",
|
|
"http://127.0.0.1:3000/api/v1/projects/cloud-only-project/messages",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ body: "尝试访问未授权线程" }),
|
|
},
|
|
),
|
|
{ params: Promise.resolve({ projectId: "cloud-only-project" }) },
|
|
);
|
|
assert.equal(denied.status, 403);
|
|
});
|
|
|
|
test("message delete requires explicit thread.chat permission", async () => {
|
|
const state = await data.readState();
|
|
state.accountProjectGrants = [
|
|
{
|
|
grantId: "grant-thread-ui-view-only",
|
|
account: "worker@example.com",
|
|
projectId: "rbac-thread",
|
|
permissions: ["project.view"],
|
|
grantedBy: "krisolo",
|
|
grantedAt: "2026-04-26T12:30:00+08:00",
|
|
},
|
|
];
|
|
await data.writeState(state);
|
|
const messageId = state.projects.find((project) => project.id === "rbac-thread")?.messages[0]?.id;
|
|
assert.ok(messageId, "expected seeded rbac-thread message");
|
|
|
|
const denied = await deleteMessage(
|
|
await authedRequest(
|
|
"worker@example.com",
|
|
"member",
|
|
`http://127.0.0.1:3000/api/v1/projects/rbac-thread/messages?messageId=${messageId}`,
|
|
{ method: "DELETE" },
|
|
),
|
|
{ params: Promise.resolve({ projectId: "rbac-thread" }) },
|
|
);
|
|
assert.equal(denied.status, 403);
|
|
|
|
const nextState = await data.readState();
|
|
nextState.accountProjectGrants = [
|
|
{
|
|
grantId: "grant-thread-ui-chat-delete",
|
|
account: "worker@example.com",
|
|
projectId: "rbac-thread",
|
|
permissions: ["project.view", "thread.chat"],
|
|
grantedBy: "krisolo",
|
|
grantedAt: "2026-04-26T12:40:00+08:00",
|
|
},
|
|
];
|
|
await data.writeState(nextState);
|
|
|
|
const allowed = await deleteMessage(
|
|
await authedRequest(
|
|
"worker@example.com",
|
|
"member",
|
|
`http://127.0.0.1:3000/api/v1/projects/rbac-thread/messages?messageId=${messageId}`,
|
|
{ method: "DELETE" },
|
|
),
|
|
{ params: Promise.resolve({ projectId: "rbac-thread" }) },
|
|
);
|
|
assert.equal(allowed.status, 200);
|
|
});
|
|
|
|
test("device skills route only returns skills granted to member", async () => {
|
|
const state = await data.readState();
|
|
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",
|
|
},
|
|
{
|
|
skillId: "mac-studio:gitea-version-upload",
|
|
deviceId: "mac-studio",
|
|
name: "gitea-version-upload",
|
|
description: "Gitea 上传",
|
|
path: "/Users/kris/.codex/skills/gitea-version-upload/SKILL.md",
|
|
invocation: "$gitea-version-upload",
|
|
category: "Mac Studio",
|
|
updatedAt: "2026-04-26T12:00:00+08:00",
|
|
},
|
|
];
|
|
state.accountSkillGrants = [
|
|
{
|
|
grantId: "grant-skill-view",
|
|
account: "worker@example.com",
|
|
skillId: "mac-studio:boss-server-debug",
|
|
deviceId: "mac-studio",
|
|
permissions: ["skill.view", "skill.use"],
|
|
grantedBy: "krisolo",
|
|
grantedAt: "2026-04-26T12:30:00+08:00",
|
|
},
|
|
];
|
|
await data.writeState(state);
|
|
|
|
const response = await getDeviceSkills(
|
|
await authedRequest(
|
|
"worker@example.com",
|
|
"member",
|
|
"http://127.0.0.1:3000/api/v1/devices/mac-studio/skills",
|
|
),
|
|
{ params: Promise.resolve({ deviceId: "mac-studio" }) },
|
|
);
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.deepEqual(
|
|
body.skills.map((skill: { skillId: string }) => skill.skillId),
|
|
["mac-studio:boss-server-debug"],
|
|
);
|
|
});
|