d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
232 lines
8.1 KiB
TypeScript
232 lines
8.1 KiB
TypeScript
/**
|
|
* @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()
|
|
})
|
|
})
|
|
})
|