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
284 lines
9.0 KiB
TypeScript
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()
|
|
})
|
|
})
|