Files
2026-05-04 13:41:53 -04:00

318 lines
9.9 KiB
TypeScript

/**
* Component: Authentication Context Tests
* Documentation: documentation/backend/services/auth.md
*/
// @vitest-environment jsdom
import React from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
const isTokenExpiredMock = vi.hoisted(() => vi.fn());
const getRefreshTimeMsMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/utils/jwt-client', () => ({
isTokenExpired: isTokenExpiredMock,
getRefreshTimeMs: getRefreshTimeMsMock,
}));
function TestConsumer() {
const { user, accessToken, isLoading, login, logout, refreshToken, setAuthData } = useAuth();
const [loginResult, setLoginResult] = React.useState('none');
return (
<div>
<div data-testid="loading">{String(isLoading)}</div>
<div data-testid="user">{user?.username ?? 'none'}</div>
<div data-testid="token">{accessToken ?? 'none'}</div>
<div data-testid="login-result">{loginResult}</div>
<button type="button" onClick={() => void login(123).then(setLoginResult)}>
login
</button>
<button type="button" onClick={logout}>
logout
</button>
<button type="button" onClick={() => void refreshToken()}>
refresh
</button>
<button
type="button"
onClick={() => setAuthData({ id: 'user-99', plexId: 'plex-99', username: 'set-user', role: 'user' }, 'set-token')}
>
setAuth
</button>
</div>
);
}
function renderAuthProvider() {
return render(
<AuthProvider>
<TestConsumer />
</AuthProvider>
);
}
describe('AuthProvider', () => {
let locationStub: { href: string; pathname: string };
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
sessionStorage.clear();
isTokenExpiredMock.mockReturnValue(false);
getRefreshTimeMsMock.mockReturnValue(300_000);
locationStub = { href: 'http://localhost/', pathname: '/' };
vi.stubGlobal('location', locationStub);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('restores session and refreshes user details with a valid token', async () => {
const storedUser = {
id: 'user-1',
plexId: 'plex-1',
username: 'old-user',
role: 'user',
};
localStorage.setItem('accessToken', 'access-token');
localStorage.setItem('user', JSON.stringify(storedUser));
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ user: { ...storedUser, username: 'fresh-user' } }),
});
vi.stubGlobal('fetch', fetchMock);
renderAuthProvider();
await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('fresh-user'));
expect(screen.getByTestId('token')).toHaveTextContent('access-token');
expect(screen.getByTestId('loading')).toHaveTextContent('false');
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/me',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer access-token',
}),
})
);
const storedUserJson = JSON.parse(localStorage.getItem('user') ?? '{}') as { username?: string };
expect(storedUserJson.username).toBe('fresh-user');
});
it('refreshes the access token on mount when the access token is expired', async () => {
isTokenExpiredMock.mockImplementation((token: string) => token.startsWith('expired'));
localStorage.setItem('accessToken', 'expired-access');
localStorage.setItem('refreshToken', 'refresh-token');
localStorage.setItem('user', JSON.stringify({ id: 'user-2' }));
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ accessToken: 'new-access' }),
});
vi.stubGlobal('fetch', fetchMock);
renderAuthProvider();
await waitFor(() => expect(screen.getByTestId('token')).toHaveTextContent('new-access'));
expect(screen.getByTestId('loading')).toHaveTextContent('false');
expect(localStorage.getItem('accessToken')).toBe('new-access');
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/refresh',
expect.objectContaining({
method: 'POST',
})
);
});
it('clears stored auth data when both tokens are expired', async () => {
isTokenExpiredMock.mockImplementation((token: string) => token.startsWith('expired'));
localStorage.setItem('accessToken', 'expired-access');
localStorage.setItem('refreshToken', 'expired-refresh');
localStorage.setItem('user', JSON.stringify({ id: 'user-3' }));
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
renderAuthProvider();
await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false'));
expect(localStorage.getItem('accessToken')).toBeNull();
expect(localStorage.getItem('refreshToken')).toBeNull();
expect(localStorage.getItem('user')).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('stores tokens and user data after a successful login', async () => {
const loginUser = {
id: 'user-4',
plexId: 'plex-4',
username: 'plex-user',
role: 'user',
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
success: true,
authorized: true,
accessToken: 'login-access',
refreshToken: 'login-refresh',
user: loginUser,
}),
});
vi.stubGlobal('fetch', fetchMock);
renderAuthProvider();
fireEvent.click(screen.getByRole('button', { name: 'login' }));
await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('plex-user'));
expect(screen.getByTestId('token')).toHaveTextContent('login-access');
expect(localStorage.getItem('accessToken')).toBe('login-access');
expect(localStorage.getItem('refreshToken')).toBe('login-refresh');
expect(screen.getByTestId('login-result')).toHaveTextContent('authenticated');
});
it('returns profile selection result without storing auth data for Plex Home users', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
success: true,
authorized: true,
requiresProfileSelection: true,
redirectUrl: '/auth/select-profile?pinId=123',
}),
});
vi.stubGlobal('fetch', fetchMock);
renderAuthProvider();
fireEvent.click(screen.getByRole('button', { name: 'login' }));
await waitFor(() => expect(screen.getByTestId('login-result')).toHaveTextContent('profile-selection-required'));
expect(locationStub.href).toBe('/auth/select-profile?pinId=123');
expect(screen.getByTestId('user')).toHaveTextContent('none');
expect(screen.getByTestId('token')).toHaveTextContent('none');
expect(localStorage.getItem('accessToken')).toBeNull();
expect(localStorage.getItem('refreshToken')).toBeNull();
});
it('logs out by clearing storage and redirecting to the login page', () => {
localStorage.setItem('accessToken', 'access-token');
localStorage.setItem('refreshToken', 'refresh-token');
localStorage.setItem('user', JSON.stringify({ id: 'user-5' }));
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
vi.stubGlobal('fetch', fetchMock);
renderAuthProvider();
fireEvent.click(screen.getByRole('button', { name: 'logout' }));
expect(localStorage.getItem('accessToken')).toBeNull();
expect(localStorage.getItem('refreshToken')).toBeNull();
expect(localStorage.getItem('user')).toBeNull();
expect(fetchMock).toHaveBeenCalledWith('/api/auth/logout', { method: 'POST' });
expect(locationStub.href).toContain('/login');
});
it('throws when useAuth is used outside the provider', () => {
function BrokenConsumer() {
useAuth();
return null;
}
expect(() => render(<BrokenConsumer />)).toThrow('useAuth must be used within an AuthProvider');
});
it('sets auth data directly and updates state', async () => {
renderAuthProvider();
fireEvent.click(screen.getByRole('button', { name: 'setAuth' }));
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('set-user');
});
expect(screen.getByTestId('token')).toHaveTextContent('set-token');
});
it('refreshes token when refreshToken is called', async () => {
localStorage.setItem('refreshToken', 'refresh-token');
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ accessToken: 'refreshed-token' }),
});
vi.stubGlobal('fetch', fetchMock);
renderAuthProvider();
fireEvent.click(screen.getByRole('button', { name: 'refresh' }));
await waitFor(() => {
expect(screen.getByTestId('token')).toHaveTextContent('refreshed-token');
});
expect(localStorage.getItem('accessToken')).toBe('refreshed-token');
});
it('logs out when access token is removed in another tab', async () => {
renderAuthProvider();
fireEvent.click(screen.getByRole('button', { name: 'setAuth' }));
await waitFor(() => {
expect(screen.getByTestId('token')).toHaveTextContent('set-token');
});
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: 'accessToken', newValue: null }));
});
await waitFor(() => {
expect(screen.getByTestId('token')).toHaveTextContent('none');
});
expect(locationStub.href).toContain('/login');
});
it('syncs auth data when access token is added in another tab', async () => {
localStorage.setItem('user', JSON.stringify({ id: 'user-sync', plexId: 'plex-sync', username: 'synced', role: 'user' }));
renderAuthProvider();
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: 'accessToken', newValue: 'synced-token' }));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('synced');
});
expect(screen.getByTestId('token')).toHaveTextContent('synced-token');
});
});