// skill-registry-atomic.test.mjs — #51 (v7.8.3): saveRegistry must write // atomically (temp file + rename). A torn write would parse-fail in // loadRegistry and silently reset to an empty registry (permanent data loss). // Zero external dependencies: node:test + node:assert only. import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, chmodSync, readFileSync, readdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { loadRegistry, saveRegistry, registryPath } from '../../scanners/lib/skill-registry.mjs'; describe('skill-registry — atomic saveRegistry (v7.8.3 #51)', () => { it('replaces the registry file via temp+rename (write survives a read-only target)', () => { const root = mkdtempSync(join(tmpdir(), 'llmsec-registry-atomic-')); try { const registry = loadRegistry(root); registry.entries['f'.repeat(64)] = { name: 'first', last_scanned: new Date().toISOString() }; const filePath = saveRegistry(registry, root); assert.equal(filePath, registryPath(root)); // A direct writeFileSync to a read-only file throws EACCES. An atomic // temp+rename replaces the file because only directory write permission // is required. chmodSync(filePath, 0o444); registry.entries['a'.repeat(64)] = { name: 'second', last_scanned: new Date().toISOString() }; saveRegistry(registry, root); const raw = JSON.parse(readFileSync(filePath, 'utf8')); assert.equal(raw.entry_count, 2, 'expected atomic rename to replace the read-only file'); assert.ok(raw.entries['a'.repeat(64)], 'second entry persisted'); const leftovers = readdirSync(join(root, 'reports')).filter((f) => f !== 'skill-registry.json'); assert.deepEqual(leftovers, [], 'no temp files left behind'); } finally { try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ } } }); });