Initial commit

This commit is contained in:
2025-09-30 22:34:56 +03:00
commit fafd5e89d4
28 changed files with 6481 additions and 0 deletions

View 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
View 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)...');
});
});
});

View 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();
});
});
});

View 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
View 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');
});
});