- 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>
378 lines
17 KiB
TypeScript
378 lines
17 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|
|
});
|