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
357 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|