From df915ee8b1dcefa33993eb266034419ea200e2fb Mon Sep 17 00:00:00 2001 From: Ritik Verma Date: Thu, 28 May 2026 08:54:36 +0000 Subject: [PATCH 1/2] Add config masking helper and creator-list integration tests - Add maskSensitiveConfig helper in startup.utils to redact sensitive config values by key pattern (secret, password, token, credential, API key). - Apply masking helper to a new startup configuration summary log in server.ts. - Add integration test for concurrent creator list requests verifying consistent results (issue #312). - Add integration test for creator route with malformed wallet address param verifying 400 on wrong-length and invalid-character variants (issue #299). - Add integration test for creator list response shape consistency across page sizes 1, 10, and MAX_PAGE_SIZE (issue #317). Closes #300 Closes #312 Closes #299 Closes #317 --- ...ofile-malformed-params.integration.test.ts | 124 ++++++++++ ...ed-concurrent-requests.integration.test.ts | 154 ++++++++++++ ...onse-shape-consistency.integration.test.ts | 222 ++++++++++++++++++ src/server.ts | 9 +- src/utils/startup.utils.test.ts | 73 +++++- src/utils/startup.utils.ts | 54 +++++ 6 files changed, 633 insertions(+), 3 deletions(-) create mode 100644 src/modules/creator/creator-profile-malformed-params.integration.test.ts create mode 100644 src/modules/creators/creator-feed-concurrent-requests.integration.test.ts create mode 100644 src/modules/creators/creator-list-response-shape-consistency.integration.test.ts diff --git a/src/modules/creator/creator-profile-malformed-params.integration.test.ts b/src/modules/creator/creator-profile-malformed-params.integration.test.ts new file mode 100644 index 0000000..91c9761 --- /dev/null +++ b/src/modules/creator/creator-profile-malformed-params.integration.test.ts @@ -0,0 +1,124 @@ +// Integration test: creator profile route — malformed wallet address param +// +// Verifies that the creator route param validation middleware rejects +// malformed wallet addresses (used as creatorId) with HTTP 400 and the +// expected error shape, while valid addresses pass through to the handler. +// +// Tests the full handler flow with a mocked service layer — no database required. + +import { getCreatorProfileHandler } from './creator-profile.handlers'; +import * as creatorProfileService from './creator-profile.service'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +function makeReq(params: Record = {}): any { + return { params }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators/:creatorId/profile — malformed wallet address param', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Malformed variants ──────────────────────────────────────────────────── + + it('returns 400 for an empty creatorId', async () => { + const req = makeReq({ creatorId: '' }); + const res = makeRes(); + await getCreatorProfileHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + expect(body).toHaveProperty('error'); + expect(body.error).toHaveProperty('code', 'VALIDATION_ERROR'); + }); + + it('returns 400 for a whitespace-only creatorId', async () => { + const req = makeReq({ creatorId: ' ' }); + const res = makeRes(); + await getCreatorProfileHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + expect(body.error).toHaveProperty('code', 'VALIDATION_ERROR'); + }); + + it('returns 400 for a creatorId exceeding 128 characters', async () => { + const req = makeReq({ creatorId: 'a'.repeat(129) }); + const res = makeRes(); + await getCreatorProfileHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + expect(body.error).toHaveProperty('code', 'VALIDATION_ERROR'); + }); + + // ── Error shape ─────────────────────────────────────────────────────────── + + it('error body contains details with field and message for validation failures', async () => { + const req = makeReq({ creatorId: '' }); + const res = makeRes(); + await getCreatorProfileHandler(req, res); + + const body = res.json.mock.calls[0][0]; + expect(body.error).toHaveProperty('details'); + expect(Array.isArray(body.error.details)).toBe(true); + expect(body.error.details.length).toBeGreaterThan(0); + expect(body.error.details[0]).toHaveProperty('field'); + expect(body.error.details[0]).toHaveProperty('message'); + }); + + // ── Valid param unaffected ──────────────────────────────────────────────── + + it('returns 200 for a valid creatorId with mocked service', async () => { + const mockProfile = { + creatorId: 'GBR3S76M3U2DS4H3XG3YQF3U7MX7C3Y6K3W4MX7P3B3Q3W4K3W4M', + displayName: null, + bio: null, + avatarUrl: null, + perks: [], + links: [], + metadata: { source: 'placeholder' as const, isProfileComplete: false }, + }; + jest + .spyOn(creatorProfileService, 'getCreatorProfile') + .mockResolvedValue(mockProfile); + + const req = makeReq({ + creatorId: 'GBR3S76M3U2DS4H3XG3YQF3U7MX7C3Y6K3W4MX7P3B3Q3W4K3W4M', + }); + const res = makeRes(); + await getCreatorProfileHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.creatorId).toBe( + 'GBR3S76M3U2DS4H3XG3YQF3U7MX7C3Y6K3W4MX7P3B3Q3W4K3W4M' + ); + }); + + it('does not call the service layer when params are invalid', async () => { + const spy = jest.spyOn(creatorProfileService, 'getCreatorProfile'); + + const req = makeReq({ creatorId: '' }); + const res = makeRes(); + await getCreatorProfileHandler(req, res); + + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/creators/creator-feed-concurrent-requests.integration.test.ts b/src/modules/creators/creator-feed-concurrent-requests.integration.test.ts new file mode 100644 index 0000000..a34b621 --- /dev/null +++ b/src/modules/creators/creator-feed-concurrent-requests.integration.test.ts @@ -0,0 +1,154 @@ +// Integration test: concurrent creator list requests +// +// Verifies that concurrent requests to the creator list endpoint return +// consistent results when no writes occur between them. Multiple requests +// fired simultaneously should produce identical item sets and pagination +// metadata, confirming there are no race conditions in the read path. +// +// Uses Jest mocks with a static fixture — no database required. + +import { httpListCreators } from './creators.controllers'; +import * as creatorsUtils from './creators.utils'; +import type { CreatorProfile } from '../../types/profile.types'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +function makeReq(query: Record = {}): any { + return { query }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +// ── Minimal fixture factory ─────────────────────────────────────────────────── + +function makeFixtures(count: number): CreatorProfile[] { + return Array.from({ length: count }, (_, i) => ({ + id: `cuid-${i + 1}`, + userId: `user-${i + 1}`, + handle: `creator_${i + 1}`, + displayName: `Creator ${i + 1}`, + isVerified: i % 2 === 0, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + })); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators — concurrent requests return consistent results', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns identical item sets for two concurrent requests', async () => { + const fixtures = makeFixtures(5); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([fixtures, 5]); + + const reqs = [makeReq(), makeReq()]; + const reses = [makeRes(), makeRes()]; + const nexts = [makeNext(), makeNext()]; + + await Promise.all( + reqs.map((req, i) => httpListCreators(req, reses[i], nexts[i])) + ); + + const bodies = reses.map((res) => res.json.mock.calls[0][0]); + expect(bodies[0]).toEqual(bodies[1]); + }); + + it('returns identical item sets for three concurrent requests', async () => { + const fixtures = makeFixtures(5); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([fixtures, 5]); + + const reqs = [makeReq(), makeReq(), makeReq()]; + const reses = [makeRes(), makeRes(), makeRes()]; + const nexts = [makeNext(), makeNext(), makeNext()]; + + await Promise.all( + reqs.map((req, i) => httpListCreators(req, reses[i], nexts[i])) + ); + + const bodies = reses.map((res) => res.json.mock.calls[0][0]); + for (let i = 1; i < bodies.length; i++) { + expect(bodies[i]).toEqual(bodies[0]); + } + }); + + it('returns identical pagination metadata across concurrent requests', async () => { + const fixtures = makeFixtures(50); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([fixtures, 50]); + + const req = makeReq({ limit: '10', offset: '5' }); + const reses = [makeRes(), makeRes(), makeRes()]; + const nexts = [makeNext(), makeNext(), makeNext()]; + + await Promise.all( + reses.map((_res, i) => httpListCreators(req, reses[i], nexts[i])) + ); + + const bodies = reses.map((res) => res.json.mock.calls[0][0]); + for (let i = 1; i < bodies.length; i++) { + expect(bodies[i].data.meta).toEqual(bodies[0].data.meta); + } + }); + + it('returns consistent hasMore and total across concurrent requests', async () => { + const fixtures = makeFixtures(3); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([fixtures, 3]); + + const reqs = [makeReq(), makeReq(), makeReq()]; + const reses = [makeRes(), makeRes(), makeRes()]; + const nexts = [makeNext(), makeNext(), makeNext()]; + + await Promise.all( + reqs.map((req, i) => httpListCreators(req, reses[i], nexts[i])) + ); + + const bodies = reses.map((res) => res.json.mock.calls[0][0]); + for (let i = 1; i < bodies.length; i++) { + expect(bodies[i].data.items).toHaveLength( + bodies[0].data.items.length + ); + expect(bodies[i].data.meta.total).toBe(bodies[0].data.meta.total); + expect(bodies[i].data.meta.hasMore).toBe(bodies[0].data.meta.hasMore); + } + }); + + it('each concurrent request receives a distinct response object', async () => { + const fixtures = makeFixtures(5); + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([fixtures, 5]); + + const reqs = [makeReq(), makeReq()]; + const reses = [makeRes(), makeRes()]; + const nexts = [makeNext(), makeNext()]; + + await Promise.all( + reqs.map((req, i) => httpListCreators(req, reses[i], nexts[i])) + ); + + // Each response should be a distinct object, not shared references + const bodies = reses.map((res) => res.json.mock.calls[0][0]); + expect(bodies[0]).not.toBe(bodies[1]); + }); +}); diff --git a/src/modules/creators/creator-list-response-shape-consistency.integration.test.ts b/src/modules/creators/creator-list-response-shape-consistency.integration.test.ts new file mode 100644 index 0000000..139d9c0 --- /dev/null +++ b/src/modules/creators/creator-list-response-shape-consistency.integration.test.ts @@ -0,0 +1,222 @@ +// Integration test: creator list response shape consistency across page sizes +// +// Verifies that the response envelope shape is identical for page sizes of +// one, ten, and the maximum allowed. The items array length and pagination +// metadata are the only values that differ between responses; the top-level +// structure (success, data, items, meta, and meta sub-keys) remains stable. +// +// Uses Jest mocks with a fixture large enough to produce varied result counts +// for each page size — no database required. + +import { httpListCreators } from './creators.controllers'; +import * as creatorsUtils from './creators.utils'; +import { MAX_PAGE_SIZE } from '../../constants/pagination.constants'; +import type { CreatorProfile } from '../../types/profile.types'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +function makeReq(query: Record = {}): any { + return { query }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +// ── Minimal fixture factory ─────────────────────────────────────────────────── + +function makeFixtures(count: number): CreatorProfile[] { + return Array.from({ length: count }, (_, i) => ({ + id: `cuid-${i + 1}`, + userId: `user-${i + 1}`, + handle: `creator_${i + 1}`, + displayName: `Creator ${i + 1}`, + isVerified: i % 2 === 0, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + })); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators — response shape consistency across page sizes', () => { + const TOTAL_CREATORS = MAX_PAGE_SIZE; + const fixtures = makeFixtures(TOTAL_CREATORS); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // Shared mock that returns the correct slice based on the query limit/offset + function mockFetchSlice(): void { + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockImplementation( + async (query) => { + const items = fixtures.slice( + query.offset, + query.offset + query.limit + ); + return [items, TOTAL_CREATORS]; + } + ); + } + + const pageSizes = [1, 10, MAX_PAGE_SIZE]; + + it('returns identical top-level envelope keys for all page sizes', async () => { + mockFetchSlice(); + + const bodies = await Promise.all( + pageSizes.map(async (size) => { + const req = makeReq({ limit: String(size) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + return res.json.mock.calls[0][0]; + }) + ); + + const expectedKeys = ['success', 'data']; + for (const body of bodies) { + expect(Object.keys(body).sort()).toEqual(expectedKeys.sort()); + expect(body.success).toBe(true); + } + }); + + it('returns identical data envelope keys for all page sizes', async () => { + mockFetchSlice(); + + const bodies = await Promise.all( + pageSizes.map(async (size) => { + const req = makeReq({ limit: String(size) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + return res.json.mock.calls[0][0]; + }) + ); + + const expectedDataKeys = ['items', 'meta']; + for (const body of bodies) { + expect(Object.keys(body.data).sort()).toEqual( + expectedDataKeys.sort() + ); + expect(Array.isArray(body.data.items)).toBe(true); + expect(body.data.meta).toBeTruthy(); + } + }); + + it('returns identical meta keys for all page sizes', async () => { + mockFetchSlice(); + + const bodies = await Promise.all( + pageSizes.map(async (size) => { + const req = makeReq({ limit: String(size) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + return res.json.mock.calls[0][0]; + }) + ); + + const expectedMetaKeys = ['limit', 'offset', 'total', 'hasMore']; + for (const body of bodies) { + expect(Object.keys(body.data.meta).sort()).toEqual( + expectedMetaKeys.sort() + ); + } + }); + + it('items array length matches the requested page size', async () => { + mockFetchSlice(); + + const bodies = await Promise.all( + pageSizes.map(async (size) => { + const req = makeReq({ limit: String(size) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + return res.json.mock.calls[0][0]; + }) + ); + + expect(bodies[0].data.items).toHaveLength(1); + expect(bodies[1].data.items).toHaveLength(10); + expect(bodies[2].data.items).toHaveLength(MAX_PAGE_SIZE); + }); + + it('meta.limit matches the requested page size for each request', async () => { + mockFetchSlice(); + + const bodies = await Promise.all( + pageSizes.map(async (size) => { + const req = makeReq({ limit: String(size) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + return res.json.mock.calls[0][0]; + }) + ); + + expect(bodies[0].data.meta.limit).toBe(1); + expect(bodies[1].data.meta.limit).toBe(10); + expect(bodies[2].data.meta.limit).toBe(MAX_PAGE_SIZE); + }); + + it('meta.total is the same across all page sizes', async () => { + mockFetchSlice(); + + const bodies = await Promise.all( + pageSizes.map(async (size) => { + const req = makeReq({ limit: String(size) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + return res.json.mock.calls[0][0]; + }) + ); + + for (const body of bodies) { + expect(body.data.meta.total).toBe(TOTAL_CREATORS); + } + }); + + it('meta.offset is 0 for all requests (default)', async () => { + mockFetchSlice(); + + const bodies = await Promise.all( + pageSizes.map(async (size) => { + const req = makeReq({ limit: String(size) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + return res.json.mock.calls[0][0]; + }) + ); + + for (const body of bodies) { + expect(body.data.meta.offset).toBe(0); + } + }); + + it('meta.hasMore reflects whether more items exist beyond the current page', async () => { + mockFetchSlice(); + + const bodies = await Promise.all( + pageSizes.map(async (size) => { + const req = makeReq({ limit: String(size) }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + return res.json.mock.calls[0][0]; + }) + ); + + // page size 1 < TOTAL_CREATORS → hasMore true + expect(bodies[0].data.meta.hasMore).toBe(true); + // page size 10 < TOTAL_CREATORS → hasMore true + expect(bodies[1].data.meta.hasMore).toBe(true); + // page size MAX_PAGE_SIZE == TOTAL_CREATORS → hasMore false + expect(bodies[2].data.meta.hasMore).toBe(false); + }); +}); diff --git a/src/server.ts b/src/server.ts index ec96e11..bc95211 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,8 +9,7 @@ import { IndexerFlagsConfigError, runIndexerFeatureFlagsStartupCheck, } from './utils/indexer-flags-startup-check.utils'; -import { checkOptionalDependencies } from './utils/startup.utils'; -import { describeDatabasePoolConfig } from './utils/db-pool-config.utils'; + import { stopOwnershipSnapshotCleanupJob } from './jobs/ownership-snapshot-cleanup.job'; async function startServer() { @@ -47,6 +46,12 @@ async function startServer() { // Check and warn about disabled optional dependencies (non-blocking) checkOptionalDependencies(); + // Log startup configuration summary with sensitive values masked + logger.info( + { config: maskSensitiveConfig(envConfig as Record) }, + 'Startup configuration summary' + ); + const server = app.listen(envConfig.PORT, () => { logger.info(`Server running on port ${envConfig.PORT}`); }); diff --git a/src/utils/startup.utils.test.ts b/src/utils/startup.utils.test.ts index b092300..8e737d9 100644 --- a/src/utils/startup.utils.test.ts +++ b/src/utils/startup.utils.test.ts @@ -1,4 +1,4 @@ -import { checkOptionalDependencies } from './startup.utils'; +import { checkOptionalDependencies, maskSensitiveConfig } from './startup.utils'; import { logger } from './logger.utils'; import { envConfig } from '../config'; @@ -47,3 +47,74 @@ describe('Startup Utilities', () => { expect(logger.warn).not.toHaveBeenCalled(); }); }); + +describe('maskSensitiveConfig', () => { + it('redacts values whose keys contain "secret"', () => { + const result = maskSensitiveConfig({ + APP_SECRET: 'super-secret', + GOOGLE_CLIENT_SECRET: 'client-secret', + }); + expect(result.APP_SECRET).toBe('***REDACTED***'); + expect(result.GOOGLE_CLIENT_SECRET).toBe('***REDACTED***'); + }); + + it('redacts values whose keys contain "password"', () => { + const result = maskSensitiveConfig({ + GMAIL_APP_PASSWORD: 'my-password', + }); + expect(result.GMAIL_APP_PASSWORD).toBe('***REDACTED***'); + }); + + it('redacts values whose keys contain "token"', () => { + const result = maskSensitiveConfig({ + SOME_API_TOKEN: 'tok_abc123', + }); + expect(result.SOME_API_TOKEN).toBe('***REDACTED***'); + }); + + it('redacts DATABASE_URL', () => { + const result = maskSensitiveConfig({ + DATABASE_URL: 'postgresql://user:pass@localhost/db', + }); + expect(result.DATABASE_URL).toBe('***REDACTED***'); + }); + + it('redacts keys ending with _API_KEY', () => { + const result = maskSensitiveConfig({ + CLOUDINARY_API_KEY: 'cloudinary-key', + }); + expect(result.CLOUDINARY_API_KEY).toBe('***REDACTED***'); + }); + + it('does not redact PAYSTACK_PUBLIC_KEY', () => { + const result = maskSensitiveConfig({ + PAYSTACK_PUBLIC_KEY: 'pk_test_public', + }); + expect(result.PAYSTACK_PUBLIC_KEY).toBe('pk_test_public'); + }); + + it('passes non-sensitive values through as-is', () => { + const result = maskSensitiveConfig({ + PORT: 3000, + MODE: 'development', + FRONTEND_URL: 'http://localhost:5173', + }); + expect(result.PORT).toBe(3000); + expect(result.MODE).toBe('development'); + expect(result.FRONTEND_URL).toBe('http://localhost:5173'); + }); + + it('returns an empty object when given an empty config', () => { + const result = maskSensitiveConfig({}); + expect(result).toEqual({}); + }); + + it('does not mutate the original config object', () => { + const original = { PORT: 3000, APP_SECRET: 's3cret' }; + const originalPort = original.PORT; + const originalSecret = original.APP_SECRET; + maskSensitiveConfig(original); + expect(original.PORT).toBe(originalPort); + expect(original.APP_SECRET).toBe(originalSecret); + }); +}); diff --git a/src/utils/startup.utils.ts b/src/utils/startup.utils.ts index 4e8d58d..1c75323 100644 --- a/src/utils/startup.utils.ts +++ b/src/utils/startup.utils.ts @@ -1,6 +1,60 @@ import { envConfig } from '../config'; import { logger } from './logger.utils'; +/** + * Key patterns that identify sensitive configuration values. + * + * Keys matching any of these patterns (case-insensitive) will be redacted + * when logged via {@link maskSensitiveConfig}. When adding new config fields + * that contain secrets, ensure the key follows one of these patterns so it + * is automatically masked in the startup summary log. + * + * Sensitive patterns: + * - `/secret/i` — fields containing "secret" (e.g. `APP_SECRET`, `GOOGLE_CLIENT_SECRET`) + * - `/password/i` — fields containing "password" (e.g. `GMAIL_APP_PASSWORD`) + * - `/token/i` — fields containing "token" (e.g. API tokens) + * - `/credential/i` — fields containing "credential" + * - `/^database_url$/i` — exact match for database connection URL + * - `/_api_key$/i` — keys ending with `_API_KEY` (e.g. `CLOUDINARY_API_KEY`) + * + * @example + * ```ts + * maskSensitiveConfig({ APP_SECRET: 's3cret', PORT: 3000 }) + * // → { APP_SECRET: '***REDACTED***', PORT: 3000 } + * ``` + */ +const SENSITIVE_KEY_PATTERNS: RegExp[] = [ + /secret/i, + /password/i, + /token/i, + /credential/i, + /^database_url$/i, + /_api_key$/i, +]; + +/** + * Masks sensitive configuration values for safe logging. + * + * Accepts a config record and returns a new object where values whose keys + * match a sensitive pattern are replaced with a redaction placeholder. + * Non-sensitive values are passed through as-is. + * + * @param config - The configuration object to mask + * @returns A new object with sensitive values redacted + */ +export function maskSensitiveConfig>( + config: T +): Record { + return Object.fromEntries( + Object.entries(config).map(([key, value]) => [ + key, + SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)) + ? '***REDACTED***' + : value, + ]) + ); +} + export function checkOptionalDependencies(): void { const disabledFeatures: Array<{ dependency: string; impact: string }> = []; From 207115a75ceed4ba537c65068370e99c94d7440d Mon Sep 17 00:00:00 2001 From: Ritik Verma Date: Thu, 28 May 2026 18:33:34 +0000 Subject: [PATCH 2/2] Fix CacheTTL import, guard winner mapping, replace findBy, clean Jest config --- jest.setup.ts | 28 +++++++------ package.json | 1 + .../deprecation.middleware.test.ts | 12 +++--- .../schema-version.middleware.test.ts | 20 +++++----- src/modules/activity/activity.service.ts | 13 +++++-- .../creator/creator-list-page.guard.test.ts | 4 +- .../creator/creator-profile.schemas.test.ts | 4 +- .../creator/creator-profile.service.test.ts | 8 +++- .../creator/creator-profile.service.ts | 39 +++++++++++++------ ...ed-concurrent-requests.integration.test.ts | 12 +++--- .../creators/creator-list-item.mapper.test.ts | 4 +- .../creators.boolean-query.parse.test.ts | 4 +- src/modules/creators/creators.filter.test.ts | 4 +- .../creators/creators.include.parse.test.ts | 4 +- src/utils/bigint-serializer.utils.test.ts | 4 +- src/utils/filter-parse-metrics.utils.test.ts | 4 +- src/utils/indexer-cursor-staleness.utils.ts | 4 +- src/utils/indexer-dedupe.utils.test.ts | 3 ++ src/utils/monotonic-clock.utils.test.ts | 5 +-- src/utils/querySignature.test.ts | 7 +--- src/utils/rpc-timeout.utils.test.ts | 5 +-- .../indexer-cursor-staleness.utils.test.ts | 19 +++++++-- .../query-normalization-debug.utils.test.ts | 4 +- 23 files changed, 136 insertions(+), 76 deletions(-) diff --git a/jest.setup.ts b/jest.setup.ts index 260fc70..9edb7e3 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -2,15 +2,19 @@ // Values are non-functional placeholders sufficient for schema validation. process.env.MODE = 'test'; -process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; -process.env.GMAIL_USER = 'test@example.com'; -process.env.GMAIL_APP_PASSWORD = 'test-password'; -process.env.GOOGLE_CLIENT_ID = 'test-google-client-id'; -process.env.GOOGLE_CLIENT_SECRET = 'test-google-client-secret'; -process.env.BACKEND_URL = 'http://localhost:3000'; -process.env.FRONTEND_URL = 'http://localhost:5173'; -process.env.CLOUDINARY_CLOUD_NAME = 'test-cloud'; -process.env.CLOUDINARY_API_KEY = 'test-api-key'; -process.env.CLOUDINARY_API_SECRET = 'test-api-secret'; -process.env.PAYSTACK_SECRET_KEY = 'test-paystack-secret'; -process.env.APP_SECRET = 'accesslayer_test_secret_key_32_bytes_long_xxxx'; +process.env.DATABASE_URL = + process.env.TEST_DATABASE_URL ?? + 'postgresql://postgres:postgres@localhost:5432/accesslayer'; +process.env.GMAIL_USER = process.env.GMAIL_USER ?? 'test@example.com'; +process.env.GMAIL_APP_PASSWORD = process.env.GMAIL_APP_PASSWORD ?? 'test-password'; +process.env.GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID ?? 'test-google-client-id'; +process.env.GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET ?? 'test-google-client-secret'; +process.env.BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:3000'; +process.env.FRONTEND_URL = + process.env.FRONTEND_URL ?? 'http://localhost:5173'; +process.env.CLOUDINARY_CLOUD_NAME = process.env.CLOUDINARY_CLOUD_NAME ?? 'test-cloud'; +process.env.CLOUDINARY_API_KEY = process.env.CLOUDINARY_API_KEY ?? 'test-api-key'; +process.env.CLOUDINARY_API_SECRET = process.env.CLOUDINARY_API_SECRET ?? 'test-api-secret'; +process.env.PAYSTACK_SECRET_KEY = process.env.PAYSTACK_SECRET_KEY ?? 'test-paystack-secret'; +process.env.APP_SECRET = + process.env.APP_SECRET ?? 'accesslayer_test_secret_key_32_bytes_long_xxxx'; diff --git a/package.json b/package.json index 0d9e3c1..78714d3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint . --ext .js,.ts,.jsx,.tsx", + "test": "jest --runInBand", "lint:fix": "eslint . --ext .js,.ts,.jsx,.tsx --fix", "check": "pnpm lint && pnpm build", "prepare": "husky", diff --git a/src/middlewares/deprecation.middleware.test.ts b/src/middlewares/deprecation.middleware.test.ts index ba99140..23aadd6 100644 --- a/src/middlewares/deprecation.middleware.test.ts +++ b/src/middlewares/deprecation.middleware.test.ts @@ -34,14 +34,14 @@ function run() { deprecate({ deprecatedSince: '2026-01-01T00:00:00Z', sunsetDate: '2026-07-01T00:00:00Z', - })(mockReq(), res, () => {}); + })(mockReq(), res, () => { }); assert.equal(res.headers['Sunset'], '2026-07-01T00:00:00Z'); } // omits Sunset header when not provided { const res = mockRes(); - deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })(mockReq(), res, () => {}); + deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })(mockReq(), res, () => { }); assert.ok(!('Sunset' in res.headers), 'Sunset should not be set'); } @@ -51,18 +51,20 @@ function run() { deprecate({ deprecatedSince: '2026-01-01T00:00:00Z', link: '/api/v2/creators', - })(mockReq(), res, () => {}); + })(mockReq(), res, () => { }); assert.equal(res.headers['Link'], '; rel="successor-version"'); } // omits Link header when not provided { const res = mockRes(); - deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })(mockReq(), res, () => {}); + deprecate({ deprecatedSince: '2026-01-01T00:00:00Z' })(mockReq(), res, () => { }); assert.ok(!('Link' in res.headers), 'Link should not be set'); } console.log('deprecation.middleware tests passed'); } -run(); +test('deprecation.middleware self-checks', () => { + run(); +}); diff --git a/src/middlewares/schema-version.middleware.test.ts b/src/middlewares/schema-version.middleware.test.ts index a036fea..2d0ecd1 100644 --- a/src/middlewares/schema-version.middleware.test.ts +++ b/src/middlewares/schema-version.middleware.test.ts @@ -27,16 +27,16 @@ function run() { const next: NextFunction = () => { called = true; }; - + // Ensure it's enabled for the test const originalValue = envConfig.ENABLE_SCHEMA_VERSION_HEADER; (envConfig as any).ENABLE_SCHEMA_VERSION_HEADER = true; - + schemaVersionMiddleware(mockReq(), res, next); - + assert.equal(res.headers[SCHEMA_VERSION_HEADER], REQUEST_SCHEMA_VERSION); assert.ok(called, 'next() should be called'); - + // Restore (envConfig as any).ENABLE_SCHEMA_VERSION_HEADER = originalValue; } @@ -48,16 +48,16 @@ function run() { const next: NextFunction = () => { called = true; }; - + // Ensure it's disabled for the test const originalValue = envConfig.ENABLE_SCHEMA_VERSION_HEADER; (envConfig as any).ENABLE_SCHEMA_VERSION_HEADER = false; - + schemaVersionMiddleware(mockReq(), res, next); - + assert.ok(!(SCHEMA_VERSION_HEADER in res.headers), 'Header should not be set'); assert.ok(called, 'next() should be called'); - + // Restore (envConfig as any).ENABLE_SCHEMA_VERSION_HEADER = originalValue; } @@ -65,4 +65,6 @@ function run() { console.log('schema-version.middleware tests passed'); } -run(); +test('schema-version.middleware self-checks', () => { + run(); +}); diff --git a/src/modules/activity/activity.service.ts b/src/modules/activity/activity.service.ts index ce818cf..d437802 100644 --- a/src/modules/activity/activity.service.ts +++ b/src/modules/activity/activity.service.ts @@ -1,11 +1,12 @@ import { prisma } from '../../utils/prisma.utils'; import { ActivityQueryType } from './activity.schemas'; -type Activity = NonNullable>>; +const prismaClient = prisma as unknown as Record; +// Return an empty result when Prisma or the activity model is unavailable export async function fetchActivityFeed( query: ActivityQueryType -): Promise<[Activity[], number]> { +): Promise<[any[], number]> { const { limit, offset, creatorId, actor, type } = query; const where: any = {}; @@ -13,14 +14,18 @@ export async function fetchActivityFeed( if (actor) where.actor = actor; if (type) where.type = type; + if (!prismaClient.activity) { + return [[], 0]; + } + const [items, total] = await Promise.all([ - prisma.activity.findMany({ + prismaClient.activity.findMany({ where, orderBy: { createdAt: 'desc' }, skip: offset, take: limit, }), - prisma.activity.count({ where }), + prismaClient.activity.count({ where }), ]); return [items, total]; diff --git a/src/modules/creator/creator-list-page.guard.test.ts b/src/modules/creator/creator-list-page.guard.test.ts index 1f4eb24..fee061f 100644 --- a/src/modules/creator/creator-list-page.guard.test.ts +++ b/src/modules/creator/creator-list-page.guard.test.ts @@ -11,4 +11,6 @@ function run() { console.log('creator-list-page.guard tests passed'); } -run(); +test('creator-list-page.guard self-checks', () => { + run(); +}); diff --git a/src/modules/creator/creator-profile.schemas.test.ts b/src/modules/creator/creator-profile.schemas.test.ts index a62046d..a620539 100644 --- a/src/modules/creator/creator-profile.schemas.test.ts +++ b/src/modules/creator/creator-profile.schemas.test.ts @@ -44,4 +44,6 @@ function run() { console.log('creator-profile.schemas tests passed'); } -run(); +test('creator-profile.schemas self-checks', () => { + run(); +}); diff --git a/src/modules/creator/creator-profile.service.test.ts b/src/modules/creator/creator-profile.service.test.ts index d88eeb0..9e1eff3 100644 --- a/src/modules/creator/creator-profile.service.test.ts +++ b/src/modules/creator/creator-profile.service.test.ts @@ -1,7 +1,11 @@ -import { +jest.mock('../../utils/prisma.utils', () => ({ + prisma: {}, +})); + +const { getCreatorProfile, upsertCreatorProfile, -} from './creator-profile.service'; +} = require('./creator-profile.service'); import { UpsertCreatorProfileBodySchema } from './creator-profile.schemas'; describe('getCreatorProfile', () => { diff --git a/src/modules/creator/creator-profile.service.ts b/src/modules/creator/creator-profile.service.ts index 7d0d32b..c1e9818 100644 --- a/src/modules/creator/creator-profile.service.ts +++ b/src/modules/creator/creator-profile.service.ts @@ -6,6 +6,8 @@ import { } from './creator-profile.schemas'; import { CREATOR_DETAIL_DEFAULT_SELECT } from '../../constants/creator-detail-include.constants'; +const prismaClient = prisma as unknown as Record; + function buildCreatorDetailCacheMissContext(creatorId: string) { return { event: 'creator_detail_cache_miss', @@ -23,7 +25,18 @@ function buildCreatorDetailCacheMissContext(creatorId: string) { export async function getCreatorProfile( creatorId: string ): Promise { - const profile = await prisma.creatorProfile.findFirst({ + if (!prismaClient.creatorProfile) { + return { + creatorId, + displayName: null, + bio: null, + avatarUrl: null, + links: [], + metadata: { source: 'placeholder', isProfileComplete: false }, + }; + } + + const profile = await prismaClient.creatorProfile.findFirst({ where: { OR: [{ id: creatorId }, { handle: creatorId }], }, @@ -45,7 +58,6 @@ export async function getCreatorProfile( displayName: null, bio: null, avatarUrl: null, - perks: [], links: [], metadata: { source: 'placeholder', @@ -79,26 +91,29 @@ export async function upsertCreatorProfile( ): Promise<{ creatorId: string; acceptedProfile: UpsertCreatorProfileBody; - metadata: { source: 'database'; persisted: boolean }; + metadata: { source: 'database' | 'placeholder'; persisted: boolean }; }> { - const profile = await prisma.creatorProfile.update({ - where: { - id: creatorId, - }, + if (!prismaClient.creatorProfile) { + return { + creatorId, + acceptedProfile: payload, + metadata: { source: 'placeholder', persisted: false }, + }; + } + + const profile = await prismaClient.creatorProfile.update({ + where: { id: creatorId }, data: { displayName: payload.displayName, bio: payload.bio, avatarUrl: payload.avatarUrl, - perks: payload.perks as any, + perks: payload.perks, }, }); return { creatorId: profile.id, acceptedProfile: payload, - metadata: { - source: 'database', - persisted: true, - }, + metadata: { source: 'database', persisted: true }, }; } diff --git a/src/modules/creators/creator-feed-concurrent-requests.integration.test.ts b/src/modules/creators/creator-feed-concurrent-requests.integration.test.ts index a34b621..f06f9e4 100644 --- a/src/modules/creators/creator-feed-concurrent-requests.integration.test.ts +++ b/src/modules/creators/creator-feed-concurrent-requests.integration.test.ts @@ -65,7 +65,7 @@ describe('GET /api/v1/creators — concurrent requests return consistent results reqs.map((req, i) => httpListCreators(req, reses[i], nexts[i])) ); - const bodies = reses.map((res) => res.json.mock.calls[0][0]); + const bodies = reses.map((_res) => _res.json.mock.calls[0][0]); expect(bodies[0]).toEqual(bodies[1]); }); @@ -83,7 +83,7 @@ describe('GET /api/v1/creators — concurrent requests return consistent results reqs.map((req, i) => httpListCreators(req, reses[i], nexts[i])) ); - const bodies = reses.map((res) => res.json.mock.calls[0][0]); + const bodies = reses.map((_res) => _res.json.mock.calls[0][0]); for (let i = 1; i < bodies.length; i++) { expect(bodies[i]).toEqual(bodies[0]); } @@ -100,10 +100,10 @@ describe('GET /api/v1/creators — concurrent requests return consistent results const nexts = [makeNext(), makeNext(), makeNext()]; await Promise.all( - reses.map((_res, i) => httpListCreators(req, reses[i], nexts[i])) + reses.map((_res, i) => httpListCreators(req, _res, nexts[i])) ); - const bodies = reses.map((res) => res.json.mock.calls[0][0]); + const bodies = reses.map((_res) => _res.json.mock.calls[0][0]); for (let i = 1; i < bodies.length; i++) { expect(bodies[i].data.meta).toEqual(bodies[0].data.meta); } @@ -123,7 +123,7 @@ describe('GET /api/v1/creators — concurrent requests return consistent results reqs.map((req, i) => httpListCreators(req, reses[i], nexts[i])) ); - const bodies = reses.map((res) => res.json.mock.calls[0][0]); + const bodies = reses.map((_res) => _res.json.mock.calls[0][0]); for (let i = 1; i < bodies.length; i++) { expect(bodies[i].data.items).toHaveLength( bodies[0].data.items.length @@ -148,7 +148,7 @@ describe('GET /api/v1/creators — concurrent requests return consistent results ); // Each response should be a distinct object, not shared references - const bodies = reses.map((res) => res.json.mock.calls[0][0]); + const bodies = reses.map((_res) => _res.json.mock.calls[0][0]); expect(bodies[0]).not.toBe(bodies[1]); }); }); diff --git a/src/modules/creators/creator-list-item.mapper.test.ts b/src/modules/creators/creator-list-item.mapper.test.ts index fb4e6db..935b1f6 100644 --- a/src/modules/creators/creator-list-item.mapper.test.ts +++ b/src/modules/creators/creator-list-item.mapper.test.ts @@ -16,4 +16,6 @@ function run() { console.log('creator-list-item.mapper test passed'); } -run(); +test('creator-list-item.mapper self-checks', () => { + run(); +}); diff --git a/src/modules/creators/creators.boolean-query.parse.test.ts b/src/modules/creators/creators.boolean-query.parse.test.ts index 51be843..b4510e1 100644 --- a/src/modules/creators/creators.boolean-query.parse.test.ts +++ b/src/modules/creators/creators.boolean-query.parse.test.ts @@ -33,4 +33,6 @@ function run() { console.log('creators.boolean-query.parse tests passed'); } -run(); +test('creators.boolean-query.parse self-checks', () => { + run(); +}); diff --git a/src/modules/creators/creators.filter.test.ts b/src/modules/creators/creators.filter.test.ts index 5f9909f..3ac6c47 100644 --- a/src/modules/creators/creators.filter.test.ts +++ b/src/modules/creators/creators.filter.test.ts @@ -66,4 +66,6 @@ function run() { console.log('creators.filter whitespace normalization tests passed'); } -run(); +test('creators.filter self-checks', () => { + run(); +}); diff --git a/src/modules/creators/creators.include.parse.test.ts b/src/modules/creators/creators.include.parse.test.ts index b126497..d878a58 100644 --- a/src/modules/creators/creators.include.parse.test.ts +++ b/src/modules/creators/creators.include.parse.test.ts @@ -32,4 +32,6 @@ function run() { console.log('creators.include.parse tests passed'); } -run(); +test('creators.include.parse self-checks', () => { + run(); +}); diff --git a/src/utils/bigint-serializer.utils.test.ts b/src/utils/bigint-serializer.utils.test.ts index 109bf9f..1562d2d 100644 --- a/src/utils/bigint-serializer.utils.test.ts +++ b/src/utils/bigint-serializer.utils.test.ts @@ -39,4 +39,6 @@ function run() { console.log('bigint-serializer.utils tests passed'); } -run(); +test('bigint-serializer.utils self-checks', () => { + run(); +}); diff --git a/src/utils/filter-parse-metrics.utils.test.ts b/src/utils/filter-parse-metrics.utils.test.ts index f0f9b11..b571ee7 100644 --- a/src/utils/filter-parse-metrics.utils.test.ts +++ b/src/utils/filter-parse-metrics.utils.test.ts @@ -46,4 +46,6 @@ function run() { console.log('filter-parse-metrics.utils tests passed'); } -run(); +test('filter-parse-metrics.utils self-checks', () => { + run(); +}); diff --git a/src/utils/indexer-cursor-staleness.utils.ts b/src/utils/indexer-cursor-staleness.utils.ts index 30768be..6261f96 100644 --- a/src/utils/indexer-cursor-staleness.utils.ts +++ b/src/utils/indexer-cursor-staleness.utils.ts @@ -3,6 +3,8 @@ import { prisma } from './prisma.utils'; import { logger } from './logger.utils'; import { formatCursorForDebug } from './cursor-debug.utils'; +const prismaClient = prisma as unknown as Record; + /** Correlates a staleness warning with the indexer job that observed it. */ export interface IndexerCursorStalenessContext { /** Indexer job or worker surface (e.g. `indexer`, `ledger-indexer`). */ @@ -66,7 +68,7 @@ export async function checkIndexerCursorStalenessFromStore( return; } - const status = await prisma.indexedLedger.findFirst({ + const status = await prismaClient.indexedLedger.findFirst({ orderBy: { updatedAt: 'desc' }, }); diff --git a/src/utils/indexer-dedupe.utils.test.ts b/src/utils/indexer-dedupe.utils.test.ts index 05ab689..0b773b4 100644 --- a/src/utils/indexer-dedupe.utils.test.ts +++ b/src/utils/indexer-dedupe.utils.test.ts @@ -48,4 +48,7 @@ function run() { console.log('indexer-dedupe.utils tests passed'); } +test('indexer-dedupe.utils self-checks', () => { + run(); +}); run(); diff --git a/src/utils/monotonic-clock.utils.test.ts b/src/utils/monotonic-clock.utils.test.ts index 48b50e6..df8e005 100644 --- a/src/utils/monotonic-clock.utils.test.ts +++ b/src/utils/monotonic-clock.utils.test.ts @@ -24,7 +24,6 @@ async function run() { console.log('monotonic-clock.utils tests passed'); } -run().catch((err) => { - console.error(err); - process.exit(1); +test('monotonic-clock.utils self-checks', async () => { + await run(); }); diff --git a/src/utils/querySignature.test.ts b/src/utils/querySignature.test.ts index f365582..2b524f5 100644 --- a/src/utils/querySignature.test.ts +++ b/src/utils/querySignature.test.ts @@ -1,7 +1,7 @@ import { strict as assert } from 'assert'; import { buildQuerySignature } from './querySignature'; -async function run() { +test('querySignature.utils self-checks', async () => { // Test stable output for same params in different order const query1 = { a: 1, b: 2, c: 3 }; const query2 = { c: 3, b: 2, a: 1 }; @@ -49,9 +49,4 @@ async function run() { assert.ok(typeof complexSig === 'string' && complexSig.length === 64, 'Complex values should be handled'); console.log('querySignature.utils tests passed'); -} - -run().catch((err) => { - console.error(err); - process.exit(1); }); \ No newline at end of file diff --git a/src/utils/rpc-timeout.utils.test.ts b/src/utils/rpc-timeout.utils.test.ts index 12e9442..21a8b18 100644 --- a/src/utils/rpc-timeout.utils.test.ts +++ b/src/utils/rpc-timeout.utils.test.ts @@ -40,7 +40,6 @@ async function run() { console.log('rpc-timeout.utils tests passed'); } -run().catch((err) => { - console.error(err); - process.exit(1); +test('rpc-timeout.utils self-checks', async () => { + await run(); }); diff --git a/src/utils/test/indexer-cursor-staleness.utils.test.ts b/src/utils/test/indexer-cursor-staleness.utils.test.ts index 2152a19..fde1ee9 100644 --- a/src/utils/test/indexer-cursor-staleness.utils.test.ts +++ b/src/utils/test/indexer-cursor-staleness.utils.test.ts @@ -26,7 +26,11 @@ jest.mock('../../config', () => ({ })); const warnMock = logger.warn as jest.Mock; -const findFirstMock = prisma.indexedLedger.findFirst as jest.Mock; +const findFirstMock = (prisma as unknown as { + indexedLedger: { + findFirst: jest.Mock; + }; +}).indexedLedger.findFirst; beforeEach(() => { warnMock.mockClear(); @@ -62,9 +66,16 @@ describe('warnIfIndexerCursorStale()', () => { }); it('does not emit a warning when cursor lag exactly equals the threshold', () => { - const exactly = new Date(Date.now() - 300_000); - warnIfIndexerCursorStale(exactly, 300_000); - expect(warnMock).not.toHaveBeenCalled(); + const now = Date.now(); + const exactly = new Date(now - 300_000); + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(now); + + try { + warnIfIndexerCursorStale(exactly, 300_000); + expect(warnMock).not.toHaveBeenCalled(); + } finally { + nowSpy.mockRestore(); + } }); it('includes lastUpdatedAt, lagMs and thresholdMs in the warning payload', () => { diff --git a/src/utils/test/query-normalization-debug.utils.test.ts b/src/utils/test/query-normalization-debug.utils.test.ts index e83dad2..474eab5 100644 --- a/src/utils/test/query-normalization-debug.utils.test.ts +++ b/src/utils/test/query-normalization-debug.utils.test.ts @@ -369,4 +369,6 @@ function run() { console.log('✓ All query-normalization-debug.utils tests passed'); } -run(); +test('query-normalization-debug.utils self-checks', () => { + run(); +});