import { UnauthorizedException } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; /** * `@nestjs/jwt` v12 is ESM and this repo's jest does not transform node_modules, * so the real class cannot be imported here. The test injects its own stub. */ jest.mock('@nestjs/jwt', () => ({ JwtService: class JwtService {} })); import { CustomerAuthService } from './customer-auth.service'; import { IamAccessDeniedError, IamUnavailableError, IamUserUnknownError } from '../iam/iam.service'; /** * The app's OTP login and the central identity service. * * This login has **no password**, so it never reached the identity service at * all: no central session, nothing in the security log, and a central sign-out * could not end it. These tests lock down the fix and, more importantly, the * boundaries: an identity-service problem must never keep a customer out of the * app, while a refusal (locked / deactivated / two-factor) must be honoured. */ describe('CustomerAuthService — OTP login with the identity service', () => { const MOBILE = '09120000000'; const CODE = '1234'; const CUSTOMER = { id: 'cust-1', mobile: MOBILE, isActive: true, isArchived: false }; let prisma: any; let jwt: { signAsync: jest.Mock }; let sms: { send: jest.Mock }; const challenge = async () => ({ id: 'otp-1', mobile: MOBILE, codeHash: await bcrypt.hash(CODE, 8), attempts: 0, consumedAt: null, expiresAt: new Date(Date.now() + 60_000), customer: CUSTOMER, }); const build = (iam: Record, sessions?: Record) => { const session = sessions ?? { newTokenId: jest.fn().mockReturnValue(null), record: jest.fn() }; const service = new CustomerAuthService( prisma as never, jwt as never, sms as never, { browserBootstrapUrl: () => null, ...iam } as never, session as never, ); jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined); return { service, sessions: session }; }; beforeEach(async () => { delete process.env.OTP_MASTER_CODE; process.env.JWT_CUSTOMER_SECRET = 'test-secret'; prisma = { customer: { findFirst: jest.fn().mockResolvedValue(CUSTOMER) }, customerOtp: { findFirst: jest.fn().mockResolvedValue(await challenge()), update: jest.fn().mockResolvedValue({}), create: jest.fn().mockResolvedValue({}), count: jest.fn().mockResolvedValue(0), }, adminUser: { findFirst: jest.fn().mockResolvedValue(null) }, appSession: { create: jest.fn(), findUnique: jest.fn(), updateMany: jest.fn() }, }; jwt = { signAsync: jest.fn().mockResolvedValue('signed.jwt.token') }; sms = { send: jest.fn().mockResolvedValue({ ok: true }) }; }); /* ─────────────── feature off: unchanged behaviour ─────────────── */ it('never contacts the identity service while the feature is off', async () => { const attestOtpLogin = jest.fn(); const { service } = build({ enabled: false, attestOtpLogin }); const result = await service.verifyOtp(MOBILE, CODE, '203.0.113.5'); expect(result.token).toBe('signed.jwt.token'); expect(attestOtpLogin).not.toHaveBeenCalled(); // بدون ردیابی نشست، توکن دقیقاً همان ادعاهای قبلی را دارد expect(jwt.signAsync.mock.calls[0][0]).toEqual({ sub: 'cust-1', mobile: MOBILE, typ: 'customer' }); }); /* ─────────────── feature on ─────────────── */ it('reports the verified code with the end-user IP and a masked number', async () => { const attestOtpLogin = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' }); const { service } = build({ enabled: true, attestOtpLogin }); await service.verifyOtp(MOBILE, CODE, '203.0.113.5'); // بدون IP کاربر نهایی، محدودیت نرخ IdP روی IP همین سرور می‌افتاد expect(attestOtpLogin).toHaveBeenCalledWith(MOBILE, '203.0.113.5', '0912***0000'); }); it('records the session with the identity service ids so a central sign-out can end it', async () => { const attestOtpLogin = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' }); const tracked = { newTokenId: jest.fn().mockReturnValue('jti-1'), record: jest.fn() }; const { service } = build({ enabled: true, attestOtpLogin }, tracked); await service.verifyOtp(MOBILE, CODE, '203.0.113.5'); expect(jwt.signAsync.mock.calls[0][0]).toMatchObject({ jti: 'jti-1' }); expect(tracked.record).toHaveBeenCalledWith( expect.objectContaining({ jti: 'jti-1', subjectId: 'cust-1', subjectType: 'customer', iamSessionId: 'sid-1', iamSub: 'sub-1', }), ); }); /* ─────────────── the safety net ─────────────── */ it('lets the customer in when the identity service does not know them yet', async () => { const attestOtpLogin = jest.fn().mockRejectedValue(new IamUserUnknownError(MOBILE)); const { service } = build({ enabled: true, attestOtpLogin }); const result = await service.verifyOtp(MOBILE, CODE, undefined); // مناسب مهاجرت تدریجی: هنوز همه کاربران آنجا نیستند expect(result.token).toBe('signed.jwt.token'); }); it('lets the customer in when the identity service is down', async () => { const attestOtpLogin = jest.fn().mockRejectedValue(new IamUnavailableError('down')); const { service } = build({ enabled: true, attestOtpLogin }); // مهم‌ترین تضمین: خرابی IdP نباید کاربر را از اپ بیرون نگه دارد await expect(service.verifyOtp(MOBILE, CODE, undefined)).resolves.toMatchObject({ token: 'signed.jwt.token', }); }); it('honours a refusal from the identity service (locked / deactivated / two-factor)', async () => { const attestOtpLogin = jest.fn().mockRejectedValue(new IamAccessDeniedError('refused', 'account_locked')); const { service } = build({ enabled: true, attestOtpLogin }); // اینجا «نمی‌دانم» نیست، «نه» است — دور زدنش قفل مرکزی را بی‌اثر می‌کرد await expect(service.verifyOtp(MOBILE, CODE, undefined)).rejects.toBeInstanceOf(UnauthorizedException); }); it('still refuses a wrong code before any of this runs', async () => { const attestOtpLogin = jest.fn(); const { service } = build({ enabled: true, attestOtpLogin }); await expect(service.verifyOtp(MOBILE, '9999', undefined)).rejects.toBeInstanceOf(UnauthorizedException); expect(attestOtpLogin).not.toHaveBeenCalled(); }); it('does not report the delivery master code as a verified factor', async () => { process.env.OTP_MASTER_CODE = '4321'; const attestOtpLogin = jest.fn(); const { service } = build({ enabled: true, attestOtpLogin }); await service.verifyOtp(MOBILE, '4321', undefined); // کد اضطراری، عامل واقعی کاربر نیست؛ نشست مرکزی روی آن ساخته نمی‌شود expect(attestOtpLogin).not.toHaveBeenCalled(); }); });