fix(auth): make snapshot-and-clear one atomic storage step (#2144)
Copilot review round 18. Three of its findings were the same check-then-act: `planOAuthRevocation` is asynchronous, so the gap between its reads and the following `storage.clear()` is still a window — shorter than the network one closed in round 13, but the same shape. An OAuth completion landing at any of those awaits saved a grant the clear then destroyed. `OAuthStorage.takeRevocationSnapshot(serverUrl)` reads what revocation needs and clears the server in ONE synchronous pass over the in-memory state, with the persist after the mutation. `clearAndPlanRevocation` replaces `planOAuthRevocation`, and there is no separate `clear` call left in any caller — the clear cannot be forgotten or reordered. The snapshot returns values unparsed, deliberately: schema validation is pure and belongs after the mutation, since running it inside would reintroduce the `await` this exists to remove. Two consequences worth naming. A failure of the take-and-clear now REJECTS rather than returning an outcome, because the state was not cleared and the caller's contract is that it was; both clients already have a rejection path. And cross-process atomicity is explicitly not claimed — nothing in this store takes a lock, every mutation is a read-modify-write over a loaded snapshot, and `clear` alone always had that property. Also from round 18: - The CLI's budget check ran before considering `plan.outcome`, so a key that needed no network was reported as budget-exhausted — warning that a grant may be live when that key held none. - A disconnect failure after a successful clear was reported as "could not clear OAuth state" in both clients. It now goes to the disconnect channel, and the success is still reported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
0ded02b34c
commit
463227c947
2 changed files with 586 additions and 347 deletions
|
|
@ -1445,6 +1445,41 @@ describe("App (mid-session auth lifecycle events)", () => {
|
|||
);
|
||||
});
|
||||
|
||||
// The clear has already succeeded when the disconnect runs, so its failure
|
||||
// must not propagate: `AuthTab` would report "Could not clear OAuth state",
|
||||
// which is false. It goes to the disconnect error line, where the same
|
||||
// failure from the `d` key already lands.
|
||||
it("reports a post-clear disconnect failure as a disconnect failure", async () => {
|
||||
h.ctrl.status = "connected";
|
||||
h.disconnect.mockRejectedValue(new Error("disconnect-blew-up"));
|
||||
const r = await mount(oneHttp());
|
||||
await press(r, ["a", "s"]);
|
||||
|
||||
await expectFrame(r, "disconnect-blew-up");
|
||||
expect(r.lastFrame() ?? "").not.toContain("Could not clear OAuth state");
|
||||
});
|
||||
|
||||
// A transport can reject with something that is not an `Error`; the message
|
||||
// line must still be readable rather than "[object Object]".
|
||||
it("reports a non-Error disconnect failure from a clear", async () => {
|
||||
h.ctrl.status = "connected";
|
||||
h.disconnect.mockRejectedValue("plain-disconnect-string");
|
||||
const r = await mount(oneHttp());
|
||||
await press(r, ["a", "s"]);
|
||||
await expectFrame(r, "plain-disconnect-string");
|
||||
});
|
||||
|
||||
// A clear while still CONNECTING tears the attempt down too — the session is
|
||||
// half-built, and leaving it up with its OAuth state gone is worse than not
|
||||
// having it.
|
||||
it("disconnects a connecting session when clearing", async () => {
|
||||
h.ctrl.status = "connecting";
|
||||
const r = await mount(oneHttp());
|
||||
await press(r, ["a", "s"]);
|
||||
await waitUntil(() => h.disconnect.mock.calls.length > 0);
|
||||
expect(h.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says nothing when there was nothing to revoke", async () => {
|
||||
const r = await mount(oneHttp());
|
||||
await press(r, ["a", "s"]);
|
||||
|
|
|
|||
|
|
@ -992,7 +992,15 @@ function App({
|
|||
}
|
||||
setConnectError(null);
|
||||
if (inspectorStatus === "connected" || inspectorStatus === "connecting") {
|
||||
await disconnectInspector();
|
||||
// The clear has already succeeded by here, so a disconnect failure must
|
||||
// not propagate as one: `AuthTab` would report "Could not clear OAuth
|
||||
// state", which is false. It goes to the disconnect error line instead,
|
||||
// where the same failure from the `d` key already lands.
|
||||
try {
|
||||
await disconnectInspector();
|
||||
} catch (err) {
|
||||
setDisconnectError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
// Revalidate: the disconnect is a second await, and a switch during it
|
||||
// would make the revision bump below land on the new selection.
|
||||
if (
|
||||
|
|
|
|||
Loading…
Reference in a new issue