fix: Prevent manual tracking of internal events that are automatically tracked
This commit is contained in:
@@ -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: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result2 = ActionSchemas.track.safeParse({
|
||||||
|
event: 'contact.updated',
|
||||||
|
email: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result2 = ActionSchemas.track.safeParse({
|
||||||
|
event: 'segment.premium.updated',
|
||||||
|
email: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ export class Actions {
|
|||||||
// Zod validation - errors automatically handled by global error handler
|
// Zod validation - errors automatically handled by global error handler
|
||||||
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
|
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
|
// Create or update contact with persistent data only
|
||||||
// ContactService.upsert will filter out non-persistent fields
|
// ContactService.upsert will filter out non-persistent fields
|
||||||
const contact = await ContactService.upsert(
|
const contact = await ContactService.upsert(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import signale from 'signale';
|
|||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {ValidationError} from '../exceptions/index.js';
|
||||||
import {Keys} from './keys.js';
|
import {Keys} from './keys.js';
|
||||||
|
|
||||||
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
||||||
@@ -289,9 +290,9 @@ export class EventService {
|
|||||||
* @param eventName - The event name to delete
|
* @param eventName - The event name to delete
|
||||||
*/
|
*/
|
||||||
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
|
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
|
||||||
// Prevent deletion of system events
|
// Prevent deletion of reserved system events
|
||||||
if (eventName.startsWith('email.') || eventName.startsWith('segment.')) {
|
if (this.isReservedEvent(eventName)) {
|
||||||
throw new Error('Cannot delete system events (email.* or segment.*)');
|
throw new Error(`Cannot delete reserved system event: ${eventName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if event is in use
|
// Check if event is in use
|
||||||
@@ -313,6 +314,36 @@ export class EventService {
|
|||||||
return {deletedCount: result.count};
|
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.<slug>.entry or segment.<slug>.exit
|
||||||
|
if (eventName.startsWith('segment.') && (eventName.endsWith('.entry') || eventName.endsWith('.exit'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger workflows based on an event
|
* Trigger workflows based on an event
|
||||||
* Uses Redis caching for enabled workflows to improve performance
|
* Uses Redis caching for enabled workflows to improve performance
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user