diff --git a/apps/api/src/modules/auth/presentation/__tests__/auth-guards-branch-coverage.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/auth-guards-branch-coverage.spec.ts new file mode 100644 index 0000000..a63b3fb --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/auth-guards-branch-coverage.spec.ts @@ -0,0 +1,167 @@ +/** + * Supplemental branch-coverage tests for auth guards. + * Covers: x-forwarded-for array header, request.url fallback, + * OptionalJwtAuthGuard handleRequest method. + */ +import { UnauthorizedException } from '@nestjs/common'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MfaEnrollmentOnlyGuard } from '../guards/mfa-enrollment-only.guard'; +import { MfaRecentlyVerifiedGuard } from '../guards/mfa-recently-verified.guard'; +import { + REQUIRE_MFA_RECENT_KEY, +} from '../decorators/require-mfa-recent.decorator'; +import { OptionalJwtAuthGuard } from '../guards/optional-jwt-auth.guard'; + +describe('MfaEnrollmentOnlyGuard — branch coverage supplements', () => { + let guard: MfaEnrollmentOnlyGuard; + let mockLogger: { warn: ReturnType }; + + beforeEach(() => { + mockLogger = { warn: vi.fn() }; + guard = new MfaEnrollmentOnlyGuard(mockLogger as any); + }); + + it('uses request.url as fallback when request.path is undefined', () => { + const ctx = { + getType: () => 'http', + switchToHttp: () => ({ + getRequest: () => ({ + user: { sub: 'u1', mfa: 'enrollment_required' }, + // no path property + url: '/listings?page=1', + ip: '10.0.0.1', + headers: {}, + }), + }), + getHandler: () => ({ name: 'h' }), + getClass: () => ({ name: 'C' }), + } as any; + + expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException); + }); + + it('handles x-forwarded-for as array (picks first element for ip)', () => { + const ctx = { + getType: () => 'http', + switchToHttp: () => ({ + getRequest: () => ({ + user: { sub: 'u1', mfa: 'enrollment_required' }, + path: '/listings', + // no ip, array forwarded-for + headers: { 'x-forwarded-for': ['203.0.113.1', '10.0.0.1'] }, + }), + }), + getHandler: () => ({ name: 'h' }), + getClass: () => ({ name: 'C' }), + } as any; + + try { + guard.canActivate(ctx); + } catch (err) { + expect(err).toBeInstanceOf(UnauthorizedException); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('MFA enrollment required'), + 'MfaEnrollmentOnlyGuard', + ); + } + }); + + it('falls back to "unknown" ip when no ip or headers', () => { + const ctx = { + getType: () => 'http', + switchToHttp: () => ({ + getRequest: () => ({ + user: { sub: 'u1', mfa: 'enrollment_required' }, + path: '/some/endpoint', + }), + }), + getHandler: () => ({}), + getClass: () => ({}), + } as any; + + expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('unknown'), + 'MfaEnrollmentOnlyGuard', + ); + }); +}); + +describe('MfaRecentlyVerifiedGuard — branch coverage supplements', () => { + const NOW_SEC = 1_700_000_000; + let guard: MfaRecentlyVerifiedGuard; + let mockReflector: { getAllAndOverride: ReturnType }; + let mockLogger: { warn: ReturnType }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW_SEC * 1000)); + mockReflector = { getAllAndOverride: vi.fn() }; + mockLogger = { warn: vi.fn() }; + guard = new MfaRecentlyVerifiedGuard(mockReflector as any, mockLogger as any); + }); + + afterEach(() => vi.useRealTimers()); + + it('uses request.url fallback when path is missing', () => { + mockReflector.getAllAndOverride.mockReturnValue({ windowSeconds: 300 }); + const ctx = { + getType: () => 'http', + switchToHttp: () => ({ + getRequest: () => ({ + user: { sub: 'u1', mfa: 'none' }, + url: '/admin/settings', + headers: {}, + }), + }), + getHandler: () => ({ name: 'h' }), + getClass: () => ({ name: 'C' }), + } as any; + + expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException); + }); + + it('handles x-forwarded-for as string (single ip)', () => { + mockReflector.getAllAndOverride.mockReturnValue({ windowSeconds: 300 }); + const ctx = { + getType: () => 'http', + switchToHttp: () => ({ + getRequest: () => ({ + user: { sub: 'u1', mfa: 'enrollment_required' }, + path: '/admin/sensitive', + headers: { 'x-forwarded-for': '203.0.113.1' }, + }), + }), + getHandler: () => ({ name: 'h' }), + getClass: () => ({ name: 'C' }), + } as any; + + expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException); + expect(mockLogger.warn).toHaveBeenCalled(); + }); +}); + +describe('OptionalJwtAuthGuard — handleRequest returns user as-is', () => { + it('handleRequest returns user when user is provided', () => { + // Test the handleRequest override directly — it should pass through + const guard = new OptionalJwtAuthGuard(); + const fakeUser = { sub: 'user-1', role: 'BUYER' }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (guard as any).handleRequest(null, fakeUser); + expect(result).toBe(fakeUser); + }); + + it('handleRequest returns undefined when user is falsy (anonymous)', () => { + const guard = new OptionalJwtAuthGuard(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (guard as any).handleRequest(null, undefined); + expect(result).toBeUndefined(); + }); + + it('handleRequest returns false for unauthenticated passport result', () => { + const guard = new OptionalJwtAuthGuard(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (guard as any).handleRequest(null, false); + expect(result).toBe(false); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/__tests__/mfa-enrollment-only.guard.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/mfa-enrollment-only.guard.spec.ts new file mode 100644 index 0000000..346117f --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/mfa-enrollment-only.guard.spec.ts @@ -0,0 +1,142 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MfaEnrollmentOnlyGuard } from '../guards/mfa-enrollment-only.guard'; + +describe('MfaEnrollmentOnlyGuard', () => { + let guard: MfaEnrollmentOnlyGuard; + let mockLogger: { warn: ReturnType }; + + const createMockContext = ( + user: { sub: string; mfa?: string } | undefined, + path: string, + ) => { + const mockRequest = { + user, + path, + ip: '127.0.0.1', + headers: {}, + }; + return { + getType: () => 'http', + switchToHttp: () => ({ getRequest: () => mockRequest }), + getHandler: () => ({ name: 'h' }), + getClass: () => ({ name: 'C' }), + } as any; + }; + + beforeEach(() => { + mockLogger = { warn: vi.fn() }; + guard = new MfaEnrollmentOnlyGuard(mockLogger as any); + }); + + it('allows requests when no user is present', () => { + expect(guard.canActivate(createMockContext(undefined, '/anything'))).toBe( + true, + ); + }); + + it('allows requests when mfa claim is verified', () => { + expect( + guard.canActivate( + createMockContext({ sub: 'u1', mfa: 'verified' }, '/listings'), + ), + ).toBe(true); + }); + + it('allows requests when mfa claim is none/grace', () => { + expect( + guard.canActivate(createMockContext({ sub: 'u1', mfa: 'none' }, '/x')), + ).toBe(true); + expect( + guard.canActivate(createMockContext({ sub: 'u1', mfa: 'grace' }, '/x')), + ).toBe(true); + }); + + it('allows enrollment-required tokens to hit /auth/mfa/enroll', () => { + expect( + guard.canActivate( + createMockContext( + { sub: 'u1', mfa: 'enrollment_required' }, + '/auth/mfa/enroll', + ), + ), + ).toBe(true); + expect( + guard.canActivate( + createMockContext( + { sub: 'u1', mfa: 'enrollment_required' }, + '/auth/mfa/enroll/confirm', + ), + ), + ).toBe(true); + }); + + it('allows enrollment-required tokens to hit existing setup endpoints', () => { + expect( + guard.canActivate( + createMockContext( + { sub: 'u1', mfa: 'enrollment_required' }, + '/auth/mfa/setup', + ), + ), + ).toBe(true); + expect( + guard.canActivate( + createMockContext( + { sub: 'u1', mfa: 'enrollment_required' }, + '/auth/mfa/verify-setup', + ), + ), + ).toBe(true); + }); + + it('allows enrollment-required tokens to logout', () => { + expect( + guard.canActivate( + createMockContext( + { sub: 'u1', mfa: 'enrollment_required' }, + '/auth/logout', + ), + ), + ).toBe(true); + }); + + it('blocks enrollment-required tokens hitting non-enrollment endpoints with 401 mfa_step_up_required', () => { + expect.assertions(3); + try { + guard.canActivate( + createMockContext( + { sub: 'u1', mfa: 'enrollment_required' }, + '/listings', + ), + ); + } catch (err) { + expect(err).toBeInstanceOf(UnauthorizedException); + expect((err as UnauthorizedException).getResponse()).toEqual({ + error: 'mfa_step_up_required', + }); + expect(mockLogger.warn).toHaveBeenCalled(); + } + }); + + it('strips query strings before matching allowlist', () => { + expect( + guard.canActivate( + createMockContext( + { sub: 'u1', mfa: 'enrollment_required' }, + '/auth/mfa/enroll?foo=bar', + ), + ), + ).toBe(true); + }); + + it('returns true for non-http contexts (rpc/ws)', () => { + const ctx = { + getType: () => 'rpc', + switchToHttp: () => ({ getRequest: () => ({}) }), + getHandler: () => ({}), + getClass: () => ({}), + } as any; + expect(guard.canActivate(ctx)).toBe(true); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/__tests__/mfa-recently-verified.guard.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/mfa-recently-verified.guard.spec.ts new file mode 100644 index 0000000..cb2732b --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/mfa-recently-verified.guard.spec.ts @@ -0,0 +1,180 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + REQUIRE_MFA_RECENT_KEY, + DEFAULT_MFA_RECENT_WINDOW_SECONDS, +} from '../decorators/require-mfa-recent.decorator'; +import { MfaRecentlyVerifiedGuard } from '../guards/mfa-recently-verified.guard'; + +describe('MfaRecentlyVerifiedGuard', () => { + let guard: MfaRecentlyVerifiedGuard; + let mockReflector: { getAllAndOverride: ReturnType }; + let mockLogger: { warn: ReturnType }; + const NOW_SEC = 1_700_000_000; + + const createMockContext = ( + user: + | { sub: string; mfa?: string; mfaAt?: number } + | undefined, + path = '/admin/something', + ) => { + const mockRequest = { + user, + path, + ip: '127.0.0.1', + headers: {}, + }; + return { + getType: () => 'http', + switchToHttp: () => ({ getRequest: () => mockRequest }), + getHandler: () => ({ name: 'h' }), + getClass: () => ({ name: 'C' }), + } as any; + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW_SEC * 1000)); + mockReflector = { getAllAndOverride: vi.fn() }; + mockLogger = { warn: vi.fn() }; + guard = new MfaRecentlyVerifiedGuard( + mockReflector as any, + mockLogger as any, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('passes through when route has no @RequireMfaRecent metadata', () => { + mockReflector.getAllAndOverride.mockReturnValue(undefined); + expect( + guard.canActivate(createMockContext({ sub: 'u1', mfa: 'none' })), + ).toBe(true); + }); + + it('reads metadata from handler and class', () => { + mockReflector.getAllAndOverride.mockReturnValue(undefined); + guard.canActivate(createMockContext({ sub: 'u1' })); + expect(mockReflector.getAllAndOverride).toHaveBeenCalledWith( + REQUIRE_MFA_RECENT_KEY, + [ + expect.objectContaining({ name: 'h' }), + expect.objectContaining({ name: 'C' }), + ], + ); + }); + + it('allows when mfa=verified and mfaAt is within window', () => { + mockReflector.getAllAndOverride.mockReturnValue({ windowSeconds: 300 }); + expect( + guard.canActivate( + createMockContext({ sub: 'u1', mfa: 'verified', mfaAt: NOW_SEC - 60 }), + ), + ).toBe(true); + }); + + it('rejects when mfa=verified but mfaAt is older than window', () => { + mockReflector.getAllAndOverride.mockReturnValue({ windowSeconds: 300 }); + expect.assertions(2); + try { + guard.canActivate( + createMockContext({ sub: 'u1', mfa: 'verified', mfaAt: NOW_SEC - 600 }), + ); + } catch (err) { + expect(err).toBeInstanceOf(UnauthorizedException); + expect((err as UnauthorizedException).getResponse()).toEqual({ + error: 'mfa_step_up_required', + }); + } + }); + + it('rejects when mfa is not verified', () => { + mockReflector.getAllAndOverride.mockReturnValue({ windowSeconds: 300 }); + expect.assertions(1); + try { + guard.canActivate( + createMockContext({ sub: 'u1', mfa: 'none', mfaAt: NOW_SEC }), + ); + } catch (err) { + expect((err as UnauthorizedException).getResponse()).toEqual({ + error: 'mfa_step_up_required', + }); + } + }); + + it('rejects when mfaAt is missing', () => { + mockReflector.getAllAndOverride.mockReturnValue({ windowSeconds: 300 }); + expect(() => + guard.canActivate(createMockContext({ sub: 'u1', mfa: 'verified' })), + ).toThrow(UnauthorizedException); + }); + + it('rejects when no user is on request (fail-closed)', () => { + mockReflector.getAllAndOverride.mockReturnValue({ windowSeconds: 300 }); + expect(() => guard.canActivate(createMockContext(undefined))).toThrow( + UnauthorizedException, + ); + }); + + it('honors custom window seconds', () => { + mockReflector.getAllAndOverride.mockReturnValue({ windowSeconds: 60 }); + expect(() => + guard.canActivate( + createMockContext({ sub: 'u1', mfa: 'verified', mfaAt: NOW_SEC - 90 }), + ), + ).toThrow(UnauthorizedException); + expect( + guard.canActivate( + createMockContext({ sub: 'u1', mfa: 'verified', mfaAt: NOW_SEC - 30 }), + ), + ).toBe(true); + }); + + it('falls back to default window when metadata omits windowSeconds', () => { + mockReflector.getAllAndOverride.mockReturnValue({}); + expect( + guard.canActivate( + createMockContext({ + sub: 'u1', + mfa: 'verified', + mfaAt: NOW_SEC - (DEFAULT_MFA_RECENT_WINDOW_SECONDS - 1), + }), + ), + ).toBe(true); + expect(() => + guard.canActivate( + createMockContext({ + sub: 'u1', + mfa: 'verified', + mfaAt: NOW_SEC - (DEFAULT_MFA_RECENT_WINDOW_SECONDS + 1), + }), + ), + ).toThrow(UnauthorizedException); + }); + + it('logs a warning on rejection', () => { + mockReflector.getAllAndOverride.mockReturnValue({ windowSeconds: 300 }); + try { + guard.canActivate(createMockContext({ sub: 'u1', mfa: 'none' })); + } catch { + /* expected */ + } + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('MFA step-up required'), + 'MfaRecentlyVerifiedGuard', + ); + }); + + it('returns true for non-http contexts', () => { + mockReflector.getAllAndOverride.mockReturnValue({ windowSeconds: 300 }); + const ctx = { + getType: () => 'rpc', + switchToHttp: () => ({ getRequest: () => ({}) }), + getHandler: () => ({}), + getClass: () => ({}), + } as any; + expect(guard.canActivate(ctx)).toBe(true); + }); +}); diff --git a/apps/api/src/modules/auth/presentation/decorators/index.ts b/apps/api/src/modules/auth/presentation/decorators/index.ts index 48d685f..4866143 100644 --- a/apps/api/src/modules/auth/presentation/decorators/index.ts +++ b/apps/api/src/modules/auth/presentation/decorators/index.ts @@ -1,2 +1,8 @@ export { Roles, ROLES_KEY } from './roles.decorator'; export { CurrentUser } from './current-user.decorator'; +export { + RequireMfaRecent, + REQUIRE_MFA_RECENT_KEY, + DEFAULT_MFA_RECENT_WINDOW_SECONDS, + type RequireMfaRecentOptions, +} from './require-mfa-recent.decorator'; diff --git a/apps/api/src/modules/auth/presentation/decorators/require-mfa-recent.decorator.ts b/apps/api/src/modules/auth/presentation/decorators/require-mfa-recent.decorator.ts new file mode 100644 index 0000000..2ac180a --- /dev/null +++ b/apps/api/src/modules/auth/presentation/decorators/require-mfa-recent.decorator.ts @@ -0,0 +1,30 @@ +import { SetMetadata } from '@nestjs/common'; + +export const REQUIRE_MFA_RECENT_KEY = 'require_mfa_recent'; + +/** + * Default freshness window for MFA verification (seconds). + * 5 minutes per spec for high-risk operations. + */ +export const DEFAULT_MFA_RECENT_WINDOW_SECONDS = 5 * 60; + +export interface RequireMfaRecentOptions { + /** + * Maximum age (in seconds) of the most recent MFA verification. + * Defaults to 5 minutes. + */ + windowSeconds?: number; +} + +/** + * Marks a route handler (or controller) as requiring a recently-verified MFA + * step. The {@link MfaRecentlyVerifiedGuard} reads this metadata and rejects + * tokens whose `mfa` claim is not `verified` or whose `mfaAt` claim is older + * than `windowSeconds`. + * + * Failure response: `401 { error: 'mfa_step_up_required' }`. + */ +export const RequireMfaRecent = (options: RequireMfaRecentOptions = {}) => + SetMetadata(REQUIRE_MFA_RECENT_KEY, { + windowSeconds: options.windowSeconds ?? DEFAULT_MFA_RECENT_WINDOW_SECONDS, + } satisfies Required); diff --git a/apps/api/src/modules/auth/presentation/guards/index.ts b/apps/api/src/modules/auth/presentation/guards/index.ts index 8be26b6..c781887 100644 --- a/apps/api/src/modules/auth/presentation/guards/index.ts +++ b/apps/api/src/modules/auth/presentation/guards/index.ts @@ -1,3 +1,5 @@ export { JwtAuthGuard } from './jwt-auth.guard'; export { LocalAuthGuard } from './local-auth.guard'; export { RolesGuard } from './roles.guard'; +export { MfaEnrollmentOnlyGuard } from './mfa-enrollment-only.guard'; +export { MfaRecentlyVerifiedGuard } from './mfa-recently-verified.guard'; diff --git a/apps/api/src/modules/auth/presentation/guards/mfa-enrollment-only.guard.ts b/apps/api/src/modules/auth/presentation/guards/mfa-enrollment-only.guard.ts new file mode 100644 index 0000000..7e021ac --- /dev/null +++ b/apps/api/src/modules/auth/presentation/guards/mfa-enrollment-only.guard.ts @@ -0,0 +1,93 @@ +import { + Injectable, + UnauthorizedException, + type CanActivate, + type ExecutionContext, +} from '@nestjs/common'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata +import { LoggerService } from '@modules/shared'; +import type { JwtPayload } from '../../infrastructure/services/token.service'; + +/** + * MFA-aware view of the JwtPayload. The `mfa` and `mfaAt` claims are added + * by the auth/MFA flow (see GOO-230) and may not yet be present in older + * tokens; this guard treats missing values as "no MFA enforcement". + */ +type MfaJwtPayload = JwtPayload & { + mfa?: 'verified' | 'enrollment_required' | 'grace' | 'none'; + mfaAt?: number; +}; + +/** + * Allowlist of path prefixes (matched against `req.path`) that callers with an + * `enrollment_required` MFA claim are permitted to hit. Everything else must be + * blocked with 401 `mfa_step_up_required` until the user enrolls and obtains + * a token whose `mfa` claim is `verified` (or `none`/`grace`). + * + * Both `/auth/mfa/enroll` and the existing `/auth/mfa/setup` + `verify-setup` + * endpoints are allowed, since enrollment is the express purpose of this state. + */ +const MFA_ENROLLMENT_ALLOWED_PREFIXES = [ + '/auth/mfa/enroll', + '/auth/mfa/setup', + '/auth/mfa/verify-setup', + '/auth/logout', +] as const; + +/** + * Blocks all requests except MFA enrollment endpoints when the access token's + * `mfa` claim is `enrollment_required`. Apply globally (or to any module that + * sits behind `JwtAuthGuard`) so an enrollment-required token cannot be used + * to call business endpoints. + * + * Failure response: `401 { error: 'mfa_step_up_required' }`. + */ +@Injectable() +export class MfaEnrollmentOnlyGuard implements CanActivate { + constructor(private readonly logger: LoggerService) {} + + canActivate(context: ExecutionContext): boolean { + if (context.getType() !== 'http') { + return true; + } + + const request = context.switchToHttp().getRequest<{ + user?: MfaJwtPayload; + path?: string; + url?: string; + ip?: string; + headers?: Record; + }>(); + + const user = request.user; + if (!user || user.mfa !== 'enrollment_required') { + // No JWT or token is not in the enrollment-required state — defer to + // other guards (JwtAuthGuard, RolesGuard, recently-verified guard). + return true; + } + + const path = (request.path ?? request.url ?? '').split('?')[0] ?? ''; + const allowed = MFA_ENROLLMENT_ALLOWED_PREFIXES.some((prefix) => + path.startsWith(prefix), + ); + + if (allowed) { + return true; + } + + const ip = + request.ip || + (Array.isArray(request.headers?.['x-forwarded-for']) + ? request.headers?.['x-forwarded-for']?.[0] + : (request.headers?.['x-forwarded-for'] as string | undefined)) || + 'unknown'; + + this.logger.warn( + `MFA enrollment required but caller hit non-enrollment endpoint: ` + + `userId=${user.sub}, path=${path}, ip=${ip}`, + 'MfaEnrollmentOnlyGuard', + ); + + throw new UnauthorizedException({ error: 'mfa_step_up_required' }); + } +} diff --git a/apps/api/src/modules/auth/presentation/guards/mfa-recently-verified.guard.ts b/apps/api/src/modules/auth/presentation/guards/mfa-recently-verified.guard.ts new file mode 100644 index 0000000..d3a8c8d --- /dev/null +++ b/apps/api/src/modules/auth/presentation/guards/mfa-recently-verified.guard.ts @@ -0,0 +1,108 @@ +import { + Injectable, + UnauthorizedException, + type CanActivate, + type ExecutionContext, +} from '@nestjs/common'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata +import { Reflector } from '@nestjs/core'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata +import { LoggerService } from '@modules/shared'; +import type { JwtPayload } from '../../infrastructure/services/token.service'; +import { + REQUIRE_MFA_RECENT_KEY, + DEFAULT_MFA_RECENT_WINDOW_SECONDS, + type RequireMfaRecentOptions, +} from '../decorators/require-mfa-recent.decorator'; + +/** + * MFA-aware view of the JwtPayload. The `mfa` and `mfaAt` claims are added + * by the auth/MFA flow (see GOO-230). Tokens that predate that change have + * neither value, in which case this guard treats the caller as not recently + * verified. + */ +type MfaJwtPayload = JwtPayload & { + mfa?: 'verified' | 'enrollment_required' | 'grace' | 'none'; + mfaAt?: number; +}; + +/** + * Guard that enforces a recent MFA verification window for routes decorated + * with {@link RequireMfaRecent}. If the route (or its containing controller) + * carries the `require_mfa_recent` metadata, the caller must: + * + * 1. Be authenticated (have `req.user`). + * 2. Have `mfa === 'verified'` on the access token. + * 3. Have `mfaAt` within `windowSeconds` of `now` (default 5 minutes). + * + * Otherwise responds with `401 { error: 'mfa_step_up_required' }` so clients + * can trigger an MFA step-up and retry. + */ +@Injectable() +export class MfaRecentlyVerifiedGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly logger: LoggerService, + ) {} + + canActivate(context: ExecutionContext): boolean { + if (context.getType() !== 'http') { + return true; + } + + const options = this.reflector.getAllAndOverride< + Required | undefined + >(REQUIRE_MFA_RECENT_KEY, [context.getHandler(), context.getClass()]); + + if (!options) { + return true; + } + + const windowSeconds = + options.windowSeconds ?? DEFAULT_MFA_RECENT_WINDOW_SECONDS; + + const request = context.switchToHttp().getRequest<{ + user?: MfaJwtPayload; + path?: string; + url?: string; + ip?: string; + headers?: Record; + }>(); + + const user = request.user; + + // No user → let JwtAuthGuard handle it elsewhere; but if this guard runs + // without a user in-context, fail closed with step-up required. + if (!user) { + throw new UnauthorizedException({ error: 'mfa_step_up_required' }); + } + + const mfaAtSec = typeof user.mfaAt === 'number' ? user.mfaAt : null; + const nowSec = Math.floor(Date.now() / 1000); + const fresh = + user.mfa === 'verified' && + mfaAtSec !== null && + nowSec - mfaAtSec <= windowSeconds; + + if (fresh) { + return true; + } + + const path = (request.path ?? request.url ?? '').split('?')[0] ?? ''; + const ip = + request.ip || + (Array.isArray(request.headers?.['x-forwarded-for']) + ? request.headers?.['x-forwarded-for']?.[0] + : (request.headers?.['x-forwarded-for'] as string | undefined)) || + 'unknown'; + + this.logger.warn( + `MFA step-up required: userId=${user.sub}, path=${path}, ip=${ip}, ` + + `mfa=${user.mfa ?? 'none'}, mfaAt=${mfaAtSec ?? 'null'}, ` + + `windowSeconds=${windowSeconds}`, + 'MfaRecentlyVerifiedGuard', + ); + + throw new UnauthorizedException({ error: 'mfa_step_up_required' }); + } +} diff --git a/apps/api/src/modules/payments/application/__tests__/payments-branch-coverage.spec.ts b/apps/api/src/modules/payments/application/__tests__/payments-branch-coverage.spec.ts new file mode 100644 index 0000000..d230b4c --- /dev/null +++ b/apps/api/src/modules/payments/application/__tests__/payments-branch-coverage.spec.ts @@ -0,0 +1,126 @@ +/** + * Supplemental branch-coverage tests for payments handlers. + * Covers gateway error path, InternalServerErrorException wrap, + * and refund handler edge cases. + */ +import { InternalServerErrorException } from '@nestjs/common'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { type IPaymentRepository } from '../../domain/repositories/payment.repository'; +import { CreatePaymentCommand } from '../commands/create-payment/create-payment.command'; +import { CreatePaymentHandler } from '../commands/create-payment/create-payment.handler'; +import { RefundPaymentCommand } from '../commands/refund-payment/refund-payment.command'; +import { RefundPaymentHandler } from '../commands/refund-payment/refund-payment.handler'; +import { PaymentEntity } from '../../domain/entities/payment.entity'; +import { Money } from '../../domain/value-objects/money.vo'; + +function makeCompletedPayment(): PaymentEntity { + const money = Money.create(500_000n).unwrap(); + const p = PaymentEntity.createNew('pay-1', 'user-1', 'VNPAY', 'LISTING_FEE', money, undefined, undefined); + p.markProcessing('vnpay-tx-1'); + p.markCompleted({ resultCode: '00' }); + p.clearDomainEvents(); + return p; +} + +function makePendingPayment(): PaymentEntity { + const money = Money.create(500_000n).unwrap(); + const p = PaymentEntity.createNew('pay-2', 'user-1', 'VNPAY', 'LISTING_FEE', money); + p.clearDomainEvents(); + return p; +} + +function makeRepo(): { [K in keyof IPaymentRepository]: ReturnType } { + return { + findById: vi.fn(), + findByProviderTxId: vi.fn(), + findByIdempotencyKey: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + updateIfStatus: vi.fn(), + }; +} + +describe('CreatePaymentHandler — branch coverage supplements', () => { + let handler: CreatePaymentHandler; + let mockRepo: ReturnType; + let mockGatewayFactory: { getGateway: ReturnType }; + let mockGateway: { createPaymentUrl: ReturnType; verifyCallback: ReturnType; refund: ReturnType }; + let mockEventBus: { publish: ReturnType }; + let mockLogger: any; + + beforeEach(() => { + mockRepo = makeRepo(); + mockGateway = { + createPaymentUrl: vi.fn().mockResolvedValue({ paymentUrl: 'https://pay.vn/1', providerTxId: 'tx-1' }), + verifyCallback: vi.fn(), + refund: vi.fn(), + }; + mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) }; + mockEventBus = { publish: vi.fn() }; + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + handler = new CreatePaymentHandler(mockRepo as any, mockGatewayFactory as any, mockEventBus as any, mockLogger); + }); + + const makeCmd = () => new CreatePaymentCommand( + 'user-1', 'VNPAY', 'LISTING_FEE', 500_000n, 'Thanh toán phí đăng tin', + 'https://return.url', '127.0.0.1', + ); + + it('throws ValidationException when gateway createPaymentUrl throws', async () => { + mockRepo.findByIdempotencyKey.mockResolvedValue(null); + mockGateway.createPaymentUrl.mockRejectedValue(new Error('Gateway timeout')); + + const { ValidationException } = await import('@modules/shared'); + await expect(handler.execute(makeCmd())).rejects.toBeInstanceOf(ValidationException); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('wraps unexpected repo.save error in InternalServerErrorException', async () => { + mockRepo.findByIdempotencyKey.mockResolvedValue(null); + mockRepo.save.mockRejectedValue(new Error('DB write failed')); + + await expect(handler.execute(makeCmd())).rejects.toBeInstanceOf(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); + +describe('RefundPaymentHandler — branch coverage supplements', () => { + let handler: RefundPaymentHandler; + let mockRepo: ReturnType; + let mockGatewayFactory: { getGateway: ReturnType }; + let mockGateway: { createPaymentUrl: ReturnType; verifyCallback: ReturnType; refund: ReturnType }; + let mockEventBus: { publish: ReturnType }; + let mockLogger: any; + + beforeEach(() => { + mockRepo = makeRepo(); + mockGateway = { + createPaymentUrl: vi.fn(), + verifyCallback: vi.fn(), + refund: vi.fn().mockResolvedValue({ success: true, refundTxId: 'ref-tx-1' }), + }; + mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) }; + mockEventBus = { publish: vi.fn() }; + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + handler = new RefundPaymentHandler(mockRepo as any, mockGatewayFactory as any, mockEventBus as any, mockLogger); + }); + + it('throws NotFoundException when payment not found', async () => { + mockRepo.findById.mockResolvedValue(null); + const { NotFoundException } = await import('@modules/shared'); + + await expect( + handler.execute(new RefundPaymentCommand('missing-id', 'duplicate', 'user-1')), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws ValidationException when trying to refund non-completed payment', async () => { + mockRepo.findById.mockResolvedValue(makePendingPayment()); + const { ValidationException } = await import('@modules/shared'); + + await expect( + handler.execute(new RefundPaymentCommand('pay-2', 'error', 'user-1')), + ).rejects.toBeInstanceOf(ValidationException); + }); +}); diff --git a/apps/api/src/modules/subscriptions/application/__tests__/subscription-handlers-branch-coverage.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/subscription-handlers-branch-coverage.spec.ts new file mode 100644 index 0000000..0994b39 --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/__tests__/subscription-handlers-branch-coverage.spec.ts @@ -0,0 +1,196 @@ +/** + * Supplemental branch-coverage tests for subscription application handlers. + * Targets the uncovered `catch (non-DomainException)` → InternalServerErrorException + * paths and plan-field=null branches that were missed by the primary spec files. + */ +import { InternalServerErrorException } from '@nestjs/common'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SubscriptionEntity } from '../../domain/entities/subscription.entity'; +import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository'; +import { CheckQuotaHandler } from '../queries/check-quota/check-quota.handler'; +import { CheckQuotaQuery } from '../queries/check-quota/check-quota.query'; +import { MeterUsageHandler } from '../commands/meter-usage/meter-usage.handler'; +import { MeterUsageCommand } from '../commands/meter-usage/meter-usage.command'; +import { UpgradeSubscriptionHandler } from '../commands/upgrade-subscription/upgrade-subscription.handler'; +import { UpgradeSubscriptionCommand } from '../commands/upgrade-subscription/upgrade-subscription.command'; +import { CancelSubscriptionHandler } from '../commands/cancel-subscription/cancel-subscription.handler'; +import { CancelSubscriptionCommand } from '../commands/cancel-subscription/cancel-subscription.command'; + +function makeActiveSub(): SubscriptionEntity { + const sub = SubscriptionEntity.createNew( + 'sub-1', 'user-1', 'plan-1', 'AGENT_PRO', + new Date('2026-01-01'), new Date('2026-02-01'), + ); + sub.clearDomainEvents(); + return sub; +} + +function makeRepo(): { [K in keyof ISubscriptionRepository]: ReturnType } { + return { + findById: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn(), + update: vi.fn(), + }; +} + +function makeLogger() { + return { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; +} + +// ── CheckQuotaHandler ───────────────────────────────────────────────────────── + +describe('CheckQuotaHandler — branch coverage supplements', () => { + let handler: CheckQuotaHandler; + let mockRepo: ReturnType; + let mockPrisma: any; + let mockCache: { getOrSet: ReturnType; invalidate: ReturnType; invalidateByPrefix: ReturnType }; + let mockLogger: ReturnType; + + beforeEach(() => { + mockRepo = makeRepo(); + mockPrisma = { + plan: { findFirst: vi.fn(), findUnique: vi.fn() }, + usageRecord: { findFirst: vi.fn(), findUnique: vi.fn() }, + }; + mockCache = { + getOrSet: vi.fn().mockImplementation((_k: string, fn: () => Promise) => fn()), + invalidate: vi.fn().mockResolvedValue(undefined), + invalidateByPrefix: vi.fn().mockResolvedValue(undefined), + }; + mockLogger = makeLogger(); + handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any, mockLogger as any); + }); + + it('returns unlimited when known plan field has null value (e.g. unlimited savedSearches)', async () => { + const sub = makeActiveSub(); + mockRepo.findByUserId.mockResolvedValue(sub); + // maxSavedSearches = null means unlimited + mockPrisma.plan.findUnique.mockResolvedValue({ id: 'plan-1', maxSavedSearches: null }); + + const result = await handler.execute(new CheckQuotaQuery('user-1', 'searches_saved')); + + expect(result.limit).toBeNull(); + expect(result.allowed).toBe(true); + expect(result.remaining).toBeNull(); + }); + + it('propagates error from cache.getOrSet when loadQuota throws non-domain error', async () => { + // The handler returns (not awaits) cache.getOrSet, so the rejection propagates as-is + // when a raw Error is thrown inside the fn. We test this via the loadQuota inner path. + mockRepo.findByUserId.mockRejectedValue(new Error('DB crash')); + // cache.getOrSet calls the inner fn and propagates + mockCache.getOrSet.mockImplementation(async (_k: string, fn: () => Promise) => fn()); + + await expect(handler.execute(new CheckQuotaQuery('user-1', 'listings_created'))) + .rejects.toThrow('DB crash'); + }); + + it('re-throws DomainException directly without wrapping', async () => { + const { NotFoundException } = await import('@modules/shared'); + mockCache.getOrSet.mockImplementationOnce(async (_k: string, fn: () => Promise) => { + throw new NotFoundException('Plan', 'plan-missing'); + }); + + await expect(handler.execute(new CheckQuotaQuery('user-1', 'listings_created'))) + .rejects.toBeInstanceOf(NotFoundException); + }); +}); + +// ── MeterUsageHandler ───────────────────────────────────────────────────────── + +describe('MeterUsageHandler — branch coverage supplements', () => { + let handler: MeterUsageHandler; + let mockRepo: ReturnType; + let mockPrisma: any; + let mockCache: any; + let mockLogger: ReturnType; + + beforeEach(() => { + mockRepo = makeRepo(); + mockPrisma = { usageRecord: { upsert: vi.fn() } }; + mockCache = { getOrSet: vi.fn(), invalidate: vi.fn().mockResolvedValue(undefined), invalidateByPrefix: vi.fn() }; + mockLogger = makeLogger(); + handler = new MeterUsageHandler(mockRepo as any, mockPrisma, mockCache as any, mockLogger as any); + }); + + it('wraps unexpected repo error in InternalServerErrorException', async () => { + const sub = makeActiveSub(); + mockRepo.findByUserId.mockResolvedValue(sub); + mockPrisma.usageRecord.upsert.mockRejectedValue(new Error('DB unavailable')); + + await expect(handler.execute(new MeterUsageCommand('user-1', 'listings_created', 1))) + .rejects.toBeInstanceOf(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); + +// ── UpgradeSubscriptionHandler ──────────────────────────────────────────────── + +describe('UpgradeSubscriptionHandler — branch coverage supplements', () => { + let handler: UpgradeSubscriptionHandler; + let mockRepo: ReturnType; + let mockPrisma: any; + let mockEventBus: { publish: ReturnType }; + let mockCache: any; + let mockLogger: ReturnType; + + beforeEach(() => { + mockRepo = makeRepo(); + mockPrisma = { plan: { findFirst: vi.fn() } }; + mockEventBus = { publish: vi.fn() }; + mockCache = { invalidateByPrefix: vi.fn().mockResolvedValue(undefined) }; + mockLogger = makeLogger(); + handler = new UpgradeSubscriptionHandler( + mockRepo as any, mockPrisma, mockEventBus as any, mockCache as any, mockLogger as any, + ); + }); + + it('wraps unexpected error in InternalServerErrorException', async () => { + mockRepo.findByUserId.mockRejectedValue(new Error('Connection refused')); + + await expect( + handler.execute(new UpgradeSubscriptionCommand('user-1', 'ENTERPRISE' as any)), + ).rejects.toBeInstanceOf(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('allows downgrade from AGENT_PRO to INVESTOR (same tier order level)', async () => { + const sub = makeActiveSub(); // planTier = AGENT_PRO + mockRepo.findByUserId.mockResolvedValue(sub); + mockPrisma.plan.findFirst.mockResolvedValue({ id: 'plan-investor', tier: 'INVESTOR' }); + mockRepo.update.mockResolvedValue(undefined); + + const result = await handler.execute( + new UpgradeSubscriptionCommand('user-1', 'INVESTOR' as any), + ); + + expect(result.newTier).toBe('INVESTOR'); + expect(result.previousTier).toBe('AGENT_PRO'); + }); +}); + +// ── CancelSubscriptionHandler ───────────────────────────────────────────────── + +describe('CancelSubscriptionHandler — branch coverage supplements', () => { + let handler: CancelSubscriptionHandler; + let mockRepo: ReturnType; + let mockEventBus: { publish: ReturnType }; + let mockLogger: ReturnType; + + beforeEach(() => { + mockRepo = makeRepo(); + mockEventBus = { publish: vi.fn() }; + mockLogger = makeLogger(); + handler = new CancelSubscriptionHandler(mockRepo as any, mockEventBus as any, mockLogger as any); + }); + + it('wraps unexpected error in InternalServerErrorException', async () => { + mockRepo.findByUserId.mockRejectedValue(new Error('Network error')); + + await expect( + handler.execute(new CancelSubscriptionCommand('user-1', 'test-reason')), + ).rejects.toBeInstanceOf(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/subscriptions/domain/__tests__/subscription-entity-branch-coverage.spec.ts b/apps/api/src/modules/subscriptions/domain/__tests__/subscription-entity-branch-coverage.spec.ts new file mode 100644 index 0000000..d36cae4 --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/__tests__/subscription-entity-branch-coverage.spec.ts @@ -0,0 +1,78 @@ +/** + * Supplemental branch-coverage tests for SubscriptionEntity. + * Covers error paths in markPastDue, markExpired, and isExpired. + */ +import { describe, it, expect } from 'vitest'; +import { SubscriptionEntity } from '../entities/subscription.entity'; + +function makeSub(): SubscriptionEntity { + return SubscriptionEntity.createNew( + 'sub-1', 'user-1', 'plan-1', 'AGENT_PRO', + new Date('2026-01-01'), new Date('2026-02-01'), + ); +} + +describe('SubscriptionEntity — branch coverage supplements', () => { + it('returns error from markPastDue when already cancelled', () => { + const sub = makeSub(); + sub.cancel(); + const result = sub.markPastDue(); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('CANCELLED'); + }); + + it('returns error from markPastDue when already expired', () => { + const sub = makeSub(); + sub.markExpired(); + const result = sub.markPastDue(); + expect(result.isErr).toBe(true); + }); + + it('returns error from markExpired when already cancelled', () => { + const sub = makeSub(); + sub.cancel(); + const result = sub.markExpired(); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('CANCELLED'); + }); + + it('marks expired from PAST_DUE state successfully', () => { + const sub = makeSub(); + sub.markPastDue(); + sub.clearDomainEvents(); + const result = sub.markExpired(); + expect(result.isOk).toBe(true); + expect(sub.status).toBe('EXPIRED'); + }); + + it('isExpired returns false for subscription with future end date', () => { + const sub = makeSub(); // ends 2026-02-01, today is 2026-04-24 so it's past + // Create with a future end date + const futureSub = SubscriptionEntity.createNew( + 'sub-2', 'user-1', 'plan-1', 'FREE', + new Date(), new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + ); + expect(futureSub.isExpired()).toBe(false); + }); + + it('isExpired returns true for subscription with past end date', () => { + const pastSub = SubscriptionEntity.createNew( + 'sub-3', 'user-1', 'plan-1', 'FREE', + new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), + new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), // expired yesterday + ); + expect(pastSub.isExpired()).toBe(true); + }); + + it('isActive returns false for cancelled subscription', () => { + const sub = makeSub(); + sub.cancel(); + expect(sub.isActive()).toBe(false); + }); + + it('isActive returns false for past-due subscription', () => { + const sub = makeSub(); + sub.markPastDue(); + expect(sub.isActive()).toBe(false); + }); +}); diff --git a/apps/api/src/modules/subscriptions/infrastructure/__tests__/bank-transfer-subscription-activation.handler.spec.ts b/apps/api/src/modules/subscriptions/infrastructure/__tests__/bank-transfer-subscription-activation.handler.spec.ts new file mode 100644 index 0000000..01beed3 --- /dev/null +++ b/apps/api/src/modules/subscriptions/infrastructure/__tests__/bank-transfer-subscription-activation.handler.spec.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BankTransferConfirmedEvent } from '@modules/payments'; +import { SubscriptionEntity } from '../../domain/entities/subscription.entity'; +import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository'; +import { BankTransferSubscriptionActivationHandler } from '../event-handlers/bank-transfer-subscription-activation.handler'; + +function makeSub(periodStart: Date, periodEnd: Date): SubscriptionEntity { + const sub = SubscriptionEntity.createNew( + 'sub-1', 'user-1', 'plan-1', 'AGENT_PRO', periodStart, periodEnd, + ); + sub.clearDomainEvents(); + return sub; +} + +describe('BankTransferSubscriptionActivationHandler', () => { + let handler: BankTransferSubscriptionActivationHandler; + let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + beforeEach(() => { + mockRepo = { + findById: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + }; + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + handler = new BankTransferSubscriptionActivationHandler( + mockRepo as any, + mockLogger as any, + ); + }); + + const makeEvent = (type: 'SUBSCRIPTION' | 'LISTING_FEE'): BankTransferConfirmedEvent => + new BankTransferConfirmedEvent( + 'payment-1', 'user-1', type as any, 5_000_000n, 'admin-1', 'REF-001', + ); + + it('does nothing for non-SUBSCRIPTION payment types', async () => { + await handler.handle(makeEvent('LISTING_FEE')); + expect(mockRepo.findByUserId).not.toHaveBeenCalled(); + }); + + it('logs warning and returns when no subscription found for user', async () => { + mockRepo.findByUserId.mockResolvedValue(null); + await handler.handle(makeEvent('SUBSCRIPTION')); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('manual CS review required'), + 'BankTransferSubscriptionActivationHandler', + ); + expect(mockRepo.update).not.toHaveBeenCalled(); + }); + + it('renews period from current periodEnd when subscription is still active (end > now)', async () => { + const now = new Date(); + const futureEnd = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000); // +15 days + const start = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000); // -15 days + const sub = makeSub(start, futureEnd); + mockRepo.findByUserId.mockResolvedValue(sub); + + await handler.handle(makeEvent('SUBSCRIPTION')); + + expect(mockRepo.update).toHaveBeenCalledWith(sub); + const events = sub.domainEvents; + // renewPeriod emits subscription.renewed + expect(events.some((e) => e.eventName === 'subscription.renewed')).toBe(true); + // new period end should be ~30 days from futureEnd + expect(sub.currentPeriodEnd.getTime()).toBeGreaterThan(futureEnd.getTime()); + }); + + it('renews period from now when subscription is already expired (end <= now)', async () => { + const pastStart = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000); // -60 days + const pastEnd = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // expired 5 days ago + const sub = makeSub(pastStart, pastEnd); + mockRepo.findByUserId.mockResolvedValue(sub); + + const before = Date.now(); + await handler.handle(makeEvent('SUBSCRIPTION')); + const after = Date.now(); + + expect(mockRepo.update).toHaveBeenCalledWith(sub); + // periodStart should now be ~now (within test runtime window) + expect(sub.currentPeriodStart.getTime()).toBeGreaterThanOrEqual(before - 100); + expect(sub.currentPeriodStart.getTime()).toBeLessThanOrEqual(after + 100); + }); + + it('logs success after activation', async () => { + const now = new Date(); + const futureEnd = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000); + mockRepo.findByUserId.mockResolvedValue(makeSub(now, futureEnd)); + + await handler.handle(makeEvent('SUBSCRIPTION')); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Subscription activated via bank transfer'), + 'BankTransferSubscriptionActivationHandler', + ); + }); + + it('logs error and does not rethrow when repo.update throws', async () => { + const now = new Date(); + const futureEnd = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000); + mockRepo.findByUserId.mockResolvedValue(makeSub(now, futureEnd)); + mockRepo.update.mockRejectedValue(new Error('DB connection lost')); + + await expect(handler.handle(makeEvent('SUBSCRIPTION'))).resolves.not.toThrow(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to activate subscription on bank transfer confirmation'), + expect.any(String), + 'BankTransferSubscriptionActivationHandler', + ); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 84143bd..87c2c5e 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -10,6 +10,30 @@ export default defineConfig({ env: { BCRYPT_ROUNDS: '4', }, + coverage: { + provider: 'v8', + reporter: ['text', 'text-summary', 'html', 'lcov', 'json-summary'], + reportsDirectory: './coverage', + include: ['src/**/*.ts'], + exclude: [ + 'src/**/*.spec.ts', + 'src/**/*.integration.spec.ts', + 'src/**/__tests__/**', + 'src/**/*.module.ts', + 'src/**/*.dto.ts', + 'src/**/index.ts', + 'src/main.ts', + ], + // GOO-134: CI gate thresholds. Branches raised to 60 via GOO-180 + // (payments/sbv-compliance, subscriptions/quotas, auth/guards). + // CTO approval: 8f2b125a. + thresholds: { + statements: 70, + lines: 70, + functions: 70, + branches: 60, + }, + }, }, resolve: { alias: {