diff --git a/crates/cli/src/events/mod.rs b/crates/cli/src/events/mod.rs index e14a2374e..f51e54adb 100644 --- a/crates/cli/src/events/mod.rs +++ b/crates/cli/src/events/mod.rs @@ -51,6 +51,10 @@ pub(crate) enum NormalizedEvent { HookMark(SessionEvent), } +#[cfg(test)] +#[path = "../../tests/coverage/shared/events_tests.rs"] +mod tests; + impl NormalizedEvent { // Extracts the routing session id regardless of normalized event kind. Keeping this on the // enum lets the session manager group events before it needs to inspect lifecycle semantics. diff --git a/crates/cli/tests/coverage/shared/events_tests.rs b/crates/cli/tests/coverage/shared/events_tests.rs new file mode 100644 index 000000000..3f2103a10 --- /dev/null +++ b/crates/cli/tests/coverage/shared/events_tests.rs @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::json; + +use super::AgentKind; +use super::json_path::{string_at, string_at_any, value_at, value_at_any}; + +#[test] +fn agent_kinds_use_stable_runtime_metadata_names() { + assert_eq!(AgentKind::Codex.as_str(), "codex"); + assert_eq!(AgentKind::ClaudeCode.as_str(), "claude-code"); + assert_eq!(AgentKind::Hermes.as_str(), "hermes"); + assert_eq!(AgentKind::Gateway.as_str(), "gateway"); +} + +#[test] +fn values_follow_nested_paths_and_return_owned_data() { + let payload = json!({"request": {"id": "request-1", "enabled": true}}); + + let extracted = value_at(&payload, &["request", "id"]); + assert_eq!(extracted, Some(json!("request-1"))); + assert_eq!(value_at(&payload, &["request", "missing"]), None); + assert_eq!( + value_at_any(&payload, &[&["missing"], &["request", "enabled"]]), + Some(json!(true)) + ); + assert_eq!( + value_at_any(&payload, &[&["request", "id"], &["request", "enabled"]]), + Some(json!("request-1")) + ); +} + +#[test] +fn string_helpers_accept_scalars_and_skip_empty_or_structured_values() { + let payload = json!({ + "empty": "", + "number": 42, + "enabled": false, + "object": {"id": "nested"} + }); + + assert_eq!(string_at(&payload, &["number"]), Some("42".into())); + assert_eq!(string_at(&payload, &["enabled"]), Some("false".into())); + assert_eq!(string_at(&payload, &["empty"]), None); + assert_eq!(string_at(&payload, &["object"]), None); + assert_eq!( + string_at_any(&payload, &[&["empty"], &["object"], &["object", "id"]]), + Some("nested".into()) + ); + assert_eq!( + string_at_any(&payload, &[&["object", "id"], &["number"]]), + Some("nested".into()) + ); +} diff --git a/crates/cli/tests/coverage/shared/file_io_tests.rs b/crates/cli/tests/coverage/shared/file_io_tests.rs index f61d2b73b..f8d764cba 100644 --- a/crates/cli/tests/coverage/shared/file_io_tests.rs +++ b/crates/cli/tests/coverage/shared/file_io_tests.rs @@ -33,6 +33,54 @@ fn lock_attempts_distinguish_contention_from_errors() { fs2::FileExt::unlock(&waiter).unwrap(); } +#[test] +fn bounded_file_reads_stream_regular_content_and_reject_directories() { + let directory = tempdir().unwrap(); + let path = directory.path().join("payload.json"); + std::fs::write(&path, b"relay payload").unwrap(); + + assert_eq!( + crate::filesystem::bounded::read_bounded_regular_file(&path, "payload").unwrap(), + b"relay payload" + ); + + let mut chunks = Vec::new(); + crate::filesystem::bounded::stream_bounded_regular_file(&path, "payload", |chunk| { + chunks.extend_from_slice(chunk); + }) + .unwrap(); + assert_eq!(chunks, b"relay payload"); + + let error = crate::filesystem::bounded::read_bounded_regular_file(directory.path(), "payload") + .unwrap_err(); + assert!(error.contains("must be a regular file"), "{error}"); +} + +#[test] +fn backups_and_snapshots_restore_original_or_missing_files() { + let directory = tempdir().unwrap(); + let path = directory.path().join("relay.toml"); + std::fs::write(&path, b"original").unwrap(); + + backup(&path).unwrap(); + std::fs::write(&path, b"changed").unwrap(); + backup(&path).unwrap(); + assert_eq!(std::fs::read(backup_path(&path)).unwrap(), b"original"); + remove_backup(&path).unwrap(); + remove_backup(&path).unwrap(); + + let existing = snapshot_optional_file(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + restore_file_snapshot(&existing).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"changed"); + + let missing = directory.path().join("missing.toml"); + let absent = snapshot_optional_file(&missing).unwrap(); + std::fs::write(&missing, b"temporary").unwrap(); + restore_file_snapshot(&absent).unwrap(); + assert!(!missing.exists()); +} + #[cfg(unix)] #[test] fn private_atomic_write_ignores_a_permissive_umask() { diff --git a/crates/cli/tests/coverage/shared/installer_tests.rs b/crates/cli/tests/coverage/shared/installer_tests.rs index e3908c6c7..29a699e66 100644 --- a/crates/cli/tests/coverage/shared/installer_tests.rs +++ b/crates/cli/tests/coverage/shared/installer_tests.rs @@ -215,6 +215,36 @@ fn verified_hook_response_rejects_invalid_status_and_fail_open_http_errors() { handle_hook_forward_status(reqwest::StatusCode::BAD_GATEWAY, String::new(), false).unwrap(); } +#[test] +fn hook_response_statuses_preserve_guardrail_rejections_and_fail_closed_errors() { + let rejection = handle_hook_forward_status( + reqwest::StatusCode::FORBIDDEN, + r#"{"error":{"type":"nemo_relay_guardrail_rejected","reason":"policy denied"}}"#.into(), + false, + ) + .unwrap_err() + .to_string(); + assert!(rejection.contains("policy denied"), "{rejection}"); + + let fallback = handle_hook_forward_status( + reqwest::StatusCode::BAD_REQUEST, + r#"{"error":{"type":"nemo_relay_guardrail_rejected","message":"fallback"}}"#.into(), + false, + ) + .unwrap_err() + .to_string(); + assert!(fallback.contains("fallback"), "{fallback}"); + + let error = handle_hook_forward_status( + reqwest::StatusCode::BAD_GATEWAY, + "not a guardrail response".into(), + true, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("HTTP 502"), "{error}"); +} + #[test] fn windows_hook_decoder_rejects_unsafe_odd_and_trailing_argument_envelopes() { const SEPARATOR: &str = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "; diff --git a/crates/core/src/plugin/dynamic/host.rs b/crates/core/src/plugin/dynamic/host.rs index dce481855..f3fa7f820 100644 --- a/crates/core/src/plugin/dynamic/host.rs +++ b/crates/core/src/plugin/dynamic/host.rs @@ -397,60 +397,5 @@ impl Drop for PluginHostActivation { } #[cfg(test)] -mod tests { - use super::*; - use crate::plugin::{PLUGIN_HANDLERS, PLUGIN_MUTATION_OWNER, PluginMutationOwner}; - - struct PoisonedRegistryCleanup; - - impl Drop for PoisonedRegistryCleanup { - fn drop(&mut self) { - PLUGIN_HANDLERS.clear_poison(); - if let Ok(mut owner) = PLUGIN_MUTATION_OWNER.lock() { - *owner = PluginMutationOwner::Idle; - } - } - } - - #[test] - fn unsafe_kind_deregistration_retains_runtime_and_owner() { - let _guard = crate::shared_runtime::runtime_owner_test_mutex() - .lock() - .unwrap_or_else(|error| error.into_inner()); - let _cleanup = PoisonedRegistryCleanup; - let claim = acquire_plugin_host_lease().expect("fixture host should acquire the owner"); - let owner_id = claim.owner_id(); - let mut activation = PluginHostActivation { - active: true, - native: Some(NativePluginActivation::with_plugin_kind_for_test( - "fixture.poisoned", - )), - #[cfg(feature = "worker-grpc")] - worker: None, - claim: Some(claim), - }; - - std::thread::spawn(|| { - let _registry = PLUGIN_HANDLERS.write().unwrap(); - panic!("poison plugin registry for teardown test"); - }) - .join() - .expect_err("fixture registry writer should panic"); - - let error = activation - .clear_inner() - .expect_err("an uncertain kind deregistration must retain the activation") - .to_string(); - assert!(error.contains("plugin registry lock poisoned"), "{error}"); - assert!(error.contains("activation owner were retained"), "{error}"); - assert!(!activation.is_active()); - assert_eq!( - *PLUGIN_MUTATION_OWNER.lock().unwrap(), - PluginMutationOwner::Host(owner_id) - ); - assert!(matches!( - acquire_plugin_host_lease(), - Err(crate::plugin::PluginError::Conflict(_)) - )); - } -} +#[path = "../../../tests/unit/plugin_dynamic_host_tests.rs"] +mod tests; diff --git a/crates/core/tests/coverage/error_tests.rs b/crates/core/tests/coverage/error_tests.rs index ef88ebde9..d7e000fb0 100644 --- a/crates/core/tests/coverage/error_tests.rs +++ b/crates/core/tests/coverage/error_tests.rs @@ -53,3 +53,26 @@ fn test_error_debug() { let debug = format!("{e:?}"); assert!(debug.contains("AlreadyExists")); } + +#[test] +fn upstream_failures_classify_retryability_and_render_status() { + use std::collections::BTreeMap; + + let retryable = UpstreamFailure { + status: Some(503), + body: "temporarily unavailable".into(), + headers: BTreeMap::new(), + class: UpstreamFailureClass::RetryableStatus, + }; + assert!(retryable.is_retryable()); + assert!(retryable.to_string().contains("HTTP 503")); + + let rejected = UpstreamFailure { + status: None, + body: "invalid API key".into(), + headers: BTreeMap::new(), + class: UpstreamFailureClass::Authentication, + }; + assert!(!rejected.is_retryable()); + assert!(rejected.to_string().contains("transport failure")); +} diff --git a/crates/core/tests/unit/plugin_dynamic_host_tests.rs b/crates/core/tests/unit/plugin_dynamic_host_tests.rs new file mode 100644 index 000000000..f959b8eb2 --- /dev/null +++ b/crates/core/tests/unit/plugin_dynamic_host_tests.rs @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::plugin::{PLUGIN_HANDLERS, PLUGIN_MUTATION_OWNER, PluginMutationOwner}; +use serde_json::{Map, Value as Json}; + +struct PoisonedRegistryCleanup; + +impl Drop for PoisonedRegistryCleanup { + fn drop(&mut self) { + PLUGIN_HANDLERS.clear_poison(); + if let Ok(mut owner) = PLUGIN_MUTATION_OWNER.lock() { + *owner = PluginMutationOwner::Idle; + } + } +} + +#[test] +fn unsafe_kind_deregistration_retains_runtime_and_owner() { + let _guard = crate::shared_runtime::runtime_owner_test_mutex() + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _cleanup = PoisonedRegistryCleanup; + let claim = acquire_plugin_host_lease().expect("fixture host should acquire the owner"); + let owner_id = claim.owner_id(); + let mut activation = PluginHostActivation { + active: true, + native: Some(NativePluginActivation::with_plugin_kind_for_test( + "fixture.poisoned", + )), + #[cfg(feature = "worker-grpc")] + worker: None, + claim: Some(claim), + }; + + std::thread::spawn(|| { + let _registry = PLUGIN_HANDLERS.write().unwrap(); + panic!("poison plugin registry for teardown test"); + }) + .join() + .expect_err("fixture registry writer should panic"); + + let error = activation + .clear_inner() + .expect_err("an uncertain kind deregistration must retain the activation") + .to_string(); + assert!(error.contains("plugin registry lock poisoned"), "{error}"); + assert!(error.contains("activation owner were retained"), "{error}"); + assert!(!activation.is_active()); + assert_eq!( + *PLUGIN_MUTATION_OWNER.lock().unwrap(), + PluginMutationOwner::Host(owner_id) + ); + assert!(matches!( + acquire_plugin_host_lease(), + Err(crate::plugin::PluginError::Conflict(_)) + )); +} + +#[test] +fn dynamic_plugin_specs_require_unique_nonempty_input() { + let empty = validate_dynamic_plugin_specs(&[]).unwrap_err().to_string(); + assert!( + empty.contains("requires at least one dynamic plugin"), + "{empty}" + ); + + let duplicate = DynamicPluginActivationSpec { + plugin_id: "fixture.duplicate".into(), + kind: DynamicPluginKind::RustDynamic, + manifest_ref: "relay-plugin.toml".into(), + environment_ref: None, + config: Map::new(), + }; + let error = validate_dynamic_plugin_specs(&[duplicate.clone(), duplicate]) + .unwrap_err() + .to_string(); + assert!(error.contains("duplicate dynamic plugin id"), "{error}"); +} + +#[test] +fn plugin_error_context_preserves_each_error_class() { + use crate::plugin::PluginError; + + let serialization = serde_json::from_str::("{").unwrap_err(); + let errors = [ + PluginError::InvalidConfig("invalid".into()), + PluginError::Conflict("conflict".into()), + PluginError::NotFound("missing".into()), + PluginError::Serialization(serialization), + PluginError::Internal("internal".into()), + PluginError::RegistrationFailed("registration".into()), + ]; + + for error in errors { + let message = plugin_error_context("dynamic load", error).to_string(); + assert!(message.contains("dynamic load"), "{message}"); + } +} + +#[test] +fn retained_runtime_errors_include_cleanup_details_when_available() { + let default_error = retained_runtime_error(Vec::new()).to_string(); + assert!( + default_error.contains("teardown was incomplete"), + "{default_error}" + ); + + let detailed_error = + retained_runtime_error(vec!["registry remained active".into()]).to_string(); + assert!( + detailed_error.contains("registry remained active"), + "{detailed_error}" + ); +} diff --git a/crates/core/tests/unit/plugins/nemo_guardrails/local_python_tests.rs b/crates/core/tests/unit/plugins/nemo_guardrails/local_python_tests.rs index 7867c8c6a..214fa567e 100644 --- a/crates/core/tests/unit/plugins/nemo_guardrails/local_python_tests.rs +++ b/crates/core/tests/unit/plugins/nemo_guardrails/local_python_tests.rs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +#![allow(clippy::await_holding_lock)] // Runtime isolation requires serial async plugin tests. + #[cfg(unix)] use std::fs; #[cfg(unix)] @@ -10,12 +12,29 @@ use std::path::{Path, PathBuf}; #[cfg(unix)] use std::process::Command; #[cfg(unix)] -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use serde_json::json; use super::*; +#[cfg(unix)] +use crate::api::llm::{LlmAttributes, LlmCallExecuteParams, LlmRequest, llm_call_execute}; +#[cfg(unix)] +use crate::api::runtime::{ + LlmExecutionNextFn, NemoRelayContextState, ThreadScopeStackBinding, capture_thread_scope_stack, + create_scope_stack, global_context, restore_thread_scope_stack, set_thread_scope_stack, +}; +#[cfg(unix)] +use crate::api::tool::{ToolCallExecuteParams, tool_call_execute}; +#[cfg(unix)] +use crate::codec::openai_chat::OpenAIChatCodec; +#[cfg(unix)] +use crate::codec::traits::LlmResponseCodec; +#[cfg(unix)] +use crate::plugin::{ + PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins, +}; use crate::plugins::nemo_guardrails::component::LocalBackendConfig; #[cfg(unix)] @@ -490,3 +509,194 @@ fn stream_text_extraction_handles_supported_codecs() { Some("hello".to_string()) ); } + +#[cfg(unix)] +async fn install_local_plugin(config: &NeMoGuardrailsConfig) { + let component_config = serde_json::to_value(config) + .unwrap() + .as_object() + .unwrap() + .clone(); + initialize_plugins(PluginConfig { + version: 1, + components: vec![PluginComponentSpec { + kind: crate::plugins::nemo_guardrails::component::NEMO_GUARDRAILS_PLUGIN_KIND + .to_string(), + enabled: true, + config: component_config, + }], + policy: Default::default(), + }) + .await + .unwrap(); +} + +#[cfg(unix)] +struct PluginRuntimeResetGuard { + previous_scope_stack: ThreadScopeStackBinding, +} + +#[cfg(unix)] +impl Drop for PluginRuntimeResetGuard { + fn drop(&mut self) { + let _ = clear_plugin_configuration(); + crate::shared_runtime::reset_runtime_owner_for_tests(); + *global_context().write().unwrap() = NemoRelayContextState::new(); + restore_thread_scope_stack(self.previous_scope_stack.clone()); + } +} + +#[cfg(unix)] +fn reset_plugin_runtime() -> PluginRuntimeResetGuard { + let previous_scope_stack = capture_thread_scope_stack(); + let _ = clear_plugin_configuration(); + crate::shared_runtime::reset_runtime_owner_for_tests(); + *global_context().write().unwrap() = NemoRelayContextState::new(); + set_thread_scope_stack(create_scope_stack()); + PluginRuntimeResetGuard { + previous_scope_stack, + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "current_thread")] +async fn registered_local_backend_rewrites_llm_requests_and_tool_payloads() { + if !python3_available() { + return; + } + + let _guard = crate::plugins::nemo_guardrails::test_mutex() + .lock() + .unwrap_or_else(|err| err.into_inner()); + let _runtime_guard = reset_plugin_runtime(); + + let fixture = FakeGuardrails::new("0.22.0"); + let mut config = fixture.config(); + config.input = true; + config.output = true; + config.tool_input = true; + config.tool_output = true; + install_local_plugin(&config).await; + + let observed_request = Arc::new(Mutex::new(None)); + let observed_callback_request = Arc::clone(&observed_request); + let callback: LlmExecutionNextFn = Arc::new(move |request| { + *observed_callback_request.lock().unwrap() = Some(request); + Box::pin(async move { + Ok(json!({ + "choices": [{"message": {"role": "assistant", "content": "provider answer"}}] + })) + }) + }); + let request = LlmRequest { + headers: Default::default(), + content: json!({ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "modify this request"}] + }), + }; + let response = llm_call_execute( + LlmCallExecuteParams::builder() + .name("openai") + .request(request) + .func(callback) + .attributes(LlmAttributes::empty()) + .response_codec(Arc::new(OpenAIChatCodec) as Arc) + .build(), + ) + .await + .unwrap(); + assert_eq!( + observed_request.lock().unwrap().as_ref().unwrap().content["messages"][0]["content"], + json!("rewritten") + ); + assert_eq!( + response["choices"][0]["message"]["content"], + json!("provider answer") + ); + + let tool_result = tool_call_execute( + ToolCallExecuteParams::builder() + .name("lookup") + .args(json!({"modify-tool": true})) + .func(Arc::new(|args| { + Box::pin(async move { + assert_eq!(args, json!({"safe": true})); + Ok(json!({"original": true})) + }) + })) + .build(), + ) + .await + .unwrap(); + assert_eq!(tool_result, json!({"original": true})); +} + +#[cfg(unix)] +#[tokio::test(flavor = "current_thread")] +async fn registered_local_backend_rejects_blocked_llm_and_tool_inputs() { + if !python3_available() { + return; + } + + let _guard = crate::plugins::nemo_guardrails::test_mutex() + .lock() + .unwrap_or_else(|err| err.into_inner()); + let _runtime_guard = reset_plugin_runtime(); + + let fixture = FakeGuardrails::new("0.22.0"); + let mut config = fixture.config(); + config.input = true; + config.tool_input = true; + install_local_plugin(&config).await; + + let llm_callback_called = Arc::new(AtomicBool::new(false)); + let llm_callback_marker = Arc::clone(&llm_callback_called); + let llm_error = llm_call_execute( + LlmCallExecuteParams::builder() + .name("openai") + .request(LlmRequest { + headers: Default::default(), + content: json!({ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "block this request"}] + }), + }) + .func(Arc::new(move |_| { + llm_callback_marker.store(true, Ordering::SeqCst); + Box::pin(async { Ok(json!({})) }) + })) + .attributes(LlmAttributes::empty()) + .response_codec(Arc::new(OpenAIChatCodec) as Arc) + .build(), + ) + .await + .unwrap_err(); + assert!( + llm_error + .to_string() + .contains("input rail blocked the LLM call") + ); + assert!(!llm_callback_called.load(Ordering::SeqCst)); + + let tool_callback_called = Arc::new(AtomicBool::new(false)); + let tool_callback_marker = Arc::clone(&tool_callback_called); + let tool_error = tool_call_execute( + ToolCallExecuteParams::builder() + .name("lookup") + .args(json!({"block": true})) + .func(Arc::new(move |_| { + tool_callback_marker.store(true, Ordering::SeqCst); + Box::pin(async { Ok(json!({})) }) + })) + .build(), + ) + .await + .unwrap_err(); + assert!( + tool_error + .to_string() + .contains("tool_input rail blocked the tool call") + ); + assert!(!tool_callback_called.load(Ordering::SeqCst)); +} diff --git a/crates/core/tests/unit/types_tests.rs b/crates/core/tests/unit/types_tests.rs index d9375b497..557a3286a 100644 --- a/crates/core/tests/unit/types_tests.rs +++ b/crates/core/tests/unit/types_tests.rs @@ -19,7 +19,10 @@ use crate::api::scope::{HandleAttributes, ScopeAttributes, ScopeHandle, ScopeTyp use crate::api::tool::{ToolAttributes, ToolHandle}; use crate::codec::request::{AnnotatedLlmRequest, Message, MessageContent}; use crate::codec::response::AnnotatedLlmResponse; -use crate::config_editor::{EditorConfig, EditorFieldKind}; +use crate::config_editor::{ + EditorConfig, EditorFieldKind, JSON_LIST_ITEM, STRING_LIST_ITEM, default_json_list_item_value, + default_string_list_item_value, +}; #[derive(Default, serde::Serialize)] struct NestedEditorFixture { @@ -557,6 +560,16 @@ fn scope_type_strings_and_editor_metadata_cover_public_helpers() { assert!(schema.field("missing").is_none()); } +#[test] +fn editor_list_defaults_describe_empty_string_and_null_values() { + assert_eq!(default_string_list_item_value(), json!("")); + assert_eq!(default_json_list_item_value(), json!(null)); + assert_eq!(STRING_LIST_ITEM.kind, EditorFieldKind::String); + assert_eq!(STRING_LIST_ITEM.default.unwrap()(), json!("")); + assert_eq!(JSON_LIST_ITEM.kind, EditorFieldKind::Json); + assert_eq!(JSON_LIST_ITEM.default.unwrap()(), json!(null)); +} + #[test] fn event_json_value_uses_canonical_subscriber_shape() { let request = annotated_request("demo-model", "hi");