import { UnauthorizedException } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; /** * `@nestjs/jwt` v12 ships as ESM and this repo's jest does not transform * node_modules, so importing AuthService would fail to load. The real class is * never exercised here (the test injects its own stub), so replacing the module * keeps the suite runnable without touching the shared jest config. */ jest.mock('@nestjs/jwt', () => ({ JwtService: class JwtService {} })); /** * The real bcryptjs, with `compare` wrapped so a test can assert it was never * reached. It calls straight through, so every other test still runs against * the real implementation. (A namespace import's properties cannot be * re-defined, so jest.spyOn is not an option here.) */ jest.mock('bcryptjs', () => { const actual = jest.requireActual('bcryptjs'); return { ...actual, compare: jest.fn(actual.compare) }; }); import { AuthService, compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './auth.service'; import type { AdminSessionService } from '../sessions/admin-session.service'; import { IamInvalidCredentialsError, IamMfaRequiredError, IamService, IamUnavailableError, } from '../iam/iam.service'; /** * Admin login with the identity service in the loop. * * The contract being locked down: with the feature off, login behaves exactly * as before (one bcrypt compare, no network). With it on, an IAM problem must * never block an admin who still has a valid local password. */ describe('AuthService login', () => { const PASSWORD = 'Secret123!'; let passwordHash: string; const adminRow = () => ({ id: 'admin-1', mobile: '09120000000', fullName: 'مدیر سامانه', role: 'admin', userTypeId: 'ut-1', branchId: 'br-1', isActive: true, passwordHash, userType: { permissions: [{ rid: 10 }, { rid: 20 }] }, }); /** Session tracking off — the shape of a deployment that does not use it. */ const untrackedSessions = (): Partial => ({ newTokenId: jest.fn().mockReturnValue(null), record: jest.fn(), }); const build = ( iam: Partial, user: unknown = adminRow(), sessions: Partial = untrackedSessions(), ) => { const prisma = { adminUser: { findUnique: jest.fn().mockResolvedValue(user) }, }; const jwt = { signAsync: jest.fn().mockResolvedValue('local.jwt.token') }; return { service: new AuthService( prisma as never, jwt as never, { browserBootstrapUrl: () => null, ...iam } as IamService, sessions as AdminSessionService, ), prisma, jwt, sessions, }; }; const disabledIam = (): Partial => ({ enabled: false, allowsLocalFallback: true, verifyCredentials: jest.fn(), }); beforeAll(async () => { passwordHash = await bcrypt.hash(PASSWORD, 8); }); /* ─────────────── feature off: unchanged behaviour ─────────────── */ it('logs in with the local password and never calls the identity service', async () => { const iam = disabledIam(); const { service, jwt } = build(iam); const result = await service.login({ phone: '09120000000', password: PASSWORD }); expect(result.api_token).toBe('local.jwt.token'); expect(result.roles).toEqual([{ rid: 10 }, { rid: 20 }]); expect(jwt.signAsync).toHaveBeenCalledTimes(1); expect(iam.verifyCredentials).not.toHaveBeenCalled(); }); it('still rejects a wrong local password', async () => { const { service } = build(disabledIam()); await expect( service.login({ phone: '09120000000', password: 'wrong' }), ).rejects.toBeInstanceOf(UnauthorizedException); }); it('still rejects an inactive admin', async () => { const { service } = build(disabledIam(), { ...adminRow(), isActive: false }); await expect( service.login({ phone: '09120000000', password: PASSWORD }), ).rejects.toBeInstanceOf(UnauthorizedException); }); it('still rejects an unknown phone without leaking that it is unknown', async () => { const { service } = build(disabledIam(), null); await expect( service.login({ phone: '09129999999', password: PASSWORD }), ).rejects.toThrow('نام کاربری یا رمز عبور اشتباه است'); }); /* ─────────────── feature on ─────────────── */ it('accepts a password the identity service approves', async () => { const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' }); const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials }); // Deliberately not the local password: the IAM is the authority here const result = await service.login({ phone: '09120000000', password: 'iam-side-password' }); expect(result.api_token).toBe('local.jwt.token'); expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'iam-side-password', undefined); }); it('forwards the end-user IP to the identity service', async () => { const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' }); const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials }); await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5'); expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'x', '203.0.113.5'); }); /* ─────────────── the safety net ─────────────── */ it('falls back to the local password when the account is unknown there', async () => { const verifyCredentials = jest .fn() .mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials')); const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials }); const result = await service.login({ phone: '09120000000', password: PASSWORD }); expect(result.api_token).toBe('local.jwt.token'); }); it('falls back to the local password when the identity service is down', async () => { const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down')); const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials }); // The single most important guarantee: an IAM outage cannot lock admins out const result = await service.login({ phone: '09120000000', password: PASSWORD }); expect(result.api_token).toBe('local.jwt.token'); }); it('rejects a wrong password even when falling back', async () => { const verifyCredentials = jest .fn() .mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials')); const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials }); await expect( service.login({ phone: '09120000000', password: 'wrong' }), ).rejects.toBeInstanceOf(UnauthorizedException); }); /* ─────────────── exclusive mode ─────────────── */ it('refuses the local password in exclusive mode', async () => { const verifyCredentials = jest .fn() .mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials')); const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials }); await expect( service.login({ phone: '09120000000', password: PASSWORD }), ).rejects.toBeInstanceOf(UnauthorizedException); }); it('fails loudly in exclusive mode when the identity service is down', async () => { const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down')); const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials }); await expect( service.login({ phone: '09120000000', password: PASSWORD }), ).rejects.toThrow('سرویس هویت در دسترس نیست'); }); /* ─────────────── revocable sessions ─────────────── */ it('signs the same token as before while session tracking is off', async () => { const { service, jwt, sessions } = build(disabledIam()); await service.login({ phone: '09120000000', password: PASSWORD }); // No jti, no row: the previous behaviour, byte for byte expect(jwt.signAsync).toHaveBeenCalledWith({ sub: 'admin-1', mobile: '09120000000' }); expect(sessions.record).not.toHaveBeenCalled(); }); it('records the issued token with the IAM session id when tracking is on', async () => { const tracked: Partial = { newTokenId: jest.fn().mockReturnValue('jti-1'), record: jest.fn(), }; const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' }); const { service, jwt } = build( { enabled: true, allowsLocalFallback: true, verifyCredentials }, adminRow(), tracked, ); await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5', 'Firefox'); expect(jwt.signAsync).toHaveBeenCalledWith({ sub: 'admin-1', mobile: '09120000000', jti: 'jti-1', }); // Without the IAM session id, a back-channel logout could not find this session expect(tracked.record).toHaveBeenCalledWith({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1', iamSub: 'sub-1', ipAddress: '203.0.113.5', userAgent: 'Firefox', }); }); it('still records a session when the login fell back to the local password', async () => { const tracked: Partial = { newTokenId: jest.fn().mockReturnValue('jti-2'), record: jest.fn(), }; const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down')); const { service } = build( { enabled: true, allowsLocalFallback: true, verifyCredentials }, adminRow(), tracked, ); await service.login({ phone: '09120000000', password: PASSWORD }); // No IAM identity to attach, but the session must still be revocable locally expect(tracked.record).toHaveBeenCalledWith( expect.objectContaining({ jti: 'jti-2', iamSessionId: null, iamSub: null }), ); }); /* ─────────────── accounts with no usable password ─────────────── */ describe('accounts with no usable password', () => { const noPassword = ['', OTP_ONLY_PASSWORD_HASH]; it.each(noPassword)('refuses login for hash %j, whatever is typed', async (hash) => { const { service } = build(disabledIam(), { ...adminRow(), passwordHash: hash }); // Including the empty string and the sentinel itself: neither may open it for (const typed of ['', 'x', OTP_ONLY_PASSWORD_HASH]) { await expect( service.login({ phone: '09120000000', password: typed }), ).rejects.toThrow('نام کاربری یا رمز عبور اشتباه است'); } }); it.each(noPassword)('refuses hash %j on the IAM fallback path too', async (hash) => { const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down')); const { service } = build( { enabled: true, allowsLocalFallback: true, verifyCredentials }, { ...adminRow(), passwordHash: hash }, ); await expect( service.login({ phone: '09120000000', password: '' }), ).rejects.toBeInstanceOf(UnauthorizedException); }); it('never hands a malformed hash to bcrypt', async () => { const compare = bcrypt.compare as unknown as jest.Mock; compare.mockClear(); expect(await compareLocalHash('x', OTP_ONLY_PASSWORD_HASH)).toBe(false); expect(await compareLocalHash('x', '')).toBe(false); expect(await compareLocalHash('x', null)).toBe(false); expect(compare).not.toHaveBeenCalled(); // …while a real hash still goes through it await compareLocalHash(PASSWORD, passwordHash); expect(compare).toHaveBeenCalledTimes(1); }); it("still accepts the legacy server's PHP $2y$ hashes", async () => { // PHP's $2y$ is the same algorithm as $2b$; the legacy passwords // (customers and the sales experts moving to AdminUser) use it. const legacy = '$2y$' + (await bcrypt.hash(PASSWORD, 4)).slice(4); expect(await compareLocalHash(PASSWORD, legacy)).toBe(true); expect(await compareLocalHash('wrong', legacy)).toBe(false); }); }); /* ─────────────── two-factor ─────────────── */ it('explains that a two-factor account cannot log in through this path', async () => { const verifyCredentials = jest.fn().mockRejectedValue(new IamMfaRequiredError('mfa')); const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials }); await expect( service.login({ phone: '09120000000', password: PASSWORD }), ).rejects.toThrow('دو مرحله‌ای'); }); });