From 147d2f71a08ff53a42291b3d99af1e292300a921 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 13:21:05 +0330 Subject: [PATCH 01/12] chore: add helmet, compression, throttler dependencies --- package-lock.json | 80 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 ++ 2 files changed, 83 insertions(+) diff --git a/package-lock.json b/package-lock.json index b5bc16b..febe8f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,16 +20,19 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.0", "@nestjs/terminus": "^11.0.0", + "@nestjs/throttler": "^6.5.0", "@prisma/client": "^6.15.0", "@turf/turf": "^7.2.0", "cache-manager": "^7.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "compression": "^1.8.1", "connect-redis": "^9.0.0", "cookie-parser": "^1.4.7", "cross-env": "^7.0.3", "date-fns": "^4.1.0", "express-session": "^1.18.1", + "helmet": "^8.3.0", "jsonwebtoken": "^9.0.2", "redis": "^5.6.0", "reflect-metadata": "^0.2.2", @@ -4237,6 +4240,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -10206,6 +10220,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -12235,6 +12303,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/hookified": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.10.0.tgz", diff --git a/package.json b/package.json index 20e948f..c879e4d 100644 --- a/package.json +++ b/package.json @@ -38,16 +38,19 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.0", "@nestjs/terminus": "^11.0.0", + "@nestjs/throttler": "^6.5.0", "@prisma/client": "^6.15.0", "@turf/turf": "^7.2.0", "cache-manager": "^7.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "compression": "^1.8.1", "connect-redis": "^9.0.0", "cookie-parser": "^1.4.7", "cross-env": "^7.0.3", "date-fns": "^4.1.0", "express-session": "^1.18.1", + "helmet": "^8.3.0", "jsonwebtoken": "^9.0.2", "redis": "^5.6.0", "reflect-metadata": "^0.2.2", From d1c69f2584288e1b7ccf99667fb2a35b657c3f28 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 13:21:19 +0330 Subject: [PATCH 02/12] refactor: add EnvSchema typed configuration and config key constants --- src/common/config/config-names.ts | 84 ++++++++++++ src/common/config/env.validation.spec.ts | 93 +++++++++++++ src/common/config/env.validation.ts | 159 +++++++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 src/common/config/config-names.ts create mode 100644 src/common/config/env.validation.spec.ts create mode 100644 src/common/config/env.validation.ts diff --git a/src/common/config/config-names.ts b/src/common/config/config-names.ts new file mode 100644 index 0000000..a57ddf2 --- /dev/null +++ b/src/common/config/config-names.ts @@ -0,0 +1,84 @@ +/** + * Canonical config keys as const objects to eliminate stringly-typed typos. + * Bootstrap/shared consumers use these in Phase 2; services are migrated in + * their own phases. + */ +export const ConfigKey = { + Database: { + Url: 'DATABASE_URL', + }, + Redis: { + Url: 'REDIS_URL', + OtpUrl: 'OTP_REDIS_URL', + SessionUrl: 'SESSION_REDIS_URL', + }, + Session: { + Secret: 'SESSION_SECRET', + Secure: 'COOKIE_SECURE', + SameSite: 'COOKIE_SAMESITE', + Domain: 'COOKIE_DOMAIN', + MaxAge: 'COOKIE_MAX_AGE', + }, + Auth: { + CookieSecret: 'COOKIE_SECRET', + MaxSendAttempts: 'MAX_SEND_ATTEMPTS', + MaxCheckAttempts: 'MAX_CHECK_ATTEMPTS', + SendWindow: 'SEND_WINDOW', + BaseBlockTime: 'BASE_BLOCK_TIME', + OtpExpirationTime: 'OTP_EXPIRATION_TIME', + JwtAccessSecret: 'JWT_ACCESS_SECRET_KEY', + JwtTempSecret: 'JWT_TEMP_SECRET_KEY', + JwtProgressSecret: 'JWT_PROGRESS_SECRET_KEY', + }, + Pricing: { + BasePrice: 'PRICING_BASE_PRICE', + FuelRate: 'PRICING_FUEL_RATE', + WeightBaseRate: 'PRICING_WEIGHT_BASE_RATE', + PlatformCommission: 'PRICING_PLATFORM_COMMISSION', + DriverShare: 'PRICING_DRIVER_SHARE', + FragileMultiplier: 'PRICING_FRAGILE_MULTIPLIER', + PerishableMultiplier: 'PRICING_PERISHABLE_MULTIPLIER', + BothFragilePerishable: 'PRICING_BOTH_FRAGILE_PERISHABLE', + MajorCityOrigin: 'PRICING_MAJOR_CITY_ORIGIN', + MajorCityDestination: 'PRICING_MAJOR_CITY_DESTINATION', + BothMajorCities: 'PRICING_BOTH_MAJOR_CITIES', + SmallCityFactor: 'PRICING_SMALL_CITY_FACTOR', + DeviationRate: 'PRICING_DEVIATION_RATE', + TimeDeviationRate: 'PRICING_TIME_DEVIATION_RATE', + Tier1Rate: 'PRICING_TIER_1_RATE', + Tier2Rate: 'PRICING_TIER_2_RATE', + Tier3Rate: 'PRICING_TIER_3_RATE', + Tier4Rate: 'PRICING_TIER_4_RATE', + Tier5Rate: 'PRICING_TIER_5_RATE', + MajorCities: 'PRICING_MAJOR_CITIES', + CorridorWidth: 'CORRIDOR_WIDTH', + }, + Aws: { + AccessKey: 'AWS_ACCESS_KEY', + SecretKey: 'AWS_SECRET_KEY', + BucketName: 'AWS_BUCKET_NAME', + Endpoint: 'AWS_ENDPOINT', + Region: 'AWS_REGION', + }, + Map: { + ApiKey: 'MAP_API_KEY', + ApiUrl: 'MAP_API_URL', + }, + Sms: { + ApiKey: 'SMS_API_KEY', + }, + Throttle: { + Ttl: 'THROTTLE_TTL', + Limit: 'THROTTLE_LIMIT', + }, + Server: { + Port: 'PORT', + ApiPrefix: 'API_PREFIX', + LogLevel: 'LOG_LEVEL', + NodeEnv: 'NODE_ENV', + CorsOrigins: 'CORS_ORIGINS', + TimeoutMs: 'TIMEOUT_MS', + ResponseWrapperEnabled: 'RESPONSE_WRAPPER_ENABLED', + MemoryHeapThresholdMb: 'MEMORY_HEAP_THRESHOLD_MB', + }, +} as const; diff --git a/src/common/config/env.validation.spec.ts b/src/common/config/env.validation.spec.ts new file mode 100644 index 0000000..3c4121b --- /dev/null +++ b/src/common/config/env.validation.spec.ts @@ -0,0 +1,93 @@ +import 'reflect-metadata'; +import { validateEnv, EnvSchema } from './env.validation'; + +const makeFullEnv = () => ({ + DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/hambaar-db', + REDIS_URL: 'redis://localhost:6379', + OTP_REDIS_URL: 'redis://localhost:6379/1', + SESSION_REDIS_URL: 'redis://localhost:6379/2', + SESSION_SECRET: 'a'.repeat(16), + COOKIE_SECRET: 'b'.repeat(16), + JWT_ACCESS_SECRET_KEY: 'c'.repeat(16), + JWT_TEMP_SECRET_KEY: 'd'.repeat(16), + JWT_PROGRESS_SECRET_KEY: 'e'.repeat(16), + AWS_ACCESS_KEY: 'aws-access-key', + AWS_SECRET_KEY: 'aws-secret-key', + AWS_BUCKET_NAME: 'aws-bucket', + AWS_ENDPOINT: 'https://storage.example.com', + MAP_API_KEY: 'map-api-key', + MAP_API_URL: 'https://api.example.com', + SMS_API_KEY: 'sms-api-key', +}); + +describe('validateEnv', () => { + it('passes with a valid full env', () => { + const result = validateEnv(makeFullEnv()); + expect(result).toBeDefined(); + }); + + describe('required keys', () => { + const requiredKeys = [ + 'DATABASE_URL', + 'REDIS_URL', + 'OTP_REDIS_URL', + 'SESSION_REDIS_URL', + 'SESSION_SECRET', + 'COOKIE_SECRET', + 'JWT_ACCESS_SECRET_KEY', + 'JWT_TEMP_SECRET_KEY', + 'JWT_PROGRESS_SECRET_KEY', + 'AWS_ACCESS_KEY', + 'AWS_SECRET_KEY', + 'AWS_BUCKET_NAME', + 'AWS_ENDPOINT', + 'MAP_API_KEY', + 'MAP_API_URL', + 'SMS_API_KEY', + ]; + + it.each(requiredKeys)('fails when %s is missing', (key) => { + const env = makeFullEnv(); + delete (env as any)[key]; + expect(() => validateEnv(env)).toThrow(/Invalid environment configuration/); + }); + }); + + it('fails on invalid value types (PORT=abc)', () => { + const env = { ...makeFullEnv(), PORT: 'abc' }; + expect(() => validateEnv(env)).toThrow(/PORT/); + }); + + it('fails when JWT secrets are too short (<16 chars)', () => { + const env = { ...makeFullEnv(), JWT_ACCESS_SECRET_KEY: 'short' }; + expect(() => validateEnv(env)).toThrow(/JWT_ACCESS_SECRET_KEY/); + }); + + it('passes when optional keys are absent', () => { + const env = makeFullEnv(); + const result = validateEnv(env); + expect(result.PORT).toBeUndefined(); + }); + + it('accepts a valid CORS_ORIGINS list', () => { + const env = { + ...makeFullEnv(), + CORS_ORIGINS: 'https://a.example.com,https://b.example.com', + }; + expect(() => validateEnv(env)).not.toThrow(); + }); + + it('fails on an invalid CORS_ORIGINS entry', () => { + const env = { ...makeFullEnv(), CORS_ORIGINS: 'not-a-url' }; + expect(() => validateEnv(env)).toThrow(/CORS_ORIGINS/); + }); + + it('does not read the trailing-space MAX_SEND_ATTEMPTS key', () => { + // The canonical key (no trailing space) is what the schema reads. + const env: any = makeFullEnv(); + env['MAX_SEND_ATTEMPTS'] = 5; + env['MAX_SEND_ATTEMPTS '] = 99; + const result = validateEnv(env); + expect(result.MAX_SEND_ATTEMPTS).toBe(5); + }); +}); diff --git a/src/common/config/env.validation.ts b/src/common/config/env.validation.ts new file mode 100644 index 0000000..1edf30f --- /dev/null +++ b/src/common/config/env.validation.ts @@ -0,0 +1,159 @@ +import { plainToInstance } from 'class-transformer'; +import { + IsInt, + IsOptional, + IsString, + MinLength, + Validate, + validateSync, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +@ValidatorConstraint({ name: 'commaSeparatedOrigins', async: false }) +class CommaSeparatedOriginsConstraint implements ValidatorConstraintInterface { + validate(value: string): boolean { + return value + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + .every((origin) => /^https?:\/\/.+/.test(origin)); + } + defaultMessage(): string { + return 'CORS_ORIGINS must be a comma-separated list of valid http(s) origins'; + } +} + +export class EnvSchema { + // --- Required infrastructure --- + @IsString() DATABASE_URL!: string; + @IsString() REDIS_URL!: string; + @IsString() OTP_REDIS_URL!: string; + @IsString() SESSION_REDIS_URL!: string; + + @IsString() @MinLength(16) SESSION_SECRET!: string; + @IsString() COOKIE_SECRET!: string; + + @IsString() @MinLength(16) JWT_ACCESS_SECRET_KEY!: string; + @IsString() @MinLength(16) JWT_TEMP_SECRET_KEY!: string; + @IsString() @MinLength(16) JWT_PROGRESS_SECRET_KEY!: string; + + @IsString() AWS_ACCESS_KEY!: string; + @IsString() AWS_SECRET_KEY!: string; + @IsString() AWS_BUCKET_NAME!: string; + @IsString() AWS_ENDPOINT!: string; + + @IsString() MAP_API_KEY!: string; + @IsString() MAP_API_URL!: string; + @IsString() SMS_API_KEY!: string; + + // --- Optional with validated types/range --- + @IsOptional() @IsInt() PORT?: number; + + @IsOptional() @IsInt() COOKIE_MAX_AGE?: number; + @IsOptional() @IsInt() OTP_EXPIRATION_TIME?: number; + @IsOptional() @IsInt() MAX_SEND_ATTEMPTS?: number; + @IsOptional() @IsInt() MAX_CHECK_ATTEMPTS?: number; + @IsOptional() @IsInt() SEND_WINDOW?: number; + @IsOptional() @IsInt() BASE_BLOCK_TIME?: number; + @IsOptional() @IsInt() CORRIDOR_WIDTH?: number; + + @IsOptional() @IsString() AWS_REGION?: string; + + @IsOptional() @IsInt() THROTTLE_TTL?: number; + @IsOptional() @IsInt() THROTTLE_LIMIT?: number; + + @IsOptional() @IsString() API_PREFIX?: string; + @IsOptional() @IsString() LOG_LEVEL?: string; + + @IsOptional() @IsString() COOKIE_SECURE?: string; + @IsOptional() @IsString() COOKIE_SAMESITE?: string; + @IsOptional() @IsString() COOKIE_DOMAIN?: string; + + @IsOptional() @IsInt() MEMORY_HEAP_THRESHOLD_MB?: number; + @IsOptional() @IsInt() TIMEOUT_MS?: number; + @IsOptional() @IsString() RESPONSE_WRAPPER_ENABLED?: string; + + @IsOptional() @IsString() NODE_ENV?: string; + + // --- Pricing (all optional) --- + @IsOptional() @IsInt() PRICING_BASE_PRICE?: number; + @IsOptional() @IsInt() PRICING_FUEL_RATE?: number; + @IsOptional() @IsInt() PRICING_WEIGHT_BASE_RATE?: number; + @IsOptional() @IsInt() PRICING_PLATFORM_COMMISSION?: number; + @IsOptional() @IsInt() PRICING_DRIVER_SHARE?: number; + + @IsOptional() @IsInt() PRICING_FRAGILE_MULTIPLIER?: number; + @IsOptional() @IsInt() PRICING_PERISHABLE_MULTIPLIER?: number; + @IsOptional() @IsInt() PRICING_BOTH_FRAGILE_PERISHABLE?: number; + + @IsOptional() @IsInt() PRICING_MAJOR_CITY_ORIGIN?: number; + @IsOptional() @IsInt() PRICING_MAJOR_CITY_DESTINATION?: number; + @IsOptional() @IsInt() PRICING_BOTH_MAJOR_CITIES?: number; + @IsOptional() @IsInt() PRICING_SMALL_CITY_FACTOR?: number; + + @IsOptional() @IsInt() PRICING_DEVIATION_RATE?: number; + @IsOptional() @IsInt() PRICING_TIME_DEVIATION_RATE?: number; + + @IsOptional() @IsInt() PRICING_TIER_1_RATE?: number; + @IsOptional() @IsInt() PRICING_TIER_2_RATE?: number; + @IsOptional() @IsInt() PRICING_TIER_3_RATE?: number; + @IsOptional() @IsInt() PRICING_TIER_4_RATE?: number; + @IsOptional() @IsInt() PRICING_TIER_5_RATE?: number; + + @IsOptional() @IsString() PRICING_MAJOR_CITIES?: string; + + // --- CORS --- + @IsOptional() + @IsString() + @Validate(CommaSeparatedOriginsConstraint) + CORS_ORIGINS?: string; +} + +// Note: a previous config contained a trailing space on `MAX_SEND_ATTEMPTS `. +// The canonical key is enforced here so the app fails fast on the bad key. +export const REQUIRED_KEYS: string[] = [ + 'DATABASE_URL', + 'REDIS_URL', + 'OTP_REDIS_URL', + 'SESSION_REDIS_URL', + 'SESSION_SECRET', + 'COOKIE_SECRET', + 'JWT_ACCESS_SECRET_KEY', + 'JWT_TEMP_SECRET_KEY', + 'JWT_PROGRESS_SECRET_KEY', + 'AWS_ACCESS_KEY', + 'AWS_SECRET_KEY', + 'AWS_BUCKET_NAME', + 'AWS_ENDPOINT', + 'MAP_API_KEY', + 'MAP_API_URL', + 'SMS_API_KEY', +]; + +export function validateEnv(config: Record) { + const validated = plainToInstance(EnvSchema, config, { + enableImplicitConversion: true, + }); + const errors = validateSync(validated, { + skipMissingProperties: false, + forbidUnknownValues: false, + }); + + if (errors.length > 0) { + const messages = errors.flatMap((e) => + Object.values(e.constraints ?? {}).map( + (msg) => `${e.property}: ${msg}`, + ), + ); + throw new Error( + `Invalid environment configuration:\n${messages.join('\n')}`, + ); + } + + return validated; +} + + + + From 1607b37b90d2eee53873461d004cfa29711d4f90 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 13:21:32 +0330 Subject: [PATCH 03/12] refactor: extract getCookieOptions helper --- src/common/cookies/cookie-options.spec.ts | 95 +++++++++++++++++++++++ src/common/cookies/cookie-options.ts | 39 ++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/common/cookies/cookie-options.spec.ts create mode 100644 src/common/cookies/cookie-options.ts diff --git a/src/common/cookies/cookie-options.spec.ts b/src/common/cookies/cookie-options.spec.ts new file mode 100644 index 0000000..5f37cb7 --- /dev/null +++ b/src/common/cookies/cookie-options.spec.ts @@ -0,0 +1,95 @@ +import { getCookieOptions } from './cookie-options'; +import { ConfigService } from '@nestjs/config'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; + +describe('getCookieOptions', () => { + let config: DeepMockProxy; + + beforeEach(() => { + config = mockDeep(); + }); + + const baseConfig = () => { + config.get.mockImplementation((key: string, defaultValue?: any) => { + const map: Record = { + NODE_ENV: 'production', + COOKIE_MAX_AGE: 15 * 24 * 3600 * 1000, + COOKIE_SAMESITE: 'strict', + }; + return map[key] ?? defaultValue; + }); + }; + + it('sets httpOnly and secure in production by default', () => { + baseConfig(); + const options = getCookieOptions(config); + expect(options.httpOnly).toBe(true); + expect(options.secure).toBe(true); + expect(options.sameSite).toBe('strict'); + expect(options.path).toBe('/'); + }); + + it('sets secure false in development by default', () => { + config.get.mockImplementation((key: string, defaultValue?: any) => { + const map: Record = { + NODE_ENV: 'development', + COOKIE_MAX_AGE: 15 * 24 * 3600 * 1000, + COOKIE_SAMESITE: 'strict', + }; + return map[key] ?? defaultValue; + }); + const options = getCookieOptions(config); + expect(options.secure).toBe(false); + }); + + it('honors explicit COOKIE_SECURE=true even in development', () => { + config.get.mockImplementation((key: string, defaultValue?: any) => { + const map: Record = { + NODE_ENV: 'development', + COOKIE_SECURE: 'true', + COOKIE_MAX_AGE: 15 * 24 * 3600 * 1000, + COOKIE_SAMESITE: 'strict', + }; + return map[key] ?? defaultValue; + }); + const options = getCookieOptions(config); + expect(options.secure).toBe(true); + }); + + it('uses default maxAge of 15 days when unset', () => { + config.get.mockImplementation((key: string, defaultValue?: any) => { + if (key === 'NODE_ENV') return 'production'; + if (key === 'COOKIE_SAMESITE') return 'strict'; + return defaultValue; + }); + const options = getCookieOptions(config); + expect(options.maxAge).toBe(15 * 24 * 3600 * 1000); + }); + + it('falls back to strict for invalid sameSite', () => { + config.get.mockImplementation((key: string, defaultValue?: any) => { + const map: Record = { + NODE_ENV: 'production', + COOKIE_MAX_AGE: 15 * 24 * 3600 * 1000, + COOKIE_SAMESITE: 'bogus', + }; + return map[key] ?? defaultValue; + }); + const options = getCookieOptions(config); + expect(options.sameSite).toBe('strict'); + }); + + it('includes domain when set', () => { + config.get.mockImplementation((key: string, defaultValue?: any) => { + const map: Record = { + NODE_ENV: 'production', + COOKIE_MAX_AGE: 15 * 24 * 3600 * 1000, + COOKIE_SAMESITE: 'strict', + COOKIE_DOMAIN: 'example.com', + }; + return map[key] ?? defaultValue; + }); + const options = getCookieOptions(config); + expect(options.domain).toBe('example.com'); + }); +}); diff --git a/src/common/cookies/cookie-options.ts b/src/common/cookies/cookie-options.ts new file mode 100644 index 0000000..94d71f3 --- /dev/null +++ b/src/common/cookies/cookie-options.ts @@ -0,0 +1,39 @@ +import { ConfigService } from '@nestjs/config'; + +export interface CookieOptions { + httpOnly: boolean; + secure: boolean; + sameSite: 'strict' | 'lax' | 'none'; + maxAge: number; + domain?: string; + path: string; +} + +const DAY_MS = 24 * 3600 * 1000; + +/** + * Single source of truth for auth cookie options. Used by the auth controller + * and anywhere else that sets auth cookies. Fixes the duplicated cookie + * objects and hard-coded secure:false scattered across the controller. + */ +export function getCookieOptions(config: ConfigService): CookieOptions { + const isProduction = config.get('NODE_ENV') === 'production'; + const secureRaw = config.get('COOKIE_SECURE'); + const sameSiteRaw = config.get('COOKIE_SAMESITE') ?? 'strict'; + const domain = config.get('COOKIE_DOMAIN'); + + const maxAge = config.get('COOKIE_MAX_AGE', 15 * DAY_MS); // 15 days + + const sameSite = (['strict', 'lax', 'none'].includes(sameSiteRaw) + ? sameSiteRaw + : 'strict') as 'strict' | 'lax' | 'none'; + + return { + httpOnly: true, + secure: secureRaw !== undefined ? secureRaw === 'true' : isProduction, + sameSite, + maxAge, + ...(domain ? { domain } : {}), + path: '/', + }; +} From 3b1c76a33bb9b9d6c85994f57470dc2b629e08a2 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 13:21:53 +0330 Subject: [PATCH 04/12] refactor: add pure bootstrap-options helpers (CORS, swagger) --- src/common/http/bootstrap-options.spec.ts | 94 +++++++++++++++++++++++ src/common/http/bootstrap-options.ts | 51 ++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 src/common/http/bootstrap-options.spec.ts create mode 100644 src/common/http/bootstrap-options.ts diff --git a/src/common/http/bootstrap-options.spec.ts b/src/common/http/bootstrap-options.spec.ts new file mode 100644 index 0000000..fb8db06 --- /dev/null +++ b/src/common/http/bootstrap-options.spec.ts @@ -0,0 +1,94 @@ +import { + parseCorsOrigins, + buildSwaggerConfig, + isSwaggerEnabled, +} from './bootstrap-options'; +import { ConfigService } from '@nestjs/config'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; + +describe('bootstrap-options', () => { + let config: DeepMockProxy; + + beforeEach(() => { + config = mockDeep(); + }); + + describe('parseCorsOrigins', () => { + it('returns origin:false when CORS_ORIGINS is unset', () => { + config.get.mockReturnValue(undefined); + const result = parseCorsOrigins(config); + expect(result.origin).toBe(false); + expect(result.credentials).toBe(true); + }); + + it('allows listed origins and blocks others', () => { + config.get.mockReturnValue('https://a.example.com,https://b.example.com'); + const result = parseCorsOrigins(config); + const origin = result.origin as ( + o: string | undefined, + cb: (err: Error | null, allow: boolean) => void, + ) => void; + + const allowedCb = jest.fn(); + origin('https://a.example.com', allowedCb); + expect(allowedCb).toHaveBeenCalledWith(null, true); + + const blockedCb = jest.fn(); + origin('https://evil.example.com', blockedCb); + expect(blockedCb).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('not allowed by CORS'), + }), + ); + }); + + it('allows requests with no origin header', () => { + config.get.mockReturnValue('https://a.example.com'); + const result = parseCorsOrigins(config); + const origin = result.origin as ( + o: string | undefined, + cb: (err: Error | null, allow: boolean) => void, + ) => void; + const cb = jest.fn(); + origin(undefined, cb); + expect(cb).toHaveBeenCalledWith(null, true); + }); + + it('trims whitespace around origins', () => { + config.get.mockReturnValue(' https://a.example.com , https://b.example.com '); + const result = parseCorsOrigins(config); + const origin = result.origin as ( + o: string | undefined, + cb: (err: Error | null, allow: boolean) => void, + ) => void; + const cb = jest.fn(); + origin('https://b.example.com', cb); + expect(cb).toHaveBeenCalledWith(null, true); + }); + }); + + describe('buildSwaggerConfig', () => { + it('returns a DocumentBuilder config with title', () => { + const result = buildSwaggerConfig(); + expect(result).toBeDefined(); + expect(result.info.title).toBe('HamBaar App'); + }); + }); + + describe('isSwaggerEnabled', () => { + it('returns true when NODE_ENV is not production', () => { + config.get.mockReturnValue('development'); + expect(isSwaggerEnabled(config)).toBe(true); + }); + + it('returns false when NODE_ENV is production', () => { + config.get.mockReturnValue('production'); + expect(isSwaggerEnabled(config)).toBe(false); + }); + + it('returns true when NODE_ENV is unset', () => { + config.get.mockReturnValue(undefined); + expect(isSwaggerEnabled(config)).toBe(true); + }); + }); +}); diff --git a/src/common/http/bootstrap-options.ts b/src/common/http/bootstrap-options.ts new file mode 100644 index 0000000..ef02f69 --- /dev/null +++ b/src/common/http/bootstrap-options.ts @@ -0,0 +1,51 @@ +import { ConfigService } from '@nestjs/config'; +import { DocumentBuilder } from '@nestjs/swagger'; +import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; + +/** + * Parse the CORS_ORIGINS env (comma-separated) into a CorsOptions origin + * callback. Returns undefined when unset — caller should then deny/omit CORS. + * Never returns `true` (no reflect-request-origin wildcard). + */ +export function parseCorsOrigins(config: ConfigService): CorsOptions { + const raw = config.get('CORS_ORIGINS'); + + if (!raw) { + return { origin: false, credentials: true }; + } + + const origins = raw + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + + return { + origin: (origin, callback) => { + if (!origin || origins.includes(origin)) { + callback(null, true); + } else { + callback(new Error(`Origin ${origin} not allowed by CORS`)); + } + }, + credentials: true, + }; +} + +/** + * Build the Swagger document config. Pure — no app reference needed for tests. + */ +export function buildSwaggerConfig() { + return new DocumentBuilder() + .setTitle('HamBaar App') + .setDescription('HamBaar App API using NestJS and Prisma') + .setVersion('1.0') + .build(); +} + +/** + * Swagger is enabled only outside production. + */ +export function isSwaggerEnabled(config: ConfigService): boolean { + const nodeEnv = config.get('NODE_ENV'); + return nodeEnv !== 'production'; +} From 40cb3bd0765bb08bb8e1afbad3cc88bee1bce86c Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 13:22:07 +0330 Subject: [PATCH 05/12] refactor: add PrismaErrorMapper pure error mapping --- src/common/errors/prisma-error-mapper.spec.ts | 158 ++++++++++++++++++ src/common/errors/prisma-error-mapper.ts | 107 ++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 src/common/errors/prisma-error-mapper.spec.ts create mode 100644 src/common/errors/prisma-error-mapper.ts diff --git a/src/common/errors/prisma-error-mapper.spec.ts b/src/common/errors/prisma-error-mapper.spec.ts new file mode 100644 index 0000000..74a9a0e --- /dev/null +++ b/src/common/errors/prisma-error-mapper.spec.ts @@ -0,0 +1,158 @@ +import { mapPrismaError } from './prisma-error-mapper'; +import { Prisma } from '../../../generated/prisma'; +import { + BadRequestException, + ConflictException, + HttpException, + NotFoundException, +} from '@nestjs/common'; + +const makeKnownError = (code: string, meta: Record = {}) => { + const error = new Prisma.PrismaClientKnownRequestError('prisma error', { + clientVersion: '6.15.0', + code, + meta, + } as any); + return error; +}; + +describe('mapPrismaError', () => { + it('passes through HttpException unchanged', () => { + const httpError = new ConflictException('already exists'); + const result = mapPrismaError(httpError); + expect(result.status).toBe(409); + expect(result.message).toBe('already exists'); + }); + + it('maps P2000 to 400 (value too long)', () => { + const result = mapPrismaError( + makeKnownError('P2000', { target: ['email'], field_name: 'email', modelName: 'User' }), + ); + expect(result.status).toBe(400); + expect(result.message).toContain('Value too long'); + expect(result.message).toContain('email'); + }); + + it('maps P2000 with array target joined', () => { + const result = mapPrismaError( + makeKnownError('P2000', { target: ['a', 'b'], field_name: 'a', modelName: 'User' }), + ); + expect(result.message).toContain('a, b'); + }); + + it('maps P2002 to 409 (unique constraint)', () => { + const result = mapPrismaError( + makeKnownError('P2002', { target: ['phoneNumber'], field_name: 'phoneNumber', modelName: 'User' }), + ); + expect(result.status).toBe(409); + expect(result.message).toContain('with the phoneNumber already exists'); + }); + + it('maps P2003 to 400 (foreign key)', () => { + const result = mapPrismaError( + makeKnownError('P2003', { + constraint: 'Package_senderId_fkey', + target: 'senderId', + field_name: 'senderId', + modelName: 'Package', + }), + ); + expect(result.status).toBe(400); + expect(result.message).toContain("The specified 'senderId' does not exist"); + }); + + it('maps P2003 without _fkey constraint', () => { + const result = mapPrismaError( + makeKnownError('P2003', { target: 'field', field_name: 'field', modelName: 'Model' }), + ); + expect(result.status).toBe(400); + expect(result.message).toContain('Foreign key constraint violation'); + }); + + it('maps P2004 to 400 (constraint violation)', () => { + const result = mapPrismaError( + makeKnownError('P2004', { + target: 'field', + field_name: 'field', + modelName: 'Model', + cause: 'failed', + }), + ); + expect(result.status).toBe(400); + expect(result.message).toContain('Constraint violation'); + }); + + it('maps P2025 to 404 (not found)', () => { + const result = mapPrismaError( + makeKnownError('P2025', { + target: 'id', + field_name: 'id', + modelName: 'User', + cause: 'No record', + }), + ); + expect(result.status).toBe(404); + expect(result.message).toContain('No User found'); + expect(result.message).toContain('with id'); + }); + + it('maps P2025 without target to 404', () => { + const result = mapPrismaError( + makeKnownError('P2025', { modelName: 'User', cause: 'No record' }), + ); + expect(result.status).toBe(404); + expect(result.message).toBe('No User found. No record'); + }); + + it('maps P2016 to 400 (query interpretation)', () => { + const result = mapPrismaError( + makeKnownError('P2016', { + target: 'field', + field_name: 'field', + modelName: 'Model', + cause: 'bad query', + }), + ); + expect(result.status).toBe(400); + expect(result.message).toContain('Query interpretation error'); + }); + + it('maps unknown Prisma code to 500', () => { + const result = mapPrismaError( + makeKnownError('P9999', { modelName: 'Model', cause: 'oops' }), + ); + expect(result.status).toBe(500); + expect(result.message).toContain('Database error (P9999)'); + }); + + it('maps PrismaClientValidationError to 400', () => { + const error = new Prisma.PrismaClientValidationError( + 'Unknown argument\nInvalid input data', + { clientVersion: '6.15.0' }, + ); + const result = mapPrismaError(error); + expect(result.status).toBe(400); + expect(result.message).toContain('Invalid input data'); + }); + + it('maps PrismaClientUnknownRequestError to 500', () => { + const error = new Prisma.PrismaClientUnknownRequestError('boom', { + clientVersion: '6.15.0', + }); + const result = mapPrismaError(error); + expect(result.status).toBe(500); + expect(result.message).toContain('Unknown database error'); + }); + + it('maps a generic Error to 500', () => { + const result = mapPrismaError(new Error('something broke')); + expect(result.status).toBe(500); + expect(result.message).toContain('something broke'); + }); + + it('maps a non-error to 500', () => { + const result = mapPrismaError('weird'); + expect(result.status).toBe(500); + expect(result.message).toContain('Unknown error'); + }); +}); diff --git a/src/common/errors/prisma-error-mapper.ts b/src/common/errors/prisma-error-mapper.ts new file mode 100644 index 0000000..ca66d9b --- /dev/null +++ b/src/common/errors/prisma-error-mapper.ts @@ -0,0 +1,107 @@ +import { Prisma } from '../../../generated/prisma'; +import { + BadRequestException, + ConflictException, + HttpException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; + +export interface PrismaErrorResult { + status: number; + message: string; +} + +/** + * Pure mapping of Prisma errors to HTTP status/messages. Behavior-preserving + * with the legacy `formatPrismaError` in utilities.ts — only the control flow + * (throw vs. return) changes. + */ +export function mapPrismaError(error: unknown): PrismaErrorResult { + if (error instanceof HttpException) { + return { + status: error.getStatus(), + message: error.message, + }; + } + + if (error instanceof Prisma.PrismaClientKnownRequestError) { + const target = Array.isArray(error.meta?.target) + ? error.meta.target.join(', ') + : (error.meta?.field_name as string) || 'unknown field'; + const model = (error.meta?.modelName as string) || 'unknown model'; + const cause = (error.meta?.cause as string) || 'Operation failed'; + + switch (error.code) { + case 'P2000': + return { + status: 400, + message: `Value too long for field ${target} in ${model}.`, + }; + case 'P2002': + return { + status: 409, + message: `A ${model} with the ${target} already exists. Please use a different value.`, + }; + case 'P2003': { + const constraintName = error.meta?.constraint as string; + let relationshipMessage = ''; + + if (constraintName?.includes('_fkey')) { + relationshipMessage = + target !== 'unknown field' + ? `The specified '${target}' does not exist or is invalid.` + : `Referenced record does not exist.`; + } + + return { + status: 400, + message: `Foreign key constraint violation in ${model}. ${relationshipMessage}`, + }; + } + case 'P2004': + return { + status: 400, + message: `Constraint violation on ${target} in ${model}: ${cause}.`, + }; + case 'P2025': + return { + status: 404, + message: `No ${model} found${target !== 'unknown field' ? ` with ${target}` : ''}. ${cause}`, + }; + case 'P2016': + return { + status: 400, + message: `Query interpretation error in ${model} for ${target}. ${cause}`, + }; + default: + return { + status: 500, + message: `Database error (${error.code}): ${cause || error.message}`, + }; + } + } + + if (error instanceof Prisma.PrismaClientValidationError) { + const message = + error.message.split('\n').pop()?.trim() || 'Invalid input data provided.'; + return { + status: 400, + message: `Validation failed: ${message}`, + }; + } + + if (error instanceof Prisma.PrismaClientUnknownRequestError) { + return { + status: 500, + message: `Unknown database error: ${error.message}`, + }; + } + + const message = + error instanceof Error ? error.message : 'Unknown error'; + return { + status: 500, + message: `An unexpected error occurred: ${message || 'Unknown error'}`, + }; +} From 19549c6a570e3c783024ce8623d0de4b2b9f7eaa Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 13:22:20 +0330 Subject: [PATCH 06/12] refactor: add AllExceptionsFilter global error handler --- .../filters/all-exceptions.filter.spec.ts | 119 ++++++++++++++++++ src/common/filters/all-exceptions.filter.ts | 68 ++++++++++ 2 files changed, 187 insertions(+) create mode 100644 src/common/filters/all-exceptions.filter.spec.ts create mode 100644 src/common/filters/all-exceptions.filter.ts diff --git a/src/common/filters/all-exceptions.filter.spec.ts b/src/common/filters/all-exceptions.filter.spec.ts new file mode 100644 index 0000000..2ca4e31 --- /dev/null +++ b/src/common/filters/all-exceptions.filter.spec.ts @@ -0,0 +1,119 @@ +import { AllExceptionsFilter } from './all-exceptions.filter'; +import { + BadRequestException, + ConflictException, + HttpException, + HttpStatus, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '../../../generated/prisma'; + +const buildHost = (request: any = {}) => { + const response = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + return { + switchToHttp: () => ({ + getResponse: () => response, + getRequest: () => request, + }), + getArgs: () => [], + getArgByIndex: () => undefined, + switchToRpc: () => ({ getData: () => ({}) }), + switchToWs: () => ({ getData: () => ({}) }), + getType: () => 'http' as any, + } as any; +}; + +describe('AllExceptionsFilter', () => { + let filter: AllExceptionsFilter; + + beforeEach(() => { + filter = new AllExceptionsFilter(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns the HttpException envelope unchanged', () => { + const host = buildHost(); + const res = host.switchToHttp().getResponse(); + const json = res.json as jest.Mock; + + filter.catch(new ConflictException('already exists'), host); + + expect(res.status).toHaveBeenCalledWith(409); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 409, + message: 'already exists', + }), + ); + }); + + it('flattens array validation messages', () => { + const host = buildHost(); + const res = host.switchToHttp().getResponse(); + const json = res.json as jest.Mock; + + filter.catch( + new BadRequestException({ message: ['field is required', 'field2 bad'], error: 'Bad Request' }), + host, + ); + + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'field is required, field2 bad', + }), + ); + }); + + it('maps a Prisma P2025 error to 404', () => { + const error = new Prisma.PrismaClientKnownRequestError('not found', { + clientVersion: '6.15.0', + code: 'P2025', + } as any); + (error as any).meta = { modelName: 'User', field_name: 'id', cause: 'No record' }; + const host = buildHost(); + const res = host.switchToHttp().getResponse(); + const json = res.json as jest.Mock; + + filter.catch(error, host); + + expect(res.status).toHaveBeenCalledWith(404); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 404 }), + ); + }); + + it('returns 500 for an unknown error', () => { + const host = buildHost(); + const res = host.switchToHttp().getResponse(); + const json = res.json as jest.Mock; + + filter.catch(new Error('boom'), host); + + expect(res.status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 500, + error: 'Internal Server Error', + }), + ); + }); + + it('includes requestId when present on the request', () => { + const host = buildHost({ id: 'req-123' }); + const res = host.switchToHttp().getResponse(); + const json = res.json as jest.Mock; + + filter.catch(new BadRequestException('bad'), host); + + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ requestId: 'req-123' }), + ); + }); +}); diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..701d366 --- /dev/null +++ b/src/common/filters/all-exceptions.filter.ts @@ -0,0 +1,68 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { mapPrismaError } from '../errors/prisma-error-mapper'; + +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger('ExceptionsFilter'); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const requestId = (request as any).id as string | undefined; + const status = + exception instanceof HttpException + ? exception.getStatus() + : mapPrismaError(exception).status; + + let message: string; + let error: string; + + if (exception instanceof HttpException) { + const res = exception.getResponse(); + if (typeof res === 'string') { + message = res; + error = exception.name; + } else { + const body = res as Record; + message = Array.isArray(body.message) + ? body.message.join(', ') + : (body.message ?? exception.message); + error = body.error ?? exception.name; + } + } else { + const mapped = mapPrismaError(exception); + message = mapped.message; + error = 'Internal Server Error'; + } + + if (status >= 500) { + this.logger.error( + `${request.method} ${request.url} ${status} — ${message}`, + exception instanceof Error ? exception.stack : undefined, + { requestId }, + ); + } + + const body: Record = { + statusCode: status, + message, + error, + }; + + if (requestId) { + body.requestId = requestId; + } + + response.status(status).json(body); + } +} From 557460ee614eee81857ba4ffa83723f245bd6c95 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 13:22:35 +0330 Subject: [PATCH 07/12] refactor: add Logging, Timeout, Transform interceptors --- .../interceptors/logging.interceptor.ts | 36 ++++++++++ .../interceptors/timeout.interceptor.ts | 31 +++++++++ .../transform.interceptor.spec.ts | 67 +++++++++++++++++++ .../interceptors/transform.interceptor.ts | 33 +++++++++ 4 files changed, 167 insertions(+) create mode 100644 src/common/interceptors/logging.interceptor.ts create mode 100644 src/common/interceptors/timeout.interceptor.ts create mode 100644 src/common/interceptors/transform.interceptor.spec.ts create mode 100644 src/common/interceptors/transform.interceptor.ts diff --git a/src/common/interceptors/logging.interceptor.ts b/src/common/interceptors/logging.interceptor.ts new file mode 100644 index 0000000..f689e65 --- /dev/null +++ b/src/common/interceptors/logging.interceptor.ts @@ -0,0 +1,36 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Request } from 'express'; + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger('HTTP'); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() !== 'http') { + return next.handle(); + } + + const request = context.switchToHttp().getRequest(); + const { method, url } = request; + const requestId = (request as any).id as string | undefined; + const now = Date.now(); + + return next.handle().pipe( + tap(() => { + const response = context.switchToHttp().getResponse(); + const delay = Date.now() - now; + this.logger.log( + `${method} ${url} ${response.statusCode} ${delay}ms${requestId ? ` [${requestId}]` : ''}`, + ); + }), + ); + } +} diff --git a/src/common/interceptors/timeout.interceptor.ts b/src/common/interceptors/timeout.interceptor.ts new file mode 100644 index 0000000..e923889 --- /dev/null +++ b/src/common/interceptors/timeout.interceptor.ts @@ -0,0 +1,31 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + RequestTimeoutException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Observable, throwError, TimeoutError } from 'rxjs'; +import { catchError, timeout } from 'rxjs/operators'; + +@Injectable() +export class TimeoutInterceptor implements NestInterceptor { + private readonly timeoutMs: number; + + constructor(config: ConfigService) { + this.timeoutMs = config.get('TIMEOUT_MS', 30_000); + } + + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + timeout(this.timeoutMs), + catchError((err) => { + if (err instanceof TimeoutError) { + return throwError(() => new RequestTimeoutException()); + } + return throwError(() => err); + }), + ); + } +} diff --git a/src/common/interceptors/transform.interceptor.spec.ts b/src/common/interceptors/transform.interceptor.spec.ts new file mode 100644 index 0000000..412b6ec --- /dev/null +++ b/src/common/interceptors/transform.interceptor.spec.ts @@ -0,0 +1,67 @@ +import { of } from 'rxjs'; +import { TransformInterceptor } from './transform.interceptor'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { ConfigService } from '@nestjs/config'; +import { ExecutionContext } from '@nestjs/common'; + +const buildContext = () => ({ + getType: () => 'http', + switchToHttp: () => ({ + getRequest: () => ({}), + getResponse: () => ({}), + }), + getHandler: () => ({}), + getClass: () => ({}), +}) as unknown as ExecutionContext; + +describe('TransformInterceptor', () => { + let config: DeepMockProxy; + + beforeEach(() => { + config = mockDeep(); + }); + + it('passes through data unchanged when wrapper is disabled', (done) => { + config.get.mockReturnValue('undefined'); + const interceptor = new TransformInterceptor(config); + const context = buildContext(); + + interceptor.intercept(context, { handle: () => of({ foo: 'bar' }) } as any).subscribe((result) => { + expect(result).toEqual({ foo: 'bar' }); + done(); + }); + }); + + it('wraps data in { data } when enabled', (done) => { + config.get.mockReturnValue('true'); + const interceptor = new TransformInterceptor(config); + const context = buildContext(); + + interceptor.intercept(context, { handle: () => of({ foo: 'bar' }) } as any).subscribe((result) => { + expect(result).toEqual({ data: { foo: 'bar' } }); + done(); + }); + }); + + it('wraps arrays', (done) => { + config.get.mockReturnValue('true'); + const interceptor = new TransformInterceptor(config); + const context = buildContext(); + + interceptor.intercept(context, { handle: () => of([1, 2, 3]) } as any).subscribe((result) => { + expect(result).toEqual({ data: [1, 2, 3] }); + done(); + }); + }); + + it('does not wrap null/undefined', (done) => { + config.get.mockReturnValue('true'); + const interceptor = new TransformInterceptor(config); + const context = buildContext(); + + interceptor.intercept(context, { handle: () => of(null) } as any).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + }); +}); diff --git a/src/common/interceptors/transform.interceptor.ts b/src/common/interceptors/transform.interceptor.ts new file mode 100644 index 0000000..0b2f9c0 --- /dev/null +++ b/src/common/interceptors/transform.interceptor.ts @@ -0,0 +1,33 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class TransformInterceptor implements NestInterceptor { + private readonly enabled: boolean; + + constructor(config: ConfigService) { + this.enabled = config.get('RESPONSE_WRAPPER_ENABLED') === 'true'; + } + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (!this.enabled) { + return next.handle(); + } + + return next.handle().pipe( + map((data) => { + if (data === null || data === undefined) { + return data; + } + return { data }; + }), + ); + } +} From e1d85cc46bd267dc7f9ac4367aedd6d9a04a67fa Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 13:22:54 +0330 Subject: [PATCH 08/12] refactor: add RequestIdMiddleware --- src/common/middleware/request-id.middleware.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/common/middleware/request-id.middleware.ts diff --git a/src/common/middleware/request-id.middleware.ts b/src/common/middleware/request-id.middleware.ts new file mode 100644 index 0000000..ddcae08 --- /dev/null +++ b/src/common/middleware/request-id.middleware.ts @@ -0,0 +1,14 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; +import * as crypto from 'crypto'; + +@Injectable() +export class RequestIdMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction) { + const incoming = req.headers['x-request-id'] as string | undefined; + const requestId = incoming ?? crypto.randomUUID(); + (req as any).id = requestId; + res.setHeader('X-Request-Id', requestId); + next(); + } +} From 5b6a28457a8a4addbcf9c467ea23af3d5f73931b Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 13:23:10 +0330 Subject: [PATCH 09/12] refactor: add SessionModule with Redis provider and middleware lifecycle --- src/infra/session/session.constants.ts | 1 + src/infra/session/session.module.ts | 42 +++++++++++++++++ src/infra/session/session.provider.ts | 65 ++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 src/infra/session/session.constants.ts create mode 100644 src/infra/session/session.module.ts create mode 100644 src/infra/session/session.provider.ts diff --git a/src/infra/session/session.constants.ts b/src/infra/session/session.constants.ts new file mode 100644 index 0000000..e709825 --- /dev/null +++ b/src/infra/session/session.constants.ts @@ -0,0 +1 @@ +export const SESSION_STORE_PROVIDER = Symbol('SESSION_STORE_PROVIDER'); diff --git a/src/infra/session/session.module.ts b/src/infra/session/session.module.ts new file mode 100644 index 0000000..d48a98d --- /dev/null +++ b/src/infra/session/session.module.ts @@ -0,0 +1,42 @@ +import { + Global, + MiddlewareConsumer, + Module, + NestModule, + Inject, + Logger, +} from '@nestjs/common'; +import * as cookieParser from 'cookie-parser'; +import { sessionBundleProvider } from './session.provider'; +import { SESSION_STORE_PROVIDER } from './session.constants'; +import { SessionBundle } from './session.provider'; + +/** + * Global session module. Wires cookie-parser BEFORE the session middleware + * (fixes B-7 parser order). The configure() is synchronous — all async work + * is done in the session provider factory. + */ +@Global() +@Module({ + providers: [sessionBundleProvider], +}) +export class SessionModule implements NestModule { + private readonly logger = new Logger(SessionModule.name); + + constructor( + @Inject(SESSION_STORE_PROVIDER) + private readonly bundle: SessionBundle, + ) {} + + configure(consumer: MiddlewareConsumer) { + consumer.apply(cookieParser()).forRoutes('*'); + + consumer.apply(this.bundle.middleware).forRoutes('*'); + } + + async onApplicationShutdown() { + this.logger.log('Shutting down session module...'); + await this.bundle.client.quit(); + } +} + diff --git a/src/infra/session/session.provider.ts b/src/infra/session/session.provider.ts new file mode 100644 index 0000000..7753ab4 --- /dev/null +++ b/src/infra/session/session.provider.ts @@ -0,0 +1,65 @@ +import { Provider, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createClient, RedisClientType } from 'redis'; +import * as session from 'express-session'; +import { RedisStore } from 'connect-redis'; +import { CookieNames } from '../../common/enums/cookies.enum'; +import { SESSION_STORE_PROVIDER } from './session.constants'; + +export interface SessionBundle { + client: RedisClientType; + store: RedisStore; + middleware: ReturnType; +} + +/** + * Creates the session middleware bundle: a connected Redis client, a + * connect-redis store, and an express-session middleware wired to the session + * secret and cookie settings from config. Registers onApplicationShutdown to + * quit the Redis client cleanly. + */ +export const sessionBundleProvider: Provider = { + provide: SESSION_STORE_PROVIDER, + inject: [ConfigService], + useFactory: async (config: ConfigService): Promise => { + const logger = new Logger('SessionProvider'); + + const redisClient = createClient({ + url: config.getOrThrow('SESSION_REDIS_URL'), + }); + await redisClient.connect(); + + const store = new RedisStore({ + client: redisClient as any, + prefix: 'user-session', + }); + + const DAY_MS = 24 * 3600 * 1000; + const maxAge = config.get('COOKIE_MAX_AGE', 15 * DAY_MS); + const isProduction = config.get('NODE_ENV') === 'production'; + + const middleware = session({ + name: CookieNames.SessionId, + secret: config.getOrThrow('SESSION_SECRET'), + resave: false, + saveUninitialized: false, + store, + cookie: { + httpOnly: true, + secure: config.get('COOKIE_SECURE') === 'true' || isProduction, + sameSite: 'strict', + maxAge, + path: '/', + }, + }); + + const client = redisClient as RedisClientType; + const origQuit = client.quit.bind(client); + client.quit = async () => { + logger.log('Closing session Redis connection...'); + return origQuit(); + }; + + return { client, store, middleware }; + }, +}; From c09b39717b90c1abfdc892658af5da06bcf346d0 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 13:23:28 +0330 Subject: [PATCH 10/12] refactor: consolidate auth guards with BaseTokenGuard and B-2 fix --- src/common/guards/access-token.guard.spec.ts | 57 +++++++ src/common/guards/access-token.guard.ts | 18 +++ .../guards/deny-authorized.guard.spec.ts | 77 ++++++++++ src/common/guards/deny-authorized.guard.ts | 50 +++++++ src/common/guards/multi-token.guard.ts | 65 ++++++++ src/common/guards/ownership.guard.spec.ts | 120 +++++++++++++++ src/common/guards/ownership.guard.ts | 141 ++++++++++++++++++ src/common/guards/progress-token.guard.ts | 18 +++ .../guards/temporary-token.guard.spec.ts | 82 ++++++++++ src/common/guards/temporary-token.guard.ts | 18 +++ src/common/guards/token.guard.base.ts | 81 ++++++++++ .../auth/guard/deny-authorized.guard.ts | 39 +---- src/modules/auth/guard/multi-token.guard.ts | 62 +------- src/modules/auth/guard/ownership.guard.ts | 140 +---------------- src/modules/auth/guard/token.guard.ts | 110 +------------- 15 files changed, 733 insertions(+), 345 deletions(-) create mode 100644 src/common/guards/access-token.guard.spec.ts create mode 100644 src/common/guards/access-token.guard.ts create mode 100644 src/common/guards/deny-authorized.guard.spec.ts create mode 100644 src/common/guards/deny-authorized.guard.ts create mode 100644 src/common/guards/multi-token.guard.ts create mode 100644 src/common/guards/ownership.guard.spec.ts create mode 100644 src/common/guards/ownership.guard.ts create mode 100644 src/common/guards/progress-token.guard.ts create mode 100644 src/common/guards/temporary-token.guard.spec.ts create mode 100644 src/common/guards/temporary-token.guard.ts create mode 100644 src/common/guards/token.guard.base.ts diff --git a/src/common/guards/access-token.guard.spec.ts b/src/common/guards/access-token.guard.spec.ts new file mode 100644 index 0000000..cedb3dd --- /dev/null +++ b/src/common/guards/access-token.guard.spec.ts @@ -0,0 +1,57 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { AccessTokenGuard } from './access-token.guard'; +import { TokenService } from '../../modules/token/token.service'; +import { CookieNames } from '../enums/cookies.enum'; +import { AuthTokens } from '../enums/auth.enum'; +import { AuthMessages } from '../enums/messages.enum'; + +const buildContext = (cookies: any = {}, session: any = {}) => { + const request = { cookies, session }; + return { + getType: () => 'http', + switchToHttp: () => ({ + getRequest: () => request, + }), + } as any; +}; + +describe('AccessTokenGuard', () => { + let guard: AccessTokenGuard; + let tokenService: DeepMockProxy; + + beforeEach(() => { + tokenService = mockDeep(); + guard = new AccessTokenGuard(tokenService); + }); + + it('throws MissingAccessToken when cookie is absent', async () => { + await expect(guard.canActivate(buildContext())).rejects.toThrow( + AuthMessages.MissingAccessToken, + ); + }); + + it('throws InvalidToken when phone does not match session', async () => { + tokenService.verifyToken.mockReturnValue({ sub: 'u1', phoneNumber: '0912' }); + await expect( + guard.canActivate( + buildContext( + { [CookieNames.AccessToken]: 'a' }, + { phoneNumber: '0999' }, + ), + ), + ).rejects.toThrow(AuthMessages.InvalidToken); + }); + + it('passes and sets user id when phone matches session', async () => { + tokenService.verifyToken.mockReturnValue({ sub: 'u1', phoneNumber: '0912' }); + const ctx = buildContext( + { [CookieNames.AccessToken]: 'a' }, + { phoneNumber: '0912' }, + ); + const result = await guard.canActivate(ctx); + expect(result).toBe(true); + expect(tokenService.verifyToken).toHaveBeenCalledWith('a', AuthTokens.Access); + expect(ctx.switchToHttp().getRequest().user).toEqual({ id: 'u1' }); + }); +}); diff --git a/src/common/guards/access-token.guard.ts b/src/common/guards/access-token.guard.ts new file mode 100644 index 0000000..5aeefa7 --- /dev/null +++ b/src/common/guards/access-token.guard.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { CookieNames } from '../enums/cookies.enum'; +import { AuthTokens } from '../enums/auth.enum'; +import { AuthMessages } from '../enums/messages.enum'; +import { TokenService } from '../../modules/token/token.service'; +import { BaseTokenGuard } from './token.guard.base'; + +@Injectable() +export class AccessTokenGuard extends BaseTokenGuard { + constructor(tokenService: TokenService) { + super(tokenService, { + cookieName: CookieNames.AccessToken, + tokenType: AuthTokens.Access, + matchPolicy: 'phone-or-sub', + missingMessage: AuthMessages.MissingAccessToken, + }); + } +} diff --git a/src/common/guards/deny-authorized.guard.spec.ts b/src/common/guards/deny-authorized.guard.spec.ts new file mode 100644 index 0000000..86b8063 --- /dev/null +++ b/src/common/guards/deny-authorized.guard.spec.ts @@ -0,0 +1,77 @@ +import { BadRequestException } from '@nestjs/common'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { DenyAuthorizedGuard } from './deny-authorized.guard'; +import { TokenService } from '../../modules/token/token.service'; +import { CookieNames } from '../enums/cookies.enum'; +import { AuthTokens } from '../enums/auth.enum'; +import { AuthMessages } from '../enums/messages.enum'; + +const buildContext = (cookies: any = {}, session: any = {}) => { + const request = { cookies, session }; + return { + getType: () => 'http', + switchToHttp: () => ({ + getRequest: () => request, + }), + } as any; +}; + +describe('DenyAuthorizedGuard', () => { + let guard: DenyAuthorizedGuard; + let tokenService: DeepMockProxy; + + beforeEach(() => { + tokenService = mockDeep(); + guard = new DenyAuthorizedGuard(tokenService); + }); + + it('passes through when no access token is present', async () => { + const result = await guard.canActivate(buildContext()); + expect(result).toBe(true); + }); + + it('passes through when token verification fails', async () => { + tokenService.verifyToken.mockImplementation(() => { + throw new Error('bad'); + }); + const result = await guard.canActivate( + buildContext({ [CookieNames.AccessToken]: 'a' }, {}), + ); + expect(result).toBe(true); + }); + + it('throws AlreadyAuthorized (400) when phone matches session', async () => { + tokenService.verifyToken.mockReturnValue({ sub: 'u1', phoneNumber: '0912' }); + await expect( + guard.canActivate( + buildContext( + { [CookieNames.AccessToken]: 'a' }, + { phoneNumber: '0912', userId: 'other' }, + ), + ), + ).rejects.toThrow(AuthMessages.AlreadyAuthorized); + }); + + it('throws AlreadyAuthorized when session.userId matches sub', async () => { + tokenService.verifyToken.mockReturnValue({ sub: 'u1', phoneNumber: '0912' }); + await expect( + guard.canActivate( + buildContext( + { [CookieNames.AccessToken]: 'a' }, + { phoneNumber: '0999', userId: 'u1' }, + ), + ), + ).rejects.toThrow(AuthMessages.AlreadyAuthorized); + }); + + it('passes through when neither phone nor sub match', async () => { + tokenService.verifyToken.mockReturnValue({ sub: 'u1', phoneNumber: '0912' }); + const result = await guard.canActivate( + buildContext( + { [CookieNames.AccessToken]: 'a' }, + { phoneNumber: '0999', userId: 'other' }, + ), + ); + expect(result).toBe(true); + }); +}); diff --git a/src/common/guards/deny-authorized.guard.ts b/src/common/guards/deny-authorized.guard.ts new file mode 100644 index 0000000..0f4186c --- /dev/null +++ b/src/common/guards/deny-authorized.guard.ts @@ -0,0 +1,50 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + Injectable, +} from '@nestjs/common'; +import { Request } from 'express'; +import { JwtPayload } from 'jsonwebtoken'; +import { CookieNames } from '../enums/cookies.enum'; +import { AuthTokens } from '../enums/auth.enum'; +import { AuthMessages } from '../enums/messages.enum'; +import { TokenService } from '../../modules/token/token.service'; + +/** + * Inverse guard: if the user is already authorized (valid access token whose + * phone/sub matches the session), reject with 400 AlreadyAuthorized. + * Otherwise pass through. + */ +@Injectable() +export class DenyAuthorizedGuard implements CanActivate { + constructor(private readonly tokenService: TokenService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const session = request.session; + const accessToken = request.cookies?.[CookieNames.AccessToken] as + | string + | undefined; + + let payload: JwtPayload | undefined; + try { + payload = this.tokenService.verifyToken(accessToken as string, AuthTokens.Access); + } catch { + return true; + } + + if (!payload) { + return true; + } + + const isOkToken = + session.phoneNumber === payload.phoneNumber || + session.userId === payload.sub; + if (isOkToken) { + throw new BadRequestException(AuthMessages.AlreadyAuthorized); + } + + return true; + } +} diff --git a/src/common/guards/multi-token.guard.ts b/src/common/guards/multi-token.guard.ts new file mode 100644 index 0000000..cb73217 --- /dev/null +++ b/src/common/guards/multi-token.guard.ts @@ -0,0 +1,65 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; +import { CookieNames } from '../enums/cookies.enum'; +import { AuthTokens } from '../enums/auth.enum'; +import { AuthMessages } from '../enums/messages.enum'; +import { TokenService } from '../../modules/token/token.service'; + +/** + * Tries ProgressToken then AccessToken. Sets request.user = { id } from the + * first valid token. No session comparison. Fixes B-2: spread preserves prior + * user fields, id always wins. + */ +@Injectable() +export class MultiTokenGuard implements CanActivate { + constructor(private readonly tokenService: TokenService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + // Check ProgressToken + const progressToken = request.cookies?.[CookieNames.ProgressToken] as + | string + | undefined; + if (progressToken) { + try { + const payload = this.tokenService.verifyToken( + progressToken, + AuthTokens.Progress, + ); + if (payload.sub && payload.phoneNumber) { + request.user = { ...request.user, id: payload.sub }; + return true; + } + } catch { + /* token invalid — fall through to the next token type */ + } + } + + // Check AccessToken + const accessToken = request.cookies?.[CookieNames.AccessToken] as + | string + | undefined; + if (accessToken) { + try { + const payload = this.tokenService.verifyToken( + accessToken, + AuthTokens.Access, + ); + if (payload.sub && payload.phoneNumber) { + request.user = { ...request.user, id: payload.sub }; + return true; + } + } catch { + /* token invalid — fall through to unauthorized response */ + } + } + + throw new UnauthorizedException(AuthMessages.MissingOrInvalidToken); + } +} diff --git a/src/common/guards/ownership.guard.spec.ts b/src/common/guards/ownership.guard.spec.ts new file mode 100644 index 0000000..82d86c6 --- /dev/null +++ b/src/common/guards/ownership.guard.spec.ts @@ -0,0 +1,120 @@ +import { + BadRequestException, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { OwnershipGuard } from './ownership.guard'; +import { PrismaService } from '../../modules/prisma/prisma.service'; +import { RolesEnum } from '../../../generated/prisma'; +import { AuthMessages } from '../enums/messages.enum'; + +const buildContext = (handler: any = () => {}, request: any = {}) => + ({ + getType: () => 'http', + getHandler: () => handler, + switchToHttp: () => ({ + getRequest: () => request, + }), + }) as any; + +describe('OwnershipGuard', () => { + let guard: OwnershipGuard; + let reflector: DeepMockProxy; + let prisma: DeepMockProxy; + + beforeEach(() => { + reflector = mockDeep(); + prisma = mockDeep(); + guard = new OwnershipGuard(reflector, prisma); + }); + + it('returns true when no ownership config is set', async () => { + reflector.get.mockReturnValue(undefined); + const result = await guard.canActivate(buildContext()); + expect(result).toBe(true); + }); + + it('throws ForbiddenException when user is not authenticated', async () => { + reflector.get.mockReturnValue({ entity: 'vehicle' }); + await expect( + guard.canActivate(buildContext(() => {}, { user: undefined, params: {} })), + ).rejects.toThrow(ForbiddenException); + }); + + it('returns true for admin users', async () => { + reflector.get.mockReturnValue({ entity: 'vehicle' }); + const result = await guard.canActivate( + buildContext(() => {}, { + user: { id: 'admin-1', role: RolesEnum.admin }, + params: { id: 'v1' }, + }), + ); + expect(result).toBe(true); + }); + + it('throws BadRequestException when :id param is missing', async () => { + reflector.get.mockReturnValue({ entity: 'vehicle' }); + await expect( + guard.canActivate( + buildContext(() => {}, { + user: { id: 'u1', role: RolesEnum.sender }, + params: {}, + }), + ), + ).rejects.toThrow(BadRequestException); + }); + + it('throws EntityAccessDenied when record not found', async () => { + reflector.get.mockReturnValue({ entity: 'vehicle' }); + (prisma as any).vehicle = { findFirst: jest.fn().mockResolvedValue(null) }; + + await expect( + guard.canActivate( + buildContext(() => {}, { + user: { id: 'u1', role: RolesEnum.sender }, + params: { id: 'v1' }, + }), + ), + ).rejects.toThrow(/Access Denied/); + }); + + it('returns true when record is owned', async () => { + reflector.get.mockReturnValue({ entity: 'vehicle' }); + (prisma as any).vehicle = { findFirst: jest.fn().mockResolvedValue({ id: 'v1' }) }; + + const result = await guard.canActivate( + buildContext(() => {}, { + user: { id: 'u1', role: RolesEnum.sender }, + params: { id: 'v1' }, + }), + ); + expect(result).toBe(true); + }); + + it('throws 403 fallback on Prisma failure', async () => { + reflector.get.mockReturnValue({ entity: 'vehicle' }); + (prisma as any).vehicle = { findFirst: jest.fn().mockRejectedValue(new Error('db down')) }; + + await expect( + guard.canActivate( + buildContext(() => {}, { + user: { id: 'u1', role: RolesEnum.sender }, + params: { id: 'v1' }, + }), + ), + ).rejects.toThrow(AuthMessages.AccessDenied); + }); + + it('throws error for invalid entity name', async () => { + reflector.get.mockReturnValue({ entity: 'nonexistent' }); + await expect( + guard.canActivate( + buildContext(() => {}, { + user: { id: 'u1', role: RolesEnum.sender }, + params: { id: 'x1' }, + }), + ), + ).rejects.toThrow(); + }); +}); diff --git a/src/common/guards/ownership.guard.ts b/src/common/guards/ownership.guard.ts new file mode 100644 index 0000000..18de951 --- /dev/null +++ b/src/common/guards/ownership.guard.ts @@ -0,0 +1,141 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RolesEnum } from '../../../generated/prisma'; +import { PrismaService } from '../../modules/prisma/prisma.service'; +import { OwnershipConfig } from '../../modules/auth/types/auth.types'; +import { AuthMessages } from '../enums/messages.enum'; + +@Injectable() +export class OwnershipGuard implements CanActivate { + private readonly logger = new Logger(OwnershipGuard.name); + + constructor( + private readonly reflector: Reflector, + private readonly prisma: PrismaService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const config = this.reflector.get( + 'ownership', + context.getHandler(), + ); + + if (!config) { + return true; // No ownership check required + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user || !user.id) { + throw new ForbiddenException('User not authenticated'); + } + + // Admin can access all resources + if (user.role === RolesEnum.admin) { + return true; + } + + const entityId = request.params[config.paramName ?? 'id']; + if (!entityId) { + throw new BadRequestException('Entity Id is required.'); + } + + const ownershipName = config.ownershipName ?? config.entity; + const customWhere = this.getCustomWhere(ownershipName, user.id, entityId); + + try { + const record = await this.getEntityRecord(config.entity, customWhere); + + if (!record) { + throw new ForbiddenException( + `${AuthMessages.EntityAccessDenied} ${config.entity}.`, + ); + } + + return true; + } catch (error) { + if ( + error instanceof ForbiddenException || + error instanceof BadRequestException + ) { + throw error; + } + this.logger.error('Ownership check error:', error as Error); + throw new ForbiddenException(AuthMessages.AccessDenied); + } + } + + // Entity customWhere mapping + private getCustomWhere( + entity: string, + ownerId: string, + entityId: string, + ): Record { + const entityCustomWhereMap: Record> = { + vehicle: { + id: entityId, + ownerId, + deletedAt: null, + }, + address: { + id: entityId, + userId: ownerId, + deletedAt: null, + }, + package: { + id: entityId, + senderId: ownerId, + deletedAt: null, + }, + trip: { + id: entityId, + transporter: { + userId: ownerId, + }, + deletedAt: null, + }, + tripRequest: { + id: entityId, + trip: { + transporter: { + userId: ownerId, + }, + }, + deletedAt: null, + }, + packageRequest: { + id: entityId, + package: { + senderId: ownerId, + }, + deletedAt: null, + }, + // ... + }; + + return entityCustomWhereMap[entity]; + } + + private async getEntityRecord( + entity: string, + customWhere: Record, + ): Promise { + const model = this.prisma[entity as keyof PrismaService] as any; + + if (!model || typeof model.findFirst !== 'function') { + throw new Error(`Invalid entity: ${entity}`); + } + + return model.findFirst({ + where: customWhere, + }); + } +} diff --git a/src/common/guards/progress-token.guard.ts b/src/common/guards/progress-token.guard.ts new file mode 100644 index 0000000..cca9f1b --- /dev/null +++ b/src/common/guards/progress-token.guard.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { CookieNames } from '../enums/cookies.enum'; +import { AuthTokens } from '../enums/auth.enum'; +import { AuthMessages } from '../enums/messages.enum'; +import { TokenService } from '../../modules/token/token.service'; +import { BaseTokenGuard } from './token.guard.base'; + +@Injectable() +export class ProgressTokenGuard extends BaseTokenGuard { + constructor(tokenService: TokenService) { + super(tokenService, { + cookieName: CookieNames.ProgressToken, + tokenType: AuthTokens.Progress, + matchPolicy: 'phone-or-sub', + missingMessage: AuthMessages.MissingProgressToken, + }); + } +} diff --git a/src/common/guards/temporary-token.guard.spec.ts b/src/common/guards/temporary-token.guard.spec.ts new file mode 100644 index 0000000..434a52d --- /dev/null +++ b/src/common/guards/temporary-token.guard.spec.ts @@ -0,0 +1,82 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { TemporaryTokenGuard } from './temporary-token.guard'; +import { TokenService } from '../../modules/token/token.service'; +import { CookieNames } from '../enums/cookies.enum'; +import { AuthTokens } from '../enums/auth.enum'; +import { AuthMessages } from '../enums/messages.enum'; + +const buildContext = (cookies: any = {}, session: any = {}) => { + const request = { cookies, session }; + return { + getType: () => 'http', + switchToHttp: () => ({ + getRequest: () => request, + }), + } as any; +}; + +describe('TemporaryTokenGuard', () => { + let guard: TemporaryTokenGuard; + let tokenService: DeepMockProxy; + + beforeEach(() => { + tokenService = mockDeep(); + guard = new TemporaryTokenGuard(tokenService); + }); + + it('throws MissingTempToken when cookie is absent', async () => { + tokenService.verifyToken.mockReturnValue({ phoneNumber: '0912' }); + await expect(guard.canActivate(buildContext())).rejects.toThrow( + UnauthorizedException, + ); + await expect(guard.canActivate(buildContext())).rejects.toThrow( + AuthMessages.MissingTempToken, + ); + }); + + it('throws InvalidToken when phone does not match session', async () => { + tokenService.verifyToken.mockReturnValue({ phoneNumber: '0912' }); + await expect( + guard.canActivate(buildContext( + { [CookieNames.TemporaryToken]: 't' }, + { phoneNumber: '0999' }, + )), + ).rejects.toThrow(AuthMessages.InvalidToken); + }); + + it('passes and sets user id when phone matches session', async () => { + tokenService.verifyToken.mockReturnValue({ sub: 'u1', phoneNumber: '0912' }); + const ctx = buildContext( + { [CookieNames.TemporaryToken]: 't' }, + { phoneNumber: '0912' }, + ); + const result = await guard.canActivate(ctx); + expect(result).toBe(true); + expect(tokenService.verifyToken).toHaveBeenCalledWith('t', AuthTokens.Temporary); + expect(ctx.switchToHttp().getRequest().user).toEqual({ id: 'u1' }); + }); + + it('overrides prior user.id with the verified id (B-2)', async () => { + tokenService.verifyToken.mockReturnValue({ sub: 'verified-id', phoneNumber: '0912' }); + const ctx = buildContext( + { [CookieNames.TemporaryToken]: 't' }, + { phoneNumber: '0912' }, + ); + ctx.switchToHttp().getRequest().user = { id: 'stale', role: 'sender' }; + await guard.canActivate(ctx); + expect(ctx.switchToHttp().getRequest().user).toEqual({ + id: 'verified-id', + role: 'sender', + }); + }); + + it('throws InvalidToken when verifyToken throws (expired/invalid)', async () => { + tokenService.verifyToken.mockImplementation(() => { + throw new UnauthorizedException(AuthMessages.InvalidToken); + }); + await expect( + guard.canActivate(buildContext({ [CookieNames.TemporaryToken]: 't' }, {})), + ).rejects.toThrow(AuthMessages.InvalidToken); + }); +}); diff --git a/src/common/guards/temporary-token.guard.ts b/src/common/guards/temporary-token.guard.ts new file mode 100644 index 0000000..8f958f2 --- /dev/null +++ b/src/common/guards/temporary-token.guard.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { CookieNames } from '../enums/cookies.enum'; +import { AuthTokens } from '../enums/auth.enum'; +import { AuthMessages } from '../enums/messages.enum'; +import { TokenService } from '../../modules/token/token.service'; +import { BaseTokenGuard } from './token.guard.base'; + +@Injectable() +export class TemporaryTokenGuard extends BaseTokenGuard { + constructor(tokenService: TokenService) { + super(tokenService, { + cookieName: CookieNames.TemporaryToken, + tokenType: AuthTokens.Temporary, + matchPolicy: 'phone', + missingMessage: AuthMessages.MissingTempToken, + }); + } +} diff --git a/src/common/guards/token.guard.base.ts b/src/common/guards/token.guard.base.ts new file mode 100644 index 0000000..a05b137 --- /dev/null +++ b/src/common/guards/token.guard.base.ts @@ -0,0 +1,81 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; +import { CookieNames } from '../enums/cookies.enum'; +import { AuthTokens } from '../enums/auth.enum'; +import { TokenService } from '../../modules/token/token.service'; +import { AuthMessages } from '../enums/messages.enum'; + +export interface TokenGuardOptions { + cookieName: CookieNames; + tokenType: AuthTokens; + /** How the token payload is validated against the session. */ + matchPolicy: 'phone' | 'phone-or-sub' | 'multi'; + missingMessage: AuthMessages; +} + +/** + * Base class for all token-based guards. Fixes B-2: the verified `id` from the + * token payload always wins over any prior value on request.user. + */ +@Injectable() +export abstract class BaseTokenGuard implements CanActivate { + private readonly options: TokenGuardOptions; + + constructor( + protected readonly tokenService: TokenService, + options: TokenGuardOptions, + ) { + this.options = options; + } + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = request.cookies?.[this.options.cookieName] as string | undefined; + + if (!token) { + throw new UnauthorizedException(this.options.missingMessage); + } + + const payload = this.tokenService.verifyToken(token, this.options.tokenType); + + if (!this.matchesSession(request, payload)) { + throw new UnauthorizedException(AuthMessages.InvalidToken); + } + + // B-2 fix: verified id from token always wins + request.user = { + ...request.user, + id: payload.sub, + }; + + return true; + } + + protected matchesSession( + request: Request, + payload: { sub?: string; phoneNumber?: string }, + ): boolean { + const session = request.session; + + switch (this.options.matchPolicy) { + case 'phone': + return ( + !!payload.phoneNumber && + payload.phoneNumber === session.phoneNumber + ); + case 'phone-or-sub': + return ( + !!payload.sub && + !!payload.phoneNumber && + session.phoneNumber === payload.phoneNumber + ); + default: + return !!payload.sub && !!payload.phoneNumber; + } + } +} diff --git a/src/modules/auth/guard/deny-authorized.guard.ts b/src/modules/auth/guard/deny-authorized.guard.ts index e782476..751231a 100644 --- a/src/modules/auth/guard/deny-authorized.guard.ts +++ b/src/modules/auth/guard/deny-authorized.guard.ts @@ -1,39 +1,2 @@ -import { - BadRequestException, - CanActivate, - ExecutionContext, - Injectable, -} from '@nestjs/common'; -import { Request } from 'express'; -import { JwtPayload } from 'jsonwebtoken'; -import { AuthTokens } from '../../../common/enums/auth.enum'; -import { CookieNames } from '../../../common/enums/cookies.enum'; -import { AuthMessages } from '../../../common/enums/messages.enum'; -import { TokenService } from '../../../modules/token/token.service'; +export { DenyAuthorizedGuard } from '../../../common/guards/deny-authorized.guard'; -@Injectable() -export class DenyAuthorizedGuard implements CanActivate { - constructor(private tokenService: TokenService) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const session = request.session; - const accessToken = request.cookies?.[CookieNames.AccessToken] as string; - - let payload: JwtPayload | undefined; - try { - payload = this.tokenService.verifyToken(accessToken, AuthTokens.Access); - } catch { - return true; - } - - const isOkToken = - session.phoneNumber === payload.phoneNumber || - session.userId === payload.sub; - if (isOkToken) { - throw new BadRequestException(AuthMessages.AlreadyAuthorized); - } - - return true; - } -} diff --git a/src/modules/auth/guard/multi-token.guard.ts b/src/modules/auth/guard/multi-token.guard.ts index 3781b79..8e7e1e9 100644 --- a/src/modules/auth/guard/multi-token.guard.ts +++ b/src/modules/auth/guard/multi-token.guard.ts @@ -1,62 +1,2 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; -import { Request } from 'express'; -import { AuthTokens } from '../../../common/enums/auth.enum'; -import { CookieNames } from '../../../common/enums/cookies.enum'; -import { AuthMessages } from '../../../common/enums/messages.enum'; -import { TokenService } from '../../token/token.service'; +export { MultiTokenGuard } from '../../../common/guards/multi-token.guard'; -@Injectable() -export class MultiTokenGuard implements CanActivate { - constructor(private tokenService: TokenService) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const progressToken = request.cookies?.[ - CookieNames.ProgressToken - ] as string; - const accessToken = request.cookies?.[CookieNames.AccessToken] as string; - - // Check ProgressToken - if (progressToken) { - try { - const payload = this.tokenService.verifyToken( - progressToken, - AuthTokens.Progress, - ); - const isOkToken = payload.sub && payload.phoneNumber; - - if (isOkToken) { - request.user = { id: payload.sub }; - return true; - } - } catch { - /* token invalid — fall through to the next token type */ - } - } - - // Check AccessToken - if (accessToken) { - try { - const payload = this.tokenService.verifyToken( - accessToken, - AuthTokens.Access, - ); - const isOkToken = payload.sub && payload.phoneNumber; - - if (isOkToken) { - request.user = { id: payload.sub }; - return true; - } - } catch { - /* token invalid — fall through to unauthorized response */ - } - } - - throw new UnauthorizedException(AuthMessages.MissingOrInvalidToken); - } -} diff --git a/src/modules/auth/guard/ownership.guard.ts b/src/modules/auth/guard/ownership.guard.ts index 087d189..ae36096 100644 --- a/src/modules/auth/guard/ownership.guard.ts +++ b/src/modules/auth/guard/ownership.guard.ts @@ -1,140 +1,2 @@ -import { - Injectable, - CanActivate, - ExecutionContext, - ForbiddenException, - BadRequestException, -} from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { RolesEnum } from '../../../../generated/prisma'; -import { PrismaService } from '../../prisma/prisma.service'; -import { OwnershipConfig } from '../types/auth.types'; -import { AuthMessages } from '../../../common/enums/messages.enum'; +export { OwnershipGuard } from '../../../common/guards/ownership.guard'; -// Just a mvp version - -@Injectable() -export class OwnershipGuard implements CanActivate { - constructor( - private reflector: Reflector, - private prisma: PrismaService, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const config = this.reflector.get( - 'ownership', - context.getHandler(), - ); - - if (!config) { - return true; // No ownership check required - } - - const request = context.switchToHttp().getRequest(); - const user = request.user; - - if (!user || !user.id) { - throw new ForbiddenException('User not authenticated'); - } - - // Admin can access all resources - if (user.role === RolesEnum.admin) { - return true; - } - - const entityId = request.params[config.paramName ?? 'id']; - if (!entityId) { - throw new BadRequestException('Entity Id is required.'); - } - - const ownershipName = config.ownershipName ?? config.entity; - const customWhere = this.getCustomWhere(ownershipName, user.id, entityId); - - try { - const record = await this.getEntityRecord(config.entity, customWhere); - - if (!record) { - throw new ForbiddenException( - `${AuthMessages.EntityAccessDenied} ${config.entity}.`, - ); - } - - return true; - } catch (error) { - if ( - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - console.error('Ownership check error:', error); - throw new ForbiddenException(AuthMessages.AccessDenied); - } - } - - // Entity customWhere mapping - private getCustomWhere( - entity: string, - ownerId: string, - entityId: string, - ): Record { - const entityCustomWhereMap: Record> = { - vehicle: { - id: entityId, - ownerId, - deletedAt: null, - }, - address: { - id: entityId, - userId: ownerId, - deletedAt: null, - }, - package: { - id: entityId, - senderId: ownerId, - deletedAt: null, - }, - trip: { - id: entityId, - transporter: { - userId: ownerId, - }, - deletedAt: null, - }, - tripRequest: { - id: entityId, - trip: { - transporter: { - userId: ownerId, - }, - }, - deletedAt: null, - }, - packageRequest: { - id: entityId, - package: { - senderId: ownerId, - }, - deletedAt: null, - }, - // ... - }; - - return entityCustomWhereMap[entity]; - } - - private async getEntityRecord( - entity: string, - customWhere: Record, - ): Promise { - const model = this.prisma[entity as keyof PrismaService] as any; - - if (!model || typeof model.findFirst !== 'function') { - throw new Error(`Invalid entity: ${entity}`); - } - - return model.findFirst({ - where: customWhere, - }); - } -} diff --git a/src/modules/auth/guard/token.guard.ts b/src/modules/auth/guard/token.guard.ts index e28c0a3..94c78f5 100644 --- a/src/modules/auth/guard/token.guard.ts +++ b/src/modules/auth/guard/token.guard.ts @@ -1,108 +1,4 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; -import { Request } from 'express'; -import { AuthTokens } from '../../../common/enums/auth.enum'; -import { CookieNames } from '../../../common/enums/cookies.enum'; -import { AuthMessages } from '../../../common/enums/messages.enum'; -import { TokenService } from '../../../modules/token/token.service'; +export { TemporaryTokenGuard } from '../../../common/guards/temporary-token.guard'; +export { ProgressTokenGuard } from '../../../common/guards/progress-token.guard'; +export { AccessTokenGuard } from '../../../common/guards/access-token.guard'; -@Injectable() -export class TemporaryTokenGuard implements CanActivate { - constructor(private tokenService: TokenService) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const session = request.session; - const tempToken = request.cookies?.[CookieNames.TemporaryToken] as string; - - if (!tempToken) { - throw new UnauthorizedException(AuthMessages.MissingTempToken); - } - - const payload = this.tokenService.verifyToken( - tempToken, - AuthTokens.Temporary, - ); - if (!payload.phoneNumber || payload.phoneNumber !== session.phoneNumber) { - throw new UnauthorizedException(AuthMessages.InvalidToken); - } - - return true; - } -} - -@Injectable() -export class ProgressTokenGuard implements CanActivate { - constructor(private tokenService: TokenService) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const session = request.session; - const progressToken = request.cookies?.[ - CookieNames.ProgressToken - ] as string; - - if (!progressToken) { - throw new UnauthorizedException(AuthMessages.MissingProgressToken); - } - - const payload = this.tokenService.verifyToken( - progressToken, - AuthTokens.Progress, - ); - const isOkToken = - payload.sub && - payload.phoneNumber && - session.phoneNumber === payload.phoneNumber; - - if (!isOkToken) { - throw new UnauthorizedException(AuthMessages.InvalidToken); - } - - request.user = { - id: payload.sub, - ...request.user, - }; - - return true; - } -} - -@Injectable() -export class AccessTokenGuard implements CanActivate { - constructor(private tokenService: TokenService) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const session = request.session; - const accessToken = request.cookies?.[CookieNames.AccessToken] as string; - - if (!accessToken) { - throw new UnauthorizedException(AuthMessages.MissingAccessToken); - } - - const payload = this.tokenService.verifyToken( - accessToken, - AuthTokens.Access, - ); - const isOkToken = - payload.sub && - payload.phoneNumber && - session.phoneNumber === payload.phoneNumber; - - if (!isOkToken) { - throw new UnauthorizedException(AuthMessages.InvalidToken); - } - - request.user = { - id: payload.sub, - ...request.user, - }; - - return true; - } -} From a04ebb06cd13016353118139288ad5b4371c6c32 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 13:23:49 +0330 Subject: [PATCH 11/12] refactor: harden HealthController, remove external ping, add heap threshold env --- src/modules/health/health.controller.spec.ts | 63 ++++++++++++++++++++ src/modules/health/health.controller.ts | 12 ++-- 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/modules/health/health.controller.spec.ts diff --git a/src/modules/health/health.controller.spec.ts b/src/modules/health/health.controller.spec.ts new file mode 100644 index 0000000..8220e39 --- /dev/null +++ b/src/modules/health/health.controller.spec.ts @@ -0,0 +1,63 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HealthController } from './health.controller'; +import { + HealthCheckService, + MemoryHealthIndicator, + PrismaHealthIndicator, +} from '@nestjs/terminus'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../prisma/prisma.service'; +import { RedisHealthIndicator } from './redis-health.indicator'; + +describe('HealthController', () => { + let controller: HealthController; + let healthCheckService: DeepMockProxy; + let redisHealthIndicator: DeepMockProxy; + + beforeEach(async () => { + healthCheckService = mockDeep(); + redisHealthIndicator = mockDeep(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { provide: HealthCheckService, useValue: healthCheckService }, + { provide: PrismaHealthIndicator, useValue: mockDeep() }, + { provide: RedisHealthIndicator, useValue: redisHealthIndicator }, + { provide: MemoryHealthIndicator, useValue: mockDeep() }, + { provide: PrismaService, useValue: mockDeep() }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, defaultValue?: any) => + key === 'MEMORY_HEAP_THRESHOLD_MB' ? 150 : defaultValue, + ), + }, + }, + ], + }).compile(); + + controller = module.get(HealthController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('runs health checks without the external nestjs-docs ping', () => { + healthCheckService.check.mockResolvedValue({ + status: 'ok', + info: { database: { status: 'up' } }, + error: {}, + details: { database: { status: 'up' } }, + } as any); + + controller.check(); + + const checks = healthCheckService.check.mock.calls[0][0] as Array<() => any>; + expect(checks).toHaveLength(3); + const labels = checks.map((fn) => fn.name); + expect(labels).not.toContain('bound pingCheck'); + }); +}); diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index d436d00..f597c93 100644 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -2,10 +2,10 @@ import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common'; import { HealthCheck, HealthCheckService, - HttpHealthIndicator, MemoryHealthIndicator, PrismaHealthIndicator, } from '@nestjs/terminus'; +import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../prisma/prisma.service'; import { RedisHealthIndicator } from './redis-health.indicator'; import { HealthCheckDto } from './health-check.dto'; @@ -15,11 +15,11 @@ import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; export class HealthController { constructor( private health: HealthCheckService, - private http: HttpHealthIndicator, private db: PrismaHealthIndicator, private redis: RedisHealthIndicator, private memory: MemoryHealthIndicator, private prisma: PrismaService, + private config: ConfigService, ) {} @ApiOperation({ @@ -32,11 +32,15 @@ export class HealthController { @Get() @HealthCheck() check() { + const heapMb = + this.config.get('MEMORY_HEAP_THRESHOLD_MB', 150); + const heapBytes = heapMb * 1024 * 1024; + return this.health.check([ - () => this.http.pingCheck('nestjs-docs', 'https://docs.nestjs.com'), () => this.db.pingCheck('database', this.prisma), () => this.redis.isHealthy('redis'), - () => this.memory.checkHeap('memory_heap', 150 * 1024 * 1024), // 150MB + () => this.memory.checkHeap('memory_heap', heapBytes), ]); } } + From 64370940966db000d5386822ac9f2d02387dc4a2 Mon Sep 17 00:00:00 2001 From: Mohammad Hosseini Date: Thu, 3 Sep 2026 13:24:04 +0330 Subject: [PATCH 12/12] refactor: rewrite main.ts bootstrap and AppModule as pure composition root --- src/main.ts | 76 ++++++++++++++++++++++++++--------- src/modules/app/app.module.ts | 63 +++++++++-------------------- 2 files changed, 77 insertions(+), 62 deletions(-) diff --git a/src/main.ts b/src/main.ts index 62ad7c4..f195db4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,27 +1,67 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './modules/app/app.module'; -import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { Logger, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import helmet from 'helmet'; +import compression from 'compression'; +import { SwaggerModule } from '@nestjs/swagger'; +import { + AllExceptionsFilter, +} from './common/filters/all-exceptions.filter'; +import { RequestIdMiddleware } from './common/middleware/request-id.middleware'; +import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; +import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor'; +import { TransformInterceptor } from './common/interceptors/transform.interceptor'; +import { parseCorsOrigins, buildSwaggerConfig, isSwaggerEnabled } from './common/http/bootstrap-options'; async function bootstrap() { const app = await NestFactory.create(AppModule); - const config = new DocumentBuilder() - .setTitle('HamBaar App') - .setDescription('HamBaar App API using NestJS and Prisma') - .setVersion('1.0') - .build(); - const documentFactory = () => SwaggerModule.createDocument(app, config); - SwaggerModule.setup('docs', app, documentFactory); - - app.enableCors({ - origin: true, - credentials: true, - }); - - const PORT = process.env.PORT ?? 3000; - await app.listen(PORT, '0.0.0.0', () => - console.log(`Listening on port ${PORT}.`), + const logger = new Logger('Bootstrap'); + const config = app.get(ConfigService); + + app.use(helmet()); + app.use(compression()); + + app.enableCors(parseCorsOrigins(config)); + + const apiPrefix = config.get('API_PREFIX', 'api'); + app.setGlobalPrefix(apiPrefix); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + app.useGlobalFilters(new AllExceptionsFilter()); + app.useGlobalInterceptors( + new LoggingInterceptor(), + new TimeoutInterceptor(config), + new TransformInterceptor(config), ); + + app.enableShutdownHooks(); + + // Assign request ids + app.use(RequestIdMiddleware); + + if (isSwaggerEnabled(config)) { + const swaggerConfig = buildSwaggerConfig(); + const documentFactory = () => SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('docs', app, documentFactory); + } + + const port = config.get('PORT', 3000); + await app.listen(port, '0.0.0.0'); + logger.log(`Listening on port ${port}`); } -void bootstrap(); +void bootstrap().catch((error) => { + // eslint-disable-next-line no-console + console.error('Fatal startup error:', error); + process.exit(1); +}); + diff --git a/src/modules/app/app.module.ts b/src/modules/app/app.module.ts index de3fc33..da807ce 100644 --- a/src/modules/app/app.module.ts +++ b/src/modules/app/app.module.ts @@ -1,15 +1,10 @@ -import { MiddlewareConsumer, Module, ValidationPipe } from '@nestjs/common'; +import { Module, ValidationPipe } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { CacheModule } from '@nestjs/cache-manager'; import { createKeyv } from '@keyv/redis'; import { APP_PIPE } from '@nestjs/core'; -import { createClient } from 'redis'; -import * as session from 'express-session'; -import * as cookieParser from 'cookie-parser'; -import { RedisStore } from 'connect-redis'; -import { CookieNames } from 'src/common/enums/cookies.enum'; import { AuthModule } from '../auth/auth.module'; import { UserModule } from '../user/user.module'; import { VehicleModule } from '../vehicle/vehicle.module'; @@ -23,12 +18,16 @@ import { MapModule } from '../map/map.module'; import { HealthModule } from '../health/health.module'; import { DashboardModule } from '../dashboard/dashboard.module'; import { NotificationModule } from '../notification/notification.module'; +import { SessionModule } from '../../infra/session/session.module'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { validateEnv } from '../../common/config/env.validation'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: `.env.${process.env.NODE_ENV}`, + validate: validateEnv, }), CacheModule.registerAsync({ isGlobal: true, @@ -40,6 +39,18 @@ import { NotificationModule } from '../notification/notification.module'; ], }), }), + ThrottlerModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + throttlers: [ + { + ttl: config.get('THROTTLE_TTL', 60) * 1000, + limit: config.get('THROTTLE_LIMIT', 100), + }, + ], + }), + }), + SessionModule, AuthModule, UserModule, DashboardModule, @@ -62,46 +73,10 @@ import { NotificationModule } from '../notification/notification.module'; useValue: new ValidationPipe({ whitelist: true, transform: true, + forbidNonWhitelisted: true, }), }, ], }) -export class AppModule { - constructor(private readonly config: ConfigService) {} - - async configure(consumer: MiddlewareConsumer) { - const redisClient = await createClient({ - url: this.config.getOrThrow('SESSION_REDIS_URL'), - }).connect(); - - consumer - .apply( - session({ - name: CookieNames.SessionId, - secret: this.config.getOrThrow('SESSION_SECRET'), - resave: false, - saveUninitialized: false, - store: new RedisStore({ - client: redisClient, - prefix: 'user-session', - }), - cookie: { - httpOnly: true, - // TODO: Fix this when you deploy on a https server - // secure: process.env.NODE_ENV === 'production', - secure: false, - sameSite: 'strict', - maxAge: this.config.get( - 'COOKIE_MAX_AGE', - 15 * 24 * 3600 * 1000, - ), // 15 days - }, - }), - ) - .forRoutes('*'); +export class AppModule {} - consumer - .apply(cookieParser(this.config.get('COOKIE_SECRET'))) - .forRoutes('*'); - } -}