heddle-hosted-client 0.15.1

Heddle's in-repo hosted client: transport, credentials, identity, and hosted sync.
use std::{ffi::OsString, sync::MutexGuard};

use api::heddle::api::v1alpha1::CreateAgentAccountResponse;
use chrono::{Duration, Utc};
use config::credentials::{self, ServerCredential};
use crypto::{Ed25519Signer, Signer as _};
use heddle_cli_args::CliContext;
use tempfile::TempDir;

use super::{
    agent_node_identity,
    auth::headless_token_metadata,
    auth_login::{LoginInputs, LoginPath, login, login_path, store_agent_root},
    auth_login_agent::finish_invite_create_from_response,
    device_flow::restrict_agent_account_root,
    identity_state::{self, ClaimState},
    root_mint::mint_agent_root,
};

struct TextCtx;

impl CliContext for TextCtx {
    fn repo_path(&self) -> Option<&std::path::Path> {
        None
    }
    fn operation_id_wire(&self) -> String {
        String::new()
    }
    fn should_output_json(&self, _repo_config: Option<&repo::Config>) -> bool {
        false
    }
}

struct IsolatedHome {
    _guard: MutexGuard<'static, ()>,
    _temp: TempDir,
    prev_home: Option<OsString>,
    prev_heddle_home: Option<OsString>,
    prev_credential: Option<OsString>,
}

impl IsolatedHome {
    fn new() -> Self {
        let guard = credentials::lock_test_env();
        let temp = TempDir::new().expect("temp home");
        let prev_home = std::env::var_os("HOME");
        let prev_heddle_home = std::env::var_os("HEDDLE_HOME");
        let prev_credential = std::env::var_os("HEDDLE_CREDENTIAL");
        unsafe {
            std::env::set_var("HOME", temp.path());
            std::env::remove_var("HEDDLE_HOME");
            std::env::remove_var("HEDDLE_CREDENTIAL");
        }
        Self {
            _guard: guard,
            _temp: temp,
            prev_home,
            prev_heddle_home,
            prev_credential,
        }
    }
}

impl Drop for IsolatedHome {
    fn drop(&mut self) {
        unsafe {
            match &self.prev_home {
                Some(value) => std::env::set_var("HOME", value),
                None => std::env::remove_var("HOME"),
            }
            match &self.prev_heddle_home {
                Some(value) => std::env::set_var("HEDDLE_HOME", value),
                None => std::env::remove_var("HEDDLE_HOME"),
            }
            match &self.prev_credential {
                Some(value) => std::env::set_var("HEDDLE_CREDENTIAL", value),
                None => std::env::remove_var("HEDDLE_CREDENTIAL"),
            }
        }
    }
}

#[test]
fn login_path_covers_the_five_locked_routes() {
    let reuse = LoginInputs {
        reusable_cred: true,
        node_key_account: true,
        has_invite: true,
        interactive: true,
        force_browser: true,
    };
    assert_eq!(login_path(reuse), LoginPath::Reuse);

    let remint = LoginInputs {
        reusable_cred: false,
        node_key_account: true,
        has_invite: true,
        interactive: false,
        force_browser: false,
    };
    assert_eq!(login_path(remint), LoginPath::Remint);

    let invite = LoginInputs {
        reusable_cred: false,
        node_key_account: false,
        has_invite: true,
        interactive: false,
        force_browser: false,
    };
    assert_eq!(login_path(invite), LoginPath::CreateWithInvite);

    let browser = LoginInputs {
        reusable_cred: false,
        node_key_account: false,
        has_invite: false,
        interactive: true,
        force_browser: false,
    };
    assert_eq!(login_path(browser), LoginPath::Browser);

    let forced = LoginInputs {
        reusable_cred: false,
        node_key_account: false,
        has_invite: false,
        interactive: false,
        force_browser: true,
    };
    assert_eq!(login_path(forced), LoginPath::Browser);

    let fail_closed = LoginInputs {
        reusable_cred: false,
        node_key_account: false,
        has_invite: false,
        interactive: false,
        force_browser: false,
    };
    assert_eq!(login_path(fail_closed), LoginPath::FailClosed);
}

