forge-guard 0.3.6

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! M15 — External Tooling & Interoperability integration tests.
//!
//! Exercises the public APIs of `importer`, `notify`, and `suppressions`
//! end-to-end (parse → transform → report) without network access.

use forge_guard::core::{Finding, ProjectConfig, Severity};
use forge_guard::importer::{self, ExternalTool};
use forge_guard::notify::{self, AuditNotification, NotificationKind};
use forge_guard::suppressions;

// ─────────────────────────────────────────────────────────────────
// Import
// ─────────────────────────────────────────────────────────────────

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);

    // The same issue reported by forge-guard at the same location.
    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); // forge-guard kept, mythril dup removed
    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");
}

// ─────────────────────────────────────────────────────────────────
// Suppressions
// ─────────────────────────────────────────────────────────────────

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),
    ];

    // Generate, reload, and verify matching.
    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();
    // generate() writes one entry per distinct check ID, so every finding
    // (FA-H-001, FA-H-002, FA-M-003) has a matching entry.
    assert_eq!(suppressed.len(), 4);

    // Round-trip: regenerated content parses to the same entries.
    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));
}

// ─────────────────────────────────────────────────────────────────
// Notify
// ─────────────────────────────────────────────────────────────────

#[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() {
    // make_finding() produces HIGH findings, so a "quiet" notification is
    // simulated by gating against a higher threshold.
    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
    ));

    // No findings: never meets a high gate, but meets an informational one.
    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
    );

    // Config round-trip through ProjectConfig (serde defaults apply).
    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);

    // resolve_webhook prefers CLI override.
    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![],
    };
    // No webhooks configured → no-op, not an error.
    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);

    // Older findings without the field default to false.
    let old: Finding = serde_json::from_str(
        r#"{"id":"X","title":"t","description":"d","severity":"low","recommendation":"r","category":"c"}"#,
    )
    .unwrap();
    assert!(!old.suppressed);
}