use jsonwebtoken::{EncodingKey, Header, encode}; use reqwest::header::{HeaderMap, HeaderValue}; use serde::Serialize; use std::{path::PathBuf, process::Command, time::Duration}; use uuid::Uuid; 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() } fn docker_enabled() -> bool { std::env::var("CONTROL_TEST_DOCKER") .ok() .is_some_and(|v| v.trim() == "1") } fn compose_file() -> PathBuf { repo_root().join("docker-compose.yml") } #[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() + 300) 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() } #[tokio::test] async fn documents_upload_list_download_roundtrip_via_control_api_compose() { if !docker_enabled() { eprintln!("skipping: set CONTROL_TEST_DOCKER=1 to enable docker compose tests"); return; } // Must match docker-compose.yml CONTROL_GATEWAY_JWT_HS256_SECRET. let jwt_secret = b"dev_secret"; let token = make_token(jwt_secret, &["control:read", "control:write"]); let compose = compose_file(); let up = Command::new("docker") .args(["compose", "-f"]) .arg(&compose) .args(["up", "-d", "control-api"]) .status() .expect("failed to run docker compose up control-api"); assert!(up.success(), "docker compose up control-api failed"); // Wait for control-api to be reachable (port publish is in compose). let http = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() .unwrap(); let base = "http://127.0.0.1:38080"; let health_deadline = tokio::time::Instant::now() + Duration::from_secs(30); loop { if tokio::time::Instant::now() > health_deadline { panic!("control-api did not become healthy in time"); } match http.get(format!("{base}/health")).send().await { Ok(res) if res.status().is_success() => break, _ => tokio::time::sleep(Duration::from_millis(250)).await, } } let tenant_id = Uuid::new_v4().to_string(); let doc_type = "deployments"; let doc_id = Uuid::new_v4().to_string(); let filename = "hello.txt"; let bytes = b"hello-docs".to_vec(); let mut headers = HeaderMap::new(); headers.insert( "authorization", HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), ); headers.insert("x-tenant-id", HeaderValue::from_str(&tenant_id).unwrap()); // Upload (proxy endpoint). let put_url = format!("{base}/admin/v1/tenants/{tenant_id}/docs/{doc_type}/{doc_id}/{filename}"); let put = http .put(&put_url) .headers(headers.clone()) .header("content-type", "text/plain") .body(bytes.clone()) .send() .await .expect("upload request failed"); assert!( put.status().is_success(), "upload failed: {}", put.text().await.unwrap_or_default() ); let put_json: serde_json::Value = put.json().await.expect("invalid upload json"); let key = put_json .get("key") .and_then(|v| v.as_str()) .expect("missing key") .to_string(); // List should include the key. let list_url = format!("{base}/admin/v1/tenants/{tenant_id}/docs?prefix={doc_type}/"); let list = http .get(&list_url) .headers(headers.clone()) .send() .await .expect("list request failed"); assert!(list.status().is_success(), "list failed"); let list_json: serde_json::Value = list.json().await.expect("invalid list json"); let objects = list_json .get("objects") .and_then(|v| v.as_array()) .expect("missing objects"); assert!( objects .iter() .any(|o| o.get("key").and_then(|k| k.as_str()) == Some(key.as_str())), "expected list to include uploaded key" ); // Download (proxy endpoint) returns same bytes. let get_url = format!( "{base}/admin/v1/tenants/{tenant_id}/docs/object/{}", urlencoding::encode(&key) ); let got = http .get(&get_url) .headers(headers.clone()) .send() .await .expect("download request failed"); assert!(got.status().is_success(), "download failed"); let got_bytes = got.bytes().await.expect("download bytes failed").to_vec(); assert_eq!(got_bytes, bytes); // Best-effort cleanup. let _ = Command::new("docker") .args(["compose", "-f"]) .arg(&compose) .args(["down", "-v"]) .status(); }