cmn-hypha 0.3.0

CMN CLI tool — spawn, grow, release, taste, bond, and absorb spores on the Code Mycelial Network
Documentation
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use super::*;

#[test]
fn test_default_values() {
    let cfg = HyphaConfig::default();
    assert_eq!(cfg.cache.cmn_ttl_s, 300);
    assert_eq!(cfg.cache.spore_max_download_bytes, 1024 * 1024 * 1024);
    assert_eq!(cfg.cache.spore_max_extract_bytes, 512 * 1024 * 1024);
    assert_eq!(cfg.cache.spore_max_extract_files, 100_000);
    assert_eq!(cfg.cache.spore_max_extract_file_bytes, 256 * 1024 * 1024);
    assert_eq!(
        cfg.cache.spore_reject_path_components,
        vec![".git".to_string(), ".cmn".to_string()]
    );
    assert!(cfg.defaults.synapse.is_none());
}

#[test]
fn test_parse_full_toml() {
    let toml_str = r#"
[defaults]
synapse = "synapse.cmn.dev"

[cache]
cmn_ttl_s = 60
spore_max_download_bytes = 1073741824
spore_max_extract_bytes = 536870912
spore_max_extract_files = 100000
spore_max_extract_file_bytes = 268435456
spore_reject_path_components = [".git", ".cmn"]
"#;
    let cfg: HyphaConfig = toml::from_str(toml_str).unwrap();
    assert_eq!(cfg.cache.cmn_ttl_s, 60);
    assert_eq!(cfg.cache.spore_max_download_bytes, 1_073_741_824);
    assert_eq!(cfg.cache.spore_max_extract_bytes, 536_870_912);
    assert_eq!(cfg.cache.spore_max_extract_files, 100_000);
    assert_eq!(cfg.cache.spore_max_extract_file_bytes, 268_435_456);
    assert_eq!(
        cfg.cache.spore_reject_path_components,
        vec![".git".to_string(), ".cmn".to_string()]
    );
    assert_eq!(cfg.defaults.synapse.as_deref(), Some("synapse.cmn.dev"));
}

#[test]
fn test_parse_partial_toml_cmn_only() {
    let toml_str = r#"
[cache]
cmn_ttl_s = 10
"#;
    let cfg: HyphaConfig = toml::from_str(toml_str).unwrap();
    assert_eq!(cfg.cache.cmn_ttl_s, 10);
}

#[test]
fn test_parse_empty_toml() {
    let cfg: HyphaConfig = toml::from_str("").unwrap();
    assert_eq!(cfg.cache.cmn_ttl_s, 300);
}

#[test]
fn test_parse_empty_cache_section() {
    let toml_str = "[cache]\n";
    let cfg: HyphaConfig = toml::from_str(toml_str).unwrap();
    assert_eq!(cfg.cache.cmn_ttl_s, 300);
    assert_eq!(
        cfg.cache.spore_reject_path_components,
        vec![".git".to_string(), ".cmn".to_string()]
    );
}

#[test]
fn test_legacy_cache_spore_limit_keys_are_unsupported() {
    let toml_str = r#"
[cache]
max_download_bytes = 1
max_extract_bytes = 1
max_extract_files = 1
max_extract_file_bytes = 1
"#;
    let err = toml::from_str::<HyphaConfig>(toml_str).unwrap_err();
    assert!(
        err.to_string().contains("unknown field"),
        "unexpected error: {}",
        err
    );
}

#[test]
fn test_invalid_toml_falls_back_to_default() {
    let bad_toml = "this is not valid toml {{{{";
    let cfg: HyphaConfig = toml::from_str(bad_toml).unwrap_or_default();
    assert_eq!(cfg.cache.cmn_ttl_s, 300);
}

#[test]
fn test_require_domain_first_key_default_true() {
    let cfg = HyphaConfig::default();
    assert!(cfg.cache.require_domain_first_key);
}

#[test]
fn test_require_domain_first_key_toml_parse() {
    let toml_str = r#"
[cache]
require_domain_first_key = false
"#;
    let cfg: HyphaConfig = toml::from_str(toml_str).unwrap();
    assert!(!cfg.cache.require_domain_first_key);
}

#[test]
fn test_require_domain_first_key_absent_defaults_true() {
    let toml_str = r#"
[cache]
cmn_ttl_s = 60
"#;
    let cfg: HyphaConfig = toml::from_str(toml_str).unwrap();
    assert!(cfg.cache.require_domain_first_key);
}

