Auth test plan
The canonical, full-path case matrix for authentication in both apps
(@enode/tracking = pro_app, @enode/portal = portal): login, register
(including the legacy "seamless register" promotion and onboarding), roles and
privileges, account deletion, the resumable redirects (310/311/312), the
terminal flavor block (314), and the error families (wrong/consumed OTP,
wrong-flow 400, rate limits).
This document is the single source of truth. Tests reference the case IDs
in their titles (it("AUTH-LOG-03 …")), the live-run protocol renderer parses
the IDs from this file to report coverage gaps, and the backend handoff
document indexes the same IDs instead of duplicating rows. Expected values
marked live-verified 2026-07-27 were observed against backdev by the live
suite — never assumed.
Coverage layers
| Layer | What | Where | Run with |
|---|---|---|---|
| U — unit | Deterministic, offline; stubbed fetch; reducer + step functions + API wrappers | packages/core/src/auth/*.test.ts, packages/core/src/api/auth.test.ts | npm test |
| L — live API | Client-less: the real @enode/core API client from Node against backdev, full user lifecycle (create → assert → delete) | tests/live/ | npm run test:live:auth:protocol |
| E — browser smoke | The real app in a real browser against backdev; small, documented, agent-driven (chrome-devtools MCP) | Browser smoke below | manual / agent |
| S — server-side | Only testable inside the backend (clock control, limiter internals, mail content); mirrored by the backend team | backend service tests | backend CI |
A case tagged U+L is asserted twice on purpose: U pins the client's reaction,
L pins the real wire contract.
Environment and conventions (live-verified 2026-07-27)
- Backend: backdev (
https://backdev.enode.ai/api). Never production — the live suite hard-refuses any other host (developonly viaENODE_TEST_ALLOW_DEVELOP=true). - On dev backends no OTP emails are sent; the OTP is always
123456— proven by the suite's preflight for login, register, AND delete before any test runs (fail-fast otherwise). - Every request needs the device identity headers (
device-type,device-name,device-id,x-user-agent,timezone,Accept-Language) besidesapi-key— a bare request answers 400 "Missing device information in header." A missingapi-keyanswers 400 "Missing api-key field in header." - Sessions are bound to (user, device-id): a different user logging in on
the same
device-idevicts the previous user's session ("There is no active device session for this user" on their next call), and a login OTP session is de-facto bound to thedevice-idthat requested it (a login from another device-id answers 400 "Wrong or mismatching email address and passcode" — contradicting the documented any-device reuse; open question in the backend handoff). - The login-OTP request hides account existence: an unknown email answers
200
{reason:"login"}exactly like an existing account. Existence can only be proven with a session token (GET /users/{id}→ 401 = gone). - Disposable users are named
authlive-<runId>-<proc>-<seq>@<domain>(default domainauthtest.enode.ai); deleted accounts keep their email reserved, so addresses never repeat. Every destructive call refuses targets that do not match theauthlive-prefix — fixture accounts andteam@enode.aican never be deleted by the suite. - Auth encodings: OTP request/resend are unauthenticated GETs with the
lowercased+trimmed email base64-encoded into the path; login/register/delete
send HTTP Basic
base64(email:otp); everything else sends the session Bearer token.
Fixture registry
Cases that need pre-existing backend state are tagged with a fixture slot.
Slots are filled interactively at the start of a protocol run (or in
tests/live/fixtures.local.json); absent slots make their cases skip with a
reason — they never guess.
| Slot | Meaning | Status |
|---|---|---|
legacyPro | Email that exists in the legacy enode_pro DB but not the new system | open |
legacyOne | Email that exists in the legacy enode_one DB but not the new system | open |
oneTier | Active One-tier account in the new system | open |
proPlus | OTP-capable account whose license is named "Pro Plus" | open |
socialCollision | Email registered via a social provider (Apple/Google) — must sign in via email OTP since social sign-in is retired | jwws98g7qv@privaterelay.appleid.com (Apple; account tier "Pro +") |
Case matrix
Column key — Flavor: which api-key/app config the case runs under.
Layers: see above. Trace: requirement IDs from
the requirements catalogs (— = no stable
requirement yet).
Login — AUTH-LOG
| ID | Case | Flavor | Preconditions | Sequence → expected | Layers | Trace |
|---|---|---|---|---|---|---|
| AUTH-LOG-01 | Request login OTP for an existing plain Pro user | both | disposable Pro user (registered in-run) | GET /users/login/otp/{b64} → 200 {reason:"login"} | U+L+E | SHR-AUTH-01 |
| AUTH-LOG-02 | Verify login OTP → session | both | LOG-01 | POST /users/login Basic → 201 {token, user}; token works for GET /users/{uid}; merged privilegeKeys present (owner set live-recorded) | U+L+E | SHR-AUTH-01 |
| AUTH-LOG-03 | Wrong OTP on verify | both | LOG-01 | Live-verified: 403 {reason:"Wrong or mismatching email address and passcode."}; client shows an inline retryable error and stays on the OTP step (U pins the client half with 401 — both statuses land in the same inline-error path) | U+L | SHR-AUTH-01 |
| AUTH-LOG-04 | Three wrong attempts consume the OTP session | pro_app | LOG-01 | Live-verified sequence: 403, 403, then 400 (session consumed); the correct code afterwards also answers 400; a fresh requestLoginOtp recovers | L | — |
| AUTH-LOG-05 | Resend login OTP, same session | both | LOG-01 | GET /users/login/otp/resend/{b64} → 200; the fixed code still verifies afterwards | U+L | — |
| AUTH-LOG-06 | Login OTP for an unknown email | both | fresh never-registered email | Live-verified: 200 {reason:"login"} — the request hides account existence; the register-promotion therefore only materializes for legacy register* reasons (client promotion half stays U-covered) | U+L | SHR-AUTH-03 |
| AUTH-LOG-07 | Social-registered account signs in via email OTP | both | fixture socialCollision | Product decision 2026-07-28: social sign-in is retired — Apple/Google-registered accounts MUST use the normal email OTP flow. Live-verified: OTP request → 200 {reason:"login"} and the OTP login succeeds (the historical wrong-flow 400 is gone; the client's generic 400 handling stays U-covered for the register-flow case) | L | — |
| AUTH-LOG-08 | Legacy-known email on the LOGIN request → register promotion | portal | fixture legacyPro (doris@enode.ai) | Expected (product-confirmed 2026-07-28, live-verified): the login-OTP request answers 200 plain {reason:"register"} — the seamless-promotion trigger (a truly unknown email answers login instead, LOG-06). The *Pro origin reasons do not surface at the OTP stage; when they are emitted is handoff Q9. Client handling of every register*/login* reason stays U-covered | U+L | SHR-AUTH-04 |
| AUTH-LOG-09 | Legacy-known One email at the OTP stage | portal | fixture legacyOne | Same expected contract as LOG-08: 200 plain {reason:"register"} on both OTP requests (the *One origin does not surface here) | U+L | POR-AUTH-01 |
| AUTH-LOG-10 | One-tier user on the Pro app → 314 block | pro_app | fixture oneTier | POST /users/login → 314 {reason, currentTier, appFlavor, missingPrivileges:["canAccessPro"]}; client shows the terminal blocked dialog | U+L+E | TRK-AUTH-02 |
| AUTH-LOG-11 | One-tier user on the portal → ALSO blocked (314) | portal | fixture oneTier (one@enode.ai) | Product decision 2026-07-28: One users are blocked on the portal too. Live-verified: POST /users/login → 314 {missingPrivileges:["canAccessPortal"], currentTier:"Enode One"}. The old "One may sign in to the portal (to migrate)" story is retired — POR-AUTH-01 updated; client follow-up: retire allowOneLogin + the One welcome copy | U+L | POR-AUTH-01 |
| AUTH-LOG-12 | enableLegacyMigration:false suppresses migration | n/a | — | Client config only: a legacy reason with migration disabled dispatches no /migrations/* call (login + register variants) | U | — |
| AUTH-LOG-13 | Privilege-restricted login (athlete) | both | ROLE-02 athlete | Athlete logs in → 201; merged privilegeKeys lacks writeData (full set live-recorded once the 312 acceptance route exists) | L | — |
| AUTH-LOG-14 | "Pro Plus" license behaves as Pro | both | fixture proPlus (pro@enode.ai) | Live-verified: login → 2xx; license name "Enode Pro+" with its own base id (7853FCB4-…) — isProLicenseBaseID() (single-id stopgap) returns false for it, so id-based Pro checks misclassify Pro+; the authoritative signal is the canAccessPro privilege (present ✓). Client follow-up: migrate isProLicenseBaseID consumers to the privilege | L | — |
| AUTH-LOG-15 | Offline / backend down | n/a | — | Network failure / 502 → offline disposition, inline retry (stubbed; deterministic) | U | — |
Register — AUTH-REG
| ID | Case | Flavor | Preconditions | Sequence → expected | Layers | Trace |
|---|---|---|---|---|---|---|
| AUTH-REG-01 | Request register OTP for a fresh email | both | fresh disposable email | GET /users/register/otp/{b64} → 200 {reason:"register"} | U+L+E | SHR-AUTH-03 |
| AUTH-REG-02 | Register → owner account created | both | REG-01 | POST /users/v3/register Basic + {email, name, licenseBaseID} → 201 {token, user}. Live-verified: owner role UUID, license nested as license.baseID = Pro base + license.name "Enode Pro" (top-level licenseBaseID absent on the full read), merged privilegeKeys recorded | U+L+E | SHR-AUTH-03 |
| AUTH-REG-03 | Wrong OTP on register verify | both | REG-01 | Live-verified: 403; no account created (the register-OTP request still answers register), and the same OTP session still completes with the correct code afterwards | U+L | SHR-AUTH-03 |
| AUTH-REG-04 | Register OTP for an already-registered email → 400 | both | registered disposable user | GET /users/register/otp/{b64} → 400; client suggests the login flow | U+L | SHR-AUTH-03 |
| AUTH-REG-05 | Legacy One email: register-OTP request | portal | fixture legacyOne | Expected: 200 plain {reason:"register"} (see LOG-08 — origin reasons do not surface at the OTP stage). Client handling of registerOne stays U-covered for when the backend emits it | U+L | SHR-AUTH-04 |
| AUTH-REG-06 | Legacy Pro email: register-OTP request | portal | fixture legacyPro | Live-verified: 200 plain {reason:"register"} (product-confirmed as expected). Client handling of registerPro stays U-covered | U+L | SHR-AUTH-04 |
| AUTH-REG-07 | Resend register OTP, same session | both | REG-01 | GET /users/register/otp/resend/{b64} → 200; code still verifies | U+L | — |
| AUTH-REG-08 | Wrong-tier licenseBaseID → 314 | pro_app | fresh email | Live-verified: registering with an unknown licenseBaseID → 314 (no account created) | U+L | — |
| AUTH-REG-09a | Onboarding (311): Pro name submit resumes | pro_app | account in the 311 state | PUT /users/onboarding {name} → original request resumes. NOT yet fabricable live: created org children answer 312, not 311 (see RED-02) — the endpoint TODO in api/auth.ts stays open for the backend to confirm | L (blocked) + S | SHR-AUTH-08 |
| AUTH-REG-09b | Onboarding cancel sticks per session token | n/a | — | Cancelled onboarding is remembered per token; background 311s do not re-summon the screen | U | SHR-AUTH-08 |
| AUTH-REG-09c | Onboarding for a One-tier account → dead end | portal | fixture oneTier in 311 state | Onboarding screen shows "finish in the One app"; no name submit | U; L fixture-gated | SHR-AUTH-08 |
| AUTH-REG-09d | Onboarding name validation | pro_app | 311 state as 09a | Empty/blank name must not enable the account — recorded when a 311 becomes fabricable | L (blocked) + S | — |
Roles — AUTH-ROLE
| ID | Case | Flavor | Preconditions | Sequence → expected | Layers | Trace |
|---|---|---|---|---|---|---|
| AUTH-ROLE-01 | Register creates an owner | both | = REG-02 | Live-verified: role id 46381D7A-… (owner), key owner; license nested on the full read | U (role helpers) + L | SHR-AUTH-03 |
| AUTH-ROLE-02 | Athlete under a disposable org | pro_app | disposable owner (REG-02) | Live-verified: POST /users/{ownerId} requires name, email, bodyHeight (metres), bodyWeight, gender → athlete POST /users/login succeeds (201), and the first authenticated request answers 312 (open invitation from the org) — see RED-03 | L | — |
| AUTH-ROLE-03 | Coach under a disposable org | pro_app | disposable owner | Same as ROLE-02 with the coach role — same live behavior (login 201, then 312) | L | — |
| AUTH-ROLE-04 | Role/privilege helpers | n/a | — | userHasPrivilege, isAthleteRole/isOwnerRole, isProLicenseBaseID, privilege-catalogue identity | U | — |
Account deletion — AUTH-DEL
| ID | Case | Flavor | Preconditions | Sequence → expected | Layers | Trace |
|---|---|---|---|---|---|---|
| AUTH-DEL-01 | Request delete OTP (Bearer) | both | signed-in disposable user | Live-verified: GET /users/deleteUser/otp/{b64} Bearer → 200 {reason:"userDelete"} | U+L+E | — |
| AUTH-DEL-02 | Delete account; success answers 401 | both | DEL-01 | Live-verified: the fixed dev OTP authorizes the delete; DELETE /users/delete Basic → 401 ≡ deleted. Proof of deletion = the old session token is DEAD (GET /users/{uid} → 401) — the login-OTP reason proves nothing (it hides existence) | U+L+E | — |
| AUTH-DEL-03 | Wrong delete OTP | both | DEL-01 | Live-verified: 403 {reason:"Wrong or mismatching…"}, account survives. Because success is a bodiless 401, deleteAccount() now PROVES deletion via the dead-token probe before wiping local data (client hardening shipped with this plan; U-pinned) | U+L | — |
| AUTH-DEL-04 | Delete-OTP request without Bearer | both | none | Live-verified: 401 | U+L | — |
| AUTH-DEL-05 | Owner-driven delete of an org child | pro_app | disposable org + child | Live-verified: DELETE /users/{id} Bearer(owner) → 204; the child leaves the org listing (/users/all_roles). Resolves the route TODO in api/users.ts for the owner→child case | L | — |
Resumable redirects — AUTH-RED
| ID | Case | Flavor | Preconditions | Sequence → expected | Layers | Trace |
|---|---|---|---|---|---|---|
| AUTH-RED-01 | Device limit (310) reached and resolved | pro_app | disposable Pro user | Per attempt: fresh device-id → login OTP → login (OTP sessions are device-bound). Soft-skips with a recorded reason when the limit does not fire within ENODE_TEST_DEVICE_LIMIT_TRIES (live: did not fire within 8 — the backdev licence's limit is higher; ask in the handoff). On a 310: assert {sessionRemoveToken, sessions[]} → DELETE /user_device_sessions/remove/{id} → re-login | L | SHR-AUTH-06 |
| AUTH-RED-02 | User-not-enabled (311) fabricated | pro_app | — | NOT yet fabricable: created org children are enabled and answer 312 instead. 311's body shape is pinned via the debug endpoint (RED-04); how a real 311 account arises on backdev is an open handoff question | L (blocked) + S | SHR-AUTH-08 |
| AUTH-RED-03 | Open invitations (312) fabricated and resolved | pro_app | disposable org + child | Live-verified: the created child's first authenticated request answers 312 {userId, invitations:[{parentId, parentUserName, parentUserEmail, accepted}]} (login itself succeeds). Resolution is blocked: POST /users/{userId}/invitations does not exist (500 "RouteNotFound.404") — the api/invitations.ts TODO is now a confirmed open contract question | L | SHR-AUTH-07 |
| AUTH-RED-04 | Redirect body shapes via debug endpoints | pro_app | session | Live-verified (Bearer): /debug/errors/device-limit → 310 {sessionRemoveToken, sessions, reason}; /debug/errors/user-not-enabled → 311 {reason, userDto}; /debug/errors/open-invitations → 312 {userId, reason, invitations}; /debug/errors/abort/314 → 314. Unauthenticated, the debug route answers 401 | L | POR-AUTH-02 |
| AUTH-RED-05 | Client resume semantics | n/a | — | Resolved action → same request retried (≤ 3); cancelled → ActionRequiredError. Stubbed | U | POR-AUTH-02 |
Flavor / header gates — AUTH-GATE
| ID | Case | Flavor | Preconditions | Sequence → expected | Layers | Trace |
|---|---|---|---|---|---|---|
| AUTH-GATE-01 | Portal key without the enodeportalwebapp user-agent | portal | disposable user | Documented contract: rejected. Live-verified: backdev accepts it (OTP 200, login 201) — the portal UA gate is not enforced there; open handoff question (prod behavior unknown) | L | — |
| AUTH-GATE-02 | Missing api-key header | n/a | none | Live-verified: 400 {reason:"Missing api-key field in header"} (device headers present); without device headers: 400 "Missing device information in header." | L | — |
| AUTH-GATE-03 | One user on the Pro app | — | — | = AUTH-LOG-10 (cross-reference, not a separate test) | — | TRK-AUTH-02 |
Error families — AUTH-ERR
| ID | Case | Flavor | Preconditions | Sequence → expected | Layers | Trace |
|---|---|---|---|---|---|---|
| AUTH-ERR-01 | OTP request rate limit (429) | — | — | S only (deliberate: probing would pollute backdev's limiter for other testers). Backend asserts limiter thresholds + the 429 body | S | — |
| AUTH-ERR-02 | 15-minute OTP expiry | — | — | S only (needs clock control). Backend asserts expiry + that resend does not reset the budget | S | — |
| AUTH-ERR-03 | Client-side OTP input constraints | n/a | — | The OTP input only auto-submits complete 6-digit codes (no partial-code request is ever sent). The guarantee lives in the DOM-coupled OtpInput (onComplete fires on the last box), so it is observed in the browser smoke, not headless | E | — |
Browser smoke (Layer E)
Three cases per app, run in a real Chrome against backdev via the
run-tracking / portal skill (chrome-devtools MCP), results recorded in the
same dated run file as the live protocol
(docs/testing/test-runs/YYYY-MM-DD-auth-live.md):
| Smoke | App | Covers | Steps |
|---|---|---|---|
| E-T1 | tracking | AUTH-LOG-01/02 | Welcome → Sign in → smoke fixture email → OTP 123456 → lands on /workouts/today (login transition) |
| E-T2 | tracking | AUTH-REG-01/02, AUTH-DEL-01/02, AUTH-ERR-03 | Create account (disposable email + org name) → OTP → lands on /workouts/today; then Settings → Profile → delete account (OTP) → back on welcome |
| E-T3 | tracking | AUTH-LOG-10 | Sign in with the oneTier fixture → blocked dialog names the One app |
| E-P1 | portal | AUTH-LOG-01/02 | Sign in → lands on /dashboard |
| E-P2 | portal | AUTH-REG-01/02, AUTH-DEL-01/02 | Register → /dashboard; delete account → welcome |
| E-P3 | portal | AUTH-LOG-11 | Sign in with the oneTier fixture → succeeds (One allowed on portal) |
Running the suites
npm test # Layer U (offline, deterministic)
npm run test:live:auth # Layer L against backdev (no protocol)
npm run test:live:auth:protocol # Layer L + fixture prompt + protocol render
npm run test:live:auth:sweep # delete any leftover authlive-* users
A protocol run writes tests/live/out/protocol-<runId>.md (gitignored). Runs
that serve as proof — a release gate or the backend-handoff baseline — are
copied to docs/testing/test-runs/YYYY-MM-DD-auth-live.md and committed, per
the system test plan.
Open items
- Fixture slots marked open keep their cases skipping with a reason until provided.
- Confirmed open contract questions (each carried in the
backend handoff): the 312 acceptance route
(RouteNotFound live), how a real 311 arises +
PUT /users/onboarding, the portal UA gate not enforced on backdev, OTP device-binding vs the documented any-device reuse, the backdev device limit (> 8), when the legacy origin reasons (loginPro/loginOne/registerPro/registerOne) are actually emitted (Q9 — plainregisterat the OTP stage is the confirmed expected behavior), and the one orphanedauthlive-*athlete whose pending invitation from a deleted org blocks even its delete flow. - Client follow-up from the One-blocked-everywhere decision (AUTH-LOG-11):
retire the portal's
allowOneLoginflavor flag and the One-user welcome copy (app-store badges) — the backend now blocks One on both flavors. - Client follow-up surfaced by AUTH-LOG-14: replace
isProLicenseBaseID(single-id stopgap) with thecanAccessProprivilege — the Pro+ licence base is a different id and is misclassified today. docs/auth.mdstill documents the any-device OTP reuse and the plain 401-means-deleted contract — update it together with the backend's answers (tracked here so the doc-sync happens with the contract clarification, not against a moving target).