Files
halobestie-clone/requirement/phase3.4-testing.md
ramadhan sjamsani a48f108fc0 Phase 4 §2.1: anonymous → existing-user merge breadcrumb
Adds `customers.account_belongs_to UUID NULL` and refactors customer
sign-in (phone/Google/Apple) so an anon row that re-verifies into an
existing customer no longer 409s. Instead the anon row stays intact
with a breadcrumb pointing at the real customer; tokens are issued
for the existing user. Actual data reconciliation onto the existing
row (chat_sessions, customer_transactions, payment_sessions,
pairing_failures) is deferred.

Backend
- migrate.js: ADD COLUMN account_belongs_to UUID REFERENCES customers(id)
  ON DELETE SET NULL.
- customer.service.js: stampAccountBelongsTo helper; account_belongs_to
  exposed in CUSTOMER_SELECT.
- auth.service.js: new shared resolveCustomerForIdentity (4-case logic);
  normalizeIdentityConflict + IDENTITY_ALREADY_LINKED 409 deleted;
  completeCustomerPhoneSignIn / signInWithGoogle / signInWithApple all
  route through the shared helper.
- client.auth.routes.js: new resolveAnonymousCustomerId picks the anon
  prefix ONLY from a verified Bearer JWT — closes the UUID-leak attack
  where a tamper-able body field could mis-route someone else's
  transactions. /otp/verify, /google, /apple all use it; the body field
  `anonymous_customer_id` is no longer accepted on any of them.
- test/services/auth.service.test.js: 9 Vitest cases covering phone +
  Google + Apple, all 4 logic cases + multi-merge accumulation.

Customer app
- auth_notifier.dart::verifyOtp: drop `skipAuth: true` and the dead
  body field so ApiClient auto-attaches the anon's Bearer from
  AuthBridge. Survives the AuthOtpSentData state transition (the
  earlier `_currentAnonymousCustomerId()` state-drop bug is bypassed by
  sourcing the id from the bridge instead of state).
- Google + Apple client paths remain unchanged (gated on provider
  creds; mirror this fix when wiring lands).

Docs
- flow_customer.mermaid.md: new §2.1 sub-section with the merge
  diagram, schema note, replaces-current-behaviour paragraph, and
  Bearer-only security callout.
- phase3.4-testing.md: §1.5 line 76 simplified (no more per-path
  split); new §1.5.1 with the 5-step operator scenario + DB invariants
  + curl recipe + Vitest pointer; new §1.5.2 covering Google/Apple
  parity (deferred client work flagged).

Verification (against live dev backend, before this commit):
- Vitest: 9/9 in auth.service.test.js; 49/51 overall (2 unrelated
  pre-existing failures in session-timer.service.test.js).
- Operator Node smoke: 14/14 in the §1.5.1 scenario; 11/11 in the
  Bearer-precedence cases.
- Real-device UI walkthrough on SM-A530F still pending — see resume
  memory `project_phase4_2_1_resume_test`.

Sister WIP bundled in migrate.js + customer.service.js: `usp_seen`
column + `markCustomerUspSeen` helper (Phase 4 USP one-time gate, was
already uncommitted in the working tree).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:57:53 +08:00

38 KiB
Raw Permalink Blame History

Phase 3.4 Testing & Outstanding Regression Checklist

Consolidated testing document covering Phase 3.4 (self-managed auth) plus every outstanding test item carried over from Phases 3.0 3.3. Replaces phase3.3-testing.md.

Tick boxes as you verify. Cluster labels in brackets: [BE] backend / curl, [CC] control_center, [M] mitra_app, [C] client_app.

Related docs: phase3.4.md, phase3.4-plan.md, phase3.3.md, phase3.3-plan.md.


Part 1 — Phase 3.4: Self-Managed Auth

1.1 Backend: Schema, Services, Env

  • [BE] npm run db:migrate is idempotent — re-running reports "skipping" on existing objects, adds new 3.4 tables/columns, exits 0
  • [BE] auth_sessions, otp_requests tables exist with correct columns + indexes
  • [BE] customers has new nullable columns: email, google_sub (UNIQUE), apple_sub (UNIQUE)
  • [BE] control_center_users has password_hash, failed_login_count, lockout_until
  • [BE] firebase_uid columns still exist but are nullable + unused by code (cleanup migration deferred)
  • [BE] app_config seeded with 6 new OTP / CC-lockout keys at default values
  • [BE] npm run db:seed creates the super admin with bcrypt-hashed password (runs twice → second is a no-op)
  • [BE] Server boots cleanly on INTERNAL_PORT=3001 + PUBLIC_PORT=3000
  • [BE] Missing AUTH_JWT_SECRET (or <32 chars) → server refuses to start / logs a clear error
  • [BE] CORS config on internal listener allows CC_ORIGIN with credentials: true

