Skip to content

Commit 9b66dd2

Browse files
committed
test(clickup): webhook provider handler tests + redact create-response secret in error logs
1 parent 9009b54 commit 9b66dd2

2 files changed

Lines changed: 395 additions & 2 deletions

File tree

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { hmacSha256Hex } from '@sim/security/hmac'
5+
import { NextRequest, NextResponse } from 'next/server'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockGetCredentialOwner, mockRefreshAccessTokenIfNeeded } = vi.hoisted(() => ({
9+
mockGetCredentialOwner: vi.fn(),
10+
mockRefreshAccessTokenIfNeeded: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({
14+
getProviderConfig: (webhook: { providerConfig?: Record<string, unknown> }) =>
15+
webhook.providerConfig || {},
16+
getNotificationUrl: () => 'https://app.example.com/api/webhooks/trigger/clickup-path',
17+
getCredentialOwner: mockGetCredentialOwner,
18+
}))
19+
20+
vi.mock('@/app/api/auth/oauth/utils', () => ({
21+
refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded,
22+
}))
23+
24+
import { clickupHandler } from '@/lib/webhooks/providers/clickup'
25+
26+
const fetchMock = vi.fn()
27+
28+
function reqWithHeaders(headers: Record<string, string>): NextRequest {
29+
return new NextRequest('http://localhost/test', { headers })
30+
}
31+
32+
function jsonResponse(status: number, body: Record<string, unknown>) {
33+
return new Response(JSON.stringify(body), {
34+
status,
35+
headers: { 'Content-Type': 'application/json' },
36+
})
37+
}
38+
39+
function createContext(providerConfig: Record<string, unknown>) {
40+
return {
41+
webhook: { id: 'webhook-row-1', path: 'clickup-path', providerConfig },
42+
workflow: {},
43+
userId: 'user-1',
44+
requestId: 'req-1',
45+
} as never
46+
}
47+
48+
describe('ClickUp webhook provider', () => {
49+
beforeEach(() => {
50+
vi.clearAllMocks()
51+
vi.stubGlobal('fetch', fetchMock)
52+
mockGetCredentialOwner.mockResolvedValue({ userId: 'user-1', accountId: 'account-1' })
53+
mockRefreshAccessTokenIfNeeded.mockResolvedValue('oauth-token')
54+
})
55+
56+
afterEach(() => {
57+
vi.unstubAllGlobals()
58+
})
59+
60+
describe('verifyAuth', () => {
61+
it('fails closed when no webhookSecret is configured', async () => {
62+
const res = await clickupHandler.verifyAuth!({
63+
request: reqWithHeaders({}),
64+
rawBody: '{}',
65+
requestId: 't1',
66+
providerConfig: {},
67+
webhook: {},
68+
workflow: {},
69+
})
70+
expect(res?.status).toBe(401)
71+
})
72+
73+
it('rejects when the X-Signature header is missing', async () => {
74+
const res = await clickupHandler.verifyAuth!({
75+
request: reqWithHeaders({}),
76+
rawBody: '{}',
77+
requestId: 't2',
78+
providerConfig: { webhookSecret: 'secret' },
79+
webhook: {},
80+
workflow: {},
81+
})
82+
expect(res?.status).toBe(401)
83+
})
84+
85+
it('rejects an invalid signature', async () => {
86+
const res = await clickupHandler.verifyAuth!({
87+
request: reqWithHeaders({ 'X-Signature': 'deadbeef' }),
88+
rawBody: '{"event":"taskCreated"}',
89+
requestId: 't3',
90+
providerConfig: { webhookSecret: 'secret' },
91+
webhook: {},
92+
workflow: {},
93+
})
94+
expect(res?.status).toBe(401)
95+
})
96+
97+
it('accepts a valid HMAC-SHA256 hex signature over the raw body', async () => {
98+
const rawBody = '{"event":"taskCreated","task_id":"abc"}'
99+
const signature = hmacSha256Hex(rawBody, 'secret')
100+
const res = await clickupHandler.verifyAuth!({
101+
request: reqWithHeaders({ 'X-Signature': signature }),
102+
rawBody,
103+
requestId: 't4',
104+
providerConfig: { webhookSecret: 'secret' },
105+
webhook: {},
106+
workflow: {},
107+
})
108+
expect(res).toBeNull()
109+
})
110+
})
111+
112+
describe('matchEvent', () => {
113+
it('passes when the event matches the trigger', async () => {
114+
const result = await clickupHandler.matchEvent!({
115+
webhook: { id: 'w1' },
116+
workflow: { id: 'wf1' },
117+
body: { event: 'taskCreated' },
118+
request: reqWithHeaders({}),
119+
requestId: 't5',
120+
providerConfig: { triggerId: 'clickup_task_created' },
121+
})
122+
expect(result).toBe(true)
123+
})
124+
125+
it('skips with a response when the event does not match', async () => {
126+
const result = await clickupHandler.matchEvent!({
127+
webhook: { id: 'w1' },
128+
workflow: { id: 'wf1' },
129+
body: { event: 'taskDeleted' },
130+
request: reqWithHeaders({}),
131+
requestId: 't6',
132+
providerConfig: { triggerId: 'clickup_task_created' },
133+
})
134+
expect(result).toBeInstanceOf(NextResponse)
135+
})
136+
137+
it('passes all events through for the catch-all trigger', async () => {
138+
const result = await clickupHandler.matchEvent!({
139+
webhook: { id: 'w1' },
140+
workflow: { id: 'wf1' },
141+
body: { event: 'goalCreated' },
142+
request: reqWithHeaders({}),
143+
requestId: 't7',
144+
providerConfig: { triggerId: 'clickup_webhook' },
145+
})
146+
expect(result).toBe(true)
147+
})
148+
})
149+
150+
describe('extractIdempotencyId', () => {
151+
it('derives the documented webhook_id:history_item_id key', () => {
152+
const body = {
153+
event: 'taskCreated',
154+
webhook_id: 'wh-1',
155+
task_id: 'abc',
156+
history_items: [{ id: 'hist-1' }],
157+
}
158+
expect(clickupHandler.extractIdempotencyId!(body)).toBe('clickup:wh-1:hist-1')
159+
expect(clickupHandler.extractIdempotencyId!({ ...body })).toBe('clickup:wh-1:hist-1')
160+
})
161+
162+
it('returns null without history items or webhook_id', () => {
163+
expect(
164+
clickupHandler.extractIdempotencyId!({ event: 'taskCreated', webhook_id: 'wh-1' })
165+
).toBeNull()
166+
expect(
167+
clickupHandler.extractIdempotencyId!({
168+
event: 'taskCreated',
169+
history_items: [{ id: 'h1' }],
170+
})
171+
).toBeNull()
172+
})
173+
})
174+
175+
describe('formatInput', () => {
176+
const baseBody = {
177+
event: 'taskCreated',
178+
webhook_id: 'wh-1',
179+
task_id: 'abc',
180+
history_items: [{ id: 'h1' }],
181+
}
182+
183+
function formatCtx(triggerId: string, body: Record<string, unknown>) {
184+
return {
185+
webhook: { providerConfig: { triggerId } },
186+
workflow: { id: 'wf1', userId: 'user-1' },
187+
body,
188+
headers: {},
189+
requestId: 't8',
190+
} as never
191+
}
192+
193+
it('maps task events to the task output keys', async () => {
194+
const { input } = await clickupHandler.formatInput!(
195+
formatCtx('clickup_task_created', baseBody)
196+
)
197+
expect(Object.keys(input as Record<string, unknown>).sort()).toEqual([
198+
'eventType',
199+
'historyItems',
200+
'payload',
201+
'taskId',
202+
])
203+
expect((input as Record<string, unknown>).taskId).toBe('abc')
204+
})
205+
206+
it('maps list events to the list output keys', async () => {
207+
const body = { event: 'listCreated', webhook_id: 'wh-1', list_id: '162641285' }
208+
const { input } = await clickupHandler.formatInput!(formatCtx('clickup_list_created', body))
209+
expect((input as Record<string, unknown>).listId).toBe('162641285')
210+
expect((input as Record<string, unknown>).eventType).toBe('listCreated')
211+
})
212+
213+
it('maps goal events to the documented base keys only', async () => {
214+
const body = { event: 'goalCreated', webhook_id: 'wh-1' }
215+
const { input } = await clickupHandler.formatInput!(formatCtx('clickup_goal_created', body))
216+
expect(Object.keys(input as Record<string, unknown>).sort()).toEqual([
217+
'eventType',
218+
'historyItems',
219+
'payload',
220+
])
221+
})
222+
223+
it('maps the catch-all trigger to the generic output keys', async () => {
224+
const { input } = await clickupHandler.formatInput!(formatCtx('clickup_webhook', baseBody))
225+
expect(Object.keys(input as Record<string, unknown>).sort()).toEqual([
226+
'eventType',
227+
'folderId',
228+
'historyItems',
229+
'listId',
230+
'payload',
231+
'spaceId',
232+
'taskId',
233+
])
234+
})
235+
})
236+
237+
describe('createSubscription', () => {
238+
const validConfig = {
239+
triggerId: 'clickup_task_created',
240+
credentialId: 'cred-1',
241+
triggerWorkspaceId: '108',
242+
}
243+
244+
it('creates the webhook and returns externalId + webhookSecret', async () => {
245+
fetchMock.mockResolvedValueOnce(
246+
jsonResponse(200, { id: 'ext-1', webhook: { id: 'ext-1', secret: 'shh' } })
247+
)
248+
249+
const result = await clickupHandler.createSubscription!(createContext(validConfig))
250+
251+
expect(fetchMock).toHaveBeenCalledTimes(1)
252+
const [url, init] = fetchMock.mock.calls[0]
253+
expect(url).toBe('https://api.clickup.com/api/v2/team/108/webhook')
254+
expect(init.headers.Authorization).toBe('Bearer oauth-token')
255+
expect(JSON.parse(init.body)).toEqual({
256+
endpoint: 'https://app.example.com/api/webhooks/trigger/clickup-path',
257+
events: ['taskCreated'],
258+
})
259+
expect(result?.providerConfigUpdates).toEqual({ externalId: 'ext-1', webhookSecret: 'shh' })
260+
})
261+
262+
it('subscribes with the wildcard for the catch-all trigger and coerces location filters', async () => {
263+
fetchMock.mockResolvedValueOnce(
264+
jsonResponse(200, { id: 'ext-2', webhook: { secret: 'shh' } })
265+
)
266+
267+
await clickupHandler.createSubscription!(
268+
createContext({
269+
triggerId: 'clickup_webhook',
270+
credentialId: 'cred-1',
271+
triggerWorkspaceId: '108',
272+
triggerSpaceId: '1234',
273+
triggerListId: '9876',
274+
triggerTaskId: 'abc1234',
275+
})
276+
)
277+
278+
const [, init] = fetchMock.mock.calls[0]
279+
expect(JSON.parse(init.body)).toEqual({
280+
endpoint: 'https://app.example.com/api/webhooks/trigger/clickup-path',
281+
events: ['*'],
282+
space_id: 1234,
283+
list_id: 9876,
284+
task_id: 'abc1234',
285+
})
286+
})
287+
288+
it('throws a friendly error when the workspace is missing', async () => {
289+
await expect(
290+
clickupHandler.createSubscription!(
291+
createContext({ triggerId: 'clickup_task_created', credentialId: 'cred-1' })
292+
)
293+
).rejects.toThrow(/workspace is required/i)
294+
expect(fetchMock).not.toHaveBeenCalled()
295+
})
296+
297+
it('throws a friendly error on 401 from ClickUp', async () => {
298+
fetchMock.mockResolvedValueOnce(jsonResponse(401, { err: 'Token invalid' }))
299+
await expect(
300+
clickupHandler.createSubscription!(createContext(validConfig))
301+
).rejects.toThrow(/authentication failed/i)
302+
})
303+
304+
it('rolls back the created webhook and throws when no secret is returned', async () => {
305+
fetchMock.mockResolvedValueOnce(jsonResponse(200, { id: 'ext-3', webhook: { id: 'ext-3' } }))
306+
fetchMock.mockResolvedValueOnce(jsonResponse(200, {}))
307+
308+
await expect(
309+
clickupHandler.createSubscription!(createContext(validConfig))
310+
).rejects.toThrow(/no signing secret/i)
311+
312+
expect(fetchMock).toHaveBeenCalledTimes(2)
313+
const [deleteUrl, deleteInit] = fetchMock.mock.calls[1]
314+
expect(deleteUrl).toBe('https://api.clickup.com/api/v2/webhook/ext-3')
315+
expect(deleteInit.method).toBe('DELETE')
316+
})
317+
318+
it('rejects non-numeric location filters before calling ClickUp', async () => {
319+
await expect(
320+
clickupHandler.createSubscription!(
321+
createContext({ ...validConfig, triggerSpaceId: 'not-a-number' })
322+
)
323+
).rejects.toThrow(/Space ID must be numeric/)
324+
expect(fetchMock).not.toHaveBeenCalled()
325+
})
326+
})
327+
328+
describe('deleteSubscription', () => {
329+
it('deletes the external webhook', async () => {
330+
fetchMock.mockResolvedValueOnce(jsonResponse(200, {}))
331+
await clickupHandler.deleteSubscription!({
332+
webhook: {
333+
id: 'webhook-row-1',
334+
providerConfig: { externalId: 'ext-1', credentialId: 'cred-1' },
335+
},
336+
workflow: {},
337+
requestId: 'req-1',
338+
})
339+
const [url, init] = fetchMock.mock.calls[0]
340+
expect(url).toBe('https://api.clickup.com/api/v2/webhook/ext-1')
341+
expect(init.method).toBe('DELETE')
342+
})
343+
344+
it('tolerates 404 and never throws when not strict', async () => {
345+
fetchMock.mockResolvedValueOnce(jsonResponse(404, {}))
346+
await expect(
347+
clickupHandler.deleteSubscription!({
348+
webhook: {
349+
id: 'webhook-row-1',
350+
providerConfig: { externalId: 'ext-1', credentialId: 'cred-1' },
351+
},
352+
workflow: {},
353+
requestId: 'req-1',
354+
})
355+
).resolves.toBeUndefined()
356+
})
357+
358+
it('throws on failure only when strict', async () => {
359+
fetchMock.mockResolvedValueOnce(jsonResponse(500, {}))
360+
await expect(
361+
clickupHandler.deleteSubscription!({
362+
webhook: {
363+
id: 'webhook-row-1',
364+
providerConfig: { externalId: 'ext-1', credentialId: 'cred-1' },
365+
},
366+
workflow: {},
367+
requestId: 'req-1',
368+
strict: true,
369+
})
370+
).rejects.toThrow(/Failed to delete ClickUp webhook/)
371+
})
372+
373+
it('skips gracefully when externalId is missing', async () => {
374+
await expect(
375+
clickupHandler.deleteSubscription!({
376+
webhook: { id: 'webhook-row-1', providerConfig: { credentialId: 'cred-1' } },
377+
workflow: {},
378+
requestId: 'req-1',
379+
})
380+
).resolves.toBeUndefined()
381+
expect(fetchMock).not.toHaveBeenCalled()
382+
})
383+
})
384+
})

0 commit comments

Comments
 (0)