Files
simstudioai--sim/apps/sim/lib/webhooks/providers/incidentio.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

284 lines
9.0 KiB
TypeScript

import crypto from 'node:crypto'
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { incidentioHandler } from '@/lib/webhooks/providers/incidentio'
const SECRET_BYTES = Buffer.from('incidentio-test-secret-key-padding!!')
const SIGNING_SECRET = `whsec_${SECRET_BYTES.toString('base64')}`
function signIncidentioBody(msgId: string, timestamp: string, rawBody: string): string {
const toSign = `${msgId}.${timestamp}.${rawBody}`
const sig = crypto.createHmac('sha256', SECRET_BYTES).update(toSign, 'utf8').digest('base64')
return `v1,${sig}`
}
function requestWithSvixHeaders(
msgId: string,
timestamp: string,
rawBody: string,
signature?: string
): NextRequest {
const headers: Record<string, string> = {
'webhook-id': msgId,
'webhook-timestamp': timestamp,
}
if (signature !== undefined) {
headers['webhook-signature'] = signature
}
return new NextRequest('http://localhost/test', { headers })
}
const baseAuthCtx = {
webhook: {},
workflow: {},
rawBody: '',
}
describe('incident.io webhook provider', () => {
it('rejects requests when the signing secret is missing', async () => {
const res = await incidentioHandler.verifyAuth!({
...baseAuthCtx,
request: requestWithSvixHeaders('msg_1', `${Math.floor(Date.now() / 1000)}`, '{}'),
rawBody: '{}',
requestId: 'incidentio-t1',
providerConfig: {},
})
expect(res?.status).toBe(401)
})
it('rejects requests missing Svix signature headers', async () => {
const rawBody = JSON.stringify({ event_type: 'public_incident.incident_created_v2' })
const ts = `${Math.floor(Date.now() / 1000)}`
const res = await incidentioHandler.verifyAuth!({
...baseAuthCtx,
request: requestWithSvixHeaders('msg_1', ts, rawBody),
rawBody,
requestId: 'incidentio-t2',
providerConfig: { signingSecret: SIGNING_SECRET },
})
expect(res?.status).toBe(401)
})
it('rejects requests with an invalid signature', async () => {
const rawBody = JSON.stringify({ event_type: 'public_incident.incident_created_v2' })
const ts = `${Math.floor(Date.now() / 1000)}`
const res = await incidentioHandler.verifyAuth!({
...baseAuthCtx,
request: requestWithSvixHeaders('msg_1', ts, rawBody, 'v1,not-a-valid-signature'),
rawBody,
requestId: 'incidentio-t3',
providerConfig: { signingSecret: SIGNING_SECRET },
})
expect(res?.status).toBe(401)
})
it('rejects requests when the timestamp skew is too large', async () => {
const rawBody = JSON.stringify({ event_type: 'public_incident.incident_created_v2' })
const ts = `${Math.floor(Date.now() / 1000) - 600}`
const signature = signIncidentioBody('msg_1', ts, rawBody)
const res = await incidentioHandler.verifyAuth!({
...baseAuthCtx,
request: requestWithSvixHeaders('msg_1', ts, rawBody, signature),
rawBody,
requestId: 'incidentio-t4',
providerConfig: { signingSecret: SIGNING_SECRET },
})
expect(res?.status).toBe(401)
})
it('accepts a correctly signed request within the allowed window', async () => {
const rawBody = JSON.stringify({ event_type: 'public_incident.incident_created_v2' })
const ts = `${Math.floor(Date.now() / 1000)}`
const signature = signIncidentioBody('msg_1', ts, rawBody)
const res = await incidentioHandler.verifyAuth!({
...baseAuthCtx,
request: requestWithSvixHeaders('msg_1', ts, rawBody, signature),
rawBody,
requestId: 'incidentio-t5',
providerConfig: { signingSecret: SIGNING_SECRET },
})
expect(res).toBeNull()
})
it('matches events by event_type for a specific trigger', async () => {
const body = { event_type: 'public_incident.incident_created_v2' }
const matched = await incidentioHandler.matchEvent!({
body,
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
requestId: 'incidentio-t6',
providerConfig: { triggerId: 'incidentio_incident_created' },
})
expect(matched).toBe(true)
const mismatched = await incidentioHandler.matchEvent!({
body,
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
requestId: 'incidentio-t7',
providerConfig: { triggerId: 'incidentio_incident_updated' },
})
expect(mismatched).toBe(false)
})
it('formats incident_created input from the directly-nested wrapper', async () => {
const incident = {
id: 'inc_123',
reference: 'INC-123',
name: 'Database outage',
summary: 'DB is sad',
incident_status: { id: 'st_1', name: 'Investigating' },
severity: { id: 'sev_1', name: 'Major' },
mode: 'standard',
visibility: 'public',
permalink: 'https://app.incident.io/incidents/123',
created_at: '2021-08-17T13:28:57.801578Z',
updated_at: '2021-08-17T13:28:57.801578Z',
}
const body = {
event_type: 'public_incident.incident_created_v2',
'public_incident.incident_created_v2': incident,
}
const result = await incidentioHandler.formatInput!({
body,
webhook: {},
workflow: { id: 'wf_1', userId: 'user_1' },
headers: {},
requestId: 'incidentio-t8',
})
expect(result.input).toEqual({
event_type: 'public_incident.incident_created_v2',
incident,
incident_id: 'inc_123',
name: 'Database outage',
reference: 'INC-123',
summary: 'DB is sad',
incident_status: { id: 'st_1', name: 'Investigating' },
severity: { id: 'sev_1', name: 'Major' },
mode: 'standard',
visibility: 'public',
permalink: 'https://app.incident.io/incidents/123',
created_at: '2021-08-17T13:28:57.801578Z',
updated_at: '2021-08-17T13:28:57.801578Z',
new_status: null,
previous_status: null,
update_message: null,
payload: body,
})
})
it('formats incident_status_updated input from the nested incident + status change fields', async () => {
const incident = { id: 'inc_123', reference: 'INC-123', name: 'Database outage' }
const new_status = { id: 'st_2', name: 'Resolved' }
const previous_status = { id: 'st_1', name: 'Investigating' }
const body = {
event_type: 'public_incident.incident_status_updated_v2',
'public_incident.incident_status_updated_v2': {
incident,
new_status,
previous_status,
message: 'Fixed it',
},
}
const result = await incidentioHandler.formatInput!({
body,
webhook: {},
workflow: { id: 'wf_1', userId: 'user_1' },
headers: {},
requestId: 'incidentio-t8b',
})
expect(result.input).toMatchObject({
event_type: 'public_incident.incident_status_updated_v2',
incident,
incident_id: 'inc_123',
new_status,
previous_status,
update_message: 'Fixed it',
payload: body,
})
})
it('formats alert_created (v1) input from the directly-nested wrapper', async () => {
const alert = {
id: 'alrt_1',
title: 'CPU high',
description: 'CPU exceeded 75%',
status: 'firing',
alert_source_id: 'src_1',
deduplication_key: 'dedup_1',
source_url: 'https://alerts.example.com/1',
created_at: '2021-08-17T13:28:57.801578Z',
updated_at: '2021-08-17T13:28:57.801578Z',
resolved_at: '2021-08-17T14:28:57.801578Z',
}
const body = {
event_type: 'public_alert.alert_created_v1',
'public_alert.alert_created_v1': alert,
}
const result = await incidentioHandler.formatInput!({
body,
webhook: {},
workflow: { id: 'wf_1', userId: 'user_1' },
headers: {},
requestId: 'incidentio-t8c',
})
expect(result.input).toEqual({
event_type: 'public_alert.alert_created_v1',
alert,
alert_id: 'alrt_1',
title: 'CPU high',
description: 'CPU exceeded 75%',
status: 'firing',
alert_source_id: 'src_1',
deduplication_key: 'dedup_1',
source_url: 'https://alerts.example.com/1',
created_at: '2021-08-17T13:28:57.801578Z',
updated_at: '2021-08-17T13:28:57.801578Z',
resolved_at: '2021-08-17T14:28:57.801578Z',
payload: body,
})
})
it('matches the alert_created trigger against public_alert.alert_created_v1', async () => {
const matched = await incidentioHandler.matchEvent!({
body: { event_type: 'public_alert.alert_created_v1' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
requestId: 'incidentio-t9b',
providerConfig: { triggerId: 'incidentio_alert_created' },
})
expect(matched).toBe(true)
})
it('extracts an idempotency id from event_type and entity id', () => {
const body = {
event_type: 'public_incident.incident_created_v2',
'public_incident.incident_created_v2': { incident: { id: 'inc_123' } },
}
expect(incidentioHandler.extractIdempotencyId!(body)).toBe(
'public_incident.incident_created_v2:inc_123'
)
expect(incidentioHandler.extractIdempotencyId!({})).toBeNull()
})
})