Adds three new Aphoria CLI commands and supporting infrastructure for org-pattern alignment and claim tier advancement: - `aphoria claims search` — find claims by concept pattern, predicate, category, or max authority tier (works local and hosted mode) - `aphoria claims promote` — raise a claim to a higher authority tier by creating a superseding claim (append-only; original marked Deprecated) - `aphoria claims stats` — breakdown of claim counts by tier and status for a given concept_path + predicate pair New modules: - `convergence.rs` — pure engine comparing local scan observations to remote org claims, producing `ConvergenceSuggestion`s at read time - `types/convergence.rs` — `ConvergenceSuggestion` type with severity derived from the driving claim's authority tier - `types/promotion.rs` — `PromotionRequest` / `PromotionResult` types - `handlers/promote.rs` — promotion handler; validates tier ordering Remote client: adds `search_claims` and `claim_stats` methods to `RemoteClaimStore`, wiring hosted mode for all three new commands. API (`stemedb-api`): new `/v1/claims/search` and `/v1/claims/stats` endpoints with DTOs, plus report formatters (JSON/Markdown/SARIF/table) for search and stats output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
292 lines
9.1 KiB
Rust
292 lines
9.1 KiB
Rust
//! Integration tests for conflict detection (Phase 2A).
|
|
|
|
use crate::*;
|
|
use ed25519_dalek::SigningKey;
|
|
use stemedb_core::types::{Assertion, ObjectValue, SourceClass};
|
|
|
|
/// Create a minimal test corpus for integration tests.
|
|
#[allow(clippy::vec_init_then_push)]
|
|
#[allow(dead_code)]
|
|
fn create_test_corpus(signing_key: &SigningKey) -> Vec<Assertion> {
|
|
let timestamp = crate::episteme::current_timestamp();
|
|
let mut assertions = Vec::new();
|
|
|
|
// TLS verification (RFC 5246 + OWASP)
|
|
assertions.push(crate::episteme::create_authoritative_assertion(
|
|
signing_key,
|
|
"rfc://5246/tls/cert_verification",
|
|
"enabled",
|
|
ObjectValue::Boolean(true),
|
|
SourceClass::Regulatory,
|
|
"TLS certificate verification MUST be enabled (RFC 5246)",
|
|
timestamp,
|
|
));
|
|
|
|
assertions.push(crate::episteme::create_authoritative_assertion(
|
|
signing_key,
|
|
"owasp://transport_layer/tls/cert_verification",
|
|
"enabled",
|
|
ObjectValue::Boolean(true),
|
|
SourceClass::Clinical,
|
|
"OWASP: Always verify TLS certificates",
|
|
timestamp,
|
|
));
|
|
|
|
// JWT validation (RFC 7519)
|
|
assertions.push(crate::episteme::create_authoritative_assertion(
|
|
signing_key,
|
|
"rfc://7519/jwt/audience_validation",
|
|
"enabled",
|
|
ObjectValue::Boolean(true),
|
|
SourceClass::Regulatory,
|
|
"JWT audience claim MUST be validated (RFC 7519 Section 4.1.3)",
|
|
timestamp,
|
|
));
|
|
|
|
assertions.push(crate::episteme::create_authoritative_assertion(
|
|
signing_key,
|
|
"rfc://7519/jwt/expiry_validation",
|
|
"enabled",
|
|
ObjectValue::Boolean(true),
|
|
SourceClass::Regulatory,
|
|
"JWT expiry claim MUST be validated (RFC 7519 Section 4.1.4)",
|
|
timestamp,
|
|
));
|
|
|
|
// CORS security (OWASP)
|
|
assertions.push(crate::episteme::create_authoritative_assertion(
|
|
signing_key,
|
|
"owasp://cors/allow_origin",
|
|
"config_value",
|
|
ObjectValue::Text("explicit_list".to_string()),
|
|
SourceClass::Clinical,
|
|
"OWASP: Never use wildcard (*) for CORS Allow-Origin in production",
|
|
timestamp,
|
|
));
|
|
|
|
// Secrets management (OWASP)
|
|
assertions.push(crate::episteme::create_authoritative_assertion(
|
|
signing_key,
|
|
"owasp://secrets/api_key",
|
|
"storage_method",
|
|
ObjectValue::Text("environment_or_vault".to_string()),
|
|
SourceClass::Clinical,
|
|
"OWASP: Never hardcode API keys in source code",
|
|
timestamp,
|
|
));
|
|
|
|
assertions
|
|
}
|
|
|
|
#[ignore = "Needs corpus refactor after hardcoded deletion"]
|
|
#[tokio::test]
|
|
async fn test_conflict_detection_tls_disabled() {
|
|
// Create temp project with danger_accept_invalid_certs(true)
|
|
let temp_dir =
|
|
tempfile::Builder::new().prefix("aphoria_tls_conflict").tempdir().expect("create temp dir");
|
|
|
|
let src_dir = temp_dir.path().join("src");
|
|
std::fs::create_dir_all(&src_dir).expect("create src dir");
|
|
|
|
// Write a Rust file with TLS verification disabled
|
|
std::fs::write(
|
|
src_dir.join("client.rs"),
|
|
r#"
|
|
fn create_client() -> Result<Client, Error> {
|
|
let client = reqwest::Client::builder()
|
|
.danger_accept_invalid_certs(true)
|
|
.build()?;
|
|
Ok(client)
|
|
}
|
|
"#,
|
|
)
|
|
.expect("write file");
|
|
|
|
// Create Cargo.toml so it's detected as a Rust project
|
|
std::fs::write(
|
|
temp_dir.path().join("Cargo.toml"),
|
|
r#"
|
|
[package]
|
|
name = "testproject"
|
|
version = "0.1.0"
|
|
"#,
|
|
)
|
|
.expect("write cargo.toml");
|
|
|
|
let args = ScanArgs {
|
|
path: temp_dir.path().to_path_buf(),
|
|
format: "table".to_string(),
|
|
exit_code_enabled: true,
|
|
mode: ScanMode::Ephemeral,
|
|
debug: false,
|
|
sync: false,
|
|
file_source: FileSource::All,
|
|
benchmark: false,
|
|
show_claims: false,
|
|
strict: false,
|
|
show_observations: false,
|
|
explain_authority: false,
|
|
suggest_convergence: false,
|
|
};
|
|
|
|
let mut config = AphoriaConfig::default();
|
|
config.episteme.data_dir = temp_dir.path().join(".aphoria").join("db");
|
|
|
|
let result = run_scan(args, &config).await.expect("scan should succeed");
|
|
|
|
// Assert: conflicts not empty, has_blocks() == true
|
|
assert!(
|
|
!result.conflicts.is_empty(),
|
|
"Should detect conflicts for TLS verification disabled. \
|
|
Claims extracted: {}, Files scanned: {}",
|
|
result.claims_extracted,
|
|
result.files_scanned
|
|
);
|
|
|
|
assert!(
|
|
result.has_blocks(),
|
|
"TLS verification disabled should be a BLOCK verdict. \
|
|
Conflicts: {:?}",
|
|
result.conflicts.iter().map(|c| (&c.claim.concept_path, &c.verdict)).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[ignore = "Needs corpus refactor after hardcoded deletion"]
|
|
#[tokio::test]
|
|
async fn test_conflict_detection_jwt_audience_disabled() {
|
|
// Create temp project with JWT audience validation disabled
|
|
let temp_dir =
|
|
tempfile::Builder::new().prefix("aphoria_jwt_conflict").tempdir().expect("create temp dir");
|
|
|
|
let src_dir = temp_dir.path().join("src");
|
|
std::fs::create_dir_all(&src_dir).expect("create src dir");
|
|
|
|
// Write a Rust file with JWT audience validation disabled
|
|
std::fs::write(
|
|
src_dir.join("auth.rs"),
|
|
r#"
|
|
fn validate_token(token: &str) -> Result<Claims, Error> {
|
|
let mut validation = Validation::default();
|
|
validation.validate_aud = false; // Disabled!
|
|
let token_data = decode::<Claims>(token, &key, &validation)?;
|
|
Ok(token_data.claims)
|
|
}
|
|
"#,
|
|
)
|
|
.expect("write file");
|
|
|
|
// Create Cargo.toml
|
|
std::fs::write(
|
|
temp_dir.path().join("Cargo.toml"),
|
|
r#"
|
|
[package]
|
|
name = "testproject"
|
|
version = "0.1.0"
|
|
"#,
|
|
)
|
|
.expect("write cargo.toml");
|
|
|
|
let args = ScanArgs {
|
|
path: temp_dir.path().to_path_buf(),
|
|
format: "table".to_string(),
|
|
exit_code_enabled: true,
|
|
mode: ScanMode::Ephemeral,
|
|
debug: false,
|
|
sync: false,
|
|
file_source: FileSource::All,
|
|
benchmark: false,
|
|
show_claims: false,
|
|
strict: false,
|
|
show_observations: false,
|
|
explain_authority: false,
|
|
suggest_convergence: false,
|
|
};
|
|
|
|
let mut config = AphoriaConfig::default();
|
|
config.episteme.data_dir = temp_dir.path().join(".aphoria").join("db");
|
|
|
|
let result = run_scan(args, &config).await.expect("scan should succeed");
|
|
|
|
// Assert: conflicts not empty for JWT audience validation
|
|
assert!(
|
|
!result.conflicts.is_empty(),
|
|
"Should detect conflicts for JWT audience validation disabled. \
|
|
Claims extracted: {}, Files scanned: {}",
|
|
result.claims_extracted,
|
|
result.files_scanned
|
|
);
|
|
|
|
// Check that at least one conflict is about JWT audience
|
|
let has_jwt_conflict = result
|
|
.conflicts
|
|
.iter()
|
|
.any(|c| c.claim.concept_path.contains("jwt") && c.claim.concept_path.contains("audience"));
|
|
assert!(
|
|
has_jwt_conflict,
|
|
"Should have a conflict about JWT audience validation. \
|
|
Conflicts: {:?}",
|
|
result.conflicts.iter().map(|c| &c.claim.concept_path).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[ignore = "Needs corpus refactor after hardcoded deletion"]
|
|
#[tokio::test]
|
|
async fn test_no_conflicts_when_compliant() {
|
|
// Create temp project with compliant code (no dangerous patterns)
|
|
let temp_dir =
|
|
tempfile::Builder::new().prefix("aphoria_compliant").tempdir().expect("create temp dir");
|
|
|
|
let src_dir = temp_dir.path().join("src");
|
|
std::fs::create_dir_all(&src_dir).expect("create src dir");
|
|
|
|
// Write a Rust file with compliant code
|
|
std::fs::write(
|
|
src_dir.join("main.rs"),
|
|
r#"
|
|
fn main() {
|
|
println!("Hello, world!");
|
|
}
|
|
"#,
|
|
)
|
|
.expect("write file");
|
|
|
|
// Create Cargo.toml
|
|
std::fs::write(
|
|
temp_dir.path().join("Cargo.toml"),
|
|
r#"
|
|
[package]
|
|
name = "testproject"
|
|
version = "0.1.0"
|
|
"#,
|
|
)
|
|
.expect("write cargo.toml");
|
|
|
|
let args = ScanArgs {
|
|
path: temp_dir.path().to_path_buf(),
|
|
format: "table".to_string(),
|
|
exit_code_enabled: true,
|
|
mode: ScanMode::Ephemeral,
|
|
debug: false,
|
|
sync: false,
|
|
file_source: FileSource::All,
|
|
benchmark: false,
|
|
show_claims: false,
|
|
strict: false,
|
|
show_observations: false,
|
|
explain_authority: false,
|
|
suggest_convergence: false,
|
|
};
|
|
|
|
let mut config = AphoriaConfig::default();
|
|
config.episteme.data_dir = temp_dir.path().join(".aphoria").join("db");
|
|
|
|
let result = run_scan(args, &config).await.expect("scan should succeed");
|
|
|
|
// No dangerous patterns = no claims = no conflicts
|
|
assert!(
|
|
result.conflicts.is_empty(),
|
|
"Compliant code should have no conflicts. Found: {:?}",
|
|
result.conflicts.iter().map(|c| &c.claim.concept_path).collect::<Vec<_>>()
|
|
);
|
|
}
|