elasticctl 0.1.3

Operate Elastic Security as code with a safety-first CLI for security engineers.
use assert_cmd::Command;
use std::fs;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn config_for(dir: &std::path::Path, uri: &str) -> std::path::PathBuf {
    let p = dir.join("config.toml");
    fs::write(
        &p,
        format!(
            "current = \"default\"\n\n[profiles.default]\nkibana_url = \"{uri}\"\napi_key = \"essu_t\"\nspace = \"default\"\nverify = true\ntimeout_secs = 5\n"
        ),
    )
    .unwrap();
    p
}

async fn server_with_one_rule() -> MockServer {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/api/detection_engine/rules"))
        .and(query_param("rule_id", "abc"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "rule_id": "abc", "name": "Alpha", "type": "query", "enabled": true
        })))
        .mount(&server)
        .await;
    server
}

#[tokio::test]
async fn a_dry_run_previews_on_stderr_and_changes_nothing() {
    let server = server_with_one_rule().await;
    let dir = tempfile::tempdir().unwrap();
    let cfg = config_for(dir.path(), &server.uri());

    let out = Command::cargo_bin("elasticctl")
        .unwrap()
        .args(["rules", "disable", "abc", "--json", "--config"])
        .arg(&cfg)
        .output()
        .unwrap();

    assert!(
        out.status.success(),
        "a preview is a success, not a failure"
    );
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("[DRY RUN]"), "{err}");
    assert!(err.contains("Pass --yes to apply."), "{err}");
    assert!(
        err.contains("Alpha"),
        "the preview must name the affected rule: {err}"
    );

    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    assert_eq!(v["applied"], false);
    assert_eq!(
        v["total"], 1,
        "dry run and apply must share one count field"
    );

    // A dry run must not send a bulk action.
    let hits = server.received_requests().await.unwrap();
    assert!(
        !hits.iter().any(|r| r.url.path().contains("_bulk_action")),
        "a dry run must not send a mutation"
    );
}

#[tokio::test]
async fn the_preview_banner_names_the_profile_and_host() {
    let server = server_with_one_rule().await;
    let dir = tempfile::tempdir().unwrap();
    let cfg = config_for(dir.path(), &server.uri());

    let out = Command::cargo_bin("elasticctl")
        .unwrap()
        .args(["rules", "disable", "abc", "--config"])
        .arg(&cfg)
        .output()
        .unwrap();
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("profile: default"), "{err}");
    assert!(err.contains("space: default"), "{err}");
}

#[tokio::test]
async fn yes_applies_the_bulk_action_and_reports_the_outcome() {
    let server = server_with_one_rule().await;
    Mock::given(method("POST"))
        .and(path("/api/detection_engine/rules/_bulk_action"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "attributes": {"summary": {"succeeded": 1, "failed": 0, "skipped": 0, "total": 1}}
        })))
        .mount(&server)
        .await;

    let dir = tempfile::tempdir().unwrap();
    let cfg = config_for(dir.path(), &server.uri());

    let out = Command::cargo_bin("elasticctl")
        .unwrap()
        .args(["rules", "disable", "abc", "--yes", "--json", "--config"])
        .arg(&cfg)
        .output()
        .unwrap();

    assert!(out.status.success());
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.starts_with("Applying:"), "{err}");
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    assert_eq!(v["applied"], true);
    assert_eq!(v["succeeded"], 1);
}

/// `set_enabled` uses one apply path for `enable` and `disable`, with a
/// different guard path for each command. Exercise `enable` here so both
/// paths are checked.
#[tokio::test]
async fn yes_enables_a_rule_through_the_same_apply_path_as_disable() {
    let server = server_with_one_rule().await;
    Mock::given(method("POST"))
        .and(path("/api/detection_engine/rules/_bulk_action"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "attributes": {"summary": {"succeeded": 1, "failed": 0, "skipped": 0, "total": 1}}
        })))
        .mount(&server)
        .await;

    let dir = tempfile::tempdir().unwrap();
    let cfg = config_for(dir.path(), &server.uri());

    let out = Command::cargo_bin("elasticctl")
        .unwrap()
        .args(["rules", "enable", "abc", "--yes", "--json", "--config"])
        .arg(&cfg)
        .output()
        .unwrap();

    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.starts_with("Applying:"), "{err}");
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    assert_eq!(v["applied"], true);
    assert_eq!(v["succeeded"], 1);
}

/// A positive `failed` count is a partial failure even without a per-item
/// list. The command must exit non-zero while still printing its full summary.
#[tokio::test]
async fn a_bulk_action_partial_failure_exits_non_zero_but_still_reports_the_summary() {
    let server = server_with_one_rule().await;
    Mock::given(method("POST"))
        .and(path("/api/detection_engine/rules/_bulk_action"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "attributes": {"summary": {"succeeded": 0, "failed": 1, "skipped": 0, "total": 1}}
        })))
        .mount(&server)
        .await;

    let dir = tempfile::tempdir().unwrap();
    let cfg = config_for(dir.path(), &server.uri());

    let out = Command::cargo_bin("elasticctl")
        .unwrap()
        .args(["rules", "disable", "abc", "--yes", "--json", "--config"])
        .arg(&cfg)
        .output()
        .unwrap();

    assert_eq!(
        out.status.code(),
        Some(1),
        "a bulk action reporting a failed count must not exit 0: {}",
        String::from_utf8_lossy(&out.stdout)
    );
    let v: serde_json::Value = serde_json::from_slice(&out.stdout)
        .expect("the operator must still get the report, not just the code");
    assert_eq!(v["applied"], true);
    assert_eq!(v["succeeded"], 0);
    assert_eq!(v["failed"], 1);
    assert_eq!(v["total"], 1);
}

