Files
orca/src/main/ipc/developer-permissions.test.ts
T
Brennan Benson b9e9811924 fix(permissions): use standard macOS Local Network request flow (STA-3505) (#12833)
* fix(permissions): surface macOS silent Local Network denial with diagnostic and workaround (STA-3505)

On macOS 27 beta, NECP silently denies Orca's whole process tree Local
Network access: no prompt fires, the app never appears in System
Settings, and terminal child processes fail with EHOSTUNREACH. The
Settings trigger swallowed the probe's socket error and reported
'unknown' + a 'Permission request sent' toast, indistinguishable from
success.

Classify the mDNS probe outcome (EHOSTUNREACH/EHOSTDOWN -> denied,
clean send -> granted, else unknown), remember the verdict for the
status chip, and render an inline diagnostic with the documented
NECP re-evaluation workaround when denial is detected.

* fix(permissions): avoid false Local Network grants

* fix(permissions): use standard Local Network request flow

* feat(permissions): add local network connection test

* fix(permissions): nest local network connection test

* fix(permissions): collapse connection test by default

* fix(permissions): emphasize connection test action

* fix(permissions): restore outlined connection action
2026-08-06 14:10:26 -07:00

246 lines
7.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
handleMock,
shellOpenExternalMock,
askForMediaAccessMock,
getMediaAccessStatusMock,
isTrustedAccessibilityClientMock,
getMacosFullDiskAccessStatusMock,
execFileMock,
createSocketMock,
socketMock,
socketState
} = vi.hoisted(() => {
const handleMock = vi.fn()
const socketState = {
sendCallback: null as ((error?: Error | null) => void) | null,
errorListener: null as ((error: Error) => void) | null
}
const socketMock = {
on: vi.fn(),
removeListener: vi.fn(),
bind: vi.fn(),
send: vi.fn(),
close: vi.fn()
}
return {
handleMock,
shellOpenExternalMock: vi.fn(),
askForMediaAccessMock: vi.fn(),
getMediaAccessStatusMock: vi.fn(),
isTrustedAccessibilityClientMock: vi.fn(),
getMacosFullDiskAccessStatusMock: vi.fn(),
execFileMock: vi.fn(),
createSocketMock: vi.fn(() => socketMock),
socketMock,
socketState
}
})
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock
},
shell: {
openExternal: shellOpenExternalMock
},
systemPreferences: {
askForMediaAccess: askForMediaAccessMock,
getMediaAccessStatus: getMediaAccessStatusMock,
isTrustedAccessibilityClient: isTrustedAccessibilityClientMock
}
}))
vi.mock('node:dgram', () => ({
default: {
createSocket: createSocketMock
}
}))
vi.mock('node:child_process', () => ({
execFile: execFileMock
}))
vi.mock('../macos-full-disk-access-status', () => ({
getMacosFullDiskAccessStatus: getMacosFullDiskAccessStatusMock
}))
import type { DeveloperPermissionState } from '../../shared/developer-permissions-types'
import { registerDeveloperPermissionHandlers } from './developer-permissions'
describe('registerDeveloperPermissionHandlers', () => {
const originalPlatform = process.platform
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllTimers()
handleMock.mockClear()
shellOpenExternalMock.mockClear()
askForMediaAccessMock.mockReset()
getMediaAccessStatusMock.mockReset()
isTrustedAccessibilityClientMock.mockReset()
getMacosFullDiskAccessStatusMock.mockReset()
getMacosFullDiskAccessStatusMock.mockResolvedValue('denied')
execFileMock.mockReset()
execFileMock.mockImplementation((...args: unknown[]) => {
const callback = args.at(-1)
if (typeof callback === 'function') {
const execCallback = callback as () => void
execCallback()
}
return { kill: vi.fn() }
})
createSocketMock.mockClear()
socketState.sendCallback = null
socketState.errorListener = null
socketMock.on.mockReset()
socketMock.on.mockImplementation((event: string, listener: (error: Error) => void) => {
if (event === 'error') {
socketState.errorListener = listener
}
})
socketMock.removeListener.mockClear()
socketMock.bind.mockReset()
socketMock.bind.mockImplementation((callback: () => void) => callback())
socketMock.send.mockReset()
socketMock.send.mockImplementation(
(
_message: Buffer,
_offset: number,
_length: number,
_port: number,
_address: string,
callback: (error?: Error | null) => void
) => {
socketState.sendCallback = callback
}
)
socketMock.close.mockClear()
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
})
afterEach(() => {
vi.useRealTimers()
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
function getRequestHandler(): (_event: unknown, args: { id: string }) => Promise<unknown> {
const call = handleMock.mock.calls.find(
(c: unknown[]) => c[0] === 'developerPermissions:request'
)
if (!call) {
throw new Error('developerPermissions:request handler not registered')
}
return call[1] as (_event: unknown, args: { id: string }) => Promise<unknown>
}
function getStatusHandler(): () => Promise<DeveloperPermissionState[]> {
const call = handleMock.mock.calls.find(
(c: unknown[]) => c[0] === 'developerPermissions:getStatus'
)
if (!call) {
throw new Error('developerPermissions:getStatus handler not registered')
}
return call[1] as () => Promise<DeveloperPermissionState[]>
}
function getOpenSettingsHandler(): (_event: unknown, args: { id: string }) => Promise<void> {
const call = handleMock.mock.calls.find(
(c: unknown[]) => c[0] === 'developerPermissions:openSettings'
)
if (!call) {
throw new Error('developerPermissions:openSettings handler not registered')
}
return call[1] as (_event: unknown, args: { id: string }) => Promise<void>
}
it('returns the Full Disk Access read-probe status', async () => {
getMacosFullDiskAccessStatusMock.mockResolvedValue('granted')
registerDeveloperPermissionHandlers()
const call = handleMock.mock.calls.find(
(registration: unknown[]) => registration[0] === 'developerPermissions:getStatus'
)
const handler = call?.[1] as (() => Promise<unknown>) | undefined
await expect(handler?.()).resolves.toContainEqual({
id: 'full-disk-access',
status: 'granted'
})
})
it('keeps the local-network status unknown when UDP send settles without an error', async () => {
registerDeveloperPermissionHandlers()
const result = getRequestHandler()({}, { id: 'local-network' })
expect(socketMock.send).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(1)
socketState.sendCallback?.()
await expect(result).resolves.toEqual({
id: 'local-network',
status: 'unknown',
openedSystemSettings: false
})
expect(vi.getTimerCount()).toBe(0)
expect(socketMock.removeListener).toHaveBeenCalledWith('error', expect.any(Function))
expect(socketMock.close).toHaveBeenCalledTimes(1)
})
it('keeps local-network status unknown when the prompt trigger receives a socket error', async () => {
registerDeveloperPermissionHandlers()
const result = getRequestHandler()({}, { id: 'local-network' })
socketState.errorListener?.(
Object.assign(new Error('send EHOSTUNREACH 224.0.0.251:5353'), {
code: 'EHOSTUNREACH'
})
)
await expect(result).resolves.toEqual({
id: 'local-network',
status: 'unknown',
openedSystemSettings: false
})
await expect(getStatusHandler()()).resolves.toContainEqual({
id: 'local-network',
status: 'unknown'
})
})
it('opens the standard macOS Local Network settings pane', async () => {
registerDeveloperPermissionHandlers()
await getOpenSettingsHandler()({}, { id: 'local-network' })
expect(shellOpenExternalMock).toHaveBeenCalledWith(
'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_LocalNetwork'
)
})
it('settles the automation prompt when osascript hangs', async () => {
const killMock = vi.fn()
execFileMock.mockImplementation(() => ({ kill: killMock }))
registerDeveloperPermissionHandlers()
const result = getRequestHandler()({}, { id: 'automation' })
let settled = false
void result.finally(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(3_000)
await Promise.resolve()
expect(settled).toBe(true)
await expect(result).resolves.toEqual({
id: 'automation',
status: 'unknown',
openedSystemSettings: false
})
expect(killMock).toHaveBeenCalled()
})
})