Files
halobestie-clone/requirement/phase3.4-testing.md
ramadhan sjamsani b59c66f7df Consolidate testing checklist into phase3.4-testing.md
Replaces phase3.3-testing.md. New doc covers:
- Part 1: Phase 3.4 self-managed auth — backend curl matrix, CC UI
  (cookie refresh + bridge), mitra_app + client_app (anonymous →
  upgrade, OTP stub codes, social behind flag), cross-app WS handshake
- Parts 2-4: Phase 3.3 topic sensitivity + 3.2 overlay/E2E/iOS + 3/3.1
  session lifecycle / chat mechanics / navigation — verbatim carry-over
- Part 5: Cross-cutting regression after 3.4 merge, platform coverage,
  security/negative (JWT leak, refresh rotation, cookie flags), and
  Known Blockers / Deferred updated for 3.4 reality (Valkey revocation,
  merge-on-link, firebase_uid drop, real Fazpass, social creds, Apple
  Dev prereqs, JWT rotation procedure)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:14:46 +08:00

457 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.md), [phase3.4-plan.md](./phase3.4-plan.md), [phase3.3.md](./phase3.3.md), [phase3.3-plan.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 → 409 `IDENTITY_CONFLICT` (anonymous row is NOT deleted)
- [ ] **[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.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`)