mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
fix(mobile): preserve replacement relay credentials
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
getHostProfilePublicationRevision,
|
||||
publishHostProfileTransaction,
|
||||
recordDurableHostIdentity,
|
||||
retireHostProfilePublication,
|
||||
resetHostProfilePublicationForTests,
|
||||
serializeHostProfilePublication
|
||||
} from './host-profile-publication'
|
||||
@@ -139,7 +140,7 @@ describe('existing host relay endpoint lifecycle publication', () => {
|
||||
expect(writeBundle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('conditionally cleans a bundle written after its endpoint lifecycle retires', async () => {
|
||||
it('preserves a committed bundle for a replacement endpoint lifecycle', async () => {
|
||||
let releaseWrite: (() => void) | null = null
|
||||
let markWriteStarted: (() => void) | null = null
|
||||
const writeStarted = new Promise<void>((resolve) => {
|
||||
@@ -165,9 +166,57 @@ describe('existing host relay endpoint lifecycle publication', () => {
|
||||
beginHostEndpointPublicationLifecycle(HOST.id)
|
||||
releaseWrite?.()
|
||||
|
||||
await expect(staleWrite).rejects.toBeInstanceOf(MobileRelayUpgradeLifecycleRetiredError)
|
||||
expect(cleanupBundle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans a committed bundle after its host identity is removed', async () => {
|
||||
let releaseWrite: (() => void) | null = null
|
||||
const staleBundle = credentialBundle(1)
|
||||
const cleanupBundle = vi.fn(async () => true)
|
||||
const lifecycle = beginHostEndpointPublicationLifecycle(HOST.id)
|
||||
const staleWrite = writeExistingHostRelayCredentialBundle(
|
||||
HOST,
|
||||
staleBundle,
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseWrite = resolve
|
||||
}),
|
||||
lifecycle,
|
||||
cleanupBundle
|
||||
)
|
||||
await vi.waitFor(() => expect(releaseWrite).not.toBeNull())
|
||||
|
||||
retireHostProfilePublication(HOST.id)
|
||||
hostStoreMock.loadStoredHostIdentity.mockResolvedValue(null)
|
||||
releaseWrite?.()
|
||||
|
||||
await expect(staleWrite).rejects.toBeInstanceOf(MobileRelayUpgradeLifecycleRetiredError)
|
||||
await vi.waitFor(() => expect(cleanupBundle).toHaveBeenCalledWith(staleBundle))
|
||||
expect(await cleanupBundle.mock.results[0]!.value).toBe(false)
|
||||
})
|
||||
|
||||
it('cleans a committed bundle after its host identity is superseded', async () => {
|
||||
let releaseWrite: (() => void) | null = null
|
||||
const staleBundle = credentialBundle(1)
|
||||
const cleanupBundle = vi.fn(async () => true)
|
||||
const lifecycle = beginHostEndpointPublicationLifecycle(HOST.id)
|
||||
const staleWrite = writeExistingHostRelayCredentialBundle(
|
||||
HOST,
|
||||
staleBundle,
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseWrite = resolve
|
||||
}),
|
||||
lifecycle,
|
||||
cleanupBundle
|
||||
)
|
||||
await vi.waitFor(() => expect(releaseWrite).not.toBeNull())
|
||||
|
||||
recordDurableHostIdentity({ ...HOST, deviceToken: 'token-2' })
|
||||
releaseWrite?.()
|
||||
|
||||
await expect(staleWrite).rejects.toBeInstanceOf(MobileRelayUpgradeHostSupersededError)
|
||||
await vi.waitFor(() => expect(cleanupBundle).toHaveBeenCalledWith(staleBundle))
|
||||
})
|
||||
|
||||
it('rechecks routing publication after its credential write', async () => {
|
||||
|
||||
@@ -71,7 +71,9 @@ export async function writeExistingHostRelayCredentialBundle(
|
||||
try {
|
||||
requireCurrentPublication(validated, endpointLifecycle)
|
||||
} catch (error) {
|
||||
void cleanupBundle(bundle).catch(() => {})
|
||||
if (!(await currentHostMayOwnBundle(validated))) {
|
||||
void cleanupBundle(bundle).catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
@@ -110,6 +112,31 @@ function requireMatchingHostMetadata(
|
||||
}
|
||||
}
|
||||
|
||||
async function currentHostMayOwnBundle(host: HostProfile): Promise<boolean> {
|
||||
const publishedIdentity = getPublishedHostIdentity(host.id)
|
||||
if (publishedIdentity) {
|
||||
return (
|
||||
publishedIdentity.deviceToken === host.deviceToken &&
|
||||
publishedIdentity.publicKeyB64 === host.publicKeyB64
|
||||
)
|
||||
}
|
||||
let existing: Pick<HostProfile, 'publicKeyB64'> | null
|
||||
try {
|
||||
existing = await loadStoredHostIdentity(host.id)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
if (!existing || existing.publicKeyB64 !== host.publicKeyB64) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return (await readHostDeviceToken(host.id)) === host.deviceToken
|
||||
} catch {
|
||||
// Why: uncertain storage ownership must not destroy the only resumable Relay credential.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function requireCurrentPublication(
|
||||
host: HostProfile,
|
||||
endpointLifecycle: HostEndpointPublicationLifecycle
|
||||
|
||||
@@ -10,9 +10,43 @@ import type { RpcClient } from './rpc-client'
|
||||
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostProfile, RpcResponse } from './types'
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
}))
|
||||
const secureStoreMock = vi.hoisted(() => ({
|
||||
getItemAsync: vi.fn(),
|
||||
setItemAsync: vi.fn(),
|
||||
deleteItemAsync: vi.fn()
|
||||
}))
|
||||
const hostStoreMock = vi.hoisted(() => ({ loadStoredHostIdentity: vi.fn() }))
|
||||
const tokenStoreMock = vi.hoisted(() => ({ readHostDeviceToken: vi.fn() }))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock }))
|
||||
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
|
||||
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
|
||||
vi.mock('expo-secure-store', () => ({
|
||||
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked',
|
||||
...secureStoreMock
|
||||
}))
|
||||
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
|
||||
vi.mock('./host-store', () => hostStoreMock)
|
||||
vi.mock('./host-device-token-store', () => tokenStoreMock)
|
||||
|
||||
import {
|
||||
deleteMobileRelayCredentialBundleIfCurrent,
|
||||
readMobileRelayCredentialBundle,
|
||||
writeMobileRelayCredentialBundle
|
||||
} from './mobile-relay-credential-bundle'
|
||||
import {
|
||||
MobileRelayUpgradeLifecycleRetiredError,
|
||||
writeExistingHostRelayCredentialBundle
|
||||
} from './existing-host-relay-routing'
|
||||
import {
|
||||
beginHostEndpointPublicationLifecycle,
|
||||
resetHostProfilePublicationForTests
|
||||
} from './host-profile-publication'
|
||||
import { resetPairingKeychainForTests } from './pairing-keychain'
|
||||
|
||||
class FakeSession implements RpcClient {
|
||||
readonly sendRequest = vi.fn(
|
||||
@@ -162,9 +196,27 @@ function mockCredentialRotation(logical: FakeLogicalClient): void {
|
||||
}
|
||||
|
||||
describe('mobile endpoint supervisor retirement', () => {
|
||||
let storedCredentialRaw: string | null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
|
||||
resetPairingKeychainForTests()
|
||||
resetHostProfilePublicationForTests()
|
||||
storedCredentialRaw = null
|
||||
asyncStorageMock.getItem.mockResolvedValue(null)
|
||||
asyncStorageMock.setItem.mockResolvedValue(undefined)
|
||||
asyncStorageMock.removeItem.mockResolvedValue(undefined)
|
||||
secureStoreMock.getItemAsync.mockImplementation(async () => storedCredentialRaw)
|
||||
secureStoreMock.setItemAsync.mockImplementation(async (_key: string, value: string) => {
|
||||
storedCredentialRaw = value
|
||||
})
|
||||
secureStoreMock.deleteItemAsync.mockImplementation(async () => {
|
||||
storedCredentialRaw = null
|
||||
})
|
||||
hostStoreMock.loadStoredHostIdentity.mockResolvedValue(host)
|
||||
tokenStoreMock.readHostDeviceToken.mockResolvedValue(host.deviceToken)
|
||||
})
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
@@ -219,4 +271,98 @@ describe('mobile endpoint supervisor retirement', () => {
|
||||
expect(deps.saveHost).not.toHaveBeenCalled()
|
||||
expect(onHostUpdated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a replacement-owned rotation durable across direct loss and restart', async () => {
|
||||
const rotatedBundle: MobileRelayCredentialBundle = {
|
||||
...bundle,
|
||||
current: {
|
||||
token: 'C'.repeat(43),
|
||||
hash: 'D'.repeat(43),
|
||||
version: 3,
|
||||
expiresAt: Number.MAX_SAFE_INTEGER
|
||||
},
|
||||
grace: bundle.current
|
||||
}
|
||||
await writeMobileRelayCredentialBundle(bundle)
|
||||
|
||||
let finishSecureWrite: (() => void) | null = null
|
||||
let markSecureWriteStarted: (() => void) | null = null
|
||||
const secureWriteStarted = new Promise<void>((resolve) => {
|
||||
markSecureWriteStarted = resolve
|
||||
})
|
||||
secureStoreMock.setItemAsync.mockImplementation(async (_key: string, value: string) => {
|
||||
if (value === JSON.stringify(rotatedBundle)) {
|
||||
markSecureWriteStarted?.()
|
||||
await new Promise<void>((resolve) => {
|
||||
finishSecureWrite = resolve
|
||||
})
|
||||
}
|
||||
storedCredentialRaw = value
|
||||
})
|
||||
|
||||
let markReplacementRecovered: (() => void) | null = null
|
||||
const replacementRecovered = new Promise<void>((resolve) => {
|
||||
markReplacementRecovered = resolve
|
||||
})
|
||||
const cleanupBundle = vi.fn(async (value: MobileRelayCredentialBundle) => {
|
||||
await replacementRecovered
|
||||
return deleteMobileRelayCredentialBundleIfCurrent(value)
|
||||
})
|
||||
const oldLifecycle = beginHostEndpointPublicationLifecycle(host.id)
|
||||
const oldWrite = writeExistingHostRelayCredentialBundle(
|
||||
host,
|
||||
rotatedBundle,
|
||||
writeMobileRelayCredentialBundle,
|
||||
oldLifecycle,
|
||||
cleanupBundle
|
||||
)
|
||||
await secureWriteStarted
|
||||
|
||||
beginHostEndpointPublicationLifecycle(host.id)
|
||||
const replacementLogical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const replacementOpenRelay = vi.fn(() => {
|
||||
markReplacementRecovered?.()
|
||||
return new FakeRelaySession('connected')
|
||||
})
|
||||
const replacement = new MobileEndpointSupervisor(
|
||||
replacementLogical,
|
||||
host,
|
||||
dependencies({ readBundle: readMobileRelayCredentialBundle, openRelay: replacementOpenRelay })
|
||||
)
|
||||
const replacementStart = replacement.start()
|
||||
|
||||
finishSecureWrite?.()
|
||||
await expect(oldWrite).rejects.toBeInstanceOf(MobileRelayUpgradeLifecycleRetiredError)
|
||||
await replacementStart
|
||||
expect(replacementOpenRelay).toHaveBeenCalledWith(
|
||||
relay,
|
||||
expect.objectContaining({
|
||||
token: rotatedBundle.current.token,
|
||||
version: rotatedBundle.current.version
|
||||
}),
|
||||
expect.any(String)
|
||||
)
|
||||
replacement.stop()
|
||||
|
||||
const restartedLogical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const restartedOpenRelay = vi.fn(() => new FakeRelaySession('connected'))
|
||||
const restarted = new MobileEndpointSupervisor(
|
||||
restartedLogical,
|
||||
host,
|
||||
dependencies({ readBundle: readMobileRelayCredentialBundle, openRelay: restartedOpenRelay })
|
||||
)
|
||||
await restarted.start()
|
||||
|
||||
expect(restartedOpenRelay).toHaveBeenCalledWith(
|
||||
relay,
|
||||
expect.objectContaining({
|
||||
token: rotatedBundle.current.token,
|
||||
version: rotatedBundle.current.version
|
||||
}),
|
||||
expect.any(String)
|
||||
)
|
||||
expect(JSON.parse(storedCredentialRaw ?? 'null')).toEqual(rotatedBundle)
|
||||
expect(cleanupBundle).not.toHaveBeenCalled()
|
||||
restarted.stop()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user