stemedb/applications/aphoria/src/hosted.rs
jml 65065f3d8f feat(aphoria): implement community corpus with wiki import and pattern aggregation
Implements Phase 4 (A4) - Community corpus as first-class citizens:

- **Community Corpus Builder** - Queries StemeDB pattern aggregates
- **Wiki Import** - Bootstrap corpus from markdown docs (aphoria corpus import wiki)
- **Pattern Aggregation** - Automatic learning from local scans (--sync flag)
- **Storage Layer** - StemeDBPatternStore with content-addressed deduplication
- **Promotion Logic** - Multi-tier thresholds (95%/80%/50% adoption rates)
- **Corpus Build** - Unified registry for RFC/OWASP/Vendor/Community sources
- **Trust Packs** - Export corpus as signed, distributable artifacts
- **Documentation** - bootstrap-corpus.md guide + CLI reference updates

Technical details:
- Pattern aggregates stored as assertions with predicate "pattern_aggregate"
- Content-addressed subjects via BLAKE3(subject:predicate:value)
- PatternAggregator handles write path (observations → patterns)
- StemeDBPatternStore handles read path (pattern queries)
- Integration tests + fixtures in tests/wiki_import_test.rs

Deleted hardcoded.rs (368 lines) - corpus now fully emergent from StemeDB.
Deleted enriched-corpus-patterns.md (677 lines) - feature shipped.

Closes VG-026 (community corpus), part of A4 milestone.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 00:12:31 +00:00

978 lines
34 KiB
Rust

//! 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<String>,
/// Agent's public key (hex-encoded).
agent_id: String,
/// Optional API key for authentication.
api_key: Option<String>,
/// 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<ObservationDto>,
/// Project identifier.
pub project_id: String,
/// Optional team identifier.
#[serde(skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,
/// 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<CommunityObservationDto>,
/// 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<SignatureDto>,
/// Timestamp of the observation.
pub timestamp: u64,
/// Source metadata as JSON string.
#[serde(skip_serializing_if = "Option::is_none")]
pub source_metadata: Option<String>,
}
/// 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<String>,
}
// ============================================================================
// 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<SharedPattern>,
/// 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<u64>,
/// 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<Option<Self>, 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<Assertion>) -> Result<usize, AphoriaError> {
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<Assertion>) -> Result<usize, AphoriaError> {
// Convert assertions to DTOs
let observation_dtos: Vec<ObservationDto> =
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<Assertion>) -> Result<usize, AphoriaError> {
// Convert assertions to anonymized community DTOs
let community_dtos: Vec<CommunityObservationDto> =
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<AphoriaError>) -> Result<usize, AphoriaError> {
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<PushObservationsResponse, AphoriaError> {
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<PushCommunityObservationsResponse, AphoriaError> {
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<SharedPattern>,
) -> Result<PushPatternsResponse, AphoriaError> {
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<PushPatternsResponse, AphoriaError> {
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<u64>,
min_projects: u64,
) -> Result<Vec<CommunityExtractor>, 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<Vec<CommunityExtractor>, 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<SignatureDto> = 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");
}
}