mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
318 lines
9.9 KiB
TypeScript
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');
|
|
});
|
|
});
|