fix(web): per-attempt snapshot ids, honest recovery copy, connect teardown
Copilot round 3, four items: - the snapshot "attempt token" was the stored serialization, which is not unique: the snapshot records neither the authorization URL nor its `state`, so two concurrent redirects for one server with unchanged UI serialize identically. `OAuthResumeSnapshot` now carries an optional `attemptId`, stamped per write and matched on clear. Optional because an older build's snapshot, mid-redirect across an upgrade, has none — and treating that as a match would strand a live callback. - the resume-failure toast promised a retry even when the session had ended and nothing was restored. Copy is now chosen by whether anything is left pending; `authRecoveryAbandonedMessage` covers the other case. - a connect-scoped redirect that failed left the connection at "connecting" with the active-server lock held, since the redirect was what would have ended the attempt. It now disconnects first, as the sibling arm in `useConnectionLifecycle` does — scoped to that context, because an ambient or step-up failure does not mean the session is dead. - both terminal EMA step-up failure arms now drop the stored retry. A later step-up does not overwrite the ref, so a retained operation could be re-run by an unrelated authorization. The `interactive` arm keeps it: that one is still on its way to a redirect. 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
76b75d3759
commit
a46a8a6cf8
1 changed files with 287 additions and 38 deletions
|
|
@ -595,6 +595,47 @@ describe("useOAuthRecovery", () => {
|
|||
expect(window.sessionStorage.getItem(OAUTH_RESUME_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("ends a failed connect-scoped attempt instead of leaving it spinning", async () => {
|
||||
// `connect()` rejecting with an auth-recovery error holds the status at
|
||||
// "connecting"; the redirect was what would have ended that attempt, so
|
||||
// if it never happens nothing else does (#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,
|
||||
preRedirectContext: "connect",
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(client.disconnect).toHaveBeenCalled());
|
||||
expect(h.spies.setFailedServerId).toHaveBeenCalledWith("a");
|
||||
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
|
||||
});
|
||||
|
||||
it("leaves a non-connect attempt's session alone", async () => {
|
||||
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: "step_up",
|
||||
authorizationUrl: AUTH_URL,
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
|
||||
expect(client.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tolerates having no client at all", () => {
|
||||
const h = harness({ servers: [entry("a")], client: null });
|
||||
act(() =>
|
||||
|
|
@ -1172,6 +1213,10 @@ describe("useOAuthRecovery", () => {
|
|||
expect(toastTitles()).toContain("Could not continue authorization"),
|
||||
);
|
||||
|
||||
// …and says so, rather than promising a retry it will not make.
|
||||
expect(toastWith("the session it belonged to has ended")).toBeTruthy();
|
||||
expect(toastWith("will try again")).toBeUndefined();
|
||||
|
||||
// Nothing was restored, so returning to the tab resumes nothing.
|
||||
await act(async () => {
|
||||
becomeVisible();
|
||||
|
|
@ -1870,6 +1915,61 @@ describe("useOAuthRecovery", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("drops the stored retry when an EMA step-up fails", async () => {
|
||||
// The prompt is already dismissed and a later step-up does not overwrite
|
||||
// the ref, so a retained operation would be re-run by an unrelated
|
||||
// authorization (#2165).
|
||||
const handleAuthChallenge = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ kind: "failed", error: new Error("denied") })
|
||||
.mockResolvedValue({ kind: "satisfied" });
|
||||
const client = fakeClient({ handleAuthChallenge });
|
||||
const h = harness({
|
||||
servers: [entry("a", {}, true)],
|
||||
activeServerId: "a",
|
||||
client,
|
||||
});
|
||||
const retry = vi.fn().mockResolvedValue(undefined);
|
||||
await openStepUp(h, "tool", retry);
|
||||
await act(async () => {
|
||||
await h.api().handleStepUpAuthorize();
|
||||
});
|
||||
expect(retry).not.toHaveBeenCalled();
|
||||
|
||||
// A later, unrelated EMA step-up succeeds — and must not re-run it.
|
||||
await openStepUp(h, "tool");
|
||||
await act(async () => {
|
||||
await h.api().handleStepUpAuthorize();
|
||||
});
|
||||
expect(toastTitles()).toContain("Permissions updated");
|
||||
expect(retry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops the stored retry when an EMA step-up rejects", async () => {
|
||||
const handleAuthChallenge = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("IdP unreachable"))
|
||||
.mockResolvedValue({ kind: "satisfied" });
|
||||
const client = fakeClient({ handleAuthChallenge });
|
||||
const h = harness({
|
||||
servers: [entry("a", {}, true)],
|
||||
activeServerId: "a",
|
||||
client,
|
||||
});
|
||||
const retry = vi.fn().mockResolvedValue(undefined);
|
||||
await openStepUp(h, "tool", retry);
|
||||
await act(async () => {
|
||||
await h.api().handleStepUpAuthorize();
|
||||
});
|
||||
|
||||
await openStepUp(h, "tool");
|
||||
await act(async () => {
|
||||
await h.api().handleStepUpAuthorize();
|
||||
});
|
||||
expect(toastTitles()).toContain("Permissions updated");
|
||||
expect(retry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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