fix(site/e2e): reset both providers in external auth hook - #26798
#26798Open
jakehwll wants to merge 3 commits into
maincoder/coder:mainfrom
jakehwll/devex-413-reset-all-providerscoder/coder:jakehwll/devex-413-reset-all-providersCopy head branch name to clipboard
Open
fix(site/e2e): reset both providers in external auth hook#26798jakehwll wants to merge 3 commits intomaincoder/coder:mainfrom jakehwll/devex-413-reset-all-providerscoder/coder:jakehwll/devex-413-reset-all-providersCopy head branch name to clipboard
jakehwll wants to merge 3 commits into
maincoder/coder:mainfrom
jakehwll/devex-413-reset-all-providerscoder/coder:jakehwll/devex-413-reset-all-providersCopy head branch name to clipboard
Conversation
This was referenced Jun 29, 2026
jakehwll
added a commit
that referenced
this pull request
Jul 1, 2026
> 🤖 This PR was written by Coder Agents on behalf of Jake Howell. Stack: 1. #26575 `fix(site/e2e): close mock external-auth servers in teardown` ← this PR 2. #26793 `fix(site/e2e): accept 404 from external auth reset hook` 3. #26795 `fix(site/src): refresh provider state after device-flow exchange` 4. #26798 `fix(site/e2e): reset both providers in external auth hook` 5. #26648 `chore(site/e2e): re-enable externalAuth suite` The externalAuth e2e suite has been skipped since #17235 because `createServer` in `site/e2e/helpers.ts` started an express server but never gave callers a way to close it. On retries or repeated runs, the listener from the previous invocation was still bound to the hardcoded port and the next `beforeAll` failed with `EADDRINUSE`, eventually timing out in `waitForPort`. `createServer` now returns a `{ app, close }` pair. The web flow closes in `afterAll`; the device flow uses `try/finally`. `closeAllConnections()` is called before `close()` so teardown stays bounded if keep-alive connections linger. The suite remains `test.describe.skip` here; #26648 flips the skip off once the rest of the stack is in. Refs https://linear.app/codercom/issue/DEVEX-413 Refs coder/internal#356 <details> <summary>Decision log</summary> Discussed the full options list with @jakehwll before drafting. Picked option A (minimal teardown) because: - Two prior PRs (#15537, #16528) attacked symptoms (port probing, longer timeout) without addressing the leaked listener. - Kayla's diagnosis on coder/internal#356 pointed at exactly this case: nothing else in CI is grabbing the port, the listener from the previous run is still bound. - A is mechanical and orthogonal: it adds a real teardown without changing port allocation, fixture wiring, or what gets mocked. If the flake persists after A, we know to escalate to a worker-scoped fixture or dynamically allocated ports. Returning `close` rather than the raw `http.Server` encapsulates the `closeAllConnections` + `close` choreography so callers don't repeat it. `closeAllConnections` is optional-chained because it landed in Node 18.2; coder/coder runs newer, but the chain costs nothing. The device test uses `try/finally` rather than a shared `afterEach` to keep per-test state local. The web flow's `afterAll` mirrors its `beforeAll`. </details>
jakehwll
force-pushed
the
jakehwll/devex-413-device-flow-invalidate
branch
from
July 1, 2026 03:21
d7a3347 to
eb911ad
Compare
jakehwll
force-pushed
the
jakehwll/devex-413-reset-all-providers
branch
from
July 1, 2026 03:21
67e425b to
73901cf
Compare
jakehwll
force-pushed
the
jakehwll/devex-413-device-flow-invalidate
branch
from
July 6, 2026 03:36
eb911ad to
3d12f7d
Compare
jakehwll
force-pushed
the
jakehwll/devex-413-reset-all-providers
branch
from
July 6, 2026 03:36
73901cf to
9d156be
Compare
jakehwll
force-pushed
the
jakehwll/devex-413-device-flow-invalidate
branch
from
July 6, 2026 03:44
3d12f7d to
fae2e1d
Compare
jakehwll
force-pushed
the
jakehwll/devex-413-reset-all-providers
branch
from
July 6, 2026 03:44
9d156be to
b448ef1
Compare
jakehwll
force-pushed
the
jakehwll/devex-413-device-flow-invalidate
branch
from
July 6, 2026 03:48
fae2e1d to
abf6b5f
Compare
jakehwll
force-pushed
the
jakehwll/devex-413-reset-all-providers
branch
from
July 6, 2026 03:48
b448ef1 to
bb757af
Compare
jakehwll
force-pushed
the
jakehwll/devex-413-device-flow-invalidate
branch
from
July 6, 2026 03:51
abf6b5f to
cc657cd
Compare
jakehwll
force-pushed
the
jakehwll/devex-413-reset-all-providers
branch
from
July 6, 2026 03:51
bb757af to
d39bd81
Compare
jakehwll
added a commit
that referenced
this pull request
Jul 6, 2026
> 🤖 This PR was written by Coder Agents on behalf of Jake Howell. Stack: 1. #26575 `fix(site/e2e): close mock external-auth servers in teardown` 2. #26793 `fix(site/e2e): accept 404 from external auth reset hook` ← this PR 3. #26795 `fix(site/src): refresh provider state after device-flow exchange` 4. #26798 `fix(site/e2e): reset both providers in external auth hook` 5. #26648 `chore(site/e2e): re-enable externalAuth suite` `deleteExternalAuthByID` used to be inverted: `sql.ErrNoRows` (link doesn't exist for this user/provider) fell through to the `500` path, while non-`ErrNoRows` DB errors went to `httpapi.ResourceNotFound`. #19775 (Sep 2025) refactored it to return `404` for not-found and `500` for real DB errors, which is the contract you'd expect. The relevant lines from #19775 in `coderd/externalauth.go`: ```diff - err := api.Database.DeleteExternalAuthLink(ctx, ...) + link, err := api.Database.GetExternalAuthLink(ctx, ...) if err != nil { - if !errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, sql.ErrNoRows) { httpapi.ResourceNotFound(w) return } httpapi.Write(ctx, w, http.StatusInternalServerError, ...) return } ``` `resetExternalAuthKey` in `site/e2e/hooks.ts` still treats `500` as the not-found code, so the first `beforeEach` in the externalAuth suite throws. The suite was skipped at the time #19775 landed (#17235), so nobody noticed the contract drift until #26648 tried to re-enable it. This just flips the accepted status codes to `200 || 404` and rewrites the stale comment. The 401/403/500 paths still surface as failures, which is what we want. Refs https://linear.app/codercom/issue/DEVEX-413 Refs #19775 <details> <summary>Why a separate PR</summary> Keeps the bisection signal clean: #26575 proves the EADDRINUSE flake is fixed, this PR fixes the hook contract drift surfaced by re-enabling the suite, and #26648 just flips `.skip`. Squashing into #26648 would conflate two unrelated fixes. The CI run on #26648 already confirms the flake fix is doing its job: `successful external auth from workspace` passes (5.6s) and the `beforeAll`/`afterAll` mock servers come up and tear down cleanly with no EADDRINUSE. The only failures are this 404 hook drift. </details>
jakehwll
force-pushed
the
jakehwll/devex-413-device-flow-invalidate
branch
from
July 6, 2026 03:57
cc657cd to
a15b490
Compare
jakehwll
force-pushed
the
jakehwll/devex-413-reset-all-providers
branch
from
July 6, 2026 03:58
d39bd81 to
df1ccab
Compare
#18039 upgraded react-query to v5, which removed onSuccess/onError/onSettled from useQuery (they only live on useMutation now). The migration updated the invalidateQueries argument shape but left the now-dead onSuccess in place on exchangeExternalAuthDevice. After a successful device-flow exchange the externalAuthProvider query was never invalidated, so the UI stayed on "Checking for authentication..." until manual refresh. Drop the dead onSuccess (and the queryClient param it depended on), and invalidate via a useEffect in ExternalAuthPage when the exchange query flips to isSuccess. Refs https://linear.app/codercom/issue/DEVEX-413 Refs #18039
resetExternalAuthKey only deleted the link for gitAuth.webProvider, leaving the device-flow link in place across iterations. The device test passed once and then deterministically failed on every subsequent run: page sees authenticated=true immediately, never polls the mock token endpoint, and sentPending.wait() hangs until the 30s test timeout. Reset every provider the suite exercises. Refactored the per-link delete into a Promise-returning helper so errors actually propagate (the previous throws inside response callbacks crashed the worker instead of rejecting the call). Locally verified with --repeat-each=20: 61/61 passing in 4.8m. The previous behaviour was 100% pass on iter 1, 100% device failure on iter 2+. Refs https://linear.app/codercom/issue/DEVEX-413
jakehwll
force-pushed
the
jakehwll/devex-413-device-flow-invalidate
branch
from
July 13, 2026 00:43
a15b490 to
5e91079
Compare
jakehwll
force-pushed
the
jakehwll/devex-413-reset-all-providers
branch
from
July 13, 2026 00:43
df1ccab to
46c991c
Compare
jakehwll
marked this pull request as ready for review
July 23, 2026 04:07
jakehwll
added a commit
that referenced
this pull request
Jul 28, 2026
) > 🤖 This PR was modified by Coder Agents on behalf of Jake Howell. Stack: 1. #26575 `fix(site/e2e): close mock external-auth servers in teardown` 2. #26793 `fix(site/e2e): accept 404 from external auth reset hook` 3. #26795 `fix(site/src): refresh provider state after device-flow exchange` ← this PR 4. #26798 `fix(site/e2e): reset both providers in external auth hook` 5. #26648 `chore(site/e2e): re-enable externalAuth suite` #18039 (May 2025) upgraded `@tanstack/react-query` from v4 to v5, which [removed `onSuccess`/`onError`/`onSettled` from `useQuery`](https://tanstack.com/query/v5/docs/react/guides/migrating-to-v5#callbacks-on-usequery-and-queryobserver-have-been-removed). The migration updated the `invalidateQueries` argument shape but left the now-dead `onSuccess` in place on `exchangeExternalAuthDevice`, which is consumed by `useQuery` in `ExternalAuthPage.tsx`. The relevant lines from #18039 in `site/src/api/queries/externalAuth.ts`: ```diff queryKey: ["external-auth", providerId, "device", deviceCode], onSuccess: async () => { // Force a refresh of the Git auth status. - await queryClient.invalidateQueries(["external-auth", providerId]); + await queryClient.invalidateQueries({ + queryKey: ["external-auth", providerId], + }); }, ``` Result: after a successful device-flow exchange the `externalAuthProvider` query is never invalidated, so `externalAuthProviderQuery.data.authenticated` stays `false` and the UI is stuck on "Checking for authentication..." until a manual page refresh. This breaks real users who go through the device flow today, not just the e2e suite that re-enables in #26648. This PR drops the dead `onSuccess` (and the `queryClient` param it depended on), and moves the invalidation into a `useEffect` in `ExternalAuthPage.tsx` that fires when `exchangeExternalAuthDeviceQuery.isSuccess` flips true. Matches the existing pattern in `LoginOAuthDevicePage.tsx`. Verified against the failing CI run on #26648 ([job 83970095566](https://github.com/coder/coder/actions/runs/28346242963/job/83970095566?pr=26648)): the trace shows `POST /api/v2/external-auth/device/device` returning `204` (exchange succeeded) followed by no subsequent `GET /api/v2/external-auth/device` to refresh the provider state. With this PR the `isSuccess` effect runs, the provider query refetches, and the UI flips to the authorized state. ## Regression test Added `site/src/pages/ExternalAuthPage/ExternalAuthPage.test.tsx` (the first test for this page). It renders the device flow with a stateful provider handler that returns `authenticated: false` until the exchange `POST` lands, then asserts the UI flips from the "Authenticate with GitHub" polling screen to "You've authenticated with GitHub!" without a manual refresh, and that the provider endpoint is refetched after the exchange. Confirmed red against the pre-fix code (stuck on the polling screen) and green with the fix. Refs https://linear.app/codercom/issue/DEVEX-413 Refs #18039 <details> <summary>Why <code>useEffect</code> rather than a query-level callback</summary> react-query v5 removed `onSuccess`/`onError`/`onSettled` from `useQuery` because the v4 behaviour was unsound: the callbacks fired per-observer rather than per-query, so they ran twice when two components observed the same query and not at all when a component unmounted and cached data was reused. [TKDodo's "Breaking React Query's API on Purpose"](https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose) and the [official v5 migration guide](https://tanstack.com/query/v5/docs/react/guides/migrating-to-v5#callbacks-on-usequery-and-queryobserver-have-been-removed) both recommend `useEffect` on `isSuccess` as the replacement. Alternatives considered: | Option | Why not | | --- | --- | | Side-effect in `queryFn` | Re-adds the `queryClient` dependency we just removed, and fires on every retry and cached re-read, not just first success. | | Convert to `useMutation` | Wrong semantics. This is a polling query with `retry: isExchangeErrorRetryable` and `retryDelay`; mutations are one-shot and don't have retry-on-pending machinery. | | Global `QueryCache` `onSuccess` | Runs for every query in the app; filtering by queryKey or `meta` for a single per-page side effect is more code than the effect it replaces. | | Custom hook wrapping `useQuery` | Only one consumer, so the abstraction would have one caller. | Local precedent: `LoginOAuthDevicePage.tsx` already uses the same `isSuccess` → `useEffect` pattern for the post-success `location.href` redirect. </details> <details> <summary>Why a separate PR</summary> Keeps the bisection signal clean. #26575 fixes the EADDRINUSE flake, #26793 fixes the 404 hook contract drift, this PR fixes the dropped invalidation, and #26648 just flips `.skip`. Each PR addresses one independent root cause that piled up while the externalAuth suite was skipped. </details>
Base automatically changed from
jakehwll/devex-413-device-flow-invalidate
to
main
July 28, 2026 03:53
jeremyruppel
approved these changes
Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack:
fix(site/e2e): close mock external-auth servers in teardownfix(site/e2e): accept 404 from external auth reset hookfix(site/src): refresh provider state after device-flow exchangefix(site/e2e): reset both providers in external auth hook← this PRchore(site/e2e): re-enable externalAuth suiteSurfaced by stress-testing the leaf state of the stack locally with
--repeat-each=N. The suite passes on iter 1, then deterministically fails the device test on every subsequent iteration:resetExternalAuthKeyinsite/e2e/hooks.tsonly deletes the link forgitAuth.webProvider, nevergitAuth.deviceProvider. So:Iter 1 of the device test creates a link for the
deviceprovider against the test user. The cleanup hook between tests resetswebbut notdevice. On iter 2 the page hits/external-auth/device,externalAuthProviderQuery.data.authenticatedis alreadytrue, the page renders the authorized state immediately, and the test'ssentPending.wait()hangs forever because the mock token endpoint is never polled. The whole test times out at 30s.Fix: reset every provider the suite exercises. Refactored the per-link delete into a Promise-returning helper so errors actually propagate; the previous
throwinside the response anderrorcallbacks crashed the worker rather than rejecting the call.Doesn't affect CI (single run per PR, fresh
--ephemeralcoderd), but it makes the suite robust to local stress testing and to anyone re-running it in the same coderd process. Filed before the un-skip lands so the un-skipped suite is repeatable from day one.Local verification:
pnpm playwright:test e2e/tests/externalAuth.spec.ts --repeat-each=20 --workers=1→ 61/61 passing in 4m48s. Same command before this fix → 1 pass, then deterministic device-test timeouts.Refs https://linear.app/codercom/issue/DEVEX-413
Why a separate PR
This is the fourth distinct root cause that piled up while the suite was skipped, and it's orthogonal to the other three. #26575 fixes the listener leak, #26793 fixes the 404 hook contract drift, #26795 fixes the dropped invalidation, and this PR fixes the per-test cleanup so the suite is actually repeatable. #26648 then just flips
.skip.