fn store_device_cred(server: &str, expires_at: Option<chrono::DateTime<Utc>>) -> String {
    let signer = Ed25519Signer::generate().expect("device key");
    let mut builder = biscuit_auth::Biscuit::builder()
        .fact(r#"user("alice")"#)
        .expect("user fact")
        .fact(format!("device_pop_key(\"{}\")", hex::encode(signer.public_key())).as_str())
        .expect("device PoP fact");
    if let Some(expires_at) = expires_at {
        builder = builder
            .fact(format!("expires_at({})", expires_at.to_rfc3339()).as_str())
            .expect("expiry fact");
    }
    let token = builder
        .build(&biscuit_auth::KeyPair::new())
        .expect("build token")
        .to_base64()
        .expect("encode token");
    credentials::store_server_credential(
        server,
        ServerCredential {
            token: token.clone(),
            subject: "alice".to_string(),
            device_id: None,
            credential_id: None,
            private_key_pem: Some(signer.to_pem().expect("pem")),
            expires_at: expires_at.map(|value| value.to_rfc3339()),
        },
    )
    .expect("store credential");
    token
}

#[tokio::test]
async fn login_reuses_a_valid_unexpired_credential_without_minting() {
    let _home = IsolatedHome::new();
    let server = "api.reuse.test";
    let token = store_device_cred(server, Some(Utc::now() + Duration::hours(2)));
    login(&TextCtx, server, false, None, false)
        .await
        .expect("reuse must succeed");
    let stored = credentials::get_server_credential(server)
        .expect("load")
        .expect("still stored");
    assert_eq!(stored.token, token, "reuse must not remint");
}

#[tokio::test]
async fn login_reuses_a_credential_that_has_no_stored_expiry() {
    let _home = IsolatedHome::new();
    let server = "api.reuse-no-expiry.test";
    let token = store_device_cred(server, None);
    login(&TextCtx, server, false, None, false)
        .await
        .expect("missing expiry is still a valid stored cred");
    let stored = credentials::get_server_credential(server)
        .expect("load")
        .expect("still stored");
    assert_eq!(stored.token, token, "reuse must not remint");
}

#[tokio::test]
async fn login_remints_an_expired_node_key_account_without_an_invite() {
    let _home = IsolatedHome::new();
    let server = "api.remint.test";
    let identity = agent_node_identity::load_or_create().expect("node identity");
    let seed = identity.secret_key().to_bytes();
    let signer = Ed25519Signer::from_seed(&seed).expect("signer");
    let root = mint_agent_root(&seed).expect("mint");
    let restricted =
        restrict_agent_account_root(&root.token, &signer, root.expires_at).expect("restrict");
    let expired = Utc::now() - Duration::hours(1);
    store_agent_root(
        server,
        restricted.clone(),
        root.subject.clone(),
        root.private_key_pem.clone(),
        expired,
    )
    .expect("store expired");
    login(&TextCtx, server, false, None, false)
        .await
        .expect("remint must succeed without invite");
    let stored = credentials::get_server_credential(server)
        .expect("load")
        .expect("reminted");
    assert_ne!(
        stored.token, restricted,
        "remint must replace the expired token"
    );
    let expires = stored.expires_at.expect("refreshed expiry");
    let parsed = chrono::DateTime::parse_from_rfc3339(&expires)
        .expect("rfc3339")
        .with_timezone(&Utc);
    assert!(parsed > Utc::now(), "reminted expiry must be in the future");
    let metadata = headless_token_metadata(&stored.token).expect("metadata");
    assert!(
        metadata
            .proof_public_key_hex
            .eq_ignore_ascii_case(&identity.node_id().to_string())
    );
}

#[tokio::test]
async fn login_fail_closed_without_tty_invite_or_account() {
    let _home = IsolatedHome::new();
    let error = login(&TextCtx, "api.heddle.sh", false, None, false)
        .await
        .expect_err("non-TTY login must fail closed");
    let advice = error
        .downcast_ref::<heddle_cli_contract::cli::commands::RecoveryAdvice>()
        .expect("typed refusal");
    assert_eq!(advice.kind, "auth_login_invite_required");
    assert_eq!(advice.primary_command, "heddle auth login --invite <code>");
    assert!(
        !agent_node_identity::identity_path().exists(),
        "fail-closed must not mint a node key"
    );
}

#[tokio::test]
async fn login_with_invite_does_not_take_the_fail_closed_path() {
    let _home = IsolatedHome::new();
    let _ = rustls::crypto::ring::default_provider().install_default();
    let error = tokio::time::timeout(
        std::time::Duration::from_secs(10),
        login(
            &TextCtx,
            "https://127.0.0.1:1",
            false,
            Some("invite-secret".to_string()),
            false,
        ),
    )
    .await
    .expect("invite login must not hang on a claim URL")
    .expect_err("invite create still needs a reachable server");
    let message = error.to_string();
    assert!(
        error
            .downcast_ref::<heddle_cli_contract::cli::commands::RecoveryAdvice>()
            .is_none_or(|advice| advice.kind != "auth_login_invite_required"),
        "invite must not fail closed: {message}"
    );
}

#[test]
fn login_invite_create_succeeds_with_a_claim_next_directive() {
    let _home = IsolatedHome::new();
    let server = "api.claim-next.test";
    let output = finish_invite_create_from_response(
        server,
        CreateAgentAccountResponse {
            account_id: "7ed1b633-64dd-4b78-b3a8-7f8e08fc4a28".into(),
            pet_name: "quiet-otter".into(),
            agent_capability: Vec::new(),
            web_origin: "https://claims.heddle.test/".into(),
        },
    )
    .expect("invite create must succeed without a server claim token");
    assert_eq!(output.output_kind, "agent_account_created");
    assert!(output.authenticated);
    assert!(output.credential_saved);
    assert_eq!(output.next.kind, "human_promotion_required");
    assert_eq!(output.next.command, "heddle claim");
    assert_eq!(output.next.account_id, output.account_id);
    let json = serde_json::to_value(&output).expect("serialize machine contract");
    assert_eq!(json["next"]["kind"], "human_promotion_required");
    assert_eq!(json["next"]["command"], "heddle claim");
    assert!(
        credentials::get_server_credential(server)
            .expect("load credential")
            .is_some(),
        "successful create must persist its agent credential"
    );
    let state = identity_state::load()
        .expect("load claim state")
        .expect("claim state was stored");
    assert_eq!(
        state.web_origin.as_deref(),
        Some("https://claims.heddle.test/")
    );
}

#[tokio::test]
async fn remint_uses_claim_state_when_the_keystore_row_is_missing() {
    let _home = IsolatedHome::new();
    let server = "api.claim-state.test";
    let identity = agent_node_identity::load_or_create().expect("node identity");
    identity_state::store(&ClaimState::new(
        server.to_string(),
        uuid::Uuid::parse_str("7ed1b633-64dd-4b78-b3a8-7f8e08fc4a28").expect("uuid"),
        "subject-1".to_string(),
        "quiet-otter".to_string(),
        identity.node_id().to_string(),
        None,
    ))
    .expect("store claim state");
    login(&TextCtx, server, false, None, false)
        .await
        .expect("missing cred + claim state remints");
    let stored = credentials::get_server_credential(server)
        .expect("load")
        .expect("reminted into the keystore");
    assert!(!stored.token.is_empty());
}