//! HTTP client for pushing observations to a hosted StemeDB server. //! //! In hosted mode, all observations are automatically synced to a central //! team server, enabling pattern aggregation across projects. use std::time::Duration; use ed25519_dalek::SigningKey; use serde::{Deserialize, Serialize}; use stemedb_core::types::Assertion; use tracing::{info, instrument, warn}; use crate::community::{CommunityExtractor, SharedPattern}; use crate::config::{CommunityConfig, HostedConfig, OfflineFallback}; use crate::AphoriaError; /// HTTP client for pushing observations to a hosted StemeDB server. pub struct HostedClient { /// Base URL of the server (e.g., "https://episteme.acme.corp"). base_url: String, /// Project identifier. project_id: String, /// Optional team identifier. team_id: Option, /// Agent's public key (hex-encoded). agent_id: String, /// Optional API key for authentication. api_key: Option, /// Maximum retry attempts. max_retries: u32, /// Delay between retries in milliseconds. retry_delay_ms: u64, /// Behavior when server is unreachable. offline_fallback: OfflineFallback, /// Whether to route observations to community endpoint for pattern aggregation. /// When true, observations go to /v1/aphoria/community/observations. /// When false, observations go to /v1/aphoria/observations. community_enabled: bool, } /// Request payload for pushing observations (team storage). #[derive(Debug, Clone, Serialize)] pub struct PushObservationsRequest { /// The observations to push. pub observations: Vec, /// Project identifier. pub project_id: String, /// Optional team identifier. #[serde(skip_serializing_if = "Option::is_none")] pub team_id: Option, /// Client version for debugging. pub client_version: String, } /// Request payload for pushing community observations (corpus aggregation). #[derive(Debug, Clone, Serialize)] pub struct PushCommunityObservationsRequest { /// The anonymized observations to share. pub observations: Vec, /// Hash of the project (for deduplication, NOT the actual project name). /// This is BLAKE3 hash of the project name to prevent name leakage. pub project_hash: String, /// Client version for debugging. pub client_version: String, } /// Community observation response. #[derive(Debug, Clone, Deserialize)] pub struct PushCommunityObservationsResponse { /// Number of observations recorded. pub recorded: usize, /// Number of new patterns discovered. pub new_patterns: usize, /// Number of existing patterns updated. pub updated_patterns: usize, } /// A single observation in the request (team storage). #[derive(Debug, Clone, Serialize)] pub struct ObservationDto { /// The subject (concept path). pub subject: String, /// The predicate being claimed. pub predicate: String, /// The object value. pub object: ObjectValueDto, /// Confidence score (0.0 to 1.0). pub confidence: f32, /// Source hash (hex-encoded). pub source_hash: String, /// Signatures (hex-encoded). pub signatures: Vec, /// Timestamp of the observation. pub timestamp: u64, /// Source metadata as JSON string. #[serde(skip_serializing_if = "Option::is_none")] pub source_metadata: Option, } /// A single anonymized community observation (corpus aggregation). #[derive(Debug, Clone, Serialize)] pub struct CommunityObservationDto { /// Wildcarded subject path (e.g., `code://rust/*/tls/cert_verification`). pub subject: String, /// The predicate (e.g., "enabled", "min_version"). pub predicate: String, /// The extracted value. pub object: CommunityValueDto, /// Confidence of extraction (0.0 to 1.0). pub confidence: f32, /// Hash of (subject, predicate, value) ONLY - for deduplication. /// CRITICAL: Must NOT include file, line, or matched_text. pub anon_hash: String, /// Timestamp rounded to the nearest hour (for k-anonymity). pub timestamp_hour: u64, } /// Object value in the DTO (team storage). #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", content = "value")] pub enum ObjectValueDto { /// Textual value Text(String), /// Numeric value Number(f64), /// Boolean value Boolean(bool), /// Entity reference Reference(String), } /// Community value DTO (anonymized, simpler than ObjectValueDto). #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", content = "value")] pub enum CommunityValueDto { /// Boolean value Boolean(bool), /// Numeric value Number(f64), /// Textual value Text(String), } /// Signature entry in the DTO. #[derive(Debug, Clone, Serialize)] pub struct SignatureDto { /// Agent's public key (hex-encoded). pub agent_id: String, /// Signature bytes (hex-encoded). pub signature: String, /// Timestamp of signature. pub timestamp: u64, /// Signature version. pub version: u8, } /// Response from pushing observations. #[derive(Debug, Clone, Deserialize)] pub struct PushObservationsResponse { /// Number of observations accepted. pub accepted: usize, /// Number of observations deduplicated (already existed). pub deduplicated: usize, /// Hashes of created assertions (hex-encoded). /// Note: This field is included in the server response but not currently used by the client. #[allow(dead_code)] pub hashes: Vec, } // ============================================================================ // Cross-Project Learning Types (reserved for future use) // ============================================================================ /// Request payload for pushing learned patterns. #[allow(dead_code)] #[derive(Debug, Clone, Serialize)] pub struct PushPatternsRequest { /// BLAKE3 hash of the organization identifier. /// /// Privacy: Only the hash is sent, not the actual org name. pub org_hash: String, /// The patterns to push. pub patterns: Vec, /// Client version for debugging and compatibility. pub client_version: String, } /// Response from pushing patterns. #[allow(dead_code)] #[derive(Debug, Clone, Default, Deserialize)] pub struct PushPatternsResponse { /// Number of patterns accepted as new. pub accepted: usize, /// Number of patterns merged with existing. pub merged: usize, /// Number of patterns that were duplicates. pub deduplicated: usize, } /// Query parameters for getting community extractors. #[allow(dead_code)] #[derive(Debug, Clone, Serialize)] pub struct GetCommunityExtractorsQuery { /// Only return extractors promoted after this timestamp. #[serde(skip_serializing_if = "Option::is_none")] pub since: Option, /// Minimum project count threshold. pub min_projects: u64, } impl HostedClient { /// Create a new hosted client if hosted mode is configured. /// /// Returns `None` if hosted mode is not enabled (no URL configured). /// Returns `Err` if configuration is invalid. pub fn new( config: &HostedConfig, community_config: &CommunityConfig, signing_key: &SigningKey, fallback_project_name: &str, ) -> Result, AphoriaError> { let base_url = match &config.url { Some(url) => url.trim_end_matches('/').to_string(), None => return Ok(None), }; // Get project ID (from config or fallback to project name) let project_id = config.project_id.clone().unwrap_or_else(|| fallback_project_name.to_string()); // Get agent ID from signing key let agent_id = hex::encode(signing_key.verifying_key().to_bytes()); // Try to get API key from environment let api_key = if !config.api_key_env.is_empty() { std::env::var(&config.api_key_env).ok() } else { None }; Ok(Some(Self { base_url, project_id, team_id: config.team_id.clone(), agent_id, api_key, max_retries: config.max_retries, retry_delay_ms: config.retry_delay_ms, offline_fallback: config.offline_fallback, community_enabled: community_config.is_enabled(), })) } /// Push observations to the remote server. /// /// Returns the number of observations successfully pushed. #[instrument(skip(self, observations), fields(count = observations.len(), project = %self.project_id))] pub fn push_observations(&self, observations: Vec) -> Result { if observations.is_empty() { return Ok(0); } if self.community_enabled { // Community mode: anonymize and send to corpus aggregation endpoint self.push_community(observations) } else { // Team mode: send full observations to team storage endpoint self.push_team(observations) } } /// Push observations to team storage endpoint (full provenance). fn push_team(&self, observations: Vec) -> Result { // Convert assertions to DTOs let observation_dtos: Vec = observations.iter().map(assertion_to_dto).collect(); let request = PushObservationsRequest { observations: observation_dtos, project_id: self.project_id.clone(), team_id: self.team_id.clone(), client_version: env!("CARGO_PKG_VERSION").to_string(), }; let url = format!("{}/v1/aphoria/observations", self.base_url); // Retry loop let mut last_error = None; for attempt in 0..=self.max_retries { if attempt > 0 { info!(attempt, "Retrying push to team server"); std::thread::sleep(Duration::from_millis(self.retry_delay_ms)); } match self.do_push_team(&url, &request) { Ok(response) => { info!( accepted = response.accepted, deduplicated = response.deduplicated, "Pushed observations to team server" ); return Ok(response.accepted); } Err(e) => { warn!(attempt, error = %e, "Failed to push to team server"); last_error = Some(e); } } } self.handle_push_error(last_error) } /// Push observations to community corpus endpoint (anonymized). fn push_community(&self, observations: Vec) -> Result { // Convert assertions to anonymized community DTOs let community_dtos: Vec = observations.iter().map(|a| assertion_to_community_dto(a, &self.project_id)).collect(); // Compute project hash for privacy let project_hash = { let mut hasher = blake3::Hasher::new(); hasher.update(self.project_id.as_bytes()); hex::encode(hasher.finalize().as_bytes()) }; let request = PushCommunityObservationsRequest { observations: community_dtos, project_hash, client_version: env!("CARGO_PKG_VERSION").to_string(), }; let url = format!("{}/v1/aphoria/community/observations", self.base_url); // Retry loop let mut last_error = None; for attempt in 0..=self.max_retries { if attempt > 0 { info!(attempt, "Retrying push to community corpus"); std::thread::sleep(Duration::from_millis(self.retry_delay_ms)); } match self.do_push_community(&url, &request) { Ok(response) => { info!( recorded = response.recorded, new_patterns = response.new_patterns, updated_patterns = response.updated_patterns, "Pushed observations to community corpus" ); return Ok(response.recorded); } Err(e) => { warn!(attempt, error = %e, "Failed to push to community corpus"); last_error = Some(e); } } } self.handle_push_error(last_error) } /// Handle push error based on offline fallback strategy. fn handle_push_error(&self, last_error: Option) -> Result { let error = last_error.unwrap_or_else(|| { AphoriaError::Hosted("Unknown error during hosted sync".to_string()) }); match self.offline_fallback { OfflineFallback::Skip => { warn!(error = %error, "Hosted sync failed, continuing (offline_fallback=skip)"); Ok(0) } OfflineFallback::Fail => Err(error), OfflineFallback::Queue => { warn!( error = %error, "Hosted sync failed, queue not implemented (treating as skip)" ); Ok(0) } } } /// Perform the actual HTTP POST request for team observations. fn do_push_team( &self, url: &str, request: &PushObservationsRequest, ) -> Result { let mut http_request = ureq::post(url) .set("Content-Type", "application/json") .set("X-Agent-Id", &self.agent_id); if let Some(ref api_key) = self.api_key { http_request = http_request.set("Authorization", &format!("Bearer {}", api_key)); } let body = serde_json::to_string(request) .map_err(|e| AphoriaError::Hosted(format!("Failed to serialize request: {e}")))?; let response = http_request .send_string(&body) .map_err(|e| AphoriaError::Hosted(format!("HTTP error: {e}")))?; if response.status() >= 200 && response.status() < 300 { let body = response .into_string() .map_err(|e| AphoriaError::Hosted(format!("Failed to read response: {e}")))?; serde_json::from_str(&body) .map_err(|e| AphoriaError::Hosted(format!("Failed to parse response: {e}"))) } else { Err(AphoriaError::Hosted(format!("Server returned status {}", response.status()))) } } /// Perform the actual HTTP POST request for community observations. fn do_push_community( &self, url: &str, request: &PushCommunityObservationsRequest, ) -> Result { let mut http_request = ureq::post(url) .set("Content-Type", "application/json") .set("X-Agent-Id", &self.agent_id); if let Some(ref api_key) = self.api_key { http_request = http_request.set("Authorization", &format!("Bearer {}", api_key)); } let body = serde_json::to_string(request) .map_err(|e| AphoriaError::Hosted(format!("Failed to serialize request: {e}")))?; let response = http_request .send_string(&body) .map_err(|e| AphoriaError::Hosted(format!("HTTP error: {e}")))?; if response.status() >= 200 && response.status() < 300 { let body = response .into_string() .map_err(|e| AphoriaError::Hosted(format!("Failed to read response: {e}")))?; serde_json::from_str(&body) .map_err(|e| AphoriaError::Hosted(format!("Failed to parse response: {e}"))) } else { Err(AphoriaError::Hosted(format!("Server returned status {}", response.status()))) } } // ======================================================================== // Cross-Project Learning Methods // ======================================================================== /// Compute the organization hash for pattern attribution. /// /// Uses BLAKE3 hash of (project_id, team_id) for privacy. pub fn compute_org_hash(&self) -> String { let mut hasher = blake3::Hasher::new(); hasher.update(self.project_id.as_bytes()); if let Some(ref team_id) = self.team_id { hasher.update(b":"); hasher.update(team_id.as_bytes()); } hex::encode(hasher.finalize().as_bytes()) } /// Push learned patterns to the hosted server. /// /// Patterns are anonymized before sending - only normalized patterns, /// project counts (not identifiers), and confidence scores are sent. #[instrument(skip(self, patterns), fields(count = patterns.len(), project = %self.project_id))] pub fn push_patterns( &self, patterns: Vec, ) -> Result { if patterns.is_empty() { return Ok(PushPatternsResponse::default()); } let request = PushPatternsRequest { org_hash: self.compute_org_hash(), patterns, client_version: env!("CARGO_PKG_VERSION").to_string(), }; let url = format!("{}/v1/aphoria/patterns", self.base_url); // Retry loop let mut last_error = None; for attempt in 0..=self.max_retries { if attempt > 0 { info!(attempt, "Retrying pattern push to hosted server"); std::thread::sleep(Duration::from_millis(self.retry_delay_ms)); } match self.do_push_patterns(&url, &request) { Ok(response) => { info!( accepted = response.accepted, merged = response.merged, deduplicated = response.deduplicated, "Pushed patterns to hosted server" ); return Ok(response); } Err(e) => { warn!(attempt, error = %e, "Failed to push patterns to hosted server"); last_error = Some(e); } } } // All retries failed let error = last_error.unwrap_or_else(|| { AphoriaError::Hosted("Unknown error during pattern sync".to_string()) }); match self.offline_fallback { OfflineFallback::Skip => { warn!(error = %error, "Pattern sync failed, continuing (offline_fallback=skip)"); Ok(PushPatternsResponse::default()) } OfflineFallback::Fail => Err(error), OfflineFallback::Queue => { warn!( error = %error, "Pattern sync failed, queue not implemented (treating as skip)" ); Ok(PushPatternsResponse::default()) } } } /// Perform the actual HTTP POST request for patterns. fn do_push_patterns( &self, url: &str, request: &PushPatternsRequest, ) -> Result { let mut http_request = ureq::post(url) .set("Content-Type", "application/json") .set("X-Agent-Id", &self.agent_id); if let Some(ref api_key) = self.api_key { http_request = http_request.set("Authorization", &format!("Bearer {}", api_key)); } let body = serde_json::to_string(request) .map_err(|e| AphoriaError::Hosted(format!("Failed to serialize request: {e}")))?; let response = http_request .send_string(&body) .map_err(|e| AphoriaError::Hosted(format!("HTTP error: {e}")))?; if response.status() >= 200 && response.status() < 300 { let body = response .into_string() .map_err(|e| AphoriaError::Hosted(format!("Failed to read response: {e}")))?; serde_json::from_str(&body) .map_err(|e| AphoriaError::Hosted(format!("Failed to parse response: {e}"))) } else { Err(AphoriaError::Hosted(format!("Server returned status {}", response.status()))) } } /// Get community extractors from the hosted server. /// /// Returns extractors that have been aggregated from patterns across /// many organizations and promoted to community extractors. #[instrument(skip(self), fields(project = %self.project_id))] pub fn get_community_extractors( &self, since: Option, min_projects: u64, ) -> Result, AphoriaError> { let mut url = format!("{}/v1/aphoria/community/extractors", self.base_url); // Build query string let mut params = vec![format!("min_projects={}", min_projects)]; if let Some(ts) = since { params.push(format!("since={}", ts)); } if !params.is_empty() { url = format!("{}?{}", url, params.join("&")); } // Retry loop let mut last_error = None; for attempt in 0..=self.max_retries { if attempt > 0 { info!(attempt, "Retrying community extractors fetch"); std::thread::sleep(Duration::from_millis(self.retry_delay_ms)); } match self.do_get_extractors(&url) { Ok(extractors) => { info!(count = extractors.len(), "Fetched community extractors"); return Ok(extractors); } Err(e) => { warn!(attempt, error = %e, "Failed to fetch community extractors"); last_error = Some(e); } } } // All retries failed let error = last_error.unwrap_or_else(|| { AphoriaError::Hosted("Unknown error during extractor fetch".to_string()) }); match self.offline_fallback { OfflineFallback::Skip => { warn!(error = %error, "Extractor fetch failed, continuing (offline_fallback=skip)"); Ok(vec![]) } OfflineFallback::Fail => Err(error), OfflineFallback::Queue => { warn!( error = %error, "Extractor fetch failed, queue not implemented (treating as skip)" ); Ok(vec![]) } } } /// Perform the actual HTTP GET request for extractors. fn do_get_extractors(&self, url: &str) -> Result, AphoriaError> { let mut http_request = ureq::get(url).set("Accept", "application/json").set("X-Agent-Id", &self.agent_id); if let Some(ref api_key) = self.api_key { http_request = http_request.set("Authorization", &format!("Bearer {}", api_key)); } let response = http_request.call().map_err(|e| AphoriaError::Hosted(format!("HTTP error: {e}")))?; if response.status() >= 200 && response.status() < 300 { let body = response .into_string() .map_err(|e| AphoriaError::Hosted(format!("Failed to read response: {e}")))?; serde_json::from_str(&body) .map_err(|e| AphoriaError::Hosted(format!("Failed to parse response: {e}"))) } else { Err(AphoriaError::Hosted(format!("Server returned status {}", response.status()))) } } /// Get the base URL for the hosted server. pub fn base_url(&self) -> &str { &self.base_url } /// Get the project ID. pub fn project_id(&self) -> &str { &self.project_id } } /// Convert an Assertion to an ObservationDto for the API. fn assertion_to_dto(assertion: &Assertion) -> ObservationDto { use stemedb_core::types::ObjectValue; let object = match &assertion.object { ObjectValue::Text(s) => ObjectValueDto::Text(s.clone()), ObjectValue::Number(n) => ObjectValueDto::Number(*n), ObjectValue::Boolean(b) => ObjectValueDto::Boolean(*b), ObjectValue::Reference(e) => ObjectValueDto::Reference(e.clone()), }; let signatures: Vec = assertion .signatures .iter() .map(|s| SignatureDto { agent_id: hex::encode(s.agent_id), signature: hex::encode(s.signature), timestamp: s.timestamp, version: s.version, }) .collect(); let source_metadata = assertion.source_metadata.as_ref().and_then(|m| String::from_utf8(m.clone()).ok()); ObservationDto { subject: assertion.subject.clone(), predicate: assertion.predicate.clone(), object, confidence: assertion.confidence, source_hash: hex::encode(assertion.source_hash), signatures, timestamp: assertion.timestamp, source_metadata, } } /// Convert an Assertion to a CommunityObservationDto (anonymized for corpus). fn assertion_to_community_dto(assertion: &Assertion, project_id: &str) -> CommunityObservationDto { use stemedb_core::types::ObjectValue; // Wildcardize the subject path: code://rust/{project}/module/concept -> code://rust/*/module/concept let subject = wildcardize_subject(&assertion.subject, project_id); // Convert to community value DTO (simpler, no Reference type) let object = match &assertion.object { ObjectValue::Boolean(b) => CommunityValueDto::Boolean(*b), ObjectValue::Number(n) => CommunityValueDto::Number(*n), ObjectValue::Text(s) => CommunityValueDto::Text(s.clone()), ObjectValue::Reference(e) => CommunityValueDto::Text(e.clone()), // Convert reference to text }; // Compute anon_hash: BLAKE3(subject + predicate + value) let anon_hash = { let mut hasher = blake3::Hasher::new(); hasher.update(subject.as_bytes()); hasher.update(b":"); hasher.update(assertion.predicate.as_bytes()); hasher.update(b":"); match &assertion.object { ObjectValue::Boolean(b) => hasher.update(b.to_string().as_bytes()), ObjectValue::Number(n) => hasher.update(n.to_string().as_bytes()), ObjectValue::Text(s) | ObjectValue::Reference(s) => hasher.update(s.as_bytes()), }; hex::encode(hasher.finalize().as_bytes()) }; // Round timestamp to nearest hour (for k-anonymity) let timestamp_hour = (assertion.timestamp / 3600) * 3600; CommunityObservationDto { subject, predicate: assertion.predicate.clone(), object, confidence: assertion.confidence, anon_hash, timestamp_hour, } } /// Wildcardize subject path to anonymize project-specific information. /// /// Examples: /// - `code://rust/maxwell/core/tls` -> `code://rust/*/core/tls` /// - `code://go/myproject/auth/oauth` -> `code://go/*/auth/oauth` fn wildcardize_subject(subject: &str, project_id: &str) -> String { // Simple replacement: replace project_id with wildcard subject.replace(project_id, "*") } #[cfg(test)] mod tests { use super::*; use crate::bridge::generate_signing_key; use crate::config::SyncMode; #[test] fn test_client_not_created_without_url() { let config = HostedConfig::default(); let community_config = CommunityConfig::default(); let key = generate_signing_key(); let client = HostedClient::new(&config, &community_config, &key, "test-project") .expect("should not fail"); assert!(client.is_none()); } #[test] fn test_client_created_with_url() { let config = HostedConfig { url: Some("https://episteme.acme.corp".to_string()), project_id: Some("my-project".to_string()), team_id: Some("platform".to_string()), sync_mode: SyncMode::RemoteOnly, offline_fallback: OfflineFallback::Skip, max_retries: 3, retry_delay_ms: 1000, api_key_env: String::new(), }; let community_config = CommunityConfig::default(); let key = generate_signing_key(); let client = HostedClient::new(&config, &community_config, &key, "fallback-project") .expect("should not fail") .unwrap(); assert_eq!(client.base_url, "https://episteme.acme.corp"); assert_eq!(client.project_id, "my-project"); assert_eq!(client.team_id, Some("platform".to_string())); assert_eq!(client.agent_id.len(), 64); // 32 bytes hex-encoded } #[test] fn test_client_uses_fallback_project_name() { let config = HostedConfig { url: Some("https://episteme.acme.corp".to_string()), project_id: None, // Not set ..Default::default() }; let community_config = CommunityConfig::default(); let key = generate_signing_key(); let client = HostedClient::new(&config, &community_config, &key, "fallback-project") .expect("should not fail") .unwrap(); assert_eq!(client.project_id, "fallback-project"); } #[test] fn test_assertion_to_dto() { use stemedb_core::types::{ Assertion, HlcTimestamp, LifecycleStage, ObjectValue, SignatureEntry, SourceClass, }; let assertion = Assertion { subject: "code://rust/myapp/tls".to_string(), predicate: "enabled".to_string(), object: ObjectValue::Boolean(true), parent_hash: None, source_hash: [1u8; 32], source_class: SourceClass::Community, visual_hash: None, epoch: None, source_metadata: Some(b"{\"file\":\"test.rs\"}".to_vec()), lifecycle: LifecycleStage::Approved, signatures: vec![SignatureEntry { agent_id: [2u8; 32], signature: [3u8; 64], timestamp: 12345, version: 1, }], confidence: 0.9, timestamp: 67890, hlc_timestamp: HlcTimestamp::default(), vector: None, }; let dto = assertion_to_dto(&assertion); assert_eq!(dto.subject, "code://rust/myapp/tls"); assert_eq!(dto.predicate, "enabled"); assert!(matches!(dto.object, ObjectValueDto::Boolean(true))); assert_eq!(dto.confidence, 0.9); assert_eq!(dto.timestamp, 67890); assert_eq!(dto.signatures.len(), 1); assert_eq!(dto.signatures[0].version, 1); assert_eq!(dto.source_metadata, Some("{\"file\":\"test.rs\"}".to_string())); } #[test] fn test_compute_org_hash() { let config = HostedConfig { url: Some("https://episteme.acme.corp".to_string()), project_id: Some("my-project".to_string()), team_id: Some("platform".to_string()), ..Default::default() }; let community_config = CommunityConfig::default(); let key = generate_signing_key(); let client = HostedClient::new(&config, &community_config, &key, "fallback-project") .expect("should not fail") .unwrap(); let hash = client.compute_org_hash(); // Hash should be 64 hex characters (32 bytes) assert_eq!(hash.len(), 64); // Same inputs should produce same hash let hash2 = client.compute_org_hash(); assert_eq!(hash, hash2); } #[test] fn test_compute_org_hash_without_team() { let config = HostedConfig { url: Some("https://episteme.acme.corp".to_string()), project_id: Some("my-project".to_string()), team_id: None, ..Default::default() }; let community_config = CommunityConfig::default(); let key = generate_signing_key(); let client = HostedClient::new(&config, &community_config, &key, "fallback-project") .expect("should not fail") .unwrap(); let hash = client.compute_org_hash(); assert_eq!(hash.len(), 64); // With team should produce different hash let config_with_team = HostedConfig { url: Some("https://episteme.acme.corp".to_string()), project_id: Some("my-project".to_string()), team_id: Some("platform".to_string()), ..Default::default() }; let client_with_team = HostedClient::new(&config_with_team, &community_config, &key, "fallback-project") .expect("should not fail") .unwrap(); let hash_with_team = client_with_team.compute_org_hash(); assert_ne!(hash, hash_with_team); } #[test] fn test_push_patterns_empty() { let config = HostedConfig { url: Some("https://episteme.acme.corp".to_string()), project_id: Some("my-project".to_string()), ..Default::default() }; let community_config = CommunityConfig::default(); let key = generate_signing_key(); let client = HostedClient::new(&config, &community_config, &key, "fallback-project") .expect("should not fail") .unwrap(); // Empty patterns should return default response without making HTTP call let result = client.push_patterns(vec![]); assert!(result.is_ok()); let response = result.unwrap(); assert_eq!(response.accepted, 0); assert_eq!(response.merged, 0); assert_eq!(response.deduplicated, 0); } #[test] fn test_accessors() { let config = HostedConfig { url: Some("https://episteme.acme.corp".to_string()), project_id: Some("my-project".to_string()), ..Default::default() }; let community_config = CommunityConfig::default(); let key = generate_signing_key(); let client = HostedClient::new(&config, &community_config, &key, "fallback-project") .expect("should not fail") .unwrap(); assert_eq!(client.base_url(), "https://episteme.acme.corp"); assert_eq!(client.project_id(), "my-project"); } }