Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions web/src/hooks/auth/useAuthStatus.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { useAuthStatus } from './useServices';

const checkAuthStatusMock = vi.hoisted(() => vi.fn());

vi.mock('@/utils/auth.guards', () => ({
checkAuthStatus: checkAuthStatusMock,
}));

const wrapper = (client: QueryClient) => {
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
};

const newClient = () =>
new QueryClient({ defaultOptions: { queries: { retry: false } } });

describe('useAuthStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
checkAuthStatusMock.mockResolvedValue({
isAuthenticated: true,
role: null,
organizationId: null,
});
});

it('shares one request between callers that do not set the flag', async () => {
const client = newClient();
const { result } = renderHook(
() => ({ a: useAuthStatus(), b: useAuthStatus() }),
{ wrapper: wrapper(client) },
);

await waitFor(() => expect(result.current.a.isSuccess).toBe(true));
expect(checkAuthStatusMock).toHaveBeenCalledTimes(1);
expect(checkAuthStatusMock).toHaveBeenCalledWith({
anonymousAllowed: undefined,
});
});

// `anonymousAllowed: false` must behave exactly like an unflagged caller, so a
// non-404 route shares the sidebar's cache entry instead of fetching twice.
it('shares the plain cache entry when the flag is off', async () => {
const client = newClient();
const { result } = renderHook(
() => ({
root: useAuthStatus({ anonymousAllowed: false }),
other: useAuthStatus(),
}),
{ wrapper: wrapper(client) },
);

await waitFor(() => expect(result.current.root.isSuccess).toBe(true));
expect(checkAuthStatusMock).toHaveBeenCalledTimes(1);
expect(
client
.getQueryCache()
.findAll()
.map((q) => q.queryKey),
).toEqual([['auth', 'status']]);
});

// The variants must never share a key: whichever observer fetched first would
// otherwise decide the flag for the other (mount-order dependent).
it('keeps the anonymous variant on its own cache key', async () => {
const client = newClient();
const { result } = renderHook(
() => ({
notFound: useAuthStatus({ anonymousAllowed: true }),
other: useAuthStatus(),
}),
{ wrapper: wrapper(client) },
);

await waitFor(() => expect(result.current.notFound.isSuccess).toBe(true));
expect(
client
.getQueryCache()
.findAll()
.map((q) => q.queryKey),
).toEqual(
expect.arrayContaining([
['auth', 'status', 'anonymous'],
['auth', 'status'],
]),
);
expect(checkAuthStatusMock).toHaveBeenCalledWith({
anonymousAllowed: true,
});
});
});
20 changes: 16 additions & 4 deletions web/src/hooks/auth/useServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,23 @@ import toast from 'react-hot-toast';

const authService = new AuthService();

