Keep private device list requests separate across view generations

This commit is contained in:
archipelago
2026-10-08 07:15:47 -04:00
parent 214cebfac2
commit a1bc642615
3 changed files with 30 additions and 2 deletions
+12
View File
@@ -87,3 +87,15 @@ Final follow-up receipts:
All fixture browser/server processes stopped after verification. No node, helper,
firewall, wallet or saved-device configuration was changed by these UI checks.
A final narrow follow-up disables in-flight deduplication for the private device
list. The shared client's dedup key is only method+params; a new view/capability
generation could otherwise attach to the preceding generation's unresolved
promise. The device suite now passes 18/18, including fresh-list recovery and late
old-response rejection (`/tmp/archy-firewall-private-generation-tests-20261008.log`).
No broader browser rerun was needed for this request-policy-only change.
Retry behavior is unchanged: this client's maxRetries value of 1 already means
one network attempt, just like 0 after its minimum clamp. A confirmed pre-dispatch
CSRF rejection may still refresh credentials and retry. No cross-node dedup fault
was claimed: the client's base URL is fixed per instance.
@@ -36,7 +36,23 @@ describe('Private device management', () => {
expect(rpcClient.call).not.toHaveBeenCalled()
await w.setProps({ statusKnown: true }); await flushPromises()
expect(rpcClient.call).toHaveBeenCalledTimes(1)
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'vpn.list-peers' }))
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'vpn.list-peers', dedup: false }))
w.unmount()
})
it('starts a fresh non-deduplicated list after capability recovery and ignores the prior generation', async () => {
const replies: Array<(value: { peers: typeof peer[] }) => void> = []
vi.mocked(rpcClient.call).mockImplementation(async () => new Promise(resolve => { replies.push(resolve) }))
const w = view(); await flushPromises()
await w.setProps({ managementVerified: false }); await flushPromises()
await w.setProps({ managementVerified: true }); await flushPromises()
expect(replies).toHaveLength(2)
for (const [options] of vi.mocked(rpcClient.call).mock.calls) {
expect(options).toEqual(expect.objectContaining({ method: 'vpn.list-peers', dedup: false }))
}
replies[1]!({ peers: [{ ...peer, name: 'Tablet' }] }); await flushPromises()
expect(w.text()).toContain('Tablet')
replies[0]!({ peers: [{ ...peer, name: 'Old Phone' }] }); await flushPromises()
expect(w.text()).toContain('Tablet'); expect(w.text()).not.toContain('Old Phone')
w.unmount()
})
it('clears private details and rejects an in-flight reveal when capability is lost', async () => {
@@ -32,7 +32,7 @@ async function refresh() {
loading.value = true
error.value = ''
try {
const result = await rpcClient.call<{ peers: Device[] }>({ method: 'vpn.list-peers', dedup: true, maxRetries: 1 })
const result = await rpcClient.call<{ peers: Device[] }>({ method: 'vpn.list-peers', dedup: false, maxRetries: 1 })
if (!canReadDevices.value || !active || generation !== request) return
if (!Array.isArray(result?.peers) || result.peers.some(p => !p || typeof p.name !== 'string' || typeof p.ip !== 'string' || (p.management_state !== undefined && !['active', 'pending', 'revoking'].includes(p.management_state)))) throw new Error('Invalid device list')
devices.value = result.peers.map(p => ({ name: p.name, ip: p.ip, management_state: p.management_state }))