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>
255 lines
8.5 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|