fix: 2026-06-04 audit pass — README, USB pack, multi-area fixes
WS ticket dashboard auth, builder universal signing/size limits/fusion obfuscation/dropper bundles, Path Tracer WireGuard topology, SessionGate degraded mode and download timeouts, server bootstrap (data dir, cloudflared dedupe, config port precedence), agent mesh/miner/spread fixes. README refreshed; usb bundle repacked; PROBLEMS.md audit log updated.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, renderHook } from '@testing-library/react';
|
||||
import { act, render, renderHook, waitFor } from '@testing-library/react';
|
||||
import { WebSocketProvider } from './WebSocketProvider';
|
||||
import { useWebSocketContext } from './WebSocketContext';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
@@ -50,6 +50,7 @@ describe('WebSocketProvider', () => {
|
||||
MockWebSocket.instances = [];
|
||||
setStoredAuth('testuser', 'testpass', { silent: true });
|
||||
vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket);
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no ws ticket')));
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'http:', host: 'localhost:8080' },
|
||||
configurable: true,
|
||||
@@ -64,16 +65,23 @@ describe('WebSocketProvider', () => {
|
||||
return MockWebSocket.instances.at(-1)!;
|
||||
}
|
||||
|
||||
async function waitForSocket() {
|
||||
await waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThan(0);
|
||||
});
|
||||
return latestSocket();
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <WebSocketProvider>{children}</WebSocketProvider>;
|
||||
}
|
||||
|
||||
it('connects to ws dashboard with auth token query param', () => {
|
||||
it('connects to ws dashboard with auth token query param', async () => {
|
||||
setStoredAuth('drjones', 'secret');
|
||||
MockWebSocket.instances = [];
|
||||
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||
|
||||
const ws = latestSocket();
|
||||
const ws = await waitForSocket();
|
||||
const token = btoa('drjones:secret');
|
||||
expect(ws.url).toBe(`ws://localhost:8080/ws/dashboard?token=${encodeURIComponent(token)}`);
|
||||
|
||||
@@ -92,9 +100,10 @@ describe('WebSocketProvider', () => {
|
||||
expect(useWebSocket).toBe(useWebSocketContext);
|
||||
});
|
||||
|
||||
it('handles init and agent_online messages', () => {
|
||||
it('handles init and agent_online messages', async () => {
|
||||
const agent = mockAgent({ id: 'live-1' });
|
||||
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||
await waitForSocket();
|
||||
|
||||
act(() => {
|
||||
latestSocket().emitOpen();
|
||||
@@ -115,9 +124,10 @@ describe('WebSocketProvider', () => {
|
||||
expect(result.current.agents[0].hashrate_15s).toBe(999);
|
||||
});
|
||||
|
||||
it('marks agent offline and caps recent shares', () => {
|
||||
it('marks agent offline and caps recent shares', async () => {
|
||||
const agent = mockAgent({ id: 'a-offline' });
|
||||
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||
await waitForSocket();
|
||||
|
||||
act(() => {
|
||||
latestSocket().emitOpen();
|
||||
@@ -140,8 +150,9 @@ describe('WebSocketProvider', () => {
|
||||
expect(result.current.recentShares.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('assigns monotonic _seq on command_result', () => {
|
||||
it('assigns monotonic _seq on command_result', async () => {
|
||||
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||
await waitForSocket();
|
||||
|
||||
act(() => {
|
||||
latestSocket().emitOpen();
|
||||
@@ -161,23 +172,24 @@ describe('WebSocketProvider', () => {
|
||||
expect(result.current.agentLogs.a1).toBe('log data');
|
||||
});
|
||||
|
||||
it('schedules reconnect after close', () => {
|
||||
vi.useFakeTimers();
|
||||
it('schedules reconnect after close', async () => {
|
||||
MockWebSocket.instances = [];
|
||||
renderHook(() => useWebSocketContext(), { wrapper });
|
||||
const first = latestSocket();
|
||||
const first = await waitForSocket();
|
||||
|
||||
act(() => first.close());
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 3100));
|
||||
});
|
||||
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(2));
|
||||
}, 10000);
|
||||
|
||||
it('closes socket on unmount', () => {
|
||||
it('closes socket on unmount', async () => {
|
||||
const closeSpy = vi.spyOn(MockWebSocket.prototype, 'close');
|
||||
const { unmount } = render(<WebSocketProvider><span /></WebSocketProvider>);
|
||||
await waitForSocket();
|
||||
unmount();
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||
import { WebSocketContext } from './WebSocketContext';
|
||||
import type { SeqCommandResult } from './WebSocketContext';
|
||||
import { getStoredAuth } from '../api/auth';
|
||||
import { authHeaders, getStoredAuth } from '../api/auth';
|
||||
|
||||
/**
|
||||
* WebSocketProvider mounts a SINGLE WebSocket connection for the whole app.
|
||||
@@ -54,25 +54,44 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const existing = wsRef.current;
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
||||
existing.onclose = null;
|
||||
existing.close();
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => { if (!unmounted.current) setIsConnected(true); };
|
||||
|
||||
ws.onclose = () => {
|
||||
const openSocket = async () => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
let wsQuery = `token=${encodeURIComponent(token)}`;
|
||||
try {
|
||||
const resp = await fetch('/api/v1/auth/ws-ticket', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = (await resp.json()) as { ticket?: string };
|
||||
if (data.ticket) {
|
||||
wsQuery = `ticket=${encodeURIComponent(data.ticket)}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall back to legacy token query param */
|
||||
}
|
||||
if (unmounted.current) return;
|
||||
setIsConnected(false);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
if (!getStoredAuth()) return;
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => { ws.close(); };
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?${wsQuery}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => { if (!unmounted.current) setIsConnected(true); };
|
||||
|
||||
ws.onclose = () => {
|
||||
if (unmounted.current) return;
|
||||
setIsConnected(false);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
if (!getStoredAuth()) return;
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => { ws.close(); };
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
@@ -222,6 +241,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
console.error('Failed to parse WebSocket message:', err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
void openSocket();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user