//! Integration test: HTTP API endpoints gated behind auth. //! //! Spins up a full actor runtime with GatewayActor, starts the HTTP API server, //! and uses ureq to prove that authorized requests succeed while unauthorized //! ones get 403 and missing-auth requests get 401. use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::Ordering; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use distribution::crypto::Keypair; use shared_types::ContentHash; use swactor::config::RuntimeConfig; use swactor::runtime::Runtime; use swactor_datastore::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor}; use swactor_datastore::api::start_api_server; use swactor_datastore::auth::{ sign_request, AccessControlList, AuthzEngine, DatastoreAction, SignedRequestPayload, }; use swactor_datastore::storage::InMemoryBackend; use swactor_datastore::types::DatastoreConfig; fn now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() } fn random_nonce() -> [u8; 16] { let t = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let mut nonce = [0u8; 16]; nonce.copy_from_slice(&t.to_le_bytes()); nonce } fn sign_header(keypair: &Keypair, action: DatastoreAction) -> String { let payload = SignedRequestPayload { action, timestamp: now_secs(), nonce: random_nonce(), }; let request = sign_request(keypair, payload); serde_json::to_string(&request).unwrap() } /// Find an available TCP port by binding to :0. fn available_port() -> u16 { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.local_addr().unwrap().port() } // ═══════════════════════════════════════════════════════════════════════════ // Scenario: Owner operates over HTTP; stranger is denied // ═══════════════════════════════════════════════════════════════════════════ #[test] fn http_auth_owner_allowed_stranger_denied() { let owner_kp = Keypair::generate(); let stranger_kp = Keypair::generate(); let owner_id = owner_kp.node_id(); // ── Build runtime & actors ─────────────────────────────────────────── let rt = Runtime::new(RuntimeConfig { num_threads: 2, max_actors: 256, channel_buffer_size: 1024, ..Default::default() }); let blob_store_addr = rt .spawn(BlobStoreActor::new(Box::new(InMemoryBackend::new()))) .unwrap(); let mut metadata = MetadataActor::new(owner_id, &DatastoreConfig::default()); metadata.set_blob_store(blob_store_addr); let metadata_addr = rt.spawn(metadata).unwrap(); let config = DatastoreConfig { chunk_size: 1_048_576, ..Default::default() }; let datastore_node = DatastoreNode::new(owner_id, blob_store_addr, metadata_addr, config); let datastore_addr = rt.spawn(datastore_node).unwrap(); let acl = AccessControlList { owner: owner_id, authorized_keys: HashSet::new(), key_labels: HashMap::new(), }; let engine = AuthzEngine::new(acl); let gateway_addr = rt .spawn(GatewayActor::new(engine, datastore_addr, None)) .unwrap(); let handle = rt.run().expect("failed to start runtime"); // ── Start HTTP server ──────────────────────────────────────────────── let port = available_port(); let metrics = std::sync::Arc::new(swactor_datastore::metrics::DatastoreMetrics::new()); let (shutdown, _peers) = start_api_server( handle.runtime.clone(), datastore_addr, metadata_addr, blob_store_addr, Some(gateway_addr), port, metrics, ); // Give the HTTP server threads a moment to start accepting connections. std::thread::sleep(Duration::from_millis(100)); let base = format!("http://127.0.0.1:{port}"); // ── 1. Owner PUTs data ─────────────────────────────────────────────── let test_data = b"hello from the integration test"; let expected_hash = ContentHash::of(test_data); let put_header = sign_header( &owner_kp, DatastoreAction::Put { name: Some("test.txt".to_string()), content_hash: expected_hash, size_bytes: test_data.len() as u64, tags: BTreeMap::new(), }, ); let put_resp = ureq::post(&format!("{base}/api/put?name=test.txt")) .set("X-Signed-Request", &put_header) .send_bytes(test_data) .expect("PUT request failed"); assert_eq!(put_resp.status(), 200); let put_body: serde_json::Value = put_resp.into_json().unwrap(); let returned_hash = put_body["content_hash"].as_str().unwrap(); assert_eq!(returned_hash, expected_hash.to_hex()); // ── 2. Owner GETs it back ──────────────────────────────────────────── let get_header = sign_header( &owner_kp, DatastoreAction::Get { content_hash: expected_hash, }, ); let get_resp = ureq::get(&format!("{base}/api/get?hash={}", expected_hash.to_hex())) .set("X-Signed-Request", &get_header) .call() .expect("GET request failed"); assert_eq!(get_resp.status(), 200); let get_body: serde_json::Value = get_resp.into_json().unwrap(); assert_eq!( get_body["entry"]["content_hash"].as_str().unwrap(), expected_hash.to_hex() ); // ── 3. Owner LISTs ────────────────────────────────────────────────── let list_header = sign_header( &owner_kp, DatastoreAction::List { name_filter: None }, ); let list_resp = ureq::get(&format!("{base}/api/list")) .set("X-Signed-Request", &list_header) .call() .expect("LIST request failed"); assert_eq!(list_resp.status(), 200); let list_body: serde_json::Value = list_resp.into_json().unwrap(); let entries = list_body["entries"].as_array().unwrap(); assert!( entries .iter() .any(|e| e["content_hash"].as_str() == Some(&expected_hash.to_hex())), "expected hash in list results" ); // ── 4. Stranger tries GET → 403 ───────────────────────────────────── let stranger_header = sign_header( &stranger_kp, DatastoreAction::Get { content_hash: expected_hash, }, ); let stranger_resp = ureq::get(&format!( "{base}/api/get?hash={}", expected_hash.to_hex() )) .set("X-Signed-Request", &stranger_header) .call(); match stranger_resp { Err(ureq::Error::Status(403, _)) => {} // expected Err(e) => panic!("expected 403, got error: {e}"), Ok(r) => panic!("expected 403, got {}", r.status()), } // ── 5. No auth header → 401 ───────────────────────────────────────── let no_auth_resp = ureq::get(&format!( "{base}/api/get?hash={}", expected_hash.to_hex() )) .call(); match no_auth_resp { Err(ureq::Error::Status(401, _)) => {} // expected Err(e) => panic!("expected 401, got error: {e}"), Ok(r) => panic!("expected 401, got {}", r.status()), } // ── 6. Owner DELETEs ───────────────────────────────────────────────── let delete_header = sign_header( &owner_kp, DatastoreAction::Delete { content_hash: expected_hash, }, ); let delete_resp = ureq::post(&format!( "{base}/api/delete?hash={}", expected_hash.to_hex() )) .set("X-Signed-Request", &delete_header) .send_bytes(&[]) .expect("DELETE request failed"); assert_eq!(delete_resp.status(), 200); // ── Teardown ───────────────────────────────────────────────────────── shutdown.store(true, Ordering::Relaxed); handle.shutdown(); handle.join(); }