import { AsyncLocalStorage } from 'node:async_hooks' import { describe, expect, it, vi } from 'vitest' function createTestContext() { const mainStore = { auth: undefined, user: undefined, isAdmin: false, plans: [], } const organizationStore = { organizations: [{ gid: 'org-123', role: 'read' }], hasOrganizations: true, fetchOrganizations: vi.fn(async () => {}), dedupFetchOrganizations: vi.fn(async () => {}), } const mockGetClaims = vi.fn().mockResolvedValue({ data: { claims: { sub: 'user-123', }, }, }) const mockGetSession = vi.fn().mockResolvedValue({ data: { session: { access_token: 'token-123', user: { id: 'user-123', email: 'user@example.com', email_confirmed_at: '2026-04-15T10:00:00.000Z', app_metadata: { provider: 'email', providers: ['email'] }, factors: [{ factor_type: 'totp', status: 'verified' }], }, }, }, }) const mockGetAuthenticatorAssuranceLevel = vi.fn().mockResolvedValue({ data: { currentLevel: 'aal1', nextLevel: 'aal2', }, error: null, }) const mockRpc = vi.fn(async (name: string): Promise<{ data: boolean | null, error: { message: string } | null }> => { if (name === 'verify_mfa') return { data: false, error: null } if (name === 'is_account_disabled') return { data: false, error: null } return { data: null, error: null } }) const mockHideLoader = vi.fn() return { mainStore, mockGetAuthenticatorAssuranceLevel, mockGetClaims, mockGetSession, mockHideLoader, mockRpc, organizationStore, } } type AuthGuardTestContext = ReturnType const contextStorage = new AsyncLocalStorage() function getContext() { const context = contextStorage.getStore() if (!context) throw new Error('Missing auth guard test context') return context } async function withTestContext(run: (context: AuthGuardTestContext) => Promise) { const context = createTestContext() await contextStorage.run(context, async () => { await run(context) }) } vi.mock('~/services/loader', () => ({ hideLoader: () => getContext().mockHideLoader(), })) vi.mock('~/services/posthog', () => ({ setUser: vi.fn(), })) vi.mock('~/services/storage', () => ({ createSignedImageUrl: vi.fn(async (value: string) => value), getImmediateImageUrl: (value?: string | null) => value ?? '', })) vi.mock('~/services/tracking', () => ({ sendEvent: vi.fn().mockResolvedValue(undefined), })) vi.mock('~/services/websiteAuthCookie', () => ({ clearWebsitePaidUserCookie: vi.fn(), setWebsitePaidUserCookie: vi.fn(), })) vi.mock('~/services/console', () => ({ getLocalConfig: () => ({ apiHost: 'https://api.capgo.test' }), getPlans: vi.fn(async () => []), isPlatformAdmin: vi.fn(async () => false), useConsole: () => { const context = getContext() return { auth: { getClaims: context.mockGetClaims, getSession: context.mockGetSession, getAuthenticatorAssuranceLevel: context.mockGetAuthenticatorAssuranceLevel, mfa: { getAuthenticatorAssuranceLevel: context.mockGetAuthenticatorAssuranceLevel, }, signOut: vi.fn(), onAuthStateChange: vi.fn(), }, rpc: context.mockRpc, from: vi.fn(() => ({ select: vi.fn(() => ({ eq: vi.fn(() => ({ maybeSingle: vi.fn(async () => ({ data: { id: 'user-123', email: 'user@example.com', first_name: '', last_name: '', image_url: null, }, error: null, })), })), })), })), } }, defaultApiHost: 'https://api.capgo.test', })) vi.mock('~/stores/main', () => ({ useMainStore: () => getContext().mainStore, })) vi.mock('~/stores/organization', () => ({ isPendingOrganizationInvite: () => false, useOrganizationStore: () => getContext().organizationStore, })) async function getGuard() { const router = { beforeEach: vi.fn(), } const { install } = await import('../src/modules/auth.ts') install({ router } as never) const guard = router.beforeEach.mock.calls[0]?.[0] if (!guard) throw new Error('Auth guard was not registered') return guard } describe('auth guard MFA assurance', () => { it.concurrent('redirects enrolled TOTP users at aal1 to complete MFA', async () => { await withTestContext(async (context) => { const guard = await getGuard() const next = vi.fn() await guard( { path: '/dashboard', fullPath: '/dashboard', meta: { middleware: 'auth' }, query: {} }, { path: '/login', fullPath: '/login', meta: {}, query: {} }, next, ) expect(context.mockRpc).toHaveBeenCalledWith('verify_mfa') expect(next).toHaveBeenCalledWith({ path: '/login', query: { to: '/dashboard', }, }) }) }) it.concurrent('redirects to login when verify_mfa returns an RPC error', async () => { await withTestContext(async (context) => { const verifyMfaError = { message: 'MFA verification failed' } const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) context.mockRpc.mockImplementation(async (name: string) => { if (name === 'verify_mfa') return { data: null, error: verifyMfaError } if (name === 'is_account_disabled') return { data: false, error: null } return { data: null, error: null } }) const guard = await getGuard() const next = vi.fn() await guard( { path: '/dashboard', fullPath: '/dashboard', meta: { middleware: 'auth' }, query: {} }, { path: '/login', fullPath: '/login', meta: {}, query: {} }, next, ) expect(consoleError).toHaveBeenCalledWith('Cannot verify MFA assurance', verifyMfaError) expect(next).toHaveBeenCalledWith({ path: '/login', query: { to: '/dashboard', }, }) consoleError.mockRestore() }) }) // The guard never trusts client-side factor data for impersonation: verify_mfa // only passes aal1 for session_ids registered by platform-admin log_as. it.concurrent('allows aal1 when verify_mfa returns true for a registered impersonation session', async () => { await withTestContext(async (context) => { context.mockRpc.mockImplementation(async (name: string) => { if (name === 'verify_mfa') return { data: true, error: null } if (name === 'is_account_disabled') return { data: false, error: null } return { data: null, error: null } }) const guard = await getGuard() const next = vi.fn() await guard( { path: '/dashboard', fullPath: '/dashboard', meta: { middleware: 'auth' }, query: {} }, { path: '/login', fullPath: '/login', meta: {}, query: {} }, next, ) expect(context.mockRpc).toHaveBeenCalledWith('verify_mfa') expect(next).toHaveBeenCalledWith() }) }) it.concurrent('redirects to login when getAuthenticatorAssuranceLevel fails', async () => { await withTestContext(async (context) => { const aalLookupError = { message: 'AAL lookup failed' } const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) context.mockGetAuthenticatorAssuranceLevel.mockResolvedValue({ data: null, error: aalLookupError, }) const guard = await getGuard() const next = vi.fn() await guard( { path: '/dashboard', fullPath: '/dashboard', meta: { middleware: 'auth' }, query: {} }, { path: '/login', fullPath: '/login', meta: {}, query: {} }, next, ) expect(consoleError).toHaveBeenCalledWith('Cannot guard auth', aalLookupError) expect(context.mockRpc).not.toHaveBeenCalledWith('verify_mfa') expect(next).toHaveBeenCalledWith({ path: '/login', query: { to: '/dashboard', }, }) consoleError.mockRestore() }) }) it.concurrent('allows users who already completed MFA at aal2', async () => { await withTestContext(async (context) => { context.mockGetAuthenticatorAssuranceLevel.mockResolvedValue({ data: { currentLevel: 'aal2', nextLevel: 'aal2', }, error: null, }) const guard = await getGuard() const next = vi.fn() await guard( { path: '/dashboard', fullPath: '/dashboard', meta: { middleware: 'auth' }, query: {} }, { path: '/login', fullPath: '/login', meta: {}, query: {} }, next, ) expect(context.mockRpc).not.toHaveBeenCalledWith('verify_mfa') expect(next).toHaveBeenCalledWith() }) }) })