mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-19 10:31:16 -07:00
feat: better error messages (#20577)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
This commit is contained in:
@@ -17,7 +17,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
getErrorMessage: (e: unknown) => (e as Error).message,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ describe('auth', () => {
|
|||||||
|
|
||||||
it('should return null if authType is undefined', async () => {
|
it('should return null if authType is undefined', async () => {
|
||||||
const result = await performInitialAuth(mockConfig, undefined);
|
const result = await performInitialAuth(mockConfig, undefined);
|
||||||
expect(result).toBeNull();
|
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
|
||||||
expect(mockConfig.refreshAuth).not.toHaveBeenCalled();
|
expect(mockConfig.refreshAuth).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ describe('auth', () => {
|
|||||||
mockConfig,
|
mockConfig,
|
||||||
AuthType.LOGIN_WITH_GOOGLE,
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
);
|
);
|
||||||
expect(result).toBeNull();
|
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
|
||||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||||
AuthType.LOGIN_WITH_GOOGLE,
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
);
|
);
|
||||||
@@ -54,7 +53,10 @@ describe('auth', () => {
|
|||||||
mockConfig,
|
mockConfig,
|
||||||
AuthType.LOGIN_WITH_GOOGLE,
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
);
|
);
|
||||||
expect(result).toBe('Failed to login. Message: Auth failed');
|
expect(result).toEqual({
|
||||||
|
authError: 'Failed to login. Message: Auth failed',
|
||||||
|
accountSuspensionInfo: null,
|
||||||
|
});
|
||||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||||
AuthType.LOGIN_WITH_GOOGLE,
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
);
|
);
|
||||||
@@ -68,7 +70,48 @@ describe('auth', () => {
|
|||||||
mockConfig,
|
mockConfig,
|
||||||
AuthType.LOGIN_WITH_GOOGLE,
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
);
|
);
|
||||||
expect(result).toBeNull();
|
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
|
||||||
|
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||||
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return accountSuspensionInfo for 403 TOS_VIOLATION error', async () => {
|
||||||
|
vi.mocked(mockConfig.refreshAuth).mockRejectedValue({
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
error: {
|
||||||
|
code: 403,
|
||||||
|
message:
|
||||||
|
'This service has been disabled for violation of Terms of Service.',
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||||
|
reason: 'TOS_VIOLATION',
|
||||||
|
domain: 'example.googleapis.com',
|
||||||
|
metadata: {
|
||||||
|
appeal_url: 'https://example.com/appeal',
|
||||||
|
appeal_url_link_text: 'Appeal Here',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await performInitialAuth(
|
||||||
|
mockConfig,
|
||||||
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
authError: null,
|
||||||
|
accountSuspensionInfo: {
|
||||||
|
message:
|
||||||
|
'This service has been disabled for violation of Terms of Service.',
|
||||||
|
appealUrl: 'https://example.com/appeal',
|
||||||
|
appealLinkText: 'Appeal Here',
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||||
AuthType.LOGIN_WITH_GOOGLE,
|
AuthType.LOGIN_WITH_GOOGLE,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,20 +9,28 @@ import {
|
|||||||
type Config,
|
type Config,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
ValidationRequiredError,
|
ValidationRequiredError,
|
||||||
|
isAccountSuspendedError,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
|
||||||
|
|
||||||
|
export interface InitialAuthResult {
|
||||||
|
authError: string | null;
|
||||||
|
accountSuspensionInfo: AccountSuspensionInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the initial authentication flow.
|
* Handles the initial authentication flow.
|
||||||
* @param config The application config.
|
* @param config The application config.
|
||||||
* @param authType The selected auth type.
|
* @param authType The selected auth type.
|
||||||
* @returns An error message if authentication fails, otherwise null.
|
* @returns The auth result with error message and account suspension status.
|
||||||
*/
|
*/
|
||||||
export async function performInitialAuth(
|
export async function performInitialAuth(
|
||||||
config: Config,
|
config: Config,
|
||||||
authType: AuthType | undefined,
|
authType: AuthType | undefined,
|
||||||
): Promise<string | null> {
|
): Promise<InitialAuthResult> {
|
||||||
if (!authType) {
|
if (!authType) {
|
||||||
return null;
|
return { authError: null, accountSuspensionInfo: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -33,10 +41,24 @@ export async function performInitialAuth(
|
|||||||
if (e instanceof ValidationRequiredError) {
|
if (e instanceof ValidationRequiredError) {
|
||||||
// Don't treat validation required as a fatal auth error during startup.
|
// Don't treat validation required as a fatal auth error during startup.
|
||||||
// This allows the React UI to load and show the ValidationDialog.
|
// This allows the React UI to load and show the ValidationDialog.
|
||||||
return null;
|
return { authError: null, accountSuspensionInfo: null };
|
||||||
}
|
}
|
||||||
return `Failed to login. Message: ${getErrorMessage(e)}`;
|
const suspendedError = isAccountSuspendedError(e);
|
||||||
|
if (suspendedError) {
|
||||||
|
return {
|
||||||
|
authError: null,
|
||||||
|
accountSuspensionInfo: {
|
||||||
|
message: suspendedError.message,
|
||||||
|
appealUrl: suspendedError.appealUrl,
|
||||||
|
appealLinkText: suspendedError.appealLinkText,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
authError: `Failed to login. Message: ${getErrorMessage(e)}`,
|
||||||
|
accountSuspensionInfo: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return { authError: null, accountSuspensionInfo: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ describe('initializer', () => {
|
|||||||
vi.mocked(IdeClient.getInstance).mockResolvedValue(
|
vi.mocked(IdeClient.getInstance).mockResolvedValue(
|
||||||
mockIdeClient as unknown as IdeClient,
|
mockIdeClient as unknown as IdeClient,
|
||||||
);
|
);
|
||||||
vi.mocked(performInitialAuth).mockResolvedValue(null);
|
vi.mocked(performInitialAuth).mockResolvedValue({
|
||||||
|
authError: null,
|
||||||
|
accountSuspensionInfo: null,
|
||||||
|
});
|
||||||
vi.mocked(validateTheme).mockReturnValue(null);
|
vi.mocked(validateTheme).mockReturnValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,6 +87,7 @@ describe('initializer', () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
authError: null,
|
authError: null,
|
||||||
|
accountSuspensionInfo: null,
|
||||||
themeError: null,
|
themeError: null,
|
||||||
shouldOpenAuthDialog: false,
|
shouldOpenAuthDialog: false,
|
||||||
geminiMdFileCount: 5,
|
geminiMdFileCount: 5,
|
||||||
@@ -103,6 +107,7 @@ describe('initializer', () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
authError: null,
|
authError: null,
|
||||||
|
accountSuspensionInfo: null,
|
||||||
themeError: null,
|
themeError: null,
|
||||||
shouldOpenAuthDialog: false,
|
shouldOpenAuthDialog: false,
|
||||||
geminiMdFileCount: 5,
|
geminiMdFileCount: 5,
|
||||||
@@ -116,7 +121,10 @@ describe('initializer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle auth error', async () => {
|
it('should handle auth error', async () => {
|
||||||
vi.mocked(performInitialAuth).mockResolvedValue('Auth failed');
|
vi.mocked(performInitialAuth).mockResolvedValue({
|
||||||
|
authError: 'Auth failed',
|
||||||
|
accountSuspensionInfo: null,
|
||||||
|
});
|
||||||
const result = await initializeApp(
|
const result = await initializeApp(
|
||||||
mockConfig as unknown as Config,
|
mockConfig as unknown as Config,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import {
|
|||||||
import { type LoadedSettings } from '../config/settings.js';
|
import { type LoadedSettings } from '../config/settings.js';
|
||||||
import { performInitialAuth } from './auth.js';
|
import { performInitialAuth } from './auth.js';
|
||||||
import { validateTheme } from './theme.js';
|
import { validateTheme } from './theme.js';
|
||||||
|
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
|
||||||
|
|
||||||
export interface InitializationResult {
|
export interface InitializationResult {
|
||||||
authError: string | null;
|
authError: string | null;
|
||||||
|
accountSuspensionInfo: AccountSuspensionInfo | null;
|
||||||
themeError: string | null;
|
themeError: string | null;
|
||||||
shouldOpenAuthDialog: boolean;
|
shouldOpenAuthDialog: boolean;
|
||||||
geminiMdFileCount: number;
|
geminiMdFileCount: number;
|
||||||
@@ -37,7 +39,7 @@ export async function initializeApp(
|
|||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
): Promise<InitializationResult> {
|
): Promise<InitializationResult> {
|
||||||
const authHandle = startupProfiler.start('authenticate');
|
const authHandle = startupProfiler.start('authenticate');
|
||||||
const authError = await performInitialAuth(
|
const { authError, accountSuspensionInfo } = await performInitialAuth(
|
||||||
config,
|
config,
|
||||||
settings.merged.security.auth.selectedType,
|
settings.merged.security.auth.selectedType,
|
||||||
);
|
);
|
||||||
@@ -60,6 +62,7 @@ export async function initializeApp(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
authError,
|
authError,
|
||||||
|
accountSuspensionInfo,
|
||||||
themeError,
|
themeError,
|
||||||
shouldOpenAuthDialog,
|
shouldOpenAuthDialog,
|
||||||
geminiMdFileCount: config.getGeminiMdFileCount(),
|
geminiMdFileCount: config.getGeminiMdFileCount(),
|
||||||
|
|||||||
@@ -1202,6 +1202,7 @@ describe('startInteractiveUI', () => {
|
|||||||
const mockWorkspaceRoot = '/root';
|
const mockWorkspaceRoot = '/root';
|
||||||
const mockInitializationResult = {
|
const mockInitializationResult = {
|
||||||
authError: null,
|
authError: null,
|
||||||
|
accountSuspensionInfo: null,
|
||||||
themeError: null,
|
themeError: null,
|
||||||
shouldOpenAuthDialog: false,
|
shouldOpenAuthDialog: false,
|
||||||
geminiMdFileCount: 0,
|
geminiMdFileCount: 0,
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ vi.mock('../ui/auth/useAuth.js', () => ({
|
|||||||
onAuthError: vi.fn(),
|
onAuthError: vi.fn(),
|
||||||
apiKeyDefaultValue: 'test-api-key',
|
apiKeyDefaultValue: 'test-api-key',
|
||||||
reloadApiKey: vi.fn().mockResolvedValue('test-api-key'),
|
reloadApiKey: vi.fn().mockResolvedValue('test-api-key'),
|
||||||
|
accountSuspensionInfo: null,
|
||||||
|
setAccountSuspensionInfo: vi.fn(),
|
||||||
}),
|
}),
|
||||||
validateAuthMethodWithSettings: () => null,
|
validateAuthMethodWithSettings: () => null,
|
||||||
}));
|
}));
|
||||||
@@ -387,6 +389,7 @@ export class AppRig {
|
|||||||
version="test-version"
|
version="test-version"
|
||||||
initializationResult={{
|
initializationResult={{
|
||||||
authError: null,
|
authError: null,
|
||||||
|
accountSuspensionInfo: null,
|
||||||
themeError: null,
|
themeError: null,
|
||||||
shouldOpenAuthDialog: false,
|
shouldOpenAuthDialog: false,
|
||||||
geminiMdFileCount: 0,
|
geminiMdFileCount: 0,
|
||||||
|
|||||||
@@ -615,6 +615,7 @@ const mockUIActions: UIActions = {
|
|||||||
handleRestart: vi.fn(),
|
handleRestart: vi.fn(),
|
||||||
handleNewAgentsSelect: vi.fn(),
|
handleNewAgentsSelect: vi.fn(),
|
||||||
getPreferredEditor: vi.fn(),
|
getPreferredEditor: vi.fn(),
|
||||||
|
clearAccountSuspension: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let capturedOverflowState: OverflowState | undefined;
|
let capturedOverflowState: OverflowState | undefined;
|
||||||
|
|||||||
@@ -675,7 +675,14 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
onAuthError,
|
onAuthError,
|
||||||
apiKeyDefaultValue,
|
apiKeyDefaultValue,
|
||||||
reloadApiKey,
|
reloadApiKey,
|
||||||
} = useAuthCommand(settings, config, initializationResult.authError);
|
accountSuspensionInfo,
|
||||||
|
setAccountSuspensionInfo,
|
||||||
|
} = useAuthCommand(
|
||||||
|
settings,
|
||||||
|
config,
|
||||||
|
initializationResult.authError,
|
||||||
|
initializationResult.accountSuspensionInfo,
|
||||||
|
);
|
||||||
const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>(
|
const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@@ -2197,6 +2204,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
isAuthenticating,
|
isAuthenticating,
|
||||||
isConfigInitialized,
|
isConfigInitialized,
|
||||||
authError,
|
authError,
|
||||||
|
accountSuspensionInfo,
|
||||||
isAuthDialogOpen,
|
isAuthDialogOpen,
|
||||||
isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput,
|
isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput,
|
||||||
apiKeyDefaultValue,
|
apiKeyDefaultValue,
|
||||||
@@ -2328,6 +2336,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
isAuthenticating,
|
isAuthenticating,
|
||||||
isConfigInitialized,
|
isConfigInitialized,
|
||||||
authError,
|
authError,
|
||||||
|
accountSuspensionInfo,
|
||||||
isAuthDialogOpen,
|
isAuthDialogOpen,
|
||||||
editorError,
|
editorError,
|
||||||
isEditorDialogOpen,
|
isEditorDialogOpen,
|
||||||
@@ -2537,6 +2546,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
setNewAgents(null);
|
setNewAgents(null);
|
||||||
},
|
},
|
||||||
getPreferredEditor,
|
getPreferredEditor,
|
||||||
|
clearAccountSuspension: () => {
|
||||||
|
setAccountSuspensionInfo(null);
|
||||||
|
setAuthState(AuthState.Updating);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
handleThemeSelect,
|
handleThemeSelect,
|
||||||
@@ -2587,6 +2600,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
setActiveBackgroundShellPid,
|
setActiveBackgroundShellPid,
|
||||||
setIsBackgroundShellListOpen,
|
setIsBackgroundShellListOpen,
|
||||||
setAuthContext,
|
setAuthContext,
|
||||||
|
setAccountSuspensionInfo,
|
||||||
newAgents,
|
newAgents,
|
||||||
config,
|
config,
|
||||||
historyManager,
|
historyManager,
|
||||||
|
|||||||
241
packages/cli/src/ui/auth/BannedAccountDialog.test.tsx
Normal file
241
packages/cli/src/ui/auth/BannedAccountDialog.test.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
|
import { waitFor } from '../../test-utils/async.js';
|
||||||
|
import { BannedAccountDialog } from './BannedAccountDialog.js';
|
||||||
|
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||||
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
import {
|
||||||
|
openBrowserSecurely,
|
||||||
|
shouldLaunchBrowser,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { Text } from 'ink';
|
||||||
|
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||||
|
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
|
||||||
|
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
openBrowserSecurely: vi.fn(),
|
||||||
|
shouldLaunchBrowser: vi.fn().mockReturnValue(true),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../utils/cleanup.js', () => ({
|
||||||
|
runExitCleanup: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../hooks/useKeypress.js', () => ({
|
||||||
|
useKeypress: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/shared/RadioButtonSelect.js', () => ({
|
||||||
|
RadioButtonSelect: vi.fn(({ items }) => (
|
||||||
|
<>
|
||||||
|
{items.map((item: { value: string; label: string }) => (
|
||||||
|
<Text key={item.value}>{item.label}</Text>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedRadioButtonSelect = RadioButtonSelect as Mock;
|
||||||
|
const mockedUseKeypress = useKeypress as Mock;
|
||||||
|
const mockedOpenBrowser = openBrowserSecurely as Mock;
|
||||||
|
const mockedShouldLaunchBrowser = shouldLaunchBrowser as Mock;
|
||||||
|
const mockedRunExitCleanup = runExitCleanup as Mock;
|
||||||
|
|
||||||
|
const DEFAULT_SUSPENSION_INFO: AccountSuspensionInfo = {
|
||||||
|
message:
|
||||||
|
'This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product.',
|
||||||
|
appealUrl: 'https://example.com/appeal',
|
||||||
|
appealLinkText: 'Appeal Here',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('BannedAccountDialog', () => {
|
||||||
|
let onExit: Mock;
|
||||||
|
let onChangeAuth: Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
mockedShouldLaunchBrowser.mockReturnValue(true);
|
||||||
|
mockedOpenBrowser.mockResolvedValue(undefined);
|
||||||
|
mockedRunExitCleanup.mockResolvedValue(undefined);
|
||||||
|
onExit = vi.fn();
|
||||||
|
onChangeAuth = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the suspension message from accountSuspensionInfo', async () => {
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<BannedAccountDialog
|
||||||
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
|
onExit={onExit}
|
||||||
|
onChangeAuth={onChangeAuth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const frame = lastFrame();
|
||||||
|
expect(frame).toContain('Account Suspended');
|
||||||
|
expect(frame).toContain('violation of Terms of Service');
|
||||||
|
expect(frame).toContain('Escape to exit');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders menu options with appeal link text from response', async () => {
|
||||||
|
const { waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<BannedAccountDialog
|
||||||
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
|
onExit={onExit}
|
||||||
|
onChangeAuth={onChangeAuth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||||
|
expect(items).toHaveLength(3);
|
||||||
|
expect(items[0].label).toBe('Appeal Here');
|
||||||
|
expect(items[1].label).toBe('Change authentication');
|
||||||
|
expect(items[2].label).toBe('Exit');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides form option when no appealUrl is provided', async () => {
|
||||||
|
const infoWithoutUrl: AccountSuspensionInfo = {
|
||||||
|
message: 'Account suspended.',
|
||||||
|
};
|
||||||
|
const { waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<BannedAccountDialog
|
||||||
|
accountSuspensionInfo={infoWithoutUrl}
|
||||||
|
onExit={onExit}
|
||||||
|
onChangeAuth={onChangeAuth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items[0].label).toBe('Change authentication');
|
||||||
|
expect(items[1].label).toBe('Exit');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default label when appealLinkText is not provided', async () => {
|
||||||
|
const infoWithoutLinkText: AccountSuspensionInfo = {
|
||||||
|
message: 'Account suspended.',
|
||||||
|
appealUrl: 'https://example.com/appeal',
|
||||||
|
};
|
||||||
|
const { waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<BannedAccountDialog
|
||||||
|
accountSuspensionInfo={infoWithoutLinkText}
|
||||||
|
onExit={onExit}
|
||||||
|
onChangeAuth={onChangeAuth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||||
|
expect(items[0].label).toBe('Open the Google Form');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens browser when appeal option is selected', async () => {
|
||||||
|
const { waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<BannedAccountDialog
|
||||||
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
|
onExit={onExit}
|
||||||
|
onChangeAuth={onChangeAuth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
|
await onSelect('open_form');
|
||||||
|
expect(mockedOpenBrowser).toHaveBeenCalledWith(
|
||||||
|
'https://example.com/appeal',
|
||||||
|
);
|
||||||
|
expect(onExit).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows URL when browser cannot be launched', async () => {
|
||||||
|
mockedShouldLaunchBrowser.mockReturnValue(false);
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<BannedAccountDialog
|
||||||
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
|
onExit={onExit}
|
||||||
|
onChangeAuth={onChangeAuth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
|
onSelect('open_form');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(lastFrame()).toContain('Please open this URL in a browser');
|
||||||
|
});
|
||||||
|
expect(mockedOpenBrowser).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onExit when "Exit" is selected', async () => {
|
||||||
|
const { waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<BannedAccountDialog
|
||||||
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
|
onExit={onExit}
|
||||||
|
onChangeAuth={onChangeAuth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
|
await onSelect('exit');
|
||||||
|
expect(mockedRunExitCleanup).toHaveBeenCalled();
|
||||||
|
expect(onExit).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChangeAuth when "Change authentication" is selected', async () => {
|
||||||
|
const { waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<BannedAccountDialog
|
||||||
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
|
onExit={onExit}
|
||||||
|
onChangeAuth={onChangeAuth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
|
onSelect('change_auth');
|
||||||
|
expect(onChangeAuth).toHaveBeenCalled();
|
||||||
|
expect(onExit).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits on escape key', async () => {
|
||||||
|
const { waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<BannedAccountDialog
|
||||||
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
|
onExit={onExit}
|
||||||
|
onChangeAuth={onChangeAuth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||||
|
const result = keypressHandler({ name: 'escape' });
|
||||||
|
expect(result).toBe(true);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders snapshot correctly', async () => {
|
||||||
|
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||||
|
<BannedAccountDialog
|
||||||
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
|
onExit={onExit}
|
||||||
|
onChangeAuth={onChangeAuth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitUntilReady();
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
138
packages/cli/src/ui/auth/BannedAccountDialog.tsx
Normal file
138
packages/cli/src/ui/auth/BannedAccountDialog.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { theme } from '../semantic-colors.js';
|
||||||
|
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||||
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
import {
|
||||||
|
openBrowserSecurely,
|
||||||
|
shouldLaunchBrowser,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||||
|
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
|
||||||
|
|
||||||
|
interface BannedAccountDialogProps {
|
||||||
|
accountSuspensionInfo: AccountSuspensionInfo;
|
||||||
|
onExit: () => void;
|
||||||
|
onChangeAuth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BannedAccountDialog({
|
||||||
|
accountSuspensionInfo,
|
||||||
|
onExit,
|
||||||
|
onChangeAuth,
|
||||||
|
}: BannedAccountDialogProps): React.JSX.Element {
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const appealUrl = accountSuspensionInfo.appealUrl;
|
||||||
|
const appealLinkText =
|
||||||
|
accountSuspensionInfo.appealLinkText ?? 'Open the Google Form';
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const menuItems = [];
|
||||||
|
if (appealUrl) {
|
||||||
|
menuItems.push({
|
||||||
|
label: appealLinkText,
|
||||||
|
value: 'open_form' as const,
|
||||||
|
key: 'open_form',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
menuItems.push(
|
||||||
|
{
|
||||||
|
label: 'Change authentication',
|
||||||
|
value: 'change_auth' as const,
|
||||||
|
key: 'change_auth',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Exit',
|
||||||
|
value: 'exit' as const,
|
||||||
|
key: 'exit',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return menuItems;
|
||||||
|
}, [appealUrl, appealLinkText]);
|
||||||
|
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
if (key.name === 'escape') {
|
||||||
|
void handleExit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{ isActive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExit = useCallback(async () => {
|
||||||
|
await runExitCleanup();
|
||||||
|
onExit();
|
||||||
|
}, [onExit]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
async (choice: string) => {
|
||||||
|
if (choice === 'open_form' && appealUrl) {
|
||||||
|
if (!shouldLaunchBrowser()) {
|
||||||
|
setErrorMessage(`Please open this URL in a browser: ${appealUrl}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openBrowserSecurely(appealUrl);
|
||||||
|
} catch {
|
||||||
|
setErrorMessage(`Failed to open browser. Please visit: ${appealUrl}`);
|
||||||
|
}
|
||||||
|
} else if (choice === 'change_auth') {
|
||||||
|
onChangeAuth();
|
||||||
|
} else {
|
||||||
|
await handleExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleExit, onChangeAuth, appealUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" padding={1}>
|
||||||
|
<Text bold color={theme.status.error}>
|
||||||
|
Error: Account Suspended
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text>{accountSuspensionInfo.message}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{appealUrl && (
|
||||||
|
<>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text>Appeal URL:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={theme.text.link}>[{appealUrl}]</Text>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={theme.status.error}>{errorMessage}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<RadioButtonSelect
|
||||||
|
items={items}
|
||||||
|
onSelect={(choice) => void handleSelect(choice)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text dimColor>Escape to exit</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`BannedAccountDialog > renders snapshot correctly 1`] = `
|
||||||
|
"
|
||||||
|
Error: Account Suspended
|
||||||
|
|
||||||
|
This service has been disabled in this account for violation of Terms of Service. Please submit an
|
||||||
|
appeal to continue using this product.
|
||||||
|
|
||||||
|
Appeal URL:
|
||||||
|
[https://example.com/appeal]
|
||||||
|
|
||||||
|
Appeal HereChange authenticationExit
|
||||||
|
|
||||||
|
Escape to exit
|
||||||
|
"
|
||||||
|
`;
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type Config,
|
type Config,
|
||||||
loadApiKey,
|
loadApiKey,
|
||||||
debugLogger,
|
debugLogger,
|
||||||
|
isAccountSuspendedError,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||||
import { AuthState } from '../types.js';
|
import { AuthState } from '../types.js';
|
||||||
@@ -34,16 +35,21 @@ export function validateAuthMethodWithSettings(
|
|||||||
return validateAuthMethod(authType);
|
return validateAuthMethod(authType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
|
||||||
|
|
||||||
export const useAuthCommand = (
|
export const useAuthCommand = (
|
||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
config: Config,
|
config: Config,
|
||||||
initialAuthError: string | null = null,
|
initialAuthError: string | null = null,
|
||||||
|
initialAccountSuspensionInfo: AccountSuspensionInfo | null = null,
|
||||||
) => {
|
) => {
|
||||||
const [authState, setAuthState] = useState<AuthState>(
|
const [authState, setAuthState] = useState<AuthState>(
|
||||||
initialAuthError ? AuthState.Updating : AuthState.Unauthenticated,
|
initialAuthError ? AuthState.Updating : AuthState.Unauthenticated,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [authError, setAuthError] = useState<string | null>(initialAuthError);
|
const [authError, setAuthError] = useState<string | null>(initialAuthError);
|
||||||
|
const [accountSuspensionInfo, setAccountSuspensionInfo] =
|
||||||
|
useState<AccountSuspensionInfo | null>(initialAccountSuspensionInfo);
|
||||||
const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState<
|
const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
@@ -130,7 +136,16 @@ export const useAuthCommand = (
|
|||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
setAuthState(AuthState.Authenticated);
|
setAuthState(AuthState.Authenticated);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
|
const suspendedError = isAccountSuspendedError(e);
|
||||||
|
if (suspendedError) {
|
||||||
|
setAccountSuspensionInfo({
|
||||||
|
message: suspendedError.message,
|
||||||
|
appealUrl: suspendedError.appealUrl,
|
||||||
|
appealLinkText: suspendedError.appealLinkText,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [
|
}, [
|
||||||
@@ -150,5 +165,7 @@ export const useAuthCommand = (
|
|||||||
onAuthError,
|
onAuthError,
|
||||||
apiKeyDefaultValue,
|
apiKeyDefaultValue,
|
||||||
reloadApiKey,
|
reloadApiKey,
|
||||||
|
accountSuspensionInfo,
|
||||||
|
setAccountSuspensionInfo,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ThemeDialog } from './ThemeDialog.js';
|
|||||||
import { SettingsDialog } from './SettingsDialog.js';
|
import { SettingsDialog } from './SettingsDialog.js';
|
||||||
import { AuthInProgress } from '../auth/AuthInProgress.js';
|
import { AuthInProgress } from '../auth/AuthInProgress.js';
|
||||||
import { AuthDialog } from '../auth/AuthDialog.js';
|
import { AuthDialog } from '../auth/AuthDialog.js';
|
||||||
|
import { BannedAccountDialog } from '../auth/BannedAccountDialog.js';
|
||||||
import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
|
import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
|
||||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||||
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
|
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
|
||||||
@@ -320,6 +321,21 @@ export const DialogManager = ({
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (uiState.accountSuspensionInfo) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<BannedAccountDialog
|
||||||
|
accountSuspensionInfo={uiState.accountSuspensionInfo}
|
||||||
|
onExit={() => {
|
||||||
|
process.exit(1);
|
||||||
|
}}
|
||||||
|
onChangeAuth={() => {
|
||||||
|
uiActions.clearAccountSuspension();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (uiState.isAuthenticating) {
|
if (uiState.isAuthenticating) {
|
||||||
return (
|
return (
|
||||||
<AuthInProgress
|
<AuthInProgress
|
||||||
@@ -342,6 +358,7 @@ export const DialogManager = ({
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.isAuthDialogOpen) {
|
if (uiState.isAuthDialogOpen) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export interface UIActions {
|
|||||||
handleRestart: () => void;
|
handleRestart: () => void;
|
||||||
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
|
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
|
||||||
getPreferredEditor: () => EditorType | undefined;
|
getPreferredEditor: () => EditorType | undefined;
|
||||||
|
clearAccountSuspension: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ export interface QuotaState {
|
|||||||
emptyWalletRequest: EmptyWalletDialogRequest | null;
|
emptyWalletRequest: EmptyWalletDialogRequest | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccountSuspensionInfo {
|
||||||
|
message: string;
|
||||||
|
appealUrl?: string;
|
||||||
|
appealLinkText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UIState {
|
export interface UIState {
|
||||||
history: HistoryItem[];
|
history: HistoryItem[];
|
||||||
historyManager: UseHistoryManagerReturn;
|
historyManager: UseHistoryManagerReturn;
|
||||||
@@ -107,6 +113,7 @@ export interface UIState {
|
|||||||
isAuthenticating: boolean;
|
isAuthenticating: boolean;
|
||||||
isConfigInitialized: boolean;
|
isConfigInitialized: boolean;
|
||||||
authError: string | null;
|
authError: string | null;
|
||||||
|
accountSuspensionInfo: AccountSuspensionInfo | null;
|
||||||
isAuthDialogOpen: boolean;
|
isAuthDialogOpen: boolean;
|
||||||
isAwaitingApiKeyInput: boolean;
|
isAwaitingApiKeyInput: boolean;
|
||||||
apiKeyDefaultValue?: string;
|
apiKeyDefaultValue?: string;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
toFriendlyError,
|
toFriendlyError,
|
||||||
BadRequestError,
|
BadRequestError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
|
AccountSuspendedError,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
getErrorType,
|
getErrorType,
|
||||||
FatalAuthenticationError,
|
FatalAuthenticationError,
|
||||||
@@ -127,9 +128,86 @@ describe('toFriendlyError', () => {
|
|||||||
};
|
};
|
||||||
const result = toFriendlyError(error);
|
const result = toFriendlyError(error);
|
||||||
expect(result).toBeInstanceOf(ForbiddenError);
|
expect(result).toBeInstanceOf(ForbiddenError);
|
||||||
|
expect(result).not.toBeInstanceOf(AccountSuspendedError);
|
||||||
expect((result as ForbiddenError).message).toBe('Forbidden');
|
expect((result as ForbiddenError).message).toBe('Forbidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return AccountSuspendedError for 403 with TOS_VIOLATION reason in details', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
error: {
|
||||||
|
code: 403,
|
||||||
|
message:
|
||||||
|
'This service has been disabled in this account for violation of Terms of Service.',
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||||
|
reason: 'TOS_VIOLATION',
|
||||||
|
domain: 'example.googleapis.com',
|
||||||
|
metadata: {
|
||||||
|
uiMessage: 'true',
|
||||||
|
appeal_url_link_text: 'Appeal Here',
|
||||||
|
appeal_url: 'https://example.com/appeal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = toFriendlyError(error);
|
||||||
|
expect(result).toBeInstanceOf(AccountSuspendedError);
|
||||||
|
expect(result).toBeInstanceOf(ForbiddenError);
|
||||||
|
const suspended = result as AccountSuspendedError;
|
||||||
|
expect(suspended.message).toBe(
|
||||||
|
'This service has been disabled in this account for violation of Terms of Service.',
|
||||||
|
);
|
||||||
|
expect(suspended.appealUrl).toBe('https://example.com/appeal');
|
||||||
|
expect(suspended.appealLinkText).toBe('Appeal Here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ForbiddenError for 403 with violation message but no TOS_VIOLATION detail', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
error: {
|
||||||
|
code: 403,
|
||||||
|
message:
|
||||||
|
'This service has been disabled in this account for violation of Terms of Service.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = toFriendlyError(error);
|
||||||
|
expect(result).toBeInstanceOf(ForbiddenError);
|
||||||
|
expect(result).not.toBeInstanceOf(AccountSuspendedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ForbiddenError for 403 with non-TOS_VIOLATION detail', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
error: {
|
||||||
|
code: 403,
|
||||||
|
message: 'Forbidden',
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||||
|
reason: 'ACCESS_DENIED',
|
||||||
|
domain: 'googleapis.com',
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = toFriendlyError(error);
|
||||||
|
expect(result).toBeInstanceOf(ForbiddenError);
|
||||||
|
expect(result).not.toBeInstanceOf(AccountSuspendedError);
|
||||||
|
});
|
||||||
|
|
||||||
it('should parse stringified JSON data', () => {
|
it('should parse stringified JSON data', () => {
|
||||||
const error = {
|
const error = {
|
||||||
response: {
|
response: {
|
||||||
@@ -236,6 +314,9 @@ describe('getErrorType', () => {
|
|||||||
'FatalCancellationError',
|
'FatalCancellationError',
|
||||||
);
|
);
|
||||||
expect(getErrorType(new ForbiddenError('test'))).toBe('ForbiddenError');
|
expect(getErrorType(new ForbiddenError('test'))).toBe('ForbiddenError');
|
||||||
|
expect(getErrorType(new AccountSuspendedError('test'))).toBe(
|
||||||
|
'AccountSuspendedError',
|
||||||
|
);
|
||||||
expect(getErrorType(new UnauthorizedError('test'))).toBe(
|
expect(getErrorType(new UnauthorizedError('test'))).toBe(
|
||||||
'UnauthorizedError',
|
'UnauthorizedError',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { parseGoogleApiError, type ErrorInfo } from './googleErrors.js';
|
||||||
|
|
||||||
interface GaxiosError {
|
interface GaxiosError {
|
||||||
response?: {
|
response?: {
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
@@ -107,6 +109,17 @@ export class CanceledError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ForbiddenError extends Error {}
|
export class ForbiddenError extends Error {}
|
||||||
|
export class AccountSuspendedError extends ForbiddenError {
|
||||||
|
readonly appealUrl?: string;
|
||||||
|
readonly appealLinkText?: string;
|
||||||
|
|
||||||
|
constructor(message: string, metadata?: Record<string, string>) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AccountSuspendedError';
|
||||||
|
this.appealUrl = metadata?.['appeal_url'];
|
||||||
|
this.appealLinkText = metadata?.['appeal_url_link_text'];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class UnauthorizedError extends Error {}
|
export class UnauthorizedError extends Error {}
|
||||||
export class BadRequestError extends Error {}
|
export class BadRequestError extends Error {}
|
||||||
|
|
||||||
@@ -157,6 +170,24 @@ function isResponseData(data: unknown): data is ResponseData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toFriendlyError(error: unknown): unknown {
|
export function toFriendlyError(error: unknown): unknown {
|
||||||
|
// First, try structured parsing for TOS_VIOLATION detection.
|
||||||
|
const googleApiError = parseGoogleApiError(error);
|
||||||
|
if (googleApiError && googleApiError.code === 403) {
|
||||||
|
const tosDetail = googleApiError.details.find(
|
||||||
|
(d): d is ErrorInfo =>
|
||||||
|
d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo' &&
|
||||||
|
'reason' in d &&
|
||||||
|
d.reason === 'TOS_VIOLATION',
|
||||||
|
);
|
||||||
|
if (tosDetail) {
|
||||||
|
return new AccountSuspendedError(
|
||||||
|
googleApiError.message,
|
||||||
|
tosDetail.metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to basic Gaxios error parsing for other HTTP errors.
|
||||||
if (isGaxiosError(error)) {
|
if (isGaxiosError(error)) {
|
||||||
const data = parseResponseData(error);
|
const data = parseResponseData(error);
|
||||||
if (data && data.error && data.error.message && data.error.code) {
|
if (data && data.error && data.error.message && data.error.code) {
|
||||||
@@ -166,9 +197,6 @@ export function toFriendlyError(error: unknown): unknown {
|
|||||||
case 401:
|
case 401:
|
||||||
return new UnauthorizedError(data.error.message);
|
return new UnauthorizedError(data.error.message);
|
||||||
case 403:
|
case 403:
|
||||||
// It's import to pass the message here since it might
|
|
||||||
// explain the cause like "the cloud project you're
|
|
||||||
// using doesn't have code assist enabled".
|
|
||||||
return new ForbiddenError(data.error.message);
|
return new ForbiddenError(data.error.message);
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
@@ -177,6 +205,13 @@ export function toFriendlyError(error: unknown): unknown {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAccountSuspendedError(
|
||||||
|
error: unknown,
|
||||||
|
): AccountSuspendedError | null {
|
||||||
|
const friendly = toFriendlyError(error);
|
||||||
|
return friendly instanceof AccountSuspendedError ? friendly : null;
|
||||||
|
}
|
||||||
|
|
||||||
function parseResponseData(error: GaxiosError): ResponseData | undefined {
|
function parseResponseData(error: GaxiosError): ResponseData | undefined {
|
||||||
let data = error.response?.data;
|
let data = error.response?.data;
|
||||||
// Inexplicably, Gaxios sometimes doesn't JSONify the response data.
|
// Inexplicably, Gaxios sometimes doesn't JSONify the response data.
|
||||||
|
|||||||
Reference in New Issue
Block a user