From 019c26743528917e3cebdbc381eba9edd722dbee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 12:32:51 +0200 Subject: [PATCH] test(api): harden estimate races and user auth boundaries --- .../assistant-tools-import-export.test.ts | 107 ++++++++++++++++++ .../src/__tests__/user-router-auth.test.ts | 81 +++++++++++++ packages/api/src/router/assistant-tools.ts | 5 +- 3 files changed, 192 insertions(+), 1 deletion(-) diff --git a/packages/api/src/__tests__/assistant-tools-import-export.test.ts b/packages/api/src/__tests__/assistant-tools-import-export.test.ts index 2110cbc..20bb7dd 100644 --- a/packages/api/src/__tests__/assistant-tools-import-export.test.ts +++ b/packages/api/src/__tests__/assistant-tools-import-export.test.ts @@ -2889,6 +2889,113 @@ describe("assistant import/export and dispo tools", () => { }); }); + it("returns stable assistant errors when estimate creation loses referenced records mid-write", async () => { + const cases = [ + { + name: "missing resource reference", + payload: { + name: "Delivery Estimate", + demandLines: [ + { + resourceId: "resource_1", + lineType: "LABOR", + name: "Animation", + hours: 40, + costRateCents: 0, + billRateCents: 0, + currency: "EUR", + costTotalCents: 0, + priceTotalCents: 0, + monthlySpread: {}, + staffingAttributes: {}, + metadata: {}, + }, + ], + }, + rejection: { + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "EstimateDemandLine_resourceId_fkey" }, + }, + expected: "Resource not found with the given criteria.", + }, + { + name: "missing scope item reference", + payload: { + name: "Delivery Estimate", + scopeItems: [ + { + sequenceNo: 1, + scopeType: "SHOT", + name: "Shot 010", + technicalSpec: {}, + metadata: {}, + }, + ], + demandLines: [ + { + scopeItemId: "scope_item_missing", + lineType: "LABOR", + name: "Lighting", + hours: 24, + costRateCents: 0, + billRateCents: 0, + currency: "EUR", + costTotalCents: 0, + priceTotalCents: 0, + monthlySpread: {}, + staffingAttributes: {}, + metadata: {}, + }, + ], + }, + rejection: { + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "EstimateDemandLine_scopeItemId_fkey" }, + }, + expected: "Estimate scope item not found with the given criteria.", + }, + { + name: "generic referenced record race", + payload: { name: "Delivery Estimate" }, + rejection: { + code: "P2025", + message: "Record to create no longer references a valid row", + meta: { cause: "Dependent record disappeared during nested estimate create" }, + }, + expected: "One of the referenced project, role, resource, or scope items no longer exists.", + }, + ] as const; + + for (const testCase of cases) { + const ctx = createToolContext( + { + estimate: { + create: vi.fn().mockRejectedValue(testCase.rejection), + }, + rateCardLine: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.MANAGER, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_estimate", + JSON.stringify(testCase.payload), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: testCase.expected, + }); + } + }); + it("returns stable assistant errors for estimate mutation tools backed by estimate application use-cases", async () => { const cases = [ { diff --git a/packages/api/src/__tests__/user-router-auth.test.ts b/packages/api/src/__tests__/user-router-auth.test.ts index 9c01be6..f6e0e3b 100644 --- a/packages/api/src/__tests__/user-router-auth.test.ts +++ b/packages/api/src/__tests__/user-router-auth.test.ts @@ -34,6 +34,57 @@ function createContext( } describe("user router authorization", () => { + it("requires authentication for self-service profile lookups", async () => { + const findUnique = vi.fn(); + const caller = createCaller(createContext({ + user: { + findUnique, + }, + }, { session: false })); + + await expect(caller.me()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("requires authentication for dashboard layout reads", async () => { + const findUnique = vi.fn(); + const caller = createCaller(createContext({ + user: { + findUnique, + }, + }, { session: false })); + + await expect(caller.getDashboardLayout()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("requires authentication for favorite project toggles", async () => { + const findUnique = vi.fn(); + const update = vi.fn(); + const caller = createCaller(createContext({ + user: { + findUnique, + update, + }, + }, { session: false })); + + await expect(caller.toggleFavoriteProject({ projectId: "project_1" })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + it("forbids regular users from listing assignable users", async () => { const findMany = vi.fn(); const caller = createCaller(createContext({ @@ -105,6 +156,36 @@ describe("user router authorization", () => { expect(findUnique).not.toHaveBeenCalled(); }); + it("forbids non-admin users from linking resources", async () => { + const userFindUnique = vi.fn(); + const resourceFindUnique = vi.fn(); + const updateMany = vi.fn(); + const update = vi.fn(); + const caller = createCaller(createContext({ + user: { + findUnique: userFindUnique, + }, + resource: { + findUnique: resourceFindUnique, + updateMany, + update, + }, + }, { role: SystemRole.MANAGER })); + + await expect(caller.linkResource({ + userId: "user_2", + resourceId: "resource_1", + })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + + expect(userFindUnique).not.toHaveBeenCalled(); + expect(resourceFindUnique).not.toHaveBeenCalled(); + expect(updateMany).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + it("keeps TOTP verification public for the login flow", async () => { const findUniqueOrThrow = vi.fn().mockResolvedValue({ id: "user_1", diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index 67a0e65..10e06f8 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -1683,7 +1683,7 @@ function getTrpcErrorMetadata(error: unknown): { message?: unknown; cause?: unknown; data?: { code?: unknown }; - shape?: { code?: unknown; message?: unknown }; + shape?: { code?: unknown; message?: unknown; data?: { cause?: unknown } }; }; const candidateCode = typeof candidate.code === "string" @@ -1709,6 +1709,9 @@ function getTrpcErrorMetadata(error: unknown): { if ("cause" in candidate) { queue.push(candidate.cause); } + if (candidate.shape?.data?.cause) { + queue.push(candidate.shape.data.cause); + } } return null;