parity(api,apps): delivery-mode gaps — master OTP, non-blocking SMS, faToEn, v2 cart cap

D20: OTP_MASTER_CODE login bypass read only from the environment, disabled when
unset, audited on every use, with a boot banner while armed. SMS no longer
aborts the boot in production — it warns and declines silently, so no message
can stop an operation.

D19: port Utility::faTOen onto admin-entered mobiles (before the uniqueness
lookup, which could previously be bypassed with Persian digits), and the
create-only welcome SMS, byte-identical to the reference and fire-and-forget.

user-app-v2 was missing the cart stock ceiling entirely: capQty/maxQty, the
un-orderable guard and the qty clamp are ported, leaving v2's own design intact.
The two cart stores are now byte-identical. isVariantOrderable and the
orderable-first default selection ported too.

jest 45/45; typecheck clean on api, admin-panel, user-app, user-app-v2.

[ref: henza_server/app/Http/Controllers/Adm/AdminController.php:333,348,364,378-387]
[ref: henza_server/app/Models/Utility.php:26]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-22 18:35:33 +03:30
co-authored by Claude Opus 5
parent 2194b3d4d9
commit 460cf276b6
128 changed files with 756 additions and 181 deletions
@@ -26,6 +26,26 @@ const SEND_WINDOW_MS = 10 * 60 * 1000;
/** Minimum gap between two sends to the same number. */
const RESEND_COOLDOWN_MS = 60 * 1000;
/**
* D20 — the delivery master OTP.
*
* Read from `OTP_MASTER_CODE` and NEVER hardcoded. Unset or empty disables it
* completely, which is the state every real deployment must be in.
*
* Read through a function rather than a module constant so tests (and a
* restart-free config change) see the current value instead of whatever was in
* the environment when this module was first imported.
*/
export function masterOtpCode(): string | null {
const raw = process.env.OTP_MASTER_CODE?.trim();
return raw ? raw : null;
}
/** Whether the master code is currently armed. */
export function masterOtpEnabled(): boolean {
return masterOtpCode() !== null;
}
@Injectable()
export class CustomerAuthService {
private readonly logger = new Logger(CustomerAuthService.name);
@@ -96,6 +116,39 @@ export class CustomerAuthService {
const mobile = faToEn(rawMobile);
const code = faToEn(rawCode);
// One message for every failure mode, so a caller cannot tell "no code was
// sent" from "wrong code" from "expired". Declared before the master-code
// branch so that path can reject identically.
const reject = () => new UnauthorizedException('کد وارد شده صحیح نیست');
// D20 — the delivery master code. Checked BEFORE the stored challenge so it
// works even when the customer never requested a code (no CustomerOtp row
// exists to compare against). It does not weaken any other rule: the
// customer must still exist, be active and not archived, and the send
// throttle on requestOtp is untouched.
//
// MUST be removed from the environment before the real release — see D20
// and DELIVERY.md.
const master = masterOtpCode();
if (master !== null && code === master) {
const customer = await this.prisma.customer.findFirst({
where: { mobile, isActive: true, isArchived: false },
select: { id: true, mobile: true },
});
if (!customer) throw reject();
// Audited with the mobile, as D20 requires, so every bypass login is
// traceable in the server log.
this.logger.warn(
`OTP_MASTER_CODE used to sign in customer ${customer.id} (mobile ${customer.mobile})`,
);
return {
token: await this.issueToken(customer.id, customer.mobile),
customer: { id: customer.id, mobile: customer.mobile },
};
}
const challenge = await this.prisma.customerOtp.findFirst({
where: { mobile, consumedAt: null },
orderBy: { createdAt: 'desc' },
@@ -106,10 +159,6 @@ export class CustomerAuthService {
},
});
// One message for every failure mode below, so a caller cannot tell "no
// code was sent" from "wrong code" from "expired".
const reject = () => new UnauthorizedException('کد وارد شده صحیح نیست');
if (!challenge) throw reject();
if (challenge.expiresAt.getTime() < Date.now()) throw reject();
@@ -138,25 +187,32 @@ export class CustomerAuthService {
data: { consumedAt: new Date() },
});
return {
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile),
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
};
}
/**
* Sign a customer JWT. Shared by the normal verify path and the D20 master
* code so both issue exactly the same token — the bypass grants no extra
* privilege and no longer lifetime.
*
* Reference reused api_token forever once set (:60-62); a fresh token is
* issued on every verify here (T1).
*/
private issueToken(customerId: string, mobile: string): Promise<string> {
const payload: CustomerJwtPayload = {
sub: challenge.customer.id,
mobile: challenge.customer.mobile,
sub: customerId,
mobile,
typ: CUSTOMER_TOKEN_TYPE,
};
// Reference reused api_token forever once set (:60-62); a fresh token is
// issued on every verify here (T1).
const token = await this.jwt.signAsync(payload, {
return this.jwt.signAsync(payload, {
secret: customerJwtSecret(),
// jsonwebtoken types expiresIn as a template-literal union, which a plain
// string from process.env does not satisfy — same cast AuthModule uses.
expiresIn: (process.env.JWT_CUSTOMER_EXPIRES_IN ?? '30d') as `${number}d`,
});
return {
token,
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
};
}
/**
@@ -0,0 +1,203 @@
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 } } }),
);
});
});
});