1.2 Backend: Token & Session Service (curl)

  • [BE] Access token claims: { sub, user_type, session_id, iat, exp } — HS256, 1h TTL
  • [BE] Refresh token is opaque (uuid.random), 30d TTL, bcrypt-hashed in auth_sessions.refresh_token_hash
  • [BE] Refresh rotates: old refresh after one use → REFRESH_INVALID, same session_id persists, new access token issued
  • [BE] Logout deletes the auth_sessions row → subsequent refresh → REFRESH_INVALID
  • [BE] Multi-device: same user signs in twice → two auth_sessions rows; logout on one doesn't kill the other
  • [BE] Tampered JWT (signature broken) → 401 TOKEN_INVALID
  • [BE] Expired JWT → 401; api_client auto-refreshes and retries once
  • [BE] device_info JSONB populated with user_agent + ip on each session
  • [BE] Session fingerprint survives refresh (no churn)
  • [BE] Documented 1h access-token revocation window: post-logout access token still verifies until natural expiry (expected — Valkey revoked_sessions pre-wired but not active)

1.3 Backend: OTP Service (stub mode)

Fazpass is stubbed. Dev code is logged to backend console as [OTP STUB] phone=… code=… ref=….

  • [BE] POST /api/client/auth/otp/request with valid phone → 200 + otp_request_id, stub logs code
  • [BE] POST /api/mitra/auth/otp/request same, separate user_type
  • [BE] Invalid phone format (not E.164) → 422 PHONE_INVALID
  • [BE] Resend within otp_resend_cooldown_seconds (default 60) → 429 OTP_COOLDOWN
  • [BE] 4th request in an hour from same phone → 429 OTP_RATE_LIMIT_PHONE
  • [BE] 11th request in an hour from same IP → 429 OTP_RATE_LIMIT_IP
  • [BE] OTP verify with wrong code → 401 CODE_MISMATCH, attempts incremented
  • [BE] 6th verify attempt (after 5 wrongs) → 429 OTP_ATTEMPTS_EXCEEDED
  • [BE] OTP verify after expires_at (default 5min) → 410 OTP_EXPIRED
  • [BE] OTP verify twice with correct code → second call → 409 OTP_USED
  • [BE] Mitra OTP verified via /api/client/auth/otp/verify → 400 WRONG_FLOW
  • [BE] Customer OTP verified via /api/mitra/auth/otp/verify → 400 WRONG_FLOW

1.4 Backend: Social Identity (post-creds)

Deferred until Google/Apple OAuth credentials land. Run this block after setting GOOGLE_OAUTH_CLIENT_IDS + APPLE_* in .env and flipping ENABLE_SOCIAL_AUTH=true on client_app.

  • [BE] Google: valid id_token → customer created / upgraded, tokens issued
  • [BE] Google: wrong audience (id_token from different client) → 401 INVALID_ID_TOKEN
  • [BE] Google: tampered signature → 401 INVALID_ID_TOKEN
  • [BE] Google: google_sub already linked to another customer → 409 IDENTITY_CONFLICT (merge deferred)
  • [BE] Apple: valid id_token → customer created / upgraded
  • [BE] Apple: invalid signature (JWKS mismatch) → 401 INVALID_ID_TOKEN
  • [BE] Apple: missing email (user opted out) → account still created, email NULL
  • [BE] Apple: apple_sub already linked elsewhere → 409 IDENTITY_CONFLICT

1.5 Backend: Anonymous + Upgrade

  • [BE] POST /api/shared/auth/anonymous → creates customer with auto-generated display_name, returns tokens + profile
  • [BE] Anonymous customer row: phone=NULL, google_sub=NULL, apple_sub=NULL
  • [BE] OTP verify with anonymous_customer_id → upgrades SAME customer row; customer UUID unchanged; display_name preserved
  • [BE] Google verify with anonymous_customer_id → upgrades SAME customer row; google_sub added; display_name preserved if present, else backfilled from Google profile
  • [BE] Apple verify with anonymous_customer_id → upgrades SAME row
  • [BE] Upgrade with anonymous_customer_id when identity is ALREADY taken by a different customer → 200 with merge breadcrumb (see §1.5.1 below). Applies uniformly to phone, Google, and Apple paths (Phase 4 §2.1).
  • [BE] Upgrade issues a NEW auth_sessions row. Old anonymous refresh still works (separate session) and reflects the upgraded profile on subsequent calls — intentional for multi-device UX; do not "fix" without discussion

1.5.1 Backend: Anonymous → existing-user merge breadcrumb (Phase 4 §2.1)

End-to-end scenario where a verified user accidentally transacts as anonymous on a fresh install, then re-verifies the same phone. The flow must NOT 409 and must stamp the breadcrumb on the anonymous row so reconciliation can move its transactions onto the real account later. Spec: flow_customer.mermaid.md §2.1.

Setup

  • Verified customer row User-A exists with phone=+62…X, display_name="Wati", is_anonymous=false. (Created earlier via OTP sign-up.)

