Files
simstudioai--sim/apps/sim/lib/webhooks/providers/sentry.test.ts
T
wehub-resource-sync d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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