// tests/validators/query-privacy-gate.test.mjs // Cover lib/validators/query-privacy-gate.mjs: two-sided code table // (absolute path / repo-internal identifier / secret-shaped token), a // benign query passing untouched, the opt-in env var reaching only the // warn tier (never the hard-block tier), strict/soft severity, and the // CLI shim. // // Secret-shaped fixtures are built via string concatenation/repeat, never // as literal tokens — the repo's own secrets pre-edit hook (correctly) // treats a literal AKIA/sk-/ghp_ string as a real credential. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { execFileSync } from 'node:child_process'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { validateOutboundQuery, ABSOLUTE_PATH_PATTERNS, REPO_IDENTIFIER_PATTERNS, SECRET_SHAPED_PATTERNS, } from '../../lib/validators/query-privacy-gate.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const SHIM = join(HERE, '..', '..', 'lib', 'validators', 'query-privacy-gate.mjs'); const FAKE_OPENAI_KEY = 'sk-' + 'a'.repeat(24); const FAKE_AWS_KEY = 'AKIA' + 'Q'.repeat(16); const FAKE_GITHUB_PAT = 'ghp_' + 'b'.repeat(36); function runShim(args) { try { const out = execFileSync(process.execPath, [SHIM, ...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }); return { code: 0, out }; } catch (e) { return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' }; } } // ---- two-sided code table ---------------------------------------------------- const TABLE = [ { label: 'absolute path (/Users/...)', text: 'find every caller of foo in /Users/ktg/repos/voyage/lib/util/foo.mjs', code: 'PRIVACY_ABSOLUTE_PATH' }, { label: 'absolute path (/home/...)', text: 'trace /home/alice/projects/app/src/index.js for imports', code: 'PRIVACY_ABSOLUTE_PATH' }, { label: 'repo-internal identifier (forgejo host)', text: 'what changed recently on git.fromaitochitta.com/open/voyage', code: 'PRIVACY_REPO_IDENTIFIER' }, { label: 'repo-internal identifier (repo name)', text: 'search issues for ktg-plugin-marketplace regressions', code: 'PRIVACY_REPO_IDENTIFIER' }, { label: 'secret-shaped (OpenAI/Anthropic-style key)', text: `auth failing with key ${FAKE_OPENAI_KEY}`, code: 'PRIVACY_SECRET_SHAPED' }, { label: 'secret-shaped (AWS access key)', text: `rotate ${FAKE_AWS_KEY} now`, code: 'PRIVACY_SECRET_SHAPED' }, { label: 'secret-shaped (GitHub PAT)', text: `token leaked: ${FAKE_GITHUB_PAT}`, code: 'PRIVACY_SECRET_SHAPED' }, ]; for (const { label, text, code } of TABLE) { test(`validateOutboundQuery — ${label} → ${code} (strict, error)`, () => { const r = validateOutboundQuery(text, { strict: true, env: {} }); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === code), JSON.stringify(r.errors)); }); } test('validateOutboundQuery — benign generic query passes untouched', () => { const r = validateOutboundQuery('What are the tradeoffs between optimistic and pessimistic locking?', { env: {} }); assert.equal(r.valid, true); assert.deepEqual(r.errors, []); assert.deepEqual(r.warnings, []); }); // ---- strict vs soft (warn tier only) ----------------------------------------- test('validateOutboundQuery — soft mode downgrades warn-tier findings to warnings, stays valid', () => { const r = validateOutboundQuery('inspect /Users/ktg/repos/voyage', { strict: false, env: {} }); assert.equal(r.valid, true); assert.equal(r.errors.length, 0); assert.ok(r.warnings.find(w => w.code === 'PRIVACY_ABSOLUTE_PATH')); }); test('validateOutboundQuery — soft mode does NOT downgrade the hard-block tier', () => { const r = validateOutboundQuery(`leaked ${FAKE_OPENAI_KEY}`, { strict: false, env: {} }); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED')); }); // ---- opt-in env var reaches only the warn tier ------------------------------- test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 bypasses the warn tier entirely', () => { const r = validateOutboundQuery('inspect /Users/ktg/repos/voyage', { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } }); assert.equal(r.valid, true); assert.equal(r.errors.length, 0); assert.equal(r.warnings.length, 0); }); test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 does NOT open the hard-block tier', () => { const r = validateOutboundQuery(`leaked ${FAKE_OPENAI_KEY}`, { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } }); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'), 'opt-in must never unlock the hard-block tier'); }); test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 combined with a secret still denies', () => { const r = validateOutboundQuery(`/Users/ktg/x leaked ${FAKE_OPENAI_KEY}`, { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } }); assert.equal(r.valid, false); assert.equal(r.errors.length, 1); assert.equal(r.errors[0].code, 'PRIVACY_SECRET_SHAPED'); }); // ---- empty input -------------------------------------------------------------- test('validateOutboundQuery — empty string is invalid', () => { const r = validateOutboundQuery('', { env: {} }); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'PRIVACY_EMPTY_QUERY')); }); // ---- pattern set is frozen ---------------------------------------------------- test('pattern sets are Object.frozen', () => { assert.equal(Object.isFrozen(ABSOLUTE_PATH_PATTERNS), true); assert.equal(Object.isFrozen(REPO_IDENTIFIER_PATTERNS), true); assert.equal(Object.isFrozen(SECRET_SHAPED_PATTERNS), true); }); // ---- CLI shim ----------------------------------------------------------------- test('CLI shim — benign query exits 0 with valid:true', () => { const r = runShim(['harmless generic question about caching strategies']); assert.equal(r.code, 0); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.valid, true); }); test('CLI shim — secret-shaped query exits 1 even with --soft', () => { const r = runShim(['--soft', `leaked ${FAKE_OPENAI_KEY}`]); assert.equal(r.code, 1); const parsed = JSON.parse(r.out.trim()); assert.equal(parsed.valid, false); assert.ok(parsed.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED')); }); test('CLI shim — missing query argument exits 2 (usage error)', () => { const r = runShim([]); assert.equal(r.code, 2); });