Skip to main content

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

LayerWhatWhereRun with
U — unitDeterministic, offline; stubbed fetch; reducer + step functions + API wrapperspackages/core/src/auth/*.test.ts, packages/core/src/api/auth.test.tsnpm test
L — live APIClient-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 smokeThe real app in a real browser against backdev; small, documented, agent-driven (chrome-devtools MCP)Browser smoke belowmanual / agent
S — server-sideOnly testable inside the backend (clock control, limiter internals, mail content); mirrored by the backend teambackend service testsbackend 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 (develop only via ENODE_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) besides api-key — a bare request answers 400 "Missing device information in header." A missing api-key answers 400 "Missing api-key field in header."
  • Sessions are bound to (user, device-id): a different user logging in on the same device-id evicts 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 the device-id that 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 domain authtest.enode.ai); deleted accounts keep their email reserved, so addresses never repeat. Every destructive call refuses targets that do not match the authlive- prefix — fixture accounts and team@enode.ai can 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.

SlotMeaningStatus
legacyProEmail that exists in the legacy enode_pro DB but not the new systemopen
legacyOneEmail that exists in the legacy enode_one DB but not the new systemopen
oneTierActive One-tier account in the new systemopen
proPlusOTP-capable account whose license is named "Pro Plus"open
socialCollisionEmail registered via a social provider (Apple/Google) — must sign in via email OTP since social sign-in is retiredjwws98g7qv@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

IDCaseFlavorPreconditionsSequence → expectedLayersTrace
AUTH-LOG-01Request login OTP for an existing plain Pro userbothdisposable Pro user (registered in-run)GET /users/login/otp/{b64} → 200 {reason:"login"}U+L+ESHR-AUTH-01
AUTH-LOG-02Verify login OTP → sessionbothLOG-01POST /users/login Basic → 201 {token, user}; token works for GET /users/{uid}; merged privilegeKeys present (owner set live-recorded)U+L+ESHR-AUTH-01
AUTH-LOG-03Wrong OTP on verifybothLOG-01Live-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+LSHR-AUTH-01
AUTH-LOG-04Three wrong attempts consume the OTP sessionpro_appLOG-01Live-verified sequence: 403, 403, then 400 (session consumed); the correct code afterwards also answers 400; a fresh requestLoginOtp recoversL
AUTH-LOG-05Resend login OTP, same sessionbothLOG-01GET /users/login/otp/resend/{b64} → 200; the fixed code still verifies afterwardsU+L
AUTH-LOG-06Login OTP for an unknown emailbothfresh never-registered emailLive-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+LSHR-AUTH-03
AUTH-LOG-07Social-registered account signs in via email OTPbothfixture socialCollisionProduct 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-08Legacy-known email on the LOGIN request → register promotionportalfixture 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-coveredU+LSHR-AUTH-04
AUTH-LOG-09Legacy-known One email at the OTP stageportalfixture legacyOneSame expected contract as LOG-08: 200 plain {reason:"register"} on both OTP requests (the *One origin does not surface here)U+LPOR-AUTH-01
AUTH-LOG-10One-tier user on the Pro app → 314 blockpro_appfixture oneTierPOST /users/login314 {reason, currentTier, appFlavor, missingPrivileges:["canAccessPro"]}; client shows the terminal blocked dialogU+L+ETRK-AUTH-02
AUTH-LOG-11One-tier user on the portal → ALSO blocked (314)portalfixture 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 copyU+LPOR-AUTH-01
AUTH-LOG-12enableLegacyMigration:false suppresses migrationn/aClient config only: a legacy reason with migration disabled dispatches no /migrations/* call (login + register variants)U
AUTH-LOG-13Privilege-restricted login (athlete)bothROLE-02 athleteAthlete 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 Probothfixture 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 privilegeL
AUTH-LOG-15Offline / backend downn/aNetwork failure / 502 → offline disposition, inline retry (stubbed; deterministic)U

Register — AUTH-REG

IDCaseFlavorPreconditionsSequence → expectedLayersTrace
AUTH-REG-01Request register OTP for a fresh emailbothfresh disposable emailGET /users/register/otp/{b64} → 200 {reason:"register"}U+L+ESHR-AUTH-03
AUTH-REG-02Register → owner account createdbothREG-01POST /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 recordedU+L+ESHR-AUTH-03
AUTH-REG-03Wrong OTP on register verifybothREG-01Live-verified: 403; no account created (the register-OTP request still answers register), and the same OTP session still completes with the correct code afterwardsU+LSHR-AUTH-03
AUTH-REG-04Register OTP for an already-registered email → 400bothregistered disposable userGET /users/register/otp/{b64} → 400; client suggests the login flowU+LSHR-AUTH-03
AUTH-REG-05Legacy One email: register-OTP requestportalfixture legacyOneExpected: 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 itU+LSHR-AUTH-04
AUTH-REG-06Legacy Pro email: register-OTP requestportalfixture legacyProLive-verified: 200 plain {reason:"register"} (product-confirmed as expected). Client handling of registerPro stays U-coveredU+LSHR-AUTH-04
AUTH-REG-07Resend register OTP, same sessionbothREG-01GET /users/register/otp/resend/{b64} → 200; code still verifiesU+L
AUTH-REG-08Wrong-tier licenseBaseID → 314pro_appfresh emailLive-verified: registering with an unknown licenseBaseID314 (no account created)U+L
AUTH-REG-09aOnboarding (311): Pro name submit resumespro_appaccount in the 311 statePUT /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 confirmL (blocked) + SSHR-AUTH-08
AUTH-REG-09bOnboarding cancel sticks per session tokenn/aCancelled onboarding is remembered per token; background 311s do not re-summon the screenUSHR-AUTH-08
AUTH-REG-09cOnboarding for a One-tier account → dead endportalfixture oneTier in 311 stateOnboarding screen shows "finish in the One app"; no name submitU; L fixture-gatedSHR-AUTH-08
AUTH-REG-09dOnboarding name validationpro_app311 state as 09aEmpty/blank name must not enable the account — recorded when a 311 becomes fabricableL (blocked) + S

Roles — AUTH-ROLE

IDCaseFlavorPreconditionsSequence → expectedLayersTrace
AUTH-ROLE-01Register creates an ownerboth= REG-02Live-verified: role id 46381D7A-… (owner), key owner; license nested on the full readU (role helpers) + LSHR-AUTH-03
AUTH-ROLE-02Athlete under a disposable orgpro_appdisposable 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-03L
AUTH-ROLE-03Coach under a disposable orgpro_appdisposable ownerSame as ROLE-02 with the coach role — same live behavior (login 201, then 312)L
AUTH-ROLE-04Role/privilege helpersn/auserHasPrivilege, isAthleteRole/isOwnerRole, isProLicenseBaseID, privilege-catalogue identityU

Account deletion — AUTH-DEL

IDCaseFlavorPreconditionsSequence → expectedLayersTrace
AUTH-DEL-01Request delete OTP (Bearer)bothsigned-in disposable userLive-verified: GET /users/deleteUser/otp/{b64} Bearer → 200 {reason:"userDelete"}U+L+E
AUTH-DEL-02Delete account; success answers 401bothDEL-01Live-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-03Wrong delete OTPbothDEL-01Live-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-04Delete-OTP request without BearerbothnoneLive-verified: 401U+L
AUTH-DEL-05Owner-driven delete of an org childpro_appdisposable org + childLive-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 caseL

Resumable redirects — AUTH-RED

IDCaseFlavorPreconditionsSequence → expectedLayersTrace
AUTH-RED-01Device limit (310) reached and resolvedpro_appdisposable Pro userPer 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-loginLSHR-AUTH-06
AUTH-RED-02User-not-enabled (311) fabricatedpro_appNOT 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 questionL (blocked) + SSHR-AUTH-08
AUTH-RED-03Open invitations (312) fabricated and resolvedpro_appdisposable org + childLive-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 questionLSHR-AUTH-07
AUTH-RED-04Redirect body shapes via debug endpointspro_appsessionLive-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 401LPOR-AUTH-02
AUTH-RED-05Client resume semanticsn/aResolved action → same request retried (≤ 3); cancelled → ActionRequiredError. StubbedUPOR-AUTH-02

Flavor / header gates — AUTH-GATE

IDCaseFlavorPreconditionsSequence → expectedLayersTrace
AUTH-GATE-01Portal key without the enodeportalwebapp user-agentportaldisposable userDocumented 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-02Missing api-key headern/anoneLive-verified: 400 {reason:"Missing api-key field in header"} (device headers present); without device headers: 400 "Missing device information in header."L
AUTH-GATE-03One user on the Pro app= AUTH-LOG-10 (cross-reference, not a separate test)TRK-AUTH-02

Error families — AUTH-ERR

IDCaseFlavorPreconditionsSequence → expectedLayersTrace
AUTH-ERR-01OTP request rate limit (429)S only (deliberate: probing would pollute backdev's limiter for other testers). Backend asserts limiter thresholds + the 429 bodyS
AUTH-ERR-0215-minute OTP expiryS only (needs clock control). Backend asserts expiry + that resend does not reset the budgetS
AUTH-ERR-03Client-side OTP input constraintsn/aThe 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 headlessE

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):

SmokeAppCoversSteps
E-T1trackingAUTH-LOG-01/02Welcome → Sign in → smoke fixture email → OTP 123456 → lands on /workouts/today (login transition)
E-T2trackingAUTH-REG-01/02, AUTH-DEL-01/02, AUTH-ERR-03Create account (disposable email + org name) → OTP → lands on /workouts/today; then Settings → Profile → delete account (OTP) → back on welcome
E-T3trackingAUTH-LOG-10Sign in with the oneTier fixture → blocked dialog names the One app
E-P1portalAUTH-LOG-01/02Sign in → lands on /dashboard
E-P2portalAUTH-REG-01/02, AUTH-DEL-01/02Register → /dashboard; delete account → welcome
E-P3portalAUTH-LOG-11Sign 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 — plain register at the OTP stage is the confirmed expected behavior), and the one orphaned authlive-* 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 allowOneLogin flavor 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 the canAccessPro privilege — the Pro+ licence base is a different id and is misclassified today.
  • docs/auth.md still 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).