mantimon-tcg/frontend/src/composables/useAuth.spec.ts
Cal Corum 3cc8d6645e Implement auth composables and starter selection (F1-003, F1-004, F1-005)
Features:
- Add useAuth composable with OAuth flow and token management
- Add useStarter composable with API integration and dev mock fallback
- Implement app auth initialization blocking navigation until ready
- Complete StarterSelectionPage with 5 themed deck options

Bug fixes:
- Fix CORS by adding localhost:3001 to allowed origins
- Fix OAuth URL to include redirect_uri parameter
- Fix emoji rendering in nav components (use actual chars, not escapes)
- Fix requireStarter guard timing by allowing navigation from /starter
- Fix starter "already selected" detection for 400 status code

Documentation:
- Update dev-server skill to use `docker compose` (newer CLI syntax)
- Update .env.example with port 3001 in CORS comment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:36:14 -06:00

956 lines
28 KiB
TypeScript

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
// Create mock router instance
const mockRouter = {
push: vi.fn(),
currentRoute: { value: { name: 'AuthCallback', path: '/auth/callback' } },
}
// Mock vue-router (hoisted)
vi.mock('vue-router', () => ({
useRouter: () => mockRouter,
useRoute: () => ({ query: {} }),
}))
// Mock the API client
vi.mock('@/api/client', () => ({
apiClient: {
get: vi.fn(),
},
}))
// Mock the config
vi.mock('@/config', () => ({
config: {
apiBaseUrl: 'http://localhost:8000',
wsUrl: 'http://localhost:8000',
oauthRedirectUri: 'http://localhost:5173/auth/callback',
isDev: true,
isProd: false,
},
}))
import { apiClient } from '@/api/client'
import { useAuth } from './useAuth'
describe('useAuth', () => {
let mockLocation: { hash: string; pathname: string; search: string; href: string }
beforeEach(() => {
setActivePinia(createPinia())
// Reset mock router
mockRouter.push.mockReset()
// Mock window.location with a property descriptor that allows href assignment
mockLocation = {
hash: '',
pathname: '/auth/callback',
search: '',
href: 'http://localhost:5173/auth/callback',
}
// Delete and redefine to avoid conflicts
delete (window as unknown as Record<string, unknown>).location
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
configurable: true,
})
// Mock history.replaceState
vi.spyOn(window.history, 'replaceState').mockImplementation(() => {})
// Mock fetch for logout calls
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
})
// Reset mocks
vi.mocked(apiClient.get).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initial state', () => {
it('starts with unauthenticated state', () => {
/**
* Test that useAuth starts in an unauthenticated state.
*
* Before OAuth flow or initialization completes, the composable
* should report that the user is not authenticated.
*/
const { isAuthenticated, isInitialized, user, error } = useAuth()
expect(isAuthenticated.value).toBe(false)
expect(isInitialized.value).toBe(false)
expect(user.value).toBeNull()
expect(error.value).toBeNull()
})
it('starts with isLoading as false', () => {
/**
* Test initial loading state.
*
* The composable should not be in a loading state until
* an async operation is initiated.
*/
const { isLoading } = useAuth()
expect(isLoading.value).toBe(false)
})
})
describe('initiateOAuth', () => {
it('redirects to Google OAuth URL with redirect_uri', () => {
/**
* Test Google OAuth initiation.
*
* When initiating Google OAuth, the browser should be redirected
* to the backend's Google OAuth endpoint with a redirect_uri param,
* which will then redirect to Google's consent screen.
*/
const { initiateOAuth } = useAuth()
initiateOAuth('google')
expect(mockLocation.href).toContain('/api/auth/google')
expect(mockLocation.href).toContain('redirect_uri=')
})
it('redirects to Discord OAuth URL with redirect_uri', () => {
/**
* Test Discord OAuth initiation.
*
* When initiating Discord OAuth, the browser should be redirected
* to the backend's Discord OAuth endpoint with a redirect_uri param,
* which will then redirect to Discord's consent screen.
*/
const { initiateOAuth } = useAuth()
initiateOAuth('discord')
expect(mockLocation.href).toContain('/api/auth/discord')
expect(mockLocation.href).toContain('redirect_uri=')
})
it('clears any existing error', () => {
/**
* Test error clearing on OAuth initiation.
*
* Starting a new OAuth flow should clear any previous errors
* so users don't see stale error messages.
*/
const auth = useAuth()
auth.initiateOAuth('google')
expect(auth.error.value).toBeNull()
})
})
describe('handleCallback', () => {
it('successfully extracts tokens from URL hash', async () => {
/**
* Test token extraction from OAuth callback.
*
* The OAuth provider redirects back with tokens in the URL fragment.
* handleCallback must parse these tokens and store them correctly.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(true)
expect(result.user?.id).toBe('user-1')
expect(result.user?.displayName).toBe('Test User')
})
it('stores tokens in auth store', async () => {
/**
* Test that tokens are persisted in the auth store.
*
* After extracting tokens, they must be stored in the auth store
* so they can be used for subsequent API requests.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
const { handleCallback } = useAuth()
await handleCallback()
const authStore = useAuthStore()
expect(authStore.accessToken).toBe('abc123')
expect(authStore.refreshToken).toBe('xyz789')
expect(authStore.expiresAt).toBeGreaterThan(Date.now())
})
it('fetches user profile after storing tokens', async () => {
/**
* Test profile fetch after token storage.
*
* Once tokens are stored, we need to fetch the user's profile
* to know their display name, avatar, and starter deck status.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: 'https://example.com/avatar.png',
has_starter_deck: true,
})
const { handleCallback } = useAuth()
await handleCallback()
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
const authStore = useAuthStore()
expect(authStore.user).toEqual({
id: 'user-1',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.png',
hasStarterDeck: true,
})
})
it('returns needsStarter=true for users without starter deck', async () => {
/**
* Test starter deck status in callback result.
*
* The callback result should indicate if the user needs to select
* a starter deck, allowing the caller to redirect appropriately.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'New User',
avatar_url: null,
has_starter_deck: false,
})
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(true)
expect(result.needsStarter).toBe(true)
})
it('returns needsStarter=false for users with starter deck', async () => {
/**
* Test starter deck status for existing users.
*
* Users who already have a starter deck should have needsStarter=false,
* allowing them to proceed directly to the dashboard.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Existing User',
avatar_url: null,
has_starter_deck: true,
})
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(true)
expect(result.needsStarter).toBe(false)
})
it('returns error for missing tokens in hash', async () => {
/**
* Test error handling for malformed OAuth response.
*
* If the URL fragment is missing required tokens, handleCallback
* should return an error result, not throw an exception.
*/
mockLocation.hash = '#access_token=abc123' // Missing refresh_token and expires_in
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toContain('Missing tokens')
})
it('returns error for empty hash', async () => {
/**
* Test error handling for empty hash fragment.
*
* If the OAuth provider redirects without any tokens,
* handleCallback should return an appropriate error.
*/
mockLocation.hash = ''
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
it('returns error from OAuth provider query params', async () => {
/**
* Test error forwarding from OAuth provider.
*
* When OAuth fails, the backend redirects with error info
* in query params. handleCallback should extract and return this.
*/
mockLocation.hash = ''
mockLocation.search = '?error=access_denied&message=User%20cancelled'
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toBe('User cancelled')
})
it('uses default error message when message param is missing', async () => {
/**
* Test fallback error message.
*
* If the backend only provides an error code without a message,
* we should use a sensible default.
*/
mockLocation.hash = ''
mockLocation.search = '?error=unknown_error'
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toBe('Authentication failed. Please try again.')
})
it('returns error when profile fetch fails', async () => {
/**
* Test error handling for profile fetch failures.
*
* If we successfully get tokens but fail to fetch the profile,
* handleCallback should return an error result.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'))
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toBe('Network error')
})
it('clears tokens from URL after parsing', async () => {
/**
* Test security cleanup of URL.
*
* Tokens in the URL should be cleared after parsing to prevent
* them from appearing in browser history or being logged.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
const { handleCallback } = useAuth()
await handleCallback()
expect(window.history.replaceState).toHaveBeenCalledWith(
null,
'',
'/auth/callback'
)
})
})
describe('logout', () => {
it('calls auth store logout with server revocation', async () => {
/**
* Test logout with server-side token revocation.
*
* When logging out, we should revoke the refresh token on the
* server to prevent it from being used if somehow compromised.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const logoutSpy = vi.spyOn(authStore, 'logout')
const { logout } = useAuth()
await logout()
expect(logoutSpy).toHaveBeenCalledWith(true)
})
it('clears auth state after logout', async () => {
/**
* Test state clearing after logout.
*
* All authentication state should be cleared so the user
* is properly logged out and cannot access protected resources.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
const { logout, isAuthenticated, user } = useAuth()
await logout(false) // Don't redirect in test
expect(isAuthenticated.value).toBe(false)
expect(user.value).toBeNull()
})
it('redirects to login by default', async () => {
/**
* Test default redirect behavior after logout.
*
* After logging out, users should be redirected to the login page
* by default so they can log in again if desired.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const { logout } = useAuth()
await logout()
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Login' })
})
it('skips redirect when redirectToLogin is false', async () => {
/**
* Test optional redirect suppression.
*
* Sometimes we want to log out without redirecting (e.g., when
* the user is already on the login page or when handling errors).
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const { logout } = useAuth()
await logout(false)
expect(mockRouter.push).not.toHaveBeenCalled()
})
})
describe('logoutAll', () => {
it('calls logout-all endpoint', async () => {
/**
* Test all-device logout API call.
*
* logoutAll should call the special endpoint that revokes
* all refresh tokens for the user, logging them out everywhere.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const { logoutAll } = useAuth()
await logoutAll(false)
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:8000/api/auth/logout-all',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Authorization': 'Bearer test-token',
}),
})
)
})
it('clears local state even if server call fails', async () => {
/**
* Test graceful handling of server errors.
*
* Even if the server call to revoke all tokens fails, we should
* still clear local state so the user is logged out locally.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
const { logoutAll, isAuthenticated } = useAuth()
await logoutAll(false)
expect(isAuthenticated.value).toBe(false)
})
it('redirects to login by default', async () => {
/**
* Test default redirect after all-device logout.
*
* After logging out from all devices, users should be
* redirected to the login page.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const { logoutAll } = useAuth()
await logoutAll()
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Login' })
})
})
describe('initialize', () => {
it('sets isInitialized to true after completion', async () => {
/**
* Test initialization completion flag.
*
* After initialize() completes, isInitialized should be true
* so navigation guards know it's safe to check auth state.
*/
const { initialize, isInitialized } = useAuth()
expect(isInitialized.value).toBe(false)
await initialize()
expect(isInitialized.value).toBe(true)
})
it('returns true when authenticated with valid tokens', async () => {
/**
* Test initialization with existing valid session.
*
* If tokens are already stored and valid, initialization should
* return true and keep the user logged in.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
const { initialize } = useAuth()
const result = await initialize()
expect(result).toBe(true)
})
it('returns false when not authenticated', async () => {
/**
* Test initialization without existing session.
*
* If no tokens are stored, initialization should return false
* indicating the user needs to log in.
*/
const { initialize } = useAuth()
const result = await initialize()
expect(result).toBe(false)
})
it('fetches profile if tokens exist but user is null', async () => {
/**
* Test profile fetch during initialization.
*
* If we have tokens persisted but no user data (e.g., after
* page reload), we should fetch the profile during init.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
// Note: user is NOT set
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
const { initialize } = useAuth()
await initialize()
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
expect(authStore.user).toEqual({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
})
it('logs out if profile fetch fails during initialization', async () => {
/**
* Test handling of invalid tokens during initialization.
*
* If tokens exist but profile fetch fails (invalid tokens, etc.),
* we should clear the invalid tokens and return false.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
vi.mocked(apiClient.get).mockRejectedValue(new Error('Unauthorized'))
const { initialize, isAuthenticated } = useAuth()
const result = await initialize()
expect(result).toBe(false)
expect(isAuthenticated.value).toBe(false)
})
it('only runs once (returns cached result on second call)', async () => {
/**
* Test initialization idempotency.
*
* Multiple calls to initialize() should only run the
* initialization logic once. Subsequent calls should
* return the same result immediately.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
const { initialize, isInitialized } = useAuth()
await initialize()
expect(isInitialized.value).toBe(true)
// Call again - should return immediately
const result = await initialize()
expect(result).toBe(true)
})
})
describe('fetchProfile', () => {
it('fetches and updates user profile', async () => {
/**
* Test manual profile refresh.
*
* Components may need to refresh the user profile (e.g., after
* updating display name). fetchProfile should update the store.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Updated Name',
avatar_url: 'https://example.com/new-avatar.png',
has_starter_deck: true,
})
const { fetchProfile } = useAuth()
const result = await fetchProfile()
expect(result.displayName).toBe('Updated Name')
expect(authStore.user?.displayName).toBe('Updated Name')
})
it('throws error when not authenticated', async () => {
/**
* Test unauthenticated profile fetch rejection.
*
* fetchProfile should throw an error if called when not
* authenticated, rather than making a doomed API call.
*/
const { fetchProfile } = useAuth()
await expect(fetchProfile()).rejects.toThrow('Not authenticated')
})
})
describe('clearError', () => {
it('clears the error state', async () => {
/**
* Test error clearing.
*
* After displaying an error, components may want to clear it
* (e.g., when user dismisses the error or tries again).
*/
// First cause an error
mockLocation.hash = ''
const { handleCallback, clearError, error } = useAuth()
await handleCallback()
// Error should be set
expect(error.value).toBeDefined()
// Clear it
clearError()
expect(error.value).toBeNull()
})
})
describe('loading states', () => {
it('isLoading is true during handleCallback', async () => {
/**
* Test loading state during callback processing.
*
* Components should show loading indicators while the callback
* is being processed (token storage, profile fetch).
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
// Create a deferred promise to control when the API responds
let resolveApi: (value: unknown) => void
vi.mocked(apiClient.get).mockImplementation(
() => new Promise((resolve) => { resolveApi = resolve })
)
const { handleCallback, isLoading } = useAuth()
// Start callback (don't await)
const promise = handleCallback()
// Should be loading
expect(isLoading.value).toBe(true)
// Resolve the API call
resolveApi!({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
await promise
// Should no longer be loading
expect(isLoading.value).toBe(false)
})
it('isLoading is true during logout', async () => {
/**
* Test loading state during logout.
*
* Components should disable logout buttons and show feedback
* while the logout operation is in progress.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
// Create a deferred promise
let resolveFetch: () => void
global.fetch = vi.fn().mockImplementation(
() => new Promise((resolve) => {
resolveFetch = () => resolve({ ok: true })
})
)
const { logout, isLoading } = useAuth()
// Start logout (don't await)
const promise = logout(false)
// Should be loading
expect(isLoading.value).toBe(true)
// Resolve the fetch
resolveFetch!()
await promise
// Should no longer be loading
expect(isLoading.value).toBe(false)
})
it('isLoading is true during initialize', async () => {
/**
* Test loading state during initialization.
*
* The app should show a loading screen while auth state
* is being initialized on startup.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
// Note: NOT setting user, so init will need to fetch profile
// Create a deferred promise that we control
let resolveApi: (value: unknown) => void = () => {}
const apiPromise = new Promise((resolve) => {
resolveApi = resolve
})
vi.mocked(apiClient.get).mockReturnValue(apiPromise as Promise<unknown>)
const { initialize, isLoading } = useAuth()
// Start init (don't await)
const promise = initialize()
// Wait a tick for async operations to start
await new Promise((r) => setTimeout(r, 0))
// Should be loading (needs to fetch profile)
expect(isLoading.value).toBe(true)
// Resolve the API call
resolveApi({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
await promise
// Should no longer be loading
expect(isLoading.value).toBe(false)
})
})
describe('computed properties', () => {
it('isAuthenticated reflects auth store state', () => {
/**
* Test isAuthenticated reactivity.
*
* The isAuthenticated computed should automatically update
* when the auth store's authentication state changes.
*/
const authStore = useAuthStore()
const { isAuthenticated } = useAuth()
expect(isAuthenticated.value).toBe(false)
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
expect(isAuthenticated.value).toBe(true)
})
it('user reflects auth store state', () => {
/**
* Test user computed reactivity.
*
* The user computed should automatically update when the
* auth store's user data changes.
*/
const authStore = useAuthStore()
const { user } = useAuth()
expect(user.value).toBeNull()
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
expect(user.value?.displayName).toBe('Test User')
})
it('error combines local and store errors', () => {
/**
* Test error state composition.
*
* The error computed should show local errors (from composable
* operations) or store errors (from token refresh, etc.).
*/
const authStore = useAuthStore()
const { error } = useAuth()
expect(error.value).toBeNull()
// Store error should be reflected
authStore.error = 'Store error'
expect(error.value).toBe('Store error')
})
})
})