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>
562 lines
38 KiB
Markdown
562 lines
38 KiB
Markdown
# 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`)
|