/** * Unit tests for src/services/WebhookService.ts * * Covers: createSubscription, listSubscriptions, getSubscription, * updateSubscription, deleteSubscription, listDeliveries, * getSubscriptionSecret — all paths including error cases. */ import crypto from 'crypto'; import { Pool } from 'pg'; import { RedisClientType } from 'redis'; import { WebhookService, WebhookNotFoundError, WebhookValidationError } from '../../../src/services/WebhookService'; import { VaultClient } from '../../../src/vault/VaultClient'; import { IWebhookSubscription } from '../../../src/types/webhook'; // ─── Mocks ──────────────────────────────────────────────────────────────────── jest.mock('pg', () => ({ Pool: jest.fn().mockImplementation(() => ({ query: jest.fn(), })), })); jest.mock('../../../src/vault/VaultClient'); // ─── Helpers ────────────────────────────────────────────────────────────────── const ORG_ID = crypto.randomUUID(); const SUB_ID = crypto.randomUUID(); const NOW = new Date('2026-03-30T10:00:00Z'); function makeSubRow(overrides: Partial> = {}) { return { id: SUB_ID, organization_id: ORG_ID, name: 'Test Hook', url: 'https://example.com/hook', events: ['agent.created'], secret_hash: 'vault', vault_secret_path: 'secret/data/agentidp/webhooks/org/sub/secret', active: true, failure_count: 0, created_at: NOW, updated_at: NOW, ...overrides, }; } function makeDeliveryRow(overrides: Partial> = {}) { return { id: crypto.randomUUID(), subscription_id: SUB_ID, event_type: 'agent.created', payload: { id: 'evt-1', event: 'agent.created', timestamp: NOW.toISOString(), organization_id: ORG_ID, data: {} }, status: 'pending', http_status_code: null, attempt_count: 0, next_retry_at: null, delivered_at: null, created_at: NOW, updated_at: NOW, ...overrides, }; } // ─── Suite ──────────────────────────────────────────────────────────────────── describe('WebhookService', () => { let pool: jest.Mocked; let vaultClient: jest.Mocked; let service: WebhookService; const mockRedis = {} as RedisClientType; beforeEach(() => { jest.clearAllMocks(); pool = new Pool() as jest.Mocked; vaultClient = new (VaultClient as jest.MockedClass)('http://vault', 'token') as jest.Mocked; service = new WebhookService(pool, vaultClient, mockRedis); }); // ──────────────────────────────────────────────────────────────────────────── // createSubscription // ──────────────────────────────────────────────────────────────────────────── describe('createSubscription()', () => { it('stores secret in Vault when vaultClient present and returns subscription with secret', async () => { vaultClient.writeArbitrarySecret = jest.fn().mockResolvedValue(undefined); (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [makeSubRow()], rowCount: 1 }); const result = await service.createSubscription(ORG_ID, { name: 'Test Hook', url: 'https://example.com/hook', events: ['agent.created'], }); expect(vaultClient.writeArbitrarySecret).toHaveBeenCalledTimes(1); const [vaultPath, secretData] = (vaultClient.writeArbitrarySecret as jest.Mock).mock.calls[0] as [string, Record]; expect(vaultPath).toMatch(/^secret\/data\/agentidp\/webhooks\//); expect(typeof secretData['webhookSecret']).toBe('string'); expect(secretData['webhookSecret'].length).toBeGreaterThan(0); expect(typeof result.secret).toBe('string'); expect(result.secret.length).toBeGreaterThan(0); expect(result.id).toBe(SUB_ID); expect(result.organization_id).toBe(ORG_ID); }); it('uses bcrypt hash in local mode (no vaultClient) and returns subscription with secret', async () => { const localService = new WebhookService(pool, null, mockRedis); (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [makeSubRow({ vault_secret_path: 'local', secret_hash: '$2b$10$hash' })], rowCount: 1, }); const result = await localService.createSubscription(ORG_ID, { name: 'Local Hook', url: 'https://example.com/hook', events: ['agent.created'], }); const insertArgs = (pool.query as jest.Mock).mock.calls[0][1] as unknown[]; const secretHashArg = insertArgs[5] as string; expect(secretHashArg).toMatch(/^\$2[ab]\$/); expect(insertArgs[6]).toBe('local'); expect(typeof result.secret).toBe('string'); expect(result.secret.length).toBeGreaterThan(0); }); it('throws WebhookValidationError for http:// URL', async () => { await expect( service.createSubscription(ORG_ID, { name: 'Bad Hook', url: 'http://example.com/hook', events: ['agent.created'], }), ).rejects.toThrow(WebhookValidationError); }); it('throws WebhookValidationError for an invalid URL string', async () => { await expect( service.createSubscription(ORG_ID, { name: 'Bad Hook', url: 'not-a-url', events: ['agent.created'], }), ).rejects.toThrow(WebhookValidationError); }); it('throws WebhookValidationError when events array is empty', async () => { await expect( service.createSubscription(ORG_ID, { name: 'Empty Events Hook', url: 'https://example.com/hook', events: [], }), ).rejects.toThrow(WebhookValidationError); }); }); // ──────────────────────────────────────────────────────────────────────────── // listSubscriptions // ──────────────────────────────────────────────────────────────────────────── describe('listSubscriptions()', () => { it('returns all subscriptions for the org with secret fields excluded', async () => { const rows = [makeSubRow(), makeSubRow({ id: crypto.randomUUID(), name: 'Hook 2' })]; (pool.query as jest.Mock).mockResolvedValueOnce({ rows, rowCount: 2 }); const result = await service.listSubscriptions(ORG_ID); expect(result).toHaveLength(2); result.forEach((sub: IWebhookSubscription) => { expect((sub as unknown as Record)['secret_hash']).toBeUndefined(); expect((sub as unknown as Record)['vault_secret_path']).toBeUndefined(); expect(sub.organization_id).toBe(ORG_ID); }); }); it('returns empty array when org has no subscriptions', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await service.listSubscriptions(ORG_ID); expect(result).toEqual([]); }); }); // ──────────────────────────────────────────────────────────────────────────── // getSubscription // ──────────────────────────────────────────────────────────────────────────── describe('getSubscription()', () => { it('returns the matching subscription for the correct org', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [makeSubRow()], rowCount: 1 }); const result = await service.getSubscription(SUB_ID, ORG_ID); expect(result.id).toBe(SUB_ID); expect(result.organization_id).toBe(ORG_ID); }); it('throws WebhookNotFoundError when subscription belongs to another org', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 }); await expect(service.getSubscription(SUB_ID, crypto.randomUUID())).rejects.toThrow( WebhookNotFoundError, ); }); it('throws WebhookNotFoundError when subscription does not exist', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 }); await expect(service.getSubscription(crypto.randomUUID(), ORG_ID)).rejects.toThrow( WebhookNotFoundError, ); }); }); // ──────────────────────────────────────────────────────────────────────────── // updateSubscription // ──────────────────────────────────────────────────────────────────────────── describe('updateSubscription()', () => { it('updates the name and returns the updated subscription', async () => { const updatedRow = makeSubRow({ name: 'Updated Hook' }); (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [makeSubRow()], rowCount: 1 }) // fetchRow (ownership check) .mockResolvedValueOnce({ rows: [updatedRow], rowCount: 1 }); // UPDATE RETURNING const result = await service.updateSubscription(SUB_ID, ORG_ID, { name: 'Updated Hook' }); expect(result.name).toBe('Updated Hook'); }); it('validates the new URL when provided and rejects http://', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [makeSubRow()], rowCount: 1 }); await expect( service.updateSubscription(SUB_ID, ORG_ID, { url: 'http://bad.example.com' }), ).rejects.toThrow(WebhookValidationError); }); it('throws WebhookNotFoundError when subscription is missing', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 }); await expect( service.updateSubscription(crypto.randomUUID(), ORG_ID, { name: 'x' }), ).rejects.toThrow(WebhookNotFoundError); }); it('returns current subscription unchanged when no fields are provided', async () => { const row = makeSubRow(); (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [row], rowCount: 1 }) // fetchRow (ownership check) .mockResolvedValueOnce({ rows: [row], rowCount: 1 }); // getSubscription → fetchRow const result = await service.updateSubscription(SUB_ID, ORG_ID, {}); expect(result.id).toBe(SUB_ID); }); }); // ──────────────────────────────────────────────────────────────────────────── // deleteSubscription // ──────────────────────────────────────────────────────────────────────────── describe('deleteSubscription()', () => { it('deletes the subscription successfully', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 1 }); await expect(service.deleteSubscription(SUB_ID, ORG_ID)).resolves.toBeUndefined(); expect(pool.query).toHaveBeenCalledTimes(1); }); it('throws WebhookNotFoundError when not found or wrong org', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 }); await expect(service.deleteSubscription(crypto.randomUUID(), ORG_ID)).rejects.toThrow( WebhookNotFoundError, ); }); }); // ──────────────────────────────────────────────────────────────────────────── // listDeliveries // ──────────────────────────────────────────────────────────────────────────── describe('listDeliveries()', () => { it('verifies org ownership and returns paginated deliveries', async () => { const deliveries = [makeDeliveryRow(), makeDeliveryRow()]; (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [makeSubRow()], rowCount: 1 }) // fetchRow .mockResolvedValueOnce({ rows: [{ count: '5' }], rowCount: 1 }) // COUNT .mockResolvedValueOnce({ rows: deliveries, rowCount: 2 }); // SELECT const result = await service.listDeliveries(SUB_ID, ORG_ID, 20, 0); expect(result.total).toBe(5); expect(result.limit).toBe(20); expect(result.offset).toBe(0); expect(result.deliveries).toHaveLength(2); }); it('throws WebhookNotFoundError when subscription belongs to another org', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 }); await expect( service.listDeliveries(SUB_ID, crypto.randomUUID(), 20, 0), ).rejects.toThrow(WebhookNotFoundError); }); it('passes correct limit and offset to the query', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [makeSubRow()], rowCount: 1 }) .mockResolvedValueOnce({ rows: [{ count: '50' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await service.listDeliveries(SUB_ID, ORG_ID, 10, 30); expect(result.limit).toBe(10); expect(result.offset).toBe(30); const selectArgs = (pool.query as jest.Mock).mock.calls[2][1] as unknown[]; expect(selectArgs[1]).toBe(10); expect(selectArgs[2]).toBe(30); }); }); // ──────────────────────────────────────────────────────────────────────────── // getSubscriptionSecret // ──────────────────────────────────────────────────────────────────────────── describe('getSubscriptionSecret()', () => { it('returns the secret from Vault in Vault mode', async () => { const vaultPath = 'secret/data/agentidp/webhooks/org/sub/secret'; (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [makeSubRow({ vault_secret_path: vaultPath })], rowCount: 1, }); vaultClient.readArbitrarySecret = jest.fn().mockResolvedValue({ webhookSecret: 'mysecret' }); const result = await service.getSubscriptionSecret(SUB_ID, ORG_ID); expect(result).toBe('mysecret'); expect(vaultClient.readArbitrarySecret).toHaveBeenCalledWith(vaultPath); }); it('throws WebhookValidationError in local mode (secret not recoverable)', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [makeSubRow({ vault_secret_path: 'local' })], rowCount: 1, }); await expect(service.getSubscriptionSecret(SUB_ID, ORG_ID)).rejects.toThrow( WebhookValidationError, ); }); it('throws WebhookNotFoundError when subscription does not exist', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 }); await expect(service.getSubscriptionSecret(crypto.randomUUID(), ORG_ID)).rejects.toThrow( WebhookNotFoundError, ); }); }); // ──────────────────────────────────────────────────────────────────────────── // Error class properties // ──────────────────────────────────────────────────────────────────────────── describe('Error classes', () => { it('WebhookNotFoundError has httpStatus 404 and correct code', () => { const err = new WebhookNotFoundError('abc'); expect(err.httpStatus).toBe(404); expect(err.code).toBe('WEBHOOK_NOT_FOUND'); expect(err.details).toEqual({ subscriptionId: 'abc' }); }); it('WebhookValidationError has httpStatus 400 and correct code', () => { const err = new WebhookValidationError('bad', { field: 'url' }); expect(err.httpStatus).toBe(400); expect(err.code).toBe('WEBHOOK_VALIDATION_ERROR'); }); }); });