diff --git a/lib/utils/validateEventStructure.test.ts b/lib/utils/validateEventStructure.test.ts new file mode 100644 index 00000000..ad865242 --- /dev/null +++ b/lib/utils/validateEventStructure.test.ts @@ -0,0 +1,296 @@ +import { validateEventStructure } from './validateEventStructure'; + +describe('validateEventStructure', () => { + describe('Basic type validation', () => { + test('should reject null payload', () => { + const result = validateEventStructure(null); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Invalid payload type: null'); + }); + + test('should reject array payload', () => { + const result = validateEventStructure([]); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('expected object, got array'); + }); + + test('should reject string payload', () => { + const result = validateEventStructure('invalid'); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('expected object, got string'); + }); + + test('should reject number payload', () => { + const result = validateEventStructure(123); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('expected object, got number'); + }); + + test('should reject undefined payload', () => { + const result = validateEventStructure(undefined); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('expected object, got undefined'); + }); + }); + + describe('Required fields validation', () => { + test('should reject payload without title', () => { + const result = validateEventStructure({ + backtrace: [], + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('Event title is required'); + }); + + test('should reject payload with empty title', () => { + const result = validateEventStructure({ + title: '', + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('Event title is required'); + }); + + test('should reject payload with non-string title', () => { + const result = validateEventStructure({ + title: 123, + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('title'); + }); + + test('should accept payload with only title', () => { + const result = validateEventStructure({ + title: 'Error occurred', + }); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); + + describe('Optional fields validation', () => { + test('should accept valid payload with all optional fields', () => { + const result = validateEventStructure({ + title: 'TypeError: Cannot read property', + type: 'TypeError', + backtrace: [ + { + file: '/path/to/file.js', + line: 42, + column: 10, + function: 'myFunction', + }, + ], + breadcrumbs: [ + { + timestamp: Date.now(), + type: 'navigation', + message: 'User navigated to /home', + }, + ], + release: 'v1.0.0', + user: { + id: 'user123', + name: 'John Doe', + }, + context: { + customKey: 'customValue', + }, + addons: { + vue: { + version: '3.0.0', + }, + }, + catcherVersion: '3.2.0', + }); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + test('should accept payload with optional backtrace', () => { + const result = validateEventStructure({ + title: 'Error', + backtrace: [ + { + file: 'file.js', + line: 1, + }, + ], + }); + + expect(result.isValid).toBe(true); + }); + + test('should accept payload with optional breadcrumbs', () => { + const result = validateEventStructure({ + title: 'Error', + breadcrumbs: [ + { + timestamp: 1234567890, + message: 'User clicked button', + }, + ], + }); + + expect(result.isValid).toBe(true); + }); + + test('should accept payload with optional user', () => { + const result = validateEventStructure({ + title: 'Error', + user: { + id: 'user123', + }, + }); + + expect(result.isValid).toBe(true); + }); + + test('should accept payload with context as object', () => { + const result = validateEventStructure({ + title: 'Error', + context: { + key: 'value', + }, + }); + + expect(result.isValid).toBe(true); + }); + + test('should accept payload with context as string', () => { + const result = validateEventStructure({ + title: 'Error', + context: 'string context', + }); + + expect(result.isValid).toBe(true); + }); + + test('should accept payload with addons', () => { + const result = validateEventStructure({ + title: 'Error', + addons: { + vue: { + componentName: 'MyComponent', + }, + }, + }); + + expect(result.isValid).toBe(true); + }); + }); + + describe('Type validation for optional fields', () => { + test('should reject backtrace if not array', () => { + const result = validateEventStructure({ + title: 'Error', + backtrace: 'invalid', + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('backtrace'); + }); + + test('should reject breadcrumbs if not array', () => { + const result = validateEventStructure({ + title: 'Error', + breadcrumbs: 'invalid', + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('breadcrumbs'); + }); + + test('should reject breadcrumbs with missing timestamp', () => { + const result = validateEventStructure({ + title: 'Error', + breadcrumbs: [ + { + message: 'test', + }, + ], + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('timestamp'); + }); + + test('should reject release if not string', () => { + const result = validateEventStructure({ + title: 'Error', + release: 123, + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('release'); + }); + + test('should reject type if not string', () => { + const result = validateEventStructure({ + title: 'Error', + type: 123, + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('type'); + }); + }); + + describe('Edge cases', () => { + test('should accept empty backtrace array', () => { + const result = validateEventStructure({ + title: 'Error', + backtrace: [], + }); + + expect(result.isValid).toBe(true); + }); + + test('should accept empty breadcrumbs array', () => { + const result = validateEventStructure({ + title: 'Error', + breadcrumbs: [], + }); + + expect(result.isValid).toBe(true); + }); + + test('should accept empty context object', () => { + const result = validateEventStructure({ + title: 'Error', + context: {}, + }); + + expect(result.isValid).toBe(true); + }); + + test('should accept empty addons object', () => { + const result = validateEventStructure({ + title: 'Error', + addons: {}, + }); + + expect(result.isValid).toBe(true); + }); + + test('should handle multiple validation errors', () => { + const result = validateEventStructure({ + title: 123, + backtrace: 'invalid', + breadcrumbs: 'invalid', + }); + + expect(result.isValid).toBe(false); + expect(result.error).toBeTruthy(); + }); + }); +}); diff --git a/lib/utils/validateEventStructure.ts b/lib/utils/validateEventStructure.ts new file mode 100644 index 00000000..7e30e098 --- /dev/null +++ b/lib/utils/validateEventStructure.ts @@ -0,0 +1,137 @@ +import * as yup from 'yup'; +import type { AffectedUser, BacktraceFrame, Breadcrumb } from '@hawk.so/types'; + +/** + * Schema for sourceCode line (BacktraceFrame.sourceCode item) + */ +const sourceCodeLineSchema = yup.object({ + line: yup.number().strict(true).required(), + content: yup.string().strict(true).required(), +}); + +/** + * Schema for BacktraceFrame + */ +const backtraceFrameSchema: yup.ObjectSchema> = yup.object({ + file: yup.string().strict(true).optional(), + line: yup.number().strict(true).optional(), + column: yup.number().strict(true).optional(), + function: yup.string().strict(true).optional(), + sourceCode: yup.array().of(sourceCodeLineSchema).optional(), + arguments: yup.array().of(yup.string().strict(true)).optional(), +}); + +/** + * Schema for Breadcrumb + */ +const breadcrumbSchema: yup.ObjectSchema> = yup.object({ + timestamp: yup.number().strict(true).required(), + type: yup.string().strict(true).optional(), + category: yup.string().strict(true).optional(), + message: yup.string().strict(true).optional(), + level: yup.string().strict(true).optional(), + data: yup.object().optional(), +}); + +/** + * Schema for AffectedUser + */ +const affectedUserSchema: yup.ObjectSchema> = yup.object({ + id: yup.string().strict(true).optional(), + name: yup.string().strict(true).optional(), + photo: yup.string().strict(true).optional(), + url: yup.string().strict(true).optional(), +}); + +/** + * Event payload validation schema + * Validates the structure and types of event data + */ +const eventDataSchema = yup.object({ + /** + * Title is required and non-empty after trim + */ + title: yup.string().strict(true).trim().min(1, 'Event title is required').required('Event title is required'), + + /** + * Optional fields + */ + type: yup.string().strict(true).optional(), + backtrace: yup.array().of(backtraceFrameSchema).optional(), + breadcrumbs: yup.array().of(breadcrumbSchema).optional(), + addons: yup.object().strict(true).optional(), + release: yup.string().strict(true).optional(), + user: affectedUserSchema.optional(), + context: yup.mixed().optional(), + catcherVersion: yup.string().strict(true).optional(), +}); + +/** + * Validation result + */ +export interface ValidationResult { + /** + * Whether the validation passed + */ + isValid: boolean; + + /** + * Error message if validation failed + */ + error?: string; +} + +/** + * Validates event structure according to EventData schema. + * Rejects invalid payload types (e.g. payload: true from beforeSend) so they are not + * persisted; otherwise GraphQL returns null for EventPayload.title and frontend breaks. + * + * @param payload - Event payload to validate + * @returns Validation result with isValid flag and optional error message + */ +export function validateEventStructure(payload: unknown): ValidationResult { + /** + * Explicit null check for human-readable message (typeof null === 'object') + */ + if (payload === null) { + return { + isValid: false, + error: 'Invalid payload type: null', + }; + } + + /** + * Payload must be a non-array object + */ + if (typeof payload !== 'object' || Array.isArray(payload)) { + return { + isValid: false, + error: `Invalid payload type: expected object, got ${Array.isArray(payload) ? 'array' : typeof payload}`, + }; + } + + /** + * Validate against schema (strict: no type coercion) + */ + try { + eventDataSchema.validateSync(payload, { abortEarly: false, strict: true }); + + return { + isValid: true, + }; + } catch (error) { + if (error instanceof yup.ValidationError) { + const errors = error.errors.join('; '); + + return { + isValid: false, + error: `Event validation failed: ${errors}`, + }; + } + + return { + isValid: false, + error: `Unexpected validation error: ${String(error)}`, + }; + } +} diff --git a/workers/grouper/src/index.ts b/workers/grouper/src/index.ts index 73f16fc7..fe8100fc 100644 --- a/workers/grouper/src/index.ts +++ b/workers/grouper/src/index.ts @@ -24,6 +24,7 @@ import RedisHelper from './redisHelper'; import { computeDelta } from './utils/repetitionDiff'; import { rightTrim } from '../../../lib/utils/string'; import { hasValue } from '../../../lib/utils/hasValue'; +import { validateEventStructure } from '../../../lib/utils/validateEventStructure'; /* eslint-disable-next-line no-unused-vars */ import { memoize } from '../../../lib/memoize'; @@ -105,6 +106,14 @@ export default class GrouperWorker extends Worker { * @param task - event to handle */ public async handle(task: GroupWorkerTask): Promise { + const validation = validateEventStructure(task.payload); + + if (!validation.isValid) { + this.logger.error(`${validation.error}. Event rejected.`); + + return; + } + let uniqueEventHash = await this.getUniqueEventHash(task); // FIX RELEASE TYPE diff --git a/workers/javascript/src/index.ts b/workers/javascript/src/index.ts index 84fae857..4c99d2a5 100644 --- a/workers/javascript/src/index.ts +++ b/workers/javascript/src/index.ts @@ -12,6 +12,7 @@ import HawkCatcher from '@hawk.so/nodejs'; import { BacktraceFrame, CatcherMessagePayload, CatcherMessageType, ErrorsCatcherType, SourceCodeLine, SourceMapDataExtended } from '@hawk.so/types'; import { beautifyUserAgent, getFunctionContext } from './utils'; import { Collection } from 'mongodb'; +import { validateEventStructure } from '../../../lib/utils/validateEventStructure'; /* eslint-disable-next-line no-unused-vars */ import { memoize } from '../../../lib/memoize'; @@ -71,6 +72,14 @@ export default class JavascriptEventWorker extends EventWorker { * @param event - event to handle */ public async handle(event: JavaScriptEventWorkerTask): Promise { + const validation = validateEventStructure(event.payload); + + if (!validation.isValid) { + this.logger.error(`${validation.error}. Event rejected.`); + + return; + } + if (event.payload.release && event.payload.backtrace) { this.logger.info('beautifyBacktrace called');