Scenario steps

  1. [C] Customer logs in successfully using phone +62…X → app holds User-A tokens (smoke-verifiable: /api/client/auth/me returns User-A's id + display_name="Wati").
  2. [C] Customer taps logout → refresh token revoked; app state returns to AuthInitialData.
  3. [C] Customer reopens the app and proceeds through onboarding → S2 Nama "Bujak" → VerifChoice → "tanpa verifikasi" (anonymous) → backend creates Anon-B with is_anonymous=true, display_name="Bujak", phone=NULL.
  4. [C] Customer completes ≥1 transaction as Anon-B (chat session, payment session, etc.) → rows in chat_sessions / customer_transactions carry customer_id = Anon-B.id.
  5. [C] Customer goes back through VerifChoice → "verifikasi nomor HP" → enters the same +62…X → OTP verify request carries Authorization: Bearer <Anon-B's access_token> (issued by /api/shared/auth/anonymous in step 3). The backend derives the anon id from the Bearer JWT only — there is no body field for anonymous_customer_id. The same Bearer-only contract applies to /api/client/auth/google and /api/client/auth/apple (client_app does not exercise those paths yet — creds pending — but the backend behaves identically when they land).

Expected

  • [BE] POST /api/client/auth/otp/verify returns 200 (NOT 409).
  • [BE] Response data.profile.id === User-A.id (the existing account, not Anon-B).
  • [BE] Response data.profile.display_name === "Wati" (overwrites the locally-typed "Bujak" per mermaid §2 line 62).
  • [BE] Response data.access_token decodes to { sub: User-A.id, user_type: 'customer', … } — subsequent authenticated calls act as User-A.
  • [BE] DB invariant: SELECT account_belongs_to FROM customers WHERE id = Anon-B.idUser-A.id. Breadcrumb stamped.
  • [BE] DB invariant: Anon-B row still exists — is_anonymous=true, display_name="Bujak" preserved, phone=NULL. NOT deleted.
  • [BE] DB invariant: Anon-B's chat_sessions / customer_transactions FKs are unchanged (still point at Anon-B.id). Actual data reconciliation onto User-A is the deferred phase; the breadcrumb is what enables it later.
  • [BE] Re-running the same flow on a NEW fresh install (different Anon-C) with the same phone → second breadcrumb accumulates (Anon-C.account_belongs_to = User-A.id). Many anon rows may point at the same real user — fine.

Negative checks

  • [BE] If the customer logs in directly with their phone (no anonymous prefix, fresh install) → 200, no merge breadcrumb on any row (Case 2a of the spec).
  • [BE] If the customer re-verifies their own phone while logged in as User-A (anonymous_customer_id == existing.id) → 200, account_belongs_to stays NULL (no self-stamping, Case 2b).
  • [BE] New phone (not in DB) + anonymous_customer_id set → anon row upgraded in place (is_anonymous=false, phone set, display_name preserved); no merge breadcrumb (Case 3 of spec).

Auth source for anonymous_customer_id (security note)

The backend derives the anon id only from a verified Bearer JWT presented as Authorization: Bearer <token>. The body field anonymous_customer_id is no longer read — accepting it would let anyone who learns a victim's anon UUID stamp the merge breadcrumb on it. Bearer tokens are HS256-signed with AUTH_JWT_SECRET and cannot be forged.

The resolver (resolveAnonymousCustomerId in client.auth.routes.js) returns:

  • The anon's customer.id, if the Bearer is valid AND is_anonymous = true.
  • null if the Bearer is missing, invalid, expired, or belongs to a verified customer.

Curl-runnable smoke (against running dev backend; uses fazpass_reference to read the stub OTP code from DB):

# Pre: insert User-A and Anon-B via SQL; Anon-B's access_token is what the
# /api/shared/auth/anonymous endpoint returned.
curl -X POST $BASE/api/client/auth/otp/request \
  -H 'content-type: application/json' \
  -d '{"phone":"+62…X"}'   # → otp_request_id

# Look up stub code:
psql -c "SELECT fazpass_reference FROM otp_requests WHERE id='<otp_request_id>'"
# fazpass_reference is "stub_<uuid>:<code>"

curl -X POST $BASE/api/client/auth/otp/verify \
  -H 'content-type: application/json' \
  -H "Authorization: Bearer <Anon-B's access_token>" \
  -d '{"otp_request_id":"…","code":"<code>"}'
# Expect 200; profile.id === User-A.id; profile.display_name === "Wati"

psql -c "SELECT id, account_belongs_to FROM customers WHERE id='<Anon-B.id>'"
# Expect account_belongs_to === User-A.id

Vitest coverage for the boundary logic: backend/test/services/auth.service.test.js covers all 4 service-level cases + multi-merge accumulation (6 tests). The route-layer Bearer enforcement was operator-verified with 11 additional checks against the live backend (5 cases: anon-Bearer, attacker-tries-body, no-Bearer-body-ignored, verified-Bearer, garbage-Bearer) — all green; the test script is intentionally not retained in-tree (one-off operator smoke).

1.5.2 Backend: Google / Apple parity for the merge breadcrumb (Phase 4 §2.1)

The same 4-case merge logic (resolveCustomerForIdentity in auth.service.js) is applied to signInWithGoogle and signInWithApple. Both /api/client/auth/google and /api/client/auth/apple derive the anon prefix only from the Bearer JWT; the body field anonymous_customer_id is not read.

client_app does not exercise these routes today. Google / Apple SDK integration on the customer app is gated on authProvidersProvider and won't trigger until provider credentials are provisioned. The backend tests below cover the behaviour so the merge is correct the moment the client wiring lands. When that happens, the client work mirrors the phone-OTP fix: drop skipAuth: true and any anonymous_customer_id body field on loginGoogle / loginApple so ApiClient attaches the anon's Bearer automatically.

Expected behaviour — uniform with the phone path

Path Existing identity? Anon prefix (Bearer)? Outcome
/google yes, different id yes Anon row gets account_belongs_to = Existing.id; tokens for Existing. Anon row preserved.
/google no yes Anon row upgraded in place (google_sub + email set; display_name preserved via COALESCE; is_anonymous=false).
/google yes no Returns existing; no merge.
/google no no Creates a fresh row with display_name=null; client routes to AuthNeedsDisplayNameData.
/apple (same matrix) (same outcomes; apple_sub instead of google_sub)

Provider-specific notes

  • Display name is not taken from the provider. Google's name claim is intentionally ignored (anonymous-chose name wins); Apple's first-launch display_name behaviour (Apple withholds it on subsequent sign-ins) is irrelevant for the same reason.
  • email is recorded on the customer row when present in the verified id_token; subsequent OTP/Apple sign-ins don't overwrite a non-null email via COALESCE.

Checklist

  • [BE] Vitest signInWithGoogle — Case 1 (existing google_sub on a DIFFERENT customer + anon prefix) → 200; stamps account_belongs_to; returns existing.
  • [BE] Vitest signInWithGoogle — Case 3 (new google_sub + anon prefix) → 200; anon row upgraded in place; display_name preserved; google_sub/email set.
  • [BE] Vitest signInWithApple — Case 1 (existing apple_sub on a DIFFERENT customer + anon prefix) → 200; stamps account_belongs_to; returns existing.
  • [BE] Route-level (curl): POST /api/client/auth/google with Authorization: Bearer <Anon's access_token> and a valid id_token for an existing Google account → 200 + merge breadcrumb on the anon row.
  • [BE] Route-level (curl): same call WITHOUT Bearer (body has anonymous_customer_id instead) → 200 sign-in completes, but the body field is ignored; anon row's account_belongs_to stays NULL. Confirms the security hardening from §1.5.1 covers Google as well.
  • [BE] Identical curl smokes for /api/client/auth/apple.

The "valid id_token" requirement makes the curl checks non-trivial without provider credentials. Until creds land, the Vitest layer is the authoritative coverage; the curl checklist serves as an operator runbook for the day client wiring goes live.

Vitest coverage lives in backend/test/services/auth.service.test.js alongside the phone tests (9 tests total: 6 phone + 1 Google upgrade + 1 Google stamp + 1 Apple stamp). The social-identity verifiers are mocked at module scope so tests don't require real id_tokens.

1.6 Backend: Auth Middleware + Cross-User-Type Guards

  • [BE] Protected route with no Authorization header → 401 AUTH_MISSING
  • [BE] Expired access token → 401 → api_client refreshes → retry succeeds
  • [BE] Customer JWT calling /api/mitra/auth/me → 403 FORBIDDEN
  • [BE] Mitra JWT calling /api/client/auth/me → 403 FORBIDDEN
  • [BE] Customer JWT calling /internal/* → 403 FORBIDDEN
  • [BE] request.auth = { userType, userId, sessionId } populated correctly on every protected route (spot-check in logs)

1.7 Backend: Mitra Activation + Inactive Flow

  • [BE] New phone number → auto-creates mitras row with is_active=false; OTP verify returns 403 ACCOUNT_INACTIVE
  • [BE] Admin activates mitra via CC (or direct DB UPDATE mitras SET is_active=true) → re-request OTP → verify succeeds, tokens returned
  • [BE] Mitra re-OTP after deactivation → 403 ACCOUNT_INACTIVE on next verify; existing sessions keep working until natural JWT expiry (documented 1h window)

1.8 Backend: Password / CC Login

  • [BE] POST /internal/auth/login with correct creds → sets cc_refresh_token httpOnly cookie, returns access token + profile + role/permissions
  • [BE] Wrong password → 401 INVALID_CREDENTIALS, failed_login_count incremented
  • [BE] 5th wrong password → lockout_until = NOW() + 15min; subsequent attempts (even with right password) → 423 ACCOUNT_LOCKED
  • [BE] Successful login resets failed_login_count + lockout_until
  • [BE] PATCH /internal/control-center-users/me/password with wrong current → 401 INVALID_CREDENTIALS
  • [BE] Password complexity rejected: <8 chars → PASSWORD_TOO_SHORT; no digit → PASSWORD_MISSING_DIGIT; no upper → PASSWORD_MISSING_UPPERCASE; no lower → PASSWORD_MISSING_LOWERCASE
  • [BE] Admin-forced reset via PATCH /internal/control-center-users/:id/password requires control_center_users:update permission
  • [BE] Create CC user POST /internal/control-center-users requires control_center_users:create; stores bcrypt hash; rejects weak password with same complexity codes
  • [BE] Seed script does NOT enforce complexity (intentional bootstrap loophole — dev admin123 works)

1.9 Backend: WebSocket Auth

  • [BE] WS handshake with valid JWT in first {type:"auth", token} frame → {type:"auth_ok"}
  • [BE] WS handshake with missing token → connection closed with code 4401 (or equivalent)
  • [BE] WS handshake with expired/tampered token → closed
  • [BE] request.auth equivalent available on WS connection (userId + userType + sessionId)
  • [BE] Old Firebase-era token on WS → rejected (documented expected failure from pre-3.4 builds)

1.10 Control Center: UI + Auth

  • [CC] firebase dep is gone from package.json; bundle is ~280KB not ~800KB
  • [CC] Login page: right creds → redirects to /dashboard
  • [CC] Login page: wrong creds → inline error "Email atau password salah."
  • [CC] Login page: account-locked → shows backend message (or translated Indonesian)
  • [CC] Refresh cookie persists across a hard browser reload (F5) → user stays logged in
  • [CC] Close tab + reopen → AuthContext.bootstrap calls /internal/auth/refresh with the cookie → user stays logged in
  • [CC] Logout clears access token in memory + calls /logout + clears cookie → next action redirects to login
  • [CC] Access token expires mid-session → next api call 401 → bridge auto-refreshes → request succeeds transparently (simulate by breaking the access token in devtools)
  • [CC] Refresh fails (cookie expired / revoked) → onUnauthenticated → user bounced to login
  • [CC] Two concurrent 401s (fire two API calls in parallel with a broken token) → only one refresh fires; both retries succeed
  • [CC] Sidebar "Ganti password" form: wrong current → "Password saat ini salah."
  • [CC] Sidebar "Ganti password" form: weak new password → shows backend complexity message
  • [CC] Sidebar "Ganti password" form: success → "Password berhasil diubah." (and next login uses the new password)
  • [CC] UsersPage create: all fields required; Generate button produces a compliant password; submit creates user
  • [CC] UsersPage create: duplicate email → inline error ("Email sudah digunakan.")
  • [CC] UsersPage per-row "Reset password": success → inline "Tersimpan."; the target user can then log in with that password
  • [CC] All API calls send credentials: 'include' (verify via devtools network tab)

1.11 mitra_app: UI + Auth

  • [M] firebase_auth dep is gone from pubspec.yaml; app builds debug APK clean
  • [M] First launch with no stored session → lands on /login
  • [M] Phone OTP request → OTP screen; backend console shows [OTP STUB] phone=… code=…
  • [M] Enter stub code → /home
  • [M] Kill app, relaunch → bootstrap reads refresh from secure storage → /home (no login screen flash)
  • [M] Inactive mitra → snackbar "Akun tidak aktif. Hubungi administrator."
  • [M] Wrong OTP code → snackbar "Kode OTP salah." Fields cleared, focus returns to first digit
  • [M] OTP expired (wait 5min) → "Kode OTP kedaluwarsa. Minta kode baru."
  • [M] Resend within cooldown → "Tunggu sebentar..." message
  • [M] Logout button → storage cleared; next launch hits /login
  • [M] Long session: access token expires mid-chat → api_client auto-refreshes; user sees no disruption
  • [M] Refresh token expires / revoked → onUnauthenticated → app returns to /login with current screen cleared
  • [M] WebSocket handshake uses JWT from AuthBridge — verify by logging the token frame in dev
  • [M] iOS: keychain persists the refresh token across app updates (reinstall DOES wipe it, but update keeps it)
  • [M] Android: encrypted SharedPrefs persists across app updates

1.12 client_app: UI + Auth

  • [C] firebase_auth dep is gone; app builds debug APK clean
  • [C] First launch → onboarding → welcome screen
  • [C] "Lanjut sebagai Tamu" → DisplayNameScreen → enter name → /home (anonymous customer created, display_name PATCHed)
  • [C] Kill app, relaunch → bootstrap succeeds → /home (anonymous session restored)
  • [C] "Daftar / Masuk" → phone field → OTP → home (customer created with phone)
  • [C] From anonymous state, tap "Daftar / Masuk" → OTP upgrade → same customer_id, display_name preserved, phone added (verify via /me)
  • [C] OTP error codes all render correct Indonesian messages (CODE_MISMATCH, OTP_EXPIRED, IDENTITY_CONFLICT, etc.)
  • [C] Anonymity config anonymity_enabled=false → after anonymous sign-in, router sends to /auth/force-register
  • [C] From ForceRegister, complete phone OTP → lands in /home with upgraded profile
  • [C] Google/Apple buttons are hidden by default (ENABLE_SOCIAL_AUTH=false)
  • [C] --dart-define=ENABLE_SOCIAL_AUTH=true → buttons appear; tapping without backend creds returns clear error (no crash)
  • [C] After Google/Apple creds are live: sign-in with fresh account works; sign-in with existing-elsewhere account → IDENTITY_CONFLICT surfaced as "Akun ini sudah terhubung..."
  • [C] Logout from home → returns to welcome (storage cleared)
  • [C] Set display name screen: empty input disabled; success → home
  • [C] Long session: access token expires → auto-refresh transparent
  • [C] Refresh expires → unauthenticated → welcome screen, not a broken home

1.13 Cross-App / WebSocket / Regression After Auth Change

  • [BE][M][C] WS auth in chat (chat_notifier client + mitra_chat_notifier) works with the new JWT
  • [BE][M][C] WS auth in pairing (pairing_notifier client + chat_request_notifier mitra) works with the new JWT
  • [BE][M][C] Full chat flow: pair → chat → extension → closure — using the new JWT end-to-end
  • [BE] FCM device-token registration endpoint (/api/shared/device-token) still works; Authorization header is the new JWT

Part 2 — Phase 3.3 Carry-Over: Topic Sensitivity

2.1 Database / Migration

  • Migration runs cleanly on an existing dev DB (no errors, all IF NOT EXISTS / ON CONFLICT paths hit)
  • chat_sessions.topic_sensitivity column exists with default 'regular' and NOT NULL
  • Existing sessions (created before the migration) have topic_sensitivity = 'regular' after migration
  • session_sensitivity_log table exists with correct FKs (sessions, mitras)
  • idx_chat_sessions_topic_sensitivity index created
  • idx_session_sensitivity_log_session index created
  • app_config has sensitive_flip_confirmation_enabled = true by default
  • app_config has sensitive_flag_one_way_latch = false by default

2.2 Customer Flow (client_app)

Topic selection bottom sheet

  • Tap "Mulai Curhat" → topic selection bottom sheet appears
  • Sheet cannot be dismissed by tapping outside
  • Sheet cannot be dismissed by swiping down
  • System back button cancels entire "Mulai Curhat" flow (does NOT open pricing)
  • Copy matches PRD (title, body, sub-question, helper line)
  • "Topik umum" button styling is primary
  • "Topik sensitif" button styling is secondary but equal weight (not de-emphasized)

Request submission

  • Tap "Topik umum" → pricing sheet opens with topic pre-selected as regular
  • Tap "Topik sensitif" → pricing sheet opens with topic pre-selected as sensitive
  • After pricing confirm → POST /api/client/chat/request body includes topic_sensitivity: regular|sensitive
  • Backend rejects request with missing topic_sensitivity (400 BAD_REQUEST)
  • Backend rejects request with invalid topic_sensitivity value (e.g., "other")
  • Created chat_sessions row has correct topic_sensitivity value

Customer UI after request

  • Chat screen stays pink (no yellow), regardless of flag
  • Customer history screen: no badge on any row regardless of flag
  • Customer transcript screen: stays pink
  • Customer receives session_topic_updated WS message after mitra flip → no UI change, no error, no crash

2.3 Mitra Flow — Incoming Request (mitra_app)

  • Regular request: overlay has no badge, no yellow accent
  • Sensitive request: overlay shows "Topik sensitif" badge + yellow accent
  • Overlay payload from WS includes topic_sensitivity
  • Overlay payload from FCM fallback includes topic_sensitivity (or app fetches on open)
  • Overlay payload from getPendingRequestsForMitra (app-resume path) includes topic_sensitivity
  • Mitra accepts sensitive request → lands in active chat screen with correct flag

2.4 Mitra Flow — Active Chat Screen

  • Sensitive active session: yellow doodle background
  • Regular active session: pink doodle (unchanged behavior)
  • Header banner shows "Topik sensitif" label only when sensitive
  • App-bar toggle icon visible (flag / flag_outlined depending on state)

Flip toggle — confirmation enabled (default)

  • Tap toggle regular → sensitive → dialog appears: "Tandai sesi ini sebagai sensitif?"
  • "Batal" cancels, no state change, no audit log entry, no WS broadcast
  • "Tandai" flips, background turns yellow instantly, log entry created
  • Tap toggle sensitive → regular → dialog: "Tandai sesi ini sebagai topik umum?"

Flip toggle — confirmation disabled (via CC config)

  • Toggle sensitive_flip_confirmation_enabled to false in CC
  • Flip happens immediately, toast "Sesi ditandai sensitif" / "Sesi ditandai topik umum"
  • No dialog appears

One-way latch — disabled (default)

  • Can flip regular → sensitive → regular → sensitive freely

One-way latch — enabled (via CC config)

  • Toggle sensitive_flag_one_way_latch to true in CC
  • Session that was regular: can flip to sensitive; toggle then disabled
  • Session that was already sensitive at latch-enable time: toggle disabled with tooltip
  • Attempt to flip sensitive → regular with latch on: API returns 409 SENSITIVITY_LATCHED
  • Error dialog shown: "Sesi sudah ditandai sensitif dan tidak bisa diubah kembali."

Audit trail

  • Every successful flip creates a session_sensitivity_log row with correct from_value, to_value, changed_by_mitra_id
  • No-op flip (e.g., tap confirm but value didn't actually change) does NOT create a log row
  • Log entries ordered correctly (ascending created_at)

2.5 Mitra Flow — Extension

  • Customer requests extension on regular session → mitra extension card has no badge
  • Customer requests extension on sensitive session → mitra extension card shows "Topik sensitif" badge + yellow accent
  • Mitra flipped session mid-chat regular → sensitive, then customer requests extension → extension card reflects current sensitive flag
  • Extension accepted → flag carries over unchanged to extended session

2.6 Mitra Flow — History & Transcript

  • Mitra history list row shows "Topik sensitif" badge for sensitive sessions
  • Mitra history list row has no badge for regular sessions
  • Mitra transcript view: sensitive session → yellow doodle background
  • Mitra transcript view: regular session → pink doodle
  • Customer-side history and transcript: always pink, no badge

2.7 Mitra Flow — Edge Cases

  • Mitra tries to flip flag on a session they don't own → 403 FORBIDDEN
  • Mitra tries to flip flag on a CLOSING session → 409 SESSION_NOT_ACTIVE
  • Mitra tries to flip flag on a COMPLETED session → 409 SESSION_NOT_ACTIVE
  • Mitra tries to flip flag on an EXPIRED session → 409 SESSION_NOT_ACTIVE
  • Invalid topic_sensitivity value sent to PATCH endpoint → 400 BAD_REQUEST
  • Customer tries to call PATCH endpoint → 403 FORBIDDEN (only mitra allowed)

2.8 Control Center — Settings

  • Settings page has new "Sensitivitas Topik" section
  • sensitive_flip_confirmation_enabled checkbox reflects current backend value
  • sensitive_flag_one_way_latch checkbox reflects current backend value
  • PATCH /internal/config/sensitivity persists changes
  • Changes take effect immediately on next mitra flip (no app restart needed on mitra side, if mitra fetches config dynamically)

2.9 Control Center — Sessions Page

  • Sessions list has new filter dropdown (All / Umum / Sensitif)
  • Filter "Sensitif" returns only sessions with topic_sensitivity = 'sensitive'
  • Filter "Umum" returns only regular
  • Filter "All" returns everything (backward-compatible)
  • Filter works combined with existing status filter
  • Session list has new "Topik" column showing badge (green "Umum" / yellow "Sensitif")

2.10 Control Center — Session Detail

  • Session detail page shows current topic_sensitivity
  • Session detail shows sensitivity audit trail timeline: "Mitra {name} menandai topik sebagai {from→to} pada {timestamp}"
  • Timeline ordered ascending
  • Sessions with no flips show empty timeline (no error)

2.11 Control Center — Dashboard

  • Dashboard shows "Sesi Sensitif" card with total count + 30-day % breakdown
  • Percentage math correct (sensitive / total × 100, rounded to 1 decimal)
  • Edge case: 0 sessions in last 30 days → shows 0% not NaN

2.12 Control Center — Mitra Activity

  • Summary table has new columns: Sensitive Total, Sensitive Accepted, Sensitive Rate (%)
  • Mitra with 0 sensitive requests shows (not 0%)
  • Sensitive rate computed correctly (sensitive_accepted / sensitive_total × 100)
  • Detail log table: optional new "Topik" column with badge
  • Date range filter still works with new columns

2.13 Control Center — Integration Regression

  • Existing settings (anonymity, free-trial, extension-timeout, early-end, mitra-ping, price-tiers) still work under the new auth
  • Existing sessions filter (by status) still works
  • Existing dashboard cards still render

Part 3 — Phase 3.2 Carry-Over

3.1 Chat Request Overlay

  • Multiple concurrent chat requests — verify queue behavior (one shown at a time, next appears when current resolved)
  • Stale request: "cancelled by customer" message shown + requires acknowledge (no auto-dismiss)
  • Stale request: "accepted by other bestie" message shown + requires acknowledge
  • Stale request: "expired" message shown + requires acknowledge
  • Swipe-to-dismiss (ignore) does NOT send reject to backend
  • Ignored request eventually logs as ignored in chat_request_notifications after 60s timeout
  • Request missed (another mitra accepted first) logs correctly
  • active_session_count captured correctly at notification creation

3.2 End-to-End Flows

  • Full chat flow: pair → chat → extension → closure (customer + mitra)
  • Goodbye flow: session expires → closing → both submit goodbye → completed
  • Extension accepted mid-flow → session resumes, timer extends, no grace timer lingering
  • Extension rejected → session moves to closing, both see closure UI
  • Extension timeout (no mitra response) → closing

3.3 iOS Coverage

  • OTP login on iOS (customer) — update: flow now uses stub Fazpass OTP from backend console, no native reCAPTCHA
  • OTP login on iOS (mitra) — same as above
  • Push notifications on iOS (customer + mitra)
  • FCM token registration on iOS
  • Chat screen rendering on iOS
  • Back button behavior on iOS (deep-link pop fallback)
  • Overlay on iOS (iOS setup started but incomplete per prior memory)
  • Splash screen on iOS
  • Onboarding carousel on iOS
  • Keyboard handling on iOS (chat input, goodbye form)

Part 4 — Phase 3 / 3.1 Carry-Over

4.1 Session Lifecycle

  • Server restart mid-session: session timer is restored from DB (restoreActiveTimers)
  • Stale active sessions auto-complete on restart
  • Closing sessions with stale grace timers auto-complete on restart
  • Session expired from customer side (5-min countdown display)
  • Abandoned session during closure grace period → auto-completes
  • Known limitation: multi-instance backend sessions not supported until Valkey keyspace notifications implemented (out of scope — see project_session_timer_scaling in memory)

4.2 Chat Mechanics

  • Message status transitions (sent → delivered → read) work correctly
  • Typing indicator shows/hides correctly on both sides
  • Messages received while backgrounded are marked delivered on foreground resume
  • Messages viewed are marked read and the read receipt propagates back to sender
  • Unread badge on home screen updates correctly (client_app + mitra_app)

4.3 Navigation / UI

  • All navigation uses GoRouter.context.push/go (no leftover Navigator.pushNamed)
  • Deep-linked screens work with canPop fallback + PopScope
  • notification_service uses go (not push) for terminal states
  • Splash screen hides auth loading flash on both apps
  • Goodbye views use SingleChildScrollView (no keyboard overflow)

4.4 Control Center Settings

  • Free trial config: toggle + duration edit
  • Extension timeout: edit seconds
  • Early end: toggle mitra / customer independently
  • Mitra ping: toggle require + interval
  • Price tiers: add / edit / remove tiers and verify client_app pricing sheet reflects changes

Part 5 — Cross-Cutting / Pre-Release

5.1 Regression Checks (after Phase 3.4 merge)

  • Existing customer auth flow still works (welcome → OTP → register → home) — under new JWT
  • Existing mitra auth flow still works — under new JWT
  • Existing control center login still works (admin with rotated password) — under new cookie-based refresh
  • Pairing flow (mulai curhat → matched) still works end-to-end under new auth
  • All existing WS messages still processed (no regressions from session_topic_updated handler or JWT handshake)

5.2 Platform Coverage

  • Android: client_app on emulator (Medium_Phone_API_36.1)
  • Android: mitra_app on physical device (SM-A530F, 52002a5db8e0c46b)
  • iOS: client_app (Mac + simulator / physical)
  • iOS: mitra_app (Mac + simulator / physical)
  • Control center: Chrome latest
  • Control center: Firefox / Safari (if required)

5.3 Load / Concurrency (sanity)

  • 2 concurrent customers requesting chat at the same time — both find a mitra (or one waits)
  • 1 customer, 5 mitras online — blast notification reaches all 5
  • Mitra accepts after another mitra already accepted → receives missed with accepted_by_other
  • Backend restart with active sessions → timers restored, no data loss
  • 3 concurrent CC admins logging in → each gets its own auth_sessions row; logout on one doesn't affect others

5.4 Config Flag Interactions

  • sensitive_flag_one_way_latch = true + existing sensitive session → toggle disabled
  • sensitive_flip_confirmation_enabled = false + rapid flips → no race, all logged in order
  • Anonymity toggle flip (enabled → disabled) while a user has an active anonymous session → next bootstrap routes to ForceRegister
  • Anonymity toggle flip (disabled → enabled) → no-op for already-identified users; new users can go anonymous again

5.5 Security / Negative

  • JWT secret leak simulation: a manually-signed token with the wrong secret → 401 across all surfaces
  • Token from a deleted auth_sessions row still verifies for up to 1h (documented window); revoke immediately with future Valkey revoked_sessions set
  • Refresh token used twice in parallel → only one succeeds (rotation-on-use); second → REFRESH_INVALID
  • Refresh token stolen + used from a different IP → still works (documented design — rotation + bcrypt is the defense); device_info logs the new IP for audit
  • Control center cookie SameSite / Secure / HttpOnly flags correct in prod (verify via devtools in staging)

5.6 Known Blockers / Deferred

Not tests — tracked so they don't get forgotten:

  • Valkey keyspace notifications — required for multi-instance session timers + real token revocation
  • Mitra QC auto-flag — auto-flagging high-rejection mitras on CC dashboard
  • Merge-on-link for social login (currently reject-on-existing with IDENTITY_CONFLICT)
  • Drop firebase_uid columns — cleanup migration once no code path references them
  • Real Fazpass integration — replace stub in otp.service.js when API docs + creds arrive
  • Google + Apple OAuth creds — provision then flip ENABLE_SOCIAL_AUTH=true and run §1.4 + client_app social blocks
  • Apple Developer setup — Services ID + .p8 + Team ID + Key ID before iOS Apple-sign-in E2E
  • JWT secret rotation procedure — documented but not implemented (dual-secret window plan in phase3.4-plan.md)