use clap::Parser;
use forge_guard::cli::{Cli, Commands, SecurityAction, SharedFlags};
fn assert_shared_defaults(
chain: &str,
project: &std::path::Path,
json: bool,
markdown: bool,
strict: bool,
offline: bool,
production: bool,
report: bool,
parallelism: usize,
) {
assert_eq!(chain, "ethereum", "default chain should be ethereum");
assert_eq!(
project.to_string_lossy(),
".",
"default project should be ."
);
assert!(!json, "json should default to false");
assert!(!markdown, "markdown should default to false");
assert!(!strict, "strict should default to false");
assert!(!offline, "offline should default to false");
assert!(!production, "production should default to false");
assert!(!report, "report should default to false");
assert_eq!(parallelism, 4, "parallelism should default to 4");
}
fn parse_shared(
cli: &Cli,
) -> (
&str,
&std::path::Path,
bool,
bool,
bool,
bool,
bool,
bool,
usize,
) {
let args = match &cli.command {
Commands::Audit(a) => &a.shared,
Commands::Deploy(a) => &a.shared,
Commands::DeploySafe(a) => &a.shared,
Commands::Fuzz(a) => &a.shared,
Commands::Invariant(a) => &a.shared,
Commands::Simulate(a) => &a.shared,
Commands::Gas(a) => &a.shared,
Commands::Report(a) => &a.shared,
Commands::Verify(a) => &a.shared,
Commands::Doctor(a) => &a.shared,
Commands::Watch(a) => &a.shared,
Commands::Dashboard(a) => &a.shared,
Commands::Ci(a) => &a.shared,
Commands::Benchmark(a) => &a.shared,
Commands::Scan(a) => &a.shared,
Commands::UpgradeCheck(a) => &a.shared,
Commands::Plugins(a) => &a.shared,
Commands::Chain(a) => &a.shared,
Commands::Sbom(a) => &a.shared,
Commands::Security(a) => &a.shared,
Commands::InstallHook(_) | Commands::Import(_) | Commands::Notify(_) => {
static DEFAULT_FLAGS: std::sync::OnceLock<SharedFlags> = std::sync::OnceLock::new();
DEFAULT_FLAGS.get_or_init(SharedFlags::default)
}
};
(
&args.chain,
&args.project,
args.json,
args.markdown,
args.strict,
args.offline,
args.production,
args.report,
args.parallelism,
)
}
#[test]
fn test_cli_parse_audit() {
let cli = Cli::try_parse_from(&["forge-guard", "audit"]).unwrap();
assert!(matches!(cli.command, Commands::Audit(_)));
}
#[test]
fn test_cli_parse_audit_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"audit",
"--full",
"--quick",
"--summary",
"--ai",
"--ai-provider",
"claude",
"--ai-model",
"claude-5-opus-20260701",
"--ai-api-key",
"sk-test",
"--ollama-endpoint",
"http://localhost:11434",
"--ai-full",
"--exploit",
"--gas",
"--all-chains",
"--sources",
"src,contracts",
"--exclude",
"test,mock",
"--chain",
"polygon",
"--project",
"/tmp/test",
"--json",
"--strict",
"--offline",
"--production",
"--report",
"--parallelism",
"8",
])
.unwrap();
match cli.command {
Commands::Audit(args) => {
assert!(args.full);
assert!(args.quick);
assert!(args.summary);
assert!(args.ai);
assert_eq!(args.ai_provider, "claude");
assert_eq!(args.ai_model, "claude-5-opus-20260701");
assert_eq!(args.ai_api_key, Some("sk-test".into()));
assert_eq!(args.ollama_endpoint, Some("http://localhost:11434".into()));
assert!(args.ai_full);
assert!(args.exploit);
assert!(args.gas);
assert!(args.all_chains);
assert_eq!(args.sources, "src,contracts");
assert_eq!(args.exclude, Some("test,mock".into()));
assert_eq!(args.shared.chain, "polygon");
assert!(args.shared.json);
assert!(args.shared.strict);
assert!(args.shared.offline);
assert!(args.shared.production);
assert!(args.shared.report);
assert_eq!(args.shared.parallelism, 8);
}
_ => panic!("Expected Audit command"),
}
}
#[test]
fn test_cli_parse_audit_defaults() {
let cli = Cli::try_parse_from(&["forge-guard", "audit"]).unwrap();
match &cli.command {
Commands::Audit(args) => {
assert!(!args.full);
assert!(!args.quick);
assert!(!args.summary);
assert!(!args.ai);
assert_eq!(args.ai_provider, "openai");
assert_eq!(args.ai_model, "gpt-5");
assert!(args.ai_api_key.is_none());
assert!(args.ollama_endpoint.is_none());
assert!(!args.ai_full);
assert!(!args.exploit);
assert!(!args.gas);
assert!(!args.all_chains);
assert_eq!(args.sources, "src");
assert!(args.exclude.is_none());
let (c, p, j, m, s, o, pr, r, pl) = parse_shared(&cli);
assert_shared_defaults(c, p, j, m, s, o, pr, r, pl);
}
_ => panic!("Expected Audit command"),
}
}
#[test]
fn test_cli_parse_deploy() {
let cli = Cli::try_parse_from(&["forge-guard", "deploy"]).unwrap();
assert!(matches!(cli.command, Commands::Deploy(_)));
}
#[test]
fn test_cli_parse_deploy_with_contract() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"deploy",
"MyContract",
"--force",
"--args",
"0x1234,100",
"--salt",
"0xabcd",
"--verify",
"--chain",
"polygon",
])
.unwrap();
match cli.command {
Commands::Deploy(args) => {
assert_eq!(args.contract, Some("MyContract".into()));
assert!(args.force);
assert_eq!(args.args, Some("0x1234,100".into()));
assert_eq!(args.salt, Some("0xabcd".into()));
assert!(args.verify);
assert_eq!(args.shared.chain, "polygon");
}
_ => panic!("Expected Deploy command"),
}
}
#[test]
fn test_cli_parse_deploy_defaults() {
let cli = Cli::try_parse_from(&["forge-guard", "deploy"]).unwrap();
match &cli.command {
Commands::Deploy(args) => {
assert!(args.contract.is_none());
assert!(!args.force);
assert!(args.args.is_none());
assert!(args.salt.is_none());
assert!(!args.verify);
let (c, p, j, m, s, o, pr, r, pl) = parse_shared(&cli);
assert_shared_defaults(c, p, j, m, s, o, pr, r, pl);
}
_ => panic!("Expected Deploy command"),
}
}
#[test]
fn test_cli_parse_deploy_safe() {
let cli = Cli::try_parse_from(&["forge-guard", "deploy-safe"]).unwrap();
assert!(matches!(cli.command, Commands::DeploySafe(_)));
}
#[test]
fn test_cli_parse_deploy_safe_with_contract() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"deploy-safe",
"MyContract",
"--args",
"0x1234",
"--verify",
])
.unwrap();
match cli.command {
Commands::DeploySafe(args) => {
assert_eq!(args.contract, Some("MyContract".into()));
assert_eq!(args.args, Some("0x1234".into()));
assert!(args.verify);
}
_ => panic!("Expected DeploySafe command"),
}
}
#[test]
fn test_cli_parse_fuzz() {
let cli = Cli::try_parse_from(&["forge-guard", "fuzz"]).unwrap();
assert!(matches!(cli.command, Commands::Fuzz(_)));
}
#[test]
fn test_cli_parse_fuzz_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"fuzz",
"MyTest",
"--runs",
"50000",
"--seed",
"42",
"--test",
"test_deposit",
])
.unwrap();
match cli.command {
Commands::Fuzz(args) => {
assert_eq!(args.contract, Some("MyTest".into()));
assert_eq!(args.runs, 50000);
assert_eq!(args.seed, Some(42));
assert_eq!(args.test, Some("test_deposit".into()));
}
_ => panic!("Expected Fuzz command"),
}
}
#[test]
fn test_cli_parse_fuzz_defaults() {
let cli = Cli::try_parse_from(&["forge-guard", "fuzz"]).unwrap();
match cli.command {
Commands::Fuzz(args) => {
assert!(args.contract.is_none());
assert_eq!(args.runs, 10000);
assert!(args.seed.is_none());
assert!(args.test.is_none());
}
_ => panic!("Expected Fuzz command"),
}
}
#[test]
fn test_cli_parse_invariant() {
let cli = Cli::try_parse_from(&["forge-guard", "invariant"]).unwrap();
assert!(matches!(cli.command, Commands::Invariant(_)));
}
#[test]
fn test_cli_parse_invariant_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"invariant",
"MyInvariant",
"--runs",
"5000",
"--depth",
"200",
"--fail-on-revert",
])
.unwrap();
match cli.command {
Commands::Invariant(args) => {
assert_eq!(args.contract, Some("MyInvariant".into()));
assert_eq!(args.runs, 5000);
assert_eq!(args.depth, 200);
assert!(args.fail_on_revert);
}
_ => panic!("Expected Invariant command"),
}
}
#[test]
fn test_cli_parse_simulate() {
let cli = Cli::try_parse_from(&["forge-guard", "simulate"]).unwrap();
assert!(matches!(cli.command, Commands::Simulate(_)));
}
#[test]
fn test_cli_parse_simulate_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"simulate",
"MyContract",
"--blocks",
"500",
"--deployer",
"0x1234",
"--mev",
])
.unwrap();
match cli.command {
Commands::Simulate(args) => {
assert_eq!(args.contract, Some("MyContract".into()));
assert_eq!(args.blocks, 500);
assert_eq!(args.deployer, Some("0x1234".into()));
assert!(args.mev);
}
_ => panic!("Expected Simulate command"),
}
}
#[test]
fn test_cli_parse_gas() {
let cli = Cli::try_parse_from(&["forge-guard", "gas"]).unwrap();
assert!(matches!(cli.command, Commands::Gas(_)));
}
#[test]
fn test_cli_parse_gas_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"gas",
"MyContract",
"--diff",
"previous.json",
"--all",
"--warn-threshold",
"100000",
])
.unwrap();
match cli.command {
Commands::Gas(args) => {
assert_eq!(args.contract, Some("MyContract".into()));
assert_eq!(args.diff, Some("previous.json".into()));
assert!(args.all);
assert_eq!(args.warn_threshold, 100000);
}
_ => panic!("Expected Gas command"),
}
}
#[test]
fn test_cli_parse_report() {
let cli = Cli::try_parse_from(&["forge-guard", "report"]).unwrap();
assert!(matches!(cli.command, Commands::Report(_)));
}
#[test]
fn test_cli_parse_report_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"report",
"result.json",
"--format",
"json",
"--output",
"report.json",
"--exploit-paths",
"--summary",
])
.unwrap();
match cli.command {
Commands::Report(args) => {
assert_eq!(args.input, Some(std::path::PathBuf::from("result.json")));
assert_eq!(args.format, "json");
assert_eq!(args.output, Some(std::path::PathBuf::from("report.json")));
assert!(args.exploit_paths);
assert!(args.summary);
}
_ => panic!("Expected Report command"),
}
}
#[test]
fn test_cli_parse_verify() {
let cli = Cli::try_parse_from(&["forge-guard", "verify"]).unwrap();
assert!(matches!(cli.command, Commands::Verify(_)));
}
#[test]
fn test_cli_parse_verify_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"verify",
"0x1234",
"MyContract",
"--api-key",
"test-key",
"--constructor-args",
"0xabcdef",
"--all",
])
.unwrap();
match cli.command {
Commands::Verify(args) => {
assert_eq!(args.address, Some("0x1234".into()));
assert_eq!(args.name, Some("MyContract".into()));
assert_eq!(args.api_key, Some("test-key".into()));
assert_eq!(args.constructor_args, Some("0xabcdef".into()));
assert!(args.all);
}
_ => panic!("Expected Verify command"),
}
}
#[test]
fn test_cli_parse_doctor() {
let cli = Cli::try_parse_from(&["forge-guard", "doctor"]).unwrap();
assert!(matches!(cli.command, Commands::Doctor(_)));
}
#[test]
fn test_cli_parse_doctor_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"doctor",
"--fix",
"--verbose",
"--check",
"foundry",
])
.unwrap();
match cli.command {
Commands::Doctor(args) => {
assert!(args.fix);
assert!(args.verbose);
assert_eq!(args.check, Some("foundry".into()));
}
_ => panic!("Expected Doctor command"),
}
}
#[test]
fn test_cli_parse_watch() {
let cli = Cli::try_parse_from(&["forge-guard", "watch"]).unwrap();
assert!(matches!(cli.command, Commands::Watch(_)));
}
#[test]
fn test_cli_parse_watch_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"watch",
"--dirs",
"src,contracts",
"--debounce-ms",
"1000",
"--exclude",
"test",
"--full",
])
.unwrap();
match cli.command {
Commands::Watch(args) => {
assert_eq!(args.dirs, "src,contracts");
assert_eq!(args.debounce_ms, 1000);
assert_eq!(args.exclude, Some("test".into()));
assert!(args.full);
}
_ => panic!("Expected Watch command"),
}
}
#[test]
fn test_cli_parse_ci() {
let cli = Cli::try_parse_from(&["forge-guard", "ci"]).unwrap();
assert!(matches!(cli.command, Commands::Ci(_)));
}
#[test]
fn test_cli_parse_ci_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"ci",
"--platform",
"gitlab",
"--output",
".gitlab-ci",
"--include-deploy",
"--overwrite",
])
.unwrap();
match cli.command {
Commands::Ci(args) => {
assert_eq!(args.platform, "gitlab");
assert_eq!(args.output, std::path::PathBuf::from(".gitlab-ci"));
assert!(args.include_deploy);
assert!(!args.vscode);
assert!(args.overwrite);
}
_ => panic!("Expected Ci command"),
}
}
#[test]
fn test_cli_parse_ci_vscode() {
let cli = Cli::try_parse_from(&["forge-guard", "ci", "--vscode"]).unwrap();
match cli.command {
Commands::Ci(args) => assert!(args.vscode),
_ => panic!("Expected Ci command"),
}
}
#[test]
fn test_cli_parse_ci_defaults() {
let cli = Cli::try_parse_from(&["forge-guard", "ci"]).unwrap();
match &cli.command {
Commands::Ci(args) => {
assert_eq!(args.platform, "github");
assert_eq!(args.output, std::path::PathBuf::from(".github/workflows"));
assert!(!args.include_deploy);
assert!(!args.vscode);
assert!(!args.overwrite);
let (c, p, j, m, s, o, pr, r, pl) = parse_shared(&cli);
assert_shared_defaults(c, p, j, m, s, o, pr, r, pl);
}
_ => panic!("Expected Ci command"),
}
}
#[test]
fn test_cli_parse_benchmark() {
let cli = Cli::try_parse_from(&["forge-guard", "benchmark"]).unwrap();
assert!(matches!(cli.command, Commands::Benchmark(_)));
}
#[test]
fn test_cli_parse_benchmark_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"benchmark",
"--iterations",
"20",
"--warmup",
"5",
"--compare",
"baseline.json",
"--save",
"results.json",
"--module",
"pattern_matching",
])
.unwrap();
match cli.command {
Commands::Benchmark(args) => {
assert_eq!(args.iterations, 20);
assert_eq!(args.warmup, 5);
assert_eq!(
args.compare,
Some(std::path::PathBuf::from("baseline.json"))
);
assert_eq!(args.save, Some(std::path::PathBuf::from("results.json")));
assert_eq!(args.module, Some("pattern_matching".into()));
}
_ => panic!("Expected Benchmark command"),
}
}
#[test]
fn test_cli_parse_scan() {
let cli = Cli::try_parse_from(&["forge-guard", "scan"]).unwrap();
assert!(matches!(cli.command, Commands::Scan(_)));
}
#[test]
fn test_cli_parse_scan_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"scan",
"--depth",
"2",
"--update",
"--vulnerable-only",
"--fail-fast",
])
.unwrap();
match cli.command {
Commands::Scan(args) => {
assert_eq!(args.depth, 2);
assert!(args.update);
assert!(args.vulnerable_only);
assert!(args.fail_fast);
}
_ => panic!("Expected Scan command"),
}
}
#[test]
fn test_cli_parse_upgrade_check() {
let cli = Cli::try_parse_from(&["forge-guard", "upgrade-check"]).unwrap();
assert!(matches!(cli.command, Commands::UpgradeCheck(_)));
}
#[test]
fn test_cli_parse_upgrade_check_with_flags() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"upgrade-check",
"0xproxy",
"0ximpl",
"--all",
"--storage-collision",
"--uups",
])
.unwrap();
match cli.command {
Commands::UpgradeCheck(args) => {
assert_eq!(args.proxy, Some("0xproxy".into()));
assert_eq!(args.implementation, Some("0ximpl".into()));
assert!(args.all);
assert!(args.storage_collision);
assert!(args.uups);
}
_ => panic!("Expected UpgradeCheck command"),
}
}
#[test]
fn test_cli_parse_plugins_list() {
let cli = Cli::try_parse_from(&["forge-guard", "plugins", "list"]).unwrap();
assert!(matches!(cli.command, Commands::Plugins(_)));
}
#[test]
fn test_cli_parse_plugins_install() {
let cli = Cli::try_parse_from(&["forge-guard", "plugins", "install", "my-plugin"]).unwrap();
match cli.command {
Commands::Plugins(args) => {
assert!(args.action.is_some());
}
_ => panic!("Expected Plugins command"),
}
}
#[test]
fn test_cli_parse_plugins_install_with_source() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"plugins",
"install",
"my-plugin",
"https://github.com/user/plugin.git",
])
.unwrap();
assert!(matches!(cli.command, Commands::Plugins(_)));
}
#[test]
fn test_cli_parse_plugins_remove() {
let cli = Cli::try_parse_from(&["forge-guard", "plugins", "remove", "my-plugin"]).unwrap();
assert!(matches!(cli.command, Commands::Plugins(_)));
}
#[test]
fn test_cli_parse_plugins_enable_disable() {
let cli_enable =
Cli::try_parse_from(&["forge-guard", "plugins", "enable", "my-plugin"]).unwrap();
assert!(matches!(cli_enable.command, Commands::Plugins(_)));
let cli_disable =
Cli::try_parse_from(&["forge-guard", "plugins", "disable", "my-plugin"]).unwrap();
assert!(matches!(cli_disable.command, Commands::Plugins(_)));
}
#[test]
fn test_cli_parse_plugins_new() {
let cli = Cli::try_parse_from(&["forge-guard", "plugins", "new", "my-awesome-plugin"]).unwrap();
assert!(matches!(cli.command, Commands::Plugins(_)));
}
#[test]
fn test_cli_parse_chain_list() {
let cli = Cli::try_parse_from(&["forge-guard", "chain", "list"]).unwrap();
assert!(matches!(cli.command, Commands::Chain(_)));
}
#[test]
fn test_cli_parse_chain_info() {
let cli = Cli::try_parse_from(&["forge-guard", "chain", "info", "polygon"]).unwrap();
assert!(matches!(cli.command, Commands::Chain(_)));
}
#[test]
fn test_cli_parse_chain_add() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"chain",
"add",
"my-chain",
"https://rpc.my-chain.io",
"99999",
])
.unwrap();
assert!(matches!(cli.command, Commands::Chain(_)));
}
#[test]
fn test_cli_parse_chain_remove() {
let cli = Cli::try_parse_from(&["forge-guard", "chain", "remove", "my-chain"]).unwrap();
assert!(matches!(cli.command, Commands::Chain(_)));
}
#[test]
fn test_cli_parse_chain_test() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"chain",
"test",
"polygon",
"https://polygon-rpc.com",
])
.unwrap();
assert!(matches!(cli.command, Commands::Chain(_)));
}
#[test]
fn test_cli_parse_security_config() {
let cli = Cli::try_parse_from(&["forge-guard", "security", "config"]).unwrap();
assert!(matches!(cli.command, Commands::Security(_)));
}
#[test]
fn test_cli_parse_security_threshold() {
let cli = Cli::try_parse_from(&["forge-guard", "security", "threshold", "85"]).unwrap();
assert!(matches!(cli.command, Commands::Security(_)));
}
#[test]
fn test_cli_parse_security_enable() {
let cli = Cli::try_parse_from(&["forge-guard", "security", "enable", "Reentrancy"]).unwrap();
assert!(matches!(cli.command, Commands::Security(_)));
}
#[test]
fn test_cli_parse_security_list() {
let cli = Cli::try_parse_from(&["forge-guard", "security", "list"]).unwrap();
assert!(matches!(cli.command, Commands::Security(_)));
}
#[test]
fn test_cli_parse_security_info() {
let cli = Cli::try_parse_from(&["forge-guard", "security", "info", "Reentrancy"]).unwrap();
assert!(matches!(cli.command, Commands::Security(_)));
}
#[test]
fn test_shared_flags_on_audit() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"audit",
"--chain",
"polygon",
"--project",
"/tmp/test",
"--json",
"--markdown",
"--strict",
"--offline",
"--production",
"--report",
"--parallelism",
"16",
])
.unwrap();
match cli.command {
Commands::Audit(args) => {
assert_eq!(args.shared.chain, "polygon");
assert_eq!(args.shared.project, std::path::PathBuf::from("/tmp/test"));
assert!(args.shared.json);
assert!(args.shared.markdown);
assert!(args.shared.strict);
assert!(args.shared.offline);
assert!(args.shared.production);
assert!(args.shared.report);
assert_eq!(args.shared.parallelism, 16);
}
_ => panic!("Expected Audit command"),
}
}
#[test]
fn test_shared_flags_on_doctor() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"doctor",
"--chain",
"optimism",
"--offline",
"--json",
])
.unwrap();
match cli.command {
Commands::Doctor(args) => {
assert_eq!(args.shared.chain, "optimism");
assert!(args.shared.offline);
assert!(args.shared.json);
}
_ => panic!("Expected Doctor command"),
}
}
#[test]
fn test_shared_flags_on_ci() {
let cli = Cli::try_parse_from(&[
"forge-guard",
"ci",
"--strict",
"--production",
"--parallelism",
"2",
])
.unwrap();
match cli.command {
Commands::Ci(args) => {
assert!(args.shared.strict);
assert!(args.shared.production);
assert_eq!(args.shared.parallelism, 2);
}
_ => panic!("Expected Ci command"),
}
}
#[test]
fn test_cli_parse_invalid_subcommand() {
let result = Cli::try_parse_from(&["forge-guard", "nonexistent"]);
assert!(result.is_err(), "Expected error for invalid subcommand");
}
#[test]
fn test_cli_parse_invalid_flag() {
let result = Cli::try_parse_from(&["forge-guard", "audit", "--nonexistent-flag"]);
assert!(result.is_err(), "Expected error for invalid flag");
}
#[test]
fn test_cli_parse_invalid_parallelism_value() {
let result = Cli::try_parse_from(&["forge-guard", "audit", "--parallelism", "not-a-number"]);
assert!(
result.is_err(),
"Expected error for non-numeric parallelism"
);
}
#[test]
fn test_cli_parse_missing_argument() {
let result = Cli::try_parse_from(&["forge-guard", "plugins", "install"]);
assert!(result.is_err(), "Expected error for missing plugin name");
}
#[test]
fn test_cli_parse_no_subcommand() {
let result = Cli::try_parse_from(&["forge-guard"]);
assert!(result.is_err(), "Expected error when no subcommand given");
}
#[test]
fn test_run_chain_list() {
let cli = Cli::try_parse_from(&["forge-guard", "chain", "list"]).unwrap();
let result = cli.run();
assert!(
result.is_ok(),
"chain list should succeed: {:?}",
result.err()
);
}
#[test]
fn test_run_security_list() {
let cli = Cli::try_parse_from(&["forge-guard", "security", "list"]).unwrap();
let result = cli.run();
assert!(
result.is_ok(),
"security list should succeed: {:?}",
result.err()
);
}
#[test]
fn test_run_security_config() {
let cli = Cli::try_parse_from(&["forge-guard", "security", "config"]).unwrap();
let result = cli.run();
assert!(
result.is_ok(),
"security config should succeed: {:?}",
result.err()
);
}
#[test]
fn test_run_security_threshold() {
let cli = Cli::try_parse_from(&["forge-guard", "security", "threshold", "75"]).unwrap();
let result = cli.run();
assert!(
result.is_ok(),
"security threshold should succeed: {:?}",
result.err()
);
}
#[test]
fn test_run_security_enable() {
let cli = Cli::try_parse_from(&["forge-guard", "security", "enable", "Reentrancy"]).unwrap();
let result = cli.run();
assert!(
result.is_ok(),
"security enable should succeed: {:?}",
result.err()
);
}
#[test]
fn test_run_security_disable() {
let cli = Cli::try_parse_from(&["forge-guard", "security", "disable", "Reentrancy"]).unwrap();
let result = cli.run();
assert!(
result.is_ok(),
"security disable should succeed: {:?}",
result.err()
);
}
#[test]
fn test_run_chain_info() {
let cli = Cli::try_parse_from(&["forge-guard", "chain", "info", "polygon"]).unwrap();
let result = cli.run();
assert!(
result.is_ok(),
"chain info should succeed: {:?}",
result.err()
);
}
#[test]
fn test_run_security_info() {
let cli = Cli::try_parse_from(&["forge-guard", "security", "info", "Reentrancy"]).unwrap();
let result = cli.run();
assert!(
result.is_ok(),
"security info should succeed: {:?}",
result.err()
);
}
#[test]
fn test_security_action_variants() {
let config = Cli::try_parse_from(&["forge-guard", "security", "config"]).unwrap();
assert!(matches!(config.command, Commands::Security(_)));
let threshold = Cli::try_parse_from(&["forge-guard", "security", "threshold", "90"]).unwrap();
let _action = SecurityAction::Threshold { score: 90 };
assert!(matches!(threshold.command, Commands::Security(_)));
let enable =
Cli::try_parse_from(&["forge-guard", "security", "enable", "tx.origin Usage"]).unwrap();
let _action2 = SecurityAction::Enable {
check: "tx.origin Usage".into(),
};
assert!(matches!(enable.command, Commands::Security(_)));
let disable =
Cli::try_parse_from(&["forge-guard", "security", "disable", "Reentrancy"]).unwrap();
assert!(matches!(disable.command, Commands::Security(_)));
let list = Cli::try_parse_from(&["forge-guard", "security", "list"]).unwrap();
assert!(matches!(list.command, Commands::Security(_)));
let info = Cli::try_parse_from(&["forge-guard", "security", "info", "Reentrancy"]).unwrap();
assert!(matches!(info.command, Commands::Security(_)));
}
#[test]
fn test_plugin_action_variants() {
let r = Cli::try_parse_from(&["forge-guard", "plugins", "list"]);
assert!(r.is_ok());
let r = Cli::try_parse_from(&["forge-guard", "plugins", "install", "p"]);
assert!(r.is_ok());
let r = Cli::try_parse_from(&[
"forge-guard",
"plugins",
"install",
"p",
"https://github.com/x/y.git",
]);
assert!(r.is_ok());
let r = Cli::try_parse_from(&["forge-guard", "plugins", "remove", "p"]);
assert!(r.is_ok());
let r = Cli::try_parse_from(&["forge-guard", "plugins", "enable", "p"]);
assert!(r.is_ok());
let r = Cli::try_parse_from(&["forge-guard", "plugins", "disable", "p"]);
assert!(r.is_ok());
let r = Cli::try_parse_from(&["forge-guard", "plugins", "new", "my-plugin"]);
assert!(r.is_ok());
}
#[test]
fn test_chain_action_variants() {
let r = Cli::try_parse_from(&["forge-guard", "chain", "list"]);
assert!(r.is_ok());
let r = Cli::try_parse_from(&["forge-guard", "chain", "info", "ethereum"]);
assert!(r.is_ok());
let r = Cli::try_parse_from(&["forge-guard", "chain", "add", "c", "http://rpc", "1"]);
assert!(r.is_ok());
let r = Cli::try_parse_from(&["forge-guard", "chain", "remove", "c"]);
assert!(r.is_ok());
let r = Cli::try_parse_from(&["forge-guard", "chain", "test", "c", "http://rpc"]);
assert!(r.is_ok());
}
#[test]
fn test_cli_run_returns_result_ok_for_valid_commands() {
let cmds = vec![
vec!["forge-guard", "security", "list"],
vec!["forge-guard", "security", "config"],
vec!["forge-guard", "chain", "list"],
vec!["forge-guard", "chain", "info", "ethereum"],
];
for args in cmds {
let cli = Cli::try_parse_from(&args).unwrap();
let result = cli.run();
assert!(
result.is_ok(),
"Command '{:?}' should succeed: {:?}",
args,
result.err()
);
}
}
const CLEAN_CONTRACT: &str = r#"
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Simple {
uint256 public value;
address public owner;
event ValueChanged(address indexed sender, uint256 newValue);
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
constructor() {
owner = msg.sender;
}
function set(uint256 newValue) external onlyOwner {
value = newValue;
emit ValueChanged(msg.sender, newValue);
}
function get() external view returns (uint256) {
return value;
}
}
"#;
const VULNERABLE_CONTRACT: &str = r#"
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Vulnerable {
mapping(address => uint256) public balances;
address public owner;
constructor() {
owner = msg.sender;
}
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount;
}
function setAdmin(address newAdmin) external {
admin = newAdmin;
}
function kill() external {
selfdestruct(payable(msg.sender));
}
function transfer(address to, uint256 amount) external {
require(tx.origin == owner);
balances[to] += amount;
}
receive() external payable {}
}
"#;
fn create_temp_project(contracts: &[(&str, &str)]) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("Failed to create temp dir");
let src_dir = dir.path().join("test-contracts").join("secure").join("src");
std::fs::create_dir_all(&src_dir).expect("Failed to create src dir");
for (filename, content) in contracts {
let file_path = src_dir.join(filename);
std::fs::write(&file_path, content)
.unwrap_or_else(|e| panic!("Failed to write {}: {}", filename, e));
}
let path = dir.path().to_path_buf();
(dir, path)
}
fn run_audit(project: &std::path::Path, extra_args: &[&str]) -> Result<(), anyhow::Error> {
let mut args = vec!["forge-guard", "audit", "--offline"];
args.push("--project");
args.push(project.to_str().unwrap());
args.extend_from_slice(extra_args);
let cli = Cli::try_parse_from(&args).expect("Failed to parse CLI args");
cli.run()
}
fn run_binary_in(dir: &std::path::Path, args: &[&str]) -> std::process::Output {
std::process::Command::new(env!("CARGO_BIN_EXE_forge-guard"))
.args(args)
.current_dir(dir)
.output()
.expect("Failed to spawn forge-guard binary")
}
#[test]
fn test_audit_clean_contract_succeeds() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let result = run_audit(&project, &[]);
assert!(
result.is_ok(),
"Audit of clean contract should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_clean_contract_quick_mode() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let result = run_audit(&project, &["--quick"]);
assert!(
result.is_ok(),
"Quick audit of clean contract should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_clean_contract_json_output() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let result = run_audit(&project, &["--json"]);
assert!(
result.is_ok(),
"Audit with --json should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_clean_contract_strict_mode_passes() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let result = run_audit(&project, &["--strict"]);
let _ = result;
}
#[test]
fn test_audit_vulnerable_contract_detects_issues() {
let (_dir, project) = create_temp_project(&[("Vulnerable.sol", VULNERABLE_CONTRACT)]);
let result = run_audit(&project, &[]);
assert!(
result.is_ok(),
"Audit of vulnerable contract should succeed (reports issues): {:?}",
result.err()
);
}
#[test]
fn test_audit_vulnerable_contract_strict_fails() {
let (_dir, project) = create_temp_project(&[("Vulnerable.sol", VULNERABLE_CONTRACT)]);
let result = run_audit(&project, &["--strict"]);
assert!(
result.is_err(),
"Strict audit should fail on vulnerable contract with findings"
);
}
#[test]
fn test_audit_vulnerable_contract_with_exploit() {
let (_dir, project) = create_temp_project(&[("Vulnerable.sol", VULNERABLE_CONTRACT)]);
let result = run_audit(&project, &["--exploit"]);
assert!(
result.is_ok(),
"Audit with exploit analysis should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_vulnerable_contract_with_gas() {
let (_dir, project) = create_temp_project(&[("Vulnerable.sol", VULNERABLE_CONTRACT)]);
let result = run_audit(&project, &["--gas"]);
assert!(
result.is_ok(),
"Audit with gas analysis should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_vulnerable_contract_json_output() {
let (_dir, project) = create_temp_project(&[("Vulnerable.sol", VULNERABLE_CONTRACT)]);
let result = run_audit(&project, &["--json"]);
assert!(
result.is_ok(),
"Audit with --json on vulnerable contract should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_empty_directory_fails() {
let (_dir, project) = create_temp_project(&[]);
let result = run_audit(&project, &[]);
assert!(result.is_err(), "Audit with empty project should fail");
let err = format!("{:#}", result.unwrap_err());
assert!(
err.contains("No Solidity source files found"),
"Error should mention no source files: {}",
err
);
}
#[test]
fn test_audit_multiple_contracts() {
let (_dir, project) = create_temp_project(&[
("Simple.sol", CLEAN_CONTRACT),
("Vulnerable.sol", VULNERABLE_CONTRACT),
]);
let result = run_audit(&project, &[]);
assert!(
result.is_ok(),
"Audit of multiple contracts should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_with_report_output() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let result = run_audit(&project, &["--report"]);
assert!(
result.is_ok(),
"Audit with --report should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_different_chain() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let result = run_audit(&project, &["--chain", "polygon"]);
assert!(
result.is_ok(),
"Audit with different chain should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_with_exclude_pattern() {
let (_dir, project) = create_temp_project(&[
("Simple.sol", CLEAN_CONTRACT),
("Vulnerable.sol", VULNERABLE_CONTRACT),
]);
let result = run_audit(&project, &["--exclude", "Vulnerable"]);
assert!(
result.is_ok(),
"Audit with exclude should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_custom_sources() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let result = run_audit(&project, &["--sources", "src"]);
assert!(
result.is_ok(),
"Audit with explicit sources should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_with_defi_template_succeeds() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let result = run_audit(&project, &["--template", "defi"]);
assert!(
result.is_ok(),
"Audit with --template defi should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_with_template_json_output_succeeds() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let result = run_audit(&project, &["--template", "erc20", "--json"]);
assert!(
result.is_ok(),
"Audit with --template erc20 --json should succeed: {:?}",
result.err()
);
}
#[test]
fn test_audit_with_unknown_template_fails() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let result = run_audit(&project, &["--template", "does-not-exist"]);
assert!(result.is_err(), "Unknown template should error");
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("Unknown audit template"),
"Error should mention unknown template: {}",
msg
);
}
#[test]
fn test_audit_list_templates_succeeds() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let result = run_audit(&project, &["--list-templates"]);
assert!(
result.is_ok(),
"--list-templates should succeed: {:?}",
result.err()
);
}
#[test]
fn test_binary_audit_template_min_scores_gate() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("Upgradeable.sol"),
"contract Upgradeable is UUPSUpgradeable {\n uint256 public value;\n}\n",
)
.unwrap();
std::fs::write(
dir.path().join("forge-guard.toml"),
"src_dirs = [\"src\"]\n",
)
.unwrap();
let output = run_binary_in(dir.path(), &["audit", "--json"]);
assert!(
output.status.success(),
"audit should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let v: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).unwrap();
assert!(v["production_ready"].as_bool().unwrap());
assert!(v["overall_score"].as_u64().unwrap() >= 70);
let output = run_binary_in(
dir.path(),
&["audit", "--template", "upgradeable", "--json"],
);
assert!(
output.status.success(),
"audit with template should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let v: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).unwrap();
assert!(
v["overall_score"].as_u64().unwrap() >= 70,
"Overall score should remain above the deployment threshold"
);
assert!(
!v["production_ready"].as_bool().unwrap(),
"Template min_scores gate should block production_ready"
);
}
#[test]
fn test_binary_audit_template_focus_areas_weight_score() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("Clean.sol"),
"contract Counter {\n uint256 private count;\n function increment() external { count += 1; }\n}\n",
)
.unwrap();
std::fs::write(
dir.path().join("forge-guard.toml"),
"src_dirs = [\"src\"]\n",
)
.unwrap();
let output = run_binary_in(dir.path(), &["audit", "--template", "defi", "--json"]);
assert!(
output.status.success(),
"audit with defi template should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let v: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).unwrap();
assert!(v["overall_score"].as_u64().is_some());
assert!(v["production_ready"].is_boolean());
assert!(v["deployment_approved"].is_boolean());
assert!(v["risk_level"].is_string());
}
#[test]
fn test_audit_single_file_sources() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let file_path = project
.join("test-contracts")
.join("secure")
.join("src")
.join("Simple.sol");
let result = run_audit(&project, &["--sources", file_path.to_str().unwrap()]);
assert!(
result.is_ok(),
"Audit of a single staged file via --sources should succeed: {:?}",
result.err()
);
}
#[test]
fn test_binary_doctor_sync_writes_forge_guard_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("foundry.toml"),
"[profile.default]\n\
src = 'contracts'\n\
test = 'tests'\n\
solc = '0.8.23'\n",
)
.unwrap();
let output = run_binary_in(dir.path(), &["doctor", "--sync"]);
assert!(
output.status.success(),
"doctor --sync should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
let reparsed: toml::Value = toml::from_str(&written).unwrap();
assert_eq!(reparsed["src_dirs"][0].as_str(), Some("contracts"));
assert_eq!(reparsed["solc_version"].as_str(), Some("0.8.23"));
}
#[test]
fn test_binary_doctor_sync_dry_run_does_not_write() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("foundry.toml"),
"[profile.default]\nsrc = 'contracts'\n",
)
.unwrap();
let output = run_binary_in(dir.path(), &["doctor", "--sync", "--dry-run"]);
assert!(
output.status.success(),
"doctor --sync --dry-run should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!dir.path().join("forge-guard.toml").exists(),
"Dry run must not write forge-guard.toml"
);
}
#[test]
fn test_binary_sbom_ci_writes_workflow() {
let dir = tempfile::tempdir().unwrap();
let output = run_binary_in(dir.path(), &["sbom", "--ci", "--output", "sbom.json"]);
assert!(
output.status.success(),
"sbom --ci should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(dir.path().join("sbom.json").exists());
let content = std::fs::read_to_string(dir.path().join("sbom.json")).unwrap();
assert!(content.contains("bomFormat"));
let wf = dir
.path()
.join(".github")
.join("workflows")
.join("sbom.yml");
assert!(wf.exists(), "sbom.yml workflow should be written");
let wf_content = std::fs::read_to_string(&wf).unwrap();
assert!(wf_content.contains("name: SBOM Generation"));
assert!(wf_content.contains("forge-guard sbom --format cyclonedx"));
assert!(wf_content.contains("forge-guard sbom --format spdx"));
}
#[test]
fn test_binary_sbom_without_ci_writes_no_workflow() {
let dir = tempfile::tempdir().unwrap();
let output = run_binary_in(dir.path(), &["sbom", "--output", "sbom.json"]);
assert!(output.status.success());
assert!(dir.path().join("sbom.json").exists());
assert!(
!dir.path().join(".github").exists(),
"Without --ci, no workflow should be written"
);
}
#[test]
fn test_binary_install_hook_and_uninstall() {
let dir = tempfile::tempdir().unwrap();
let git_dir = dir.path().join(".git");
std::fs::create_dir_all(git_dir.join("hooks")).unwrap();
let output = run_binary_in(dir.path(), &["install-hook"]);
assert!(
output.status.success(),
"install-hook should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let hook_path = git_dir.join("hooks").join("pre-commit");
assert!(hook_path.exists(), "pre-commit hook should be created");
let content = std::fs::read_to_string(&hook_path).unwrap();
assert!(content.contains("forge-guard pre-commit hook"));
assert!(content.contains("--sources \"$file\""));
let output = run_binary_in(dir.path(), &["install-hook", "--uninstall"]);
assert!(output.status.success());
assert!(
!hook_path.exists(),
"pre-commit hook should be removed after uninstall"
);
}
#[test]
fn test_binary_install_hook_refuses_overwrite_without_force() {
let dir = tempfile::tempdir().unwrap();
let git_dir = dir.path().join(".git");
std::fs::create_dir_all(git_dir.join("hooks")).unwrap();
std::fs::write(git_dir.join("hooks").join("pre-commit"), "existing hook").unwrap();
let output = run_binary_in(dir.path(), &["install-hook"]);
assert!(
!output.status.success(),
"install-hook should fail when hook exists without --force"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("already exists"));
let output = run_binary_in(dir.path(), &["install-hook", "--force"]);
assert!(output.status.success());
let content = std::fs::read_to_string(git_dir.join("hooks").join("pre-commit")).unwrap();
assert!(content.contains("forge-guard pre-commit hook"));
}
#[test]
fn test_binary_install_hook_without_git_fails() {
let dir = tempfile::tempdir().unwrap();
let output = run_binary_in(dir.path(), &["install-hook"]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("No .git directory"));
}
#[test]
fn test_audit_non_standard_sources_dir() {
let dir = tempfile::tempdir().expect("Failed to create temp dir");
let default_src = dir.path().join("test-contracts").join("secure").join("src");
std::fs::create_dir_all(&default_src).expect("Failed to create dir");
std::fs::write(default_src.join("Simple.sol"), CLEAN_CONTRACT)
.expect("Failed to write contract");
let contracts_dir = dir.path().join("contracts");
std::fs::create_dir_all(&contracts_dir).expect("Failed to create dir");
std::fs::write(contracts_dir.join("Other.sol"), CLEAN_CONTRACT)
.expect("Failed to write contract");
let result = run_audit(dir.path(), &["--quick"]);
assert!(
result.is_ok(),
"Audit should succeed with files in configured src_dirs: {:?}",
result.err()
);
}
#[test]
fn test_deploy_vulnerable_contract_blocked() {
let (_dir, project) = create_temp_project(&[("Vulnerable.sol", VULNERABLE_CONTRACT)]);
let args = vec![
"forge-guard",
"deploy",
"Vulnerable",
"--project",
project.to_str().unwrap(),
"--offline",
];
let cli = Cli::try_parse_from(&args).expect("Failed to parse CLI args");
let result = cli.run();
assert!(result.is_err(), "Deploy of vulnerable contract should err");
let err_msg = format!("{:#}", result.unwrap_err());
assert!(
err_msg.contains("blocked")
|| err_msg.contains("forge")
|| err_msg.contains("Failed")
|| err_msg.contains("Deploy failed"),
"Error should mention blocked or forge: {:#}",
err_msg
);
}
#[test]
fn test_deploy_clean_contract_outcome() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let args = vec![
"forge-guard",
"deploy",
"Simple",
"--project",
project.to_str().unwrap(),
"--offline",
];
let cli = Cli::try_parse_from(&args).expect("Failed to parse CLI args");
let result = cli.run();
if let Err(e) = &result {
let msg = format!("{:#}", e);
assert!(
msg.contains("forge") || msg.contains("Failed") || msg.contains("blocked"),
"If deploy fails, should mention forge or blocked: {:#}",
msg
);
}
}
#[test]
fn test_deploy_force_bypass() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let args = vec![
"forge-guard",
"deploy",
"Simple",
"--project",
project.to_str().unwrap(),
"--offline",
"--force",
];
let cli = Cli::try_parse_from(&args).expect("Failed to parse CLI args");
let result = cli.run();
if let Err(e) = &result {
let msg = format!("{:#}", e);
assert!(
msg.contains("forge") || msg.contains("Failed"),
"With --force, error should be forge-related: {:#}",
msg
);
}
}
#[test]
fn test_deploy_safe_blocks_vulnerable() {
let (_dir, project) = create_temp_project(&[("Vulnerable.sol", VULNERABLE_CONTRACT)]);
let args = vec![
"forge-guard",
"deploy-safe",
"Vulnerable",
"--project",
project.to_str().unwrap(),
"--offline",
];
let cli = Cli::try_parse_from(&args).expect("Failed to parse CLI args");
let result = cli.run();
assert!(result.is_err(), "deploy-safe should block vulnerable");
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("blocked") || msg.contains("Security"),
"Error should mention blocked or security: {:#}",
msg
);
}
#[test]
fn test_deploy_empty_project_fails() {
let (_dir, project) = create_temp_project(&[]);
let args = vec![
"forge-guard",
"deploy",
"--project",
project.to_str().unwrap(),
"--offline",
];
let cli = Cli::try_parse_from(&args).expect("Failed to parse CLI args");
let result = cli.run();
assert!(result.is_err(), "Deploy with no contracts should fail");
}
const SLITHER_RESULTS_JSON: &str = r#"{
"success": true,
"results": {
"detectors": [
{
"check": "reentrancy-eth",
"impact": "High",
"confidence": "Medium",
"description": "Reentrancy in Vault.withdraw (contracts/Vault.sol#42-47)",
"markdown": "Use checks-effects-interactions or a reentrancy guard.",
"elements": [
{
"type": "function",
"name": "withdraw",
"source_mapping": {
"filename_relative": "contracts/Vault.sol",
"line": 42,
"end_line": 47,
"column": 8,
"content": "(bool ok, ) = msg.sender.call{value: amount}(\"\");"
}
}
]
},
{
"check": "uninitialized-state",
"impact": "Low",
"confidence": "High",
"description": "State variable owner is never initialized",
"markdown": "Initialize the state variable in the constructor.",
"elements": [
{
"type": "state_variable",
"name": "owner",
"source_mapping": {
"filename_relative": "contracts/Vault.sol",
"line": 12,
"column": 4,
"content": "address public owner;"
}
}
]
}
]
}
}"#;
#[test]
fn test_import_slither_json_end_to_end() {
let (_dir, project) = create_temp_project(&[]);
std::fs::write(project.join("slither_out.json"), SLITHER_RESULTS_JSON)
.expect("write Slither JSON fixture");
let result = run_binary_in(
&project,
&["import", "--from", "slither", "slither_out.json", "--json"],
);
assert!(
result.status.success(),
"import should succeed: {}",
String::from_utf8_lossy(&result.stderr)
);
let stdout = String::from_utf8_lossy(&result.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout should be valid JSON: {e}\n{stdout}"));
assert_eq!(json["tool"], "Slither");
assert_eq!(json["merged_with_forge_guard"], false);
assert_eq!(json["duplicates_removed"], 0);
let findings = json["findings"].as_array().expect("findings array");
assert_eq!(
findings.len(),
2,
"two detectors should import as two findings"
);
let first = &findings[0];
assert!(
first["id"].as_str().unwrap().starts_with("SL-"),
"imported ids should use the SL- prefix"
);
assert_eq!(first["severity"], "high");
assert_eq!(first["title"], "reentrancy-eth (High)");
assert_eq!(first["file"], "contracts/Vault.sol");
assert_eq!(first["line"], 42);
assert_eq!(first["category"], "slither:reentrancy-eth");
assert!(
first["code_snippet"].as_str().unwrap().contains("call"),
"source snippet should carry the vulnerable code"
);
assert_eq!(findings[1]["severity"], "low");
}
#[test]
fn test_import_slither_writes_unified_report_file() {
let (_dir, project) = create_temp_project(&[]);
std::fs::write(project.join("slither_out.json"), SLITHER_RESULTS_JSON)
.expect("write Slither JSON fixture");
let result = run_binary_in(
&project,
&[
"import",
"--from",
"slither",
"slither_out.json",
"--json",
"--output",
"unified.json",
],
);
assert!(
result.status.success(),
"import with --output should succeed: {}",
String::from_utf8_lossy(&result.stderr)
);
let report = std::fs::read_to_string(project.join("unified.json"))
.expect("unified report should be written to disk");
let json: serde_json::Value = serde_json::from_str(&report).expect("report is valid JSON");
assert_eq!(json["tool"], "Slither");
assert_eq!(json["findings"].as_array().unwrap().len(), 2);
assert_eq!(
json["source_files"],
serde_json::json!(["contracts/Vault.sol"])
);
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(
stderr.contains("Unified report written"),
"stderr should mention the written report: {stderr}"
);
}
#[test]
fn test_import_missing_input_file_errors() {
let (_dir, project) = create_temp_project(&[]);
let result = run_binary_in(&project, &["import", "--from", "slither", "nope.json"]);
assert!(
!result.status.success(),
"import with a missing input file should fail"
);
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(
stderr.contains("Results file not found"),
"stderr should explain the missing file: {stderr}"
);
}
const MYTHRIL_RESULTS_JSON: &str = r#"{
"success": true,
"issues": [
{
"title": "The contract executes an external call",
"description": "The contract executes an external call",
"severity": "High",
"swc-id": "107",
"function": "withdraw",
"address": 1234,
"source": {
"filename": "contracts/Vault.sol",
"line": 42,
"source": "msg.sender.call{value: amount}(\"\");"
}
},
{
"title": "State change after external call",
"description": "State is written after an external call",
"type": "Medium",
"swc-id": "107",
"function": "withdraw",
"source": {
"filename": "contracts/Vault.sol",
"line": 44,
"source": "balances[msg.sender] -= amount;"
}
}
]
}"#;
const SEMGREP_RESULTS_JSON: &str = r#"{
"results": [
{
"check_id": "solidity.reentrancy",
"path": "contracts/Vault.sol",
"start": { "line": 42, "col": 1 },
"end": { "line": 42, "col": 30 },
"extra": {
"message": "External call before state update",
"severity": "ERROR",
"metadata": { "cwe": ["CWE-1077"] },
"lines": "msg.sender.call{value: amount}(\"\");"
}
},
{
"check_id": "solidity.avoid-tx-origin",
"path": "contracts/Vault.sol",
"start": { "line": 60, "col": 1 },
"extra": {
"message": "Use of tx.origin",
"severity": "WARNING",
"metadata": { "cwe": "CWE-477" }
}
}
],
"errors": []
}"#;
#[test]
fn test_import_mythril_json_end_to_end() {
let (_dir, project) = create_temp_project(&[]);
std::fs::write(project.join("mythril_out.json"), MYTHRIL_RESULTS_JSON)
.expect("write Mythril JSON fixture");
let result = run_binary_in(
&project,
&["import", "--from", "mythril", "mythril_out.json", "--json"],
);
assert!(
result.status.success(),
"import should succeed: {}",
String::from_utf8_lossy(&result.stderr)
);
let stdout = String::from_utf8_lossy(&result.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout should be valid JSON: {e}\n{stdout}"));
assert_eq!(json["tool"], "Mythril");
let findings = json["findings"].as_array().expect("findings array");
assert_eq!(
findings.len(),
2,
"two issues should import as two findings"
);
let first = &findings[0];
assert!(
first["id"].as_str().unwrap().starts_with("MY-"),
"imported ids should use the MY- prefix"
);
assert_eq!(first["severity"], "high");
assert_eq!(first["title"], "The contract executes an external call");
assert_eq!(first["file"], "contracts/Vault.sol");
assert_eq!(first["line"], 42);
assert_eq!(first["category"], "mythril:withdraw");
let refs = first["references"].as_array().unwrap();
assert!(
refs.iter().any(|r| r == "SWC-107"),
"Mythril SWC reference should be carried through"
);
assert_eq!(findings[1]["severity"], "medium");
}
#[test]
fn test_import_semgrep_json_end_to_end() {
let (_dir, project) = create_temp_project(&[]);
std::fs::write(project.join("semgrep_out.json"), SEMGREP_RESULTS_JSON)
.expect("write Semgrep JSON fixture");
let result = run_binary_in(
&project,
&["import", "--from", "semgrep", "semgrep_out.json", "--json"],
);
assert!(
result.status.success(),
"import should succeed: {}",
String::from_utf8_lossy(&result.stderr)
);
let stdout = String::from_utf8_lossy(&result.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout should be valid JSON: {e}\n{stdout}"));
assert_eq!(json["tool"], "Semgrep");
let findings = json["findings"].as_array().expect("findings array");
assert_eq!(
findings.len(),
2,
"two results should import as two findings"
);
let first = &findings[0];
assert!(
first["id"].as_str().unwrap().starts_with("SG-"),
"imported ids should use the SG- prefix"
);
assert_eq!(first["severity"], "high");
assert_eq!(first["title"], "External call before state update");
assert_eq!(first["file"], "contracts/Vault.sol");
assert_eq!(first["line"], 42);
assert_eq!(first["category"], "semgrep:solidity.reentrancy");
assert!(
first["code_snippet"].as_str().unwrap().contains("call"),
"source lines should be carried as the code snippet"
);
let refs = first["references"].as_array().unwrap();
assert!(
refs.iter().any(|r| r == "CWE-1077"),
"Semgrep CWE reference should be carried through"
);
assert_eq!(findings[1]["severity"], "medium");
assert!(findings[1]["references"]
.as_array()
.unwrap()
.iter()
.any(|r| r == "CWE-477"));
}
#[test]
fn test_import_semgrep_writes_unified_report_file() {
let (_dir, project) = create_temp_project(&[]);
std::fs::write(project.join("semgrep_out.json"), SEMGREP_RESULTS_JSON)
.expect("write Semgrep JSON fixture");
let result = run_binary_in(
&project,
&[
"import",
"--from",
"semgrep",
"semgrep_out.json",
"--json",
"--output",
"unified.json",
],
);
assert!(
result.status.success(),
"import with --output should succeed: {}",
String::from_utf8_lossy(&result.stderr)
);
let report = std::fs::read_to_string(project.join("unified.json"))
.expect("unified report should be written to disk");
let json: serde_json::Value = serde_json::from_str(&report).expect("report is valid JSON");
assert_eq!(json["tool"], "Semgrep");
assert_eq!(json["findings"].as_array().unwrap().len(), 2);
assert_eq!(
json["source_files"],
serde_json::json!(["contracts/Vault.sol"])
);
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(
stderr.contains("Unified report written"),
"stderr should mention the written report: {stderr}"
);
}
const FORGE_GUARD_AUDIT_JSON: &str = r#"{
"project_name": "Vault",
"chain": "ethereum",
"timestamp": "2026-08-09T12:00:00Z",
"duration_seconds": 2.5,
"findings": [
{
"id": "FG-H-001",
"title": "reentrancy-eth (High)",
"description": "Reentrancy in withdraw (also reported by Slither)",
"severity": "high",
"file": "contracts/Vault.sol",
"line": 42,
"column": 8,
"code_snippet": "(bool ok, ) = msg.sender.call{value: amount}(\"\");",
"recommendation": "Apply checks-effects-interactions",
"category": "Security",
"blocks_deployment": true
},
{
"id": "FG-M-002",
"title": "Unrelated issue",
"description": "A finding Slither did not report",
"severity": "medium",
"file": "contracts/Other.sol",
"line": 7,
"recommendation": "Fix it",
"category": "Security"
}
],
"scores": {
"access_control": 85, "security": 55, "fuzzing": 100, "gas": 92,
"architecture": 75, "upgradeability": 100, "dependencies": 90,
"deployment": 80, "proxy_safety": 95, "chain_compatibility": 90,
"production_readiness": 60, "exploit_resistance": 70
},
"overall_score": 62,
"risk_level": "medium",
"production_ready": false,
"deployment_approved": false,
"summary": {
"total_findings": 2, "critical_count": 0, "high_count": 1, "medium_count": 1,
"low_count": 0, "info_count": 0, "files_analyzed": 2, "lines_analyzed": 120,
"contracts_analyzed": 2
}
}"#;
#[test]
fn test_import_slither_dedups_against_forge_guard_audit() {
let (_dir, project) = create_temp_project(&[]);
std::fs::write(project.join("slither_out.json"), SLITHER_RESULTS_JSON)
.expect("write Slither JSON fixture");
std::fs::write(project.join("audit_result.json"), FORGE_GUARD_AUDIT_JSON)
.expect("write forge-guard audit fixture");
let result = run_binary_in(
&project,
&[
"import",
"--from",
"slither",
"slither_out.json",
"--findings",
"audit_result.json",
"--json",
],
);
assert!(
result.status.success(),
"import with --findings should succeed: {}",
String::from_utf8_lossy(&result.stderr)
);
let stdout = String::from_utf8_lossy(&result.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout should be valid JSON: {e}\n{stdout}"));
assert_eq!(json["tool"], "Slither");
assert_eq!(json["merged_with_forge_guard"], true);
assert_eq!(
json["duplicates_removed"], 1,
"the Slither reentrancy finding duplicates the forge-guard one"
);
let findings = json["findings"].as_array().expect("findings array");
assert_eq!(
findings.len(),
3,
"2 forge-guard + 2 imported - 1 duplicate"
);
assert_eq!(findings[0]["id"], "FG-H-001");
assert_eq!(findings[1]["id"], "FG-M-002");
let last = &findings[2];
assert!(
last["id"].as_str().unwrap().starts_with("SL-"),
"kept imported finding should use the SL- prefix"
);
assert_eq!(last["title"], "uninitialized-state (Low)");
assert_eq!(last["file"], "contracts/Vault.sol");
assert_eq!(last["line"], 12);
assert_eq!(last["severity"], "low");
}
#[test]
fn test_import_missing_findings_file_errors() {
let (_dir, project) = create_temp_project(&[]);
std::fs::write(project.join("slither_out.json"), SLITHER_RESULTS_JSON)
.expect("write Slither JSON fixture");
let result = run_binary_in(
&project,
&[
"import",
"--from",
"slither",
"slither_out.json",
"--findings",
"no_such_audit.json",
],
);
assert!(
!result.status.success(),
"import with a missing --findings file should fail"
);
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(
stderr.contains("Forge-guard findings file not found"),
"stderr should explain the missing findings file: {stderr}"
);
assert!(
stderr.contains("no_such_audit.json"),
"stderr should name the missing file: {stderr}"
);
}
#[test]
fn test_import_invalid_findings_json_errors() {
let (_dir, project) = create_temp_project(&[]);
std::fs::write(project.join("slither_out.json"), SLITHER_RESULTS_JSON)
.expect("write Slither JSON fixture");
std::fs::write(project.join("bad_audit.json"), r#"{"foo": "bar"}"#)
.expect("write invalid audit fixture");
let result = run_binary_in(
&project,
&[
"import",
"--from",
"slither",
"slither_out.json",
"--findings",
"bad_audit.json",
],
);
assert!(
!result.status.success(),
"import with an invalid --findings file should fail"
);
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(
stderr.contains("is not a valid forge-guard audit result"),
"stderr should explain the invalid audit result: {stderr}"
);
assert!(
stderr.contains("bad_audit.json"),
"stderr should name the offending file: {stderr}"
);
}
fn run_binary_audit_in(project: &std::path::Path, args: &[&str]) -> std::process::Output {
std::fs::write(
project.join("forge-guard.toml"),
"src_dirs = [\"test-contracts/secure/src\"]\n",
)
.expect("write forge-guard.toml");
let mut full_args = vec!["audit"];
full_args.extend_from_slice(args);
run_binary_in(project, &full_args)
}
#[test]
fn test_audit_all_chains_json_end_to_end() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let output = run_binary_audit_in(&project, &["--all-chains", "--json"]);
assert!(
output.status.success(),
"audit --all-chains should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout should be valid JSON: {e}\n{stdout}"));
assert_eq!(v["chain"], "all", "aggregated result chain should be 'all'");
let chains = v["chains"].as_array().expect("chains array");
assert_eq!(
chains.len(),
17,
"all 17 supported EVM chains should be audited"
);
let names: Vec<&str> = chains.iter().map(|c| c.as_str().unwrap()).collect();
assert!(names.contains(&"Ethereum"));
assert!(names.contains(&"Base"));
assert!(names.contains(&"Arbitrum"));
assert!(names.contains(&"Robinhood"));
let findings = v["findings"].as_array().expect("findings array");
assert!(!findings.is_empty(), "findings should be present");
for f in findings {
let chain = f["chain"]
.as_str()
.expect("finding should be chain-labeled");
assert!(
names.contains(&chain),
"finding chain '{}' must be one of the audited chains",
chain
);
}
assert!(v["overall_score"].as_u64().is_some());
assert!(v["production_ready"].is_boolean());
assert!(v["deployment_approved"].is_boolean());
}
#[test]
fn test_audit_all_chains_terminal_end_to_end() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let output = run_binary_audit_in(&project, &["--all-chains"]);
assert!(
output.status.success(),
"audit --all-chains (terminal) should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("MULTI-CHAIN REPORT"),
"terminal output should show the multi-chain report"
);
assert!(stdout.contains("Per-Chain Results"));
assert!(
stdout.contains("Ethereum"),
"per-chain table should list Ethereum"
);
assert!(stdout.contains("Base"), "per-chain table should list Base");
}
#[test]
fn test_audit_all_chains_max_parallel_flag() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let output = run_binary_audit_in(
&project,
&["--all-chains", "--max-parallel-chains", "2", "--json"],
);
assert!(
output.status.success(),
"audit --all-chains --max-parallel-chains 2 should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let v: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).unwrap();
assert_eq!(v["chain"], "all");
assert_eq!(v["chains"].as_array().unwrap().len(), 17);
}
#[test]
fn test_audit_all_chains_quick_mode() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let output = run_binary_audit_in(&project, &["--all-chains", "--quick"]);
assert!(
output.status.success(),
"quick audit --all-chains should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("EXECUTIVE SUMMARY"),
"quick mode shows the executive summary"
);
}
#[test]
fn test_audit_all_chains_report_files_written() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let output = run_binary_audit_in(&project, &["--all-chains", "--report"]);
assert!(
output.status.success(),
"audit --all-chains --report should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let report_dir = project.join("reports");
assert!(report_dir.join("audit.json").exists());
assert!(report_dir.join("audit.md").exists());
assert!(report_dir.join("audit.html").exists());
let md = std::fs::read_to_string(report_dir.join("audit.md")).unwrap();
assert!(
md.contains("**Chain:** all"),
"markdown report should show the aggregate chain"
);
}
#[test]
fn test_audit_single_chain_json_chains_field() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let output = run_binary_audit_in(&project, &["--chain", "base", "--json"]);
assert!(
output.status.success(),
"audit --chain base should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let v: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).unwrap();
assert_eq!(v["chain"], "base");
let chains = v["chains"].as_array().unwrap();
assert_eq!(chains.len(), 1);
assert_eq!(chains[0], "base");
}
fn write_history_config(project: &std::path::Path) {
std::fs::write(
project.join("forge-guard.toml"),
"src_dirs = [\"test-contracts/secure/src\"]\n\
[history]\n\
db_path = \"history.db\"\n",
)
.expect("write forge-guard.toml");
}
#[test]
fn test_history_record_and_trends_end_to_end() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
write_history_config(&project);
for _ in 0..2 {
let output = run_binary_in(&project, &["audit", "--enable-history"]);
assert!(
output.status.success(),
"audit --enable-history should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Audit recorded in history database"),
"stderr should confirm the history write: {stderr}"
);
}
assert!(
project.join("history.db").exists(),
"history.db should be created next to the project"
);
let output = run_binary_in(&project, &["report", "--history"]);
assert!(
output.status.success(),
"report --history should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("SCORE TREND HISTORY"));
assert!(
stdout.contains("ethereum"),
"trend table should list the chain"
);
assert!(stdout.contains("/100"), "trend table should show scores");
assert!(
stdout.contains("Score trend"),
"history report should summarize the trend"
);
}
#[test]
fn test_history_regression_detects_new_findings() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
write_history_config(&project);
let output = run_binary_in(&project, &["audit", "--enable-history"]);
assert!(output.status.success(), "first audit should succeed");
let src = project
.join("test-contracts")
.join("secure")
.join("src")
.join("Simple.sol");
std::fs::write(&src, VULNERABLE_CONTRACT).expect("write vulnerable contract");
let output = run_binary_in(&project, &["audit"]);
assert!(output.status.success(), "second audit should succeed");
let output = run_binary_in(&project, &["report", "--regression"]);
assert!(
output.status.success(),
"report --regression should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("REGRESSION REPORT"));
assert!(
stdout.contains("New Findings Since Last Audit"),
"regression report should have a new-findings section"
);
assert!(
stdout.contains("Reentrancy"),
"new reentrancy finding should be highlighted: {stdout}"
);
}
#[test]
fn test_history_report_no_history_graceful() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
write_history_config(&project);
let output = run_binary_in(&project, &["report", "--history"]);
assert!(
output.status.success(),
"report --history with no data should not fail: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("No recorded history"),
"stderr should explain the empty history: {stderr}"
);
}
#[test]
fn test_history_regression_no_previous_audit_graceful() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
write_history_config(&project);
let output = run_binary_in(&project, &["audit"]);
assert!(output.status.success(), "audit should succeed");
let output = run_binary_in(&project, &["report", "--regression"]);
assert!(
output.status.success(),
"report --regression with no previous audit should not fail: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("No previous audit recorded"),
"stderr should explain the missing baseline: {stderr}"
);
}
fn free_port() -> u16 {
std::net::TcpListener::bind(("127.0.0.1", 0))
.expect("bind ephemeral port")
.local_addr()
.expect("local addr")
.port()
}
fn http_get(host: &str, port: u16, path: &str) -> String {
use std::io::{Read, Write};
let mut stream =
std::net::TcpStream::connect((host, port)).expect("connect to dashboard server");
stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.expect("set timeout");
let req = format!(
"GET {} HTTP/1.1\r\nHost: {}:{}\r\nConnection: close\r\n\r\n",
path, host, port
);
stream.write_all(req.as_bytes()).expect("write request");
let mut buf = String::new();
stream.read_to_string(&mut buf).expect("read response");
buf
}
#[test]
fn test_dashboard_serves_page_and_audit_api() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
std::fs::write(
project.join("forge-guard.toml"),
"src_dirs = [\"test-contracts/secure/src\"]\n",
)
.expect("write forge-guard.toml");
let output = run_binary_in(&project, &["audit"]);
assert!(
output.status.success(),
"audit should succeed before dashboard: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
project
.join(".forge-guard-cache")
.join("last_audit.json")
.exists(),
"audit should write last_audit.json"
);
let port = free_port();
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_forge-guard"))
.args([
"dashboard",
"--port",
&port.to_string(),
"--project",
project.to_str().unwrap(),
])
.spawn()
.expect("spawn dashboard");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() {
break;
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
panic!("dashboard server did not start in time");
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let page = http_get("127.0.0.1", port, "/");
assert!(
page.contains("200 OK"),
"dashboard should return HTTP 200: {page}"
);
assert!(
page.contains("Forge Guard") && page.contains("Dashboard"),
"page should be the dashboard: {}",
&page[..page.len().min(200)]
);
let api = http_get("127.0.0.1", port, "/api/audit");
assert!(
api.contains("200 OK"),
"api should return HTTP 200: {}",
&api[..api.len().min(200)]
);
let body = api.split("\r\n\r\n").nth(1).unwrap_or("");
let v: serde_json::Value =
serde_json::from_str(body).expect("api body should be valid audit JSON");
assert_eq!(v["chain"], "ethereum");
assert!(v["findings"].is_array());
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn test_dashboard_api_404_without_audit() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
let port = free_port();
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_forge-guard"))
.args([
"dashboard",
"--port",
&port.to_string(),
"--project",
project.to_str().unwrap(),
])
.spawn()
.expect("spawn dashboard");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() {
break;
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
panic!("dashboard server did not start in time");
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let api = http_get("127.0.0.1", port, "/api/audit");
assert!(
api.contains("404 Not Found"),
"api without an audit should be 404: {}",
&api[..api.len().min(200)]
);
assert!(
api.contains("No audit result"),
"404 body should hint: {api}"
);
let _ = child.kill();
let _ = child.wait();
}
fn ws_read_first_frame(port: u16, timeout_ms: u64) -> Option<String> {
use std::io::{Read, Write};
let mut stream =
std::net::TcpStream::connect(("127.0.0.1", port)).expect("connect to dashboard");
stream
.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))
.expect("set timeout");
let key = "dGhlIHNhbXBsZSBub25jZQ=="; let req = format!(
"GET /ws HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n",
port, key
);
stream
.write_all(req.as_bytes())
.expect("write ws handshake");
let mut buf = Vec::with_capacity(8192);
let mut leftover = 0usize;
loop {
let mut chunk = [0u8; 4096];
let n = stream.read(&mut chunk).expect("read ws handshake");
if n == 0 {
return None;
}
buf.extend_from_slice(&chunk[..n]);
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
leftover = pos + 4;
let headers = String::from_utf8_lossy(&buf[..pos + 4]);
assert!(
headers.contains("101 Switching Protocols"),
"ws upgrade should return 101: {}",
headers.lines().next().unwrap_or("")
);
break;
}
}
let mut frame = vec![0u8; 2];
read_ws_bytes(&mut stream, &mut buf, &mut leftover, &mut frame);
let opcode = frame[0] & 0x0f;
let len = (frame[1] & 0x7f) as usize;
let mut payload_len = len;
if len == 126 {
let mut ext = [0u8; 2];
read_ws_bytes(&mut stream, &mut buf, &mut leftover, &mut ext);
payload_len = u16::from_be_bytes(ext) as usize;
} else if len == 127 {
let mut ext = [0u8; 8];
read_ws_bytes(&mut stream, &mut buf, &mut leftover, &mut ext);
payload_len = u64::from_be_bytes(ext) as usize;
}
let mut payload = vec![0u8; payload_len];
read_ws_bytes(&mut stream, &mut buf, &mut leftover, &mut payload);
if opcode == 0x1 {
Some(String::from_utf8(payload).expect("ws text frame utf8"))
} else {
None
}
}
fn read_ws_bytes(
stream: &mut std::net::TcpStream,
buf: &mut Vec<u8>,
leftover: &mut usize,
out: &mut [u8],
) {
use std::io::Read;
let mut filled = 0;
let from_buf = (*leftover..buf.len()).len().min(out.len());
out[..from_buf].copy_from_slice(&buf[*leftover..*leftover + from_buf]);
*leftover += from_buf;
filled += from_buf;
while filled < out.len() {
let n = stream
.read(&mut out[filled..])
.expect("read ws frame bytes");
if n == 0 {
break;
}
filled += n;
}
}
#[test]
fn test_dashboard_websocket_pushes_audit_result() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
std::fs::write(
project.join("forge-guard.toml"),
"src_dirs = [\"test-contracts/secure/src\"]\n",
)
.expect("write forge-guard.toml");
let output = run_binary_in(&project, &["audit"]);
assert!(
output.status.success(),
"audit should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let port = free_port();
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_forge-guard"))
.args([
"dashboard",
"--port",
&port.to_string(),
"--project",
project.to_str().unwrap(),
])
.spawn()
.expect("spawn dashboard");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() {
break;
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
panic!("dashboard server did not start in time");
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let frame = ws_read_first_frame(port, 3000).expect("expected a ws text frame");
let v: serde_json::Value = serde_json::from_str(&frame).expect("ws frame should be audit JSON");
assert_eq!(v["chain"], "ethereum");
assert!(v["findings"].is_array());
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn test_dashboard_websocket_broadcasts_reaudit() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
std::fs::write(
project.join("forge-guard.toml"),
"src_dirs = [\"test-contracts/secure/src\"]\n",
)
.expect("write forge-guard.toml");
let output = run_binary_in(&project, &["audit"]);
assert!(
output.status.success(),
"initial audit should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let port = free_port();
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_forge-guard"))
.args([
"dashboard",
"--port",
&port.to_string(),
"--project",
project.to_str().unwrap(),
"--watch",
"--dirs",
"test-contracts/secure/src",
])
.spawn()
.expect("spawn dashboard with --watch");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() {
break;
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
panic!("dashboard server did not start in time");
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let first = ws_read_first_frame(port, 3000).expect("initial ws frame");
let v: serde_json::Value = serde_json::from_str(&first).expect("initial frame is JSON");
assert!(v["findings"].is_array());
let contract = project
.join("test-contracts")
.join("secure")
.join("src")
.join("Simple.sol");
let mut content = std::fs::read_to_string(&contract).expect("read contract");
content.push_str("\n// dashboard re-audit\n");
std::fs::write(&contract, content).expect("touch contract");
let second = ws_read_first_frame(port, 15000).expect("broadcast after re-audit");
let v2: serde_json::Value = serde_json::from_str(&second).expect("broadcast frame is JSON");
assert!(v2["findings"].is_array());
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn test_dashboard_history_api_serves_recorded_trends() {
let (_dir, project) = create_temp_project(&[("Simple.sol", CLEAN_CONTRACT)]);
std::fs::write(
project.join("forge-guard.toml"),
"src_dirs = [\"test-contracts/secure/src\"]\n\
[history]\n\
db_path = \"history.db\"\n",
)
.expect("write forge-guard.toml");
for _ in 0..2 {
let output = run_binary_in(&project, &["audit", "--enable-history"]);
assert!(
output.status.success(),
"audit --enable-history should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let port = free_port();
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_forge-guard"))
.args([
"dashboard",
"--port",
&port.to_string(),
"--project",
project.to_str().unwrap(),
])
.spawn()
.expect("spawn dashboard");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() {
break;
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
panic!("dashboard server did not start in time");
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let api = http_get("127.0.0.1", port, "/api/history");
assert!(
api.contains("200 OK"),
"history api should return 200: {}",
&api[..api.len().min(200)]
);
let body = api.split("\r\n\r\n").nth(1).unwrap_or("");
let points: serde_json::Value =
serde_json::from_str(body).expect("history api body should be valid JSON");
let arr = points.as_array().expect("history api should be an array");
assert!(
arr.len() >= 2,
"two recorded audits should produce two history points: {body}"
);
for point in arr {
assert!(point["overall_score"].is_number(), "point has a score");
assert!(point["timestamp"].is_string(), "point has a timestamp");
assert!(
point["total_findings"].is_number(),
"point has finding count"
);
}
let page = http_get("127.0.0.1", port, "/");
assert!(
page.contains("Score Trend"),
"page should have the trend panel"
);
assert!(
page.contains("/api/history"),
"page should fetch the history API"
);
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn test_ci_vscode_generates_extension_config() {
let (_dir, project) = create_temp_project(&[]);
std::fs::write(
project.join("forge-guard.toml"),
"solc_version = \"0.8.23\"\nremappings = [\"@openzeppelin/=lib/openzeppelin-contracts/\"]\n",
)
.expect("write forge-guard.toml");
let output = run_binary_in(&project, &["ci", "--vscode"]);
assert!(
output.status.success(),
"ci --vscode should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let settings: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(project.join(".vscode/settings.json")).expect("settings.json"),
)
.expect("settings.json is valid JSON");
assert_eq!(
settings["solidity.validation.disable"],
serde_json::json!(false)
);
assert_eq!(
settings["solidity.validation.requiredCompilerVersion"],
serde_json::json!("0.8.23")
);
assert_eq!(
settings["solidity.remappings"][0],
serde_json::json!("@openzeppelin/=lib/openzeppelin-contracts/")
);
assert_eq!(
settings["emeraldwalk.runonsave"]["commands"][0]["cmd"],
serde_json::json!("forge-guard audit --offline")
);
let tasks: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(project.join(".vscode/tasks.json")).expect("tasks.json"),
)
.expect("tasks.json is valid JSON");
assert_eq!(
tasks["tasks"][0]["label"],
serde_json::json!("forge-guard: audit")
);
let matchers = tasks["tasks"][0]["problemMatcher"].as_array().unwrap();
assert_eq!(matchers.len(), 3);
assert_eq!(matchers[0]["severity"], serde_json::json!("error"));
assert_eq!(matchers[1]["severity"], serde_json::json!("warning"));
assert_eq!(matchers[2]["severity"], serde_json::json!("info"));
assert!(matchers[0]["pattern"][0]["regexp"]
.as_str()
.unwrap()
.contains("CRITICAL"));
let extensions: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(project.join(".vscode/extensions.json")).expect("extensions.json"),
)
.expect("extensions.json is valid JSON");
assert!(extensions["recommendations"]
.as_array()
.unwrap()
.iter()
.any(|r| r == &serde_json::json!("juanblanco.solidity")));
let second = run_binary_in(&project, &["ci", "--vscode"]);
assert!(
!second.status.success(),
"ci --vscode should refuse to overwrite without --overwrite"
);
assert!(String::from_utf8_lossy(&second.stderr).contains("--overwrite"));
let third = run_binary_in(&project, &["ci", "--vscode", "--overwrite"]);
assert!(
third.status.success(),
"ci --vscode --overwrite should succeed: {}",
String::from_utf8_lossy(&third.stderr)
);
}