diff --git a/apps/elf-api/tests/http/helpers.rs b/apps/elf-api/tests/http/helpers.rs index a461ec21..ec8b91cb 100644 --- a/apps/elf-api/tests/http/helpers.rs +++ b/apps/elf-api/tests/http/helpers.rs @@ -1,947 +1,33 @@ -use std::{collections::HashMap, env}; - -use axum::{ - Router, - body::{self, Body}, - http::{Request, Response, StatusCode}, -}; -use qdrant_client::{ - Payload, - qdrant::{Document, PointStruct, UpsertPointsBuilder, Vector}, -}; -use serde_json::Map; -use tower::util::ServiceExt as _; -use tracing::Level; -use uuid::Uuid; - -use elf_api::{ - routes::{self, OPENAPI_JSON_PATH}, - state::AppState, -}; -use elf_config::{ - Chunking, Config, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, Memory, MemoryPolicy, - Postgres, ProviderConfig, Providers, Qdrant, Ranking, RankingBlend, RankingBlendSegment, - RankingDeterministic, RankingDeterministicDecay, RankingDeterministicHits, - RankingDeterministicLexical, RankingDiversity, RankingRetrievalSources, ReadProfiles, - ScopePrecedence, ScopeWriteAllowed, Scopes, Search, SearchCache, SearchDynamic, - SearchExpansion, SearchExplain, SearchGraphContext, SearchPrefilter, SearchRecursive, Security, - SecurityAuthKey, SecurityAuthRole, Service, Storage, TtlDays, +#[path = "helpers/config.rs"] mod config; +#[path = "helpers/contract.rs"] mod contract; +#[path = "helpers/core_blocks.rs"] mod core_blocks; +#[path = "helpers/database.rs"] mod database; +#[path = "helpers/org_shared.rs"] mod org_shared; +#[path = "helpers/payload_level.rs"] mod payload_level; +#[path = "helpers/requests.rs"] mod requests; + +pub(crate) use self::{ + config::{ + TEST_AGENT_A, TEST_AGENT_B, TEST_PROJECT_ID, TEST_PROJECT_ID_B, TEST_TENANT_ID, test_config, + }, + contract::{assert_openapi_method, contract_json}, + core_blocks::{attach_core_block, create_core_block, get_core_blocks}, + database::{ + active_org_shared_project_grant_count, active_org_shared_project_grant_count_for_project, + active_project_grant_count, insert_note, insert_project_scope_grant, + note_scope_and_project_id, search_session_count, + }, + org_shared::{ + assert_note_visible_to_project_reader, list_org_shared_notes_as_reader, + org_shared_note_is_visible_across_projects_fixture, + publish_org_shared_note_as_reader_can_see, + }, + payload_level::{ + create_note_for_payload_level_tests, fetch_admin_search_raw_source_ref, + fetch_search_notes_for_payload_level, insert_note_summary_field, + }, + requests::{ + context_request, init_test_tracing, post_admin_json, post_with_authorization_and_json_body, + test_env, + }, }; -use elf_storage::qdrant::{BM25_MODEL, BM25_VECTOR_NAME, DENSE_VECTOR_NAME}; -use elf_testkit::TestDatabase; - -pub(crate) const TEST_TENANT_ID: &str = "tenant_alpha"; -pub(crate) const TEST_PROJECT_ID: &str = "project_alpha"; -pub(crate) const TEST_PROJECT_ID_B: &str = "project_beta"; -pub(crate) const TEST_AGENT_A: &str = "a"; -pub(crate) const TEST_AGENT_B: &str = "b"; - -pub(crate) fn test_ranking() -> Ranking { - Ranking { - recency_tau_days: 60.0, - tie_breaker_weight: 0.1, - deterministic: RankingDeterministic { - enabled: false, - lexical: RankingDeterministicLexical { - enabled: false, - weight: 0.05, - min_ratio: 0.3, - max_query_terms: 16, - max_text_terms: 1_024, - }, - hits: RankingDeterministicHits { - enabled: false, - weight: 0.05, - half_saturation: 8.0, - last_hit_tau_days: 14.0, - }, - decay: RankingDeterministicDecay { enabled: false, weight: 0.05, tau_days: 30.0 }, - }, - blend: RankingBlend { - enabled: true, - rerank_normalization: "rank".to_string(), - retrieval_normalization: "rank".to_string(), - segments: vec![ - RankingBlendSegment { max_retrieval_rank: 3, retrieval_weight: 0.8 }, - RankingBlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.5 }, - RankingBlendSegment { max_retrieval_rank: 1_000_000, retrieval_weight: 0.2 }, - ], - }, - diversity: RankingDiversity { - enabled: true, - sim_threshold: 0.88, - mmr_lambda: 0.7, - max_skips: 64, - }, - retrieval_sources: RankingRetrievalSources { - fusion_weight: 1.0, - structured_field_weight: 1.0, - fusion_priority: 1, - structured_field_priority: 0, - }, - } -} - -pub(crate) fn test_config(dsn: String, qdrant_url: String, collection: String) -> Config { - Config { - service: Service { - http_bind: "127.0.0.1:0".to_string(), - mcp_bind: "127.0.0.1:0".to_string(), - admin_bind: "127.0.0.1:0".to_string(), - log_level: "info".to_string(), - }, - storage: Storage { - postgres: Postgres { dsn, pool_max_conns: 4 }, - qdrant: Qdrant { - url: qdrant_url, - collection: collection.clone(), - docs_collection: format!("{collection}_docs"), - vector_dim: 4_096, - }, - }, - providers: Providers { - embedding: dummy_embedding_provider(), - rerank: dummy_provider(), - llm_extractor: dummy_llm_provider(), - }, - scopes: Scopes { - allowed: vec![ - "agent_private".to_string(), - "project_shared".to_string(), - "org_shared".to_string(), - ], - read_profiles: ReadProfiles { - private_only: vec!["agent_private".to_string()], - private_plus_project: vec![ - "agent_private".to_string(), - "project_shared".to_string(), - ], - all_scopes: vec![ - "agent_private".to_string(), - "project_shared".to_string(), - "org_shared".to_string(), - ], - }, - precedence: ScopePrecedence { agent_private: 30, project_shared: 20, org_shared: 10 }, - write_allowed: ScopeWriteAllowed { - agent_private: true, - project_shared: true, - org_shared: true, - }, - }, - memory: Memory { - max_notes_per_add_event: 3, - max_note_chars: 240, - dup_sim_threshold: 0.92, - update_sim_threshold: 0.85, - candidate_k: 60, - top_k: 12, - policy: MemoryPolicy { rules: vec![] }, - }, - search: test_search(), - ranking: test_ranking(), - lifecycle: Lifecycle { - ttl_days: TtlDays { - plan: 14, - fact: 180, - preference: 0, - constraint: 0, - decision: 0, - profile: 0, - }, - purge_deleted_after_days: 30, - purge_deprecated_after_days: 180, - }, - security: Security { - bind_localhost_only: true, - reject_non_english: true, - redact_secrets_on_write: true, - evidence_min_quotes: 1, - evidence_max_quotes: 2, - evidence_max_quote_chars: 320, - auth_mode: "off".to_string(), - auth_keys: vec![], - }, - chunking: Chunking { - enabled: true, - max_tokens: 512, - overlap_tokens: 128, - tokenizer_repo: "gpt2".to_string(), - }, - context: None, - mcp: None, - } -} - -pub(crate) fn test_search() -> Search { - Search { - expansion: SearchExpansion { - mode: "off".to_string(), - max_queries: 4, - include_original: true, - }, - dynamic: SearchDynamic { min_candidates: 10, min_top_score: 0.12 }, - prefilter: SearchPrefilter { max_candidates: 0 }, - cache: SearchCache { - enabled: true, - expansion_ttl_days: 7, - rerank_ttl_days: 7, - max_payload_bytes: Some(262_144), - }, - explain: SearchExplain { - retention_days: 7, - capture_candidates: false, - candidate_retention_days: 2, - write_mode: "outbox".to_string(), - }, - recursive: SearchRecursive { - enabled: false, - max_depth: 2, - max_children_per_node: 4, - max_nodes_per_scope: 32, - max_total_nodes: 256, - }, - graph_context: SearchGraphContext { - enabled: false, - max_facts_per_item: 16, - max_evidence_notes_per_fact: 16, - }, - } -} - -pub(crate) fn dummy_embedding_provider() -> EmbeddingProviderConfig { - EmbeddingProviderConfig { - provider_id: "local".to_string(), - api_base: "http://127.0.0.1:1".to_string(), - api_key: "test-key".to_string(), - path: "/".to_string(), - model: "local-hash".to_string(), - dimensions: 4_096, - timeout_ms: 1_000, - default_headers: Map::new(), - } -} - -pub(crate) fn dummy_provider() -> ProviderConfig { - ProviderConfig { - provider_id: "local".to_string(), - api_base: "http://127.0.0.1:1".to_string(), - api_key: "test-key".to_string(), - path: "/".to_string(), - model: "local-token-overlap".to_string(), - timeout_ms: 1_000, - default_headers: Map::new(), - } -} - -pub(crate) fn dummy_llm_provider() -> LlmProviderConfig { - LlmProviderConfig { - provider_id: "test".to_string(), - api_base: "http://127.0.0.1:1".to_string(), - api_key: "test-key".to_string(), - path: "/".to_string(), - model: "test".to_string(), - temperature: 0.1, - timeout_ms: 1_000, - default_headers: Map::new(), - } -} - -pub(crate) fn assert_openapi_method(spec: &serde_json::Value, path: &str, method: &str) { - let operation = spec - .get("paths") - .and_then(|paths| paths.get(path)) - .and_then(|path_item| path_item.get(method)); - - assert!(operation.is_some(), "Missing OpenAPI operation {method} {path}"); -} - -pub(crate) fn init_test_tracing() { - let _ = tracing_subscriber::fmt().with_max_level(Level::ERROR).with_test_writer().try_init(); -} - -pub(crate) fn context_request( - method: &str, - uri: impl AsRef, - agent_id: &str, - read_profile: &str, -) -> Request { - Request::builder() - .method(method) - .uri(uri.as_ref()) - .header("content-type", "application/json") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", agent_id) - .header("X-ELF-Read-Profile", read_profile) - .body(Body::empty()) - .expect("Failed to build context request.") -} - -pub(crate) async fn test_env() -> Option<(TestDatabase, String, String)> { - let base_dsn = match elf_testkit::env_dsn() { - Some(value) => value, - None => { - eprintln!("Skipping HTTP tests; set ELF_PG_DSN to run this test."); - - return None; - }, - }; - let qdrant_url = match env::var("ELF_QDRANT_GRPC_URL").or_else(|_| env::var("ELF_QDRANT_URL")) { - Ok(value) => value, - Err(_) => { - eprintln!( - "Skipping HTTP tests; set ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run this test." - ); - - return None; - }, - }; - let test_db = TestDatabase::new(&base_dsn).await.expect("Failed to create test database."); - let collection = test_db.collection_name("elf_http"); - - Some((test_db, qdrant_url, collection)) -} - -pub(crate) async fn insert_note( - state: &AppState, - note_id: Uuid, - note_scope: &str, - note_agent: &str, - note_text: &str, -) { - sqlx::query( - "INSERT INTO memory_notes ( - note_id, - tenant_id, - project_id, - agent_id, - scope, - type, - key, - text, - importance, - confidence, - status, - created_at, - updated_at, - expires_at, - embedding_version, - source_ref - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now(), now(), NULL, $12, $13)", - ) - .bind(note_id) - .bind(TEST_TENANT_ID) - .bind(TEST_PROJECT_ID) - .bind(note_agent) - .bind(note_scope) - .bind("fact") - .bind(None::) - .bind(note_text) - .bind(0.7_f32) - .bind(0.9_f32) - .bind("active") - .bind("v2-test") - .bind(serde_json::json!({ "source": "integration-test" })) - .execute(&state.service.db.pool) - .await - .expect("Failed to seed memory note."); -} - -pub(crate) async fn insert_project_scope_grant( - state: &AppState, - owner_agent_id: &str, - granter_agent_id: &str, -) { - sqlx::query( - "INSERT INTO memory_space_grants ( - grant_id, - tenant_id, - project_id, - scope, - space_owner_agent_id, - grantee_kind, - grantee_agent_id, - granted_by_agent_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", - ) - .bind(Uuid::new_v4()) - .bind(TEST_TENANT_ID) - .bind(TEST_PROJECT_ID) - .bind("project_shared") - .bind(owner_agent_id) - .bind("project") - .bind(None::) - .bind(granter_agent_id) - .execute(&state.service.db.pool) - .await - .expect("Failed to seed project scope grant."); -} - -pub(crate) async fn search_session_count(state: &AppState) -> i64 { - sqlx::query_scalar("SELECT COUNT(*) FROM search_sessions") - .fetch_one(&state.service.db.pool) - .await - .expect("Failed to count search sessions.") -} - -pub(crate) async fn post_admin_json( - app: &Router, - uri: impl AsRef, - agent_id: &str, - body: serde_json::Value, -) -> (StatusCode, serde_json::Value) { - let request = Request::builder() - .method("POST") - .uri(uri.as_ref()) - .header("content-type", "application/json") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", agent_id) - .body(Body::from(body.to_string())) - .expect("Failed to build admin JSON request."); - let response = app.clone().oneshot(request).await.expect("Failed to call admin route."); - let status = response.status(); - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read admin response body."); - - (status, serde_json::from_slice(&body).expect("Failed to parse admin response.")) -} - -pub(crate) async fn create_core_block( - admin_app: &Router, - scope: &str, - key: &str, - content: &str, -) -> Uuid { - let payload = serde_json::json!({ - "scope": scope, - "key": key, - "title": "Operating context", - "content": content, - "source_ref": { - "schema": "core_block_source/v1", - "ref": { "issue": "XY-832" } - } - }); - let (status, body) = - post_admin_json(admin_app, "/v2/admin/core-blocks", TEST_AGENT_A, payload).await; - - assert_eq!(status, StatusCode::OK); - - Uuid::parse_str( - body.pointer("/block/block_id") - .and_then(serde_json::Value::as_str) - .expect("Missing core block id."), - ) - .expect("Invalid core block id.") -} - -pub(crate) async fn attach_core_block( - admin_app: &Router, - block_id: Uuid, - target_agent_id: &str, - read_profile: &str, -) -> (StatusCode, serde_json::Value) { - let payload = serde_json::json!({ - "target_agent_id": target_agent_id, - "read_profile": read_profile, - "reason": "Attach fixture block." - }); - let uri = format!("/v2/admin/core-blocks/{block_id}/attachments"); - - post_admin_json(admin_app, uri, TEST_AGENT_A, payload).await -} - -pub(crate) async fn get_core_blocks( - app: &Router, - agent_id: &str, - read_profile: &str, -) -> serde_json::Value { - let response = app - .clone() - .oneshot(context_request("GET", "/v2/core-blocks", agent_id, read_profile)) - .await - .expect("Failed to fetch core blocks."); - - assert_eq!(response.status(), StatusCode::OK); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read core blocks response body."); - - serde_json::from_slice(&body).expect("Failed to parse core blocks response.") -} - -pub(crate) async fn active_project_grant_count(state: &AppState, owner_agent_id: &str) -> i64 { - sqlx::query_scalar( - "SELECT COUNT(*) FROM memory_space_grants \ - WHERE tenant_id = $1 AND project_id = $2 AND scope = 'project_shared' \ - AND space_owner_agent_id = $3 AND grantee_kind = 'project' AND revoked_at IS NULL", - ) - .bind(TEST_TENANT_ID) - .bind(TEST_PROJECT_ID) - .bind(owner_agent_id) - .fetch_one(&state.service.db.pool) - .await - .expect("Failed to query project grant count.") -} - -pub(crate) async fn note_scope_and_project_id(state: &AppState, note_id: Uuid) -> (String, String) { - let row: (String, String) = sqlx::query_as( - "SELECT scope, project_id FROM memory_notes WHERE tenant_id = $1 AND note_id = $2", - ) - .bind(TEST_TENANT_ID) - .bind(note_id) - .fetch_one(&state.service.db.pool) - .await - .expect("Failed to query note scope and project id."); - - row -} - -pub(crate) async fn active_org_shared_project_grant_count( - state: &AppState, - owner_agent_id: &str, -) -> i64 { - sqlx::query_scalar( - "SELECT COUNT(*) FROM memory_space_grants \ - WHERE tenant_id = $1 AND project_id = '__org__' AND scope = 'org_shared' \ - AND space_owner_agent_id = $2 AND grantee_kind = 'project' AND revoked_at IS NULL", - ) - .bind(TEST_TENANT_ID) - .bind(owner_agent_id) - .fetch_one(&state.service.db.pool) - .await - .expect("Failed to query org_shared project grant count.") -} - -pub(crate) async fn active_org_shared_project_grant_count_for_project( - state: &AppState, - project_id: &str, - owner_agent_id: &str, -) -> i64 { - sqlx::query_scalar( - "SELECT COUNT(*) FROM memory_space_grants \ - WHERE tenant_id = $1 AND project_id = $2 AND scope = 'org_shared' \ - AND space_owner_agent_id = $3 AND grantee_kind = 'project' AND revoked_at IS NULL", - ) - .bind(TEST_TENANT_ID) - .bind(project_id) - .bind(owner_agent_id) - .fetch_one(&state.service.db.pool) - .await - .expect("Failed to query org_shared project grant count for project.") -} - -pub(crate) async fn org_shared_note_is_visible_across_projects_fixture() --> Option<(TestDatabase, Router, AppState, Uuid)> { - let (test_db, qdrant_url, collection) = test_env().await?; - let mut config = test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "static_keys".to_string(); - config.security.auth_keys = vec![ - SecurityAuthKey { - token_id: "admin-token-id".to_string(), - token: "admin-token".to_string(), - tenant_id: TEST_TENANT_ID.to_string(), - project_id: TEST_PROJECT_ID.to_string(), - agent_id: Some("admin-agent".to_string()), - read_profile: "all_scopes".to_string(), - role: SecurityAuthRole::Admin, - }, - SecurityAuthKey { - token_id: "reader-token-id".to_string(), - token: "reader-token".to_string(), - tenant_id: TEST_TENANT_ID.to_string(), - project_id: TEST_PROJECT_ID_B.to_string(), - agent_id: Some("reader-agent".to_string()), - read_profile: "all_scopes".to_string(), - role: SecurityAuthRole::User, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state.clone()); - let note_id = Uuid::new_v4(); - - insert_note( - &state, - note_id, - "agent_private", - "admin-agent", - "Fact: org_shared cross-project visibility.", - ) - .await; - - Some((test_db, app, state, note_id)) -} - -pub(crate) async fn list_org_shared_notes_as_reader(app: &Router) -> serde_json::Value { - let response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v2/notes?scope=org_shared") - .header("Authorization", "Bearer reader-token") - .body(Body::empty()) - .expect("Failed to build list request."), - ) - .await - .expect("Failed to call notes list."); - - assert_eq!(response.status(), StatusCode::OK); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read list response body."); - - serde_json::from_slice(&body).expect("Failed to parse list response.") -} - -pub(crate) async fn publish_org_shared_note_as_reader_can_see(scope_app: &Router, note_id: Uuid) { - let payload = serde_json::json!({ "space": "org_shared" }).to_string(); - let response = scope_app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/v2/notes/{note_id}/publish")) - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload)) - .expect("Failed to build note publish request."), - ) - .await - .expect("Failed to call notes publish."); - - assert_eq!(response.status(), StatusCode::OK); -} - -pub(crate) async fn assert_note_visible_to_project_reader( - scope_app: &Router, - state: &AppState, - note_id: Uuid, -) { - let (scope, project_id) = note_scope_and_project_id(state, note_id).await; - - assert_eq!(scope, "org_shared"); - // org_shared note rows live in the synthetic org project, not the request project. - assert_eq!(project_id, "__org__"); - - let org_grant_count = active_org_shared_project_grant_count(state, "admin-agent").await; - - assert!(org_grant_count > 0); - - // org_shared grant rows live in '__org__' as well; they should not be written into the request - // project. - let request_project_grant_count = - active_org_shared_project_grant_count_for_project(state, TEST_PROJECT_ID, "admin-agent") - .await; - - assert_eq!(request_project_grant_count, 0); - - let list_after_json = list_org_shared_notes_as_reader(scope_app).await; - let items = list_after_json["items"].as_array().expect("Missing items array."); - let ids: Vec<&str> = items.iter().filter_map(|item| item["note_id"].as_str()).collect(); - let note_id_str = note_id.to_string(); - - assert!(ids.contains(¬e_id_str.as_str())); -} - -pub(crate) async fn post_with_authorization_and_json_body( - app: &Router, - uri: &str, - auth: &str, - payload: &str, - build_expect: &str, - call_expect: &str, -) -> Response { - app.clone() - .oneshot( - Request::builder() - .method("POST") - .uri(uri) - .header("Authorization", auth) - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect(build_expect), - ) - .await - .expect(call_expect) -} - -pub(crate) async fn create_note_for_payload_level_tests( - app: &Router, - state: &AppState, - text: &str, - source_ref: serde_json::Value, -) -> Uuid { - init_test_tracing(); - - let payload = serde_json::json!({ - "scope": "agent_private", - "notes": [{ - "type": "fact", - "key": null, - "text": text, - "importance": 0.8, - "confidence": 0.9, - "ttl_days": null, - "source_ref": source_ref, - }] - }); - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/notes/ingest") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_A) - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build note ingest request."), - ) - .await - .expect("Failed to call note ingest."); - let status = response.status(); - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read note ingest response body."); - - assert_eq!( - status, - StatusCode::OK, - "Unexpected note ingest status with body: {}", - String::from_utf8_lossy(&body) - ); - - let json: serde_json::Value = - serde_json::from_slice(&body).expect("Failed to parse note ingest response."); - let note_id = json["results"] - .as_array() - .expect("Missing results array in note ingest response.") - .first() - .and_then(|result| result["note_id"].as_str()) - .expect("Missing note_id in note ingest response."); - let note_id = Uuid::parse_str(note_id).expect("Invalid note_id in note ingest response."); - - index_note_for_payload_level_tests(state, note_id, text).await; - - note_id -} - -pub(crate) async fn index_note_for_payload_level_tests( - state: &AppState, - note_id: Uuid, - text: &str, -) { - let chunk_id = Uuid::new_v4(); - let embedding_version = format!( - "{}:{}:{}", - state.service.cfg.providers.embedding.provider_id, - state.service.cfg.providers.embedding.model, - state.service.cfg.storage.qdrant.vector_dim - ); - - sqlx::query( - "INSERT INTO memory_note_chunks ( - chunk_id, - note_id, - chunk_index, - start_offset, - end_offset, - text, - embedding_version - ) VALUES ($1, $2, $3, $4, $5, $6, $7)", - ) - .bind(chunk_id) - .bind(note_id) - .bind(0_i32) - .bind(0_i32) - .bind(i32::try_from(text.len()).expect("Payload-level test text fits i32 offsets.")) - .bind(text) - .bind(embedding_version.as_str()) - .execute(&state.service.db.pool) - .await - .expect("Failed to seed memory note chunk."); - - let mut payload = Payload::new(); - - payload.insert("note_id", note_id.to_string()); - payload.insert("chunk_id", chunk_id.to_string()); - payload.insert("chunk_index", 0_i64); - payload.insert("start_offset", 0_i64); - payload.insert("end_offset", i64::try_from(text.len()).expect("Test text fits i64 offsets.")); - payload.insert("tenant_id", TEST_TENANT_ID); - payload.insert("project_id", TEST_PROJECT_ID); - payload.insert("agent_id", TEST_AGENT_A); - payload.insert("scope", "agent_private"); - payload.insert("type", "fact"); - payload.insert("status", "active"); - payload.insert("embedding_version", embedding_version); - - let mut vectors = HashMap::new(); - - vectors.insert( - DENSE_VECTOR_NAME.to_string(), - Vector::from(vec![0.0_f32; state.service.qdrant.vector_dim as usize]), - ); - vectors.insert( - BM25_VECTOR_NAME.to_string(), - Vector::from(Document::new(text.to_string(), BM25_MODEL)), - ); - - let point = PointStruct::new(chunk_id.to_string(), vectors, payload); - - state - .service - .qdrant - .client - .upsert_points( - UpsertPointsBuilder::new(state.service.qdrant.collection.clone(), vec![point]) - .wait(true), - ) - .await - .expect("Failed to seed Qdrant point."); -} - -pub(crate) async fn insert_note_summary_field(state: &AppState, note_id: Uuid, summary: &str) { - sqlx::query( - "INSERT INTO memory_note_fields (field_id, note_id, field_kind, item_index, text) \ - VALUES ($1, $2, $3, $4, $5)", - ) - .bind(Uuid::new_v4()) - .bind(note_id) - .bind("summary") - .bind(0) - .bind(summary) - .execute(&state.service.db.pool) - .await - .expect("Failed to insert note summary field."); -} - -pub(crate) async fn fetch_search_notes_for_payload_level( - app: &Router, - search_id: Uuid, - note_id: Uuid, - payload_level: &str, -) -> serde_json::Value { - let payload = serde_json::json!({ - "note_ids": [note_id], - "payload_level": payload_level, - "record_hits": false, - }); - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/v2/searches/{search_id}/notes")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_A) - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build search notes request."), - ) - .await - .expect("Failed to call search notes."); - let status = response.status(); - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read search notes response body."); - - assert_eq!( - status, - StatusCode::OK, - "Unexpected search notes response: {}", - String::from_utf8_lossy(&body) - ); - - let json: serde_json::Value = - serde_json::from_slice(&body).expect("Failed to parse search notes response."); - - json.get("results") - .and_then(serde_json::Value::as_array) - .and_then(|results| results.first()) - .and_then(|result| result.get("note")) - .cloned() - .expect("Expected note in search notes response.") -} - -pub(crate) async fn fetch_admin_search_raw_source_ref( - app: &Router, - query: &str, - payload_level: &str, -) -> serde_json::Value { - let payload = serde_json::json!({ - "mode": "quick_find", - "query": query, - "top_k": 5, - "candidate_k": 10, - "payload_level": payload_level, - }); - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/admin/searches/raw") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_A) - .header("X-ELF-Read-Profile", "private_only") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build admin search raw request."), - ) - .await - .expect("Failed to call admin search raw."); - let status = response.status(); - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read admin search raw response body."); - - assert_eq!( - status, - StatusCode::OK, - "Unexpected admin search raw status with body: {}", - String::from_utf8_lossy(&body) - ); - - let json: serde_json::Value = - serde_json::from_slice(&body).expect("Failed to parse admin search raw response."); - let item = json["items"] - .as_array() - .expect("Missing items in admin search raw response.") - .first() - .expect("Expected at least one raw search item."); - - item["source_ref"].clone() -} - -pub(crate) async fn contract_json() -> serde_json::Value { - let app = routes::contract_router::<()>(); - let response = app - .oneshot( - Request::builder() - .uri(OPENAPI_JSON_PATH) - .body(Body::empty()) - .expect("Failed to build OpenAPI request."), - ) - .await - .expect("Failed to call OpenAPI route."); - - assert_eq!(response.status(), StatusCode::OK); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read OpenAPI response body."); - - serde_json::from_slice(&body).expect("Failed to parse OpenAPI response.") -} diff --git a/apps/elf-api/tests/http/helpers/config.rs b/apps/elf-api/tests/http/helpers/config.rs new file mode 100644 index 00000000..15a4aded --- /dev/null +++ b/apps/elf-api/tests/http/helpers/config.rs @@ -0,0 +1,228 @@ +use serde_json::Map; + +use elf_config::{ + Chunking, Config, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, Memory, MemoryPolicy, + Postgres, ProviderConfig, Providers, Qdrant, Ranking, RankingBlend, RankingBlendSegment, + RankingDeterministic, RankingDeterministicDecay, RankingDeterministicHits, + RankingDeterministicLexical, RankingDiversity, RankingRetrievalSources, ReadProfiles, + ScopePrecedence, ScopeWriteAllowed, Scopes, Search, SearchCache, SearchDynamic, + SearchExpansion, SearchExplain, SearchGraphContext, SearchPrefilter, SearchRecursive, Security, + Service, Storage, TtlDays, +}; + +pub(crate) const TEST_TENANT_ID: &str = "tenant_alpha"; +pub(crate) const TEST_PROJECT_ID: &str = "project_alpha"; +pub(crate) const TEST_PROJECT_ID_B: &str = "project_beta"; +pub(crate) const TEST_AGENT_A: &str = "a"; +pub(crate) const TEST_AGENT_B: &str = "b"; + +pub(crate) fn test_ranking() -> Ranking { + Ranking { + recency_tau_days: 60.0, + tie_breaker_weight: 0.1, + deterministic: RankingDeterministic { + enabled: false, + lexical: RankingDeterministicLexical { + enabled: false, + weight: 0.05, + min_ratio: 0.3, + max_query_terms: 16, + max_text_terms: 1_024, + }, + hits: RankingDeterministicHits { + enabled: false, + weight: 0.05, + half_saturation: 8.0, + last_hit_tau_days: 14.0, + }, + decay: RankingDeterministicDecay { enabled: false, weight: 0.05, tau_days: 30.0 }, + }, + blend: RankingBlend { + enabled: true, + rerank_normalization: "rank".to_string(), + retrieval_normalization: "rank".to_string(), + segments: vec![ + RankingBlendSegment { max_retrieval_rank: 3, retrieval_weight: 0.8 }, + RankingBlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.5 }, + RankingBlendSegment { max_retrieval_rank: 1_000_000, retrieval_weight: 0.2 }, + ], + }, + diversity: RankingDiversity { + enabled: true, + sim_threshold: 0.88, + mmr_lambda: 0.7, + max_skips: 64, + }, + retrieval_sources: RankingRetrievalSources { + fusion_weight: 1.0, + structured_field_weight: 1.0, + fusion_priority: 1, + structured_field_priority: 0, + }, + } +} + +pub(crate) fn test_config(dsn: String, qdrant_url: String, collection: String) -> Config { + Config { + service: Service { + http_bind: "127.0.0.1:0".to_string(), + mcp_bind: "127.0.0.1:0".to_string(), + admin_bind: "127.0.0.1:0".to_string(), + log_level: "info".to_string(), + }, + storage: Storage { + postgres: Postgres { dsn, pool_max_conns: 4 }, + qdrant: Qdrant { + url: qdrant_url, + collection: collection.clone(), + docs_collection: format!("{collection}_docs"), + vector_dim: 4_096, + }, + }, + providers: Providers { + embedding: dummy_embedding_provider(), + rerank: dummy_provider(), + llm_extractor: dummy_llm_provider(), + }, + scopes: Scopes { + allowed: vec![ + "agent_private".to_string(), + "project_shared".to_string(), + "org_shared".to_string(), + ], + read_profiles: ReadProfiles { + private_only: vec!["agent_private".to_string()], + private_plus_project: vec![ + "agent_private".to_string(), + "project_shared".to_string(), + ], + all_scopes: vec![ + "agent_private".to_string(), + "project_shared".to_string(), + "org_shared".to_string(), + ], + }, + precedence: ScopePrecedence { agent_private: 30, project_shared: 20, org_shared: 10 }, + write_allowed: ScopeWriteAllowed { + agent_private: true, + project_shared: true, + org_shared: true, + }, + }, + memory: Memory { + max_notes_per_add_event: 3, + max_note_chars: 240, + dup_sim_threshold: 0.92, + update_sim_threshold: 0.85, + candidate_k: 60, + top_k: 12, + policy: MemoryPolicy { rules: vec![] }, + }, + search: test_search(), + ranking: test_ranking(), + lifecycle: Lifecycle { + ttl_days: TtlDays { + plan: 14, + fact: 180, + preference: 0, + constraint: 0, + decision: 0, + profile: 0, + }, + purge_deleted_after_days: 30, + purge_deprecated_after_days: 180, + }, + security: Security { + bind_localhost_only: true, + reject_non_english: true, + redact_secrets_on_write: true, + evidence_min_quotes: 1, + evidence_max_quotes: 2, + evidence_max_quote_chars: 320, + auth_mode: "off".to_string(), + auth_keys: vec![], + }, + chunking: Chunking { + enabled: true, + max_tokens: 512, + overlap_tokens: 128, + tokenizer_repo: "gpt2".to_string(), + }, + context: None, + mcp: None, + } +} + +pub(crate) fn test_search() -> Search { + Search { + expansion: SearchExpansion { + mode: "off".to_string(), + max_queries: 4, + include_original: true, + }, + dynamic: SearchDynamic { min_candidates: 10, min_top_score: 0.12 }, + prefilter: SearchPrefilter { max_candidates: 0 }, + cache: SearchCache { + enabled: true, + expansion_ttl_days: 7, + rerank_ttl_days: 7, + max_payload_bytes: Some(262_144), + }, + explain: SearchExplain { + retention_days: 7, + capture_candidates: false, + candidate_retention_days: 2, + write_mode: "outbox".to_string(), + }, + recursive: SearchRecursive { + enabled: false, + max_depth: 2, + max_children_per_node: 4, + max_nodes_per_scope: 32, + max_total_nodes: 256, + }, + graph_context: SearchGraphContext { + enabled: false, + max_facts_per_item: 16, + max_evidence_notes_per_fact: 16, + }, + } +} + +pub(crate) fn dummy_embedding_provider() -> EmbeddingProviderConfig { + EmbeddingProviderConfig { + provider_id: "local".to_string(), + api_base: "http://127.0.0.1:1".to_string(), + api_key: "test-key".to_string(), + path: "/".to_string(), + model: "local-hash".to_string(), + dimensions: 4_096, + timeout_ms: 1_000, + default_headers: Map::new(), + } +} + +pub(crate) fn dummy_provider() -> ProviderConfig { + ProviderConfig { + provider_id: "local".to_string(), + api_base: "http://127.0.0.1:1".to_string(), + api_key: "test-key".to_string(), + path: "/".to_string(), + model: "local-token-overlap".to_string(), + timeout_ms: 1_000, + default_headers: Map::new(), + } +} + +pub(crate) fn dummy_llm_provider() -> LlmProviderConfig { + LlmProviderConfig { + provider_id: "test".to_string(), + api_base: "http://127.0.0.1:1".to_string(), + api_key: "test-key".to_string(), + path: "/".to_string(), + model: "test".to_string(), + temperature: 0.1, + timeout_ms: 1_000, + default_headers: Map::new(), + } +} diff --git a/apps/elf-api/tests/http/helpers/contract.rs b/apps/elf-api/tests/http/helpers/contract.rs new file mode 100644 index 00000000..54df6f2f --- /dev/null +++ b/apps/elf-api/tests/http/helpers/contract.rs @@ -0,0 +1,38 @@ +use axum::{ + body::{self, Body}, + http::{Request, StatusCode}, +}; +use serde_json::Value; +use tower::util::ServiceExt as _; + +use elf_api::routes::{self, OPENAPI_JSON_PATH}; + +pub(crate) fn assert_openapi_method(spec: &Value, path: &str, method: &str) { + let operation = spec + .get("paths") + .and_then(|paths| paths.get(path)) + .and_then(|path_item| path_item.get(method)); + + assert!(operation.is_some(), "Missing OpenAPI operation {method} {path}"); +} + +pub(crate) async fn contract_json() -> Value { + let app = routes::contract_router::<()>(); + let response = app + .oneshot( + Request::builder() + .uri(OPENAPI_JSON_PATH) + .body(Body::empty()) + .expect("Failed to build OpenAPI request."), + ) + .await + .expect("Failed to call OpenAPI route."); + + assert_eq!(response.status(), StatusCode::OK); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read OpenAPI response body."); + + serde_json::from_slice(&body).expect("Failed to parse OpenAPI response.") +} diff --git a/apps/elf-api/tests/http/helpers/core_blocks.rs b/apps/elf-api/tests/http/helpers/core_blocks.rs new file mode 100644 index 00000000..a98724b4 --- /dev/null +++ b/apps/elf-api/tests/http/helpers/core_blocks.rs @@ -0,0 +1,70 @@ +use axum::{Router, body, http::StatusCode}; +use tower::util::ServiceExt as _; +use uuid::Uuid; + +use crate::helpers::{self, TEST_AGENT_A}; + +pub(crate) async fn create_core_block( + admin_app: &Router, + scope: &str, + key: &str, + content: &str, +) -> Uuid { + let payload = serde_json::json!({ + "scope": scope, + "key": key, + "title": "Operating context", + "content": content, + "source_ref": { + "schema": "core_block_source/v1", + "ref": { "issue": "XY-832" } + } + }); + let (status, body) = + helpers::post_admin_json(admin_app, "/v2/admin/core-blocks", TEST_AGENT_A, payload).await; + + assert_eq!(status, StatusCode::OK); + + Uuid::parse_str( + body.pointer("/block/block_id") + .and_then(serde_json::Value::as_str) + .expect("Missing core block id."), + ) + .expect("Invalid core block id.") +} + +pub(crate) async fn attach_core_block( + admin_app: &Router, + block_id: Uuid, + target_agent_id: &str, + read_profile: &str, +) -> (StatusCode, serde_json::Value) { + let payload = serde_json::json!({ + "target_agent_id": target_agent_id, + "read_profile": read_profile, + "reason": "Attach fixture block." + }); + let uri = format!("/v2/admin/core-blocks/{block_id}/attachments"); + + helpers::post_admin_json(admin_app, uri, TEST_AGENT_A, payload).await +} + +pub(crate) async fn get_core_blocks( + app: &Router, + agent_id: &str, + read_profile: &str, +) -> serde_json::Value { + let response = app + .clone() + .oneshot(helpers::context_request("GET", "/v2/core-blocks", agent_id, read_profile)) + .await + .expect("Failed to fetch core blocks."); + + assert_eq!(response.status(), StatusCode::OK); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read core blocks response body."); + + serde_json::from_slice(&body).expect("Failed to parse core blocks response.") +} diff --git a/apps/elf-api/tests/http/helpers/database.rs b/apps/elf-api/tests/http/helpers/database.rs new file mode 100644 index 00000000..83eece42 --- /dev/null +++ b/apps/elf-api/tests/http/helpers/database.rs @@ -0,0 +1,147 @@ +use uuid::Uuid; + +use crate::helpers::{TEST_PROJECT_ID, TEST_TENANT_ID}; +use elf_api::state::AppState; + +pub(crate) async fn insert_note( + state: &AppState, + note_id: Uuid, + note_scope: &str, + note_agent: &str, + note_text: &str, +) { + sqlx::query( + "INSERT INTO memory_notes ( + note_id, + tenant_id, + project_id, + agent_id, + scope, + type, + key, + text, + importance, + confidence, + status, + created_at, + updated_at, + expires_at, + embedding_version, + source_ref + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now(), now(), NULL, $12, $13)", + ) + .bind(note_id) + .bind(TEST_TENANT_ID) + .bind(TEST_PROJECT_ID) + .bind(note_agent) + .bind(note_scope) + .bind("fact") + .bind(None::) + .bind(note_text) + .bind(0.7_f32) + .bind(0.9_f32) + .bind("active") + .bind("v2-test") + .bind(serde_json::json!({ "source": "integration-test" })) + .execute(&state.service.db.pool) + .await + .expect("Failed to seed memory note."); +} + +pub(crate) async fn insert_project_scope_grant( + state: &AppState, + owner_agent_id: &str, + granter_agent_id: &str, +) { + sqlx::query( + "INSERT INTO memory_space_grants ( + grant_id, + tenant_id, + project_id, + scope, + space_owner_agent_id, + grantee_kind, + grantee_agent_id, + granted_by_agent_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + ) + .bind(Uuid::new_v4()) + .bind(TEST_TENANT_ID) + .bind(TEST_PROJECT_ID) + .bind("project_shared") + .bind(owner_agent_id) + .bind("project") + .bind(None::) + .bind(granter_agent_id) + .execute(&state.service.db.pool) + .await + .expect("Failed to seed project scope grant."); +} + +pub(crate) async fn search_session_count(state: &AppState) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM search_sessions") + .fetch_one(&state.service.db.pool) + .await + .expect("Failed to count search sessions.") +} + +pub(crate) async fn active_project_grant_count(state: &AppState, owner_agent_id: &str) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(*) FROM memory_space_grants \ + WHERE tenant_id = $1 AND project_id = $2 AND scope = 'project_shared' \ + AND space_owner_agent_id = $3 AND grantee_kind = 'project' AND revoked_at IS NULL", + ) + .bind(TEST_TENANT_ID) + .bind(TEST_PROJECT_ID) + .bind(owner_agent_id) + .fetch_one(&state.service.db.pool) + .await + .expect("Failed to query project grant count.") +} + +pub(crate) async fn note_scope_and_project_id(state: &AppState, note_id: Uuid) -> (String, String) { + let row: (String, String) = sqlx::query_as( + "SELECT scope, project_id FROM memory_notes WHERE tenant_id = $1 AND note_id = $2", + ) + .bind(TEST_TENANT_ID) + .bind(note_id) + .fetch_one(&state.service.db.pool) + .await + .expect("Failed to query note scope and project id."); + + row +} + +pub(crate) async fn active_org_shared_project_grant_count( + state: &AppState, + owner_agent_id: &str, +) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(*) FROM memory_space_grants \ + WHERE tenant_id = $1 AND project_id = '__org__' AND scope = 'org_shared' \ + AND space_owner_agent_id = $2 AND grantee_kind = 'project' AND revoked_at IS NULL", + ) + .bind(TEST_TENANT_ID) + .bind(owner_agent_id) + .fetch_one(&state.service.db.pool) + .await + .expect("Failed to query org_shared project grant count.") +} + +pub(crate) async fn active_org_shared_project_grant_count_for_project( + state: &AppState, + project_id: &str, + owner_agent_id: &str, +) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(*) FROM memory_space_grants \ + WHERE tenant_id = $1 AND project_id = $2 AND scope = 'org_shared' \ + AND space_owner_agent_id = $3 AND grantee_kind = 'project' AND revoked_at IS NULL", + ) + .bind(TEST_TENANT_ID) + .bind(project_id) + .bind(owner_agent_id) + .fetch_one(&state.service.db.pool) + .await + .expect("Failed to query org_shared project grant count for project.") +} diff --git a/apps/elf-api/tests/http/helpers/org_shared.rs b/apps/elf-api/tests/http/helpers/org_shared.rs new file mode 100644 index 00000000..cdccfad5 --- /dev/null +++ b/apps/elf-api/tests/http/helpers/org_shared.rs @@ -0,0 +1,133 @@ +use axum::{ + Router, + body::{self, Body}, + http::{Request, StatusCode}, +}; +use serde_json::Value; +use tower::util::ServiceExt as _; +use uuid::Uuid; + +use crate::helpers::{self, TEST_PROJECT_ID, TEST_PROJECT_ID_B, TEST_TENANT_ID}; +use elf_api::{routes, state::AppState}; +use elf_config::{SecurityAuthKey, SecurityAuthRole}; +use elf_testkit::TestDatabase; + +pub(crate) async fn org_shared_note_is_visible_across_projects_fixture() +-> Option<(TestDatabase, Router, AppState, Uuid)> { + let (test_db, qdrant_url, collection) = helpers::test_env().await?; + let mut config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "static_keys".to_string(); + config.security.auth_keys = vec![ + SecurityAuthKey { + token_id: "admin-token-id".to_string(), + token: "admin-token".to_string(), + tenant_id: TEST_TENANT_ID.to_string(), + project_id: TEST_PROJECT_ID.to_string(), + agent_id: Some("admin-agent".to_string()), + read_profile: "all_scopes".to_string(), + role: SecurityAuthRole::Admin, + }, + SecurityAuthKey { + token_id: "reader-token-id".to_string(), + token: "reader-token".to_string(), + tenant_id: TEST_TENANT_ID.to_string(), + project_id: TEST_PROJECT_ID_B.to_string(), + agent_id: Some("reader-agent".to_string()), + read_profile: "all_scopes".to_string(), + role: SecurityAuthRole::User, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state.clone()); + let note_id = Uuid::new_v4(); + + helpers::insert_note( + &state, + note_id, + "agent_private", + "admin-agent", + "Fact: org_shared cross-project visibility.", + ) + .await; + + Some((test_db, app, state, note_id)) +} + +pub(crate) async fn list_org_shared_notes_as_reader(app: &Router) -> Value { + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v2/notes?scope=org_shared") + .header("Authorization", "Bearer reader-token") + .body(Body::empty()) + .expect("Failed to build list request."), + ) + .await + .expect("Failed to call notes list."); + + assert_eq!(response.status(), StatusCode::OK); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read list response body."); + + serde_json::from_slice(&body).expect("Failed to parse list response.") +} + +pub(crate) async fn publish_org_shared_note_as_reader_can_see(scope_app: &Router, note_id: Uuid) { + let payload = serde_json::json!({ "space": "org_shared" }).to_string(); + let response = scope_app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/v2/notes/{note_id}/publish")) + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload)) + .expect("Failed to build note publish request."), + ) + .await + .expect("Failed to call notes publish."); + + assert_eq!(response.status(), StatusCode::OK); +} + +pub(crate) async fn assert_note_visible_to_project_reader( + scope_app: &Router, + state: &AppState, + note_id: Uuid, +) { + let (scope, project_id) = helpers::note_scope_and_project_id(state, note_id).await; + + assert_eq!(scope, "org_shared"); + // org_shared note rows live in the synthetic org project, not the request project. + assert_eq!(project_id, "__org__"); + + let org_grant_count = + helpers::active_org_shared_project_grant_count(state, "admin-agent").await; + + assert!(org_grant_count > 0); + + // org_shared grant rows live in '__org__' as well; they should not be written into the request + // project. + let request_project_grant_count = helpers::active_org_shared_project_grant_count_for_project( + state, + TEST_PROJECT_ID, + "admin-agent", + ) + .await; + + assert_eq!(request_project_grant_count, 0); + + let list_after_json = list_org_shared_notes_as_reader(scope_app).await; + let items = list_after_json["items"].as_array().expect("Missing items array."); + let ids: Vec<&str> = items.iter().filter_map(|item| item["note_id"].as_str()).collect(); + let note_id_str = note_id.to_string(); + + assert!(ids.contains(¬e_id_str.as_str())); +} diff --git a/apps/elf-api/tests/http/helpers/payload_level.rs b/apps/elf-api/tests/http/helpers/payload_level.rs new file mode 100644 index 00000000..13f3dc2a --- /dev/null +++ b/apps/elf-api/tests/http/helpers/payload_level.rs @@ -0,0 +1,269 @@ +use std::collections::HashMap; + +use axum::{ + Router, + body::{self, Body}, + http::{Request, StatusCode}, +}; +use qdrant_client::{ + Payload, + qdrant::{Document, PointStruct, UpsertPointsBuilder, Vector}, +}; +use tower::util::ServiceExt as _; +use uuid::Uuid; + +use crate::helpers::{self, TEST_AGENT_A, TEST_PROJECT_ID, TEST_TENANT_ID}; +use elf_api::state::AppState; +use elf_storage::qdrant::{BM25_MODEL, BM25_VECTOR_NAME, DENSE_VECTOR_NAME}; + +pub(crate) async fn create_note_for_payload_level_tests( + app: &Router, + state: &AppState, + text: &str, + source_ref: serde_json::Value, +) -> Uuid { + helpers::init_test_tracing(); + + let payload = serde_json::json!({ + "scope": "agent_private", + "notes": [{ + "type": "fact", + "key": null, + "text": text, + "importance": 0.8, + "confidence": 0.9, + "ttl_days": null, + "source_ref": source_ref, + }] + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/notes/ingest") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_A) + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build note ingest request."), + ) + .await + .expect("Failed to call note ingest."); + let status = response.status(); + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read note ingest response body."); + + assert_eq!( + status, + StatusCode::OK, + "Unexpected note ingest status with body: {}", + String::from_utf8_lossy(&body) + ); + + let json: serde_json::Value = + serde_json::from_slice(&body).expect("Failed to parse note ingest response."); + let note_id = json["results"] + .as_array() + .expect("Missing results array in note ingest response.") + .first() + .and_then(|result| result["note_id"].as_str()) + .expect("Missing note_id in note ingest response."); + let note_id = Uuid::parse_str(note_id).expect("Invalid note_id in note ingest response."); + + index_note_for_payload_level_tests(state, note_id, text).await; + + note_id +} + +pub(crate) async fn index_note_for_payload_level_tests( + state: &AppState, + note_id: Uuid, + text: &str, +) { + let chunk_id = Uuid::new_v4(); + let embedding_version = format!( + "{}:{}:{}", + state.service.cfg.providers.embedding.provider_id, + state.service.cfg.providers.embedding.model, + state.service.cfg.storage.qdrant.vector_dim + ); + + sqlx::query( + "INSERT INTO memory_note_chunks ( + chunk_id, + note_id, + chunk_index, + start_offset, + end_offset, + text, + embedding_version + ) VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(chunk_id) + .bind(note_id) + .bind(0_i32) + .bind(0_i32) + .bind(i32::try_from(text.len()).expect("Payload-level test text fits i32 offsets.")) + .bind(text) + .bind(embedding_version.as_str()) + .execute(&state.service.db.pool) + .await + .expect("Failed to seed memory note chunk."); + + let mut payload = Payload::new(); + + payload.insert("note_id", note_id.to_string()); + payload.insert("chunk_id", chunk_id.to_string()); + payload.insert("chunk_index", 0_i64); + payload.insert("start_offset", 0_i64); + payload.insert("end_offset", i64::try_from(text.len()).expect("Test text fits i64 offsets.")); + payload.insert("tenant_id", TEST_TENANT_ID); + payload.insert("project_id", TEST_PROJECT_ID); + payload.insert("agent_id", TEST_AGENT_A); + payload.insert("scope", "agent_private"); + payload.insert("type", "fact"); + payload.insert("status", "active"); + payload.insert("embedding_version", embedding_version); + + let mut vectors = HashMap::new(); + + vectors.insert( + DENSE_VECTOR_NAME.to_string(), + Vector::from(vec![0.0_f32; state.service.qdrant.vector_dim as usize]), + ); + vectors.insert( + BM25_VECTOR_NAME.to_string(), + Vector::from(Document::new(text.to_string(), BM25_MODEL)), + ); + + let point = PointStruct::new(chunk_id.to_string(), vectors, payload); + + state + .service + .qdrant + .client + .upsert_points( + UpsertPointsBuilder::new(state.service.qdrant.collection.clone(), vec![point]) + .wait(true), + ) + .await + .expect("Failed to seed Qdrant point."); +} + +pub(crate) async fn insert_note_summary_field(state: &AppState, note_id: Uuid, summary: &str) { + sqlx::query( + "INSERT INTO memory_note_fields (field_id, note_id, field_kind, item_index, text) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(Uuid::new_v4()) + .bind(note_id) + .bind("summary") + .bind(0) + .bind(summary) + .execute(&state.service.db.pool) + .await + .expect("Failed to insert note summary field."); +} + +pub(crate) async fn fetch_search_notes_for_payload_level( + app: &Router, + search_id: Uuid, + note_id: Uuid, + payload_level: &str, +) -> serde_json::Value { + let payload = serde_json::json!({ + "note_ids": [note_id], + "payload_level": payload_level, + "record_hits": false, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/v2/searches/{search_id}/notes")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_A) + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build search notes request."), + ) + .await + .expect("Failed to call search notes."); + let status = response.status(); + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read search notes response body."); + + assert_eq!( + status, + StatusCode::OK, + "Unexpected search notes response: {}", + String::from_utf8_lossy(&body) + ); + + let json: serde_json::Value = + serde_json::from_slice(&body).expect("Failed to parse search notes response."); + + json.get("results") + .and_then(serde_json::Value::as_array) + .and_then(|results| results.first()) + .and_then(|result| result.get("note")) + .cloned() + .expect("Expected note in search notes response.") +} + +pub(crate) async fn fetch_admin_search_raw_source_ref( + app: &Router, + query: &str, + payload_level: &str, +) -> serde_json::Value { + let payload = serde_json::json!({ + "mode": "quick_find", + "query": query, + "top_k": 5, + "candidate_k": 10, + "payload_level": payload_level, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/admin/searches/raw") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_A) + .header("X-ELF-Read-Profile", "private_only") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build admin search raw request."), + ) + .await + .expect("Failed to call admin search raw."); + let status = response.status(); + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read admin search raw response body."); + + assert_eq!( + status, + StatusCode::OK, + "Unexpected admin search raw status with body: {}", + String::from_utf8_lossy(&body) + ); + + let json: serde_json::Value = + serde_json::from_slice(&body).expect("Failed to parse admin search raw response."); + let item = json["items"] + .as_array() + .expect("Missing items in admin search raw response.") + .first() + .expect("Expected at least one raw search item."); + + item["source_ref"].clone() +} diff --git a/apps/elf-api/tests/http/helpers/requests.rs b/apps/elf-api/tests/http/helpers/requests.rs new file mode 100644 index 00000000..287fd180 --- /dev/null +++ b/apps/elf-api/tests/http/helpers/requests.rs @@ -0,0 +1,106 @@ +use std::env; + +use axum::{ + Router, + body::{self, Body}, + http::{Request, Response, StatusCode}, +}; +use serde_json::Value; +use tower::util::ServiceExt as _; +use tracing::Level; + +use crate::helpers::{TEST_PROJECT_ID, TEST_TENANT_ID}; +use elf_testkit::TestDatabase; + +pub(crate) fn init_test_tracing() { + let _ = tracing_subscriber::fmt().with_max_level(Level::ERROR).with_test_writer().try_init(); +} + +pub(crate) fn context_request( + method: &str, + uri: impl AsRef, + agent_id: &str, + read_profile: &str, +) -> Request { + Request::builder() + .method(method) + .uri(uri.as_ref()) + .header("content-type", "application/json") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", agent_id) + .header("X-ELF-Read-Profile", read_profile) + .body(Body::empty()) + .expect("Failed to build context request.") +} + +pub(crate) async fn test_env() -> Option<(TestDatabase, String, String)> { + let base_dsn = match elf_testkit::env_dsn() { + Some(value) => value, + None => { + eprintln!("Skipping HTTP tests; set ELF_PG_DSN to run this test."); + + return None; + }, + }; + let qdrant_url = match env::var("ELF_QDRANT_GRPC_URL").or_else(|_| env::var("ELF_QDRANT_URL")) { + Ok(value) => value, + Err(_) => { + eprintln!( + "Skipping HTTP tests; set ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run this test." + ); + + return None; + }, + }; + let test_db = TestDatabase::new(&base_dsn).await.expect("Failed to create test database."); + let collection = test_db.collection_name("elf_http"); + + Some((test_db, qdrant_url, collection)) +} + +pub(crate) async fn post_admin_json( + app: &Router, + uri: impl AsRef, + agent_id: &str, + body: Value, +) -> (StatusCode, Value) { + let request = Request::builder() + .method("POST") + .uri(uri.as_ref()) + .header("content-type", "application/json") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", agent_id) + .body(Body::from(body.to_string())) + .expect("Failed to build admin JSON request."); + let response = app.clone().oneshot(request).await.expect("Failed to call admin route."); + let status = response.status(); + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read admin response body."); + + (status, serde_json::from_slice(&body).expect("Failed to parse admin response.")) +} + +pub(crate) async fn post_with_authorization_and_json_body( + app: &Router, + uri: &str, + auth: &str, + payload: &str, + build_expect: &str, + call_expect: &str, +) -> Response { + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header("Authorization", auth) + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect(build_expect), + ) + .await + .expect(call_expect) +} diff --git a/packages/elf-config/tests/config_validation.rs b/packages/elf-config/tests/config_validation.rs index 26554a07..bc1fa973 100644 --- a/packages/elf-config/tests/config_validation.rs +++ b/packages/elf-config/tests/config_validation.rs @@ -2,751 +2,11 @@ //! Config validation tests for the ELF configuration loader. -use std::{ - collections::HashMap, - env, fs, - path::PathBuf, - process, - sync::atomic::{AtomicU64, Ordering}, - time::{SystemTime, UNIX_EPOCH}, -}; - -use toml::Value; - -use elf_config::{self, Config, Context, Error, MemoryPolicyRule}; - -const SAMPLE_CONFIG_TEMPLATE_TOML: &str = include_str!("fixtures/sample_config.template.toml"); -const TRACE_GATE_CONFIG_TOML: &str = - include_str!("../../../.github/fixtures/trace_gate/config.toml"); - -fn sample_toml(reject_non_english: bool) -> String { - sample_toml_with_recursive(reject_non_english, false, 2, 4, 32, 256) -} - -fn sample_toml_with_recursive( - reject_non_english: bool, - recursive_enabled: bool, - max_depth: i64, - max_children_per_node: i64, - max_nodes_per_scope: i64, - max_total_nodes: i64, -) -> String { - let mut value: Value = - toml::from_str(SAMPLE_CONFIG_TEMPLATE_TOML).expect("Failed to parse template config."); - let root = value.as_table_mut().expect("Template config must be a table."); - let search = root - .get_mut("search") - .and_then(Value::as_table_mut) - .expect("Template config must include [search]."); - let recursive = search - .get_mut("recursive") - .and_then(Value::as_table_mut) - .expect("Template config must include [search.recursive]."); - - recursive.insert("enabled".to_string(), Value::Boolean(recursive_enabled)); - recursive.insert("max_depth".to_string(), Value::Integer(max_depth)); - recursive.insert("max_children_per_node".to_string(), Value::Integer(max_children_per_node)); - recursive.insert("max_nodes_per_scope".to_string(), Value::Integer(max_nodes_per_scope)); - recursive.insert("max_total_nodes".to_string(), Value::Integer(max_total_nodes)); - - let security = root - .get_mut("security") - .and_then(Value::as_table_mut) - .expect("Template config must include [security]."); - - security.insert("reject_non_english".to_string(), Value::Boolean(reject_non_english)); - - toml::to_string(&value).expect("Failed to render template config.") -} - -fn sample_toml_with_cache( - reject_non_english: bool, - expansion_ttl_days: i64, - rerank_ttl_days: i64, - cache_enabled: bool, -) -> String { - let mut value: Value = - toml::from_str(&sample_toml_with_recursive(reject_non_english, false, 2, 4, 32, 256)) - .expect("Failed to parse template config."); - let root = value.as_table_mut().expect("Template config must be a table."); - let search = root - .get_mut("search") - .and_then(Value::as_table_mut) - .expect("Template config must include [search]."); - let cache = search - .get_mut("cache") - .and_then(Value::as_table_mut) - .expect("Template config must include [search.cache]."); - - cache.insert("enabled".to_string(), Value::Boolean(cache_enabled)); - cache.insert("expansion_ttl_days".to_string(), Value::Integer(expansion_ttl_days)); - cache.insert("rerank_ttl_days".to_string(), Value::Integer(rerank_ttl_days)); - - toml::to_string(&value).expect("Failed to render template config.") -} - -fn write_temp_config(payload: String) -> PathBuf { - static COUNTER: AtomicU64 = AtomicU64::new(0); - - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("System time must be valid.") - .as_nanos(); - let ordinal = COUNTER.fetch_add(1, Ordering::SeqCst); - let pid = process::id(); - let mut path = env::temp_dir(); - - path.push(format!("elf_config_test_{nanos}_{pid}_{ordinal}.toml")); - - fs::write(&path, payload).expect("Failed to write test config."); - - path -} - -fn remove_required_config_key(payload: &str, path: &[&str]) -> String { - assert!(!path.is_empty(), "Config path must not be empty."); - - let mut value: Value = toml::from_str(payload).expect("Failed to parse test config."); - let mut table = value.as_table_mut().expect("Template config must be a table."); - - for segment in &path[..path.len() - 1] { - table = table - .get_mut(*segment) - .and_then(Value::as_table_mut) - .unwrap_or_else(|| panic!("Template config must include [{}].", segment)); - } - - let field = path[path.len() - 1]; - let removed = table.remove(field); - - assert!(removed.is_some(), "Template config must include {}.", path.join(".")); - - toml::to_string(&value).expect("Failed to render template config.") -} - -fn assert_missing_field_error(result: Result, field: &str) { - let err = result.expect_err("Expected missing required field parse error."); - let message = match err { - Error::ParseConfig { source, .. } => source.to_string(), - err => panic!("Expected parse config error, got {err}"), - }; - - assert!(message.contains(&format!("missing field `{field}`")), "Unexpected error: {message}"); -} - -fn base_config() -> Config { - let payload = sample_toml(true); - - toml::from_str(&payload).expect("Failed to parse test config.") -} - -#[test] -fn required_config_fields_must_be_explicit() { - let cases = [ - (&["storage", "qdrant", "docs_collection"][..], "docs_collection"), - (&["memory", "policy"][..], "policy"), - (&["search", "recursive"][..], "recursive"), - (&["search", "graph_context"][..], "graph_context"), - (&["security", "auth_keys"][..], "auth_keys"), - ]; - - for (path, field) in cases { - let payload = remove_required_config_key(&sample_toml(true), path); - let config_path = write_temp_config(payload); - let result = elf_config::load(&config_path); - - fs::remove_file(&config_path).expect("Failed to remove test config."); - - assert_missing_field_error(result, field); - } -} - -#[test] -fn docker_local_config_is_strict_valid() { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../config/local/elf.docker.toml"); - let cfg = elf_config::load(path.as_path()).expect("Docker local config must load."); - - assert_eq!( - cfg.storage.postgres.dsn, - "postgres://elf_dev:elf_dev_password@127.0.0.1:51888/elf_local" - ); - assert_eq!(cfg.storage.qdrant.url, "http://127.0.0.1:51890"); - assert_eq!(cfg.storage.qdrant.collection, "elf_local_notes"); - assert_eq!(cfg.storage.qdrant.docs_collection, "elf_local_doc_chunks"); - assert_eq!(cfg.providers.embedding.provider_id, "local"); - assert_eq!(cfg.providers.rerank.provider_id, "local"); - assert_eq!(cfg.search.expansion.mode, "off"); -} - -#[test] -fn reject_non_english_must_be_true() { - let payload = sample_toml(false); - let path = write_temp_config(payload); - let result = elf_config::load(&path); - - fs::remove_file(&path).expect("Failed to remove test config."); - - let err = result.expect_err("Expected reject_non_english validation error."); - let message = err.to_string(); - - assert!( - message.contains("security.reject_non_english must be true."), - "Unexpected error message: {message}" - ); -} - -#[test] -fn cache_ttl_must_be_positive() { - let payload = sample_toml_with_cache(true, 0, 7, true); - let path = write_temp_config(payload); - let result = elf_config::load(&path); - - fs::remove_file(&path).expect("Failed to remove test config."); - - let err = result.expect_err("Expected cache TTL validation error."); - - assert!( - err.to_string().contains("search.cache.expansion_ttl_days must be greater than zero."), - "Unexpected error: {err}" - ); -} - -#[test] -fn recursive_search_settings_can_be_valid() { - let mut cfg = base_config(); - - cfg.search.recursive.enabled = true; - cfg.search.recursive.max_depth = 4; - cfg.search.recursive.max_children_per_node = 12; - cfg.search.recursive.max_nodes_per_scope = 64; - cfg.search.recursive.max_total_nodes = 120; - - assert!(elf_config::validate(&cfg).is_ok()); -} - -#[test] -fn recursive_search_settings_require_valid_depth_bounds() { - let mut cfg = base_config(); - - cfg.search.recursive.enabled = true; - cfg.search.recursive.max_depth = 0; - - let err = - elf_config::validate(&cfg).expect_err("Expected recursive max_depth validation error."); - - assert!( - err.to_string().contains("search.recursive.max_depth must be greater than zero."), - "Unexpected error: {err}" - ); -} - -#[test] -fn recursive_search_settings_require_reasonable_bounds() { - let mut cfg = base_config(); - - cfg.search.recursive.enabled = true; - cfg.search.recursive.max_children_per_node = 0; - - let err = - elf_config::validate(&cfg).expect_err("Expected recursive branch factor validation error."); - - assert!( - err.to_string() - .contains("search.recursive.max_children_per_node must be greater than zero."), - "Unexpected error: {err}" - ); - - cfg = base_config(); - cfg.search.recursive.enabled = true; - cfg.search.recursive.max_total_nodes = 8; - cfg.search.recursive.max_nodes_per_scope = 12; - - let err = elf_config::validate(&cfg) - .expect_err("Expected recursive max_total_nodes lower-bound validation error."); - - assert!( - err.to_string().contains( - "search.recursive.max_total_nodes must be at least search.recursive.max_nodes_per_scope." - ), - "Unexpected error: {err}" - ); -} - -#[test] -fn graph_context_settings_max_facts_per_item_must_be_positive_when_enabled() { - let mut cfg = base_config(); - - cfg.search.graph_context.enabled = true; - cfg.search.graph_context.max_facts_per_item = 0; - - let err = elf_config::validate(&cfg) - .expect_err("Expected graph_context max_facts_per_item validation error."); - - assert!( - err.to_string() - .contains("search.graph_context.max_facts_per_item must be greater than zero."), - "Unexpected error: {err}" - ); -} - -#[test] -fn graph_context_settings_max_evidence_notes_per_fact_must_be_positive_when_enabled() { - let mut cfg = base_config(); - - cfg.search.graph_context.enabled = true; - cfg.search.graph_context.max_evidence_notes_per_fact = 0; - - let err = elf_config::validate(&cfg) - .expect_err("Expected graph_context max_evidence_notes_per_fact validation error."); - - assert!( - err.to_string().contains( - "search.graph_context.max_evidence_notes_per_fact must be greater than zero." - ), - "Unexpected error: {err}" - ); -} - -#[test] -fn graph_context_settings_max_facts_per_item_cannot_exceed_hard_limit() { - let mut cfg = base_config(); - - cfg.search.graph_context.enabled = true; - cfg.search.graph_context.max_facts_per_item = 1_001; - - let err = elf_config::validate(&cfg) - .expect_err("Expected graph_context max_facts_per_item upper-bound validation error."); - - assert!( - err.to_string().contains("search.graph_context.max_facts_per_item must be 1,000 or less."), - "Unexpected error: {err}" - ); -} - -#[test] -fn graph_context_settings_max_evidence_notes_per_fact_cannot_exceed_hard_limit() { - let mut cfg = base_config(); - - cfg.search.graph_context.enabled = true; - cfg.search.graph_context.max_evidence_notes_per_fact = 1_001; - - let err = elf_config::validate(&cfg).expect_err( - "Expected graph_context max_evidence_notes_per_fact upper-bound validation error.", - ); - - assert!( - err.to_string() - .contains("search.graph_context.max_evidence_notes_per_fact must be 1,000 or less."), - "Unexpected error: {err}" - ); -} - -#[test] -fn chunking_config_requires_valid_bounds() { - let mut cfg = base_config(); - - cfg.chunking.max_tokens = 0; - - assert!(elf_config::validate(&cfg).is_err()); - - cfg = base_config(); - cfg.chunking.overlap_tokens = cfg.chunking.max_tokens; - - assert!(elf_config::validate(&cfg).is_err()); -} - -#[test] -fn chunking_tokenizer_repo_cannot_be_empty_or_whitespace() { - let mut payload = sample_toml(true); - - payload = payload.replace("tokenizer_repo = \"REPLACE_ME\"", "tokenizer_repo = \" \""); - - let path = write_temp_config(payload); - let err = elf_config::load(&path).expect_err("Expected tokenizer validation error."); - - fs::remove_file(&path).expect("Failed to remove test config."); - - assert!(err.to_string().contains("chunking.tokenizer_repo must be a non-empty string.")); -} - -#[test] -fn chunking_tokenizer_repo_is_required() { - let mut payload = sample_toml(true); - - payload = payload.replace("tokenizer_repo = \"REPLACE_ME\"\n", ""); - - let path = write_temp_config(payload); - let err = elf_config::load(&path).expect_err("Expected missing tokenizer_repo parse error."); - - fs::remove_file(&path).expect("Failed to remove test config."); - - let message = match err { - Error::ParseConfig { source, .. } => source.to_string(), - err => panic!("Expected parse config error, got {err}"), - }; - - assert!( - message.contains("missing field `tokenizer_repo`") - || message.contains("missing field `tokenizer repo`"), - "Unexpected error: {message}" - ); -} - -#[test] -fn context_scope_boost_weight_requires_scope_descriptions_when_enabled() { - let mut cfg = base_config(); - - cfg.context = Some(Context { - project_descriptions: None, - scope_descriptions: None, - scope_boost_weight: Some(0.1), - }); - - let err = elf_config::validate(&cfg).expect_err("Expected context validation error."); - - assert!( - err.to_string().contains( - "context.scope_descriptions must be non-empty when context.scope_boost_weight is greater than zero." - ), - "Unexpected error: {err}" - ); -} - -#[test] -fn context_scope_boost_weight_accepts_zero_without_descriptions() { - let mut cfg = base_config(); - - cfg.context = Some(Context { - project_descriptions: None, - scope_descriptions: None, - scope_boost_weight: Some(0.0), - }); - - assert!(elf_config::validate(&cfg).is_ok()); -} - -#[test] -fn context_scope_boost_weight_must_be_finite() { - let mut cfg = base_config(); - let mut scope_descriptions = HashMap::new(); - - scope_descriptions.insert("project_shared".to_string(), "Project notes.".to_string()); - - cfg.context = Some(Context { - project_descriptions: None, - scope_descriptions: Some(scope_descriptions), - scope_boost_weight: Some(f32::NAN), - }); - - let err = elf_config::validate(&cfg).expect_err("Expected context validation error."); - - assert!( - err.to_string().contains("context.scope_boost_weight must be a finite number."), - "Unexpected error: {err}" - ); -} - -#[test] -fn context_scope_boost_weight_must_be_in_range() { - let mut cfg = base_config(); - let mut scope_descriptions = HashMap::new(); - - scope_descriptions.insert("project_shared".to_string(), "Project notes.".to_string()); - - cfg.context = Some(Context { - project_descriptions: None, - scope_descriptions: Some(scope_descriptions.clone()), - scope_boost_weight: Some(-0.01), - }); - - let err = elf_config::validate(&cfg).expect_err("Expected context validation error."); - - assert!( - err.to_string().contains("context.scope_boost_weight must be zero or greater."), - "Unexpected error: {err}" - ); - - cfg.context = Some(Context { - project_descriptions: None, - scope_descriptions: Some(scope_descriptions), - scope_boost_weight: Some(1.01), - }); - - let err = elf_config::validate(&cfg).expect_err("Expected context validation error."); - - assert!( - err.to_string().contains("context.scope_boost_weight must be 1.0 or less."), - "Unexpected error: {err}" - ); -} - -#[test] -fn elf_example_toml_is_valid() { - let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - - path.push("../../elf.example.toml"); - - elf_config::load(&path).expect("Expected elf.example.toml to be a valid config."); -} - -#[test] -fn trace_gate_fixture_toml_is_valid() { - let path = write_temp_config(TRACE_GATE_CONFIG_TOML.to_string()); - - elf_config::load(&path).expect("Expected trace gate fixture config to be valid."); - fs::remove_file(&path).expect("Failed to remove test config."); -} - -#[test] -fn retrieval_source_weights_must_be_non_negative() { - let mut cfg = base_config(); - - cfg.ranking.retrieval_sources.fusion_weight = -0.1; - - let err = - elf_config::validate(&cfg).expect_err("Expected retrieval source weight validation error."); - - assert!( - err.to_string() - .contains("ranking.retrieval_sources.fusion_weight must be zero or greater."), - "Unexpected error: {err}" - ); -} - -#[test] -fn retrieval_source_weights_require_at_least_one_positive() { - let mut cfg = base_config(); - - cfg.ranking.retrieval_sources.fusion_weight = 0.0; - cfg.ranking.retrieval_sources.structured_field_weight = 0.0; - - let err = elf_config::validate(&cfg) - .expect_err("Expected retrieval source at-least-one-positive validation error."); - - assert!( - err.to_string().contains("At least one retrieval source weight must be greater than zero."), - "Unexpected error: {err}" - ); -} - -#[test] -fn security_auth_keys_require_unique_token_ids() { - let mut cfg = base_config(); - - cfg.security.auth_mode = "static_keys".to_string(); - cfg.security.auth_keys = vec![ - elf_config::SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret-1".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: elf_config::SecurityAuthRole::User, - }, - elf_config::SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret-2".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: elf_config::SecurityAuthRole::Admin, - }, - ]; - - let err = - elf_config::validate(&cfg).expect_err("Expected duplicate token_id validation error."); - - assert!( - err.to_string().contains("token_id must be unique across security.auth_keys."), - "Unexpected error: {err}" - ); -} - -#[test] -fn security_auth_keys_require_known_read_profile() { - let mut cfg = base_config(); - - cfg.security.auth_mode = "static_keys".to_string(); - cfg.security.auth_keys = vec![elf_config::SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret-1".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "unknown".to_string(), - role: elf_config::SecurityAuthRole::User, - }]; - - let err = - elf_config::validate(&cfg).expect_err("Expected auth key read_profile validation error."); - - assert!( - err.to_string().contains( - "read_profile must be one of private_only, private_plus_project, or all_scopes." - ), - "Unexpected error: {err}" - ); -} - -#[test] -fn memory_policy_min_confidence_must_be_finite() { - let mut cfg = base_config(); - - cfg.memory - .policy - .rules - .push(MemoryPolicyRule { min_confidence: Some(f32::NAN), ..Default::default() }); - - let err = elf_config::validate(&cfg).expect_err("Expected min_confidence validation error."); - - assert!( - err.to_string().contains("memory.policy.rules[1].min_confidence must be a finite number."), - "Unexpected error: {err}" - ); -} - -#[test] -fn memory_policy_min_confidence_must_be_in_range() { - let mut cfg = base_config(); - - cfg.memory - .policy - .rules - .push(MemoryPolicyRule { min_confidence: Some(1.01), ..Default::default() }); - - let err = - elf_config::validate(&cfg).expect_err("Expected min_confidence range validation error."); - - assert!( - err.to_string() - .contains("memory.policy.rules[1].min_confidence must be between 0.0 and 1.0."), - "Unexpected error: {err}" - ); -} - -#[test] -fn memory_policy_min_importance_must_be_finite() { - let mut cfg = base_config(); - - cfg.memory - .policy - .rules - .push(MemoryPolicyRule { min_importance: Some(f32::INFINITY), ..Default::default() }); - - let err = elf_config::validate(&cfg).expect_err("Expected min_importance validation error."); - - assert!( - err.to_string().contains("memory.policy.rules[1].min_importance must be a finite number."), - "Unexpected error: {err}" - ); -} - -#[test] -fn memory_policy_min_importance_must_be_in_range() { - let mut cfg = base_config(); - - cfg.memory - .policy - .rules - .push(MemoryPolicyRule { min_importance: Some(-0.01), ..Default::default() }); - - let err = - elf_config::validate(&cfg).expect_err("Expected min_importance range validation error."); - - assert!( - err.to_string() - .contains("memory.policy.rules[1].min_importance must be between 0.0 and 1.0."), - "Unexpected error: {err}" - ); -} - -#[test] -fn memory_policy_note_type_must_be_known_value() { - let mut cfg = base_config(); - - cfg.memory - .policy - .rules - .push(MemoryPolicyRule { note_type: Some("unknown".to_string()), ..Default::default() }); - - let err = elf_config::validate(&cfg).expect_err("Expected note_type validation error."); - - assert!( - err.to_string().contains( - "memory.policy.rules[1].note_type must be one of preference, constraint, decision, profile, fact, or plan." - ), - "Unexpected error: {err}" - ); -} - -#[test] -fn memory_policy_scope_must_be_allowed() { - let mut cfg = base_config(); - - cfg.memory - .policy - .rules - .push(MemoryPolicyRule { scope: Some("invalid_scope".to_string()), ..Default::default() }); - - let err = elf_config::validate(&cfg).expect_err("Expected scope validation error."); - - assert!( - err.to_string().contains("memory.policy.rules[1].scope must be one of allowed scopes."), - "Unexpected error: {err}" - ); -} - -#[test] -fn memory_policy_rule_pairs_must_be_unique() { - let mut cfg = base_config(); - - cfg.memory.policy.rules.push(Default::default()); - cfg.memory.policy.rules.push(Default::default()); - - let err = elf_config::validate(&cfg).expect_err("Expected duplicate rule validation error."); - - assert!( - err.to_string() - .contains("memory.policy.rules[2] has a duplicate note_type and scope pair."), - "Unexpected error: {err}" - ); -} - -#[test] -fn memory_policy_note_type_must_not_be_whitespace_only() { - let mut cfg = base_config(); - - cfg.memory - .policy - .rules - .push(MemoryPolicyRule { note_type: Some(" ".to_string()), ..Default::default() }); - - let err = - elf_config::validate(&cfg).expect_err("Expected whitespace note_type validation error."); - - assert!( - err.to_string() - .contains("memory.policy.rules[1].note_type cannot be blank or whitespace-only."), - "Unexpected error: {err}" - ); -} - -#[test] -fn memory_policy_scope_must_not_be_whitespace_only() { - let mut cfg = base_config(); - - cfg.memory - .policy - .rules - .push(MemoryPolicyRule { scope: Some(" ".to_string()), ..Default::default() }); - - let err = elf_config::validate(&cfg).expect_err("Expected whitespace scope validation error."); - - assert!( - err.to_string() - .contains("memory.policy.rules[1].scope cannot be blank or whitespace-only."), - "Unexpected error: {err}" - ); -} +#[path = "config_validation/chunking.rs"] mod chunking; +#[path = "config_validation/context.rs"] mod context; +#[path = "config_validation/core.rs"] mod core; +#[path = "config_validation/helpers.rs"] mod helpers; +#[path = "config_validation/memory_policy.rs"] mod memory_policy; +#[path = "config_validation/ranking.rs"] mod ranking; +#[path = "config_validation/search.rs"] mod search; +#[path = "config_validation/security.rs"] mod security; diff --git a/packages/elf-config/tests/config_validation/chunking.rs b/packages/elf-config/tests/config_validation/chunking.rs new file mode 100644 index 00000000..a6475d36 --- /dev/null +++ b/packages/elf-config/tests/config_validation/chunking.rs @@ -0,0 +1,55 @@ +use std::fs; + +use crate::helpers; +use elf_config::Error; + +#[test] +fn chunking_config_requires_valid_bounds() { + let mut cfg = helpers::base_config(); + + cfg.chunking.max_tokens = 0; + + assert!(elf_config::validate(&cfg).is_err()); + + cfg = helpers::base_config(); + cfg.chunking.overlap_tokens = cfg.chunking.max_tokens; + + assert!(elf_config::validate(&cfg).is_err()); +} + +#[test] +fn chunking_tokenizer_repo_cannot_be_empty_or_whitespace() { + let mut payload = helpers::sample_toml(true); + + payload = payload.replace("tokenizer_repo = \"REPLACE_ME\"", "tokenizer_repo = \" \""); + + let path = helpers::write_temp_config(payload); + let err = elf_config::load(&path).expect_err("Expected tokenizer validation error."); + + fs::remove_file(&path).expect("Failed to remove test config."); + + assert!(err.to_string().contains("chunking.tokenizer_repo must be a non-empty string.")); +} + +#[test] +fn chunking_tokenizer_repo_is_required() { + let mut payload = helpers::sample_toml(true); + + payload = payload.replace("tokenizer_repo = \"REPLACE_ME\"\n", ""); + + let path = helpers::write_temp_config(payload); + let err = elf_config::load(&path).expect_err("Expected missing tokenizer_repo parse error."); + + fs::remove_file(&path).expect("Failed to remove test config."); + + let message = match err { + Error::ParseConfig { source, .. } => source.to_string(), + err => panic!("Expected parse config error, got {err}"), + }; + + assert!( + message.contains("missing field `tokenizer_repo`") + || message.contains("missing field `tokenizer repo`"), + "Unexpected error: {message}" + ); +} diff --git a/packages/elf-config/tests/config_validation/context.rs b/packages/elf-config/tests/config_validation/context.rs new file mode 100644 index 00000000..5ee4d6e9 --- /dev/null +++ b/packages/elf-config/tests/config_validation/context.rs @@ -0,0 +1,92 @@ +use std::collections::HashMap; + +use crate::helpers; +use elf_config::Context; + +#[test] +fn context_scope_boost_weight_requires_scope_descriptions_when_enabled() { + let mut cfg = helpers::base_config(); + + cfg.context = Some(Context { + project_descriptions: None, + scope_descriptions: None, + scope_boost_weight: Some(0.1), + }); + + let err = elf_config::validate(&cfg).expect_err("Expected context validation error."); + + assert!( + err.to_string().contains( + "context.scope_descriptions must be non-empty when context.scope_boost_weight is greater than zero." + ), + "Unexpected error: {err}" + ); +} + +#[test] +fn context_scope_boost_weight_accepts_zero_without_descriptions() { + let mut cfg = helpers::base_config(); + + cfg.context = Some(Context { + project_descriptions: None, + scope_descriptions: None, + scope_boost_weight: Some(0.0), + }); + + assert!(elf_config::validate(&cfg).is_ok()); +} + +#[test] +fn context_scope_boost_weight_must_be_finite() { + let mut cfg = helpers::base_config(); + let mut scope_descriptions = HashMap::new(); + + scope_descriptions.insert("project_shared".to_string(), "Project notes.".to_string()); + + cfg.context = Some(Context { + project_descriptions: None, + scope_descriptions: Some(scope_descriptions), + scope_boost_weight: Some(f32::NAN), + }); + + let err = elf_config::validate(&cfg).expect_err("Expected context validation error."); + + assert!( + err.to_string().contains("context.scope_boost_weight must be a finite number."), + "Unexpected error: {err}" + ); +} + +#[test] +fn context_scope_boost_weight_must_be_in_range() { + let mut cfg = helpers::base_config(); + let mut scope_descriptions = HashMap::new(); + + scope_descriptions.insert("project_shared".to_string(), "Project notes.".to_string()); + + cfg.context = Some(Context { + project_descriptions: None, + scope_descriptions: Some(scope_descriptions.clone()), + scope_boost_weight: Some(-0.01), + }); + + let err = elf_config::validate(&cfg).expect_err("Expected context validation error."); + + assert!( + err.to_string().contains("context.scope_boost_weight must be zero or greater."), + "Unexpected error: {err}" + ); + + cfg.context = Some(Context { + project_descriptions: None, + scope_descriptions: Some(scope_descriptions), + scope_boost_weight: Some(1.01), + }); + + let err = elf_config::validate(&cfg).expect_err("Expected context validation error."); + + assert!( + err.to_string().contains("context.scope_boost_weight must be 1.0 or less."), + "Unexpected error: {err}" + ); +} diff --git a/packages/elf-config/tests/config_validation/core.rs b/packages/elf-config/tests/config_validation/core.rs new file mode 100644 index 00000000..70e0aee9 --- /dev/null +++ b/packages/elf-config/tests/config_validation/core.rs @@ -0,0 +1,74 @@ +use std::{env, fs, path::PathBuf}; + +use crate::helpers::{self, TRACE_GATE_CONFIG_TOML}; + +#[test] +fn required_config_fields_must_be_explicit() { + let cases = [ + (&["storage", "qdrant", "docs_collection"][..], "docs_collection"), + (&["memory", "policy"][..], "policy"), + (&["search", "recursive"][..], "recursive"), + (&["search", "graph_context"][..], "graph_context"), + (&["security", "auth_keys"][..], "auth_keys"), + ]; + + for (path, field) in cases { + let payload = helpers::remove_required_config_key(&helpers::sample_toml(true), path); + let config_path = helpers::write_temp_config(payload); + let result = elf_config::load(&config_path); + + fs::remove_file(&config_path).expect("Failed to remove test config."); + helpers::assert_missing_field_error(result, field); + } +} + +#[test] +fn docker_local_config_is_strict_valid() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../config/local/elf.docker.toml"); + let cfg = elf_config::load(path.as_path()).expect("Docker local config must load."); + + assert_eq!( + cfg.storage.postgres.dsn, + "postgres://elf_dev:elf_dev_password@127.0.0.1:51888/elf_local" + ); + assert_eq!(cfg.storage.qdrant.url, "http://127.0.0.1:51890"); + assert_eq!(cfg.storage.qdrant.collection, "elf_local_notes"); + assert_eq!(cfg.storage.qdrant.docs_collection, "elf_local_doc_chunks"); + assert_eq!(cfg.providers.embedding.provider_id, "local"); + assert_eq!(cfg.providers.rerank.provider_id, "local"); + assert_eq!(cfg.search.expansion.mode, "off"); +} + +#[test] +fn reject_non_english_must_be_true() { + let payload = helpers::sample_toml(false); + let path = helpers::write_temp_config(payload); + let result = elf_config::load(&path); + + fs::remove_file(&path).expect("Failed to remove test config."); + + let err = result.expect_err("Expected reject_non_english validation error."); + let message = err.to_string(); + + assert!( + message.contains("security.reject_non_english must be true."), + "Unexpected error message: {message}" + ); +} + +#[test] +fn elf_example_toml_is_valid() { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + path.push("../../elf.example.toml"); + + elf_config::load(&path).expect("Expected elf.example.toml to be a valid config."); +} + +#[test] +fn trace_gate_fixture_toml_is_valid() { + let path = helpers::write_temp_config(TRACE_GATE_CONFIG_TOML.to_string()); + + elf_config::load(&path).expect("Expected trace gate fixture config to be valid."); + fs::remove_file(&path).expect("Failed to remove test config."); +} diff --git a/packages/elf-config/tests/config_validation/helpers.rs b/packages/elf-config/tests/config_validation/helpers.rs new file mode 100644 index 00000000..0abb69f2 --- /dev/null +++ b/packages/elf-config/tests/config_validation/helpers.rs @@ -0,0 +1,137 @@ +use std::{ + env, fs, + path::PathBuf, + process, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use toml::Value; + +use elf_config::{Config, Error}; + +pub(crate) const TRACE_GATE_CONFIG_TOML: &str = + include_str!("../../../../.github/fixtures/trace_gate/config.toml"); +pub(crate) const SAMPLE_CONFIG_TEMPLATE_TOML: &str = + include_str!("../fixtures/sample_config.template.toml"); + +pub(crate) fn sample_toml(reject_non_english: bool) -> String { + sample_toml_with_recursive(reject_non_english, false, 2, 4, 32, 256) +} + +pub(crate) fn sample_toml_with_recursive( + reject_non_english: bool, + recursive_enabled: bool, + max_depth: i64, + max_children_per_node: i64, + max_nodes_per_scope: i64, + max_total_nodes: i64, +) -> String { + let mut value: Value = + toml::from_str(SAMPLE_CONFIG_TEMPLATE_TOML).expect("Failed to parse template config."); + let root = value.as_table_mut().expect("Template config must be a table."); + let search = root + .get_mut("search") + .and_then(Value::as_table_mut) + .expect("Template config must include [search]."); + let recursive = search + .get_mut("recursive") + .and_then(Value::as_table_mut) + .expect("Template config must include [search.recursive]."); + + recursive.insert("enabled".to_string(), Value::Boolean(recursive_enabled)); + recursive.insert("max_depth".to_string(), Value::Integer(max_depth)); + recursive.insert("max_children_per_node".to_string(), Value::Integer(max_children_per_node)); + recursive.insert("max_nodes_per_scope".to_string(), Value::Integer(max_nodes_per_scope)); + recursive.insert("max_total_nodes".to_string(), Value::Integer(max_total_nodes)); + + let security = root + .get_mut("security") + .and_then(Value::as_table_mut) + .expect("Template config must include [security]."); + + security.insert("reject_non_english".to_string(), Value::Boolean(reject_non_english)); + + toml::to_string(&value).expect("Failed to render template config.") +} + +pub(crate) fn sample_toml_with_cache( + reject_non_english: bool, + expansion_ttl_days: i64, + rerank_ttl_days: i64, + cache_enabled: bool, +) -> String { + let mut value: Value = + toml::from_str(&sample_toml_with_recursive(reject_non_english, false, 2, 4, 32, 256)) + .expect("Failed to parse template config."); + let root = value.as_table_mut().expect("Template config must be a table."); + let search = root + .get_mut("search") + .and_then(Value::as_table_mut) + .expect("Template config must include [search]."); + let cache = search + .get_mut("cache") + .and_then(Value::as_table_mut) + .expect("Template config must include [search.cache]."); + + cache.insert("enabled".to_string(), Value::Boolean(cache_enabled)); + cache.insert("expansion_ttl_days".to_string(), Value::Integer(expansion_ttl_days)); + cache.insert("rerank_ttl_days".to_string(), Value::Integer(rerank_ttl_days)); + + toml::to_string(&value).expect("Failed to render template config.") +} + +pub(crate) fn write_temp_config(payload: String) -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("System time must be valid.") + .as_nanos(); + let ordinal = COUNTER.fetch_add(1, Ordering::SeqCst); + let pid = process::id(); + let mut path = env::temp_dir(); + + path.push(format!("elf_config_test_{nanos}_{pid}_{ordinal}.toml")); + + fs::write(&path, payload).expect("Failed to write test config."); + + path +} + +pub(crate) fn remove_required_config_key(payload: &str, path: &[&str]) -> String { + assert!(!path.is_empty(), "Config path must not be empty."); + + let mut value: Value = toml::from_str(payload).expect("Failed to parse test config."); + let mut table = value.as_table_mut().expect("Template config must be a table."); + + for segment in &path[..path.len() - 1] { + table = table + .get_mut(*segment) + .and_then(Value::as_table_mut) + .unwrap_or_else(|| panic!("Template config must include [{}].", segment)); + } + + let field = path[path.len() - 1]; + let removed = table.remove(field); + + assert!(removed.is_some(), "Template config must include {}.", path.join(".")); + + toml::to_string(&value).expect("Failed to render template config.") +} + +pub(crate) fn assert_missing_field_error(result: Result, field: &str) { + let err = result.expect_err("Expected missing required field parse error."); + let message = match err { + Error::ParseConfig { source, .. } => source.to_string(), + err => panic!("Expected parse config error, got {err}"), + }; + + assert!(message.contains(&format!("missing field `{field}`")), "Unexpected error: {message}"); +} + +pub(crate) fn base_config() -> Config { + let payload = sample_toml(true); + + toml::from_str(&payload).expect("Failed to parse test config.") +} diff --git a/packages/elf-config/tests/config_validation/memory_policy.rs b/packages/elf-config/tests/config_validation/memory_policy.rs new file mode 100644 index 00000000..c22894fb --- /dev/null +++ b/packages/elf-config/tests/config_validation/memory_policy.rs @@ -0,0 +1,163 @@ +use crate::helpers; +use elf_config::MemoryPolicyRule; + +#[test] +fn memory_policy_min_confidence_must_be_finite() { + let mut cfg = helpers::base_config(); + + cfg.memory + .policy + .rules + .push(MemoryPolicyRule { min_confidence: Some(f32::NAN), ..Default::default() }); + + let err = elf_config::validate(&cfg).expect_err("Expected min_confidence validation error."); + + assert!( + err.to_string().contains("memory.policy.rules[1].min_confidence must be a finite number."), + "Unexpected error: {err}" + ); +} + +#[test] +fn memory_policy_min_confidence_must_be_in_range() { + let mut cfg = helpers::base_config(); + + cfg.memory + .policy + .rules + .push(MemoryPolicyRule { min_confidence: Some(1.01), ..Default::default() }); + + let err = + elf_config::validate(&cfg).expect_err("Expected min_confidence range validation error."); + + assert!( + err.to_string() + .contains("memory.policy.rules[1].min_confidence must be between 0.0 and 1.0."), + "Unexpected error: {err}" + ); +} + +#[test] +fn memory_policy_min_importance_must_be_finite() { + let mut cfg = helpers::base_config(); + + cfg.memory + .policy + .rules + .push(MemoryPolicyRule { min_importance: Some(f32::INFINITY), ..Default::default() }); + + let err = elf_config::validate(&cfg).expect_err("Expected min_importance validation error."); + + assert!( + err.to_string().contains("memory.policy.rules[1].min_importance must be a finite number."), + "Unexpected error: {err}" + ); +} + +#[test] +fn memory_policy_min_importance_must_be_in_range() { + let mut cfg = helpers::base_config(); + + cfg.memory + .policy + .rules + .push(MemoryPolicyRule { min_importance: Some(-0.01), ..Default::default() }); + + let err = + elf_config::validate(&cfg).expect_err("Expected min_importance range validation error."); + + assert!( + err.to_string() + .contains("memory.policy.rules[1].min_importance must be between 0.0 and 1.0."), + "Unexpected error: {err}" + ); +} + +#[test] +fn memory_policy_note_type_must_be_known_value() { + let mut cfg = helpers::base_config(); + + cfg.memory + .policy + .rules + .push(MemoryPolicyRule { note_type: Some("unknown".to_string()), ..Default::default() }); + + let err = elf_config::validate(&cfg).expect_err("Expected note_type validation error."); + + assert!( + err.to_string().contains( + "memory.policy.rules[1].note_type must be one of preference, constraint, decision, profile, fact, or plan." + ), + "Unexpected error: {err}" + ); +} + +#[test] +fn memory_policy_scope_must_be_allowed() { + let mut cfg = helpers::base_config(); + + cfg.memory + .policy + .rules + .push(MemoryPolicyRule { scope: Some("invalid_scope".to_string()), ..Default::default() }); + + let err = elf_config::validate(&cfg).expect_err("Expected scope validation error."); + + assert!( + err.to_string().contains("memory.policy.rules[1].scope must be one of allowed scopes."), + "Unexpected error: {err}" + ); +} + +#[test] +fn memory_policy_rule_pairs_must_be_unique() { + let mut cfg = helpers::base_config(); + + cfg.memory.policy.rules.push(Default::default()); + cfg.memory.policy.rules.push(Default::default()); + + let err = elf_config::validate(&cfg).expect_err("Expected duplicate rule validation error."); + + assert!( + err.to_string() + .contains("memory.policy.rules[2] has a duplicate note_type and scope pair."), + "Unexpected error: {err}" + ); +} + +#[test] +fn memory_policy_note_type_must_not_be_whitespace_only() { + let mut cfg = helpers::base_config(); + + cfg.memory + .policy + .rules + .push(MemoryPolicyRule { note_type: Some(" ".to_string()), ..Default::default() }); + + let err = + elf_config::validate(&cfg).expect_err("Expected whitespace note_type validation error."); + + assert!( + err.to_string() + .contains("memory.policy.rules[1].note_type cannot be blank or whitespace-only."), + "Unexpected error: {err}" + ); +} + +#[test] +fn memory_policy_scope_must_not_be_whitespace_only() { + let mut cfg = helpers::base_config(); + + cfg.memory + .policy + .rules + .push(MemoryPolicyRule { scope: Some(" ".to_string()), ..Default::default() }); + + let err = elf_config::validate(&cfg).expect_err("Expected whitespace scope validation error."); + + assert!( + err.to_string() + .contains("memory.policy.rules[1].scope cannot be blank or whitespace-only."), + "Unexpected error: {err}" + ); +} diff --git a/packages/elf-config/tests/config_validation/ranking.rs b/packages/elf-config/tests/config_validation/ranking.rs new file mode 100644 index 00000000..79d80825 --- /dev/null +++ b/packages/elf-config/tests/config_validation/ranking.rs @@ -0,0 +1,33 @@ +use crate::helpers; + +#[test] +fn retrieval_source_weights_must_be_non_negative() { + let mut cfg = helpers::base_config(); + + cfg.ranking.retrieval_sources.fusion_weight = -0.1; + + let err = + elf_config::validate(&cfg).expect_err("Expected retrieval source weight validation error."); + + assert!( + err.to_string() + .contains("ranking.retrieval_sources.fusion_weight must be zero or greater."), + "Unexpected error: {err}" + ); +} + +#[test] +fn retrieval_source_weights_require_at_least_one_positive() { + let mut cfg = helpers::base_config(); + + cfg.ranking.retrieval_sources.fusion_weight = 0.0; + cfg.ranking.retrieval_sources.structured_field_weight = 0.0; + + let err = elf_config::validate(&cfg) + .expect_err("Expected retrieval source at-least-one-positive validation error."); + + assert!( + err.to_string().contains("At least one retrieval source weight must be greater than zero."), + "Unexpected error: {err}" + ); +} diff --git a/packages/elf-config/tests/config_validation/search.rs b/packages/elf-config/tests/config_validation/search.rs new file mode 100644 index 00000000..a47b3e8c --- /dev/null +++ b/packages/elf-config/tests/config_validation/search.rs @@ -0,0 +1,149 @@ +use std::fs; + +use crate::helpers; + +#[test] +fn cache_ttl_must_be_positive() { + let payload = helpers::sample_toml_with_cache(true, 0, 7, true); + let path = helpers::write_temp_config(payload); + let result = elf_config::load(&path); + + fs::remove_file(&path).expect("Failed to remove test config."); + + let err = result.expect_err("Expected cache TTL validation error."); + + assert!( + err.to_string().contains("search.cache.expansion_ttl_days must be greater than zero."), + "Unexpected error: {err}" + ); +} + +#[test] +fn recursive_search_settings_can_be_valid() { + let mut cfg = helpers::base_config(); + + cfg.search.recursive.enabled = true; + cfg.search.recursive.max_depth = 4; + cfg.search.recursive.max_children_per_node = 12; + cfg.search.recursive.max_nodes_per_scope = 64; + cfg.search.recursive.max_total_nodes = 120; + + assert!(elf_config::validate(&cfg).is_ok()); +} + +#[test] +fn recursive_search_settings_require_valid_depth_bounds() { + let mut cfg = helpers::base_config(); + + cfg.search.recursive.enabled = true; + cfg.search.recursive.max_depth = 0; + + let err = + elf_config::validate(&cfg).expect_err("Expected recursive max_depth validation error."); + + assert!( + err.to_string().contains("search.recursive.max_depth must be greater than zero."), + "Unexpected error: {err}" + ); +} + +#[test] +fn recursive_search_settings_require_reasonable_bounds() { + let mut cfg = helpers::base_config(); + + cfg.search.recursive.enabled = true; + cfg.search.recursive.max_children_per_node = 0; + + let err = + elf_config::validate(&cfg).expect_err("Expected recursive branch factor validation error."); + + assert!( + err.to_string() + .contains("search.recursive.max_children_per_node must be greater than zero."), + "Unexpected error: {err}" + ); + + cfg = helpers::base_config(); + cfg.search.recursive.enabled = true; + cfg.search.recursive.max_total_nodes = 8; + cfg.search.recursive.max_nodes_per_scope = 12; + + let err = elf_config::validate(&cfg) + .expect_err("Expected recursive max_total_nodes lower-bound validation error."); + + assert!( + err.to_string().contains( + "search.recursive.max_total_nodes must be at least search.recursive.max_nodes_per_scope." + ), + "Unexpected error: {err}" + ); +} + +#[test] +fn graph_context_settings_max_facts_per_item_must_be_positive_when_enabled() { + let mut cfg = helpers::base_config(); + + cfg.search.graph_context.enabled = true; + cfg.search.graph_context.max_facts_per_item = 0; + + let err = elf_config::validate(&cfg) + .expect_err("Expected graph_context max_facts_per_item validation error."); + + assert!( + err.to_string() + .contains("search.graph_context.max_facts_per_item must be greater than zero."), + "Unexpected error: {err}" + ); +} + +#[test] +fn graph_context_settings_max_evidence_notes_per_fact_must_be_positive_when_enabled() { + let mut cfg = helpers::base_config(); + + cfg.search.graph_context.enabled = true; + cfg.search.graph_context.max_evidence_notes_per_fact = 0; + + let err = elf_config::validate(&cfg) + .expect_err("Expected graph_context max_evidence_notes_per_fact validation error."); + + assert!( + err.to_string().contains( + "search.graph_context.max_evidence_notes_per_fact must be greater than zero." + ), + "Unexpected error: {err}" + ); +} + +#[test] +fn graph_context_settings_max_facts_per_item_cannot_exceed_hard_limit() { + let mut cfg = helpers::base_config(); + + cfg.search.graph_context.enabled = true; + cfg.search.graph_context.max_facts_per_item = 1_001; + + let err = elf_config::validate(&cfg) + .expect_err("Expected graph_context max_facts_per_item upper-bound validation error."); + + assert!( + err.to_string().contains("search.graph_context.max_facts_per_item must be 1,000 or less."), + "Unexpected error: {err}" + ); +} + +#[test] +fn graph_context_settings_max_evidence_notes_per_fact_cannot_exceed_hard_limit() { + let mut cfg = helpers::base_config(); + + cfg.search.graph_context.enabled = true; + cfg.search.graph_context.max_evidence_notes_per_fact = 1_001; + + let err = elf_config::validate(&cfg).expect_err( + "Expected graph_context max_evidence_notes_per_fact upper-bound validation error.", + ); + + assert!( + err.to_string() + .contains("search.graph_context.max_evidence_notes_per_fact must be 1,000 or less."), + "Unexpected error: {err}" + ); +} diff --git a/packages/elf-config/tests/config_validation/security.rs b/packages/elf-config/tests/config_validation/security.rs new file mode 100644 index 00000000..549a53e0 --- /dev/null +++ b/packages/elf-config/tests/config_validation/security.rs @@ -0,0 +1,62 @@ +use crate::helpers; + +#[test] +fn security_auth_keys_require_unique_token_ids() { + let mut cfg = helpers::base_config(); + + cfg.security.auth_mode = "static_keys".to_string(); + cfg.security.auth_keys = vec![ + elf_config::SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret-1".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: elf_config::SecurityAuthRole::User, + }, + elf_config::SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret-2".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: elf_config::SecurityAuthRole::Admin, + }, + ]; + + let err = + elf_config::validate(&cfg).expect_err("Expected duplicate token_id validation error."); + + assert!( + err.to_string().contains("token_id must be unique across security.auth_keys."), + "Unexpected error: {err}" + ); +} + +#[test] +fn security_auth_keys_require_known_read_profile() { + let mut cfg = helpers::base_config(); + + cfg.security.auth_mode = "static_keys".to_string(); + cfg.security.auth_keys = vec![elf_config::SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret-1".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "unknown".to_string(), + role: elf_config::SecurityAuthRole::User, + }]; + + let err = + elf_config::validate(&cfg).expect_err("Expected auth key read_profile validation error."); + + assert!( + err.to_string().contains( + "read_profile must be one of private_only, private_plus_project, or all_scopes." + ), + "Unexpected error: {err}" + ); +}