voyage/tests/validators/query-privacy-gate.test.mjs

143 lines
6.3 KiB
JavaScript

// 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);
});