import { describe, it, expect } from 'vitest'; import { WebhookSubscriptionSchema, CreateSubscriptionSchema, UpdateSubscriptionSchema, WebhookEventSchema, WEBHOOK_EVENT_TYPES, type WebhookSubscription, type CreateSubscription, type WebhookEvent, } from './types.js'; import { signPayload, buildSignatureHeader, verifySignature } from './dispatcher.js'; // ── Types & Schema Tests ────────────────────────────────────── describe('Webhook Types', () => { it('should define 15 event types', () => { expect(WEBHOOK_EVENT_TYPES).toHaveLength(15); }); it('should include all timer event types', () => { const timerEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('timer.')); expect(timerEvents).toEqual([ 'timer.created', 'timer.fired', 'timer.dismissed', 'timer.completed', 'timer.snoozed', 'timer.paused', 'timer.resumed', ]); }); it('should include all routine event types', () => { const routineEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('routine.')); expect(routineEvents).toEqual([ 'routine.started', 'routine.completed', 'routine.step_completed', ]); }); it('should include all household event types', () => { const householdEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('household.')); expect(householdEvents).toEqual(['household.member_joined', 'household.member_left']); }); it('should include all shared_timer event types', () => { const sharedEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('shared_timer.')); expect(sharedEvents).toEqual([ 'shared_timer.created', 'shared_timer.fired', 'shared_timer.acknowledged', ]); }); it('should have unique event types', () => { const unique = new Set(WEBHOOK_EVENT_TYPES); expect(unique.size).toBe(WEBHOOK_EVENT_TYPES.length); }); }); describe('WebhookSubscriptionSchema', () => { const validSub: WebhookSubscription = { id: 'sub-1', userId: 'user-1', productId: 'chronomind', url: 'https://example.com/webhook', secret: 'super-secret-key-1234567', events: ['timer.fired', 'timer.dismissed'], active: true, failureCount: 0, maxRetries: 3, }; it('should validate a correct subscription', () => { const result = WebhookSubscriptionSchema.safeParse(validSub); expect(result.success).toBe(true); }); it('should reject subscription without url', () => { const result = WebhookSubscriptionSchema.safeParse({ ...validSub, url: '' }); expect(result.success).toBe(false); }); it('should reject subscription with invalid url', () => { const result = WebhookSubscriptionSchema.safeParse({ ...validSub, url: 'not-a-url' }); expect(result.success).toBe(false); }); it('should reject subscription with short secret', () => { const result = WebhookSubscriptionSchema.safeParse({ ...validSub, secret: 'short' }); expect(result.success).toBe(false); }); it('should reject subscription with empty events', () => { const result = WebhookSubscriptionSchema.safeParse({ ...validSub, events: [] }); expect(result.success).toBe(false); }); it('should reject subscription with invalid event type', () => { const result = WebhookSubscriptionSchema.safeParse({ ...validSub, events: ['timer.fired', 'invalid.event'], }); expect(result.success).toBe(false); }); it('should default active to true', () => { const withoutActive = { ...validSub }; delete (withoutActive as Record).active; const result = WebhookSubscriptionSchema.safeParse(withoutActive); expect(result.success).toBe(true); if (result.success) { expect(result.data.active).toBe(true); } }); it('should default failureCount to 0', () => { const withoutCount = { ...validSub }; delete (withoutCount as Record).failureCount; const result = WebhookSubscriptionSchema.safeParse(withoutCount); expect(result.success).toBe(true); if (result.success) { expect(result.data.failureCount).toBe(0); } }); }); describe('CreateSubscriptionSchema', () => { const validCreate: CreateSubscription = { url: 'https://hooks.zapier.com/abc123', secret: 'webhook-signing-secret-abc123', events: ['timer.fired'], }; it('should validate a correct create payload', () => { const result = CreateSubscriptionSchema.safeParse(validCreate); expect(result.success).toBe(true); }); it('should accept optional description', () => { const result = CreateSubscriptionSchema.safeParse({ ...validCreate, description: 'My Zapier integration', }); expect(result.success).toBe(true); }); it('should accept optional maxRetries', () => { const result = CreateSubscriptionSchema.safeParse({ ...validCreate, maxRetries: 5, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.maxRetries).toBe(5); } }); it('should reject maxRetries > 10', () => { const result = CreateSubscriptionSchema.safeParse({ ...validCreate, maxRetries: 15, }); expect(result.success).toBe(false); }); it('should accept multiple event types', () => { const result = CreateSubscriptionSchema.safeParse({ ...validCreate, events: ['timer.fired', 'timer.dismissed', 'routine.completed'], }); expect(result.success).toBe(true); if (result.success) { expect(result.data.events).toHaveLength(3); } }); }); describe('UpdateSubscriptionSchema', () => { it('should validate partial updates', () => { const result = UpdateSubscriptionSchema.safeParse({ active: false }); expect(result.success).toBe(true); }); it('should validate url-only update', () => { const result = UpdateSubscriptionSchema.safeParse({ url: 'https://new-endpoint.example.com/hook', }); expect(result.success).toBe(true); }); it('should validate events update', () => { const result = UpdateSubscriptionSchema.safeParse({ events: ['timer.created', 'timer.completed'], }); expect(result.success).toBe(true); }); it('should reject empty events array in update', () => { const result = UpdateSubscriptionSchema.safeParse({ events: [] }); expect(result.success).toBe(false); }); }); describe('WebhookEventSchema', () => { const validEvent: WebhookEvent = { id: 'evt-1', subscriptionId: 'sub-1', userId: 'user-1', productId: 'chronomind', eventType: 'timer.fired', payload: { timerId: 'timer-1', label: 'Meeting' }, createdAt: new Date().toISOString(), attempts: 1, maxRetries: 3, }; it('should validate a correct event', () => { const result = WebhookEventSchema.safeParse(validEvent); expect(result.success).toBe(true); }); it('should accept delivered event with statusCode', () => { const result = WebhookEventSchema.safeParse({ ...validEvent, deliveredAt: new Date().toISOString(), statusCode: 200, }); expect(result.success).toBe(true); }); it('should accept failed event with error', () => { const result = WebhookEventSchema.safeParse({ ...validEvent, error: 'Connection refused', attempts: 4, }); expect(result.success).toBe(true); }); }); // ── Dispatcher Tests ────────────────────────────────────────── describe('Webhook Dispatcher — HMAC Signing', () => { const secret = 'test-secret-key-for-hmac-1234'; const payload = JSON.stringify({ type: 'timer.fired', data: { id: 'timer-1' } }); it('should produce consistent HMAC signatures', () => { const sig1 = signPayload(payload, secret); const sig2 = signPayload(payload, secret); expect(sig1).toBe(sig2); expect(sig1).toMatch(/^[0-9a-f]{64}$/); // SHA-256 hex }); it('should produce different signatures for different payloads', () => { const sig1 = signPayload('payload-1', secret); const sig2 = signPayload('payload-2', secret); expect(sig1).not.toBe(sig2); }); it('should produce different signatures for different secrets', () => { const sig1 = signPayload(payload, 'secret-1-aaaaaaaaaa'); const sig2 = signPayload(payload, 'secret-2-bbbbbbbbbb'); expect(sig1).not.toBe(sig2); }); }); describe('Webhook Dispatcher — Signature Header', () => { const secret = 'test-secret-key-for-hmac-5678'; const body = '{"type":"timer.fired","data":{}}'; it('should build a valid signature header', () => { const header = buildSignatureHeader(body, secret); expect(header).toMatch(/^t=\d+,v1=[0-9a-f]{64}$/); }); it('should include a recent timestamp', () => { const header = buildSignatureHeader(body, secret); const tPart = header.split(',')[0]; const timestamp = parseInt(tPart.slice(2), 10); const now = Math.floor(Date.now() / 1000); expect(Math.abs(now - timestamp)).toBeLessThan(5); }); }); describe('Webhook Dispatcher — Signature Verification', () => { const secret = 'test-secret-for-verification!'; const body = JSON.stringify({ type: 'timer.dismissed', data: { id: 't-99' } }); it('should verify a valid signature', () => { const header = buildSignatureHeader(body, secret); expect(verifySignature(header, body, secret)).toBe(true); }); it('should reject a tampered body', () => { const header = buildSignatureHeader(body, secret); expect(verifySignature(header, body + 'tampered', secret)).toBe(false); }); it('should reject a wrong secret', () => { const header = buildSignatureHeader(body, secret); expect(verifySignature(header, body, 'wrong-secret-1234567890')).toBe(false); }); it('should reject a malformed header', () => { expect(verifySignature('invalid', body, secret)).toBe(false); }); it('should reject missing timestamp', () => { expect(verifySignature('v1=abc123', body, secret)).toBe(false); }); it('should reject missing signature', () => { expect(verifySignature('t=1234567890', body, secret)).toBe(false); }); it('should reject expired timestamp', () => { // Build a header with a timestamp from 10 minutes ago const oldTimestamp = Math.floor(Date.now() / 1000) - 600; // signPayload produces HMAC of the raw string, matching verifySignature's `${timestamp}.${body}` pattern const sig = signPayload(`${oldTimestamp}.${body}`, secret); const header = `t=${oldTimestamp},v1=${sig}`; // Default tolerance is 300 seconds (5 minutes) — 600s ago should be rejected expect(verifySignature(header, body, secret, 300)).toBe(false); }); it('should accept within tolerance window', () => { const header = buildSignatureHeader(body, secret); // Use a large tolerance window expect(verifySignature(header, body, secret, 3600)).toBe(true); }); }); // ── Event Type Categorization Tests ─────────────────────────── describe('Event Type Categories', () => { it('all event types should have category.action format', () => { for (const type of WEBHOOK_EVENT_TYPES) { const parts = type.split('.'); expect(parts).toHaveLength(2); expect(parts[0].length).toBeGreaterThan(0); expect(parts[1].length).toBeGreaterThan(0); } }); it('should have 4 categories', () => { const categories = new Set(WEBHOOK_EVENT_TYPES.map(t => t.split('.')[0])); expect(categories.size).toBe(4); expect(categories).toContain('timer'); expect(categories).toContain('routine'); expect(categories).toContain('household'); expect(categories).toContain('shared_timer'); }); });