/** * Integration tests for Webhook endpoints. * Uses a real Postgres test DB and Redis test instance. * * Routes covered: * POST /api/v1/webhooks — create a webhook subscription * GET /api/v1/webhooks — list webhook subscriptions for org * GET /api/v1/webhooks/:id — get a webhook subscription by ID * PATCH /api/v1/webhooks/:id — update a webhook subscription * DELETE /api/v1/webhooks/:id — delete a webhook subscription */ import crypto from 'crypto'; import request from 'supertest'; import { Application } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { Pool } from 'pg'; const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); process.env['DATABASE_URL'] = process.env['TEST_DATABASE_URL'] ?? 'postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test'; process.env['REDIS_URL'] = process.env['TEST_REDIS_URL'] ?? 'redis://localhost:6379/1'; process.env['JWT_PRIVATE_KEY'] = privateKey; process.env['JWT_PUBLIC_KEY'] = publicKey; process.env['NODE_ENV'] = 'test'; process.env['DEFAULT_ORG_ID'] = 'org_system'; import { createApp } from '../../src/app'; import { signToken } from '../../src/utils/jwt'; import { closePool } from '../../src/db/pool'; import { closeRedisClient } from '../../src/cache/redis'; const ORG_ID = uuidv4(); const AGENT_ID = uuidv4(); const SCOPE = 'webhooks:manage'; function makeToken(sub: string = AGENT_ID, scope: string = SCOPE, orgId: string = ORG_ID): string { return signToken({ sub, client_id: sub, scope, organization_id: orgId, jti: uuidv4() }, privateKey); } const VALID_SUBSCRIPTION = { url: 'https://example.com/webhook', events: ['agent.created', 'agent.updated'], secret: 'test-secret-123', }; describe('Webhook Endpoints Integration Tests', () => { let app: Application; let pool: Pool; let createdId: string; beforeAll(async () => { app = await createApp(); pool = new Pool({ connectionString: process.env['DATABASE_URL'] }); await pool.query(` CREATE TABLE IF NOT EXISTS organizations ( organization_id VARCHAR(40) PRIMARY KEY, name VARCHAR(100) NOT NULL, plan VARCHAR(20) NOT NULL DEFAULT 'free', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await pool.query(` CREATE TABLE IF NOT EXISTS webhook_subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id VARCHAR(40) NOT NULL, url TEXT NOT NULL, events JSONB NOT NULL, secret_hash TEXT, active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await pool.query( `INSERT INTO organizations (organization_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [ORG_ID, 'Test Webhook Org'], ); }); afterAll(async () => { await pool.query(`DELETE FROM webhook_subscriptions WHERE organization_id = $1`, [ORG_ID]); await pool.query(`DELETE FROM organizations WHERE organization_id = $1`, [ORG_ID]); await pool.end(); await closePool(); await closeRedisClient(); }); // ─── POST /webhooks ────────────────────────────────────────────────────────── describe('POST /api/v1/webhooks', () => { it('should create a webhook subscription and return 201', async () => { const res = await request(app) .post('/api/v1/webhooks') .set('Authorization', `Bearer ${makeToken()}`) .send(VALID_SUBSCRIPTION); expect(res.status).toBe(201); expect(res.body).toHaveProperty('id'); expect(res.body.url).toBe(VALID_SUBSCRIPTION.url); createdId = res.body.id as string; }); it('should return 401 when no token provided', async () => { const res = await request(app).post('/api/v1/webhooks').send(VALID_SUBSCRIPTION); expect(res.status).toBe(401); }); it('should return 422 when url is missing', async () => { const res = await request(app) .post('/api/v1/webhooks') .set('Authorization', `Bearer ${makeToken()}`) .send({ events: ['agent.created'] }); expect([400, 422]).toContain(res.status); }); it('should return 422 when events array is empty', async () => { const res = await request(app) .post('/api/v1/webhooks') .set('Authorization', `Bearer ${makeToken()}`) .send({ url: 'https://example.com/wh', events: [] }); expect([400, 422]).toContain(res.status); }); }); // ─── GET /webhooks ─────────────────────────────────────────────────────────── describe('GET /api/v1/webhooks', () => { it('should return 200 with list of subscriptions', async () => { const res = await request(app) .get('/api/v1/webhooks') .set('Authorization', `Bearer ${makeToken()}`); expect(res.status).toBe(200); expect(Array.isArray(res.body.data ?? res.body)).toBe(true); }); it('should return 401 when no token provided', async () => { const res = await request(app).get('/api/v1/webhooks'); expect(res.status).toBe(401); }); }); // ─── GET /webhooks/:id ─────────────────────────────────────────────────────── describe('GET /api/v1/webhooks/:id', () => { it('should return 200 with the subscription', async () => { if (!createdId) return; // depends on POST test const res = await request(app) .get(`/api/v1/webhooks/${createdId}`) .set('Authorization', `Bearer ${makeToken()}`); expect(res.status).toBe(200); expect(res.body.id).toBe(createdId); }); it('should return 404 for non-existent subscription', async () => { const res = await request(app) .get(`/api/v1/webhooks/${uuidv4()}`) .set('Authorization', `Bearer ${makeToken()}`); expect(res.status).toBe(404); }); it('should return 401 when no token provided', async () => { const res = await request(app).get(`/api/v1/webhooks/${uuidv4()}`); expect(res.status).toBe(401); }); }); // ─── DELETE /webhooks/:id ──────────────────────────────────────────────────── describe('DELETE /api/v1/webhooks/:id', () => { it('should return 204 when deleting owned subscription', async () => { if (!createdId) return; const res = await request(app) .delete(`/api/v1/webhooks/${createdId}`) .set('Authorization', `Bearer ${makeToken()}`); expect(res.status).toBe(204); }); it('should return 404 when subscription does not exist', async () => { const res = await request(app) .delete(`/api/v1/webhooks/${uuidv4()}`) .set('Authorization', `Bearer ${makeToken()}`); expect(res.status).toBe(404); }); it('should return 401 when no token provided', async () => { const res = await request(app).delete(`/api/v1/webhooks/${uuidv4()}`); expect(res.status).toBe(401); }); }); });