Skip to content

Commit 9882dab

Browse files
committed
test: add comprehensive tests for shell PATH resolution
Unit tests for shell_env module: - merge_paths: deduplication, ordering, empty inputs, identical paths - get_shell_path: caching (OnceLock), no trailing newline, no empty entries, valid UTF-8, returns standard directories - try_resolve_path_from_shell: login shell, nonexistent shell, invalid flags Unit tests for stdio transport helpers: - resolve_command: with/without shell path, nonexistent, restricted path - inject_shell_path: adds when missing, respects existing, no-op when None - command_hint: Docker vs non-Docker hints - classify_stderr_line: error/warn/debug/info classification Integration tests: - Shell PATH finds system commands (sh, ls, env) - Shell PATH has more entries than minimal default - Child process receives injected shell PATH - User-set PATH override not clobbered by injection - StdioTransport resolves commands via shell PATH Signed-off-by: Claude <noreply@anthropic.com> https://claude.ai/code/session_01EYZVAqzgNBAAtJptw9KRwB Signed-off-by: Claude <noreply@anthropic.com>
1 parent 27ed63a commit 9882dab

3 files changed

Lines changed: 487 additions & 0 deletions

File tree

crates/mcpmux-gateway/src/pool/transport/shell_env.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ fn merge_paths(primary: &str, secondary: &str) -> String {
153153
mod tests {
154154
use super::*;
155155

156+
// ── merge_paths tests ──────────────────────────────────────────
157+
156158
#[cfg(unix)]
157159
#[test]
158160
fn test_merge_paths_deduplicates() {
@@ -181,6 +183,36 @@ mod tests {
181183
assert_eq!(result, "/a:/b");
182184
}
183185

186+
#[cfg(unix)]
187+
#[test]
188+
fn test_merge_paths_empty_primary() {
189+
let result = merge_paths("", "/a:/b");
190+
assert_eq!(result, "/a:/b");
191+
}
192+
193+
#[cfg(unix)]
194+
#[test]
195+
fn test_merge_paths_both_empty() {
196+
let result = merge_paths("", "");
197+
assert_eq!(result, "");
198+
}
199+
200+
#[cfg(unix)]
201+
#[test]
202+
fn test_merge_paths_identical() {
203+
let result = merge_paths("/a:/b:/c", "/a:/b:/c");
204+
assert_eq!(result, "/a:/b:/c");
205+
}
206+
207+
#[cfg(unix)]
208+
#[test]
209+
fn test_merge_paths_many_duplicates() {
210+
let result = merge_paths("/a:/b:/c:/d", "/d:/c:/b:/a:/e");
211+
assert_eq!(result, "/a:/b:/c:/d:/e");
212+
}
213+
214+
// ── get_shell_path tests ───────────────────────────────────────
215+
184216
#[cfg(unix)]
185217
#[test]
186218
fn test_get_shell_path_returns_something() {
@@ -194,4 +226,87 @@ mod tests {
194226
path_str
195227
);
196228
}
229+
230+
#[cfg(unix)]
231+
#[test]
232+
fn test_get_shell_path_is_cached() {
233+
// Calling twice should return the exact same reference (OnceLock)
234+
let first = get_shell_path();
235+
let second = get_shell_path();
236+
assert!(first.is_some());
237+
assert!(second.is_some());
238+
// Same pointer — verifies caching via OnceLock
239+
assert!(std::ptr::eq(first.unwrap(), second.unwrap()));
240+
}
241+
242+
#[cfg(unix)]
243+
#[test]
244+
fn test_get_shell_path_has_no_trailing_newline() {
245+
let path = get_shell_path();
246+
if let Some(p) = path {
247+
let s = p.to_string_lossy();
248+
assert!(
249+
!s.ends_with('\n') && !s.ends_with('\r'),
250+
"PATH should not have trailing newlines: {:?}",
251+
s
252+
);
253+
}
254+
}
255+
256+
#[cfg(unix)]
257+
#[test]
258+
fn test_get_shell_path_entries_are_not_empty() {
259+
let path = get_shell_path();
260+
if let Some(p) = path {
261+
let s = p.to_string_lossy();
262+
for entry in s.split(':') {
263+
assert!(
264+
!entry.is_empty(),
265+
"PATH should not contain empty entries: {:?}",
266+
s
267+
);
268+
}
269+
}
270+
}
271+
272+
#[cfg(unix)]
273+
#[test]
274+
fn test_get_shell_path_is_valid_utf8() {
275+
let path = get_shell_path();
276+
if let Some(p) = path {
277+
assert!(p.to_str().is_some(), "PATH should be valid UTF-8: {:?}", p);
278+
}
279+
}
280+
281+
// ── try_resolve_path_from_shell tests ──────────────────────────
282+
283+
#[cfg(unix)]
284+
#[test]
285+
fn test_try_resolve_shell_with_login_flag() {
286+
// /bin/sh should work with -l -c
287+
let result = try_resolve_path_from_shell("/bin/sh", &["-l", "-c"]);
288+
assert!(result.is_some(), "Should resolve PATH from /bin/sh -l -c");
289+
let path = result.unwrap();
290+
assert!(!path.is_empty(), "PATH should not be empty");
291+
assert!(
292+
path.contains("/bin") || path.contains("/usr"),
293+
"PATH should contain standard dirs: {}",
294+
path
295+
);
296+
}
297+
298+
#[cfg(unix)]
299+
#[test]
300+
fn test_try_resolve_shell_nonexistent_shell() {
301+
let result = try_resolve_path_from_shell("/nonexistent/shell_binary_xyz", &["-l", "-c"]);
302+
assert!(result.is_none(), "Should fail for nonexistent shell");
303+
}
304+
305+
#[cfg(unix)]
306+
#[test]
307+
fn test_try_resolve_shell_invalid_flags() {
308+
// --bogus-flag should cause the shell to error
309+
let result = try_resolve_path_from_shell("/bin/sh", &["--bogus-flag-xyz", "-c"]);
310+
assert!(result.is_none(), "Should fail with invalid shell flags");
311+
}
197312
}

crates/mcpmux-gateway/src/pool/transport/stdio.rs

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,3 +343,186 @@ fn inject_shell_path(env: &mut HashMap<String, String>, shell_path: Option<&std:
343343
}
344344
}
345345
}
346+
347+
#[cfg(test)]
348+
mod tests {
349+
use super::*;
350+
use std::ffi::OsString;
351+
352+
// ── resolve_command tests ──────────────────────────────────────
353+
354+
#[test]
355+
fn test_resolve_command_finds_sh_with_shell_path() {
356+
// /bin/sh exists on every Unix system
357+
#[cfg(unix)]
358+
{
359+
let path = OsString::from("/bin:/usr/bin");
360+
let result = resolve_command("sh", Some(&path));
361+
assert!(result.is_ok(), "Should find 'sh' in /bin:/usr/bin");
362+
}
363+
}
364+
365+
#[test]
366+
fn test_resolve_command_finds_command_without_shell_path() {
367+
// Without shell_path, falls back to which::which (uses process PATH)
368+
#[cfg(unix)]
369+
{
370+
let result = resolve_command("sh", None);
371+
assert!(result.is_ok(), "Should find 'sh' via process PATH");
372+
}
373+
}
374+
375+
#[test]
376+
fn test_resolve_command_returns_error_for_nonexistent() {
377+
let fake_path = OsString::from("/nonexistent/path");
378+
let result = resolve_command("this_command_surely_does_not_exist_xyz", Some(&fake_path));
379+
assert!(result.is_err(), "Should fail for nonexistent command");
380+
}
381+
382+
#[test]
383+
fn test_resolve_command_not_found_in_restricted_path() {
384+
// Even if 'sh' exists, it shouldn't be found if PATH points elsewhere
385+
let path = OsString::from("/tmp/empty_dir_that_does_not_exist");
386+
let result = resolve_command("sh", Some(&path));
387+
assert!(
388+
result.is_err(),
389+
"Should not find 'sh' in a path that doesn't contain it"
390+
);
391+
}
392+
393+
#[cfg(unix)]
394+
#[test]
395+
fn test_resolve_command_with_full_shell_path() {
396+
// Use the actual shell-resolved PATH to find a real command
397+
if let Some(shell_path) = shell_env::get_shell_path() {
398+
let result = resolve_command("sh", Some(shell_path));
399+
assert!(result.is_ok(), "Should find 'sh' using resolved shell PATH");
400+
}
401+
}
402+
403+
// ── inject_shell_path tests ────────────────────────────────────
404+
405+
#[test]
406+
fn test_inject_shell_path_adds_when_missing() {
407+
let mut env = HashMap::new();
408+
env.insert("FOO".to_string(), "bar".to_string());
409+
410+
let path = OsString::from("/usr/bin:/usr/local/bin");
411+
inject_shell_path(&mut env, Some(&path));
412+
413+
assert_eq!(
414+
env.get("PATH"),
415+
Some(&"/usr/bin:/usr/local/bin".to_string()),
416+
"PATH should be injected"
417+
);
418+
assert_eq!(
419+
env.get("FOO"),
420+
Some(&"bar".to_string()),
421+
"Existing vars should be preserved"
422+
);
423+
}
424+
425+
#[test]
426+
fn test_inject_shell_path_respects_existing_path() {
427+
let mut env = HashMap::new();
428+
env.insert("PATH".to_string(), "/custom/path".to_string());
429+
430+
let path = OsString::from("/usr/bin:/usr/local/bin");
431+
inject_shell_path(&mut env, Some(&path));
432+
433+
assert_eq!(
434+
env.get("PATH"),
435+
Some(&"/custom/path".to_string()),
436+
"User-set PATH should not be overridden"
437+
);
438+
}
439+
440+
#[test]
441+
fn test_inject_shell_path_noop_when_none() {
442+
let mut env = HashMap::new();
443+
env.insert("FOO".to_string(), "bar".to_string());
444+
445+
inject_shell_path(&mut env, None);
446+
447+
assert!(
448+
!env.contains_key("PATH"),
449+
"Should not inject PATH when shell_path is None"
450+
);
451+
}
452+
453+
#[test]
454+
fn test_inject_shell_path_empty_env() {
455+
let mut env = HashMap::new();
456+
457+
let path = OsString::from("/a:/b:/c");
458+
inject_shell_path(&mut env, Some(&path));
459+
460+
assert_eq!(env.get("PATH"), Some(&"/a:/b:/c".to_string()));
461+
assert_eq!(env.len(), 1, "Should only have PATH");
462+
}
463+
464+
// ── command_hint tests ─────────────────────────────────────────
465+
466+
#[test]
467+
fn test_command_hint_docker() {
468+
assert!(command_hint("docker").contains("Docker Desktop"));
469+
assert!(command_hint("/usr/local/bin/docker").contains("Docker Desktop"));
470+
}
471+
472+
#[test]
473+
fn test_command_hint_non_docker() {
474+
assert_eq!(command_hint("npx"), "");
475+
assert_eq!(command_hint("node"), "");
476+
assert_eq!(command_hint("python"), "");
477+
}
478+
479+
// ── classify_stderr_line tests ─────────────────────────────────
480+
481+
#[test]
482+
fn test_classify_stderr_error() {
483+
assert_eq!(
484+
classify_stderr_line("ERROR: something failed"),
485+
LogLevel::Error
486+
);
487+
assert_eq!(
488+
classify_stderr_line("fatal: not a git repository"),
489+
LogLevel::Error
490+
);
491+
assert_eq!(
492+
classify_stderr_line("thread 'main' panicked"),
493+
LogLevel::Error
494+
);
495+
}
496+
497+
#[test]
498+
fn test_classify_stderr_warn() {
499+
assert_eq!(
500+
classify_stderr_line("WARN: deprecated feature"),
501+
LogLevel::Warn
502+
);
503+
assert_eq!(
504+
classify_stderr_line("Warning: something is off"),
505+
LogLevel::Warn
506+
);
507+
}
508+
509+
#[test]
510+
fn test_classify_stderr_debug() {
511+
assert_eq!(
512+
classify_stderr_line("DEBUG: internal state"),
513+
LogLevel::Debug
514+
);
515+
assert_eq!(
516+
classify_stderr_line("trace: verbose output"),
517+
LogLevel::Debug
518+
);
519+
}
520+
521+
#[test]
522+
fn test_classify_stderr_info_default() {
523+
assert_eq!(
524+
classify_stderr_line("Server listening on port 3000"),
525+
LogLevel::Info
526+
);
527+
}
528+
}

0 commit comments

Comments
 (0)