use assert_cmd::Command;
use predicates::prelude::*;
fn apexe() -> Command {
let home = tempfile::tempdir().unwrap().keep();
let mut cmd = Command::cargo_bin("apexe").unwrap();
cmd.env("HOME", home);
cmd
}
#[test]
fn test_help_shows_subcommands() {
apexe()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("scan"))
.stdout(predicate::str::contains("serve"))
.stdout(predicate::str::contains("a2a"))
.stdout(predicate::str::contains("list"))
.stdout(predicate::str::contains("config"));
}
#[test]
fn test_version_flag() {
apexe()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains("apexe"));
}
#[test]
fn test_scan_help_shows_expected_flags() {
apexe()
.args(["scan", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("TOOLS"))
.stdout(predicate::str::contains("--output-dir"))
.stdout(predicate::str::contains("--depth"))
.stdout(predicate::str::contains("--no-cache"))
.stdout(predicate::str::contains("--format"));
}
#[test]
fn test_serve_help_shows_expected_flags() {
apexe()
.args(["serve", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--transport"))
.stdout(predicate::str::contains("--host"))
.stdout(predicate::str::contains("--port"))
.stdout(predicate::str::contains("--explorer"));
}
#[test]
fn test_a2a_help_shows_expected_flags() {
apexe()
.args(["a2a", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--url"))
.stdout(predicate::str::contains("--modules-dir"))
.stdout(predicate::str::contains("--acl"))
.stdout(predicate::str::contains("--explorer"))
.stdout(predicate::str::contains("--enable-approval").not());
}
#[test]
fn test_scan_no_args_fails() {
apexe().arg("scan").assert().failure().code(2);
}
#[test]
fn test_config_show_succeeds() {
apexe()
.args(["config", "--show"])
.assert()
.success()
.stdout(predicate::str::contains("modules_dir"))
.stdout(predicate::str::contains("log_level"));
}
#[test]
fn test_config_no_flags_succeeds() {
apexe().arg("config").assert().success();
}
#[test]
fn test_scan_writes_bindings_for_the_tools_that_succeeded() {
let out = tempfile::tempdir().unwrap();
let assert = apexe()
.args(["scan", "echo", "zzz_no_such_tool_xyz", "ls"])
.args(["--no-cache", "--output-dir"])
.arg(out.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr).to_string();
assert!(
stderr.contains("zzz_no_such_tool_xyz"),
"the failing tool must be named: {stderr}"
);
assert!(
stderr.contains("Scanned 2 of 3 tools"),
"the message must state both halves: {stderr}"
);
let written: Vec<String> = std::fs::read_dir(out.path())
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string()))
.collect();
assert!(
written.iter().any(|n| n.contains("echo")),
"bindings written: {written:?}"
);
assert!(
written.iter().any(|n| n.contains("ls")),
"the tool after the failure must still be scanned: {written:?}"
);
}
fn show_config(extra: &[&str]) -> serde_json::Value {
let output = apexe()
.args(["serve", "--show-config"])
.args(extra)
.assert()
.success();
let stdout = String::from_utf8_lossy(&output.get_output().stdout).to_string();
serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("stdout is not JSON ({e}): {stdout}"))
}
fn args_of(config: &serde_json::Value, name: &str) -> Vec<String> {
config["mcpServers"][name]["args"]
.as_array()
.unwrap_or_else(|| panic!("no args in {config}"))
.iter()
.map(|v| v.as_str().unwrap_or_default().to_string())
.collect()
}
#[test]
fn test_show_config_stdio_carries_every_surface_flag() {
let config = show_config(&[
"claude-desktop",
"--modules-dir",
"/srv/apexe/modules",
"--prefix",
"cli.git",
"--tags",
"readonly",
"--acl",
"/etc/apexe/acl.yaml",
"--enable-approval",
]);
let args = args_of(&config, "apexe");
assert_eq!(config["mcpServers"]["apexe"]["command"], "apexe");
for pair in [
["--modules-dir", "/srv/apexe/modules"],
["--prefix", "cli.git"],
["--tags", "readonly"],
["--acl", "/etc/apexe/acl.yaml"],
] {
assert!(
args.windows(2).any(|w| w == pair),
"{pair:?} missing from {args:?}"
);
}
assert!(args.iter().any(|a| a == "--enable-approval"));
}
#[test]
fn test_show_config_cursor_honours_http_transport() {
let config = show_config(&["cursor", "--transport", "http", "--port", "9111"]);
assert_eq!(
config["mcpServers"]["apexe"]["url"],
"http://127.0.0.1:9111/mcp"
);
assert!(config["mcpServers"]["apexe"]["command"].is_null());
}
#[test]
fn test_show_config_unknown_format_fails_without_writing_stdout() {
let assert = apexe()
.args(["serve", "--show-config", "vscode"])
.assert()
.failure();
let output = assert.get_output();
assert!(
output.stdout.is_empty(),
"nothing may reach stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("vscode"), "stderr: {stderr}");
assert!(stderr.contains("claude-desktop"), "stderr: {stderr}");
}
#[test]
fn test_show_config_never_echoes_credentials() {
for extra in [
vec![
"claude-desktop",
"--transport",
"http",
"--auth",
"token",
"--auth-token",
"s3cret-bearer-value",
],
vec![
"cursor",
"--auth-token",
"s3cret-bearer-value",
"--jwt-secret",
"s3cret-signing-key",
],
] {
let rendered = show_config(&extra).to_string();
for forbidden in ["s3cret-bearer-value", "s3cret-signing-key", "--auth-token"] {
assert!(
!rendered.contains(forbidden),
"{extra:?} leaked {forbidden}: {rendered}"
);
}
}
}
#[test]
fn test_serve_rejects_removed_skip_validation_flag() {
apexe()
.args(["serve", "--skip-validation"])
.assert()
.failure()
.code(2);
}
#[test]
fn test_manual_acl_example_denies_the_destructive_module() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("acl.yaml");
std::fs::write(
&path,
"default_effect: allow\n\
rules:\n\
\x20 - callers: [\"*\"]\n\
\x20 targets: [\"cli.git.status\", \"cli.git.log\", \"cli.git.diff\"]\n\
\x20 effect: allow\n\
\x20 description: \"Auto-allow readonly git commands\"\n\
\x20 - callers: [\"*\"]\n\
\x20 targets: [\"cli.git.push\"]\n\
\x20 effect: deny\n\
\x20 description: \"Block destructive git commands\"\n",
)
.unwrap();
let acl = apexe::governance::AclManager::from_config(&path)
.expect("the manual's example must be a loadable ACL")
.into_inner();
assert!(
!acl.check(None, "cli.git.push", None),
"the manual's deny rule must deny without relying on default_effect"
);
assert!(
acl.check(None, "cli.git.status", None),
"the manual's allow rule must still allow readonly commands"
);
}
#[test]
fn test_manual_acl_example_carries_no_unregistered_condition() {
let manual = include_str!("../docs/user-manual.md");
for line in manual.lines() {
assert!(
!line.trim_start().starts_with("require_approval:"),
"docs/user-manual.md still shows `require_approval` as an ACL \
condition; apcore registers only identity_types, roles, \
max_call_depth, $or and $not, so such a rule can never match: {line}"
);
}
}
#[test]
fn test_scan_fails_outright_when_no_tool_can_be_scanned() {
let out = tempfile::tempdir().unwrap();
apexe()
.args(["scan", "zzz_no_such_tool_xyz", "zzz_also_missing_xyz"])
.args(["--no-cache", "--output-dir"])
.arg(out.path())
.assert()
.failure()
.stderr(predicate::str::contains("No tool could be scanned"));
}
#[test]
fn test_a_non_zero_exit_is_not_reported_as_an_mcp_error() {
let tmp = tempfile::tempdir().unwrap();
let modules = tmp.path().join("modules");
std::fs::create_dir_all(&modules).unwrap();
std::fs::write(
modules.join("cli.false.binding.yaml"),
"spec_version: '1.0'\n\
bindings:\n\
- module_id: cli.false\n\
\x20 target: exec:///usr/bin/false\n\
\x20 description: Always exit non-zero\n\
\x20 version: '1.0.0'\n\
\x20 tags: [cli]\n\
\x20 input_schema:\n\
\x20 type: object\n\
\x20 properties: {}\n\
\x20 additionalProperties: false\n\
\x20 output_schema:\n\
\x20 type: object\n",
)
.unwrap();
let session = concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#,
"\n",
r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
"\n",
r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"cli.false","arguments":{}}}"#,
"\n",
);
let output = apexe()
.args([
"serve",
"--transport",
"stdio",
"--modules-dir",
modules.to_str().unwrap(),
])
.write_stdin(session)
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("MCP responses are UTF-8");
let call: serde_json::Value = stdout
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.find(|message| message["id"] == 5)
.expect("the tools/call response must arrive");
let result = &call["result"];
assert_eq!(
result["isError"], false,
"a command that ran and exited non-zero is a successful call: {result}"
);
let text = result["content"][0]["text"]
.as_str()
.expect("the payload rides in content[0].text");
let payload: serde_json::Value =
serde_json::from_str(text).expect("content[0].text is a JSON document");
assert_eq!(
payload["exit_code"], 1,
"the non-zero exit must be readable where the manual says it is: {payload}"
);
assert!(
payload.get("ai_guidance").is_some(),
"a non-zero exit must carry guidance a caller can self-correct from: {payload}"
);
}
#[test]
fn test_man_flag_emits_a_man_page() {
let output = apexe()
.arg("--man")
.assert()
.success()
.get_output()
.stdout
.clone();
let man = String::from_utf8(output).expect("a man page is UTF-8");
assert!(
man.contains(".TH"),
"roff output must carry a title header: {}",
&man[..man.len().min(200)]
);
assert!(man.contains("apexe"), "the page must name the program");
}
#[test]
fn test_the_explorer_prefills_only_required_keys() {
let port = 8_931;
let home = tempfile::tempdir().unwrap().keep();
let mut server = std::process::Command::new(env!("CARGO_BIN_EXE_apexe"))
.env("HOME", home)
.args([
"serve",
"--transport",
"http",
"--port",
&port.to_string(),
"--explorer",
"--auth",
"none",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("the server binary starts");
let url = format!("http://127.0.0.1:{port}/explorer");
let html = (0..80)
.find_map(|_| {
std::thread::sleep(std::time::Duration::from_millis(100));
std::process::Command::new("curl")
.args(["-sf", &url])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
})
.unwrap_or_default();
let _ = server.kill();
let _ = server.wait();
assert!(
html.contains("function defaultFromSchema"),
"the served page must carry the Try-It prefill"
);
assert!(
html.contains("var required = schema.required;"),
"the prefill must read `required` rather than every property"
);
for fabricated in [
"result[key] = '';",
"result[key] = 0;",
"result[key] = false;",
"result[key] = [];",
"result[key] = {};",
] {
assert!(
!html.contains(fabricated),
"the served prefill fabricates a type-based value again: {fabricated}"
);
}
}
#[test]
fn test_scan_dry_run_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("modules");
let skills = tmp.path().join("skills");
let output = apexe()
.args([
"scan",
"ls",
"--output-dir",
out.to_str().unwrap(),
"--skills-dir",
skills.to_str().unwrap(),
"--dry-run",
])
.assert()
.success()
.get_output()
.clone();
let stderr = String::from_utf8(output.stderr).expect("stderr is UTF-8");
assert!(
stderr.contains("Would write binding"),
"a dry run must still name the bindings it skipped: {stderr}"
);
assert!(
stderr.contains("Would write ACL policy"),
"the ACL is a deliverable too: {stderr}"
);
let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8");
assert!(
!stdout.contains("Would write"),
"dry-run progress messages must not land on stdout, apexe's \
machine-readable channel: {stdout}"
);
let created = |dir: &std::path::Path| -> usize { walk_files(dir).len() };
assert_eq!(created(&out), 0, "no binding may be written");
assert_eq!(created(&skills), 0, "no skill may be written");
}
#[test]
fn test_dry_run_progress_messages_do_not_pollute_json_stdout() {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("modules");
let stdout = apexe()
.args([
"scan",
"ls",
"--output-dir",
out.to_str().unwrap(),
"--dry-run",
"--format",
"json",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let report = String::from_utf8(stdout).expect("stdout is UTF-8");
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&report);
assert!(
parsed.is_ok(),
"stdout must be valid JSON under --dry-run --format json, got: {report}\nerror: {:?}",
parsed.err()
);
}
fn walk_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut found = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
found.extend(walk_files(&path));
} else {
found.push(path);
}
}
found
}
#[test]
fn test_scan_without_dry_run_writes_the_binding() {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("modules");
apexe()
.args(["scan", "ls", "--output-dir", out.to_str().unwrap()])
.assert()
.success();
assert!(
!walk_files(&out).is_empty(),
"the same scan without --dry-run must produce a binding"
);
}
#[test]
fn test_explorer_on_stdio_warns_that_it_does_nothing() {
let session = concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#,
"\n",
);
let stderr = apexe()
.args(["serve", "--transport", "stdio", "--explorer"])
.write_stdin(session)
.assert()
.success()
.get_output()
.stderr
.clone();
let log = String::from_utf8_lossy(&stderr).into_owned();
assert!(
log.contains("--explorer has no effect on stdio"),
"the flag must say it does nothing here: {log}"
);
}