login.vue is rewritten to consume useAuthStore.login()'s discriminated
union — no more direct apiClient calls or branching on raw API response
shapes. The page maps result.kind to UI/routing decisions only:
- mfa-required → swap to MfaChallengeCard with the typed payload
- authenticated → resolvePostLoginTarget() (?to= relative, else
auth.resolveLandingRoute())
- must-set-password → forward-compatible placeholder route
- failed → field-level errors + rate_limit message branch
resolveLandingRoute() now returns a string path instead of
RouteLocationRaw — the typed router accepts string-paths cleanly,
removes the cast at every call site, and lets useAuthStore.spec.ts +
guards.spec.ts assert the resolved path directly.
A13-3 minimum precaution lives in a new utility:
src/utils/postLoginRedirect.ts. The relative-only check
(`startsWith('/') && !startsWith('//')`) rejects absolute, protocol-
relative, javascript:, and data: schemes. Full domain validation lands
in WS-3 PR-B2b.
6 vitest specs in utils/__tests__/postLoginRedirect.spec.ts cover the
six rejection / passthrough scenarios.
Test count 192 → 198. Lint + typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
270 lines
7.5 KiB
TypeScript
270 lines
7.5 KiB
TypeScript
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).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' } })
|
|
})
|
|
})
|