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:
co-authored by
Claude Opus 5
parent
2194b3d4d9
commit
460cf276b6
@@ -1,9 +1,11 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { CustomerKind, Prisma, VerificationStep } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { faToEn } from '../customer-auth/digits';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
|
||||
/**
|
||||
* Wholesale ("عمده") customer lists — legacy views/customer/omde/*.
|
||||
@@ -89,11 +91,41 @@ function verifyStepTwo(step: VerificationStep): number {
|
||||
|
||||
@Injectable()
|
||||
export class AdminCustomersService {
|
||||
private readonly logger = new Logger(AdminCustomersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The welcome SMS the reference sends after every new customer is created
|
||||
* (AdminController.php:378-387). Byte-identical, including the line order and
|
||||
* the trailing PWA link; the commented-out Android/iOS lines are not sent
|
||||
* there and are not sent here.
|
||||
*
|
||||
* D20 — awaiting it would let a slow or unconfigured gateway hold up the
|
||||
* HTTP response, and `send()` already swallows every error, so the result is
|
||||
* only logged. The reference behaves the same way: `Sms::Send` is fired
|
||||
* inline and its return value discarded, so a failed message never blocked
|
||||
* the creation.
|
||||
*/
|
||||
private sendWelcomeSms(mobile: string, firstName: string, lastName: string) {
|
||||
const body =
|
||||
'هنزا گلد\n' +
|
||||
`همکار گرامی ${firstName} ${lastName}\n` +
|
||||
`نام کاربری : ${mobile}\n` +
|
||||
'لینک pwa \n' +
|
||||
'https://app.henzagold.com';
|
||||
|
||||
void this.sms.send(mobile, body).catch((e: unknown) => {
|
||||
// send() is documented never to throw; this is belt-and-braces so an
|
||||
// unexpected rejection can never become an unhandled promise rejection.
|
||||
this.logger.error(`Welcome SMS to ${mobile} failed: ${String(e)}`);
|
||||
});
|
||||
}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'fullName',
|
||||
@@ -611,7 +643,12 @@ export class AdminCustomersService {
|
||||
const firstName = String(data.fname ?? data.name ?? '').trim();
|
||||
const lastName = String(data.lname ?? '').trim();
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||
const mobile = String(data.mobile ?? '').trim();
|
||||
// D19 gap 2 — the reference normalises the submitted mobile with
|
||||
// Utility::faTOen before storing it (AdminController.php:333,364). Without
|
||||
// this, an operator typing ۰۹۱۲… stores Persian digits and that customer
|
||||
// can never log in: the app's own faToEn turns the typed number into ASCII
|
||||
// and the lookup misses.
|
||||
const mobile = faToEn(String(data.mobile ?? '').trim());
|
||||
const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim();
|
||||
|
||||
if (!fullName || !mobile) {
|
||||
@@ -633,7 +670,7 @@ export class AdminCustomersService {
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.customer.create({
|
||||
const created = await this.prisma.customer.create({
|
||||
data: {
|
||||
fullName,
|
||||
firstName: firstName || null,
|
||||
@@ -675,6 +712,13 @@ export class AdminCustomersService {
|
||||
referredByAgentId: (data.reagent_id as string) || null,
|
||||
},
|
||||
});
|
||||
|
||||
// D19 gap 1 — the reference sends this only on CREATE, guarded by
|
||||
// `if (!isset($input['id']))` (AdminController.php:378). Fire-and-forget,
|
||||
// so the operator's save never waits on the gateway.
|
||||
this.sendWelcomeSms(mobile, firstName, lastName);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
@@ -701,7 +745,11 @@ export class AdminCustomersService {
|
||||
patch.makingChargeOffsetPercent = new Prisma.Decimal(String(data.percent));
|
||||
}
|
||||
|
||||
if (data.mobile !== undefined) patch.mobile = String(data.mobile);
|
||||
// D19 gap 2 — same normalisation as create(). The reference discards the
|
||||
// submitted mobile entirely on edit (AdminController.php:348); D19 ruled
|
||||
// that lock NOT ported, so the field stays editable here and is normalised
|
||||
// instead. Logged in ISSUES as PERS-01.
|
||||
if (data.mobile !== undefined) patch.mobile = faToEn(String(data.mobile));
|
||||
if (data.national_code !== undefined) patch.nationalCode = String(data.national_code);
|
||||
if (data.city_id !== undefined) {
|
||||
patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true };
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { faToEn } from '../customer-auth/digits';
|
||||
|
||||
/**
|
||||
* Admin users and the permission matrix — legacy views/user/*, served by
|
||||
@@ -171,7 +172,11 @@ export class AdminUsersService {
|
||||
const firstName = String(data.fname ?? data.name ?? '').trim();
|
||||
const lastName = String(data.lname ?? '').trim();
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||
const mobile = String(data.mobile ?? '').trim();
|
||||
// D19 gap 2 — normalise Persian digits, as the reference does with
|
||||
// Utility::faTOen (AdminController.php:333,364). Done BEFORE the
|
||||
// uniqueness lookup below: otherwise ۰۹۱۲… and 0912… are different strings
|
||||
// and the same number could be registered twice.
|
||||
const mobile = faToEn(String(data.mobile ?? '').trim());
|
||||
const password = String(data.password ?? '');
|
||||
const role = data.role as AdminRole | undefined;
|
||||
|
||||
@@ -260,7 +265,8 @@ export class AdminUsersService {
|
||||
}
|
||||
|
||||
if (data.mobile !== undefined) {
|
||||
const mobile = String(data.mobile);
|
||||
// Normalised before the clash check, for the same reason as create().
|
||||
const mobile = faToEn(String(data.mobile));
|
||||
if (mobile !== existing.mobile) {
|
||||
const clash = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||
if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
|
||||
@@ -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 } } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,11 @@ import { Logger } from '@nestjs/common';
|
||||
import { assertSmsConfigured, smsConfig, smsConfigured } from './sms.service';
|
||||
|
||||
/**
|
||||
* Covers the boot-time contract the owner specified:
|
||||
* Covers the boot-time contract as REVISED by D20:
|
||||
* * while no key is set, development behaviour is unchanged;
|
||||
* * in production a missing key is a clear, explicit boot failure.
|
||||
* * a missing key NEVER stops the boot, in any environment — it warns, and
|
||||
* sending silently declines. (D14's "fail to boot in production" rule was
|
||||
* removed by the owner for the delivery.)
|
||||
*/
|
||||
describe('SMS configuration', () => {
|
||||
const saved = { ...process.env };
|
||||
@@ -71,32 +73,39 @@ describe('SMS configuration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertSmsConfigured', () => {
|
||||
it('only warns outside production when keys are missing', () => {
|
||||
/**
|
||||
* D20 REVERSED the original boot contract. SMS must never stop the server
|
||||
* from starting, in any environment — an earlier revision of these tests
|
||||
* asserted the opposite (a throw in production) and is intentionally gone.
|
||||
*/
|
||||
describe('assertSmsConfigured (D20 — never throws)', () => {
|
||||
it('warns outside production when keys are missing', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('THROWS in production when keys are missing, naming them', () => {
|
||||
it('warns in production when keys are missing, naming them, and still boots', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
expect(() => assertSmsConfigured(logger)).toThrow(/SMS_TREZ_USERNAME/);
|
||||
expect(() => assertSmsConfigured(logger)).toThrow(/SMS_TREZ_PASSWORD/);
|
||||
expect(() => assertSmsConfigured(logger)).toThrow(/SMS_TREZ_SENDER_LINE/);
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
const warned = (logger.warn as jest.Mock).mock.calls.flat().join(' ');
|
||||
expect(warned).toMatch(/SMS_TREZ_USERNAME/);
|
||||
expect(warned).toMatch(/SMS_TREZ_PASSWORD/);
|
||||
expect(warned).toMatch(/SMS_TREZ_SENDER_LINE/);
|
||||
});
|
||||
|
||||
it('passes in production once everything is set', () => {
|
||||
it('passes silently in production once everything is set', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
configureFully();
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('THROWS in production when SMS is explicitly disabled', () => {
|
||||
// Disabling SMS in production means nobody can log in; that must be a
|
||||
// deliberate, visible failure rather than a silent one.
|
||||
it('allows SMS to be explicitly disabled in production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.SMS_PROVIDER = 'none';
|
||||
expect(() => assertSmsConfigured(logger)).toThrow(/Refusing to start in production/);
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows SMS to be disabled outside production', () => {
|
||||
@@ -105,9 +114,12 @@ describe('SMS configuration', () => {
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unknown provider name outright', () => {
|
||||
it('warns about an unknown provider name instead of refusing to boot', () => {
|
||||
process.env.SMS_PROVIDER = 'kavenegar';
|
||||
expect(() => assertSmsConfigured(logger)).toThrow(/Unknown SMS_PROVIDER/);
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect((logger.warn as jest.Mock).mock.calls.flat().join(' ')).toMatch(
|
||||
/Unknown SMS_PROVIDER/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,30 +68,30 @@ export function smsConfigured(): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from main.ts before the app listens. In production a missing key is
|
||||
* fatal: without it no customer can log in, and a silent no-op gives no clue
|
||||
* why. Outside production it is only a warning, so local development works
|
||||
* with no credentials at all.
|
||||
* Called from main.ts before the app listens. Reports the SMS configuration
|
||||
* and NEVER throws.
|
||||
*
|
||||
* D20 reversed the earlier rule (D14) that a missing credential should abort
|
||||
* the boot in production. The server now always starts: a missing or unknown
|
||||
* provider is a warning, `send()` declines silently and logs, and no message —
|
||||
* including the welcome SMS — can stop an operation. The trade-off is
|
||||
* deliberate and accepted: with SMS unconfigured, customers cannot receive a
|
||||
* login code, which is why `OTP_MASTER_CODE` exists for the delivery.
|
||||
*/
|
||||
export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): void {
|
||||
const c = smsConfig();
|
||||
|
||||
if (c.provider === 'none') {
|
||||
const message = 'SMS_PROVIDER=none — no SMS will be sent.';
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error(
|
||||
`${message} Refusing to start in production: customers could not log in. ` +
|
||||
'Set SMS_PROVIDER=trez and the SMS_TREZ_* variables.',
|
||||
);
|
||||
}
|
||||
logger.warn(message);
|
||||
logger.warn('SMS_PROVIDER=none — no SMS will be sent.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (c.provider !== 'trez') {
|
||||
throw new Error(
|
||||
`Unknown SMS_PROVIDER "${c.provider}". Supported values: "trez", "none".`,
|
||||
logger.warn(
|
||||
`Unknown SMS_PROVIDER "${c.provider}" (supported: "trez", "none"). ` +
|
||||
'No SMS will be sent.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = (
|
||||
@@ -106,14 +106,10 @@ export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): v
|
||||
|
||||
if (missing.length === 0) return;
|
||||
|
||||
const message = `SMS provider "trez" is selected but these are not set: ${missing.join(', ')}.`;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error(
|
||||
`${message} Refusing to start in production: no customer could receive a login code. ` +
|
||||
'Set them, or set SMS_PROVIDER=none to acknowledge that SMS is disabled.',
|
||||
);
|
||||
}
|
||||
logger.warn(`${message} Running in development mode — messages are logged, not sent.`);
|
||||
logger.warn(
|
||||
`SMS provider "trez" is selected but these are not set: ${missing.join(', ')}. ` +
|
||||
'Messages are logged, not sent. Customers cannot receive a login code.',
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
Reference in New Issue
Block a user