lib/auth-context.test.tsx
Rama main · réplica de lectura del repositorio original.
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthChangeEvent, Session, SupabaseClient } from "@supabase/supabase-js";
import { createBrowserClient } from "@supabase/ssr";
import { AuthProvider, useAuth } from "./auth-context";
const h = vi.hoisted(() => ({
getSession: vi.fn(),
unsubscribe: vi.fn(),
callback: null as null | ((event: AuthChangeEvent, session: Session | null) => void),
analytics: { identify: vi.fn(), reset: vi.fn(), track: vi.fn() },
clearCache: vi.fn(),
clearTabs: vi.fn(),
sdk: null as SupabaseClient | null,
signOut: vi.fn(),
leaveProtectedScreen: vi.fn(),
}));
vi.mock("./supabase", () => ({ getSupabase: () => h.sdk ?? ({ auth: {
getSession: h.getSession,
signOut: h.signOut,
onAuthStateChange: (callback: typeof h.callback) => {
h.callback = callback;
return { data: { subscription: { unsubscribe: h.unsubscribe } } };
},
} }) }));
vi.mock("./query-provider", () => ({ clearPersistedQueryCache: h.clearCache }));
vi.mock("./app-tabs-storage", () => ({ clearAppTabsWindowState: h.clearTabs }));
vi.mock("./use-analytics", () => ({ useAnalytics: () => h.analytics }));
vi.mock("./runtime-config-provider", () => ({ browserRuntimeConfig: () => ({
supabaseUrl: "https://synthetic.supabase.co", supabaseAnonKey: "public",
}) }));
vi.mock("./interface-locale", () => ({ readInterfaceLocale: () => "en" }));
vi.mock("./signed-out-redirect", () => ({ leaveProtectedScreenAfterSignOut: h.leaveProtectedScreen }));
const savedSession = (id = "owner") => ({
access_token: `token-${id}`, refresh_token: "retained-refresh", expires_in: 3_600, token_type: "bearer",
user: { id, aud: "authenticated", created_at: "2026-01-01T00:00:00Z", user_metadata: { locale: "en" }, app_metadata: {} },
}) satisfies Session;
const result = (session: Session | null = null, error: unknown = null) => ({ data: { session }, error });
const transientFailure = () => Object.assign(new Error("Failed to fetch"), { name: "AuthRetryableFetchError" });
let host: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
let mounted: boolean;
let providers: string[];
let renders: number;
let lastUser: unknown;
let explicitSignOut: () => Promise<void>;
function Snapshot() {
const { user, session, loading, oauthProviders, signOut } = useAuth();
explicitSignOut = signOut;
providers = oauthProviders;
renders++;
lastUser = user;
return <output>{JSON.stringify({ owner: user?.id ?? null, token: session?.access_token ?? null, loading })}</output>;
}
const snapshot = () => JSON.parse(host.textContent!);
async function emit(event: AuthChangeEvent, session: Session | null) {
await act(async () => { h.callback!(event, session); });
}
async function advance(ms: number) {
await act(async () => { await vi.advanceTimersByTimeAsync(ms); });
}
async function dispatch(type: string) {
await act(async () => { window.dispatchEvent(new Event(type)); });
}
beforeEach(async () => {
vi.useFakeTimers();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ external: {} })));
h.getSession.mockReset().mockResolvedValue(result());
h.sdk = null;
h.clearCache.mockClear(); h.clearTabs.mockClear(); h.unsubscribe.mockClear();
h.signOut.mockReset(); h.leaveProtectedScreen.mockClear();
renders = 0;
host = document.createElement("div"); document.body.append(host);
root = createRoot(host); mounted = true;
await act(async () => { root.render(<AuthProvider><Snapshot /></AuthProvider>); });
});
afterEach(async () => {
if (mounted) await act(async () => { root.unmount(); });
h.sdk?.auth.stopAutoRefresh();
host.remove(); vi.useRealTimers(); vi.unstubAllGlobals();
});
describe("browser session recovery", () => {
it("rediscovers OAuth providers after an offline startup returns online", async () => {
const settings = vi.fn()
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValue(Response.json({ external: { google: true, github: true } }));
vi.stubGlobal("fetch", settings);
await act(async () => root.render(<AuthProvider key="settings"><Snapshot /></AuthProvider>));
await emit("INITIAL_SESSION", savedSession());
expect(providers).toEqual([]);
await dispatch("online");
expect(providers).toEqual(["google", "github"]);
expect(settings).toHaveBeenCalledTimes(2);
await dispatch("focus");
await advance(16_000);
expect(settings).toHaveBeenCalledTimes(2);
});
it("bounds stalled provider discovery and ignores its late settings after retry succeeds", async () => {
let finish!: (value: Response) => void;
const settings = vi.fn()
.mockImplementationOnce(() => new Promise<Response>(done => { finish = done; }))
.mockResolvedValue(Response.json({ external: { github: true } }));
vi.stubGlobal("fetch", settings);
await act(async () => root.render(<AuthProvider key="settings"><Snapshot /></AuthProvider>));
await emit("INITIAL_SESSION", savedSession());
await dispatch("online");
await dispatch("focus");
expect(settings).toHaveBeenCalledTimes(1);
await advance(16_000);
expect(providers).toEqual(["github"]);
expect(settings).toHaveBeenCalledTimes(2);
expect(settings.mock.calls[0][1].signal.aborted).toBe(true);
await act(async () => finish(Response.json({ external: { google: true } })));
expect(providers).toEqual(["github"]);
});
it("retries unavailable provider settings and removes retries and wake listeners on unmount", async () => {
const settings = vi.fn(async () => Response.json({}, { status: 503 }));
vi.stubGlobal("fetch", settings);
await act(async () => root.render(<AuthProvider key="settings"><Snapshot /></AuthProvider>));
await emit("INITIAL_SESSION", savedSession());
await advance(8_000);
expect(settings).toHaveBeenCalledTimes(2);
await act(async () => root.unmount());
mounted = false;
await dispatch("online");
await dispatch("focus");
await advance(16_000);
expect(settings).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
});
it("keeps startup loading until a null initial event is confirmed by the session read", async () => {
let finish!: (value: ReturnType<typeof result>) => void;
h.getSession.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await emit("INITIAL_SESSION", null);
await advance(0);
expect(snapshot().loading).toBe(true);
await act(async () => { finish(result(savedSession())); });
expect(snapshot()).toEqual({ owner: "owner", token: "token-owner", loading: false });
});
it("lets confirmed missing credentials leave the startup loading state", async () => {
await emit("INITIAL_SESSION", null);
await advance(0);
expect(snapshot()).toEqual({ owner: null, token: null, loading: false });
});
it("recovers an expired cookie with the actual SDK after an offline desktop startup", async () => {
const cookies = new Map<string, string>();
const token = (expiresAt: number) => [
{ alg: "HS256", typ: "JWT" }, { sub: "owner", exp: expiresAt, session_id: "session" },
].map(value => Buffer.from(JSON.stringify(value)).toString("base64url")).join(".") + ".signature";
cookies.set("test-session", JSON.stringify({ ...savedSession(), access_token: token(1), expires_at: 1 }));
let online = false;
const refresh = vi.fn(async () => online
? Response.json({ ...savedSession(), access_token: token(Math.floor(Date.now() / 1000) + 3_600), refresh_token: "recovered-refresh" })
: Response.json({ message: "Backend unavailable" }, { status: 503 }));
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
h.sdk = createBrowserClient("https://synthetic.supabase.co", "synthetic-anon-key", {
isSingleton: false, cookieOptions: { name: "test-session" }, cookieEncoding: "raw",
cookies: {
getAll: () => [...cookies].map(([name, value]) => ({ name, value })),
setAll: values => { values.forEach(({ name, value }) => cookies.set(name, value)); },
},
auth: { autoRefreshToken: true, detectSessionInUrl: false },
global: { fetch: refresh },
});
const events: { event: string; session: unknown }[] = [];
const { data: { subscription } } = h.sdk.auth.onAuthStateChange((event, session) => {
events.push({ event, session });
});
await act(async () => { root.render(<AuthProvider key="sdk"><Snapshot /></AuthProvider>); });
await advance(35_000);
await h.sdk.auth.initialize();
h.sdk.auth.stopAutoRefresh();
expect(events).toContainEqual({ event: "INITIAL_SESSION", session: null });
expect(cookies.get("test-session")).toContain("retained-refresh");
expect(snapshot().owner).toBeNull();
online = true;
await dispatch("online");
// The SDK caches failed renewals for a minute. Recovery must keep retrying
// after its automatic ticker is stopped, without a sign-out or reload.
await advance(65_000);
expect(snapshot().owner).toBe("owner");
expect(snapshot().loading).toBe(false);
expect(cookies.get("test-session")).toContain("recovered-refresh");
subscription.unsubscribe();
} finally {
warn.mockRestore();
}
});
it("restores a retained session after transient startup refresh failure without signing in again", async () => {
h.getSession.mockResolvedValueOnce(result(null, transientFailure())).mockResolvedValueOnce(result(savedSession()));
await emit("INITIAL_SESSION", null);
expect(h.getSession).not.toHaveBeenCalled();
await advance(0);
expect(snapshot().owner).toBeNull();
await advance(8_000);
expect(snapshot()).toEqual({ owner: "owner", token: "token-owner", loading: false });
expect(h.getSession).toHaveBeenCalledTimes(2);
});
it("recovers on network return and coalesces simultaneous foreground events", async () => {
h.getSession.mockResolvedValueOnce(result(null, transientFailure()));
await emit("INITIAL_SESSION", null); await advance(0);
let finish!: (value: ReturnType<typeof result>) => void;
h.getSession.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await dispatch("online"); await dispatch("focus");
await act(async () => { document.dispatchEvent(new Event("visibilitychange")); });
expect(h.getSession).toHaveBeenCalledTimes(2);
await act(async () => { finish(result(savedSession())); });
expect(snapshot().owner).toBe("owner");
await advance(8_000);
expect(h.getSession).toHaveBeenCalledTimes(2);
});
it("actively checks the session when the initial auth event misses the watchdog", async () => {
h.getSession.mockResolvedValueOnce(result(savedSession()));
await advance(8_000);
expect(snapshot().owner).toBe("owner");
expect(snapshot().loading).toBe(false);
});
it("releases a stalled session read so network return can recover and ignores its late result", async () => {
let finish!: (value: ReturnType<typeof result>) => void;
h.getSession.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await emit("INITIAL_SESSION", null);
await advance(0);
await advance(8_000);
h.getSession.mockResolvedValueOnce(result(savedSession()));
await dispatch("online");
expect(h.getSession).toHaveBeenCalledTimes(2);
expect(snapshot().owner).toBe("owner");
await act(async () => { finish(result(null)); });
expect(snapshot().owner).toBe("owner");
expect(h.clearCache).not.toHaveBeenCalled();
await advance(16_000);
expect(h.getSession).toHaveBeenCalledTimes(2);
});
it("cancels the recovery deadline when unmounting during a stalled session read", async () => {
h.getSession.mockImplementationOnce(() => new Promise(() => {}));
await emit("INITIAL_SESSION", null);
await advance(0);
await act(async () => { root.unmount(); }); mounted = false;
expect(vi.getTimerCount()).toBe(0);
await advance(16_000);
expect(h.getSession).toHaveBeenCalledTimes(1);
});
it.each(["SIGNED_OUT", "SIGNED_IN"] as const)("ignores a late session read after %s", async event => {
await emit("INITIAL_SESSION", savedSession());
let finish!: (value: ReturnType<typeof result>) => void;
h.getSession.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await dispatch("focus");
await emit(event, event === "SIGNED_IN" ? savedSession("other") : null);
await act(async () => { finish(result(savedSession())); });
expect(snapshot().owner).toBe(event === "SIGNED_IN" ? "other" : null);
expect(h.clearCache).toHaveBeenCalledOnce();
expect(h.clearTabs).toHaveBeenCalledOnce();
});
it("preserves the known account when a foreground read fails transiently", async () => {
await emit("INITIAL_SESSION", savedSession());
h.getSession.mockResolvedValueOnce(result(null, transientFailure()));
await dispatch("focus");
expect(snapshot().owner).toBe("owner");
expect(h.clearCache).not.toHaveBeenCalled();
});
it("does not clear a known account for a null initial notification during refresh", async () => {
await emit("SIGNED_IN", savedSession());
let finish!: (value: ReturnType<typeof result>) => void;
h.getSession.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await dispatch("focus");
await emit("INITIAL_SESSION", null); await advance(0);
await act(async () => { finish(result(null, transientFailure())); });
expect(snapshot().owner).toBe("owner");
expect(h.clearCache).not.toHaveBeenCalled();
h.getSession.mockResolvedValueOnce(result(savedSession()));
await advance(8_000);
expect(h.getSession).toHaveBeenCalledTimes(2);
expect(snapshot().owner).toBe("owner");
});
it.each([null, Object.assign(new Error("Invalid refresh token"), { status: 400 })])(
"does not poll absent or permanently rejected sessions: %s", async error => {
h.getSession.mockResolvedValueOnce(result(null, error));
await emit("INITIAL_SESSION", null); await advance(0); await advance(80_000);
expect(h.getSession).toHaveBeenCalledTimes(1);
expect(snapshot().owner).toBeNull();
},
);
it("keeps consumers still when a foreground read returns the same account", async () => {
await emit("INITIAL_SESSION", savedSession());
const settled = lastUser;
const before = renders;
h.getSession.mockImplementation(async () => result(savedSession()));
await dispatch("focus");
await act(async () => { document.dispatchEvent(new Event("visibilitychange")); });
await dispatch("online");
expect(h.getSession).toHaveBeenCalledTimes(3);
expect(lastUser).toBe(settled);
expect(renders).toBe(before);
const saved = savedSession();
const updated = { ...saved, user: { ...saved.user, user_metadata: { locale: "en", onboarding_done: true } } };
await emit("USER_UPDATED", updated);
expect(lastUser).not.toBe(settled);
expect((lastUser as typeof updated.user).user_metadata).toEqual({ locale: "en", onboarding_done: true });
await emit("SIGNED_IN", savedSession("other"));
expect(snapshot().owner).toBe("other");
await emit("SIGNED_OUT", null);
expect(lastUser).toBeNull();
});
it("leaves a protected screen when the session ends on its own, not after an explicit sign-out", async () => {
await emit("INITIAL_SESSION", savedSession());
await emit("SIGNED_OUT", null);
expect(h.leaveProtectedScreen).toHaveBeenCalledOnce();
h.leaveProtectedScreen.mockClear();
await emit("SIGNED_IN", savedSession());
h.signOut.mockImplementation(async () => { h.callback!("SIGNED_OUT", null); return { error: null }; });
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
await act(async () => { await explicitSignOut(); });
errors.mockRestore();
expect(h.signOut).toHaveBeenCalledWith({ scope: "local" });
expect(h.leaveProtectedScreen).not.toHaveBeenCalled();
});
it("skips provider discovery when the shell does not show provider buttons", async () => {
await act(async () => { root.unmount(); });
const settings = vi.fn(async () => Response.json({ external: { google: true } }));
vi.stubGlobal("fetch", settings);
root = createRoot(host);
await act(async () => {
root.render(<AuthProvider discoverOAuthProviders={false}><Snapshot /></AuthProvider>);
});
h.getSession.mockResolvedValue(result(savedSession()));
await emit("INITIAL_SESSION", savedSession());
await dispatch("online"); await dispatch("focus"); await advance(16_000);
expect(settings).not.toHaveBeenCalled();
expect(providers).toEqual([]);
expect(snapshot().owner).toBe("owner");
});
it("removes recovery listeners and retry timers when the provider unmounts", async () => {
h.getSession.mockResolvedValueOnce(result(null, transientFailure()));
await emit("INITIAL_SESSION", null); await advance(0);
await act(async () => { root.unmount(); }); mounted = false;
await dispatch("online"); await dispatch("focus"); await advance(80_000);
expect(h.getSession).toHaveBeenCalledTimes(1);
expect(h.unsubscribe).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
});