diff --git a/listener/src/api/archive-api.test.ts b/listener/src/api/archive-api.test.ts new file mode 100644 index 0000000..98cb6f9 --- /dev/null +++ b/listener/src/api/archive-api.test.ts @@ -0,0 +1,173 @@ +import http from 'http'; +import { EventEmitter } from 'events'; +import { handleArchiveRequest } from './archive-api'; +import { ArchiveStore } from './../services/archive-store'; +import { ArchiveService } from '../services/archive-service'; + +function makeRequest(method: string, url: string): http.IncomingMessage { + const req = new EventEmitter() as http.IncomingMessage; + req.method = method; + req.url = url; + return req; +} + +function makeResponse(): http.ServerResponse & { statusCode: number; body: any } { + const res = new EventEmitter() as any; + res.statusCode = 0; + res.body = undefined; + res.writeHead = jest.fn((status: number) => { + res.statusCode = status; + return res; + }); + res.end = jest.fn((data?: string) => { + res.body = data ? JSON.parse(data) : undefined; + return res; + }); + return res; +} + +function makeStore(): jest.Mocked> { + return { + query: jest.fn().mockResolvedValue({ records: [], total: 0, limit: 20, offset: 0, itemCount: 0, totalPages: 0 }), + getById: jest.fn().mockResolvedValue(null), + }; +} + +describe('archive-api validation', () => { + let store: ReturnType; + + beforeEach(() => { + store = makeStore(); + }); + + describe('GET /api/archive', () => { + it('rejects a non-numeric limit', async () => { + const req = makeRequest('GET', '/api/archive?limit=abc'); + const res = makeResponse(); + + const handled = await handleArchiveRequest(req, res, { store: store as unknown as ArchiveStore }, 'req-1'); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(400); + expect(res.body.details[0].field).toBe('limit'); + expect(store.query).not.toHaveBeenCalled(); + }); + + it('rejects a limit above the documented maximum', async () => { + const req = makeRequest('GET', '/api/archive?limit=101'); + const res = makeResponse(); + + await handleArchiveRequest(req, res, { store: store as unknown as ArchiveStore }, 'req-1'); + + expect(res.statusCode).toBe(400); + expect(store.query).not.toHaveBeenCalled(); + }); + + it('rejects a negative offset', async () => { + const req = makeRequest('GET', '/api/archive?offset=-1'); + const res = makeResponse(); + + await handleArchiveRequest(req, res, { store: store as unknown as ArchiveStore }, 'req-1'); + + expect(res.statusCode).toBe(400); + expect(store.query).not.toHaveBeenCalled(); + }); + + it('rejects an unknown status value', async () => { + const req = makeRequest('GET', '/api/archive?status=BOGUS'); + const res = makeResponse(); + + await handleArchiveRequest(req, res, { store: store as unknown as ArchiveStore }, 'req-1'); + + expect(res.statusCode).toBe(400); + expect(res.body.details[0].field).toBe('status'); + expect(store.query).not.toHaveBeenCalled(); + }); + + it('rejects an invalid startDate', async () => { + const req = makeRequest('GET', '/api/archive?startDate=not-a-date'); + const res = makeResponse(); + + await handleArchiveRequest(req, res, { store: store as unknown as ArchiveStore }, 'req-1'); + + expect(res.statusCode).toBe(400); + expect(store.query).not.toHaveBeenCalled(); + }); + + it('accepts a request with valid filters', async () => { + const req = makeRequest('GET', '/api/archive?limit=10&offset=0&status=COMPLETED'); + const res = makeResponse(); + + await handleArchiveRequest(req, res, { store: store as unknown as ArchiveStore }, 'req-1'); + + expect(res.statusCode).toBe(200); + expect(store.query).toHaveBeenCalledWith( + expect.objectContaining({ limit: 10, offset: 0, status: 'COMPLETED' }), + ); + }); + + it('accepts a request with no filters at all', async () => { + const req = makeRequest('GET', '/api/archive'); + const res = makeResponse(); + + await handleArchiveRequest(req, res, { store: store as unknown as ArchiveStore }, 'req-1'); + + expect(res.statusCode).toBe(200); + }); + }); + + describe('GET /api/archive/:id', () => { + it('rejects a non-numeric id', async () => { + const req = makeRequest('GET', '/api/archive/abc'); + const res = makeResponse(); + + const handled = await handleArchiveRequest(req, res, { store: store as unknown as ArchiveStore }, 'req-1'); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(400); + expect(store.getById).not.toHaveBeenCalled(); + }); + + it('rejects a zero or negative id', async () => { + const req = makeRequest('GET', '/api/archive/0'); + const res = makeResponse(); + + await handleArchiveRequest(req, res, { store: store as unknown as ArchiveStore }, 'req-1'); + + expect(res.statusCode).toBe(400); + expect(store.getById).not.toHaveBeenCalled(); + }); + + it('accepts a valid positive integer id', async () => { + const req = makeRequest('GET', '/api/archive/42'); + const res = makeResponse(); + + await handleArchiveRequest(req, res, { store: store as unknown as ArchiveStore }, 'req-1'); + + expect(store.getById).toHaveBeenCalledWith(42); + expect(res.statusCode).toBe(404); + }); + }); + + describe('POST /api/archive/run', () => { + it('returns 503 when the archive service is not configured', async () => { + const req = makeRequest('POST', '/api/archive/run'); + const res = makeResponse(); + + await handleArchiveRequest(req, res, { store: store as unknown as ArchiveStore, service: null }, 'req-1'); + + expect(res.statusCode).toBe(503); + }); + + it('runs the archive cycle when the service is configured', async () => { + const service = { runCycle: jest.fn().mockResolvedValue({ archived: 3 }) } as unknown as ArchiveService; + const req = makeRequest('POST', '/api/archive/run'); + const res = makeResponse(); + + await handleArchiveRequest(req, res, { store: store as unknown as ArchiveStore, service }, 'req-1'); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ archived: 3 }); + }); + }); +}); diff --git a/listener/src/api/archive-api.ts b/listener/src/api/archive-api.ts index ac35801..e517fa3 100644 --- a/listener/src/api/archive-api.ts +++ b/listener/src/api/archive-api.ts @@ -13,6 +13,15 @@ import http from 'http'; import { ArchiveStore } from '../services/archive-store'; import { ArchiveService } from '../services/archive-service'; import logger from '../utils/logger'; +import { NotificationStatus } from '../types/scheduled-notification'; +import { + InputValidator, + ValidationError, + isOneOf, + parseOptionalDateParam, + parseOptionalIntParam, + validationErrorBody, +} from '../utils/validation'; import { sendOk, sendErr, ErrorCode } from '../utils/response'; export interface ArchiveApiHandlerDeps { @@ -52,9 +61,14 @@ export async function handleArchiveRequest( } // GET /api/archive/:id - const singleMatch = pathname.match(/^\/api\/archive\/(\d+)$/); + const singleMatch = pathname.match(/^\/api\/archive\/([^/]+)$/); if (req.method === 'GET' && singleMatch) { const id = parseInt(singleMatch[1], 10); + if (!Number.isInteger(id) || id <= 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'id must be a positive integer' })); + return true; + } logger.info('Handling GET /api/archive/:id', { requestId, id }); try { const record = await deps.store.getById(id); @@ -74,17 +88,39 @@ export async function handleArchiveRequest( if (req.method === 'GET' && pathname === '/api/archive') { logger.info('Handling GET /api/archive', { requestId }); try { + const limit = parseOptionalIntParam(url.searchParams.get('limit'), 'limit', { min: 1, max: 100 }); + const offset = parseOptionalIntParam(url.searchParams.get('offset'), 'offset', { min: 0 }); + const status = url.searchParams.get('status') ?? undefined; + const startDate = parseOptionalDateParam(url.searchParams.get('startDate'), 'startDate'); + const endDate = parseOptionalDateParam(url.searchParams.get('endDate'), 'endDate'); + + const v = new InputValidator(); + if (status !== undefined) { + v.check( + isOneOf(status, Object.values(NotificationStatus)), + 'status', + `must be one of: ${Object.values(NotificationStatus).join(', ')}`, + ); + } + v.throwIfInvalid(); + const options = { - limit: url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit')!, 10) : undefined, - offset: url.searchParams.get('offset') ? parseInt(url.searchParams.get('offset')!, 10) : undefined, - status: url.searchParams.get('status') ?? undefined, + limit, + offset, + status, contractAddress: url.searchParams.get('contractAddress') ?? undefined, - startDate: url.searchParams.get('startDate') ?? undefined, - endDate: url.searchParams.get('endDate') ?? undefined, + startDate, + endDate, }; const result = await deps.store.query(options); sendOk(res, 200, result); } catch (err) { + if (err instanceof ValidationError) { + logger.warn('Archive query rejected', { requestId, error: err.message }); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(validationErrorBody(err))); + return true; + } logger.error('Failed to query archive', { error: err, requestId }); sendErr(res, 500, (err as Error).message, ErrorCode.INTERNAL_ERROR); } diff --git a/listener/src/api/template-api.ts b/listener/src/api/template-api.ts index 53876ef..2de0fa5 100644 --- a/listener/src/api/template-api.ts +++ b/listener/src/api/template-api.ts @@ -220,12 +220,12 @@ export function createTemplateAPIHandler(options: TemplateAPIOptions) { } import { resolveRequestActor } from '../utils/request-actor'; import { - NotificationTemplate, + AuditedNotificationTemplate, TemplateAuditRecord, UpdateNotificationTemplateInput, } from '../types/notification-template'; -export function serializeTemplate(template: NotificationTemplate): Record { +export function serializeTemplate(template: AuditedNotificationTemplate): Record { return { ...template, createdAt: template.createdAt ? template.createdAt.toISOString() : new Date().toISOString(), @@ -245,7 +245,7 @@ export function serializeAuditRecord(record: TemplateAuditRecord): Record { + if (body !== undefined) { + req.emit('data', Buffer.from(JSON.stringify(body))); + } + req.emit('end'); + }); + return req; +} + +function makeRawRequest(method: string, url: string, rawBody: string): http.IncomingMessage { + const req = new EventEmitter() as http.IncomingMessage; + req.method = method; + req.url = url; + process.nextTick(() => { + req.emit('data', Buffer.from(rawBody)); + req.emit('end'); + }); + return req; +} + +function makeResponse(): http.ServerResponse & { statusCode: number; body: any } { + const res = new EventEmitter() as any; + res.statusCode = 0; + res.body = undefined; + res.writeHead = jest.fn((status: number) => { + res.statusCode = status; + return res; + }); + res.end = jest.fn((data?: string) => { + res.body = data ? JSON.parse(data) : undefined; + return res; + }); + return res; +} + +function makeTemplateService(): jest.Mocked< + Pick< + TemplateService, + | 'createTemplate' + | 'updateTemplate' + | 'listTemplates' + | 'getTemplate' + | 'renderTemplate' + | 'deactivateTemplate' + | 'deleteTemplate' + | 'getTemplateStats' + | 'getOverviewStats' + > +> { + return { + createTemplate: jest.fn(), + updateTemplate: jest.fn(), + listTemplates: jest.fn().mockResolvedValue([]), + getTemplate: jest.fn(), + renderTemplate: jest.fn(), + deactivateTemplate: jest.fn().mockResolvedValue(true), + deleteTemplate: jest.fn().mockResolvedValue(true), + getTemplateStats: jest.fn(), + getOverviewStats: jest.fn().mockResolvedValue({ totalTemplates: 0 }), + }; +} + +describe('template-routes validation', () => { + let service: ReturnType; + + beforeEach(() => { + service = makeTemplateService(); + }); + + describe('POST /api/templates', () => { + it('rejects a request missing required fields without calling the service', async () => { + const req = makeRequest('POST', '/api/templates', { name: 'Only a name' }); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(400); + expect(res.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: 'uniqueKey' }), + expect.objectContaining({ field: 'channelType' }), + expect.objectContaining({ field: 'bodyTemplate' }), + ]), + ); + expect(service.createTemplate).not.toHaveBeenCalled(); + }); + + it('rejects an invalid channelType', async () => { + const req = makeRequest('POST', '/api/templates', { + uniqueKey: 'k', + name: 'n', + channelType: 'CARRIER_PIGEON', + bodyTemplate: 'body', + }); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(400); + expect(service.createTemplate).not.toHaveBeenCalled(); + }); + + it('rejects malformed JSON with a 400, not a 500', async () => { + const req = makeRawRequest('POST', '/api/templates', '{not valid json'); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('Invalid JSON body'); + }); + + it('returns 400 when the service reports the template failed validation', async () => { + service.createTemplate.mockResolvedValue({ + success: false, + error: 'Template validation failed', + validation: { isValid: false, errors: ['Body template is required'] }, + }); + const req = makeRequest('POST', '/api/templates', { + uniqueKey: 'k', + name: 'n', + channelType: TemplateChannelType.EMAIL, + bodyTemplate: 'body', + }); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('Template validation failed'); + }); + + it('returns 201 when the service accepts the template', async () => { + service.createTemplate.mockResolvedValue({ success: true, templateId: 5 }); + const req = makeRequest('POST', '/api/templates', { + uniqueKey: 'k', + name: 'n', + channelType: TemplateChannelType.EMAIL, + bodyTemplate: 'body', + }); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(201); + expect(res.body.id).toBe(5); + }); + }); + + describe('GET /api/templates', () => { + it('rejects an invalid channelType query param', async () => { + const req = makeRequest('GET', '/api/templates?channelType=NOT_REAL'); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(400); + expect(service.listTemplates).not.toHaveBeenCalled(); + }); + + it('accepts a valid channelType query param', async () => { + const req = makeRequest('GET', '/api/templates?channelType=EMAIL'); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(200); + expect(service.listTemplates).toHaveBeenCalledWith({ + channelType: TemplateChannelType.EMAIL, + isActive: undefined, + }); + }); + }); + + describe('PUT /api/templates/:id', () => { + it('does not route a non-numeric id to the update handler', async () => { + const req = makeRequest('PUT', '/api/templates/abc', { name: 'New name' }); + const res = makeResponse(); + + const handled = await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(handled).toBe(false); + expect(service.updateTemplate).not.toHaveBeenCalled(); + }); + + it('returns 404 when the service reports the template does not exist', async () => { + service.updateTemplate.mockResolvedValue({ success: false, error: 'Template not found' }); + const req = makeRequest('PUT', '/api/templates/999', { name: 'New name' }); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(404); + }); + + it('returns 400 (not 200) when the service rejects the update', async () => { + service.updateTemplate.mockResolvedValue({ + success: false, + error: 'Template validation failed', + validation: { isValid: false, errors: ['Body template exceeds maximum length'] }, + }); + const req = makeRequest('PUT', '/api/templates/1', { bodyTemplate: 'x'.repeat(20000) }); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('Template validation failed'); + }); + + it('returns 200 when the service accepts the update', async () => { + service.updateTemplate.mockResolvedValue({ success: true }); + const req = makeRequest('PUT', '/api/templates/1', { name: 'New name' }); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(200); + }); + }); + + describe('POST /api/templates/render', () => { + it('rejects a non-object context', async () => { + const req = makeRequest('POST', '/api/templates/render', { templateId: 1, context: 'nope' }); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(400); + expect(service.renderTemplate).not.toHaveBeenCalled(); + }); + + it('rejects a request missing both templateId and uniqueKey', async () => { + const req = makeRequest('POST', '/api/templates/render', { context: {} }); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(400); + }); + + it('surfaces missing variables from the service as a 400', async () => { + service.renderTemplate.mockResolvedValue({ + success: false, + error: 'Missing required variables', + missingVariables: ['name'], + }); + const req = makeRequest('POST', '/api/templates/render', { templateId: 1, context: {} }); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(400); + expect(res.body.missingVariables).toEqual(['name']); + }); + }); + + describe('GET /api/templates/stats', () => { + it('rejects a non-numeric templateId query param', async () => { + const req = makeRequest('GET', '/api/templates/stats?templateId=abc'); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(400); + expect(service.getTemplateStats).not.toHaveBeenCalled(); + }); + + it('falls back to overview stats when no templateId is given', async () => { + const req = makeRequest('GET', '/api/templates/stats'); + const res = makeResponse(); + + await handleTemplateRoutes(req, res, 'req-1', service as unknown as TemplateService); + + expect(res.statusCode).toBe(200); + expect(service.getOverviewStats).toHaveBeenCalled(); + expect(service.getTemplateStats).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/listener/src/api/template-routes.ts b/listener/src/api/template-routes.ts index 95bdeb1..b30e71c 100644 --- a/listener/src/api/template-routes.ts +++ b/listener/src/api/template-routes.ts @@ -6,6 +6,16 @@ import http from 'http'; import { TemplateService } from '../services/template-service'; import logger from '../utils/logger'; +import { TemplateChannelType } from '../types/notification-template'; +import { + InputValidator, + ValidationError, + isNonEmptyString, + isOneOf, + isPlainObject, + isPositiveInteger, + validationErrorBody, +} from '../utils/validation'; import { sendOk, sendErr, ErrorCode } from '../utils/response'; interface TemplateRouteContext { @@ -35,6 +45,51 @@ async function parseBody(req: http.IncomingMessage): Promise { }); } +/** + * Send JSON response + */ +function sendJson(res: http.ServerResponse, statusCode: number, data: any): void { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); +} + +/** + * Maps a caught error to a meaningful HTTP response. Input-shape problems + * (bad JSON, failed validation) become 400s with the specific reason; + * anything else falls back to a generic 500 rather than leaking internals. + */ +function respondWithError( + res: http.ServerResponse, + error: unknown, + options: { notFoundMessage?: string } = {}, +): void { + if (error instanceof ValidationError) { + sendJson(res, 400, validationErrorBody(error)); + return; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage === 'Invalid JSON body') { + sendJson(res, 400, { error: errorMessage }); + return; + } + + const lower = errorMessage.toLowerCase(); + if (lower.includes('not found')) { + sendJson(res, 404, { error: options.notFoundMessage ?? errorMessage }); + return; + } + if (lower.includes('unique constraint')) { + sendJson(res, 409, { error: 'Template with this unique key already exists' }); + return; + } + if (lower.includes('validation') || lower.includes('invalid') || lower.includes('required')) { + sendJson(res, 400, { error: errorMessage }); + return; + } + sendJson(res, 500, { error: 'Internal server error' }); +} + /** * Handle POST /api/templates - Create template */ @@ -44,6 +99,19 @@ export async function handleCreateTemplate(ctx: TemplateRouteContext): Promise> { + return { + create: jest.fn().mockResolvedValue(1), + }; +} + +function futureDate(msFromNow = 60_000): Date { + return new Date(Date.now() + msFromNow); +} + +function baseInput() { + return { + payload: { message: 'hello' }, + notificationType: NotificationType.DISCORD, + targetRecipient: 'https://discord.com/webhook/abc', + executeAt: futureDate(), + }; +} + +describe('NotificationAPI.scheduleNotification', () => { + let repository: jest.Mocked>; + let api: NotificationAPI; + + beforeEach(() => { + repository = makeRepository(); + api = new NotificationAPI(repository as unknown as ScheduledNotificationRepository); + }); + + it('accepts a valid notification and forwards it to the repository', async () => { + const input = baseInput(); + const id = await api.scheduleNotification(input); + expect(id).toBe(1); + expect(repository.create).toHaveBeenCalledWith(input, undefined); + }); + + it('rejects a missing executeAt', async () => { + const input = { ...baseInput(), executeAt: undefined as any }; + await expect(api.scheduleNotification(input)).rejects.toThrow('executeAt must be a valid date'); + }); + + it('rejects an executeAt in the past', async () => { + const input = { ...baseInput(), executeAt: new Date(Date.now() - 60_000) }; + await expect(api.scheduleNotification(input)).rejects.toThrow( + 'executeAt must be a future timestamp', + ); + }); + + it('rejects a non-object payload', async () => { + const input = { ...baseInput(), payload: 'not-an-object' as any }; + await expect(api.scheduleNotification(input)).rejects.toThrow('payload must be a valid object'); + }); + + it('rejects an array payload', async () => { + const input = { ...baseInput(), payload: ['a', 'b'] as any }; + await expect(api.scheduleNotification(input)).rejects.toThrow('payload must be a valid object'); + }); + + it('rejects an empty targetRecipient', async () => { + const input = { ...baseInput(), targetRecipient: ' ' }; + await expect(api.scheduleNotification(input)).rejects.toThrow('targetRecipient is required'); + }); + + it('rejects an unknown notificationType', async () => { + const input = { ...baseInput(), notificationType: 'carrier-pigeon' as any }; + await expect(api.scheduleNotification(input)).rejects.toThrow(ValidationError); + await expect(api.scheduleNotification(input)).rejects.toThrow(/notificationType/); + }); + + it('rejects a negative maxRetries', async () => { + const input = { ...baseInput(), maxRetries: -1 }; + await expect(api.scheduleNotification(input)).rejects.toThrow(/maxRetries/); + }); + + it('rejects a non-integer maxRetries', async () => { + const input = { ...baseInput(), maxRetries: 2.5 }; + await expect(api.scheduleNotification(input)).rejects.toThrow(/maxRetries/); + }); + + it('rejects a priority outside the documented 1-10 range', async () => { + const tooLow = { ...baseInput(), priority: 0 }; + const tooHigh = { ...baseInput(), priority: 11 }; + await expect(api.scheduleNotification(tooLow)).rejects.toThrow(/priority/); + await expect(api.scheduleNotification(tooHigh)).rejects.toThrow(/priority/); + }); + + it('accepts priority at the documented boundaries', async () => { + await expect(api.scheduleNotification({ ...baseInput(), priority: 1 })).resolves.toBe(1); + await expect(api.scheduleNotification({ ...baseInput(), priority: 10 })).resolves.toBe(1); + }); + + it('rejects a non-object metadata', async () => { + const input = { ...baseInput(), metadata: 'oops' as any }; + await expect(api.scheduleNotification(input)).rejects.toThrow(/metadata/); + }); + + it('rejects an empty eventId when provided', async () => { + const input = { ...baseInput(), eventId: '' }; + await expect(api.scheduleNotification(input)).rejects.toThrow(/eventId/); + }); + + it('reports every invalid field in a single error', async () => { + const input = { + ...baseInput(), + notificationType: 'bogus' as any, + maxRetries: -5, + priority: 999, + }; + try { + await api.scheduleNotification(input); + throw new Error('expected scheduleNotification to reject'); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + const fields = (err as ValidationError).issues.map((i) => i.field); + expect(fields).toEqual(expect.arrayContaining(['notificationType', 'maxRetries', 'priority'])); + } + }); + + it('does not call the repository when validation fails', async () => { + const input = { ...baseInput(), priority: 999 }; + await expect(api.scheduleNotification(input)).rejects.toThrow(); + expect(repository.create).not.toHaveBeenCalled(); import { jest, describe, it, expect, beforeEach } from '@jest/globals'; import { NotificationAPI } from './notification-api'; import { PayloadTooLargeError, DEFAULT_MAX_PAYLOAD_SIZE_BYTES } from '../utils/payload-size-validator'; diff --git a/listener/src/services/notification-api.ts b/listener/src/services/notification-api.ts index 58ab0bb..be65948 100644 --- a/listener/src/services/notification-api.ts +++ b/listener/src/services/notification-api.ts @@ -8,6 +8,18 @@ import { import { validateNotificationMetadata } from '../utils/metadata-validator'; import { ensureNotificationVersion } from '../utils/notification-version'; import logger from '../utils/logger'; +import { + InputValidator, + isNonEmptyString, + isNonNegativeInteger, + isOneOf, + isPlainObject, + isInRange, + isInteger, +} from '../utils/validation'; + +const PRIORITY_MIN = 1; +const PRIORITY_MAX = 10; import { buildRetryStatisticsPayload } from './retry-statistics'; /** @@ -62,14 +74,40 @@ export class NotificationAPI { throw new Error('executeAt must be a future timestamp — the provided date has already expired'); } - if (!input.payload || typeof input.payload !== 'object') { + if (!isPlainObject(input.payload)) { throw new Error('payload must be a valid object'); } - if (!input.targetRecipient) { - throw new Error('targetRecipient is required'); + if (!isNonEmptyString(input.targetRecipient)) { + throw new Error('targetRecipient is required and must be a non-empty string'); } + const v = new InputValidator(); + v.check( + isOneOf(input.notificationType, Object.values(NotificationType)), + 'notificationType', + `must be one of: ${Object.values(NotificationType).join(', ')}`, + ); + if (input.maxRetries !== undefined) { + v.check(isNonNegativeInteger(input.maxRetries), 'maxRetries', 'must be a non-negative integer'); + } + if (input.priority !== undefined) { + v.check( + isInteger(input.priority) && isInRange(input.priority, PRIORITY_MIN, PRIORITY_MAX), + 'priority', + `must be an integer between ${PRIORITY_MIN} and ${PRIORITY_MAX}`, + ); + } + if (input.eventId !== undefined) { + v.check(isNonEmptyString(input.eventId), 'eventId', 'must be a non-empty string'); + } + if (input.contractAddress !== undefined) { + v.check(isNonEmptyString(input.contractAddress), 'contractAddress', 'must be a non-empty string'); + } + if (input.metadata !== undefined) { + v.check(isPlainObject(input.metadata), 'metadata', 'must be an object'); + } + v.throwIfInvalid(); // Stamp / verify protocol version on the payload. input = { ...input, diff --git a/listener/src/services/notification-template-cache.test.ts b/listener/src/services/notification-template-cache.test.ts index 89e8df2..fc692fc 100644 --- a/listener/src/services/notification-template-cache.test.ts +++ b/listener/src/services/notification-template-cache.test.ts @@ -1,6 +1,6 @@ -import { NotificationTemplateCache, getTemplateCache, resetTemplateCache, NotificationTemplate } from './notification-template-cache'; +import { NotificationTemplateCache, getTemplateCache, resetTemplateCache, AuditedNotificationTemplate } from './notification-template-cache'; -const makeTemplate = (id: string): NotificationTemplate => ({ +const makeTemplate = (id: string): AuditedNotificationTemplate => ({ id, name: `Template ${id}`, type: 'email', diff --git a/listener/src/services/notification-template-cache.ts b/listener/src/services/notification-template-cache.ts index 0342a72..6c15abf 100644 --- a/listener/src/services/notification-template-cache.ts +++ b/listener/src/services/notification-template-cache.ts @@ -1,8 +1,8 @@ import NodeCache from 'node-cache'; import logger from '../utils/logger'; -import { NotificationTemplate } from '../types/notification-template'; +import { AuditedNotificationTemplate } from '../types/notification-template'; -export type { NotificationTemplate } from '../types/notification-template'; +export type { AuditedNotificationTemplate } from '../types/notification-template'; /** * Cache statistics for monitoring hit rate @@ -50,8 +50,8 @@ export class NotificationTemplateCache { * @param templateId - The template identifier * @returns Cached template or undefined if not found/expired */ - get(templateId: string): NotificationTemplate | undefined { - const value = this.cache.get(templateId); + get(templateId: string): AuditedNotificationTemplate | undefined { + const value = this.cache.get(templateId); if (value !== undefined) { this.hits++; logger.debug('[TemplateCache] Cache hit', { templateId }); @@ -68,7 +68,7 @@ export class NotificationTemplateCache { * @param template - Template data to cache * @param ttl - Optional custom TTL in seconds */ - set(templateId: string, template: NotificationTemplate, ttl?: number): void { + set(templateId: string, template: AuditedNotificationTemplate, ttl?: number): void { const success = ttl !== undefined ? this.cache.set(templateId, template, ttl) : this.cache.set(templateId, template); @@ -89,9 +89,9 @@ export class NotificationTemplateCache { */ async getOrLoad( templateId: string, - loader: () => Promise, + loader: () => Promise, ttl?: number, - ): Promise { + ): Promise { const cached = this.get(templateId); if (cached !== undefined) { return cached; diff --git a/listener/src/services/notification-template-repository.ts b/listener/src/services/notification-template-repository.ts index f920f93..831b1d3 100644 --- a/listener/src/services/notification-template-repository.ts +++ b/listener/src/services/notification-template-repository.ts @@ -1,6 +1,9 @@ import { Database } from '../database/database'; import logger from '../utils/logger'; import { + CreateNotificationTemplateInput, + AuditedNotificationTemplate, + AuditedNotificationTemplateRow, CreateNotificationTemplateInputOld, NotificationTemplateOld, NotificationTemplateRowOld, @@ -34,6 +37,7 @@ export class NotificationTemplateRepository { private readonly cache?: NotificationTemplateCache, ) {} + async create(input: CreateNotificationTemplateInput): Promise { async create(input: CreateNotificationTemplateInputOld): Promise { this.validateTemplateInput(input.id, input.name, input.body); @@ -66,6 +70,8 @@ export class NotificationTemplateRepository { return template; } + async getById(templateId: string): Promise { + const row = await this.db.get( async getById(templateId: string): Promise { const row = await this.db.get( 'SELECT * FROM notification_templates WHERE id = ?', @@ -78,6 +84,7 @@ export class NotificationTemplateRepository { templateId: string, input: UpdateNotificationTemplateInputOld, actor: string, + ): Promise { ): Promise { const trimmedActor = actor?.trim(); if (!trimmedActor) { @@ -93,6 +100,7 @@ export class NotificationTemplateRepository { const nextBody = input.body ?? existing.body; this.validateTemplateInput(templateId, nextName, nextBody); + const updated: AuditedNotificationTemplate = { const updated: NotificationTemplateOld = { ...existing, ...input, @@ -149,6 +157,13 @@ export class NotificationTemplateRepository { return persisted; } + async getAll(): Promise { + const rows = await this.db.all( + 'SELECT * FROM notification_templates', + ); + return rows.map(row => this.rowToModel(row)); + async listAll(): Promise { + const rows = await this.db.all( async getAll(): Promise { const rows = await this.db.all( 'SELECT * FROM notification_templates', @@ -193,6 +208,8 @@ export class NotificationTemplateRepository { } private hasTemplateChanges( + previous: AuditedNotificationTemplate, + next: AuditedNotificationTemplate, previous: NotificationTemplateOld, next: NotificationTemplateOld, ): boolean { @@ -200,6 +217,7 @@ export class NotificationTemplateRepository { !== JSON.stringify(this.snapshotForComparison(next)); } + private snapshotForComparison(template: AuditedNotificationTemplate): Record { private snapshotForComparison(template: NotificationTemplateOld): Record { return { id: template.id, @@ -213,6 +231,7 @@ export class NotificationTemplateRepository { }; } + private rowToModel(row: AuditedNotificationTemplateRow): AuditedNotificationTemplate { private rowToModel(row: NotificationTemplateRowOld): NotificationTemplateOld { return { id: row.id, diff --git a/listener/src/services/notification-template-service.ts b/listener/src/services/notification-template-service.ts index a06f90c..28f9ca0 100644 --- a/listener/src/services/notification-template-service.ts +++ b/listener/src/services/notification-template-service.ts @@ -1,4 +1,6 @@ import { + CreateNotificationTemplateInput, + AuditedNotificationTemplate, CreateNotificationTemplateInputOld, NotificationTemplateOld, TemplateAuditRecord, @@ -24,12 +26,14 @@ export class NotificationTemplateService { private readonly cache: NotificationTemplateCache = getTemplateCache(), ) {} + async create(input: CreateNotificationTemplateInput): Promise { async create(input: CreateNotificationTemplateInputOld): Promise { const template = await this.repository.create(input); this.cache.set(String(template.id ?? ''), template); return template; } + async listAll(): Promise { async listAll(): Promise { return this.repository.listAll(); } @@ -44,6 +48,7 @@ export class NotificationTemplateService { * Returns the rendered subject and body, or throws if required variables are missing. */ renderTemplate( + template: AuditedNotificationTemplate, template: NotificationTemplateOld, variables: Record, ): { subject?: string; body: string } { @@ -65,6 +70,7 @@ export class NotificationTemplateService { }; } + async getById(templateId: string): Promise { async getById(templateId: string): Promise { return this.cache.getOrLoad(templateId, () => this.repository.getById(templateId)); } @@ -73,6 +79,11 @@ export class NotificationTemplateService { templateId: string, input: UpdateNotificationTemplateInputOld, actor: string, + ): Promise { + return this.repository.update(templateId, input, actor); + } + + async getAll(): Promise { ): Promise { return this.repository.update(templateId, input, actor); } diff --git a/listener/src/services/template-audit-trail.ts b/listener/src/services/template-audit-trail.ts index 13f66fe..85dec32 100644 --- a/listener/src/services/template-audit-trail.ts +++ b/listener/src/services/template-audit-trail.ts @@ -1,7 +1,7 @@ import { Database } from '../database/database'; import logger from '../utils/logger'; import { - NotificationTemplate, + AuditedNotificationTemplate, TemplateAuditAction, TemplateAuditRecord, TemplateAuditRecordRow, @@ -11,8 +11,8 @@ export interface RecordTemplateAuditInput { templateId: string; actor: string; action?: TemplateAuditAction; - previousSnapshot: NotificationTemplate; - newSnapshot: NotificationTemplate; + previousSnapshot: AuditedNotificationTemplate; + newSnapshot: AuditedNotificationTemplate; } /** @@ -78,8 +78,8 @@ export class TemplateAuditTrail { actor: row.actor, action: row.action as TemplateAuditAction, changedAt: new Date(row.changed_at), - previousSnapshot: JSON.parse(row.previous_snapshot) as NotificationTemplate, - newSnapshot: JSON.parse(row.new_snapshot) as NotificationTemplate, + previousSnapshot: JSON.parse(row.previous_snapshot) as AuditedNotificationTemplate, + newSnapshot: JSON.parse(row.new_snapshot) as AuditedNotificationTemplate, }; } } diff --git a/listener/src/services/template-service.test.ts b/listener/src/services/template-service.test.ts new file mode 100644 index 0000000..d5322d6 --- /dev/null +++ b/listener/src/services/template-service.test.ts @@ -0,0 +1,145 @@ +import { TemplateService } from './template-service'; +import { TemplateRepository } from './template-repository'; +import { TemplateChannelType } from '../types/notification-template'; + +function makeRepository(): jest.Mocked< + Pick +> { + return { + create: jest.fn().mockResolvedValue(1), + exists: jest.fn().mockResolvedValue(false), + getById: jest.fn(), + update: jest.fn().mockResolvedValue(true), + }; +} + +function validCreateInput() { + return { + uniqueKey: 'welcome-email', + name: 'Welcome Email', + channelType: TemplateChannelType.EMAIL, + subjectTemplate: 'Welcome {{name}}', + bodyTemplate: 'Hello {{name}}, welcome aboard!', + }; +} + +describe('TemplateService.createTemplate', () => { + let repository: jest.Mocked>; + let service: TemplateService; + + beforeEach(() => { + repository = makeRepository(); + service = new TemplateService(repository as unknown as TemplateRepository); + }); + + it('creates a template when all fields are valid', async () => { + const result = await service.createTemplate(validCreateInput()); + expect(result.success).toBe(true); + expect(result.templateId).toBe(1); + expect(repository.create).toHaveBeenCalled(); + }); + + it('rejects a missing name without touching the repository', async () => { + const input = { ...validCreateInput(), name: '' }; + const result = await service.createTemplate(input); + expect(result.success).toBe(false); + expect(result.error).toMatch(/name/); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('rejects a whitespace-only name', async () => { + const input = { ...validCreateInput(), name: ' ' }; + const result = await service.createTemplate(input); + expect(result.success).toBe(false); + expect(result.error).toMatch(/name/); + }); + + it('rejects an invalid channelType', async () => { + const input = { ...validCreateInput(), channelType: 'CARRIER_PIGEON' as any }; + const result = await service.createTemplate(input); + expect(result.success).toBe(false); + expect(result.error).toMatch(/channelType/); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('rejects a description that is not a string', async () => { + const input = { ...validCreateInput(), description: 12345 as any }; + const result = await service.createTemplate(input); + expect(result.success).toBe(false); + expect(result.error).toMatch(/description/); + }); + + it('rejects a name longer than 255 characters', async () => { + const input = { ...validCreateInput(), name: 'a'.repeat(256) }; + const result = await service.createTemplate(input); + expect(result.success).toBe(false); + expect(result.error).toMatch(/name/); + }); + + it('rejects variables that are not an array of strings', async () => { + const input = { ...validCreateInput(), variables: [1, 2, 3] as any }; + const result = await service.createTemplate(input); + expect(result.success).toBe(false); + expect(result.error).toMatch(/variables/); + }); + + it('rejects a non-object defaultValues', async () => { + const input = { ...validCreateInput(), defaultValues: 'nope' as any }; + const result = await service.createTemplate(input); + expect(result.success).toBe(false); + expect(result.error).toMatch(/defaultValues/); + }); + + it('still runs template content validation for a valid name/channelType with a bad body', async () => { + const input = { ...validCreateInput(), bodyTemplate: '' }; + const result = await service.createTemplate(input); + expect(result.success).toBe(false); + expect(result.validation?.isValid).toBe(false); + }); +}); + +describe('TemplateService.updateTemplate', () => { + let repository: jest.Mocked>; + let service: TemplateService; + + beforeEach(() => { + repository = makeRepository(); + repository.getById.mockResolvedValue({ + id: 1, + uniqueKey: 'welcome-email', + name: 'Welcome Email', + channelType: TemplateChannelType.EMAIL, + bodyTemplate: 'Hello {{name}}', + variables: ['name'], + defaultValues: {}, + isActive: true, + version: 1, + } as any); + service = new TemplateService(repository as unknown as TemplateRepository); + }); + + it('updates a template when the new name is valid', async () => { + const result = await service.updateTemplate(1, { name: 'Updated Name' }); + expect(result.success).toBe(true); + expect(repository.update).toHaveBeenCalled(); + }); + + it('rejects an empty name without touching the repository', async () => { + const result = await service.updateTemplate(1, { name: '' }); + expect(result.success).toBe(false); + expect(result.error).toMatch(/name/); + expect(repository.update).not.toHaveBeenCalled(); + }); + + it('leaves name/channelType unvalidated when not present in the update payload', async () => { + const result = await service.updateTemplate(1, { description: 'A short description' }); + expect(result.success).toBe(true); + }); + + it('rejects a non-object defaultValues on update', async () => { + const result = await service.updateTemplate(1, { defaultValues: 'nope' as any }); + expect(result.success).toBe(false); + expect(result.error).toMatch(/defaultValues/); + expect(repository.update).not.toHaveBeenCalled(); + }); +}); diff --git a/listener/src/services/template-service.ts b/listener/src/services/template-service.ts index 24127ce..ee2c6d9 100644 --- a/listener/src/services/template-service.ts +++ b/listener/src/services/template-service.ts @@ -18,6 +18,61 @@ import { ChannelNotificationTemplate, TemplateChannelType, } from '../types/notification-template'; +import { InputValidator, isNonEmptyString, isOneOf, isPlainObject } from '../utils/validation'; + +const MAX_NAME_LENGTH = 255; +const MAX_DESCRIPTION_LENGTH = 1000; + +/** Validates the request-shaped fields of a template (name/channelType/description/variables/defaultValues). Content validation stays in TemplateValidator. */ +function validateTemplateFields(input: { + name?: string; + channelType?: TemplateChannelType; + description?: string; + variables?: string[]; + defaultValues?: Record; +}, requireRequiredFields: boolean): void { + const v = new InputValidator(); + + if (requireRequiredFields || input.name !== undefined) { + v.check(isNonEmptyString(input.name), 'name', 'is required and must be a non-empty string'); + if (typeof input.name === 'string') { + v.check(input.name.length <= MAX_NAME_LENGTH, 'name', `must not exceed ${MAX_NAME_LENGTH} characters`); + } + } + + if (requireRequiredFields || input.channelType !== undefined) { + v.check( + isOneOf(input.channelType, Object.values(TemplateChannelType)), + 'channelType', + `must be one of: ${Object.values(TemplateChannelType).join(', ')}`, + ); + } + + if (input.description !== undefined && input.description !== null) { + v.check(typeof input.description === 'string', 'description', 'must be a string'); + if (typeof input.description === 'string') { + v.check( + input.description.length <= MAX_DESCRIPTION_LENGTH, + 'description', + `must not exceed ${MAX_DESCRIPTION_LENGTH} characters`, + ); + } + } + + if (input.variables !== undefined) { + v.check( + Array.isArray(input.variables) && input.variables.every((item) => typeof item === 'string'), + 'variables', + 'must be an array of strings', + ); + } + + if (input.defaultValues !== undefined) { + v.check(isPlainObject(input.defaultValues), 'defaultValues', 'must be an object'); + } + + v.throwIfInvalid(); +} export class TemplateService { constructor(private repository: TemplateRepository) {} @@ -32,6 +87,9 @@ export class TemplateService { error?: string; }> { try { + // Validate request-shaped fields before touching content validation or the repository + validateTemplateFields(input, true); + // Validate unique key format const keyValidation = TemplateValidator.validateUniqueKey(input.uniqueKey); if (!keyValidation.valid) { @@ -104,6 +162,9 @@ export class TemplateService { error?: string; }> { try { + // Validate request-shaped fields before touching the repository + validateTemplateFields(input, false); + // Get existing template const existing = await this.repository.getById(id); if (!existing) { diff --git a/listener/src/store/preference-store.test.ts b/listener/src/store/preference-store.test.ts index a64d31a..ae39915 100644 --- a/listener/src/store/preference-store.test.ts +++ b/listener/src/store/preference-store.test.ts @@ -1,4 +1,5 @@ import { PreferenceStore } from './preference-store'; +import { ValidationError } from '../utils/validation'; describe('PreferenceStore', () => { let store: PreferenceStore; @@ -53,6 +54,39 @@ describe('PreferenceStore', () => { expect(store.get('user-2').categories.discord).toBe(false); expect(store.get('user-2').categories.discord).toBe(false); }); + + it('rejects a missing categories object', () => { + expect(() => store.update('user-1', {} as any)).toThrow(ValidationError); + expect(() => store.update('user-1', { categories: null } as any)).toThrow(ValidationError); + }); + + it('rejects a categories value that is an array instead of an object', () => { + expect(() => store.update('user-1', { categories: ['discord'] as any })).toThrow(ValidationError); + }); + + it('rejects non-boolean category values with a message naming the offending category', () => { + expect(() => store.update('user-1', { categories: { discord: 'yes' } as any })).toThrow( + ValidationError, + ); + try { + store.update('user-1', { categories: { discord: 'yes' } as any }); + throw new Error('expected update to throw'); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).issues).toContainEqual({ + field: 'categories.discord', + message: 'must be a boolean, received "yes"', + }); + } + }); + + it('does not persist a partially-invalid update', () => { + store.update('user-3', { categories: { discord: true } }); + expect(() => + store.update('user-3', { categories: { discord: true, email: 'nope' } as any }), + ).toThrow(ValidationError); + expect(store.get('user-3').categories).toEqual({ discord: true }); + }); }); describe('isCategoryEnabled', () => { diff --git a/listener/src/store/preference-store.ts b/listener/src/store/preference-store.ts index cec143d..d04a124 100644 --- a/listener/src/store/preference-store.ts +++ b/listener/src/store/preference-store.ts @@ -1,4 +1,5 @@ import { UserPreferences, PreferencesUpdateInput } from '../types/preferences'; +import { InputValidator, isBoolean, isNonEmptyString, isPlainObject } from '../utils/validation'; export class PreferenceStore { private store = new Map(); @@ -17,8 +18,18 @@ export class PreferenceStore { return { ...stored, categories: { ...stored.categories } }; } - /** Merges category updates, returns updated preferences */ + /** Merges category updates, returns updated preferences. Throws ValidationError on invalid input. */ update(userId: string, input: PreferencesUpdateInput): UserPreferences { + const v = new InputValidator(); + v.check(isNonEmptyString(userId), 'userId', 'is required'); + if (v.check(isPlainObject(input?.categories), 'categories', 'must be an object of category name to boolean')) { + for (const [category, enabled] of Object.entries(input.categories)) { + v.check(isNonEmptyString(category), 'categories', 'category names must be non-empty strings'); + v.check(isBoolean(enabled), `categories.${category}`, `must be a boolean, received ${JSON.stringify(enabled)}`); + } + } + v.throwIfInvalid(); + const existing = this.get(userId); const updated: UserPreferences = { ...existing, diff --git a/listener/src/types/notification-template.ts b/listener/src/types/notification-template.ts index 40153b3..6743d2d 100644 --- a/listener/src/types/notification-template.ts +++ b/listener/src/types/notification-template.ts @@ -97,6 +97,7 @@ export interface ChannelNotificationTemplateRow { updated_by: string | null; } +export interface AuditedNotificationTemplate { export interface NotificationTemplateOld { id: string; name: string; @@ -148,11 +149,11 @@ export interface TemplateAuditRecord { actor: string; action: TemplateAuditAction; changedAt: Date; - previousSnapshot: NotificationTemplate; - newSnapshot: NotificationTemplate; + previousSnapshot: AuditedNotificationTemplate; + newSnapshot: AuditedNotificationTemplate; } -export interface NotificationTemplateRow { +export interface AuditedNotificationTemplateRow { id: string; name: string; type: string; diff --git a/listener/src/utils/validation.test.ts b/listener/src/utils/validation.test.ts new file mode 100644 index 0000000..cec0185 --- /dev/null +++ b/listener/src/utils/validation.test.ts @@ -0,0 +1,167 @@ +import { + InputValidator, + ValidationError, + isPlainObject, + isNonEmptyString, + isBoolean, + isFiniteNumber, + isInteger, + isNonNegativeInteger, + isPositiveInteger, + isInRange, + isOneOf, + isValidDate, + parseOptionalIntParam, + parseOptionalDateParam, + validationErrorBody, +} from './validation'; + +describe('type guards', () => { + it('isPlainObject accepts plain objects and rejects arrays, null, and primitives', () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ a: 1 })).toBe(true); + expect(isPlainObject([])).toBe(false); + expect(isPlainObject(null)).toBe(false); + expect(isPlainObject('x')).toBe(false); + expect(isPlainObject(undefined)).toBe(false); + }); + + it('isNonEmptyString rejects empty and whitespace-only strings', () => { + expect(isNonEmptyString('hello')).toBe(true); + expect(isNonEmptyString('')).toBe(false); + expect(isNonEmptyString(' ')).toBe(false); + expect(isNonEmptyString(123)).toBe(false); + expect(isNonEmptyString(null)).toBe(false); + }); + + it('isBoolean only accepts actual booleans', () => { + expect(isBoolean(true)).toBe(true); + expect(isBoolean(false)).toBe(true); + expect(isBoolean('true')).toBe(false); + expect(isBoolean(1)).toBe(false); + }); + + it('isFiniteNumber rejects NaN and Infinity', () => { + expect(isFiniteNumber(5)).toBe(true); + expect(isFiniteNumber(-5.5)).toBe(true); + expect(isFiniteNumber(NaN)).toBe(false); + expect(isFiniteNumber(Infinity)).toBe(false); + expect(isFiniteNumber('5')).toBe(false); + }); + + it('isInteger, isNonNegativeInteger, isPositiveInteger enforce whole-number bounds', () => { + expect(isInteger(5)).toBe(true); + expect(isInteger(5.5)).toBe(false); + expect(isNonNegativeInteger(0)).toBe(true); + expect(isNonNegativeInteger(-1)).toBe(false); + expect(isPositiveInteger(0)).toBe(false); + expect(isPositiveInteger(1)).toBe(true); + }); + + it('isInRange is inclusive on both ends', () => { + expect(isInRange(1, 1, 10)).toBe(true); + expect(isInRange(10, 1, 10)).toBe(true); + expect(isInRange(0, 1, 10)).toBe(false); + expect(isInRange(11, 1, 10)).toBe(false); + }); + + it('isOneOf checks membership in an allowed list', () => { + expect(isOneOf('a', ['a', 'b'] as const)).toBe(true); + expect(isOneOf('c', ['a', 'b'] as const)).toBe(false); + }); + + it('isValidDate accepts Date instances and parseable strings, rejects garbage', () => { + expect(isValidDate(new Date())).toBe(true); + expect(isValidDate('2026-07-24T00:00:00.000Z')).toBe(true); + expect(isValidDate('not-a-date')).toBe(false); + expect(isValidDate(new Date('invalid'))).toBe(false); + expect(isValidDate(null)).toBe(false); + expect(isValidDate({})).toBe(false); + }); +}); + +describe('InputValidator', () => { + it('does not throw when every check passes', () => { + const v = new InputValidator(); + v.check(true, 'name', 'required'); + expect(() => v.throwIfInvalid()).not.toThrow(); + expect(v.hasIssues()).toBe(false); + }); + + it('collects every failing field and throws a single ValidationError', () => { + const v = new InputValidator(); + v.check(false, 'name', 'is required'); + v.check(true, 'age', 'must be a number'); + v.check(false, 'email', 'must be valid'); + + expect(v.hasIssues()).toBe(true); + expect(v.getIssues()).toEqual([ + { field: 'name', message: 'is required' }, + { field: 'email', message: 'must be valid' }, + ]); + + try { + v.throwIfInvalid(); + throw new Error('expected throwIfInvalid to throw'); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).issues).toHaveLength(2); + } + }); +}); + +describe('parseOptionalIntParam', () => { + it('returns undefined for absent values', () => { + expect(parseOptionalIntParam(null, 'limit')).toBeUndefined(); + expect(parseOptionalIntParam('', 'limit')).toBeUndefined(); + }); + + it('parses valid integers', () => { + expect(parseOptionalIntParam('50', 'limit')).toBe(50); + expect(parseOptionalIntParam('0', 'offset')).toBe(0); + }); + + it('rejects non-integer values with a meaningful message', () => { + expect(() => parseOptionalIntParam('abc', 'limit')).toThrow(ValidationError); + expect(() => parseOptionalIntParam('12.5', 'limit')).toThrow(ValidationError); + try { + parseOptionalIntParam('abc', 'limit'); + } catch (err) { + expect((err as ValidationError).issues[0]).toEqual({ + field: 'limit', + message: "must be an integer, received 'abc'", + }); + } + }); + + it('enforces min/max bounds', () => { + expect(() => parseOptionalIntParam('-1', 'limit', { min: 0 })).toThrow(ValidationError); + expect(() => parseOptionalIntParam('101', 'limit', { max: 100 })).toThrow(ValidationError); + expect(parseOptionalIntParam('100', 'limit', { min: 0, max: 100 })).toBe(100); + }); +}); + +describe('parseOptionalDateParam', () => { + it('returns undefined for absent values', () => { + expect(parseOptionalDateParam(null, 'startDate')).toBeUndefined(); + expect(parseOptionalDateParam(undefined, 'startDate')).toBeUndefined(); + }); + + it('passes through valid date strings', () => { + expect(parseOptionalDateParam('2026-01-01', 'startDate')).toBe('2026-01-01'); + }); + + it('rejects invalid date strings', () => { + expect(() => parseOptionalDateParam('not-a-date', 'startDate')).toThrow(ValidationError); + }); +}); + +describe('validationErrorBody', () => { + it('formats a ValidationError as a JSON-ready body', () => { + const error = new ValidationError([{ field: 'name', message: 'is required' }]); + expect(validationErrorBody(error)).toEqual({ + error: 'Validation failed', + details: [{ field: 'name', message: 'is required' }], + }); + }); +}); diff --git a/listener/src/utils/validation.ts b/listener/src/utils/validation.ts new file mode 100644 index 0000000..d8c7e72 --- /dev/null +++ b/listener/src/utils/validation.ts @@ -0,0 +1,145 @@ +/** + * Shared input validation helpers. + * + * Services and API handlers use these to reject invalid input before doing + * any processing, and to report *why* the input was rejected in a form that + * is safe to return to a caller. + */ + +export interface ValidationIssue { + field: string; + message: string; +} + +/** + * Thrown when one or more fields fail validation. Carries every failing + * field (not just the first) so callers can report a complete, actionable + * error message in a single response. + */ +export class ValidationError extends Error { + readonly issues: ValidationIssue[]; + + constructor(issues: ValidationIssue[] | ValidationIssue) { + const list = Array.isArray(issues) ? issues : [issues]; + super(list.map((issue) => `${issue.field}: ${issue.message}`).join('; ')); + this.name = 'ValidationError'; + this.issues = list; + } +} + +/** Accumulates field-level validation issues and throws them together. */ +export class InputValidator { + private issues: ValidationIssue[] = []; + + /** Records an issue if `condition` is false. Returns `condition` so checks can short-circuit dependent rules. */ + check(condition: boolean, field: string, message: string): boolean { + if (!condition) { + this.issues.push({ field, message }); + } + return condition; + } + + hasIssues(): boolean { + return this.issues.length > 0; + } + + getIssues(): ValidationIssue[] { + return [...this.issues]; + } + + /** Throws a ValidationError containing every recorded issue, if any were recorded. */ + throwIfInvalid(): void { + if (this.issues.length > 0) { + throw new ValidationError(this.issues); + } + } +} + +export function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +export function isBoolean(value: unknown): value is boolean { + return typeof value === 'boolean'; +} + +export function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +export function isInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value); +} + +export function isNonNegativeInteger(value: unknown): value is number { + return isInteger(value) && value >= 0; +} + +export function isPositiveInteger(value: unknown): value is number { + return isInteger(value) && value > 0; +} + +export function isInRange(value: number, min: number, max: number): boolean { + return value >= min && value <= max; +} + +export function isOneOf(value: unknown, allowed: readonly T[]): value is T { + return (allowed as readonly unknown[]).includes(value); +} + +/** True for a value that parses to a real calendar date, whether given as a Date or a string/number Date() accepts. */ +export function isValidDate(value: unknown): boolean { + if (value instanceof Date) { + return !Number.isNaN(value.getTime()); + } + if (typeof value === 'string' || typeof value === 'number') { + return !Number.isNaN(new Date(value).getTime()); + } + return false; +} + +/** Parses a query-string integer parameter, returning undefined when absent and throwing ValidationError when present-but-invalid. */ +export function parseOptionalIntParam( + raw: string | null, + field: string, + options: { min?: number; max?: number } = {}, +): number | undefined { + if (raw === null || raw === undefined || raw === '') { + return undefined; + } + const parsed = Number(raw); + if (!Number.isInteger(parsed)) { + throw new ValidationError({ field, message: `must be an integer, received '${raw}'` }); + } + if (options.min !== undefined && parsed < options.min) { + throw new ValidationError({ field, message: `must be >= ${options.min}` }); + } + if (options.max !== undefined && parsed > options.max) { + throw new ValidationError({ field, message: `must be <= ${options.max}` }); + } + return parsed; +} + +/** Parses an optional ISO-ish date query/body parameter, throwing ValidationError when present-but-invalid. */ +export function parseOptionalDateParam(raw: string | null | undefined, field: string): string | undefined { + if (raw === null || raw === undefined || raw === '') { + return undefined; + } + if (!isValidDate(raw)) { + throw new ValidationError({ field, message: `must be a valid date, received '${raw}'` }); + } + return raw; +} + +/** Standard shape for reporting a ValidationError over HTTP. */ +export function validationErrorBody(error: ValidationError): { error: string; details: ValidationIssue[] } { + return { error: 'Validation failed', details: error.issues }; +}