Skip to content

Hold the login-page redirect until permissions load (#3540) - #3541

Merged
mcdonc merged 4 commits into
mainfrom
i3540-fmtk-e2e-a
Oct 2, 2026
Merged

mcdonc merged 4 commits into
mainfrom
i3540-fmtk-e2e-a

Conversation

@mcdonc

@mcdonc mcdonc commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Summary

The flows-suite 'Handle' wait_for flake (#3540): a logged-in admin intermittently lands on /workspaces instead of the admin page after login, and nothing retries the navigation. Run 37013393514's backend log pins the mechanism — the workspaces page was fetching at 13:40:30 before /my-permissions returned at 13:40:31, so the post-login guard re-parse ran while _permissions was still empty. guardLoggedInPublicRoute tested the stashed /admin-prefixed target against canAccessAdmin == false and took the #2670 non-admin fallback to /workspaces; when permissions land, _fetchPermissions notifies nobody, the committed location stays /workspaces, and the stash is never re-attempted. The next test's identical navigation lands fine once the state is settled — which is why a different admin test fails each night.

Fix, two layers:

  • App (root cause). AuthService tracks permissionsLoaded across the session cycle — false when a token is saved or the session cleared, true once the permission fetch settles (a 200, a non-401 refusal, or a network error; a 401 clears the session instead). guardLoggedInPublicRoute holds (returns null) for a logged-in user on a public route while it is false: the fetch's completion fires the notify that re-parses the same location, and the gate then decides with live data. A hold is safe — the login page is a legitimate resting surface for the fraction of a second the fetch takes, and no other guard redirects a logged-in user away from it (no loop: the flag strictly transitions once per login).
  • Harness (belt and suspenders). open_admin_users re-runs its hash navigation once when the 'Handle' wait expires, absorbing any residual navigation race.

Closes #3540.

Testing

  • New guard tests: hold while loading (admin stash, no stash), decide normally once loaded, hold reaches the composed evaluateGuards.
  • New AuthService lifecycle tests: login settles the flag, a failed fetch still settles it (no eternal hold), logout clears it, a 401 clears the session rather than settling.
  • Full frontend suite: 1391 tests pass. The flows e2e workflow dispatched on the branch validates the scenario end to end.

A post-login guard re-parse that fires between the token write and
the /my-permissions response tests canAdminSection against empty
permissions: an admin's stashed /admin-prefixed target takes the
non-admin fallback to /workspaces, and once the fetch completes
nothing re-attempts the stash — the user is parked (the fmtk flows
'Handle' wait_for timeout, ~every 2nd-3rd nightly).

AuthService now tracks permissionsLoaded across the session cycle
(set false on token save/clear, true when the fetch settles on any
outcome but a 401), and guardLoggedInPublicRoute holds (returns
null) for a logged-in user on a public route while it is false —
the fetch's completion notify re-parses the same location and the
gate then decides with live data. open_admin_users retries its
navigation once as harness-side belt and suspenders.
@github-actions github-actions Bot added the backport/2.0 Merge also backports the squash commit to stable/2.0 (#3361) label Oct 2, 2026
mcdonc added 2 commits October 2, 2026 10:55
Review follow-up: the reachable flake path is a mid-saveToken
navigation to /admin/users (the e2e login wait matches the typed
email, so the harness navigates before /my-permissions answers) —
a non-public route, so the guardLoggedInPublicRoute hold never
fires and guardAdminRoute bounced the empty-permission admin to
/workspaces. guardAdminRoute now takes permissionsLoaded and holds
the same way; the fetch's completion notify re-parses the held
location and keeps an admin there (or bounces a settled non-admin).

Also pins the integration path: app_redirect_test now wires the
real flag into its router and drives the deferred-permission login
both ways (admin stays, non-admin bounces on settle), plus a
persisted-boot-token lifecycle test and doc precision on the
hold's scope and the flag's meaning.
@mcdonc

mcdonc commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

Fresh-eyes review

Full review (REQUEST CHANGES → addressed in 4c424a3):

Blocking issues

1. The hold does not cover guardAdminRoute — the path the #3540 flake actually takes. src/frontend/lib/app_guards.dart:166-181 still decides canAccessAdmin against empty permissions with no permissionsLoaded input, while the fix (line 137) holds only guardLoggedInPublicRoute. Trace the observed race: fmtk's wait_for "matches any string in the tree" (fmtk schema), including the filled login form — the harness's own helpers confirm typed field values match text predicates (row_top and picker_result_email skip textField nodes for exactly this reason, and invite_via_dialog waits for dialog-closed before waiting for the emailed row). So app.login(ADMIN_EMAIL, …, expect_text=ADMIN_EMAIL) returns the moment the typed email is visible — i.e. mid-login(). open_admin_users then hash-navigates to /admin/users while _saveToken is still awaiting bind → config → my-permissions (auth_service.dart:474-492): GoRouter parses /admin/users, guardLoggedInPublicRoute is a no-op (not a public route), and guardAdminRoute bounces the empty-permission admin to /workspaces — committed, stash never re-attempted, admin parked. That is exactly the issue's failure snapshot. Conversely, the guardLoggedInPublicRoute hold is nearly unreachable in the password-login flow: the only AuthService notifications in that flow (_saveToken's trailing notify, login()'s finally) fire after _fetchPermissions settles, so the guard never evaluates with empty permissions there. Net: the app-level fix guards a mostly theoretical path, leaves the real one open, and the harness retry papers over it — costing a 30s first-wait stall whenever the race fires, and still failing when the fetch window spans both waits (the issue's own log shows a ~60s my-permissions roundtrip in run 37013393514). Issue #3540 names this explicitly: "the guard should hold the route, not land the user back on /workspaces". The changelog claim "Admin deep-link login lands on the requested admin page" is therefore false for any direct /admin navigation during the window. Fix: thread permissionsLoaded into guardAdminRoute (or hold centrally in evaluateGuards) and add the matching guard test.

Important issues

2. The integration behavior is not pinned anywhere. src/frontend/test/app_redirect_test.dart:121 hardcodes permissionsLoaded: true — the suite whose stated purpose is to pin GoRouter re-parse semantics (#2670) now bypasses the new behavior instead of covering it. The new unit tests pin the guard function (app_guards_test.dart:343-396, real expectations, not tautological) and the flag lifecycle (auth_service_test.dart:572-661, including the failed-fetch and 401 cases — good), but nothing asserts that a notification fires after the flag flips in the login path. A refactor that moves _saveToken's notifyListeners() ahead of _fetchPermissions() would pass every test in this PR and resurrect the parked-on-/workspaces bug. A app_redirect_test scenario driving login with a deferred my-permissions response (the actual #3540 mechanism, whichever guard holds it) is the missing pin. Also no test for the _loadToken persisted-token boot path setting the flag.

Nits / questions

  1. guardLoggedInPublicRoute's doc (app_guards.dart:105-117) says "the login page is a legitimate resting surface" — the hold actually applies to every non-feature public route (/verify, /forgot-password, /reset-password, /accept-invite, /oidc-complete). During token-refresh windows (the flag re-flips false on every _saveToken, auth_service.dart:478) a logged-in user briefly lands on those pages before the bounce — transient, cosmetic, but the doc under-describes the scope.
  2. permissionsLoaded getter doc ("Whether [permissions] reflects the live session", auth_service.dart:223-226) is imprecise: during token refresh the flag is false while _permissions still holds the previous fetch's data. The flag means "no fetch has settled for the current token", not "data absent".
  3. _fetchPermissions has no HTTP timeout — a hung /my-permissions holds the login page indefinitely with the Log In button disabled (loading stays true through _saveToken). Pre-existing shape (pre-fix the bounce equally waited on the trailing notify), so not a regression — but the hold now makes "fraction of a second" (app_guards.dart:115-116) an unbounded claim. Worst case: user stuck on /login with a spinner until TCP gives up.
  4. If writeToken throws inside _saveToken (auth_service.dart:475-479), _token is already set, no fetch is ever scheduled, and the flag stays false → permanent hold with isLoggedIn == true. Degraded-storage edge; login()'s catch surfaces an error string but the router holds. Noting the worst case; pre-existing lack of try/catch.

Verified non-issues: no strand in the normal lifecycle (all _saveToken continuations settle the flag; the _token != token early-return only fires when a newer save or a logout owns the session; 401 clears the session with the flag false; logout races end logged-out); no redirect loop (banner gate is terminal and precedes the hold; forced-password-change precedes it; the hold returns allow and the settle-notify re-parses once); #2670 stash-not-consumed semantics, #3321 _clearToken ordering, cross-session stash fallback, and #2923 delegated-auditor gating are all unchanged (delegated users benefit from the hold). The sed-inserted permissionsLoaded: true arguments are pure named-arg additions — no existing expectation changed. The harness retry is minimal and does not mask failures (the second wait_for_text re-raises). Both test files pass (169 tests).

Verdict

REQUEST CHANGES — the hold fixes the wrong guard: guardAdminRoute still bounces empty-permission admins to /workspaces during the fetch window (the reachable, issue-named path), leaving the changelog claim untrue and the e2e flake survived only by the harness retry.


Delta review of the fix commit (APPROVE):

Review: PR #3541 (delta 5bd0e31..4c424a3)

Prior findings verification

Blocking 1 — VERIFIED FIXED. guardAdminRoute now takes required bool permissionsLoaded and holds (return null) for a logged-in user on an /admin-prefixed location while the flag is false (src/frontend/lib/app_guards.dart:179–188); a settled non-admin still bounces to /workspaces. evaluateGuards threads the flag (app_guards.dart:263–267), and the production wiring passes the live flag (src/frontend/lib/app.dart:115). Guard tests added for all four outcomes (app_guards_test.dart:476–521).

Mutation check: I reverted the guard body to the pre-fix logic (keeping the parameter) — both new widget tests fail with Expected: '/admin/users' Actual: '/workspaces'. The tests are load-bearing, not vacuous.

Important 2 — VERIFIED FIXED. app_redirect_test.dart:125 wires the real auth.permissionsLoaded; the two Completer-driven testWidgets pin the full integration — notably the non-admin test's bounce to /workspaces happens with no router.go after the fetch completes, proving the completion-notification re-parse is what routes. auth_service_test.dart:644–666 pins the _loadToken persisted-boot path settling the flag (and the boot deep-link path can't see the hold anyway — app.dart builds the router only after initialized, which flips post-fetch).

Nits 3–4 — fixed (doc rewordings verified in the tree).

All three touched test files pass (180 tests), plus oidc_complete_page_test (the other evaluateGuards caller — no compile breakage).

Blocking issues

None.

Important issues

None.

Nits / questions

  1. Nit 5 is papered over, not noted. The new doc claims "the bounded cost of not parking admins permanently" (app_guards.dart:170–172), but _fetchPermissions has no timeout (no .timeout() anywhere in auth_service.dart) — a hung /my-permissions makes the hold unbounded and leaves a non-admin parked on the admin page for its duration. The prior review asked for this worst case to be noted; the delta instead asserts boundedness. Pre-existing shape, so nit-level, but the doc states the opposite of the truth in that case.
  2. Test router fidelity: app_redirect_test.dart:124 wires canAccessAdmin: auth.isAdmin while production wires auth.canAdminSection (app.dart:114) — the delegated-admin (permissions-based, is_admin: false) variant of the mid-login hold isn't exercised through the widget router. Unit tests cover canAccessAdmin as an opaque boolean, so behavior is equivalent; minor gap.
  3. Nit 6 (writeToken throw → flag stuck false) unaddressed — acknowledged pre-existing.
  4. Changelog headline still reads "Admin deep-link login lands on the requested admin page" while the body (correctly) covers direct /admin navigations too — the headline under-claims the fix.

Regression bleed

None found. The permissionsLoaded: true additions to existing guard tests are mechanical; switching the redirect-test router to the real flag changed no existing test outcome (they settle everything via pumpAndSettle before asserting, by which point the flag is true). Refresh-window navigation to /admin (flag false, stale-but-present permissions) holds then settles correctly — same tradeoff, already documented.

Verdict

APPROVE — the blocking and important findings are genuinely fixed with load-bearing tests (mutation-verified), and only doc-accuracy nits remain.

@mcdonc

mcdonc commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

3D dependency graph (self-contained HTML)

The page is fully self-contained: save it from either link and it opens offline. GitHub serves the raw gist as plain text, so the source link downloads; the render link views.

The dispatched flows run exposed the second-order gap: the admin
gate's hold can mount AdminUsersPage while /my-permissions is in
flight, and the page re-resolves its permission-gated tabs only in
build() — with no dependency on AuthService, the settle notification
never rebuilt it, so it stayed on 'No admin sections available'
forever (the events test's 'Handle' timeout, 65s through both the
wait and the harness retry). The page now watches AuthService, so
the fetch's completion notify rebuilds it with live permissions.

Also updates the prior review's doc nits (hold-duration honesty —
the fetch has no client-side timeout; test-router fidelity with
production's canAdminSection wiring; changelog headline).
@mcdonc

mcdonc commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

e2e validation (fmtk flows, dispatched on this branch)

run commit result admin module
37023406623 4c424a3 2 failed / 27 passed events failed — exposed the stale-page gap (below)
37027724889 9812570* 8 failed / 21 passed all green
37031651839 9812570 2 failed / 27 passed all green

* run predates 9812570 by one docs-only commit; 8 failures were the #3469 wedge family (one isolate-wedge marker) — this branch does not touch those paths.

The first dispatched run caught a real second-order bug the review had flagged as a worst case: with the hold, AdminUsersPage can mount while the permission fetch is in flight, and its "re-resolve on each build" only worked because nothing could previously mount it mid-fetch — the settle notification never rebuilt the page, so it stayed on "No admin sections available" for 65s (through both the wait and the harness retry). 9812570 makes the page watch AuthService, with a mutation-verified regression test.

Across the three runs the 'Handle' timeout (#3540's subject) never recurred — every admin scenario passed in the last two. The residual failures are the pre-existing flake families on main's nightlies (the auth pair in runs 1 and 3; the #3469 wedge cascade in run 2), all outside this branch's blast radius.

@mcdonc
mcdonc merged commit 633a8be into main Oct 2, 2026
9 of 11 checks passed
@mcdonc
mcdonc deleted the i3540-fmtk-e2e-a branch October 2, 2026 16:34
@mcdonc

mcdonc commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

Created backport PR for stable/2.0:

Please cherry-pick the changes locally and resolve any conflicts.

git fetch origin backport-3541-to-stable/2.0
git worktree add --checkout .worktree/backport-3541-to-stable/2.0 backport-3541-to-stable/2.0
cd .worktree/backport-3541-to-stable/2.0
git reset --hard HEAD^
git cherry-pick -x 633a8be638978ea343df173ec1da93e8981d1033

mcdonc added a commit that referenced this pull request Oct 2, 2026
…issions load (#3545)

* frontend: hold the login-page redirect until permissions load (#3540)

A post-login guard re-parse that fires between the token write and
the /my-permissions response tests canAdminSection against empty
permissions: an admin's stashed /admin-prefixed target takes the
non-admin fallback to /workspaces, and once the fetch completes
nothing re-attempts the stash — the user is parked (the fmtk flows
'Handle' wait_for timeout, ~every 2nd-3rd nightly).

AuthService now tracks permissionsLoaded across the session cycle
(set false on token save/clear, true when the fetch settles on any
outcome but a 401), and guardLoggedInPublicRoute holds (returns
null) for a logged-in user on a public route while it is false —
the fetch's completion notify re-parses the same location and the
gate then decides with live data. open_admin_users retries its
navigation once as harness-side belt and suspenders.

* frontend: guardAdminRoute holds while permissions load too (#3540)

Review follow-up: the reachable flake path is a mid-saveToken
navigation to /admin/users (the e2e login wait matches the typed
email, so the harness navigates before /my-permissions answers) —
a non-public route, so the guardLoggedInPublicRoute hold never
fires and guardAdminRoute bounced the empty-permission admin to
/workspaces. guardAdminRoute now takes permissionsLoaded and holds
the same way; the fetch's completion notify re-parses the held
location and keeps an admin there (or bounces a settled non-admin).

Also pins the integration path: app_redirect_test now wires the
real flag into its router and drives the deferred-permission login
both ways (admin stays, non-admin bounces on settle), plus a
persisted-boot-token lifecycle test and doc precision on the
hold's scope and the flag's meaning.

* docs(fmtk #3540): review nits — hold-duration honesty, test fidelity, changelog headline

* frontend: admin page rebuilds when the permission fetch settles (#3540)

The dispatched flows run exposed the second-order gap: the admin
gate's hold can mount AdminUsersPage while /my-permissions is in
flight, and the page re-resolves its permission-gated tabs only in
build() — with no dependency on AuthService, the settle notification
never rebuilt it, so it stayed on 'No admin sections available'
forever (the events test's 'Handle' timeout, 65s through both the
wait and the harness retry). The page now watches AuthService, so
the fetch's completion notify rebuilds it with live permissions.

Also updates the prior review's doc nits (hold-duration honesty —
the fetch has no client-side timeout; test-router fidelity with
production's canAdminSection wiring; changelog headline).

(cherry picked from commit 633a8be)
mcdonc added a commit that referenced this pull request Oct 2, 2026
…issions load (#3545) (#3545)

* frontend: hold the login-page redirect until permissions load (#3540)

A post-login guard re-parse that fires between the token write and
the /my-permissions response tests canAdminSection against empty
permissions: an admin's stashed /admin-prefixed target takes the
non-admin fallback to /workspaces, and once the fetch completes
nothing re-attempts the stash — the user is parked (the fmtk flows
'Handle' wait_for timeout, ~every 2nd-3rd nightly).

AuthService now tracks permissionsLoaded across the session cycle
(set false on token save/clear, true when the fetch settles on any
outcome but a 401), and guardLoggedInPublicRoute holds (returns
null) for a logged-in user on a public route while it is false —
the fetch's completion notify re-parses the same location and the
gate then decides with live data. open_admin_users retries its
navigation once as harness-side belt and suspenders.

* frontend: guardAdminRoute holds while permissions load too (#3540)

Review follow-up: the reachable flake path is a mid-saveToken
navigation to /admin/users (the e2e login wait matches the typed
email, so the harness navigates before /my-permissions answers) —
a non-public route, so the guardLoggedInPublicRoute hold never
fires and guardAdminRoute bounced the empty-permission admin to
/workspaces. guardAdminRoute now takes permissionsLoaded and holds
the same way; the fetch's completion notify re-parses the held
location and keeps an admin there (or bounces a settled non-admin).

Also pins the integration path: app_redirect_test now wires the
real flag into its router and drives the deferred-permission login
both ways (admin stays, non-admin bounces on settle), plus a
persisted-boot-token lifecycle test and doc precision on the
hold's scope and the flag's meaning.

* docs(fmtk #3540): review nits — hold-duration honesty, test fidelity, changelog headline

* frontend: admin page rebuilds when the permission fetch settles (#3540)

The dispatched flows run exposed the second-order gap: the admin
gate's hold can mount AdminUsersPage while /my-permissions is in
flight, and the page re-resolves its permission-gated tabs only in
build() — with no dependency on AuthService, the settle notification
never rebuilt it, so it stayed on 'No admin sections available'
forever (the events test's 'Handle' timeout, 65s through both the
wait and the harness retry). The page now watches AuthService, so
the fetch's completion notify rebuilds it with live permissions.

Also updates the prior review's doc nits (hold-duration honesty —
the fetch has no client-side timeout; test-router fidelity with
production's canAdminSection wiring; changelog headline).

(cherry picked from commit 633a8be)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport/2.0 Merge also backports the squash commit to stable/2.0 (#3361)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fmtk e2e: admin /admin/users navigation flakes — wait_for 'Handle' times out on /workspaces

1 participant