Return separate links for searchable types instead of single templated link (#1733)

The search link of the index resource is now an array of links instead of single templated link.
The array contains one link for each searchable type.

Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Sebastian Sdorra
2021-07-21 10:07:41 +02:00
committed by GitHub
parent 8ba93422a2
commit 39d2f12b66
23 changed files with 443 additions and 98 deletions

View File

@@ -25,6 +25,7 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.Link;
import de.otto.edison.hal.Links;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
@@ -41,15 +42,19 @@ import sonia.scm.config.ScmConfiguration;
import sonia.scm.initialization.InitializationFinisher;
import sonia.scm.initialization.InitializationStep;
import sonia.scm.initialization.InitializationStepResource;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SearchableType;
import sonia.scm.security.AnonymousMode;
import java.net.URI;
import java.util.Arrays;
import java.util.List;
import static de.otto.edison.hal.Link.link;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static sonia.scm.SCMContext.USER_ANONYMOUS;
@@ -66,7 +71,8 @@ class IndexDtoGeneratorTest {
private ScmConfiguration configuration;
@Mock
private InitializationFinisher initializationFinisher;
@Mock
private SearchEngine searchEngine;
@InjectMocks
private IndexDtoGenerator generator;
@@ -137,6 +143,33 @@ class IndexDtoGeneratorTest {
assertThat(dto.getLinks().getLinkBy("me")).isNotPresent();
}
@Test
void shouldAppendSearchLinksForEveryType() {
List<SearchableType> types = Arrays.asList(
searchableType("repository"),
searchableType("user"),
searchableType("group")
);
when(searchEngine.getSearchableTypes()).thenReturn(types);
mockSubjectRelatedResourceLinks();
when(resourceLinks.search()).thenReturn(new ResourceLinks.SearchLinks(scmPathInfo));
when(subject.isAuthenticated()).thenReturn(true);
IndexDto dto = generator.generate();
assertThat(dto.getLinks().getLinksBy("search")).contains(
Link.linkBuilder("search", "/api/v2/search/query/repository").withName("repository").build(),
Link.linkBuilder("search", "/api/v2/search/query/user").withName("user").build(),
Link.linkBuilder("search", "/api/v2/search/query/group").withName("group").build()
);
}
}
private SearchableType searchableType(String name) {
SearchableType searchableType = mock(SearchableType.class);
when(searchableType.getName()).thenReturn(name);
return searchableType;
}
@Nested
@@ -195,6 +228,5 @@ class IndexDtoGeneratorTest {
when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(scmPathInfo));
when(resourceLinks.me()).thenReturn(new ResourceLinks.MeLinks(scmPathInfo, new ResourceLinks.UserLinks(scmPathInfo)));
when(resourceLinks.repository()).thenReturn(new ResourceLinks.RepositoryLinks(scmPathInfo));
when(resourceLinks.search()).thenReturn(new ResourceLinks.SearchLinks(scmPathInfo));
}
}

View File

@@ -33,8 +33,7 @@ import org.junit.Test;
import sonia.scm.SCMContextProvider;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.initialization.InitializationFinisher;
import sonia.scm.initialization.InitializationStep;
import sonia.scm.initialization.InitializationStepResource;
import sonia.scm.search.SearchEngine;
import java.net.URI;
import java.util.Optional;
@@ -59,11 +58,12 @@ public class IndexResourceTest {
this.scmContextProvider = mock(SCMContextProvider.class);
InitializationFinisher initializationFinisher = mock(InitializationFinisher.class);
when(initializationFinisher.isFullyInitialized()).thenReturn(true);
SearchEngine searchEngine = mock(SearchEngine.class);
IndexDtoGenerator generator = new IndexDtoGenerator(
ResourceLinksMock.createMock(URI.create("/")),
scmContextProvider,
configuration,
initializationFinisher);
initializationFinisher, searchEngine);
this.indexResource = new IndexResource(generator);
}

View File

