import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { AuthenticationContextValue, SessionContextValue } from '@rocket.chat/ui-contexts';
import { AuthenticationContext, SessionContext, UserContext } from '@rocket.chat/ui-contexts';
import { act, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { useContext } from 'react';
import AuthenticationCheck from './AuthenticationCheck';
import { STORAGE_KEYS, removeStoredItem } from '../../../lib/sdk/storage';
// The chain below this gate reaches for a great deal of app; what this spec is about is which of the three
// outcomes the gate picks, so stand the rest of it down.
jest.mock('./LoggedInArea', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <>{children}> }));
jest.mock('./UsernameCheck', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <>{children}> }));
jest.mock('./LoginPage', () => ({ __esModule: true, default: () =>
login-page
}));
jest.mock('../../home/HomeSkeleton', () => ({ __esModule: true, default: () => home-skeleton
}));
const renderGate = (root: ReturnType) =>
render(conference, { wrapper: root.build() });
afterEach(() => {
localStorage.clear();
});
it('renders the route for a user who is logged in', () => {
renderGate(mockAppRoot().withJohnDoe());
expect(screen.getByText('conference')).toBeInTheDocument();
});
it('shows the login page to a visitor with no session', () => {
renderGate(mockAppRoot().withAnonymous());
expect(screen.getByText('login-page')).toBeInTheDocument();
});
// The bug this guards: a window that opens with a session already stored — a call popout, or any plain reload —
// has no user until the login is resumed from that token, and showed a login form in the meantime.
describe('while the session is being resumed', () => {
it('shows the skeleton instead of the login page when a stored token is about to be used', () => {
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token');
renderGate(mockAppRoot().withAnonymous());
expect(screen.queryByText('login-page')).not.toBeInTheDocument();
expect(screen.getByText('home-skeleton')).toBeInTheDocument();
});
// The stored token is read through a subscription, not per render, and this is why: a rejected resume clears
// the credentials without anything else about the gate changing — no user arrives, the status stays healthy —
// so a per-render read would sit on the stale token with no next render to correct it.
it('falls through to the login page when the stored token is cleared under it', () => {
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-token-the-server-rejects');
renderGate(mockAppRoot().withAnonymous());
expect(screen.getByText('home-skeleton')).toBeInTheDocument();
act(() => {
removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
});
expect(screen.getByText('login-page')).toBeInTheDocument();
expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument();
});
// The one way a stored token could strand someone: it is cleared when a server *rejects* it, so a server that
// never answers at all clears nothing. Once the connection stops trying, the form has to be reachable.
it.each(['waiting', 'failed'] as const)('shows the login page when the connection has given up (%s)', (status) => {
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token');
renderGate(mockAppRoot().withAnonymous().withServerContext({ connected: false, status }));
expect(screen.getByText('login-page')).toBeInTheDocument();
expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument();
});
// `offline` is not a give-up state, however much it reads like one: the DDP SDK starts every page load `idle`
// and that is reported as `offline`, so counting it would show the form on healthy reloads — the very bug.
it('keeps the skeleton up while the connection is merely idle', () => {
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token');
renderGate(mockAppRoot().withAnonymous().withServerContext({ connected: false, status: 'offline' }));
expect(screen.getByText('home-skeleton')).toBeInTheDocument();
expect(screen.queryByText('login-page')).not.toBeInTheDocument();
});
// ...but not while it is still the ordinary first connect of a page load, which is the flash this removes.
it('keeps the skeleton up while the connection is still being made', () => {
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token');
renderGate(mockAppRoot().withAnonymous().withServerContext({ connected: false, status: 'connecting' }));
expect(screen.getByText('home-skeleton')).toBeInTheDocument();
expect(screen.queryByText('login-page')).not.toBeInTheDocument();
});
// Someone who was logged out, or whose session the server rejected, must reach the form — otherwise a stale
// token in storage would leave them staring at a skeleton forever. The mocked app root has no session
// support, so this one supplies the context itself.
it('shows the login page when a login is being forced', () => {
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stale-token');
const forcingLogin = {
query: () => [() => () => undefined, () => true] as ReturnType,
dispatch: () => undefined,
};
renderGate(
mockAppRoot()
.withAnonymous()
.wrap((children) => {children}),
);
expect(screen.getByText('login-page')).toBeInTheDocument();
});
});
// The regression that came with the guard above, and the reason it asks only about the stored token: `isLoggingIn`
// is true of *any* login in flight, a person typing their password at the form included. Swapping the form for a
// skeleton mid-attempt lost the rejection — the form came back blank, with neither field marked invalid — and
// iframe login never appeared at all, since the flow that fetches its URL runs from inside `LoginPage`.
it('keeps the login page up while someone is logging in at it', () => {
const loggingIn = { isLoggingIn: true } as AuthenticationContextValue;
renderGate(
mockAppRoot()
.withAnonymous()
.wrap((children) => {children}),
);
expect(screen.getByText('login-page')).toBeInTheDocument();
expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument();
});
// Overrides whatever user the mocked root supplies, without changing the shape of the tree — the gate has to keep
// the same instance across the switch for the test to be about anything.
const MaybeSessionEnded = ({ ended, children }: { ended: boolean; children: ReactNode }) => {
const value = useContext(UserContext);
return {children};
};
// Deleting your own account ends the session server-side but clears nothing locally: the stored token stays put,
// and reading it alone the gate answered "a resume is in flight" and held the skeleton up for good. A user that
// has already been seen going away is an ended session, whatever storage still says.
it('shows the login page when the user goes away with a token still stored', () => {
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-token-nobody-cleared');
const Root = mockAppRoot().withJohnDoe().build();
const { rerender } = render(
conference
,
);
expect(screen.getByText('conference')).toBeInTheDocument();
rerender(
conference
,
);
expect(screen.getByText('login-page')).toBeInTheDocument();
expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument();
});