Files
Easytier/easytier-gui/src/composables/mobile_vpn.test.ts
T
Zhengqi Zhang 35845c14e6 fix(android): reconcile VPN service after startup (#2491)
Summary
Reconcile the Android system VPN after the GUI has finished initializing the EasyTier core.
Retry reconciliation while network information or the virtual IPv4 address is not ready yet.
Serialize reconciliation work and de-duplicate concurrent VPN permission requests.
Root cause
On Android, the network instance can report that it has started before collectNetworkInfo exposes the instance state and virtual IPv4 address. The previous startup path treated that temporary state as a terminal failure, stopped VPN setup, and relied on another event to retry it. If no later event arrived, peers could connect successfully while the Android VpnService remained inactive until the user stopped and started the network again.

PR #1628 added polling for the DHCP-specific empty-IP case. The same race can occur earlier, while network information is still unavailable, and can also affect static-IP configurations.
2026-08-17 22:12:01 +08:00

233 lines
6.8 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const listeners = new Map<string, (payload: unknown) => Promise<void>>()
const configs = new Map<string, Record<string, unknown>>()
const networkInfo = new Map<string, unknown>()
return {
listeners,
configs,
networkInfo,
addPluginListener: vi.fn(async (_plugin: string, event: string, listener: (payload: unknown) => Promise<void>) => {
listeners.set(event, listener)
}),
collectNetworkInfo: vi.fn(async (instanceId: string) => ({
info: { map: { [instanceId]: networkInfo.get(instanceId) } },
})),
getConfig: vi.fn(async (instanceId: string) => configs.get(instanceId)),
getVpnStatus: vi.fn<() => Promise<Record<string, unknown>>>(async () => ({ running: false })),
listNetworkInstanceIds: vi.fn<() => Promise<{ running_inst_ids: unknown[] }>>(async () => ({ running_inst_ids: [] })),
prepareVpn: vi.fn(async () => ({ granted: true })),
setTunFd: vi.fn(async () => undefined),
startVpn: vi.fn(async () => {
await listeners.get('vpn_service_start')?.({ fd: 1 })
return {}
}),
stopVpn: vi.fn(async () => {
await listeners.get('vpn_service_stop')?.({})
return {}
}),
}
})
vi.mock('@tauri-apps/api/core', () => ({
addPluginListener: mocks.addPluginListener,
}))
vi.mock('easytier-frontend-lib', () => ({
Utils: {
UuidToStr: (value: unknown) => String(value),
ipv4ToString: (address: { addr: string }) => address.addr,
},
}))
vi.mock('tauri-plugin-vpnservice-api', () => ({
get_vpn_status: mocks.getVpnStatus,
prepare_vpn: mocks.prepareVpn,
start_vpn: mocks.startVpn,
stop_vpn: mocks.stopVpn,
}))
vi.mock('./backend', () => ({
collectNetworkInfo: mocks.collectNetworkInfo,
getConfig: mocks.getConfig,
listNetworkInstanceIds: mocks.listNetworkInstanceIds,
setTunFd: mocks.setTunFd,
}))
function setConfig(instanceId: string, noTun = false) {
mocks.configs.set(instanceId, {
no_tun: noTun,
dhcp: false,
enable_magic_dns: false,
routes: [],
})
}
function setReady(instanceId: string, ipv4: string) {
mocks.networkInfo.set(instanceId, {
my_node_info: {
virtual_ipv4: {
address: { addr: ipv4 },
network_length: 24,
},
},
routes: [],
})
}
async function loadVpnModule() {
const mobileVpn = await import('./mobile_vpn')
await mobileVpn.initMobileVpnService()
return mobileVpn
}
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
mocks.listeners.clear()
mocks.configs.clear()
mocks.networkInfo.clear()
mocks.addPluginListener.mockClear()
mocks.collectNetworkInfo.mockClear()
mocks.getConfig.mockClear()
mocks.getVpnStatus.mockReset()
mocks.getVpnStatus.mockResolvedValue({ running: false })
mocks.listNetworkInstanceIds.mockReset()
mocks.listNetworkInstanceIds.mockResolvedValue({ running_inst_ids: [] })
mocks.prepareVpn.mockClear()
mocks.setTunFd.mockClear()
mocks.startVpn.mockClear()
mocks.stopVpn.mockClear()
})
describe('mobile VPN reconciliation ownership', () => {
it('stops A before retrying an unavailable B, then starts B when it becomes ready', async () => {
setConfig('A')
setConfig('B')
setReady('A', '10.0.0.1')
const vpn = await loadVpnModule()
await vpn.onNetworkInstanceChange('A')
expect(mocks.startVpn).toHaveBeenCalledTimes(1)
mocks.startVpn.mockClear()
await vpn.onNetworkInstanceChange('B')
expect(mocks.stopVpn).toHaveBeenCalledTimes(1)
expect(mocks.startVpn).not.toHaveBeenCalled()
setReady('B', '10.0.0.2')
await vpn.onNetworkInstanceUpdate('B')
expect(mocks.startVpn).toHaveBeenCalledTimes(1)
expect(mocks.startVpn).toHaveBeenCalledWith(expect.objectContaining({ ipv4Addr: '10.0.0.2/24' }))
})
it('stops the previous owner during pre-run even if the new instance never reaches post-run', async () => {
setConfig('A')
setConfig('B')
setReady('A', '10.0.0.1')
const vpn = await loadVpnModule()
await vpn.onNetworkInstanceChange('A')
mocks.stopVpn.mockClear()
await vpn.prepareVpnService('B')
expect(mocks.stopVpn).toHaveBeenCalledTimes(1)
})
it('preserves the VPN while retrying the same instance', async () => {
setConfig('A')
setReady('A', '10.0.0.1')
const vpn = await loadVpnModule()
await vpn.onNetworkInstanceChange('A')
mocks.stopVpn.mockClear()
mocks.networkInfo.delete('A')
await vpn.onNetworkInstanceUpdate('A')
expect(mocks.stopVpn).not.toHaveBeenCalled()
})
it('ignores an update from an instance that no longer owns the VPN', async () => {
setConfig('A')
setConfig('B')
setReady('A', '10.0.0.1')
const vpn = await loadVpnModule()
await vpn.onNetworkInstanceChange('A')
await vpn.onNetworkInstanceChange('B')
mocks.collectNetworkInfo.mockClear()
await vpn.onNetworkInstanceUpdate('A')
expect(mocks.collectNetworkInfo).not.toHaveBeenCalled()
})
it('does not apply an in-flight result after the desired instance changes', async () => {
setConfig('A')
setConfig('B')
setReady('A', '10.0.0.1')
const vpn = await loadVpnModule()
await vpn.onNetworkInstanceChange('A')
mocks.startVpn.mockClear()
mocks.stopVpn.mockClear()
interface NetworkInfoResponse { info: { map: Record<string, unknown> } }
let resolveNetworkInfo: (value: NetworkInfoResponse) => void = () => undefined
let markCollectStarted: () => void = () => undefined
const collectStarted = new Promise<void>((resolve) => {
markCollectStarted = resolve
})
mocks.collectNetworkInfo.mockImplementationOnce(async () => await new Promise<NetworkInfoResponse>((resolve) => {
resolveNetworkInfo = resolve
markCollectStarted()
}))
const staleUpdate = vpn.onNetworkInstanceUpdate('A')
await collectStarted
const switchToB = vpn.onNetworkInstanceChange('B')
resolveNetworkInfo({
info: {
map: {
A: {
my_node_info: {
virtual_ipv4: {
address: { addr: '10.0.0.99' },
network_length: 24,
},
},
routes: [],
},
},
},
})
await Promise.all([staleUpdate, switchToB])
expect(mocks.startVpn).not.toHaveBeenCalled()
expect(mocks.stopVpn).toHaveBeenCalledTimes(1)
})
it('stops a native VPN with unknown ownership before retrying the selected instance', async () => {
setConfig('A')
mocks.getVpnStatus.mockResolvedValue({
running: true,
ipv4Addr: '10.0.0.1/24',
routes: [],
})
mocks.listNetworkInstanceIds.mockResolvedValue({ running_inst_ids: ['A'] })
const vpn = await loadVpnModule()
await vpn.syncMobileVpnService()
expect(mocks.stopVpn).toHaveBeenCalledTimes(1)
expect(mocks.startVpn).not.toHaveBeenCalled()
})
})