This is an automated email from the ASF dual-hosted git repository.

tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new e960f7c0d0 [ZEPPELIN-6414] Apply the authorization filter before the 
search cutoff
e960f7c0d0 is described below

commit e960f7c0d00d2c52dd22e1cf5c7520964d177142
Author: 김동환 <[email protected]>
AuthorDate: Sun Sep 13 15:43:34 2026 +0900

    [ZEPPELIN-6414] Apply the authorization filter before the search cutoff
    
    ### What is this PR for?
    
    `/notebook/search` asked the search service for results and then dropped 
the ones the caller may not read. Both search services cut their results down 
to twenty before returning them, so that removal ran on an already shortened 
list:
    
    * A caller with access to few notes is served fewer results than it is 
allowed to see. If the twenty highest scoring hits all belong to notes it 
cannot read, the search comes back empty while its own matching notes sit just 
below the cut.
    * In `EmbeddingSearch` the entries the caller cannot read are also counted 
into the table boost of Phase 1, so notes that never reach the caller still 
move the ranking of the ones that do.
    
    The read check now travels with the query, as a predicate over the note id, 
and every implementation applies it before it cuts anything.
    
    | | before | after |
    | --- | --- | --- |
    | `EmbeddingSearch` | scores every entry, cuts to `MAX_RESULTS`, REST 
filters | skips unreadable entries while scoring, so neither the table weights 
nor the cutoff see them |
    | `LuceneSearch` | `searcher.search(query, 20)`, REST filters | walks the 
hits in score order with `searchAfter` until a full page of readable hits is 
collected, or the hits run out |
    | `NotebookRestApi` | four permission calls per result, list mutated while 
iterating | passes the predicate, no post-filtering |
    
    `LuceneSearch` reads only the id field to decide on a hit and loads the 
whole document for the hits it keeps, so hits that are dropped cost one stored 
field read and no highlighting.
    
    `query(String)` is removed rather than kept next to the new method: leaving 
it would leave a way to search without saying who is asking, which is the 
defect this issue is about. `SearchService` is bound in `ZeppelinServer` to the 
three implementations in the repository and is not reachable as an extension 
point, so nothing outside the tree implements it.
    
    Note that this changes what the result limit means: it is now the top 
twenty results the caller may read, rather than what is left of the top twenty 
overall after filtering.
    
    ### What type of PR is it?
    Bug Fix
    
    ### What is the Jira issue?
    * https://issues.apache.org/jira/browse/ZEPPELIN-6414
    
    ### How should this be tested?
    
    New tests in both search services, 
`keepsReadableResultsThatTheCutWouldHide`, put twenty-eight matching notes in 
the index and let the caller read three of them. Each test first checks its own 
fixture: it queries with an allow-all predicate and asserts that the readable 
notes really do fall outside the cut, so that the test cannot quietly stop 
testing anything if the limit or the scoring changes later. It then queries 
with the real predicate and expects all three readable notes back a [...]
    
    `LuceneSearchTest.returnsNothingWhenTheCallerMayReadNothing` covers the 
walk ending on its own when no hit is readable.
    
    Run locally:
    
    * `LuceneSearchTest` - 12 tests, including the two new ones
    * `EmbeddingSearchTest` - 12 tests, including the new one, run with 
`ZEPPELIN_EMBEDDING_TEST=true` after `bin/install-search-model.sh`
    
    The existing call sites in both test classes now pass `id -> true` 
explicitly.
    
    ### Questions:
    * Does the license files need to update? No
    * Is there breaking changes for older versions? 
`SearchService.query(String)` is replaced by `query(String, 
Predicate<String>)`. Only the three in-tree implementations and the REST 
resource use it.
    * Does this needs documentation? No
    
    ### Possible follow-up
    
    Indexing the note id as its own field would let `LuceneSearch` hand the 
permission filter to Lucene instead of walking the hits, but it needs an index 
schema change and a rebuild, so it is left out of this issue.
    
    
    Closes #5427 from dev-donghwan/ZEPPELIN-6414.
    
    Signed-off-by: ChanHo Lee <[email protected]>
