use forge_guard::core::{Finding, ProjectConfig, Severity};
use forge_guard::importer::{self, ExternalTool};
use forge_guard::notify::{self, AuditNotification, NotificationKind};
use forge_guard::suppressions;
const SLITHER_JSON: &str = r#"
{
"success": true,
"results": {
"detectors": [
{
"check": "reentrancy-eth",
"impact": "High",
"confidence": "Medium",
"description": "Reentrancy in withdraw",
"elements": [
{
"type": "function",
"name": "withdraw",
"source_mapping": {
"filename_relative": "contracts/Vault.sol",
"line": 42,
"content": "msg.sender.call{value: amount}(\"\");"
}
}
]
}
]
}
}
"#;
#[test]
fn import_slither_to_findings() {
let findings = importer::import_from_json(ExternalTool::Slither, SLITHER_JSON).unwrap();
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::High);
assert_eq!(findings[0].file.as_deref(), Some("contracts/Vault.sol"));
assert_eq!(findings[0].line, Some(42));
assert!(findings[0].id.starts_with("SL-"));
assert!(findings[0].category.starts_with("slither:"));
}
#[test]
fn import_mythril_and_dedup_against_forge_guard() {
let mythril = r#"
{
"success": true,
"issues": [
{
"title": "External call to user-supplied address",
"description": "External call",
"severity": "High",
"swc-id": "107",
"function": "withdraw",
"source": { "filename": "contracts/Vault.sol", "line": 42 }
}
]
}
"#;
let imported = importer::import_from_json(ExternalTool::Mythril, mythril).unwrap();
assert_eq!(imported[0].severity, Severity::High);
let forge_guard_findings = vec![Finding::builder()
.id("FA-H-001-1")
.title("External call to user-supplied address")
.description("desc")
.severity(Severity::High)
.file("contracts/Vault.sol")
.location(42, 0)
.recommendation("fix")
.category("Security")
.build()];
let unified = importer::build_unified(ExternalTool::Mythril, forge_guard_findings, imported);
assert_eq!(unified.total(), 1); assert_eq!(unified.duplicates_removed, 1);
assert_eq!(unified.findings[0].id, "FA-H-001-1");
}
#[test]
fn import_semgrep_roundtrip_json() {
let semgrep = r#"
{
"results": [
{
"check_id": "solidity.tx-origin",
"path": "contracts/Vault.sol",
"start": { "line": 60, "col": 1 },
"extra": { "message": "Avoid tx.origin", "severity": "WARNING" }
}
]
}
"#;
let unified = importer::build_unified(
ExternalTool::Semgrep,
Vec::new(),
importer::import_from_json(ExternalTool::Semgrep, semgrep).unwrap(),
);
assert_eq!(unified.findings[0].severity, Severity::Medium);
let json = serde_json::to_string(&unified).unwrap();
assert!(json.contains("solidity.tx-origin"));
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["tool"], "Semgrep");
}
fn make_finding(id: &str, file: &str, line: usize) -> Finding {
Finding::builder()
.id(id)
.title("Finding")
.description("desc")
.severity(Severity::High)
.file(file)
.location(line, 0)
.recommendation("fix")
.category("Security")
.build()
}
#[test]
fn suppression_file_lifecycle_with_tempdir() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".forge-guard-suppressions");
let findings = vec![
make_finding("FA-H-001-1", "src/Vault.sol", 42),
make_finding("FA-H-001-2", "src/Vault.sol", 88),
make_finding("FA-H-002-1", "src/Oracle.sol", 10),
make_finding("FA-M-003-1", "src/PriceFeed.sol", 20),
];
suppressions::write(&path, &findings).unwrap();
let loaded = suppressions::load(&path).unwrap();
assert!(!loaded.is_empty());
let suppressed: Vec<&Finding> = findings
.iter()
.filter(|f| suppressions::is_suppressed(f, &loaded))
.collect();
assert_eq!(suppressed.len(), 4);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("FA-H-001"));
assert!(content.contains("FA-H-002"));
assert!(content.contains("FA-M-003"));
assert!(!content.contains("FA-L-"));
}
#[test]
fn suppression_file_scoped_to_file() {
let content = "FA-H-001 src/Vault.sol\nFA-H-002\n";
let entries = suppressions::parse(content);
let vault = make_finding("FA-H-001-1", "src/Vault.sol", 1);
let other = make_finding("FA-H-001-1", "src/Other.sol", 1);
let h2 = make_finding("FA-H-002-1", "src/Other.sol", 2);
assert!(suppressions::is_suppressed(&vault, &entries));
assert!(!suppressions::is_suppressed(&other, &entries));
assert!(suppressions::is_suppressed(&h2, &entries));
}
#[test]
fn notify_payloads_are_valid_json() {
let n = AuditNotification {
title: "Nightly audit".into(),
project: "demo".into(),
chain: "base".into(),
overall_score: Some(74),
risk: Some("MEDIUM".into()),
success: true,
findings: vec![
make_finding("FA-H-001-1", "Vault.sol", 42),
make_finding("FA-M-003-1", "Vault.sol", 90),
],
};
let slack = notify::build_slack_payload(&n);
let slack_json = serde_json::to_string(&slack).unwrap();
assert!(slack_json.contains("\"blocks\""));
assert!(slack_json.contains("Nightly audit"));
assert!(slack_json.contains("Vault.sol:42"));
let discord = notify::build_discord_payload(&n);
let discord_json = serde_json::to_string(&discord).unwrap();
assert!(discord_json.contains("\"embeds\""));
assert!(discord_json.contains("74/100"));
assert!(discord_json.contains("Critical: 0"));
}
#[test]
fn notify_severity_gating() {
let loud = AuditNotification {
title: "t".into(),
project: "p".into(),
chain: "c".into(),
overall_score: None,
risk: None,
success: true,
findings: vec![make_finding("FA-H-001-1", "Vault.sol", 1)],
};
assert!(notify::meets_severity_gate(&loud.findings, Severity::High));
assert!(!notify::meets_severity_gate(
&loud.findings,
Severity::Critical
));
let empty = AuditNotification {
title: "t".into(),
project: "p".into(),
chain: "c".into(),
overall_score: None,
risk: None,
success: true,
findings: vec![],
};
assert!(!notify::meets_severity_gate(
&empty.findings,
Severity::High
));
assert!(notify::meets_severity_gate(
&empty.findings,
Severity::Informational
));
}
#[test]
fn notify_kind_detection_and_config() {
assert_eq!(
NotificationKind::detect("https://discord.com/api/webhooks/1/2"),
NotificationKind::Discord
);
assert_eq!(
NotificationKind::detect("https://hooks.slack.com/services/A/B/C"),
NotificationKind::Slack
);
let toml_str = r#"
[notifications.discord]
webhook = "https://discord.com/api/webhooks/1/2"
"#;
let config: ProjectConfig = toml::from_str(toml_str).unwrap();
assert_eq!(
config.notifications.discord.webhook.as_deref(),
Some("https://discord.com/api/webhooks/1/2")
);
assert_eq!(config.notifications.discord.min_severity, "high");
assert_eq!(config.notifications.slack.min_severity, "high");
assert_eq!(config.notifications.slack.webhook, None);
assert_eq!(
notify::resolve_webhook(
NotificationKind::Discord,
Some("https://cli.example/hook"),
&config
),
Some("https://cli.example/hook".into())
);
assert_eq!(
notify::resolve_webhook(NotificationKind::Discord, None, &config),
Some("https://discord.com/api/webhooks/1/2".into())
);
assert_eq!(
notify::resolve_webhook(NotificationKind::Slack, None, &config),
None
);
}
#[test]
fn notify_from_config_without_webhook_is_ok() {
let config = ProjectConfig::default();
let n = AuditNotification {
title: "t".into(),
project: "p".into(),
chain: "c".into(),
overall_score: None,
risk: None,
success: false,
findings: vec![],
};
assert!(notify::notify_from_config(&config, &n, false).is_ok());
assert!(notify::notify_from_config(&config, &n, true).is_ok());
}
#[test]
fn finding_suppressed_flag_roundtrip_through_serde() {
let mut f = make_finding("FA-H-001-1", "Vault.sol", 1);
f.suppressed = true;
let json = serde_json::to_string(&f).unwrap();
let back: Finding = serde_json::from_str(&json).unwrap();
assert!(back.suppressed);
let old: Finding = serde_json::from_str(
r#"{"id":"X","title":"t","description":"d","severity":"low","recommendation":"r","category":"c"}"#,
)
.unwrap();
assert!(!old.suppressed);
}