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

562 lines
38 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 → 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](./flow_customer.mermaid.md#21-anonymous--existing-user-merge-post-transaction-otp-).
**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.id``User-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](../backend/src/routes/public/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):
```bash
# 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`](../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](../backend/src/services/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`](../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`)