From f50712d3f91ced68907b79e23c9f91fcf5b025b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 05:13:02 +0000 Subject: [PATCH] test: add CLI and fetch integration tests (closes #65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add test/cli.test.ts covering the CI-gate exit-code path (D/F → exit 1, A+ → exit 0), --json output, --version/-v, --help/-h, missing-URL error, network-error propagation, and --timeout forwarding to fetchHeaders. - Add test/fetch.test.ts covering header lowercasing, GET-method enforcement, AbortSignal wiring, body cancellation, null-body safety, and timeout abort. - Change cli.ts to use top-level `await main()` so dynamic import() resolves only after main() finishes, enabling the vi.resetModules() test pattern. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FvB5dYob9rjdUekNBA4Dvn --- src/cli.ts | 2 +- test/cli.test.ts | 167 +++++++++++++++++++++++++++++++++++++++++++++ test/fetch.test.ts | 103 ++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 test/cli.test.ts create mode 100644 test/fetch.test.ts diff --git a/src/cli.ts b/src/cli.ts index a6d2e8b..ce9ef8e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -99,4 +99,4 @@ async function main() { } } -main(); +await main(); diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..af77734 --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,167 @@ +import { vi, describe, it, expect, afterEach } from 'vitest'; + +// Stable reference defined outside the factory so it survives vi.resetModules(). +// When the module registry is cleared and fetch.js is re-imported, Vitest runs +// the factory again — but the factory closes over this same vi.fn() instance. +const fetchHeadersMock = vi.fn(); + +vi.mock('../src/fetch.js', () => ({ + fetchHeaders: fetchHeadersMock, +})); + +// A fully-configured set of headers → A+ (90/100, 90%) +const A_PLUS_HEADERS: Record = { + 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', + 'content-security-policy': "default-src 'self'; form-action 'self'; base-uri 'self'", + 'x-frame-options': 'DENY', + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'strict-origin-when-cross-origin', + 'permissions-policy': 'camera=(), microphone=(), geolocation=()', + 'cross-origin-embedder-policy': 'require-corp', + 'cross-origin-opener-policy': 'same-origin', + 'cross-origin-resource-policy': 'same-origin', +}; + +// Partial HSTS + nosniff only → 28/100 → D grade +const D_HEADERS: Record = { + 'strict-transport-security': 'max-age=31536000; includeSubDomains', + 'x-content-type-options': 'nosniff', +}; + +/** + * Runs the CLI with the given argv arguments and returns captured + * stdout, stderr, and the exit code passed to process.exit(). + * + * The CLI module uses top-level `await main()`, so the dynamic import + * resolves only after main() has fully finished (or thrown via the mocked + * process.exit). vi.resetModules() ensures main() is re-executed on every + * call — the vi.mock() registration for fetch.js persists across resets. + */ +async function runCli(args: string[]): Promise<{ + stdout: string; + stderr: string; + exitCode: number; +}> { + const savedArgv = process.argv; + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + let exitCode = 0; + + process.argv = ['node', 'cli.js', ...args]; + + const logSpy = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { + stdoutChunks.push(a.map(String).join(' ')); + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { + stderrChunks.push(a.map(String).join(' ')); + }); + vi.spyOn(process, 'exit').mockImplementation((code?: number | string) => { + exitCode = typeof code === 'number' ? code : 0; + // Throwing causes main() to reject, which propagates through the + // top-level `await main()` in cli.ts and rejects the dynamic import. + throw new Error(`process.exit(${exitCode})`); + }); + + vi.resetModules(); + try { + await import('../src/cli.js'); + } catch { + // swallow the throw from the mocked process.exit() + } + + process.argv = savedArgv; + logSpy.mockRestore(); + errorSpy.mockRestore(); + vi.restoreAllMocks(); + + return { + stdout: stdoutChunks.join('\n'), + stderr: stderrChunks.join('\n'), + exitCode, + }; +} + +describe('cli', () => { + afterEach(() => { + fetchHeadersMock.mockReset(); + }); + + it('exits 0 and prints a report for an A+ site', async () => { + fetchHeadersMock.mockResolvedValueOnce(A_PLUS_HEADERS); + const { exitCode, stdout } = await runCli(['https://example.com']); + expect(exitCode).toBe(0); + expect(stdout).toContain('Security Headers Report'); + }); + + it('exits 1 for an F-grade site — CI gate enforced', async () => { + fetchHeadersMock.mockResolvedValueOnce({}); + const { exitCode } = await runCli(['https://bad.example.com']); + expect(exitCode).toBe(1); + }); + + it('exits 1 for a D-grade site — CI gate enforced', async () => { + fetchHeadersMock.mockResolvedValueOnce(D_HEADERS); + const { exitCode } = await runCli(['https://d-grade.example.com']); + expect(exitCode).toBe(1); + }); + + it('--json emits valid JSON with grade, score, and headers array', async () => { + fetchHeadersMock.mockResolvedValueOnce(A_PLUS_HEADERS); + const { stdout, exitCode } = await runCli(['https://example.com', '--json']); + expect(exitCode).toBe(0); + const report = JSON.parse(stdout); + expect(report).toHaveProperty('grade'); + expect(report).toHaveProperty('score'); + expect(Array.isArray(report.headers)).toBe(true); + }); + + it('--json includes the url field', async () => { + fetchHeadersMock.mockResolvedValueOnce(A_PLUS_HEADERS); + const { stdout } = await runCli(['https://example.com', '--json']); + const report = JSON.parse(stdout); + expect(report.url).toBe('https://example.com'); + }); + + it('--version prints a semver string and exits 0', async () => { + const { stdout, exitCode } = await runCli(['--version']); + expect(stdout).toMatch(/^\d+\.\d+\.\d+/); + expect(exitCode).toBe(0); + }); + + it('-v is an alias for --version', async () => { + const { stdout, exitCode } = await runCli(['-v']); + expect(stdout).toMatch(/^\d+\.\d+\.\d+/); + expect(exitCode).toBe(0); + }); + + it('--help prints usage information and exits 0', async () => { + const { stdout, exitCode } = await runCli(['--help']); + expect(stdout).toContain('Usage'); + expect(exitCode).toBe(0); + }); + + it('-h is an alias for --help', async () => { + const { stdout, exitCode } = await runCli(['-h']); + expect(stdout).toContain('Usage'); + expect(exitCode).toBe(0); + }); + + it('missing URL exits 1 with a usage hint on stderr', async () => { + const { stderr, exitCode } = await runCli([]); + expect(exitCode).toBe(1); + expect(stderr).toContain('Usage'); + }); + + it('network error exits 1 with the error message on stderr', async () => { + fetchHeadersMock.mockRejectedValueOnce(new Error('ECONNREFUSED')); + const { stderr, exitCode } = await runCli(['https://unreachable.example.com']); + expect(exitCode).toBe(1); + expect(stderr).toContain('ECONNREFUSED'); + }); + + it('--timeout passes the parsed integer value to fetchHeaders', async () => { + fetchHeadersMock.mockResolvedValueOnce(A_PLUS_HEADERS); + await runCli(['https://example.com', '--timeout', '3000']); + expect(fetchHeadersMock).toHaveBeenCalledWith('https://example.com', { timeoutMs: 3000 }); + }); +}); diff --git a/test/fetch.test.ts b/test/fetch.test.ts new file mode 100644 index 0000000..8d800aa --- /dev/null +++ b/test/fetch.test.ts @@ -0,0 +1,103 @@ +import { vi, describe, it, expect, afterEach } from 'vitest'; +import { fetchHeaders } from '../src/fetch.js'; + +describe('fetchHeaders', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns response headers as lowercase key-value pairs', async () => { + const mockHeaders = new Headers({ + 'Content-Type': 'text/html; charset=utf-8', + 'X-Custom-Header': 'SomeValue', + 'Strict-Transport-Security': 'max-age=31536000', + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + headers: mockHeaders, + body: { cancel: vi.fn() }, + })); + + const result = await fetchHeaders('https://example.com'); + expect(result).toEqual({ + 'content-type': 'text/html; charset=utf-8', + 'x-custom-header': 'SomeValue', + 'strict-transport-security': 'max-age=31536000', + }); + }); + + it('uses GET — not HEAD — to avoid sites that omit CSP on HEAD responses', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce({ + headers: new Headers(), + body: null, + }); + vi.stubGlobal('fetch', fetchFn); + + await fetchHeaders('https://example.com'); + expect(fetchFn).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('passes an AbortSignal to fetch for timeout control', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce({ + headers: new Headers(), + body: null, + }); + vi.stubGlobal('fetch', fetchFn); + + await fetchHeaders('https://example.com'); + expect(fetchFn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('cancels the response body after collecting headers', async () => { + const cancel = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + headers: new Headers({ 'x-test': 'value' }), + body: { cancel }, + })); + + await fetchHeaders('https://example.com'); + expect(cancel).toHaveBeenCalled(); + }); + + it('handles a null body without throwing', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + headers: new Headers(), + body: null, + })); + + await expect(fetchHeaders('https://example.com')).resolves.toEqual({}); + }); + + it('aborts the request after timeoutMs elapses', async () => { + const fetchFn = vi.fn().mockImplementation((_url: string, opts: RequestInit) => { + const signal = opts.signal as AbortSignal; + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted'))); + }); + }); + vi.stubGlobal('fetch', fetchFn); + + // 1 ms timeout — fires almost immediately; the fetch mock never resolves + await expect(fetchHeaders('https://slow.example.com', { timeoutMs: 1 })).rejects.toThrow('aborted'); + }); + + it('clears the abort timer after a successful fetch', async () => { + // If the timer were not cleared, it would fire after the test ends and + // could abort a subsequent request or log a warning. + const cancel = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + headers: new Headers({ 'x-ok': '1' }), + body: { cancel }, + })); + + const result = await fetchHeaders('https://example.com', { timeoutMs: 50 }); + // Waiting past the original timeout should not throw or abort anything + await new Promise(r => setTimeout(r, 60)); + expect(result).toHaveProperty('x-ok', '1'); + }); +});