#[test]
fn test_zero_ttl_allowed() {
    let toml_str = r#"
[cache]
cmn_ttl_s = 0
"#;
    let cfg: HyphaConfig = toml::from_str(toml_str).unwrap();
    assert_eq!(cfg.cache.cmn_ttl_s, 0);
}

#[test]
fn test_config_save_load() {
    let _lock = super::ENV_LOCK.lock().unwrap();
    let dir = tempfile::tempdir().unwrap();
    std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());

    let mut cfg = HyphaConfig::default();
    cfg.defaults.synapse = Some("synapse.cmn.dev".to_string());
    cfg.cache.cmn_ttl_s = 999;
    cfg.save().unwrap();

    let loaded = HyphaConfig::load().unwrap();
    assert_eq!(loaded.defaults.synapse.as_deref(), Some("synapse.cmn.dev"));
    assert_eq!(loaded.cache.cmn_ttl_s, 999);

    std::env::remove_var("CMN_HOME");
}

#[test]
fn test_config_load_invalid_file_returns_error() {
    let _lock = super::ENV_LOCK.lock().unwrap();
    let dir = tempfile::tempdir().unwrap();
    let hypha_dir = dir.path().join("hypha");
    std::fs::create_dir_all(&hypha_dir).unwrap();
    std::fs::write(hypha_dir.join("config.toml"), "this is not valid toml {{{{").unwrap();
    std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());

    let err = HyphaConfig::load().unwrap_err();
    assert_eq!(err.code, "config_parse_failed");
    assert!(err.message.contains("config.toml"));
    assert_eq!(
        err.hint.as_deref(),
        Some("fix the file or remove it to use defaults")
    );

    std::env::remove_var("CMN_HOME");
}

#[test]
fn test_synapse_node_roundtrip() {
    let toml_str = r#"
url = "https://synapse.cmn.dev"
token_secret = "sk-abc123"
"#;
    let node: SynapseNode = toml::from_str(toml_str).unwrap();
    assert_eq!(node.url, "https://synapse.cmn.dev");
    assert_eq!(node.token_secret.as_deref(), Some("sk-abc123"));

    let serialized = toml::to_string_pretty(&node).unwrap();
    let parsed: SynapseNode = toml::from_str(&serialized).unwrap();
    assert_eq!(parsed.url, "https://synapse.cmn.dev");
    assert_eq!(parsed.token_secret.as_deref(), Some("sk-abc123"));
}

#[test]
fn test_synapse_node_no_token() {
    let toml_str = "url = \"https://synapse.cmn.dev\"\n";
    let node: SynapseNode = toml::from_str(toml_str).unwrap();
    assert!(node.token_secret.is_none());

    let serialized = toml::to_string_pretty(&node).unwrap();
    assert!(!serialized.contains("token_secret"));
}

#[test]
fn test_save_load_synapse_node() {
    let _lock = super::ENV_LOCK.lock().unwrap();
    let dir = tempfile::tempdir().unwrap();
    std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());

    let node = SynapseNode {
        url: "https://synapse.cmn.dev".to_string(),
        token_secret: Some("tok".to_string()),
    };
    save_synapse_node("synapse.cmn.dev", &node).unwrap();

    let node_dir = dir
        .path()
        .join("hypha")
        .join("synapse")
        .join("synapse.cmn.dev");
    assert!(node_dir.join("config.toml").exists());

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        let mode = std::fs::metadata(node_dir.join("config.toml"))
            .unwrap()
            .permissions()
            .mode();
        assert_eq!(
            mode & 0o777,
            0o600,
            "config.toml should be 0600, got {:o}",
            mode & 0o777
        );
    }

    let loaded = load_synapse_node("synapse.cmn.dev").unwrap();
    assert_eq!(loaded.url, "https://synapse.cmn.dev");
    assert_eq!(loaded.token_secret.as_deref(), Some("tok"));

    std::env::remove_var("CMN_HOME");
}

#[test]
fn test_list_synapse_domains() {
    let _lock = super::ENV_LOCK.lock().unwrap();
    let dir = tempfile::tempdir().unwrap();
    std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());

    save_synapse_node(
        "beta.example.com",
        &SynapseNode {
            url: "https://beta.example.com".to_string(),
            token_secret: None,
        },
    )
    .unwrap();
    save_synapse_node(
        "alpha.example.com",
        &SynapseNode {
            url: "https://alpha.example.com".to_string(),
            token_secret: None,
        },
    )
    .unwrap();

    let domains = list_synapse_domains();
    assert_eq!(domains, vec!["alpha.example.com", "beta.example.com"]);

    std::env::remove_var("CMN_HOME");
}

