Ctrl K
ComunidadGuías…

lib/auth-context.test.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// @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);
  });
});