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
343 lines
11 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|