codeburn/tests/fs-utils.test.ts
iamtoruk 8e39a89fe0 fix: pricing accuracy, stream leak, CSV injection hardening
- Remove bidirectional fuzzy match in getModelCosts that could return
  wrong pricing when a short canonical name prefix-matched a longer key
- Use explicit undefined check in parseLiteLLMEntry so free models with
  zero cost are not silently dropped from the LiteLLM pricing database
- Destroy read stream in finally block of readSessionLines to prevent
  file descriptor leaks when the generator is abandoned early
- Extend CSV injection escaping to cover tab and carriage-return prefixes
- Add optional chaining fallback for empty periods in exportCsv/exportJson
- Add regression tests for all fixes (models, export, fs-utils)
2026-04-21 04:20:46 -07:00

98 lines
3.1 KiB
TypeScript

import { describe, it, expect, afterEach, vi } from 'vitest'
import { mkdtemp, writeFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
MAX_SESSION_FILE_BYTES,
STREAM_THRESHOLD_BYTES,
readSessionFile,
readSessionLines,
} from '../src/fs-utils.js'
describe('readSessionFile', () => {
const tmpDirs: string[] = []
afterEach(async () => {
delete process.env.CODEBURN_VERBOSE
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function tmpPath(content: string | Buffer): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-fs-'))
tmpDirs.push(base)
const p = join(base, 'x.jsonl')
await writeFile(p, content)
return p
}
it('returns content for small files via readFile fast path', async () => {
const p = await tmpPath('hello\nworld\n')
expect(await readSessionFile(p)).toBe('hello\nworld\n')
})
it('returns content for files at the stream threshold via stream path', async () => {
const p = await tmpPath(Buffer.alloc(STREAM_THRESHOLD_BYTES, 'a'))
const got = await readSessionFile(p)
expect(got).not.toBeNull()
expect(got!.length).toBe(STREAM_THRESHOLD_BYTES)
})
it('returns null and skips files over the cap', async () => {
const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'b'))
expect(await readSessionFile(p)).toBeNull()
})
it('emits stderr warning under CODEBURN_VERBOSE=1 for skipped file', async () => {
process.env.CODEBURN_VERBOSE = '1'
const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'c'))
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
await readSessionFile(p)
expect(spy).toHaveBeenCalled()
const msg = (spy.mock.calls[0][0] as string)
expect(msg).toContain('codeburn')
expect(msg).toContain('oversize')
spy.mockRestore()
})
it('returns null on stat failure without throwing', async () => {
expect(await readSessionFile('/nonexistent/path/x.jsonl')).toBeNull()
})
})
describe('readSessionLines', () => {
const tmpDirs: string[] = []
afterEach(async () => {
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function tmpPath(content: string): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-lines-'))
tmpDirs.push(base)
const p = join(base, 'session.jsonl')
await writeFile(p, content)
return p
}
it('yields all lines from a file', async () => {
const p = await tmpPath('line1\nline2\nline3\n')
const lines: string[] = []
for await (const line of readSessionLines(p)) lines.push(line)
expect(lines).toEqual(['line1', 'line2', 'line3'])
})
it('does not leak file descriptors when generator is abandoned early', async () => {
const content = Array.from({ length: 1000 }, (_, i) => `line-${i}`).join('\n')
const p = await tmpPath(content)
const gen = readSessionLines(p)
await gen.next()
await gen.return(undefined)
})
})