/** * Auth Store Tests * * Tests for authentication state management, Discord OAuth, and JWT token handling. */ // IMPORTANT: Mock process.env BEFORE any other imports to fix Pinia ;(globalThis as any).process = { ...((globalThis as any).process || {}), env: { ...((globalThis as any).process?.env || {}), NODE_ENV: 'test', }, client: true, } import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { useAuthStore } from '~/store/auth' import type { DiscordUser, Team } from '~/types' // Mock $fetch global.$fetch = vi.fn() // Mock useRuntimeConfig vi.mock('#app', () => ({ useRuntimeConfig: vi.fn(() => ({ public: { apiUrl: 'http://localhost:8000', discordClientId: 'test-client-id', discordRedirectUri: 'http://localhost:3000/auth/callback', }, })), navigateTo: vi.fn(), })) describe('useAuthStore', () => { let mockLocalStorage: { [key: string]: string } let mockSessionStorage: { [key: string]: string } beforeEach(() => { // Create fresh Pinia instance for each test setActivePinia(createPinia()) // Mock localStorage mockLocalStorage = {} global.localStorage = { getItem: vi.fn((key: string) => mockLocalStorage[key] || null), setItem: vi.fn((key: string, value: string) => { mockLocalStorage[key] = value }), removeItem: vi.fn((key: string) => { delete mockLocalStorage[key] }), clear: vi.fn(() => { mockLocalStorage = {} }), length: 0, key: vi.fn(), } as any // Mock sessionStorage mockSessionStorage = {} global.sessionStorage = { getItem: vi.fn((key: string) => mockSessionStorage[key] || null), setItem: vi.fn((key: string, value: string) => { mockSessionStorage[key] = value }), removeItem: vi.fn((key: string) => { delete mockSessionStorage[key] }), clear: vi.fn(() => { mockSessionStorage = {} }), length: 0, key: vi.fn(), } as any // Mock window.location delete (global.window as any).location global.window.location = { href: '' } as any // Mock process.client ;(global as any).process = { client: true } // Clear all mocks vi.clearAllMocks() }) afterEach(() => { vi.restoreAllMocks() }) describe('initialization', () => { it('initializes with null/empty state', () => { const store = useAuthStore() expect(store.token).toBeNull() expect(store.refreshToken).toBeNull() expect(store.user).toBeNull() expect(store.teams).toEqual([]) expect(store.isLoading).toBe(false) expect(store.error).toBeNull() }) it('has correct computed properties on init', () => { const store = useAuthStore() expect(store.isAuthenticated).toBe(false) expect(store.isTokenValid).toBe(false) expect(store.needsRefresh).toBe(false) expect(store.currentUser).toBeNull() expect(store.userTeams).toEqual([]) expect(store.userId).toBeNull() }) it('loads auth state from localStorage on init', () => { const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } const mockTeams: Team[] = [ { id: 1, league_id: 'sba', name: 'Test Team', abbreviation: 'TEST', owner_discord_id: '123', }, ] // Pre-populate localStorage mockLocalStorage['auth_token'] = 'stored-token' mockLocalStorage['refresh_token'] = 'stored-refresh' mockLocalStorage['token_expires_at'] = (Date.now() + 3600000).toString() mockLocalStorage['user'] = JSON.stringify(mockUser) mockLocalStorage['teams'] = JSON.stringify(mockTeams) const store = useAuthStore() store.initializeAuth() expect(store.token).toBe('stored-token') expect(store.refreshToken).toBe('stored-refresh') expect(store.user).toEqual(mockUser) expect(store.teams).toEqual(mockTeams) expect(store.isAuthenticated).toBe(true) }) }) describe('authentication state management', () => { it('sets auth data and persists to localStorage', () => { const store = useAuthStore() const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } store.setAuth({ access_token: 'access-token-123', refresh_token: 'refresh-token-456', expires_in: 3600, user: mockUser, }) expect(store.token).toBe('access-token-123') expect(store.refreshToken).toBe('refresh-token-456') expect(store.user).toEqual(mockUser) expect(store.isAuthenticated).toBe(true) expect(store.error).toBeNull() // Verify localStorage persistence expect(localStorage.setItem).toHaveBeenCalledWith('auth_token', 'access-token-123') expect(localStorage.setItem).toHaveBeenCalledWith('refresh_token', 'refresh-token-456') expect(localStorage.setItem).toHaveBeenCalledWith('user', JSON.stringify(mockUser)) }) it('sets teams and persists to localStorage', () => { const store = useAuthStore() const mockTeams: Team[] = [ { id: 1, league_id: 'sba', name: 'Team A', abbreviation: 'TMA', owner_discord_id: '123', }, { id: 2, league_id: 'pd', name: 'Team B', abbreviation: 'TMB', owner_discord_id: '123', }, ] store.setTeams(mockTeams) expect(store.teams).toEqual(mockTeams) expect(store.userTeams).toEqual(mockTeams) expect(localStorage.setItem).toHaveBeenCalledWith('teams', JSON.stringify(mockTeams)) }) it('clears auth data and localStorage on logout', () => { const store = useAuthStore() // Set up auth state const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } store.setAuth({ access_token: 'token', refresh_token: 'refresh', expires_in: 3600, user: mockUser, }) expect(store.isAuthenticated).toBe(true) // Clear auth store.clearAuth() expect(store.token).toBeNull() expect(store.refreshToken).toBeNull() expect(store.user).toBeNull() expect(store.teams).toEqual([]) expect(store.error).toBeNull() expect(store.isAuthenticated).toBe(false) // Verify localStorage cleared expect(localStorage.removeItem).toHaveBeenCalledWith('auth_token') expect(localStorage.removeItem).toHaveBeenCalledWith('refresh_token') expect(localStorage.removeItem).toHaveBeenCalledWith('user') expect(localStorage.removeItem).toHaveBeenCalledWith('teams') }) }) describe('token validation', () => { it('computes isTokenValid correctly when token is valid', () => { const store = useAuthStore() const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } // Token expires in 1 hour store.setAuth({ access_token: 'token', refresh_token: 'refresh', expires_in: 3600, user: mockUser, }) expect(store.isTokenValid).toBe(true) }) it('computes isTokenValid as false when token is expired', () => { const store = useAuthStore() const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } // Token expires in -1 second (already expired) store.setAuth({ access_token: 'token', refresh_token: 'refresh', expires_in: -1, user: mockUser, }) expect(store.isTokenValid).toBe(false) }) it('computes needsRefresh when token expires soon', () => { const store = useAuthStore() const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } // Token expires in 4 minutes (should trigger refresh at 5 min threshold) store.setAuth({ access_token: 'token', refresh_token: 'refresh', expires_in: 240, // 4 minutes user: mockUser, }) expect(store.needsRefresh).toBe(true) }) it('does not need refresh when token has plenty of time', () => { const store = useAuthStore() const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } // Token expires in 10 minutes store.setAuth({ access_token: 'token', refresh_token: 'refresh', expires_in: 600, user: mockUser, }) expect(store.needsRefresh).toBe(false) }) }) describe('token refresh', () => { it('refreshes access token successfully', async () => { const store = useAuthStore() const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } // Set initial auth with refresh token store.setAuth({ access_token: 'old-token', refresh_token: 'refresh-token', expires_in: 3600, user: mockUser, }) // Mock successful refresh response vi.mocked($fetch).mockResolvedValueOnce({ access_token: 'new-token', expires_in: 3600, }) const result = await store.refreshAccessToken() expect(result).toBe(true) expect(store.token).toBe('new-token') expect(store.refreshToken).toBe('refresh-token') // Unchanged expect(store.isAuthenticated).toBe(true) expect(localStorage.setItem).toHaveBeenCalledWith('auth_token', 'new-token') }) it('clears auth on failed token refresh', async () => { const store = useAuthStore() const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } store.setAuth({ access_token: 'old-token', refresh_token: 'refresh-token', expires_in: 3600, user: mockUser, }) // Mock failed refresh vi.mocked($fetch).mockRejectedValueOnce(new Error('Refresh failed')) const result = await store.refreshAccessToken() expect(result).toBe(false) expect(store.token).toBeNull() expect(store.user).toBeNull() expect(store.isAuthenticated).toBe(false) expect(store.error).toBe('Refresh failed') }) it('does not refresh if no refresh token exists', async () => { const store = useAuthStore() const result = await store.refreshAccessToken() expect(result).toBe(false) expect($fetch).not.toHaveBeenCalled() expect(store.isAuthenticated).toBe(false) }) }) describe('Discord OAuth flow', () => { it('generates OAuth URL with correct parameters', () => { const store = useAuthStore() store.loginWithDiscord() // Check sessionStorage for OAuth state expect(sessionStorage.setItem).toHaveBeenCalledWith('oauth_state', expect.any(String)) // Check redirect URL expect(window.location.href).toContain('https://discord.com/api/oauth2/authorize') expect(window.location.href).toContain('client_id=test-client-id') expect(window.location.href).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fcallback') expect(window.location.href).toContain('response_type=code') expect(window.location.href).toContain('scope=identify+email') expect(window.location.href).toContain('state=') }) it('handles Discord callback successfully', async () => { const store = useAuthStore() // Set up OAuth state mockSessionStorage['oauth_state'] = 'test-state-123' const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } // Mock successful callback response vi.mocked($fetch).mockResolvedValueOnce({ access_token: 'discord-token', refresh_token: 'discord-refresh', expires_in: 3600, user: mockUser, }) const result = await store.handleDiscordCallback('auth-code', 'test-state-123') expect(result).toBe(true) expect(store.isAuthenticated).toBe(true) expect(store.user).toEqual(mockUser) expect(sessionStorage.removeItem).toHaveBeenCalledWith('oauth_state') }) it('rejects callback with invalid state (CSRF protection)', async () => { const store = useAuthStore() // Set up different OAuth state mockSessionStorage['oauth_state'] = 'correct-state' const result = await store.handleDiscordCallback('auth-code', 'wrong-state') expect(result).toBe(false) expect(store.error).toBe('Invalid OAuth state - possible CSRF attack') expect($fetch).not.toHaveBeenCalled() expect(store.isAuthenticated).toBe(false) }) it('handles Discord callback failure', async () => { const store = useAuthStore() mockSessionStorage['oauth_state'] = 'test-state' // Mock failed callback vi.mocked($fetch).mockRejectedValueOnce(new Error('OAuth failed')) const result = await store.handleDiscordCallback('auth-code', 'test-state') expect(result).toBe(false) expect(store.error).toBe('OAuth failed') expect(store.isAuthenticated).toBe(false) }) }) describe('user teams loading', () => { it('loads user teams from API', async () => { const store = useAuthStore() const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } store.setAuth({ access_token: 'token', refresh_token: 'refresh', expires_in: 3600, user: mockUser, }) const mockTeams: Team[] = [ { id: 1, league_id: 'sba', name: 'Team A', abbreviation: 'TMA', owner_discord_id: '123', }, ] vi.mocked($fetch).mockResolvedValueOnce({ teams: mockTeams }) await store.loadUserTeams() expect(store.teams).toEqual(mockTeams) expect($fetch).toHaveBeenCalledWith( 'http://localhost:8000/api/auth/me', expect.objectContaining({ headers: { Authorization: 'Bearer token', }, }) ) }) it('does not load teams if not authenticated', async () => { const store = useAuthStore() await store.loadUserTeams() expect($fetch).not.toHaveBeenCalled() expect(store.teams).toEqual([]) }) it('handles teams loading failure gracefully', async () => { const store = useAuthStore() const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } store.setAuth({ access_token: 'token', refresh_token: 'refresh', expires_in: 3600, user: mockUser, }) vi.mocked($fetch).mockRejectedValueOnce(new Error('Failed to load teams')) // Should not crash or set error await store.loadUserTeams() expect(store.teams).toEqual([]) expect(store.error).toBeNull() // Teams are optional }) }) describe('logout', () => { it('clears auth and navigates to home', () => { const store = useAuthStore() const { navigateTo } = require('#app') const mockUser: DiscordUser = { id: '123', username: 'testuser', discriminator: '0001', avatar: 'avatar-url', email: 'test@example.com', } store.setAuth({ access_token: 'token', refresh_token: 'refresh', expires_in: 3600, user: mockUser, }) store.logout() expect(store.isAuthenticated).toBe(false) expect(store.token).toBeNull() expect(navigateTo).toHaveBeenCalledWith('/') }) }) })