Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

agent/cli/tests/integration_holger.rs

//! Integration test: holger-server + mannequin
//!
//! Uses holger-mannequin as a mock backend (PKI + mock Nexus).
//! Tests both --no-tls (h2c) and TLS paths.
//!
//! Run with: cargo test -p holger-agent-cli --test integration_holger -- --nocapture

use std::time::Duration;

/// Test holger-server with --no-tls via subprocess + mannequin mock Nexus.
#[tokio::test]
async fn test_holger_h2c_with_mannequin_nexus() {
    use mannequin::MannequinServer;

    // Start mannequin on a random port
    let mannequin = MannequinServer::new(0).unwrap();
    let mannequin_router = mannequin.router();

    let mannequin_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let mannequin_port = mannequin_listener.local_addr().unwrap().port();

    tokio::spawn(async move {
        axum::serve(mannequin_listener, mannequin_router)
            .await
            .unwrap();
    });

    // Wait for mannequin to be ready
    tokio::time::sleep(Duration::from_millis(100)).await;

    let client = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");

    // Verify mannequin health
    let resp = client
        .get(format!("http://127.0.0.1:{}/health", mannequin_port))
        .send()
        .await
        .expect("mannequin health check failed");
    assert_eq!(resp.status(), 200);

    // Get CA cert from mannequin
    let ca_pem = client
        .get(format!("http://127.0.0.1:{}/pki/ca.pem", mannequin_port))
        .send()
        .await
        .unwrap()
        .text()
        .await
        .unwrap();
    assert!(
        ca_pem.contains("BEGIN CERTIFICATE"),
        "Should get PEM CA cert"
    );

    // Issue a server cert from mannequin
    let cert_resp: serde_json::Value = client
        .post(format!(
            "http://127.0.0.1:{}/pki/issue/server",
            mannequin_port
        ))
        .json(&serde_json::json!({ "cn": "localhost", "sans": ["localhost", "127.0.0.1"] }))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert!(cert_resp["cert_pem"]
        .as_str()
        .unwrap()
        .contains("BEGIN CERTIFICATE"));
    assert!(cert_resp["key_pem"]
        .as_str()
        .unwrap()
        .contains("PRIVATE KEY"));

    // Test mock Nexus: list repos
    let repos: serde_json::Value = client
        .get(format!(
            "http://127.0.0.1:{}/service/rest/v1/repositories",
            mannequin_port
        ))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert!(
        !repos.as_array().unwrap().is_empty(),
        "Should have at least upload"
    );

    // Test mock Nexus: upload an asset
    let upload_resp = client
        .put(format!(
            "http://127.0.0.1:{}/repository/upload/crates/serde/1.0.0/serde-1.0.0.crate",
            mannequin_port
        ))
        .body(b"FAKE_CRATE_DATA".to_vec())
        .send()
        .await
        .unwrap();
    assert_eq!(upload_resp.status(), 201);

    // Test mock Nexus: list assets
    let assets: serde_json::Value = client
        .get(format!(
            "http://127.0.0.1:{}/service/rest/v1/assets?repository=upload",
            mannequin_port
        ))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    let items = assets["items"].as_array().unwrap();
    assert_eq!(items.len(), 1);
    assert_eq!(items[0]["path"], "crates/serde/1.0.0/serde-1.0.0.crate");

    // Test mock Nexus: download asset
    let download_resp = client
        .get(format!(
            "http://127.0.0.1:{}/repository/upload/crates/serde/1.0.0/serde-1.0.0.crate",
            mannequin_port
        ))
        .send()
        .await
        .unwrap();
    assert_eq!(download_resp.status(), 200);
    assert_eq!(
        download_resp.bytes().await.unwrap().as_ref(),
        b"FAKE_CRATE_DATA"
    );

    // Test OIDC discovery
    let discovery: serde_json::Value = client
        .get(format!(
            "http://127.0.0.1:{}/.well-known/openid-configuration",
            mannequin_port
        ))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert!(discovery["issuer"].as_str().is_some());
    assert!(discovery["token_endpoint"].as_str().is_some());

    // Test OIDC token issuance
    let token_resp: serde_json::Value = client
        .post(format!("http://127.0.0.1:{}/token", mannequin_port))
        .json(&serde_json::json!({ "username": "testuser", "grant_type": "password" }))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    let access_token = token_resp["access_token"].as_str().unwrap();
    assert_eq!(token_resp["token_type"], "Bearer");

    // Test OIDC userinfo with token
    let userinfo: serde_json::Value = client
        .get(format!("http://127.0.0.1:{}/userinfo", mannequin_port))
        .header("Authorization", format!("Bearer {}", access_token))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert_eq!(userinfo["sub"], "testuser");

    println!("=== All mannequin integration tests passed ===");
}

