forjar 1.4.1

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! Template tests — secrets resolution and edge cases.

#![allow(unused_imports)]
use super::template::{resolve_secret, resolve_template};
use super::*;
use std::collections::HashMap;

#[test]
fn test_fj132_resolve_secret_missing() {
    // Use a key that won't exist in any CI/local env
    let result = resolve_secret("zzz-nonexistent-key-12345", &SecretsConfig::default());
    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(err.contains("FORJAR_SECRET_ZZZ_NONEXISTENT_KEY_12345"));
    assert!(err.contains("not found"));
}

#[test]
fn test_fj132_resolve_secret_env_key_format() {
    // Verify the env key derivation: hyphens → underscores, uppercase
    let result = resolve_secret("my-db-pass", &SecretsConfig::default());
    // Will fail because env var doesn't exist, but error message shows the derived key
    let err = result.unwrap_err();
    assert!(
        err.contains("FORJAR_SECRET_MY_DB_PASS"),
        "should derive FORJAR_SECRET_MY_DB_PASS from 'my-db-pass'"
    );
}

#[test]
fn test_resolve_secret_file_provider() {
    let dir = tempfile::tempdir().unwrap();
    let secret_path = dir.path().join("db-pass");
    std::fs::write(&secret_path, "s3cret_val\n").unwrap();
    let cfg = SecretsConfig {
        provider: Some("file".into()),
        path: Some(dir.path().to_string_lossy().to_string()),
        ..Default::default()
    };
    let result = resolve_secret("db-pass", &cfg).unwrap();
    assert_eq!(result, "s3cret_val");
}

#[test]
fn test_resolve_secret_file_provider_missing() {
    let dir = tempfile::tempdir().unwrap();
    let cfg = SecretsConfig {
        provider: Some("file".into()),
        path: Some(dir.path().to_string_lossy().to_string()),
        ..Default::default()
    };
    let result = resolve_secret("nonexistent", &cfg);
    assert!(result.is_err());
    assert!(result.unwrap_err().contains("not found"));
}

#[test]
fn test_fj132_unclosed_template() {
    let params = HashMap::new();
    let machines = indexmap::IndexMap::new();
    let result = resolve_template("hello {{params.name", &params, &machines);
    assert!(result.is_err());
    assert!(result.unwrap_err().contains("unclosed template"));
}

#[test]
fn test_fj132_resolve_template_secret_missing_error() {
    let params = HashMap::new();
    let machines = indexmap::IndexMap::new();
    let result = resolve_template("token={{secrets.zzz-missing-99}}", &params, &machines);
    assert!(result.is_err());
    assert!(result.unwrap_err().contains("FORJAR_SECRET_ZZZ_MISSING_99"));
}

#[test]
fn test_resolve_template_nested_braces() {
    let mut params = HashMap::new();
    params.insert(
        "x".to_string(),
        serde_yaml_ng::Value::String("value".to_string()),
    );
    let machines = indexmap::IndexMap::new();
    let result = resolve_template("{{params.x}}_suffix", &params, &machines).unwrap();
    assert_eq!(result, "value_suffix");
}

#[test]
fn test_resolve_template_multiple() {
    let mut params = HashMap::new();
    params.insert(
        "a".to_string(),
        serde_yaml_ng::Value::String("hello".to_string()),
    );
    params.insert(
        "b".to_string(),
        serde_yaml_ng::Value::String("world".to_string()),
    );
    let machines = indexmap::IndexMap::new();
    let result = resolve_template("{{params.a}}-{{params.b}}", &params, &machines).unwrap();
    assert_eq!(result, "hello-world");
}

// ── FJ-2300: Secret provider tests ──

#[test]
fn test_secret_file_provider() {
    let dir = tempfile::tempdir().unwrap();
    let secret_file = dir.path().join("db_password");
    std::fs::write(&secret_file, "s3cret\n").unwrap();

    let result = super::template::resolve_secret_with_provider(
        "db_password",
        Some("file"),
        Some(dir.path().to_str().unwrap()),
        None,
    );
    assert_eq!(result.unwrap(), "s3cret"); // trims trailing newline
}

