// #1873 (redact6): a control-board refusal names the config, the seen marks file or the seat // by label, never a host path. The sentinel is a component of every scratch path, so a refusal // that quotes a path quotes the sentinel too; each test also checks the scratch root and the label. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { ConfigError, loadConfig, loadRegistrations, loadSeen } from '../src/scan.mjs'; import { readRegistration, registrationPath, SeatError } from '../../seat/src/seat.mjs'; import { startServer } from '../src/serve.mjs'; const secret = 'sk-test-NOT-A-SECRET-1234'; function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r6-')); t.after(() => rmSync(root, { recursive: true, force: true })); const dir = join(root, secret); mkdirSync(dir); return { root, dir }; } function clean(text, root) { assert.ok(!text.includes(root), text); assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/); } function labelled(label, root) { return error => { assert.ok(error instanceof ConfigError, error.stack); assert.equal(error.message, label); clean(error.message, root); return true; }; } function write(file, text) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, text); return file; } test('config refusals name the config, not its path', t => { const { root, dir } = scratch(t); assert.throws(() => loadConfig(join(dir, 'config.json')), labelled('config not found', root)); assert.throws(() => loadConfig(write(join(dir, 'array.json'), '[]')), labelled('config is not an object', root)); assert.throws(() => loadConfig(write(join(dir, 'relative.json'), '{"dataRoot":"data"}')), labelled('config.dataRoot must be an absolute path', root)); }); test('seen marks refusals name seen.json, not its path', t => { const { root, dir } = scratch(t); write(join(dir, 'seen.json'), '[]'); assert.throws(() => loadSeen(dir), labelled('seen marks file seen.json must be a JSON object', root)); write(join(dir, 'seen.json'), '{"a":1}'); assert.throws(() => loadSeen(dir), labelled('seen marks file seen.json has a non-string value for "a"', root)); }); test('a bad registration is reported by layout and seat, never by its path', t => { const { root, dir } = scratch(t); write(registrationPath(dir, 'builder', 'repo'), '{}'); const { registrations, errors } = loadRegistrations(dir); assert.deepEqual(registrations, []); assert.equal(errors.length, 1); assert.match(errors[0], /^seat repo\/builder: .*: registration repo\/builder$/); clean(errors[0], root); }); // Review round (#1873): a key or name that could be a host path is described, never echoed. test('a seen marks key or a registration field that is not a plain name is described, never echoed', t => { const { root, dir } = scratch(t); writeFileSync(join(dir, 'seen.json'), JSON.stringify({ [join(dir, 'host')]: 1 })); assert.throws(() => loadSeen(dir), labelled('seen marks file seen.json has a non-string value for that is not a plain name', root)); const f = registrationPath(dir, 'builder', 'repo'); mkdirSync(dirname(f), { recursive: true }); writeFileSync(f, JSON.stringify({ [join(dir, 'host')]: 1 })); assert.throws(() => readRegistration(dir, 'builder', 'repo'), e => { assert.ok(e instanceof SeatError, e.stack); assert.equal(e.exitCode, 2); assert.equal(e.message, 'registration has an unknown field: that is not a plain name: registration repo/builder'); clean(e.message, root); return true; }); }); test('an invalid seat name or layout states the constraint, never the value, and keeps exit 4', t => { const { root, dir } = scratch(t); assert.throws(() => registrationPath(dir, join(dir, 'x'), 'repo'), e => e instanceof SeatError && e.exitCode === 4 && e.message === 'invalid seat name (lowercase letters, digits, ., _ or -, at most 64)' && (clean(e.message, root), true)); assert.throws(() => registrationPath(dir, 'builder', join(dir, 'x')), e => e instanceof SeatError && e.exitCode === 4 && e.message === 'invalid layout (one of repo, fleet, unknown)' && (clean(e.message, root), true)); }); // The board API returns a scan refusal in its JSON body, so the HTTP body carries the label too. test('a seen marks refusal reaches the board API body by label, never by path', async t => { const { root, dir } = scratch(t); write(join(dir, 'seen.json'), '[]'); const server = await startServer({ host: '127.0.0.1', port: 0, specs: [], boardDir: dir, isAlive: () => true, page: '' }); t.after(() => new Promise(done => server.close(done))); const res = await fetch(`http://127.0.0.1:${server.address().port}/api/board`); const body = await res.text(); assert.equal(res.status, 500); assert.deepEqual(JSON.parse(body), { error: 'seen marks file seen.json must be a JSON object' }); clean(body, root); });