Files
simstudioai--sim/apps/sim/lib/webhooks/providers/rootly.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

343 lines
11 KiB
TypeScript

import crypto from 'node:crypto'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { rootlyHandler } from '@/lib/webhooks/providers/rootly'
function signRootlyBody(secret: string, timestamp: string, rawBody: string): string {
return crypto.createHmac('sha256', secret).update(`${timestamp}${rawBody}`, 'utf8').digest('hex')
}
function requestWithRootlySignature(
secret: string,
timestamp: string,
rawBody: string
): NextRequest {
const signature = signRootlyBody(secret, timestamp, rawBody)
return new NextRequest('http://localhost/test', {
headers: {
'X-Rootly-Signature': `t=${timestamp},v1=${signature}`,
},
})
}
describe('Rootly webhook provider', () => {
it('accepts a correctly signed request within the allowed timestamp window', async () => {
const secret = 'rootly-secret'
const timestamp = Math.floor(Date.now() / 1000).toString()
const rawBody = JSON.stringify({
event: { id: 'evt-1', type: 'incident.created', issued_at: '2022-11-27T19:44:33.633-08:00' },
data: { id: 'inc-1', title: 'Sparkling Frost' },
})
const res = await rootlyHandler.verifyAuth!({
request: requestWithRootlySignature(secret, timestamp, rawBody),
rawBody,
requestId: 'rootly-t1',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('rejects when the signing secret is missing from config (fail-closed)', async () => {
const rawBody = JSON.stringify({ event: { id: 'evt-1', type: 'incident.created' }, data: {} })
const res = await rootlyHandler.verifyAuth!({
request: new NextRequest('http://localhost/test'),
rawBody,
requestId: 'rootly-t1b',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects a request with an invalid signature', async () => {
const secret = 'rootly-secret'
const timestamp = Math.floor(Date.now() / 1000).toString()
const rawBody = JSON.stringify({ event: { id: 'evt-1', type: 'incident.created' }, data: {} })
const req = new NextRequest('http://localhost/test', {
headers: { 'X-Rootly-Signature': `t=${timestamp},v1=deadbeef` },
})
const res = await rootlyHandler.verifyAuth!({
request: req,
rawBody,
requestId: 'rootly-t2',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects when the signature header is missing', async () => {
const secret = 'rootly-secret'
const rawBody = JSON.stringify({ event: { id: 'evt-1', type: 'incident.created' }, data: {} })
const res = await rootlyHandler.verifyAuth!({
request: new NextRequest('http://localhost/test'),
rawBody,
requestId: 'rootly-t3',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects when the timestamp skew is too large', async () => {
const secret = 'rootly-secret'
const timestamp = (Math.floor(Date.now() / 1000) - 600).toString()
const rawBody = JSON.stringify({ event: { id: 'evt-1', type: 'incident.created' }, data: {} })
const res = await rootlyHandler.verifyAuth!({
request: requestWithRootlySignature(secret, timestamp, rawBody),
rawBody,
requestId: 'rootly-t4',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('skips events that do not match the configured trigger', async () => {
const body = { event: { id: 'evt-1', type: 'incident.updated' }, data: { id: 'inc-1' } }
const matched = await rootlyHandler.matchEvent!({
body,
requestId: 'rootly-t5',
providerConfig: { triggerId: 'rootly_incident_created' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
})
expect(matched).toBe(false)
})
it('matches events that match the configured trigger', async () => {
const body = { event: { id: 'evt-1', type: 'incident.created' }, data: { id: 'inc-1' } }
const matched = await rootlyHandler.matchEvent!({
body,
requestId: 'rootly-t6',
providerConfig: { triggerId: 'rootly_incident_created' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
})
expect(matched).toBe(true)
})
it('formats input with keys matching the trigger outputs', async () => {
const body = {
event: { id: 'evt-1', type: 'incident.created', issued_at: '2022-11-27T19:44:33.633-08:00' },
data: { id: 'inc-1', title: 'Sparkling Frost' },
}
const result = await rootlyHandler.formatInput!({
body,
webhook: {},
workflow: { id: 'wf-1', userId: 'user-1' },
headers: {},
requestId: 'rootly-t7',
})
expect(result.input).toEqual({
eventId: 'evt-1',
eventType: 'incident.created',
issuedAt: '2022-11-27T19:44:33.633-08:00',
data: { id: 'inc-1', title: 'Sparkling Frost' },
})
})
it('extracts the event id for idempotency', () => {
const id = rootlyHandler.extractIdempotencyId!({
event: { id: 'evt-1', type: 'incident.created' },
data: {},
})
expect(id).toBe('evt-1')
})
describe('createSubscription', () => {
const fetchMock = vi.fn()
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test')
vi.stubGlobal('fetch', fetchMock)
fetchMock.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
it('creates a Rootly endpoint with a generated secret and the mapped event type', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ data: { id: 'wh-123', type: 'webhooks_endpoints' } }),
})
const result = await rootlyHandler.createSubscription!({
webhook: {
id: 'webhook-1',
path: 'abc-path',
providerConfig: { apiKey: 'rootly-key', triggerId: 'rootly_incident_created' },
},
workflow: {},
userId: 'user-1',
requestId: 'req-create-1',
request: new NextRequest('http://localhost/test'),
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.rootly.com/v1/webhooks/endpoints')
expect(init.method).toBe('POST')
const sent = JSON.parse(init.body)
expect(sent.data.type).toBe('webhooks_endpoints')
expect(sent.data.attributes.event_types).toEqual(['incident.created'])
expect(typeof sent.data.attributes.secret).toBe('string')
expect(sent.data.attributes.secret.length).toBeGreaterThan(0)
expect(sent.data.attributes.url).toContain('/api/webhooks/trigger/abc-path')
expect(result?.providerConfigUpdates?.externalId).toBe('wh-123')
expect(result?.providerConfigUpdates?.webhookSecret).toBe(sent.data.attributes.secret)
})
it('subscribes to all event types when triggerId is generic/unknown', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ data: { id: 'wh-456' } }),
})
await rootlyHandler.createSubscription!({
webhook: {
id: 'webhook-2',
path: 'p2',
providerConfig: { apiKey: 'rootly-key', triggerId: 'rootly_webhook' },
},
workflow: {},
userId: 'user-1',
requestId: 'req-create-2',
request: new NextRequest('http://localhost/test'),
})
const sent = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(sent.data.attributes.event_types).toEqual([
'incident.created',
'incident.updated',
'incident.resolved',
'alert.created',
])
})
it('throws a friendly error on 401', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ errors: [{ detail: 'unauthorized' }] }),
})
await expect(
rootlyHandler.createSubscription!({
webhook: {
id: 'webhook-3',
path: 'p3',
providerConfig: { apiKey: 'bad-key', triggerId: 'rootly_incident_created' },
},
workflow: {},
userId: 'user-1',
requestId: 'req-create-3',
request: new NextRequest('http://localhost/test'),
})
).rejects.toThrow(/Invalid Rootly API key/)
})
it('throws when apiKey is missing', async () => {
await expect(
rootlyHandler.createSubscription!({
webhook: {
id: 'webhook-4',
path: 'p4',
providerConfig: { triggerId: 'rootly_alert_created' },
},
workflow: {},
userId: 'user-1',
requestId: 'req-create-4',
request: new NextRequest('http://localhost/test'),
})
).rejects.toThrow(/Rootly API key is required/)
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe('deleteSubscription', () => {
const fetchMock = vi.fn()
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock)
fetchMock.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('deletes the endpoint by externalId', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, body: null })
await rootlyHandler.deleteSubscription!({
webhook: {
id: 'webhook-1',
providerConfig: { apiKey: 'rootly-key', externalId: 'wh-123' },
},
workflow: {},
requestId: 'req-del-1',
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.rootly.com/v1/webhooks/endpoints/wh-123')
expect(init.method).toBe('DELETE')
})
it('skips when apiKey or externalId is missing and does not throw', async () => {
await rootlyHandler.deleteSubscription!({
webhook: { id: 'webhook-2', providerConfig: { externalId: 'wh-123' } },
workflow: {},
requestId: 'req-del-2',
})
await rootlyHandler.deleteSubscription!({
webhook: { id: 'webhook-3', providerConfig: { apiKey: 'rootly-key' } },
workflow: {},
requestId: 'req-del-3',
})
expect(fetchMock).not.toHaveBeenCalled()
})
it('does not throw on a non-ok response in non-strict mode', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) })
await expect(
rootlyHandler.deleteSubscription!({
webhook: {
id: 'webhook-4',
providerConfig: { apiKey: 'rootly-key', externalId: 'wh-9' },
},
workflow: {},
requestId: 'req-del-4',
})
).resolves.toBeUndefined()
})
})
})