import { beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import type { NavigationGuard, RouteLocationNormalized, Router } from 'vue-router' import type { MeResponse } from '@/types/auth' vi.mock('@/lib/axios', () => ({ apiClient: { get: vi.fn(), post: vi.fn() }, })) vi.mock('@/utils/deviceFingerprint', () => ({ generateDeviceFingerprint: () => 'test-fingerprint', })) const { setupGuards } = await import('@/plugins/1.router/guards') const { useAuthStore } = await import('@/stores/useAuthStore') const { useOrganisationStore } = await import('@/stores/useOrganisationStore') function captureGuard(): NavigationGuard { let guard: NavigationGuard | null = null const router = { beforeEach: (fn: NavigationGuard) => { guard = fn }, } as unknown as Router setupGuards(router) if (!guard) throw new Error('guard not captured') return guard } function makeRoute(overrides: Partial = {}): RouteLocationNormalized { return { path: '/', fullPath: '/', name: undefined, meta: {}, params: {}, query: {}, hash: '', matched: [], redirectedFrom: undefined, ...overrides, } as RouteLocationNormalized } function hydrate(me: Partial = {}): void { const auth = useAuthStore() auth.setUser({ id: '01ABC', first_name: 'Test', last_name: 'User', full_name: 'Test User', date_of_birth: null, email: 'test@example.nl', phone: null, timezone: 'Europe/Amsterdam', locale: 'nl', avatar: null, organisations: [], app_roles: [], permissions: [], ...me, }) // initialize() is guarded by isInitialized; flip it directly so the // guard's first-step initialize() short-circuits without touching // the mocked apiClient. ;(auth as unknown as { isInitialized: boolean }).isInitialized = true } describe('router guards (WS-3 PR-B2a)', () => { let guard: NavigationGuard beforeEach(() => { setActivePinia(createPinia()) localStorage.clear() guard = captureGuard() // Default: store starts uninitialized; tests opt-in via hydrate() const auth = useAuthStore() ;(auth as unknown as { isInitialized: boolean }).isInitialized = true }) it('public routes pass through', async () => { const result = await guard( makeRoute({ path: '/login', meta: { public: true } }), makeRoute(), () => {}, ) expect(result).toBe(true) }) it('redirects unauthenticated user to login with `?to=` query', async () => { const result = await guard( makeRoute({ path: '/events', fullPath: '/events', meta: {} }), makeRoute(), () => {}, ) expect(result).toEqual({ name: 'login', query: { to: '/events' } }) }) it('portal route passes when user has portal context', async () => { hydrate({ contexts: { available: ['portal'], default: 'portal' }, }) const result = await guard( makeRoute({ path: '/portal/evenementen', meta: { context: 'portal' } }), makeRoute(), () => {}, ) expect(result).toBe(true) expect(useAuthStore().lastContext).toBe('portal') }) it('portal route forbidden for organizer-only user', async () => { hydrate({ organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }], app_roles: ['org_admin'], contexts: { available: ['organizer'], default: 'organizer' }, }) // Pre-select org so the guard doesn't redirect to select-organisation. useOrganisationStore().setActiveOrganisation('01') const result = await guard( makeRoute({ path: '/portal/evenementen', meta: { context: 'portal' } }), makeRoute(), () => {}, ) expect(result).toEqual({ name: 'forbidden' }) }) it('organizer route sets lastContext to organizer for multi-role user', async () => { hydrate({ organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }], app_roles: ['org_admin'], contexts: { available: ['portal', 'organizer'], default: 'organizer' }, }) useOrganisationStore().setActiveOrganisation('01') const result = await guard( makeRoute({ path: '/events', meta: { context: 'organizer' } }), makeRoute(), () => {}, ) expect(result).toBe(true) expect(useAuthStore().lastContext).toBe('organizer') }) it('platform route forbidden for non-super_admin (declarative requiresRole)', async () => { hydrate({ organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }], app_roles: ['org_admin'], contexts: { available: ['organizer'], default: 'organizer' }, }) useOrganisationStore().setActiveOrganisation('01') const result = await guard( makeRoute({ path: '/platform/users', meta: { context: 'organizer', requiresRole: 'super_admin' }, }), makeRoute(), () => {}, ) expect(result).toEqual({ name: 'forbidden' }) }) it('platform route allowed for super_admin', async () => { hydrate({ organisations: [], app_roles: ['super_admin'], contexts: { available: ['organizer'], default: 'organizer' }, }) const result = await guard( makeRoute({ path: '/platform/users', meta: { context: 'organizer', requiresRole: 'super_admin' }, }), makeRoute(), () => {}, ) expect(result).toBe(true) }) it('redirects to select-organisation when org user has no active org', async () => { hydrate({ organisations: [ { id: '01', name: 'Org A', slug: 'a', role: 'org_admin' }, { id: '02', name: 'Org B', slug: 'b', role: 'org_admin' }, ], app_roles: ['org_admin'], contexts: { available: ['organizer'], default: 'organizer' }, }) useOrganisationStore().clear() const result = await guard( makeRoute({ path: '/events', fullPath: '/events', meta: { context: 'organizer' } }), makeRoute(), () => {}, ) expect(result).toEqual({ path: '/select-organisation', query: { to: '/events' } }) }) it('does not redirect portal-context route to select-organisation', async () => { hydrate({ organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }], app_roles: ['org_admin'], contexts: { available: ['portal', 'organizer'], default: 'organizer' }, }) useOrganisationStore().clear() const result = await guard( makeRoute({ path: '/portal/evenementen', meta: { context: 'portal' } }), makeRoute(), () => {}, ) expect(result).toBe(true) }) it('redirects authenticated user away from login back to landing route', async () => { hydrate({ contexts: { available: ['portal'], default: 'portal' }, }) const result = await guard( makeRoute({ path: '/login', meta: { public: true } }), makeRoute(), () => {}, ) expect(result).toBe('/portal/evenementen') }) it('MFA setup gate redirects to /account-settings when mfaSetupRequired', async () => { hydrate({ organisations: [{ id: '01', name: 'Org', slug: 'org', role: 'org_admin' }], app_roles: ['org_admin'], mfa: { enabled: false, method: null, confirmed_at: null, setup_required: true }, contexts: { available: ['organizer'], default: 'organizer' }, }) useOrganisationStore().setActiveOrganisation('01') const result = await guard( makeRoute({ path: '/events', meta: { context: 'organizer' } }), makeRoute(), () => {}, ) expect(result).toEqual({ path: '/account-settings', query: { tab: 'security' } }) }) })