From d3ad3508217b01e0b4836f5fe51589961a90d5a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 12:20:55 +0200 Subject: [PATCH] test(assistant): document self-service approval access --- docs/route-access-matrix.md | 10 ++ .../__tests__/assistant-router-auth.test.ts | 130 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 packages/api/src/__tests__/assistant-router-auth.test.ts diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 19bccf5..2144981 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -218,6 +218,16 @@ Reasoning: - `listAssignable` is an operational lookup for delegation and assignment flows, which fits manager and admin audiences - user administration and effective-permission inspection expose high-sensitivity identity and authorization state and therefore should remain admin-only +### `packages/api/src/router/assistant.ts` + +- `listPendingApprovals`: `self-service` +- `chat`: authenticated shell with tool-level audience enforcement + +Reasoning: + +- `listPendingApprovals` reads pending approvals by `ctx.dbUser.id`, so it is a self-service view of the caller's own approval queue +- `chat` requires authentication, but the effective data audience is enforced by assistant tool selection and backing router permissions rather than by a single broad router audience on the chat endpoint itself + ## Assistant Parity Rule - assistant tool visibility must never widen the audience of the backing router diff --git a/packages/api/src/__tests__/assistant-router-auth.test.ts b/packages/api/src/__tests__/assistant-router-auth.test.ts new file mode 100644 index 0000000..28cbcb8 --- /dev/null +++ b/packages/api/src/__tests__/assistant-router-auth.test.ts @@ -0,0 +1,130 @@ +import { AssistantApprovalStatus, type Prisma } from "@capakraken/db"; +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { assistantRouter } from "../router/assistant.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(assistantRouter); + +function createContext( + db: Record, + options: { + role?: SystemRole; + session?: boolean; + } = {}, +) { + const { role = SystemRole.USER, session = true } = options; + + return { + session: session + ? { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + } + : null, + db: db as never, + dbUser: session + ? { + id: role === SystemRole.ADMIN ? "user_admin" : "user_1", + systemRole: role, + permissionOverrides: null, + } + : null, + }; +} + +describe("assistant router authorization", () => { + it("requires authentication for pending approval lists", async () => { + const updateMany = vi.fn(); + const findMany = vi.fn(); + const caller = createCaller(createContext({ + assistantApproval: { + updateMany, + findMany, + }, + }, { session: false })); + + await expect(caller.listPendingApprovals()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(updateMany).not.toHaveBeenCalled(); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("lists only pending approvals for the current user", async () => { + const createdAt = new Date("2026-03-30T10:00:00.000Z"); + const expiresAt = new Date("2026-03-30T10:15:00.000Z"); + const updateMany = vi.fn().mockResolvedValue({ count: 0 }); + const findMany = vi.fn().mockResolvedValue([ + { + id: "approval_1", + userId: "user_1", + conversationId: "conv_1", + toolName: "create_project", + toolArguments: "{\"name\":\"Apollo\"}", + summary: "create project Apollo", + status: AssistantApprovalStatus.PENDING, + createdAt, + expiresAt, + }, + ] satisfies Array>>); + const caller = createCaller(createContext({ + assistantApproval: { + updateMany, + findMany, + }, + })); + + const result = await caller.listPendingApprovals(); + + expect(result).toEqual([ + { + id: "approval_1", + status: "pending", + conversationId: "conv_1", + toolName: "create_project", + summary: "create project Apollo", + createdAt: createdAt.toISOString(), + expiresAt: expiresAt.toISOString(), + }, + ]); + expect(updateMany).toHaveBeenCalledWith({ + where: { + userId: "user_1", + status: AssistantApprovalStatus.PENDING, + expiresAt: { lte: expect.any(Date) }, + }, + data: { + status: AssistantApprovalStatus.EXPIRED, + }, + }); + expect(findMany).toHaveBeenCalledWith({ + where: { + userId: "user_1", + status: AssistantApprovalStatus.PENDING, + expiresAt: { gt: expect.any(Date) }, + }, + orderBy: { createdAt: "desc" }, + }); + }); + + it("requires authentication before starting assistant chat", async () => { + const findUnique = vi.fn(); + const caller = createCaller(createContext({ + systemSettings: { + findUnique, + }, + }, { session: false })); + + await expect(caller.chat({ + messages: [{ role: "user", content: "Hallo" }], + })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + }); +});