tests/auth-mfa-guard.unit.test.ts
Rama main · réplica de lectura del repositorio original.
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<typeof createTestContext>
const contextStorage = new AsyncLocalStorage<AuthGuardTestContext>()
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<void>) {
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()
})
})
})