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