use api::{ AppState, AuditStore, AuthConfig, ConfigLocks, ConfigRegistry, JobStore, PlacementStore, SwarmStore, TenantLocks, config_registry::NatsKvSource, }; use axum::{ Router, body::Body, http::{Request, StatusCode, header}, }; use jsonwebtoken::{EncodingKey, Header, encode}; use metrics_exporter_prometheus::PrometheusBuilder; use serde::Serialize; use std::{path::PathBuf, sync::OnceLock, time::Duration}; use tower::ServiceExt; use uuid::Uuid; fn enabled() -> bool { std::env::var("CONTROL_TEST_NATS").ok().as_deref() == Some("1") && std::env::var("CONTROL_TEST_NATS_URL").is_ok() } #[derive(Serialize)] struct TestClaims { sub: String, session_id: String, permissions: Vec, exp: usize, } fn make_token(secret: &[u8], perms: &[&str]) -> String { let exp = (std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() + 60) as usize; encode( &Header::default(), &TestClaims { sub: "user_1".to_string(), session_id: "sess_1".to_string(), permissions: perms.iter().map(|p| (*p).to_string()).collect(), exp, }, &EncodingKey::from_secret(secret), ) .unwrap() } static HANDLE: OnceLock = OnceLock::new(); fn repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .and_then(|p| p.parent()) .expect("api crate should live under repo root") .to_path_buf() } async fn wait_done(app: Router, job_id: Uuid, token: &str) -> serde_json::Value { let start = tokio::time::Instant::now(); loop { let res = app .clone() .oneshot( Request::builder() .uri(format!("/admin/v1/jobs/{job_id}")) .header(header::AUTHORIZATION, format!("Bearer {token}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let body = axum::body::to_bytes(res.into_body(), 1024 * 1024) .await .unwrap(); let job: serde_json::Value = serde_json::from_slice(&body).unwrap(); let status = job .get("status") .and_then(|v| v.as_str()) .unwrap_or("unknown"); if status != "pending" && status != "running" { return job; } if start.elapsed() > Duration::from_secs(2) { return job; } tokio::time::sleep(Duration::from_millis(25)).await; } } #[tokio::test] async fn config_jobs_with_nats_kv_are_env_gated() { if !enabled() { eprintln!( "skipping: set CONTROL_TEST_NATS=1 and CONTROL_TEST_NATS_URL=nats://... to enable nats config tests" ); return; } let nats_url = std::env::var("CONTROL_TEST_NATS_URL").unwrap(); unsafe { std::env::set_var("CONTROL_CONFIG_NATS_URL", &nats_url); } let bucket = format!("cloudlysis-test-config-{}", Uuid::new_v4()); let routing_key = format!("routing/{}", Uuid::new_v4()); let placement_key = format!("placement/{}", Uuid::new_v4()); let routing_src = NatsKvSource::connect(nats_url.clone(), bucket.clone(), routing_key) .await .expect("connect routing kv"); let placement_src = NatsKvSource::connect(nats_url.clone(), bucket.clone(), placement_key) .await .expect("connect placement kv"); let config = ConfigRegistry::new( Some(std::sync::Arc::new(routing_src)), Some(std::sync::Arc::new(placement_src)), ); let secret = b"test_secret".to_vec(); let token = make_token(&secret, &["control:write", "control:read"]); let handle = HANDLE .get_or_init(|| { PrometheusBuilder::new() .install_recorder() .expect("failed to install prometheus recorder") }) .clone(); let app = api::build_app(AppState { prometheus: handle, auth: AuthConfig { hs256_secret: Some(secret), }, jobs: JobStore::default(), audit: AuditStore::default(), tenant_locks: TenantLocks::default(), config_locks: ConfigLocks::default(), http: reqwest::Client::new(), placement: PlacementStore::new(repo_root().join("config/placement/dev.json")), billing: api::billing::BillingStore::new(std::env::temp_dir().join("billing-test.json")), billing_provider: std::sync::Arc::new(api::billing::MockProvider), billing_enforcement_enabled: false, config, fleet_services: vec![], swarm: SwarmStore::new(repo_root().join("swarm/dev.json")), docs: None, }); let routing_value = serde_json::json!({ "revision": 1, "aggregate_placement": { "t1": "local" }, "projection_placement": { "t1": "local" }, "runner_placement": { "t1": "local" }, "aggregate_shards": { "local": ["http://aggregate:50051"] }, "projection_shards": { "local": ["http://projection:8080"] }, "runner_shards": { "local": ["http://runner:8080"] } }); let apply = app .clone() .oneshot( Request::builder() .uri("/admin/v1/jobs/config/apply") .method("POST") .header(header::AUTHORIZATION, format!("Bearer {token}")) .header("idempotency-key", format!("k-{}", Uuid::new_v4())) .header(header::CONTENT_TYPE, "application/json") .body(Body::from( serde_json::json!({ "domain": "routing", "expected_revision": null, "reason": "test apply", "value": routing_value }) .to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(apply.status(), StatusCode::OK); let body = axum::body::to_bytes(apply.into_body(), 1024 * 1024) .await .unwrap(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let job_id = Uuid::parse_str(v.get("job_id").unwrap().as_str().unwrap()).unwrap(); let job = wait_done(app.clone(), job_id, &token).await; assert_eq!( job.get("status").and_then(|v| v.as_str()), Some("succeeded") ); let get = app .clone() .oneshot( Request::builder() .uri("/admin/v1/config/routing") .header(header::AUTHORIZATION, format!("Bearer {token}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(get.status(), StatusCode::OK); let body = axum::body::to_bytes(get.into_body(), 1024 * 1024) .await .unwrap(); let got: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(got.get("domain").unwrap().as_str().unwrap(), "routing"); assert!(got.get("revision").unwrap().as_u64().unwrap_or(0) > 0); let rollback = app .clone() .oneshot( Request::builder() .uri("/admin/v1/jobs/config/rollback") .method("POST") .header(header::AUTHORIZATION, format!("Bearer {token}")) .header("idempotency-key", format!("k-{}", Uuid::new_v4())) .header(header::CONTENT_TYPE, "application/json") .body(Body::from( serde_json::json!({ "domain": "routing", "reason": "test rollback" }) .to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(rollback.status(), StatusCode::OK); let body = axum::body::to_bytes(rollback.into_body(), 1024 * 1024) .await .unwrap(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let rb_id = Uuid::parse_str(v.get("job_id").unwrap().as_str().unwrap()).unwrap(); let rb_job = wait_done(app.clone(), rb_id, &token).await; assert_eq!( rb_job.get("status").and_then(|v| v.as_str()), Some("succeeded") ); }