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/jest.config.ts b/jest.config.ts index 317d69b..7b9e929 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,12 +3,27 @@ import type { Config } from 'jest'; const config: Config = { moduleFileExtensions: ['js', 'json', 'ts'], rootDir: 'src', + // Default: run all specs (unit + integration together) testRegex: '.*\\.spec\\.ts$', transform: { '^.+\\.ts$': 'ts-jest', }, - collectCoverageFrom: ['**/*.ts', '!main.ts', '!index.ts', '!**/*.module.ts'], + collectCoverageFrom: [ + '**/*.ts', + '!main.ts', + '!index.ts', + '!**/*.module.ts', + '!**/*.dto.ts', + '!**/*.constants.ts', + '!db/schema.ts', + ], coverageDirectory: '../coverage', + coverageThreshold: { + global: { lines: 0, functions: 0, branches: 0 }, + './auth/**/*.ts': { lines: 80, functions: 80, branches: 80 }, + './checkout/**/*.ts': { lines: 80, functions: 80, branches: 80 }, + './payments/**/*.ts': { lines: 80, functions: 80, branches: 80 }, + }, testEnvironment: 'node', setupFiles: ['../jest-setup.ts'], }; 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..4499e01 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "start": "node dist/main.js", "dev": "ts-node src/main.ts", "test": "jest", + "test:unit": "jest --testPathPattern='(?); + 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__/auth-flow.integration.spec.ts b/src/__tests__/auth-flow.integration.spec.ts new file mode 100644 index 0000000..dbac4ee --- /dev/null +++ b/src/__tests__/auth-flow.integration.spec.ts @@ -0,0 +1,233 @@ +/** + * Auth flow integration tests (HTTP layer). + * + * Exercises the full SEP-10 challenge/verify flow through the real NestJS + * HTTP stack: AuthController → AuthService → JwtService → JwtStrategy. + * + * Redis and Horizon are mocked so no real network connections are opened. + * The JWT secret comes from jest-setup.ts (process.env.JWT_SECRET) so + * JwtStrategy and JwtModule share the same signing key. + */ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import request from 'supertest'; +import * as StellarSdk from '@stellar/stellar-sdk'; +import axios from 'axios'; +import { ConfigService } from '@nestjs/config'; + +import { AuthController } from '../auth/auth.controller'; +import { AuthService } from '../auth/auth.service'; +import { JwtStrategy } from '../auth/jwt.strategy'; +import { RedisService } from '../redis/redis.service'; + +jest.mock('axios', () => { + const mock: any = jest.fn(); + mock.get = jest.fn(); + mock.post = jest.fn(); + mock.create = jest.fn(() => mock); + mock.interceptors = { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }; + mock.defaults = { headers: { common: {} } }; + return mock; +}); + +const mockedAxios = axios as jest.Mocked; + +// process.env.JWT_SECRET is set to 'test-secret-with-at-least-32-characters' in jest-setup.ts +const JWT_SECRET = process.env.JWT_SECRET!; +const serverKeypair = StellarSdk.Keypair.random(); +const clientKeypair = StellarSdk.Keypair.random(); + +// In-memory nonce store shared across service calls within a test. +const nonceStore: Record = {}; + +const redisMock = { + get: jest.fn((key: string) => Promise.resolve(nonceStore[key] ?? null)), + set: jest.fn((key: string, value: string) => { + nonceStore[key] = value; + return Promise.resolve('OK'); + }), + del: jest.fn((key: string) => { + delete nonceStore[key]; + return Promise.resolve(1); + }), + incr: jest.fn().mockResolvedValue(1), + expire: jest.fn().mockResolvedValue(1), +}; + +describe('Auth flow integration (HTTP)', () => { + let app: INestApplication; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + PassportModule, + JwtModule.register({ secret: JWT_SECRET, signOptions: { expiresIn: '1h' } }), + ], + controllers: [AuthController], + providers: [ + AuthService, + JwtStrategy, + { provide: RedisService, useValue: { getClient: () => redisMock } }, + { + provide: ConfigService, + useValue: { + get: (key: string) => { + const map: Record = { + STELLAR_PLATFORM_SECRET_KEY: serverKeypair.secret(), + CHALLENGE_TTL_SECONDS: 300, + STELLAR_NETWORK: 'testnet', + STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org', + }; + return map[key]; + }, + }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true })); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(nonceStore).forEach((k) => delete nonceStore[k]); + + redisMock.get.mockImplementation((key: string) => Promise.resolve(nonceStore[key] ?? null)); + redisMock.set.mockImplementation((key: string, value: string) => { + nonceStore[key] = value; + return Promise.resolve('OK'); + }); + redisMock.del.mockImplementation((key: string) => { + delete nonceStore[key]; + return Promise.resolve(1); + }); + redisMock.incr.mockResolvedValue(1); + redisMock.expire.mockResolvedValue(1); + }); + + describe('POST /auth/challenge', () => { + it('returns a signed transaction envelope and network passphrase', async () => { + const res = await request(app.getHttpServer()) + .post('/auth/challenge') + .send({ walletAddress: clientKeypair.publicKey() }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('transaction'); + expect(res.body).toHaveProperty('passphrase', StellarSdk.Networks.TESTNET); + expect(res.body).toHaveProperty('expiresAt'); + expect(redisMock.set).toHaveBeenCalledWith( + `challenge:${clientKeypair.publicKey()}`, + expect.any(String), + 'EX', + 300, + ); + }); + + it('returns 400 when walletAddress is missing', async () => { + const res = await request(app.getHttpServer()).post('/auth/challenge').send({}); + expect(res.status).toBe(400); + }); + }); + + describe('challenge → verify → JWT', () => { + it('issues a JWT after a valid client signature', async () => { + // Step 1: request challenge + const challengeRes = await request(app.getHttpServer()) + .post('/auth/challenge') + .send({ walletAddress: clientKeypair.publicKey() }); + + expect(challengeRes.status).toBe(200); + + // Step 2: client signs the envelope + const { transaction: txEnvelope, passphrase } = challengeRes.body; + const tx = StellarSdk.TransactionBuilder.fromXDR( + txEnvelope, + passphrase, + ) as StellarSdk.Transaction; + tx.sign(clientKeypair); + + // Step 3: mock Horizon signer lookup + mockedAxios.get.mockResolvedValue({ + data: { signers: [{ key: clientKeypair.publicKey(), weight: 1 }] }, + }); + + // Step 4: verify challenge + const verifyRes = await request(app.getHttpServer()) + .post('/auth/verify') + .send({ + walletAddress: clientKeypair.publicKey(), + transaction: { tx: tx.toEnvelope().toXDR('base64'), passphrase }, + }); + + expect(verifyRes.status).toBe(200); + expect(verifyRes.body).toHaveProperty('access_token'); + expect(typeof verifyRes.body.access_token).toBe('string'); + expect(verifyRes.body.wallet).toBe(clientKeypair.publicKey()); + + // Step 5: use JWT on a protected endpoint (POST /auth/refresh) + const refreshRes = await request(app.getHttpServer()) + .post('/auth/refresh') + .set('Authorization', `Bearer ${verifyRes.body.access_token}`); + + expect(refreshRes.status).toBe(200); + expect(refreshRes.body).toHaveProperty('access_token'); + }); + }); + + describe('POST /auth/verify — rejection cases', () => { + it('returns 401 when no challenge has been issued for the wallet', async () => { + const res = await request(app.getHttpServer()) + .post('/auth/verify') + .send({ + walletAddress: clientKeypair.publicKey(), + transaction: { tx: 'AAAAAgAAAAA=', passphrase: StellarSdk.Networks.TESTNET }, + }); + + expect(res.status).toBe(401); + }); + + it('returns 401 when the client signs with the wrong key', async () => { + const challengeRes = await request(app.getHttpServer()) + .post('/auth/challenge') + .send({ walletAddress: clientKeypair.publicKey() }); + + const { transaction: txEnvelope, passphrase } = challengeRes.body; + const tx = StellarSdk.TransactionBuilder.fromXDR( + txEnvelope, + passphrase, + ) as StellarSdk.Transaction; + tx.sign(StellarSdk.Keypair.random()); // wrong key + + mockedAxios.get.mockResolvedValue({ + data: { signers: [{ key: clientKeypair.publicKey(), weight: 1 }] }, + }); + + const res = await request(app.getHttpServer()) + .post('/auth/verify') + .send({ + walletAddress: clientKeypair.publicKey(), + transaction: { tx: tx.toEnvelope().toXDR('base64'), passphrase }, + }); + + expect(res.status).toBe(401); + }); + }); + + describe('POST /auth/refresh — protected endpoint', () => { + it('returns 401 when no Authorization header is provided', async () => { + const res = await request(app.getHttpServer()).post('/auth/refresh'); + expect(res.status).toBe(401); + }); + }); +}); diff --git a/src/__tests__/checkout.integration.spec.ts b/src/__tests__/checkout.integration.spec.ts new file mode 100644 index 0000000..0b08f70 --- /dev/null +++ b/src/__tests__/checkout.integration.spec.ts @@ -0,0 +1,174 @@ +/** + * Integration tests for the checkout session lifecycle. + * + * Uses @nestjs/testing with real NestJS DI: controller, pipes, and guards + * wired through the NestJS lifecycle. All I/O dependencies are mocked so + * no real database or Redis connection is required. + */ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; +import request from 'supertest'; +import { CheckoutController } from '../checkout/checkout.controller'; +import { CheckoutService } from '../checkout/checkout.service'; +import { AuditService } from '../audit/audit.service'; +import { ApiKeyGuard } from '../auth/api-key.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { ResourceOwnershipGuard } from '../auth/resource-ownership.guard'; +import { MerchantsService } from '../merchants/merchants.service'; + +const MERCHANT_ID = 'aaaaaaaa-0000-4000-8000-aaaaaaaaaaaa'; + +const stubSession = { + id: 'cccccccc-0000-4000-8000-cccccccccccc', + url: 'https://checkout.example.com/checkout/cccccccc-0000-4000-8000-cccccccccccc', + amount: '25.0000000', + asset: 'USDC', + status: 'pending' as const, + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), +}; + +// Guard that always passes and injects a fixed merchantId +const passThroughApiKeyGuard = { + canActivate: (ctx: any) => { + ctx.switchToHttp().getRequest().merchantId = MERCHANT_ID; + return true; + }, +}; + +const passThroughGuard = { canActivate: () => true }; + +const mockCheckoutService = { + createSession: jest.fn(), + getSession: jest.fn(), + cancelSession: jest.fn(), +}; + +const mockMerchantsService = { + validateApiKey: jest.fn().mockResolvedValue(MERCHANT_ID), + findById: jest.fn(), +}; + +describe('Checkout session lifecycle (integration)', () => { + let app: INestApplication; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CheckoutController], + providers: [ + { provide: CheckoutService, useValue: mockCheckoutService }, + { provide: AuditService, useValue: { logSensitiveOperation: jest.fn() } }, + { provide: MerchantsService, useValue: mockMerchantsService }, + Reflector, + ], + }) + .overrideGuard(ApiKeyGuard) + .useValue(passThroughApiKeyGuard) + .overrideGuard(RolesGuard) + .useValue(passThroughGuard) + .overrideGuard(ResourceOwnershipGuard) + .useValue(passThroughGuard) + .compile(); + + app = module.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockCheckoutService.createSession.mockResolvedValue(stubSession); + mockCheckoutService.getSession.mockResolvedValue(stubSession); + mockCheckoutService.cancelSession.mockResolvedValue({ ...stubSession, status: 'cancelled' }); + }); + + // ── Create session ────────────────────────────────────────────────────────── + + describe('POST /v1/checkout/sessions — create checkout session', () => { + it('returns 201 with session id and URL', async () => { + const res = await request(app.getHttpServer()) + .post('/v1/checkout/sessions') + .set('Authorization', 'Bearer sk_test_dummy') + .send({ amount: 25, asset: 'USDC' }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ id: stubSession.id, status: 'pending' }); + expect(res.body.url).toContain('/checkout/'); + expect(mockCheckoutService.createSession).toHaveBeenCalledWith( + MERCHANT_ID, + expect.objectContaining({ amount: 25, asset: 'USDC' }), + ); + }); + + it('returns 400 when amount is missing', async () => { + const res = await request(app.getHttpServer()) + .post('/v1/checkout/sessions') + .set('Authorization', 'Bearer sk_test_dummy') + .send({ asset: 'USDC' }); + + expect(res.status).toBe(400); + expect(mockCheckoutService.createSession).not.toHaveBeenCalled(); + }); + + it('returns 400 when asset is missing', async () => { + const res = await request(app.getHttpServer()) + .post('/v1/checkout/sessions') + .set('Authorization', 'Bearer sk_test_dummy') + .send({ amount: 25 }); + + expect(res.status).toBe(400); + }); + }); + + // ── Get session status ────────────────────────────────────────────────────── + + describe('GET /v1/checkout/sessions/:id — get session status', () => { + it('returns 200 with public session fields', async () => { + const res = await request(app.getHttpServer()).get(`/v1/checkout/sessions/${stubSession.id}`); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ id: stubSession.id, status: 'pending' }); + expect(res.body).not.toHaveProperty('memo'); + expect(res.body).not.toHaveProperty('receivingAccount'); + }); + + it('returns 404 when session does not exist', async () => { + const { NotFoundException } = await import('@nestjs/common'); + mockCheckoutService.getSession.mockRejectedValue(new NotFoundException('Session not found')); + + const res = await request(app.getHttpServer()).get('/v1/checkout/sessions/nonexistent'); + + expect(res.status).toBe(404); + }); + }); + + // ── Cancel session ────────────────────────────────────────────────────────── + + describe('POST /v1/checkout/sessions/:id/cancel — cancel session', () => { + it('returns 201 and delegates to cancelSession', async () => { + const res = await request(app.getHttpServer()) + .post(`/v1/checkout/sessions/${stubSession.id}/cancel`) + .set('Authorization', 'Bearer sk_test_dummy'); + + expect(res.status).toBe(201); + expect(mockCheckoutService.cancelSession).toHaveBeenCalledWith(stubSession.id, MERCHANT_ID); + }); + + it('returns 400 when session is not cancellable', async () => { + const { BadRequestException } = await import('@nestjs/common'); + mockCheckoutService.cancelSession.mockRejectedValue( + new BadRequestException('Session is not pending'), + ); + + const res = await request(app.getHttpServer()) + .post(`/v1/checkout/sessions/${stubSession.id}/cancel`) + .set('Authorization', 'Bearer sk_test_dummy'); + + expect(res.status).toBe(400); + }); + }); +}); diff --git a/src/__tests__/fixtures/merchant.fixture.ts b/src/__tests__/fixtures/merchant.fixture.ts new file mode 100644 index 0000000..a236532 --- /dev/null +++ b/src/__tests__/fixtures/merchant.fixture.ts @@ -0,0 +1,22 @@ +export const merchantFixture = { + id: 'aaaaaaaa-0000-4000-8000-aaaaaaaaaaaa', + walletAddress: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + businessName: 'Test Merchant', + email: 'merchant@example.com', + role: 'merchant' as const, + webhookUrl: 'https://merchant.example.com/webhook', + webhookSecret: 'whsec_test1234567890', + logoUrl: null, + corsOrigins: [], + createdAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +export const apiKeyFixture = { + id: 'bbbbbbbb-0000-4000-8000-bbbbbbbbbbbb', + merchantId: merchantFixture.id, + keyPrefix: 'sk_test_abc...', + keyHash: 'hash123', + environment: 'testnet' as const, + isActive: true, + createdAt: new Date('2026-01-01T00:00:00.000Z'), +}; diff --git a/src/__tests__/fixtures/session.fixture.ts b/src/__tests__/fixtures/session.fixture.ts new file mode 100644 index 0000000..a70903a --- /dev/null +++ b/src/__tests__/fixtures/session.fixture.ts @@ -0,0 +1,30 @@ +import { merchantFixture } from './merchant.fixture'; + +export const sessionFixture = { + id: 'cccccccc-0000-4000-8000-cccccccccccc', + merchantId: merchantFixture.id, + amount: '10.0000000', + assetCode: 'USDC', + assetIssuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + receivingAccount: 'GBPLATFORM00000000000000000000000000000000000000000000000', + memo: 'deadbeef12345678', + status: 'pending' as const, + successUrl: 'https://merchant.example.com/success', + cancelUrl: 'https://merchant.example.com/cancel', + metadata: { orderId: 'order-123' }, + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + createdAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +export const expiredSessionFixture = { + ...sessionFixture, + id: 'dddddddd-0000-4000-8000-dddddddddddd', + status: 'pending' as const, + expiresAt: new Date(Date.now() - 1000), +}; + +export const paidSessionFixture = { + ...sessionFixture, + id: 'eeeeeeee-0000-4000-8000-eeeeeeeeeeee', + status: 'paid' as const, +}; diff --git a/src/__tests__/helpers/stellar.mock.ts b/src/__tests__/helpers/stellar.mock.ts new file mode 100644 index 0000000..acc894a --- /dev/null +++ b/src/__tests__/helpers/stellar.mock.ts @@ -0,0 +1,66 @@ +import { StellarService, PaymentsPage } from '../../stellar/stellar.service'; + +export const mockAccountInfo = { + id: 'GBTEST1234567890123456789012345678901234567890123456789', + sequence: '123456789', + balances: [ + { asset_type: 'native', balance: '100.0000000' }, + { + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + balance: '500.0000000', + }, + ], +}; + +export const mockHorizonPayment = { + id: 'op-1', + type: 'payment', + transaction_hash: 'txhash-abc123', + from: 'GBBUYER000000000000000000000000000000000000000000000000', + to: 'GBTEST1234567890123456789012345678901234567890123456789', + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + amount: '10.0000000', + transaction: { + memo: 'test-memo-0001', + memo_type: 'text', + successful: true, + }, +}; + +export function createStellarServiceMock( + overrides: Partial> = {}, +): jest.Mocked { + const defaultPage: PaymentsPage = { + records: [], + rateLimitLimit: 100, + rateLimitRemaining: 90, + httpStatus: 200, + }; + + return { + horizonUrl: 'https://horizon-testnet.stellar.org', + getAccountInfo: jest.fn().mockResolvedValue(mockAccountInfo), + getBalance: jest.fn().mockResolvedValue(100.0), + verifyTransaction: jest.fn().mockResolvedValue(true), + getTransactionOperations: jest.fn().mockResolvedValue([]), + getPaymentsForAccount: jest.fn().mockResolvedValue([]), + getPaymentsPage: jest.fn().mockResolvedValue(defaultPage), + ...overrides, + } as any; +} + +/** Returns a PaymentsPage fixture with a single matching payment. */ +export function createPaymentsPageWithPayment( + overrides: Partial = {}, +): PaymentsPage { + return { + records: [{ ...mockHorizonPayment, ...overrides }], + rateLimitLimit: 100, + rateLimitRemaining: 90, + httpStatus: 200, + }; +} diff --git a/src/__tests__/helpers/test-auth.ts b/src/__tests__/helpers/test-auth.ts new file mode 100644 index 0000000..2bd5f7b --- /dev/null +++ b/src/__tests__/helpers/test-auth.ts @@ -0,0 +1,48 @@ +import * as crypto from 'crypto'; + +const TEST_JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret-with-at-least-32-characters'; + +function base64url(input: string): string { + return Buffer.from(input).toString('base64url'); +} + +/** + * Creates a minimal HS256 JWT for a given merchantId. Suitable for testing + * endpoints that require a valid Bearer token. + */ +export function getAuthToken(merchantId: string, ttlSeconds = 3600): string { + const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payload = base64url( + JSON.stringify({ + sub: merchantId, + merchantId, + exp: Math.floor(Date.now() / 1000) + ttlSeconds, + iat: Math.floor(Date.now() / 1000), + }), + ); + const sig = crypto + .createHmac('sha256', TEST_JWT_SECRET) + .update(`${header}.${payload}`) + .digest('base64url'); + return `${header}.${payload}.${sig}`; +} + +/** + * Returns a Bearer authorization header for the given merchantId. + */ +export function bearerHeader(merchantId: string): { Authorization: string } { + return { Authorization: `Bearer ${getAuthToken(merchantId)}` }; +} + +/** + * Generates a realistic API key string (not hashed) for testing. + * Format matches the `sk_test_` prefix used by MerchantsService. + */ +export function getApiKey(environment: 'testnet' | 'mainnet' = 'testnet'): string { + const prefix = environment === 'testnet' ? 'sk_test_' : 'sk_live_'; + return prefix + crypto.randomBytes(24).toString('hex'); +} + +export function apiKeyHeader(key: string): { Authorization: string } { + return { Authorization: `Bearer ${key}` }; +} 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__/payment-flow.integration.spec.ts b/src/__tests__/payment-flow.integration.spec.ts new file mode 100644 index 0000000..c674950 --- /dev/null +++ b/src/__tests__/payment-flow.integration.spec.ts @@ -0,0 +1,159 @@ +/** + * Payment flow integration test. + * + * Exercises the full path from a Horizon payment fixture through + * PaymentDetectorService.processPayment(), verifying that the session + * transitions to 'paid' and a webhook is dispatched. + * + * Uses `createPaymentsPageWithPayment` to build the Horizon fixture and + * then maps it to the flat op shape that processPayment expects. + */ +jest.mock('../db/index', () => ({ + db: { transaction: jest.fn() }, + client: {}, +})); + +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'; +import { WebhookService } from '../webhook/webhook.service'; +import { MetricsService } from '../monitoring/metrics.service'; +import { db } from '../db/index'; +import { + createStellarServiceMock, + createPaymentsPageWithPayment, + mockHorizonPayment, +} from './helpers/stellar.mock'; + +const mockDb = db as jest.Mocked; + +const SESSION_ID = 'sess-pay-flow-001'; +const MERCHANT_ID = 'merchant-pay-flow-001'; +const SESSION_MEMO = mockHorizonPayment.transaction.memo; +const SESSION_AMOUNT = mockHorizonPayment.amount; +const SESSION_ASSET = mockHorizonPayment.asset_code; + +/** Build a processPayment op from a Horizon fixture record. */ +function opFromFixture(overrides: Partial = {}) { + const record = createPaymentsPageWithPayment(overrides).records[0]; + return { + ...record, + // Horizon embeds memo on transaction; processPayment reads transaction_memo. + transaction_memo: record.transaction?.memo, + }; +} + +function makeClaimTx(session: Record) { + const returningMock = jest.fn().mockResolvedValue([{ id: session['id'] }]); + const setMock = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ returning: returningMock }), + }); + const updateMock = jest.fn().mockReturnValue({ set: setMock }); + const tx = { + execute: jest.fn().mockResolvedValue([session]), + query: { payments: { findFirst: jest.fn().mockResolvedValue(null) } }, + update: updateMock, + }; + return { tx, setMock }; +} + +function makeFinalizeTx() { + const onConflictDoNothingMock = jest.fn().mockResolvedValue(undefined); + const valuesMock = jest.fn().mockReturnValue({ onConflictDoNothing: onConflictDoNothingMock }); + const insertMock = jest.fn().mockReturnValue({ values: valuesMock }); + const updateWhereMock = jest.fn().mockResolvedValue(undefined); + const setMock = jest.fn().mockReturnValue({ where: updateWhereMock }); + const updateMock = jest.fn().mockReturnValue({ set: setMock }); + return { tx: { insert: insertMock, update: updateMock }, setMock }; +} + +describe('Payment flow integration', () => { + let service: PaymentDetectorService; + let mockWebhooks: jest.Mocked; + let mockMetrics: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + const mockStellar: jest.Mocked = createStellarServiceMock(); + mockWebhooks = { dispatchWebhook: jest.fn() } as any; + mockMetrics = { paymentsConfirmed: { inc: jest.fn() } } as any; + const mockCursorService = { + restoreCursor: jest.fn(), + updateCursor: jest.fn(), + appendCheckpoint: jest.fn(), + acquireLock: jest.fn(), + renewLock: jest.fn(), + releaseLock: jest.fn(), + } as unknown as jest.Mocked; + const mockConfig = { get: () => undefined } as unknown as ConfigService; + + service = new PaymentDetectorService( + mockStellar, + mockWebhooks, + mockMetrics, + mockCursorService, + mockConfig, + ); + }); + + it('marks session paid and dispatches webhook when payment matches memo, amount, and asset', async () => { + const session = { + id: SESSION_ID, + merchant_id: MERCHANT_ID, + amount: SESSION_AMOUNT, + asset_code: SESSION_ASSET, + memo: SESSION_MEMO, + status: 'pending', + }; + const claim = makeClaimTx(session); + const finalize = makeFinalizeTx(); + + (mockDb.transaction as jest.Mock) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(claim.tx)) + .mockImplementationOnce(async (fn: (tx: any) => Promise) => fn(finalize.tx)); + + await (service as any).processPayment(opFromFixture()); + + expect(claim.setMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'processing' })); + expect(finalize.setMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'paid' })); + expect(mockMetrics.paymentsConfirmed.inc).toHaveBeenCalledTimes(1); + expect(mockWebhooks.dispatchWebhook).toHaveBeenCalledWith( + MERCHANT_ID, + 'payment.confirmed', + expect.objectContaining({ sessionId: SESSION_ID }), + ); + }); + + it('does not dispatch webhook when payment amount does not match', async () => { + const session = { + id: SESSION_ID, + merchant_id: MERCHANT_ID, + amount: '999.0000000', + asset_code: SESSION_ASSET, + memo: SESSION_MEMO, + status: 'pending', + }; + const claim = makeClaimTx(session); + + (mockDb.transaction as jest.Mock).mockImplementationOnce( + async (fn: (tx: any) => Promise) => fn(claim.tx), + ); + + await (service as any).processPayment(opFromFixture()); + + expect(mockMetrics.paymentsConfirmed.inc).not.toHaveBeenCalled(); + expect(mockWebhooks.dispatchWebhook).not.toHaveBeenCalled(); + }); + + it('silently skips operations that are not type "payment"', async () => { + await (service as any).processPayment({ + type: 'create_account', + transaction_memo: SESSION_MEMO, + }); + + expect(mockDb.transaction).not.toHaveBeenCalled(); + expect(mockWebhooks.dispatchWebhook).not.toHaveBeenCalled(); + }); +}); 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..855b4e5 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,79 @@ 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({ @@ -131,5 +220,40 @@ describe('CheckoutService', () => { }), ); }); + + it('should throw NotFoundException when session does not belong to merchant', async () => { + (db.query.checkoutSessions.findFirst as jest.Mock).mockResolvedValue(undefined); + + await expect(service.cancelSession('sess-other', 'merchant-1')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException when session is not pending', async () => { + (db.query.checkoutSessions.findFirst as jest.Mock).mockResolvedValue({ + id: 'sess-paid', + merchantId: 'merchant-1', + status: 'paid', + }); + + await expect(service.cancelSession('sess-paid', 'merchant-1')).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('markAsPaid', () => { + it('should update session status to "paid"', async () => { + (db.update as jest.Mock).mockReturnValue({ + set: jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + returning: jest.fn().mockResolvedValue([{ id: 'sess-1', status: 'paid' }]), + }), + }), + }); + + const result = await service.markAsPaid('sess-1'); + expect(result).toMatchObject({ id: 'sess-1', status: 'paid' }); + }); }); }); 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; }