diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2be2154e..29766b23 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -158,6 +158,18 @@ services: - ./:/usr/src/app - workers-deps:/usr/src/app/node_modules + hawk-worker-webhook: + build: + dockerfile: "dev.Dockerfile" + context: . + env_file: + - .env + restart: unless-stopped + entrypoint: yarn run-webhook + volumes: + - ./:/usr/src/app + - workers-deps:/usr/src/app/node_modules + volumes: workers-deps: diff --git a/package.json b/package.json index a053c5b8..175b7bc5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hawk.workers", "private": true, - "version": "0.1.2", + "version": "0.1.3", "description": "Hawk workers", "repository": "git@github.com:codex-team/hawk.workers.git", "license": "BUSL-1.1", @@ -34,6 +34,7 @@ "test:notifier": "jest workers/notifier", "test:js": "jest workers/javascript", "test:task-manager": "jest workers/task-manager", + "test:webhook": "jest workers/webhook", "test:clear": "jest --clearCache", "run-default": "yarn worker hawk-worker-default", "run-sentry": "yarn worker hawk-worker-sentry", @@ -49,13 +50,14 @@ "run-email": "yarn worker hawk-worker-email", "run-telegram": "yarn worker hawk-worker-telegram", "run-limiter": "yarn worker hawk-worker-limiter", - "run-task-manager": "yarn worker hawk-worker-task-manager" + "run-task-manager": "yarn worker hawk-worker-task-manager", + "run-webhook": "yarn worker hawk-worker-webhook" }, "dependencies": { "@babel/parser": "^7.26.9", "@babel/traverse": "7.26.9", "@hawk.so/nodejs": "^3.1.1", - "@hawk.so/types": "^0.5.7", + "@hawk.so/types": "^0.5.9", "@types/amqplib": "^0.8.2", "@types/jest": "^29.5.14", "@types/mongodb": "^3.5.15", diff --git a/workers/email/tests/provider.test.ts b/workers/email/tests/provider.test.ts index d9fe9f40..f83a59d0 100644 --- a/workers/email/tests/provider.test.ts +++ b/workers/email/tests/provider.test.ts @@ -10,7 +10,7 @@ nodemailerMock.createTransport = jest.fn(() => ({ jest.mock('nodemailer', () => nodemailerMock); -import { DecodedGroupedEvent, ProjectDBScheme } from '@hawk.so/types'; +import { DecodedGroupedEvent, ProjectDBScheme, UserDBScheme } from '@hawk.so/types'; import '../src/env'; import EmailProvider from '../src/provider'; import Templates from '../src/templates/names'; @@ -187,6 +187,11 @@ describe('EmailProvider', () => { password: '$argon2i$v=19$m=4096,t=3,p=1$QOo3u8uEor0t+nqCLpEW3g$aTCDEaHht9ro9VZDD1yZGpaTi+g1OWsfHbYd5TQBRPs', name: 'Hahahawk', }, + assignee: { + _id: new ObjectId('5ec3ffe769c0030022f88f25'), + email: 'assignee@email.ru', + name: 'Assignee User', + } as UserDBScheme, project: { _id: new ObjectId('5d206f7f9aaf7c0071d64596'), token: 'project-token', diff --git a/workers/notifier/types/channel.ts b/workers/notifier/types/channel.ts index 001a1f27..832d34d3 100644 --- a/workers/notifier/types/channel.ts +++ b/workers/notifier/types/channel.ts @@ -6,6 +6,7 @@ export enum ChannelType { Telegram = 'telegram', Slack = 'slack', Loop = 'loop', + Webhook = 'webhook', } /** diff --git a/workers/sender/src/index.ts b/workers/sender/src/index.ts index c4c243b9..242d2da8 100644 --- a/workers/sender/src/index.ts +++ b/workers/sender/src/index.ts @@ -253,6 +253,7 @@ export default abstract class SenderWorker extends Worker { project, event, whoAssigned, + assignee, daysRepeated, }, } as AssigneeNotification); diff --git a/workers/sender/types/template-variables/assignee.ts b/workers/sender/types/template-variables/assignee.ts index 9f4a29ae..b4af9125 100644 --- a/workers/sender/types/template-variables/assignee.ts +++ b/workers/sender/types/template-variables/assignee.ts @@ -21,6 +21,11 @@ export interface AssigneeTemplateVariables extends CommonTemplateVariables { */ whoAssigned: UserDBScheme; + /** + * User who was assigned to resolve the event + */ + assignee: UserDBScheme; + /** * Number of event repetitions */ diff --git a/workers/webhook/package.json b/workers/webhook/package.json new file mode 100644 index 00000000..91c8035d --- /dev/null +++ b/workers/webhook/package.json @@ -0,0 +1,11 @@ +{ + "name": "hawk-worker-webhook", + "version": "1.0.0", + "description": "Webhook sender worker — delivers event notifications as JSON POST requests", + "main": "src/index.ts", + "license": "MIT", + "workerType": "sender/webhook", + "scripts": { + "test": "jest" + } +} diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts new file mode 100644 index 00000000..5e5a104b --- /dev/null +++ b/workers/webhook/src/deliverer.ts @@ -0,0 +1,125 @@ +import https from 'https'; +import http from 'http'; +import { createLogger, format, Logger, transports } from 'winston'; +import { WebhookDelivery } from '../types/template'; +import { HttpStatusCode, MS_IN_SEC } from '../../../lib/utils/consts'; + +/** + * How many seconds to wait for a webhook response before aborting + */ +const DELIVERY_TIMEOUT_SEC = 10; + +/** + * Timeout for webhook delivery in milliseconds + */ +const DELIVERY_TIMEOUT_MS = MS_IN_SEC * DELIVERY_TIMEOUT_SEC; + +/** + * Deliverer sends JSON POST requests to external webhook endpoints. + * + * SSRF validation is performed at the API layer when the endpoint is saved — + * this class trusts the stored URL and only handles delivery. + */ +export default class WebhookDeliverer { + /** + * Logger module + * (default level='info') + */ + private logger: Logger = createLogger({ + level: process.env.LOG_LEVEL || 'info', + transports: [ + new transports.Console({ + format: format.combine( + format.timestamp(), + format.colorize(), + format.simple(), + format.printf((msg) => `${msg.timestamp} - ${msg.level}: ${msg.message}`) + ), + }), + ], + }); + + /** + * Sends webhook delivery to the endpoint via HTTP POST. + * Adds X-Hawk-Notification header with the notification type (similar to GitHub's X-GitHub-Event). + * + * @param endpoint - URL to POST to + * @param delivery - webhook delivery { type, payload } + */ + public async deliver(endpoint: string, delivery: WebhookDelivery): Promise { + let url: URL; + + try { + url = new URL(endpoint); + } catch { + this.logger.log('error', `Webhook delivery skipped — invalid URL: ${endpoint}`); + + return; + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + this.logger.log('error', `Webhook delivery skipped — unsupported protocol: ${url.protocol}`); + + return; + } + + const body = JSON.stringify(delivery); + const transport = url.protocol === 'https:' ? https : http; + + return new Promise((resolve) => { + const req = transport.request( + url, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Hawk-Webhook/1.0', + 'X-Hawk-Notification': delivery.type, + 'Content-Length': Buffer.byteLength(body), + }, + timeout: DELIVERY_TIMEOUT_MS, + }, + (res) => { + res.resume(); + + const status = res.statusCode || 0; + + const isRedirect = ( + status === HttpStatusCode.MovedPermanently || + status === HttpStatusCode.Found || + status === HttpStatusCode.SeeOther || + status === HttpStatusCode.TemporaryRedirect || + status === HttpStatusCode.PermanentRedirect + ) && res.headers.location; + + if (isRedirect) { + this.logger.log('error', `Webhook blocked — redirect ${status} to ${res.headers.location} from ${endpoint}`); + resolve(); + + return; + } + + if (status >= HttpStatusCode.BadRequest) { + this.logger.log('error', `Webhook delivery failed: ${status} ${res.statusMessage} for ${endpoint}`); + } + + resolve(); + } + ); + + req.on('error', (e) => { + this.logger.log('error', `Can't deliver webhook to ${endpoint}: ${e.message}`); + resolve(); + }); + + req.on('timeout', () => { + this.logger.log('error', `Webhook delivery timed out for ${endpoint}`); + req.destroy(); + resolve(); + }); + + req.write(body); + req.end(); + }); + } +} diff --git a/workers/webhook/src/index.ts b/workers/webhook/src/index.ts new file mode 100644 index 00000000..f826bb02 --- /dev/null +++ b/workers/webhook/src/index.ts @@ -0,0 +1,24 @@ +import * as pkg from './../package.json'; +import WebhookProvider from './provider'; +import SenderWorker from 'hawk-worker-sender/src'; +import { ChannelType } from 'hawk-worker-notifier/types/channel'; + +/** + * Worker to send webhook notifications + */ +export default class WebhookSenderWorker extends SenderWorker { + /** + * Worker type + */ + public readonly type: string = pkg.workerType; + + /** + * Webhook channel type + */ + protected channelType = ChannelType.Webhook; + + /** + * Webhook provider + */ + protected provider = new WebhookProvider(); +} diff --git a/workers/webhook/src/provider.ts b/workers/webhook/src/provider.ts new file mode 100644 index 00000000..f894141e --- /dev/null +++ b/workers/webhook/src/provider.ts @@ -0,0 +1,34 @@ +import NotificationsProvider from 'hawk-worker-sender/src/provider'; +import { Notification } from 'hawk-worker-sender/types/template-variables'; +import { toDelivery } from './templates'; +import WebhookDeliverer from './deliverer'; + +/** + * Webhook notification provider. + * Supports all notification types via a single generic serializer — + * type comes from notification.type, payload is sanitized automatically. + */ +export default class WebhookProvider extends NotificationsProvider { + /** + * Class with the 'deliver' method for sending HTTP POST requests + */ + private readonly deliverer: WebhookDeliverer; + + constructor() { + super(); + + this.deliverer = new WebhookDeliverer(); + } + + /** + * Send webhook notification to recipient + * + * @param to - recipient endpoint URL + * @param notification - notification with payload and type + */ + public async send(to: string, notification: Notification): Promise { + const delivery = toDelivery(notification); + + await this.deliverer.deliver(to, delivery); + } +} diff --git a/workers/webhook/src/templates/generic.ts b/workers/webhook/src/templates/generic.ts new file mode 100644 index 00000000..f2fcabf3 --- /dev/null +++ b/workers/webhook/src/templates/generic.ts @@ -0,0 +1,187 @@ +import { Notification, TemplateEventData } from 'hawk-worker-sender/types/template-variables'; +import { + ProjectDBScheme, + WorkspaceDBScheme, + UserDBScheme, + DecodedGroupedEvent, + PlanDBScheme, +} from '@hawk.so/types'; +import { WebhookDelivery } from '../../types/template'; + +/** + * Projects safe public fields from a project document + * + * @param p - project DB record + */ +function projectDTO(p: ProjectDBScheme): Record { + return { + id: String(p._id), + name: p.name, + workspaceId: String(p.workspaceId), + image: p.image ?? null, + }; +} + +/** + * Projects safe public fields from a workspace document + * + * @param w - workspace DB record + */ +function workspaceDTO(w: WorkspaceDBScheme): Record { + return { + id: String(w._id), + name: w.name, + image: w.image ?? null, + }; +} + +/** + * Projects safe public fields from a user document (no password, no bank cards) + * + * @param u - user DB record + */ +function userDTO(u: UserDBScheme): Record { + return { + id: String(u._id), + name: u.name ?? null, + email: u.email ?? null, + image: u.image ?? null, + }; +} + +/** + * Projects safe public fields from a grouped event (no sourceCode, no breadcrumbs, no addons) + * + * @param e - decoded grouped event + */ +function eventDTO(e: DecodedGroupedEvent): Record { + return { + id: e._id ? String(e._id) : null, + groupHash: e.groupHash, + totalCount: e.totalCount, + catcherType: e.catcherType, + timestamp: e.timestamp, + usersAffected: e.usersAffected, + title: e.payload.title, + type: e.payload.type ?? null, + backtrace: (e.payload.backtrace ?? []).map((f) => ({ + file: f.file, + line: f.line, + column: f.column ?? null, + function: f.function ?? null, + })), + }; +} + +/** + * Projects event list item with its metadata (newCount, daysRepeated, etc.) + * + * @param item - template event data from sender worker + */ +function templateEventDataDTO(item: TemplateEventData): Record { + return { + event: eventDTO(item.event), + newCount: item.newCount, + daysRepeated: item.daysRepeated, + usersAffected: item.usersAffected ?? null, + repetitionId: item.repetitionId ? String(item.repetitionId) : null, + }; +} + +/** + * Projects safe public fields from a plan document + * + * @param p - plan DB record + */ +function planDTO(p: PlanDBScheme): Record { + return { + id: String(p._id), + name: p.name, + eventsLimit: p.eventsLimit, + monthlyCharge: p.monthlyCharge, + }; +} + +type PayloadProjector = (payload: Record) => Record; + +const projectors: Record = { + 'event': (p) => ({ + project: p.project ? projectDTO(p.project as ProjectDBScheme) : null, + events: ((p.events ?? []) as TemplateEventData[]).map(templateEventDataDTO), + period: p.period ?? null, + }), + + 'several-events': (p) => ({ + project: p.project ? projectDTO(p.project as ProjectDBScheme) : null, + events: ((p.events ?? []) as TemplateEventData[]).map(templateEventDataDTO), + period: p.period ?? null, + }), + + 'assignee': (p) => ({ + project: p.project ? projectDTO(p.project as ProjectDBScheme) : null, + event: p.event ? eventDTO(p.event as DecodedGroupedEvent) : null, + assignedBy: p.whoAssigned ? userDTO(p.whoAssigned as UserDBScheme) : null, + assignee: p.assignee ? userDTO(p.assignee as UserDBScheme) : null, + daysRepeated: p.daysRepeated ?? null, + }), + + 'block-workspace': (p) => ({ + workspace: p.workspace ? workspaceDTO(p.workspace as WorkspaceDBScheme) : null, + }), + + 'blocked-workspace-reminder': (p) => ({ + workspace: p.workspace ? workspaceDTO(p.workspace as WorkspaceDBScheme) : null, + daysAfterBlock: p.daysAfterBlock ?? null, + }), + + 'days-limit-almost-reached': (p) => ({ + workspace: p.workspace ? workspaceDTO(p.workspace as WorkspaceDBScheme) : null, + daysLeft: p.daysLeft ?? null, + }), + + 'events-limit-almost-reached': (p) => ({ + workspace: p.workspace ? workspaceDTO(p.workspace as WorkspaceDBScheme) : null, + eventsCount: p.eventsCount ?? null, + eventsLimit: p.eventsLimit ?? null, + }), + + 'payment-failed': (p) => ({ + workspace: p.workspace ? workspaceDTO(p.workspace as WorkspaceDBScheme) : null, + reason: p.reason ?? null, + }), + + 'payment-success': (p) => ({ + workspace: p.workspace ? workspaceDTO(p.workspace as WorkspaceDBScheme) : null, + plan: p.plan ? planDTO(p.plan as PlanDBScheme) : null, + }), + + 'sign-up': (p) => ({ + email: p.email ?? null, + }), + + 'password-reset': () => ({}), + + 'workspace-invite': (p) => ({ + workspaceName: p.workspaceName ?? null, + inviteLink: p.inviteLink ?? null, + }), +}; + +/** + * Whitelist-based webhook template — each notification type has an explicit + * DTO projector that picks only safe, relevant fields. + * Unknown types produce an empty payload (fail-closed). + * + * @param notification - notification with type and payload + */ +export default function render(notification: Notification): WebhookDelivery { + const projector = projectors[notification.type]; + const payload = projector + ? projector(notification.payload as unknown as Record) + : {}; + + return { + type: notification.type, + payload, + }; +} diff --git a/workers/webhook/src/templates/index.ts b/workers/webhook/src/templates/index.ts new file mode 100644 index 00000000..cce548d0 --- /dev/null +++ b/workers/webhook/src/templates/index.ts @@ -0,0 +1 @@ +export { default as toDelivery } from './generic'; diff --git a/workers/webhook/tests/__mocks__/assignee-notify.ts b/workers/webhook/tests/__mocks__/assignee-notify.ts new file mode 100644 index 00000000..2ce8ce3e --- /dev/null +++ b/workers/webhook/tests/__mocks__/assignee-notify.ts @@ -0,0 +1,37 @@ +import { AssigneeNotification } from 'hawk-worker-sender/types/template-variables'; +import { ObjectId } from 'mongodb'; + +/** + * Example of assignee notify template variables + */ +export default { + type: 'assignee', + payload: { + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + event: { + totalCount: 5, + groupHash: 'abc123', + payload: { + title: 'TypeError: Cannot read property', + }, + }, + whoAssigned: { + name: 'John Doe', + email: 'john@example.com', + }, + assignee: { + name: 'Jane Smith', + email: 'jane@example.com', + }, + daysRepeated: 3, + }, +} as AssigneeNotification; diff --git a/workers/webhook/tests/__mocks__/event-notify.ts b/workers/webhook/tests/__mocks__/event-notify.ts new file mode 100644 index 00000000..77a53b54 --- /dev/null +++ b/workers/webhook/tests/__mocks__/event-notify.ts @@ -0,0 +1,43 @@ +import { EventNotification } from 'hawk-worker-sender/types/template-variables'; +import { ObjectId } from 'mongodb'; + +/** + * Example of new-events notify template variables + */ +export default { + type: 'event', + payload: { + events: [ + { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + }, + daysRepeated: 1, + newCount: 1, + }, + ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + }, +} as EventNotification; diff --git a/workers/webhook/tests/__mocks__/several-events-notify.ts b/workers/webhook/tests/__mocks__/several-events-notify.ts new file mode 100644 index 00000000..24ed30e8 --- /dev/null +++ b/workers/webhook/tests/__mocks__/several-events-notify.ts @@ -0,0 +1,63 @@ +import { SeveralEventsNotification } from 'hawk-worker-sender/types/template-variables'; +import { GroupedEventDBScheme } from '@hawk.so/types'; +import { ObjectId } from 'mongodb'; + +/** + * Example of several-events notify template variables + */ +export default { + type: 'several-events', + payload: { + events: [ + { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + } as GroupedEventDBScheme, + daysRepeated: 1, + newCount: 1, + }, + { + event: { + totalCount: 5, + payload: { + title: 'New event 2', + timestamp: Date.now(), + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + }, + daysRepeated: 100, + newCount: 1, + }, + ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + }, +} as SeveralEventsNotification; diff --git a/workers/webhook/tests/provider.test.ts b/workers/webhook/tests/provider.test.ts new file mode 100644 index 00000000..878d2335 --- /dev/null +++ b/workers/webhook/tests/provider.test.ts @@ -0,0 +1,430 @@ +import EventNotifyMock from './__mocks__/event-notify'; +import SeveralEventsNotifyMock from './__mocks__/several-events-notify'; +import AssigneeNotifyMock from './__mocks__/assignee-notify'; +import WebhookProvider from '../src/provider'; +import { Notification } from 'hawk-worker-sender/types/template-variables'; +import { ObjectId } from 'mongodb'; + +/** + * The sample of a webhook endpoint + */ +const webhookEndpointSample = 'https://example.com/hawk-webhook'; + +/** + * Mock the 'deliver' method of WebhookDeliverer + */ +const deliver = jest.fn(); + +/** + * Webhook Deliverer mock + */ +jest.mock('../src/deliverer', () => { + return jest.fn().mockImplementation(() => { + return { + deliver: deliver, + }; + }); +}); + +/** + * Clear all records of mock calls between tests + */ +afterEach(() => { + jest.clearAllMocks(); +}); + +/** + * All notification types supported by the system + */ +const ALL_NOTIFICATION_TYPES = [ + 'event', + 'several-events', + 'assignee', + 'block-workspace', + 'blocked-workspace-reminder', + 'payment-failed', + 'payment-success', + 'days-limit-almost-reached', + 'events-limit-almost-reached', + 'sign-up', + 'password-reset', + 'workspace-invite', +] as const; + +/** + * Helper to extract delivered payload + */ +function getDeliveredPayload(): Record { + return deliver.mock.calls[0][1].payload as Record; +} + +describe('WebhookProvider', () => { + it('should deliver a message with { type, payload } structure', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, EventNotifyMock); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith(webhookEndpointSample, expect.objectContaining({ + type: 'event', + payload: expect.any(Object), + })); + }); + + it('should only have { type, payload } keys at root level', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, EventNotifyMock); + + const delivery = deliver.mock.calls[0][1]; + + expect(Object.keys(delivery).sort()).toEqual(['payload', 'type']); + }); + + it.each([ + ['event', EventNotifyMock], + ['several-events', SeveralEventsNotifyMock], + ['assignee', AssigneeNotifyMock], + ] as const)('should preserve type "%s" from mock notification', async (expectedType, mock) => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, mock); + + expect(deliver.mock.calls[0][1].type).toBe(expectedType); + }); + + it.each( + ALL_NOTIFICATION_TYPES.map((type) => [type]) + )('should handle "%s" notification without throwing', async (type) => { + const provider = new WebhookProvider(); + + await expect( + provider.send(webhookEndpointSample, { + type, + payload: { projectName: 'Test' }, + } as unknown as Notification) + ).resolves.toBeUndefined(); + + const delivery = deliver.mock.calls[0][1]; + + expect(delivery.type).toBe(type); + expect(delivery.payload).toBeDefined(); + }); + + describe('whitelist DTO behavior', () => { + it('should only include whitelisted project fields for "event" type', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'event', + payload: { + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + name: 'My Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + image: 'img.png', + token: 'secret-token', + notifications: [{ rule: 'data' }], + integrationId: 'uuid', + uidAdded: 'user-id', + }, + events: [], + period: 60, + host: 'https://garage.hawk.so', + hostOfStatic: 'https://api.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + const project = payload.project as Record; + + expect(Object.keys(project).sort()).toEqual(['id', 'image', 'name', 'workspaceId']); + expect(project.name).toBe('My Project'); + expect(project.id).toBe('5d206f7f9aaf7c0071d64596'); + expect(project.image).toBe('img.png'); + }); + + it('should strip sourceCode from event backtrace', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, EventNotifyMock); + + const payload = getDeliveredPayload(); + const events = payload.events as Array>; + const firstEvent = events[0].event as Record; + const backtrace = firstEvent.backtrace as Array>; + + expect(backtrace[0]).not.toHaveProperty('sourceCode'); + expect(backtrace[0]).toHaveProperty('file', 'file'); + expect(backtrace[0]).toHaveProperty('line', 1); + }); + + it('should never leak host/hostOfStatic from event payload', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, EventNotifyMock); + + const payload = getDeliveredPayload(); + + expect(payload).not.toHaveProperty('host'); + expect(payload).not.toHaveProperty('hostOfStatic'); + }); + + it('should return only email for "sign-up" (no password)', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'sign-up', + payload: { + email: 'john@example.com', + password: 'super-secret-password', + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toEqual({ email: 'john@example.com' }); + expect(payload).not.toHaveProperty('password'); + expect(payload).not.toHaveProperty('host'); + }); + + it('should return empty payload for "password-reset"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'password-reset', + payload: { + password: 'new-secret', + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toEqual({}); + }); + + it('should return empty payload for unknown notification type', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'totally-unknown-type', + payload: { + secret: 'should-not-leak', + token: 'also-secret', + }, + } as unknown as Notification); + + const delivery = deliver.mock.calls[0][1]; + + expect(delivery.type).toBe('totally-unknown-type'); + expect(delivery.payload).toEqual({}); + }); + + it('should include workspace DTO for "block-workspace"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'block-workspace', + payload: { + workspace: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + name: 'My Workspace', + image: 'ws.png', + balance: 1000, + accountId: 'acc-123', + }, + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + const workspace = payload.workspace as Record; + + expect(Object.keys(workspace).sort()).toEqual(['id', 'image', 'name']); + expect(workspace.name).toBe('My Workspace'); + expect(workspace).not.toHaveProperty('balance'); + expect(workspace).not.toHaveProperty('accountId'); + }); + + it('should include workspace and plan DTOs for "payment-success"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'payment-success', + payload: { + workspace: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + name: 'Ws', + }, + plan: { + _id: new ObjectId('5d206f7f9aaf7c0071d64597'), + name: 'Pro', + eventsLimit: 50000, + monthlyCharge: 999, + }, + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toHaveProperty('workspace'); + expect(payload).toHaveProperty('plan'); + + const plan = payload.plan as Record; + + expect(plan).toEqual({ + id: '5d206f7f9aaf7c0071d64597', + name: 'Pro', + eventsLimit: 50000, + monthlyCharge: 999, + }); + }); + + it('should include assignedBy, assignee and event DTOs for "assignee"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, AssigneeNotifyMock); + + const payload = getDeliveredPayload(); + const assignedBy = payload.assignedBy as Record; + const assignee = payload.assignee as Record; + + expect(assignedBy).toHaveProperty('name', 'John Doe'); + expect(assignedBy).toHaveProperty('email', 'john@example.com'); + expect(assignee).toHaveProperty('name', 'Jane Smith'); + expect(assignee).toHaveProperty('email', 'jane@example.com'); + expect(payload).toHaveProperty('event'); + expect(payload).toHaveProperty('daysRepeated', 3); + }); + + it('should include inviteLink and workspaceName for "workspace-invite"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'workspace-invite', + payload: { + workspaceName: 'My Team', + inviteLink: 'https://hawk.so/invite/abc', + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toEqual({ + workspaceName: 'My Team', + inviteLink: 'https://hawk.so/invite/abc', + }); + }); + + it('should include daysAfterBlock for "blocked-workspace-reminder"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'blocked-workspace-reminder', + payload: { + workspace: { _id: new ObjectId(), name: 'Ws' }, + daysAfterBlock: 7, + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toHaveProperty('daysAfterBlock', 7); + expect(payload).toHaveProperty('workspace'); + }); + + it('should include eventsCount and eventsLimit for "events-limit-almost-reached"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'events-limit-almost-reached', + payload: { + workspace: { _id: new ObjectId(), name: 'Ws' }, + eventsCount: 9500, + eventsLimit: 10000, + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toHaveProperty('eventsCount', 9500); + expect(payload).toHaveProperty('eventsLimit', 10000); + }); + + it('should include reason for "payment-failed"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'payment-failed', + payload: { + workspace: { _id: new ObjectId(), name: 'Ws' }, + reason: 'Insufficient funds', + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toHaveProperty('reason', 'Insufficient funds'); + }); + + it('should include daysLeft for "days-limit-almost-reached"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'days-limit-almost-reached', + payload: { + workspace: { _id: new ObjectId(), name: 'Ws' }, + daysLeft: 3, + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toHaveProperty('daysLeft', 3); + }); + + it('should never include sensitive fields regardless of notification type', async () => { + const provider = new WebhookProvider(); + const toxicFields = { + host: 'h', + hostOfStatic: 's', + token: 'secret', + notifications: [], + integrationId: 'id', + notificationRuleId: 'rid', + visitedBy: ['u1'], + uidAdded: 'uid', + password: 'pass', + }; + + for (const type of ALL_NOTIFICATION_TYPES) { + deliver.mockClear(); + + await provider.send(webhookEndpointSample, { + type, + payload: { ...toxicFields }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).not.toHaveProperty('host'); + expect(payload).not.toHaveProperty('hostOfStatic'); + expect(payload).not.toHaveProperty('token'); + expect(payload).not.toHaveProperty('notifications'); + expect(payload).not.toHaveProperty('integrationId'); + expect(payload).not.toHaveProperty('notificationRuleId'); + expect(payload).not.toHaveProperty('visitedBy'); + expect(payload).not.toHaveProperty('uidAdded'); + expect(payload).not.toHaveProperty('password'); + } + }); + }); +}); diff --git a/workers/webhook/types/template.d.ts b/workers/webhook/types/template.d.ts new file mode 100644 index 00000000..a2d06046 --- /dev/null +++ b/workers/webhook/types/template.d.ts @@ -0,0 +1,11 @@ +/** + * Unified root-level structure for all webhook deliveries. + * Every webhook POST body has the same shape: { type, payload }. + */ +export interface WebhookDelivery { + /** Notification type (e.g. 'event', 'several-events', 'assignee', 'payment-failed', ...) */ + type: string; + + /** Notification-specific payload — structure depends on the type */ + payload: Record; +} diff --git a/yarn.lock b/yarn.lock index 2232af0a..3172b2bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -402,10 +402,10 @@ dependencies: "@types/mongodb" "^3.5.34" -"@hawk.so/types@^0.5.7": - version "0.5.7" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.5.7.tgz#964af0ad780998820801708292dcb06c390fb9a3" - integrity sha512-ccKVQkFcgTpIe9VVQiz94mCXvGk0Zn6uDyHONlXpS3E0j037eISaw2wUsddzAzKntXYbnFQbxah+zmx35KkVtA== +"@hawk.so/types@^0.5.9": + version "0.5.9" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.5.9.tgz#817e8b26283d0367371125f055f2e37a274797bc" + integrity sha512-86aE0Bdzvy8C+Dqd1iZpnDho44zLGX/t92SGuAv2Q52gjSJ7SHQdpGDWtM91FXncfT5uzAizl9jYMuE6Qrtm0Q== dependencies: bson "^7.0.0"