Files
boss/tests/rbac-route-filtering.test.ts
2026-05-17 02:20:08 +08:00

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"],
);
});