Kulcare Docs
AbdmPhr

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-OTP users[] 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::AbhaProfile row per linked ABHA. Specifically:

  • verify_mobile_otp_service upserts one row per users[] entry in the multi-ABHA case (after GenerateKulcareSessionService.call(...))
  • generate_kulcare_session_service upserts one row when abha_address is 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_otp skips GenerateKulcareSessionService (no abha_address yet — user will pick one via verify_user); verify_user_service calls generate_kulcare_session(abha_address: selected) which goes through GenerateKulcareSessionService → 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_REVOKE constant added in G2)
  • LoginForm.tsx + AbhaOtpVerification.tsx are the only FE consumers of phrAuthService
  • 12 service methods for login + refreshToken + revokeSession
  • Routes: /login, /abha-verify-otp, plus post-login /dashboard
  • Logout invoked from AppSidebar.tsx
  • Backend: ProfileUpserter exists in backend/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 phr to 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:

#SetupActionExpected
2.1.1Identity::User with phone 9876543210 exists/login → ABHA Login tab → "Login with Mobile" card → enter 9876543210 → Send OTPLoading → 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.22.1.1 OTP sentEnter 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.39876543210 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.49876543210 linked to zero ABHAsverifyMobileOtp returns users: []Toast: "No ABHA accounts are linked to this mobile number". Stays on OTP screen
2.1.5any 2.1.xWrong 6-digit OTPToast: "OTP verification failed"; inputs cleared; first input re-focused
2.1.6any 2.1.xMobile 8888888888 (not registered)Send OTP → verify → backend error surfaced

DB checks after successful login (apply your DB client):

TableWhat to verify
identity.usersRow for the test patient mobile (9876543210 / 9815707711 / etc.)
identity.patientsRow linking that user via user_id (has abha_address + abha_number set if single-ABHA flow)
phr.abha_profilesFor 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_tokensNew row with access_token (x_token), refresh_token, expires_at non-null
phr.requestsOutbound 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

#ActionExpected
2.2.1ABHA Address → Mobile OTP → john123@sbx → Send OTPOTP screen; subtitle mentions "registered mobile"
2.2.2Submit valid OTPverifyAbhaAddressOtp → Dashboard. Network: POST /phr/login/mobile_verify_otp
2.2.3DBphr.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)

#ActionExpected
2.3.1ABHA Address → Aadhaar OTP → jane@sbxSubtitle mentions "Aadhaar OTP"
2.3.2Submit valid Aadhaar OTPverifyAbhaAddressAadhaarOtp → Dashboard
2.3.3DBSame 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)

#ActionExpected
2.4.1ABHA Number → Mobile OTP → enter 12-3456-7890-1234 (auto-formats 12345678901234 → dashed form)Form accepts
2.4.246-0315-7614-552 (13 digits)Schema rejects: "Enter a valid ABHA number"
2.4.3Submit valid→ Dashboard
2.4.4DBphr.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)

#ActionExpected
2.6.1ABHA Address → enter address → search (searchAbhaAddress)Returns authMethods: [...] (e.g. ['PASSWORD', 'AADHAAR_OTP', ...])
2.6.2If PASSWORD in authMethods, FE shows password field
2.6.3Submit abha_address + password (FE RSA-encrypts before send)verifyAbhaAddressPassword → Dashboard
2.6.4DBphr.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.

#ActionExpected
3.1.1Make any PHR call from the patient appSucceeds (token fresh). Network: 200 response
3.1.2Manually corrupt kulcare-auth-token in DevTools to invalid_jwt_token and retry the same PHR callNext 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.3DBphr.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.4localStoragekulcare-auth-token = new token (from refresh response)

3.2 Refresh-fails edge cases

#ScenarioExpected
3.2.1Manually delete kulcare-refresh-token from localStorage → make any PHR callOriginal 401 bubbles up (no refresh possible); FE surfaces auth-required error
3.2.2Manually corrupt kulcare-refresh-token to invalid → call protected endpointrefresh returns 401 (ABDM upstream); interceptor does NOT retry (no infinite loop due to _retry flag); original 401 bubbles up
3.2.3Direct 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

#ActionExpected
4.1.1Log in as patient with at least one Phr::AbhaProfile row + at least one Phr::PhrAccessToken rowUI shows logged-in state
4.1.2Click 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.3FE navigates to /login (or /)useAuthStore.getState() shows user: null, isAuthenticated: false, identityPatientId: null
4.1.4DB: phr.access_tokensCount for this identity_patient decremented (revoke deletes the rows)
4.1.5DB: phr.abha_profilesCount UNCHANGED (revoke does NOT touch profiles, only access tokens)
4.1.6localStoragekulcare-auth-token, kulcare-refresh-token, kulcare-user-details cleared
4.1.7Re-login with the same ABHAFresh 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

#ScenarioExpected
4.2.1Logout when identityPatientId is null (e.g. logged in via phone-password, not PHR)revokeSession is skipped; identity logout still completes
4.2.2Logout 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.3Logout when localStorage is emptyrevokeSession 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.

#ActionExpected
5.1.1Login via Mobile OTP for 9876543210UI 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.2Card sort orderVERIFIED kyc_status first, PENDING after (verify)
5.1.3Pick a PENDING KYC cardBackend accepts; JWT's abha_number claim is null
5.1.4Pick a card, complete loginDB: 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.5DB sanity check after multi-ABHA loginAll 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)

#ActionExpected
6.1Arrive at OTP screen, click "Resend" before countdown expiresButton disabled, no request fired
6.2Wait for countdown to expire (60s), click ResendNew txnId fetched; phrOtpState updated; OTP inputs cleared
6.3Navigate directly to /abha-verify-otp without sending OTPGuard fires → toast "Session expired or timed out" → redirect to /login
6.4After login, refresh pageAuth persists (tokens in localStorage); UI shows logged-in
6.5Refresh 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)

TableWhat to look for
identity.usersRows for the test patient mobile (e.g. 9876543210 / 9815707711)
identity.patientsRows linking to those users via user_id. Will have abha_address + abha_number populated for the last-logged-in ABHA (single-ABHA path)
clinical.patientsNO abha_id / abha_number columns (dropped per multi-ABHA plan). ABHA identity lives in phr.abha_profiles
phr.abha_profilesRows 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_tokensOne row per successful login; deleted by revoke_session. access_token field is the ABDM x_token
phr.requestsOutbound 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_sessionsCMS-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]

On this page