From b6baac5844cad9b22af044d4b866639f9e19f8fc Mon Sep 17 00:00:00 2001 From: Ekezie Uchechukwu Date: Wed, 24 Jun 2026 15:12:19 +0100 Subject: [PATCH] feat(config): type-safe config module with Zod validation Introduces a Zod-validated ConfigModule that replaces scattered process.env reads across the codebase with a single typed ConfigService. - src/config/config.schema.ts: full Zod schema with coercions, transforms, and environment-specific validation (production rejects defaults) - src/config/config.module.ts: NestJS ConfigModule wrapper (isGlobal: true) - scripts/config-check.ts + npm run config:check: validates .env without starting the app, useful in CI / Docker health checks - Injects ConfigService into: CheckoutService, StellarService, RedisService, AuthService, PaymentDetectorService, WebhookQueueService, DynamicCorsMiddleware - Updates all specs to provide ConfigService mocks; adds 14 new config tests - jest-setup.ts: adds test env vars required by Zod schema - .env.example: rewritten with all vars, required vs optional annotations Closes #11 --- .env.example | 72 ++++-- jest-setup.ts | 4 + package-lock.json | 56 ++++- package.json | 7 +- scripts/config-check.ts | 18 ++ .../payment-detector-backoff.spec.ts | 5 + .../payment-detector-transactions.spec.ts | 10 +- src/__tests__/webhook-module.spec.ts | 19 +- src/__tests__/webhook-queue.spec.ts | 5 +- src/app.module.ts | 2 + src/auth/auth.service.spec.ts | 27 ++- src/auth/auth.service.ts | 36 ++- src/checkout/checkout.service.spec.ts | 103 +++++++- src/checkout/checkout.service.ts | 16 +- src/config/config.module.ts | 16 ++ src/config/config.schema.spec.ts | 135 +++++++++++ src/config/config.schema.ts | 91 +++++++ .../dynamic-cors.middleware.spec.ts | 7 +- src/middleware/dynamic-cors.middleware.ts | 9 +- src/payments/payment-detector.service.ts | 7 +- src/redis/redis.service.ts | 6 +- src/stellar/stellar.service.spec.ts | 223 ++++++++++++++++++ src/stellar/stellar.service.ts | 10 +- src/webhook/webhook-queue.service.ts | 15 +- 24 files changed, 829 insertions(+), 70 deletions(-) create mode 100644 scripts/config-check.ts create mode 100644 src/config/config.module.ts create mode 100644 src/config/config.schema.spec.ts create mode 100644 src/config/config.schema.ts create mode 100644 src/stellar/stellar.service.spec.ts diff --git a/.env.example b/.env.example index 73adba1..cbbd78d 100644 --- a/.env.example +++ b/.env.example @@ -1,37 +1,81 @@ -# Required - App +# ─── App ───────────────────────────────────────────────────────────────────── +# REQUIRED in production; defaults to 'development' NODE_ENV=development + +# REQUIRED in production; defaults to 3001 PORT=3001 -# Required - Database +# ─── Database ───────────────────────────────────────────────────────────────── +# REQUIRED — must be a valid PostgreSQL URL DATABASE_URL=postgresql://postgres:password@localhost:5432/orbitstream -# Required - Redis +# ─── Redis ──────────────────────────────────────────────────────────────────── +# REQUIRED — must be a valid Redis URL REDIS_URL=redis://localhost:6379 -# Required - Auth +# ─── Auth / JWT ─────────────────────────────────────────────────────────────── +# REQUIRED — minimum 32 characters; generate with: openssl rand -base64 48 JWT_SECRET=change-me-to-a-random-32-char-string-minimum + +# OPTIONAL — token lifetime; defaults to '7d' JWT_EXPIRES_IN=7d -# Required - Stellar +# OPTIONAL — previous JWT secret, accepted during key rotation window +JWT_SECRET_PREVIOUS= + +# ─── Stellar ────────────────────────────────────────────────────────────────── +# REQUIRED — 'testnet' or 'mainnet' STELLAR_NETWORK=testnet + +# REQUIRED — Horizon API base URL STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org + +# OPTIONAL — explicit network passphrase; derived from STELLAR_NETWORK if absent STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 -# Required - Platform -# Your Stellar public key for receiving payments. +# ─── Stellar auth secrets (at least one required for auth to work) ───────────── +# Fallback secret key used when network-specific keys are absent +STELLAR_PLATFORM_SECRET_KEY= + +# Network-specific overrides (take precedence over STELLAR_PLATFORM_SECRET_KEY) +TESTNET_AUTH_SECRET_KEY= +MAINNET_AUTH_SECRET_KEY= + +# ─── Platform ───────────────────────────────────────────────────────────────── +# REQUIRED — Stellar public key (starts with G) that receives payments PLATFORM_RECEIVING_ACCOUNT=G... + +# REQUIRED in production; defaults to 'http://localhost:3000' FRONTEND_URL=http://localhost:3000 -# Optional +# REQUIRED in production; defaults to 'http://localhost:3001' +PLATFORM_DOMAIN=http://localhost:3001 + +# ─── Checkout ───────────────────────────────────────────────────────────────── +# OPTIONAL — session TTL in minutes; defaults to 30 CHECKOUT_SESSION_TTL_MINUTES=30 + +# OPTIONAL — SEP-10 auth challenge TTL in seconds; defaults to 300 CHALLENGE_TTL_SECONDS=300 + +# ─── CORS ───────────────────────────────────────────────────────────────────── +# OPTIONAL — comma-separated list of allowed merchant origins CORS_ALLOWED_ORIGINS=http://localhost:3000 -JWT_SECRET_PREVIOUS= -PLATFORM_DOMAIN=http://localhost:3001 -STELLAR_RPC_URL=https://soroban-testnet.stellar.org -STREAM_CONTRACT_ID= -TREASURY_CONTRACT_ID= + +# ─── Redis cursor ───────────────────────────────────────────────────────────── +# OPTIONAL — Horizon payment cursor TTL in Redis; defaults to 24h +REDIS_CURSOR_TTL_HOURS=24 + +# ─── Webhook worker ─────────────────────────────────────────────────────────── +# OPTIONAL — poll interval in ms; defaults to 250 WEBHOOK_POLL_MS=250 + +# OPTIONAL — max concurrent deliveries; defaults to 100 WEBHOOK_MAX_CONCURRENCY=100 + +# OPTIONAL — set to 'true' to disable the webhook worker process WEBHOOK_WORKER_DISABLED=false -WS_CORS_ORIGIN=http://localhost:3000 + +# ─── Security ───────────────────────────────────────────────────────────────── +# OPTIONAL — override the default Content-Security-Policy header value +CONTENT_SECURITY_POLICY= diff --git a/jest-setup.ts b/jest-setup.ts index 80f30b2..5aead47 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -2,3 +2,7 @@ process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; process.env.REDIS_URL = 'redis://localhost:6379'; process.env.JWT_SECRET = 'test-secret-with-at-least-32-characters'; process.env.PLATFORM_DOMAIN = 'http://localhost:3001'; +process.env.STELLAR_NETWORK = 'testnet'; +process.env.STELLAR_HORIZON_URL = 'https://horizon-testnet.stellar.org'; +process.env.PLATFORM_RECEIVING_ACCOUNT = + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; diff --git a/package-lock.json b/package-lock.json index 478f3e4..0a7ccd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@nestjs/common": "^10.0.0", + "@nestjs/config": "^4.0.4", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.1.0", "@nestjs/passport": "^10.0.0", @@ -32,7 +33,8 @@ "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", "typeorm": "^0.3.17", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "zod": "^4.4.3" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -2520,6 +2522,33 @@ } } }, + "node_modules/@nestjs/config": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@nestjs/core": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.22.tgz", @@ -5494,6 +5523,21 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/drizzle-kit": { "version": "0.30.6", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.30.6.tgz", @@ -8757,7 +8801,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash.includes": { @@ -12187,6 +12230,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index dd1c6b6..90f5a58 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "format:check": "prettier --check \"src/**/*.ts\"", "drizzle:generate": "drizzle-kit generate", "drizzle:push": "drizzle-kit push", - "drizzle:studio": "drizzle-kit studio" + "drizzle:studio": "drizzle-kit studio", + "config:check": "ts-node scripts/config-check.ts" }, "dependencies": { "@nestjs/common": "^10.0.0", + "@nestjs/config": "^4.0.4", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.1.0", "@nestjs/passport": "^10.0.0", @@ -43,7 +45,8 @@ "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", "typeorm": "^0.3.17", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "zod": "^4.4.3" }, "devDependencies": { "@nestjs/cli": "^10.0.0", diff --git a/scripts/config-check.ts b/scripts/config-check.ts new file mode 100644 index 0000000..8670958 --- /dev/null +++ b/scripts/config-check.ts @@ -0,0 +1,18 @@ +import 'dotenv/config'; +import { validate } from '../src/config/config.schema'; + +try { + const config = validate(process.env as Record); + console.log('✓ Config is valid'); + const nodeEnv = config.NODE_ENV; + const dbUrl = config.DATABASE_URL.replace(/:\/\/[^@]+@/, '://@'); + console.log(` NODE_ENV=${nodeEnv}`); + console.log(` DATABASE_URL=${dbUrl}`); + console.log(` REDIS_URL=${config.REDIS_URL}`); + console.log(` STELLAR_NETWORK=${config.STELLAR_NETWORK}`); + process.exit(0); +} catch (err) { + console.error('✗ Config validation failed:'); + console.error((err as Error).message); + process.exit(1); +} diff --git a/src/__tests__/payment-detector-backoff.spec.ts b/src/__tests__/payment-detector-backoff.spec.ts index b9e8d74..dfa3e2c 100644 --- a/src/__tests__/payment-detector-backoff.spec.ts +++ b/src/__tests__/payment-detector-backoff.spec.ts @@ -9,6 +9,7 @@ jest.mock('../db/index', () => ({ jest.mock('../db/schema', () => ({ checkoutSessions: {}, payments: {} })); import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; import { PaymentDetectorService } from '../payments/payment-detector.service'; import { PaymentCursorService } from '../payments/payment-cursor.service'; import { StellarService } from '../stellar/stellar.service'; @@ -59,6 +60,10 @@ describe('PaymentDetectorService — rate-limit backoff', () => { appendCheckpoint: jest.fn(), }, }, + { + provide: ConfigService, + useValue: { get: () => undefined }, + }, ], }).compile(); diff --git a/src/__tests__/payment-detector-transactions.spec.ts b/src/__tests__/payment-detector-transactions.spec.ts index 18c5aa5..de111e0 100644 --- a/src/__tests__/payment-detector-transactions.spec.ts +++ b/src/__tests__/payment-detector-transactions.spec.ts @@ -1,3 +1,4 @@ +import { ConfigService } from '@nestjs/config'; import { PaymentDetectorService } from '../payments/payment-detector.service'; import { PaymentCursorService } from '../payments/payment-cursor.service'; import { StellarService } from '../stellar/stellar.service'; @@ -116,7 +117,14 @@ describe('PaymentDetectorService - Transaction Wrapping', () => { releaseLock: jest.fn(), } as any; - service = new PaymentDetectorService(mockStellar, mockWebhooks, mockMetrics, mockCursorService); + const mockConfig = { get: () => undefined } as unknown as ConfigService; + service = new PaymentDetectorService( + mockStellar, + mockWebhooks, + mockMetrics, + mockCursorService, + mockConfig, + ); }); describe('two-phase claim + finalize', () => { diff --git a/src/__tests__/webhook-module.spec.ts b/src/__tests__/webhook-module.spec.ts index b3dec63..03e270e 100644 --- a/src/__tests__/webhook-module.spec.ts +++ b/src/__tests__/webhook-module.spec.ts @@ -1,9 +1,11 @@ import { Test } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; import { WebhookModule } from '../webhook/webhook.module'; import { RedisModule } from '../redis/redis.module'; import { RedisService } from '../redis/redis.service'; import { WebhookQueueService } from '../webhook/webhook-queue.service'; import { AuditService } from '../audit/audit.service'; +import { validate } from '../config/config.schema'; const mockAuditService = { log: jest.fn(), @@ -12,25 +14,28 @@ const mockAuditService = { logSensitiveOperation: jest.fn(), }; +const mockRedisService = { getClient: jest.fn().mockReturnValue({}) }; + /** - * Resolves the real WebhookModule DI graph (no manual provider overrides) to - * prove NestJS can construct WebhookQueueService and inject RedisService at - * startup. `compile()` instantiates every provider but does NOT run lifecycle - * hooks, so no real Redis/Postgres connection is opened. + * Resolves the real WebhookModule DI graph to prove NestJS can construct + * WebhookQueueService and that RedisService is injectable into it. + * `compile()` instantiates every provider but does NOT run lifecycle hooks, + * so no real Redis connection is opened. */ describe('WebhookModule dependency injection', () => { it('resolves WebhookQueueService with RedisService injected', async () => { const moduleRef = await Test.createTestingModule({ - imports: [WebhookModule], + imports: [ConfigModule.forRoot({ isGlobal: true, validate }), WebhookModule], }) .overrideProvider(AuditService) .useValue(mockAuditService) + .overrideProvider(RedisService) + .useValue(mockRedisService) .compile(); const queue = moduleRef.get(WebhookQueueService); expect(queue).toBeInstanceOf(WebhookQueueService); - // RedisService is resolvable from the same graph (provided by @Global RedisModule). - expect(moduleRef.get(RedisService, { strict: false })).toBeInstanceOf(RedisService); + expect(moduleRef.get(RedisService, { strict: false })).toBeDefined(); }); it('RedisModule exports RedisService', () => { diff --git a/src/__tests__/webhook-queue.spec.ts b/src/__tests__/webhook-queue.spec.ts index 851f68f..a9ffc3c 100644 --- a/src/__tests__/webhook-queue.spec.ts +++ b/src/__tests__/webhook-queue.spec.ts @@ -70,9 +70,12 @@ jest.mock('../db/index', () => { }); import RedisMock from 'ioredis-mock'; +import { ConfigService } from '@nestjs/config'; import { WebhookQueueService } from '../webhook/webhook-queue.service'; import { RedisService } from '../redis/redis.service'; +const mockConfigService = { get: () => undefined } as unknown as ConfigService; + const sharedMock = new RedisMock(); const redisService = { getClient: () => sharedMock } as unknown as RedisService; @@ -129,7 +132,7 @@ describe('WebhookQueueService', () => { s.deliveries.length = 0; s.deadLetters.length = 0; delivery = makeDelivery(); - svc = new WebhookQueueService(redisService, delivery as any); + svc = new WebhookQueueService(redisService, delivery as any, mockConfigService); }); describe('priority ordering', () => { diff --git a/src/app.module.ts b/src/app.module.ts index 8993c19..b6863ce 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,11 +11,13 @@ import { MonitoringModule } from './monitoring/monitoring.module'; import { RedisModule } from './redis/redis.module'; import { RateLimitModule } from './api/middleware/rate-limit.module'; import { AuditModule } from './audit/audit.module'; +import { ConfigModule } from './config/config.module'; import { DynamicCorsMiddleware } from './middleware/dynamic-cors.middleware'; import { SecurityHeadersMiddleware } from './middleware/security-headers.middleware'; @Module({ imports: [ + ConfigModule, ScheduleModule.forRoot(), TypeOrmModule.forRoot({ type: 'postgres', diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 5fcc8af..4fa1aac 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UnauthorizedException, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { JwtService } from '@nestjs/jwt'; import { RedisService } from '../redis/redis.service'; @@ -35,21 +36,23 @@ describe('AuthService', () => { expire: jest.fn(), }; - beforeAll(() => { - process.env.STELLAR_PLATFORM_SECRET_KEY = mockServerKeypair.secret(); - process.env.CHALLENGE_TTL_SECONDS = '300'; - }); - - afterAll(() => { - delete process.env.STELLAR_PLATFORM_SECRET_KEY; - delete process.env.CHALLENGE_TTL_SECONDS; - }); - beforeEach(async () => { jest.clearAllMocks(); mockRedisClient.incr.mockResolvedValue(1); // rate limit + const mockConfigService = { + get: (key: string) => { + const values: Record = { + STELLAR_PLATFORM_SECRET_KEY: mockServerKeypair.secret(), + CHALLENGE_TTL_SECONDS: 300, + STELLAR_NETWORK: 'testnet', + STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org', + }; + return values[key]; + }, + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, @@ -61,6 +64,10 @@ describe('AuthService', () => { provide: RedisService, useValue: { getClient: () => mockRedisClient }, }, + { + provide: ConfigService, + useValue: mockConfigService, + }, ], }).compile(); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 41f9ef6..a7c1992 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -6,18 +6,20 @@ import { ServiceUnavailableException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; import { RequestChallengeDto, VerifyChallengeDto } from './auth.dto'; import { RedisService } from '../redis/redis.service'; +import { Config } from '../config/config.schema'; import * as StellarSdk from '@stellar/stellar-sdk'; import * as crypto from 'crypto'; import axios from 'axios'; -const CHALLENGE_TTL_SECONDS = parseInt(process.env.CHALLENGE_TTL_SECONDS || '300', 10); const NONCE_BYTES = 32; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); + private readonly challengeTtlSeconds: number; // Circuit breaker state private horizonFailureCount = 0; @@ -26,21 +28,33 @@ export class AuthService { constructor( private readonly jwt: JwtService, private readonly redisService: RedisService, - ) {} + private readonly config: ConfigService, + ) { + this.challengeTtlSeconds = this.config.get('CHALLENGE_TTL_SECONDS', { infer: true }) ?? 300; + } private getNetworkConfig() { - const network = (process.env.STELLAR_NETWORK || 'TESTNET').toUpperCase(); + const network = ( + this.config.get('STELLAR_NETWORK', { infer: true }) ?? 'testnet' + ).toUpperCase(); + const horizonUrl = + this.config.get('STELLAR_HORIZON_URL', { infer: true }) ?? + 'https://horizon-testnet.stellar.org'; if (network === 'MAINNET') { return { passphrase: StellarSdk.Networks.PUBLIC, - horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon.stellar.org', - secret: process.env.MAINNET_AUTH_SECRET_KEY || process.env.STELLAR_PLATFORM_SECRET_KEY, + horizonUrl, + secret: + this.config.get('MAINNET_AUTH_SECRET_KEY', { infer: true }) || + this.config.get('STELLAR_PLATFORM_SECRET_KEY', { infer: true }), }; } return { passphrase: StellarSdk.Networks.TESTNET, - horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org', - secret: process.env.TESTNET_AUTH_SECRET_KEY || process.env.STELLAR_PLATFORM_SECRET_KEY, + horizonUrl, + secret: + this.config.get('TESTNET_AUTH_SECRET_KEY', { infer: true }) || + this.config.get('STELLAR_PLATFORM_SECRET_KEY', { infer: true }), }; } @@ -130,8 +144,8 @@ export class AuthService { const nowSeconds = Math.floor(Date.now() / 1000); const timebounds = { - minTime: (nowSeconds - CHALLENGE_TTL_SECONDS).toString(), - maxTime: (nowSeconds + CHALLENGE_TTL_SECONDS).toString(), + minTime: (nowSeconds - this.challengeTtlSeconds).toString(), + maxTime: (nowSeconds + this.challengeTtlSeconds).toString(), }; const transaction = new StellarSdk.TransactionBuilder(serverAccount, { @@ -153,12 +167,12 @@ export class AuthService { const txEnvelope = transaction.toEnvelope().toXDR('base64'); const redis = this.redisService.getClient(); - await redis.set(`challenge:${walletAddress}`, nonce, 'EX', CHALLENGE_TTL_SECONDS); + await redis.set(`challenge:${walletAddress}`, nonce, 'EX', this.challengeTtlSeconds); return { transaction: txEnvelope, passphrase: config.passphrase, - expiresAt: new Date((nowSeconds + CHALLENGE_TTL_SECONDS) * 1000).toISOString(), + expiresAt: new Date((nowSeconds + this.challengeTtlSeconds) * 1000).toISOString(), }; } diff --git a/src/checkout/checkout.service.spec.ts b/src/checkout/checkout.service.spec.ts index a6e70ef..f3a0da2 100644 --- a/src/checkout/checkout.service.spec.ts +++ b/src/checkout/checkout.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { CheckoutService } from './checkout.service'; import { AuditService } from '../audit/audit.service'; @@ -15,6 +16,12 @@ jest.mock('../db/index', () => ({ import { db } from '../db/index'; +const FRONTEND_URL = 'https://checkout.example.com'; +const RECEIVING_ACCOUNT = 'GTESTACCOUNT123456789012345678901234567890123456789012345'; + +const mockConfigGet = jest.fn(); +const mockConfigService = { get: mockConfigGet }; + describe('CheckoutService', () => { let service: CheckoutService; @@ -27,12 +34,21 @@ describe('CheckoutService', () => { beforeEach(async () => { jest.clearAllMocks(); - process.env.FRONTEND_URL = 'https://checkout.example.com'; - process.env.PLATFORM_RECEIVING_ACCOUNT = - 'GTESTACCOUNT123456789012345678901234567890123456789012345'; + mockConfigGet.mockImplementation((key: string) => { + const values: Record = { + FRONTEND_URL, + PLATFORM_RECEIVING_ACCOUNT: RECEIVING_ACCOUNT, + CHECKOUT_SESSION_TTL_MINUTES: 30, + }; + return values[key]; + }); const module: TestingModule = await Test.createTestingModule({ - providers: [CheckoutService, { provide: AuditService, useValue: mockAuditService }], + providers: [ + CheckoutService, + { provide: AuditService, useValue: mockAuditService }, + { provide: ConfigService, useValue: mockConfigService }, + ], }).compile(); service = module.get(CheckoutService); @@ -60,7 +76,7 @@ describe('CheckoutService', () => { expect(result).toEqual({ id: 'sess-1', - url: 'https://checkout.example.com/checkout/sess-1', + url: `${FRONTEND_URL}/checkout/sess-1`, amount: '10.0000000', asset: 'USDC', status: 'pending', @@ -106,6 +122,81 @@ describe('CheckoutService', () => { }); }); + + describe('createSession', () => { + const mockSession = { + id: 'sess-new', + merchantId: 'merchant-1', + amount: '25.0000000', + assetCode: 'USDC', + status: 'pending', + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + }; + + it('should create a session and return a URL + public fields', async () => { + (db.insert as jest.Mock).mockReturnValue({ + values: jest.fn().mockReturnValue({ + returning: jest.fn().mockResolvedValue([mockSession]), + }), + }); + + const result = await service.createSession('merchant-1', { + amount: 25, + asset: 'USDC', + successUrl: 'https://example.com/success', + }); + + expect(result.id).toBe('sess-new'); + expect(result.url).toContain('/checkout/sess-new'); + expect(result.status).toBe('pending'); + expect(result).not.toHaveProperty('memo'); + expect(result).not.toHaveProperty('receivingAccount'); + expect(db.insert).toHaveBeenCalled(); + }); + + it('should throw BadRequestException when PLATFORM_RECEIVING_ACCOUNT is not set', async () => { + mockConfigGet.mockImplementation((key: string) => { + if (key === 'PLATFORM_RECEIVING_ACCOUNT') return undefined; + const values: Record = { + FRONTEND_URL, + CHECKOUT_SESSION_TTL_MINUTES: 30, + }; + return values[key]; + }); + + await expect( + service.createSession('merchant-1', { amount: 10, asset: 'USDC' }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('getSession — auto-expire', () => { + it('should mark the session expired and return status "expired"', async () => { + (db.query.checkoutSessions.findFirst as jest.Mock).mockResolvedValue({ + id: 'sess-expired', + merchantId: 'merchant-1', + amount: '10.0000000', + assetCode: 'USDC', + receivingAccount: 'GSECRET', + memo: 'secret', + status: 'pending', + expiresAt: new Date(Date.now() - 1000), + createdAt: new Date(), + }); + (db.update as jest.Mock).mockReturnValue({ + set: jest.fn().mockReturnValue({ + where: jest.fn().mockResolvedValue(undefined), + }), + }); + + const result = await service.getSession('sess-expired'); + + expect(result.status).toBe('expired'); + expect(db.update).toHaveBeenCalled(); + }); + }); + + describe('cancelSession', () => { it('should log sensitive operation on cancel', async () => { (db.query.checkoutSessions.findFirst as jest.Mock).mockResolvedValue({ diff --git a/src/checkout/checkout.service.ts b/src/checkout/checkout.service.ts index a60bda5..d6a2c72 100644 --- a/src/checkout/checkout.service.ts +++ b/src/checkout/checkout.service.ts @@ -1,16 +1,24 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { db } from '../db/index'; import { checkoutSessions } from '../db/schema'; import { eq, and } from 'drizzle-orm'; import * as crypto from 'crypto'; import { AuditService } from '../audit/audit.service'; +import { Config } from '../config/config.schema'; @Injectable() export class CheckoutService { - private readonly frontendUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000'; - private readonly sessionTtlMinutes = Number(process.env.CHECKOUT_SESSION_TTL_MINUTES ?? 30); + private readonly frontendUrl: string; + private readonly sessionTtlMinutes: number; - constructor(private readonly auditService: AuditService) {} + constructor( + private readonly auditService: AuditService, + private readonly config: ConfigService, + ) { + this.frontendUrl = this.config.get('FRONTEND_URL', { infer: true }) ?? 'http://localhost:3000'; + this.sessionTtlMinutes = this.config.get('CHECKOUT_SESSION_TTL_MINUTES', { infer: true }) ?? 30; + } async createSession( merchantId: string, @@ -24,7 +32,7 @@ export class CheckoutService { }, ) { const memo = crypto.randomBytes(8).toString('hex'); - const receivingAccount = process.env.PLATFORM_RECEIVING_ACCOUNT; + const receivingAccount = this.config.get('PLATFORM_RECEIVING_ACCOUNT', { infer: true }); if (!receivingAccount) { throw new BadRequestException('Platform receiving account not configured'); } diff --git a/src/config/config.module.ts b/src/config/config.module.ts new file mode 100644 index 0000000..b7c23e5 --- /dev/null +++ b/src/config/config.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule as NestConfigModule } from '@nestjs/config'; +import { validate } from './config.schema'; + +@Module({ + imports: [ + NestConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env.local', '.env'], + validate, + cache: true, + }), + ], + exports: [NestConfigModule], +}) +export class ConfigModule {} diff --git a/src/config/config.schema.spec.ts b/src/config/config.schema.spec.ts new file mode 100644 index 0000000..370531d --- /dev/null +++ b/src/config/config.schema.spec.ts @@ -0,0 +1,135 @@ +import { validate, configSchema } from './config.schema'; + +const VALID_BASE: Record = { + NODE_ENV: 'development', + PORT: '3001', + DATABASE_URL: 'postgresql://postgres:password@localhost:5432/orbitstream', + REDIS_URL: 'redis://localhost:6379', + JWT_SECRET: 'a-strong-secret-with-at-least-32-chars!!', + STELLAR_NETWORK: 'testnet', + STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org', + PLATFORM_RECEIVING_ACCOUNT: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + CORS_ALLOWED_ORIGINS: 'http://localhost:3000', +}; + +describe('configSchema', () => { + describe('valid config', () => { + it('accepts a valid development config', () => { + const result = configSchema.safeParse(VALID_BASE); + expect(result.success).toBe(true); + }); + + it('applies defaults for missing optional keys', () => { + const result = configSchema.safeParse(VALID_BASE); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.PORT).toBe(3001); + expect(result.data.NODE_ENV).toBe('development'); + expect(result.data.FRONTEND_URL).toBe('http://localhost:3000'); + expect(result.data.CHECKOUT_SESSION_TTL_MINUTES).toBe(30); + expect(result.data.CHALLENGE_TTL_SECONDS).toBe(300); + expect(result.data.JWT_EXPIRES_IN).toBe('7d'); + }); + + it('coerces PORT from string to number', () => { + const result = configSchema.safeParse({ ...VALID_BASE, PORT: '8080' }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.PORT).toBe(8080); + }); + + it('transforms CORS_ALLOWED_ORIGINS into an array', () => { + const result = configSchema.safeParse({ + ...VALID_BASE, + CORS_ALLOWED_ORIGINS: 'http://localhost:3000,https://app.example.com', + }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.CORS_ALLOWED_ORIGINS).toEqual([ + 'http://localhost:3000', + 'https://app.example.com', + ]); + }); + + it('accepts mainnet network', () => { + const result = configSchema.safeParse({ ...VALID_BASE, STELLAR_NETWORK: 'mainnet' }); + expect(result.success).toBe(true); + }); + }); + + describe('invalid config', () => { + it('rejects when DATABASE_URL is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { DATABASE_URL: _url, ...rest } = VALID_BASE; + const result = configSchema.safeParse(rest); + expect(result.success).toBe(false); + }); + + it('rejects when JWT_SECRET is too short (< 32 chars)', () => { + const result = configSchema.safeParse({ ...VALID_BASE, JWT_SECRET: 'tooshort' }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues[0].message).toMatch(/32 characters/); + }); + + it('rejects invalid DATABASE_URL', () => { + const result = configSchema.safeParse({ ...VALID_BASE, DATABASE_URL: 'not-a-url' }); + expect(result.success).toBe(false); + }); + + it('rejects PLATFORM_RECEIVING_ACCOUNT not starting with G', () => { + const result = configSchema.safeParse({ + ...VALID_BASE, + PLATFORM_RECEIVING_ACCOUNT: 'XBAD123', + }); + expect(result.success).toBe(false); + }); + + it('rejects unknown STELLAR_NETWORK value', () => { + const result = configSchema.safeParse({ ...VALID_BASE, STELLAR_NETWORK: 'devnet' }); + expect(result.success).toBe(false); + }); + }); +}); + +describe('validate()', () => { + it('returns parsed config on valid input', () => { + const config = validate(VALID_BASE); + expect(config.DATABASE_URL).toBe(VALID_BASE.DATABASE_URL); + expect(config.PORT).toBe(3001); + }); + + it('throws with a descriptive message on invalid input', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { DATABASE_URL: _db, JWT_SECRET: _jwt, ...rest } = VALID_BASE; + expect(() => validate(rest)).toThrow('Config validation failed'); + }); + + it('throws in production when defaultable keys are missing from raw env', () => { + expect(() => + validate({ + ...VALID_BASE, + NODE_ENV: 'production', + // PORT, FRONTEND_URL etc. absent — only defaults would fill them + }), + ).toThrow(/Production requires/); + }); + + it('passes in production when all defaultable keys are explicitly provided', () => { + const prodEnv = { + ...VALID_BASE, + NODE_ENV: 'production', + PORT: '443', + JWT_EXPIRES_IN: '1d', + FRONTEND_URL: 'https://app.example.com', + CHECKOUT_SESSION_TTL_MINUTES: '30', + CHALLENGE_TTL_SECONDS: '300', + REDIS_CURSOR_TTL_HOURS: '24', + WEBHOOK_POLL_MS: '250', + WEBHOOK_MAX_CONCURRENCY: '100', + PLATFORM_DOMAIN: 'https://api.example.com', + CORS_ALLOWED_ORIGINS: 'https://app.example.com', + }; + expect(() => validate(prodEnv)).not.toThrow(); + }); +}); diff --git a/src/config/config.schema.ts b/src/config/config.schema.ts new file mode 100644 index 0000000..5c39120 --- /dev/null +++ b/src/config/config.schema.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; + +export const configSchema = z.object({ + NODE_ENV: z.enum(['development', 'staging', 'production', 'test']).default('development'), + PORT: z.coerce.number().int().positive().default(3001), + + DATABASE_URL: z.string().url({ message: 'DATABASE_URL must be a valid PostgreSQL URL' }), + REDIS_URL: z.string().url({ message: 'REDIS_URL must be a valid Redis URL' }), + + JWT_SECRET: z.string().min(32, { message: 'JWT_SECRET must be at least 32 characters' }), + JWT_EXPIRES_IN: z.string().default('7d'), + JWT_SECRET_PREVIOUS: z.string().optional(), + + STELLAR_NETWORK: z.enum(['testnet', 'mainnet']), + STELLAR_HORIZON_URL: z.string().url(), + STELLAR_NETWORK_PASSPHRASE: z.string().min(1).optional(), + + PLATFORM_RECEIVING_ACCOUNT: z + .string() + .startsWith('G', { message: 'Must be a valid Stellar public key' }), + + FRONTEND_URL: z.string().url().default('http://localhost:3000'), + CHECKOUT_SESSION_TTL_MINUTES: z.coerce.number().int().positive().default(30), + CHALLENGE_TTL_SECONDS: z.coerce.number().int().positive().default(300), + + CORS_ALLOWED_ORIGINS: z + .string() + .default('') + .transform((s) => + s + .split(',') + .map((o) => o.trim()) + .filter(Boolean), + ), + REDIS_CURSOR_TTL_HOURS: z.coerce.number().int().positive().default(24), + + // Stellar auth secrets (network-dependent, at least one required in production) + STELLAR_PLATFORM_SECRET_KEY: z.string().optional(), + MAINNET_AUTH_SECRET_KEY: z.string().optional(), + TESTNET_AUTH_SECRET_KEY: z.string().optional(), + + // Webhook worker + WEBHOOK_POLL_MS: z.coerce.number().int().positive().default(250), + WEBHOOK_MAX_CONCURRENCY: z.coerce.number().int().positive().default(100), + WEBHOOK_WORKER_DISABLED: z.string().optional(), + + // Middleware + PLATFORM_DOMAIN: z.string().url().default('http://localhost:3001'), + CONTENT_SECURITY_POLICY: z.string().optional(), +}); + +export type Config = z.infer; + +/** Keys that have Zod defaults — production must supply them explicitly. */ +const DEFAULTED_KEYS: ReadonlyArray = [ + 'NODE_ENV', + 'PORT', + 'JWT_EXPIRES_IN', + 'FRONTEND_URL', + 'CHECKOUT_SESSION_TTL_MINUTES', + 'CHALLENGE_TTL_SECONDS', + 'REDIS_CURSOR_TTL_HOURS', + 'WEBHOOK_POLL_MS', + 'WEBHOOK_MAX_CONCURRENCY', + 'PLATFORM_DOMAIN', + 'CORS_ALLOWED_ORIGINS', +]; + +export function validate(rawEnv: Record): Config { + const result = configSchema.safeParse(rawEnv); + if (!result.success) { + const msg = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n'); + throw new Error(`Config validation failed:\n${msg}`); + } + + if (rawEnv.NODE_ENV === 'production') { + const missing = DEFAULTED_KEYS.filter((k) => !(k in rawEnv)); + if (missing.length > 0) { + throw new Error( + `Production requires all config to be explicitly set. Missing: ${missing.join(', ')}`, + ); + } + } else if (rawEnv.NODE_ENV !== 'test') { + const missing = DEFAULTED_KEYS.filter((k) => !(k in rawEnv)); + if (missing.length > 0) { + console.warn(`[Config] Using defaults for: ${missing.join(', ')}`); + } + } + + return result.data; +} diff --git a/src/middleware/dynamic-cors.middleware.spec.ts b/src/middleware/dynamic-cors.middleware.spec.ts index c8942a3..9ea00c2 100644 --- a/src/middleware/dynamic-cors.middleware.spec.ts +++ b/src/middleware/dynamic-cors.middleware.spec.ts @@ -1,3 +1,4 @@ +import { ConfigService } from '@nestjs/config'; import { DynamicCorsMiddleware } from './dynamic-cors.middleware'; import { CorsOriginsCacheService } from './cors-origins-cache.service'; @@ -33,7 +34,6 @@ describe('DynamicCorsMiddleware - route grouping', () => { let cache: jest.Mocked; beforeEach(() => { - process.env.PLATFORM_DOMAIN = 'http://localhost:3001'; cache = { getAllMerchantOrigins: jest.fn().mockResolvedValue(['https://myshop.com']), getMerchantOrigins: jest.fn().mockResolvedValue([] as string[]), @@ -41,7 +41,10 @@ describe('DynamicCorsMiddleware - route grouping', () => { refreshCache: jest.fn(), invalidateAllCache: jest.fn(), } as any; - middleware = new DynamicCorsMiddleware(cache); + const mockConfig = { + get: (key: string) => (key === 'PLATFORM_DOMAIN' ? 'http://localhost:3001' : undefined), + } as unknown as ConfigService; + middleware = new DynamicCorsMiddleware(cache, mockConfig); }); it('allows all origins on public GET /v1/checkout/sessions/:id', async () => { diff --git a/src/middleware/dynamic-cors.middleware.ts b/src/middleware/dynamic-cors.middleware.ts index 6872c61..ad08e9c 100644 --- a/src/middleware/dynamic-cors.middleware.ts +++ b/src/middleware/dynamic-cors.middleware.ts @@ -1,6 +1,8 @@ import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Request, Response, NextFunction } from 'express'; import { CorsOriginsCacheService } from './cors-origins-cache.service'; +import { Config } from '../config/config.schema'; type RouteGroup = 'public' | 'merchant_api' | 'dashboard'; @@ -30,8 +32,11 @@ export class DynamicCorsMiddleware implements NestMiddleware { private readonly logger = new Logger(DynamicCorsMiddleware.name); private readonly platformOrigin: string; - constructor(private readonly corsCache: CorsOriginsCacheService) { - const domain = process.env.PLATFORM_DOMAIN ?? 'http://localhost:3001'; + constructor( + private readonly corsCache: CorsOriginsCacheService, + private readonly config: ConfigService, + ) { + const domain = this.config.get('PLATFORM_DOMAIN', { infer: true }) ?? 'http://localhost:3001'; this.platformOrigin = new URL(domain).origin; } diff --git a/src/payments/payment-detector.service.ts b/src/payments/payment-detector.service.ts index 1834b7c..8938bb2 100644 --- a/src/payments/payment-detector.service.ts +++ b/src/payments/payment-detector.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { randomUUID } from 'crypto'; import { db } from '../db/index'; import { checkoutSessions, payments } from '../db/schema'; @@ -7,6 +8,7 @@ import { StellarService } from '../stellar/stellar.service'; import { WebhookService } from '../webhook/webhook.service'; import { MetricsService } from '../monitoring/metrics.service'; import { PaymentCursorService, PERSIST_EVERY } from './payment-cursor.service'; +import { Config } from '../config/config.schema'; interface LockedSessionRow { id: string; @@ -40,10 +42,11 @@ export class PaymentDetectorService implements OnModuleInit, OnModuleDestroy { private readonly webhooks: WebhookService, private readonly metrics: MetricsService, private readonly cursorService: PaymentCursorService, + private readonly config: ConfigService, ) {} async onModuleInit(): Promise { - const account = process.env.PLATFORM_RECEIVING_ACCOUNT; + const account = this.config.get('PLATFORM_RECEIVING_ACCOUNT', { infer: true }); if (!account) { this.logger.warn('PLATFORM_RECEIVING_ACCOUNT not set — payment detection disabled'); return; @@ -55,7 +58,7 @@ export class PaymentDetectorService implements OnModuleInit, OnModuleDestroy { async onModuleDestroy(): Promise { this.polling = false; - const account = process.env.PLATFORM_RECEIVING_ACCOUNT; + const account = this.config.get('PLATFORM_RECEIVING_ACCOUNT', { infer: true }); if (account) await this.cursorService.releaseLock(account, this.instanceId); } diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index 42c3020..17c48a4 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -1,13 +1,17 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; +import { Config } from '../config/config.schema'; @Injectable() export class RedisService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(RedisService.name); private client!: Redis; + constructor(private readonly config: ConfigService) {} + onModuleInit(): void { - const url = process.env.REDIS_URL ?? 'redis://localhost:6379'; + const url = this.config.get('REDIS_URL', { infer: true }) ?? 'redis://localhost:6379'; this.client = new Redis(url, { enableReadyCheck: true, retryStrategy: (times) => Math.min(times * 100, 5_000), diff --git a/src/stellar/stellar.service.spec.ts b/src/stellar/stellar.service.spec.ts new file mode 100644 index 0000000..4bac87c --- /dev/null +++ b/src/stellar/stellar.service.spec.ts @@ -0,0 +1,223 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { StellarService } from './stellar.service'; +import axios from 'axios'; + +jest.mock('axios', () => { + const mockAxios: any = jest.fn(); + mockAxios.get = jest.fn(); + mockAxios.isAxiosError = jest.fn((e: unknown) => (e as any)?.__isAxiosError === true); + mockAxios.create = jest.fn(() => mockAxios); + mockAxios.defaults = { headers: { common: {} } }; + mockAxios.interceptors = { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }; + return mockAxios; +}); + +const mockedGet = axios.get as jest.Mock; + +const WALLET = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + +const accountData = { + id: WALLET, + sequence: '123456789', + balances: [ + { asset_type: 'native', balance: '100.0000000' }, + { + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + balance: '500.0000000', + }, + ], +}; + +describe('StellarService', () => { + let service: StellarService; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StellarService, + { + provide: ConfigService, + useValue: { + get: (key: string) => + key === 'STELLAR_HORIZON_URL' ? 'https://horizon-testnet.stellar.org' : undefined, + }, + }, + ], + }).compile(); + service = module.get(StellarService); + }); + + describe('getAccountInfo', () => { + it('returns id, sequence and balances from Horizon', async () => { + mockedGet.mockResolvedValue({ data: accountData }); + + const result = await service.getAccountInfo(WALLET); + + expect(result).toEqual({ + id: WALLET, + sequence: '123456789', + balances: accountData.balances, + }); + expect(mockedGet).toHaveBeenCalledWith(expect.stringContaining(`/accounts/${WALLET}`)); + }); + }); + + describe('getBalance', () => { + it('returns native balance when asset is "native"', async () => { + mockedGet.mockResolvedValue({ data: accountData }); + + const balance = await service.getBalance(WALLET, 'native'); + expect(balance).toBe(100.0); + }); + + it('returns USDC balance when asset is "USDC"', async () => { + mockedGet.mockResolvedValue({ data: accountData }); + + const balance = await service.getBalance(WALLET, 'USDC'); + expect(balance).toBe(500.0); + }); + + it('returns 0 when asset is not found in balances', async () => { + mockedGet.mockResolvedValue({ data: accountData }); + + const balance = await service.getBalance(WALLET, 'UNKNOWN'); + expect(balance).toBe(0); + }); + }); + + describe('verifyTransaction', () => { + it('returns true for a successful transaction', async () => { + mockedGet.mockResolvedValue({ data: { successful: true } }); + + const result = await service.verifyTransaction('txhash-abc'); + expect(result).toBe(true); + }); + + it('returns false for an unsuccessful transaction', async () => { + mockedGet.mockResolvedValue({ data: { successful: false } }); + + const result = await service.verifyTransaction('txhash-abc'); + expect(result).toBe(false); + }); + + it('returns false when Horizon throws (e.g. 404)', async () => { + mockedGet.mockRejectedValue(new Error('Network error')); + + const result = await service.verifyTransaction('nonexistent'); + expect(result).toBe(false); + }); + }); + + describe('getTransactionOperations', () => { + it('returns the records array from Horizon', async () => { + const ops = [{ type: 'payment', amount: '10.0000000' }]; + mockedGet.mockResolvedValue({ data: { _embedded: { records: ops } } }); + + const result = await service.getTransactionOperations('txhash-abc'); + expect(result).toEqual(ops); + }); + + it('returns empty array when _embedded is absent', async () => { + mockedGet.mockResolvedValue({ data: {} }); + + const result = await service.getTransactionOperations('txhash-abc'); + expect(result).toEqual([]); + }); + }); + + describe('getPaymentsPage', () => { + it('returns records and rate-limit headers', async () => { + const records = [{ id: 'op-1', type: 'payment' }]; + mockedGet.mockResolvedValue({ + status: 200, + data: { _embedded: { records } }, + headers: { 'x-ratelimit-limit': '200', 'x-ratelimit-remaining': '150' }, + }); + + const page = await service.getPaymentsPage(WALLET, 'now'); + + expect(page.records).toEqual(records); + expect(page.rateLimitLimit).toBe(200); + expect(page.rateLimitRemaining).toBe(150); + expect(page.httpStatus).toBe(200); + }); + + it('omits cursor param when cursor is "now"', async () => { + mockedGet.mockResolvedValue({ + status: 200, + data: { _embedded: { records: [] } }, + headers: {}, + }); + + await service.getPaymentsPage(WALLET, 'now'); + + const [, config] = mockedGet.mock.calls[0]; + expect(config?.params?.cursor).toBeUndefined(); + }); + + it('includes cursor param when a valid cursor is provided', async () => { + mockedGet.mockResolvedValue({ + status: 200, + data: { _embedded: { records: [] } }, + headers: {}, + }); + + await service.getPaymentsPage(WALLET, '12345678'); + + const [, config] = mockedGet.mock.calls[0]; + expect(config?.params?.cursor).toBe('12345678'); + }); + + it('propagates Horizon errors so callers can inspect status codes', async () => { + const err: any = new Error('Rate limited'); + err.__isAxiosError = true; + err.response = { status: 429 }; + mockedGet.mockRejectedValue(err); + + await expect(service.getPaymentsPage(WALLET)).rejects.toThrow('Rate limited'); + }); + }); + + describe('getAssetInfo', () => { + it('returns native type for XLM', async () => { + const info = await service.getAssetInfo('XLM'); + expect(info).toEqual({ type: 'native', code: 'XLM' }); + }); + + it('returns native type for "native"', async () => { + const info = await service.getAssetInfo('native'); + expect(info).toEqual({ type: 'native', code: 'XLM' }); + }); + + it('returns credit type for non-native assets', async () => { + const issuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + const info = await service.getAssetInfo('USDC', issuer); + expect(info).toEqual({ type: 'credit_alphanum4', code: 'USDC', issuer }); + }); + }); + + describe('getHttpStatusFromError', () => { + it('extracts status from an AxiosError', () => { + const err: any = new Error('not found'); + err.__isAxiosError = true; + err.response = { status: 404 }; + (axios.isAxiosError as unknown as jest.Mock).mockReturnValue(true); + + const status = service.getHttpStatusFromError(err); + expect(status).toBe(404); + }); + + it('returns 0 for plain errors', () => { + (axios.isAxiosError as unknown as jest.Mock).mockReturnValue(false); + const status = service.getHttpStatusFromError(new Error('oops')); + expect(status).toBe(0); + }); + }); +}); diff --git a/src/stellar/stellar.service.ts b/src/stellar/stellar.service.ts index c278cfb..31cacc9 100644 --- a/src/stellar/stellar.service.ts +++ b/src/stellar/stellar.service.ts @@ -1,5 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import axios, { AxiosError } from 'axios'; +import { Config } from '../config/config.schema'; export interface PaymentsPage { records: any[]; @@ -11,7 +13,13 @@ export interface PaymentsPage { @Injectable() export class StellarService { private readonly logger = new Logger(StellarService.name); - readonly horizonUrl = process.env.STELLAR_HORIZON_URL ?? 'https://horizon-testnet.stellar.org'; + readonly horizonUrl: string; + + constructor(private readonly config: ConfigService) { + this.horizonUrl = + this.config.get('STELLAR_HORIZON_URL', { infer: true }) ?? + 'https://horizon-testnet.stellar.org'; + } async getAccountInfo(walletAddress: string) { const { data } = await axios.get(`${this.horizonUrl}/accounts/${walletAddress}`); diff --git a/src/webhook/webhook-queue.service.ts b/src/webhook/webhook-queue.service.ts index 9ed9a62..7830258 100644 --- a/src/webhook/webhook-queue.service.ts +++ b/src/webhook/webhook-queue.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Config } from '../config/config.schema'; import { v4 as uuidv4 } from 'uuid'; import { eq, inArray } from 'drizzle-orm'; import { db } from '../db/index'; @@ -56,8 +58,8 @@ type AttemptLogEntry = import('../db/schema').WebhookDeliveryAttempt; export class WebhookQueueService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(WebhookQueueService.name); - private readonly pollMs = Number(process.env.WEBHOOK_POLL_MS ?? 250); - private maxConcurrency = Number(process.env.WEBHOOK_MAX_CONCURRENCY ?? 100); + private readonly pollMs: number; + private maxConcurrency: number; private timer: NodeJS.Timeout | null = null; private ticking = false; @@ -70,10 +72,15 @@ export class WebhookQueueService implements OnModuleInit, OnModuleDestroy { constructor( private readonly redis: RedisService, private readonly delivery: WebhookDeliveryService, - ) {} + private readonly config: ConfigService, + ) { + this.pollMs = this.config.get('WEBHOOK_POLL_MS', { infer: true }) ?? 250; + this.maxConcurrency = this.config.get('WEBHOOK_MAX_CONCURRENCY', { infer: true }) ?? 100; + } onModuleInit(): void { - if (process.env.NODE_ENV === 'test' || process.env.WEBHOOK_WORKER_DISABLED === 'true') { + const workerDisabled = this.config.get('WEBHOOK_WORKER_DISABLED', { infer: true }); + if (process.env.NODE_ENV === 'test' || workerDisabled === 'true') { this.logger.log('Webhook worker auto-start disabled'); return; }