Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions src/client/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { renderHook } from '@testing-library/react'
import { createElement, type ReactNode } from 'react'
import { useAuth0, useUser, useOrg } from './hooks.js'
import type { Auth0RouterContext } from '../types/index.js'

// The real Auth0Provider (used by the useLogin/useLogout tests) reads router
// context and the router instance. Mock both. Auth0TestProvider (used by the
// other tests) does not touch these hooks, so it is unaffected.
let routeContextValue: Auth0RouterContext | undefined
const invalidate = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useRouteContext: (opts: { select: (c: { auth0?: unknown }) => unknown }) =>
opts.select({ auth0: routeContextValue }),
useRouter: () => ({ invalidate }),
}))

import { useAuth0, useUser, useOrg, useLogin, useLogout } from './hooks.js'
import { Auth0TestProvider } from '../testing/index.js'
import { Auth0Provider } from './provider.js'
import { getClientAuthCache } from './auth-cache.js'

function wrapper(props: { user?: Record<string, unknown> }) {
return ({ children }: { children: ReactNode }) =>
createElement(Auth0TestProvider, { user: props.user, children })
}

function providerWrapper({ children }: { children: ReactNode }) {
return createElement(Auth0Provider, { children })
}

describe('useAuth0', () => {
it('exposes user, isAuthenticated, status and isLoading when signed in', () => {
const { result } = renderHook(() => useAuth0(), {
Expand Down Expand Up @@ -51,3 +70,44 @@ describe('useOrg', () => {
expect(result.current).toEqual({ id: 'org_1', name: 'acme' })
})
})

describe('useLogin / useLogout', () => {
let assign: ReturnType<typeof vi.fn>

beforeEach(() => {
routeContextValue = {
user: { sub: 'auth0|1' },
isAuthenticated: true,
status: 'resolved',
isLoading: false,
}
invalidate.mockClear()
assign = vi.fn()
// jsdom does not implement navigation; stub assign so the redirect is
// observable without a "Not implemented" warning.
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...window.location, assign },
})
})

it('useLogin navigates to the login route with an encoded returnTo', () => {
const { result } = renderHook(() => useLogin(), { wrapper: providerWrapper })
result.current('/dashboard')
expect(assign).toHaveBeenCalledWith('/auth/login?returnTo=%2Fdashboard')
})

it('useLogin forwards authorizationParams as query params', () => {
const { result } = renderHook(() => useLogin(), { wrapper: providerWrapper })
result.current(undefined, { authorizationParams: { screen_hint: 'signup' } })
expect(assign).toHaveBeenCalledWith('/auth/login?screen_hint=signup')
})

it('useLogout clears the client cache, invalidates the router, and navigates', () => {
const { result } = renderHook(() => useLogout(), { wrapper: providerWrapper })
result.current()
expect(getClientAuthCache()).toBeUndefined()
expect(invalidate).toHaveBeenCalled()
expect(assign).toHaveBeenCalledWith('/auth/logout')
})
})
45 changes: 44 additions & 1 deletion src/server/auth0-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
// Capture the options passed to the foundation's store + client so we can assert
// that our sessionConfiguration is forwarded (H3: it used to be silently dropped).
const statelessArgs: unknown[] = []
const statefulArgs: unknown[] = []
const serverClientArgs: unknown[] = []

// These classes are instantiated with `new` in auth0-server.ts, so the mocks
Expand All @@ -20,7 +21,8 @@ vi.mock('@auth0/auth0-server-js', () => ({
statelessArgs.push(opts)
return { __stateless: true }
}),
StatefulStateStore: vi.fn(function () {
StatefulStateStore: vi.fn(function (opts: unknown) {
statefulArgs.push(opts)
return { __stateful: true }
}),
}))
Expand All @@ -43,6 +45,7 @@ const BASE = {

beforeEach(() => {
statelessArgs.length = 0
statefulArgs.length = 0
serverClientArgs.length = 0
vi.clearAllMocks()
})
Expand Down Expand Up @@ -125,3 +128,43 @@ describe('auth0Server domain resolver (Multiple Custom Domains)', () => {
expect(clientOpts.authorizationParams.redirect_uri).toBeUndefined()
})
})

