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] : `) 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)