|
| 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