From 59627b40dc50695ffdcbff4c3df2dfcee55d20a5 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 30 Jun 2026 23:19:51 -0400 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Split remaining oversized test modules","authority":"manual"} --- apps/elf-api/tests/http/request_validation.rs | 488 +--------------- .../http/request_validation/english_gate.rs | 293 ++++++++++ .../tests/http/request_validation/health.rs | 33 ++ .../http/request_validation/payload_level.rs | 180 ++++++ apps/elf-api/tests/http/sharing.rs | 501 +---------------- .../elf-api/tests/http/sharing/core_blocks.rs | 93 +++ apps/elf-api/tests/http/sharing/grants.rs | 233 ++++++++ apps/elf-api/tests/http/sharing/org_shared.rs | 35 ++ apps/elf-api/tests/http/sharing/visibility.rs | 154 +++++ packages/elf-domain/tests/memory_policy.rs | 528 +----------------- .../tests/memory_policy/decisions.rs | 58 ++ .../tests/memory_policy/precedence.rs | 107 ++++ .../elf-domain/tests/memory_policy/support.rs | 277 +++++++++ .../tests/memory_policy/thresholds.rs | 91 +++ .../elf-service/tests/acceptance/suite.rs | 497 +---------------- .../tests/acceptance/suite/config.rs | 266 +++++++++ .../tests/acceptance/suite/providers.rs | 76 +++ .../tests/acceptance/suite/runtime.rs | 154 +++++ 18 files changed, 2068 insertions(+), 1996 deletions(-) create mode 100644 apps/elf-api/tests/http/request_validation/english_gate.rs create mode 100644 apps/elf-api/tests/http/request_validation/health.rs create mode 100644 apps/elf-api/tests/http/request_validation/payload_level.rs create mode 100644 apps/elf-api/tests/http/sharing/core_blocks.rs create mode 100644 apps/elf-api/tests/http/sharing/grants.rs create mode 100644 apps/elf-api/tests/http/sharing/org_shared.rs create mode 100644 apps/elf-api/tests/http/sharing/visibility.rs create mode 100644 packages/elf-domain/tests/memory_policy/decisions.rs create mode 100644 packages/elf-domain/tests/memory_policy/precedence.rs create mode 100644 packages/elf-domain/tests/memory_policy/support.rs create mode 100644 packages/elf-domain/tests/memory_policy/thresholds.rs create mode 100644 packages/elf-service/tests/acceptance/suite/config.rs create mode 100644 packages/elf-service/tests/acceptance/suite/providers.rs create mode 100644 packages/elf-service/tests/acceptance/suite/runtime.rs diff --git a/apps/elf-api/tests/http/request_validation.rs b/apps/elf-api/tests/http/request_validation.rs index 756fb9b1..99304e0c 100644 --- a/apps/elf-api/tests/http/request_validation.rs +++ b/apps/elf-api/tests/http/request_validation.rs @@ -1,485 +1,3 @@ -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}; - -fn payload_level_source_ref() -> Value { - serde_json::json!({ - "schema": "note_source_ref/v1", - "locator": { - "document_id": Uuid::new_v4().to_string(), - "chunk_id": Uuid::new_v4().to_string(), - "revision": "payload-shaping-contract-test" - }, - "metadata": { - "heavy_field": "This field should be hidden when payload_level is below l2." - } - }) -} - -#[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)) = 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.clone()); - let _ = routes::admin_router(state); - let response = app - .oneshot( - Request::builder() - .uri("/health") - .body(Body::empty()) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call /health."); - - assert_eq!(response.status(), StatusCode::OK); - - 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 rejects_non_english_in_add_note() { - 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!({ - "scope": "agent_private", - "notes": [{ - "type": "fact", - "key": null, - "text": "你好", - "importance": 0.5, - "confidence": 0.9, - "ttl_days": null, - "source_ref": {} - }] - }); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/notes/ingest") - .header("X-ELF-Tenant-Id", "t") - .header("X-ELF-Project-Id", "p") - .header("X-ELF-Agent-Id", "a") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call add_note."); - - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let body = body::to_bytes(response.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"], "NON_ENGLISH_INPUT"); - assert_eq!(json["fields"][0], "$.notes[0].text"); - - 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 rejects_cyrillic_in_add_note() { - 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!({ - "scope": "agent_private", - "notes": [{ - "type": "fact", - "key": null, - "text": "Привет мир", - "importance": 0.5, - "confidence": 0.9, - "ttl_days": null, - "source_ref": {} - }] - }); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/notes/ingest") - .header("X-ELF-Tenant-Id", "t") - .header("X-ELF-Project-Id", "p") - .header("X-ELF-Agent-Id", "a") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call add_note."); - - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let body = body::to_bytes(response.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"], "NON_ENGLISH_INPUT"); - assert_eq!(json["fields"][0], "$.notes[0].text"); - - 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 rejects_non_english_in_add_event() { - 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!({ - "scope": "agent_private", - "dry_run": true, - "messages": [{ - "role": "user", - "content": "こんにちは" - }] - }); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/events/ingest") - .header("X-ELF-Tenant-Id", "t") - .header("X-ELF-Project-Id", "p") - .header("X-ELF-Agent-Id", "a") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call add_event."); - - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let body = body::to_bytes(response.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"], "NON_ENGLISH_INPUT"); - assert_eq!(json["fields"][0], "$.messages[0].content"); - - 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 rejects_cyrillic_in_add_event() { - 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!({ - "scope": "agent_private", - "dry_run": true, - "messages": [{ - "role": "user", - "content": "Это не английский текст." - }] - }); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/events/ingest") - .header("X-ELF-Tenant-Id", "t") - .header("X-ELF-Project-Id", "p") - .header("X-ELF-Agent-Id", "a") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call add_event."); - - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let body = body::to_bytes(response.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"], "NON_ENGLISH_INPUT"); - assert_eq!(json["fields"][0], "$.messages[0].content"); - - 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 rejects_non_english_in_search() { - 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); - - for mode in ["quick_find", "planned_search"] { - let payload = serde_json::json!({ - "mode": mode, - "query": "안녕하세요", - "top_k": 5, - "candidate_k": 10, - }); - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/searches") - .header("X-ELF-Tenant-Id", "t") - .header("X-ELF-Project-Id", "p") - .header("X-ELF-Agent-Id", "a") - .header("X-ELF-Read-Profile", "private_only") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call search."); - - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let body = body::to_bytes(response.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"], "NON_ENGLISH_INPUT"); - assert_eq!(json["fields"][0], "$.query"); - } - - 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 rejects_cyrillic_in_search() { - 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); - - for mode in ["quick_find", "planned_search"] { - let payload = serde_json::json!({ - "mode": mode, - "query": "Привет", - "top_k": 5, - "candidate_k": 10, - }); - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/searches") - .header("X-ELF-Tenant-Id", "t") - .header("X-ELF-Project-Id", "p") - .header("X-ELF-Agent-Id", "a") - .header("X-ELF-Read-Profile", "private_only") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call search."); - - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - - let body = body::to_bytes(response.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"], "NON_ENGLISH_INPUT"); - assert_eq!(json["fields"][0], "$.query"); - } - - 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 searches_notes_payload_level_shapes_source_ref_and_structured() { - 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.clone()); - let source_ref = payload_level_source_ref(); - let structured_summary = "Compact structured summary used for payload-level l1 and l2 shaping."; - let note_text = - "Payload shaping note used in contract tests for search details output shaping."; - let note_id = - helpers::create_note_for_payload_level_tests(&app, &state, note_text, source_ref.clone()) - .await; - - helpers::insert_note_summary_field(&state, note_id, structured_summary).await; - - let search_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/searches") - .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( - serde_json::json!({ - "mode": "quick_find", - "query": "payload shaping", - "top_k": 5, - "candidate_k": 10, - }) - .to_string(), - )) - .expect("Failed to build searches request."), - ) - .await - .expect("Failed to call searches."); - - assert_eq!(search_response.status(), StatusCode::OK); - - let search_body = body::to_bytes(search_response.into_body(), usize::MAX) - .await - .expect("Failed to read searches response body."); - let search_json: Value = - serde_json::from_slice(&search_body).expect("Failed to parse searches response."); - let trajectory = &search_json["trajectory_summary"]; - - if !trajectory.is_null() { - assert!(trajectory.is_object()); - assert!(trajectory.get("stages").is_some()); - } - - let search_id = Uuid::parse_str( - search_json["search_id"].as_str().expect("Missing search_id in searches response."), - ) - .expect("Invalid search_id value."); - let notes_l0 = - helpers::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l0").await; - let notes_l1 = - helpers::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l1").await; - let notes_l2 = - helpers::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l2").await; - let search_get_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/v2/searches/{search_id}")) - .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") - .body(Body::empty()) - .expect("Failed to build searches get request."), - ) - .await - .expect("Failed to call searches get."); - - assert_eq!(search_get_response.status(), StatusCode::OK); - - let search_get_body = body::to_bytes(search_get_response.into_body(), usize::MAX) - .await - .expect("Failed to read searches get response body."); - let search_get_json: Value = - serde_json::from_slice(&search_get_body).expect("Failed to parse searches get response."); - let search_get_trajectory = &search_get_json["trajectory_summary"]; - - if !search_get_trajectory.is_null() { - assert!(search_get_trajectory.is_object()); - assert!(search_get_trajectory.get("stages").is_some()); - } - - let notes_l0_text = notes_l0["text"].as_str().expect("Missing l0 text."); - let notes_l1_text = notes_l1["text"].as_str().expect("Missing l1 text."); - let notes_l2_text = notes_l2["text"].as_str().expect("Missing l2 text."); - - assert_eq!(notes_l0["source_ref"], serde_json::json!({})); - assert_eq!(notes_l1["source_ref"], serde_json::json!({})); - assert_eq!(notes_l2["source_ref"], source_ref); - assert!(notes_l0["structured"].is_null()); - assert!(notes_l1["structured"].is_object()); - assert!(notes_l2["structured"].is_object()); - assert!(notes_l0_text.len() <= 240); - assert_eq!(notes_l0_text, note_text); - assert_eq!(notes_l1_text, structured_summary); - assert_eq!(notes_l2_text, note_text); - - 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_searches_raw_payload_level_shapes_source_ref() { - 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.clone()); - let admin_app = routes::admin_router(state.clone()); - let source_ref = serde_json::json!({ - "schema": "note_source_ref/v1", - "locator": { - "document_id": Uuid::new_v4().to_string(), - "chunk_id": Uuid::new_v4().to_string(), - "revision": "admin-raw-contract-test" - }, - "metadata": { - "heavy_field": "This field should be hidden when payload_level is below l2." - } - }); - let note_text = - "Admin raw search payload shaping contract note. This long note should be indexed."; - let _note_id = - helpers::create_note_for_payload_level_tests(&app, &state, note_text, source_ref.clone()) - .await; - let raw_l0 = - helpers::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l0").await; - let raw_l1 = - helpers::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l1").await; - let raw_l2 = - 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!({})); - assert_eq!(raw_l2, source_ref); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} +#[path = "request_validation/english_gate.rs"] mod english_gate; +#[path = "request_validation/health.rs"] mod health; +#[path = "request_validation/payload_level.rs"] mod payload_level; diff --git a/apps/elf-api/tests/http/request_validation/english_gate.rs b/apps/elf-api/tests/http/request_validation/english_gate.rs new file mode 100644 index 00000000..fce5b20f --- /dev/null +++ b/apps/elf-api/tests/http/request_validation/english_gate.rs @@ -0,0 +1,293 @@ +use axum::{ + body::{self, Body}, + http::{Request, StatusCode}, +}; +use serde_json::Value; +use tower::util::ServiceExt as _; + +use crate::helpers; +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 rejects_non_english_in_add_note() { + 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!({ + "scope": "agent_private", + "notes": [{ + "type": "fact", + "key": null, + "text": "你好", + "importance": 0.5, + "confidence": 0.9, + "ttl_days": null, + "source_ref": {} + }] + }); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/notes/ingest") + .header("X-ELF-Tenant-Id", "t") + .header("X-ELF-Project-Id", "p") + .header("X-ELF-Agent-Id", "a") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call add_note."); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = body::to_bytes(response.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"], "NON_ENGLISH_INPUT"); + assert_eq!(json["fields"][0], "$.notes[0].text"); + + 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 rejects_cyrillic_in_add_note() { + 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!({ + "scope": "agent_private", + "notes": [{ + "type": "fact", + "key": null, + "text": "Привет мир", + "importance": 0.5, + "confidence": 0.9, + "ttl_days": null, + "source_ref": {} + }] + }); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/notes/ingest") + .header("X-ELF-Tenant-Id", "t") + .header("X-ELF-Project-Id", "p") + .header("X-ELF-Agent-Id", "a") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call add_note."); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = body::to_bytes(response.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"], "NON_ENGLISH_INPUT"); + assert_eq!(json["fields"][0], "$.notes[0].text"); + + 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 rejects_non_english_in_add_event() { + 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!({ + "scope": "agent_private", + "dry_run": true, + "messages": [{ + "role": "user", + "content": "こんにちは" + }] + }); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/events/ingest") + .header("X-ELF-Tenant-Id", "t") + .header("X-ELF-Project-Id", "p") + .header("X-ELF-Agent-Id", "a") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call add_event."); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = body::to_bytes(response.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"], "NON_ENGLISH_INPUT"); + assert_eq!(json["fields"][0], "$.messages[0].content"); + + 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 rejects_cyrillic_in_add_event() { + 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!({ + "scope": "agent_private", + "dry_run": true, + "messages": [{ + "role": "user", + "content": "Это не английский текст." + }] + }); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/events/ingest") + .header("X-ELF-Tenant-Id", "t") + .header("X-ELF-Project-Id", "p") + .header("X-ELF-Agent-Id", "a") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call add_event."); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = body::to_bytes(response.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"], "NON_ENGLISH_INPUT"); + assert_eq!(json["fields"][0], "$.messages[0].content"); + + 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 rejects_non_english_in_search() { + 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); + + for mode in ["quick_find", "planned_search"] { + let payload = serde_json::json!({ + "mode": mode, + "query": "안녕하세요", + "top_k": 5, + "candidate_k": 10, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/searches") + .header("X-ELF-Tenant-Id", "t") + .header("X-ELF-Project-Id", "p") + .header("X-ELF-Agent-Id", "a") + .header("X-ELF-Read-Profile", "private_only") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call search."); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = body::to_bytes(response.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"], "NON_ENGLISH_INPUT"); + assert_eq!(json["fields"][0], "$.query"); + } + + 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 rejects_cyrillic_in_search() { + 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); + + for mode in ["quick_find", "planned_search"] { + let payload = serde_json::json!({ + "mode": mode, + "query": "Привет", + "top_k": 5, + "candidate_k": 10, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/searches") + .header("X-ELF-Tenant-Id", "t") + .header("X-ELF-Project-Id", "p") + .header("X-ELF-Agent-Id", "a") + .header("X-ELF-Read-Profile", "private_only") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call search."); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = body::to_bytes(response.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"], "NON_ENGLISH_INPUT"); + assert_eq!(json["fields"][0], "$.query"); + } + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/request_validation/health.rs b/apps/elf-api/tests/http/request_validation/health.rs new file mode 100644 index 00000000..1b283cf3 --- /dev/null +++ b/apps/elf-api/tests/http/request_validation/health.rs @@ -0,0 +1,33 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use tower::util::ServiceExt as _; + +use crate::helpers; +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 health_ok() { + 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.clone()); + let _ = routes::admin_router(state); + let response = app + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call /health."); + + assert_eq!(response.status(), StatusCode::OK); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/request_validation/payload_level.rs b/apps/elf-api/tests/http/request_validation/payload_level.rs new file mode 100644 index 00000000..323f81c5 --- /dev/null +++ b/apps/elf-api/tests/http/request_validation/payload_level.rs @@ -0,0 +1,180 @@ +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}; + +fn payload_level_source_ref() -> Value { + serde_json::json!({ + "schema": "note_source_ref/v1", + "locator": { + "document_id": Uuid::new_v4().to_string(), + "chunk_id": Uuid::new_v4().to_string(), + "revision": "payload-shaping-contract-test" + }, + "metadata": { + "heavy_field": "This field should be hidden when payload_level is below l2." + } + }) +} + +#[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)) = 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.clone()); + let source_ref = payload_level_source_ref(); + let structured_summary = "Compact structured summary used for payload-level l1 and l2 shaping."; + let note_text = + "Payload shaping note used in contract tests for search details output shaping."; + let note_id = + helpers::create_note_for_payload_level_tests(&app, &state, note_text, source_ref.clone()) + .await; + + helpers::insert_note_summary_field(&state, note_id, structured_summary).await; + + let search_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/searches") + .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( + serde_json::json!({ + "mode": "quick_find", + "query": "payload shaping", + "top_k": 5, + "candidate_k": 10, + }) + .to_string(), + )) + .expect("Failed to build searches request."), + ) + .await + .expect("Failed to call searches."); + + assert_eq!(search_response.status(), StatusCode::OK); + + let search_body = body::to_bytes(search_response.into_body(), usize::MAX) + .await + .expect("Failed to read searches response body."); + let search_json: Value = + serde_json::from_slice(&search_body).expect("Failed to parse searches response."); + let trajectory = &search_json["trajectory_summary"]; + + if !trajectory.is_null() { + assert!(trajectory.is_object()); + assert!(trajectory.get("stages").is_some()); + } + + let search_id = Uuid::parse_str( + search_json["search_id"].as_str().expect("Missing search_id in searches response."), + ) + .expect("Invalid search_id value."); + let notes_l0 = + helpers::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l0").await; + let notes_l1 = + helpers::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l1").await; + let notes_l2 = + helpers::fetch_search_notes_for_payload_level(&app, search_id, note_id, "l2").await; + let search_get_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/v2/searches/{search_id}")) + .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") + .body(Body::empty()) + .expect("Failed to build searches get request."), + ) + .await + .expect("Failed to call searches get."); + + assert_eq!(search_get_response.status(), StatusCode::OK); + + let search_get_body = body::to_bytes(search_get_response.into_body(), usize::MAX) + .await + .expect("Failed to read searches get response body."); + let search_get_json: Value = + serde_json::from_slice(&search_get_body).expect("Failed to parse searches get response."); + let search_get_trajectory = &search_get_json["trajectory_summary"]; + + if !search_get_trajectory.is_null() { + assert!(search_get_trajectory.is_object()); + assert!(search_get_trajectory.get("stages").is_some()); + } + + let notes_l0_text = notes_l0["text"].as_str().expect("Missing l0 text."); + let notes_l1_text = notes_l1["text"].as_str().expect("Missing l1 text."); + let notes_l2_text = notes_l2["text"].as_str().expect("Missing l2 text."); + + assert_eq!(notes_l0["source_ref"], serde_json::json!({})); + assert_eq!(notes_l1["source_ref"], serde_json::json!({})); + assert_eq!(notes_l2["source_ref"], source_ref); + assert!(notes_l0["structured"].is_null()); + assert!(notes_l1["structured"].is_object()); + assert!(notes_l2["structured"].is_object()); + assert!(notes_l0_text.len() <= 240); + assert_eq!(notes_l0_text, note_text); + assert_eq!(notes_l1_text, structured_summary); + assert_eq!(notes_l2_text, note_text); + + 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_searches_raw_payload_level_shapes_source_ref() { + 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.clone()); + let admin_app = routes::admin_router(state.clone()); + let source_ref = serde_json::json!({ + "schema": "note_source_ref/v1", + "locator": { + "document_id": Uuid::new_v4().to_string(), + "chunk_id": Uuid::new_v4().to_string(), + "revision": "admin-raw-contract-test" + }, + "metadata": { + "heavy_field": "This field should be hidden when payload_level is below l2." + } + }); + let note_text = + "Admin raw search payload shaping contract note. This long note should be indexed."; + let _note_id = + helpers::create_note_for_payload_level_tests(&app, &state, note_text, source_ref.clone()) + .await; + let raw_l0 = + helpers::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l0").await; + let raw_l1 = + helpers::fetch_admin_search_raw_source_ref(&admin_app, "payload shaping", "l1").await; + let raw_l2 = + 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!({})); + assert_eq!(raw_l2, source_ref); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/sharing.rs b/apps/elf-api/tests/http/sharing.rs index 8109daef..4874ffa7 100644 --- a/apps/elf-api/tests/http/sharing.rs +++ b/apps/elf-api/tests/http/sharing.rs @@ -1,497 +1,4 @@ -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_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)) = 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.clone()); - let note_id = Uuid::new_v4(); - - helpers::insert_note( - &state, - note_id, - "project_shared", - TEST_AGENT_A, - "Fact: shared note without grant", - ) - .await; - - let response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v2/notes?scope=project_shared") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .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."); - let list_json: Value = serde_json::from_slice(&body).expect("Failed to parse list response."); - - assert_eq!(list_json["items"].as_array().expect("Missing items array.").len(), 0); - - let note_response = app - .clone() - .oneshot( - Request::builder() - .uri(format!("/v2/notes/{note_id}")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build get request."), - ) - .await - .expect("Failed to call notes get."); - - assert_eq!(note_response.status(), StatusCode::BAD_REQUEST); - - let body = body::to_bytes(note_response.into_body(), usize::MAX) - .await - .expect("Failed to read get response body."); - let note_json: Value = serde_json::from_slice(&body).expect("Failed to parse get response."); - - assert_eq!(note_json["error_code"], "INVALID_REQUEST"); - - 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 core_blocks_are_explicitly_attached_and_separate_from_archival_search() { - 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.clone()); - let admin_app = routes::admin_router(state.clone()); - let private_block_id = helpers::create_core_block( - &admin_app, - "agent_private", - "private_operating_context", - "Preference: Keep core context separate from archival search.", - ) - .await; - let note_id = Uuid::new_v4(); - - helpers::insert_note( - &state, - note_id, - "agent_private", - TEST_AGENT_A, - "Fact: This archival note must not appear in attached core blocks.", - ) - .await; - - let (status, _) = - 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); - assert_eq!(blocks["schema"], "elf.core_memory_blocks/v1"); - assert_eq!(blocks["items"].as_array().expect("items array").len(), 1); - assert_eq!( - blocks["items"][0]["content"], - "Preference: Keep core context separate from archival search." - ); - assert_eq!(blocks["items"][0]["source_ref"]["schema"], "core_block_source/v1"); - 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 = 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 = 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, _) = helpers::attach_core_block( - &admin_app, - shared_block_id, - TEST_AGENT_B, - "private_plus_project", - ) - .await; - - assert_eq!(denied_status, StatusCode::FORBIDDEN); - - helpers::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).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); - assert_eq!(b_shared["items"][0]["scope"], "project_shared"); - assert_eq!(b_wrong_profile["items"].as_array().expect("items array").len(), 0); - - 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 org_shared_note_is_visible_across_projects() { - let Some((test_db, app, state, note_id)) = - helpers::org_shared_note_is_visible_across_projects_fixture().await - else { - return; - }; - 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); - - 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 = 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.", - ) - .await; - - assert_eq!(grant_upsert_response.status(), StatusCode::OK); - - helpers::assert_note_visible_to_project_reader(&app, &state, 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 sharing_project_grant_enables_agent_access_to_shared_note() { - 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.clone()); - let note_id = Uuid::new_v4(); - - helpers::insert_note( - &state, - note_id, - "project_shared", - TEST_AGENT_A, - "Fact: shared note with explicit grant.", - ) - .await; - helpers::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; - - let response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v2/notes?scope=project_shared") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .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."); - let list_json: Value = serde_json::from_slice(&body).expect("Failed to parse list response."); - let items = list_json["items"].as_array().expect("Missing items array."); - - assert_eq!(items.len(), 1); - assert_eq!(items[0]["note_id"], note_id.to_string()); - - let note_response = app - .clone() - .oneshot( - Request::builder() - .uri(format!("/v2/notes/{note_id}")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build get request."), - ) - .await - .expect("Failed to call notes get."); - - assert_eq!(note_response.status(), StatusCode::OK); - - let body = body::to_bytes(note_response.into_body(), usize::MAX) - .await - .expect("Failed to read get response body."); - let note_json: Value = serde_json::from_slice(&body).expect("Failed to parse get response."); - - assert_eq!(note_json["note_id"], note_id.to_string()); - assert_eq!(note_json["scope"], "project_shared"); - - 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 sharing_publish_creates_scope_and_grant_visibility() { - 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.clone()); - let note_id = Uuid::new_v4(); - - helpers::insert_note( - &state, - note_id, - "agent_private", - TEST_AGENT_A, - "Fact: private note for publish test.", - ) - .await; - - let initial_grant_count = helpers::active_project_grant_count(&state, TEST_AGENT_A).await; - - assert_eq!(initial_grant_count, 0); - - let publish_payload = serde_json::json!({"space":"team_shared"}).to_string(); - let publish_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/v2/notes/{note_id}/publish")) - .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(publish_payload)) - .expect("Failed to build publish request."), - ) - .await - .expect("Failed to call note publish."); - - assert_eq!(publish_response.status(), StatusCode::OK); - - let publish_body = body::to_bytes(publish_response.into_body(), usize::MAX) - .await - .expect("Failed to read publish response body."); - let publish_json: Value = - serde_json::from_slice(&publish_body).expect("Failed to parse publish response."); - - assert_eq!(publish_json["note_id"], note_id.to_string()); - assert_eq!(publish_json["space"], "team_shared"); - - let after_grant_count = helpers::active_project_grant_count(&state, TEST_AGENT_A).await; - - assert_eq!(after_grant_count, 1); - - let list_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v2/notes?scope=project_shared") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build list request."), - ) - .await - .expect("Failed to call notes list."); - - assert_eq!(list_response.status(), StatusCode::OK); - - let list_body = body::to_bytes(list_response.into_body(), usize::MAX) - .await - .expect("Failed to read list response body."); - let list_json: Value = - serde_json::from_slice(&list_body).expect("Failed to parse list response."); - let items = list_json["items"].as_array().expect("Missing items array."); - - assert_eq!(items.len(), 1); - assert_eq!(items[0]["note_id"], note_id.to_string()); - - let get_response = app - .clone() - .oneshot( - Request::builder() - .uri(format!("/v2/notes/{note_id}")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build get request."), - ) - .await - .expect("Failed to call notes get."); - - assert_eq!(get_response.status(), StatusCode::OK); - - let get_body = body::to_bytes(get_response.into_body(), usize::MAX) - .await - .expect("Failed to read get response body."); - let get_json: Value = serde_json::from_slice(&get_body).expect("Failed to parse get response."); - - assert_eq!(get_json["note_id"], note_id.to_string()); - assert_eq!(get_json["scope"], "project_shared"); - - 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 sharing_revoke_project_grant_removes_visibility() { - 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.clone()); - let note_id = Uuid::new_v4(); - - helpers::insert_note( - &state, - note_id, - "project_shared", - TEST_AGENT_A, - "Fact: shared note for revoke test.", - ) - .await; - helpers::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; - - let grant_count_before = helpers::active_project_grant_count(&state, TEST_AGENT_A).await; - - assert_eq!(grant_count_before, 1); - - let list_before = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v2/notes?scope=project_shared") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build list request."), - ) - .await - .expect("Failed to call notes list."); - let list_before_body = body::to_bytes(list_before.into_body(), usize::MAX) - .await - .expect("Failed to read list response body."); - let list_before_json: Value = - serde_json::from_slice(&list_before_body).expect("Failed to parse list response."); - - assert_eq!(list_before_json["items"].as_array().expect("Missing items array.").len(), 1); - - let revoke_payload = serde_json::json!({"grantee_kind":"project"}).to_string(); - let revoke_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/spaces/team_shared/grants/revoke") - .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(revoke_payload)) - .expect("Failed to build revoke request."), - ) - .await - .expect("Failed to call grant revoke."); - - assert_eq!(revoke_response.status(), StatusCode::OK); - - let grant_count_after = helpers::active_project_grant_count(&state, TEST_AGENT_A).await; - - assert_eq!(grant_count_after, 0); - - let list_after = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/v2/notes?scope=project_shared") - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build list request."), - ) - .await - .expect("Failed to call notes list."); - - assert_eq!(list_after.status(), StatusCode::OK); - - let list_after_body = body::to_bytes(list_after.into_body(), usize::MAX) - .await - .expect("Failed to read list response body."); - let list_after_json: Value = - serde_json::from_slice(&list_after_body).expect("Failed to parse list response."); - - assert_eq!(list_after_json["items"].as_array().expect("Missing items array.").len(), 0); - - let get_after = app - .oneshot( - Request::builder() - .uri(format!("/v2/notes/{note_id}")) - .header("X-ELF-Tenant-Id", TEST_TENANT_ID) - .header("X-ELF-Project-Id", TEST_PROJECT_ID) - .header("X-ELF-Agent-Id", TEST_AGENT_B) - .body(Body::empty()) - .expect("Failed to build get request."), - ) - .await - .expect("Failed to call notes get."); - - assert_eq!(get_after.status(), StatusCode::BAD_REQUEST); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} +#[path = "sharing/core_blocks.rs"] mod core_blocks; +#[path = "sharing/grants.rs"] mod grants; +#[path = "sharing/org_shared.rs"] mod org_shared; +#[path = "sharing/visibility.rs"] mod visibility; diff --git a/apps/elf-api/tests/http/sharing/core_blocks.rs b/apps/elf-api/tests/http/sharing/core_blocks.rs new file mode 100644 index 00000000..7f816b06 --- /dev/null +++ b/apps/elf-api/tests/http/sharing/core_blocks.rs @@ -0,0 +1,93 @@ +use axum::http::StatusCode; +use uuid::Uuid; + +use crate::helpers::{self, TEST_AGENT_A, TEST_AGENT_B}; +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 core_blocks_are_explicitly_attached_and_separate_from_archival_search() { + 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.clone()); + let admin_app = routes::admin_router(state.clone()); + let private_block_id = helpers::create_core_block( + &admin_app, + "agent_private", + "private_operating_context", + "Preference: Keep core context separate from archival search.", + ) + .await; + let note_id = Uuid::new_v4(); + + helpers::insert_note( + &state, + note_id, + "agent_private", + TEST_AGENT_A, + "Fact: This archival note must not appear in attached core blocks.", + ) + .await; + + let (status, _) = + 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); + assert_eq!(blocks["schema"], "elf.core_memory_blocks/v1"); + assert_eq!(blocks["items"].as_array().expect("items array").len(), 1); + assert_eq!( + blocks["items"][0]["content"], + "Preference: Keep core context separate from archival search." + ); + assert_eq!(blocks["items"][0]["source_ref"]["schema"], "core_block_source/v1"); + 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 = 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 = 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, _) = helpers::attach_core_block( + &admin_app, + shared_block_id, + TEST_AGENT_B, + "private_plus_project", + ) + .await; + + assert_eq!(denied_status, StatusCode::FORBIDDEN); + + helpers::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).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); + assert_eq!(b_shared["items"][0]["scope"], "project_shared"); + assert_eq!(b_wrong_profile["items"].as_array().expect("items array").len(), 0); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/sharing/grants.rs b/apps/elf-api/tests/http/sharing/grants.rs new file mode 100644 index 00000000..93aaabec --- /dev/null +++ b/apps/elf-api/tests/http/sharing/grants.rs @@ -0,0 +1,233 @@ +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_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_publish_creates_scope_and_grant_visibility() { + 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.clone()); + let note_id = Uuid::new_v4(); + + helpers::insert_note( + &state, + note_id, + "agent_private", + TEST_AGENT_A, + "Fact: private note for publish test.", + ) + .await; + + let initial_grant_count = helpers::active_project_grant_count(&state, TEST_AGENT_A).await; + + assert_eq!(initial_grant_count, 0); + + let publish_payload = serde_json::json!({"space":"team_shared"}).to_string(); + let publish_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/v2/notes/{note_id}/publish")) + .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(publish_payload)) + .expect("Failed to build publish request."), + ) + .await + .expect("Failed to call note publish."); + + assert_eq!(publish_response.status(), StatusCode::OK); + + let publish_body = body::to_bytes(publish_response.into_body(), usize::MAX) + .await + .expect("Failed to read publish response body."); + let publish_json: Value = + serde_json::from_slice(&publish_body).expect("Failed to parse publish response."); + + assert_eq!(publish_json["note_id"], note_id.to_string()); + assert_eq!(publish_json["space"], "team_shared"); + + let after_grant_count = helpers::active_project_grant_count(&state, TEST_AGENT_A).await; + + assert_eq!(after_grant_count, 1); + + let list_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v2/notes?scope=project_shared") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build list request."), + ) + .await + .expect("Failed to call notes list."); + + assert_eq!(list_response.status(), StatusCode::OK); + + let list_body = body::to_bytes(list_response.into_body(), usize::MAX) + .await + .expect("Failed to read list response body."); + let list_json: Value = + serde_json::from_slice(&list_body).expect("Failed to parse list response."); + let items = list_json["items"].as_array().expect("Missing items array."); + + assert_eq!(items.len(), 1); + assert_eq!(items[0]["note_id"], note_id.to_string()); + + let get_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v2/notes/{note_id}")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build get request."), + ) + .await + .expect("Failed to call notes get."); + + assert_eq!(get_response.status(), StatusCode::OK); + + let get_body = body::to_bytes(get_response.into_body(), usize::MAX) + .await + .expect("Failed to read get response body."); + let get_json: Value = serde_json::from_slice(&get_body).expect("Failed to parse get response."); + + assert_eq!(get_json["note_id"], note_id.to_string()); + assert_eq!(get_json["scope"], "project_shared"); + + 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 sharing_revoke_project_grant_removes_visibility() { + 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.clone()); + let note_id = Uuid::new_v4(); + + helpers::insert_note( + &state, + note_id, + "project_shared", + TEST_AGENT_A, + "Fact: shared note for revoke test.", + ) + .await; + helpers::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; + + let grant_count_before = helpers::active_project_grant_count(&state, TEST_AGENT_A).await; + + assert_eq!(grant_count_before, 1); + + let list_before = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v2/notes?scope=project_shared") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build list request."), + ) + .await + .expect("Failed to call notes list."); + let list_before_body = body::to_bytes(list_before.into_body(), usize::MAX) + .await + .expect("Failed to read list response body."); + let list_before_json: Value = + serde_json::from_slice(&list_before_body).expect("Failed to parse list response."); + + assert_eq!(list_before_json["items"].as_array().expect("Missing items array.").len(), 1); + + let revoke_payload = serde_json::json!({"grantee_kind":"project"}).to_string(); + let revoke_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/spaces/team_shared/grants/revoke") + .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(revoke_payload)) + .expect("Failed to build revoke request."), + ) + .await + .expect("Failed to call grant revoke."); + + assert_eq!(revoke_response.status(), StatusCode::OK); + + let grant_count_after = helpers::active_project_grant_count(&state, TEST_AGENT_A).await; + + assert_eq!(grant_count_after, 0); + + let list_after = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v2/notes?scope=project_shared") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build list request."), + ) + .await + .expect("Failed to call notes list."); + + assert_eq!(list_after.status(), StatusCode::OK); + + let list_after_body = body::to_bytes(list_after.into_body(), usize::MAX) + .await + .expect("Failed to read list response body."); + let list_after_json: Value = + serde_json::from_slice(&list_after_body).expect("Failed to parse list response."); + + assert_eq!(list_after_json["items"].as_array().expect("Missing items array.").len(), 0); + + let get_after = app + .oneshot( + Request::builder() + .uri(format!("/v2/notes/{note_id}")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build get request."), + ) + .await + .expect("Failed to call notes get."); + + assert_eq!(get_after.status(), StatusCode::BAD_REQUEST); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/sharing/org_shared.rs b/apps/elf-api/tests/http/sharing/org_shared.rs new file mode 100644 index 00000000..1d386aa8 --- /dev/null +++ b/apps/elf-api/tests/http/sharing/org_shared.rs @@ -0,0 +1,35 @@ +use axum::http::StatusCode; + +use crate::helpers; + +#[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 org_shared_note_is_visible_across_projects() { + let Some((test_db, app, state, note_id)) = + helpers::org_shared_note_is_visible_across_projects_fixture().await + else { + return; + }; + 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); + + 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 = 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.", + ) + .await; + + assert_eq!(grant_upsert_response.status(), StatusCode::OK); + + helpers::assert_note_visible_to_project_reader(&app, &state, note_id).await; + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/sharing/visibility.rs b/apps/elf-api/tests/http/sharing/visibility.rs new file mode 100644 index 00000000..3656e52c --- /dev/null +++ b/apps/elf-api/tests/http/sharing/visibility.rs @@ -0,0 +1,154 @@ +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_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)) = 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.clone()); + let note_id = Uuid::new_v4(); + + helpers::insert_note( + &state, + note_id, + "project_shared", + TEST_AGENT_A, + "Fact: shared note without grant", + ) + .await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v2/notes?scope=project_shared") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .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."); + let list_json: Value = serde_json::from_slice(&body).expect("Failed to parse list response."); + + assert_eq!(list_json["items"].as_array().expect("Missing items array.").len(), 0); + + let note_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v2/notes/{note_id}")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build get request."), + ) + .await + .expect("Failed to call notes get."); + + assert_eq!(note_response.status(), StatusCode::BAD_REQUEST); + + let body = body::to_bytes(note_response.into_body(), usize::MAX) + .await + .expect("Failed to read get response body."); + let note_json: Value = serde_json::from_slice(&body).expect("Failed to parse get response."); + + assert_eq!(note_json["error_code"], "INVALID_REQUEST"); + + 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 sharing_project_grant_enables_agent_access_to_shared_note() { + 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.clone()); + let note_id = Uuid::new_v4(); + + helpers::insert_note( + &state, + note_id, + "project_shared", + TEST_AGENT_A, + "Fact: shared note with explicit grant.", + ) + .await; + helpers::insert_project_scope_grant(&state, TEST_AGENT_A, TEST_AGENT_A).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v2/notes?scope=project_shared") + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .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."); + let list_json: Value = serde_json::from_slice(&body).expect("Failed to parse list response."); + let items = list_json["items"].as_array().expect("Missing items array."); + + assert_eq!(items.len(), 1); + assert_eq!(items[0]["note_id"], note_id.to_string()); + + let note_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v2/notes/{note_id}")) + .header("X-ELF-Tenant-Id", TEST_TENANT_ID) + .header("X-ELF-Project-Id", TEST_PROJECT_ID) + .header("X-ELF-Agent-Id", TEST_AGENT_B) + .body(Body::empty()) + .expect("Failed to build get request."), + ) + .await + .expect("Failed to call notes get."); + + assert_eq!(note_response.status(), StatusCode::OK); + + let body = body::to_bytes(note_response.into_body(), usize::MAX) + .await + .expect("Failed to read get response body."); + let note_json: Value = serde_json::from_slice(&body).expect("Failed to parse get response."); + + assert_eq!(note_json["note_id"], note_id.to_string()); + assert_eq!(note_json["scope"], "project_shared"); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/packages/elf-domain/tests/memory_policy.rs b/packages/elf-domain/tests/memory_policy.rs index 18261e00..27035a5a 100644 --- a/packages/elf-domain/tests/memory_policy.rs +++ b/packages/elf-domain/tests/memory_policy.rs @@ -2,527 +2,7 @@ //! Integration tests for memory-policy evaluation. -use serde_json::Map; - -use elf_config::{ - Chunking, Config, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, Memory, MemoryPolicy, - MemoryPolicyRule, Postgres, ProviderConfig, Providers, Qdrant, Ranking, RankingBlend, - RankingBlendSegment, RankingDeterministic, RankingDeterministicDecay, RankingDeterministicHits, - RankingDeterministicLexical, RankingDiversity, RankingRetrievalSources, ReadProfiles, - ScopePrecedence, ScopeWriteAllowed, Scopes, Search, SearchCache, SearchDynamic, - SearchExpansion, SearchExplain, SearchGraphContext, SearchPrefilter, SearchRecursive, Security, - Service, Storage, TtlDays, -}; -use elf_domain::memory_policy::{self, MemoryPolicyDecision, MemoryPolicyEvaluation}; - -fn memory_policy_config(policy: MemoryPolicy) -> Config { - let mut cfg = memory_policy_default_config(); - - cfg.memory.policy = policy; - - cfg -} - -fn memory_policy_default_config() -> Config { - Config { - service: memory_policy_service_config(), - storage: memory_policy_storage_config(), - providers: memory_policy_providers_config(), - scopes: memory_policy_scopes_config(), - memory: memory_policy_memory_config(), - search: memory_policy_search_config(), - ranking: memory_policy_ranking_config(), - lifecycle: memory_policy_lifecycle_config(), - security: memory_policy_security_config(), - chunking: memory_policy_chunking_config(), - context: None, - mcp: None, - } -} - -fn memory_policy_service_config() -> Service { - Service { - http_bind: "127.0.0.1:8080".to_string(), - mcp_bind: "127.0.0.1:8082".to_string(), - admin_bind: "127.0.0.1:8081".to_string(), - log_level: "info".to_string(), - } -} - -fn memory_policy_storage_config() -> Storage { - Storage { - postgres: Postgres { - dsn: "postgres://user:pass@localhost/db".to_string(), - pool_max_conns: 1, - }, - qdrant: Qdrant { - url: "http://localhost".to_string(), - collection: "mem_notes_v2".to_string(), - docs_collection: "doc_chunks_v1".to_string(), - vector_dim: 4_096, - }, - } -} - -fn memory_policy_providers_config() -> Providers { - Providers { - embedding: embedding_provider_config(), - rerank: rerank_provider_config(), - llm_extractor: llm_extractor_provider_config(), - } -} - -fn embedding_provider_config() -> EmbeddingProviderConfig { - EmbeddingProviderConfig { - provider_id: "p".to_string(), - api_base: "http://localhost".to_string(), - api_key: "key".to_string(), - path: "/".to_string(), - model: "m".to_string(), - dimensions: 3, - timeout_ms: 1_000, - default_headers: Map::new(), - } -} - -fn rerank_provider_config() -> ProviderConfig { - ProviderConfig { - provider_id: "p".to_string(), - api_base: "http://localhost".to_string(), - api_key: "key".to_string(), - path: "/".to_string(), - model: "m".to_string(), - timeout_ms: 1_000, - default_headers: Map::new(), - } -} - -fn llm_extractor_provider_config() -> LlmProviderConfig { - LlmProviderConfig { - provider_id: "p".to_string(), - api_base: "http://localhost".to_string(), - api_key: "key".to_string(), - path: "/".to_string(), - model: "m".to_string(), - temperature: 0.1, - timeout_ms: 1_000, - default_headers: Map::new(), - } -} - -fn memory_policy_scopes_config() -> Scopes { - Scopes { - allowed: vec!["agent_private".to_string()], - read_profiles: ReadProfiles { - private_only: vec!["agent_private".to_string()], - private_plus_project: vec!["agent_private".to_string()], - all_scopes: vec!["agent_private".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, - }, - } -} - -fn memory_policy_memory_config() -> 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![ - MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: Some(0.9), - min_importance: Some(0.1), - }, - MemoryPolicyRule { - note_type: Some("preference".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: Some(0.75), - min_importance: None, - }, - MemoryPolicyRule { - note_type: Some("preference".to_string()), - scope: None, - min_confidence: Some(0.6), - min_importance: None, - }, - MemoryPolicyRule { - note_type: None, - scope: None, - min_confidence: None, - min_importance: None, - }, - ], - }, - } -} - -fn memory_policy_search_config() -> 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 memory_policy_ranking_config() -> 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: 10, retrieval_weight: 0.5 }], - }, - 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 memory_policy_lifecycle_config() -> 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, - } -} - -fn memory_policy_security_config() -> 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![], - } -} - -fn memory_policy_chunking_config() -> Chunking { - Chunking { - enabled: true, - max_tokens: 512, - overlap_tokens: 128, - tokenizer_repo: "REPLACE_ME".to_string(), - } -} -#[test] -fn selects_note_type_and_scope_rule_before_note_type() { - let cfg = memory_policy_config(MemoryPolicy { - rules: vec![ - MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: None, - min_confidence: Some(0.2), - min_importance: None, - }, - MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: Some(0.9), - min_importance: None, - }, - MemoryPolicyRule { - note_type: None, - scope: Some("agent_private".to_string()), - min_confidence: Some(0.0), - min_importance: None, - }, - ], - }); - let MemoryPolicyEvaluation { decision, matched_rule } = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.5, - 0.5, - MemoryPolicyDecision::Remember, - ); - - assert_eq!(decision, MemoryPolicyDecision::Ignore); - assert!(matched_rule.is_some()); - assert_eq!(matched_rule.unwrap().note_type.as_deref(), Some("fact")); - assert_eq!(matched_rule.unwrap().scope.as_deref(), Some("agent_private")); - assert_eq!(matched_rule.unwrap().min_confidence, Some(0.9)); -} - -#[test] -fn downgrades_only_remember_or_update() { - let cfg = memory_policy_config(MemoryPolicy { - rules: vec![MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: Some(0.9), - min_importance: None, - }], - }); - let remember = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.5, - 0.5, - MemoryPolicyDecision::Remember, - ); - - assert_eq!(remember.decision, MemoryPolicyDecision::Ignore); - - let update = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.5, - 0.5, - MemoryPolicyDecision::Update, - ); - - assert_eq!(update.decision, MemoryPolicyDecision::Ignore); - - let ignored = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.5, - 0.5, - MemoryPolicyDecision::Ignore, - ); - - assert_eq!(ignored.decision, MemoryPolicyDecision::Ignore); - - let rejected = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.5, - 0.5, - MemoryPolicyDecision::Reject, - ); - - assert_eq!(rejected.decision, MemoryPolicyDecision::Reject); -} - -#[test] -fn note_type_only_beats_scope_only() { - let cfg = memory_policy_config(MemoryPolicy { - rules: vec![ - MemoryPolicyRule { - note_type: None, - scope: Some("agent_private".to_string()), - min_confidence: Some(0.1), - min_importance: None, - }, - MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: None, - min_confidence: Some(0.1), - min_importance: None, - }, - ], - }); - let output = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.2, - 0.0, - MemoryPolicyDecision::Remember, - ); - - assert_eq!(output.decision, MemoryPolicyDecision::Remember); - assert_eq!(output.matched_rule.and_then(|rule| rule.note_type.as_deref()), Some("fact")); - assert_eq!(output.matched_rule.and_then(|rule| rule.scope.as_deref()), None); -} - -#[test] -fn scope_only_beats_fallback_none() { - let cfg = memory_policy_config(MemoryPolicy { - rules: vec![ - MemoryPolicyRule { - note_type: None, - scope: None, - min_confidence: Some(0.1), - min_importance: None, - }, - MemoryPolicyRule { - note_type: None, - scope: Some("agent_private".to_string()), - min_confidence: Some(0.1), - min_importance: None, - }, - ], - }); - let output = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.2, - 0.0, - MemoryPolicyDecision::Remember, - ); - - assert_eq!(output.decision, MemoryPolicyDecision::Remember); - assert_eq!(output.matched_rule.and_then(|rule| rule.note_type.as_deref()), None); - assert_eq!(output.matched_rule.and_then(|rule| rule.scope.as_deref()), Some("agent_private")); -} - -#[test] -fn confidence_meets_minimum_is_not_a_downgrade() { - let cfg = memory_policy_config(MemoryPolicy { - rules: vec![MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: Some(0.5), - min_importance: None, - }], - }); - let output = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.5, - 0.0, - MemoryPolicyDecision::Remember, - ); - - assert_eq!(output.decision, MemoryPolicyDecision::Remember); -} - -#[test] -fn importance_meets_minimum_is_not_a_downgrade() { - let cfg = memory_policy_config(MemoryPolicy { - rules: vec![MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: None, - min_importance: Some(0.7), - }], - }); - let output = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.0, - 0.7, - MemoryPolicyDecision::Remember, - ); - - assert_eq!(output.decision, MemoryPolicyDecision::Remember); -} - -#[test] -fn non_finite_metrics_fail_threshold() { - let cfg = memory_policy_config(MemoryPolicy { - rules: vec![MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: Some(0.9), - min_importance: None, - }], - }); - let output = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - f64::NAN, - 0.5, - MemoryPolicyDecision::Remember, - ); - - assert_eq!(output.decision, MemoryPolicyDecision::Ignore); -} - -#[test] -fn missing_threshold_does_not_change_decision() { - let cfg = memory_policy_config(MemoryPolicy { - rules: vec![MemoryPolicyRule { - note_type: Some("fact".to_string()), - scope: Some("agent_private".to_string()), - min_confidence: None, - min_importance: None, - }], - }); - let output = memory_policy::evaluate_memory_policy( - &cfg, - "fact", - "agent_private", - 0.0, - 0.0, - MemoryPolicyDecision::Remember, - ); - - assert_eq!(output.decision, MemoryPolicyDecision::Remember); -} +#[path = "memory_policy/decisions.rs"] mod decisions; +#[path = "memory_policy/precedence.rs"] mod precedence; +#[path = "memory_policy/support.rs"] mod support; +#[path = "memory_policy/thresholds.rs"] mod thresholds; diff --git a/packages/elf-domain/tests/memory_policy/decisions.rs b/packages/elf-domain/tests/memory_policy/decisions.rs new file mode 100644 index 00000000..5d9a9da3 --- /dev/null +++ b/packages/elf-domain/tests/memory_policy/decisions.rs @@ -0,0 +1,58 @@ +use crate::support; +use elf_config::{MemoryPolicy, MemoryPolicyRule}; +use elf_domain::memory_policy::{self, MemoryPolicyDecision}; + +#[test] +fn downgrades_only_remember_or_update() { + let cfg = support::memory_policy_config(MemoryPolicy { + rules: vec![MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: Some(0.9), + min_importance: None, + }], + }); + let remember = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.5, + 0.5, + MemoryPolicyDecision::Remember, + ); + + assert_eq!(remember.decision, MemoryPolicyDecision::Ignore); + + let update = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.5, + 0.5, + MemoryPolicyDecision::Update, + ); + + assert_eq!(update.decision, MemoryPolicyDecision::Ignore); + + let ignored = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.5, + 0.5, + MemoryPolicyDecision::Ignore, + ); + + assert_eq!(ignored.decision, MemoryPolicyDecision::Ignore); + + let rejected = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.5, + 0.5, + MemoryPolicyDecision::Reject, + ); + + assert_eq!(rejected.decision, MemoryPolicyDecision::Reject); +} diff --git a/packages/elf-domain/tests/memory_policy/precedence.rs b/packages/elf-domain/tests/memory_policy/precedence.rs new file mode 100644 index 00000000..050d2a27 --- /dev/null +++ b/packages/elf-domain/tests/memory_policy/precedence.rs @@ -0,0 +1,107 @@ +use crate::support; +use elf_config::{MemoryPolicy, MemoryPolicyRule}; +use elf_domain::memory_policy::{self, MemoryPolicyDecision, MemoryPolicyEvaluation}; + +#[test] +fn selects_note_type_and_scope_rule_before_note_type() { + let cfg = support::memory_policy_config(MemoryPolicy { + rules: vec![ + MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: None, + min_confidence: Some(0.2), + min_importance: None, + }, + MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: Some(0.9), + min_importance: None, + }, + MemoryPolicyRule { + note_type: None, + scope: Some("agent_private".to_string()), + min_confidence: Some(0.0), + min_importance: None, + }, + ], + }); + let MemoryPolicyEvaluation { decision, matched_rule } = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.5, + 0.5, + MemoryPolicyDecision::Remember, + ); + + assert_eq!(decision, MemoryPolicyDecision::Ignore); + assert!(matched_rule.is_some()); + assert_eq!(matched_rule.unwrap().note_type.as_deref(), Some("fact")); + assert_eq!(matched_rule.unwrap().scope.as_deref(), Some("agent_private")); + assert_eq!(matched_rule.unwrap().min_confidence, Some(0.9)); +} + +#[test] +fn note_type_only_beats_scope_only() { + let cfg = support::memory_policy_config(MemoryPolicy { + rules: vec![ + MemoryPolicyRule { + note_type: None, + scope: Some("agent_private".to_string()), + min_confidence: Some(0.1), + min_importance: None, + }, + MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: None, + min_confidence: Some(0.1), + min_importance: None, + }, + ], + }); + let output = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.2, + 0.0, + MemoryPolicyDecision::Remember, + ); + + assert_eq!(output.decision, MemoryPolicyDecision::Remember); + assert_eq!(output.matched_rule.and_then(|rule| rule.note_type.as_deref()), Some("fact")); + assert_eq!(output.matched_rule.and_then(|rule| rule.scope.as_deref()), None); +} + +#[test] +fn scope_only_beats_fallback_none() { + let cfg = support::memory_policy_config(MemoryPolicy { + rules: vec![ + MemoryPolicyRule { + note_type: None, + scope: None, + min_confidence: Some(0.1), + min_importance: None, + }, + MemoryPolicyRule { + note_type: None, + scope: Some("agent_private".to_string()), + min_confidence: Some(0.1), + min_importance: None, + }, + ], + }); + let output = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.2, + 0.0, + MemoryPolicyDecision::Remember, + ); + + assert_eq!(output.decision, MemoryPolicyDecision::Remember); + assert_eq!(output.matched_rule.and_then(|rule| rule.note_type.as_deref()), None); + assert_eq!(output.matched_rule.and_then(|rule| rule.scope.as_deref()), Some("agent_private")); +} diff --git a/packages/elf-domain/tests/memory_policy/support.rs b/packages/elf-domain/tests/memory_policy/support.rs new file mode 100644 index 00000000..b8fd6dcc --- /dev/null +++ b/packages/elf-domain/tests/memory_policy/support.rs @@ -0,0 +1,277 @@ +use serde_json::Map; + +use elf_config::{ + Chunking, Config, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, Memory, MemoryPolicy, + MemoryPolicyRule, Postgres, ProviderConfig, Providers, Qdrant, Ranking, RankingBlend, + RankingBlendSegment, RankingDeterministic, RankingDeterministicDecay, RankingDeterministicHits, + RankingDeterministicLexical, RankingDiversity, RankingRetrievalSources, ReadProfiles, + ScopePrecedence, ScopeWriteAllowed, Scopes, Search, SearchCache, SearchDynamic, + SearchExpansion, SearchExplain, SearchGraphContext, SearchPrefilter, SearchRecursive, Security, + Service, Storage, TtlDays, +}; + +pub(crate) fn memory_policy_config(policy: MemoryPolicy) -> Config { + let mut cfg = memory_policy_default_config(); + + cfg.memory.policy = policy; + + cfg +} + +fn memory_policy_default_config() -> Config { + Config { + service: memory_policy_service_config(), + storage: memory_policy_storage_config(), + providers: memory_policy_providers_config(), + scopes: memory_policy_scopes_config(), + memory: memory_policy_memory_config(), + search: memory_policy_search_config(), + ranking: memory_policy_ranking_config(), + lifecycle: memory_policy_lifecycle_config(), + security: memory_policy_security_config(), + chunking: memory_policy_chunking_config(), + context: None, + mcp: None, + } +} + +fn memory_policy_service_config() -> Service { + Service { + http_bind: "127.0.0.1:8080".to_string(), + mcp_bind: "127.0.0.1:8082".to_string(), + admin_bind: "127.0.0.1:8081".to_string(), + log_level: "info".to_string(), + } +} + +fn memory_policy_storage_config() -> Storage { + Storage { + postgres: Postgres { + dsn: "postgres://user:pass@localhost/db".to_string(), + pool_max_conns: 1, + }, + qdrant: Qdrant { + url: "http://localhost".to_string(), + collection: "mem_notes_v2".to_string(), + docs_collection: "doc_chunks_v1".to_string(), + vector_dim: 4_096, + }, + } +} + +fn memory_policy_providers_config() -> Providers { + Providers { + embedding: embedding_provider_config(), + rerank: rerank_provider_config(), + llm_extractor: llm_extractor_provider_config(), + } +} + +fn embedding_provider_config() -> EmbeddingProviderConfig { + EmbeddingProviderConfig { + provider_id: "p".to_string(), + api_base: "http://localhost".to_string(), + api_key: "key".to_string(), + path: "/".to_string(), + model: "m".to_string(), + dimensions: 3, + timeout_ms: 1_000, + default_headers: Map::new(), + } +} + +fn rerank_provider_config() -> ProviderConfig { + ProviderConfig { + provider_id: "p".to_string(), + api_base: "http://localhost".to_string(), + api_key: "key".to_string(), + path: "/".to_string(), + model: "m".to_string(), + timeout_ms: 1_000, + default_headers: Map::new(), + } +} + +fn llm_extractor_provider_config() -> LlmProviderConfig { + LlmProviderConfig { + provider_id: "p".to_string(), + api_base: "http://localhost".to_string(), + api_key: "key".to_string(), + path: "/".to_string(), + model: "m".to_string(), + temperature: 0.1, + timeout_ms: 1_000, + default_headers: Map::new(), + } +} + +fn memory_policy_scopes_config() -> Scopes { + Scopes { + allowed: vec!["agent_private".to_string()], + read_profiles: ReadProfiles { + private_only: vec!["agent_private".to_string()], + private_plus_project: vec!["agent_private".to_string()], + all_scopes: vec!["agent_private".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, + }, + } +} + +fn memory_policy_memory_config() -> 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![ + MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: Some(0.9), + min_importance: Some(0.1), + }, + MemoryPolicyRule { + note_type: Some("preference".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: Some(0.75), + min_importance: None, + }, + MemoryPolicyRule { + note_type: Some("preference".to_string()), + scope: None, + min_confidence: Some(0.6), + min_importance: None, + }, + MemoryPolicyRule { + note_type: None, + scope: None, + min_confidence: None, + min_importance: None, + }, + ], + }, + } +} + +fn memory_policy_search_config() -> 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 memory_policy_ranking_config() -> 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: 10, retrieval_weight: 0.5 }], + }, + 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 memory_policy_lifecycle_config() -> 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, + } +} + +fn memory_policy_security_config() -> 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![], + } +} + +fn memory_policy_chunking_config() -> Chunking { + Chunking { + enabled: true, + max_tokens: 512, + overlap_tokens: 128, + tokenizer_repo: "REPLACE_ME".to_string(), + } +} diff --git a/packages/elf-domain/tests/memory_policy/thresholds.rs b/packages/elf-domain/tests/memory_policy/thresholds.rs new file mode 100644 index 00000000..26a077b9 --- /dev/null +++ b/packages/elf-domain/tests/memory_policy/thresholds.rs @@ -0,0 +1,91 @@ +use crate::support; +use elf_config::{MemoryPolicy, MemoryPolicyRule}; +use elf_domain::memory_policy::{self, MemoryPolicyDecision}; + +#[test] +fn confidence_meets_minimum_is_not_a_downgrade() { + let cfg = support::memory_policy_config(MemoryPolicy { + rules: vec![MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: Some(0.5), + min_importance: None, + }], + }); + let output = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.5, + 0.0, + MemoryPolicyDecision::Remember, + ); + + assert_eq!(output.decision, MemoryPolicyDecision::Remember); +} + +#[test] +fn importance_meets_minimum_is_not_a_downgrade() { + let cfg = support::memory_policy_config(MemoryPolicy { + rules: vec![MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: None, + min_importance: Some(0.7), + }], + }); + let output = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.0, + 0.7, + MemoryPolicyDecision::Remember, + ); + + assert_eq!(output.decision, MemoryPolicyDecision::Remember); +} + +#[test] +fn missing_threshold_does_not_change_decision() { + let cfg = support::memory_policy_config(MemoryPolicy { + rules: vec![MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: None, + min_importance: None, + }], + }); + let output = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + 0.0, + 0.0, + MemoryPolicyDecision::Remember, + ); + + assert_eq!(output.decision, MemoryPolicyDecision::Remember); +} + +#[test] +fn non_finite_metrics_fail_threshold() { + let cfg = support::memory_policy_config(MemoryPolicy { + rules: vec![MemoryPolicyRule { + note_type: Some("fact".to_string()), + scope: Some("agent_private".to_string()), + min_confidence: Some(0.9), + min_importance: None, + }], + }); + let output = memory_policy::evaluate_memory_policy( + &cfg, + "fact", + "agent_private", + f64::NAN, + 0.5, + MemoryPolicyDecision::Remember, + ); + + assert_eq!(output.decision, MemoryPolicyDecision::Ignore); +} diff --git a/packages/elf-service/tests/acceptance/suite.rs b/packages/elf-service/tests/acceptance/suite.rs index e2b615ec..416a7c80 100644 --- a/packages/elf-service/tests/acceptance/suite.rs +++ b/packages/elf-service/tests/acceptance/suite.rs @@ -1,6 +1,7 @@ mod add_note_no_llm; mod chunk_search; mod chunking; +#[path = "suite/config.rs"] mod config; mod consolidation; mod docs_extension_v1; mod english_only_boundary; @@ -10,500 +11,16 @@ mod idempotency; mod knowledge_pages; mod memory_history; mod outbox_eventual_consistency; +#[path = "suite/providers.rs"] mod providers; mod rebuild_qdrant; +#[path = "suite/runtime.rs"] mod runtime; mod sot_vectors; mod structured_field_retrieval; mod trace_admin_observability; mod work_journal; -use std::{ - env, fs, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, - time::Duration, +pub(crate) use self::{ + config::{dummy_embedding_provider, test_config, test_qdrant_url}, + providers::{SpyEmbedding, SpyExtractor, StubEmbedding, StubRerank}, + runtime::{build_service, reset_db, reset_qdrant_collection, test_db}, }; - -use ahash::AHashMap; -use qdrant_client::{ - QdrantError, - qdrant::{ - CreateCollectionBuilder, Distance, Modifier, SparseVectorParamsBuilder, - SparseVectorsConfigBuilder, VectorParamsBuilder, VectorsConfigBuilder, - }, -}; -use serde_json::{Map, Value}; -use sqlx::PgExecutor; -use tokenizers::{Tokenizer, models::wordlevel::WordLevel}; -use tokio::time; - -use elf_config::{ - Chunking, Config, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, Memory, MemoryPolicy, - Postgres, ProviderConfig, Ranking, RankingBlend, RankingBlendSegment, RankingDeterministic, - RankingDeterministicDecay, RankingDeterministicHits, RankingDeterministicLexical, - RankingDiversity, RankingRetrievalSources, ReadProfiles, ScopePrecedence, ScopeWriteAllowed, - Scopes, Search, SearchCache, SearchDynamic, SearchExpansion, SearchExplain, SearchGraphContext, - SearchPrefilter, SearchRecursive, Security, Service, Storage, TtlDays, -}; -use elf_service::{ - BoxFuture, ElfService, EmbeddingProvider, ExtractorProvider, RerankProvider, Result, -}; -use elf_storage::{ - db::Db, - qdrant::{BM25_VECTOR_NAME, DENSE_VECTOR_NAME, QdrantStore}, -}; -use elf_testkit::TestDatabase; - -type AcceptanceResult = Result; - -#[derive(Debug, thiserror::Error)] -enum AcceptanceFailure { - #[error(transparent)] - Storage(#[from] elf_storage::Error), - #[error(transparent)] - Sqlx(#[from] sqlx::Error), - #[error(transparent)] - Qdrant(#[from] QdrantError), - #[error("{0}")] - Message(String), -} - -pub struct StubEmbedding { - pub vector_dim: u32, -} -impl EmbeddingProvider for StubEmbedding { - fn embed<'a>( - &'a self, - _cfg: &'a EmbeddingProviderConfig, - texts: &'a [String], - ) -> BoxFuture<'a, Result>>> { - let dim = self.vector_dim as usize; - let vectors = texts.iter().map(|_| vec![0.0; dim]).collect(); - - Box::pin(async move { Ok(vectors) }) - } -} - -pub struct SpyEmbedding { - pub vector_dim: u32, - pub calls: Arc, -} -impl EmbeddingProvider for SpyEmbedding { - fn embed<'a>( - &'a self, - _cfg: &'a EmbeddingProviderConfig, - texts: &'a [String], - ) -> BoxFuture<'a, Result>>> { - self.calls.fetch_add(1, Ordering::SeqCst); - - let dim = self.vector_dim as usize; - let vectors = texts.iter().map(|_| vec![0.0; dim]).collect(); - - Box::pin(async move { Ok(vectors) }) - } -} - -pub struct StubRerank; -impl RerankProvider for StubRerank { - fn rerank<'a>( - &'a self, - _cfg: &'a ProviderConfig, - _query: &'a str, - docs: &'a [String], - ) -> BoxFuture<'a, Result>> { - let scores = vec![0.5; docs.len()]; - - Box::pin(async move { Ok(scores) }) - } -} - -pub struct SpyExtractor { - pub calls: Arc, - pub payload: Value, -} -impl ExtractorProvider for SpyExtractor { - fn extract<'a>( - &'a self, - _cfg: &'a LlmProviderConfig, - _messages: &'a [Value], - ) -> BoxFuture<'a, Result> { - let payload = self.payload.clone(); - - self.calls.fetch_add(1, Ordering::SeqCst); - - Box::pin(async move { Ok(payload) }) - } -} - -pub fn test_qdrant_url() -> Option { - env::var("ELF_QDRANT_GRPC_URL").ok().or_else(|| env::var("ELF_QDRANT_URL").ok()) -} - -pub fn test_config( - dsn: String, - qdrant_url: String, - vector_dim: u32, - collection: String, - docs_collection: String, -) -> Config { - let mut embedding = dummy_embedding_provider(); - - embedding.dimensions = vector_dim; - - 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: 2 }, - qdrant: elf_config::Qdrant { - url: qdrant_url, - collection: collection.clone(), - docs_collection, - vector_dim, - }, - }, - providers: elf_config::Providers { - embedding, - 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, - }, - chunking: Chunking { - enabled: true, - max_tokens: 512, - overlap_tokens: 128, - tokenizer_repo: test_tokenizer_repo(&collection), - }, - 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![], - }, - context: None, - mcp: None, - } -} - -pub fn dummy_embedding_provider() -> EmbeddingProviderConfig { - EmbeddingProviderConfig { - 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(), - dimensions: 4_096, - timeout_ms: 1_000, - default_headers: Map::new(), - } -} - -pub fn dummy_provider() -> ProviderConfig { - ProviderConfig { - 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(), - timeout_ms: 1_000, - default_headers: Map::new(), - } -} - -pub 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 async fn test_db() -> Option { - let base_dsn = elf_testkit::env_dsn()?; - let db = TestDatabase::new(&base_dsn).await.expect("Failed to create test database."); - - Some(db) -} - -fn test_tokenizer_repo(collection: &str) -> String { - let tokenizer_path = env::temp_dir().join(format!("{collection}-tokenizer.json")); - - if tokenizer_path.exists() { - return tokenizer_path.to_string_lossy().into_owned(); - } - - let mut vocab = AHashMap::new(); - - vocab.insert("".to_string(), 0_u32); - - let model = WordLevel::builder() - .vocab(vocab) - .unk_token("".to_string()) - .build() - .expect("Failed to build acceptance tokenizer."); - let tokenizer = Tokenizer::new(model); - let parent = tokenizer_path.parent().expect("Temporary tokenizer path has a parent directory."); - - fs::create_dir_all(parent).expect("Failed to create acceptance tokenizer directory."); - - tokenizer.save(&tokenizer_path, false).expect("Failed to save acceptance tokenizer."); - - tokenizer_path.to_string_lossy().into_owned() -} - -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 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, - }, - } -} - -async fn reset_qdrant_collection( - client: &qdrant_client::Qdrant, - collection: &str, - vector_dim: u32, -) -> AcceptanceResult<()> { - let max_attempts = 8; - let mut backoff = Duration::from_millis(100); - let mut last_err = None; - - for attempt in 1..=max_attempts { - let _ = client.delete_collection(collection.to_string()).await; - let mut vectors_config = VectorsConfigBuilder::default(); - - vectors_config.add_named_vector_params( - DENSE_VECTOR_NAME, - VectorParamsBuilder::new(vector_dim.into(), Distance::Cosine), - ); - - let mut sparse_vectors_config = SparseVectorsConfigBuilder::default(); - - sparse_vectors_config.add_named_vector_params( - BM25_VECTOR_NAME, - SparseVectorParamsBuilder::default().modifier(Modifier::Idf as i32), - ); - - let builder = CreateCollectionBuilder::new(collection.to_string()) - .vectors_config(vectors_config) - .sparse_vectors_config(sparse_vectors_config); - - match client.create_collection(builder).await { - Ok(_) => return Ok(()), - Err(err) => { - last_err = Some(err); - - if attempt == max_attempts { - break; - } - - time::sleep(backoff).await; - - backoff = backoff.saturating_mul(2).min(Duration::from_secs(2)); - }, - } - } - - Err(AcceptanceFailure::Message(format!( - "Failed to create Qdrant collection {collection:?} after {max_attempts} attempts: {last_err:?}." - ))) -} - -async fn build_service( - cfg: Config, - providers: elf_service::Providers, -) -> AcceptanceResult { - let db = Db::connect(&cfg.storage.postgres).await?; - - db.ensure_schema(cfg.storage.qdrant.vector_dim).await?; - - let qdrant = QdrantStore::new(&cfg.storage.qdrant)?; - - Ok(ElfService::with_providers(cfg, db, qdrant, providers)) -} - -async fn reset_db<'e, E>(executor: E) -> AcceptanceResult<()> -where - E: PgExecutor<'e>, -{ - sqlx::query( - "\ -TRUNCATE - graph_entities, - graph_entity_aliases, - graph_predicates, - graph_predicate_aliases, - graph_facts, - graph_fact_evidence, - graph_fact_supersessions, - memory_hits, - memory_ingest_decisions, - memory_note_versions, - memory_space_grants, - note_field_embeddings, - memory_note_fields, - note_chunk_embeddings, - memory_note_chunks, - note_embeddings, - search_trace_items, - search_trace_stage_items, - search_trace_stages, - search_traces, - search_trace_outbox, - search_sessions, - search_trace_candidates, - indexing_outbox, - doc_indexing_outbox, - doc_chunk_embeddings, - doc_chunks, - doc_documents, - knowledge_page_lint_findings, - knowledge_page_source_refs, - knowledge_page_sections, - knowledge_pages, - consolidation_run_jobs, - consolidation_proposal_reviews, - consolidation_proposals, - consolidation_runs, - memory_notes", - ) - .execute(executor) - .await?; - - Ok(()) -} diff --git a/packages/elf-service/tests/acceptance/suite/config.rs b/packages/elf-service/tests/acceptance/suite/config.rs new file mode 100644 index 00000000..8d135a4e --- /dev/null +++ b/packages/elf-service/tests/acceptance/suite/config.rs @@ -0,0 +1,266 @@ +use std::{env, fs}; + +use ahash::AHashMap; +use serde_json::Map; +use tokenizers::{Tokenizer, models::wordlevel::WordLevel}; + +use elf_config::{ + Chunking, Config, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, Memory, MemoryPolicy, + Postgres, ProviderConfig, Providers, Qdrant, Ranking, RankingBlend, RankingBlendSegment, + RankingDeterministic, RankingDeterministicDecay, RankingDeterministicHits, + RankingDeterministicLexical, RankingDiversity, RankingRetrievalSources, ReadProfiles, + ScopePrecedence, ScopeWriteAllowed, Scopes, Search, SearchCache, SearchDynamic, + SearchExpansion, SearchExplain, SearchGraphContext, SearchPrefilter, SearchRecursive, Security, + Service, Storage, TtlDays, +}; + +pub(crate) fn test_qdrant_url() -> Option { + env::var("ELF_QDRANT_GRPC_URL").ok().or_else(|| env::var("ELF_QDRANT_URL").ok()) +} + +pub(crate) fn test_config( + dsn: String, + qdrant_url: String, + vector_dim: u32, + collection: String, + docs_collection: String, +) -> Config { + let mut embedding = dummy_embedding_provider(); + + embedding.dimensions = vector_dim; + + 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: 2 }, + qdrant: Qdrant { + url: qdrant_url, + collection: collection.clone(), + docs_collection, + vector_dim, + }, + }, + providers: Providers { + embedding, + 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, + }, + chunking: Chunking { + enabled: true, + max_tokens: 512, + overlap_tokens: 128, + tokenizer_repo: test_tokenizer_repo(&collection), + }, + 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![], + }, + context: None, + mcp: None, + } +} + +pub(crate) fn dummy_embedding_provider() -> EmbeddingProviderConfig { + EmbeddingProviderConfig { + 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(), + dimensions: 4_096, + timeout_ms: 1_000, + default_headers: Map::new(), + } +} + +fn dummy_provider() -> ProviderConfig { + ProviderConfig { + 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(), + 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 test_tokenizer_repo(collection: &str) -> String { + let tokenizer_path = env::temp_dir().join(format!("{collection}-tokenizer.json")); + + if tokenizer_path.exists() { + return tokenizer_path.to_string_lossy().into_owned(); + } + + let mut vocab = AHashMap::new(); + + vocab.insert("".to_string(), 0_u32); + + let model = WordLevel::builder() + .vocab(vocab) + .unk_token("".to_string()) + .build() + .expect("Failed to build acceptance tokenizer."); + let tokenizer = Tokenizer::new(model); + let parent = tokenizer_path.parent().expect("Temporary tokenizer path has a parent directory."); + + fs::create_dir_all(parent).expect("Failed to create acceptance tokenizer directory."); + + tokenizer.save(&tokenizer_path, false).expect("Failed to save acceptance tokenizer."); + + tokenizer_path.to_string_lossy().into_owned() +} + +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 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, + }, + } +} diff --git a/packages/elf-service/tests/acceptance/suite/providers.rs b/packages/elf-service/tests/acceptance/suite/providers.rs new file mode 100644 index 00000000..d9e8b4d3 --- /dev/null +++ b/packages/elf-service/tests/acceptance/suite/providers.rs @@ -0,0 +1,76 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use serde_json::Value; + +use elf_config::{EmbeddingProviderConfig, LlmProviderConfig, ProviderConfig}; +use elf_service::{BoxFuture, EmbeddingProvider, ExtractorProvider, RerankProvider, Result}; + +pub struct StubEmbedding { + pub vector_dim: u32, +} +impl EmbeddingProvider for StubEmbedding { + fn embed<'a>( + &'a self, + _cfg: &'a EmbeddingProviderConfig, + texts: &'a [String], + ) -> BoxFuture<'a, Result>>> { + let dim = self.vector_dim as usize; + let vectors = texts.iter().map(|_| vec![0.0; dim]).collect(); + + Box::pin(async move { Ok(vectors) }) + } +} + +pub struct SpyEmbedding { + pub vector_dim: u32, + pub calls: Arc, +} +impl EmbeddingProvider for SpyEmbedding { + fn embed<'a>( + &'a self, + _cfg: &'a EmbeddingProviderConfig, + texts: &'a [String], + ) -> BoxFuture<'a, Result>>> { + self.calls.fetch_add(1, Ordering::SeqCst); + + let dim = self.vector_dim as usize; + let vectors = texts.iter().map(|_| vec![0.0; dim]).collect(); + + Box::pin(async move { Ok(vectors) }) + } +} + +pub struct StubRerank; +impl RerankProvider for StubRerank { + fn rerank<'a>( + &'a self, + _cfg: &'a ProviderConfig, + _query: &'a str, + docs: &'a [String], + ) -> BoxFuture<'a, Result>> { + let scores = vec![0.5; docs.len()]; + + Box::pin(async move { Ok(scores) }) + } +} + +pub struct SpyExtractor { + pub calls: Arc, + pub payload: Value, +} +impl ExtractorProvider for SpyExtractor { + fn extract<'a>( + &'a self, + _cfg: &'a LlmProviderConfig, + _messages: &'a [Value], + ) -> BoxFuture<'a, Result> { + let payload = self.payload.clone(); + + self.calls.fetch_add(1, Ordering::SeqCst); + + Box::pin(async move { Ok(payload) }) + } +} diff --git a/packages/elf-service/tests/acceptance/suite/runtime.rs b/packages/elf-service/tests/acceptance/suite/runtime.rs new file mode 100644 index 00000000..eda96ad6 --- /dev/null +++ b/packages/elf-service/tests/acceptance/suite/runtime.rs @@ -0,0 +1,154 @@ +use std::time::Duration; + +use qdrant_client::{ + Qdrant, QdrantError, + qdrant::{ + CreateCollectionBuilder, Distance, Modifier, SparseVectorParamsBuilder, + SparseVectorsConfigBuilder, VectorParamsBuilder, VectorsConfigBuilder, + }, +}; +use sqlx::PgExecutor; +use tokio::time; + +use elf_config::Config; +use elf_service::{ElfService, Providers}; +use elf_storage::{ + db::Db, + qdrant::{BM25_VECTOR_NAME, DENSE_VECTOR_NAME, QdrantStore}, +}; +use elf_testkit::TestDatabase; + +pub(crate) type AcceptanceResult = Result; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum AcceptanceFailure { + #[error(transparent)] + Storage(#[from] elf_storage::Error), + #[error(transparent)] + Sqlx(#[from] sqlx::Error), + #[error(transparent)] + Qdrant(#[from] QdrantError), + #[error("{0}")] + Message(String), +} + +pub(crate) async fn test_db() -> Option { + let base_dsn = elf_testkit::env_dsn()?; + let db = TestDatabase::new(&base_dsn).await.expect("Failed to create test database."); + + Some(db) +} + +pub(crate) async fn reset_qdrant_collection( + client: &Qdrant, + collection: &str, + vector_dim: u32, +) -> AcceptanceResult<()> { + let max_attempts = 8; + let mut backoff = Duration::from_millis(100); + let mut last_err = None; + + for attempt in 1..=max_attempts { + let _ = client.delete_collection(collection.to_string()).await; + let mut vectors_config = VectorsConfigBuilder::default(); + + vectors_config.add_named_vector_params( + DENSE_VECTOR_NAME, + VectorParamsBuilder::new(vector_dim.into(), Distance::Cosine), + ); + + let mut sparse_vectors_config = SparseVectorsConfigBuilder::default(); + + sparse_vectors_config.add_named_vector_params( + BM25_VECTOR_NAME, + SparseVectorParamsBuilder::default().modifier(Modifier::Idf as i32), + ); + + let builder = CreateCollectionBuilder::new(collection.to_string()) + .vectors_config(vectors_config) + .sparse_vectors_config(sparse_vectors_config); + + match client.create_collection(builder).await { + Ok(_) => return Ok(()), + Err(err) => { + last_err = Some(err); + + if attempt == max_attempts { + break; + } + + time::sleep(backoff).await; + + backoff = backoff.saturating_mul(2).min(Duration::from_secs(2)); + }, + } + } + + Err(AcceptanceFailure::Message(format!( + "Failed to create Qdrant collection {collection:?} after {max_attempts} attempts: {last_err:?}." + ))) +} + +pub(crate) async fn build_service( + cfg: Config, + providers: Providers, +) -> AcceptanceResult { + let db = Db::connect(&cfg.storage.postgres).await?; + + db.ensure_schema(cfg.storage.qdrant.vector_dim).await?; + + let qdrant = QdrantStore::new(&cfg.storage.qdrant)?; + + Ok(ElfService::with_providers(cfg, db, qdrant, providers)) +} + +pub(crate) async fn reset_db<'e, E>(executor: E) -> AcceptanceResult<()> +where + E: PgExecutor<'e>, +{ + sqlx::query( + "\ +TRUNCATE + graph_entities, + graph_entity_aliases, + graph_predicates, + graph_predicate_aliases, + graph_facts, + graph_fact_evidence, + graph_fact_supersessions, + memory_hits, + memory_ingest_decisions, + memory_note_versions, + memory_space_grants, + note_field_embeddings, + memory_note_fields, + note_chunk_embeddings, + memory_note_chunks, + note_embeddings, + search_trace_items, + search_trace_stage_items, + search_trace_stages, + search_traces, + search_trace_outbox, + search_sessions, + search_trace_candidates, + indexing_outbox, + doc_indexing_outbox, + doc_chunk_embeddings, + doc_chunks, + doc_documents, + knowledge_page_lint_findings, + knowledge_page_source_refs, + knowledge_page_sections, + knowledge_pages, + consolidation_run_jobs, + consolidation_proposal_reviews, + consolidation_proposals, + consolidation_runs, + memory_notes", + ) + .execute(executor) + .await?; + + Ok(()) +}