204 lines
7.6 KiB
TypeScript
204 lines
7.6 KiB
TypeScript
import { UnauthorizedException } from '@nestjs/common';
|
|||
|
|
|
||
|
|
// @nestjs/jwt v12 ships ESM only, which this CommonJS jest setup cannot load.
|
||
|
|
// The service is constructed with a hand-built JwtService double anyway, so the
|
||
|
|
// real module is never used here — stubbing it keeps the suite runnable without
|
||
|
|
// touching the shared jest config (D19: lowest-risk option).
|
||
|
|
jest.mock('@nestjs/jwt', () => ({ JwtService: class {} }));
|
||
|
|
|
||
|
|
import { CustomerAuthService, masterOtpCode, masterOtpEnabled } from './customer-auth.service';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* D20 — the delivery master OTP.
|
||
|
|
*
|
||
|
|
* The three cases the owner specified:
|
||
|
|
* 1. with OTP_MASTER_CODE set, that code signs an active customer in;
|
||
|
|
* 2. with the variable absent, the same code is rejected;
|
||
|
|
* 3. the real, stored code still works.
|
||
|
|
*
|
||
|
|
* Plus the guards that must survive the bypass: an inactive or archived
|
||
|
|
* customer is still refused, and an unknown mobile is still refused.
|
||
|
|
*
|
||
|
|
* The service is driven with hand-built doubles rather than a Nest testing
|
||
|
|
* module, so these run without a database.
|
||
|
|
*/
|
||
|
|
describe('OTP_MASTER_CODE (D20)', () => {
|
||
|
|
const saved = { ...process.env };
|
||
|
|
|
||
|
|
/** An active customer whose stored challenge holds the real code "4321". */
|
||
|
|
const CUSTOMER = { id: 'cus_1', mobile: '09121112233', isActive: true, isArchived: false };
|
||
|
|
|
||
|
|
let prisma: {
|
||
|
|
customer: { findFirst: jest.Mock };
|
||
|
|
customerOtp: { findFirst: jest.Mock; update: jest.Mock; create: jest.Mock; count: jest.Mock };
|
||
|
|
};
|
||
|
|
let jwt: { signAsync: jest.Mock };
|
||
|
|
let sms: { send: jest.Mock };
|
||
|
|
let service: CustomerAuthService;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
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(null),
|
||
|
|
update: jest.fn().mockResolvedValue({}),
|
||
|
|
create: jest.fn().mockResolvedValue({}),
|
||
|
|
count: jest.fn().mockResolvedValue(0),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
jwt = { signAsync: jest.fn().mockResolvedValue('signed.jwt.token') };
|
||
|
|
sms = { send: jest.fn().mockResolvedValue({ ok: true }) };
|
||
|
|
|
||
|
|
service = new CustomerAuthService(
|
||
|
|
prisma as never,
|
||
|
|
jwt as never,
|
||
|
|
sms as never,
|
||
|
|
);
|
||
|
|
// The bypass logs every use; keep the test output clean.
|
||
|
|
jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined);
|
||
|
|
});
|
||
|
|
|
||
|
|
afterAll(() => {
|
||
|
|
process.env = { ...saved };
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('the env switch itself', () => {
|
||
|
|
it('is disabled when the variable is absent', () => {
|
||
|
|
expect(masterOtpCode()).toBeNull();
|
||
|
|
expect(masterOtpEnabled()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('is disabled when the variable is empty or only whitespace', () => {
|
||
|
|
process.env.OTP_MASTER_CODE = '';
|
||
|
|
expect(masterOtpEnabled()).toBe(false);
|
||
|
|
process.env.OTP_MASTER_CODE = ' ';
|
||
|
|
expect(masterOtpEnabled()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('is enabled when the variable holds a code', () => {
|
||
|
|
process.env.OTP_MASTER_CODE = '1379';
|
||
|
|
expect(masterOtpCode()).toBe('1379');
|
||
|
|
expect(masterOtpEnabled()).toBe(true);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('1 — the master code signs an active customer in', () => {
|
||
|
|
it('issues a token without any stored challenge', async () => {
|
||
|
|
process.env.OTP_MASTER_CODE = '1379';
|
||
|
|
|
||
|
|
const result = await service.verifyOtp(CUSTOMER.mobile, '1379', '127.0.0.1');
|
||
|
|
|
||
|
|
expect(result.token).toBe('signed.jwt.token');
|
||
|
|
expect(result.customer).toEqual({ id: CUSTOMER.id, mobile: CUSTOMER.mobile });
|
||
|
|
// It must not depend on a CustomerOtp row existing.
|
||
|
|
expect(prisma.customerOtp.findFirst).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('accepts the master code typed with Persian digits', async () => {
|
||
|
|
// faToEn runs before the comparison, as it does for a real code.
|
||
|
|
process.env.OTP_MASTER_CODE = '1379';
|
||
|
|
await expect(service.verifyOtp(CUSTOMER.mobile, '۱۳۷۹', undefined)).resolves.toMatchObject({
|
||
|
|
token: 'signed.jwt.token',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('logs the bypass with the customer mobile', async () => {
|
||
|
|
process.env.OTP_MASTER_CODE = '1379';
|
||
|
|
const warn = jest.spyOn(service['logger'], 'warn');
|
||
|
|
|
||
|
|
await service.verifyOtp(CUSTOMER.mobile, '1379', undefined);
|
||
|
|
|
||
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining(CUSTOMER.mobile));
|
||
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('OTP_MASTER_CODE'));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('still refuses an inactive or archived customer', async () => {
|
||
|
|
process.env.OTP_MASTER_CODE = '1379';
|
||
|
|
// The lookup filters on isActive/isArchived, so it finds nothing.
|
||
|
|
prisma.customer.findFirst.mockResolvedValue(null);
|
||
|
|
|
||
|
|
await expect(service.verifyOtp(CUSTOMER.mobile, '1379', undefined)).rejects.toBeInstanceOf(
|
||
|
|
UnauthorizedException,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('still refuses a mobile that belongs to no customer', async () => {
|
||
|
|
process.env.OTP_MASTER_CODE = '1379';
|
||
|
|
prisma.customer.findFirst.mockResolvedValue(null);
|
||
|
|
|
||
|
|
await expect(service.verifyOtp('09990000000', '1379', undefined)).rejects.toBeInstanceOf(
|
||
|
|
UnauthorizedException,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('2 — without the env var the same code is rejected', () => {
|
||
|
|
it('falls through to the normal path and rejects it', async () => {
|
||
|
|
// No OTP_MASTER_CODE, and no stored challenge to match.
|
||
|
|
await expect(service.verifyOtp(CUSTOMER.mobile, '1379', undefined)).rejects.toBeInstanceOf(
|
||
|
|
UnauthorizedException,
|
||
|
|
);
|
||
|
|
// It took the ordinary route, which does consult the challenge table.
|
||
|
|
expect(prisma.customerOtp.findFirst).toHaveBeenCalled();
|
||
|
|
expect(jwt.signAsync).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects a code that merely resembles the master one while it is armed', async () => {
|
||
|
|
process.env.OTP_MASTER_CODE = '1379';
|
||
|
|
|
||
|
|
await expect(service.verifyOtp(CUSTOMER.mobile, '1378', undefined)).rejects.toBeInstanceOf(
|
||
|
|
UnauthorizedException,
|
||
|
|
);
|
||
|
|
expect(jwt.signAsync).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('3 — the real code still works', () => {
|
||
|
|
/** bcrypt hash of "4321", generated once for this test. */
|
||
|
|
const bcrypt = require('bcryptjs') as typeof import('bcryptjs');
|
||
|
|
|
||
|
|
const challengeWith = async (code: string) => ({
|
||
|
|
id: 'otp_1',
|
||
|
|
codeHash: await bcrypt.hash(code, 4),
|
||
|
|
expiresAt: new Date(Date.now() + 60_000),
|
||
|
|
attempts: 0,
|
||
|
|
customer: CUSTOMER,
|
||
|
|
});
|
||
|
|
|
||
|
|
it('verifies a stored code while the master code is armed', async () => {
|
||
|
|
process.env.OTP_MASTER_CODE = '1379';
|
||
|
|
prisma.customerOtp.findFirst.mockResolvedValue(await challengeWith('4321'));
|
||
|
|
|
||
|
|
const result = await service.verifyOtp(CUSTOMER.mobile, '4321', undefined);
|
||
|
|
|
||
|
|
expect(result.token).toBe('signed.jwt.token');
|
||
|
|
// The real path burns the challenge; the master path never touches it.
|
||
|
|
expect(prisma.customerOtp.update).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({ data: expect.objectContaining({ consumedAt: expect.any(Date) }) }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('verifies a stored code while the master code is absent', async () => {
|
||
|
|
prisma.customerOtp.findFirst.mockResolvedValue(await challengeWith('4321'));
|
||
|
|
|
||
|
|
await expect(service.verifyOtp(CUSTOMER.mobile, '4321', undefined)).resolves.toMatchObject({
|
||
|
|
token: 'signed.jwt.token',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('still rejects a wrong code and counts the attempt', async () => {
|
||
|
|
prisma.customerOtp.findFirst.mockResolvedValue(await challengeWith('4321'));
|
||
|
|
|
||
|
|
await expect(service.verifyOtp(CUSTOMER.mobile, '0000', undefined)).rejects.toBeInstanceOf(
|
||
|
|
UnauthorizedException,
|
||
|
|
);
|
||
|
|
expect(prisma.customerOtp.update).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({ data: { attempts: { increment: 1 } } }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|