#[test]
fn test_secret_file_provider_missing() {
    let result = super::template::resolve_secret_with_provider(
        "nonexistent",
        Some("file"),
        Some("/tmp/forjar-test-no-such-dir"),
        None,
    );
    assert!(result.is_err());
    assert!(result.unwrap_err().contains("not found"));
}

#[test]
#[allow(clippy::disallowed_methods)]
fn test_secret_env_provider_explicit() {
    std::env::set_var("FORJAR_SECRET_TEST_KEY_2300", "env-secret");
    let result =
        super::template::resolve_secret_with_provider("test_key_2300", Some("env"), None, None);
    assert_eq!(result.unwrap(), "env-secret");
    std::env::remove_var("FORJAR_SECRET_TEST_KEY_2300");
}

#[test]
fn test_redact_secrets() {
    let text = "password is s3cret and token is abc123";
    let secrets = vec!["s3cret".to_string(), "abc123".to_string()];
    let redacted = super::template::redact_secrets(text, &secrets);
    assert_eq!(redacted, "password is *** and token is ***");
}

#[test]
fn test_redact_secrets_empty() {
    let text = "no secrets here";
    let redacted = super::template::redact_secrets(text, &[]);
    assert_eq!(redacted, "no secrets here");
}

#[test]
fn test_redact_secrets_empty_value() {
    let text = "keep me";
    let secrets = vec!["".to_string()];
    let redacted = super::template::redact_secrets(text, &secrets);
    assert_eq!(redacted, "keep me"); // empty secrets are skipped
}

// ── FJ-2300: SOPS and 1Password provider tests ──

#[test]
fn test_secret_sops_provider_missing_binary() {
    // sops is not installed in CI — verify graceful error
    let result = super::template::resolve_secret_with_provider(
        "db_password",
        Some("sops"),
        None,
        Some("secrets.enc.yaml"),
    );
    // Either sops isn't installed (exec error) or file doesn't exist (sops error)
    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(err.contains("sops"), "expected sops in error: {err}");
}

#[test]
fn test_secret_sops_provider_default_file() {
    let result = super::template::resolve_secret_with_provider(
        "api_key",
        Some("sops"),
        None,
        None, // defaults to secrets.enc.yaml
    );
    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(err.contains("sops"), "expected sops in error: {err}");
}

#[test]
fn test_secret_op_provider_missing_binary() {
    let result = super::template::resolve_secret_with_provider(
        "db_password",
        Some("op"),
        Some("my-vault"),
        None,
    );
    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(err.contains("op"), "expected op in error: {err}");
}

#[test]
fn test_secret_op_provider_default_vault() {
    let result = super::template::resolve_secret_with_provider(
        "api_key",
        Some("op"),
        None, // defaults to "forjar" vault
        None,
    );
    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(err.contains("op"), "expected op in error: {err}");
}

#[test]
fn test_secret_unknown_provider_falls_back_to_env() {
    // Unknown provider falls through to env provider (the default case)
    let result = super::template::resolve_secret_with_provider(
        "nonexistent_key_xyz",
        Some("unknown_provider"),
        None,
        None,
    );
    assert!(result.is_err());
    // Should get the env provider error message
    assert!(result.unwrap_err().contains("FORJAR_SECRET_"));
}

#[test]
fn test_secret_config_with_sops_file() {
    use crate::core::types::SecretsConfig;
    let cfg = SecretsConfig {
        provider: Some("sops".into()),
        file: Some("my-secrets.enc.yaml".into()),
        ..Default::default()
    };
    let result = super::template::resolve_secret_with_provider(
        "key",
        cfg.provider.as_deref(),
        cfg.path.as_deref(),
        cfg.file.as_deref(),
    );
    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(err.contains("sops"), "expected sops in error: {err}");
}