Skip to content

Commit 0e57d57

Browse files
committed
feat(tests): enhance integration and unit tests for error handling and TLS config
1 parent 3eeae8c commit 0e57d57

7 files changed

Lines changed: 198 additions & 24 deletions

File tree

__tests__/integration/index.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,20 @@ async function runTests() {
193193
}, results);
194194

195195
await testFunction('Importing index.ts does not start the CLI server', () => {
196+
// When this suite runs under a debugger (e.g. VS Code's JavaScript Debug
197+
// Terminal / auto-attach), Node injects the inspector into child processes
198+
// and prints banner lines like "Debugger attached." to stderr. That has
199+
// nothing to do with the program under test, so:
200+
// 1. strip the inspector env vars so the child never attaches, and
201+
// 2. filter any residual debugger banner lines from the captured output.
202+
const { NODE_OPTIONS: _n, VSCODE_INSPECTOR_OPTIONS: _v, ...cleanEnv } = process.env;
203+
204+
const stripDebuggerNoise = (output: string): string =>
205+
output
206+
.split('\n')
207+
.filter(line => !/^(Debugger attached\.|Waiting for the debugger to disconnect\.\.\.|Debugger listening on |For help, see: https:\/\/nodejs\.org\/en\/docs\/inspector)/.test(line))
208+
.join('\n');
209+
196210
const result = spawnSync(
197211
process.execPath,
198212
[
@@ -204,7 +218,7 @@ async function runTests() {
204218
{
205219
cwd: process.cwd(),
206220
env: {
207-
...process.env,
221+
...cleanEnv,
208222
MCP_HTTP_PORT: '',
209223
SEARXNG_URL: '',
210224
},
@@ -214,8 +228,8 @@ async function runTests() {
214228
);
215229

216230
assert.equal(result.status, 0, `Import process failed: ${result.stderr}`);
217-
assert.equal(result.stdout, '');
218-
assert.equal(result.stderr, '');
231+
assert.equal(stripDebuggerNoise(result.stdout), '');
232+
assert.equal(stripDebuggerNoise(result.stderr), '');
219233
}, results);
220234

221235
await testFunction('Running cli.js responds to MCP initialize', () => {

__tests__/unit/error-handler.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import {
2222
createTimeoutError,
2323
createEmptyContentWarning,
2424
createUnexpectedError,
25-
validateEnvironment
25+
validateEnvironment,
26+
handleUncaughtException,
27+
handleUnhandledRejection
2628
} from '../../src/error-handler.js';
2729
import { testFunction, createTestResults, printTestSummary } from '../helpers/test-utils.js';
2830
import { EnvManager } from '../helpers/env-utils.js';
@@ -229,6 +231,45 @@ async function runTests() {
229231
assert.ok(result.message.includes('DEPTH_ZERO_SELF_SIGNED_CERT'), `Expected code in message, got: ${result.message}`);
230232
}, results);
231233

234+
// --- Process-level crash handlers ---
235+
// process.exit / console.error are stubbed so the handlers can be exercised
236+
// without killing the test process or printing to the real console.
237+
function captureExitAndError(fn: () => void): { exitCode: number | undefined; calls: unknown[][] } {
238+
const originalExit = process.exit;
239+
const originalError = console.error;
240+
let exitCode: number | undefined;
241+
const calls: unknown[][] = [];
242+
process.exit = ((code?: number) => { exitCode = code; }) as unknown as typeof process.exit;
243+
console.error = (...args: unknown[]) => { calls.push(args); };
244+
try {
245+
fn();
246+
} finally {
247+
process.exit = originalExit;
248+
console.error = originalError;
249+
}
250+
return { exitCode, calls };
251+
}
252+
253+
await testFunction('handleUncaughtException logs the error and exits with code 1', () => {
254+
const err = new Error('boom');
255+
const { exitCode, calls } = captureExitAndError(() => handleUncaughtException(err));
256+
assert.equal(exitCode, 1);
257+
assert.equal(calls.length, 1);
258+
assert.equal(calls[0][0], 'Uncaught Exception:');
259+
assert.equal(calls[0][1], err);
260+
}, results);
261+
262+
await testFunction('handleUnhandledRejection logs the reason/promise and exits with code 1', () => {
263+
const reason = new Error('nope');
264+
const promise = Promise.reject(reason);
265+
promise.catch(() => {}); // settle it so the test process sees no real unhandled rejection
266+
const { exitCode, calls } = captureExitAndError(() => handleUnhandledRejection(reason, promise));
267+
assert.equal(exitCode, 1);
268+
assert.equal(calls.length, 1);
269+
assert.equal(calls[0][0], 'Unhandled Rejection at:');
270+
assert.equal(calls[0][3], reason);
271+
}, results);
272+
232273
printTestSummary(results, 'Error Handler Module');
233274
return results;
234275
}

__tests__/unit/tls-config.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,27 @@
33
/**
44
* Unit Tests: tls-config.ts
55
*
6-
* Tests for system CA certificate loading
6+
* Tests for system CA certificate loading.
7+
*
8+
* The real-system tests below exercise the default (no-dependency) code path,
9+
* while the injected-dependency tests deterministically cover every branch —
10+
* including the Windows and unreadable-bundle paths — on any host/OS.
711
*/
812

913
import { strict as assert } from 'node:assert';
1014
import { fileURLToPath } from 'node:url';
1115
import { getSystemCACerts, getConnectOptions } from '../../src/tls-config.js';
1216
import { testFunction, createTestResults, printTestSummary } from '../helpers/test-utils.js';
1317

18+
const PEM = '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n';
19+
1420
const results = createTestResults();
1521

1622
async function runTests() {
1723
console.log('🧪 Testing: tls-config.ts\n');
1824

25+
// --- Real-system path (covers the default platform/fs dependencies) ---
26+
1927
await testFunction('getSystemCACerts returns string or null', () => {
2028
const certs = getSystemCACerts();
2129
assert.ok(certs === null || typeof certs === 'string');
@@ -56,6 +64,86 @@ async function runTests() {
5664
}
5765
}, results);
5866

67+
// --- Injected dependencies: deterministic branch coverage ---
68+
69+
await testFunction('getSystemCACerts returns null on win32 without touching the filesystem', () => {
70+
let touched = false;
71+
const certs = getSystemCACerts({
72+
platformName: 'win32',
73+
fileExists: () => { touched = true; return true; },
74+
readFile: () => { touched = true; return PEM; },
75+
caPaths: ['/should/not/be/read'],
76+
});
77+
assert.equal(certs, null);
78+
assert.equal(touched, false, 'win32 short-circuits before any fs access');
79+
}, results);
80+
81+
await testFunction('getSystemCACerts returns the first readable bundle', () => {
82+
const reads: string[] = [];
83+
const certs = getSystemCACerts({
84+
platformName: 'linux',
85+
fileExists: () => true,
86+
readFile: (p) => { reads.push(p); return PEM; },
87+
caPaths: ['/etc/ssl/first.crt', '/etc/ssl/second.crt'],
88+
});
89+
assert.equal(certs, PEM);
90+
assert.deepEqual(reads, ['/etc/ssl/first.crt'], 'stops at the first readable bundle');
91+
}, results);
92+
93+
await testFunction('getSystemCACerts skips an existing-but-unreadable bundle and tries the next', () => {
94+
const certs = getSystemCACerts({
95+
platformName: 'linux',
96+
fileExists: () => true,
97+
readFile: (p) => {
98+
if (p === '/etc/ssl/locked.crt') {
99+
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
100+
}
101+
return PEM;
102+
},
103+
caPaths: ['/etc/ssl/locked.crt', '/etc/ssl/readable.crt'],
104+
});
105+
assert.equal(certs, PEM, 'falls through the unreadable path to the readable one');
106+
}, results);
107+
108+
await testFunction('getSystemCACerts returns null when no candidate path exists', () => {
109+
const certs = getSystemCACerts({
110+
platformName: 'linux',
111+
fileExists: () => false,
112+
readFile: () => { throw new Error('should not be called'); },
113+
caPaths: ['/nope/a.crt', '/nope/b.crt'],
114+
});
115+
assert.equal(certs, null);
116+
}, results);
117+
118+
await testFunction('getSystemCACerts returns null when every bundle is unreadable', () => {
119+
const certs = getSystemCACerts({
120+
platformName: 'linux',
121+
fileExists: () => true,
122+
readFile: () => { throw new Error('EACCES'); },
123+
caPaths: ['/etc/ssl/a.crt', '/etc/ssl/b.crt'],
124+
});
125+
assert.equal(certs, null);
126+
}, results);
127+
128+
await testFunction('getConnectOptions wraps the CA bundle when one is found', () => {
129+
const opts = getConnectOptions({
130+
platformName: 'linux',
131+
fileExists: () => true,
132+
readFile: () => PEM,
133+
caPaths: ['/etc/ssl/found.crt'],
134+
});
135+
assert.deepEqual(opts, { ca: PEM });
136+
}, results);
137+
138+
await testFunction('getConnectOptions returns empty object when no CA bundle is found', () => {
139+
const opts = getConnectOptions({
140+
platformName: 'linux',
141+
fileExists: () => false,
142+
caPaths: ['/nope.crt'],
143+
});
144+
assert.deepEqual(opts, {});
145+
}, results);
146+
59147
printTestSummary(results, 'TLS Config Module');
60148
return results;
61149
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"build": "tsc && shx chmod +x dist/*.js",
3939
"watch": "tsc --watch",
4040
"test": "cross-env SEARXNG_URL=https://test-searx.example.com tsx __tests__/run-all.ts",
41-
"test:coverage": "cross-env SEARXNG_URL=https://test-searx.example.com c8 --reporter=text --exclude 'dist/**' --check-coverage --lines 80 tsx __tests__/run-all.ts",
41+
"test:coverage": "cross-env SEARXNG_URL=https://test-searx.example.com c8 --reporter=text --include 'src/**' --check-coverage --lines 90 --branches 85 tsx __tests__/run-all.ts",
4242
"test:e2e": "node --env-file-if-exists=.env.e2e node_modules/.bin/tsx __tests__/e2e/run-e2e.ts",
4343
"bootstrap": "npm install && npm run build",
4444
"inspector": "DANGEROUSLY_OMIT_AUTH=true npx @modelcontextprotocol/inspector node dist/cli.js",

src/cli.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
#!/usr/bin/env node
22

33
import { main } from "./index.js";
4+
import { handleUncaughtException, handleUnhandledRejection } from "./error-handler.js";
45

5-
process.on('uncaughtException', (error) => {
6-
console.error('Uncaught Exception:', error);
7-
process.exit(1);
8-
});
9-
10-
process.on('unhandledRejection', (reason, promise) => {
11-
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
12-
process.exit(1);
13-
});
6+
process.on('uncaughtException', handleUncaughtException);
7+
process.on('unhandledRejection', handleUnhandledRejection);
148

159
main().catch((error) => {
1610
console.error("Failed to start server:", error);

src/error-handler.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,23 @@ export function createUnexpectedError(error: any, context: ErrorContext): MCPSea
154154
return new MCPSearXNGError(`❓ Unexpected Error: ${error.message || String(error)}`);
155155
}
156156

157+
/**
158+
* Process-level crash handlers, registered by the CLI entrypoint (cli.ts).
159+
*
160+
* Extracted here so the logic is unit-testable: cli.ts calls main() at import
161+
* time (it must always start the server — see issue #91), so it cannot be
162+
* imported to test these in place.
163+
*/
164+
export function handleUncaughtException(error: unknown): void {
165+
console.error('Uncaught Exception:', error);
166+
process.exit(1);
167+
}
168+
169+
export function handleUnhandledRejection(reason: unknown, promise: Promise<unknown>): void {
170+
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
171+
process.exit(1);
172+
}
173+
157174
export function validateEnvironment(): string | null {
158175
const issues: string[] = [];
159176

src/tls-config.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,44 @@ const CA_BUNDLE_PATHS = [
1212
"/etc/ssl/cert.pem", // Alpine, macOS
1313
];
1414

15+
/**
16+
* Injectable dependencies for {@link getSystemCACerts}.
17+
*
18+
* These exist purely as a test seam: production callers pass nothing and the
19+
* real platform / filesystem are used. Tests override them to exercise branches
20+
* (e.g. Windows, unreadable bundles) deterministically on any host.
21+
*/
22+
export interface CACertDeps {
23+
platformName?: NodeJS.Platform;
24+
fileExists?: (path: string) => boolean;
25+
readFile?: (path: string) => string;
26+
caPaths?: readonly string[];
27+
}
28+
1529
/**
1630
* Reads system CA certificates from well-known bundle paths.
1731
* Returns null on Windows (no universal file path) or if no bundle is found.
1832
*
1933
* On Windows, users should set NODE_EXTRA_CA_CERTS pointing to a PEM file.
2034
*/
21-
export function getSystemCACerts(): string | null {
35+
export function getSystemCACerts(deps: CACertDeps = {}): string | null {
36+
const {
37+
platformName = platform,
38+
fileExists = existsSync,
39+
// eslint-disable-next-line security/detect-non-literal-fs-filename
40+
readFile = (path: string) => readFileSync(path, "utf8"),
41+
caPaths = CA_BUNDLE_PATHS,
42+
} = deps;
43+
2244
// Windows has no universal CA bundle path; skip auto-detection
23-
if (platform === "win32") {
45+
if (platformName === "win32") {
2446
return null;
2547
}
2648

27-
for (const caPath of CA_BUNDLE_PATHS) {
28-
// eslint-disable-next-line security/detect-non-literal-fs-filename
29-
if (existsSync(caPath)) {
49+
for (const caPath of caPaths) {
50+
if (fileExists(caPath)) {
3051
try {
31-
// eslint-disable-next-line security/detect-non-literal-fs-filename
32-
return readFileSync(caPath, "utf8");
52+
return readFile(caPath);
3353
} catch {
3454
// File exists but is unreadable (permissions); try next
3555
continue;
@@ -49,7 +69,7 @@ export function getSystemCACerts(): string | null {
4969
* new Agent({ connect: getConnectOptions() })
5070
* new ProxyAgent({ uri: proxyUrl, connect: getConnectOptions() })
5171
*/
52-
export function getConnectOptions(): { ca: string } | Record<string, never> {
53-
const ca = getSystemCACerts();
72+
export function getConnectOptions(deps: CACertDeps = {}): { ca: string } | Record<string, never> {
73+
const ca = getSystemCACerts(deps);
5474
return ca !== null ? { ca } : {};
5575
}

0 commit comments

Comments
 (0)