Skip to content

Commit 48d5d7c

Browse files
CLI-173 add E2E test for pre-commit hook
1 parent 8bc9f3f commit 48d5d7c

File tree

4 files changed

+133
-18
lines changed

4 files changed

+133
-18
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: 126 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,78 @@
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(harness: TestHarness, sonarBinDir: string): Record<string, string> {
42+
return {
43+
...process.env,
44+
HOME: harness.userHome.path,
45+
SONAR_CLI_KEYCHAIN_FILE: harness.keychainJsonFile.path,
46+
PATH: `${sonarBinDir}${PATH_DELIM}${pathWithoutNodeModules(process.env.PATH)}`,
47+
};
48+
}
49+
50+
function setupSonarBinDir(harness: TestHarness): {
51+
sonarBinDir: string;
52+
hookEnv: Record<string, string>;
53+
} {
54+
const sonarBinDir = join(harness.cwd.path, 'sonar-bin');
55+
mkdirSync(sonarBinDir, { recursive: true });
56+
symlinkSync(BINARY_PATH, join(sonarBinDir, 'sonar'));
57+
return { sonarBinDir, hookEnv: buildHookEnv(harness, sonarBinDir) };
58+
}
59+
60+
function setupGitUser(cwd: string): void {
61+
Bun.spawnSync(['git', 'config', 'user.email', 'test@example.com'], { cwd });
62+
Bun.spawnSync(['git', 'config', 'user.name', 'Test User'], { cwd });
63+
}
64+
65+
function addBareRemote(cwd: string): void {
66+
const remotePath = join(cwd, '..', 'remote.git');
67+
mkdirSync(remotePath, { recursive: true });
68+
Bun.spawnSync(['git', 'init', '--bare'], { cwd: remotePath });
69+
Bun.spawnSync(['git', 'remote', 'add', 'origin', remotePath], { cwd });
70+
Bun.spawnSync(['git', 'branch', '-M', 'main'], { cwd });
71+
}
72+
73+
function gitCommit(
74+
cwd: string,
75+
env: Record<string, string>,
76+
message: string,
77+
): ReturnType<typeof Bun.spawnSync> {
78+
return Bun.spawnSync(['git', 'commit', '-m', message], {
79+
cwd,
80+
env,
81+
stdout: 'pipe',
82+
stderr: 'pipe',
83+
});
84+
}
85+
86+
function gitPush(
87+
cwd: string,
88+
env: Record<string, string>,
89+
setUpstream: boolean,
90+
): ReturnType<typeof Bun.spawnSync> {
91+
const args = setUpstream
92+
? ['git', 'push', '-u', 'origin', 'main']
93+
: ['git', 'push', 'origin', 'main'];
94+
return Bun.spawnSync(args, { cwd, env, stdout: 'pipe', stderr: 'pipe' });
95+
}
2796