#[tokio::test]
async fn an_unresolvable_selector_fails_before_any_mutation() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/api/detection_engine/rules"))
        .respond_with(
            ResponseTemplate::new(404).set_body_json(serde_json::json!({"message": "nope"})),
        )
        .mount(&server)
        .await;
    Mock::given(method("GET"))
        .and(path("/api/detection_engine/rules/_find"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "page": 1, "perPage": 100, "total": 0, "data": []
        })))
        .mount(&server)
        .await;

    let dir = tempfile::tempdir().unwrap();
    let cfg = config_for(dir.path(), &server.uri());

    let out = Command::cargo_bin("elasticctl")
        .unwrap()
        .args(["rules", "disable", "ghost", "--yes", "--json", "--config"])
        .arg(&cfg)
        .output()
        .unwrap();

    assert_eq!(out.status.code(), Some(1));
    let v: serde_json::Value = serde_json::from_slice(&out.stderr).unwrap();
    assert_eq!(v["error"]["kind"], "not_found");
    assert!(
        !server
            .received_requests()
            .await
            .unwrap()
            .iter()
            .any(|r| r.url.path().contains("_bulk_action")),
        "resolution must fail before anything mutates"
    );
}

async fn server_with_three_rules() -> MockServer {
    let server = MockServer::start().await;
    for (id, name) in [("r1", "One"), ("r2", "Two"), ("r3", "Three")] {
        Mock::given(method("GET"))
            .and(path("/api/detection_engine/rules"))
            .and(query_param("rule_id", id))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "rule_id": id, "name": name, "type": "query", "enabled": true
            })))
            .mount(&server)
            .await;
    }
    server
}

/// `delete` removes rules one at a time. If one deletion fails, later rules
/// must still be attempted and the result must list every outcome.
#[tokio::test]
async fn delete_continues_past_a_per_rule_failure_and_reports_every_outcome() {
    let server = server_with_three_rules().await;

    Mock::given(method("DELETE"))
        .and(path("/api/detection_engine/rules"))
        .and(query_param("rule_id", "r1"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "rule_id": "r1", "name": "One", "type": "query", "enabled": true
        })))
        .mount(&server)
        .await;
    Mock::given(method("DELETE"))
        .and(path("/api/detection_engine/rules"))
        .and(query_param("rule_id", "r2"))
        .respond_with(
            ResponseTemplate::new(409).set_body_json(serde_json::json!({"message": "locked"})),
        )
        .mount(&server)
        .await;
    Mock::given(method("DELETE"))
        .and(path("/api/detection_engine/rules"))
        .and(query_param("rule_id", "r3"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "rule_id": "r3", "name": "Three", "type": "query", "enabled": true
        })))
        .mount(&server)
        .await;

    let dir = tempfile::tempdir().unwrap();
    let cfg = config_for(dir.path(), &server.uri());

    let out = Command::cargo_bin("elasticctl")
        .unwrap()
        .args([
            "rules", "delete", "r1", "r2", "r3", "--yes", "--json", "--config",
        ])
        .arg(&cfg)
        .output()
        .unwrap();

    assert_eq!(
        out.status.code(),
        Some(1),
        "a partial failure must not exit 0: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    assert_eq!(v["applied"], true);
    assert_eq!(v["total"], 3);

    let deleted: Vec<&str> = v["deleted"]
        .as_array()
        .unwrap()
        .iter()
        .map(|r| r["rule_id"].as_str().unwrap())
        .collect();
    assert_eq!(
        deleted,
        vec!["r1", "r3"],
        "rules 1 and 3 must be reported as deleted"
    );

    let failed = v["failed"].as_array().unwrap();
    assert_eq!(failed.len(), 1);
    assert_eq!(failed[0]["rule_id"], "r2");
    assert!(
        failed[0]["error"].as_str().unwrap().contains("locked"),
        "{v}"
    );

    // All three DELETEs must reach the server, including the one after failure.
    let hits = server.received_requests().await.unwrap();
    let delete_count = hits
        .iter()
        .filter(|r| r.method.as_str() == "DELETE")
        .count();
    assert_eq!(
        delete_count, 3,
        "rule 3 must still be attempted after rule 2 fails"
    );
}

#[tokio::test]
async fn delete_of_every_rule_succeeding_has_an_empty_failed_list_and_exits_zero() {
    let server = server_with_three_rules().await;
    for id in ["r1", "r2", "r3"] {
        Mock::given(method("DELETE"))
            .and(path("/api/detection_engine/rules"))
            .and(query_param("rule_id", id))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "rule_id": id, "name": "x", "type": "query", "enabled": true
            })))
            .mount(&server)
            .await;
    }

    let dir = tempfile::tempdir().unwrap();
    let cfg = config_for(dir.path(), &server.uri());

    let out = Command::cargo_bin("elasticctl")
        .unwrap()
        .args([
            "rules", "delete", "r1", "r2", "r3", "--yes", "--json", "--config",
        ])
        .arg(&cfg)
        .output()
        .unwrap();

    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    assert_eq!(v["applied"], true);
    assert!(v["failed"].as_array().unwrap().is_empty());
    assert_eq!(v["deleted"].as_array().unwrap().len(), 3);
    assert_eq!(v["total"], 3);
}