//! Integration tests for [`aura_cli::init::provision_client`]. //! //! These tests first generate a CA + server cert via `pki::init` / `pki::issue_server`, then //! drive `provision_client` against that CA and verify: //! //! * the bundle directory ends up with `ca.crt`, `client.crt`, `client.key`, `client.toml`; //! * the rendered `client.toml` parses; //! * the issued client cert verifies against the original CA via [`AuraCertVerifier`]; //! * `--id` defaults to a UUID v4 and is reflected as the cert CN. use std::path::PathBuf; use aura_cli::config::ClientConfigFile; use aura_cli::init::{self, ProvisionClientOpts}; use aura_cli::pki; use aura_pki::AuraCertVerifier; use rustls_pki_types::CertificateDer; /// Per-test temp dir. fn temp_dir(tag: &str) -> PathBuf { let mut dir = std::env::temp_dir(); dir.push(format!( "aura-cli-provision-{tag}-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&dir).expect("create temp dir"); dir } /// Generate a CA at `ca_dir` for the rest of the test to use. fn bootstrap_ca(ca_dir: &std::path::Path) { pki::init("Aura Provision Test CA", ca_dir).expect("ca init"); } /// Decode a single-cert PEM into a DER chain for the verifier. fn pem_chain(pem_path: &std::path::Path) -> Vec> { let pem = std::fs::read(pem_path).expect("read cert"); let (_, parsed) = x509_parser::pem::parse_x509_pem(&pem).expect("parse PEM"); vec![CertificateDer::from(parsed.contents)] } /// Extract the certificate's CN via `x509-parser` so we can check that the assigned id ended up /// in the cert. fn cert_common_name(pem_path: &std::path::Path) -> String { let pem = std::fs::read(pem_path).expect("read cert"); let (_, parsed) = x509_parser::pem::parse_x509_pem(&pem).expect("parse PEM"); let (_, cert) = x509_parser::parse_x509_certificate(&parsed.contents).expect("parse cert"); let subject = cert.subject(); for cn in subject.iter_common_name() { if let Ok(s) = cn.as_str() { return s.to_string(); } } panic!("no CN in subject {subject:?}"); } /// Happy path: explicit id, bundle materialises and parses, cert verifies against CA. #[test] fn provision_client_with_explicit_id() { let root = temp_dir("happy"); let ca_dir = root.join("ca"); bootstrap_ca(&ca_dir); let bundle = root.join("client-bundle"); let mut opts = ProvisionClientOpts::new( &ca_dir, "203.0.113.10", "vpn.example.com", "10.7.0.2", &bundle, ); opts.id = Some("phone-1".to_string()); let report = init::provision_client(&opts).expect("provision"); assert_eq!(report.id, "phone-1", "explicit id preserved"); assert!(report.ca_cert.exists()); assert!(report.client_cert.exists()); assert!(report.client_key.exists()); assert!(report.client_config.exists()); // The bundled cert's CN matches the id we passed. assert_eq!(cert_common_name(&report.client_cert), "phone-1"); // The client.toml round-trips through the parser cleanly. let cfg = ClientConfigFile::load(&report.client_config).expect("parse client.toml"); assert_eq!(cfg.client.server_addr, "203.0.113.10:443"); assert_eq!(cfg.client.sni, "vpn.example.com"); assert_eq!(cfg.tunnel.local_ip, "10.7.0.2"); assert!(cfg.client.bridges.is_empty(), "no bridges by default"); // The verifier accepts the bundled chain against the same CA we issued from. let ca_pem = std::fs::read_to_string(ca_dir.join(pki::CA_CERT)).expect("read ca"); let verifier = AuraCertVerifier::new(&ca_pem).expect("verifier"); let chain = pem_chain(&report.client_cert); let cn = verifier .verify_client_cert(&chain) .expect("issued client cert chains to the CA"); assert_eq!(cn, "phone-1"); let _ = std::fs::remove_dir_all(&root); } /// Default `--id` path: a fresh UUID v4 is assigned and ends up as the CN. #[test] fn provision_client_default_id_is_uuid_v4() { let root = temp_dir("uuid"); let ca_dir = root.join("ca"); bootstrap_ca(&ca_dir); let bundle = root.join("bundle"); let opts = ProvisionClientOpts::new( &ca_dir, "203.0.113.10", "vpn.example.com", "10.7.0.5", &bundle, ); let report = init::provision_client(&opts).expect("provision"); // The id is a valid UUID v4 and equals the cert CN. let parsed = uuid::Uuid::parse_str(&report.id).expect("id is uuid"); assert_eq!(parsed.get_version_num(), 4, "uuid v4"); assert_eq!(cert_common_name(&report.client_cert), report.id); let _ = std::fs::remove_dir_all(&root); } /// `bridges = [...]` ends up in the rendered client.toml and parses back through the config. #[test] fn provision_client_writes_bridges() { let root = temp_dir("bridges"); let ca_dir = root.join("ca"); bootstrap_ca(&ca_dir); let bundle = root.join("bundle"); let mut opts = ProvisionClientOpts::new( &ca_dir, "203.0.113.10", "vpn.example.com", "10.7.0.3", &bundle, ); opts.bridges = vec!["203.0.113.11".to_string(), "203.0.113.12".to_string()]; let report = init::provision_client(&opts).expect("provision"); let cfg = ClientConfigFile::load(&report.client_config).expect("parse"); assert_eq!(cfg.client.bridges.len(), 2); assert!(cfg.client.bridges.contains(&"203.0.113.11".to_string())); assert!(cfg.client.bridges.contains(&"203.0.113.12".to_string())); let _ = std::fs::remove_dir_all(&root); } /// `enable_knock` / `enable_cover_traffic` flip the rendered TOML's `[transport.knock]` / /// `[transport.cover]` sections. #[test] fn provision_client_anti_surveillance_toggles() { let root = temp_dir("knock"); let ca_dir = root.join("ca"); bootstrap_ca(&ca_dir); let bundle = root.join("bundle"); let mut opts = ProvisionClientOpts::new( &ca_dir, "203.0.113.10", "vpn.example.com", "10.7.0.4", &bundle, ); opts.enable_knock = true; opts.enable_cover_traffic = true; let report = init::provision_client(&opts).expect("provision"); let cfg = ClientConfigFile::load(&report.client_config).expect("parse"); assert!(cfg.transport.knock.enabled); assert!(cfg.transport.cover.enabled); let _ = std::fs::remove_dir_all(&root); } /// v3.2: `--circuit-hops N` issues N independent client certs, each with its own UUID v4 CN. /// The bundled `client.toml` gains a `[client.circuit]` section with N `[[client.circuit.hops]]` /// tables. Each hop's `cert_path` / `key_path` references the freshly-issued PEM file in the /// bundle, and each cert's CN is a distinct UUID v4. #[test] fn provision_client_with_v3_2_circuit_hops() { let root = temp_dir("v32hops"); let ca_dir = root.join("ca"); bootstrap_ca(&ca_dir); let bundle = root.join("bundle"); let mut opts = ProvisionClientOpts::new( &ca_dir, "203.0.113.10", "vpn.example.com", "10.7.0.7", &bundle, ); opts.circuit_hops = Some(3); // entry + middle + exit let report = init::provision_client(&opts).expect("provision"); // Three distinct per-hop certs were issued, all with unique UUID-v4 CNs. assert_eq!(report.circuit_hop_certs.len(), 3, "3 hop certs issued"); let mut cns: Vec = report .circuit_hop_certs .iter() .map(|(cn, _, _)| cn.clone()) .collect(); cns.sort(); cns.dedup(); assert_eq!(cns.len(), 3, "all hop CNs are distinct"); for (cn, _, _) in &report.circuit_hop_certs { let parsed = uuid::Uuid::parse_str(cn).expect("hop cn is a uuid"); assert_eq!(parsed.get_version_num(), 4, "hop cn is uuid v4"); } for (i, (_, cert, key)) in report.circuit_hop_certs.iter().enumerate() { assert!(cert.exists(), "hop {i} cert exists"); assert!(key.exists(), "hop {i} key exists"); assert!(cert .file_name() .unwrap() .to_string_lossy() .contains(&format!("circuit-hop-{i}"))); } // The bundled client.toml has `[client.circuit] enabled = true` and 3 hop tables. let cfg = ClientConfigFile::load(&report.client_config).expect("parse client.toml"); assert!(cfg.client.circuit.enabled, "[client.circuit] enabled"); assert_eq!(cfg.client.circuit.hops.len(), 3, "3 hops in client.toml"); // Every hop entry is the Full variant (per-hop cert/key paths). use aura_cli::config::CircuitHop; for (i, hop) in cfg.client.circuit.hops.iter().enumerate() { match hop { CircuitHop::Full { cert_path, key_path, .. } => { let cert_str = cert_path.to_string_lossy(); let key_str = key_path.to_string_lossy(); assert!( cert_str.contains(&format!("circuit-hop-{i}")), "hop {i} cert_path references circuit-hop-{i}.crt; got {cert_str}" ); assert!( key_str.contains(&format!("circuit-hop-{i}")), "hop {i} key_path references circuit-hop-{i}.key; got {key_str}" ); } _ => panic!("hop {i}: expected Full variant in rendered client.toml"), } } // Cell padding is enabled by default in the v3.2 rendered config. assert!( cfg.client.circuit.cell_padding, "cell_padding defaults true in v3.2 render" ); let _ = std::fs::remove_dir_all(&root); } /// `--circuit-hops 1` is rejected (N must be >= 2). #[test] fn provision_client_circuit_hops_too_few_errors() { let root = temp_dir("v32hops_few"); let ca_dir = root.join("ca"); bootstrap_ca(&ca_dir); let bundle = root.join("bundle"); let mut opts = ProvisionClientOpts::new( &ca_dir, "203.0.113.10", "vpn.example.com", "10.7.0.8", &bundle, ); opts.circuit_hops = Some(1); let err = init::provision_client(&opts).unwrap_err().to_string(); assert!(err.contains("circuit-hops"), "got: {err}"); let _ = std::fs::remove_dir_all(&root); } /// A non-empty bundle directory triggers an error without `--force`. #[test] fn provision_client_refuses_non_empty_bundle() { let root = temp_dir("nonempty"); let ca_dir = root.join("ca"); bootstrap_ca(&ca_dir); let bundle = root.join("bundle"); std::fs::create_dir_all(&bundle).unwrap(); std::fs::write(bundle.join("junk.txt"), b"hi").unwrap(); let opts = ProvisionClientOpts::new( &ca_dir, "203.0.113.10", "vpn.example.com", "10.7.0.6", &bundle, ); let err = init::provision_client(&opts).unwrap_err().to_string(); assert!(err.contains("not empty"), "got: {err}"); let _ = std::fs::remove_dir_all(&root); }