Files
web-terminal/test/history.test.ts
Yaojia Wang d22dcd24f7 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
2026-06-20 18:27:45 +02:00

124 lines
5.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
// ── mock node:fs/promises + node:os ──────────────────────────────────────────
// Hoisted by vitest; history.ts binds to these mocks.
const mockReaddir = vi.fn()
const mockStat = vi.fn()
const mockOpen = vi.fn()
const mockHomedir = vi.fn(() => '/home/tester')
vi.mock('node:fs/promises', () => ({
default: {
readdir: (...a: unknown[]) => mockReaddir(...a),
stat: (...a: unknown[]) => mockStat(...a),
open: (...a: unknown[]) => mockOpen(...a),
},
}))
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>()
return { ...actual, default: { ...actual.default, homedir: () => mockHomedir() } }
})
const { parseSessionMeta, listSessions } = await import('../src/http/history.js')
// ── parseSessionMeta (pure) ───────────────────────────────────────────────────
describe('parseSessionMeta', () => {
it('extracts cwd and the first user prompt (string content)', () => {
const jsonl = [
JSON.stringify({ type: 'last-prompt', sessionId: 'x' }),
JSON.stringify({ type: 'attachment', cwd: '/Users/me/proj', sessionId: 'x' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'Add monitoring please' } }),
].join('\n')
expect(parseSessionMeta(jsonl)).toEqual({ cwd: '/Users/me/proj', preview: 'Add monitoring please' })
})
it('reads the first text block when content is an array', () => {
const jsonl = [
JSON.stringify({ type: 'user', cwd: '/p', message: { role: 'user', content: [{ type: 'text', text: 'hello there' }] } }),
].join('\n')
expect(parseSessionMeta(jsonl)).toEqual({ cwd: '/p', preview: 'hello there' })
})
it('collapses whitespace and truncates long previews to 120 chars', () => {
const long = 'a'.repeat(200)
const jsonl = JSON.stringify({ type: 'user', cwd: '/p', message: { role: 'user', content: `line1\n line2 ${long}` } })
const { preview } = parseSessionMeta(jsonl)
expect(preview.length).toBe(120)
expect(preview.startsWith('line1 line2 ')).toBe(true)
})
it('returns nulls/empty when there is no cwd or user message', () => {
expect(parseSessionMeta('')).toEqual({ cwd: null, preview: '' })
expect(parseSessionMeta('not json\n{"type":"summary"}')).toEqual({ cwd: null, preview: '' })
})
})
// ── listSessions (fs traversal, async) ────────────────────────────────────────
describe('listSessions', () => {
function mockFile(text: string) {
const buf = Buffer.from(text, 'utf8')
return {
read: vi.fn(async (b: Buffer, off: number, len: number) => {
const n = buf.copy(b, off, 0, Math.min(len, buf.length))
return { bytesRead: n }
}),
close: vi.fn(async () => undefined),
}
}
beforeEach(() => {
mockReaddir.mockReset()
mockStat.mockReset()
mockOpen.mockReset()
mockHomedir.mockReturnValue('/home/tester')
})
it('returns [] when the projects root is missing', async () => {
mockReaddir.mockRejectedValueOnce(new Error('ENOENT'))
expect(await listSessions()).toEqual([])
})
it('lists .jsonl sessions newest-first with parsed cwd/preview/project', async () => {
mockReaddir
.mockResolvedValueOnce(['projA']) // root
.mockResolvedValueOnce(['old.jsonl', 'new.jsonl', 'ignore.txt']) // projA
mockStat.mockImplementation(async (p: string) => ({
mtimeMs: p.endsWith('new.jsonl') ? 2000 : 1000,
}))
mockOpen.mockImplementation(async (p: string) =>
p.endsWith('new.jsonl')
? mockFile(JSON.stringify({ type: 'user', cwd: '/work/newdir', message: { role: 'user', content: 'newest task' } }))
: mockFile(JSON.stringify({ type: 'user', cwd: '/work/olddir', message: { role: 'user', content: 'older task' } })),
)
const out = await listSessions()
expect(out.map((s) => s.id)).toEqual(['new', 'old']) // newest first
expect(out[0]).toMatchObject({ id: 'new', cwd: '/work/newdir', project: 'newdir', preview: 'newest task' })
// .txt is skipped
expect(out).toHaveLength(2)
})
it('skips a project subdir that cannot be read', async () => {
mockReaddir
.mockResolvedValueOnce(['good', 'bad'])
.mockResolvedValueOnce(['s.jsonl']) // good
.mockRejectedValueOnce(new Error('EACCES')) // bad
mockStat.mockResolvedValue({ mtimeMs: 1000 })
mockOpen.mockResolvedValue(mockFile(JSON.stringify({ type: 'user', cwd: '/g', message: { role: 'user', content: 'hi' } })))
const out = await listSessions()
expect(out).toHaveLength(1)
expect(out[0]!.id).toBe('s')
})
it('respects the limit', async () => {
mockReaddir.mockResolvedValueOnce(['p']).mockResolvedValueOnce(['a.jsonl', 'b.jsonl', 'c.jsonl'])
mockStat.mockImplementation(async (p: string) => ({ mtimeMs: p.charCodeAt(p.length - 7) }))
mockOpen.mockResolvedValue(mockFile(JSON.stringify({ type: 'user', cwd: '/p', message: { role: 'user', content: 'x' } })))
const out = await listSessions(2)
expect(out).toHaveLength(2)
})
})