From 69f272d70532b0907a49aebb7d69648f51a7cb47 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:00:12 +0000 Subject: [PATCH] test: guard linked review handoff identity and target boundaries --- .../tasks/__tests__/sendMessageToTask.test.ts | 92 +++++++++++++++++++ .../__tests__/sendMessageUserContext.test.ts | 60 +++++++++--- 2 files changed, 138 insertions(+), 14 deletions(-) diff --git a/apps/api/src/handlers/tasks/__tests__/sendMessageToTask.test.ts b/apps/api/src/handlers/tasks/__tests__/sendMessageToTask.test.ts index edb9c0fa6c..69d6c74d2a 100644 --- a/apps/api/src/handlers/tasks/__tests__/sendMessageToTask.test.ts +++ b/apps/api/src/handlers/tasks/__tests__/sendMessageToTask.test.ts @@ -419,6 +419,98 @@ describe('sendMessageToTask', () => { expect(mockEnqueueTask).not.toHaveBeenCalled(); }); + it.each([ + ['missing token', 'Linked review handoff requires a PR review run token.'], + [ + 'missing review run', + 'Linked review handoff requires an active PR review run.', + ], + [ + 'non-review run', + 'Linked review handoff requires an active PR review run.', + ], + [ + 'missing PR metadata', + 'Linked review handoff requires PR metadata on the review run.', + ], + [ + 'missing PR owner', + 'Linked review handoff requires a reusable PR owner task for the PR.', + ], + [ + 'unrelated target', + 'Linked review handoff target must match the reusable PR owner task for the PR.', + ], + ])( + 'rejects linked review handoffs with %s before any delivery or actor change', + async (invalidCase, error) => { + mockFindLatestTaskRun.mockResolvedValue(createActiveRun()); + mockTaskRunFindFirst.mockResolvedValue( + invalidCase === 'missing review run' + ? null + : { + id: 200, + taskId: 'review-task', + payloadKind: + invalidCase === 'non-review run' + ? 'standard' + : 'github_pr_review', + payload: + invalidCase === 'missing PR metadata' + ? {} + : { + repo: 'acme/app', + prNumber: 42, + prUrl: 'https://github.com/acme/app/pull/42', + }, + }, + ); + mockFindReusableGitHubPrFollowUpOwner.mockResolvedValue( + invalidCase === 'missing PR owner' + ? null + : { + taskId: + invalidCase === 'unrelated target' ? 'other-task' : 'task-1', + }, + ); + + const result = await sendMessageToTask({ + taskId: 'task-1', + userId: 'reviewer-user', + authContext: + invalidCase === 'missing token' + ? undefined + : { + tokenType: 'run', + runId: 200, + userId: null, + principal: 'deployment', + version: 1, + }, + senderMode: 'linked_review_handoff', + message: ` +initial +clean +0 +acme/app +42 +`, + }); + + expect(result).toEqual({ + success: false, + status: 500, + error, + }); + expect(mockSendPromptMutate).not.toHaveBeenCalled(); + expect(mockSteerTaskMutate).not.toHaveBeenCalled(); + expect(mockEnqueueTask).not.toHaveBeenCalled(); + expect(mockNotifyFastAgentParentOnPrFeedback).not.toHaveBeenCalled(); + expect(mockUpdateActingUserIdIfNeeded).not.toHaveBeenCalled(); + expect(mockTouchTaskActivity).not.toHaveBeenCalled(); + }, + ); + it('uses the review run as stable feedback identity when no head SHA is available', async () => { mockFindLatestTaskRun.mockResolvedValue( createActiveRun({ diff --git a/apps/api/src/handlers/tasks/__tests__/sendMessageUserContext.test.ts b/apps/api/src/handlers/tasks/__tests__/sendMessageUserContext.test.ts index a92c9243ac..badc6b8328 100644 --- a/apps/api/src/handlers/tasks/__tests__/sendMessageUserContext.test.ts +++ b/apps/api/src/handlers/tasks/__tests__/sendMessageUserContext.test.ts @@ -142,19 +142,51 @@ describe('send_message / steer_message user context', () => { ); }); - it('still rejects when no user can be resolved for the run', async () => { - mockTaskRunsFindFirst.mockResolvedValue({ - actingUserId: null, - taskId: 'task-review', - }); - - const response = await post( - createApp(userlessRunAuth), - '/tasks/task-impl/send_message', - ); + it.each(['send_message', 'steer_message'])( + '%s ignores stale token attribution after the acting user changes', + async (route) => { + mockTaskRunsFindFirst.mockResolvedValue({ + actingUserId: 'user-current', + taskId: 'task-review', + }); + const response = await post( + createApp({ + ...userlessRunAuth, + userId: 'user-old', + principal: 'user', + }), + `/tasks/task-impl/${route}`, + ); + + expect(response.status).toBe(200); + const deliver = + route === 'send_message' + ? mockSendMessageToTask + : mockSteerMessageToTask; + expect(deliver).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-current' }), + ); + expect(mockGetTaskHumanOwnerUserIds).not.toHaveBeenCalled(); + }, + ); - expect(response.status).toBe(403); - expect(await response.json()).toEqual({ error: 'User context required' }); - expect(mockSendMessageToTask).not.toHaveBeenCalled(); - }); + it.each(['send_message', 'steer_message'])( + 'still rejects %s when no user can be resolved for the run', + async (route) => { + mockTaskRunsFindFirst.mockResolvedValue({ + actingUserId: null, + taskId: 'task-review', + }); + + const response = await post( + createApp(userlessRunAuth), + `/tasks/task-impl/${route}`, + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'User context required' }); + expect(mockSendMessageToTask).not.toHaveBeenCalled(); + expect(mockSteerMessageToTask).not.toHaveBeenCalled(); + }, + ); });