diff --git a/cdk/test/handlers/github-webhook-processor.test.ts b/cdk/test/handlers/github-webhook-processor.test.ts new file mode 100644 index 00000000..f6b9667e --- /dev/null +++ b/cdk/test/handlers/github-webhook-processor.test.ts @@ -0,0 +1,295 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const s3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn(() => ({ send: s3Send })), + PutObjectCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), +})); + +const captureScreenshotMock = jest.fn(); +jest.mock('../../src/handlers/shared/agentcore-browser', () => ({ + captureScreenshot: (...args: unknown[]) => captureScreenshotMock(...args), +})); + +const resolveGitHubTokenMock = jest.fn(); +jest.mock('../../src/handlers/shared/context-hydration', () => ({ + resolveGitHubToken: (...args: unknown[]) => resolveGitHubTokenMock(...args), +})); + +const upsertTaskCommentMock = jest.fn(); +jest.mock('../../src/handlers/shared/github-comment', () => ({ + upsertTaskComment: (...args: unknown[]) => upsertTaskCommentMock(...args), +})); + +const postIssueCommentMock = jest.fn(); +jest.mock('../../src/handlers/shared/linear-feedback', () => ({ + postIssueComment: (...args: unknown[]) => postIssueCommentMock(...args), +})); + +const findLinearIssueMock = jest.fn(); +const extractLinearIdentifierMock = jest.fn(); +jest.mock('../../src/handlers/shared/linear-issue-lookup', () => ({ + findLinearIssueByIdentifier: (...args: unknown[]) => findLinearIssueMock(...args), + extractLinearIdentifier: (...args: unknown[]) => extractLinearIdentifierMock(...args), +})); + +process.env.SCREENSHOT_BUCKET_NAME = 'screenshot-bucket'; +process.env.SCREENSHOT_PUBLIC_HOST = 'd1.cloudfront.net'; +process.env.GITHUB_TOKEN_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:gh-token'; +process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME = 'LinearWorkspaceRegistry'; + +import { handler } from '../../src/handlers/github-webhook-processor'; + +function payload(overrides: Record = {}): { raw_body: string } { + const body = { + deployment_status: { + id: 99, + state: 'success', + environment_url: 'https://preview.example.com', + }, + deployment: { id: 42, sha: 'abc1234', environment: 'Preview' }, + repository: { full_name: 'owner/repo' }, + ...overrides, + }; + return { raw_body: JSON.stringify(body) }; +} + +function fetchOk(jsonValue: unknown, status = 200): jest.SpyInstance { + return jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: status >= 200 && status < 300, + status, + json: async () => jsonValue, + } as unknown as Response); +} + +describe('github-webhook-processor handler', () => { + beforeEach(() => { + s3Send.mockReset(); + captureScreenshotMock.mockReset(); + resolveGitHubTokenMock.mockReset(); + upsertTaskCommentMock.mockReset(); + postIssueCommentMock.mockReset(); + findLinearIssueMock.mockReset(); + extractLinearIdentifierMock.mockReset(); + jest.restoreAllMocks(); + }); + + test('returns silently when raw_body is empty', async () => { + await handler({ raw_body: '' }); + expect(resolveGitHubTokenMock).not.toHaveBeenCalled(); + }); + + test('returns silently when raw_body is malformed JSON', async () => { + await handler({ raw_body: 'not-json{' }); + expect(resolveGitHubTokenMock).not.toHaveBeenCalled(); + }); + + test('returns when payload missing repo/sha/preview_url', async () => { + await handler({ raw_body: JSON.stringify({ deployment: { id: 42 } }) }); + expect(resolveGitHubTokenMock).not.toHaveBeenCalled(); + }); + + test('returns when GitHub token cannot be resolved', async () => { + resolveGitHubTokenMock.mockRejectedValueOnce(new Error('SM unavailable')); + await handler(payload()); + expect(captureScreenshotMock).not.toHaveBeenCalled(); + }); + + test('returns when no open PR is associated with the SHA after retries', async () => { + jest.useFakeTimers(); + try { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + // Four calls (delays = [0, 5s, 10s, 20s]) all return empty list. + fetchOk([]); + fetchOk([]); + fetchOk([]); + fetchOk([]); + const promise = handler(payload()); + await jest.runAllTimersAsync(); + await promise; + expect(captureScreenshotMock).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + test('only OPEN PRs are accepted (closed/merged are filtered)', async () => { + jest.useFakeTimers(); + try { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 1, state: 'closed', title: 'old', body: '' }]); + fetchOk([{ number: 1, state: 'closed', title: 'old', body: '' }]); + fetchOk([{ number: 1, state: 'closed', title: 'old', body: '' }]); + fetchOk([{ number: 1, state: 'closed', title: 'old', body: '' }]); + const promise = handler(payload()); + await jest.runAllTimersAsync(); + await promise; + expect(captureScreenshotMock).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + test('happy path: PR found → screenshot → S3 → PR comment posted', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 'feat: add x', body: 'body' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1, 2, 3])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); + + await handler(payload()); + + expect(captureScreenshotMock).toHaveBeenCalledWith('https://preview.example.com'); + expect(s3Send).toHaveBeenCalledTimes(1); + const putArg = (s3Send.mock.calls[0][0] as { input: { Key: string; ContentType: string } }).input; + expect(putArg.Key).toBe('screenshots/owner_repo/abc1234-42.png'); + expect(putArg.ContentType).toBe('image/png'); + expect(upsertTaskCommentMock).toHaveBeenCalledTimes(1); + const commentArg = upsertTaskCommentMock.mock.calls[0][0] as { repo: string; issueOrPrNumber: number; body: string }; + expect(commentArg.repo).toBe('owner/repo'); + expect(commentArg.issueOrPrNumber).toBe(17); + expect(commentArg.body).toContain('https://d1.cloudfront.net/screenshots/owner_repo/abc1234-42.png'); + }); + + test('aborts when screenshot capture throws', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 't', body: '' }]); + captureScreenshotMock.mockRejectedValueOnce(new Error('CDP failed')); + + await handler(payload()); + + expect(s3Send).not.toHaveBeenCalled(); + expect(upsertTaskCommentMock).not.toHaveBeenCalled(); + }); + + test('aborts when S3 PutObject throws', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 't', body: '' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockRejectedValueOnce(new Error('S3 throttled')); + + await handler(payload()); + + expect(upsertTaskCommentMock).not.toHaveBeenCalled(); + }); + + test('PR comment failure is non-fatal (log + continue)', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 't', body: '' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockRejectedValueOnce(new Error('GitHub 502')); + + // Should not throw — the handler is best-effort. + await expect(handler(payload())).resolves.toBeUndefined(); + }); + + test('Linear branch fires when registry table set + identifier in PR title', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 'ABCA-42 fix login', body: 'body' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); + extractLinearIdentifierMock.mockReturnValueOnce('ABCA-42'); + findLinearIssueMock.mockResolvedValueOnce({ + issueId: 'issue-uuid', + linearWorkspaceId: 'ws-1', + workspaceSlug: 'abca', + }); + postIssueCommentMock.mockResolvedValueOnce(true); + + await handler(payload()); + + expect(extractLinearIdentifierMock).toHaveBeenCalledWith('ABCA-42 fix login'); + expect(findLinearIssueMock).toHaveBeenCalledWith('ABCA-42', 'LinearWorkspaceRegistry'); + expect(postIssueCommentMock).toHaveBeenCalledTimes(1); + const linearArg = postIssueCommentMock.mock.calls[0]; + expect(linearArg[1]).toBe('issue-uuid'); + expect(linearArg[2]).toContain('https://d1.cloudfront.net/screenshots/owner_repo/abc1234-42.png'); + }); + + test('falls back to extractor on PR body when title yields no identifier', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 'feat: add foo', body: 'closes ABCA-42' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); + extractLinearIdentifierMock + .mockReturnValueOnce(null) // title produces no match + .mockReturnValueOnce('ABCA-42'); // body does + findLinearIssueMock.mockResolvedValueOnce({ + issueId: 'issue-uuid', + linearWorkspaceId: 'ws-1', + workspaceSlug: 'abca', + }); + postIssueCommentMock.mockResolvedValueOnce(true); + + await handler(payload()); + + expect(extractLinearIdentifierMock).toHaveBeenCalledTimes(2); + expect(postIssueCommentMock).toHaveBeenCalledTimes(1); + }); + + test('skips Linear when no identifier extracted', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 'no id', body: 'no id' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); + extractLinearIdentifierMock.mockReturnValue(null); + + await handler(payload()); + + expect(findLinearIssueMock).not.toHaveBeenCalled(); + expect(postIssueCommentMock).not.toHaveBeenCalled(); + }); + + test('skips Linear post when identifier does not resolve to an issue', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 'ABCA-42 stale', body: '' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); + extractLinearIdentifierMock.mockReturnValueOnce('ABCA-42'); + findLinearIssueMock.mockResolvedValueOnce(null); + + await handler(payload()); + + expect(postIssueCommentMock).not.toHaveBeenCalled(); + }); + + test('Linear comment failure does not propagate (best-effort)', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 'ABCA-42 fix', body: '' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); + extractLinearIdentifierMock.mockReturnValueOnce('ABCA-42'); + findLinearIssueMock.mockResolvedValueOnce({ + issueId: 'issue-uuid', + linearWorkspaceId: 'ws-1', + workspaceSlug: 'abca', + }); + postIssueCommentMock.mockResolvedValueOnce(false); + + // No throw — postIssueComment returning false is just logged. + await expect(handler(payload())).resolves.toBeUndefined(); + }); +}); diff --git a/cdk/test/handlers/github-webhook.test.ts b/cdk/test/handlers/github-webhook.test.ts new file mode 100644 index 00000000..d8d71f43 --- /dev/null +++ b/cdk/test/handlers/github-webhook.test.ts @@ -0,0 +1,214 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const ddbSend = jest.fn(); +class FakeConditionalCheckFailedException extends Error { + constructor() { + super('ConditionalCheckFailed'); + this.name = 'ConditionalCheckFailedException'; + } +} +jest.mock('@aws-sdk/client-dynamodb', () => ({ + DynamoDBClient: jest.fn(() => ({})), + ConditionalCheckFailedException: FakeConditionalCheckFailedException, +})); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + DeleteCommand: jest.fn((input: unknown) => ({ _type: 'Delete', input })), +})); + +const lambdaSend = jest.fn(); +jest.mock('@aws-sdk/client-lambda', () => ({ + LambdaClient: jest.fn(() => ({ send: lambdaSend })), + InvokeCommand: jest.fn((input: unknown) => ({ _type: 'Invoke', input })), +})); + +const verifyMock = jest.fn(); +jest.mock('../../src/handlers/shared/github-webhook-verify', () => ({ + verifyGitHubRequest: (...args: unknown[]) => verifyMock(...args), +})); + +process.env.GITHUB_WEBHOOK_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:gh-webhook'; +process.env.GITHUB_WEBHOOK_DEDUP_TABLE_NAME = 'GhWebhookDedup'; +process.env.GITHUB_WEBHOOK_PROCESSOR_FUNCTION_NAME = 'gh-webhook-processor'; + +import { handler } from '../../src/handlers/github-webhook'; + +function event(body: string | null, headers: Record = {}): APIGatewayProxyEvent { + return { + body, + headers: { + 'X-Hub-Signature-256': 'sha256=ignored', + 'X-GitHub-Event': 'deployment_status', + ...headers, + }, + } as unknown as APIGatewayProxyEvent; +} + +function deploymentStatusBody(overrides: { + state?: string; + environment?: string; + environmentUrl?: string | null; + deploymentId?: number | null; + statusId?: number | null; + repo?: string | null; +} = {}): string { + // `??` only short-circuits on undefined/null — so for fields where we + // want to keep an explicit null in the payload (to test missing-field + // behaviour), distinguish on `=== undefined`. + return JSON.stringify({ + deployment_status: { + id: overrides.statusId === undefined ? 99 : overrides.statusId, + state: overrides.state ?? 'success', + environment_url: overrides.environmentUrl === undefined ? 'https://preview-foo.vercel.app' : overrides.environmentUrl, + }, + deployment: { + id: overrides.deploymentId === undefined ? 42 : overrides.deploymentId, + sha: 'abc1234', + environment: overrides.environment ?? 'Preview', + }, + repository: { full_name: overrides.repo === undefined ? 'owner/repo' : overrides.repo }, + }); +} + +describe('github-webhook receiver', () => { + beforeEach(() => { + ddbSend.mockReset(); + lambdaSend.mockReset(); + verifyMock.mockReset(); + verifyMock.mockResolvedValue(true); + }); + + test('400 when body is missing', async () => { + const res = await handler(event(null)); + expect(res.statusCode).toBe(400); + }); + + test('401 when signature header missing', async () => { + const res = await handler(event('{}', { 'X-Hub-Signature-256': '' })); + expect(res.statusCode).toBe(401); + expect(verifyMock).not.toHaveBeenCalled(); + }); + + test('401 when verification fails', async () => { + verifyMock.mockResolvedValueOnce(false); + const res = await handler(event('{}')); + expect(res.statusCode).toBe(401); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('200 ok on ping event', async () => { + const res = await handler(event('{}', { 'X-GitHub-Event': 'ping' })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ ok: true, ping: true }); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('200 silently ignores non-deployment_status events', async () => { + const res = await handler(event('{}', { 'X-GitHub-Event': 'pull_request' })); + expect(res.statusCode).toBe(200); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('400 when body is not JSON', async () => { + const res = await handler(event('not-json{')); + expect(res.statusCode).toBe(400); + }); + + test('200 skipped when deployment_status.state is not success', async () => { + const res = await handler(event(deploymentStatusBody({ state: 'failure' }))); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).skipped_state).toBe('failure'); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('200 skipped when environment does not match SCREENSHOT_TARGET_ENVIRONMENT', async () => { + const res = await handler(event(deploymentStatusBody({ environment: 'Production' }))); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).skipped_environment).toBe('Production'); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('SCREENSHOT_TARGET_ENVIRONMENT override accepts non-Preview names', async () => { + process.env.SCREENSHOT_TARGET_ENVIRONMENT = 'Production'; + try { + ddbSend.mockResolvedValueOnce({}); + lambdaSend.mockResolvedValueOnce({}); + const res = await handler(event(deploymentStatusBody({ environment: 'Production' }))); + expect(res.statusCode).toBe(200); + expect(lambdaSend).toHaveBeenCalledTimes(1); + } finally { + delete process.env.SCREENSHOT_TARGET_ENVIRONMENT; + } + }); + + test('400 when payload missing repo / deployment id / status id', async () => { + const res = await handler(event(deploymentStatusBody({ deploymentId: null }))); + expect(res.statusCode).toBe(400); + }); + + test('200 skipped when environment_url is missing', async () => { + const res = await handler(event(deploymentStatusBody({ environmentUrl: null }))); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).skipped_no_url).toBe(true); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('200 deduped when dedup row already exists', async () => { + ddbSend.mockRejectedValueOnce(new FakeConditionalCheckFailedException()); + const res = await handler(event(deploymentStatusBody())); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).deduped).toBe(true); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('200 ok on the happy path: dedup put, processor invoked', async () => { + ddbSend.mockResolvedValueOnce({}); + lambdaSend.mockResolvedValueOnce({}); + const res = await handler(event(deploymentStatusBody())); + expect(res.statusCode).toBe(200); + expect(lambdaSend).toHaveBeenCalledTimes(1); + // Forwarded payload preserves the raw body verbatim. + const invokeArg = (lambdaSend.mock.calls[0][0] as { input: { Payload: Uint8Array } }).input; + const decoded = JSON.parse(new TextDecoder().decode(invokeArg.Payload)); + expect(decoded.raw_body).toBeDefined(); + }); + + test('rolls back the dedup row when processor invoke fails', async () => { + ddbSend + .mockResolvedValueOnce({}) // PutCommand + .mockResolvedValueOnce({}); // DeleteCommand cleanup + lambdaSend.mockRejectedValueOnce(new Error('lambda throttled')); + const res = await handler(event(deploymentStatusBody())); + expect(res.statusCode).toBe(500); + // Two ddb calls: put then delete-rollback. + expect(ddbSend).toHaveBeenCalledTimes(2); + const second = (ddbSend.mock.calls[1][0] as { _type: string }) ; + expect(second._type).toBe('Delete'); + }); + + test('returns 500 if dedup put throws a non-ConditionalCheck error', async () => { + ddbSend.mockRejectedValueOnce(new Error('DDB unavailable')); + const res = await handler(event(deploymentStatusBody())); + expect(res.statusCode).toBe(500); + }); +}); diff --git a/cdk/test/handlers/shared/agentcore-browser.test.ts b/cdk/test/handlers/shared/agentcore-browser.test.ts new file mode 100644 index 00000000..dcbf5616 --- /dev/null +++ b/cdk/test/handlers/shared/agentcore-browser.test.ts @@ -0,0 +1,222 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { EventEmitter } from 'events'; + +const bedrockSend = jest.fn(); +jest.mock('@aws-sdk/client-bedrock-agentcore', () => ({ + BedrockAgentCoreClient: jest.fn(() => ({ send: bedrockSend })), + StartBrowserSessionCommand: jest.fn((input: unknown) => ({ _type: 'Start', input })), + StopBrowserSessionCommand: jest.fn((input: unknown) => ({ _type: 'Stop', input })), +})); + +// Static credentials so SigV4 doesn't reach for real AWS metadata. +jest.mock('@aws-sdk/credential-provider-node', () => ({ + defaultProvider: () => async () => ({ + accessKeyId: 'AKIATEST', + secretAccessKey: 'secret', + sessionToken: 'token', + }), +})); + +class FakeWebSocket extends EventEmitter { + // Latest instance — tests reach in to drive message events. + public static last: FakeWebSocket | null = null; + /** Override per-test to inject failures on construction. */ + public static onConstruct: ((url: string, ws: FakeWebSocket) => void) | null = null; + /** Per-test scripted reactions — one per cdpSend call, in order. */ + public static reactions: Array<(msg: { id: number; method: string }) => Record | null> = []; + + public sentMessages: string[] = []; + public closed = false; + + constructor(public url: string) { + super(); + FakeWebSocket.last = this; + // Fire `open` on the next tick so callers can wire listeners first. + setImmediate(() => { + if (FakeWebSocket.onConstruct) { + FakeWebSocket.onConstruct(url, this); + } else { + this.emit('open'); + } + }); + } + + send(data: string): void { + this.sentMessages.push(data); + // Auto-respond from the per-test scripted reactions. + const msg = JSON.parse(data) as { id: number; method: string; sessionId?: string }; + const reaction = FakeWebSocket.reactions.shift(); + if (reaction) { + const reply = reaction(msg); + if (reply !== null) { + // Echo the id back so the caller's pending map resolves. + setImmediate(() => this.emit('message', JSON.stringify({ ...reply, id: msg.id }))); + } + } + } + + close(): void { + this.closed = true; + } +} + +jest.mock('ws', () => ({ + __esModule: true, + default: jest.fn().mockImplementation((url: string) => new FakeWebSocket(url)), +})); + +import { captureScreenshot } from '../../../src/handlers/shared/agentcore-browser'; + +describe('captureScreenshot', () => { + beforeEach(() => { + bedrockSend.mockReset(); + FakeWebSocket.last = null; + FakeWebSocket.reactions = []; + FakeWebSocket.onConstruct = null; + }); + + test('throws if StartBrowserSession returns no sessionId / endpoint', async () => { + bedrockSend.mockResolvedValueOnce({ sessionId: undefined, streams: undefined }); + await expect(captureScreenshot('https://x')).rejects.toThrow(/no sessionId/); + }); + + test('happy path: drives CDP, returns PNG bytes, stops the session', async () => { + // Start + bedrockSend.mockResolvedValueOnce({ + sessionId: 'sess-1', + streams: { + automationStream: { streamEndpoint: 'wss://example.com/automation' }, + }, + }); + // Stop (in finally) + bedrockSend.mockResolvedValueOnce({}); + + // CDP exchange — one reaction per send() in order: + // 1. Target.getTargets → return one page target + // 2. Target.attachToTarget → return sessionId + // 3. Page.enable → ack {} + // 4. Page.navigate → ack {} + // 5. Page.captureScreenshot → return base64 PNG + FakeWebSocket.reactions = [ + () => ({ result: { targetInfos: [{ targetId: 't1', type: 'page', url: 'about:blank' }] } }), + () => ({ result: { sessionId: 'flat-sess' } }), + () => ({ result: {} }), + () => { + // Navigation succeeded; emit Page.loadEventFired event next tick. + setImmediate(() => { + FakeWebSocket.last!.emit('message', JSON.stringify({ method: 'Page.loadEventFired' })); + }); + return { result: {} }; + }, + () => ({ result: { data: Buffer.from('PNG-DATA').toString('base64') } }), + ]; + + // Skip the 2-second post-load settle — keep test fast. + const realSetTimeout = global.setTimeout; + jest.spyOn(global, 'setTimeout').mockImplementation(((cb: () => void, ms?: number) => { + // Fire 2-second settle synchronously, but preserve real timer + // behaviour for the deadline-tracking timeouts (long delays). + if (typeof ms === 'number' && ms === 2000) { + cb(); + return 0 as unknown as NodeJS.Timeout; + } + return realSetTimeout(cb, ms); + }) as typeof global.setTimeout); + + const png = await captureScreenshot('https://preview.example.com'); + + expect(Buffer.from(png).toString()).toBe('PNG-DATA'); + // StartBrowserSession + StopBrowserSession both called. + expect(bedrockSend).toHaveBeenCalledTimes(2); + // WSS URL was presigned with SigV4 query params — must contain the + // canonical X-Amz- headers. + expect(FakeWebSocket.last!.url).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256'); + expect(FakeWebSocket.last!.url).toContain('X-Amz-Credential='); + expect(FakeWebSocket.last!.url).toContain('X-Amz-Signature='); + // Socket closed on the way out. + expect(FakeWebSocket.last!.closed).toBe(true); + }); + + test('still attempts StopBrowserSession on CDP failure (best-effort cleanup)', async () => { + bedrockSend.mockResolvedValueOnce({ + sessionId: 'sess-1', + streams: { automationStream: { streamEndpoint: 'wss://example.com/automation' } }, + }); + bedrockSend.mockResolvedValueOnce({}); + + // Target.getTargets returns NO page target → should throw. + FakeWebSocket.reactions = [ + () => ({ result: { targetInfos: [] } }), + ]; + + await expect(captureScreenshot('https://x')).rejects.toThrow(/No page target/); + // Stop was still called. + const stopCalls = bedrockSend.mock.calls.filter((c) => (c[0] as { _type: string })._type === 'Stop'); + expect(stopCalls.length).toBe(1); + }); + + test('logs but does not throw when StopBrowserSession itself fails', async () => { + bedrockSend + .mockResolvedValueOnce({ + sessionId: 'sess-1', + streams: { automationStream: { streamEndpoint: 'wss://example.com/automation' } }, + }) + .mockRejectedValueOnce(new Error('stop failed')); // Stop in finally + + FakeWebSocket.reactions = [ + () => ({ result: { targetInfos: [] } }), // -> caller throws "No page target" + ]; + + // Original error from try-block surfaces; finally's Stop error is logged. + await expect(captureScreenshot('https://x')).rejects.toThrow(/No page target/); + }); + + test('rejects when WS upgrade returns unexpected-response (e.g. 403)', async () => { + bedrockSend.mockResolvedValueOnce({ + sessionId: 'sess-1', + streams: { automationStream: { streamEndpoint: 'wss://example.com/automation' } }, + }); + bedrockSend.mockResolvedValueOnce({}); + + FakeWebSocket.onConstruct = (_url, ws) => { + setImmediate(() => ws.emit('unexpected-response', {}, { statusCode: 403 })); + }; + + await expect(captureScreenshot('https://x')).rejects.toThrow(/handshake failed: HTTP 403/); + }); + + test('Page.navigate that errors throws with the error text', async () => { + bedrockSend.mockResolvedValueOnce({ + sessionId: 'sess-1', + streams: { automationStream: { streamEndpoint: 'wss://example.com/automation' } }, + }); + bedrockSend.mockResolvedValueOnce({}); + + FakeWebSocket.reactions = [ + () => ({ result: { targetInfos: [{ targetId: 't1', type: 'page', url: 'about:blank' }] } }), + () => ({ result: { sessionId: 'flat-sess' } }), + () => ({ result: {} }), + () => ({ result: { errorText: 'net::ERR_CONNECTION_REFUSED' } }), + ]; + + await expect(captureScreenshot('https://broken')).rejects.toThrow(/net::ERR_CONNECTION_REFUSED/); + }); +}); diff --git a/cdk/test/handlers/shared/github-webhook-verify.test.ts b/cdk/test/handlers/shared/github-webhook-verify.test.ts new file mode 100644 index 00000000..bebc3063 --- /dev/null +++ b/cdk/test/handlers/shared/github-webhook-verify.test.ts @@ -0,0 +1,158 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; + +const smSend = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: smSend })), + GetSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'GetSecretValue', input })), +})); + +import { + getGitHubWebhookSecret, + invalidateGitHubWebhookSecretCache, + verifyGitHubRequest, + verifyGitHubSignature, +} from '../../../src/handlers/shared/github-webhook-verify'; + +const SECRET_ID = 'arn:aws:secretsmanager:us-east-1:123:secret:gh-webhook'; +const SECRET_VALUE = 'super-secret'; + +function signed(body: string, key = SECRET_VALUE): string { + return 'sha256=' + crypto.createHmac('sha256', key).update(body).digest('hex'); +} + +describe('verifyGitHubSignature', () => { + test('accepts a valid sha256 signature', () => { + const body = '{"hello":"world"}'; + expect(verifyGitHubSignature(SECRET_VALUE, signed(body), body)).toBe(true); + }); + + test('rejects when signature mismatches', () => { + const body = '{"hello":"world"}'; + expect(verifyGitHubSignature('wrong', signed(body), body)).toBe(false); + }); + + test('rejects header missing the sha256= prefix (e.g. legacy sha1=)', () => { + const body = '{"hello":"world"}'; + const sha1 = 'sha1=' + crypto.createHmac('sha1', SECRET_VALUE).update(body).digest('hex'); + expect(verifyGitHubSignature(SECRET_VALUE, sha1, body)).toBe(false); + }); + + test('rejects when provided digest is the wrong byte length (timingSafeEqual would throw)', () => { + const body = '{"hello":"world"}'; + expect(verifyGitHubSignature(SECRET_VALUE, 'sha256=deadbeef', body)).toBe(false); + }); + + test('rejects when the body has been tampered', () => { + const sig = signed('{"hello":"world"}'); + expect(verifyGitHubSignature(SECRET_VALUE, sig, '{"hello":"WORLD"}')).toBe(false); + }); +}); + +describe('getGitHubWebhookSecret', () => { + beforeEach(() => { + smSend.mockReset(); + invalidateGitHubWebhookSecretCache(SECRET_ID); + }); + + test('returns the secret string and caches it', async () => { + smSend.mockResolvedValueOnce({ SecretString: SECRET_VALUE }); + const v1 = await getGitHubWebhookSecret(SECRET_ID); + const v2 = await getGitHubWebhookSecret(SECRET_ID); + expect(v1).toBe(SECRET_VALUE); + expect(v2).toBe(SECRET_VALUE); + // Cache hit on second call — only one SM round-trip. + expect(smSend).toHaveBeenCalledTimes(1); + }); + + test('forceRefresh bypasses the cache', async () => { + smSend + .mockResolvedValueOnce({ SecretString: SECRET_VALUE }) + .mockResolvedValueOnce({ SecretString: 'rotated' }); + await getGitHubWebhookSecret(SECRET_ID); + const v2 = await getGitHubWebhookSecret(SECRET_ID, true); + expect(v2).toBe('rotated'); + expect(smSend).toHaveBeenCalledTimes(2); + }); + + test('returns null and drops cache entry when SecretString is missing', async () => { + smSend.mockResolvedValueOnce({}); + expect(await getGitHubWebhookSecret(SECRET_ID)).toBeNull(); + // Next call should re-fetch (cache should be dropped). + smSend.mockResolvedValueOnce({ SecretString: SECRET_VALUE }); + expect(await getGitHubWebhookSecret(SECRET_ID)).toBe(SECRET_VALUE); + expect(smSend).toHaveBeenCalledTimes(2); + }); + + test('returns null on ResourceNotFoundException', async () => { + const err = new Error('not found') as Error & { name: string }; + err.name = 'ResourceNotFoundException'; + smSend.mockRejectedValueOnce(err); + expect(await getGitHubWebhookSecret(SECRET_ID)).toBeNull(); + }); + + test('rethrows on transient SM errors so callers can fail-closed', async () => { + smSend.mockRejectedValueOnce(new Error('throttled')); + await expect(getGitHubWebhookSecret(SECRET_ID)).rejects.toThrow('throttled'); + }); +}); + +describe('verifyGitHubRequest (cache + transparent re-fetch)', () => { + beforeEach(() => { + smSend.mockReset(); + invalidateGitHubWebhookSecretCache(SECRET_ID); + }); + + test('verifies on first try when cached secret matches', async () => { + smSend.mockResolvedValueOnce({ SecretString: SECRET_VALUE }); + const body = '{"event":"deployment_status"}'; + expect(await verifyGitHubRequest(SECRET_ID, signed(body), body)).toBe(true); + }); + + test('re-fetches and retries on signature mismatch (post-rotation path)', async () => { + // First fetch returns the OLD secret; refresh returns the NEW one. + // GitHub now signs with NEW; we should accept after the re-fetch. + smSend + .mockResolvedValueOnce({ SecretString: 'old-secret' }) + .mockResolvedValueOnce({ SecretString: 'new-secret' }); + const body = '{"event":"deployment_status"}'; + const sig = signed(body, 'new-secret'); + expect(await verifyGitHubRequest(SECRET_ID, sig, body)).toBe(true); + expect(smSend).toHaveBeenCalledTimes(2); + }); + + test('returns false when refresh returns identical secret (no real rotation)', async () => { + smSend + .mockResolvedValueOnce({ SecretString: 'old-secret' }) + .mockResolvedValueOnce({ SecretString: 'old-secret' }); + const body = '{"event":"deployment_status"}'; + const sig = signed(body, 'definitely-not-the-secret'); + expect(await verifyGitHubRequest(SECRET_ID, sig, body)).toBe(false); + }); + + test('returns false when refresh fetch returns null', async () => { + smSend + .mockResolvedValueOnce({ SecretString: 'old-secret' }) + .mockResolvedValueOnce({}); // no SecretString + const body = '{"event":"deployment_status"}'; + expect(await verifyGitHubRequest(SECRET_ID, signed(body, 'wrong'), body)).toBe(false); + }); +}); diff --git a/cdk/test/handlers/shared/linear-issue-lookup.test.ts b/cdk/test/handlers/shared/linear-issue-lookup.test.ts new file mode 100644 index 00000000..4273f862 --- /dev/null +++ b/cdk/test/handlers/shared/linear-issue-lookup.test.ts @@ -0,0 +1,240 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + ScanCommand: jest.fn((input: unknown) => ({ _type: 'Scan', input })), +})); + +const resolveLinearOauthTokenMock = jest.fn(); +jest.mock('../../../src/handlers/shared/linear-oauth-resolver', () => ({ + resolveLinearOauthToken: (...args: unknown[]) => resolveLinearOauthTokenMock(...args), +})); + +import { + extractLinearIdentifier, + findLinearIssueByIdentifier, +} from '../../../src/handlers/shared/linear-issue-lookup'; + +const REGISTRY = 'LinearWorkspaceRegistry'; + +interface RegistryRow { + linear_workspace_id: string; + workspace_slug: string; + team_keys?: string[]; +} + +function scanResultRows(rows: RegistryRow[]): { Items: Record[] } { + return { Items: rows.map((r) => ({ ...r, status: 'active' })) }; +} + +function mockGraphqlOnce(found: { id: string; identifier: string } | null, status = 200): jest.SpyInstance { + const fetchMock = jest.spyOn(global, 'fetch') as unknown as jest.SpyInstance; + fetchMock.mockResolvedValueOnce({ + ok: status >= 200 && status < 300, + status, + json: async () => (found + ? { data: { issueVcsBranchSearch: found } } + : { data: { issueVcsBranchSearch: null } }), + } as unknown as Response); + return fetchMock; +} + +describe('extractLinearIdentifier', () => { + test('matches an identifier in plain text', () => { + expect(extractLinearIdentifier('Fix bug ABCA-42 today')).toBe('ABCA-42'); + }); + + test('returns the FIRST identifier when multiple are present', () => { + expect(extractLinearIdentifier('See ABCA-1 and PLAT-99')).toBe('ABCA-1'); + }); + + test('handles identifiers with single-letter team keys', () => { + expect(extractLinearIdentifier('Closes A-7')).toBe('A-7'); + }); + + test('returns null on null/undefined input', () => { + expect(extractLinearIdentifier(null)).toBeNull(); + expect(extractLinearIdentifier(undefined)).toBeNull(); + expect(extractLinearIdentifier('')).toBeNull(); + }); + + test('rejects lowercase team keys (Linear keys are upper-case)', () => { + expect(extractLinearIdentifier('abca-42')).toBeNull(); + }); + + test('rejects identifiers with too many digits (regex bound)', () => { + expect(extractLinearIdentifier('ABCA-123456789')).toBeNull(); + }); + + test('subsequent calls are independent (g-flag lastIndex reset)', () => { + const first = extractLinearIdentifier('first ABCA-1'); + const second = extractLinearIdentifier('second PLAT-2'); + expect(first).toBe('ABCA-1'); + expect(second).toBe('PLAT-2'); + }); +}); + +describe('findLinearIssueByIdentifier', () => { + beforeEach(() => { + ddbSend.mockReset(); + resolveLinearOauthTokenMock.mockReset(); + jest.restoreAllMocks(); + }); + + test('returns null when registry scan fails', async () => { + ddbSend.mockRejectedValueOnce(new Error('throttled')); + const result = await findLinearIssueByIdentifier('ABCA-1', REGISTRY); + expect(result).toBeNull(); + expect(resolveLinearOauthTokenMock).not.toHaveBeenCalled(); + }); + + test('returns null when no active workspaces are registered', async () => { + ddbSend.mockResolvedValueOnce({ Items: [] }); + expect(await findLinearIssueByIdentifier('ABCA-1', REGISTRY)).toBeNull(); + }); + + test('prefix-matches the workspace whose team_keys contain the identifier prefix', async () => { + ddbSend.mockResolvedValueOnce(scanResultRows([ + { linear_workspace_id: 'ws-other', workspace_slug: 'other', team_keys: ['PLAT'] }, + { linear_workspace_id: 'ws-abca', workspace_slug: 'abca', team_keys: ['ABCA'] }, + ])); + resolveLinearOauthTokenMock.mockResolvedValueOnce({ accessToken: 'tok-abca' }); + mockGraphqlOnce({ id: 'issue-uuid', identifier: 'ABCA-42' }); + + const result = await findLinearIssueByIdentifier('ABCA-42', REGISTRY); + + expect(result).toEqual({ + issueId: 'issue-uuid', + linearWorkspaceId: 'ws-abca', + workspaceSlug: 'abca', + }); + // Only the matching workspace's token was resolved — no scan-all. + expect(resolveLinearOauthTokenMock).toHaveBeenCalledTimes(1); + expect(resolveLinearOauthTokenMock).toHaveBeenCalledWith('ws-abca', REGISTRY); + }); + + test('prefix-match comparison is case-insensitive on identifier and team_keys', async () => { + ddbSend.mockResolvedValueOnce(scanResultRows([ + { linear_workspace_id: 'ws-abca', workspace_slug: 'abca', team_keys: ['abca'] }, + ])); + resolveLinearOauthTokenMock.mockResolvedValueOnce({ accessToken: 'tok-abca' }); + mockGraphqlOnce({ id: 'issue-uuid', identifier: 'ABCA-42' }); + + const result = await findLinearIssueByIdentifier('ABCA-42', REGISTRY); + expect(result?.issueId).toBe('issue-uuid'); + }); + + test('falls through to scan-all when no workspace prefix-matches (legacy rows)', async () => { + ddbSend.mockResolvedValueOnce(scanResultRows([ + { linear_workspace_id: 'ws-legacy-a', workspace_slug: 'legacy-a' }, // no team_keys + { linear_workspace_id: 'ws-legacy-b', workspace_slug: 'legacy-b' }, // no team_keys + ])); + resolveLinearOauthTokenMock + .mockResolvedValueOnce({ accessToken: 'tok-a' }) + .mockResolvedValueOnce({ accessToken: 'tok-b' }); + mockGraphqlOnce(null); // first workspace doesn't have the issue + mockGraphqlOnce({ id: 'issue-uuid', identifier: 'ABCA-42' }); + + const result = await findLinearIssueByIdentifier('ABCA-42', REGISTRY); + + expect(result?.linearWorkspaceId).toBe('ws-legacy-b'); + expect(resolveLinearOauthTokenMock).toHaveBeenCalledTimes(2); + }); + + test('falls back to scanning others when prefix-matched workspace returns no hit', async () => { + ddbSend.mockResolvedValueOnce(scanResultRows([ + { linear_workspace_id: 'ws-abca', workspace_slug: 'abca', team_keys: ['ABCA'] }, + { linear_workspace_id: 'ws-other', workspace_slug: 'other', team_keys: ['PLAT'] }, + ])); + resolveLinearOauthTokenMock + .mockResolvedValueOnce({ accessToken: 'tok-abca' }) // prefix match + .mockResolvedValueOnce({ accessToken: 'tok-other' }); // fallback iter + mockGraphqlOnce(null); // ABCA workspace doesn't actually have it + mockGraphqlOnce({ id: 'issue-uuid', identifier: 'ABCA-42' }); + + const result = await findLinearIssueByIdentifier('ABCA-42', REGISTRY); + expect(result?.linearWorkspaceId).toBe('ws-other'); + // Two resolves: prefix-match attempt + one fallback. + expect(resolveLinearOauthTokenMock).toHaveBeenCalledTimes(2); + }); + + test('skips workspaces whose token resolver returns null', async () => { + ddbSend.mockResolvedValueOnce(scanResultRows([ + { linear_workspace_id: 'ws-revoked', workspace_slug: 'rev', team_keys: ['ABCA'] }, + { linear_workspace_id: 'ws-good', workspace_slug: 'good', team_keys: ['PLAT'] }, + ])); + resolveLinearOauthTokenMock + .mockResolvedValueOnce(null) // prefix match has no usable token + .mockResolvedValueOnce({ accessToken: 'tok-good' }); + // Only ONE GraphQL call — the prefix-matched workspace was skipped. + mockGraphqlOnce({ id: 'issue-uuid', identifier: 'ABCA-42' }); + + const result = await findLinearIssueByIdentifier('ABCA-42', REGISTRY); + expect(result?.linearWorkspaceId).toBe('ws-good'); + }); + + test('returns null when GraphQL returns a different identifier (fuzzy match guard)', async () => { + ddbSend.mockResolvedValueOnce(scanResultRows([ + { linear_workspace_id: 'ws-abca', workspace_slug: 'abca', team_keys: ['ABCA'] }, + ])); + resolveLinearOauthTokenMock.mockResolvedValueOnce({ accessToken: 'tok-abca' }); + // Linear's branch search is fuzzy — we asked for ABCA-42 but it + // returned ABCA-43. Reject the near-neighbor. + mockGraphqlOnce({ id: 'issue-uuid', identifier: 'ABCA-43' }); + + expect(await findLinearIssueByIdentifier('ABCA-42', REGISTRY)).toBeNull(); + }); + + test('returns null when GraphQL responds non-2xx', async () => { + ddbSend.mockResolvedValueOnce(scanResultRows([ + { linear_workspace_id: 'ws-abca', workspace_slug: 'abca', team_keys: ['ABCA'] }, + ])); + resolveLinearOauthTokenMock.mockResolvedValueOnce({ accessToken: 'tok-abca' }); + mockGraphqlOnce(null, 500); + + expect(await findLinearIssueByIdentifier('ABCA-42', REGISTRY)).toBeNull(); + }); + + test('returns null when GraphQL fetch throws (network failure)', async () => { + ddbSend.mockResolvedValueOnce(scanResultRows([ + { linear_workspace_id: 'ws-abca', workspace_slug: 'abca', team_keys: ['ABCA'] }, + ])); + resolveLinearOauthTokenMock.mockResolvedValueOnce({ accessToken: 'tok-abca' }); + jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('socket hang up')); + + expect(await findLinearIssueByIdentifier('ABCA-42', REGISTRY)).toBeNull(); + }); + + test('returns null when GraphQL response carries errors[]', async () => { + ddbSend.mockResolvedValueOnce(scanResultRows([ + { linear_workspace_id: 'ws-abca', workspace_slug: 'abca', team_keys: ['ABCA'] }, + ])); + resolveLinearOauthTokenMock.mockResolvedValueOnce({ accessToken: 'tok-abca' }); + jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ errors: [{ message: 'unauthorized' }] }), + } as unknown as Response); + + expect(await findLinearIssueByIdentifier('ABCA-42', REGISTRY)).toBeNull(); + }); +});