describe('auth0Server session store selection', () => {
it('uses the stateless store by default (no sessionStore)', () => {
auth0Server(BASE)
expect(statelessArgs).toHaveLength(1)
expect(statefulArgs).toHaveLength(0)
})

it('uses the stateful store and forwards the store + secret when sessionStore is provided', () => {
const store = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
deleteByLogoutToken: vi.fn(),
}
auth0Server({ ...BASE, sessionStore: store })

expect(statelessArgs).toHaveLength(0)
expect(statefulArgs).toHaveLength(1)
const opts = statefulArgs[0] as Record<string, unknown>
expect(opts.store).toBe(store)
expect(opts.secret).toBe('x'.repeat(32))
})

it('forwards sessionConfiguration to the stateful store', () => {
const store = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
deleteByLogoutToken: vi.fn(),
}
auth0Server({
...BASE,
sessionStore: store,
sessionConfiguration: { absoluteDuration: 999 },
})
const opts = statefulArgs[0] as Record<string, unknown>
expect(opts.absoluteDuration).toBe(999)
})
})
121 changes: 121 additions & 0 deletions src/server/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import {
getSession,
getAccessToken,
getTokenSet,
createFetcher,
} from './session.js'
import { AccessTokenError } from '../errors/index.js'
import type { Auth0Instance } from './auth0-server.js'

// The session helpers delegate to auth0.client and the session-mapper. Mock the
// mapper so we assert the helpers' own behavior (delegation, shaping, errors)
// rather than re-testing mapping logic covered by session-mapper.test.ts.
vi.mock('./session-mapper.js', () => ({
toSessionData: vi.fn((session: unknown) => (session ? { mapped: 'session' } : null)),
toTokenSet: vi.fn((session: unknown) => (session ? { mapped: 'tokenSet' } : null)),
}))

function mockAuth0(over: Record<string, unknown> = {}): Auth0Instance {
return {
client: {
getSession: vi.fn(async () => ({ user: { sub: 'auth0|1' } })),
getAccessToken: vi.fn(async () => ({
accessToken: 'at',
expiresAt: 123,
scope: 'read:x',
})),
...over,
},
config: { audience: 'https://api.example.com' },
} as unknown as Auth0Instance
}

beforeEach(() => vi.clearAllMocks())

describe('getSession', () => {
it('maps the foundation session through toSessionData with the configured audience', async () => {
const auth0 = mockAuth0()
const result = await getSession(auth0)
expect(auth0.client.getSession).toHaveBeenCalled()
expect(result).toEqual({ mapped: 'session' })
})

it('returns null when there is no session', async () => {
const auth0 = mockAuth0({ getSession: vi.fn(async () => null) })
expect(await getSession(auth0)).toBeNull()
})
})

describe('getTokenSet', () => {
it('maps the session through toTokenSet', async () => {
const auth0 = mockAuth0()
expect(await getTokenSet(auth0)).toEqual({ mapped: 'tokenSet' })
})

it('returns null when there is no session', async () => {
const auth0 = mockAuth0({ getSession: vi.fn(async () => null) })
expect(await getTokenSet(auth0)).toBeNull()
})
})

describe('getAccessToken', () => {
it('returns the token, expiry, and scope from the foundation', async () => {
const auth0 = mockAuth0()
const res = await getAccessToken(auth0)
expect(res).toEqual({ token: 'at', expiresAt: 123, scope: 'read:x' })
})

it('forwards audience and scope options to the foundation', async () => {
const auth0 = mockAuth0()
await getAccessToken(auth0, { audience: 'https://other', scope: 'write:y' })
expect(auth0.client.getAccessToken).toHaveBeenCalledWith({
audience: 'https://other',
scope: 'write:y',
})
})

it('wraps a foundation failure in AccessTokenError with the cause', async () => {
const cause = new Error('no refresh token')
const auth0 = mockAuth0({
getAccessToken: vi.fn(async () => {
throw cause
}),
})
await expect(getAccessToken(auth0)).rejects.toBeInstanceOf(AccessTokenError)
await expect(getAccessToken(auth0)).rejects.toMatchObject({ cause })
})
})

describe('createFetcher', () => {
it('attaches the access token as a Bearer header on the request', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('ok'))
const auth0 = mockAuth0()

const fetcher = createFetcher(auth0, { audience: 'https://api.example.com' })
await fetcher('https://api.example.com/items')

const init = fetchSpy.mock.calls[0]![1] as RequestInit
const headers = new Headers(init.headers)
expect(headers.get('Authorization')).toBe('Bearer at')
fetchSpy.mockRestore()
})

it('propagates AccessTokenError and never calls fetch when the token cannot be obtained', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response())
const auth0 = mockAuth0({
getAccessToken: vi.fn(async () => {
throw new Error('expired')
}),
})

const fetcher = createFetcher(auth0)
await expect(fetcher('https://api.example.com/items')).rejects.toBeInstanceOf(
AccessTokenError,
)
expect(fetchSpy).not.toHaveBeenCalled()
fetchSpy.mockRestore()
})
})