diff --git a/frontend/src/router/__tests__/router.guard.test.js b/frontend/src/router/__tests__/router.guard.test.js
index 036a88c..aab3144 100644
--- a/frontend/src/router/__tests__/router.guard.test.js
+++ b/frontend/src/router/__tests__/router.guard.test.js
@@ -6,7 +6,11 @@ vi.mock('../../stores/auth.js', () => ({
useAuthStore: vi.fn(),
}))
-// Mock all lazily-imported view components so the router can be instantiated
+// 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: '
({ default: { template: '
' } }))
vi.mock('../../views/SharedView.vue', () => ({ default: { template: '
' } }))
-// Heavy view components imported statically — stub them too
+// 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: '
' } }))
@@ -35,6 +41,10 @@ beforeEach(() => {
vi.clearAllMocks()
})
+// ---------------------------------------------------------------------------
+// Auth route meta
+// ---------------------------------------------------------------------------
+
describe('router — auth route meta (SEC-07, AUTH-01)', () => {
const authPaths = ['/login', '/register', '/password-reset', '/password-reset/confirm']
@@ -57,6 +67,10 @@ describe('router — auth route meta (SEC-07, AUTH-01)', () => {
)
})
+// ---------------------------------------------------------------------------
+// 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'
@@ -97,4 +111,146 @@ describe('router — admin guard (SEC-07)', () => {
// 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')
+ })
})
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
index e0f0d73..ab183f3 100644
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -1,19 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'
+// FileManagerView is kept synchronous because it is the critical first authenticated
+// surface rendered at '/'. Lazy-loading it would delay the initial paint for logged-in
+// users with a valid refresh cookie (the most common app entry point).
+// All other authenticated routes are lazy-loaded to split them into separate chunks.
+// See PERF-03 / Plan 11-02 and D-10 in 11-CONTEXT.md.
import FileManagerView from '../views/FileManagerView.vue'
-import TopicsView from '../views/TopicsView.vue'
-import DocumentView from '../views/DocumentView.vue'
-import SettingsView from '../views/SettingsView.vue'
-import CloudFolderView from '../views/CloudFolderView.vue'
-import CloudStorageView from '../views/CloudStorageView.vue'
const routes = [
- // File manager is the home — handles both root and folder views
+ // File manager is the home — handles both root and folder views.
+ // FileManagerView stays synchronous (see comment above).
{ path: '/', component: FileManagerView },
- { path: '/topics', component: TopicsView },
- { path: '/topics/:name', component: TopicsView },
- { path: '/document/:id', component: DocumentView },
- { path: '/settings', component: SettingsView },
+ { path: '/topics', component: () => import('../views/TopicsView.vue') },
+ { path: '/topics/:name', component: () => import('../views/TopicsView.vue') },
+ { path: '/document/:id', component: () => import('../views/DocumentView.vue') },
+ { path: '/settings', component: () => import('../views/SettingsView.vue') },
{
path: '/login',
@@ -57,13 +58,13 @@ const routes = [
{
path: '/cloud',
name: 'cloud',
- component: CloudStorageView,
+ component: () => import('../views/CloudStorageView.vue'),
meta: { requiresAuth: true },
},
{
path: '/cloud/:provider/:folderId(.*)',
name: 'cloud-folder',
- component: CloudFolderView,
+ component: () => import('../views/CloudFolderView.vue'),
meta: { requiresAuth: true },
},