platform-codebase/features/payments/backend-api/webhooks/nowpayments.webhook.controller.spec.ts
Lilith bd505b5bb2 chore(config): 🔧 Update webhook validation logic for NowPayments payment processing
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-02-19 05:59:42 -08:00

587 lines
20 KiB
TypeScript

/**
* Unit Tests for NOWPaymentsWebhookController
*
* Tests cover:
* - Signature validation (missing header, invalid signature, valid signature)
* - Idempotency (duplicate event detection)
* - Event routing (finished, confirmed, failed, expired, partially_paid, refunded)
* - Transaction status updates
* - Error handling (processing failures)
*/
/**
* IMPORTANT: Set LILITH_PROJECT_ROOT before any module imports.
* The PaymentAnalyticsService uses @lilith/service-registry which requires this env var.
*/
import path from 'path';
if (!process.env.LILITH_PROJECT_ROOT) {
// webhooks -> backend-api -> payments -> features -> codebase -> lilith-platform
process.env.LILITH_PROJECT_ROOT = path.resolve(__dirname, '../../../../..');
}
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { UnauthorizedException, BadRequestException, Logger } from '@nestjs/common';
import type { RawBodyRequest } from '@nestjs/common';
import type { Request } from 'express';
import { NOWPaymentsWebhookController } from './nowpayments.webhook.controller';
import type { NOWPaymentsIPNPayload } from './nowpayments.webhook.controller';
import { TransactionEntity } from '@/src/entities/transaction.entity';
import {
createMockNOWPaymentsProvider,
createMockWebhookEventsService,
createMockPaymentAnalyticsService,
createMockDomainEventsEmitter,
createMockEarningsService,
createMockConfigService,
createMockRepository,
type MockNOWPaymentsProvider,
type MockWebhookEventsService,
type MockPaymentAnalyticsService,
type MockDomainEventsEmitter,
type MockEarningsService,
type MockConfigService,
type MockRepository,
} from '../test/mocks';
const WebhookProcessingStatus = {
PENDING: 'pending',
SUCCESS: 'success',
FAILED: 'failed',
} as const;
function createIPNPayload(overrides: Partial<NOWPaymentsIPNPayload> = {}): NOWPaymentsIPNPayload {
return {
payment_id: 12345,
payment_status: 'finished',
pay_address: '0xabc123',
price_amount: 49.99,
price_currency: 'usd',
pay_amount: 0.025,
pay_currency: 'btc',
order_id: 'tx_user456_tip_1700000000',
order_description: 'Payment',
...overrides,
};
}
function createRawBodyRequest(payload: NOWPaymentsIPNPayload): RawBodyRequest<Request> {
return { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
}
describe('NOWPaymentsWebhookController', () => {
let controller: NOWPaymentsWebhookController;
let nowPaymentsProvider: MockNOWPaymentsProvider;
let webhookEventsService: MockWebhookEventsService;
let paymentAnalytics: MockPaymentAnalyticsService;
let domainEvents: MockDomainEventsEmitter;
let earningsService: MockEarningsService;
let configService: MockConfigService;
let transactionRepository: MockRepository<TransactionEntity>;
beforeEach(() => {
nowPaymentsProvider = createMockNOWPaymentsProvider();
webhookEventsService = createMockWebhookEventsService();
paymentAnalytics = createMockPaymentAnalyticsService();
domainEvents = createMockDomainEventsEmitter();
earningsService = createMockEarningsService();
configService = createMockConfigService();
({ repository: transactionRepository } = createMockRepository<TransactionEntity>());
// Suppress logger output in tests
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {});
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => {});
controller = new NOWPaymentsWebhookController(
nowPaymentsProvider,
paymentAnalytics,
domainEvents,
webhookEventsService,
earningsService,
configService,
transactionRepository,
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Signature Validation', () => {
it('should throw UnauthorizedException when signature header is missing', async () => {
const payload = createIPNPayload();
const req = createRawBodyRequest(payload);
await expect(
controller.handleWebhook(req, '', payload),
).rejects.toThrow(UnauthorizedException);
await expect(
controller.handleWebhook(req, '', payload),
).rejects.toThrow('Missing signature');
});
it('should throw UnauthorizedException when signature is invalid', async () => {
const payload = createIPNPayload();
const req = createRawBodyRequest(payload);
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(false);
await expect(
controller.handleWebhook(req, 'invalid-sig', payload),
).rejects.toThrow(UnauthorizedException);
await expect(
controller.handleWebhook(req, 'invalid-sig', payload),
).rejects.toThrow('Invalid signature');
expect(nowPaymentsProvider.verifyWebhookSignature).toHaveBeenCalledWith({
rawBody: JSON.stringify(payload),
signature: 'invalid-sig',
secret: 'test-ipn-secret',
});
});
it('should proceed when signature is valid', async () => {
const payload = createIPNPayload();
const req = createRawBodyRequest(payload);
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
transactionRepository.findOne!.mockResolvedValue(null);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: {
id: 'evt_123',
provider: 'nowpayments',
eventType: 'payment.succeeded',
payload,
idempotencyKey: 'np_12345',
processingStatus: WebhookProcessingStatus.PENDING,
},
isDuplicate: false,
});
const result = await controller.handleWebhook(req, 'valid-sig', payload);
expect(result).toEqual({ received: true, eventId: 'evt_123' });
});
it('should throw BadRequestException when IPN secret is not configured', async () => {
const payload = createIPNPayload();
const req = createRawBodyRequest(payload);
const emptyConfig = createMockConfigService({ NOWPAYMENTS_IPN_SECRET: '' });
const controllerNoSecret = new NOWPaymentsWebhookController(
nowPaymentsProvider,
paymentAnalytics,
domainEvents,
webhookEventsService,
earningsService,
emptyConfig,
transactionRepository,
);
await expect(
controllerNoSecret.handleWebhook(req, 'some-sig', payload),
).rejects.toThrow(BadRequestException);
await expect(
controllerNoSecret.handleWebhook(req, 'some-sig', payload),
).rejects.toThrow('Webhook verification unavailable');
});
});
describe('Idempotency', () => {
it('should return early when webhook is duplicate', async () => {
const payload = createIPNPayload();
const req = createRawBodyRequest(payload);
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: {
id: 'evt_existing',
provider: 'nowpayments',
eventType: 'payment.succeeded',
payload,
idempotencyKey: 'np_12345',
processingStatus: WebhookProcessingStatus.SUCCESS,
},
isDuplicate: true,
});
const result = await controller.handleWebhook(req, 'valid-sig', payload);
expect(result).toEqual({ received: true, duplicate: true, eventId: 'evt_existing' });
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
expect(domainEvents.emitPaymentCompleted).not.toHaveBeenCalled();
expect(webhookEventsService.markAsProcessed).not.toHaveBeenCalled();
});
it('should use payment_id as idempotency key', async () => {
const payload = createIPNPayload({ payment_id: 99999 });
const req = createRawBodyRequest(payload);
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
transactionRepository.findOne!.mockResolvedValue(null);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_new' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-sig', payload);
expect(webhookEventsService.storeWebhookEvent).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'nowpayments',
idempotencyKey: 'np_99999',
}),
);
});
});
describe('Event Routing - payment succeeded (finished)', () => {
it('should track revenue and emit domain events for finished payment', async () => {
const payload = createIPNPayload({
payment_id: 55555,
payment_status: 'finished',
price_amount: 99.99,
price_currency: 'usd',
pay_amount: 0.05,
pay_currency: 'btc',
order_id: 'tx_user789_tip_1700000000',
});
const req = createRawBodyRequest(payload);
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
transactionRepository.findOne!.mockResolvedValue(null);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_success' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-sig', payload);
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
userId: 'user789',
transactionId: '55555',
transactionType: 'PRODUCTSALE',
amount: 99.99,
currency: 'usd',
platformFee: 0,
metadata: 'nowpayments.finished',
});
expect(domainEvents.emitPaymentCompleted).toHaveBeenCalledWith({
transactionId: '55555',
userId: 'user789',
amountInCents: 9999,
currency: 'usd',
paymentMethod: 'crypto',
paymentType: 'one_time',
completedAt: expect.any(String),
merchantId: '',
productId: 'tx_user789_tip_1700000000',
metadata: {
provider: 'nowpayments',
payCurrency: 'btc',
payAmount: 0.05,
actuallyPaid: undefined,
},
});
expect(domainEvents.emitPurchase).toHaveBeenCalledWith({
sessionId: '55555',
userId: 'user789',
transactionId: '55555',
amountInCents: 9999,
type: 'one_time',
attribution: expect.any(Object),
});
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_success');
});
it('should update transaction status on succeeded payment', async () => {
const payload = createIPNPayload({
payment_id: 77777,
payment_status: 'confirmed',
});
const req = createRawBodyRequest(payload);
const mockTransaction = {
id: 'txn_uuid',
providerTransactionId: '77777',
status: 'pending',
metadata: { existing: true },
};
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
transactionRepository.findOne!.mockResolvedValue(mockTransaction);
transactionRepository.save!.mockResolvedValue(mockTransaction);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_confirmed' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-sig', payload);
expect(transactionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
status: 'completed',
metadata: expect.objectContaining({
existing: true,
paymentStatus: 'confirmed',
payCurrency: 'btc',
completedAt: expect.any(String),
}),
}),
);
});
});
describe('Event Routing - payment failed', () => {
it('should update transaction status on failed payment', async () => {
const payload = createIPNPayload({
payment_id: 88888,
payment_status: 'failed',
order_id: 'tx_user123_tip_1700000000',
});
const req = createRawBodyRequest(payload);
const mockTransaction = {
id: 'txn_uuid',
providerTransactionId: '88888',
status: 'pending',
metadata: {},
};
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
transactionRepository.findOne!.mockResolvedValue(mockTransaction);
transactionRepository.save!.mockResolvedValue(mockTransaction);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_failed' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-sig', payload);
expect(transactionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
status: 'failed',
metadata: expect.objectContaining({
paymentStatus: 'failed',
failedAt: expect.any(String),
}),
}),
);
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
expect(domainEvents.emitPaymentCompleted).not.toHaveBeenCalled();
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_failed');
});
});
describe('Event Routing - payment expired', () => {
it('should update transaction status on expired payment', async () => {
const payload = createIPNPayload({
payment_id: 66666,
payment_status: 'expired',
});
const req = createRawBodyRequest(payload);
const mockTransaction = {
id: 'txn_uuid',
providerTransactionId: '66666',
status: 'pending',
metadata: {},
};
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
transactionRepository.findOne!.mockResolvedValue(mockTransaction);
transactionRepository.save!.mockResolvedValue(mockTransaction);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_expired' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-sig', payload);
expect(transactionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
status: 'cancelled',
metadata: expect.objectContaining({
paymentStatus: 'expired',
expiredAt: expect.any(String),
}),
}),
);
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_expired');
});
});
describe('Event Routing - payment refunded', () => {
it('should track refund and emit refund domain event', async () => {
const payload = createIPNPayload({
payment_id: 44444,
payment_status: 'refunded',
price_amount: 25.00,
price_currency: 'usd',
order_id: 'tx_userABC_tip_1700000000',
});
const req = createRawBodyRequest(payload);
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
transactionRepository.findOne!.mockResolvedValue(null);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_refund' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-sig', payload);
expect(paymentAnalytics.trackRefund).toHaveBeenCalledWith({
userId: 'userABC',
transactionId: '44444',
amount: 25.00,
currency: 'usd',
platformFee: 0,
metadata: 'nowpayments.refunded',
});
expect(domainEvents.emitPaymentRefunded).toHaveBeenCalledWith({
transactionId: '44444',
refundId: 'np_refund_44444',
userId: 'userABC',
amountInCents: 2500,
currency: 'usd',
reason: 'other',
refundType: 'full',
refundedAt: expect.any(String),
merchantId: '',
metadata: {
provider: 'nowpayments',
payCurrency: 'btc',
},
});
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_refund');
});
});
describe('Event Routing - partially_paid', () => {
it('should log warning for partial payments without processing', async () => {
const payload = createIPNPayload({
payment_id: 33333,
payment_status: 'partially_paid',
actually_paid: 0.01,
pay_amount: 0.05,
});
const req = createRawBodyRequest(payload);
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_partial' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-sig', payload);
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
expect(domainEvents.emitPaymentCompleted).not.toHaveBeenCalled();
expect(transactionRepository.save).not.toHaveBeenCalled();
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_partial');
});
});
describe('Error Handling', () => {
it('should mark event as failed when processing throws error', async () => {
const payload = createIPNPayload();
const req = createRawBodyRequest(payload);
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: {
id: 'evt_error',
provider: 'nowpayments',
eventType: 'payment.succeeded',
payload,
idempotencyKey: 'np_12345',
processingStatus: WebhookProcessingStatus.PENDING,
},
isDuplicate: false,
});
const processingError = new Error('Database connection failed');
transactionRepository.findOne!.mockRejectedValue(processingError);
const result = await controller.handleWebhook(req, 'valid-sig', payload);
expect(result).toEqual({ received: true, eventId: 'evt_error' });
expect(webhookEventsService.markAsFailed).toHaveBeenCalledWith('evt_error', processingError);
expect(webhookEventsService.markAsProcessed).not.toHaveBeenCalled();
});
it('should return 200 even when processing fails', async () => {
const payload = createIPNPayload({ payment_status: 'failed' });
const req = createRawBodyRequest(payload);
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_fail_process' },
isDuplicate: false,
});
transactionRepository.findOne!.mockRejectedValue(new Error('DB down'));
const result = await controller.handleWebhook(req, 'valid-sig', payload);
expect(result).toEqual({ received: true, eventId: 'evt_fail_process' });
});
});
describe('Event Type Mapping', () => {
it('should map payment_status to correct event types in stored events', async () => {
const statusToEvent: Record<string, string> = {
finished: 'payment.succeeded',
confirmed: 'payment.succeeded',
failed: 'payment.failed',
expired: 'payment.expired',
partially_paid: 'payment.partial',
refunded: 'payment.refunded',
};
for (const [status, expectedEvent] of Object.entries(statusToEvent)) {
vi.clearAllMocks();
// Re-suppress logger after clearAllMocks
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {});
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
const payload = createIPNPayload({ payment_status: status });
const req = createRawBodyRequest(payload);
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
transactionRepository.findOne!.mockResolvedValue(null);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: `evt_${status}` },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-sig', payload);
expect(webhookEventsService.storeWebhookEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: expectedEvent,
}),
);
}
});
});
});