Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile, RpcResponse } from './types'

export class FakeSession implements RpcClient {
readonly sendRequest = vi.fn(
async (_method: string, _params?: unknown): Promise<RpcResponse> => ({
id: 'rpc-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
)
readonly subscribe = vi.fn(() => () => {})
readonly updateTerminalSubscriptionViewport = vi.fn()
readonly notifyForeground = vi.fn()
readonly close = vi.fn()
private readonly listeners = new Set<(state: ConnectionState) => void>()

constructor(private state: ConnectionState) {}

getState = () => this.state
getReconnectAttempt = () => 0
getLastConnectedAt = () => null
onStateChange = (listener: (state: ConnectionState) => void) => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}

publishState(state: ConnectionState): void {
this.state = state
for (const listener of this.listeners) {
listener(state)
}
}
}

export class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
constructor(
state: ConnectionState,
private readonly failure: Error | null = null,
private readonly resumeExpiry = Date.now() + 30 * 24 * 3_600_000,
private readonly renewed = true
) {
super(state)
}
// Why: production-realistic defaults — fictional fake values hid three
// live defects in this subsystem (latch, churn, int32 timer overflow).
getAttachDeadlineAt = () => Date.now() + 10_000
getResumeExpiresAt = () => this.resumeExpiry
getResumeConfirmation = () => ({
v: 1 as const,
reqId: 'confirm-1',
currentVersion: 2,
acceptedAs: this.renewed ? ('current' as const) : ('grace' as const),
renewed: this.renewed,
resumeExpiresAt: this.resumeExpiry
})
getFailure = () => this.failure
}

export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private generation = 1

constructor(state: ConnectionState, path: MobileConnectionPath) {
super(state)
this.path = path
}

migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => {
if (session.getState() !== 'connected') {
session.close()
throw new Error(`replacement session ${session.getState()}`)
}
this.path = path
this.generation += 1
this.publishState('connected')
})
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
getGeneration = () => this.generation
}

export const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
export const host: HostProfile = {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' },
{ id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' }
],
relayHostId: relay.relayHostId,
relay
}
export const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: host.id,
deviceToken: host.deviceToken,
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 2,
expiresAt: Number.MAX_SAFE_INTEGER
}
}

export function dependencies(
overrides: Partial<MobileEndpointSupervisorDependencies> = {}
): MobileEndpointSupervisorDependencies {
return {
openDirect: vi.fn(() => new FakeSession('connected')),
openRelay: vi.fn(() => new FakeRelaySession('connected')),
resolveRelay: vi.fn(async ({ relay }) => relay),
readBundle: vi.fn(async () => bundle),
writeBundle: vi.fn(async () => {}),
saveHost: vi.fn(async () => {}),
now: Date.now,
randomBytes: (length) => new Uint8Array(length).fill(1),
setTimer: setTimeout,
clearTimer: clearTimeout,
...overrides
}
}

export function mockCredentialRotation(logical: FakeLogicalClient): void {
let installResult: Record<string, unknown> | null = null
logical.sendRequest.mockImplementation(async (method, params) => {
const request = params as { installReqId?: string; reqId?: string }
if (method === 'pairing.provisionRelay') {
installResult = {
v: 1,
reqId: request.reqId,
authorizationMode: 'authenticated-direct',
currentVersion: 3,
resumeExpiresAt: Date.now() + 300_000,
graceExpiresAt: Date.now() + 60_000
}
return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } }
}
return {
id: 'rpc-1',
ok: true,
result: {
v: 1,
relay,
installStatus: installResult
? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult }
: { v: 1, reqId: request.installReqId, state: 'not-found' }
},
_meta: { runtimeId: 'runtime-1' }
}
})
}
199 changes: 31 additions & 168 deletions mobile/src/transport/mobile-endpoint-supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,180 +2,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import {
MobileEndpointSupervisor,
type MobileEndpointSupervisorDependencies
} from './mobile-endpoint-supervisor'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile, RpcResponse } from './types'
bundle,
dependencies,
FakeLogicalClient,
FakeRelaySession,
FakeSession,
host,
mockCredentialRotation,
relay
} from './mobile-endpoint-supervisor-test-fakes'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'

vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))

