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()),
)
}