---
 .../org/apache/zeppelin/rest/NotebookRestApi.java  |  14 +-
 .../apache/zeppelin/search/EmbeddingSearch.java    |  15 ++-
 .../org/apache/zeppelin/search/LuceneSearch.java   | 142 +++++++++++++--------
 .../apache/zeppelin/search/NoSearchService.java    |   3 +-
 .../org/apache/zeppelin/search/SearchService.java  |  17 ++-
 .../zeppelin/search/EmbeddingSearchTest.java       |  67 ++++++++--
 .../apache/zeppelin/search/LuceneSearchTest.java   |  70 ++++++++--
 7 files changed, 234 insertions(+), 94 deletions(-)

diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/NotebookRestApi.java 
b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/NotebookRestApi.java
index 184e01f080..fd7f87c5a6 100644
--- 
a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/NotebookRestApi.java
+++ 
b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/NotebookRestApi.java
@@ -1211,18 +1211,8 @@ public class NotebookRestApi extends AbstractRestApi {
     HashSet<String> userAndRoles = new HashSet<>();
     userAndRoles.add(principal);
     userAndRoles.addAll(roles);
-    List<Map<String, String>> notesFound = noteSearchService.query(queryTerm);
-    for (int i = 0; i < notesFound.size(); i++) {
-      String[] ids = notesFound.get(i).get("id").split("/", 2);
-      String noteId = ids[0];
-      if (!authorizationService.isOwner(noteId, userAndRoles) &&
-              !authorizationService.isReader(noteId, userAndRoles) &&
-              !authorizationService.isWriter(noteId, userAndRoles) &&
-              !authorizationService.isRunner(noteId, userAndRoles)) {
-        notesFound.remove(i);
-        i--;
-      }
-    }
+    List<Map<String, String>> notesFound = noteSearchService.query(queryTerm,
+        noteId -> authorizationService.isReader(noteId, userAndRoles));
     LOGGER.info("{} notes found", notesFound.size());
     return new JsonResponse<>(Status.OK, notesFound).build();
   }
diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/search/EmbeddingSearch.java 
b/zeppelin-server/src/main/java/org/apache/zeppelin/search/EmbeddingSearch.java
index 2d60d3fc28..c95e6fbe70 100644
--- 
a/zeppelin-server/src/main/java/org/apache/zeppelin/search/EmbeddingSearch.java
+++ 
b/zeppelin-server/src/main/java/org/apache/zeppelin/search/EmbeddingSearch.java
@@ -42,6 +42,7 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Predicate;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Executors;
@@ -509,15 +510,11 @@ public class EmbeddingSearch extends SearchService {
   // ---- SearchService implementation ----
 
   @Override
-  // TODO(ZEPPELIN-6414): Accept user/roles (or a readability Predicate) and 
apply the auth
-  // filter before Phase-1 table collection and before the top-K cutoff. 
Currently the REST
-  // layer filters after truncation, which can hide results the caller is 
authorized for and
-  // lets inaccessible notes contaminate the table-boost ranking. Requires a 
SearchService
-  // interface change that also affects LuceneSearch.
-  public List<Map<String, String>> query(String queryStr) {
+  public List<Map<String, String>> query(String queryStr, Predicate<String> 
readable) {
     if (StringUtils.isBlank(queryStr) || index.isEmpty()) {
       return Collections.emptyList();
     }
+    Map<String, Boolean> readableNotes = new HashMap<>();
 
     float[] queryEmbedding = embed(queryStr);
     String queryLower = queryStr.toLowerCase(Locale.ROOT);
@@ -527,6 +524,12 @@ public class EmbeddingSearch extends SearchService {
     indexLock.readLock().lock();
     try {
       for (Map.Entry<String, IndexEntry> entry : index.entrySet()) {
+        // Dropping the entries here keeps them out of the table weights below 
and out of
+        // the cutoff, so the caller is served its own top results and not 
what is left of
+        // everyone's top results.
+        if (!readableNotes.computeIfAbsent(noteIdOf(entry.getKey()), 
readable::test)) {
+          continue;
+        }
         float sim = cosineSimilarity(queryEmbedding, 
entry.getValue().embedding);
         IndexEntry ie = entry.getValue();
         if (ie.text != null && 
ie.text.toLowerCase(Locale.ROOT).contains(queryLower)) {
diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/search/LuceneSearch.java 
b/zeppelin-server/src/main/java/org/apache/zeppelin/search/LuceneSearch.java
index 904069fb33..8d1a081cf3 100644
--- a/zeppelin-server/src/main/java/org/apache/zeppelin/search/LuceneSearch.java
+++ b/zeppelin-server/src/main/java/org/apache/zeppelin/search/LuceneSearch.java
@@ -21,10 +21,13 @@ import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
 import javax.annotation.PreDestroy;
 import jakarta.inject.Inject;
 
@@ -76,6 +79,18 @@ public class LuceneSearch extends SearchService {
   private static final String SEARCH_FIELD_TITLE = "header";
   private static final String PARAGRAPH = "paragraph";
   private static final String ID_FIELD = "id";
+  /** Number of results a query returns at most. */
+  private static final int MAX_RESULTS = 20;
+  /** Number of hits pulled from the index on the first round while looking 
for readable ones. */
+  private static final int HIT_BATCH_SIZE = 20;
+  /**
+   * Upper bound for the growing batch. Each searchAfter round re-executes the 
query, so the
+   * batch doubles per round to keep the round count logarithmic in the number 
of hits walked;
+   * the cap bounds the per-round allocation.
+   */
+  private static final int MAX_HIT_BATCH_SIZE = 1024;
+  /** Only the id is needed to tell whether the caller may read a hit. */
+  private static final Set<String> ID_FIELD_ONLY = 
Collections.singleton(ID_FIELD);
 
   private final Directory indexDirectory;
   private final IndexWriter indexWriter;
@@ -113,7 +128,7 @@ public class LuceneSearch extends SearchService {
    * @see org.apache.zeppelin.search.Search#query(java.lang.String)
    */
   @Override
-  public List<Map<String, String>> query(String queryStr) {
+  public List<Map<String, String>> query(String queryStr, Predicate<String> 
readable) {
     if (null == indexDirectory) {
       throw new IllegalStateException(
           "Something went wrong on instance creation time, index dir is null");
@@ -134,7 +149,7 @@ public class LuceneSearch extends SearchService {
       SimpleHTMLFormatter htmlFormatter = new SimpleHTMLFormatter();
       Highlighter highlighter = new Highlighter(htmlFormatter, new 
QueryScorer(query));
 
-      result = doSearch(indexSearcher, query, analyzer, highlighter);
+      result = doSearch(indexSearcher, query, analyzer, highlighter, readable);
     } catch (IOException e) {
       LOGGER.error("Failed to open index dir {}, make sure indexing finished 
OK", indexDirectory, e);
     } catch (ParseException e) {
@@ -144,65 +159,45 @@ public class LuceneSearch extends SearchService {
   }
 
   private List<Map<String, String>> doSearch(
-      IndexSearcher searcher, Query query, Analyzer analyzer, Highlighter 
highlighter) {
+      IndexSearcher searcher, Query query, Analyzer analyzer, Highlighter 
highlighter,
+      Predicate<String> readable) {
     List<Map<String, String>> matchingParagraphs = new ArrayList<>();
-    ScoreDoc[] hits;
+    Map<String, Boolean> readableNotes = new HashMap<>();
     try {
-      hits = searcher.search(query, 20).scoreDocs;
-      for (int i = 0; i < hits.length; i++) {
-        LOGGER.debug("doc={} score={}", hits[i].doc, hits[i].score);
-
-        int id = hits[i].doc;
-        Document doc = searcher.doc(id);
-        String path = doc.get(ID_FIELD);
-        if (path != null) {
-          LOGGER.debug( "{}. {}", (i + 1), path);
-          String title = doc.get("title");
-          if (title != null) {
-            LOGGER.debug("   Title: {}", doc.get("title"));
+      // Walk the hits in score order and keep the ones the caller may read 
until the result
+      // set is full or the hits run out. Reading is checked here and not on 
the result set,
+      // because a cut that runs first would hide results the caller is 
allowed to see.
+      ScoreDoc lastHit = null;
+      int batchSize = HIT_BATCH_SIZE;
+      while (matchingParagraphs.size() < MAX_RESULTS) {
+        ScoreDoc[] hits = (lastHit == null
+            ? searcher.search(query, batchSize)
+            : searcher.searchAfter(lastHit, query, batchSize)).scoreDocs;
+        for (ScoreDoc hit : hits) {
+          if (matchingParagraphs.size() >= MAX_RESULTS) {
+            break;
           }
-
-          String text = doc.get(SEARCH_FIELD_TEXT);
-          String header = doc.get(SEARCH_FIELD_TITLE);
-          String fragment = "";
-
-          if (text != null) {
-            TokenStream tokenStream =
-                TokenSources.getTokenStream(
-                    searcher.getIndexReader(), id, SEARCH_FIELD_TEXT, 
analyzer);
-            TextFragment[] frags = 
highlighter.getBestTextFragments(tokenStream, text, true, 3);
-            LOGGER.debug("    {} fragments found for query '{}'", 
frags.length, query);
-            for (TextFragment frag : frags) {
-              if ((frag != null) && (frag.getScore() > 0)) {
-                LOGGER.debug("    Fragment: {}", frag);
-              }
-            }
-            fragment = (frags != null && frags.length > 0) ? 
frags[0].toString() : "";
+          LOGGER.debug("doc={} score={}", hit.doc, hit.score);
+          // Read the id alone to decide on a hit. The rest of the document is 
only worth
+          // loading for the hits that end up in the result set.
+          String path = searcher.doc(hit.doc, ID_FIELD_ONLY).get(ID_FIELD);
+          if (path == null) {
+            LOGGER.info("No {} for this document", ID_FIELD);
+            continue;
           }
-
-          if (header != null) {
-            TokenStream tokenTitle =
-                TokenSources.getTokenStream(
-                    searcher.getIndexReader(), id, SEARCH_FIELD_TITLE, 
analyzer);
-            TextFragment[] frgTitle = 
highlighter.getBestTextFragments(tokenTitle, header, true, 3);
-            header = (frgTitle != null && frgTitle.length > 0) ? 
frgTitle[0].toString() : "";
-          } else {
-            header = "";
+          if (!readableNotes.computeIfAbsent(noteIdOf(path), readable::test)) {
+            continue;
           }
           matchingParagraphs.add(
-              ImmutableMap.<String, String>builder()
-                  .put("id", path)
-                  .put("name", title)
-                  .put("snippet", fragment)
-                  .put("text", text)
-                  .put("header", header)
-                  .put("title", header)
-                  .put("tables", "")
-                  .put("output", "")
-                  .build());
-        } else {
-          LOGGER.info("{}. No {} for this document", i + 1, ID_FIELD);
+              toMatch(searcher, query, analyzer, highlighter, hit.doc, 
searcher.doc(hit.doc)));
+        }
+        // Fewer hits than asked for means the index has no more; asking again 
would only
+        // return an empty batch.
+        if (hits.length < batchSize) {
+          break;
         }
+        lastHit = hits[hits.length - 1];
+        batchSize = Math.min(batchSize * 2, MAX_HIT_BATCH_SIZE);
       }
     } catch (IOException | InvalidTokenOffsetsException e) {
       LOGGER.error("Exception on searching for {}", query, e);
@@ -210,6 +205,45 @@ public class LuceneSearch extends SearchService {
     return matchingParagraphs;
   }
 
+  private Map<String, String> toMatch(IndexSearcher searcher, Query query, 
Analyzer analyzer,
+      Highlighter highlighter, int docId, Document doc)
+      throws IOException, InvalidTokenOffsetsException {
+    String path = doc.get(ID_FIELD);
+    String title = doc.get("title");
+    String text = doc.get(SEARCH_FIELD_TEXT);
+    String header = doc.get(SEARCH_FIELD_TITLE);
+    String fragment = "";
+
+    if (text != null) {
+      TokenStream tokenStream =
+          TokenSources.getTokenStream(
+              searcher.getIndexReader(), docId, SEARCH_FIELD_TEXT, analyzer);
+      TextFragment[] frags = highlighter.getBestTextFragments(tokenStream, 
text, true, 3);
+      LOGGER.debug("    {} fragments found for query '{}'", frags.length, 
query);
+      fragment = (frags != null && frags.length > 0) ? frags[0].toString() : 
"";
+    }
+
+    if (header != null) {
+      TokenStream tokenTitle =
+          TokenSources.getTokenStream(
+              searcher.getIndexReader(), docId, SEARCH_FIELD_TITLE, analyzer);
+      TextFragment[] frgTitle = highlighter.getBestTextFragments(tokenTitle, 
header, true, 3);
+      header = (frgTitle != null && frgTitle.length > 0) ? 
frgTitle[0].toString() : "";
+    } else {
+      header = "";
+    }
+    return ImmutableMap.<String, String>builder()
+        .put("id", path)
+        .put("name", title)
+        .put("snippet", fragment)
+        .put("text", text)
+        .put("header", header)
+        .put("title", header)
+        .put("tables", "")
+        .put("output", "")
+        .build();
+  }
+
   /* (non-Javadoc)
    * @see 
org.apache.zeppelin.search.Search#updateIndexDoc(org.apache.zeppelin.notebook.Note)
    */
diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/search/NoSearchService.java 
b/zeppelin-server/src/main/java/org/apache/zeppelin/search/NoSearchService.java
index 1d0d33d304..10a0493236 100644
--- 
a/zeppelin-server/src/main/java/org/apache/zeppelin/search/NoSearchService.java
+++ 
b/zeppelin-server/src/main/java/org/apache/zeppelin/search/NoSearchService.java
@@ -21,6 +21,7 @@ import jakarta.inject.Inject;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Predicate;
 
 public class NoSearchService extends SearchService {
 
@@ -30,7 +31,7 @@ public class NoSearchService extends SearchService {
   }
 
   @Override
-  public List<Map<String, String>> query(String queryStr) {
+  public List<Map<String, String>> query(String queryStr, Predicate<String> 
readable) {
     return Collections.emptyList();
   }
 
diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/search/SearchService.java 
b/zeppelin-server/src/main/java/org/apache/zeppelin/search/SearchService.java
index d36e4b1693..b37e452aa1 100644
--- 
a/zeppelin-server/src/main/java/org/apache/zeppelin/search/SearchService.java
+++ 
b/zeppelin-server/src/main/java/org/apache/zeppelin/search/SearchService.java
@@ -19,6 +19,7 @@ package org.apache.zeppelin.search;
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Predicate;
 import org.apache.zeppelin.notebook.NoteEventAsyncListener;
 import javax.annotation.PreDestroy;
 
@@ -39,9 +40,23 @@ public abstract class SearchService extends 
NoteEventAsyncListener {
    * Full-text search in all the notes
    *
    * @param queryStr a query
+   * @param readable tells for a noteId whether the caller may read it. 
Entries the caller
+   *                 cannot read are dropped before the result set is cut 
down, so that the
+   *                 caller is not served fewer results than it is allowed to 
see.
    * @return A list of matching paragraphs (id, text, snippet w/ highlight)
    */
-  public abstract List<Map<String, String>> query(String queryStr);
+  public abstract List<Map<String, String>> query(String queryStr, 
Predicate<String> readable);
+
+  /**
+   * The id of an indexed document is either a noteId or a noteId followed by 
the paragraph.
+   *
+   * @see LuceneSearch#formatId(String, org.apache.zeppelin.notebook.Paragraph)
+   * @see EmbeddingSearch#formatId(String, 
org.apache.zeppelin.notebook.Paragraph)
+   */
+  static String noteIdOf(String documentId) {
+    int separator = documentId.indexOf('/');
+    return separator < 0 ? documentId : documentId.substring(0, separator);
+  }
 
   /**
    * Updates note index for the given note, only update index of note meta 
info,
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/search/EmbeddingSearchTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/search/EmbeddingSearchTest.java
index 902925eb7c..ae2031aab8 100644
--- 
a/zeppelin-server/src/test/java/org/apache/zeppelin/search/EmbeddingSearchTest.java
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/search/EmbeddingSearchTest.java
@@ -17,6 +17,7 @@
 package org.apache.zeppelin.search;
 
 import static org.apache.zeppelin.search.EmbeddingSearch.formatId;
+import static org.apache.zeppelin.search.SearchService.noteIdOf;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -26,6 +27,9 @@ import static org.mockito.Mockito.when;
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.function.Predicate;
 import java.util.List;
 import java.util.Map;
 
@@ -127,7 +131,7 @@ class EmbeddingSearchTest {
     drainSearchEvents();
 
     // when — semantic search for a meaningful phrase
-    List<Map<String, String>> results = searchService.query("testing 
something");
+    List<Map<String, String>> results = searchService.query("testing 
something", id -> true);
 
     // then
     assertFalse(results.isEmpty());
@@ -144,7 +148,7 @@ class EmbeddingSearchTest {
     drainSearchEvents();
 
     // when
-    List<Map<String, String>> results = searchService.query("Notebook1");
+    List<Map<String, String>> results = searchService.query("Notebook1", id -> 
true);
 
     // then
     assertFalse(results.isEmpty());
@@ -159,7 +163,7 @@ class EmbeddingSearchTest {
     drainSearchEvents();
 
     // when
-    List<Map<String, String>> results = 
searchService.query("testingTitleSearch");
+    List<Map<String, String>> results = 
searchService.query("testingTitleSearch", id -> true);
 
     // then
     assertFalse(results.isEmpty());
@@ -178,7 +182,7 @@ class EmbeddingSearchTest {
     drainSearchEvents();
 
     // when — natural language query, no exact keyword match
-    List<Map<String, String>> results = searchService.query("yesterday's 
spending");
+    List<Map<String, String>> results = searchService.query("yesterday's 
spending", id -> true);
 
     // then — should rank the spend query higher than the user count query
     assertFalse(results.isEmpty());
@@ -193,7 +197,7 @@ class EmbeddingSearchTest {
     drainSearchEvents();
 
     // when
-    List<Map<String, String>> results = searchService.query("test");
+    List<Map<String, String>> results = searchService.query("test", id -> 
true);
     assertFalse(results.isEmpty());
 
     // then — find the paragraph result (not the note-name result)
@@ -214,7 +218,7 @@ class EmbeddingSearchTest {
   void canNotSearchBeforeIndexing() {
     // given NO indexing was done
     // when
-    List<Map<String, String>> result = searchService.query("anything");
+    List<Map<String, String>> result = searchService.query("anything", id -> 
true);
     // then
     assertTrue(result.isEmpty());
   }
@@ -235,7 +239,8 @@ class EmbeddingSearchTest {
     });
 
     // then — updated content should now be findable
-    List<Map<String, String>> results = searchService.query("reindexing 
updated content");
+    List<Map<String, String>> results =
+        searchService.query("reindexing updated content", id -> true);
     assertFalse(results.isEmpty());
   }
 
@@ -252,16 +257,16 @@ class EmbeddingSearchTest {
     String note2Id = newNoteWithParagraphs("Notebook2", "not test", "not test 
at all");
     drainSearchEvents();
 
-    assertFalse(searchService.query("Notebook2").isEmpty());
+    assertFalse(searchService.query("Notebook2", id -> true).isEmpty());
 
     // when
     searchService.deleteNoteIndex(note2Id);
 
     // then — no results should reference the deleted note's ID
-    boolean foundNote2After = searchService.query("not test at all").stream()
+    boolean foundNote2After = searchService.query("not test at all", id -> 
true).stream()
         .anyMatch(r -> r.get("id").startsWith(note2Id));
     assertFalse(foundNote2After, "Note2 should be removed from index after 
deletion");
-    assertFalse(searchService.query("Notebook1").isEmpty());
+    assertFalse(searchService.query("Notebook1", id -> true).isEmpty());
   }
 
   @Test
@@ -282,7 +287,7 @@ class EmbeddingSearchTest {
     drainSearchEvents();
 
     // then — "Notebook1" note name should still be findable
-    assertFalse(searchService.query("Notebook1").isEmpty());
+    assertFalse(searchService.query("Notebook1", id -> true).isEmpty());
   }
 
   @Test
@@ -302,7 +307,7 @@ class EmbeddingSearchTest {
     drainSearchEvents();
 
     // then — the new paragraph should be findable by semantic query
-    List<Map<String, String>> results = searchService.query("lifetime value");
+    List<Map<String, String>> results = searchService.query("lifetime value", 
id -> true);
     assertFalse(results.isEmpty(), "Newly added paragraph should be 
searchable");
     boolean found = results.stream()
         .anyMatch(r -> r.get("text").contains("lifetime_value"));
@@ -311,6 +316,44 @@ class EmbeddingSearchTest {
 
   // ---- Helper methods (same as LuceneSearchTest) ----
 
+  @Test
+  void keepsReadableResultsThatTheCutWouldHide() throws IOException, 
InterruptedException {
+    // given: more notes than one result set holds. The notes the caller may 
not read match
+    // the query exactly, the ones it may read carry the same words but say 
more, so they
+    // score lower and fall outside the cut.
+    String queryStr = "quarterly revenue report";
+    Set<String> readableNoteIds = new HashSet<>();
+    for (int i = 0; i < 25; i++) {
+      newNoteWithParagraph("Hidden" + i, queryStr);
+    }
+    for (int i = 0; i < 3; i++) {
+      readableNoteIds.add(newNoteWithParagraph("Mine" + i, queryStr
+          + " which also walks through unrelated kitchen recipes, holiday 
photographs and"
+          + " a long list of gardening tips that have nothing to do with the 
numbers"));
+    }
+    drainSearchEvents();
+    Predicate<String> readable = readableNoteIds::contains;
+
+    // the fixture has to be one where cutting first actually loses results, 
otherwise this
+    // test would pass on any implementation
+    List<Map<String, String>> unfiltered = searchService.query(queryStr, id -> 
true);
+    long readableWithinCut = unfiltered.stream()
+        .filter(result -> readable.test(noteIdOf(result.get("id"))))
+        .count();
+    assertTrue(readableWithinCut < readableNoteIds.size(),
+        "the readable notes have to fall outside the cut for this test to mean 
anything");
+
+    // when
+    List<Map<String, String>> results = searchService.query(queryStr, 
readable);
+
+    // then: every readable note comes back, and nothing else does
+    assertEquals(readableNoteIds.size(), results.size());
+    for (Map<String, String> result : results) {
+      assertTrue(readable.test(noteIdOf(result.get("id"))),
+          "a result the caller may not read: " + result.get("id"));
+    }
+  }
+
   private String newNoteWithParagraph(String noteName, String parText) throws 
IOException {
     String noteId = newNote(noteName);
     notebook.processNote(noteId, note -> {
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/search/LuceneSearchTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/search/LuceneSearchTest.java
index a27c2df358..71be92efd7 100644
--- 
a/zeppelin-server/src/test/java/org/apache/zeppelin/search/LuceneSearchTest.java
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/search/LuceneSearchTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.zeppelin.search;
 import static org.apache.zeppelin.search.LuceneSearch.formatId;
+import static org.apache.zeppelin.search.SearchService.noteIdOf;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -28,6 +29,9 @@ import static org.mockito.Mockito.when;
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.function.Predicate;
 import java.util.List;
 import java.util.Map;
 
@@ -98,7 +102,7 @@ class LuceneSearchTest {
     drainSearchEvents();
 
     // when
-    List<Map<String, String>> results = noteSearchService.query("all");
+    List<Map<String, String>> results = noteSearchService.query("all", id -> 
true);
 
     // then
     assertFalse(results.isEmpty());
@@ -118,7 +122,7 @@ class LuceneSearchTest {
     drainSearchEvents();
 
     // when
-    List<Map<String, String>> results = noteSearchService.query("Notebook1");
+    List<Map<String, String>> results = noteSearchService.query("Notebook1", 
id -> true);
 
     // then
     assertFalse(results.isEmpty());
@@ -134,7 +138,7 @@ class LuceneSearchTest {
     drainSearchEvents();
 
     // when
-    List<Map<String, String>> results = 
noteSearchService.query("testingTitleSearch");
+    List<Map<String, String>> results = 
noteSearchService.query("testingTitleSearch", id -> true);
 
     // then
     assertFalse(results.isEmpty());
@@ -168,7 +172,7 @@ class LuceneSearchTest {
   void canNotSearchBeforeIndexing() {
     // given NO noteSearchService.index() was called
     // when
-    List<Map<String, String>> result = noteSearchService.query("anything");
+    List<Map<String, String>> result = noteSearchService.query("anything", id 
-> true);
     // then
     assertTrue(result.isEmpty());
     // assert logs were printed
@@ -193,10 +197,10 @@ class LuceneSearchTest {
       });
 
     // then
-    List<Map<String, String>> results = noteSearchService.query("all");
+    List<Map<String, String>> results = noteSearchService.query("all", id -> 
true);
     assertTrue(results.isEmpty());
 
-    results = noteSearchService.query("indeed");
+    results = noteSearchService.query("indeed", id -> true);
     assertFalse(results.isEmpty());
   }
 
@@ -221,7 +225,7 @@ class LuceneSearchTest {
     noteSearchService.deleteNoteIndex(note2Id);
 
     // then
-    assertTrue(noteSearchService.query("all").isEmpty());
+    assertTrue(noteSearchService.query("all", id -> true).isEmpty());
     assertTrue(resultForQuery("Notebook2").isEmpty());
 
     List<Map<String, String>> results = resultForQuery("test");
@@ -287,7 +291,7 @@ class LuceneSearchTest {
   }
 
   private List<Map<String, String>> resultForQuery(String q) {
-    return noteSearchService.query(q);
+    return noteSearchService.query(q, id -> true);
   }
 
   /**
@@ -297,6 +301,56 @@ class LuceneSearchTest {
    * @param parText text of the paragraph
    * @return Note
    */
+  @Test
+  void keepsReadableResultsThatTheCutWouldHide() throws IOException, 
InterruptedException {
+    // given: more notes than one result set holds, and only a few of them 
readable
+    Set<String> readableNoteIds = new HashSet<>();
+    for (int i = 0; i < 25; i++) {
+      newNoteWithParagraph("Hidden" + i, "shared search term");
+    }
+    for (int i = 0; i < 3; i++) {
+      readableNoteIds.add(newNoteWithParagraph("Mine" + i, "shared search 
term"));
+    }
+    drainSearchEvents();
+    Predicate<String> readable = readableNoteIds::contains;
+
+    // the fixture has to be one where cutting first actually loses results, 
otherwise this
+    // test would pass on any implementation
+    List<Map<String, String>> unfiltered = noteSearchService.query("shared 
search term",
+        id -> true);
+    long readableWithinCut = unfiltered.stream()
+        .filter(result -> readable.test(noteIdOf(result.get("id"))))
+        .count();
+    assertTrue(readableWithinCut < readableNoteIds.size(),
+        "the readable notes have to fall outside the cut for this test to mean 
anything");
+
+    // when
+    List<Map<String, String>> results = noteSearchService.query("shared search 
term", readable);
+
+    // then: every readable note comes back, and nothing else does
+    assertEquals(readableNoteIds.size(), results.size());
+    for (Map<String, String> result : results) {
+      assertTrue(readable.test(noteIdOf(result.get("id"))),
+          "a result the caller may not read: " + result.get("id"));
+    }
+  }
+
+  @Test
+  void returnsNothingWhenTheCallerMayReadNothing() throws IOException, 
InterruptedException {
+    // given
+    for (int i = 0; i < 25; i++) {
+      newNoteWithParagraph("Hidden" + i, "shared search term");
+    }
+    drainSearchEvents();
+
+    // when: the walk has to end on its own once the hits run out
+    List<Map<String, String>> results = noteSearchService.query("shared search 
term",
+        id -> false);
+
+    // then
+    assertTrue(results.isEmpty(), () -> "unreadable results were returned: " + 
results);
+  }
+
   private String newNoteWithParagraph(String noteName, String parText) throws 
IOException {
     String note1Id = newNote(noteName);
     notebook.processNote(note1Id,

Reply via email to