| 1 | import assert from 'node:assert/strict'; |
| 2 | import { test } from 'node:test'; |
| 3 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; |
| 4 | import { tmpdir } from 'node:os'; |
| 5 | import { join } from 'node:path'; |
| 6 | import { classify, run, settings, target } from './review.mjs'; |
| 7 | |
| 8 | const HEAD = 'a'.repeat(40); |
| 9 | const BASE = 'b'.repeat(40); |
| 10 | const pr = { state: 'open', draft: false, base: { sha: BASE, repo: { full_name: 'owner/repo' } }, |
| 11 | head: { sha: HEAD, repo: { full_name: 'owner/repo' } } }; |
| 12 | const event = { action: 'opened', number: 12, pull_request: pr }; |
| 13 | const defaults = { GITHUB_REPOSITORY: 'owner/repo', GITHUB_EVENT_NAME: 'pull_request', |
| 14 | CW_VERSION: 'v0.10.0', CW_PROVIDER: 'codewhale', CW_MODEL: 'provider/model', |
| 15 | CW_ACTION_PATH: '/trusted/action', GH_TOKEN: 'github-secret', CODEWHALE_API_KEY: 'account-secret' }; |
| 16 | const completion = (override = {}) => ({ mode: 'review', success: true, complete: true, |
| 17 | pr: { head_sha: HEAD }, review: { issues: [] }, review_passes: 1, publication: 'posted', |
| 18 | receipt: { coverage: { manifest: { head_sha: HEAD, base_sha: BASE, skipped_files: [] }, completed_passes: [{}] } }, |
| 19 | usage: { input_tokens: 10, output_tokens: 20 }, ...override }); |
| 20 | const output = data => ({ status: 0, stdout: JSON.stringify(data), stderr: '' }); |
| 21 | |
| 22 | function fixture(t, { env = {}, payload = event, snapshot = pr, intercept } = {}) { |
| 23 | const dir = mkdtempSync(join(tmpdir(), 'cw-action-test-')); |
| 24 | t.after(() => rmSync(dir, { recursive: true, force: true })); |
| 25 | const input = { ...defaults, RUNNER_TEMP: dir, GITHUB_EVENT_PATH: join(dir, 'event.json'), |
| 26 | GITHUB_OUTPUT: join(dir, 'output'), GITHUB_STEP_SUMMARY: join(dir, 'summary'), ...env }; |
| 27 | writeFileSync(input.GITHUB_EVENT_PATH, JSON.stringify(payload)); |
| 28 | const calls = []; |
| 29 | const result = run(input, (program, args, opts) => { |
| 30 | calls.push({ program, args, opts }); |
| 31 | const intercepted = intercept?.(program, args, opts); |
| 32 | if (intercepted) return intercepted; |
| 33 | if (program === 'gh') return output(snapshot); |
| 34 | if (args.includes('rev-parse')) return { status: 0, stdout: `${HEAD}\n` }; |
| 35 | if (args.includes('merge-base')) return { status: 0, stdout: `${BASE}\n` }; |
| 36 | if (args.includes('review')) return output(completion()); |
| 37 | return { status: 0, stdout: '' }; |
| 38 | }); |
| 39 | return { ...result, calls, summary: readFileSync(input.GITHUB_STEP_SUMMARY, 'utf8'), |
| 40 | saved: JSON.parse(readFileSync(result.receiptPath, 'utf8')), outputs: readFileSync(input.GITHUB_OUTPUT, 'utf8') }; |
| 41 | } |
| 42 | |
| 43 | test('a pinned complete review reuses the installer and CLI without executing PR source', t => { |
| 44 | const result = fixture(t, { env: { DEEPSEEK_API_KEY: 'stale-vendor-secret', UNRELATED_TOKEN: 'private', |
| 45 | CODEWHALE_RELEASE_BASE_URL: 'https://attacker.example', CW_MAX_OUTPUT_TOKENS: '8192' } }); |
| 46 | assert.equal(result.exitCode, 0); |
| 47 | assert.equal(result.saved.outcome, 'reviewed_clean'); |
| 48 | assert.equal(result.saved.head_sha, HEAD); |
| 49 | assert.deepEqual(result.saved.usage, { input_tokens: 10, output_tokens: 20 }); |
| 50 | assert.match(result.outputs, /pr-url=https:\/\/github.com\/owner\/repo\/pull\/12/); |
| 51 | const install = result.calls.find(c => c.program === 'sh'); |
| 52 | assert.equal(install.args[0], '/trusted/action/web/public/install.sh'); |
| 53 | assert.equal(install.opts.env.CODEWHALE_VERSION, 'v0.10.0'); |
| 54 | assert.equal(install.opts.env.GH_TOKEN, undefined); |
| 55 | assert.equal(install.opts.env.CODEWHALE_RELEASE_BASE_URL, undefined); |
| 56 | const review = result.calls.find(c => c.args.includes('review')); |
| 57 | assert.ok(review.args.includes('--json')); |
| 58 | assert.ok(review.args.includes('--no-project-config')); |
| 59 | assert.equal(review.opts.env.CODEWHALE_API_KEY, 'account-secret'); |
| 60 | assert.equal(review.opts.env.DEEPSEEK_API_KEY, undefined); |
| 61 | assert.equal(review.opts.env.UNRELATED_TOKEN, undefined); |
| 62 | assert.equal(review.opts.env.CODEWHALE_MAX_OUTPUT_TOKENS, '8192'); |
| 63 | assert.equal(result.calls.some(c => c.args.includes('checkout')), false); |
| 64 | assert.equal(result.calls.some(c => c.program === 'cargo'), false); |
| 65 | assert.doesNotMatch(JSON.stringify(result.saved) + result.summary, /account-secret|stale-vendor-secret/); |
| 66 | }); |
| 67 | |
| 68 | test('forks, draft PRs and pull_request_target make no network or model calls', t => { |
| 69 | for (const options of [ |
| 70 | { payload: { ...event, pull_request: { ...pr, head: { ...pr.head, repo: { full_name: 'fork/repo' } } } } }, |
| 71 | { payload: { ...event, pull_request: { ...pr, draft: true } } }, |
| 72 | { env: { GITHUB_EVENT_NAME: 'pull_request_target' } }, |
| 73 | ]) { |
| 74 | const result = fixture(t, options); |
| 75 | assert.equal(result.saved.outcome, 'not_eligible'); |
| 76 | assert.equal(result.calls.length, 0); |
| 77 | } |
| 78 | }); |
| 79 | |
| 80 | test('manual recovery validates the current PR and refuses forks', t => { |
| 81 | const env = { GITHUB_EVENT_NAME: 'workflow_dispatch', CW_PR_NUMBER: '12' }; |
| 82 | assert.equal(fixture(t, { env, payload: {} }).saved.outcome, 'reviewed_clean'); |
| 83 | const result = fixture(t, { env, payload: {}, snapshot: { ...pr, head: { ...pr.head, repo: { full_name: 'fork/repo' } } } }); |
| 84 | assert.equal(result.saved.outcome, 'not_eligible'); |
| 85 | assert.equal(result.calls.length, 1); |
| 86 | }); |
| 87 | |
| 88 | test('stale events and a head changed during fetch stop before inference', t => { |
| 89 | const stale = fixture(t, { snapshot: { ...pr, head: { ...pr.head, sha: 'c'.repeat(40) } } }); |
| 90 | assert.equal(stale.saved.outcome, 'superseded'); |
| 91 | assert.equal(stale.calls.length, 1); |
| 92 | const moved = fixture(t, { intercept: (_p, args) => args.includes('rev-parse') && { status: 0, stdout: 'c'.repeat(40) } }); |
| 93 | assert.equal(moved.saved.outcome, 'superseded'); |
| 94 | assert.equal(moved.calls.some(c => c.args.includes('review')), false); |
| 95 | }); |
| 96 | |
| 97 | test('bad config cannot fall back to a stale vendor key or contact another GitHub host', t => { |
| 98 | for (const env of [ |
| 99 | { CODEWHALE_API_KEY: '', DEEPSEEK_API_KEY: 'stale' }, { CW_PROVIDER: 'deepseek', DEEPSEEK_API_KEY: 'stale' }, |
| 100 | { CW_MODEL: 'guessed-model' }, { CW_MODEL: 'provider/model\n::error::injection' }, |
| 101 | { CW_VERSION: 'latest' }, { CW_MAX_PASSES: '65' }, { CW_MAX_CHARS: '8388609' }, |
| 102 | { CW_MAX_OUTPUT_TOKENS: '1000' }, { CW_TIMEOUT_SECONDS: '0' }, { CW_POST: 'maybe' }, |
| 103 | { GITHUB_SERVER_URL: 'https://github.enterprise.example' }, |
| 104 | ]) { |
| 105 | const result = fixture(t, { env }); |
| 106 | assert.equal(result.saved.outcome, 'configuration_missing'); |
| 107 | assert.equal(result.exitCode, 1); |
| 108 | assert.equal(result.calls.length, 0); |
| 109 | } |
| 110 | assert.equal(settings({ ...defaults, CODEWHALE_API_KEY: '', CW_PROVIDER: 'deepseek', CW_MODEL: 'exact-model', DEEPSEEK_API_KEY: 'vendor' }).provider, 'deepseek'); |
| 111 | assert.throws(() => target({ ...defaults, GITHUB_REPOSITORY: '../x' }, event)); |
| 112 | }); |
| 113 | |
| 114 | test('release verification and account preflight failures stop before review and save safe receipts', t => { |
| 115 | for (const fail of [(p) => p === 'sh', (_p, args) => args.includes('account')]) { |
| 116 | const result = fixture(t, { intercept: (p, args) => fail(p, args) && { status: 1, stdout: 'account-secret', stderr: '::error::secret' } }); |
| 117 | assert.equal(result.exitCode, 1); |
| 118 | assert.equal(result.saved.outcome, 'failed'); |
| 119 | assert.equal(result.calls.some(c => c.args.includes('review')), false); |
| 120 | assert.doesNotMatch(JSON.stringify(result.saved) + result.summary, /account-secret|::error::secret/); |
| 121 | } |
| 122 | }); |
| 123 | |
| 124 | test('zero exit, empty output, wrong head, incomplete coverage and malformed receipts never mean clean', () => { |
| 125 | for (const result of [ |
| 126 | { status: 0, stdout: '' }, { status: 0, stdout: 'no problems' }, output({ success: true }), |
| 127 | output(completion({ pr: { head_sha: BASE } })), output(completion({ review_passes: 0 })), |
| 128 | output(completion({ publication: 'not_attempted' })), output(completion({ review: null })), |
| 129 | output(completion({ receipt: null })), |
| 130 | output(completion({ receipt: { coverage: { manifest: { head_sha: HEAD, base_sha: 'c'.repeat(40), skipped_files: [] }, completed_passes: [{}] } } })), |
| 131 | { ...output(completion()), status: 1 }, |
| 132 | { status: 1, stdout: JSON.stringify({ success: false, error: 'HTTP 402: account-secret', publication: 'not_attempted' }) }, |
| 133 | ]) assert.equal(classify(result, HEAD, true, BASE).outcome, 'failed'); |
| 134 | assert.equal(classify(output(completion({ complete: false })), HEAD, true, BASE).outcome, 'incomplete'); |
| 135 | assert.equal(classify(output(completion({ publication: 'uncertain' })), HEAD, true, BASE).outcome, 'publication_uncertain'); |
| 136 | assert.equal(classify({ status: null, error: new Error('timeout') }, HEAD, true, BASE).outcome, 'publication_uncertain'); |
| 137 | }); |
| 138 | |
| 139 | test('findings are advisory and preview has no publication claim', t => { |
| 140 | const findings = classify(output(completion({ review: { issues: [{ description: 'private repository text' }] } })), HEAD, true, BASE); |
| 141 | assert.equal(findings.outcome, 'reviewed_with_findings'); |
| 142 | assert.equal(findings.findings, 1); |
| 143 | assert.doesNotMatch(JSON.stringify(findings), /private repository text/); |
| 144 | const preview = fixture(t, { env: { CW_POST: 'false' }, intercept: (_p, args) => args.includes('review') && output(completion({ publication: 'not_requested' })) }); |
| 145 | assert.equal(preview.exitCode, 0); |
| 146 | assert.equal(preview.calls.find(c => c.args.includes('review')).args.includes('--post'), false); |
| 147 | }); |
| 148 |