Files
hedena_gold/apps/api/src/modules/auth/auth.service.spec.ts
T

335 lines
13 KiB
TypeScript
Raw Normal View History

2026-09-25 19:04:48 +03:30
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';
2026-09-25 19:04:48 +03:30
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<AdminSessionService> => ({
newTokenId: jest.fn().mockReturnValue(null),
record: jest.fn(),
});
const build = (
iam: Partial<IamService>,
user: unknown = adminRow(),
sessions: Partial<AdminSessionService> = untrackedSessions(),
) => {
const prisma = {
admin: { findUnique: jest.fn().mockResolvedValue(user) },
2026-09-25 19:04:48 +03:30
};
const jwt = { signAsync: jest.fn().mockResolvedValue('local.jwt.token') };
return {
service: new AuthService(
prisma as never,
jwt as never,
{ browserBootstrapUrl: () => null, ...iam } as IamService,
2026-09-25 19:04:48 +03:30
sessions as AdminSessionService,
),
prisma,
jwt,
sessions,
};
};
const disabledIam = (): Partial<IamService> => ({
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<AdminSessionService> = {
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',
adminId: 'admin-1',
2026-09-25 19:04:48 +03:30
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<AdminSessionService> = {
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);
});
});
2026-09-25 19:04:48 +03:30
/* ─────────────── 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('دو مرحله‌ای');
});
});