import { UnauthorizedException } from '@nestjs/common'; import { AuthService } from './auth.service'; import { legacyHashPassword } from './password-hasher'; function createUsersMock(roleCode: string, overrides: Record = {}) { const user = { id: 'user-1', tenantId: roleCode === 'enterprise_admin' ? 'tenant-1' : null, username: 'user', email: 'user@example.com', phone: '13800000000', displayName: '用户', passwordHash: legacyHashPassword('secret1'), status: 'active', deletedAt: null, lockedUntil: null, tenant: { id: 'tenant-1', name: '企业A' }, roles: [{ role: { code: roleCode } }], ...overrides, }; return { findByLogin: jest.fn().mockResolvedValue(user), verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'), recordLoginSuccess: jest.fn(), recordLoginFailure: jest.fn(), verifyCurrentPassword: jest.fn(), }; } function createSessionsMock() { const captchas = new Map(); const failures = new Map(); const record = { userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000, }; return { storeCaptcha: jest.fn(async (id: string, answer: string) => { captchas.set(id, answer); }), consumeCaptcha: jest.fn(async (id: string) => { const answer = captchas.get(id) ?? null; captchas.delete(id); return answer; }), assertCaptchaRequestAllowed: jest.fn().mockResolvedValue(true), anonymousLoginLockScope: jest.fn(async (login: string) => ((failures.get(login) ?? 0) >= 5 ? 'account' : null)), recordAnonymousLoginFailure: jest.fn(async (login: string) => { const count = (failures.get(login) ?? 0) + 1; failures.set(login, count); return [count, count, count]; }), clearAnonymousLoginFailures: jest.fn(async (login: string) => { failures.delete(login); }), create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }), publicSession: jest .fn() .mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString(), }), unlock: jest.fn().mockResolvedValue({ status: 'active' }), markReauthenticated: jest.fn().mockResolvedValue({ status: 'active' }), }; } function createMetricsMock() { return { recordAuthProtectionResult: jest.fn() }; } async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') { const captcha = await service.createCaptcha('203.0.113.10'); const answer = captcha.challenge .split('=')[0] .split('+') .map((part) => Number(part.trim())) .reduce((sum, value) => sum + value, 0); return service.login( { login: 'user@example.com', password, captchaId: captcha.captchaId, captchaText: String(answer), }, portal, '203.0.113.10', ); } describe('AuthService', () => { it('allows platform admins to login admin portal', async () => { const users = createUsersMock('platform_admin'); const sessions = createSessionsMock(); const service = new AuthService(users as never, sessions as never, createMetricsMock() as never); await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual( expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }), ); expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1'); expect(sessions.create).toHaveBeenCalledWith('user-1', 'admin', 0); }); it('rejects enterprise admins on admin portal', async () => { const users = createUsersMock('enterprise_admin'); const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never); await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException); expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1'); }); it('locks user after five failed password attempts', async () => { const users = createUsersMock('platform_admin'); const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never); for (let index = 0; index < 5; index += 1) { await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException); } expect(users.recordLoginFailure).toHaveBeenCalledTimes(5); }); it('rejects captcha bursts before allocating a captcha', async () => { const sessions = createSessionsMock(); sessions.assertCaptchaRequestAllowed.mockResolvedValue(false); const metrics = createMetricsMock(); const service = new AuthService(createUsersMock('platform_admin') as never, sessions as never, metrics as never); await expect(service.createCaptcha('203.0.113.10')).rejects.toMatchObject({ status: 429 }); expect(sessions.storeCaptcha).not.toHaveBeenCalled(); expect(metrics.recordAuthProtectionResult).toHaveBeenCalledWith('captcha_rejected'); }); it('allows a tenant-bound enterprise admin to login to the client portal', async () => { const service = new AuthService( createUsersMock('enterprise_admin') as never, createSessionsMock() as never, createMetricsMock() as never, ); await expect(loginWithCaptcha(service, 'client')).resolves.toEqual(expect.objectContaining({ portal: 'client' })); }); it('records anonymous failures without disclosing whether an account exists', async () => { const users = createUsersMock('platform_admin'); users.findByLogin.mockResolvedValue(null); const sessions = createSessionsMock(); const service = new AuthService(users as never, sessions as never, createMetricsMock() as never); await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException); expect(sessions.recordAnonymousLoginFailure).toHaveBeenCalledWith('user@example.com', '203.0.113.10'); }); it('rejects expired and incorrect one-time captchas', async () => { const sessions = createSessionsMock(); const service = new AuthService( createUsersMock('platform_admin') as never, sessions as never, createMetricsMock() as never, ); await expect( service.login( { login: 'user', password: 'secret1', captchaId: 'missing', captchaText: '1' }, 'admin', '203.0.113.10', ), ).rejects.toMatchObject({ status: 400 }); await sessions.storeCaptcha('captcha-wrong', '7'); await expect( service.login( { login: 'user', password: 'secret1', captchaId: 'captcha-wrong', captchaText: '8' }, 'admin', '203.0.113.10', ), ).rejects.toMatchObject({ status: 400 }); }); it('delegates unlock and recent reauthentication to password and session services', async () => { const users = createUsersMock('platform_admin'); const sessions = createSessionsMock(); const service = new AuthService(users as never, sessions as never, createMetricsMock() as never); await expect(service.unlock('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' }); await expect(service.reauthenticate('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' }); expect(users.verifyCurrentPassword).toHaveBeenCalledTimes(2); }); });