mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-19 02:06:38 +00:00
## Problem
When the auth backend rejects a sign-up, the Firebase client SDK
surfaces the rejection wrapped in a generic error code (e.g.
`auth/internal-error`). `reportError` maps only by `error.code` →
`auth.errors.${code}`, so this case falls through to the generic
*"Something went wrong while signing you in. Please try again."* toast —
giving the user no actionable guidance.
## Change
- `useAuthActions.ts` — add a branch in `reportError` that matches a
stable `signup_blocked` token in `error.message` (matched
**case-insensitively** on the message, not `error.code`, which the SDK
wraps inconsistently across versions) and shows a dedicated message.
- `locales/en/main.json` — new `auth.errors.signupBlocked` string. Other
locales inherit it via `fallbackLocale: 'en'`.
- `useAuthActions.test.ts` — unit tests asserting the token path wins
over the generic fallback, and that the match is case-insensitive.
## Validation
- `vitest run src/composables/auth` — 12/12 pass (incl. token +
case-insensitive tests)
- `vue-tsc --noEmit` — clean
## Why no E2E (Playwright) test
This branch is reachable only when the **auth backend actively rejects a
Google-Workspace-SSO sign-up** (a server-side blocking decision) and the
Firebase SDK returns an error whose message carries the `signup_blocked`
token. Reproducing that in the browser E2E harness would require driving
a real federated-SSO sign-up against a backend configured to reject the
specific account — state the FE test environment cannot set up
deterministically. The logic under test is pure error-mapping with no
UI/navigation surface beyond the existing toast, so it is fully covered
by the unit tests above. The end-to-end path will instead be confirmed
via a one-time staging repro (see below).
## Draft — pending confirmation
Kept as **draft** until we confirm, via a staging repro, the exact error
shape the client receives for this rejection (the message-token match is
the robust hedge regardless of `error.code`, but a live repro makes it
deterministic). The backend that emits the `signup_blocked` token is a
separate change.
285 lines
8.7 KiB
TypeScript
285 lines
8.7 KiB
TypeScript
import { FirebaseError } from 'firebase/app'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
|
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
|
|
|
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
|
|
|
|
const mockAuthStore = vi.hoisted(() => ({
|
|
logout: vi.fn().mockResolvedValue(undefined)
|
|
}))
|
|
|
|
const mockToastStore = vi.hoisted(() => ({
|
|
add: vi.fn()
|
|
}))
|
|
|
|
const mockWorkflowStore = vi.hoisted(() => ({
|
|
modifiedWorkflows: [] as ModifiedWorkflow[]
|
|
}))
|
|
|
|
const mockWorkflowService = vi.hoisted(() => ({
|
|
saveWorkflow: vi.fn().mockResolvedValue(true)
|
|
}))
|
|
|
|
const mockDialogService = vi.hoisted(() => ({
|
|
confirm: vi.fn()
|
|
}))
|
|
|
|
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
|
|
|
const knownAuthErrorCodes = new Set([
|
|
'auth/invalid-credential',
|
|
'auth/email-already-in-use'
|
|
])
|
|
|
|
vi.mock('@/i18n', () => ({
|
|
t: (key: string, values?: { workflow?: string }) =>
|
|
values?.workflow ? `${key}:${values.workflow}` : key,
|
|
st: (key: string, fallback: string) => {
|
|
const code = key.replace('auth.errors.', '')
|
|
return knownAuthErrorCodes.has(code) ? key : fallback
|
|
}
|
|
}))
|
|
|
|
vi.mock('@/platform/distribution/types', () => ({
|
|
isCloud: false
|
|
}))
|
|
|
|
vi.mock('@/platform/telemetry', () => ({
|
|
useTelemetry: vi.fn(() => undefined)
|
|
}))
|
|
|
|
vi.mock('@/platform/updates/common/toastStore', () => ({
|
|
useToastStore: vi.fn(() => mockToastStore)
|
|
}))
|
|
|
|
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
|
useWorkflowStore: vi.fn(() => mockWorkflowStore)
|
|
}))
|
|
|
|
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
|
useWorkflowService: vi.fn(() => mockWorkflowService)
|
|
}))
|
|
|
|
vi.mock('@/services/dialogService', () => ({
|
|
useDialogService: vi.fn(() => mockDialogService)
|
|
}))
|
|
|
|
vi.mock('@/stores/authStore', () => ({
|
|
useAuthStore: vi.fn(() => mockAuthStore)
|
|
}))
|
|
|
|
vi.mock('@/composables/billing/useBillingContext', () => ({
|
|
useBillingContext: vi.fn(() => ({
|
|
isActiveSubscription: { value: false },
|
|
isFreeTier: { value: true },
|
|
type: { value: 'free' }
|
|
}))
|
|
}))
|
|
|
|
vi.mock('@/composables/useErrorHandling', () => ({
|
|
useErrorHandling: () => ({
|
|
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
|
|
action: (...args: TArgs) => Promise<TReturn> | TReturn
|
|
) => action,
|
|
toastErrorHandler: mockToastErrorHandler
|
|
})
|
|
}))
|
|
|
|
function makeWorkflow(path: string): ModifiedWorkflow {
|
|
return { path, isModified: true } satisfies ModifiedWorkflow
|
|
}
|
|
|
|
describe('useAuthActions.logout', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia())
|
|
vi.clearAllMocks()
|
|
mockWorkflowStore.modifiedWorkflows = []
|
|
})
|
|
|
|
it('logs out without prompting when no workflows are modified', async () => {
|
|
const { logout } = useAuthActions()
|
|
|
|
await logout()
|
|
|
|
expect(mockDialogService.confirm).not.toHaveBeenCalled()
|
|
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
|
|
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('cancels sign-out when the dialog is dismissed (null)', async () => {
|
|
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
|
|
mockDialogService.confirm.mockResolvedValueOnce(null)
|
|
const { logout } = useAuthActions()
|
|
|
|
await logout()
|
|
|
|
expect(mockDialogService.confirm).toHaveBeenCalledTimes(1)
|
|
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
|
|
expect(mockAuthStore.logout).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('signs out without saving when the user picks "Sign out anyway" (false)', async () => {
|
|
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
|
|
mockDialogService.confirm.mockResolvedValueOnce(false)
|
|
const { logout } = useAuthActions()
|
|
|
|
await logout()
|
|
|
|
expect(mockDialogService.confirm).toHaveBeenCalledTimes(1)
|
|
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
|
|
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('cancels sign-out when saving a workflow is cancelled', async () => {
|
|
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
|
|
mockDialogService.confirm.mockResolvedValueOnce(true)
|
|
mockWorkflowService.saveWorkflow.mockResolvedValueOnce(false)
|
|
const { logout } = useAuthActions()
|
|
|
|
await logout()
|
|
|
|
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
|
|
expect(mockAuthStore.logout).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not log out if a workflow save fails', async () => {
|
|
mockWorkflowStore.modifiedWorkflows = [
|
|
makeWorkflow('a.json'),
|
|
makeWorkflow('b.json')
|
|
]
|
|
mockDialogService.confirm.mockResolvedValueOnce(true)
|
|
mockWorkflowService.saveWorkflow.mockRejectedValueOnce(
|
|
new Error('disk full')
|
|
)
|
|
const { logout } = useAuthActions()
|
|
|
|
await expect(logout()).rejects.toThrow('auth.signOut.saveFailed:a.json')
|
|
|
|
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
|
|
expect(mockAuthStore.logout).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('saves every modified workflow before signing out when user picks Save (true)', async () => {
|
|
const workflows = [makeWorkflow('a.json'), makeWorkflow('b.json')]
|
|
mockWorkflowStore.modifiedWorkflows = workflows
|
|
mockDialogService.confirm.mockResolvedValueOnce(true)
|
|
const { logout } = useAuthActions()
|
|
|
|
await logout()
|
|
|
|
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(2)
|
|
expect(mockWorkflowService.saveWorkflow).toHaveBeenNthCalledWith(
|
|
1,
|
|
workflows[0]
|
|
)
|
|
expect(mockWorkflowService.saveWorkflow).toHaveBeenNthCalledWith(
|
|
2,
|
|
workflows[1]
|
|
)
|
|
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
|
|
expect(
|
|
mockWorkflowService.saveWorkflow.mock.invocationCallOrder[1]
|
|
).toBeLessThan(mockAuthStore.logout.mock.invocationCallOrder[0])
|
|
expect(
|
|
mockWorkflowService.saveWorkflow.mock.invocationCallOrder[0]
|
|
).toBeLessThan(mockWorkflowService.saveWorkflow.mock.invocationCallOrder[1])
|
|
})
|
|
|
|
it('passes denyLabel "Sign out anyway" to the dialog', async () => {
|
|
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
|
|
mockDialogService.confirm.mockResolvedValueOnce(null)
|
|
const { logout } = useAuthActions()
|
|
|
|
await logout()
|
|
|
|
expect(mockDialogService.confirm).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: 'dirtyClose',
|
|
title: 'auth.signOut.unsavedChangesTitle',
|
|
message: 'auth.signOut.unsavedChangesMessage',
|
|
denyLabel: 'auth.signOut.signOutAnyway'
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('useAuthActions.reportError', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia())
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('shows the friendly message for a known Firebase auth code', () => {
|
|
const { reportError } = useAuthActions()
|
|
|
|
reportError(new FirebaseError('auth/invalid-credential', 'raw firebase'))
|
|
|
|
expect(mockToastStore.add).toHaveBeenCalledWith({
|
|
severity: 'error',
|
|
summary: 'g.error',
|
|
detail: 'auth.errors.auth/invalid-credential'
|
|
})
|
|
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('shows the signupBlocked message when the error carries the signup_blocked token', () => {
|
|
const { reportError } = useAuthActions()
|
|
|
|
// The backend wraps the rejection in a generic code; we match the token in
|
|
// the message, so it must win over the auth.errors.${code} fallback.
|
|
reportError(
|
|
new FirebaseError(
|
|
'auth/internal-error',
|
|
'Account creation is temporarily unavailable. (ref: signup_blocked)'
|
|
)
|
|
)
|
|
|
|
expect(mockToastStore.add).toHaveBeenCalledWith({
|
|
severity: 'error',
|
|
summary: 'g.error',
|
|
detail: 'auth.errors.signupBlocked'
|
|
})
|
|
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('matches the signup_blocked token case-insensitively', () => {
|
|
const { reportError } = useAuthActions()
|
|
|
|
reportError(
|
|
new FirebaseError('auth/internal-error', 'rejected: SIGNUP_BLOCKED')
|
|
)
|
|
|
|
expect(mockToastStore.add).toHaveBeenCalledWith({
|
|
severity: 'error',
|
|
summary: 'g.error',
|
|
detail: 'auth.errors.signupBlocked'
|
|
})
|
|
})
|
|
|
|
it('shows the generic fallback for an unknown Firebase auth code', () => {
|
|
const { reportError } = useAuthActions()
|
|
|
|
reportError(new FirebaseError('auth/some-new-code', 'raw firebase'))
|
|
|
|
expect(mockToastStore.add).toHaveBeenCalledWith({
|
|
severity: 'error',
|
|
summary: 'g.error',
|
|
detail: 'auth.errors.generic'
|
|
})
|
|
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('delegates non-Firebase errors to toastErrorHandler', () => {
|
|
const { reportError } = useAuthActions()
|
|
const networkError = new TypeError('Failed to fetch')
|
|
|
|
reportError(networkError)
|
|
|
|
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
|
|
expect(mockToastStore.add).not.toHaveBeenCalled()
|
|
})
|
|
})
|