From b254ab70ba8aeac32b0865d8b1acff951160f34b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 11:08:14 +0200 Subject: [PATCH] test(auth): cover notification and user router audiences --- docs/route-access-matrix.md | 23 ++++ .../notification-router-auth.test.ts | 116 ++++++++++++++++ .../src/__tests__/user-router-auth.test.ts | 130 ++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 packages/api/src/__tests__/notification-router-auth.test.ts create mode 100644 packages/api/src/__tests__/user-router-auth.test.ts diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 9aa9817..872cc18 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -168,6 +168,29 @@ Reasoning: - the calendar catalog is currently consumed in the web app only by the admin vacation editor, so broad authenticated reads expose internal configuration without a product need - narrowing just the catalog reads keeps the hardening slice small while avoiding regressions in shared holiday-resolution helpers used by vacation, timeline, and assistant flows +### `packages/api/src/router/notification.ts` + +- `list`, `unreadCount`, `markRead`, task detail/status routes, reminder routes, and `delete`: `self-service` +- `create`, `createBroadcast`, `listBroadcasts`, `getBroadcastById`, `createTask`, `assignTask`: `manager-write` + +Reasoning: + +- the self-service surface is already constrained to the caller's own notifications, reminders, tasks, or assignee visibility +- broadcast and task-assignment flows can affect other users and organization-wide messaging, so they must stay on explicit manager-or-admin procedures + +### `packages/api/src/router/user.ts` + +- `me`, dashboard layout/preferences, favorites, MFA setup/status: `self-service` +- `listAssignable`: `manager-write` +- `list`, `activeCount`, create/update role and permissions, resource linking, `getEffectivePermissions`, `disableTotp`: `admin-only` +- `verifyTotp`: `public` for the login flow + +Reasoning: + +- self-service user routes only expose or mutate the authenticated account's own preferences and MFA state +- `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 + ## Assistant Parity Rule - assistant tool visibility must never widen the audience of the backing router diff --git a/packages/api/src/__tests__/notification-router-auth.test.ts b/packages/api/src/__tests__/notification-router-auth.test.ts new file mode 100644 index 0000000..e72c3b2 --- /dev/null +++ b/packages/api/src/__tests__/notification-router-auth.test.ts @@ -0,0 +1,116 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { notificationRouter } from "../router/notification.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(notificationRouter); + +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.MANAGER ? "user_mgr" : "user_1", + systemRole: role, + permissionOverrides: null, + } + : null, + }; +} + +describe("notification router authorization", () => { + it("requires authentication for self-service notification lists", async () => { + const findMany = vi.fn(); + const caller = createCaller(createContext({ + notification: { + findMany, + }, + user: { + findUnique: vi.fn(), + }, + }, { session: false })); + + await expect(caller.list({ limit: 20 })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findMany).not.toHaveBeenCalled(); + }); + + it("forbids regular users from creating broadcasts", async () => { + const create = vi.fn(); + const caller = createCaller(createContext({ + notificationBroadcast: { + create, + }, + })); + + await expect(caller.createBroadcast({ + title: "Ops update", + targetType: "all", + })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Manager or Admin role required", + }); + + expect(create).not.toHaveBeenCalled(); + }); + + it("forbids regular users from reassigning tasks", async () => { + const findUnique = vi.fn(); + const caller = createCaller(createContext({ + notification: { + findUnique, + }, + })); + + await expect(caller.assignTask({ id: "task_1", assigneeId: "user_2" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Manager or Admin role required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("allows managers to list broadcasts", async () => { + const findMany = vi.fn().mockResolvedValue([ + { + id: "broadcast_1", + title: "Ops update", + createdAt: new Date("2026-03-30T10:00:00Z"), + sender: { id: "user_mgr", name: "Manager", email: "mgr@example.com" }, + }, + ]); + const caller = createCaller(createContext({ + notificationBroadcast: { + findMany, + }, + }, { role: SystemRole.MANAGER })); + + const result = await caller.listBroadcasts({ limit: 10 }); + + expect(result).toHaveLength(1); + expect(findMany).toHaveBeenCalledWith({ + orderBy: { createdAt: "desc" }, + take: 10, + include: { + sender: { select: { id: true, name: true, email: true } }, + }, + }); + }); +}); diff --git a/packages/api/src/__tests__/user-router-auth.test.ts b/packages/api/src/__tests__/user-router-auth.test.ts new file mode 100644 index 0000000..9c01be6 --- /dev/null +++ b/packages/api/src/__tests__/user-router-auth.test.ts @@ -0,0 +1,130 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { userRouter } from "../router/user.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(userRouter); + +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, + roleDefaults: null, + }; +} + +describe("user router authorization", () => { + it("forbids regular users from listing assignable users", async () => { + const findMany = vi.fn(); + const caller = createCaller(createContext({ + user: { + findMany, + }, + })); + + await expect(caller.listAssignable()).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Manager or Admin role required", + }); + + expect(findMany).not.toHaveBeenCalled(); + }); + + it("allows managers to list assignable users", async () => { + const findMany = vi.fn().mockResolvedValue([ + { id: "user_1", name: "Alice", email: "alice@example.com" }, + ]); + const caller = createCaller(createContext({ + user: { + findMany, + }, + }, { role: SystemRole.MANAGER })); + + const result = await caller.listAssignable(); + + expect(result).toHaveLength(1); + expect(findMany).toHaveBeenCalledWith({ + select: { + id: true, + name: true, + email: true, + }, + orderBy: { name: "asc" }, + }); + }); + + it("forbids non-admin users from reading effective permissions", async () => { + const findUnique = vi.fn(); + const caller = createCaller(createContext({ + user: { + findUnique, + }, + }, { role: SystemRole.MANAGER })); + + await expect(caller.getEffectivePermissions({ userId: "user_2" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("forbids non-admin users from disabling TOTP for other users", async () => { + const findUnique = vi.fn(); + const caller = createCaller(createContext({ + user: { + findUnique, + }, + }, { role: SystemRole.MANAGER })); + + await expect(caller.disableTotp({ userId: "user_2" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("keeps TOTP verification public for the login flow", async () => { + const findUniqueOrThrow = vi.fn().mockResolvedValue({ + id: "user_1", + totpEnabled: false, + totpSecret: null, + }); + const caller = createCaller(createContext({ + user: { + findUniqueOrThrow, + }, + }, { session: false })); + + await expect(caller.verifyTotp({ userId: "user_1", token: "123456" })).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "TOTP is not enabled for this user.", + }); + + expect(findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: "user_1" }, + select: { id: true, totpSecret: true, totpEnabled: true }, + }); + }); +});