diff --git a/apps/elf-api/tests/http.rs b/apps/elf-api/tests/http.rs index a268cac4..c2a6109e 100644 --- a/apps/elf-api/tests/http.rs +++ b/apps/elf-api/tests/http.rs @@ -2,939 +2,9 @@ //! End-to-end HTTP integration tests for the ELF API app. +#[path = "http/helpers.rs"] pub(crate) mod helpers; + #[path = "http/auth_admin.rs"] mod auth_admin; #[path = "http/contract.rs"] mod contract; #[path = "http/request_validation.rs"] mod request_validation; #[path = "http/sharing.rs"] mod sharing; - -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; -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, -}; -use elf_storage::qdrant::{BM25_MODEL, BM25_VECTOR_NAME, DENSE_VECTOR_NAME}; -use elf_testkit::TestDatabase; - -const TEST_TENANT_ID: &str = "tenant_alpha"; -const TEST_PROJECT_ID: &str = "project_alpha"; -const TEST_PROJECT_ID_B: &str = "project_beta"; -const TEST_AGENT_A: &str = "a"; -const TEST_AGENT_B: &str = "b"; - -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, - }, - } -} - -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, - } -} - -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, - }, - } -} - -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(), - } -} - -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(), - } -} - -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(), - } -} - -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}"); -} - -fn init_test_tracing() { - let _ = tracing_subscriber::fmt().with_max_level(Level::ERROR).with_test_writer().try_init(); -} - -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.") -} - -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)) -} - -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."); -} - -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."); -} - -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.") -} - -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.")) -} - -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.") -} - -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 -} - -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.") -} - -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.") -} - -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 -} - -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.") -} - -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.") -} - -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)) -} - -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.") -} - -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); -} - -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())); -} - -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) -} - -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 -} - -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."); -} - -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."); -} - -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.") -} - -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() -} - -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/auth_admin.rs b/apps/elf-api/tests/http/auth_admin.rs index 95a0688e..e29a39de 100644 --- a/apps/elf-api/tests/http/auth_admin.rs +++ b/apps/elf-api/tests/http/auth_admin.rs @@ -1,801 +1,3 @@ -use axum::{ - Router, - body::{self, Body}, - http::{Request, StatusCode}, -}; -use serde_json::Value; -use tower::util::ServiceExt as _; -use uuid::Uuid; - -use crate::{TEST_AGENT_A, TEST_PROJECT_ID, TEST_TENANT_ID}; -use elf_api::{routes, state::AppState}; -use elf_config::{SecurityAuthKey, SecurityAuthRole}; -use elf_testkit::TestDatabase; - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_requires_bearer_header() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { - return; - }; - let mut config = crate::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: "k1".to_string(), - token: "secret".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: SecurityAuthRole::User, - }]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let no_auth = app - .clone() - .oneshot(Request::builder().uri("/health").body(Body::empty()).expect("build request")) - .await - .expect("call /health without auth"); - - assert_eq!(no_auth.status(), StatusCode::UNAUTHORIZED); - - let non_bearer_auth = app - .clone() - .oneshot( - Request::builder() - .uri("/health") - .header("Authorization", "Basic secret") - .body(Body::empty()) - .expect("build non-bearer auth request"), - ) - .await - .expect("call /health with non-bearer auth"); - - assert_eq!(non_bearer_auth.status(), StatusCode::UNAUTHORIZED); - - let bearer_auth = app - .oneshot( - Request::builder() - .uri("/health") - .header("Authorization", "Bearer secret") - .body(Body::empty()) - .expect("build bearer auth request"), - ) - .await - .expect("call /health with bearer auth"); - - assert_eq!(bearer_auth.status(), StatusCode::OK); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -async fn static_keys_admin_required_for_org_shared_writes_fixture() --> Option<(TestDatabase, Router, Uuid)> { - let (test_db, qdrant_url, collection) = crate::test_env().await?; - let mut config = crate::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: "user-token-id".to_string(), - token: "user-token".to_string(), - tenant_id: TEST_TENANT_ID.to_string(), - project_id: TEST_PROJECT_ID.to_string(), - agent_id: Some("user-agent".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }, - 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: "private_plus_project".to_string(), - role: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state.clone()); - let note_id = Uuid::new_v4(); - - crate::insert_note( - &state, - note_id, - "agent_private", - "admin-agent", - "Fact: org-shared publish setup note.", - ) - .await; - - Some((test_db, app, note_id)) -} - -async fn static_keys_admin_required_for_org_shared_writes_requests(app: &Router, note_id: Uuid) { - static_keys_admin_required_for_org_shared_writes_ingest_checks(app).await; - static_keys_admin_required_for_org_shared_writes_publish_checks(app, note_id).await; - static_keys_admin_required_for_org_shared_writes_grant_checks(app).await; -} - -async fn static_keys_admin_required_for_org_shared_writes_ingest_checks(app: &Router) { - let notes_payload = serde_json::json!({ - "scope": "org_shared", - "notes": [{ - "type": "fact", - "key": null, - "text": "你好", - "importance": 0.5, - "confidence": 0.9, - "ttl_days": null, - "source_ref": {} - }] - }) - .to_string(); - let user_ingest = crate::post_with_authorization_and_json_body( - app, - "/v2/notes/ingest", - "Bearer user-token", - ¬es_payload, - "Failed to build notes ingest request.", - "Failed to call notes ingest.", - ) - .await; - - assert_eq!(user_ingest.status(), StatusCode::FORBIDDEN); - - let admin_ingest = crate::post_with_authorization_and_json_body( - app, - "/v2/notes/ingest", - "Bearer admin-token", - ¬es_payload, - "Failed to build notes ingest request.", - "Failed to call notes ingest (admin).", - ) - .await; - - assert_eq!(admin_ingest.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let admin_ingest_body = body::to_bytes(admin_ingest.into_body(), usize::MAX) - .await - .expect("Failed to read notes ingest response body."); - let admin_ingest_json: Value = - serde_json::from_slice(&admin_ingest_body).expect("Failed to parse response."); - - assert_eq!(admin_ingest_json["error_code"], "NON_ENGLISH_INPUT"); -} - -async fn static_keys_admin_required_for_org_shared_writes_publish_checks( - app: &Router, - note_id: Uuid, -) { - let publish_payload = serde_json::json!({ "space": "org_shared" }).to_string(); - let user_publish = crate::post_with_authorization_and_json_body( - app, - &format!("/v2/notes/{note_id}/publish"), - "Bearer user-token", - &publish_payload, - "Failed to build note publish request.", - "Failed to call notes publish.", - ) - .await; - - assert_eq!(user_publish.status(), StatusCode::FORBIDDEN); - - let admin_publish = crate::post_with_authorization_and_json_body( - app, - &format!("/v2/notes/{note_id}/publish"), - "Bearer admin-token", - &publish_payload, - "Failed to build note publish request.", - "Failed to call notes publish (admin).", - ) - .await; - - assert_eq!(admin_publish.status(), StatusCode::OK); -} - -async fn static_keys_admin_required_for_org_shared_writes_grant_checks(app: &Router) { - let grant_upsert_payload = serde_json::json!({ "grantee_kind": "project" }).to_string(); - let user_grant_upsert = crate::post_with_authorization_and_json_body( - app, - "/v2/spaces/org_shared/grants", - "Bearer user-token", - &grant_upsert_payload, - "Failed to build grant upsert request.", - "Failed to call grant upsert.", - ) - .await; - - assert_eq!(user_grant_upsert.status(), StatusCode::FORBIDDEN); - - let admin_grant_upsert = crate::post_with_authorization_and_json_body( - app, - "/v2/spaces/org_shared/grants", - "Bearer admin-token", - &grant_upsert_payload, - "Failed to build grant upsert request.", - "Failed to call grant upsert (admin).", - ) - .await; - - assert_eq!(admin_grant_upsert.status(), StatusCode::OK); - - let grant_revoke_payload = serde_json::json!({ "grantee_kind": "project" }).to_string(); - let user_grant_revoke = crate::post_with_authorization_and_json_body( - app, - "/v2/spaces/org_shared/grants/revoke", - "Bearer user-token", - &grant_revoke_payload, - "Failed to build grant revoke request.", - "Failed to call grant revoke.", - ) - .await; - - assert_eq!(user_grant_revoke.status(), StatusCode::FORBIDDEN); - - let admin_grant_revoke = crate::post_with_authorization_and_json_body( - app, - "/v2/spaces/org_shared/grants/revoke", - "Bearer admin-token", - &grant_revoke_payload, - "Failed to build grant revoke request.", - "Failed to call grant revoke (admin).", - ) - .await; - - assert_eq!(admin_grant_revoke.status(), StatusCode::OK); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_admin_required_for_org_shared_writes() { - let Some((test_db, app, note_id)) = - static_keys_admin_required_for_org_shared_writes_fixture().await - else { - return; - }; - - static_keys_admin_required_for_org_shared_writes_requests(&app, note_id).await; - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_org_shared_ingest_requires_admin() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { return }; - let mut config = crate::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: "user".to_string(), - token: "user-token".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: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".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: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({ - "scope": "org_shared", - "notes": [{ - "type": "fact", - "key": null, - "text": "你好", - "importance": 0.5, - "confidence": 0.9, - "ttl_days": null, - "source_ref": {} - }] - }); - let response_user = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/notes/ingest") - .header("Authorization", "Bearer user-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call notes ingest (user)."); - - assert_eq!(response_user.status(), StatusCode::FORBIDDEN); - - let response_admin = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/notes/ingest") - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call notes ingest (admin)."); - - assert_eq!(response_admin.status(), StatusCode::UNPROCESSABLE_ENTITY); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_org_shared_events_ingest_requires_admin() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { return }; - let mut config = crate::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: "user".to_string(), - token: "user-token".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: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".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: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({ - "scope": "org_shared", - "dry_run": true, - "messages": [{ - "role": "user", - "content": "こんにちは" - }] - }); - let response_user = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/events/ingest") - .header("Authorization", "Bearer user-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call events ingest (user)."); - - assert_eq!(response_user.status(), StatusCode::FORBIDDEN); - - let response_admin = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/events/ingest") - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call events ingest (admin)."); - - assert_eq!(response_admin.status(), StatusCode::UNPROCESSABLE_ENTITY); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_org_shared_publish_requires_admin() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { return }; - let mut config = crate::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: "user".to_string(), - token: "user-token".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: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".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: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let note_id = Uuid::new_v4(); - let payload = serde_json::json!({"space":"org_shared"}).to_string(); - let response_user = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/v2/notes/{note_id}/publish")) - .header("Authorization", "Bearer user-token") - .header("content-type", "application/json") - .body(Body::from(payload.clone())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call note publish (user)."); - - assert_eq!(response_user.status(), StatusCode::FORBIDDEN); - - let response_admin = app - .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 request."), - ) - .await - .expect("Failed to call note publish (admin)."); - - assert_ne!(response_admin.status(), StatusCode::FORBIDDEN); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_org_shared_grants_require_admin() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { return }; - let mut config = crate::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: "user".to_string(), - token: "user-token".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: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".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: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({"grantee_kind":"project","grantee_agent_id":null}).to_string(); - let response_user = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/spaces/org_shared/grants") - .header("Authorization", "Bearer user-token") - .header("content-type", "application/json") - .body(Body::from(payload.clone())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call grant upsert (user)."); - - assert_eq!(response_user.status(), StatusCode::FORBIDDEN); - - let response_admin = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/spaces/org_shared/grants") - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload)) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call grant upsert (admin)."); - - assert_ne!(response_admin.status(), StatusCode::FORBIDDEN); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn admin_note_provenance_includes_request_id_on_success() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { - return; - }; - let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "off".to_string(); - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::admin_router(state.clone()); - let note_id = Uuid::new_v4(); - let request_id = Uuid::new_v4(); - - crate::insert_note( - &state, - note_id, - "agent_private", - TEST_AGENT_A, - "Provenance integration test note.", - ) - .await; - - let response = app - .oneshot( - Request::builder() - .uri(format!("/v2/admin/notes/{note_id}/provenance")) - .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-Request-Id", request_id.to_string()) - .body(Body::empty()) - .expect("Failed to build provenance request."), - ) - .await - .expect("Failed to call admin note provenance."); - - assert_eq!(response.status(), StatusCode::OK); - - let expected_request_id = request_id.to_string(); - - assert_eq!( - response.headers().get("X-ELF-Request-Id").and_then(|value| value.to_str().ok()), - Some(expected_request_id.as_str()) - ); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read provenance response body."); - let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["schema"], "elf.note_provenance_bundle/v1"); - assert_eq!(json["request_id"], request_id.to_string()); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn admin_note_history_includes_request_id_on_success() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { - return; - }; - let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "off".to_string(); - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::admin_router(state.clone()); - let note_id = Uuid::new_v4(); - let request_id = Uuid::new_v4(); - - crate::insert_note( - &state, - note_id, - "agent_private", - TEST_AGENT_A, - "History integration test note.", - ) - .await; - - let response = app - .oneshot( - Request::builder() - .uri(format!("/v2/admin/notes/{note_id}/history")) - .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-Request-Id", request_id.to_string()) - .body(Body::empty()) - .expect("Failed to build history request."), - ) - .await - .expect("Failed to call admin note history."); - - assert_eq!(response.status(), StatusCode::OK); - - let expected_request_id = request_id.to_string(); - - assert_eq!( - response.headers().get("X-ELF-Request-Id").and_then(|value| value.to_str().ok()), - Some(expected_request_id.as_str()) - ); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read history response body."); - let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["schema"], "elf.memory_history/v1"); - assert_eq!(json["request_id"], request_id.to_string()); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn admin_note_provenance_rejects_invalid_request_id_header() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { - return; - }; - let mut config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "off".to_string(); - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::admin_router(state); - let note_id = Uuid::new_v4(); - let response = app - .oneshot( - Request::builder() - .uri(format!("/v2/admin/notes/{note_id}/provenance")) - .header("X-ELF-Request-Id", "not-a-uuid") - .body(Body::empty()) - .expect("Failed to build provenance request."), - ) - .await - .expect("Failed to call admin note provenance."); - let response_request_id = response - .headers() - .get("X-ELF-Request-Id") - .and_then(|value| value.to_str().ok()) - .expect("Expected request id header in error response."); - let generated_request_id = Uuid::parse_str(response_request_id) - .expect("Expected valid generated request_id in response header."); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - let body = body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("Failed to read provenance response body."); - let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["error_code"], "INVALID_REQUEST"); - assert_eq!(json["fields"][0], "$.headers.X-ELF-Request-Id"); - assert_eq!(json["request_id"], serde_json::Value::String(generated_request_id.to_string()),); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn global_graph_predicate_write_requires_super_admin() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { - return; - }; - let mut config = crate::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".to_string(), - token: "admin-token".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: SecurityAuthRole::Admin, - }, - SecurityAuthKey { - token_id: "super".to_string(), - token: "super-token".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: SecurityAuthRole::SuperAdmin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::admin_router(state.clone()); - let predicate_id = Uuid::new_v4(); - - sqlx::query( - "\ - INSERT INTO graph_predicates ( - predicate_id, - scope_key, - tenant_id, - project_id, - canonical, - canonical_norm, - cardinality, - status, - created_at, - updated_at - ) - VALUES ($1, '__global__', NULL, NULL, 'global_test', 'global_test', 'multi', 'pending', now(), now())", - ) - .bind(predicate_id) - .execute(&state.service.db.pool) - .await - .expect("Failed to insert global predicate."); - - let payload = serde_json::json!({ "status": "active" }); - let response_admin = app - .clone() - .oneshot( - Request::builder() - .method("PATCH") - .uri(format!("/v2/admin/graph/predicates/{predicate_id}")) - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call admin graph predicate patch (admin)."); - - assert_eq!(response_admin.status(), StatusCode::FORBIDDEN); - - let body = body::to_bytes(response_admin.into_body(), usize::MAX) - .await - .expect("Failed to read response body."); - let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); - - assert_eq!(json["error_code"], "SCOPE_DENIED"); - - let response_super = app - .oneshot( - Request::builder() - .method("PATCH") - .uri(format!("/v2/admin/graph/predicates/{predicate_id}")) - .header("Authorization", "Bearer super-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call admin graph predicate patch (super_admin)."); - - assert_eq!(response_super.status(), StatusCode::OK); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} +#[path = "auth_admin/graph.rs"] mod graph; +#[path = "auth_admin/org_shared.rs"] mod org_shared; +#[path = "auth_admin/provenance.rs"] mod provenance; diff --git a/apps/elf-api/tests/http/auth_admin/graph.rs b/apps/elf-api/tests/http/auth_admin/graph.rs new file mode 100644 index 00000000..7440ba5c --- /dev/null +++ b/apps/elf-api/tests/http/auth_admin/graph.rs @@ -0,0 +1,108 @@ +use axum::{ + body::{self, Body}, + http::{Request, StatusCode}, +}; +use serde_json::Value; +use tower::util::ServiceExt as _; +use uuid::Uuid; + +use crate::helpers; +use elf_api::{routes, state::AppState}; +use elf_config::{SecurityAuthKey, SecurityAuthRole}; + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn global_graph_predicate_write_requires_super_admin() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { + return; + }; + 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".to_string(), + token: "admin-token".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: SecurityAuthRole::Admin, + }, + SecurityAuthKey { + token_id: "super".to_string(), + token: "super-token".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: SecurityAuthRole::SuperAdmin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::admin_router(state.clone()); + let predicate_id = Uuid::new_v4(); + + sqlx::query( + "\ + INSERT INTO graph_predicates ( + predicate_id, + scope_key, + tenant_id, + project_id, + canonical, + canonical_norm, + cardinality, + status, + created_at, + updated_at + ) + VALUES ($1, '__global__', NULL, NULL, 'global_test', 'global_test', 'multi', 'pending', now(), now())", + ) + .bind(predicate_id) + .execute(&state.service.db.pool) + .await + .expect("Failed to insert global predicate."); + + let payload = serde_json::json!({ "status": "active" }); + let response_admin = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/v2/admin/graph/predicates/{predicate_id}")) + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call admin graph predicate patch (admin)."); + + assert_eq!(response_admin.status(), StatusCode::FORBIDDEN); + + let body = body::to_bytes(response_admin.into_body(), usize::MAX) + .await + .expect("Failed to read response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["error_code"], "SCOPE_DENIED"); + + let response_super = app + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/v2/admin/graph/predicates/{predicate_id}")) + .header("Authorization", "Bearer super-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call admin graph predicate patch (super_admin)."); + + assert_eq!(response_super.status(), StatusCode::OK); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/auth_admin/org_shared.rs b/apps/elf-api/tests/http/auth_admin/org_shared.rs new file mode 100644 index 00000000..e16d157e --- /dev/null +++ b/apps/elf-api/tests/http/auth_admin/org_shared.rs @@ -0,0 +1,543 @@ +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_TENANT_ID}; +use elf_api::{routes, state::AppState}; +use elf_config::{SecurityAuthKey, SecurityAuthRole}; +use elf_testkit::TestDatabase; + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_requires_bearer_header() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { + return; + }; + 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: "k1".to_string(), + token: "secret".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: SecurityAuthRole::User, + }]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let no_auth = app + .clone() + .oneshot(Request::builder().uri("/health").body(Body::empty()).expect("build request")) + .await + .expect("call /health without auth"); + + assert_eq!(no_auth.status(), StatusCode::UNAUTHORIZED); + + let non_bearer_auth = app + .clone() + .oneshot( + Request::builder() + .uri("/health") + .header("Authorization", "Basic secret") + .body(Body::empty()) + .expect("build non-bearer auth request"), + ) + .await + .expect("call /health with non-bearer auth"); + + assert_eq!(non_bearer_auth.status(), StatusCode::UNAUTHORIZED); + + let bearer_auth = app + .oneshot( + Request::builder() + .uri("/health") + .header("Authorization", "Bearer secret") + .body(Body::empty()) + .expect("build bearer auth request"), + ) + .await + .expect("call /health with bearer auth"); + + assert_eq!(bearer_auth.status(), StatusCode::OK); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +async fn static_keys_admin_required_for_org_shared_writes_fixture() +-> Option<(TestDatabase, Router, 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: "user-token-id".to_string(), + token: "user-token".to_string(), + tenant_id: TEST_TENANT_ID.to_string(), + project_id: TEST_PROJECT_ID.to_string(), + agent_id: Some("user-agent".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }, + 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: "private_plus_project".to_string(), + role: SecurityAuthRole::Admin, + }, + ]; + + 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 publish setup note.", + ) + .await; + + Some((test_db, app, note_id)) +} + +async fn static_keys_admin_required_for_org_shared_writes_requests(app: &Router, note_id: Uuid) { + static_keys_admin_required_for_org_shared_writes_ingest_checks(app).await; + static_keys_admin_required_for_org_shared_writes_publish_checks(app, note_id).await; + static_keys_admin_required_for_org_shared_writes_grant_checks(app).await; +} + +async fn static_keys_admin_required_for_org_shared_writes_ingest_checks(app: &Router) { + let notes_payload = serde_json::json!({ + "scope": "org_shared", + "notes": [{ + "type": "fact", + "key": null, + "text": "你好", + "importance": 0.5, + "confidence": 0.9, + "ttl_days": null, + "source_ref": {} + }] + }) + .to_string(); + let user_ingest = helpers::post_with_authorization_and_json_body( + app, + "/v2/notes/ingest", + "Bearer user-token", + ¬es_payload, + "Failed to build notes ingest request.", + "Failed to call notes ingest.", + ) + .await; + + assert_eq!(user_ingest.status(), StatusCode::FORBIDDEN); + + let admin_ingest = helpers::post_with_authorization_and_json_body( + app, + "/v2/notes/ingest", + "Bearer admin-token", + ¬es_payload, + "Failed to build notes ingest request.", + "Failed to call notes ingest (admin).", + ) + .await; + + assert_eq!(admin_ingest.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let admin_ingest_body = body::to_bytes(admin_ingest.into_body(), usize::MAX) + .await + .expect("Failed to read notes ingest response body."); + let admin_ingest_json: Value = + serde_json::from_slice(&admin_ingest_body).expect("Failed to parse response."); + + assert_eq!(admin_ingest_json["error_code"], "NON_ENGLISH_INPUT"); +} + +async fn static_keys_admin_required_for_org_shared_writes_publish_checks( + app: &Router, + note_id: Uuid, +) { + let publish_payload = serde_json::json!({ "space": "org_shared" }).to_string(); + let user_publish = helpers::post_with_authorization_and_json_body( + app, + &format!("/v2/notes/{note_id}/publish"), + "Bearer user-token", + &publish_payload, + "Failed to build note publish request.", + "Failed to call notes publish.", + ) + .await; + + assert_eq!(user_publish.status(), StatusCode::FORBIDDEN); + + let admin_publish = helpers::post_with_authorization_and_json_body( + app, + &format!("/v2/notes/{note_id}/publish"), + "Bearer admin-token", + &publish_payload, + "Failed to build note publish request.", + "Failed to call notes publish (admin).", + ) + .await; + + assert_eq!(admin_publish.status(), StatusCode::OK); +} + +async fn static_keys_admin_required_for_org_shared_writes_grant_checks(app: &Router) { + let grant_upsert_payload = serde_json::json!({ "grantee_kind": "project" }).to_string(); + let user_grant_upsert = helpers::post_with_authorization_and_json_body( + app, + "/v2/spaces/org_shared/grants", + "Bearer user-token", + &grant_upsert_payload, + "Failed to build grant upsert request.", + "Failed to call grant upsert.", + ) + .await; + + assert_eq!(user_grant_upsert.status(), StatusCode::FORBIDDEN); + + let admin_grant_upsert = helpers::post_with_authorization_and_json_body( + app, + "/v2/spaces/org_shared/grants", + "Bearer admin-token", + &grant_upsert_payload, + "Failed to build grant upsert request.", + "Failed to call grant upsert (admin).", + ) + .await; + + assert_eq!(admin_grant_upsert.status(), StatusCode::OK); + + let grant_revoke_payload = serde_json::json!({ "grantee_kind": "project" }).to_string(); + let user_grant_revoke = helpers::post_with_authorization_and_json_body( + app, + "/v2/spaces/org_shared/grants/revoke", + "Bearer user-token", + &grant_revoke_payload, + "Failed to build grant revoke request.", + "Failed to call grant revoke.", + ) + .await; + + assert_eq!(user_grant_revoke.status(), StatusCode::FORBIDDEN); + + let admin_grant_revoke = helpers::post_with_authorization_and_json_body( + app, + "/v2/spaces/org_shared/grants/revoke", + "Bearer admin-token", + &grant_revoke_payload, + "Failed to build grant revoke request.", + "Failed to call grant revoke (admin).", + ) + .await; + + assert_eq!(admin_grant_revoke.status(), StatusCode::OK); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_admin_required_for_org_shared_writes() { + let Some((test_db, app, note_id)) = + static_keys_admin_required_for_org_shared_writes_fixture().await + else { + return; + }; + + static_keys_admin_required_for_org_shared_writes_requests(&app, note_id).await; + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_org_shared_ingest_requires_admin() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; + 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: "user".to_string(), + token: "user-token".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: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".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: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({ + "scope": "org_shared", + "notes": [{ + "type": "fact", + "key": null, + "text": "你好", + "importance": 0.5, + "confidence": 0.9, + "ttl_days": null, + "source_ref": {} + }] + }); + let response_user = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/notes/ingest") + .header("Authorization", "Bearer user-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call notes ingest (user)."); + + assert_eq!(response_user.status(), StatusCode::FORBIDDEN); + + let response_admin = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/notes/ingest") + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call notes ingest (admin)."); + + assert_eq!(response_admin.status(), StatusCode::UNPROCESSABLE_ENTITY); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_org_shared_events_ingest_requires_admin() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; + 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: "user".to_string(), + token: "user-token".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: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".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: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({ + "scope": "org_shared", + "dry_run": true, + "messages": [{ + "role": "user", + "content": "こんにちは" + }] + }); + let response_user = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/events/ingest") + .header("Authorization", "Bearer user-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call events ingest (user)."); + + assert_eq!(response_user.status(), StatusCode::FORBIDDEN); + + let response_admin = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/events/ingest") + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call events ingest (admin)."); + + assert_eq!(response_admin.status(), StatusCode::UNPROCESSABLE_ENTITY); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_org_shared_publish_requires_admin() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; + 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: "user".to_string(), + token: "user-token".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: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".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: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let note_id = Uuid::new_v4(); + let payload = serde_json::json!({"space":"org_shared"}).to_string(); + let response_user = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/v2/notes/{note_id}/publish")) + .header("Authorization", "Bearer user-token") + .header("content-type", "application/json") + .body(Body::from(payload.clone())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call note publish (user)."); + + assert_eq!(response_user.status(), StatusCode::FORBIDDEN); + + let response_admin = app + .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 request."), + ) + .await + .expect("Failed to call note publish (admin)."); + + assert_ne!(response_admin.status(), StatusCode::FORBIDDEN); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_org_shared_grants_require_admin() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; + 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: "user".to_string(), + token: "user-token".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: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".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: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({"grantee_kind":"project","grantee_agent_id":null}).to_string(); + let response_user = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/spaces/org_shared/grants") + .header("Authorization", "Bearer user-token") + .header("content-type", "application/json") + .body(Body::from(payload.clone())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call grant upsert (user)."); + + assert_eq!(response_user.status(), StatusCode::FORBIDDEN); + + let response_admin = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/spaces/org_shared/grants") + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload)) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call grant upsert (admin)."); + + assert_ne!(response_admin.status(), StatusCode::FORBIDDEN); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/auth_admin/provenance.rs b/apps/elf-api/tests/http/auth_admin/provenance.rs new file mode 100644 index 00000000..53a30d69 --- /dev/null +++ b/apps/elf-api/tests/http/auth_admin/provenance.rs @@ -0,0 +1,171 @@ +use axum::{ + body::{self, Body}, + http::{Request, StatusCode}, +}; +use serde_json::Value; +use tower::util::ServiceExt as _; +use uuid::Uuid; + +use crate::helpers::{self, TEST_AGENT_A, TEST_PROJECT_ID, TEST_TENANT_ID}; +use elf_api::{routes, state::AppState}; + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn admin_note_provenance_includes_request_id_on_success() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { + return; + }; + let mut config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "off".to_string(); + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::admin_router(state.clone()); + let note_id = Uuid::new_v4(); + let request_id = Uuid::new_v4(); + + helpers::insert_note( + &state, + note_id, + "agent_private", + TEST_AGENT_A, + "Provenance integration test note.", + ) + .await; + + let response = app + .oneshot( + Request::builder() + .uri(format!("/v2/admin/notes/{note_id}/provenance")) + .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-Request-Id", request_id.to_string()) + .body(Body::empty()) + .expect("Failed to build provenance request."), + ) + .await + .expect("Failed to call admin note provenance."); + + assert_eq!(response.status(), StatusCode::OK); + + let expected_request_id = request_id.to_string(); + + assert_eq!( + response.headers().get("X-ELF-Request-Id").and_then(|value| value.to_str().ok()), + Some(expected_request_id.as_str()) + ); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read provenance response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["schema"], "elf.note_provenance_bundle/v1"); + assert_eq!(json["request_id"], request_id.to_string()); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn admin_note_history_includes_request_id_on_success() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { + return; + }; + let mut config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "off".to_string(); + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::admin_router(state.clone()); + let note_id = Uuid::new_v4(); + let request_id = Uuid::new_v4(); + + helpers::insert_note( + &state, + note_id, + "agent_private", + TEST_AGENT_A, + "History integration test note.", + ) + .await; + + let response = app + .oneshot( + Request::builder() + .uri(format!("/v2/admin/notes/{note_id}/history")) + .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-Request-Id", request_id.to_string()) + .body(Body::empty()) + .expect("Failed to build history request."), + ) + .await + .expect("Failed to call admin note history."); + + assert_eq!(response.status(), StatusCode::OK); + + let expected_request_id = request_id.to_string(); + + assert_eq!( + response.headers().get("X-ELF-Request-Id").and_then(|value| value.to_str().ok()), + Some(expected_request_id.as_str()) + ); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read history response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["schema"], "elf.memory_history/v1"); + assert_eq!(json["request_id"], request_id.to_string()); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn admin_note_provenance_rejects_invalid_request_id_header() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { + return; + }; + let mut config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "off".to_string(); + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::admin_router(state); + let note_id = Uuid::new_v4(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/v2/admin/notes/{note_id}/provenance")) + .header("X-ELF-Request-Id", "not-a-uuid") + .body(Body::empty()) + .expect("Failed to build provenance request."), + ) + .await + .expect("Failed to call admin note provenance."); + let response_request_id = response + .headers() + .get("X-ELF-Request-Id") + .and_then(|value| value.to_str().ok()) + .expect("Expected request id header in error response."); + let generated_request_id = Uuid::parse_str(response_request_id) + .expect("Expected valid generated request_id in response header."); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("Failed to read provenance response body."); + let json: Value = serde_json::from_slice(&body).expect("Failed to parse response."); + + assert_eq!(json["error_code"], "INVALID_REQUEST"); + assert_eq!(json["fields"][0], "$.headers.X-ELF-Request-Id"); + assert_eq!(json["request_id"], serde_json::Value::String(generated_request_id.to_string()),); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/contract.rs b/apps/elf-api/tests/http/contract.rs index 3fe53873..858e7a75 100644 --- a/apps/elf-api/tests/http/contract.rs +++ b/apps/elf-api/tests/http/contract.rs @@ -4,55 +4,56 @@ use axum::{ }; use tower::util::ServiceExt as _; +use crate::helpers; use elf_api::routes::{self, SCALAR_DOCS_PATH}; #[tokio::test] async fn openapi_json_route_serves_generated_contract() { - let spec = crate::contract_json().await; + let spec = helpers::contract_json().await; assert_eq!(spec["info"]["title"], "ELF API"); assert!(spec.get("request_id").is_none()); - crate::assert_openapi_method(&spec, "/health", "get"); - crate::assert_openapi_method(&spec, "/v2/notes/ingest", "post"); - crate::assert_openapi_method(&spec, "/v2/events/ingest", "post"); - crate::assert_openapi_method(&spec, "/v2/core-blocks", "get"); - crate::assert_openapi_method(&spec, "/v2/entity-memory", "get"); - crate::assert_openapi_method(&spec, "/v2/docs/search/l0", "post"); - crate::assert_openapi_method(&spec, "/v2/work-journal/entries", "post"); - crate::assert_openapi_method(&spec, "/v2/work-journal/entries/{entry_id}", "get"); - crate::assert_openapi_method(&spec, "/v2/work-journal/readback", "post"); - crate::assert_openapi_method(&spec, "/v2/searches/{search_id}/notes", "post"); - crate::assert_openapi_method(&spec, "/v2/admin/core-blocks", "post"); - crate::assert_openapi_method(&spec, "/v2/admin/core-blocks/{block_id}/attachments", "post"); - crate::assert_openapi_method( + helpers::assert_openapi_method(&spec, "/health", "get"); + helpers::assert_openapi_method(&spec, "/v2/notes/ingest", "post"); + helpers::assert_openapi_method(&spec, "/v2/events/ingest", "post"); + helpers::assert_openapi_method(&spec, "/v2/core-blocks", "get"); + helpers::assert_openapi_method(&spec, "/v2/entity-memory", "get"); + helpers::assert_openapi_method(&spec, "/v2/docs/search/l0", "post"); + helpers::assert_openapi_method(&spec, "/v2/work-journal/entries", "post"); + helpers::assert_openapi_method(&spec, "/v2/work-journal/entries/{entry_id}", "get"); + helpers::assert_openapi_method(&spec, "/v2/work-journal/readback", "post"); + helpers::assert_openapi_method(&spec, "/v2/searches/{search_id}/notes", "post"); + helpers::assert_openapi_method(&spec, "/v2/admin/core-blocks", "post"); + helpers::assert_openapi_method(&spec, "/v2/admin/core-blocks/{block_id}/attachments", "post"); + helpers::assert_openapi_method( &spec, "/v2/admin/core-blocks/attachments/{attachment_id}", "delete", ); - crate::assert_openapi_method(&spec, "/v2/admin/docs/{doc_id}", "get"); - crate::assert_openapi_method(&spec, "/v2/admin/docs/search/l0", "post"); - crate::assert_openapi_method(&spec, "/v2/admin/docs/excerpts", "post"); - crate::assert_openapi_method(&spec, "/v2/graph/report", "post"); - crate::assert_openapi_method(&spec, "/v2/admin/searches/raw", "post"); - crate::assert_openapi_method(&spec, "/v2/admin/events/ingestion-profiles/default", "get"); - crate::assert_openapi_method(&spec, "/v2/admin/events/ingestion-profiles/default", "put"); - crate::assert_openapi_method(&spec, "/v2/admin/consolidation/runs", "post"); - crate::assert_openapi_method(&spec, "/v2/admin/consolidation/runs", "get"); - crate::assert_openapi_method(&spec, "/v2/admin/consolidation/runs/{run_id}", "get"); - crate::assert_openapi_method(&spec, "/v2/admin/consolidation/proposals", "get"); - crate::assert_openapi_method(&spec, "/v2/admin/consolidation/proposals/{proposal_id}", "get"); - crate::assert_openapi_method( + helpers::assert_openapi_method(&spec, "/v2/admin/docs/{doc_id}", "get"); + helpers::assert_openapi_method(&spec, "/v2/admin/docs/search/l0", "post"); + helpers::assert_openapi_method(&spec, "/v2/admin/docs/excerpts", "post"); + helpers::assert_openapi_method(&spec, "/v2/graph/report", "post"); + helpers::assert_openapi_method(&spec, "/v2/admin/searches/raw", "post"); + helpers::assert_openapi_method(&spec, "/v2/admin/events/ingestion-profiles/default", "get"); + helpers::assert_openapi_method(&spec, "/v2/admin/events/ingestion-profiles/default", "put"); + helpers::assert_openapi_method(&spec, "/v2/admin/consolidation/runs", "post"); + helpers::assert_openapi_method(&spec, "/v2/admin/consolidation/runs", "get"); + helpers::assert_openapi_method(&spec, "/v2/admin/consolidation/runs/{run_id}", "get"); + helpers::assert_openapi_method(&spec, "/v2/admin/consolidation/proposals", "get"); + helpers::assert_openapi_method(&spec, "/v2/admin/consolidation/proposals/{proposal_id}", "get"); + helpers::assert_openapi_method( &spec, "/v2/admin/consolidation/proposals/{proposal_id}/review", "post", ); - crate::assert_openapi_method(&spec, "/v2/admin/notes/{note_id}/corrections", "post"); - crate::assert_openapi_method(&spec, "/v2/admin/knowledge/pages/rebuild", "post"); - crate::assert_openapi_method(&spec, "/v2/admin/knowledge/pages", "get"); - crate::assert_openapi_method(&spec, "/v2/admin/knowledge/pages/search", "post"); - crate::assert_openapi_method(&spec, "/v2/admin/knowledge/pages/{page_id}", "get"); - crate::assert_openapi_method(&spec, "/v2/admin/knowledge/pages/{page_id}/lint", "post"); + helpers::assert_openapi_method(&spec, "/v2/admin/notes/{note_id}/corrections", "post"); + helpers::assert_openapi_method(&spec, "/v2/admin/knowledge/pages/rebuild", "post"); + helpers::assert_openapi_method(&spec, "/v2/admin/knowledge/pages", "get"); + helpers::assert_openapi_method(&spec, "/v2/admin/knowledge/pages/search", "post"); + helpers::assert_openapi_method(&spec, "/v2/admin/knowledge/pages/{page_id}", "get"); + helpers::assert_openapi_method(&spec, "/v2/admin/knowledge/pages/{page_id}/lint", "post"); } #[tokio::test] @@ -86,7 +87,7 @@ async fn scalar_docs_route_serves_api_reference_html() { #[tokio::test] async fn openapi_includes_default_ingestion_profile_get_put_contract() { - let spec = crate::contract_json().await; + let spec = helpers::contract_json().await; let default_path = &spec["paths"]["/v2/admin/events/ingestion-profiles/default"]; let get_schema_ref = default_path["get"]["responses"]["200"]["content"]["application/json"]["schema"]["$ref"] diff --git a/apps/elf-api/tests/http/helpers.rs b/apps/elf-api/tests/http/helpers.rs new file mode 100644 index 00000000..a461ec21 --- /dev/null +++ b/apps/elf-api/tests/http/helpers.rs @@ -0,0 +1,947 @@ +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, +}; +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/request_validation.rs b/apps/elf-api/tests/http/request_validation.rs index 5d57b2be..756fb9b1 100644 --- a/apps/elf-api/tests/http/request_validation.rs +++ b/apps/elf-api/tests/http/request_validation.rs @@ -6,7 +6,7 @@ use serde_json::Value; use tower::util::ServiceExt as _; use uuid::Uuid; -use crate::{TEST_AGENT_A, TEST_PROJECT_ID, TEST_TENANT_ID}; +use crate::helpers::{self, TEST_AGENT_A, TEST_PROJECT_ID, TEST_TENANT_ID}; use elf_api::{routes, state::AppState}; fn payload_level_source_ref() -> Value { @@ -26,10 +26,10 @@ fn payload_level_source_ref() -> Value { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn health_ok() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return; }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state.clone()); let _ = routes::admin_router(state); @@ -51,10 +51,10 @@ async fn health_ok() { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn rejects_non_english_in_add_note() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return; }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state); let payload = serde_json::json!({ @@ -100,10 +100,10 @@ async fn rejects_non_english_in_add_note() { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn rejects_cyrillic_in_add_note() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return; }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state); let payload = serde_json::json!({ @@ -149,8 +149,8 @@ async fn rejects_cyrillic_in_add_note() { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn rejects_non_english_in_add_event() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { return }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state); let payload = serde_json::json!({ @@ -192,8 +192,8 @@ async fn rejects_non_english_in_add_event() { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn rejects_cyrillic_in_add_event() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { return }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state); let payload = serde_json::json!({ @@ -235,10 +235,10 @@ async fn rejects_cyrillic_in_add_event() { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn rejects_non_english_in_search() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return; }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state); @@ -283,10 +283,10 @@ async fn rejects_non_english_in_search() { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn rejects_cyrillic_in_search() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return; }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state); @@ -331,10 +331,10 @@ async fn rejects_cyrillic_in_search() { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn searches_notes_payload_level_shapes_source_ref_and_structured() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return; }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state.clone()); let source_ref = payload_level_source_ref(); @@ -342,10 +342,10 @@ async fn searches_notes_payload_level_shapes_source_ref_and_structured() { let note_text = "Payload shaping note used in contract tests for search details output shaping."; let note_id = - crate::create_note_for_payload_level_tests(&app, &state, note_text, source_ref.clone()) + helpers::create_note_for_payload_level_tests(&app, &state, note_text, source_ref.clone()) .await; - crate::insert_note_summary_field(&state, note_id, structured_summary).await; + helpers::insert_note_summary_field(&state, note_id, structured_summary).await; let search_response = app .clone() @@ -391,11 +391,11 @@ async fn searches_notes_payload_level_shapes_source_ref_and_structured() { ) .expect("Invalid search_id value."); let notes_l0 = - crate::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l0").await; + helpers::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l0").await; let notes_l1 = - crate::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l1").await; + helpers::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l1").await; let notes_l2 = - crate::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l2").await; + helpers::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l2").await; let search_get_response = app .clone() .oneshot( @@ -447,10 +447,10 @@ async fn searches_notes_payload_level_shapes_source_ref_and_structured() { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn admin_searches_raw_payload_level_shapes_source_ref() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return; }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state.clone()); let admin_app = routes::admin_router(state.clone()); @@ -468,14 +468,14 @@ async fn admin_searches_raw_payload_level_shapes_source_ref() { let note_text = "Admin raw search payload shaping contract note. This long note should be indexed."; let _note_id = - crate::create_note_for_payload_level_tests(&app, &state, note_text, source_ref.clone()) + helpers::create_note_for_payload_level_tests(&app, &state, note_text, source_ref.clone()) .await; let raw_l0 = - crate::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l0").await; + helpers::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l0").await; let raw_l1 = - crate::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l1").await; + helpers::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l1").await; let raw_l2 = - crate::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l2").await; + helpers::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l2").await; assert_eq!(raw_l0, serde_json::json!({})); assert_eq!(raw_l1, serde_json::json!({})); diff --git a/apps/elf-api/tests/http/sharing.rs b/apps/elf-api/tests/http/sharing.rs index cf28b952..8109daef 100644 --- a/apps/elf-api/tests/http/sharing.rs +++ b/apps/elf-api/tests/http/sharing.rs @@ -6,21 +6,21 @@ use serde_json::Value; use tower::util::ServiceExt as _; use uuid::Uuid; -use crate::{TEST_AGENT_A, TEST_AGENT_B, TEST_PROJECT_ID, TEST_TENANT_ID}; +use crate::helpers::{self, TEST_AGENT_A, TEST_AGENT_B, TEST_PROJECT_ID, TEST_TENANT_ID}; use elf_api::{routes, state::AppState}; #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn sharing_visibility_requires_explicit_project_grant() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return; }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state.clone()); let note_id = Uuid::new_v4(); - crate::insert_note( + helpers::insert_note( &state, note_id, "project_shared", @@ -82,14 +82,14 @@ async fn sharing_visibility_requires_explicit_project_grant() { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn core_blocks_are_explicitly_attached_and_separate_from_archival_search() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return; }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state.clone()); let admin_app = routes::admin_router(state.clone()); - let private_block_id = crate::create_core_block( + let private_block_id = helpers::create_core_block( &admin_app, "agent_private", "private_operating_context", @@ -98,7 +98,7 @@ async fn core_blocks_are_explicitly_attached_and_separate_from_archival_search() .await; let note_id = Uuid::new_v4(); - crate::insert_note( + helpers::insert_note( &state, note_id, "agent_private", @@ -108,10 +108,11 @@ async fn core_blocks_are_explicitly_attached_and_separate_from_archival_search() .await; let (status, _) = - crate::attach_core_block(&admin_app, private_block_id, TEST_AGENT_A, "private_only").await; - let before_sessions = crate::search_session_count(&state).await; - let blocks = crate::get_core_blocks(&app, TEST_AGENT_A, "private_only").await; - let after_sessions = crate::search_session_count(&state).await; + helpers::attach_core_block(&admin_app, private_block_id, TEST_AGENT_A, "private_only") + .await; + let before_sessions = helpers::search_session_count(&state).await; + let blocks = helpers::get_core_blocks(&app, TEST_AGENT_A, "private_only").await; + let after_sessions = helpers::search_session_count(&state).await; assert_eq!(status, StatusCode::OK); assert_eq!(before_sessions, after_sessions); @@ -125,30 +126,38 @@ async fn core_blocks_are_explicitly_attached_and_separate_from_archival_search() assert!(blocks["items"][0]["audit_history"].as_array().expect("audit history").len() >= 2); assert!(!blocks.to_string().contains("archival note must not appear")); - let b_private = crate::get_core_blocks(&app, TEST_AGENT_B, "private_only").await; + let b_private = helpers::get_core_blocks(&app, TEST_AGENT_B, "private_only").await; assert_eq!(b_private["items"].as_array().expect("items array").len(), 0); - let shared_block_id = crate::create_core_block( + let shared_block_id = helpers::create_core_block( &admin_app, "project_shared", "shared_operating_context", "Constraint: Shared core context requires explicit project grant and attachment.", ) .await; - let (denied_status, _) = - crate::attach_core_block(&admin_app, shared_block_id, TEST_AGENT_B, "private_plus_project") - .await; + let (denied_status, _) = helpers::attach_core_block( + &admin_app, + shared_block_id, + TEST_AGENT_B, + "private_plus_project", + ) + .await; assert_eq!(denied_status, StatusCode::FORBIDDEN); - crate::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; + helpers::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; - let (shared_status, _) = - crate::attach_core_block(&admin_app, shared_block_id, TEST_AGENT_B, "private_plus_project") - .await; - let b_shared = crate::get_core_blocks(&app, TEST_AGENT_B, "private_plus_project").await; - let b_wrong_profile = crate::get_core_blocks(&app, TEST_AGENT_B, "private_only").await; + let (shared_status, _) = helpers::attach_core_block( + &admin_app, + shared_block_id, + TEST_AGENT_B, + "private_plus_project", + ) + .await; + let b_shared = helpers::get_core_blocks(&app, TEST_AGENT_B, "private_plus_project").await; + let b_wrong_profile = helpers::get_core_blocks(&app, TEST_AGENT_B, "private_only").await; assert_eq!(shared_status, StatusCode::OK); assert_eq!(b_shared["items"].as_array().expect("items array").len(), 1); @@ -162,18 +171,18 @@ async fn core_blocks_are_explicitly_attached_and_separate_from_archival_search() #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn org_shared_note_is_visible_across_projects() { let Some((test_db, app, state, note_id)) = - crate::org_shared_note_is_visible_across_projects_fixture().await + helpers::org_shared_note_is_visible_across_projects_fixture().await else { return; }; - let list_before_json = crate::list_org_shared_notes_as_reader(&app).await; + let list_before_json = helpers::list_org_shared_notes_as_reader(&app).await; assert_eq!(list_before_json["items"].as_array().expect("Missing items array.").len(), 0); - crate::publish_org_shared_note_as_reader_can_see(&app, note_id).await; + helpers::publish_org_shared_note_as_reader_can_see(&app, note_id).await; let grant_upsert_payload = serde_json::json!({ "grantee_kind": "project" }).to_string(); - let grant_upsert_response = crate::post_with_authorization_and_json_body( + let grant_upsert_response = helpers::post_with_authorization_and_json_body( &app, "/v2/spaces/org_shared/grants", "Bearer admin-token", @@ -185,7 +194,7 @@ async fn org_shared_note_is_visible_across_projects() { assert_eq!(grant_upsert_response.status(), StatusCode::OK); - crate::assert_note_visible_to_project_reader(&app, &state, note_id).await; + helpers::assert_note_visible_to_project_reader(&app, &state, note_id).await; test_db.cleanup().await.expect("Failed to cleanup test database."); } @@ -193,15 +202,15 @@ async fn org_shared_note_is_visible_across_projects() { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn sharing_project_grant_enables_agent_access_to_shared_note() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return; }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state.clone()); let note_id = Uuid::new_v4(); - crate::insert_note( + helpers::insert_note( &state, note_id, "project_shared", @@ -209,7 +218,7 @@ async fn sharing_project_grant_enables_agent_access_to_shared_note() { "Fact: shared note with explicit grant.", ) .await; - crate::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; + helpers::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; let response = app .clone() @@ -267,15 +276,15 @@ async fn sharing_project_grant_enables_agent_access_to_shared_note() { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn sharing_publish_creates_scope_and_grant_visibility() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return; }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state.clone()); let note_id = Uuid::new_v4(); - crate::insert_note( + helpers::insert_note( &state, note_id, "agent_private", @@ -284,7 +293,7 @@ async fn sharing_publish_creates_scope_and_grant_visibility() { ) .await; - let initial_grant_count = crate::active_project_grant_count(&state, TEST_AGENT_A).await; + let initial_grant_count = helpers::active_project_grant_count(&state, TEST_AGENT_A).await; assert_eq!(initial_grant_count, 0); @@ -316,7 +325,7 @@ async fn sharing_publish_creates_scope_and_grant_visibility() { assert_eq!(publish_json["note_id"], note_id.to_string()); assert_eq!(publish_json["space"], "team_shared"); - let after_grant_count = crate::active_project_grant_count(&state, TEST_AGENT_A).await; + let after_grant_count = helpers::active_project_grant_count(&state, TEST_AGENT_A).await; assert_eq!(after_grant_count, 1); @@ -377,15 +386,15 @@ async fn sharing_publish_creates_scope_and_grant_visibility() { #[tokio::test] #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] async fn sharing_revoke_project_grant_removes_visibility() { - let Some((test_db, qdrant_url, collection)) = crate::test_env().await else { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return; }; - let config = crate::test_config(test_db.dsn().to_string(), qdrant_url, collection); + let config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); let state = AppState::new(config).await.expect("Failed to initialize app state."); let app = routes::router(state.clone()); let note_id = Uuid::new_v4(); - crate::insert_note( + helpers::insert_note( &state, note_id, "project_shared", @@ -393,9 +402,9 @@ async fn sharing_revoke_project_grant_removes_visibility() { "Fact: shared note for revoke test.", ) .await; - crate::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; + helpers::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; - let grant_count_before = crate::active_project_grant_count(&state, TEST_AGENT_A).await; + let grant_count_before = helpers::active_project_grant_count(&state, TEST_AGENT_A).await; assert_eq!(grant_count_before, 1); @@ -440,7 +449,7 @@ async fn sharing_revoke_project_grant_removes_visibility() { assert_eq!(revoke_response.status(), StatusCode::OK); - let grant_count_after = crate::active_project_grant_count(&state, TEST_AGENT_A).await; + let grant_count_after = helpers::active_project_grant_count(&state, TEST_AGENT_A).await; assert_eq!(grant_count_after, 0);