Files
simstudioai--sim/apps/sim/lib/webhooks/providers/ashby.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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()
})
})
})