Reported by the owner: a number that is not in the system produced no error at all. The app advanced to the code box and then nothing happened, and there was no way to tell apart "the SMS is late", "the gateway is down" and "this number was never registered" — the only one of the three the person can actually do something about. Step 1 now says which it is: - unregistered number -> 404 «این شماره در سیستم ثبت نشده است…» - SMS send failed -> 503 «ارسال پیام کوتاه … ممکن نیست…» - sales expert, account unusable -> 403, its own message and both apps surface the server's Persian message for a 404 exactly as they already did for a 429. Without that second half the person would still have seen the generic «خطا در ارسال کد», which is the confusion being removed. This restores the reference's behaviour (firstOrFail() in ApiMobileController::userAuth:43 returns 404); the opaque answer was our deviation, not this. The anti-enumeration protection it was there for is deliberately reduced at step 1 on the owner's instruction — the cost was being paid by real users and by support. Still standing: the per-mobile and per-IP send throttles, which the reference never had, so enumeration is rate-bounded rather than free, and verifyOtp keeps one message for every failure reason. ISSUES.md SEC-03's "no enumeration" claim is corrected rather than left to go stale. Two more instances of the same shape turned up while fixing it: A failed SMS send also returned success, because SmsService never throws (D20), so a dead gateway produced the identical dead end. D20 exists so a failed SMS cannot break an operation — an order must still be placed — but in a login the SMS IS the operation, so it now fails loudly. The local-dev path keys off SmsService's own `sms-not-configured` result rather than re-testing the config: with D26's default relay driver, smsConfigured() is true on any machine with an external IPv4, so a config test would have called a dev box configured and then failed its login. The new test caught precisely that on its first run. Three states return no trading account for a sales expert who does exist. Under the new 404 each would have claimed the number was not registered — false, and it would send support hunting a registration problem that is not there. Also corrects both apps' LoginView header comment, which still described this flow as stubbed: no SMS provider, any 4-digit code accepted, step 1 matching the customer list in the client (that was the login leak, D8). All three had been untrue for weeks and the stale text misled a reader during this session. requestOtp had NO test coverage: the 28-test customer-auth suite stayed green through a change to its response for every unregistered number, so its greenness was not treated as evidence. request-otp.spec.ts adds 9 cases, including that the dev fallback does not apply in production. api: 295/295 tests, 27 suites, typecheck clean; both apps vue-tsc clean. اپ: اگر شماره شما در سیستم ثبت نشده باشد، همان را میگوید و دیگر بیدلیل به صفحه کد نمیرود. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
7.5 KiB
TypeScript
166 lines
7.5 KiB
TypeScript
import { ForbiddenException, HttpException, NotFoundException } from '@nestjs/common';
|
|
import { AdminRole } from '@prisma/client';
|
|
|
|
// @nestjs/jwt v12 ships ESM only, which this CommonJS jest setup cannot load.
|
|
// Same stub as master-otp.spec.ts — the service gets a hand-built double.
|
|
jest.mock('@nestjs/jwt', () => ({ JwtService: class {} }));
|
|
|
|
import { CustomerAuthService } from './customer-auth.service';
|
|
|
|
/**
|
|
* Owner ruling 2026-10-12 — step 1 of the app login must say what went wrong.
|
|
*
|
|
* Until now an unknown number got `{ sent: true }` (opaque on purpose, to stop
|
|
* customer enumeration) and a failed SMS send also got `{ sent: true }` (a dead
|
|
* gateway is only a log line, D20). Both walked the person to the code box to
|
|
* wait for a message that was never going to arrive, and nothing on screen told
|
|
* the three real causes apart: the SMS is late, the gateway is down, or the
|
|
* number was never registered.
|
|
*
|
|
* `requestOtp` had NO test coverage at all before this file, so the whole suite
|
|
* stayed green through that change — these cases exist so it cannot again.
|
|
*/
|
|
describe('CustomerAuthService.requestOtp — the caller is told what happened', () => {
|
|
const saved = { ...process.env };
|
|
|
|
const CUSTOMER = { id: 'cus_1', mobile: '09121112233', isActive: true, isArchived: false };
|
|
|
|
function build(opts: {
|
|
customer?: unknown;
|
|
expert?: unknown;
|
|
smsResult?: { ok: boolean; error?: string };
|
|
} = {}) {
|
|
const prisma = {
|
|
customer: {
|
|
findFirst: jest.fn().mockResolvedValue('customer' in opts ? opts.customer : CUSTOMER),
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
},
|
|
customerOtp: {
|
|
findFirst: jest.fn().mockResolvedValue(null),
|
|
create: jest.fn().mockResolvedValue({}),
|
|
count: jest.fn().mockResolvedValue(0),
|
|
},
|
|
smsMessage: { create: jest.fn().mockResolvedValue({}) },
|
|
adminUser: { findFirst: jest.fn().mockResolvedValue(opts.expert ?? null) },
|
|
};
|
|
const sms = { send: jest.fn().mockResolvedValue(opts.smsResult ?? { ok: true }) };
|
|
const service = new CustomerAuthService(
|
|
prisma as never,
|
|
{ signAsync: jest.fn() } as never,
|
|
sms as never,
|
|
{ enabled: false, attestOtpLogin: jest.fn(), browserBootstrapUrl: () => null } as never,
|
|
{ newTokenId: jest.fn().mockReturnValue(null), record: jest.fn() } as never,
|
|
);
|
|
jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined);
|
|
jest.spyOn(service['logger'], 'error').mockImplementation(() => undefined);
|
|
return { service, prisma, sms };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
process.env.NODE_ENV = 'test';
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = { ...saved };
|
|
});
|
|
|
|
it('sends the code for a registered, active customer', async () => {
|
|
const { service, sms, prisma } = build();
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).resolves.toEqual({ sent: true });
|
|
expect(sms.send).toHaveBeenCalledTimes(1);
|
|
expect(prisma.customerOtp.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('refuses an unregistered number with «ثبت نشده»، not a silent success', async () => {
|
|
const { service, sms } = build({ customer: null });
|
|
await expect(service.requestOtp('09129999999', '127.0.0.1')).rejects.toThrow(NotFoundException);
|
|
await expect(service.requestOtp('09129999999', '127.0.0.1')).rejects.toThrow('ثبت نشده');
|
|
// Nothing was sent and no challenge was stored for a number nobody holds.
|
|
expect(sms.send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('normalises Persian digits before deciding the number is unknown', async () => {
|
|
// Guards against a false «ثبت نشده» for someone typing on a Persian keypad.
|
|
const { service, prisma } = build();
|
|
await service.requestOtp('۰۹۱۲۱۱۱۲۲۳۳', '127.0.0.1');
|
|
expect(prisma.customer.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: expect.objectContaining({ mobile: '09121112233' }) }),
|
|
);
|
|
});
|
|
|
|
it('reports a failed SMS send as 503 instead of claiming it was sent', async () => {
|
|
const { service } = build({ smsResult: { ok: false, error: 'gateway refused' } });
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow(HttpException);
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('ارسال پیام کوتاه');
|
|
});
|
|
|
|
it('still completes the login in development when there is no gateway at all', async () => {
|
|
// The local-dev path: SmsService sends nothing, returns its own
|
|
// `sms-not-configured`, and the code is logged instead — so this one failure
|
|
// must NOT become an error or nobody could sign in locally.
|
|
process.env.NODE_ENV = 'development';
|
|
const { service } = build({ smsResult: { ok: false, error: 'sms-not-configured' } });
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).resolves.toEqual({ sent: true });
|
|
});
|
|
|
|
it('still fails in development when a configured gateway refuses', async () => {
|
|
// D26's default `relay` driver counts as configured on any machine with an
|
|
// external IPv4, so a dev box that cannot reach the relay gets a real
|
|
// failure — which must be reported, not swallowed as "no gateway".
|
|
process.env.NODE_ENV = 'development';
|
|
const { service } = build({ smsResult: { ok: false, error: 'relay unreachable' } });
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('ارسال پیام کوتاه');
|
|
});
|
|
|
|
it('refuses in production even when nothing is configured', async () => {
|
|
// The dev fallback must never let a production deployment report success
|
|
// while silently sending nothing.
|
|
process.env.NODE_ENV = 'production';
|
|
const { service } = build({ smsResult: { ok: false, error: 'sms-not-configured' } });
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('ارسال پیام کوتاه');
|
|
});
|
|
|
|
/**
|
|
* A sales expert signs in through this same flow (D21). Three states return
|
|
* no trading account for an expert who DOES exist, and «ثبت نشده است» would
|
|
* be a lie in each — it would send support hunting a registration problem.
|
|
*/
|
|
describe('a sales expert whose trading account cannot be used', () => {
|
|
it('says the account is disabled, not that the number is unknown', async () => {
|
|
const { service } = build({
|
|
customer: null,
|
|
expert: {
|
|
id: 'adm_1',
|
|
mobile: '09121112233',
|
|
role: AdminRole.SALES_EXPERT,
|
|
fullName: 'کارشناس',
|
|
firstName: 'کار',
|
|
lastName: 'شناس',
|
|
wagePercent: '0',
|
|
salesExpertProfile: { id: 'sep_1', gramLevel: '0', nationalCode: null, tradingCustomerId: 'cus_dead' },
|
|
},
|
|
});
|
|
// findUnique returns null → the linked customer is gone or unusable.
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow(ForbiddenException);
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('غیرفعال');
|
|
});
|
|
|
|
it('says the account is incomplete when the expert has no profile', async () => {
|
|
const { service } = build({
|
|
customer: null,
|
|
expert: {
|
|
id: 'adm_2',
|
|
mobile: '09121112233',
|
|
role: AdminRole.SALES_EXPERT,
|
|
fullName: 'کارشناس',
|
|
firstName: 'کار',
|
|
lastName: 'شناس',
|
|
wagePercent: '0',
|
|
salesExpertProfile: null,
|
|
},
|
|
});
|
|
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('کامل نیست');
|
|
});
|
|
});
|
|
});
|