""" Tests for coverage gaps in research_service.py. Targets specific uncovered lines: - run_research_process early termination (307-313) - SettingsContext inner class (358-380) - progress_callback termination check (428-434) - LLM config error handling with ValueError raise (605-630) - Search engine config error handling with ValueError raise (646-670) - Search error classification (703-727) - cleanup_research_resources test-mode delay path (1693-1699) - handle_termination exception path (1797-1800) - cancel_research database exception path (1860-1864) - cancel_research outer exception path (1867-1871) """ from contextlib import contextmanager from unittest.mock import MagicMock, patch from loguru import logger # Register MILESTONE log level used by progress_callback try: logger.level("MILESTONE") except ValueError: logger.level("MILESTONE", no=26) # Module path constants MODULE = "local_deep_research.web.services.research_service" GLOBALS_MOD = "local_deep_research.web.routes.globals" THREAD_SETTINGS_MOD = "local_deep_research.config.thread_settings" SETTINGS_LOGGER_MOD = "local_deep_research.settings.logger" QUEUE_PROC_MOD = "local_deep_research.web.queue.processor_v2" ENV_REGISTRY_MOD = "local_deep_research.settings.env_registry" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _fake_session_ctx(session=None): """Return a context manager factory that yields a mock session.""" if session is None: session = MagicMock() @contextmanager def ctx(username=None): yield session return ctx def _make_mock_research(status=None, research_meta=None): """Build a minimal ResearchHistory mock.""" r = MagicMock() r.status = status r.research_meta = research_meta r.created_at = "2024-01-01T00:00:00" r.report_content = None return r def _get_raw_run_research_process(): """Get the unwrapped (no decorators) run_research_process function.""" from local_deep_research.web.services.research_service import ( run_research_process, ) return run_research_process.__wrapped__.__wrapped__ def _base_run_patches(mock_session=None): """Return a dict of patches needed for run_research_process tests.""" if mock_session is None: mock_session = MagicMock() mock_research = _make_mock_research(research_meta={}) mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research return { f"{MODULE}.get_user_db_session": _fake_session_ctx(mock_session), f"{MODULE}.handle_termination": MagicMock(), f"{MODULE}.cleanup_research_resources": MagicMock(), f"{MODULE}.set_search_context": MagicMock(), f"{MODULE}.SocketIOService": MagicMock(), f"{MODULE}.calculate_duration": MagicMock(return_value=5), f"{MODULE}.ErrorReportGenerator": MagicMock( return_value=MagicMock( generate_error_report=MagicMock(return_value="error report") ) ), f"{GLOBALS_MOD}.is_termination_requested": MagicMock( return_value=False ), f"{GLOBALS_MOD}.is_research_active": MagicMock(return_value=False), f"{GLOBALS_MOD}.update_progress_and_check_active": MagicMock( return_value=(5, True) ), f"{SETTINGS_LOGGER_MOD}.log_settings": MagicMock(), f"{THREAD_SETTINGS_MOD}.set_settings_context": MagicMock(), f"{QUEUE_PROC_MOD}.queue_processor": MagicMock(), } # --------------------------------------------------------------------------- # 1. run_research_process early termination (lines 307-313) # --------------------------------------------------------------------------- class TestRunResearchEarlyTermination: """When termination is requested before the research even starts, the function should clean up and return immediately.""" def test_returns_immediately_when_terminated_before_start(self): """If is_termination_requested returns True on entry, the function calls cleanup_research_resources and returns without running research.""" func = _get_raw_run_research_process() mock_cleanup = MagicMock() patches = _base_run_patches() # Override: termination already requested patches[f"{GLOBALS_MOD}.is_termination_requested"] = MagicMock( return_value=True ) patches[f"{MODULE}.cleanup_research_resources"] = mock_cleanup # AdvancedSearchSystem should never be instantiated patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock() stack = [] for target, mock_obj in patches.items(): p = patch(target, mock_obj) stack.append(p) p.start() try: func(1, "test query", "quick", username="testuser") finally: for p in reversed(stack): p.stop() # cleanup_research_resources must have been called. Terminated # before start → reports SUSPENDED, not a spurious "completed". mock_cleanup.assert_called_once_with( 1, "testuser", user_password=None, final_status="suspended" ) # AdvancedSearchSystem should not have been created patches[f"{MODULE}.AdvancedSearchSystem"].assert_not_called() # --------------------------------------------------------------------------- # 2. SettingsContext inner class (lines 358-380) # --------------------------------------------------------------------------- class TestSettingsContextInnerClass: """The SettingsContext inner class in run_research_process extracts values from a settings snapshot and provides get_setting().""" def test_settings_context_extracts_dict_values(self): """When snapshot items are full setting objects with 'value' keys, SettingsContext should extract just the value.""" func = _get_raw_run_research_process() captured_contexts = [] original_set = MagicMock() def capture_settings_context(ctx): captured_contexts.append(ctx) original_set(ctx) mock_system = MagicMock() mock_system.analyze_topic.return_value = { "findings": "test", "formatted_findings": "test result", } patches = _base_run_patches() patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock( return_value=mock_system ) patches[f"{THREAD_SETTINGS_MOD}.set_settings_context"] = ( capture_settings_context ) snapshot = { "llm.model": {"value": "gpt-4", "type": "string"}, "search.max_results": {"value": 10, "type": "int"}, "plain_key": "just_a_value", } stack = [] for target, mock_obj in patches.items(): p = patch(target, mock_obj) stack.append(p) p.start() try: func( 1, "test", "quick", username="user1", settings_snapshot=snapshot, ) finally: for p in reversed(stack): p.stop() assert len(captured_contexts) == 1 ctx = captured_contexts[0] # Full setting objects should have their values extracted assert ctx.get_setting("llm.model") == "gpt-4" assert ctx.get_setting("search.max_results") == 10 # Plain values should be kept as-is assert ctx.get_setting("plain_key") == "just_a_value" def test_settings_context_returns_default_for_missing_key(self): """get_setting returns default when key is not in snapshot.""" func = _get_raw_run_research_process() captured_contexts = [] def capture(ctx): captured_contexts.append(ctx) mock_system = MagicMock() mock_system.analyze_topic.return_value = { "findings": "test", "formatted_findings": "result", } patches = _base_run_patches() patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock( return_value=mock_system ) patches[f"{THREAD_SETTINGS_MOD}.set_settings_context"] = capture stack = [] for target, mock_obj in patches.items(): p = patch(target, mock_obj) stack.append(p) p.start() try: func( 1, "test", "quick", username="user1", settings_snapshot={"search.tool": "searxng"}, ) finally: for p in reversed(stack): p.stop() ctx = captured_contexts[0] assert ctx.get_setting("nonexistent.key", "fallback") == "fallback" assert ctx.get_setting("nonexistent.key") is None # --------------------------------------------------------------------------- # 2b. Worker fails closed when the run has no configured primary engine # --------------------------------------------------------------------------- class TestWorkerFailsClosedOnMissingPrimary: """The CLI/scheduler/queue path bypasses the API precheck, so the worker is the only gate. A snapshot with no ``search.tool`` must make run_research_process refuse the run at the egress build (resolve_run_primary_engine raises ValueError) — fail closed — rather than silently running on the public ``searxng`` default. Regression guard for the security-relevant fail-closed change.""" def test_missing_primary_never_reaches_search_system(self): func = _get_raw_run_research_process() mock_system = MagicMock() mock_system.analyze_topic.return_value = { "findings": "x", "formatted_findings": "x", } patches = _base_run_patches() patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock( return_value=mock_system ) stack = [] for target, mock_obj in patches.items(): p = patch(target, mock_obj) stack.append(p) p.start() try: # Empty snapshot => no search.tool => egress build raises => # run_research_process handles it as a failed run internally. func(1, "test", "quick", username="user1", settings_snapshot={}) finally: for p in reversed(stack): p.stop() # Fail closed: the run aborted before the search system ran. (A # realistic snapshot WITH search.tool does reach analyze_topic — see # TestSettingsContextInnerClass — so this isolates the egress refusal.) mock_system.analyze_topic.assert_not_called() # --------------------------------------------------------------------------- # 3. progress_callback termination check (lines 428-434) # --------------------------------------------------------------------------- class TestProgressCallbackTerminationCheck: """progress_callback checks is_termination_requested on every call and raises ResearchTerminatedException if termination was requested.""" def test_progress_callback_raises_on_termination(self): """When termination is requested mid-research, progress_callback should call handle_termination and raise ResearchTerminatedException.""" from local_deep_research.exceptions import ( ResearchTerminatedException, ) func = _get_raw_run_research_process() call_count = [0] mock_handle = MagicMock() def termination_check(rid): call_count[0] += 1 # Return True on second call (first call is the early-exit check, # second call is from inside progress_callback) return call_count[0] >= 2 mock_system = MagicMock() # analyze_topic will trigger progress_callback which triggers termination mock_system.analyze_topic.side_effect = ResearchTerminatedException( "terminated" ) patches = _base_run_patches() patches[f"{GLOBALS_MOD}.is_termination_requested"] = termination_check patches[f"{MODULE}.handle_termination"] = mock_handle patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock( return_value=mock_system ) stack = [] for target, mock_obj in patches.items(): p = patch(target, mock_obj) stack.append(p) p.start() try: # The function catches ResearchTerminatedException internally func( 1, "test", "quick", username="user1", settings_snapshot={"search.tool": "searxng"}, ) finally: for p in reversed(stack): p.stop() # The function should not propagate ResearchTerminatedException # (it is caught in the except ResearchTerminatedException block) # --------------------------------------------------------------------------- # 4. LLM config error handling (lines 605-630) # --------------------------------------------------------------------------- class TestLLMConfigErrorRaisesValueError: """When get_llm raises an error containing config-related keywords, the code re-raises as ValueError('LLM Configuration Error: ...').""" def _run_with_llm_error(self, error_msg, model="gpt-4", provider="openai"): """Run research with get_llm raising an exception containing error_msg.""" func = _get_raw_run_research_process() mock_qp = MagicMock() patches = _base_run_patches() patches[f"{MODULE}.get_llm"] = MagicMock( side_effect=Exception(error_msg) ) patches[f"{QUEUE_PROC_MOD}.queue_processor"] = mock_qp stack = [] for target, mock_obj in patches.items(): p = patch(target, mock_obj) stack.append(p) p.start() try: func( 1, "test", "quick", username="user1", model=model, model_provider=provider, settings_snapshot={"search.tool": "searxng"}, ) finally: for p in reversed(stack): p.stop() return mock_qp def test_llamacpp_keyword_triggers_config_error(self): """'llamacpp' is classified as an LLM config problem, but the raw exception text must not reach the client (CWE-209).""" mock_qp = self._run_with_llm_error("llamacpp model failed to load") # Error is caught by outer handler and queued mock_qp.queue_error_update.assert_called_once() call_kwargs = str(mock_qp.queue_error_update.call_args) # Classified as an LLM config problem -> safe generic message... assert "There was a problem with the LLM configuration" in call_kwargs # ...with the raw exception detail stripped (it can carry server paths). assert "llamacpp model failed to load" not in call_kwargs def test_model_path_keyword_triggers_config_error(self): """'model path' -> LLM config category; raw path not leaked.""" mock_qp = self._run_with_llm_error("model path /foo/bar does not exist") mock_qp.queue_error_update.assert_called_once() call_kwargs = str(mock_qp.queue_error_update.call_args) assert "There was a problem with the LLM configuration" in call_kwargs assert "/foo/bar" not in call_kwargs def test_gguf_keyword_triggers_config_error(self): """'.gguf' -> LLM config category; raw detail not leaked.""" mock_qp = self._run_with_llm_error("please provide a valid .gguf file") mock_qp.queue_error_update.assert_called_once() call_kwargs = str(mock_qp.queue_error_update.call_args) assert "There was a problem with the LLM configuration" in call_kwargs assert "please provide a valid .gguf file" not in call_kwargs def test_non_config_llm_error_is_genericized(self): """An unrecognized LLM error (no config keyword) reaches the central handler's `else` branch: the raw exception text must NOT reach the client (CWE-209) — only the generic 'unexpected error' message.""" mock_qp = self._run_with_llm_error( "unexpected null pointer in inference at 0xdeadbeef" ) mock_qp.queue_error_update.assert_called_once() error_str = str(mock_qp.queue_error_update.call_args) assert "LLM Configuration Error" not in error_str # The genuine fix: the raw exception text is genericized. assert "null pointer in inference" not in error_str assert "unexpected error" in error_str.lower() # --------------------------------------------------------------------------- # 5. Search engine config error handling (lines 646-670) # --------------------------------------------------------------------------- class TestSearchEngineConfigError: """When get_search raises with config keywords, the code re-raises as ValueError('Search Engine Configuration Error ...').""" def _run_with_search_error(self, error_msg, search_engine="searxng"): func = _get_raw_run_research_process() mock_qp = MagicMock() patches = _base_run_patches() patches[f"{MODULE}.get_search"] = MagicMock( side_effect=Exception(error_msg) ) patches[f"{QUEUE_PROC_MOD}.queue_processor"] = mock_qp stack = [] for target, mock_obj in patches.items(): p = patch(target, mock_obj) stack.append(p) p.start() try: func( 1, "test", "quick", username="user1", search_engine=search_engine, settings_snapshot={"search.tool": "searxng"}, ) finally: for p in reversed(stack): p.stop() return mock_qp def test_searxng_keyword_triggers_config_error(self): """'searxng' -> search config category; raw detail not leaked (CWE-209).""" mock_qp = self._run_with_search_error("SearXNG instance unreachable") mock_qp.queue_error_update.assert_called_once() call_kwargs = str(mock_qp.queue_error_update.call_args) assert ( "There was a problem with the search engine configuration" in call_kwargs ) assert "instance unreachable" not in call_kwargs def test_api_key_keyword_triggers_config_error(self): """'api_key' -> search config category; raw detail not leaked.""" mock_qp = self._run_with_search_error( "Missing api_key for search provider" ) mock_qp.queue_error_update.assert_called_once() call_kwargs = str(mock_qp.queue_error_update.call_args) assert ( "There was a problem with the search engine configuration" in call_kwargs ) assert "Missing api_key for search provider" not in call_kwargs def test_connection_keyword_triggers_config_error(self): """'connection' -> search config category; raw detail not leaked.""" mock_qp = self._run_with_search_error( "Connection refused by search backend" ) mock_qp.queue_error_update.assert_called_once() call_kwargs = str(mock_qp.queue_error_update.call_args) assert ( "There was a problem with the search engine configuration" in call_kwargs ) assert "Connection refused by search backend" not in call_kwargs def test_non_config_search_error_is_genericized(self): """An unrecognized search error reaches the `else` branch: raw text must NOT reach the client (CWE-209).""" mock_qp = self._run_with_search_error( "some random internal crash at /srv/internal" ) mock_qp.queue_error_update.assert_called_once() error_str = str(mock_qp.queue_error_update.call_args) assert "Search Engine Configuration Error" not in error_str assert "/srv/internal" not in error_str assert "unexpected error" in error_str.lower() # --------------------------------------------------------------------------- # 6. Search error classification (lines 703-727) # --------------------------------------------------------------------------- class TestSearchErrorClassification: """system.analyze_topic() failures are classified by HTTP status code or connection error pattern and re-raised with improved messages.""" def _run_with_analyze_error(self, error_msg): func = _get_raw_run_research_process() mock_qp = MagicMock() mock_system = MagicMock() mock_system.analyze_topic.side_effect = Exception(error_msg) patches = _base_run_patches() patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock( return_value=mock_system ) patches[f"{QUEUE_PROC_MOD}.queue_processor"] = mock_qp stack = [] for target, mock_obj in patches.items(): p = patch(target, mock_obj) stack.append(p) p.start() try: func( 1, "test", "quick", username="user1", settings_snapshot={"search.tool": "searxng"}, ) finally: for p in reversed(stack): p.stop() return mock_qp def test_503_classified_as_ollama_unavailable(self): """HTTP 503 is classified as ollama_unavailable by the inner handler (lines 709-711), then the outer handler (lines 1469-1473) rewrites it to a user-friendly message with a solution hint.""" mock_qp = self._run_with_analyze_error( "Request failed with status code: 503" ) mock_qp.queue_error_update.assert_called_once() call_kwargs = mock_qp.queue_error_update.call_args[1] # Outer handler replaces the error type marker with user-friendly text assert ( "Ollama AI service is unavailable" in call_kwargs["error_message"] ) assert "solution" in call_kwargs["metadata"] assert "ollama serve" in call_kwargs["metadata"]["solution"] def test_404_classified_as_model_not_found(self): """HTTP 404 is classified as model_not_found by the inner handler (lines 712-714), then rewritten by outer handler (lines 1474-1478).""" mock_qp = self._run_with_analyze_error( "Request failed with status code: 404" ) mock_qp.queue_error_update.assert_called_once() call_kwargs = mock_qp.queue_error_update.call_args[1] assert "model not found" in call_kwargs["error_message"].lower() assert "solution" in call_kwargs["metadata"] assert "ollama pull" in call_kwargs["metadata"]["solution"] def test_other_status_code_classified_as_api_error(self): """Other HTTP status codes are classified as api_error; the client message is genericized and must not carry the raw status-line tail (CWE-209), while the solution hint is preserved.""" mock_qp = self._run_with_analyze_error( "Request failed with status code: 429 LEAKED-INTERNAL-DETAIL" ) mock_qp.queue_error_update.assert_called_once() call_kwargs = mock_qp.queue_error_update.call_args[1] assert ( call_kwargs["error_message"] == "The language model API rejected the request." ) assert "LEAKED-INTERNAL-DETAIL" not in str(call_kwargs) assert "solution" in call_kwargs["metadata"] assert "API" in call_kwargs["metadata"]["solution"] def test_connection_error_classified_correctly(self): """Connection errors (case-insensitive) are classified as connection_error by lines 720-722, then rewritten by outer handler (lines 1479-1483).""" mock_qp = self._run_with_analyze_error( "Connection refused to localhost:11434" ) mock_qp.queue_error_update.assert_called_once() call_kwargs = mock_qp.queue_error_update.call_args[1] assert "Connection error" in call_kwargs["error_message"] assert "solution" in call_kwargs["metadata"] def test_unknown_error_is_genericized(self): """Errors without known patterns keep error_type=unknown and match no rewrite tier, so they reach the central handler's `else` branch: the raw text must be genericized (CWE-209) and no solution hint is added.""" mock_qp = self._run_with_analyze_error( "something completely unexpected at /srv/secret happened" ) mock_qp.queue_error_update.assert_called_once() call_kwargs = mock_qp.queue_error_update.call_args[1] # The raw text is not surfaced; a generic message is used instead. assert "/srv/secret" not in str(call_kwargs) assert "unexpected error" in call_kwargs["error_message"].lower() # No solution context is added for unrecognized errors assert "solution" not in call_kwargs["metadata"] # --------------------------------------------------------------------------- # 7. cleanup_research_resources test-mode delay (lines 1693-1699) # --------------------------------------------------------------------------- class TestCleanupResearchResourcesTestMode: """When is_test_mode() returns True, cleanup adds a 5-second delay.""" @patch(f"{MODULE}.SocketIOService") @patch(f"{GLOBALS_MOD}.cleanup_research") @patch(f"{QUEUE_PROC_MOD}.queue_processor") @patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=True) def test_test_mode_sleeps_before_cleanup( self, mock_test_mode, mock_qp, mock_cleanup, mock_socket ): """In test mode, time.sleep(5) is called before continuing cleanup.""" from local_deep_research.web.services.research_service import ( cleanup_research_resources, ) with patch(f"{MODULE}.time.sleep") as mock_sleep: cleanup_research_resources(42, username="user1") mock_sleep.assert_called_once_with(5) mock_test_mode.assert_called_once() @patch(f"{MODULE}.SocketIOService") @patch(f"{GLOBALS_MOD}.cleanup_research") @patch(f"{QUEUE_PROC_MOD}.queue_processor") @patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False) def test_non_test_mode_does_not_sleep( self, mock_test_mode, mock_qp, mock_cleanup, mock_socket ): """Outside test mode, no delay is added.""" from local_deep_research.web.services.research_service import ( cleanup_research_resources, ) with patch(f"{MODULE}.time.sleep") as mock_sleep: cleanup_research_resources(42, username="user1") mock_sleep.assert_not_called() # --------------------------------------------------------------------------- # 8. handle_termination exception path (lines 1797-1800) # --------------------------------------------------------------------------- class TestHandleTerminationExceptionPath: """When queue_processor.queue_error_update raises, handle_termination catches the exception and still proceeds to cleanup.""" @patch(f"{MODULE}.cleanup_research_resources") def test_queue_error_exception_is_caught_and_cleanup_still_runs( self, mock_cleanup ): """If queue_error_update raises, the exception is caught and cleanup_research_resources is still called.""" from local_deep_research.web.services.research_service import ( handle_termination, ) mock_qp = MagicMock() mock_qp.queue_error_update.side_effect = RuntimeError( "queue processor down" ) with patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp): # Should not raise handle_termination(99, username="user1") # cleanup_research_resources must still be called despite the error mock_cleanup.assert_called_once_with( 99, "user1", final_status="suspended" ) @patch(f"{MODULE}.cleanup_research_resources") def test_successful_termination_queues_suspended_status(self, mock_cleanup): """On success, handle_termination queues a SUSPENDED status update.""" from local_deep_research.constants import ResearchStatus from local_deep_research.web.services.research_service import ( handle_termination, ) mock_qp = MagicMock() with patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp): handle_termination(55, username="user1") mock_qp.queue_error_update.assert_called_once() call_kwargs = mock_qp.queue_error_update.call_args[1] assert call_kwargs["status"] == ResearchStatus.SUSPENDED assert call_kwargs["username"] == "user1" assert call_kwargs["research_id"] == 55 mock_cleanup.assert_called_once_with( 55, "user1", final_status="suspended" ) # --------------------------------------------------------------------------- # 9. cancel_research database exception path (lines 1860-1864) # --------------------------------------------------------------------------- class TestCancelResearchDbException: """When the database query in the inactive-research branch of cancel_research raises, the function returns False.""" def test_db_exception_returns_false(self): """Database errors in the non-active path return False.""" from local_deep_research.web.services.research_service import ( cancel_research, ) with patch(f"{GLOBALS_MOD}.set_termination_flag"): with patch(f"{GLOBALS_MOD}.is_research_active", return_value=False): with patch( f"{MODULE}.get_user_db_session", side_effect=RuntimeError("db connection lost"), ): result = cancel_research(123, username="user1") assert result is False def test_db_not_found_returns_false(self): """When research is not found in database, returns False.""" from local_deep_research.web.services.research_service import ( cancel_research, ) mock_session = MagicMock() mock_session.query.return_value.filter_by.return_value.first.return_value = None with patch(f"{GLOBALS_MOD}.set_termination_flag"): with patch(f"{GLOBALS_MOD}.is_research_active", return_value=False): with patch( f"{MODULE}.get_user_db_session", _fake_session_ctx(mock_session), ): result = cancel_research(123, username="user1") assert result is False # --------------------------------------------------------------------------- # 10. cancel_research outer exception path (lines 1867-1871) # --------------------------------------------------------------------------- class TestCancelResearchOuterException: """When an unexpected exception occurs in cancel_research's outer try block (e.g., set_termination_flag itself raises), the function catches the exception and returns False.""" def test_set_termination_flag_raises_returns_false(self): """If set_termination_flag raises, cancel_research returns False.""" from local_deep_research.web.services.research_service import ( cancel_research, ) with patch( f"{GLOBALS_MOD}.set_termination_flag", side_effect=RuntimeError("unexpected failure"), ): result = cancel_research(456, username="user1") assert result is False def test_is_research_active_raises_returns_false(self): """If is_research_active raises, cancel_research returns False.""" from local_deep_research.web.services.research_service import ( cancel_research, ) with patch(f"{GLOBALS_MOD}.set_termination_flag"): with patch( f"{GLOBALS_MOD}.is_research_active", side_effect=RuntimeError("state corrupted"), ): result = cancel_research(789, username="user1") assert result is False def test_active_research_calls_handle_termination_and_returns_true(self): """When research IS active, cancel_research calls handle_termination and returns True.""" from local_deep_research.web.services.research_service import ( cancel_research, ) mock_handle = MagicMock() with patch(f"{GLOBALS_MOD}.set_termination_flag"): with patch(f"{GLOBALS_MOD}.is_research_active", return_value=True): with patch(f"{MODULE}.handle_termination", mock_handle): result = cancel_research(10, username="user1") assert result is True mock_handle.assert_called_once_with(10, "user1") # --------------------------------------------------------------------------- # CWE-209: the persisted error REPORT must not embed raw exception text # --------------------------------------------------------------------------- class TestUnexpectedFailureDoesNotLeakRawException: """The enhanced error report is persisted and retrievable via the report routes, so its error_message must be the sanitized user_friendly_error, not raw str(e) (CWE-209). This guards the report sink specifically (the queue/status surface is guarded by the TestLLMConfigError / TestSearchEngineConfigError ``_is_genericized`` cases).""" def test_unexpected_exception_text_is_not_in_error_report(self): # Keyword-free so it is NOT classified as a config/known error and # reaches the central handler's generic `else` branch. secret = "ZZSECRETZZ /opt/internal raw traceback frame 0xdeadbeef" captured = {} def _capture_report(*args, **kwargs): captured["error_message"] = kwargs.get("error_message", "") return "error report" mock_generator = MagicMock() mock_generator.generate_error_report.side_effect = _capture_report patches = _base_run_patches() patches[f"{MODULE}.ErrorReportGenerator"] = MagicMock( return_value=mock_generator ) # search.tool in the snapshot gets the run past the egress precheck so # the secret-bearing exception actually reaches the failure handler. patches[f"{MODULE}.get_search"] = MagicMock( side_effect=Exception(secret) ) func = _get_raw_run_research_process() stack = [] for target, mock_obj in patches.items(): p = patch(target, mock_obj) stack.append(p) p.start() try: func( 1, "test query", "quick", username="testuser", search_engine="searxng", settings_snapshot={"search.tool": "searxng"}, ) finally: for p in reversed(stack): p.stop() assert "error_message" in captured, ( "error report was not generated; the except handler was not reached" ) assert secret not in captured["error_message"] assert "unexpected error" in captured["error_message"].lower()