fix(web): terminate the five void-discarded OAuth recovery promises
Each of the five sites in `useOAuthRecovery` discarded a promise with `void`, satisfying `@typescript-eslint/no-floating-promises` but not the narrower rule AGENTS.md states — `void` only where the callee owns its failures or the caller cannot await, with a comment saying which. Each now picks a user-visible outcome: - `prepareOAuthRedirect` clears the resume snapshot (so the next load cannot read a redirect that never started as an *abandoned* one), flags the server and raises the re-auth banner. - the `authChallengeInteractive` handler flags and banners, where before a rejection drew no UI response at all. - `resumePendingReauth` restores the pending slot and toasts, so the next trigger retries the deferred recovery instead of losing it. - the `/oauth/callback` effect flags and banners what falls between its existing arms — the client rebuild and the post-resume scope check. - `handleStepUpAuthorize`'s EMA branch reports exactly as its `failed` outcome arm does; the stored retry gets its own catch, since by then the permissions leg succeeded and the failure is the command's. The two surviving `void`s carry the justification AGENTS.md asks for. Closes #2165 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:
parent
8c91ae8820
commit
f74aa757d2
1 changed files with 290 additions and 5 deletions
|
|
@ -570,6 +570,31 @@ describe("useOAuthRecovery", () => {
|
|||
expect(active.beginInteractiveAuthorization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the snapshot and reports a redirect that never started", async () => {
|
||||
// The navigation is what the snapshot describes, so a rejection here
|
||||
// must not leave one behind: the next load would read it as an
|
||||
// *abandoned* redirect and offer that (wrong) diagnosis (#2165).
|
||||
const client = fakeClient({
|
||||
beginInteractiveAuthorization: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("provider state unreadable")),
|
||||
});
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
await act(async () => {
|
||||
h.api().prepareOAuthRedirect({
|
||||
serverId: "a",
|
||||
authKind: "reauth",
|
||||
authorizationUrl: AUTH_URL,
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
|
||||
expect(h.api().reAuthBanner?.message).toContain(
|
||||
"provider state unreadable",
|
||||
);
|
||||
expect(h.spies.setFailedServerId).toHaveBeenCalledWith("a");
|
||||
expect(window.sessionStorage.getItem(OAUTH_RESUME_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("tolerates having no client at all", () => {
|
||||
const h = harness({ servers: [entry("a")], client: null });
|
||||
act(() =>
|
||||
|
|
@ -845,6 +870,29 @@ describe("useOAuthRecovery", () => {
|
|||
expect(client.beginInteractiveAuthorization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a challenge whose recovery check rejects", async () => {
|
||||
// A listener cannot be awaited, so without the handler's own catch this
|
||||
// challenge draws no UI response whatsoever (#2165).
|
||||
const client = fakeClient({
|
||||
checkAuthChallengeSatisfied: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("remote auth state unreachable")),
|
||||
});
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
await act(async () => {
|
||||
client.emit("authChallengeInteractive", {
|
||||
challenge: challenge(),
|
||||
authorizationUrl: AUTH_URL,
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
|
||||
expect(h.api().reAuthBanner?.message).toContain(
|
||||
"remote auth state unreachable",
|
||||
);
|
||||
expect(h.spies.setFailedServerId).toHaveBeenCalledWith("a");
|
||||
expect(client.beginInteractiveAuthorization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("raises the banner on an oauthError event", async () => {
|
||||
const client = fakeClient();
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
|
|
@ -987,6 +1035,36 @@ describe("useOAuthRecovery", () => {
|
|||
expect(client.handleAuthChallenge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("restores the deferred slot when the resume itself rejects", async () => {
|
||||
// The slot is cleared to keep two triggers from racing, not because the
|
||||
// recovery was delivered — so a rejection has to put it back rather than
|
||||
// dropping the deferred recovery silently (#2165).
|
||||
const client = fakeClient({
|
||||
handleAuthChallenge: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("token endpoint unreachable")),
|
||||
});
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
await defer(client, h);
|
||||
|
||||
await act(async () => {
|
||||
becomeVisible();
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(client.handleAuthChallenge).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(toastTitles()).toContain("Could not continue authorization");
|
||||
expect(toastWith("token endpoint unreachable")).toBeTruthy();
|
||||
|
||||
// Restored, so the next trigger retries it.
|
||||
await act(async () => {
|
||||
becomeVisible();
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(client.handleAuthChallenge).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not resume while disconnected, and resumes on the reconnect", async () => {
|
||||
const client = fakeClient({
|
||||
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
|
||||
|
|
@ -1167,6 +1245,43 @@ describe("useOAuthRecovery", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("flags and banners a client rebuild that throws", async () => {
|
||||
// Between the arms that have their own handling (#2165): by the time
|
||||
// `setupClientForServer` runs, the callback URL and the one-shot
|
||||
// snapshot are both spent, so there is nothing left to retry with.
|
||||
snapshot();
|
||||
onCallbackUrl(`?code=abc&state=${AUTH_ID}`);
|
||||
const h = harness({
|
||||
servers: [entry("a")],
|
||||
setupClient: () => {
|
||||
throw new Error("client construction failed");
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
|
||||
expect(h.api().reAuthBanner?.message).toContain(
|
||||
"client construction failed",
|
||||
);
|
||||
expect(h.spies.setFailedServerId).toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
it("flags and banners a post-resume scope check that rejects", async () => {
|
||||
snapshot({
|
||||
authKind: "step_up",
|
||||
authChallenge: { reason: "insufficient_scope" },
|
||||
});
|
||||
const client = fakeClient({
|
||||
checkAuthChallengeSatisfied: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("scope check unreachable")),
|
||||
});
|
||||
const h = callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
|
||||
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
|
||||
expect(h.api().reAuthBanner?.message).toContain(
|
||||
"scope check unreachable",
|
||||
);
|
||||
expect(h.spies.setFailedServerId).toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
it("restores the shell, rebuilds the client and completes", async () => {
|
||||
snapshot({ remoteSessionId: "remote-9" });
|
||||
const client = fakeClient();
|
||||
|
|
@ -1593,6 +1708,54 @@ describe("useOAuthRecovery", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("routes an EMA step-up that rejects back to the panel that asked", async () => {
|
||||
// `StepUpAuthModal` calls this handler as `void onAuthorize()`, so
|
||||
// without the catch the rejection reaches nobody (#2165).
|
||||
const client = fakeClient({
|
||||
handleAuthChallenge: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("IdP unreachable")),
|
||||
});
|
||||
const h = harness({
|
||||
servers: [entry("a", {}, true)],
|
||||
activeServerId: "a",
|
||||
client,
|
||||
});
|
||||
await openStepUp(h, "prompt");
|
||||
await act(async () => {
|
||||
await h.api().handleStepUpAuthorize();
|
||||
});
|
||||
expect(h.spies.setSourceScopedError).toHaveBeenCalledWith(
|
||||
"prompt",
|
||||
expect.stringContaining("IdP unreachable"),
|
||||
);
|
||||
expect(toastTitles()).toContain("Organization permissions");
|
||||
});
|
||||
|
||||
it("reports a failed retry as the command's failure, not the step-up's", async () => {
|
||||
const client = fakeClient({
|
||||
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
|
||||
});
|
||||
const h = harness({
|
||||
servers: [entry("a", {}, true)],
|
||||
activeServerId: "a",
|
||||
client,
|
||||
});
|
||||
const retry = vi.fn().mockRejectedValue(new Error("tools/call failed"));
|
||||
await openStepUp(h, "tool", retry);
|
||||
await act(async () => {
|
||||
await h.api().handleStepUpAuthorize();
|
||||
});
|
||||
// The permissions leg succeeded, so this is the retried command's
|
||||
// failure and is reported as such.
|
||||
expect(toastTitles()).toContain("Permissions updated");
|
||||
expect(toastTitles()).toContain("Retry failed");
|
||||
expect(h.spies.setSourceScopedError).toHaveBeenCalledWith(
|
||||
"tool",
|
||||
"tools/call failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels back to the panel that asked", async () => {
|
||||
const client = fakeClient();
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
|
|
|
|||
Loading…
Reference in a new issue