Brings the four release/delivery commits (IAM SSO for the admin panel,
both apps and the API; IAM env in docker-compose) onto fix/issues-triage.
Conflicts:
- customer-auth.service.ts imports: both kept.
- both apps' api/client.ts: both kept (AccountingBalance/WageQuote, and
iamAppUrl).
- all build output (admin-panel, user-app, user-app-v2 dist, and the api
release bundle): rebuilt from the merged source instead of picking a
side.
Fixed while verifying:
- The IAM change calls iam.browserBootstrapUrl after every login, but
the identity-service stand-ins in three test files lacked it. 15 tests
failed on release/delivery itself.
- SMS relay: SMS_RELAY_PLATFORM was read but never sent, so the gateway
never got `platform` (documented body: phone, host, text, platform).
relayConfig() now returns it ("default" → null) and the request carries
it. sms.service.spec now passes.
All 249 API tests pass; the three front-ends type-check and build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
7.1 KiB
TypeScript
167 lines
7.1 KiB
TypeScript
import { UnauthorizedException } from '@nestjs/common';
|
|
import * as bcrypt from 'bcryptjs';
|
|
|
|
/**
|
|
* `@nestjs/jwt` v12 is ESM and this repo's jest does not transform node_modules,
|
|
* so the real class cannot be imported here. The test injects its own stub.
|
|
*/
|
|
jest.mock('@nestjs/jwt', () => ({ JwtService: class JwtService {} }));
|
|
|
|
import { CustomerAuthService } from './customer-auth.service';
|
|
import { IamAccessDeniedError, IamUnavailableError, IamUserUnknownError } from '../iam/iam.service';
|
|
|
|
/**
|
|
* The app's OTP login and the central identity service.
|
|
*
|
|
* This login has **no password**, so it never reached the identity service at
|
|
* all: no central session, nothing in the security log, and a central sign-out
|
|
* could not end it. These tests lock down the fix and, more importantly, the
|
|
* boundaries: an identity-service problem must never keep a customer out of the
|
|
* app, while a refusal (locked / deactivated / two-factor) must be honoured.
|
|
*/
|
|
describe('CustomerAuthService — OTP login with the identity service', () => {
|
|
const MOBILE = '09120000000';
|
|
const CODE = '1234';
|
|
const CUSTOMER = { id: 'cust-1', mobile: MOBILE, isActive: true, isArchived: false };
|
|
|
|
let prisma: any;
|
|
let jwt: { signAsync: jest.Mock };
|
|
let sms: { send: jest.Mock };
|
|
|
|
const challenge = async () => ({
|
|
id: 'otp-1',
|
|
mobile: MOBILE,
|
|
codeHash: await bcrypt.hash(CODE, 8),
|
|
attempts: 0,
|
|
consumedAt: null,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
customer: CUSTOMER,
|
|
});
|
|
|
|
const build = (iam: Record<string, unknown>, sessions?: Record<string, unknown>) => {
|
|
const session = sessions ?? { newTokenId: jest.fn().mockReturnValue(null), record: jest.fn() };
|
|
const service = new CustomerAuthService(
|
|
prisma as never,
|
|
jwt as never,
|
|
sms as never,
|
|
{ browserBootstrapUrl: () => null, ...iam } as never,
|
|
session as never,
|
|
);
|
|
jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined);
|
|
return { service, sessions: session };
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
delete process.env.OTP_MASTER_CODE;
|
|
process.env.JWT_CUSTOMER_SECRET = 'test-secret';
|
|
|
|
prisma = {
|
|
customer: { findFirst: jest.fn().mockResolvedValue(CUSTOMER) },
|
|
customerOtp: {
|
|
findFirst: jest.fn().mockResolvedValue(await challenge()),
|
|
update: jest.fn().mockResolvedValue({}),
|
|
create: jest.fn().mockResolvedValue({}),
|
|
count: jest.fn().mockResolvedValue(0),
|
|
},
|
|
adminUser: { findFirst: jest.fn().mockResolvedValue(null) },
|
|
appSession: { create: jest.fn(), findUnique: jest.fn(), updateMany: jest.fn() },
|
|
};
|
|
jwt = { signAsync: jest.fn().mockResolvedValue('signed.jwt.token') };
|
|
sms = { send: jest.fn().mockResolvedValue({ ok: true }) };
|
|
});
|
|
|
|
/* ─────────────── feature off: unchanged behaviour ─────────────── */
|
|
|
|
it('never contacts the identity service while the feature is off', async () => {
|
|
const attestOtpLogin = jest.fn();
|
|
const { service } = build({ enabled: false, attestOtpLogin });
|
|
|
|
const result = await service.verifyOtp(MOBILE, CODE, '203.0.113.5');
|
|
|
|
expect(result.token).toBe('signed.jwt.token');
|
|
expect(attestOtpLogin).not.toHaveBeenCalled();
|
|
// بدون ردیابی نشست، توکن دقیقاً همان ادعاهای قبلی را دارد
|
|
expect(jwt.signAsync.mock.calls[0][0]).toEqual({ sub: 'cust-1', mobile: MOBILE, typ: 'customer' });
|
|
});
|
|
|
|
/* ─────────────── feature on ─────────────── */
|
|
|
|
it('reports the verified code with the end-user IP and a masked number', async () => {
|
|
const attestOtpLogin = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' });
|
|
const { service } = build({ enabled: true, attestOtpLogin });
|
|
|
|
await service.verifyOtp(MOBILE, CODE, '203.0.113.5');
|
|
|
|
// بدون IP کاربر نهایی، محدودیت نرخ IdP روی IP همین سرور میافتاد
|
|
expect(attestOtpLogin).toHaveBeenCalledWith(MOBILE, '203.0.113.5', '0912***0000');
|
|
});
|
|
|
|
it('records the session with the identity service ids so a central sign-out can end it', async () => {
|
|
const attestOtpLogin = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' });
|
|
const tracked = { newTokenId: jest.fn().mockReturnValue('jti-1'), record: jest.fn() };
|
|
const { service } = build({ enabled: true, attestOtpLogin }, tracked);
|
|
|
|
await service.verifyOtp(MOBILE, CODE, '203.0.113.5');
|
|
|
|
expect(jwt.signAsync.mock.calls[0][0]).toMatchObject({ jti: 'jti-1' });
|
|
expect(tracked.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
jti: 'jti-1',
|
|
subjectId: 'cust-1',
|
|
subjectType: 'customer',
|
|
iamSessionId: 'sid-1',
|
|
iamSub: 'sub-1',
|
|
}),
|
|
);
|
|
});
|
|
|
|
/* ─────────────── the safety net ─────────────── */
|
|
|
|
it('lets the customer in when the identity service does not know them yet', async () => {
|
|
const attestOtpLogin = jest.fn().mockRejectedValue(new IamUserUnknownError(MOBILE));
|
|
const { service } = build({ enabled: true, attestOtpLogin });
|
|
|
|
const result = await service.verifyOtp(MOBILE, CODE, undefined);
|
|
|
|
// مناسب مهاجرت تدریجی: هنوز همه کاربران آنجا نیستند
|
|
expect(result.token).toBe('signed.jwt.token');
|
|
});
|
|
|
|
it('lets the customer in when the identity service is down', async () => {
|
|
const attestOtpLogin = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
|
const { service } = build({ enabled: true, attestOtpLogin });
|
|
|
|
// مهمترین تضمین: خرابی IdP نباید کاربر را از اپ بیرون نگه دارد
|
|
await expect(service.verifyOtp(MOBILE, CODE, undefined)).resolves.toMatchObject({
|
|
token: 'signed.jwt.token',
|
|
});
|
|
});
|
|
|
|
it('honours a refusal from the identity service (locked / deactivated / two-factor)', async () => {
|
|
const attestOtpLogin = jest.fn().mockRejectedValue(new IamAccessDeniedError('refused', 'account_locked'));
|
|
const { service } = build({ enabled: true, attestOtpLogin });
|
|
|
|
// اینجا «نمیدانم» نیست، «نه» است — دور زدنش قفل مرکزی را بیاثر میکرد
|
|
await expect(service.verifyOtp(MOBILE, CODE, undefined)).rejects.toBeInstanceOf(UnauthorizedException);
|
|
});
|
|
|
|
it('still refuses a wrong code before any of this runs', async () => {
|
|
const attestOtpLogin = jest.fn();
|
|
const { service } = build({ enabled: true, attestOtpLogin });
|
|
|
|
await expect(service.verifyOtp(MOBILE, '9999', undefined)).rejects.toBeInstanceOf(UnauthorizedException);
|
|
expect(attestOtpLogin).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not report the delivery master code as a verified factor', async () => {
|
|
process.env.OTP_MASTER_CODE = '4321';
|
|
const attestOtpLogin = jest.fn();
|
|
const { service } = build({ enabled: true, attestOtpLogin });
|
|
|
|
await service.verifyOtp(MOBILE, '4321', undefined);
|
|
|
|
// کد اضطراری، عامل واقعی کاربر نیست؛ نشست مرکزی روی آن ساخته نمیشود
|
|
expect(attestOtpLogin).not.toHaveBeenCalled();
|
|
});
|
|
});
|