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
175 lines
5.1 KiB
TypeScript
175 lines
5.1 KiB
TypeScript
import crypto from 'node:crypto'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { clerkHandler } from '@/lib/webhooks/providers/clerk'
|
|
|
|
const SECRET_BASE64 = Buffer.from('clerk-test-signing-secret').toString('base64')
|
|
const SIGNING_SECRET = `whsec_${SECRET_BASE64}`
|
|
|
|
function signSvix(msgId: string, timestamp: string, body: string): string {
|
|
const toSign = `${msgId}.${timestamp}.${body}`
|
|
const sig = crypto
|
|
.createHmac('sha256', Buffer.from(SECRET_BASE64, 'base64'))
|
|
.update(toSign, 'utf8')
|
|
.digest('base64')
|
|
return `v1,${sig}`
|
|
}
|
|
|
|
function makeRequest(headers: Record<string, string>) {
|
|
return { headers: new Headers(headers) } as unknown as Parameters<
|
|
NonNullable<typeof clerkHandler.verifyAuth>
|
|
>[0]['request']
|
|
}
|
|
|
|
describe('Clerk webhook provider', () => {
|
|
it('verifyAuth accepts a valid Svix signature', async () => {
|
|
const msgId = 'msg_123'
|
|
const timestamp = String(Math.floor(Date.now() / 1000))
|
|
const rawBody = JSON.stringify({ type: 'user.created', data: { id: 'user_1' } })
|
|
const signature = signSvix(msgId, timestamp, rawBody)
|
|
|
|
const result = await clerkHandler.verifyAuth!({
|
|
request: makeRequest({
|
|
'svix-id': msgId,
|
|
'svix-timestamp': timestamp,
|
|
'svix-signature': signature,
|
|
}),
|
|
rawBody,
|
|
requestId: 'test',
|
|
providerConfig: { signingSecret: SIGNING_SECRET },
|
|
webhook: {},
|
|
workflow: {},
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('verifyAuth rejects an invalid Svix signature', async () => {
|
|
const timestamp = String(Math.floor(Date.now() / 1000))
|
|
const rawBody = JSON.stringify({ type: 'user.created', data: { id: 'user_1' } })
|
|
|
|
const result = await clerkHandler.verifyAuth!({
|
|
request: makeRequest({
|
|
'svix-id': 'msg_123',
|
|
'svix-timestamp': timestamp,
|
|
'svix-signature': 'v1,not-a-valid-signature',
|
|
}),
|
|
rawBody,
|
|
requestId: 'test',
|
|
providerConfig: { signingSecret: SIGNING_SECRET },
|
|
webhook: {},
|
|
workflow: {},
|
|
})
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result?.status).toBe(401)
|
|
})
|
|
|
|
it('verifyAuth rejects when the signing secret is missing', async () => {
|
|
const result = await clerkHandler.verifyAuth!({
|
|
request: makeRequest({
|
|
'svix-id': 'msg_123',
|
|
'svix-timestamp': String(Math.floor(Date.now() / 1000)),
|
|
'svix-signature': 'v1,whatever',
|
|
}),
|
|
rawBody: '{}',
|
|
requestId: 'test',
|
|
providerConfig: {},
|
|
webhook: {},
|
|
workflow: {},
|
|
})
|
|
|
|
expect(result?.status).toBe(401)
|
|
})
|
|
|
|
it('formatInput maps user.created fields to documented outputs', async () => {
|
|
const { input } = await clerkHandler.formatInput!({
|
|
webhook: {},
|
|
workflow: { id: 'wf', userId: 'u' },
|
|
body: {
|
|
type: 'user.created',
|
|
object: 'event',
|
|
timestamp: 1654012591835,
|
|
instance_id: 'ins_abc',
|
|
data: {
|
|
id: 'user_29w83sxmDNGwOuEthce5gg56FcC',
|
|
first_name: 'Ada',
|
|
last_name: 'Lovelace',
|
|
username: 'ada',
|
|
image_url: 'https://img.clerk.com/ada.png',
|
|
primary_email_address_id: 'idn_email_1',
|
|
email_addresses: [{ id: 'idn_email_1', email_address: 'ada@example.com' }],
|
|
external_id: 'ext_1',
|
|
created_at: 1654012591514,
|
|
updated_at: 1654012591835,
|
|
},
|
|
},
|
|
headers: {},
|
|
requestId: 'test',
|
|
})
|
|
|
|
expect(input).toMatchObject({
|
|
type: 'user.created',
|
|
object: 'event',
|
|
timestamp: 1654012591835,
|
|
instance_id: 'ins_abc',
|
|
userId: 'user_29w83sxmDNGwOuEthce5gg56FcC',
|
|
firstName: 'Ada',
|
|
lastName: 'Lovelace',
|
|
username: 'ada',
|
|
primaryEmailAddressId: 'idn_email_1',
|
|
externalId: 'ext_1',
|
|
createdAt: 1654012591514,
|
|
})
|
|
})
|
|
|
|
it('formatInput resolves userId from session and membership payloads', async () => {
|
|
const session = await clerkHandler.formatInput!({
|
|
webhook: {},
|
|
workflow: { id: 'wf', userId: 'u' },
|
|
body: {
|
|
type: 'session.created',
|
|
data: { id: 'sess_1', user_id: 'user_1', client_id: 'client_1', status: 'active' },
|
|
},
|
|
headers: {},
|
|
requestId: 'test',
|
|
})
|
|
expect(session.input).toMatchObject({
|
|
sessionId: 'sess_1',
|
|
userId: 'user_1',
|
|
clientId: 'client_1',
|
|
status: 'active',
|
|
})
|
|
|
|
const membership = await clerkHandler.formatInput!({
|
|
webhook: {},
|
|
workflow: { id: 'wf', userId: 'u' },
|
|
body: {
|
|
type: 'organizationMembership.created',
|
|
data: {
|
|
id: 'orgmem_1',
|
|
role: 'org:admin',
|
|
organization: { id: 'org_1', name: 'Acme' },
|
|
public_user_data: { user_id: 'user_2' },
|
|
},
|
|
},
|
|
headers: {},
|
|
requestId: 'test',
|
|
})
|
|
expect(membership.input).toMatchObject({
|
|
membershipId: 'orgmem_1',
|
|
role: 'org:admin',
|
|
organizationId: 'org_1',
|
|
userId: 'user_2',
|
|
})
|
|
})
|
|
|
|
it('extractIdempotencyId derives a stable, timestamp-free key from type and data id', () => {
|
|
const id = clerkHandler.extractIdempotencyId!({
|
|
type: 'user.updated',
|
|
timestamp: 1654012591835,
|
|
data: { id: 'user_1' },
|
|
})
|
|
expect(id).toBe('user.updated:user_1')
|
|
})
|
|
})
|