use forge_guard::ci::CiGenerator;
use forge_guard::core::*;
use forge_guard::deployment::verifier::{
explorer_api_key_env, explorer_api_url, explorer_base_url, forge_available, rpc_url_for_chain,
ContractVerifier, VerificationMethod, VerificationResult,
};
use forge_guard::deployment::DeploymentGuard;
use forge_guard::exploit;
use forge_guard::fuzzing::FuzzingAdapter;
use forge_guard::fuzzing::FuzzingConfig;
use forge_guard::fuzzing::FuzzingReport;
use forge_guard::gas;
use forge_guard::parser::{
self, parse_source, ContractKind, Mutability, StatementKind, Visibility,
};
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::security::checks::{self, SecurityCheckMeta, ALL_CHECKS};
use forge_guard::security::engine::SecurityEngine;
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());
assert!(config.enabled_checks.is_empty());
assert!(config.disabled_checks.is_empty());
}
#[test]
fn test_security_config_template_fields_serialize_roundtrip() {
let config = SecurityConfig {
enable_high: true,
enable_medium: true,
enable_low: true,
enable_info: false,
exploit_analysis: true,
gas_analysis: false,
bytecode_analysis: false,
max_findings_per_check: 50,
severity_overrides: std::collections::HashMap::new(),
enabled_checks: vec!["FA-H-011".into(), "FA-H-016".into()],
disabled_checks: vec!["FA-L-001".into()],
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("FA-H-011"));
assert!(json.contains("FA-L-001"));
let back: SecurityConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back.enabled_checks, vec!["FA-H-011", "FA-H-016"]);
assert_eq!(back.disabled_checks, vec!["FA-L-001"]);
}
#[test]
fn test_project_config_parses_template_fields_from_toml() {
let dir = std::env::temp_dir().join(format!("fg-cfg-tpl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let toml_content = r#"
[security]
enable_high = true
enabled_checks = ["FA-H-011", "FA-H-016"]
disabled_checks = ["FA-H-001"]
"#;
std::fs::write(dir.join("forge-guard.toml"), toml_content).unwrap();
let config = ProjectConfig::from_file(dir.join("forge-guard.toml")).unwrap();
assert_eq!(
config.security.enabled_checks,
vec!["FA-H-011".to_string(), "FA-H-016".to_string()]
);
assert_eq!(
config.security.disabled_checks,
vec!["FA-H-001".to_string()]
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_project_config_missing_template_fields_defaults() {
let dir = std::env::temp_dir().join(format!("fg-cfg-tpl2-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("forge-guard.toml"),
"[security]\nenable_high = false\n",
)
.unwrap();
let config = ProjectConfig::from_file(dir.join("forge-guard.toml")).unwrap();
assert!(!config.security.enable_high);
assert!(config.security.enabled_checks.is_empty());
assert!(config.security.disabled_checks.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[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!(config.security.enable_high);
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]
#[allow(clippy::assertions_on_constants)]
fn test_reentrancy_check_exists() {
assert_eq!(checks::REENTRANCY.id, "FA-H-001");
assert_eq!(checks::REENTRANCY.severity, "high");
assert!(const { checks::REENTRANCY.blocks_deployment });
assert_eq!(checks::REENTRANCY.category, "Access Control");
}
#[test]
#[allow(clippy::assertions_on_constants)]
fn test_access_control_check_exists() {
assert_eq!(checks::ACCESS_CONTROL.id, "FA-H-002");
assert!(const { 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_de_fi_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(),
chains: vec!["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(),
chains: vec!["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_fuzzing_config_defaults() {
let config = FuzzingConfig::default();
assert_eq!(config.runs, 10_000);
assert_eq!(config.seed, None);
assert_eq!(config.test_filter, None);
assert_eq!(config.contract_filter, None);
assert!(!config.fail_on_revert);
}
#[test]
fn test_fuzzing_config_custom() {
let config = FuzzingConfig {
runs: 50_000,
seed: Some(42),
test_filter: Some("testFuzz".into()),
contract_filter: Some("Vault".into()),
fail_on_revert: true,
};
assert_eq!(config.runs, 50_000);
assert_eq!(config.seed, Some(42));
assert_eq!(config.test_filter, Some("testFuzz".to_string()));
assert_eq!(config.contract_filter, Some("Vault".to_string()));
assert!(config.fail_on_revert);
}
#[test]
fn test_fuzzing_report_struct() {
let report = FuzzingReport {
passed: true,
runs: 10_000,
duration_secs: 2.5,
output: "All tests passed".into(),
errors: String::new(),
};
assert!(report.passed);
assert_eq!(report.runs, 10_000);
assert_eq!(report.duration_secs, 2.5);
assert!(report.output.contains("All tests passed"));
assert!(report.errors.is_empty());
}
#[test]
fn test_fuzzing_report_failure() {
let report = FuzzingReport {
passed: false,
runs: 5_000,
duration_secs: 30.0,
output: "Some failures".into(),
errors: "Error: reentrancy detected".into(),
};
assert!(!report.passed);
assert_eq!(report.runs, 5_000);
assert!(report.errors.contains("reentrancy"));
}
#[test]
fn test_fuzzing_adapter_command_fail() {
let config = FuzzingConfig::default();
let result = FuzzingAdapter::run_forge_fuzz(&config);
assert!(
result.is_err(),
"Expected forge not found error in this environment"
);
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Failed to run fuzz") || msg.contains("Command"),
"Error message should indicate command failure, got: {}",
msg
);
}
#[test]
fn test_fuzzing_adapter_custom_config() {
let config = FuzzingConfig {
runs: 100_000,
seed: Some(12345),
test_filter: Some("testFuzzDeposit".into()),
contract_filter: Some("Vault".into()),
fail_on_revert: true,
};
let result = FuzzingAdapter::run_forge_fuzz(&config);
assert!(
result.is_err(),
"Expected forge not found error in this environment"
);
}
#[test]
fn test_ci_generator_all_platforms_produce_valid_output() {
let platforms = ["github", "gitlab", "bitbucket", "azure"];
for platform in platforms {
let gen = CiGenerator::new(platform);
let config = gen.generate(false).unwrap();
assert!(
!config.is_empty(),
"Platform {} should produce non-empty config",
platform
);
assert!(
config.contains("forge audit --strict"),
"Platform {} should include forge audit, got: {:.100}",
platform,
config
);
}
}
#[test]
fn test_ci_generator_github_full_structure() {
let gen = CiGenerator::new("github");
let config = gen.generate(false).unwrap();
assert!(config.contains("name:"));
assert!(config.contains("on:"));
assert!(config.contains("jobs:"));
assert!(config.contains("security-audit:"));
assert!(config.contains("runs-on: ubuntu-latest"));
assert!(config.contains("actions/checkout@v4"));
assert!(config.contains("foundry-rs/foundry-toolchain@v1"));
assert!(config.contains("forge fuzz --runs 10000"));
assert!(config.contains("forge invariant --runs 1000"));
assert!(config.contains("forge scan --depth 1"));
assert!(config.contains("forge audit --report --markdown"));
}
#[test]
fn test_ci_generator_github_with_deploy_job() {
let gen = CiGenerator::new("github");
let config = gen.generate(true).unwrap();
assert!(config.contains(" deploy:"));
assert!(config.contains("needs: [security-audit]"));
assert!(config.contains("forge deploy-safe"));
assert!(config.contains("secrets.ETH_RPC_URL"));
assert!(config.contains("secrets.DEPLOYER_PRIVATE_KEY"));
}
#[test]
fn test_ci_generator_github_without_deploy() {
let gen = CiGenerator::new("github");
let config_no_deploy = gen.generate(false).unwrap();
let config_with_deploy = gen.generate(true).unwrap();
assert!(
config_no_deploy.len() < config_with_deploy.len(),
"Config without deploy should be shorter"
);
assert!(
!config_no_deploy.contains("forge deploy-safe"),
"Without deploy flag, should not contain deploy-safe"
);
}
#[test]
fn test_ci_generator_gitlab_full_structure() {
let gen = CiGenerator::new("gitlab");
let config = gen.generate(false).unwrap();
assert!(config.contains("stages:"));
assert!(config.contains("- security-audit"));
assert!(config.contains("- fuzzing"));
assert!(config.contains("forge-guard:"));
assert!(config.contains("forge audit --strict"));
assert!(config.contains("foundry-rs/foundry"));
assert!(config.contains("forge scan --depth 1"));
}
#[test]
fn test_ci_generator_gitlab_with_deploy() {
let gen = CiGenerator::new("gitlab");
let config = gen.generate(true).unwrap();
assert!(config.contains("deploy:"));
assert!(config.contains("forge deploy-safe"));
assert!(config.contains("only:"));
assert!(config.contains("- main"));
assert!(config.contains("environment: production"));
}
#[test]
fn test_ci_generator_bitbucket_structure() {
let gen = CiGenerator::new("bitbucket");
let config = gen.generate(false).unwrap();
assert!(config.contains("image:"));
assert!(config.contains("pipelines:"));
assert!(config.contains("Security Audit"));
assert!(config.contains("forge audit --strict"));
assert!(config.contains("forge fuzz --runs 10000"));
assert!(config.contains("forge deploy-safe"));
assert!(config.contains("deployment: production"));
}
#[test]
fn test_ci_generator_azure_structure() {
let gen = CiGenerator::new("azure");
let config = gen.generate(false).unwrap();
assert!(config.contains("trigger:"));
assert!(config.contains("- main"));
assert!(config.contains("pool:"));
assert!(config.contains("vmImage: ubuntu-latest"));
assert!(config.contains("displayName: 'Install Forge Guard'"));
assert!(config.contains("forge audit --strict"));
assert!(config.contains("PublishBuildArtifacts@1"));
assert!(config.contains("audit-reports"));
}
#[test]
fn test_ci_generator_azure_with_deploy() {
let gen = CiGenerator::new("azure");
let config = gen.generate(true).unwrap();
assert!(config.contains("forge deploy-safe"));
assert!(config.contains("ETH_RPC_URL"));
}
#[test]
fn test_ci_generator_unsupported_platform() {
let gen = CiGenerator::new("circleci");
let result = gen.generate(false);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Unsupported CI platform"));
assert!(err.to_string().contains("circleci"));
assert!(err.to_string().contains("github, gitlab, bitbucket, azure"));
}
#[test]
fn test_ci_generator_case_insensitivity() {
let gen1 = CiGenerator::new("GitHub");
let gen2 = CiGenerator::new("GITHUB");
let gen3 = CiGenerator::new("github");
assert_eq!(gen1.filename(), gen3.filename());
assert_eq!(gen2.filename(), gen3.filename());
let config1 = gen1.generate(false).unwrap();
let config3 = gen3.generate(false).unwrap();
assert_eq!(config1, config3);
}
#[test]
fn test_ci_generator_all_platform_filenames() {
assert_eq!(CiGenerator::new("github").filename(), "audit.yml");
assert_eq!(CiGenerator::new("gitlab").filename(), ".gitlab-ci.yml");
assert_eq!(
CiGenerator::new("bitbucket").filename(),
"bitbucket-pipelines.yml"
);
assert_eq!(CiGenerator::new("azure").filename(), "azure-pipelines.yml");
assert_eq!(CiGenerator::new("unknown").filename(), "ci-config.yml");
}
#[test]
fn test_ci_generator_each_platform_is_distinct() {
let github = CiGenerator::new("github").generate(false).unwrap();
let gitlab = CiGenerator::new("gitlab").generate(false).unwrap();
let bitbucket = CiGenerator::new("bitbucket").generate(false).unwrap();
let azure = CiGenerator::new("azure").generate(false).unwrap();
assert_ne!(github, gitlab);
assert_ne!(github, bitbucket);
assert_ne!(github, azure);
assert_ne!(gitlab, bitbucket);
assert_ne!(gitlab, azure);
assert_ne!(bitbucket, azure);
}
#[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);
}
fn make_guard() -> (DeploymentGuard, ProjectConfig) {
let config = ProjectConfig::default();
let registry = PluginRegistry::new(&config).unwrap();
let engine = SecurityEngine::new(&config, ®istry).unwrap();
let guard = DeploymentGuard::new(&config, &engine).unwrap();
(guard, config)
}
#[test]
fn test_deployment_guard_creation() {
let (guard, _) = make_guard();
assert!(guard.can_deploy(&[], 100, RiskLevel::Minimal));
}
#[test]
fn test_can_deploy_blocked_by_critical() {
let (guard, _) = make_guard();
let findings = vec![Finding::builder()
.id("FA-CRIT-001")
.title("Critical Vulnerability")
.description("Critical issue")
.severity(Severity::Critical)
.file("Test.sol")
.code("danger()")
.recommendation("Fix immediately")
.category("Security")
.build()];
assert!(!guard.can_deploy(&findings, 100, RiskLevel::Minimal));
}
#[test]
fn test_can_deploy_blocked_by_high() {
let (guard, _) = make_guard();
let findings = vec![Finding::builder()
.id("FA-HIGH-001")
.title("High Issue")
.description("High severity issue")
.severity(Severity::High)
.file("Test.sol")
.code("unsafe()")
.recommendation("Fix")
.category("Security")
.build()];
assert!(!guard.can_deploy(&findings, 100, RiskLevel::Minimal));
}
#[test]
fn test_can_deploy_medium_not_blocked_by_default() {
let (guard, _) = make_guard();
let findings = vec![Finding::builder()
.id("FA-MED-001")
.title("Medium Issue")
.description("Medium issue")
.severity(Severity::Medium)
.file("Test.sol")
.code("loop()")
.recommendation("Optimize")
.category("Gas")
.build()];
assert!(guard.can_deploy(&findings, 100, RiskLevel::Minimal));
}
#[test]
fn test_can_deploy_blocked_by_medium_when_configured() {
let mut config = ProjectConfig::default();
config.deployment.block_on_medium = true;
let registry = PluginRegistry::new(&config).unwrap();
let engine = SecurityEngine::new(&config, ®istry).unwrap();
let guard = DeploymentGuard::new(&config, &engine).unwrap();
let findings = vec![Finding::builder()
.id("FA-MED-002")
.title("Medium Issue")
.description("Medium issue")
.severity(Severity::Medium)
.file("Test.sol")
.code("loop()")
.recommendation("Optimize")
.category("Gas")
.build()];
assert!(!guard.can_deploy(&findings, 100, RiskLevel::Minimal));
}
#[test]
fn test_can_deploy_blocked_by_score() {
let (guard, _) = make_guard();
assert!(!guard.can_deploy(&[], 50, RiskLevel::Minimal));
}
#[test]
fn test_can_deploy_blocked_by_high_risk_level() {
let (guard, _) = make_guard();
assert!(!guard.can_deploy(&[], 100, RiskLevel::Critical));
assert!(!guard.can_deploy(&[], 100, RiskLevel::High));
assert!(guard.can_deploy(&[], 100, RiskLevel::Medium));
assert!(guard.can_deploy(&[], 100, RiskLevel::Low));
}
#[test]
fn test_can_deploy_blocked_by_blocks_deployment_flag() {
let (guard, _) = make_guard();
let findings = vec![Finding::builder()
.id("FA-BLOCK-001")
.title("Blocks deployment")
.description("This finding explicitly blocks deployment")
.severity(Severity::Low)
.file("Test.sol")
.code("test")
.recommendation("Fix")
.category("General")
.blocks_deployment(true)
.build()];
assert!(!guard.can_deploy(&findings, 100, RiskLevel::Minimal));
}
#[test]
fn test_calculate_overall_score_perfect() {
let (guard, _) = make_guard();
let scores = SecurityScores::perfect();
assert_eq!(guard.calculate_overall_score(&scores), 100);
}
#[test]
fn test_calculate_overall_score_mixed() {
let (guard, _) = make_guard();
let scores = SecurityScores {
access_control: 100,
security: 80,
fuzzing: 100,
gas: 90,
architecture: 75,
upgradeability: 100,
dependencies: 100,
deployment: 85,
proxy_safety: 100,
chain_compatibility: 100,
production_readiness: 65,
exploit_resistance: 70,
};
assert_eq!(guard.calculate_overall_score(&scores), 88);
}
#[test]
fn test_determine_risk_level_from_score() {
let (guard, _) = make_guard();
assert_eq!(guard.determine_risk_level(100, &[]), RiskLevel::Minimal);
assert_eq!(guard.determine_risk_level(84, &[]), RiskLevel::Low);
assert_eq!(guard.determine_risk_level(69, &[]), RiskLevel::Medium);
assert_eq!(guard.determine_risk_level(50, &[]), RiskLevel::Medium);
assert_eq!(guard.determine_risk_level(49, &[]), RiskLevel::High);
assert_eq!(guard.determine_risk_level(30, &[]), RiskLevel::High);
assert_eq!(guard.determine_risk_level(29, &[]), RiskLevel::Critical);
}
#[test]
fn test_determine_risk_level_with_critical_finding_overrides_score() {
let (guard, _) = make_guard();
let critical_findings = vec![Finding::builder()
.id("FA-CRIT-002")
.title("Critical")
.description("Critical")
.severity(Severity::Critical)
.file("Test.sol")
.code("code")
.recommendation("Fix")
.category("Security")
.build()];
assert_eq!(
guard.determine_risk_level(100, &critical_findings),
RiskLevel::Critical
);
}
#[test]
fn test_determine_risk_level_with_high_finding_overrides_score() {
let (guard, _) = make_guard();
let high_findings = vec![Finding::builder()
.id("FA-HIGH-002")
.title("High")
.description("High")
.severity(Severity::High)
.file("Test.sol")
.code("code")
.recommendation("Fix")
.category("Security")
.build()];
assert_eq!(
guard.determine_risk_level(100, &high_findings),
RiskLevel::High
);
}
#[test]
fn test_build_result_with_findings() {
let (guard, config) = make_guard();
let findings = vec![
Finding::builder()
.id("FA-H-001-99")
.title("Reentrancy")
.description("CEI violation")
.severity(Severity::High)
.file("Vault.sol")
.code("call")
.recommendation("Use ReentrancyGuard")
.category("Security")
.build(),
Finding::builder()
.id("FA-L-001-99")
.title("Style")
.description("Naming convention")
.severity(Severity::Low)
.file("Vault.sol")
.code("name_")
.recommendation("Use camelCase")
.category("Best Practices")
.build(),
];
let scores = SecurityScores::perfect();
let overall = guard.calculate_overall_score(&scores);
let risk = guard.determine_risk_level(overall, &findings);
let result = guard.build_result(findings, scores, overall, risk, 3);
assert_eq!(result.project_name, config.project_root.to_string_lossy());
assert_eq!(result.chain, "ethereum");
assert_eq!(result.overall_score, 100);
assert_eq!(result.risk_level, RiskLevel::High); assert!(result.production_ready); assert!(!result.deployment_approved); assert_eq!(result.summary.total_findings, 2);
assert_eq!(result.summary.high_count, 1);
assert_eq!(result.summary.low_count, 1);
assert_eq!(result.summary.files_analyzed, 3);
}
#[test]
fn test_build_result_clean() {
let (guard, _config) = make_guard();
let scores = SecurityScores::perfect();
let overall = guard.calculate_overall_score(&scores);
let risk = guard.determine_risk_level(overall, &[]);
let result = guard.build_result(vec![], scores, overall, risk, 5);
assert_eq!(result.overall_score, 100);
assert_eq!(result.risk_level, RiskLevel::Minimal);
assert!(result.production_ready);
assert!(result.deployment_approved);
assert_eq!(result.summary.total_findings, 0);
}
#[test]
fn test_verify_contract_without_verifier() {
let (guard, _) = make_guard();
let result = guard.verify_contract("0x1234", "MyContract", None);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Verification not configured"));
}
#[test]
fn test_verify_bytecode_without_verifier() {
let (guard, _) = make_guard();
let result = guard.verify_bytecode("0x1234", "MyContract", "http://localhost:8545");
assert!(!result.verified);
assert!(result.details.contains("Verifier not configured"));
}
#[test]
fn test_explorer_api_url_all_chains() {
assert_eq!(
explorer_api_url("ethereum"),
Some("https://api.etherscan.io/api")
);
assert_eq!(
explorer_api_url("base"),
Some("https://api.basescan.org/api")
);
assert_eq!(
explorer_api_url("arbitrum"),
Some("https://api.arbiscan.io/api")
);
assert_eq!(
explorer_api_url("optimism"),
Some("https://api-optimistic.etherscan.io/api")
);
assert_eq!(
explorer_api_url("polygon"),
Some("https://api.polygonscan.com/api")
);
assert_eq!(explorer_api_url("bnb"), Some("https://api.bscscan.com/api"));
assert_eq!(
explorer_api_url("avalanche"),
Some("https://api.snowtrace.io/api")
);
assert_eq!(
explorer_api_url("scroll"),
Some("https://api.scrollscan.com/api")
);
assert_eq!(
explorer_api_url("linea"),
Some("https://api.lineascan.build/api")
);
assert_eq!(
explorer_api_url("zksync"),
Some("https://api-era.zksync.network/api")
);
assert_eq!(
explorer_api_url("blast"),
Some("https://api.blastscan.io/api")
);
assert_eq!(
explorer_api_url("mantle"),
Some("https://api.mantlescan.xyz/api")
);
assert_eq!(
explorer_api_url("robinhood"),
Some("https://api.robinhoodscan.com/api")
);
assert_eq!(explorer_api_url("unknown-chain"), None);
}
#[test]
fn test_explorer_base_url_all_chains() {
assert_eq!(explorer_base_url("ethereum"), Some("https://etherscan.io"));
assert_eq!(explorer_base_url("base"), Some("https://basescan.org"));
assert_eq!(explorer_base_url("arbitrum"), Some("https://arbiscan.io"));
assert_eq!(
explorer_base_url("optimism"),
Some("https://optimistic.etherscan.io")
);
assert_eq!(
explorer_base_url("polygon"),
Some("https://polygonscan.com")
);
assert_eq!(explorer_base_url("bnb"), Some("https://bscscan.com"));
assert_eq!(explorer_base_url("avalanche"), Some("https://snowtrace.io"));
assert_eq!(explorer_base_url("scroll"), Some("https://scrollscan.com"));
assert_eq!(explorer_base_url("linea"), Some("https://lineascan.build"));
assert_eq!(explorer_base_url("unichain"), Some("https://uniscan.xyz"));
assert_eq!(
explorer_base_url("zksync"),
Some("https://explorer.zksync.io")
);
assert_eq!(explorer_base_url("hyperevm"), Some("https://hyperscan.xyz"));
assert_eq!(explorer_base_url("monad"), Some("https://monadscan.xyz"));
assert_eq!(explorer_base_url("sonic"), Some("https://sonicscan.org"));
assert_eq!(explorer_base_url("blast"), Some("https://blastscan.io"));
assert_eq!(explorer_base_url("mantle"), Some("https://mantlescan.xyz"));
assert_eq!(
explorer_base_url("robinhood"),
Some("https://robinhoodscan.com")
);
assert_eq!(explorer_base_url("unknown-chain"), None);
}
#[test]
fn test_explorer_api_key_env_all_chains() {
assert_eq!(explorer_api_key_env("ethereum"), "ETHERSCAN_API_KEY");
assert_eq!(explorer_api_key_env("base"), "BASESCAN_API_KEY");
assert_eq!(explorer_api_key_env("arb"), "ARBISCAN_API_KEY");
assert_eq!(
explorer_api_key_env("optimism"),
"OPTIMISTIC_ETHERSCAN_API_KEY"
);
assert_eq!(explorer_api_key_env("polygon"), "POLYGONSCAN_API_KEY");
assert_eq!(explorer_api_key_env("bnb"), "BSCSCAN_API_KEY");
assert_eq!(explorer_api_key_env("avalanche"), "SNOWTRACE_API_KEY");
assert_eq!(explorer_api_key_env("scroll"), "SCROLLSCAN_API_KEY");
assert_eq!(explorer_api_key_env("linea"), "LINEASCAN_API_KEY");
assert_eq!(explorer_api_key_env("zk"), "ZKSYNC_API_KEY");
assert_eq!(explorer_api_key_env("blast"), "BLASTSCAN_API_KEY");
assert_eq!(explorer_api_key_env("mantle"), "MANTLESCAN_API_KEY");
assert_eq!(explorer_api_key_env("unknown"), "ETHERSCAN_API_KEY");
}
#[test]
fn test_verifier_no_api_key() {
let verifier = ContractVerifier::new("ethereum", None);
let result = verifier.forge_verify("0x1234", "MyContract", "ethereum", None);
assert!(!result.verified);
assert!(result.details.contains("API key"));
assert!(result.details.contains("ETHERSCAN_API_KEY"));
}
#[test]
fn test_verifier_explicit_api_key() {
let verifier = ContractVerifier::new("ethereum", Some("test_key_123".into()));
let result = verifier.forge_verify("0x1234", "MyContract", "ethereum", None);
assert!(!result.verified);
assert!(
result.details.contains("failed")
|| result.details.contains("forge")
|| result.details.contains("Not a valid address")
);
}
#[test]
fn test_verifier_with_constructor_args() {
let verifier = ContractVerifier::new("ethereum", Some("key".into()));
let result = verifier.forge_verify("0x1234", "MyContract", "ethereum", Some("0x0001"));
assert!(!result.verified);
}
#[test]
fn test_verification_result_creation() {
use std::time::Duration;
let result = VerificationResult {
verified: true,
method: VerificationMethod::Explorer,
details: "Successfully verified on Etherscan".into(),
duration: Duration::from_secs(5),
};
assert!(result.verified);
assert_eq!(result.method, VerificationMethod::Explorer);
assert!(result.details.contains("Etherscan"));
assert_eq!(result.duration.as_secs(), 5);
}
#[test]
fn test_verification_result_not_verified() {
use std::time::Duration;
let result = VerificationResult {
verified: false,
method: VerificationMethod::BytecodeMatch,
details: "Bytecode mismatch at position 42".into(),
duration: Duration::from_millis(500),
};
assert!(!result.verified);
assert_eq!(result.method, VerificationMethod::BytecodeMatch);
assert_eq!(result.duration.as_millis(), 500);
}
#[test]
fn test_verification_method_display() {
assert_eq!(VerificationMethod::Explorer.to_string(), "Block Explorer");
assert_eq!(
VerificationMethod::BytecodeMatch.to_string(),
"Bytecode Match"
);
assert_eq!(VerificationMethod::Sourcify.to_string(), "Sourcify");
assert_eq!(VerificationMethod::Pending.to_string(), "Pending");
}
#[test]
fn test_verification_method_equality() {
assert_eq!(VerificationMethod::Explorer, VerificationMethod::Explorer);
assert_ne!(
VerificationMethod::Explorer,
VerificationMethod::BytecodeMatch
);
assert_ne!(VerificationMethod::Pending, VerificationMethod::Sourcify);
}
#[test]
fn test_forge_available_check() {
let available = forge_available();
assert!(available == available);
}
#[test]
fn test_rpc_url_for_chain_default() {
let url = rpc_url_for_chain("ethereum");
assert!(url.contains("localhost:8545") || url.contains("http"));
}
#[test]
fn test_rpc_url_for_chain_env_var_names() {
let eth = rpc_url_for_chain("ethereum");
let base = rpc_url_for_chain("base");
let arb = rpc_url_for_chain("arbitrum");
let op = rpc_url_for_chain("optimism");
assert!(eth.contains("localhost") || !eth.is_empty());
assert!(base.contains("localhost") || !base.is_empty());
assert!(arb.contains("localhost") || !arb.is_empty());
assert!(op.contains("localhost") || !op.is_empty());
}
#[test]
fn test_verifier_bytecode_match_no_compilation() {
let verifier = ContractVerifier::new("ethereum", None);
let result = verifier.verify_bytecode_match(
"0xdead000000000000000000000000000000000000",
"NonexistentContract",
"http://localhost:8545",
);
assert!(!result.verified, "should fail without forge build");
assert!(
!result.details.is_empty(),
"should have a descriptive error"
);
}
#[test]
fn test_contract_verifier_creates_with_api_key() {
let verifier = ContractVerifier::new("base", Some("my_base_key".into()));
let result = verifier.forge_verify("0xabcd", "MyContract", "base", None);
assert!(!result.verified);
assert!(!result.details.contains("API key") || result.details.contains("Already"));
}
#[test]
fn test_verifier_chain_specific_key_env() {
let verifier = ContractVerifier::new("bsc", None);
let result = verifier.forge_verify("0x1234", "C", "bsc", None);
assert!(result.details.contains("BSCSCAN_API_KEY"));
}
#[test]
fn test_benchmark_runner_creation() {
let mut runner = forge_guard::benchmark::BenchmarkRunner::new(10, 2);
let results = runner.benchmark_all().unwrap();
assert_eq!(results.len(), 3);
}
#[test]
fn test_benchmark_all_returns_expected_names() {
let mut runner = forge_guard::benchmark::BenchmarkRunner::new(5, 1);
let results = runner.benchmark_all().unwrap();
let names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"source_discovery"));
assert!(names.contains(&"pattern_matching"));
assert!(names.contains(&"json_serialization"));
}
#[test]
fn test_benchmark_results_have_positive_timings() {
let mut runner = forge_guard::benchmark::BenchmarkRunner::new(10, 2);
let results = runner.benchmark_all().unwrap();
for result in &results {
assert!(result.avg_ms >= 0.0, "{} avg_ms >= 0", result.name);
assert!(result.min_ms >= 0.0, "{} min_ms >= 0", result.name);
assert!(result.max_ms >= 0.0, "{} max_ms >= 0", result.name);
assert!(result.median_ms >= 0.0, "{} median_ms >= 0", result.name);
assert!(result.p99_ms >= 0.0, "{} p99_ms >= 0", result.name);
}
}
#[test]
fn test_benchmark_min_less_than_or_equal_max() {
let mut runner = forge_guard::benchmark::BenchmarkRunner::new(10, 2);
let results = runner.benchmark_all().unwrap();
for result in &results {
assert!(result.min_ms <= result.max_ms, "{} min <= max", result.name);
assert!(result.avg_ms >= result.min_ms, "{} avg >= min", result.name);
assert!(result.avg_ms <= result.max_ms, "{} avg <= max", result.name);
}
}
#[test]
fn test_benchmark_samples_match_iterations() {
let mut runner = forge_guard::benchmark::BenchmarkRunner::new(25, 5);
let results = runner.benchmark_all().unwrap();
for result in &results {
assert_eq!(result.samples, 25, "{} samples = 25", result.name);
}
}
#[test]
fn test_benchmark_module_source_discovery() {
let mut runner = forge_guard::benchmark::BenchmarkRunner::new(5, 1);
let result = runner.benchmark_module("source_discovery").unwrap();
assert_eq!(result.name, "source_discovery");
assert_eq!(result.samples, 5);
assert!(result.avg_ms >= 0.0);
assert!(result.min_ms <= result.max_ms);
}
#[test]
fn test_benchmark_module_pattern_matching() {
let mut runner = forge_guard::benchmark::BenchmarkRunner::new(5, 1);
let result = runner.benchmark_module("pattern_matching").unwrap();
assert_eq!(result.name, "pattern_matching");
assert_eq!(result.samples, 5);
assert!(result.avg_ms >= 0.0);
}
#[test]
fn test_benchmark_module_json_serialization() {
let mut runner = forge_guard::benchmark::BenchmarkRunner::new(5, 1);
let result = runner.benchmark_module("json_serialization").unwrap();
assert_eq!(result.name, "json_serialization");
assert_eq!(result.samples, 5);
assert!(result.avg_ms >= 0.0);
}
#[test]
fn test_benchmark_module_unknown_returns_error() {
let mut runner = forge_guard::benchmark::BenchmarkRunner::new(5, 1);
let result = runner.benchmark_module("nonexistent_module");
assert!(result.is_err());
let err = result.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unknown benchmark module"));
assert!(msg.contains("nonexistent_module"));
}
#[test]
fn test_benchmark_result_serialization() {
use forge_guard::benchmark::BenchmarkResult;
let result = BenchmarkResult {
name: "test_bench".into(),
avg_ms: 1.23,
min_ms: 0.5,
max_ms: 2.0,
median_ms: 1.0,
p99_ms: 1.9,
samples: 100,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"name\":\"test_bench\""));
assert!(json.contains("\"avg_ms\":1.23"));
assert!(json.contains("\"samples\":100"));
let deserialized: BenchmarkResult = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "test_bench");
assert_eq!(deserialized.avg_ms, 1.23);
assert_eq!(deserialized.samples, 100);
}
#[test]
fn test_benchmark_result_display_shape() {
use forge_guard::benchmark::BenchmarkResult;
let result = BenchmarkResult {
name: "pattern_matching".into(),
avg_ms: 0.05,
min_ms: 0.02,
max_ms: 0.12,
median_ms: 0.04,
p99_ms: 0.11,
samples: 1000,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"avg_ms\":0.05"));
}
#[test]
fn test_benchmark_runner_different_iterations() {
let mut runner = forge_guard::benchmark::BenchmarkRunner::new(3, 0);
let results = runner.benchmark_all().unwrap();
assert_eq!(results.len(), 3);
for result in &results {
assert_eq!(result.samples, 3);
}
}
#[test]
fn test_benchmark_median_and_p99_within_bounds() {
let mut runner = forge_guard::benchmark::BenchmarkRunner::new(20, 3);
let results = runner.benchmark_all().unwrap();
for result in &results {
assert!(
result.median_ms >= result.min_ms,
"{} median >= min",
result.name
);
assert!(
result.median_ms <= result.max_ms,
"{} median <= max",
result.name
);
assert!(
result.p99_ms >= result.median_ms,
"{} p99 >= median",
result.name
);
assert!(result.p99_ms <= result.max_ms, "{} p99 <= max", result.name);
}
}
#[test]
fn test_benchmark_all_results_are_distinct() {
let mut runner = forge_guard::benchmark::BenchmarkRunner::new(5, 1);
let results = runner.benchmark_all().unwrap();
let mut names = results.iter().map(|r| r.name.clone()).collect::<Vec<_>>();
names.sort();
names.dedup();
assert_eq!(names.len(), 3);
}
#[test]
fn test_doctor_creation_and_report() {
let config = ProjectConfig::default();
let doctor = forge_guard::doctor::Doctor::new(&config, false).unwrap();
let report = doctor.generate_report(true);
assert!(report.healthy);
assert_eq!(report.chain, "ethereum");
assert_eq!(report.project_path, ".");
assert!(report.issues.is_empty());
}
#[test]
fn test_doctor_report_unhealthy_with_issues() {
use forge_guard::doctor::{DoctorIssue, DoctorReport};
let report = DoctorReport {
healthy: false,
issues: vec![DoctorIssue {
category: "config".into(),
severity: "high".into(),
message: "Missing foundry.toml".into(),
recommendation: Some("Run forge init".into()),
}],
foundry_version: Some("nightly-2a7c3d9".into()),
solidity_version: Some("0.8.23".into()),
chain: "base".into(),
project_path: "/projects/my-contract".into(),
};
assert!(!report.healthy);
assert_eq!(report.issues.len(), 1);
assert_eq!(report.chain, "base");
assert_eq!(report.foundry_version.as_deref(), Some("nightly-2a7c3d9"));
}
#[test]
fn test_doctor_report_serialization_roundtrip() {
use forge_guard::doctor::{DoctorIssue, DoctorReport};
let report = DoctorReport {
healthy: false,
issues: vec![DoctorIssue {
category: "security".into(),
severity: "critical".into(),
message: "Unsafe delegatecall".into(),
recommendation: Some("Use onlyDelegatecall pattern".into()),
}],
foundry_version: None,
solidity_version: Some("0.8.20".into()),
chain: "ethereum".into(),
project_path: "/tmp/test".into(),
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("Unsafe delegatecall"));
assert!(json.contains("ethereum"));
let deserialized: DoctorReport = serde_json::from_str(&json).unwrap();
assert!(!deserialized.healthy);
assert_eq!(deserialized.chain, "ethereum");
assert_eq!(deserialized.issues[0].severity, "critical");
}
#[test]
fn test_doctor_issue_creation_and_access() {
use forge_guard::doctor::DoctorIssue;
let issue = DoctorIssue {
category: "dependencies".into(),
severity: "medium".into(),
message: "Outdated OpenZeppelin".into(),
recommendation: None,
};
assert_eq!(issue.category, "dependencies");
assert_eq!(issue.severity, "medium");
assert!(issue.recommendation.is_none());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_doctor_check_security_config_strict_mode() {
let mut config = ProjectConfig::default();
config.strict = true;
let mut doctor = forge_guard::doctor::Doctor::new(&config, true).unwrap();
let issues = doctor.check_security_config().unwrap();
assert!(!issues.is_empty());
assert!(issues[0].contains("Strict mode"));
}
#[test]
fn test_doctor_check_security_config_normal() {
let config = ProjectConfig::default();
let mut doctor = forge_guard::doctor::Doctor::new(&config, false).unwrap();
let issues = doctor.check_security_config().unwrap();
assert!(issues.is_empty());
}
#[test]
fn test_doctor_check_compiler_settings_no_foundry_toml() {
let config = ProjectConfig::default();
let mut doctor = forge_guard::doctor::Doctor::new(&config, false).unwrap();
let issues = doctor.check_compiler_settings().unwrap();
assert!(issues.is_empty() || issues.iter().any(|i| i.contains("optimizer")));
}
#[test]
fn test_doctor_check_dependencies_no_remappings() {
let config = ProjectConfig::default();
let mut doctor = forge_guard::doctor::Doctor::new(&config, false).unwrap();
let issues = doctor.check_dependencies().unwrap();
assert!(issues.is_empty() || issues.iter().any(|i| i.contains("remappings")));
}
#[test]
fn test_doctor_check_project_structure_default() {
let config = ProjectConfig::default();
let mut doctor = forge_guard::doctor::Doctor::new(&config, false).unwrap();
let issues = doctor.check_project_structure().unwrap();
assert!(issues.is_empty() || !issues.is_empty());
}
#[test]
fn test_doctor_check_foundry_version_fails_gracefully() {
let config = ProjectConfig::default();
let mut doctor = forge_guard::doctor::Doctor::new(&config, false).unwrap();
let result = doctor.check_foundry_version();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("forge not found") || err.contains("Command"));
}
#[test]
fn test_doctor_check_solidity_version_fails_gracefully() {
let config = ProjectConfig::default();
let mut doctor = forge_guard::doctor::Doctor::new(&config, false).unwrap();
let result = doctor.check_solidity_version();
if let Ok(version) = result {
assert!(!version.is_empty());
}
}
#[test]
fn test_doctor_verbose_flag_reflected() {
let config = ProjectConfig::default();
let doctor = forge_guard::doctor::Doctor::new(&config, true).unwrap();
let report = doctor.generate_report(true);
assert!(report.healthy);
}
#[test]
fn test_dependency_scanner_creation() {
let scanner = forge_guard::dependencies::DependencyScanner::new(1);
assert!(scanner.db_size() >= 30);
assert!(scanner.db_size() <= 50);
}
#[test]
fn test_dependency_scanner_scan_no_files() {
let scanner = forge_guard::dependencies::DependencyScanner::new(1);
let results = scanner.scan().unwrap();
assert!(results.is_empty());
}
#[test]
fn test_dependency_vulnerability_serialization() {
use forge_guard::dependencies::DependencyVulnerability;
let vuln = DependencyVulnerability {
name: "Test Vuln".into(),
package: "test-pkg".into(),
version: "<1.0.0".into(),
severity: "high".into(),
description: "A test vulnerability".into(),
recommended_fix: "Upgrade to 1.0.0".into(),
};
assert_eq!(vuln.name, "Test Vuln");
assert_eq!(vuln.severity, "high");
let json = serde_json::to_string(&vuln).unwrap();
assert!(json.contains("Test Vuln"));
assert!(json.contains("test-pkg"));
let deserialized: DependencyVulnerability = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "Test Vuln");
assert_eq!(deserialized.recommended_fix, "Upgrade to 1.0.0");
}
#[test]
fn test_dependency_vulnerability_defaults() {
use forge_guard::dependencies::DependencyVulnerability;
let vuln = DependencyVulnerability {
name: String::new(),
package: String::new(),
version: String::new(),
severity: String::new(),
description: String::new(),
recommended_fix: String::new(),
};
assert!(vuln.name.is_empty());
assert!(vuln.package.is_empty());
}
#[test]
fn test_dependency_scanner_db_size() {
let scanner = forge_guard::dependencies::DependencyScanner::new(2);
let size = scanner.db_size();
assert!(size >= 30, "Expected >= 30 entries, got {}", size);
}
#[test]
fn test_dependency_scanner_depth_setting() {
let s1 = forge_guard::dependencies::DependencyScanner::new(1);
let s2 = forge_guard::dependencies::DependencyScanner::new(5);
assert_eq!(s1.db_size(), s2.db_size());
}
#[test]
fn test_dependency_scanner_db_minimum_size() {
let scanner = forge_guard::dependencies::DependencyScanner::new(1);
assert!(scanner.db_size() >= 30, "Expected >= 30 entries");
}
#[test]
fn test_dependency_scanner_scan_with_temp_dir() {
let scanner = forge_guard::dependencies::DependencyScanner::new(1);
let results = scanner.scan().unwrap();
assert!(results.is_empty());
}
#[test]
fn test_dependency_vulnerability_empty_fields() {
use forge_guard::dependencies::DependencyVulnerability;
let vuln = DependencyVulnerability {
name: String::new(),
package: String::new(),
version: String::new(),
severity: String::new(),
description: String::new(),
recommended_fix: String::new(),
};
assert!(vuln.name.is_empty());
assert!(vuln.package.is_empty());
assert!(vuln.description.is_empty());
}
#[test]
fn test_dependency_different_depth_does_not_affect_db() {
let d1 = forge_guard::dependencies::DependencyScanner::new(1);
let d5 = forge_guard::dependencies::DependencyScanner::new(5);
assert_eq!(d1.db_size(), d5.db_size());
}
#[test]
fn test_dependency_vulnerability_serialize_full() {
use forge_guard::dependencies::DependencyVulnerability;
let vuln = DependencyVulnerability {
name: "Signature Replay".into(),
package: "openzeppelin-contracts".into(),
version: ">=4.0.0 <4.7.0".into(),
severity: "critical".into(),
description: "Signature replay via delegatecall".into(),
recommended_fix: "Upgrade to 4.7.0+".into(),
};
let json = serde_json::to_string(&vuln).unwrap();
assert!(json.contains("Signature Replay"));
assert!(json.contains("openzeppelin"));
assert!(json.contains("critical"));
let deserialized: DependencyVulnerability = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "Signature Replay");
assert_eq!(deserialized.severity, "critical");
assert_eq!(deserialized.recommended_fix, "Upgrade to 4.7.0+");
}
#[test]
fn test_ai_auditor_config_defaults() {
let config = forge_guard::ai::AiAuditorConfig::default();
assert_eq!(config.provider, "openai");
assert_eq!(config.model, "gpt-5");
assert!((config.temperature - 0.1).abs() < 0.001);
assert_eq!(config.max_tokens, 4000);
assert!((config.min_confidence - 0.5).abs() < 0.001);
assert!(config.api_key.is_none());
assert!(config.ollama_endpoint.is_none());
assert!(!config.full_audit);
}
#[test]
fn test_ai_auditor_config_custom() {
let config = forge_guard::ai::AiAuditorConfig {
provider: "claude".into(),
model: "claude-5-opus-20260701".into(),
temperature: 0.3,
max_tokens: 8000,
min_confidence: 0.7,
api_key: Some("sk-test-key".into()),
ollama_endpoint: Some("http://localhost:11434".into()),
full_audit: true,
};
assert_eq!(config.provider, "claude");
assert_eq!(config.model, "claude-5-opus-20260701");
assert_eq!(config.api_key, Some("sk-test-key".to_string()));
assert!(config.full_audit);
}
#[test]
fn test_audit_context_creation() {
let ctx = forge_guard::ai::AuditContext::new("contract Test {}", "Test.sol", "^0.8.20");
assert_eq!(ctx.source_code, "contract Test {}");
assert_eq!(ctx.file_name, "Test.sol");
assert_eq!(ctx.compiler_version, "^0.8.20");
assert!(ctx.additional.is_empty());
}
#[test]
fn test_audit_context_with_additional() {
use std::collections::HashMap;
let mut extra = HashMap::new();
extra.insert("chain".into(), "base".into());
let ctx = forge_guard::ai::AuditContext {
source_code: "contract C {}".into(),
file_name: "C.sol".into(),
compiler_version: "0.8.23".into(),
additional: extra,
};
assert_eq!(ctx.additional.get("chain").unwrap(), "base");
}
#[test]
fn test_auditor_finding_creation() {
use forge_guard::core::Severity;
let finding = forge_guard::ai::AuditorFinding {
title: "Reentrancy".into(),
description: "CEI violation in withdraw".into(),
confidence: 0.92,
severity: Severity::High,
suggestion: "Apply ReentrancyGuard".into(),
line_numbers: vec![15, 17],
category: "Reentrancy".into(),
};
assert_eq!(finding.title, "Reentrancy");
assert_eq!(finding.severity, Severity::High);
assert!((finding.confidence - 0.92).abs() < 0.01);
assert_eq!(finding.line_numbers, vec![15, 17]);
}
#[test]
fn test_auditor_finding_empty_line_numbers() {
use forge_guard::core::Severity;
let finding = forge_guard::ai::AuditorFinding {
title: "Gas".into(),
description: "Loop optimization".into(),
confidence: 0.6,
severity: Severity::Low,
suggestion: "Cache array length".into(),
line_numbers: vec![],
category: "Gas".into(),
};
assert!(finding.line_numbers.is_empty());
}
#[test]
fn test_consensus_finding_creation() {
use forge_guard::ai::AuditorFinding;
use forge_guard::core::Severity;
let finding = AuditorFinding {
title: "Access Control".into(),
description: "Missing onlyOwner".into(),
confidence: 0.85,
severity: Severity::High,
suggestion: "Add modifier".into(),
line_numbers: vec![23],
category: "AccessControl".into(),
};
let consensus = forge_guard::ai::ConsensusFinding {
auditor: "security-auditor".into(),
domain: "Security".into(),
finding,
cross_validated: false,
};
assert_eq!(consensus.auditor, "security-auditor");
assert_eq!(consensus.domain, "Security");
assert!(!consensus.cross_validated);
assert_eq!(consensus.finding.title, "Access Control");
}
#[test]
fn test_consensus_finding_cross_validated() {
use forge_guard::ai::AuditorFinding;
use forge_guard::core::Severity;
let finding = AuditorFinding {
title: "Unchecked external call".into(),
description: "Low-level call without success check".into(),
confidence: 0.95,
severity: Severity::Critical,
suggestion: "Check return value".into(),
line_numbers: vec![42],
category: "Security".into(),
};
let cf = forge_guard::ai::ConsensusFinding {
auditor: "gas-auditor".into(),
domain: "Gas".into(),
finding,
cross_validated: true,
};
assert!(cf.cross_validated);
}
#[test]
fn test_consensus_config_defaults() {
let config = forge_guard::ai::ConsensusConfig::default();
assert!((config.min_confidence - 0.5).abs() < 0.001);
assert!((config.consensus_boost - 0.2).abs() < 0.001);
assert!(config.cross_domain_merge);
}
#[test]
fn test_consensus_report_empty() {
let report = forge_guard::ai::ConsensusReport {
findings: vec![],
auditor_count: 0,
deduplicated_count: 0,
filtered_count: 0,
};
assert!(report.findings.is_empty());
assert_eq!(report.auditor_count, 0);
assert_eq!(report.deduplicated_count, 0);
assert_eq!(report.filtered_count, 0);
}
#[test]
fn test_consensus_report_with_findings() {
use forge_guard::ai::{AuditorFinding, ConsensusFinding};
use forge_guard::core::Severity;
let findings = vec![ConsensusFinding {
auditor: "security".into(),
domain: "Security".into(),
finding: AuditorFinding {
title: "Reentrancy".into(),
description: "CEI violation".into(),
confidence: 0.9,
severity: Severity::High,
suggestion: "Fix".into(),
line_numbers: vec![10],
category: "Reentrancy".into(),
},
cross_validated: true,
}];
let report = forge_guard::ai::ConsensusReport {
findings,
auditor_count: 2,
deduplicated_count: 1,
filtered_count: 0,
};
assert_eq!(report.findings.len(), 1);
assert_eq!(report.auditor_count, 2);
assert_eq!(report.deduplicated_count, 1);
assert!(report.findings[0].cross_validated);
}
#[test]
fn test_consensus_to_core_finding_conversion() {
use forge_guard::ai::{consensus_to_core_finding, AuditorFinding, ConsensusFinding};
use forge_guard::core::Severity;
let cf = ConsensusFinding {
auditor: "security-auditor".into(),
domain: "Security".into(),
finding: AuditorFinding {
title: "Reentrancy Vulnerability".into(),
description: "External call before state update in withdraw()".into(),
confidence: 0.95,
severity: Severity::High,
suggestion: "Apply ReentrancyGuard or follow CEI pattern".into(),
line_numbers: vec![42],
category: "Reentrancy".into(),
},
cross_validated: true,
};
let core_finding = consensus_to_core_finding(&cf, "Vault.sol");
assert!(core_finding.id.starts_with("AI-"));
assert!(core_finding.title.contains("Reentrancy"));
assert_eq!(core_finding.severity, Severity::High);
assert_eq!(core_finding.file.unwrap(), "Vault.sol");
assert_eq!(core_finding.line.unwrap(), 42);
assert!(core_finding.description.contains("cross-validated"));
assert_eq!(
core_finding.recommendation,
"Apply ReentrancyGuard or follow CEI pattern"
);
assert_eq!(core_finding.category, "Reentrancy");
}
#[test]
fn test_consensus_to_core_finding_not_cross_validated() {
use forge_guard::ai::{consensus_to_core_finding, AuditorFinding, ConsensusFinding};
use forge_guard::core::Severity;
let cf = ConsensusFinding {
auditor: "gas-auditor".into(),
domain: "Gas".into(),
finding: AuditorFinding {
title: "Unbounded Loop".into(),
description: "Loop over dynamic array".into(),
confidence: 0.7,
severity: Severity::Medium,
suggestion: "Cache array length".into(),
line_numbers: vec![],
category: "Gas".into(),
},
cross_validated: false,
};
let core = consensus_to_core_finding(&cf, "Vault.sol");
assert!(core.id.starts_with("AI-"));
assert!(!core.description.contains("cross-validated"));
assert_eq!(core.severity, Severity::Medium);
assert!(core.line == Some(0));
}
#[test]
fn test_consensus_to_core_finding_line_numbers() {
use forge_guard::ai::{consensus_to_core_finding, AuditorFinding, ConsensusFinding};
use forge_guard::core::Severity;
let cf = ConsensusFinding {
auditor: "logic-auditor".into(),
domain: "Business Logic".into(),
finding: AuditorFinding {
title: "Rounding Error".into(),
description: "Division before multiplication".into(),
confidence: 0.8,
severity: Severity::Medium,
suggestion: "Multiply before divide".into(),
line_numbers: vec![55, 58, 60],
category: "Logic".into(),
},
cross_validated: false,
};
let core = consensus_to_core_finding(&cf, "Dex.sol");
assert_eq!(core.line.unwrap(), 55); }
#[test]
fn test_parse_findings_json_empty() {
let findings =
forge_guard::ai::parse_findings_json(r#"{"findings": []}"#, "test-auditor").unwrap();
assert!(findings.is_empty());
}
#[test]
fn test_parse_findings_json_strips_code_fences() {
let json = "```json\n{\"findings\": [{\"title\": \"Test\", \"description\": \"desc\", \"severity\": \"high\", \"line_numbers\": [1], \"recommendation\": \"fix\", \"category\": \"Security\"}]}\n```";
let findings = forge_guard::ai::parse_findings_json(json, "test").unwrap();
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].title, "Test");
assert_eq!(findings[0].severity, forge_guard::core::Severity::High);
}
#[test]
fn test_parse_findings_json_missing_findings() {
let result = forge_guard::ai::parse_findings_json(r#"{"error": "something"}"#, "test");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("missing 'findings'"));
}
#[test]
fn test_parse_findings_json_default_severity() {
let json = r#"{"findings": [{"title":"T","description":"d","severity":"unknown","line_numbers":[],"recommendation":"r","category":"C"}]}"#;
let findings = forge_guard::ai::parse_findings_json(json, "test").unwrap();
assert_eq!(findings[0].severity, forge_guard::core::Severity::Medium);
}
#[test]
fn test_parse_findings_json_default_confidence() {
let json = r#"{"findings": [{"title":"T","description":"d","severity":"high","line_numbers":[],"recommendation":"r","category":"C"}]}"#;
let findings = forge_guard::ai::parse_findings_json(json, "test").unwrap();
assert!((findings[0].confidence - 0.85).abs() < 0.01);
}
#[test]
fn test_parse_findings_json_missing_title_defaults() {
let json = r#"{"findings": [{"description":"d","severity":"low","line_numbers":[],"recommendation":"r","category":"C"}]}"#;
let findings = forge_guard::ai::parse_findings_json(json, "test").unwrap();
assert_eq!(findings[0].title, "Unknown issue");
}
#[test]
fn test_build_audit_context_with_pragma() {
let source = "// SPDX\npragma solidity ^0.8.20;\ncontract Test {}";
let ctx = forge_guard::ai::build_audit_context(source, "Test.sol");
assert_eq!(ctx.source_code, source);
assert_eq!(ctx.file_name, "Test.sol");
assert_eq!(ctx.compiler_version, "^0.8.20");
}
#[test]
fn test_build_audit_context_no_pragma() {
let source = "contract Test {}";
let ctx = forge_guard::ai::build_audit_context(source, "Test.sol");
assert_eq!(ctx.compiler_version, "unknown");
}
#[test]
fn test_build_audit_context_with_complex_pragma() {
let source = "pragma solidity >=0.8.0 <0.9.0;\ncontract C {}";
let ctx = forge_guard::ai::build_audit_context(source, "C.sol");
assert_eq!(ctx.compiler_version, ">=0.8.0 <0.9.0");
}
#[test]
fn test_chain_info_creation() {
use forge_guard::chains::ChainInfo;
let info = ChainInfo {
name: "TestChain".into(),
chain_id: 99999,
currency: "TST".into(),
explorer_url: "https://testscan.io".into(),
rpc_urls: vec!["https://rpc.testchain.io".into()],
is_evm: true,
supported: true,
};
assert_eq!(info.name, "TestChain");
assert_eq!(info.chain_id, 99999);
assert_eq!(info.currency, "TST");
assert_eq!(info.rpc_urls.len(), 1);
assert!(info.is_evm);
}
#[test]
fn test_chain_info_non_evm() {
use forge_guard::chains::ChainInfo;
let info = ChainInfo {
name: "Solana".into(),
chain_id: 0,
currency: "SOL".into(),
explorer_url: "https://explorer.solana.com".into(),
rpc_urls: vec![],
is_evm: false,
supported: false,
};
assert!(!info.is_evm);
assert!(!info.supported);
assert!(info.rpc_urls.is_empty());
}
#[test]
fn test_chain_info_serialization() {
use forge_guard::chains::ChainInfo;
let info = ChainInfo {
name: "Ethereum".into(),
chain_id: 1,
currency: "ETH".into(),
explorer_url: "https://etherscan.io".into(),
rpc_urls: vec!["https://eth.llamarpc.com".into()],
is_evm: true,
supported: true,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"name\":\"Ethereum\""));
assert!(json.contains("\"chain_id\":1"));
let deserialized: ChainInfo = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "Ethereum");
assert_eq!(deserialized.currency, "ETH");
}
#[test]
fn test_chain_registry_default_count() {
use forge_guard::chains::ChainRegistry;
let registry = ChainRegistry::default();
let chains = registry.list_chains().unwrap();
assert_eq!(chains.len(), 17);
}
#[test]
fn test_chain_registry_get_by_name_case_insensitive() {
use forge_guard::chains::ChainRegistry;
let registry = ChainRegistry::default();
let eth = registry.get_chain("ethereum").unwrap();
let eth_upper = registry.get_chain("ETHEREUM").unwrap();
let eth_title = registry.get_chain("Ethereum").unwrap();
assert_eq!(eth.chain_id, eth_upper.chain_id);
assert_eq!(eth.chain_id, eth_title.chain_id);
}
#[test]
fn test_chain_registry_unknown_chain() {
use forge_guard::chains::ChainRegistry;
let registry = ChainRegistry::default();
assert!(registry.get_chain("nonexistent").is_none());
}
#[test]
fn test_chain_registry_get_by_chain_id_known() {
use forge_guard::chains::ChainRegistry;
let registry = ChainRegistry::default();
assert_eq!(registry.get_by_chain_id(1).unwrap().name, "Ethereum");
assert_eq!(registry.get_by_chain_id(56).unwrap().name, "BNB Chain");
assert_eq!(registry.get_by_chain_id(8453).unwrap().name, "Base");
}
#[test]
fn test_chain_registry_get_by_chain_id_unknown() {
use forge_guard::chains::ChainRegistry;
let registry = ChainRegistry::default();
assert!(registry.get_by_chain_id(999999).is_none());
}
#[test]
fn test_chain_registry_evm_chains_all() {
use forge_guard::chains::ChainRegistry;
let registry = ChainRegistry::default();
let evm = registry.evm_chains();
assert_eq!(evm.len(), 17);
assert!(evm.iter().all(|c| c.is_evm));
}
#[test]
fn test_chain_registry_register_custom_and_retrieve() {
use forge_guard::chains::{ChainInfo, ChainRegistry};
let mut registry = ChainRegistry::default();
let custom = ChainInfo {
name: "MyCustom".into(),
chain_id: 4242,
currency: "MCT".into(),
explorer_url: "https://my.custom.explorer".into(),
rpc_urls: vec!["https://rpc.custom.io".into()],
is_evm: true,
supported: true,
};
registry.register_chain(custom.clone());
let retrieved = registry.get_chain("MyCustom").unwrap();
assert_eq!(retrieved.chain_id, 4242);
assert_eq!(retrieved.currency, "MCT");
assert_eq!(registry.list_chains().unwrap().len(), 18);
}
#[test]
fn test_chain_registry_register_overwrites_existing() {
use forge_guard::chains::{ChainInfo, ChainRegistry};
let mut registry = ChainRegistry::default();
let overwrite = ChainInfo {
name: "Ethereum".into(),
chain_id: 999,
currency: "OVR".into(),
explorer_url: "https://override.explorer".into(),
rpc_urls: vec![],
is_evm: false,
supported: false,
};
registry.register_chain(overwrite.clone());
let retrieved = registry.get_chain("ethereum").unwrap();
assert_eq!(retrieved.chain_id, 999);
assert!(!retrieved.is_evm);
assert_eq!(registry.list_chains().unwrap().len(), 17);
}
#[test]
fn test_chain_registry_each_currency_value() {
use forge_guard::chains::ChainRegistry;
let registry = ChainRegistry::default();
assert_eq!(registry.get_chain("ethereum").unwrap().currency, "ETH");
assert_eq!(registry.get_chain("polygon").unwrap().currency, "MATIC");
assert_eq!(registry.get_chain("bnb chain").unwrap().currency, "BNB");
assert_eq!(registry.get_chain("avalanche").unwrap().currency, "AVAX");
assert_eq!(registry.get_chain("monad").unwrap().currency, "MON");
assert_eq!(registry.get_chain("sonic").unwrap().currency, "S");
}
#[test]
fn test_utils_cpu_count_positive() {
assert!(forge_guard::utils::cpu_count() >= 1);
}
#[test]
fn test_utils_truncate_short() {
assert_eq!(forge_guard::utils::truncate("short", 10), "short");
}
#[test]
fn test_utils_truncate_long() {
let result = forge_guard::utils::truncate("hello world this is a long string", 15);
assert_eq!(result.len(), 15);
assert!(result.ends_with("..."));
assert!(result.starts_with("hello"));
}
#[test]
fn test_utils_truncate_exact_length() {
assert_eq!(forge_guard::utils::truncate("exact", 5), "exact");
}
#[test]
fn test_utils_truncate_empty() {
assert_eq!(forge_guard::utils::truncate("", 10), "");
}
#[test]
fn test_utils_truncate_zero_max() {
let result = forge_guard::utils::truncate("hello", 3);
assert_eq!(result, "...");
assert_eq!(result.len(), 3);
}
#[test]
fn test_utils_format_duration_ms() {
let result = forge_guard::utils::format_duration(0.05);
assert!(result.contains("ms"));
assert!(result.contains("50"));
}
#[test]
fn test_utils_format_duration_seconds() {
assert_eq!(forge_guard::utils::format_duration(5.5), "5.5s");
}
#[test]
fn test_utils_format_duration_minutes() {
assert_eq!(forge_guard::utils::format_duration(125.0), "2m 5s");
}
#[test]
fn test_utils_format_duration_hours() {
assert_eq!(forge_guard::utils::format_duration(3661.0), "1h 1m");
}
#[test]
fn test_utils_format_duration_exact_hour() {
assert_eq!(forge_guard::utils::format_duration(3600.0), "1h 0m");
}
#[test]
fn test_utils_format_number_small() {
assert_eq!(forge_guard::utils::format_number(0), "0");
assert_eq!(forge_guard::utils::format_number(1), "1");
assert_eq!(forge_guard::utils::format_number(999), "999");
}
#[test]
fn test_utils_format_number_with_commas() {
assert_eq!(forge_guard::utils::format_number(1000), "1,000");
assert_eq!(forge_guard::utils::format_number(1000000), "1,000,000");
assert_eq!(forge_guard::utils::format_number(123456789), "123,456,789");
}
#[test]
fn test_utils_format_pct_values() {
assert_eq!(forge_guard::utils::format_pct(0.0), "0.0%");
assert_eq!(forge_guard::utils::format_pct(0.5), "50.0%");
assert_eq!(forge_guard::utils::format_pct(1.0), "100.0%");
assert_eq!(forge_guard::utils::format_pct(0.333), "33.3%");
}
#[test]
fn test_utils_format_bytes_zero() {
assert_eq!(forge_guard::utils::format_bytes(0), "0 B");
}
#[test]
fn test_utils_format_bytes_bytes() {
assert_eq!(forge_guard::utils::format_bytes(500), "500 B");
}
#[test]
fn test_utils_format_bytes_kb() {
let result = forge_guard::utils::format_bytes(2048);
assert!(result.contains("2.00"));
assert!(result.contains("KB"));
}
#[test]
fn test_utils_format_bytes_mb() {
let result = forge_guard::utils::format_bytes(1048576);
assert!(result.contains("1.00"));
assert!(result.contains("MB"));
}
#[test]
fn test_utils_format_bytes_gb() {
let result = forge_guard::utils::format_bytes(1073741824);
assert!(result.contains("1.00"));
assert!(result.contains("GB"));
}
#[test]
fn test_utils_read_file_trimmed() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.txt");
let mut f = std::fs::File::create(&file_path).unwrap();
writeln!(f, " hello world ").unwrap();
f.flush().unwrap();
let content = forge_guard::utils::read_file_trimmed(&file_path).unwrap();
assert_eq!(content, "hello world");
}
#[test]
fn test_utils_read_file_trimmed_file_not_found() {
let path = std::path::PathBuf::from("/nonexistent/path/file.txt");
let result = forge_guard::utils::read_file_trimmed(&path);
assert!(result.is_err());
}
#[test]
fn test_cache_disabled_by_default() {
let mut config = forge_guard::core::ProjectConfig::default();
config.cache.enabled = false;
let cache = forge_guard::utils::Cache::new(&config).unwrap();
assert!(!cache.has("test-key"));
assert!(cache.load::<String>("test-key").unwrap().is_none());
}
#[test]
fn test_cache_store_and_load() {
let dir = tempfile::tempdir().unwrap();
let mut config = forge_guard::core::ProjectConfig::default();
config.cache.enabled = true;
config.cache.directory = dir.path().join("cache").to_string_lossy().to_string();
config.project_root = dir.path().into();
let cache = forge_guard::utils::Cache::new(&config).unwrap();
cache.store("my-key", &"my-value".to_string()).unwrap();
let loaded: Option<String> = cache.load("my-key").unwrap();
assert_eq!(loaded, Some("my-value".to_string()));
}
#[test]
fn test_cache_has_key() {
let dir = tempfile::tempdir().unwrap();
let mut config = forge_guard::core::ProjectConfig::default();
config.cache.enabled = true;
config.cache.directory = dir.path().join("cache").to_string_lossy().to_string();
config.project_root = dir.path().into();
let cache = forge_guard::utils::Cache::new(&config).unwrap();
assert!(!cache.has("nonexistent"));
cache.store("exists", &"value".to_string()).unwrap();
assert!(cache.has("exists"));
}
#[test]
fn test_cache_clear() {
let dir = tempfile::tempdir().unwrap();
let mut config = forge_guard::core::ProjectConfig::default();
config.cache.enabled = true;
config.cache.directory = dir.path().join("cache").to_string_lossy().to_string();
config.project_root = dir.path().into();
let cache = forge_guard::utils::Cache::new(&config).unwrap();
cache.store("key1", &"val1".to_string()).unwrap();
cache.store("key2", &"val2".to_string()).unwrap();
assert!(cache.has("key1"));
cache.clear().unwrap();
assert!(!cache.has("key1"));
assert!(!cache.has("key2"));
}
#[test]
fn test_cache_file_hash_known_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.sol");
std::fs::write(&file_path, "contract Test {}").unwrap();
let hash = forge_guard::utils::Cache::file_hash(&file_path).unwrap();
assert_eq!(hash.len(), 64); assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_cache_file_hash_deterministic() {
let dir = tempfile::tempdir().unwrap();
let f1 = dir.path().join("a.sol");
let f2 = dir.path().join("b.sol");
std::fs::write(&f1, "same content").unwrap();
std::fs::write(&f2, "same content").unwrap();
let h1 = forge_guard::utils::Cache::file_hash(&f1).unwrap();
let h2 = forge_guard::utils::Cache::file_hash(&f2).unwrap();
assert_eq!(h1, h2, "Same content should produce same hash");
}
#[test]
fn test_cache_file_hash_different_content() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let f1 = dir.path().join("a.sol");
let f2 = dir.path().join("b.sol");
std::fs::write(&f1, "contract A {}").unwrap();
std::fs::write(&f2, "contract B {}").unwrap();
let h1 = forge_guard::utils::Cache::file_hash(&f1).unwrap();
let h2 = forge_guard::utils::Cache::file_hash(&f2).unwrap();
assert_ne!(h1, h2, "Different content should produce different hash");
}
#[test]
fn test_cache_filter_changed_files_all_new() {
let dir = tempfile::tempdir().unwrap();
let mut config = forge_guard::core::ProjectConfig::default();
config.cache.enabled = true;
config.cache.directory = dir.path().join("cache").to_string_lossy().to_string();
config.project_root = dir.path().into();
let cache = forge_guard::utils::Cache::new(&config).unwrap();
let file1 = dir.path().join("new1.sol");
let file2 = dir.path().join("new2.sol");
std::fs::write(&file1, "contract A {}").unwrap();
std::fs::write(&file2, "contract B {}").unwrap();
let changed = cache.filter_changed_files(&[file1.clone(), file2.clone()]);
assert_eq!(changed.len(), 2, "All new files should need analysis");
}
#[test]
fn test_cache_filter_changed_files_after_record() {
let dir = tempfile::tempdir().unwrap();
let mut config = forge_guard::core::ProjectConfig::default();
config.cache.enabled = true;
config.cache.directory = dir.path().join("cache").to_string_lossy().to_string();
config.project_root = dir.path().into();
let cache = forge_guard::utils::Cache::new(&config).unwrap();
let file = dir.path().join("stable.sol");
std::fs::write(&file, "contract Stable {}").unwrap();
cache.record_file_hash(&file).unwrap();
let changed = cache.filter_changed_files(std::slice::from_ref(&file));
assert!(changed.is_empty(), "Unchanged file should be filtered out");
}
#[test]
fn test_cache_disabled_filter_returns_all() {
let dir = tempfile::tempdir().unwrap();
let mut config = forge_guard::core::ProjectConfig::default();
config.cache.enabled = false;
config.project_root = dir.path().into();
let cache = forge_guard::utils::Cache::new(&config).unwrap();
let file = dir.path().join("test.sol");
std::fs::write(&file, "contract T {}").unwrap();
let changed = cache.filter_changed_files(std::slice::from_ref(&file));
assert_eq!(changed.len(), 1, "Disabled cache returns all files");
}