From 077c749cb701d721d20fd07e0730a0171148435f Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 18:43:50 +0300 Subject: [PATCH] fix(windows): harden elevated runtime detection --- ...TeamsRuntimeProviderManagementCliClient.ts | 2 +- .../runtime/ClaudeMultimodelBridgeService.ts | 9 +- src/main/utils/windowsElevation.ts | 110 +++++- .../WindowsAdministratorBanner.test.tsx | 84 ++++- ...RuntimeProviderManagementCliClient.test.ts | 3 +- .../ClaudeMultimodelBridgeService.test.ts | 24 +- test/main/utils/windowsElevation.test.ts | 336 +++++++++++++++++- .../utils/windowsElevationRuntime.test.ts | 176 +++++++++ .../preload/electronApiMemberWorkSync.test.ts | 21 ++ 9 files changed, 735 insertions(+), 30 deletions(-) create mode 100644 test/main/utils/windowsElevationRuntime.test.ts diff --git a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts index a2942160..a979d611 100644 --- a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts +++ b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts @@ -28,8 +28,8 @@ import type { } from '@features/runtime-provider-management/contracts'; import type { ChildProcessWithoutNullStreams } from 'child_process'; -const COMMAND_TIMEOUT_MS = 45_000; const PROBE_COMMAND_TIMEOUT_MS = 90_000; +const COMMAND_TIMEOUT_MS = PROBE_COMMAND_TIMEOUT_MS; const COMMAND_ERROR_DETAIL_LIMIT = 1_600; const COMMAND_OUTPUT_PREVIEW_LIMIT = 1_200; const ESCAPE_CHARACTER = String.fromCharCode(27); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 72aecb3f..73f8d55c 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -23,8 +23,8 @@ import type { const logger = createLogger('ClaudeMultimodelBridgeService'); -const PROVIDER_STATUS_TIMEOUT_MS = 25_000; -const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 15_000; +const PROVIDER_STATUS_TIMEOUT_MS = 90_000; +const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 30_000; const PROVIDER_MODELS_TIMEOUT_MS = 25_000; const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024; const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024; @@ -995,8 +995,11 @@ export class ClaudeMultimodelBridgeService { if (options.summary) { args.push('--summary'); } + const timeout = + options.timeoutMs ?? + (options.summary ? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS : PROVIDER_STATUS_TIMEOUT_MS); const { stdout } = await execCli(binaryPath, args, { - timeout: options.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS, + timeout, maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES, env, }); diff --git a/src/main/utils/windowsElevation.ts b/src/main/utils/windowsElevation.ts index f241fed9..2c386721 100644 --- a/src/main/utils/windowsElevation.ts +++ b/src/main/utils/windowsElevation.ts @@ -5,6 +5,24 @@ import type { WindowsElevationStatus } from '@shared/types/api'; const DEFAULT_WINDOWS_ELEVATION_TIMEOUT_MS = 3_000; const DEFAULT_WINDOWS_SYSTEM_ROOT = 'C:\\Windows'; +const NON_ELEVATED_FLTMC_PATTERNS = [ + /\baccess\s+is\s+denied\b/i, + /\baccess\s+denied\b/i, + /\boperation\s+not\s+permitted\b/i, + /requires?\s+elevation/i, + /\belevated\b/i, + /\badministrator\b/i, + /\bprivileges?\b/i, + /\bpermission\s+denied\b/i, + /not\s+held\s+by\s+the\s+client/i, +]; +const PROBE_UNAVAILABLE_PATTERNS = [ + /cannot\s+find/i, + /not\s+found/i, + /not\s+recognized/i, + /not\s+a\s+valid\s+win32\s+application/i, + /bad\s+exe\s+format/i, +]; export interface WindowsElevationCommandResult { error: unknown; @@ -22,6 +40,7 @@ export type WindowsElevationCommandRunner = ( export interface WindowsElevationStatusCheckerOptions { platform?: string; + arch?: string; systemRoot?: string; timeoutMs?: number; runCommand?: WindowsElevationCommandRunner; @@ -63,6 +82,10 @@ function wasKilledOrTimedOut(error: unknown): boolean { return killed === true || signal === 'SIGTERM' || code === 'ETIMEDOUT'; } +function isMissingCommand(error: unknown): boolean { + return getErrorCode(error) === 'ENOENT'; +} + function toCappedString(value: unknown): string | null { if (typeof value === 'string') { return value.slice(0, 500); @@ -84,8 +107,50 @@ function getErrorMessage(error: unknown, stderr: unknown): string | null { return null; } -function getFltmcPath(systemRoot: string): string { - return pathWin32.join(systemRoot, 'System32', 'fltmc.exe'); +function getCombinedErrorText(error: unknown, stderr: unknown): string { + return [getErrorMessage(error, null), toCappedString(stderr)] + .filter((part): part is string => Boolean(part?.trim())) + .join('\n'); +} + +function looksLikeNonElevatedFltmcError(error: unknown, stderr: unknown): boolean { + const code = getErrorCode(error); + const combined = getCombinedErrorText(error, stderr); + if (PROBE_UNAVAILABLE_PATTERNS.some((pattern) => pattern.test(combined))) { + return false; + } + + if (code === 1 || code === '1' || code === 5 || code === '5') { + return true; + } + + return NON_ELEVATED_FLTMC_PATTERNS.some((pattern) => pattern.test(combined)); +} + +function normalizeWindowsRoot(value: string | null | undefined): string | null { + const normalized = value?.trim().replace(/^['"]|['"]$/g, ''); + if (!normalized || !pathWin32.isAbsolute(normalized)) { + return null; + } + return normalized; +} + +function resolveWindowsSystemRoot(explicitSystemRoot: string | undefined): string { + if (explicitSystemRoot !== undefined) { + return normalizeWindowsRoot(explicitSystemRoot) ?? DEFAULT_WINDOWS_SYSTEM_ROOT; + } + + return ( + normalizeWindowsRoot(process.env.SystemRoot) ?? + normalizeWindowsRoot(process.env.windir) ?? + normalizeWindowsRoot(process.env.WINDIR) ?? + DEFAULT_WINDOWS_SYSTEM_ROOT + ); +} + +function getFltmcPathCandidates(systemRoot: string, arch: string): string[] { + const systemDirs = arch === 'ia32' ? ['Sysnative', 'System32'] : ['System32']; + return systemDirs.map((dir) => pathWin32.join(systemRoot, dir, 'fltmc.exe')); } function runFltmc(command: string, options: WindowsElevationCommandOptions) { @@ -105,7 +170,8 @@ export function createWindowsElevationStatusChecker( options: WindowsElevationStatusCheckerOptions = {} ): () => Promise { const platform = options.platform ?? process.platform; - const systemRoot = options.systemRoot ?? process.env.SystemRoot ?? DEFAULT_WINDOWS_SYSTEM_ROOT; + const arch = options.arch ?? process.arch; + const systemRoot = resolveWindowsSystemRoot(options.systemRoot); const timeoutMs = options.timeoutMs ?? DEFAULT_WINDOWS_ELEVATION_TIMEOUT_MS; const runCommand = options.runCommand ?? runFltmc; @@ -114,24 +180,36 @@ export function createWindowsElevationStatusChecker( return createStatus(platform, null, false); } - let result: WindowsElevationCommandResult; - try { - result = await runCommand(getFltmcPath(systemRoot), { timeoutMs }); - } catch (error) { - return createStatus(platform, null, true, getErrorMessage(error, null)); - } + for (const command of getFltmcPathCandidates(systemRoot, arch)) { + let result: WindowsElevationCommandResult; + try { + result = await runCommand(command, { timeoutMs }); + } catch (error) { + if (isMissingCommand(error)) { + continue; + } + return createStatus(platform, null, true, getErrorMessage(error, null)); + } - if (!result.error) { - return createStatus(platform, true, false); - } + if (!result.error) { + return createStatus(platform, true, false); + } + + const message = getErrorMessage(result.error, result.stderr); + if (isMissingCommand(result.error)) { + continue; + } + if (wasKilledOrTimedOut(result.error)) { + return createStatus(platform, null, true, message); + } + if (looksLikeNonElevatedFltmcError(result.error, result.stderr)) { + return createStatus(platform, false, false, message); + } - const code = getErrorCode(result.error); - const message = getErrorMessage(result.error, result.stderr); - if (code === 'ENOENT' || wasKilledOrTimedOut(result.error)) { return createStatus(platform, null, true, message); } - return createStatus(platform, false, false, message); + return createStatus(platform, null, true, 'Windows elevation probe command was not found.'); }; } diff --git a/src/renderer/components/dashboard/WindowsAdministratorBanner.test.tsx b/src/renderer/components/dashboard/WindowsAdministratorBanner.test.tsx index 750fb018..247cc809 100644 --- a/src/renderer/components/dashboard/WindowsAdministratorBanner.test.tsx +++ b/src/renderer/components/dashboard/WindowsAdministratorBanner.test.tsx @@ -19,7 +19,22 @@ function createStatus(overrides: Partial = {}): WindowsE } function installElevationStatus(status: WindowsElevationStatus) { - const getWindowsElevationStatus = vi.fn().mockResolvedValue(status); + return installElevationStatusPromise(Promise.resolve(status)); +} + +function installElevationStatusPromise(promise: Promise) { + const getWindowsElevationStatus = vi.fn().mockReturnValue(promise); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + getWindowsElevationStatus, + }, + }); + return getWindowsElevationStatus; +} + +function installElevationStatusFailure() { + const getWindowsElevationStatus = vi.fn().mockRejectedValue(new Error('IPC failed')); Object.defineProperty(window, 'electronAPI', { configurable: true, value: { @@ -110,6 +125,73 @@ describe('WindowsAdministratorBanner', () => { }); }); + it('hides the warning when the status check is inconclusive', async () => { + const getWindowsElevationStatus = installElevationStatus( + createStatus({ isAdministrator: null, checkFailed: true, error: 'probe unavailable' }) + ); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(WindowsAdministratorBanner)); + await flushReact(); + }); + + expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1); + expect(host.textContent).toBe(''); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + }); + + it('hides the warning when the status check rejects', async () => { + const getWindowsElevationStatus = installElevationStatusFailure(); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(WindowsAdministratorBanner)); + await flushReact(); + }); + + expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1); + expect(host.textContent).toBe(''); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + }); + + it('does not render stale status after unmount', async () => { + let resolveStatus: ((status: WindowsElevationStatus) => void) | null = null; + const pendingStatus = new Promise((resolve) => { + resolveStatus = resolve; + }); + const getWindowsElevationStatus = installElevationStatusPromise(pendingStatus); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(WindowsAdministratorBanner)); + await flushReact(); + }); + + await act(async () => { + root.unmount(); + resolveStatus?.(createStatus()); + await flushReact(); + }); + + expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1); + expect(host.textContent).toBe(''); + }); + it('hides the warning when the preload bridge does not expose the status check', async () => { Object.defineProperty(window, 'electronAPI', { configurable: true, diff --git a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts index 81661fb0..c9c6fc5c 100644 --- a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts +++ b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts @@ -1096,6 +1096,7 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { expect.arrayContaining(['runtime', 'providers', 'view']), expect.any(Object) ); + expect(execCliMock.mock.calls[0]?.[2]).toMatchObject({ timeout: 90_000 }); }); it('explains OpenCode CLI help output instead of returning a generic JSON error', async () => { @@ -1274,7 +1275,7 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { apiKey: 'sk-input-secret-value-123456', }); - await vi.advanceTimersByTimeAsync(45_000); + await vi.advanceTimersByTimeAsync(90_000); const response = await responsePromise; vi.useRealTimers(); diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index 97ca8457..f75b7614 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -350,7 +350,7 @@ describe('ClaudeMultimodelBridgeService', () => { if (normalizedArgs === 'runtime status --json --provider codex --summary') { return Promise.reject( new Error( - 'Command timed out after 25000ms: /mock/agent_teams_orchestrator runtime status --json --provider codex --summary' + 'Command timed out after 30000ms: /mock/agent_teams_orchestrator runtime status --json --provider codex --summary' ) ); } @@ -370,7 +370,7 @@ describe('ClaudeMultimodelBridgeService', () => { verificationState: 'error', statusMessage: 'Provider status unavailable', }); - expect(provider.detailMessage).toContain('Command timed out after 25000ms'); + expect(provider.detailMessage).toContain('Command timed out after 30000ms'); expect(calls).toEqual(['runtime status --json --provider codex --summary']); expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([ expect.stringContaining( @@ -386,7 +386,7 @@ describe('ClaudeMultimodelBridgeService', () => { if (normalizedArgs === 'runtime status --json --provider opencode --summary') { return Promise.reject( new Error( - 'Command timed out after 25000ms: /mock/agent_teams_orchestrator runtime status --json --provider opencode --summary' + 'Command timed out after 30000ms: /mock/agent_teams_orchestrator runtime status --json --provider opencode --summary' ) ); } @@ -412,7 +412,7 @@ describe('ClaudeMultimodelBridgeService', () => { 'not necessarily that OpenCode auth is missing' ); expect(provider.detailMessage).toContain('provider/model inventory'); - expect(provider.detailMessage).toContain('Raw timeout detail: Command timed out after 25000ms'); + expect(provider.detailMessage).toContain('Raw timeout detail: Command timed out after 30000ms'); expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).toEqual([ 'runtime status --json --provider opencode --summary', ]); @@ -509,9 +509,9 @@ describe('ClaudeMultimodelBridgeService', () => { expect(execCliMock).toHaveBeenCalledTimes(3); expect(execCliMock.mock.calls.map((call) => call[2]?.timeout)).toEqual([ - 15000, - 15000, - 15000, + 30000, + 30000, + 30000, ]); expect(calls).toEqual([ 'runtime status --json --provider anthropic --summary', @@ -895,6 +895,16 @@ describe('ClaudeMultimodelBridgeService', () => { defaultModelId: 'gpt-5.4', }, }); + expect( + execCliMock.mock.calls.find( + (call) => call[1].join(' ') === 'runtime status --json --provider codex --summary' + )?.[2]?.timeout + ).toBe(30_000); + expect( + execCliMock.mock.calls.find( + (call) => call[1].join(' ') === 'runtime status --json --provider codex' + )?.[2]?.timeout + ).toBe(90_000); }); it('queues fresh single-provider catalog hydration behind an in-flight one', async () => { diff --git a/test/main/utils/windowsElevation.test.ts b/test/main/utils/windowsElevation.test.ts index 5b772f33..8451ea99 100644 --- a/test/main/utils/windowsElevation.test.ts +++ b/test/main/utils/windowsElevation.test.ts @@ -14,9 +14,24 @@ function createError( return Object.assign(new Error(message), fields); } +const originalSystemRoot = process.env.SystemRoot; +const originalWindir = process.env.windir; +const originalWINDIR = process.env.WINDIR; + +function restoreEnvValue(name: 'SystemRoot' | 'windir' | 'WINDIR', value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + describe('windowsElevation', () => { afterEach(() => { resetWindowsElevationStatusCacheForTests(); + restoreEnvValue('SystemRoot', originalSystemRoot); + restoreEnvValue('windir', originalWindir); + restoreEnvValue('WINDIR', originalWINDIR); }); it('does not run the elevation command outside Windows', async () => { @@ -72,6 +87,145 @@ describe('windowsElevation', () => { expect(status.error).toBe('Access is denied.'); }); + it('reports non-elevated Windows when fltmc returns an access-denied message', async () => { + const runCommand = vi.fn().mockResolvedValue({ + error: createError('Command failed', { code: 'EPERM' }), + stderr: 'The requested operation requires elevation.', + }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + runCommand, + })(); + + expect(status.isAdministrator).toBe(false); + expect(status.checkFailed).toBe(false); + }); + + it('reports non-elevated Windows when stderr is a Buffer', async () => { + const runCommand = vi.fn().mockResolvedValue({ + error: createError('Command failed', { code: 'EPERM' }), + stderr: Buffer.from('Access is denied.', 'utf8'), + }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + runCommand, + })(); + + expect(status.isAdministrator).toBe(false); + expect(status.error).toBe('Access is denied.'); + }); + + it('uses the error message when stderr is empty', async () => { + const runCommand = vi.fn().mockResolvedValue({ + error: createError('operation not permitted', { code: 'EPERM' }), + stderr: '', + }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + runCommand, + })(); + + expect(status.isAdministrator).toBe(false); + expect(status.error).toBe('operation not permitted'); + }); + + it('reports non-elevated Windows when fltmc returns Windows access-denied code 5', async () => { + const runCommand = vi.fn().mockResolvedValue({ + error: createError('Command failed', { code: 5 }), + stderr: '', + }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + runCommand, + })(); + + expect(status.isAdministrator).toBe(false); + expect(status.checkFailed).toBe(false); + }); + + it('passes a custom timeout to the Windows probe command', async () => { + const runCommand = vi + .fn() + .mockResolvedValue({ error: null }); + + await createWindowsElevationStatusChecker({ + platform: 'win32', + timeoutMs: 750, + runCommand, + })(); + + expect(runCommand).toHaveBeenCalledWith('C:\\Windows\\System32\\fltmc.exe', { + timeoutMs: 750, + }); + }); + + it('reports an unknown status when fltmc fails for an unrelated reason', async () => { + const runCommand = vi.fn().mockResolvedValue({ + error: createError('Unexpected fltmc failure', { code: 2 }), + stderr: 'The system cannot find the file specified.', + }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + runCommand, + })(); + + expect(status.isAdministrator).toBeNull(); + expect(status.checkFailed).toBe(true); + expect(status.error).toBe('The system cannot find the file specified.'); + }); + + it('caps long probe error text before returning it to the renderer', async () => { + const longError = 'x'.repeat(600); + const runCommand = vi.fn().mockResolvedValue({ + error: createError('Unexpected fltmc failure', { code: 2 }), + stderr: longError, + }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + runCommand, + })(); + + expect(status.isAdministrator).toBeNull(); + expect(status.error).toHaveLength(500); + }); + + it('does not treat code 1 as non-elevated when the probe executable is unavailable', async () => { + const runCommand = vi.fn().mockResolvedValue({ + error: createError('Command failed', { code: 1 }), + stderr: 'The system cannot find the file specified.', + }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + runCommand, + })(); + + expect(status.isAdministrator).toBeNull(); + expect(status.checkFailed).toBe(true); + expect(status.error).toBe('The system cannot find the file specified.'); + }); + + it('does not treat code 1 as non-elevated when Windows cannot recognize the probe command', async () => { + const runCommand = vi.fn().mockResolvedValue({ + error: createError('Command failed', { code: 1 }), + stderr: "'fltmc.exe' is not recognized as an internal or external command.", + }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + runCommand, + })(); + + expect(status.isAdministrator).toBeNull(); + expect(status.checkFailed).toBe(true); + }); + it('reports an unknown status when the Windows probe command is missing', async () => { const runCommand = vi.fn().mockResolvedValue({ error: createError('spawn fltmc.exe ENOENT', { code: 'ENOENT' }), @@ -84,7 +238,23 @@ describe('windowsElevation', () => { expect(status.isAdministrator).toBeNull(); expect(status.checkFailed).toBe(true); - expect(status.error).toContain('ENOENT'); + expect(status.error).toBe('Windows elevation probe command was not found.'); + }); + + it('continues to the next path when the probe command throws ENOENT', async () => { + const runCommand = vi + .fn() + .mockRejectedValueOnce(createError('spawn fltmc.exe ENOENT', { code: 'ENOENT' })) + .mockResolvedValueOnce({ error: null }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + arch: 'ia32', + runCommand, + })(); + + expect(runCommand).toHaveBeenCalledTimes(2); + expect(status.isAdministrator).toBe(true); }); it('reports an unknown status when the Windows probe times out', async () => { @@ -102,6 +272,170 @@ describe('windowsElevation', () => { expect(status.error).toContain('Command timed out'); }); + it('does not try the System32 fallback after a Sysnative timeout', async () => { + const runCommand = vi.fn().mockResolvedValue({ + error: createError('Command timed out', { code: 'ETIMEDOUT', killed: true }), + }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + arch: 'ia32', + runCommand, + })(); + + expect(runCommand).toHaveBeenCalledTimes(1); + expect(status.isAdministrator).toBeNull(); + expect(status.checkFailed).toBe(true); + }); + + it('tries the Sysnative fltmc path first for 32-bit Windows processes', async () => { + const runCommand = vi + .fn() + .mockResolvedValueOnce({ + error: createError('spawn fltmc.exe ENOENT', { code: 'ENOENT' }), + }) + .mockResolvedValueOnce({ error: null }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + arch: 'ia32', + systemRoot: 'C:\\Windows', + runCommand, + })(); + + expect(runCommand).toHaveBeenNthCalledWith(1, 'C:\\Windows\\Sysnative\\fltmc.exe', { + timeoutMs: 3_000, + }); + expect(runCommand).toHaveBeenNthCalledWith(2, 'C:\\Windows\\System32\\fltmc.exe', { + timeoutMs: 3_000, + }); + expect(status.isAdministrator).toBe(true); + }); + + it('uses only System32 for non-32-bit Windows processes', async () => { + const runCommand = vi + .fn() + .mockResolvedValue({ error: null }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + arch: 'arm64', + systemRoot: 'C:\\Windows', + runCommand, + })(); + + expect(runCommand).toHaveBeenCalledTimes(1); + expect(runCommand).toHaveBeenCalledWith('C:\\Windows\\System32\\fltmc.exe', { + timeoutMs: 3_000, + }); + expect(status.isAdministrator).toBe(true); + }); + + it('continues from missing Sysnative to a non-elevated System32 result', async () => { + const runCommand = vi + .fn() + .mockResolvedValueOnce({ + error: createError('spawn fltmc.exe ENOENT', { code: 'ENOENT' }), + }) + .mockResolvedValueOnce({ + error: createError('Command failed', { code: 5 }), + stderr: '', + }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + arch: 'ia32', + runCommand, + })(); + + expect(runCommand).toHaveBeenCalledTimes(2); + expect(status.isAdministrator).toBe(false); + }); + + it('falls back to the default Windows root when SystemRoot is empty', async () => { + const runCommand = vi + .fn() + .mockResolvedValue({ error: null }); + + const status = await createWindowsElevationStatusChecker({ + platform: 'win32', + systemRoot: '', + runCommand, + })(); + + expect(runCommand).toHaveBeenCalledWith('C:\\Windows\\System32\\fltmc.exe', { + timeoutMs: 3_000, + }); + expect(status.isAdministrator).toBe(true); + }); + + it('normalizes quoted Windows root values', async () => { + const runCommand = vi + .fn() + .mockResolvedValue({ error: null }); + + await createWindowsElevationStatusChecker({ + platform: 'win32', + systemRoot: ' "D:\\Windows" ', + runCommand, + })(); + + expect(runCommand).toHaveBeenCalledWith('D:\\Windows\\System32\\fltmc.exe', { + timeoutMs: 3_000, + }); + }); + + it('falls back to windir when SystemRoot is unavailable', async () => { + delete process.env.SystemRoot; + process.env.windir = 'E:\\WinDir'; + const runCommand = vi + .fn() + .mockResolvedValue({ error: null }); + + await createWindowsElevationStatusChecker({ + platform: 'win32', + runCommand, + })(); + + expect(runCommand).toHaveBeenCalledWith('E:\\WinDir\\System32\\fltmc.exe', { + timeoutMs: 3_000, + }); + }); + + it('falls back to uppercase WINDIR when SystemRoot and windir are unavailable', async () => { + delete process.env.SystemRoot; + delete process.env.windir; + process.env.WINDIR = 'F:\\Windows'; + const runCommand = vi + .fn() + .mockResolvedValue({ error: null }); + + await createWindowsElevationStatusChecker({ + platform: 'win32', + runCommand, + })(); + + expect(runCommand).toHaveBeenCalledWith('F:\\Windows\\System32\\fltmc.exe', { + timeoutMs: 3_000, + }); + }); + + it('falls back to the default Windows root for relative SystemRoot values', async () => { + const runCommand = vi + .fn() + .mockResolvedValue({ error: null }); + + await createWindowsElevationStatusChecker({ + platform: 'win32', + systemRoot: 'Windows', + runCommand, + })(); + + expect(runCommand).toHaveBeenCalledWith('C:\\Windows\\System32\\fltmc.exe', { + timeoutMs: 3_000, + }); + }); + it('reports an unknown status when the Windows probe throws before returning a result', async () => { const runCommand = vi .fn() diff --git a/test/main/utils/windowsElevationRuntime.test.ts b/test/main/utils/windowsElevationRuntime.test.ts new file mode 100644 index 00000000..c9aff270 --- /dev/null +++ b/test/main/utils/windowsElevationRuntime.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + execFile: vi.fn(), +})); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFile: mocks.execFile, + }; +}); + +type ExecFileCallback = ( + error: Error | null, + stdout: string | Buffer, + stderr: string | Buffer +) => void; + +function createError( + message: string, + fields: { code?: string | number; killed?: boolean; signal?: string | null } = {} +): Error & { code?: string | number; killed?: boolean; signal?: string | null } { + return Object.assign(new Error(message), fields); +} + +function setPlatform(value: string): void { + Object.defineProperty(process, 'platform', { + value, + configurable: true, + }); +} + +function setArch(value: string): void { + Object.defineProperty(process, 'arch', { + value, + configurable: true, + }); +} + +const originalPlatform = process.platform; +const originalArch = process.arch; +const originalSystemRoot = process.env.SystemRoot; +const originalWindir = process.env.windir; +const originalWINDIR = process.env.WINDIR; + +function restoreEnvValue(name: 'SystemRoot' | 'windir' | 'WINDIR', value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +async function importRuntime() { + return import('@main/utils/windowsElevation'); +} + +describe('windowsElevation runtime integration', () => { + beforeEach(() => { + vi.resetModules(); + mocks.execFile.mockReset(); + setPlatform(originalPlatform); + setArch(originalArch); + restoreEnvValue('SystemRoot', originalSystemRoot); + restoreEnvValue('windir', originalWindir); + restoreEnvValue('WINDIR', originalWINDIR); + }); + + afterEach(() => { + setPlatform(originalPlatform); + setArch(originalArch); + restoreEnvValue('SystemRoot', originalSystemRoot); + restoreEnvValue('windir', originalWindir); + restoreEnvValue('WINDIR', originalWINDIR); + vi.restoreAllMocks(); + }); + + it('does not invoke execFile when the process platform is not Windows', async () => { + setPlatform('linux'); + const { getWindowsElevationStatus } = await importRuntime(); + + const status = await getWindowsElevationStatus(); + + expect(mocks.execFile).not.toHaveBeenCalled(); + expect(status).toEqual({ + platform: 'linux', + isWindows: false, + isAdministrator: null, + checkFailed: false, + error: null, + }); + }); + + it('runs fltmc with hidden-window timeout options on Windows', async () => { + setPlatform('win32'); + setArch('x64'); + process.env.SystemRoot = 'C:\\Windows'; + mocks.execFile.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(null, '', ''); + } + ); + const { getWindowsElevationStatus } = await importRuntime(); + + const status = await getWindowsElevationStatus(); + + expect(mocks.execFile).toHaveBeenCalledTimes(1); + expect(mocks.execFile).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\fltmc.exe', + [], + { timeout: 3_000, windowsHide: true }, + expect.any(Function) + ); + expect(status.isAdministrator).toBe(true); + }); + + it('coalesces concurrent status requests into one Windows subprocess', async () => { + setPlatform('win32'); + setArch('x64'); + const captured: { callback?: ExecFileCallback } = {}; + mocks.execFile.mockImplementation( + (_command: string, _args: string[], _options: unknown, nextCallback: ExecFileCallback) => { + captured.callback = nextCallback; + } + ); + const { getWindowsElevationStatus } = await importRuntime(); + + const first = getWindowsElevationStatus(); + const second = getWindowsElevationStatus(); + + expect(mocks.execFile).toHaveBeenCalledTimes(1); + expect(captured.callback).toBeTypeOf('function'); + captured.callback?.(null, '', ''); + await expect(first).resolves.toMatchObject({ isAdministrator: true }); + await expect(second).resolves.toMatchObject({ isAdministrator: true }); + }); + + it('reuses the cached result after the first Windows probe completes', async () => { + setPlatform('win32'); + setArch('x64'); + mocks.execFile.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(createError('Command failed', { code: 5 }), '', ''); + } + ); + const { getWindowsElevationStatus } = await importRuntime(); + + const first = await getWindowsElevationStatus(); + const second = await getWindowsElevationStatus(); + + expect(mocks.execFile).toHaveBeenCalledTimes(1); + expect(first).toEqual(second); + expect(second.isAdministrator).toBe(false); + }); + + it('runs a new probe after the cache is reset for tests', async () => { + setPlatform('win32'); + setArch('x64'); + mocks.execFile.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(null, '', ''); + } + ); + const { getWindowsElevationStatus, resetWindowsElevationStatusCacheForTests } = + await importRuntime(); + + await getWindowsElevationStatus(); + resetWindowsElevationStatusCacheForTests(); + await getWindowsElevationStatus(); + + expect(mocks.execFile).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/preload/electronApiMemberWorkSync.test.ts b/test/preload/electronApiMemberWorkSync.test.ts index 762fce10..b96c178a 100644 --- a/test/preload/electronApiMemberWorkSync.test.ts +++ b/test/preload/electronApiMemberWorkSync.test.ts @@ -41,6 +41,7 @@ describe('preload electronAPI memberWorkSync wiring', () => { vi.resetModules(); vi.useFakeTimers(); mocks.contextBridge.exposeInMainWorld.mockClear(); + mocks.ipcRenderer.invoke.mockClear(); mocks.createMemberWorkSyncBridge.mockClear(); }); @@ -64,4 +65,24 @@ describe('preload electronAPI memberWorkSync wiring', () => { expect(apiName).toBe('electronAPI'); expect(electronAPI.memberWorkSync).toBe(mocks.memberWorkSyncBridge); }); + + it('wires the Windows elevation status API to the app IPC channel', async () => { + await import('../../src/preload/index'); + + const [, electronAPI] = mocks.contextBridge.exposeInMainWorld.mock.calls[0] as [ + string, + ElectronAPI, + ]; + const expectedStatus = { + platform: 'win32', + isWindows: true, + isAdministrator: false, + checkFailed: false, + error: null, + }; + mocks.ipcRenderer.invoke.mockResolvedValueOnce(expectedStatus); + + await expect(electronAPI.getWindowsElevationStatus()).resolves.toBe(expectedStatus); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith('app:getWindowsElevationStatus'); + }); });