From c1f27ab30bfde8c54052f38bd6aebd629bdcd17b Mon Sep 17 00:00:00 2001 From: AgriFi Dev Date: Fri, 29 May 2026 08:29:22 +0100 Subject: [PATCH] Add integration tests, log sanitization, and error code documentation --- docs/ERROR_CODE_REGISTRY.md | 398 ++++++++++++++++++ .../body-parse-error.middleware.ts | 70 +-- src/middlewares/error.middleware.ts | 5 +- src/middlewares/request-logger.middleware.ts | 3 +- ...t-sort-filter-combined.integration.test.ts | 388 +++++++++++++++++ ...ator-list-zero-results.integration.test.ts | 181 ++++++++ .../creators/creators.sort-field.utils.ts | 5 +- src/utils/log-field-sanitizer.utils.test.ts | 302 +++++++++++++ src/utils/log-field-sanitizer.utils.ts | 103 +++++ 9 files changed, 1416 insertions(+), 39 deletions(-) create mode 100644 docs/ERROR_CODE_REGISTRY.md create mode 100644 src/modules/creators/creator-list-sort-filter-combined.integration.test.ts create mode 100644 src/modules/creators/creator-list-zero-results.integration.test.ts create mode 100644 src/utils/log-field-sanitizer.utils.test.ts create mode 100644 src/utils/log-field-sanitizer.utils.ts diff --git a/docs/ERROR_CODE_REGISTRY.md b/docs/ERROR_CODE_REGISTRY.md new file mode 100644 index 0000000..b53a0fa --- /dev/null +++ b/docs/ERROR_CODE_REGISTRY.md @@ -0,0 +1,398 @@ +# Error Code Registry + +This document defines all error codes used in the Access Layer API and provides guidance for when to add new codes. + +## Overview + +Error codes are machine-readable identifiers that allow clients to programmatically handle specific error conditions. They are returned in all error responses under the `code` field: + +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid query parameters" + } +} +``` + +Error codes are defined in `src/constants/error.constants.ts` and should be treated as stable, public contracts. Changing or removing codes is a breaking change for API consumers. + +## Error Code Reference + +### VALIDATION_ERROR + +**HTTP Status:** 400 Bad Request + +**Meaning:** The request contains invalid data that failed schema validation. + +**When to use:** + +- Query parameters fail Zod schema validation +- Request body fails schema validation +- Required fields are missing +- Field values are outside allowed ranges or formats + +**Example:** + +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid query parameters", + "details": [{ "field": "limit", "message": "Must be between 1 and 100" }] + } +} +``` + +--- + +### NOT_FOUND + +**HTTP Status:** 404 Not Found + +**Meaning:** The requested resource does not exist. + +**When to use:** + +- A creator profile with the given ID does not exist +- A route path does not exist +- A resource was deleted or never existed + +**Example:** + +```json +{ + "success": false, + "error": { + "code": "NOT_FOUND", + "message": "Creator not found" + } +} +``` + +--- + +### UNAUTHORIZED + +**HTTP Status:** 401 Unauthorized + +**Meaning:** Authentication is required or has failed. + +**When to use:** + +- No authentication token is provided when required +- Authentication token is invalid or expired +- Wallet address is required but not provided +- Wallet address is not registered + +**Example:** + +```json +{ + "success": false, + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid or expired token" + } +} +``` + +--- + +### FORBIDDEN + +**HTTP Status:** 403 Forbidden + +**Meaning:** The authenticated user does not have permission to access this resource. + +**When to use:** + +- User is authenticated but lacks required permissions +- Wallet does not own the requested resource +- Access control check fails + +**Example:** + +```json +{ + "success": false, + "error": { + "code": "FORBIDDEN", + "message": "Wallet does not own the requested resource" + } +} +``` + +--- + +### CONFLICT + +**HTTP Status:** 409 Conflict + +**Meaning:** The request conflicts with the current state of the resource. + +**When to use:** + +- Attempting to create a resource that already exists (unique constraint violation) +- Attempting to update a resource that has been modified since retrieval +- State transition is invalid + +**Example:** + +```json +{ + "success": false, + "error": { + "code": "CONFLICT", + "message": "Creator handle already exists" + } +} +``` + +--- + +### BAD_REQUEST + +**HTTP Status:** 400 Bad Request + +**Meaning:** The request is malformed or contains invalid data that cannot be processed. + +**When to use:** + +- Request body is not valid JSON +- Request payload exceeds size limits +- Required path parameters are missing +- Query parameters are malformed (but not validation errors) + +**Example:** + +```json +{ + "success": false, + "error": { + "code": "BAD_REQUEST", + "message": "Request payload too large" + } +} +``` + +--- + +### INTERNAL_ERROR + +**HTTP Status:** 500 Internal Server Error + +**Meaning:** An unexpected error occurred on the server. + +**When to use:** + +- Unhandled exceptions +- Database connection failures +- External service failures +- Timeout errors +- Any error that cannot be categorized as a client error + +**Example:** + +```json +{ + "success": false, + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } +} +``` + +--- + +### RATE_LIMIT + +**HTTP Status:** 429 Too Many Requests + +**Meaning:** The client has exceeded the rate limit for this endpoint. + +**When to use:** + +- Client has made too many requests in a time window +- Rate limiting middleware has rejected the request + +**Example:** + +```json +{ + "success": false, + "error": { + "code": "RATE_LIMIT", + "message": "Too many requests. Please try again later." + } +} +``` + +--- + +### DATABASE_ERROR (PRISMA_ERROR) + +**HTTP Status:** 400 Bad Request + +**Meaning:** A database operation failed. + +**When to use:** + +- Prisma errors (P2002, P2025, P2003, etc.) +- Database constraint violations +- Foreign key violations +- Record not found in database + +**Example:** + +```json +{ + "success": false, + "error": { + "code": "DATABASE_ERROR", + "message": "Record already exists (unique constraint violation)" + } +} +``` + +--- + +### TOKEN_ERROR (JWT_ERROR) + +**HTTP Status:** 401 Unauthorized + +**Meaning:** JWT token validation failed. + +**When to use:** + +- JWT token is invalid or malformed +- JWT token has expired +- JWT signature verification failed + +**Example:** + +```json +{ + "success": false, + "error": { + "code": "TOKEN_ERROR", + "message": "Token has expired" + } +} +``` + +--- + +## Adding New Error Codes + +### When to Add a New Code + +Add a new error code **only** when: + +1. **No existing code fits the error condition.** Review the registry above to ensure an existing code cannot be reused. +2. **The error is a distinct, recoverable client condition.** Transient server errors should use `INTERNAL_ERROR`. +3. **Clients need to handle this error differently.** If clients would handle it the same way as an existing code, reuse the existing code. +4. **The error is stable and will not change.** Error codes are part of the public API contract. + +### When NOT to Add a New Code + +Do **not** add a new code if: + +- The error is a variant of an existing code (e.g., "CREATOR_NOT_FOUND" vs "NOT_FOUND"). Use the existing code with a descriptive message instead. +- The error is transient or internal (e.g., "CACHE_MISS", "RETRY_NEEDED"). Use `INTERNAL_ERROR`. +- The error is specific to a single endpoint or feature. Use an existing code and vary the message. +- The error is a database-specific error. Use `DATABASE_ERROR` and include details in the message. + +### How to Add a New Code + +1. **Add the constant** to `src/constants/error.constants.ts`: + + ```typescript + export const ErrorCode = { + // ... existing codes + NEW_ERROR_CODE: 'NEW_ERROR_CODE', + } as const; + ``` + +2. **Document the code** in this registry with: + - HTTP status code + - Clear meaning + - When to use (with examples) + - Example error response + +3. **Update error handling** in relevant middleware or handlers to use the new code. + +4. **Add tests** that verify the new code is returned in the appropriate error conditions. + +5. **Update this document** with the new code entry. + +### Example: Adding a New Code + +**Scenario:** You need a distinct error code for when a creator's profile is incomplete and cannot be published. + +**Decision:** This is a client error (incomplete data), distinct from validation errors (schema violations), and clients need to handle it differently (prompt user to complete profile vs. show validation errors). Add a new code. + +**Implementation:** + +1. Add to `error.constants.ts`: + + ```typescript + export const ErrorCode = { + // ... existing codes + INCOMPLETE_PROFILE: 'INCOMPLETE_PROFILE', + } as const; + ``` + +2. Document in this registry: + + ```markdown + ### INCOMPLETE_PROFILE + + **HTTP Status:** 400 Bad Request + + **Meaning:** The creator profile is missing required fields and cannot be published. + + **When to use:** + + - Attempting to publish a profile with missing required fields + - Profile lacks required information (bio, avatar, etc.) + ``` + +3. Use in handler: + + ```typescript + if (!profile.bio || !profile.avatarUrl) { + return sendError( + res, + 400, + ErrorCode.INCOMPLETE_PROFILE, + 'Profile is incomplete' + ); + } + ``` + +4. Add test: + ```typescript + it('returns INCOMPLETE_PROFILE when bio is missing', async () => { + const res = await publishProfile({ avatarUrl: 'url' }); + expect(res.body.error.code).toBe(ErrorCode.INCOMPLETE_PROFILE); + }); + ``` + +## Error Code Stability + +Error codes are part of the public API contract and should be treated as stable: + +- **Do not rename** existing codes. If a code needs to be renamed, create a new code and deprecate the old one. +- **Do not remove** codes. If a code is no longer used, mark it as deprecated in comments but keep it defined. +- **Do not change HTTP status codes** for existing codes. If the status code needs to change, create a new code. +- **Do not change meanings** of existing codes. If the meaning needs to change, create a new code. + +## Related Documentation + +- [API Error Handling](./architecture/route-error-mapping.md) - Error handling architecture +- [Error Constants](../src/constants/error.constants.ts) - Error code definitions +- [API Response Utilities](../src/utils/api-response.utils.ts) - Helper functions for sending errors diff --git a/src/middlewares/body-parse-error.middleware.ts b/src/middlewares/body-parse-error.middleware.ts index 36af7ad..bc8712a 100644 --- a/src/middlewares/body-parse-error.middleware.ts +++ b/src/middlewares/body-parse-error.middleware.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express'; import { logger } from '../utils/logger.utils'; import { getClientIp } from '../utils/client-ip.utils'; import { ErrorCode } from '../constants/error.constants'; +import { sanitizeLogFieldValue } from '../utils/log-field-sanitizer.utils'; const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); @@ -22,47 +23,46 @@ const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); * - request headers beyond what Express already exposes on req */ export const bodyParseErrorMiddleware = ( - err: any, - req: Request, - res: Response, - next: NextFunction + err: any, + req: Request, + res: Response, + next: NextFunction ): void => { - const isSyntaxError = - err instanceof SyntaxError && 'body' in err; - const isEntityTooLarge = - err.type === 'entity.too.large' || - err.status === 413 || - err.statusCode === 413; + const isSyntaxError = err instanceof SyntaxError && 'body' in err; + const isEntityTooLarge = + err.type === 'entity.too.large' || + err.status === 413 || + err.statusCode === 413; - const isParseFailure = isSyntaxError || isEntityTooLarge; + const isParseFailure = isSyntaxError || isEntityTooLarge; - if (!isParseFailure || !MUTATION_METHODS.has(req.method)) { - return next(err); - } + if (!isParseFailure || !MUTATION_METHODS.has(req.method)) { + return next(err); + } - const clientIp = getClientIp(req); + const clientIp = getClientIp(req); - logger.error({ - type: 'body_parse_failure', - method: req.method, - path: req.originalUrl || req.url, - requestId: req.requestId, - clientIp, - errorType: isEntityTooLarge ? 'entity.too.large' : 'invalid_json', - }); + logger.error({ + type: 'body_parse_failure', + method: req.method, + path: sanitizeLogFieldValue(req.originalUrl || req.url), + requestId: req.requestId, + clientIp, + errorType: isEntityTooLarge ? 'entity.too.large' : 'invalid_json', + }); - if (isEntityTooLarge) { - res.status(413).json({ + if (isEntityTooLarge) { + res.status(413).json({ + success: false, + code: ErrorCode.BAD_REQUEST, + message: 'Request payload too large', + }); + return; + } + + res.status(400).json({ success: false, code: ErrorCode.BAD_REQUEST, - message: 'Request payload too large', - }); - return; - } - - res.status(400).json({ - success: false, - code: ErrorCode.BAD_REQUEST, - message: 'Invalid JSON in request body', - }); + message: 'Invalid JSON in request body', + }); }; diff --git a/src/middlewares/error.middleware.ts b/src/middlewares/error.middleware.ts index 6844249..0f9b091 100644 --- a/src/middlewares/error.middleware.ts +++ b/src/middlewares/error.middleware.ts @@ -8,6 +8,7 @@ import { ErrorCode, ErrorCodeType } from '../constants/error.constants'; import { logger } from '../utils/logger.utils'; import { mapUnknownRouteError } from '../utils/route-error.utils'; import { buildErrorContext } from '../utils/error-context.utils'; +import { sanitizeLogFieldValue } from '../utils/log-field-sanitizer.utils'; export class ApiError extends Error { statusCode: number; @@ -65,7 +66,7 @@ export const errorHandler: ErrorRequestHandler = ( requestId: req.requestId, includeStack: envConfig.MODE === 'development', }), - route: `${req.method} ${req.originalUrl}`, + route: `${req.method} ${sanitizeLogFieldValue(req.originalUrl)}`, }, 'Error caught by global handler' ); @@ -144,7 +145,7 @@ export const errorHandler: ErrorRequestHandler = ( ) { logger.warn({ msg: 'Request payload too large', - route: `${req.method} ${req.originalUrl}`, + route: `${req.method} ${sanitizeLogFieldValue(req.originalUrl)}`, contentLength: req.headers['content-length'], limitBytes: err.limit, }); diff --git a/src/middlewares/request-logger.middleware.ts b/src/middlewares/request-logger.middleware.ts index 638b00d..0825f9a 100644 --- a/src/middlewares/request-logger.middleware.ts +++ b/src/middlewares/request-logger.middleware.ts @@ -4,6 +4,7 @@ import { envConfig } from '../config'; import { logger } from '../utils/logger.utils'; import { computeRequestContextHash } from '../utils/request-context-hash.utils'; import { getClientIp } from '../utils/client-ip.utils'; +import { sanitizeLogFieldValue } from '../utils/log-field-sanitizer.utils'; /** * Lightweight request logging middleware. @@ -36,7 +37,7 @@ export const requestLoggerMiddleware = ( logger.info({ type: 'request', method: req.method, - url: req.originalUrl || req.url, + url: sanitizeLogFieldValue(req.originalUrl || req.url), status: res.statusCode, duration: `${durationMs}ms`, requestId: req.requestId, diff --git a/src/modules/creators/creator-list-sort-filter-combined.integration.test.ts b/src/modules/creators/creator-list-sort-filter-combined.integration.test.ts new file mode 100644 index 0000000..71fa384 --- /dev/null +++ b/src/modules/creators/creator-list-sort-filter-combined.integration.test.ts @@ -0,0 +1,388 @@ +// Integration test: creator list with combined sort and filter parameters +// +// Verifies that sort and filter parameters work correctly when applied together. +// Tests that items satisfy the filter constraint AND are in the expected sort order. +// Uses a fixture with enough variety to confirm both constraints are applied. +// +// Scope: exercises the complete request path with both sort and filter parameters, +// ensuring neither constraint is dropped silently and they interact correctly. + +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(); +} + +// ── Fixture: creators spanning multiple categories ──────────────────────────── +// +// Fixture set includes: +// • Verified and unverified creators +// • Creators with searchable handles/displayNames +// • Multiple creation dates for sort testing +// +// This variety ensures both filter and sort constraints are testable. + +const FIXTURE_VERIFIED_ALICE: CreatorProfile = { + id: 'cuid-1', + userId: 'user-1', + handle: 'alice_jazz', + displayName: 'Alice Jazz', + isVerified: true, + createdAt: new Date('2024-01-01T10:00:00Z'), + updatedAt: new Date('2024-01-01T10:00:00Z'), +}; + +const FIXTURE_VERIFIED_BOB: CreatorProfile = { + id: 'cuid-2', + userId: 'user-2', + handle: 'bob_rock', + displayName: 'Bob Rock', + isVerified: true, + createdAt: new Date('2024-01-02T10:00:00Z'), + updatedAt: new Date('2024-01-02T10:00:00Z'), +}; + +const FIXTURE_VERIFIED_CHARLIE: CreatorProfile = { + id: 'cuid-3', + userId: 'user-3', + handle: 'charlie_jazz', + displayName: 'Charlie Jazz', + isVerified: true, + createdAt: new Date('2024-01-03T10:00:00Z'), + updatedAt: new Date('2024-01-03T10:00:00Z'), +}; + +const FIXTURE_UNVERIFIED_DIANA: CreatorProfile = { + id: 'cuid-4', + userId: 'user-4', + handle: 'diana_jazz', + displayName: 'Diana Jazz', + isVerified: false, + createdAt: new Date('2024-01-04T10:00:00Z'), + updatedAt: new Date('2024-01-04T10:00:00Z'), +}; + +const FIXTURE_UNVERIFIED_EVE: CreatorProfile = { + id: 'cuid-5', + userId: 'user-5', + handle: 'eve_rock', + displayName: 'Eve Rock', + isVerified: false, + createdAt: new Date('2024-01-05T10:00:00Z'), + updatedAt: new Date('2024-01-05T10:00:00Z'), +}; + +const ALL_FIXTURES = [ + FIXTURE_VERIFIED_ALICE, + FIXTURE_VERIFIED_BOB, + FIXTURE_VERIFIED_CHARLIE, + FIXTURE_UNVERIFIED_DIANA, + FIXTURE_UNVERIFIED_EVE, +]; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators — combined sort and filter parameters', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Filter: verified=true, Sort: createdAt desc ──────────────────────────── + + it('filters by verified=true and sorts by createdAt descending', async () => { + const verifiedCreators = [ + FIXTURE_VERIFIED_CHARLIE, + FIXTURE_VERIFIED_BOB, + FIXTURE_VERIFIED_ALICE, + ]; + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([verifiedCreators, verifiedCreators.length]); + + const req = makeReq({ + verified: 'true', + sort: 'createdAt', + order: 'desc', + }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + + // Assert filter: all items are verified + expect(body.data.items).toHaveLength(3); + body.data.items.forEach((item: any) => { + const fixture = ALL_FIXTURES.find(f => f.id === item.id); + expect(fixture?.isVerified).toBe(true); + }); + + // Assert sort: items are in descending order by createdAt + const ids = body.data.items.map((item: any) => item.id); + expect(ids).toEqual(['cuid-3', 'cuid-2', 'cuid-1']); + }); + + // ── Filter: verified=true, Sort: displayName ascending ───────────────────── + + it('filters by verified=true and sorts by displayName ascending', async () => { + const verifiedCreators = [ + FIXTURE_VERIFIED_ALICE, + FIXTURE_VERIFIED_BOB, + FIXTURE_VERIFIED_CHARLIE, + ]; + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([verifiedCreators, verifiedCreators.length]); + + const req = makeReq({ + verified: 'true', + sort: 'displayName', + order: 'asc', + }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + + // Assert filter: all items are verified + body.data.items.forEach((item: any) => { + const fixture = ALL_FIXTURES.find(f => f.id === item.id); + expect(fixture?.isVerified).toBe(true); + }); + + // Assert sort: items are in ascending order by displayName + const names = body.data.items.map((item: any) => item.name); + expect(names).toEqual(['Alice Jazz', 'Bob Rock', 'Charlie Jazz']); + }); + + // ── Filter: search term, Sort: handle ascending ──────────────────────────── + + it('filters by search term and sorts by handle ascending', async () => { + // Simulate search for "jazz" matching Alice, Charlie, Diana + const jazzCreators = [ + FIXTURE_VERIFIED_ALICE, + FIXTURE_VERIFIED_CHARLIE, + FIXTURE_UNVERIFIED_DIANA, + ]; + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([jazzCreators, jazzCreators.length]); + + const req = makeReq({ search: 'jazz', sort: 'handle', order: 'asc' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + + // Assert filter: all items contain "jazz" in handle or displayName + body.data.items.forEach((item: any) => { + const fixture = ALL_FIXTURES.find(f => f.id === item.id); + const hasJazz = + fixture?.handle.toLowerCase().includes('jazz') || + fixture?.displayName.toLowerCase().includes('jazz'); + expect(hasJazz).toBe(true); + }); + + // Assert sort: items are in ascending order by handle + const handles = body.data.items.map((item: any) => { + const fixture = ALL_FIXTURES.find(f => f.id === item.id); + return fixture?.handle; + }); + expect(handles).toEqual(['alice_jazz', 'charlie_jazz', 'diana_jazz']); + }); + + // ── Filter: verified=false, Sort: updatedAt descending ────────────────────── + + it('filters by verified=false and sorts by updatedAt descending', async () => { + const unverifiedCreators = [ + FIXTURE_UNVERIFIED_EVE, + FIXTURE_UNVERIFIED_DIANA, + ]; + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([unverifiedCreators, unverifiedCreators.length]); + + const req = makeReq({ + verified: 'false', + sort: 'updatedAt', + order: 'desc', + }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + + // Assert filter: all items are unverified + expect(body.data.items).toHaveLength(2); + body.data.items.forEach((item: any) => { + const fixture = ALL_FIXTURES.find(f => f.id === item.id); + expect(fixture?.isVerified).toBe(false); + }); + + // Assert sort: items are in descending order by updatedAt + const ids = body.data.items.map((item: any) => item.id); + expect(ids).toEqual(['cuid-5', 'cuid-4']); + }); + + // ── Filter: verified + search, Sort: createdAt ascending ──────────────────── + + it('filters by verified=true and search term, sorts by createdAt ascending', async () => { + // Simulate verified creators with "jazz" in handle/displayName + const verifiedJazzCreators = [ + FIXTURE_VERIFIED_ALICE, + FIXTURE_VERIFIED_CHARLIE, + ]; + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([ + verifiedJazzCreators, + verifiedJazzCreators.length, + ]); + + const req = makeReq({ + verified: 'true', + search: 'jazz', + sort: 'createdAt', + order: 'asc', + }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + + // Assert filter: all items are verified AND contain "jazz" + body.data.items.forEach((item: any) => { + const fixture = ALL_FIXTURES.find(f => f.id === item.id); + expect(fixture?.isVerified).toBe(true); + const hasJazz = + fixture?.handle.toLowerCase().includes('jazz') || + fixture?.displayName.toLowerCase().includes('jazz'); + expect(hasJazz).toBe(true); + }); + + // Assert sort: items are in ascending order by createdAt + const ids = body.data.items.map((item: any) => item.id); + expect(ids).toEqual(['cuid-1', 'cuid-3']); + }); + + // ── Pagination metadata with filters and sort ────────────────────────────── + + it('includes correct pagination metadata with filters and sort applied', async () => { + const verifiedCreators = [ + FIXTURE_VERIFIED_ALICE, + FIXTURE_VERIFIED_BOB, + FIXTURE_VERIFIED_CHARLIE, + ]; + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([verifiedCreators, verifiedCreators.length]); + + const req = makeReq({ + verified: 'true', + sort: 'createdAt', + order: 'desc', + limit: '20', + offset: '0', + }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta).toEqual({ + limit: 20, + offset: 0, + total: 3, + hasMore: false, + }); + }); + + // ── Verify filter is not dropped silently ────────────────────────────────── + + it('fails if filter is dropped (all creators returned instead of filtered)', async () => { + // Mock returns ALL creators instead of just verified ones + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([ALL_FIXTURES, ALL_FIXTURES.length]); + + const req = makeReq({ + verified: 'true', + sort: 'createdAt', + order: 'desc', + }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + + // This test should fail if the filter is not applied + // because we'd get 5 items instead of 3 + expect(body.data.items.length).not.toBe(5); + }); + + // ── Verify sort is not dropped silently ──────────────────────────────────── + + it('fails if sort is dropped (items not in expected order)', async () => { + // Mock returns verified creators but in wrong order + const wrongOrder = [ + FIXTURE_VERIFIED_ALICE, + FIXTURE_VERIFIED_CHARLIE, + FIXTURE_VERIFIED_BOB, + ]; + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([wrongOrder, wrongOrder.length]); + + const req = makeReq({ + verified: 'true', + sort: 'createdAt', + order: 'desc', + }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + const ids = body.data.items.map((item: any) => item.id); + + // This test should fail if sort is not applied correctly + // because we'd get [cuid-1, cuid-3, cuid-2] instead of [cuid-3, cuid-2, cuid-1] + expect(ids).not.toEqual(['cuid-1', 'cuid-3', 'cuid-2']); + }); + + // ── Response structure is valid with combined parameters ──────────────────── + + it('returns valid response structure with combined sort and filter', async () => { + const verifiedCreators = [FIXTURE_VERIFIED_ALICE]; + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([verifiedCreators, verifiedCreators.length]); + + const req = makeReq({ verified: 'true', sort: 'handle', order: 'asc' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body).toHaveProperty('success', true); + expect(body).toHaveProperty('data'); + expect(body.data).toHaveProperty('items'); + expect(body.data).toHaveProperty('meta'); + expect(Array.isArray(body.data.items)).toBe(true); + expect(typeof body.data.meta).toBe('object'); + }); +}); diff --git a/src/modules/creators/creator-list-zero-results.integration.test.ts b/src/modules/creators/creator-list-zero-results.integration.test.ts new file mode 100644 index 0000000..db976d6 --- /dev/null +++ b/src/modules/creators/creator-list-zero-results.integration.test.ts @@ -0,0 +1,181 @@ +// Integration test: creator list response with zero total results +// +// Verifies that an empty database or a filter set that matches no creators +// returns a valid response with an empty items array and accurate pagination metadata. +// Uses Jest mocks with an isolated empty fixture — no database required. +// +// Scope: exercises the complete response envelope and pagination metadata shape +// when the result set is empty, ensuring graceful handling of the baseline state. + +import { httpListCreators } from './creators.controllers'; +import * as creatorsUtils from './creators.utils'; + +// ── 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(); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators — zero total results', () => { + beforeEach(() => { + // Mock fetchCreatorList to return empty results with zero total + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([[], 0]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Response Envelope Structure ──────────────────────────────────────────── + + it('returns valid response with empty items array', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body).toHaveProperty('success', true); + expect(body).toHaveProperty('data'); + expect(body.data).toHaveProperty('items'); + expect(Array.isArray(body.data.items)).toBe(true); + expect(body.data.items).toHaveLength(0); + }); + + it('returns pagination metadata with zero total count', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveProperty('meta'); + expect(body.data.meta).toHaveProperty('total', 0); + expect(typeof body.data.meta.total).toBe('number'); + }); + + it('returns hasMore=false when total is zero', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta).toHaveProperty('hasMore', false); + }); + + it('includes all required pagination metadata fields', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + const meta = body.data.meta; + expect(meta).toHaveProperty('limit'); + expect(meta).toHaveProperty('offset'); + expect(meta).toHaveProperty('total'); + expect(meta).toHaveProperty('hasMore'); + expect(typeof meta.limit).toBe('number'); + expect(typeof meta.offset).toBe('number'); + expect(typeof meta.total).toBe('number'); + expect(typeof meta.hasMore).toBe('boolean'); + }); + + // ── Default Pagination Values ────────────────────────────────────────────── + + it('applies default limit when not specified', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.limit).toBeGreaterThan(0); + expect(typeof body.data.meta.limit).toBe('number'); + }); + + it('applies default offset of 0 when not specified', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta.offset).toBe(0); + }); + + // ── Isolated Empty Fixture ───────────────────────────────────────────────── + + it('uses isolated empty fixture to avoid interference from other tests', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + // Verify that fetchCreatorList was called with the expected parameters + expect(creatorsUtils.fetchCreatorList).toHaveBeenCalled(); + + // Verify the mock was set up to return empty results + const mockResult = await creatorsUtils.fetchCreatorList({ + limit: 20, + offset: 0, + sort: 'createdAt', + order: 'desc', + } as any); + expect(mockResult[0]).toEqual([]); + expect(mockResult[1]).toBe(0); + }); + + // ── Edge Cases with Empty Results ────────────────────────────────────────── + + it('returns HTTP 200 for empty results (not 404)', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalled(); + }); + + it('maintains valid response structure with custom pagination params', async () => { + const req = makeReq({ limit: '50', offset: '10' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toEqual([]); + expect(body.data.meta.total).toBe(0); + expect(body.data.meta.hasMore).toBe(false); + }); + + it('maintains valid response structure with filter parameters', async () => { + const req = makeReq({ verified: 'true', search: 'nonexistent' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toEqual([]); + expect(body.data.meta.total).toBe(0); + }); + + it('does not call next() error handler on success', async () => { + const req = makeReq(); + const res = makeRes(); + const next = makeNext(); + await httpListCreators(req, res, next); + + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/creators/creators.sort-field.utils.ts b/src/modules/creators/creators.sort-field.utils.ts index d018c49..42d20e7 100644 --- a/src/modules/creators/creators.sort-field.utils.ts +++ b/src/modules/creators/creators.sort-field.utils.ts @@ -8,6 +8,7 @@ import { normalizeCreatorListQueryStringValue, } from './creators.query-string.utils'; import { logger } from '../../utils/logger.utils'; +import { sanitizeLogFieldValue } from '../../utils/log-field-sanitizer.utils'; /** * Returns true when `value` is an allowed public creator list sort field. @@ -57,7 +58,9 @@ export function warnIfUnrecognizedCreatorListSort( logger.warn({ msg: 'Unrecognized creator list sort field', - sort: encodeCreatorListQueryStringValue(normalized) ?? normalized, + sort: sanitizeLogFieldValue( + encodeCreatorListQueryStringValue(normalized) ?? normalized + ), ...(requestId ? { requestId } : {}), }); } diff --git a/src/utils/log-field-sanitizer.utils.test.ts b/src/utils/log-field-sanitizer.utils.test.ts new file mode 100644 index 0000000..ae28131 --- /dev/null +++ b/src/utils/log-field-sanitizer.utils.test.ts @@ -0,0 +1,302 @@ +/** + * Unit tests for log field sanitization utility. + * + * Covers sanitization of control characters (newline, carriage return, tab, etc.) + * to prevent log injection attacks. + */ + +import { + sanitizeLogFieldValue, + sanitizeLogObject, +} from './log-field-sanitizer.utils'; + +describe('sanitizeLogFieldValue', () => { + // ── Newline Character Tests ──────────────────────────────────────────────── + + it('escapes newline characters', () => { + const input = 'search\nterm'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe('search\\nterm'); + }); + + it('escapes multiple newlines', () => { + const input = 'line1\nline2\nline3'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe('line1\\nline2\\nline3'); + }); + + // ── Carriage Return Character Tests ──────────────────────────────────────── + + it('escapes carriage return characters', () => { + const input = 'handle\rname'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe('handle\\rname'); + }); + + it('escapes multiple carriage returns', () => { + const input = 'text\rmore\rtext'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe('text\\rmore\\rtext'); + }); + + // ── Tab Character Tests ─────────────────────────────────────────────────── + + it('escapes tab characters', () => { + const input = 'field\tvalue'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe('field\\tvalue'); + }); + + it('escapes multiple tabs', () => { + const input = 'col1\tcol2\tcol3'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe('col1\\tcol2\\tcol3'); + }); + + // ── Mixed Control Character Tests ────────────────────────────────────────── + + it('escapes mixed control characters', () => { + const input = 'search\nterm\rwith\ttabs'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe('search\\nterm\\rwith\\ttabs'); + }); + + it('escapes form feed characters', () => { + const input = 'text\fmore'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe('text\\fmore'); + }); + + it('escapes vertical tab characters', () => { + const input = 'text\vmore'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe('text\\vmore'); + }); + + it('escapes null characters', () => { + const input = 'text\0more'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe('text\\0more'); + }); + + // ── Edge Cases ───────────────────────────────────────────────────────────── + + it('returns empty string for null input', () => { + const result = sanitizeLogFieldValue(null); + expect(result).toBe(''); + }); + + it('returns empty string for undefined input', () => { + const result = sanitizeLogFieldValue(undefined); + expect(result).toBe(''); + }); + + it('returns unchanged string with no control characters', () => { + const input = 'normal string with no control chars'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe(input); + }); + + it('converts non-string values to string and sanitizes', () => { + const result = sanitizeLogFieldValue(42); + expect(result).toBe('42'); + }); + + it('converts boolean values to string', () => { + const result = sanitizeLogFieldValue(true); + expect(result).toBe('true'); + }); + + it('handles strings with only control characters', () => { + const input = '\n\r\t'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe('\\n\\r\\t'); + }); + + it('preserves special characters that are not control chars', () => { + const input = 'special!@#$%^&*()_+-=[]{}|;:,.<>?'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe(input); + }); + + it('preserves unicode characters', () => { + const input = 'unicode: 你好 🎉 café'; + const result = sanitizeLogFieldValue(input); + expect(result).toBe(input); + }); +}); + +describe('sanitizeLogObject', () => { + // ── String Value Sanitization ────────────────────────────────────────────── + + it('sanitizes string values in a flat object', () => { + const input = { + search: 'term\nwith\nnewlines', + handle: 'user\rname', + }; + const result = sanitizeLogObject(input); + expect(result).toEqual({ + search: 'term\\nwith\\nnewlines', + handle: 'user\\rname', + }); + }); + + // ── Nested Object Sanitization ───────────────────────────────────────────── + + it('sanitizes nested objects recursively', () => { + const input = { + user: { + search: 'query\nwith\nnewline', + profile: { + handle: 'handle\rwith\rcarriage', + }, + }, + }; + const result = sanitizeLogObject(input); + expect(result).toEqual({ + user: { + search: 'query\\nwith\\nnewline', + profile: { + handle: 'handle\\rwith\\rcarriage', + }, + }, + }); + }); + + // ── Array Sanitization ───────────────────────────────────────────────────── + + it('sanitizes string values in arrays', () => { + const input = { + items: ['item\nwith\nnewline', 'normal item', 'tab\there'], + }; + const result = sanitizeLogObject(input); + expect(result).toEqual({ + items: ['item\\nwith\\nnewline', 'normal item', 'tab\\there'], + }); + }); + + it('sanitizes nested arrays of objects', () => { + const input = { + results: [{ name: 'name\nwith\nnewline' }, { name: 'normal\rname' }], + }; + const result = sanitizeLogObject(input); + expect(result).toEqual({ + results: [ + { name: 'name\\nwith\\nnewline' }, + { name: 'normal\\rname' }, + ], + }); + }); + + // ── Non-String Value Preservation ────────────────────────────────────────── + + it('preserves non-string values unchanged', () => { + const input = { + count: 42, + active: true, + ratio: 3.14, + empty: null, + missing: undefined, + }; + const result = sanitizeLogObject(input); + expect(result).toEqual(input); + }); + + it('preserves mixed types in objects', () => { + const input = { + search: 'term\nwith\nnewline', + count: 42, + active: true, + nested: { + value: 'nested\rvalue', + number: 100, + }, + }; + const result = sanitizeLogObject(input); + expect(result).toEqual({ + search: 'term\\nwith\\nnewline', + count: 42, + active: true, + nested: { + value: 'nested\\rvalue', + number: 100, + }, + }); + }); + + // ── Edge Cases ───────────────────────────────────────────────────────────── + + it('handles null input', () => { + const result = sanitizeLogObject(null); + expect(result).toBeNull(); + }); + + it('handles undefined input', () => { + const result = sanitizeLogObject(undefined); + expect(result).toBeUndefined(); + }); + + it('handles empty object', () => { + const result = sanitizeLogObject({}); + expect(result).toEqual({}); + }); + + it('handles empty array', () => { + const result = sanitizeLogObject([]); + expect(result).toEqual([]); + }); + + it('does not mutate original object', () => { + const input = { + search: 'term\nwith\nnewline', + count: 42, + }; + const original = JSON.parse(JSON.stringify(input)); + sanitizeLogObject(input); + expect(input).toEqual(original); + }); + + it('handles deeply nested structures', () => { + const input = { + level1: { + level2: { + level3: { + value: 'deep\nvalue', + }, + }, + }, + }; + const result = sanitizeLogObject(input); + expect(result).toEqual({ + level1: { + level2: { + level3: { + value: 'deep\\nvalue', + }, + }, + }, + }); + }); + + it('handles arrays with mixed types', () => { + const input = { + mixed: [ + 'string\nwith\nnewline', + 42, + true, + { nested: 'value\rwith\rcarriage' }, + null, + ], + }; + const result = sanitizeLogObject(input); + expect(result).toEqual({ + mixed: [ + 'string\\nwith\\nnewline', + 42, + true, + { nested: 'value\\rwith\\rcarriage' }, + null, + ], + }); + }); +}); diff --git a/src/utils/log-field-sanitizer.utils.ts b/src/utils/log-field-sanitizer.utils.ts new file mode 100644 index 0000000..f761579 --- /dev/null +++ b/src/utils/log-field-sanitizer.utils.ts @@ -0,0 +1,103 @@ +/** + * Log field sanitization utility to prevent log injection attacks. + * + * Strips or escapes control characters (newlines, carriage returns, tabs, etc.) + * from log field values to prevent injection into structured log streams. + * + * This is particularly important for user-supplied values like search terms, + * handles, and filter parameters that could contain newline characters to + * break log parsing. + */ + +/** + * Control characters that can break structured log parsing. + * Includes: newline, carriage return, tab, form feed, vertical tab, null. + */ +const CONTROL_CHAR_PATTERN = /[\n\r\t\f\v\0]/g; + +/** + * Escape sequence map for control characters. + * Maps control characters to their escaped representations. + */ +const ESCAPE_MAP: Record = { + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\f': '\\f', + '\v': '\\v', + '\0': '\\0', +}; + +/** + * Sanitizes a log field value by escaping control characters. + * + * Converts control characters to their escaped representations: + * - Newline (\n) → \\n + * - Carriage return (\r) → \\r + * - Tab (\t) → \\t + * - Form feed (\f) → \\f + * - Vertical tab (\v) → \\v + * - Null (\0) → \\0 + * + * @param value - The log field value to sanitize + * @returns Sanitized string with control characters escaped + * + * @example + * sanitizeLogFieldValue('search\nterm') // Returns: 'search\\nterm' + * sanitizeLogFieldValue('handle\rwith\rcarriage') // Returns: 'handle\\rwith\\rcarriage' + * sanitizeLogFieldValue('normal string') // Returns: 'normal string' + */ +export function sanitizeLogFieldValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + + const str = String(value); + return str.replace(CONTROL_CHAR_PATTERN, char => ESCAPE_MAP[char] || char); +} + +/** + * Sanitizes all string values in an object recursively. + * + * Walks through an object and applies sanitizeLogFieldValue to all string values, + * leaving non-string values unchanged. Handles nested objects and arrays. + * + * @param obj - Object to sanitize + * @returns New object with all string values sanitized + * + * @example + * sanitizeLogObject({ + * search: 'term\nwith\nnewlines', + * count: 42, + * nested: { handle: 'user\rname' } + * }) + * // Returns: + * // { + * // search: 'term\\nwith\\nnewlines', + * // count: 42, + * // nested: { handle: 'user\\rname' } + * // } + */ +export function sanitizeLogObject(obj: unknown): unknown { + if (obj === null || obj === undefined) { + return obj; + } + + if (typeof obj === 'string') { + return sanitizeLogFieldValue(obj); + } + + if (Array.isArray(obj)) { + return obj.map(item => sanitizeLogObject(item)); + } + + if (typeof obj === 'object') { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(obj)) { + sanitized[key] = sanitizeLogObject(value); + } + return sanitized; + } + + return obj; +}