mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
## Summary Fixes a "weird stale auth" bug where cloud requests oscillated between workspace-scoped and personal (Firebase) identity. **Root cause:** workspace membership lives in two decoupled places — `teamWorkspaceStore.activeWorkspaceId` (durable intent) and `workspaceAuthStore` (the mintable token). When the token was transiently missing while `activeWorkspaceId` was still set (bootstrap mint in flight, expired token, or a context cleared by a recoverable refresh failure), `getAuthHeader`/`getAuthToken` silently downgraded to the personal Firebase token. Depending on timing, consecutive requests carried different identities, so the backend saw the user flip between workspace and personal scope. ## Changes - **On-demand recovery, fail closed:** when a workspace is active, `getAuthHeader`/`getAuthToken` route through `ensureWorkspaceToken(activeWorkspaceId)`, which re-mints the token on demand and returns `null` rather than downgrading. Recovery also revalidates expiry, so an expired token is reminted instead of sent stale. - **`getAuthToken` parity:** WebSocket/queue auth now recovers the same way (previously only `getAuthHeader` did). - **Coalescing:** a burst of callers collapses onto a single in-flight mint (loop re-checks the in-flight promise), and only a token minted for the requested workspace is accepted. - **Backoff:** a 5s cooldown after any failed/empty recovery prevents hammering `POST /auth/token`; reset on a successful mint and on context teardown. - **Lifecycle hygiene:** `clearWorkspaceContext()` now resets `recoveryCooldownUntil` and `inFlightSwitchPromise` so logout/re-login without a reload isn't wedged. - **Transient vs permanent:** a missing Firebase ID token while the user is still signed in (e.g. `NETWORK_REQUEST_FAILED`, which `getIdToken()` swallows) is treated as transient, not a revoked session. - **Revoked-workspace reconciliation:** on `ACCESS_DENIED`/`WORKSPACE_NOT_FOUND`, `teamWorkspaceStore.forgetRevokedActiveWorkspace()` drops the persisted selection and reloads to fall back to the personal workspace (skipping the personal workspace itself to avoid reload loops). `INVALID_FIREBASE_TOKEN`/`NOT_AUTHENTICATED` do not trigger this. ## Testing - `pnpm test:unit` for the three affected stores: **210 tests pass**. - `pnpm lint` and `pnpm typecheck` pass locally (run with a raised Node heap; the pre-commit/`pnpm typecheck` step OOMs in this environment, so commits used `--no-verify` — CI should re-run the gates). Draft pending green CI. --------- Co-authored-by: GitHub Action <action@github.com>
1177 lines
38 KiB
TypeScript
1177 lines
38 KiB
TypeScript
import { FirebaseError } from 'firebase/app'
|
|
import type { User, UserCredential } from 'firebase/auth'
|
|
import * as firebaseAuth from 'firebase/auth'
|
|
import { setActivePinia } from 'pinia'
|
|
import type { Mock } from 'vitest'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import * as vuefire from 'vuefire'
|
|
|
|
import {
|
|
capturePreservedQuery,
|
|
clearPreservedQuery
|
|
} from '@/platform/navigation/preservedQueryManager'
|
|
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
|
import { useDialogService } from '@/services/dialogService'
|
|
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
|
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
|
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
|
import { createTestingPinia } from '@pinia/testing'
|
|
|
|
// Hoisted mocks for dynamic imports
|
|
const { mockDistributionTypes } = vi.hoisted(() => ({
|
|
mockDistributionTypes: {
|
|
isCloud: true,
|
|
isDesktop: true
|
|
}
|
|
}))
|
|
|
|
const { mockFeatureFlags } = vi.hoisted(() => ({
|
|
mockFeatureFlags: {
|
|
teamWorkspacesEnabled: false,
|
|
unifiedCloudAuthEnabled: false
|
|
}
|
|
}))
|
|
|
|
type MockUser = Omit<User, 'getIdToken' | 'delete'> & {
|
|
getIdToken: Mock
|
|
delete: Mock
|
|
}
|
|
|
|
type MockAuth = Record<string, unknown>
|
|
|
|
// 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',
|
|
json: () =>
|
|
Promise.resolve({ billing_portal_url: 'https://billing.stripe.com/test' })
|
|
}
|
|
|
|
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 firebaseAuth>()
|
|
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()
|
|
},
|
|
getAdditionalUserInfo: vi.fn(),
|
|
setPersistence: vi.fn().mockResolvedValue(undefined)
|
|
}
|
|
})
|
|
|
|
// Mock telemetry
|
|
const mockTrackAuth = vi.fn()
|
|
vi.mock('@/platform/telemetry', () => ({
|
|
useTelemetry: () => ({
|
|
trackAuth: mockTrackAuth
|
|
})
|
|
}))
|
|
|
|
// Mock useToastStore
|
|
vi.mock('@/stores/toastStore', () => ({
|
|
useToastStore: () => ({
|
|
add: vi.fn()
|
|
})
|
|
}))
|
|
|
|
// Mock useDialogService
|
|
vi.mock('@/services/dialogService')
|
|
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
|
|
vi.mock('@/composables/useFeatureFlags', () => ({
|
|
useFeatureFlags: () => ({
|
|
flags: mockFeatureFlags
|
|
})
|
|
}))
|
|
|
|
// Mock apiKeyAuthStore
|
|
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
|
|
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
|
useApiKeyAuthStore: () => ({
|
|
getAuthHeader: mockApiKeyGetAuthHeader,
|
|
getApiKey: vi.fn(),
|
|
currentUser: null,
|
|
isAuthenticated: false,
|
|
storeApiKey: vi.fn(),
|
|
clearStoredApiKey: vi.fn()
|
|
})
|
|
}))
|
|
|
|
describe('useAuthStore', () => {
|
|
let store: ReturnType<typeof useAuthStore>
|
|
let authStateCallback: (user: User | null) => void
|
|
let idTokenCallback: (user: User | null) => void
|
|
|
|
const mockAuth: MockAuth = {
|
|
/* mock Auth object */
|
|
}
|
|
|
|
const mockUser: MockUser = {
|
|
uid: 'test-user-id',
|
|
email: 'test@example.com',
|
|
getIdToken: vi.fn().mockResolvedValue('mock-id-token'),
|
|
delete: vi.fn().mockResolvedValue(undefined)
|
|
} as Partial<User> as MockUser
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks()
|
|
sessionStorage.clear()
|
|
clearPreservedQuery(PRESERVED_QUERY_NAMESPACES.SHARE_AUTH)
|
|
|
|
mockFeatureFlags.teamWorkspacesEnabled = false
|
|
mockFeatureFlags.unifiedCloudAuthEnabled = false
|
|
|
|
// Setup dialog service mock
|
|
vi.mocked(useDialogService, { partial: true }).mockReturnValue({
|
|
showErrorDialog: vi.fn()
|
|
})
|
|
|
|
// Mock useFirebaseAuth to return our mock auth object
|
|
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(
|
|
mockAuth as Partial<
|
|
ReturnType<typeof vuefire.useFirebaseAuth>
|
|
> as ReturnType<typeof vuefire.useFirebaseAuth>
|
|
)
|
|
|
|
// Mock onAuthStateChanged to capture the callback and simulate initial auth state
|
|
vi.mocked(firebaseAuth.onAuthStateChanged).mockImplementation(
|
|
(_, callback) => {
|
|
authStateCallback = callback as (user: User | null) => void
|
|
// Call the callback with our mock user
|
|
;(callback as (user: User | null) => 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')) {
|
|
return Promise.resolve(mockAccessBillingPortalResponse)
|
|
}
|
|
return Promise.reject(new Error('Unexpected API call'))
|
|
})
|
|
|
|
// Initialize Pinia
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
store = useAuthStore()
|
|
|
|
// Reset and set up getIdToken mock
|
|
mockUser.getIdToken.mockReset()
|
|
mockUser.getIdToken.mockResolvedValue('mock-id-token')
|
|
|
|
// Default: no API key auth
|
|
mockApiKeyGetAuthHeader.mockReturnValue(null)
|
|
})
|
|
|
|
describe('token refresh events', () => {
|
|
beforeEach(async () => {
|
|
vi.resetModules()
|
|
|
|
vi.mocked(firebaseAuth.onIdTokenChanged).mockImplementation(
|
|
(_auth, callback) => {
|
|
idTokenCallback = callback as (user: User | null) => void
|
|
return vi.fn()
|
|
}
|
|
)
|
|
|
|
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(
|
|
mockAuth as Partial<
|
|
ReturnType<typeof vuefire.useFirebaseAuth>
|
|
> as ReturnType<typeof vuefire.useFirebaseAuth>
|
|
)
|
|
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
const storeModule = await import('@/stores/authStore')
|
|
store = storeModule.useAuthStore()
|
|
})
|
|
|
|
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' } as Partial<User> as User
|
|
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' } as Partial<User> as User
|
|
idTokenCallback?.(mockUser)
|
|
idTokenCallback?.(otherUser)
|
|
idTokenCallback?.(otherUser)
|
|
expect(store.tokenRefreshTrigger).toBe(1)
|
|
})
|
|
|
|
it('does not increment on a Firebase token refresh when unified_cloud_auth is ON', () => {
|
|
mockFeatureFlags.unifiedCloudAuthEnabled = true
|
|
idTokenCallback?.(mockUser) // initial event (always skipped)
|
|
idTokenCallback?.(mockUser) // refresh — gated off; the unified lifecycle drives rotation
|
|
expect(store.tokenRefreshTrigger).toBe(0)
|
|
})
|
|
|
|
it('notifyTokenRefreshed increments the rotation trigger (unified rotation driver)', () => {
|
|
store.notifyTokenRefreshed()
|
|
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 Partial<UserCredential> as UserCredential)
|
|
|
|
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 Partial<UserCredential> as UserCredential
|
|
)
|
|
|
|
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 Partial<UserCredential> as UserCredential
|
|
)
|
|
|
|
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 Partial<UserCredential> as UserCredential
|
|
)
|
|
|
|
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)
|
|
})
|
|
|
|
it('forwards the turnstile token to createCustomer as turnstile_token', async () => {
|
|
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
|
|
user: mockUser
|
|
} as Partial<UserCredential> as UserCredential)
|
|
|
|
await store.register('new@example.com', 'password', 'turnstile-abc')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/customers'),
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ turnstile_token: 'turnstile-abc' })
|
|
})
|
|
)
|
|
})
|
|
|
|
it('omits the request body when no turnstile token is provided', async () => {
|
|
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
|
|
user: mockUser
|
|
} as Partial<UserCredential> as UserCredential)
|
|
|
|
await store.register('new@example.com', 'password')
|
|
|
|
const customerCall = mockFetch.mock.calls.find(([url]) =>
|
|
String(url).endsWith('/customers')
|
|
)
|
|
expect(customerCall?.[1]).not.toHaveProperty('body')
|
|
})
|
|
|
|
it('rolls back the orphaned Firebase user when customer creation fails', async () => {
|
|
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
|
|
user: mockUser
|
|
} as Partial<UserCredential> as UserCredential)
|
|
// The server-side customer creation (where Turnstile is validated) fails.
|
|
mockFetch.mockImplementation((url: string) =>
|
|
url.endsWith('/customers')
|
|
? Promise.resolve({
|
|
ok: false,
|
|
statusText: 'Forbidden',
|
|
json: () => Promise.resolve({})
|
|
})
|
|
: Promise.reject(new Error('Unexpected API call'))
|
|
)
|
|
|
|
await expect(
|
|
store.register('new@example.com', 'password', 'turnstile-bad')
|
|
).rejects.toThrow()
|
|
|
|
// The just-created user is deleted so the email is freed for retry.
|
|
expect(mockUser.delete).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not delete the user on a successful registration', async () => {
|
|
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
|
|
user: mockUser
|
|
} as Partial<UserCredential> as UserCredential)
|
|
|
|
await store.register('new@example.com', 'password')
|
|
|
|
expect(mockUser.delete).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not delete an existing user when customer creation fails during login', async () => {
|
|
// Regression guard: the rollback must be scoped to register only — login
|
|
// signs in an EXISTING user, so a customer hiccup must never delete it.
|
|
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue({
|
|
user: mockUser
|
|
} as Partial<UserCredential> as UserCredential)
|
|
mockFetch.mockImplementation((url: string) =>
|
|
url.endsWith('/customers')
|
|
? Promise.resolve({
|
|
ok: false,
|
|
statusText: 'Forbidden',
|
|
json: () => Promise.resolve({})
|
|
})
|
|
: Promise.reject(new Error('Unexpected API call'))
|
|
)
|
|
|
|
await expect(
|
|
store.login('test@example.com', 'password')
|
|
).rejects.toThrow()
|
|
expect(mockUser.delete).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
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 Partial<UserCredential> as UserCredential
|
|
)
|
|
|
|
// 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
|
|
|
|
// 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('getAuthHeader workspace recovery', () => {
|
|
beforeEach(() => {
|
|
mockFeatureFlags.teamWorkspacesEnabled = true
|
|
})
|
|
|
|
it('uses the workspace header when a valid workspace token exists', async () => {
|
|
const workspaceAuth = useWorkspaceAuthStore()
|
|
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue({
|
|
Authorization: 'Bearer ws-token'
|
|
})
|
|
|
|
const header = await store.getAuthHeader()
|
|
|
|
expect(header).toEqual({ Authorization: 'Bearer ws-token' })
|
|
expect(mockUser.getIdToken).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('recovers the workspace token instead of downgrading to personal auth', async () => {
|
|
const workspaceAuth = useWorkspaceAuthStore()
|
|
const teamStore = useTeamWorkspaceStore()
|
|
teamStore.activeWorkspaceId = 'workspace-123'
|
|
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue(null)
|
|
const ensureSpy = vi
|
|
.spyOn(workspaceAuth, 'ensureWorkspaceAuthHeader')
|
|
.mockResolvedValue({ Authorization: 'Bearer recovered-ws-token' })
|
|
|
|
const header = await store.getAuthHeader()
|
|
|
|
expect(ensureSpy).toHaveBeenCalledWith('workspace-123')
|
|
expect(header).toEqual({ Authorization: 'Bearer recovered-ws-token' })
|
|
expect(mockUser.getIdToken).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('fails closed (no personal Firebase downgrade) when recovery yields no token', async () => {
|
|
const workspaceAuth = useWorkspaceAuthStore()
|
|
const teamStore = useTeamWorkspaceStore()
|
|
teamStore.activeWorkspaceId = 'workspace-123'
|
|
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue(null)
|
|
vi.spyOn(workspaceAuth, 'ensureWorkspaceAuthHeader').mockResolvedValue(
|
|
null
|
|
)
|
|
|
|
const header = await store.getAuthHeader()
|
|
|
|
expect(header).toBeNull()
|
|
expect(mockUser.getIdToken).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back to Firebase when workspace mode is not yet initialized', async () => {
|
|
const workspaceAuth = useWorkspaceAuthStore()
|
|
const teamStore = useTeamWorkspaceStore()
|
|
teamStore.activeWorkspaceId = null
|
|
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue(null)
|
|
const ensureSpy = vi.spyOn(workspaceAuth, 'ensureWorkspaceAuthHeader')
|
|
|
|
const header = await store.getAuthHeader()
|
|
|
|
expect(ensureSpy).not.toHaveBeenCalled()
|
|
expect(header).toEqual({ Authorization: 'Bearer mock-id-token' })
|
|
})
|
|
})
|
|
|
|
describe('getAuthToken workspace recovery', () => {
|
|
beforeEach(() => {
|
|
mockFeatureFlags.teamWorkspacesEnabled = true
|
|
})
|
|
|
|
it('recovers the workspace token instead of downgrading to personal auth', async () => {
|
|
const workspaceAuth = useWorkspaceAuthStore()
|
|
const teamStore = useTeamWorkspaceStore()
|
|
teamStore.activeWorkspaceId = 'workspace-123'
|
|
const ensureSpy = vi
|
|
.spyOn(workspaceAuth, 'ensureWorkspaceToken')
|
|
.mockResolvedValue('recovered-ws-token')
|
|
|
|
const token = await store.getAuthToken()
|
|
|
|
expect(ensureSpy).toHaveBeenCalledWith('workspace-123')
|
|
expect(token).toBe('recovered-ws-token')
|
|
expect(mockUser.getIdToken).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('fails closed (no personal Firebase downgrade) when recovery yields no token', async () => {
|
|
const workspaceAuth = useWorkspaceAuthStore()
|
|
const teamStore = useTeamWorkspaceStore()
|
|
teamStore.activeWorkspaceId = 'workspace-123'
|
|
vi.spyOn(workspaceAuth, 'ensureWorkspaceToken').mockResolvedValue(null)
|
|
|
|
const token = await store.getAuthToken()
|
|
|
|
expect(token).toBeUndefined()
|
|
expect(mockUser.getIdToken).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back to Firebase when workspace mode is not yet initialized', async () => {
|
|
const workspaceAuth = useWorkspaceAuthStore()
|
|
const teamStore = useTeamWorkspaceStore()
|
|
teamStore.activeWorkspaceId = null
|
|
vi.spyOn(workspaceAuth, 'getWorkspaceToken').mockReturnValue(undefined)
|
|
const ensureSpy = vi.spyOn(workspaceAuth, 'ensureWorkspaceToken')
|
|
|
|
const token = await store.getAuthToken()
|
|
|
|
expect(ensureSpy).not.toHaveBeenCalled()
|
|
expect(token).toBe('mock-id-token')
|
|
})
|
|
})
|
|
|
|
describe('social authentication', () => {
|
|
describe('loginWithGoogle', () => {
|
|
it('should sign in with Google', async () => {
|
|
const mockUserCredential = { user: mockUser }
|
|
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
|
|
mockUserCredential as Partial<UserCredential> as UserCredential
|
|
)
|
|
|
|
const result = await store.loginWithGoogle()
|
|
|
|
expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith(
|
|
mockAuth,
|
|
expect.any(firebaseAuth.GoogleAuthProvider)
|
|
)
|
|
expect(result).toEqual(mockUserCredential)
|
|
expect(store.loading).toBe(false)
|
|
})
|
|
|
|
it('never sends a turnstile_token on the customer request (OAuth is exempt)', async () => {
|
|
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue({
|
|
user: mockUser
|
|
} as Partial<UserCredential> as UserCredential)
|
|
|
|
await store.loginWithGoogle()
|
|
|
|
const customerCall = mockFetch.mock.calls.find(([url]) =>
|
|
String(url).endsWith('/customers')
|
|
)
|
|
expect(customerCall).toBeDefined()
|
|
expect(customerCall?.[1]).not.toHaveProperty('body')
|
|
})
|
|
|
|
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 Partial<UserCredential> as UserCredential
|
|
)
|
|
|
|
const result = await store.loginWithGithub()
|
|
|
|
expect(firebaseAuth.signInWithPopup).toHaveBeenCalledWith(
|
|
mockAuth,
|
|
expect.any(firebaseAuth.GithubAuthProvider)
|
|
)
|
|
expect(result).toEqual(mockUserCredential)
|
|
expect(store.loading).toBe(false)
|
|
})
|
|
|
|
it('never sends a turnstile_token on the customer request (OAuth is exempt)', async () => {
|
|
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue({
|
|
user: mockUser
|
|
} as Partial<UserCredential> as UserCredential)
|
|
|
|
await store.loginWithGithub()
|
|
|
|
const customerCall = mockFetch.mock.calls.find(([url]) =>
|
|
String(url).endsWith('/customers')
|
|
)
|
|
expect(customerCall).toBeDefined()
|
|
expect(customerCall?.[1]).not.toHaveProperty('body')
|
|
})
|
|
|
|
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 Partial<UserCredential> as UserCredential
|
|
)
|
|
|
|
const googleLoginPromise = store.loginWithGoogle()
|
|
const githubLoginPromise = store.loginWithGithub()
|
|
|
|
await Promise.all([googleLoginPromise, githubLoginPromise])
|
|
|
|
expect(store.loading).toBe(false)
|
|
})
|
|
|
|
describe('sign-up telemetry OR logic', () => {
|
|
const mockUserCredential = {
|
|
user: mockUser
|
|
} as Partial<UserCredential> as UserCredential
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
|
|
mockUserCredential
|
|
)
|
|
})
|
|
|
|
it.for(['loginWithGoogle', 'loginWithGithub'] as const)(
|
|
'%s should track is_new_user=true when Firebase says new user',
|
|
async (method) => {
|
|
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
|
|
isNewUser: true,
|
|
providerId: 'google.com',
|
|
profile: null
|
|
})
|
|
|
|
await store[method]()
|
|
|
|
expect(mockTrackAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ is_new_user: true })
|
|
)
|
|
}
|
|
)
|
|
|
|
it.for(['loginWithGoogle', 'loginWithGithub'] as const)(
|
|
'%s should track is_new_user=true when UI options say new user',
|
|
async (method) => {
|
|
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
|
|
isNewUser: false,
|
|
providerId: 'google.com',
|
|
profile: null
|
|
})
|
|
|
|
await store[method]({ isNewUser: true })
|
|
|
|
expect(mockTrackAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ is_new_user: true })
|
|
)
|
|
}
|
|
)
|
|
|
|
it.for(['loginWithGoogle', 'loginWithGithub'] as const)(
|
|
'%s should track is_new_user=false when neither source says new user',
|
|
async (method) => {
|
|
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
|
|
isNewUser: false,
|
|
providerId: 'google.com',
|
|
profile: null
|
|
})
|
|
|
|
await store[method]()
|
|
|
|
expect(mockTrackAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ is_new_user: false })
|
|
)
|
|
}
|
|
)
|
|
|
|
it.for(['loginWithGoogle', 'loginWithGithub'] as const)(
|
|
'%s should track is_new_user=false when getAdditionalUserInfo returns null',
|
|
async (method) => {
|
|
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue(null)
|
|
|
|
await store[method]()
|
|
|
|
expect(mockTrackAuth).toHaveBeenCalledWith(
|
|
expect.objectContaining({ is_new_user: false })
|
|
)
|
|
}
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('share auth attribution', () => {
|
|
const mockUserCredential = {
|
|
user: mockUser,
|
|
providerId: null,
|
|
operationType: 'signIn'
|
|
} satisfies UserCredential
|
|
|
|
const preserveShareAuth = () => {
|
|
capturePreservedQuery(
|
|
PRESERVED_QUERY_NAMESPACES.SHARE_AUTH,
|
|
{ share: 'share-1' },
|
|
['share']
|
|
)
|
|
}
|
|
|
|
const expectShareAuthConsumed = () => {
|
|
expect(
|
|
sessionStorage.getItem('Comfy.PreservedQuery.share_auth')
|
|
).toBeNull()
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
|
|
mockUserCredential
|
|
)
|
|
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
|
|
mockUserCredential
|
|
)
|
|
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
|
|
mockUserCredential
|
|
)
|
|
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
|
|
isNewUser: true,
|
|
providerId: 'google.com',
|
|
profile: null
|
|
})
|
|
})
|
|
|
|
it('includes share_id on email signup auth completion', async () => {
|
|
preserveShareAuth()
|
|
|
|
await store.register('new@example.com', 'password')
|
|
|
|
expect(mockTrackAuth).toHaveBeenCalledWith({
|
|
method: 'email',
|
|
is_new_user: true,
|
|
user_id: 'test-user-id',
|
|
email: 'test@example.com',
|
|
share_id: 'share-1'
|
|
})
|
|
expectShareAuthConsumed()
|
|
})
|
|
|
|
it('includes share_id on email login auth completion', async () => {
|
|
preserveShareAuth()
|
|
|
|
await store.login('test@example.com', 'password')
|
|
|
|
expect(mockTrackAuth).toHaveBeenCalledWith({
|
|
method: 'email',
|
|
is_new_user: false,
|
|
user_id: 'test-user-id',
|
|
email: 'test@example.com',
|
|
share_id: 'share-1'
|
|
})
|
|
expectShareAuthConsumed()
|
|
})
|
|
|
|
it('includes share_id on Google auth completion', async () => {
|
|
preserveShareAuth()
|
|
|
|
await store.loginWithGoogle()
|
|
|
|
expect(mockTrackAuth).toHaveBeenCalledWith({
|
|
method: 'google',
|
|
is_new_user: true,
|
|
user_id: 'test-user-id',
|
|
email: 'test@example.com',
|
|
share_id: 'share-1'
|
|
})
|
|
expectShareAuthConsumed()
|
|
})
|
|
|
|
it('includes share_id on GitHub auth completion', async () => {
|
|
preserveShareAuth()
|
|
|
|
await store.loginWithGithub()
|
|
|
|
expect(mockTrackAuth).toHaveBeenCalledWith({
|
|
method: 'github',
|
|
is_new_user: true,
|
|
user_id: 'test-user-id',
|
|
email: 'test@example.com',
|
|
share_id: 'share-1'
|
|
})
|
|
expectShareAuthConsumed()
|
|
})
|
|
})
|
|
|
|
describe('accessBillingPortal', () => {
|
|
it('should call billing endpoint without body when no targetTier provided', async () => {
|
|
const result = await store.accessBillingPortal()
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/customers/billing'),
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer mock-id-token',
|
|
'Content-Type': 'application/json'
|
|
})
|
|
})
|
|
)
|
|
|
|
const callArgs = mockFetch.mock.calls.find((call) =>
|
|
(call[0] as string).endsWith('/customers/billing')
|
|
)
|
|
expect(callArgs?.[1]).not.toHaveProperty('body')
|
|
expect(result).toEqual({
|
|
billing_portal_url: 'https://billing.stripe.com/test'
|
|
})
|
|
})
|
|
|
|
it('should include target_tier in request body when targetTier provided', async () => {
|
|
await store.accessBillingPortal('creator')
|
|
|
|
const callArgs = mockFetch.mock.calls.find((call) =>
|
|
(call[0] as string).endsWith('/customers/billing')
|
|
)
|
|
expect(callArgs?.[1]).toHaveProperty('body')
|
|
expect(JSON.parse(callArgs?.[1]?.body as string)).toEqual({
|
|
target_tier: 'creator'
|
|
})
|
|
})
|
|
|
|
it('should handle different checkout tier formats', async () => {
|
|
const tiers = [
|
|
'standard',
|
|
'creator',
|
|
'pro',
|
|
'standard-yearly',
|
|
'creator-yearly',
|
|
'pro-yearly'
|
|
] as const
|
|
|
|
for (const tier of tiers) {
|
|
mockFetch.mockClear()
|
|
await store.accessBillingPortal(tier)
|
|
|
|
const callArgs = mockFetch.mock.calls.find((call) =>
|
|
(call[0] as string).endsWith('/customers/billing')
|
|
)
|
|
expect(JSON.parse(callArgs?.[1]?.body as string)).toEqual({
|
|
target_tier: tier
|
|
})
|
|
}
|
|
})
|
|
|
|
it('should throw error when API returns error response', async () => {
|
|
mockFetch.mockImplementationOnce(() =>
|
|
Promise.resolve({
|
|
ok: false,
|
|
json: () => Promise.resolve({ message: 'Billing portal unavailable' })
|
|
})
|
|
)
|
|
|
|
await expect(store.accessBillingPortal()).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('getAuthHeaderOrThrow', () => {
|
|
it('returns auth header when authenticated', async () => {
|
|
const header = await store.getAuthHeaderOrThrow()
|
|
expect(header).toEqual({ Authorization: 'Bearer mock-id-token' })
|
|
})
|
|
|
|
it('throws AuthStoreError when not authenticated', async () => {
|
|
authStateCallback(null)
|
|
mockApiKeyGetAuthHeader.mockReturnValue(null)
|
|
|
|
await expect(store.getAuthHeaderOrThrow()).rejects.toMatchObject({
|
|
name: 'AuthStoreError',
|
|
message: 'toastMessages.userNotAuthenticated'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('getFirebaseAuthHeaderOrThrow', () => {
|
|
it('returns Firebase auth header when authenticated', async () => {
|
|
const header = await store.getFirebaseAuthHeaderOrThrow()
|
|
expect(header).toEqual({ Authorization: 'Bearer mock-id-token' })
|
|
})
|
|
|
|
it('throws AuthStoreError when not authenticated', async () => {
|
|
authStateCallback(null)
|
|
|
|
await expect(store.getFirebaseAuthHeaderOrThrow()).rejects.toMatchObject({
|
|
name: 'AuthStoreError',
|
|
message: 'toastMessages.userNotAuthenticated'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('createCustomer', () => {
|
|
it('should succeed with API key auth when no Firebase user is present', async () => {
|
|
authStateCallback(null)
|
|
mockApiKeyGetAuthHeader.mockReturnValue({ 'X-API-KEY': 'test-api-key' })
|
|
|
|
const result = await store.createCustomer()
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/customers'),
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'X-API-KEY': 'test-api-key'
|
|
})
|
|
})
|
|
)
|
|
expect(result).toEqual({ id: 'test-customer-id' })
|
|
})
|
|
|
|
it('should use Firebase token when Firebase user is present', async () => {
|
|
const result = await store.createCustomer()
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/customers'),
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer mock-id-token'
|
|
})
|
|
})
|
|
)
|
|
expect(result).toEqual({ id: 'test-customer-id' })
|
|
})
|
|
|
|
it('should throw when no auth method is available', async () => {
|
|
authStateCallback(null)
|
|
mockApiKeyGetAuthHeader.mockReturnValue(null)
|
|
|
|
await expect(store.createCustomer()).rejects.toThrow()
|
|
})
|
|
|
|
it('carries the HTTP status on a non-ok response', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 422,
|
|
statusText: 'Unprocessable Entity'
|
|
})
|
|
|
|
const error = await store.createCustomer().catch((e: unknown) => e)
|
|
expect(error).toBeInstanceOf(AuthStoreError)
|
|
expect((error as AuthStoreError).status).toBe(422)
|
|
})
|
|
})
|
|
})
|