class FakeSession implements RpcClient {
readonly sendRequest = vi.fn(
async (_method: string, _params?: unknown): Promise<RpcResponse> => ({
id: 'rpc-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
)
readonly subscribe = vi.fn(() => () => {})
readonly updateTerminalSubscriptionViewport = vi.fn()
readonly notifyForeground = vi.fn()
readonly close = vi.fn()
private readonly listeners = new Set<(state: ConnectionState) => void>()

constructor(private state: ConnectionState) {}

getState = () => this.state
getReconnectAttempt = () => 0
getLastConnectedAt = () => null
onStateChange = (listener: (state: ConnectionState) => void) => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}

publishState(state: ConnectionState): void {
this.state = state
for (const listener of this.listeners) {
listener(state)
}
}
}

class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
constructor(
state: ConnectionState,
private readonly failure: Error | null = null,
private readonly resumeExpiry = Date.now() + 30 * 24 * 3_600_000
) {
super(state)
}
// Why: production-realistic defaults — fictional fake values hid three
// live defects in this subsystem (latch, churn, int32 timer overflow).
getAttachDeadlineAt = () => Date.now() + 10_000
getResumeExpiresAt = () => this.resumeExpiry
getResumeConfirmation = () => ({
v: 1 as const,
reqId: 'confirm-1',
currentVersion: 2,
acceptedAs: 'current' as const,
renewed: true,
resumeExpiresAt: this.resumeExpiry
})
getFailure = () => this.failure
}

class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private generation = 1

constructor(state: ConnectionState, path: MobileConnectionPath) {
super(state)
this.path = path
}

migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => {
if (session.getState() !== 'connected') {
session.close()
throw new Error(`replacement session ${session.getState()}`)
}
this.path = path
this.generation += 1
this.publishState('connected')
})
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
getGeneration = () => this.generation
}

const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
const host: HostProfile = {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' },
{ id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' }
],
relayHostId: relay.relayHostId,
relay
}
const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: host.id,
deviceToken: host.deviceToken,
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 2,
expiresAt: Number.MAX_SAFE_INTEGER
}
}

function dependencies(
overrides: Partial<MobileEndpointSupervisorDependencies> = {}
): MobileEndpointSupervisorDependencies {
return {
openDirect: vi.fn(() => new FakeSession('connected')),
openRelay: vi.fn(() => new FakeRelaySession('connected')),
resolveRelay: vi.fn(async ({ relay }) => relay),
readBundle: vi.fn(async () => bundle),
writeBundle: vi.fn(async () => {}),
saveHost: vi.fn(async () => {}),
now: Date.now,
randomBytes: (length) => new Uint8Array(length).fill(1),
setTimer: setTimeout,
clearTimer: clearTimeout,
...overrides
}
}

function mockCredentialRotation(logical: FakeLogicalClient): void {
let installResult: Record<string, unknown> | null = null
logical.sendRequest.mockImplementation(async (method, params) => {
const request = params as { installReqId?: string; reqId?: string }
if (method === 'pairing.provisionRelay') {
installResult = {
v: 1,
reqId: request.reqId,
authorizationMode: 'authenticated-direct',
currentVersion: 3,
resumeExpiresAt: Date.now() + 300_000,
graceExpiresAt: Date.now() + 60_000
}
return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } }
}
return {
id: 'rpc-1',
ok: true,
result: {
v: 1,
relay,
installStatus: installResult
? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult }
: { v: 1, reqId: request.installReqId, state: 'not-found' }
},
_meta: { runtimeId: 'runtime-1' }
}
})
}

describe('mobile endpoint supervisor', () => {
beforeEach(() => {
vi.useFakeTimers()
Expand Down Expand Up @@ -805,6 +647,27 @@ describe('mobile endpoint supervisor', () => {
supervisor.stop()
})

it('skips forced rotation for a session resumed without renewal', async () => {
// Why: renewed=false means a re-resume provably returns the same unchanged
// deadline; rotating anyway churned one session replacement per clamp floor.
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(
() => new FakeRelaySession('connected', null, Date.now() + 31_000, false)
)
const deps = dependencies({
openRelay,
openDirect: vi.fn(() => new FakeSession('disconnected'))
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)

await supervisor.start()
expect(openRelay).toHaveBeenCalledTimes(1)

await vi.advanceTimersByTimeAsync(5 * 60_000)
expect(openRelay).toHaveBeenCalledTimes(1)
supervisor.stop()
})

it('keeps a fatal lease-replacement gate after the active relay later drops', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi
Expand Down
Loading
Loading