use api::{ AppState, AuditStore, AuthConfig, ConfigLocks, JobStore, PlacementStore, SwarmStore, TenantLocks, billing::BillingStore, config_registry::ConfigRegistry, }; 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::{Arc, OnceLock}, }; use tower::ServiceExt; use uuid::Uuid; fn prod_enabled() -> bool { std::env::var("CONTROL_TEST_BILLING_PROD").ok().as_deref() == Some("1") } 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(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() } fn test_app() -> Router { let handle = HANDLE .get_or_init(|| { PrometheusBuilder::new() .install_recorder() .expect("failed to install prometheus recorder") }) .clone(); let provider_type = std::env::var("CONTROL_BILLING_PROVIDER").unwrap_or_else(|_| "mock".to_string()); let billing_provider: Arc = match provider_type.as_str() { "stripe" => Arc::new(api::billing::StripeProvider { secret_key: std::env::var("CONTROL_STRIPE_SECRET_KEY").unwrap_or_default(), price_pro: std::env::var("CONTROL_STRIPE_PRICE_ID_PRO").unwrap_or_default(), price_enterprise: std::env::var("CONTROL_STRIPE_PRICE_ID_ENTERPRISE") .unwrap_or_default(), }), _ => Arc::new(api::billing::MockProvider), }; 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: BillingStore::new(std::env::temp_dir().join("billing-prod-smoke.json")), billing_provider, billing_enforcement_enabled: true, config: ConfigRegistry::new(None, None), fleet_services: vec![], swarm: SwarmStore::new(repo_root().join("swarm/dev.json")), docs: None, }) } #[tokio::test] async fn billing_production_smoke_test() { if !prod_enabled() { eprintln!("skipping: set CONTROL_TEST_BILLING_PROD=1 to enable production smoke tests"); return; } let app = test_app(); let token = make_token(b"test_secret", &["control:read", "control:write"]); let tenant_id = Uuid::new_v4(); // 1. Verify GET billing works (empty initially) let res = app .clone() .oneshot( Request::builder() .uri(format!("/admin/v1/tenants/{tenant_id}/billing")) .header(header::AUTHORIZATION, format!("Bearer {token}")) .header("x-tenant-id", tenant_id.to_string()) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); // 2. Verify Checkout session generation let res = app .clone() .oneshot( Request::builder() .uri(format!("/admin/v1/tenants/{tenant_id}/billing/checkout")) .method("POST") .header(header::AUTHORIZATION, format!("Bearer {token}")) .header("x-tenant-id", tenant_id.to_string()) .header(header::CONTENT_TYPE, "application/json") .body(Body::from( serde_json::json!({ "plan": "pro", "return_path": "/billing" }) .to_string(), )) .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(); assert!(v.get("url").and_then(|u| u.as_str()).is_some()); // 3. Verify Portal session generation (may fail if tenant has no stripe customer id yet, which is expected for fresh tenant) let res = app .clone() .oneshot( Request::builder() .uri(format!("/admin/v1/tenants/{tenant_id}/billing/portal")) .method("POST") .header(header::AUTHORIZATION, format!("Bearer {token}")) .header("x-tenant-id", tenant_id.to_string()) .body(Body::empty()) .unwrap(), ) .await .unwrap(); // For smoke test, we just want to see it reached the provider and didn't crash assert!(res.status() == StatusCode::OK || res.status() == StatusCode::INTERNAL_SERVER_ERROR); }