feat(router): context-aware guards with meta-driven role/context resolution

Rewrites plugins/1.router/guards.ts per ARCH-CONSOLIDATION §4.3. The
B1 portal-context carve-out is removed; portal/organizer routing is
now declarative via meta.context, role gates via meta.requiresRole.

Guard pipeline:
1. Initialize auth store on first navigation
2. Public routes pass through (authenticated user on guest-only path
   is bounced to resolveLandingRoute)
3. Auth required → /login?to=<path>
4. MFA setup gate → /account-settings?tab=security
5. requiresRole declarative check (replaces hardcoded /platform path
   prefix + isSuperAdmin)
6. Context routing — portal returns early, organizer falls through
   and sets lastContext
7. Org-selection check (organizer routes only)

Page meta updates (mechanical, idempotent):
- 4 portal pages: removed `requiresAuth: true` (auth is implicit)
- 4 pages: replaced `requiresAuth: false` with `meta.public: true`
  (registreren, wachtwoord-instellen, advance/[token],
  invitations/[token])
- 22 organizer pages: added `context: 'organizer'`
  (account-settings, events/**, organisation/form-failures/**,
  select-organisation, dashboard, events/index, members,
  organisation/{index,companies,settings})
- 8 platform pages: added `context: 'organizer'` +
  `requiresRole: 'super_admin'`
- 6 organizer pages had no definePage block — one was added with
  `context: 'organizer'`

Adds plugins/1.router/__tests__/guards.spec.ts (11 tests) covering
public passthrough, unauthenticated redirect, portal/organizer
context branching, declarative requiresRole, org-selection
redirect, MFA gate.

Test count 178 → 189 (11 new). Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 21:32:54 +02:00
parent f2b08ecb21
commit 473b22ac9e
38 changed files with 388 additions and 84 deletions

View File

@@ -0,0 +1,269 @@
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> = {}): RouteLocationNormalized {
return {
path: '/',
fullPath: '/',
name: undefined,
meta: {},
params: {},
query: {},
hash: '',
matched: [],
redirectedFrom: undefined,
...overrides,
} as RouteLocationNormalized
}
function hydrate(me: Partial<MeResponse> = {}): 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).toEqual({ path: '/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' } })
})
})