mirror of
https://github.com/ivuorinen/gh-codeql-report.git
synced 2026-02-03 02:44:28 +00:00
Initial commit
This commit is contained in:
41
src/__tests__/auth.test.ts
Normal file
41
src/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getGitHubToken } from '../lib/auth.js';
|
||||
|
||||
vi.mock('node:child_process');
|
||||
|
||||
describe('getGitHubToken', () => {
|
||||
const originalEnv = process.env.GITHUB_TOKEN;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.GITHUB_TOKEN = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return token from GITHUB_TOKEN env var', () => {
|
||||
process.env.GITHUB_TOKEN = 'test-token-from-env';
|
||||
const token = getGitHubToken();
|
||||
expect(token).toBe('test-token-from-env');
|
||||
});
|
||||
|
||||
it('should fall back to gh CLI when GITHUB_TOKEN is not set', () => {
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocking requires any type
|
||||
vi.mocked(execSync).mockReturnValue('test-token-from-gh\n' as any);
|
||||
|
||||
const token = getGitHubToken();
|
||||
expect(token).toBe('test-token-from-gh');
|
||||
expect(execSync).toHaveBeenCalledWith('gh auth token', { encoding: 'utf-8' });
|
||||
});
|
||||
|
||||
it('should throw error when neither GITHUB_TOKEN nor gh CLI are available', () => {
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
throw new Error('gh not found');
|
||||
});
|
||||
|
||||
expect(() => getGitHubToken()).toThrow(
|
||||
'GitHub token not found. Please set GITHUB_TOKEN environment variable or authenticate with `gh auth login`',
|
||||
);
|
||||
});
|
||||
});
|
||||
290
src/__tests__/cli.test.ts
Normal file
290
src/__tests__/cli.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { Octokit } from 'octokit';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { main } from '../cli.js';
|
||||
import { formatAsJSON } from '../formatters/json.js';
|
||||
import { formatAsMarkdown } from '../formatters/markdown.js';
|
||||
import { formatAsSARIF } from '../formatters/sarif.js';
|
||||
import { formatAsText } from '../formatters/text.js';
|
||||
import { getGitHubToken } from '../lib/auth.js';
|
||||
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||
import { fetchAllAlertsWithDetails } from '../lib/codeql.js';
|
||||
import { getGitHubRepoFromRemote } from '../lib/git.js';
|
||||
|
||||
// Mock all dependencies
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('octokit');
|
||||
vi.mock('../lib/auth.js');
|
||||
vi.mock('../lib/git.js');
|
||||
vi.mock('../lib/codeql.js');
|
||||
vi.mock('../formatters/json.js');
|
||||
vi.mock('../formatters/text.js');
|
||||
vi.mock('../formatters/markdown.js');
|
||||
vi.mock('../formatters/sarif.js');
|
||||
|
||||
const mockAlert: CodeQLAlert = {
|
||||
number: 1,
|
||||
rule: {
|
||||
id: 'js/sql-injection',
|
||||
severity: 'error',
|
||||
description: 'SQL injection vulnerability',
|
||||
name: 'SQL Injection',
|
||||
},
|
||||
most_recent_instance: {
|
||||
ref: 'refs/heads/main',
|
||||
analysis_key: 'test-analysis',
|
||||
category: 'security',
|
||||
state: 'open',
|
||||
commit_sha: 'abc123',
|
||||
message: {
|
||||
text: 'Potential SQL injection detected',
|
||||
},
|
||||
location: {
|
||||
path: 'src/database.js',
|
||||
start_line: 10,
|
||||
end_line: 12,
|
||||
start_column: 5,
|
||||
end_column: 20,
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
name: 'CodeQL',
|
||||
version: '2.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
describe('CLI', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let originalArgv: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock console methods
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Save original argv
|
||||
originalArgv = process.argv;
|
||||
|
||||
// Setup default mocks
|
||||
vi.mocked(getGitHubToken).mockReturnValue('test-token');
|
||||
vi.mocked(getGitHubRepoFromRemote).mockResolvedValue({
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
});
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(formatAsJSON).mockReturnValue('{"mock":"json"}');
|
||||
vi.mocked(formatAsText).mockReturnValue('mock text');
|
||||
vi.mocked(formatAsMarkdown).mockReturnValue('# Mock Markdown');
|
||||
vi.mocked(formatAsSARIF).mockReturnValue('{"mock":"sarif"}');
|
||||
|
||||
// Mock Octokit constructor
|
||||
vi.mocked(Octokit).mockImplementation(() => ({}) as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original argv
|
||||
process.argv = originalArgv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('successful alert generation', () => {
|
||||
it('should generate JSON report with default settings', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(getGitHubToken).toHaveBeenCalled();
|
||||
expect(getGitHubRepoFromRemote).toHaveBeenCalled();
|
||||
expect(fetchAllAlertsWithDetails).toHaveBeenCalled();
|
||||
expect(formatAsJSON).toHaveBeenCalledWith([mockAlert], 'medium');
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/code-scanning-report-.*\.json$/),
|
||||
'{"mock":"json"}',
|
||||
'utf-8',
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('✅ Report saved to:'));
|
||||
});
|
||||
|
||||
it('should generate SARIF report when format specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--format', 'sarif'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(formatAsSARIF).toHaveBeenCalledWith([mockAlert], 'test-owner/test-repo', 'medium');
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/code-scanning-report-.*\.sarif$/),
|
||||
'{"mock":"sarif"}',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate text report when format specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--format', 'txt'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(formatAsText).toHaveBeenCalledWith([mockAlert], 'medium');
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/code-scanning-report-.*\.txt$/),
|
||||
'mock text',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate markdown report when format specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--format', 'md'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(formatAsMarkdown).toHaveBeenCalledWith([mockAlert], 'test-owner/test-repo', 'medium');
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/code-scanning-report-.*\.md$/),
|
||||
'# Mock Markdown',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom output path when specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--output', 'custom-report.json'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(writeFile).toHaveBeenCalledWith('custom-report.json', '{"mock":"json"}', 'utf-8');
|
||||
});
|
||||
|
||||
it('should use minimum detail level when specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--detail', 'minimum'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(formatAsJSON).toHaveBeenCalledWith([mockAlert], 'minimum');
|
||||
});
|
||||
|
||||
it('should use full detail level when specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--detail', 'full'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(formatAsJSON).toHaveBeenCalledWith([mockAlert], 'full');
|
||||
});
|
||||
|
||||
it('should use raw detail level when specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--detail', 'raw'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(formatAsJSON).toHaveBeenCalledWith([mockAlert], 'raw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('no alerts found (celebration)', () => {
|
||||
it('should celebrate and exit with 0 when no alerts found', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([]);
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'🎉 No CodeQL alerts found! Your repository is clean!',
|
||||
);
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle git remote error and exit with 1', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(getGitHubRepoFromRemote).mockRejectedValue(
|
||||
new Error('No git remotes found. Make sure you are in a git repository.'),
|
||||
);
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'❌ Error: No git remotes found. Make sure you are in a git repository.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle authentication error', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(getGitHubToken).mockImplementation(() => {
|
||||
throw new Error('GitHub token not found');
|
||||
});
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Error: GitHub token not found');
|
||||
});
|
||||
|
||||
it('should handle API error', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockRejectedValue(new Error('API request failed'));
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Error: API request failed');
|
||||
});
|
||||
|
||||
it('should handle file write error', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
vi.mocked(writeFile).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Error: Permission denied');
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockRejectedValue('string error');
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ An unexpected error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('console output', () => {
|
||||
it('should log progress messages', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('🔐 Authenticating with GitHub...');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('📂 Detecting repository from git remote...');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(' Repository: test-owner/test-repo');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('🔍 Fetching CodeQL alerts...');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(' Found 1 open alert(s)');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('📝 Generating JSON report (medium detail)...');
|
||||
});
|
||||
});
|
||||
});
|
||||
196
src/__tests__/codeql.test.ts
Normal file
196
src/__tests__/codeql.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { Octokit } from 'octokit';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||
import { fetchAlertDetails, fetchAllAlertsWithDetails, fetchCodeQLAlerts } from '../lib/codeql.js';
|
||||
import type { GitHubRepo } from '../lib/git.js';
|
||||
|
||||
const mockAlert: CodeQLAlert = {
|
||||
number: 1,
|
||||
rule: {
|
||||
id: 'js/sql-injection',
|
||||
severity: 'error',
|
||||
description: 'SQL injection vulnerability',
|
||||
name: 'SQL Injection',
|
||||
},
|
||||
most_recent_instance: {
|
||||
ref: 'refs/heads/main',
|
||||
analysis_key: 'test-analysis',
|
||||
category: 'security',
|
||||
state: 'open',
|
||||
commit_sha: 'abc123',
|
||||
message: {
|
||||
text: 'Potential SQL injection detected',
|
||||
},
|
||||
location: {
|
||||
path: 'src/database.js',
|
||||
start_line: 10,
|
||||
end_line: 12,
|
||||
start_column: 5,
|
||||
end_column: 20,
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
name: 'CodeQL',
|
||||
version: '2.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
const mockRepo: GitHubRepo = {
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
};
|
||||
|
||||
describe('CodeQL API', () => {
|
||||
describe('fetchCodeQLAlerts', () => {
|
||||
it('should stop pagination when empty page received', async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
listAlertsForRepo: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: [mockAlert, { ...mockAlert, number: 2 }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alerts = await fetchCodeQLAlerts(mockOctokit, mockRepo);
|
||||
|
||||
expect(alerts).toHaveLength(2);
|
||||
// Should stop on first call because result is less than perPage (100)
|
||||
expect(mockOctokit.rest.codeScanning.listAlertsForRepo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle single page of alerts', async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
listAlertsForRepo: vi.fn().mockResolvedValue({
|
||||
data: [mockAlert],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alerts = await fetchCodeQLAlerts(mockOctokit, mockRepo);
|
||||
|
||||
expect(alerts).toHaveLength(1);
|
||||
expect(mockOctokit.rest.codeScanning.listAlertsForRepo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
listAlertsForRepo: vi.fn().mockResolvedValue({
|
||||
data: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alerts = await fetchCodeQLAlerts(mockOctokit, mockRepo);
|
||||
|
||||
expect(alerts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should continue pagination until fewer than perPage results', async () => {
|
||||
const mockAlerts = Array.from({ length: 100 }, (_, i) => ({ ...mockAlert, number: i + 1 }));
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
listAlertsForRepo: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: mockAlerts,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [{ ...mockAlert, number: 101 }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alerts = await fetchCodeQLAlerts(mockOctokit, mockRepo);
|
||||
|
||||
expect(alerts).toHaveLength(101);
|
||||
expect(mockOctokit.rest.codeScanning.listAlertsForRepo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAlertDetails', () => {
|
||||
it('should fetch details for a specific alert', async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
getAlert: vi.fn().mockResolvedValue({
|
||||
data: mockAlert,
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alert = await fetchAlertDetails(mockOctokit, mockRepo, 1);
|
||||
|
||||
expect(alert).toEqual(mockAlert);
|
||||
expect(mockOctokit.rest.codeScanning.getAlert).toHaveBeenCalledWith({
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
alert_number: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAllAlertsWithDetails', () => {
|
||||
it('should fetch all alerts and their details', async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
listAlertsForRepo: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ ...mockAlert, number: 1 },
|
||||
{ ...mockAlert, number: 2 },
|
||||
],
|
||||
}),
|
||||
getAlert: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: { ...mockAlert, number: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { ...mockAlert, number: 2 },
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alerts = await fetchAllAlertsWithDetails(mockOctokit, mockRepo);
|
||||
|
||||
expect(alerts).toHaveLength(2);
|
||||
expect(mockOctokit.rest.codeScanning.listAlertsForRepo).toHaveBeenCalledTimes(1);
|
||||
expect(mockOctokit.rest.codeScanning.getAlert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
listAlertsForRepo: vi.fn().mockResolvedValue({
|
||||
data: [],
|
||||
}),
|
||||
getAlert: vi.fn(),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alerts = await fetchAllAlertsWithDetails(mockOctokit, mockRepo);
|
||||
|
||||
expect(alerts).toHaveLength(0);
|
||||
expect(mockOctokit.rest.codeScanning.getAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
300
src/__tests__/formatters.test.ts
Normal file
300
src/__tests__/formatters.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatAsJSON } from '../formatters/json.js';
|
||||
import { formatAsMarkdown, generateMarkdownTable } from '../formatters/markdown.js';
|
||||
import { formatAsSARIF } from '../formatters/sarif.js';
|
||||
import { formatAsText } from '../formatters/text.js';
|
||||
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||
|
||||
const mockAlert: CodeQLAlert = {
|
||||
number: 1,
|
||||
rule: {
|
||||
id: 'js/sql-injection',
|
||||
severity: 'error',
|
||||
description: 'SQL injection vulnerability',
|
||||
name: 'SQL Injection',
|
||||
},
|
||||
most_recent_instance: {
|
||||
ref: 'refs/heads/main',
|
||||
analysis_key: 'test-analysis',
|
||||
category: 'security',
|
||||
state: 'open',
|
||||
commit_sha: 'abc123',
|
||||
message: {
|
||||
text: 'Potential SQL injection detected',
|
||||
},
|
||||
location: {
|
||||
path: 'src/database.js',
|
||||
start_line: 10,
|
||||
end_line: 12,
|
||||
start_column: 5,
|
||||
end_column: 20,
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
name: 'CodeQL',
|
||||
version: '2.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Formatters', () => {
|
||||
describe('formatAsJSON', () => {
|
||||
it('should format alerts as JSON with default (medium) detail', () => {
|
||||
const result = formatAsJSON([mockAlert]);
|
||||
expect(result).toContain('"number": 1');
|
||||
expect(result).toContain('"js/sql-injection"');
|
||||
expect(() => JSON.parse(result)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should format alerts with minimum detail (flat structure)', () => {
|
||||
const result = formatAsJSON([mockAlert], 'minimum');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed[0]).toHaveProperty('number');
|
||||
expect(parsed[0]).toHaveProperty('rule_id');
|
||||
expect(parsed[0]).toHaveProperty('commit_sha'); // Now in all levels
|
||||
expect(parsed[0]).not.toHaveProperty('rule_description');
|
||||
expect(parsed[0]).not.toHaveProperty('rule.id'); // Nested structure removed
|
||||
});
|
||||
|
||||
it('should format alerts with medium detail (flat structure)', () => {
|
||||
const result = formatAsJSON([mockAlert], 'medium');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed[0]).toHaveProperty('number');
|
||||
expect(parsed[0]).toHaveProperty('rule_description');
|
||||
expect(parsed[0]).toHaveProperty('commit_sha');
|
||||
expect(parsed[0]).toHaveProperty('state');
|
||||
expect(parsed[0]).not.toHaveProperty('ref');
|
||||
expect(parsed[0]).not.toHaveProperty('most_recent_instance'); // Nested structure removed
|
||||
});
|
||||
|
||||
it('should format alerts with full detail (flat structure)', () => {
|
||||
const result = formatAsJSON([mockAlert], 'full');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed[0]).toHaveProperty('ref');
|
||||
expect(parsed[0]).toHaveProperty('tool_name');
|
||||
expect(parsed[0]).toHaveProperty('tool_version');
|
||||
expect(parsed[0]).not.toHaveProperty('tool'); // Nested structure removed
|
||||
});
|
||||
|
||||
it('should include help_text in full detail when available', () => {
|
||||
const alertWithHelp = {
|
||||
...mockAlert,
|
||||
help: 'This is a helpful guide on how to fix this issue.',
|
||||
};
|
||||
const result = formatAsJSON([alertWithHelp], 'full');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed[0]).toHaveProperty('help_text');
|
||||
expect(parsed[0].help_text).toBe('This is a helpful guide on how to fix this issue.');
|
||||
});
|
||||
|
||||
it('should format alerts with raw detail (original structure)', () => {
|
||||
const result = formatAsJSON([mockAlert], 'raw');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed[0]).toHaveProperty('most_recent_instance');
|
||||
expect(parsed[0]).toHaveProperty('tool');
|
||||
expect(parsed[0]).toHaveProperty('rule');
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = formatAsJSON([]);
|
||||
expect(result).toBe('[]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAsText', () => {
|
||||
it('should format alerts as text with default (medium) detail', () => {
|
||||
const result = formatAsText([mockAlert]);
|
||||
expect(result).toContain('CodeQL Security Scan Report');
|
||||
expect(result).toContain('Total Alerts: 1');
|
||||
expect(result).toContain('Detail Level: medium');
|
||||
expect(result).toContain('Alert #1');
|
||||
expect(result).toContain('js/sql-injection');
|
||||
expect(result).toContain('SQL Injection');
|
||||
expect(result).toContain('src/database.js');
|
||||
});
|
||||
|
||||
it('should format alerts with minimum detail (commit now included)', () => {
|
||||
const result = formatAsText([mockAlert], 'minimum');
|
||||
expect(result).toContain('Detail Level: minimum');
|
||||
expect(result).toContain('Alert #1');
|
||||
expect(result).toContain('Commit:'); // Now in all levels
|
||||
expect(result).not.toContain('Description:');
|
||||
expect(result).not.toContain('Columns:');
|
||||
expect(result).not.toContain('State:');
|
||||
});
|
||||
|
||||
it('should format alerts with full detail', () => {
|
||||
const result = formatAsText([mockAlert], 'full');
|
||||
expect(result).toContain('Detail Level: full');
|
||||
expect(result).toContain('Description:');
|
||||
expect(result).toContain('Columns:');
|
||||
expect(result).toContain('Commit:');
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = formatAsText([]);
|
||||
expect(result).toContain('Total Alerts: 0');
|
||||
});
|
||||
|
||||
it('should format alerts with raw detail (original structure)', () => {
|
||||
const result = formatAsText([mockAlert], 'raw');
|
||||
expect(result).toContain('Detail Level: raw');
|
||||
expect(result).toContain('"most_recent_instance"');
|
||||
expect(result).toContain('"tool"');
|
||||
expect(result).toContain('"rule"');
|
||||
const parsed = JSON.parse(result.split('\n').slice(4, -2).join('\n'));
|
||||
expect(parsed).toHaveProperty('most_recent_instance');
|
||||
expect(parsed).toHaveProperty('tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMarkdownTable', () => {
|
||||
it('should generate a valid markdown table', () => {
|
||||
const data = [
|
||||
['Name', 'Age', 'City'],
|
||||
['Alice', '30', 'NYC'],
|
||||
['Bob', '25', 'LA'],
|
||||
];
|
||||
const result = generateMarkdownTable(data);
|
||||
expect(result).toContain('| Name | Age | City |');
|
||||
expect(result).toContain('| ----- | --- | ---- |');
|
||||
expect(result).toContain('| Alice | 30 | NYC |');
|
||||
expect(result).toContain('| Bob | 25 | LA |');
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = generateMarkdownTable([]);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle jagged arrays (rows with missing columns)', () => {
|
||||
const data = [
|
||||
['Name', 'Age', 'City'],
|
||||
['Alice', '30'], // Missing city
|
||||
['Bob', '25', 'LA'],
|
||||
];
|
||||
const result = generateMarkdownTable(data);
|
||||
expect(result).toContain('| Name | Age | City |');
|
||||
expect(result).toContain('| ----- | --- | ---- |');
|
||||
expect(result).toContain('| Alice | 30 | |'); // Empty cell for missing column
|
||||
expect(result).toContain('| Bob | 25 | LA |');
|
||||
});
|
||||
|
||||
it('should handle single row (headers only)', () => {
|
||||
const data = [['Header1', 'Header2']];
|
||||
const result = generateMarkdownTable(data);
|
||||
expect(result).toContain('| Header1 | Header2 |');
|
||||
expect(result).toContain('| ------- | ------- |');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAsMarkdown', () => {
|
||||
it('should format alerts as markdown with default (medium) detail', () => {
|
||||
const result = formatAsMarkdown([mockAlert], 'owner/repo');
|
||||
expect(result).toContain('# CodeQL Security Scan Report');
|
||||
expect(result).toContain('**Repository:** owner/repo');
|
||||
expect(result).toContain('**Total Alerts:** 1');
|
||||
expect(result).toContain('**Detail Level:** medium');
|
||||
expect(result).toContain('## Summary by Severity');
|
||||
expect(result).toContain('### Alert #1: SQL Injection');
|
||||
expect(result).toContain('`js/sql-injection`');
|
||||
});
|
||||
|
||||
it('should format with minimum detail (commit now included)', () => {
|
||||
const result = formatAsMarkdown([mockAlert], 'owner/repo', 'minimum');
|
||||
expect(result).toContain('**Detail Level:** minimum');
|
||||
expect(result).toContain('**Commit:**'); // Now in all levels
|
||||
expect(result).toContain('#### Details'); // Now always present (for commit)
|
||||
expect(result).not.toContain('**Description:**');
|
||||
expect(result).not.toContain('**Columns:**');
|
||||
expect(result).not.toContain('**State:**');
|
||||
});
|
||||
|
||||
it('should format with full detail (includes ref)', () => {
|
||||
const result = formatAsMarkdown([mockAlert], 'owner/repo', 'full');
|
||||
expect(result).toContain('**Detail Level:** full');
|
||||
expect(result).toContain('**Reference:**');
|
||||
});
|
||||
|
||||
it('should include severity summary table', () => {
|
||||
const result = formatAsMarkdown([mockAlert], 'owner/repo');
|
||||
expect(result).toContain('Severity');
|
||||
expect(result).toContain('Count');
|
||||
expect(result).toContain('error');
|
||||
});
|
||||
|
||||
it('should format with raw detail (original structure as JSON)', () => {
|
||||
const result = formatAsMarkdown([mockAlert], 'owner/repo', 'raw');
|
||||
expect(result).toContain('**Detail Level:** raw');
|
||||
expect(result).toContain('```json');
|
||||
expect(result).toContain('"most_recent_instance"');
|
||||
expect(result).toContain('"tool"');
|
||||
expect(result).toContain('"rule"');
|
||||
});
|
||||
|
||||
it('should handle multiple alerts with different severities', () => {
|
||||
const alerts: CodeQLAlert[] = [
|
||||
mockAlert,
|
||||
{ ...mockAlert, number: 2, rule: { ...mockAlert.rule, severity: 'warning' } },
|
||||
{ ...mockAlert, number: 3, rule: { ...mockAlert.rule, severity: 'warning' } },
|
||||
{ ...mockAlert, number: 4, rule: { ...mockAlert.rule, severity: 'note' } },
|
||||
];
|
||||
const result = formatAsMarkdown(alerts, 'owner/repo');
|
||||
expect(result).toContain('**Total Alerts:** 4');
|
||||
expect(result).toContain('error');
|
||||
expect(result).toContain('warning');
|
||||
expect(result).toContain('note');
|
||||
// Should have summary table with all three severities
|
||||
expect(result).toContain('## Summary by Severity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAsSARIF', () => {
|
||||
it('should format alerts as valid SARIF with default (medium) detail', () => {
|
||||
const result = formatAsSARIF([mockAlert], 'owner/repo');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toHaveProperty('$schema');
|
||||
expect(parsed).toHaveProperty('version');
|
||||
expect(parsed.runs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should format with minimum detail', () => {
|
||||
const result = formatAsSARIF([mockAlert], 'owner/repo', 'minimum');
|
||||
expect(() => JSON.parse(result)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should format with full detail (includes tool version)', () => {
|
||||
const result = formatAsSARIF([mockAlert], 'owner/repo', 'full');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.runs[0].tool.driver.version).toBe('2.0.0');
|
||||
});
|
||||
|
||||
it('should format with raw detail (returns original JSON)', () => {
|
||||
const result = formatAsSARIF([mockAlert], 'owner/repo', 'raw');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed[0]).toHaveProperty('most_recent_instance');
|
||||
expect(parsed[0]).toHaveProperty('tool');
|
||||
});
|
||||
|
||||
it('should map medium severity to warning level', () => {
|
||||
const mediumAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'medium' } };
|
||||
const result = formatAsSARIF([mediumAlert], 'owner/repo');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.runs[0].results[0].level).toBe('warning');
|
||||
});
|
||||
|
||||
it('should map warning severity to warning level', () => {
|
||||
const warningAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'warning' } };
|
||||
const result = formatAsSARIF([warningAlert], 'owner/repo');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.runs[0].results[0].level).toBe('warning');
|
||||
});
|
||||
|
||||
it('should map unknown severity to note level', () => {
|
||||
const lowAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'low' } };
|
||||
const result = formatAsSARIF([lowAlert], 'owner/repo');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.runs[0].results[0].level).toBe('note');
|
||||
});
|
||||
});
|
||||
});
|
||||
148
src/__tests__/git.test.ts
Normal file
148
src/__tests__/git.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { SimpleGit } from 'simple-git';
|
||||
import simpleGit from 'simple-git';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getGitHubRepoFromRemote, parseGitHubUrl } from '../lib/git.js';
|
||||
|
||||
vi.mock('simple-git');
|
||||
|
||||
describe('parseGitHubUrl', () => {
|
||||
it('should parse HTTPS URL', () => {
|
||||
const result = parseGitHubUrl('https://github.com/owner/repo.git');
|
||||
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('should parse HTTPS URL without .git', () => {
|
||||
const result = parseGitHubUrl('https://github.com/owner/repo');
|
||||
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('should parse SSH URL', () => {
|
||||
const result = parseGitHubUrl('git@github.com:owner/repo.git');
|
||||
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('should parse git:// URL', () => {
|
||||
const result = parseGitHubUrl('git://github.com/owner/repo.git');
|
||||
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('should return null for invalid URL', () => {
|
||||
const result = parseGitHubUrl('not-a-valid-url');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle URLs with hyphens and underscores', () => {
|
||||
const result = parseGitHubUrl('https://github.com/my-org_name/my-repo_name.git');
|
||||
expect(result).toEqual({ owner: 'my-org_name', repo: 'my-repo_name' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitHubRepoFromRemote', () => {
|
||||
it('should extract repo from origin remote', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://github.com/owner/repo.git', push: '' } },
|
||||
]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
const result = await getGitHubRepoFromRemote();
|
||||
|
||||
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
expect(mockGit.getRemotes).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should use first remote if origin not found', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ name: 'upstream', refs: { fetch: 'https://github.com/other/repo.git', push: '' } },
|
||||
]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
const result = await getGitHubRepoFromRemote();
|
||||
|
||||
expect(result).toEqual({ owner: 'other', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('should use push URL if fetch URL not available', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: '', push: 'git@github.com:owner/repo.git' } },
|
||||
]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
const result = await getGitHubRepoFromRemote();
|
||||
|
||||
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('should throw error if no remotes found', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
await expect(getGitHubRepoFromRemote()).rejects.toThrow('No git remotes found');
|
||||
});
|
||||
|
||||
it('should throw error if remote has no valid URL', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi.fn().mockResolvedValue([{ name: 'origin', refs: { fetch: '', push: '' } }]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
await expect(getGitHubRepoFromRemote()).rejects.toThrow('No valid remote URL found');
|
||||
});
|
||||
|
||||
it('should throw error if URL cannot be parsed', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ name: 'origin', refs: { fetch: 'not-a-github-url', push: '' } }]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
await expect(getGitHubRepoFromRemote()).rejects.toThrow('Unable to parse GitHub repository');
|
||||
});
|
||||
|
||||
it('should handle git errors', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi.fn().mockRejectedValue(new Error('Git error')),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
await expect(getGitHubRepoFromRemote()).rejects.toThrow('Git error');
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi.fn().mockRejectedValue('string error'),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
await expect(getGitHubRepoFromRemote()).rejects.toThrow('Failed to get git remote information');
|
||||
});
|
||||
|
||||
it('should pass cwd parameter to simpleGit', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://github.com/owner/repo.git', push: '' } },
|
||||
]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
await getGitHubRepoFromRemote('/custom/path');
|
||||
|
||||
expect(simpleGit).toHaveBeenCalledWith('/custom/path');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user