Recover stale authenticated CSRF cookies and distinguish interface fetch failures
This commit is contained in:
@@ -36,9 +36,38 @@ describe('RPCClient', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.cookie = 'csrf_token=; Max-Age=0; Path=/'
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('uses a refreshed CSRF cookie once even with a single-attempt request', async () => {
|
||||
document.cookie = 'csrf_token=old; Path=/'
|
||||
mockFetch.mockImplementationOnce(async () => {
|
||||
document.cookie = 'csrf_token=new; Path=/'
|
||||
return jsonResponse({ error: { message: 'CSRF token missing or invalid' } }, 403)
|
||||
}).mockResolvedValueOnce(jsonResponse({ result: { interfaces: [] } }))
|
||||
await expect(rpcClient.call({ method: 'network.list-interfaces', maxRetries: 1 })).resolves.toEqual({ interfaces: [] })
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetch.mock.calls[0]![1].headers['X-CSRF-Token']).toBe('old')
|
||||
expect(mockFetch.mock.calls[1]![1].headers['X-CSRF-Token']).toBe('new')
|
||||
})
|
||||
|
||||
it('never loops on changing CSRF cookies or retries a permissions rejection', async () => {
|
||||
let count = 0
|
||||
mockFetch.mockImplementation(async () => {
|
||||
document.cookie = `csrf_token=rotated-${++count}; Path=/`
|
||||
return jsonResponse({ error: { message: 'CSRF token missing or invalid' } }, 403)
|
||||
})
|
||||
await expect(rpcClient.call({ method: 'network.list-interfaces', maxRetries: 1 })).rejects.toThrow('CSRF')
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
mockFetch.mockReset().mockImplementationOnce(async () => {
|
||||
document.cookie = 'csrf_token=another; Path=/'
|
||||
return jsonResponse({ error: { message: 'Forbidden: insufficient permissions' } }, 403)
|
||||
})
|
||||
await expect(rpcClient.call({ method: 'system.settings.set', maxRetries: 1 })).rejects.toThrow('insufficient permissions')
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('makes a successful RPC call and returns the result', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ class RPCClient {
|
||||
private async callInner<T>(options: RPCOptions): Promise<T> {
|
||||
const { method, params = {}, timeout = 15000, signal: external } = options
|
||||
const maxRetries = Math.max(1, options.maxRetries ?? 3)
|
||||
let csrfRefreshed = false
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
if (external?.aborted) throw new Error('Aborted')
|
||||
@@ -187,6 +188,16 @@ class RPCClient {
|
||||
} catch { /* body parse failed */ }
|
||||
|
||||
const isCsrf = !reason || reason.toLowerCase().includes('csrf')
|
||||
const refreshedToken = getCsrfToken()
|
||||
if (isCsrf && !csrfRefreshed && refreshedToken && refreshedToken !== csrfToken) {
|
||||
// The server rejected the action before dispatch and refreshed
|
||||
// the cookie for this authenticated session. Allow exactly one
|
||||
// retry even for single-attempt reads; never replay on ambiguous
|
||||
// network failures or a permissions rejection.
|
||||
csrfRefreshed = true
|
||||
attempt--
|
||||
continue
|
||||
}
|
||||
if (isCsrf && attempt < maxRetries - 1) {
|
||||
// CSRF mismatch — cookie may have been updated by a concurrent
|
||||
// Set-Cookie response not yet visible to JS. Retry after delay.
|
||||
|
||||
@@ -279,7 +279,11 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
|
||||
<div v-if="interfacesError" role="status" class="flex items-center justify-between gap-3 text-sm text-white/60 py-2">
|
||||
<span>{{ allInterfaces.length ? 'Couldn’t refresh interfaces. Showing last known information.' : 'Couldn’t load network interfaces.' }}</span>
|
||||
<button class="glass-button rounded px-3 py-1.5 text-xs shrink-0" :disabled="interfacesRefreshing" @click="loadInterfaces">Retry</button>
|
||||
</div>
|
||||
<p v-else-if="physicalInterfaces.length === 0 && interfacesRes.data.value !== null" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
|
||||
<p v-if="wifiRadioError" class="text-xs text-red-400">{{ wifiRadioError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -653,7 +657,8 @@ const interfacesRes = useCachedResource<NetworkInterface[]>({
|
||||
return res.interfaces
|
||||
},
|
||||
})
|
||||
const interfacesLoading = computed(() => interfacesRes.loadState.value === 'loading')
|
||||
const interfacesError = computed(() => interfacesRes.error.value)
|
||||
const interfacesLoading = computed(() => interfacesRes.loadState.value === 'loading' || (interfacesRes.loadState.value === 'idle' && interfacesRes.data.value === null))
|
||||
const interfacesRefreshing = computed(() => interfacesRes.loadState.value === 'refreshing')
|
||||
const allInterfaces = computed(() => interfacesRes.data.value ?? [])
|
||||
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
|
||||
|
||||
@@ -43,6 +43,32 @@ function mountServer(options: { renderTorServices?: boolean } = {}) {
|
||||
}
|
||||
|
||||
describe('Server network refresh states', () => {
|
||||
it('shows an interface fetch failure without claiming hardware is absent, then retries', async () => {
|
||||
sessionStorage.clear()
|
||||
let failed = true
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.list-interfaces') {
|
||||
if (failed) return Promise.reject(new Error('CSRF token missing or invalid'))
|
||||
return Promise.resolve({ interfaces: [{ name: 'wlp3s0', type: 'wifi', state: 'up', mac: '', ipv4: ['192.0.2.10'] }] })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
|
||||
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||
const wrapper = mountServer()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Couldn’t load network interfaces.')
|
||||
expect(wrapper.text()).not.toContain('No physical interfaces detected')
|
||||
failed = false
|
||||
const retry = wrapper.findAll('button').find(button => button.text() === 'Retry')!
|
||||
await retry.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('wlp3s0')
|
||||
expect(wrapper.text()).not.toContain('Couldn’t load network interfaces.')
|
||||
wrapper.unmount()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
it('keeps network overview visible while refresh is pending', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.diagnostics') {
|
||||
|
||||
Reference in New Issue
Block a user