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
224 lines
6.5 KiB
TypeScript
224 lines
6.5 KiB
TypeScript
import crypto from 'node:crypto'
|
|
import { NextRequest } from 'next/server'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { sentryHandler } from '@/lib/webhooks/providers/sentry'
|
|
|
|
function signSentryBody(secret: string, rawBody: string): string {
|
|
return crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')
|
|
}
|
|
|
|
function requestWithSentrySignature(
|
|
secret: string,
|
|
rawBody: string,
|
|
resource = 'issue'
|
|
): NextRequest {
|
|
const signature = signSentryBody(secret, rawBody)
|
|
return new NextRequest('http://localhost/test', {
|
|
headers: {
|
|
'Sentry-Hook-Signature': signature,
|
|
'Sentry-Hook-Resource': resource,
|
|
},
|
|
})
|
|
}
|
|
|
|
describe('Sentry webhook provider', () => {
|
|
it('accepts requests with a valid HMAC signature', async () => {
|
|
const secret = 'sentry-client-secret'
|
|
const rawBody = JSON.stringify({ action: 'created', data: { issue: { id: '1' } } })
|
|
|
|
const res = await sentryHandler.verifyAuth!({
|
|
request: requestWithSentrySignature(secret, rawBody),
|
|
rawBody,
|
|
requestId: 'sentry-t1',
|
|
providerConfig: { clientSecret: secret },
|
|
webhook: {},
|
|
workflow: {},
|
|
})
|
|
|
|
expect(res).toBeNull()
|
|
})
|
|
|
|
it('rejects requests with an invalid signature', async () => {
|
|
const secret = 'sentry-client-secret'
|
|
const rawBody = JSON.stringify({ action: 'created', data: { issue: { id: '1' } } })
|
|
|
|
const request = new NextRequest('http://localhost/test', {
|
|
headers: {
|
|
'Sentry-Hook-Signature': 'deadbeef',
|
|
'Sentry-Hook-Resource': 'issue',
|
|
},
|
|
})
|
|
|
|
const res = await sentryHandler.verifyAuth!({
|
|
request,
|
|
rawBody,
|
|
requestId: 'sentry-t2',
|
|
providerConfig: { clientSecret: secret },
|
|
webhook: {},
|
|
workflow: {},
|
|
})
|
|
|
|
expect(res?.status).toBe(401)
|
|
})
|
|
|
|
it('rejects requests missing the signature header', async () => {
|
|
const secret = 'sentry-client-secret'
|
|
const rawBody = JSON.stringify({ action: 'created', data: { issue: { id: '1' } } })
|
|
|
|
const request = new NextRequest('http://localhost/test', {
|
|
headers: { 'Sentry-Hook-Resource': 'issue' },
|
|
})
|
|
|
|
const res = await sentryHandler.verifyAuth!({
|
|
request,
|
|
rawBody,
|
|
requestId: 'sentry-t3',
|
|
providerConfig: { clientSecret: secret },
|
|
webhook: {},
|
|
workflow: {},
|
|
})
|
|
|
|
expect(res?.status).toBe(401)
|
|
})
|
|
|
|
it('rejects requests when the client secret is not configured (fail-closed)', async () => {
|
|
const secret = 'sentry-client-secret'
|
|
const rawBody = JSON.stringify({ action: 'created', data: { issue: { id: '1' } } })
|
|
|
|
const res = await sentryHandler.verifyAuth!({
|
|
request: requestWithSentrySignature(secret, rawBody),
|
|
rawBody,
|
|
requestId: 'sentry-t3b',
|
|
providerConfig: {},
|
|
webhook: {},
|
|
workflow: {},
|
|
})
|
|
|
|
expect(res?.status).toBe(401)
|
|
})
|
|
|
|
it('matches an issue created event by resource header and action', async () => {
|
|
const rawBody = JSON.stringify({ action: 'created', data: { issue: { id: '1' } } })
|
|
const request = new NextRequest('http://localhost/test', {
|
|
headers: { 'Sentry-Hook-Resource': 'issue' },
|
|
})
|
|
|
|
const matched = await sentryHandler.matchEvent!({
|
|
body: JSON.parse(rawBody),
|
|
request,
|
|
requestId: 'sentry-t4',
|
|
providerConfig: { triggerId: 'sentry_issue_created' },
|
|
webhook: {},
|
|
workflow: {},
|
|
})
|
|
|
|
expect(matched).toBe(true)
|
|
})
|
|
|
|
it('skips an issue created trigger when the action is resolved', async () => {
|
|
const rawBody = JSON.stringify({ action: 'resolved', data: { issue: { id: '1' } } })
|
|
const request = new NextRequest('http://localhost/test', {
|
|
headers: { 'Sentry-Hook-Resource': 'issue' },
|
|
})
|
|
|
|
const matched = await sentryHandler.matchEvent!({
|
|
body: JSON.parse(rawBody),
|
|
request,
|
|
requestId: 'sentry-t5',
|
|
providerConfig: { triggerId: 'sentry_issue_created' },
|
|
webhook: {},
|
|
workflow: {},
|
|
})
|
|
|
|
expect(matched).toBe(false)
|
|
})
|
|
|
|
it('skips when the resource header does not match the trigger', async () => {
|
|
const rawBody = JSON.stringify({ action: 'created', data: { error: { event_id: 'abc' } } })
|
|
const request = new NextRequest('http://localhost/test', {
|
|
headers: { 'Sentry-Hook-Resource': 'error' },
|
|
})
|
|
|
|
const matched = await sentryHandler.matchEvent!({
|
|
body: JSON.parse(rawBody),
|
|
request,
|
|
requestId: 'sentry-t6',
|
|
providerConfig: { triggerId: 'sentry_issue_created' },
|
|
webhook: {},
|
|
workflow: {},
|
|
})
|
|
|
|
expect(matched).toBe(false)
|
|
})
|
|
|
|
it('formats issue input with keys matching the trigger outputs', async () => {
|
|
const body = {
|
|
action: 'created',
|
|
installation: { uuid: 'inst-1' },
|
|
actor: { type: 'application', id: 'app-1', name: 'Test' },
|
|
data: { issue: { id: '42', title: 'Boom', type: 'error' } },
|
|
}
|
|
|
|
const result = await sentryHandler.formatInput!({
|
|
body,
|
|
headers: { 'sentry-hook-resource': 'issue' },
|
|
webhook: {},
|
|
workflow: { id: 'wf-1', userId: 'user-1' },
|
|
requestId: 'sentry-t7',
|
|
})
|
|
|
|
expect(result.input).toEqual({
|
|
action: 'created',
|
|
installation: { uuid: 'inst-1' },
|
|
actor: { type: 'application', id: 'app-1', name: 'Test' },
|
|
issue: { id: '42', title: 'Boom', type: 'error', eventType: 'error' },
|
|
})
|
|
})
|
|
|
|
it('extracts an idempotency id for issue events', () => {
|
|
const id = sentryHandler.extractIdempotencyId!({
|
|
action: 'created',
|
|
data: { issue: { id: '42' } },
|
|
})
|
|
expect(id).toBe('sentry:issue:42:created')
|
|
})
|
|
|
|
it('does not match when the body is null', async () => {
|
|
const request = new NextRequest('http://localhost/test', {
|
|
headers: { 'Sentry-Hook-Resource': 'issue' },
|
|
})
|
|
|
|
const matched = await sentryHandler.matchEvent!({
|
|
body: null,
|
|
request,
|
|
requestId: 'sentry-t8',
|
|
providerConfig: { triggerId: 'sentry_issue_created' },
|
|
webhook: {},
|
|
workflow: {},
|
|
})
|
|
|
|
expect(matched).toBe(false)
|
|
})
|
|
|
|
it('formats input with an empty envelope when the body is null', async () => {
|
|
const result = await sentryHandler.formatInput!({
|
|
body: null,
|
|
headers: { 'sentry-hook-resource': 'issue' },
|
|
webhook: {},
|
|
workflow: { id: 'wf-1', userId: 'user-1' },
|
|
requestId: 'sentry-t9',
|
|
})
|
|
|
|
expect(result.input).toEqual({
|
|
action: '',
|
|
installation: null,
|
|
actor: null,
|
|
issue: null,
|
|
})
|
|
})
|
|
|
|
it('returns null idempotency id when the body is null', () => {
|
|
expect(sentryHandler.extractIdempotencyId!(null)).toBeNull()
|
|
})
|
|
})
|