mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-30 12:59:55 +00:00
Fixes a race causing “No auth header available for session creation” during sign‑in, by skipping the initial token refresh event, and wrapping extension auth hooks with async error handling. Sentry: https://comfy-org.sentry.io/issues/6990347926/?alert_rule_id=1614600&project=4509681221369857 Context - Error surfaced as an unhandled rejection when session creation was triggered without a valid auth header. - Triggers: both onAuthUserResolved and onAuthTokenRefreshed fired during initial login. - Pre‑fix, onIdTokenChanged treated the very first token emission as a “refresh” as well, so two concurrent createSession() calls ran back‑to‑back. - One of those calls could land before a Firebase ID token existed, so getAuthHeader() returned null → createSession threw “No auth header available for session creation”. Exact pre‑fix failure path - src/extensions/core/cloudSessionCookie.ts - onAuthUserResolved → useSessionCookie().createSession() - onAuthTokenRefreshed → useSessionCookie().createSession() - src/stores/firebaseAuthStore.ts - onIdTokenChanged increments tokenRefreshTrigger even for the initial token (treated as a refresh) - getAuthHeader() → getIdToken() may be undefined briefly during initialization - src/platform/auth/session/useSessionCookie.ts - createSession(): calls authStore.getAuthHeader(); if falsy, throws Error('No auth header available for session creation') What this PR changes 1) Skip initial token “refresh” - Track lastTokenUserId and ignore the first onIdTokenChanged for a user; only subsequent token changes count as refresh events. - File: src/stores/firebaseAuthStore.ts 2) Wrap extension auth hooks with async error handling - Use wrapWithErrorHandlingAsync for onAuthUserResolved/onAuthTokenRefreshed/onAuthUserLogout callbacks to avoid unhandled rejections. - File: src/services/extensionService.ts Result - Eliminates the timing window where createSession() runs before getIdToken() returns a token. - Ensures any remaining errors are caught and reported instead of surfacing as unhandled promise rejections. Notes - Lint and typecheck run clean (pnpm lint:fix && pnpm typecheck). ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6563-Fix-session-cookie-creation-race-dedupe-calls-skip-initial-token-refresh-wrap-extensio-2a16d73d365081ef8c22c5ac8cb948aa) by [Unito](https://www.unito.io)
546 lines
17 KiB
TypeScript
546 lines
17 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(),
|
|
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<typeof useFirebaseAuthStore>
|
|
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)
|
|
})
|
|
})
|
|
})
|