camber 0.1.6

Opinionated async Rust for IO-bound services on top of Tokio
Documentation
use camber::secret::{SecretRef, load_secret};
use std::io::Write;
use tempfile::NamedTempFile;

#[test]
fn loads_token_from_env_var() {
    let expected = std::env::var("HOME").expect("HOME must be set");

    let result = load_secret(&SecretRef::Env("HOME".into()));

    assert_eq!(&*result.unwrap(), expected.trim());
}

#[test]
fn loads_token_from_file() {
    let mut f = NamedTempFile::new().expect("temp file");
    f.write_all(b"secret456\n").expect("write");

    let path: Box<str> = f.path().to_str().expect("utf8 path").into();
    let result = load_secret(&SecretRef::File(path));

    assert_eq!(&*result.unwrap(), "secret456");
}

#[test]
fn missing_env_var_returns_error() {
    let result = load_secret(&SecretRef::Env("NONEXISTENT_VAR_12345".into()));

    let err = result.unwrap_err();
    let msg = err.to_string();
    assert!(
        msg.contains("NONEXISTENT_VAR_12345"),
        "error should name the variable: {msg}"
    );
}

#[test]
fn missing_file_returns_error() {
    let result = load_secret(&SecretRef::File("/tmp/nonexistent_token_file".into()));

    let err = result.unwrap_err();
    let msg = err.to_string();
    assert!(
        msg.contains("nonexistent_token_file"),
        "error should name the file: {msg}"
    );
}

#[cfg(feature = "dns01")]
mod cloudflare {
    use camber::dns01::{CloudflareProvider, DnsProvider};
    use serde_json::json;
    use wiremock::matchers::{header, method, path, query_param};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    fn zone_response() -> serde_json::Value {
        json!({
            "success": true,
            "result": [{"id": "zone123", "name": "example.com"}],
            "errors": []
        })
    }

    async fn mock_zone_lookup(server: &MockServer) {
        Mock::given(method("GET"))
            .and(path("/zones"))
            .and(query_param("name", "example.com"))
            .respond_with(ResponseTemplate::new(200).set_body_json(zone_response()))
            .mount(server)
            .await;
    }

    async fn setup_provider(server: &MockServer) -> CloudflareProvider {
        mock_zone_lookup(server).await;
        CloudflareProvider::with_base_url("test-token".into(), "example.com", server.uri().into())
            .await
            .expect("provider creation")
    }

    #[tokio::test]
    async fn cloudflare_creates_txt_record() {
        let server = MockServer::start().await;

        Mock::given(method("POST"))
            .and(path("/zones/zone123/dns_records"))
            .and(header("Authorization", "Bearer test-token"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "success": true,
                "result": {"id": "record456"},
                "errors": []
            })))
            .expect(1)
            .mount(&server)
            .await;

        let provider = setup_provider(&server).await;
        let record_id = provider
            .create_txt_record("_acme-challenge.example.com", "token123")
            .await
            .expect("create record");

        assert_eq!(&*record_id, "record456");
    }

    #[tokio::test]
    async fn cloudflare_deletes_txt_record() {
        let server = MockServer::start().await;

        Mock::given(method("DELETE"))
            .and(path("/zones/zone123/dns_records/record456"))
            .and(header("Authorization", "Bearer test-token"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "success": true,
                "result": {"id": "record456"},
                "errors": []
            })))
            .expect(1)
            .mount(&server)
            .await;

        let provider = setup_provider(&server).await;
        provider
            .delete_txt_record("record456")
            .await
            .expect("delete record");
    }

    #[tokio::test]
    async fn cloudflare_looks_up_zone_id() {
        let server = MockServer::start().await;

        Mock::given(method("GET"))
            .and(path("/zones"))
            .and(query_param("name", "example.com"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "success": true,
                "result": [{"id": "resolved-zone-42", "name": "example.com"}],
                "errors": []
            })))
            .expect(1)
            .mount(&server)
            .await;

        let provider = CloudflareProvider::with_base_url(
            "test-token".into(),
            "example.com",
            server.uri().into(),
        )
        .await;

        assert!(
            provider.is_ok(),
            "zone ID should be resolved from API response"
        );
    }

    #[tokio::test]
    async fn cloudflare_zone_lookup_walks_hierarchy() {
        let server = MockServer::start().await;

        Mock::given(method("GET"))
            .and(path("/zones"))
            .and(query_param("name", "app.example.com"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "success": true,
                "result": [],
                "errors": []
            })))
            .expect(1)
            .mount(&server)
            .await;

        Mock::given(method("GET"))
            .and(path("/zones"))
            .and(query_param("name", "example.com"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "success": true,
                "result": [{"id": "zone-walked", "name": "example.com"}],
                "errors": []
            })))
            .expect(1)
            .mount(&server)
            .await;

        let provider = CloudflareProvider::with_base_url(
            "test-token".into(),
            "app.example.com",
            server.uri().into(),
        )
        .await;

        assert!(provider.is_ok(), "should find zone by walking hierarchy");
    }

    #[tokio::test]
    async fn cloudflare_zone_lookup_multi_part_tld() {
        let server = MockServer::start().await;

        Mock::given(method("GET"))
            .and(path("/zones"))
            .and(query_param("name", "app.mysite.co.uk"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "success": true, "result": [], "errors": []
            })))
            .expect(1)
            .mount(&server)
            .await;

        Mock::given(method("GET"))
            .and(path("/zones"))
            .and(query_param("name", "mysite.co.uk"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "success": true,
                "result": [{"id": "zone-uk", "name": "mysite.co.uk"}],
                "errors": []
            })))
            .expect(1)
            .mount(&server)
            .await;

        let provider = CloudflareProvider::with_base_url(
            "test-token".into(),
            "app.mysite.co.uk",
            server.uri().into(),
        )
        .await;

        assert!(provider.is_ok(), "should find zone for multi-part TLD");
    }

    #[tokio::test]
    async fn cloudflare_auth_failure_returns_error() {
        let server = MockServer::start().await;

        Mock::given(method("POST"))
            .and(path("/zones/zone123/dns_records"))
            .respond_with(ResponseTemplate::new(403).set_body_json(json!({
                "success": false,
                "result": null,
                "errors": [{"code": 9103, "message": "Unknown X-Auth-Key or X-Auth-Email"}]
            })))
            .mount(&server)
            .await;

        let provider = setup_provider(&server).await;
        let err = provider
            .create_txt_record("_acme-challenge.example.com", "token123")
            .await
            .unwrap_err();

        let msg = err.to_string();
        assert!(
            msg.contains("Auth"),
            "error should describe authentication failure: {msg}"
        );
    }
}

