learning_ai_clock/backend/src/modules/webhooks/webhooks.test.ts
saravanakumardb1 f10b83c122 feat(backend): scaffold product-specific Fastify backend (port 4011)
Add backend/ directory with Fastify 5 + TypeScript ESM service:
- Modules: timers, routines, households, shared-timers, webhooks (migrated from platform-service)
- Cosmos containers: timers, routines, households, shared_timers, webhook_subscriptions, webhook_events
- JWT verification via jose (matches platform-service issuer)
- Shared @bytelyst/* packages via file: refs
- 171 Vitest tests passing

Update AGENTS.md: update backend integration section with product backend details
2026-03-01 20:39:08 -08:00

357 lines
12 KiB
TypeScript

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<string, unknown>).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<string, unknown>).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');
});
});