- B1: Next.js 15 + Payload CMS 3.0 + Postgres 16, ESLint, Prettier, Husky, Vitest - B2: 9 collections, 6 globals, 12 Page Builder blocks, access control, slugify/revalidate hooks - B3: ezy.com.ua payments, Binotel HMAC webhook, leads API, Telegram bot, Resend email, rate limiting - B4: Tariffs collection with ezy API sync (cron + manual), dynamic pricing source-of-truth - B5: 13 test files covering unit libs and all API routes - B6: Dockerfile multi-stage, docker-compose.prod.yml, nginx.conf SSL, GitHub Actions CI/CD, health endpoint - B7: docs/admin-guide-ua.md (marketer guide), docs/deploy.md (VPS instructions), README quickstart Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
166 lines
6.7 KiB
JavaScript
166 lines
6.7 KiB
JavaScript
/**
|
|
* 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 <number> <body>" 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 <num> <body>" 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(', ')}`)
|
|
})
|
|
})
|
|
})
|