/// Test OIDC auth flow: agent fetches token from mannequin, uses it to authenticate.
#[tokio::test]
async fn test_oidc_auth_flow_with_mannequin() {
    use connector_nexus::{fetch_oidc_token, NexusConnector};
    use mannequin::MannequinServer;
    use traits::ConnectorTrait;

    // Bind listener first to get actual port, then create mannequin with that port
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let port = listener.local_addr().unwrap().port();
    let mannequin = MannequinServer::new(port).unwrap();
    let mannequin_router = mannequin.router();

    tokio::spawn(async move {
        axum::serve(listener, mannequin_router).await.unwrap();
    });
    tokio::time::sleep(Duration::from_millis(50)).await;

    let base_url = format!("http://127.0.0.1:{}", port);

    // 1. Fetch OIDC token (like the agent CLI would do with --auth oidc)
    let token_url = format!("{}/token", base_url);
    let token = fetch_oidc_token(&token_url, "ci-agent", "").await.unwrap();
    assert!(!token.is_empty(), "Should get a non-empty token");

    // 2. Create connector with Bearer token
    let connector = NexusConnector::with_oidc_token(base_url.clone(), token.clone()).unwrap();

    // 3. Use connector to list repos (proves Bearer auth works through)
    let repos = connector.list_repositories().await.unwrap();
    assert!(repos.len() >= 3, "Should see proxy-only, prod, upload");

    // 4. Upload via connector to upload repo
    connector
        .upload_asset("upload", "test/oidc-test.crate", b"OIDC_AUTHED_DATA")
        .await
        .unwrap();

    // 5. Download it back
    let assets = connector.list_assets("upload").await.unwrap();
    assert!(assets.iter().any(|a| a.path == "test/oidc-test.crate"));

    let data = connector
        .download_asset(
            assets
                .iter()
                .find(|a| a.path == "test/oidc-test.crate")
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(data, b"OIDC_AUTHED_DATA");

    // 6. Validate the token via auth endpoint
    let client = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    let validate: serde_json::Value = client
        .get(format!("{}/auth/validate", base_url))
        .header("Authorization", format!("Bearer {}", token))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert_eq!(validate["subject"], "ci-agent");
    assert_eq!(validate["method"], "oidc");
    let roles = validate["roles"].as_array().unwrap();
    assert!(roles.iter().any(|r| r == "writer"));

    println!("=== OIDC auth flow test passed ===");
}

/// Test mTLS auth: agent uses client cert to authenticate.
#[tokio::test]
async fn test_mtls_auth_validation_with_mannequin() {
    use mannequin::MannequinServer;

    // Start mannequin
    let mannequin = MannequinServer::new(0).unwrap();
    let mannequin_router = mannequin.router();

    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let port = listener.local_addr().unwrap().port();

    tokio::spawn(async move {
        axum::serve(listener, mannequin_router).await.unwrap();
    });
    tokio::time::sleep(Duration::from_millis(50)).await;

    let base_url = format!("http://127.0.0.1:{}", port);
    let client = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");

    // Issue client cert for "writer" user
    let creds: serde_json::Value = client
        .post(format!("{}/auth/credentials/writer", base_url))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert!(creds["client_cert_pem"]
        .as_str()
        .unwrap()
        .contains("BEGIN CERTIFICATE"));

    // Validate via X-Client-CN header (simulating mTLS CN extraction)
    let validate: serde_json::Value = client
        .get(format!("{}/auth/validate", base_url))
        .header("X-Client-CN", "writer")
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert_eq!(validate["subject"], "writer");
    assert_eq!(validate["method"], "mtls");
    let roles = validate["roles"].as_array().unwrap();
    assert!(roles.iter().any(|r| r == "writer"));
    assert!(roles.iter().any(|r| r == "reader"));

    println!("=== mTLS auth validation test passed ===");
}

/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour (2026-10-02): the ring
/// provider and the bundled Mozilla roots (webpki), not 0.13's aws-lc-rs + OS
/// trust store. Roots added later on the builder are merged in, as in 0.12.
#[allow(dead_code)]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
    let _ = rustls::crypto::ring::default_provider().install_default();
    b.tls_certs_only(
        webpki_root_certs::TLS_SERVER_ROOT_CERTS
            .iter()
            .filter_map(|c| reqwest::Certificate::from_der(c).ok()),
    )
}