diff --git a/web/src/lib/components/AdminSidebar.svelte b/web/src/lib/components/AdminSidebar.svelte new file mode 100644 index 00000000..8097f1e9 --- /dev/null +++ b/web/src/lib/components/AdminSidebar.svelte @@ -0,0 +1,68 @@ + + + + + diff --git a/web/src/lib/components/AdminSidebar.test.ts b/web/src/lib/components/AdminSidebar.test.ts new file mode 100644 index 00000000..792913e7 --- /dev/null +++ b/web/src/lib/components/AdminSidebar.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; + +const state = vi.hoisted(() => ({ + pageUrl: new URL('http://localhost/admin') +})); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +import AdminSidebar from './AdminSidebar.svelte'; + +describe('AdminSidebar', () => { + test('Overview link is active when on /admin', () => { + state.pageUrl = new URL('http://localhost/admin'); + render(AdminSidebar); + const overview = screen.getByRole('link', { name: /overview/i }); + expect(overview).toHaveAttribute('aria-current', 'page'); + }); + + test('Integrations link is active when on /admin/integrations', () => { + state.pageUrl = new URL('http://localhost/admin/integrations'); + render(AdminSidebar); + expect(screen.getByRole('link', { name: /integrations/i })).toHaveAttribute( + 'aria-current', + 'page' + ); + expect(screen.getByRole('link', { name: /overview/i })).not.toHaveAttribute('aria-current'); + }); + + test('Requests link is active when on /admin/requests', () => { + state.pageUrl = new URL('http://localhost/admin/requests'); + render(AdminSidebar); + expect(screen.getByRole('link', { name: /requests/i })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); + + test('placeholder items render as non-links with aria-disabled', () => { + state.pageUrl = new URL('http://localhost/admin'); + render(AdminSidebar); + expect(screen.queryByRole('link', { name: /quarantine/i })).not.toBeInTheDocument(); + const quar = screen.getByText(/quarantine/i); + expect(quar.closest('[aria-disabled="true"]')).toBeInTheDocument(); + // Users + Library are also placeholders today. + expect(screen.queryByRole('link', { name: /^users$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /^library$/i })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 9c83f655..9fc18724 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -30,6 +30,13 @@ { href: '/playlists', label: 'Playlists' }, { href: '/settings', label: 'Settings' } ]; + + // Admin link sits between Playlists and Settings, only visible to admins. + const visibleNavItems = $derived( + user.value?.is_admin + ? [...navItems.slice(0, -1), { href: '/admin', label: 'Admin' }, navItems[navItems.length - 1]] + : navItems + ); (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> @@ -70,7 +77,7 @@ - {#each navItems as item} + {#each visibleNavItems as item} ({ goto: vi.fn() })); +// Mutable handle so individual tests can flip the user between admin / +// non-admin without re-importing the module. +const userState = vi.hoisted(() => ({ + current: { id: '1', username: 'alice', is_admin: false } as { + id: string; + username: string; + is_admin: boolean; + } | null +})); + vi.mock('$lib/auth/store.svelte', () => ({ - user: { value: { id: '1', username: 'alice', is_admin: false } }, + user: { get value() { return userState.current; } }, logout: vi.fn().mockResolvedValue(undefined) })); @@ -20,6 +30,7 @@ import { goto } from '$app/navigation'; afterEach(() => { vi.clearAllMocks(); + userState.current = { id: '1', username: 'alice', is_admin: false }; }); describe('Shell', () => { @@ -37,6 +48,29 @@ describe('Shell', () => { expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); }); + test('non-admin users do not see the Admin nav link', () => { + userState.current = { id: '1', username: 'alice', is_admin: false }; + render(Shell); + expect(screen.queryByRole('link', { name: 'Admin' })).not.toBeInTheDocument(); + }); + + test('admin users see the Admin nav link between Playlists and Settings', () => { + userState.current = { id: '1', username: 'alice', is_admin: true }; + render(Shell); + const link = screen.getByRole('link', { name: 'Admin' }); + expect(link).toHaveAttribute('href', '/admin'); + // Order check: Playlists then Admin then Settings. + const labels = screen + .getAllByRole('link') + .map((el) => el.textContent?.trim()) + .filter(Boolean); + const idxPlaylists = labels.indexOf('Playlists'); + const idxAdmin = labels.indexOf('Admin'); + const idxSettings = labels.indexOf('Settings'); + expect(idxPlaylists).toBeLessThan(idxAdmin); + expect(idxAdmin).toBeLessThan(idxSettings); + }); + test('user-menu "Log out" calls logout() and navigates to /login', async () => { render(Shell); await fireEvent.click(screen.getByRole('button', { name: /alice/i })); diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte new file mode 100644 index 00000000..072dd601 --- /dev/null +++ b/web/src/routes/admin/+layout.svelte @@ -0,0 +1,19 @@ + + + + + + Admin + + + + + {@render children?.()} + + + diff --git a/web/src/routes/admin/+layout.ts b/web/src/routes/admin/+layout.ts new file mode 100644 index 00000000..e3f246f6 --- /dev/null +++ b/web/src/routes/admin/+layout.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit'; +import { user } from '$lib/auth/store.svelte'; +import type { LayoutLoad } from './$types'; + +// Hard route gate: runs before the layout (and any child page) renders. +// Auth is guaranteed bootstrapped by the root +layout.ts, so user.value is +// either a settled User object or null. +export const load: LayoutLoad = () => { + if (!user.value || !user.value.is_admin) { + throw redirect(302, '/'); + } + return {}; +}; diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte new file mode 100644 index 00000000..40f1c6d6 --- /dev/null +++ b/web/src/routes/admin/+page.svelte @@ -0,0 +1,37 @@ + + + + + Overview + + + + + Pending requests + {pendingCount} + + + Lidarr + + {lidarrConnected ? 'Connected' : 'Unset'} + + + + diff --git a/web/src/routes/admin/admin.test.ts b/web/src/routes/admin/admin.test.ts new file mode 100644 index 00000000..e1221f9b --- /dev/null +++ b/web/src/routes/admin/admin.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; + +// Mutable mock-user handle so individual tests can flip is_admin / null +// without re-importing the module under test. +const userState = vi.hoisted(() => ({ + current: null as { id: string; username: string; is_admin: boolean } | null +})); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { get value() { return userState.current; } } +})); + +vi.mock('$app/state', () => ({ + page: { url: new URL('http://localhost/admin') } +})); + +vi.mock('$lib/api/admin', () => ({ + createAdminRequestsQuery: vi.fn(), + createLidarrConfigQuery: vi.fn() +})); + +afterEach(() => { + vi.clearAllMocks(); + userState.current = null; +}); + +describe('admin/+layout.ts load gate', () => { + test('non-admin user throws redirect to /', async () => { + userState.current = { id: 'u1', username: 'alice', is_admin: false }; + const { load } = await import('./+layout'); + await expect(async () => + // The SvelteKit `load` signature wants a LoadEvent; we don't read any of + // it inside the function, so an empty cast is sufficient for this gate. + load({} as Parameters[0]) + ).rejects.toMatchObject({ status: 302, location: '/' }); + }); + + test('unauthenticated (null user) throws redirect to /', async () => { + userState.current = null; + const { load } = await import('./+layout'); + await expect(async () => + load({} as Parameters[0]) + ).rejects.toMatchObject({ status: 302, location: '/' }); + }); + + test('admin user passes the gate', async () => { + userState.current = { id: 'u1', username: 'root', is_admin: true }; + const { load } = await import('./+layout'); + // load() is synchronous in the happy path; it only throws on the gate. + expect(load({} as Parameters[0])).toEqual({}); + }); +}); + +describe('admin Overview page', () => { + test('renders pending requests count and Lidarr status (connected)', async () => { + const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin'); + (createAdminRequestsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [{ id: '1' }, { id: '2' }] }) + ); + (createLidarrConfigQuery as ReturnType).mockReturnValue( + mockQuery({ data: { enabled: true } }) + ); + const { default: OverviewPage } = await import('./+page.svelte'); + render(OverviewPage); + expect(screen.getByText('Pending requests')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('Lidarr')).toBeInTheDocument(); + expect(screen.getByText(/connected/i)).toBeInTheDocument(); + }); + + test('shows zero pending and "Unset" when nothing is configured', async () => { + const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin'); + (createAdminRequestsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [] }) + ); + (createLidarrConfigQuery as ReturnType).mockReturnValue( + mockQuery({ data: { enabled: false } }) + ); + const { default: OverviewPage } = await import('./+page.svelte'); + render(OverviewPage); + expect(screen.getByText('0')).toBeInTheDocument(); + expect(screen.getByText(/unset/i)).toBeInTheDocument(); + }); + + test('Overview cards link to their sub-pages', async () => { + const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin'); + (createAdminRequestsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [] }) + ); + (createLidarrConfigQuery as ReturnType).mockReturnValue( + mockQuery({ data: { enabled: false } }) + ); + const { default: OverviewPage } = await import('./+page.svelte'); + render(OverviewPage); + const reqLink = screen.getByRole('link', { name: /pending requests/i }); + expect(reqLink).toHaveAttribute('href', '/admin/requests'); + const lidarrLink = screen.getByRole('link', { name: /lidarr/i }); + expect(lidarrLink).toHaveAttribute('href', '/admin/integrations'); + }); +});