Shumiland/tests/helpers/github-safe.test.js
Vadym Samoilenko 9b41fa447a
Some checks are pending
CI / Type Check (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Unit Tests (push) Waiting to run
Deploy / Build & Push Image (push) Waiting to run
Deploy / Deploy to VPS (push) Blocked by required conditions
feat: complete backend B1-B7 — Payload CMS, ezy payments, leads, deploy
- 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>
2026-05-09 19:14:54 +01:00

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(', ')}`)
})
})
})