205 lines
8.1 KiB
TypeScript
205 lines
8.1 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
import { mkdtempSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { Repository } from '../db/repository.js';
|
|
import { createDashboardApi } from './dashboard-api.js';
|
|
import type { BackendStatusRegistry, NodeStatus } from '../engine/backend-status-registry.js';
|
|
|
|
function makeApp(userId: string, repo: Repository, opts?: {
|
|
registry?: BackendStatusRegistry | null;
|
|
}): express.Application {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).user = { id: userId, role: 'user' };
|
|
next();
|
|
});
|
|
app.use(
|
|
'/api/local/dashboard',
|
|
createDashboardApi({
|
|
repo,
|
|
getWorkers: () => [
|
|
{ id: 'w1', endpoint: 'x', roles: ['task'] },
|
|
],
|
|
authActive: true,
|
|
backendStatusRegistry: opts?.registry ?? null,
|
|
}),
|
|
);
|
|
return app;
|
|
}
|
|
|
|
function stubRegistry(nodes: NodeStatus[]): BackendStatusRegistry {
|
|
return {
|
|
start: () => {},
|
|
stop: async () => {},
|
|
getAll: () => nodes.slice(),
|
|
getByNodeId: (id) => nodes.find(n => n.nodeId === id) ?? null,
|
|
subscribe: () => () => {},
|
|
refresh: async () => {},
|
|
};
|
|
}
|
|
|
|
describe('Dashboard API', () => {
|
|
let tmpDir: string;
|
|
let repo: Repository;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'dashboard-api-test-'));
|
|
repo = new Repository(join(tmpDir, 'test.db'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('GET /workers returns idle/running per worker', async () => {
|
|
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/workers');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.workers).toHaveLength(1);
|
|
expect(res.body.workers[0].id).toBe('w1');
|
|
expect(res.body.workers[0].state).toBe('idle');
|
|
});
|
|
|
|
it('GET /workers does not include job id/title/owner', async () => {
|
|
const j = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'seed' });
|
|
await repo.updateJob(j.id, { status: 'running', workerId: 'w1' });
|
|
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/workers');
|
|
const keys = Object.keys(res.body.workers[0]).sort();
|
|
// `proxy` was added when Worker widget gained tree-expand for proxy
|
|
// workers (PR #350). `backends` / `busySlots` / `totalSlots` / `online`
|
|
// are conditional (proxy with registry, direct with registry) so they
|
|
// can be absent. The privacy contract here is the negative — job id,
|
|
// title, and owner must never appear — so assert that explicitly
|
|
// alongside the allowed-key whitelist.
|
|
expect(keys.includes('proxy')).toBe(true);
|
|
const allowed = new Set(['id', 'name', 'roles', 'state', 'proxy', 'backends', 'busySlots', 'totalSlots', 'online']);
|
|
for (const k of keys) {
|
|
expect(allowed.has(k)).toBe(true);
|
|
}
|
|
// Defensive: leaks would show up as one of these substrings.
|
|
const serialized = JSON.stringify(res.body.workers[0]);
|
|
expect(serialized).not.toMatch(/local\/task-1/);
|
|
expect(serialized).not.toMatch(/instruction|"u1"|"seed"/);
|
|
});
|
|
|
|
it('returns 401 when no req.user and authActive=true', async () => {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/api/local/dashboard', createDashboardApi({
|
|
repo,
|
|
getWorkers: () => [],
|
|
authActive: true,
|
|
}));
|
|
const res = await request(app).get('/api/local/dashboard/workers');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('GET /node-status returns 503 when registry is not configured', async () => {
|
|
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/node-status');
|
|
expect(res.status).toBe(503);
|
|
});
|
|
|
|
it('GET /node-status returns registry snapshot', async () => {
|
|
const nodes: NodeStatus[] = [{
|
|
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
|
|
online: true, busy: false, busySlots: 0, totalSlots: 4,
|
|
loadedModel: 'qwen3:8b', throughputTps: null,
|
|
lastSeen: '2026-05-18T00:00:00.000Z',
|
|
}];
|
|
const res = await request(makeApp('u1', repo, { registry: stubRegistry(nodes) }))
|
|
.get('/api/local/dashboard/node-status');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.nodes).toEqual(nodes);
|
|
});
|
|
|
|
it('GET /node-status sets Cache-Control: no-store and a weak ETag', async () => {
|
|
const nodes: NodeStatus[] = [{
|
|
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
|
|
online: true, busy: false, busySlots: 0, totalSlots: 4,
|
|
loadedModel: 'qwen3:8b', throughputTps: null,
|
|
lastSeen: '2026-05-18T00:00:00.000Z',
|
|
}];
|
|
const res = await request(makeApp('u1', repo, { registry: stubRegistry(nodes) }))
|
|
.get('/api/local/dashboard/node-status');
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['cache-control']).toBe('no-store');
|
|
expect(res.headers['etag']).toMatch(/^W\/"[0-9a-f]{16}"$/);
|
|
});
|
|
|
|
it('GET /node-status returns 304 on If-None-Match match', async () => {
|
|
const nodes: NodeStatus[] = [{
|
|
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
|
|
online: true, busy: false, busySlots: 0, totalSlots: 4,
|
|
loadedModel: 'qwen3:8b', throughputTps: null,
|
|
lastSeen: '2026-05-18T00:00:00.000Z',
|
|
}];
|
|
const app = makeApp('u1', repo, { registry: stubRegistry(nodes) });
|
|
const first = await request(app).get('/api/local/dashboard/node-status');
|
|
const etag = first.headers['etag'];
|
|
const second = await request(app)
|
|
.get('/api/local/dashboard/node-status')
|
|
.set('If-None-Match', etag);
|
|
expect(second.status).toBe(304);
|
|
// 304 must not carry a body.
|
|
expect(second.text).toBe('');
|
|
});
|
|
|
|
it('GET /node-status returns 304 on multi-value If-None-Match (RFC 9110 §13.1.2)', async () => {
|
|
const nodes: NodeStatus[] = [{
|
|
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
|
|
online: true, busy: false, busySlots: 0, totalSlots: 4,
|
|
loadedModel: 'qwen3:8b', throughputTps: null,
|
|
lastSeen: '2026-05-18T00:00:00.000Z',
|
|
}];
|
|
const app = makeApp('u1', repo, { registry: stubRegistry(nodes) });
|
|
const first = await request(app).get('/api/local/dashboard/node-status');
|
|
const etag = first.headers['etag'] as string;
|
|
// Browsers' BFCache restore and HTTP intermediaries can produce
|
|
// comma-separated multi-tag If-None-Match headers. The server must
|
|
// match any of them per RFC 9110 §13.1.2.
|
|
const multi = `W/"deadbeefdeadbeef", ${etag}, W/"cafef00dcafef00d"`;
|
|
const second = await request(app)
|
|
.get('/api/local/dashboard/node-status')
|
|
.set('If-None-Match', multi);
|
|
expect(second.status).toBe(304);
|
|
expect(second.text).toBe('');
|
|
});
|
|
|
|
it('GET /node-status returns 200 when no tag in multi-value If-None-Match matches', async () => {
|
|
const nodes: NodeStatus[] = [{
|
|
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
|
|
online: true, busy: false, busySlots: 0, totalSlots: 4,
|
|
loadedModel: 'qwen3:8b', throughputTps: null,
|
|
lastSeen: '2026-05-18T00:00:00.000Z',
|
|
}];
|
|
const app = makeApp('u1', repo, { registry: stubRegistry(nodes) });
|
|
const second = await request(app)
|
|
.get('/api/local/dashboard/node-status')
|
|
.set('If-None-Match', 'W/"deadbeef", W/"cafef00d"');
|
|
expect(second.status).toBe(200);
|
|
expect(second.body.nodes).toEqual(nodes);
|
|
});
|
|
|
|
it('GET /node-status returns 200 when If-None-Match header is absent', async () => {
|
|
const nodes: NodeStatus[] = [];
|
|
const res = await request(makeApp('u1', repo, { registry: stubRegistry(nodes) }))
|
|
.get('/api/local/dashboard/node-status');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.nodes).toEqual([]);
|
|
});
|
|
|
|
it('GET /node-status calls noteSubscriberActivity when available', async () => {
|
|
const nodes: NodeStatus[] = [];
|
|
const stub = stubRegistry(nodes) as BackendStatusRegistry & { calls: number };
|
|
stub.calls = 0;
|
|
(stub as any).noteSubscriberActivity = () => { stub.calls++; };
|
|
const res = await request(makeApp('u1', repo, { registry: stub }))
|
|
.get('/api/local/dashboard/node-status');
|
|
expect(res.status).toBe(200);
|
|
expect(stub.calls).toBe(1);
|
|
});
|
|
});
|