Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions apps/sim/lib/webhooks/providers/ashby.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/**
* @vitest-environment node
*/
import crypto from 'crypto'
import { createMockRequest } from '@sim/testing'
import { describe, expect, it } from 'vitest'
import { ashbyHandler } from '@/lib/webhooks/providers/ashby'

describe('ashbyHandler', () => {
describe('verifyAuth', () => {
const secret = 'test-secret-token'
const rawBody = JSON.stringify({ action: 'ping', data: { webhookActionType: 'ping' } })
const signature = `sha256=${crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')}`

it('returns 401 when secretToken is missing', () => {
const request = createMockRequest('POST', JSON.parse(rawBody), {
'ashby-signature': signature,
})
const res = ashbyHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})

it('returns 401 when signature header is missing', () => {
const request = createMockRequest('POST', JSON.parse(rawBody), {})
const res = ashbyHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { secretToken: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})

it('returns 401 when signature is invalid', () => {
const request = createMockRequest('POST', JSON.parse(rawBody), {
'ashby-signature': 'sha256=deadbeef',
})
const res = ashbyHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { secretToken: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})

it('returns null when signature is valid', () => {
const request = createMockRequest('POST', JSON.parse(rawBody), {
'ashby-signature': signature,
})
const res = ashbyHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { secretToken: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
})

describe('matchEvent', () => {
it('rejects ping events', async () => {
const matched = await ashbyHandler.matchEvent!({
webhook: { id: 'w1' } as any,
body: { action: 'ping', data: { webhookActionType: 'ping' } },
requestId: 'r1',
providerConfig: { triggerId: 'ashby_application_submit' },
} as any)
expect(matched).toBe(false)
})

it('matches when action equals the configured trigger event', async () => {
const matched = await ashbyHandler.matchEvent!({
webhook: { id: 'w1' } as any,
body: { action: 'applicationSubmit', data: {} },
requestId: 'r1',
providerConfig: { triggerId: 'ashby_application_submit' },
} as any)
expect(matched).toBe(true)
})

it('rejects when action does not match the configured trigger event', async () => {
const matched = await ashbyHandler.matchEvent!({
webhook: { id: 'w1' } as any,
body: { action: 'jobCreate', data: {} },
requestId: 'r1',
providerConfig: { triggerId: 'ashby_application_submit' },
} as any)
expect(matched).toBe(false)
})
})

describe('formatInput', () => {
it('spreads data fields to the top level alongside action', async () => {
const result = await ashbyHandler.formatInput!({
body: {
action: 'applicationSubmit',
data: { application: { id: 'app-1', status: 'Active' } },
},
} as any)
expect(result.input).toEqual({
action: 'applicationSubmit',
application: { id: 'app-1', status: 'Active' },
})
})

it('renames currentInterviewStage.type to stageType, matching the trigger output schema', async () => {
const result = await ashbyHandler.formatInput!({
body: {
action: 'candidateStageChange',
data: {
application: {
id: 'app-1',
currentInterviewStage: { id: 'stage-1', title: 'Offer', type: 'Offer' },
},
},
},
} as any)
expect(result.input.application).toEqual({
id: 'app-1',
currentInterviewStage: { id: 'stage-1', title: 'Offer', stageType: 'Offer' },
})
})
})

describe('extractIdempotencyId', () => {
it('derives a stable key from application id + updatedAt', () => {
const body = {
action: 'candidateStageChange',
data: { application: { id: 'app-1', updatedAt: '2026-01-01T00:00:00Z' } },
}
expect(ashbyHandler.extractIdempotencyId!(body)).toBe(
'ashby:candidateStageChange:app-1:2026-01-01T00:00:00Z'
)
expect(ashbyHandler.extractIdempotencyId!({ ...body })).toBe(
ashbyHandler.extractIdempotencyId!(body)
)
})

it('derives a key from candidate id for candidateDelete', () => {
const body = { action: 'candidateDelete', data: { candidate: { id: 'cand-1' } } }
expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:candidateDelete:cand-1')
})

it('derives a key from job id for jobCreate', () => {
const body = { action: 'jobCreate', data: { job: { id: 'job-1' } } }
expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:jobCreate:job-1')
})

it('derives a stable key from offer id alone, ignoring mutable decidedAt', () => {
const created = { action: 'offerCreate', data: { offer: { id: 'offer-1', decidedAt: null } } }
expect(ashbyHandler.extractIdempotencyId!(created)).toBe('ashby:offerCreate:offer-1')

const retriedAfterDecision = {
action: 'offerCreate',
data: { offer: { id: 'offer-1', decidedAt: '2026-01-02T00:00:00Z' } },
}
expect(ashbyHandler.extractIdempotencyId!(retriedAfterDecision)).toBe(
ashbyHandler.extractIdempotencyId!(created)
)
})

it('falls back to a content fingerprint when updatedAt is missing, still deduping retries', () => {
const body = {
action: 'candidateStageChange',
data: { application: { id: 'app-1', status: 'Active' } },
}
const key = ashbyHandler.extractIdempotencyId!(body)
expect(key).not.toBeNull()
expect(ashbyHandler.extractIdempotencyId!({ ...body, data: { ...body.data } })).toBe(key)

const different = {
action: 'candidateStageChange',
data: { application: { id: 'app-1', status: 'Hired' } },
}
expect(ashbyHandler.extractIdempotencyId!(different)).not.toBe(key)
})

it('distinguishes candidateHire deliveries that share an application snapshot but differ in offer', () => {
const application = { id: 'app-1', status: 'Hired' }
const first = {
action: 'candidateHire',
data: { application, offer: { id: 'offer-1' } },
}
const second = {
action: 'candidateHire',
data: { application, offer: { id: 'offer-2' } },
}
expect(ashbyHandler.extractIdempotencyId!(first)).not.toBe(
ashbyHandler.extractIdempotencyId!(second)
)
})

it('distinguishes candidateHire deliveries sharing application id + updatedAt but differing in offer', () => {
const application = { id: 'app-1', status: 'Hired', updatedAt: '2026-01-01T00:00:00Z' }
const first = {
action: 'candidateHire',
data: { application, offer: { id: 'offer-1' } },
}
const second = {
action: 'candidateHire',
data: { application, offer: { id: 'offer-2' } },
}
expect(ashbyHandler.extractIdempotencyId!(first)).not.toBe(
ashbyHandler.extractIdempotencyId!(second)
)
// a genuine retry of `first` (identical offer too) still dedupes
expect(ashbyHandler.extractIdempotencyId!({ ...first })).toBe(
ashbyHandler.extractIdempotencyId!(first)
)
})

it('returns null when no recognizable resource is present', () => {
expect(ashbyHandler.extractIdempotencyId!({ action: 'ping', data: {} })).toBeNull()
expect(ashbyHandler.extractIdempotencyId!({})).toBeNull()
})
})
})
49 changes: 45 additions & 4 deletions apps/sim/lib/webhooks/providers/ashby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { generateId } from '@sim/utils/id'
import { omit } from '@sim/utils/object'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
Expand All @@ -14,6 +15,7 @@ import type {
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { buildFallbackDeliveryFingerprint } from '@/lib/webhooks/providers/utils'

const logger = createLogger('WebhookProvider:Ashby')

Expand All @@ -37,18 +39,54 @@ function validateAshbySignature(secretToken: string, signature: string, body: st
export const ashbyHandler: WebhookProviderHandler = {
extractIdempotencyId(body: unknown): string | null {
const obj = body as Record<string, unknown>
const webhookActionId = obj.webhookActionId
if (typeof webhookActionId === 'string' && webhookActionId) {
return `ashby:${webhookActionId}`
const action = typeof obj.action === 'string' ? obj.action : undefined
const data = obj.data as Record<string, unknown> | undefined
if (!action || !data) return null

const application = data.application as Record<string, unknown> | undefined
const candidate = data.candidate as Record<string, unknown> | undefined
const job = data.job as Record<string, unknown> | undefined
const offer = data.offer as Record<string, unknown> | undefined

if (application?.id) {
const discriminator = application.updatedAt ?? buildFallbackDeliveryFingerprint(data)
const offerSuffix = offer?.id ? `:${offer.id}` : ''
return `ashby:${action}:${application.id}:${discriminator}${offerSuffix}`
}
if (offer?.id) {
return `ashby:${action}:${offer.id}`
}
if (candidate?.id) {
return `ashby:${action}:${candidate.id}`
}
if (job?.id) {
return `ashby:${action}:${job.id}`
}
return null
},

async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
const data = (b.data as Record<string, unknown>) || {}
const application = data.application as Record<string, unknown> | undefined
const currentInterviewStage = application?.currentInterviewStage as
| Record<string, unknown>
| undefined

return {
input: {
...((b.data as Record<string, unknown>) || {}),
...data,
...(application && currentInterviewStage
? {
application: {
...application,
currentInterviewStage: {
...omit(currentInterviewStage, ['type']),
stageType: currentInterviewStage.type,
},
},
}
: {}),
action: b.action,
},
}
Expand Down Expand Up @@ -185,6 +223,9 @@ export const ashbyHandler: WebhookProviderHandler = {
} else if (ashbyResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.'
} else if (/duplicate webhook/i.test(errorMessage)) {
userFriendlyMessage =
'A webhook for this URL and event already exists in Ashby. This usually happens when a previous save succeeded in Ashby but Sim failed to record it. Delete the duplicate webhook under Ashby Settings > API/Webhooks, then re-save this trigger.'
} else if (errorMessage && errorMessage !== 'Unknown Ashby API error') {
userFriendlyMessage = `Ashby error: ${errorMessage}`
}
Expand Down
22 changes: 1 addition & 21 deletions apps/sim/lib/webhooks/providers/salesforce.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { NextResponse } from 'next/server'
import type {
AuthContext,
Expand All @@ -8,7 +7,7 @@ import type {
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
import { buildFallbackDeliveryFingerprint, verifyTokenAuth } from '@/lib/webhooks/providers/utils'

export function extractSalesforceObjectTypeFromPayload(
body: Record<string, unknown>
Expand Down Expand Up @@ -98,25 +97,6 @@ function pickTimestamp(body: Record<string, unknown>, record: Record<string, unk
return ''
}

function stableSerialize(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableSerialize(item)).join(',')}]`
}

if (value && typeof value === 'object') {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`)
.join(',')}}`
}

return JSON.stringify(value)
}

function buildFallbackDeliveryFingerprint(body: Record<string, unknown>): string {
return sha256Hex(stableSerialize(body))
}

function pickRecordId(body: Record<string, unknown>, record: Record<string, unknown>): string {
const id =
(typeof body.recordId === 'string' && body.recordId) ||
Expand Down
30 changes: 30 additions & 0 deletions apps/sim/lib/webhooks/providers/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,41 @@
import type { Logger } from '@sim/logger'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { sha256Hex } from '@sim/security/hash'
import { NextResponse } from 'next/server'
import type { AuthContext, EventFilterContext } from '@/lib/webhooks/providers/types'

const logger = createLogger('WebhookProviderAuth')

/**
* Deterministic JSON serialization with object keys sorted, so structurally
* identical payloads produce identical output regardless of key order.
*/
function stableSerialize(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableSerialize(item)).join(',')}]`
}

if (value && typeof value === 'object') {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`)
.join(',')}}`
}

return JSON.stringify(value)
}

/**
* Fallback idempotency fingerprint for payloads with no stable delivery id
* or content timestamp to key on. A provider retry resends identical bytes,
* so this hash is stable across retries of the same delivery while still
* differentiating distinct events.
*/
export function buildFallbackDeliveryFingerprint(body: unknown): string {
return sha256Hex(stableSerialize(body))
}

interface HmacVerifierOptions {
configKey: string
headerName: string
Expand Down
Loading
Loading