feat(cli): implement atomic writes and safety checks for trusted folders (#18406)

This commit is contained in:
Gal Zahavi
2026-02-09 09:16:56 -08:00
committed by GitHub
parent 01906a9205
commit 81ccd80c6d
16 changed files with 549 additions and 971 deletions
+3
View File
@@ -31,6 +31,7 @@
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"@types/shell-quote": "^1.7.5", "@types/shell-quote": "^1.7.5",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^3.1.1", "@vitest/coverage-v8": "^3.1.1",
"@vitest/eslint-plugin": "^1.3.4", "@vitest/eslint-plugin": "^1.3.4",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@@ -18138,6 +18139,7 @@
"mnemonist": "^0.40.3", "mnemonist": "^0.40.3",
"open": "^10.1.2", "open": "^10.1.2",
"prompts": "^2.4.2", "prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"react": "^19.2.0", "react": "^19.2.0",
"read-package-up": "^11.0.0", "read-package-up": "^11.0.0",
"shell-quote": "^1.8.3", "shell-quote": "^1.8.3",
@@ -18241,6 +18243,7 @@
"mnemonist": "^0.40.3", "mnemonist": "^0.40.3",
"open": "^10.1.2", "open": "^10.1.2",
"picomatch": "^4.0.1", "picomatch": "^4.0.1",
"proper-lockfile": "^4.1.2",
"read-package-up": "^11.0.0", "read-package-up": "^11.0.0",
"shell-quote": "^1.8.3", "shell-quote": "^1.8.3",
"simple-git": "^3.28.0", "simple-git": "^3.28.0",
+1
View File
@@ -90,6 +90,7 @@
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"@types/shell-quote": "^1.7.5", "@types/shell-quote": "^1.7.5",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^3.1.1", "@vitest/coverage-v8": "^3.1.1",
"@vitest/eslint-plugin": "^1.3.4", "@vitest/eslint-plugin": "^1.3.4",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
+1
View File
@@ -54,6 +54,7 @@
"mnemonist": "^0.40.3", "mnemonist": "^0.40.3",
"open": "^10.1.2", "open": "^10.1.2",
"prompts": "^2.4.2", "prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"react": "^19.2.0", "react": "^19.2.0",
"read-package-up": "^11.0.0", "read-package-up": "^11.0.0",
"shell-quote": "^1.8.3", "shell-quote": "^1.8.3",
+4 -1
View File
@@ -188,7 +188,10 @@ export class ExtensionManager extends ExtensionLoader {
) )
) { ) {
const trustedFolders = loadTrustedFolders(); const trustedFolders = loadTrustedFolders();
trustedFolders.setValue(this.workspaceDir, TrustLevel.TRUST_FOLDER); await trustedFolders.setValue(
this.workspaceDir,
TrustLevel.TRUST_FOLDER,
);
} else { } else {
throw new Error( throw new Error(
`Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`, `Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`,
@@ -5,23 +5,20 @@
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { getMissingSettings } from './extensionSettings.js'; import { getMissingSettings } from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js'; import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js';
import { import {
KeychainTokenStorage,
debugLogger, debugLogger,
type ExtensionInstallMetadata, type ExtensionInstallMetadata,
type GeminiCLIExtension, type GeminiCLIExtension,
coreEvents, coreEvents,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
import { ExtensionManager } from '../extension-manager.js'; import { ExtensionManager } from '../extension-manager.js';
import { createTestMergedSettings } from '../settings.js'; import { createTestMergedSettings } from '../settings.js';
// --- Mocks ---
vi.mock('node:fs', async (importOriginal) => { vi.mock('node:fs', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = await importOriginal<any>(); const actual = await importOriginal<any>();
@@ -29,11 +26,23 @@ vi.mock('node:fs', async (importOriginal) => {
...actual, ...actual,
default: { default: {
...actual.default, ...actual.default,
// eslint-disable-next-line @typescript-eslint/no-explicit-any existsSync: vi.fn(),
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)), statSync: vi.fn(),
lstatSync: vi.fn(),
realpathSync: vi.fn((p) => p),
},
existsSync: vi.fn(),
statSync: vi.fn(),
lstatSync: vi.fn(),
realpathSync: vi.fn((p) => p),
promises: {
...actual.promises,
mkdir: vi.fn(),
writeFile: vi.fn(),
rm: vi.fn(),
cp: vi.fn(),
readFile: vi.fn(),
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
}; };
}); });
@@ -49,183 +58,93 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
log: vi.fn(), log: vi.fn(),
}, },
coreEvents: { coreEvents: {
emitFeedback: vi.fn(), // Mock emitFeedback emitFeedback: vi.fn(),
on: vi.fn(), on: vi.fn(),
off: vi.fn(), off: vi.fn(),
emitConsoleLog: vi.fn(),
}, },
loadSkillsFromDir: vi.fn().mockResolvedValue([]),
loadAgentsFromDirectory: vi
.fn()
.mockResolvedValue({ agents: [], errors: [] }),
}; };
}); });
// Mock os.homedir because ExtensionStorage uses it vi.mock('./consent.js', () => ({
maybeRequestConsentOrFail: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./extensionSettings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('./extensionSettings.js')>();
return {
...actual,
getEnvContents: vi.fn().mockResolvedValue({}),
getMissingSettings: vi.fn(), // We will mock this implementation per test
};
});
vi.mock('../trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }), // Default to trusted to simplify flow
loadTrustedFolders: vi.fn().mockReturnValue({
setValue: vi.fn().mockResolvedValue(undefined),
}),
TrustLevel: { TRUST_FOLDER: 'TRUST_FOLDER' },
}));
// Mock ExtensionStorage to avoid real FS paths
vi.mock('./storage.js', () => ({
ExtensionStorage: class {
constructor(public name: string) {}
getExtensionDir() {
return `/mock/extensions/${this.name}`;
}
static getUserExtensionsDir() {
return '/mock/extensions';
}
static createTmpDir() {
return Promise.resolve('/mock/tmp');
}
},
}));
vi.mock('os', async (importOriginal) => { vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>(); const mockedOs = await importOriginal<typeof import('node:os')>();
return { return {
...mockedOs, ...mockedOs,
homedir: vi.fn(), homedir: vi.fn().mockReturnValue('/mock/home'),
}; };
}); });
describe('extensionUpdates', () => { describe('extensionUpdates', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string; let tempWorkspaceDir: string;
let extensionDir: string;
let mockKeychainData: Record<string, Record<string, string>>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockKeychainData = {}; // Default fs mocks
vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.promises.rm).mockResolvedValue(undefined);
vi.mocked(fs.promises.cp).mockResolvedValue(undefined);
// Mock Keychain // Allow directories to exist by default to satisfy Config/WorkspaceContext checks
vi.mocked(KeychainTokenStorage).mockImplementation( vi.mocked(fs.existsSync).mockReturnValue(true);
(serviceName: string) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!mockKeychainData[serviceName]) { vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
mockKeychainData[serviceName] = {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any
} vi.mocked(fs.lstatSync).mockReturnValue({ isDirectory: () => true } as any);
const keychainData = mockKeychainData[serviceName]; vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
return {
getSecret: vi
.fn()
.mockImplementation(
async (key: string) => keychainData[key] || null,
),
setSecret: vi
.fn()
.mockImplementation(async (key: string, value: string) => {
keychainData[key] = value;
}),
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
delete keychainData[key];
}),
listSecrets: vi
.fn()
.mockImplementation(async () => Object.keys(keychainData)),
isAvailable: vi.fn().mockResolvedValue(true),
} as unknown as KeychainTokenStorage;
},
);
// Setup Temp Dirs tempWorkspaceDir = '/mock/workspace';
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
// Mock ExtensionStorage to rely on our temp extension dir
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
extensionDir,
);
// Mock getEnvFilePath is checking extensionDir/variables.env? No, it used ExtensionStorage logic.
// getEnvFilePath in extensionSettings.ts:
// if workspace, process.cwd()/.env (we need to mock process.cwd or move tempWorkspaceDir there)
// if user, ExtensionStorage(name).getEnvFilePath() -> joins extensionDir + '.env'
fs.mkdirSync(extensionDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
}); });
afterEach(() => { afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe('getMissingSettings', () => {
it('should return empty list if all settings are present', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
// Setup User Env
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
fs.writeFileSync(userEnvPath, 'VAR1=val1');
// Setup Keychain
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext ${extensionId}`,
);
await userKeychain.setSecret('VAR2', 'val2');
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toEqual([]);
});
it('should identify missing non-sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s1');
});
it('should identify missing sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s2');
});
it('should respect settings present in workspace', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
// Setup Workspace Env
const workspaceEnvPath = path.join(
tempWorkspaceDir,
EXTENSION_SETTINGS_FILENAME,
);
fs.writeFileSync(workspaceEnvPath, 'VAR1=val1');
const missing = await getMissingSettings(
config,
extensionId,
tempWorkspaceDir,
);
expect(missing).toEqual([]);
});
});
describe('ExtensionManager integration', () => { describe('ExtensionManager integration', () => {
it('should warn about missing settings after update', async () => { it('should warn about missing settings after update', async () => {
// Mock ExtensionManager methods to avoid FS/Network usage // 1. Setup Data
const newConfig: ExtensionConfig = { const newConfig: ExtensionConfig = {
name: 'test-ext', name: 'test-ext',
version: '1.1.0', version: '1.1.0',
@@ -239,31 +158,30 @@ describe('extensionUpdates', () => {
}; };
const installMetadata: ExtensionInstallMetadata = { const installMetadata: ExtensionInstallMetadata = {
source: extensionDir, source: '/mock/source',
type: 'local', type: 'local',
autoUpdate: true, autoUpdate: true,
}; };
// 2. Setup Manager
const manager = new ExtensionManager({ const manager = new ExtensionManager({
workspaceDir: tempWorkspaceDir, workspaceDir: tempWorkspaceDir,
settings: createTestMergedSettings({ settings: createTestMergedSettings({
telemetry: { enabled: false }, telemetry: { enabled: false },
experimental: { extensionConfig: true }, experimental: { extensionConfig: true },
}), }),
requestConsent: vi.fn().mockResolvedValue(true), requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null, // Simulate non-interactive requestSetting: null,
}); });
// Mock methods called by installOrUpdateExtension // 3. Mock Internal Manager Methods
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig); vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
vi.spyOn(manager, 'getExtensions').mockReturnValue([ vi.spyOn(manager, 'getExtensions').mockReturnValue([
{ {
name: 'test-ext', name: 'test-ext',
version: '1.0.0', version: '1.0.0',
installMetadata, installMetadata,
path: extensionDir, path: '/mock/extensions/test-ext',
// Mocks for other required props
contextFiles: [], contextFiles: [],
mcpServers: {}, mcpServers: {},
hooks: undefined, hooks: undefined,
@@ -275,23 +193,28 @@ describe('extensionUpdates', () => {
} as unknown as GeminiCLIExtension, } as unknown as GeminiCLIExtension,
]); ]);
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined); vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
// Mock loadExtension to return something so the method doesn't crash at the end
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue( vi.spyOn(manager as any, 'loadExtension').mockResolvedValue({
{} as unknown as GeminiCLIExtension, name: 'test-ext',
); version: '1.1.0',
vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined); } as GeminiCLIExtension);
// Mock fs.promises for the operations inside installOrUpdateExtension // 4. Mock External Helpers
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); // This is the key fix: we explicitly mock `getMissingSettings` to return
vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined); // the result we expect, avoiding any real FS or logic execution during the update.
vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined); vi.mocked(getMissingSettings).mockResolvedValue([
vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks {
try { name: 's1',
await manager.installOrUpdateExtension(installMetadata, previousConfig); description: 'd1',
} catch (_) { envVar: 'VAR1',
// Ignore errors from copyExtension or others, we just want to verify the warning },
} ]);
// 5. Execute
await manager.installOrUpdateExtension(installMetadata, previousConfig);
// 6. Assert
expect(debugLogger.warn).toHaveBeenCalledWith( expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining( expect.stringContaining(
'Extension "test-ext" has missing settings: s1', 'Extension "test-ext" has missing settings: s1',
File diff suppressed because it is too large Load Diff
+92 -20
View File
@@ -6,6 +6,8 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { lock } from 'proper-lockfile';
import { import {
FatalConfigError, FatalConfigError,
getErrorMessage, getErrorMessage,
@@ -13,10 +15,13 @@ import {
ideContextStore, ideContextStore,
GEMINI_DIR, GEMINI_DIR,
homedir, homedir,
coreEvents,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { Settings } from './settings.js'; import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments'; import stripJsonComments from 'strip-json-comments';
const { promises: fsPromises } = fs;
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
export function getUserSettingsDir(): string { export function getUserSettingsDir(): string {
@@ -67,6 +72,13 @@ export interface TrustResult {
const realPathCache = new Map<string, string>(); const realPathCache = new Map<string, string>();
/**
* Parses the trusted folders JSON content, stripping comments.
*/
function parseTrustedFoldersJson(content: string): unknown {
return JSON.parse(stripJsonComments(content));
}
/** /**
* FOR TESTING PURPOSES ONLY. * FOR TESTING PURPOSES ONLY.
* Clears the real path cache. * Clears the real path cache.
@@ -150,19 +162,67 @@ export class LoadedTrustedFolders {
return undefined; return undefined;
} }
setValue(path: string, trustLevel: TrustLevel): void { async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
const originalTrustLevel = this.user.config[path]; if (this.errors.length > 0) {
this.user.config[path] = trustLevel; const errorMessages = this.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
);
}
const dirPath = path.dirname(this.user.path);
if (!fs.existsSync(dirPath)) {
await fsPromises.mkdir(dirPath, { recursive: true });
}
// lockfile requires the file to exist
if (!fs.existsSync(this.user.path)) {
await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
mode: 0o600,
});
}
const release = await lock(this.user.path, {
retries: {
retries: 10,
minTimeout: 100,
},
});
try { try {
saveTrustedFolders(this.user); // Re-read the file to handle concurrent updates
} catch (e) { const content = await fsPromises.readFile(this.user.path, 'utf-8');
// Revert the in-memory change if the save failed. let config: Record<string, TrustLevel>;
if (originalTrustLevel === undefined) { try {
delete this.user.config[path]; config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
} else { } catch (error) {
this.user.config[path] = originalTrustLevel; coreEvents.emitFeedback(
'error',
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
error,
);
config = {};
} }
throw e;
const originalTrustLevel = config[folderPath];
config[folderPath] = trustLevel;
this.user.config[folderPath] = trustLevel;
try {
saveTrustedFolders({ ...this.user, config });
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[folderPath];
} else {
this.user.config[folderPath] = originalTrustLevel;
}
throw e;
}
} finally {
await release();
} }
} }
} }
@@ -190,10 +250,7 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
try { try {
if (fs.existsSync(userPath)) { if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8'); const content = fs.readFileSync(userPath, 'utf-8');
const parsed = JSON.parse(stripJsonComments(content)) as Record< const parsed = parseTrustedFoldersJson(content) as Record<string, string>;
string,
string
>;
if ( if (
typeof parsed !== 'object' || typeof parsed !== 'object' ||
@@ -241,11 +298,26 @@ export function saveTrustedFolders(
fs.mkdirSync(dirPath, { recursive: true }); fs.mkdirSync(dirPath, { recursive: true });
} }
fs.writeFileSync( const content = JSON.stringify(trustedFoldersFile.config, null, 2);
trustedFoldersFile.path, const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
JSON.stringify(trustedFoldersFile.config, null, 2),
{ encoding: 'utf-8', mode: 0o600 }, try {
); fs.writeFileSync(tempPath, content, {
encoding: 'utf-8',
mode: 0o600,
});
fs.renameSync(tempPath, trustedFoldersFile.path);
} catch (error) {
// Clean up temp file if it was created but rename failed
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw error;
}
} }
/** Is folder trust feature enabled per the current applied settings */ /** Is folder trust feature enabled per the current applied settings */
@@ -67,7 +67,7 @@ describe('ConsentPrompt', () => {
unmount(); unmount();
}); });
it('calls onConfirm with true when "Yes" is selected', () => { it('calls onConfirm with true when "Yes" is selected', async () => {
const prompt = 'Are you sure?'; const prompt = 'Are you sure?';
const { unmount } = render( const { unmount } = render(
<ConsentPrompt <ConsentPrompt
@@ -78,7 +78,7 @@ describe('ConsentPrompt', () => {
); );
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
act(() => { await act(async () => {
onSelect(true); onSelect(true);
}); });
@@ -86,7 +86,7 @@ describe('ConsentPrompt', () => {
unmount(); unmount();
}); });
it('calls onConfirm with false when "No" is selected', () => { it('calls onConfirm with false when "No" is selected', async () => {
const prompt = 'Are you sure?'; const prompt = 'Are you sure?';
const { unmount } = render( const { unmount } = render(
<ConsentPrompt <ConsentPrompt
@@ -97,7 +97,7 @@ describe('ConsentPrompt', () => {
); );
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
act(() => { await act(async () => {
onSelect(false); onSelect(false);
}); });
@@ -46,22 +46,26 @@ describe('LogoutConfirmationDialog', () => {
expect(mockCall.isFocused).toBe(true); expect(mockCall.isFocused).toBe(true);
}); });
it('should call onSelect with LOGIN when Login is selected', () => { it('should call onSelect with LOGIN when Login is selected', async () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
renderWithProviders(<LogoutConfirmationDialog onSelect={onSelect} />); renderWithProviders(<LogoutConfirmationDialog onSelect={onSelect} />);
const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0]; const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0];
mockCall.onSelect(LogoutChoice.LOGIN); await act(async () => {
mockCall.onSelect(LogoutChoice.LOGIN);
});
expect(onSelect).toHaveBeenCalledWith(LogoutChoice.LOGIN); expect(onSelect).toHaveBeenCalledWith(LogoutChoice.LOGIN);
}); });
it('should call onSelect with EXIT when Exit is selected', () => { it('should call onSelect with EXIT when Exit is selected', async () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
renderWithProviders(<LogoutConfirmationDialog onSelect={onSelect} />); renderWithProviders(<LogoutConfirmationDialog onSelect={onSelect} />);
const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0]; const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0];
mockCall.onSelect(LogoutChoice.EXIT); await act(async () => {
mockCall.onSelect(LogoutChoice.EXIT);
});
expect(onSelect).toHaveBeenCalledWith(LogoutChoice.EXIT); expect(onSelect).toHaveBeenCalledWith(LogoutChoice.EXIT);
}); });
@@ -125,7 +125,10 @@ export const MultiFolderTrustDialog: React.FC<MultiFolderTrustDialogProps> = ({
try { try {
const expandedPath = path.resolve(expandHomeDir(dir)); const expandedPath = path.resolve(expandHomeDir(dir));
if (choice === MultiFolderTrustChoice.YES_AND_REMEMBER) { if (choice === MultiFolderTrustChoice.YES_AND_REMEMBER) {
trustedFolders.setValue(expandedPath, TrustLevel.TRUST_FOLDER); await trustedFolders.setValue(
expandedPath,
TrustLevel.TRUST_FOLDER,
);
} }
workspaceContext.addDirectory(expandedPath); workspaceContext.addDirectory(expandedPath);
added.push(dir); added.push(dir);
@@ -69,13 +69,14 @@ export function PermissionsModifyTrustDialog({
return true; return true;
} }
if (needsRestart && key.name === 'r') { if (needsRestart && key.name === 'r') {
const success = commitTrustLevelChange(); void (async () => {
if (success) { const success = await commitTrustLevelChange();
// eslint-disable-next-line @typescript-eslint/no-floating-promises if (success) {
relaunchApp(); void relaunchApp();
} else { } else {
onExit(); onExit();
} }
})();
return true; return true;
} }
return false; return false;
@@ -149,7 +149,9 @@ describe('useFolderTrust', () => {
}); });
await act(async () => { await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER); await result.current.handleFolderTrustSelect(
FolderTrustChoice.TRUST_FOLDER,
);
}); });
await waitFor(() => { await waitFor(() => {
@@ -173,7 +175,9 @@ describe('useFolderTrust', () => {
); );
await act(async () => { await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_PARENT); await result.current.handleFolderTrustSelect(
FolderTrustChoice.TRUST_PARENT,
);
}); });
await waitFor(() => { await waitFor(() => {
@@ -197,7 +201,9 @@ describe('useFolderTrust', () => {
); );
await act(async () => { await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.DO_NOT_TRUST); await result.current.handleFolderTrustSelect(
FolderTrustChoice.DO_NOT_TRUST,
);
}); });
await waitFor(() => { await waitFor(() => {
@@ -221,7 +227,7 @@ describe('useFolderTrust', () => {
); );
await act(async () => { await act(async () => {
result.current.handleFolderTrustSelect( await result.current.handleFolderTrustSelect(
'invalid_choice' as FolderTrustChoice, 'invalid_choice' as FolderTrustChoice,
); );
}); });
@@ -253,7 +259,9 @@ describe('useFolderTrust', () => {
}); });
await act(async () => { await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER); await result.current.handleFolderTrustSelect(
FolderTrustChoice.TRUST_FOLDER,
);
}); });
await waitFor(() => { await waitFor(() => {
@@ -272,7 +280,9 @@ describe('useFolderTrust', () => {
); );
await act(async () => { await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER); await result.current.handleFolderTrustSelect(
FolderTrustChoice.TRUST_FOLDER,
);
}); });
await waitFor(() => { await waitFor(() => {
@@ -294,8 +304,10 @@ describe('useFolderTrust', () => {
useFolderTrust(mockSettings, onTrustChange, addItem), useFolderTrust(mockSettings, onTrustChange, addItem),
); );
act(() => { await act(async () => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER); await result.current.handleFolderTrustSelect(
FolderTrustChoice.TRUST_FOLDER,
);
}); });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
+2 -2
View File
@@ -48,7 +48,7 @@ export const useFolderTrust = (
}, [folderTrust, onTrustChange, settings.merged, addItem]); }, [folderTrust, onTrustChange, settings.merged, addItem]);
const handleFolderTrustSelect = useCallback( const handleFolderTrustSelect = useCallback(
(choice: FolderTrustChoice) => { async (choice: FolderTrustChoice) => {
const trustLevelMap: Record<FolderTrustChoice, TrustLevel> = { const trustLevelMap: Record<FolderTrustChoice, TrustLevel> = {
[FolderTrustChoice.TRUST_FOLDER]: TrustLevel.TRUST_FOLDER, [FolderTrustChoice.TRUST_FOLDER]: TrustLevel.TRUST_FOLDER,
[FolderTrustChoice.TRUST_PARENT]: TrustLevel.TRUST_PARENT, [FolderTrustChoice.TRUST_PARENT]: TrustLevel.TRUST_PARENT,
@@ -62,7 +62,7 @@ export const useFolderTrust = (
const trustedFolders = loadTrustedFolders(); const trustedFolders = loadTrustedFolders();
try { try {
trustedFolders.setValue(cwd, trustLevel); await trustedFolders.setValue(cwd, trustLevel);
} catch (_e) { } catch (_e) {
coreEvents.emitFeedback( coreEvents.emitFeedback(
'error', 'error',
@@ -142,7 +142,7 @@ describe('usePermissionsModifyTrust', () => {
expect(result.current.isInheritedTrustFromParent).toBe(false); expect(result.current.isInheritedTrustFromParent).toBe(false);
}); });
it('should set needsRestart but not save when trust changes', () => { it('should set needsRestart but not save when trust changes', async () => {
const mockSetValue = vi.fn(); const mockSetValue = vi.fn();
mockedLoadTrustedFolders.mockReturnValue({ mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} }, user: { config: {} },
@@ -157,15 +157,15 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
); );
act(() => { await act(async () => {
result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER); await result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
}); });
expect(result.current.needsRestart).toBe(true); expect(result.current.needsRestart).toBe(true);
expect(mockSetValue).not.toHaveBeenCalled(); expect(mockSetValue).not.toHaveBeenCalled();
}); });
it('should save immediately if trust does not change', () => { it('should save immediately if trust does not change', async () => {
const mockSetValue = vi.fn(); const mockSetValue = vi.fn();
mockedLoadTrustedFolders.mockReturnValue({ mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} }, user: { config: {} },
@@ -181,8 +181,8 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
); );
act(() => { await act(async () => {
result.current.updateTrustLevel(TrustLevel.TRUST_PARENT); await result.current.updateTrustLevel(TrustLevel.TRUST_PARENT);
}); });
expect(result.current.needsRestart).toBe(false); expect(result.current.needsRestart).toBe(false);
@@ -193,7 +193,7 @@ describe('usePermissionsModifyTrust', () => {
expect(mockOnExit).toHaveBeenCalled(); expect(mockOnExit).toHaveBeenCalled();
}); });
it('should commit the pending trust level change', () => { it('should commit the pending trust level change', async () => {
const mockSetValue = vi.fn(); const mockSetValue = vi.fn();
mockedLoadTrustedFolders.mockReturnValue({ mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} }, user: { config: {} },
@@ -208,14 +208,14 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
); );
act(() => { await act(async () => {
result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER); await result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
}); });
expect(result.current.needsRestart).toBe(true); expect(result.current.needsRestart).toBe(true);
act(() => { await act(async () => {
result.current.commitTrustLevelChange(); await result.current.commitTrustLevelChange();
}); });
expect(mockSetValue).toHaveBeenCalledWith( expect(mockSetValue).toHaveBeenCalledWith(
@@ -224,7 +224,7 @@ describe('usePermissionsModifyTrust', () => {
); );
}); });
it('should add warning when setting DO_NOT_TRUST but still trusted by parent', () => { it('should add warning when setting DO_NOT_TRUST but still trusted by parent', async () => {
mockedLoadTrustedFolders.mockReturnValue({ mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} }, user: { config: {} },
setValue: vi.fn(), setValue: vi.fn(),
@@ -238,8 +238,8 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
); );
act(() => { await act(async () => {
result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST); await result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST);
}); });
expect(mockAddItem).toHaveBeenCalledWith( expect(mockAddItem).toHaveBeenCalledWith(
@@ -251,7 +251,7 @@ describe('usePermissionsModifyTrust', () => {
); );
}); });
it('should add warning when setting DO_NOT_TRUST but still trusted by IDE', () => { it('should add warning when setting DO_NOT_TRUST but still trusted by IDE', async () => {
mockedLoadTrustedFolders.mockReturnValue({ mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} }, user: { config: {} },
setValue: vi.fn(), setValue: vi.fn(),
@@ -265,8 +265,8 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
); );
act(() => { await act(async () => {
result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST); await result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST);
}); });
expect(mockAddItem).toHaveBeenCalledWith( expect(mockAddItem).toHaveBeenCalledWith(
@@ -299,7 +299,7 @@ describe('usePermissionsModifyTrust', () => {
expect(result.current.isInheritedTrustFromIde).toBe(false); expect(result.current.isInheritedTrustFromIde).toBe(false);
}); });
it('should save immediately without needing a restart', () => { it('should save immediately without needing a restart', async () => {
const mockSetValue = vi.fn(); const mockSetValue = vi.fn();
mockedLoadTrustedFolders.mockReturnValue({ mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} }, user: { config: {} },
@@ -314,8 +314,8 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory), usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory),
); );
act(() => { await act(async () => {
result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER); await result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
}); });
expect(result.current.needsRestart).toBe(false); expect(result.current.needsRestart).toBe(false);
@@ -326,7 +326,7 @@ describe('usePermissionsModifyTrust', () => {
expect(mockOnExit).toHaveBeenCalled(); expect(mockOnExit).toHaveBeenCalled();
}); });
it('should not add a warning when setting DO_NOT_TRUST', () => { it('should not add a warning when setting DO_NOT_TRUST', async () => {
mockedLoadTrustedFolders.mockReturnValue({ mockedLoadTrustedFolders.mockReturnValue({
user: { config: {} }, user: { config: {} },
setValue: vi.fn(), setValue: vi.fn(),
@@ -340,15 +340,15 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory), usePermissionsModifyTrust(mockOnExit, mockAddItem, otherDirectory),
); );
act(() => { await act(async () => {
result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST); await result.current.updateTrustLevel(TrustLevel.DO_NOT_TRUST);
}); });
expect(mockAddItem).not.toHaveBeenCalled(); expect(mockAddItem).not.toHaveBeenCalled();
}); });
}); });
it('should emit feedback when setValue throws in updateTrustLevel', () => { it('should emit feedback when setValue throws in updateTrustLevel', async () => {
const mockSetValue = vi.fn().mockImplementation(() => { const mockSetValue = vi.fn().mockImplementation(() => {
throw new Error('test error'); throw new Error('test error');
}); });
@@ -368,8 +368,8 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
); );
act(() => { await act(async () => {
result.current.updateTrustLevel(TrustLevel.TRUST_PARENT); await result.current.updateTrustLevel(TrustLevel.TRUST_PARENT);
}); });
expect(emitFeedbackSpy).toHaveBeenCalledWith( expect(emitFeedbackSpy).toHaveBeenCalledWith(
@@ -379,7 +379,7 @@ describe('usePermissionsModifyTrust', () => {
expect(mockOnExit).toHaveBeenCalled(); expect(mockOnExit).toHaveBeenCalled();
}); });
it('should emit feedback when setValue throws in commitTrustLevelChange', () => { it('should emit feedback when setValue throws in commitTrustLevelChange', async () => {
const mockSetValue = vi.fn().mockImplementation(() => { const mockSetValue = vi.fn().mockImplementation(() => {
throw new Error('test error'); throw new Error('test error');
}); });
@@ -398,12 +398,12 @@ describe('usePermissionsModifyTrust', () => {
usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()), usePermissionsModifyTrust(mockOnExit, mockAddItem, mockedCwd()),
); );
act(() => { await act(async () => {
result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER); await result.current.updateTrustLevel(TrustLevel.TRUST_FOLDER);
}); });
act(() => { await act(async () => {
const success = result.current.commitTrustLevelChange(); const success = await result.current.commitTrustLevelChange();
expect(success).toBe(false); expect(success).toBe(false);
}); });
@@ -92,12 +92,12 @@ export const usePermissionsModifyTrust = (
settings.merged.security.folderTrust.enabled ?? true; settings.merged.security.folderTrust.enabled ?? true;
const updateTrustLevel = useCallback( const updateTrustLevel = useCallback(
(trustLevel: TrustLevel) => { async (trustLevel: TrustLevel) => {
// If we are not editing the current workspace, the logic is simple: // If we are not editing the current workspace, the logic is simple:
// just save the setting and exit. No restart or warnings are needed. // just save the setting and exit. No restart or warnings are needed.
if (!isCurrentWorkspace) { if (!isCurrentWorkspace) {
const folders = loadTrustedFolders(); const folders = loadTrustedFolders();
folders.setValue(cwd, trustLevel); await folders.setValue(cwd, trustLevel);
onExit(); onExit();
return; return;
} }
@@ -140,7 +140,7 @@ export const usePermissionsModifyTrust = (
} else { } else {
const folders = loadTrustedFolders(); const folders = loadTrustedFolders();
try { try {
folders.setValue(cwd, trustLevel); await folders.setValue(cwd, trustLevel);
} catch (_e) { } catch (_e) {
coreEvents.emitFeedback( coreEvents.emitFeedback(
'error', 'error',
@@ -153,11 +153,11 @@ export const usePermissionsModifyTrust = (
[cwd, settings.merged, onExit, addItem, isCurrentWorkspace], [cwd, settings.merged, onExit, addItem, isCurrentWorkspace],
); );
const commitTrustLevelChange = useCallback(() => { const commitTrustLevelChange = useCallback(async () => {
if (pendingTrustLevel) { if (pendingTrustLevel) {
const folders = loadTrustedFolders(); const folders = loadTrustedFolders();
try { try {
folders.setValue(cwd, pendingTrustLevel); await folders.setValue(cwd, pendingTrustLevel);
return true; return true;
} catch (_e) { } catch (_e) {
coreEvents.emitFeedback( coreEvents.emitFeedback(
+1
View File
@@ -60,6 +60,7 @@
"mnemonist": "^0.40.3", "mnemonist": "^0.40.3",
"open": "^10.1.2", "open": "^10.1.2",
"picomatch": "^4.0.1", "picomatch": "^4.0.1",
"proper-lockfile": "^4.1.2",
"read-package-up": "^11.0.0", "read-package-up": "^11.0.0",
"shell-quote": "^1.8.3", "shell-quote": "^1.8.3",
"simple-git": "^3.28.0", "simple-git": "^3.28.0",