Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactNode, RefObject } from "react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { mock } from "vitest-mock-extended";

Expand Down Expand Up @@ -77,10 +78,49 @@ describe(PasswordlessAuth.name, () => {
expect(checkSession).toHaveBeenCalled();
});

it("navigates back once the user is authenticated", () => {
it("navigates back once the user is authenticated and the session is re-validated", async () => {
const { navigateBack } = setup({ authenticated: true });

expect(navigateBack).toHaveBeenCalled();
await vi.waitFor(() => expect(navigateBack).toHaveBeenCalled());
});

it("does not navigate back before the session re-validation settles", () => {
const { navigateBack } = setup({ authenticated: true, sessionRevalidation: "pending" });

expect(navigateBack).not.toHaveBeenCalled();
});

it("renders the login form instead of navigating back when re-validation clears a stale user", async () => {
const EmailCodeStartMock = vi.fn(ComponentMock);
const { navigateBack, checkSession } = setup({
authenticated: true,
sessionRevalidation: "expired",
dependencies: { EmailCodeStart: EmailCodeStartMock as never }
});

await vi.waitFor(() => expect(EmailCodeStartMock).toHaveBeenCalled());

expect(checkSession).toHaveBeenCalled();
expect(navigateBack).not.toHaveBeenCalled();
});

it("does not re-check the session when there is no cached user to revalidate", () => {
const { checkSession } = setup();

expect(checkSession).not.toHaveBeenCalled();
});

it("shows the login form without navigating back when re-validation fails transiently", async () => {
const EmailCodeStartMock = vi.fn(ComponentMock);
const { navigateBack } = setup({
authenticated: true,
sessionRevalidation: "errored",
dependencies: { EmailCodeStart: EmailCodeStartMock as never }
});

await vi.waitFor(() => expect(EmailCodeStartMock).toHaveBeenCalled());

expect(navigateBack).not.toHaveBeenCalled();
});

it("renders the boot loader instead of the auth forms when authenticated", () => {
Expand Down Expand Up @@ -109,6 +149,12 @@ describe(PasswordlessAuth.name, () => {
expect(replace).not.toHaveBeenCalled();
});

it("redirects to entry on a missing-email verify step when a stale user's re-check errors", async () => {
const { replace } = setup({ authenticated: true, sessionRevalidation: "errored", step: "verify", initialEmail: "" });

await vi.waitFor(() => expect(replace).toHaveBeenCalledWith(expect.not.stringContaining("step"), undefined, { shallow: true }));
});

it("provides a captcha-token getter that resolves to the Turnstile token", async () => {
const EmailCodeStartMock = vi.fn(ComponentMock);
setup({ dependencies: { EmailCodeStart: EmailCodeStartMock as never } });
Expand Down Expand Up @@ -145,24 +191,43 @@ describe(PasswordlessAuth.name, () => {
initialEmail?: string;
step?: string;
authenticated?: boolean;
/**
* "valid" (default) keeps the user after re-validation, "expired" clears it, "pending" never
* settles, "errored" keeps the stale user but surfaces an error (transient re-fetch failure).
*/
sessionRevalidation?: "valid" | "expired" | "pending" | "errored";
dependencies?: Partial<typeof DEPENDENCIES>;
} = {}
) {
const analyticsService = mock<AnalyticsService>();
const onEmailChange = vi.fn();
const onFlowReset = vi.fn();
const checkSession = vi.fn(async () => undefined);
const navigateBack = vi.fn();
const push = vi.fn();
const replace = vi.fn();
const params = new URLSearchParams();
if (input.step) params.set("step", input.step);
const useUser: typeof DEPENDENCIES.useUser = () =>
mock<ReturnType<typeof DEPENDENCIES.useUser>>({
const initialUser = input.authenticated ? mock<NonNullable<ReturnType<typeof DEPENDENCIES.useUser>["user"]>>({ userId: "user-1" }) : undefined;
let clearUser: () => void = () => undefined;
let failRevalidation: () => void = () => undefined;
const checkSession = vi.fn(async () => {
if (input.sessionRevalidation === "pending") return new Promise<undefined>(() => undefined);
if (input.sessionRevalidation === "expired") clearUser();
if (input.sessionRevalidation === "errored") failRevalidation();
return undefined;
});
const useUser: typeof DEPENDENCIES.useUser = () => {
const [user, setUser] = useState(initialUser);
const [error, setError] = useState<Error | undefined>(undefined);
clearUser = () => setUser(undefined);
failRevalidation = () => setError(new Error("network down"));
return mock<ReturnType<typeof DEPENDENCIES.useUser>>({
checkSession,
isLoading: false,
user: input.authenticated ? mock<NonNullable<ReturnType<typeof DEPENDENCIES.useUser>["user"]>>({ userId: "user-1" }) : undefined
user,
error
});
};
const useReturnTo: typeof DEPENDENCIES.useReturnTo = () =>
mock<ReturnType<typeof DEPENDENCIES.useReturnTo>>({
returnTo: "/",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ interface Props extends PassedFlowProps {
export function PasswordlessAuth({ dependencies: d = DEPENDENCIES, ...props }: Props) {
const { publicConfig, analyticsService } = useServices();
const { navigateBack } = d.useReturnTo({ defaultReturnTo: "/" });
const { checkSession, user } = d.useUser();
const { checkSession, user, error } = d.useUser();
const router = d.useRouter();
const searchParams = d.useSearchParams();
const [email, setEmail] = useState(props.initialEmail);
const [screenKey, setScreenKey] = useState(0);
const [isSessionRevalidated, setIsSessionRevalidated] = useState(false);
const turnstileRef = useRef<TurnstileRef>(null);
const hadCachedUserOnMountRef = useRef(!!user);

const screen: "entry" | "verify" = searchParams.get("step") === "verify" ? "verify" : "entry";

Expand Down Expand Up @@ -77,26 +79,51 @@ export function PasswordlessAuth({ dependencies: d = DEPENDENCIES, ...props }: P

/**
* Sends a visitor who reached `?step=verify` without an in-flight email (a deep link, or a reload
* after the flow was cleared) back to the entry screen. Skipped once authenticated: a successful
* verification clears the persisted email and remounts this component (an ancestor provider swaps
* on the anon→authed transition) with an empty `email`; firing here would `router.replace` back to
* entry and clobber the post-verify `navigateBack()`.
* after the flow was cleared) back to the entry screen. Skipped only for a confirmed user (`!error`)
* about to leave via `leaveWhenAuthenticated`: a successful verification clears the persisted email
* and remounts this component (an ancestor provider swaps on the anon→authed transition) with an
* empty `email`; firing here would `router.replace` back to entry and clobber the post-verify
* `navigateBack()`. When a re-check errors and retains a stale user, none of the leave guards fire,
* so this must still redirect rather than leave a blank screen.
*/
useEffect(
function redirectToEntryWhenEmailMissing() {
if (user) return;
if (user && !error) return;
if (screen === "verify" && !email) {
goBackToEntry();
}
},
[screen, email, goBackToEntry, user]
[screen, email, goBackToEntry, user, error]
);

/**
* The Auth0 client context can hold a stale user whose server session has already expired (the
* session cookie outlives the access token). Trusting it here would `navigateBack()` to a gated
* page whose SSR guard bounces straight back to /login — an infinite loop on a boot spinner
* (DEPLOY-WEB-2C4). When a user is cached, re-fetch the profile to clear a dead one before any
* navigation; a logged-out visitor has nothing to revalidate, so skip the redundant round trip.
*/
useEffect(
function revalidateSessionOnMount() {
if (!hadCachedUserOnMountRef.current) {
setIsSessionRevalidated(true);
return;
}
checkSession().finally(() => setIsSessionRevalidated(true));
},
[checkSession]
);

/**
* Auth0's `checkSession` keeps the cached user and only populates `error` when the profile
* re-fetch itself fails (network error or 5xx, as opposed to a clean 401 that clears the user).
* Gating on `!error` avoids navigating away on a stale user a transient failure couldn't confirm.
*/
useEffect(
function leaveWhenAuthenticated() {
if (user) navigateBack();
if (isSessionRevalidated && user && !error) navigateBack();
},
[user, navigateBack]
[isSessionRevalidated, user, error, navigateBack]
);
Comment thread
baktun14 marked this conversation as resolved.
Comment thread
baktun14 marked this conversation as resolved.

const handleVerified = useCallback(async () => {
Expand All @@ -108,7 +135,7 @@ export function PasswordlessAuth({ dependencies: d = DEPENDENCIES, ...props }: P
setScreenKey(value => value + 1);
}, []);

if (user) return <d.BootLoading />;
if (user && !error) return <d.BootLoading />;

return (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { useState } from "react";
import type { LoggerService } from "@akashnetwork/logging";
import { describe, expect, it, vi } from "vitest";
import { mock } from "vitest-mock-extended";

import { SessionExpiryNotifier } from "@src/services/session-expiry-notifier/session-expiry-notifier.service";
import type { DEPENDENCIES } from "./SessionExpirySync";
import { SessionExpirySync } from "./SessionExpirySync";

import { act, render } from "@testing-library/react";
import { TestContainerProvider } from "@tests/unit/TestContainerProvider";

describe(SessionExpirySync.name, () => {
it("re-checks the session when notified of an expiry", async () => {
const { notifier, checkSession } = setup();

await act(async () => notifier.notify());

expect(checkSession).toHaveBeenCalledTimes(1);
});

it("collapses a burst of notifications into a single in-flight re-check", async () => {
const { notifier, checkSession } = setup({ checkSessionOutcome: "pending" });

await act(async () => {
notifier.notify();
notifier.notify();
notifier.notify();
});

expect(checkSession).toHaveBeenCalledTimes(1);
});

it("re-checks again once the previous re-check has settled", async () => {
const { notifier, checkSession } = setup();

await act(async () => notifier.notify());
await act(async () => notifier.notify());

expect(checkSession).toHaveBeenCalledTimes(2);
});

it("unsubscribes on unmount", async () => {
const { notifier, checkSession, unmount } = setup();

unmount();
await act(async () => notifier.notify());

expect(checkSession).not.toHaveBeenCalled();
});

it("logs when a triggered re-check surfaces a session error", async () => {
const { notifier, logger } = setup({ checkSessionOutcome: "error" });

await act(async () => notifier.notify());

expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "SESSION_RECHECK_FAILED" }));
});

it("does not log an auth error that no re-check triggered", () => {
const { logger } = setup({ initialError: new Error("boot profile fetch failed") });

expect(logger.error).not.toHaveBeenCalled();
});

it("does not log when a triggered re-check reports no error", async () => {
const { notifier, logger } = setup();

await act(async () => notifier.notify());

expect(logger.error).not.toHaveBeenCalled();
});

it("swallows and logs an unexpected rejection from the re-check", async () => {
const { notifier, logger } = setup({ checkSessionOutcome: "rejected" });

await act(async () => notifier.notify());

expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "SESSION_RECHECK_FAILED" }));
});

it("does not log an unrelated error that lands after a clean re-check", async () => {
const { notifier, logger, surfaceError } = setup();

await act(async () => notifier.notify());
await act(async () => surfaceError());

expect(logger.error).not.toHaveBeenCalled();
});

function setup(input: { checkSessionOutcome?: "success" | "pending" | "rejected" | "error"; initialError?: Error } = {}) {
const notifier = new SessionExpiryNotifier();
const logger = mock<LoggerService>();
let surfaceError: () => void = () => undefined;
const checkSession = vi.fn(async () => {
if (input.checkSessionOutcome === "pending") return new Promise<void>(() => undefined);
if (input.checkSessionOutcome === "rejected") throw new Error("network down");
if (input.checkSessionOutcome === "error") surfaceError();
return undefined;
});
const useUser: typeof DEPENDENCIES.useUser = () => {
const [error, setError] = useState<Error | undefined>(input.initialError);
surfaceError = () => setError(new Error("network down"));
return mock<ReturnType<typeof DEPENDENCIES.useUser>>({
checkSession,
isLoading: false,
user: undefined,
error
});
};

const { unmount } = render(
<TestContainerProvider services={{ sessionExpiryNotifier: () => notifier, logger: () => logger }}>
<SessionExpirySync dependencies={{ useUser }} />
</TestContainerProvider>
);

return { notifier, checkSession, logger, unmount, surfaceError: () => surfaceError() };
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useRef, useState } from "react";

import { useServices } from "@src/context/ServicesProvider";
import { useUser } from "@src/hooks/useUser";

export const DEPENDENCIES = { useUser };

interface Props {
dependencies?: typeof DEPENDENCIES;
}

/**
* Converges client auth state with the server. A proxied API call that 401s means the server-side
* session is dead while the Auth0 client context may still hold a cached user; re-fetching the
* profile drops that user so `RequireAuth` routes to /login cleanly, instead of the app silently
* firing more requests with an expired token (DEPLOY-WEB-2C4). The in-flight guard collapses a
* burst of parallel 401s into a single re-check.
*/
export function SessionExpirySync({ dependencies: d = DEPENDENCIES }: Props = {}) {
const { checkSession, error } = d.useUser();
const { sessionExpiryNotifier, logger } = useServices();
const isReCheckingRef = useRef(false);
const [reCheckSettleCount, setReCheckSettleCount] = useState(0);
const reportedSettleCountRef = useRef(0);

useEffect(
function reCheckSessionOnExpiryNotice() {
return sessionExpiryNotifier.subscribe(async () => {
if (isReCheckingRef.current) return;
isReCheckingRef.current = true;
try {
await checkSession();
} catch (thrown) {
logger.error({ event: "SESSION_RECHECK_FAILED", error: thrown });
} finally {
isReCheckingRef.current = false;
setReCheckSettleCount(count => count + 1);
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
[sessionExpiryNotifier, checkSession, logger]
);
Comment thread
baktun14 marked this conversation as resolved.

/**
* Auth0's `checkSession` swallows a failed profile fetch (network error or 5xx) into the shared
* `error` state and resolves rather than rejecting, so the catch above never fires for that case.
* `reCheckSettleCount` ticks once per re-check this component triggered; only acting when it moves
* past the last reported tick reports that re-check's error alone, so an unrelated auth error (the
* app-boot profile fetch, the /login re-check) that also lands in `error` isn't mislabeled here.
*/
useEffect(
function reportReCheckOutcome() {
if (reCheckSettleCount === reportedSettleCountRef.current) return;
reportedSettleCountRef.current = reCheckSettleCount;
if (error) logger.error({ event: "SESSION_RECHECK_FAILED", error });
Comment thread
baktun14 marked this conversation as resolved.
},
[reCheckSettleCount, error, logger]
);
Comment thread
baktun14 marked this conversation as resolved.

return null;
}
Loading