Files
upstream-rocketchat/apps/meteor/client/views/root/MainLayout/AuthenticationCheck.spec.tsx
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

174 lines
7.9 KiB
TypeScript

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: () => <div>login-page</div> }));
jest.mock('../../home/HomeSkeleton', () => ({ __esModule: true, default: () => <div>home-skeleton</div> }));
const renderGate = (root: ReturnType<typeof mockAppRoot>) =>
render(<AuthenticationCheck>conference</AuthenticationCheck>, { 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<SessionContextValue['query']>,
dispatch: () => undefined,
};
renderGate(
mockAppRoot()
.withAnonymous()
.wrap((children) => <SessionContext.Provider value={forcingLogin}>{children}</SessionContext.Provider>),
);
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) => <AuthenticationContext.Provider value={loggingIn}>{children}</AuthenticationContext.Provider>),
);
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 <UserContext.Provider value={ended ? { ...value, userId: undefined, user: null } : value}>{children}</UserContext.Provider>;
};
// 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(
<Root>
<MaybeSessionEnded ended={false}>
<AuthenticationCheck>conference</AuthenticationCheck>
</MaybeSessionEnded>
</Root>,
);
expect(screen.getByText('conference')).toBeInTheDocument();
rerender(
<Root>
<MaybeSessionEnded ended={true}>
<AuthenticationCheck>conference</AuthenticationCheck>
</MaybeSessionEnded>
</Root>,
);
expect(screen.getByText('login-page')).toBeInTheDocument();
expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument();
});