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
+62
View File
@@ -929,3 +929,65 @@ Noted in the code: with the IAM enabled, silent SSO leaves the page before this
guard runs, so the toast is a no-op there by design, not a defect.
_No data was deleted in this entry._
---
## 2026-10-12 — App login — say what went wrong (AUTH-01/02/03, DOC-01)
- **What:** Step 1 of the app login now reports an unregistered number (404) and
a failed SMS send (503) instead of answering success in both cases. A sales
expert whose trading account cannot be used gets its own 403 rather than the
unknown-number message. Both apps surface the server's Persian message for a
404 as they already did for a 429.
- **Why:** owner report — a number that was not in the system produced no error:
the app moved to the code box and «واقعا نمیشد فهمید خطا از پروژه هست یا نه
پیامک نمیاد یا نه واقعا نیست». Three causes looked identical on screen, and
only one of them was something the person could act on.
- **Reference:** `ApiMobileController::userAuth:43` — `firstOrFail()`, i.e. 404
for a number with no active customer. **The opaque answer was the deviation;
this restores the reference.**
- **Target:** `apps/api/src/modules/customer-auth/customer-auth.service.ts`
(`requestOtp`, `deliver`, `resolveExpertTradingAccount`), both apps'
`src/views/user/LoginView.vue`.
- **Matrix row:** AUTH-01, AUTH-02, AUTH-03, DOC-01 in `ISSUES.md`.
- **Commit:** see below.
**The security trade-off, stated plainly.** The opaque response existed to stop
an outsider enumerating which mobile numbers are registered. That protection is
now reduced at step 1, deliberately, on the owner's instruction: the cost was
being paid by real users and by support on every login problem. What still
stands — the per-mobile and per-IP send throttles (`assertSendAllowed`), which
the reference did not have, so enumeration is rate-bounded rather than free; and
`verifyOtp`, which keeps one message for every failure reason. `ISSUES.md`
SEC-03's "no enumeration" claim was corrected rather than left standing.
**Two further problems found while fixing the reported one.** Both were the same
shape — an operation reporting success while doing nothing:
1. A failed SMS send also returned `{ sent: true }`, because `SmsService` never
throws (D20). A dead gateway therefore produced the exact same dead end. D20
is about not letting a failed SMS break an *operation*; in a login the SMS is
the operation, so it now fails loudly. The local-dev path is preserved by
keying 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 test caught
exactly this**, on the first run.
2. Three states return no trading account for a sales expert who *does* exist.
Under the new 404 they would each have claimed the number was not registered
— a false message that sends support looking for the wrong thing.
**A stale comment that actively misled.** Both apps' `LoginView.vue` header still
described the login as stubbed: no SMS provider, any 4-digit code accepted, and
step 1 matching against the customer list in the client (which was the login
leak, D8). All three had been untrue for weeks, and it misled a reader during
this very session. Corrected in place, with that noted.
**Verification.** `requestOtp` had **no tests at all** — the 28-test customer-auth
suite stayed green through a change to its response for every unregistered
number, which is why the suite's greenness was not treated as evidence.
`request-otp.spec.ts` adds 9 cases, including that the dev fallback does *not*
apply in production. Full api suite **295/295, 27 suites**; both apps `vue-tsc`
clean.
_No data was deleted in this entry._