#[test]
fn test_remove_synapse_node() {
    let _lock = super::ENV_LOCK.lock().unwrap();
    let dir = tempfile::tempdir().unwrap();
    std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());

    save_synapse_node(
        "test.example.com",
        &SynapseNode {
            url: "https://test.example.com".to_string(),
            token_secret: None,
        },
    )
    .unwrap();

    assert!(load_synapse_node("test.example.com").is_some());

    remove_synapse_node("test.example.com").unwrap();
    assert!(load_synapse_node("test.example.com").is_none());
    assert!(list_synapse_domains().is_empty());

    std::env::remove_var("CMN_HOME");
}

#[test]
fn test_domain_from_url() {
    assert_eq!(
        domain_from_url("https://synapse.cmn.dev").unwrap(),
        "synapse.cmn.dev"
    );
    assert_eq!(
        domain_from_url("http://localhost:8080").unwrap(),
        "localhost"
    );
    assert_eq!(
        domain_from_url("https://example.com/path").unwrap(),
        "example.com"
    );
    assert!(domain_from_url("not-a-url").is_err());
    assert!(domain_from_url("https://..").is_err());
}

#[test]
fn test_validate_synapse_url_rejects_clearnet_http() {
    let err = validate_synapse_url("http://synapse.cmn.dev").unwrap_err();
    assert_eq!(err.code, "invalid_synapse_url");
    assert!(err
        .message
        .contains("Insecure cleartext transport rejected"));
}

#[test]
fn test_validate_synapse_url_allows_https_and_anonymous_http() {
    assert!(validate_synapse_url("https://synapse.cmn.dev").is_ok());
    assert!(validate_synapse_url("http://abc123.onion").is_ok());
    assert!(validate_synapse_url("http://site.i2p").is_ok());
}

#[test]
fn test_validate_synapse_url_rejects_known_cleartext_schemes() {
    assert_eq!(
        validate_synapse_url("ws://synapse.cmn.dev")
            .unwrap_err()
            .code,
        "invalid_synapse_url"
    );
    assert_eq!(
        validate_synapse_url("ftp://synapse.cmn.dev")
            .unwrap_err()
            .code,
        "invalid_synapse_url"
    );
}

#[test]
fn test_validate_synapse_url_rejects_ip_literals() {
    for url in ["https://127.0.0.1", "https://[::1]", "https://203.0.113.10"] {
        assert_eq!(
            validate_synapse_url(url).unwrap_err().code,
            "invalid_synapse_url",
            "{} should be rejected",
            url
        );
    }
}

#[test]
fn test_resolve_synapse_env_var_override() {
    let _lock = super::ENV_LOCK.lock().unwrap();
    let dir = tempfile::tempdir().unwrap();
    std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());

    save_synapse_node(
        "test.example.com",
        &SynapseNode {
            url: "https://test.example.com".to_string(),
            token_secret: Some("config-token".to_string()),
        },
    )
    .unwrap();

    std::env::set_var("SYNAPSE_TOKEN_SECRET", "env-token");
    let resolved = resolve_synapse(Some("test.example.com"), None).unwrap();
    assert_eq!(resolved.token_secret.as_deref(), Some("env-token"));

    let resolved = resolve_synapse(Some("test.example.com"), Some("cli-token")).unwrap();
    assert_eq!(resolved.token_secret.as_deref(), Some("cli-token"));

    let resolved = resolve_synapse(Some("test.example.com"), Some("")).unwrap();
    assert!(resolved.token_secret.is_none());

    std::env::remove_var("SYNAPSE_TOKEN_SECRET");

    let resolved = resolve_synapse(Some("test.example.com"), None).unwrap();
    assert_eq!(resolved.token_secret.as_deref(), Some("config-token"));

    std::env::remove_var("CMN_HOME");
}

#[test]
fn test_load_missing_node_returns_none() {
    let _lock = super::ENV_LOCK.lock().unwrap();
    let dir = tempfile::tempdir().unwrap();
    std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());

    assert!(load_synapse_node("nonexistent.example.com").is_none());

    std::env::remove_var("CMN_HOME");
}