/** * Component: Login Page Tests * Documentation: documentation/frontend/pages/login.md */ // @vitest-environment jsdom import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { resetMockRouter, routerMock, setMockSearchParams } from '../helpers/mock-next-navigation'; import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth'; const makeJsonResponse = (body: any, ok: boolean = true) => ({ ok, status: ok ? 200 : 500, json: async () => body, }); const baseProviders = { backendMode: 'plex', providers: ['plex'], registrationEnabled: false, hasLocalUsers: false, oidcProviderName: null, localLoginDisabled: false, automationEnabled: false, }; describe('LoginPage', () => { beforeEach(() => { resetMockRouter(); resetMockAuthState(); localStorage.clear(); document.cookie.split(';').forEach((cookie) => { const name = cookie.split('=')[0]?.trim(); if (name) { document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; } }); setMockSearchParams(''); window.innerWidth = 1024; vi.resetModules(); }); afterEach(() => { vi.unstubAllGlobals(); }); it('renders description based on backend mode and automation flag', async () => { const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') { return makeJsonResponse({ ...baseProviders, backendMode: 'audiobookshelf', automationEnabled: true, }); } if (url === '/api/audiobooks/covers') { return makeJsonResponse({ success: true, covers: [] }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); expect( await screen.findByText( "Request audiobooks and they'll automatically download and appear in your Audiobookshelf library" ) ).toBeInTheDocument(); }); it('redirects to intended page when user is already logged in', async () => { setMockAuthState({ user: { id: 'user-1', plexId: 'plex-1', username: 'user', role: 'user' }, isLoading: false, }); setMockSearchParams('redirect=/requests'); const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); await waitFor(() => { expect(routerMock.push).toHaveBeenCalledWith('/requests'); }); }); it('handles Plex login with popup flow', async () => { const loginMock = vi.fn().mockResolvedValue(undefined); setMockAuthState({ login: loginMock, isLoading: false }); const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); if (url === '/api/auth/plex/login') { return makeJsonResponse({ pinId: 123, authUrl: 'http://plex/auth' }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const closeMock = vi.fn(); const openMock = vi.fn().mockReturnValue({ close: closeMock }); vi.stubGlobal('open', openMock); const { default: LoginPage } = await import('@/app/login/page'); render(); const loginButton = await screen.findByRole('button', { name: 'Login with Plex' }); fireEvent.click(loginButton); await waitFor(() => { expect(loginMock).toHaveBeenCalledWith(123); expect(routerMock.push).toHaveBeenCalledWith('/'); }); expect(openMock).toHaveBeenCalledWith( 'http://plex/auth', 'plex-auth', 'width=600,height=700,scrollbars=yes,resizable=yes' ); expect(closeMock).toHaveBeenCalled(); }); it('shows an error when Plex login popup is blocked', async () => { const loginMock = vi.fn().mockResolvedValue(undefined); setMockAuthState({ login: loginMock, isLoading: false }); const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); if (url === '/api/auth/plex/login') { return makeJsonResponse({ pinId: 456, authUrl: 'http://plex/auth' }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const { default: LoginPage } = await import('@/app/login/page'); render(); const loginButton = await screen.findByRole('button', { name: 'Login with Plex' }); fireEvent.click(loginButton); expect(await screen.findByText(/Popup was blocked/i)).toBeInTheDocument(); expect(loginMock).not.toHaveBeenCalled(); }); it('logs in with local credentials and stores tokens', async () => { const setAuthDataMock = vi.fn(); setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); const providers = { ...baseProviders, providers: ['local'], }; const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(providers); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); if (url === '/api/auth/local/login') { return makeJsonResponse({ accessToken: 'access-token', refreshToken: 'refresh-token', user: { id: 'user-1', username: 'local-user', role: 'admin' }, }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); const username = await screen.findByLabelText('Username'); const password = screen.getByLabelText('Password'); fireEvent.change(username, { target: { value: 'admin' } }); fireEvent.change(password, { target: { value: 'secret' } }); fireEvent.click(screen.getByRole('button', { name: 'Login' })); await waitFor(() => { expect(setAuthDataMock).toHaveBeenCalledWith( { id: 'user-1', username: 'local-user', role: 'admin' }, 'access-token' ); expect(routerMock.push).toHaveBeenCalledWith('/'); }); expect(localStorage.getItem('accessToken')).toBe('access-token'); expect(localStorage.getItem('refreshToken')).toBe('refresh-token'); }); it('validates registration passwords before sending request', async () => { const providers = { ...baseProviders, providers: ['local'], registrationEnabled: true, }; const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(providers); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); const registerToggle = await screen.findByRole('button', { name: /Don't have an account\? Register/i }); fireEvent.click(registerToggle); fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } }); const passwordInputs = screen.getAllByLabelText('Password'); fireEvent.change(passwordInputs[0], { target: { value: 'password1' } }); fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password2' } }); fireEvent.click(screen.getByRole('button', { name: 'Register' })); expect(await screen.findByText('Passwords do not match')).toBeInTheDocument(); }); it('renders an OIDC login button and redirects to the provider', async () => { const providers = { ...baseProviders, providers: ['oidc'], oidcProviderName: 'Auth0', }; const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(providers); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); expect(await screen.findByRole('button', { name: 'Login with Auth0' })).toBeInTheDocument(); expect( screen.getByText("You'll be redirected to Auth0 to authenticate") ).toBeInTheDocument(); }); it('logs in via admin credentials when Plex mode exposes admin login', async () => { const setAuthDataMock = vi.fn(); setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); if (url === '/api/auth/admin/login') { return makeJsonResponse({ accessToken: 'admin-access', refreshToken: 'admin-refresh', user: { id: 'admin-1', username: 'admin', role: 'admin' }, }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); const toggleButton = await screen.findByRole('button', { name: 'Admin Login' }); fireEvent.click(toggleButton); fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } }); fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } }); fireEvent.click(screen.getByRole('button', { name: 'Login as Admin' })); await waitFor(() => { expect(setAuthDataMock).toHaveBeenCalledWith( { id: 'admin-1', username: 'admin', role: 'admin' }, 'admin-access' ); expect(routerMock.push).toHaveBeenCalledWith('/'); }); expect(localStorage.getItem('accessToken')).toBe('admin-access'); expect(localStorage.getItem('refreshToken')).toBe('admin-refresh'); }); it('renders book cover images when the covers API returns data', async () => { window.innerWidth = 500; const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); if (url === '/api/audiobooks/covers') { return makeJsonResponse({ success: true, covers: [ { asin: 'asin-1', title: 'Book One', author: 'Author', coverUrl: '/cover.jpg', }, ], }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); expect(await screen.findByAltText('Book One')).toBeInTheDocument(); }); it('shows pending approval alert when admin login returns pending status', async () => { const setAuthDataMock = vi.fn(); setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); if (url === '/api/auth/admin/login') { return makeJsonResponse({ pendingApproval: true }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); fireEvent.click(await screen.findByRole('button', { name: 'Admin Login' })); fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } }); fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } }); fireEvent.click(screen.getByRole('button', { name: 'Login as Admin' })); expect(await screen.findByText('Account Pending Approval')).toBeInTheDocument(); expect(setAuthDataMock).not.toHaveBeenCalled(); }); it('shows registration pending alert when registration needs approval', async () => { const providers = { ...baseProviders, providers: ['local'], registrationEnabled: true, }; const setAuthDataMock = vi.fn(); setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(providers); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); if (url === '/api/auth/register') { return makeJsonResponse({ pendingApproval: true }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); fireEvent.click( await screen.findByRole('button', { name: /Don't have an account\? Register/i }) ); fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } }); fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password1' } }); fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password1' } }); fireEvent.click(screen.getByRole('button', { name: 'Register' })); expect(await screen.findByText('Registration Pending')).toBeInTheDocument(); expect(setAuthDataMock).not.toHaveBeenCalled(); }); it('auto-logs in after successful registration', async () => { const providers = { ...baseProviders, providers: ['local'], registrationEnabled: true, }; const setAuthDataMock = vi.fn(); setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(providers); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); if (url === '/api/auth/register') { return makeJsonResponse({ success: true, accessToken: 'reg-access', refreshToken: 'reg-refresh', user: { id: 'user-3', username: 'new-user', role: 'user' }, }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); fireEvent.click( await screen.findByRole('button', { name: /Don't have an account\? Register/i }) ); fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } }); fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password1' } }); fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password1' } }); fireEvent.click(screen.getByRole('button', { name: 'Register' })); await waitFor(() => { expect(setAuthDataMock).toHaveBeenCalledWith( { id: 'user-3', username: 'new-user', role: 'user' }, 'reg-access' ); expect(routerMock.push).toHaveBeenCalledWith('/'); }); expect(localStorage.getItem('accessToken')).toBe('reg-access'); expect(localStorage.getItem('refreshToken')).toBe('reg-refresh'); }); it('falls back to Plex mode when providers fetch fails', async () => { const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined); const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') { throw new Error('providers down'); } if (url === '/api/audiobooks/covers') { throw new Error('covers down'); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); expect(await screen.findByRole('button', { name: 'Login with Plex' })).toBeInTheDocument(); expect(errorMock).toHaveBeenCalledWith('Failed to fetch auth providers:', expect.any(Error)); expect(errorMock).toHaveBeenCalledWith('Failed to fetch book covers:', expect.any(Error)); }); it('processes mobile auth data from URL hash', async () => { const setAuthDataMock = vi.fn(); setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); setMockSearchParams('auth=success&redirect=/requests'); const authData = { accessToken: 'mobile-access', refreshToken: 'mobile-refresh', user: { id: 'user-9', username: 'mobile-user', role: 'user' }, }; window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`; const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); await waitFor(() => { expect(setAuthDataMock).toHaveBeenCalledWith(authData.user, authData.accessToken); expect(routerMock.push).toHaveBeenCalledWith('/requests'); }); expect(localStorage.getItem('accessToken')).toBe('mobile-access'); expect(localStorage.getItem('refreshToken')).toBe('mobile-refresh'); expect(window.location.hash).toBe(''); }); it('shows error message from query string', async () => { setMockSearchParams('error=Access%20Denied'); const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); expect(await screen.findByText('Access Denied')).toBeInTheDocument(); }); it('falls back to cookies when mobile auth has no hash', async () => { const setAuthDataMock = vi.fn(); setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); setMockSearchParams('auth=success&redirect=/requests'); const userData = { id: 'user-10', username: 'cookie-user', role: 'user' }; document.cookie = 'accessToken=cookie-access'; document.cookie = 'refreshToken=cookie-refresh'; document.cookie = `userData=${encodeURIComponent(JSON.stringify(userData))}`; const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); await waitFor(() => { expect(setAuthDataMock).toHaveBeenCalledWith(userData, 'cookie-access'); expect(routerMock.push).toHaveBeenCalledWith('/requests'); }); expect(localStorage.getItem('accessToken')).toBe('cookie-access'); expect(localStorage.getItem('refreshToken')).toBe('cookie-refresh'); }); it('shows an error when cookie auth payload is invalid', async () => { const setAuthDataMock = vi.fn(); setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); setMockSearchParams('auth=success'); document.cookie = 'accessToken=cookie-access'; document.cookie = 'userData=not-json'; const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); expect(await screen.findByText('Login failed. Please try again.')).toBeInTheDocument(); expect(setAuthDataMock).not.toHaveBeenCalled(); }); it('shows an error when cookie auth data is missing', async () => { setMockSearchParams('auth=success'); const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const { default: LoginPage } = await import('@/app/login/page'); render(); expect(await screen.findByText('Authentication failed. Please try again.')).toBeInTheDocument(); }); it('redirects to Plex OAuth on mobile without opening a popup', async () => { window.innerWidth = 500; const loginMock = vi.fn().mockResolvedValue(undefined); setMockAuthState({ login: loginMock, isLoading: false }); const fetchMock = vi.fn(async (input: RequestInfo) => { const url = typeof input === 'string' ? input : input.url; if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); if (url === '/api/auth/plex/login') { return makeJsonResponse({ pinId: 321, authUrl: 'http://plex/mobile' }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const openMock = vi.fn(); vi.stubGlobal('open', openMock); const originalLocation = window.location; delete (window as any).location; (window as any).location = { ...originalLocation, href: 'http://localhost/login', hash: '', pathname: '/login', search: '', }; const { default: LoginPage } = await import('@/app/login/page'); render(); const loginButton = await screen.findByRole('button', { name: 'Login with Plex' }); fireEvent.click(loginButton); await waitFor(() => { expect(openMock).not.toHaveBeenCalled(); expect(loginMock).not.toHaveBeenCalled(); expect(window.location.href).toBe('http://plex/mobile'); }); (window as any).location = originalLocation; }); });