import { IamInvalidCredentialsError, IamMfaRequiredError, IamService, IamUnavailableError, } from './iam.service'; /** * Credential delegation to the central identity service. * * The behaviour these lock down is the safety contract: the feature is inert * until fully configured, and any uncertain outcome must leave the existing * local password check in charge so an IAM problem can never lock admins out. */ describe('IamService', () => { const ORIGINAL_ENV = process.env; let service: IamService; let fetchMock: jest.Mock; const configure = (overrides: Record = {}) => { process.env = { ...ORIGINAL_ENV, IAM_ENABLED: 'true', IAM_ISSUER: 'https://auth.test.local', IAM_CLIENT_ID: 'goldbank_api', IAM_CLIENT_SECRET: 'server-secret', IAM_TENANT_SLUG: 'montzar', IAM_LOGIN_DELEGATION: 'fallback', ...overrides, }; }; const respondWith = (status: number, body: unknown) => { fetchMock.mockResolvedValue({ ok: status >= 200 && status < 300, status, json: async () => body, } as Response); }; beforeEach(() => { service = new IamService(); fetchMock = jest.fn(); global.fetch = fetchMock as unknown as typeof fetch; configure(); }); afterEach(() => { process.env = ORIGINAL_ENV; jest.restoreAllMocks(); }); /* ─────────────── the switch ─────────────── */ it('is disabled by default', () => { process.env = { ...ORIGINAL_ENV }; delete process.env.IAM_ENABLED; expect(new IamService().enabled).toBe(false); }); it('stays disabled when the switch is on but configuration is incomplete', () => { configure({ IAM_CLIENT_SECRET: '' }); expect(service.enabled).toBe(false); configure({ IAM_ISSUER: '' }); expect(service.enabled).toBe(false); configure({ IAM_CLIENT_ID: '' }); expect(service.enabled).toBe(false); configure(); expect(service.enabled).toBe(true); }); it('treats an empty value as unset, like the rest of the codebase', () => { // docker-compose passes unset optional variables as "" — see common/env.ts configure({ IAM_ENABLED: ' ' }); expect(service.enabled).toBe(false); }); it('is disabled when delegation is explicitly turned off', () => { configure({ IAM_LOGIN_DELEGATION: 'off' }); expect(service.enabled).toBe(false); }); it('falls back to the local password check unless exclusive mode is set', () => { expect(service.allowsLocalFallback).toBe(true); configure({ IAM_LOGIN_DELEGATION: 'exclusive' }); expect(service.allowsLocalFallback).toBe(false); }); it('ignores an unknown delegation mode rather than failing', () => { configure({ IAM_LOGIN_DELEGATION: 'nonsense' }); expect(service.mode).toBe('fallback'); }); /* ─────────────── request contract ─────────────── */ it('sends client credentials, tenant and the end-user IP', async () => { respondWith(200, { status: 'SUCCESS', user: { id: 'sub-1' }, session_id: 'sid-1' }); await service.verifyCredentials('09120000000', 'secret', '203.0.113.7'); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('https://auth.test.local/api/v1/auth/login'); const body = JSON.parse((init as RequestInit).body as string); expect(body.identifier).toBe('09120000000'); expect(body.client_id).toBe('goldbank_api'); expect(body.client_secret).toBe('server-secret'); expect(body.tenant_slug).toBe('montzar'); // Without this the IAM would rate-limit on this server's IP and lock // out every admin after a handful of attempts. expect(body.client_ip).toBe('203.0.113.7'); }); it('prefers tenant id over slug when both are configured', async () => { configure({ IAM_TENANT_ID: '11111111-2222-3333-4444-555555555555' }); respondWith(200, { status: 'SUCCESS', user: { id: 'sub-1' } }); await service.verifyCredentials('09120000000', 'secret'); const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); expect(body.tenant_id).toBe('11111111-2222-3333-4444-555555555555'); expect(body.tenant_slug).toBeUndefined(); }); it('returns the identity on success', async () => { respondWith(200, { status: 'SUCCESS', session_id: 'sid-9', user: { id: 'sub-9', email: 'a@b.c', phone_number: '09120000000', first_name: 'رضا', last_name: 'کاظمی', }, }); const identity = await service.verifyCredentials('09120000000', 'secret'); expect(identity).toEqual({ sub: 'sub-9', email: 'a@b.c', phoneNumber: '09120000000', firstName: 'رضا', lastName: 'کاظمی', sessionId: 'sid-9', browserTicket: null, }); }); /* ─────────────── failure mapping ─────────────── */ it('maps a rejected password to an invalid-credentials error', async () => { respondWith(401, { error: 'invalid_credentials' }); await expect(service.verifyCredentials('09120000000', 'bad')).rejects.toBeInstanceOf( IamInvalidCredentialsError, ); }); it('treats a locked account as a definite rejection, not an outage', async () => { // Falling back to the local password here would defeat the IAM's lockout. respondWith(403, { error: 'account_locked' }); await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf( IamInvalidCredentialsError, ); }); it('maps a two-factor challenge to its own error', async () => { respondWith(200, { status: 'MFA_REQUIRED', mfa_session_token: 'x' }); await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf( IamMfaRequiredError, ); }); it('maps a server error to an availability problem', async () => { respondWith(500, {}); await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf( IamUnavailableError, ); }); it('maps rate limiting to an availability problem so login still falls back', async () => { respondWith(429, { error: 'rate_limited' }); await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf( IamUnavailableError, ); }); it('maps a network failure to an availability problem', async () => { fetchMock.mockRejectedValue(new Error('ECONNREFUSED')); await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf( IamUnavailableError, ); }); it('rejects an unexpected success payload instead of trusting it', async () => { respondWith(200, { status: 'SOMETHING_ELSE' }); await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf( IamUnavailableError, ); }); });