| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { describe, test } from 'node:test'; |
| import assert from 'node:assert/strict'; |
| import { mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync } from 'node:fs'; |
| import { tmpdir } from 'node:os'; |
| import { join } from 'node:path'; |
|
|
| import { writeJsonAtomic } from '../src/fs-atomic.js'; |
| import { cacheKey } from '../src/cache.js'; |
| import { checkout, checkin, poolClear } from '../src/conversation-pool.js'; |
|
|
| describe('writeJsonAtomic (audit fix #1: durable config writes)', () => { |
| test('writes JSON to the target via tmp + rename', () => { |
| const dir = mkdtempSync(join(tmpdir(), 'wa-atomic-')); |
| try { |
| const target = join(dir, 'config.json'); |
| writeJsonAtomic(target, { hello: 'world', n: 1 }); |
| assert.deepEqual(JSON.parse(readFileSync(target, 'utf8')), { hello: 'world', n: 1 }); |
| |
| |
| assert.ok(!existsSync(`${target}.tmp`), '.tmp file should be removed after rename'); |
| } finally { |
| rmSync(dir, { recursive: true, force: true }); |
| } |
| }); |
|
|
| test('cleans up the .tmp file when stringify throws', () => { |
| const dir = mkdtempSync(join(tmpdir(), 'wa-atomic-')); |
| try { |
| const target = join(dir, 'config.json'); |
| |
| |
| |
| writeJsonAtomic(target, { existing: true }); |
| |
| const circular = {}; circular.self = circular; |
| assert.throws(() => writeJsonAtomic(target, circular)); |
| |
| assert.deepEqual(JSON.parse(readFileSync(target, 'utf8')), { existing: true }); |
| |
| const leftover = readdirSync(dir).filter(f => f.endsWith('.tmp')); |
| assert.deepEqual(leftover, [], `expected no .tmp leftovers, got ${leftover.join(',')}`); |
| } finally { |
| rmSync(dir, { recursive: true, force: true }); |
| } |
| }); |
|
|
| test('overwrites an existing target byte-for-byte', () => { |
| const dir = mkdtempSync(join(tmpdir(), 'wa-atomic-')); |
| try { |
| const target = join(dir, 'config.json'); |
| writeFileSync(target, '{"old": "garbage", "extra": "padding to be longer"}'); |
| writeJsonAtomic(target, { v: 2 }); |
| assert.deepEqual(JSON.parse(readFileSync(target, 'utf8')), { v: 2 }); |
| } finally { |
| rmSync(dir, { recursive: true, force: true }); |
| } |
| }); |
| }); |
|
|
| describe('cacheKey (audit fix #2: cross-tenant cache leak)', () => { |
| test('two callers sending the same body get DIFFERENT cache keys', () => { |
| const body = { |
| model: 'claude-sonnet-4.6', |
| messages: [{ role: 'user', content: 'hi' }], |
| }; |
| const k1 = cacheKey(body, 'api:abc:user:alice'); |
| const k2 = cacheKey(body, 'api:def:user:bob'); |
| assert.notEqual(k1, k2, |
| 'identical body must produce different cache keys for different callers'); |
| }); |
|
|
| test('same caller + same body still produces a stable key (cache works)', () => { |
| const body = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; |
| assert.equal(cacheKey(body, 'api:abc'), cacheKey(body, 'api:abc')); |
| }); |
|
|
| test('empty callerKey is permitted (test path) but distinct from any real scope', () => { |
| const body = { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }; |
| const anon = cacheKey(body, ''); |
| const real = cacheKey(body, 'api:abc'); |
| assert.notEqual(anon, real); |
| }); |
|
|
| test('cannot be tricked by a body field that mimics the scope prefix', () => { |
| |
| |
| |
| const k1 = cacheKey({ model: 'api:victim:user:bob', messages: [] }, 'api:attacker'); |
| const k2 = cacheKey({ model: '', messages: [] }, 'api:victim:user:bob'); |
| assert.notEqual(k1, k2); |
| }); |
| }); |
|
|
| describe('conversation-pool checkout (audit fix #3: validate-before-delete)', () => { |
| test('callerKey mismatch leaves the entry in place for the rightful owner', () => { |
| poolClear(); |
| const fp = 'fp-shared-by-accident'; |
| const entry = { |
| cascadeId: 'c-1', sessionId: 's-1', lsPort: 42100, |
| apiKey: 'k-alice', stepOffset: 0, generatorOffset: 0, |
| historyCoverage: null, createdAt: Date.now(), lastAccess: Date.now(), |
| }; |
| checkin(fp, entry, 'caller-alice'); |
|
|
| |
| const wrong = checkout(fp, 'caller-bob'); |
| assert.equal(wrong, null, 'wrong caller must miss'); |
|
|
| |
| |
| const right = checkout(fp, 'caller-alice'); |
| assert.ok(right, 'rightful owner must still find their entry after a wrong-caller miss'); |
| assert.equal(right.cascadeId, 'c-1'); |
|
|
| poolClear(); |
| }); |
|
|
| test('successful checkout still removes the entry (one-shot semantics intact)', () => { |
| poolClear(); |
| const fp = 'fp-x'; |
| checkin(fp, { |
| cascadeId: 'c-2', sessionId: 's-2', lsPort: 42100, |
| apiKey: 'k', stepOffset: 0, generatorOffset: 0, |
| historyCoverage: null, createdAt: Date.now(), lastAccess: Date.now(), |
| }, 'caller-x'); |
|
|
| const first = checkout(fp, 'caller-x'); |
| assert.ok(first); |
| const second = checkout(fp, 'caller-x'); |
| assert.equal(second, null, 'second checkout for same fp must miss — entry was consumed'); |
|
|
| poolClear(); |
| }); |
|
|
| test('expected.apiKey mismatch also leaves entry in place', () => { |
| poolClear(); |
| const fp = 'fp-y'; |
| checkin(fp, { |
| cascadeId: 'c-3', sessionId: 's-3', lsPort: 42100, |
| apiKey: 'k-A', stepOffset: 0, generatorOffset: 0, |
| historyCoverage: null, createdAt: Date.now(), lastAccess: Date.now(), |
| }, 'caller-y'); |
|
|
| const wrongKey = checkout(fp, 'caller-y', { apiKey: 'k-B' }); |
| assert.equal(wrongKey, null); |
|
|
| const rightKey = checkout(fp, 'caller-y', { apiKey: 'k-A' }); |
| assert.ok(rightKey, 'matching apiKey must succeed'); |
| assert.equal(rightKey.apiKey, 'k-A'); |
|
|
| poolClear(); |
| }); |
| }); |
|
|
| describe('atomic write call sites use writeJsonAtomic', () => { |
| |
| |
| |
| |
| const __dirname = new URL('.', import.meta.url).pathname.replace(/^\//, ''); |
| const ROOT = join(__dirname, '..'); |
| const FILES = [ |
| 'src/runtime-config.js', |
| 'src/dashboard/stats.js', |
| 'src/dashboard/proxy-config.js', |
| 'src/dashboard/model-access.js', |
| ]; |
| for (const rel of FILES) { |
| test(`${rel} uses writeJsonAtomic (no bare writeFileSync to its config target)`, () => { |
| const src = readFileSync(join(ROOT, rel), 'utf8'); |
| assert.match(src, /writeJsonAtomic/, |
| `${rel} should import and use writeJsonAtomic`); |
| |
| |
| |
| |
| assert.ok(!/writeFileSync\((?:STATS_FILE|FILE|PROXY_FILE|ACCESS_FILE)\b/.test(src), |
| `${rel} still has a bare writeFileSync to its config constant`); |
| }); |
| } |
| }); |
|
|