fix(auth): guard OAuth-storage async paths against unhandled rejections

Review follow-ups on the RemoteOAuthStorage web migration:

- useEmaIdpLoginState.logout(): add a .catch to the now-async
  clearEmaIdpSession call so a storage failure no longer surfaces as an
  unhandled rejection; leave loginState unchanged on failure (the session
  was not actually cleared). Add a test asserting no unhandled rejection.
- createOAuthStore: attach a no-op catch to the eager persistLoadPromise
  so a hydration read that fails before any load() awaits it does not
  become an unhandled rejection; real awaiters still observe the rejection
  via the shared reference.
- storage-remote.test: replace `as unknown as typeof fetch` double-casts
  with the typed vi.fn<typeof fetch>() pattern the same PR established.
- remoteOAuthStorage: correct the doc comment to describe the cache-of-one
  accurately instead of implying a keyed map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-07-08 10:59:40 -04:00
commit ba48e06ffd
2 changed files with 80 additions and 9 deletions

View file

@ -142,12 +142,12 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
version: 0,
};
let resolveFetch!: (value: Response) => void;
const delayedFetch = vi.fn(
const delayedFetch = vi.fn<typeof fetch>(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
) as unknown as typeof fetch;
);
const delayedStorage = new RemoteOAuthStorage({
baseUrl: "http://remote.example",
@ -167,9 +167,9 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
});
it("load() rejects when remote GET fails instead of hanging", async () => {
const failingFetch = vi.fn(
const failingFetch = vi.fn<typeof fetch>(
async () => new Response("server error", { status: 500 }),
) as unknown as typeof fetch;
);
const failingStorage = new RemoteOAuthStorage({
baseUrl: "http://remote.example",

View file

@ -67,6 +67,58 @@ describe("useEmaIdpLoginState", () => {
});
});
it("logout swallows a clear failure without an unhandled rejection and keeps state", async () => {
const exp = Math.floor(Date.now() / 1000) + 3600;
const payload = btoa(JSON.stringify({ exp }))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
vi.mocked(storage.getIdpSession).mockResolvedValue({
idToken: `h.${payload}.s`,
});
vi.mocked(storage.clearIdpSession).mockRejectedValue(
new Error("storage backend unreachable"),
);
const unhandled = vi.fn();
const onUnhandled = (event: PromiseRejectionEvent) => {
event.preventDefault();
unhandled();
};
window.addEventListener("unhandledrejection", onUnhandled);
try {
const { result } = renderHook(() =>
useEmaIdpLoginState(storage, "https://idp.test", true),
);
await waitFor(() => {
expect(result.current.loginState).toBe("logged_in");
});
act(() => {
result.current.logout();
});
await waitFor(() => {
expect(storage.clearIdpSession).toHaveBeenCalledWith(
"https://idp.test",
);
});
// Give the rejected promise a turn to settle so any unhandled rejection
// would have fired.
await act(async () => {
await Promise.resolve();
});
// Clear failed, so the session is still present: state stays "logged_in"
// and no unhandled rejection escaped.
expect(result.current.loginState).toBe("logged_in");
expect(unhandled).not.toHaveBeenCalled();
} finally {
window.removeEventListener("unhandledrejection", onUnhandled);
}
});
it("reports 'expired' for an expired token with no refresh token", async () => {
const exp = Math.floor(Date.now() / 1000) - 3600;
const payload = btoa(JSON.stringify({ exp }))