use forge_guard::core::*;
use forge_guard::parser::{
self, parse_source, Contract, ContractKind, FunctionDef, Mutability, StatementKind, Visibility,
};
use forge_guard::security::checks::{self, SecurityCheckMeta, ALL_CHECKS};
use forge_guard::security::engine::SecurityEngine;
use forge_guard::plugins::{
register_default_plugins, ExamplePlugin, OfflineGuardPlugin, Plugin, PluginContext,
PluginExecutionStats, PluginInfo, PluginIpcFinding, PluginIpcInput, PluginIpcOutput,
PluginRegistry, PluginType,
};
use forge_guard::reports::{
count_recommendations, generate_executive_summary, json::JsonReport, markdown,
ReportGenerator,
};
use forge_guard::gas;
use forge_guard::exploit;
use std::path::PathBuf;
#[test]
fn test_severity_ordering() {
assert!(Severity::Critical > Severity::High);
assert!(Severity::High > Severity::Medium);
assert!(Severity::Medium > Severity::Low);
assert!(Severity::Low > Severity::Informational);
assert_eq!(Severity::Critical.score(), 5);
assert_eq!(Severity::Informational.score(), 1);
assert_eq!(Severity::High.label(), "HIGH");
assert_eq!(Severity::Low.label(), "LOW");
assert_eq!(Severity::Critical.to_string(), "CRITICAL");
}
#[test]
fn test_finding_builder_full() {
let finding = Finding::builder()
.id("FA-TEST-001")
.title("Test Finding")
.description("A description")
.severity(Severity::High)
.file("Test.sol")
.location(42, 5)
.code("dangerous();")
.recommendation("Fix it")
.category("Security")
.blocks_deployment(true)
.exploit_path(vec!["Step 1".into(), "Step 2".into()])
.reference("SWC-101")
.reference("CVE-2026-0001")
.build();
assert_eq!(finding.id, "FA-TEST-001");
assert_eq!(finding.title, "Test Finding");
assert_eq!(finding.description, "A description");
assert_eq!(finding.severity, Severity::High);
assert_eq!(finding.file.unwrap(), "Test.sol");
assert_eq!(finding.line.unwrap(), 42);
assert_eq!(finding.column.unwrap(), 5);
assert_eq!(finding.code_snippet.unwrap(), "dangerous();");
assert_eq!(finding.recommendation, "Fix it");
assert_eq!(finding.category, "Security");
assert!(finding.blocks_deployment);
assert_eq!(finding.exploit_path.unwrap().len(), 2);
assert_eq!(finding.references.len(), 2);
}
#[test]
fn test_finding_builder_minimal() {
let finding = Finding::builder()
.title("Minimal")
.description("Minimal finding")
.severity(Severity::Low)
.build();
assert!(finding.id.starts_with("FA-"));
assert_eq!(finding.title, "Minimal");
assert_eq!(finding.severity, Severity::Low);
assert_eq!(finding.recommendation, "");
assert_eq!(finding.category, "General");
assert!(!finding.blocks_deployment);
assert!(finding.references.is_empty());
}
#[test]
fn test_security_scores_perfect() {
let scores = SecurityScores::perfect();
assert_eq!(scores.access_control, 100);
assert_eq!(scores.security, 100);
assert_eq!(scores.exploit_resistance, 100);
assert_eq!(scores.production_readiness, 100);
assert_eq!(scores.deployment, 100);
}
#[test]
fn test_risk_level_display() {
assert_eq!(RiskLevel::Critical.to_string(), "CRITICAL");
assert_eq!(RiskLevel::High.to_string(), "HIGH");
assert_eq!(RiskLevel::Medium.to_string(), "MEDIUM");
assert_eq!(RiskLevel::Low.to_string(), "LOW");
assert_eq!(RiskLevel::Minimal.to_string(), "MINIMAL");
}
#[test]
fn test_chain_id_resolution() {
assert_eq!(ChainId::from_name("ethereum"), ChainId::Ethereum);
assert_eq!(ChainId::from_name("ETH"), ChainId::Ethereum);
assert_eq!(ChainId::from_name("base"), ChainId::Base);
assert_eq!(ChainId::from_name("arbitrum"), ChainId::Arbitrum);
assert_eq!(ChainId::from_name("optimism"), ChainId::Optimism);
assert_eq!(ChainId::from_name("polygon"), ChainId::Polygon);
assert_eq!(ChainId::from_name("bnb"), ChainId::Bnb);
assert_eq!(ChainId::from_name("avalanche"), ChainId::Avalanche);
assert_eq!(ChainId::from_name("monad"), ChainId::Monad);
assert_eq!(ChainId::from_name("unknown-chain"), ChainId::Other("unknown-chain".into()));
}
#[test]
fn test_chain_id_supported() {
assert!(ChainId::Ethereum.is_supported());
assert!(ChainId::Base.is_supported());
assert!(!ChainId::Other("foobar".into()).is_supported());
}
#[test]
fn test_chain_id_name() {
assert_eq!(ChainId::Ethereum.name(), "Ethereum");
assert_eq!(ChainId::ZkSync.name(), "ZKSync");
assert_eq!(ChainId::HyperEvm.name(), "HyperEVM");
assert_eq!(ChainId::Other("Custom".into()).name(), "Custom");
}
#[test]
fn test_supported_chains_list() {
let chains = ChainId::supported();
assert!(chains.len() >= 17);
assert!(chains.contains(&ChainId::Ethereum));
assert!(chains.contains(&ChainId::Monad));
assert!(chains.contains(&ChainId::Sonic));
assert!(chains.contains(&ChainId::Robinhood));
}
#[test]
fn test_project_config_defaults() {
let config = ProjectConfig::default();
assert_eq!(config.chain, "ethereum");
assert_eq!(config.project_root, PathBuf::from("."));
assert_eq!(config.src_dirs, vec![PathBuf::from("src")]);
assert!(!config.strict);
assert!(!config.offline);
assert!(!config.production);
assert_eq!(config.output, OutputFormat::Terminal);
assert_eq!(config.min_deployment_score, 70);
assert!(config.cache_enabled);
assert_eq!(config.parallelism, 4);
}
#[test]
fn test_project_config_from_default_location() {
let config = ProjectConfig::from_default_location();
assert_eq!(config.chain, "ethereum");
}
#[test]
fn test_output_format_variants() {
assert_eq!(OutputFormat::Terminal as u8, 0);
assert_eq!(OutputFormat::Json as u8, 1);
assert_eq!(OutputFormat::Markdown as u8, 2);
assert_ne!(OutputFormat::Terminal, OutputFormat::Json);
}
#[test]
fn test_deployment_config_defaults() {
let config = DeploymentConfig::default();
assert_eq!(config.min_score, 70);
assert!(config.block_on_high);
assert!(!config.block_on_medium);
assert!(config.block_on_critical);
assert!(config.require_fuzzing);
assert!(config.require_invariants);
assert!(config.simulate_deployment);
assert!(!config.require_verification);
assert!(!config.auto_verify);
assert!(config.explorer_api_key.is_none());
}
#[test]
fn test_security_config_defaults() {
let config = SecurityConfig::default();
assert!(config.enable_high);
assert!(config.enable_medium);
assert!(config.enable_low);
assert!(!config.enable_info);
assert!(config.exploit_analysis);
assert!(!config.gas_analysis);
assert_eq!(config.max_findings_per_check, 50);
assert!(config.severity_overrides.is_empty());
}
#[test]
fn test_cache_config_defaults() {
let config = CacheConfig::default();
assert!(config.enabled);
assert_eq!(config.directory, ".forge-guard-cache");
assert_eq!(config.max_size_mb, 500);
assert_eq!(config.ttl_seconds, 3600);
}
#[test]
fn test_ai_config_defaults() {
let config = AiConfig::default();
assert_eq!(config.provider, "openai");
assert_eq!(config.model, "gpt-4");
assert_eq!(config.temperature, 0.1);
assert_eq!(config.max_tokens, 4000);
assert_eq!(config.min_confidence, 0.5);
assert!(!config.full_audit);
}
#[test]
fn test_plugin_config_defaults() {
let config = PluginConfig::default();
assert_eq!(config.directories, vec![".forge-guard/plugins"]);
assert!(config.enabled.is_empty());
assert!(config.disabled.is_empty());
assert!(!config.allow_external);
}
#[test]
fn test_forge_guard_config_defaults() {
let config = ForgeGuardConfig::default();
assert_eq!(config.deployment.min_score, 70);
assert_eq!(config.security.enable_high, true);
assert_eq!(config.report.output_dir, "reports");
assert_eq!(config.ai.provider, "openai");
}
fn sample_vulnerable_contract() -> &'static str {
r#"
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./Ownable.sol";
contract VulnerableToken is Ownable {
mapping(address => uint256) public balances;
mapping(address => mapping(address => uint256)) public allowances;
address public owner;
uint256 public totalSupply;
bool public paused;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
constructor() {
owner = msg.sender;
}
function withdraw(uint256 amount) public {
require(amount > 0, "Invalid amount");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
function withdrawUnsafe(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount;
}
function mint(address to, uint256 amount) external onlyOwner {
balances[to] += amount;
totalSupply += amount;
emit Transfer(address(0), to, amount);
}
function setAdmin(address newAdmin) external {
admin = newAdmin;
}
function transfer(address to, uint256 amount) public returns (bool) {
balances[msg.sender] -= amount;
balances[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
receive() external payable {}
struct User {
string name;
uint256 age;
}
error Unauthorized(address caller);
}
"#
}
#[test]
fn test_parse_full_contract() {
let source = parse_source(sample_vulnerable_contract());
assert_eq!(source.pragma.clone().unwrap_or_default(), "pragma solidity ^0.8.20;");
assert_eq!(source.imports.len(), 1);
assert!(source.imports[0].contains("Ownable"));
let contract = source.get_contract("VulnerableToken");
assert!(contract.is_some());
let c = contract.unwrap();
assert_eq!(c.name, "VulnerableToken");
assert_eq!(c.kind, ContractKind::Contract);
assert_eq!(c.inheritance, vec!["Ownable"]);
assert!(c.inherits_access_control());
}
#[test]
fn test_parse_state_variables() {
let source = parse_source(sample_vulnerable_contract());
let c = source.get_contract("VulnerableToken").unwrap();
let state_vars = &c.state_variables;
assert!(state_vars.len() >= 5);
let balances = c.state_variable_by_name("balances").unwrap();
assert_eq!(balances.type_name, "mapping(address => uint256)");
assert_eq!(balances.visibility, Visibility::Public);
let owner_var = c.state_variable_by_name("owner").unwrap();
assert_eq!(owner_var.visibility, Visibility::Public);
}
#[test]
fn test_parse_functions() {
let source = parse_source(sample_vulnerable_contract());
let c = source.get_contract("VulnerableToken").unwrap();
assert!(c.functions.len() >= 6);
let withdraw = c.get_function("withdrawUnsafe").unwrap();
assert_eq!(withdraw.visibility, Visibility::Public);
assert!(!withdraw.is_constructor);
assert!(!withdraw.is_fallback);
assert!(!withdraw.is_receive);
let kinds: Vec<&StatementKind> = withdraw.body.iter().map(|s| &s.kind).collect();
assert!(
kinds.contains(&&StatementKind::Guard),
"Should have a require guard"
);
assert!(
kinds.contains(&&StatementKind::ExternalCall),
"Should have an external call (.call{{)"
);
assert!(
kinds.contains(&&StatementKind::StateWrite),
"Should have a state write"
);
let ext_call_pos = kinds.iter().position(|k| **k == StatementKind::ExternalCall);
let state_write_pos = kinds.iter().position(|k| **k == StatementKind::StateWrite);
assert!(
ext_call_pos < state_write_pos,
"State write after external call = CEI violation"
);
}
#[test]
fn test_parse_mint_function() {
let source = parse_source(sample_vulnerable_contract());
let c = source.get_contract("VulnerableToken").unwrap();
let mint = c.get_function("mint").unwrap();
assert_eq!(mint.visibility, Visibility::External);
assert!(mint.has_access_control(c));
assert_eq!(mint.modifiers[0], "onlyOwner");
let has_emit = mint.body.iter().any(|s| matches!(s.kind, StatementKind::Emit));
assert!(has_emit, "mint should emit Transfer event");
}
#[test]
fn test_parse_constructor() {
let source = parse_source(sample_vulnerable_contract());
let c = source.get_contract("VulnerableToken").unwrap();
let constructor = c
.functions
.iter()
.find(|f| f.is_constructor)
.unwrap();
assert!(constructor.is_constructor);
assert!(!constructor.is_fallback);
}
#[test]
fn test_parse_receive() {
let source = parse_source(sample_vulnerable_contract());
let c = source.get_contract("VulnerableToken").unwrap();
let receive = c
.functions
.iter()
.find(|f| f.is_receive)
.unwrap();
assert!(receive.is_receive);
assert_eq!(receive.mutability, Mutability::Payable);
assert_eq!(receive.visibility, Visibility::External);
}
#[test]
fn test_parse_struct_and_error() {
let source = parse_source(sample_vulnerable_contract());
let c = source.get_contract("VulnerableToken").unwrap();
assert!(
c.structs.iter().any(|s| s.name == "User"),
"Should have User struct"
);
let user = c.structs.iter().find(|s| s.name == "User").unwrap();
assert_eq!(user.fields.len(), 2);
assert_eq!(user.fields[0].name, "name");
assert_eq!(user.fields[0].type_name, "string");
assert!(
c.errors.iter().any(|e| e.name == "Unauthorized"),
"Should have Unauthorized error"
);
}
#[test]
fn test_parse_interface() {
let source = r#"
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
}
"#;
let parsed = parse_source(source);
assert_eq!(parsed.contracts.len(), 1);
let c = &parsed.contracts[0];
assert_eq!(c.kind, ContractKind::Interface);
assert_eq!(c.name, "IERC20");
assert_eq!(c.functions.len(), 3);
for func in &c.functions {
assert!(func.body.is_empty(), "Interface functions should have empty bodies");
}
}
#[test]
fn test_parse_multiple_inheritance() {
let source = r#"
contract MyContract is Ownable, ReentrancyGuard, AccessControl {
uint256 public x;
}
"#;
let parsed = parse_source(source);
let c = &parsed.contracts[0];
assert_eq!(c.inheritance.len(), 3);
assert!(c.inherits_access_control());
assert!(c.has_reentrancy_guard_modifier());
}
#[test]
fn test_parse_function_visibility_detection() {
let source = r#"
contract Test {
function pub() public {}
function ext() external {}
function inter() internal {}
function priv() private {}
function pureFn() public pure returns (uint256) { return 1; }
}
"#;
let parsed = parse_source(source);
let funcs = &parsed.contracts[0].functions;
let f = |name: &str| funcs.iter().find(|f| f.name == name).unwrap();
assert_eq!(f("pub").visibility, Visibility::Public);
assert_eq!(f("ext").visibility, Visibility::External);
assert_eq!(f("inter").visibility, Visibility::Internal);
assert_eq!(f("priv").visibility, Visibility::Private);
assert_eq!(f("pureFn").mutability, Mutability::Pure);
}
#[test]
fn test_extract_call_target_interface() {
let target = parser::extract_call_target("IERC20(token).transfer(to, amount);");
assert_eq!(
target,
parser::CallTarget::Interface("IERC20".into())
);
}
#[test]
fn test_extract_call_target_address() {
let target = parser::extract_call_target("address(0x123).call{value: amount}(\"\");");
assert_eq!(target, parser::CallTarget::Address);
}
#[test]
fn test_all_checks_defined() {
assert!(!ALL_CHECKS.is_empty());
assert!(checks::check_count() >= 35);
}
#[test]
fn test_all_checks_metadata_valid() {
for check in ALL_CHECKS {
assert!(!check.id.is_empty(), "Check must have an ID");
assert!(!check.name.is_empty(), "Check must have a name");
assert!(!check.severity.is_empty(), "Check must have a severity");
assert!(!check.category.is_empty(), "Check must have a category");
assert!(
check.id.starts_with("FA-"),
"Check ID must start with FA-: {}",
check.id
);
}
}
#[test]
fn test_high_severity_checks_block_deployment() {
let high_checks: Vec<&SecurityCheckMeta> = 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 because severity is {}",
check.id,
check.severity
);
}
}
#[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 ALL_CHECKS {
assert!(
valid_categories.contains(&check.category),
"Check '{}' has invalid category: '{}'",
check.id,
check.category
);
}
}
#[test]
fn test_reentrancy_check_exists() {
assert_eq!(checks::REENTRANCY.id, "FA-H-001");
assert_eq!(checks::REENTRANCY.severity, "high");
assert!(checks::REENTRANCY.blocks_deployment);
assert_eq!(checks::REENTRANCY.category, "Access Control");
}
#[test]
fn test_access_control_check_exists() {
assert_eq!(checks::ACCESS_CONTROL.id, "FA-H-002");
assert!(checks::ACCESS_CONTROL.blocks_deployment);
}
#[test]
fn test_engine_calculates_scores() {
let config = ProjectConfig::default();
let registry = PluginRegistry::new(&config).unwrap();
let engine = SecurityEngine::new(&config, ®istry).unwrap();
let findings = vec![
Finding::builder()
.id("FA-H-001-10")
.title("Reentrancy")
.description("Test")
.severity(Severity::Critical)
.file("Test.sol")
.code("test")
.recommendation("Fix")
.category("Security")
.build(),
Finding::builder()
.id("FA-M-001-20")
.title("Gas")
.description("Test")
.severity(Severity::Medium)
.file("Test.sol")
.code("test")
.recommendation("Fix")
.category("Gas")
.build(),
];
let scores = engine.calculate_scores(&findings);
assert_eq!(scores.security, 70);
assert_eq!(scores.gas, 92);
assert_eq!(scores.production_readiness, 81);
}
#[test]
fn test_gas_analysis_empty() {
let result = gas::analyze_gas(&[], &ProjectConfig::default()).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_gas_analysis_loop() {
let findings = gas::analyze_gas(
&[PathBuf::from("test.sol")],
&ProjectConfig::default(),
);
assert!(findings.is_err());
}
#[test]
fn test_exploit_paths_empty() {
let result = exploit::analyze_exploit_paths(&[], &[]).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_exploit_paths_for_high_findings() {
let findings = vec![
Finding::builder()
.id("FA-H-001-1")
.title("Access Control Issue")
.description("Missing access control")
.severity(Severity::High)
.file("Test.sol")
.code("function setAdmin() { admin = msg.sender; }")
.recommendation("Add onlyOwner modifier")
.category("Access Control")
.build(),
];
let result = exploit::analyze_exploit_paths(&findings, &[]).unwrap();
assert_eq!(result.len(), 1);
let enhanced = &result[0];
let path = enhanced.exploit_path.as_ref().unwrap();
assert!(path.len() >= 3);
assert!(path[0].contains("unprotected"));
}
#[test]
fn test_exploit_paths_skips_low_findings() {
let findings = vec![
Finding::builder()
.id("FA-L-001-1")
.title("Style")
.description("Style issue")
.severity(Severity::Low)
.file("Test.sol")
.code("test")
.recommendation("Fix style")
.category("Best Practices")
.build(),
];
let result = exploit::analyze_exploit_paths(&findings, &[]).unwrap();
assert!(result.is_empty(), "Low severity findings should not get exploit paths");
}
#[test]
fn test_exploit_paths_deFi_category() {
let findings = vec![
Finding::builder()
.id("FA-DEFI-001")
.title("Oracle Manipulation")
.description("Unchecked oracle price")
.severity(Severity::Critical)
.file("Test.sol")
.code("price = oracle.getPrice();")
.recommendation("Use TWAP")
.category("DeFi")
.build(),
];
let result = exploit::analyze_exploit_paths(&findings, &[]).unwrap();
assert_eq!(result.len(), 1);
let path = result[0].exploit_path.as_ref().unwrap();
assert!(path.iter().any(|s| s.contains("flash loan")));
}
fn sample_audit_result() -> AuditResult {
let findings = vec![
Finding::builder()
.id("FA-H-001-15")
.title("Reentrancy Vulnerability")
.description("CEI violation in withdraw function")
.severity(Severity::High)
.file("Vault.sol")
.location(15, 0)
.code("(bool ok, ) = msg.sender.call{value: amount}(\"\");\nbalances[msg.sender] -= amount;")
.recommendation("Apply ReentrancyGuard modifier")
.category("Security")
.blocks_deployment(true)
.build(),
Finding::builder()
.id("FA-M-001-30")
.title("Unbounded Loop")
.description("Loop over dynamic array could run out of gas")
.severity(Severity::Medium)
.file("Vault.sol")
.location(30, 0)
.code("for (uint i = 0; i < users.length; i++)")
.recommendation("Cache array length before loop")
.category("Gas")
.build(),
Finding::builder()
.id("FA-L-001-45")
.title("Unused Variable")
.description("Variable 'temp' is never used")
.severity(Severity::Low)
.file("Vault.sol")
.location(45, 0)
.code("uint256 temp = 0;")
.recommendation("Remove unused variable")
.category("Best Practices")
.build(),
];
AuditResult {
project_name: "Vault".into(),
chain: "ethereum".into(),
timestamp: "2026-07-26T12:00:00Z".into(),
duration_seconds: 2.5,
findings,
scores: SecurityScores {
access_control: 85,
security: 55,
fuzzing: 100,
gas: 92,
architecture: 75,
upgradeability: 100,
dependencies: 100,
deployment: 80,
proxy_safety: 100,
chain_compatibility: 100,
production_readiness: 65,
exploit_resistance: 70,
},
overall_score: 72,
risk_level: RiskLevel::Medium,
production_ready: false,
deployment_approved: false,
summary: AuditSummary {
total_findings: 3,
critical_count: 0,
high_count: 1,
medium_count: 1,
low_count: 1,
info_count: 0,
files_analyzed: 5,
lines_analyzed: 350,
contracts_analyzed: 2,
},
}
}
#[test]
fn test_json_report_generation() {
let result = sample_audit_result();
let report = JsonReport;
let output = report.generate(&result).unwrap();
assert!(output.contains("Reentrancy Vulnerability"));
assert!(output.contains("FA-H-001-15"));
assert!(output.contains("ethereum"));
assert!(output.contains("Vault"));
assert!(output.contains("high"));
let deserialized: AuditResult = serde_json::from_str(&output).unwrap();
assert_eq!(deserialized.project_name, "Vault");
assert_eq!(deserialized.findings.len(), 3);
assert_eq!(deserialized.risk_level, RiskLevel::Medium);
}
#[test]
fn test_json_report_roundtrip() {
let result = sample_audit_result();
let report = JsonReport;
let json = report.generate(&result).unwrap();
let rt: AuditResult = serde_json::from_str(&json).unwrap();
assert_eq!(rt.project_name, result.project_name);
assert_eq!(rt.chain, result.chain);
assert_eq!(rt.overall_score, result.overall_score);
assert_eq!(rt.summary.total_findings, result.summary.total_findings);
assert_eq!(rt.findings.len(), result.findings.len());
assert_eq!(rt.findings[0].id, result.findings[0].id);
}
#[test]
fn test_json_report_extension() {
assert_eq!(JsonReport.extension(), "json");
}
#[test]
fn test_markdown_report_generation() {
let result = sample_audit_result();
let md = markdown::generate_report(&result).unwrap();
assert!(md.contains("Forge Guard Report"));
assert!(md.contains("Vault"));
assert!(md.contains("ethereum"));
assert!(md.contains("72/100"));
assert!(md.contains("Reentrancy Vulnerability"));
assert!(md.contains("Unbounded Loop"));
assert!(md.contains("BLOCKED"));
assert!(!md.contains("APPROVED"));
assert!(md.contains("ReentrancyGuard"));
}
#[test]
fn test_markdown_report_passed() {
let mut result = sample_audit_result();
result.findings.clear();
result.deployment_approved = true;
result.production_ready = true;
result.overall_score = 95;
result.risk_level = RiskLevel::Minimal;
let md = markdown::generate_report(&result).unwrap();
assert!(md.contains("APPROVED"));
assert!(md.contains("95/100"));
assert!(!md.contains("BLOCKED"));
}
#[test]
fn test_executive_summary() {
let result = sample_audit_result();
let summary = generate_executive_summary(&result);
assert!(summary.contains("EXECUTIVE SUMMARY"));
assert!(summary.contains("FAIL"));
assert!(summary.contains("BLOCKED"));
assert!(summary.contains("Reentrancy"));
assert!(summary.contains("ReentrancyGuard"));
assert!(summary.contains("Production Ready"));
}
#[test]
fn test_executive_summary_passed() {
let mut result = sample_audit_result();
result.deployment_approved = true;
result.production_ready = true;
result.overall_score = 95;
result.risk_level = RiskLevel::Low;
let summary = generate_executive_summary(&result);
assert!(summary.contains("PASS"));
assert!(summary.contains("APPROVED"));
}
#[test]
fn test_count_recommendations_by_category() {
let result = sample_audit_result();
let counts = count_recommendations(&result.findings);
assert_eq!(counts.get("Security").copied().unwrap_or(0), 1);
assert_eq!(counts.get("Gas").copied().unwrap_or(0), 1);
assert_eq!(counts.get("Best Practices").copied().unwrap_or(0), 1);
}
#[test]
fn test_plugin_types() {
assert_eq!(PluginType::Builtin, PluginType::Builtin);
assert_eq!(PluginType::External, PluginType::External);
assert_ne!(PluginType::Builtin, PluginType::External);
}
#[test]
fn test_example_plugin_metadata() {
let plugin = ExamplePlugin;
assert_eq!(plugin.name(), "forge-guard-example");
assert_eq!(plugin.version(), "0.1.0");
assert!(plugin.supports_offline());
assert!(!plugin.requires_rpc());
}
#[test]
fn test_offline_guard_plugin_metadata() {
let plugin = OfflineGuardPlugin;
assert_eq!(plugin.name(), "forge-guard-offline-guard");
assert!(plugin.supports_offline());
assert!(!plugin.requires_rpc());
}
#[test]
fn test_plugin_execution_basic() {
let plugin = ExamplePlugin;
let ctx = PluginContext::new(&ProjectConfig::default(), vec![]);
let result = plugin.execute(&ctx);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_registry_create_and_register() {
let config = ProjectConfig::default();
let mut registry = PluginRegistry::new(&config).unwrap();
assert_eq!(registry.plugin_count(), 0);
registry.register_builtin(Box::new(ExamplePlugin));
assert_eq!(registry.plugin_count(), 1);
registry.register_builtin(Box::new(OfflineGuardPlugin));
assert_eq!(registry.plugin_count(), 2);
}
#[test]
fn test_default_plugins_registered() {
let config = ProjectConfig::default();
let mut registry = PluginRegistry::new(&config).unwrap();
register_default_plugins(&mut registry);
assert_eq!(registry.plugin_count(), 2);
let names: Vec<String> = registry.list_plugins().iter().map(|p| p.name.clone()).collect();
assert!(names.contains(&"forge-guard-example".into()));
assert!(names.contains(&"forge-guard-offline-guard".into()));
}
#[test]
fn test_plugin_enable_disable() {
let config = ProjectConfig::default();
let mut registry = PluginRegistry::new(&config).unwrap();
register_default_plugins(&mut registry);
assert_eq!(registry.enabled_count(), 2);
assert!(registry.disable_plugin("forge-guard-example"));
assert_eq!(registry.enabled_count(), 1);
assert!(registry.enable_plugin("forge-guard-example"));
assert_eq!(registry.enabled_count(), 2);
assert!(!registry.disable_plugin("non-existent"));
assert!(!registry.enable_plugin("non-existent"));
}
#[test]
fn test_plugin_info_display() {
let info = PluginInfo {
name: "my-plugin".into(),
version: "1.0.0".into(),
description: "My test plugin".into(),
enabled: true,
plugin_type: PluginType::External,
path: Some("/tmp/plugin".into()),
};
assert_eq!(info.name, "my-plugin");
assert_eq!(info.path.unwrap().to_string_lossy(), "/tmp/plugin");
assert_eq!(info.plugin_type, PluginType::External);
}
#[test]
fn test_ipc_input_serialization() {
let input = PluginIpcInput {
protocol_version: "1.0".into(),
plugin_name: "test-plugin".into(),
context: PluginContext::new(&ProjectConfig::default(), vec![]),
};
let json = serde_json::to_string(&input).unwrap();
assert!(json.contains("protocol_version"));
assert!(json.contains("plugin_name"));
let deserialized: PluginIpcInput = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.protocol_version, "1.0");
assert_eq!(deserialized.plugin_name, "test-plugin");
}
#[test]
fn test_ipc_output_success() {
let output = PluginIpcOutput {
success: true,
findings: vec![],
error: None,
stats: PluginExecutionStats {
files_analyzed: 10,
duration_ms: 100,
},
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"success\":true"));
let deserialized: PluginIpcOutput = serde_json::from_str(&json).unwrap();
assert!(deserialized.success);
assert!(deserialized.findings.is_empty());
assert!(deserialized.error.is_none());
assert_eq!(deserialized.stats.files_analyzed, 10);
}
#[test]
fn test_ipc_output_error() {
let output = PluginIpcOutput {
success: false,
findings: vec![],
error: Some("Something went wrong".into()),
stats: PluginExecutionStats::default(),
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"success\":false"));
assert!(json.contains("Something went wrong"));
let deserialized: PluginIpcOutput = serde_json::from_str(&json).unwrap();
assert!(!deserialized.success);
assert_eq!(deserialized.error.unwrap(), "Something went wrong");
}
#[test]
fn test_ipc_finding_serialization() {
let finding = PluginIpcFinding {
title: "Plugin finding".into(),
severity: "high".into(),
description: "A plugin found a vulnerability".into(),
file: Some("Test.sol".into()),
line: Some(42),
column: None,
code_snippet: Some("vuln();".into()),
recommendation: Some("Fix it".into()),
category: Some("Plugin".into()),
blocks_deployment: true,
references: vec![],
};
let json = serde_json::to_string(&finding).unwrap();
assert!(json.contains("Plugin finding"));
let deserialized: PluginIpcFinding = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.title, "Plugin finding");
assert_eq!(deserialized.line, Some(42));
assert_eq!(deserialized.file.unwrap(), "Test.sol");
}
#[test]
fn test_forge_guard_error_display() {
let err = ForgeGuardError::FileNotFound("missing.toml".into());
let msg = err.to_string();
assert!(msg.contains("File not found"));
assert!(msg.contains("missing.toml"));
let err2 = ForgeGuardError::DeploymentBlocked("Score too low".into());
assert_eq!(err2.to_string(), "Deployment blocked: Score too low");
let err3 = ForgeGuardError::CriticalVulnerability("Reentrancy".into());
assert_eq!(err3.to_string(), "Critical vulnerability found: Reentrancy");
}
#[test]
fn test_forge_guard_error_from_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let fg_err: ForgeGuardError = io_err.into();
assert!(fg_err.to_string().contains("I/O error"));
}
#[test]
fn test_full_pipeline_with_vulnerable_contract() {
let source = parse_source(sample_vulnerable_contract());
let contract = source.get_contract("VulnerableToken").unwrap();
assert!(contract.inherits_access_control());
assert!(!contract.has_reentrancy_guard_modifier());
let withdraw = contract.get_function("withdrawUnsafe").unwrap();
assert!(!withdraw.has_reentrancy_guard());
let kinds: Vec<&StatementKind> = withdraw.body.iter().map(|s| &s.kind).collect();
let guard_pos = kinds.iter().position(|k| **k == StatementKind::Guard);
let call_pos = kinds.iter().position(|k| **k == StatementKind::ExternalCall);
let write_pos = kinds.iter().position(|k| **k == StatementKind::StateWrite);
assert!(guard_pos.is_some(), "Should have require guard");
assert!(call_pos.is_some(), "Should have external call");
assert!(write_pos.is_some(), "Should have state write");
if let (Some(call), Some(write)) = (call_pos, write_pos) {
assert!(
call < write,
"State write after external call = CEI violation"
);
}
let safe_withdraw = contract.get_function("withdraw").unwrap();
let safe_kinds: Vec<&StatementKind> = safe_withdraw.body.iter().map(|s| &s.kind).collect();
let sw_write_pos = safe_kinds.iter().position(|k| **k == StatementKind::StateWrite);
let sw_call_pos = safe_kinds.iter().position(|k| **k == StatementKind::ExternalCall);
if let (Some(write), Some(call)) = (sw_write_pos, sw_call_pos) {
assert!(
write < call,
"Safe withdraw: state write should come BEFORE external call"
);
}
}
#[test]
fn test_severity_score_calculation_pipeline() {
let config = ProjectConfig::default();
let registry = PluginRegistry::new(&config).unwrap();
let engine = SecurityEngine::new(&config, ®istry).unwrap();
let findings = vec![
Finding::builder()
.id("T1")
.title("Critical")
.description("Critical issue")
.severity(Severity::Critical)
.file("test.sol")
.code("code")
.recommendation("Fix")
.category("Access Control")
.build(),
Finding::builder()
.id("T2")
.title("High")
.description("High issue")
.severity(Severity::High)
.file("test.sol")
.code("code")
.recommendation("Fix")
.category("Security")
.build(),
Finding::builder()
.id("T3")
.title("Medium")
.description("Medium issue")
.severity(Severity::Medium)
.file("test.sol")
.code("code")
.recommendation("Fix")
.category("Gas")
.build(),
Finding::builder()
.id("T4")
.title("Low")
.description("Low issue")
.severity(Severity::Low)
.file("test.sol")
.code("code")
.recommendation("Fix")
.category("Best Practices")
.build(),
];
let scores = engine.calculate_scores(&findings);
assert_eq!(scores.access_control, 70);
assert_eq!(scores.security, 82);
assert_eq!(scores.gas, 92);
}
#[test]
fn test_markdown_grouped_by_severity() {
let result = sample_audit_result();
let md = markdown::generate_report(&result).unwrap();
assert!(md.contains("### 🔴 HIGH (1 found)"));
assert!(md.contains("### 🟡 MEDIUM (1 found)"));
assert!(md.contains("### 🔵 LOW (1 found)"));
assert!(!md.contains("CRITICAL ("));
assert!(!md.contains("INFO ("));
}
#[test]
fn test_markdown_with_no_findings() {
let mut result = sample_audit_result();
result.findings.clear();
result.deployment_approved = true;
result.production_ready = true;
result.risk_level = RiskLevel::Minimal;
let md = markdown::generate_report(&result).unwrap();
assert!(md.contains("Forge Guard Report"));
assert!(!md.contains("### 🔴"));
assert!(!md.contains("### 🟡"));
}
#[test]
fn test_executive_summary_areas_for_improvement() {
let result = sample_audit_result();
let summary = generate_executive_summary(&result);
assert!(summary.contains("65"));
assert!(summary.contains("Production Ready"));
assert!(summary.contains("Security"));
assert!(summary.contains("Security")); }
#[test]
fn test_json_serialize_deserialize_empty_findings() {
let result = AuditResult {
project_name: "empty".into(),
chain: "base".into(),
timestamp: "2026-01-01T00:00:00Z".into(),
duration_seconds: 0.0,
findings: vec![],
scores: SecurityScores::perfect(),
overall_score: 100,
risk_level: RiskLevel::Minimal,
production_ready: true,
deployment_approved: true,
summary: AuditSummary {
total_findings: 0,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
info_count: 0,
files_analyzed: 0,
lines_analyzed: 0,
contracts_analyzed: 0,
},
};
let json = serde_json::to_string_pretty(&result).unwrap();
assert!(json.contains("empty"));
assert!(json.contains("base"));
let rt: AuditResult = serde_json::from_str(&json).unwrap();
assert!(rt.findings.is_empty());
assert!(rt.deployment_approved);
assert_eq!(rt.overall_score, 100);
}
#[test]
fn test_audit_summary_counts() {
let result = sample_audit_result();
assert_eq!(result.summary.total_findings, 3);
assert_eq!(result.summary.high_count, 1);
assert_eq!(result.summary.medium_count, 1);
assert_eq!(result.summary.low_count, 1);
assert_eq!(result.summary.files_analyzed, 5);
assert_eq!(result.summary.contracts_analyzed, 2);
}
#[test]
fn test_exploit_paths_logic_category() {
let findings = vec![
Finding::builder()
.id("FA-LOGIC-001")
.title("Logic Error")
.description("Rounding in favor of user")
.severity(Severity::High)
.file("Dex.sol")
.code("amount = amount * rate / 1000;")
.recommendation("Round in favor of protocol")
.category("Logic")
.build(),
];
let result = exploit::analyze_exploit_paths(&findings, &[]).unwrap();
assert_eq!(result.len(), 1);
let path = result[0].exploit_path.as_ref().unwrap();
assert!(path.len() >= 3);
assert!(path[0].contains("logical"));
}
#[test]
fn test_forge_guard_config_file_nonexistent() {
let result = ProjectConfig::from_file("/nonexistent/path/forge-guard.toml");
assert!(result.is_err() || result.is_ok());
if let Ok(config) = result {
assert_eq!(config.chain, "ethereum");
}
}
#[test]
fn test_chain_registry_default() {
assert_eq!(ChainId::from_name("ethereum"), ChainId::Ethereum);
assert!(ChainId::Ethereum.is_supported());
}
#[test]
fn test_severity_serialize_deserialize() {
let json = serde_json::to_string(&Severity::Critical).unwrap();
assert_eq!(json, "\"critical\"");
let deserialized: Severity = serde_json::from_str("\"high\"").unwrap();
assert_eq!(deserialized, Severity::High);
}
#[test]
fn test_risk_level_serialize_deserialize() {
let json = serde_json::to_string(&RiskLevel::Medium).unwrap();
assert_eq!(json, "\"medium\"");
let deserialized: RiskLevel = serde_json::from_str("\"critical\"").unwrap();
assert_eq!(deserialized, RiskLevel::Critical);
}