security-client-rs 0.1.1

Async Kibana client for security operations and Fleet management
Documentation
mod common;

use common::Mock;
use security_client_rs::{
    SortOrder,
    cases::{CaseComment, CasePatch, CaseStatus, NewCase, SECURITY_OWNER},
    security::Severity,
};
use serde_json::{Value, json};

#[tokio::test]
async fn extra_patch_fields_preserve_case_identity_and_version() {
    let mock = Mock::start().await;
    let patch = CasePatch::new("a", "v1")
        .status(CaseStatus::Closed)
        .field("assignees", json!([{ "uid": "analyst" }]))
        .unwrap()
        .field("customFields", json!([]))
        .unwrap();
    mock.json(json!([]));
    mock.soc().cases().update([patch]).send().await.unwrap();
    mock.take()
        .route("PATCH", "/s/soc/api/cases", &[])
        .body(json!({"cases": [{
            "id": "a", "version": "v1", "status": "closed",
            "assignees": [{"uid": "analyst"}], "customFields": []
        }]}));

    for key in ["id", "version"] {
        assert!(matches!(
            CasePatch::new("a", "v1").field(key, "replacement"),
            Err(security_client_rs::Error::InvalidRequest(_))
        ));
    }
}

fn case(id: &str, status: &str) -> Value {
    json!({
        "id": id, "version": "WzEsMV0=", "title": "Brute force", "description": "d",
        "owner": "securitySolution", "status": status, "severity": "low", "tags": [],
        "created_at": "2026-09-28T00:00:00Z", "updated_at": null, "totalComment": 2,
        "assignees": [], "connector": {"id": "none"}
    })
}

#[tokio::test]
async fn find_cases_supports_filters_repeated_owners_and_tags() {
    let mock = Mock::start().await;
    let client = mock.soc();
    mock.json(
        json!({"cases": [case("a", "open")], "page": 1, "per_page": 5, "total": 1,
                     "count_open_cases": 1, "count_in_progress_cases": 0, "count_closed_cases": 0}),
    );
    let page = client
        .cases()
        .find()
        .page(1)
        .per_page(5)
        .owner(SECURITY_OWNER)
        .owner("observability")
        .search("ssh")
        .status(CaseStatus::InProgress)
        .severity(Severity::Critical)
        .tag("linux")
        .tag("auth")
        .sort_field("updatedAt")
        .sort_order(SortOrder::Asc)
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert_eq!(page.total, 1);
    assert_eq!(page.cases[0].total_comments, 2);
    assert_eq!(page.extra["count_open_cases"], 1);
    mock.take().route(
        "GET",
        "/s/soc/api/cases/_find",
        &[
            ("page", "1"),
            ("perPage", "5"),
            ("owner", "securitySolution"),
            ("owner", "observability"),
            ("search", "ssh"),
            ("status", "in-progress"),
            ("severity", "critical"),
            ("tags", "linux"),
            ("tags", "auth"),
            ("sortField", "updatedAt"),
            ("sortOrder", "asc"),
        ],
    );
}

#[tokio::test]
async fn create_get_update_and_delete_cases() {
    let mock = Mock::start().await;
    let client = mock.soc();
    mock.json(case("new", "open"));
    let created = client
        .cases()
        .create(
            &NewCase::security("Brute force", "SSH failures")
                .severity(Severity::High)
                .tags(["ssh"])
                .sync_alerts(true),
        )
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert_eq!(created.version, "WzEsMV0=");
    assert_eq!(created.extra["assignees"], json!([]));
    mock.take()
        .route("POST", "/s/soc/api/cases", &[])
        .body(json!({
            "title": "Brute force", "description": "SSH failures", "owner": "securitySolution",
            "severity": "high", "tags": ["ssh"],
            "connector": {"id": "none", "name": "none", "type": ".none", "fields": null},
            "settings": {"syncAlerts": true}
        }));

    let connector =
        json!({"id": "jira", "name": "Jira", "type": ".jira", "fields": {"issueType": "10001"}});
    mock.json(case("obs", "open"));
    client
        .cases()
        .create(&NewCase::new("Latency", "p99", "observability").connector(connector.clone()))
        .send()
        .await
        .unwrap();
    let body = mock.take().json();
    assert_eq!(
        (
            body["owner"].clone(),
            body["severity"].clone(),
            body["connector"].clone()
        ),
        (json!("observability"), json!("low"), connector)
    );
    assert_eq!(body["settings"], json!({"syncAlerts": false}));

    mock.json(case("a/b", "open"));
    assert_eq!(
        client
            .cases()
            .get("a/b")
            .send()
            .await
            .unwrap()
            .json()
            .await
            .unwrap()
            .id,
        "a/b"
    );
    mock.take()
        .route("GET", "/s/soc/api/cases/a%2Fb", &[])
        .no_body();

    mock.json(json!([case("a", "closed"), case("b", "open")]));
    let updated = client
        .cases()
        .update([
            CasePatch::new("a", "v1")
                .status(CaseStatus::Closed)
                .title("Closed")
                .description("Resolved")
                .severity(Severity::Medium)
                .tags(["done"]),
            CasePatch::new("b", "v2"),
        ])
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert_eq!(updated[0].status, "closed");
    mock.take().route("PATCH", "/s/soc/api/cases", &[]).body(json!({"cases": [
        {"id": "a", "version": "v1", "status": "closed", "title": "Closed", "description": "Resolved",
         "severity": "medium", "tags": ["done"]},
        {"id": "b", "version": "v2"}
    ]}));

    mock.reply(204, "");
    client.cases().delete(["a", "b/c"]).send().await.unwrap();
    mock.take()
        .route("DELETE", "/s/soc/api/cases", &[("ids", r#"["a","b/c"]"#)])
        .no_body();
}

#[tokio::test]
async fn comments_are_added_and_paged() {
    let mock = Mock::start().await;
    let client = mock.soc();
    mock.json(case("a", "open"));
    let updated = client
        .cases()
        .add_comment("a", &CaseComment::user(SECURITY_OWNER, "Checked the host"))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert_eq!(updated.total_comments, 2);
    mock.take()
        .route("POST", "/s/soc/api/cases/a/comments", &[])
        .body(json!({"type": "user", "owner": "securitySolution", "comment": "Checked the host"}));

    mock.json(json!({"comments": [{"id": "c1", "type": "user", "comment": "x"}], "page": 2, "per_page": 1, "total": 2}));
    let page = client
        .cases()
        .find_comments("a")
        .page(2)
        .per_page(1)
        .sort_order(SortOrder::Desc)
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert_eq!((page.page, page.total), (2, 2));
    assert_eq!(page.comments[0]["id"], "c1");
    mock.take().route(
        "GET",
        "/s/soc/api/cases/a/comments/_find",
        &[("page", "2"), ("perPage", "1"), ("sortOrder", "desc")],
    );
}