ABHA Login — Test Plan
Scope: what's actually wired in
patient/src/today. Login (5 paths) + 401-refresh interceptor + logout-with-revoke. Multi-ABHA scenarios (mobile-OTPusers[]selection) where the FE handles them. DB tables to inspect after each scenario.API source of truth:
docs/abdm/api-reference/phr-login.md,phr-patient-profile-fetch.md,phr-profile-management.md.Recent change (must re-verify on next pass): the backend patient-side login flow now upserts a
Phr::AbhaProfilerow per linked ABHA. Specifically:
verify_mobile_otp_serviceupserts one row perusers[]entry in the multi-ABHA case (afterGenerateKulcareSessionService.call(...))generate_kulcare_session_serviceupserts one row whenabha_addressis present (every other login flow that calls this service — single-ABHA Mobile OTP, ABHA Number, ABHA Address × Aadhaar / Mobile / Password, etc.)- The single-ABHA Mobile OTP path:
verify_mobile_otpskipsGenerateKulcareSessionService(no abha_address yet — user will pick one viaverify_user);verify_user_servicecallsgenerate_kulcare_session(abha_address: selected)which goes throughGenerateKulcareSessionService→ upsert. Net effect: one row is upserted per successful login, regardless of which path.NOT covered by this guide (out of scope / not wired in the FE yet):
/phr/registrations/*(no FE consumer),/phr/patients/*(no FE consumer),/phr/profile/*(no FE consumer). The backend endpoints exist but the patient app doesn't call them — test those separately against Postman or backend integration tests.
1. What's actually in the patient app
Run these greps first to confirm what's wired before testing:
# Service methods (12 login + refreshToken + revokeSession = 14)
grep -E "^ [a-zA-Z]+\(" patient/src/services/phr-auth.service.ts | head -20
# FE components that call phrAuthService
grep -rE "phrAuthService\." patient/src/features/auth/ patient/src/pages/ --include="*.tsx" --include="*.ts"
# Route paths declared in PHR_ENDPOINTS
grep -E "= '/(login|profile|patients)" patient/src/constants/api.ts
# Router wiring
grep -E "Route path=\{?APP_ROUTES" patient/src/app/App.tsx | head
# Logout invocation
grep -rE "logout\(\)" patient/src/components/ patient/src/app/ | head
# Backend upserter (this morning's fix)
grep -rn "ProfileUpserter" backend/domains/phr/services/login/Expected output:
- 14 PHR_ENDPOINTS constants (5 login flows × 2 stages + refresh_token = 14, plus the
PROFILE_SESSIONS_REVOKEconstant added in G2) LoginForm.tsx+AbhaOtpVerification.tsxare the only FE consumers ofphrAuthService- 12 service methods for login +
refreshToken+revokeSession - Routes:
/login,/abha-verify-otp, plus post-login/dashboard - Logout invoked from
AppSidebar.tsx - Backend:
ProfileUpserterexists inbackend/domains/phr/services/login/profile_upserter.rb
2. Login flows (FE-side testing — what to click)
For each scenario below: exercise the FE flow in the browser (open DevTools → Network tab → filter
phrto capture the actual request/response), then inspect the DB tables in §7.
2.1 Mobile OTP (Section 6.3) — sendMobileOtp + verifyMobileOtp + verifyUser
Files: LoginForm.tsx (method selection → mobile input form), AbhaOtpVerification.tsx (OTP + profile selection)
Scenarios:
| # | Setup | Action | Expected |
|---|---|---|---|
| 2.1.1 | Identity::User with phone 9876543210 exists | /login → ABHA Login tab → "Login with Mobile" card → enter 9876543210 → Send OTP | Loading → navigates to /abha-verify-otp. Network: POST /phr/login/mobile/request_otp. DB: new phr.requests row with action=registration_mobile_verify_request_otp, status=success |
| 2.1.2 | 2.1.1 OTP sent | Enter 6-digit OTP (auto-submits at 6 digits) | If verifyMobileOtp returns 1 ABHA → auto-fires verifyUser → Dashboard. Network: POST /phr/login/mobile/verify_otp then POST /phr/login/verify_user. DB: phr.requests rows for both, plus phr.access_tokens row created |
| 2.1.3 | 9876543210 linked to multiple ABHAs (sandbox has 6 accounts per phr-login.md §3.11) | Same as 2.1.2 but verify returns users[] | UI: "Select ABHA Profile" + RadioGroup cards. Each card shows abha_address, abha_number (or "—" for PENDING KYC), name, kyc_status badge. |
| 2.1.4 | 9876543210 linked to zero ABHAs | verifyMobileOtp returns users: [] | Toast: "No ABHA accounts are linked to this mobile number". Stays on OTP screen |
| 2.1.5 | any 2.1.x | Wrong 6-digit OTP | Toast: "OTP verification failed"; inputs cleared; first input re-focused |
| 2.1.6 | any 2.1.x | Mobile 8888888888 (not registered) | Send OTP → verify → backend error surfaced |
DB checks after successful login (apply your DB client):
| Table | What to verify |
|---|---|
identity.users | Row for the test patient mobile (9876543210 / 9815707711 / etc.) |
identity.patients | Row linking that user via user_id (has abha_address + abha_number set if single-ABHA flow) |
phr.abha_profiles | For 2.1.2 (single-ABHA): 1 row for the chosen abha_address with identity_patient_id set, phr_linked=true, linked_at set, last_verified_at set, registration_status='active'. For 2.1.3 (multi-ABHA): N rows (one per users[] entry) with identity_patient_id set, abha_linked=true, etc. |
phr.access_tokens | New row with access_token (x_token), refresh_token, expires_at non-null |
phr.requests | Outbound rows: registration_mobile_verify_request_otp, registration_mobile_login_verify_otp, possibly login_verify_user. Each has auth_mode=:mobile or :abha_address etc. |
phr.webhooks | (No inbound webhooks expected for the patient login flow) |
2.2 ABHA Address — Mobile OTP (Section 12.1)
Files: LoginForm.tsx (method → OTP type picker → address form), AbhaOtpVerification.tsx
| # | Action | Expected |
|---|---|---|
| 2.2.1 | ABHA Address → Mobile OTP → john123@sbx → Send OTP | OTP screen; subtitle mentions "registered mobile" |
| 2.2.2 | Submit valid OTP | verifyAbhaAddressOtp → Dashboard. Network: POST /phr/login/mobile_verify_otp |
| 2.2.3 | DB | phr.abha_profiles: 1 row for john123@sbx, identity_patient_id set, phr_linked=true, last_verified_at set. phr.access_tokens: 1 row. phr.requests: 1 outbound row (action enum will likely be abha_address_verify_otp or similar) |
2.3 ABHA Address — Aadhaar OTP (Section 6.1)
| # | Action | Expected |
|---|---|---|
| 2.3.1 | ABHA Address → Aadhaar OTP → jane@sbx | Subtitle mentions "Aadhaar OTP" |
| 2.3.2 | Submit valid Aadhaar OTP | verifyAbhaAddressAadhaarOtp → Dashboard |
| 2.3.3 | DB | Same as 2.2.3 but for jane@sbx. Also verify phr.requests action=abha_address_aadhaar_verify_otp |
2.4 ABHA Number — Mobile OTP (Section 6.2)
| # | Action | Expected |
|---|---|---|
| 2.4.1 | ABHA Number → Mobile OTP → enter 12-3456-7890-1234 (auto-formats 12345678901234 → dashed form) | Form accepts |
| 2.4.2 | 46-0315-7614-552 (13 digits) | Schema rejects: "Enter a valid ABHA number" |
| 2.4.3 | Submit valid | → Dashboard |
| 2.4.4 | DB | phr.abha_profiles: 1 row with abha_number="12345678901234" (dashes stripped — verify domain convention). abha_address should also be set (extracted from token payload via preferredAbhaAddress). phr.requests action=abha_number_verify_otp |
2.5 ABHA Number — Aadhaar OTP (Section 6.1)
Same pattern as 2.4 with Aadhaar subtitle. phr.requests action=abha_number_aadhaar_verify_otp.
2.6 Password login (§12)
| # | Action | Expected |
|---|---|---|
| 2.6.1 | ABHA Address → enter address → search (searchAbhaAddress) | Returns authMethods: [...] (e.g. ['PASSWORD', 'AADHAAR_OTP', ...]) |
| 2.6.2 | If PASSWORD in authMethods, FE shows password field | |
| 2.6.3 | Submit abha_address + password (FE RSA-encrypts before send) | verifyAbhaAddressPassword → Dashboard |
| 2.6.4 | DB | phr.abha_profiles: 1 row for the address, last_verified_at set |
3. 401-refresh interceptor (G1)
Files: patient/src/api/phr-client.ts (interceptor), patient/src/services/phr-auth.service.ts (refreshToken method)
3.1 Happy path: refresh + retry
Setup: log in successfully → DevTools → Application → Local Storage → kulcare-auth-token + kulcare-refresh-token are populated.
| # | Action | Expected |
|---|---|---|
| 3.1.1 | Make any PHR call from the patient app | Succeeds (token fresh). Network: 200 response |
| 3.1.2 | Manually corrupt kulcare-auth-token in DevTools to invalid_jwt_token and retry the same PHR call | Next PHR call returns 401 → interceptor calls refreshToken with the refresh_token → retries with new token → succeeds. Network: 3 requests visible — original (401) + login_refresh_token (200) + retry of original (200) |
| 3.1.3 | DB | phr.access_tokens: 2 rows (old + new). phr.requests: 1 login_refresh_token row + the original call's first-401 row + the retry's success row |
| 3.1.4 | localStorage | kulcare-auth-token = new token (from refresh response) |
3.2 Refresh-fails edge cases
| # | Scenario | Expected |
|---|---|---|
| 3.2.1 | Manually delete kulcare-refresh-token from localStorage → make any PHR call | Original 401 bubbles up (no refresh possible); FE surfaces auth-required error |
| 3.2.2 | Manually corrupt kulcare-refresh-token to invalid → call protected endpoint | refresh returns 401 (ABDM upstream); interceptor does NOT retry (no infinite loop due to _retry flag); original 401 bubbles up |
| 3.2.3 | Direct call to /login/* returns 401 (e.g. wrong credentials) | Interceptor does NOT fire (filter: !originalRequest.url.includes('/login/')) |
3.3 Known gap (documented)
The phr-client request interceptor sets REQUEST-ID + TIMESTAMP on every request, but does NOT inject X-token (the ABDM session token). The interceptor's refresh-on-401 path is the only way the FE authenticates for endpoints that don't set X-token explicitly (e.g. /phr/patients/*, /phr/profile/*). The plan's refresh flow works for these.
phrAuthService.verifyUser and refreshToken set the X-Token header per-call and don't go through the interceptor — they work directly.
4. Logout + session revocation (G2)
Files: patient/src/stores/auth.store.ts (logout action), patient/src/services/phr-auth.service.ts (revokeSession), patient/src/components/layout/AppSidebar.tsx (logout UI trigger)
4.1 Happy path: identity logout + phr revoke
| # | Action | Expected |
|---|---|---|
| 4.1.1 | Log in as patient with at least one Phr::AbhaProfile row + at least one Phr::PhrAccessToken row | UI shows logged-in state |
| 4.1.2 | Click logout (sidebar menu) | Two requests fire in order: DELETE /identity/sessions/logout (or POST per current API contract), then POST /phr/profile/sessions/revoke with {identity_patient_id: ...} |
| 4.1.3 | FE navigates to /login (or /) | useAuthStore.getState() shows user: null, isAuthenticated: false, identityPatientId: null |
| 4.1.4 | DB: phr.access_tokens | Count for this identity_patient decremented (revoke deletes the rows) |
| 4.1.5 | DB: phr.abha_profiles | Count UNCHANGED (revoke does NOT touch profiles, only access tokens) |
| 4.1.6 | localStorage | kulcare-auth-token, kulcare-refresh-token, kulcare-user-details cleared |
| 4.1.7 | Re-login with the same ABHA | Fresh Phr::AccessTokens row (count +1); existing Phr::AbhaProfile row is REUSED (no new row); last_verified_at is updated to the new login time |
4.2 Edge cases
| # | Scenario | Expected |
|---|---|---|
| 4.2.1 | Logout when identityPatientId is null (e.g. logged in via phone-password, not PHR) | revokeSession is skipped; identity logout still completes |
| 4.2.2 | Logout when revokeSession call fails (network down) | Logout still completes locally; console.warn('phr revoke_session failed (continuing logout)') is logged; identity logout already succeeded |
| 4.2.3 | Logout when localStorage is empty | revokeSession call body has identity_patient_id: null; backend returns 422; caught + swallowed |
5. Multi-ABHA scenarios
5.1 First-login users[] selection (verifyMobileOtp)
Setup: mobile 9876543210 is linked to multiple ABHAs in sandbox.
| # | Action | Expected |
|---|---|---|
| 5.1.1 | Login via Mobile OTP for 9876543210 | UI shows "Select ABHA Profile" + RadioGroup cards (one per linked ABHA on phr.abha_profiles for the identity_patient). Verify in DB that there are N rows for this identity_patient with phr_linked=true — these were upserted by verify_mobile_otp_service during the multi-ABHA branch, before the picker was even shown. |
| 5.1.2 | Card sort order | VERIFIED kyc_status first, PENDING after (verify) |
| 5.1.3 | Pick a PENDING KYC card | Backend accepts; JWT's abha_number claim is null |
| 5.1.4 | Pick a card, complete login | DB: NO new Phr::AbhaProfile row (the profile already exists from the verify_otp upsert — the picker just picked one). Only Phr::PhrAccessToken row is created/updated. Verify last_verified_at updated on the picked row. |
| 5.1.5 | DB sanity check after multi-ABHA login | All N Phr::AbhaProfile rows for this identity_patient have last_verified_at from the CURRENT login (verify_otp upsert) OR only the picked row has the current time (verify_user upsert). Either pattern is acceptable — the current code uses verify_otp upsert for ALL users in users[], so all N get updated. Verify by sorting last_verified_at desc and checking all top N rows are for this identity_patient. |
5.2 JWT active-profile claim inspection
Test: decode the JWT after login (use jwt.io or any JWT decoder). Verify the active_abha_profile_id claim equals the selected profile's id. On any subsequent login (same ABHA, same profile), the claim should be unchanged.
6. Resend / guard / state (small UX tests)
| # | Action | Expected |
|---|---|---|
| 6.1 | Arrive at OTP screen, click "Resend" before countdown expires | Button disabled, no request fired |
| 6.2 | Wait for countdown to expire (60s), click Resend | New txnId fetched; phrOtpState updated; OTP inputs cleared |
| 6.3 | Navigate directly to /abha-verify-otp without sending OTP | Guard fires → toast "Session expired or timed out" → redirect to /login |
| 6.4 | After login, refresh page | Auth persists (tokens in localStorage); UI shows logged-in |
| 6.5 | Refresh page mid-flow (after Send OTP, before Verify) | phrOtpState is NOT persisted (by design) → redirect to /login |
7. DB tables to inspect (no SQL — use your DB client)
| Table | What to look for |
|---|---|
identity.users | Rows for the test patient mobile (e.g. 9876543210 / 9815707711) |
identity.patients | Rows linking to those users via user_id. Will have abha_address + abha_number populated for the last-logged-in ABHA (single-ABHA path) |
clinical.patients | NO abha_id / abha_number columns (dropped per multi-ABHA plan). ABHA identity lives in phr.abha_profiles |
phr.abha_profiles | Rows keyed by (abha_address, identity_patient_id). After a successful login, the test patient should have at least 1 row (single-ABHA) or N rows (multi-ABHA). Each row has phr_linked=true, linked_at set on first create, last_verified_at updated on every login |
phr.access_tokens | One row per successful login; deleted by revoke_session. access_token field is the ABDM x_token |
phr.requests | Outbound ABDM calls (request/response audit); action names from PHR_ACTIONS enum. Refresh-token path produces a login_refresh_token row |
phr.webhooks | (No inbound webhooks expected for the patient login flow) |
abdm_integration.abdm_auth_sessions | CMS-side multi-step OTP state for verifications/* (not used by patient FE) |
8. Reporting failures
For each failure:
Test ID: [e.g. 2.1.2]
Steps taken: [paste actions]
Expected: [from the table]
Actual: [observed]
FE console: [errors]
Network tab: [request/response bodies]
DB state: [which tables you checked + values]
Reproduction: [steps]
Root cause: [your analysis]
Fix: [proposed fix or N/A — backend issue]