/** * Tests for the Layout component. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import { render } from '../utils'; import { Layout } from '../../components/Layout'; import { getAuthToken, setAuthToken } from '../../api/client'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout'; describe('Layout', () => { beforeEach(() => { vi.mocked(localStorage.getItem).mockReset(); vi.mocked(localStorage.setItem).mockReset(); vi.mocked(localStorage.removeItem).mockReset(); vi.mocked(localStorage.clear).mockReset(); localStorage.clear(); server.use( http.get('/api/v1/printers/', () => { return HttpResponse.json([ { id: 1, name: 'X1 Carbon', model: 'X1C', enabled: true }, ]); }), http.get('/api/v1/printers/:id/status', () => { return HttpResponse.json({ connected: true, state: 'IDLE', }); }), http.get('/api/v1/version', () => { return HttpResponse.json({ version: '0.1.6', build: 'test' }); }), http.get('/api/v1/settings/', () => { return HttpResponse.json({ check_updates: false, check_printer_firmware: false, auto_archive: true, }); }), // What the sidebar actually gates on. Layout used to read these from // /settings/, which a non-admin cannot fetch (#3023). http.get('/api/v1/settings/ui-flags', () => { return HttpResponse.json({ check_updates: false, billing_enabled: false, user_notifications_enabled: true, currency: 'EUR', }); }), http.get('/api/v1/external-links/', () => { return HttpResponse.json([]); }), http.get('/api/v1/smart-plugs/', () => { return HttpResponse.json([]); }), http.get('/api/v1/support/debug-logging', () => { return HttpResponse.json({ enabled: false }); }), http.get('/api/v1/queue/', () => { return HttpResponse.json([]); }), http.get('/api/v1/pending-uploads/count', () => { return HttpResponse.json({ count: 0 }); }), http.get('/api/v1/updates/check', () => { return HttpResponse.json({ update_available: false }); }), http.get('/api/v1/auth/status', () => { return HttpResponse.json({ auth_enabled: false, requires_setup: false }); }), http.get('/api/v1/printers/developer-mode-warnings', () => { return HttpResponse.json([]); }) ); }); describe('rendering', () => { it('renders the sidebar', async () => { render(); // Layout renders as a flex container with sidebar await waitFor(() => { const sidebar = document.querySelector('aside'); expect(sidebar).toBeInTheDocument(); }); }); it('renders navigation links', async () => { render(); await waitFor(() => { // Navigation links should be present const links = document.querySelectorAll('a'); expect(links.length).toBeGreaterThan(0); }); }); }); describe('navigation', () => { it('has navigation items', async () => { render(); await waitFor(() => { // Should have multiple navigation links const navLinks = document.querySelectorAll('a[href]'); expect(navLinks.length).toBeGreaterThan(0); }); }); it('includes settings link', async () => { render(); await waitFor(() => { // Settings link should exist (route /settings) const settingsLink = document.querySelector('a[href="/settings"]'); expect(settingsLink).toBeInTheDocument(); }); }); it('hides system nav items stored in sidebar layout preferences', async () => { vi.mocked(localStorage.getItem).mockImplementation((key) => { if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']); return null; }); render(); await waitFor(() => { const sidebar = document.querySelector('aside'); expect(sidebar).toBeInTheDocument(); expect(sidebar?.querySelector('a[href="/inventory"]')).toBeInTheDocument(); }); expect(document.querySelector('aside a[href="/"]')).toBeNull(); }); it('applies admin default sidebar hidden state with the default order', async () => { const storage: Record = {}; vi.mocked(localStorage.getItem).mockImplementation((key) => storage[key] ?? null); vi.mocked(localStorage.setItem).mockImplementation((key, value) => { storage[key] = value; }); server.use( http.get('/api/v1/settings/default-sidebar-order', () => HttpResponse.json({ default_sidebar_order: JSON.stringify({ order: ['inventory', 'printers', 'settings'], hiddenSystemItemIds: ['printers'], }), }), ), ); render(); await waitFor(() => { const sidebar = document.querySelector('aside'); expect(sidebar).toBeInTheDocument(); expect(sidebar?.querySelector('a[href="/inventory"]')).toBeInTheDocument(); }); await waitFor(() => { expect(document.querySelector('aside a[href="/"]')).toBeNull(); expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_ORDER_KEY, JSON.stringify(['inventory', 'printers', 'settings'])); expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers'])); }); }); }); describe('finance nav item', () => { it('stays out of the sidebar while billing is off', async () => { // billing_enabled defaults to false and the Finance page has nothing to // show without it, so the entry must not be there at all. render(); await waitFor(() => { expect(document.querySelector('aside a[href="/stats"]')).toBeInTheDocument(); }); expect(document.querySelector('aside a[href="/finance"]')).toBeNull(); }); it('appears between Statistics and Settings once billing is on', async () => { server.use( http.get('/api/v1/settings/ui-flags', () => HttpResponse.json({ check_updates: false, billing_enabled: true, user_notifications_enabled: true, currency: 'EUR', }), ), ); render(); await waitFor(() => { expect(document.querySelector('aside a[href="/finance"]')).toBeInTheDocument(); }); const sidebar = document.querySelector('aside'); const hrefs = Array.from(sidebar?.querySelectorAll('a[href]') ?? []).map((a) => a.getAttribute('href')); expect(hrefs.indexOf('/finance')).toBeGreaterThan(hrefs.indexOf('/stats')); expect(hrefs.indexOf('/finance')).toBeLessThan(hrefs.indexOf('/settings')); }); }); describe('Sidebar gates survive a user who cannot read /settings (#3023)', () => { // Every gate below used to be fed by GET /settings, which requires // settings:read. A non-admin gets 403 there, so the value arrived // undefined and each gate silently took its fallback -- in opposite // directions, which is why only one of the two was ever reported. let priorToken: string | null = null; const asNonAdmin = (permissions: string[]) => { server.use( http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false }), ), http.get('/api/v1/auth/me', () => HttpResponse.json({ id: 2, username: 'operator', role: 'user', is_active: true, is_admin: false, groups: [{ id: 2, name: 'Operators' }], permissions, created_at: '2026-01-01T00:00:00Z', }), ), // The 403 that started it. Layout must not need this call at all. http.get('/api/v1/settings/', () => HttpResponse.json({ detail: 'Not enough permissions' }, { status: 403 }), ), ); // localStorage is a no-op mock in setup.ts, so writing the key there // authenticates nobody. Set the client's token directly. priorToken = getAuthToken(); setAuthToken('test-token', 'session'); }; afterEach(() => { setAuthToken(priorToken, 'session'); priorToken = null; }); it('shows Finance to a user with cost_centers:read_own and no settings:read', async () => { asNonAdmin(['cost_centers:read_own']); server.use( http.get('/api/v1/settings/ui-flags', () => HttpResponse.json({ billing_enabled: true, user_notifications_enabled: true }), ), ); render(); await waitFor(() => { expect(document.querySelector('aside a[href="/finance"]')).toBeInTheDocument(); }); }); it('still hides Finance from that user when billing is off', async () => { // Waits on Notifications appearing rather than on the sidebar existing. // Asserting absence the moment