#[cfg(feature = "dns01")]
mod acme_dns01 {
    use std::path::Path;
    use std::time::{SystemTime, UNIX_EPOCH};

    use camber::dns01::AcmeDns01;
    use tempfile::TempDir;

    fn write_test_cache(cache_dir: &Path, days_until_expiry: i64) {
        let ck = rcgen::generate_simple_self_signed(vec!["test.example.com".into()])
            .expect("generate cert");

        std::fs::create_dir_all(cache_dir).expect("create cache dir");
        std::fs::write(cache_dir.join("cert.pem"), ck.cert.pem()).expect("write cert");
        std::fs::write(cache_dir.join("key.pem"), ck.signing_key.serialize_pem())
            .expect("write key");

        let expiry_secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time")
            .as_secs() as i64
            + (days_until_expiry * 86400);
        std::fs::write(cache_dir.join("expiry"), expiry_secs.to_string()).expect("write expiry");
    }

    #[tokio::test]
    #[ignore]
    async fn acme_dns01_provisions_cert() {
        let domain = std::env::var("ACME_TEST_DOMAIN").expect("ACME_TEST_DOMAIN must be set");
        let token = std::env::var("CF_TOKEN").expect("CF_TOKEN must be set");

        let provider = camber::dns01::CloudflareProvider::new(token.into(), &domain)
            .await
            .expect("cloudflare provider");

        let cache_dir = TempDir::new().expect("temp dir");
        let config = AcmeDns01::new("test", [&*domain])
            .email("test@example.com")
            .cache_dir(cache_dir.path())
            .staging(true);

        let cert = config
            .provision_cert(&provider)
            .await
            .expect("provision cert");
        assert!(!cert.cert.is_empty(), "cert chain should not be empty");
        assert!(
            cache_dir.path().join("cert.pem").exists(),
            "cert cached to disk"
        );
        assert!(
            cache_dir.path().join("key.pem").exists(),
            "key cached to disk"
        );
    }

    #[tokio::test]
    async fn cert_cached_to_disk() {
        let cache_dir = TempDir::new().expect("temp dir");
        write_test_cache(cache_dir.path(), 60);

        let config = AcmeDns01::new("test", ["test.example.com"]).cache_dir(cache_dir.path());
        let cert = config.load_cached_cert().expect("load cached cert");
        assert!(
            cert.is_some(),
            "cached cert should load without new ACME order"
        );

        let config2 = AcmeDns01::new("test", ["test.example.com"]).cache_dir(cache_dir.path());
        let cert2 = config2.load_cached_cert().expect("load cached cert again");
        assert!(cert2.is_some(), "cert still loadable from cache");
    }

    #[tokio::test]
    async fn renewal_triggered_before_expiry() {
        let cache_near = TempDir::new().expect("temp dir");
        write_test_cache(cache_near.path(), 15);
        let config_near = AcmeDns01::new("test", ["test.example.com"]).cache_dir(cache_near.path());
        assert!(
            config_near.needs_renewal(),
            "cert expiring in 15 days should need renewal"
        );

        let cache_far = TempDir::new().expect("temp dir");
        write_test_cache(cache_far.path(), 60);
        let config_far = AcmeDns01::new("test", ["test.example.com"]).cache_dir(cache_far.path());
        assert!(
            !config_far.needs_renewal(),
            "cert expiring in 60 days should not need renewal"
        );
    }
}