fix(auth): tell the user when the number is not registered (D28)

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>
This commit is contained in:
s.esmaeili
2026-10-12 10:59:48 +03:30
co-authored by Claude Opus 5
parent 89e2d7937e
commit c518f9995b
8 changed files with 403 additions and 49 deletions
+33
View File
@@ -1418,3 +1418,36 @@ and product lists and a live balance on the app profile, all working whenever
a provider is connected regardless of the feature switches; and the order
status that posts the temporary document (پیش‌فاکتور) is an admin-chosen rule
(`documents.triggerStatus`, default «تحویل مسئول فروش»).
---
## D28 — ورود اپ باید خطای واقعی را بگوید (۲۰۲۶/۱۰/۱۲)
**گزارش مالک:** «اگر کاربری در اپ نبود خطا نمیدادی کاربر یافت نشد میبردیش توی
صفحه ارسال کد و واقعا نمیشد فهمید خطا از پروژه هست یا نه پیامک نمیاد یا نه واقعا
نیست میخوام خطا بدی کاربر یافت نشد»
**تصمیم:** مرحله اول ورود باید علت را بگوید.
| حالت | قبل | بعد |
|---|---|---|
| شماره ثبت نشده | `{ sent: true }` — کاربر به صفحه کد می‌رفت | **۴۰۴** «این شماره در سیستم ثبت نشده است…» |
| پیامک فرستاده نشد (درگاه خراب) | `{ sent: true }` | **۵۰۳** «ارسال پیام کوتاه در حال حاضر ممکن نیست…» |
| کارشناس فروش با حساب معاملاتی غیرفعال | `{ sent: true }` | **۴۰۳** «حساب شما غیرفعال است…» |
| کارشناس فروش بدون پروفایل | `{ sent: true }` | **۴۰۳** «حساب شما کامل نیست…» |
**نسبت با مرجع:** این همان کاری است که مرجع می‌کرد (`firstOrFail()` در
`ApiMobileController::userAuth:43` ⇒ ۴۰۴). پاسخ مبهم، انحراف ما بود نه این.
**معاوضه امنیتی، صریح:** پاسخ مبهم برای این بود که کسی از بیرون نتواند بفهمد چه
شماره‌هایی در سیستم ثبت‌اند. این محافظت در مرحله اول کاهش یافت، **عمداً و به
دستور مالک**، چون هزینه‌اش را کاربران واقعی و پشتیبانی می‌دادند. چیزی که سر جایش
ماند: سقف تعداد ارسال برای هر شماره و هر IP (`assertSendAllowed`) که مرجع
نداشت — پس شمارش‌کردن شماره‌ها محدود است نه آزاد؛ و مرحله دوم (`verifyOtp`) که
برای همه علت‌های شکست یک پیام واحد می‌دهد.
**چرا D20 اینجا نقض نمی‌شود:** D20 می‌گوید پیامک ناموفق نباید یک *عملیات* را
خراب کند — سفارش باید ثبت شود حتی اگر پیامکش نرود. در ورود، پیامک **خودِ
عملیات** است، پس سکوت از چیزی محافظت نمی‌کند.
ثبت شده در `ISSUES.md` به‌عنوان AUTH-01/02/03 و DOC-01.