/** Role/org for UI gating (B4). Guards fetch their own copy; this one is for rendering. */
export const useAuthStatus = () => {
/**
* Role/org for UI gating (B4). Guards fetch their own copy; this one is for rendering.
*
* Set `anonymousAllowed` when the caller renders for logged-out visitors, so a 401 resolves
* to ANONYMOUS instead of bouncing the browser to /login. See `checkAuthStatus`.
*/
export const useAuthStatus = ({
anonymousAllowed,
}: { anonymousAllowed?: boolean } = {}) => {
return useQuery<AuthStatus>({
queryKey: ['auth', 'status'],
queryFn: checkAuthStatus,
// Distinct key per variant. Sharing one key would let whichever observer fetches
// first decide the flag for every other caller (mount-order dependent), leaking
// the anonymous no-refresh behaviour onto guarded pages.
queryKey: anonymousAllowed
? ['auth', 'status', 'anonymous']
: ['auth', 'status'],
queryFn: () => checkAuthStatus({ anonymousAllowed }),
staleTime: 60 * 1000,
});
};
Expand Down
8 changes: 6 additions & 2 deletions web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';

import { routeTree } from './routeTree.gen';
import notFoundRoute from './routes/-not-found';

const queryClient = new QueryClient();

const router = createRouter({
routeTree,
defaultNotFoundComponent: notFoundRoute,
// __root.tsx owns the 404: it detects the not-found match itself and renders
// <NotFoundPage /> with the auth-aware shell *instead of* <Outlet />. This
// component only renders where the router substitutes a not-found inside an
// <Outlet /> — a path the root's branch never reaches — so returning null
// keeps the page from rendering a second time without its shell.
defaultNotFoundComponent: () => null,
});

const rootElement = document.getElementById('root')!;
Expand Down
188 changes: 188 additions & 0 deletions web/src/routes/-__root.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { Route } from './__root';

const routerStateMock = vi.fn();
const useAuthStatusMock = vi.fn();

vi.mock('@tanstack/react-router', () => ({
Outlet: () => <div data-testid="outlet" />,
createRootRoute: (opts: any) => ({ options: opts }),
useRouterState: ({ select }: any) => select(routerStateMock()),
Link: ({ children, to, ...rest }: any) => (
<a href={to} {...rest}>
{children}
</a>
),
}));

vi.mock('@tanstack/react-router-devtools', () => ({
TanStackRouterDevtools: () => null,
}));

vi.mock('@/hooks/auth/useServices', () => ({
// Forward args: the not-found branch depends on which variant the root asks for.
useAuthStatus: (options?: unknown) => useAuthStatusMock(options),
}));

vi.mock('@/components/organisms/sidebar', () => ({
default: () => <div data-testid="sidebar" />,
}));

vi.mock('@/components/organisms/landing-nav', () => ({
default: () => <div data-testid="landing-nav" />,
}));

vi.mock('@/contexts/AuthContext', () => ({
useAuth: () => ({ isAuthenticated: false }),
}));

vi.mock('@/contexts/NavigationContext', () => ({
NavigationProvider: ({ children }: any) => <>{children}</>,
}));

vi.mock('./-not-found', () => ({
default: ({ isAuthenticated }: { isAuthenticated: boolean }) => (
<div data-testid="not-found">{isAuthenticated ? 'authed' : 'anon'}</div>
),
}));

const RootComponent = (Route as any).options.component;

// The shape the router actually produces for an unmatched URL: the root match
// stays `success` and carries globalNotFound. A nested `notFound()` throw is the
// `status: 'notFound'` shape instead — both must route to the 404 shell.
const notFoundState = {
matches: [{ status: 'success', globalNotFound: true }],
location: { pathname: '/zzz' },
};

const nestedNotFoundState = {
matches: [{ status: 'success' }, { status: 'notFound' }],
location: { pathname: '/zzz' },
};

describe('RootComponent not-found shell', () => {
beforeEach(() => {
vi.clearAllMocks();
useAuthStatusMock.mockReturnValue({ data: undefined, isLoading: true });
});

it('hides the sidebar for an anonymous visitor', () => {
routerStateMock.mockReturnValue(notFoundState);
useAuthStatusMock.mockReturnValue({
data: { isAuthenticated: false },
isLoading: false,
});
render(<RootComponent />);
expect(screen.getByTestId('not-found')).toHaveTextContent('anon');
expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
});

it('renders the 404 shell for a nested notFound() throw', () => {
routerStateMock.mockReturnValue(nestedNotFoundState);
useAuthStatusMock.mockReturnValue({
data: { isAuthenticated: false },
isLoading: false,
});
render(<RootComponent />);
expect(screen.getByTestId('not-found')).toBeInTheDocument();
expect(screen.queryByTestId('outlet')).not.toBeInTheDocument();
});

// Committing to a variant while auth is unresolved would show an authed user the
// anonymous page, then jump them into the sidebar layout once it settles.
it('renders neither variant while the auth request is in flight', () => {
routerStateMock.mockReturnValue(notFoundState);
useAuthStatusMock.mockReturnValue({ data: undefined, isLoading: true });
render(<RootComponent />);
expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
expect(screen.queryByTestId('not-found')).not.toBeInTheDocument();
expect(screen.getByTestId('not-found-pending')).toHaveAttribute(
'aria-busy',
'true',
);
});

// Offline, the query pauses: `isPending` stays true forever and the fetch never runs,
// so waiting on it would leave a permanently blank page. `isLoading` is false while
// paused, so we fall through and show the anonymous page instead of hanging.
it('shows the anonymous page rather than hanging when the query is paused', () => {
routerStateMock.mockReturnValue(notFoundState);
useAuthStatusMock.mockReturnValue({
data: undefined,
isLoading: false,
isPending: true,
isPaused: true,
});
render(<RootComponent />);
expect(screen.queryByTestId('not-found-pending')).not.toBeInTheDocument();
expect(screen.getByTestId('not-found')).toHaveTextContent('anon');
});

// The root must ask for the anonymous variant ONLY on a 404: elsewhere it shares
// the plain cache key with the sidebar instead of fetching /auth/status twice.
it('requests the anonymous auth variant only on a not-found match', () => {
routerStateMock.mockReturnValue(notFoundState);
useAuthStatusMock.mockReturnValue({
data: { isAuthenticated: false },
isLoading: false,
});
render(<RootComponent />);
expect(useAuthStatusMock).toHaveBeenCalledWith({ anonymousAllowed: true });

vi.clearAllMocks();
routerStateMock.mockReturnValue({
matches: [{ status: 'success' }],
location: { pathname: '/applications' },
});
useAuthStatusMock.mockReturnValue({
data: { isAuthenticated: true },
isLoading: false,
});
render(<RootComponent />);
expect(useAuthStatusMock).toHaveBeenCalledWith({ anonymousAllowed: false });
});

it('shows the sidebar for an authed user', () => {
routerStateMock.mockReturnValue(notFoundState);
useAuthStatusMock.mockReturnValue({
data: { isAuthenticated: true },
isLoading: false,
});
render(<RootComponent />);
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
expect(screen.getByTestId('not-found')).toHaveTextContent('authed');
});

it('leaves a matched app route on the normal sidebar shell', () => {
routerStateMock.mockReturnValue({
matches: [{ status: 'success' }],
location: { pathname: '/applications' },
});
useAuthStatusMock.mockReturnValue({
data: { isAuthenticated: true },
isLoading: false,
});
render(<RootComponent />);
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
expect(screen.getByTestId('outlet')).toBeInTheDocument();
expect(screen.queryByTestId('not-found')).not.toBeInTheDocument();
});

it('leaves a matched public route on the public shell', () => {
routerStateMock.mockReturnValue({
matches: [{ status: 'success' }],
location: { pathname: '/login' },
});
useAuthStatusMock.mockReturnValue({
data: { isAuthenticated: false },
isLoading: false,
});
render(<RootComponent />);
expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
expect(screen.getByTestId('outlet')).toBeInTheDocument();
expect(screen.queryByTestId('not-found')).not.toBeInTheDocument();
});
});
Loading
Loading