Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions packages/cli/src/config/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ import { AuthType } from '@google/gemini-cli-core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { validateAuthMethod } from './auth.js';

vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
loadApiKey: vi.fn().mockResolvedValue(null),
};
});

vi.mock('./settings.js', () => ({
loadEnvironment: vi.fn(),
loadSettings: vi.fn().mockReturnValue({
Expand Down Expand Up @@ -90,10 +99,10 @@ describe('validateAuthMethod', () => {
envs: {},
expected: 'Invalid auth method selected.',
},
])('$description', ({ authType, envs, expected }) => {
])('$description', async ({ authType, envs, expected }) => {
for (const [key, value] of Object.entries(envs)) {
vi.stubEnv(key, value as string);
}
expect(validateAuthMethod(authType)).toBe(expected);
expect(await validateAuthMethod(authType)).toBe(expected);
});
});
9 changes: 6 additions & 3 deletions packages/cli/src/config/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { AuthType } from '@google/gemini-cli-core';
import { AuthType, loadApiKey } from '@google/gemini-cli-core';
import { loadEnvironment, loadSettings } from './settings.js';

export function validateAuthMethod(authMethod: string): string | null {
export async function validateAuthMethod(
authMethod: string,
): Promise<string | null> {
loadEnvironment(loadSettings().merged, process.cwd());
if (
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
Expand All @@ -17,7 +19,8 @@ export function validateAuthMethod(authMethod: string): string | null {
}

if (authMethod === AuthType.USE_GEMINI) {
if (!process.env['GEMINI_API_KEY']) {
const key = process.env['GEMINI_API_KEY'] || (await loadApiKey());
if (!key) {
return (
'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' +
'Update your environment and try again (no reload needed if using .env)!'
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
validateNonInteractiveAuth: vi.fn().mockResolvedValue('google'),
}));

vi.mock('./config/auth.js', () => ({
validateAuthMethod: vi.fn().mockResolvedValue(null),
}));

describe('gemini.tsx main function', () => {
let originalIsTTY: boolean | undefined;
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
Expand Down Expand Up @@ -1276,6 +1280,44 @@ describe('gemini.tsx main function exit codes', () => {
}
});

it('should exit with 41 for validateAuthMethod failure during sandbox setup', async () => {
vi.stubEnv('SANDBOX', '');
vi.mocked(loadSandboxConfig).mockResolvedValue(
createMockSandboxConfig({
command: 'docker',
image: 'test-image',
}),
);
vi.mocked(loadCliConfig).mockResolvedValue(
createMockConfig({
refreshAuth: vi.fn().mockResolvedValue(undefined),
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
isInteractive: vi.fn().mockReturnValue(true),
}),
);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
security: { auth: { selectedType: 'google', useExternal: false } },
},
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as CliArgs);

const authModule = await import('./config/auth.js');
vi.mocked(authModule.validateAuthMethod).mockResolvedValueOnce(
'Auth method invalid',
);

try {
await main();
expect.fail('Should have thrown MockProcessExitError');
} catch (e) {
expect(e).toBeInstanceOf(MockProcessExitError);
expect((e as MockProcessExitError).code).toBe(41);
}
});

it('should exit with 41 for auth failure during sandbox setup', async () => {
vi.stubEnv('SANDBOX', '');
vi.mocked(loadSandboxConfig).mockResolvedValue(
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ export async function main() {
partialConfig.isInteractive() &&
settings.merged.security.auth.selectedType
) {
const err = validateAuthMethod(
const err = await validateAuthMethod(
settings.merged.security.auth.selectedType,
);
if (err) {
Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ vi.mock('./hooks/useQuotaAndFallback.js');
vi.mock('./hooks/useHistoryManager.js');
vi.mock('./hooks/useThemeCommand.js');
vi.mock('./auth/useAuth.js');
vi.mock('../config/auth.js', () => ({
validateAuthMethod: vi.fn().mockResolvedValue(null),
}));
vi.mock('./hooks/useEditorSettings.js');
vi.mock('./hooks/useSettingsCommand.js');
vi.mock('./hooks/useModelCommand.js');
Expand Down Expand Up @@ -217,6 +220,7 @@ vi.mock('../utils/cleanup.js');
import { useHistory } from './hooks/useHistoryManager.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './auth/useAuth.js';
import { validateAuthMethod } from '../config/auth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
Expand Down Expand Up @@ -576,6 +580,36 @@ describe('AppContainer State Management', () => {
});

describe('State Initialization', () => {
it('calls validateAuthMethod and onAuthError if validation fails', async () => {
const mockOnAuthError = vi.fn();
mockedUseAuthCommand.mockReturnValue({
authState: 'authenticated',
setAuthState: vi.fn(),
authError: null,
onAuthError: mockOnAuthError,
});
vi.mocked(validateAuthMethod).mockResolvedValueOnce('Validation Failed');

const { unmount } = await act(async () =>
renderAppContainer({
settings: createMockSettings({
merged: {
security: {
auth: { selectedType: 'oauth-personal', useExternal: false },
},
},
}),
}),
);

await waitFor(() => {
expect(validateAuthMethod).toHaveBeenCalledWith('oauth-personal');
expect(mockOnAuthError).toHaveBeenCalledWith('Validation Failed');
});

unmount();
});

it('sends a macOS notification when confirmation is pending and terminal is unfocused', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
Expand Down
22 changes: 16 additions & 6 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -912,12 +912,22 @@ Logging in with Google... Restarting Gemini CLI to continue.
return;
}

const error = validateAuthMethod(
settings.merged.security.auth.selectedType,
);
if (error) {
onAuthError(error);
}
const authMethod = settings.merged.security.auth.selectedType;
void (async () => {
try {
const error = await validateAuthMethod(authMethod);
if (
error &&
authMethod === settings.merged.security.auth.selectedType
) {
onAuthError(error);
}
} catch (e) {
if (authMethod === settings.merged.security.auth.selectedType) {
onAuthError(getErrorMessage(e));
}
}
})();
Comment thread
cocosheng-g marked this conversation as resolved.
Comment thread
cocosheng-g marked this conversation as resolved.
}
}, [
settings.merged.security.auth.selectedType,
Expand Down
22 changes: 11 additions & 11 deletions packages/cli/src/ui/auth/AuthDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,11 @@ describe('AuthDialog', () => {

describe('handleAuthSelect', () => {
it('calls onAuthError if validation fails', async () => {
mockedValidateAuthMethod.mockReturnValue('Invalid method');
mockedValidateAuthMethod.mockResolvedValue('Invalid method');
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
handleAuthSelect(AuthType.USE_GEMINI);
await handleAuthSelect(AuthType.USE_GEMINI);

expect(mockedValidateAuthMethod).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
Expand All @@ -231,7 +231,7 @@ describe('AuthDialog', () => {
});

it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
Expand All @@ -245,7 +245,7 @@ describe('AuthDialog', () => {

it('sets auth context with requiresRestart: true for USE_VERTEX_AI in Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
Expand All @@ -259,7 +259,7 @@ describe('AuthDialog', () => {

it('sets auth context with empty object for USE_VERTEX_AI outside Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', '');
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
Expand All @@ -270,7 +270,7 @@ describe('AuthDialog', () => {
});

it('sets auth context with empty object for other auth types', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
Expand All @@ -281,7 +281,7 @@ describe('AuthDialog', () => {
});

it('always shows API key dialog even when env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup

Expand All @@ -297,7 +297,7 @@ describe('AuthDialog', () => {
});

it('always shows API key dialog even when env var is empty string', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
// props.settings.merged.security.auth.selectedType is undefined here

Expand All @@ -313,7 +313,7 @@ describe('AuthDialog', () => {
});

it('shows API key dialog on initial setup if no env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
// process.env['GEMINI_API_KEY'] is not set
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup

Expand All @@ -329,7 +329,7 @@ describe('AuthDialog', () => {
});

it('always shows API key dialog on re-auth even if env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// Simulate switching from a different auth method (e.g., Google Login → API key)
props.settings.merged.security.auth.selectedType =
Expand All @@ -353,7 +353,7 @@ describe('AuthDialog', () => {
.mockImplementation(() => undefined as never);
const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);

const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/ui/auth/AuthDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,11 @@ export function AuthDialog({
[settings, config, setAuthState, exiting, setAuthContext],
);

const handleAuthSelect = (authMethod: AuthType) => {
const error = validateAuthMethodWithSettings(authMethod, settings);
const handleAuthSelect = async (authMethod: AuthType) => {
const error = await validateAuthMethodWithSettings(
authMethod,
settings,
).catch((e) => (e instanceof Error ? e.message : String(e)));
if (error) {
onAuthError(error);
} else {
Expand Down
20 changes: 10 additions & 10 deletions packages/cli/src/ui/auth/useAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('useAuth', () => {
});

describe('validateAuthMethodWithSettings', () => {
it('should return error if auth type is enforced and does not match', () => {
it('should return error if auth type is enforced and does not match', async () => {
const settings = {
merged: {
security: {
Expand All @@ -56,14 +56,14 @@ describe('useAuth', () => {
},
} as LoadedSettings;

const error = validateAuthMethodWithSettings(
const error = await validateAuthMethodWithSettings(
AuthType.USE_GEMINI,
settings,
);
expect(error).toContain('Authentication is enforced to be oauth');
});

it('should return null if useExternal is true', () => {
it('should return null if useExternal is true', async () => {
const settings = {
merged: {
security: {
Expand All @@ -74,14 +74,14 @@ describe('useAuth', () => {
},
} as LoadedSettings;

const error = validateAuthMethodWithSettings(
const error = await validateAuthMethodWithSettings(
AuthType.LOGIN_WITH_GOOGLE,
settings,
);
expect(error).toBeNull();
});

it('should return null if authType is USE_GEMINI', () => {
it('should return null if authType is USE_GEMINI', async () => {
const settings = {
merged: {
security: {
Expand All @@ -90,14 +90,14 @@ describe('useAuth', () => {
},
} as LoadedSettings;

const error = validateAuthMethodWithSettings(
const error = await validateAuthMethodWithSettings(
AuthType.USE_GEMINI,
settings,
);
expect(error).toBeNull();
});

it('should call validateAuthMethod for other auth types', () => {
it('should call validateAuthMethod for other auth types', async () => {
const settings = {
merged: {
security: {
Expand All @@ -106,8 +106,8 @@ describe('useAuth', () => {
},
} as LoadedSettings;

mockValidateAuthMethod.mockReturnValue('Validation Error');
const error = validateAuthMethodWithSettings(
mockValidateAuthMethod.mockResolvedValue('Validation Error');
const error = await validateAuthMethodWithSettings(
AuthType.LOGIN_WITH_GOOGLE,
settings,
);
Expand Down Expand Up @@ -265,7 +265,7 @@ describe('useAuth', () => {
});

it('should set error if validation fails', async () => {
mockValidateAuthMethod.mockReturnValue('Validation Failed');
mockValidateAuthMethod.mockResolvedValue('Validation Failed');
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
Expand Down
Loading
Loading