silicon-hook-cli 1.1.0

The hook CLI: manage a Silicon's signed provider webhooks, signed in with Silicon Accounts
//! The management commands against the stub Hook API: paths, bodies, the
//! default Silicon, and how refusals are shown.

mod support;

use serde_json::json;
use support::{CARBON, HOOK_ID, Home, SILICON, Server, hook, start};

async fn signed_in_silicon() -> (Server, Home) {
    let server = start().await;
    let home = Home::new();
    let run = hook(&home, &server, &["login", "slt_ok_commands"], None).await;
    assert_eq!(run.code(), 0, "{}", run.stderr());
    (server, home)
}

async fn signed_in_carbon() -> (Server, Home) {
    let server = start().await;
    let home = Home::new();
    *server.stub.device_script.lock().unwrap() = vec!["ok"];
    let run = hook(&home, &server, &["login", "--json"], None).await;
    assert_eq!(run.code(), 0, "{}", run.stderr());
    (server, home)
}

fn last(server: &Server) -> support::Seen {
    server.stub.hook_requests().pop().expect("a request")
}

#[tokio::test]
async fn a_silicon_creates_and_manages_its_own_hooks_by_uuid() {
    let (server, home) = signed_in_silicon().await;
    let secret = home.0.join("secret.txt");
    std::fs::write(&secret, " provider secret \n").expect("secret");
    let run = hook(
        &home,
        &server,
        &[
            "create",
            "Stripe",
            "--secret-file",
            secret.to_str().expect("path"),
            "--time-zone",
            "Asia/Kolkata",
        ],
        None,
    )
    .await;
    assert_eq!(run.code(), 0, "{}", run.stderr());
    let request = last(&server);
    assert_eq!(
        (request.method.as_str(), request.path.as_str()),
        ("POST", &*format!("/api/v3/silicons/{}/hooks", SILICON.0))
    );
    assert_eq!(request.body["signature"]["secret"], " provider secret ");
    assert_eq!(request.body["time_zone"], "Asia/Kolkata");
    assert!(request.idempotency_key.is_some());
    let run = hook(
        &home,
        &server,
        &[
            "--idempotency-key",
            "same-key-123",
            "update",
            HOOK_ID,
            "--patch",
            "{\"description\":null}",
        ],
        None,
    )
    .await;
    assert_eq!(run.code(), 0, "{}", run.stderr());
    let request = last(&server);
    assert_eq!(request.method, "PATCH");
    assert_eq!(request.body, json!({"description": null}));
    assert_eq!(request.idempotency_key.as_deref(), Some("same-key-123"));
    let run = hook(&home, &server, &["delete", HOOK_ID], None).await;
    assert_eq!(run.code(), 0);
    assert_eq!(
        run.json(),
        json!({"deleted": HOOK_ID, "recoverable_days": 45})
    );
    let run = hook(
        &home,
        &server,
        &[
            "events", "--hook", HOOK_ID, "--limit", "5", "--cursor", "abc",
        ],
        None,
    )
    .await;
    assert_eq!(run.code(), 0, "{}", run.stderr());
    let request = last(&server);
    assert_eq!(
        request.path,
        format!("/api/v3/silicons/{}/hooks/{HOOK_ID}/events", SILICON.0)
    );
    assert_eq!(request.query.as_deref(), Some("limit=5&cursor=abc"));
}

#[tokio::test]
async fn a_carbon_must_choose_the_silicon_and_can_save_a_default() {
    let (server, home) = signed_in_carbon().await;
    let run = hook(&home, &server, &["--json", "list"], None).await;
    assert_eq!(run.code(), 2);
    let error = run.error();
    assert!(
        error["error"]["message"]
            .as_str()
            .unwrap_or_default()
            .contains("Which Silicon?")
    );
    assert!(
        error["error"]["hint"]
            .as_str()
            .unwrap_or_default()
            .contains("hook silicons")
    );
    let run = hook(&home, &server, &["silicons"], None).await;
    assert_eq!(run.code(), 0);
    assert_eq!(run.json()["items"][0]["access"], "custodian");
    let run = hook(&home, &server, &["--silicon", SILICON.1, "list"], None).await;
    assert_eq!(run.code(), 0, "{}", run.stderr());
    assert_eq!(
        last(&server).path,
        format!("/api/v3/silicons/{}/hooks", SILICON.1)
    );
    assert_eq!(
        hook(
            &home,
            &server,
            &["config", "set", "silicon", SILICON.1],
            None
        )
        .await
        .code(),
        0
    );
    assert_eq!(hook(&home, &server, &["list"], None).await.code(), 0);
    let shown = hook(&home, &server, &["config", "show"], None).await.json();
    assert_eq!(shown["silicon"], SILICON.1);
    assert_eq!(
        shown["signed_in_as"],
        format!("{} ({})", CARBON.1, CARBON.0)
    );
    assert!(
        !shown.to_string().contains("at-"),
        "no token in config show"
    );
    let refused = hook(
        &home,
        &server,
        &["config", "set", "url", "http://127.0.0.1:9"],
        None,
    )
    .await;
    assert_eq!(refused.code(), 2, "a signed-in profile keeps its Hook URL");
}

