/** * Tests for .claude/helpers/github-safe.js * * github-safe.js is an ES module, so we test it by running it as a subprocess * via child_process. This mirrors real usage and avoids ESM/CJS interop issues. * * Run: node --test tests/helpers/github-safe.test.js * * Coverage gaps addressed: * - Exit 1 when fewer than 2 args provided (usage guard) * - Exit 1 for commands not in ALLOWED_COMMANDS * - Body written to temp file for issue/pr comment/create with --body * - Positional body for "issue comment " form * - No temp file created when no body is present * - Shell metacharacters in body don't cause injection (they go through temp file) * - Temp file is cleaned up even when gh exits non-zero * - Non-body commands forwarded directly (no temp file) * * NOTE: Tests that need `gh` stub it with a simple Node script so the suite * runs without GitHub credentials. */ 'use strict' const { describe, it } = require('node:test') const assert = require('node:assert/strict') const { spawnSync, execFileSync } = require('node:child_process') const fs = require('node:fs') const path = require('node:path') const os = require('node:os') const SCRIPT = path.resolve(__dirname, '../../.claude/helpers/github-safe.js') // Spawn github-safe.js with a fake $PATH that puts a stub `gh` first function run(args, { ghStub = null, env = {} } = {}) { let binDir = null if (ghStub) { binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gh-stub-')) const stub = path.join(binDir, 'gh') fs.writeFileSync(stub, ghStub, { mode: 0o755 }) } const result = spawnSync(process.execPath, ['--input-type=module', SCRIPT, ...args], { encoding: 'utf-8', env: { ...process.env, ...(binDir ? { PATH: `${binDir}:${process.env.PATH}` } : {}), ...env, }, }) if (binDir) fs.rmSync(binDir, { recursive: true, force: true }) return result } // A stub gh that just echos its args as JSON to stdout and exits 0 const ECHO_STUB = `#!/bin/sh\necho "$@"` // A stub gh that exits 1 (simulates gh failure) const FAIL_STUB = `#!/bin/sh\nexit 1` describe('github-safe.js', () => { // ── Usage guard ───────────────────────────────────────────────────────────── describe('usage guard', () => { it('exits 1 when no args provided', () => { const r = run([]) assert.equal(r.status, 1, 'expected exit code 1') }) it('exits 1 when only one arg provided', () => { const r = run(['issue']) assert.equal(r.status, 1) }) it('prints usage text when no args', () => { const r = run([]) assert.ok(r.stdout.includes('Usage'), `stdout: ${r.stdout}`) }) }) // ── ALLOWED_COMMANDS allowlist ────────────────────────────────────────────── describe('command allowlist', () => { it('exits 1 for an unknown top-level gh command', () => { const r = run(['badcmd', 'list'], { ghStub: ECHO_STUB }) assert.equal(r.status, 1) assert.ok(r.stderr.includes('Refusing'), `stderr: ${r.stderr}`) }) const allowed = ['issue', 'pr', 'repo', 'api', 'workflow', 'run', 'release', 'auth', 'gist'] for (const cmd of allowed) { it(`allows the "${cmd}" command`, () => { const r = run([cmd, 'list'], { ghStub: ECHO_STUB }) assert.notEqual(r.status, 1, `${cmd} should not be blocked`) }) } }) // ── Body handling ────────────────────────────────────────────────────────── describe('body handling — temp file routing', () => { it('passes --body-file for "issue comment " positional form', () => { const r = run(['issue', 'comment', '42', 'hello world'], { ghStub: ECHO_STUB }) // gh stub echoes all args; --body-file must appear in the output assert.ok(r.stdout.includes('--body-file'), `stdout: ${r.stdout}`) }) it('passes --body-file for "issue create --title T --body B" form', () => { const r = run(['issue', 'create', '--title', 'My Issue', '--body', 'Description here'], { ghStub: ECHO_STUB, }) assert.ok(r.stdout.includes('--body-file'), `stdout: ${r.stdout}`) }) it('passes --body-file for "pr create --title T --body B" form', () => { const r = run(['pr', 'create', '--title', 'My PR', '--body', 'PR body'], { ghStub: ECHO_STUB, }) assert.ok(r.stdout.includes('--body-file'), `stdout: ${r.stdout}`) }) it('routes body with backticks through temp file (no shell interpretation)', () => { const body = 'Code: `ls -la` and more `$(whoami)`' const r = run(['issue', 'comment', '1', body], { ghStub: ECHO_STUB }) // If injection were happening, the shell would expand $() — instead we see --body-file assert.ok(r.stdout.includes('--body-file'), `stdout: ${r.stdout}`) }) it('does NOT create --body-file when no body is provided', () => { const r = run(['issue', 'list'], { ghStub: ECHO_STUB }) assert.ok( !r.stdout.includes('--body-file'), `stdout should not contain --body-file: ${r.stdout}` ) }) }) // ── Non-body commands forwarded directly ──────────────────────────────────── describe('passthrough for non-body commands', () => { it('forwards "repo list" directly without --body-file', () => { const r = run(['repo', 'list'], { ghStub: ECHO_STUB }) assert.ok(!r.stdout.includes('--body-file')) }) it('forwards "workflow run" directly', () => { const r = run(['workflow', 'run', 'deploy.yml'], { ghStub: ECHO_STUB }) assert.ok(!r.stdout.includes('--body-file')) }) }) // ── Cleanup on failure ────────────────────────────────────────────────────── describe('temp file cleanup', () => { it('exits cleanly even when gh fails (no leftover temp files observed via fs)', () => { // Capture the /tmp listing before and after — no .tmp file should remain const tmpBefore = new Set(fs.readdirSync(os.tmpdir()).filter((f) => f.startsWith('gh-body-'))) const r = run(['issue', 'comment', '1', 'body text'], { ghStub: FAIL_STUB }) const tmpAfter = new Set(fs.readdirSync(os.tmpdir()).filter((f) => f.startsWith('gh-body-'))) const leaked = [...tmpAfter].filter((f) => !tmpBefore.has(f)) assert.deepEqual(leaked, [], `Temp files leaked: ${leaked.join(', ')}`) }) }) })