From f4abaecf4a8f6cc3ba863dc007274c8db4280513 Mon Sep 17 00:00:00 2001 From: Nwokedi Chinelo Date: Sat, 27 Jun 2026 15:18:10 +0100 Subject: [PATCH 1/4] feat(utils): add maskWebhookUrl helper to strip secrets from webhook URLs (#444) Returns scheme://host only; removes path, query string, and fragment. Returns '[invalid url]' on parse failure. --- src/utils/webhook-mask.utils.test.ts | 31 ++++++++++++++++++++++++++++ src/utils/webhook-mask.utils.ts | 24 +++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/utils/webhook-mask.utils.test.ts create mode 100644 src/utils/webhook-mask.utils.ts diff --git a/src/utils/webhook-mask.utils.test.ts b/src/utils/webhook-mask.utils.test.ts new file mode 100644 index 0000000..ad40494 --- /dev/null +++ b/src/utils/webhook-mask.utils.test.ts @@ -0,0 +1,31 @@ +import { maskWebhookUrl } from './webhook-mask.utils'; + +describe('maskWebhookUrl', () => { + it('strips query string containing a secret', () => { + expect(maskWebhookUrl('https://api.example.com/hook?token=secret')).toBe('https://api.example.com'); + }); + + it('strips path segments', () => { + expect(maskWebhookUrl('https://hooks.slack.com/services/T123/B456/xyzxyz')).toBe('https://hooks.slack.com'); + }); + + it('returns the URL as-is when there is no path or query string', () => { + expect(maskWebhookUrl('http://localhost:3000')).toBe('http://localhost:3000'); + }); + + it('returns [invalid url] for a non-URL string', () => { + expect(maskWebhookUrl('not a url')).toBe('[invalid url]'); + }); + + it('returns [invalid url] for an empty string', () => { + expect(maskWebhookUrl('')).toBe('[invalid url]'); + }); + + it('strips path and preserves non-standard port', () => { + expect(maskWebhookUrl('https://internal.corp:8443/webhooks/receiver?auth=abc')).toBe('https://internal.corp:8443'); + }); + + it('strips fragment identifiers', () => { + expect(maskWebhookUrl('https://example.com/path#section')).toBe('https://example.com'); + }); +}); diff --git a/src/utils/webhook-mask.utils.ts b/src/utils/webhook-mask.utils.ts new file mode 100644 index 0000000..f9fe4c7 --- /dev/null +++ b/src/utils/webhook-mask.utils.ts @@ -0,0 +1,24 @@ +/** + * Strips everything after the host from a webhook URL, returning only + * `scheme://host` (or `scheme://host:port` when a non-standard port is + * present). This prevents secrets embedded in paths or query strings from + * leaking into log output. + * + * @example + * maskWebhookUrl('https://api.example.com/hook?token=secret') + * // → 'https://api.example.com' + * + * maskWebhookUrl('https://hooks.slack.com/services/T123/B456/xyzxyz') + * // → 'https://hooks.slack.com' + * + * maskWebhookUrl('not a url') + * // → '[invalid url]' + */ +export function maskWebhookUrl(url: string): string { + try { + const parsed = new URL(url); + return parsed.origin; + } catch { + return '[invalid url]'; + } +} From 9b281a56534444d562df7f01d431bef713b630b4 Mon Sep 17 00:00:00 2001 From: Nwokedi Chinelo Date: Sat, 27 Jun 2026 15:18:10 +0100 Subject: [PATCH 2/4] feat(webhooks): add buildWebhookPayload helper and structured registration/deletion logs (#449 #450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildWebhookPayload(TradeEvent) centralises the TradeEvent→WebhookEventPayload mapping; used in dispatchWebhookEvent to replace the inline object literal - logger.info emitted on successful webhook creation and deletion with creator_id, webhook_id, event_types/deleted_at; callback URL is never logged --- .../webhooks/webhook-payload.utils.test.ts | 74 +++++++++++++++++++ src/modules/webhooks/webhook-payload.utils.ts | 13 ++++ src/modules/webhooks/webhook.service.ts | 23 +++--- 3 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 src/modules/webhooks/webhook-payload.utils.test.ts create mode 100644 src/modules/webhooks/webhook-payload.utils.ts diff --git a/src/modules/webhooks/webhook-payload.utils.test.ts b/src/modules/webhooks/webhook-payload.utils.test.ts new file mode 100644 index 0000000..0e96c48 --- /dev/null +++ b/src/modules/webhooks/webhook-payload.utils.test.ts @@ -0,0 +1,74 @@ +import { buildWebhookPayload } from './webhook-payload.utils'; +import type { TradeEvent, WebhookEventPayload } from './webhook.types'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeTradeEvent(overrides: Partial = {}): TradeEvent { + return { + type: 'buy', + creatorId: 'creator-1', + buyerOrSellerAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + amount: '10', + price: '50', + feePaid: '1', + timestamp: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('buildWebhookPayload', () => { + it('maps all fields correctly for a buy event', () => { + const event = makeTradeEvent({ type: 'buy' }); + const payload = buildWebhookPayload(event); + + expect(payload).toEqual({ + event_type: 'buy', + creator_id: 'creator-1', + buyer_or_seller_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + amount: '10', + price: '50', + fee_paid: '1', + timestamp: '2026-01-01T00:00:00.000Z', + }); + }); + + it('sets event_type to sell for a sell event and maps fields correctly', () => { + const event = makeTradeEvent({ + type: 'sell', + creatorId: 'creator-2', + buyerOrSellerAddress: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + amount: '5', + price: '200', + feePaid: '2', + timestamp: '2026-06-15T12:00:00.000Z', + }); + const payload = buildWebhookPayload(event); + + expect(payload.event_type).toBe('sell'); + expect(payload.creator_id).toBe('creator-2'); + expect(payload.buyer_or_seller_address).toBe('GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'); + expect(payload.amount).toBe('5'); + expect(payload.price).toBe('200'); + expect(payload.fee_paid).toBe('2'); + expect(payload.timestamp).toBe('2026-06-15T12:00:00.000Z'); + }); + + it('produces no extra keys beyond the WebhookEventPayload contract', () => { + const event = makeTradeEvent(); + const payload = buildWebhookPayload(event); + + const expectedKeys: Array = [ + 'event_type', + 'creator_id', + 'buyer_or_seller_address', + 'amount', + 'price', + 'fee_paid', + 'timestamp', + ]; + + expect(Object.keys(payload).sort()).toEqual(expectedKeys.sort()); + }); +}); diff --git a/src/modules/webhooks/webhook-payload.utils.ts b/src/modules/webhooks/webhook-payload.utils.ts new file mode 100644 index 0000000..920986c --- /dev/null +++ b/src/modules/webhooks/webhook-payload.utils.ts @@ -0,0 +1,13 @@ +import type { TradeEvent, WebhookEventPayload } from './webhook.types'; + +export function buildWebhookPayload(event: TradeEvent): WebhookEventPayload { + return { + event_type: event.type, + creator_id: event.creatorId, + buyer_or_seller_address: event.buyerOrSellerAddress, + amount: event.amount, + price: event.price, + fee_paid: event.feePaid, + timestamp: event.timestamp, + }; +} diff --git a/src/modules/webhooks/webhook.service.ts b/src/modules/webhooks/webhook.service.ts index bc5dbb4..7390b16 100644 --- a/src/modules/webhooks/webhook.service.ts +++ b/src/modules/webhooks/webhook.service.ts @@ -1,6 +1,8 @@ import { prisma } from '../../utils/prisma.utils'; import { logger } from '../../utils/logger.utils'; import { envConfig } from '../../config'; +import { maskWebhookUrl } from '../../utils/webhook-mask.utils'; +import { buildWebhookPayload } from './webhook-payload.utils'; import type { CreateWebhookInput, TradeEvent, WebhookEventPayload, WebhookEventName } from './webhook.types'; function normalizeEvents(events: string[]): ('BUY' | 'SELL')[] { @@ -38,6 +40,11 @@ export async function createWebhook( }, }); + logger.info( + { creator_id: creatorId, webhook_id: webhook.id, event_types: input.events, registered_at: webhook.createdAt.toISOString() }, + 'Webhook registered' + ); + return { ...webhook, events: denormalizeEvents(webhook.events as ('BUY' | 'SELL')[]), @@ -66,6 +73,12 @@ export async function deleteWebhook(webhookId: string, creatorId: string) { } await prisma.webhook.delete({ where: { id: webhookId } }); + + logger.info( + { creator_id: creatorId, webhook_id: webhookId, deleted_at: new Date().toISOString() }, + 'Webhook deleted' + ); + return { id: webhookId }; } @@ -85,15 +98,7 @@ export async function dispatchWebhookEvent(tradeEvent: TradeEvent) { if (webhooks.length === 0) return; for (const webhook of webhooks) { - const payload: WebhookEventPayload = { - event_type: tradeEvent.type, - creator_id: tradeEvent.creatorId, - buyer_or_seller_address: tradeEvent.buyerOrSellerAddress, - amount: tradeEvent.amount, - price: tradeEvent.price, - fee_paid: tradeEvent.feePaid, - timestamp: tradeEvent.timestamp, - }; + const payload: WebhookEventPayload = buildWebhookPayload(tradeEvent); await prisma.webhookEvent.create({ data: { From d8d6e0624cd5b1856eabfedfa8deb91a37b39a06 Mon Sep 17 00:00:00 2001 From: Nwokedi Chinelo Date: Sat, 27 Jun 2026 15:18:10 +0100 Subject: [PATCH 3/4] feat(wallets): add GET /wallets/:address/holdings endpoint and empty-wallet integration test (#442) Add wallet-holdings.service.ts, wallet-holdings.controllers.ts, and register GET /:address/holdings in wallets.routes.ts. Integration test covers empty wallet returning 200+[] and malformed address returning 400. --- .../wallet-holdings.integration.test.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 src/modules/wallets/wallet-holdings.integration.test.ts diff --git a/src/modules/wallets/wallet-holdings.integration.test.ts b/src/modules/wallets/wallet-holdings.integration.test.ts new file mode 100644 index 0000000..8313012 --- /dev/null +++ b/src/modules/wallets/wallet-holdings.integration.test.ts @@ -0,0 +1,171 @@ +// Integration test: wallet holdings endpoint (#442) +// +// Covers: empty wallet → 200 + empty array, wallet with holdings → 200 + data, +// malformed address → 400. +// Uses Jest mocks — no database required. + +import { httpGetWalletHoldings } from './wallet-holdings.controllers'; +import * as walletHoldingsService from './wallet-holdings.service'; +import { HoldingItem } from './wallet-holdings.service'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MALFORMED_ADDRESS = 'not-a-stellar-address'; + +function makeReq(params: Record = {}): any { + return { params }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +function makeHolding(overrides: Partial = {}): HoldingItem { + return { + id: 'holding-1', + ownerAddress: VALID_ADDRESS, + creatorId: 'creator-1', + balance: '10', + currentPrice: '0', + updatedAt: new Date('2026-01-01T00:00:00Z'), + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /wallets/:address/holdings', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Empty wallet ────────────────────────────────────────────────────────── + + it('returns 200 with empty holdings array and total_portfolio_value of 0 for a wallet with no holdings', async () => { + jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue({ + holdings: [], + total_portfolio_value: '0', + }); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.holdings).toEqual([]); + expect(body.data.total_portfolio_value).toBe('0'); + }); + + // ── Wallet with holdings ────────────────────────────────────────────────── + + it('returns 200 with populated holdings for a wallet with positions', async () => { + const holdings: HoldingItem[] = [ + makeHolding({ creatorId: 'creator-1', balance: '5', currentPrice: '100' }), + makeHolding({ id: 'holding-2', creatorId: 'creator-2', balance: '3', currentPrice: '50' }), + ]; + jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue({ + holdings, + total_portfolio_value: '650', + }); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.holdings).toHaveLength(2); + expect(body.data.total_portfolio_value).toBe('650'); + }); + + it('each holding item includes required fields', async () => { + const holding = makeHolding({ + id: 'holding-abc', + ownerAddress: VALID_ADDRESS, + creatorId: 'creator-xyz', + balance: '7', + currentPrice: '0', + updatedAt: new Date('2026-03-01T00:00:00Z'), + }); + jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue({ + holdings: [holding], + total_portfolio_value: '0', + }); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const item = res.json.mock.calls[0][0].data.holdings[0]; + expect(item).toMatchObject({ + id: 'holding-abc', + ownerAddress: VALID_ADDRESS, + creatorId: 'creator-xyz', + balance: '7', + currentPrice: '0', + }); + expect(item.updatedAt).toBeDefined(); + }); + + it('passes the address to the service', async () => { + const spy = jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue({ + holdings: [], + total_portfolio_value: '0', + }); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(spy).toHaveBeenCalledWith(VALID_ADDRESS); + }); + + // ── Malformed address → 400 ─────────────────────────────────────────────── + + it('returns 400 for a malformed Stellar address', async () => { + const req = makeReq({ address: MALFORMED_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('does not call the service when the address is invalid', async () => { + const spy = jest.spyOn(walletHoldingsService, 'fetchWalletHoldings'); + + const req = makeReq({ address: MALFORMED_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(spy).not.toHaveBeenCalled(); + }); + + // ── Error forwarding ────────────────────────────────────────────────────── + + it('forwards service errors to next()', async () => { + const err = new Error('db down'); + jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockRejectedValue(err); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + const next = makeNext(); + await httpGetWalletHoldings(req, res, next); + + expect(next).toHaveBeenCalledWith(err); + }); +}); From cad9fb3db4a24d3f68f2ad9ca03ca131dcadd435 Mon Sep 17 00:00:00 2001 From: Nwokedi Chinelo Date: Sat, 27 Jun 2026 15:18:10 +0100 Subject: [PATCH 4/4] fix(lint): use maskWebhookUrl in delivery retry/failure log entries Add masked callback URL to warn and error log fields so delivery failures include the endpoint origin without leaking tokens embedded in paths or query strings. --- src/modules/webhooks/webhook.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/webhooks/webhook.service.ts b/src/modules/webhooks/webhook.service.ts index 7390b16..9bf2116 100644 --- a/src/modules/webhooks/webhook.service.ts +++ b/src/modules/webhooks/webhook.service.ts @@ -159,6 +159,7 @@ async function attemptDelivery( { webhook_id: webhookId, creator_id: payload.creator_id, + callback_url: maskWebhookUrl(callbackUrl), attempt_number: attempt + 1, backoff_delay_ms: delay, last_error_code: errMsg,