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:
SentryAgent.ai Developer
2026-03-31 00:07:41 +00:00
parent 03b5de300c
commit 272b69f18d
20 changed files with 1994 additions and 25 deletions

View 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');
});
});
});