mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
fix(mobile): fence reconnect recovery lifecycles
This commit is contained in:
@@ -415,6 +415,74 @@ describe('useHostClient', () => {
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('publishes lifecycle updates after cold-start priming races the host lookup', async () => {
|
||||
let resolveLookup: ((hosts: HostProfile[]) => void) | null = null
|
||||
const lookup = new Promise<HostProfile[]>((resolve) => {
|
||||
resolveLookup = resolve
|
||||
})
|
||||
const initial = makeFakeClient('connected')
|
||||
const fresh = makeFakeClient('connected')
|
||||
fresh.sendRequest = vi.fn(async () => ({ id: 'health', ok: true, result: {} }))
|
||||
loadHostsMock.mockReturnValue(lookup)
|
||||
connectMock.mockReturnValueOnce(initial).mockReturnValueOnce(fresh)
|
||||
|
||||
const harness = await renderHarness(HOST.id)
|
||||
harness.primeHosts([HOST])
|
||||
await act(async () => {
|
||||
resolveLookup?.([HOST])
|
||||
await lookup
|
||||
})
|
||||
const relayHostId = 'AbCdEf0123_-xyZ9'
|
||||
const upgradedHost: HostProfile = {
|
||||
...HOST,
|
||||
endpoints: [
|
||||
{ id: 'direct-primary', kind: 'lan', url: HOST.endpoint },
|
||||
{ id: 'relay-primary', kind: 'relay', url: 'wss://relay.invalid/v1/connect/host' }
|
||||
],
|
||||
relayHostId,
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.invalid',
|
||||
cellUrl: 'https://relay.invalid',
|
||||
assignmentEpoch: 1,
|
||||
relayHostId,
|
||||
e2eeFraming: 2
|
||||
}
|
||||
}
|
||||
const publishHostUpdate = connectMock.mock.calls[0]?.[2] as
|
||||
| ((host: HostProfile) => void)
|
||||
| undefined
|
||||
publishHostUpdate?.(upgradedHost)
|
||||
|
||||
await act(async () => harness.forceReconnect(HOST.id))
|
||||
|
||||
expect(connectMock.mock.calls[1]?.[0]).toEqual(upgradedHost)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('ignores a cancelled lookup failure after its replacement connects', async () => {
|
||||
let rejectLookup: ((error: Error) => void) | null = null
|
||||
const lookup = new Promise<HostProfile[]>((_resolve, reject) => {
|
||||
rejectLookup = reject
|
||||
})
|
||||
const fresh = makeFakeClient('connected')
|
||||
fresh.sendRequest = vi.fn(async () => ({ id: 'health', ok: true, result: {} }))
|
||||
loadHostsMock.mockReturnValue(lookup)
|
||||
connectMock.mockReturnValue(fresh)
|
||||
|
||||
const harness = await renderHarness(HOST.id)
|
||||
harness.primeHosts([HOST])
|
||||
await act(async () => harness.forceReconnect(HOST.id))
|
||||
await act(async () => {
|
||||
rejectLookup?.(new Error('keychain locked'))
|
||||
await lookup.catch(() => undefined)
|
||||
})
|
||||
|
||||
expect(harness.hook.client).toBe(fresh)
|
||||
expect(harness.hook.state).toBe('connected')
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('coalesces overlapping Force Reconnect calls for one host', async () => {
|
||||
const stale = makeFakeClient('connected')
|
||||
const fresh = makeFakeClient('connecting')
|
||||
|
||||
@@ -16,6 +16,7 @@ import { HostForceReconnectCoordinator, type HostReconnectEntry } from './host-f
|
||||
import { HostReconnectProfileCache, type HostOpenProfile } from './host-reconnect-profile-cache'
|
||||
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
|
||||
import { HostClientOpenRegistry } from './host-client-open-registry'
|
||||
import { loadHostClientOpenProfile } from './host-client-open-profile'
|
||||
import { loadHosts } from './host-store'
|
||||
import { openHostLogicalClient } from './host-logical-client'
|
||||
import { clientActivePath } from './rpc-client-active-path'
|
||||
@@ -85,29 +86,26 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
const pendingOpen = pendingOpensRef.current.register(hostId, profileVersion, promise)
|
||||
|
||||
try {
|
||||
// Why: prefer the primed cache so we don't serialize a second Keychain pass on cold start.
|
||||
let host = requestedProfile?.host ?? primedHostsRef.current.get(hostId)
|
||||
if (!host) {
|
||||
try {
|
||||
const hosts = await loadHosts()
|
||||
host = hosts.find((h) => h.id === hostId)
|
||||
} catch {
|
||||
// Why: cold-start Keychain failure (iOS mid-unlock / Android Keystore race); surface 'disconnected' so the user can Reconnect.
|
||||
notifyHostState(hostId, 'disconnected')
|
||||
notifyAllHosts()
|
||||
return null
|
||||
}
|
||||
if (!host) {
|
||||
// Why: silent return leaves screens on a permanent spinner (STA-1511); surface 'disconnected' so they show a retry affordance.
|
||||
notifyHostState(hostId, 'disconnected')
|
||||
notifyAllHosts()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingOpen.cancelled) {
|
||||
const cachedHost = requestedProfile?.host ?? primedHostsRef.current.get(hostId)
|
||||
const profile = cachedHost
|
||||
? { host: cachedHost, version: requestedProfile?.version ?? profileVersion }
|
||||
: await loadHostClientOpenProfile({
|
||||
hostId,
|
||||
cache: primedHostsRef.current,
|
||||
ticket: pendingOpen,
|
||||
loadHosts,
|
||||
onUnavailable: () => {
|
||||
notifyHostState(hostId, 'disconnected')
|
||||
notifyAllHosts()
|
||||
}
|
||||
})
|
||||
if (!profile || pendingOpen.cancelled) {
|
||||
return null
|
||||
}
|
||||
const resolvedProfile = cachedHost
|
||||
? profile
|
||||
: primedHostsRef.current.resolveLoadedOpenProfile(profile.host)
|
||||
pendingOpen.profileVersion = resolvedProfile.version
|
||||
|
||||
// Re-check after any await — another acquire() may have completed.
|
||||
const after = storeRef.current.get(hostId)
|
||||
@@ -119,9 +117,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
const onLog: ConnectionLogSink = (entry) => connectionLogStore.append(hostId, entry)
|
||||
try {
|
||||
client = openHostLogicalClient(
|
||||
host,
|
||||
resolvedProfile.host,
|
||||
onLog,
|
||||
primedHostsRef.current.publisher(hostId, profileVersion)
|
||||
primedHostsRef.current.publisher(hostId, resolvedProfile.version)
|
||||
)
|
||||
} catch {
|
||||
// Why: openHostLogicalClient can throw synchronously (bad public key / invalid URL); notify so the UI leaves 'connecting'.
|
||||
@@ -459,6 +457,5 @@ export function useForceReconnect(): (hostId: string) => Promise<void> {
|
||||
|
||||
// Why: primes already-loaded HostProfiles so the provider can skip a second loadHosts()/Keychain pass on cold start.
|
||||
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
|
||||
const ctx = useRpcClientContext()
|
||||
return ctx.primeHosts
|
||||
return useRpcClientContext().primeHosts
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HostProfileSchema, type HostProfile } from './types'
|
||||
import { loadHosts } from './host-store'
|
||||
import { loadStoredHostCredentialIdentity } from './host-store'
|
||||
import { serializeHostProfilePublication } from './host-profile-publication'
|
||||
import * as hostListLoads from './host-list-load-sharing'
|
||||
import { saveMobileRelayHostOverlay } from './mobile-relay-host-overlay-store'
|
||||
@@ -54,7 +54,7 @@ export async function writeExistingHostRelayCredentialBundle(
|
||||
async function requireCurrentHostIdentity(
|
||||
host: HostProfile
|
||||
): Promise<Pick<HostProfile, 'endpoint'>> {
|
||||
const existing = (await loadHosts({ requireCredentials: true })).find(({ id }) => id === host.id)
|
||||
const existing = await loadStoredHostCredentialIdentity(host.id)
|
||||
if (!existing || existing.publicKeyB64 !== host.publicKeyB64) {
|
||||
throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { HostClientOpenTicket } from './host-client-open-registry'
|
||||
import { HostReconnectProfileCache } from './host-reconnect-profile-cache'
|
||||
import type { HostProfile } from './types'
|
||||
|
||||
type HostClientOpenProfileOptions = {
|
||||
hostId: string
|
||||
cache: HostReconnectProfileCache
|
||||
ticket: HostClientOpenTicket
|
||||
loadHosts: () => Promise<HostProfile[]>
|
||||
onUnavailable: () => void
|
||||
}
|
||||
|
||||
export async function loadHostClientOpenProfile(
|
||||
options: HostClientOpenProfileOptions
|
||||
): Promise<{ host: HostProfile; version: number } | null> {
|
||||
const { hostId, cache, ticket } = options
|
||||
let loadedHost: HostProfile | undefined
|
||||
try {
|
||||
loadedHost = (await options.loadHosts()).find(({ id }) => id === hostId)
|
||||
} catch {
|
||||
if (!ticket.cancelled) {
|
||||
options.onUnavailable()
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (ticket.cancelled) {
|
||||
return null
|
||||
}
|
||||
if (!loadedHost) {
|
||||
options.onUnavailable()
|
||||
return null
|
||||
}
|
||||
return { host: cache.get(hostId) ?? loadedHost, version: cache.version(hostId) }
|
||||
}
|
||||
@@ -45,6 +45,10 @@ export class HostReconnectProfileCache {
|
||||
}
|
||||
}
|
||||
|
||||
resolveLoadedOpenProfile(host: HostProfile): { host: HostProfile; version: number } {
|
||||
return { host: this.get(host.id) ?? host, version: this.version(host.id) }
|
||||
}
|
||||
|
||||
get(hostId: string): HostProfile | undefined {
|
||||
return this.profiles.get(hostId)?.host
|
||||
}
|
||||
|
||||
@@ -282,6 +282,49 @@ describe('host-store list mutations', () => {
|
||||
expect(storedOverlayRaw).toBeNull()
|
||||
})
|
||||
|
||||
it('validates only the target credential during relay publication', async () => {
|
||||
secureStoreMock.getItemAsync.mockImplementation(async (key: string) =>
|
||||
key.endsWith(HOST_ONE.id) ? 'token-1' : null
|
||||
)
|
||||
const relayHostId = 'AbCdEf0123_-xyZ9'
|
||||
|
||||
await saveExistingHostRelayRouting({
|
||||
...HOST_ONE,
|
||||
deviceToken: 'token-1',
|
||||
endpoints: [
|
||||
{ id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint },
|
||||
{ id: 'relay-primary', kind: 'relay', url: 'wss://relay.onorca.dev/v1/connect/host' }
|
||||
],
|
||||
relayHostId,
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.onorca.dev',
|
||||
cellUrl: 'https://relay.onorca.dev',
|
||||
assignmentEpoch: 1,
|
||||
relayHostId,
|
||||
e2eeFraming: 2
|
||||
}
|
||||
})
|
||||
|
||||
expect(secureStoreMock.getItemAsync).toHaveBeenCalledOnce()
|
||||
expect(secureStoreMock.getItemAsync.mock.calls[0]?.[0]).toContain(HOST_ONE.id)
|
||||
expect(JSON.parse(storedOverlayRaw ?? '[]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not infer host removal from unreadable identity storage', async () => {
|
||||
storedHostsRaw = '{'
|
||||
|
||||
const error = await saveExistingHostRelayRouting({
|
||||
...HOST_ONE,
|
||||
deviceToken: 'token-1'
|
||||
}).catch((reason: unknown) => reason)
|
||||
|
||||
expect(error).toMatchObject({ message: 'host list storage unreadable' })
|
||||
expect(error).not.toBeInstanceOf(MobileRelayUpgradeHostRemovedError)
|
||||
expect(storedOverlayRaw).toBeNull()
|
||||
expect(secureStoreMock.getItemAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('publishes relay routing without overwriting a newer endpoint edit', async () => {
|
||||
const relayHostId = 'AbCdEf0123_-xyZ9'
|
||||
await updateHostNameAndEndpoint(HOST_ONE.id, { endpoint: 'ws://127.0.0.1:9' })
|
||||
@@ -355,6 +398,7 @@ describe('host-store list mutations', () => {
|
||||
|
||||
it('rejects relay routing from a lifecycle superseded by same-host re-pairing', async () => {
|
||||
await saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' })
|
||||
secureStoreMock.getItemAsync.mockResolvedValueOnce('replacement-token')
|
||||
const relayHostId = 'AbCdEf0123_-xyZ9'
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -64,19 +64,14 @@ function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadHosts(
|
||||
options: { requireCredentials?: boolean } = {}
|
||||
): Promise<HostProfile[]> {
|
||||
export async function loadHosts(): Promise<HostProfile[]> {
|
||||
// Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list.
|
||||
await hostListMutation
|
||||
if (options.requireCredentials) {
|
||||
return doLoadHosts(true)
|
||||
}
|
||||
// Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass.
|
||||
return hostListLoads.shareHostListLoad(() => doLoadHosts(false))
|
||||
return hostListLoads.shareHostListLoad(doLoadHosts)
|
||||
}
|
||||
|
||||
async function doLoadHosts(requireCredentials: boolean): Promise<HostProfile[]> {
|
||||
async function doLoadHosts(): Promise<HostProfile[]> {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
const storedHosts = parseStoredHosts(raw)
|
||||
if (!storedHosts) {
|
||||
@@ -99,17 +94,11 @@ async function doLoadHosts(requireCredentials: boolean): Promise<HostProfile[]>
|
||||
let fetched: string | null
|
||||
try {
|
||||
fetched = await readHostDeviceToken(stored.id)
|
||||
} catch (error) {
|
||||
if (requireCredentials) {
|
||||
throw error
|
||||
}
|
||||
} catch {
|
||||
// Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it.
|
||||
continue
|
||||
}
|
||||
if (!fetched) {
|
||||
if (requireCredentials) {
|
||||
throw new Error('host credential unavailable')
|
||||
}
|
||||
// Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host.
|
||||
continue
|
||||
}
|
||||
@@ -134,6 +123,21 @@ async function doLoadHosts(requireCredentials: boolean): Promise<HostProfile[]>
|
||||
return out
|
||||
}
|
||||
|
||||
export async function loadStoredHostCredentialIdentity(
|
||||
hostId: string
|
||||
): Promise<Pick<HostProfile, 'endpoint' | 'publicKeyB64' | 'deviceToken'> | null> {
|
||||
await hostListMutation
|
||||
const stored = (await readStoredHostsForMutation()).find(({ id }) => id === hostId)
|
||||
if (!stored) {
|
||||
return null
|
||||
}
|
||||
const deviceToken = await readHostDeviceToken(hostId)
|
||||
if (!deviceToken) {
|
||||
throw new Error('host credential unavailable')
|
||||
}
|
||||
return { endpoint: stored.endpoint, publicKeyB64: stored.publicKeyB64, deviceToken }
|
||||
}
|
||||
|
||||
export async function resolvePairingHostIdentity(
|
||||
publicKeyB64: string,
|
||||
newHostId: string
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
|
||||
import {
|
||||
deletePairingKeychainItem,
|
||||
deletePairingKeychainItemIfMatches,
|
||||
readPairingKeychainItem,
|
||||
writePairingKeychainItem
|
||||
} from './pairing-keychain'
|
||||
@@ -70,6 +71,16 @@ export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Pro
|
||||
await deletePairingKeychainItem(journalKey(hostId))
|
||||
}
|
||||
|
||||
export function deleteMobileRelayDirectUpgradeJournalIfCurrent(
|
||||
journal: MobileRelayDirectUpgradeJournal
|
||||
): Promise<boolean> {
|
||||
if (Platform.OS === 'web') {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal)
|
||||
return deletePairingKeychainItemIfMatches(journalKey(parsed.hostId), JSON.stringify(parsed))
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of value) {
|
||||
|
||||
@@ -62,8 +62,12 @@ function dependencies(journal: MobileRelayDirectUpgradeJournal | null = null) {
|
||||
writeJournal: vi.fn(async (next: MobileRelayDirectUpgradeJournal) => {
|
||||
stored = next
|
||||
}),
|
||||
clearJournal: vi.fn(async () => {
|
||||
clearJournal: vi.fn(async (expected: MobileRelayDirectUpgradeJournal) => {
|
||||
if (stored?.reqId !== expected.reqId) {
|
||||
return false
|
||||
}
|
||||
stored = null
|
||||
return true
|
||||
}),
|
||||
writeBundle: vi.fn(async () => {}),
|
||||
deleteBundle: vi.fn(async () => {}),
|
||||
@@ -121,7 +125,7 @@ describe('existing direct pairing relay upgrade', () => {
|
||||
expect(deps.saveHost).toHaveBeenCalledWith(expect.any(Object), expect.any(Function))
|
||||
expect(deps.writeBundle).toHaveBeenCalledOnce()
|
||||
expect(result?.host.relay).toEqual(relay)
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(journal)
|
||||
})
|
||||
|
||||
it('does not delete a replacement pairing bundle when the old upgrade is superseded', async () => {
|
||||
@@ -146,7 +150,35 @@ describe('existing direct pairing relay upgrade', () => {
|
||||
).rejects.toBeInstanceOf(MobileRelayUpgradeHostSupersededError)
|
||||
expect(deps.writeBundle).not.toHaveBeenCalled()
|
||||
expect(deps.deleteBundle).not.toHaveBeenCalled()
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(journal)
|
||||
})
|
||||
|
||||
it('does not clear a replacement lifecycle journal from a superseded upgrade', async () => {
|
||||
const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) =>
|
||||
new Uint8Array(length).fill(6)
|
||||
)
|
||||
const replacement = createMobileRelayDirectUpgradeJournal(host.id, (length) =>
|
||||
new Uint8Array(length).fill(9)
|
||||
)
|
||||
const committed = installed(journal)
|
||||
const deps = dependencies(journal)
|
||||
deps.saveHost.mockImplementation(async () => {
|
||||
await deps.writeJournal(replacement)
|
||||
throw new MobileRelayUpgradeHostSupersededError('mobile relay upgrade host was re-paired')
|
||||
})
|
||||
const client = clientWith([
|
||||
success({
|
||||
v: 1,
|
||||
relay,
|
||||
installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed }
|
||||
})
|
||||
])
|
||||
|
||||
await expect(
|
||||
upgradeDirectMobileRelay({ client, host, dependencies: deps })
|
||||
).rejects.toBeInstanceOf(MobileRelayUpgradeHostSupersededError)
|
||||
|
||||
await expect(deps.readJournal()).resolves.toEqual(replacement)
|
||||
})
|
||||
|
||||
it('retains recovery state when host identity credentials are temporarily unavailable', async () => {
|
||||
@@ -205,7 +237,7 @@ describe('existing direct pairing relay upgrade', () => {
|
||||
])
|
||||
|
||||
await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull()
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(expect.objectContaining({ hostId: host.id }))
|
||||
expect(deps.writeBundle).not.toHaveBeenCalled()
|
||||
expect(deps.saveHost).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -242,6 +274,6 @@ describe('existing direct pairing relay upgrade', () => {
|
||||
upgradeDirectMobileRelay({ client, host, dependencies: deps })
|
||||
).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError)
|
||||
expect(deps.deleteBundle).toHaveBeenCalledWith(host.id)
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(journal)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from './mobile-relay-credential-bundle'
|
||||
import {
|
||||
createMobileRelayDirectUpgradeJournal,
|
||||
deleteMobileRelayDirectUpgradeJournal,
|
||||
deleteMobileRelayDirectUpgradeJournalIfCurrent,
|
||||
readMobileRelayDirectUpgradeJournal,
|
||||
writeMobileRelayDirectUpgradeJournal,
|
||||
type MobileRelayDirectUpgradeJournal
|
||||
@@ -35,7 +35,7 @@ export type MobileRelayDirectUpgradeResult = {
|
||||
type Dependencies = {
|
||||
readJournal: typeof readMobileRelayDirectUpgradeJournal
|
||||
writeJournal: typeof writeMobileRelayDirectUpgradeJournal
|
||||
clearJournal: typeof deleteMobileRelayDirectUpgradeJournal
|
||||
clearJournal: typeof deleteMobileRelayDirectUpgradeJournalIfCurrent
|
||||
writeBundle: typeof writeMobileRelayCredentialBundle
|
||||
saveHost: typeof saveExistingHostRelayRouting
|
||||
deleteBundle: typeof deleteMobileRelayCredentialBundle
|
||||
@@ -53,7 +53,7 @@ export async function upgradeDirectMobileRelay(args: {
|
||||
const dependencies: Dependencies = {
|
||||
readJournal: readMobileRelayDirectUpgradeJournal,
|
||||
writeJournal: writeMobileRelayDirectUpgradeJournal,
|
||||
clearJournal: deleteMobileRelayDirectUpgradeJournal,
|
||||
clearJournal: deleteMobileRelayDirectUpgradeJournalIfCurrent,
|
||||
writeBundle: writeMobileRelayCredentialBundle,
|
||||
saveHost: saveExistingHostRelayRouting,
|
||||
deleteBundle: deleteMobileRelayCredentialBundle,
|
||||
@@ -69,7 +69,7 @@ export async function upgradeDirectMobileRelay(args: {
|
||||
|
||||
const initial = await getEndpoints(args.client, journal.reqId)
|
||||
if (initial === 'method-not-found') {
|
||||
await dependencies.clearJournal(args.host.id)
|
||||
await dependencies.clearJournal(journal)
|
||||
return null
|
||||
}
|
||||
if (initial.installStatus?.state === 'committed') {
|
||||
@@ -84,7 +84,7 @@ export async function upgradeDirectMobileRelay(args: {
|
||||
newResumeTokenHash: journal.pendingResumeTokenHash
|
||||
})
|
||||
if (isMethodNotFound(provisionResponse)) {
|
||||
await dependencies.clearJournal(args.host.id)
|
||||
await dependencies.clearJournal(journal)
|
||||
return null
|
||||
}
|
||||
const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provisionResponse))
|
||||
@@ -127,13 +127,13 @@ async function publishCommitted(
|
||||
} catch (error) {
|
||||
if (error instanceof MobileRelayUpgradeHostRemovedError) {
|
||||
await dependencies.deleteBundle(host.id)
|
||||
await dependencies.clearJournal(host.id)
|
||||
await dependencies.clearJournal(journal)
|
||||
} else if (error instanceof MobileRelayUpgradeHostSupersededError) {
|
||||
await dependencies.clearJournal(host.id)
|
||||
await dependencies.clearJournal(journal)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await dependencies.clearJournal(host.id)
|
||||
await dependencies.clearJournal(journal)
|
||||
return { host: updatedHost, bundle }
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock('react-native', () => ({ Platform: platformMock }))
|
||||
|
||||
import {
|
||||
deletePairingKeychainItem,
|
||||
deletePairingKeychainItemIfMatches,
|
||||
readPairingKeychainItem,
|
||||
resetPairingKeychainForTests,
|
||||
writePairingKeychainItem
|
||||
@@ -338,6 +339,22 @@ describe('pairing keychain', () => {
|
||||
expect(asyncStorageMock.removeItem).toHaveBeenCalledWith(TOKEN_PRESENCE_KEY)
|
||||
})
|
||||
|
||||
it('does not delete a keychain item replaced by a newer lifecycle', async () => {
|
||||
secureStoreMock.getItemAsync.mockResolvedValue('replacement')
|
||||
|
||||
await expect(deletePairingKeychainItemIfMatches(TOKEN_KEY, 'stale')).resolves.toBe(false)
|
||||
|
||||
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes a keychain item only while its expected value is current', async () => {
|
||||
secureStoreMock.getItemAsync.mockResolvedValue('current')
|
||||
|
||||
await expect(deletePairingKeychainItemIfMatches(TOKEN_KEY, 'current')).resolves.toBe(true)
|
||||
|
||||
expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports a partial delete failure after attempting every generation', async () => {
|
||||
generationRecord = '2'
|
||||
const deleteError = new Error('delete failed')
|
||||
|
||||
@@ -156,8 +156,7 @@ async function setItemAtGeneration(key: string, value: string, generation: numbe
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPairingKeychainItem(key: string): Promise<string | null> {
|
||||
await keychainMutation
|
||||
async function readPairingKeychainItemImpl(key: string): Promise<string | null> {
|
||||
const presenceGeneration = await loadPresenceGeneration(key)
|
||||
if (presenceGeneration !== null) {
|
||||
const value = await SecureStore.getItemAsync(key, optionsForGeneration(presenceGeneration))
|
||||
@@ -182,6 +181,11 @@ export async function readPairingKeychainItem(key: string): Promise<string | nul
|
||||
return null
|
||||
}
|
||||
|
||||
export async function readPairingKeychainItem(key: string): Promise<string | null> {
|
||||
await keychainMutation
|
||||
return readPairingKeychainItemImpl(key)
|
||||
}
|
||||
|
||||
async function writePairingKeychainItemImpl(key: string, value: string): Promise<void> {
|
||||
const loaded = await loadGeneration()
|
||||
if (!loaded.reliable) {
|
||||
@@ -255,6 +259,20 @@ export function deletePairingKeychainItem(key: string): Promise<void> {
|
||||
return enqueueKeychainMutation(() => deletePairingKeychainItemImpl(key))
|
||||
}
|
||||
|
||||
export function deletePairingKeychainItemIfMatches(
|
||||
key: string,
|
||||
expectedValue: string
|
||||
): Promise<boolean> {
|
||||
let deleted = false
|
||||
return enqueueKeychainMutation(async () => {
|
||||
if ((await readPairingKeychainItemImpl(key)) !== expectedValue) {
|
||||
return
|
||||
}
|
||||
await deletePairingKeychainItemImpl(key)
|
||||
deleted = true
|
||||
}).then(() => deleted)
|
||||
}
|
||||
|
||||
/** Test-only: drop cached generation and mutation state between cases. */
|
||||
export function resetPairingKeychainForTests(): void {
|
||||
cachedGeneration = null
|
||||
|
||||
Reference in New Issue
Block a user