import { FirebaseError } from 'firebase/app' import * as firebaseAuth from 'firebase/auth' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import * as vuefire from 'vuefire' import { useDialogService } from '@/services/dialogService' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' // Mock fetch const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) // Mock successful API responses const mockCreateCustomerResponse = { ok: true, statusText: 'OK', json: () => Promise.resolve({ id: 'test-customer-id' }) } const mockFetchBalanceResponse = { ok: true, json: () => Promise.resolve({ balance: 0 }) } const mockAddCreditsResponse = { ok: true, statusText: 'OK' } const mockAccessBillingPortalResponse = { ok: true, statusText: 'OK' } vi.mock('vuefire', () => ({ useFirebaseAuth: vi.fn() })) vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }), createI18n: () => ({ global: { t: (key: string) => key } }) })) vi.mock('firebase/auth', async (importOriginal) => { const actual = await importOriginal() return { ...actual, signInWithEmailAndPassword: vi.fn(), createUserWithEmailAndPassword: vi.fn(), signOut: vi.fn(), onAuthStateChanged: vi.fn(), onIdTokenChanged: vi.fn(), signInWithPopup: vi.fn(), GoogleAuthProvider: class { addScope = vi.fn() setCustomParameters = vi.fn() }, GithubAuthProvider: class { addScope = vi.fn() setCustomParameters = vi.fn() }, setPersistence: vi.fn().mockResolvedValue(undefined) } }) // Mock useToastStore vi.mock('@/stores/toastStore', () => ({ useToastStore: () => ({ add: vi.fn() }) })) // Mock useDialogService vi.mock('@/services/dialogService') describe('useFirebaseAuthStore', () => { let store: ReturnType let authStateCallback: (user: any) => void let idTokenCallback: (user: any) => void const mockAuth = { /* mock Auth object */ } const mockUser = { uid: 'test-user-id', email: 'test@example.com', getIdToken: vi.fn().mockResolvedValue('mock-id-token') } beforeEach(() => { vi.resetAllMocks() // Setup dialog service mock vi.mocked(useDialogService, { partial: true }).mockReturnValue({ showSettingsDialog: vi.fn(), showErrorDialog: vi.fn() }) // Mock useFirebaseAuth to return our mock auth object vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any) // Mock onAuthStateChanged to capture the callback and simulate initial auth state vi.mocked(firebaseAuth.onAuthStateChanged).mockImplementation( (_, callback) => { authStateCallback = callback as (user: any) => void // Call the callback with our mock user ;(callback as (user: any) => void)(mockUser) // Return an unsubscribe function return vi.fn() } ) // Mock fetch responses mockFetch.mockImplementation((url: string) => { if (url.endsWith('/customers')) { return Promise.resolve(mockCreateCustomerResponse) } if (url.endsWith('/customers/balance')) { return Promise.resolve(mockFetchBalanceResponse) } if (url.endsWith('/customers/credit')) { return Promise.resolve(mockAddCreditsResponse) } if (url.endsWith('/customers/billing-portal')) { return Promise.resolve(mockAccessBillingPortalResponse) } return Promise.reject(new Error('Unexpected API call')) }) // Initialize Pinia setActivePinia(createPinia()) store = useFirebaseAuthStore() // Reset and set up getIdToken mock mockUser.getIdToken.mockReset() mockUser.getIdToken.mockResolvedValue('mock-id-token') }) describe('token refresh events', () => { beforeEach(async () => { vi.resetModules() vi.doMock('@/platform/distribution/types', () => ({ isCloud: true, isDesktop: true })) vi.mocked(firebaseAuth.onIdTokenChanged).mockImplementation( (_auth, callback) => { idTokenCallback = callback as (user: any) => void return vi.fn() } ) vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any) setActivePinia(createPinia()) const storeModule = await import('@/stores/firebaseAuthStore') store = storeModule.useFirebaseAuthStore() }) it("should not increment tokenRefreshTrigger on the user's first ID token event", () => { idTokenCallback?.(mockUser) expect(store.tokenRefreshTrigger).toBe(0) }) it('should increment tokenRefreshTrigger on subsequent ID token events for the same user', () => { idTokenCallback?.(mockUser) idTokenCallback?.(mockUser) expect(store.tokenRefreshTrigger).toBe(1) }) it('should not increment when ID token event is for a different user UID', () => { const otherUser = { uid: 'other-user-id' } idTokenCallback?.(mockUser) idTokenCallback?.(otherUser) expect(store.tokenRefreshTrigger).toBe(0) }) it('should increment after switching to a new UID and receiving a second event for that UID', () => { const otherUser = { uid: 'other-user-id' } idTokenCallback?.(mockUser) idTokenCallback?.(otherUser) idTokenCallback?.(otherUser) expect(store.tokenRefreshTrigger).toBe(1) }) }) it('should initialize with the current user', () => { expect(store.currentUser).toEqual(mockUser) expect(store.isAuthenticated).toBe(true) expect(store.userEmail).toBe('test@example.com') expect(store.userId).toBe('test-user-id') expect(store.loading).toBe(false) }) it('should set persistence to local storage on initialization', () => { expect(firebaseAuth.setPersistence).toHaveBeenCalledWith( mockAuth, firebaseAuth.browserLocalPersistence ) }) it('should properly clean up error state between operations', async () => { // First, cause an error const mockError = new Error('Invalid password') vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockRejectedValueOnce( mockError ) try { await store.login('test@example.com', 'wrong-password') } catch (e) { // Error expected } // Now, succeed on next attempt vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValueOnce({ user: mockUser } as any) await store.login('test@example.com', 'correct-password') }) describe('login', () => { it('should login with valid credentials', async () => { const mockUserCredential = { user: mockUser } vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue( mockUserCredential as any ) const result = await store.login('test@example.com', 'password') expect(firebaseAuth.signInWithEmailAndPassword).toHaveBeenCalledWith( mockAuth, 'test@example.com', 'password' ) expect(result).toEqual(mockUserCredential) expect(store.loading).toBe(false) }) it('should handle login errors', async () => { const mockError = new Error('Invalid password') vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockRejectedValue( mockError ) await expect( store.login('test@example.com', 'wrong-password') ).rejects.toThrow('Invalid password') expect(firebaseAuth.signInWithEmailAndPassword).toHaveBeenCalledWith( mockAuth, 'test@example.com', 'wrong-password' ) expect(store.loading).toBe(false) }) it('should handle concurrent login attempts correctly', async () => { // Set up multiple login promises const mockUserCredential = { user: mockUser } vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue( mockUserCredential as any ) const loginPromise1 = store.login('user1@example.com', 'password1') const loginPromise2 = store.login('user2@example.com', 'password2') // Resolve both promises await Promise.all([loginPromise1, loginPromise2]) // Verify the loading state is reset expect(store.loading).toBe(false) }) }) describe('register', () => { it('should register a new user', async () => { const mockUserCredential = { user: mockUser } vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue( mockUserCredential as any ) const result = await store.register('new@example.com', 'password') expect(firebaseAuth.createUserWithEmailAndPassword).toHaveBeenCalledWith( mockAuth, 'new@example.com', 'password' ) expect(result).toEqual(mockUserCredential) expect(store.loading).toBe(false) }) it('should handle registration errors', async () => { const mockError = new Error('Email already in use') vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockRejectedValue( mockError ) await expect( store.register('existing@example.com', 'password') ).rejects.toThrow('Email already in use') expect(firebaseAuth.createUserWithEmailAndPassword).toHaveBeenCalledWith( mockAuth, 'existing@example.com', 'password' ) expect(store.loading).toBe(false) }) }) describe('logout', () => { it('should sign out the user', async () => { vi.mocked(firebaseAuth.signOut).mockResolvedValue(undefined) await store.logout() expect(firebaseAuth.signOut).toHaveBeenCalledWith(mockAuth) }) it('should handle logout errors', async () => { const mockError = new Error('Network error') vi.mocked(firebaseAuth.signOut).mockRejectedValue(mockError) await expect(store.logout()).rejects.toThrow('Network error') expect(firebaseAuth.signOut).toHaveBeenCalledWith(mockAuth) }) }) describe('getIdToken', () => { it('should return the user ID token', async () => { // FIX 2: Reset the mock and set a specific return value mockUser.getIdToken.mockReset() mockUser.getIdToken.mockResolvedValue('mock-id-token') const token = await store.getIdToken() expect(mockUser.getIdToken).toHaveBeenCalled() expect(token).toBe('mock-id-token') }) it('should return null when no user is logged in', async () => { // Simulate logged out state authStateCallback(null) const token = await store.getIdToken() expect(token).toBeUndefined() }) it('should return null for token after login and logout sequence', async () => { // Setup mock for login const mockUserCredential = { user: mockUser } vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue( mockUserCredential as any ) // Login await store.login('test@example.com', 'password') // Simulate successful auth state update after login authStateCallback(mockUser) // Verify we're logged in and can get a token mockUser.getIdToken.mockReset() mockUser.getIdToken.mockResolvedValue('mock-id-token') expect(await store.getIdToken()).toBe('mock-id-token') // Setup mock for logout vi.mocked(firebaseAuth.signOut).mockResolvedValue(undefined) // Logout await store.logout() // Simulate successful auth state update after logout authStateCallback(null) // Verify token is null after logout const tokenAfterLogout = await store.getIdToken() expect(tokenAfterLogout).toBeUndefined() }) it('should handle network errors gracefully when offline (reproduces issue #4468)', async () => { // This test reproduces the issue where Firebase Auth makes network requests when offline // and fails without graceful error handling, causing toast error messages // Simulate a user with an expired token that requires network refresh mockUser.getIdToken.mockReset() // Mock network failure (auth/network-request-failed error from Firebase) const networkError = new FirebaseError( firebaseAuth.AuthErrorCodes.NETWORK_REQUEST_FAILED, 'mock error' ) mockUser.getIdToken.mockRejectedValue(networkError) const token = await store.getIdToken() expect(token).toBeUndefined() // Should return undefined instead of throwing }) it('should show error dialog when getIdToken fails with non-network error', async () => { // This test verifies that non-network errors trigger the error dialog mockUser.getIdToken.mockReset() // Mock a non-network error using actual Firebase Auth error code const authError = new FirebaseError( firebaseAuth.AuthErrorCodes.USER_DISABLED, 'User account is disabled.' ) mockUser.getIdToken.mockRejectedValue(authError) // Should call the error dialog instead of throwing const token = await store.getIdToken() const dialogService = useDialogService() expect(dialogService.showErrorDialog).toHaveBeenCalledWith(authError, { title: 'errorDialog.defaultTitle', reportType: 'authenticationError' }) expect(token).toBeUndefined() }) }) describe('getAuthHeader', () => { it('should handle network errors gracefully when getting Firebase token (reproduces issue #4468)', async () => { // This test reproduces the issue where getAuthHeader fails due to network errors // when Firebase Auth tries to refresh tokens offline // Mock useApiKeyAuthStore to return null (no API key fallback) const mockApiKeyStore = { getAuthHeader: vi.fn().mockReturnValue(null) } vi.doMock('@/stores/apiKeyAuthStore', () => ({ useApiKeyAuthStore: () => mockApiKeyStore })) // Setup user with network error on token refresh mockUser.getIdToken.mockReset() const networkError = new FirebaseError( firebaseAuth.AuthErrorCodes.NETWORK_REQUEST_FAILED, 'mock error' ) mockUser.getIdToken.mockRejectedValue(networkError) const authHeader = await store.getAuthHeader() expect(authHeader).toBeNull() // Should fallback gracefully }) }) describe('social authentication', () => { describe('loginWithGoogle', () => { it('should sign in with Google', async () => { const mockUserCredential = { user: mockUser } vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue( mockUserCredential as any ) const result = await store.loginWithGoogle() expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith( mockAuth, expect.any(firebaseAuth.GoogleAuthProvider) ) expect(result).toEqual(mockUserCredential) expect(store.loading).toBe(false) }) it('should handle Google sign in errors', async () => { const mockError = new Error('Google authentication failed') vi.mocked(firebaseAuth.signInWithPopup).mockRejectedValue(mockError) await expect(store.loginWithGoogle()).rejects.toThrow( 'Google authentication failed' ) expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith( mockAuth, expect.any(firebaseAuth.GoogleAuthProvider) ) expect(store.loading).toBe(false) }) }) describe('loginWithGithub', () => { it('should sign in with Github', async () => { const mockUserCredential = { user: mockUser } vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue( mockUserCredential as any ) const result = await store.loginWithGithub() expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith( mockAuth, expect.any(firebaseAuth.GithubAuthProvider) ) expect(result).toEqual(mockUserCredential) expect(store.loading).toBe(false) }) it('should handle Github sign in errors', async () => { const mockError = new Error('Github authentication failed') vi.mocked(firebaseAuth.signInWithPopup).mockRejectedValue(mockError) await expect(store.loginWithGithub()).rejects.toThrow( 'Github authentication failed' ) expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith( mockAuth, expect.any(firebaseAuth.GithubAuthProvider) ) expect(store.loading).toBe(false) }) }) it('should handle concurrent social login attempts correctly', async () => { const mockUserCredential = { user: mockUser } vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue( mockUserCredential as any ) const googleLoginPromise = store.loginWithGoogle() const githubLoginPromise = store.loginWithGithub() await Promise.all([googleLoginPromise, githubLoginPromise]) expect(store.loading).toBe(false) }) }) })