diff --git a/apps/sim/lib/webhooks/providers/gitlab.test.ts b/apps/sim/lib/webhooks/providers/gitlab.test.ts new file mode 100644 index 00000000000..5ac2143a252 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/gitlab.test.ts @@ -0,0 +1,229 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { gitlabHandler } from '@/lib/webhooks/providers/gitlab' +import { isGitLabEventMatch } from '@/triggers/gitlab/utils' + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +describe('GitLab webhook provider', () => { + it('verifyAuth rejects when webhookSecret is missing', async () => { + const res = await gitlabHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth rejects when X-Gitlab-Token header is missing', async () => { + const res = await gitlabHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't2', + providerConfig: { webhookSecret: 'my-secret' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth rejects when the token does not match', async () => { + const res = await gitlabHandler.verifyAuth!({ + request: reqWithHeaders({ 'X-Gitlab-Token': 'wrong' }), + rawBody: '{}', + requestId: 't3', + providerConfig: { webhookSecret: 'my-secret' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth accepts a matching X-Gitlab-Token', async () => { + const res = await gitlabHandler.verifyAuth!({ + request: reqWithHeaders({ 'X-Gitlab-Token': 'my-secret' }), + rawBody: '{}', + requestId: 't4', + providerConfig: { webhookSecret: 'my-secret' }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + + it('isGitLabEventMatch matches the configured trigger to its object_kind', () => { + expect(isGitLabEventMatch('gitlab_push', 'push')).toBe(true) + expect(isGitLabEventMatch('gitlab_push', 'issue')).toBe(false) + expect(isGitLabEventMatch('gitlab_comment', 'note')).toBe(true) + expect(isGitLabEventMatch('gitlab_webhook', 'anything')).toBe(true) + }) + + it('matchEvent passes through all events for the all-events trigger', async () => { + const result = await gitlabHandler.matchEvent!({ + body: { object_kind: 'issue' }, + requestId: 't5', + providerConfig: { triggerId: 'gitlab_webhook' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(true) + }) + + it('matchEvent filters events that do not match the configured trigger', async () => { + const result = await gitlabHandler.matchEvent!({ + body: { object_kind: 'issue' }, + requestId: 't6', + providerConfig: { triggerId: 'gitlab_push' }, + webhook: {}, + workflow: {}, + request: reqWithHeaders({}), + }) + expect(result).toBe(false) + }) + + it('formatInput derives event_type and branch from the push payload', async () => { + const { input } = await gitlabHandler.formatInput!({ + body: { object_kind: 'push', ref: 'refs/heads/main', checkout_sha: 'abc123' }, + headers: { 'x-gitlab-event': 'Push Hook' }, + requestId: 't7', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + expect(i.event_type).toBe('Push Hook') + expect(i.branch).toBe('main') + expect(i.checkout_sha).toBe('abc123') + }) + + it('formatInput exposes object_attributes.type as work_item_type on issue payloads, keeping the raw type key too', async () => { + const { input } = await gitlabHandler.formatInput!({ + body: { + object_kind: 'issue', + object_attributes: { id: 1, iid: 2, title: 'Bug', type: 'Issue' }, + }, + headers: { 'x-gitlab-event': 'Issue Hook' }, + requestId: 't8', + webhook: {}, + workflow: { id: 'w', userId: 'u' }, + }) + const i = input as Record + const attrs = i.object_attributes as Record + expect(attrs.work_item_type).toBe('Issue') + expect(attrs.type).toBe('Issue') + expect(attrs.title).toBe('Bug') + }) + + it('extractIdempotencyId derives a stable key for push events from checkout_sha', () => { + const body = { + object_kind: 'push', + project: { id: 42 }, + ref: 'refs/heads/main', + checkout_sha: 'abc123', + } + const first = gitlabHandler.extractIdempotencyId!(body) + const second = gitlabHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + expect(first).toContain('abc123') + expect(first).toContain('42') + }) + + it('extractIdempotencyId does not collide across different branches deleted in the same project', () => { + const deleteMain = { + object_kind: 'push', + project: { id: 42 }, + ref: 'refs/heads/main', + checkout_sha: null, + after: '0000000000000000000000000000000000000000', + } + const deleteFeature = { + object_kind: 'push', + project: { id: 42 }, + ref: 'refs/heads/feature', + checkout_sha: null, + after: '0000000000000000000000000000000000000000', + } + const first = gitlabHandler.extractIdempotencyId!(deleteMain) + const second = gitlabHandler.extractIdempotencyId!(deleteFeature) + expect(first).not.toBeNull() + expect(second).not.toBeNull() + expect(first).not.toBe(second) + }) + + it('extractIdempotencyId is stable for a repeated delivery of the same branch deletion', () => { + const body = { + object_kind: 'push', + project: { id: 42 }, + ref: 'refs/heads/main', + checkout_sha: null, + after: '0000000000000000000000000000000000000000', + } + const first = gitlabHandler.extractIdempotencyId!(body) + const second = gitlabHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + }) + + it('extractIdempotencyId derives a stable key for issue events from object_attributes', () => { + const body = { + object_kind: 'issue', + project: { id: 7 }, + object_attributes: { id: 99, updated_at: '2026-01-01T00:00:00.000Z' }, + } + const first = gitlabHandler.extractIdempotencyId!(body) + const second = gitlabHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + expect(first).toContain('99') + expect(first).toContain('7') + }) + + it('extractIdempotencyId distinguishes pipeline lifecycle transitions despite no updated_at', () => { + const pending = gitlabHandler.extractIdempotencyId!({ + object_kind: 'pipeline', + project: { id: 7 }, + object_attributes: { id: 31, status: 'pending', created_at: '2026-01-01T00:00:00Z' }, + }) + const running = gitlabHandler.extractIdempotencyId!({ + object_kind: 'pipeline', + project: { id: 7 }, + object_attributes: { id: 31, status: 'running', created_at: '2026-01-01T00:00:00Z' }, + }) + const success = gitlabHandler.extractIdempotencyId!({ + object_kind: 'pipeline', + project: { id: 7 }, + object_attributes: { + id: 31, + status: 'success', + created_at: '2026-01-01T00:00:00Z', + finished_at: '2026-01-01T00:03:00Z', + }, + }) + expect(pending).not.toBeNull() + expect(pending).not.toBe(running) + expect(running).not.toBe(success) + + const retryOfSuccess = gitlabHandler.extractIdempotencyId!({ + object_kind: 'pipeline', + project: { id: 7 }, + object_attributes: { + id: 31, + status: 'success', + created_at: '2026-01-01T00:00:00Z', + finished_at: '2026-01-01T00:03:00Z', + }, + }) + expect(success).toBe(retryOfSuccess) + }) + + it('extractIdempotencyId returns null when there is no stable identifier', () => { + expect(gitlabHandler.extractIdempotencyId!({ object_kind: 'push' })).toBeNull() + expect(gitlabHandler.extractIdempotencyId!({ object_kind: 'issue' })).toBeNull() + }) +}) diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts index d812ee19d92..2d2d9e66f58 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -99,16 +99,87 @@ export const gitlabHandler: WebhookProviderHandler = { return true }, + /** + * GitLab 17.2+ adds a `type` field inside `object_attributes` on Issue Hook + * payloads (e.g. "Issue", "Incident", "Task"). `type` is a reserved + * TriggerOutput meta-key, so it can't be declared in the output schema + * under that name — exposed there as `work_item_type` instead. The raw + * `type` key is kept in the delivered data alongside it (this is plain + * passthrough data, not schema-constrained) so a workflow already + * referencing the undocumented raw path keeps working. + */ async formatInput({ body, headers }: FormatInputContext): Promise { const b = asRecord(body) const eventType = headers['x-gitlab-event'] || '' const ref = (b.ref as string) || '' const branch = ref.replace('refs/heads/', '') - return { - input: { ...b, event_type: eventType, branch }, + const objectAttributes = b.object_attributes + let input: Record = { ...b, event_type: eventType, branch } + if ( + objectAttributes && + typeof objectAttributes === 'object' && + !Array.isArray(objectAttributes) + ) { + const workItemType = (objectAttributes as Record).type + if (workItemType !== undefined) { + input = { + ...input, + object_attributes: { ...objectAttributes, work_item_type: workItemType }, + } + } } + + return { input } }, + /** + * GitLab does not automatically retry a failed delivery — a failed request + * only counts toward auto-disabling the webhook (4 consecutive failures + * disables it temporarily, 40 permanently), and re-delivery only happens + * via a manual "Resend Request" (UI or API), which carries the same + * `webhook-id`/`Idempotency-Key`/`X-Gitlab-Event-UUID` headers as the + * original. Those headers are already in the shared idempotency service's + * allowlist and checked ahead of this method, which only receives the body. + * This is a content-derived fallback for the rare case those headers are + * stripped in transit (e.g. by an intermediary proxy). checkout_sha is + * null on branch/tag deletion (after falls back to the all-zeros SHA), so + * ref is included to keep unrelated deletions in one project from + * colliding onto the same key. Pipeline Hook payloads have no updated_at + * at all (confirmed against docs.gitlab.com — only Issue/Merge Request/ + * Note hooks reliably include it), so status + finished_at/created_at is + * used there instead to keep each lifecycle transition of one pipeline + * (pending/running/success/failed) from colliding onto the same key. + */ + extractIdempotencyId(body: unknown): string | null { + const b = asRecord(body) + const objectKind = (b.object_kind as string) || '' + const project = asRecord(b.project) + const projectId = project.id != null ? String(project.id) : '' + + if (objectKind === 'push' || objectKind === 'tag_push') { + const ref = (b.ref as string) || '' + const checkoutSha = (b.checkout_sha as string) || (b.after as string) || '' + if (!checkoutSha && !ref) return null + return `gitlab:${objectKind}:${projectId}:${ref}:${checkoutSha}` + } + + const objectAttributes = asRecord(b.object_attributes) + const id = objectAttributes.id != null ? String(objectAttributes.id) : '' + if (!id) return null + const version = + (objectAttributes.updated_at as string) || + [objectAttributes.status, objectAttributes.finished_at || objectAttributes.created_at] + .filter(Boolean) + .join(':') || + '' + return `gitlab:${objectKind || 'event'}:${projectId}:${id}:${version}` + }, + + /** + * Validates the optional self-managed host up front so a structurally + * unsafe value surfaces as a clear error instead of an unhandled + * UnsafeGitLabHostError. + */ async createSubscription(ctx: SubscriptionContext): Promise { const config = getProviderConfig(ctx.webhook) const accessToken = config.accessToken as string | undefined @@ -120,8 +191,6 @@ export const gitlabHandler: WebhookProviderHandler = { throw new Error('GitLab Personal Access Token is required to create the webhook.') if (!projectId) throw new Error('GitLab Project ID is required to create the webhook.') - // Validate the optional self-managed host up front so a structurally unsafe - // value surfaces as a clear error instead of an unhandled UnsafeGitLabHostError. try { getGitLabApiBase(host) } catch (error) { @@ -163,8 +232,6 @@ export const gitlabHandler: WebhookProviderHandler = { const created = (await res.json().catch(() => ({}))) as { id?: number | string } if (created.id === undefined || created.id === null) { - // The hook was created but we can't read its id — delete it by URL so it - // is not orphaned in GitLab. await cleanupGitLabHookByUrl(projectId, accessToken, getNotificationUrl(ctx.webhook), host) throw new Error('GitLab webhook created but no hook ID was returned.') } @@ -173,6 +240,10 @@ export const gitlabHandler: WebhookProviderHandler = { return { providerConfigUpdates: { externalId: String(created.id), webhookSecret: secretToken } } }, + /** + * A structurally unsafe host must not abort cleanup in non-strict mode — + * mirrors the graceful skip used for missing credentials below. + */ async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { const config = getProviderConfig(ctx.webhook) const accessToken = config.accessToken as string | undefined @@ -188,8 +259,6 @@ export const gitlabHandler: WebhookProviderHandler = { return } - // A structurally unsafe host must not abort cleanup in non-strict mode — mirror - // the graceful skip used for missing credentials above. try { getGitLabApiBase(host) } catch (error) { diff --git a/apps/sim/lib/webhooks/providers/gmail.test.ts b/apps/sim/lib/webhooks/providers/gmail.test.ts new file mode 100644 index 00000000000..301ef37c372 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/gmail.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { gmailHandler } from '@/lib/webhooks/providers/gmail' +import { gmailPollingTrigger } from '@/triggers/gmail' + +const sampleEmail = { + id: 'msg-123', + threadId: 'thread-456', + subject: 'Test subject', + from: 'sender@example.com', + to: 'recipient@example.com', + cc: '', + date: '2026-07-08T00:00:00.000Z', + bodyText: 'plain text body', + bodyHtml: '

html body

', + labels: ['INBOX', 'UNREAD'], + hasAttachments: false, + attachments: [], +} + +describe('Gmail webhook provider', () => { + it('formatInput passes through the polled email and timestamp unchanged', async () => { + const { input } = await gmailHandler.formatInput!({ + webhook: {}, + workflow: { id: 'wf', userId: 'u' }, + body: { email: sampleEmail, timestamp: '2026-07-08T00:00:05.000Z' }, + headers: {}, + requestId: 'test', + }) + + expect(input).toEqual({ + email: sampleEmail, + timestamp: '2026-07-08T00:00:05.000Z', + }) + }) + + it('passes the raw body through when it has no email key', async () => { + const { input } = await gmailHandler.formatInput!({ + webhook: {}, + workflow: { id: 'wf', userId: 'u' }, + body: { foo: 'bar' }, + headers: {}, + requestId: 'test', + }) + + expect(input).toEqual({ foo: 'bar' }) + }) + + it('every key formatInput can deliver on `email` matches a declared trigger output key', async () => { + const { input } = await gmailHandler.formatInput!({ + webhook: {}, + workflow: { id: 'wf', userId: 'u' }, + body: { email: sampleEmail, timestamp: '2026-07-08T00:00:05.000Z' }, + headers: {}, + requestId: 'test', + }) + + const declaredEmailKeys = Object.keys(gmailPollingTrigger.outputs.email) + const deliveredEmailKeys = Object.keys((input as { email: object }).email) + + expect(deliveredEmailKeys.sort()).toEqual(declaredEmailKeys.sort()) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/instantly.test.ts b/apps/sim/lib/webhooks/providers/instantly.test.ts new file mode 100644 index 00000000000..9e3584cde9a --- /dev/null +++ b/apps/sim/lib/webhooks/providers/instantly.test.ts @@ -0,0 +1,405 @@ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { instantlyHandler } from '@/lib/webhooks/providers/instantly' + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +describe('Instantly webhook provider', () => { + it('verifyAuth rejects when secretToken is missing (fail-closed)', async () => { + const res = await instantlyHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth rejects when the token header is missing', async () => { + const res = await instantlyHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't2', + providerConfig: { secretToken: 'expected-token' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth rejects an incorrect token', async () => { + const res = await instantlyHandler.verifyAuth!({ + request: reqWithHeaders({ 'x-sim-webhook-token': 'wrong-token' }), + rawBody: '{}', + requestId: 't3', + providerConfig: { secretToken: 'expected-token' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('verifyAuth accepts a matching token', async () => { + const res = await instantlyHandler.verifyAuth!({ + request: reqWithHeaders({ 'x-sim-webhook-token': 'expected-token' }), + rawBody: '{}', + requestId: 't4', + providerConfig: { secretToken: 'expected-token' }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + + it('matchEvent passes all events through for the generic webhook trigger', async () => { + const matched = await instantlyHandler.matchEvent!({ + body: { event_type: 'lead_interested' }, + requestId: 't5', + providerConfig: { triggerId: 'instantly_webhook' }, + webhook: {}, + workflow: {}, + request: new NextRequest('http://localhost/test'), + }) + expect(matched).toBe(true) + }) + + it('matchEvent filters events that do not match the configured trigger', async () => { + const matched = await instantlyHandler.matchEvent!({ + body: { event_type: 'email_opened' }, + requestId: 't6', + providerConfig: { triggerId: 'instantly_email_sent' }, + webhook: {}, + workflow: {}, + request: new NextRequest('http://localhost/test'), + }) + expect(matched).toBe(false) + }) + + it('matchEvent matches events for the configured trigger', async () => { + const matched = await instantlyHandler.matchEvent!({ + body: { event_type: 'email_sent' }, + requestId: 't7', + providerConfig: { triggerId: 'instantly_email_sent' }, + webhook: {}, + workflow: {}, + request: new NextRequest('http://localhost/test'), + }) + expect(matched).toBe(true) + }) + + it('matchEvent accepts both link-click event type spellings', async () => { + const matchedA = await instantlyHandler.matchEvent!({ + body: { event_type: 'link_clicked' }, + requestId: 't8a', + providerConfig: { triggerId: 'instantly_link_clicked' }, + webhook: {}, + workflow: {}, + request: new NextRequest('http://localhost/test'), + }) + const matchedB = await instantlyHandler.matchEvent!({ + body: { event_type: 'email_link_clicked' }, + requestId: 't8b', + providerConfig: { triggerId: 'instantly_link_clicked' }, + webhook: {}, + workflow: {}, + request: new NextRequest('http://localhost/test'), + }) + expect(matchedA).toBe(true) + expect(matchedB).toBe(true) + }) + + it('formats input with keys matching the trigger outputs', async () => { + const body = { + timestamp: '2026-07-08T12:00:00.000Z', + event_type: 'reply_received', + workspace: 'ws-1', + campaign_id: 'camp-1', + campaign_name: 'Q3 Outreach', + lead_email: 'lead@example.com', + email_account: 'sender@example.com', + unibox_url: 'https://app.instantly.ai/unibox/1', + step: 2, + variant: 1, + is_first: true, + email_id: 'email-1', + reply_text_snippet: 'Sounds good', + reply_subject: 'Re: Q3 Outreach', + reply_text: 'Sounds good, thanks!', + reply_html: '

Sounds good, thanks!

', + } + + const result = await instantlyHandler.formatInput!({ + body, + webhook: {}, + workflow: { id: 'wf-1', userId: 'user-1' }, + headers: {}, + requestId: 't9', + }) + + expect(result.input).toEqual({ + timestamp: '2026-07-08T12:00:00.000Z', + eventType: 'reply_received', + workspace: 'ws-1', + campaignId: 'camp-1', + campaignName: 'Q3 Outreach', + leadEmail: 'lead@example.com', + emailAccount: 'sender@example.com', + uniboxUrl: 'https://app.instantly.ai/unibox/1', + step: 2, + variant: 1, + isFirst: true, + emailId: 'email-1', + emailSubject: null, + emailText: null, + emailHtml: null, + replyTextSnippet: 'Sounds good', + replySubject: 'Re: Q3 Outreach', + replyText: 'Sounds good, thanks!', + replyHtml: '

Sounds good, thanks!

', + payload: body, + }) + }) + + describe('extractIdempotencyId', () => { + it('prefers the email_id when present, qualified by timestamp', () => { + const id = instantlyHandler.extractIdempotencyId!({ + event_type: 'email_sent', + email_id: 'email-123', + campaign_id: 'camp-1', + lead_email: 'lead@example.com', + timestamp: '2026-07-08T12:00:00.000Z', + }) + expect(id).toBe('instantly:email_sent:email-123:2026-07-08T12:00:00.000Z') + }) + + it('returns null when email_id is present but timestamp is missing, rather than risk a false collision', () => { + const id = instantlyHandler.extractIdempotencyId!({ + event_type: 'email_sent', + email_id: 'email-123', + }) + expect(id).toBeNull() + }) + + it('falls back to a content-based key without an email_id', () => { + const id = instantlyHandler.extractIdempotencyId!({ + event_type: 'lead_interested', + campaign_id: 'camp-1', + lead_email: 'lead@example.com', + timestamp: '2026-07-08T12:00:00.000Z', + }) + expect(id).toBe('instantly:lead_interested:camp-1:lead@example.com:2026-07-08T12:00:00.000Z') + }) + + it('is stable across retries of the same delivery', () => { + const body = { + event_type: 'lead_interested', + campaign_id: 'camp-1', + lead_email: 'lead@example.com', + timestamp: '2026-07-08T12:00:00.000Z', + } + const first = instantlyHandler.extractIdempotencyId!(body) + const second = instantlyHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + }) + + it('is stable across retries of the same email_id-keyed delivery', () => { + const body = { + event_type: 'email_opened', + email_id: 'email-123', + timestamp: '2026-07-08T12:00:00.000Z', + } + const first = instantlyHandler.extractIdempotencyId!(body) + const second = instantlyHandler.extractIdempotencyId!({ ...body }) + expect(first).toBe(second) + }) + + it('does not collide across distinct occurrences of the same email_id (e.g. repeat opens/clicks/replies)', () => { + const firstOpen = instantlyHandler.extractIdempotencyId!({ + event_type: 'email_opened', + email_id: 'email-123', + timestamp: '2026-07-08T12:00:00.000Z', + }) + const secondOpen = instantlyHandler.extractIdempotencyId!({ + event_type: 'email_opened', + email_id: 'email-123', + timestamp: '2026-07-08T13:30:00.000Z', + }) + expect(firstOpen).not.toBe(secondOpen) + }) + + it('returns null when there is not enough data to build a stable key', () => { + expect(instantlyHandler.extractIdempotencyId!({ event_type: 'account_error' })).toBeNull() + expect(instantlyHandler.extractIdempotencyId!({})).toBeNull() + expect(instantlyHandler.extractIdempotencyId!('not-an-object')).toBeNull() + }) + }) + + describe('createSubscription', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test') + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + }) + + it('creates an Instantly webhook with the mapped event type', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: 'wh-123' }), + }) + + const result = await instantlyHandler.createSubscription!({ + webhook: { + id: 'webhook-1', + path: 'abc-path', + providerConfig: { triggerApiKey: 'instantly-key', triggerId: 'instantly_email_sent' }, + }, + workflow: {}, + userId: 'user-1', + requestId: 'req-create-1', + request: new NextRequest('http://localhost/test'), + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.instantly.ai/api/v2/webhooks') + expect(init.method).toBe('POST') + expect(init.headers.Authorization).toBe('Bearer instantly-key') + const sent = JSON.parse(init.body) + expect(sent.event_type).toBe('email_sent') + expect(sent.target_hook_url).toContain('/api/webhooks/trigger/abc-path') + expect(typeof sent.headers['X-Sim-Webhook-Token']).toBe('string') + + expect(result?.providerConfigUpdates?.externalId).toBe('wh-123') + }) + + it('maps the link-clicked trigger to the email_link_clicked subscription event', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ id: 'wh-456' }) }) + + await instantlyHandler.createSubscription!({ + webhook: { + id: 'webhook-2', + path: 'p2', + providerConfig: { triggerApiKey: 'instantly-key', triggerId: 'instantly_link_clicked' }, + }, + workflow: {}, + userId: 'user-1', + requestId: 'req-create-2', + request: new NextRequest('http://localhost/test'), + }) + + const sent = JSON.parse(fetchMock.mock.calls[0][1].body) + expect(sent.event_type).toBe('email_link_clicked') + }) + + it('throws a friendly error on 401', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ message: 'unauthorized' }), + }) + + await expect( + instantlyHandler.createSubscription!({ + webhook: { + id: 'webhook-3', + path: 'p3', + providerConfig: { triggerApiKey: 'bad-key', triggerId: 'instantly_email_sent' }, + }, + workflow: {}, + userId: 'user-1', + requestId: 'req-create-3', + request: new NextRequest('http://localhost/test'), + }) + ).rejects.toThrow(/Invalid Instantly API Key/) + }) + + it('throws when the API key is missing', async () => { + await expect( + instantlyHandler.createSubscription!({ + webhook: { + id: 'webhook-4', + path: 'p4', + providerConfig: { triggerId: 'instantly_email_sent' }, + }, + workflow: {}, + userId: 'user-1', + requestId: 'req-create-4', + request: new NextRequest('http://localhost/test'), + }) + ).rejects.toThrow(/Instantly API Key is required/) + expect(fetchMock).not.toHaveBeenCalled() + }) + }) + + describe('deleteSubscription', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('deletes the webhook by externalId', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, body: null }) + + await instantlyHandler.deleteSubscription!({ + webhook: { + id: 'webhook-1', + providerConfig: { triggerApiKey: 'instantly-key', externalId: 'wh-123' }, + }, + workflow: {}, + requestId: 'req-del-1', + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.instantly.ai/api/v2/webhooks/wh-123') + expect(init.method).toBe('DELETE') + }) + + it('skips when apiKey or externalId is missing and does not throw', async () => { + await instantlyHandler.deleteSubscription!({ + webhook: { id: 'webhook-2', providerConfig: { externalId: 'wh-123' } }, + workflow: {}, + requestId: 'req-del-2', + }) + await instantlyHandler.deleteSubscription!({ + webhook: { id: 'webhook-3', providerConfig: { triggerApiKey: 'instantly-key' } }, + workflow: {}, + requestId: 'req-del-3', + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('does not throw on a non-ok response in non-strict mode', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) }) + + await expect( + instantlyHandler.deleteSubscription!({ + webhook: { + id: 'webhook-4', + providerConfig: { triggerApiKey: 'instantly-key', externalId: 'wh-9' }, + }, + workflow: {}, + requestId: 'req-del-4', + }) + ).resolves.toBeUndefined() + }) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/instantly.ts b/apps/sim/lib/webhooks/providers/instantly.ts index fcfc81d48b0..30acd025ce1 100644 --- a/apps/sim/lib/webhooks/providers/instantly.ts +++ b/apps/sim/lib/webhooks/providers/instantly.ts @@ -57,6 +57,38 @@ export const instantlyHandler: WebhookProviderHandler = { return true }, + /** + * `email_id` (Instantly's `reply_to_uuid`) identifies the sent email, not + * one occurrence of an event on that email — the same event_type can fire + * more than once for one email_id (every open, every click, every reply in + * a thread), so email_id alone would collapse those distinct, legitimate + * deliveries into one idempotency slot. `timestamp` is fixed per event + * occurrence (not regenerated on retry), so appending it keeps the key + * both retry-stable and unique per occurrence — without it there's no way + * to distinguish occurrences, so dedup is skipped rather than risking a + * false collision. + */ + extractIdempotencyId(body: unknown): string | null { + if (!isRecordLike(body)) return null + + const eventType = typeof body.event_type === 'string' ? body.event_type : undefined + if (!eventType) return null + + const timestamp = typeof body.timestamp === 'string' ? body.timestamp : undefined + const emailId = typeof body.email_id === 'string' ? body.email_id : undefined + if (emailId) { + return timestamp ? `instantly:${eventType}:${emailId}:${timestamp}` : null + } + + const campaignId = typeof body.campaign_id === 'string' ? body.campaign_id : undefined + const leadEmail = typeof body.lead_email === 'string' ? body.lead_email : undefined + if (campaignId && leadEmail && timestamp) { + return `instantly:${eventType}:${campaignId}:${leadEmail}:${timestamp}` + } + + return null + }, + async formatInput({ body }: FormatInputContext): Promise { const payload = isRecordLike(body) ? body : {} diff --git a/apps/sim/lib/webhooks/providers/linear.test.ts b/apps/sim/lib/webhooks/providers/linear.test.ts index 0f94977f3e7..9962ba1f790 100644 --- a/apps/sim/lib/webhooks/providers/linear.test.ts +++ b/apps/sim/lib/webhooks/providers/linear.test.ts @@ -75,4 +75,102 @@ describe('Linear webhook provider', () => { expect(res).toBeNull() }) + + it('accepts signed requests within the 5-minute skew window (wider than Linear’s literal 60s suggestion, to tolerate retries)', async () => { + const secret = 'linear-secret' + const rawBody = JSON.stringify({ + action: 'update', + type: 'Issue', + webhookTimestamp: Date.now() - 61_000, + }) + + const res = await linearHandler.verifyAuth!({ + request: requestWithLinearSignature(secret, rawBody), + rawBody, + requestId: 'linear-t4', + providerConfig: { webhookSecret: secret }, + webhook: {}, + workflow: {}, + }) + + expect(res).toBeNull() + }) + + it('skips verification entirely when no webhookSecret is configured', async () => { + const rawBody = JSON.stringify({ action: 'create', type: 'Issue' }) + + const res = await linearHandler.verifyAuth!({ + request: new NextRequest('http://localhost/test'), + rawBody, + requestId: 'linear-t5', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + + expect(res).toBeNull() + }) + + describe('matchEvent', () => { + it('returns null-body-safe result instead of throwing when body is null', async () => { + const result = await linearHandler.matchEvent!({ + body: null, + requestId: 'linear-t6', + providerConfig: { triggerId: 'linear_issue_created' }, + webhook: {}, + workflow: {}, + request: new NextRequest('http://localhost/test'), + }) + expect(result).toBe(false) + }) + }) + + describe('formatInput', () => { + it('does not throw when body is null', async () => { + const { input } = await linearHandler.formatInput!({ body: null } as any) + expect(input).toMatchObject({ action: '', type: '', actor: null }) + }) + }) + + describe('extractIdempotencyId', () => { + it('builds a stable key from type, action, entity id, and updatedAt', () => { + const key = linearHandler.extractIdempotencyId!({ + type: 'Issue', + action: 'update', + data: { id: 'issue-1', updatedAt: '2026-07-08T00:00:00.000Z' }, + }) + + expect(key).toBe('linear:Issue:update:issue-1:2026-07-08T00:00:00.000Z') + }) + + it('falls back to createdAt when updatedAt is absent (create events)', () => { + const key = linearHandler.extractIdempotencyId!({ + type: 'Comment', + action: 'create', + data: { id: 'comment-1', createdAt: '2026-07-08T00:00:00.000Z' }, + }) + + expect(key).toBe('linear:Comment:create:comment-1:2026-07-08T00:00:00.000Z') + }) + + it('returns null when the entity id is missing', () => { + const key = linearHandler.extractIdempotencyId!({ type: 'Issue', action: 'create' }) + expect(key).toBeNull() + }) + + it('returns null when type is missing', () => { + const key = linearHandler.extractIdempotencyId!({ action: 'create', data: { id: 'x' } }) + expect(key).toBeNull() + }) + + it('returns null instead of throwing when the body is null', () => { + expect(linearHandler.extractIdempotencyId!(null)).toBeNull() + }) + + it('returns null instead of throwing when the body is a non-object', () => { + expect(linearHandler.extractIdempotencyId!('not an object')).toBeNull() + expect(linearHandler.extractIdempotencyId!(42)).toBeNull() + expect(linearHandler.extractIdempotencyId!(['array'])).toBeNull() + }) + }) }) diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index 4402659f71d..dd0068ff561 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -3,6 +3,7 @@ import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { NextResponse } from 'next/server' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { @@ -15,6 +16,7 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Linear') @@ -43,30 +45,37 @@ function validateLinearSignature(secret: string, signature: string, body: string } } +/** + * Linear's docs recommend a 60s window ("Reject any webhooks not within 60 seconds of the + * current time to prevent replay attacks") but do NOT document whether `webhookTimestamp` is + * re-stamped per delivery attempt or fixed to the original event time. Linear's own retry policy + * resends failed deliveries after 1 minute, 1 hour, and 6 hours (@see + * https://linear.app/developers/webhooks) — if the timestamp is fixed rather than refreshed per + * attempt, a strict 60s window would silently and permanently drop every legitimate 1hr/6hr retry + * following any transient outage on our side, since Linear gives up after 3 failed attempts. + * We keep a wider 5-minute window: idempotency dedup (Linear-Delivery header / extractIdempotencyId + * fallback below) already prevents double-processing of any replayed or retried delivery within + * that window, so the incremental replay-protection benefit of matching Linear's 60s suggestion + * literally is marginal compared to the risk of dropping real business events. + */ const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 5 * 60 * 1000 +const verifyLinearSignature = createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'Linear-Signature', + validateFn: validateLinearSignature, + providerLabel: 'Linear', +}) + export const linearHandler: WebhookProviderHandler = { - async verifyAuth({ - request, - rawBody, - requestId, - providerConfig, - }: AuthContext): Promise { - const secret = providerConfig.webhookSecret as string | undefined - if (!secret) { + async verifyAuth(ctx: AuthContext): Promise { + const { rawBody, requestId, providerConfig } = ctx + if (!providerConfig.webhookSecret) { return null } - const signature = request.headers.get('Linear-Signature') - if (!signature) { - logger.warn(`[${requestId}] Linear webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Linear signature', { status: 401 }) - } - - if (!validateLinearSignature(secret, signature, rawBody)) { - logger.warn(`[${requestId}] Linear signature verification failed`) - return new NextResponse('Unauthorized - Invalid Linear signature', { status: 401 }) - } + const signatureError = await verifyLinearSignature(ctx) + if (signatureError) return signatureError try { const parsed = JSON.parse(rawBody) as Record @@ -98,7 +107,7 @@ export const linearHandler: WebhookProviderHandler = { }, async formatInput({ body }: FormatInputContext): Promise { - const b = body as Record + const b = isRecordLike(body) ? body : {} const rawActor = b.actor let actor: unknown = null if (rawActor && typeof rawActor === 'object' && !Array.isArray(rawActor)) { @@ -130,9 +139,9 @@ export const linearHandler: WebhookProviderHandler = { const triggerId = providerConfig.triggerId as string | undefined if (triggerId && !triggerId.endsWith('_webhook') && !triggerId.endsWith('_webhook_v2')) { const { isLinearEventMatch } = await import('@/triggers/linear/utils') - const obj = body as Record - const action = obj.action as string | undefined - const type = obj.type as string | undefined + const obj = isRecordLike(body) ? body : {} + const action = typeof obj.action === 'string' ? obj.action : undefined + const type = typeof obj.type === 'string' ? obj.type : undefined if (!isLinearEventMatch(triggerId, type || '', action)) { logger.debug( `[${requestId}] Linear event mismatch for trigger ${triggerId}. Type: ${type}, Action: ${action}. Skipping.` @@ -143,6 +152,26 @@ export const linearHandler: WebhookProviderHandler = { return true }, + /** + * Fallback for dedup when the `Linear-Delivery` header (already handled generically by the + * idempotency service) is unavailable. Keys on the entity id plus its own updatedAt/createdAt, + * not a request-time timestamp, so retried deliveries of the same event still collapse. + */ + extractIdempotencyId(body: unknown): string | null { + if (!body || typeof body !== 'object' || Array.isArray(body)) return null + + const b = body as Record + const type = typeof b.type === 'string' ? b.type : undefined + const action = typeof b.action === 'string' ? b.action : undefined + const data = b.data as Record | undefined + const id = typeof data?.id === 'string' ? data.id : undefined + if (!type || !id) { + return null + } + const version = data?.updatedAt || data?.createdAt || b.createdAt + return [`linear:${type}`, action, id, version].filter(Boolean).join(':') + }, + async createSubscription(ctx: SubscriptionContext): Promise { const config = getProviderConfig(ctx.webhook) const triggerId = config.triggerId as string | undefined diff --git a/apps/sim/triggers/gitlab/utils.ts b/apps/sim/triggers/gitlab/utils.ts index a25d09e3447..be47e1bcf47 100644 --- a/apps/sim/triggers/gitlab/utils.ts +++ b/apps/sim/triggers/gitlab/utils.ts @@ -38,9 +38,12 @@ const ALL_EVENT_FLAGS = { tag_push_events: true, } as const -// Tag pushes (object_kind 'tag_push') only flow through the all-events trigger; -// there is no dedicated single-event trigger for them. A future "GitLab Tag Push" -// trigger would need its own object_kind mapping in TRIGGER_OBJECT_KINDS above. +/** + * Tag pushes (object_kind 'tag_push') only flow through the all-events + * trigger; there is no dedicated single-event trigger for them. A future + * "GitLab Tag Push" trigger would need its own object_kind mapping in + * TRIGGER_OBJECT_KINDS above. + */ const TRIGGER_EVENT_FLAGS: Record> = { gitlab_push: { push_events: true }, gitlab_merge_request: { merge_requests_events: true }, @@ -160,7 +163,8 @@ export function buildGitLabMergeRequestOutputs(): Record action: { type: 'string', description: 'Action (open, close, reopen, update, merge, etc.)' }, source_branch: { type: 'string', description: 'Source branch' }, target_branch: { type: 'string', description: 'Target branch' }, - merge_status: { type: 'string', description: 'Merge status' }, + merge_status: { type: 'string', description: 'Merge status (deprecated by GitLab)' }, + detailed_merge_status: { type: 'string', description: 'Detailed merge status' }, draft: { type: 'boolean', description: 'Whether the merge request is a draft' }, url: { type: 'string', description: 'Merge request URL' }, }, @@ -182,6 +186,10 @@ export function buildGitLabIssueOutputs(): Record { description: { type: 'string', description: 'Issue description' }, confidential: { type: 'boolean', description: 'Whether the issue is confidential' }, url: { type: 'string', description: 'Issue URL' }, + work_item_type: { + type: 'string', + description: 'Work item type (e.g. Issue, Incident, Task); GitLab 17.2+ only', + }, }, } } diff --git a/apps/sim/triggers/gmail/poller.ts b/apps/sim/triggers/gmail/poller.ts index 6689f3fc9cb..1d03c838f3b 100644 --- a/apps/sim/triggers/gmail/poller.ts +++ b/apps/sim/triggers/gmail/poller.ts @@ -184,7 +184,7 @@ Return ONLY the Gmail search query, no explanations or markdown.`, description: 'HTML email body', }, labels: { - type: 'string', + type: 'array', description: 'Email labels array', }, hasAttachments: { diff --git a/apps/sim/triggers/linear/utils.ts b/apps/sim/triggers/linear/utils.ts index 371c1049681..5970288923e 100644 --- a/apps/sim/triggers/linear/utils.ts +++ b/apps/sim/triggers/linear/utils.ts @@ -217,62 +217,6 @@ export const userOutputs = { }, } as const -/** - * Shared team output schema - */ -export const teamOutputs = { - id: { - type: 'string', - description: 'Team ID', - }, - name: { - type: 'string', - description: 'Team name', - }, - key: { - type: 'string', - description: 'Team key (used in issue identifiers)', - }, - description: { - type: 'string', - description: 'Team description', - }, - private: { - type: 'boolean', - description: 'Whether the team is private', - }, - timezone: { - type: 'string', - description: 'Team timezone', - }, -} as const - -/** - * Shared state output schema - */ -export const stateOutputs = { - id: { - type: 'string', - description: 'State ID', - }, - name: { - type: 'string', - description: 'State name', - }, - type: { - type: 'string', - description: 'State type (backlog, unstarted, started, completed, canceled)', - }, - color: { - type: 'string', - description: 'State color', - }, - position: { - type: 'number', - description: 'State position in the workflow', - }, -} as const - /** * Build output schema for issue events */ @@ -1089,12 +1033,10 @@ export function isLinearEventMatch(triggerId: string, eventType: string, action? return false } - // Check event type if (config.type !== eventType) { return false } - // Check action if specified if (config.actions && action && !config.actions.includes(action)) { return false }