Files
orca/src/main/ipc/mobile.test.ts
T
Jinwoo HongandOrca 77b154d5dd Add Orca Relay desktop and mobile transport (#8536)
* feat(mobile): define relay protocol groundwork

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): implement replay-safe E2EE v2 sessions

Co-authored-by: Orca <help@stably.ai>

* test(auth): lock cloud refresh single-flight

Co-authored-by: Orca <help@stably.ai>

* test(mobile): complete E2EE v2 adversarial coverage

Co-authored-by: Orca <help@stably.ai>

* refactor(runtime): unify mobile socket wiring

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay control and data clients

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): coordinate desktop relay sessions

Co-authored-by: Orca <help@stably.ai>

* fix(auth): fence stale cloud session mutations

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay pairing and durable revoke

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay credential pairing RPCs

Co-authored-by: Orca <help@stably.ai>

* feat(settings): show Orca Relay sign-in status

Co-authored-by: Orca <help@stably.ai>

* test(relay): prove desktop lifecycle and E2EE splice

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): persist relay pairing state

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): race direct and relay pairing

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): recover pairing through relay director

Co-authored-by: Orca <help@stably.ai>

* fix(relay): preserve origin controls during drain

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): recover interrupted relay pairing

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): add stable relay RPC sessions

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): supervise direct and relay endpoints

Co-authored-by: Orca <help@stably.ai>

* Cover mobile relay director fallback matrix

Co-authored-by: Orca <help@stably.ai>

* Fix relay settings component test isolation

Co-authored-by: Orca <help@stably.ai>

* Remove unrelated merge formatting drift

Co-authored-by: Orca <help@stably.ai>

* Update runtime connection count integration assertion

Co-authored-by: Orca <help@stably.ai>

* Run mobile typecheck through pnpm

Co-authored-by: Orca <help@stably.ai>

* feat(relay): gate desktop controls on mobile demand

Co-authored-by: Orca <help@stably.ai>

* test(mobile): cover served relay recovery

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): upgrade direct pairings to relay

Co-authored-by: Orca <help@stably.ai>

* fix(relay): harden mobile reconnect and teardown

Co-authored-by: Orca <help@stably.ai>

* fix(auth): clarify account sign-in state

Co-authored-by: Orca <help@stably.ai>

* fix(auth): polish sign-in completion flow

Co-authored-by: Orca <help@stably.ai>

* fix(auth): clarify sign-out confirmation

Co-authored-by: Orca <help@stably.ai>

* fix(auth): simplify sign-in completion page

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): add per-device pairing connection mode

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): stabilize pairing option layout

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): give pairing choices stable space

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): stabilize pairing QR regeneration

Co-authored-by: Orca <help@stably.ai>

* Animate mobile pairing flow height

Co-authored-by: Orca <help@stably.ai>

* Configure auth in packaged builds

Co-authored-by: Orca <help@stably.ai>

* Make Orca Relay pairing an opt-in beta

Co-authored-by: Orca <help@stably.ai>

* Show Relay beta details on hover

Co-authored-by: Orca <help@stably.ai>

* Refine mobile relay pairing choice

Co-authored-by: Orca <help@stably.ai>

* Polish Orca Relay pairing controls

Co-authored-by: Orca <help@stably.ai>

* Keep mobile contract fallback test additive

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-14 11:47:05 -07:00

