From 525eaf74d7aa29092bee9f8a9736fe593fd8a892 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Fri, 29 May 2026 15:14:46 -0700 Subject: [PATCH] refactor(rust): move large inline tests to files (#2987) --- app/src-tauri/src/discord_scanner/mod.rs | 337 +- .../src/discord_scanner/mod_tests.rs | 333 ++ app/src-tauri/src/lib.rs | 945 +---- app/src-tauri/src/lib_tests.rs | 940 +++++ app/src-tauri/src/webview_accounts/mod.rs | 1213 +----- .../src/webview_accounts/mod_tests.rs | 1196 ++++++ ...snapshot_test.rs => dom_snapshot_tests.rs} | 0 app/src-tauri/src/whatsapp_scanner/mod.rs | 616 +-- .../src/whatsapp_scanner/mod_tests.rs | 609 +++ src/core/observability.rs | 3558 +---------------- src/core/observability_tests.rs | 3545 ++++++++++++++++ src/openhuman/agent/harness/mod.rs | 2 +- ..._support_test.rs => test_support_tests.rs} | 0 .../agent/tools/spawn_parallel_agents.rs | 2 +- ...test.rs => spawn_parallel_agents_tests.rs} | 0 src/openhuman/composio/client_tests.rs | 2 +- src/openhuman/composio/ops.rs | 2 +- .../composio/{ops_test.rs => ops_tests.rs} | 0 src/openhuman/cwd_jail/registry.rs | 2 +- .../{registry_test.rs => registry_tests.rs} | 2 +- src/openhuman/inference/provider/factory.rs | 4 +- .../{factory_test.rs => factory_tests.rs} | 0 src/openhuman/inference/provider/ops.rs | 1292 +----- src/openhuman/inference/provider/ops_tests.rs | 1278 ++++++ src/openhuman/inference/provider/router.rs | 4 +- .../{router_test.rs => router_tests.rs} | 0 src/openhuman/integrations/test_support.rs | 2 +- ..._support_test.rs => test_support_tests.rs} | 0 src/openhuman/mcp_server/tools.rs | 914 +---- src/openhuman/mcp_server/tools_tests.rs | 906 +++++ src/openhuman/memory/mod.rs | 4 +- src/openhuman/memory/read_rpc.rs | 783 +--- src/openhuman/memory/read_rpc_tests.rs | 785 ++++ ...e2e_test.rs => sync_pipeline_e2e_tests.rs} | 0 .../{tree_e2e_test.rs => tree_e2e_tests.rs} | 0 .../composio/providers/slack/rpc.rs | 2 +- .../composio/providers/user_scopes.rs | 2 +- ...er_scopes_test.rs => user_scopes_tests.rs} | 0 ...tegration_test.rs => integration_tests.rs} | 0 src/openhuman/memory_tree/retrieval/mod.rs | 2 +- ...tegration_test.rs => integration_tests.rs} | 0 src/openhuman/subconscious/mod.rs | 2 +- src/openhuman/tool_registry/ops.rs | 2 +- .../{ops_test.rs => ops_tests.rs} | 0 src/openhuman/webview_accounts/mod.rs | 2 +- ..._ingest_test.rs => wechat_ingest_tests.rs} | 0 46 files changed, 9628 insertions(+), 9660 deletions(-) create mode 100644 app/src-tauri/src/discord_scanner/mod_tests.rs create mode 100644 app/src-tauri/src/lib_tests.rs create mode 100644 app/src-tauri/src/webview_accounts/mod_tests.rs rename app/src-tauri/src/whatsapp_scanner/{dom_snapshot_test.rs => dom_snapshot_tests.rs} (100%) create mode 100644 app/src-tauri/src/whatsapp_scanner/mod_tests.rs create mode 100644 src/core/observability_tests.rs rename src/openhuman/agent/harness/{test_support_test.rs => test_support_tests.rs} (100%) rename src/openhuman/agent/tools/{spawn_parallel_agents_test.rs => spawn_parallel_agents_tests.rs} (100%) rename src/openhuman/composio/{ops_test.rs => ops_tests.rs} (100%) rename src/openhuman/cwd_jail/{registry_test.rs => registry_tests.rs} (99%) rename src/openhuman/inference/provider/{factory_test.rs => factory_tests.rs} (100%) create mode 100644 src/openhuman/inference/provider/ops_tests.rs rename src/openhuman/inference/provider/{router_test.rs => router_tests.rs} (100%) rename src/openhuman/integrations/{test_support_test.rs => test_support_tests.rs} (100%) create mode 100644 src/openhuman/mcp_server/tools_tests.rs create mode 100644 src/openhuman/memory/read_rpc_tests.rs rename src/openhuman/memory/{sync_pipeline_e2e_test.rs => sync_pipeline_e2e_tests.rs} (100%) rename src/openhuman/memory/{tree_e2e_test.rs => tree_e2e_tests.rs} (100%) rename src/openhuman/memory_sync/composio/providers/{user_scopes_test.rs => user_scopes_tests.rs} (100%) rename src/openhuman/memory_tree/retrieval/{integration_test.rs => integration_tests.rs} (100%) rename src/openhuman/subconscious/{integration_test.rs => integration_tests.rs} (100%) rename src/openhuman/tool_registry/{ops_test.rs => ops_tests.rs} (100%) rename src/openhuman/webview_accounts/{wechat_ingest_test.rs => wechat_ingest_tests.rs} (100%) diff --git a/app/src-tauri/src/discord_scanner/mod.rs b/app/src-tauri/src/discord_scanner/mod.rs index b172d3de7..9cf4a2aff 100644 --- a/app/src-tauri/src/discord_scanner/mod.rs +++ b/app/src-tauri/src/discord_scanner/mod.rs @@ -1322,338 +1322,5 @@ impl ScannerRegistry { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn gateway_guild_create_and_message_create_build_channel_snapshot() { - let mut state = DiscordIngestState::default(); - state.apply_gateway_payload( - r#"{ - "op":0, - "t":"GUILD_CREATE", - "d":{ - "id":"guild-1", - "channels":[{"id":"chan-1","name":"general"}], - "threads":[] - } - }"#, - ); - - let snapshots = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_CREATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "guild_id":"guild-1", - "content":"hello discord", - "timestamp":"2026-05-17T12:34:56.000Z", - "author":{"id":"user-1","username":"alice","global_name":"Alice"}, - "member":{"nick":"Ali"} - } - }"#, - ); - - assert_eq!(snapshots.len(), 1); - let snapshot = &snapshots[0]; - assert_eq!(snapshot.channel_id, "chan-1"); - assert_eq!(snapshot.channel_name, "general"); - assert_eq!(snapshot.guild_id.as_deref(), Some("guild-1")); - assert_eq!(snapshot.messages.len(), 1); - assert_eq!(snapshot.messages[0].author, "Ali"); - assert_eq!(snapshot.messages[0].body, "hello discord"); - assert_eq!( - snapshot.messages[0].source_ref, - "https://discord.com/channels/guild-1/chan-1/msg-1" - ); - } - - #[test] - fn channel_create_dm_recipients_become_channel_name() { - let mut state = DiscordIngestState::default(); - let snapshots = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"CHANNEL_CREATE", - "d":{ - "id":"dm-1", - "type":1, - "recipients":[ - {"id":"u1","username":"alice"}, - {"id":"u2","global_name":"Bob Builder"} - ] - } - }"#, - ); - - assert!(snapshots.is_empty()); - let channel = state.channels.get("dm-1").expect("dm channel cached"); - assert_eq!(channel.name.as_deref(), Some("alice, Bob Builder")); - } - - #[test] - fn channel_update_emits_snapshot_when_messages_are_already_cached() { - let mut state = DiscordIngestState::default(); - let _ = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_CREATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "guild_id":"guild-1", - "content":"hello", - "timestamp":"2026-05-17T12:34:56.000Z", - "author":{"id":"user-1","username":"alice"} - } - }"#, - ); - - let snapshots = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"CHANNEL_UPDATE", - "d":{ - "id":"chan-1", - "guild_id":"guild-1", - "name":"renamed-general" - } - }"#, - ); - - assert_eq!(snapshots.len(), 1); - assert_eq!(snapshots[0].channel_name, "renamed-general"); - assert_eq!(snapshots[0].messages.len(), 1); - } - - #[test] - fn message_update_replaces_existing_message_body() { - let mut state = DiscordIngestState::default(); - let _ = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_CREATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "content":"before", - "timestamp":"2026-05-17T12:34:56.000Z", - "author":{"id":"user-1","username":"alice"} - } - }"#, - ); - - let snapshots = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_UPDATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "content":"after", - "timestamp":"2026-05-17T12:34:56.000Z", - "author":{"id":"user-1","username":"alice"} - } - }"#, - ); - - assert_eq!(snapshots.len(), 1); - assert_eq!(snapshots[0].messages.len(), 1); - assert_eq!(snapshots[0].messages[0].body, "after"); - } - - #[test] - fn message_update_preserves_missing_fields_from_cached_message() { - let mut state = DiscordIngestState::default(); - let _ = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_CREATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "guild_id":"guild-1", - "content":"before", - "timestamp":"2026-05-17T12:34:56.000Z", - "author":{"id":"user-1","username":"alice"} - } - }"#, - ); - - let snapshots = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_UPDATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "guild_id":"guild-1", - "edited_timestamp":"2026-05-17T12:35:56.000Z" - } - }"#, - ); - - assert_eq!(snapshots.len(), 1); - let message = &snapshots[0].messages[0]; - assert_eq!(message.body, "before"); - assert_eq!(message.author, "alice"); - assert_eq!(message.author_id, "user-1"); - assert_eq!( - message.timestamp_ms, - parse_discord_timestamp_ms("2026-05-17T12:34:56.000Z").unwrap() - ); - } - - #[test] - fn message_update_with_embed_only_keeps_existing_body_text() { - let mut state = DiscordIngestState::default(); - let _ = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_CREATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "content":"before", - "timestamp":"2026-05-17T12:34:56.000Z", - "author":{"id":"user-1","username":"alice"} - } - }"#, - ); - - let snapshots = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_UPDATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "embeds":[{"title":"preview card"}] - } - }"#, - ); - - assert_eq!(snapshots.len(), 1); - assert_eq!(snapshots[0].messages[0].body, "before"); - } - - #[test] - fn discord_channel_doc_key_scopes_same_channel_name_by_guild() { - assert_eq!( - discord_channel_doc_key("guild-1", "chan-1"), - "guild-1:chan-1" - ); - assert_eq!( - discord_channel_doc_key("guild-2", "chan-1"), - "guild-2:chan-1" - ); - assert_eq!(discord_channel_doc_key("", "chan-1"), "@me:chan-1"); - } - - fn insert_pending_tasks( - registry: &ScannerRegistry, - account_id: &str, - count: usize, - ) -> Vec> { - let mut tasks = Vec::with_capacity(count); - let mut abort_handles = Vec::with_capacity(count); - for _ in 0..count { - let task = tokio::spawn(async { - std::future::pending::<()>().await; - }); - abort_handles.push(task.abort_handle()); - tasks.push(task); - } - registry - .started - .lock() - .insert(account_id.to_string(), abort_handles); - tasks - } - - async fn assert_cancelled(task: tokio::task::JoinHandle<()>) { - let err = tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("aborted scanner task should finish") - .expect_err("scanner task should be cancelled"); - assert!(err.is_cancelled()); - } - - async fn assert_all_cancelled(tasks: Vec>) { - for task in tasks { - assert_cancelled(task).await; - } - } - - #[tokio::test] - async fn registry_forget_aborts_all_handles_for_account_only() { - let registry = ScannerRegistry::default(); - let account_tasks = insert_pending_tasks(®istry, "acct-1", 2); - let survivor_tasks = insert_pending_tasks(®istry, "acct-2", 1); - - registry.forget("acct-1"); - - { - let guard = registry.started.lock(); - assert_eq!(guard.len(), 1); - assert!(guard.contains_key("acct-2")); - } - assert_all_cancelled(account_tasks).await; - assert!( - !survivor_tasks[0].is_finished(), - "forget(acct-1) must not abort acct-2" - ); - - assert_eq!(registry.forget_all(), 1); - assert_all_cancelled(survivor_tasks).await; - } - - #[tokio::test] - async fn registry_forget_missing_account_is_noop() { - let registry = ScannerRegistry::default(); - let mut tasks = insert_pending_tasks(®istry, "acct-1", 1); - - registry.forget("missing"); - - { - let guard = registry.started.lock(); - assert_eq!(guard.len(), 1); - assert!(guard.contains_key("acct-1")); - } - assert!( - !tasks[0].is_finished(), - "forget(missing) must not abort existing scanners" - ); - - registry.forget("acct-1"); - assert_cancelled(tasks.pop().expect("task")).await; - } - - #[tokio::test] - async fn registry_forget_all_aborts_all_tasks_and_reports_handle_count() { - let registry = ScannerRegistry::default(); - let task_a = insert_pending_tasks(®istry, "acct-1", 2); - let task_b = insert_pending_tasks(®istry, "acct-2", 3); - - assert_eq!(registry.forget_all(), 5); - - assert!(registry.started.lock().is_empty()); - assert_all_cancelled(task_a).await; - assert_all_cancelled(task_b).await; - } - - #[tokio::test] - async fn registry_forget_all_is_repeatable_noop_after_drain() { - let registry = ScannerRegistry::default(); - assert_eq!(registry.forget_all(), 0); - - let tasks = insert_pending_tasks(®istry, "acct-1", 1); - assert_eq!(registry.forget_all(), 1); - assert_eq!(registry.forget_all(), 0); - - assert!(registry.started.lock().is_empty()); - assert_all_cancelled(tasks).await; - } -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/app/src-tauri/src/discord_scanner/mod_tests.rs b/app/src-tauri/src/discord_scanner/mod_tests.rs new file mode 100644 index 000000000..27b1c36a3 --- /dev/null +++ b/app/src-tauri/src/discord_scanner/mod_tests.rs @@ -0,0 +1,333 @@ +use super::*; + +#[test] +fn gateway_guild_create_and_message_create_build_channel_snapshot() { + let mut state = DiscordIngestState::default(); + state.apply_gateway_payload( + r#"{ + "op":0, + "t":"GUILD_CREATE", + "d":{ + "id":"guild-1", + "channels":[{"id":"chan-1","name":"general"}], + "threads":[] + } + }"#, + ); + + let snapshots = state.apply_gateway_payload( + r#"{ + "op":0, + "t":"MESSAGE_CREATE", + "d":{ + "id":"msg-1", + "channel_id":"chan-1", + "guild_id":"guild-1", + "content":"hello discord", + "timestamp":"2026-05-17T12:34:56.000Z", + "author":{"id":"user-1","username":"alice","global_name":"Alice"}, + "member":{"nick":"Ali"} + } + }"#, + ); + + assert_eq!(snapshots.len(), 1); + let snapshot = &snapshots[0]; + assert_eq!(snapshot.channel_id, "chan-1"); + assert_eq!(snapshot.channel_name, "general"); + assert_eq!(snapshot.guild_id.as_deref(), Some("guild-1")); + assert_eq!(snapshot.messages.len(), 1); + assert_eq!(snapshot.messages[0].author, "Ali"); + assert_eq!(snapshot.messages[0].body, "hello discord"); + assert_eq!( + snapshot.messages[0].source_ref, + "https://discord.com/channels/guild-1/chan-1/msg-1" + ); +} + +#[test] +fn channel_create_dm_recipients_become_channel_name() { + let mut state = DiscordIngestState::default(); + let snapshots = state.apply_gateway_payload( + r#"{ + "op":0, + "t":"CHANNEL_CREATE", + "d":{ + "id":"dm-1", + "type":1, + "recipients":[ + {"id":"u1","username":"alice"}, + {"id":"u2","global_name":"Bob Builder"} + ] + } + }"#, + ); + + assert!(snapshots.is_empty()); + let channel = state.channels.get("dm-1").expect("dm channel cached"); + assert_eq!(channel.name.as_deref(), Some("alice, Bob Builder")); +} + +#[test] +fn channel_update_emits_snapshot_when_messages_are_already_cached() { + let mut state = DiscordIngestState::default(); + let _ = state.apply_gateway_payload( + r#"{ + "op":0, + "t":"MESSAGE_CREATE", + "d":{ + "id":"msg-1", + "channel_id":"chan-1", + "guild_id":"guild-1", + "content":"hello", + "timestamp":"2026-05-17T12:34:56.000Z", + "author":{"id":"user-1","username":"alice"} + } + }"#, + ); + + let snapshots = state.apply_gateway_payload( + r#"{ + "op":0, + "t":"CHANNEL_UPDATE", + "d":{ + "id":"chan-1", + "guild_id":"guild-1", + "name":"renamed-general" + } + }"#, + ); + + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].channel_name, "renamed-general"); + assert_eq!(snapshots[0].messages.len(), 1); +} + +#[test] +fn message_update_replaces_existing_message_body() { + let mut state = DiscordIngestState::default(); + let _ = state.apply_gateway_payload( + r#"{ + "op":0, + "t":"MESSAGE_CREATE", + "d":{ + "id":"msg-1", + "channel_id":"chan-1", + "content":"before", + "timestamp":"2026-05-17T12:34:56.000Z", + "author":{"id":"user-1","username":"alice"} + } + }"#, + ); + + let snapshots = state.apply_gateway_payload( + r#"{ + "op":0, + "t":"MESSAGE_UPDATE", + "d":{ + "id":"msg-1", + "channel_id":"chan-1", + "content":"after", + "timestamp":"2026-05-17T12:34:56.000Z", + "author":{"id":"user-1","username":"alice"} + } + }"#, + ); + + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].messages.len(), 1); + assert_eq!(snapshots[0].messages[0].body, "after"); +} + +#[test] +fn message_update_preserves_missing_fields_from_cached_message() { + let mut state = DiscordIngestState::default(); + let _ = state.apply_gateway_payload( + r#"{ + "op":0, + "t":"MESSAGE_CREATE", + "d":{ + "id":"msg-1", + "channel_id":"chan-1", + "guild_id":"guild-1", + "content":"before", + "timestamp":"2026-05-17T12:34:56.000Z", + "author":{"id":"user-1","username":"alice"} + } + }"#, + ); + + let snapshots = state.apply_gateway_payload( + r#"{ + "op":0, + "t":"MESSAGE_UPDATE", + "d":{ + "id":"msg-1", + "channel_id":"chan-1", + "guild_id":"guild-1", + "edited_timestamp":"2026-05-17T12:35:56.000Z" + } + }"#, + ); + + assert_eq!(snapshots.len(), 1); + let message = &snapshots[0].messages[0]; + assert_eq!(message.body, "before"); + assert_eq!(message.author, "alice"); + assert_eq!(message.author_id, "user-1"); + assert_eq!( + message.timestamp_ms, + parse_discord_timestamp_ms("2026-05-17T12:34:56.000Z").unwrap() + ); +} + +#[test] +fn message_update_with_embed_only_keeps_existing_body_text() { + let mut state = DiscordIngestState::default(); + let _ = state.apply_gateway_payload( + r#"{ + "op":0, + "t":"MESSAGE_CREATE", + "d":{ + "id":"msg-1", + "channel_id":"chan-1", + "content":"before", + "timestamp":"2026-05-17T12:34:56.000Z", + "author":{"id":"user-1","username":"alice"} + } + }"#, + ); + + let snapshots = state.apply_gateway_payload( + r#"{ + "op":0, + "t":"MESSAGE_UPDATE", + "d":{ + "id":"msg-1", + "channel_id":"chan-1", + "embeds":[{"title":"preview card"}] + } + }"#, + ); + + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].messages[0].body, "before"); +} + +#[test] +fn discord_channel_doc_key_scopes_same_channel_name_by_guild() { + assert_eq!( + discord_channel_doc_key("guild-1", "chan-1"), + "guild-1:chan-1" + ); + assert_eq!( + discord_channel_doc_key("guild-2", "chan-1"), + "guild-2:chan-1" + ); + assert_eq!(discord_channel_doc_key("", "chan-1"), "@me:chan-1"); +} + +fn insert_pending_tasks( + registry: &ScannerRegistry, + account_id: &str, + count: usize, +) -> Vec> { + let mut tasks = Vec::with_capacity(count); + let mut abort_handles = Vec::with_capacity(count); + for _ in 0..count { + let task = tokio::spawn(async { + std::future::pending::<()>().await; + }); + abort_handles.push(task.abort_handle()); + tasks.push(task); + } + registry + .started + .lock() + .insert(account_id.to_string(), abort_handles); + tasks +} + +async fn assert_cancelled(task: tokio::task::JoinHandle<()>) { + let err = tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("aborted scanner task should finish") + .expect_err("scanner task should be cancelled"); + assert!(err.is_cancelled()); +} + +async fn assert_all_cancelled(tasks: Vec>) { + for task in tasks { + assert_cancelled(task).await; + } +} + +#[tokio::test] +async fn registry_forget_aborts_all_handles_for_account_only() { + let registry = ScannerRegistry::default(); + let account_tasks = insert_pending_tasks(®istry, "acct-1", 2); + let survivor_tasks = insert_pending_tasks(®istry, "acct-2", 1); + + registry.forget("acct-1"); + + { + let guard = registry.started.lock(); + assert_eq!(guard.len(), 1); + assert!(guard.contains_key("acct-2")); + } + assert_all_cancelled(account_tasks).await; + assert!( + !survivor_tasks[0].is_finished(), + "forget(acct-1) must not abort acct-2" + ); + + assert_eq!(registry.forget_all(), 1); + assert_all_cancelled(survivor_tasks).await; +} + +#[tokio::test] +async fn registry_forget_missing_account_is_noop() { + let registry = ScannerRegistry::default(); + let mut tasks = insert_pending_tasks(®istry, "acct-1", 1); + + registry.forget("missing"); + + { + let guard = registry.started.lock(); + assert_eq!(guard.len(), 1); + assert!(guard.contains_key("acct-1")); + } + assert!( + !tasks[0].is_finished(), + "forget(missing) must not abort existing scanners" + ); + + registry.forget("acct-1"); + assert_cancelled(tasks.pop().expect("task")).await; +} + +#[tokio::test] +async fn registry_forget_all_aborts_all_tasks_and_reports_handle_count() { + let registry = ScannerRegistry::default(); + let task_a = insert_pending_tasks(®istry, "acct-1", 2); + let task_b = insert_pending_tasks(®istry, "acct-2", 3); + + assert_eq!(registry.forget_all(), 5); + + assert!(registry.started.lock().is_empty()); + assert_all_cancelled(task_a).await; + assert_all_cancelled(task_b).await; +} + +#[tokio::test] +async fn registry_forget_all_is_repeatable_noop_after_drain() { + let registry = ScannerRegistry::default(); + assert_eq!(registry.forget_all(), 0); + + let tasks = insert_pending_tasks(®istry, "acct-1", 1); + assert_eq!(registry.forget_all(), 1); + assert_eq!(registry.forget_all(), 0); + + assert!(registry.started.lock().is_empty()); + assert_all_cancelled(tasks).await; +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 87a0501e3..a2292a4a1 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3726,946 +3726,5 @@ fn macos_os_version() -> Option { } #[cfg(test)] -mod tests { - use super::*; - - // Tests that read/write process-global env vars must serialize through this - // mutex. Rust's test runner executes tests in parallel by default; without - // coordination, concurrent set_var / remove_var calls race and produce - // spurious failures. - static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - /// Test that is_daemon_mode correctly detects daemon flag variations - #[test] - fn is_daemon_mode_detects_daemon_flag() { - // Note: This test relies on the current process args, so in test mode - // it will typically return false. We verify the function is callable. - let _result = is_daemon_mode(); - } - - /// Test core_rpc_url returns expected format - #[test] - fn core_rpc_url_returns_expected_format() { - let _g = ENV_LOCK.lock().unwrap(); - let original = std::env::var("OPENHUMAN_CORE_RPC_URL").ok(); - - std::env::set_var("OPENHUMAN_CORE_RPC_URL", "http://localhost:9999/rpc"); - let url = core_rpc_url(); - assert_eq!(url, "http://localhost:9999/rpc"); - - std::env::remove_var("OPENHUMAN_CORE_RPC_URL"); - let url = core_rpc_url(); - assert_eq!(url, "http://127.0.0.1:7788/rpc"); - - match original { - Some(v) => std::env::set_var("OPENHUMAN_CORE_RPC_URL", v), - None => std::env::remove_var("OPENHUMAN_CORE_RPC_URL"), - } - } - - /// Test overlay_parent_rpc_url handles empty env var - #[test] - fn overlay_parent_rpc_url_handles_empty() { - let _g = ENV_LOCK.lock().unwrap(); - let original = std::env::var("OPENHUMAN_CORE_RPC_URL").ok(); - - std::env::set_var("OPENHUMAN_CORE_RPC_URL", ""); - assert!(overlay_parent_rpc_url().is_none()); - - std::env::set_var("OPENHUMAN_CORE_RPC_URL", " "); - assert!(overlay_parent_rpc_url().is_none()); - - std::env::set_var("OPENHUMAN_CORE_RPC_URL", "http://127.0.0.1:7788/rpc"); - assert_eq!( - overlay_parent_rpc_url(), - Some("http://127.0.0.1:7788/rpc".to_string()) - ); - - match original { - Some(v) => std::env::set_var("OPENHUMAN_CORE_RPC_URL", v), - None => std::env::remove_var("OPENHUMAN_CORE_RPC_URL"), - } - } - - #[test] - fn reset_local_data_windows_file_lock_error_codes_are_recognized() { - assert!(is_windows_file_lock_raw_os_error(Some(32))); - assert!(is_windows_file_lock_raw_os_error(Some(33))); - assert!(!is_windows_file_lock_raw_os_error(Some(5))); - assert!(!is_windows_file_lock_raw_os_error(None)); - } - - #[test] - fn reset_local_data_delete_error_keeps_generic_message_for_other_errors() { - let err = std::io::Error::from(std::io::ErrorKind::PermissionDenied); - let result = reset_local_data_delete_error( - "current openhuman dir", - std::path::Path::new("/tmp/openhuman"), - &err, - ); - - let msg = result.expect_err("non-lock errors must still surface to the UI"); - assert!(msg.starts_with("Failed to remove current openhuman dir at /tmp/openhuman:")); - assert!(!msg.contains("Close all OpenHuman windows and try again")); - } - - #[cfg(windows)] - #[test] - fn reset_local_data_delete_error_swallows_lock_failure_when_path_disappeared() { - // Race condition the reboot fallback now handles: the locked path - // was gone by the time `schedule_path_for_reboot_deletion` ran its - // `symlink_metadata` probe, so the reset goal is already met. The - // helper must return `Ok(())` rather than surfacing a confusing - // "couldn't remove (it's not there)" toast. - let dir = tempfile::tempdir().expect("tempdir for reset error test"); - let missing = dir.path().join("definitely-not-there"); - - let err = std::io::Error::from_raw_os_error(32); - let result = reset_local_data_delete_error("current openhuman dir", &missing, &err); - - assert!( - result.is_ok(), - "expected NotFound + empty partial schedule to be swallowed as success, got {result:?}" - ); - } - - #[cfg(windows)] - #[test] - fn reset_local_data_delete_error_reports_reboot_schedule_counts() { - // When the lock fallback can walk a real directory tree, the user - // message should report how much has been queued so the support - // log preserves "what was actually scheduled". Scheduling itself - // may still fail at the MoveFileExW step in unprivileged test - // processes (the registry key write requires administrator); the - // fallback then carries a partial schedule that the error path - // surfaces, so both branches must keep mentioning the lock cause - // *and* expose either the queued counts or the schedule failure. - let dir = tempfile::tempdir().expect("tempdir for reset error test"); - let target = dir.path().join("reset-mock"); - std::fs::create_dir_all(target.join("nested")).expect("mkdir nested"); - std::fs::write(target.join("a.txt"), b"x").expect("write a.txt"); - std::fs::write(target.join("nested").join("b.txt"), b"y").expect("write b.txt"); - - let err = std::io::Error::from_raw_os_error(32); - let result = reset_local_data_delete_error("current openhuman dir", &target, &err); - - // Path exists on disk, so the fallback must surface the outcome — - // either an "all-queued" success-but-needs-reboot message (admin) - // or one of the failure flavours (non-admin). - let msg = result - .expect_err("path exists, fallback must report queued counts or scheduling failure"); - let admin_path = msg.contains("queued for deletion the next time you restart Windows") - && msg.contains("2 files and 2 folders"); - let user_full_fail = msg.contains("scheduling deletion on next reboot also failed"); - let user_partial = msg.contains("queued for the next reboot before scheduling failed"); - assert!( - admin_path || user_full_fail || user_partial, - "expected reboot-scheduled, fully-failed, or partial-fail message, got: {msg}" - ); - // Whatever branch we land on, the user must still be told the lock - // is what blocked the immediate removal. - assert!( - msg.contains("locked by another OpenHuman window or process") - || msg.contains("another process is holding it open"), - "missing lock cause: {msg}" - ); - } - - /// Tests for setup_tray conditional compilation - /// The PR adds two versions of setup_tray(): - /// 1. No-op for linux + cef: logs warning and returns Ok(()) - /// 2. Full implementation for other platforms - /// - /// These tests verify the function signatures are correct and - /// the compile-time cfg blocks are properly set up. - - /// Verify setup_tray function exists and has correct signature - /// This test passes if the code compiles, as the function signature - /// is validated by the compiler. - #[test] - fn setup_tray_function_signature_compiles() { - // This test exists to ensure the conditional compilation - // of setup_tray is valid. The function is not actually called - // here because it requires a full Tauri AppHandle. - // The cfg attributes ensure only one version exists at compile time. - } - - /// Test that AppRuntime is defined for the current feature set - #[test] - fn app_runtime_type_exists() { - // This test verifies AppRuntime is properly defined - // based on the cef feature flag. - // The type alias exists at module scope and is used throughout. - fn _check_runtime() {} - // _check_runtime::(); // Would require importing - } - - #[test] - fn no_app_update_available_result_is_quiet_unavailable() { - let info = no_app_update_available("0.53.43".to_string()); - - assert_eq!(info.current_version, "0.53.43"); - assert!(!info.available); - assert!(info.available_version.is_none()); - assert!(info.body.is_none()); - } - - /// Verify tray logging patterns exist (grep-friendly) - #[test] - fn tray_setup_logging_patterns_exist() { - // These log patterns from the PR are grep-friendly: - // "[tray] skipping tray setup on linux+cef: ..." - // "[tray] setting up tray icon" - // "[tray] tray icon ready" - // "[tray] action=show_window ..." - // "[tray] action=quit ..." - // "[tray] failed to setup tray icon ..." - // "[app] RunEvent::Ready — GTK initialized, setting up tray" - // - // This test passes if the code compiles with these log messages. - } - - // ------------------------------------------------------------------------- - // macos_os_version (issue #1012) - // ------------------------------------------------------------------------- - - /// On macOS, sw_vers is always present and must return a version string. - #[cfg(target_os = "macos")] - #[test] - fn macos_os_version_returns_some() { - assert!( - macos_os_version().is_some(), - "sw_vers -productVersion must succeed on macOS" - ); - } - - /// The returned version must be a non-empty trimmed string. - #[cfg(target_os = "macos")] - #[test] - fn macos_os_version_is_nonempty() { - let ver = macos_os_version().expect("sw_vers must return a version on macOS"); - assert!(!ver.is_empty()); - // No leading/trailing whitespace (the impl trims). - assert_eq!(ver, ver.trim()); - } - - /// The version string must look like dot-separated integers ("14.5", "13.2.1"). - #[cfg(target_os = "macos")] - #[test] - fn macos_os_version_is_dotted_integer_format() { - let ver = macos_os_version().expect("sw_vers must return a version on macOS"); - let all_numeric_parts = ver - .split('.') - .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())); - assert!( - all_numeric_parts, - "os version {ver:?} must be dot-separated integers (e.g. '14.5')" - ); - } - - /// The version must have at least one component (e.g. a bare major "15" is valid). - #[cfg(target_os = "macos")] - #[test] - fn macos_os_version_has_at_least_one_component() { - let ver = macos_os_version().expect("sw_vers must return a version on macOS"); - assert!( - !ver.split('.').next().unwrap_or("").is_empty(), - "version must have at least one numeric component" - ); - } - - // ------------------------------------------------------------------------- - // WSL + X11 desktop startup warning (issue #1653) - // ------------------------------------------------------------------------- - - #[test] - fn wsl_x11_warning_detects_classic_x11_forwarding() { - assert!(should_warn_for_wsl_x11_desktop(true, true, false, false)); - } - - #[test] - fn wsl_x11_warning_skips_non_wsl_or_headless_runs() { - assert!(!should_warn_for_wsl_x11_desktop(false, true, false, false)); - assert!(!should_warn_for_wsl_x11_desktop(true, false, false, false)); - } - - #[test] - fn wsl_x11_warning_skips_wslg_or_wayland_runs() { - assert!(!should_warn_for_wsl_x11_desktop(true, true, true, false)); - assert!(!should_warn_for_wsl_x11_desktop(true, true, false, true)); - } - - // ------------------------------------------------------------------------- - // Linux display-server pre-flight (Sentry OPENHUMAN-TAURI-K1) - // ------------------------------------------------------------------------- - - #[test] - fn linux_display_present_with_x11() { - assert!(linux_display_server_present(true, false)); - } - - #[test] - fn linux_display_present_with_wayland() { - assert!(linux_display_server_present(false, true)); - } - - #[test] - fn linux_display_present_with_both() { - assert!(linux_display_server_present(true, true)); - } - - #[test] - fn linux_display_absent_without_either() { - assert!(!linux_display_server_present(false, false)); - } - - #[test] - fn linux_root_uid_detected() { - assert!(linux_is_root_uid(0)); - } - - #[test] - fn linux_non_root_uid_not_detected() { - assert!(!linux_is_root_uid(1000)); - assert!(!linux_is_root_uid(1)); - } - - // ------------------------------------------------------------------------- - // Linux D-Bus session-bus probe (Sentry OPENHUMAN-TAURI-TM) - // ------------------------------------------------------------------------- - - #[test] - fn dbus_address_unix_is_supported() { - assert!(dbus_address_is_supported("unix:path=/run/user/1000/bus")); - assert!(dbus_address_is_supported("unix:abstract=/tmp/dbus-abc")); - } - - #[test] - fn dbus_address_tcp_and_launchd_supported() { - assert!(dbus_address_is_supported("tcp:host=localhost,port=1234")); - assert!(dbus_address_is_supported( - "launchd:env=DBUS_LAUNCHD_SESSION_BUS_SOCKET" - )); - assert!(dbus_address_is_supported("autolaunch:")); - } - - #[test] - fn dbus_address_disabled_is_unsupported() { - // The literal value WSL2-without-WSLg sets — root cause of the panic. - assert!(!dbus_address_is_supported("disabled")); - assert!(!dbus_address_is_supported("")); - assert!(!dbus_address_is_supported(" ")); - } - - #[test] - fn dbus_address_unknown_transport_is_unsupported() { - assert!(!dbus_address_is_supported("nonce-tcp:host=localhost")); - assert!(!dbus_address_is_supported("bogus:")); - } - - #[test] - fn dbus_address_picks_first_supported_in_list() { - // zbus walks the semicolon-separated list and uses the first reachable - // transport, so one good entry is enough. - assert!(dbus_address_is_supported( - "disabled;unix:path=/run/user/1000/bus" - )); - assert!(dbus_address_is_supported( - "bogus:;tcp:host=localhost,port=55" - )); - assert!(!dbus_address_is_supported("disabled;bogus:")); - } - - #[test] - fn dbus_reachable_when_env_addr_is_supported() { - assert!(linux_dbus_session_reachable( - Some("unix:path=/run/user/1000/bus"), - false, - )); - } - - #[test] - fn dbus_unreachable_when_env_addr_disabled() { - // Even if the socket exists, an explicit `disabled` value means the - // session bus is intentionally turned off and zbus will reject it. - assert!(!linux_dbus_session_reachable(Some("disabled"), true)); - } - - #[test] - fn dbus_falls_back_to_runtime_socket_when_env_unset() { - assert!(linux_dbus_session_reachable(None, true)); - assert!(!linux_dbus_session_reachable(None, false)); - } - - // ------------------------------------------------------------------------- - // Platform constants (issue #1012 Sentry tagging) - // ------------------------------------------------------------------------- - - /// cpu_arch tag is derived from std::env::consts::ARCH which must be non-empty. - #[test] - fn platform_arch_constant_is_nonempty() { - assert!( - !std::env::consts::ARCH.is_empty(), - "ARCH constant used for Sentry cpu_arch tag must be non-empty" - ); - } - - /// os_name tag is derived from std::env::consts::OS which must be non-empty. - #[test] - fn platform_os_constant_is_nonempty() { - assert!( - !std::env::consts::OS.is_empty(), - "OS constant used for Sentry os_name tag must be non-empty" - ); - } - - /// On a macOS build the OS constant must equal "macos". - #[cfg(target_os = "macos")] - #[test] - fn platform_os_is_macos_on_macos_build() { - assert_eq!(std::env::consts::OS, "macos"); - } - - #[test] - fn platform_cef_gpu_workarounds_disable_linux_gpu_path() { - let mut args = Vec::new(); - append_platform_cef_gpu_workarounds(&mut args, "linux", "x86_64", None); - - assert!(args.contains(&("--disable-gpu", None))); - assert!(args.contains(&("--disable-gpu-compositing", None))); - } - - #[test] - fn platform_cef_gpu_workarounds_disable_intel_macos_compositing_only() { - let mut args = Vec::new(); - append_platform_cef_gpu_workarounds(&mut args, "macos", "x86_64", None); - - assert_eq!(args, vec![("--disable-gpu-compositing", None)]); - } - - #[test] - fn platform_cef_gpu_workarounds_leave_other_platforms_alone() { - for (os, arch) in [("macos", "aarch64"), ("windows", "x86_64")] { - let mut args = Vec::new(); - append_platform_cef_gpu_workarounds(&mut args, os, arch, None); - - assert!( - args.is_empty(), - "unexpected CEF GPU flags for {os}/{arch}: {args:?}" - ); - } - } - - // ------------------------------------------------------------------------- - // OPENHUMAN_FORCE_GPU override (re-enables WebGL2 surfaces — Rive mascot) - // ------------------------------------------------------------------------- - - #[test] - fn force_gpu_default_off_when_env_unset() { - assert!(!cef_force_gpu_enabled(None)); - } - - #[test] - fn force_gpu_explicit_enable_values_match_prewarm_pattern() { - for v in ["1", "true", "yes", "on", "TRUE", "Yes", "On"] { - assert!( - cef_force_gpu_enabled(Some(v)), - "OPENHUMAN_FORCE_GPU={v:?} should opt in" - ); - } - } - - #[test] - fn force_gpu_anything_else_is_off() { - // Mirrors prewarm semantics: only explicit truthy values opt in. - for v in ["", "0", "false", "no", "off", "FALSE", "Off", "maybe", " "] { - assert!( - !cef_force_gpu_enabled(Some(v)), - "OPENHUMAN_FORCE_GPU={v:?} must not silently opt in" - ); - } - } - - #[test] - fn platform_cef_gpu_workarounds_skip_linux_disable_when_force_gpu_set() { - // Assert the two GPU-disable flags are absent rather than the whole - // arg list being empty: on root-Linux runners (CI in some configs) - // the function still appends `--no-sandbox` via the orthogonal - // OPENHUMAN-TAURI-K1 branch, which would make a strict `is_empty()` - // check fail spuriously. We only care about the GPU branch here. - let mut args = Vec::new(); - append_platform_cef_gpu_workarounds(&mut args, "linux", "x86_64", Some("1")); - - assert!( - !args.contains(&("--disable-gpu", None)), - "OPENHUMAN_FORCE_GPU=1 must suppress --disable-gpu, got: {args:?}" - ); - assert!( - !args.contains(&("--disable-gpu-compositing", None)), - "OPENHUMAN_FORCE_GPU=1 must suppress --disable-gpu-compositing, got: {args:?}" - ); - } - - #[test] - fn platform_cef_gpu_workarounds_force_gpu_does_not_affect_intel_macos_path() { - // OPENHUMAN_FORCE_GPU only governs the Linux #1697 workaround; the - // separate Intel-macOS #1012 disable must still apply, regardless of - // the env var. - let mut args = Vec::new(); - append_platform_cef_gpu_workarounds(&mut args, "macos", "x86_64", Some("1")); - - assert_eq!(args, vec![("--disable-gpu-compositing", None)]); - } - - /// On an Intel macOS build the ARCH constant must equal "x86_64". - /// This is the architecture that triggers --disable-gpu-compositing. - #[cfg(all(target_os = "macos", target_arch = "x86_64"))] - #[test] - fn platform_arch_is_x86_64_on_intel_build() { - assert_eq!(std::env::consts::ARCH, "x86_64"); - } - - /// On Apple Silicon the ARCH constant must equal "aarch64"; the GPU flag - /// must NOT be compiled in (verified by this test existing in the binary). - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - #[test] - fn platform_arch_is_aarch64_on_apple_silicon_build() { - assert_eq!(std::env::consts::ARCH, "aarch64"); - } - - // ------------------------------------------------------------------------- - // cef_prewarm_enabled (issue #2463 — Wayland/XWayland BadWindow guard) - // ------------------------------------------------------------------------- - - #[test] - fn prewarm_enabled_by_default_on_non_wayland() { - assert!(cef_prewarm_enabled(None, false)); - } - - #[test] - fn prewarm_auto_disabled_on_wayland_when_env_unset() { - assert!(!cef_prewarm_enabled(None, true)); - } - - #[test] - fn prewarm_explicit_disable_respected_on_non_wayland() { - assert!(!cef_prewarm_enabled(Some("0"), false)); - assert!(!cef_prewarm_enabled(Some("false"), false)); - assert!(!cef_prewarm_enabled(Some("no"), false)); - assert!(!cef_prewarm_enabled(Some("off"), false)); - } - - #[test] - fn prewarm_explicit_disable_respected_on_wayland() { - assert!(!cef_prewarm_enabled(Some("0"), true)); - assert!(!cef_prewarm_enabled(Some("false"), true)); - } - - #[test] - fn prewarm_explicit_enable_overrides_wayland_guard() { - // OPENHUMAN_CEF_PREWARM=1 (or any non-disable value) lets ops - // force prewarm even on Wayland sessions. - assert!(cef_prewarm_enabled(Some("1"), true)); - assert!(cef_prewarm_enabled(Some("true"), true)); - assert!(cef_prewarm_enabled(Some("yes"), true)); - assert!(cef_prewarm_enabled(Some("on"), true)); - } - - #[test] - fn prewarm_disable_flags_are_case_insensitive() { - assert!(!cef_prewarm_enabled(Some("FALSE"), false)); - assert!(!cef_prewarm_enabled(Some("OFF"), true)); - assert!(!cef_prewarm_enabled(Some(" 0 "), false)); - assert!(!cef_prewarm_enabled(Some(" No "), true)); - } - - #[test] - fn prewarm_unknown_env_value_treated_as_enable() { - // Any string that is not a recognised disable token → treat as enable. - assert!(cef_prewarm_enabled(Some("enabled"), false)); - assert!(cef_prewarm_enabled(Some("yes"), false)); - assert!(cef_prewarm_enabled(Some(""), false)); - } - - // ------------------------------------------------------------------------- - // build_sentry_release_tag - // ------------------------------------------------------------------------- - - #[test] - fn sentry_release_tag_starts_with_openhuman() { - let tag = build_sentry_release_tag(); - assert!( - tag.starts_with("openhuman@"), - "release tag must start with 'openhuman@', got: {tag:?}" - ); - } - - #[test] - fn sentry_release_tag_contains_cargo_pkg_version() { - let tag = build_sentry_release_tag(); - let version = env!("CARGO_PKG_VERSION"); - assert!( - tag.contains(version), - "release tag {tag:?} must embed CARGO_PKG_VERSION {version:?}" - ); - } - - #[test] - fn sentry_release_tag_version_part_is_nonempty() { - let tag = build_sentry_release_tag(); - let after_prefix = tag.strip_prefix("openhuman@").unwrap_or(""); - assert!(!after_prefix.is_empty(), "version part must not be empty"); - } - - /// When a SHA is baked in the tag takes the form `openhuman@+`. - /// When it is not, the tag is simply `openhuman@` with no `+`. - /// Either way the full tag must be non-empty. - #[test] - fn sentry_release_tag_is_nonempty() { - assert!(!build_sentry_release_tag().is_empty()); - } - - // ------------------------------------------------------------------------- - // resolve_sentry_environment - // ------------------------------------------------------------------------- - - #[test] - fn sentry_environment_reads_openhuman_app_env() { - let _g = ENV_LOCK.lock().unwrap(); - let key = "OPENHUMAN_APP_ENV"; - let original = std::env::var(key).ok(); - std::env::set_var(key, "staging"); - let env = resolve_sentry_environment(); - match original { - Some(v) => std::env::set_var(key, v), - None => std::env::remove_var(key), - } - assert_eq!(env, "staging"); - } - - #[test] - fn sentry_environment_trims_whitespace_from_openhuman_app_env() { - let _g = ENV_LOCK.lock().unwrap(); - let key = "OPENHUMAN_APP_ENV"; - let original = std::env::var(key).ok(); - std::env::set_var(key, " dev "); - let env = resolve_sentry_environment(); - match original { - Some(v) => std::env::set_var(key, v), - None => std::env::remove_var(key), - } - assert_eq!(env, "dev"); - } - - #[test] - fn sentry_environment_skips_empty_openhuman_app_env() { - let _g = ENV_LOCK.lock().unwrap(); - let key = "OPENHUMAN_APP_ENV"; - let original = std::env::var(key).ok(); - std::env::set_var(key, ""); - let env = resolve_sentry_environment(); - match original { - Some(v) => std::env::set_var(key, v), - None => std::env::remove_var(key), - } - // Falls through to VITE_ compile-time value or "production"; must be non-empty. - assert!(!env.is_empty()); - } - - #[test] - fn sentry_environment_skips_whitespace_only_openhuman_app_env() { - let _g = ENV_LOCK.lock().unwrap(); - let key = "OPENHUMAN_APP_ENV"; - let original = std::env::var(key).ok(); - std::env::set_var(key, " "); - let env = resolve_sentry_environment(); - match original { - Some(v) => std::env::set_var(key, v), - None => std::env::remove_var(key), - } - assert!(!env.is_empty()); - } - - /// When neither runtime env var nor compile-time VITE_ is set, the fallback - /// must be "production". Guard with a compile-time check so this test only - /// asserts the hard default when no compile-time override is present. - #[test] - fn sentry_environment_defaults_to_production_when_unset() { - let _g = ENV_LOCK.lock().unwrap(); - if option_env!("VITE_OPENHUMAN_APP_ENV").is_some() { - // A compile-time override is baked in; skip — the fallback path is - // exercised by sentry_environment_skips_empty_openhuman_app_env. - return; - } - let key = "OPENHUMAN_APP_ENV"; - let original = std::env::var(key).ok(); - std::env::remove_var(key); - let env = resolve_sentry_environment(); - match original { - Some(v) => std::env::set_var(key, v), - None => std::env::remove_var(key), - } - assert_eq!(env, "production"); - } - - // ── Sentry before_send filter: drop "Failed to request http://localhost:…" - // noise emitted by the vendored tauri-runtime-cef dev proxy in packaged - // builds (issue OPENHUMAN-TAURI-V). Tests target the pure - // `message_is_localhost_dev_fetch_noise` helper so the rule can be - // asserted without standing up a Sentry client. - - #[test] - fn localhost_dev_fetch_noise_drops_vite_dev_url_1420() { - // The exact message shape reported by the latest event tag in Sentry - // (URL repeated by reqwest's `error sending request for url (…)`). - let msg = "Failed to request http://localhost:1420/components/skills/SkillCard.tsx: \ - error sending request for url (http://localhost:1420/components/skills/SkillCard.tsx)"; - assert!( - message_is_localhost_dev_fetch_noise(msg), - "expected Vite dev-server fetch failure to be filtered" - ); - } - - #[test] - fn localhost_dev_fetch_noise_drops_127_0_0_1_dev_url() { - // Some environments resolve `localhost` to 127.0.0.1 at the reqwest - // layer; the formatted message can carry either spelling. - let msg = "Failed to request http://127.0.0.1:1420/index.html: \ - error sending request for url (http://127.0.0.1:1420/index.html)"; - assert!( - message_is_localhost_dev_fetch_noise(msg), - "expected 127.0.0.1 dev-server fetch failure to be filtered" - ); - } - - #[test] - fn localhost_dev_fetch_noise_passes_production_url_through() { - // Real upstream failures (e.g. backend API errors surfaced via the - // same `Failed to request …` wording elsewhere) must NOT be filtered — - // they're the high-signal events Sentry exists for. - let msg = "Failed to request https://api.openhuman.ai/v1/skills: \ - error sending request for url (https://api.openhuman.ai/v1/skills)"; - assert!( - !message_is_localhost_dev_fetch_noise(msg), - "production API errors must NOT be filtered out" - ); - } - - #[test] - fn localhost_dev_fetch_noise_passes_unrelated_localhost_messages() { - // The filter is anchored on the dev-proxy's exact prefix to avoid - // accidentally dropping any error that happens to mention localhost - // (e.g. core-sidecar transport errors logged from coreRpcClient). - let msg = - "[core_rpc] transport error: error sending request for url (http://localhost:7788/rpc)"; - assert!( - !message_is_localhost_dev_fetch_noise(msg), - "non-tauri-cef localhost errors must NOT be filtered" - ); - } - - #[test] - fn event_filter_uses_message_field() { - // event-level coverage: when sentry-tracing populates - // `event.message` (default with `attach_stacktrace=false`), the - // filter should see the noise payload through the primary read - // path. Per graycyrus on PR #1545. - let mut event = sentry::protocol::Event::new(); - event.message = Some("Failed to request http://localhost:1420/foo: timeout".into()); - assert!( - event_is_localhost_dev_fetch_noise(&event), - "event.message read path must catch noise messages" - ); - } - - #[test] - fn event_filter_falls_back_to_last_exception_value() { - // event-level coverage: if `attach_stacktrace` is ever turned on, - // sentry-tracing populates `event.exception` instead of (or in - // addition to) `event.message`. Filter must still see the noise - // payload through the exception fallback. Per graycyrus on PR #1545. - let mut event = sentry::protocol::Event::new(); - event.message = None; - event.exception.values.push(sentry::protocol::Exception { - ty: "log".into(), - value: Some("Failed to request http://localhost:1420/foo: timeout".into()), - ..Default::default() - }); - assert!( - event_is_localhost_dev_fetch_noise(&event), - "exception fallback must catch noise messages when event.message is absent" - ); - } - - #[test] - fn event_filter_passes_through_when_neither_field_matches() { - // Negative event-level case: no noise prefix in either field → - // event must NOT be filtered. - let mut event = sentry::protocol::Event::new(); - event.message = Some("genuine production error".into()); - event.exception.values.push(sentry::protocol::Exception { - ty: "log".into(), - value: Some("connection refused (10061)".into()), - ..Default::default() - }); - assert!( - !event_is_localhost_dev_fetch_noise(&event), - "legitimate production events must pass through" - ); - } - - #[test] - fn localhost_dev_fetch_noise_anchors_to_message_start() { - // CodeRabbit (PR #1545) caught that the predicate used - // `contains` rather than `starts_with`. Regression: a message - // that merely embeds the dev-proxy prefix later in its text - // must NOT be filtered — only messages that *begin* with it. - let msg = "User report: `Failed to request http://localhost:1420/foo` was logged earlier"; - assert!( - !message_is_localhost_dev_fetch_noise(msg), - "messages that merely contain the dev-proxy prefix must NOT be filtered" - ); - } - - // ------------------------------------------------------------------------- - // path_has_executable / deep-link xdg-mime pre-flight (OPENHUMAN-TAURI-AS) - // ------------------------------------------------------------------------- - - /// With a controlled `$PATH` containing one dir that holds a file named - /// `xdg-mime`, the lookup must succeed (mirrors a Linux desktop install - /// where xdg-utils ships the binary). - #[cfg(target_os = "linux")] - #[test] - fn path_has_executable_finds_file_on_path() { - let _g = ENV_LOCK.lock().unwrap(); - let original = std::env::var_os("PATH"); - - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::write(dir.path().join("xdg-mime"), b"#!/bin/sh\n").expect("write stub"); - std::env::set_var("PATH", dir.path()); - - assert!( - path_has_executable("xdg-mime"), - "must discover xdg-mime when present in a $PATH entry" - ); - - match original { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - } - - /// With a controlled `$PATH` that does NOT contain `xdg-mime`, the lookup - /// must fail (mirrors WSL2 / minimal containers without xdg-utils — the - /// case OPENHUMAN-TAURI-AS protects against). - #[cfg(target_os = "linux")] - #[test] - fn path_has_executable_returns_false_when_missing() { - let _g = ENV_LOCK.lock().unwrap(); - let original = std::env::var_os("PATH"); - - let dir = tempfile::tempdir().expect("tempdir"); - // Intentionally do not create xdg-mime in `dir`. - std::env::set_var("PATH", dir.path()); - - assert!( - !path_has_executable("xdg-mime"), - "must return false when xdg-mime is not in any $PATH entry" - ); - - match original { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - } - - /// When `$PATH` is unset entirely, the lookup must short-circuit to false - /// rather than panic or fall back to the cwd. - #[cfg(target_os = "linux")] - #[test] - fn path_has_executable_returns_false_when_path_unset() { - let _g = ENV_LOCK.lock().unwrap(); - let original = std::env::var_os("PATH"); - - std::env::remove_var("PATH"); - assert!( - !path_has_executable("xdg-mime"), - "unset $PATH must yield false (skip register_all on the missing-xdg-utils branch)" - ); - - match original { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - } - - /// Regression guard for OPENHUMAN-TAURI-5V: a Linux host with `xdg-mime` - /// installed but `update-desktop-database` missing must classify as - /// "skip register_all" — the pre-#5V code only checked `xdg-mime` and - /// would have entered the plugin call, which then fires the noisy - /// `Failed to run OS command \`update-desktop-database\`` internal log - /// that escapes to Sentry. The Wave-4 fix pre-flights every xdg-utils - /// binary the plugin shells out to; this test pins that contract by - /// checking each binary lookup independently with a `$PATH` that - /// contains only `xdg-mime`. - #[cfg(target_os = "linux")] - #[test] - fn path_has_executable_returns_false_for_partial_xdg_utils_install() { - let _g = ENV_LOCK.lock().unwrap(); - let original = std::env::var_os("PATH"); - - let dir = tempfile::tempdir().expect("tempdir"); - // Only `xdg-mime` exists; `update-desktop-database` and - // `xdg-icon-resource` are deliberately absent. - std::fs::write(dir.path().join("xdg-mime"), b"#!/bin/sh\n").expect("write stub"); - std::env::set_var("PATH", dir.path()); - - assert!( - path_has_executable("xdg-mime"), - "xdg-mime stub must be discoverable in the partial-install $PATH" - ); - assert!( - !path_has_executable("update-desktop-database"), - "partial xdg-utils install must NOT report update-desktop-database present (OPENHUMAN-TAURI-5V)" - ); - assert!( - !path_has_executable("xdg-icon-resource"), - "partial xdg-utils install must NOT report xdg-icon-resource present" - ); - - match original { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - } - - /// Regression guard for issue #2228: `tauri-plugin-single-instance` must - /// enable the `deep-link` feature so that second-launch deep-link payloads - /// (e.g. `openhuman://oauth/...` callbacks from Windows/Linux system - /// browsers) are forwarded into the primary instance. Without it, hot OAuth - /// callbacks silently no-op while only focusing the existing window. - #[test] - fn single_instance_dep_enables_deep_link_feature() { - let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); - let manifest = - std::fs::read_to_string(&manifest_path).expect("read app/src-tauri/Cargo.toml"); - let parsed: toml::Value = manifest.parse().expect("parse Cargo.toml"); - - let dep = parsed - .get("dependencies") - .and_then(|d| d.get("tauri-plugin-single-instance")) - .expect("tauri-plugin-single-instance dependency must exist"); - - let features = dep.get("features").and_then(|f| f.as_array()).expect( - "tauri-plugin-single-instance must be a table with a `features` array \ - — issue #2228 requires the `deep-link` feature to forward hot-instance \ - OAuth callbacks on Windows/Linux", - ); - - assert!( - features.iter().any(|v| v.as_str() == Some("deep-link")), - "tauri-plugin-single-instance must enable the `deep-link` feature \ - (issue #2228 — hot-instance OAuth callback forwarding)" - ); - } -} +#[path = "lib_tests.rs"] +mod tests; diff --git a/app/src-tauri/src/lib_tests.rs b/app/src-tauri/src/lib_tests.rs new file mode 100644 index 000000000..952c76903 --- /dev/null +++ b/app/src-tauri/src/lib_tests.rs @@ -0,0 +1,940 @@ +use super::*; + +// Tests that read/write process-global env vars must serialize through this +// mutex. Rust's test runner executes tests in parallel by default; without +// coordination, concurrent set_var / remove_var calls race and produce +// spurious failures. +static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Test that is_daemon_mode correctly detects daemon flag variations +#[test] +fn is_daemon_mode_detects_daemon_flag() { + // Note: This test relies on the current process args, so in test mode + // it will typically return false. We verify the function is callable. + let _result = is_daemon_mode(); +} + +/// Test core_rpc_url returns expected format +#[test] +fn core_rpc_url_returns_expected_format() { + let _g = ENV_LOCK.lock().unwrap(); + let original = std::env::var("OPENHUMAN_CORE_RPC_URL").ok(); + + std::env::set_var("OPENHUMAN_CORE_RPC_URL", "http://localhost:9999/rpc"); + let url = core_rpc_url(); + assert_eq!(url, "http://localhost:9999/rpc"); + + std::env::remove_var("OPENHUMAN_CORE_RPC_URL"); + let url = core_rpc_url(); + assert_eq!(url, "http://127.0.0.1:7788/rpc"); + + match original { + Some(v) => std::env::set_var("OPENHUMAN_CORE_RPC_URL", v), + None => std::env::remove_var("OPENHUMAN_CORE_RPC_URL"), + } +} + +/// Test overlay_parent_rpc_url handles empty env var +#[test] +fn overlay_parent_rpc_url_handles_empty() { + let _g = ENV_LOCK.lock().unwrap(); + let original = std::env::var("OPENHUMAN_CORE_RPC_URL").ok(); + + std::env::set_var("OPENHUMAN_CORE_RPC_URL", ""); + assert!(overlay_parent_rpc_url().is_none()); + + std::env::set_var("OPENHUMAN_CORE_RPC_URL", " "); + assert!(overlay_parent_rpc_url().is_none()); + + std::env::set_var("OPENHUMAN_CORE_RPC_URL", "http://127.0.0.1:7788/rpc"); + assert_eq!( + overlay_parent_rpc_url(), + Some("http://127.0.0.1:7788/rpc".to_string()) + ); + + match original { + Some(v) => std::env::set_var("OPENHUMAN_CORE_RPC_URL", v), + None => std::env::remove_var("OPENHUMAN_CORE_RPC_URL"), + } +} + +#[test] +fn reset_local_data_windows_file_lock_error_codes_are_recognized() { + assert!(is_windows_file_lock_raw_os_error(Some(32))); + assert!(is_windows_file_lock_raw_os_error(Some(33))); + assert!(!is_windows_file_lock_raw_os_error(Some(5))); + assert!(!is_windows_file_lock_raw_os_error(None)); +} + +#[test] +fn reset_local_data_delete_error_keeps_generic_message_for_other_errors() { + let err = std::io::Error::from(std::io::ErrorKind::PermissionDenied); + let result = reset_local_data_delete_error( + "current openhuman dir", + std::path::Path::new("/tmp/openhuman"), + &err, + ); + + let msg = result.expect_err("non-lock errors must still surface to the UI"); + assert!(msg.starts_with("Failed to remove current openhuman dir at /tmp/openhuman:")); + assert!(!msg.contains("Close all OpenHuman windows and try again")); +} + +#[cfg(windows)] +#[test] +fn reset_local_data_delete_error_swallows_lock_failure_when_path_disappeared() { + // Race condition the reboot fallback now handles: the locked path + // was gone by the time `schedule_path_for_reboot_deletion` ran its + // `symlink_metadata` probe, so the reset goal is already met. The + // helper must return `Ok(())` rather than surfacing a confusing + // "couldn't remove (it's not there)" toast. + let dir = tempfile::tempdir().expect("tempdir for reset error test"); + let missing = dir.path().join("definitely-not-there"); + + let err = std::io::Error::from_raw_os_error(32); + let result = reset_local_data_delete_error("current openhuman dir", &missing, &err); + + assert!( + result.is_ok(), + "expected NotFound + empty partial schedule to be swallowed as success, got {result:?}" + ); +} + +#[cfg(windows)] +#[test] +fn reset_local_data_delete_error_reports_reboot_schedule_counts() { + // When the lock fallback can walk a real directory tree, the user + // message should report how much has been queued so the support + // log preserves "what was actually scheduled". Scheduling itself + // may still fail at the MoveFileExW step in unprivileged test + // processes (the registry key write requires administrator); the + // fallback then carries a partial schedule that the error path + // surfaces, so both branches must keep mentioning the lock cause + // *and* expose either the queued counts or the schedule failure. + let dir = tempfile::tempdir().expect("tempdir for reset error test"); + let target = dir.path().join("reset-mock"); + std::fs::create_dir_all(target.join("nested")).expect("mkdir nested"); + std::fs::write(target.join("a.txt"), b"x").expect("write a.txt"); + std::fs::write(target.join("nested").join("b.txt"), b"y").expect("write b.txt"); + + let err = std::io::Error::from_raw_os_error(32); + let result = reset_local_data_delete_error("current openhuman dir", &target, &err); + + // Path exists on disk, so the fallback must surface the outcome — + // either an "all-queued" success-but-needs-reboot message (admin) + // or one of the failure flavours (non-admin). + let msg = + result.expect_err("path exists, fallback must report queued counts or scheduling failure"); + let admin_path = msg.contains("queued for deletion the next time you restart Windows") + && msg.contains("2 files and 2 folders"); + let user_full_fail = msg.contains("scheduling deletion on next reboot also failed"); + let user_partial = msg.contains("queued for the next reboot before scheduling failed"); + assert!( + admin_path || user_full_fail || user_partial, + "expected reboot-scheduled, fully-failed, or partial-fail message, got: {msg}" + ); + // Whatever branch we land on, the user must still be told the lock + // is what blocked the immediate removal. + assert!( + msg.contains("locked by another OpenHuman window or process") + || msg.contains("another process is holding it open"), + "missing lock cause: {msg}" + ); +} + +/// Tests for setup_tray conditional compilation +/// The PR adds two versions of setup_tray(): +/// 1. No-op for linux + cef: logs warning and returns Ok(()) +/// 2. Full implementation for other platforms +/// +/// These tests verify the function signatures are correct and +/// the compile-time cfg blocks are properly set up. + +/// Verify setup_tray function exists and has correct signature +/// This test passes if the code compiles, as the function signature +/// is validated by the compiler. +#[test] +fn setup_tray_function_signature_compiles() { + // This test exists to ensure the conditional compilation + // of setup_tray is valid. The function is not actually called + // here because it requires a full Tauri AppHandle. + // The cfg attributes ensure only one version exists at compile time. +} + +/// Test that AppRuntime is defined for the current feature set +#[test] +fn app_runtime_type_exists() { + // This test verifies AppRuntime is properly defined + // based on the cef feature flag. + // The type alias exists at module scope and is used throughout. + fn _check_runtime() {} + // _check_runtime::(); // Would require importing +} + +#[test] +fn no_app_update_available_result_is_quiet_unavailable() { + let info = no_app_update_available("0.53.43".to_string()); + + assert_eq!(info.current_version, "0.53.43"); + assert!(!info.available); + assert!(info.available_version.is_none()); + assert!(info.body.is_none()); +} + +/// Verify tray logging patterns exist (grep-friendly) +#[test] +fn tray_setup_logging_patterns_exist() { + // These log patterns from the PR are grep-friendly: + // "[tray] skipping tray setup on linux+cef: ..." + // "[tray] setting up tray icon" + // "[tray] tray icon ready" + // "[tray] action=show_window ..." + // "[tray] action=quit ..." + // "[tray] failed to setup tray icon ..." + // "[app] RunEvent::Ready — GTK initialized, setting up tray" + // + // This test passes if the code compiles with these log messages. +} + +// ------------------------------------------------------------------------- +// macos_os_version (issue #1012) +// ------------------------------------------------------------------------- + +/// On macOS, sw_vers is always present and must return a version string. +#[cfg(target_os = "macos")] +#[test] +fn macos_os_version_returns_some() { + assert!( + macos_os_version().is_some(), + "sw_vers -productVersion must succeed on macOS" + ); +} + +/// The returned version must be a non-empty trimmed string. +#[cfg(target_os = "macos")] +#[test] +fn macos_os_version_is_nonempty() { + let ver = macos_os_version().expect("sw_vers must return a version on macOS"); + assert!(!ver.is_empty()); + // No leading/trailing whitespace (the impl trims). + assert_eq!(ver, ver.trim()); +} + +/// The version string must look like dot-separated integers ("14.5", "13.2.1"). +#[cfg(target_os = "macos")] +#[test] +fn macos_os_version_is_dotted_integer_format() { + let ver = macos_os_version().expect("sw_vers must return a version on macOS"); + let all_numeric_parts = ver + .split('.') + .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())); + assert!( + all_numeric_parts, + "os version {ver:?} must be dot-separated integers (e.g. '14.5')" + ); +} + +/// The version must have at least one component (e.g. a bare major "15" is valid). +#[cfg(target_os = "macos")] +#[test] +fn macos_os_version_has_at_least_one_component() { + let ver = macos_os_version().expect("sw_vers must return a version on macOS"); + assert!( + !ver.split('.').next().unwrap_or("").is_empty(), + "version must have at least one numeric component" + ); +} + +// ------------------------------------------------------------------------- +// WSL + X11 desktop startup warning (issue #1653) +// ------------------------------------------------------------------------- + +#[test] +fn wsl_x11_warning_detects_classic_x11_forwarding() { + assert!(should_warn_for_wsl_x11_desktop(true, true, false, false)); +} + +#[test] +fn wsl_x11_warning_skips_non_wsl_or_headless_runs() { + assert!(!should_warn_for_wsl_x11_desktop(false, true, false, false)); + assert!(!should_warn_for_wsl_x11_desktop(true, false, false, false)); +} + +#[test] +fn wsl_x11_warning_skips_wslg_or_wayland_runs() { + assert!(!should_warn_for_wsl_x11_desktop(true, true, true, false)); + assert!(!should_warn_for_wsl_x11_desktop(true, true, false, true)); +} + +// ------------------------------------------------------------------------- +// Linux display-server pre-flight (Sentry OPENHUMAN-TAURI-K1) +// ------------------------------------------------------------------------- + +#[test] +fn linux_display_present_with_x11() { + assert!(linux_display_server_present(true, false)); +} + +#[test] +fn linux_display_present_with_wayland() { + assert!(linux_display_server_present(false, true)); +} + +#[test] +fn linux_display_present_with_both() { + assert!(linux_display_server_present(true, true)); +} + +#[test] +fn linux_display_absent_without_either() { + assert!(!linux_display_server_present(false, false)); +} + +#[test] +fn linux_root_uid_detected() { + assert!(linux_is_root_uid(0)); +} + +#[test] +fn linux_non_root_uid_not_detected() { + assert!(!linux_is_root_uid(1000)); + assert!(!linux_is_root_uid(1)); +} + +// ------------------------------------------------------------------------- +// Linux D-Bus session-bus probe (Sentry OPENHUMAN-TAURI-TM) +// ------------------------------------------------------------------------- + +#[test] +fn dbus_address_unix_is_supported() { + assert!(dbus_address_is_supported("unix:path=/run/user/1000/bus")); + assert!(dbus_address_is_supported("unix:abstract=/tmp/dbus-abc")); +} + +#[test] +fn dbus_address_tcp_and_launchd_supported() { + assert!(dbus_address_is_supported("tcp:host=localhost,port=1234")); + assert!(dbus_address_is_supported( + "launchd:env=DBUS_LAUNCHD_SESSION_BUS_SOCKET" + )); + assert!(dbus_address_is_supported("autolaunch:")); +} + +#[test] +fn dbus_address_disabled_is_unsupported() { + // The literal value WSL2-without-WSLg sets — root cause of the panic. + assert!(!dbus_address_is_supported("disabled")); + assert!(!dbus_address_is_supported("")); + assert!(!dbus_address_is_supported(" ")); +} + +#[test] +fn dbus_address_unknown_transport_is_unsupported() { + assert!(!dbus_address_is_supported("nonce-tcp:host=localhost")); + assert!(!dbus_address_is_supported("bogus:")); +} + +#[test] +fn dbus_address_picks_first_supported_in_list() { + // zbus walks the semicolon-separated list and uses the first reachable + // transport, so one good entry is enough. + assert!(dbus_address_is_supported( + "disabled;unix:path=/run/user/1000/bus" + )); + assert!(dbus_address_is_supported( + "bogus:;tcp:host=localhost,port=55" + )); + assert!(!dbus_address_is_supported("disabled;bogus:")); +} + +#[test] +fn dbus_reachable_when_env_addr_is_supported() { + assert!(linux_dbus_session_reachable( + Some("unix:path=/run/user/1000/bus"), + false, + )); +} + +#[test] +fn dbus_unreachable_when_env_addr_disabled() { + // Even if the socket exists, an explicit `disabled` value means the + // session bus is intentionally turned off and zbus will reject it. + assert!(!linux_dbus_session_reachable(Some("disabled"), true)); +} + +#[test] +fn dbus_falls_back_to_runtime_socket_when_env_unset() { + assert!(linux_dbus_session_reachable(None, true)); + assert!(!linux_dbus_session_reachable(None, false)); +} + +// ------------------------------------------------------------------------- +// Platform constants (issue #1012 Sentry tagging) +// ------------------------------------------------------------------------- + +/// cpu_arch tag is derived from std::env::consts::ARCH which must be non-empty. +#[test] +fn platform_arch_constant_is_nonempty() { + assert!( + !std::env::consts::ARCH.is_empty(), + "ARCH constant used for Sentry cpu_arch tag must be non-empty" + ); +} + +/// os_name tag is derived from std::env::consts::OS which must be non-empty. +#[test] +fn platform_os_constant_is_nonempty() { + assert!( + !std::env::consts::OS.is_empty(), + "OS constant used for Sentry os_name tag must be non-empty" + ); +} + +/// On a macOS build the OS constant must equal "macos". +#[cfg(target_os = "macos")] +#[test] +fn platform_os_is_macos_on_macos_build() { + assert_eq!(std::env::consts::OS, "macos"); +} + +#[test] +fn platform_cef_gpu_workarounds_disable_linux_gpu_path() { + let mut args = Vec::new(); + append_platform_cef_gpu_workarounds(&mut args, "linux", "x86_64", None); + + assert!(args.contains(&("--disable-gpu", None))); + assert!(args.contains(&("--disable-gpu-compositing", None))); +} + +#[test] +fn platform_cef_gpu_workarounds_disable_intel_macos_compositing_only() { + let mut args = Vec::new(); + append_platform_cef_gpu_workarounds(&mut args, "macos", "x86_64", None); + + assert_eq!(args, vec![("--disable-gpu-compositing", None)]); +} + +#[test] +fn platform_cef_gpu_workarounds_leave_other_platforms_alone() { + for (os, arch) in [("macos", "aarch64"), ("windows", "x86_64")] { + let mut args = Vec::new(); + append_platform_cef_gpu_workarounds(&mut args, os, arch, None); + + assert!( + args.is_empty(), + "unexpected CEF GPU flags for {os}/{arch}: {args:?}" + ); + } +} + +// ------------------------------------------------------------------------- +// OPENHUMAN_FORCE_GPU override (re-enables WebGL2 surfaces — Rive mascot) +// ------------------------------------------------------------------------- + +#[test] +fn force_gpu_default_off_when_env_unset() { + assert!(!cef_force_gpu_enabled(None)); +} + +#[test] +fn force_gpu_explicit_enable_values_match_prewarm_pattern() { + for v in ["1", "true", "yes", "on", "TRUE", "Yes", "On"] { + assert!( + cef_force_gpu_enabled(Some(v)), + "OPENHUMAN_FORCE_GPU={v:?} should opt in" + ); + } +} + +#[test] +fn force_gpu_anything_else_is_off() { + // Mirrors prewarm semantics: only explicit truthy values opt in. + for v in ["", "0", "false", "no", "off", "FALSE", "Off", "maybe", " "] { + assert!( + !cef_force_gpu_enabled(Some(v)), + "OPENHUMAN_FORCE_GPU={v:?} must not silently opt in" + ); + } +} + +#[test] +fn platform_cef_gpu_workarounds_skip_linux_disable_when_force_gpu_set() { + // Assert the two GPU-disable flags are absent rather than the whole + // arg list being empty: on root-Linux runners (CI in some configs) + // the function still appends `--no-sandbox` via the orthogonal + // OPENHUMAN-TAURI-K1 branch, which would make a strict `is_empty()` + // check fail spuriously. We only care about the GPU branch here. + let mut args = Vec::new(); + append_platform_cef_gpu_workarounds(&mut args, "linux", "x86_64", Some("1")); + + assert!( + !args.contains(&("--disable-gpu", None)), + "OPENHUMAN_FORCE_GPU=1 must suppress --disable-gpu, got: {args:?}" + ); + assert!( + !args.contains(&("--disable-gpu-compositing", None)), + "OPENHUMAN_FORCE_GPU=1 must suppress --disable-gpu-compositing, got: {args:?}" + ); +} + +#[test] +fn platform_cef_gpu_workarounds_force_gpu_does_not_affect_intel_macos_path() { + // OPENHUMAN_FORCE_GPU only governs the Linux #1697 workaround; the + // separate Intel-macOS #1012 disable must still apply, regardless of + // the env var. + let mut args = Vec::new(); + append_platform_cef_gpu_workarounds(&mut args, "macos", "x86_64", Some("1")); + + assert_eq!(args, vec![("--disable-gpu-compositing", None)]); +} + +/// On an Intel macOS build the ARCH constant must equal "x86_64". +/// This is the architecture that triggers --disable-gpu-compositing. +#[cfg(all(target_os = "macos", target_arch = "x86_64"))] +#[test] +fn platform_arch_is_x86_64_on_intel_build() { + assert_eq!(std::env::consts::ARCH, "x86_64"); +} + +/// On Apple Silicon the ARCH constant must equal "aarch64"; the GPU flag +/// must NOT be compiled in (verified by this test existing in the binary). +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +#[test] +fn platform_arch_is_aarch64_on_apple_silicon_build() { + assert_eq!(std::env::consts::ARCH, "aarch64"); +} + +// ------------------------------------------------------------------------- +// cef_prewarm_enabled (issue #2463 — Wayland/XWayland BadWindow guard) +// ------------------------------------------------------------------------- + +#[test] +fn prewarm_enabled_by_default_on_non_wayland() { + assert!(cef_prewarm_enabled(None, false)); +} + +#[test] +fn prewarm_auto_disabled_on_wayland_when_env_unset() { + assert!(!cef_prewarm_enabled(None, true)); +} + +#[test] +fn prewarm_explicit_disable_respected_on_non_wayland() { + assert!(!cef_prewarm_enabled(Some("0"), false)); + assert!(!cef_prewarm_enabled(Some("false"), false)); + assert!(!cef_prewarm_enabled(Some("no"), false)); + assert!(!cef_prewarm_enabled(Some("off"), false)); +} + +#[test] +fn prewarm_explicit_disable_respected_on_wayland() { + assert!(!cef_prewarm_enabled(Some("0"), true)); + assert!(!cef_prewarm_enabled(Some("false"), true)); +} + +#[test] +fn prewarm_explicit_enable_overrides_wayland_guard() { + // OPENHUMAN_CEF_PREWARM=1 (or any non-disable value) lets ops + // force prewarm even on Wayland sessions. + assert!(cef_prewarm_enabled(Some("1"), true)); + assert!(cef_prewarm_enabled(Some("true"), true)); + assert!(cef_prewarm_enabled(Some("yes"), true)); + assert!(cef_prewarm_enabled(Some("on"), true)); +} + +#[test] +fn prewarm_disable_flags_are_case_insensitive() { + assert!(!cef_prewarm_enabled(Some("FALSE"), false)); + assert!(!cef_prewarm_enabled(Some("OFF"), true)); + assert!(!cef_prewarm_enabled(Some(" 0 "), false)); + assert!(!cef_prewarm_enabled(Some(" No "), true)); +} + +#[test] +fn prewarm_unknown_env_value_treated_as_enable() { + // Any string that is not a recognised disable token → treat as enable. + assert!(cef_prewarm_enabled(Some("enabled"), false)); + assert!(cef_prewarm_enabled(Some("yes"), false)); + assert!(cef_prewarm_enabled(Some(""), false)); +} + +// ------------------------------------------------------------------------- +// build_sentry_release_tag +// ------------------------------------------------------------------------- + +#[test] +fn sentry_release_tag_starts_with_openhuman() { + let tag = build_sentry_release_tag(); + assert!( + tag.starts_with("openhuman@"), + "release tag must start with 'openhuman@', got: {tag:?}" + ); +} + +#[test] +fn sentry_release_tag_contains_cargo_pkg_version() { + let tag = build_sentry_release_tag(); + let version = env!("CARGO_PKG_VERSION"); + assert!( + tag.contains(version), + "release tag {tag:?} must embed CARGO_PKG_VERSION {version:?}" + ); +} + +#[test] +fn sentry_release_tag_version_part_is_nonempty() { + let tag = build_sentry_release_tag(); + let after_prefix = tag.strip_prefix("openhuman@").unwrap_or(""); + assert!(!after_prefix.is_empty(), "version part must not be empty"); +} + +/// When a SHA is baked in the tag takes the form `openhuman@+`. +/// When it is not, the tag is simply `openhuman@` with no `+`. +/// Either way the full tag must be non-empty. +#[test] +fn sentry_release_tag_is_nonempty() { + assert!(!build_sentry_release_tag().is_empty()); +} + +// ------------------------------------------------------------------------- +// resolve_sentry_environment +// ------------------------------------------------------------------------- + +#[test] +fn sentry_environment_reads_openhuman_app_env() { + let _g = ENV_LOCK.lock().unwrap(); + let key = "OPENHUMAN_APP_ENV"; + let original = std::env::var(key).ok(); + std::env::set_var(key, "staging"); + let env = resolve_sentry_environment(); + match original { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + assert_eq!(env, "staging"); +} + +#[test] +fn sentry_environment_trims_whitespace_from_openhuman_app_env() { + let _g = ENV_LOCK.lock().unwrap(); + let key = "OPENHUMAN_APP_ENV"; + let original = std::env::var(key).ok(); + std::env::set_var(key, " dev "); + let env = resolve_sentry_environment(); + match original { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + assert_eq!(env, "dev"); +} + +#[test] +fn sentry_environment_skips_empty_openhuman_app_env() { + let _g = ENV_LOCK.lock().unwrap(); + let key = "OPENHUMAN_APP_ENV"; + let original = std::env::var(key).ok(); + std::env::set_var(key, ""); + let env = resolve_sentry_environment(); + match original { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + // Falls through to VITE_ compile-time value or "production"; must be non-empty. + assert!(!env.is_empty()); +} + +#[test] +fn sentry_environment_skips_whitespace_only_openhuman_app_env() { + let _g = ENV_LOCK.lock().unwrap(); + let key = "OPENHUMAN_APP_ENV"; + let original = std::env::var(key).ok(); + std::env::set_var(key, " "); + let env = resolve_sentry_environment(); + match original { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + assert!(!env.is_empty()); +} + +/// When neither runtime env var nor compile-time VITE_ is set, the fallback +/// must be "production". Guard with a compile-time check so this test only +/// asserts the hard default when no compile-time override is present. +#[test] +fn sentry_environment_defaults_to_production_when_unset() { + let _g = ENV_LOCK.lock().unwrap(); + if option_env!("VITE_OPENHUMAN_APP_ENV").is_some() { + // A compile-time override is baked in; skip — the fallback path is + // exercised by sentry_environment_skips_empty_openhuman_app_env. + return; + } + let key = "OPENHUMAN_APP_ENV"; + let original = std::env::var(key).ok(); + std::env::remove_var(key); + let env = resolve_sentry_environment(); + match original { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + assert_eq!(env, "production"); +} + +// ── Sentry before_send filter: drop "Failed to request http://localhost:…" +// noise emitted by the vendored tauri-runtime-cef dev proxy in packaged +// builds (issue OPENHUMAN-TAURI-V). Tests target the pure +// `message_is_localhost_dev_fetch_noise` helper so the rule can be +// asserted without standing up a Sentry client. + +#[test] +fn localhost_dev_fetch_noise_drops_vite_dev_url_1420() { + // The exact message shape reported by the latest event tag in Sentry + // (URL repeated by reqwest's `error sending request for url (…)`). + let msg = "Failed to request http://localhost:1420/components/skills/SkillCard.tsx: \ + error sending request for url (http://localhost:1420/components/skills/SkillCard.tsx)"; + assert!( + message_is_localhost_dev_fetch_noise(msg), + "expected Vite dev-server fetch failure to be filtered" + ); +} + +#[test] +fn localhost_dev_fetch_noise_drops_127_0_0_1_dev_url() { + // Some environments resolve `localhost` to 127.0.0.1 at the reqwest + // layer; the formatted message can carry either spelling. + let msg = "Failed to request http://127.0.0.1:1420/index.html: \ + error sending request for url (http://127.0.0.1:1420/index.html)"; + assert!( + message_is_localhost_dev_fetch_noise(msg), + "expected 127.0.0.1 dev-server fetch failure to be filtered" + ); +} + +#[test] +fn localhost_dev_fetch_noise_passes_production_url_through() { + // Real upstream failures (e.g. backend API errors surfaced via the + // same `Failed to request …` wording elsewhere) must NOT be filtered — + // they're the high-signal events Sentry exists for. + let msg = "Failed to request https://api.openhuman.ai/v1/skills: \ + error sending request for url (https://api.openhuman.ai/v1/skills)"; + assert!( + !message_is_localhost_dev_fetch_noise(msg), + "production API errors must NOT be filtered out" + ); +} + +#[test] +fn localhost_dev_fetch_noise_passes_unrelated_localhost_messages() { + // The filter is anchored on the dev-proxy's exact prefix to avoid + // accidentally dropping any error that happens to mention localhost + // (e.g. core-sidecar transport errors logged from coreRpcClient). + let msg = + "[core_rpc] transport error: error sending request for url (http://localhost:7788/rpc)"; + assert!( + !message_is_localhost_dev_fetch_noise(msg), + "non-tauri-cef localhost errors must NOT be filtered" + ); +} + +#[test] +fn event_filter_uses_message_field() { + // event-level coverage: when sentry-tracing populates + // `event.message` (default with `attach_stacktrace=false`), the + // filter should see the noise payload through the primary read + // path. Per graycyrus on PR #1545. + let mut event = sentry::protocol::Event::new(); + event.message = Some("Failed to request http://localhost:1420/foo: timeout".into()); + assert!( + event_is_localhost_dev_fetch_noise(&event), + "event.message read path must catch noise messages" + ); +} + +#[test] +fn event_filter_falls_back_to_last_exception_value() { + // event-level coverage: if `attach_stacktrace` is ever turned on, + // sentry-tracing populates `event.exception` instead of (or in + // addition to) `event.message`. Filter must still see the noise + // payload through the exception fallback. Per graycyrus on PR #1545. + let mut event = sentry::protocol::Event::new(); + event.message = None; + event.exception.values.push(sentry::protocol::Exception { + ty: "log".into(), + value: Some("Failed to request http://localhost:1420/foo: timeout".into()), + ..Default::default() + }); + assert!( + event_is_localhost_dev_fetch_noise(&event), + "exception fallback must catch noise messages when event.message is absent" + ); +} + +#[test] +fn event_filter_passes_through_when_neither_field_matches() { + // Negative event-level case: no noise prefix in either field → + // event must NOT be filtered. + let mut event = sentry::protocol::Event::new(); + event.message = Some("genuine production error".into()); + event.exception.values.push(sentry::protocol::Exception { + ty: "log".into(), + value: Some("connection refused (10061)".into()), + ..Default::default() + }); + assert!( + !event_is_localhost_dev_fetch_noise(&event), + "legitimate production events must pass through" + ); +} + +#[test] +fn localhost_dev_fetch_noise_anchors_to_message_start() { + // CodeRabbit (PR #1545) caught that the predicate used + // `contains` rather than `starts_with`. Regression: a message + // that merely embeds the dev-proxy prefix later in its text + // must NOT be filtered — only messages that *begin* with it. + let msg = "User report: `Failed to request http://localhost:1420/foo` was logged earlier"; + assert!( + !message_is_localhost_dev_fetch_noise(msg), + "messages that merely contain the dev-proxy prefix must NOT be filtered" + ); +} + +// ------------------------------------------------------------------------- +// path_has_executable / deep-link xdg-mime pre-flight (OPENHUMAN-TAURI-AS) +// ------------------------------------------------------------------------- + +/// With a controlled `$PATH` containing one dir that holds a file named +/// `xdg-mime`, the lookup must succeed (mirrors a Linux desktop install +/// where xdg-utils ships the binary). +#[cfg(target_os = "linux")] +#[test] +fn path_has_executable_finds_file_on_path() { + let _g = ENV_LOCK.lock().unwrap(); + let original = std::env::var_os("PATH"); + + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("xdg-mime"), b"#!/bin/sh\n").expect("write stub"); + std::env::set_var("PATH", dir.path()); + + assert!( + path_has_executable("xdg-mime"), + "must discover xdg-mime when present in a $PATH entry" + ); + + match original { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } +} + +/// With a controlled `$PATH` that does NOT contain `xdg-mime`, the lookup +/// must fail (mirrors WSL2 / minimal containers without xdg-utils — the +/// case OPENHUMAN-TAURI-AS protects against). +#[cfg(target_os = "linux")] +#[test] +fn path_has_executable_returns_false_when_missing() { + let _g = ENV_LOCK.lock().unwrap(); + let original = std::env::var_os("PATH"); + + let dir = tempfile::tempdir().expect("tempdir"); + // Intentionally do not create xdg-mime in `dir`. + std::env::set_var("PATH", dir.path()); + + assert!( + !path_has_executable("xdg-mime"), + "must return false when xdg-mime is not in any $PATH entry" + ); + + match original { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } +} + +/// When `$PATH` is unset entirely, the lookup must short-circuit to false +/// rather than panic or fall back to the cwd. +#[cfg(target_os = "linux")] +#[test] +fn path_has_executable_returns_false_when_path_unset() { + let _g = ENV_LOCK.lock().unwrap(); + let original = std::env::var_os("PATH"); + + std::env::remove_var("PATH"); + assert!( + !path_has_executable("xdg-mime"), + "unset $PATH must yield false (skip register_all on the missing-xdg-utils branch)" + ); + + match original { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } +} + +/// Regression guard for OPENHUMAN-TAURI-5V: a Linux host with `xdg-mime` +/// installed but `update-desktop-database` missing must classify as +/// "skip register_all" — the pre-#5V code only checked `xdg-mime` and +/// would have entered the plugin call, which then fires the noisy +/// `Failed to run OS command \`update-desktop-database\`` internal log +/// that escapes to Sentry. The Wave-4 fix pre-flights every xdg-utils +/// binary the plugin shells out to; this test pins that contract by +/// checking each binary lookup independently with a `$PATH` that +/// contains only `xdg-mime`. +#[cfg(target_os = "linux")] +#[test] +fn path_has_executable_returns_false_for_partial_xdg_utils_install() { + let _g = ENV_LOCK.lock().unwrap(); + let original = std::env::var_os("PATH"); + + let dir = tempfile::tempdir().expect("tempdir"); + // Only `xdg-mime` exists; `update-desktop-database` and + // `xdg-icon-resource` are deliberately absent. + std::fs::write(dir.path().join("xdg-mime"), b"#!/bin/sh\n").expect("write stub"); + std::env::set_var("PATH", dir.path()); + + assert!( + path_has_executable("xdg-mime"), + "xdg-mime stub must be discoverable in the partial-install $PATH" + ); + assert!( + !path_has_executable("update-desktop-database"), + "partial xdg-utils install must NOT report update-desktop-database present (OPENHUMAN-TAURI-5V)" + ); + assert!( + !path_has_executable("xdg-icon-resource"), + "partial xdg-utils install must NOT report xdg-icon-resource present" + ); + + match original { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } +} + +/// Regression guard for issue #2228: `tauri-plugin-single-instance` must +/// enable the `deep-link` feature so that second-launch deep-link payloads +/// (e.g. `openhuman://oauth/...` callbacks from Windows/Linux system +/// browsers) are forwarded into the primary instance. Without it, hot OAuth +/// callbacks silently no-op while only focusing the existing window. +#[test] +fn single_instance_dep_enables_deep_link_feature() { + let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + let manifest = std::fs::read_to_string(&manifest_path).expect("read app/src-tauri/Cargo.toml"); + let parsed: toml::Value = manifest.parse().expect("parse Cargo.toml"); + + let dep = parsed + .get("dependencies") + .and_then(|d| d.get("tauri-plugin-single-instance")) + .expect("tauri-plugin-single-instance dependency must exist"); + + let features = dep.get("features").and_then(|f| f.as_array()).expect( + "tauri-plugin-single-instance must be a table with a `features` array \ + — issue #2228 requires the `deep-link` feature to forward hot-instance \ + OAuth callbacks on Windows/Linux", + ); + + assert!( + features.iter().any(|v| v.as_str() == Some("deep-link")), + "tauri-plugin-single-instance must enable the `deep-link` feature \ + (issue #2228 — hot-instance OAuth callback forwarding)" + ); +} diff --git a/app/src-tauri/src/webview_accounts/mod.rs b/app/src-tauri/src/webview_accounts/mod.rs index 15419daa5..ea4b60be0 100644 --- a/app/src-tauri/src/webview_accounts/mod.rs +++ b/app/src-tauri/src/webview_accounts/mod.rs @@ -3260,1214 +3260,5 @@ pub async fn webview_recipe_event( } #[cfg(test)] -mod tests { - use super::*; - - fn url(s: &str) -> Url { - Url::parse(s).expect("valid url") - } - - #[test] - fn reveal_trigger_from_ipc_warns_and_defaults_unknown_to_load() { - assert_eq!(RevealTrigger::from_ipc(None), RevealTrigger::Load); - assert_eq!(RevealTrigger::from_ipc(Some("load")), RevealTrigger::Load); - assert_eq!( - RevealTrigger::from_ipc(Some("watchdog")), - RevealTrigger::Watchdog - ); - assert_eq!( - RevealTrigger::from_ipc(Some("watchdog-typo")), - RevealTrigger::Load - ); - } - - // ── shutdown teardown ────────────────────────────────── - - /// Smoke-test [`WebviewAccountsState::drain_for_shutdown`] in isolation - /// from the Tauri runtime. Populates the state with representative - /// per-account resources (CDP / watchdog `JoinHandle`s, a CEF browser - /// id, an `acct_*` label, plus the small bookkeeping sets) and asserts - /// that one call drains every collection and aborts the long-running - /// tasks, that the returned label list is what `shutdown_all` will - /// `wv.close()` against, and that a second call is a safe no-op. - /// - /// `shutdown_all` itself takes an `AppHandle` and is exercised end-to- - /// end at runtime; the inner `drain_for_shutdown` covers the part of - /// the teardown that doesn't need a Tauri runtime to verify. - #[tokio::test] - async fn drain_for_shutdown_clears_state_and_repeat_is_noop() { - use std::time::Duration; - - let state = WebviewAccountsState::default(); - - let cdp_task = tokio::spawn(async { - tokio::time::sleep(Duration::from_secs(60)).await; - }); - let cdp_abort = cdp_task.abort_handle(); - let watchdog_task = tokio::spawn(async { - tokio::time::sleep(Duration::from_secs(60)).await; - }); - let watchdog_abort = watchdog_task.abort_handle(); - - state - .cdp_sessions - .lock() - .unwrap() - .insert("acct-1".into(), cdp_task); - state - .load_watchdogs - .lock() - .unwrap() - .insert("acct-1".into(), watchdog_task); - state - .browser_ids - .lock() - .unwrap() - .insert("acct-1".into(), 42); - state - .inner - .lock() - .unwrap() - .insert("acct-1".into(), "acct_1".into()); - state - .account_providers - .lock() - .unwrap() - .insert("acct-1".into(), "slack".into()); - state - .loaded_accounts - .lock() - .unwrap() - .insert("acct-1".into()); - state.requested_bounds.lock().unwrap().insert( - "acct-1".into(), - Bounds { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }, - ); - state - .spawn_started_at - .lock() - .unwrap() - .insert("acct-1".into(), Instant::now()); - // Saturate the gmeet rewrite counter so we can assert it gets - // cleared by drain (otherwise the next reopen would inherit a - // stale entry — `label_for()` reuses the same label). - for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { - let _ = state.track_gmeet_marketing_rewrite("acct_1", Instant::now()); - } - assert!(!state.gmeet_marketing_rewrites.lock().unwrap().is_empty()); - - let labels = state.drain_for_shutdown(); - tokio::task::yield_now().await; - - assert_eq!( - labels, - vec![("acct-1".to_string(), "acct_1".to_string())], - "shutdown_all should close the acct_* webview returned here" - ); - assert!(cdp_abort.is_finished(), "CDP session task was aborted"); - assert!( - watchdog_abort.is_finished(), - "load watchdog task was aborted" - ); - assert!(state.cdp_sessions.lock().unwrap().is_empty()); - assert!(state.load_watchdogs.lock().unwrap().is_empty()); - assert!(state.browser_ids.lock().unwrap().is_empty()); - assert!(state.inner.lock().unwrap().is_empty()); - assert!(state.account_providers.lock().unwrap().is_empty()); - assert!(state.loaded_accounts.lock().unwrap().is_empty()); - assert!(state.requested_bounds.lock().unwrap().is_empty()); - assert!(state.spawn_started_at.lock().unwrap().is_empty()); - assert!( - state.gmeet_marketing_rewrites.lock().unwrap().is_empty(), - "gmeet rewrite counter must clear on drain so reopens don't inherit stale entries" - ); - - // Second call must be a safe no-op: nothing left to drain. - let labels2 = state.drain_for_shutdown(); - assert!(labels2.is_empty()); - assert!(state.cdp_sessions.lock().unwrap().is_empty()); - assert!(state.inner.lock().unwrap().is_empty()); - assert!(state.account_providers.lock().unwrap().is_empty()); - } - - // ── provider registry match arms ────────────────────────────────── - - #[test] - fn zoom_registered_in_provider_url() { - assert_eq!(provider_url("zoom"), Some("https://zoom.us/")); - } - - #[test] - fn wechat_registered_in_provider_url() { - assert_eq!(provider_url("wechat"), Some("https://web.wechat.com/")); - } - - #[test] - fn wechat_has_no_recipe_js_injection() { - assert!(provider_recipe_js("wechat").is_none()); - } - - #[test] - fn wechat_allowed_hosts_cover_web_and_login_domains() { - let hosts = provider_allowed_hosts("wechat"); - assert!(hosts.contains(&"wechat.com"), "wechat.com in allowlist"); - assert!(hosts.contains(&"wx.qq.com"), "wx.qq.com in allowlist"); - assert!( - hosts.contains(&"login.weixin.qq.com"), - "login.weixin.qq.com in allowlist" - ); - } - - #[test] - fn zoom_has_no_recipe_js_injection() { - // Per the CLAUDE.md "no new JS injection" rule for CEF child - // webviews, Zoom must rely solely on Rust `on_navigation` + - // `on_new_window` (plus CDP from scanner modules, if any) — no - // `recipe.js` should be registered. - assert!(provider_recipe_js("zoom").is_none()); - } - - #[test] - fn zoom_allowed_hosts_covers_core_domains() { - let hosts = provider_allowed_hosts("zoom"); - assert!(hosts.contains(&"zoom.us"), "zoom.us in allowlist"); - assert!(hosts.contains(&"zoomgov.com"), "zoomgov.com in allowlist"); - assert!(hosts.contains(&"zdassets.com"), "zdassets.com in allowlist"); - } - - #[test] - fn zoom_allowed_hosts_covers_google_oauth() { - // Zoom's "Sign in with Google" reroutes the popup into the - // embedded webview (see popup_should_navigate_parent). The - // resulting accounts.google.com / oauth2.googleapis.com / - // www.googleapis.com hops MUST be classified internal so the - // auth chain doesn't escape to the system browser mid-flight - // and trigger Zoom error 300 (#1294). - assert!(url_is_internal( - "zoom", - &url("https://accounts.google.com/v3/signin/identifier"), - )); - assert!(url_is_internal( - "zoom", - &url("https://oauth2.googleapis.com/token"), - )); - assert!(url_is_internal( - "zoom", - &url("https://www.googleapis.com/oauth2/v3/userinfo"), - )); - } - - #[test] - fn zoom_supports_google_sso() { - // Zoom's web client offers "Sign in with Google" via a popup - // window.open("https://accounts.google.com/..."). The popup - // gate at popup_should_navigate_parent gates on this helper — - // without zoom listed the popup falls through to the system - // browser and breaks the auth callback (#1294). - assert!(provider_supports_google_sso("zoom")); - } - - #[test] - fn zoom_popup_navigates_parent_for_google_sso() { - // Mirror of slack_google_signin_popup_navigates_parent — - // clicking "Sign in with Google" inside Zoom MUST replace the - // parent webview's URL instead of escaping to the system - // browser, so the Google session cookie lands in the per-account - // CEF profile (#1294). - assert_eq!( - popup_should_navigate_parent( - "zoom", - &url("https://accounts.google.com/v3/signin/identifier"), - ) - .map(|u| u.to_string()), - Some("https://accounts.google.com/v3/signin/identifier".to_string()) - ); - } - - // ── LinkedIn Google SSO (issue #1021) ────────────────────────────── - - #[test] - fn linkedin_supports_google_sso() { - // LinkedIn's "Sign in with Google" button must be handled in-app; - // without linkedin in provider_supports_google_sso the popup falls - // through to the system browser, which opens blank (#1021). - assert!(provider_supports_google_sso("linkedin")); - } - - #[test] - fn linkedin_allowed_hosts_cover_google_oauth() { - // Google auth chain hops through oauth2.googleapis.com and - // www.googleapis.com which are not Google SSO hosts and must be - // present in the explicit allowlist so mid-flight redirects don't - // leak to the system browser. - let hosts = provider_allowed_hosts("linkedin"); - for host in [ - "accounts.google.com", - "accounts.googleusercontent.com", - "ssl.gstatic.com", - "fonts.gstatic.com", - "lh3.googleusercontent.com", - "oauth2.googleapis.com", - "www.googleapis.com", - ] { - assert!(hosts.contains(&host), "{host} in LinkedIn allowlist"); - } - } - - #[test] - fn linkedin_google_signin_popup_navigates_parent() { - // Clicking "Sign in with Google" on LinkedIn's login page issues a - // window.open to accounts.google.com/signin/... — must navigate the - // parent in-app instead of opening the system browser (#1021). - assert_eq!( - popup_should_navigate_parent( - "linkedin", - &url("https://accounts.google.com/v3/signin/identifier"), - ) - .map(|u| u.to_string()), - Some("https://accounts.google.com/v3/signin/identifier".to_string()) - ); - } - - #[test] - fn linkedin_google_oauth2_popup_navigates_parent() { - // LinkedIn may issue window.open to the initial OAuth2 auth - // endpoint (/o/oauth2/v2/auth) which doesn't contain "signin" - // in the path — must still be caught and routed in-app (#1021). - assert!(popup_should_navigate_parent( - "linkedin", - &url("https://accounts.google.com/o/oauth2/v2/auth?client_id=x&redirect_uri=https://www.linkedin.com/..."), - ) - .is_some()); - } - - #[test] - fn linkedin_google_sso_navigation_is_internal() { - // Direct (non-popup) navigation to accounts.google.com during the - // LinkedIn Google SSO flow must be classified internal so it stays - // in the embedded webview. - assert!(url_is_internal( - "linkedin", - &url("https://accounts.google.com/v3/signin/identifier"), - )); - assert!(url_is_internal( - "linkedin", - &url("https://accounts.youtube.com/accounts/SetSID?..."), - )); - } - - #[test] - fn linkedin_own_domain_still_internal() { - assert!(url_is_internal( - "linkedin", - &url("https://www.linkedin.com/messaging/"), - )); - assert!(url_is_internal( - "linkedin", - &url("https://media.licdn.com/dms/image/foo.jpg"), - )); - } - - #[test] - fn linkedin_unrelated_popup_still_goes_to_system_browser() { - // Non-Google external links from LinkedIn must still route out. - assert!( - popup_should_navigate_parent("linkedin", &url("https://example.com/blog"),).is_none() - ); - assert!(!popup_should_stay_in_app( - "linkedin", - &url("https://example.com/blog"), - )); - } - - #[test] - fn linkedin_gsi_popup_stays_in_app() { - // LinkedIn's "Sign in with Google" uses the Google Identity Services - // (GSI) library. The GSI button iframe (accounts.google.com/gsi/button) - // calls window.open("accounts.google.com/gsi/select?...") to show the - // account chooser. This popup must be an in-app child window — NOT sent - // to the system browser (blank screen) and NOT a parent navigation (the - // postMessage credential callback would have no opener to reach) (#1021). - assert!(popup_should_stay_in_app( - "linkedin", - &url( - "https://accounts.google.com/gsi/select?client_id=990339570472-k6nq&ux_mode=popup" - ), - )); - assert!(popup_should_stay_in_app( - "linkedin", - &url("https://accounts.google.com/gsi/issue?client_id=x"), - )); - } - - #[test] - fn linkedin_gsi_popup_does_not_navigate_parent() { - // The GSI account-chooser popup must NOT navigate the parent — it needs - // to remain a child popup for postMessage to work. - assert!(popup_should_navigate_parent( - "linkedin", - &url("https://accounts.google.com/gsi/select?client_id=x"), - ) - .is_none()); - } - - #[test] - fn slack_allowed_hosts_include_google_oauth() { - let hosts = provider_allowed_hosts("slack"); - for host in [ - "accounts.google.com", - "accounts.googleusercontent.com", - "ssl.gstatic.com", - "fonts.gstatic.com", - "lh3.googleusercontent.com", - "oauth2.googleapis.com", - "www.googleapis.com", - ] { - assert!(hosts.contains(&host), "{host} in Slack allowlist"); - } - } - - #[test] - fn slack_allowed_hosts_still_internal_for_slack_origins() { - assert!(url_is_internal( - "slack", - &url("https://app.slack.com/client/T123/C456"), - )); - assert!(url_is_internal( - "slack", - &url("https://a.slack-edge.com/bv1/app.js"), - )); - assert!(url_is_internal( - "slack", - &url("https://wss-primary.slack.com/?ticket=redacted"), - )); - } - - #[test] - fn slack_allowed_hosts_do_not_bare_allow_google() { - let hosts = provider_allowed_hosts("slack"); - assert!( - !hosts.contains(&"google.com"), - "bare google.com not allowed" - ); - assert!(!hosts.contains(&"googleusercontent.com")); - assert!(!hosts.contains(&"gstatic.com")); - assert!(!hosts.contains(&"googleapis.com")); - - assert!(url_is_internal( - "slack", - &url("https://accounts.google.com/v3/signin/identifier"), - )); - assert!(!url_is_internal("slack", &url("https://google.com/"))); - assert!(!url_is_internal("slack", &url("https://mail.google.com/"))); - assert!(!url_is_internal("slack", &url("https://apis.google.com/"))); - } - - #[test] - fn zoom_is_supported() { - assert!(provider_is_supported("zoom")); - } - - // ── url_is_internal: subdomain + exact match ────────────────────── - - #[test] - fn zoom_web_client_subdomain_is_internal() { - assert!(url_is_internal( - "zoom", - &url("https://app.zoom.us/wc/join/123") - )); - } - - #[test] - fn zoom_apex_domain_is_internal() { - assert!(url_is_internal("zoom", &url("https://zoom.us/signin"))); - } - - #[test] - fn zoom_external_host_is_not_internal() { - assert!(!url_is_internal( - "zoom", - &url("https://unrelated.example.com/") - )); - } - - // ── rewrite_provider_deep_link: Zoom flows ──────────────────────── - - #[test] - fn rewrite_join_flow_with_confno() { - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoomus://zoom.us/join?action=join&confno=9819254358"), - ) - .expect("rewrite should succeed"); - assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/join/9819254358"); - } - - #[test] - fn rewrite_start_flow_with_confno() { - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoomus://zoom.us/start?action=start&confno=86449940711"), - ) - .expect("rewrite should succeed"); - assert_eq!( - rewritten.as_str(), - "https://app.zoom.us/wc/join/86449940711" - ); - } - - #[test] - fn rewrite_preserves_pwd_query_param() { - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoomus://zoom.us/join?action=join&confno=111&pwd=secret"), - ) - .expect("rewrite should succeed"); - assert_eq!( - rewritten.as_str(), - "https://app.zoom.us/wc/join/111?pwd=secret" - ); - } - - #[test] - fn rewrite_falls_back_to_tk_when_pwd_absent() { - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoommtg://zoom.us/join?confno=222&tk=tokenvalue"), - ) - .expect("rewrite should succeed"); - assert_eq!( - rewritten.as_str(), - "https://app.zoom.us/wc/join/222?pwd=tokenvalue" - ); - } - - #[test] - fn rewrite_accepts_zoommtg_scheme() { - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoommtg://zoom.us/join?action=join&confno=333"), - ) - .expect("rewrite should succeed"); - assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/join/333"); - } - - #[test] - fn rewrite_without_confno_falls_back_to_home() { - let rewritten = - rewrite_provider_deep_link("zoom", &url("zoomus://zoom.us/home?action=home")) - .expect("rewrite should succeed"); - assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/home"); - } - - #[test] - fn rewrite_with_empty_confno_falls_back_to_home() { - let rewritten = - rewrite_provider_deep_link("zoom", &url("zoomus://zoom.us/join?action=join&confno=")) - .expect("rewrite should succeed"); - assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/home"); - } - - #[test] - fn rewrite_rejects_non_zoom_provider() { - assert!(rewrite_provider_deep_link( - "slack", - &url("zoomus://zoom.us/join?action=join&confno=444") - ) - .is_none()); - assert!(rewrite_provider_deep_link( - "google-meet", - &url("zoomus://zoom.us/join?action=join&confno=555") - ) - .is_none()); - } - - #[test] - fn rewrite_rejects_http_zoom_url() { - // Ordinary https zoom.us navigations must pass through untouched so - // the existing `url_is_internal` flow decides. - assert!(rewrite_provider_deep_link("zoom", &url("https://zoom.us/j/9819254358")).is_none()); - } - - #[test] - fn rewrite_rejects_unknown_scheme() { - assert!(rewrite_provider_deep_link( - "zoom", - &url("msteams://teams.microsoft.com/l/meetup-join/666") - ) - .is_none()); - } - - // ── is_provider_native_deep_link_scheme: native-app suppression ─── - // - // These guard the workspace-isolation contract from #1074: provider - // native-desktop-app deep-link schemes must NEVER reach the system - // browser, because macOS hands them off to the native provider app - // which then signs the user into the workspace using session tokens - // intended only for the embedded webview (see slack://magic-login - // smoking gun in the #1074 trace). - - #[test] - fn deep_link_scheme_matches_known_provider_native_apps() { - // Slack desktop ("slack://T01.../magic-login/") - assert!(is_provider_native_deep_link_scheme("slack")); - // Discord desktop - assert!(is_provider_native_deep_link_scheme("discord")); - // Telegram desktop ("tg://join?invite=…") - assert!(is_provider_native_deep_link_scheme("tg")); - // Microsoft Teams - assert!(is_provider_native_deep_link_scheme("msteams")); - // Zoom client (both variants registered by the installer) - assert!(is_provider_native_deep_link_scheme("zoomus")); - assert!(is_provider_native_deep_link_scheme("zoommtg")); - } - - #[test] - fn deep_link_scheme_rejects_legitimate_external_schemes() { - // HTTP(S) — the bread-and-butter external link. - assert!(!is_provider_native_deep_link_scheme("https")); - assert!(!is_provider_native_deep_link_scheme("http")); - // Mail clients are legit external — must NOT be suppressed. - assert!(!is_provider_native_deep_link_scheme("mailto")); - // Telephone / sms are legit external too. - assert!(!is_provider_native_deep_link_scheme("tel")); - assert!(!is_provider_native_deep_link_scheme("sms")); - // about: / data: / blob: handled elsewhere; never deep-link. - assert!(!is_provider_native_deep_link_scheme("about")); - assert!(!is_provider_native_deep_link_scheme("data")); - assert!(!is_provider_native_deep_link_scheme("blob")); - // Empty / unrelated string. - assert!(!is_provider_native_deep_link_scheme("")); - assert!(!is_provider_native_deep_link_scheme("file")); - } - - #[test] - fn deep_link_scheme_matches_real_world_slack_magic_login_url() { - // Real slack://-flavoured magic-login URL recorded in the - // #1074 CDP trace. The handler must catch it before - // open_in_system_browser is reached. - let parsed = url("slack://T01CWHNCJ9Z/magic-login/11035712490054-abc"); - assert!(is_provider_native_deep_link_scheme(parsed.scheme())); - } - - #[test] - fn deep_link_scheme_does_not_match_https_app_slack_com() { - // The web-flow URL stays untouched — only the slack:// scheme is - // suppressed; ordinary HTTPS slack navigations route normally. - let parsed = url("https://app.slack.com/client/T01CWHNCJ9Z"); - assert!(!is_provider_native_deep_link_scheme(parsed.scheme())); - } - - /// Locks the contract that zoomus:// stays on the rewrite path - /// (handled by `rewrite_provider_deep_link` for the "zoom" provider) - /// rather than being silently suppressed. - /// - /// The wiring in on_navigation / on_new_window calls - /// `rewrite_provider_deep_link` BEFORE the suppress check, so a - /// rewriteable scheme is rewritten and never reaches the suppress - /// branch. This test pins both halves of that contract: the rewrite - /// still succeeds for zoom, AND the scheme is recognised as a - /// native-app deep-link (so if a future provider config dropped the - /// rewrite, suppression would be the safe fallback rather than - /// leaking to the system browser). - #[test] - fn zoomus_join_still_rewrites_and_is_recognized_as_native_scheme() { - let zoom_url = url("zoomus://zoom.us/join?action=join&confno=9819254358"); - assert!(is_provider_native_deep_link_scheme(zoom_url.scheme())); - let rewritten = rewrite_provider_deep_link("zoom", &zoom_url) - .expect("zoom rewrite should still succeed before suppress branch"); - assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/join/9819254358"); - } - - #[test] - fn rewrite_percent_encodes_reserved_chars_in_pwd() { - // Zoom tokens commonly contain `&` / `=` / `%` / `#` / `+` which - // would corrupt a hand-rolled format!() URL. The `Url`-based - // builder must percent-encode them. - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoomus://zoom.us/join?action=join&confno=777&pwd=a%26b%3Dc"), - ) - .expect("rewrite should succeed"); - // `url::Url` round-trips the encoded `%26` (`&`) and `%3D` (`=`) - // back into the rewritten query. - assert!( - rewritten.as_str().contains("pwd=a%26b%3Dc"), - "expected encoded pwd, got {}", - rewritten.as_str() - ); - } - - #[test] - fn rewrite_percent_encodes_confno_segment() { - // Defensive — path segments never should carry reserved chars but - // the helper must not corrupt them if they do. - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoomus://zoom.us/join?action=join&confno=abc%2Fdef"), - ) - .expect("rewrite should succeed"); - // `/` inside the id must be percent-encoded, not merged into the path. - assert!( - rewritten.path().ends_with("/abc%2Fdef"), - "expected encoded path segment, got {}", - rewritten.path() - ); - } - - // ── popup_should_stay_in_app: Zoom WebClient popups ─────────────── - - #[test] - fn zoom_webclient_popup_stays_in_app() { - assert!(popup_should_stay_in_app( - "zoom", - &url("https://app.zoom.us/wc/join/999") - )); - } - - #[test] - fn zoom_apex_webclient_popup_stays_in_app() { - assert!(popup_should_stay_in_app( - "zoom", - &url("https://zoom.us/wc/join/999") - )); - } - - #[test] - fn zoom_non_wc_popup_does_not_stay_in_app() { - // Marketing / blog / download-link popups should hand off to the - // system browser, not grow an in-app child window. - assert!(!popup_should_stay_in_app( - "zoom", - &url("https://zoom.us/about") - )); - } - - #[test] - fn zoom_popup_to_foreign_host_does_not_stay_in_app() { - assert!(!popup_should_stay_in_app( - "zoom", - &url("https://example.com/wc/join/888") - )); - } - - // ── popup_should_navigate_parent: Google-auth popups ────────────── - - #[test] - fn unsupported_provider_popup_does_not_navigate_parent() { - // Only providers that explicitly support Google SSO opt into - // the popup-takeover path. Every other provider (and any unknown - // string) must fall through to the default popup handling. - assert!(popup_should_navigate_parent( - "discord", - &url("https://accounts.google.com/signin/v2/identifier"), - ) - .is_none()); - assert!(popup_should_navigate_parent( - "whatsapp", - &url("https://accounts.google.com/signin/v2/identifier"), - ) - .is_none()); - assert!(popup_should_navigate_parent( - "unknown-provider", - &url("https://accounts.google.com/signin/v2/identifier"), - ) - .is_none()); - } - - #[test] - fn google_meet_accounts_popup_navigates_parent() { - assert!(popup_should_navigate_parent( - "google-meet", - &url("https://accounts.google.com/signin/v2/identifier"), - ) - .is_some()); - } - - #[test] - fn slack_google_signin_popup_navigates_parent() { - assert_eq!( - popup_should_navigate_parent( - "slack", - &url("https://accounts.google.com/v3/signin/identifier"), - ) - .map(|u| u.to_string()), - Some("https://accounts.google.com/v3/signin/identifier".to_string()) - ); - } - - #[test] - fn slack_about_blank_popup_does_not_navigate_parent() { - assert!(popup_should_navigate_parent("slack", &url("about:blank")).is_none()); - } - - #[test] - fn slack_same_origin_popup_does_not_navigate_parent() { - assert!(popup_should_navigate_parent( - "slack", - &url("https://app.slack.com/client/T123/C456"), - ) - .is_none()); - } - - #[test] - fn slack_unrelated_popup_does_not_navigate_parent() { - assert!(popup_should_navigate_parent("slack", &url("https://example.com/blog"),).is_none()); - } - - #[test] - fn slack_meet_google_com_popup_does_not_navigate_parent() { - assert!(popup_should_navigate_parent( - "slack", - &url("https://meet.google.com/abc-defg-hij"), - ) - .is_none()); - } - - #[test] - fn gmeet_room_popup_navigates_parent() { - // "Start an instant meeting" / "New meeting" calls - // window.open(meet.google.com/) to launch a room. - // Without intervention this would route to system Chrome and - // leak the meeting out of OpenHuman. - assert_eq!( - popup_should_navigate_parent( - "google-meet", - &url("https://meet.google.com/abc-defg-hij"), - ) - .map(|u| u.to_string()), - Some("https://meet.google.com/abc-defg-hij".to_string()) - ); - } - - #[test] - fn gmeet_landing_popup_navigates_parent() { - // Bare meet.google.com (no room code) should also be kept - // in-app — matches the "back to Meet home" UX after hangup. - assert!( - popup_should_navigate_parent("google-meet", &url("https://meet.google.com/"),) - .is_some() - ); - } - - #[test] - fn gmeet_workspace_popup_does_not_navigate_parent() { - // workspace.google.com is the marketing page; if it ever - // arrives via window.open() we let the default external-route - // logic handle it (covered in the on_navigation rewrite path - // separately). - assert!(popup_should_navigate_parent( - "google-meet", - &url("https://workspace.google.com/products/meet/"), - ) - .is_none()); - } - - #[test] - fn gmeet_unrelated_popup_does_not_navigate_parent() { - // External link in the post-call review screen, for instance. - // Should NOT navigate the parent — should fall through to the - // system-browser path. - assert!( - popup_should_navigate_parent("google-meet", &url("https://example.com/blog"),) - .is_none() - ); - } - - // ── provider_supports_google_sso ─────────────────────────────────── - - #[test] - fn provider_supports_google_sso_matrix() { - assert!(provider_supports_google_sso("google-meet")); - assert!(provider_supports_google_sso("slack")); - assert!(provider_supports_google_sso("zoom")); - assert!(provider_supports_google_sso("linkedin")); - assert!(!provider_supports_google_sso("whatsapp")); - assert!(!provider_supports_google_sso("telegram")); - assert!(!provider_supports_google_sso("discord")); - assert!(!provider_supports_google_sso("browserscan")); - assert!(!provider_supports_google_sso("")); - assert!(!provider_supports_google_sso("unknown-provider")); - } - - #[test] - fn google_meet_service_login_popup_navigates_parent() { - assert_eq!( - popup_should_navigate_parent( - "google-meet", - &url("https://accounts.google.com/ServiceLogin?continue=https://meet.google.com"), - ) - .map(|u| u.to_string()), - Some( - "https://accounts.google.com/ServiceLogin?continue=https://meet.google.com" - .to_string() - ) - ); - } - - #[test] - fn redact_navigation_url_strips_query_and_fragment() { - let redacted = redact_navigation_url(&url( - "https://accounts.google.com/o/oauth2/v2/auth?code=secret#frag", - )); - assert_eq!(redacted, "https://accounts.google.com/o/oauth2/v2/auth"); - } - - // ── purge_data_dir_with_retry ────────────────────────────────── - - #[tokio::test] - async fn purge_data_dir_with_retry_noop_when_missing() { - let dir = std::env::temp_dir().join(format!("openhuman-purge-noop-{}", std::process::id())); - // Sanity: dir must NOT exist - let _ = std::fs::remove_dir_all(&dir); - assert!(!dir.exists()); - - // Should return without error or panic. - purge_data_dir_with_retry(&dir) - .await - .expect("missing dir should be treated as success"); - - assert!(!dir.exists()); - } - - #[tokio::test] - async fn purge_data_dir_with_retry_removes_existing_dir() { - let dir = std::env::temp_dir().join(format!( - "openhuman-purge-existing-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(dir.join("nested/dir")).expect("create test dir"); - std::fs::write(dir.join("cookies.json"), b"{\"sid\":\"abc\"}") - .expect("write test cookie file"); - std::fs::write(dir.join("nested/dir/local.storage"), b"key=value") - .expect("write nested file"); - assert!(dir.exists()); - - purge_data_dir_with_retry(&dir) - .await - .expect("existing dir should be removed"); - - assert!(!dir.exists(), "data dir should be removed"); - } - - // ── track_gmeet_marketing_rewrite ────────────────────────────── - - #[test] - fn gmeet_rewrite_allowed_under_threshold() { - let state = WebviewAccountsState::default(); - let label = "acct_test"; - let now = Instant::now(); - for i in 1..=GMEET_REWRITE_MAX_ATTEMPTS { - assert_eq!( - state.track_gmeet_marketing_rewrite(label, now), - GmeetRewriteAction::Rewrite, - "attempt {} should still rewrite", - i - ); - } - } - - #[test] - fn gmeet_rewrite_bails_after_threshold() { - let state = WebviewAccountsState::default(); - let label = "acct_test"; - let now = Instant::now(); - for _ in 0..GMEET_REWRITE_MAX_ATTEMPTS { - let _ = state.track_gmeet_marketing_rewrite(label, now); - } - // Next call exceeds the threshold within the window — must bail. - assert_eq!( - state.track_gmeet_marketing_rewrite(label, now), - GmeetRewriteAction::Bail - ); - } - - #[test] - fn gmeet_rewrite_resets_after_window() { - let state = WebviewAccountsState::default(); - let label = "acct_test"; - let start = Instant::now(); - // Saturate the counter at start. - for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { - let _ = state.track_gmeet_marketing_rewrite(label, start); - } - // After the window expires, a fresh attempt must rewrite again. - let later = start + GMEET_REWRITE_WINDOW + Duration::from_secs(1); - assert_eq!( - state.track_gmeet_marketing_rewrite(label, later), - GmeetRewriteAction::Rewrite - ); - } - - // ── is_google_sso_host ──────────────────────────────────────── - - #[test] - fn google_sso_host_matches_canonical_accounts() { - assert!(is_google_sso_host("accounts.google.com")); - assert!(is_google_sso_host("accounts.googleusercontent.com")); - assert!(is_google_sso_host("accounts.youtube.com")); - assert!(is_google_sso_host("myaccount.google.com")); - } - - #[test] - fn google_sso_host_matches_cctld_variants() { - assert!(is_google_sso_host("accounts.google.co.in")); - assert!(is_google_sso_host("accounts.google.co.uk")); - assert!(is_google_sso_host("accounts.google.de")); - assert!(is_google_sso_host("accounts.google.fr")); - assert!(is_google_sso_host("accounts.google.com.au")); - } - - #[test] - fn google_sso_host_rejects_phishing_alikes() { - // Spoofed hosts that hijack the full domain by prefixing `accounts.google.`. - assert!(!is_google_sso_host("accounts.google.com.evil.tld")); - assert!(!is_google_sso_host("accounts.google.")); - assert!(!is_google_sso_host("accounts.google.com.evil.example.com")); - // Two-label suffix where the second label is NOT a real cctld - // (the dots-only predicate accepted these — CR caught it). - assert!(!is_google_sso_host("accounts.google.com.evil")); - assert!(!is_google_sso_host("accounts.google.co.attacker")); - assert!(!is_google_sso_host("accounts.google.com.attackerlong")); - // Single label that's not a real cctld (3+ chars). - assert!(!is_google_sso_host("accounts.google.evil")); - assert!(!is_google_sso_host("accounts.google.attackerlong")); - // Unknown sld in the 2-label shape — only co/com/net/org allowed. - assert!(!is_google_sso_host("accounts.google.xyz.uk")); - // Unrelated google sub-services that aren't sso surfaces. - assert!(!is_google_sso_host("mail.google.com")); - assert!(!is_google_sso_host("meet.google.com")); - assert!(!is_google_sso_host("workspace.google.com")); - assert!(!is_google_sso_host("evil.com")); - } - - #[test] - fn google_sso_host_case_insensitive() { - assert!(is_google_sso_host("ACCOUNTS.GOOGLE.COM")); - assert!(is_google_sso_host("Accounts.Google.Co.Uk")); - } - - // ── url_is_internal: gmeet SSO coverage ─────────────────────── - - #[test] - fn url_is_internal_allows_youtube_setsid_for_gmeet() { - assert!(url_is_internal( - "google-meet", - &url("https://accounts.youtube.com/accounts/SetSID?ssdc=1&continue=https://meet.google.com/"), - )); - } - - #[test] - fn url_is_internal_allows_youtube_setsid_for_slack_google_sso() { - assert!(url_is_internal( - "slack", - &url("https://accounts.youtube.com/accounts/SetSID?ssdc=1&continue=https://app.slack.com/"), - )); - } - - #[test] - fn url_is_internal_allows_cctld_accounts_google_for_gmail() { - assert!(url_is_internal( - "gmail", - &url("https://accounts.google.co.in/signin/v2/identifier"), - )); - } - - #[test] - fn url_is_internal_blocks_unrelated_youtube_for_gmeet() { - // Plain youtube.com (e.g. video play) MUST stay external for - // gmeet — the SSO bypass only covers `accounts.youtube.com`. - assert!(!url_is_internal( - "google-meet", - &url("https://www.youtube.com/watch?v=abc"), - )); - } - - #[test] - fn gmeet_rewrite_per_label_independent() { - let state = WebviewAccountsState::default(); - let now = Instant::now(); - // Saturate label A — bails next time. - for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { - let _ = state.track_gmeet_marketing_rewrite("acct_a", now); - } - // Label B must still be allowed independently. - assert_eq!( - state.track_gmeet_marketing_rewrite("acct_b", now), - GmeetRewriteAction::Rewrite - ); - } - - // ── is_gmeet_marketing_redirect ──────────────────────────────── - - #[test] - fn gmeet_marketing_match_canonical_paths() { - assert!(is_gmeet_marketing_redirect( - "workspace.google.com", - "/products/meet/" - )); - assert!(is_gmeet_marketing_redirect( - "workspace.google.com", - "/products/meet" - )); - assert!(is_gmeet_marketing_redirect( - "workspace.google.com", - "/products/meet/learn-more" - )); - assert!(is_gmeet_marketing_redirect( - "WORKSPACE.GOOGLE.COM", - "/PRODUCTS/MEET/" - )); - } - - #[test] - fn gmeet_marketing_match_subdomain_workspace() { - assert!(is_gmeet_marketing_redirect( - "support.workspace.google.com", - "/products/meet/faq" - )); - } - - #[test] - fn gmeet_marketing_rejects_other_workspace_paths() { - // Legitimate Workspace pages a user might reach from Meet must NOT - // be hijacked — admin console, Workspace Status, support, etc. - assert!(!is_gmeet_marketing_redirect("workspace.google.com", "/")); - assert!(!is_gmeet_marketing_redirect( - "workspace.google.com", - "/products/calendar/" - )); - assert!(!is_gmeet_marketing_redirect( - "admin.workspace.google.com", - "/ac/users" - )); - assert!(!is_gmeet_marketing_redirect( - "workspace.google.com", - "/status" - )); - assert!(!is_gmeet_marketing_redirect( - "workspace.google.com", - "/products/meeter" - )); - } - - #[test] - fn gmeet_marketing_rejects_non_workspace_hosts() { - assert!(!is_gmeet_marketing_redirect( - "meet.google.com", - "/products/meet/" - )); - assert!(!is_gmeet_marketing_redirect("evil.com", "/products/meet/")); - // Phishing alike: workspace-google.com is NOT workspace.google.com - assert!(!is_gmeet_marketing_redirect( - "workspace-google.com", - "/products/meet/" - )); - } - - #[test] - fn gmeet_clear_marketing_rewrite_drops_counter() { - let state = WebviewAccountsState::default(); - let now = Instant::now(); - for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { - let _ = state.track_gmeet_marketing_rewrite("acct_test", now); - } - // Counter saturated — next call would bail. - assert_eq!( - state.track_gmeet_marketing_rewrite("acct_test", now), - GmeetRewriteAction::Bail - ); - // Clear it — next call within the window starts fresh. - state.clear_gmeet_marketing_rewrite("acct_test"); - assert_eq!( - state.track_gmeet_marketing_rewrite("acct_test", now), - GmeetRewriteAction::Rewrite - ); - } - - #[test] - fn gmeet_handoff_flag_default_is_unset() { - let state = WebviewAccountsState::default(); - assert!(!state.take_awaiting_gmeet_handoff("acct_test")); - } - - #[test] - fn gmeet_handoff_flag_marks_then_consumes_single_shot() { - let state = WebviewAccountsState::default(); - state.mark_awaiting_gmeet_handoff("acct_test"); - // First take returns true. - assert!(state.take_awaiting_gmeet_handoff("acct_test")); - // Second take returns false — single-shot semantics so a later - // user-initiated `myaccount.google.com` visit isn't hijacked. - assert!(!state.take_awaiting_gmeet_handoff("acct_test")); - } - - #[test] - fn gmeet_handoff_flag_is_per_label() { - let state = WebviewAccountsState::default(); - state.mark_awaiting_gmeet_handoff("acct_a"); - // `acct_b` was never marked — must not consume a flag set on `acct_a`. - assert!(!state.take_awaiting_gmeet_handoff("acct_b")); - // `acct_a`'s flag is still pending. - assert!(state.take_awaiting_gmeet_handoff("acct_a")); - } - - #[test] - fn gmeet_handoff_flag_cleared_by_drain_for_shutdown() { - let state = WebviewAccountsState::default(); - state.mark_awaiting_gmeet_handoff("acct_test"); - let _ = state.drain_for_shutdown(); - // Stale flag would hijack the first user-initiated - // `myaccount.google.com` visit after relaunch. - assert!(!state.take_awaiting_gmeet_handoff("acct_test")); - } - - // ── prewarm bookkeeping (issue #1233) ────────────────── - - /// Default state must include an empty `prewarm_accounts` set so - /// fresh boots never spuriously suppress load events. - #[test] - fn prewarm_accounts_default_is_empty() { - let state = WebviewAccountsState::default(); - assert!(state.prewarm_accounts.lock().unwrap().is_empty()); - } - - /// Inserting an id into `prewarm_accounts` and then removing it should - /// leave the set empty — covers the warm-reopen path where the user's - /// first click promotes the prewarmed webview to live. - #[test] - fn prewarm_accounts_insert_then_remove_clears() { - let state = WebviewAccountsState::default(); - state - .prewarm_accounts - .lock() - .unwrap() - .insert("acct-1".to_string()); - assert!(state.prewarm_accounts.lock().unwrap().contains("acct-1")); - state.prewarm_accounts.lock().unwrap().remove("acct-1"); - assert!(!state.prewarm_accounts.lock().unwrap().contains("acct-1")); - } - - /// `drain_for_shutdown` must not leak prewarm flags either — otherwise - /// a relaunch could spuriously suppress the very first cold open. - #[test] - fn prewarm_flag_cleared_by_drain_for_shutdown() { - let state = WebviewAccountsState::default(); - state - .prewarm_accounts - .lock() - .unwrap() - .insert("acct-warm".to_string()); - let _ = state.drain_for_shutdown(); - assert!(state.prewarm_accounts.lock().unwrap().is_empty()); - } -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/app/src-tauri/src/webview_accounts/mod_tests.rs b/app/src-tauri/src/webview_accounts/mod_tests.rs new file mode 100644 index 000000000..09ec68fde --- /dev/null +++ b/app/src-tauri/src/webview_accounts/mod_tests.rs @@ -0,0 +1,1196 @@ +use super::*; + +fn url(s: &str) -> Url { + Url::parse(s).expect("valid url") +} + +#[test] +fn reveal_trigger_from_ipc_warns_and_defaults_unknown_to_load() { + assert_eq!(RevealTrigger::from_ipc(None), RevealTrigger::Load); + assert_eq!(RevealTrigger::from_ipc(Some("load")), RevealTrigger::Load); + assert_eq!( + RevealTrigger::from_ipc(Some("watchdog")), + RevealTrigger::Watchdog + ); + assert_eq!( + RevealTrigger::from_ipc(Some("watchdog-typo")), + RevealTrigger::Load + ); +} + +// ── shutdown teardown ────────────────────────────────── + +/// Smoke-test [`WebviewAccountsState::drain_for_shutdown`] in isolation +/// from the Tauri runtime. Populates the state with representative +/// per-account resources (CDP / watchdog `JoinHandle`s, a CEF browser +/// id, an `acct_*` label, plus the small bookkeeping sets) and asserts +/// that one call drains every collection and aborts the long-running +/// tasks, that the returned label list is what `shutdown_all` will +/// `wv.close()` against, and that a second call is a safe no-op. +/// +/// `shutdown_all` itself takes an `AppHandle` and is exercised end-to- +/// end at runtime; the inner `drain_for_shutdown` covers the part of +/// the teardown that doesn't need a Tauri runtime to verify. +#[tokio::test] +async fn drain_for_shutdown_clears_state_and_repeat_is_noop() { + use std::time::Duration; + + let state = WebviewAccountsState::default(); + + let cdp_task = tokio::spawn(async { + tokio::time::sleep(Duration::from_secs(60)).await; + }); + let cdp_abort = cdp_task.abort_handle(); + let watchdog_task = tokio::spawn(async { + tokio::time::sleep(Duration::from_secs(60)).await; + }); + let watchdog_abort = watchdog_task.abort_handle(); + + state + .cdp_sessions + .lock() + .unwrap() + .insert("acct-1".into(), cdp_task); + state + .load_watchdogs + .lock() + .unwrap() + .insert("acct-1".into(), watchdog_task); + state + .browser_ids + .lock() + .unwrap() + .insert("acct-1".into(), 42); + state + .inner + .lock() + .unwrap() + .insert("acct-1".into(), "acct_1".into()); + state + .account_providers + .lock() + .unwrap() + .insert("acct-1".into(), "slack".into()); + state + .loaded_accounts + .lock() + .unwrap() + .insert("acct-1".into()); + state.requested_bounds.lock().unwrap().insert( + "acct-1".into(), + Bounds { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }, + ); + state + .spawn_started_at + .lock() + .unwrap() + .insert("acct-1".into(), Instant::now()); + // Saturate the gmeet rewrite counter so we can assert it gets + // cleared by drain (otherwise the next reopen would inherit a + // stale entry — `label_for()` reuses the same label). + for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { + let _ = state.track_gmeet_marketing_rewrite("acct_1", Instant::now()); + } + assert!(!state.gmeet_marketing_rewrites.lock().unwrap().is_empty()); + + let labels = state.drain_for_shutdown(); + tokio::task::yield_now().await; + + assert_eq!( + labels, + vec![("acct-1".to_string(), "acct_1".to_string())], + "shutdown_all should close the acct_* webview returned here" + ); + assert!(cdp_abort.is_finished(), "CDP session task was aborted"); + assert!( + watchdog_abort.is_finished(), + "load watchdog task was aborted" + ); + assert!(state.cdp_sessions.lock().unwrap().is_empty()); + assert!(state.load_watchdogs.lock().unwrap().is_empty()); + assert!(state.browser_ids.lock().unwrap().is_empty()); + assert!(state.inner.lock().unwrap().is_empty()); + assert!(state.account_providers.lock().unwrap().is_empty()); + assert!(state.loaded_accounts.lock().unwrap().is_empty()); + assert!(state.requested_bounds.lock().unwrap().is_empty()); + assert!(state.spawn_started_at.lock().unwrap().is_empty()); + assert!( + state.gmeet_marketing_rewrites.lock().unwrap().is_empty(), + "gmeet rewrite counter must clear on drain so reopens don't inherit stale entries" + ); + + // Second call must be a safe no-op: nothing left to drain. + let labels2 = state.drain_for_shutdown(); + assert!(labels2.is_empty()); + assert!(state.cdp_sessions.lock().unwrap().is_empty()); + assert!(state.inner.lock().unwrap().is_empty()); + assert!(state.account_providers.lock().unwrap().is_empty()); +} + +// ── provider registry match arms ────────────────────────────────── + +#[test] +fn zoom_registered_in_provider_url() { + assert_eq!(provider_url("zoom"), Some("https://zoom.us/")); +} + +#[test] +fn wechat_registered_in_provider_url() { + assert_eq!(provider_url("wechat"), Some("https://web.wechat.com/")); +} + +#[test] +fn wechat_has_no_recipe_js_injection() { + assert!(provider_recipe_js("wechat").is_none()); +} + +#[test] +fn wechat_allowed_hosts_cover_web_and_login_domains() { + let hosts = provider_allowed_hosts("wechat"); + assert!(hosts.contains(&"wechat.com"), "wechat.com in allowlist"); + assert!(hosts.contains(&"wx.qq.com"), "wx.qq.com in allowlist"); + assert!( + hosts.contains(&"login.weixin.qq.com"), + "login.weixin.qq.com in allowlist" + ); +} + +#[test] +fn zoom_has_no_recipe_js_injection() { + // Per the CLAUDE.md "no new JS injection" rule for CEF child + // webviews, Zoom must rely solely on Rust `on_navigation` + + // `on_new_window` (plus CDP from scanner modules, if any) — no + // `recipe.js` should be registered. + assert!(provider_recipe_js("zoom").is_none()); +} + +#[test] +fn zoom_allowed_hosts_covers_core_domains() { + let hosts = provider_allowed_hosts("zoom"); + assert!(hosts.contains(&"zoom.us"), "zoom.us in allowlist"); + assert!(hosts.contains(&"zoomgov.com"), "zoomgov.com in allowlist"); + assert!(hosts.contains(&"zdassets.com"), "zdassets.com in allowlist"); +} + +#[test] +fn zoom_allowed_hosts_covers_google_oauth() { + // Zoom's "Sign in with Google" reroutes the popup into the + // embedded webview (see popup_should_navigate_parent). The + // resulting accounts.google.com / oauth2.googleapis.com / + // www.googleapis.com hops MUST be classified internal so the + // auth chain doesn't escape to the system browser mid-flight + // and trigger Zoom error 300 (#1294). + assert!(url_is_internal( + "zoom", + &url("https://accounts.google.com/v3/signin/identifier"), + )); + assert!(url_is_internal( + "zoom", + &url("https://oauth2.googleapis.com/token"), + )); + assert!(url_is_internal( + "zoom", + &url("https://www.googleapis.com/oauth2/v3/userinfo"), + )); +} + +#[test] +fn zoom_supports_google_sso() { + // Zoom's web client offers "Sign in with Google" via a popup + // window.open("https://accounts.google.com/..."). The popup + // gate at popup_should_navigate_parent gates on this helper — + // without zoom listed the popup falls through to the system + // browser and breaks the auth callback (#1294). + assert!(provider_supports_google_sso("zoom")); +} + +#[test] +fn zoom_popup_navigates_parent_for_google_sso() { + // Mirror of slack_google_signin_popup_navigates_parent — + // clicking "Sign in with Google" inside Zoom MUST replace the + // parent webview's URL instead of escaping to the system + // browser, so the Google session cookie lands in the per-account + // CEF profile (#1294). + assert_eq!( + popup_should_navigate_parent( + "zoom", + &url("https://accounts.google.com/v3/signin/identifier"), + ) + .map(|u| u.to_string()), + Some("https://accounts.google.com/v3/signin/identifier".to_string()) + ); +} + +// ── LinkedIn Google SSO (issue #1021) ────────────────────────────── + +#[test] +fn linkedin_supports_google_sso() { + // LinkedIn's "Sign in with Google" button must be handled in-app; + // without linkedin in provider_supports_google_sso the popup falls + // through to the system browser, which opens blank (#1021). + assert!(provider_supports_google_sso("linkedin")); +} + +#[test] +fn linkedin_allowed_hosts_cover_google_oauth() { + // Google auth chain hops through oauth2.googleapis.com and + // www.googleapis.com which are not Google SSO hosts and must be + // present in the explicit allowlist so mid-flight redirects don't + // leak to the system browser. + let hosts = provider_allowed_hosts("linkedin"); + for host in [ + "accounts.google.com", + "accounts.googleusercontent.com", + "ssl.gstatic.com", + "fonts.gstatic.com", + "lh3.googleusercontent.com", + "oauth2.googleapis.com", + "www.googleapis.com", + ] { + assert!(hosts.contains(&host), "{host} in LinkedIn allowlist"); + } +} + +#[test] +fn linkedin_google_signin_popup_navigates_parent() { + // Clicking "Sign in with Google" on LinkedIn's login page issues a + // window.open to accounts.google.com/signin/... — must navigate the + // parent in-app instead of opening the system browser (#1021). + assert_eq!( + popup_should_navigate_parent( + "linkedin", + &url("https://accounts.google.com/v3/signin/identifier"), + ) + .map(|u| u.to_string()), + Some("https://accounts.google.com/v3/signin/identifier".to_string()) + ); +} + +#[test] +fn linkedin_google_oauth2_popup_navigates_parent() { + // LinkedIn may issue window.open to the initial OAuth2 auth + // endpoint (/o/oauth2/v2/auth) which doesn't contain "signin" + // in the path — must still be caught and routed in-app (#1021). + assert!(popup_should_navigate_parent( + "linkedin", + &url("https://accounts.google.com/o/oauth2/v2/auth?client_id=x&redirect_uri=https://www.linkedin.com/..."), + ) + .is_some()); +} + +#[test] +fn linkedin_google_sso_navigation_is_internal() { + // Direct (non-popup) navigation to accounts.google.com during the + // LinkedIn Google SSO flow must be classified internal so it stays + // in the embedded webview. + assert!(url_is_internal( + "linkedin", + &url("https://accounts.google.com/v3/signin/identifier"), + )); + assert!(url_is_internal( + "linkedin", + &url("https://accounts.youtube.com/accounts/SetSID?..."), + )); +} + +#[test] +fn linkedin_own_domain_still_internal() { + assert!(url_is_internal( + "linkedin", + &url("https://www.linkedin.com/messaging/"), + )); + assert!(url_is_internal( + "linkedin", + &url("https://media.licdn.com/dms/image/foo.jpg"), + )); +} + +#[test] +fn linkedin_unrelated_popup_still_goes_to_system_browser() { + // Non-Google external links from LinkedIn must still route out. + assert!(popup_should_navigate_parent("linkedin", &url("https://example.com/blog"),).is_none()); + assert!(!popup_should_stay_in_app( + "linkedin", + &url("https://example.com/blog"), + )); +} + +#[test] +fn linkedin_gsi_popup_stays_in_app() { + // LinkedIn's "Sign in with Google" uses the Google Identity Services + // (GSI) library. The GSI button iframe (accounts.google.com/gsi/button) + // calls window.open("accounts.google.com/gsi/select?...") to show the + // account chooser. This popup must be an in-app child window — NOT sent + // to the system browser (blank screen) and NOT a parent navigation (the + // postMessage credential callback would have no opener to reach) (#1021). + assert!(popup_should_stay_in_app( + "linkedin", + &url("https://accounts.google.com/gsi/select?client_id=990339570472-k6nq&ux_mode=popup"), + )); + assert!(popup_should_stay_in_app( + "linkedin", + &url("https://accounts.google.com/gsi/issue?client_id=x"), + )); +} + +#[test] +fn linkedin_gsi_popup_does_not_navigate_parent() { + // The GSI account-chooser popup must NOT navigate the parent — it needs + // to remain a child popup for postMessage to work. + assert!(popup_should_navigate_parent( + "linkedin", + &url("https://accounts.google.com/gsi/select?client_id=x"), + ) + .is_none()); +} + +#[test] +fn slack_allowed_hosts_include_google_oauth() { + let hosts = provider_allowed_hosts("slack"); + for host in [ + "accounts.google.com", + "accounts.googleusercontent.com", + "ssl.gstatic.com", + "fonts.gstatic.com", + "lh3.googleusercontent.com", + "oauth2.googleapis.com", + "www.googleapis.com", + ] { + assert!(hosts.contains(&host), "{host} in Slack allowlist"); + } +} + +#[test] +fn slack_allowed_hosts_still_internal_for_slack_origins() { + assert!(url_is_internal( + "slack", + &url("https://app.slack.com/client/T123/C456"), + )); + assert!(url_is_internal( + "slack", + &url("https://a.slack-edge.com/bv1/app.js"), + )); + assert!(url_is_internal( + "slack", + &url("https://wss-primary.slack.com/?ticket=redacted"), + )); +} + +#[test] +fn slack_allowed_hosts_do_not_bare_allow_google() { + let hosts = provider_allowed_hosts("slack"); + assert!( + !hosts.contains(&"google.com"), + "bare google.com not allowed" + ); + assert!(!hosts.contains(&"googleusercontent.com")); + assert!(!hosts.contains(&"gstatic.com")); + assert!(!hosts.contains(&"googleapis.com")); + + assert!(url_is_internal( + "slack", + &url("https://accounts.google.com/v3/signin/identifier"), + )); + assert!(!url_is_internal("slack", &url("https://google.com/"))); + assert!(!url_is_internal("slack", &url("https://mail.google.com/"))); + assert!(!url_is_internal("slack", &url("https://apis.google.com/"))); +} + +#[test] +fn zoom_is_supported() { + assert!(provider_is_supported("zoom")); +} + +// ── url_is_internal: subdomain + exact match ────────────────────── + +#[test] +fn zoom_web_client_subdomain_is_internal() { + assert!(url_is_internal( + "zoom", + &url("https://app.zoom.us/wc/join/123") + )); +} + +#[test] +fn zoom_apex_domain_is_internal() { + assert!(url_is_internal("zoom", &url("https://zoom.us/signin"))); +} + +#[test] +fn zoom_external_host_is_not_internal() { + assert!(!url_is_internal( + "zoom", + &url("https://unrelated.example.com/") + )); +} + +// ── rewrite_provider_deep_link: Zoom flows ──────────────────────── + +#[test] +fn rewrite_join_flow_with_confno() { + let rewritten = rewrite_provider_deep_link( + "zoom", + &url("zoomus://zoom.us/join?action=join&confno=9819254358"), + ) + .expect("rewrite should succeed"); + assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/join/9819254358"); +} + +#[test] +fn rewrite_start_flow_with_confno() { + let rewritten = rewrite_provider_deep_link( + "zoom", + &url("zoomus://zoom.us/start?action=start&confno=86449940711"), + ) + .expect("rewrite should succeed"); + assert_eq!( + rewritten.as_str(), + "https://app.zoom.us/wc/join/86449940711" + ); +} + +#[test] +fn rewrite_preserves_pwd_query_param() { + let rewritten = rewrite_provider_deep_link( + "zoom", + &url("zoomus://zoom.us/join?action=join&confno=111&pwd=secret"), + ) + .expect("rewrite should succeed"); + assert_eq!( + rewritten.as_str(), + "https://app.zoom.us/wc/join/111?pwd=secret" + ); +} + +#[test] +fn rewrite_falls_back_to_tk_when_pwd_absent() { + let rewritten = rewrite_provider_deep_link( + "zoom", + &url("zoommtg://zoom.us/join?confno=222&tk=tokenvalue"), + ) + .expect("rewrite should succeed"); + assert_eq!( + rewritten.as_str(), + "https://app.zoom.us/wc/join/222?pwd=tokenvalue" + ); +} + +#[test] +fn rewrite_accepts_zoommtg_scheme() { + let rewritten = rewrite_provider_deep_link( + "zoom", + &url("zoommtg://zoom.us/join?action=join&confno=333"), + ) + .expect("rewrite should succeed"); + assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/join/333"); +} + +#[test] +fn rewrite_without_confno_falls_back_to_home() { + let rewritten = rewrite_provider_deep_link("zoom", &url("zoomus://zoom.us/home?action=home")) + .expect("rewrite should succeed"); + assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/home"); +} + +#[test] +fn rewrite_with_empty_confno_falls_back_to_home() { + let rewritten = + rewrite_provider_deep_link("zoom", &url("zoomus://zoom.us/join?action=join&confno=")) + .expect("rewrite should succeed"); + assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/home"); +} + +#[test] +fn rewrite_rejects_non_zoom_provider() { + assert!(rewrite_provider_deep_link( + "slack", + &url("zoomus://zoom.us/join?action=join&confno=444") + ) + .is_none()); + assert!(rewrite_provider_deep_link( + "google-meet", + &url("zoomus://zoom.us/join?action=join&confno=555") + ) + .is_none()); +} + +#[test] +fn rewrite_rejects_http_zoom_url() { + // Ordinary https zoom.us navigations must pass through untouched so + // the existing `url_is_internal` flow decides. + assert!(rewrite_provider_deep_link("zoom", &url("https://zoom.us/j/9819254358")).is_none()); +} + +#[test] +fn rewrite_rejects_unknown_scheme() { + assert!(rewrite_provider_deep_link( + "zoom", + &url("msteams://teams.microsoft.com/l/meetup-join/666") + ) + .is_none()); +} + +// ── is_provider_native_deep_link_scheme: native-app suppression ─── +// +// These guard the workspace-isolation contract from #1074: provider +// native-desktop-app deep-link schemes must NEVER reach the system +// browser, because macOS hands them off to the native provider app +// which then signs the user into the workspace using session tokens +// intended only for the embedded webview (see slack://magic-login +// smoking gun in the #1074 trace). + +#[test] +fn deep_link_scheme_matches_known_provider_native_apps() { + // Slack desktop ("slack://T01.../magic-login/") + assert!(is_provider_native_deep_link_scheme("slack")); + // Discord desktop + assert!(is_provider_native_deep_link_scheme("discord")); + // Telegram desktop ("tg://join?invite=…") + assert!(is_provider_native_deep_link_scheme("tg")); + // Microsoft Teams + assert!(is_provider_native_deep_link_scheme("msteams")); + // Zoom client (both variants registered by the installer) + assert!(is_provider_native_deep_link_scheme("zoomus")); + assert!(is_provider_native_deep_link_scheme("zoommtg")); +} + +#[test] +fn deep_link_scheme_rejects_legitimate_external_schemes() { + // HTTP(S) — the bread-and-butter external link. + assert!(!is_provider_native_deep_link_scheme("https")); + assert!(!is_provider_native_deep_link_scheme("http")); + // Mail clients are legit external — must NOT be suppressed. + assert!(!is_provider_native_deep_link_scheme("mailto")); + // Telephone / sms are legit external too. + assert!(!is_provider_native_deep_link_scheme("tel")); + assert!(!is_provider_native_deep_link_scheme("sms")); + // about: / data: / blob: handled elsewhere; never deep-link. + assert!(!is_provider_native_deep_link_scheme("about")); + assert!(!is_provider_native_deep_link_scheme("data")); + assert!(!is_provider_native_deep_link_scheme("blob")); + // Empty / unrelated string. + assert!(!is_provider_native_deep_link_scheme("")); + assert!(!is_provider_native_deep_link_scheme("file")); +} + +#[test] +fn deep_link_scheme_matches_real_world_slack_magic_login_url() { + // Real slack://-flavoured magic-login URL recorded in the + // #1074 CDP trace. The handler must catch it before + // open_in_system_browser is reached. + let parsed = url("slack://T01CWHNCJ9Z/magic-login/11035712490054-abc"); + assert!(is_provider_native_deep_link_scheme(parsed.scheme())); +} + +#[test] +fn deep_link_scheme_does_not_match_https_app_slack_com() { + // The web-flow URL stays untouched — only the slack:// scheme is + // suppressed; ordinary HTTPS slack navigations route normally. + let parsed = url("https://app.slack.com/client/T01CWHNCJ9Z"); + assert!(!is_provider_native_deep_link_scheme(parsed.scheme())); +} + +/// Locks the contract that zoomus:// stays on the rewrite path +/// (handled by `rewrite_provider_deep_link` for the "zoom" provider) +/// rather than being silently suppressed. +/// +/// The wiring in on_navigation / on_new_window calls +/// `rewrite_provider_deep_link` BEFORE the suppress check, so a +/// rewriteable scheme is rewritten and never reaches the suppress +/// branch. This test pins both halves of that contract: the rewrite +/// still succeeds for zoom, AND the scheme is recognised as a +/// native-app deep-link (so if a future provider config dropped the +/// rewrite, suppression would be the safe fallback rather than +/// leaking to the system browser). +#[test] +fn zoomus_join_still_rewrites_and_is_recognized_as_native_scheme() { + let zoom_url = url("zoomus://zoom.us/join?action=join&confno=9819254358"); + assert!(is_provider_native_deep_link_scheme(zoom_url.scheme())); + let rewritten = rewrite_provider_deep_link("zoom", &zoom_url) + .expect("zoom rewrite should still succeed before suppress branch"); + assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/join/9819254358"); +} + +#[test] +fn rewrite_percent_encodes_reserved_chars_in_pwd() { + // Zoom tokens commonly contain `&` / `=` / `%` / `#` / `+` which + // would corrupt a hand-rolled format!() URL. The `Url`-based + // builder must percent-encode them. + let rewritten = rewrite_provider_deep_link( + "zoom", + &url("zoomus://zoom.us/join?action=join&confno=777&pwd=a%26b%3Dc"), + ) + .expect("rewrite should succeed"); + // `url::Url` round-trips the encoded `%26` (`&`) and `%3D` (`=`) + // back into the rewritten query. + assert!( + rewritten.as_str().contains("pwd=a%26b%3Dc"), + "expected encoded pwd, got {}", + rewritten.as_str() + ); +} + +#[test] +fn rewrite_percent_encodes_confno_segment() { + // Defensive — path segments never should carry reserved chars but + // the helper must not corrupt them if they do. + let rewritten = rewrite_provider_deep_link( + "zoom", + &url("zoomus://zoom.us/join?action=join&confno=abc%2Fdef"), + ) + .expect("rewrite should succeed"); + // `/` inside the id must be percent-encoded, not merged into the path. + assert!( + rewritten.path().ends_with("/abc%2Fdef"), + "expected encoded path segment, got {}", + rewritten.path() + ); +} + +// ── popup_should_stay_in_app: Zoom WebClient popups ─────────────── + +#[test] +fn zoom_webclient_popup_stays_in_app() { + assert!(popup_should_stay_in_app( + "zoom", + &url("https://app.zoom.us/wc/join/999") + )); +} + +#[test] +fn zoom_apex_webclient_popup_stays_in_app() { + assert!(popup_should_stay_in_app( + "zoom", + &url("https://zoom.us/wc/join/999") + )); +} + +#[test] +fn zoom_non_wc_popup_does_not_stay_in_app() { + // Marketing / blog / download-link popups should hand off to the + // system browser, not grow an in-app child window. + assert!(!popup_should_stay_in_app( + "zoom", + &url("https://zoom.us/about") + )); +} + +#[test] +fn zoom_popup_to_foreign_host_does_not_stay_in_app() { + assert!(!popup_should_stay_in_app( + "zoom", + &url("https://example.com/wc/join/888") + )); +} + +// ── popup_should_navigate_parent: Google-auth popups ────────────── + +#[test] +fn unsupported_provider_popup_does_not_navigate_parent() { + // Only providers that explicitly support Google SSO opt into + // the popup-takeover path. Every other provider (and any unknown + // string) must fall through to the default popup handling. + assert!(popup_should_navigate_parent( + "discord", + &url("https://accounts.google.com/signin/v2/identifier"), + ) + .is_none()); + assert!(popup_should_navigate_parent( + "whatsapp", + &url("https://accounts.google.com/signin/v2/identifier"), + ) + .is_none()); + assert!(popup_should_navigate_parent( + "unknown-provider", + &url("https://accounts.google.com/signin/v2/identifier"), + ) + .is_none()); +} + +#[test] +fn google_meet_accounts_popup_navigates_parent() { + assert!(popup_should_navigate_parent( + "google-meet", + &url("https://accounts.google.com/signin/v2/identifier"), + ) + .is_some()); +} + +#[test] +fn slack_google_signin_popup_navigates_parent() { + assert_eq!( + popup_should_navigate_parent( + "slack", + &url("https://accounts.google.com/v3/signin/identifier"), + ) + .map(|u| u.to_string()), + Some("https://accounts.google.com/v3/signin/identifier".to_string()) + ); +} + +#[test] +fn slack_about_blank_popup_does_not_navigate_parent() { + assert!(popup_should_navigate_parent("slack", &url("about:blank")).is_none()); +} + +#[test] +fn slack_same_origin_popup_does_not_navigate_parent() { + assert!( + popup_should_navigate_parent("slack", &url("https://app.slack.com/client/T123/C456"),) + .is_none() + ); +} + +#[test] +fn slack_unrelated_popup_does_not_navigate_parent() { + assert!(popup_should_navigate_parent("slack", &url("https://example.com/blog"),).is_none()); +} + +#[test] +fn slack_meet_google_com_popup_does_not_navigate_parent() { + assert!( + popup_should_navigate_parent("slack", &url("https://meet.google.com/abc-defg-hij"),) + .is_none() + ); +} + +#[test] +fn gmeet_room_popup_navigates_parent() { + // "Start an instant meeting" / "New meeting" calls + // window.open(meet.google.com/) to launch a room. + // Without intervention this would route to system Chrome and + // leak the meeting out of OpenHuman. + assert_eq!( + popup_should_navigate_parent("google-meet", &url("https://meet.google.com/abc-defg-hij"),) + .map(|u| u.to_string()), + Some("https://meet.google.com/abc-defg-hij".to_string()) + ); +} + +#[test] +fn gmeet_landing_popup_navigates_parent() { + // Bare meet.google.com (no room code) should also be kept + // in-app — matches the "back to Meet home" UX after hangup. + assert!( + popup_should_navigate_parent("google-meet", &url("https://meet.google.com/"),).is_some() + ); +} + +#[test] +fn gmeet_workspace_popup_does_not_navigate_parent() { + // workspace.google.com is the marketing page; if it ever + // arrives via window.open() we let the default external-route + // logic handle it (covered in the on_navigation rewrite path + // separately). + assert!(popup_should_navigate_parent( + "google-meet", + &url("https://workspace.google.com/products/meet/"), + ) + .is_none()); +} + +#[test] +fn gmeet_unrelated_popup_does_not_navigate_parent() { + // External link in the post-call review screen, for instance. + // Should NOT navigate the parent — should fall through to the + // system-browser path. + assert!( + popup_should_navigate_parent("google-meet", &url("https://example.com/blog"),).is_none() + ); +} + +// ── provider_supports_google_sso ─────────────────────────────────── + +#[test] +fn provider_supports_google_sso_matrix() { + assert!(provider_supports_google_sso("google-meet")); + assert!(provider_supports_google_sso("slack")); + assert!(provider_supports_google_sso("zoom")); + assert!(provider_supports_google_sso("linkedin")); + assert!(!provider_supports_google_sso("whatsapp")); + assert!(!provider_supports_google_sso("telegram")); + assert!(!provider_supports_google_sso("discord")); + assert!(!provider_supports_google_sso("browserscan")); + assert!(!provider_supports_google_sso("")); + assert!(!provider_supports_google_sso("unknown-provider")); +} + +#[test] +fn google_meet_service_login_popup_navigates_parent() { + assert_eq!( + popup_should_navigate_parent( + "google-meet", + &url("https://accounts.google.com/ServiceLogin?continue=https://meet.google.com"), + ) + .map(|u| u.to_string()), + Some( + "https://accounts.google.com/ServiceLogin?continue=https://meet.google.com".to_string() + ) + ); +} + +#[test] +fn redact_navigation_url_strips_query_and_fragment() { + let redacted = redact_navigation_url(&url( + "https://accounts.google.com/o/oauth2/v2/auth?code=secret#frag", + )); + assert_eq!(redacted, "https://accounts.google.com/o/oauth2/v2/auth"); +} + +// ── purge_data_dir_with_retry ────────────────────────────────── + +#[tokio::test] +async fn purge_data_dir_with_retry_noop_when_missing() { + let dir = std::env::temp_dir().join(format!("openhuman-purge-noop-{}", std::process::id())); + // Sanity: dir must NOT exist + let _ = std::fs::remove_dir_all(&dir); + assert!(!dir.exists()); + + // Should return without error or panic. + purge_data_dir_with_retry(&dir) + .await + .expect("missing dir should be treated as success"); + + assert!(!dir.exists()); +} + +#[tokio::test] +async fn purge_data_dir_with_retry_removes_existing_dir() { + let dir = std::env::temp_dir().join(format!( + "openhuman-purge-existing-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(dir.join("nested/dir")).expect("create test dir"); + std::fs::write(dir.join("cookies.json"), b"{\"sid\":\"abc\"}").expect("write test cookie file"); + std::fs::write(dir.join("nested/dir/local.storage"), b"key=value").expect("write nested file"); + assert!(dir.exists()); + + purge_data_dir_with_retry(&dir) + .await + .expect("existing dir should be removed"); + + assert!(!dir.exists(), "data dir should be removed"); +} + +// ── track_gmeet_marketing_rewrite ────────────────────────────── + +#[test] +fn gmeet_rewrite_allowed_under_threshold() { + let state = WebviewAccountsState::default(); + let label = "acct_test"; + let now = Instant::now(); + for i in 1..=GMEET_REWRITE_MAX_ATTEMPTS { + assert_eq!( + state.track_gmeet_marketing_rewrite(label, now), + GmeetRewriteAction::Rewrite, + "attempt {} should still rewrite", + i + ); + } +} + +#[test] +fn gmeet_rewrite_bails_after_threshold() { + let state = WebviewAccountsState::default(); + let label = "acct_test"; + let now = Instant::now(); + for _ in 0..GMEET_REWRITE_MAX_ATTEMPTS { + let _ = state.track_gmeet_marketing_rewrite(label, now); + } + // Next call exceeds the threshold within the window — must bail. + assert_eq!( + state.track_gmeet_marketing_rewrite(label, now), + GmeetRewriteAction::Bail + ); +} + +#[test] +fn gmeet_rewrite_resets_after_window() { + let state = WebviewAccountsState::default(); + let label = "acct_test"; + let start = Instant::now(); + // Saturate the counter at start. + for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { + let _ = state.track_gmeet_marketing_rewrite(label, start); + } + // After the window expires, a fresh attempt must rewrite again. + let later = start + GMEET_REWRITE_WINDOW + Duration::from_secs(1); + assert_eq!( + state.track_gmeet_marketing_rewrite(label, later), + GmeetRewriteAction::Rewrite + ); +} + +// ── is_google_sso_host ──────────────────────────────────────── + +#[test] +fn google_sso_host_matches_canonical_accounts() { + assert!(is_google_sso_host("accounts.google.com")); + assert!(is_google_sso_host("accounts.googleusercontent.com")); + assert!(is_google_sso_host("accounts.youtube.com")); + assert!(is_google_sso_host("myaccount.google.com")); +} + +#[test] +fn google_sso_host_matches_cctld_variants() { + assert!(is_google_sso_host("accounts.google.co.in")); + assert!(is_google_sso_host("accounts.google.co.uk")); + assert!(is_google_sso_host("accounts.google.de")); + assert!(is_google_sso_host("accounts.google.fr")); + assert!(is_google_sso_host("accounts.google.com.au")); +} + +#[test] +fn google_sso_host_rejects_phishing_alikes() { + // Spoofed hosts that hijack the full domain by prefixing `accounts.google.`. + assert!(!is_google_sso_host("accounts.google.com.evil.tld")); + assert!(!is_google_sso_host("accounts.google.")); + assert!(!is_google_sso_host("accounts.google.com.evil.example.com")); + // Two-label suffix where the second label is NOT a real cctld + // (the dots-only predicate accepted these — CR caught it). + assert!(!is_google_sso_host("accounts.google.com.evil")); + assert!(!is_google_sso_host("accounts.google.co.attacker")); + assert!(!is_google_sso_host("accounts.google.com.attackerlong")); + // Single label that's not a real cctld (3+ chars). + assert!(!is_google_sso_host("accounts.google.evil")); + assert!(!is_google_sso_host("accounts.google.attackerlong")); + // Unknown sld in the 2-label shape — only co/com/net/org allowed. + assert!(!is_google_sso_host("accounts.google.xyz.uk")); + // Unrelated google sub-services that aren't sso surfaces. + assert!(!is_google_sso_host("mail.google.com")); + assert!(!is_google_sso_host("meet.google.com")); + assert!(!is_google_sso_host("workspace.google.com")); + assert!(!is_google_sso_host("evil.com")); +} + +#[test] +fn google_sso_host_case_insensitive() { + assert!(is_google_sso_host("ACCOUNTS.GOOGLE.COM")); + assert!(is_google_sso_host("Accounts.Google.Co.Uk")); +} + +// ── url_is_internal: gmeet SSO coverage ─────────────────────── + +#[test] +fn url_is_internal_allows_youtube_setsid_for_gmeet() { + assert!(url_is_internal( + "google-meet", + &url( + "https://accounts.youtube.com/accounts/SetSID?ssdc=1&continue=https://meet.google.com/" + ), + )); +} + +#[test] +fn url_is_internal_allows_youtube_setsid_for_slack_google_sso() { + assert!(url_is_internal( + "slack", + &url("https://accounts.youtube.com/accounts/SetSID?ssdc=1&continue=https://app.slack.com/"), + )); +} + +#[test] +fn url_is_internal_allows_cctld_accounts_google_for_gmail() { + assert!(url_is_internal( + "gmail", + &url("https://accounts.google.co.in/signin/v2/identifier"), + )); +} + +#[test] +fn url_is_internal_blocks_unrelated_youtube_for_gmeet() { + // Plain youtube.com (e.g. video play) MUST stay external for + // gmeet — the SSO bypass only covers `accounts.youtube.com`. + assert!(!url_is_internal( + "google-meet", + &url("https://www.youtube.com/watch?v=abc"), + )); +} + +#[test] +fn gmeet_rewrite_per_label_independent() { + let state = WebviewAccountsState::default(); + let now = Instant::now(); + // Saturate label A — bails next time. + for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { + let _ = state.track_gmeet_marketing_rewrite("acct_a", now); + } + // Label B must still be allowed independently. + assert_eq!( + state.track_gmeet_marketing_rewrite("acct_b", now), + GmeetRewriteAction::Rewrite + ); +} + +// ── is_gmeet_marketing_redirect ──────────────────────────────── + +#[test] +fn gmeet_marketing_match_canonical_paths() { + assert!(is_gmeet_marketing_redirect( + "workspace.google.com", + "/products/meet/" + )); + assert!(is_gmeet_marketing_redirect( + "workspace.google.com", + "/products/meet" + )); + assert!(is_gmeet_marketing_redirect( + "workspace.google.com", + "/products/meet/learn-more" + )); + assert!(is_gmeet_marketing_redirect( + "WORKSPACE.GOOGLE.COM", + "/PRODUCTS/MEET/" + )); +} + +#[test] +fn gmeet_marketing_match_subdomain_workspace() { + assert!(is_gmeet_marketing_redirect( + "support.workspace.google.com", + "/products/meet/faq" + )); +} + +#[test] +fn gmeet_marketing_rejects_other_workspace_paths() { + // Legitimate Workspace pages a user might reach from Meet must NOT + // be hijacked — admin console, Workspace Status, support, etc. + assert!(!is_gmeet_marketing_redirect("workspace.google.com", "/")); + assert!(!is_gmeet_marketing_redirect( + "workspace.google.com", + "/products/calendar/" + )); + assert!(!is_gmeet_marketing_redirect( + "admin.workspace.google.com", + "/ac/users" + )); + assert!(!is_gmeet_marketing_redirect( + "workspace.google.com", + "/status" + )); + assert!(!is_gmeet_marketing_redirect( + "workspace.google.com", + "/products/meeter" + )); +} + +#[test] +fn gmeet_marketing_rejects_non_workspace_hosts() { + assert!(!is_gmeet_marketing_redirect( + "meet.google.com", + "/products/meet/" + )); + assert!(!is_gmeet_marketing_redirect("evil.com", "/products/meet/")); + // Phishing alike: workspace-google.com is NOT workspace.google.com + assert!(!is_gmeet_marketing_redirect( + "workspace-google.com", + "/products/meet/" + )); +} + +#[test] +fn gmeet_clear_marketing_rewrite_drops_counter() { + let state = WebviewAccountsState::default(); + let now = Instant::now(); + for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { + let _ = state.track_gmeet_marketing_rewrite("acct_test", now); + } + // Counter saturated — next call would bail. + assert_eq!( + state.track_gmeet_marketing_rewrite("acct_test", now), + GmeetRewriteAction::Bail + ); + // Clear it — next call within the window starts fresh. + state.clear_gmeet_marketing_rewrite("acct_test"); + assert_eq!( + state.track_gmeet_marketing_rewrite("acct_test", now), + GmeetRewriteAction::Rewrite + ); +} + +#[test] +fn gmeet_handoff_flag_default_is_unset() { + let state = WebviewAccountsState::default(); + assert!(!state.take_awaiting_gmeet_handoff("acct_test")); +} + +#[test] +fn gmeet_handoff_flag_marks_then_consumes_single_shot() { + let state = WebviewAccountsState::default(); + state.mark_awaiting_gmeet_handoff("acct_test"); + // First take returns true. + assert!(state.take_awaiting_gmeet_handoff("acct_test")); + // Second take returns false — single-shot semantics so a later + // user-initiated `myaccount.google.com` visit isn't hijacked. + assert!(!state.take_awaiting_gmeet_handoff("acct_test")); +} + +#[test] +fn gmeet_handoff_flag_is_per_label() { + let state = WebviewAccountsState::default(); + state.mark_awaiting_gmeet_handoff("acct_a"); + // `acct_b` was never marked — must not consume a flag set on `acct_a`. + assert!(!state.take_awaiting_gmeet_handoff("acct_b")); + // `acct_a`'s flag is still pending. + assert!(state.take_awaiting_gmeet_handoff("acct_a")); +} + +#[test] +fn gmeet_handoff_flag_cleared_by_drain_for_shutdown() { + let state = WebviewAccountsState::default(); + state.mark_awaiting_gmeet_handoff("acct_test"); + let _ = state.drain_for_shutdown(); + // Stale flag would hijack the first user-initiated + // `myaccount.google.com` visit after relaunch. + assert!(!state.take_awaiting_gmeet_handoff("acct_test")); +} + +// ── prewarm bookkeeping (issue #1233) ────────────────── + +/// Default state must include an empty `prewarm_accounts` set so +/// fresh boots never spuriously suppress load events. +#[test] +fn prewarm_accounts_default_is_empty() { + let state = WebviewAccountsState::default(); + assert!(state.prewarm_accounts.lock().unwrap().is_empty()); +} + +/// Inserting an id into `prewarm_accounts` and then removing it should +/// leave the set empty — covers the warm-reopen path where the user's +/// first click promotes the prewarmed webview to live. +#[test] +fn prewarm_accounts_insert_then_remove_clears() { + let state = WebviewAccountsState::default(); + state + .prewarm_accounts + .lock() + .unwrap() + .insert("acct-1".to_string()); + assert!(state.prewarm_accounts.lock().unwrap().contains("acct-1")); + state.prewarm_accounts.lock().unwrap().remove("acct-1"); + assert!(!state.prewarm_accounts.lock().unwrap().contains("acct-1")); +} + +/// `drain_for_shutdown` must not leak prewarm flags either — otherwise +/// a relaunch could spuriously suppress the very first cold open. +#[test] +fn prewarm_flag_cleared_by_drain_for_shutdown() { + let state = WebviewAccountsState::default(); + state + .prewarm_accounts + .lock() + .unwrap() + .insert("acct-warm".to_string()); + let _ = state.drain_for_shutdown(); + assert!(state.prewarm_accounts.lock().unwrap().is_empty()); +} diff --git a/app/src-tauri/src/whatsapp_scanner/dom_snapshot_test.rs b/app/src-tauri/src/whatsapp_scanner/dom_snapshot_tests.rs similarity index 100% rename from app/src-tauri/src/whatsapp_scanner/dom_snapshot_test.rs rename to app/src-tauri/src/whatsapp_scanner/dom_snapshot_tests.rs diff --git a/app/src-tauri/src/whatsapp_scanner/mod.rs b/app/src-tauri/src/whatsapp_scanner/mod.rs index 240e52de0..57e18c859 100644 --- a/app/src-tauri/src/whatsapp_scanner/mod.rs +++ b/app/src-tauri/src/whatsapp_scanner/mod.rs @@ -33,7 +33,7 @@ use tokio_tungstenite::{connect_async, tungstenite::Message}; mod dom_snapshot; #[cfg(test)] -mod dom_snapshot_test; +mod dom_snapshot_tests; mod idb; const CDP_HOST: &str = "127.0.0.1"; @@ -1479,615 +1479,5 @@ impl ScannerRegistry { } #[cfg(test)] -mod tests { - use super::*; - - // ── Issue #1376 — chat-name normalization for active-chat → JID lookup ── - - #[test] - fn normalize_chat_name_strips_punctuation_and_emoji() { - // Group titles routinely pick up emoji + punctuation drift between - // the DOM-parsed conversation header and the IDB-stored chat name. - // Normalization should collapse both sides to the same key so the - // lookup at scan_once succeeds. - assert_eq!( - normalize_chat_name("17-18-19 July samagam"), - "171819julysamagam" - ); - assert_eq!( - normalize_chat_name("17 18 19 July samagam"), - "171819julysamagam" - ); - assert_eq!( - normalize_chat_name("17-18-19 July samagam ✨"), - "171819julysamagam" - ); - assert_eq!( - normalize_chat_name("17.18.19 July, samagam!"), - "171819julysamagam" - ); - // Identity property — already-normal strings round-trip unchanged. - assert_eq!(normalize_chat_name("foo123"), "foo123"); - // Empty input → empty output (caller guards against this). - assert_eq!(normalize_chat_name(""), ""); - assert_eq!(normalize_chat_name(" "), ""); - assert_eq!(normalize_chat_name("✨"), ""); - } - - #[test] - fn normalize_chat_name_lowercases() { - assert_eq!(normalize_chat_name("Hello World"), "helloworld"); - assert_eq!(normalize_chat_name("HELLO"), "hello"); - assert_eq!(normalize_chat_name("hElLo"), "hello"); - } - - fn insert_pending_tasks( - registry: &ScannerRegistry, - account_id: &str, - count: usize, - ) -> Vec> { - let mut tasks = Vec::with_capacity(count); - let mut abort_handles = Vec::with_capacity(count); - for _ in 0..count { - let task = tokio::spawn(async { - std::future::pending::<()>().await; - }); - abort_handles.push(task.abort_handle()); - tasks.push(task); - } - registry - .started - .lock() - .insert(account_id.to_string(), abort_handles); - tasks - } - - async fn assert_cancelled(task: tokio::task::JoinHandle<()>) { - let err = tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("aborted scanner task should finish") - .expect_err("scanner task should be cancelled"); - assert!(err.is_cancelled()); - } - - async fn assert_all_cancelled(tasks: Vec>) { - for task in tasks { - assert_cancelled(task).await; - } - } - - #[tokio::test] - async fn registry_forget_aborts_all_handles_for_account_only() { - let registry = ScannerRegistry::default(); - let account_tasks = insert_pending_tasks(®istry, "acct-1", 2); - let survivor_tasks = insert_pending_tasks(®istry, "acct-2", 1); - - registry.forget("acct-1"); - - { - let guard = registry.started.lock(); - assert_eq!(guard.len(), 1); - assert!(guard.contains_key("acct-2")); - } - assert_all_cancelled(account_tasks).await; - assert!( - !survivor_tasks[0].is_finished(), - "forget(acct-1) must not abort acct-2" - ); - - assert_eq!(registry.forget_all(), 1); - assert_all_cancelled(survivor_tasks).await; - } - - #[tokio::test] - async fn registry_forget_missing_account_is_noop() { - let registry = ScannerRegistry::default(); - let mut tasks = insert_pending_tasks(®istry, "acct-1", 1); - - registry.forget("missing"); - - { - let guard = registry.started.lock(); - assert_eq!(guard.len(), 1); - assert!(guard.contains_key("acct-1")); - } - assert!( - !tasks[0].is_finished(), - "forget(missing) must not abort existing scanners" - ); - - registry.forget("acct-1"); - assert_cancelled(tasks.pop().expect("task")).await; - } - - #[tokio::test] - async fn registry_forget_all_aborts_all_tasks_and_reports_handle_count() { - let registry = ScannerRegistry::default(); - let task_a = insert_pending_tasks(®istry, "acct-1", 2); - let task_b = insert_pending_tasks(®istry, "acct-2", 3); - - assert_eq!(registry.forget_all(), 5); - - assert!(registry.started.lock().is_empty()); - assert_all_cancelled(task_a).await; - assert_all_cancelled(task_b).await; - } - - #[tokio::test] - async fn registry_forget_all_is_repeatable_noop_after_drain() { - let registry = ScannerRegistry::default(); - assert_eq!(registry.forget_all(), 0); - - let tasks = insert_pending_tasks(®istry, "acct-1", 1); - assert_eq!(registry.forget_all(), 1); - assert_eq!(registry.forget_all(), 0); - - assert!(registry.started.lock().is_empty()); - assert_all_cancelled(tasks).await; - } - - // ── seconds_to_ymd ──────────────────────────────────────────────────────── - - #[test] - fn seconds_to_ymd_known_timestamp() { - // Unix timestamp 1_700_000_000 = 2023-11-14 (UTC). - assert_eq!(seconds_to_ymd(1_700_000_000), "2023-11-14"); - } - - #[test] - fn seconds_to_ymd_epoch_zero() { - // Unix epoch origin = 1970-01-01. - assert_eq!(seconds_to_ymd(0), "1970-01-01"); - } - - #[test] - fn seconds_to_ymd_output_format_is_yyyy_mm_dd() { - let s = seconds_to_ymd(1_700_000_000); - // Must match YYYY-MM-DD: 10 chars, digit/digit/digit/digit-...-... - assert_eq!(s.len(), 10, "expected 10-char date string, got: {s}"); - let parts: Vec<&str> = s.split('-').collect(); - assert_eq!(parts.len(), 3, "expected 3 dash-separated parts: {s}"); - assert_eq!(parts[0].len(), 4, "year must be 4 digits: {s}"); - assert_eq!(parts[1].len(), 2, "month must be 2 digits: {s}"); - assert_eq!(parts[2].len(), 2, "day must be 2 digits: {s}"); - assert!( - parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())), - "all parts must be numeric: {s}" - ); - } - - // ── parse_pre_timestamp_ymd ─────────────────────────────────────────────── - - #[test] - fn parse_pre_timestamp_ymd_valid_wa_format() { - // WhatsApp Web format: "4:53 AM, 7/5/2025" - let result = parse_pre_timestamp_ymd("4:53 AM, 7/5/2025"); - assert_eq!(result.as_deref(), Some("2025-07-05")); - } - - #[test] - fn parse_pre_timestamp_ymd_another_valid_date() { - // "10:01 PM, 11/14/2023" — matches our known ts - let result = parse_pre_timestamp_ymd("10:01 PM, 11/14/2023"); - assert_eq!(result.as_deref(), Some("2023-11-14")); - } - - #[test] - fn parse_pre_timestamp_ymd_empty_string_returns_none() { - assert!(parse_pre_timestamp_ymd("").is_none()); - } - - #[test] - fn parse_pre_timestamp_ymd_no_comma_returns_none() { - assert!(parse_pre_timestamp_ymd("4:53 AM 7/5/2025").is_none()); - } - - #[test] - fn parse_pre_timestamp_ymd_invalid_date_parts_return_none() { - // Month 13 is out of range. - assert!(parse_pre_timestamp_ymd("10:00 AM, 13/5/2025").is_none()); - // Day 32 is out of range. - assert!(parse_pre_timestamp_ymd("10:00 AM, 1/32/2025").is_none()); - } - - #[test] - fn parse_pre_timestamp_ymd_garbage_returns_none() { - assert!(parse_pre_timestamp_ymd("not a timestamp at all").is_none()); - } - - // ── emit_grouped_whatsapp grouping ──────────────────────────────────────── - - /// Build a minimal message Value that `emit_grouped_whatsapp` will accept. - fn make_msg(chat_id: &str, ts: i64, body: &str, from_me: bool) -> Value { - json!({ - "chatId": chat_id, - "body": body, - "timestamp": ts, - "fromMe": from_me, - "from": if from_me { "me" } else { chat_id }, - }) - } - - #[test] - fn grouping_produces_correct_group_count_and_keys() { - use std::collections::HashMap; - - // 3 messages in alice@c.us on day 2023-11-14 (ts ≈ 1_700_000_000). - // 2 messages in group@g.us on a different day (ts ≈ 1_700_100_000 = - // 2023-11-15 UTC). - let day1_ts = 1_700_000_000i64; // 2023-11-14 - let day2_ts = 1_700_100_000i64; // 2023-11-15 - - let messages = vec![ - make_msg("alice@c.us", day1_ts, "Hello", false), - make_msg("alice@c.us", day1_ts + 60, "How are you?", false), - make_msg("alice@c.us", day1_ts + 120, "Fine thanks", true), - make_msg("group@g.us", day2_ts, "Meeting at 3pm", false), - make_msg("group@g.us", day2_ts + 30, "Got it", true), - ]; - - // Collect groups the same way emit_grouped_whatsapp does it. - let empty_chats = serde_json::Map::new(); - let now_secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - - let mut groups: HashMap<(String, String), Vec> = HashMap::new(); - for m in &messages { - let chat_id = match m.get("chatId").and_then(|v| v.as_str()) { - Some(s) if !s.is_empty() => s.to_string(), - _ => continue, - }; - let body = m - .get("body") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .unwrap_or_default(); - if body.is_empty() { - continue; - } - let day: String = if let Some(t) = m.get("timestamp").and_then(|v| v.as_i64()) { - seconds_to_ymd(t) - } else { - seconds_to_ymd(now_secs) - }; - let _ = &empty_chats; - groups.entry((chat_id, day)).or_default().push(m.clone()); - } - - assert_eq!(groups.len(), 2, "expected exactly 2 (chatId, day) groups"); - - let alice_day = seconds_to_ymd(day1_ts); - let group_day = seconds_to_ymd(day2_ts); - - let alice_key = ("alice@c.us".to_string(), alice_day.clone()); - let group_key = ("group@g.us".to_string(), group_day.clone()); - - assert!( - groups.contains_key(&alice_key), - "alice group missing; groups: {groups:?}" - ); - assert!( - groups.contains_key(&group_key), - "group@g.us group missing; groups: {groups:?}" - ); - - assert_eq!( - groups[&alice_key].len(), - 3, - "alice chat should have 3 messages" - ); - assert_eq!( - groups[&group_key].len(), - 2, - "group chat should have 2 messages" - ); - } - - // ── transcript format ───────────────────────────────────────────────────── - - #[test] - fn build_doc_ingest_params_transcript_contains_senders_and_bodies() { - let day_ts = 1_700_000_000i64; // 2023-11-14 - let ingest = json!({ - "chatId": "alice@c.us", - "chatName": "Alice", - "day": seconds_to_ymd(day_ts), - "messages": [ - { - "chatId": "alice@c.us", - "fromMe": false, - "from": "alice@c.us", - "fromName": "Alice", - "body": "Hey there!", - "timestamp": day_ts, - }, - { - "chatId": "alice@c.us", - "fromMe": true, - "from": "me", - "fromName": null, - "body": "Hi Alice!", - "timestamp": day_ts + 60, - }, - { - "chatId": "alice@c.us", - "fromMe": false, - "from": "alice@c.us", - "fromName": "Alice", - "body": "How are you?", - "timestamp": day_ts + 120, - }, - ], - }); - - let params = build_doc_ingest_params("test-acct@c.us", &ingest) - .expect("should build params for valid ingest"); - - let content = params - .get("content") - .and_then(|v| v.as_str()) - .expect("content must be present"); - - // Senders should appear in the transcript. - assert!( - content.contains("Alice"), - "transcript must contain sender name 'Alice'; content:\n{content}" - ); - assert!( - content.contains("me"), - "transcript must contain 'me' for self-sent messages; content:\n{content}" - ); - - // Bodies must be present. - assert!( - content.contains("Hey there!"), - "transcript must contain first message body; content:\n{content}" - ); - assert!( - content.contains("Hi Alice!"), - "transcript must contain second message body; content:\n{content}" - ); - assert!( - content.contains("How are you?"), - "transcript must contain third message body; content:\n{content}" - ); - - // Lines must appear in ascending timestamp order — verify by position. - let pos_hey = content.find("Hey there!").expect("Hey there not found"); - let pos_hi = content.find("Hi Alice!").expect("Hi Alice not found"); - let pos_how = content.find("How are you?").expect("How are you not found"); - assert!( - pos_hey < pos_hi && pos_hi < pos_how, - "transcript lines must be in timestamp order" - ); - } - - // ── build_doc_ingest_params payload shape ───────────────────────────────── - - #[test] - fn build_doc_ingest_params_namespace_and_key_format() { - let day = "2023-11-14"; - let ingest = json!({ - "chatId": "alice@c.us", - "chatName": "Alice", - "day": day, - "messages": [ - { "chatId": "alice@c.us", "fromMe": false, "from": "alice@c.us", - "fromName": "Alice", "body": "Hello", "timestamp": 1_700_000_000i64 } - ], - }); - - let params = - build_doc_ingest_params("test-acct@c.us", &ingest).expect("should build params"); - - assert_eq!( - params.get("namespace").and_then(|v| v.as_str()), - Some("whatsapp-web:test-acct@c.us"), - "namespace must be 'whatsapp-web:'" - ); - assert_eq!( - params.get("key").and_then(|v| v.as_str()), - Some("alice@c.us:2023-11-14"), - "key must be ':'" - ); - assert_eq!( - params.get("source_type").and_then(|v| v.as_str()), - Some("whatsapp-web"), - "source_type must be 'whatsapp-web'" - ); - - // Content must be non-empty and contain the body. - let content = params - .get("content") - .and_then(|v| v.as_str()) - .expect("content must be present"); - assert!(!content.is_empty(), "content must not be empty"); - assert!( - content.contains("Hello"), - "content must contain message body; got:\n{content}" - ); - } - - #[test] - fn build_doc_ingest_params_missing_chat_id_returns_none() { - let ingest = json!({ - "chatName": "Alice", - "day": "2023-11-14", - "messages": [ - { "chatId": "alice@c.us", "fromMe": false, "body": "Hello", "timestamp": 1i64 } - ], - }); - assert!( - build_doc_ingest_params("acct", &ingest).is_none(), - "missing chatId must return None" - ); - } - - #[test] - fn build_doc_ingest_params_empty_messages_returns_none() { - let ingest = json!({ - "chatId": "alice@c.us", - "chatName": "Alice", - "day": "2023-11-14", - "messages": [], - }); - assert!( - build_doc_ingest_params("acct", &ingest).is_none(), - "empty messages must return None" - ); - } - - // ── DOM-IDB merge ───────────────────────────────────────────────────────── - - #[test] - fn merge_dom_patches_empty_body_from_idb_message() { - // IDB message with empty body; matching DOM row has the decrypted body. - let idb = vec![json!({ - "id": "abc123", - "chatId": "alice@c.us", - "fromMe": false, - "body": "", - })]; - let dom = vec![json!({ - "dataId": "abc123", - "msgId": "abc123", - "chatId": "alice@c.us", - "fromMe": false, - "body": "Hello", - "author": "Alice", - "preTimestamp": null, - })]; - - let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None); - - assert_eq!(patched, 1, "one message should be patched"); - assert_eq!(appended, 0, "no messages should be appended"); - assert_eq!(merged.len(), 1, "still one message in merged list"); - - let body = merged[0] - .get("body") - .and_then(|v| v.as_str()) - .expect("body must be present"); - assert_eq!(body, "Hello", "patched body must equal DOM body"); - - let source = merged[0] - .get("bodySource") - .and_then(|v| v.as_str()) - .expect("bodySource must be present"); - assert_eq!(source, "dom", "bodySource must be 'dom' after patching"); - } - - #[test] - fn merge_dom_appends_unmatched_row_with_active_chat_backfill() { - // No IDB messages; DOM has a row with no chatId. active_chat_jid - // should be stamped onto the appended message. - let idb: Vec = vec![]; - let dom = vec![json!({ - "dataId": "newrow1", - "msgId": "newrow1", - "chatId": "", // empty — needs backfill - "fromMe": false, - "body": "Hey from active chat", - "author": "Bob", - "preTimestamp": null, - })]; - - let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, Some("bob@c.us")); - - assert_eq!(patched, 0, "nothing to patch"); - assert_eq!(appended, 1, "one row should be appended"); - assert_eq!(merged.len(), 1, "merged list should have 1 entry"); - - let chat_id = merged[0] - .get("chatId") - .and_then(|v| v.as_str()) - .expect("chatId must be present"); - assert_eq!( - chat_id, "bob@c.us", - "chatId should be backfilled from active_chat_jid" - ); - - let body_source = merged[0] - .get("bodySource") - .and_then(|v| v.as_str()) - .expect("bodySource must be present"); - assert_eq!(body_source, "dom-only"); - } - - #[test] - fn merge_dom_does_not_append_row_without_body() { - // DOM rows without a body should be silently skipped. - let idb: Vec = vec![]; - let dom = vec![json!({ - "dataId": "empty1", - "msgId": "empty1", - "chatId": "alice@c.us", - "fromMe": false, - "body": "", - })]; - - let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None); - - assert_eq!(patched, 0); - assert_eq!(appended, 0, "empty-body DOM rows must not be appended"); - assert!( - merged.is_empty(), - "no messages should appear in merged list" - ); - } - - #[test] - fn merge_dom_does_not_consume_row_twice() { - // Two IDB messages with the same bare msgId; only the first match - // should consume the DOM row. - let idb = vec![ - json!({ "id": "chat_abc", "chatId": "alice@c.us", "fromMe": false, "body": "" }), - json!({ "id": "chat_abc_2", "chatId": "alice@c.us", "fromMe": true, "body": "" }), - ]; - // DOM row keyed only by bare msgId "abc". - let dom = vec![json!({ - "dataId": "abc", - "msgId": "abc", - "chatId": "alice@c.us", - "fromMe": false, - "body": "Only once", - })]; - - let (merged, patched, _appended) = merge_dom_into_snapshot(&idb, &dom, None); - - // Exactly one of the two IDB messages should be patched. - assert_eq!(patched, 1, "DOM row must be consumed at most once"); - assert_eq!(merged.len(), 2, "both IDB messages must survive merge"); - let patched_bodies: Vec<&str> = merged - .iter() - .filter_map(|m| m.get("body").and_then(|v| v.as_str())) - .filter(|b| *b == "Only once") - .collect(); - assert_eq!( - patched_bodies.len(), - 1, - "body 'Only once' must appear exactly once in merged list" - ); - } - - #[test] - fn merge_dom_empty_dom_returns_idb_messages_unchanged() { - let idb = vec![ - json!({ "id": "m1", "chatId": "a@c.us", "body": "hello" }), - json!({ "id": "m2", "chatId": "a@c.us", "body": "" }), - ]; - let dom: Vec = vec![]; - - let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None); - - assert_eq!(patched, 0); - assert_eq!(appended, 0); - assert_eq!(merged.len(), 2, "IDB messages must be returned unchanged"); - assert_eq!( - merged[0].get("body").and_then(|v| v.as_str()), - Some("hello") - ); - } -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/app/src-tauri/src/whatsapp_scanner/mod_tests.rs b/app/src-tauri/src/whatsapp_scanner/mod_tests.rs new file mode 100644 index 000000000..9084bcdf7 --- /dev/null +++ b/app/src-tauri/src/whatsapp_scanner/mod_tests.rs @@ -0,0 +1,609 @@ +use super::*; + +// ── Issue #1376 — chat-name normalization for active-chat → JID lookup ── + +#[test] +fn normalize_chat_name_strips_punctuation_and_emoji() { + // Group titles routinely pick up emoji + punctuation drift between + // the DOM-parsed conversation header and the IDB-stored chat name. + // Normalization should collapse both sides to the same key so the + // lookup at scan_once succeeds. + assert_eq!( + normalize_chat_name("17-18-19 July samagam"), + "171819julysamagam" + ); + assert_eq!( + normalize_chat_name("17 18 19 July samagam"), + "171819julysamagam" + ); + assert_eq!( + normalize_chat_name("17-18-19 July samagam ✨"), + "171819julysamagam" + ); + assert_eq!( + normalize_chat_name("17.18.19 July, samagam!"), + "171819julysamagam" + ); + // Identity property — already-normal strings round-trip unchanged. + assert_eq!(normalize_chat_name("foo123"), "foo123"); + // Empty input → empty output (caller guards against this). + assert_eq!(normalize_chat_name(""), ""); + assert_eq!(normalize_chat_name(" "), ""); + assert_eq!(normalize_chat_name("✨"), ""); +} + +#[test] +fn normalize_chat_name_lowercases() { + assert_eq!(normalize_chat_name("Hello World"), "helloworld"); + assert_eq!(normalize_chat_name("HELLO"), "hello"); + assert_eq!(normalize_chat_name("hElLo"), "hello"); +} + +fn insert_pending_tasks( + registry: &ScannerRegistry, + account_id: &str, + count: usize, +) -> Vec> { + let mut tasks = Vec::with_capacity(count); + let mut abort_handles = Vec::with_capacity(count); + for _ in 0..count { + let task = tokio::spawn(async { + std::future::pending::<()>().await; + }); + abort_handles.push(task.abort_handle()); + tasks.push(task); + } + registry + .started + .lock() + .insert(account_id.to_string(), abort_handles); + tasks +} + +async fn assert_cancelled(task: tokio::task::JoinHandle<()>) { + let err = tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("aborted scanner task should finish") + .expect_err("scanner task should be cancelled"); + assert!(err.is_cancelled()); +} + +async fn assert_all_cancelled(tasks: Vec>) { + for task in tasks { + assert_cancelled(task).await; + } +} + +#[tokio::test] +async fn registry_forget_aborts_all_handles_for_account_only() { + let registry = ScannerRegistry::default(); + let account_tasks = insert_pending_tasks(®istry, "acct-1", 2); + let survivor_tasks = insert_pending_tasks(®istry, "acct-2", 1); + + registry.forget("acct-1"); + + { + let guard = registry.started.lock(); + assert_eq!(guard.len(), 1); + assert!(guard.contains_key("acct-2")); + } + assert_all_cancelled(account_tasks).await; + assert!( + !survivor_tasks[0].is_finished(), + "forget(acct-1) must not abort acct-2" + ); + + assert_eq!(registry.forget_all(), 1); + assert_all_cancelled(survivor_tasks).await; +} + +#[tokio::test] +async fn registry_forget_missing_account_is_noop() { + let registry = ScannerRegistry::default(); + let mut tasks = insert_pending_tasks(®istry, "acct-1", 1); + + registry.forget("missing"); + + { + let guard = registry.started.lock(); + assert_eq!(guard.len(), 1); + assert!(guard.contains_key("acct-1")); + } + assert!( + !tasks[0].is_finished(), + "forget(missing) must not abort existing scanners" + ); + + registry.forget("acct-1"); + assert_cancelled(tasks.pop().expect("task")).await; +} + +#[tokio::test] +async fn registry_forget_all_aborts_all_tasks_and_reports_handle_count() { + let registry = ScannerRegistry::default(); + let task_a = insert_pending_tasks(®istry, "acct-1", 2); + let task_b = insert_pending_tasks(®istry, "acct-2", 3); + + assert_eq!(registry.forget_all(), 5); + + assert!(registry.started.lock().is_empty()); + assert_all_cancelled(task_a).await; + assert_all_cancelled(task_b).await; +} + +#[tokio::test] +async fn registry_forget_all_is_repeatable_noop_after_drain() { + let registry = ScannerRegistry::default(); + assert_eq!(registry.forget_all(), 0); + + let tasks = insert_pending_tasks(®istry, "acct-1", 1); + assert_eq!(registry.forget_all(), 1); + assert_eq!(registry.forget_all(), 0); + + assert!(registry.started.lock().is_empty()); + assert_all_cancelled(tasks).await; +} + +// ── seconds_to_ymd ──────────────────────────────────────────────────────── + +#[test] +fn seconds_to_ymd_known_timestamp() { + // Unix timestamp 1_700_000_000 = 2023-11-14 (UTC). + assert_eq!(seconds_to_ymd(1_700_000_000), "2023-11-14"); +} + +#[test] +fn seconds_to_ymd_epoch_zero() { + // Unix epoch origin = 1970-01-01. + assert_eq!(seconds_to_ymd(0), "1970-01-01"); +} + +#[test] +fn seconds_to_ymd_output_format_is_yyyy_mm_dd() { + let s = seconds_to_ymd(1_700_000_000); + // Must match YYYY-MM-DD: 10 chars, digit/digit/digit/digit-...-... + assert_eq!(s.len(), 10, "expected 10-char date string, got: {s}"); + let parts: Vec<&str> = s.split('-').collect(); + assert_eq!(parts.len(), 3, "expected 3 dash-separated parts: {s}"); + assert_eq!(parts[0].len(), 4, "year must be 4 digits: {s}"); + assert_eq!(parts[1].len(), 2, "month must be 2 digits: {s}"); + assert_eq!(parts[2].len(), 2, "day must be 2 digits: {s}"); + assert!( + parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())), + "all parts must be numeric: {s}" + ); +} + +// ── parse_pre_timestamp_ymd ─────────────────────────────────────────────── + +#[test] +fn parse_pre_timestamp_ymd_valid_wa_format() { + // WhatsApp Web format: "4:53 AM, 7/5/2025" + let result = parse_pre_timestamp_ymd("4:53 AM, 7/5/2025"); + assert_eq!(result.as_deref(), Some("2025-07-05")); +} + +#[test] +fn parse_pre_timestamp_ymd_another_valid_date() { + // "10:01 PM, 11/14/2023" — matches our known ts + let result = parse_pre_timestamp_ymd("10:01 PM, 11/14/2023"); + assert_eq!(result.as_deref(), Some("2023-11-14")); +} + +#[test] +fn parse_pre_timestamp_ymd_empty_string_returns_none() { + assert!(parse_pre_timestamp_ymd("").is_none()); +} + +#[test] +fn parse_pre_timestamp_ymd_no_comma_returns_none() { + assert!(parse_pre_timestamp_ymd("4:53 AM 7/5/2025").is_none()); +} + +#[test] +fn parse_pre_timestamp_ymd_invalid_date_parts_return_none() { + // Month 13 is out of range. + assert!(parse_pre_timestamp_ymd("10:00 AM, 13/5/2025").is_none()); + // Day 32 is out of range. + assert!(parse_pre_timestamp_ymd("10:00 AM, 1/32/2025").is_none()); +} + +#[test] +fn parse_pre_timestamp_ymd_garbage_returns_none() { + assert!(parse_pre_timestamp_ymd("not a timestamp at all").is_none()); +} + +// ── emit_grouped_whatsapp grouping ──────────────────────────────────────── + +/// Build a minimal message Value that `emit_grouped_whatsapp` will accept. +fn make_msg(chat_id: &str, ts: i64, body: &str, from_me: bool) -> Value { + json!({ + "chatId": chat_id, + "body": body, + "timestamp": ts, + "fromMe": from_me, + "from": if from_me { "me" } else { chat_id }, + }) +} + +#[test] +fn grouping_produces_correct_group_count_and_keys() { + use std::collections::HashMap; + + // 3 messages in alice@c.us on day 2023-11-14 (ts ≈ 1_700_000_000). + // 2 messages in group@g.us on a different day (ts ≈ 1_700_100_000 = + // 2023-11-15 UTC). + let day1_ts = 1_700_000_000i64; // 2023-11-14 + let day2_ts = 1_700_100_000i64; // 2023-11-15 + + let messages = vec![ + make_msg("alice@c.us", day1_ts, "Hello", false), + make_msg("alice@c.us", day1_ts + 60, "How are you?", false), + make_msg("alice@c.us", day1_ts + 120, "Fine thanks", true), + make_msg("group@g.us", day2_ts, "Meeting at 3pm", false), + make_msg("group@g.us", day2_ts + 30, "Got it", true), + ]; + + // Collect groups the same way emit_grouped_whatsapp does it. + let empty_chats = serde_json::Map::new(); + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let mut groups: HashMap<(String, String), Vec> = HashMap::new(); + for m in &messages { + let chat_id = match m.get("chatId").and_then(|v| v.as_str()) { + Some(s) if !s.is_empty() => s.to_string(), + _ => continue, + }; + let body = m + .get("body") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + if body.is_empty() { + continue; + } + let day: String = if let Some(t) = m.get("timestamp").and_then(|v| v.as_i64()) { + seconds_to_ymd(t) + } else { + seconds_to_ymd(now_secs) + }; + let _ = &empty_chats; + groups.entry((chat_id, day)).or_default().push(m.clone()); + } + + assert_eq!(groups.len(), 2, "expected exactly 2 (chatId, day) groups"); + + let alice_day = seconds_to_ymd(day1_ts); + let group_day = seconds_to_ymd(day2_ts); + + let alice_key = ("alice@c.us".to_string(), alice_day.clone()); + let group_key = ("group@g.us".to_string(), group_day.clone()); + + assert!( + groups.contains_key(&alice_key), + "alice group missing; groups: {groups:?}" + ); + assert!( + groups.contains_key(&group_key), + "group@g.us group missing; groups: {groups:?}" + ); + + assert_eq!( + groups[&alice_key].len(), + 3, + "alice chat should have 3 messages" + ); + assert_eq!( + groups[&group_key].len(), + 2, + "group chat should have 2 messages" + ); +} + +// ── transcript format ───────────────────────────────────────────────────── + +#[test] +fn build_doc_ingest_params_transcript_contains_senders_and_bodies() { + let day_ts = 1_700_000_000i64; // 2023-11-14 + let ingest = json!({ + "chatId": "alice@c.us", + "chatName": "Alice", + "day": seconds_to_ymd(day_ts), + "messages": [ + { + "chatId": "alice@c.us", + "fromMe": false, + "from": "alice@c.us", + "fromName": "Alice", + "body": "Hey there!", + "timestamp": day_ts, + }, + { + "chatId": "alice@c.us", + "fromMe": true, + "from": "me", + "fromName": null, + "body": "Hi Alice!", + "timestamp": day_ts + 60, + }, + { + "chatId": "alice@c.us", + "fromMe": false, + "from": "alice@c.us", + "fromName": "Alice", + "body": "How are you?", + "timestamp": day_ts + 120, + }, + ], + }); + + let params = build_doc_ingest_params("test-acct@c.us", &ingest) + .expect("should build params for valid ingest"); + + let content = params + .get("content") + .and_then(|v| v.as_str()) + .expect("content must be present"); + + // Senders should appear in the transcript. + assert!( + content.contains("Alice"), + "transcript must contain sender name 'Alice'; content:\n{content}" + ); + assert!( + content.contains("me"), + "transcript must contain 'me' for self-sent messages; content:\n{content}" + ); + + // Bodies must be present. + assert!( + content.contains("Hey there!"), + "transcript must contain first message body; content:\n{content}" + ); + assert!( + content.contains("Hi Alice!"), + "transcript must contain second message body; content:\n{content}" + ); + assert!( + content.contains("How are you?"), + "transcript must contain third message body; content:\n{content}" + ); + + // Lines must appear in ascending timestamp order — verify by position. + let pos_hey = content.find("Hey there!").expect("Hey there not found"); + let pos_hi = content.find("Hi Alice!").expect("Hi Alice not found"); + let pos_how = content.find("How are you?").expect("How are you not found"); + assert!( + pos_hey < pos_hi && pos_hi < pos_how, + "transcript lines must be in timestamp order" + ); +} + +// ── build_doc_ingest_params payload shape ───────────────────────────────── + +#[test] +fn build_doc_ingest_params_namespace_and_key_format() { + let day = "2023-11-14"; + let ingest = json!({ + "chatId": "alice@c.us", + "chatName": "Alice", + "day": day, + "messages": [ + { "chatId": "alice@c.us", "fromMe": false, "from": "alice@c.us", + "fromName": "Alice", "body": "Hello", "timestamp": 1_700_000_000i64 } + ], + }); + + let params = build_doc_ingest_params("test-acct@c.us", &ingest).expect("should build params"); + + assert_eq!( + params.get("namespace").and_then(|v| v.as_str()), + Some("whatsapp-web:test-acct@c.us"), + "namespace must be 'whatsapp-web:'" + ); + assert_eq!( + params.get("key").and_then(|v| v.as_str()), + Some("alice@c.us:2023-11-14"), + "key must be ':'" + ); + assert_eq!( + params.get("source_type").and_then(|v| v.as_str()), + Some("whatsapp-web"), + "source_type must be 'whatsapp-web'" + ); + + // Content must be non-empty and contain the body. + let content = params + .get("content") + .and_then(|v| v.as_str()) + .expect("content must be present"); + assert!(!content.is_empty(), "content must not be empty"); + assert!( + content.contains("Hello"), + "content must contain message body; got:\n{content}" + ); +} + +#[test] +fn build_doc_ingest_params_missing_chat_id_returns_none() { + let ingest = json!({ + "chatName": "Alice", + "day": "2023-11-14", + "messages": [ + { "chatId": "alice@c.us", "fromMe": false, "body": "Hello", "timestamp": 1i64 } + ], + }); + assert!( + build_doc_ingest_params("acct", &ingest).is_none(), + "missing chatId must return None" + ); +} + +#[test] +fn build_doc_ingest_params_empty_messages_returns_none() { + let ingest = json!({ + "chatId": "alice@c.us", + "chatName": "Alice", + "day": "2023-11-14", + "messages": [], + }); + assert!( + build_doc_ingest_params("acct", &ingest).is_none(), + "empty messages must return None" + ); +} + +// ── DOM-IDB merge ───────────────────────────────────────────────────────── + +#[test] +fn merge_dom_patches_empty_body_from_idb_message() { + // IDB message with empty body; matching DOM row has the decrypted body. + let idb = vec![json!({ + "id": "abc123", + "chatId": "alice@c.us", + "fromMe": false, + "body": "", + })]; + let dom = vec![json!({ + "dataId": "abc123", + "msgId": "abc123", + "chatId": "alice@c.us", + "fromMe": false, + "body": "Hello", + "author": "Alice", + "preTimestamp": null, + })]; + + let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None); + + assert_eq!(patched, 1, "one message should be patched"); + assert_eq!(appended, 0, "no messages should be appended"); + assert_eq!(merged.len(), 1, "still one message in merged list"); + + let body = merged[0] + .get("body") + .and_then(|v| v.as_str()) + .expect("body must be present"); + assert_eq!(body, "Hello", "patched body must equal DOM body"); + + let source = merged[0] + .get("bodySource") + .and_then(|v| v.as_str()) + .expect("bodySource must be present"); + assert_eq!(source, "dom", "bodySource must be 'dom' after patching"); +} + +#[test] +fn merge_dom_appends_unmatched_row_with_active_chat_backfill() { + // No IDB messages; DOM has a row with no chatId. active_chat_jid + // should be stamped onto the appended message. + let idb: Vec = vec![]; + let dom = vec![json!({ + "dataId": "newrow1", + "msgId": "newrow1", + "chatId": "", // empty — needs backfill + "fromMe": false, + "body": "Hey from active chat", + "author": "Bob", + "preTimestamp": null, + })]; + + let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, Some("bob@c.us")); + + assert_eq!(patched, 0, "nothing to patch"); + assert_eq!(appended, 1, "one row should be appended"); + assert_eq!(merged.len(), 1, "merged list should have 1 entry"); + + let chat_id = merged[0] + .get("chatId") + .and_then(|v| v.as_str()) + .expect("chatId must be present"); + assert_eq!( + chat_id, "bob@c.us", + "chatId should be backfilled from active_chat_jid" + ); + + let body_source = merged[0] + .get("bodySource") + .and_then(|v| v.as_str()) + .expect("bodySource must be present"); + assert_eq!(body_source, "dom-only"); +} + +#[test] +fn merge_dom_does_not_append_row_without_body() { + // DOM rows without a body should be silently skipped. + let idb: Vec = vec![]; + let dom = vec![json!({ + "dataId": "empty1", + "msgId": "empty1", + "chatId": "alice@c.us", + "fromMe": false, + "body": "", + })]; + + let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None); + + assert_eq!(patched, 0); + assert_eq!(appended, 0, "empty-body DOM rows must not be appended"); + assert!( + merged.is_empty(), + "no messages should appear in merged list" + ); +} + +#[test] +fn merge_dom_does_not_consume_row_twice() { + // Two IDB messages with the same bare msgId; only the first match + // should consume the DOM row. + let idb = vec![ + json!({ "id": "chat_abc", "chatId": "alice@c.us", "fromMe": false, "body": "" }), + json!({ "id": "chat_abc_2", "chatId": "alice@c.us", "fromMe": true, "body": "" }), + ]; + // DOM row keyed only by bare msgId "abc". + let dom = vec![json!({ + "dataId": "abc", + "msgId": "abc", + "chatId": "alice@c.us", + "fromMe": false, + "body": "Only once", + })]; + + let (merged, patched, _appended) = merge_dom_into_snapshot(&idb, &dom, None); + + // Exactly one of the two IDB messages should be patched. + assert_eq!(patched, 1, "DOM row must be consumed at most once"); + assert_eq!(merged.len(), 2, "both IDB messages must survive merge"); + let patched_bodies: Vec<&str> = merged + .iter() + .filter_map(|m| m.get("body").and_then(|v| v.as_str())) + .filter(|b| *b == "Only once") + .collect(); + assert_eq!( + patched_bodies.len(), + 1, + "body 'Only once' must appear exactly once in merged list" + ); +} + +#[test] +fn merge_dom_empty_dom_returns_idb_messages_unchanged() { + let idb = vec![ + json!({ "id": "m1", "chatId": "a@c.us", "body": "hello" }), + json!({ "id": "m2", "chatId": "a@c.us", "body": "" }), + ]; + let dom: Vec = vec![]; + + let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None); + + assert_eq!(patched, 0); + assert_eq!(appended, 0); + assert_eq!(merged.len(), 2, "IDB messages must be returned unchanged"); + assert_eq!( + merged[0].get("body").and_then(|v| v.as_str()), + Some("hello") + ); +} diff --git a/src/core/observability.rs b/src/core/observability.rs index 5521dac4b..c882b22a8 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -2183,3559 +2183,5 @@ fn event_contains_budget_insufficient_phrase(event: &sentry::protocol::Event<'_> } #[cfg(test)] -mod tests { - use super::*; - - /// Helper must accept `&anyhow::Error`, `&dyn std::error::Error`, and - /// plain `&str` — the three shapes that show up at error sites today. - #[test] - fn report_error_accepts_common_error_shapes() { - let anyhow_err = anyhow::anyhow!("boom"); - report_error(&anyhow_err, "test", "anyhow_shape", &[]); - - let io_err = std::io::Error::other("io failed"); - report_error(&io_err, "test", "io_shape", &[("kind", "io")]); - - report_error("plain message", "test", "str_shape", &[]); - } - - #[test] - fn anyhow_chain_is_rendered_in_full() { - // Regression guard: `err.to_string()` on an anyhow chain only emits - // the outermost context. Using `{:#}` joins every cause, which is - // what Sentry needs to actually diagnose wrapped failures. - let inner = std::io::Error::other("inner cause"); - let wrapped = anyhow::Error::from(inner).context("outer ctx"); - assert_eq!(format!("{wrapped:#}"), "outer ctx: inner cause"); - } - - #[test] - fn classifies_expected_config_errors() { - assert_eq!( - expected_error_kind("rpc.invoke_method failed: local ai is disabled"), - Some(ExpectedErrorKind::LocalAiDisabled) - ); - assert_eq!( - expected_error_kind( - "agent.provider_chat failed: ollama API key not set. Configure via the web UI" - ), - Some(ExpectedErrorKind::ApiKeyMissing) - ); - assert_eq!( - expected_error_kind("ollama embed failed with status 500"), - None - ); - } - - /// Task B (issue #2898): prove the canonical 429 error message produced by - /// the embedding clients is already classified as `TransientUpstreamHttp` - /// so Sentry events are suppressed even without backoff. - /// - /// The `is_transient_upstream_http_message` matcher checks for - /// `"api error (429 "` (case-insensitive), which is present in both the - /// OpenAI and Cohere canonical error shapes. - #[test] - fn embedding_429_classifies_as_transient_upstream_http() { - // OpenAI/Voyage canonical shape (openai.rs emit site). - let msg = "Embedding API error (429 Too Many Requests): Rate limit exceeded."; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "OpenAI 429 must classify as TransientUpstreamHttp: {msg}" - ); - - // Cohere canonical shape (cohere.rs emit site). - let cohere_msg = "Cohere embed API error (429 Too Many Requests): rate limit exceeded."; - assert_eq!( - expected_error_kind(cohere_msg), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "Cohere 429 must classify as TransientUpstreamHttp: {cohere_msg}" - ); - - // After-cap bail shape from the retry loop (openai.rs). - let cap_msg = - "Embedding API error (429 Too Many Requests): rate limit exceeded after 3 retries"; - assert_eq!( - expected_error_kind(cap_msg), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "retry-cap bail message must classify as TransientUpstreamHttp: {cap_msg}" - ); - - // After-cap bail shape from the retry loop (cohere.rs). - let cohere_cap_msg = - "Cohere embed API error (429 Too Many Requests): rate limit exceeded after 3 retries"; - assert_eq!( - expected_error_kind(cohere_cap_msg), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "Cohere retry-cap bail message must classify as TransientUpstreamHttp: {cohere_cap_msg}" - ); - } - - #[test] - fn classifies_backend_env_api_key_not_configured() { - // TAURI-RUST-2H5 (~5 K events): backend embedding endpoint returns a - // 400 with `{"success":false,"error":"VOYAGE_API_KEY is not configured"}` - // whenever the backend env var is absent. This is a known server-side - // config state, not an app error — silence it the same way we silence - // other `ApiKeyMissing` variants. - for raw in [ - r#"Embedding API error (400 Bad Request): {"success":false,"error":"VOYAGE_API_KEY is not configured"}"#, - r#"Embedding API error 400 Bad Request: {"success":false,"error":"VOYAGE_API_KEY is not configured"}"#, - // Future-proof: same shape for any other backend-managed embedder. - r#"Embedding API error (400 Bad Request): {"success":false,"error":"COHERE_API_KEY is not configured"}"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ApiKeyMissing), - "should classify backend env api-key missing: {raw}" - ); - } - } - - #[test] - fn does_not_classify_unrelated_is_not_configured_messages() { - // The `_api_key` anchor must keep prose that merely says "is not - // configured" from being silenced — only env-var-style key names - // should match. - assert_eq!( - expected_error_kind("workspace path is not configured for this user"), - None - ); - assert_eq!( - expected_error_kind("embedding model is not configured"), - None - ); - assert_eq!( - expected_error_kind("provider 'voyage' is not configured in settings"), - None - ); - } - - #[test] - fn classifies_ollama_user_config_rejections() { - // TAURI-RUST-XS (~376 events): user pointed embedder at a chat / - // vision model id, sometimes with a temperature suffix like `@0.7` - // that Ollama parses as malformed. - for raw in [ - // Canonical XS wire shape from - // `OllamaEmbedding::embed` non-2xx path on a 400 Bad Request. - r#"ollama embed failed with status 400 Bad Request: {"error":"invalid model name"}"#, - // Same shape with a temperature-suffix model id the user pasted - // into Settings → Embeddings → Ollama. - r#"ollama embed failed with status 400 Bad Request: {"error":"invalid model name: qwen3-vl:4b@0.7"}"#, - // OPENHUMAN-TAURI-MA — model not pulled (404 Not Found). - r#"ollama embed failed with status 404 Not Found: {"error":"model \"bge-m3\" not found, try pulling it first"}"#, - // OPENHUMAN-TAURI-KM — same shape, different model id + `:latest` tag. - r#"ollama embed failed with status 404 Not Found: {"error":"model \"nomic-embed-text:latest\" not found, try pulling it first"}"#, - // OPENHUMAN-TAURI-GX — daemon-unreachable opt-in state. - "ollama embeddings opted-in but daemon unreachable at http://localhost:11434; falling back to cloud embeddings for this session", - // TAURI-RUST-3X — 501-status model-does-not-support-embeddings. - r#"ollama embed failed with status 501 Not Implemented: {"error":"this model does not support embeddings"}"#, - // TAURI-RUST-3E — 401 unauthorized embed (auth required at ollama endpoint). - r#"ollama embed failed with status 401 Unauthorized: {"error": "unauthorized"}"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ProviderUserState), - "should classify Ollama user-config rejection: {raw}" - ); - } - } - - #[test] - fn classifies_embedding_backend_auth_failure() { - // TAURI-RUST-T (~4k events) — companion of TAURI-RUST-4K5: the - // OpenHuman backend rejected the embeddings worker's bearer - // token. Both the bare-status and parenthesised wire shapes - // must classify as SessionExpired so the FE re-login prompt - // fires (matches the contract introduced by #2786 and - // exercised by classifies_embedding_api_invalid_token_401_as_session_expired). - for raw in [ - r#"Embedding API error 401 Unauthorized: {"success":false,"error":"Invalid token"}"#, - r#"Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::SessionExpired), - "should classify embedding backend auth failure as SessionExpired: {raw}" - ); - } - } - - #[test] - fn does_not_classify_unrelated_invalid_token_messages() { - // Provider 401s with "invalid token" in the body but no - // `Embedding API error` prefix must keep reaching Sentry — they're - // not the same wire shape and may indicate real provider bugs. - assert_eq!( - expected_error_kind(r#"openai chat failed 401: {"error":"invalid token"}"#), - None - ); - // Embedding error without 401 must not be silenced. - assert_eq!( - expected_error_kind( - r#"Embedding API error 500 Internal Server Error: {"error":"invalid token signature service down"}"# - ), - None - ); - } - - #[test] - fn does_not_classify_unrelated_ollama_errors_as_user_config() { - // Unrelated 500 — server-side ollama bug must still reach Sentry. - assert_eq!( - expected_error_kind("ollama embed failed with status 500"), - None - ); - // Parse-failure on the response — real bug in either the server - // or our deserializer, must still reach Sentry. - assert_eq!( - expected_error_kind( - "ollama embed response parse failed: invalid type: expected sequence" - ), - None - ); - // Dimension mismatch — real bug (model dims don't match what we - // recorded), must still reach Sentry. - assert_eq!( - expected_error_kind( - "ollama embed dimension mismatch at index 0: expected 768, got 1024" - ), - None - ); - // Unrelated `invalid model name` outside Ollama embed call — - // anchor on the `ollama embed` prefix keeps this from being silenced. - assert_eq!( - expected_error_kind("provider config validation failed: invalid model name"), - None - ); - // Unrelated `model "…" not found` text without the `ollama embed` - // prefix — anchor keeps this from being silenced even when the - // exact MA/KM wire-shape substring appears in another context. - assert_eq!( - expected_error_kind(r#"provider listing failed: model \"foo\" not found in registry"#), - None - ); - } - - #[test] - fn classifies_local_ai_capability_unavailable_errors() { - // OPENHUMAN-TAURI-3B: surfaced by `local_ai_download_asset` when a - // user on a 0–4 GB RAM tier requests a vision asset. Both canonical - // wire shapes — emitted from `assets.rs` and `vision_embed.rs` — - // must classify as expected so they stop reaching Sentry. - for raw in [ - "Vision is disabled for this RAM tier. Switch to the 4-8 GB tier or above to enable it.", - "vision summaries are unavailable for this RAM tier. Use OCR-only summarization or switch to a higher local AI tier.", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::LocalAiCapabilityUnavailable), - "should classify as local-ai capability unavailable: {raw}" - ); - } - - // Wrapped by the RPC dispatch layer as it reaches `report_error_or_expected` - // — the classifier is substring-based, so caller context must not defeat it. - assert_eq!( - expected_error_kind( - "rpc.invoke_method failed: Vision is disabled for this RAM tier. Switch to the 4-8 GB tier or above to enable it." - ), - Some(ExpectedErrorKind::LocalAiCapabilityUnavailable) - ); - } - - #[test] - fn classifies_prompt_injection_blocked_errors() { - // OPENHUMAN-TAURI-140: ~1 480 events from `openhuman.agent_chat` where - // users' messages scored ≥ 0.45 on the injection heuristic. Both - // enforcement wire shapes must be classified as expected so they stop - // reaching Sentry. - for raw in [ - "Prompt flagged for security review and was not processed. Please rephrase clearly.", - "Prompt blocked by security policy. Please rephrase without instruction overrides or exfiltration requests.", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::PromptInjectionBlocked), - "should classify as prompt-injection blocked: {raw}" - ); - } - - // Wrapped by the RPC dispatch layer — substring match must survive the prefix. - assert_eq!( - expected_error_kind( - "rpc.invoke_method failed: Prompt flagged for security review and was not processed. Please rephrase clearly." - ), - Some(ExpectedErrorKind::PromptInjectionBlocked) - ); - } - - #[test] - fn does_not_classify_unrelated_messages_as_prompt_injection_blocked() { - // Must not silently swallow real security errors or generic "prompt" mentions. - assert_eq!( - expected_error_kind("prompt injection detected in tool arguments"), - None - ); - assert_eq!( - expected_error_kind("security review required for deploy"), - None - ); - } - - // ── ContextWindowExceeded (TAURI-RUST-501) ───────────────────────────── - - #[test] - fn classifies_context_window_exceeded_rereport() { - // TAURI-RUST-501: the custom-provider 500 body that escapes the - // provider api_error cascade's own status-gated checks. When the - // error is re-raised by `agent.run_single` / `web_channel. - // run_chat_task`, `report_error_or_expected` runs the classifier on - // the full message — this arm must catch the new phrasing. - assert_eq!( - expected_error_kind( - "custom API error (500 Internal Server Error): \ - {\"error\":{\"code\":500,\"message\":\"Context size has been exceeded.\",\"type\":\"server_error\"}}" - ), - Some(ExpectedErrorKind::ContextWindowExceeded) - ); - - // The established phrasings the provider/reliable layer already - // recognized must classify here too (single-source matcher). - for raw in [ - "OpenAI API error (400): This model's maximum context length is 8192 tokens", - "request exceeds the context window of this model", - "context length exceeded", - "prompt is too long", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ContextWindowExceeded), - "should classify as context-window-exceeded: {raw}" - ); - } - } - - #[test] - fn does_not_classify_unrelated_messages_as_context_window_exceeded() { - // Anchors are context-overflow specific. A generic "window" or - // "context" mention, or an unrelated rate-limit "exceeded", must - // not classify. - for raw in [ - "rate limit exceeded, retry after 30s", - "failed to open context menu window", - "tool call exceeded the allowed budget", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "must NOT classify as context-window-exceeded: {raw}" - ); - } - } - - // ── FilesystemUserPathInvalid (TAURI-RUST-4QH) ───────────────────────── - - #[test] - fn classifies_vault_create_root_path_not_a_directory_as_filesystem_user_path_invalid() { - // TAURI-RUST-4QH: verbatim wire shape from - // `openhuman::vault::ops::vault_create` line 37 when the - // user-picked vault folder doesn't resolve to an existing - // directory. Bubbles up as the RPC dispatcher's - // `display_message` and reaches `report_error_or_expected` — - // must classify so no Sentry event fires. - assert_eq!( - expected_error_kind( - "root_path is not a directory: /Users/zadam/Documents/SndBrainOpenHuman" - ), - Some(ExpectedErrorKind::FilesystemUserPathInvalid) - ); - - // The same body wrapped by the JSON-RPC dispatcher's `display_message` - // prefix (`rpc.invoke_method` re-emit shape from `src/core/jsonrpc.rs`). - // Must still classify so the dispatch-site re-report doesn't escape - // the matcher even if a future caller layers more context. - assert_eq!( - expected_error_kind( - "rpc.invoke_method failed: root_path is not a directory: /Users/alice/openhuman-data" - ), - Some(ExpectedErrorKind::FilesystemUserPathInvalid) - ); - } - - #[test] - fn classifies_http_host_hosted_path_not_a_directory_as_filesystem_user_path_invalid() { - // Preempt the symmetric shape from - // `openhuman::http_host::path_utils:23` — - // `"hosted path is not a directory: "`. Not yet observed - // in Sentry but shares the same RPC validation polarity as - // vault_create's `root_path` check. Anchoring on - // `"path is not a directory:"` (with trailing colon) covers - // both without two separate matchers. - assert_eq!( - expected_error_kind("hosted path is not a directory: /var/www/static-site"), - Some(ExpectedErrorKind::FilesystemUserPathInvalid) - ); - } - - #[test] - fn does_not_classify_unrelated_path_messages_as_filesystem_user_path_invalid() { - // Polarity contract — the anchor requires a trailing colon - // after `"is not a directory"`, which discriminates user input - // (path follows the colon) from other shapes: - // - // 1. The `skills::ops_install:475` SAFETY GUARD — - // `" is not a directory — refusing to remove"` — must - // stay actionable. It catches an `rm -rf` invariant violation - // (the target should have been a directory but wasn't), - // which is a code bug, not user input. - // 2. A narrative log line that happens to mention the phrase - // without the user-path colon suffix is not a validation - // failure and must not be silenced. - // 3. The dot-prefix variant from POSIX `EISDIR`/`ENOTDIR` - // renderings (`"Is a directory (os error 21)"`) is the - // inverse condition — different code path entirely. - for raw in [ - // Safety guard — must NOT classify. - "/tmp/openhuman-cache is not a directory — refusing to remove", - // Narrative log line — must NOT classify. - "checked that path is not a directory before mkdir", - // Inverse condition (os error 21: EISDIR) — must NOT classify. - "open /etc/passwd failed: Is a directory (os error 21)", - // Bare path with no `directory` mention — must NOT classify. - "root_path must be absolute: ./relative/path", - // Generic body with the trailing colon but no known vault/http_host - // prefix — must NOT classify (future provider/storage errors that - // happen to embed "path is not a directory: ..." should reach Sentry). - "input config path is not a directory: /etc/foo", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "polarity contract: must NOT classify as FilesystemUserPathInvalid: {raw}" - ); - } - } - - // ── EmptyProviderResponse (TAURI-RUST-4Z1) ───────────────────────────── - - #[test] - fn classifies_empty_provider_response_web_channel_rereport() { - // TAURI-RUST-4Z1: the web-channel re-report of the agent harness's - // empty-provider-response bail. `run_chat_task` wraps the flattened - // string and routes it through `report_error_or_expected` — the - // agent-layer typed suppression (PR #2790) can't reach it, so this - // string classifier must. - assert_eq!( - expected_error_kind( - "run_chat_task failed client_id=l1uxaLd20_1mAdhp \ - thread_id=thread-8f03e7f7-3477-42cd-9283-f0bacd4bfbca \ - request_id=a73716a3-a85a-4045-984b-315772c5b3b8 \ - error=The model returned an empty response. Please try again." - ), - Some(ExpectedErrorKind::EmptyProviderResponse) - ); - - // Bare user-facing string (the verbatim `turn.rs` emission), in case - // a different call site re-reports it without the run_chat_task wrap. - assert_eq!( - expected_error_kind("The model returned an empty response. Please try again."), - Some(ExpectedErrorKind::EmptyProviderResponse) - ); - } - - #[test] - fn does_not_classify_unrelated_empty_response_phrases() { - // Polarity contract: the anchor is `"model returned an empty - // response"`, NOT the looser `"empty response"`. The sibling paths - // below use different subjects or phrasings and are not user-facing - // failures — they must stay out of this bucket so a real regression - // in those paths still reaches Sentry. - for raw in [ - // payload_summarizer.rs:261 — internal fall-through, not a failure. - "[payload_summarizer] summarizer returned empty response, falling through", - // subagent_runner/extract_tool.rs:379 — graceful empty extraction. - "[extract_from_result] provider returned an empty response; returning empty extraction", - // Generic mention without the model-subject anchor. - "warning: empty response body from health probe", - // channels/bus.rs:185 — channel-inbound graceful fallback (routes - // through report_error_or_expected; subject is "agent", not "model"). - "[channel-inbound] agent returned empty response — finalizing draft with fallback", - // memory/query/walk.rs:292 — debug-level memory walk, not a failure. - "[memory_tree_walk] turn=3 LLM gave up (empty response)", - // learning/reflection.rs:576 — reflection skip, not a failure. - "[learning] reflection skipped (empty response — gate off or local AI unavailable)", - // agent/harness/session/turn.rs:811 — "provider returned an empty - // final response" uses subject "provider", not "model"; must not match. - "[agent_loop] provider returned an empty final response (i=2, no text, no tool calls)", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "must NOT classify as EmptyProviderResponse: {raw}" - ); - } - } - - #[test] - fn classifies_memory_store_pii_rejection_errors() { - // TAURI-RUST-54T: ~915 events from one user where the PII guard - // rejected memory-store writes on namespace/key values that look like - // personal identifiers. All three canonical wire shapes — from - // `documents.rs` (upsert_document / upsert_document_metadata_only) - // and `kv.rs` (kv_set_global / kv_set_namespace) — must classify as - // expected so they stop reaching Sentry. - for raw in [ - "document namespace/key cannot contain personal identifiers", - "kv key cannot contain personal identifiers", - "kv namespace/key cannot contain personal identifiers", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::MemoryStorePiiRejection), - "should classify as memory-store PII rejection: {raw}" - ); - } - - // Wrapped by the RPC dispatch layer — substring match must survive the - // `rpc.invoke_method failed: ` prefix that `jsonrpc.rs` prepends. - assert_eq!( - expected_error_kind( - "rpc.invoke_method failed: document namespace/key cannot contain personal identifiers" - ), - Some(ExpectedErrorKind::MemoryStorePiiRejection) - ); - } - - #[test] - fn classifies_memory_store_breaker_open() { - // TAURI-RUST-52X (~455 events on self-hosted Sentry): the chunk-store - // per-path circuit breaker tripped after consecutive SQLite init - // failures. The Windows wire shape is wrapped by - // `memory_tree::tree::rpc::pipeline_status_rpc`'s `chunk aggregates: …` - // context so the substring matcher must survive that prefix. - for raw in [ - // Canonical wire shape from `get_or_init_connection`. - "[memory_tree] circuit breaker open for /home/u/.openhuman/workspace/memory_tree/chunks.db: too many consecutive init failures", - // Canonical wire shape wrapped by the RPC handler's - // `format!("chunk aggregates: {e:#}")` context. - r"chunk aggregates: [memory_tree] circuit breaker open for C:\Users\u\.openhuman\users\6a09\workspace\memory_tree\chunks.db: too many consecutive init failures", - // Wrapped further by the JSON-RPC dispatch layer before reaching - // `report_error_or_expected`. - r"rpc.invoke_method failed: chunk aggregates: [memory_tree] circuit breaker open for /home/u/.openhuman/workspace/memory_tree/chunks.db: too many consecutive init failures", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::MemoryStoreBreakerOpen), - "should classify memory-store breaker-open: {raw}" - ); - } - } - - #[test] - fn classifies_disk_full_errors() { - for raw in [ - // Canonical POSIX errno 28 rendering from `std::io::Error`. - "Failed to create auth profile lock: open lock file: No space left on device (os error 28)", - // Same shape from a different call site — `tokio::fs::write` - // for a state snapshot. - "state snapshot write failed: No space left on device (os error 28)", - // Windows ERROR_DISK_FULL (112) rendering. - "log rotation failed: There is not enough space on the disk. (os error 112)", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::DiskFull), - "should classify disk-full: {raw}" - ); - } - } - - #[test] - fn does_not_classify_unrelated_space_messages() { - // Generic "space" prose without the errno-text anchor must not be - // silenced — the matcher pins to the platform-stable errno - // renderings only. - assert_eq!( - expected_error_kind("workspace path is invalid: contains a space character"), - None - ); - assert_eq!( - expected_error_kind("not enough memory to allocate buffer"), - None - ); - } - - #[test] - fn classifies_config_load_timed_out() { - // Canonical wire string emitted by `load_config_with_timeout` and - // `reload_config_snapshot_with_timeout` in - // `src/openhuman/config/ops.rs`. Drops TAURI-RUST-5X. - assert_eq!( - expected_error_kind("Config loading timed out"), - Some(ExpectedErrorKind::ConfigLoadTimedOut), - ); - // Same shape after the RPC dispatch wraps it for display — the - // matcher is substring-anchored, so a context prefix does not - // break it. - assert_eq!( - expected_error_kind("rpc.invoke_method failed: Config loading timed out"), - Some(ExpectedErrorKind::ConfigLoadTimedOut), - ); - } - - #[test] - fn does_not_classify_unrelated_timeouts_as_config_load_timed_out() { - // Network / HTTP timeouts go to `NetworkUnreachable` / - // `TransientUpstreamHttp`, not the config-load bucket. The - // anchor is the full literal phrase, so a bare "timed out" or - // "operation timed out" body cannot trip this matcher. - assert_ne!( - expected_error_kind( - "Channel discord error: IO error: Operation timed out (os error 60); restarting" - ), - Some(ExpectedErrorKind::ConfigLoadTimedOut), - ); - assert_ne!( - expected_error_kind("OpenHuman API error (504 Gateway Timeout): error code: 504"), - Some(ExpectedErrorKind::ConfigLoadTimedOut), - ); - // Bare "timed out" without the config-load phrase must not match. - assert_eq!(expected_error_kind("cron job timed out after 30s"), None,); - } - - fn classifies_whatsapp_data_sqlite_busy_errors() { - for raw in [ - r#"[whatsapp_data] ingest failed: upsert wa_message chat=120363402402350155@g.us msg=false_120363402402350155@g.us_3A357F28AE74548B1507_207897942335683@lid: database is locked: Error code 5: The database file is locked"#, - r#"rpc.invoke_method failed: [whatsapp_data] ingest failed: upsert wa_message [email] msg=false_120363402402350155@g.us_3A357F28AE74548B1507_207897942335683@lid: database is locked: Error code 5: The database file is locked"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::WhatsAppDataSqliteBusy), - "should classify whatsapp_data sqlite busy/locked: {raw}" - ); - } - } - - #[test] - fn does_not_classify_unrelated_sqlite_lock_messages_as_whatsapp_busy() { - for raw in [ - "failed to run subconscious schema DDL: database is locked", - "memory queue write failed: database table is locked", - "[whatsapp_data] list_messages failed: database is locked", - ] { - assert_ne!( - expected_error_kind(raw), - Some(ExpectedErrorKind::WhatsAppDataSqliteBusy), - "must not classify as whatsapp_data sqlite busy: {raw}" - ); - } - } - - #[test] - fn does_not_classify_unrelated_messages_as_memory_pii_rejection() { - // A generic "personal identifiers" mention without the "cannot contain" - // anchor must not be silenced. - assert_eq!( - expected_error_kind("processing personal identifiers"), - None, - "must not match a bare 'personal identifiers' mention" - ); - // The secret-rejection variant uses different wording and must not be - // swallowed by the PII classifier. - assert_eq!( - expected_error_kind("document namespace/key cannot contain secrets"), - None, - "secret rejection must remain unclassified" - ); - } - - #[test] - fn does_not_classify_unrelated_breaker_messages() { - // Generic "circuit breaker open" without the `[memory_tree]` anchor - // must not be silenced — other domains may use the same phrase for - // real bugs that need to reach Sentry. - assert_eq!( - expected_error_kind("provider reliability: circuit breaker open for openai"), - None - ); - // The `[memory_tree]` tag alone is not enough — must co-occur with - // the `circuit breaker open` substring. - assert_eq!( - expected_error_kind("[memory_tree] failed to run schema DDL: disk full"), - None - ); - } - - // ── Upstream rate-limit suppression (OPENHUMAN-TAURI-S / -6Y / -2E / -RQ) ─ - - /// Canonical Anthropic / OpenAI body with a structured `"rate_limit_error"` - /// type — OPENHUMAN-TAURI-2E (~1 482 events) and -RQ (~741 events). - #[test] - fn classifies_rate_limit_error_type_as_transient() { - for raw in [ - // Direct 429 from the embeddings path (OPENHUMAN-TAURI-RQ): - r#"Embedding API error (429 Too Many Requests): {"error":{"message":"Rate limit exceeded. Please retry after a brief wait.","type":"rate_limit_error"}}"#, - // Via llm_provider.api_error (OPENHUMAN-TAURI-2E): - r#"[observability] llm_provider.api_error failed: OpenHuman API error (429 Too Many Requests): {"error":{"message":"Rate limit exceeded. Please retry after a brief wait.","type":"rate_limit_error"}}"#, - // Re-reported by agent.run_single: - r#"run_chat_task failed client_id=abc thread_id=t1 request_id=r1 error=OpenHuman API error (429 Too Many Requests): {"error":{"message":"Rate limit exceeded.","type":"rate_limit_error"}}"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "should classify rate_limit_error body as transient: {raw}" - ); - } - } - - /// OpenHuman backend wrapping an upstream 429 as HTTP 500 with a - /// `"upstream rate limit exceeded"` body — OPENHUMAN-TAURI-6Y (~19 849 - /// events). - #[test] - fn classifies_upstream_rate_limit_in_500_body_as_transient() { - for raw in [ - r#"OpenHuman API error (500 Internal Server Error): {"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'. Please retry shortly."}"#, - r#"[observability] llm_provider.api_error failed: OpenHuman API error (500 Internal Server Error): {"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'. Please retry shortly.","details":{"provider":"gmi","upstreamModel":"deepseek-ai/DeepSeek-V3-0324"}}"#, - // Re-wrapped by rpc.invoke_method: - r#"rpc.invoke_method failed: LLM summarisation failed: OpenHuman API error (500 Internal Server Error): {"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'."}"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "should classify upstream-rate-limit-in-500 as transient: {raw}" - ); - } - } - - /// Backend returning HTTP 500 with a numeric `"429 rate limit exceeded"` - /// body — OPENHUMAN-TAURI-S (~6 984 events). - #[test] - fn classifies_429_rate_limit_in_500_body_as_transient() { - for raw in [ - r#"OpenHuman API error (500 Internal Server Error): {"success":false,"error":"429 rate limit exceeded, please try again later"}"#, - r#"[observability] llm_provider.api_error failed: OpenHuman API error (500 Internal Server Error): {"success":false,"error":"429 rate limit exceeded, please try again later"}"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "should classify 429-in-500-body as transient: {raw}" - ); - } - } - - /// The security::policy `"Rate limit exceeded: action budget exhausted"` - /// must NOT be silenced — it's a user-facing hard stop, not a transient - /// upstream quota hit. - #[test] - fn does_not_classify_security_policy_rate_limit_as_transient() { - let msg = "Rate limit exceeded: action budget exhausted (0 actions/hour). \ - Increase the limit in Settings -> Advanced -> Agent autonomy"; - assert_eq!( - expected_error_kind(msg), - None, - "security policy action-budget error must reach Sentry: {msg}" - ); - // Wrapped by rpc.invoke_method — the prefix must not accidentally - // trigger the `api error (` anchor. - assert_eq!( - expected_error_kind(&format!("rpc.invoke_method failed: {msg}")), - None, - "wrapped security policy action-budget error must reach Sentry" - ); - } - - /// Standalone `"rate limit exceeded"` without the `"api error ("` anchor - /// must NOT be silenced — keeps loose phrases from accidentally demoting - /// unrelated errors. - #[test] - fn does_not_classify_bare_rate_limit_exceeded_as_transient() { - assert_eq!( - expected_error_kind("rate limit exceeded"), - None, - "bare 'rate limit exceeded' without API error anchor must reach Sentry" - ); - } - - /// `is_upstream_rate_limit_message` predicate unit tests — verifies the - /// polarity contract independently of `expected_error_kind`. - #[test] - fn upstream_rate_limit_predicate_matches_expected_shapes() { - for lower in [ - r#"{"error":{"message":"rate limit exceeded.","type":"rate_limit_error"}}"#, - "upstream rate limit exceeded for model 'summarization-v1'", - "429 rate limit exceeded, please try again later", - r#"openai api error (429 too many requests): {"error":{"message":"rate limit exceeded.","type":"rate_limit_error"}}"#, - ] { - assert!( - is_upstream_rate_limit_message(lower), - "should match: {lower}" - ); - } - } - - #[test] - fn upstream_rate_limit_predicate_does_not_match_unrelated() { - for lower in [ - // security::policy budget message — must not be swallowed - "rate limit exceeded: action budget exhausted (0 actions/hour)", - // bare phrase without anchor - "rate limit exceeded", - // unrelated 500 body - r#"{"success":false,"error":"internal server error"}"#, - // budget exhausted — different concept - "budget exhausted, add credits to continue", - ] { - assert!( - !is_upstream_rate_limit_message(lower), - "should not match: {lower}" - ); - } - } - - #[test] - fn does_not_classify_unrelated_messages_as_capability_unavailable() { - // The classifier anchors on the exact "for this RAM tier" substring. - // Messages that talk about RAM in a different context (sizing the - // tier list, doc references) must not be silenced. - assert_eq!(expected_error_kind("ollama embed failed: out of RAM"), None); - assert_eq!( - expected_error_kind("local_ai_set_ram_tier failed: invalid tier value"), - None - ); - } - - #[test] - fn classifies_network_unreachable_errors() { - // OPENHUMAN-TAURI-32: reqwest's transport-level error wrapped by the - // web_channel error site. The classifier must catch it even when - // embedded in caller context, since `report_error_or_expected` runs - // `expected_error_kind` on the full anyhow chain. - assert_eq!( - expected_error_kind( - "run_chat_task failed client_id=abc thread_id=t1 request_id=r1 \ - error=error sending request for url (https://api.tinyhumans.ai/openai/v1/chat/completions)" - ), - Some(ExpectedErrorKind::NetworkUnreachable) - ); - for raw in [ - "error sending request for url (https://api.example.com/x)", - "provider failed: dns error: failed to lookup address information", - "tcp connect: connection refused (os error 61)", - "stream closed: connection reset by peer", - "network is unreachable (os error 51)", - "no route to host", - "tls handshake eof", - "certificate verify failed: unable to get local issuer certificate", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::NetworkUnreachable), - "should classify as network-unreachable: {raw}" - ); - } - } - - #[test] - fn does_not_classify_unrelated_provider_errors_as_network() { - // Status-bearing provider failures (404, 500, …) are surfaced via - // their HTTP status path and must NOT be silenced by the - // network-unreachable classifier — the body text doesn't hit any of - // the transport-level markers. - assert_eq!( - expected_error_kind("OpenAI API error (404): model gpt-x not found"), - None - ); - assert_eq!( - expected_error_kind("OpenAI API error (500): internal server error"), - None - ); - } - - #[test] - fn classifies_wave4_socket_transport_wire_shapes() { - // OPENHUMAN-TAURI-44 (~50 events): libc `getaddrinfo()` rendering - // without the `dns error` token, wrapped by the socket emit site. - // The Wave 4 matcher arms catch the literal resolver phrases that - // the original `dns error` substring would miss when reqwest's - // wrapper isn't in the chain (e.g. tungstenite IO errors). - assert_eq!( - expected_error_kind( - "[socket] Connection failed (sustained outage after 5 attempts): \ - WebSocket connect: IO error: failed to lookup address information: \ - nodename nor servname provided, or not known" - ), - Some(ExpectedErrorKind::NetworkUnreachable) - ); - - // OPENHUMAN-TAURI-4P (~66 events): tungstenite renders a captive - // portal / corporate proxy that intercepts the WS handshake as - // `WsError::Http(200)` → `"HTTP error: 200 OK"`. Classify as - // network-unreachable since no amount of app-side retry can pierce - // an intercepting proxy. - assert_eq!( - expected_error_kind( - "[socket] Connection failed (sustained outage after 5 attempts): \ - WebSocket connect: HTTP error: 200 OK" - ), - Some(ExpectedErrorKind::NetworkUnreachable) - ); - } - - #[test] - fn http_200_classifier_does_not_silence_unrelated_log_lines() { - // The captive-portal arm anchors on `"http error: 200 ok"` (the - // exact tungstenite `WsError::Http(200)` Display rendering). - // Adjacent non-WebSocket log lines that mention `"HTTP/1.1 200 OK"` - // or `"status: 200 OK"` MUST NOT classify — those are normal-flow - // success traces, not failure events. Pin this precedence so a - // future refactor doesn't broaden the substring. - assert_eq!(expected_error_kind("HTTP/1.1 200 OK"), None); - assert_eq!( - expected_error_kind("upstream returned status: 200 OK after retry"), - None - ); - } - - #[test] - fn classifies_tls_handshake_eof_as_network_unreachable() { - // TAURI-RUST-4ZD (first seen on `openhuman@0.56.0+e8968077aeb5`, - // Windows): `native-tls` renders a peer / firewall / antivirus / - // corporate-proxy TCP close mid-TLS-handshake as - // `"TLS error: native-tls error: unexpected EOF during handshake"`, - // which `socket::ws_loop::run_connection` wraps as - // `"WebSocket connect: "` and the supervisor's - // sustained-outage escalation wraps again. The existing - // `"tls handshake"` arm misses it because the words are not - // contiguous in this render (`"tls error"` … `"during handshake"`). - // Same user-environment shape as the other handshake-stage entries: - // the socket supervisor already retries with exponential backoff and - // Sentry has no actionable signal beyond that. - assert_eq!( - expected_error_kind( - "[socket] Connection failed (sustained outage after 5 attempts): \ - WebSocket connect: TLS error: native-tls error: unexpected EOF during handshake" - ), - Some(ExpectedErrorKind::NetworkUnreachable) - ); - - // Bare native-tls render (no socket-supervisor wrap) — fires when the - // same handshake EOF escapes through a non-supervisor call site. The - // classifier runs on the full anyhow chain, so the shorter form must - // also match. - assert_eq!( - expected_error_kind("TLS error: native-tls error: unexpected EOF during handshake"), - Some(ExpectedErrorKind::NetworkUnreachable) - ); - } - - #[test] - fn classifies_ws_protocol_wrong_http_version_as_network_unreachable() { - // CORE-RUST-DP (~2 events / 24h on `openhuman@0.56.0+e8968077aeb5`, - // self-hosted `core-rust`): tungstenite renders - // `ProtocolError::WrongHttpVersion` as - // `"WebSocket protocol error: HTTP version must be 1.1 or higher"`, - // wrapped by `socket::ws_loop::run_connection` as - // `"WebSocket connect: "` and then by the supervisor's - // sustained-outage escalation as - // `"[socket] Connection failed (sustained outage after N attempts): - // WebSocket connect: WebSocket protocol error: HTTP version must be - // 1.1 or higher"`. - // - // The handshake requires HTTP/1.1; a server or intermediary proxy - // that responds with HTTP/2+ to the upgrade is misconfigured - // upstream — same shape as the existing `"tls handshake"` / - // `"certificate verify failed"` user-environment entries. The - // supervisor already retries with exponential backoff; Sentry has - // no actionable signal to add. - assert_eq!( - expected_error_kind( - "[socket] Connection failed (sustained outage after 5 attempts): \ - WebSocket connect: WebSocket protocol error: HTTP version must be 1.1 or higher" - ), - Some(ExpectedErrorKind::NetworkUnreachable) - ); - - // Bare tungstenite render (no socket-supervisor wrap) — fires when - // the same protocol error escapes through a non-supervisor call - // site. The classifier runs on the full anyhow chain, so the - // shorter form must also match. - assert_eq!( - expected_error_kind("WebSocket protocol error: HTTP version must be 1.1 or higher"), - Some(ExpectedErrorKind::NetworkUnreachable) - ); - } - - #[test] - fn tls_handshake_eof_anchor_does_not_silence_unrelated_log_lines() { - // The anchor is the literal `"unexpected eof during handshake"` - // phrase. A bare data-phase `"unexpected EOF"` (server closed - // mid-stream, parser truncation, …) MUST NOT classify — those are - // outside the handshake stage and may carry actionable signal. Pin - // the rejection contract so a future refactor doesn't loosen the - // substring into a generic `"unexpected eof"` matcher. - for raw in [ - "stream closed: unexpected EOF", - "reqwest: unexpected EOF while reading body", - "json parser: unexpected EOF at byte 1024", - "decoder hit unexpected eof mid-frame", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "non-handshake unexpected-EOF log line must NOT classify: {raw}" - ); - } - } - - #[test] - fn wrong_http_version_anchor_does_not_silence_unrelated_log_lines() { - // The anchor is the literal tungstenite Display string. Adjacent - // log lines that mention HTTP version in any other context - // (`"upgrading from HTTP/1.0 to HTTP/2"`, `"HTTP/1.1 only"`, - // `"server requires HTTP version 2.0"`) MUST NOT classify — those - // are unrelated transport / negotiation traces and may carry - // actionable signal. Pin the rejection contract so a future - // refactor doesn't loosen the substring into a generic - // `"http version"` matcher. - for raw in [ - "[transport] upgrading from HTTP/1.0 to HTTP/2", - "server advertises HTTP version 2.0 (h2 alpn)", - "client supports HTTP/1.1 only", - "version mismatch: requires HTTP/1.2 or higher", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "unrelated HTTP-version log line must NOT classify: {raw}" - ); - } - } - - #[test] - fn classifies_transient_upstream_http_errors() { - // OPENHUMAN-TAURI-5Z: the canonical shape emitted by - // `providers::ops::api_error` and re-raised through `agent.run_single`. - assert_eq!( - expected_error_kind("OpenHuman API error (504 Gateway Timeout): error code: 504"), - Some(ExpectedErrorKind::TransientUpstreamHttp) - ); - - // Every transient code must classify, whether the status renders as - // bare digits or " ". - for raw in [ - "OpenHuman API error (408): request timeout", - "OpenAI API error (429 Too Many Requests): rate limit", - "Anthropic API error (502 Bad Gateway): upstream unhealthy", - "OpenHuman API error (503): service unavailable", - "Provider API error (504): upstream timed out", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "should classify as transient upstream HTTP: {raw}" - ); - } - - // Wrapped in an anyhow chain (as it reaches the agent layer) must - // still classify — `expected_error_kind` is substring-based. - assert_eq!( - expected_error_kind( - "agent turn failed: OpenHuman API error (504 Gateway Timeout): \ - error code: 504" - ), - Some(ExpectedErrorKind::TransientUpstreamHttp) - ); - - // TAURI-RUST-H (~1360 events, 504) / TAURI-RUST-2T (~310 events, 502): - // legacy no-paren wire shape from older `embeddings::openai` / - // `embeddings::cohere` emit-site formats that predate the - // parenthesised `({status})` rendering. Anchored on the trailing - // space after the status code so unrelated digit runs don't match. - for raw in [ - "Embedding API error 504 Gateway Timeout: error code: 504", - "Embedding API error 502 Bad Gateway: error code: 502", - "Cohere embed API error 503 Service Unavailable: error code: 503", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "should classify legacy no-paren transient shape: {raw}" - ); - } - } - - #[test] - fn does_not_classify_unrelated_digit_runs_as_transient() { - // The legacy no-paren matcher anchors `api error ` with a - // trailing space so adjacent digit runs (`api error 5042…`) and - // non-transient codes (400/401/403/404) don't get silenced. - assert_eq!( - expected_error_kind("OpenHuman API error 400 Bad Request: malformed body"), - None - ); - assert_eq!( - expected_error_kind("provider returned api error 5042 (custom internal sentinel)"), - None - ); - } - - #[test] - fn integrations_post_composio_timeout_dropped() { - // OPENHUMAN-TAURI-18 / -G regression guard. The integrations - // client at `crate::openhuman::integrations::client::IntegrationClient::post` - // builds the reqwest error chain and routes it through - // `report_error_or_expected(.., "integrations", "post", &[("failure", - // "transport")])`. The chain text contains the - // `"error sending request for url"` anchor so - // `is_network_unreachable_message` matches first and demotes to - // `NetworkUnreachable` (functionally equivalent to - // `TransientUpstreamHttp` for Sentry suppression — both routes - // skip the report path via `report_expected_message`). - // - // Pinning this exact wire shape catches a future refactor that - // drops the URL anchor (e.g. a chain-flatten helper that strips - // it for "PII safety"), which would silently re-open the leak. - let chain = "error sending request for url \ - (https://api.tinyhumans.ai/agent-integrations/composio/execute) → \ - client error (SendRequest) → connection error → \ - Operation timed out (os error 60)"; - assert_eq!( - expected_error_kind(chain), - Some(ExpectedErrorKind::NetworkUnreachable), - "TAURI-18 chain shape must classify as NetworkUnreachable" - ); - - // If the URL anchor is ever dropped, the transport-phrase - // fallback (`operation timed out` from - // `TRANSIENT_TRANSPORT_PHRASES`) catches it via the message - // classifier helper used at upstream re-emit sites — confirm - // both paths so the regression surface is fully pinned. - assert!( - is_transient_message_failure(chain), - "TAURI-18 chain must also satisfy upstream message classifier \ - (defense-in-depth for sites that lose the URL anchor)" - ); - } - - #[test] - fn channel_supervisor_operation_timed_out_classifies_as_expected() { - // OPENHUMAN-TAURI-EM (128 events) + TAURI-RUST-15/-BB: `channels::runtime::supervision` - // wraps a channel listener failure as - // `format!("Channel {} error: {e:#}; restarting", ch.name())` and - // routes the message through `report_error_or_expected`. The - // newer `ChannelSupervisorRestart` classifier (added for the - // broader 11.4k-event Sentry leak) anchors on the supervisor - // wrapper shape itself — `"Channel error: …; restarting"` - // — and takes precedence over `NetworkUnreachable`. That single - // arm now covers every ETIMEDOUT / WSAETIMEDOUT / hyper-prose - // shape the old narrower anchor pinned, plus OS-localized - // variants the English-only `NetworkUnreachable` would miss. - // - // Demotion tier difference: `ChannelSupervisorRestart` emits at - // `info!` (breadcrumb only, no Sentry event) where - // `NetworkUnreachable` emitted at `warn!` (still captured as a - // Sentry warn event). Sustained outages still page via - // `health.bus` / `FAIL_ESCALATE_THRESHOLD`. - for raw in [ - // macOS (os error 60 = ETIMEDOUT on BSD) - "Channel discord error: IO error: Operation timed out (os error 60); restarting", - // Linux (os error 110 = ETIMEDOUT) - "Channel discord error: IO error: Operation timed out (os error 110); restarting", - // Windows (os error 10060 = WSAETIMEDOUT) - "Channel discord error: IO error: Operation timed out (os error 10060); restarting", - // Same shape on other channels — supervisor wrapper is provider-agnostic. - "Channel slack error: IO error: Operation timed out (os error 60); restarting", - "Channel telegram error: IO error: Operation timed out (os error 110); restarting", - // Bare prose form (no errno suffix) from hyper / tungstenite layers - // that render `std::io::Error` without `raw_os_error()`. - "Channel discord error: WebSocket connect: IO error: Operation timed out; restarting", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ChannelSupervisorRestart), - "channel supervisor timeout shape must classify as ChannelSupervisorRestart \ - (precedence over NetworkUnreachable; got {:?} for {raw:?})", - expected_error_kind(raw) - ); - } - } - - #[test] - fn operation_timed_out_negative_cases_still_report() { - // Counter-case: a configuration/validation message that mentions - // "timeout" as a knob name (not transport state) and has no other - // classifier anchor must still reach Sentry. The substring chosen - // for the new matcher is `"operation timed out"`, not `"timeout"`, - // precisely so unrelated mentions of the word do not collide. - assert_eq!( - expected_error_kind("config rejected: timeout must be a positive integer"), - None, - "config validation noise (no 'operation timed out' anchor) must still reach Sentry" - ); - // Bare empty string — no anchors at all. - assert_eq!(expected_error_kind(""), None); - } - - #[test] - fn channels_dispatch_re_emit_of_provider_502_classifies_as_transient() { - // OPENHUMAN-TAURI-4F (~157 events) / -1C (~87 events) / -8F - // (~39 events): the reliable provider layer retried 5xx, the - // agent re-raised the error, and `channels::runtime::dispatch` - // re-emitted it under `domain="channels", operation="dispatch_llm_error"` - // via raw `report_error` (which skips classification). Switching - // that site to `report_error_or_expected` routes the chain - // through this classifier — but only works if the canonical - // `"OpenHuman API error (NNN ...)"` substring still anchors the - // match through the channels-layer wrapping. - // - // The wrapping shape at the dispatch site is the agent error - // chain rendered via `format!("{e:#}")`. For a backend 502 from - // `providers::ops::api_error`, that resolves to: - // "OpenHuman API error (502 Bad Gateway): error code: 502" - // possibly prepended with a runner / iteration prefix. Both - // shapes must classify as transient so the dispatch re-emit - // gets demoted. - for raw in [ - "OpenHuman API error (502 Bad Gateway): error code: 502", - "agent.provider_chat failed: OpenHuman API error (503 Service Unavailable): retry budget exhausted", - "all providers exhausted: OpenHuman API error (504 Gateway Timeout): error code: 504", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "channels.dispatch re-emit of {raw:?} must classify as transient" - ); - } - } - - #[test] - fn classifies_socket_transient_http_errors() { - // OPENHUMAN-TAURI-5P / -EZ: tungstenite's `WsError::Http(response)` - // surfaces during the WebSocket upgrade handshake when the backend - // load balancer returns 502 / 504. The socket reconnect loop wraps - // it as `format!("WebSocket connect: {e}")`, producing - // `"WebSocket connect: HTTP error: "`. Each - // sustained-outage threshold escalation routes the formatted reason - // through `report_error_or_expected`, which must classify as - // transient so the per-client noise stops reaching Sentry. - for raw in [ - "WebSocket connect: HTTP error: 502 Bad Gateway", - "WebSocket connect: HTTP error: 503 Service Unavailable", - "WebSocket connect: HTTP error: 504 Gateway Timeout", - "[socket] Connection failed (sustained outage after 5 attempts): \ - WebSocket connect: HTTP error: 502 Bad Gateway", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::TransientUpstreamHttp), - "should classify as transient upstream HTTP (socket shape): {raw}" - ); - } - - // Trailing-colon separator (chained error formatting). - // Note: avoid words like "connection refused" or "timeout" in the - // suffix — those would also match `is_network_unreachable_message` / - // `TRANSIENT_TRANSPORT_PHRASES` and the order in `expected_error_kind` - // would route through `NetworkUnreachable` first, defeating the - // assertion. Both classifications silence the event so production - // behavior is identical, but the test is anchored on the canonical - // socket shape so a future regression in `is_transient_upstream_http_message` - // surfaces here, not behind another classifier. - assert_eq!( - expected_error_kind( - "WebSocket connect: HTTP error: 502: upstream returned bad gateway" - ), - Some(ExpectedErrorKind::TransientUpstreamHttp) - ); - - // Trailing-newline separator (multi-line error chain). - assert_eq!( - expected_error_kind("WebSocket connect: HTTP error: 504\nupstream gateway"), - Some(ExpectedErrorKind::TransientUpstreamHttp) - ); - } - - #[test] - fn does_not_classify_unrelated_http_error_text_as_transient_socket() { - // Bare numeric "HTTP error: 5023" (port number, runbook ID) without - // a separator must NOT silence — pin the matcher to space/newline/colon. - assert_eq!(expected_error_kind("HTTP error: 5023"), None); - // Non-transient HTTP statuses must not match — `WsError::Http` for - // a 401 / 403 / 404 is genuinely actionable (auth / routing bug). - for raw in [ - "WebSocket connect: HTTP error: 401 Unauthorized", - "WebSocket connect: HTTP error: 403 Forbidden", - "WebSocket connect: HTTP error: 404 Not Found", - "WebSocket connect: HTTP error: 500 Internal Server Error", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "must NOT silence actionable socket HTTP error: {raw}" - ); - } - } - - #[test] - fn does_not_classify_actionable_provider_errors_as_transient_upstream() { - // 4xx (other than 408/429) and non-transient 5xx must continue to - // reach Sentry — those are real bugs (wrong model name, malformed - // request, internal exception) that need to be triaged. - for raw in [ - "OpenAI API error (400): bad request", - "OpenAI API error (401): unauthorized", - "OpenAI API error (403): forbidden", - "OpenAI API error (404): model not found", - "OpenAI API error (500): internal server error", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "must NOT silence actionable provider error: {raw}" - ); - } - - // A free-form message that merely mentions "504" without the - // `api error (` prefix must not be classified — pin the match to - // the canonical shape from `ops::api_error`. - assert_eq!( - expected_error_kind("see runbook for 504 handling at https://example.com/504"), - None - ); - } - - #[test] - fn classifies_backend_user_error_responses() { - // OPENHUMAN-TAURI-BC: SharePoint authorize 400 because the user - // didn't fill in the required Tenant Name field. After the - // ProviderUserState classifier was added (#1472 wave E), this - // canonical shape now lands in the more specific - // ProviderUserState bucket — `"missing required fields"` wins - // over the generic 4xx matcher. Either expected-kind silences - // Sentry; the dedicated bucket gives operators a finer-grained - // `kind="provider_user_state"` info-log facet for triage. - let bc = "Backend returned 400 Bad Request for POST \ - https://api.tinyhumans.ai/agent-integrations/composio/authorize: \ - Composio authorization failed: 400 \ - {\"error\":{\"message\":\"Missing required fields: Tenant Name\",\ - \"slug\":\"ConnectedAccount_MissingRequiredFields\",\"status\":400}}"; - assert_eq!( - expected_error_kind(bc), - Some(ExpectedErrorKind::ProviderUserState), - "OPENHUMAN-TAURI-BC wire shape must classify as ProviderUserState (the \ - more specific bucket once #1472 wave E added it)" - ); - - // Cover the rest of the 4xx surface produced by integrations / - // composio clients — all user-input / auth-state failures that - // Sentry can't action. - for raw in [ - "Backend returned 400 Bad Request for POST https://api.example.com/x: bad input", - "Backend returned 401 Unauthorized for GET https://api.example.com/x: token expired", - "Backend returned 403 Forbidden for GET https://api.example.com/x: permission denied", - "Backend returned 404 Not Found for GET https://api.example.com/x: missing", - "Backend returned 422 Unprocessable Entity for POST https://api.example.com/x: validation failed", - "Backend returned 451 Unavailable for Legal Reasons for GET https://api.example.com/x: blocked", - // Lowercased context wrapping is irrelevant — substring match is case-insensitive. - "[observability] integrations.post failed: Backend returned 400 Bad Request for POST https://api.tinyhumans.ai/x: detail", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::BackendUserError), - "must classify as backend user-error: {raw}" - ); - } - } - - #[test] - fn does_not_classify_transient_or_server_backend_errors_as_user_error() { - // 408 / 429 are transient — they belong to the - // upstream-transient bucket (or are retried at the caller), not - // the user-error bucket. A sustained 429 (rate limit cliff) MUST - // still surface so we can react. - for raw in [ - "Backend returned 408 Request Timeout for POST https://api.example.com/x: timeout", - "Backend returned 429 Too Many Requests for POST https://api.example.com/x: slow down", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "transient 4xx must NOT be classified as user-error: {raw}" - ); - } - - // 5xx is always actionable — server bugs need to reach Sentry. - for raw in [ - "Backend returned 500 Internal Server Error for POST https://api.example.com/x: oops", - "Backend returned 502 Bad Gateway for POST https://api.example.com/x: upstream down", - "Backend returned 503 Service Unavailable for POST https://api.example.com/x: maintenance", - "Backend returned 504 Gateway Timeout for POST https://api.example.com/x: slow upstream", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "5xx must NOT be classified as user-error: {raw}" - ); - } - - // A free-form message that mentions "400" but doesn't follow the - // `Backend returned ` prefix from the integrations / - // composio clients must not be silenced. - assert_eq!( - expected_error_kind("see HTTP 400 specification at https://example.com/400"), - None - ); - assert_eq!( - expected_error_kind("OpenAI API error (400): bad request"), - None, - "provider-formatted 4xx must keep going through the provider classifier path" - ); - } - - #[test] - fn classifies_trigger_type_not_found_as_provider_user_state() { - // OPENHUMAN-TAURI-3R / -3S: composio enable_trigger when the slug - // isn't in the trigger registry. Backend wraps the upstream - // composio 4xx as 500, so this would otherwise escape the - // 4xx-only `is_backend_user_error_message` matcher. - assert_eq!( - expected_error_kind( - "Backend returned 500 Internal Server Error for POST \ - https://api.tinyhumans.ai/agent-integrations/composio/triggers: \ - Trigger type GITHUB_PUSH_EVENT not found" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - - // Wrapped by `rpc.invoke_method` / `[composio] sync(toolkit) failed: …` - // — substring match must survive caller context. - assert_eq!( - expected_error_kind( - "rpc.invoke_method failed: Backend returned 500 Internal Server Error \ - for POST /agent-integrations/composio/triggers: \ - Trigger type SLACK_NEW_MESSAGE not found" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - - // Alternate phrasing observed from the same cluster. - assert_eq!( - expected_error_kind( - "composio: Cannot enable trigger 'GITHUB_PUSH_EVENT': trigger not found in registry" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - } - - #[test] - fn classifies_toolkit_not_enabled_as_provider_user_state() { - // OPENHUMAN-TAURI-34: 400 from composio because the user hasn't - // enabled the toolkit. Must classify as ProviderUserState (more - // specific) rather than the generic BackendUserError bucket — the - // ordering in `expected_error_kind` enforces that. - let msg = "Backend returned 400 Bad Request for POST \ - https://api.tinyhumans.ai/agent-integrations/composio/execute: \ - Toolkit \"get\" is not enabled"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState) - ); - - // Wrapped variant (anyhow chain through the agent runtime). - assert_eq!( - expected_error_kind( - "tool.invoke failed: Backend returned 400 Bad Request for POST \ - /agent-integrations/composio/execute: Toolkit \"linear\" is not enabled \ - for this account" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - } - - #[test] - fn classifies_custom_openai_upstream_bad_request_as_provider_user_state() { - assert_eq!( - expected_error_kind( - "custom_openai API error (400 Bad Request): \ - {\"error\":{\"message\":\"Bad request to upstream provider\",\ - \"type\":\"upstream_error\",\"status\":400}}" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - - // Wrapped by higher-level callers (`agent.run_single`, - // `rpc.invoke_method`) must still classify. - assert_eq!( - expected_error_kind( - "agent.run_single failed: custom_openai API error (400 Bad Request): \ - {\"error\":{\"message\":\"Bad request to upstream provider\",\ - \"type\":\"upstream_error\",\"status\":400}}" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - } - - /// Regression for CodeRabbit feedback on PR #2107: the matcher must - /// not demote unrelated errors that happen to contain both - /// "bad request to upstream provider" and "upstream_error" without - /// the `custom_openai API error (400` anchor. - #[test] - fn does_not_silence_unrelated_error_with_only_inner_substrings() { - // No `custom_openai API error (400` prefix → must NOT classify - // as ProviderUserState, otherwise we'd silence actionable bugs. - assert_eq!( - expected_error_kind( - "internal panic in router: bad request to upstream provider \ - (state=upstream_error)" - ), - None, - ); - - // A future hypothetical provider envelope reusing one substring - // also must not classify. - assert_eq!( - expected_error_kind( - "anthropic_api error: upstream_error encountered while \ - forwarding bad request to upstream provider" - ), - None, - ); - } - - #[test] - fn classifies_missing_required_fields_as_provider_user_state() { - // OPENHUMAN-TAURI-97: composio authorize with a blank required - // field. Backend wraps the composio 400 as 500 with the inner - // body embedded as a JSON-stringified error message. - assert_eq!( - expected_error_kind( - "Backend returned 500 Internal Server Error for POST \ - https://api.tinyhumans.ai/agent-integrations/composio/authorize: \ - 400 {\"error\":{\"message\":\"Missing required fields: Your Subdomain\"}}" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - - // Sibling toolkits surface the same shape with different field names. - for raw in [ - "Backend returned 500 Internal Server Error for POST /authorize: Missing required fields: WABA ID", - "Backend returned 500 Internal Server Error for POST /authorize: Missing required fields: Tenant Name", - "Backend returned 400 Bad Request for POST /authorize: Missing required fields: Domain URL", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ProviderUserState), - "missing-required-fields shape must classify: {raw}" - ); - } - } - - #[test] - fn classifies_insufficient_scopes_as_provider_user_state() { - // OPENHUMAN-TAURI-33: gmail sync surfaced the upstream Google - // OAuth scopes error verbatim through composio. Reaches the RPC - // dispatch site via `[composio] sync(gmail) failed: [composio:gmail] - // GMAIL_FETCH_EMAILS page 0: HTTP 403: Request had insufficient - // authentication scopes.`. - assert_eq!( - expected_error_kind( - "[composio:gmail] GMAIL_FETCH_EMAILS page 0: HTTP 403: \ - Request had insufficient authentication scopes." - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - - // Bare upstream shape (in case any future caller forwards without - // the gmail prefix). - assert_eq!( - expected_error_kind("HTTP 403: Request had insufficient authentication scopes."), - Some(ExpectedErrorKind::ProviderUserState) - ); - } - - #[test] - fn classifies_access_terminated_provider_policy_as_provider_user_state() { - assert_eq!( - expected_error_kind( - "custom_openai API error (403 Forbidden): {\"error\":{\"message\":\"Kimi For Coding is currently only available for Coding Agents such as Kimi CLI, Claude Code, Roo Code, Kilo Code, etc.\",\"type\":\"access_terminated_error\"}}" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - - assert_eq!( - expected_error_kind( - "agent turn failed: custom_openai API error (403): currently only available for coding agents" - ), - Some(ExpectedErrorKind::ProviderUserState) - ); - } - - #[test] - fn does_not_classify_unrelated_500s_as_provider_user_state() { - // Sanity check: a generic 500 with no provider-user-state body - // shape must continue to reach Sentry as an actionable event. - assert_eq!( - expected_error_kind( - "Backend returned 500 Internal Server Error for POST \ - /agent-integrations/composio/triggers: random panic in handler" - ), - None - ); - assert_eq!( - expected_error_kind( - "Backend returned 500 Internal Server Error for GET /teams: database connection lost" - ), - None - ); - - // Free-form text that mentions "not found" / "is not enabled" out - // of context must not be silenced. - assert_eq!( - expected_error_kind("file not found at /tmp/x.json"), - None, - "bare 'not found' without 'trigger type' anchor must NOT classify" - ); - assert_eq!( - expected_error_kind("the cache is not enabled in this build"), - None, - "bare 'is not enabled' without 'toolkit ' anchor must NOT classify" - ); - } - - #[test] - fn classifies_provider_config_rejection() { - // #2079 — an OpenHuman abstract tier alias leaked to a custom - // provider; raised again by `agent.run_single` / - // `web_channel.run_chat_task` so it escapes the provider-layer - // demotion and reaches `report_error_or_expected` here. - assert_eq!( - expected_error_kind( - "agent.run_single failed: custom_openai API error (400 Bad Request): \ - The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \ - but you passed reasoning-v1." - ), - Some(ExpectedErrorKind::ProviderConfigRejection) - ); - // #2076 — Moonshot Kimi K2 temperature constraint. - assert_eq!( - expected_error_kind( - "custom_openai API error (400): invalid temperature: only 1 is allowed for this model" - ), - Some(ExpectedErrorKind::ProviderConfigRejection) - ); - // #2202 — unknown / stale model pin (OpenAI-compatible body). - assert_eq!( - expected_error_kind( - "custom_openai API error (400): Model 'claude-opus-4-7' is not available. \ - Use GET /openai/v1/models to list available models." - ), - Some(ExpectedErrorKind::ProviderConfigRejection) - ); - } - - #[test] - fn does_not_classify_unrelated_provider_failures_as_config_rejection() { - // Inverted polarity / scope guard: a 5xx or a generic 4xx with no - // config-rejection body must still reach Sentry as actionable. - // (The OpenHuman backend never emits these phrases, so the - // message-level predicate is intrinsically custom-provider scoped; - // the HTTP-layer twin enforces the non-backend guard explicitly.) - assert_eq!( - expected_error_kind("custom_openai API error (500): internal server error"), - None - ); - assert_eq!( - expected_error_kind( - "custom_openai API error (400 Bad Request): missing required field 'messages'" - ), - None, - "generic 4xx without a config-rejection body must NOT demote" - ); - } - - #[test] - fn unrelated_missing_required_fields_classifies_as_accepted_false_positive() { - // Documents the breadth of the `"missing required fields"` arm — - // unlike the trigger/toolkit arms it has no second anchor, so a - // non-composio call site whose error happens to contain the phrase - // will also demote. This is the accepted false-positive surface - // per the classifier doc-comment (every current emit site is - // scoped to composio/integrations envelopes, so a stray collision - // would have to come from a brand-new opt-in call site). - // - // Pinning this assertion locks the breadth in so a future - // narrowing of the matcher surfaces here instead of silently - // re-bucketing the demote path. - assert_eq!( - expected_error_kind("Internal error: missing required fields in config"), - Some(ExpectedErrorKind::ProviderUserState), - "accepted false-positive: bare 'missing required fields' demotes by design" - ); - } - - #[test] - fn provider_user_state_takes_precedence_over_backend_user_error() { - // Critical ordering guarantee: a 4xx body that contains the - // toolkit-not-enabled phrasing must land in `ProviderUserState` - // (more specific) — not in the generic `BackendUserError` bucket. - // Without the ordering in `expected_error_kind`, the 4xx matcher - // would win and the operator would see a different breadcrumb - // kind than intended (and miss the `kind="provider_user_state"` - // tag in info logs). - let msg = "Backend returned 400 Bad Request for POST \ - /agent-integrations/composio/execute: \ - Toolkit \"github\" is not enabled"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "4xx + toolkit-not-enabled must land in ProviderUserState, not BackendUserError" - ); - } - - // ── TAURI-RUST-X9 (#1166): composio-direct 401 / Invalid API key ──── - - #[test] - fn classifies_composio_direct_invalid_api_key_as_provider_user_state() { - // Canonical Sentry TAURI-RUST-X9 wire shape — the verbatim title - // body from the issue, captured 15,732 times in ~22h on a single - // user with a bad direct-mode key. The classifier must demote - // this to `ProviderUserState` so the polling layer's 5 s retry - // doesn't keep flooding Sentry. - let msg = "[composio-direct] list_connections failed: \ - Composio v3 connected_accounts failed: \ - HTTP 401: Invalid API key: ak_VsUvq*****"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "composio-direct HTTP 401 + Invalid API key must demote to ProviderUserState" - ); - } - - #[test] - fn classifies_composio_direct_invalid_api_key_for_other_ops() { - // Same arm must cover every op-name the direct branches emit — - // not just `list_connections`. The matcher gates on the - // `[composio-direct]` prefix, not on a specific op string, so - // `list_tools` / `authorize` / `list_connections` all demote. - let shapes = [ - // list_tools prefetch fails before the actual list_tools call - "[composio-direct] list_tools: prefetch connections failed: \ - Composio v3 connected_accounts failed: HTTP 401: Invalid API key: ak_…", - // direct authorize hits the v3 /connected_accounts/link wall - "[composio-direct] authorize failed: \ - Composio v3 connected_accounts/link failed: HTTP 401: Invalid API key: ak_…", - // direct list_tools itself - "[composio-direct] list_tools failed: \ - Composio v3 tools failed: HTTP 401: Invalid API key: ak_…", - // periodic-tick rendering (no "[composio-direct]" prefix because - // periodic.rs wraps differently, but the failure still gets the - // hook — handled by ops.rs's report path, not the - // expected_error_kind body shape, so we only verify the - // composio-direct branch here) - ]; - for msg in shapes { - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "every [composio-direct] op with HTTP 401 / Invalid API key must demote: {msg}" - ); - } - } - - #[test] - fn classifies_composio_direct_with_invalid_api_key_only_no_http_401() { - // The matcher accepts EITHER `HTTP 401` OR `Invalid API key` - // alongside the `[composio-direct]` prefix. Catches the wire - // shape variant where the body anchor lands but the status text - // is rendered differently (e.g. "401 Unauthorized" instead of - // "HTTP 401") — same user-state condition. - let msg = "[composio-direct] list_connections failed: \ - Composio v3 connected_accounts failed: \ - 401 Unauthorized: Invalid API key: ak_…"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "composio-direct + Invalid API key body must demote even without literal 'HTTP 401'" - ); - } - - #[test] - fn does_not_classify_unrelated_http_401_as_composio_direct_user_state() { - // Discrimination test: a generic 401 that does NOT carry the - // `[composio-direct]` prefix must NOT match this arm. This - // protects against the arm accidentally swallowing backend-mode - // composio 401s, unrelated integration 401s, or any other - // 401-containing message that lacks the direct-mode anchor. - // - // The backend-mode shape is `Backend returned 401 …`; it does - // not contain `[composio-direct]`, so the new arm rightly skips - // it. Backend-mode 401s remain a real Sentry signal (bad - // service-to-service auth, expired token, etc.). - let backend_401 = "[composio] list_connections failed: \ - Backend returned 401 Unauthorized for GET \ - https://api.tinyhumans.ai/agent-integrations/composio/connections: \ - Invalid API key"; - assert_ne!( - expected_error_kind(backend_401), - Some(ExpectedErrorKind::ProviderUserState), - "backend-mode 401 must NOT demote via the composio-direct arm" - ); - - let unrelated_401 = "GitHub API error: HTTP 401: Bad credentials"; - assert_ne!( - expected_error_kind(unrelated_401), - Some(ExpectedErrorKind::ProviderUserState), - "unrelated 401 (no [composio-direct] anchor) must NOT match the composio-direct arm" - ); - } - - #[test] - fn does_not_classify_composio_direct_500_as_user_state() { - // Real bug shapes — a 500 from the direct v3 path with no auth - // body anchor — must still fall through to `None` so Sentry - // sees them. Without this guard the arm could be too permissive - // and silence genuine backend faults. - let msg = "[composio-direct] list_connections failed: \ - Composio v3 connected_accounts failed: HTTP 500"; - assert_eq!( - expected_error_kind(msg), - None, - "composio-direct 500 with no auth body must NOT demote — it is a real bug shape" - ); - } - - // ── TAURI-RUST-322 (#2929): composio-direct 403 (key missing perms) ─ - - #[test] - fn classifies_composio_direct_403_as_provider_user_state() { - // Canonical Sentry TAURI-RUST-322 wire shape — the verbatim - // title body from the issue (1,021 events, multi-release). The - // Composio v3 `/connected_accounts` endpoint returns HTTP 403 - // when the BYO API key exists but lacks `connected_accounts:read` - // permission. This is a user-state condition; Sentry has no - // remediation path. - let msg = "[composio-direct] list_connections failed: \ - Composio v3 connected_accounts failed: HTTP 403"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "composio-direct HTTP 403 must demote to ProviderUserState (TAURI-RUST-322)" - ); - } - - #[test] - fn classifies_composio_direct_403_for_other_ops() { - // The `[composio-direct]` + `HTTP 403` arm must cover every op - // that can hit a 403 from the Composio v3 tenant (list_tools - // prefetch, authorize, etc.) — not just list_connections. - let shapes = [ - // list_tools prefetch of connections hits the 403 wall - "[composio-direct] list_tools: prefetch connections failed: \ - Composio v3 connected_accounts failed: HTTP 403", - // list_connections itself (the primary source of the leak) - "[composio-direct] list_connections (direct) failed: \ - Composio v3 connected_accounts failed: HTTP 403", - // any future direct-mode op that hits a 403 - "[composio-direct] composio_list_connections (direct) failed: \ - Composio v3 connected_accounts failed: HTTP 403", - ]; - for msg in shapes { - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "every [composio-direct] op with HTTP 403 must demote to ProviderUserState: {msg}" - ); - } - } - - #[test] - fn does_not_classify_unrelated_http_403_as_composio_direct_user_state() { - // Discrimination test: a 403 that does NOT carry the - // `[composio-direct]` prefix must NOT match this arm. Backend-mode - // composio 403s and unrelated 403s must remain visible in Sentry. - let backend_403 = "[composio] list_connections failed: \ - Backend returned 403 Forbidden for GET \ - https://api.tinyhumans.ai/agent-integrations/composio/connections"; - // The backend-mode shape passes through `is_backend_user_error_message` - // (4xx matcher), not this arm. Verify it does NOT match this arm. - assert!( - !lower_contains_composio_direct_auth_wall(backend_403), - "backend-mode 403 must NOT match the composio-direct arm" - ); - - let unrelated_403 = "GitHub API error: HTTP 403: rate limit exceeded"; - assert_ne!( - expected_error_kind(unrelated_403), - Some(ExpectedErrorKind::ProviderUserState), - "unrelated 403 (no [composio-direct] anchor) must NOT match the composio-direct arm" - ); - } - - // Helper used only in the discrimination test above — mirrors the - // exact condition in `is_provider_user_state_message` without - // requiring access to the private function. - fn lower_contains_composio_direct_auth_wall(msg: &str) -> bool { - let lower = msg.to_ascii_lowercase(); - lower.contains("[composio-direct]") - && (lower.contains("http 401") - || lower.contains("http 403") - || lower.contains("invalid api key")) - } - - // ── TAURI-RUST-34H: backend-wrapped Cloudflare anti-bot interstitial ─ - - #[test] - fn classifies_backend_cloudflare_antibot_wrap_as_provider_user_state() { - // Canonical Sentry TAURI-RUST-34H wire shape — the verbatim title - // body from the issue (8,851 events / 14d on self-hosted - // `tauri-rust`). The backend wraps an upstream Cloudflare 403 - // anti-bot challenge as `Backend returned 500 … 403 Just a moment...

Icon for api.tinyhumans.aiapi.tinyhumans.ai

...Powered by Cloudflare..."#; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "backend-wrapped Cloudflare anti-bot interstitial must demote to ProviderUserState" - ); - } - - #[test] - fn classifies_minimal_cloudflare_antibot_body_as_provider_user_state() { - // Strip the wire shape down to just the two anchors — the - // matcher should still fire so future renderings (different - // line breaks, stripped HTML, alternate caller wrappers) still - // demote. - let msg = "Just a moment...\ncloudflare\n"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::ProviderUserState), - "minimal `Just a moment...` + `cloudflare` body must demote" - ); - } - - #[test] - fn does_not_classify_half_anchor_cloudflare_messages_as_user_state() { - // Discrimination test for the double-anchor: either half on its - // own must NOT match. This guards against unrelated bodies that - // happen to use either phrase out of context. - - // Half-anchor 1: `just a moment` without `cloudflare` — e.g. - // a daemon restart spinner blurb. - let half_a = "Just a moment, while we restart the daemon"; - assert_ne!( - expected_error_kind(half_a), - Some(ExpectedErrorKind::ProviderUserState), - "`Just a moment` without `cloudflare` must NOT match the CF anti-bot arm" - ); - - // Half-anchor 2: `cloudflare` without `just a moment...` — e.g. - // a CF Workers footer mention elsewhere. - let half_b = "Powered by Cloudflare"; - assert_ne!( - expected_error_kind(half_b), - Some(ExpectedErrorKind::ProviderUserState), - "`cloudflare` without `Just a moment...` must NOT match the CF anti-bot arm" - ); - } - - #[test] - fn does_not_classify_genuine_backend_500_without_cloudflare_body() { - // Real bug shape — a 500 from the same backend endpoint with no - // Cloudflare interstitial body — must still fall through so - // Sentry sees it. Without this guard the arm could be too - // permissive and silence genuine database / handler faults. - let msg = "Backend returned 500 Internal Server Error for GET \ - https://api.tinyhumans.ai/agent-integrations/composio/connections: \ - database connection pool exhausted"; - assert_eq!( - expected_error_kind(msg), - None, - "genuine backend 500 without Cloudflare body must NOT demote — it is a real bug" - ); - } - - #[test] - fn classifies_list_models_404_as_provider_user_state() { - // OPENHUMAN-TAURI-YJ: `inference/provider/ops.rs::list_models` probed - // a custom-provider's `/models` endpoint and the upstream server - // returned 404 because the base URL is wrong / doesn't host a models - // listing. User-config state — the model-dropdown probe already - // surfaces it inline. Pin the verbatim Sentry payload plus a few - // body-shape variants (different upstreams emit different 404 bodies) - // so the path-agnostic prefix anchor stays the source of truth. - for raw in [ - // Verbatim shape from the Sentry event. - r#"provider returned 404: {"error":"path \"/api/v1/models\" not found"}"#, - // FastAPI-style: `{"detail":"Not Found"}`. - r#"provider returned 404: {"detail":"Not Found"}"#, - // Bare HTML — happens when the user pointed at a non-API origin - // (e.g. the provider's docs site). - "provider returned 404: Not Found", - // After `truncate_with_ellipsis(.., 300)` clips a longer body — - // prefix anchor must still match. - r#"provider returned 404: {"error":{"message":"The requested URL /api/v1/models was not found on this server. Please check the URL or co…"#, - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ProviderUserState), - "OPENHUMAN-TAURI-YJ list_models 404 must classify as ProviderUserState: {raw}" - ); - } - } - - #[test] - fn does_not_classify_non_404_list_models_failures_as_user_state() { - // Discrimination guard: only the 404 prefix demotes. Sibling 4xx / - // 5xx codes from the same `provider returned NNN:` emit site must - // stay actionable in Sentry — they map to BYO-key auth walls (401 / - // 403), client-shape bugs (400), and transient / server faults - // (429 / 5xx) respectively. Pinning each shape here protects the - // #2286 BYO-key 401 contract and prevents the arm from silently - // widening to all 4xx. - for raw in [ - // BYO-key auth wall — must still escalate (`does_not_classify_byo_key_provider_401_as_session_expired` sibling guard). - r#"provider returned 401: {"error":"Invalid API key"}"#, - r#"provider returned 403: {"error":"Forbidden: API key revoked"}"#, - // Request-shape mismatch — likely a bug in our client. - r#"provider returned 400: {"error":"Bad Request"}"#, - // Transient — caught by retry/backoff at the provider layer, - // does NOT belong in the user-state bucket. - r#"provider returned 429: {"error":"rate_limited"}"#, - r#"provider returned 503: upstream temporarily unavailable"#, - // 500 — a real upstream bug; must reach Sentry. - r#"provider returned 500: {"error":"internal_server_error"}"#, - ] { - assert_ne!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ProviderUserState), - "non-404 list_models failure must NOT demote to ProviderUserState: {raw}" - ); - } - } - - #[test] - fn classifies_local_ai_binary_missing_errors() { - // OPENHUMAN-TAURI-9N: `local_ai_tts` returns this exact string - // from `service::speech::tts` when piper isn't on PATH or - // `PIPER_BIN` isn't set. - assert_eq!( - expected_error_kind("piper binary not found. Set PIPER_BIN or install piper."), - Some(ExpectedErrorKind::LocalAiBinaryMissing) - ); - // Sibling shapes from the same service area share the anchor and - // must classify the same way — the user-facing remediation is - // identical (install / configure the binary). - assert_eq!( - expected_error_kind( - "whisper.cpp binary not found. Set WHISPER_BIN or install whisper-cli." - ), - Some(ExpectedErrorKind::LocalAiBinaryMissing) - ); - assert_eq!( - expected_error_kind( - "Ollama binary not found at '/usr/local/bin/ollama'. Provide a valid path to the ollama executable." - ), - Some(ExpectedErrorKind::LocalAiBinaryMissing) - ); - assert_eq!( - expected_error_kind("Ollama installed but binary not found on system"), - Some(ExpectedErrorKind::LocalAiBinaryMissing) - ); - // Wrapped by the RPC dispatcher in production: - // `"rpc.invoke_method failed: piper binary not found. …"`. - // The classifier is substring-based, so caller context must not - // defeat it. - assert_eq!( - expected_error_kind( - "rpc.invoke_method failed: piper binary not found. Set PIPER_BIN or install piper." - ), - Some(ExpectedErrorKind::LocalAiBinaryMissing) - ); - } - - #[test] - fn does_not_classify_unrelated_messages_as_binary_missing() { - // Pin the anchor: messages that talk about binaries in a - // different context (download failures, version mismatches) - // must not be silenced. - assert_eq!( - expected_error_kind("piper binary failed to spawn: permission denied"), - None - ); - assert_eq!( - expected_error_kind("whisper.cpp returned empty transcript"), - None - ); - } - - #[test] - fn classifies_session_expired_messages() { - // OPENHUMAN-TAURI-26: the canonical wire shape that `agent.run_single` - // and `web_channel.run_chat_task` re-emit via `report_error_or_expected` - // when the user's JWT expires mid-conversation. The classifier - // anchors on the literal `"session expired"` substring from the - // OpenHuman backend's 401 body — NOT on the bare `(401 Unauthorized)` - // status, which would also silence BYO-key OpenAI/Anthropic 401s - // that are actionable. - assert_eq!( - expected_error_kind( - r#"OpenHuman API error (401 Unauthorized): {"success":false,"error":"Session expired. Please log in again."}"# - ), - Some(ExpectedErrorKind::SessionExpired) - ); - - // Wrapped by the agent / web-channel report sites in production — - // the classifier is substring-based so caller context must not - // defeat it. - assert_eq!( - expected_error_kind( - r#"run_chat_task failed client_id=abc thread_id=t1 request_id=r1 error=OpenHuman API error (401 Unauthorized): {"success":false,"error":"Session expired. Please log in again."}"# - ), - Some(ExpectedErrorKind::SessionExpired) - ); - - // Sentinel raised by `providers::openhuman_backend::resolve_bearer` - // when the scheduler-gate signed-out override is set - // (OPENHUMAN-TAURI-1T's cascade dampener returns this so callers - // get the same teardown path as a real backend 401). - assert_eq!( - expected_error_kind( - "SESSION_EXPIRED: backend session not active — sign in to resume LLM work" - ), - Some(ExpectedErrorKind::SessionExpired) - ); - - // Local pre-flight guards — OpenHuman-specific phrasing, safe to - // match regardless of caller wrapping. - for raw in [ - "no backend session token; run auth_store_session first", - "session JWT required", - "composio unavailable: no backend session token. Sign in first (auth_store_session).", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::SessionExpired), - "should classify as session-expired: {raw}" - ); - } - } - - /// OPENHUMAN-TAURI-SG (33 events, escalating, release `0.53.43+2b64ea8…`): - /// pre-#1763 leak of the `resolve_bearer` sentinel through - /// `agent.run_single`. PR #1763 (1fb0bef5) wired the `SessionExpired` - /// arm and the existing `classifies_session_expired_messages` test - /// covers the same byte string — this test pins the *Sentry-event - /// verbatim* shape (taken from the OPENHUMAN-TAURI-SG event payload) - /// so a future tweak to `is_session_expired_message` cannot regress - /// this exact wire form without a red test. - #[test] - fn session_expired_sg_wire_shape_matches() { - let msg = "SESSION_EXPIRED: backend session not active — sign in to resume LLM work"; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::SessionExpired), - "OPENHUMAN-TAURI-SG wire shape must classify as SessionExpired — \ - a regression here re-leaks 33+ events/cycle to Sentry" - ); - } - - /// The two sibling `SESSION_EXPIRED:` bail sites in - /// `providers::factory::verify_session_active` emit different message - /// suffixes but the same sentinel prefix. They route through the same - /// classifier as the run_single bail at - /// `providers::openhuman_backend::resolve_bearer`, and any matcher - /// tweak that breaks the family (e.g. moving from `contains` to a - /// stricter prefix/suffix match) would re-leak ALL of them. Pin every - /// variant the codebase actually emits so a future regression on the - /// matcher is caught for the whole family, not just the SG instance. - #[test] - fn session_expired_sibling_family_factory_strings_match() { - // src/openhuman/inference/provider/factory.rs:247 - // (verify_session_active — scheduler_gate signed-out path) - let custom_providers_variant = - "SESSION_EXPIRED: backend session not active — sign in to use custom providers"; - // src/openhuman/inference/provider/factory.rs:266 - // (verify_session_active — empty auth-profile JWT path) - let no_backend_session_variant = - "SESSION_EXPIRED: no backend session — sign in to use OpenHuman"; - - for raw in [custom_providers_variant, no_backend_session_variant] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::SessionExpired), - "factory.rs sibling sentinel must classify as SessionExpired: {raw}" - ); - } - } - - /// OPENHUMAN-TAURI-4P0: the OpenHuman backend rejects an expired/ - /// revoked JWT with the envelope `{"success":false,"error":"Invalid - /// token"}` (vs. the explicit `"Session expired. Please log in again."` - /// body covered by `classifies_session_expired_messages`). Same emit - /// site, same wrapping by `web_channel.run_chat_task`, but the body - /// substring is different. - /// - /// The matcher uses a conjunctive `"OpenHuman API error (401"` + - /// envelope-shaped `"\"error\":\"Invalid token\""` anchor pair so the - /// #2286 contract for bare `"Invalid token"` / BYO-key 401s is - /// preserved — `does_not_classify_byo_key_provider_401_as_session_expired` - /// pins that and must stay green. - #[test] - fn classifies_openhuman_invalid_token_401_as_session_expired() { - // Verbatim wire shape from the OPENHUMAN-TAURI-4P0 event payload. - let msg = r#"run_chat_task failed client_id=lssXhQidBfzGXG9k thread_id=thread-743193ba-f0c1-4008-b665-64d3030d1453 request_id=00696b71-fa05-4574-bcdb-5744a5dac6ea error=OpenHuman API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::SessionExpired), - "OPENHUMAN-TAURI-4P0 verbatim wire shape must classify as SessionExpired" - ); - - // Unwrapped emit shape (without the run_chat_task prefix) — also - // appears at provider/agent layers; the substring matcher must - // catch it regardless of caller wrapping. - assert_eq!( - expected_error_kind( - r#"OpenHuman API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"# - ), - Some(ExpectedErrorKind::SessionExpired), - "unwrapped OpenHuman invalid-token envelope must classify as SessionExpired" - ); - } - - /// TAURI-RUST-4K5 (118 events, escalating on 0.56.0): the embedding - /// client at `src/openhuman/embeddings/openai.rs:139` wraps the same - /// OpenHuman backend `{"success":false,"error":"Invalid token"}` 401 - /// envelope as 4P0, but with the `"Embedding API error"` prefix - /// instead of `"OpenHuman API error"` (different emit-site format - /// string, same underlying session-expired cause — see breadcrumb - /// `[scheduler_gate] signed_out false -> true` immediately preceding - /// the 401 in the event payload). - /// - /// Uses the same conjunctive `" (401"` + envelope-shaped - /// `"\"error\":\"Invalid token\""` anchor pattern as 4P0 so the - /// #2286 / BYO-key contract is preserved — covered by - /// `does_not_classify_byo_key_provider_401_as_session_expired` and - /// `does_not_classify_embedding_byo_key_401_as_session_expired` - /// (below). - #[test] - fn classifies_embedding_api_invalid_token_401_as_session_expired() { - // Verbatim wire shape from the TAURI-RUST-4K5 event payload (Sentry - // issue 5230, latest event 2026-05-27 20:49 on openhuman@0.56.0, - // domain=embeddings operation=openai_embed status=401). - let msg = - r#"Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::SessionExpired), - "TAURI-RUST-4K5 verbatim wire shape must classify as SessionExpired" - ); - - // The substring matcher must survive caller wrapping the same way - // the 4P0 web-channel `run_chat_task` test wraps the body — callers - // that re-emit through a tracing field or another layer prepend - // arbitrary context. - let wrapped = r#"openai_embed failed error=Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; - assert_eq!( - expected_error_kind(wrapped), - Some(ExpectedErrorKind::SessionExpired), - "wrapped 4K5 envelope must still classify as SessionExpired" - ); - } - - /// TAURI-RUST-1EE (Sentry issue 1807, 110 events, 109 on - /// openhuman@0.56.0): the streaming-chat path wraps the same OpenHuman - /// backend `{"success":false,"error":"Invalid token"}` 401 envelope - /// with the `"OpenHuman streaming API error"` prefix (emitted at - /// `inference/provider/compatible.rs:949`) — distinct from the - /// non-streaming `"OpenHuman API error"` prefix (4P0) and the - /// `"Embedding API error"` prefix (4K5). The `streaming` token between - /// `OpenHuman` and `API error` means the 4P0 anchor - /// (`"OpenHuman API error (401"`) does not match it, so it needs its - /// own prefix arm. - #[test] - fn classifies_openhuman_streaming_invalid_token_401_as_session_expired() { - // Verbatim wire shape from the TAURI-RUST-1EE event payload - // (domain=llm_provider operation=streaming_chat status=401 - // provider=OpenHuman model=reasoning-v1). - let msg = r#"OpenHuman streaming API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; - assert_eq!( - expected_error_kind(msg), - Some(ExpectedErrorKind::SessionExpired), - "TAURI-RUST-1EE verbatim streaming wire shape must classify as SessionExpired" - ); - - // Caller-wrapped (agent.run_single / web_channel.run_chat_task - // re-emit prepends context) must still classify. - let wrapped = r#"run_chat_task failed error=OpenHuman streaming API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; - assert_eq!( - expected_error_kind(wrapped), - Some(ExpectedErrorKind::SessionExpired), - "wrapped 1EE streaming envelope must still classify as SessionExpired" - ); - } - - /// Polarity guard for the 1EE streaming arm — a third-party BYO-key - /// provider's streaming 401 (`"OpenAI streaming API error (401 …): - /// invalid_api_key"`) must STILL reach Sentry as actionable - /// misconfiguration. The `"OpenHuman streaming API error (401"` prefix - /// gate keeps the match OpenHuman-scoped. - #[test] - fn does_not_classify_streaming_byo_key_401_as_session_expired() { - for raw in [ - "OpenAI streaming API error (401 Unauthorized): invalid_api_key", - r#"OpenAI streaming API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#, - "Anthropic streaming API error (401): authentication_error", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "BYO-key streaming 401 must reach Sentry as actionable error: {raw}" - ); - } - } - - /// Polarity guard for the 4K5 arm. The classifier must NOT swallow - /// `"Embedding API error (401 …)"` shapes from third-party BYO-key - /// embedding providers (OpenAI / Voyage / Cohere upstream rejecting - /// the user's own API key). Those are actionable user-config errors - /// that need to reach Sentry — same contract as - /// `does_not_classify_byo_key_provider_401_as_session_expired` for - /// the OpenAI chat API. - #[test] - fn does_not_classify_embedding_byo_key_401_as_session_expired() { - for raw in [ - "Embedding API error (401 Unauthorized): invalid_api_key", - r#"Embedding API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#, - // Wire shape without the OpenHuman envelope — bare provider - // rejection prose. Must reach Sentry as actionable BYO-key - // misconfiguration. - "Embedding API error (401): authentication_error", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "BYO-key embedding 401 must reach Sentry as actionable error: {raw}" - ); - } - } - - #[test] - fn does_not_classify_byo_key_provider_401_as_session_expired() { - // Critical: a BYO-key 401 from OpenAI / Anthropic etc. is an - // actionable misconfiguration (wrong API key) that the user needs - // to fix in settings. It must reach Sentry as an error and must - // NOT be classified as session-expired at the agent layer — the - // strict classifier requires the OpenHuman backend's - // "session expired" body to anchor the match. The JSON-RPC - // dispatch-site classifier uses the same strict rule so these - // scoped provider failures never clear the app session either. - for raw in [ - "OpenAI API error (401 Unauthorized): invalid_api_key", - "Anthropic API error (401 Unauthorized): authentication_error", - "OpenAI API error (401): unauthorized", - r#"OpenAI API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#, - // Generic "invalid token" without OpenHuman session phrasing — - // could mean a third-party provider rejected its own token. - "Invalid token", - "got an invalid token here", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "BYO-key / generic 401 must reach Sentry as actionable error: {raw}" - ); - } - } - - #[test] - fn does_not_classify_unrelated_messages_as_session_expired() { - // Bare numeric 401 (port number, runbook reference) must not be - // silenced. - assert_eq!(expected_error_kind("server returned 401"), None); - assert_eq!( - expected_error_kind("see runbook for 401 handling at https://example.com/401"), - None - ); - // Provider 5xx — must reach Sentry. - assert_eq!( - expected_error_kind("OpenAI API error (500): internal server error"), - None - ); - // Lowercase sentinel must NOT match — the SESSION_EXPIRED sentinel - // is case-sensitive by design (matches the sentinel emitted by - // `providers::openhuman_backend::resolve_bearer` exactly). - assert_eq!(expected_error_kind("session_expired lowercase"), None); - } - - /// TAURI-RUST-E (~1 437 events): billing poll fires `report_error_or_expected` - /// on every refresh cycle once the user's JWT lapses because the - /// `BackendApiError::Unauthorized` typed error was stringified to - /// `"backend rejected session token on GET /payments/stripe/currentPlan"` by - /// `billing::ops::get_authed_value(..).map_err(|e| e.to_string())` before the - /// phrase was added to `is_session_expired_message`. - /// - /// The phrase `"backend rejected session token"` is uniquely produced by - /// `BackendApiError::Unauthorized`'s `Display` impl in `api::rest` — no - /// third-party provider path emits it — so no conjunctive guard is needed. - #[test] - fn classifies_billing_401_as_session_expired() { - // Exact wire shape from `billing_get_current_plan` — the most common - // event in TAURI-RUST-E. - assert_eq!( - expected_error_kind( - "backend rejected session token on GET /payments/stripe/currentPlan" - ), - Some(ExpectedErrorKind::SessionExpired), - "TAURI-RUST-E: billing_get_current_plan 401 must classify as SessionExpired" - ); - - // Other billing methods share the same `BackendApiError::Unauthorized` - // display shape — pin them so a wording change in `rest.rs` would catch - // every billing call site. - for path in [ - "/payments/credits/balance", - "/payments/credits/transactions?limit=20&offset=0", - "/payments/credits/auto-recharge", - "/payments/credits/auto-recharge/cards", - "/payments/stripe/purchasePlan", - "/payments/stripe/portal", - "/coupons/me", - ] { - let msg = format!("backend rejected session token on GET {path}"); - assert_eq!( - expected_error_kind(&msg), - Some(ExpectedErrorKind::SessionExpired), - "billing 401 must classify as SessionExpired: {msg}" - ); - } - - // POST / PATCH / DELETE variants are also produced by `authed_json`. - for raw in [ - "backend rejected session token on POST /payments/credits/top-up", - "backend rejected session token on PATCH /payments/credits/auto-recharge", - "backend rejected session token on DELETE /payments/credits/auto-recharge/cards/pm_123", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::SessionExpired), - "TAURI-RUST-E variant must classify as SessionExpired: {raw}" - ); - } - } - - /// `"backend rejected session token"` is scoped to `BackendApiError::Unauthorized` - /// in `api::rest`. Ensure unrelated messages containing the individual - /// words don't accidentally match. - #[test] - fn does_not_classify_unrelated_rejected_messages_as_session_expired() { - // Third-party provider errors that mention a token being rejected but - // do not contain the exact OpenHuman `BackendApiError::Unauthorized` - // display phrase. - for raw in [ - "Discord API error: token rejected by upstream", - "Stripe webhook signature rejected — bad secret", - "API token rejected: please regenerate", - ] { - assert_eq!( - expected_error_kind(raw), - None, - "unrelated token-rejected message must NOT suppress Sentry: {raw}" - ); - } - } - - #[test] - fn report_error_does_not_panic_with_many_tags() { - let err = anyhow::anyhow!("multi-tag"); - report_error( - &err, - "test", - "multi_tag", - &[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")], - ); - } - - fn event_with_tags(pairs: &[(&str, &str)]) -> sentry::protocol::Event<'static> { - let mut event = sentry::protocol::Event::default(); - let mut tags: std::collections::BTreeMap = - std::collections::BTreeMap::new(); - for (k, v) in pairs { - tags.insert((*k).to_string(), (*v).to_string()); - } - event.tags = tags; - event - } - - fn event_with_tags_and_message( - pairs: &[(&str, &str)], - message: &str, - ) -> sentry::protocol::Event<'static> { - let mut event = event_with_tags(pairs); - event.message = Some(message.to_string()); - event - } - - #[test] - fn transient_filter_drops_429_408_502_503_504() { - for status in ["429", "408", "502", "503", "504"] { - let event = event_with_tags(&[ - ("domain", "llm_provider"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - is_transient_provider_http_failure(&event), - "status {status} must be classified as transient and filtered" - ); - } - } - - #[test] - fn transient_filter_keeps_permanent_failures() { - for status in ["400", "401", "403", "404", "500"] { - let event = event_with_tags(&[ - ("domain", "llm_provider"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - !is_transient_provider_http_failure(&event), - "status {status} must NOT be filtered — it's actionable" - ); - } - } - - #[test] - fn transient_filter_keeps_aggregate_all_exhausted() { - let event = event_with_tags(&[ - ("domain", "llm_provider"), - ("failure", "all_exhausted"), - ("status", "503"), - ]); - assert!( - !is_transient_provider_http_failure(&event), - "aggregate all_exhausted events must surface (they are the cascade signal)" - ); - } - - #[test] - fn transient_filter_keeps_events_with_no_status_tag() { - let event = event_with_tags(&[("domain", "llm_provider"), ("failure", "non_2xx")]); - assert!( - !is_transient_provider_http_failure(&event), - "missing status tag must not be silently dropped" - ); - } - - // Regression guard: the filter must scope to provider events only. Other - // subsystems emit `failure=non_2xx` (e.g. - // `providers/compatible.rs` uses the same marker for OAI-compatible - // error paths, but every site goes through `report_error(.., - // "llm_provider", ..)` so the domain tag is consistent), but the broader - // point is: any future caller that re-uses the same tag set for a - // different domain must NOT be silently dropped by this filter. - #[test] - fn transient_filter_keeps_events_with_no_domain_tag() { - let event = event_with_tags(&[("failure", "non_2xx"), ("status", "503")]); - assert!( - !is_transient_provider_http_failure(&event), - "missing domain tag means the event isn't provider-originated — must surface" - ); - } - - #[test] - fn transient_filter_keeps_events_from_other_domains() { - let event = event_with_tags(&[ - ("domain", "scheduler"), - ("failure", "non_2xx"), - ("status", "503"), - ]); - assert!( - !is_transient_provider_http_failure(&event), - "non-provider domain must surface even if failure/status tags collide" - ); - } - - #[test] - fn backend_api_filter_drops_transient_statuses() { - for status in TRANSIENT_HTTP_STATUSES { - let event = event_with_tags(&[ - ("domain", "backend_api"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - is_transient_backend_api_failure(&event), - "backend status {status} must be classified as transient" - ); - } - } - - #[test] - fn backend_api_filter_drops_transient_transport_phrases() { - for phrase in TRANSIENT_TRANSPORT_PHRASES { - let event = event_with_tags_and_message( - &[("domain", "backend_api"), ("failure", "transport")], - &format!("GET /teams failed: {phrase}"), - ); - assert!( - is_transient_backend_api_failure(&event), - "backend transport phrase {phrase} must be classified as transient" - ); - } - } - - #[test] - fn backend_api_filter_keeps_non_transient_failures() { - for status in ["404", "500"] { - let event = event_with_tags(&[ - ("domain", "backend_api"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - !is_transient_backend_api_failure(&event), - "backend status {status} must stay visible" - ); - } - - let wrong_domain = event_with_tags(&[ - ("domain", "scheduler"), - ("failure", "non_2xx"), - ("status", "503"), - ]); - assert!( - !is_transient_backend_api_failure(&wrong_domain), - "domain scoping must keep unrelated transient-shaped events visible" - ); - - let non_matching_transport = event_with_tags_and_message( - &[("domain", "backend_api"), ("failure", "transport")], - "GET /teams failed: certificate verify failed", - ); - assert!( - !is_transient_backend_api_failure(&non_matching_transport), - "transport failures without an allowlisted phrase must stay visible" - ); - } - - #[test] - fn integrations_filter_drops_transient_statuses() { - for status in TRANSIENT_HTTP_STATUSES { - let event = event_with_tags(&[ - ("domain", "integrations"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - is_transient_integrations_failure(&event), - "integrations status {status} must be classified as transient" - ); - } - } - - #[test] - fn integrations_filter_drops_transient_transport_phrases() { - for phrase in TRANSIENT_TRANSPORT_PHRASES { - let event = event_with_tags_and_message( - &[("domain", "integrations"), ("failure", "transport")], - &format!("GET /agent-integrations/tools failed: {phrase}"), - ); - assert!( - is_transient_integrations_failure(&event), - "integrations transport phrase {phrase} must be classified as transient" - ); - } - } - - #[test] - fn integrations_filter_keeps_non_transient_failures() { - for status in ["404", "500"] { - let event = event_with_tags(&[ - ("domain", "integrations"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - !is_transient_integrations_failure(&event), - "integrations status {status} must stay visible" - ); - } - - // Sibling-domain check: composio op-layer events MUST be silenced - // by the integrations filter — composio routes through the same - // `IntegrationClient` so the failure shape is identical, but - // op-level reporters that wrap and re-emit with their own domain - // tag would otherwise escape (OPENHUMAN-TAURI-35 / -2H). - let scheduler_domain = event_with_tags(&[ - ("domain", "scheduler"), - ("failure", "non_2xx"), - ("status", "503"), - ]); - assert!( - !is_transient_integrations_failure(&scheduler_domain), - "domain scoping must keep unrelated transient-shaped events visible" - ); - - let non_matching_transport = event_with_tags_and_message( - &[("domain", "integrations"), ("failure", "transport")], - "GET /agent-integrations/tools failed: invalid certificate", - ); - assert!( - !is_transient_integrations_failure(&non_matching_transport), - "transport failures without an allowlisted phrase must stay visible" - ); - } - - #[test] - fn composio_domain_routes_through_integrations_filter() { - // OPENHUMAN-TAURI-35 (~139 events) / -2H (~26 events): - // `[composio] list_connections failed: Backend returned 502 …` — - // composio op-layer wrappers (e.g. `composio_list_connections`) emit - // errors under `domain="composio"` so the original - // `domain="integrations"` filter let them through. Routing the - // composio domain through the same transient classifier closes - // that gap; the underlying transport / non_2xx semantics are - // identical because both layers share the same `IntegrationClient`. - for status in TRANSIENT_HTTP_STATUSES { - let event = event_with_tags(&[ - ("domain", "composio"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - is_transient_integrations_failure(&event), - "composio status {status} must be classified as transient" - ); - } - - // Transport-phrase variant — composio also surfaces reqwest - // transport failures (timeouts, connection resets) once the op - // wrapper has tagged the event with `failure=transport`. - for phrase in TRANSIENT_TRANSPORT_PHRASES { - let event = event_with_tags_and_message( - &[("domain", "composio"), ("failure", "transport")], - &format!("[composio] execute failed: {phrase}"), - ); - assert!( - is_transient_integrations_failure(&event), - "composio transport phrase {phrase} must be classified as transient" - ); - } - - // Non-transient composio statuses (404 / 500) must still surface — - // actionable bugs even when reported under the composio domain. - for status in ["404", "500"] { - let event = event_with_tags(&[ - ("domain", "composio"), - ("failure", "non_2xx"), - ("status", status), - ]); - assert!( - !is_transient_integrations_failure(&event), - "composio status {status} must stay visible" - ); - } - } - - #[test] - fn updater_transient_403_is_dropped() { - let event = event_with_tags_and_message( - &[ - ("domain", "update"), - ("operation", "check_releases"), - ("failure", "non_2xx"), - ("status", "403"), - ], - "[observability] update.check_releases failed: GitHub API error: 403 Forbidden", - ); - assert!( - is_updater_transient_event(&event), - "GitHub 403 updater checks are unactionable transient/rate-limit noise" - ); - } - - #[test] - fn updater_transient_502_is_dropped() { - let event = event_with_tags_and_message( - &[ - ("domain", "update.check_releases"), - ("failure", "non_2xx"), - ("status", "502"), - ], - "GitHub API error: 502 Bad Gateway", - ); - assert!( - is_updater_transient_event(&event), - "GitHub 5xx updater checks must be filtered as transient" - ); - } - - #[test] - fn updater_real_panic_still_reported() { - let event = event_with_tags_and_message( - &[("domain", "update"), ("operation", "check_releases")], - "thread 'main' panicked at src/openhuman/update/core.rs: index out of bounds", - ); - assert!( - !is_updater_transient_event(&event), - "update-domain events without a transient updater shape must still reach Sentry" - ); - } - - #[test] - fn updater_endpoint_non_success_message_is_dropped() { - // TAURI-RUST-CD (~151 events / 9 days, Windows): `tauri-plugin-updater` - // logs `update endpoint did not respond with a successful status code` - // (updater.rs) on any non-2xx response and discards the status, so the - // captured event has NO `domain`/`status` tag — only the bare message. - // It can therefore only be matched via the message fast-path. - assert!(is_updater_transient_message( - "update endpoint did not respond with a successful status code" - )); - - let event = event_with_tags_and_message( - &[], - "update endpoint did not respond with a successful status code", - ); - assert!( - is_updater_transient_event(&event), - "the plugin's status-blind, domain-less non-success log line is unactionable updater noise" - ); - } - - #[test] - fn updater_endpoint_non_success_anchor_does_not_silence_unrelated_errors() { - // The new anchor is the literal plugin string. Other updater failures - // that DO carry an actionable signal (signature/permission failures on - // apply, deserialize errors) and unrelated non-updater errors that - // merely mention a status code MUST NOT be dropped by it. Pin the - // rejection contract so a future refactor doesn't loosen the substring. - for msg in [ - "failed to apply update: signature verification failed", - "failed to deserialize update response: missing field `version`", - "backend request to /agent-integrations failed with status code 500", - "tool exited with non-zero status code 1", - ] { - let event = event_with_tags_and_message(&[], msg); - assert!( - !is_updater_transient_event(&event), - "unrelated/actionable error must still reach Sentry: {msg}" - ); - } - } - - #[test] - fn message_failure_classifier_matches_canonical_status_phrases() { - for msg in [ - "rpc.invoke_method failed: GET /teams failed (502 Bad Gateway)", - "GET /teams/me/usage failed (503 Service Unavailable)", - "downstream returned (504 Gateway Timeout): retry budget exhausted", - "OpenHuman API error (520 ): cf", - "POST /channels/telegram/typing failed (429 Too Many Requests)", - "auth connect failed: 503 Service Unavailable", - ] { - assert!( - is_transient_message_failure(msg), - "{msg:?} must be classified as transient" - ); - } - } - - #[test] - fn message_failure_classifier_matches_transport_phrases() { - for msg in [ - "integrations.get failed: composio/tools → operation timed out", - "GET https://api.example.com → connection forcibly closed (os 10054)", - "POST /v1/foo → tls handshake eof", - "error sending request for url (https://api.example.com)", - ] { - assert!( - is_transient_message_failure(msg), - "{msg:?} must be classified as transient" - ); - } - } - - #[test] - fn message_failure_classifier_keeps_unrelated_messages() { - for msg in [ - "rpc.invoke_method failed: schema validation error", - "process 502 exited unexpectedly", - "GET /teams failed (404 Not Found)", - "GET /teams failed (500 Internal Server Error)", - "unrelated error with port 5023", - "", - ] { - assert!( - !is_transient_message_failure(msg), - "{msg:?} must not be classified as transient" - ); - } - } - - #[test] - fn budget_filter_drops_budget_message_on_tagged_400() { - let event = event_with_tags_and_message( - &[("failure", "non_2xx"), ("status", "400")], - r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#, - ); - - assert!(is_budget_event(&event)); - } - - #[test] - fn budget_filter_drops_budget_exception_on_tagged_400() { - let mut event = event_with_tags(&[("failure", "non_2xx"), ("status", "400")]); - event.exception.values.push(sentry::protocol::Exception { - value: Some("Budget exceeded — add credits to continue".to_string()), - ..Default::default() - }); - - assert!(is_budget_event(&event)); - } - - #[test] - fn budget_filter_keeps_non_budget_400() { - let event = event_with_tags_and_message( - &[("failure", "non_2xx"), ("status", "400")], - "Bad request: missing field", - ); - - assert!(!is_budget_event(&event)); - } - - #[test] - fn budget_filter_requires_non_2xx_failure_and_400_status_for_loose_phrases() { - // Tier 1 requires both tags for the loose budget phrases ("budget exceeded", - // "add credits", …) that might coincidentally appear in unrelated product - // copy. Tier 2 (text-only) only fires for the exact backend phrase - // "insufficient budget" — so the loose phrases still need both tags. - let message = "Budget exceeded — add credits to continue"; - for tags in [ - vec![("failure", "transport"), ("status", "400")], - vec![("failure", "non_2xx"), ("status", "500")], - vec![("failure", "non_2xx")], - ] { - let event = event_with_tags_and_message(&tags, message); - assert!(!is_budget_event(&event)); - } - } - - /// Tier-2 defense-in-depth: drop the exact "Insufficient budget" phrase - /// the OpenHuman backend returns regardless of which tags are set. - /// Regression guard for OPENHUMAN-CORE-N / TAURI-RUST-1P (GitHub #2935). - #[test] - fn budget_filter_drops_insufficient_budget_without_tags() { - // The exact JSON wire body from the OpenHuman backend. - let message = r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#; - - // No tags at all. - let event = event_with_message(message); - assert!( - is_budget_event(&event), - "tier-2 must drop 'Insufficient budget' even without failure/status tags" - ); - - // Wrong status tag only. - let event = event_with_tags_and_message(&[("status", "400")], message); - assert!( - is_budget_event(&event), - "tier-2 must drop 'Insufficient budget' when only status tag is set" - ); - - // Wrong failure tag only. - let event = event_with_tags_and_message(&[("failure", "transport")], message); - assert!( - is_budget_event(&event), - "tier-2 must drop 'Insufficient budget' when failure tag doesn't match" - ); - } - - /// Regression guard: tier-2 must also catch the exception/tracing path - /// (`sentry-tracing` with `attach_stacktrace=true` may populate the - /// exception list rather than `event.message`). - #[test] - fn budget_filter_drops_insufficient_budget_exception_without_tags() { - // Exact wire body from OpenHuman backend wrapped in an exception value. - let event = event_with_exception_value( - r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"INSUFFICIENT BUDGET"}"#, - ); - assert!( - is_budget_event(&event), - "tier-2 must drop exception-path 'Insufficient budget' case-insensitively" - ); - - // Mixed case variant. - let event = event_with_exception_value( - r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient Budget"}"#, - ); - assert!( - is_budget_event(&event), - "tier-2 must drop exception-path 'Insufficient Budget' case-insensitively" - ); - - // Wrong failure/status tags should not prevent tier-2 from matching. - let mut event = event_with_exception_value( - r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#, - ); - event - .tags - .insert("failure".to_string(), "transport".to_string()); - assert!( - is_budget_event(&event), - "tier-2 must drop exception-path even when failure tag does not match" - ); - - // Unrelated exception values must not match. - let event = event_with_exception_value("retry budget exhausted after 3 attempts"); - assert!( - !is_budget_event(&event), - "tier-2 must not match unrelated exception value" - ); - } - - /// Tier-2 only matches the tight phrase — unrelated 400 errors with "budget" - /// in an unrelated context should not be silently dropped. - #[test] - fn budget_filter_tier2_does_not_match_unrelated_messages() { - for msg in [ - "retry budget exhausted after 3 attempts", - "bad request: missing field", - "budget_id=42 not found", - "", - ] { - let event = event_with_message(msg); - assert!( - !is_budget_event(&event), - "tier-2 must not match unrelated message: {msg:?}" - ); - } - } - - /// CORE-RUST-EK (~827 events): every 401 from the embeddings call path - /// (`domain=embeddings`, `failure=non_2xx`, `status=401`) must be filtered - /// before it reaches Sentry. Covers both the OpenHuman-backend "Invalid - /// token" shape (already handled by the primary `is_session_expired_message` - /// classifier) and third-party provider body shapes (OpenAI - /// `invalid_api_key`, plain `Unauthorized`) that fall through the string - /// classifier. - #[test] - fn embeddings_401_filter_drops_domain_embeddings_status_401() { - // Canonical CORE-RUST-EK wire shape: OpenAI `text-embedding-3-large` - // key is stale. `report_error_or_expected` sets - // domain=embeddings / operation=openai_embed / failure=non_2xx / - // status=401 / model=text-embedding-3-large. - let event = event_with_tags_and_message( - &[ - ("domain", "embeddings"), - ("operation", "openai_embed"), - ("failure", "non_2xx"), - ("status", "401"), - ("model", "text-embedding-3-large"), - ], - r#"Embedding API error (401 Unauthorized): {"error":{"message":"Incorrect API key provided. You can find your API key at https://platform.openai.com/account/api-keys.","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}"#, - ); - assert!( - is_embeddings_api_key_401_event(&event), - "CORE-RUST-EK: domain=embeddings status=401 must be filtered" - ); - } - - /// Any other embedding status (e.g. 429 rate-limit, 500 server error) - /// must not be filtered by the embeddings-401 guard — those have their - /// own handlers (rate-limit filter, general error reporting). - #[test] - fn embeddings_401_filter_passes_other_statuses() { - for status in ["429", "500", "400"] { - let event = event_with_tags_and_message( - &[ - ("domain", "embeddings"), - ("failure", "non_2xx"), - ("status", status), - ], - "Embedding API error", - ); - assert!( - !is_embeddings_api_key_401_event(&event), - "domain=embeddings status={status} must NOT be filtered by the 401 guard" - ); - } - } - - /// Non-embeddings domains must not be filtered even if status=401 — the - /// guard is scoped specifically to `domain=embeddings` so that provider-chat - /// and backend-API 401s remain subject to their own classifiers. - #[test] - fn embeddings_401_filter_passes_non_embeddings_domains() { - for domain in ["llm_provider", "backend_api", "rpc", "composio"] { - let event = event_with_tags_and_message( - &[ - ("domain", domain), - ("failure", "non_2xx"), - ("status", "401"), - ], - "API error (401 Unauthorized): some body", - ); - assert!( - !is_embeddings_api_key_401_event(&event), - "domain={domain} status=401 must NOT be swallowed by the embeddings-401 guard" - ); - } - } - - /// The guard requires both `failure=non_2xx` and `status=401` to be - /// present; missing either tag must cause the filter to pass the event - /// through. - #[test] - fn embeddings_401_filter_requires_failure_and_status_tags() { - let no_failure_tag = event_with_tags_and_message( - &[("domain", "embeddings"), ("status", "401")], - "Embedding API error (401 Unauthorized): unauthorized", - ); - assert!( - !is_embeddings_api_key_401_event(&no_failure_tag), - "missing failure tag must not trigger the guard" - ); - - let no_status_tag = event_with_tags_and_message( - &[("domain", "embeddings"), ("failure", "non_2xx")], - "Embedding API error (401 Unauthorized): unauthorized", - ); - assert!( - !is_embeddings_api_key_401_event(&no_status_tag), - "missing status tag must not trigger the guard" - ); - } - - #[test] - fn report_error_or_expected_does_not_panic() { - report_error_or_expected( - "local ai is disabled", - "rpc", - "invoke_method", - &[("method", "openhuman.inference_prompt")], - ); - report_error_or_expected( - "ollama API key not set", - "agent", - "provider_chat", - &[("provider", "ollama")], - ); - // #2079 / #2076 / #2202 — exercises the expected_error_kind - // ProviderConfigRejection branch AND the report_expected_message - // skip-log arm (the agent/web-channel re-report demotion path). - report_error_or_expected( - "agent.run_single failed: custom_openai API error (400 Bad Request): \ - The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \ - but you passed reasoning-v1.", - "agent", - "native_chat", - &[("provider", "custom_openai")], - ); - report_error_or_expected( - "custom_openai API error (400): invalid temperature: only 1 is allowed for this model", - "web_channel", - "run_chat_task", - &[("provider", "custom_openai")], - ); - } - - fn event_with_message(msg: &str) -> sentry::protocol::Event<'static> { - let mut event = sentry::protocol::Event::default(); - event.message = Some(msg.to_string()); - event - } - - fn event_with_exception_value(value: &str) -> sentry::protocol::Event<'static> { - let mut event = sentry::protocol::Event::default(); - event.exception = vec![sentry::protocol::Exception { - value: Some(value.to_string()), - ..Default::default() - }] - .into(); - event - } - - #[test] - fn max_iterations_filter_matches_message_path() { - // `report_error_message` calls `sentry::capture_message`, which - // populates `event.message`. The filter must see the canonical - // phrase on that field path. - let event = event_with_message("Agent exceeded maximum tool iterations (8)"); - assert!(is_max_iterations_event(&event)); - } - - #[test] - fn max_iterations_filter_matches_exception_path() { - // sentry-tracing with attach_stacktrace=true populates the - // exception list instead of (or in addition to) `event.message`. - // Filter must still catch the noise. - let event = event_with_exception_value( - "agent.run_single failed: Agent exceeded maximum tool iterations (10)", - ); - assert!(is_max_iterations_event(&event)); - } - - #[test] - fn max_iterations_filter_keeps_unrelated_events() { - assert!(!is_max_iterations_event(&event_with_message( - "provider returned 503" - ))); - assert!(!is_max_iterations_event(&event_with_message(""))); - assert!(!is_max_iterations_event(&sentry::protocol::Event::default())); - } - - // ── is_channel_message_not_found_event (TAURI-R7) ──────────────────────── - - fn channel_message_404_event(method: &str) -> sentry::protocol::Event<'static> { - let mut event = sentry::protocol::Event::default(); - event.tags.insert("domain".into(), "backend_api".into()); - event.tags.insert("failure".into(), "non_2xx".into()); - event.tags.insert("status".into(), "404".into()); - event.tags.insert("method".into(), method.into()); - event.message = Some( - "PATCH /channels/telegram/messages/1103 failed (404); response_body_len=172" - .to_string(), - ); - event - } - - #[test] - fn channel_message_not_found_filter_matches_patch() { - // Canonical TAURI-R7 shape: PATCH 404 on a channel-message path. - assert!(is_channel_message_not_found_event( - &channel_message_404_event("PATCH") - )); - } - - #[test] - fn channel_message_not_found_filter_matches_delete() { - assert!(is_channel_message_not_found_event( - &channel_message_404_event("DELETE") - )); - } - - #[test] - fn channel_message_not_found_filter_ignores_get_404() { - // GET 404 on a channel-message path is NOT an expected state — must keep Sentry signal. - assert!(!is_channel_message_not_found_event( - &channel_message_404_event("GET") - )); - } - - #[test] - fn channel_message_not_found_filter_ignores_non_channel_path() { - let mut event = channel_message_404_event("PATCH"); - event.message = Some("PATCH /auth/profile failed (404); response_body_len=42".to_string()); - assert!(!is_channel_message_not_found_event(&event)); - } - - #[test] - fn channel_message_not_found_filter_ignores_wrong_status() { - let mut event = channel_message_404_event("PATCH"); - event.tags.insert("status".into(), "403".into()); - assert!(!is_channel_message_not_found_event(&event)); - } - - #[test] - fn channel_message_not_found_filter_ignores_wrong_domain() { - let mut event = channel_message_404_event("PATCH"); - event.tags.insert("domain".into(), "channels".into()); - assert!(!is_channel_message_not_found_event(&event)); - } - - #[test] - fn channel_message_not_found_filter_matches_exception_path() { - // sentry-tracing with attach_stacktrace=true populates exception list. - let mut event = sentry::protocol::Event::default(); - event.tags.insert("domain".into(), "backend_api".into()); - event.tags.insert("failure".into(), "non_2xx".into()); - event.tags.insert("status".into(), "404".into()); - event.tags.insert("method".into(), "PATCH".into()); - event.exception = vec![sentry::protocol::Exception { - value: Some("PATCH /channels/discord/messages/abc failed (404): Not Found".to_string()), - ..Default::default() - }] - .into(); - assert!(is_channel_message_not_found_event(&event)); - } - - // ── LoopbackUnavailable (TAURI-R5, TAURI-R6) ───────────────────────────── - - /// Verbatim body shape from OPENHUMAN-TAURI-R5 (~2.5k events): the - /// `integrations.get` site reaches the embedded core's `127.0.0.1:18474` - /// listener during the boot window and reqwest's source chain renders as - /// `error sending request for url (…) → client error (Connect) → tcp - /// connect error → Connection refused (os error 61)`. - const R5_BODY: &str = "error sending request for url \ - (http://127.0.0.1:18474/agent-integrations/composio/connections) \ - → client error (Connect) → tcp connect error → Connection refused (os error 61)"; - - /// Verbatim body shape from OPENHUMAN-TAURI-R6 (~2.5k events): the same - /// transport failure as R5, re-wrapped one frame up by the composio - /// op-layer and re-emitted at the `rpc.invoke_method` site so it lands in - /// Sentry under `domain=rpc` instead of `domain=integrations`. - const R6_BODY: &str = "[composio] list_connections failed: \ - GET http://127.0.0.1:18474/agent-integrations/composio/connections failed: \ - error sending request for url \ - (http://127.0.0.1:18474/agent-integrations/composio/connections) \ - → client error (Connect) → tcp connect error → Connection refused (os error 61)"; - - #[test] - fn classifies_r5_loopback_connect_refused_as_loopback_unavailable() { - assert_eq!( - expected_error_kind(R5_BODY), - Some(ExpectedErrorKind::LoopbackUnavailable), - "R5 body must classify as LoopbackUnavailable, not the broader NetworkUnreachable bucket" - ); - } - - #[test] - fn classifies_r6_rpc_wrapped_loopback_connect_refused_as_loopback_unavailable() { - assert_eq!( - expected_error_kind(R6_BODY), - Some(ExpectedErrorKind::LoopbackUnavailable), - "R6 body (rpc.invoke_method re-wrap) must classify as LoopbackUnavailable" - ); - } - - #[test] - fn classifies_loopback_connect_refused_across_platforms() { - // Linux WSL / native: os error 111. Windows WSAECONNREFUSED: 10061. - // Both must classify so the matcher works regardless of where the - // user's desktop happens to be running. - for raw in [ - "error sending request for url (http://127.0.0.1:18474/x) \ - → tcp connect error → Connection refused (os error 111)", - "error sending request for url (http://localhost:18474/x) \ - → tcp connect error → Connection refused (os error 10061)", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::LoopbackUnavailable), - "should classify as LoopbackUnavailable across platforms: {raw}" - ); - } - } - - #[test] - fn loopback_unavailable_precedence_over_network_unreachable() { - // Precedence guard: a loopback `Connection refused (os error 61)` - // body would ALSO match `is_network_unreachable_message` because the - // broader matcher catches both `error sending request for url` and - // `connection refused`. The ladder must route through the - // loopback-specific bucket first so the two error classes stay - // distinguishable in Sentry. - let kind = expected_error_kind(R5_BODY); - assert_eq!(kind, Some(ExpectedErrorKind::LoopbackUnavailable)); - assert_ne!(kind, Some(ExpectedErrorKind::NetworkUnreachable)); - } - - #[test] - fn does_not_classify_loopback_url_with_different_error_class_as_loopback() { - // A real upstream HTTP failure that happens to hit a developer's - // local proxy on `127.0.0.1:` (e.g. `mitmproxy`, `Charles`, - // `ngrok http`) must NOT be silenced as loopback noise — the body - // shape is a 503 status, not a transport-level connect-refused, and - // is actionable for Sentry. - let raw = "Backend returned 503 Service Unavailable for GET \ - http://127.0.0.1:8080/agent-integrations/composio/connections: \ - upstream timed out"; - assert!( - !matches!( - expected_error_kind(raw), - Some(ExpectedErrorKind::LoopbackUnavailable) - ), - "loopback URL with non-transport error must not classify as LoopbackUnavailable" - ); - } - - #[test] - fn does_not_classify_non_loopback_connect_refused_as_loopback() { - // A `Connection refused` against a non-loopback host (DNS resolved - // to a remote IP, ISP-level block, captive portal) must fall - // through to `NetworkUnreachable`, not into the loopback bucket. - let raw = "error sending request for url \ - (https://api.tinyhumans.ai/agent-integrations/composio/connections) \ - → tcp connect error → Connection refused (os error 61)"; - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::NetworkUnreachable) - ); - } - - #[test] - fn loopback_matcher_requires_both_host_and_errno_anchors() { - // Defense against the matcher being too eager: bodies that satisfy - // only one of the two conjunctive anchors must not classify into the - // loopback bucket. They may still demote via the broader - // `NetworkUnreachable` matcher — that is the correct fall-through — - // but the bucket must stay distinct so Sentry's "what class is - // spiking?" signal is preserved. - let loopback_host_no_errno = - "doctor: probed 127.0.0.1:18474 and got connection refused without errno detail"; - assert_ne!( - expected_error_kind(loopback_host_no_errno), - Some(ExpectedErrorKind::LoopbackUnavailable), - "loopback host without `(os error N)` errno must not classify as LoopbackUnavailable" - ); - - let errno_no_loopback_host = "note: connection refused (os error 61) on retry"; - assert_ne!( - expected_error_kind(errno_no_loopback_host), - Some(ExpectedErrorKind::LoopbackUnavailable), - "errno without loopback host anchor must not classify as LoopbackUnavailable" - ); - } - - #[test] - fn report_error_or_expected_routes_r5_r6_through_expected_path() { - // Smoke test: both verbatim Sentry bodies flow through - // `report_error_or_expected` without panicking. The classifier - // routes them to `report_expected_message` (debug breadcrumb, - // metadata-only) instead of `report_error_message` - // (`sentry::capture_message` at error level). We can't observe the - // Sentry hub from this test, but exercising the call path catches - // any future regression that re-introduces a panic or mis-types - // the arm. - report_error_or_expected( - R5_BODY, - "integrations", - "get", - &[ - ("path", "/agent-integrations/composio/connections"), - ("failure", "transport"), - ], - ); - report_error_or_expected( - R6_BODY, - "rpc", - "invoke_method", - &[("method", "openhuman.composio_list_connections")], - ); - } - - #[test] - fn classifies_channel_supervisor_restart_english_discord_gateway() { - // TAURI-RUST-15 (~11.4k events / 14d on self-hosted `tauri-rust`): - // verbatim wrapper from `channels::runtime::supervision::spawn_supervised_listener` - // around the Discord gateway transport error. The English body - // would otherwise match `is_network_unreachable_message` (which - // demotes to `warn!` — still a Sentry event); the supervisor - // wrap precedence routes it to `ChannelSupervisorRestart` - // (info-only breadcrumb). - let body = "Channel discord error: error sending request for url \ - (https://discord.com/api/v10/gateway/bot); restarting"; - assert_eq!( - expected_error_kind(body), - Some(ExpectedErrorKind::ChannelSupervisorRestart) - ); - } - - #[test] - fn classifies_channel_supervisor_restart_chinese_windows_wsaetimedout() { - // TAURI-RUST-BB (~815 events / 14d): same supervisor wrapper, - // OS-localized inner WSAETIMEDOUT body on Chinese Windows. The - // English-only `is_network_unreachable_message` anchors miss - // this inner message, so without the language-agnostic - // supervisor matcher it would escape classification entirely - // and emit a full Sentry error. The wrapper-anchored predicate - // catches it regardless of OS locale. - let body = "Channel discord error: IO error: \ - 由于连接方在一段时间后没有正确答复或连接的主机没有反应,连接尝试失败。 \ - (os error 10060); restarting"; - assert_eq!( - expected_error_kind(body), - Some(ExpectedErrorKind::ChannelSupervisorRestart) - ); - } - - #[test] - fn channel_supervisor_restart_matches_multiple_channel_names() { - // The wrapper format is `"Channel error: ; restarting"`. - // The name slot varies by provider (discord, slack, telegram, - // whatsapp, gmessages, …). The matcher must classify all of them — - // language-agnostic, name-agnostic. - for raw in [ - "Channel slack error: gateway disconnect; restarting", - "Channel telegram error: tls handshake eof; restarting", - "Channel whatsapp error: connection reset by peer (os error 54); restarting", - "Channel gmessages error: WebSocket connect: HTTP error: 502 Bad Gateway; restarting", - ] { - assert_eq!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ChannelSupervisorRestart), - "should classify as channel-supervisor-restart: {raw}" - ); - } - } - - #[test] - fn channel_supervisor_restart_precedence_over_network_unreachable() { - // Pin the precedence: a supervisor-wrap body that ALSO contains - // the canonical `"error sending request for url"` anchor (which - // would by itself classify as `NetworkUnreachable`) MUST route - // to `ChannelSupervisorRestart`. The supervisor's own backoff - // handles the condition; `NetworkUnreachable` would demote to - // `warn!` (still a Sentry event), whereas - // `ChannelSupervisorRestart` demotes to `info!` (no event). - let body = "Channel discord error: error sending request for url \ - (https://discord.com/api/v10/gateway/bot); restarting"; - let kind = expected_error_kind(body); - assert_eq!(kind, Some(ExpectedErrorKind::ChannelSupervisorRestart)); - assert_ne!(kind, Some(ExpectedErrorKind::NetworkUnreachable)); - } - - #[test] - fn channel_supervisor_restart_does_not_classify_unrelated_restart_notes() { - // Defense against the matcher being too eager: bodies that - // contain `"; restarting"` but NOT the `"Channel error:"` - // preamble must NOT classify — those are generic restart logs - // from other subsystems where Sentry signal may still be - // actionable. The matcher requires all three anchors together - // (`"channel "` prefix + `" error:"` separator + `"; restarting"` - // trailer). - for raw in [ - // No `Channel ` preamble. - "systemd: docker.service; restarting", - // No `Channel ` preamble even though `; restarting` - // appears. - "Connection refused; restarting", - // The string `channel` appears but not as the leading - // `"Channel error:"` wrapper — must not classify. - "channels::runtime::dispatch failed: error: provider exhausted; restarting", - // The wrapper prefix is present but the trailer is not — - // a half-formed log line must not classify. - "Channel discord error: gateway disconnect", - ] { - assert_ne!( - expected_error_kind(raw), - Some(ExpectedErrorKind::ChannelSupervisorRestart), - "must NOT classify as channel-supervisor-restart: {raw}" - ); - } - } - - #[test] - fn report_error_or_expected_routes_channel_supervisor_restart_through_expected_path() { - // Smoke test: the verbatim TAURI-RUST-15 Sentry body flows through - // `report_error_or_expected` without panicking. The classifier - // routes it to `report_expected_message` (info breadcrumb) instead - // of `report_error_message` (`sentry::capture_message` at error - // level). We can't observe the Sentry hub from this test, but - // exercising the call path catches any future regression that - // re-introduces a panic or mis-types the arm. - report_error_or_expected( - "Channel discord error: error sending request for url \ - (https://discord.com/api/v10/gateway/bot); restarting", - "channels", - "supervised_listener", - &[("channel", "discord")], - ); - } -} +#[path = "observability_tests.rs"] +mod tests; diff --git a/src/core/observability_tests.rs b/src/core/observability_tests.rs new file mode 100644 index 000000000..0ccd9ff95 --- /dev/null +++ b/src/core/observability_tests.rs @@ -0,0 +1,3545 @@ +use super::*; + +/// Helper must accept `&anyhow::Error`, `&dyn std::error::Error`, and +/// plain `&str` — the three shapes that show up at error sites today. +#[test] +fn report_error_accepts_common_error_shapes() { + let anyhow_err = anyhow::anyhow!("boom"); + report_error(&anyhow_err, "test", "anyhow_shape", &[]); + + let io_err = std::io::Error::other("io failed"); + report_error(&io_err, "test", "io_shape", &[("kind", "io")]); + + report_error("plain message", "test", "str_shape", &[]); +} + +#[test] +fn anyhow_chain_is_rendered_in_full() { + // Regression guard: `err.to_string()` on an anyhow chain only emits + // the outermost context. Using `{:#}` joins every cause, which is + // what Sentry needs to actually diagnose wrapped failures. + let inner = std::io::Error::other("inner cause"); + let wrapped = anyhow::Error::from(inner).context("outer ctx"); + assert_eq!(format!("{wrapped:#}"), "outer ctx: inner cause"); +} + +#[test] +fn classifies_expected_config_errors() { + assert_eq!( + expected_error_kind("rpc.invoke_method failed: local ai is disabled"), + Some(ExpectedErrorKind::LocalAiDisabled) + ); + assert_eq!( + expected_error_kind( + "agent.provider_chat failed: ollama API key not set. Configure via the web UI" + ), + Some(ExpectedErrorKind::ApiKeyMissing) + ); + assert_eq!( + expected_error_kind("ollama embed failed with status 500"), + None + ); +} + +/// Task B (issue #2898): prove the canonical 429 error message produced by +/// the embedding clients is already classified as `TransientUpstreamHttp` +/// so Sentry events are suppressed even without backoff. +/// +/// The `is_transient_upstream_http_message` matcher checks for +/// `"api error (429 "` (case-insensitive), which is present in both the +/// OpenAI and Cohere canonical error shapes. +#[test] +fn embedding_429_classifies_as_transient_upstream_http() { + // OpenAI/Voyage canonical shape (openai.rs emit site). + let msg = "Embedding API error (429 Too Many Requests): Rate limit exceeded."; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "OpenAI 429 must classify as TransientUpstreamHttp: {msg}" + ); + + // Cohere canonical shape (cohere.rs emit site). + let cohere_msg = "Cohere embed API error (429 Too Many Requests): rate limit exceeded."; + assert_eq!( + expected_error_kind(cohere_msg), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "Cohere 429 must classify as TransientUpstreamHttp: {cohere_msg}" + ); + + // After-cap bail shape from the retry loop (openai.rs). + let cap_msg = + "Embedding API error (429 Too Many Requests): rate limit exceeded after 3 retries"; + assert_eq!( + expected_error_kind(cap_msg), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "retry-cap bail message must classify as TransientUpstreamHttp: {cap_msg}" + ); + + // After-cap bail shape from the retry loop (cohere.rs). + let cohere_cap_msg = + "Cohere embed API error (429 Too Many Requests): rate limit exceeded after 3 retries"; + assert_eq!( + expected_error_kind(cohere_cap_msg), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "Cohere retry-cap bail message must classify as TransientUpstreamHttp: {cohere_cap_msg}" + ); +} + +#[test] +fn classifies_backend_env_api_key_not_configured() { + // TAURI-RUST-2H5 (~5 K events): backend embedding endpoint returns a + // 400 with `{"success":false,"error":"VOYAGE_API_KEY is not configured"}` + // whenever the backend env var is absent. This is a known server-side + // config state, not an app error — silence it the same way we silence + // other `ApiKeyMissing` variants. + for raw in [ + r#"Embedding API error (400 Bad Request): {"success":false,"error":"VOYAGE_API_KEY is not configured"}"#, + r#"Embedding API error 400 Bad Request: {"success":false,"error":"VOYAGE_API_KEY is not configured"}"#, + // Future-proof: same shape for any other backend-managed embedder. + r#"Embedding API error (400 Bad Request): {"success":false,"error":"COHERE_API_KEY is not configured"}"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ApiKeyMissing), + "should classify backend env api-key missing: {raw}" + ); + } +} + +#[test] +fn does_not_classify_unrelated_is_not_configured_messages() { + // The `_api_key` anchor must keep prose that merely says "is not + // configured" from being silenced — only env-var-style key names + // should match. + assert_eq!( + expected_error_kind("workspace path is not configured for this user"), + None + ); + assert_eq!( + expected_error_kind("embedding model is not configured"), + None + ); + assert_eq!( + expected_error_kind("provider 'voyage' is not configured in settings"), + None + ); +} + +#[test] +fn classifies_ollama_user_config_rejections() { + // TAURI-RUST-XS (~376 events): user pointed embedder at a chat / + // vision model id, sometimes with a temperature suffix like `@0.7` + // that Ollama parses as malformed. + for raw in [ + // Canonical XS wire shape from + // `OllamaEmbedding::embed` non-2xx path on a 400 Bad Request. + r#"ollama embed failed with status 400 Bad Request: {"error":"invalid model name"}"#, + // Same shape with a temperature-suffix model id the user pasted + // into Settings → Embeddings → Ollama. + r#"ollama embed failed with status 400 Bad Request: {"error":"invalid model name: qwen3-vl:4b@0.7"}"#, + // OPENHUMAN-TAURI-MA — model not pulled (404 Not Found). + r#"ollama embed failed with status 404 Not Found: {"error":"model \"bge-m3\" not found, try pulling it first"}"#, + // OPENHUMAN-TAURI-KM — same shape, different model id + `:latest` tag. + r#"ollama embed failed with status 404 Not Found: {"error":"model \"nomic-embed-text:latest\" not found, try pulling it first"}"#, + // OPENHUMAN-TAURI-GX — daemon-unreachable opt-in state. + "ollama embeddings opted-in but daemon unreachable at http://localhost:11434; falling back to cloud embeddings for this session", + // TAURI-RUST-3X — 501-status model-does-not-support-embeddings. + r#"ollama embed failed with status 501 Not Implemented: {"error":"this model does not support embeddings"}"#, + // TAURI-RUST-3E — 401 unauthorized embed (auth required at ollama endpoint). + r#"ollama embed failed with status 401 Unauthorized: {"error": "unauthorized"}"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ProviderUserState), + "should classify Ollama user-config rejection: {raw}" + ); + } +} + +#[test] +fn classifies_embedding_backend_auth_failure() { + // TAURI-RUST-T (~4k events) — companion of TAURI-RUST-4K5: the + // OpenHuman backend rejected the embeddings worker's bearer + // token. Both the bare-status and parenthesised wire shapes + // must classify as SessionExpired so the FE re-login prompt + // fires (matches the contract introduced by #2786 and + // exercised by classifies_embedding_api_invalid_token_401_as_session_expired). + for raw in [ + r#"Embedding API error 401 Unauthorized: {"success":false,"error":"Invalid token"}"#, + r#"Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::SessionExpired), + "should classify embedding backend auth failure as SessionExpired: {raw}" + ); + } +} + +#[test] +fn does_not_classify_unrelated_invalid_token_messages() { + // Provider 401s with "invalid token" in the body but no + // `Embedding API error` prefix must keep reaching Sentry — they're + // not the same wire shape and may indicate real provider bugs. + assert_eq!( + expected_error_kind(r#"openai chat failed 401: {"error":"invalid token"}"#), + None + ); + // Embedding error without 401 must not be silenced. + assert_eq!( + expected_error_kind( + r#"Embedding API error 500 Internal Server Error: {"error":"invalid token signature service down"}"# + ), + None + ); +} + +#[test] +fn does_not_classify_unrelated_ollama_errors_as_user_config() { + // Unrelated 500 — server-side ollama bug must still reach Sentry. + assert_eq!( + expected_error_kind("ollama embed failed with status 500"), + None + ); + // Parse-failure on the response — real bug in either the server + // or our deserializer, must still reach Sentry. + assert_eq!( + expected_error_kind("ollama embed response parse failed: invalid type: expected sequence"), + None + ); + // Dimension mismatch — real bug (model dims don't match what we + // recorded), must still reach Sentry. + assert_eq!( + expected_error_kind("ollama embed dimension mismatch at index 0: expected 768, got 1024"), + None + ); + // Unrelated `invalid model name` outside Ollama embed call — + // anchor on the `ollama embed` prefix keeps this from being silenced. + assert_eq!( + expected_error_kind("provider config validation failed: invalid model name"), + None + ); + // Unrelated `model "…" not found` text without the `ollama embed` + // prefix — anchor keeps this from being silenced even when the + // exact MA/KM wire-shape substring appears in another context. + assert_eq!( + expected_error_kind(r#"provider listing failed: model \"foo\" not found in registry"#), + None + ); +} + +#[test] +fn classifies_local_ai_capability_unavailable_errors() { + // OPENHUMAN-TAURI-3B: surfaced by `local_ai_download_asset` when a + // user on a 0–4 GB RAM tier requests a vision asset. Both canonical + // wire shapes — emitted from `assets.rs` and `vision_embed.rs` — + // must classify as expected so they stop reaching Sentry. + for raw in [ + "Vision is disabled for this RAM tier. Switch to the 4-8 GB tier or above to enable it.", + "vision summaries are unavailable for this RAM tier. Use OCR-only summarization or switch to a higher local AI tier.", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::LocalAiCapabilityUnavailable), + "should classify as local-ai capability unavailable: {raw}" + ); + } + + // Wrapped by the RPC dispatch layer as it reaches `report_error_or_expected` + // — the classifier is substring-based, so caller context must not defeat it. + assert_eq!( + expected_error_kind( + "rpc.invoke_method failed: Vision is disabled for this RAM tier. Switch to the 4-8 GB tier or above to enable it." + ), + Some(ExpectedErrorKind::LocalAiCapabilityUnavailable) + ); +} + +#[test] +fn classifies_prompt_injection_blocked_errors() { + // OPENHUMAN-TAURI-140: ~1 480 events from `openhuman.agent_chat` where + // users' messages scored ≥ 0.45 on the injection heuristic. Both + // enforcement wire shapes must be classified as expected so they stop + // reaching Sentry. + for raw in [ + "Prompt flagged for security review and was not processed. Please rephrase clearly.", + "Prompt blocked by security policy. Please rephrase without instruction overrides or exfiltration requests.", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::PromptInjectionBlocked), + "should classify as prompt-injection blocked: {raw}" + ); + } + + // Wrapped by the RPC dispatch layer — substring match must survive the prefix. + assert_eq!( + expected_error_kind( + "rpc.invoke_method failed: Prompt flagged for security review and was not processed. Please rephrase clearly." + ), + Some(ExpectedErrorKind::PromptInjectionBlocked) + ); +} + +#[test] +fn does_not_classify_unrelated_messages_as_prompt_injection_blocked() { + // Must not silently swallow real security errors or generic "prompt" mentions. + assert_eq!( + expected_error_kind("prompt injection detected in tool arguments"), + None + ); + assert_eq!( + expected_error_kind("security review required for deploy"), + None + ); +} + +// ── ContextWindowExceeded (TAURI-RUST-501) ───────────────────────────── + +#[test] +fn classifies_context_window_exceeded_rereport() { + // TAURI-RUST-501: the custom-provider 500 body that escapes the + // provider api_error cascade's own status-gated checks. When the + // error is re-raised by `agent.run_single` / `web_channel. + // run_chat_task`, `report_error_or_expected` runs the classifier on + // the full message — this arm must catch the new phrasing. + assert_eq!( + expected_error_kind( + "custom API error (500 Internal Server Error): \ + {\"error\":{\"code\":500,\"message\":\"Context size has been exceeded.\",\"type\":\"server_error\"}}" + ), + Some(ExpectedErrorKind::ContextWindowExceeded) + ); + + // The established phrasings the provider/reliable layer already + // recognized must classify here too (single-source matcher). + for raw in [ + "OpenAI API error (400): This model's maximum context length is 8192 tokens", + "request exceeds the context window of this model", + "context length exceeded", + "prompt is too long", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ContextWindowExceeded), + "should classify as context-window-exceeded: {raw}" + ); + } +} + +#[test] +fn does_not_classify_unrelated_messages_as_context_window_exceeded() { + // Anchors are context-overflow specific. A generic "window" or + // "context" mention, or an unrelated rate-limit "exceeded", must + // not classify. + for raw in [ + "rate limit exceeded, retry after 30s", + "failed to open context menu window", + "tool call exceeded the allowed budget", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "must NOT classify as context-window-exceeded: {raw}" + ); + } +} + +// ── FilesystemUserPathInvalid (TAURI-RUST-4QH) ───────────────────────── + +#[test] +fn classifies_vault_create_root_path_not_a_directory_as_filesystem_user_path_invalid() { + // TAURI-RUST-4QH: verbatim wire shape from + // `openhuman::vault::ops::vault_create` line 37 when the + // user-picked vault folder doesn't resolve to an existing + // directory. Bubbles up as the RPC dispatcher's + // `display_message` and reaches `report_error_or_expected` — + // must classify so no Sentry event fires. + assert_eq!( + expected_error_kind( + "root_path is not a directory: /Users/zadam/Documents/SndBrainOpenHuman" + ), + Some(ExpectedErrorKind::FilesystemUserPathInvalid) + ); + + // The same body wrapped by the JSON-RPC dispatcher's `display_message` + // prefix (`rpc.invoke_method` re-emit shape from `src/core/jsonrpc.rs`). + // Must still classify so the dispatch-site re-report doesn't escape + // the matcher even if a future caller layers more context. + assert_eq!( + expected_error_kind( + "rpc.invoke_method failed: root_path is not a directory: /Users/alice/openhuman-data" + ), + Some(ExpectedErrorKind::FilesystemUserPathInvalid) + ); +} + +#[test] +fn classifies_http_host_hosted_path_not_a_directory_as_filesystem_user_path_invalid() { + // Preempt the symmetric shape from + // `openhuman::http_host::path_utils:23` — + // `"hosted path is not a directory: "`. Not yet observed + // in Sentry but shares the same RPC validation polarity as + // vault_create's `root_path` check. Anchoring on + // `"path is not a directory:"` (with trailing colon) covers + // both without two separate matchers. + assert_eq!( + expected_error_kind("hosted path is not a directory: /var/www/static-site"), + Some(ExpectedErrorKind::FilesystemUserPathInvalid) + ); +} + +#[test] +fn does_not_classify_unrelated_path_messages_as_filesystem_user_path_invalid() { + // Polarity contract — the anchor requires a trailing colon + // after `"is not a directory"`, which discriminates user input + // (path follows the colon) from other shapes: + // + // 1. The `skills::ops_install:475` SAFETY GUARD — + // `" is not a directory — refusing to remove"` — must + // stay actionable. It catches an `rm -rf` invariant violation + // (the target should have been a directory but wasn't), + // which is a code bug, not user input. + // 2. A narrative log line that happens to mention the phrase + // without the user-path colon suffix is not a validation + // failure and must not be silenced. + // 3. The dot-prefix variant from POSIX `EISDIR`/`ENOTDIR` + // renderings (`"Is a directory (os error 21)"`) is the + // inverse condition — different code path entirely. + for raw in [ + // Safety guard — must NOT classify. + "/tmp/openhuman-cache is not a directory — refusing to remove", + // Narrative log line — must NOT classify. + "checked that path is not a directory before mkdir", + // Inverse condition (os error 21: EISDIR) — must NOT classify. + "open /etc/passwd failed: Is a directory (os error 21)", + // Bare path with no `directory` mention — must NOT classify. + "root_path must be absolute: ./relative/path", + // Generic body with the trailing colon but no known vault/http_host + // prefix — must NOT classify (future provider/storage errors that + // happen to embed "path is not a directory: ..." should reach Sentry). + "input config path is not a directory: /etc/foo", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "polarity contract: must NOT classify as FilesystemUserPathInvalid: {raw}" + ); + } +} + +// ── EmptyProviderResponse (TAURI-RUST-4Z1) ───────────────────────────── + +#[test] +fn classifies_empty_provider_response_web_channel_rereport() { + // TAURI-RUST-4Z1: the web-channel re-report of the agent harness's + // empty-provider-response bail. `run_chat_task` wraps the flattened + // string and routes it through `report_error_or_expected` — the + // agent-layer typed suppression (PR #2790) can't reach it, so this + // string classifier must. + assert_eq!( + expected_error_kind( + "run_chat_task failed client_id=l1uxaLd20_1mAdhp \ + thread_id=thread-8f03e7f7-3477-42cd-9283-f0bacd4bfbca \ + request_id=a73716a3-a85a-4045-984b-315772c5b3b8 \ + error=The model returned an empty response. Please try again." + ), + Some(ExpectedErrorKind::EmptyProviderResponse) + ); + + // Bare user-facing string (the verbatim `turn.rs` emission), in case + // a different call site re-reports it without the run_chat_task wrap. + assert_eq!( + expected_error_kind("The model returned an empty response. Please try again."), + Some(ExpectedErrorKind::EmptyProviderResponse) + ); +} + +#[test] +fn does_not_classify_unrelated_empty_response_phrases() { + // Polarity contract: the anchor is `"model returned an empty + // response"`, NOT the looser `"empty response"`. The sibling paths + // below use different subjects or phrasings and are not user-facing + // failures — they must stay out of this bucket so a real regression + // in those paths still reaches Sentry. + for raw in [ + // payload_summarizer.rs:261 — internal fall-through, not a failure. + "[payload_summarizer] summarizer returned empty response, falling through", + // subagent_runner/extract_tool.rs:379 — graceful empty extraction. + "[extract_from_result] provider returned an empty response; returning empty extraction", + // Generic mention without the model-subject anchor. + "warning: empty response body from health probe", + // channels/bus.rs:185 — channel-inbound graceful fallback (routes + // through report_error_or_expected; subject is "agent", not "model"). + "[channel-inbound] agent returned empty response — finalizing draft with fallback", + // memory/query/walk.rs:292 — debug-level memory walk, not a failure. + "[memory_tree_walk] turn=3 LLM gave up (empty response)", + // learning/reflection.rs:576 — reflection skip, not a failure. + "[learning] reflection skipped (empty response — gate off or local AI unavailable)", + // agent/harness/session/turn.rs:811 — "provider returned an empty + // final response" uses subject "provider", not "model"; must not match. + "[agent_loop] provider returned an empty final response (i=2, no text, no tool calls)", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "must NOT classify as EmptyProviderResponse: {raw}" + ); + } +} + +#[test] +fn classifies_memory_store_pii_rejection_errors() { + // TAURI-RUST-54T: ~915 events from one user where the PII guard + // rejected memory-store writes on namespace/key values that look like + // personal identifiers. All three canonical wire shapes — from + // `documents.rs` (upsert_document / upsert_document_metadata_only) + // and `kv.rs` (kv_set_global / kv_set_namespace) — must classify as + // expected so they stop reaching Sentry. + for raw in [ + "document namespace/key cannot contain personal identifiers", + "kv key cannot contain personal identifiers", + "kv namespace/key cannot contain personal identifiers", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::MemoryStorePiiRejection), + "should classify as memory-store PII rejection: {raw}" + ); + } + + // Wrapped by the RPC dispatch layer — substring match must survive the + // `rpc.invoke_method failed: ` prefix that `jsonrpc.rs` prepends. + assert_eq!( + expected_error_kind( + "rpc.invoke_method failed: document namespace/key cannot contain personal identifiers" + ), + Some(ExpectedErrorKind::MemoryStorePiiRejection) + ); +} + +#[test] +fn classifies_memory_store_breaker_open() { + // TAURI-RUST-52X (~455 events on self-hosted Sentry): the chunk-store + // per-path circuit breaker tripped after consecutive SQLite init + // failures. The Windows wire shape is wrapped by + // `memory_tree::tree::rpc::pipeline_status_rpc`'s `chunk aggregates: …` + // context so the substring matcher must survive that prefix. + for raw in [ + // Canonical wire shape from `get_or_init_connection`. + "[memory_tree] circuit breaker open for /home/u/.openhuman/workspace/memory_tree/chunks.db: too many consecutive init failures", + // Canonical wire shape wrapped by the RPC handler's + // `format!("chunk aggregates: {e:#}")` context. + r"chunk aggregates: [memory_tree] circuit breaker open for C:\Users\u\.openhuman\users\6a09\workspace\memory_tree\chunks.db: too many consecutive init failures", + // Wrapped further by the JSON-RPC dispatch layer before reaching + // `report_error_or_expected`. + r"rpc.invoke_method failed: chunk aggregates: [memory_tree] circuit breaker open for /home/u/.openhuman/workspace/memory_tree/chunks.db: too many consecutive init failures", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::MemoryStoreBreakerOpen), + "should classify memory-store breaker-open: {raw}" + ); + } +} + +#[test] +fn classifies_disk_full_errors() { + for raw in [ + // Canonical POSIX errno 28 rendering from `std::io::Error`. + "Failed to create auth profile lock: open lock file: No space left on device (os error 28)", + // Same shape from a different call site — `tokio::fs::write` + // for a state snapshot. + "state snapshot write failed: No space left on device (os error 28)", + // Windows ERROR_DISK_FULL (112) rendering. + "log rotation failed: There is not enough space on the disk. (os error 112)", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::DiskFull), + "should classify disk-full: {raw}" + ); + } +} + +#[test] +fn does_not_classify_unrelated_space_messages() { + // Generic "space" prose without the errno-text anchor must not be + // silenced — the matcher pins to the platform-stable errno + // renderings only. + assert_eq!( + expected_error_kind("workspace path is invalid: contains a space character"), + None + ); + assert_eq!( + expected_error_kind("not enough memory to allocate buffer"), + None + ); +} + +#[test] +fn classifies_config_load_timed_out() { + // Canonical wire string emitted by `load_config_with_timeout` and + // `reload_config_snapshot_with_timeout` in + // `src/openhuman/config/ops.rs`. Drops TAURI-RUST-5X. + assert_eq!( + expected_error_kind("Config loading timed out"), + Some(ExpectedErrorKind::ConfigLoadTimedOut), + ); + // Same shape after the RPC dispatch wraps it for display — the + // matcher is substring-anchored, so a context prefix does not + // break it. + assert_eq!( + expected_error_kind("rpc.invoke_method failed: Config loading timed out"), + Some(ExpectedErrorKind::ConfigLoadTimedOut), + ); +} + +#[test] +fn does_not_classify_unrelated_timeouts_as_config_load_timed_out() { + // Network / HTTP timeouts go to `NetworkUnreachable` / + // `TransientUpstreamHttp`, not the config-load bucket. The + // anchor is the full literal phrase, so a bare "timed out" or + // "operation timed out" body cannot trip this matcher. + assert_ne!( + expected_error_kind( + "Channel discord error: IO error: Operation timed out (os error 60); restarting" + ), + Some(ExpectedErrorKind::ConfigLoadTimedOut), + ); + assert_ne!( + expected_error_kind("OpenHuman API error (504 Gateway Timeout): error code: 504"), + Some(ExpectedErrorKind::ConfigLoadTimedOut), + ); + // Bare "timed out" without the config-load phrase must not match. + assert_eq!(expected_error_kind("cron job timed out after 30s"), None,); +} + +fn classifies_whatsapp_data_sqlite_busy_errors() { + for raw in [ + r#"[whatsapp_data] ingest failed: upsert wa_message chat=120363402402350155@g.us msg=false_120363402402350155@g.us_3A357F28AE74548B1507_207897942335683@lid: database is locked: Error code 5: The database file is locked"#, + r#"rpc.invoke_method failed: [whatsapp_data] ingest failed: upsert wa_message [email] msg=false_120363402402350155@g.us_3A357F28AE74548B1507_207897942335683@lid: database is locked: Error code 5: The database file is locked"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::WhatsAppDataSqliteBusy), + "should classify whatsapp_data sqlite busy/locked: {raw}" + ); + } +} + +#[test] +fn does_not_classify_unrelated_sqlite_lock_messages_as_whatsapp_busy() { + for raw in [ + "failed to run subconscious schema DDL: database is locked", + "memory queue write failed: database table is locked", + "[whatsapp_data] list_messages failed: database is locked", + ] { + assert_ne!( + expected_error_kind(raw), + Some(ExpectedErrorKind::WhatsAppDataSqliteBusy), + "must not classify as whatsapp_data sqlite busy: {raw}" + ); + } +} + +#[test] +fn does_not_classify_unrelated_messages_as_memory_pii_rejection() { + // A generic "personal identifiers" mention without the "cannot contain" + // anchor must not be silenced. + assert_eq!( + expected_error_kind("processing personal identifiers"), + None, + "must not match a bare 'personal identifiers' mention" + ); + // The secret-rejection variant uses different wording and must not be + // swallowed by the PII classifier. + assert_eq!( + expected_error_kind("document namespace/key cannot contain secrets"), + None, + "secret rejection must remain unclassified" + ); +} + +#[test] +fn does_not_classify_unrelated_breaker_messages() { + // Generic "circuit breaker open" without the `[memory_tree]` anchor + // must not be silenced — other domains may use the same phrase for + // real bugs that need to reach Sentry. + assert_eq!( + expected_error_kind("provider reliability: circuit breaker open for openai"), + None + ); + // The `[memory_tree]` tag alone is not enough — must co-occur with + // the `circuit breaker open` substring. + assert_eq!( + expected_error_kind("[memory_tree] failed to run schema DDL: disk full"), + None + ); +} + +// ── Upstream rate-limit suppression (OPENHUMAN-TAURI-S / -6Y / -2E / -RQ) ─ + +/// Canonical Anthropic / OpenAI body with a structured `"rate_limit_error"` +/// type — OPENHUMAN-TAURI-2E (~1 482 events) and -RQ (~741 events). +#[test] +fn classifies_rate_limit_error_type_as_transient() { + for raw in [ + // Direct 429 from the embeddings path (OPENHUMAN-TAURI-RQ): + r#"Embedding API error (429 Too Many Requests): {"error":{"message":"Rate limit exceeded. Please retry after a brief wait.","type":"rate_limit_error"}}"#, + // Via llm_provider.api_error (OPENHUMAN-TAURI-2E): + r#"[observability] llm_provider.api_error failed: OpenHuman API error (429 Too Many Requests): {"error":{"message":"Rate limit exceeded. Please retry after a brief wait.","type":"rate_limit_error"}}"#, + // Re-reported by agent.run_single: + r#"run_chat_task failed client_id=abc thread_id=t1 request_id=r1 error=OpenHuman API error (429 Too Many Requests): {"error":{"message":"Rate limit exceeded.","type":"rate_limit_error"}}"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "should classify rate_limit_error body as transient: {raw}" + ); + } +} + +/// OpenHuman backend wrapping an upstream 429 as HTTP 500 with a +/// `"upstream rate limit exceeded"` body — OPENHUMAN-TAURI-6Y (~19 849 +/// events). +#[test] +fn classifies_upstream_rate_limit_in_500_body_as_transient() { + for raw in [ + r#"OpenHuman API error (500 Internal Server Error): {"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'. Please retry shortly."}"#, + r#"[observability] llm_provider.api_error failed: OpenHuman API error (500 Internal Server Error): {"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'. Please retry shortly.","details":{"provider":"gmi","upstreamModel":"deepseek-ai/DeepSeek-V3-0324"}}"#, + // Re-wrapped by rpc.invoke_method: + r#"rpc.invoke_method failed: LLM summarisation failed: OpenHuman API error (500 Internal Server Error): {"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'."}"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "should classify upstream-rate-limit-in-500 as transient: {raw}" + ); + } +} + +/// Backend returning HTTP 500 with a numeric `"429 rate limit exceeded"` +/// body — OPENHUMAN-TAURI-S (~6 984 events). +#[test] +fn classifies_429_rate_limit_in_500_body_as_transient() { + for raw in [ + r#"OpenHuman API error (500 Internal Server Error): {"success":false,"error":"429 rate limit exceeded, please try again later"}"#, + r#"[observability] llm_provider.api_error failed: OpenHuman API error (500 Internal Server Error): {"success":false,"error":"429 rate limit exceeded, please try again later"}"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "should classify 429-in-500-body as transient: {raw}" + ); + } +} + +/// The security::policy `"Rate limit exceeded: action budget exhausted"` +/// must NOT be silenced — it's a user-facing hard stop, not a transient +/// upstream quota hit. +#[test] +fn does_not_classify_security_policy_rate_limit_as_transient() { + let msg = "Rate limit exceeded: action budget exhausted (0 actions/hour). \ + Increase the limit in Settings -> Advanced -> Agent autonomy"; + assert_eq!( + expected_error_kind(msg), + None, + "security policy action-budget error must reach Sentry: {msg}" + ); + // Wrapped by rpc.invoke_method — the prefix must not accidentally + // trigger the `api error (` anchor. + assert_eq!( + expected_error_kind(&format!("rpc.invoke_method failed: {msg}")), + None, + "wrapped security policy action-budget error must reach Sentry" + ); +} + +/// Standalone `"rate limit exceeded"` without the `"api error ("` anchor +/// must NOT be silenced — keeps loose phrases from accidentally demoting +/// unrelated errors. +#[test] +fn does_not_classify_bare_rate_limit_exceeded_as_transient() { + assert_eq!( + expected_error_kind("rate limit exceeded"), + None, + "bare 'rate limit exceeded' without API error anchor must reach Sentry" + ); +} + +/// `is_upstream_rate_limit_message` predicate unit tests — verifies the +/// polarity contract independently of `expected_error_kind`. +#[test] +fn upstream_rate_limit_predicate_matches_expected_shapes() { + for lower in [ + r#"{"error":{"message":"rate limit exceeded.","type":"rate_limit_error"}}"#, + "upstream rate limit exceeded for model 'summarization-v1'", + "429 rate limit exceeded, please try again later", + r#"openai api error (429 too many requests): {"error":{"message":"rate limit exceeded.","type":"rate_limit_error"}}"#, + ] { + assert!( + is_upstream_rate_limit_message(lower), + "should match: {lower}" + ); + } +} + +#[test] +fn upstream_rate_limit_predicate_does_not_match_unrelated() { + for lower in [ + // security::policy budget message — must not be swallowed + "rate limit exceeded: action budget exhausted (0 actions/hour)", + // bare phrase without anchor + "rate limit exceeded", + // unrelated 500 body + r#"{"success":false,"error":"internal server error"}"#, + // budget exhausted — different concept + "budget exhausted, add credits to continue", + ] { + assert!( + !is_upstream_rate_limit_message(lower), + "should not match: {lower}" + ); + } +} + +#[test] +fn does_not_classify_unrelated_messages_as_capability_unavailable() { + // The classifier anchors on the exact "for this RAM tier" substring. + // Messages that talk about RAM in a different context (sizing the + // tier list, doc references) must not be silenced. + assert_eq!(expected_error_kind("ollama embed failed: out of RAM"), None); + assert_eq!( + expected_error_kind("local_ai_set_ram_tier failed: invalid tier value"), + None + ); +} + +#[test] +fn classifies_network_unreachable_errors() { + // OPENHUMAN-TAURI-32: reqwest's transport-level error wrapped by the + // web_channel error site. The classifier must catch it even when + // embedded in caller context, since `report_error_or_expected` runs + // `expected_error_kind` on the full anyhow chain. + assert_eq!( + expected_error_kind( + "run_chat_task failed client_id=abc thread_id=t1 request_id=r1 \ + error=error sending request for url (https://api.tinyhumans.ai/openai/v1/chat/completions)" + ), + Some(ExpectedErrorKind::NetworkUnreachable) + ); + for raw in [ + "error sending request for url (https://api.example.com/x)", + "provider failed: dns error: failed to lookup address information", + "tcp connect: connection refused (os error 61)", + "stream closed: connection reset by peer", + "network is unreachable (os error 51)", + "no route to host", + "tls handshake eof", + "certificate verify failed: unable to get local issuer certificate", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::NetworkUnreachable), + "should classify as network-unreachable: {raw}" + ); + } +} + +#[test] +fn does_not_classify_unrelated_provider_errors_as_network() { + // Status-bearing provider failures (404, 500, …) are surfaced via + // their HTTP status path and must NOT be silenced by the + // network-unreachable classifier — the body text doesn't hit any of + // the transport-level markers. + assert_eq!( + expected_error_kind("OpenAI API error (404): model gpt-x not found"), + None + ); + assert_eq!( + expected_error_kind("OpenAI API error (500): internal server error"), + None + ); +} + +#[test] +fn classifies_wave4_socket_transport_wire_shapes() { + // OPENHUMAN-TAURI-44 (~50 events): libc `getaddrinfo()` rendering + // without the `dns error` token, wrapped by the socket emit site. + // The Wave 4 matcher arms catch the literal resolver phrases that + // the original `dns error` substring would miss when reqwest's + // wrapper isn't in the chain (e.g. tungstenite IO errors). + assert_eq!( + expected_error_kind( + "[socket] Connection failed (sustained outage after 5 attempts): \ + WebSocket connect: IO error: failed to lookup address information: \ + nodename nor servname provided, or not known" + ), + Some(ExpectedErrorKind::NetworkUnreachable) + ); + + // OPENHUMAN-TAURI-4P (~66 events): tungstenite renders a captive + // portal / corporate proxy that intercepts the WS handshake as + // `WsError::Http(200)` → `"HTTP error: 200 OK"`. Classify as + // network-unreachable since no amount of app-side retry can pierce + // an intercepting proxy. + assert_eq!( + expected_error_kind( + "[socket] Connection failed (sustained outage after 5 attempts): \ + WebSocket connect: HTTP error: 200 OK" + ), + Some(ExpectedErrorKind::NetworkUnreachable) + ); +} + +#[test] +fn http_200_classifier_does_not_silence_unrelated_log_lines() { + // The captive-portal arm anchors on `"http error: 200 ok"` (the + // exact tungstenite `WsError::Http(200)` Display rendering). + // Adjacent non-WebSocket log lines that mention `"HTTP/1.1 200 OK"` + // or `"status: 200 OK"` MUST NOT classify — those are normal-flow + // success traces, not failure events. Pin this precedence so a + // future refactor doesn't broaden the substring. + assert_eq!(expected_error_kind("HTTP/1.1 200 OK"), None); + assert_eq!( + expected_error_kind("upstream returned status: 200 OK after retry"), + None + ); +} + +#[test] +fn classifies_tls_handshake_eof_as_network_unreachable() { + // TAURI-RUST-4ZD (first seen on `openhuman@0.56.0+e8968077aeb5`, + // Windows): `native-tls` renders a peer / firewall / antivirus / + // corporate-proxy TCP close mid-TLS-handshake as + // `"TLS error: native-tls error: unexpected EOF during handshake"`, + // which `socket::ws_loop::run_connection` wraps as + // `"WebSocket connect: "` and the supervisor's + // sustained-outage escalation wraps again. The existing + // `"tls handshake"` arm misses it because the words are not + // contiguous in this render (`"tls error"` … `"during handshake"`). + // Same user-environment shape as the other handshake-stage entries: + // the socket supervisor already retries with exponential backoff and + // Sentry has no actionable signal beyond that. + assert_eq!( + expected_error_kind( + "[socket] Connection failed (sustained outage after 5 attempts): \ + WebSocket connect: TLS error: native-tls error: unexpected EOF during handshake" + ), + Some(ExpectedErrorKind::NetworkUnreachable) + ); + + // Bare native-tls render (no socket-supervisor wrap) — fires when the + // same handshake EOF escapes through a non-supervisor call site. The + // classifier runs on the full anyhow chain, so the shorter form must + // also match. + assert_eq!( + expected_error_kind("TLS error: native-tls error: unexpected EOF during handshake"), + Some(ExpectedErrorKind::NetworkUnreachable) + ); +} + +#[test] +fn classifies_ws_protocol_wrong_http_version_as_network_unreachable() { + // CORE-RUST-DP (~2 events / 24h on `openhuman@0.56.0+e8968077aeb5`, + // self-hosted `core-rust`): tungstenite renders + // `ProtocolError::WrongHttpVersion` as + // `"WebSocket protocol error: HTTP version must be 1.1 or higher"`, + // wrapped by `socket::ws_loop::run_connection` as + // `"WebSocket connect: "` and then by the supervisor's + // sustained-outage escalation as + // `"[socket] Connection failed (sustained outage after N attempts): + // WebSocket connect: WebSocket protocol error: HTTP version must be + // 1.1 or higher"`. + // + // The handshake requires HTTP/1.1; a server or intermediary proxy + // that responds with HTTP/2+ to the upgrade is misconfigured + // upstream — same shape as the existing `"tls handshake"` / + // `"certificate verify failed"` user-environment entries. The + // supervisor already retries with exponential backoff; Sentry has + // no actionable signal to add. + assert_eq!( + expected_error_kind( + "[socket] Connection failed (sustained outage after 5 attempts): \ + WebSocket connect: WebSocket protocol error: HTTP version must be 1.1 or higher" + ), + Some(ExpectedErrorKind::NetworkUnreachable) + ); + + // Bare tungstenite render (no socket-supervisor wrap) — fires when + // the same protocol error escapes through a non-supervisor call + // site. The classifier runs on the full anyhow chain, so the + // shorter form must also match. + assert_eq!( + expected_error_kind("WebSocket protocol error: HTTP version must be 1.1 or higher"), + Some(ExpectedErrorKind::NetworkUnreachable) + ); +} + +#[test] +fn tls_handshake_eof_anchor_does_not_silence_unrelated_log_lines() { + // The anchor is the literal `"unexpected eof during handshake"` + // phrase. A bare data-phase `"unexpected EOF"` (server closed + // mid-stream, parser truncation, …) MUST NOT classify — those are + // outside the handshake stage and may carry actionable signal. Pin + // the rejection contract so a future refactor doesn't loosen the + // substring into a generic `"unexpected eof"` matcher. + for raw in [ + "stream closed: unexpected EOF", + "reqwest: unexpected EOF while reading body", + "json parser: unexpected EOF at byte 1024", + "decoder hit unexpected eof mid-frame", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "non-handshake unexpected-EOF log line must NOT classify: {raw}" + ); + } +} + +#[test] +fn wrong_http_version_anchor_does_not_silence_unrelated_log_lines() { + // The anchor is the literal tungstenite Display string. Adjacent + // log lines that mention HTTP version in any other context + // (`"upgrading from HTTP/1.0 to HTTP/2"`, `"HTTP/1.1 only"`, + // `"server requires HTTP version 2.0"`) MUST NOT classify — those + // are unrelated transport / negotiation traces and may carry + // actionable signal. Pin the rejection contract so a future + // refactor doesn't loosen the substring into a generic + // `"http version"` matcher. + for raw in [ + "[transport] upgrading from HTTP/1.0 to HTTP/2", + "server advertises HTTP version 2.0 (h2 alpn)", + "client supports HTTP/1.1 only", + "version mismatch: requires HTTP/1.2 or higher", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "unrelated HTTP-version log line must NOT classify: {raw}" + ); + } +} + +#[test] +fn classifies_transient_upstream_http_errors() { + // OPENHUMAN-TAURI-5Z: the canonical shape emitted by + // `providers::ops::api_error` and re-raised through `agent.run_single`. + assert_eq!( + expected_error_kind("OpenHuman API error (504 Gateway Timeout): error code: 504"), + Some(ExpectedErrorKind::TransientUpstreamHttp) + ); + + // Every transient code must classify, whether the status renders as + // bare digits or " ". + for raw in [ + "OpenHuman API error (408): request timeout", + "OpenAI API error (429 Too Many Requests): rate limit", + "Anthropic API error (502 Bad Gateway): upstream unhealthy", + "OpenHuman API error (503): service unavailable", + "Provider API error (504): upstream timed out", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "should classify as transient upstream HTTP: {raw}" + ); + } + + // Wrapped in an anyhow chain (as it reaches the agent layer) must + // still classify — `expected_error_kind` is substring-based. + assert_eq!( + expected_error_kind( + "agent turn failed: OpenHuman API error (504 Gateway Timeout): \ + error code: 504" + ), + Some(ExpectedErrorKind::TransientUpstreamHttp) + ); + + // TAURI-RUST-H (~1360 events, 504) / TAURI-RUST-2T (~310 events, 502): + // legacy no-paren wire shape from older `embeddings::openai` / + // `embeddings::cohere` emit-site formats that predate the + // parenthesised `({status})` rendering. Anchored on the trailing + // space after the status code so unrelated digit runs don't match. + for raw in [ + "Embedding API error 504 Gateway Timeout: error code: 504", + "Embedding API error 502 Bad Gateway: error code: 502", + "Cohere embed API error 503 Service Unavailable: error code: 503", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "should classify legacy no-paren transient shape: {raw}" + ); + } +} + +#[test] +fn does_not_classify_unrelated_digit_runs_as_transient() { + // The legacy no-paren matcher anchors `api error ` with a + // trailing space so adjacent digit runs (`api error 5042…`) and + // non-transient codes (400/401/403/404) don't get silenced. + assert_eq!( + expected_error_kind("OpenHuman API error 400 Bad Request: malformed body"), + None + ); + assert_eq!( + expected_error_kind("provider returned api error 5042 (custom internal sentinel)"), + None + ); +} + +#[test] +fn integrations_post_composio_timeout_dropped() { + // OPENHUMAN-TAURI-18 / -G regression guard. The integrations + // client at `crate::openhuman::integrations::client::IntegrationClient::post` + // builds the reqwest error chain and routes it through + // `report_error_or_expected(.., "integrations", "post", &[("failure", + // "transport")])`. The chain text contains the + // `"error sending request for url"` anchor so + // `is_network_unreachable_message` matches first and demotes to + // `NetworkUnreachable` (functionally equivalent to + // `TransientUpstreamHttp` for Sentry suppression — both routes + // skip the report path via `report_expected_message`). + // + // Pinning this exact wire shape catches a future refactor that + // drops the URL anchor (e.g. a chain-flatten helper that strips + // it for "PII safety"), which would silently re-open the leak. + let chain = "error sending request for url \ + (https://api.tinyhumans.ai/agent-integrations/composio/execute) → \ + client error (SendRequest) → connection error → \ + Operation timed out (os error 60)"; + assert_eq!( + expected_error_kind(chain), + Some(ExpectedErrorKind::NetworkUnreachable), + "TAURI-18 chain shape must classify as NetworkUnreachable" + ); + + // If the URL anchor is ever dropped, the transport-phrase + // fallback (`operation timed out` from + // `TRANSIENT_TRANSPORT_PHRASES`) catches it via the message + // classifier helper used at upstream re-emit sites — confirm + // both paths so the regression surface is fully pinned. + assert!( + is_transient_message_failure(chain), + "TAURI-18 chain must also satisfy upstream message classifier \ + (defense-in-depth for sites that lose the URL anchor)" + ); +} + +#[test] +fn channel_supervisor_operation_timed_out_classifies_as_expected() { + // OPENHUMAN-TAURI-EM (128 events) + TAURI-RUST-15/-BB: `channels::runtime::supervision` + // wraps a channel listener failure as + // `format!("Channel {} error: {e:#}; restarting", ch.name())` and + // routes the message through `report_error_or_expected`. The + // newer `ChannelSupervisorRestart` classifier (added for the + // broader 11.4k-event Sentry leak) anchors on the supervisor + // wrapper shape itself — `"Channel error: …; restarting"` + // — and takes precedence over `NetworkUnreachable`. That single + // arm now covers every ETIMEDOUT / WSAETIMEDOUT / hyper-prose + // shape the old narrower anchor pinned, plus OS-localized + // variants the English-only `NetworkUnreachable` would miss. + // + // Demotion tier difference: `ChannelSupervisorRestart` emits at + // `info!` (breadcrumb only, no Sentry event) where + // `NetworkUnreachable` emitted at `warn!` (still captured as a + // Sentry warn event). Sustained outages still page via + // `health.bus` / `FAIL_ESCALATE_THRESHOLD`. + for raw in [ + // macOS (os error 60 = ETIMEDOUT on BSD) + "Channel discord error: IO error: Operation timed out (os error 60); restarting", + // Linux (os error 110 = ETIMEDOUT) + "Channel discord error: IO error: Operation timed out (os error 110); restarting", + // Windows (os error 10060 = WSAETIMEDOUT) + "Channel discord error: IO error: Operation timed out (os error 10060); restarting", + // Same shape on other channels — supervisor wrapper is provider-agnostic. + "Channel slack error: IO error: Operation timed out (os error 60); restarting", + "Channel telegram error: IO error: Operation timed out (os error 110); restarting", + // Bare prose form (no errno suffix) from hyper / tungstenite layers + // that render `std::io::Error` without `raw_os_error()`. + "Channel discord error: WebSocket connect: IO error: Operation timed out; restarting", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ChannelSupervisorRestart), + "channel supervisor timeout shape must classify as ChannelSupervisorRestart \ + (precedence over NetworkUnreachable; got {:?} for {raw:?})", + expected_error_kind(raw) + ); + } +} + +#[test] +fn operation_timed_out_negative_cases_still_report() { + // Counter-case: a configuration/validation message that mentions + // "timeout" as a knob name (not transport state) and has no other + // classifier anchor must still reach Sentry. The substring chosen + // for the new matcher is `"operation timed out"`, not `"timeout"`, + // precisely so unrelated mentions of the word do not collide. + assert_eq!( + expected_error_kind("config rejected: timeout must be a positive integer"), + None, + "config validation noise (no 'operation timed out' anchor) must still reach Sentry" + ); + // Bare empty string — no anchors at all. + assert_eq!(expected_error_kind(""), None); +} + +#[test] +fn channels_dispatch_re_emit_of_provider_502_classifies_as_transient() { + // OPENHUMAN-TAURI-4F (~157 events) / -1C (~87 events) / -8F + // (~39 events): the reliable provider layer retried 5xx, the + // agent re-raised the error, and `channels::runtime::dispatch` + // re-emitted it under `domain="channels", operation="dispatch_llm_error"` + // via raw `report_error` (which skips classification). Switching + // that site to `report_error_or_expected` routes the chain + // through this classifier — but only works if the canonical + // `"OpenHuman API error (NNN ...)"` substring still anchors the + // match through the channels-layer wrapping. + // + // The wrapping shape at the dispatch site is the agent error + // chain rendered via `format!("{e:#}")`. For a backend 502 from + // `providers::ops::api_error`, that resolves to: + // "OpenHuman API error (502 Bad Gateway): error code: 502" + // possibly prepended with a runner / iteration prefix. Both + // shapes must classify as transient so the dispatch re-emit + // gets demoted. + for raw in [ + "OpenHuman API error (502 Bad Gateway): error code: 502", + "agent.provider_chat failed: OpenHuman API error (503 Service Unavailable): retry budget exhausted", + "all providers exhausted: OpenHuman API error (504 Gateway Timeout): error code: 504", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "channels.dispatch re-emit of {raw:?} must classify as transient" + ); + } +} + +#[test] +fn classifies_socket_transient_http_errors() { + // OPENHUMAN-TAURI-5P / -EZ: tungstenite's `WsError::Http(response)` + // surfaces during the WebSocket upgrade handshake when the backend + // load balancer returns 502 / 504. The socket reconnect loop wraps + // it as `format!("WebSocket connect: {e}")`, producing + // `"WebSocket connect: HTTP error: "`. Each + // sustained-outage threshold escalation routes the formatted reason + // through `report_error_or_expected`, which must classify as + // transient so the per-client noise stops reaching Sentry. + for raw in [ + "WebSocket connect: HTTP error: 502 Bad Gateway", + "WebSocket connect: HTTP error: 503 Service Unavailable", + "WebSocket connect: HTTP error: 504 Gateway Timeout", + "[socket] Connection failed (sustained outage after 5 attempts): \ + WebSocket connect: HTTP error: 502 Bad Gateway", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::TransientUpstreamHttp), + "should classify as transient upstream HTTP (socket shape): {raw}" + ); + } + + // Trailing-colon separator (chained error formatting). + // Note: avoid words like "connection refused" or "timeout" in the + // suffix — those would also match `is_network_unreachable_message` / + // `TRANSIENT_TRANSPORT_PHRASES` and the order in `expected_error_kind` + // would route through `NetworkUnreachable` first, defeating the + // assertion. Both classifications silence the event so production + // behavior is identical, but the test is anchored on the canonical + // socket shape so a future regression in `is_transient_upstream_http_message` + // surfaces here, not behind another classifier. + assert_eq!( + expected_error_kind("WebSocket connect: HTTP error: 502: upstream returned bad gateway"), + Some(ExpectedErrorKind::TransientUpstreamHttp) + ); + + // Trailing-newline separator (multi-line error chain). + assert_eq!( + expected_error_kind("WebSocket connect: HTTP error: 504\nupstream gateway"), + Some(ExpectedErrorKind::TransientUpstreamHttp) + ); +} + +#[test] +fn does_not_classify_unrelated_http_error_text_as_transient_socket() { + // Bare numeric "HTTP error: 5023" (port number, runbook ID) without + // a separator must NOT silence — pin the matcher to space/newline/colon. + assert_eq!(expected_error_kind("HTTP error: 5023"), None); + // Non-transient HTTP statuses must not match — `WsError::Http` for + // a 401 / 403 / 404 is genuinely actionable (auth / routing bug). + for raw in [ + "WebSocket connect: HTTP error: 401 Unauthorized", + "WebSocket connect: HTTP error: 403 Forbidden", + "WebSocket connect: HTTP error: 404 Not Found", + "WebSocket connect: HTTP error: 500 Internal Server Error", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "must NOT silence actionable socket HTTP error: {raw}" + ); + } +} + +#[test] +fn does_not_classify_actionable_provider_errors_as_transient_upstream() { + // 4xx (other than 408/429) and non-transient 5xx must continue to + // reach Sentry — those are real bugs (wrong model name, malformed + // request, internal exception) that need to be triaged. + for raw in [ + "OpenAI API error (400): bad request", + "OpenAI API error (401): unauthorized", + "OpenAI API error (403): forbidden", + "OpenAI API error (404): model not found", + "OpenAI API error (500): internal server error", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "must NOT silence actionable provider error: {raw}" + ); + } + + // A free-form message that merely mentions "504" without the + // `api error (` prefix must not be classified — pin the match to + // the canonical shape from `ops::api_error`. + assert_eq!( + expected_error_kind("see runbook for 504 handling at https://example.com/504"), + None + ); +} + +#[test] +fn classifies_backend_user_error_responses() { + // OPENHUMAN-TAURI-BC: SharePoint authorize 400 because the user + // didn't fill in the required Tenant Name field. After the + // ProviderUserState classifier was added (#1472 wave E), this + // canonical shape now lands in the more specific + // ProviderUserState bucket — `"missing required fields"` wins + // over the generic 4xx matcher. Either expected-kind silences + // Sentry; the dedicated bucket gives operators a finer-grained + // `kind="provider_user_state"` info-log facet for triage. + let bc = "Backend returned 400 Bad Request for POST \ + https://api.tinyhumans.ai/agent-integrations/composio/authorize: \ + Composio authorization failed: 400 \ + {\"error\":{\"message\":\"Missing required fields: Tenant Name\",\ + \"slug\":\"ConnectedAccount_MissingRequiredFields\",\"status\":400}}"; + assert_eq!( + expected_error_kind(bc), + Some(ExpectedErrorKind::ProviderUserState), + "OPENHUMAN-TAURI-BC wire shape must classify as ProviderUserState (the \ + more specific bucket once #1472 wave E added it)" + ); + + // Cover the rest of the 4xx surface produced by integrations / + // composio clients — all user-input / auth-state failures that + // Sentry can't action. + for raw in [ + "Backend returned 400 Bad Request for POST https://api.example.com/x: bad input", + "Backend returned 401 Unauthorized for GET https://api.example.com/x: token expired", + "Backend returned 403 Forbidden for GET https://api.example.com/x: permission denied", + "Backend returned 404 Not Found for GET https://api.example.com/x: missing", + "Backend returned 422 Unprocessable Entity for POST https://api.example.com/x: validation failed", + "Backend returned 451 Unavailable for Legal Reasons for GET https://api.example.com/x: blocked", + // Lowercased context wrapping is irrelevant — substring match is case-insensitive. + "[observability] integrations.post failed: Backend returned 400 Bad Request for POST https://api.tinyhumans.ai/x: detail", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::BackendUserError), + "must classify as backend user-error: {raw}" + ); + } +} + +#[test] +fn does_not_classify_transient_or_server_backend_errors_as_user_error() { + // 408 / 429 are transient — they belong to the + // upstream-transient bucket (or are retried at the caller), not + // the user-error bucket. A sustained 429 (rate limit cliff) MUST + // still surface so we can react. + for raw in [ + "Backend returned 408 Request Timeout for POST https://api.example.com/x: timeout", + "Backend returned 429 Too Many Requests for POST https://api.example.com/x: slow down", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "transient 4xx must NOT be classified as user-error: {raw}" + ); + } + + // 5xx is always actionable — server bugs need to reach Sentry. + for raw in [ + "Backend returned 500 Internal Server Error for POST https://api.example.com/x: oops", + "Backend returned 502 Bad Gateway for POST https://api.example.com/x: upstream down", + "Backend returned 503 Service Unavailable for POST https://api.example.com/x: maintenance", + "Backend returned 504 Gateway Timeout for POST https://api.example.com/x: slow upstream", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "5xx must NOT be classified as user-error: {raw}" + ); + } + + // A free-form message that mentions "400" but doesn't follow the + // `Backend returned ` prefix from the integrations / + // composio clients must not be silenced. + assert_eq!( + expected_error_kind("see HTTP 400 specification at https://example.com/400"), + None + ); + assert_eq!( + expected_error_kind("OpenAI API error (400): bad request"), + None, + "provider-formatted 4xx must keep going through the provider classifier path" + ); +} + +#[test] +fn classifies_trigger_type_not_found_as_provider_user_state() { + // OPENHUMAN-TAURI-3R / -3S: composio enable_trigger when the slug + // isn't in the trigger registry. Backend wraps the upstream + // composio 4xx as 500, so this would otherwise escape the + // 4xx-only `is_backend_user_error_message` matcher. + assert_eq!( + expected_error_kind( + "Backend returned 500 Internal Server Error for POST \ + https://api.tinyhumans.ai/agent-integrations/composio/triggers: \ + Trigger type GITHUB_PUSH_EVENT not found" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + // Wrapped by `rpc.invoke_method` / `[composio] sync(toolkit) failed: …` + // — substring match must survive caller context. + assert_eq!( + expected_error_kind( + "rpc.invoke_method failed: Backend returned 500 Internal Server Error \ + for POST /agent-integrations/composio/triggers: \ + Trigger type SLACK_NEW_MESSAGE not found" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + // Alternate phrasing observed from the same cluster. + assert_eq!( + expected_error_kind( + "composio: Cannot enable trigger 'GITHUB_PUSH_EVENT': trigger not found in registry" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); +} + +#[test] +fn classifies_toolkit_not_enabled_as_provider_user_state() { + // OPENHUMAN-TAURI-34: 400 from composio because the user hasn't + // enabled the toolkit. Must classify as ProviderUserState (more + // specific) rather than the generic BackendUserError bucket — the + // ordering in `expected_error_kind` enforces that. + let msg = "Backend returned 400 Bad Request for POST \ + https://api.tinyhumans.ai/agent-integrations/composio/execute: \ + Toolkit \"get\" is not enabled"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState) + ); + + // Wrapped variant (anyhow chain through the agent runtime). + assert_eq!( + expected_error_kind( + "tool.invoke failed: Backend returned 400 Bad Request for POST \ + /agent-integrations/composio/execute: Toolkit \"linear\" is not enabled \ + for this account" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); +} + +#[test] +fn classifies_custom_openai_upstream_bad_request_as_provider_user_state() { + assert_eq!( + expected_error_kind( + "custom_openai API error (400 Bad Request): \ + {\"error\":{\"message\":\"Bad request to upstream provider\",\ + \"type\":\"upstream_error\",\"status\":400}}" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + // Wrapped by higher-level callers (`agent.run_single`, + // `rpc.invoke_method`) must still classify. + assert_eq!( + expected_error_kind( + "agent.run_single failed: custom_openai API error (400 Bad Request): \ + {\"error\":{\"message\":\"Bad request to upstream provider\",\ + \"type\":\"upstream_error\",\"status\":400}}" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); +} + +/// Regression for CodeRabbit feedback on PR #2107: the matcher must +/// not demote unrelated errors that happen to contain both +/// "bad request to upstream provider" and "upstream_error" without +/// the `custom_openai API error (400` anchor. +#[test] +fn does_not_silence_unrelated_error_with_only_inner_substrings() { + // No `custom_openai API error (400` prefix → must NOT classify + // as ProviderUserState, otherwise we'd silence actionable bugs. + assert_eq!( + expected_error_kind( + "internal panic in router: bad request to upstream provider \ + (state=upstream_error)" + ), + None, + ); + + // A future hypothetical provider envelope reusing one substring + // also must not classify. + assert_eq!( + expected_error_kind( + "anthropic_api error: upstream_error encountered while \ + forwarding bad request to upstream provider" + ), + None, + ); +} + +#[test] +fn classifies_missing_required_fields_as_provider_user_state() { + // OPENHUMAN-TAURI-97: composio authorize with a blank required + // field. Backend wraps the composio 400 as 500 with the inner + // body embedded as a JSON-stringified error message. + assert_eq!( + expected_error_kind( + "Backend returned 500 Internal Server Error for POST \ + https://api.tinyhumans.ai/agent-integrations/composio/authorize: \ + 400 {\"error\":{\"message\":\"Missing required fields: Your Subdomain\"}}" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + // Sibling toolkits surface the same shape with different field names. + for raw in [ + "Backend returned 500 Internal Server Error for POST /authorize: Missing required fields: WABA ID", + "Backend returned 500 Internal Server Error for POST /authorize: Missing required fields: Tenant Name", + "Backend returned 400 Bad Request for POST /authorize: Missing required fields: Domain URL", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ProviderUserState), + "missing-required-fields shape must classify: {raw}" + ); + } +} + +#[test] +fn classifies_insufficient_scopes_as_provider_user_state() { + // OPENHUMAN-TAURI-33: gmail sync surfaced the upstream Google + // OAuth scopes error verbatim through composio. Reaches the RPC + // dispatch site via `[composio] sync(gmail) failed: [composio:gmail] + // GMAIL_FETCH_EMAILS page 0: HTTP 403: Request had insufficient + // authentication scopes.`. + assert_eq!( + expected_error_kind( + "[composio:gmail] GMAIL_FETCH_EMAILS page 0: HTTP 403: \ + Request had insufficient authentication scopes." + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + // Bare upstream shape (in case any future caller forwards without + // the gmail prefix). + assert_eq!( + expected_error_kind("HTTP 403: Request had insufficient authentication scopes."), + Some(ExpectedErrorKind::ProviderUserState) + ); +} + +#[test] +fn classifies_access_terminated_provider_policy_as_provider_user_state() { + assert_eq!( + expected_error_kind( + "custom_openai API error (403 Forbidden): {\"error\":{\"message\":\"Kimi For Coding is currently only available for Coding Agents such as Kimi CLI, Claude Code, Roo Code, Kilo Code, etc.\",\"type\":\"access_terminated_error\"}}" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); + + assert_eq!( + expected_error_kind( + "agent turn failed: custom_openai API error (403): currently only available for coding agents" + ), + Some(ExpectedErrorKind::ProviderUserState) + ); +} + +#[test] +fn does_not_classify_unrelated_500s_as_provider_user_state() { + // Sanity check: a generic 500 with no provider-user-state body + // shape must continue to reach Sentry as an actionable event. + assert_eq!( + expected_error_kind( + "Backend returned 500 Internal Server Error for POST \ + /agent-integrations/composio/triggers: random panic in handler" + ), + None + ); + assert_eq!( + expected_error_kind( + "Backend returned 500 Internal Server Error for GET /teams: database connection lost" + ), + None + ); + + // Free-form text that mentions "not found" / "is not enabled" out + // of context must not be silenced. + assert_eq!( + expected_error_kind("file not found at /tmp/x.json"), + None, + "bare 'not found' without 'trigger type' anchor must NOT classify" + ); + assert_eq!( + expected_error_kind("the cache is not enabled in this build"), + None, + "bare 'is not enabled' without 'toolkit ' anchor must NOT classify" + ); +} + +#[test] +fn classifies_provider_config_rejection() { + // #2079 — an OpenHuman abstract tier alias leaked to a custom + // provider; raised again by `agent.run_single` / + // `web_channel.run_chat_task` so it escapes the provider-layer + // demotion and reaches `report_error_or_expected` here. + assert_eq!( + expected_error_kind( + "agent.run_single failed: custom_openai API error (400 Bad Request): \ + The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \ + but you passed reasoning-v1." + ), + Some(ExpectedErrorKind::ProviderConfigRejection) + ); + // #2076 — Moonshot Kimi K2 temperature constraint. + assert_eq!( + expected_error_kind( + "custom_openai API error (400): invalid temperature: only 1 is allowed for this model" + ), + Some(ExpectedErrorKind::ProviderConfigRejection) + ); + // #2202 — unknown / stale model pin (OpenAI-compatible body). + assert_eq!( + expected_error_kind( + "custom_openai API error (400): Model 'claude-opus-4-7' is not available. \ + Use GET /openai/v1/models to list available models." + ), + Some(ExpectedErrorKind::ProviderConfigRejection) + ); +} + +#[test] +fn does_not_classify_unrelated_provider_failures_as_config_rejection() { + // Inverted polarity / scope guard: a 5xx or a generic 4xx with no + // config-rejection body must still reach Sentry as actionable. + // (The OpenHuman backend never emits these phrases, so the + // message-level predicate is intrinsically custom-provider scoped; + // the HTTP-layer twin enforces the non-backend guard explicitly.) + assert_eq!( + expected_error_kind("custom_openai API error (500): internal server error"), + None + ); + assert_eq!( + expected_error_kind( + "custom_openai API error (400 Bad Request): missing required field 'messages'" + ), + None, + "generic 4xx without a config-rejection body must NOT demote" + ); +} + +#[test] +fn unrelated_missing_required_fields_classifies_as_accepted_false_positive() { + // Documents the breadth of the `"missing required fields"` arm — + // unlike the trigger/toolkit arms it has no second anchor, so a + // non-composio call site whose error happens to contain the phrase + // will also demote. This is the accepted false-positive surface + // per the classifier doc-comment (every current emit site is + // scoped to composio/integrations envelopes, so a stray collision + // would have to come from a brand-new opt-in call site). + // + // Pinning this assertion locks the breadth in so a future + // narrowing of the matcher surfaces here instead of silently + // re-bucketing the demote path. + assert_eq!( + expected_error_kind("Internal error: missing required fields in config"), + Some(ExpectedErrorKind::ProviderUserState), + "accepted false-positive: bare 'missing required fields' demotes by design" + ); +} + +#[test] +fn provider_user_state_takes_precedence_over_backend_user_error() { + // Critical ordering guarantee: a 4xx body that contains the + // toolkit-not-enabled phrasing must land in `ProviderUserState` + // (more specific) — not in the generic `BackendUserError` bucket. + // Without the ordering in `expected_error_kind`, the 4xx matcher + // would win and the operator would see a different breadcrumb + // kind than intended (and miss the `kind="provider_user_state"` + // tag in info logs). + let msg = "Backend returned 400 Bad Request for POST \ + /agent-integrations/composio/execute: \ + Toolkit \"github\" is not enabled"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "4xx + toolkit-not-enabled must land in ProviderUserState, not BackendUserError" + ); +} + +// ── TAURI-RUST-X9 (#1166): composio-direct 401 / Invalid API key ──── + +#[test] +fn classifies_composio_direct_invalid_api_key_as_provider_user_state() { + // Canonical Sentry TAURI-RUST-X9 wire shape — the verbatim title + // body from the issue, captured 15,732 times in ~22h on a single + // user with a bad direct-mode key. The classifier must demote + // this to `ProviderUserState` so the polling layer's 5 s retry + // doesn't keep flooding Sentry. + let msg = "[composio-direct] list_connections failed: \ + Composio v3 connected_accounts failed: \ + HTTP 401: Invalid API key: ak_VsUvq*****"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "composio-direct HTTP 401 + Invalid API key must demote to ProviderUserState" + ); +} + +#[test] +fn classifies_composio_direct_invalid_api_key_for_other_ops() { + // Same arm must cover every op-name the direct branches emit — + // not just `list_connections`. The matcher gates on the + // `[composio-direct]` prefix, not on a specific op string, so + // `list_tools` / `authorize` / `list_connections` all demote. + let shapes = [ + // list_tools prefetch fails before the actual list_tools call + "[composio-direct] list_tools: prefetch connections failed: \ + Composio v3 connected_accounts failed: HTTP 401: Invalid API key: ak_…", + // direct authorize hits the v3 /connected_accounts/link wall + "[composio-direct] authorize failed: \ + Composio v3 connected_accounts/link failed: HTTP 401: Invalid API key: ak_…", + // direct list_tools itself + "[composio-direct] list_tools failed: \ + Composio v3 tools failed: HTTP 401: Invalid API key: ak_…", + // periodic-tick rendering (no "[composio-direct]" prefix because + // periodic.rs wraps differently, but the failure still gets the + // hook — handled by ops.rs's report path, not the + // expected_error_kind body shape, so we only verify the + // composio-direct branch here) + ]; + for msg in shapes { + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "every [composio-direct] op with HTTP 401 / Invalid API key must demote: {msg}" + ); + } +} + +#[test] +fn classifies_composio_direct_with_invalid_api_key_only_no_http_401() { + // The matcher accepts EITHER `HTTP 401` OR `Invalid API key` + // alongside the `[composio-direct]` prefix. Catches the wire + // shape variant where the body anchor lands but the status text + // is rendered differently (e.g. "401 Unauthorized" instead of + // "HTTP 401") — same user-state condition. + let msg = "[composio-direct] list_connections failed: \ + Composio v3 connected_accounts failed: \ + 401 Unauthorized: Invalid API key: ak_…"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "composio-direct + Invalid API key body must demote even without literal 'HTTP 401'" + ); +} + +#[test] +fn does_not_classify_unrelated_http_401_as_composio_direct_user_state() { + // Discrimination test: a generic 401 that does NOT carry the + // `[composio-direct]` prefix must NOT match this arm. This + // protects against the arm accidentally swallowing backend-mode + // composio 401s, unrelated integration 401s, or any other + // 401-containing message that lacks the direct-mode anchor. + // + // The backend-mode shape is `Backend returned 401 …`; it does + // not contain `[composio-direct]`, so the new arm rightly skips + // it. Backend-mode 401s remain a real Sentry signal (bad + // service-to-service auth, expired token, etc.). + let backend_401 = "[composio] list_connections failed: \ + Backend returned 401 Unauthorized for GET \ + https://api.tinyhumans.ai/agent-integrations/composio/connections: \ + Invalid API key"; + assert_ne!( + expected_error_kind(backend_401), + Some(ExpectedErrorKind::ProviderUserState), + "backend-mode 401 must NOT demote via the composio-direct arm" + ); + + let unrelated_401 = "GitHub API error: HTTP 401: Bad credentials"; + assert_ne!( + expected_error_kind(unrelated_401), + Some(ExpectedErrorKind::ProviderUserState), + "unrelated 401 (no [composio-direct] anchor) must NOT match the composio-direct arm" + ); +} + +#[test] +fn does_not_classify_composio_direct_500_as_user_state() { + // Real bug shapes — a 500 from the direct v3 path with no auth + // body anchor — must still fall through to `None` so Sentry + // sees them. Without this guard the arm could be too permissive + // and silence genuine backend faults. + let msg = "[composio-direct] list_connections failed: \ + Composio v3 connected_accounts failed: HTTP 500"; + assert_eq!( + expected_error_kind(msg), + None, + "composio-direct 500 with no auth body must NOT demote — it is a real bug shape" + ); +} + +// ── TAURI-RUST-322 (#2929): composio-direct 403 (key missing perms) ─ + +#[test] +fn classifies_composio_direct_403_as_provider_user_state() { + // Canonical Sentry TAURI-RUST-322 wire shape — the verbatim + // title body from the issue (1,021 events, multi-release). The + // Composio v3 `/connected_accounts` endpoint returns HTTP 403 + // when the BYO API key exists but lacks `connected_accounts:read` + // permission. This is a user-state condition; Sentry has no + // remediation path. + let msg = "[composio-direct] list_connections failed: \ + Composio v3 connected_accounts failed: HTTP 403"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "composio-direct HTTP 403 must demote to ProviderUserState (TAURI-RUST-322)" + ); +} + +#[test] +fn classifies_composio_direct_403_for_other_ops() { + // The `[composio-direct]` + `HTTP 403` arm must cover every op + // that can hit a 403 from the Composio v3 tenant (list_tools + // prefetch, authorize, etc.) — not just list_connections. + let shapes = [ + // list_tools prefetch of connections hits the 403 wall + "[composio-direct] list_tools: prefetch connections failed: \ + Composio v3 connected_accounts failed: HTTP 403", + // list_connections itself (the primary source of the leak) + "[composio-direct] list_connections (direct) failed: \ + Composio v3 connected_accounts failed: HTTP 403", + // any future direct-mode op that hits a 403 + "[composio-direct] composio_list_connections (direct) failed: \ + Composio v3 connected_accounts failed: HTTP 403", + ]; + for msg in shapes { + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "every [composio-direct] op with HTTP 403 must demote to ProviderUserState: {msg}" + ); + } +} + +#[test] +fn does_not_classify_unrelated_http_403_as_composio_direct_user_state() { + // Discrimination test: a 403 that does NOT carry the + // `[composio-direct]` prefix must NOT match this arm. Backend-mode + // composio 403s and unrelated 403s must remain visible in Sentry. + let backend_403 = "[composio] list_connections failed: \ + Backend returned 403 Forbidden for GET \ + https://api.tinyhumans.ai/agent-integrations/composio/connections"; + // The backend-mode shape passes through `is_backend_user_error_message` + // (4xx matcher), not this arm. Verify it does NOT match this arm. + assert!( + !lower_contains_composio_direct_auth_wall(backend_403), + "backend-mode 403 must NOT match the composio-direct arm" + ); + + let unrelated_403 = "GitHub API error: HTTP 403: rate limit exceeded"; + assert_ne!( + expected_error_kind(unrelated_403), + Some(ExpectedErrorKind::ProviderUserState), + "unrelated 403 (no [composio-direct] anchor) must NOT match the composio-direct arm" + ); +} + +// Helper used only in the discrimination test above — mirrors the +// exact condition in `is_provider_user_state_message` without +// requiring access to the private function. +fn lower_contains_composio_direct_auth_wall(msg: &str) -> bool { + let lower = msg.to_ascii_lowercase(); + lower.contains("[composio-direct]") + && (lower.contains("http 401") + || lower.contains("http 403") + || lower.contains("invalid api key")) +} + +// ── TAURI-RUST-34H: backend-wrapped Cloudflare anti-bot interstitial ─ + +#[test] +fn classifies_backend_cloudflare_antibot_wrap_as_provider_user_state() { + // Canonical Sentry TAURI-RUST-34H wire shape — the verbatim title + // body from the issue (8,851 events / 14d on self-hosted + // `tauri-rust`). The backend wraps an upstream Cloudflare 403 + // anti-bot challenge as `Backend returned 500 … 403 Just a moment...

Icon for api.tinyhumans.aiapi.tinyhumans.ai

...Powered by Cloudflare..."#; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "backend-wrapped Cloudflare anti-bot interstitial must demote to ProviderUserState" + ); +} + +#[test] +fn classifies_minimal_cloudflare_antibot_body_as_provider_user_state() { + // Strip the wire shape down to just the two anchors — the + // matcher should still fire so future renderings (different + // line breaks, stripped HTML, alternate caller wrappers) still + // demote. + let msg = "Just a moment...\ncloudflare\n"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::ProviderUserState), + "minimal `Just a moment...` + `cloudflare` body must demote" + ); +} + +#[test] +fn does_not_classify_half_anchor_cloudflare_messages_as_user_state() { + // Discrimination test for the double-anchor: either half on its + // own must NOT match. This guards against unrelated bodies that + // happen to use either phrase out of context. + + // Half-anchor 1: `just a moment` without `cloudflare` — e.g. + // a daemon restart spinner blurb. + let half_a = "Just a moment, while we restart the daemon"; + assert_ne!( + expected_error_kind(half_a), + Some(ExpectedErrorKind::ProviderUserState), + "`Just a moment` without `cloudflare` must NOT match the CF anti-bot arm" + ); + + // Half-anchor 2: `cloudflare` without `just a moment...` — e.g. + // a CF Workers footer mention elsewhere. + let half_b = "Powered by Cloudflare"; + assert_ne!( + expected_error_kind(half_b), + Some(ExpectedErrorKind::ProviderUserState), + "`cloudflare` without `Just a moment...` must NOT match the CF anti-bot arm" + ); +} + +#[test] +fn does_not_classify_genuine_backend_500_without_cloudflare_body() { + // Real bug shape — a 500 from the same backend endpoint with no + // Cloudflare interstitial body — must still fall through so + // Sentry sees it. Without this guard the arm could be too + // permissive and silence genuine database / handler faults. + let msg = "Backend returned 500 Internal Server Error for GET \ + https://api.tinyhumans.ai/agent-integrations/composio/connections: \ + database connection pool exhausted"; + assert_eq!( + expected_error_kind(msg), + None, + "genuine backend 500 without Cloudflare body must NOT demote — it is a real bug" + ); +} + +#[test] +fn classifies_list_models_404_as_provider_user_state() { + // OPENHUMAN-TAURI-YJ: `inference/provider/ops.rs::list_models` probed + // a custom-provider's `/models` endpoint and the upstream server + // returned 404 because the base URL is wrong / doesn't host a models + // listing. User-config state — the model-dropdown probe already + // surfaces it inline. Pin the verbatim Sentry payload plus a few + // body-shape variants (different upstreams emit different 404 bodies) + // so the path-agnostic prefix anchor stays the source of truth. + for raw in [ + // Verbatim shape from the Sentry event. + r#"provider returned 404: {"error":"path \"/api/v1/models\" not found"}"#, + // FastAPI-style: `{"detail":"Not Found"}`. + r#"provider returned 404: {"detail":"Not Found"}"#, + // Bare HTML — happens when the user pointed at a non-API origin + // (e.g. the provider's docs site). + "provider returned 404: Not Found", + // After `truncate_with_ellipsis(.., 300)` clips a longer body — + // prefix anchor must still match. + r#"provider returned 404: {"error":{"message":"The requested URL /api/v1/models was not found on this server. Please check the URL or co…"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ProviderUserState), + "OPENHUMAN-TAURI-YJ list_models 404 must classify as ProviderUserState: {raw}" + ); + } +} + +#[test] +fn does_not_classify_non_404_list_models_failures_as_user_state() { + // Discrimination guard: only the 404 prefix demotes. Sibling 4xx / + // 5xx codes from the same `provider returned NNN:` emit site must + // stay actionable in Sentry — they map to BYO-key auth walls (401 / + // 403), client-shape bugs (400), and transient / server faults + // (429 / 5xx) respectively. Pinning each shape here protects the + // #2286 BYO-key 401 contract and prevents the arm from silently + // widening to all 4xx. + for raw in [ + // BYO-key auth wall — must still escalate (`does_not_classify_byo_key_provider_401_as_session_expired` sibling guard). + r#"provider returned 401: {"error":"Invalid API key"}"#, + r#"provider returned 403: {"error":"Forbidden: API key revoked"}"#, + // Request-shape mismatch — likely a bug in our client. + r#"provider returned 400: {"error":"Bad Request"}"#, + // Transient — caught by retry/backoff at the provider layer, + // does NOT belong in the user-state bucket. + r#"provider returned 429: {"error":"rate_limited"}"#, + r#"provider returned 503: upstream temporarily unavailable"#, + // 500 — a real upstream bug; must reach Sentry. + r#"provider returned 500: {"error":"internal_server_error"}"#, + ] { + assert_ne!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ProviderUserState), + "non-404 list_models failure must NOT demote to ProviderUserState: {raw}" + ); + } +} + +#[test] +fn classifies_local_ai_binary_missing_errors() { + // OPENHUMAN-TAURI-9N: `local_ai_tts` returns this exact string + // from `service::speech::tts` when piper isn't on PATH or + // `PIPER_BIN` isn't set. + assert_eq!( + expected_error_kind("piper binary not found. Set PIPER_BIN or install piper."), + Some(ExpectedErrorKind::LocalAiBinaryMissing) + ); + // Sibling shapes from the same service area share the anchor and + // must classify the same way — the user-facing remediation is + // identical (install / configure the binary). + assert_eq!( + expected_error_kind( + "whisper.cpp binary not found. Set WHISPER_BIN or install whisper-cli." + ), + Some(ExpectedErrorKind::LocalAiBinaryMissing) + ); + assert_eq!( + expected_error_kind( + "Ollama binary not found at '/usr/local/bin/ollama'. Provide a valid path to the ollama executable." + ), + Some(ExpectedErrorKind::LocalAiBinaryMissing) + ); + assert_eq!( + expected_error_kind("Ollama installed but binary not found on system"), + Some(ExpectedErrorKind::LocalAiBinaryMissing) + ); + // Wrapped by the RPC dispatcher in production: + // `"rpc.invoke_method failed: piper binary not found. …"`. + // The classifier is substring-based, so caller context must not + // defeat it. + assert_eq!( + expected_error_kind( + "rpc.invoke_method failed: piper binary not found. Set PIPER_BIN or install piper." + ), + Some(ExpectedErrorKind::LocalAiBinaryMissing) + ); +} + +#[test] +fn does_not_classify_unrelated_messages_as_binary_missing() { + // Pin the anchor: messages that talk about binaries in a + // different context (download failures, version mismatches) + // must not be silenced. + assert_eq!( + expected_error_kind("piper binary failed to spawn: permission denied"), + None + ); + assert_eq!( + expected_error_kind("whisper.cpp returned empty transcript"), + None + ); +} + +#[test] +fn classifies_session_expired_messages() { + // OPENHUMAN-TAURI-26: the canonical wire shape that `agent.run_single` + // and `web_channel.run_chat_task` re-emit via `report_error_or_expected` + // when the user's JWT expires mid-conversation. The classifier + // anchors on the literal `"session expired"` substring from the + // OpenHuman backend's 401 body — NOT on the bare `(401 Unauthorized)` + // status, which would also silence BYO-key OpenAI/Anthropic 401s + // that are actionable. + assert_eq!( + expected_error_kind( + r#"OpenHuman API error (401 Unauthorized): {"success":false,"error":"Session expired. Please log in again."}"# + ), + Some(ExpectedErrorKind::SessionExpired) + ); + + // Wrapped by the agent / web-channel report sites in production — + // the classifier is substring-based so caller context must not + // defeat it. + assert_eq!( + expected_error_kind( + r#"run_chat_task failed client_id=abc thread_id=t1 request_id=r1 error=OpenHuman API error (401 Unauthorized): {"success":false,"error":"Session expired. Please log in again."}"# + ), + Some(ExpectedErrorKind::SessionExpired) + ); + + // Sentinel raised by `providers::openhuman_backend::resolve_bearer` + // when the scheduler-gate signed-out override is set + // (OPENHUMAN-TAURI-1T's cascade dampener returns this so callers + // get the same teardown path as a real backend 401). + assert_eq!( + expected_error_kind( + "SESSION_EXPIRED: backend session not active — sign in to resume LLM work" + ), + Some(ExpectedErrorKind::SessionExpired) + ); + + // Local pre-flight guards — OpenHuman-specific phrasing, safe to + // match regardless of caller wrapping. + for raw in [ + "no backend session token; run auth_store_session first", + "session JWT required", + "composio unavailable: no backend session token. Sign in first (auth_store_session).", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::SessionExpired), + "should classify as session-expired: {raw}" + ); + } +} + +/// OPENHUMAN-TAURI-SG (33 events, escalating, release `0.53.43+2b64ea8…`): +/// pre-#1763 leak of the `resolve_bearer` sentinel through +/// `agent.run_single`. PR #1763 (1fb0bef5) wired the `SessionExpired` +/// arm and the existing `classifies_session_expired_messages` test +/// covers the same byte string — this test pins the *Sentry-event +/// verbatim* shape (taken from the OPENHUMAN-TAURI-SG event payload) +/// so a future tweak to `is_session_expired_message` cannot regress +/// this exact wire form without a red test. +#[test] +fn session_expired_sg_wire_shape_matches() { + let msg = "SESSION_EXPIRED: backend session not active — sign in to resume LLM work"; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::SessionExpired), + "OPENHUMAN-TAURI-SG wire shape must classify as SessionExpired — \ + a regression here re-leaks 33+ events/cycle to Sentry" + ); +} + +/// The two sibling `SESSION_EXPIRED:` bail sites in +/// `providers::factory::verify_session_active` emit different message +/// suffixes but the same sentinel prefix. They route through the same +/// classifier as the run_single bail at +/// `providers::openhuman_backend::resolve_bearer`, and any matcher +/// tweak that breaks the family (e.g. moving from `contains` to a +/// stricter prefix/suffix match) would re-leak ALL of them. Pin every +/// variant the codebase actually emits so a future regression on the +/// matcher is caught for the whole family, not just the SG instance. +#[test] +fn session_expired_sibling_family_factory_strings_match() { + // src/openhuman/inference/provider/factory.rs:247 + // (verify_session_active — scheduler_gate signed-out path) + let custom_providers_variant = + "SESSION_EXPIRED: backend session not active — sign in to use custom providers"; + // src/openhuman/inference/provider/factory.rs:266 + // (verify_session_active — empty auth-profile JWT path) + let no_backend_session_variant = + "SESSION_EXPIRED: no backend session — sign in to use OpenHuman"; + + for raw in [custom_providers_variant, no_backend_session_variant] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::SessionExpired), + "factory.rs sibling sentinel must classify as SessionExpired: {raw}" + ); + } +} + +/// OPENHUMAN-TAURI-4P0: the OpenHuman backend rejects an expired/ +/// revoked JWT with the envelope `{"success":false,"error":"Invalid +/// token"}` (vs. the explicit `"Session expired. Please log in again."` +/// body covered by `classifies_session_expired_messages`). Same emit +/// site, same wrapping by `web_channel.run_chat_task`, but the body +/// substring is different. +/// +/// The matcher uses a conjunctive `"OpenHuman API error (401"` + +/// envelope-shaped `"\"error\":\"Invalid token\""` anchor pair so the +/// #2286 contract for bare `"Invalid token"` / BYO-key 401s is +/// preserved — `does_not_classify_byo_key_provider_401_as_session_expired` +/// pins that and must stay green. +#[test] +fn classifies_openhuman_invalid_token_401_as_session_expired() { + // Verbatim wire shape from the OPENHUMAN-TAURI-4P0 event payload. + let msg = r#"run_chat_task failed client_id=lssXhQidBfzGXG9k thread_id=thread-743193ba-f0c1-4008-b665-64d3030d1453 request_id=00696b71-fa05-4574-bcdb-5744a5dac6ea error=OpenHuman API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::SessionExpired), + "OPENHUMAN-TAURI-4P0 verbatim wire shape must classify as SessionExpired" + ); + + // Unwrapped emit shape (without the run_chat_task prefix) — also + // appears at provider/agent layers; the substring matcher must + // catch it regardless of caller wrapping. + assert_eq!( + expected_error_kind( + r#"OpenHuman API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"# + ), + Some(ExpectedErrorKind::SessionExpired), + "unwrapped OpenHuman invalid-token envelope must classify as SessionExpired" + ); +} + +/// TAURI-RUST-4K5 (118 events, escalating on 0.56.0): the embedding +/// client at `src/openhuman/embeddings/openai.rs:139` wraps the same +/// OpenHuman backend `{"success":false,"error":"Invalid token"}` 401 +/// envelope as 4P0, but with the `"Embedding API error"` prefix +/// instead of `"OpenHuman API error"` (different emit-site format +/// string, same underlying session-expired cause — see breadcrumb +/// `[scheduler_gate] signed_out false -> true` immediately preceding +/// the 401 in the event payload). +/// +/// Uses the same conjunctive `" (401"` + envelope-shaped +/// `"\"error\":\"Invalid token\""` anchor pattern as 4P0 so the +/// #2286 / BYO-key contract is preserved — covered by +/// `does_not_classify_byo_key_provider_401_as_session_expired` and +/// `does_not_classify_embedding_byo_key_401_as_session_expired` +/// (below). +#[test] +fn classifies_embedding_api_invalid_token_401_as_session_expired() { + // Verbatim wire shape from the TAURI-RUST-4K5 event payload (Sentry + // issue 5230, latest event 2026-05-27 20:49 on openhuman@0.56.0, + // domain=embeddings operation=openai_embed status=401). + let msg = + r#"Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::SessionExpired), + "TAURI-RUST-4K5 verbatim wire shape must classify as SessionExpired" + ); + + // The substring matcher must survive caller wrapping the same way + // the 4P0 web-channel `run_chat_task` test wraps the body — callers + // that re-emit through a tracing field or another layer prepend + // arbitrary context. + let wrapped = r#"openai_embed failed error=Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; + assert_eq!( + expected_error_kind(wrapped), + Some(ExpectedErrorKind::SessionExpired), + "wrapped 4K5 envelope must still classify as SessionExpired" + ); +} + +/// TAURI-RUST-1EE (Sentry issue 1807, 110 events, 109 on +/// openhuman@0.56.0): the streaming-chat path wraps the same OpenHuman +/// backend `{"success":false,"error":"Invalid token"}` 401 envelope +/// with the `"OpenHuman streaming API error"` prefix (emitted at +/// `inference/provider/compatible.rs:949`) — distinct from the +/// non-streaming `"OpenHuman API error"` prefix (4P0) and the +/// `"Embedding API error"` prefix (4K5). The `streaming` token between +/// `OpenHuman` and `API error` means the 4P0 anchor +/// (`"OpenHuman API error (401"`) does not match it, so it needs its +/// own prefix arm. +#[test] +fn classifies_openhuman_streaming_invalid_token_401_as_session_expired() { + // Verbatim wire shape from the TAURI-RUST-1EE event payload + // (domain=llm_provider operation=streaming_chat status=401 + // provider=OpenHuman model=reasoning-v1). + let msg = r#"OpenHuman streaming API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::SessionExpired), + "TAURI-RUST-1EE verbatim streaming wire shape must classify as SessionExpired" + ); + + // Caller-wrapped (agent.run_single / web_channel.run_chat_task + // re-emit prepends context) must still classify. + let wrapped = r#"run_chat_task failed error=OpenHuman streaming API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#; + assert_eq!( + expected_error_kind(wrapped), + Some(ExpectedErrorKind::SessionExpired), + "wrapped 1EE streaming envelope must still classify as SessionExpired" + ); +} + +/// Polarity guard for the 1EE streaming arm — a third-party BYO-key +/// provider's streaming 401 (`"OpenAI streaming API error (401 …): +/// invalid_api_key"`) must STILL reach Sentry as actionable +/// misconfiguration. The `"OpenHuman streaming API error (401"` prefix +/// gate keeps the match OpenHuman-scoped. +#[test] +fn does_not_classify_streaming_byo_key_401_as_session_expired() { + for raw in [ + "OpenAI streaming API error (401 Unauthorized): invalid_api_key", + r#"OpenAI streaming API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#, + "Anthropic streaming API error (401): authentication_error", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "BYO-key streaming 401 must reach Sentry as actionable error: {raw}" + ); + } +} + +/// Polarity guard for the 4K5 arm. The classifier must NOT swallow +/// `"Embedding API error (401 …)"` shapes from third-party BYO-key +/// embedding providers (OpenAI / Voyage / Cohere upstream rejecting +/// the user's own API key). Those are actionable user-config errors +/// that need to reach Sentry — same contract as +/// `does_not_classify_byo_key_provider_401_as_session_expired` for +/// the OpenAI chat API. +#[test] +fn does_not_classify_embedding_byo_key_401_as_session_expired() { + for raw in [ + "Embedding API error (401 Unauthorized): invalid_api_key", + r#"Embedding API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#, + // Wire shape without the OpenHuman envelope — bare provider + // rejection prose. Must reach Sentry as actionable BYO-key + // misconfiguration. + "Embedding API error (401): authentication_error", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "BYO-key embedding 401 must reach Sentry as actionable error: {raw}" + ); + } +} + +#[test] +fn does_not_classify_byo_key_provider_401_as_session_expired() { + // Critical: a BYO-key 401 from OpenAI / Anthropic etc. is an + // actionable misconfiguration (wrong API key) that the user needs + // to fix in settings. It must reach Sentry as an error and must + // NOT be classified as session-expired at the agent layer — the + // strict classifier requires the OpenHuman backend's + // "session expired" body to anchor the match. The JSON-RPC + // dispatch-site classifier uses the same strict rule so these + // scoped provider failures never clear the app session either. + for raw in [ + "OpenAI API error (401 Unauthorized): invalid_api_key", + "Anthropic API error (401 Unauthorized): authentication_error", + "OpenAI API error (401): unauthorized", + r#"OpenAI API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#, + // Generic "invalid token" without OpenHuman session phrasing — + // could mean a third-party provider rejected its own token. + "Invalid token", + "got an invalid token here", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "BYO-key / generic 401 must reach Sentry as actionable error: {raw}" + ); + } +} + +#[test] +fn does_not_classify_unrelated_messages_as_session_expired() { + // Bare numeric 401 (port number, runbook reference) must not be + // silenced. + assert_eq!(expected_error_kind("server returned 401"), None); + assert_eq!( + expected_error_kind("see runbook for 401 handling at https://example.com/401"), + None + ); + // Provider 5xx — must reach Sentry. + assert_eq!( + expected_error_kind("OpenAI API error (500): internal server error"), + None + ); + // Lowercase sentinel must NOT match — the SESSION_EXPIRED sentinel + // is case-sensitive by design (matches the sentinel emitted by + // `providers::openhuman_backend::resolve_bearer` exactly). + assert_eq!(expected_error_kind("session_expired lowercase"), None); +} + +/// TAURI-RUST-E (~1 437 events): billing poll fires `report_error_or_expected` +/// on every refresh cycle once the user's JWT lapses because the +/// `BackendApiError::Unauthorized` typed error was stringified to +/// `"backend rejected session token on GET /payments/stripe/currentPlan"` by +/// `billing::ops::get_authed_value(..).map_err(|e| e.to_string())` before the +/// phrase was added to `is_session_expired_message`. +/// +/// The phrase `"backend rejected session token"` is uniquely produced by +/// `BackendApiError::Unauthorized`'s `Display` impl in `api::rest` — no +/// third-party provider path emits it — so no conjunctive guard is needed. +#[test] +fn classifies_billing_401_as_session_expired() { + // Exact wire shape from `billing_get_current_plan` — the most common + // event in TAURI-RUST-E. + assert_eq!( + expected_error_kind("backend rejected session token on GET /payments/stripe/currentPlan"), + Some(ExpectedErrorKind::SessionExpired), + "TAURI-RUST-E: billing_get_current_plan 401 must classify as SessionExpired" + ); + + // Other billing methods share the same `BackendApiError::Unauthorized` + // display shape — pin them so a wording change in `rest.rs` would catch + // every billing call site. + for path in [ + "/payments/credits/balance", + "/payments/credits/transactions?limit=20&offset=0", + "/payments/credits/auto-recharge", + "/payments/credits/auto-recharge/cards", + "/payments/stripe/purchasePlan", + "/payments/stripe/portal", + "/coupons/me", + ] { + let msg = format!("backend rejected session token on GET {path}"); + assert_eq!( + expected_error_kind(&msg), + Some(ExpectedErrorKind::SessionExpired), + "billing 401 must classify as SessionExpired: {msg}" + ); + } + + // POST / PATCH / DELETE variants are also produced by `authed_json`. + for raw in [ + "backend rejected session token on POST /payments/credits/top-up", + "backend rejected session token on PATCH /payments/credits/auto-recharge", + "backend rejected session token on DELETE /payments/credits/auto-recharge/cards/pm_123", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::SessionExpired), + "TAURI-RUST-E variant must classify as SessionExpired: {raw}" + ); + } +} + +/// `"backend rejected session token"` is scoped to `BackendApiError::Unauthorized` +/// in `api::rest`. Ensure unrelated messages containing the individual +/// words don't accidentally match. +#[test] +fn does_not_classify_unrelated_rejected_messages_as_session_expired() { + // Third-party provider errors that mention a token being rejected but + // do not contain the exact OpenHuman `BackendApiError::Unauthorized` + // display phrase. + for raw in [ + "Discord API error: token rejected by upstream", + "Stripe webhook signature rejected — bad secret", + "API token rejected: please regenerate", + ] { + assert_eq!( + expected_error_kind(raw), + None, + "unrelated token-rejected message must NOT suppress Sentry: {raw}" + ); + } +} + +#[test] +fn report_error_does_not_panic_with_many_tags() { + let err = anyhow::anyhow!("multi-tag"); + report_error( + &err, + "test", + "multi_tag", + &[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")], + ); +} + +fn event_with_tags(pairs: &[(&str, &str)]) -> sentry::protocol::Event<'static> { + let mut event = sentry::protocol::Event::default(); + let mut tags: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for (k, v) in pairs { + tags.insert((*k).to_string(), (*v).to_string()); + } + event.tags = tags; + event +} + +fn event_with_tags_and_message( + pairs: &[(&str, &str)], + message: &str, +) -> sentry::protocol::Event<'static> { + let mut event = event_with_tags(pairs); + event.message = Some(message.to_string()); + event +} + +#[test] +fn transient_filter_drops_429_408_502_503_504() { + for status in ["429", "408", "502", "503", "504"] { + let event = event_with_tags(&[ + ("domain", "llm_provider"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + is_transient_provider_http_failure(&event), + "status {status} must be classified as transient and filtered" + ); + } +} + +#[test] +fn transient_filter_keeps_permanent_failures() { + for status in ["400", "401", "403", "404", "500"] { + let event = event_with_tags(&[ + ("domain", "llm_provider"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + !is_transient_provider_http_failure(&event), + "status {status} must NOT be filtered — it's actionable" + ); + } +} + +#[test] +fn transient_filter_keeps_aggregate_all_exhausted() { + let event = event_with_tags(&[ + ("domain", "llm_provider"), + ("failure", "all_exhausted"), + ("status", "503"), + ]); + assert!( + !is_transient_provider_http_failure(&event), + "aggregate all_exhausted events must surface (they are the cascade signal)" + ); +} + +#[test] +fn transient_filter_keeps_events_with_no_status_tag() { + let event = event_with_tags(&[("domain", "llm_provider"), ("failure", "non_2xx")]); + assert!( + !is_transient_provider_http_failure(&event), + "missing status tag must not be silently dropped" + ); +} + +// Regression guard: the filter must scope to provider events only. Other +// subsystems emit `failure=non_2xx` (e.g. +// `providers/compatible.rs` uses the same marker for OAI-compatible +// error paths, but every site goes through `report_error(.., +// "llm_provider", ..)` so the domain tag is consistent), but the broader +// point is: any future caller that re-uses the same tag set for a +// different domain must NOT be silently dropped by this filter. +#[test] +fn transient_filter_keeps_events_with_no_domain_tag() { + let event = event_with_tags(&[("failure", "non_2xx"), ("status", "503")]); + assert!( + !is_transient_provider_http_failure(&event), + "missing domain tag means the event isn't provider-originated — must surface" + ); +} + +#[test] +fn transient_filter_keeps_events_from_other_domains() { + let event = event_with_tags(&[ + ("domain", "scheduler"), + ("failure", "non_2xx"), + ("status", "503"), + ]); + assert!( + !is_transient_provider_http_failure(&event), + "non-provider domain must surface even if failure/status tags collide" + ); +} + +#[test] +fn backend_api_filter_drops_transient_statuses() { + for status in TRANSIENT_HTTP_STATUSES { + let event = event_with_tags(&[ + ("domain", "backend_api"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + is_transient_backend_api_failure(&event), + "backend status {status} must be classified as transient" + ); + } +} + +#[test] +fn backend_api_filter_drops_transient_transport_phrases() { + for phrase in TRANSIENT_TRANSPORT_PHRASES { + let event = event_with_tags_and_message( + &[("domain", "backend_api"), ("failure", "transport")], + &format!("GET /teams failed: {phrase}"), + ); + assert!( + is_transient_backend_api_failure(&event), + "backend transport phrase {phrase} must be classified as transient" + ); + } +} + +#[test] +fn backend_api_filter_keeps_non_transient_failures() { + for status in ["404", "500"] { + let event = event_with_tags(&[ + ("domain", "backend_api"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + !is_transient_backend_api_failure(&event), + "backend status {status} must stay visible" + ); + } + + let wrong_domain = event_with_tags(&[ + ("domain", "scheduler"), + ("failure", "non_2xx"), + ("status", "503"), + ]); + assert!( + !is_transient_backend_api_failure(&wrong_domain), + "domain scoping must keep unrelated transient-shaped events visible" + ); + + let non_matching_transport = event_with_tags_and_message( + &[("domain", "backend_api"), ("failure", "transport")], + "GET /teams failed: certificate verify failed", + ); + assert!( + !is_transient_backend_api_failure(&non_matching_transport), + "transport failures without an allowlisted phrase must stay visible" + ); +} + +#[test] +fn integrations_filter_drops_transient_statuses() { + for status in TRANSIENT_HTTP_STATUSES { + let event = event_with_tags(&[ + ("domain", "integrations"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + is_transient_integrations_failure(&event), + "integrations status {status} must be classified as transient" + ); + } +} + +#[test] +fn integrations_filter_drops_transient_transport_phrases() { + for phrase in TRANSIENT_TRANSPORT_PHRASES { + let event = event_with_tags_and_message( + &[("domain", "integrations"), ("failure", "transport")], + &format!("GET /agent-integrations/tools failed: {phrase}"), + ); + assert!( + is_transient_integrations_failure(&event), + "integrations transport phrase {phrase} must be classified as transient" + ); + } +} + +#[test] +fn integrations_filter_keeps_non_transient_failures() { + for status in ["404", "500"] { + let event = event_with_tags(&[ + ("domain", "integrations"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + !is_transient_integrations_failure(&event), + "integrations status {status} must stay visible" + ); + } + + // Sibling-domain check: composio op-layer events MUST be silenced + // by the integrations filter — composio routes through the same + // `IntegrationClient` so the failure shape is identical, but + // op-level reporters that wrap and re-emit with their own domain + // tag would otherwise escape (OPENHUMAN-TAURI-35 / -2H). + let scheduler_domain = event_with_tags(&[ + ("domain", "scheduler"), + ("failure", "non_2xx"), + ("status", "503"), + ]); + assert!( + !is_transient_integrations_failure(&scheduler_domain), + "domain scoping must keep unrelated transient-shaped events visible" + ); + + let non_matching_transport = event_with_tags_and_message( + &[("domain", "integrations"), ("failure", "transport")], + "GET /agent-integrations/tools failed: invalid certificate", + ); + assert!( + !is_transient_integrations_failure(&non_matching_transport), + "transport failures without an allowlisted phrase must stay visible" + ); +} + +#[test] +fn composio_domain_routes_through_integrations_filter() { + // OPENHUMAN-TAURI-35 (~139 events) / -2H (~26 events): + // `[composio] list_connections failed: Backend returned 502 …` — + // composio op-layer wrappers (e.g. `composio_list_connections`) emit + // errors under `domain="composio"` so the original + // `domain="integrations"` filter let them through. Routing the + // composio domain through the same transient classifier closes + // that gap; the underlying transport / non_2xx semantics are + // identical because both layers share the same `IntegrationClient`. + for status in TRANSIENT_HTTP_STATUSES { + let event = event_with_tags(&[ + ("domain", "composio"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + is_transient_integrations_failure(&event), + "composio status {status} must be classified as transient" + ); + } + + // Transport-phrase variant — composio also surfaces reqwest + // transport failures (timeouts, connection resets) once the op + // wrapper has tagged the event with `failure=transport`. + for phrase in TRANSIENT_TRANSPORT_PHRASES { + let event = event_with_tags_and_message( + &[("domain", "composio"), ("failure", "transport")], + &format!("[composio] execute failed: {phrase}"), + ); + assert!( + is_transient_integrations_failure(&event), + "composio transport phrase {phrase} must be classified as transient" + ); + } + + // Non-transient composio statuses (404 / 500) must still surface — + // actionable bugs even when reported under the composio domain. + for status in ["404", "500"] { + let event = event_with_tags(&[ + ("domain", "composio"), + ("failure", "non_2xx"), + ("status", status), + ]); + assert!( + !is_transient_integrations_failure(&event), + "composio status {status} must stay visible" + ); + } +} + +#[test] +fn updater_transient_403_is_dropped() { + let event = event_with_tags_and_message( + &[ + ("domain", "update"), + ("operation", "check_releases"), + ("failure", "non_2xx"), + ("status", "403"), + ], + "[observability] update.check_releases failed: GitHub API error: 403 Forbidden", + ); + assert!( + is_updater_transient_event(&event), + "GitHub 403 updater checks are unactionable transient/rate-limit noise" + ); +} + +#[test] +fn updater_transient_502_is_dropped() { + let event = event_with_tags_and_message( + &[ + ("domain", "update.check_releases"), + ("failure", "non_2xx"), + ("status", "502"), + ], + "GitHub API error: 502 Bad Gateway", + ); + assert!( + is_updater_transient_event(&event), + "GitHub 5xx updater checks must be filtered as transient" + ); +} + +#[test] +fn updater_real_panic_still_reported() { + let event = event_with_tags_and_message( + &[("domain", "update"), ("operation", "check_releases")], + "thread 'main' panicked at src/openhuman/update/core.rs: index out of bounds", + ); + assert!( + !is_updater_transient_event(&event), + "update-domain events without a transient updater shape must still reach Sentry" + ); +} + +#[test] +fn updater_endpoint_non_success_message_is_dropped() { + // TAURI-RUST-CD (~151 events / 9 days, Windows): `tauri-plugin-updater` + // logs `update endpoint did not respond with a successful status code` + // (updater.rs) on any non-2xx response and discards the status, so the + // captured event has NO `domain`/`status` tag — only the bare message. + // It can therefore only be matched via the message fast-path. + assert!(is_updater_transient_message( + "update endpoint did not respond with a successful status code" + )); + + let event = event_with_tags_and_message( + &[], + "update endpoint did not respond with a successful status code", + ); + assert!( + is_updater_transient_event(&event), + "the plugin's status-blind, domain-less non-success log line is unactionable updater noise" + ); +} + +#[test] +fn updater_endpoint_non_success_anchor_does_not_silence_unrelated_errors() { + // The new anchor is the literal plugin string. Other updater failures + // that DO carry an actionable signal (signature/permission failures on + // apply, deserialize errors) and unrelated non-updater errors that + // merely mention a status code MUST NOT be dropped by it. Pin the + // rejection contract so a future refactor doesn't loosen the substring. + for msg in [ + "failed to apply update: signature verification failed", + "failed to deserialize update response: missing field `version`", + "backend request to /agent-integrations failed with status code 500", + "tool exited with non-zero status code 1", + ] { + let event = event_with_tags_and_message(&[], msg); + assert!( + !is_updater_transient_event(&event), + "unrelated/actionable error must still reach Sentry: {msg}" + ); + } +} + +#[test] +fn message_failure_classifier_matches_canonical_status_phrases() { + for msg in [ + "rpc.invoke_method failed: GET /teams failed (502 Bad Gateway)", + "GET /teams/me/usage failed (503 Service Unavailable)", + "downstream returned (504 Gateway Timeout): retry budget exhausted", + "OpenHuman API error (520 ): cf", + "POST /channels/telegram/typing failed (429 Too Many Requests)", + "auth connect failed: 503 Service Unavailable", + ] { + assert!( + is_transient_message_failure(msg), + "{msg:?} must be classified as transient" + ); + } +} + +#[test] +fn message_failure_classifier_matches_transport_phrases() { + for msg in [ + "integrations.get failed: composio/tools → operation timed out", + "GET https://api.example.com → connection forcibly closed (os 10054)", + "POST /v1/foo → tls handshake eof", + "error sending request for url (https://api.example.com)", + ] { + assert!( + is_transient_message_failure(msg), + "{msg:?} must be classified as transient" + ); + } +} + +#[test] +fn message_failure_classifier_keeps_unrelated_messages() { + for msg in [ + "rpc.invoke_method failed: schema validation error", + "process 502 exited unexpectedly", + "GET /teams failed (404 Not Found)", + "GET /teams failed (500 Internal Server Error)", + "unrelated error with port 5023", + "", + ] { + assert!( + !is_transient_message_failure(msg), + "{msg:?} must not be classified as transient" + ); + } +} + +#[test] +fn budget_filter_drops_budget_message_on_tagged_400() { + let event = event_with_tags_and_message( + &[("failure", "non_2xx"), ("status", "400")], + r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#, + ); + + assert!(is_budget_event(&event)); +} + +#[test] +fn budget_filter_drops_budget_exception_on_tagged_400() { + let mut event = event_with_tags(&[("failure", "non_2xx"), ("status", "400")]); + event.exception.values.push(sentry::protocol::Exception { + value: Some("Budget exceeded — add credits to continue".to_string()), + ..Default::default() + }); + + assert!(is_budget_event(&event)); +} + +#[test] +fn budget_filter_keeps_non_budget_400() { + let event = event_with_tags_and_message( + &[("failure", "non_2xx"), ("status", "400")], + "Bad request: missing field", + ); + + assert!(!is_budget_event(&event)); +} + +#[test] +fn budget_filter_requires_non_2xx_failure_and_400_status_for_loose_phrases() { + // Tier 1 requires both tags for the loose budget phrases ("budget exceeded", + // "add credits", …) that might coincidentally appear in unrelated product + // copy. Tier 2 (text-only) only fires for the exact backend phrase + // "insufficient budget" — so the loose phrases still need both tags. + let message = "Budget exceeded — add credits to continue"; + for tags in [ + vec![("failure", "transport"), ("status", "400")], + vec![("failure", "non_2xx"), ("status", "500")], + vec![("failure", "non_2xx")], + ] { + let event = event_with_tags_and_message(&tags, message); + assert!(!is_budget_event(&event)); + } +} + +/// Tier-2 defense-in-depth: drop the exact "Insufficient budget" phrase +/// the OpenHuman backend returns regardless of which tags are set. +/// Regression guard for OPENHUMAN-CORE-N / TAURI-RUST-1P (GitHub #2935). +#[test] +fn budget_filter_drops_insufficient_budget_without_tags() { + // The exact JSON wire body from the OpenHuman backend. + let message = + r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#; + + // No tags at all. + let event = event_with_message(message); + assert!( + is_budget_event(&event), + "tier-2 must drop 'Insufficient budget' even without failure/status tags" + ); + + // Wrong status tag only. + let event = event_with_tags_and_message(&[("status", "400")], message); + assert!( + is_budget_event(&event), + "tier-2 must drop 'Insufficient budget' when only status tag is set" + ); + + // Wrong failure tag only. + let event = event_with_tags_and_message(&[("failure", "transport")], message); + assert!( + is_budget_event(&event), + "tier-2 must drop 'Insufficient budget' when failure tag doesn't match" + ); +} + +/// Regression guard: tier-2 must also catch the exception/tracing path +/// (`sentry-tracing` with `attach_stacktrace=true` may populate the +/// exception list rather than `event.message`). +#[test] +fn budget_filter_drops_insufficient_budget_exception_without_tags() { + // Exact wire body from OpenHuman backend wrapped in an exception value. + let event = event_with_exception_value( + r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"INSUFFICIENT BUDGET"}"#, + ); + assert!( + is_budget_event(&event), + "tier-2 must drop exception-path 'Insufficient budget' case-insensitively" + ); + + // Mixed case variant. + let event = event_with_exception_value( + r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient Budget"}"#, + ); + assert!( + is_budget_event(&event), + "tier-2 must drop exception-path 'Insufficient Budget' case-insensitively" + ); + + // Wrong failure/status tags should not prevent tier-2 from matching. + let mut event = event_with_exception_value( + r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#, + ); + event + .tags + .insert("failure".to_string(), "transport".to_string()); + assert!( + is_budget_event(&event), + "tier-2 must drop exception-path even when failure tag does not match" + ); + + // Unrelated exception values must not match. + let event = event_with_exception_value("retry budget exhausted after 3 attempts"); + assert!( + !is_budget_event(&event), + "tier-2 must not match unrelated exception value" + ); +} + +/// Tier-2 only matches the tight phrase — unrelated 400 errors with "budget" +/// in an unrelated context should not be silently dropped. +#[test] +fn budget_filter_tier2_does_not_match_unrelated_messages() { + for msg in [ + "retry budget exhausted after 3 attempts", + "bad request: missing field", + "budget_id=42 not found", + "", + ] { + let event = event_with_message(msg); + assert!( + !is_budget_event(&event), + "tier-2 must not match unrelated message: {msg:?}" + ); + } +} + +/// CORE-RUST-EK (~827 events): every 401 from the embeddings call path +/// (`domain=embeddings`, `failure=non_2xx`, `status=401`) must be filtered +/// before it reaches Sentry. Covers both the OpenHuman-backend "Invalid +/// token" shape (already handled by the primary `is_session_expired_message` +/// classifier) and third-party provider body shapes (OpenAI +/// `invalid_api_key`, plain `Unauthorized`) that fall through the string +/// classifier. +#[test] +fn embeddings_401_filter_drops_domain_embeddings_status_401() { + // Canonical CORE-RUST-EK wire shape: OpenAI `text-embedding-3-large` + // key is stale. `report_error_or_expected` sets + // domain=embeddings / operation=openai_embed / failure=non_2xx / + // status=401 / model=text-embedding-3-large. + let event = event_with_tags_and_message( + &[ + ("domain", "embeddings"), + ("operation", "openai_embed"), + ("failure", "non_2xx"), + ("status", "401"), + ("model", "text-embedding-3-large"), + ], + r#"Embedding API error (401 Unauthorized): {"error":{"message":"Incorrect API key provided. You can find your API key at https://platform.openai.com/account/api-keys.","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}"#, + ); + assert!( + is_embeddings_api_key_401_event(&event), + "CORE-RUST-EK: domain=embeddings status=401 must be filtered" + ); +} + +/// Any other embedding status (e.g. 429 rate-limit, 500 server error) +/// must not be filtered by the embeddings-401 guard — those have their +/// own handlers (rate-limit filter, general error reporting). +#[test] +fn embeddings_401_filter_passes_other_statuses() { + for status in ["429", "500", "400"] { + let event = event_with_tags_and_message( + &[ + ("domain", "embeddings"), + ("failure", "non_2xx"), + ("status", status), + ], + "Embedding API error", + ); + assert!( + !is_embeddings_api_key_401_event(&event), + "domain=embeddings status={status} must NOT be filtered by the 401 guard" + ); + } +} + +/// Non-embeddings domains must not be filtered even if status=401 — the +/// guard is scoped specifically to `domain=embeddings` so that provider-chat +/// and backend-API 401s remain subject to their own classifiers. +#[test] +fn embeddings_401_filter_passes_non_embeddings_domains() { + for domain in ["llm_provider", "backend_api", "rpc", "composio"] { + let event = event_with_tags_and_message( + &[ + ("domain", domain), + ("failure", "non_2xx"), + ("status", "401"), + ], + "API error (401 Unauthorized): some body", + ); + assert!( + !is_embeddings_api_key_401_event(&event), + "domain={domain} status=401 must NOT be swallowed by the embeddings-401 guard" + ); + } +} + +/// The guard requires both `failure=non_2xx` and `status=401` to be +/// present; missing either tag must cause the filter to pass the event +/// through. +#[test] +fn embeddings_401_filter_requires_failure_and_status_tags() { + let no_failure_tag = event_with_tags_and_message( + &[("domain", "embeddings"), ("status", "401")], + "Embedding API error (401 Unauthorized): unauthorized", + ); + assert!( + !is_embeddings_api_key_401_event(&no_failure_tag), + "missing failure tag must not trigger the guard" + ); + + let no_status_tag = event_with_tags_and_message( + &[("domain", "embeddings"), ("failure", "non_2xx")], + "Embedding API error (401 Unauthorized): unauthorized", + ); + assert!( + !is_embeddings_api_key_401_event(&no_status_tag), + "missing status tag must not trigger the guard" + ); +} + +#[test] +fn report_error_or_expected_does_not_panic() { + report_error_or_expected( + "local ai is disabled", + "rpc", + "invoke_method", + &[("method", "openhuman.inference_prompt")], + ); + report_error_or_expected( + "ollama API key not set", + "agent", + "provider_chat", + &[("provider", "ollama")], + ); + // #2079 / #2076 / #2202 — exercises the expected_error_kind + // ProviderConfigRejection branch AND the report_expected_message + // skip-log arm (the agent/web-channel re-report demotion path). + report_error_or_expected( + "agent.run_single failed: custom_openai API error (400 Bad Request): \ + The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \ + but you passed reasoning-v1.", + "agent", + "native_chat", + &[("provider", "custom_openai")], + ); + report_error_or_expected( + "custom_openai API error (400): invalid temperature: only 1 is allowed for this model", + "web_channel", + "run_chat_task", + &[("provider", "custom_openai")], + ); +} + +fn event_with_message(msg: &str) -> sentry::protocol::Event<'static> { + let mut event = sentry::protocol::Event::default(); + event.message = Some(msg.to_string()); + event +} + +fn event_with_exception_value(value: &str) -> sentry::protocol::Event<'static> { + let mut event = sentry::protocol::Event::default(); + event.exception = vec![sentry::protocol::Exception { + value: Some(value.to_string()), + ..Default::default() + }] + .into(); + event +} + +#[test] +fn max_iterations_filter_matches_message_path() { + // `report_error_message` calls `sentry::capture_message`, which + // populates `event.message`. The filter must see the canonical + // phrase on that field path. + let event = event_with_message("Agent exceeded maximum tool iterations (8)"); + assert!(is_max_iterations_event(&event)); +} + +#[test] +fn max_iterations_filter_matches_exception_path() { + // sentry-tracing with attach_stacktrace=true populates the + // exception list instead of (or in addition to) `event.message`. + // Filter must still catch the noise. + let event = event_with_exception_value( + "agent.run_single failed: Agent exceeded maximum tool iterations (10)", + ); + assert!(is_max_iterations_event(&event)); +} + +#[test] +fn max_iterations_filter_keeps_unrelated_events() { + assert!(!is_max_iterations_event(&event_with_message( + "provider returned 503" + ))); + assert!(!is_max_iterations_event(&event_with_message(""))); + assert!(!is_max_iterations_event(&sentry::protocol::Event::default())); +} + +// ── is_channel_message_not_found_event (TAURI-R7) ──────────────────────── + +fn channel_message_404_event(method: &str) -> sentry::protocol::Event<'static> { + let mut event = sentry::protocol::Event::default(); + event.tags.insert("domain".into(), "backend_api".into()); + event.tags.insert("failure".into(), "non_2xx".into()); + event.tags.insert("status".into(), "404".into()); + event.tags.insert("method".into(), method.into()); + event.message = Some( + "PATCH /channels/telegram/messages/1103 failed (404); response_body_len=172".to_string(), + ); + event +} + +#[test] +fn channel_message_not_found_filter_matches_patch() { + // Canonical TAURI-R7 shape: PATCH 404 on a channel-message path. + assert!(is_channel_message_not_found_event( + &channel_message_404_event("PATCH") + )); +} + +#[test] +fn channel_message_not_found_filter_matches_delete() { + assert!(is_channel_message_not_found_event( + &channel_message_404_event("DELETE") + )); +} + +#[test] +fn channel_message_not_found_filter_ignores_get_404() { + // GET 404 on a channel-message path is NOT an expected state — must keep Sentry signal. + assert!(!is_channel_message_not_found_event( + &channel_message_404_event("GET") + )); +} + +#[test] +fn channel_message_not_found_filter_ignores_non_channel_path() { + let mut event = channel_message_404_event("PATCH"); + event.message = Some("PATCH /auth/profile failed (404); response_body_len=42".to_string()); + assert!(!is_channel_message_not_found_event(&event)); +} + +#[test] +fn channel_message_not_found_filter_ignores_wrong_status() { + let mut event = channel_message_404_event("PATCH"); + event.tags.insert("status".into(), "403".into()); + assert!(!is_channel_message_not_found_event(&event)); +} + +#[test] +fn channel_message_not_found_filter_ignores_wrong_domain() { + let mut event = channel_message_404_event("PATCH"); + event.tags.insert("domain".into(), "channels".into()); + assert!(!is_channel_message_not_found_event(&event)); +} + +#[test] +fn channel_message_not_found_filter_matches_exception_path() { + // sentry-tracing with attach_stacktrace=true populates exception list. + let mut event = sentry::protocol::Event::default(); + event.tags.insert("domain".into(), "backend_api".into()); + event.tags.insert("failure".into(), "non_2xx".into()); + event.tags.insert("status".into(), "404".into()); + event.tags.insert("method".into(), "PATCH".into()); + event.exception = vec![sentry::protocol::Exception { + value: Some("PATCH /channels/discord/messages/abc failed (404): Not Found".to_string()), + ..Default::default() + }] + .into(); + assert!(is_channel_message_not_found_event(&event)); +} + +// ── LoopbackUnavailable (TAURI-R5, TAURI-R6) ───────────────────────────── + +/// Verbatim body shape from OPENHUMAN-TAURI-R5 (~2.5k events): the +/// `integrations.get` site reaches the embedded core's `127.0.0.1:18474` +/// listener during the boot window and reqwest's source chain renders as +/// `error sending request for url (…) → client error (Connect) → tcp +/// connect error → Connection refused (os error 61)`. +const R5_BODY: &str = "error sending request for url \ + (http://127.0.0.1:18474/agent-integrations/composio/connections) \ + → client error (Connect) → tcp connect error → Connection refused (os error 61)"; + +/// Verbatim body shape from OPENHUMAN-TAURI-R6 (~2.5k events): the same +/// transport failure as R5, re-wrapped one frame up by the composio +/// op-layer and re-emitted at the `rpc.invoke_method` site so it lands in +/// Sentry under `domain=rpc` instead of `domain=integrations`. +const R6_BODY: &str = "[composio] list_connections failed: \ + GET http://127.0.0.1:18474/agent-integrations/composio/connections failed: \ + error sending request for url \ + (http://127.0.0.1:18474/agent-integrations/composio/connections) \ + → client error (Connect) → tcp connect error → Connection refused (os error 61)"; + +#[test] +fn classifies_r5_loopback_connect_refused_as_loopback_unavailable() { + assert_eq!( + expected_error_kind(R5_BODY), + Some(ExpectedErrorKind::LoopbackUnavailable), + "R5 body must classify as LoopbackUnavailable, not the broader NetworkUnreachable bucket" + ); +} + +#[test] +fn classifies_r6_rpc_wrapped_loopback_connect_refused_as_loopback_unavailable() { + assert_eq!( + expected_error_kind(R6_BODY), + Some(ExpectedErrorKind::LoopbackUnavailable), + "R6 body (rpc.invoke_method re-wrap) must classify as LoopbackUnavailable" + ); +} + +#[test] +fn classifies_loopback_connect_refused_across_platforms() { + // Linux WSL / native: os error 111. Windows WSAECONNREFUSED: 10061. + // Both must classify so the matcher works regardless of where the + // user's desktop happens to be running. + for raw in [ + "error sending request for url (http://127.0.0.1:18474/x) \ + → tcp connect error → Connection refused (os error 111)", + "error sending request for url (http://localhost:18474/x) \ + → tcp connect error → Connection refused (os error 10061)", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::LoopbackUnavailable), + "should classify as LoopbackUnavailable across platforms: {raw}" + ); + } +} + +#[test] +fn loopback_unavailable_precedence_over_network_unreachable() { + // Precedence guard: a loopback `Connection refused (os error 61)` + // body would ALSO match `is_network_unreachable_message` because the + // broader matcher catches both `error sending request for url` and + // `connection refused`. The ladder must route through the + // loopback-specific bucket first so the two error classes stay + // distinguishable in Sentry. + let kind = expected_error_kind(R5_BODY); + assert_eq!(kind, Some(ExpectedErrorKind::LoopbackUnavailable)); + assert_ne!(kind, Some(ExpectedErrorKind::NetworkUnreachable)); +} + +#[test] +fn does_not_classify_loopback_url_with_different_error_class_as_loopback() { + // A real upstream HTTP failure that happens to hit a developer's + // local proxy on `127.0.0.1:` (e.g. `mitmproxy`, `Charles`, + // `ngrok http`) must NOT be silenced as loopback noise — the body + // shape is a 503 status, not a transport-level connect-refused, and + // is actionable for Sentry. + let raw = "Backend returned 503 Service Unavailable for GET \ + http://127.0.0.1:8080/agent-integrations/composio/connections: \ + upstream timed out"; + assert!( + !matches!( + expected_error_kind(raw), + Some(ExpectedErrorKind::LoopbackUnavailable) + ), + "loopback URL with non-transport error must not classify as LoopbackUnavailable" + ); +} + +#[test] +fn does_not_classify_non_loopback_connect_refused_as_loopback() { + // A `Connection refused` against a non-loopback host (DNS resolved + // to a remote IP, ISP-level block, captive portal) must fall + // through to `NetworkUnreachable`, not into the loopback bucket. + let raw = "error sending request for url \ + (https://api.tinyhumans.ai/agent-integrations/composio/connections) \ + → tcp connect error → Connection refused (os error 61)"; + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::NetworkUnreachable) + ); +} + +#[test] +fn loopback_matcher_requires_both_host_and_errno_anchors() { + // Defense against the matcher being too eager: bodies that satisfy + // only one of the two conjunctive anchors must not classify into the + // loopback bucket. They may still demote via the broader + // `NetworkUnreachable` matcher — that is the correct fall-through — + // but the bucket must stay distinct so Sentry's "what class is + // spiking?" signal is preserved. + let loopback_host_no_errno = + "doctor: probed 127.0.0.1:18474 and got connection refused without errno detail"; + assert_ne!( + expected_error_kind(loopback_host_no_errno), + Some(ExpectedErrorKind::LoopbackUnavailable), + "loopback host without `(os error N)` errno must not classify as LoopbackUnavailable" + ); + + let errno_no_loopback_host = "note: connection refused (os error 61) on retry"; + assert_ne!( + expected_error_kind(errno_no_loopback_host), + Some(ExpectedErrorKind::LoopbackUnavailable), + "errno without loopback host anchor must not classify as LoopbackUnavailable" + ); +} + +#[test] +fn report_error_or_expected_routes_r5_r6_through_expected_path() { + // Smoke test: both verbatim Sentry bodies flow through + // `report_error_or_expected` without panicking. The classifier + // routes them to `report_expected_message` (debug breadcrumb, + // metadata-only) instead of `report_error_message` + // (`sentry::capture_message` at error level). We can't observe the + // Sentry hub from this test, but exercising the call path catches + // any future regression that re-introduces a panic or mis-types + // the arm. + report_error_or_expected( + R5_BODY, + "integrations", + "get", + &[ + ("path", "/agent-integrations/composio/connections"), + ("failure", "transport"), + ], + ); + report_error_or_expected( + R6_BODY, + "rpc", + "invoke_method", + &[("method", "openhuman.composio_list_connections")], + ); +} + +#[test] +fn classifies_channel_supervisor_restart_english_discord_gateway() { + // TAURI-RUST-15 (~11.4k events / 14d on self-hosted `tauri-rust`): + // verbatim wrapper from `channels::runtime::supervision::spawn_supervised_listener` + // around the Discord gateway transport error. The English body + // would otherwise match `is_network_unreachable_message` (which + // demotes to `warn!` — still a Sentry event); the supervisor + // wrap precedence routes it to `ChannelSupervisorRestart` + // (info-only breadcrumb). + let body = "Channel discord error: error sending request for url \ + (https://discord.com/api/v10/gateway/bot); restarting"; + assert_eq!( + expected_error_kind(body), + Some(ExpectedErrorKind::ChannelSupervisorRestart) + ); +} + +#[test] +fn classifies_channel_supervisor_restart_chinese_windows_wsaetimedout() { + // TAURI-RUST-BB (~815 events / 14d): same supervisor wrapper, + // OS-localized inner WSAETIMEDOUT body on Chinese Windows. The + // English-only `is_network_unreachable_message` anchors miss + // this inner message, so without the language-agnostic + // supervisor matcher it would escape classification entirely + // and emit a full Sentry error. The wrapper-anchored predicate + // catches it regardless of OS locale. + let body = "Channel discord error: IO error: \ + 由于连接方在一段时间后没有正确答复或连接的主机没有反应,连接尝试失败。 \ + (os error 10060); restarting"; + assert_eq!( + expected_error_kind(body), + Some(ExpectedErrorKind::ChannelSupervisorRestart) + ); +} + +#[test] +fn channel_supervisor_restart_matches_multiple_channel_names() { + // The wrapper format is `"Channel error: ; restarting"`. + // The name slot varies by provider (discord, slack, telegram, + // whatsapp, gmessages, …). The matcher must classify all of them — + // language-agnostic, name-agnostic. + for raw in [ + "Channel slack error: gateway disconnect; restarting", + "Channel telegram error: tls handshake eof; restarting", + "Channel whatsapp error: connection reset by peer (os error 54); restarting", + "Channel gmessages error: WebSocket connect: HTTP error: 502 Bad Gateway; restarting", + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ChannelSupervisorRestart), + "should classify as channel-supervisor-restart: {raw}" + ); + } +} + +#[test] +fn channel_supervisor_restart_precedence_over_network_unreachable() { + // Pin the precedence: a supervisor-wrap body that ALSO contains + // the canonical `"error sending request for url"` anchor (which + // would by itself classify as `NetworkUnreachable`) MUST route + // to `ChannelSupervisorRestart`. The supervisor's own backoff + // handles the condition; `NetworkUnreachable` would demote to + // `warn!` (still a Sentry event), whereas + // `ChannelSupervisorRestart` demotes to `info!` (no event). + let body = "Channel discord error: error sending request for url \ + (https://discord.com/api/v10/gateway/bot); restarting"; + let kind = expected_error_kind(body); + assert_eq!(kind, Some(ExpectedErrorKind::ChannelSupervisorRestart)); + assert_ne!(kind, Some(ExpectedErrorKind::NetworkUnreachable)); +} + +#[test] +fn channel_supervisor_restart_does_not_classify_unrelated_restart_notes() { + // Defense against the matcher being too eager: bodies that + // contain `"; restarting"` but NOT the `"Channel error:"` + // preamble must NOT classify — those are generic restart logs + // from other subsystems where Sentry signal may still be + // actionable. The matcher requires all three anchors together + // (`"channel "` prefix + `" error:"` separator + `"; restarting"` + // trailer). + for raw in [ + // No `Channel ` preamble. + "systemd: docker.service; restarting", + // No `Channel ` preamble even though `; restarting` + // appears. + "Connection refused; restarting", + // The string `channel` appears but not as the leading + // `"Channel error:"` wrapper — must not classify. + "channels::runtime::dispatch failed: error: provider exhausted; restarting", + // The wrapper prefix is present but the trailer is not — + // a half-formed log line must not classify. + "Channel discord error: gateway disconnect", + ] { + assert_ne!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ChannelSupervisorRestart), + "must NOT classify as channel-supervisor-restart: {raw}" + ); + } +} + +#[test] +fn report_error_or_expected_routes_channel_supervisor_restart_through_expected_path() { + // Smoke test: the verbatim TAURI-RUST-15 Sentry body flows through + // `report_error_or_expected` without panicking. The classifier + // routes it to `report_expected_message` (info breadcrumb) instead + // of `report_error_message` (`sentry::capture_message` at error + // level). We can't observe the Sentry hub from this test, but + // exercising the call path catches any future regression that + // re-introduces a panic or mis-types the arm. + report_error_or_expected( + "Channel discord error: error sending request for url \ + (https://discord.com/api/v10/gateway/bot); restarting", + "channels", + "supervised_listener", + &[("channel", "discord")], + ); +} diff --git a/src/openhuman/agent/harness/mod.rs b/src/openhuman/agent/harness/mod.rs index f499f4804..9d3b5bcc2 100644 --- a/src/openhuman/agent/harness/mod.rs +++ b/src/openhuman/agent/harness/mod.rs @@ -60,7 +60,7 @@ mod bughunt_tests; #[cfg(test)] pub(crate) mod test_support; #[cfg(test)] -mod test_support_test; +mod test_support_tests; #[cfg(test)] mod harness_gap_tests; diff --git a/src/openhuman/agent/harness/test_support_test.rs b/src/openhuman/agent/harness/test_support_tests.rs similarity index 100% rename from src/openhuman/agent/harness/test_support_test.rs rename to src/openhuman/agent/harness/test_support_tests.rs diff --git a/src/openhuman/agent/tools/spawn_parallel_agents.rs b/src/openhuman/agent/tools/spawn_parallel_agents.rs index c01807f32..d7028f94f 100644 --- a/src/openhuman/agent/tools/spawn_parallel_agents.rs +++ b/src/openhuman/agent/tools/spawn_parallel_agents.rs @@ -487,5 +487,5 @@ fn with_ownership_boundary(prompt: &str, ownership: Option<&str>) -> String { } #[cfg(test)] -#[path = "spawn_parallel_agents_test.rs"] +#[path = "spawn_parallel_agents_tests.rs"] mod tests; diff --git a/src/openhuman/agent/tools/spawn_parallel_agents_test.rs b/src/openhuman/agent/tools/spawn_parallel_agents_tests.rs similarity index 100% rename from src/openhuman/agent/tools/spawn_parallel_agents_test.rs rename to src/openhuman/agent/tools/spawn_parallel_agents_tests.rs diff --git a/src/openhuman/composio/client_tests.rs b/src/openhuman/composio/client_tests.rs index 950017924..b98f3e1db 100644 --- a/src/openhuman/composio/client_tests.rs +++ b/src/openhuman/composio/client_tests.rs @@ -1121,7 +1121,7 @@ fn store_get_clear_composio_api_key_roundtrip() { // 400-class user error. // * `direct_execute` accepts a None-arguments call and falls // through to the underlying tool surface (which then errors on the -// network call — covered by the integration test in `ops_test.rs`). +// network call — covered by the integration test in `ops_tests.rs`). // * `direct_list_connections` is a thin mapper; the real coverage // for its row → ComposioConnection translation lives in the // `connected_account_*` tests in `composio_tests.rs`. diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index fe6201134..1f3c80dcb 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -2371,7 +2371,7 @@ pub async fn composio_clear_api_key(config: &Config) -> OpResult String { } #[cfg(test)] -#[path = "registry_test.rs"] +#[path = "registry_tests.rs"] mod tests; diff --git a/src/openhuman/cwd_jail/registry_test.rs b/src/openhuman/cwd_jail/registry_tests.rs similarity index 99% rename from src/openhuman/cwd_jail/registry_test.rs rename to src/openhuman/cwd_jail/registry_tests.rs index c7674a0df..0243b66cd 100644 --- a/src/openhuman/cwd_jail/registry_test.rs +++ b/src/openhuman/cwd_jail/registry_tests.rs @@ -1,7 +1,7 @@ //! Unit tests for [`super::JailRegistry`]. //! //! Lives next to `registry.rs` (wired in via `#[cfg(test)] #[path = -//! "registry_test.rs"] mod tests;`) so the production module stays under +//! "registry_tests.rs"] mod tests;`) so the production module stays under //! the ~500-line guideline. use super::*; diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 2e9a305b5..e756d13f4 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -1088,5 +1088,5 @@ fn redact_endpoint(url: &str) -> String { // ── Unit tests ──────────────────────────────────────────────────────────────── #[cfg(test)] -#[path = "factory_test.rs"] -mod factory_test; +#[path = "factory_tests.rs"] +mod factory_tests; diff --git a/src/openhuman/inference/provider/factory_test.rs b/src/openhuman/inference/provider/factory_tests.rs similarity index 100% rename from src/openhuman/inference/provider/factory_test.rs rename to src/openhuman/inference/provider/factory_tests.rs diff --git a/src/openhuman/inference/provider/ops.rs b/src/openhuman/inference/provider/ops.rs index 6de963de2..6e4006c48 100644 --- a/src/openhuman/inference/provider/ops.rs +++ b/src/openhuman/inference/provider/ops.rs @@ -1238,1293 +1238,5 @@ pub fn canonical_china_provider_name(_name: &str) -> Option<&'static str> { } #[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::config::schema::cloud_providers::{AuthStyle, CloudProviderCreds}; - use crate::openhuman::config::Config; - use crate::openhuman::credentials::AuthService; - use axum::{ - extract::State, - http::{HeaderMap, StatusCode}, - response::{IntoResponse, Response}, - routing::get, - Json, Router, - }; - use std::collections::HashMap; - use std::sync::{ - atomic::{AtomicUsize, Ordering as AtomicOrdering}, - Arc, Mutex, - }; - use tempfile::TempDir; - - #[derive(Clone)] - struct ModelProbeState { - key_status: StatusCode, - key_calls: Arc, - model_calls: Arc, - key_authorization: Arc>>>, - model_authorization: Arc>>>, - } - - async fn openrouter_key_handler( - State(state): State, - headers: HeaderMap, - ) -> Response { - state.key_calls.fetch_add(1, AtomicOrdering::SeqCst); - state - .key_authorization - .lock() - .unwrap_or_else(|e| e.into_inner()) - .push(authorization_header(&headers)); - if state.key_status.is_success() { - Json(serde_json::json!({ - "data": { - "label": "test-key", - "usage": 0 - } - })) - .into_response() - } else { - ( - state.key_status, - Json(serde_json::json!({ - "error": { - "message": "No auth credentials found" - } - })), - ) - .into_response() - } - } - - async fn models_handler(State(state): State, headers: HeaderMap) -> Response { - state.model_calls.fetch_add(1, AtomicOrdering::SeqCst); - state - .model_authorization - .lock() - .unwrap_or_else(|e| e.into_inner()) - .push(authorization_header(&headers)); - Json(serde_json::json!({ - "data": [{ - "id": "openrouter/test-model", - "owned_by": "openrouter", - "context_length": 128000 - }] - })) - .into_response() - } - - fn authorization_header(headers: &HeaderMap) -> Option { - headers - .get("authorization") - .and_then(|value| value.to_str().ok()) - .map(|value| value.to_string()) - } - - async fn spawn_openrouter_probe_server(key_status: StatusCode) -> (String, ModelProbeState) { - let state = ModelProbeState { - key_status, - key_calls: Arc::new(AtomicUsize::new(0)), - model_calls: Arc::new(AtomicUsize::new(0)), - key_authorization: Arc::new(Mutex::new(Vec::new())), - model_authorization: Arc::new(Mutex::new(Vec::new())), - }; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().expect("local_addr"); - let app = Router::new() - .route("/key", get(openrouter_key_handler)) - .route("/models", get(models_handler)) - .with_state(state.clone()); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("serve"); - }); - (format!("http://{addr}"), state) - } - - async fn configure_openrouter_workspace( - tmp: &TempDir, - endpoint: String, - token: &str, - ) -> Config { - let mut config = Config { - config_path: tmp.path().join("config.toml"), - workspace_dir: tmp.path().join("workspace"), - ..Config::default() - }; - config.secrets.encrypt = false; - config.cloud_providers.push(CloudProviderCreds { - id: "p_openrouter_test".to_string(), - slug: "openrouter".to_string(), - label: "OpenRouter".to_string(), - endpoint, - auth_style: AuthStyle::Bearer, - legacy_type: None, - default_model: None, - }); - config.save().await.expect("save config"); - - let auth = AuthService::from_config(&config); - auth.store_provider_token( - &crate::openhuman::inference::provider::factory::auth_key_for_slug("openrouter"), - "default", - token, - HashMap::new(), - true, - ) - .expect("store provider key"); - config - } - - #[test] - fn list_configured_models_accepts_slug() { - // list_configured_models should find a provider by slug when the caller - // passes a slug instead of the opaque random id. This lets the frontend - // call the RPC before the provider config has been persisted (where only - // the slug is stable). - use crate::openhuman::config::schema::cloud_providers::{AuthStyle, CloudProviderCreds}; - use crate::openhuman::config::Config; - - let mut config = Config::default(); - config.cloud_providers.push(CloudProviderCreds { - id: "p_openai_xyz99".to_string(), - slug: "openai".to_string(), - label: "OpenAI".to_string(), - endpoint: "https://api.openai.com/v1".to_string(), - auth_style: AuthStyle::Bearer, - legacy_type: None, - default_model: None, - }); - - // The find predicate must match on slug. - let found_by_slug = config - .cloud_providers - .iter() - .find(|e| e.id == "openai" || e.slug == "openai"); - assert!( - found_by_slug.is_some(), - "slug lookup must find the provider" - ); - assert_eq!(found_by_slug.unwrap().id, "p_openai_xyz99"); - - // The find predicate must still match on id. - let found_by_id = config - .cloud_providers - .iter() - .find(|e| e.id == "p_openai_xyz99" || e.slug == "p_openai_xyz99"); - assert!(found_by_id.is_some(), "id lookup must still work"); - } - - #[test] - fn openrouter_detection_matches_builtin_slug_or_host() { - let provider = |slug: &str, endpoint: &str| CloudProviderCreds { - id: format!("p_{slug}"), - slug: slug.to_string(), - label: slug.to_string(), - endpoint: endpoint.to_string(), - auth_style: AuthStyle::Bearer, - legacy_type: None, - default_model: None, - }; - - assert!(is_openrouter_provider(&provider( - "openrouter", - "http://127.0.0.1:1234" - ))); - assert!(is_openrouter_provider(&provider( - "custom-router", - "https://openrouter.ai/api/v1" - ))); - assert!(is_openrouter_provider(&provider( - "custom-router", - "https://oauth.openrouter.ai/api/v1" - ))); - assert!(!is_openrouter_provider(&provider( - "custom-openai", - "https://api.openai.com/v1" - ))); - } - - #[tokio::test] - async fn openrouter_invalid_key_fails_before_models_catalog_probe() { - let tmp = tempfile::tempdir().expect("tempdir"); - let (endpoint, state) = spawn_openrouter_probe_server(StatusCode::UNAUTHORIZED).await; - let config = configure_openrouter_workspace(&tmp, endpoint, "bad-openrouter-key").await; - - let err = list_configured_models_from_config("openrouter", &config) - .await - .expect_err("invalid OpenRouter key must fail"); - - assert!( - err.contains("OpenRouter key validation returned 401"), - "unexpected error: {err}" - ); - assert_eq!(state.key_calls.load(AtomicOrdering::SeqCst), 1); - assert_eq!( - state.model_calls.load(AtomicOrdering::SeqCst), - 0, - "invalid OpenRouter credentials must not fall through to /models" - ); - } - - #[tokio::test] - async fn openrouter_valid_key_allows_models_catalog_probe() { - let tmp = tempfile::tempdir().expect("tempdir"); - let (endpoint, state) = spawn_openrouter_probe_server(StatusCode::OK).await; - let config = configure_openrouter_workspace(&tmp, endpoint, "valid-openrouter-key").await; - - let outcome = list_configured_models_from_config("openrouter", &config) - .await - .expect("valid OpenRouter key should list models"); - - assert_eq!(state.key_calls.load(AtomicOrdering::SeqCst), 1); - assert_eq!(state.model_calls.load(AtomicOrdering::SeqCst), 1); - assert_eq!(outcome.value["models"][0]["id"], "openrouter/test-model"); - } - - #[tokio::test] - async fn openrouter_key_is_trimmed_for_validation_and_catalog_probe() { - let tmp = tempfile::tempdir().expect("tempdir"); - let (endpoint, state) = spawn_openrouter_probe_server(StatusCode::OK).await; - let config = - configure_openrouter_workspace(&tmp, endpoint, " valid-openrouter-key\r\n").await; - - list_configured_models_from_config("openrouter", &config) - .await - .expect("trimmed OpenRouter key should list models"); - - let key_authorization = state - .key_authorization - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(); - let model_authorization = state - .model_authorization - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(); - assert_eq!( - key_authorization, - vec![Some("Bearer valid-openrouter-key".to_string())] - ); - assert_eq!( - model_authorization, - vec![Some("Bearer valid-openrouter-key".to_string())] - ); - } - - /// Spawn a minimal axum server that always returns 404 for the /models endpoint. - /// Used to verify that providers without model listing return an empty list, - /// not an error (Sentry issue TAURI-RUST-1Z). - async fn spawn_models_404_server() -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().expect("local_addr"); - let app = axum::Router::new().route( - "/models", - axum::routing::get(|| async { - (axum::http::StatusCode::NOT_FOUND, "Not Found").into_response() - }), - ); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("serve"); - }); - format!("http://{addr}") - } - - #[tokio::test] - async fn models_404_returns_empty_list_not_error() { - // Providers that return 404 on /models (e.g. DeepSeek, Kimi, custom proxies) - // must yield an empty model list, not an Err. Returning an Err was firing a - // Sentry error for every `inference_list_models` call (TAURI-RUST-1Z, 819 events). - let tmp = tempfile::tempdir().expect("tempdir"); - let endpoint = spawn_models_404_server().await; - - let mut config = Config { - config_path: tmp.path().join("config.toml"), - workspace_dir: tmp.path().join("workspace"), - ..Config::default() - }; - config.secrets.encrypt = false; - config.cloud_providers.push(CloudProviderCreds { - id: "p_custom_test".to_string(), - slug: "custom-no-models".to_string(), - label: "Custom (no /models)".to_string(), - endpoint, - auth_style: AuthStyle::Bearer, - legacy_type: None, - default_model: None, - }); - - let outcome = list_configured_models_from_config("custom-no-models", &config) - .await - .expect("404 from /models must succeed with an empty list"); - - let models = outcome.value["models"] - .as_array() - .expect("response must have a `models` array"); - assert!( - models.is_empty(), - "expected empty model list for a 404 /models endpoint, got: {models:?}" - ); - assert_eq!( - outcome.value["unsupported"], - serde_json::Value::Bool(true), - "unsupported flag must be set to true for 404 providers" - ); - } - - #[test] - fn factory_backend() { - assert!(create_backend_inference_provider( - None, - None, - None, - &ProviderRuntimeOptions::default() - ) - .is_ok()); - } - - #[test] - fn skips_sentry_report_for_transient_upstream_statuses() { - // Transient statuses — 429 rate-limit, 408 client timeout, and 502/503/504 - // gateway-layer failures — are retried by reliable.rs. The aggregate - // "all providers exhausted" event still fires for genuine outages. - // Reporting each attempt individually floods Sentry (OPENHUMAN-TAURI-2E - // ~1393 events, 84 ~1050 events, T ~871 events). - for transient in [ - reqwest::StatusCode::TOO_MANY_REQUESTS, - reqwest::StatusCode::REQUEST_TIMEOUT, - reqwest::StatusCode::BAD_GATEWAY, - reqwest::StatusCode::SERVICE_UNAVAILABLE, - reqwest::StatusCode::GATEWAY_TIMEOUT, - ] { - assert!( - !should_report_provider_http_failure(transient), - "transient status {transient} must not trigger per-attempt Sentry report" - ); - } - // Auth + permanent server faults remain reportable — those are - // misconfiguration or genuine bugs, not transient capacity issues. - for reportable in [ - reqwest::StatusCode::UNAUTHORIZED, - reqwest::StatusCode::FORBIDDEN, - reqwest::StatusCode::BAD_REQUEST, - reqwest::StatusCode::NOT_FOUND, - reqwest::StatusCode::INTERNAL_SERVER_ERROR, - ] { - assert!( - should_report_provider_http_failure(reportable), - "status {reportable} must still report to Sentry" - ); - } - } - - // Confirm the budget-exhausted suppression predicate is scoped correctly. - // These tests exercise the real production function, not a duplicate. - mod budget_exhausted_suppression { - use super::*; - - const BUDGET_BODY: &str = "Insufficient budget"; - const UNRELATED_BODY: &str = "Invalid request: model not found"; - - #[test] - fn budget_exhausted_400_is_suppressed() { - assert!(is_budget_exhausted_http_400( - reqwest::StatusCode::BAD_REQUEST, - BUDGET_BODY, - )); - } - - #[test] - fn budget_exhausted_400_is_case_insensitive() { - assert!(is_budget_exhausted_http_400( - reqwest::StatusCode::BAD_REQUEST, - "budget exceeded — ADD credits to continue", - )); - } - - #[test] - fn budget_exhausted_500_is_not_suppressed() { - // A 500 is a server bug, not expected user-state — keep reporting. - assert!(!is_budget_exhausted_http_400( - reqwest::StatusCode::INTERNAL_SERVER_ERROR, - BUDGET_BODY, - )); - } - - #[test] - fn budget_exhausted_400_unrelated_body_is_not_suppressed() { - assert!(!is_budget_exhausted_http_400( - reqwest::StatusCode::BAD_REQUEST, - UNRELATED_BODY, - )); - } - - #[test] - fn budget_exhausted_402_is_not_suppressed() { - assert!(!is_budget_exhausted_http_400( - reqwest::StatusCode::PAYMENT_REQUIRED, - BUDGET_BODY, - )); - } - - #[test] - fn budget_exhausted_empty_body_is_not_suppressed() { - assert!(!is_budget_exhausted_http_400( - reqwest::StatusCode::BAD_REQUEST, - "", - )); - } - } - - // Tests for the rate-limit body suppression guard added to `api_error`. - // Exercises `is_upstream_rate_limit_message` with the exact body shapes that - // produced OPENHUMAN-TAURI-S (~6 984 events from HTTP 500 wrapping a - // "429 rate limit exceeded" body) and OPENHUMAN-TAURI-6Y / -2E. - mod rate_limit_body_suppression { - use crate::core::observability::is_upstream_rate_limit_message; - - /// HTTP 500 with a `"429 rate limit exceeded"` body must be detected - /// as a rate-limit signal so the guard in `api_error` can skip the - /// Sentry report (OPENHUMAN-TAURI-S). - #[test] - fn http_500_with_429_body_phrase_is_rate_limited() { - let body = - r#"{"success":false,"error":"429 rate limit exceeded, please try again later"}"# - .to_ascii_lowercase(); - assert!( - is_upstream_rate_limit_message(&body), - "500-body with '429 rate limit exceeded' must be detected as rate-limited" - ); - } - - /// HTTP 500 with an `"upstream rate limit exceeded"` body - /// (OPENHUMAN-TAURI-6Y shape). - #[test] - fn http_500_with_upstream_rate_limit_body_is_rate_limited() { - let body = r#"{"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'. Please retry shortly.","details":{"provider":"gmi"}}"# - .to_ascii_lowercase(); - assert!( - is_upstream_rate_limit_message(&body), - "500-body with 'upstream rate limit exceeded' must be detected" - ); - } - - /// OpenAI / Anthropic `"rate_limit_error"` type body. - #[test] - fn body_with_rate_limit_error_type_is_rate_limited() { - let body = r#"{"error":{"message":"Rate limit exceeded. Please retry after a brief wait.","type":"rate_limit_error"}}"# - .to_ascii_lowercase(); - assert!( - is_upstream_rate_limit_message(&body), - "body with 'rate_limit_error' type must be detected" - ); - } - - /// Unrelated 500 body must NOT be detected as rate-limited. - #[test] - fn http_500_unrelated_body_is_not_rate_limited() { - let body = r#"{"success":false,"error":"internal server error: database unavailable"}"# - .to_ascii_lowercase(); - assert!( - !is_upstream_rate_limit_message(&body), - "unrelated 500 body must not be detected as rate-limited" - ); - } - } - - mod provider_access_policy_suppression { - use super::*; - - const ACCESS_TERMINATED_BODY: &str = - "{\"error\":{\"message\":\"Kimi For Coding is currently only available for Coding Agents.\",\"type\":\"access_terminated_error\"}}"; - - #[test] - fn access_terminated_403_is_suppressed() { - assert!(is_provider_access_policy_denied_http_403( - reqwest::StatusCode::FORBIDDEN, - ACCESS_TERMINATED_BODY, - )); - } - - #[test] - fn access_terminated_non_403_is_not_suppressed() { - assert!(!is_provider_access_policy_denied_http_403( - reqwest::StatusCode::BAD_REQUEST, - ACCESS_TERMINATED_BODY, - )); - } - - #[test] - fn unrelated_403_is_not_suppressed() { - assert!(!is_provider_access_policy_denied_http_403( - reqwest::StatusCode::FORBIDDEN, - "{\"error\":{\"message\":\"forbidden\"}}", - )); - } - } - - // Exercises the real `is_provider_config_rejection_http` decision used - // by `api_error`, including the inverted provider-aware polarity. - mod provider_config_rejection_suppression { - use super::*; - - // The exact #2079 Sentry body shape. - const TIER_LEAK_BODY: &str = - "The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \ - but you passed reasoning-v1."; - // #2076 Moonshot Kimi K2 temperature constraint. - const TEMP_BODY: &str = "invalid temperature: only 1 is allowed for this model"; - - #[test] - fn custom_provider_4xx_config_rejection_is_suppressed() { - assert!(is_provider_config_rejection_http( - reqwest::StatusCode::BAD_REQUEST, - "custom_openai", - TIER_LEAK_BODY, - )); - assert!(is_provider_config_rejection_http( - reqwest::StatusCode::BAD_REQUEST, - "custom_openai", - TEMP_BODY, - )); - // 404 "model does not exist" is the same user-config class. - assert!(is_provider_config_rejection_http( - reqwest::StatusCode::NOT_FOUND, - "custom_openai", - "The model `gpt-5.5` does not exist or you do not have access to it.", - )); - } - - #[test] - fn openhuman_backend_same_body_is_not_suppressed() { - // Inverted polarity: for tier-leak / temperature / litellm / - // OpenRouter-style phrases, the OpenHuman backend never - // emits them, so the same body from our OWN backend would - // mean we sent it a bad request — a real regression that - // must still reach Sentry. (Mirror of the 401/403 backend - // rule.) - assert!(!is_provider_config_rejection_http( - reqwest::StatusCode::BAD_REQUEST, - openhuman_backend::PROVIDER_LABEL, - TIER_LEAK_BODY, - )); - assert!(!is_provider_config_rejection_http( - reqwest::StatusCode::BAD_REQUEST, - openhuman_backend::PROVIDER_LABEL, - TEMP_BODY, - )); - } - - #[test] - fn openhuman_backend_openai_compatible_unknown_model_is_suppressed() { - // TAURI-RUST-2Z1 — the OpenHuman backend DOES emit the - // OpenAI-compatible "Model 'X' is not available. Use GET - // /openai/v1/models …" wire body for user-configured unknown - // model ids (here `MiniMax-M2.7-highspeed` and two - // `custom:`-prefixed fallback variants from the user's own - // `model_fallbacks` config). That's user-state, not a - // regression — drop the polarity guard for this specific - // shape so the per-attempt event stops reaching Sentry. - // (The aggregate sibling TAURI-RUST-2Z2 is already covered by - // `expected_error_kind` via the broader message-only - // classifier.) - for body in [ - r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Model 'MiniMax-M2.7-highspeed' is not available. Use GET /openai/v1/models to list available models."}"#, - r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Model 'custom:MiniMax-M2.7' is not available. Use GET /openai/v1/models to list available models."}"#, - ] { - assert!( - is_provider_config_rejection_http( - reqwest::StatusCode::BAD_REQUEST, - openhuman_backend::PROVIDER_LABEL, - body, - ), - "TAURI-RUST-2Z1 body must be suppressed for openhuman backend: {body:?}" - ); - } - } - - #[test] - fn server_error_is_not_suppressed() { - // A 5xx is a server bug, not user-config — keep reporting. - assert!(!is_provider_config_rejection_http( - reqwest::StatusCode::INTERNAL_SERVER_ERROR, - "custom_openai", - TIER_LEAK_BODY, - )); - } - - #[test] - fn transient_429_is_not_suppressed_here() { - // 429 is transient; handled by should_report_provider_http_failure, - // not this classifier (must not be swallowed as user-config). - assert!(!is_provider_config_rejection_http( - reqwest::StatusCode::TOO_MANY_REQUESTS, - "custom_openai", - TIER_LEAK_BODY, - )); - } - - #[test] - fn unrelated_4xx_body_is_not_suppressed() { - assert!(!is_provider_config_rejection_http( - reqwest::StatusCode::BAD_REQUEST, - "custom_openai", - "Bad request: missing required field 'messages'", - )); - } - - #[test] - fn log_helper_runs_without_panicking() { - // Covers the demotion log path taken by `api_error` when a - // custom provider rejects the user's model/param config. No - // tracing subscriber in unit tests, so this is a pure smoke. - log_provider_config_rejection( - "api_error", - "custom_openai", - Some("reasoning-v1"), - reqwest::StatusCode::BAD_REQUEST, - ); - } - } - - mod context_window_exceeded_suppression { - use super::*; - - #[test] - fn classifies_tauri_rust_501_custom_provider_500_body() { - // TAURI-RUST-501: the custom-provider 500 wire body. The - // matcher is status-agnostic, so the 500 mis-report is caught - // (the provider api_error cascade routes it to - // `log_context_window_exceeded` instead of `report_error`). - assert!(is_context_window_exceeded_message( - "{\"error\":{\"code\":500,\"message\":\"Context size has been exceeded.\",\"type\":\"server_error\"}}" - )); - } - - #[test] - fn classifies_established_context_overflow_phrasings() { - // The phrasings the reliable.rs non-retryable classifier - // recognized before this refactor must all still match through - // the shared single-source matcher. - for body in [ - "This model's maximum context length is 8192 tokens", - "request exceeds the context window of this model", - "context length exceeded", - "too many tokens in the prompt", - "token limit exceeded", - "prompt is too long for the selected model", - "input is too long", - ] { - assert!( - is_context_window_exceeded_message(body), - "should match context-overflow body: {body}" - ); - } - } - - #[test] - fn does_not_match_unrelated_bodies() { - for body in [ - "rate limit exceeded, retry after 30s", - "Invalid request: model not found", - "Insufficient budget", - "tool call exceeded the allowed budget", - ] { - assert!( - !is_context_window_exceeded_message(body), - "must NOT match unrelated body: {body}" - ); - } - } - - #[test] - fn token_rate_limits_are_not_context_overflow() { - // Token-count phrases collide with per-minute token RATE limits. - // Those are transient 429s that must stay retryable and keep - // reaching Sentry — they must NOT be classified as context - // overflow (CodeRabbit review of #2820). The rate-limit marker - // disambiguates. - for body in [ - "Rate limit reached: too many tokens per minute (TPM) for this org", - "rate_limit_exceeded: token limit exceeded, retry after 12s", - "You have hit too many tokens per min; try again in 30s", - ] { - assert!( - !is_context_window_exceeded_message(body), - "TPM rate-limit must NOT match as context overflow: {body}" - ); - } - // …but a token-count overflow with NO rate marker still matches. - assert!(is_context_window_exceeded_message( - "Request rejected: too many tokens in the input for this model" - )); - } - - #[test] - fn log_helper_runs_without_panicking() { - // Smoke for the demotion path taken by `api_error` — no tracing - // subscriber in unit tests. - log_context_window_exceeded( - "api_error", - "custom_openai", - None, - reqwest::StatusCode::INTERNAL_SERVER_ERROR, - ); - } - } - - #[test] - fn test_sanitize_api_error_utf8() { - let input = "🦀".repeat(MAX_API_ERROR_CHARS + 10); - let sanitized = sanitize_api_error(&input); - assert!(sanitized.ends_with("...")); - // Should truncate at MAX_API_ERROR_CHARS crabs - let crabs_count = sanitized.chars().filter(|c| *c == '🦀').count(); - assert_eq!(crabs_count, MAX_API_ERROR_CHARS); - } - - // ── TAURI-RUST-12: list_models JSON parse error must surface body ────── - // - // `response.json()` previously dropped the body when decoding failed, so - // Sentry saw `[providers][list_models] failed to parse JSON: error decoding - // response body` with no clue what the server actually returned. The fix - // reads the body as text first, parses with `serde_json::from_str`, and - // appends a sanitized + truncated snippet to the error string so the - // failure is diagnosable from the log line alone. - - #[derive(Clone)] - struct StaticResponse { - status: StatusCode, - body: &'static str, - } - - async fn static_models_handler(State(s): State) -> Response { - (s.status, s.body).into_response() - } - - async fn spawn_static_models_server(status: StatusCode, body: &'static str) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().expect("local_addr"); - let app = Router::new() - .route("/models", get(static_models_handler)) - .with_state(StaticResponse { status, body }); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("serve"); - }); - format!("http://{addr}") - } - - async fn configure_generic_workspace(tmp: &TempDir, endpoint: String) -> Config { - // Non-`openrouter` slug so the OpenRouter pre-validation path is - // skipped and the test hits `/models` directly. - let mut config = Config { - config_path: tmp.path().join("config.toml"), - workspace_dir: tmp.path().join("workspace"), - ..Config::default() - }; - config.secrets.encrypt = false; - config.cloud_providers.push(CloudProviderCreds { - id: "p_generic_test".to_string(), - slug: "generic-test".to_string(), - label: "Generic".to_string(), - endpoint, - auth_style: AuthStyle::None, - legacy_type: None, - default_model: None, - }); - config.save().await.expect("save config"); - config - } - - #[tokio::test] - async fn list_models_html_body_returns_diagnostic_snippet() { - // Captive-portal / proxy-login wire shape: 200 OK with HTML. - let tmp = tempfile::tempdir().expect("tempdir"); - let html = "Sign incaptive portal"; - let endpoint = spawn_static_models_server(StatusCode::OK, html).await; - let config = configure_generic_workspace(&tmp, endpoint).await; - - let err = list_configured_models_from_config("generic-test", &config) - .await - .expect_err("HTML body must not parse as JSON"); - - assert!( - err.contains("failed to parse JSON"), - "error must keep canonical prefix: {err}" - ); - assert!( - err.contains("captive portal") || err.contains("Sign in") || err.contains("html"), - "error must include a body snippet for diagnosis: {err}" - ); - } - - #[tokio::test] - async fn list_models_empty_body_returns_diagnostic_error() { - // Some misconfigured load balancers return 200 with an empty body. - let tmp = tempfile::tempdir().expect("tempdir"); - let endpoint = spawn_static_models_server(StatusCode::OK, "").await; - let config = configure_generic_workspace(&tmp, endpoint).await; - - let err = list_configured_models_from_config("generic-test", &config) - .await - .expect_err("empty body must not parse as JSON"); - - assert!( - err.contains("failed to parse JSON"), - "error must keep canonical prefix: {err}" - ); - } - - #[tokio::test] - async fn list_models_valid_json_still_succeeds() { - // Regression guard: the new text-then-parse path must still accept - // a valid `/models` JSON response. - let tmp = tempfile::tempdir().expect("tempdir"); - let body = r#"{"data":[{"id":"some-model","owned_by":"vendor","context_length":4096}]}"#; - let endpoint = spawn_static_models_server(StatusCode::OK, body).await; - let config = configure_generic_workspace(&tmp, endpoint).await; - - let outcome = list_configured_models_from_config("generic-test", &config) - .await - .expect("valid JSON must list models"); - assert_eq!(outcome.value["models"][0]["id"], "some-model"); - } - - // ── parse_models_response (TAURI-RUST-4Y) ────────────────────────────── - // - // Before this fix the `/models` parser collapsed "no `data` field" and - // "`data` field present but not an array" into a single misleading - // error string: `"provider response missing `data` array — endpoint is - // not OpenAI-compatible (got keys: data, object)"` — the keys list - // included `data`, contradicting the "missing" claim. The split - // surfaces the actual JSON-type mismatch so future Sentry events on - // this code path are triageable instead of looking like the parser - // is hallucinating. - - #[test] - fn parse_models_response_returns_models_for_well_formed_data_array() { - // Happy path — exact OpenAI `/models` shape, must yield model ids - // and `owned_by` / `context_length` projections from each entry. - let body = serde_json::json!({ - "object": "list", - "data": [ - { "id": "m1", "owned_by": "openai", "context_length": 8192 }, - { "id": "m2", "owned_by": "openai" }, - { "id": "m3", "context_window": 4096 }, - ], - }); - let models = parse_models_response(&body).expect("well-formed body must parse"); - assert_eq!(models.len(), 3); - assert_eq!(models[0].id, "m1"); - assert_eq!(models[0].owned_by.as_deref(), Some("openai")); - assert_eq!(models[0].context_window, Some(8192)); - assert_eq!(models[2].id, "m3"); - assert_eq!(models[2].owned_by, None); - assert_eq!(models[2].context_window, Some(4096)); - } - - #[test] - fn parse_models_response_distinguishes_missing_data_field_from_wrong_type() { - // (1) `data` field completely absent — original Sentry message - // shape, kept for backward fingerprint with the well-known - // "wrong endpoint" misconfiguration. - let body = serde_json::json!({ "object": "list", "models": [] }); - let err = parse_models_response(&body).expect_err("no data field must fail"); - assert!( - err.contains("missing `data` field"), - "no-data error should say `missing`: {err}" - ); - assert!( - err.contains("object, models") || err.contains("models, object"), - "no-data error should list actual keys: {err}" - ); - - // (2) `data` field present but wrong type — TAURI-RUST-4Y verbatim - // shape (`object` + `data` keys both present, but `data` isn't an - // array). The error MUST NOT say "missing" — it must surface the - // actual JSON type so triage knows what shape the provider sent. - for (label, value) in [ - ( - "object", - serde_json::json!({"object":"error","message":"boom"}), - ), - ("string", serde_json::json!("models go here")), - ("null", serde_json::Value::Null), - ("bool", serde_json::json!(true)), - ("number", serde_json::json!(42)), - ] { - let body = serde_json::json!({ "object": "list", "data": value }); - let err = parse_models_response(&body).expect_err("wrong-type data must fail"); - assert!( - !err.contains("missing"), - "wrong-type error must not say `missing` ({label}): {err}" - ); - assert!( - err.contains(label), - "wrong-type error must name the actual JSON kind ({label}): {err}" - ); - } - } - - // ── synthesize_local_runtime_entry (TAURI-RUST-28Z fallback) ──────────── - - #[test] - fn synthesize_local_runtime_entry_ollama_returns_v1_endpoint_with_no_auth() { - // Sentry TAURI-RUST-28Z fires when `inference_list_models("ollama")` - // runs against a config that has no `ollama` cloud_providers entry. - // The synth fallback must produce an entry routed to Ollama's - // OpenAI-compatible `/v1/models` surface at the resolved base URL, - // with `AuthStyle::None` so the probe runs without an Authorization - // header (loopback Ollama accepts unauthenticated requests). - let config = Config::default(); - let entry = synthesize_local_runtime_entry("ollama", &config) - .expect("ollama must produce a synthetic entry"); - assert_eq!(entry.slug, "ollama"); - assert_eq!(entry.auth_style, AuthStyle::None); - assert!( - entry.endpoint.ends_with("/v1"), - "ollama endpoint must terminate at /v1 so `/models` hits the OpenAI-compat surface; got {}", - entry.endpoint - ); - } - - #[test] - fn synthesize_local_runtime_entry_lmstudio_returns_v1_endpoint_with_no_auth() { - // LM Studio's default `lm_studio_base_url` already terminates at - // `/v1`; the synth must preserve that and select `AuthStyle::None` - // so the probe doesn't attach a bearer header (LM Studio runs - // unauthenticated on loopback). - let config = Config::default(); - let entry = synthesize_local_runtime_entry("lmstudio", &config) - .expect("lmstudio must produce a synthetic entry"); - assert_eq!(entry.slug, "lmstudio"); - assert_eq!(entry.auth_style, AuthStyle::None); - assert!( - entry.endpoint.ends_with("/v1"), - "lmstudio endpoint must terminate at /v1; got {}", - entry.endpoint - ); - } - - #[test] - fn synthesize_local_runtime_entry_returns_none_for_unknown_slug() { - // Only `ollama` and `lmstudio` are the recognized local-runtime - // aliases. Every other slug — built-in cloud providers (`openai`, - // `anthropic`), opaque ids (`p_random_xyz`), or typos — must fall - // through to the existing "no cloud provider" error. Pinning this - // rejection contract guards against the synth growing into a - // blanket "any unknown slug points at localhost" matcher. - let config = Config::default(); - for slug in ["openai", "anthropic", "openrouter", "p_random_xyz", "", " "] { - assert!( - synthesize_local_runtime_entry(slug, &config).is_none(), - "{slug:?} must NOT synthesize a local-runtime entry" - ); - } - } - - #[test] - fn parse_models_response_handles_non_object_body() { - // Provider returned a bare array / string / number at the - // top level — not an object at all. Surface as a parse failure - // (not a panic). - for body in [ - serde_json::json!([{"id": "m1"}]), - serde_json::json!("hello"), - serde_json::Value::Null, - ] { - let err = parse_models_response(&body) - .expect_err("non-object body must fail with a clear message"); - assert!( - !err.is_empty(), - "non-object body error must be non-empty: {err}" - ); - } - } - - /// `is_backend_auth_failure` is the polarity guard that decides whether a - /// 401/403 is the OpenHuman backend's expired session (silence + drive - /// reauth) or a third-party BYO-key rejection (actionable, must reach - /// Sentry). Getting this wrong in either direction is a regression: - /// over-matching silences real misconfig; under-matching is TAURI-RUST-N. - #[test] - fn is_backend_auth_failure_only_matches_openhuman_backend_401_403() { - use reqwest::StatusCode; - let backend = crate::openhuman::inference::provider::openhuman_backend::PROVIDER_LABEL; - - assert!(is_backend_auth_failure(backend, StatusCode::UNAUTHORIZED)); - assert!(is_backend_auth_failure(backend, StatusCode::FORBIDDEN)); - - // Non-auth backend statuses stay reportable (real server bugs / transient). - for s in [ - StatusCode::INTERNAL_SERVER_ERROR, - StatusCode::TOO_MANY_REQUESTS, - StatusCode::BAD_REQUEST, - StatusCode::NOT_FOUND, - ] { - assert!( - !is_backend_auth_failure(backend, s), - "backend {s} must not be treated as session-expiry" - ); - } - - // Third-party BYO-key 401/403 (user's own key revoked) must NOT be - // silenced — that is actionable misconfiguration for Sentry. - for provider in ["custom_openai", "OpenAI", "Anthropic", "openrouter"] { - assert!( - !is_backend_auth_failure(provider, StatusCode::UNAUTHORIZED), - "{provider} 401 must reach Sentry as actionable BYO-key error" - ); - assert!( - !is_backend_auth_failure(provider, StatusCode::FORBIDDEN), - "{provider} 403 must reach Sentry as actionable BYO-key error" - ); - } - } - - /// `publish_backend_session_expired` must emit a `SessionExpired` event on - /// the `auth` domain with the canonical source and a sanitized reason, so - /// the credentials subscriber can drive reauth. - #[tokio::test] - async fn publish_backend_session_expired_emits_sanitized_session_expired() { - use crate::core::event_bus::{global, init_global, DomainEvent}; - - init_global(1024); - let mut rx = global().expect("event bus initialized").raw_receiver(); - - // `TEST_MARKER_A` makes this event distinguishable from the sibling - // `chat_completions_backend_401_*` test's event on the shared global - // bus (both run in parallel against the same singleton). The `sk-` - // token probes that `sanitize_api_error` actually scrubs secrets out - // of the SessionExpired reason rather than just emitting the event. - let secret = "sk-LIVEA0123456789abcdefSECRET"; - let msg = format!( - r#"OpenHuman API error (401 Unauthorized): {{"success":false,"error":"TEST_MARKER_A Invalid token {secret}"}}"# - ); - publish_backend_session_expired( - "chat_completions", - crate::openhuman::inference::provider::openhuman_backend::PROVIDER_LABEL, - reqwest::StatusCode::UNAUTHORIZED, - &msg, - ); - - let mut reason_seen: Option = None; - loop { - match rx.try_recv() { - Ok(DomainEvent::SessionExpired { source, reason }) => { - if source == "llm_provider.openhuman_backend" - && reason.contains("TEST_MARKER_A") - { - reason_seen = Some(reason); - break; - } - } - Ok(_) => continue, - Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue, - Err(_) => break, - } - } - let reason = reason_seen.expect( - "publish_backend_session_expired must emit SessionExpired(source=llm_provider.openhuman_backend) carrying TEST_MARKER_A", - ); - assert!( - reason.contains("[REDACTED]"), - "sanitize_api_error must redact the sk- token in the reason: {reason}" - ); - assert!( - !reason.contains(secret), - "raw secret must not survive into the SessionExpired reason: {reason}" - ); - } - - /// End-to-end regression for TAURI-RUST-N: a backend `401 Invalid token` - /// on the hand-rolled `chat_completions` path must publish `SessionExpired` - /// (driving reauth) and surface the typed error — NOT spam Sentry. The - /// provider is labelled exactly like the OpenHuman backend provider, which - /// is what gates the backend-auth-failure branch. - #[tokio::test] - async fn chat_completions_backend_401_publishes_session_expired() { - use crate::core::event_bus::{global, init_global, DomainEvent}; - use axum::routing::post; - - init_global(1024); - let mut rx = global().expect("event bus initialized").raw_receiver(); - - async fn unauthorized_handler() -> Response { - // `TEST_MARKER_B` distinguishes this event from the sibling - // `publish_backend_session_expired_*` test on the shared global - // bus; the `sk-` token probes end-to-end redaction through - // `api_error` → `publish_backend_session_expired`. - ( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ - "success": false, - "error": "TEST_MARKER_B Invalid token sk-LIVEB9876543210fedcbaSECRET" - })), - ) - .into_response() - } - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().expect("local_addr"); - let app = Router::new().route("/chat/completions", post(unauthorized_handler)); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("serve"); - }); - - let provider = - crate::openhuman::inference::provider::compatible::OpenAiCompatibleProvider::new_no_responses_fallback( - crate::openhuman::inference::provider::openhuman_backend::PROVIDER_LABEL, - &format!("http://{addr}"), - Some("expired-jwt"), - crate::openhuman::inference::provider::compatible::AuthStyle::Bearer, - ); - - let err = crate::openhuman::inference::provider::traits::Provider::chat_with_system( - &provider, - None, - "hi", - "reasoning-quick-v1", - 0.0, - ) - .await - .expect_err("backend 401 must surface as an error"); - let msg = err.to_string(); - assert!( - msg.contains("OpenHuman API error (401") && msg.contains("Invalid token"), - "error must carry the backend 401 envelope: {msg}" - ); - - let mut reason_seen: Option = None; - loop { - match rx.try_recv() { - Ok(DomainEvent::SessionExpired { source, reason }) => { - if source == "llm_provider.openhuman_backend" - && reason.contains("TEST_MARKER_B") - { - reason_seen = Some(reason); - break; - } - } - Ok(_) => continue, - Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue, - Err(_) => break, - } - } - let reason = reason_seen.expect( - "backend 401 on chat_completions must publish SessionExpired carrying TEST_MARKER_B, not report to Sentry", - ); - assert!( - reason.contains("[REDACTED]"), - "sanitize_api_error must redact the sk- token end-to-end: {reason}" - ); - assert!( - !reason.contains("sk-LIVEB9876543210fedcbaSECRET"), - "raw secret must not survive into the SessionExpired reason: {reason}" - ); - } - - #[test] - fn synthesize_local_runtime_entry_ollama_respects_config_base_url() { - // The synth must honor `config.local_ai.base_url` (the same - // priority `ollama_base_url_from_config` uses for chat routing). - // This is the path users hit when they point Ollama at a non-loopback - // host (e.g. a LAN box at 192.168.1.5). - let mut config = Config::default(); - config.local_ai.base_url = Some("http://192.168.1.5:11434".to_string()); - let entry = synthesize_local_runtime_entry("ollama", &config) - .expect("ollama with custom base_url must still synthesize"); - assert_eq!( - entry.endpoint, "http://192.168.1.5:11434/v1", - "synth must use config.local_ai.base_url and append /v1 once", - ); - } - - #[test] - fn cloud_providers_entry_takes_precedence_over_local_runtime_synthesis() { - // Pin the precedence: if the user has explicitly added an `ollama` - // entry to `cloud_providers` (e.g. a remote ollama box at - // https://ollama.example.com/v1), that entry MUST win — the synth - // fallback is reached only when the find returns `None`. Mirrors - // the lookup in `list_configured_models_from_config` so a future - // refactor that swaps `find().or_else(synth)` for unconditional - // synthesis fails this test loudly. - let mut config = Config::default(); - config.cloud_providers.push(CloudProviderCreds { - id: "p_ollama_explicit".to_string(), - slug: "ollama".to_string(), - label: "Remote Ollama".to_string(), - endpoint: "https://ollama.example.com/v1".to_string(), - auth_style: AuthStyle::Bearer, - legacy_type: None, - default_model: None, - }); - - let resolved = config - .cloud_providers - .iter() - .find(|e| e.id == "ollama" || e.slug == "ollama") - .cloned() - .or_else(|| synthesize_local_runtime_entry("ollama", &config)) - .expect("either explicit or synth must resolve"); - assert_eq!( - resolved.endpoint, "https://ollama.example.com/v1", - "explicit cloud_providers entry must beat local-runtime synth", - ); - assert_eq!(resolved.auth_style, AuthStyle::Bearer); - } - - #[test] - fn missing_cloud_providers_entry_falls_back_to_local_runtime_synth() { - // The TAURI-RUST-28Z regression contract: when no `ollama` entry - // exists in `cloud_providers` AND the slug is a recognized - // local-runtime alias, the find/synth chain must yield a synthetic - // entry (instead of `None`, which produces the - // "no cloud provider with id or slug 'ollama' found" Sentry error). - let config = Config::default(); - assert!( - config.cloud_providers.is_empty(), - "precondition: clean config has no providers configured", - ); - - let resolved = config - .cloud_providers - .iter() - .find(|e| e.id == "ollama" || e.slug == "ollama") - .cloned() - .or_else(|| synthesize_local_runtime_entry("ollama", &config)); - assert!( - resolved.is_some(), - "ollama must resolve via synth when cloud_providers is empty" - ); - assert_eq!(resolved.unwrap().slug, "ollama"); - } - - #[test] - fn missing_cloud_providers_entry_for_unknown_slug_still_errors() { - // The synth is intentionally narrow: only `ollama` and `lmstudio` - // get fallback routing. An unknown slug with no `cloud_providers` - // match must continue to produce `None` (which the caller surfaces - // as the "no cloud provider" error) — otherwise typos would - // silently route to localhost. - let config = Config::default(); - let resolved = config - .cloud_providers - .iter() - .find(|e| e.id == "tpyo" || e.slug == "tpyo") - .cloned() - .or_else(|| synthesize_local_runtime_entry("tpyo", &config)); - assert!( - resolved.is_none(), - "unknown slug with no cloud_providers entry must NOT synthesize", - ); - } -} +#[path = "ops_tests.rs"] +mod tests; diff --git a/src/openhuman/inference/provider/ops_tests.rs b/src/openhuman/inference/provider/ops_tests.rs new file mode 100644 index 000000000..93c263983 --- /dev/null +++ b/src/openhuman/inference/provider/ops_tests.rs @@ -0,0 +1,1278 @@ +use super::*; +use crate::openhuman::config::schema::cloud_providers::{AuthStyle, CloudProviderCreds}; +use crate::openhuman::config::Config; +use crate::openhuman::credentials::AuthService; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use std::collections::HashMap; +use std::sync::{ + atomic::{AtomicUsize, Ordering as AtomicOrdering}, + Arc, Mutex, +}; +use tempfile::TempDir; + +#[derive(Clone)] +struct ModelProbeState { + key_status: StatusCode, + key_calls: Arc, + model_calls: Arc, + key_authorization: Arc>>>, + model_authorization: Arc>>>, +} + +async fn openrouter_key_handler( + State(state): State, + headers: HeaderMap, +) -> Response { + state.key_calls.fetch_add(1, AtomicOrdering::SeqCst); + state + .key_authorization + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(authorization_header(&headers)); + if state.key_status.is_success() { + Json(serde_json::json!({ + "data": { + "label": "test-key", + "usage": 0 + } + })) + .into_response() + } else { + ( + state.key_status, + Json(serde_json::json!({ + "error": { + "message": "No auth credentials found" + } + })), + ) + .into_response() + } +} + +async fn models_handler(State(state): State, headers: HeaderMap) -> Response { + state.model_calls.fetch_add(1, AtomicOrdering::SeqCst); + state + .model_authorization + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(authorization_header(&headers)); + Json(serde_json::json!({ + "data": [{ + "id": "openrouter/test-model", + "owned_by": "openrouter", + "context_length": 128000 + }] + })) + .into_response() +} + +fn authorization_header(headers: &HeaderMap) -> Option { + headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()) +} + +async fn spawn_openrouter_probe_server(key_status: StatusCode) -> (String, ModelProbeState) { + let state = ModelProbeState { + key_status, + key_calls: Arc::new(AtomicUsize::new(0)), + model_calls: Arc::new(AtomicUsize::new(0)), + key_authorization: Arc::new(Mutex::new(Vec::new())), + model_authorization: Arc::new(Mutex::new(Vec::new())), + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + let app = Router::new() + .route("/key", get(openrouter_key_handler)) + .route("/models", get(models_handler)) + .with_state(state.clone()); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + (format!("http://{addr}"), state) +} + +async fn configure_openrouter_workspace(tmp: &TempDir, endpoint: String, token: &str) -> Config { + let mut config = Config { + config_path: tmp.path().join("config.toml"), + workspace_dir: tmp.path().join("workspace"), + ..Config::default() + }; + config.secrets.encrypt = false; + config.cloud_providers.push(CloudProviderCreds { + id: "p_openrouter_test".to_string(), + slug: "openrouter".to_string(), + label: "OpenRouter".to_string(), + endpoint, + auth_style: AuthStyle::Bearer, + legacy_type: None, + default_model: None, + }); + config.save().await.expect("save config"); + + let auth = AuthService::from_config(&config); + auth.store_provider_token( + &crate::openhuman::inference::provider::factory::auth_key_for_slug("openrouter"), + "default", + token, + HashMap::new(), + true, + ) + .expect("store provider key"); + config +} + +#[test] +fn list_configured_models_accepts_slug() { + // list_configured_models should find a provider by slug when the caller + // passes a slug instead of the opaque random id. This lets the frontend + // call the RPC before the provider config has been persisted (where only + // the slug is stable). + use crate::openhuman::config::schema::cloud_providers::{AuthStyle, CloudProviderCreds}; + use crate::openhuman::config::Config; + + let mut config = Config::default(); + config.cloud_providers.push(CloudProviderCreds { + id: "p_openai_xyz99".to_string(), + slug: "openai".to_string(), + label: "OpenAI".to_string(), + endpoint: "https://api.openai.com/v1".to_string(), + auth_style: AuthStyle::Bearer, + legacy_type: None, + default_model: None, + }); + + // The find predicate must match on slug. + let found_by_slug = config + .cloud_providers + .iter() + .find(|e| e.id == "openai" || e.slug == "openai"); + assert!( + found_by_slug.is_some(), + "slug lookup must find the provider" + ); + assert_eq!(found_by_slug.unwrap().id, "p_openai_xyz99"); + + // The find predicate must still match on id. + let found_by_id = config + .cloud_providers + .iter() + .find(|e| e.id == "p_openai_xyz99" || e.slug == "p_openai_xyz99"); + assert!(found_by_id.is_some(), "id lookup must still work"); +} + +#[test] +fn openrouter_detection_matches_builtin_slug_or_host() { + let provider = |slug: &str, endpoint: &str| CloudProviderCreds { + id: format!("p_{slug}"), + slug: slug.to_string(), + label: slug.to_string(), + endpoint: endpoint.to_string(), + auth_style: AuthStyle::Bearer, + legacy_type: None, + default_model: None, + }; + + assert!(is_openrouter_provider(&provider( + "openrouter", + "http://127.0.0.1:1234" + ))); + assert!(is_openrouter_provider(&provider( + "custom-router", + "https://openrouter.ai/api/v1" + ))); + assert!(is_openrouter_provider(&provider( + "custom-router", + "https://oauth.openrouter.ai/api/v1" + ))); + assert!(!is_openrouter_provider(&provider( + "custom-openai", + "https://api.openai.com/v1" + ))); +} + +#[tokio::test] +async fn openrouter_invalid_key_fails_before_models_catalog_probe() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (endpoint, state) = spawn_openrouter_probe_server(StatusCode::UNAUTHORIZED).await; + let config = configure_openrouter_workspace(&tmp, endpoint, "bad-openrouter-key").await; + + let err = list_configured_models_from_config("openrouter", &config) + .await + .expect_err("invalid OpenRouter key must fail"); + + assert!( + err.contains("OpenRouter key validation returned 401"), + "unexpected error: {err}" + ); + assert_eq!(state.key_calls.load(AtomicOrdering::SeqCst), 1); + assert_eq!( + state.model_calls.load(AtomicOrdering::SeqCst), + 0, + "invalid OpenRouter credentials must not fall through to /models" + ); +} + +#[tokio::test] +async fn openrouter_valid_key_allows_models_catalog_probe() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (endpoint, state) = spawn_openrouter_probe_server(StatusCode::OK).await; + let config = configure_openrouter_workspace(&tmp, endpoint, "valid-openrouter-key").await; + + let outcome = list_configured_models_from_config("openrouter", &config) + .await + .expect("valid OpenRouter key should list models"); + + assert_eq!(state.key_calls.load(AtomicOrdering::SeqCst), 1); + assert_eq!(state.model_calls.load(AtomicOrdering::SeqCst), 1); + assert_eq!(outcome.value["models"][0]["id"], "openrouter/test-model"); +} + +#[tokio::test] +async fn openrouter_key_is_trimmed_for_validation_and_catalog_probe() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (endpoint, state) = spawn_openrouter_probe_server(StatusCode::OK).await; + let config = configure_openrouter_workspace(&tmp, endpoint, " valid-openrouter-key\r\n").await; + + list_configured_models_from_config("openrouter", &config) + .await + .expect("trimmed OpenRouter key should list models"); + + let key_authorization = state + .key_authorization + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let model_authorization = state + .model_authorization + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + assert_eq!( + key_authorization, + vec![Some("Bearer valid-openrouter-key".to_string())] + ); + assert_eq!( + model_authorization, + vec![Some("Bearer valid-openrouter-key".to_string())] + ); +} + +/// Spawn a minimal axum server that always returns 404 for the /models endpoint. +/// Used to verify that providers without model listing return an empty list, +/// not an error (Sentry issue TAURI-RUST-1Z). +async fn spawn_models_404_server() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + let app = axum::Router::new().route( + "/models", + axum::routing::get(|| async { + (axum::http::StatusCode::NOT_FOUND, "Not Found").into_response() + }), + ); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + format!("http://{addr}") +} + +#[tokio::test] +async fn models_404_returns_empty_list_not_error() { + // Providers that return 404 on /models (e.g. DeepSeek, Kimi, custom proxies) + // must yield an empty model list, not an Err. Returning an Err was firing a + // Sentry error for every `inference_list_models` call (TAURI-RUST-1Z, 819 events). + let tmp = tempfile::tempdir().expect("tempdir"); + let endpoint = spawn_models_404_server().await; + + let mut config = Config { + config_path: tmp.path().join("config.toml"), + workspace_dir: tmp.path().join("workspace"), + ..Config::default() + }; + config.secrets.encrypt = false; + config.cloud_providers.push(CloudProviderCreds { + id: "p_custom_test".to_string(), + slug: "custom-no-models".to_string(), + label: "Custom (no /models)".to_string(), + endpoint, + auth_style: AuthStyle::Bearer, + legacy_type: None, + default_model: None, + }); + + let outcome = list_configured_models_from_config("custom-no-models", &config) + .await + .expect("404 from /models must succeed with an empty list"); + + let models = outcome.value["models"] + .as_array() + .expect("response must have a `models` array"); + assert!( + models.is_empty(), + "expected empty model list for a 404 /models endpoint, got: {models:?}" + ); + assert_eq!( + outcome.value["unsupported"], + serde_json::Value::Bool(true), + "unsupported flag must be set to true for 404 providers" + ); +} + +#[test] +fn factory_backend() { + assert!(create_backend_inference_provider( + None, + None, + None, + &ProviderRuntimeOptions::default() + ) + .is_ok()); +} + +#[test] +fn skips_sentry_report_for_transient_upstream_statuses() { + // Transient statuses — 429 rate-limit, 408 client timeout, and 502/503/504 + // gateway-layer failures — are retried by reliable.rs. The aggregate + // "all providers exhausted" event still fires for genuine outages. + // Reporting each attempt individually floods Sentry (OPENHUMAN-TAURI-2E + // ~1393 events, 84 ~1050 events, T ~871 events). + for transient in [ + reqwest::StatusCode::TOO_MANY_REQUESTS, + reqwest::StatusCode::REQUEST_TIMEOUT, + reqwest::StatusCode::BAD_GATEWAY, + reqwest::StatusCode::SERVICE_UNAVAILABLE, + reqwest::StatusCode::GATEWAY_TIMEOUT, + ] { + assert!( + !should_report_provider_http_failure(transient), + "transient status {transient} must not trigger per-attempt Sentry report" + ); + } + // Auth + permanent server faults remain reportable — those are + // misconfiguration or genuine bugs, not transient capacity issues. + for reportable in [ + reqwest::StatusCode::UNAUTHORIZED, + reqwest::StatusCode::FORBIDDEN, + reqwest::StatusCode::BAD_REQUEST, + reqwest::StatusCode::NOT_FOUND, + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + ] { + assert!( + should_report_provider_http_failure(reportable), + "status {reportable} must still report to Sentry" + ); + } +} + +// Confirm the budget-exhausted suppression predicate is scoped correctly. +// These tests exercise the real production function, not a duplicate. +mod budget_exhausted_suppression { + use super::*; + + const BUDGET_BODY: &str = "Insufficient budget"; + const UNRELATED_BODY: &str = "Invalid request: model not found"; + + #[test] + fn budget_exhausted_400_is_suppressed() { + assert!(is_budget_exhausted_http_400( + reqwest::StatusCode::BAD_REQUEST, + BUDGET_BODY, + )); + } + + #[test] + fn budget_exhausted_400_is_case_insensitive() { + assert!(is_budget_exhausted_http_400( + reqwest::StatusCode::BAD_REQUEST, + "budget exceeded — ADD credits to continue", + )); + } + + #[test] + fn budget_exhausted_500_is_not_suppressed() { + // A 500 is a server bug, not expected user-state — keep reporting. + assert!(!is_budget_exhausted_http_400( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + BUDGET_BODY, + )); + } + + #[test] + fn budget_exhausted_400_unrelated_body_is_not_suppressed() { + assert!(!is_budget_exhausted_http_400( + reqwest::StatusCode::BAD_REQUEST, + UNRELATED_BODY, + )); + } + + #[test] + fn budget_exhausted_402_is_not_suppressed() { + assert!(!is_budget_exhausted_http_400( + reqwest::StatusCode::PAYMENT_REQUIRED, + BUDGET_BODY, + )); + } + + #[test] + fn budget_exhausted_empty_body_is_not_suppressed() { + assert!(!is_budget_exhausted_http_400( + reqwest::StatusCode::BAD_REQUEST, + "", + )); + } +} + +// Tests for the rate-limit body suppression guard added to `api_error`. +// Exercises `is_upstream_rate_limit_message` with the exact body shapes that +// produced OPENHUMAN-TAURI-S (~6 984 events from HTTP 500 wrapping a +// "429 rate limit exceeded" body) and OPENHUMAN-TAURI-6Y / -2E. +mod rate_limit_body_suppression { + use crate::core::observability::is_upstream_rate_limit_message; + + /// HTTP 500 with a `"429 rate limit exceeded"` body must be detected + /// as a rate-limit signal so the guard in `api_error` can skip the + /// Sentry report (OPENHUMAN-TAURI-S). + #[test] + fn http_500_with_429_body_phrase_is_rate_limited() { + let body = r#"{"success":false,"error":"429 rate limit exceeded, please try again later"}"# + .to_ascii_lowercase(); + assert!( + is_upstream_rate_limit_message(&body), + "500-body with '429 rate limit exceeded' must be detected as rate-limited" + ); + } + + /// HTTP 500 with an `"upstream rate limit exceeded"` body + /// (OPENHUMAN-TAURI-6Y shape). + #[test] + fn http_500_with_upstream_rate_limit_body_is_rate_limited() { + let body = r#"{"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'. Please retry shortly.","details":{"provider":"gmi"}}"# + .to_ascii_lowercase(); + assert!( + is_upstream_rate_limit_message(&body), + "500-body with 'upstream rate limit exceeded' must be detected" + ); + } + + /// OpenAI / Anthropic `"rate_limit_error"` type body. + #[test] + fn body_with_rate_limit_error_type_is_rate_limited() { + let body = r#"{"error":{"message":"Rate limit exceeded. Please retry after a brief wait.","type":"rate_limit_error"}}"# + .to_ascii_lowercase(); + assert!( + is_upstream_rate_limit_message(&body), + "body with 'rate_limit_error' type must be detected" + ); + } + + /// Unrelated 500 body must NOT be detected as rate-limited. + #[test] + fn http_500_unrelated_body_is_not_rate_limited() { + let body = r#"{"success":false,"error":"internal server error: database unavailable"}"# + .to_ascii_lowercase(); + assert!( + !is_upstream_rate_limit_message(&body), + "unrelated 500 body must not be detected as rate-limited" + ); + } +} + +mod provider_access_policy_suppression { + use super::*; + + const ACCESS_TERMINATED_BODY: &str = + "{\"error\":{\"message\":\"Kimi For Coding is currently only available for Coding Agents.\",\"type\":\"access_terminated_error\"}}"; + + #[test] + fn access_terminated_403_is_suppressed() { + assert!(is_provider_access_policy_denied_http_403( + reqwest::StatusCode::FORBIDDEN, + ACCESS_TERMINATED_BODY, + )); + } + + #[test] + fn access_terminated_non_403_is_not_suppressed() { + assert!(!is_provider_access_policy_denied_http_403( + reqwest::StatusCode::BAD_REQUEST, + ACCESS_TERMINATED_BODY, + )); + } + + #[test] + fn unrelated_403_is_not_suppressed() { + assert!(!is_provider_access_policy_denied_http_403( + reqwest::StatusCode::FORBIDDEN, + "{\"error\":{\"message\":\"forbidden\"}}", + )); + } +} + +// Exercises the real `is_provider_config_rejection_http` decision used +// by `api_error`, including the inverted provider-aware polarity. +mod provider_config_rejection_suppression { + use super::*; + + // The exact #2079 Sentry body shape. + const TIER_LEAK_BODY: &str = + "The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \ + but you passed reasoning-v1."; + // #2076 Moonshot Kimi K2 temperature constraint. + const TEMP_BODY: &str = "invalid temperature: only 1 is allowed for this model"; + + #[test] + fn custom_provider_4xx_config_rejection_is_suppressed() { + assert!(is_provider_config_rejection_http( + reqwest::StatusCode::BAD_REQUEST, + "custom_openai", + TIER_LEAK_BODY, + )); + assert!(is_provider_config_rejection_http( + reqwest::StatusCode::BAD_REQUEST, + "custom_openai", + TEMP_BODY, + )); + // 404 "model does not exist" is the same user-config class. + assert!(is_provider_config_rejection_http( + reqwest::StatusCode::NOT_FOUND, + "custom_openai", + "The model `gpt-5.5` does not exist or you do not have access to it.", + )); + } + + #[test] + fn openhuman_backend_same_body_is_not_suppressed() { + // Inverted polarity: for tier-leak / temperature / litellm / + // OpenRouter-style phrases, the OpenHuman backend never + // emits them, so the same body from our OWN backend would + // mean we sent it a bad request — a real regression that + // must still reach Sentry. (Mirror of the 401/403 backend + // rule.) + assert!(!is_provider_config_rejection_http( + reqwest::StatusCode::BAD_REQUEST, + openhuman_backend::PROVIDER_LABEL, + TIER_LEAK_BODY, + )); + assert!(!is_provider_config_rejection_http( + reqwest::StatusCode::BAD_REQUEST, + openhuman_backend::PROVIDER_LABEL, + TEMP_BODY, + )); + } + + #[test] + fn openhuman_backend_openai_compatible_unknown_model_is_suppressed() { + // TAURI-RUST-2Z1 — the OpenHuman backend DOES emit the + // OpenAI-compatible "Model 'X' is not available. Use GET + // /openai/v1/models …" wire body for user-configured unknown + // model ids (here `MiniMax-M2.7-highspeed` and two + // `custom:`-prefixed fallback variants from the user's own + // `model_fallbacks` config). That's user-state, not a + // regression — drop the polarity guard for this specific + // shape so the per-attempt event stops reaching Sentry. + // (The aggregate sibling TAURI-RUST-2Z2 is already covered by + // `expected_error_kind` via the broader message-only + // classifier.) + for body in [ + r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Model 'MiniMax-M2.7-highspeed' is not available. Use GET /openai/v1/models to list available models."}"#, + r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Model 'custom:MiniMax-M2.7' is not available. Use GET /openai/v1/models to list available models."}"#, + ] { + assert!( + is_provider_config_rejection_http( + reqwest::StatusCode::BAD_REQUEST, + openhuman_backend::PROVIDER_LABEL, + body, + ), + "TAURI-RUST-2Z1 body must be suppressed for openhuman backend: {body:?}" + ); + } + } + + #[test] + fn server_error_is_not_suppressed() { + // A 5xx is a server bug, not user-config — keep reporting. + assert!(!is_provider_config_rejection_http( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "custom_openai", + TIER_LEAK_BODY, + )); + } + + #[test] + fn transient_429_is_not_suppressed_here() { + // 429 is transient; handled by should_report_provider_http_failure, + // not this classifier (must not be swallowed as user-config). + assert!(!is_provider_config_rejection_http( + reqwest::StatusCode::TOO_MANY_REQUESTS, + "custom_openai", + TIER_LEAK_BODY, + )); + } + + #[test] + fn unrelated_4xx_body_is_not_suppressed() { + assert!(!is_provider_config_rejection_http( + reqwest::StatusCode::BAD_REQUEST, + "custom_openai", + "Bad request: missing required field 'messages'", + )); + } + + #[test] + fn log_helper_runs_without_panicking() { + // Covers the demotion log path taken by `api_error` when a + // custom provider rejects the user's model/param config. No + // tracing subscriber in unit tests, so this is a pure smoke. + log_provider_config_rejection( + "api_error", + "custom_openai", + Some("reasoning-v1"), + reqwest::StatusCode::BAD_REQUEST, + ); + } +} + +mod context_window_exceeded_suppression { + use super::*; + + #[test] + fn classifies_tauri_rust_501_custom_provider_500_body() { + // TAURI-RUST-501: the custom-provider 500 wire body. The + // matcher is status-agnostic, so the 500 mis-report is caught + // (the provider api_error cascade routes it to + // `log_context_window_exceeded` instead of `report_error`). + assert!(is_context_window_exceeded_message( + "{\"error\":{\"code\":500,\"message\":\"Context size has been exceeded.\",\"type\":\"server_error\"}}" + )); + } + + #[test] + fn classifies_established_context_overflow_phrasings() { + // The phrasings the reliable.rs non-retryable classifier + // recognized before this refactor must all still match through + // the shared single-source matcher. + for body in [ + "This model's maximum context length is 8192 tokens", + "request exceeds the context window of this model", + "context length exceeded", + "too many tokens in the prompt", + "token limit exceeded", + "prompt is too long for the selected model", + "input is too long", + ] { + assert!( + is_context_window_exceeded_message(body), + "should match context-overflow body: {body}" + ); + } + } + + #[test] + fn does_not_match_unrelated_bodies() { + for body in [ + "rate limit exceeded, retry after 30s", + "Invalid request: model not found", + "Insufficient budget", + "tool call exceeded the allowed budget", + ] { + assert!( + !is_context_window_exceeded_message(body), + "must NOT match unrelated body: {body}" + ); + } + } + + #[test] + fn token_rate_limits_are_not_context_overflow() { + // Token-count phrases collide with per-minute token RATE limits. + // Those are transient 429s that must stay retryable and keep + // reaching Sentry — they must NOT be classified as context + // overflow (CodeRabbit review of #2820). The rate-limit marker + // disambiguates. + for body in [ + "Rate limit reached: too many tokens per minute (TPM) for this org", + "rate_limit_exceeded: token limit exceeded, retry after 12s", + "You have hit too many tokens per min; try again in 30s", + ] { + assert!( + !is_context_window_exceeded_message(body), + "TPM rate-limit must NOT match as context overflow: {body}" + ); + } + // …but a token-count overflow with NO rate marker still matches. + assert!(is_context_window_exceeded_message( + "Request rejected: too many tokens in the input for this model" + )); + } + + #[test] + fn log_helper_runs_without_panicking() { + // Smoke for the demotion path taken by `api_error` — no tracing + // subscriber in unit tests. + log_context_window_exceeded( + "api_error", + "custom_openai", + None, + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + ); + } +} + +#[test] +fn test_sanitize_api_error_utf8() { + let input = "🦀".repeat(MAX_API_ERROR_CHARS + 10); + let sanitized = sanitize_api_error(&input); + assert!(sanitized.ends_with("...")); + // Should truncate at MAX_API_ERROR_CHARS crabs + let crabs_count = sanitized.chars().filter(|c| *c == '🦀').count(); + assert_eq!(crabs_count, MAX_API_ERROR_CHARS); +} + +// ── TAURI-RUST-12: list_models JSON parse error must surface body ────── +// +// `response.json()` previously dropped the body when decoding failed, so +// Sentry saw `[providers][list_models] failed to parse JSON: error decoding +// response body` with no clue what the server actually returned. The fix +// reads the body as text first, parses with `serde_json::from_str`, and +// appends a sanitized + truncated snippet to the error string so the +// failure is diagnosable from the log line alone. + +#[derive(Clone)] +struct StaticResponse { + status: StatusCode, + body: &'static str, +} + +async fn static_models_handler(State(s): State) -> Response { + (s.status, s.body).into_response() +} + +async fn spawn_static_models_server(status: StatusCode, body: &'static str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + let app = Router::new() + .route("/models", get(static_models_handler)) + .with_state(StaticResponse { status, body }); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + format!("http://{addr}") +} + +async fn configure_generic_workspace(tmp: &TempDir, endpoint: String) -> Config { + // Non-`openrouter` slug so the OpenRouter pre-validation path is + // skipped and the test hits `/models` directly. + let mut config = Config { + config_path: tmp.path().join("config.toml"), + workspace_dir: tmp.path().join("workspace"), + ..Config::default() + }; + config.secrets.encrypt = false; + config.cloud_providers.push(CloudProviderCreds { + id: "p_generic_test".to_string(), + slug: "generic-test".to_string(), + label: "Generic".to_string(), + endpoint, + auth_style: AuthStyle::None, + legacy_type: None, + default_model: None, + }); + config.save().await.expect("save config"); + config +} + +#[tokio::test] +async fn list_models_html_body_returns_diagnostic_snippet() { + // Captive-portal / proxy-login wire shape: 200 OK with HTML. + let tmp = tempfile::tempdir().expect("tempdir"); + let html = "Sign incaptive portal"; + let endpoint = spawn_static_models_server(StatusCode::OK, html).await; + let config = configure_generic_workspace(&tmp, endpoint).await; + + let err = list_configured_models_from_config("generic-test", &config) + .await + .expect_err("HTML body must not parse as JSON"); + + assert!( + err.contains("failed to parse JSON"), + "error must keep canonical prefix: {err}" + ); + assert!( + err.contains("captive portal") || err.contains("Sign in") || err.contains("html"), + "error must include a body snippet for diagnosis: {err}" + ); +} + +#[tokio::test] +async fn list_models_empty_body_returns_diagnostic_error() { + // Some misconfigured load balancers return 200 with an empty body. + let tmp = tempfile::tempdir().expect("tempdir"); + let endpoint = spawn_static_models_server(StatusCode::OK, "").await; + let config = configure_generic_workspace(&tmp, endpoint).await; + + let err = list_configured_models_from_config("generic-test", &config) + .await + .expect_err("empty body must not parse as JSON"); + + assert!( + err.contains("failed to parse JSON"), + "error must keep canonical prefix: {err}" + ); +} + +#[tokio::test] +async fn list_models_valid_json_still_succeeds() { + // Regression guard: the new text-then-parse path must still accept + // a valid `/models` JSON response. + let tmp = tempfile::tempdir().expect("tempdir"); + let body = r#"{"data":[{"id":"some-model","owned_by":"vendor","context_length":4096}]}"#; + let endpoint = spawn_static_models_server(StatusCode::OK, body).await; + let config = configure_generic_workspace(&tmp, endpoint).await; + + let outcome = list_configured_models_from_config("generic-test", &config) + .await + .expect("valid JSON must list models"); + assert_eq!(outcome.value["models"][0]["id"], "some-model"); +} + +// ── parse_models_response (TAURI-RUST-4Y) ────────────────────────────── +// +// Before this fix the `/models` parser collapsed "no `data` field" and +// "`data` field present but not an array" into a single misleading +// error string: `"provider response missing `data` array — endpoint is +// not OpenAI-compatible (got keys: data, object)"` — the keys list +// included `data`, contradicting the "missing" claim. The split +// surfaces the actual JSON-type mismatch so future Sentry events on +// this code path are triageable instead of looking like the parser +// is hallucinating. + +#[test] +fn parse_models_response_returns_models_for_well_formed_data_array() { + // Happy path — exact OpenAI `/models` shape, must yield model ids + // and `owned_by` / `context_length` projections from each entry. + let body = serde_json::json!({ + "object": "list", + "data": [ + { "id": "m1", "owned_by": "openai", "context_length": 8192 }, + { "id": "m2", "owned_by": "openai" }, + { "id": "m3", "context_window": 4096 }, + ], + }); + let models = parse_models_response(&body).expect("well-formed body must parse"); + assert_eq!(models.len(), 3); + assert_eq!(models[0].id, "m1"); + assert_eq!(models[0].owned_by.as_deref(), Some("openai")); + assert_eq!(models[0].context_window, Some(8192)); + assert_eq!(models[2].id, "m3"); + assert_eq!(models[2].owned_by, None); + assert_eq!(models[2].context_window, Some(4096)); +} + +#[test] +fn parse_models_response_distinguishes_missing_data_field_from_wrong_type() { + // (1) `data` field completely absent — original Sentry message + // shape, kept for backward fingerprint with the well-known + // "wrong endpoint" misconfiguration. + let body = serde_json::json!({ "object": "list", "models": [] }); + let err = parse_models_response(&body).expect_err("no data field must fail"); + assert!( + err.contains("missing `data` field"), + "no-data error should say `missing`: {err}" + ); + assert!( + err.contains("object, models") || err.contains("models, object"), + "no-data error should list actual keys: {err}" + ); + + // (2) `data` field present but wrong type — TAURI-RUST-4Y verbatim + // shape (`object` + `data` keys both present, but `data` isn't an + // array). The error MUST NOT say "missing" — it must surface the + // actual JSON type so triage knows what shape the provider sent. + for (label, value) in [ + ( + "object", + serde_json::json!({"object":"error","message":"boom"}), + ), + ("string", serde_json::json!("models go here")), + ("null", serde_json::Value::Null), + ("bool", serde_json::json!(true)), + ("number", serde_json::json!(42)), + ] { + let body = serde_json::json!({ "object": "list", "data": value }); + let err = parse_models_response(&body).expect_err("wrong-type data must fail"); + assert!( + !err.contains("missing"), + "wrong-type error must not say `missing` ({label}): {err}" + ); + assert!( + err.contains(label), + "wrong-type error must name the actual JSON kind ({label}): {err}" + ); + } +} + +// ── synthesize_local_runtime_entry (TAURI-RUST-28Z fallback) ──────────── + +#[test] +fn synthesize_local_runtime_entry_ollama_returns_v1_endpoint_with_no_auth() { + // Sentry TAURI-RUST-28Z fires when `inference_list_models("ollama")` + // runs against a config that has no `ollama` cloud_providers entry. + // The synth fallback must produce an entry routed to Ollama's + // OpenAI-compatible `/v1/models` surface at the resolved base URL, + // with `AuthStyle::None` so the probe runs without an Authorization + // header (loopback Ollama accepts unauthenticated requests). + let config = Config::default(); + let entry = synthesize_local_runtime_entry("ollama", &config) + .expect("ollama must produce a synthetic entry"); + assert_eq!(entry.slug, "ollama"); + assert_eq!(entry.auth_style, AuthStyle::None); + assert!( + entry.endpoint.ends_with("/v1"), + "ollama endpoint must terminate at /v1 so `/models` hits the OpenAI-compat surface; got {}", + entry.endpoint + ); +} + +#[test] +fn synthesize_local_runtime_entry_lmstudio_returns_v1_endpoint_with_no_auth() { + // LM Studio's default `lm_studio_base_url` already terminates at + // `/v1`; the synth must preserve that and select `AuthStyle::None` + // so the probe doesn't attach a bearer header (LM Studio runs + // unauthenticated on loopback). + let config = Config::default(); + let entry = synthesize_local_runtime_entry("lmstudio", &config) + .expect("lmstudio must produce a synthetic entry"); + assert_eq!(entry.slug, "lmstudio"); + assert_eq!(entry.auth_style, AuthStyle::None); + assert!( + entry.endpoint.ends_with("/v1"), + "lmstudio endpoint must terminate at /v1; got {}", + entry.endpoint + ); +} + +#[test] +fn synthesize_local_runtime_entry_returns_none_for_unknown_slug() { + // Only `ollama` and `lmstudio` are the recognized local-runtime + // aliases. Every other slug — built-in cloud providers (`openai`, + // `anthropic`), opaque ids (`p_random_xyz`), or typos — must fall + // through to the existing "no cloud provider" error. Pinning this + // rejection contract guards against the synth growing into a + // blanket "any unknown slug points at localhost" matcher. + let config = Config::default(); + for slug in ["openai", "anthropic", "openrouter", "p_random_xyz", "", " "] { + assert!( + synthesize_local_runtime_entry(slug, &config).is_none(), + "{slug:?} must NOT synthesize a local-runtime entry" + ); + } +} + +#[test] +fn parse_models_response_handles_non_object_body() { + // Provider returned a bare array / string / number at the + // top level — not an object at all. Surface as a parse failure + // (not a panic). + for body in [ + serde_json::json!([{"id": "m1"}]), + serde_json::json!("hello"), + serde_json::Value::Null, + ] { + let err = parse_models_response(&body) + .expect_err("non-object body must fail with a clear message"); + assert!( + !err.is_empty(), + "non-object body error must be non-empty: {err}" + ); + } +} + +/// `is_backend_auth_failure` is the polarity guard that decides whether a +/// 401/403 is the OpenHuman backend's expired session (silence + drive +/// reauth) or a third-party BYO-key rejection (actionable, must reach +/// Sentry). Getting this wrong in either direction is a regression: +/// over-matching silences real misconfig; under-matching is TAURI-RUST-N. +#[test] +fn is_backend_auth_failure_only_matches_openhuman_backend_401_403() { + use reqwest::StatusCode; + let backend = crate::openhuman::inference::provider::openhuman_backend::PROVIDER_LABEL; + + assert!(is_backend_auth_failure(backend, StatusCode::UNAUTHORIZED)); + assert!(is_backend_auth_failure(backend, StatusCode::FORBIDDEN)); + + // Non-auth backend statuses stay reportable (real server bugs / transient). + for s in [ + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::TOO_MANY_REQUESTS, + StatusCode::BAD_REQUEST, + StatusCode::NOT_FOUND, + ] { + assert!( + !is_backend_auth_failure(backend, s), + "backend {s} must not be treated as session-expiry" + ); + } + + // Third-party BYO-key 401/403 (user's own key revoked) must NOT be + // silenced — that is actionable misconfiguration for Sentry. + for provider in ["custom_openai", "OpenAI", "Anthropic", "openrouter"] { + assert!( + !is_backend_auth_failure(provider, StatusCode::UNAUTHORIZED), + "{provider} 401 must reach Sentry as actionable BYO-key error" + ); + assert!( + !is_backend_auth_failure(provider, StatusCode::FORBIDDEN), + "{provider} 403 must reach Sentry as actionable BYO-key error" + ); + } +} + +/// `publish_backend_session_expired` must emit a `SessionExpired` event on +/// the `auth` domain with the canonical source and a sanitized reason, so +/// the credentials subscriber can drive reauth. +#[tokio::test] +async fn publish_backend_session_expired_emits_sanitized_session_expired() { + use crate::core::event_bus::{global, init_global, DomainEvent}; + + init_global(1024); + let mut rx = global().expect("event bus initialized").raw_receiver(); + + // `TEST_MARKER_A` makes this event distinguishable from the sibling + // `chat_completions_backend_401_*` test's event on the shared global + // bus (both run in parallel against the same singleton). The `sk-` + // token probes that `sanitize_api_error` actually scrubs secrets out + // of the SessionExpired reason rather than just emitting the event. + let secret = "sk-LIVEA0123456789abcdefSECRET"; + let msg = format!( + r#"OpenHuman API error (401 Unauthorized): {{"success":false,"error":"TEST_MARKER_A Invalid token {secret}"}}"# + ); + publish_backend_session_expired( + "chat_completions", + crate::openhuman::inference::provider::openhuman_backend::PROVIDER_LABEL, + reqwest::StatusCode::UNAUTHORIZED, + &msg, + ); + + let mut reason_seen: Option = None; + loop { + match rx.try_recv() { + Ok(DomainEvent::SessionExpired { source, reason }) => { + if source == "llm_provider.openhuman_backend" && reason.contains("TEST_MARKER_A") { + reason_seen = Some(reason); + break; + } + } + Ok(_) => continue, + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue, + Err(_) => break, + } + } + let reason = reason_seen.expect( + "publish_backend_session_expired must emit SessionExpired(source=llm_provider.openhuman_backend) carrying TEST_MARKER_A", + ); + assert!( + reason.contains("[REDACTED]"), + "sanitize_api_error must redact the sk- token in the reason: {reason}" + ); + assert!( + !reason.contains(secret), + "raw secret must not survive into the SessionExpired reason: {reason}" + ); +} + +/// End-to-end regression for TAURI-RUST-N: a backend `401 Invalid token` +/// on the hand-rolled `chat_completions` path must publish `SessionExpired` +/// (driving reauth) and surface the typed error — NOT spam Sentry. The +/// provider is labelled exactly like the OpenHuman backend provider, which +/// is what gates the backend-auth-failure branch. +#[tokio::test] +async fn chat_completions_backend_401_publishes_session_expired() { + use crate::core::event_bus::{global, init_global, DomainEvent}; + use axum::routing::post; + + init_global(1024); + let mut rx = global().expect("event bus initialized").raw_receiver(); + + async fn unauthorized_handler() -> Response { + // `TEST_MARKER_B` distinguishes this event from the sibling + // `publish_backend_session_expired_*` test on the shared global + // bus; the `sk-` token probes end-to-end redaction through + // `api_error` → `publish_backend_session_expired`. + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "success": false, + "error": "TEST_MARKER_B Invalid token sk-LIVEB9876543210fedcbaSECRET" + })), + ) + .into_response() + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + let app = Router::new().route("/chat/completions", post(unauthorized_handler)); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + + let provider = + crate::openhuman::inference::provider::compatible::OpenAiCompatibleProvider::new_no_responses_fallback( + crate::openhuman::inference::provider::openhuman_backend::PROVIDER_LABEL, + &format!("http://{addr}"), + Some("expired-jwt"), + crate::openhuman::inference::provider::compatible::AuthStyle::Bearer, + ); + + let err = crate::openhuman::inference::provider::traits::Provider::chat_with_system( + &provider, + None, + "hi", + "reasoning-quick-v1", + 0.0, + ) + .await + .expect_err("backend 401 must surface as an error"); + let msg = err.to_string(); + assert!( + msg.contains("OpenHuman API error (401") && msg.contains("Invalid token"), + "error must carry the backend 401 envelope: {msg}" + ); + + let mut reason_seen: Option = None; + loop { + match rx.try_recv() { + Ok(DomainEvent::SessionExpired { source, reason }) => { + if source == "llm_provider.openhuman_backend" && reason.contains("TEST_MARKER_B") { + reason_seen = Some(reason); + break; + } + } + Ok(_) => continue, + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue, + Err(_) => break, + } + } + let reason = reason_seen.expect( + "backend 401 on chat_completions must publish SessionExpired carrying TEST_MARKER_B, not report to Sentry", + ); + assert!( + reason.contains("[REDACTED]"), + "sanitize_api_error must redact the sk- token end-to-end: {reason}" + ); + assert!( + !reason.contains("sk-LIVEB9876543210fedcbaSECRET"), + "raw secret must not survive into the SessionExpired reason: {reason}" + ); +} + +#[test] +fn synthesize_local_runtime_entry_ollama_respects_config_base_url() { + // The synth must honor `config.local_ai.base_url` (the same + // priority `ollama_base_url_from_config` uses for chat routing). + // This is the path users hit when they point Ollama at a non-loopback + // host (e.g. a LAN box at 192.168.1.5). + let mut config = Config::default(); + config.local_ai.base_url = Some("http://192.168.1.5:11434".to_string()); + let entry = synthesize_local_runtime_entry("ollama", &config) + .expect("ollama with custom base_url must still synthesize"); + assert_eq!( + entry.endpoint, "http://192.168.1.5:11434/v1", + "synth must use config.local_ai.base_url and append /v1 once", + ); +} + +#[test] +fn cloud_providers_entry_takes_precedence_over_local_runtime_synthesis() { + // Pin the precedence: if the user has explicitly added an `ollama` + // entry to `cloud_providers` (e.g. a remote ollama box at + // https://ollama.example.com/v1), that entry MUST win — the synth + // fallback is reached only when the find returns `None`. Mirrors + // the lookup in `list_configured_models_from_config` so a future + // refactor that swaps `find().or_else(synth)` for unconditional + // synthesis fails this test loudly. + let mut config = Config::default(); + config.cloud_providers.push(CloudProviderCreds { + id: "p_ollama_explicit".to_string(), + slug: "ollama".to_string(), + label: "Remote Ollama".to_string(), + endpoint: "https://ollama.example.com/v1".to_string(), + auth_style: AuthStyle::Bearer, + legacy_type: None, + default_model: None, + }); + + let resolved = config + .cloud_providers + .iter() + .find(|e| e.id == "ollama" || e.slug == "ollama") + .cloned() + .or_else(|| synthesize_local_runtime_entry("ollama", &config)) + .expect("either explicit or synth must resolve"); + assert_eq!( + resolved.endpoint, "https://ollama.example.com/v1", + "explicit cloud_providers entry must beat local-runtime synth", + ); + assert_eq!(resolved.auth_style, AuthStyle::Bearer); +} + +#[test] +fn missing_cloud_providers_entry_falls_back_to_local_runtime_synth() { + // The TAURI-RUST-28Z regression contract: when no `ollama` entry + // exists in `cloud_providers` AND the slug is a recognized + // local-runtime alias, the find/synth chain must yield a synthetic + // entry (instead of `None`, which produces the + // "no cloud provider with id or slug 'ollama' found" Sentry error). + let config = Config::default(); + assert!( + config.cloud_providers.is_empty(), + "precondition: clean config has no providers configured", + ); + + let resolved = config + .cloud_providers + .iter() + .find(|e| e.id == "ollama" || e.slug == "ollama") + .cloned() + .or_else(|| synthesize_local_runtime_entry("ollama", &config)); + assert!( + resolved.is_some(), + "ollama must resolve via synth when cloud_providers is empty" + ); + assert_eq!(resolved.unwrap().slug, "ollama"); +} + +#[test] +fn missing_cloud_providers_entry_for_unknown_slug_still_errors() { + // The synth is intentionally narrow: only `ollama` and `lmstudio` + // get fallback routing. An unknown slug with no `cloud_providers` + // match must continue to produce `None` (which the caller surfaces + // as the "no cloud provider" error) — otherwise typos would + // silently route to localhost. + let config = Config::default(); + let resolved = config + .cloud_providers + .iter() + .find(|e| e.id == "tpyo" || e.slug == "tpyo") + .cloned() + .or_else(|| synthesize_local_runtime_entry("tpyo", &config)); + assert!( + resolved.is_none(), + "unknown slug with no cloud_providers entry must NOT synthesize", + ); +} diff --git a/src/openhuman/inference/provider/router.rs b/src/openhuman/inference/provider/router.rs index d97065974..ee3e25818 100644 --- a/src/openhuman/inference/provider/router.rs +++ b/src/openhuman/inference/provider/router.rs @@ -244,5 +244,5 @@ impl Provider for RouterProvider { } #[cfg(test)] -#[path = "router_test.rs"] -mod router_test; +#[path = "router_tests.rs"] +mod router_tests; diff --git a/src/openhuman/inference/provider/router_test.rs b/src/openhuman/inference/provider/router_tests.rs similarity index 100% rename from src/openhuman/inference/provider/router_test.rs rename to src/openhuman/inference/provider/router_tests.rs diff --git a/src/openhuman/integrations/test_support.rs b/src/openhuman/integrations/test_support.rs index 3cb2801ea..4e78fd870 100644 --- a/src/openhuman/integrations/test_support.rs +++ b/src/openhuman/integrations/test_support.rs @@ -793,5 +793,5 @@ pub async fn spawn_fake_integration_backend() -> FakeIntegrationBackend { } #[cfg(test)] -#[path = "test_support_test.rs"] +#[path = "test_support_tests.rs"] mod tests; diff --git a/src/openhuman/integrations/test_support_test.rs b/src/openhuman/integrations/test_support_tests.rs similarity index 100% rename from src/openhuman/integrations/test_support_test.rs rename to src/openhuman/integrations/test_support_tests.rs diff --git a/src/openhuman/mcp_server/tools.rs b/src/openhuman/mcp_server/tools.rs index 1a182769c..98f3a6e70 100644 --- a/src/openhuman/mcp_server/tools.rs +++ b/src/openhuman/mcp_server/tools.rs @@ -1416,915 +1416,5 @@ fn json_type_name(value: &Value) -> &'static str { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn list_tools_exposes_base_mcp_surface_when_searxng_disabled() { - let config = crate::openhuman::config::Config::default(); - let result = list_tools_result_for_config(&config); - let names = result["tools"] - .as_array() - .expect("tools array") - .iter() - .map(|tool| tool["name"].as_str().expect("tool name")) - .collect::>(); - - assert_eq!( - names, - vec![ - "core.list_tools", - "core.tool_instructions", - "agent.list_subagents", - "agent.run_subagent", - "memory.search", - "memory.recall", - "tree.read_chunk", - "tree.browse", - "tree.top_entities", - "tree.list_sources", - "memory.store", - "memory.note", - "tree.tag", - ] - ); - } - - #[test] - fn list_tools_emits_annotations_for_every_tool() { - // Exercise the searxng-enabled config so the annotation contract covers - // every shipping tool, not just the base set. - let mut config = crate::openhuman::config::Config::default(); - config.searxng.enabled = true; - let result = list_tools_result_for_config(&config); - let tools = result["tools"].as_array().expect("tools array"); - for tool in tools { - let name = tool["name"].as_str().expect("tool name"); - assert!( - tool.get("annotations") - .map(Value::is_object) - .unwrap_or(false), - "tool `{name}` is missing a serialized `annotations` object", - ); - } - } - - #[test] - fn read_only_tools_are_marked_read_only_and_closed_world() { - // Every tool except the act-capable ones reads local OpenHuman state - // (memory tree / agent registry) or queries an external read-only - // search engine. Per MCP spec defaults these would be - // `readOnlyHint: false` and `openWorldHint: true`, so we MUST set - // `readOnlyHint` explicitly to communicate accurate safety affordances - // to clients. (`searxng_search` is read-only but openWorld, so it - // verifies the read-only axis here and is exempt from the - // openWorld=false check below.) - let act_tool_names = [ - "agent.run_subagent", - "memory.store", - "memory.note", - "tree.tag", - ]; - let open_world_read_only = ["searxng_search"]; - for spec in tool_specs() { - if act_tool_names.contains(&spec.name) { - continue; - } - let annotations = &spec.annotations; - assert_eq!( - annotations.get("readOnlyHint").and_then(Value::as_bool), - Some(true), - "expected `{}` to advertise readOnlyHint=true", - spec.name - ); - let expected_open_world = open_world_read_only.contains(&spec.name); - assert_eq!( - annotations.get("openWorldHint").and_then(Value::as_bool), - Some(expected_open_world), - "expected `{}` to advertise openWorldHint={}", - spec.name, - expected_open_world - ); - // Per spec these are meaningful only when readOnlyHint == false. - // Emitting them on a read-only tool would be misleading. - assert!( - annotations.get("destructiveHint").is_none(), - "read-only tool `{}` should not emit destructiveHint", - spec.name - ); - assert!( - annotations.get("idempotentHint").is_none(), - "read-only tool `{}` should not emit idempotentHint", - spec.name - ); - } - } - - #[test] - fn run_subagent_annotations_signal_act_semantics() { - let spec = tool_specs() - .into_iter() - .find(|spec| spec.name == "agent.run_subagent") - .expect("agent.run_subagent must be registered"); - assert_eq!( - spec.annotations - .get("readOnlyHint") - .and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - spec.annotations - .get("destructiveHint") - .and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - spec.annotations - .get("idempotentHint") - .and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - spec.annotations - .get("openWorldHint") - .and_then(Value::as_bool), - Some(true) - ); - } - - #[test] - fn list_tools_includes_searxng_when_enabled() { - let mut config = crate::openhuman::config::Config::default(); - config.searxng.enabled = true; - let result = list_tools_result_for_config(&config); - let names = result["tools"] - .as_array() - .expect("tools array") - .iter() - .map(|tool| tool["name"].as_str().expect("tool name")) - .collect::>(); - - assert!(names.contains(&"searxng_search")); - } - - #[test] - fn mapped_rpc_methods_are_registered() { - for spec in tool_specs() { - if let Some(rpc_method) = spec.rpc_method { - assert!( - all::schema_for_rpc_method(rpc_method).is_some(), - "missing registered RPC method for {} -> {}", - spec.name, - rpc_method - ); - } - } - } - - #[test] - fn build_rpc_params_parses_run_subagent_arguments() { - let params = build_rpc_params( - "agent.run_subagent", - json!({ - "agent_id": "researcher", - "prompt": "Find the root cause." - }), - ) - .expect("params should parse"); - - assert_eq!( - params.get("agent_id").and_then(Value::as_str), - Some("researcher") - ); - assert_eq!( - params.get("prompt").and_then(Value::as_str), - Some("Find the root cause.") - ); - } - - #[test] - fn build_rpc_params_rejects_extra_run_subagent_fields() { - let err = build_rpc_params( - "agent.run_subagent", - json!({ - "agent_id": "researcher", - "prompt": "Find the root cause.", - "toolkit": "gmail" - }), - ) - .expect_err("unexpected field should be rejected"); - - assert!( - matches!(err, ToolCallError::InvalidParams(message) if message.contains("unexpected argument")) - ); - } - - #[test] - fn memory_search_params_trim_query_and_use_default_k() { - let params = build_rpc_params( - "memory.search", - json!({ - "query": " phoenix migration ", - }), - ) - .expect("params"); - - assert_eq!(params["query"], "phoenix migration"); - assert_eq!(params["k"], DEFAULT_LIMIT); - } - - #[test] - fn searxng_search_params_accept_optional_fields() { - let params = build_rpc_params( - "searxng_search", - json!({ - "query": " rust async ", - "categories": ["web", "news"], - "language": " en ", - "max_results": 12 - }), - ) - .expect("params"); - - assert_eq!(params["query"], "rust async"); - assert_eq!(params["categories"], json!(["web", "news"])); - assert_eq!(params["language"], "en"); - assert_eq!(params["max_results"], 12); - } - - #[test] - fn searxng_search_rejects_unknown_category() { - let err = build_rpc_params( - "searxng_search", - json!({ - "query": "rust", - "categories": ["videos"] - }), - ) - .expect_err("must reject"); - - assert!(err.message().contains("unsupported SearXNG category")); - } - - #[test] - fn searxng_search_rejects_max_results_above_max() { - let err = build_rpc_params( - "searxng_search", - json!({ - "query": "rust", - "max_results": SEARXNG_MAX_RESULTS + 1 - }), - ) - .expect_err("must reject"); - - assert!(err.message().contains("must not exceed")); - } - - #[test] - fn memory_search_rejects_k_above_max() { - // Reject (don't silent-clamp) so the LLM can self-correct on the next - // call. Silent clamping makes the model believe it got the page size - // it asked for and prevents the corrective feedback loop. - let err = build_rpc_params( - "memory.search", - json!({ - "query": "phoenix", - "k": MAX_LIMIT + 1 - }), - ) - .expect_err("must reject k > MAX_LIMIT"); - - let message = err.message(); - assert!( - message.contains("must not exceed"), - "error should mention the cap, got: {message}" - ); - assert!( - message.contains(&MAX_LIMIT.to_string()), - "error should mention the limit value, got: {message}" - ); - } - - #[test] - fn memory_search_accepts_k_at_max() { - let params = build_rpc_params( - "memory.search", - json!({ "query": "phoenix", "k": MAX_LIMIT }), - ) - .expect("k = MAX_LIMIT must be accepted (boundary inclusive)"); - assert_eq!(params["k"], MAX_LIMIT); - } - - #[test] - fn tool_call_error_invalid_params_maps_to_jsonrpc_invalid_params() { - let err = ToolCallError::InvalidParams("missing query".to_string()); - assert_eq!(err.code(), -32602); - assert_eq!(err.jsonrpc_message(), "Invalid params"); - assert_eq!(err.message(), "missing query"); - } - - #[test] - fn tool_call_error_internal_maps_to_jsonrpc_internal_error() { - // Server-side failures (config load, missing resources) must surface - // as `-32603 Internal error`, not `-32602 Invalid params`, so the MCP - // client doesn't mislead the user / LLM into retrying with different - // arguments. - let err = ToolCallError::Internal("disk read failed".to_string()); - assert_eq!(err.code(), -32603); - assert_eq!(err.jsonrpc_message(), "Internal error"); - assert_eq!(err.message(), "disk read failed"); - } - - #[test] - fn memory_recall_requires_query() { - let err = build_rpc_params("memory.recall", json!({})).expect_err("must reject"); - assert!(err.message().contains("missing required argument `query`")); - } - - #[test] - fn memory_search_rejects_undocumented_limit_alias() { - let err = build_rpc_params( - "memory.search", - json!({ - "query": "phoenix", - "limit": 5 - }), - ) - .expect_err("must reject"); - - assert!(err.message().contains("unexpected argument `limit`")); - } - - #[test] - fn tree_read_chunk_maps_chunk_id_to_controller_id() { - let params = - build_rpc_params("tree.read_chunk", json!({"chunk_id": "abc"})).expect("params"); - assert_eq!(params["id"], "abc"); - assert!(!params.contains_key("chunk_id")); - } - - #[test] - fn tree_read_chunk_rejects_unknown_arguments() { - let err = build_rpc_params( - "tree.read_chunk", - json!({ - "chunk_id": "abc", - "unused": true - }), - ) - .expect_err("must reject"); - - assert!(err.message().contains("unexpected argument `unused`")); - } - - #[test] - fn non_object_arguments_are_invalid() { - let err = build_rpc_params("memory.search", json!("query")).expect_err("must reject"); - assert!(err.message().contains("arguments must be an object")); - } - - // ── tree.browse ──────────────────────────────────────────────────── - - #[test] - fn tree_browse_no_args_sends_default_limit_only() { - // Empty filter is a valid request — the controller treats unset filters - // as "no constraint" — and the MCP layer still applies its own DEFAULT_LIMIT - // so the LLM doesn't accidentally pull the controller's 50-row default - // when it asked for nothing. - let params = build_rpc_params("tree.browse", json!({})).expect("empty args are valid"); - assert_eq!(params.len(), 1); - assert_eq!(params["limit"], DEFAULT_LIMIT); - } - - #[test] - fn tree_browse_passes_through_filters_and_renames_k_to_limit() { - let params = build_rpc_params( - "tree.browse", - json!({ - "source_kinds": ["email", "chat"], - "source_ids": ["acme-thread-1"], - "entity_ids": ["person:Alice"], - "since_ms": 1_700_000_000_000_i64, - "until_ms": 1_710_000_000_000_i64, - "query": "Q3 plan", - "k": 20, - "offset": 10 - }), - ) - .expect("params"); - - assert_eq!(params["limit"], 20); - assert!(!params.contains_key("k")); - assert_eq!(params["source_kinds"], json!(["email", "chat"])); - assert_eq!(params["source_ids"], json!(["acme-thread-1"])); - assert_eq!(params["entity_ids"], json!(["person:Alice"])); - assert_eq!(params["since_ms"], 1_700_000_000_000_i64); - assert_eq!(params["until_ms"], 1_710_000_000_000_i64); - assert_eq!(params["query"], "Q3 plan"); - assert_eq!(params["offset"], 10); - } - - #[test] - fn tree_browse_rejects_k_above_max() { - // Same reject-don't-clamp policy as memory.search / memory.recall so the - // LLM gets corrective feedback instead of silently receiving fewer rows - // than it asked for. - let err = build_rpc_params("tree.browse", json!({ "k": MAX_LIMIT + 1 })) - .expect_err("must reject k > MAX_LIMIT"); - assert!(err.message().contains("must not exceed")); - } - - #[test] - fn tree_browse_rejects_unknown_argument() { - let err = build_rpc_params("tree.browse", json!({ "limit": 10 })) - .expect_err("must reject the controller's `limit` alias"); - assert!(err.message().contains("unexpected argument `limit`")); - } - - #[test] - fn tree_browse_rejects_non_array_source_kinds() { - let err = build_rpc_params("tree.browse", json!({ "source_kinds": "email" })) - .expect_err("must reject scalar where array is required"); - assert!(err.message().contains("must be an array of strings")); - } - - #[test] - fn tree_browse_rejects_non_integer_since_ms() { - let err = build_rpc_params("tree.browse", json!({ "since_ms": "yesterday" })) - .expect_err("must reject ISO-style date for ms field"); - assert!(err.message().contains("must be an integer")); - } - - #[test] - fn tree_browse_drops_blank_array_entries_silently() { - // Empty / whitespace strings inside an array are tolerated — clients - // sometimes send `["", "email"]` after a partial UI selection and the - // intent ("filter to email") is unambiguous. A fully-blank array is OK - // too and produces an empty filter (same as omitting the field). - let params = build_rpc_params( - "tree.browse", - json!({ "source_kinds": ["", "email", " "] }), - ) - .expect("blank entries don't fail the whole call"); - assert_eq!(params["source_kinds"], json!(["email"])); - } - - // ── tree.top_entities ────────────────────────────────────────────── - - #[test] - fn tree_top_entities_defaults_limit_and_omits_kind() { - let params = - build_rpc_params("tree.top_entities", json!({})).expect("empty args are valid"); - assert_eq!(params["limit"], DEFAULT_LIMIT); - assert!(!params.contains_key("kind")); - } - - #[test] - fn tree_top_entities_passes_kind_through_and_caps_limit_at_max() { - let params = build_rpc_params( - "tree.top_entities", - json!({ "kind": "person", "k": MAX_LIMIT }), - ) - .expect("k = MAX_LIMIT is the boundary, inclusive"); - assert_eq!(params["kind"], "person"); - assert_eq!(params["limit"], MAX_LIMIT); - } - - #[test] - fn tree_top_entities_rejects_empty_kind() { - // Blank kind is a client bug — the controller would happily run it as - // "no filter" but that's exactly what *omitting* the field already - // means. Rejecting nudges the LLM to drop the field instead. - let err = build_rpc_params("tree.top_entities", json!({ "kind": " " })) - .expect_err("must reject blank-only kind"); - assert!(err.message().contains("must not be empty")); - } - - // ── tree.list_sources ────────────────────────────────────────────── - - #[test] - fn tree_list_sources_accepts_empty_args() { - let params = - build_rpc_params("tree.list_sources", json!({})).expect("no args is the common case"); - assert!(params.is_empty()); - } - - #[test] - fn tree_list_sources_passes_user_email_hint() { - let params = build_rpc_params( - "tree.list_sources", - json!({ "user_email_hint": "me@example.com" }), - ) - .expect("params"); - assert_eq!(params["user_email_hint"], "me@example.com"); - } - - #[test] - fn tree_list_sources_rejects_unknown_argument() { - let err = build_rpc_params("tree.list_sources", json!({ "limit": 5 })) - .expect_err("list_sources takes no pagination"); - assert!(err.message().contains("unexpected argument `limit`")); - } - - // ── memory.store ────────────────────────────────────────────────── - - #[test] - fn memory_store_requires_title_and_content() { - let err = build_rpc_params("memory.store", json!({})).expect_err("must reject"); - assert!(err.message().contains("missing required argument `title`")); - - let err = - build_rpc_params("memory.store", json!({ "title": "T" })).expect_err("must reject"); - assert!(err - .message() - .contains("missing required argument `content`")); - } - - #[test] - fn memory_store_defaults_namespace_to_mcp() { - let params = build_rpc_params( - "memory.store", - json!({ "title": "My note", "content": "Hello world" }), - ) - .expect("params"); - - assert_eq!(params["namespace"], "mcp"); - assert_eq!(params["title"], "My note"); - assert_eq!(params["content"], "Hello world"); - assert_eq!(params["source_type"], "mcp"); - assert!(params["key"].as_str().unwrap().starts_with("mcp-store-")); - } - - #[test] - fn memory_store_accepts_custom_namespace_and_tags() { - let params = build_rpc_params( - "memory.store", - json!({ - "title": "Project Plan", - "content": "Q3 milestones", - "namespace": "work", - "tags": ["project", "planning"] - }), - ) - .expect("params"); - - assert_eq!(params["namespace"], "work"); - assert_eq!(params["tags"], json!(["project", "planning"])); - } - - #[test] - fn memory_store_rejects_unknown_argument() { - let err = build_rpc_params( - "memory.store", - json!({ "title": "T", "content": "C", "priority": "high" }), - ) - .expect_err("must reject"); - assert!(err.message().contains("unexpected argument `priority`")); - } - - // ── memory.note ─────────────────────────────────────────────────── - - #[test] - fn memory_note_requires_chunk_id_and_note_text() { - let err = build_rpc_params("memory.note", json!({})).expect_err("must reject"); - assert!(err - .message() - .contains("missing required argument `chunk_id`")); - - let err = - build_rpc_params("memory.note", json!({ "chunk_id": "abc" })).expect_err("must reject"); - assert!(err - .message() - .contains("missing required argument `note_text`")); - } - - #[test] - fn memory_note_builds_annotation_document() { - let params = build_rpc_params( - "memory.note", - json!({ "chunk_id": "chunk-42", "note_text": "Important context" }), - ) - .expect("params"); - - assert_eq!(params["namespace"], "mcp"); - assert_eq!(params["key"], "mcp-note-chunk-42"); - assert!(params["title"].as_str().unwrap().contains("chunk-42")); - assert!(params["content"] - .as_str() - .unwrap() - .contains("Important context")); - assert!(params["content"] - .as_str() - .unwrap() - .contains("chunk_id=chunk-42")); - assert_eq!(params["metadata"]["annotates_chunk_id"], "chunk-42"); - assert_eq!(params["source_type"], "mcp"); - } - - #[test] - fn memory_note_rejects_unknown_argument() { - let err = build_rpc_params( - "memory.note", - json!({ "chunk_id": "abc", "note_text": "N", "extra": true }), - ) - .expect_err("must reject"); - assert!(err.message().contains("unexpected argument `extra`")); - } - - // ── tree.tag ────────────────────────────────────────────────────── - - #[test] - fn tree_tag_requires_chunk_id_and_tags() { - let err = build_rpc_params("tree.tag", json!({})).expect_err("must reject"); - assert!( - err.message() - .contains("missing required argument `chunk_id`"), - "got: {}", - err.message() - ); - - let err = - build_rpc_params("tree.tag", json!({ "chunk_id": "abc" })).expect_err("must reject"); - assert!( - err.message().contains("missing required argument `tags`"), - "got: {}", - err.message() - ); - } - - #[test] - fn tree_tag_rejects_empty_tags_array() { - let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": [] })) - .expect_err("must reject"); - assert!( - err.message().contains("at least one non-empty string"), - "got: {}", - err.message() - ); - } - - #[test] - fn tree_tag_rejects_all_blank_tags() { - // After blank-trim the list is empty — same failure mode as `[]`. - let err = build_rpc_params( - "tree.tag", - json!({ "chunk_id": "abc", "tags": [" ", ""] }), - ) - .expect_err("must reject"); - assert!( - err.message().contains("at least one non-empty string"), - "got: {}", - err.message() - ); - } - - #[test] - fn tree_tag_rejects_non_string_tags() { - // Numeric entries inside `tags` get caught by the string-array helper. - let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": ["ok", 42] })) - .expect_err("must reject"); - assert!( - err.message() - .contains("argument `tags` must contain only strings"), - "got: {}", - err.message() - ); - } - - #[test] - fn tree_tag_builds_tag_record_document() { - let params = build_rpc_params( - "tree.tag", - json!({ "chunk_id": "chunk-42", "tags": ["todo", "q3-planning"] }), - ) - .expect("params"); - - // Document key is deterministic on chunk_id only → re-tagging - // the same chunk upserts. - assert_eq!(params["namespace"], "mcp"); - assert_eq!(params["key"], "mcp-tag-chunk-42"); - assert_eq!(params["source_type"], "mcp"); - - // Title surfaces the target chunk for human-readable recall. - assert!( - params["title"] - .as_str() - .expect("title is a string") - .contains("chunk-42"), - "title was: {}", - params["title"] - ); - - // Top-level `tags` flows to the document tag index (queryable - // via `doc_list` / search filters) — this is the key differentiator - // from `memory.note` whose payload is opaque free-form text. - assert_eq!(params["tags"], json!(["todo", "q3-planning"])); - - // Metadata carries the back-reference plus a mirrored tag list, - // so consumers reading the metadata view don't need to also - // join against the top-level `tags` field. - let metadata = params["metadata"] - .as_object() - .expect("metadata is an object"); - assert_eq!(metadata["tags_for_chunk_id"], "chunk-42"); - assert_eq!(metadata["applied_tags"], json!(["todo", "q3-planning"])); - } - - #[test] - fn tree_tag_trims_blanks_but_keeps_real_tags() { - // Mixed list — blanks are silently dropped (matches existing - // `optional_string_array` behaviour) but the resulting set is - // still non-empty so the call succeeds. - let params = build_rpc_params( - "tree.tag", - json!({ "chunk_id": "chunk-7", "tags": [" important ", "", " ", "todo"] }), - ) - .expect("params"); - - assert_eq!(params["tags"], json!(["important", "todo"])); - } - - #[test] - fn tree_tag_rejects_empty_chunk_id() { - let err = build_rpc_params("tree.tag", json!({ "chunk_id": "", "tags": ["todo"] })) - .expect_err("must reject"); - assert!( - err.message() - .contains("argument `chunk_id` must not be empty"), - "got: {}", - err.message() - ); - } - - #[test] - fn tree_tag_rejects_unknown_argument() { - let err = build_rpc_params( - "tree.tag", - json!({ "chunk_id": "abc", "tags": ["t"], "priority": "high" }), - ) - .expect_err("must reject"); - assert!( - err.message().contains("unexpected argument `priority`"), - "got: {}", - err.message() - ); - } - - #[test] - fn tree_tag_rejects_oversize_tag_array() { - // Per-graycyrus #2316 review: cap the tag-array length so a - // misbehaving client can't flood a chunk's tag-record document - // with hundreds of categorical labels. Builds an over-cap - // array and asserts the dedicated rejection message. - let oversize: Vec = (0..(TREE_TAG_MAX_TAGS + 1)) - .map(|i| format!("tag-{i}")) - .collect(); - let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": oversize })) - .expect_err("must reject"); - assert!( - err.message().contains("accepts at most"), - "got: {}", - err.message() - ); - } - - #[test] - fn tree_tag_rejects_oversize_individual_tag() { - // Per-graycyrus #2316 review: a single oversize tag is almost - // certainly free-form text that should be `memory.note` instead - // of going through the categorical tag surface — reject up-front - // so the misuse is visible rather than silently writing a giant - // token into the queryable `tags` index. - let oversize_tag = "a".repeat(TREE_TAG_MAX_TAG_LENGTH + 1); - let err = build_rpc_params( - "tree.tag", - json!({ "chunk_id": "abc", "tags": [oversize_tag] }), - ) - .expect_err("must reject"); - assert!(err.message().contains("exceeds"), "got: {}", err.message()); - } - - #[test] - fn tree_tag_accepts_max_size_tags() { - // Boundary: exactly TREE_TAG_MAX_TAGS entries (the cap is - // "at most N", not "fewer than N") with each entry at exactly - // TREE_TAG_MAX_TAG_LENGTH chars must succeed. Locks the - // inclusive-vs-exclusive bound so a future off-by-one - // refactor breaks the test, not user calls. - let max_tags: Vec = (0..TREE_TAG_MAX_TAGS) - .map(|i| format!("tag-{i:0width$}", width = TREE_TAG_MAX_TAG_LENGTH - 4)) - .collect(); - // Sanity: each entry is == TREE_TAG_MAX_TAG_LENGTH chars. - assert!(max_tags.iter().all(|t| t.len() == TREE_TAG_MAX_TAG_LENGTH)); - let params = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": max_tags })) - .expect("at the cap must succeed"); - // The built params should preserve all TREE_TAG_MAX_TAGS entries. - assert_eq!( - params["tags"].as_array().expect("tags is array").len(), - TREE_TAG_MAX_TAGS - ); - } - - #[tokio::test] - async fn call_tool_records_write_argument_rejection() { - let _env_lock = crate::openhuman::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|err| err.into_inner()); - let tmp = tempfile::tempdir().expect("tempdir"); - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); - } - let config = config_rpc::load_config_with_timeout() - .await - .expect("config"); - - let err = call_tool("memory.store", json!({ "title": "T" }), "mcp:test") - .await - .expect_err("missing content should reject"); - assert!( - err.message() - .contains("missing required argument `content`"), - "got: {}", - err.message() - ); - - let mut rows = Vec::new(); - for _ in 0..50 { - rows = crate::openhuman::mcp_audit::list_writes( - &config, - &crate::openhuman::mcp_audit::McpWriteListQuery::default(), - ) - .expect("list writes"); - if rows.len() == 1 { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - assert_eq!(rows.len(), 1); - assert!(!rows[0].success); - assert_eq!(rows[0].tool_name, "memory.store"); - assert_eq!(rows[0].client_info, "mcp:test"); - assert!(rows[0] - .error_message - .as_deref() - .unwrap_or_default() - .contains("missing required argument `content`")); - assert!(rows[0].args_summary.get("content").is_none()); - - unsafe { - std::env::remove_var("OPENHUMAN_WORKSPACE"); - } - } - - // ── slug_from ───────────────────────────────────────────────────── - - #[test] - fn slug_from_produces_clean_slug() { - assert_eq!(slug_from("Hello World!"), "hello-world"); - assert_eq!(slug_from(" spaces "), "spaces"); - assert_eq!(slug_from("CamelCase123"), "camelcase123"); - assert_eq!(slug_from("a--b"), "a-b"); - } - - #[test] - fn slug_from_truncates_long_titles() { - let long = "a".repeat(100); - let slug = slug_from(&long); - assert!(slug.len() <= 64); - } - - #[test] - fn slug_from_returns_hash_fallback_for_non_alphanumeric_titles() { - // Non-alphanumeric titles should produce "untitled-" with a - // stable, deterministic hash suffix. - let slug_bang = slug_from("!!!"); - let slug_at = slug_from("@@@"); - assert!(slug_bang.starts_with("untitled-"), "got: {slug_bang}"); - assert!(slug_at.starts_with("untitled-"), "got: {slug_at}"); - // Different inputs → different slugs - assert_ne!(slug_bang, slug_at); - // Empty title also gets a fallback - assert!(slug_from("").starts_with("untitled-")); - // Stable across calls - assert_eq!(slug_from("!!!"), slug_bang); - } - - #[test] - fn slug_from_unicode_only_titles_are_unique_and_stable() { - let chinese = slug_from("会议记录"); - let russian = slug_from("Протокол"); - let emoji = slug_from("🦀🚀"); - // All produce hash-based fallbacks - assert!(chinese.starts_with("untitled-"), "got: {chinese}"); - assert!(russian.starts_with("untitled-"), "got: {russian}"); - assert!(emoji.starts_with("untitled-"), "got: {emoji}"); - // All distinct - assert_ne!(chinese, russian); - assert_ne!(chinese, emoji); - assert_ne!(russian, emoji); - // Stable - assert_eq!(slug_from("会议记录"), chinese); - assert_eq!(slug_from("Протокол"), russian); - } -} +#[path = "tools_tests.rs"] +mod tests; diff --git a/src/openhuman/mcp_server/tools_tests.rs b/src/openhuman/mcp_server/tools_tests.rs new file mode 100644 index 000000000..bf7af5be5 --- /dev/null +++ b/src/openhuman/mcp_server/tools_tests.rs @@ -0,0 +1,906 @@ +use super::*; + +#[test] +fn list_tools_exposes_base_mcp_surface_when_searxng_disabled() { + let config = crate::openhuman::config::Config::default(); + let result = list_tools_result_for_config(&config); + let names = result["tools"] + .as_array() + .expect("tools array") + .iter() + .map(|tool| tool["name"].as_str().expect("tool name")) + .collect::>(); + + assert_eq!( + names, + vec![ + "core.list_tools", + "core.tool_instructions", + "agent.list_subagents", + "agent.run_subagent", + "memory.search", + "memory.recall", + "tree.read_chunk", + "tree.browse", + "tree.top_entities", + "tree.list_sources", + "memory.store", + "memory.note", + "tree.tag", + ] + ); +} + +#[test] +fn list_tools_emits_annotations_for_every_tool() { + // Exercise the searxng-enabled config so the annotation contract covers + // every shipping tool, not just the base set. + let mut config = crate::openhuman::config::Config::default(); + config.searxng.enabled = true; + let result = list_tools_result_for_config(&config); + let tools = result["tools"].as_array().expect("tools array"); + for tool in tools { + let name = tool["name"].as_str().expect("tool name"); + assert!( + tool.get("annotations") + .map(Value::is_object) + .unwrap_or(false), + "tool `{name}` is missing a serialized `annotations` object", + ); + } +} + +#[test] +fn read_only_tools_are_marked_read_only_and_closed_world() { + // Every tool except the act-capable ones reads local OpenHuman state + // (memory tree / agent registry) or queries an external read-only + // search engine. Per MCP spec defaults these would be + // `readOnlyHint: false` and `openWorldHint: true`, so we MUST set + // `readOnlyHint` explicitly to communicate accurate safety affordances + // to clients. (`searxng_search` is read-only but openWorld, so it + // verifies the read-only axis here and is exempt from the + // openWorld=false check below.) + let act_tool_names = [ + "agent.run_subagent", + "memory.store", + "memory.note", + "tree.tag", + ]; + let open_world_read_only = ["searxng_search"]; + for spec in tool_specs() { + if act_tool_names.contains(&spec.name) { + continue; + } + let annotations = &spec.annotations; + assert_eq!( + annotations.get("readOnlyHint").and_then(Value::as_bool), + Some(true), + "expected `{}` to advertise readOnlyHint=true", + spec.name + ); + let expected_open_world = open_world_read_only.contains(&spec.name); + assert_eq!( + annotations.get("openWorldHint").and_then(Value::as_bool), + Some(expected_open_world), + "expected `{}` to advertise openWorldHint={}", + spec.name, + expected_open_world + ); + // Per spec these are meaningful only when readOnlyHint == false. + // Emitting them on a read-only tool would be misleading. + assert!( + annotations.get("destructiveHint").is_none(), + "read-only tool `{}` should not emit destructiveHint", + spec.name + ); + assert!( + annotations.get("idempotentHint").is_none(), + "read-only tool `{}` should not emit idempotentHint", + spec.name + ); + } +} + +#[test] +fn run_subagent_annotations_signal_act_semantics() { + let spec = tool_specs() + .into_iter() + .find(|spec| spec.name == "agent.run_subagent") + .expect("agent.run_subagent must be registered"); + assert_eq!( + spec.annotations + .get("readOnlyHint") + .and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + spec.annotations + .get("destructiveHint") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + spec.annotations + .get("idempotentHint") + .and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + spec.annotations + .get("openWorldHint") + .and_then(Value::as_bool), + Some(true) + ); +} + +#[test] +fn list_tools_includes_searxng_when_enabled() { + let mut config = crate::openhuman::config::Config::default(); + config.searxng.enabled = true; + let result = list_tools_result_for_config(&config); + let names = result["tools"] + .as_array() + .expect("tools array") + .iter() + .map(|tool| tool["name"].as_str().expect("tool name")) + .collect::>(); + + assert!(names.contains(&"searxng_search")); +} + +#[test] +fn mapped_rpc_methods_are_registered() { + for spec in tool_specs() { + if let Some(rpc_method) = spec.rpc_method { + assert!( + all::schema_for_rpc_method(rpc_method).is_some(), + "missing registered RPC method for {} -> {}", + spec.name, + rpc_method + ); + } + } +} + +#[test] +fn build_rpc_params_parses_run_subagent_arguments() { + let params = build_rpc_params( + "agent.run_subagent", + json!({ + "agent_id": "researcher", + "prompt": "Find the root cause." + }), + ) + .expect("params should parse"); + + assert_eq!( + params.get("agent_id").and_then(Value::as_str), + Some("researcher") + ); + assert_eq!( + params.get("prompt").and_then(Value::as_str), + Some("Find the root cause.") + ); +} + +#[test] +fn build_rpc_params_rejects_extra_run_subagent_fields() { + let err = build_rpc_params( + "agent.run_subagent", + json!({ + "agent_id": "researcher", + "prompt": "Find the root cause.", + "toolkit": "gmail" + }), + ) + .expect_err("unexpected field should be rejected"); + + assert!( + matches!(err, ToolCallError::InvalidParams(message) if message.contains("unexpected argument")) + ); +} + +#[test] +fn memory_search_params_trim_query_and_use_default_k() { + let params = build_rpc_params( + "memory.search", + json!({ + "query": " phoenix migration ", + }), + ) + .expect("params"); + + assert_eq!(params["query"], "phoenix migration"); + assert_eq!(params["k"], DEFAULT_LIMIT); +} + +#[test] +fn searxng_search_params_accept_optional_fields() { + let params = build_rpc_params( + "searxng_search", + json!({ + "query": " rust async ", + "categories": ["web", "news"], + "language": " en ", + "max_results": 12 + }), + ) + .expect("params"); + + assert_eq!(params["query"], "rust async"); + assert_eq!(params["categories"], json!(["web", "news"])); + assert_eq!(params["language"], "en"); + assert_eq!(params["max_results"], 12); +} + +#[test] +fn searxng_search_rejects_unknown_category() { + let err = build_rpc_params( + "searxng_search", + json!({ + "query": "rust", + "categories": ["videos"] + }), + ) + .expect_err("must reject"); + + assert!(err.message().contains("unsupported SearXNG category")); +} + +#[test] +fn searxng_search_rejects_max_results_above_max() { + let err = build_rpc_params( + "searxng_search", + json!({ + "query": "rust", + "max_results": SEARXNG_MAX_RESULTS + 1 + }), + ) + .expect_err("must reject"); + + assert!(err.message().contains("must not exceed")); +} + +#[test] +fn memory_search_rejects_k_above_max() { + // Reject (don't silent-clamp) so the LLM can self-correct on the next + // call. Silent clamping makes the model believe it got the page size + // it asked for and prevents the corrective feedback loop. + let err = build_rpc_params( + "memory.search", + json!({ + "query": "phoenix", + "k": MAX_LIMIT + 1 + }), + ) + .expect_err("must reject k > MAX_LIMIT"); + + let message = err.message(); + assert!( + message.contains("must not exceed"), + "error should mention the cap, got: {message}" + ); + assert!( + message.contains(&MAX_LIMIT.to_string()), + "error should mention the limit value, got: {message}" + ); +} + +#[test] +fn memory_search_accepts_k_at_max() { + let params = build_rpc_params( + "memory.search", + json!({ "query": "phoenix", "k": MAX_LIMIT }), + ) + .expect("k = MAX_LIMIT must be accepted (boundary inclusive)"); + assert_eq!(params["k"], MAX_LIMIT); +} + +#[test] +fn tool_call_error_invalid_params_maps_to_jsonrpc_invalid_params() { + let err = ToolCallError::InvalidParams("missing query".to_string()); + assert_eq!(err.code(), -32602); + assert_eq!(err.jsonrpc_message(), "Invalid params"); + assert_eq!(err.message(), "missing query"); +} + +#[test] +fn tool_call_error_internal_maps_to_jsonrpc_internal_error() { + // Server-side failures (config load, missing resources) must surface + // as `-32603 Internal error`, not `-32602 Invalid params`, so the MCP + // client doesn't mislead the user / LLM into retrying with different + // arguments. + let err = ToolCallError::Internal("disk read failed".to_string()); + assert_eq!(err.code(), -32603); + assert_eq!(err.jsonrpc_message(), "Internal error"); + assert_eq!(err.message(), "disk read failed"); +} + +#[test] +fn memory_recall_requires_query() { + let err = build_rpc_params("memory.recall", json!({})).expect_err("must reject"); + assert!(err.message().contains("missing required argument `query`")); +} + +#[test] +fn memory_search_rejects_undocumented_limit_alias() { + let err = build_rpc_params( + "memory.search", + json!({ + "query": "phoenix", + "limit": 5 + }), + ) + .expect_err("must reject"); + + assert!(err.message().contains("unexpected argument `limit`")); +} + +#[test] +fn tree_read_chunk_maps_chunk_id_to_controller_id() { + let params = build_rpc_params("tree.read_chunk", json!({"chunk_id": "abc"})).expect("params"); + assert_eq!(params["id"], "abc"); + assert!(!params.contains_key("chunk_id")); +} + +#[test] +fn tree_read_chunk_rejects_unknown_arguments() { + let err = build_rpc_params( + "tree.read_chunk", + json!({ + "chunk_id": "abc", + "unused": true + }), + ) + .expect_err("must reject"); + + assert!(err.message().contains("unexpected argument `unused`")); +} + +#[test] +fn non_object_arguments_are_invalid() { + let err = build_rpc_params("memory.search", json!("query")).expect_err("must reject"); + assert!(err.message().contains("arguments must be an object")); +} + +// ── tree.browse ──────────────────────────────────────────────────── + +#[test] +fn tree_browse_no_args_sends_default_limit_only() { + // Empty filter is a valid request — the controller treats unset filters + // as "no constraint" — and the MCP layer still applies its own DEFAULT_LIMIT + // so the LLM doesn't accidentally pull the controller's 50-row default + // when it asked for nothing. + let params = build_rpc_params("tree.browse", json!({})).expect("empty args are valid"); + assert_eq!(params.len(), 1); + assert_eq!(params["limit"], DEFAULT_LIMIT); +} + +#[test] +fn tree_browse_passes_through_filters_and_renames_k_to_limit() { + let params = build_rpc_params( + "tree.browse", + json!({ + "source_kinds": ["email", "chat"], + "source_ids": ["acme-thread-1"], + "entity_ids": ["person:Alice"], + "since_ms": 1_700_000_000_000_i64, + "until_ms": 1_710_000_000_000_i64, + "query": "Q3 plan", + "k": 20, + "offset": 10 + }), + ) + .expect("params"); + + assert_eq!(params["limit"], 20); + assert!(!params.contains_key("k")); + assert_eq!(params["source_kinds"], json!(["email", "chat"])); + assert_eq!(params["source_ids"], json!(["acme-thread-1"])); + assert_eq!(params["entity_ids"], json!(["person:Alice"])); + assert_eq!(params["since_ms"], 1_700_000_000_000_i64); + assert_eq!(params["until_ms"], 1_710_000_000_000_i64); + assert_eq!(params["query"], "Q3 plan"); + assert_eq!(params["offset"], 10); +} + +#[test] +fn tree_browse_rejects_k_above_max() { + // Same reject-don't-clamp policy as memory.search / memory.recall so the + // LLM gets corrective feedback instead of silently receiving fewer rows + // than it asked for. + let err = build_rpc_params("tree.browse", json!({ "k": MAX_LIMIT + 1 })) + .expect_err("must reject k > MAX_LIMIT"); + assert!(err.message().contains("must not exceed")); +} + +#[test] +fn tree_browse_rejects_unknown_argument() { + let err = build_rpc_params("tree.browse", json!({ "limit": 10 })) + .expect_err("must reject the controller's `limit` alias"); + assert!(err.message().contains("unexpected argument `limit`")); +} + +#[test] +fn tree_browse_rejects_non_array_source_kinds() { + let err = build_rpc_params("tree.browse", json!({ "source_kinds": "email" })) + .expect_err("must reject scalar where array is required"); + assert!(err.message().contains("must be an array of strings")); +} + +#[test] +fn tree_browse_rejects_non_integer_since_ms() { + let err = build_rpc_params("tree.browse", json!({ "since_ms": "yesterday" })) + .expect_err("must reject ISO-style date for ms field"); + assert!(err.message().contains("must be an integer")); +} + +#[test] +fn tree_browse_drops_blank_array_entries_silently() { + // Empty / whitespace strings inside an array are tolerated — clients + // sometimes send `["", "email"]` after a partial UI selection and the + // intent ("filter to email") is unambiguous. A fully-blank array is OK + // too and produces an empty filter (same as omitting the field). + let params = build_rpc_params( + "tree.browse", + json!({ "source_kinds": ["", "email", " "] }), + ) + .expect("blank entries don't fail the whole call"); + assert_eq!(params["source_kinds"], json!(["email"])); +} + +// ── tree.top_entities ────────────────────────────────────────────── + +#[test] +fn tree_top_entities_defaults_limit_and_omits_kind() { + let params = build_rpc_params("tree.top_entities", json!({})).expect("empty args are valid"); + assert_eq!(params["limit"], DEFAULT_LIMIT); + assert!(!params.contains_key("kind")); +} + +#[test] +fn tree_top_entities_passes_kind_through_and_caps_limit_at_max() { + let params = build_rpc_params( + "tree.top_entities", + json!({ "kind": "person", "k": MAX_LIMIT }), + ) + .expect("k = MAX_LIMIT is the boundary, inclusive"); + assert_eq!(params["kind"], "person"); + assert_eq!(params["limit"], MAX_LIMIT); +} + +#[test] +fn tree_top_entities_rejects_empty_kind() { + // Blank kind is a client bug — the controller would happily run it as + // "no filter" but that's exactly what *omitting* the field already + // means. Rejecting nudges the LLM to drop the field instead. + let err = build_rpc_params("tree.top_entities", json!({ "kind": " " })) + .expect_err("must reject blank-only kind"); + assert!(err.message().contains("must not be empty")); +} + +// ── tree.list_sources ────────────────────────────────────────────── + +#[test] +fn tree_list_sources_accepts_empty_args() { + let params = + build_rpc_params("tree.list_sources", json!({})).expect("no args is the common case"); + assert!(params.is_empty()); +} + +#[test] +fn tree_list_sources_passes_user_email_hint() { + let params = build_rpc_params( + "tree.list_sources", + json!({ "user_email_hint": "me@example.com" }), + ) + .expect("params"); + assert_eq!(params["user_email_hint"], "me@example.com"); +} + +#[test] +fn tree_list_sources_rejects_unknown_argument() { + let err = build_rpc_params("tree.list_sources", json!({ "limit": 5 })) + .expect_err("list_sources takes no pagination"); + assert!(err.message().contains("unexpected argument `limit`")); +} + +// ── memory.store ────────────────────────────────────────────────── + +#[test] +fn memory_store_requires_title_and_content() { + let err = build_rpc_params("memory.store", json!({})).expect_err("must reject"); + assert!(err.message().contains("missing required argument `title`")); + + let err = build_rpc_params("memory.store", json!({ "title": "T" })).expect_err("must reject"); + assert!(err + .message() + .contains("missing required argument `content`")); +} + +#[test] +fn memory_store_defaults_namespace_to_mcp() { + let params = build_rpc_params( + "memory.store", + json!({ "title": "My note", "content": "Hello world" }), + ) + .expect("params"); + + assert_eq!(params["namespace"], "mcp"); + assert_eq!(params["title"], "My note"); + assert_eq!(params["content"], "Hello world"); + assert_eq!(params["source_type"], "mcp"); + assert!(params["key"].as_str().unwrap().starts_with("mcp-store-")); +} + +#[test] +fn memory_store_accepts_custom_namespace_and_tags() { + let params = build_rpc_params( + "memory.store", + json!({ + "title": "Project Plan", + "content": "Q3 milestones", + "namespace": "work", + "tags": ["project", "planning"] + }), + ) + .expect("params"); + + assert_eq!(params["namespace"], "work"); + assert_eq!(params["tags"], json!(["project", "planning"])); +} + +#[test] +fn memory_store_rejects_unknown_argument() { + let err = build_rpc_params( + "memory.store", + json!({ "title": "T", "content": "C", "priority": "high" }), + ) + .expect_err("must reject"); + assert!(err.message().contains("unexpected argument `priority`")); +} + +// ── memory.note ─────────────────────────────────────────────────── + +#[test] +fn memory_note_requires_chunk_id_and_note_text() { + let err = build_rpc_params("memory.note", json!({})).expect_err("must reject"); + assert!(err + .message() + .contains("missing required argument `chunk_id`")); + + let err = + build_rpc_params("memory.note", json!({ "chunk_id": "abc" })).expect_err("must reject"); + assert!(err + .message() + .contains("missing required argument `note_text`")); +} + +#[test] +fn memory_note_builds_annotation_document() { + let params = build_rpc_params( + "memory.note", + json!({ "chunk_id": "chunk-42", "note_text": "Important context" }), + ) + .expect("params"); + + assert_eq!(params["namespace"], "mcp"); + assert_eq!(params["key"], "mcp-note-chunk-42"); + assert!(params["title"].as_str().unwrap().contains("chunk-42")); + assert!(params["content"] + .as_str() + .unwrap() + .contains("Important context")); + assert!(params["content"] + .as_str() + .unwrap() + .contains("chunk_id=chunk-42")); + assert_eq!(params["metadata"]["annotates_chunk_id"], "chunk-42"); + assert_eq!(params["source_type"], "mcp"); +} + +#[test] +fn memory_note_rejects_unknown_argument() { + let err = build_rpc_params( + "memory.note", + json!({ "chunk_id": "abc", "note_text": "N", "extra": true }), + ) + .expect_err("must reject"); + assert!(err.message().contains("unexpected argument `extra`")); +} + +// ── tree.tag ────────────────────────────────────────────────────── + +#[test] +fn tree_tag_requires_chunk_id_and_tags() { + let err = build_rpc_params("tree.tag", json!({})).expect_err("must reject"); + assert!( + err.message() + .contains("missing required argument `chunk_id`"), + "got: {}", + err.message() + ); + + let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc" })).expect_err("must reject"); + assert!( + err.message().contains("missing required argument `tags`"), + "got: {}", + err.message() + ); +} + +#[test] +fn tree_tag_rejects_empty_tags_array() { + let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": [] })) + .expect_err("must reject"); + assert!( + err.message().contains("at least one non-empty string"), + "got: {}", + err.message() + ); +} + +#[test] +fn tree_tag_rejects_all_blank_tags() { + // After blank-trim the list is empty — same failure mode as `[]`. + let err = build_rpc_params( + "tree.tag", + json!({ "chunk_id": "abc", "tags": [" ", ""] }), + ) + .expect_err("must reject"); + assert!( + err.message().contains("at least one non-empty string"), + "got: {}", + err.message() + ); +} + +#[test] +fn tree_tag_rejects_non_string_tags() { + // Numeric entries inside `tags` get caught by the string-array helper. + let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": ["ok", 42] })) + .expect_err("must reject"); + assert!( + err.message() + .contains("argument `tags` must contain only strings"), + "got: {}", + err.message() + ); +} + +#[test] +fn tree_tag_builds_tag_record_document() { + let params = build_rpc_params( + "tree.tag", + json!({ "chunk_id": "chunk-42", "tags": ["todo", "q3-planning"] }), + ) + .expect("params"); + + // Document key is deterministic on chunk_id only → re-tagging + // the same chunk upserts. + assert_eq!(params["namespace"], "mcp"); + assert_eq!(params["key"], "mcp-tag-chunk-42"); + assert_eq!(params["source_type"], "mcp"); + + // Title surfaces the target chunk for human-readable recall. + assert!( + params["title"] + .as_str() + .expect("title is a string") + .contains("chunk-42"), + "title was: {}", + params["title"] + ); + + // Top-level `tags` flows to the document tag index (queryable + // via `doc_list` / search filters) — this is the key differentiator + // from `memory.note` whose payload is opaque free-form text. + assert_eq!(params["tags"], json!(["todo", "q3-planning"])); + + // Metadata carries the back-reference plus a mirrored tag list, + // so consumers reading the metadata view don't need to also + // join against the top-level `tags` field. + let metadata = params["metadata"] + .as_object() + .expect("metadata is an object"); + assert_eq!(metadata["tags_for_chunk_id"], "chunk-42"); + assert_eq!(metadata["applied_tags"], json!(["todo", "q3-planning"])); +} + +#[test] +fn tree_tag_trims_blanks_but_keeps_real_tags() { + // Mixed list — blanks are silently dropped (matches existing + // `optional_string_array` behaviour) but the resulting set is + // still non-empty so the call succeeds. + let params = build_rpc_params( + "tree.tag", + json!({ "chunk_id": "chunk-7", "tags": [" important ", "", " ", "todo"] }), + ) + .expect("params"); + + assert_eq!(params["tags"], json!(["important", "todo"])); +} + +#[test] +fn tree_tag_rejects_empty_chunk_id() { + let err = build_rpc_params("tree.tag", json!({ "chunk_id": "", "tags": ["todo"] })) + .expect_err("must reject"); + assert!( + err.message() + .contains("argument `chunk_id` must not be empty"), + "got: {}", + err.message() + ); +} + +#[test] +fn tree_tag_rejects_unknown_argument() { + let err = build_rpc_params( + "tree.tag", + json!({ "chunk_id": "abc", "tags": ["t"], "priority": "high" }), + ) + .expect_err("must reject"); + assert!( + err.message().contains("unexpected argument `priority`"), + "got: {}", + err.message() + ); +} + +#[test] +fn tree_tag_rejects_oversize_tag_array() { + // Per-graycyrus #2316 review: cap the tag-array length so a + // misbehaving client can't flood a chunk's tag-record document + // with hundreds of categorical labels. Builds an over-cap + // array and asserts the dedicated rejection message. + let oversize: Vec = (0..(TREE_TAG_MAX_TAGS + 1)) + .map(|i| format!("tag-{i}")) + .collect(); + let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": oversize })) + .expect_err("must reject"); + assert!( + err.message().contains("accepts at most"), + "got: {}", + err.message() + ); +} + +#[test] +fn tree_tag_rejects_oversize_individual_tag() { + // Per-graycyrus #2316 review: a single oversize tag is almost + // certainly free-form text that should be `memory.note` instead + // of going through the categorical tag surface — reject up-front + // so the misuse is visible rather than silently writing a giant + // token into the queryable `tags` index. + let oversize_tag = "a".repeat(TREE_TAG_MAX_TAG_LENGTH + 1); + let err = build_rpc_params( + "tree.tag", + json!({ "chunk_id": "abc", "tags": [oversize_tag] }), + ) + .expect_err("must reject"); + assert!(err.message().contains("exceeds"), "got: {}", err.message()); +} + +#[test] +fn tree_tag_accepts_max_size_tags() { + // Boundary: exactly TREE_TAG_MAX_TAGS entries (the cap is + // "at most N", not "fewer than N") with each entry at exactly + // TREE_TAG_MAX_TAG_LENGTH chars must succeed. Locks the + // inclusive-vs-exclusive bound so a future off-by-one + // refactor breaks the test, not user calls. + let max_tags: Vec = (0..TREE_TAG_MAX_TAGS) + .map(|i| format!("tag-{i:0width$}", width = TREE_TAG_MAX_TAG_LENGTH - 4)) + .collect(); + // Sanity: each entry is == TREE_TAG_MAX_TAG_LENGTH chars. + assert!(max_tags.iter().all(|t| t.len() == TREE_TAG_MAX_TAG_LENGTH)); + let params = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": max_tags })) + .expect("at the cap must succeed"); + // The built params should preserve all TREE_TAG_MAX_TAGS entries. + assert_eq!( + params["tags"].as_array().expect("tags is array").len(), + TREE_TAG_MAX_TAGS + ); +} + +#[tokio::test] +async fn call_tool_records_write_argument_rejection() { + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let tmp = tempfile::tempdir().expect("tempdir"); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); + } + let config = config_rpc::load_config_with_timeout() + .await + .expect("config"); + + let err = call_tool("memory.store", json!({ "title": "T" }), "mcp:test") + .await + .expect_err("missing content should reject"); + assert!( + err.message() + .contains("missing required argument `content`"), + "got: {}", + err.message() + ); + + let mut rows = Vec::new(); + for _ in 0..50 { + rows = crate::openhuman::mcp_audit::list_writes( + &config, + &crate::openhuman::mcp_audit::McpWriteListQuery::default(), + ) + .expect("list writes"); + if rows.len() == 1 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + assert_eq!(rows.len(), 1); + assert!(!rows[0].success); + assert_eq!(rows[0].tool_name, "memory.store"); + assert_eq!(rows[0].client_info, "mcp:test"); + assert!(rows[0] + .error_message + .as_deref() + .unwrap_or_default() + .contains("missing required argument `content`")); + assert!(rows[0].args_summary.get("content").is_none()); + + unsafe { + std::env::remove_var("OPENHUMAN_WORKSPACE"); + } +} + +// ── slug_from ───────────────────────────────────────────────────── + +#[test] +fn slug_from_produces_clean_slug() { + assert_eq!(slug_from("Hello World!"), "hello-world"); + assert_eq!(slug_from(" spaces "), "spaces"); + assert_eq!(slug_from("CamelCase123"), "camelcase123"); + assert_eq!(slug_from("a--b"), "a-b"); +} + +#[test] +fn slug_from_truncates_long_titles() { + let long = "a".repeat(100); + let slug = slug_from(&long); + assert!(slug.len() <= 64); +} + +#[test] +fn slug_from_returns_hash_fallback_for_non_alphanumeric_titles() { + // Non-alphanumeric titles should produce "untitled-" with a + // stable, deterministic hash suffix. + let slug_bang = slug_from("!!!"); + let slug_at = slug_from("@@@"); + assert!(slug_bang.starts_with("untitled-"), "got: {slug_bang}"); + assert!(slug_at.starts_with("untitled-"), "got: {slug_at}"); + // Different inputs → different slugs + assert_ne!(slug_bang, slug_at); + // Empty title also gets a fallback + assert!(slug_from("").starts_with("untitled-")); + // Stable across calls + assert_eq!(slug_from("!!!"), slug_bang); +} + +#[test] +fn slug_from_unicode_only_titles_are_unique_and_stable() { + let chinese = slug_from("会议记录"); + let russian = slug_from("Протокол"); + let emoji = slug_from("🦀🚀"); + // All produce hash-based fallbacks + assert!(chinese.starts_with("untitled-"), "got: {chinese}"); + assert!(russian.starts_with("untitled-"), "got: {russian}"); + assert!(emoji.starts_with("untitled-"), "got: {emoji}"); + // All distinct + assert_ne!(chinese, russian); + assert_ne!(chinese, emoji); + assert_ne!(russian, emoji); + // Stable + assert_eq!(slug_from("会议记录"), chinese); + assert_eq!(slug_from("Протокол"), russian); +} diff --git a/src/openhuman/memory/mod.rs b/src/openhuman/memory/mod.rs index 759d82b3b..25915d511 100644 --- a/src/openhuman/memory/mod.rs +++ b/src/openhuman/memory/mod.rs @@ -36,9 +36,9 @@ pub mod tree_source; pub mod tree_topic; #[cfg(test)] -mod sync_pipeline_e2e_test; +mod sync_pipeline_e2e_tests; #[cfg(test)] -mod tree_e2e_test; +mod tree_e2e_tests; pub use ingestion::{ ExtractedEntity, ExtractedRelation, ExtractionMode, IngestionJob, IngestionQueue, IngestionState, IngestionStatusSnapshot, MemoryIngestionConfig, MemoryIngestionRequest, diff --git a/src/openhuman/memory/read_rpc.rs b/src/openhuman/memory/read_rpc.rs index 338b8de7b..5f5e2fb41 100644 --- a/src/openhuman/memory/read_rpc.rs +++ b/src/openhuman/memory/read_rpc.rs @@ -1730,784 +1730,5 @@ fn parse_source_kind_str(s: &str) -> Option { // ── Tests ──────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::composio::providers::sync_state::KV_NAMESPACE; - use crate::openhuman::embeddings::NoopEmbedding; - use crate::openhuman::memory::ingest_pipeline::ingest_chat; - use crate::openhuman::memory_queue::drain_until_idle; - use crate::openhuman::memory_store::unified::UnifiedMemory; - use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage}; - use crate::openhuman::memory_sync::composio::providers::slack::ingest::ingest_page_into_memory_tree as ingest_slack_page; - use crate::openhuman::memory_sync::composio::providers::slack::SlackMessage; - use chrono::{TimeZone, Utc}; - use rusqlite::params; - use std::sync::Arc; - use tempfile::TempDir; - - fn test_config() -> (TempDir, Config) { - let tmp = TempDir::new().unwrap(); - let mut cfg = Config::default(); - cfg.workspace_dir = tmp.path().to_path_buf(); - // Point config_path inside the tempdir so any persistence during - // tests stays inside disposable workspace state. - cfg.config_path = tmp.path().join("config.toml"); - cfg.memory_tree.embedding_endpoint = None; - cfg.memory_tree.embedding_model = None; - cfg.memory_tree.embedding_strict = false; - // Default llm is Cloud — but the cloud provider needs a bearer - // token to actually fire. Tests that exercise the LLM path - // override either the backend or the extractor. The read RPCs - // below don't touch the LLM, so this default is fine. - (tmp, cfg) - } - - async fn seed_chat_chunk(cfg: &Config, source: &str, body: &str) { - let batch = ChatBatch { - platform: "slack".into(), - channel_label: source.into(), - messages: vec![ChatMessage { - author: "alice".into(), - timestamp: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(), - text: body.into(), - source_ref: Some("slack://x".into()), - }], - }; - ingest_chat(cfg, source, "alice", vec![], batch) - .await - .unwrap(); - } - - async fn seed_slack_chunk_with_raw_archive(cfg: &Config) -> String { - let msg = SlackMessage { - channel_id: "C123".into(), - channel_name: "engineering".into(), - is_private: false, - author: "alice".into(), - author_id: "U123".into(), - text: "Phoenix migration launch window is Friday at 22:00 UTC.".into(), - timestamp: Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(), - ts_raw: "1700000000.000100".into(), - thread_ts: None, - permalink: Some("https://slack.example.test/archives/C123/p1700000000000100".into()), - }; - ingest_slack_page(cfg, "alice", "conn-slack-1", &[msg]) - .await - .expect("seed slack ingest"); - drain_until_idle(cfg).await.expect("drain slack ingest"); - - list_chunks_rpc(cfg, ChunkFilter::default()) - .await - .expect("list chunks") - .value - .chunks - .into_iter() - .find(|chunk| chunk.source_id == "slack:conn-slack-1") - .expect("seeded slack chunk") - .id - } - - fn update_chunk_timestamp(cfg: &Config, chunk_id: &str, timestamp_ms: i64) { - with_connection(cfg, |conn| { - conn.execute( - "UPDATE mem_tree_chunks - SET timestamp_ms = ?1, - time_range_start_ms = ?1, - time_range_end_ms = ?1 - WHERE id = ?2", - params![timestamp_ms, chunk_id], - )?; - Ok(()) - }) - .unwrap(); - } - - fn insert_raw_chunk( - cfg: &Config, - id: &str, - source_kind: &str, - source_id: &str, - timestamp_ms: i64, - tags_json: &str, - content: &str, - token_count: i64, - ) { - with_connection(cfg, |conn| { - conn.execute( - "INSERT INTO mem_tree_chunks ( - id, source_kind, source_id, source_ref, owner, timestamp_ms, - time_range_start_ms, time_range_end_ms, tags_json, content, - token_count, seq_in_source, created_at_ms, lifecycle_status, content_path - ) VALUES (?1, ?2, ?3, NULL, 'tester', ?4, ?4, ?4, ?5, ?6, ?7, 0, ?4, 'seeded', NULL)", - params![id, source_kind, source_id, timestamp_ms, tags_json, content, token_count], - )?; - Ok(()) - }) - .unwrap(); - } - - #[tokio::test] - async fn list_chunks_returns_seeded_chunk() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk(&cfg, "slack:#eng", "hello @alice phoenix migration").await; - let resp = list_chunks_rpc(&cfg, ChunkFilter::default()) - .await - .unwrap() - .value; - assert!(!resp.chunks.is_empty()); - assert_eq!(resp.total, resp.chunks.len() as u64); - } - - #[tokio::test] - async fn list_chunks_filters_by_source_id() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk(&cfg, "slack:#a", "alpha").await; - seed_chat_chunk(&cfg, "slack:#b", "beta").await; - let only_a = list_chunks_rpc( - &cfg, - ChunkFilter { - source_ids: Some(vec!["slack:#a".into()]), - ..ChunkFilter::default() - }, - ) - .await - .unwrap() - .value; - assert!(only_a.chunks.iter().all(|c| c.source_id == "slack:#a")); - assert!(only_a.total >= 1); - } - - #[tokio::test] - async fn list_chunks_query_substring_works() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk(&cfg, "slack:#eng", "phoenix migration ships friday").await; - seed_chat_chunk(&cfg, "slack:#eng", "different unrelated text").await; - let resp = list_chunks_rpc( - &cfg, - ChunkFilter { - query: Some("phoenix".into()), - ..ChunkFilter::default() - }, - ) - .await - .unwrap() - .value; - assert!(resp.chunks.iter().any(|c| { - c.content_preview - .as_deref() - .unwrap_or("") - .contains("phoenix") - })); - } - - #[tokio::test] - async fn list_chunks_filters_by_source_kind_and_applies_limit_offset() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk(&cfg, "slack:#a", "first chat").await; - seed_chat_chunk(&cfg, "slack:#b", "second chat").await; - - let filtered = list_chunks_rpc( - &cfg, - ChunkFilter { - source_kinds: Some(vec!["chat".into()]), - limit: Some(1), - offset: Some(1), - ..ChunkFilter::default() - }, - ) - .await - .unwrap() - .value; - assert_eq!(filtered.chunks.len(), 1); - assert_eq!(filtered.total, 2); - assert!(filtered.chunks.iter().all(|c| c.source_kind == "chat")); - } - - #[tokio::test] - async fn list_chunks_filters_by_entity_id_and_time_window() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com handles phoenix").await; - seed_chat_chunk(&cfg, "slack:#eng", "bob@example.com handles atlas").await; - - let seeded = list_chunks_rpc(&cfg, ChunkFilter::default()) - .await - .unwrap() - .value - .chunks; - let alice = seeded - .iter() - .find(|chunk| { - chunk - .content_preview - .as_deref() - .unwrap_or("") - .contains("alice@example.com") - }) - .expect("alice chunk present"); - let bob = seeded - .iter() - .find(|chunk| { - chunk - .content_preview - .as_deref() - .unwrap_or("") - .contains("bob@example.com") - }) - .expect("bob chunk present"); - - update_chunk_timestamp(&cfg, &alice.id, 1_700_000_000_100); - update_chunk_timestamp(&cfg, &bob.id, 1_700_000_000_900); - - let filtered = list_chunks_rpc( - &cfg, - ChunkFilter { - entity_ids: Some(vec!["email:alice@example.com".into()]), - since_ms: Some(1_700_000_000_000), - until_ms: Some(1_700_000_000_500), - ..ChunkFilter::default() - }, - ) - .await - .unwrap() - .value; - - assert_eq!(filtered.total, 1); - assert_eq!(filtered.chunks.len(), 1); - assert_eq!(filtered.chunks[0].id, alice.id); - } - - #[tokio::test] - async fn list_chunks_ignores_empty_filter_lists_and_blank_query() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk(&cfg, "slack:#a", "alpha").await; - seed_chat_chunk(&cfg, "slack:#b", "beta").await; - - let resp = list_chunks_rpc( - &cfg, - ChunkFilter { - source_kinds: Some(vec![]), - source_ids: Some(vec![]), - entity_ids: Some(vec![]), - query: Some(" ".into()), - limit: Some(10), - ..ChunkFilter::default() - }, - ) - .await - .unwrap() - .value; - - assert_eq!(resp.total, 2); - assert_eq!(resp.chunks.len(), 2); - } - - #[tokio::test] - async fn list_chunks_normalizes_invalid_tags_negative_tokens_and_empty_content() { - let (_tmp, cfg) = test_config(); - insert_raw_chunk( - &cfg, - "raw-empty", - "document", - "notion:page-1", - 1_700_000_000_123, - "not-json", - "", - -7, - ); - - let resp = list_chunks_rpc(&cfg, ChunkFilter::default()) - .await - .unwrap() - .value; - let row = resp - .chunks - .into_iter() - .find(|chunk| chunk.id == "raw-empty") - .expect("raw chunk listed"); - - assert_eq!(row.token_count, 0); - assert_eq!(row.tags, Vec::::new()); - assert_eq!(row.content_preview, None); - assert!(!row.has_embedding); - } - - #[tokio::test] - async fn list_sources_aggregates() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk(&cfg, "slack:#a", "x").await; - seed_chat_chunk(&cfg, "slack:#a", "y").await; - seed_chat_chunk(&cfg, "slack:#b", "z").await; - let sources = list_sources_rpc(&cfg, None).await.unwrap().value; - let a = sources - .iter() - .find(|s| s.source_id == "slack:#a") - .expect("expected slack:#a"); - let b = sources - .iter() - .find(|s| s.source_id == "slack:#b") - .expect("expected slack:#b"); - assert_eq!(a.chunk_count, 2); - assert_eq!(b.chunk_count, 1); - } - - #[tokio::test] - async fn list_sources_formats_email_threads_with_trimmed_user_hint() { - let (_tmp, cfg) = test_config(); - insert_raw_chunk( - &cfg, - "email-thread", - "email", - "gmail:Alice@Example.com|bob@example.com|carol@example.com", - 1_700_000_000_123, - "[]", - "thread body", - 12, - ); - - let sources = list_sources_rpc(&cfg, Some(" alice@example.com ".into())) - .await - .unwrap() - .value; - let source = sources - .iter() - .find(|row| { - row.source_id == "gmail:Alice@Example.com|bob@example.com|carol@example.com" - }) - .expect("email thread source present"); - assert_eq!(source.display_name, "bob@example.com, carol@example.com"); - } - - #[tokio::test] - async fn entity_index_for_returns_extracted_entities() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await; - // Find the chunk we just seeded. - let chunks = list_chunks_rpc(&cfg, ChunkFilter::default()) - .await - .unwrap() - .value - .chunks; - let id = &chunks[0].id; - let refs = entity_index_for_rpc(&cfg, id.clone()).await.unwrap().value; - assert!( - refs.iter().any(|r| r.entity_id.contains("alice")), - "expected alice entity in index, got: {refs:?}" - ); - } - - #[tokio::test] - async fn chunks_for_entity_returns_leaf_chunk_ids_only() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await; - let chunk_id = list_chunks_rpc(&cfg, ChunkFilter::default()) - .await - .unwrap() - .value - .chunks[0] - .id - .clone(); - - let rows = chunks_for_entity_rpc(&cfg, "email:alice@example.com".into()) - .await - .unwrap() - .value; - assert_eq!(rows, vec![chunk_id]); - } - - #[tokio::test] - async fn top_entities_returns_most_frequent() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk(&cfg, "slack:#a", "alice@example.com x").await; - seed_chat_chunk(&cfg, "slack:#b", "alice@example.com y").await; - seed_chat_chunk(&cfg, "slack:#c", "bob@example.com z").await; - let top = top_entities_rpc(&cfg, Some("email".into()), 10) - .await - .unwrap() - .value; - assert!(top - .iter() - .any(|e| e.entity_id == "email:alice@example.com" && e.count >= 2)); - } - - #[tokio::test] - async fn delete_chunk_removes_chunk_and_dependent_rows() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await; - let chunks = list_chunks_rpc(&cfg, ChunkFilter::default()) - .await - .unwrap() - .value - .chunks; - let id = chunks[0].id.clone(); - let resp = delete_chunk_rpc(&cfg, id.clone()).await.unwrap().value; - assert!(resp.deleted); - // Re-list — the chunk should be gone. - let after = list_chunks_rpc(&cfg, ChunkFilter::default()) - .await - .unwrap() - .value; - assert!(after.chunks.iter().all(|c| c.id != id)); - } - - #[tokio::test] - async fn delete_missing_chunk_is_idempotent() { - let (_tmp, cfg) = test_config(); - let resp = delete_chunk_rpc(&cfg, "does-not-exist".into()) - .await - .unwrap() - .value; - assert!(!resp.deleted); - assert_eq!(resp.score_rows_removed, 0); - } - - #[tokio::test] - async fn chunk_score_returns_breakdown_after_ingest() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk( - &cfg, - "slack:#eng", - "alice@example.com owns the phoenix migration", - ) - .await; - let chunks = list_chunks_rpc(&cfg, ChunkFilter::default()) - .await - .unwrap() - .value - .chunks; - let id = &chunks[0].id; - let breakdown = chunk_score_rpc(&cfg, id.clone()).await.unwrap().value; - assert!(breakdown.is_some(), "expected score row after ingest"); - let b = breakdown.unwrap(); - assert!(b.signals.iter().any(|s| s.name == "metadata_weight")); - assert!(b.threshold > 0.0); - } - - #[tokio::test] - async fn search_returns_matching_chunks() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk(&cfg, "slack:#eng", "phoenix migration scheduled friday").await; - seed_chat_chunk(&cfg, "slack:#eng", "different unrelated text").await; - let hits = search_rpc(&cfg, "phoenix".into(), 10).await.unwrap().value; - assert!(hits.iter().any(|c| { - c.content_preview - .as_deref() - .unwrap_or("") - .contains("phoenix") - })); - } - - #[tokio::test] - async fn read_chunk_row_returns_preview_and_metadata() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk( - &cfg, - "slack:#eng", - "phoenix migration scheduled friday with context and source refs", - ) - .await; - let chunk = list_chunks_rpc(&cfg, ChunkFilter::default()) - .await - .unwrap() - .value - .chunks - .into_iter() - .next() - .expect("seeded chunk"); - - let row = read_chunk_row(&cfg, &chunk.id).unwrap().expect("chunk row"); - assert_eq!(row.id, chunk.id); - assert_eq!(row.source_kind, "chat"); - assert_eq!(row.source_id, "slack:#eng"); - assert_eq!(row.source_ref.as_deref(), Some("slack://x")); - assert_eq!(row.owner, "alice"); - assert_eq!(row.lifecycle_status, "pending_extraction"); - assert!(row.content_path.is_some()); - assert!(row - .content_preview - .as_deref() - .unwrap_or("") - .contains("phoenix migration scheduled friday")); - } - - #[tokio::test] - async fn read_chunk_row_falls_back_to_sqlite_preview_when_file_missing() { - let (_tmp, cfg) = test_config(); - let body = "sqlite preview survives missing file"; - seed_chat_chunk(&cfg, "slack:#eng", body).await; - let chunk = list_chunks_rpc(&cfg, ChunkFilter::default()) - .await - .unwrap() - .value - .chunks - .into_iter() - .next() - .expect("seeded chunk"); - - let rel_path = chunk.content_path.clone().expect("content path present"); - let abs_path = cfg.memory_tree_content_root().join(rel_path); - std::fs::remove_file(&abs_path).expect("remove chunk file"); - - let row = read_chunk_row(&cfg, &chunk.id).unwrap().expect("chunk row"); - assert_eq!(row.content_path, chunk.content_path); - assert!(row.content_preview.as_deref().unwrap_or("").contains(body)); - } - - #[tokio::test] - async fn flush_now_enqueues_once_and_reports_stale_buffers() { - let (_tmp, cfg) = test_config(); - seed_chat_chunk( - &cfg, - "slack:#eng", - "Phoenix migration ships Friday after the release checklist closes.", - ) - .await; - drain_until_idle(&cfg).await.expect("drain jobs"); - - let first = flush_now_rpc(&cfg).await.expect("flush_now first"); - assert!(first.value.enqueued, "first flush should enqueue work"); - assert!( - first.value.stale_buffers >= 1, - "expected at least one stale buffer after ingest" - ); - - let second = flush_now_rpc(&cfg).await.expect("flush_now second"); - assert!( - !second.value.enqueued, - "same 3-hour window should dedupe duplicate flush triggers" - ); - assert!( - second.value.stale_buffers >= 1, - "deduped flush should still report current stale buffer count" - ); - } - - #[tokio::test] - async fn reset_tree_preserves_raw_archive_and_source_registry() { - let (_tmp, cfg) = test_config(); - let chunk_id = seed_slack_chunk_with_raw_archive(&cfg).await; - let content_root = cfg.memory_tree_content_root(); - let raw_file = content_root - .join("raw") - .join("slack-conn-slack-1") - .join("chats") - .join("1700000000000_1700000000.000100.md"); - let source_file = content_root - .join("raw") - .join("slack-conn-slack-1") - .join("_source.md"); - assert!(raw_file.exists(), "raw archive should exist before reset"); - assert!( - source_file.exists(), - "source registry should exist before reset" - ); - - let stale_summary = content_root - .join("wiki") - .join("summaries") - .join("source-slack-conn-slack-1") - .join("L1") - .join("summary-stale.md"); - std::fs::create_dir_all( - stale_summary - .parent() - .expect("stale summary parent should exist"), - ) - .expect("create stale summary dir"); - std::fs::write(&stale_summary, "stale summary body").expect("write stale summary"); - assert!(stale_summary.exists(), "stale summary fixture should exist"); - - let outcome = reset_tree_rpc(&cfg).await.expect("reset_tree"); - assert_eq!(outcome.value.chunks_requeued, 1); - assert_eq!(outcome.value.jobs_enqueued, 1); - assert!( - outcome.value.tree_rows_deleted >= 1, - "buffer/tree rows should be removed during reset" - ); - - let row = read_chunk_row(&cfg, &chunk_id) - .expect("read chunk row") - .expect("chunk row present after reset"); - assert_eq!(row.lifecycle_status, "pending_extraction"); - assert!(raw_file.exists(), "raw archive must survive reset_tree"); - assert!( - source_file.exists(), - "source registry must survive reset_tree" - ); - assert!( - !content_root.join("wiki").join("summaries").exists(), - "derived wiki summaries should be removed" - ); - } - - #[test] - fn read_chunk_row_returns_none_for_missing_chunk() { - let (_tmp, cfg) = test_config(); - assert!(read_chunk_row(&cfg, "missing-chunk").unwrap().is_none()); - } - - #[test] - fn display_name_unslugs_email_thread_with_user_hint() { - let name = display_name_for_source( - "gmail:alice@example.com|bob@example.com", - Some("alice@example.com"), - ); - assert_eq!(name, "bob@example.com"); - } - - #[test] - fn display_name_falls_back_to_arrow_when_user_unknown() { - let name = display_name_for_source("gmail:alice@example.com|bob@example.com", None); - assert!(name.contains("alice@example.com")); - assert!(name.contains("bob@example.com")); - assert!(name.contains("↔")); - } - - #[test] - fn display_name_strips_platform_prefix() { - assert_eq!( - display_name_for_source("slack:#engineering", None), - "#engineering" - ); - } - - #[test] - fn display_name_handles_multiple_participants_and_trimmed_hint() { - let name = display_name_for_source( - "gmail:Alice@Example.com|bob@example.com|carol@example.com", - Some(" alice@example.com "), - ); - assert_eq!(name, "bob@example.com, carol@example.com"); - } - - #[test] - fn display_name_handles_no_prefix() { - assert_eq!(display_name_for_source("loose-id", None), "loose-id"); - } - - #[test] - fn sanitize_basename_replaces_windows_illegal_characters() { - assert_eq!( - sanitize_basename(r#"chat:slack/#eng\name*?"<>|"#), - "chat-slack-#eng-name------" - ); - assert_eq!(sanitize_basename("safe-name.md"), "safe-name.md"); - } - - #[test] - fn parse_source_kind_str_accepts_known_values_only() { - assert_eq!(parse_source_kind_str("chat"), Some(SourceKind::Chat)); - assert_eq!(parse_source_kind_str("email"), Some(SourceKind::Email)); - assert_eq!( - parse_source_kind_str("document"), - Some(SourceKind::Document) - ); - assert_eq!(parse_source_kind_str("unknown"), None); - } - - #[test] - fn clear_composio_sync_state_removes_only_target_namespace() { - let tmp = TempDir::new().unwrap(); - let _memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); - let db_path = tmp.path().join("memory").join("memory.db"); - let conn = rusqlite::Connection::open(&db_path).unwrap(); - - conn.execute( - "INSERT INTO kv_namespace (namespace, key, value_json, updated_at) - VALUES (?1, 'cursor', '{}', 1.0)", - params![KV_NAMESPACE], - ) - .unwrap(); - conn.execute( - "INSERT INTO kv_namespace (namespace, key, value_json, updated_at) - VALUES ('other-namespace', 'cursor', '{}', 2.0)", - [], - ) - .unwrap(); - drop(conn); - - let removed = clear_composio_sync_state(&db_path).unwrap(); - assert_eq!(removed, 1); - - let conn = rusqlite::Connection::open(&db_path).unwrap(); - let composio_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM kv_namespace WHERE namespace = ?1", - params![KV_NAMESPACE], - |row| row.get(0), - ) - .unwrap(); - let other_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM kv_namespace WHERE namespace = 'other-namespace'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(composio_count, 0); - assert_eq!(other_count, 1); - } - - #[tokio::test] - async fn obsidian_status_registered_when_override_config_lists_content_root() { - let (_tmp, cfg) = test_config(); - let content_root = cfg.memory_tree_content_root(); - // A separate dir standing in for a non-standard Obsidian config - // location, with an obsidian.json that registers the content root. - let cfg_dir = TempDir::new().unwrap(); - let body = format!( - "{{ \"vaults\": {{ \"id0\": {{ \"path\": {}, \"open\": true }} }} }}", - serde_json::to_string(&content_root.to_string_lossy().to_string()).unwrap() - ); - std::fs::write(cfg_dir.path().join("obsidian.json"), body).unwrap(); - - let outcome = - obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string())) - .await - .unwrap(); - - assert!(outcome.value.registered); - assert!(outcome.value.config_found); - assert_eq!( - outcome.value.content_root_abs, - content_root.to_string_lossy().to_string() - ); - // The log reports the booleans but redacts the absolute path (it - // embeds the user's home / username). - assert!( - outcome.logs[0].contains("registered=true"), - "log: {}", - outcome.logs[0] - ); - assert!( - !outcome.logs[0].contains(content_root.to_str().unwrap()), - "log leaked content root: {}", - outcome.logs[0] - ); - } - - #[tokio::test] - async fn obsidian_status_not_registered_for_empty_override_dir() { - let (_tmp, cfg) = test_config(); - // Empty override dir → no obsidian.json there → content root is not a - // registered vault. (A temp content root can't be under any real host - // vault either, so this stays false regardless of the dev machine.) - let cfg_dir = TempDir::new().unwrap(); - let outcome = - obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string())) - .await - .unwrap(); - assert!(!outcome.value.registered); - } - - #[tokio::test] - async fn obsidian_status_blank_override_is_treated_as_none() { - // A whitespace-only override must be normalized to None rather than - // resolving to "." and probing a stray local ./obsidian.json. The temp - // content root isn't under any real host vault, so this stays false. - let (_tmp, cfg) = test_config(); - let outcome = obsidian_vault_status_rpc(&cfg, Some(" ".to_string())) - .await - .unwrap(); - assert!(!outcome.value.registered); - } -} +#[path = "read_rpc_tests.rs"] +mod tests; diff --git a/src/openhuman/memory/read_rpc_tests.rs b/src/openhuman/memory/read_rpc_tests.rs new file mode 100644 index 000000000..5d4da34f8 --- /dev/null +++ b/src/openhuman/memory/read_rpc_tests.rs @@ -0,0 +1,785 @@ +use super::*; +use crate::openhuman::composio::providers::sync_state::KV_NAMESPACE; +use crate::openhuman::embeddings::NoopEmbedding; +use crate::openhuman::memory::ingest_pipeline::ingest_chat; +use crate::openhuman::memory_queue::drain_until_idle; +use crate::openhuman::memory_store::unified::UnifiedMemory; +use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage}; +use crate::openhuman::memory_sync::composio::providers::slack::ingest::ingest_page_into_memory_tree as ingest_slack_page; +use crate::openhuman::memory_sync::composio::providers::slack::SlackMessage; +use chrono::{TimeZone, Utc}; +use rusqlite::params; +use std::sync::Arc; +use tempfile::TempDir; + +fn test_config() -> (TempDir, Config) { + let tmp = TempDir::new().unwrap(); + let mut cfg = Config::default(); + cfg.workspace_dir = tmp.path().to_path_buf(); + // Point config_path inside the tempdir so any persistence during + // tests stays inside disposable workspace state. + cfg.config_path = tmp.path().join("config.toml"); + cfg.memory_tree.embedding_endpoint = None; + cfg.memory_tree.embedding_model = None; + cfg.memory_tree.embedding_strict = false; + // Default llm is Cloud — but the cloud provider needs a bearer + // token to actually fire. Tests that exercise the LLM path + // override either the backend or the extractor. The read RPCs + // below don't touch the LLM, so this default is fine. + (tmp, cfg) +} + +async fn seed_chat_chunk(cfg: &Config, source: &str, body: &str) { + let batch = ChatBatch { + platform: "slack".into(), + channel_label: source.into(), + messages: vec![ChatMessage { + author: "alice".into(), + timestamp: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(), + text: body.into(), + source_ref: Some("slack://x".into()), + }], + }; + ingest_chat(cfg, source, "alice", vec![], batch) + .await + .unwrap(); +} + +async fn seed_slack_chunk_with_raw_archive(cfg: &Config) -> String { + let msg = SlackMessage { + channel_id: "C123".into(), + channel_name: "engineering".into(), + is_private: false, + author: "alice".into(), + author_id: "U123".into(), + text: "Phoenix migration launch window is Friday at 22:00 UTC.".into(), + timestamp: Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(), + ts_raw: "1700000000.000100".into(), + thread_ts: None, + permalink: Some("https://slack.example.test/archives/C123/p1700000000000100".into()), + }; + ingest_slack_page(cfg, "alice", "conn-slack-1", &[msg]) + .await + .expect("seed slack ingest"); + drain_until_idle(cfg).await.expect("drain slack ingest"); + + list_chunks_rpc(cfg, ChunkFilter::default()) + .await + .expect("list chunks") + .value + .chunks + .into_iter() + .find(|chunk| chunk.source_id == "slack:conn-slack-1") + .expect("seeded slack chunk") + .id +} + +fn update_chunk_timestamp(cfg: &Config, chunk_id: &str, timestamp_ms: i64) { + with_connection(cfg, |conn| { + conn.execute( + "UPDATE mem_tree_chunks + SET timestamp_ms = ?1, + time_range_start_ms = ?1, + time_range_end_ms = ?1 + WHERE id = ?2", + params![timestamp_ms, chunk_id], + )?; + Ok(()) + }) + .unwrap(); +} + +fn insert_raw_chunk( + cfg: &Config, + id: &str, + source_kind: &str, + source_id: &str, + timestamp_ms: i64, + tags_json: &str, + content: &str, + token_count: i64, +) { + with_connection(cfg, |conn| { + conn.execute( + "INSERT INTO mem_tree_chunks ( + id, source_kind, source_id, source_ref, owner, timestamp_ms, + time_range_start_ms, time_range_end_ms, tags_json, content, + token_count, seq_in_source, created_at_ms, lifecycle_status, content_path + ) VALUES (?1, ?2, ?3, NULL, 'tester', ?4, ?4, ?4, ?5, ?6, ?7, 0, ?4, 'seeded', NULL)", + params![ + id, + source_kind, + source_id, + timestamp_ms, + tags_json, + content, + token_count + ], + )?; + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn list_chunks_returns_seeded_chunk() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk(&cfg, "slack:#eng", "hello @alice phoenix migration").await; + let resp = list_chunks_rpc(&cfg, ChunkFilter::default()) + .await + .unwrap() + .value; + assert!(!resp.chunks.is_empty()); + assert_eq!(resp.total, resp.chunks.len() as u64); +} + +#[tokio::test] +async fn list_chunks_filters_by_source_id() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk(&cfg, "slack:#a", "alpha").await; + seed_chat_chunk(&cfg, "slack:#b", "beta").await; + let only_a = list_chunks_rpc( + &cfg, + ChunkFilter { + source_ids: Some(vec!["slack:#a".into()]), + ..ChunkFilter::default() + }, + ) + .await + .unwrap() + .value; + assert!(only_a.chunks.iter().all(|c| c.source_id == "slack:#a")); + assert!(only_a.total >= 1); +} + +#[tokio::test] +async fn list_chunks_query_substring_works() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk(&cfg, "slack:#eng", "phoenix migration ships friday").await; + seed_chat_chunk(&cfg, "slack:#eng", "different unrelated text").await; + let resp = list_chunks_rpc( + &cfg, + ChunkFilter { + query: Some("phoenix".into()), + ..ChunkFilter::default() + }, + ) + .await + .unwrap() + .value; + assert!(resp.chunks.iter().any(|c| { + c.content_preview + .as_deref() + .unwrap_or("") + .contains("phoenix") + })); +} + +#[tokio::test] +async fn list_chunks_filters_by_source_kind_and_applies_limit_offset() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk(&cfg, "slack:#a", "first chat").await; + seed_chat_chunk(&cfg, "slack:#b", "second chat").await; + + let filtered = list_chunks_rpc( + &cfg, + ChunkFilter { + source_kinds: Some(vec!["chat".into()]), + limit: Some(1), + offset: Some(1), + ..ChunkFilter::default() + }, + ) + .await + .unwrap() + .value; + assert_eq!(filtered.chunks.len(), 1); + assert_eq!(filtered.total, 2); + assert!(filtered.chunks.iter().all(|c| c.source_kind == "chat")); +} + +#[tokio::test] +async fn list_chunks_filters_by_entity_id_and_time_window() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com handles phoenix").await; + seed_chat_chunk(&cfg, "slack:#eng", "bob@example.com handles atlas").await; + + let seeded = list_chunks_rpc(&cfg, ChunkFilter::default()) + .await + .unwrap() + .value + .chunks; + let alice = seeded + .iter() + .find(|chunk| { + chunk + .content_preview + .as_deref() + .unwrap_or("") + .contains("alice@example.com") + }) + .expect("alice chunk present"); + let bob = seeded + .iter() + .find(|chunk| { + chunk + .content_preview + .as_deref() + .unwrap_or("") + .contains("bob@example.com") + }) + .expect("bob chunk present"); + + update_chunk_timestamp(&cfg, &alice.id, 1_700_000_000_100); + update_chunk_timestamp(&cfg, &bob.id, 1_700_000_000_900); + + let filtered = list_chunks_rpc( + &cfg, + ChunkFilter { + entity_ids: Some(vec!["email:alice@example.com".into()]), + since_ms: Some(1_700_000_000_000), + until_ms: Some(1_700_000_000_500), + ..ChunkFilter::default() + }, + ) + .await + .unwrap() + .value; + + assert_eq!(filtered.total, 1); + assert_eq!(filtered.chunks.len(), 1); + assert_eq!(filtered.chunks[0].id, alice.id); +} + +#[tokio::test] +async fn list_chunks_ignores_empty_filter_lists_and_blank_query() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk(&cfg, "slack:#a", "alpha").await; + seed_chat_chunk(&cfg, "slack:#b", "beta").await; + + let resp = list_chunks_rpc( + &cfg, + ChunkFilter { + source_kinds: Some(vec![]), + source_ids: Some(vec![]), + entity_ids: Some(vec![]), + query: Some(" ".into()), + limit: Some(10), + ..ChunkFilter::default() + }, + ) + .await + .unwrap() + .value; + + assert_eq!(resp.total, 2); + assert_eq!(resp.chunks.len(), 2); +} + +#[tokio::test] +async fn list_chunks_normalizes_invalid_tags_negative_tokens_and_empty_content() { + let (_tmp, cfg) = test_config(); + insert_raw_chunk( + &cfg, + "raw-empty", + "document", + "notion:page-1", + 1_700_000_000_123, + "not-json", + "", + -7, + ); + + let resp = list_chunks_rpc(&cfg, ChunkFilter::default()) + .await + .unwrap() + .value; + let row = resp + .chunks + .into_iter() + .find(|chunk| chunk.id == "raw-empty") + .expect("raw chunk listed"); + + assert_eq!(row.token_count, 0); + assert_eq!(row.tags, Vec::::new()); + assert_eq!(row.content_preview, None); + assert!(!row.has_embedding); +} + +#[tokio::test] +async fn list_sources_aggregates() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk(&cfg, "slack:#a", "x").await; + seed_chat_chunk(&cfg, "slack:#a", "y").await; + seed_chat_chunk(&cfg, "slack:#b", "z").await; + let sources = list_sources_rpc(&cfg, None).await.unwrap().value; + let a = sources + .iter() + .find(|s| s.source_id == "slack:#a") + .expect("expected slack:#a"); + let b = sources + .iter() + .find(|s| s.source_id == "slack:#b") + .expect("expected slack:#b"); + assert_eq!(a.chunk_count, 2); + assert_eq!(b.chunk_count, 1); +} + +#[tokio::test] +async fn list_sources_formats_email_threads_with_trimmed_user_hint() { + let (_tmp, cfg) = test_config(); + insert_raw_chunk( + &cfg, + "email-thread", + "email", + "gmail:Alice@Example.com|bob@example.com|carol@example.com", + 1_700_000_000_123, + "[]", + "thread body", + 12, + ); + + let sources = list_sources_rpc(&cfg, Some(" alice@example.com ".into())) + .await + .unwrap() + .value; + let source = sources + .iter() + .find(|row| row.source_id == "gmail:Alice@Example.com|bob@example.com|carol@example.com") + .expect("email thread source present"); + assert_eq!(source.display_name, "bob@example.com, carol@example.com"); +} + +#[tokio::test] +async fn entity_index_for_returns_extracted_entities() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await; + // Find the chunk we just seeded. + let chunks = list_chunks_rpc(&cfg, ChunkFilter::default()) + .await + .unwrap() + .value + .chunks; + let id = &chunks[0].id; + let refs = entity_index_for_rpc(&cfg, id.clone()).await.unwrap().value; + assert!( + refs.iter().any(|r| r.entity_id.contains("alice")), + "expected alice entity in index, got: {refs:?}" + ); +} + +#[tokio::test] +async fn chunks_for_entity_returns_leaf_chunk_ids_only() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await; + let chunk_id = list_chunks_rpc(&cfg, ChunkFilter::default()) + .await + .unwrap() + .value + .chunks[0] + .id + .clone(); + + let rows = chunks_for_entity_rpc(&cfg, "email:alice@example.com".into()) + .await + .unwrap() + .value; + assert_eq!(rows, vec![chunk_id]); +} + +#[tokio::test] +async fn top_entities_returns_most_frequent() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk(&cfg, "slack:#a", "alice@example.com x").await; + seed_chat_chunk(&cfg, "slack:#b", "alice@example.com y").await; + seed_chat_chunk(&cfg, "slack:#c", "bob@example.com z").await; + let top = top_entities_rpc(&cfg, Some("email".into()), 10) + .await + .unwrap() + .value; + assert!(top + .iter() + .any(|e| e.entity_id == "email:alice@example.com" && e.count >= 2)); +} + +#[tokio::test] +async fn delete_chunk_removes_chunk_and_dependent_rows() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await; + let chunks = list_chunks_rpc(&cfg, ChunkFilter::default()) + .await + .unwrap() + .value + .chunks; + let id = chunks[0].id.clone(); + let resp = delete_chunk_rpc(&cfg, id.clone()).await.unwrap().value; + assert!(resp.deleted); + // Re-list — the chunk should be gone. + let after = list_chunks_rpc(&cfg, ChunkFilter::default()) + .await + .unwrap() + .value; + assert!(after.chunks.iter().all(|c| c.id != id)); +} + +#[tokio::test] +async fn delete_missing_chunk_is_idempotent() { + let (_tmp, cfg) = test_config(); + let resp = delete_chunk_rpc(&cfg, "does-not-exist".into()) + .await + .unwrap() + .value; + assert!(!resp.deleted); + assert_eq!(resp.score_rows_removed, 0); +} + +#[tokio::test] +async fn chunk_score_returns_breakdown_after_ingest() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk( + &cfg, + "slack:#eng", + "alice@example.com owns the phoenix migration", + ) + .await; + let chunks = list_chunks_rpc(&cfg, ChunkFilter::default()) + .await + .unwrap() + .value + .chunks; + let id = &chunks[0].id; + let breakdown = chunk_score_rpc(&cfg, id.clone()).await.unwrap().value; + assert!(breakdown.is_some(), "expected score row after ingest"); + let b = breakdown.unwrap(); + assert!(b.signals.iter().any(|s| s.name == "metadata_weight")); + assert!(b.threshold > 0.0); +} + +#[tokio::test] +async fn search_returns_matching_chunks() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk(&cfg, "slack:#eng", "phoenix migration scheduled friday").await; + seed_chat_chunk(&cfg, "slack:#eng", "different unrelated text").await; + let hits = search_rpc(&cfg, "phoenix".into(), 10).await.unwrap().value; + assert!(hits.iter().any(|c| { + c.content_preview + .as_deref() + .unwrap_or("") + .contains("phoenix") + })); +} + +#[tokio::test] +async fn read_chunk_row_returns_preview_and_metadata() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk( + &cfg, + "slack:#eng", + "phoenix migration scheduled friday with context and source refs", + ) + .await; + let chunk = list_chunks_rpc(&cfg, ChunkFilter::default()) + .await + .unwrap() + .value + .chunks + .into_iter() + .next() + .expect("seeded chunk"); + + let row = read_chunk_row(&cfg, &chunk.id).unwrap().expect("chunk row"); + assert_eq!(row.id, chunk.id); + assert_eq!(row.source_kind, "chat"); + assert_eq!(row.source_id, "slack:#eng"); + assert_eq!(row.source_ref.as_deref(), Some("slack://x")); + assert_eq!(row.owner, "alice"); + assert_eq!(row.lifecycle_status, "pending_extraction"); + assert!(row.content_path.is_some()); + assert!(row + .content_preview + .as_deref() + .unwrap_or("") + .contains("phoenix migration scheduled friday")); +} + +#[tokio::test] +async fn read_chunk_row_falls_back_to_sqlite_preview_when_file_missing() { + let (_tmp, cfg) = test_config(); + let body = "sqlite preview survives missing file"; + seed_chat_chunk(&cfg, "slack:#eng", body).await; + let chunk = list_chunks_rpc(&cfg, ChunkFilter::default()) + .await + .unwrap() + .value + .chunks + .into_iter() + .next() + .expect("seeded chunk"); + + let rel_path = chunk.content_path.clone().expect("content path present"); + let abs_path = cfg.memory_tree_content_root().join(rel_path); + std::fs::remove_file(&abs_path).expect("remove chunk file"); + + let row = read_chunk_row(&cfg, &chunk.id).unwrap().expect("chunk row"); + assert_eq!(row.content_path, chunk.content_path); + assert!(row.content_preview.as_deref().unwrap_or("").contains(body)); +} + +#[tokio::test] +async fn flush_now_enqueues_once_and_reports_stale_buffers() { + let (_tmp, cfg) = test_config(); + seed_chat_chunk( + &cfg, + "slack:#eng", + "Phoenix migration ships Friday after the release checklist closes.", + ) + .await; + drain_until_idle(&cfg).await.expect("drain jobs"); + + let first = flush_now_rpc(&cfg).await.expect("flush_now first"); + assert!(first.value.enqueued, "first flush should enqueue work"); + assert!( + first.value.stale_buffers >= 1, + "expected at least one stale buffer after ingest" + ); + + let second = flush_now_rpc(&cfg).await.expect("flush_now second"); + assert!( + !second.value.enqueued, + "same 3-hour window should dedupe duplicate flush triggers" + ); + assert!( + second.value.stale_buffers >= 1, + "deduped flush should still report current stale buffer count" + ); +} + +#[tokio::test] +async fn reset_tree_preserves_raw_archive_and_source_registry() { + let (_tmp, cfg) = test_config(); + let chunk_id = seed_slack_chunk_with_raw_archive(&cfg).await; + let content_root = cfg.memory_tree_content_root(); + let raw_file = content_root + .join("raw") + .join("slack-conn-slack-1") + .join("chats") + .join("1700000000000_1700000000.000100.md"); + let source_file = content_root + .join("raw") + .join("slack-conn-slack-1") + .join("_source.md"); + assert!(raw_file.exists(), "raw archive should exist before reset"); + assert!( + source_file.exists(), + "source registry should exist before reset" + ); + + let stale_summary = content_root + .join("wiki") + .join("summaries") + .join("source-slack-conn-slack-1") + .join("L1") + .join("summary-stale.md"); + std::fs::create_dir_all( + stale_summary + .parent() + .expect("stale summary parent should exist"), + ) + .expect("create stale summary dir"); + std::fs::write(&stale_summary, "stale summary body").expect("write stale summary"); + assert!(stale_summary.exists(), "stale summary fixture should exist"); + + let outcome = reset_tree_rpc(&cfg).await.expect("reset_tree"); + assert_eq!(outcome.value.chunks_requeued, 1); + assert_eq!(outcome.value.jobs_enqueued, 1); + assert!( + outcome.value.tree_rows_deleted >= 1, + "buffer/tree rows should be removed during reset" + ); + + let row = read_chunk_row(&cfg, &chunk_id) + .expect("read chunk row") + .expect("chunk row present after reset"); + assert_eq!(row.lifecycle_status, "pending_extraction"); + assert!(raw_file.exists(), "raw archive must survive reset_tree"); + assert!( + source_file.exists(), + "source registry must survive reset_tree" + ); + assert!( + !content_root.join("wiki").join("summaries").exists(), + "derived wiki summaries should be removed" + ); +} + +#[test] +fn read_chunk_row_returns_none_for_missing_chunk() { + let (_tmp, cfg) = test_config(); + assert!(read_chunk_row(&cfg, "missing-chunk").unwrap().is_none()); +} + +#[test] +fn display_name_unslugs_email_thread_with_user_hint() { + let name = display_name_for_source( + "gmail:alice@example.com|bob@example.com", + Some("alice@example.com"), + ); + assert_eq!(name, "bob@example.com"); +} + +#[test] +fn display_name_falls_back_to_arrow_when_user_unknown() { + let name = display_name_for_source("gmail:alice@example.com|bob@example.com", None); + assert!(name.contains("alice@example.com")); + assert!(name.contains("bob@example.com")); + assert!(name.contains("↔")); +} + +#[test] +fn display_name_strips_platform_prefix() { + assert_eq!( + display_name_for_source("slack:#engineering", None), + "#engineering" + ); +} + +#[test] +fn display_name_handles_multiple_participants_and_trimmed_hint() { + let name = display_name_for_source( + "gmail:Alice@Example.com|bob@example.com|carol@example.com", + Some(" alice@example.com "), + ); + assert_eq!(name, "bob@example.com, carol@example.com"); +} + +#[test] +fn display_name_handles_no_prefix() { + assert_eq!(display_name_for_source("loose-id", None), "loose-id"); +} + +#[test] +fn sanitize_basename_replaces_windows_illegal_characters() { + assert_eq!( + sanitize_basename(r#"chat:slack/#eng\name*?"<>|"#), + "chat-slack-#eng-name------" + ); + assert_eq!(sanitize_basename("safe-name.md"), "safe-name.md"); +} + +#[test] +fn parse_source_kind_str_accepts_known_values_only() { + assert_eq!(parse_source_kind_str("chat"), Some(SourceKind::Chat)); + assert_eq!(parse_source_kind_str("email"), Some(SourceKind::Email)); + assert_eq!( + parse_source_kind_str("document"), + Some(SourceKind::Document) + ); + assert_eq!(parse_source_kind_str("unknown"), None); +} + +#[test] +fn clear_composio_sync_state_removes_only_target_namespace() { + let tmp = TempDir::new().unwrap(); + let _memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + let db_path = tmp.path().join("memory").join("memory.db"); + let conn = rusqlite::Connection::open(&db_path).unwrap(); + + conn.execute( + "INSERT INTO kv_namespace (namespace, key, value_json, updated_at) + VALUES (?1, 'cursor', '{}', 1.0)", + params![KV_NAMESPACE], + ) + .unwrap(); + conn.execute( + "INSERT INTO kv_namespace (namespace, key, value_json, updated_at) + VALUES ('other-namespace', 'cursor', '{}', 2.0)", + [], + ) + .unwrap(); + drop(conn); + + let removed = clear_composio_sync_state(&db_path).unwrap(); + assert_eq!(removed, 1); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let composio_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM kv_namespace WHERE namespace = ?1", + params![KV_NAMESPACE], + |row| row.get(0), + ) + .unwrap(); + let other_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM kv_namespace WHERE namespace = 'other-namespace'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(composio_count, 0); + assert_eq!(other_count, 1); +} + +#[tokio::test] +async fn obsidian_status_registered_when_override_config_lists_content_root() { + let (_tmp, cfg) = test_config(); + let content_root = cfg.memory_tree_content_root(); + // A separate dir standing in for a non-standard Obsidian config + // location, with an obsidian.json that registers the content root. + let cfg_dir = TempDir::new().unwrap(); + let body = format!( + "{{ \"vaults\": {{ \"id0\": {{ \"path\": {}, \"open\": true }} }} }}", + serde_json::to_string(&content_root.to_string_lossy().to_string()).unwrap() + ); + std::fs::write(cfg_dir.path().join("obsidian.json"), body).unwrap(); + + let outcome = + obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string())) + .await + .unwrap(); + + assert!(outcome.value.registered); + assert!(outcome.value.config_found); + assert_eq!( + outcome.value.content_root_abs, + content_root.to_string_lossy().to_string() + ); + // The log reports the booleans but redacts the absolute path (it + // embeds the user's home / username). + assert!( + outcome.logs[0].contains("registered=true"), + "log: {}", + outcome.logs[0] + ); + assert!( + !outcome.logs[0].contains(content_root.to_str().unwrap()), + "log leaked content root: {}", + outcome.logs[0] + ); +} + +#[tokio::test] +async fn obsidian_status_not_registered_for_empty_override_dir() { + let (_tmp, cfg) = test_config(); + // Empty override dir → no obsidian.json there → content root is not a + // registered vault. (A temp content root can't be under any real host + // vault either, so this stays false regardless of the dev machine.) + let cfg_dir = TempDir::new().unwrap(); + let outcome = + obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string())) + .await + .unwrap(); + assert!(!outcome.value.registered); +} + +#[tokio::test] +async fn obsidian_status_blank_override_is_treated_as_none() { + // A whitespace-only override must be normalized to None rather than + // resolving to "." and probing a stray local ./obsidian.json. The temp + // content root isn't under any real host vault, so this stays false. + let (_tmp, cfg) = test_config(); + let outcome = obsidian_vault_status_rpc(&cfg, Some(" ".to_string())) + .await + .unwrap(); + assert!(!outcome.value.registered); +} diff --git a/src/openhuman/memory/sync_pipeline_e2e_test.rs b/src/openhuman/memory/sync_pipeline_e2e_tests.rs similarity index 100% rename from src/openhuman/memory/sync_pipeline_e2e_test.rs rename to src/openhuman/memory/sync_pipeline_e2e_tests.rs diff --git a/src/openhuman/memory/tree_e2e_test.rs b/src/openhuman/memory/tree_e2e_tests.rs similarity index 100% rename from src/openhuman/memory/tree_e2e_test.rs rename to src/openhuman/memory/tree_e2e_tests.rs diff --git a/src/openhuman/memory_sync/composio/providers/slack/rpc.rs b/src/openhuman/memory_sync/composio/providers/slack/rpc.rs index a004f46df..6ec54e180 100644 --- a/src/openhuman/memory_sync/composio/providers/slack/rpc.rs +++ b/src/openhuman/memory_sync/composio/providers/slack/rpc.rs @@ -218,7 +218,7 @@ pub async fn sync_status_rpc( // runner: the existing pattern across this module is to assert factory // dispatch + error wrapping rather than mock the upstream HTTP. The // network-touching paths are smoke-tested upstream in -// `composio::client_tests` / `composio::ops_test` and the +// `composio::client_tests` / `composio::ops_tests` and the // direct-mode-toggle test in `action_tool.rs`. #[cfg(test)] diff --git a/src/openhuman/memory_sync/composio/providers/user_scopes.rs b/src/openhuman/memory_sync/composio/providers/user_scopes.rs index 4e21d000a..c5c107d15 100644 --- a/src/openhuman/memory_sync/composio/providers/user_scopes.rs +++ b/src/openhuman/memory_sync/composio/providers/user_scopes.rs @@ -155,5 +155,5 @@ pub async fn load_or_default(toolkit: &str) -> UserScopePref { } #[cfg(test)] -#[path = "user_scopes_test.rs"] +#[path = "user_scopes_tests.rs"] mod tests; diff --git a/src/openhuman/memory_sync/composio/providers/user_scopes_test.rs b/src/openhuman/memory_sync/composio/providers/user_scopes_tests.rs similarity index 100% rename from src/openhuman/memory_sync/composio/providers/user_scopes_test.rs rename to src/openhuman/memory_sync/composio/providers/user_scopes_tests.rs diff --git a/src/openhuman/memory_tree/retrieval/integration_test.rs b/src/openhuman/memory_tree/retrieval/integration_tests.rs similarity index 100% rename from src/openhuman/memory_tree/retrieval/integration_test.rs rename to src/openhuman/memory_tree/retrieval/integration_tests.rs diff --git a/src/openhuman/memory_tree/retrieval/mod.rs b/src/openhuman/memory_tree/retrieval/mod.rs index b613119b9..b7ea2cb69 100644 --- a/src/openhuman/memory_tree/retrieval/mod.rs +++ b/src/openhuman/memory_tree/retrieval/mod.rs @@ -30,7 +30,7 @@ pub mod types; #[cfg(test)] mod benchmarks; #[cfg(test)] -mod integration_test; +mod integration_tests; pub use drill_down::drill_down; pub use fetch::fetch_leaves; diff --git a/src/openhuman/subconscious/integration_test.rs b/src/openhuman/subconscious/integration_tests.rs similarity index 100% rename from src/openhuman/subconscious/integration_test.rs rename to src/openhuman/subconscious/integration_tests.rs diff --git a/src/openhuman/subconscious/mod.rs b/src/openhuman/subconscious/mod.rs index a7ceb370f..65ae98849 100644 --- a/src/openhuman/subconscious/mod.rs +++ b/src/openhuman/subconscious/mod.rs @@ -14,7 +14,7 @@ pub mod types; pub mod decision_log; #[cfg(test)] -mod integration_test; +mod integration_tests; pub use engine::SubconsciousEngine; pub use reflection::{Reflection, ReflectionKind, MAX_REFLECTIONS_PER_TICK}; diff --git a/src/openhuman/tool_registry/ops.rs b/src/openhuman/tool_registry/ops.rs index 9fea029e7..f9a3719cd 100644 --- a/src/openhuman/tool_registry/ops.rs +++ b/src/openhuman/tool_registry/ops.rs @@ -495,5 +495,5 @@ fn title_from_function(function: &str) -> String { } #[cfg(test)] -#[path = "ops_test.rs"] +#[path = "ops_tests.rs"] mod tests; diff --git a/src/openhuman/tool_registry/ops_test.rs b/src/openhuman/tool_registry/ops_tests.rs similarity index 100% rename from src/openhuman/tool_registry/ops_test.rs rename to src/openhuman/tool_registry/ops_tests.rs diff --git a/src/openhuman/webview_accounts/mod.rs b/src/openhuman/webview_accounts/mod.rs index af6f18edf..ebbc09407 100644 --- a/src/openhuman/webview_accounts/mod.rs +++ b/src/openhuman/webview_accounts/mod.rs @@ -25,7 +25,7 @@ mod ops; pub mod wechat_ingest; #[cfg(test)] -#[path = "wechat_ingest_test.rs"] +#[path = "wechat_ingest_tests.rs"] mod tests; pub use ops::detect_webview_logins; diff --git a/src/openhuman/webview_accounts/wechat_ingest_test.rs b/src/openhuman/webview_accounts/wechat_ingest_tests.rs similarity index 100% rename from src/openhuman/webview_accounts/wechat_ingest_test.rs rename to src/openhuman/webview_accounts/wechat_ingest_tests.rs