diff --git a/jest.config.js b/jest.config.js index 0941da5..d4ce0ec 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,5 +8,6 @@ module.exports = { transform: { ...tsJestTransformCfg, }, + roots: ["/src"], setupFiles: ["./jest.setup.ts"], }; \ No newline at end of file diff --git a/src/__tests__/integration/creator-list-concurrent.test.ts b/src/__tests__/integration/creator-list-concurrent.test.ts new file mode 100644 index 0000000..db537c2 --- /dev/null +++ b/src/__tests__/integration/creator-list-concurrent.test.ts @@ -0,0 +1,99 @@ +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; + +const FIXTURE_SIZE = 50; +const CONCURRENT_REQUESTS = 3; + +interface PaginationMeta { + limit: number; + offset: number; + total: number; + hasMore: boolean; +} + +interface ResponseEnvelope { + success: boolean; + data: { + items: unknown[]; + meta: PaginationMeta; + }; +} + +describe('GET /api/v1/creators — concurrent requests return consistent results', () => { + beforeAll(async () => { + const usersToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({ + id: `concurrent-test-user-${i}`, + email: `concurrent-test-user-${i}@example.com`, + passwordHash: 'dummy-hash', + firstName: 'Concurrent', + lastName: `TestUser ${i}`, + })); + + await prisma.user.createMany({ + data: usersToCreate, + skipDuplicates: true, + }); + + const creatorsToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({ + userId: `concurrent-test-user-${i}`, + handle: `concurrent-test-creator-${i}`, + displayName: `Concurrent Test Creator ${i}`, + })); + + await prisma.creatorProfile.createMany({ + data: creatorsToCreate, + skipDuplicates: true, + }); + }); + + afterAll(async () => { + await prisma.creatorProfile.deleteMany({ + where: { handle: { startsWith: 'concurrent-test-creator-' } }, + }); + + await prisma.user.deleteMany({ + where: { id: { startsWith: 'concurrent-test-user-' } }, + }); + + await prisma.$disconnect(); + }); + + it('returns identical item sets and metadata across simultaneous requests', async () => { + const requests = Array.from({ length: CONCURRENT_REQUESTS }, () => + supertest(app).get('/api/v1/creators?limit=20') + ); + + const responses = await Promise.all(requests); + + for (const res of responses) { + expect(res.status).toBe(200); + } + + const bodies = responses.map((r) => r.body as ResponseEnvelope); + + for (let i = 1; i < bodies.length; i++) { + expect(bodies[i].success).toBe(bodies[0].success); + expect(bodies[i].data.items).toEqual(bodies[0].data.items); + expect(bodies[i].data.meta).toEqual(bodies[0].data.meta); + } + }); + + it('returns the same total count across all concurrent requests', async () => { + const requests = Array.from({ length: CONCURRENT_REQUESTS }, () => + supertest(app).get('/api/v1/creators?limit=10') + ); + + const responses = await Promise.all(requests); + + for (const res of responses) { + expect(res.status).toBe(200); + } + + const bodies = responses.map((r) => r.body as ResponseEnvelope); + + for (const body of bodies) { + expect(body.data.meta.total).toBe(FIXTURE_SIZE); + } + }); +}); diff --git a/src/__tests__/integration/creator-list-response-shape.test.ts b/src/__tests__/integration/creator-list-response-shape.test.ts new file mode 100644 index 0000000..a7dc307 --- /dev/null +++ b/src/__tests__/integration/creator-list-response-shape.test.ts @@ -0,0 +1,131 @@ +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; + +const FIXTURE_SIZE = 150; +const MAX_PAGE_SIZE = 100; + +interface PaginationMeta { + limit: number; + offset: number; + total: number; + hasMore: boolean; +} + +interface ResponseEnvelope { + success: boolean; + data: { + items: unknown[]; + meta: PaginationMeta; + }; +} + +describe('GET /api/v1/creators — response shape consistency across page sizes', () => { + beforeAll(async () => { + const usersToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({ + id: `shape-test-user-${i}`, + email: `shape-test-user-${i}@example.com`, + passwordHash: 'dummy-hash', + firstName: 'Shape', + lastName: `TestUser ${i}`, + })); + + await prisma.user.createMany({ + data: usersToCreate, + skipDuplicates: true, + }); + + const creatorsToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({ + userId: `shape-test-user-${i}`, + handle: `shape-test-creator-${i}`, + displayName: `Shape Test Creator ${i}`, + })); + + await prisma.creatorProfile.createMany({ + data: creatorsToCreate, + skipDuplicates: true, + }); + }); + + afterAll(async () => { + await prisma.creatorProfile.deleteMany({ + where: { handle: { startsWith: 'shape-test-creator-' } }, + }); + + await prisma.user.deleteMany({ + where: { id: { startsWith: 'shape-test-user-' } }, + }); + + await prisma.$disconnect(); + }); + + async function fetchPage(limit: number): Promise { + const res = await supertest(app).get(`/api/v1/creators?limit=${limit}`); + expect(res.status).toBe(200); + return res.body as ResponseEnvelope; + } + + it('returns the same top-level response envelope shape for all page sizes', async () => { + const [page1, page10, page100] = await Promise.all([ + fetchPage(1), + fetchPage(10), + fetchPage(MAX_PAGE_SIZE), + ]); + + const topLevelKeys = ['success', 'data']; + expect(Object.keys(page1).sort()).toEqual(topLevelKeys); + expect(Object.keys(page10).sort()).toEqual(topLevelKeys); + expect(Object.keys(page100).sort()).toEqual(topLevelKeys); + + const dataKeys = ['items', 'meta']; + expect(Object.keys(page1.data).sort()).toEqual(dataKeys); + expect(Object.keys(page10.data).sort()).toEqual(dataKeys); + expect(Object.keys(page100.data).sort()).toEqual(dataKeys); + + const metaKeys = ['limit', 'offset', 'total', 'hasMore']; + expect(Object.keys(page1.data.meta).sort()).toEqual(metaKeys); + expect(Object.keys(page10.data.meta).sort()).toEqual(metaKeys); + expect(Object.keys(page100.data.meta).sort()).toEqual(metaKeys); + }); + + it('returns items arrays whose lengths match the requested page size', async () => { + const [page1, page10, page100] = await Promise.all([ + fetchPage(1), + fetchPage(10), + fetchPage(MAX_PAGE_SIZE), + ]); + + expect(page1.data.items).toHaveLength(1); + expect(page10.data.items).toHaveLength(10); + expect(page100.data.items).toHaveLength(MAX_PAGE_SIZE); + }); + + it('returns pagination metadata that reflects the requested page size', async () => { + const [page1, page10, page100] = await Promise.all([ + fetchPage(1), + fetchPage(10), + fetchPage(MAX_PAGE_SIZE), + ]); + + expect(page1.data.meta).toMatchObject({ + limit: 1, + offset: 0, + total: FIXTURE_SIZE, + hasMore: true, + }); + + expect(page10.data.meta).toMatchObject({ + limit: 10, + offset: 0, + total: FIXTURE_SIZE, + hasMore: true, + }); + + expect(page100.data.meta).toMatchObject({ + limit: MAX_PAGE_SIZE, + offset: 0, + total: FIXTURE_SIZE, + hasMore: true, + }); + }); +}); diff --git a/src/middlewares/wallet-ownership.middleware.test.ts b/src/middlewares/wallet-ownership.middleware.test.ts index c08f0c3..a1dcb48 100644 --- a/src/middlewares/wallet-ownership.middleware.test.ts +++ b/src/middlewares/wallet-ownership.middleware.test.ts @@ -11,6 +11,9 @@ const mockedCheck = typeof walletOwnership.checkCreatorProfileOwnership >; +const VALID_STELLAR_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + function buildRes() { const json = jest.fn(); const status = jest.fn().mockImplementation(() => ({ json })); @@ -49,8 +52,36 @@ describe('requireCreatorProfileOwnership', () => { expect(mockedCheck).not.toHaveBeenCalled(); }); + it('returns 400 when the wallet address has wrong length', async () => { + const req = buildReq({ address: 'GSHORT', creatorId: 'alice' }); + const res = buildRes(); + const next = jest.fn(); + + await requireCreatorProfileOwnership()(req, res, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(400); + expect(next).not.toHaveBeenCalled(); + expect(mockedCheck).not.toHaveBeenCalled(); + }); + + it('returns 400 when the wallet address has invalid characters', async () => { + const req = buildReq({ + address: + 'G!!!!!AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + creatorId: 'alice', + }); + const res = buildRes(); + const next = jest.fn(); + + await requireCreatorProfileOwnership()(req, res, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(400); + expect(next).not.toHaveBeenCalled(); + expect(mockedCheck).not.toHaveBeenCalled(); + }); + it('returns 400 when the path parameter is missing', async () => { - const req = buildReq({ address: 'GABC' }); + const req = buildReq({ address: VALID_STELLAR_ADDRESS }); const res = buildRes(); const next = jest.fn(); @@ -63,15 +94,15 @@ describe('requireCreatorProfileOwnership', () => { it('returns 401 when the helper reports an unknown wallet', async () => { mockedCheck.mockResolvedValue({ status: 'wallet_not_found', - address: 'GABC', + address: VALID_STELLAR_ADDRESS, }); - const req = buildReq({ address: 'GABC', creatorId: 'alice' }); + const req = buildReq({ address: VALID_STELLAR_ADDRESS, creatorId: 'alice' }); const res = buildRes(); const next = jest.fn(); await requireCreatorProfileOwnership()(req, res, next as NextFunction); - expect(mockedCheck).toHaveBeenCalledWith('GABC', 'alice'); + expect(mockedCheck).toHaveBeenCalledWith(VALID_STELLAR_ADDRESS, 'alice'); expect(res.status).toHaveBeenCalledWith(401); expect(next).not.toHaveBeenCalled(); }); @@ -79,10 +110,10 @@ describe('requireCreatorProfileOwnership', () => { it('returns 403 when the helper reports forbidden ownership', async () => { mockedCheck.mockResolvedValue({ status: 'forbidden', - address: 'GABC', + address: VALID_STELLAR_ADDRESS, ownerUserId: 'someone-else', }); - const req = buildReq({ address: 'GABC', creatorId: 'alice' }); + const req = buildReq({ address: VALID_STELLAR_ADDRESS, creatorId: 'alice' }); const res = buildRes(); const next = jest.fn(); @@ -97,7 +128,7 @@ describe('requireCreatorProfileOwnership', () => { status: 'granted', ownerUserId: 'user-1', }); - const req = buildReq({ address: 'GABC', creatorId: 'alice' }); + const req = buildReq({ address: VALID_STELLAR_ADDRESS, creatorId: 'alice' }); const res = buildRes(); const next = jest.fn(); @@ -105,7 +136,7 @@ describe('requireCreatorProfileOwnership', () => { expect(next).toHaveBeenCalledWith(); expect((req as Request & { walletAddress?: string }).walletAddress).toBe( - 'GABC' + VALID_STELLAR_ADDRESS ); expect((req as Request & { ownerUserId?: string }).ownerUserId).toBe( 'user-1' @@ -113,13 +144,13 @@ describe('requireCreatorProfileOwnership', () => { expect(res.status).not.toHaveBeenCalled(); }); - it('uses the first value of an array-form wallet header', async () => { + it('uses the first value of an array-form wallet header when all are valid', async () => { mockedCheck.mockResolvedValue({ status: 'granted', ownerUserId: 'user-1', }); const req = buildReq({ - address: ['GFIRST', 'GSECOND'], + address: [VALID_STELLAR_ADDRESS, 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'], creatorId: 'alice', }); const res = buildRes(); @@ -127,13 +158,28 @@ describe('requireCreatorProfileOwnership', () => { await requireCreatorProfileOwnership()(req, res, next as NextFunction); - expect(mockedCheck).toHaveBeenCalledWith('GFIRST', 'alice'); + expect(mockedCheck).toHaveBeenCalledWith(VALID_STELLAR_ADDRESS, 'alice'); expect(next).toHaveBeenCalledWith(); }); - it('returns 500 when the helper throws', async () => { + it('returns 400 when the first of an array-form wallet header is invalid', async () => { + const req = buildReq({ + address: ['GSHORT', VALID_STELLAR_ADDRESS], + creatorId: 'alice', + }); + const res = buildRes(); + const next = jest.fn(); + + await requireCreatorProfileOwnership()(req, res, next as NextFunction); + + expect(res.status).toHaveBeenCalledWith(400); + expect(next).not.toHaveBeenCalled(); + expect(mockedCheck).not.toHaveBeenCalled(); + }); + + it('returns 500 when the helper throws with a valid address', async () => { mockedCheck.mockRejectedValue(new Error('db down')); - const req = buildReq({ address: 'GABC', creatorId: 'alice' }); + const req = buildReq({ address: VALID_STELLAR_ADDRESS, creatorId: 'alice' }); const res = buildRes(); const next = jest.fn(); const errorSpy = jest diff --git a/src/middlewares/wallet-ownership.middleware.ts b/src/middlewares/wallet-ownership.middleware.ts index 8235061..9ab60d2 100644 --- a/src/middlewares/wallet-ownership.middleware.ts +++ b/src/middlewares/wallet-ownership.middleware.ts @@ -12,6 +12,7 @@ import { Request, Response, NextFunction } from 'express'; import { checkCreatorProfileOwnership } from '../utils/wallet-ownership.utils'; import { ErrorCode, sendError } from '../utils/api-response.utils'; +import { StellarAddressSchema } from '../modules/wallet/wallet.schemas'; export interface WalletOwnedRequest extends Request { walletAddress?: string; @@ -55,6 +56,21 @@ export function requireCreatorProfileOwnership( return; } + const addressValidation = StellarAddressSchema.safeParse(address); + if (!addressValidation.success) { + sendError( + res, + 400, + ErrorCode.BAD_REQUEST, + 'Invalid wallet address format. Stellar address must be 56 characters, start with G, and use Base32 characters.', + addressValidation.error.issues.map((issue) => ({ + field: 'x-wallet-address', + message: issue.message, + })) + ); + return; + } + const rawParam = req.params[paramName]; const creatorIdOrHandle = Array.isArray(rawParam) ? rawParam[0] diff --git a/src/modules/creator/creator-profile-protected.integration.test.ts b/src/modules/creator/creator-profile-protected.integration.test.ts index 5e42379..0ebaae3 100644 --- a/src/modules/creator/creator-profile-protected.integration.test.ts +++ b/src/modules/creator/creator-profile-protected.integration.test.ts @@ -111,4 +111,62 @@ describe('PUT /api/v1/creators/:creatorId/profile — protected route heade }) ); }); + + it('returns 400 when the wallet address has wrong length', async () => { + const res = await supertest(app) + .put('/api/v1/creators/creator-1/profile') + .set('x-wallet-address', 'GSHORT') + .send({ displayName: 'Alice Example' }); + + expect(res.status).toBe(400); + expect(res.body).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'BAD_REQUEST', + }), + }) + ); + expect(res.body.error.details).toBeDefined(); + expect(res.body.error.details[0].field).toBe('x-wallet-address'); + }); + + it('returns 400 when the wallet address has invalid characters', async () => { + const res = await supertest(app) + .put('/api/v1/creators/creator-1/profile') + .set( + 'x-wallet-address', + 'G!!!!!AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ) + .send({ displayName: 'Alice Example' }); + + expect(res.status).toBe(400); + expect(res.body).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'BAD_REQUEST', + }), + }) + ); + expect(res.body.error.details).toBeDefined(); + expect(res.body.error.details[0].field).toBe('x-wallet-address'); + }); + + it('allows requests with a valid Stellar address format to reach the handler', async () => { + const res = await supertest(app) + .put('/api/v1/creators/creator-1/profile') + .set( + 'x-wallet-address', + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ) + .send({ displayName: 'Alice Example' }); + + expect(res.status).toBe(202); + expect(res.body).toEqual( + expect.objectContaining({ + success: true, + }) + ); + }); }); diff --git a/src/modules/index.ts b/src/modules/index.ts index e543c36..0ace237 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -3,6 +3,7 @@ import authRouter from './auth/auth.routes'; import healthRouter from './health/health.routes'; import configRouter from './config/config.routes'; import creatorsRouter from './creators/creators.routes'; +import creatorRouter from './creator/creator.routes'; import metricsRouter from './metrics/metrics.routes'; import ledgerRouter from './ledger/ledger.routes'; import adminRouter from './admin/admin.routes'; @@ -16,6 +17,7 @@ router.use('/health', healthRouter); router.use('/auth', authRouter); router.use('/config', configRouter); router.use(CREATORS_BASE, creatorsRouter); +router.use(CREATORS_BASE, creatorRouter); router.use('/metrics', metricsRouter); router.use('/ledger', ledgerRouter); router.use('/admin', adminRouter); diff --git a/src/server.ts b/src/server.ts index ec96e11..b4a4dee 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,7 @@ import { import { checkOptionalDependencies } from './utils/startup.utils'; import { describeDatabasePoolConfig } from './utils/db-pool-config.utils'; import { stopOwnershipSnapshotCleanupJob } from './jobs/ownership-snapshot-cleanup.job'; +import { maskSensitiveConfigValues } from './utils/config-mask.utils'; async function startServer() { try { @@ -41,6 +42,14 @@ async function startServer() { 'Database connection pool configured' ); + // Log startup config summary with sensitive values masked. + // See `maskSensitiveConfigValues` in utils/config-mask.utils.ts for + // the list of patterns considered sensitive. + logger.info( + maskSensitiveConfigValues(), + 'Startup configuration summary' + ); + // Verify migrations on startup await verifyMigrationChecksums(); diff --git a/src/utils/config-mask.utils.test.ts b/src/utils/config-mask.utils.test.ts new file mode 100644 index 0000000..593fa7c --- /dev/null +++ b/src/utils/config-mask.utils.test.ts @@ -0,0 +1,96 @@ +jest.mock('../config', () => ({ + envConfig: { + PORT: 3000, + MODE: 'test', + DATABASE_URL: 'postgresql://user:supersecret@localhost:5432/testdb', + GMAIL_USER: 'test@example.com', + GMAIL_APP_PASSWORD: 'my-app-password', + GOOGLE_CLIENT_ID: 'test-client-id', + GOOGLE_CLIENT_SECRET: 'test-client-secret', + BACKEND_URL: 'http://localhost:3000', + FRONTEND_URL: 'http://localhost:5173', + CLOUDINARY_CLOUD_NAME: 'test-cloud', + CLOUDINARY_API_KEY: 'test-api-key', + CLOUDINARY_API_SECRET: 'test-api-secret', + PAYSTACK_SECRET_KEY: 'sk_test_123456789', + PAYSTACK_PUBLIC_KEY: 'pk_test_123456789', + APP_SECRET: 'accesslayer_test_secret_key_32_bytes_long_xxxx', + STELLAR_NETWORK: 'testnet', + STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org', + STELLAR_SOROBAN_RPC_URL: 'https://soroban-testnet.stellar.org', + ENABLE_RESPONSE_TIMING: true, + API_VERSION: '1.0.0', + ENABLE_API_VERSION_HEADER: true, + ENABLE_SCHEMA_VERSION_HEADER: true, + ENABLE_REQUEST_LOGGING: true, + DB_QUERY_TIMEOUT_MS: 5000, + INDEXER_JITTER_FACTOR: 0.1, + BACKGROUND_JOB_LOCK_TTL_MS: 300000, + SLOW_QUERY_THRESHOLD_MS: 500, + CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: 500, + INDEXER_CURSOR_STALE_AGE_WARNING_MS: 300000, + INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: 300000, + ENABLE_INDEXER_DEDUPE: true, + ENABLE_INDEXER_DLQ: true, + ENABLE_INDEXER_CURSOR_STALENESS_WARNING: true, + OWNERSHIP_SNAPSHOT_TABLE_NAME: 'creator_ownership_snapshots', + OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN: true, + OWNERSHIP_SNAPSHOT_RETENTION_DAYS: 30, + OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED: false, + OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES: 60, + }, +})); + +import { maskSensitiveConfigValues } from './config-mask.utils'; + +describe('maskSensitiveConfigValues', () => { + it('redacts values for keys matching SECRET pattern', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.GOOGLE_CLIENT_SECRET).toBe('test***cret'); + expect(masked.CLOUDINARY_API_SECRET).toBe('test***cret'); + expect(masked.PAYSTACK_SECRET_KEY).toBe('sk_t***6789'); + expect(masked.APP_SECRET).toBe('acce***xxxx'); + }); + + it('redacts values for keys matching KEY pattern', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.CLOUDINARY_API_KEY).toBe('test***-key'); + expect(masked.PAYSTACK_PUBLIC_KEY).toBe('pk_t***6789'); + }); + + it('redacts values for keys matching PASSWORD pattern', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.GMAIL_APP_PASSWORD).toBe('my-a***word'); + }); + + it('redacts the password portion of DATABASE_URL', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.DATABASE_URL).toBe( + 'postgresql://***:***@localhost:5432/testdb' + ); + }); + + it('passes through non-sensitive values as-is', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.PORT).toBe(3000); + expect(masked.MODE).toBe('test'); + expect(masked.BACKEND_URL).toBe('http://localhost:3000'); + expect(masked.FRONTEND_URL).toBe('http://localhost:5173'); + expect(masked.STELLAR_NETWORK).toBe('testnet'); + expect(masked.API_VERSION).toBe('1.0.0'); + }); + + it('preserves boolean values for non-sensitive keys', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.ENABLE_RESPONSE_TIMING).toBe(true); + expect(masked.ENABLE_INDEXER_DEDUPE).toBe(true); + expect(masked.OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN).toBe(true); + }); + + it('preserves numeric values for non-sensitive keys', () => { + const masked = maskSensitiveConfigValues(); + expect(masked.PORT).toBe(3000); + expect(masked.DB_QUERY_TIMEOUT_MS).toBe(5000); + expect(masked.SLOW_QUERY_THRESHOLD_MS).toBe(500); + }); +}); diff --git a/src/utils/config-mask.utils.ts b/src/utils/config-mask.utils.ts new file mode 100644 index 0000000..6d3fa05 --- /dev/null +++ b/src/utils/config-mask.utils.ts @@ -0,0 +1,84 @@ +import { envConfig } from '../config'; + +/** + * Sensitive key patterns for config value masking. + * + * Config keys matching any of these patterns (case-insensitive substring match) + * will have their values redacted when logged. Add new patterns here when + * introducing config fields that contain secrets, keys, passwords, or tokens. + * + * Current sensitive patterns: + * - `SECRET` — matches APP_SECRET, GOOGLE_CLIENT_SECRET, CLOUDINARY_API_SECRET, + * PAYSTACK_SECRET_KEY + * - `KEY` — matches PAYSTACK_SECRET_KEY, PAYSTACK_PUBLIC_KEY, + * CLOUDINARY_API_KEY + * - `PASSWORD` — matches GMAIL_APP_PASSWORD + * - `TOKEN` — reserved for future token-based config values + * - `DATABASE_URL` — contains embedded credentials (user:password@host) + */ +const SENSITIVE_KEY_PATTERNS = [ + /SECRET/i, + /KEY/i, + /PASSWORD/i, + /TOKEN/i, +]; + +const SENSITIVE_EXACT_KEYS = ['DATABASE_URL']; + +function isKeySensitive(key: string): boolean { + if (SENSITIVE_EXACT_KEYS.includes(key)) return true; + return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); +} + +function maskDatabaseUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.password) { + parsed.password = '***'; + } + if (parsed.username) { + parsed.username = parsed.username ? '***' : ''; + } + return parsed.toString(); + } catch { + return '***'; + } +} + +function maskValue(key: string, value: unknown): unknown { + if (!isKeySensitive(key)) return value; + + if (typeof value === 'string') { + if (key === 'DATABASE_URL') { + return maskDatabaseUrl(value); + } + if (value.length > 8) { + return value.slice(0, 4) + '***' + value.slice(-4); + } + } + + return '***'; +} + +/** + * Returns a copy of the envConfig object with sensitive values redacted. + * + * Non-sensitive config values are returned as-is. Sensitive values are + * partially masked (first 4 and last 4 characters preserved when the value + * is a string longer than 8 characters; otherwise fully replaced with `'***'`). + * DATABASE_URL has its embedded password redacted while preserving the rest of + * the connection string. + * + * @example + * import { maskSensitiveConfigValues } from './utils/config-mask.utils'; + * logger.info(maskSensitiveConfigValues(), 'Startup configuration summary'); + */ +export function maskSensitiveConfigValues(): Record { + const masked: Record = {}; + + for (const [key, value] of Object.entries(envConfig)) { + masked[key] = maskValue(key, value); + } + + return masked; +} diff --git a/src/utils/test/protected-route-request.utils.ts b/src/utils/test/protected-route-request.utils.ts index e4f30bc..3ca99c4 100644 --- a/src/utils/test/protected-route-request.utils.ts +++ b/src/utils/test/protected-route-request.utils.ts @@ -9,7 +9,7 @@ const DEFAULT_PROTECTED_ROUTE_HEADERS: Record< string > = { 'x-admin-id': 'admin-test-1', - 'x-wallet-address': 'GTESTWALLETADDRESS', + 'x-wallet-address': 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', }; /**