use forge_guard::chains::ChainRegistry;
use forge_guard::core::*;
use forge_guard::plugins::PluginRegistry;
use forge_guard::security::checks;
use forge_guard::security::engine::SecurityEngine;
use forge_guard::templates;
use std::path::PathBuf;
fn default_engine() -> SecurityEngine {
let config = ProjectConfig::default();
let registry = PluginRegistry::new(&config).unwrap();
SecurityEngine::new(&config, ®istry).unwrap()
}
fn write_contract(dir: &tempfile::TempDir, name: &str, content: &str) -> PathBuf {
let path = dir.path().join(name);
std::fs::write(&path, content).unwrap();
path
}
#[test]
fn test_engine_creation() {
let engine = default_engine();
let scores = engine.calculate_scores(&[]);
assert_eq!(scores.access_control, 100);
}
#[test]
fn test_engine_disabled_checks() {
let mut config = ProjectConfig::default();
config.security.enable_high = false;
config.security.enable_medium = false;
config.security.enable_low = false;
config.security.enable_info = false;
let registry = PluginRegistry::new(&config).unwrap();
let engine = SecurityEngine::new(&config, ®istry).unwrap();
let scores = engine.calculate_scores(&[]);
assert_eq!(scores.access_control, 100);
}
#[test]
fn test_engine_quick_mode_filters_correctly() {
let dir = tempfile::tempdir().unwrap();
let file = write_contract(
&dir,
"Vuln.sol",
"contract Vuln {\n function go() public {\n delegatecall(msg.data);\n tx.origin;\n selfdestruct(payable(msg.sender));\n }\n}\n",
);
let chain_registry = ChainRegistry::default();
let engine = default_engine();
let findings = engine
.analyze_files_quick(&[file], &chain_registry)
.unwrap();
assert!(
!findings.is_empty(),
"Quick mode should find high-severity issues"
);
}
fn analyze_with_config(config: &ProjectConfig, source: &str) -> Vec<Finding> {
let dir = tempfile::tempdir().unwrap();
let file = write_contract(&dir, "Test.sol", source);
let registry = PluginRegistry::new(config).unwrap();
let engine = SecurityEngine::new(config, ®istry).unwrap();
let chain_registry = ChainRegistry::default();
engine.analyze_files(&[file], &chain_registry).unwrap()
}
#[test]
fn test_engine_disabled_checks_suppress_findings() {
let source = "contract Auth {\n function protected() public {\n require(tx.origin == msg.sender);\n }\n}\n";
let control = analyze_with_config(&ProjectConfig::default(), source);
assert!(
control.iter().any(|f| f.id.starts_with("FA-H-004")),
"Sanity: FA-H-004 should fire without disable list"
);
let mut config = ProjectConfig::default();
config.security.disabled_checks = vec!["FA-H-004".into()]; let findings = analyze_with_config(&config, source);
assert!(
!findings.iter().any(|f| f.id.starts_with("FA-H-004")),
"Disabled check FA-H-004 should not fire, got: {:?}",
findings.iter().map(|f| &f.id).collect::<Vec<_>>()
);
}
#[test]
fn test_engine_disabled_checks_keep_other_checks() {
let mut config = ProjectConfig::default();
config.security.disabled_checks = vec!["FA-H-004".into()];
let findings = analyze_with_config(
&config,
"contract Destroy {\n function kill() public {\n selfdestruct(payable(msg.sender));\n }\n}\n",
);
assert!(
findings.iter().any(|f| f.id.starts_with("FA-H-009")),
"FA-H-009 should still fire when only FA-H-004 is disabled"
);
}
#[test]
fn test_engine_enabled_checks_bypass_severity_gate() {
let mut config = ProjectConfig::default();
config.security.enable_high = false;
config.security.enable_medium = false;
config.security.enable_low = false;
config.security.enable_info = false;
config.security.enabled_checks = vec!["FA-H-011".into()];
let findings = analyze_with_config(
&config,
"contract Dex {\n function getPrice() public view returns (uint256) {\n return price;\n }\n uint256 public price;\n}\n",
);
assert!(
findings.iter().any(|f| f.id.starts_with("FA-H-011")),
"Force-enabled FA-H-011 should fire despite enable_high = false"
);
}
#[test]
fn test_engine_all_checks_disabled_no_findings() {
let mut config = ProjectConfig::default();
config.security.enable_high = false;
config.security.enable_medium = false;
config.security.enable_low = false;
config.security.enable_info = false;
let findings = analyze_with_config(
&config,
"contract Vuln {\n function kill() public {\n selfdestruct(payable(msg.sender));\n }\n}\n",
);
assert!(
findings.is_empty(),
"With all severity gates off and no force-enabled checks, no findings expected"
);
}
#[test]
fn test_template_applied_to_config_drives_engine() {
let mut config = ProjectConfig::default();
config.security.enable_high = false;
config.security.enable_medium = false;
config.security.enable_low = false;
config.security.enable_info = false;
let t = templates::get_template("defi").unwrap();
templates::apply_to_config(&mut config, &t);
let findings = analyze_with_config(
&config,
"contract Dex {\n function getPrice() public view returns (uint256) {\n return price;\n }\n uint256 public price;\n}\n",
);
assert!(
findings.iter().any(|f| f.id.starts_with("FA-H-011")),
"defi template should force-enable FA-H-011, got: {:?}",
findings.iter().map(|f| &f.id).collect::<Vec<_>>()
);
}
#[test]
fn test_template_disabled_checks_suppress_via_config() {
let source = "contract Vault {\n mapping(address => uint256) public balances;\n function withdraw(uint256 amount) public {\n (bool ok, ) = msg.sender.call{value: amount}(\"\");\n require(ok, \"failed\");\n balances[msg.sender] -= amount;\n }\n}\n";
let control = analyze_with_config(&ProjectConfig::default(), source);
assert!(
control.iter().any(|f| f.id.starts_with("FA-H-001")),
"Sanity: FA-H-001 should fire without template suppression"
);
let mut config = ProjectConfig::default();
let mut t = templates::AuditTemplate::new("custom", "No reentrancy");
t.disabled_checks = vec!["FA-H-001".into()];
templates::apply_to_config(&mut config, &t);
let findings = analyze_with_config(&config, source);
assert!(
!findings.iter().any(|f| f.id.starts_with("FA-H-001")),
"Template-disabled FA-H-001 should not fire, got: {:?}",
findings.iter().map(|f| &f.id).collect::<Vec<_>>()
);
}
#[test]
fn test_detect_delegatecall() {
let dir = tempfile::tempdir().unwrap();
let file = write_contract(
&dir,
"Delegatecall.sol",
"contract Proxy {\n function execute(address target, bytes memory data) public {\n target.delegatecall(data);\n }\n}\n",
);
let chain_registry = ChainRegistry::default();
let engine = default_engine();
let findings = engine.analyze_files(&[file], &chain_registry).unwrap();
assert!(
findings.iter().any(|f| f.id.starts_with("FA-H-003")),
"Should detect delegatecall usage"
);
}
#[test]
fn test_detect_tx_origin() {
let dir = tempfile::tempdir().unwrap();
let file = write_contract(
&dir,
"TxOrigin.sol",
"contract Auth {\n function protected() public {\n require(tx.origin == owner);\n }\n}\n",
);
let chain_registry = ChainRegistry::default();
let engine = default_engine();
let findings = engine.analyze_files(&[file], &chain_registry).unwrap();
assert!(
findings.iter().any(|f| f.id.starts_with("FA-H-004")),
"Should detect tx.origin usage"
);
}
#[test]
fn test_detect_selfdestruct() {
let dir = tempfile::tempdir().unwrap();
let file = write_contract(
&dir,
"Selfdestruct.sol",
"contract Destroy {\n function kill() public {\n selfdestruct(payable(msg.sender));\n }\n}\n",
);
let chain_registry = ChainRegistry::default();
let engine = default_engine();
let findings = engine.analyze_files(&[file], &chain_registry).unwrap();
assert!(
findings.iter().any(|f| f.id.starts_with("FA-H-009")),
"Should detect selfdestruct"
);
}
#[test]
fn test_detect_unsafe_assembly() {
let dir = tempfile::tempdir().unwrap();
let file = write_contract(
&dir,
"Assembly.sol",
"contract Asm {\n function readStorage() public view returns (uint256 val) {\n assembly {\n val := sload(0)\n }\n }\n}\n",
);
let chain_registry = ChainRegistry::default();
let engine = default_engine();
let findings = engine.analyze_files(&[file], &chain_registry).unwrap();
assert!(
findings.iter().any(|f| f.id.starts_with("FA-H-008")),
"Should detect unsafe assembly"
);
}
#[test]
fn test_detect_create2() {
let dir = tempfile::tempdir().unwrap();
let file = write_contract(
&dir,
"Create2.sol",
"contract Factory {\n function deploy() public returns (address addr) {\n addr = address(new MyContract{salt: keccak256(abi.encode(msg.sender))}());\n }\n}\n\ncontract MyContract {}\n",
);
let chain_registry = ChainRegistry::default();
let engine = default_engine();
let findings = engine.analyze_files(&[file], &chain_registry).unwrap();
let _ = findings.len();
}
#[test]
fn test_detect_erc20_issues() {
let dir = tempfile::tempdir().unwrap();
let file = write_contract(
&dir,
"ERC20.sol",
"contract MyToken {\n string public name = \"Token\";\n function transfer(address to, uint256 amount) public returns (bool) {\n _transfer(msg.sender, to, amount);\n return true;\n }\n function _transfer(address from, address to, uint256 amount) internal {\n balances[from] -= amount;\n balances[to] += amount;\n }\n mapping(address => uint256) public balances;\n}\n",
);
let chain_registry = ChainRegistry::default();
let engine = default_engine();
let findings = engine.analyze_files(&[file], &chain_registry).unwrap();
let _ = findings.len();
}
fn make_finding(id: &str, title: &str, severity: Severity, category: &str) -> Finding {
Finding::builder()
.id(id)
.title(title)
.description("Test finding")
.severity(severity)
.file("Test.sol")
.code("test")
.recommendation("Fix")
.category(category)
.build()
}
#[test]
fn test_score_critical_deduction() {
let engine = default_engine();
let findings = vec![make_finding(
"T1",
"Critical",
Severity::Critical,
"Security",
)];
let scores = engine.calculate_scores(&findings);
assert_eq!(scores.security, 70); assert_eq!(scores.production_readiness, 85); }
#[test]
fn test_score_high_deduction() {
let engine = default_engine();
let findings = vec![make_finding("T2", "High", Severity::High, "Access Control")];
let scores = engine.calculate_scores(&findings);
assert_eq!(scores.access_control, 85); }
#[test]
fn test_score_medium_deduction() {
let engine = default_engine();
let findings = vec![make_finding(
"T3",
"Medium Gas Issue",
Severity::Medium,
"Gas",
)];
let scores = engine.calculate_scores(&findings);
assert_eq!(scores.gas, 92); }
#[test]
fn test_score_low_deduction() {
let engine = default_engine();
let findings = vec![make_finding(
"T4",
"Low Style Issue",
Severity::Low,
"Best Practices",
)];
let scores = engine.calculate_scores(&findings);
assert_eq!(scores.security, 97);
}
#[test]
fn test_score_info_deduction() {
let engine = default_engine();
let findings = vec![make_finding("T5", "Info", Severity::Informational, "Style")];
let scores = engine.calculate_scores(&findings);
assert_eq!(scores.security, 99); }
#[test]
fn test_score_mixed_findings() {
let engine = default_engine();
let findings = vec![
make_finding("T1", "Critical", Severity::Critical, "DeFi"),
make_finding("T2", "High", Severity::High, "Upgradeability"),
make_finding("T3", "Medium", Severity::Medium, "Gas"),
make_finding("T4", "Low", Severity::Low, "Dependencies"),
];
let scores = engine.calculate_scores(&findings);
assert_eq!(scores.security, 70);
assert_eq!(scores.exploit_resistance, 70);
assert_eq!(scores.upgradeability, 85);
assert_eq!(scores.proxy_safety, 85);
assert_eq!(scores.gas, 92);
assert_eq!(scores.dependencies, 97);
assert_eq!(scores.production_readiness, 73);
}
#[test]
fn test_score_zero_findings() {
let engine = default_engine();
let scores = engine.calculate_scores(&[]);
assert_eq!(scores.access_control, 100);
assert_eq!(scores.security, 100);
assert_eq!(scores.fuzzing, 100);
assert_eq!(scores.gas, 100);
assert_eq!(scores.architecture, 100);
assert_eq!(scores.upgradeability, 100);
assert_eq!(scores.dependencies, 100);
assert_eq!(scores.deployment, 100);
assert_eq!(scores.proxy_safety, 100);
assert_eq!(scores.chain_compatibility, 100);
assert_eq!(scores.production_readiness, 100);
assert_eq!(scores.exploit_resistance, 100);
}
#[test]
fn test_score_saturating_no_underflow() {
let engine = default_engine();
let findings: Vec<Finding> = (0..10)
.map(|i| {
make_finding(
&format!("CRIT-{}", i),
"Critical",
Severity::Critical,
"Security",
)
})
.collect();
let scores = engine.calculate_scores(&findings);
assert_eq!(scores.security, 0);
assert_eq!(scores.production_readiness, 0);
assert!(scores.access_control <= 100);
assert!(scores.production_readiness <= 100);
}
#[test]
fn test_analyze_file_not_found() {
let engine = default_engine();
let chain_registry = ChainRegistry::default();
let findings = engine
.analyze_files(&[PathBuf::from("/nonexistent/file.sol")], &chain_registry)
.unwrap();
assert!(findings.is_empty());
}
#[test]
fn test_analyze_clean_contract() {
let dir = tempfile::tempdir().unwrap();
let file = write_contract(
&dir,
"Clean.sol",
"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\n\ncontract Counter {\n uint256 private count;\n\n function increment() external {\n count += 1;\n }\n\n function getCount() external view returns (uint256) {\n return count;\n }\n}\n",
);
let chain_registry = ChainRegistry::default();
let engine = default_engine();
let findings = engine.analyze_files(&[file], &chain_registry).unwrap();
let high_findings: Vec<_> = findings
.iter()
.filter(|f| matches!(f.severity, Severity::High | Severity::Critical))
.collect();
assert!(
high_findings.is_empty(),
"Clean contract should have no high findings, got: {:?}",
high_findings.iter().map(|f| &f.id).collect::<Vec<_>>()
);
}
#[test]
fn test_analyze_vulnerable_contract_multi_check() {
let dir = tempfile::tempdir().unwrap();
let file = write_contract(
&dir,
"MultiVuln.sol",
"contract MultiVuln {\n address public owner;\n\n function withdrawAll() public {\n (bool sent, ) = msg.sender.call{value: address(this).balance}(\"\");\n require(sent, \"Failed\");\n }\n\n function setOwner(address newOwner) public {\n owner = newOwner;\n }\n\n function kill() public {\n selfdestruct(payable(msg.sender));\n }\n}\n",
);
let chain_registry = ChainRegistry::default();
let engine = default_engine();
let findings = engine.analyze_files(&[file], &chain_registry).unwrap();
assert!(
findings.iter().any(|f| f.id.starts_with("FA-H-009")),
"Should detect selfdestruct"
);
assert!(
findings.iter().any(|f| f.id.starts_with("FA-H-002")),
"Should detect missing access control"
);
}
#[test]
fn test_all_checks_registered() {
assert!(!checks::ALL_CHECKS.is_empty());
assert!(checks::check_count() >= 35);
}
#[test]
fn test_check_ids_are_unique() {
use std::collections::HashSet;
let mut ids = HashSet::new();
for check in checks::ALL_CHECKS {
assert!(ids.insert(check.id), "Duplicate check ID: {}", check.id);
}
}
#[test]
fn test_high_severity_checks_block_deployment() {
let high_checks: Vec<_> = checks::ALL_CHECKS
.iter()
.filter(|c| c.severity == "high" || c.severity == "critical")
.collect();
assert!(!high_checks.is_empty());
for check in &high_checks {
assert!(
check.blocks_deployment,
"Check {} should block deployment",
check.id
);
}
}
#[test]
fn test_check_categories_are_valid() {
let valid_categories = [
"Access Control",
"Logic",
"Security",
"DeFi",
"Upgradeability",
"Deployment",
"Gas",
"Best Practices",
"Style",
"Architecture",
"Documentation",
"Cryptography",
"Cross-Chain",
"Dependencies",
"Standards",
];
for check in checks::ALL_CHECKS {
assert!(
valid_categories.contains(&check.category),
"Check {} has invalid category: {}",
check.id,
check.category
);
}
}
#[test]
fn test_reentrancy_check_meta() {
let check = &checks::REENTRANCY;
assert_eq!(check.id, "FA-H-001");
assert_eq!(check.severity, "high");
assert!(check.blocks_deployment);
}
#[test]
fn test_access_control_check_meta() {
let check = &checks::ACCESS_CONTROL;
assert_eq!(check.id, "FA-H-002");
assert!(check.blocks_deployment);
}
#[test]
fn test_detect_gas_loop_problems() {
let dir = tempfile::tempdir().unwrap();
let file = write_contract(
&dir,
"GasLoop.sol",
"contract GasLoop {\n uint256[] public values;\n\n function sum() public view returns (uint256 total) {\n for (uint256 i = 0; i < values.length; i++) {\n total += values[i];\n }\n }\n}\n",
);
let chain_registry = ChainRegistry::default();
let engine = default_engine();
let findings = engine.analyze_files(&[file], &chain_registry).unwrap();
assert!(
findings.iter().any(|f| f.id.starts_with("FA-H-006")),
"Should detect DoS loop issue"
);
}
#[test]
fn test_analyze_multiple_files() {
let dir = tempfile::tempdir().unwrap();
let f1 = write_contract(
&dir,
"A.sol",
"contract A {\n function go() public {\n tx.origin;\n }\n}\n",
);
let f2 = write_contract(
&dir,
"B.sol",
"contract B {\n function kill() public {\n selfdestruct(payable(msg.sender));\n }\n}\n",
);
let chain_registry = ChainRegistry::default();
let engine = default_engine();
let findings = engine.analyze_files(&[f1, f2], &chain_registry).unwrap();
assert!(
findings.iter().any(|f| f.id.starts_with("FA-H-004")),
"Should detect tx.origin in A.sol"
);
assert!(
findings.iter().any(|f| f.id.starts_with("FA-H-009")),
"Should detect selfdestruct in B.sol"
);
}