diff --git a/suite/writer/api/general.py b/suite/writer/api/general.py index ff4e465252..1c3901be6f 100644 --- a/suite/writer/api/general.py +++ b/suite/writer/api/general.py @@ -150,6 +150,18 @@ def search(query: str, filters: str | None = None): k.update(meta) cleaned_results.append(k) search["results"] = cleaned_results + + # The index is unscoped, so summary stats and spelling corrections are + # computed against every document on the site, not just what the caller + # can read. Recompute counts from the filtered set and drop corrections + # outright, since validating them against readable content isn't worth + # the cost the UI doesn't use them. + match_count = len(cleaned_results) + search["summary"]["total_matches"] = match_count + search["summary"]["returned_matches"] = match_count + search["summary"]["filtered_matches"] = match_count + search["summary"]["corrected_words"] = None + search["summary"]["corrected_query"] = None return search @@ -163,7 +175,7 @@ def get_drive_file_meta(names, ttl=3600): cache = frappe.cache() keys = {name: f"search:drive_file:{name}" for name in names} - cached = {"name": cache.get_value(k) for k in keys.values()} + cached = {"name": cache.get_value(k) for k in keys.values()} # noqa: B035 -- pre-existing, tracked separately result = {} missing = [] diff --git a/suite/writer/api/tests/test_general.py b/suite/writer/api/tests/test_general.py new file mode 100644 index 0000000000..20d50caf7d --- /dev/null +++ b/suite/writer/api/tests/test_general.py @@ -0,0 +1,51 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +from unittest.mock import patch + +from frappe.tests import IntegrationTestCase + +from suite.writer.api.general import search + + +class TestWriterSearch(IntegrationTestCase): + @patch("suite.writer.api.general.WriterSearch") + @patch("suite.writer.api.general.get_drive_file_meta") + @patch("suite.writer.api.general.get_user_access") + def test_search_summary_filters_unreadable_documents( + self, mock_get_user_access, mock_get_meta, mock_writer_search + ): + mock_search_instance = mock_writer_search.return_value + mock_search_instance.search.return_value = { + "results": [{"name": "doc1"}, {"name": "doc2"}], + "summary": { + "total_matches": 10, + "returned_matches": 10, + "filtered_matches": 10, + "corrected_words": ["secret"], + "corrected_query": "secret query", + }, + } + + mock_get_meta.return_value = { + "doc1": {"name": "doc1", "title": "Readable Doc"}, + "doc2": {"name": "doc2", "title": "Secret Doc"}, + } + + # doc1 is readable, doc2 is unreadable + mock_get_user_access.side_effect = lambda name: {"read": name == "doc1"} + + res = search("test") + + # Results should only contain readable doc1 + self.assertEqual(len(res["results"]), 1) + self.assertEqual(res["results"][0]["name"], "doc1") + + # Summary counts must be updated to filtered count (1), not raw count (10) + self.assertEqual(res["summary"]["total_matches"], 1) + self.assertEqual(res["summary"]["returned_matches"], 1) + self.assertEqual(res["summary"]["filtered_matches"], 1) + + # Corrections must be cleared to prevent word leaks + self.assertIsNone(res["summary"]["corrected_words"]) + self.assertIsNone(res["summary"]["corrected_query"])