323 lines
9.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
networkInterfacesMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { isPackaged: false },
ipcMain: { handle: handleMock },
shell: { openExternal: vi.fn() }
}))
vi.mock('qrcode', () => ({
default: {
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,qr')
}
}))
vi.mock('os', () => ({
networkInterfaces: networkInterfacesMock
}))
import { registerMobileHandlers } from './mobile'
describe('registerMobileHandlers', () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>()
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
networkInterfacesMock.mockReset()
networkInterfacesMock.mockReturnValue({})
handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => {
handlers.set(channel, handler)
})
})
it('re-reads system network interfaces on each request and prefers tailnet addresses', () => {
networkInterfacesMock
.mockReturnValueOnce({
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }]
})
.mockReturnValueOnce({
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }],
tailscale0: [{ family: 'IPv4', internal: false, address: '100.64.1.20' }]
})
registerMobileHandlers({} as never)
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
interfaces: [{ name: 'en0', address: '192.168.1.24' }]
})
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
interfaces: [
{ name: 'tailscale0', address: '100.64.1.20' },
{ name: 'en0', address: '192.168.1.24' }
]
})
})
it('generates mobile pairing urls with the tailnet address by default', async () => {
networkInterfacesMock.mockReturnValue({
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }],
utun4: [{ family: 'IPv4', internal: false, address: '100.102.47.57' }]
})
const createMobilePairingOffer = vi.fn().mockResolvedValue({
available: true,
pairingUrl: 'orca://pair#mobile',
endpoint: 'ws://100.102.47.57:6768',
deviceId: 'mobile-1'
})
const rpcServer = { createMobilePairingOffer }
registerMobileHandlers(rpcServer as never)
await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toMatchObject({
available: true,
pairingUrl: 'orca://pair#mobile',
endpoint: 'ws://100.102.47.57:6768',
deviceId: 'mobile-1'
})
expect(createMobilePairingOffer).toHaveBeenCalledWith({
address: '100.102.47.57',
connectionMode: undefined,
rotate: undefined,
name: expect.stringMatching(/^Mobile /)
})
})
it('forwards an explicit local-only pairing choice', async () => {
networkInterfacesMock.mockReturnValue({
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }]
})
const createMobilePairingOffer = vi.fn().mockResolvedValue({
available: true,
pairingUrl: 'orca://pair#local',
endpoint: 'ws://192.168.1.24:6768',
deviceId: 'mobile-local'
})
registerMobileHandlers({ createMobilePairingOffer } as never)
await handlers.get('mobile:getPairingQR')?.(null, { connectionMode: 'local-only' })
expect(createMobilePairingOffer).toHaveBeenCalledWith(
expect.objectContaining({ connectionMode: 'local-only' })
)
})
it('lists only paired mobile-scoped devices', () => {
const rpcServer = {
getDeviceRegistry: () => ({
listDevices: () => [
{
deviceId: 'mobile-1',
name: 'Phone',
scope: 'mobile',
pairedAt: 1,
lastSeenAt: 2
},
{
deviceId: 'runtime-1',
name: 'CLI',
scope: 'runtime',
pairedAt: 1,
lastSeenAt: 2
},
{
deviceId: 'pending-mobile',
name: 'Pending',
scope: 'mobile',
pairedAt: 1,
lastSeenAt: 0
}
]
})
}
registerMobileHandlers(rpcServer as never)
expect(handlers.get('mobile:listDevices')?.()).toEqual({
devices: [
{
deviceId: 'mobile-1',
name: 'Phone',
pairedAt: 1,
lastSeenAt: 2
}
]
})
})
it('generates runtime-scoped pairing urls for web and desktop clients', async () => {
const createPairingOffer = vi.fn().mockReturnValue({
available: true,
pairingUrl: 'orca://pair#runtime',
webClientUrl: 'http://100.64.1.20:6768/web-index.html?pairing=runtime',
endpoint: 'ws://100.64.1.20:6768',
deviceId: 'runtime-1'
})
const rpcServer = { createPairingOffer }
registerMobileHandlers(rpcServer as never)
await expect(
handlers.get('mobile:getRuntimePairingUrl')?.(null, {
address: '100.64.1.20',
rotate: true
})
).resolves.toEqual({
available: true,
pairingUrl: 'orca://pair#runtime',
webClientUrl: 'http://100.64.1.20:6768/web-index.html?pairing=runtime',
endpoint: 'ws://100.64.1.20:6768',
deviceId: 'runtime-1'
})
expect(createPairingOffer).toHaveBeenCalledWith({
address: '100.64.1.20',
rotate: true,
name: expect.stringMatching(/^Runtime /),
scope: 'runtime'
})
})
it('lists runtime access grants including unused generated links', () => {
const rpcServer = {
getDeviceRegistry: () => ({
listDevices: () => [
{
deviceId: 'mobile-1',
name: 'Phone',
scope: 'mobile',
pairedAt: 1,
lastSeenAt: 2
},
{
deviceId: 'runtime-1',
name: 'Browser',
scope: 'runtime',
pairedAt: 3,
lastSeenAt: 4
},
{
deviceId: 'pending-runtime',
name: 'Copied link',
scope: 'runtime',
pairedAt: 5,
lastSeenAt: 0
}
]
})
}
registerMobileHandlers(rpcServer as never)
expect(handlers.get('mobile:listRuntimeAccessGrants')?.()).toEqual({
grants: [
{
deviceId: 'pending-runtime',
name: 'Copied link',
createdAt: 5,
lastSeenAt: null
},
{
deviceId: 'runtime-1',
name: 'Browser',
createdAt: 3,
lastSeenAt: 4
}
]
})
})
it('revokes runtime access through the runtime server', () => {
const revokeRuntimeAccess = vi.fn().mockReturnValue(true)
const rpcServer = {
getDeviceRegistry: () => ({}),
revokeRuntimeAccess
}
registerMobileHandlers(rpcServer as never)
expect(handlers.get('mobile:revokeRuntimeAccess')?.(null, { deviceId: 'runtime-1' })).toEqual({
revoked: true
})
expect(revokeRuntimeAccess).toHaveBeenCalledWith('runtime-1')
})
it('awaits mobile device revocation before replying', async () => {
const revokeMobileDevice = vi.fn().mockResolvedValue(true)
const rpcServer = {
getDeviceRegistry: () => ({}),
revokeMobileDevice
}
registerMobileHandlers(rpcServer as never)
await expect(
handlers.get('mobile:revokeDevice')?.(null, { deviceId: 'mobile-1' })
).resolves.toEqual({ revoked: true })
expect(revokeMobileDevice).toHaveBeenCalledWith('mobile-1')
})
it('reports the current relay broker status without exposing a toggle', () => {
registerMobileHandlers({} as never, { getRelayStatus: () => 'registered' })
expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({ status: 'registered' })
})
it('inspects and repairs the current packaged Windows websocket port', async () => {
const runPowerShell = vi
.fn()
.mockResolvedValueOnce(
JSON.stringify({
ruleAllowed: false,
privateFirewallEnabled: true,
networkCategory: 'Private'
})
)
.mockResolvedValueOnce('{"launched":true,"exitCode":0}')
const rpcServer = { getWebSocketEndpoint: () => 'ws://0.0.0.0:6768' }
registerMobileHandlers(rpcServer as never, {
firewallEnvironment: {
platform: 'win32',
isPackaged: true,
executablePath: 'C:\\Program Files\\Orca\\Orca.exe',
runPowerShell
}
})
await expect(
handlers.get('mobile:getWindowsFirewallStatus')?.(null, { address: '192.168.0.108' })
).resolves.toMatchObject({ supported: true, port: 6768, ruleAllowed: false })
await expect(
handlers.get('mobile:repairWindowsFirewall')?.({
sender: { isDestroyed: () => false, getType: () => 'window' }
})
).resolves.toEqual({ ok: true })
})
it('rejects firewall mutation from a non-window renderer', async () => {
const runPowerShell = vi.fn()
const rpcServer = { getWebSocketEndpoint: () => 'ws://0.0.0.0:6768' }
registerMobileHandlers(rpcServer as never, {
firewallEnvironment: {
platform: 'win32',
isPackaged: true,
executablePath: 'C:\\Program Files\\Orca\\Orca.exe',
runPowerShell
}
})
expect(
handlers.get('mobile:repairWindowsFirewall')?.({
sender: { isDestroyed: () => false, getType: () => 'webview' }
})
).toEqual({ ok: false, reason: 'unsupported' })
expect(runPowerShell).not.toHaveBeenCalled()
})
})