fix(web): address Copilot review on the OAuth recovery failure arms

Three findings, all valid:

- `beginInteractiveAuthorization` navigated *before* awaiting provider
  creation and flow-state recording, so a rejection could arrive after
  the browser had been handed over — and the web catch clears the resume
  snapshot on one. Fixed at the root: the navigation now resolves last,
  after every fallible step, which makes every rejection the method can
  produce a pre-navigation rejection. It is also better on its own
  terms — a provider that cannot be built means the callback could not
  have completed, so navigating first only bought a dead-end round trip.
- `resumePendingReauth` restored the pending slot unconditionally, which
  could overwrite a newer challenge deferred while it was awaiting.
  Restores only into an empty slot, so latest wins.
- the `authChallengeInteractive` catch re-read the active server rather
  than the one the challenge arrived for. `serverId` is now captured
  synchronously in the listener and used by both the body and the catch.

Three new tests, one per finding, each driving the interleaving rather
than the happy path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-29 01:54:53 -04:00
commit ecad587afe

View file

@ -893,6 +893,38 @@ describe("useOAuthRecovery", () => {
expect(client.beginInteractiveAuthorization).not.toHaveBeenCalled();
});
it("reports a rejected challenge against the server it arrived for", async () => {
// The catch must not re-read the active server: a switch during the
// await would otherwise flag the wrong one, or drop the report (#2165).
let rejectCheck: ((err: Error) => void) | undefined;
const client = fakeClient({
checkAuthChallengeSatisfied: vi.fn(
() =>
new Promise((_resolve, reject) => {
rejectCheck = reject;
}),
),
});
const servers = [entry("a"), entry("b")];
const h = harness({ servers, activeServerId: "a", client });
await act(async () => {
client.emit("authChallengeInteractive", {
challenge: challenge(),
authorizationUrl: AUTH_URL,
});
});
await waitFor(() => expect(rejectCheck).toBeDefined());
h.rerender({ servers, activeServerId: "b", client });
await act(async () => {
rejectCheck!(new Error("remote auth state unreachable"));
});
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
expect(h.spies.setFailedServerId).toHaveBeenCalledWith("a");
expect(h.spies.setFailedServerId).not.toHaveBeenCalledWith("b");
});
it("raises the banner on an oauthError event", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
@ -1065,6 +1097,51 @@ describe("useOAuthRecovery", () => {
);
});
it("does not put a stale challenge back over a newer deferral", async () => {
// The tab can go hidden mid-resume and a newer challenge defer itself
// into the slot; that one describes the session as it is now, so the
// failing attempt must not overwrite it (#2165).
let rejectResume: ((err: Error) => void) | undefined;
const client = fakeClient({
handleAuthChallenge: vi.fn(
() =>
new Promise((_resolve, reject) => {
rejectResume = reject;
}),
),
});
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await defer(client, h);
await act(async () => {
becomeVisible();
});
await waitFor(() => expect(rejectResume).toBeDefined());
// A newer challenge arrives while the resume is still in flight.
visibility.visible = false;
await act(async () => {
client.emit("authChallengeInteractive", {
challenge: challenge("insufficient_scope"),
authorizationUrl: AUTH_URL,
});
});
await act(async () => {
rejectResume!(new Error("token endpoint unreachable"));
});
await waitFor(() =>
expect(toastTitles()).toContain("Could not continue authorization"),
);
// The newer (step-up) challenge is what the next trigger acts on.
await act(async () => {
becomeVisible();
});
await waitFor(() => expect(h.api().pendingStepUp).not.toBeNull());
expect(client.handleAuthChallenge).toHaveBeenCalledTimes(1);
});
it("does not resume while disconnected, and resumes on the reconnect", async () => {
const client = fakeClient({
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),