feat: T1016 dns_config probe + server-side drift detection + Crucible DNS DRIFT badge
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { authHeaders, clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
|
||||
|
||||
describe('auth session helpers', () => {
|
||||
@@ -23,4 +23,11 @@ describe('auth session helpers', () => {
|
||||
clearStoredAuth();
|
||||
expect(authHeaders()).toEqual({});
|
||||
});
|
||||
|
||||
it('getStoredAuth returns null when sessionStorage throws', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('blocked');
|
||||
});
|
||||
expect(getStoredAuth()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
294
server/web/src/api/client.test.ts
Normal file
294
server/web/src/api/client.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api } from './client';
|
||||
import { clearStoredAuth, setStoredAuth } from './auth';
|
||||
import { mockAgent, mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
|
||||
function jsonResponse(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function textResponse(body: string, status: number) {
|
||||
return new Response(body, { status });
|
||||
}
|
||||
|
||||
describe('api client', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function lastFetch(): { url: string; init: RequestInit } {
|
||||
const [url, init] = fetchMock.mock.calls.at(-1)!;
|
||||
return { url: url as string, init: init as RequestInit };
|
||||
}
|
||||
|
||||
function expectAuthHeaders(init: RequestInit) {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
|
||||
}
|
||||
|
||||
it('sends JSON Content-Type and auth on listAgents', async () => {
|
||||
setStoredAuth('user', 'pass');
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse([mockAgent()]));
|
||||
|
||||
const agents = await api.listAgents();
|
||||
|
||||
expect(agents).toHaveLength(1);
|
||||
const { url, init } = lastFetch();
|
||||
expect(url).toBe('/api/v1/agents');
|
||||
expect(init.method).toBeUndefined();
|
||||
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
||||
expectAuthHeaders(init);
|
||||
});
|
||||
|
||||
it('throws with status and body on API errors', async () => {
|
||||
fetchMock.mockResolvedValueOnce(textResponse('not found', 404));
|
||||
|
||||
await expect(api.getAgent('missing')).rejects.toThrow('API error 404: not found');
|
||||
expect(lastFetch().url).toBe('/api/v1/agents/missing');
|
||||
});
|
||||
|
||||
it('omits Authorization when logged out', async () => {
|
||||
clearStoredAuth();
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ status: 'ok' }));
|
||||
|
||||
await api.healthCheck();
|
||||
|
||||
const headers = lastFetch().init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getAgentStats appends limit query param', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse([]));
|
||||
await api.getAgentStats('agent-1', 25);
|
||||
expect(lastFetch().url).toBe('/api/v1/agents/agent-1/stats?limit=25');
|
||||
});
|
||||
|
||||
it('getAgentStats omits limit when undefined', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse([]));
|
||||
await api.getAgentStats('agent-1');
|
||||
expect(lastFetch().url).toBe('/api/v1/agents/agent-1/stats');
|
||||
});
|
||||
|
||||
it('getRecentShares appends limit query param', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse([]));
|
||||
await api.getRecentShares(10);
|
||||
expect(lastFetch().url).toBe('/api/v1/shares?limit=10');
|
||||
});
|
||||
|
||||
it('updateConfig PUTs JSON body', async () => {
|
||||
const partial = { server: { dashboard_subtitle: 'test' } };
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(mockServerConfig(partial)));
|
||||
|
||||
await api.updateConfig(partial);
|
||||
|
||||
const { url, init } = lastFetch();
|
||||
expect(url).toBe('/api/v1/config');
|
||||
expect(init.method).toBe('PUT');
|
||||
expect(init.body).toBe(JSON.stringify(partial));
|
||||
});
|
||||
|
||||
it('buildAgent POSTs JSON when fusion disabled', async () => {
|
||||
const req = { fusion_enabled: false, wallet: '4' + 'A'.repeat(94) } as Parameters<typeof api.buildAgent>[0];
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ build_id: 'b1', success: true }));
|
||||
|
||||
await api.buildAgent(req);
|
||||
|
||||
const { url, init } = lastFetch();
|
||||
expect(url).toBe('/api/v1/builder/build');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toBe(JSON.stringify(req));
|
||||
});
|
||||
|
||||
it('buildAgent rejects fusion without prep file', async () => {
|
||||
const req = { fusion_enabled: true } as Parameters<typeof api.buildAgent>[0];
|
||||
await expect(api.buildAgent(req)).rejects.toThrow('Fusion requires prep.exe upload');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('buildAgent POSTs multipart when fusion enabled', async () => {
|
||||
setStoredAuth('user', 'pass');
|
||||
const req = { fusion_enabled: true, wallet: '4' + 'A'.repeat(94) } as Parameters<typeof api.buildAgent>[0];
|
||||
const prep = new File(['prep'], 'custom.exe', { type: 'application/octet-stream' });
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ build_id: 'b2', success: true }));
|
||||
|
||||
await api.buildAgent(req, prep);
|
||||
|
||||
const { url, init } = lastFetch();
|
||||
expect(url).toBe('/api/v1/builder/build');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toBeInstanceOf(FormData);
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
|
||||
expect(headers['Content-Type']).toBeUndefined();
|
||||
const form = init.body as FormData;
|
||||
expect(form.get('config')).toBe(JSON.stringify(req));
|
||||
expect(form.get('prep_exe')).toBeInstanceOf(File);
|
||||
});
|
||||
|
||||
it('estimateFusion POSTs multipart with auth', async () => {
|
||||
setStoredAuth('user', 'pass');
|
||||
const req = { fusion_enabled: true } as Parameters<typeof api.estimateFusion>[0];
|
||||
const prep = new File(['prep'], 'prep.exe');
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ estimated_size_mb: 12 }));
|
||||
|
||||
await api.estimateFusion(req, prep);
|
||||
|
||||
const { url, init } = lastFetch();
|
||||
expect(url).toBe('/api/v1/builder/estimate');
|
||||
expect(init.method).toBe('POST');
|
||||
expectAuthHeaders(init);
|
||||
});
|
||||
|
||||
it('pinBuild, unpinAll, deleteBuild use correct methods and paths', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true, pinned_id: 'b1' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true }))
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true, deleted_id: 'b1' }));
|
||||
|
||||
await api.pinBuild('b1');
|
||||
expect(lastFetch().url).toBe('/api/v1/builds/b1/pin');
|
||||
expect(lastFetch().init.method).toBe('PUT');
|
||||
|
||||
await api.unpinAll();
|
||||
expect(lastFetch().url).toBe('/api/v1/builds/pin');
|
||||
expect(lastFetch().init.method).toBe('DELETE');
|
||||
|
||||
await api.deleteBuild('b1');
|
||||
expect(lastFetch().url).toBe('/api/v1/builds/b1');
|
||||
expect(lastFetch().init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('build URL helpers encode paths', () => {
|
||||
expect(api.buildDownloadUrl('id-1')).toBe('/api/v1/builds/id-1/download');
|
||||
expect(api.buildArtifactUrl('id-1', 'file with spaces.exe')).toBe(
|
||||
'/api/v1/builds/id-1/artifact/file%20with%20spaces.exe'
|
||||
);
|
||||
expect(api.buildUninstallUrl('id-1')).toBe('/api/v1/builds/id-1/uninstall');
|
||||
});
|
||||
|
||||
it('blueprint CRUD uses encoded names', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse([]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'preset' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true, name: 'preset', file_path: '/x', created_at: '' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true, name: 'preset' }));
|
||||
|
||||
await api.listBlueprints();
|
||||
expect(lastFetch().url).toBe('/api/v1/blueprints');
|
||||
|
||||
await api.getBlueprint('my preset');
|
||||
expect(lastFetch().url).toBe('/api/v1/blueprints/my%20preset');
|
||||
|
||||
await api.saveBlueprint('preset', { foo: 1 });
|
||||
expect(lastFetch().url).toBe('/api/v1/blueprints');
|
||||
expect(JSON.parse(lastFetch().init.body as string)).toEqual({ name: 'preset', data: { foo: 1 } });
|
||||
|
||||
await api.deleteBlueprint('my preset');
|
||||
expect(lastFetch().url).toBe('/api/v1/blueprints?name=my%20preset');
|
||||
expect(lastFetch().init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('fleet ops endpoints hit expected paths', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse([]))
|
||||
.mockResolvedValueOnce(jsonResponse([]))
|
||||
.mockResolvedValueOnce(jsonResponse([]))
|
||||
.mockResolvedValueOnce(jsonResponse({ xmr_per_day: 0.01, usd_per_day: 1, network_hashrate: 1 }))
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true }))
|
||||
.mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'log' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'fresh' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true, agent: mockAgent() }))
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true, sent: 2, failed: 0, action: 'pause' }));
|
||||
|
||||
await api.getAlerts();
|
||||
expect(lastFetch().url).toBe('/api/v1/alerts');
|
||||
|
||||
await api.getPoolStatus();
|
||||
expect(lastFetch().url).toBe('/api/v1/pools/status');
|
||||
|
||||
await api.getAIActivity();
|
||||
expect(lastFetch().url).toBe('/api/v1/ai/activity');
|
||||
|
||||
await api.getEarningsEstimate(1234.5);
|
||||
expect(lastFetch().url).toBe('/api/v1/earnings/estimate?hashrate=1234.5');
|
||||
|
||||
await api.sendAgentCommand('a1', 'pause', { reason: 'test' });
|
||||
expect(lastFetch().url).toBe('/api/v1/agents/a1/command');
|
||||
expect(JSON.parse(lastFetch().init.body as string)).toEqual({ action: 'pause', reason: 'test' });
|
||||
|
||||
await api.getAgentLog('a1');
|
||||
expect(lastFetch().url).toBe('/api/v1/agents/a1/log');
|
||||
|
||||
await api.getAgentLog('a1', true);
|
||||
expect(lastFetch().url).toBe('/api/v1/agents/a1/log?refresh=1');
|
||||
|
||||
await api.updateAgentMeta('a1', 'notes', ['tag1']);
|
||||
expect(lastFetch().url).toBe('/api/v1/agents/a1/meta');
|
||||
expect(JSON.parse(lastFetch().init.body as string)).toEqual({ notes: 'notes', tags: ['tag1'] });
|
||||
|
||||
await api.sendBulkCommand(['a1', 'a2'], 'resume');
|
||||
expect(lastFetch().url).toBe('/api/v1/agents/bulk-command');
|
||||
expect(JSON.parse(lastFetch().init.body as string)).toEqual({
|
||||
agent_ids: ['a1', 'a2'],
|
||||
action: 'resume',
|
||||
});
|
||||
});
|
||||
|
||||
it('createUser POSTs credentials', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ success: true }));
|
||||
|
||||
await api.createUser('alice', 'secret');
|
||||
|
||||
expect(lastFetch().url).toBe('/api/v1/users');
|
||||
expect(JSON.parse(lastFetch().init.body as string)).toEqual({ username: 'alice', password: 'secret' });
|
||||
});
|
||||
|
||||
it('getXmrPrice and getServerInfo', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ usd: 200, updated_at: 'now' }))
|
||||
.mockResolvedValueOnce(jsonResponse(mockServerInfo));
|
||||
|
||||
await api.getXmrPrice();
|
||||
expect(lastFetch().url).toBe('/api/v1/market/xmr');
|
||||
|
||||
await api.getServerInfo();
|
||||
expect(lastFetch().url).toBe('/api/v1/server/info');
|
||||
});
|
||||
|
||||
it('cancelBuild DELETEs encoded token', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ cancelled: true }));
|
||||
|
||||
await api.cancelBuild('token/with/slash');
|
||||
|
||||
expect(lastFetch().url).toBe('/api/v1/builder/cancel/token%2Fwith%2Fslash');
|
||||
expect(lastFetch().init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('getDashboardStats and listBuilds', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ total_agents: 1, online_agents: 1, total_hashrate: 100, total_shares: 5 })
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse([]));
|
||||
|
||||
await api.getDashboardStats();
|
||||
expect(lastFetch().url).toBe('/api/v1/dashboard/stats');
|
||||
|
||||
await api.listBuilds();
|
||||
expect(lastFetch().url).toBe('/api/v1/builds');
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,14 @@ import { authHeaders } from './auth';
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const { headers: extraHeaders, ...rest } = options ?? {};
|
||||
const res = await fetch(`${API_BASE}${url}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(options?.headers as Record<string, string>) },
|
||||
...options,
|
||||
...rest,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(),
|
||||
...(extraHeaders as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
|
||||
68
server/web/src/api/download.test.ts
Normal file
68
server/web/src/api/download.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { downloadAuthedFile, downloadApiFile } from './download';
|
||||
import { setStoredAuth } from './auth';
|
||||
|
||||
describe('downloadAuthedFile', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let clickMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
clickMock = vi.fn();
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('normalizes relative paths under /api/v1', async () => {
|
||||
setStoredAuth('user', 'pass');
|
||||
fetchMock.mockResolvedValueOnce(new Response(new Blob(['data']), { status: 200 }));
|
||||
|
||||
await downloadAuthedFile('/builds/b1/download', 'agent.exe');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/builds/b1/download');
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(`Basic ${btoa('user:pass')}`);
|
||||
expect(clickMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves full /api/v1 paths unchanged', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(new Blob(['x']), { status: 200 }));
|
||||
|
||||
await downloadAuthedFile('/api/v1/builds/b2/uninstall', 'uninstall.bat');
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/builds/b2/uninstall');
|
||||
});
|
||||
|
||||
it('prefixes bare paths without leading slash', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(new Blob(['x']), { status: 200 }));
|
||||
|
||||
await downloadAuthedFile('builds/b3/artifact/file.exe', 'file.exe');
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/builds/b3/artifact/file.exe');
|
||||
});
|
||||
|
||||
it('throws server error body on failure', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('forbidden', { status: 403 }));
|
||||
|
||||
await expect(downloadAuthedFile('/builds/x/download', 'x.exe')).rejects.toThrow('forbidden');
|
||||
});
|
||||
|
||||
it('throws status fallback when error body empty', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('', { status: 500 }));
|
||||
|
||||
await expect(downloadAuthedFile('/builds/x/download', 'x.exe')).rejects.toThrow('Download failed (500)');
|
||||
});
|
||||
|
||||
it('downloadApiFile is an alias', () => {
|
||||
expect(downloadApiFile).toBe(downloadAuthedFile);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user