@@ -37,7 +37,6 @@ import org.mapstruct.factory.Mappers;
import org.mockito.Answers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.search.Hit;
import sonia.scm.search.IndexNames;
import sonia.scm.search.QueryResult;
@@ -135,11 +134,11 @@ class SearchResourceTest {
JsonMockHttpResponse response = search("paging", 1, 20);
JsonNode links = response.getContentAsJson().get("_links");
assertLink(links, "self", "/v2/search/string?q=paging&page=1&pageSize=20");
assertLink(links, "first", "/v2/search/string?q=paging&page=0&pageSize=20");
assertLink(links, "prev", "/v2/search/string?q=paging&page=0&pageSize=20");
assertLink(links, "next", "/v2/search/string?q=paging&page=2&pageSize=20");
assertLink(links, "last", "/v2/search/string?q=paging&page=4&pageSize=20");
assertLink(links, "self", "/v2/search/query/string?q=paging&page=1&pageSize=20");
assertLink(links, "first", "/v2/search/query/string?q=paging&page=0&pageSize=20");
assertLink(links, "prev", "/v2/search/query/string?q=paging&page=0&pageSize=20");
assertLink(links, "next", "/v2/search/query/string?q=paging&page=2&pageSize=20");
assertLink(links, "last", "/v2/search/query/string?q=paging&page=4&pageSize=20");
}
@Test
@@ -233,7 +232,7 @@ class SearchResourceTest {
}
private JsonMockHttpResponse search(String query, Integer page, Integer pageSize) throws URISyntaxException, UnsupportedEncodingException {
String uri = "/v2/search/string?q=" + URLEncoder.encode(query, "UTF-8");
String uri = "/v2/search/query/string?q=" + URLEncoder.encode(query, "UTF-8");
if (page != null) {
uri += "&page=" + page;
}

View File

@@ -70,7 +70,7 @@ class DefaultIndexQueueTest {
SearchableTypeResolver resolver = new SearchableTypeResolver(Account.class, IndexedNumber.class);
LuceneIndexFactory indexFactory = new LuceneIndexFactory(resolver, opener);
SearchEngine engine = new LuceneSearchEngine(indexFactory, queryBuilderFactory);
SearchEngine engine = new LuceneSearchEngine(resolver, indexFactory, queryBuilderFactory);
queue = new DefaultIndexQueue(engine);
}

View File

@@ -40,8 +40,10 @@ import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.IndexableField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.shiro.authz.AuthorizationException;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.BeforeEach;
@@ -378,6 +380,24 @@ class LuceneQueryBuilderTest {
assertThrows(NoDefaultQueryFieldsFoundException.class, () -> query(Types.class, "something"));
}
@Test
void shouldFailWithoutPermissionForTheSearchedType() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(denyDoc("awesome"));
}
assertThrows(AuthorizationException.class, () -> query(Deny.class, "awesome"));
}
@Test
@SubjectAware(value = "marvin", permissions = "deny:4711")
void shouldNotFailWithRequiredPermissionForTheSearchedType() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(denyDoc("awesome"));
}
QueryResult result = query(Deny.class, "awesome");
assertThat(result.getTotalHits()).isOne();
}
@Test
void shouldLimitHitsByDefaultSize() throws IOException {
try (IndexWriter writer = writer()) {
@@ -452,7 +472,7 @@ class LuceneQueryBuilderTest {
JsonNode displayName = fields.get("displayName");
assertThat(displayName.get("highlighted").asBoolean()).isTrue();
assertThat(displayName.get("fragments").get(0).asText()).contains("**Arthur**");
assertThat(displayName.get("fragments").get(0).asText()).contains("<>Arthur</>");
}
@Test
@@ -556,10 +576,18 @@ class LuceneQueryBuilderTest {
return document;
}
private Document denyDoc(String value) {
Document document = new Document();
document.add(new TextField("value", value, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "deny", Field.Store.YES));
return document;
}
@Getter
@IndexedType
static class Types {
@Indexed
private Integer intValue;
@Indexed
@@ -600,4 +628,11 @@ class LuceneQueryBuilderTest {
private String content;
}
@Getter
@IndexedType(permission = "deny:4711")
static class Deny {
@Indexed(defaultQuery = true)
private String value;
}
}

View File

@@ -0,0 +1,138 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@SubjectAware("trillian")
@ExtendWith({MockitoExtension.class, ShiroExtension.class})
class LuceneSearchEngineTest {
@Mock
private SearchableTypeResolver resolver;
@Mock
private LuceneIndexFactory indexFactory;
@Mock
private LuceneQueryBuilderFactory queryBuilderFactory;
@InjectMocks
private LuceneSearchEngine searchEngine;
@Test
void shouldDelegateGetSearchableTypes() {
List<LuceneSearchableType> mockedTypes = Collections.singletonList(searchableType("repository"));
when(resolver.getSearchableTypes()).thenReturn(mockedTypes);
Collection<SearchableType> searchableTypes = searchEngine.getSearchableTypes();
assertThat(searchableTypes).containsAll(mockedTypes);
}
@Test
@SubjectAware(value = "dent", permissions = "user:list")
void shouldExcludeTypesWithoutPermission() {
LuceneSearchableType repository = searchableType("repository");
LuceneSearchableType user = searchableType("user", "user:list");
LuceneSearchableType group = searchableType("group", "group:list");
List<LuceneSearchableType> mockedTypes = Arrays.asList(repository, user, group);
when(resolver.getSearchableTypes()).thenReturn(mockedTypes);
Collection<SearchableType> searchableTypes = searchEngine.getSearchableTypes();
assertThat(searchableTypes).containsOnly(repository, user);
}
private LuceneSearchableType searchableType(String name) {
return searchableType(name, null);
}
private LuceneSearchableType searchableType(String name, String permission) {
LuceneSearchableType searchableType = mock(LuceneSearchableType.class);
lenient().when(searchableType.getName()).thenReturn(name);
when(searchableType.getPermission()).thenReturn(Optional.ofNullable(permission));
return searchableType;
}
@Test
void shouldDelegateGetOrCreateIndexWithDefaults() {
LuceneIndex index = mock(LuceneIndex.class);
when(indexFactory.create("idx", IndexOptions.defaults())).thenReturn(index);
Index idx = searchEngine.getOrCreate("idx");
assertThat(idx).isSameAs(index);
}
@Test
void shouldDelegateGetOrCreateIndex() {
LuceneIndex index = mock(LuceneIndex.class);
IndexOptions options = IndexOptions.naturalLanguage(Locale.ENGLISH);
when(indexFactory.create("idx", options)).thenReturn(index);
Index idx = searchEngine.getOrCreate("idx", options);
assertThat(idx).isSameAs(index);
}
@Test
void shouldDelegateSearchWithDefaults() {
LuceneQueryBuilder mockedBuilder = mock(LuceneQueryBuilder.class);
when(queryBuilderFactory.create("idx", IndexOptions.defaults())).thenReturn(mockedBuilder);
QueryBuilder queryBuilder = searchEngine.search("idx");
assertThat(queryBuilder).isSameAs(mockedBuilder);
}
@Test
void shouldDelegateSearch() {
LuceneQueryBuilder mockedBuilder = mock(LuceneQueryBuilder.class);
IndexOptions options = IndexOptions.naturalLanguage(Locale.GERMAN);
when(queryBuilderFactory.create("idx", options)).thenReturn(mockedBuilder);
QueryBuilder queryBuilder = searchEngine.search("idx", options);
assertThat(queryBuilder).isSameAs(mockedBuilder);
}
}