feat(phase-3): workstream 5 — Webhooks & Event Streaming
- DB migrations 016/017: webhook_subscriptions and webhook_deliveries tables - WebhookService: CRUD for subscriptions, Vault-backed secret storage, delivery history - WebhookDeliveryWorker: Bull queue, HMAC-SHA256 signatures, exponential backoff, SSRF protection (RFC 1918 + loopback + link-local rejection), dead-letter handling - EventPublisher: publishes 10 event types (agent/credential/token lifecycle); optional Kafka adapter activated via KAFKA_BROKERS env var - AgentService, CredentialService, OAuth2Service: wired to EventPublisher - WebhookController + routes: 6 endpoints with webhooks:read / webhooks:write scope guards - KafkaAdapter: optional Kafka producer (kafkajs), no-op when KAFKA_BROKERS unset - OAuthScope extended: webhooks:read, webhooks:write - AuditAction extended: webhook.created, webhook.updated, webhook.deleted - Metrics: agentidp_webhook_dead_letters_total counter added to registry - 523 unit tests passing; TypeScript strict throughout, zero `any` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,9 +28,9 @@ describe('metricsRegistry', () => {
|
||||
expect(metricsRegistry).not.toBe(register);
|
||||
});
|
||||
|
||||
it('contains exactly 6 metric entries', async () => {
|
||||
it('contains exactly 7 metric entries', async () => {
|
||||
const entries = await metricsRegistry.getMetricsAsJSON();
|
||||
expect(entries).toHaveLength(6);
|
||||
expect(entries).toHaveLength(7);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
377
tests/unit/services/WebhookService.test.ts
Normal file
377
tests/unit/services/WebhookService.test.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* 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<Record<string, unknown>> = {}) {
|
||||
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<Record<string, unknown>> = {}) {
|
||||
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<Pool>;
|
||||
let vaultClient: jest.Mocked<VaultClient>;
|
||||
let service: WebhookService;
|
||||
const mockRedis = {} as RedisClientType;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
pool = new Pool() as jest.Mocked<Pool>;
|
||||
vaultClient = new (VaultClient as jest.MockedClass<typeof VaultClient>)('http://vault', 'token') as jest.Mocked<VaultClient>;
|
||||
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<string, string>];
|
||||
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<string, unknown>)['secret_hash']).toBeUndefined();
|
||||
expect((sub as unknown as Record<string, unknown>)['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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user