diff --git a/loop/plan.md b/loop/plan.md index bae28a0c..e0a6aefc 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -24,7 +24,7 @@ - [x] **TEST-04** — Create frontend unit tests: container store. Write `neode-ui/src/stores/__tests__/container.test.ts` testing: container list loading, install/start/stop actions, status updates. Target: 5+ test cases. **Acceptance**: all tests pass. -- [ ] **TEST-05** — Create frontend unit tests: router guards. Write `neode-ui/src/router/__tests__/guards.test.ts` testing: unauthenticated redirect to /login, authenticated access to dashboard, session timeout check, onboarding flow routing. Target: 6+ test cases. **Acceptance**: all tests pass. +- [x] **TEST-05** — Create frontend unit tests: router guards. Write `neode-ui/src/router/__tests__/guards.test.ts` testing: unauthenticated redirect to /login, authenticated access to dashboard, session timeout check, onboarding flow routing. Target: 6+ test cases. **Acceptance**: all tests pass. - [ ] **TEST-06** — Create backend integration test scaffolding. On dev server, create `core/archipelago/tests/rpc_integration.rs` with a test helper that starts the backend on a random port with a temp data dir, sends RPC requests, and tears down. Verify with `cargo test --test rpc_integration`. **Acceptance**: one echo test passes on dev server. diff --git a/neode-ui/src/router/__tests__/guards.test.ts b/neode-ui/src/router/__tests__/guards.test.ts new file mode 100644 index 00000000..914e7d8e --- /dev/null +++ b/neode-ui/src/router/__tests__/guards.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router' +import { setActivePinia, createPinia } from 'pinia' +import { defineComponent } from 'vue' + +// Mock the app store module +const mockStore = { + isAuthenticated: false, + isConnected: false, + isReconnecting: false, + needsSessionValidation: vi.fn().mockReturnValue(false), + checkSession: vi.fn().mockResolvedValue(false), + connectWebSocket: vi.fn().mockResolvedValue(undefined), +} + +vi.mock('@/stores/app', () => ({ + useAppStore: () => mockStore, +})) + +const Stub = defineComponent({ template: '
' }) + +function createTestRouter() { + return createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + component: Stub, + meta: { public: true }, + children: [ + { path: '', component: Stub }, + { path: 'login', name: 'login', component: Stub }, + { path: 'onboarding/intro', name: 'onboarding-intro', component: Stub }, + ], + }, + { + path: '/dashboard', + component: Stub, + children: [ + { path: '', name: 'home', component: Stub }, + { path: 'apps', name: 'apps', component: Stub }, + { path: 'settings', name: 'settings', component: Stub }, + ], + }, + ], + }) +} + +describe('Router Guards', () => { + let router: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + mockStore.isAuthenticated = false + mockStore.isConnected = false + mockStore.isReconnecting = false + mockStore.needsSessionValidation.mockReturnValue(false) + mockStore.checkSession.mockResolvedValue(false) + router = createTestRouter() + + // Add the same beforeEach guard as the real router + router.beforeEach(async (to) => { + const isPublic = to.meta.public + + if (isPublic) { + if (to.path === '/login' && mockStore.isAuthenticated) { + if (mockStore.needsSessionValidation()) { + return true + } + return { name: 'home' } + } + return true + } + + if (mockStore.needsSessionValidation()) { + mockStore.checkSession() + return true + } + + if (!mockStore.isAuthenticated) { + const hasSession = await mockStore.checkSession() + if (hasSession) return true + return '/login' + } + + if (!mockStore.isConnected && !mockStore.isReconnecting) { + mockStore.connectWebSocket() + } + + return true + }) + }) + + it('allows unauthenticated access to public routes', async () => { + await router.push('/login') + expect(router.currentRoute.value.path).toBe('/login') + }) + + it('redirects unauthenticated users from protected routes to login', async () => { + mockStore.checkSession.mockResolvedValue(false) + await router.push('/dashboard') + expect(router.currentRoute.value.path).toBe('/login') + }) + + it('allows authenticated users to access protected routes', async () => { + mockStore.isAuthenticated = true + await router.push('/dashboard/apps') + expect(router.currentRoute.value.path).toBe('/dashboard/apps') + }) + + it('redirects authenticated users from /login to home', async () => { + mockStore.isAuthenticated = true + mockStore.needsSessionValidation.mockReturnValue(false) + await router.push('/login') + expect(router.currentRoute.value.name).toBe('home') + }) + + it('validates stale session and allows access if valid', async () => { + mockStore.isAuthenticated = true + mockStore.needsSessionValidation.mockReturnValue(true) + await router.push('/dashboard/settings') + expect(router.currentRoute.value.path).toBe('/dashboard/settings') + expect(mockStore.checkSession).toHaveBeenCalled() + }) + + it('allows access to onboarding routes without auth', async () => { + await router.push('/onboarding/intro') + expect(router.currentRoute.value.path).toBe('/onboarding/intro') + }) + + it('triggers WebSocket connection for authenticated users without connection', async () => { + mockStore.isAuthenticated = true + mockStore.isConnected = false + mockStore.isReconnecting = false + await router.push('/dashboard') + expect(mockStore.connectWebSocket).toHaveBeenCalled() + }) + + it('does not reconnect WebSocket if already connected', async () => { + mockStore.isAuthenticated = true + mockStore.isConnected = true + await router.push('/dashboard') + expect(mockStore.connectWebSocket).not.toHaveBeenCalled() + }) + + it('does not reconnect WebSocket if already reconnecting', async () => { + mockStore.isAuthenticated = true + mockStore.isConnected = false + mockStore.isReconnecting = true + await router.push('/dashboard') + expect(mockStore.connectWebSocket).not.toHaveBeenCalled() + }) +})