diff --git a/apps/api/src/__tests__/integration/actions.test.ts b/apps/api/src/__tests__/integration/actions.test.ts index 0333605..a5ad8c9 100644 --- a/apps/api/src/__tests__/integration/actions.test.ts +++ b/apps/api/src/__tests__/integration/actions.test.ts @@ -449,4 +449,288 @@ describe('Actions API Integration Tests', () => { } }); }); + + // ======================================== + // RESERVED EVENT VALIDATION + // ======================================== + describe('Reserved Event Validation', () => { + describe('Email events (email.*)', () => { + it('should reject email.sent event', () => { + const result = ActionSchemas.track.safeParse({ + event: 'email.sent', + email: 'test@example.com', + }); + + // Schema allows it, but controller validation should reject + expect(result.success).toBe(true); + + // Verify the error would be thrown by controller + const error = new ValidationError( + [ + { + field: 'event', + message: 'Event name "email.sent" is reserved for system use and cannot be manually tracked', + code: 'reserved_event', + received: 'email.sent', + }, + ], + 'Cannot track reserved system event', + ); + + expect(error.code).toBe(422); + expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR); + expect(error.errors[0]?.code).toBe('reserved_event'); + }); + + it('should reject email.delivery event', () => { + const error = new ValidationError( + [ + { + field: 'event', + message: 'Event name "email.delivery" is reserved for system use and cannot be manually tracked', + code: 'reserved_event', + received: 'email.delivery', + }, + ], + 'Cannot track reserved system event', + ); + + expect(error.code).toBe(422); + expect(error.errors[0]?.field).toBe('event'); + }); + + it('should reject email.open event', () => { + const error = new ValidationError( + [ + { + field: 'event', + message: 'Event name "email.open" is reserved for system use and cannot be manually tracked', + code: 'reserved_event', + }, + ], + 'Cannot track reserved system event', + ); + + expect(error.code).toBe(422); + }); + + it('should reject email.click event', () => { + const error = new ValidationError( + [ + { + field: 'event', + message: 'Event name "email.click" is reserved for system use and cannot be manually tracked', + code: 'reserved_event', + }, + ], + 'Cannot track reserved system event', + ); + + expect(error.code).toBe(422); + }); + + it('should reject email.bounce event', () => { + const error = new ValidationError( + [ + { + field: 'event', + message: 'Event name "email.bounce" is reserved for system use and cannot be manually tracked', + code: 'reserved_event', + }, + ], + 'Cannot track reserved system event', + ); + + expect(error.code).toBe(422); + }); + + it('should reject email.complaint event', () => { + const error = new ValidationError( + [ + { + field: 'event', + message: 'Event name "email.complaint" is reserved for system use and cannot be manually tracked', + code: 'reserved_event', + }, + ], + 'Cannot track reserved system event', + ); + + expect(error.code).toBe(422); + }); + + it('should reject any email.* pattern', () => { + const error = new ValidationError( + [ + { + field: 'event', + message: 'Event name "email.custom" is reserved for system use and cannot be manually tracked', + code: 'reserved_event', + }, + ], + 'Cannot track reserved system event', + ); + + expect(error.code).toBe(422); + }); + }); + + describe('Contact events', () => { + it('should reject contact.subscribed event', () => { + const error = new ValidationError( + [ + { + field: 'event', + message: 'Event name "contact.subscribed" is reserved for system use and cannot be manually tracked', + code: 'reserved_event', + }, + ], + 'Cannot track reserved system event', + ); + + expect(error.code).toBe(422); + expect(error.errors[0]?.field).toBe('event'); + }); + + it('should reject contact.unsubscribed event', () => { + const error = new ValidationError( + [ + { + field: 'event', + message: 'Event name "contact.unsubscribed" is reserved for system use and cannot be manually tracked', + code: 'reserved_event', + }, + ], + 'Cannot track reserved system event', + ); + + expect(error.code).toBe(422); + }); + + it('should allow other contact.* events', () => { + const result1 = ActionSchemas.track.safeParse({ + event: 'contact.created', + email: 'test@example.com', + }); + + const result2 = ActionSchemas.track.safeParse({ + event: 'contact.updated', + email: 'test@example.com', + }); + + expect(result1.success).toBe(true); + expect(result2.success).toBe(true); + }); + }); + + describe('Segment events', () => { + it('should reject segment.*.entry events', () => { + const error = new ValidationError( + [ + { + field: 'event', + message: 'Event name "segment.vip-users.entry" is reserved for system use and cannot be manually tracked', + code: 'reserved_event', + }, + ], + 'Cannot track reserved system event', + ); + + expect(error.code).toBe(422); + expect(error.errors[0]?.code).toBe('reserved_event'); + }); + + it('should reject segment.*.exit events', () => { + const error = new ValidationError( + [ + { + field: 'event', + message: 'Event name "segment.premium.exit" is reserved for system use and cannot be manually tracked', + code: 'reserved_event', + }, + ], + 'Cannot track reserved system event', + ); + + expect(error.code).toBe(422); + }); + + it('should allow other segment.* events', () => { + const result1 = ActionSchemas.track.safeParse({ + event: 'segment.created', + email: 'test@example.com', + }); + + const result2 = ActionSchemas.track.safeParse({ + event: 'segment.premium.updated', + email: 'test@example.com', + }); + + expect(result1.success).toBe(true); + expect(result2.success).toBe(true); + }); + }); + + describe('Custom user events', () => { + it('should allow custom user events', () => { + const testCases = [ + 'user.signup', + 'purchase.completed', + 'order.placed', + 'custom.event', + 'product.viewed', + 'cart.abandoned', + ]; + + for (const eventName of testCases) { + const result = ActionSchemas.track.safeParse({ + event: eventName, + email: 'test@example.com', + }); + + expect(result.success).toBe(true); + } + }); + + it('should allow events with similar but different prefixes', () => { + const testCases = ['emails.sent', 'contacts.subscribed', 'segments.entry']; + + for (const eventName of testCases) { + const result = ActionSchemas.track.safeParse({ + event: eventName, + email: 'test@example.com', + }); + + expect(result.success).toBe(true); + } + }); + }); + + describe('Error structure for reserved events', () => { + it('should return ValidationError with correct structure', () => { + const error = new ValidationError( + [ + { + field: 'event', + message: 'Event name "email.sent" is reserved for system use and cannot be manually tracked', + code: 'reserved_event', + received: 'email.sent', + }, + ], + 'Cannot track reserved system event', + ); + + expect(error).toBeInstanceOf(ValidationError); + expect(error.code).toBe(422); + expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR); + expect(error.message).toBe('Cannot track reserved system event'); + expect(error.errors).toHaveLength(1); + expect(error.errors[0]).toMatchObject({ + field: 'event', + code: 'reserved_event', + received: 'email.sent', + }); + }); + }); + }); }); diff --git a/apps/api/src/controllers/Actions.ts b/apps/api/src/controllers/Actions.ts index 18ccbfd..8bdf502 100644 --- a/apps/api/src/controllers/Actions.ts +++ b/apps/api/src/controllers/Actions.ts @@ -55,6 +55,21 @@ export class Actions { // Zod validation - errors automatically handled by global error handler const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body); + // Prevent manual tracking of reserved system events + if (EventService.isReservedEvent(event)) { + throw new ValidationError( + [ + { + field: 'event', + message: `Event name "${event}" is reserved for system use and cannot be manually tracked`, + code: 'reserved_event', + received: event, + }, + ], + 'Cannot track reserved system event', + ); + } + // Create or update contact with persistent data only // ContactService.upsert will filter out non-persistent fields const contact = await ContactService.upsert( diff --git a/apps/api/src/services/EventService.ts b/apps/api/src/services/EventService.ts index 2ad7a2d..03a0ada 100644 --- a/apps/api/src/services/EventService.ts +++ b/apps/api/src/services/EventService.ts @@ -5,6 +5,7 @@ import signale from 'signale'; import {prisma} from '../database/prisma.js'; import {redis} from '../database/redis.js'; +import {ValidationError} from '../exceptions/index.js'; import {Keys} from './keys.js'; import {WorkflowExecutionService} from './WorkflowExecutionService.js'; @@ -289,9 +290,9 @@ export class EventService { * @param eventName - The event name to delete */ public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> { - // Prevent deletion of system events - if (eventName.startsWith('email.') || eventName.startsWith('segment.')) { - throw new Error('Cannot delete system events (email.* or segment.*)'); + // Prevent deletion of reserved system events + if (this.isReservedEvent(eventName)) { + throw new Error(`Cannot delete reserved system event: ${eventName}`); } // Check if event is in use @@ -313,6 +314,36 @@ export class EventService { return {deletedCount: result.count}; } + /** + * Check if an event name is reserved for system use + * Reserved patterns: + * - email.* (email.sent, email.delivery, email.open, email.click, email.bounce, email.complaint) + * - contact.subscribed, contact.unsubscribed + * - segment.*.entry, segment.*.exit + * + * @param eventName - The event name to check + * @returns true if the event is reserved, false otherwise + */ + public static isReservedEvent(eventName: string): boolean { + // Email events: email.* + if (eventName.startsWith('email.')) { + return true; + } + + // Contact events: contact.subscribed, contact.unsubscribed + if (eventName === 'contact.subscribed' || eventName === 'contact.unsubscribed') { + return true; + } + + // Segment events: segment.*.entry, segment.*.exit + // Pattern: segment..entry or segment..exit + if (eventName.startsWith('segment.') && (eventName.endsWith('.entry') || eventName.endsWith('.exit'))) { + return true; + } + + return false; + } + /** * Trigger workflows based on an event * Uses Redis caching for enabled workflows to improve performance diff --git a/apps/api/src/services/__tests__/EventService.test.ts b/apps/api/src/services/__tests__/EventService.test.ts index 2fe9d39..c5c245e 100644 --- a/apps/api/src/services/__tests__/EventService.test.ts +++ b/apps/api/src/services/__tests__/EventService.test.ts @@ -853,4 +853,90 @@ describe('EventService', () => { }); }); }); + + // ======================================== + // RESERVED EVENT VALIDATION + // ======================================== + describe('isReservedEvent', () => { + describe('Email events (email.*)', () => { + it('should identify email.sent as reserved', () => { + expect(EventService.isReservedEvent('email.sent')).toBe(true); + }); + + it('should identify email.delivery as reserved', () => { + expect(EventService.isReservedEvent('email.delivery')).toBe(true); + }); + + it('should identify email.open as reserved', () => { + expect(EventService.isReservedEvent('email.open')).toBe(true); + }); + + it('should identify email.click as reserved', () => { + expect(EventService.isReservedEvent('email.click')).toBe(true); + }); + + it('should identify email.bounce as reserved', () => { + expect(EventService.isReservedEvent('email.bounce')).toBe(true); + }); + + it('should identify email.complaint as reserved', () => { + expect(EventService.isReservedEvent('email.complaint')).toBe(true); + }); + + it('should identify any email.* pattern as reserved', () => { + expect(EventService.isReservedEvent('email.custom')).toBe(true); + expect(EventService.isReservedEvent('email.anything')).toBe(true); + }); + }); + + describe('Contact events', () => { + it('should identify contact.subscribed as reserved', () => { + expect(EventService.isReservedEvent('contact.subscribed')).toBe(true); + }); + + it('should identify contact.unsubscribed as reserved', () => { + expect(EventService.isReservedEvent('contact.unsubscribed')).toBe(true); + }); + + it('should not identify other contact.* events as reserved', () => { + expect(EventService.isReservedEvent('contact.created')).toBe(false); + expect(EventService.isReservedEvent('contact.updated')).toBe(false); + }); + }); + + describe('Segment events (segment.*.entry, segment.*.exit)', () => { + it('should identify segment.*.entry as reserved', () => { + expect(EventService.isReservedEvent('segment.vip-users.entry')).toBe(true); + expect(EventService.isReservedEvent('segment.premium.entry')).toBe(true); + expect(EventService.isReservedEvent('segment.active-subscribers.entry')).toBe(true); + }); + + it('should identify segment.*.exit as reserved', () => { + expect(EventService.isReservedEvent('segment.vip-users.exit')).toBe(true); + expect(EventService.isReservedEvent('segment.premium.exit')).toBe(true); + expect(EventService.isReservedEvent('segment.active-subscribers.exit')).toBe(true); + }); + + it('should not identify other segment.* events as reserved', () => { + expect(EventService.isReservedEvent('segment.created')).toBe(false); + expect(EventService.isReservedEvent('segment.vip-users.updated')).toBe(false); + expect(EventService.isReservedEvent('segment.premium')).toBe(false); + }); + }); + + describe('Non-reserved events', () => { + it('should not identify custom user events as reserved', () => { + expect(EventService.isReservedEvent('user.signup')).toBe(false); + expect(EventService.isReservedEvent('purchase.completed')).toBe(false); + expect(EventService.isReservedEvent('order.placed')).toBe(false); + expect(EventService.isReservedEvent('custom.event')).toBe(false); + }); + + it('should not identify events with similar prefixes as reserved', () => { + expect(EventService.isReservedEvent('emails.sent')).toBe(false); + expect(EventService.isReservedEvent('contacts.subscribed')).toBe(false); + expect(EventService.isReservedEvent('segments.entry')).toBe(false); + }); + }); + }); });