mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(mobile): recover from rejected relay resume tokens
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
|
||||
import {
|
||||
bundle,
|
||||
dependencies,
|
||||
FakeLogicalClient,
|
||||
FakeRelaySession,
|
||||
host
|
||||
} from './mobile-endpoint-supervisor-test-fakes'
|
||||
import { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
|
||||
|
||||
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) }))
|
||||
|
||||
describe('mobile Relay director credential fallback', () => {
|
||||
it('uses grace when the director rejects the current resume credential', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(
|
||||
(_relay, credential: { version: number }) =>
|
||||
new FakeRelaySession(
|
||||
credential.version === bundle.current.version ? 'disconnected' : 'connected',
|
||||
credential.version === bundle.current.version ? new RelayOuterError(4409) : null
|
||||
)
|
||||
)
|
||||
const deps = dependencies({
|
||||
readBundle: vi.fn(async () => ({
|
||||
...bundle,
|
||||
grace: { ...bundle.current, token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1 }
|
||||
})),
|
||||
openRelay,
|
||||
resolveRelay: ({ relay, resumeToken }) =>
|
||||
resolveMobileRelayEndpoint({
|
||||
relay,
|
||||
resumeToken,
|
||||
fetchImpl: vi.fn(async () => new Response('', { status: 401 }))
|
||||
})
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
expect(openRelay.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ version: 1 }))
|
||||
expect(logical.getActivePath()).toBe('relay')
|
||||
supervisor.stop()
|
||||
})
|
||||
})
|
||||
@@ -5,17 +5,14 @@ import {
|
||||
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
|
||||
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
|
||||
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
import { RelayOuterError } from './relay-outer-error'
|
||||
|
||||
export { RelayOuterError } from './relay-outer-error'
|
||||
|
||||
// Native WebSockets normally emit close immediately after error; bound the
|
||||
// missing-close case so a dead socket cannot leave recovery pending forever.
|
||||
const RELAY_ERROR_CLOSE_GRACE_MS = 250
|
||||
|
||||
export class RelayOuterError extends Error {
|
||||
constructor(readonly code: number) {
|
||||
super(`relay_outer_${code}`)
|
||||
}
|
||||
}
|
||||
|
||||
type MobileRelayE2eeLinkOptions = {
|
||||
endpoint: { cellUrl: string; relayHostId: string }
|
||||
credential: string
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/mobile-relay-close-codes'
|
||||
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
|
||||
|
||||
const relay = {
|
||||
@@ -11,6 +12,20 @@ const relay = {
|
||||
}
|
||||
|
||||
describe('mobile relay resume director', () => {
|
||||
it('classifies a rejected resume credential for grace fallback', async () => {
|
||||
const rejected = vi.fn(async () => new Response('', { status: 401 }))
|
||||
|
||||
const resolution = resolveMobileRelayEndpoint({
|
||||
relay,
|
||||
resumeToken: 'A'.repeat(43),
|
||||
fetchImpl: rejected
|
||||
})
|
||||
|
||||
await expect(resolution).rejects.toMatchObject({
|
||||
code: MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a bounded POST body and never puts the bearer in the URL', async () => {
|
||||
const fetchImpl = vi.fn(
|
||||
async () =>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/mobile-relay-close-codes'
|
||||
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
|
||||
import { RelayOuterError } from './relay-outer-error'
|
||||
|
||||
const MAX_RESPONSE_BYTES = 16 * 1024
|
||||
const ResolveResponseSchema = z
|
||||
@@ -32,6 +34,9 @@ export async function resolveMobileRelayEndpoint(args: {
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL)
|
||||
}
|
||||
throw new Error(`relay director resolve failed (${response.status})`)
|
||||
}
|
||||
const declaredLength = Number(response.headers.get('content-length') ?? 0)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export class RelayOuterError extends Error {
|
||||
constructor(readonly code: number) {
|
||||
super(`relay_outer_${code}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user