use api::{ AppState, AuditStore, AuthConfig, ConfigLocks, ConfigRegistry, JobStore, PlacementStore, SwarmStore, TenantLocks, }; use axum::{ Router, body::Body, http::{Request, StatusCode, header}, }; use jsonwebtoken::{EncodingKey, Header, encode}; use metrics_exporter_prometheus::PrometheusBuilder; use serde::Serialize; use std::{fs, path::PathBuf, sync::OnceLock}; use tower::ServiceExt; 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() } #[derive(Serialize)] struct TestClaims { sub: String, session_id: String, permissions: Vec, exp: usize, } fn make_token(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(b"test_secret"), ) .unwrap() } fn temp_swarm_file(raw: &str) -> PathBuf { let mut dst = std::env::temp_dir(); dst.push(format!( "cloudlysis-control-swarm-{}-{}.json", std::process::id(), uuid::Uuid::new_v4() )); fs::write(&dst, raw).expect("failed to write temp swarm file"); dst } fn test_app_with_swarm(swarm_path: PathBuf) -> Router { let handle = HANDLE .get_or_init(|| { PrometheusBuilder::new() .install_recorder() .expect("failed to install prometheus recorder") }) .clone(); api::build_app(AppState { prometheus: handle, auth: AuthConfig { hs256_secret: Some(b"test_secret".to_vec()), }, 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-drift-test.json"), ), billing_provider: std::sync::Arc::new(api::billing::MockProvider), billing_enforcement_enabled: false, config: ConfigRegistry::new(None, None), fleet_services: vec![], swarm: SwarmStore::new(swarm_path), docs: None, }) } #[tokio::test] async fn drift_marks_extra_services_vs_desired_observation_set() { let swarm = temp_swarm_file( r#"{ "services": [{"name":"extra-1","image":null,"mode":null,"replicas":null,"updated_at":null}], "tasks": [] }"#, ); let app = test_app_with_swarm(swarm); let token = make_token(&["control:read"]); let res = app .oneshot( Request::builder() .uri("/admin/v1/platform/drift") .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 v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let items = v.get("items").and_then(|x| x.as_array()).unwrap(); assert!(items.iter().any(|i| { i.get("kind").and_then(|k| k.as_str()) == Some("extra") && i.get("service").and_then(|s| s.as_str()) == Some("extra-1") })); }