W30_live_verify — Forgot Password v4 (ticket #3273 / oms-forgt-passwd-v4)
Node: W30_live_verify (Web)
Date: 2026-09-08

SCOPE
Feature files under test (per sides.web.feature_files):
  app/(auth)/forgot-password/page.tsx
  app/(auth)/forgot-password/verify/page.tsx
  app/(auth)/forgot-password/reset/page.tsx
  components/auth/ForgotPasswordForm.tsx
  components/auth/VerifyOtpForm.tsx
  components/auth/ResetPasswordForm.tsx
  components/auth/SignInForm.tsx
Server Actions under test (lib/actions/forgot-password.ts):
  requestPasswordResetOtp, resendOtp, verifyResetOtp, resetPasswordViaOtp
ticket_spec.acceptance_criteria was an empty array; scope was taken from
requirement_text's numbered requirements + "Final Verification" checklist,
scoped strictly to the forgot-password flow (not a full app re-verification).

ENVIRONMENT SETUP (real Postgres, real Next.js dev server — no mocks)
1. Local Postgres 16 already running (oms_test / oms_test_pw @ localhost:5432,
   per .env DATABASE_URL). `prisma migrate status` showed the DB's applied
   migration history had DIVERGED from this checkout's prisma/migrations/:
     - DB had `20260904000000_password_reset_flow` applied (created a
       `PasswordResetFlow` table) which does not exist in this checkout's
       migrations directory at all — leftover from an earlier iteration of
       this ticket (branches feat/3254-... and feat/3269-... exist in git
       reflog, i.e. this is v4 of the same ticket).
     - This checkout's own `20260904090000_item_code` and
       `20260904120000_password_reset_otp` (which creates the actual
       `PasswordResetOtp` table the current code in lib/actions/forgot-password.ts
       and prisma/schema.prisma reads/writes) had NOT been applied.
   Ran `npx prisma migrate deploy` — applied both pending migrations cleanly,
   no conflicts with the stray `PasswordResetFlow` table (untouched, unrelated).
   Ran `npx prisma generate`.
2. Ran `SUPER_ADMIN_MOBILE="9840421770" npx tsx prisma/seed.ts` — .env had no
   SUPER_ADMIN_MOBILE set, so the seeded superadmin user would otherwise carry
   the intentionally-invalid "0000000" sentinel mobile (see .env.example /
   prisma/seed.ts comments). Set a real 10-digit mobile so the forgot-password
   flow has a real registered user to exercise end to end.
3. Started `npm run dev` (NODE_ENV=development implicit) on :3000. In this
   mode lib/sms.ts's sendOtp() writes the plaintext OTP to server stdout
   (`[OTP dev] <mobile>: <code>`) instead of calling a real SMS gateway — this
   is documented in-code as a dev-only escape hatch. Captured real OTPs from
   this stdout stream for each test run (never fabricated).
4. Drove the app with a real headless Chromium (Playwright, cached browser
   binaries under ~/.cache/ms-playwright, driven via node scripts run from
   /opt/drcarrot-deploy where the playwright package is available) against
   http://localhost:3000 — real HTTP requests, real Server Actions, real
   Postgres reads/writes. Full click-by-click transcript with timestamps:
   docs/gate-evidence/live-verify-3273-transcript.txt

RESULTS — golden path & edge cases (all against real DB/server, see transcript)
PASS  Sign In page has a working "Forgot Password?" link -> /forgot-password
PASS  Golden path: mobile -> OTP (real, captured from stdout) -> verify ->
      reset -> redirect to /login -> login with the NEW password succeeds
      (lands on /orders, i.e. a real authenticated session)
PASS  Old (pre-reset) password is rejected after a successful reset
PASS  Unregistered mobile: stays on /forgot-password (no redirect to verify),
      shows the generic "If a matching account is found..." anti-enumeration
      message — matches lib/actions/forgot-password.ts's documented behavior
PASS  Invalid OTP: real "Invalid code. Please check and try again." field
      error, stays on /verify
PASS  Expired OTP: forced expiry via direct DB UPDATE (expiresAt = now() -
      1 min) on a real just-issued OTP row, then submitted the original code
      -> real "This code has expired or has already been used." rejection
PASS  Reuse of an already-consumed OTP: after a full first pass consumed the
      OTP (usedAt set) and cleared fp_otp_token, re-visiting /verify directly
      redirects to /forgot-password (cookie-guard in the page component)
PASS  Resend Code: 30s countdown shown on load, button hidden until it
      elapses (waited out the real 30s, not simulated); clicking Resend
      issued a genuinely new OTP hash (verified: the pre-resend code is
      rejected afterwards, the new one succeeds); PasswordResetOtp.attempts
      incremented to 1 in the DB after one resend, confirming the
      OTP_MAX_ATTEMPTS=5 rate-limit counter is live
PASS  Password policy: a policy-violating password ("weak") leaves the
      submit button disabled client-side
PASS  Password mismatch: "Passwords do not match" inline error shown
PASS  Password history (last-3): attempting to reuse the just-set password
      is rejected with "You have used this password recently."
PASS  A second, genuinely new, compliant password completes the reset
PASS  Direct navigation to /forgot-password/verify or /forgot-password/reset
      with no fp_otp_token/fp_reset_token cookie redirects back to
      /forgot-password (guard in both page.tsx files)
PASS  Refresh mid-flow on /verify (cookie still valid) does not break the
      page; browser back/forward between step 1 and step 2 does not crash
      and the verify page remains reachable while its cookie is alive
PASS  Existing login/logout: a wrong-password login attempt for an unrelated
      seeded user (staff_test) still correctly rejects — forgot-password
      changes did not regress the existing session/auth path

CRITICAL FINDING — sendOtp() cannot deliver a real SMS outside local dev
lib/sms.ts's sendOtp() only writes the OTP to stdout when
`process.env.NODE_ENV === "development"`. In every other environment
(explicitly including production — scripts/build-prod.sh runs
`NODE_ENV=production npx next build`, and `next start`/`next build` both run
under NODE_ENV=production) it throws:
  "SMS provider not configured. Set SMS_PROVIDER_API_KEY ... Mobile: 984***"
Verified directly (not just by reading the code):
  $ NODE_ENV=production npx tsx -e "import('./lib/sms.ts').then(...)"
  -> sendOtp threw as expected in production: SMS provider not configured...
requestPasswordResetOtp's catch block swallows this and returns the generic
"We could not send the verification code. Please try again." to the user.
Net effect: in any deployed (non-dev) environment, EVERY forgot-password
attempt for EVERY registered user fails at step 1 — no OTP is ever sent, no
user can ever complete this flow. The ticket's own requirement list states
"Implement both frontend and backend/API changes if the required
forgot-password/verification functionality does not already exist" and
"Verification code must be securely generated, stored, validated, and
expired on the backend" — generation/storage/validation/expiry are all
implemented and verified above, but delivery is not, and the code says so
explicitly (lib/sms.ts's own header comment flags the SMS provider as an
unresolved "clarifications_needed" item). This is not a pre-existing/
unrelated-flow issue — it is this ticket's own OTP-delivery mechanism, and
it is unconditionally broken for any real deployment.

SECONDARY FINDING — destination mobile number not shown on the Verify screen
Requirement (explicit, with the design's own example): "Show the destination
number the code was sent to (design shows 'Enter the 6-digit code sent to
+91 98404 21770')." Verified live: components/auth/VerifyOtpForm.tsx renders
"Enter the 6-digit code we sent to your registered mobile number." — no
digits of the actual number appear anywhere on the page (checked via a live
DOM text-content scan for the seeded number's last 4 digits: not found).
Functionally the flow still works (the OTP itself is correctly scoped to the
right user via the signed cookie), but the explicit, named requirement is
not met.

ENDPOINTS SUMMARY
declared: 4 Server Actions (requestPasswordResetOtp, resendOtp,
  verifyResetOtp, resetPasswordViaOtp) in lib/actions/forgot-password.ts
tested: 4/4, each driven end-to-end at least once above with a real DB
permission_gated: 0 — all four are deliberately unauthenticated
  (pre-login) actions by design; none carry or should carry a permission
  check. permission_matrix is empty accordingly.

REPRODUCIBILITY
seed command: SUPER_ADMIN_MOBILE="9840421770" npx tsx prisma/seed.ts
  (run after `npx prisma migrate deploy`)
seeded user used for all scenarios: username=superadmin, mobile=9840421770
  (final password at end of this run: FinalPassw0rd!9 — the DB is left in
  this end state; re-running the verification flow from scratch requires
  re-seeding or resetting the password history/passwordHash for this user)
env: NODE_ENV=development, `npm run dev` on :3000, DATABASE_URL from this
  checkout's own .env (postgresql://oms_test:oms_test_pw@localhost:5432/oms_test)
