From f0f7787516b7e5643ddfe473db4e4a274c5d1329 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 24 Apr 2026 13:50:02 +0700 Subject: [PATCH] =?UTF-8?q?Revert=20"test(api):=20GOO-180=20raise=20branch?= =?UTF-8?q?=20coverage=2058=E2=86=9260=20with=20targeted=20edge-case=20tes?= =?UTF-8?q?ts"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 168bc1b657450afd67d4c8bbe8cc320a8c2b2f0a. --- .../auth-guards-branch-coverage.spec.ts | 167 --------------- .../mfa-enrollment-only.guard.spec.ts | 142 ------------- .../mfa-recently-verified.guard.spec.ts | 180 ---------------- .../auth/presentation/decorators/index.ts | 6 - .../require-mfa-recent.decorator.ts | 30 --- .../modules/auth/presentation/guards/index.ts | 2 - .../guards/mfa-enrollment-only.guard.ts | 93 --------- .../guards/mfa-recently-verified.guard.ts | 108 ---------- .../payments-branch-coverage.spec.ts | 126 ----------- ...scription-handlers-branch-coverage.spec.ts | 196 ------------------ ...ubscription-entity-branch-coverage.spec.ts | 78 ------- ...er-subscription-activation.handler.spec.ts | 113 ---------- apps/api/vitest.config.ts | 24 --- 13 files changed, 1265 deletions(-) delete mode 100644 apps/api/src/modules/auth/presentation/__tests__/auth-guards-branch-coverage.spec.ts delete mode 100644 apps/api/src/modules/auth/presentation/__tests__/mfa-enrollment-only.guard.spec.ts delete mode 100644 apps/api/src/modules/auth/presentation/__tests__/mfa-recently-verified.guard.spec.ts delete mode 100644 apps/api/src/modules/auth/presentation/decorators/require-mfa-recent.decorator.ts delete mode 100644 apps/api/src/modules/auth/presentation/guards/mfa-enrollment-only.guard.ts delete mode 100644 apps/api/src/modules/auth/presentation/guards/mfa-recently-verified.guard.ts delete mode 100644 apps/api/src/modules/payments/application/__tests__/payments-branch-coverage.spec.ts delete mode 100644 apps/api/src/modules/subscriptions/application/__tests__/subscription-handlers-branch-coverage.spec.ts delete mode 100644 apps/api/src/modules/subscriptions/domain/__tests__/subscription-entity-branch-coverage.spec.ts delete mode 100644 apps/api/src/modules/subscriptions/infrastructure/__tests__/bank-transfer-subscription-activation.handler.spec.ts 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 deleted file mode 100644 index a63b3fb..0000000 --- a/apps/api/src/modules/auth/presentation/__tests__/auth-guards-branch-coverage.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * 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 deleted file mode 100644 index 346117f..0000000 --- a/apps/api/src/modules/auth/presentation/__tests__/mfa-enrollment-only.guard.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -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 deleted file mode 100644 index cb2732b..0000000 --- a/apps/api/src/modules/auth/presentation/__tests__/mfa-recently-verified.guard.spec.ts +++ /dev/null @@ -1,180 +0,0 @@ -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 4866143..48d685f 100644 --- a/apps/api/src/modules/auth/presentation/decorators/index.ts +++ b/apps/api/src/modules/auth/presentation/decorators/index.ts @@ -1,8 +1,2 @@ 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 deleted file mode 100644 index 2ac180a..0000000 --- a/apps/api/src/modules/auth/presentation/decorators/require-mfa-recent.decorator.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 c781887..8be26b6 100644 --- a/apps/api/src/modules/auth/presentation/guards/index.ts +++ b/apps/api/src/modules/auth/presentation/guards/index.ts @@ -1,5 +1,3 @@ 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 deleted file mode 100644 index 7e021ac..0000000 --- a/apps/api/src/modules/auth/presentation/guards/mfa-enrollment-only.guard.ts +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index d3a8c8d..0000000 --- a/apps/api/src/modules/auth/presentation/guards/mfa-recently-verified.guard.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index d230b4c..0000000 --- a/apps/api/src/modules/payments/application/__tests__/payments-branch-coverage.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * 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 deleted file mode 100644 index 0994b39..0000000 --- a/apps/api/src/modules/subscriptions/application/__tests__/subscription-handlers-branch-coverage.spec.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * 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 deleted file mode 100644 index d36cae4..0000000 --- a/apps/api/src/modules/subscriptions/domain/__tests__/subscription-entity-branch-coverage.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * 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 deleted file mode 100644 index 01beed3..0000000 --- a/apps/api/src/modules/subscriptions/infrastructure/__tests__/bank-transfer-subscription-activation.handler.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 87c2c5e..84143bd 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -10,30 +10,6 @@ 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: {