Files
hedena_gold/apps/api/src/modules/iam/iam.service.spec.ts
T
s.esmaeiliandCursor 6592e88a2c Fix admin IAM redirect and separate pending wholesale lists.
Keep admin browser bootstrap from falling back to the customer app URL, and exclude activated customers from the pending-verification list so it no longer mirrors the main omde grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-25 22:44:48 +03:30

255 lines
8.5 KiB
TypeScript

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<string, string> = {}) => {
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: 'tehrantaj',
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('tehrantaj');
// 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,
);
});
/* ─────────────── browser return / bootstrap ─────────────── */
it('sends admin return_to, never the customer app URL', async () => {
configure({
IAM_BROWSER_RETURN_URL: 'https://app-v2.example/home',
IAM_ADMIN_BROWSER_RETURN_URL: 'https://admin.example/',
});
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.return_to).toBe('https://admin.example/');
});
it('omits return_to when admin return is unset, even if customer return is set', async () => {
configure({ IAM_BROWSER_RETURN_URL: 'https://app-v2.example/home' });
delete process.env.IAM_ADMIN_BROWSER_RETURN_URL;
delete process.env.IAM_ADMIN_POST_LOGOUT_REDIRECT_URI;
delete process.env.IAM_ADMIN_ALLOWED_RETURN_URLS;
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.return_to).toBeUndefined();
});
it('refuses an admin bootstrap ticket without an admin return URL', () => {
configure({ IAM_BROWSER_RETURN_URL: 'https://app-v2.example/home' });
delete process.env.IAM_ADMIN_BROWSER_RETURN_URL;
delete process.env.IAM_ADMIN_POST_LOGOUT_REDIRECT_URI;
delete process.env.IAM_ADMIN_ALLOWED_RETURN_URLS;
expect(service.browserBootstrapUrl('ticket-1', 'admin')).toBeNull();
expect(service.browserBootstrapUrl('ticket-1', 'customer')).toBe(
'https://auth.test.local/auth/bootstrap?ticket=ticket-1',
);
});
});