fix: address review report across security, architecture, quality, tests

Implements the fixes from docs/REVIEW_REPORT.md (4-agent parallel review).
typecheck clean; 341 tests pass (16 files, +113); build:web ok; coverage
thresholds (80%) enforced in vitest.config.ts.

Critical:
- multi-device approval race: release held approval only when the last
  client detaches (closing one mirror no longer cancels another's prompt)
- unbounded session creation (DoS): Config.maxSessions cap (env MAX_SESSIONS),
  enforced in manager via the existing M4 exit(-1) path
- signal-handler leak: named SIGINT/SIGTERM/uncaughtException refs removed in close()
- terminal-session initialInput timer tracked + cleared on dispose
- tabs.addEntry null-as-cast type hole removed (build session before entry)

Should-fix:
- security-headers middleware + Origin/CSRF guard on DELETE /live-sessions[/:id]
- history.ts converted to fs/promises (async /sessions handler)
- removed dead clientDims map + blur protocol message end-to-end
- per-connection WS message rate limit (Config.maxMsgsPerSec)
- /sessions behavior kept; documented as accepted LAN risk (TECH_DOC §7)

Tests:
- new tmux / preview-grid / terminal-session (jsdom) / tabs (jsdom) suites
- extended history/config/manager/integration coverage incl. regressions

Hygiene:
- parsePositiveInt -> parseNonNegativeInt; ALLOWED_ORIGINS scheme validation
- log-injection sanitize; isLoopback handles 127.0.0.0/8 + IPv4-mapped
- operational constants moved into Config
- extracted public/preview-grid.ts (DRY launcher/manage)
- doc sweeps: ARCHITECTURE §8 runtime-handle exception, stale comments
This commit is contained in:
Yaojia Wang
2026-06-20 18:27:45 +02:00
parent 97d57326fd
commit d22dcd24f7
30 changed files with 2900 additions and 400 deletions

171
test/preview-grid.test.ts Normal file
View File

@@ -0,0 +1,171 @@
// @vitest-environment jsdom
/**
* test/preview-grid.test.ts — shared launcher/manage preview helpers.
*
* xterm Terminal is stubbed; fetch is mocked. Covers the DRY core extracted in
* Phase 4: formatting, the card factory, fit scaling, and the preview/list
* fetch helpers (best-effort, no throw on failure).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { LiveSessionInfo } from '../src/types.js'
class FakeTerminal {
cols = 80
rows = 24
open = vi.fn()
reset = vi.fn()
resize = vi.fn()
dispose = vi.fn()
write = vi.fn((_d: string, cb?: () => void) => cb?.())
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
const mod = await import('../public/preview-grid.js')
const {
el,
relTime,
statusText,
sessionName,
makePreviewCard,
updatePreviewCard,
fitThumb,
fetchPreview,
renderPreview,
loadPreviewInto,
fetchLiveSessions,
} = mod
function session(over: Partial<LiveSessionInfo> = {}): LiveSessionInfo {
return {
id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
createdAt: Date.now() - 5000,
clientCount: 0,
status: 'idle',
exited: false,
cwd: '/work/proj',
cols: 80,
rows: 24,
...over,
}
}
beforeEach(() => {
vi.restoreAllMocks()
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 0
})
})
describe('formatting helpers', () => {
it('el builds an element with class + text', () => {
const n = el('div', 'x', 'hi')
expect(n.className).toBe('x')
expect(n.textContent).toBe('hi')
})
it('relTime renders s/m/h/d buckets', () => {
const now = Date.now()
expect(relTime(now)).toMatch(/^\d+s$/)
expect(relTime(now - 120_000)).toBe('2m')
expect(relTime(now - 2 * 3600_000)).toBe('2h')
expect(relTime(now - 3 * 86400_000)).toBe('3d')
})
it('statusText maps each status', () => {
expect(statusText('working')).toContain('working')
expect(statusText('waiting')).toContain('waiting')
expect(statusText('idle')).toContain('idle')
expect(statusText('unknown')).toBe('·')
})
it('sessionName uses last cwd segment, else short id', () => {
expect(sessionName(session({ cwd: '/a/b/cool' }))).toBe('cool')
expect(sessionName(session({ cwd: null }))).toBe('aaaaaaaa')
})
})
describe('makePreviewCard / updatePreviewCard', () => {
it('builds a card and fires onOpen when the thumb is clicked (button variant)', () => {
const onOpen = vi.fn()
const card = makePreviewCard(session(), { onOpen })
card.thumb.click()
expect(onOpen).toHaveBeenCalledWith(session().id)
// The Open control is a <button> when no openHref is given.
expect(card.el.querySelector('button.mg-open')).not.toBeNull()
})
it('uses an <a href> Open + extra actions when configured (manage variant)', () => {
const extra = el('button', 'mg-kill', 'Kill')
const card = makePreviewCard(session(), {
onOpen: vi.fn(),
openHref: (id) => `/?join=${id}`,
extraActions: () => [extra],
})
const open = card.el.querySelector('a.mg-open') as HTMLAnchorElement | null
expect(open?.getAttribute('href')).toBe(`/?join=${session().id}`)
expect(card.el.contains(extra)).toBe(true)
})
it('updatePreviewCard refreshes status + watch in place', () => {
const card = makePreviewCard(session(), { onOpen: vi.fn() })
updatePreviewCard(card, session({ status: 'working', clientCount: 3 }))
expect(card.status.textContent).toContain('working')
expect(card.watch.textContent).toBe('👁 3')
expect(card.watch.className).toContain('live')
})
})
describe('fitThumb', () => {
it('no-ops when the inner element has no measured size', () => {
const card = makePreviewCard(session(), { onOpen: vi.fn() })
// jsdom offsetWidth/Height are 0 → no transform applied.
fitThumb(card, 320, 200)
expect(card.inner.style.transform).toBe('')
})
it('scales and clamps height when inner has a size', () => {
const card = makePreviewCard(session(), { onOpen: vi.fn() })
Object.defineProperty(card.inner, 'offsetWidth', { value: 640, configurable: true })
Object.defineProperty(card.inner, 'offsetHeight', { value: 400, configurable: true })
fitThumb(card, 320, 200)
expect(card.inner.style.transform).toBe('scale(0.5)')
expect(card.thumb.style.height).toBe('200px')
})
})
describe('fetch helpers', () => {
it('fetchPreview returns parsed JSON on 200', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ id: 'x', cols: 80, rows: 24, data: 'hi' }) })))
expect(await fetchPreview('x')).toMatchObject({ id: 'x', data: 'hi' })
})
it('fetchPreview returns null on non-ok / throw', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
expect(await fetchPreview('x')).toBeNull()
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
expect(await fetchPreview('x')).toBeNull()
})
it('renderPreview writes the cleared tail and resizes when dims differ', () => {
const card = makePreviewCard(session(), { onOpen: vi.fn() })
renderPreview(card, { id: 'x', cols: 100, rows: 30, data: 'screen' }, 320, 200)
expect(card.term.resize).toHaveBeenCalledWith(100, 30)
expect(card.term.write).toHaveBeenCalled()
})
it('loadPreviewInto fetches then renders (no throw on failure)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ id: 'x', cols: 80, rows: 24, data: 'd' }) })))
const card = makePreviewCard(session(), { onOpen: vi.fn() })
await expect(loadPreviewInto('x', card, 320, 200)).resolves.toBeUndefined()
expect(card.term.write).toHaveBeenCalled()
})
it('fetchLiveSessions returns [] on failure and array on success', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ json: async () => [session()] })))
expect(await fetchLiveSessions()).toHaveLength(1)
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('x') }))
expect(await fetchLiveSessions()).toEqual([])
})
})