mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-30 12:59:55 +00:00
* [fix] gracefully handle Firebase auth failure * [test] Add failing tests to reproduce Firebase Auth network issue #4468 Add test cases that demonstrate the current problematic behavior where Firebase Auth makes network requests when offline without graceful error handling, causing toast error messages and degraded offline experience. Tests reproduce: - getIdToken() throwing auth/network-request-failed instead of returning null - getAuthHeader() failing to fallback gracefully when Firebase token refresh fails These tests currently pass by expecting the error to be thrown. After implementing the fix, the tests should be updated to verify graceful handling (returning null instead of throwing). Related to issue #4468: Firebase Auth makes network requests when offline without evicting token 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * [test] update firebaseAuthStore tests They match the behavior of the implemented solution now * [test] add firebaseAuthStore.getTokenId test for non-network errors * [chore] code review feedback * [test] use FirebaseError Co-authored-by: Alexander Brown <drjkl@comfy.org> * [fix] remove indentation and fix test --------- Co-authored-by: snomiao <snomiao@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Alexander Brown <drjkl@comfy.org>
493 lines
15 KiB
TypeScript
493 lines
15 KiB
TypeScript
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<typeof import('firebase/auth')>()
|
|
return {
|
|
...actual,
|
|
signInWithEmailAndPassword: vi.fn(),
|
|
createUserWithEmailAndPassword: vi.fn(),
|
|
signOut: vi.fn(),
|
|
onAuthStateChanged: vi.fn(),
|
|
signInWithPopup: vi.fn(),
|
|
GoogleAuthProvider: class {
|
|
setCustomParameters = vi.fn()
|
|
},
|
|
GithubAuthProvider: class {
|
|
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<typeof useFirebaseAuthStore>
|
|
let authStateCallback: (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')
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
})
|