import { describe, it, expect, vi, beforeEach } from 'vitest' import { createPinia, setActivePinia } from 'pinia' // Must mock before importing router (which imports useAuthStore) vi.mock('../../stores/auth.js', () => ({ useAuthStore: vi.fn(), })) // Mock all lazily-imported view components so the router can be instantiated. // These mocks also exercise the lazy-load code paths: because the router uses // () => import(...) for these modules, Vitest's vi.mock() intercepts the dynamic // import and resolves synchronously — verifying that guard logic is not broken // by the switch from static to lazy imports (PERF-03 / Plan 11-02). vi.mock('../../views/auth/LoginView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/auth/RegisterView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/auth/PasswordResetView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/auth/NewPasswordView.vue', () => ({ default: { template: '
' } })) vi.mock('../../layouts/AdminLayout.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/admin/AdminOverviewView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/admin/AdminUsersView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/admin/AdminQuotasView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/admin/AdminAiView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/admin/AdminAuditView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/SharedView.vue', () => ({ default: { template: '
' } })) // FileManagerView is the only statically-imported component (kept synchronous per D-10). vi.mock('../../views/FileManagerView.vue', () => ({ default: { template: '
' } })) // Lazy-loaded route components (now use () => import(...) in router/index.js). vi.mock('../../views/TopicsView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/DocumentView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/SettingsView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/CloudFolderView.vue', () => ({ default: { template: '
' } })) vi.mock('../../views/CloudStorageView.vue', () => ({ default: { template: '
' } })) import { useAuthStore } from '../../stores/auth.js' import router from '../index.js' beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() }) // --------------------------------------------------------------------------- // Auth route meta // --------------------------------------------------------------------------- describe('router — auth route meta (SEC-07, AUTH-01)', () => { const authPaths = ['/login', '/register', '/password-reset', '/password-reset/confirm'] it.each(authPaths)( 'route %s has meta.public = true so guard skips auth check', (path) => { const route = router.getRoutes().find(r => r.path === path) expect(route, `route "${path}" should exist`).toBeDefined() expect(route.meta.public).toBe(true) } ) it.each(authPaths)( 'route %s has meta.layout = "auth" so sidebar is never rendered', (path) => { const route = router.getRoutes().find(r => r.path === path) expect(route, `route "${path}" should exist`).toBeDefined() expect(route.meta.layout).toBe('auth') } ) }) // --------------------------------------------------------------------------- // Admin guard — non-admin blocked, admin redirected from user routes // --------------------------------------------------------------------------- describe('router — admin guard (SEC-07)', () => { it('redirects a non-admin user away from /admin to /', async () => { // Arrange: authenticated but role = 'viewer' useAuthStore.mockReturnValue({ accessToken: 'fake-token', user: { id: '1', role: 'viewer' }, refresh: vi.fn().mockResolvedValue(undefined), }) // Act: attempt to navigate to /admin const result = await router.push('/admin') // The guard returns { path: '/' }, so the router resolves to '/' expect(router.currentRoute.value.path).toBe('/') }) it('allows an admin user to reach /admin', async () => { // Arrange: authenticated as admin useAuthStore.mockReturnValue({ accessToken: 'fake-token', user: { id: '1', role: 'admin' }, refresh: vi.fn().mockResolvedValue(undefined), }) await router.push('/admin') expect(router.currentRoute.value.path).toBe('/admin') }) it('redirects an admin user away from / to /admin (D-09)', async () => { // Arrange: admin trying to navigate to a regular user route useAuthStore.mockReturnValue({ accessToken: 'fake-token', user: { id: '1', role: 'admin' }, refresh: vi.fn().mockResolvedValue(undefined), }) // Act: admin attempts to visit the regular user home await router.push('/') // The D-09 guard branch returns { path: '/admin' } expect(router.currentRoute.value.path).toBe('/admin') }) it('non-admin user is blocked from /admin/users (admin child route)', async () => { useAuthStore.mockReturnValue({ accessToken: 'fake-token', user: { id: '2', role: 'viewer' }, refresh: vi.fn().mockResolvedValue(undefined), }) await router.push('/admin/users') // Guard returns { path: '/' } because to.matched.some(r => r.meta.requiresAdmin) is true expect(router.currentRoute.value.path).toBe('/') }) it('non-admin user is blocked from /admin/quotas (admin child route)', async () => { useAuthStore.mockReturnValue({ accessToken: 'fake-token', user: { id: '2', role: 'viewer' }, refresh: vi.fn().mockResolvedValue(undefined), }) await router.push('/admin/quotas') expect(router.currentRoute.value.path).toBe('/') }) it('admin is redirected away from /settings to /admin (D-09)', async () => { useAuthStore.mockReturnValue({ accessToken: 'fake-token', user: { id: '1', role: 'admin' }, refresh: vi.fn().mockResolvedValue(undefined), }) await router.push('/settings') expect(router.currentRoute.value.path).toBe('/admin') }) it('admin is redirected away from /cloud to /admin (D-09)', async () => { useAuthStore.mockReturnValue({ accessToken: 'fake-token', user: { id: '1', role: 'admin' }, refresh: vi.fn().mockResolvedValue(undefined), }) await router.push('/cloud') expect(router.currentRoute.value.path).toBe('/admin') }) }) // --------------------------------------------------------------------------- // Refresh-before-guard — runs when access token is absent on reload // --------------------------------------------------------------------------- describe('router — refresh-before-guard (PERF-03, AUTH-01)', () => { it('calls authStore.refresh() when access token is absent for a protected route', async () => { const mockRefresh = vi.fn().mockResolvedValue(undefined) useAuthStore.mockReturnValue({ accessToken: null, // simulates page reload (token is memory-only) user: { id: '3', role: 'viewer' }, refresh: mockRefresh, }) await router.push('/topics') expect(mockRefresh).toHaveBeenCalledOnce() }) it('redirects to /login when refresh fails and no token exists', async () => { useAuthStore.mockReturnValue({ accessToken: null, user: null, refresh: vi.fn().mockRejectedValue(new Error('session expired')), }) await router.push('/settings') expect(router.currentRoute.value.path).toBe('/login') }) it('does NOT call refresh() for public routes', async () => { const mockRefresh = vi.fn() useAuthStore.mockReturnValue({ accessToken: null, user: null, refresh: mockRefresh, }) await router.push('/login') expect(mockRefresh).not.toHaveBeenCalled() expect(router.currentRoute.value.path).toBe('/login') }) }) // --------------------------------------------------------------------------- // Lazy-loaded routes resolve correctly for authenticated regular users // (verifies that switching from static to () => import() did not break routing) // --------------------------------------------------------------------------- describe('router — lazy-loaded routes resolve (PERF-03)', () => { const regularUserRoutes = [ '/topics', '/topics/finance', '/document/some-uuid', '/settings', '/cloud', '/cloud/onedrive/root', ] it.each(regularUserRoutes)( 'route %s resolves for an authenticated regular user', async (path) => { useAuthStore.mockReturnValue({ accessToken: 'fake-token', user: { id: '4', role: 'viewer' }, refresh: vi.fn().mockResolvedValue(undefined), }) await router.push(path) // The guard does not redirect regular users away from their own routes. // Because router.push() resolves after guards run and the component is // loaded, currentRoute.path should match the navigation target. expect(router.currentRoute.value.path).toBe(path) } ) it('/folders/:folderId reuses synchronous FileManagerView (D-10)', async () => { useAuthStore.mockReturnValue({ accessToken: 'fake-token', user: { id: '4', role: 'viewer' }, refresh: vi.fn().mockResolvedValue(undefined), }) await router.push('/folders/abc-123') expect(router.currentRoute.value.path).toBe('/folders/abc-123') }) it('/shared resolves for an authenticated regular user', async () => { useAuthStore.mockReturnValue({ accessToken: 'fake-token', user: { id: '4', role: 'viewer' }, refresh: vi.fn().mockResolvedValue(undefined), }) await router.push('/shared') expect(router.currentRoute.value.path).toBe('/shared') }) })