返回 CodeWhale
review.test.mjs
根目录 / scripts / github-action / review.test.mjs
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
148 lines Plain Text