mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
* 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>
323 lines
9.4 KiB
TypeScript
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()
|
|
})
|
|
})
|