#[tokio::test]
async fn access_and_allow_list_commands() {
    let (server, home) = signed_in_carbon().await;
    let base = ["--silicon", SILICON.1];
    let run = hook(
        &home,
        &server,
        &[
            &base[..],
            &["access", "grant", "c:bob", "--level", "manage"],
        ]
        .concat(),
        None,
    )
    .await;
    assert_eq!(run.code(), 0, "{}", run.stderr());
    let request = last(&server);
    assert_eq!(
        (request.method.as_str(), request.path.as_str()),
        (
            "PUT",
            &*format!("/api/v3/silicons/{}/access/c:bob", SILICON.1)
        )
    );
    assert_eq!(request.body, json!({"level": "manage"}));
    assert_eq!(
        hook(
            &home,
            &server,
            &[&base[..], &["access", "revoke", "c:bob"]].concat(),
            None
        )
        .await
        .code(),
        0
    );
    assert_eq!(last(&server).method, "DELETE");
    assert_eq!(
        hook(
            &home,
            &server,
            &[&base[..], &["access", "leave"]].concat(),
            None
        )
        .await
        .code(),
        0
    );
    assert_eq!(
        last(&server).path,
        format!("/api/v3/silicons/{}/access/me", SILICON.1)
    );
    let run = hook(
        &home,
        &server,
        &[&base[..], &["access", "list"]].concat(),
        None,
    )
    .await;
    assert_eq!(run.json()["you"]["access"], "custodian");
    assert_eq!(
        hook(
            &home,
            &server,
            &[&base[..], &["allow-list", "list"]].concat(),
            None
        )
        .await
        .code(),
        0
    );
    assert_eq!(
        hook(
            &home,
            &server,
            &[&base[..], &["allow-list", "remove", "si:friend"]].concat(),
            None
        )
        .await
        .code(),
        0
    );
    assert_eq!(
        last(&server).path,
        format!("/api/v3/silicons/{}/allow-list/si:friend", SILICON.1)
    );
}

#[tokio::test]
async fn connect_accounts_prepares_the_hook_then_stores_the_whsec_secret() {
    let (server, home) = signed_in_silicon().await;
    let run = hook(&home, &server, &["connect-accounts"], None).await;
    assert_eq!(run.code(), 0, "{}", run.stderr());
    assert_eq!(run.json()["secret_stored_now"], false);
    assert!(run.stderr().contains("silicon-accounts webhook set"));
    let run = hook(
        &home,
        &server,
        &["connect-accounts", "--secret-file", "-"],
        Some("whsec_from_accounts\n"),
    )
    .await;
    assert_eq!(run.code(), 0, "{}", run.stderr());
    assert_eq!(run.json()["secret_stored_now"], true);
    let requests = server.stub.hook_requests();
    let tail = &requests[requests.len() - 2..];
    assert_eq!(
        (tail[0].method.as_str(), tail[0].path.as_str()),
        (
            "POST",
            &*format!("/api/v3/silicons/{}/hooks/accounts", SILICON.0)
        )
    );
    assert_eq!(tail[1].method, "PATCH");
    assert_eq!(
        tail[1].body,
        json!({"signature": {"secret": "whsec_from_accounts"}})
    );
}

#[tokio::test]
async fn delivery_and_system_commands_explain_a_hook_without_ting() {
    let (server, home) = signed_in_silicon().await;
    let run = hook(&home, &server, &["system", "delivery"], None).await;
    assert_eq!(run.code(), 0);
    assert_eq!(run.json()["enabled"], false);
    let run = hook(&home, &server, &["--json", "receiving", "register"], None).await;
    assert_eq!(run.code(), 5);
    let error = run.error();
    assert_eq!(error["error"]["code"], "delivery_disabled");
    assert_eq!(error["error"]["status"], 409);
    let run = hook(&home, &server, &["receiving", "status"], None).await;
    assert_eq!(
        run.json(),
        json!({"receiving": false, "subscription": null})
    );
    let run = hook(&home, &server, &["system", "version"], None).await;
    assert_eq!(run.code(), 0);
    assert_eq!(run.json()["cli"], env!("CARGO_PKG_VERSION"));
    let missing = hook(
        &home,
        &server,
        &["--json", "--silicon", "si:nobody", "list"],
        None,
    )
    .await;
    assert_eq!(missing.code(), 4);
    assert_eq!(missing.error()["error"]["code"], "silicon_not_found");
    assert_eq!(missing.error()["error"]["request_id"], "req-stub");
}

#[tokio::test]
async fn docs_topics_are_bundled_and_unknown_topics_list_the_choices() {
    let home = Home::new();
    for topic in [
        "overview",
        "signin",
        "cli",
        "client",
        "receiving",
        "signatures",
        "delivery",
        "contracts",
        "configuration",
        "deployment",
        "releases",
    ] {
        let run = support::hook_env(Some(&home.0), &[], &["docs", topic], None).await;
        assert_eq!(run.code(), 0, "{topic}");
        assert!(
            run.stdout().starts_with('#') || run.stdout().starts_with('>'),
            "{topic}"
        );
    }
    let run = support::hook_env(Some(&home.0), &[], &["--json", "docs", "testing"], None).await;
    assert_eq!(run.code(), 2);
    assert!(
        run.error()["error"]["hint"]
            .as_str()
            .unwrap_or_default()
            .contains("signin")
    );
}