Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 100 additions & 4 deletions src/modules/wallets/wallet-holdings.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Integration test: wallet holdings endpoint (#511)
// Integration test: wallet holdings endpoint (#511, #442)
//
// Covers: 400 for address that is too short, wrong prefix, invalid characters.
// Asserts the `address` field is identified in the error body and no database
// query is attempted for any invalid input.
// Covers: 400 for address that is too short, wrong prefix, invalid characters;
// empty wallet → 200 + empty array, wallet with holdings → 200 + data.
// Uses Jest mocks — no database required.

import { httpGetWalletHoldings } from './wallet-holdings.controllers';
import * as walletHoldingsService from './wallet-holdings.service';
import { HoldingEntry } from './wallet-holdings.schemas';

// ── Helpers ───────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -38,6 +38,17 @@ function assertInvalidAddress(res: any, serviceSpy: jest.SpyInstance): void {
expect(serviceSpy).not.toHaveBeenCalled();
}

function makeHolding(overrides: Partial<HoldingEntry> = {}): HoldingEntry {
return {
creator_id: 'creator-1',
creator_handle: null,
key_count: '10',
current_price: '0',
total_value: null,
...overrides,
};
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('GET /wallets/:address/holdings', () => {
Expand Down Expand Up @@ -98,4 +109,89 @@ describe('GET /wallets/:address/holdings', () => {
expect(body.data.items).toEqual([]);
expect(body.data.total).toBe(0);
});

// ── Empty wallet ──────────────────────────────────────────────────────────

it('returns 200 with empty items array for a wallet with no holdings', async () => {
jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([[], 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.items).toEqual([]);
expect(body.data.total).toBe(0);
});

// ── Wallet with holdings ──────────────────────────────────────────────────

it('returns 200 with populated items for a wallet with positions', async () => {
const items: HoldingEntry[] = [
makeHolding({ creator_id: 'creator-1', key_count: '5', current_price: '100' }),
makeHolding({ creator_id: 'creator-2', key_count: '3', current_price: '50' }),
];
jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([items, 2]);

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.items).toHaveLength(2);
expect(body.data.total).toBe(2);
});

it('each holding item includes required fields', async () => {
const holding = makeHolding({
creator_id: 'creator-xyz',
creator_handle: 'creator-handle',
key_count: '7',
current_price: '100',
total_value: '700',
});
jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([[holding], 1]);

const req = makeReq({ address: VALID_ADDRESS });
const res = makeRes();
await httpGetWalletHoldings(req, res, makeNext());

const item = res.json.mock.calls[0][0].data.items[0];
expect(item).toMatchObject({
creator_id: 'creator-xyz',
creator_handle: 'creator-handle',
key_count: '7',
current_price: '100',
});
});

it('passes the address to the service', async () => {
const spy = jest
.spyOn(walletHoldingsService, 'fetchWalletHoldings')
.mockResolvedValue([[], 0]);

const req = makeReq({ address: VALID_ADDRESS });
const res = makeRes();
await httpGetWalletHoldings(req, res, makeNext());

expect(spy).toHaveBeenCalledWith(VALID_ADDRESS);
});

// ── 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);
});
});
74 changes: 74 additions & 0 deletions src/modules/webhooks/webhook-payload.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { buildWebhookPayload } from './webhook-payload.utils';
import type { TradeEvent, WebhookEventPayload } from './webhook.types';

// ── Helpers ───────────────────────────────────────────────────────────────────

function makeTradeEvent(overrides: Partial<TradeEvent> = {}): 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<WebhookEventPayload>({
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<keyof WebhookEventPayload> = [
'event_type',
'creator_id',
'buyer_or_seller_address',
'amount',
'price',
'fee_paid',
'timestamp',
];

expect(Object.keys(payload).sort()).toEqual(expectedKeys.sort());
});
});
13 changes: 13 additions & 0 deletions src/modules/webhooks/webhook-payload.utils.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
24 changes: 15 additions & 9 deletions src/modules/webhooks/webhook.service.ts
Original file line number Diff line number Diff line change
@@ -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')[] {
Expand Down Expand Up @@ -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')[]),
Expand Down Expand Up @@ -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 };
}

Expand All @@ -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: {
Expand Down Expand Up @@ -154,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,
Expand Down
31 changes: 31 additions & 0 deletions src/utils/webhook-mask.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
24 changes: 24 additions & 0 deletions src/utils/webhook-mask.utils.ts
Original file line number Diff line number Diff line change
@@ -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]';
}
}
Loading