2897
describe('integrate git (native hooks)', () => {
2998
let harness: TestHarness;
@@ -88,24 +157,72 @@ describe('integrate git (native hooks)', () => {
88157
);
89158

90159
it(
91-
'installs pre-commit hook when user selects pre-commit (--non-interactive)',
160+
'pre-commit hook blocks commit when staged file contains a secret',
92161
async () => {
93162
const server = await harness.newFakeServer().withAuthToken('test-token').start();
94163
harness
95164
.state()
96165
.withActiveConnection(server.baseUrl())
97-
.withKeychainToken(server.baseUrl(), 'test-token');
98-
99-
// Minimal git repo
100-
harness.cwd.writeFile('.git/.keep', '');
166+
.withKeychainToken(server.baseUrl(), 'test-token')
167+
.withSecretsBinaryInstalled();
101168

102-
// Fake binaries server so that ensureSonarSecrets() can download sonar-secrets
103-
await harness.newFakeBinariesServer().start();
169+
mkdirSync(harness.cwd.path, { recursive: true });
170+
Bun.spawnSync(['git', 'init'], { cwd: harness.cwd.path });
104171

105172
const result = await harness.run('integrate git --hook pre-commit --non-interactive');
106-
107173
expect(result.exitCode).toBe(0);
108174
expect(harness.cwd.exists('.git', 'hooks', 'pre-commit')).toBe(true);
175+
176+
const { hookEnv } = setupSonarBinDir(harness);
177+
harness.cwd.writeFile('secret.js', `const token = "${GITHUB_TEST_TOKEN}";`);
178+
Bun.spawnSync(['git', 'add', 'secret.js'], { cwd: harness.cwd.path });
179+
setupGitUser(harness.cwd.path);
180+
181+
const commit = gitCommit(harness.cwd.path, hookEnv, 'wip');
182+
expect(commit.exitCode).not.toBe(0);
183+
const output = (commit.stdout?.toString() ?? '') + (commit.stderr?.toString() ?? '');
184+
expect(output).toContain('Secrets found');
185+
},
186+
{ timeout: 30000 },
187+
);
188+
189+
it(
190+
'pre-push hook blocks push when commit contains a secret',
191+
async () => {
192+
const server = await harness.newFakeServer().withAuthToken('test-token').start();
193+
harness
194+
.state()
195+
.withActiveConnection(server.baseUrl())
196+
.withKeychainToken(server.baseUrl(), 'test-token')
197+
.withSecretsBinaryInstalled();
198+
199+
mkdirSync(harness.cwd.path, { recursive: true });
200+
Bun.spawnSync(['git', 'init'], { cwd: harness.cwd.path });
201+
202+
const result = await harness.run('integrate git --hook pre-push --non-interactive');
203+
expect(result.exitCode).toBe(0);
204+
expect(harness.cwd.exists('.git', 'hooks', 'pre-push')).toBe(true);
205+
206+
const { hookEnv } = setupSonarBinDir(harness);
207+
setupGitUser(harness.cwd.path);
208+
209+
// First commit + push: clean file, should succeed
210+
harness.cwd.writeFile('clean.js', 'const x = 1;\n');
211+
Bun.spawnSync(['git', 'add', 'clean.js'], { cwd: harness.cwd.path });
212+
gitCommit(harness.cwd.path, hookEnv, 'initial');
213+
addBareRemote(harness.cwd.path);
214+
const firstPush = gitPush(harness.cwd.path, hookEnv, true);
215+
expect(firstPush.exitCode).toBe(0);
216+
217+
// Second commit + push: file with secret, should be blocked by pre-push hook
218+
harness.cwd.writeFile('secret.js', `const token = "${GITHUB_TEST_TOKEN}";`);
219+
Bun.spawnSync(['git', 'add', 'secret.js'], { cwd: harness.cwd.path });
220+
gitCommit(harness.cwd.path, hookEnv, 'wip');
221+
const secondPush = gitPush(harness.cwd.path, hookEnv, false);
222+
223+
expect(secondPush.exitCode).not.toBe(0);
224+
const output = (secondPush.stdout?.toString() ?? '') + (secondPush.stderr?.toString() ?? '');
225+
expect(output).toContain('Secrets found');
109226
},
110227
{ timeout: 30000 },
111228
);

tests/unit/integrate-git.test.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -628,14 +628,11 @@ describe('integrateGitGlobal', () => {
628628
performSecretInstallSpy.mockRestore();
629629
});
630630

631-
it('returns without throwing when the user cancels the global install confirmation', () => {
632-
setMockUi(true);
631+
it('throws CommandFailedError when the user cancels the global install confirmation', () => {
633632
queueMockResponse(null);
634-
try {
635-
expect(resolveHookType({})).rejects.toThrow('Installation cancelled');
636-
} finally {
637-
setMockUi(false);
638-
}
633+
expect(
634+
integrateGit({ global: true, nonInteractive: false, hook: 'pre-commit' }),
635+
).rejects.toThrow('Installation cancelled');
639636
});
640637

641638
it('propagates the error when secrets installation fails after the user confirms', () => {

0 commit comments

Comments
 (0)