Adds the shared adversarial-input harness described in TEST-EXAMPLES.md
§"CLI Negative Matrix" and applies it across two layers:
1. tests/helpers/cli-negative.cjs — runCli() wraps spawnSync of
get-shit-done/bin/gsd-tools.cjs, prepends --json-errors by default,
and returns a typed IR { status, ok, reason, message,
hasStackTrace, ... } so adversarial-case tests assert on
reason codes — never on stderr prose.
2. tests/feat-3593-cli-negative-config.test.cjs — full 12-category
matrix for the config command family (the highest-risk read/write
surface): missing/empty/whitespace args, duplicate --cwd,
unknown subcommand, value-looks-like-a-flag, corrupt config.json,
50KB key, Unicode/emoji keys and values, and 9 distinct shell-
metacharacter payloads asserted as NOT-executed via per-test
sentinel-file probes.
3. tests/feat-3593-cli-negative-universal.test.cjs — narrower
cross-family sweep (phase, roadmap, state, config, workstream,
init, validate). Pins the three universal invariants every
family must satisfy: bare invocation does not crash with a V8
stack trace, unknown subcommand emits a typed reason, and shell
payloads as argv values are not executed.
4. tests/feat-3593-cli-negative-harness.test.cjs — meta-test that
pins the harness IR contract so a future regression in the
parser (stack-trace detection, JSON shape extraction, hostile
stderr handling) surfaces before it cascades through every
matrix file.
Bug fix surfaced by the new tests:
get-shit-done/bin/lib/config.cjs cmdConfigSet — invoking
`config-set <key>` with no value silently returned exit 0 and
emitted { updated: true } even though the value parameter was
undefined. JSON.stringify dropped the key during the write or
persisted a corrupt entry. Now rejected with typed ERROR_REASON.USAGE
before any write. Matching guard added to SDK configSet for parity.
Harness coverage delivered: 58 new tests (9 meta + 26 config + 23
universal sweep). Pre-existing config suites (101 tests) all pass.
lint-no-source-grep clean.
Refs #3593
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
5.3 KiB
JavaScript
139 lines
5.3 KiB
JavaScript
/**
|
|
* Meta-test for the CLI negative-matrix harness (#3593).
|
|
*
|
|
* The harness in `tests/helpers/cli-negative.cjs` shapes spawnSync
|
|
* results into a typed IR that adversarial-input tests consume. This
|
|
* file pins the IR contract by exercising the harness against
|
|
* deliberate scenarios — not as a placeholder for the real matrix tests
|
|
* (those live in sibling feat-3593-* files) but to surface harness
|
|
* regressions before they cascade through every matrix test.
|
|
*
|
|
* Tests deliberately avoid prose-matching: they assert on numeric exit
|
|
* codes, boolean flags, and reason codes pulled from the parsed JSON
|
|
* payload.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { runCli, parseSpawnResult } = require('./helpers/cli-negative.cjs');
|
|
const { createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
test('runCli rejects non-array argv with TypeError', () => {
|
|
assert.throws(
|
|
() => runCli('config-get', { cwd: '/tmp' }),
|
|
(err) => err instanceof TypeError && /argv/.test(err.message),
|
|
);
|
|
});
|
|
|
|
test('runCli rejects missing cwd with TypeError', () => {
|
|
assert.throws(
|
|
() => runCli(['config-get'], {}),
|
|
(err) => err instanceof TypeError && /cwd/.test(err.message),
|
|
);
|
|
});
|
|
|
|
test('runCli surfaces typed reason from a known failure path', (t) => {
|
|
const projectDir = createTempProject('cli-neg-harness-');
|
|
t.after(() => cleanup(projectDir));
|
|
// Unknown command — gsd-tools emits ERROR_REASON.SDK_UNKNOWN_COMMAND or
|
|
// USAGE depending on dispatch depth. Either is a real reason string;
|
|
// the contract we pin here is just "the IR carries a reason from the
|
|
// ERROR_REASON enum, never null".
|
|
const result = runCli(['this-command-does-not-exist'], { cwd: projectDir });
|
|
assert.notEqual(result.status, 0, 'unknown command must exit non-zero');
|
|
assert.equal(result.ok, false, 'JSON payload must report ok=false');
|
|
assert.equal(typeof result.reason, 'string', 'reason must be a string from ERROR_REASON');
|
|
assert.notEqual(result.reason, null);
|
|
assert.notEqual(result.reason, '');
|
|
assert.equal(result.hasStackTrace, false, 'a typed failure must NOT print a V8 stack trace');
|
|
});
|
|
|
|
test('parseSpawnResult detects stack-trace leakage in stderr', () => {
|
|
const fakeSpawn = {
|
|
status: 1,
|
|
signal: null,
|
|
stdout: '',
|
|
stderr: 'Error: boom\n at Object.<anonymous> (/some/file.js:10:5)\n at Module._compile\n',
|
|
error: null,
|
|
};
|
|
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: false });
|
|
assert.equal(ir.hasStackTrace, true, 'stack frames in stderr must be flagged');
|
|
assert.equal(ir.reason, null, 'non-JSON stderr leaves reason null');
|
|
});
|
|
|
|
test('parseSpawnResult does NOT match the literal word "at" in prose', () => {
|
|
// Guard against a regex regression that would catch sentences like
|
|
// "command failed at startup" as stack frames.
|
|
const fakeSpawn = {
|
|
status: 1,
|
|
signal: null,
|
|
stdout: '',
|
|
stderr: 'Error: command failed at startup\nbecause no project was found.\n',
|
|
error: null,
|
|
};
|
|
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: false });
|
|
assert.equal(ir.hasStackTrace, false, 'prose containing the word "at" is not a stack frame');
|
|
});
|
|
|
|
test('parseSpawnResult extracts ok/reason/message from a json-errors payload', () => {
|
|
const payload = { ok: false, reason: 'config_invalid_key', message: 'no such key: foo' };
|
|
const fakeSpawn = {
|
|
status: 1,
|
|
signal: null,
|
|
stdout: '',
|
|
stderr: JSON.stringify(payload) + '\n',
|
|
error: null,
|
|
};
|
|
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: true });
|
|
assert.equal(ir.ok, false);
|
|
assert.equal(ir.reason, 'config_invalid_key');
|
|
assert.equal(ir.message, 'no such key: foo');
|
|
assert.equal(ir.hasStackTrace, false);
|
|
});
|
|
|
|
test('parseSpawnResult ignores malformed JSON in stderr without throwing', () => {
|
|
const fakeSpawn = {
|
|
status: 1,
|
|
signal: null,
|
|
stdout: '',
|
|
stderr: '{ ok: false, reason }', // missing quotes — invalid JSON
|
|
error: null,
|
|
};
|
|
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: true });
|
|
assert.equal(ir.ok, null, 'malformed JSON must NOT promote partial data into ok');
|
|
assert.equal(ir.reason, null);
|
|
assert.equal(ir.message, null);
|
|
});
|
|
|
|
test('parseSpawnResult ignores JSON arrays and primitives, only accepts objects', () => {
|
|
const cases = [
|
|
'["ok", false]', // array
|
|
'"just a string"', // primitive
|
|
'null', // null literal
|
|
'42', // number
|
|
];
|
|
for (const stderr of cases) {
|
|
const ir = parseSpawnResult(
|
|
{ status: 1, signal: null, stdout: '', stderr, error: null },
|
|
{ jsonErrorsRequested: true },
|
|
);
|
|
assert.equal(ir.ok, null, `non-object JSON (${stderr}) must not set ok`);
|
|
assert.equal(ir.reason, null);
|
|
}
|
|
});
|
|
|
|
test('runCli treats jsonErrors=false as an explicit human-formatter path', (t) => {
|
|
const projectDir = createTempProject('cli-neg-harness-text-');
|
|
t.after(() => cleanup(projectDir));
|
|
const result = runCli(['this-command-does-not-exist'], { cwd: projectDir, jsonErrors: false });
|
|
assert.notEqual(result.status, 0);
|
|
assert.equal(result.jsonErrorsRequested, false);
|
|
// Reason fields stay null in human-mode because stderr is prose, not JSON.
|
|
assert.equal(result.ok, null);
|
|
assert.equal(result.reason, null);
|
|
// But the prose still must not include a V8 stack trace.
|
|
assert.equal(result.hasStackTrace, false);
|
|
});
|