diff --git a/apps/elf-api/src/routes/tests.rs b/apps/elf-api/src/routes/tests.rs index b526c953..e0d1af3c 100644 --- a/apps/elf-api/src/routes/tests.rs +++ b/apps/elf-api/src/routes/tests.rs @@ -1,302 +1,5 @@ -use axum::http::HeaderMap; -use serde_json::Value; -use uuid::Uuid; - -use crate::routes::{ - self, ADMIN_VIEWER_PATH, HEADER_AGENT_ID, HEADER_AUTHORIZATION, HEADER_PROJECT_ID, - HEADER_READ_PROFILE, HEADER_REQUEST_ID, HEADER_TENANT_ID, HEADER_TRUSTED_TOKEN_ID, VIEWER_HTML, -}; -use elf_config::{SecurityAuthKey, SecurityAuthRole}; - -#[test] -fn require_admin_for_org_shared_writes_denies_user_in_static_keys_mode() { - let err = - routes::require_admin_for_org_shared_writes("static_keys", Some(SecurityAuthRole::User)) - .expect_err("Expected forbidden error for non-admin role."); - - assert_eq!(err.status, axum::http::StatusCode::FORBIDDEN); -} - -#[test] -fn require_admin_for_org_shared_writes_allows_admin_in_static_keys_mode() { - routes::require_admin_for_org_shared_writes("static_keys", Some(SecurityAuthRole::Admin)) - .expect("Expected admin role to be allowed."); -} - -#[test] -fn require_admin_for_org_shared_writes_allows_superadmin_in_static_keys_mode() { - routes::require_admin_for_org_shared_writes("static_keys", Some(SecurityAuthRole::SuperAdmin)) - .expect("Expected superadmin role to be allowed."); -} - -#[test] -fn require_admin_for_org_shared_writes_allows_non_static_keys_auth_mode() { - routes::require_admin_for_org_shared_writes("off", None) - .expect("Expected auth_mode != static_keys."); -} - -#[test] -fn admin_viewer_uses_admin_operator_routes_without_raw_memory_bypasses() { - let html = VIEWER_HTML; - - assert_eq!(ADMIN_VIEWER_PATH, "/viewer"); - assert!(html.contains("/v2/admin/searches")); - assert!(html.contains("/v2/admin/docs/search/l0")); - assert!(html.contains("/v2/admin/docs/excerpts")); - assert!(html.contains("/v2/admin/docs/${encodeURIComponent(item.doc_id)}")); - assert!(html.contains("/v2/admin/dreaming/review-queue")); - assert!( - html.contains("/v2/admin/consolidation/proposals/${encodeURIComponent(proposalId)}/review") - ); - assert!(html.contains("/v2/admin/notes/${encodeURIComponent(noteId)}/history")); - assert!(html.contains("/v2/admin/notes/${encodeURIComponent(noteId)}/corrections")); - assert!(html.contains("/v2/admin/recall-debug/panel")); - assert!(html.contains("/v2/admin/traces/recent")); - assert!(html.contains("/v2/admin/traces/${encodeURIComponent(traceId)}/bundle")); - assert!(html.contains("/v2/admin/notes/")); - assert!(html.contains("/v2/admin/knowledge/pages/search")); - assert!(html.contains("mode: \"full\"")); - assert!(html.contains("candidates_limit: 200")); - assert!(html.contains("Replay Candidates")); - assert!(html.contains("Selected Final Results")); - assert!(html.contains("Providers And Ranking")); - assert!(html.contains("Relation Context")); - assert!(html.contains("Knowledge Page Snippets")); - assert!(html.contains("Derived page: source documents")); - assert!(html.contains("Source Library")); - assert!(html.contains("Memory Inbox")); - assert!(html.contains("Memory History")); - assert!(html.contains("Recall Debug")); - assert!(html.contains("Apply Ledger Correction")); - assert!(html.contains("Apply / Supersede")); - assert!(html.contains("directTraceId")); - assert!(html.contains("trace_id")); - assert!(html.contains("loadInitialTrace")); - assert!(!html.contains("method: \"PATCH\"")); - assert!(!html.contains("method: \"PUT\"")); - assert!(!html.contains("method: \"DELETE\"")); - assert!(!html.contains("/v2/notes/ingest")); - assert!(!html.contains("/v2/events/ingest")); - assert!(!html.contains("/publish")); -} - -#[test] -fn resolve_auth_key_requires_bearer_header() { - let headers = HeaderMap::new(); - let keys = vec![SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }]; - let err = routes::resolve_auth_key(&headers, &keys).expect_err("Expected unauthorized error."); - - assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); -} - -#[test] -fn resolve_auth_key_rejects_unknown_token() { - let keys = vec![SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }]; - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_AUTHORIZATION, "Bearer wrong".parse().expect("invalid header")); - - let err = routes::resolve_auth_key(&headers, &keys) - .expect_err("Expected unauthorized error for bad key."); - - assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); -} - -#[test] -fn resolve_auth_key_rejects_non_bearer_authorization() { - let keys = vec![SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }]; - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_AUTHORIZATION, "Token secret".parse().expect("invalid header")); - - let err = routes::resolve_auth_key(&headers, &keys) - .expect_err("Expected unauthorized error for non-bearer authorization."); - - assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); -} - -#[test] -fn resolve_auth_key_rejects_lowercase_bearer_prefix() { - let keys = vec![SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }]; - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_AUTHORIZATION, "bearer secret".parse().expect("invalid header")); - - let err = routes::resolve_auth_key(&headers, &keys) - .expect_err("Expected unauthorized error for lowercase bearer prefix."); - - assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); -} - -#[test] -fn apply_auth_key_context_overrides_headers() { - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_AUTHORIZATION, "Bearer old".parse().expect("invalid header")); - headers.insert(HEADER_TENANT_ID, "bad-tenant".parse().expect("invalid header")); - headers.insert(HEADER_PROJECT_ID, "bad-project".parse().expect("invalid header")); - headers.insert(HEADER_AGENT_ID, "bad-agent".parse().expect("invalid header")); - headers.insert(HEADER_READ_PROFILE, "private_only".parse().expect("invalid header")); - headers.insert(HEADER_TRUSTED_TOKEN_ID, "old-id".parse().expect("invalid header")); - - let key = SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "all_scopes".to_string(), - role: SecurityAuthRole::Admin, - }; - - routes::apply_auth_key_context(&mut headers, &key).expect("Expected context injection."); - - assert_eq!( - headers.get(HEADER_TENANT_ID).and_then(|v| v.to_str().ok()).expect("missing tenant"), - "t" - ); - assert_eq!( - headers.get(HEADER_PROJECT_ID).and_then(|v| v.to_str().ok()).expect("missing project"), - "p" - ); - assert_eq!( - headers.get(HEADER_AGENT_ID).and_then(|v| v.to_str().ok()).expect("missing agent"), - "a" - ); - assert_eq!( - headers - .get(HEADER_READ_PROFILE) - .and_then(|v| v.to_str().ok()) - .expect("missing read profile"), - "all_scopes" - ); - assert_eq!( - headers - .get(HEADER_TRUSTED_TOKEN_ID) - .and_then(|v| v.to_str().ok()) - .expect("missing trusted token_id"), - "k1" - ); -} - -#[test] -fn apply_auth_key_context_requires_agent_scope() { - let mut headers = HeaderMap::new(); - let key = SecurityAuthKey { - token_id: "k1".to_string(), - token: "secret".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: None, - read_profile: "all_scopes".to_string(), - role: SecurityAuthRole::User, - }; - let err = routes::apply_auth_key_context(&mut headers, &key) - .expect_err("Expected forbidden error for missing agent_id."); - - assert_eq!(err.status, axum::http::StatusCode::FORBIDDEN); -} - -#[test] -fn effective_token_id_ignores_header_when_auth_mode_off() { - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_TRUSTED_TOKEN_ID, "user-supplied".parse().expect("invalid header")); - - assert_eq!(routes::effective_token_id("off", &headers), None); -} - -#[test] -fn effective_token_id_uses_header_when_auth_mode_static_keys() { - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_TRUSTED_TOKEN_ID, "k1".parse().expect("invalid header")); - - assert_eq!(routes::effective_token_id("static_keys", &headers), Some("k1".to_string())); -} - -#[test] -fn sanitize_trusted_token_header_removes_header() { - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_TRUSTED_TOKEN_ID, "user-supplied".parse().expect("invalid header")); - - routes::sanitize_trusted_token_header(&mut headers); - - assert!(headers.get(HEADER_TRUSTED_TOKEN_ID).is_none()); -} - -#[test] -fn parse_request_id_from_headers_generates_when_missing() { - let headers = HeaderMap::new(); - let request_id = routes::parse_request_id_from_headers(&headers) - .expect("Expected a generated request ID when header is missing."); - - assert_ne!(request_id.to_string(), Uuid::nil().to_string()); -} - -#[test] -fn parse_request_id_from_headers_rejects_invalid() { - let mut headers = HeaderMap::new(); - - headers.insert(HEADER_REQUEST_ID, "not-a-uuid".parse().expect("invalid request_id")); - - let err = routes::parse_request_id_from_headers(&headers) - .expect_err("Expected invalid request_id to be rejected."); - - assert_eq!(err.status, axum::http::StatusCode::BAD_REQUEST); - assert_eq!(err.error_code, "INVALID_REQUEST"); - assert_eq!(err.fields, Some(vec![format!("$.headers.{HEADER_REQUEST_ID}")])); -} - -#[test] -fn inject_request_id_into_json_body_adds_request_id_to_object() { - let request_id = Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").expect("valid uuid"); - let body = serde_json::json!({"note_id":"abc","status":"ok"}).to_string(); - let response_body = routes::inject_request_id_into_json_body(body.as_bytes(), &request_id) - .expect("Expected request_id field to be injected."); - let response_value = - serde_json::from_slice::(&response_body).expect("Expected valid JSON"); - - assert_eq!(response_value["request_id"], request_id.to_string()); -} - -#[test] -fn inject_request_id_into_json_body_skips_non_object() { - let request_id = Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").expect("valid uuid"); - let body = serde_json::json!(["a", "b", "c"]).to_string(); - - assert!(routes::inject_request_id_into_json_body(body.as_bytes(), &request_id).is_none()); -} +mod admin_auth; +mod admin_viewer; +mod auth_key_context; +mod auth_key_resolution; +mod request_id; diff --git a/apps/elf-api/src/routes/tests/admin_auth.rs b/apps/elf-api/src/routes/tests/admin_auth.rs new file mode 100644 index 00000000..073c9294 --- /dev/null +++ b/apps/elf-api/src/routes/tests/admin_auth.rs @@ -0,0 +1,29 @@ +use crate::routes; +use elf_config::SecurityAuthRole; + +#[test] +fn require_admin_for_org_shared_writes_denies_user_in_static_keys_mode() { + let err = + routes::require_admin_for_org_shared_writes("static_keys", Some(SecurityAuthRole::User)) + .expect_err("Expected forbidden error for non-admin role."); + + assert_eq!(err.status, axum::http::StatusCode::FORBIDDEN); +} + +#[test] +fn require_admin_for_org_shared_writes_allows_admin_in_static_keys_mode() { + routes::require_admin_for_org_shared_writes("static_keys", Some(SecurityAuthRole::Admin)) + .expect("Expected admin role to be allowed."); +} + +#[test] +fn require_admin_for_org_shared_writes_allows_superadmin_in_static_keys_mode() { + routes::require_admin_for_org_shared_writes("static_keys", Some(SecurityAuthRole::SuperAdmin)) + .expect("Expected superadmin role to be allowed."); +} + +#[test] +fn require_admin_for_org_shared_writes_allows_non_static_keys_auth_mode() { + routes::require_admin_for_org_shared_writes("off", None) + .expect("Expected auth_mode != static_keys."); +} diff --git a/apps/elf-api/src/routes/tests/admin_viewer.rs b/apps/elf-api/src/routes/tests/admin_viewer.rs new file mode 100644 index 00000000..9bedefb0 --- /dev/null +++ b/apps/elf-api/src/routes/tests/admin_viewer.rs @@ -0,0 +1,46 @@ +use crate::routes::{ADMIN_VIEWER_PATH, VIEWER_HTML}; + +#[test] +fn admin_viewer_uses_admin_operator_routes_without_raw_memory_bypasses() { + let html = VIEWER_HTML; + + assert_eq!(ADMIN_VIEWER_PATH, "/viewer"); + assert!(html.contains("/v2/admin/searches")); + assert!(html.contains("/v2/admin/docs/search/l0")); + assert!(html.contains("/v2/admin/docs/excerpts")); + assert!(html.contains("/v2/admin/docs/${encodeURIComponent(item.doc_id)}")); + assert!(html.contains("/v2/admin/dreaming/review-queue")); + assert!( + html.contains("/v2/admin/consolidation/proposals/${encodeURIComponent(proposalId)}/review") + ); + assert!(html.contains("/v2/admin/notes/${encodeURIComponent(noteId)}/history")); + assert!(html.contains("/v2/admin/notes/${encodeURIComponent(noteId)}/corrections")); + assert!(html.contains("/v2/admin/recall-debug/panel")); + assert!(html.contains("/v2/admin/traces/recent")); + assert!(html.contains("/v2/admin/traces/${encodeURIComponent(traceId)}/bundle")); + assert!(html.contains("/v2/admin/notes/")); + assert!(html.contains("/v2/admin/knowledge/pages/search")); + assert!(html.contains("mode: \"full\"")); + assert!(html.contains("candidates_limit: 200")); + assert!(html.contains("Replay Candidates")); + assert!(html.contains("Selected Final Results")); + assert!(html.contains("Providers And Ranking")); + assert!(html.contains("Relation Context")); + assert!(html.contains("Knowledge Page Snippets")); + assert!(html.contains("Derived page: source documents")); + assert!(html.contains("Source Library")); + assert!(html.contains("Memory Inbox")); + assert!(html.contains("Memory History")); + assert!(html.contains("Recall Debug")); + assert!(html.contains("Apply Ledger Correction")); + assert!(html.contains("Apply / Supersede")); + assert!(html.contains("directTraceId")); + assert!(html.contains("trace_id")); + assert!(html.contains("loadInitialTrace")); + assert!(!html.contains("method: \"PATCH\"")); + assert!(!html.contains("method: \"PUT\"")); + assert!(!html.contains("method: \"DELETE\"")); + assert!(!html.contains("/v2/notes/ingest")); + assert!(!html.contains("/v2/events/ingest")); + assert!(!html.contains("/publish")); +} diff --git a/apps/elf-api/src/routes/tests/auth_key_context.rs b/apps/elf-api/src/routes/tests/auth_key_context.rs new file mode 100644 index 00000000..ad2f694f --- /dev/null +++ b/apps/elf-api/src/routes/tests/auth_key_context.rs @@ -0,0 +1,105 @@ +use axum::http::HeaderMap; + +use crate::routes::{ + self, HEADER_AGENT_ID, HEADER_AUTHORIZATION, HEADER_PROJECT_ID, HEADER_READ_PROFILE, + HEADER_TENANT_ID, HEADER_TRUSTED_TOKEN_ID, +}; +use elf_config::{SecurityAuthKey, SecurityAuthRole}; + +#[test] +fn apply_auth_key_context_overrides_headers() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_AUTHORIZATION, "Bearer old".parse().expect("invalid header")); + headers.insert(HEADER_TENANT_ID, "bad-tenant".parse().expect("invalid header")); + headers.insert(HEADER_PROJECT_ID, "bad-project".parse().expect("invalid header")); + headers.insert(HEADER_AGENT_ID, "bad-agent".parse().expect("invalid header")); + headers.insert(HEADER_READ_PROFILE, "private_only".parse().expect("invalid header")); + headers.insert(HEADER_TRUSTED_TOKEN_ID, "old-id".parse().expect("invalid header")); + + let key = SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "all_scopes".to_string(), + role: SecurityAuthRole::Admin, + }; + + routes::apply_auth_key_context(&mut headers, &key).expect("Expected context injection."); + + assert_eq!( + headers.get(HEADER_TENANT_ID).and_then(|v| v.to_str().ok()).expect("missing tenant"), + "t" + ); + assert_eq!( + headers.get(HEADER_PROJECT_ID).and_then(|v| v.to_str().ok()).expect("missing project"), + "p" + ); + assert_eq!( + headers.get(HEADER_AGENT_ID).and_then(|v| v.to_str().ok()).expect("missing agent"), + "a" + ); + assert_eq!( + headers + .get(HEADER_READ_PROFILE) + .and_then(|v| v.to_str().ok()) + .expect("missing read profile"), + "all_scopes" + ); + assert_eq!( + headers + .get(HEADER_TRUSTED_TOKEN_ID) + .and_then(|v| v.to_str().ok()) + .expect("missing trusted token_id"), + "k1" + ); +} + +#[test] +fn apply_auth_key_context_requires_agent_scope() { + let mut headers = HeaderMap::new(); + let key = SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: None, + read_profile: "all_scopes".to_string(), + role: SecurityAuthRole::User, + }; + let err = routes::apply_auth_key_context(&mut headers, &key) + .expect_err("Expected forbidden error for missing agent_id."); + + assert_eq!(err.status, axum::http::StatusCode::FORBIDDEN); +} + +#[test] +fn effective_token_id_ignores_header_when_auth_mode_off() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_TRUSTED_TOKEN_ID, "user-supplied".parse().expect("invalid header")); + + assert_eq!(routes::effective_token_id("off", &headers), None); +} + +#[test] +fn effective_token_id_uses_header_when_auth_mode_static_keys() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_TRUSTED_TOKEN_ID, "k1".parse().expect("invalid header")); + + assert_eq!(routes::effective_token_id("static_keys", &headers), Some("k1".to_string())); +} + +#[test] +fn sanitize_trusted_token_header_removes_header() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_TRUSTED_TOKEN_ID, "user-supplied".parse().expect("invalid header")); + + routes::sanitize_trusted_token_header(&mut headers); + + assert!(headers.get(HEADER_TRUSTED_TOKEN_ID).is_none()); +} diff --git a/apps/elf-api/src/routes/tests/auth_key_resolution.rs b/apps/elf-api/src/routes/tests/auth_key_resolution.rs new file mode 100644 index 00000000..e2ed3594 --- /dev/null +++ b/apps/elf-api/src/routes/tests/auth_key_resolution.rs @@ -0,0 +1,84 @@ +use axum::http::HeaderMap; + +use crate::routes::{self, HEADER_AUTHORIZATION}; +use elf_config::{SecurityAuthKey, SecurityAuthRole}; + +#[test] +fn resolve_auth_key_requires_bearer_header() { + let headers = HeaderMap::new(); + let keys = vec![SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }]; + let err = routes::resolve_auth_key(&headers, &keys).expect_err("Expected unauthorized error."); + + assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); +} + +#[test] +fn resolve_auth_key_rejects_unknown_token() { + let keys = vec![SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }]; + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_AUTHORIZATION, "Bearer wrong".parse().expect("invalid header")); + + let err = routes::resolve_auth_key(&headers, &keys) + .expect_err("Expected unauthorized error for bad key."); + + assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); +} + +#[test] +fn resolve_auth_key_rejects_non_bearer_authorization() { + let keys = vec![SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }]; + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_AUTHORIZATION, "Token secret".parse().expect("invalid header")); + + let err = routes::resolve_auth_key(&headers, &keys) + .expect_err("Expected unauthorized error for non-bearer authorization."); + + assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); +} + +#[test] +fn resolve_auth_key_rejects_lowercase_bearer_prefix() { + let keys = vec![SecurityAuthKey { + token_id: "k1".to_string(), + token: "secret".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }]; + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_AUTHORIZATION, "bearer secret".parse().expect("invalid header")); + + let err = routes::resolve_auth_key(&headers, &keys) + .expect_err("Expected unauthorized error for lowercase bearer prefix."); + + assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); +} diff --git a/apps/elf-api/src/routes/tests/request_id.rs b/apps/elf-api/src/routes/tests/request_id.rs new file mode 100644 index 00000000..889e0850 --- /dev/null +++ b/apps/elf-api/src/routes/tests/request_id.rs @@ -0,0 +1,48 @@ +use axum::http::HeaderMap; +use serde_json::Value; +use uuid::Uuid; + +use crate::routes::{self, HEADER_REQUEST_ID}; + +#[test] +fn parse_request_id_from_headers_generates_when_missing() { + let headers = HeaderMap::new(); + let request_id = routes::parse_request_id_from_headers(&headers) + .expect("Expected a generated request ID when header is missing."); + + assert_ne!(request_id.to_string(), Uuid::nil().to_string()); +} + +#[test] +fn parse_request_id_from_headers_rejects_invalid() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_REQUEST_ID, "not-a-uuid".parse().expect("invalid request_id")); + + let err = routes::parse_request_id_from_headers(&headers) + .expect_err("Expected invalid request_id to be rejected."); + + assert_eq!(err.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!(err.error_code, "INVALID_REQUEST"); + assert_eq!(err.fields, Some(vec![format!("$.headers.{HEADER_REQUEST_ID}")])); +} + +#[test] +fn inject_request_id_into_json_body_adds_request_id_to_object() { + let request_id = Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").expect("valid uuid"); + let body = serde_json::json!({"note_id":"abc","status":"ok"}).to_string(); + let response_body = routes::inject_request_id_into_json_body(body.as_bytes(), &request_id) + .expect("Expected request_id field to be injected."); + let response_value = + serde_json::from_slice::(&response_body).expect("Expected valid JSON"); + + assert_eq!(response_value["request_id"], request_id.to_string()); +} + +#[test] +fn inject_request_id_into_json_body_skips_non_object() { + let request_id = Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").expect("valid uuid"); + let body = serde_json::json!(["a", "b", "c"]).to_string(); + + assert!(routes::inject_request_id_into_json_body(body.as_bytes(), &request_id).is_none()); +} diff --git a/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards.rs b/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards.rs index d9debb7e..883d59bd 100644 --- a/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards.rs +++ b/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards.rs @@ -1,289 +1,4 @@ -use axum::{ - body::Body, - http::{Request, StatusCode}, -}; -use tower::util::ServiceExt as _; -use uuid::Uuid; - -use crate::helpers; -use elf_api::{routes, state::AppState}; -use elf_config::{SecurityAuthKey, SecurityAuthRole}; - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_org_shared_ingest_requires_admin() { - let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; - let mut config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "static_keys".to_string(); - config.security.auth_keys = vec![ - SecurityAuthKey { - token_id: "user".to_string(), - token: "user-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({ - "scope": "org_shared", - "notes": [{ - "type": "fact", - "key": null, - "text": "你好", - "importance": 0.5, - "confidence": 0.9, - "ttl_days": null, - "source_ref": {} - }] - }); - let response_user = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/notes/ingest") - .header("Authorization", "Bearer user-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call notes ingest (user)."); - - assert_eq!(response_user.status(), StatusCode::FORBIDDEN); - - let response_admin = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/notes/ingest") - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call notes ingest (admin)."); - - assert_eq!(response_admin.status(), StatusCode::UNPROCESSABLE_ENTITY); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_org_shared_events_ingest_requires_admin() { - let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; - let mut config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "static_keys".to_string(); - config.security.auth_keys = vec![ - SecurityAuthKey { - token_id: "user".to_string(), - token: "user-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({ - "scope": "org_shared", - "dry_run": true, - "messages": [{ - "role": "user", - "content": "こんにちは" - }] - }); - let response_user = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/events/ingest") - .header("Authorization", "Bearer user-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call events ingest (user)."); - - assert_eq!(response_user.status(), StatusCode::FORBIDDEN); - - let response_admin = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/events/ingest") - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call events ingest (admin)."); - - assert_eq!(response_admin.status(), StatusCode::UNPROCESSABLE_ENTITY); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_org_shared_publish_requires_admin() { - let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; - let mut config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "static_keys".to_string(); - config.security.auth_keys = vec![ - SecurityAuthKey { - token_id: "user".to_string(), - token: "user-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let note_id = Uuid::new_v4(); - let payload = serde_json::json!({"space":"org_shared"}).to_string(); - let response_user = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/v2/notes/{note_id}/publish")) - .header("Authorization", "Bearer user-token") - .header("content-type", "application/json") - .body(Body::from(payload.clone())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call note publish (user)."); - - assert_eq!(response_user.status(), StatusCode::FORBIDDEN); - - let response_admin = app - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/v2/notes/{note_id}/publish")) - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload)) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call note publish (admin)."); - - assert_ne!(response_admin.status(), StatusCode::FORBIDDEN); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] -async fn static_keys_org_shared_grants_require_admin() { - let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; - let mut config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); - - config.security.auth_mode = "static_keys".to_string(); - config.security.auth_keys = vec![ - SecurityAuthKey { - token_id: "user".to_string(), - token: "user-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::User, - }, - SecurityAuthKey { - token_id: "admin".to_string(), - token: "admin-token".to_string(), - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: Some("a".to_string()), - read_profile: "private_plus_project".to_string(), - role: SecurityAuthRole::Admin, - }, - ]; - - let state = AppState::new(config).await.expect("Failed to initialize app state."); - let app = routes::router(state); - let payload = serde_json::json!({"grantee_kind":"project","grantee_agent_id":null}).to_string(); - let response_user = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/spaces/org_shared/grants") - .header("Authorization", "Bearer user-token") - .header("content-type", "application/json") - .body(Body::from(payload.clone())) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call grant upsert (user)."); - - assert_eq!(response_user.status(), StatusCode::FORBIDDEN); - - let response_admin = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v2/spaces/org_shared/grants") - .header("Authorization", "Bearer admin-token") - .header("content-type", "application/json") - .body(Body::from(payload)) - .expect("Failed to build request."), - ) - .await - .expect("Failed to call grant upsert (admin)."); - - assert_ne!(response_admin.status(), StatusCode::FORBIDDEN); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} +mod admin_guards_events; +mod admin_guards_grants; +mod admin_guards_notes; +mod admin_guards_publish; diff --git a/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards_events.rs b/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards_events.rs new file mode 100644 index 00000000..3e6cc31f --- /dev/null +++ b/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards_events.rs @@ -0,0 +1,81 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use tower::util::ServiceExt as _; + +use crate::helpers; +use elf_api::{routes, state::AppState}; +use elf_config::{SecurityAuthKey, SecurityAuthRole}; + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_org_shared_events_ingest_requires_admin() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; + let mut config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "static_keys".to_string(); + config.security.auth_keys = vec![ + SecurityAuthKey { + token_id: "user".to_string(), + token: "user-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({ + "scope": "org_shared", + "dry_run": true, + "messages": [{ + "role": "user", + "content": "こんにちは" + }] + }); + let response_user = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/events/ingest") + .header("Authorization", "Bearer user-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call events ingest (user)."); + + assert_eq!(response_user.status(), StatusCode::FORBIDDEN); + + let response_admin = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/events/ingest") + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call events ingest (admin)."); + + assert_eq!(response_admin.status(), StatusCode::UNPROCESSABLE_ENTITY); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards_grants.rs b/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards_grants.rs new file mode 100644 index 00000000..54f1b9ef --- /dev/null +++ b/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards_grants.rs @@ -0,0 +1,74 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use tower::util::ServiceExt as _; + +use crate::helpers; +use elf_api::{routes, state::AppState}; +use elf_config::{SecurityAuthKey, SecurityAuthRole}; + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_org_shared_grants_require_admin() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; + let mut config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "static_keys".to_string(); + config.security.auth_keys = vec![ + SecurityAuthKey { + token_id: "user".to_string(), + token: "user-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({"grantee_kind":"project","grantee_agent_id":null}).to_string(); + let response_user = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/spaces/org_shared/grants") + .header("Authorization", "Bearer user-token") + .header("content-type", "application/json") + .body(Body::from(payload.clone())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call grant upsert (user)."); + + assert_eq!(response_user.status(), StatusCode::FORBIDDEN); + + let response_admin = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/spaces/org_shared/grants") + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload)) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call grant upsert (admin)."); + + assert_ne!(response_admin.status(), StatusCode::FORBIDDEN); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards_notes.rs b/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards_notes.rs new file mode 100644 index 00000000..c3ba1e42 --- /dev/null +++ b/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards_notes.rs @@ -0,0 +1,85 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use tower::util::ServiceExt as _; + +use crate::helpers; +use elf_api::{routes, state::AppState}; +use elf_config::{SecurityAuthKey, SecurityAuthRole}; + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_org_shared_ingest_requires_admin() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; + let mut config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "static_keys".to_string(); + config.security.auth_keys = vec![ + SecurityAuthKey { + token_id: "user".to_string(), + token: "user-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let payload = serde_json::json!({ + "scope": "org_shared", + "notes": [{ + "type": "fact", + "key": null, + "text": "你好", + "importance": 0.5, + "confidence": 0.9, + "ttl_days": null, + "source_ref": {} + }] + }); + let response_user = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/notes/ingest") + .header("Authorization", "Bearer user-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call notes ingest (user)."); + + assert_eq!(response_user.status(), StatusCode::FORBIDDEN); + + let response_admin = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v2/notes/ingest") + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call notes ingest (admin)."); + + assert_eq!(response_admin.status(), StatusCode::UNPROCESSABLE_ENTITY); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards_publish.rs b/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards_publish.rs new file mode 100644 index 00000000..8d85151e --- /dev/null +++ b/apps/elf-api/tests/http/auth_admin/org_shared/admin_guards_publish.rs @@ -0,0 +1,76 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use tower::util::ServiceExt as _; +use uuid::Uuid; + +use crate::helpers; +use elf_api::{routes, state::AppState}; +use elf_config::{SecurityAuthKey, SecurityAuthRole}; + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_GRPC_URL (or ELF_QDRANT_URL) to run."] +async fn static_keys_org_shared_publish_requires_admin() { + let Some((test_db, qdrant_url, collection)) = helpers::test_env().await else { return }; + let mut config = helpers::test_config(test_db.dsn().to_string(), qdrant_url, collection); + + config.security.auth_mode = "static_keys".to_string(); + config.security.auth_keys = vec![ + SecurityAuthKey { + token_id: "user".to_string(), + token: "user-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::User, + }, + SecurityAuthKey { + token_id: "admin".to_string(), + token: "admin-token".to_string(), + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: Some("a".to_string()), + read_profile: "private_plus_project".to_string(), + role: SecurityAuthRole::Admin, + }, + ]; + + let state = AppState::new(config).await.expect("Failed to initialize app state."); + let app = routes::router(state); + let note_id = Uuid::new_v4(); + let payload = serde_json::json!({"space":"org_shared"}).to_string(); + let response_user = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/v2/notes/{note_id}/publish")) + .header("Authorization", "Bearer user-token") + .header("content-type", "application/json") + .body(Body::from(payload.clone())) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call note publish (user)."); + + assert_eq!(response_user.status(), StatusCode::FORBIDDEN); + + let response_admin = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/v2/notes/{note_id}/publish")) + .header("Authorization", "Bearer admin-token") + .header("content-type", "application/json") + .body(Body::from(payload)) + .expect("Failed to build request."), + ) + .await + .expect("Failed to call note publish (admin)."); + + assert_ne!(response_admin.status(), StatusCode::FORBIDDEN); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/apps/elf-api/tests/http/request_validation/english_gate.rs b/apps/elf-api/tests/http/request_validation/english_gate.rs index fce5b20f..f78f7243 100644 --- a/apps/elf-api/tests/http/request_validation/english_gate.rs +++ b/apps/elf-api/tests/http/request_validation/english_gate.rs @@ -1,293 +1,3 @@ -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."); -} +mod english_gate_add_event; +mod english_gate_add_note; +mod english_gate_search; diff --git a/apps/elf-api/tests/http/request_validation/english_gate_add_event.rs b/apps/elf-api/tests/http/request_validation/english_gate_add_event.rs new file mode 100644 index 00000000..07b64f25 --- /dev/null +++ b/apps/elf-api/tests/http/request_validation/english_gate_add_event.rs @@ -0,0 +1,99 @@ +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_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."); +} diff --git a/apps/elf-api/tests/http/request_validation/english_gate_add_note.rs b/apps/elf-api/tests/http/request_validation/english_gate_add_note.rs new file mode 100644 index 00000000..1d99fe09 --- /dev/null +++ b/apps/elf-api/tests/http/request_validation/english_gate_add_note.rs @@ -0,0 +1,107 @@ +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."); +} diff --git a/apps/elf-api/tests/http/request_validation/english_gate_search.rs b/apps/elf-api/tests/http/request_validation/english_gate_search.rs new file mode 100644 index 00000000..db5ea2ee --- /dev/null +++ b/apps/elf-api/tests/http/request_validation/english_gate_search.rs @@ -0,0 +1,105 @@ +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_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-mcp/src/app/server/tests/schemas.rs b/apps/elf-mcp/src/app/server/tests/schemas.rs index f3d1b9f3..67c42dda 100644 --- a/apps/elf-mcp/src/app/server/tests/schemas.rs +++ b/apps/elf-mcp/src/app/server/tests/schemas.rs @@ -1,287 +1,5 @@ -use serde_json::Value; - -use crate::app::server; - -#[test] -fn notes_ingest_schema_includes_structured_entities_relations() { - let schema = server::notes_ingest_schema(); - let notes = schema - .get("properties") - .and_then(Value::as_object) - .expect("notes ingest schema is missing properties.") - .get("notes") - .and_then(Value::as_object) - .expect("notes schema is missing notes."); - let note_items = - notes.get("items").and_then(Value::as_object).expect("notes schema is missing items."); - let note_properties = note_items - .get("properties") - .and_then(Value::as_object) - .expect("notes schema is missing note item properties."); - let structured = note_properties - .get("structured") - .and_then(Value::as_object) - .expect("notes schema is missing structured."); - let structured_type = - structured.get("type").and_then(Value::as_array).expect("structured.type is not an array."); - - assert!( - structured_type.contains(&Value::String("object".to_string())) - && structured_type.contains(&Value::String("null".to_string())) - ); - - let structured_properties = structured - .get("properties") - .and_then(Value::as_object) - .expect("structured schema is missing properties."); - - assert!(structured_properties.contains_key("entities")); - assert!(structured_properties.contains_key("relations")); - - let relation_object = structured_properties - .get("relations") - .and_then(Value::as_object) - .and_then(|relations| relations.get("items")) - .and_then(Value::as_object) - .and_then(|items| items.get("properties")) - .and_then(Value::as_object) - .expect("relations schema is missing properties.") - .get("object") - .and_then(Value::as_object) - .expect("relation schema is missing object."); - let one_of = relation_object - .get("oneOf") - .and_then(Value::as_array) - .expect("relation object is missing oneOf."); - - assert_eq!(one_of.len(), 2, "relation object should have entity/value oneOf variants."); - assert!(one_of.iter().any(|variant| { - variant.as_object().is_some_and(|branch| { - branch - .get("required") - .and_then(Value::as_array) - .is_some_and(|required| required.iter().any(|value| value == "entity")) - }) - })); - assert!(one_of.iter().any(|variant| { - variant.as_object().is_some_and(|branch| { - branch - .get("required") - .and_then(Value::as_array) - .is_some_and(|required| required.iter().any(|value| value == "value")) - }) - })); -} - -#[test] -fn recall_debug_panel_schema_rejects_context_override_fields() { - let schema = server::recall_debug_panel_schema(); - let properties = schema - .get("properties") - .and_then(Value::as_object) - .expect("recall debug panel schema is missing properties."); - - assert_eq!(schema.get("additionalProperties"), Some(&Value::Bool(false))); - - for key in ["tenant_id", "project_id", "agent_id", "read_profile"] { - assert!(!properties.contains_key(key), "{key} must not be a tool param."); - } - for key in ["graph_subject", "graph_predicate"] { - let one_of = properties - .get(key) - .and_then(Value::as_object) - .and_then(|schema| schema.get("oneOf")) - .and_then(Value::as_array) - .expect("selector schema is missing oneOf."); - - for branch in one_of.iter().filter_map(Value::as_object) { - if branch.get("type").and_then(Value::as_str) == Some("object") { - assert_eq!( - branch.get("additionalProperties"), - Some(&Value::Bool(false)), - "{key} selector object branches must be closed." - ); - } - } - } -} - -#[test] -fn docs_search_l0_schema_includes_filter_fields() { - let schema = server::docs_search_l0_schema(); - let properties = schema - .get("properties") - .and_then(Value::as_object) - .expect("docs_search_l0 schema is missing properties."); - let required = ["query"]; - let expected = [ - "scope", - "status", - "doc_type", - "agent_id", - "thread_id", - "updated_after", - "updated_before", - "ts_gte", - "ts_lte", - "sparse_mode", - "domain", - "repo", - "explain", - ]; - - for field in required { - assert!( - schema - .get("required") - .and_then(Value::as_array) - .is_some_and(|fields| { fields.iter().any(|value| value.as_str() == Some(field)) }), - "Missing required field {field}." - ); - } - for field in expected { - assert!(properties.contains_key(field), "Missing schema field: {field}."); - } - - assert_eq!( - properties.get("status").and_then(Value::as_object).and_then(|status| { - status.get("enum").and_then(Value::as_array).map(|vals| vals.to_vec()) - }), - Some(vec![ - Value::String("active".to_string()), - Value::String("deleted".to_string()), - Value::Null, - ]) - ); - assert_eq!( - properties.get("sparse_mode").and_then(Value::as_object).and_then(|field| { - field.get("enum").and_then(Value::as_array).map(|vals| vals.to_vec()) - }), - Some(vec![ - Value::String("auto".to_string()), - Value::String("on".to_string()), - Value::String("off".to_string()), - Value::Null, - ]) - ); -} - -#[test] -fn docs_put_schema_includes_required_fields_and_write_policy() { - let schema = server::docs_put_schema(); - let properties = schema - .get("properties") - .and_then(Value::as_object) - .expect("docs_put schema is missing properties."); - let required = ["scope", "content", "source_ref"]; - let expected = ["scope", "doc_type", "title", "source_ref", "write_policy", "content"]; - - for field in required { - assert!( - schema - .get("required") - .and_then(Value::as_array) - .is_some_and(|fields| { fields.iter().any(|value| value.as_str() == Some(field)) }), - "Missing required field {field}." - ); - } - for field in expected { - assert!(properties.contains_key(field), "Missing schema field: {field}."); - } - - let write_policy = properties.get("write_policy").and_then(Value::as_object); - let source_ref_properties = properties - .get("source_ref") - .and_then(|value| value.get("properties")) - .and_then(Value::as_object) - .expect("docs_put source_ref schema is missing properties."); - - assert!( - write_policy.is_some_and(|field| { - field.get("type").and_then(Value::as_array).is_some_and(|types| { - types.contains(&Value::String("object".to_string())) - && types.contains(&Value::String("null".to_string())) - }) - }), - "Missing write_policy object/null type in docs_put schema." - ); - - for field in ["source_kind", "canonical_uri", "captured_at", "trust_label", "excerpt_locator"] { - assert!(source_ref_properties.contains_key(field), "Missing source_ref field: {field}."); - } -} - -#[test] -fn work_journal_schemas_include_families_and_source_refs() { - let create_schema = server::work_journal_entry_create_schema(); - let create_properties = create_schema - .get("properties") - .and_then(Value::as_object) - .expect("work_journal_entry_create schema is missing properties."); - let readback_schema = server::work_journal_session_readback_schema(); - let readback_properties = readback_schema - .get("properties") - .and_then(Value::as_object) - .expect("work_journal_session_readback schema is missing properties."); - - for field in ["scope", "session_id", "family", "body", "source_refs"] { - assert!( - create_schema - .get("required") - .and_then(Value::as_array) - .is_some_and(|fields| { fields.iter().any(|value| value.as_str() == Some(field)) }), - "Missing Work Journal required field {field}." - ); - } - - assert!(create_properties.contains_key("write_policy")); - assert!(create_properties.contains_key("promotion_boundary")); - assert!(readback_properties.contains_key("session_id")); - assert!(readback_properties.contains_key("families")); -} - -#[test] -fn docs_excerpts_get_schema_includes_l0_level_and_optional_explain() { - let schema = server::docs_excerpts_get_schema(); - let properties = schema - .get("properties") - .and_then(Value::as_object) - .expect("docs_excerpts_get schema is missing properties."); - let level_values = properties - .get("level") - .and_then(|level| level.get("enum")) - .and_then(|values| values.as_array()) - .expect("docs_excerpts_get level schema is missing enum."); - - assert!(level_values.contains(&Value::String("L0".to_string()))); - assert!(properties.contains_key("explain")); -} - -#[test] -fn payload_level_schema_for_search_tools_is_l0_l1_l2() { - for schema in [ - server::searches_create_schema(), - server::searches_get_schema(), - server::searches_timeline_schema(), - server::searches_notes_schema(), - ] { - let properties = schema - .get("properties") - .and_then(Value::as_object) - .expect("Search schema is missing properties."); - let payload_level = properties - .get("payload_level") - .and_then(Value::as_object) - .expect("payload_level field is missing from search schema."); - let payload_level_values = payload_level - .get("enum") - .and_then(Value::as_array) - .expect("payload_level enum is missing."); - - assert_eq!(payload_level_values.len(), 4, "Unexpected payload_level enum length."); - assert!(payload_level_values.iter().any(|value| value.as_str() == Some("l0"))); - assert!(payload_level_values.iter().any(|value| value.as_str() == Some("l1"))); - assert!(payload_level_values.iter().any(|value| value.as_str() == Some("l2"))); - assert!(payload_level_values.iter().any(|value| value.is_null())); - } -} +mod docs; +mod notes; +mod recall_debug; +mod search_payload; +mod work_journal; diff --git a/apps/elf-mcp/src/app/server/tests/schemas/docs.rs b/apps/elf-mcp/src/app/server/tests/schemas/docs.rs new file mode 100644 index 00000000..9675eba8 --- /dev/null +++ b/apps/elf-mcp/src/app/server/tests/schemas/docs.rs @@ -0,0 +1,123 @@ +use serde_json::Value; + +use crate::app::server; + +#[test] +fn docs_search_l0_schema_includes_filter_fields() { + let schema = server::docs_search_l0_schema(); + let properties = schema + .get("properties") + .and_then(Value::as_object) + .expect("docs_search_l0 schema is missing properties."); + let required = ["query"]; + let expected = [ + "scope", + "status", + "doc_type", + "agent_id", + "thread_id", + "updated_after", + "updated_before", + "ts_gte", + "ts_lte", + "sparse_mode", + "domain", + "repo", + "explain", + ]; + + for field in required { + assert!( + schema + .get("required") + .and_then(Value::as_array) + .is_some_and(|fields| { fields.iter().any(|value| value.as_str() == Some(field)) }), + "Missing required field {field}." + ); + } + for field in expected { + assert!(properties.contains_key(field), "Missing schema field: {field}."); + } + + assert_eq!( + properties.get("status").and_then(Value::as_object).and_then(|status| { + status.get("enum").and_then(Value::as_array).map(|vals| vals.to_vec()) + }), + Some(vec![ + Value::String("active".to_string()), + Value::String("deleted".to_string()), + Value::Null, + ]) + ); + assert_eq!( + properties.get("sparse_mode").and_then(Value::as_object).and_then(|field| { + field.get("enum").and_then(Value::as_array).map(|vals| vals.to_vec()) + }), + Some(vec![ + Value::String("auto".to_string()), + Value::String("on".to_string()), + Value::String("off".to_string()), + Value::Null, + ]) + ); +} +#[test] +fn docs_put_schema_includes_required_fields_and_write_policy() { + let schema = server::docs_put_schema(); + let properties = schema + .get("properties") + .and_then(Value::as_object) + .expect("docs_put schema is missing properties."); + let required = ["scope", "content", "source_ref"]; + let expected = ["scope", "doc_type", "title", "source_ref", "write_policy", "content"]; + + for field in required { + assert!( + schema + .get("required") + .and_then(Value::as_array) + .is_some_and(|fields| { fields.iter().any(|value| value.as_str() == Some(field)) }), + "Missing required field {field}." + ); + } + for field in expected { + assert!(properties.contains_key(field), "Missing schema field: {field}."); + } + + let write_policy = properties.get("write_policy").and_then(Value::as_object); + let source_ref_properties = properties + .get("source_ref") + .and_then(|value| value.get("properties")) + .and_then(Value::as_object) + .expect("docs_put source_ref schema is missing properties."); + + assert!( + write_policy.is_some_and(|field| { + field.get("type").and_then(Value::as_array).is_some_and(|types| { + types.contains(&Value::String("object".to_string())) + && types.contains(&Value::String("null".to_string())) + }) + }), + "Missing write_policy object/null type in docs_put schema." + ); + + for field in ["source_kind", "canonical_uri", "captured_at", "trust_label", "excerpt_locator"] { + assert!(source_ref_properties.contains_key(field), "Missing source_ref field: {field}."); + } +} +#[test] +fn docs_excerpts_get_schema_includes_l0_level_and_optional_explain() { + let schema = server::docs_excerpts_get_schema(); + let properties = schema + .get("properties") + .and_then(Value::as_object) + .expect("docs_excerpts_get schema is missing properties."); + let level_values = properties + .get("level") + .and_then(|level| level.get("enum")) + .and_then(|values| values.as_array()) + .expect("docs_excerpts_get level schema is missing enum."); + + assert!(level_values.contains(&Value::String("L0".to_string()))); + assert!(properties.contains_key("explain")); +} diff --git a/apps/elf-mcp/src/app/server/tests/schemas/notes.rs b/apps/elf-mcp/src/app/server/tests/schemas/notes.rs new file mode 100644 index 00000000..4e28d0ba --- /dev/null +++ b/apps/elf-mcp/src/app/server/tests/schemas/notes.rs @@ -0,0 +1,74 @@ +use serde_json::Value; + +use crate::app::server; + +#[test] +fn notes_ingest_schema_includes_structured_entities_relations() { + let schema = server::notes_ingest_schema(); + let notes = schema + .get("properties") + .and_then(Value::as_object) + .expect("notes ingest schema is missing properties.") + .get("notes") + .and_then(Value::as_object) + .expect("notes schema is missing notes."); + let note_items = + notes.get("items").and_then(Value::as_object).expect("notes schema is missing items."); + let note_properties = note_items + .get("properties") + .and_then(Value::as_object) + .expect("notes schema is missing note item properties."); + let structured = note_properties + .get("structured") + .and_then(Value::as_object) + .expect("notes schema is missing structured."); + let structured_type = + structured.get("type").and_then(Value::as_array).expect("structured.type is not an array."); + + assert!( + structured_type.contains(&Value::String("object".to_string())) + && structured_type.contains(&Value::String("null".to_string())) + ); + + let structured_properties = structured + .get("properties") + .and_then(Value::as_object) + .expect("structured schema is missing properties."); + + assert!(structured_properties.contains_key("entities")); + assert!(structured_properties.contains_key("relations")); + + let relation_object = structured_properties + .get("relations") + .and_then(Value::as_object) + .and_then(|relations| relations.get("items")) + .and_then(Value::as_object) + .and_then(|items| items.get("properties")) + .and_then(Value::as_object) + .expect("relations schema is missing properties.") + .get("object") + .and_then(Value::as_object) + .expect("relation schema is missing object."); + let one_of = relation_object + .get("oneOf") + .and_then(Value::as_array) + .expect("relation object is missing oneOf."); + + assert_eq!(one_of.len(), 2, "relation object should have entity/value oneOf variants."); + assert!(one_of.iter().any(|variant| { + variant.as_object().is_some_and(|branch| { + branch + .get("required") + .and_then(Value::as_array) + .is_some_and(|required| required.iter().any(|value| value == "entity")) + }) + })); + assert!(one_of.iter().any(|variant| { + variant.as_object().is_some_and(|branch| { + branch + .get("required") + .and_then(Value::as_array) + .is_some_and(|required| required.iter().any(|value| value == "value")) + }) + })); +} diff --git a/apps/elf-mcp/src/app/server/tests/schemas/recall_debug.rs b/apps/elf-mcp/src/app/server/tests/schemas/recall_debug.rs new file mode 100644 index 00000000..cdd5513f --- /dev/null +++ b/apps/elf-mcp/src/app/server/tests/schemas/recall_debug.rs @@ -0,0 +1,36 @@ +use serde_json::Value; + +use crate::app::server; + +#[test] +fn recall_debug_panel_schema_rejects_context_override_fields() { + let schema = server::recall_debug_panel_schema(); + let properties = schema + .get("properties") + .and_then(Value::as_object) + .expect("recall debug panel schema is missing properties."); + + assert_eq!(schema.get("additionalProperties"), Some(&Value::Bool(false))); + + for key in ["tenant_id", "project_id", "agent_id", "read_profile"] { + assert!(!properties.contains_key(key), "{key} must not be a tool param."); + } + for key in ["graph_subject", "graph_predicate"] { + let one_of = properties + .get(key) + .and_then(Value::as_object) + .and_then(|schema| schema.get("oneOf")) + .and_then(Value::as_array) + .expect("selector schema is missing oneOf."); + + for branch in one_of.iter().filter_map(Value::as_object) { + if branch.get("type").and_then(Value::as_str) == Some("object") { + assert_eq!( + branch.get("additionalProperties"), + Some(&Value::Bool(false)), + "{key} selector object branches must be closed." + ); + } + } + } +} diff --git a/apps/elf-mcp/src/app/server/tests/schemas/search_payload.rs b/apps/elf-mcp/src/app/server/tests/schemas/search_payload.rs new file mode 100644 index 00000000..5ac4d6e7 --- /dev/null +++ b/apps/elf-mcp/src/app/server/tests/schemas/search_payload.rs @@ -0,0 +1,32 @@ +use serde_json::Value; + +use crate::app::server; + +#[test] +fn payload_level_schema_for_search_tools_is_l0_l1_l2() { + for schema in [ + server::searches_create_schema(), + server::searches_get_schema(), + server::searches_timeline_schema(), + server::searches_notes_schema(), + ] { + let properties = schema + .get("properties") + .and_then(Value::as_object) + .expect("Search schema is missing properties."); + let payload_level = properties + .get("payload_level") + .and_then(Value::as_object) + .expect("payload_level field is missing from search schema."); + let payload_level_values = payload_level + .get("enum") + .and_then(Value::as_array) + .expect("payload_level enum is missing."); + + assert_eq!(payload_level_values.len(), 4, "Unexpected payload_level enum length."); + assert!(payload_level_values.iter().any(|value| value.as_str() == Some("l0"))); + assert!(payload_level_values.iter().any(|value| value.as_str() == Some("l1"))); + assert!(payload_level_values.iter().any(|value| value.as_str() == Some("l2"))); + assert!(payload_level_values.iter().any(|value| value.is_null())); + } +} diff --git a/apps/elf-mcp/src/app/server/tests/schemas/work_journal.rs b/apps/elf-mcp/src/app/server/tests/schemas/work_journal.rs new file mode 100644 index 00000000..044f5387 --- /dev/null +++ b/apps/elf-mcp/src/app/server/tests/schemas/work_journal.rs @@ -0,0 +1,32 @@ +use serde_json::Value; + +use crate::app::server; + +#[test] +fn work_journal_schemas_include_families_and_source_refs() { + let create_schema = server::work_journal_entry_create_schema(); + let create_properties = create_schema + .get("properties") + .and_then(Value::as_object) + .expect("work_journal_entry_create schema is missing properties."); + let readback_schema = server::work_journal_session_readback_schema(); + let readback_properties = readback_schema + .get("properties") + .and_then(Value::as_object) + .expect("work_journal_session_readback schema is missing properties."); + + for field in ["scope", "session_id", "family", "body", "source_refs"] { + assert!( + create_schema + .get("required") + .and_then(Value::as_array) + .is_some_and(|fields| { fields.iter().any(|value| value.as_str() == Some(field)) }), + "Missing Work Journal required field {field}." + ); + } + + assert!(create_properties.contains_key("write_policy")); + assert!(create_properties.contains_key("promotion_boundary")); + assert!(readback_properties.contains_key("session_id")); + assert!(readback_properties.contains_key("families")); +} diff --git a/apps/elf-mcp/src/app/server/tests/tool_definitions.rs b/apps/elf-mcp/src/app/server/tests/tool_definitions.rs index e63a36fc..9285e642 100644 --- a/apps/elf-mcp/src/app/server/tests/tool_definitions.rs +++ b/apps/elf-mcp/src/app/server/tests/tool_definitions.rs @@ -1,325 +1,5 @@ -use std::collections::HashMap; +pub(super) mod catalog; -use crate::app::server::HttpMethod; - -const ALL_TOOL_DEFINITIONS: [ToolDefinition; 37] = [ - ToolDefinition::new( - "elf_notes_ingest", - HttpMethod::Post, - "/v2/notes/ingest", - "Ingest deterministic notes into ELF. This tool never calls an LLM.", - ), - ToolDefinition::new( - "elf_graph_query", - HttpMethod::Post, - "/v2/graph/query", - "Query graph entities and relations by structured criteria.", - ), - ToolDefinition::new( - "elf_graph_report", - HttpMethod::Post, - "/v2/graph/report", - "Build a source-backed graph topic map with current, historical, future, inferred, ambiguous, stale, and superseded fact markers.", - ), - ToolDefinition::new( - "elf_events_ingest", - HttpMethod::Post, - "/v2/events/ingest", - "Ingest an event by extracting evidence-bound notes using the configured LLM extractor.", - ), - ToolDefinition::new( - "elf_searches_create", - HttpMethod::Post, - "/v2/searches", - "Create a search session using quick-find or planned-search mode. Response includes optional trajectory_summary.", - ), - ToolDefinition::new( - "elf_core_blocks_get", - HttpMethod::Get, - "/v2/core-blocks", - "Fetch core memory blocks explicitly attached to the configured agent and read profile.", - ), - ToolDefinition::new( - "elf_entity_memory_get", - HttpMethod::Get, - "/v2/entity-memory", - "Fetch an entity-scoped memory view across attached core blocks and graph-linked archival notes.", - ), - ToolDefinition::new( - "elf_dreaming_review_queue", - HttpMethod::Get, - "/v2/admin/dreaming/review-queue", - "List source-backed Dreaming review queue proposals with variants, affected refs, lint flags, policy gates, and review audit.", - ), - ToolDefinition::new( - "elf_recall_debug_panel", - HttpMethod::Post, - "/v2/recall-debug/panel", - "Build an agent-facing cross-layer recall/debug panel and deterministic recall_trace over memory traces, source documents, knowledge pages, graph facts, and Dreaming proposals.", - ), - ToolDefinition::new( - "elf_work_journal_entry_create", - HttpMethod::Post, - "/v2/work-journal/entries", - "Capture one source-adjacent Work Journal entry with source refs, redaction, next-step, rejected-option, and promotion-boundary metadata.", - ), - ToolDefinition::new( - "elf_work_journal_entry_get", - HttpMethod::Get, - "/v2/work-journal/entries/{entry_id}", - "Fetch one readable Work Journal entry by entry_id.", - ), - ToolDefinition::new( - "elf_work_journal_session_readback", - HttpMethod::Post, - "/v2/work-journal/readback", - "Read newest Work Journal entries for a session and return a where_stopped projection with journal evidence.", - ), - ToolDefinition::new( - "elf_searches_get", - HttpMethod::Get, - "/v2/searches/{search_id}", - "Fetch a search session index view by search_id, including optional trajectory_summary.", - ), - ToolDefinition::new( - "elf_searches_timeline", - HttpMethod::Get, - "/v2/searches/{search_id}/timeline", - "Build a timeline view from a search session.", - ), - ToolDefinition::new( - "elf_searches_notes", - HttpMethod::Post, - "/v2/searches/{search_id}/notes", - "Fetch note details for selected note_ids from a search session. l0/l1 strip evidence/source_ref/structured; l2 returns full detail.", - ), - ToolDefinition::new( - "elf_notes_list", - HttpMethod::Get, - "/v2/notes", - "List notes in a tenant and project with optional filters.", - ), - ToolDefinition::new( - "elf_notes_get", - HttpMethod::Get, - "/v2/notes/{note_id}", - "Fetch a single note by note_id.", - ), - ToolDefinition::new( - "elf_notes_patch", - HttpMethod::Patch, - "/v2/notes/{note_id}", - "Patch a note by note_id. Only provided fields are updated.", - ), - ToolDefinition::new( - "elf_notes_delete", - HttpMethod::Delete, - "/v2/notes/{note_id}", - "Delete a note by note_id.", - ), - ToolDefinition::new( - "elf_notes_publish", - HttpMethod::Post, - "/v2/notes/{note_id}/publish", - "Publish a note from agent_private into a shared space (team_shared or org_shared).", - ), - ToolDefinition::new( - "elf_notes_unpublish", - HttpMethod::Post, - "/v2/notes/{note_id}/unpublish", - "Unpublish a shared note back into agent_private scope.", - ), - ToolDefinition::new( - "elf_space_grants_list", - HttpMethod::Get, - "/v2/spaces/{space}/grants", - "List sharing grants for a space (team_shared or org_shared).", - ), - ToolDefinition::new( - "elf_space_grant_upsert", - HttpMethod::Post, - "/v2/spaces/{space}/grants", - "Upsert a sharing grant for a space (team_shared or org_shared).", - ), - ToolDefinition::new( - "elf_space_grant_revoke", - HttpMethod::Post, - "/v2/spaces/{space}/grants/revoke", - "Revoke a sharing grant for a space (team_shared or org_shared).", - ), - ToolDefinition::new( - "elf_admin_traces_recent_list", - HttpMethod::Get, - "/v2/admin/traces/recent", - "List recent traces by tenant/project with optional cursor and filters.", - ), - ToolDefinition::new( - "elf_admin_trace_get", - HttpMethod::Get, - "/v2/admin/traces/{trace_id}", - "Fetch trace metadata, items, and optional trajectory summary by trace_id.", - ), - ToolDefinition::new( - "elf_admin_trajectory_get", - HttpMethod::Get, - "/v2/admin/trajectories/{trace_id}", - "Fetch trace trajectory and stage payload by trace_id.", - ), - ToolDefinition::new( - "elf_admin_trace_item_get", - HttpMethod::Get, - "/v2/admin/trace-items/{item_id}", - "Fetch a trace item explain payload by item_id.", - ), - ToolDefinition::new( - "elf_admin_note_provenance_get", - HttpMethod::Get, - "/v2/admin/notes/{note_id}/provenance", - "Fetch provenance bundle for a note.", - ), - ToolDefinition::new( - "elf_admin_memory_history_get", - HttpMethod::Get, - "/v2/admin/notes/{note_id}/history", - "Fetch chronological memory history for a note.", - ), - ToolDefinition::new( - "elf_admin_trace_bundle_get", - HttpMethod::Get, - "/v2/admin/traces/{trace_id}/bundle", - "Fetch trace bundle for replay and diagnostics by trace_id.", - ), - ToolDefinition::new( - "elf_admin_events_ingestion_profiles_list", - HttpMethod::Get, - "/v2/admin/events/ingestion-profiles", - "List latest ingestion profiles for add_event.", - ), - ToolDefinition::new( - "elf_admin_events_ingestion_profiles_create", - HttpMethod::Post, - "/v2/admin/events/ingestion-profiles", - "Create a new ingestion profile version for add_event.", - ), - ToolDefinition::new( - "elf_admin_events_ingestion_profile_get", - HttpMethod::Get, - "/v2/admin/events/ingestion-profiles/{profile_id}", - "Get a single ingestion profile by id/version for add_event.", - ), - ToolDefinition::new( - "elf_admin_events_ingestion_profile_versions_list", - HttpMethod::Get, - "/v2/admin/events/ingestion-profiles/{profile_id}/versions", - "List all versions of one ingestion profile for add_event.", - ), - ToolDefinition::new( - "elf_admin_events_ingestion_profile_default_get", - HttpMethod::Get, - "/v2/admin/events/ingestion-profiles/default", - "Get the active default ingestion profile for add_event.", - ), - ToolDefinition::new( - "elf_admin_events_ingestion_profile_default_set", - HttpMethod::Put, - "/v2/admin/events/ingestion-profiles/default", - "Set the default ingestion profile for add_event.", - ), -]; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct ToolDefinition { - name: &'static str, - method: HttpMethod, - path: &'static str, - description: &'static str, - streaming: bool, -} -impl ToolDefinition { - const fn new( - name: &'static str, - method: HttpMethod, - path: &'static str, - description: &'static str, - ) -> Self { - Self { name, method, path, description, streaming: true } - } -} - -fn build_tools() -> HashMap<&'static str, ToolDefinition> { - ALL_TOOL_DEFINITIONS.into_iter().map(|tool| (tool.name, tool)).collect() -} - -#[test] -fn registers_all_tools() { - let tools = build_tools(); - let expected = [ - "elf_notes_ingest", - "elf_graph_query", - "elf_graph_report", - "elf_events_ingest", - "elf_core_blocks_get", - "elf_entity_memory_get", - "elf_searches_create", - "elf_searches_get", - "elf_searches_timeline", - "elf_searches_notes", - "elf_notes_list", - "elf_notes_get", - "elf_notes_patch", - "elf_notes_delete", - "elf_notes_publish", - "elf_notes_unpublish", - "elf_space_grants_list", - "elf_space_grant_upsert", - "elf_space_grant_revoke", - "elf_admin_traces_recent_list", - "elf_dreaming_review_queue", - "elf_recall_debug_panel", - "elf_work_journal_entry_create", - "elf_work_journal_entry_get", - "elf_work_journal_session_readback", - "elf_admin_trace_get", - "elf_admin_trajectory_get", - "elf_admin_trace_item_get", - "elf_admin_note_provenance_get", - "elf_admin_memory_history_get", - "elf_admin_trace_bundle_get", - "elf_admin_events_ingestion_profiles_list", - "elf_admin_events_ingestion_profiles_create", - "elf_admin_events_ingestion_profile_get", - "elf_admin_events_ingestion_profile_versions_list", - "elf_admin_events_ingestion_profile_default_get", - "elf_admin_events_ingestion_profile_default_set", - ]; - - for name in expected { - assert!(tools.contains_key(name), "Missing tool registration: {name}."); - } - - assert_eq!(tools.len(), expected.len(), "Unexpected tool count for MCP registration."); -} - -#[test] -fn recall_debug_tool_uses_public_agent_route() { - let tools = build_tools(); - let tool = tools.get("elf_recall_debug_panel").expect("Missing recall debug panel tool."); - - assert_eq!(tool.path, "/v2/recall-debug/panel"); - assert!(tool.description.contains("recall_trace")); -} - -#[test] -fn searches_notes_tool_description_mentions_payload_level_shapes() { - let tools = build_tools(); - let tool = - tools.get("elf_searches_notes").expect("Missing elf_searches_notes tool definition."); - let description = tool.description.to_lowercase(); - - assert_eq!(tool.path, "/v2/searches/{search_id}/notes"); - assert!(description.contains("l0")); - assert!(description.contains("l1")); - assert!(description.contains("l2")); - assert!(description.contains("source_ref")); - assert!(description.contains("structured")); -} +mod recall_debug; +mod registration; +mod search_payload; diff --git a/apps/elf-mcp/src/app/server/tests/tool_definitions/catalog.rs b/apps/elf-mcp/src/app/server/tests/tool_definitions/catalog.rs new file mode 100644 index 00000000..2245f48e --- /dev/null +++ b/apps/elf-mcp/src/app/server/tests/tool_definitions/catalog.rs @@ -0,0 +1,251 @@ +use std::collections::HashMap; + +use crate::app::server::HttpMethod; + +const ALL_TOOL_DEFINITIONS: [ToolDefinition; 37] = [ + ToolDefinition::new( + "elf_notes_ingest", + HttpMethod::Post, + "/v2/notes/ingest", + "Ingest deterministic notes into ELF. This tool never calls an LLM.", + ), + ToolDefinition::new( + "elf_graph_query", + HttpMethod::Post, + "/v2/graph/query", + "Query graph entities and relations by structured criteria.", + ), + ToolDefinition::new( + "elf_graph_report", + HttpMethod::Post, + "/v2/graph/report", + "Build a source-backed graph topic map with current, historical, future, inferred, ambiguous, stale, and superseded fact markers.", + ), + ToolDefinition::new( + "elf_events_ingest", + HttpMethod::Post, + "/v2/events/ingest", + "Ingest an event by extracting evidence-bound notes using the configured LLM extractor.", + ), + ToolDefinition::new( + "elf_searches_create", + HttpMethod::Post, + "/v2/searches", + "Create a search session using quick-find or planned-search mode. Response includes optional trajectory_summary.", + ), + ToolDefinition::new( + "elf_core_blocks_get", + HttpMethod::Get, + "/v2/core-blocks", + "Fetch core memory blocks explicitly attached to the configured agent and read profile.", + ), + ToolDefinition::new( + "elf_entity_memory_get", + HttpMethod::Get, + "/v2/entity-memory", + "Fetch an entity-scoped memory view across attached core blocks and graph-linked archival notes.", + ), + ToolDefinition::new( + "elf_dreaming_review_queue", + HttpMethod::Get, + "/v2/admin/dreaming/review-queue", + "List source-backed Dreaming review queue proposals with variants, affected refs, lint flags, policy gates, and review audit.", + ), + ToolDefinition::new( + "elf_recall_debug_panel", + HttpMethod::Post, + "/v2/recall-debug/panel", + "Build an agent-facing cross-layer recall/debug panel and deterministic recall_trace over memory traces, source documents, knowledge pages, graph facts, and Dreaming proposals.", + ), + ToolDefinition::new( + "elf_work_journal_entry_create", + HttpMethod::Post, + "/v2/work-journal/entries", + "Capture one source-adjacent Work Journal entry with source refs, redaction, next-step, rejected-option, and promotion-boundary metadata.", + ), + ToolDefinition::new( + "elf_work_journal_entry_get", + HttpMethod::Get, + "/v2/work-journal/entries/{entry_id}", + "Fetch one readable Work Journal entry by entry_id.", + ), + ToolDefinition::new( + "elf_work_journal_session_readback", + HttpMethod::Post, + "/v2/work-journal/readback", + "Read newest Work Journal entries for a session and return a where_stopped projection with journal evidence.", + ), + ToolDefinition::new( + "elf_searches_get", + HttpMethod::Get, + "/v2/searches/{search_id}", + "Fetch a search session index view by search_id, including optional trajectory_summary.", + ), + ToolDefinition::new( + "elf_searches_timeline", + HttpMethod::Get, + "/v2/searches/{search_id}/timeline", + "Build a timeline view from a search session.", + ), + ToolDefinition::new( + "elf_searches_notes", + HttpMethod::Post, + "/v2/searches/{search_id}/notes", + "Fetch note details for selected note_ids from a search session. l0/l1 strip evidence/source_ref/structured; l2 returns full detail.", + ), + ToolDefinition::new( + "elf_notes_list", + HttpMethod::Get, + "/v2/notes", + "List notes in a tenant and project with optional filters.", + ), + ToolDefinition::new( + "elf_notes_get", + HttpMethod::Get, + "/v2/notes/{note_id}", + "Fetch a single note by note_id.", + ), + ToolDefinition::new( + "elf_notes_patch", + HttpMethod::Patch, + "/v2/notes/{note_id}", + "Patch a note by note_id. Only provided fields are updated.", + ), + ToolDefinition::new( + "elf_notes_delete", + HttpMethod::Delete, + "/v2/notes/{note_id}", + "Delete a note by note_id.", + ), + ToolDefinition::new( + "elf_notes_publish", + HttpMethod::Post, + "/v2/notes/{note_id}/publish", + "Publish a note from agent_private into a shared space (team_shared or org_shared).", + ), + ToolDefinition::new( + "elf_notes_unpublish", + HttpMethod::Post, + "/v2/notes/{note_id}/unpublish", + "Unpublish a shared note back into agent_private scope.", + ), + ToolDefinition::new( + "elf_space_grants_list", + HttpMethod::Get, + "/v2/spaces/{space}/grants", + "List sharing grants for a space (team_shared or org_shared).", + ), + ToolDefinition::new( + "elf_space_grant_upsert", + HttpMethod::Post, + "/v2/spaces/{space}/grants", + "Upsert a sharing grant for a space (team_shared or org_shared).", + ), + ToolDefinition::new( + "elf_space_grant_revoke", + HttpMethod::Post, + "/v2/spaces/{space}/grants/revoke", + "Revoke a sharing grant for a space (team_shared or org_shared).", + ), + ToolDefinition::new( + "elf_admin_traces_recent_list", + HttpMethod::Get, + "/v2/admin/traces/recent", + "List recent traces by tenant/project with optional cursor and filters.", + ), + ToolDefinition::new( + "elf_admin_trace_get", + HttpMethod::Get, + "/v2/admin/traces/{trace_id}", + "Fetch trace metadata, items, and optional trajectory summary by trace_id.", + ), + ToolDefinition::new( + "elf_admin_trajectory_get", + HttpMethod::Get, + "/v2/admin/trajectories/{trace_id}", + "Fetch trace trajectory and stage payload by trace_id.", + ), + ToolDefinition::new( + "elf_admin_trace_item_get", + HttpMethod::Get, + "/v2/admin/trace-items/{item_id}", + "Fetch a trace item explain payload by item_id.", + ), + ToolDefinition::new( + "elf_admin_note_provenance_get", + HttpMethod::Get, + "/v2/admin/notes/{note_id}/provenance", + "Fetch provenance bundle for a note.", + ), + ToolDefinition::new( + "elf_admin_memory_history_get", + HttpMethod::Get, + "/v2/admin/notes/{note_id}/history", + "Fetch chronological memory history for a note.", + ), + ToolDefinition::new( + "elf_admin_trace_bundle_get", + HttpMethod::Get, + "/v2/admin/traces/{trace_id}/bundle", + "Fetch trace bundle for replay and diagnostics by trace_id.", + ), + ToolDefinition::new( + "elf_admin_events_ingestion_profiles_list", + HttpMethod::Get, + "/v2/admin/events/ingestion-profiles", + "List latest ingestion profiles for add_event.", + ), + ToolDefinition::new( + "elf_admin_events_ingestion_profiles_create", + HttpMethod::Post, + "/v2/admin/events/ingestion-profiles", + "Create a new ingestion profile version for add_event.", + ), + ToolDefinition::new( + "elf_admin_events_ingestion_profile_get", + HttpMethod::Get, + "/v2/admin/events/ingestion-profiles/{profile_id}", + "Get a single ingestion profile by id/version for add_event.", + ), + ToolDefinition::new( + "elf_admin_events_ingestion_profile_versions_list", + HttpMethod::Get, + "/v2/admin/events/ingestion-profiles/{profile_id}/versions", + "List all versions of one ingestion profile for add_event.", + ), + ToolDefinition::new( + "elf_admin_events_ingestion_profile_default_get", + HttpMethod::Get, + "/v2/admin/events/ingestion-profiles/default", + "Get the active default ingestion profile for add_event.", + ), + ToolDefinition::new( + "elf_admin_events_ingestion_profile_default_set", + HttpMethod::Put, + "/v2/admin/events/ingestion-profiles/default", + "Set the default ingestion profile for add_event.", + ), +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct ToolDefinition { + pub(super) name: &'static str, + pub(super) method: HttpMethod, + pub(super) path: &'static str, + pub(super) description: &'static str, + pub(super) streaming: bool, +} +impl ToolDefinition { + const fn new( + name: &'static str, + method: HttpMethod, + path: &'static str, + description: &'static str, + ) -> Self { + Self { name, method, path, description, streaming: true } + } +} + +pub(super) fn build_tools() -> HashMap<&'static str, ToolDefinition> { + ALL_TOOL_DEFINITIONS.into_iter().map(|tool| (tool.name, tool)).collect() +} diff --git a/apps/elf-mcp/src/app/server/tests/tool_definitions/recall_debug.rs b/apps/elf-mcp/src/app/server/tests/tool_definitions/recall_debug.rs new file mode 100644 index 00000000..e7529aae --- /dev/null +++ b/apps/elf-mcp/src/app/server/tests/tool_definitions/recall_debug.rs @@ -0,0 +1,10 @@ +use crate::app::server::tests::tool_definitions::catalog; + +#[test] +fn recall_debug_tool_uses_public_agent_route() { + let tools = catalog::build_tools(); + let tool = tools.get("elf_recall_debug_panel").expect("Missing recall debug panel tool."); + + assert_eq!(tool.path, "/v2/recall-debug/panel"); + assert!(tool.description.contains("recall_trace")); +} diff --git a/apps/elf-mcp/src/app/server/tests/tool_definitions/registration.rs b/apps/elf-mcp/src/app/server/tests/tool_definitions/registration.rs new file mode 100644 index 00000000..11602b4c --- /dev/null +++ b/apps/elf-mcp/src/app/server/tests/tool_definitions/registration.rs @@ -0,0 +1,51 @@ +use crate::app::server::tests::tool_definitions::catalog; + +#[test] +fn registers_all_tools() { + let tools = catalog::build_tools(); + let expected = [ + "elf_notes_ingest", + "elf_graph_query", + "elf_graph_report", + "elf_events_ingest", + "elf_core_blocks_get", + "elf_entity_memory_get", + "elf_searches_create", + "elf_searches_get", + "elf_searches_timeline", + "elf_searches_notes", + "elf_notes_list", + "elf_notes_get", + "elf_notes_patch", + "elf_notes_delete", + "elf_notes_publish", + "elf_notes_unpublish", + "elf_space_grants_list", + "elf_space_grant_upsert", + "elf_space_grant_revoke", + "elf_admin_traces_recent_list", + "elf_dreaming_review_queue", + "elf_recall_debug_panel", + "elf_work_journal_entry_create", + "elf_work_journal_entry_get", + "elf_work_journal_session_readback", + "elf_admin_trace_get", + "elf_admin_trajectory_get", + "elf_admin_trace_item_get", + "elf_admin_note_provenance_get", + "elf_admin_memory_history_get", + "elf_admin_trace_bundle_get", + "elf_admin_events_ingestion_profiles_list", + "elf_admin_events_ingestion_profiles_create", + "elf_admin_events_ingestion_profile_get", + "elf_admin_events_ingestion_profile_versions_list", + "elf_admin_events_ingestion_profile_default_get", + "elf_admin_events_ingestion_profile_default_set", + ]; + + for name in expected { + assert!(tools.contains_key(name), "Missing tool registration: {name}."); + } + + assert_eq!(tools.len(), expected.len(), "Unexpected tool count for MCP registration."); +} diff --git a/apps/elf-mcp/src/app/server/tests/tool_definitions/search_payload.rs b/apps/elf-mcp/src/app/server/tests/tool_definitions/search_payload.rs new file mode 100644 index 00000000..bb3fd35f --- /dev/null +++ b/apps/elf-mcp/src/app/server/tests/tool_definitions/search_payload.rs @@ -0,0 +1,16 @@ +use crate::app::server::tests::tool_definitions::catalog; + +#[test] +fn searches_notes_tool_description_mentions_payload_level_shapes() { + let tools = catalog::build_tools(); + let tool = + tools.get("elf_searches_notes").expect("Missing elf_searches_notes tool definition."); + let description = tool.description.to_lowercase(); + + assert_eq!(tool.path, "/v2/searches/{search_id}/notes"); + assert!(description.contains("l0")); + assert!(description.contains("l1")); + assert!(description.contains("l2")); + assert!(description.contains("source_ref")); + assert!(description.contains("structured")); +} diff --git a/packages/elf-domain/src/memory_policy/support.rs b/packages/elf-domain/src/memory_policy/support.rs index c1023260..8e1d0904 100644 --- a/packages/elf-domain/src/memory_policy/support.rs +++ b/packages/elf-domain/src/memory_policy/support.rs @@ -1,287 +1,17 @@ -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, -}; +mod lifecycle; +mod memory; +mod providers; +mod ranking; +mod scopes; +mod search; +mod service_storage; + +use elf_config::{Config, MemoryPolicy}; pub(crate) fn test_config(policy: MemoryPolicy) -> Config { - let mut cfg = test_default_config(); + let mut cfg = service_storage::test_default_config(); cfg.memory.policy = policy; cfg } - -fn test_default_config() -> Config { - Config { - service: test_service_config(), - storage: test_storage_config(), - providers: test_providers_config(), - scopes: test_scopes_config(), - memory: test_memory_config(), - search: test_search_config(), - ranking: test_ranking_config(), - lifecycle: test_lifecycle_config(), - security: test_security_config(), - chunking: test_chunking_config(), - context: None, - mcp: None, - } -} - -fn test_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 test_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 test_providers_config() -> Providers { - Providers { - embedding: test_embedding_provider_config(), - rerank: test_rerank_provider_config(), - llm_extractor: test_llm_extractor_provider_config(), - } -} - -fn test_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: Default::default(), - } -} - -fn test_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: Default::default(), - } -} - -fn test_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: Default::default(), - } -} - -fn test_scopes_config() -> Scopes { - Scopes { - allowed: vec!["agent_private".to_string()], - read_profiles: test_read_profiles_config(), - precedence: ScopePrecedence { agent_private: 30, project_shared: 20, org_shared: 10 }, - write_allowed: ScopeWriteAllowed { - agent_private: true, - project_shared: true, - org_shared: true, - }, - } -} - -fn test_read_profiles_config() -> ReadProfiles { - ReadProfiles { - private_only: vec!["agent_private".to_string()], - private_plus_project: vec!["agent_private".to_string()], - all_scopes: vec!["agent_private".to_string()], - } -} - -fn test_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 test_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 test_ranking_config() -> Ranking { - Ranking { - recency_tau_days: 60.0, - tie_breaker_weight: 0.1, - deterministic: test_ranking_deterministic_config(), - blend: RankingBlend { - enabled: true, - rerank_normalization: "rank".to_string(), - retrieval_normalization: "rank".to_string(), - segments: vec![ - RankingBlendSegment { max_retrieval_rank: 3, retrieval_weight: 0.8 }, - RankingBlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.5 }, - RankingBlendSegment { max_retrieval_rank: 1_000_000, retrieval_weight: 0.2 }, - ], - }, - diversity: RankingDiversity { - enabled: true, - sim_threshold: 0.88, - mmr_lambda: 0.7, - max_skips: 64, - }, - retrieval_sources: RankingRetrievalSources { - fusion_weight: 1.0, - structured_field_weight: 1.0, - fusion_priority: 1, - structured_field_priority: 0, - }, - } -} - -fn test_ranking_deterministic_config() -> RankingDeterministic { - 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 }, - } -} - -fn test_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 test_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 test_chunking_config() -> Chunking { - Chunking { - enabled: true, - max_tokens: 512, - overlap_tokens: 128, - tokenizer_repo: "REPLACE_ME".to_string(), - } -} diff --git a/packages/elf-domain/src/memory_policy/support/lifecycle.rs b/packages/elf-domain/src/memory_policy/support/lifecycle.rs new file mode 100644 index 00000000..bced8840 --- /dev/null +++ b/packages/elf-domain/src/memory_policy/support/lifecycle.rs @@ -0,0 +1,29 @@ +use elf_config::{Lifecycle, Security, TtlDays}; + +pub(crate) fn test_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, + } +} + +pub(crate) fn test_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![], + } +} diff --git a/packages/elf-domain/src/memory_policy/support/memory.rs b/packages/elf-domain/src/memory_policy/support/memory.rs new file mode 100644 index 00000000..c0857e87 --- /dev/null +++ b/packages/elf-domain/src/memory_policy/support/memory.rs @@ -0,0 +1,40 @@ +use elf_config::{Memory, MemoryPolicy, MemoryPolicyRule}; + +pub(crate) fn test_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, + }, + ], + }, + } +} diff --git a/packages/elf-domain/src/memory_policy/support/providers.rs b/packages/elf-domain/src/memory_policy/support/providers.rs new file mode 100644 index 00000000..574bebe0 --- /dev/null +++ b/packages/elf-domain/src/memory_policy/support/providers.rs @@ -0,0 +1,47 @@ +use elf_config::{EmbeddingProviderConfig, LlmProviderConfig, ProviderConfig, Providers}; + +pub(crate) fn test_providers_config() -> Providers { + Providers { + embedding: test_embedding_provider_config(), + rerank: test_rerank_provider_config(), + llm_extractor: test_llm_extractor_provider_config(), + } +} + +fn test_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: Default::default(), + } +} + +fn test_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: Default::default(), + } +} + +fn test_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: Default::default(), + } +} diff --git a/packages/elf-domain/src/memory_policy/support/ranking.rs b/packages/elf-domain/src/memory_policy/support/ranking.rs new file mode 100644 index 00000000..ec670a94 --- /dev/null +++ b/packages/elf-domain/src/memory_policy/support/ranking.rs @@ -0,0 +1,55 @@ +use elf_config::{ + Ranking, RankingBlend, RankingBlendSegment, RankingDeterministic, RankingDeterministicDecay, + RankingDeterministicHits, RankingDeterministicLexical, RankingDiversity, + RankingRetrievalSources, +}; + +pub(crate) fn test_ranking_config() -> Ranking { + Ranking { + recency_tau_days: 60.0, + tie_breaker_weight: 0.1, + deterministic: test_ranking_deterministic_config(), + blend: RankingBlend { + enabled: true, + rerank_normalization: "rank".to_string(), + retrieval_normalization: "rank".to_string(), + segments: vec![ + RankingBlendSegment { max_retrieval_rank: 3, retrieval_weight: 0.8 }, + RankingBlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.5 }, + RankingBlendSegment { max_retrieval_rank: 1_000_000, retrieval_weight: 0.2 }, + ], + }, + diversity: RankingDiversity { + enabled: true, + sim_threshold: 0.88, + mmr_lambda: 0.7, + max_skips: 64, + }, + retrieval_sources: RankingRetrievalSources { + fusion_weight: 1.0, + structured_field_weight: 1.0, + fusion_priority: 1, + structured_field_priority: 0, + }, + } +} + +fn test_ranking_deterministic_config() -> RankingDeterministic { + 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 }, + } +} diff --git a/packages/elf-domain/src/memory_policy/support/scopes.rs b/packages/elf-domain/src/memory_policy/support/scopes.rs new file mode 100644 index 00000000..44b20dbd --- /dev/null +++ b/packages/elf-domain/src/memory_policy/support/scopes.rs @@ -0,0 +1,22 @@ +use elf_config::{ReadProfiles, ScopePrecedence, ScopeWriteAllowed, Scopes}; + +pub(crate) fn test_scopes_config() -> Scopes { + Scopes { + allowed: vec!["agent_private".to_string()], + read_profiles: test_read_profiles_config(), + precedence: ScopePrecedence { agent_private: 30, project_shared: 20, org_shared: 10 }, + write_allowed: ScopeWriteAllowed { + agent_private: true, + project_shared: true, + org_shared: true, + }, + } +} + +fn test_read_profiles_config() -> ReadProfiles { + ReadProfiles { + private_only: vec!["agent_private".to_string()], + private_plus_project: vec!["agent_private".to_string()], + all_scopes: vec!["agent_private".to_string()], + } +} diff --git a/packages/elf-domain/src/memory_policy/support/search.rs b/packages/elf-domain/src/memory_policy/support/search.rs new file mode 100644 index 00000000..74856227 --- /dev/null +++ b/packages/elf-domain/src/memory_policy/support/search.rs @@ -0,0 +1,40 @@ +use elf_config::{ + Search, SearchCache, SearchDynamic, SearchExpansion, SearchExplain, SearchGraphContext, + SearchPrefilter, SearchRecursive, +}; + +pub(crate) fn test_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, + }, + } +} diff --git a/packages/elf-domain/src/memory_policy/support/service_storage.rs b/packages/elf-domain/src/memory_policy/support/service_storage.rs new file mode 100644 index 00000000..bb12c9eb --- /dev/null +++ b/packages/elf-domain/src/memory_policy/support/service_storage.rs @@ -0,0 +1,52 @@ +use crate::memory_policy::tests::support::{lifecycle, memory, providers, ranking, scopes, search}; +use elf_config::{Chunking, Config, Postgres, Qdrant, Service, Storage}; + +pub(crate) fn test_default_config() -> Config { + Config { + service: test_service_config(), + storage: test_storage_config(), + providers: providers::test_providers_config(), + scopes: scopes::test_scopes_config(), + memory: memory::test_memory_config(), + search: search::test_search_config(), + ranking: ranking::test_ranking_config(), + lifecycle: lifecycle::test_lifecycle_config(), + security: lifecycle::test_security_config(), + chunking: test_chunking_config(), + context: None, + mcp: None, + } +} + +fn test_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 test_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 test_chunking_config() -> Chunking { + Chunking { + enabled: true, + max_tokens: 512, + overlap_tokens: 128, + tokenizer_repo: "REPLACE_ME".to_string(), + } +} diff --git a/packages/elf-domain/src/writegate/tests.rs b/packages/elf-domain/src/writegate/tests.rs index 81612d84..17f79ea3 100644 --- a/packages/elf-domain/src/writegate/tests.rs +++ b/packages/elf-domain/src/writegate/tests.rs @@ -1,293 +1,4 @@ -use serde_json::Map; +pub(crate) mod config; -use crate::writegate::{ - self, NoteInput, RejectCode, WritePolicy, WritePolicyResult, WriteRedaction, - WriteRedactionResult, -}; -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, -}; - -fn test_ranking() -> Ranking { - Ranking { - recency_tau_days: 60.0, - tie_breaker_weight: 0.1, - deterministic: RankingDeterministic { - enabled: false, - lexical: RankingDeterministicLexical { - enabled: false, - weight: 0.05, - min_ratio: 0.3, - max_query_terms: 16, - max_text_terms: 1_024, - }, - hits: RankingDeterministicHits { - enabled: false, - weight: 0.05, - half_saturation: 8.0, - last_hit_tau_days: 14.0, - }, - decay: RankingDeterministicDecay { enabled: false, weight: 0.05, tau_days: 30.0 }, - }, - blend: RankingBlend { - enabled: true, - rerank_normalization: "rank".to_string(), - retrieval_normalization: "rank".to_string(), - segments: vec![ - RankingBlendSegment { max_retrieval_rank: 3, retrieval_weight: 0.8 }, - RankingBlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.5 }, - RankingBlendSegment { max_retrieval_rank: 1_000_000, retrieval_weight: 0.2 }, - ], - }, - diversity: RankingDiversity { - enabled: true, - sim_threshold: 0.88, - mmr_lambda: 0.7, - max_skips: 64, - }, - retrieval_sources: RankingRetrievalSources { - fusion_weight: 1.0, - structured_field_weight: 1.0, - fusion_priority: 1, - structured_field_priority: 0, - }, - } -} - -fn config() -> Config { - 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(), - }, - 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, - }, - }, - providers: Providers { - embedding: dummy_embedding_provider(), - rerank: dummy_provider(), - llm_extractor: dummy_llm_provider(), - }, - 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, - }, - }, - memory: Memory { - max_notes_per_add_event: 3, - max_note_chars: 10, - dup_sim_threshold: 0.9, - update_sim_threshold: 0.8, - candidate_k: 10, - top_k: 5, - policy: MemoryPolicy { rules: vec![] }, - }, - 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, - }, - }, - ranking: test_ranking(), - lifecycle: Lifecycle { - ttl_days: TtlDays { - plan: 1, - fact: 2, - preference: 0, - constraint: 0, - decision: 0, - profile: 0, - }, - purge_deleted_after_days: 30, - purge_deprecated_after_days: 180, - }, - security: Security { - bind_localhost_only: true, - reject_non_english: true, - redact_secrets_on_write: true, - evidence_min_quotes: 1, - evidence_max_quotes: 2, - evidence_max_quote_chars: 320, - auth_mode: "off".to_string(), - auth_keys: vec![], - }, - chunking: Chunking { - enabled: true, - max_tokens: 512, - overlap_tokens: 128, - tokenizer_repo: "REPLACE_ME".to_string(), - }, - context: None, - mcp: None, - } -} - -fn dummy_embedding_provider() -> 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 dummy_provider() -> 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 dummy_llm_provider() -> 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(), - } -} - -#[test] -fn rejects_long_text() { - let cfg = config(); - let note = NoteInput { - note_type: "fact".to_string(), - scope: "agent_private".to_string(), - text: "12345678901".to_string(), - }; - - assert_eq!(writegate::writegate(¬e, &cfg), Err(RejectCode::RejectTooLong)); -} - -#[test] -fn rejects_invalid_type() { - let cfg = config(); - let note = NoteInput { - note_type: "other".to_string(), - scope: "agent_private".to_string(), - text: "hello".to_string(), - }; - - assert_eq!(writegate::writegate(¬e, &cfg), Err(RejectCode::RejectInvalidType)); -} - -#[test] -fn detects_secret_patterns() { - assert!(writegate::contains_secrets("password: hunter2")); -} - -#[test] -fn applies_empty_policy_as_noop() { - let policy = WritePolicy::default(); - - assert_eq!( - writegate::apply_write_policy("keep this", Some(&policy)), - Ok(WritePolicyResult { - transformed: "keep this".to_string(), - ..WritePolicyResult::default() - }) - ); -} - -#[test] -fn applies_exclusion_span() { - let policy = WritePolicy { - exclusions: vec![crate::writegate::WriteSpan { start: 4, end: 9 }], - redactions: vec![], - }; - let actual = writegate::apply_write_policy("hello world", Some(&policy)) - .expect("policy apply should succeed"); - - assert_eq!(actual.transformed, "hellld"); - assert_eq!(actual.audit.exclusions, vec![crate::writegate::WriteSpan { start: 4, end: 9 }]); - assert!(actual.audit.redactions.is_empty()); -} - -#[test] -fn applies_simple_replacement_redaction() { - let policy = WritePolicy { - exclusions: vec![], - redactions: vec![WriteRedaction::Replace { - span: crate::writegate::WriteSpan { start: 4, end: 5 }, - replacement: "***".to_string(), - }], - }; - let actual = writegate::apply_write_policy("secret", Some(&policy)) - .expect("policy apply should succeed"); - - assert_eq!(actual.transformed, "secr***t"); - assert_eq!( - actual.audit.redactions, - vec![WriteRedactionResult { - span: crate::writegate::WriteSpan { start: 4, end: 5 }, - replacement: "***".to_string(), - }] - ); - assert!(actual.audit.exclusions.is_empty()); -} +mod policy; +mod validation; diff --git a/packages/elf-domain/src/writegate/tests/config.rs b/packages/elf-domain/src/writegate/tests/config.rs new file mode 100644 index 00000000..9b5595bb --- /dev/null +++ b/packages/elf-domain/src/writegate/tests/config.rs @@ -0,0 +1,210 @@ +use serde_json::Map; + +use elf_config::{ + Chunking, Config, EmbeddingProviderConfig, Lifecycle, LlmProviderConfig, Memory, MemoryPolicy, + Postgres, ProviderConfig, Providers, Qdrant, Ranking, RankingBlend, RankingBlendSegment, + RankingDeterministic, RankingDeterministicDecay, RankingDeterministicHits, + RankingDeterministicLexical, RankingDiversity, RankingRetrievalSources, ReadProfiles, + ScopePrecedence, ScopeWriteAllowed, Scopes, Search, SearchCache, SearchDynamic, + SearchExpansion, SearchExplain, SearchGraphContext, SearchPrefilter, SearchRecursive, Security, + Service, Storage, TtlDays, +}; + +pub(crate) fn config() -> Config { + 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(), + }, + 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, + }, + }, + providers: Providers { + embedding: dummy_embedding_provider(), + rerank: dummy_provider(), + llm_extractor: dummy_llm_provider(), + }, + 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, + }, + }, + memory: Memory { + max_notes_per_add_event: 3, + max_note_chars: 10, + dup_sim_threshold: 0.9, + update_sim_threshold: 0.8, + candidate_k: 10, + top_k: 5, + policy: MemoryPolicy { rules: vec![] }, + }, + 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, + }, + }, + ranking: test_ranking(), + lifecycle: Lifecycle { + ttl_days: TtlDays { + plan: 1, + fact: 2, + preference: 0, + constraint: 0, + decision: 0, + profile: 0, + }, + purge_deleted_after_days: 30, + purge_deprecated_after_days: 180, + }, + security: Security { + bind_localhost_only: true, + reject_non_english: true, + redact_secrets_on_write: true, + evidence_min_quotes: 1, + evidence_max_quotes: 2, + evidence_max_quote_chars: 320, + auth_mode: "off".to_string(), + auth_keys: vec![], + }, + chunking: Chunking { + enabled: true, + max_tokens: 512, + overlap_tokens: 128, + tokenizer_repo: "REPLACE_ME".to_string(), + }, + context: None, + mcp: None, + } +} + +fn test_ranking() -> Ranking { + Ranking { + recency_tau_days: 60.0, + tie_breaker_weight: 0.1, + deterministic: RankingDeterministic { + enabled: false, + lexical: RankingDeterministicLexical { + enabled: false, + weight: 0.05, + min_ratio: 0.3, + max_query_terms: 16, + max_text_terms: 1_024, + }, + hits: RankingDeterministicHits { + enabled: false, + weight: 0.05, + half_saturation: 8.0, + last_hit_tau_days: 14.0, + }, + decay: RankingDeterministicDecay { enabled: false, weight: 0.05, tau_days: 30.0 }, + }, + blend: RankingBlend { + enabled: true, + rerank_normalization: "rank".to_string(), + retrieval_normalization: "rank".to_string(), + segments: vec![ + RankingBlendSegment { max_retrieval_rank: 3, retrieval_weight: 0.8 }, + RankingBlendSegment { max_retrieval_rank: 10, retrieval_weight: 0.5 }, + RankingBlendSegment { max_retrieval_rank: 1_000_000, retrieval_weight: 0.2 }, + ], + }, + diversity: RankingDiversity { + enabled: true, + sim_threshold: 0.88, + mmr_lambda: 0.7, + max_skips: 64, + }, + retrieval_sources: RankingRetrievalSources { + fusion_weight: 1.0, + structured_field_weight: 1.0, + fusion_priority: 1, + structured_field_priority: 0, + }, + } +} + +fn dummy_embedding_provider() -> 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 dummy_provider() -> 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 dummy_llm_provider() -> 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(), + } +} diff --git a/packages/elf-domain/src/writegate/tests/policy.rs b/packages/elf-domain/src/writegate/tests/policy.rs new file mode 100644 index 00000000..3bc5dad6 --- /dev/null +++ b/packages/elf-domain/src/writegate/tests/policy.rs @@ -0,0 +1,53 @@ +use crate::writegate::{ + self, WritePolicy, WritePolicyResult, WriteRedaction, WriteRedactionResult, +}; + +#[test] +fn applies_empty_policy_as_noop() { + let policy = WritePolicy::default(); + + assert_eq!( + writegate::apply_write_policy("keep this", Some(&policy)), + Ok(WritePolicyResult { + transformed: "keep this".to_string(), + ..WritePolicyResult::default() + }) + ); +} + +#[test] +fn applies_exclusion_span() { + let policy = WritePolicy { + exclusions: vec![crate::writegate::WriteSpan { start: 4, end: 9 }], + redactions: vec![], + }; + let actual = writegate::apply_write_policy("hello world", Some(&policy)) + .expect("policy apply should succeed"); + + assert_eq!(actual.transformed, "hellld"); + assert_eq!(actual.audit.exclusions, vec![crate::writegate::WriteSpan { start: 4, end: 9 }]); + assert!(actual.audit.redactions.is_empty()); +} + +#[test] +fn applies_simple_replacement_redaction() { + let policy = WritePolicy { + exclusions: vec![], + redactions: vec![WriteRedaction::Replace { + span: crate::writegate::WriteSpan { start: 4, end: 5 }, + replacement: "***".to_string(), + }], + }; + let actual = writegate::apply_write_policy("secret", Some(&policy)) + .expect("policy apply should succeed"); + + assert_eq!(actual.transformed, "secr***t"); + assert_eq!( + actual.audit.redactions, + vec![WriteRedactionResult { + span: crate::writegate::WriteSpan { start: 4, end: 5 }, + replacement: "***".to_string(), + }] + ); + assert!(actual.audit.exclusions.is_empty()); +} diff --git a/packages/elf-domain/src/writegate/tests/validation.rs b/packages/elf-domain/src/writegate/tests/validation.rs new file mode 100644 index 00000000..ce9d3f7e --- /dev/null +++ b/packages/elf-domain/src/writegate/tests/validation.rs @@ -0,0 +1,30 @@ +use crate::writegate::{self, NoteInput, RejectCode, tests::config}; + +#[test] +fn rejects_long_text() { + let cfg = config::config(); + let note = NoteInput { + note_type: "fact".to_string(), + scope: "agent_private".to_string(), + text: "12345678901".to_string(), + }; + + assert_eq!(writegate::writegate(¬e, &cfg), Err(RejectCode::RejectTooLong)); +} + +#[test] +fn rejects_invalid_type() { + let cfg = config::config(); + let note = NoteInput { + note_type: "other".to_string(), + scope: "agent_private".to_string(), + text: "hello".to_string(), + }; + + assert_eq!(writegate::writegate(¬e, &cfg), Err(RejectCode::RejectInvalidType)); +} + +#[test] +fn detects_secret_patterns() { + assert!(writegate::contains_secrets("password: hunter2")); +} diff --git a/packages/elf-domain/tests/memory_policy/support.rs b/packages/elf-domain/tests/memory_policy/support.rs index b8fd6dcc..f00d2e58 100644 --- a/packages/elf-domain/tests/memory_policy/support.rs +++ b/packages/elf-domain/tests/memory_policy/support.rs @@ -1,277 +1,17 @@ -use serde_json::Map; +mod support_lifecycle; +mod support_memory; +mod support_providers; +mod support_ranking; +mod support_scopes; +mod support_search; +mod support_service_storage; -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_config::{Config, MemoryPolicy}; pub(crate) fn memory_policy_config(policy: MemoryPolicy) -> Config { - let mut cfg = memory_policy_default_config(); + let mut cfg = support_service_storage::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/support_lifecycle.rs b/packages/elf-domain/tests/memory_policy/support_lifecycle.rs new file mode 100644 index 00000000..fc823c7c --- /dev/null +++ b/packages/elf-domain/tests/memory_policy/support_lifecycle.rs @@ -0,0 +1,29 @@ +use elf_config::{Lifecycle, Security, TtlDays}; + +pub(crate) 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, + } +} + +pub(crate) 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![], + } +} diff --git a/packages/elf-domain/tests/memory_policy/support_memory.rs b/packages/elf-domain/tests/memory_policy/support_memory.rs new file mode 100644 index 00000000..7392daf2 --- /dev/null +++ b/packages/elf-domain/tests/memory_policy/support_memory.rs @@ -0,0 +1,40 @@ +use elf_config::{Memory, MemoryPolicy, MemoryPolicyRule}; + +pub(crate) 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, + }, + ], + }, + } +} diff --git a/packages/elf-domain/tests/memory_policy/support_providers.rs b/packages/elf-domain/tests/memory_policy/support_providers.rs new file mode 100644 index 00000000..f3ba1f4d --- /dev/null +++ b/packages/elf-domain/tests/memory_policy/support_providers.rs @@ -0,0 +1,49 @@ +use serde_json::Map; + +use elf_config::{EmbeddingProviderConfig, LlmProviderConfig, ProviderConfig, Providers}; + +pub(crate) 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(), + } +} diff --git a/packages/elf-domain/tests/memory_policy/support_ranking.rs b/packages/elf-domain/tests/memory_policy/support_ranking.rs new file mode 100644 index 00000000..89e66025 --- /dev/null +++ b/packages/elf-domain/tests/memory_policy/support_ranking.rs @@ -0,0 +1,47 @@ +use elf_config::{ + Ranking, RankingBlend, RankingBlendSegment, RankingDeterministic, RankingDeterministicDecay, + RankingDeterministicHits, RankingDeterministicLexical, RankingDiversity, + RankingRetrievalSources, +}; + +pub(crate) 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, + }, + } +} diff --git a/packages/elf-domain/tests/memory_policy/support_scopes.rs b/packages/elf-domain/tests/memory_policy/support_scopes.rs new file mode 100644 index 00000000..0e0430bd --- /dev/null +++ b/packages/elf-domain/tests/memory_policy/support_scopes.rs @@ -0,0 +1,18 @@ +use elf_config::{ReadProfiles, ScopePrecedence, ScopeWriteAllowed, Scopes}; + +pub(crate) 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, + }, + } +} diff --git a/packages/elf-domain/tests/memory_policy/support_search.rs b/packages/elf-domain/tests/memory_policy/support_search.rs new file mode 100644 index 00000000..f775e4be --- /dev/null +++ b/packages/elf-domain/tests/memory_policy/support_search.rs @@ -0,0 +1,40 @@ +use elf_config::{ + Search, SearchCache, SearchDynamic, SearchExpansion, SearchExplain, SearchGraphContext, + SearchPrefilter, SearchRecursive, +}; + +pub(crate) 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, + }, + } +} diff --git a/packages/elf-domain/tests/memory_policy/support_service_storage.rs b/packages/elf-domain/tests/memory_policy/support_service_storage.rs new file mode 100644 index 00000000..4d15c923 --- /dev/null +++ b/packages/elf-domain/tests/memory_policy/support_service_storage.rs @@ -0,0 +1,55 @@ +use crate::support::{ + support_lifecycle, support_memory, support_providers, support_ranking, support_scopes, + support_search, +}; +use elf_config::{Chunking, Config, Postgres, Qdrant, Service, Storage}; + +pub(crate) fn memory_policy_default_config() -> Config { + Config { + service: memory_policy_service_config(), + storage: memory_policy_storage_config(), + providers: support_providers::memory_policy_providers_config(), + scopes: support_scopes::memory_policy_scopes_config(), + memory: support_memory::memory_policy_memory_config(), + search: support_search::memory_policy_search_config(), + ranking: support_ranking::memory_policy_ranking_config(), + lifecycle: support_lifecycle::memory_policy_lifecycle_config(), + security: support_lifecycle::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_chunking_config() -> Chunking { + Chunking { + enabled: true, + max_tokens: 512, + overlap_tokens: 128, + tokenizer_repo: "REPLACE_ME".to_string(), + } +}