codeburn/tests/web-dashboard.test.ts
ozymandiashh 525b3c1d71 fix(dash): keep the disambiguator visible, make the bootstrap safe by construction
Three follow-ups from an adversarial pass over this branch.

The title cap was sized against the wrong number. 80 code points was chosen
"for both the max-w-40 legend and the tooltip", but max-w-40 is 160px and the
legend renders at text-[10px], which shows roughly 32 characters. Everything
past that is clipped -- and that is exactly where the short session id, the
provider and every collision-tier suffix lived. Two sessions in one repository
whose AI titles share a 32-character prefix rendered as the same legend entry,
which is worse than main and is the scenario #997 is about. The label now leads
with the disambiguator so it is always inside the visible width, and both the
legend and the tooltip carry title= so the full label is reachable on hover.

injectDashboardBootstrap was not safe by construction. Extracting the helper
fixed the $-substitution problem but left the security-critical '<' escaping at
the call site 94 lines away, and the new test called the helper with raw
JSON.stringify output -- so deleting that escape left every test green while
the served page became injectable through any project, device or model name.
Nothing in tests/ asserted that escaping at all. The escaping moves inside the
helper, with a test that pushes </script> through a payload value.

preferredSessionTitle picked alphabetically, not most recently. types.ts
documents title as the last ai-title entry, so when one session id yields two
summaries the legend could show the superseded one. It now picks the greatest
lastTimestamp, keeping the alphabetical order only to break exact ties so the
result stays deterministic. Entries are also ordered by key before the
collision tiers run, so the same corpus cannot emit a different label set
depending on input order.
2026-08-18 05:49:16 +03:00

97 lines
4 KiB
TypeScript

import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import type { AddressInfo } from 'net'
import type { Server } from 'http'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { injectDashboardBootstrap, runWebDashboard } from '../src/web-dashboard.js'
describe('web dashboard bootstrap injection', () => {
it('keeps replacement syntax in a payload value literal', () => {
const payloadValue = "$`|$'|$&|$1"
const payload = { devices: [{ name: payloadValue }] }
const html = '<!doctype html><script type="module" src="/app.js"></script>'
const injected = injectDashboardBootstrap(html, payload)
expect(injected).toContain(`window.__CODEBURN_BOOTSTRAP__=${JSON.stringify(payload)}</script>`)
expect(injected).toContain(`"name":"${payloadValue}"`)
})
it('escapes script-closing payload values and preserves the served bootstrap payload', () => {
const hostileName = '</script><script>globalThis.bootstrapPwned = true</script>'
const payload = {
devices: [{
id: 'local',
name: hostileName,
payload: { current: { topProjects: [{ name: hostileName }] } },
}],
}
const html = '<!doctype html><script type="module" src="/app.js"></script>'
const servedHtml = injectDashboardBootstrap(html, payload)
const marker = 'window.__CODEBURN_BOOTSTRAP__='
const start = servedHtml.indexOf(marker) + marker.length
const end = servedHtml.indexOf('</script>', start)
const serialized = servedHtml.slice(start, end)
expect(serialized).not.toContain('</script')
expect(serialized).toContain('\\u003c/script>')
expect(JSON.parse(serialized)).toEqual(payload)
})
})
// Regression guard for the original bug: a bad `period` query used to hit
// process.exit(1) and kill the long-running dashboard server. The handlers must
// now answer 400 and keep serving.
describe('web dashboard server: invalid query returns 400 without exiting', () => {
let server: Server
let base: string
let homeDir: string
let cacheDir: string
const prevHome = process.env['HOME']
const prevCache = process.env['CODEBURN_CACHE_DIR']
beforeAll(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'codeburn-web-home-'))
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-web-cache-'))
process.env['HOME'] = homeDir
process.env['CODEBURN_CACHE_DIR'] = cacheDir
server = await runWebDashboard({
period: 'today', provider: 'all', project: [], exclude: [], port: 0, open: false,
})
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
})
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()))
if (prevHome === undefined) delete process.env['HOME']
else process.env['HOME'] = prevHome
if (prevCache === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = prevCache
await rm(homeDir, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
})
it('answers 400 for an invalid /api/usage period and keeps serving', async () => {
const bad = await fetch(`${base}/api/usage?period=garbage`)
expect(bad.status).toBe(400)
expect((await bad.json() as { error: string }).error).toMatch(/Unknown period "garbage"/)
// The bug was process.exit; if it regressed, this test process would die.
// A successful follow-up request proves the server survived the bad one.
const ok = await fetch(`${base}/api/usage?period=today`)
expect(ok.status).toBe(200)
const payload = await ok.json() as { history: { timeline?: { bucketMinutes: number; points: unknown[] } } }
expect(payload.history.timeline?.bucketMinutes).toBe(15)
expect(Array.isArray(payload.history.timeline?.points)).toBe(true)
})
it('answers 400 for an invalid /api/devices period', async () => {
const bad = await fetch(`${base}/api/devices?period=garbage`)
expect(bad.status).toBe(400)
expect((await bad.json() as { error: string }).error).toMatch(/Unknown period "garbage"/)
})
})