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 /widgets returns empty when none', async () => { const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/widgets'); expect(res.status).toBe(200); expect(res.body.widgets).toEqual([]); }); it('POST /widgets creates a widget', async () => { const res = await request(makeApp('u1', repo)) .post('/api/local/dashboard/widgets') .send({ slug: 'memo', title: 'Memo', content: 'hi' }); expect(res.status).toBe(201); expect(res.body.widget.slug).toBe('memo'); expect(res.body.widget.markdownContent).toBe('hi'); }); it('POST /widgets rejects invalid slug', async () => { const res = await request(makeApp('u1', repo)) .post('/api/local/dashboard/widgets') .send({ slug: 'Bad Slug!', title: 't', content: '' }); expect(res.status).toBe(400); }); it('POST /widgets rejects duplicate slug', async () => { const app = makeApp('u1', repo); await request(app).post('/api/local/dashboard/widgets').send({ slug: 'memo', title: 'a', content: '' }); const dup = await request(app).post('/api/local/dashboard/widgets').send({ slug: 'memo', title: 'b', content: '' }); expect(dup.status).toBe(409); }); it('PATCH /widgets/:id updates content', async () => { const app = makeApp('u1', repo); const created = await request(app).post('/api/local/dashboard/widgets').send({ slug: 's', title: 't', content: 'old' }); const id = created.body.widget.id; const res = await request(app).patch(`/api/local/dashboard/widgets/${id}`).send({ content: 'new' }); expect(res.status).toBe(200); expect(res.body.widget.markdownContent).toBe('new'); }); it('PATCH /widgets/:id returns 404 for other user', async () => { const created = await request(makeApp('u1', repo)) .post('/api/local/dashboard/widgets').send({ slug: 's', title: 't', content: '' }); const res = await request(makeApp('u2', repo)) .patch(`/api/local/dashboard/widgets/${created.body.widget.id}`).send({ content: 'hack' }); expect(res.status).toBe(404); }); it('DELETE /widgets/:id removes for owner only', async () => { const created = await request(makeApp('u1', repo)) .post('/api/local/dashboard/widgets').send({ slug: 's', title: 't', content: '' }); const other = await request(makeApp('u2', repo)) .delete(`/api/local/dashboard/widgets/${created.body.widget.id}`); expect(other.status).toBe(404); const owner = await request(makeApp('u1', repo)) .delete(`/api/local/dashboard/widgets/${created.body.widget.id}`); expect(owner.status).toBe(204); }); it('PUT /widgets/reorder reorders within user scope', async () => { const app = makeApp('u1', repo); const a = (await request(app).post('/api/local/dashboard/widgets').send({ slug: 'a', title: 'A', content: '' })).body.widget.id; const b = (await request(app).post('/api/local/dashboard/widgets').send({ slug: 'b', title: 'B', content: '' })).body.widget.id; const res = await request(app).put('/api/local/dashboard/widgets/reorder').send({ ids: [b, a] }); expect(res.status).toBe(200); const list = await request(app).get('/api/local/dashboard/widgets'); expect(list.body.widgets.map((w: any) => w.slug)).toEqual(['b', 'a']); }); 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/widgets'); expect(res.status).toBe(401); }); it('POST /widgets accepts kind=node-status', async () => { const res = await request(makeApp('u1', repo)) .post('/api/local/dashboard/widgets') .send({ slug: 'nodes', title: 'Nodes', kind: 'node-status' }); expect(res.status).toBe(201); expect(res.body.widget.kind).toBe('node-status'); }); it('POST /widgets defaults kind to markdown when omitted', async () => { const res = await request(makeApp('u1', repo)) .post('/api/local/dashboard/widgets') .send({ slug: 'memo', title: 'Memo' }); expect(res.status).toBe(201); expect(res.body.widget.kind).toBe('markdown'); }); it('POST /widgets rejects unknown kind', async () => { const res = await request(makeApp('u1', repo)) .post('/api/local/dashboard/widgets') .send({ slug: 'x', title: 'X', kind: 'mystery' }); expect(res.status).toBe(400); }); it('PATCH /widgets/:id rejects content edits on node-status widgets (400)', async () => { const app = makeApp('u1', repo); const created = await request(app) .post('/api/local/dashboard/widgets') .send({ slug: 'nodes', title: 'Nodes', kind: 'node-status' }); expect(created.status).toBe(201); const id = created.body.widget.id; const res = await request(app) .patch(`/api/local/dashboard/widgets/${id}`) .send({ content: 'manual override' }); expect(res.status).toBe(400); expect(String(res.body.error)).toContain('node-status'); }); it('PATCH /widgets/:id allows title-only edits on node-status widgets (200)', async () => { const app = makeApp('u1', repo); const created = await request(app) .post('/api/local/dashboard/widgets') .send({ slug: 'nodes', title: 'Nodes', kind: 'node-status' }); const id = created.body.widget.id; const res = await request(app) .patch(`/api/local/dashboard/widgets/${id}`) .send({ title: 'GPU Pool' }); expect(res.status).toBe(200); expect(res.body.widget.title).toBe('GPU Pool'); expect(res.body.widget.kind).toBe('node-status'); }); it('PATCH /widgets/:id still allows content edits on markdown widgets (regression)', async () => { const app = makeApp('u1', repo); const created = await request(app) .post('/api/local/dashboard/widgets') .send({ slug: 'memo', title: 'Memo', content: 'old' }); const id = created.body.widget.id; const res = await request(app) .patch(`/api/local/dashboard/widgets/${id}`) .send({ content: 'new content' }); expect(res.status).toBe(200); expect(res.body.widget.markdownContent).toBe('new content'); }); 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); }); });