Skip to content

Commit bd9b999

Browse files
CLI-173 add E2E test for pre-commit hook
1 parent 3cbc3ba commit bd9b999

File tree

4 files changed

+181
-105
lines changed

4 files changed

+181
-105
lines changed

tests/integration/harness/cli-runner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import type { CliResult } from './types.js';
2626

2727
const PROJECT_ROOT = join(import.meta.dir, '../../..');
2828
const DEFAULT_BINARY = join(PROJECT_ROOT, 'dist', 'sonarqube-cli');
29-
const BINARY_PATH = process.env.SONAR_CLI_BINARY ?? DEFAULT_BINARY;
29+
export const BINARY_PATH = process.env.SONAR_CLI_BINARY ?? DEFAULT_BINARY;
3030
const DEFAULT_TIMEOUT_MS = 30000;
3131

3232
export function assertBinaryExists(): void {

tests/integration/harness/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ export class TestHarness {
123123
* Runs the CLI binary with the given command string.
124124
*
125125
* Before spawning, applies the configured environment (writes state.json + copies binary).
126-
* Automatically injects SONAR_CLI_DISABLE_KEYCHAIN=true.
126+
* Sets SONAR_CLI_KEYCHAIN_FILE so the CLI uses the file-based keychain where the harness
127+
* has written tokens (via withKeychainToken()); avoids touching the system keychain.
127128
*/
128129
async run(command: string, options?: RunOptions): Promise<CliResult> {
129130
// Apply environment to tempDir before each run

tests/integration/specs/integrate/git.test.ts

Lines changed: 164 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,110 @@
2121
// Integration tests for `sonar integrate git`
2222

2323
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
24-
import { mkdirSync, writeFileSync } from 'node:fs';
24+
import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs';
2525
import { join } from 'node:path';
2626
import { TestHarness } from '../../harness';
27+
import { BINARY_PATH } from '../../harness/cli-runner.js';
28+
29+
const PATH_DELIM = process.platform === 'win32' ? ';' : ':';
30+
function pathWithoutNodeModules(envPath: string | undefined): string {
31+
return (envPath ?? '')
32+
.split(PATH_DELIM)
33+
.filter((p) => !p.includes('node_modules'))
34+
.join(PATH_DELIM);
35+
}
36+
37+
// Hardcoded test token — intentional fixture for secret detection in pre-commit hook test
38+
// sonar-ignore-next-line S6769
39+
const GITHUB_TEST_TOKEN = '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';
40+
41+
function buildHookEnv(sonarBinDir: string): Record<string, string> {
42+
return {
43+
...process.env,
44+
PATH: `${sonarBinDir}${PATH_DELIM}${pathWithoutNodeModules(process.env.PATH)}`,
45+
};
46+
}
47+
48+
function setupSonarBinDir(harness: TestHarness): {
49+
sonarBinDir: string;
50+
hookEnv: Record<string, string>;
51+
} {
52+
const sonarBinDir = join(harness.cwd.path, 'sonar-bin');
53+
mkdirSync(sonarBinDir, { recursive: true });
54+
symlinkSync(BINARY_PATH, join(sonarBinDir, 'sonar'));
55+
return { sonarBinDir, hookEnv: buildHookEnv(sonarBinDir) };
56+
}
57+
58+
function setupGitUser(cwd: string): void {
59+
Bun.spawnSync(['git', 'config', 'user.email', 'test@example.com'], { cwd });
60+
Bun.spawnSync(['git', 'config', 'user.name', 'Test User'], { cwd });
61+
}
62+
63+
function addBareRemote(cwd: string): void {
64+
const remotePath = join(cwd, '..', 'remote.git');
65+
mkdirSync(remotePath, { recursive: true });
66+
Bun.spawnSync(['git', 'init', '--bare'], { cwd: remotePath });
67+
Bun.spawnSync(['git', 'remote', 'add', 'origin', remotePath], { cwd });
68+
Bun.spawnSync(['git', 'branch', '-M', 'main'], { cwd });
69+
}
70+
71+
function gitCommit(
72+
cwd: string,
73+
env: Record<string, string>,
74+
message: string,
75+
): ReturnType<typeof Bun.spawnSync> {
76+
return Bun.spawnSync(['git', 'commit', '-m', message], {
77+
cwd,
78+
env,
79+
stdout: 'pipe',
80+
stderr: 'pipe',
81+
});
82+
}
83+
84+
function gitPush(
85+
cwd: string,
86+
env: Record<string, string>,
87+
setUpstream: boolean,
88+
): ReturnType<typeof Bun.spawnSync> {
89+
const args = setUpstream
90+
? ['git', 'push', '-u', 'origin', 'main']
91+
: ['git', 'push', 'origin', 'main'];
92+
return Bun.spawnSync(args, { cwd, env, stdout: 'pipe', stderr: 'pipe' });
93+
}
94+
95+
const INTEGRATION_TEST_TOKEN = 'test-token';
96+
97+
type SetupAuthOptions = { withSecretsBinary?: boolean };
98+
99+
async function setupAuthenticated(
100+
harness: TestHarness,
101+
options: SetupAuthOptions = {},
102+
): Promise<void> {
103+
const server = await harness.newFakeServer().withAuthToken(INTEGRATION_TEST_TOKEN).start();
104+
const chain = harness
105+
.state()
106+
.withActiveConnection(server.baseUrl())
107+
.withKeychainToken(server.baseUrl(), INTEGRATION_TEST_TOKEN);
108+
if (options.withSecretsBinary) {
109+
chain.withSecretsBinaryInstalled();
110+
}
111+
}
112+
113+
function initGitRepo(harness: TestHarness): void {
114+
mkdirSync(harness.cwd.path, { recursive: true });
115+
Bun.spawnSync(['git', 'init'], { cwd: harness.cwd.path });
116+
}
117+
118+
function initGitRepoWithHusky(harness: TestHarness): void {
119+
initGitRepo(harness);
120+
Bun.spawnSync(['git', 'config', 'core.hooksPath', '.husky'], { cwd: harness.cwd.path });
121+
mkdirSync(join(harness.cwd.path, '.husky'), { recursive: true });
122+
}
123+
124+
function initGitRepoWithPreCommitConfig(harness: TestHarness): void {
125+
initGitRepo(harness);
126+
harness.cwd.writeFile('.pre-commit-config.yaml', 'repos: []\n');
127+
}
27128

28129
describe('integrate git (native hooks)', () => {
29130
let harness: TestHarness;
@@ -39,11 +140,7 @@ describe('integrate git (native hooks)', () => {
39140
it(
40141
'exits with error when user cancels the hook-type selection',
41142
async () => {
42-
const server = await harness.newFakeServer().withAuthToken('test-token').start();
43-
harness
44-
.state()
45-
.withActiveConnection(server.baseUrl())
46-
.withKeychainToken(server.baseUrl(), 'test-token');
143+
await setupAuthenticated(harness);
47144

48145
// Minimal git repo: findGitRoot() detects the .git directory
49146
harness.cwd.writeFile('.git/.keep', '');
@@ -72,11 +169,7 @@ describe('integrate git (native hooks)', () => {
72169
it(
73170
'exits with error when run outside a git repository',
74171
async () => {
75-
const server = await harness.newFakeServer().withAuthToken('test-token').start();
76-
harness
77-
.state()
78-
.withActiveConnection(server.baseUrl())
79-
.withKeychainToken(server.baseUrl(), 'test-token');
172+
await setupAuthenticated(harness);
80173

81174
// No .git directory — discoverProject() sets isGitRepo: false
82175
const result = await harness.run('integrate git --non-interactive');
@@ -88,42 +181,70 @@ describe('integrate git (native hooks)', () => {
88181
);
89182

90183
it(
91-
'installs pre-commit hook when user selects pre-commit (--non-interactive)',
184+
'pre-commit hook blocks commit when staged file contains a secret',
92185
async () => {
93-
const server = await harness.newFakeServer().withAuthToken('test-token').start();
94-
harness
95-
.state()
96-
.withActiveConnection(server.baseUrl())
97-
.withKeychainToken(server.baseUrl(), 'test-token');
186+
await setupAuthenticated(harness, { withSecretsBinary: true });
187+
initGitRepo(harness);
98188

99-
// Minimal git repo
100-
harness.cwd.writeFile('.git/.keep', '');
189+
const result = await harness.run('integrate git --hook pre-commit --non-interactive');
190+
expect(result.exitCode).toBe(0);
191+
expect(harness.cwd.exists('.git', 'hooks', 'pre-commit')).toBe(true);
101192

102-
// Fake binaries server so that ensureSonarSecrets() can download sonar-secrets
103-
await harness.newFakeBinariesServer().start();
193+
const { hookEnv } = setupSonarBinDir(harness);
194+
harness.cwd.writeFile('secret.js', `const token = "${GITHUB_TEST_TOKEN}";`);
195+
Bun.spawnSync(['git', 'add', 'secret.js'], { cwd: harness.cwd.path });
196+
setupGitUser(harness.cwd.path);
104197

105-
const result = await harness.run('integrate git --hook pre-commit --non-interactive');
198+
const commit = gitCommit(harness.cwd.path, hookEnv, 'wip');
199+
expect(commit.exitCode).not.toBe(0);
200+
const output = (commit.stdout?.toString() ?? '') + (commit.stderr?.toString() ?? '');
201+
expect(output).toContain('Secrets found');
202+
},
203+
{ timeout: 30000 },
204+
);
205+
206+
it(
207+
'pre-push hook blocks push when commit contains a secret',
208+
async () => {
209+
await setupAuthenticated(harness, { withSecretsBinary: true });
210+
initGitRepo(harness);
106211

212+
const result = await harness.run('integrate git --hook pre-push --non-interactive');
107213
expect(result.exitCode).toBe(0);
108-
expect(harness.cwd.exists('.git', 'hooks', 'pre-commit')).toBe(true);
214+
expect(harness.cwd.exists('.git', 'hooks', 'pre-push')).toBe(true);
215+
216+
const { hookEnv } = setupSonarBinDir(harness);
217+
setupGitUser(harness.cwd.path);
218+
219+
// First commit + push: clean file, should succeed
220+
harness.cwd.writeFile('clean.js', 'const x = 1;\n');
221+
Bun.spawnSync(['git', 'add', 'clean.js'], { cwd: harness.cwd.path });
222+
gitCommit(harness.cwd.path, hookEnv, 'initial');
223+
addBareRemote(harness.cwd.path);
224+
const firstPush = gitPush(harness.cwd.path, hookEnv, true);
225+
expect(firstPush.exitCode).toBe(0);
226+
227+
// Second commit + push: file with secret, should be blocked by pre-push hook
228+
harness.cwd.writeFile('secret.js', `const token = "${GITHUB_TEST_TOKEN}";`);
229+
Bun.spawnSync(['git', 'add', 'secret.js'], { cwd: harness.cwd.path });
230+
gitCommit(harness.cwd.path, hookEnv, 'wip');
231+
const secondPush = gitPush(harness.cwd.path, hookEnv, false);
232+
233+
expect(secondPush.exitCode).not.toBe(0);
234+
const output = (secondPush.stdout?.toString() ?? '') + (secondPush.stderr?.toString() ?? '');
235+
expect(output).toContain('Secrets found');
109236
},
110237
{ timeout: 30000 },
111238
);
112239

113240
it(
114241
'installs native pre-commit hook via interactive prompts when secrets is already installed',
115242
async () => {
116-
const server = await harness.newFakeServer().withAuthToken('test-token').start();
117-
harness
118-
.state()
119-
.withActiveConnection(server.baseUrl())
120-
.withKeychainToken(server.baseUrl(), 'test-token')
121-
.withSecretsBinaryInstalled();
243+
await setupAuthenticated(harness, { withSecretsBinary: true });
122244

123245
// Real git repo so that git commands (e.g. git config core.hooksPath) behave correctly
124246
// and resolveGitHooksDir() resolves to .git/hooks as expected
125-
mkdirSync(harness.cwd.path, { recursive: true });
126-
Bun.spawnSync(['git', 'init'], { cwd: harness.cwd.path });
247+
initGitRepo(harness);
127248

128249
// Two separate stdin chunks with a delay between them so readline doesn't buffer
129250
// both at once: 'y' confirms 'Install here?', then '\r' selects pre-commit
@@ -139,15 +260,8 @@ describe('integrate git (native hooks)', () => {
139260
it(
140261
'installs native pre-push hook via interactive prompts when secrets is already installed',
141262
async () => {
142-
const server = await harness.newFakeServer().withAuthToken('test-token').start();
143-
harness
144-
.state()
145-
.withActiveConnection(server.baseUrl())
146-
.withKeychainToken(server.baseUrl(), 'test-token')
147-
.withSecretsBinaryInstalled();
148-
149-
mkdirSync(harness.cwd.path, { recursive: true });
150-
Bun.spawnSync(['git', 'init'], { cwd: harness.cwd.path });
263+
await setupAuthenticated(harness, { withSecretsBinary: true });
264+
initGitRepo(harness);
151265

152266
// 'y' confirms 'Install here?'; '\x1b[B' moves the selection down to pre-push; '\r' submits
153267
const result = await harness.run('integrate git', {
@@ -164,12 +278,7 @@ describe('integrate git (native hooks)', () => {
164278
it(
165279
'installs native global pre-commit hook via interactive prompts when secrets is already installed',
166280
async () => {
167-
const server = await harness.newFakeServer().withAuthToken('test-token').start();
168-
harness
169-
.state()
170-
.withActiveConnection(server.baseUrl())
171-
.withKeychainToken(server.baseUrl(), 'test-token')
172-
.withSecretsBinaryInstalled();
281+
await setupAuthenticated(harness, { withSecretsBinary: true });
173282

174283
// Two separate stdin chunks with a delay between them so readline doesn't buffer
175284
// both at once: 'y' confirms global hook warning, then '\r' selects pre-commit
@@ -185,12 +294,7 @@ describe('integrate git (native hooks)', () => {
185294
it(
186295
'installs native global pre-push hook via interactive prompts when secrets is already installed',
187296
async () => {
188-
const server = await harness.newFakeServer().withAuthToken('test-token').start();
189-
harness
190-
.state()
191-
.withActiveConnection(server.baseUrl())
192-
.withKeychainToken(server.baseUrl(), 'test-token')
193-
.withSecretsBinaryInstalled();
297+
await setupAuthenticated(harness, { withSecretsBinary: true });
194298

195299
// Two separate stdin chunks with a delay between them so readline doesn't buffer
196300
// both at once: 'y' confirms global hook warning, then '\r' selects pre-push
@@ -220,17 +324,8 @@ describe('integrate git (husky)', () => {
220324
it(
221325
'installs pre-commit hook via husky when core.hooksPath is .husky',
222326
async () => {
223-
const server = await harness.newFakeServer().withAuthToken('test-token').start();
224-
harness
225-
.state()
226-
.withActiveConnection(server.baseUrl())
227-
.withKeychainToken(server.baseUrl(), 'test-token')
228-
.withSecretsBinaryInstalled();
229-
230-
mkdirSync(harness.cwd.path, { recursive: true });
231-
Bun.spawnSync(['git', 'init'], { cwd: harness.cwd.path });
232-
Bun.spawnSync(['git', 'config', 'core.hooksPath', '.husky'], { cwd: harness.cwd.path });
233-
mkdirSync(join(harness.cwd.path, '.husky'), { recursive: true });
327+
await setupAuthenticated(harness, { withSecretsBinary: true });
328+
initGitRepoWithHusky(harness);
234329

235330
const result = await harness.run('integrate git --hook pre-commit --non-interactive');
236331

@@ -246,17 +341,8 @@ describe('integrate git (husky)', () => {
246341
it(
247342
'installs pre-push hook via husky when core.hooksPath is .husky',
248343
async () => {
249-
const server = await harness.newFakeServer().withAuthToken('test-token').start();
250-
harness
251-
.state()
252-
.withActiveConnection(server.baseUrl())
253-
.withKeychainToken(server.baseUrl(), 'test-token')
254-
.withSecretsBinaryInstalled();
255-
256-
mkdirSync(harness.cwd.path, { recursive: true });
257-
Bun.spawnSync(['git', 'init'], { cwd: harness.cwd.path });
258-
Bun.spawnSync(['git', 'config', 'core.hooksPath', '.husky'], { cwd: harness.cwd.path });
259-
mkdirSync(join(harness.cwd.path, '.husky'), { recursive: true });
344+
await setupAuthenticated(harness, { withSecretsBinary: true });
345+
initGitRepoWithHusky(harness);
260346

261347
const result = await harness.run('integrate git --hook pre-push --non-interactive');
262348

@@ -293,16 +379,8 @@ describe('integrate git (pre-commit framework)', () => {
293379
it(
294380
'installs pre-commit hook via pre-commit framework when .pre-commit-config.yaml exists',
295381
async () => {
296-
const server = await harness.newFakeServer().withAuthToken('test-token').start();
297-
harness
298-
.state()
299-
.withActiveConnection(server.baseUrl())
300-
.withKeychainToken(server.baseUrl(), 'test-token')
301-
.withSecretsBinaryInstalled();
302-
303-
mkdirSync(harness.cwd.path, { recursive: true });
304-
Bun.spawnSync(['git', 'init'], { cwd: harness.cwd.path });
305-
harness.cwd.writeFile('.pre-commit-config.yaml', 'repos: []\n');
382+
await setupAuthenticated(harness, { withSecretsBinary: true });
383+
initGitRepoWithPreCommitConfig(harness);
306384

307385
const result = await harness.run('integrate git --hook pre-commit --non-interactive', {
308386
extraEnv: { PATH: setupFakePreCommit() },
@@ -319,16 +397,8 @@ describe('integrate git (pre-commit framework)', () => {
319397
it(
320398
'installs pre-push hook via pre-commit framework when .pre-commit-config.yaml exists',
321399
async () => {
322-
const server = await harness.newFakeServer().withAuthToken('test-token').start();
323-
harness
324-
.state()
325-
.withActiveConnection(server.baseUrl())
326-
.withKeychainToken(server.baseUrl(), 'test-token')
327-
.withSecretsBinaryInstalled();
328-
329-
mkdirSync(harness.cwd.path, { recursive: true });
330-
Bun.spawnSync(['git', 'init'], { cwd: harness.cwd.path });
331-
harness.cwd.writeFile('.pre-commit-config.yaml', 'repos: []\n');
400+
await setupAuthenticated(harness, { withSecretsBinary: true });
401+
initGitRepoWithPreCommitConfig(harness);
332402

333403
const result = await harness.run('integrate git --hook pre-push --non-interactive', {
334404
extraEnv: { PATH: setupFakePreCommit() },

0 commit comments

Comments
 (0)