diff --git a/java/google/registry/model/contact/ContactResource.java b/java/google/registry/model/contact/ContactResource.java index cb24e90cb..472642e74 100644 --- a/java/google/registry/model/contact/ContactResource.java +++ b/java/google/registry/model/contact/ContactResource.java @@ -121,6 +121,10 @@ public class ContactResource extends EppResource implements return internationalizedPostalInfo; } + public String getSearchName() { + return searchName; + } + public ContactPhoneNumber getVoiceNumber() { return voice; } diff --git a/java/google/registry/rdap/RdapActionBase.java b/java/google/registry/rdap/RdapActionBase.java index 05e1423a5..47358a5fa 100644 --- a/java/google/registry/rdap/RdapActionBase.java +++ b/java/google/registry/rdap/RdapActionBase.java @@ -419,6 +419,7 @@ public abstract class RdapActionBase implements Runnable { static Query queryItemsByKey( Class clazz, RdapSearchPattern partialStringQuery, + Optional cursorString, DeletedItemHandling deletedItemHandling, int resultSetMaxSize) { if (partialStringQuery.getInitialString().length() @@ -437,6 +438,9 @@ public abstract class RdapActionBase implements Runnable { .filterKey(">=", Key.create(clazz, partialStringQuery.getInitialString())) .filterKey("<", Key.create(clazz, partialStringQuery.getNextInitialString())); } + if (cursorString.isPresent()) { + query = query.filterKey(">", Key.create(clazz, cursorString.get())); + } return setOtherQueryAttributes(query, deletedItemHandling, resultSetMaxSize); } diff --git a/java/google/registry/rdap/RdapEntitySearchAction.java b/java/google/registry/rdap/RdapEntitySearchAction.java index 431fab2dd..bf250c4af 100644 --- a/java/google/registry/rdap/RdapEntitySearchAction.java +++ b/java/google/registry/rdap/RdapEntitySearchAction.java @@ -51,6 +51,24 @@ import org.joda.time.DateTime; * *

All commands and responses conform to the RDAP spec as defined in RFCs 7480 through 7485. * + *

The RDAP specification lumps contacts and registrars together and calls them "entities", which + * is confusing for us, because "entity" means something else in Objectify. But here, when we use + * the term, it means either a contact or registrar. When searching for entities, we always start by + * returning all matching contacts, and after that all matching registrars. + * + *

There are two ways to search for entities: by full name (for contacts, the search name, for + * registrars, the registrar name) or by handle (for contacts, the ROID, for registrars, the IANA + * number). The ICANN operational profile document specifies this meaning for handle searches. + * + *

Cursors are complicated by the fact that we are essentially doing two independent searches: + * one for contacts, and one for registrars. To accommodate this, the cursor has a prefix indicating + * the type of the last returned item. If the last item was a contact, we return c:{value}, where + * the value is either the search name or the ROID. If the last item was a registrar, we return + * r:{value}, where the value is either the registrar name or the IANA number. If we get a c: + * cursor, we use it to weed out contacts, and fetch all registrars. If we get an r: cursor, we know + * that we can skip the contact search altogether (because we returned a registrar, and all + * registrars come after all contacts). + * * @see RFC 7482: Registration Data Access Protocol * (RDAP) Query Format * @see RFC 7483: JSON Responses for the Registration @@ -70,6 +88,20 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { @Inject @Parameter("handle") Optional handleParam; @Inject RdapEntitySearchAction() {} + private enum QueryType { + FULL_NAME, + HANDLE + } + + private enum CursorType { + NONE, + CONTACT, + REGISTRAR + } + + private static final String CONTACT_CURSOR_PREFIX = "c:"; + private static final String REGISTRAR_CURSOR_PREFIX = "r:"; + @Override public String getHumanReadableObjectTypeName() { return "entity search"; @@ -90,6 +122,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { public ImmutableMap getJsonObjectForResource( String pathSearchString, boolean isHeadRequest) { DateTime now = clock.nowUtc(); + // RDAP syntax example: /rdap/entities?fn=Bobby%20Joe*. // The pathSearchString is not used by search commands. if (pathSearchString.length() > 0) { @@ -98,21 +131,54 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { if (Booleans.countTrue(fnParam.isPresent(), handleParam.isPresent()) != 1) { throw new BadRequestException("You must specify either fn=XXXX or handle=YYYY"); } + + // Decode the cursor token and extract the prefix and string portions. + decodeCursorToken(); + CursorType cursorType; + Optional cursorQueryString; + if (!cursorString.isPresent()) { + cursorType = CursorType.NONE; + cursorQueryString = Optional.empty(); + } else { + if (cursorString.get().startsWith(CONTACT_CURSOR_PREFIX)) { + cursorType = CursorType.CONTACT; + cursorQueryString = + Optional.of(cursorString.get().substring(CONTACT_CURSOR_PREFIX.length())); + } else if (cursorString.get().startsWith(REGISTRAR_CURSOR_PREFIX)) { + cursorType = CursorType.REGISTRAR; + cursorQueryString = + Optional.of(cursorString.get().substring(REGISTRAR_CURSOR_PREFIX.length())); + } else { + throw new BadRequestException(String.format("invalid cursor: %s", cursorTokenParam)); + } + } + + // Search by name. RdapSearchResults results; if (fnParam.isPresent()) { metricInformationBuilder.setSearchType(SearchType.BY_FULL_NAME); // syntax: /rdap/entities?fn=Bobby%20Joe* // The name is the contact name or registrar name (not registrar contact name). results = - searchByName(recordWildcardType(RdapSearchPattern.create(fnParam.get(), false)), now); + searchByName( + recordWildcardType(RdapSearchPattern.create(fnParam.get(), false)), + cursorType, + cursorQueryString, + now); + + // Search by handle. } else { metricInformationBuilder.setSearchType(SearchType.BY_HANDLE); // syntax: /rdap/entities?handle=12345-* // The handle is either the contact roid or the registrar clientId. results = searchByHandle( - recordWildcardType(RdapSearchPattern.create(handleParam.get(), false)), now); + recordWildcardType(RdapSearchPattern.create(handleParam.get(), false)), + cursorQueryString, + now); } + + // Build the result object and return it. if (results.jsonList().isEmpty()) { throw new NotFoundException("No entities found"); } @@ -121,7 +187,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { rdapJsonFormatter.addTopLevelEntries( jsonBuilder, BoilerplateType.ENTITY, - results.getIncompletenessWarnings(), + getNotices(results), ImmutableList.of(), fullServletPath); return jsonBuilder.build(); @@ -133,8 +199,8 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { *

As per Gustavo Lozano of ICANN, registrar name search should be by registrar name only, not * by registrar contact name: * - *

The search is by registrar name only. The profile is supporting the functionality defined - * in the Base Registry Agreement. + *

The search is by registrar name only. The profile is supporting the functionality defined in + * the Base Registry Agreement. * *

According to RFC 7482 section 6.1, punycode is only used for domain name labels, so we can * assume that entity names are regular unicode. @@ -143,14 +209,19 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { * set to null when the contact is deleted, so a deleted contact can never have a name. * *

Since we are restricting access to contact names, we don't want name searches to return - * contacts whose names are not visible. That would allow unscrupulous users to query by name - * and infer that all returned contacts contain that name string. So we check the authorization - * level to determine what to do. + * contacts whose names are not visible. That would allow unscrupulous users to query by name and + * infer that all returned contacts contain that name string. So we check the authorization level + * to determine what to do. * - * @see 1.6 - * of Section 4 of the Base Registry Agreement + * @see 1.6 + * of Section 4 of the Base Registry Agreement */ - private RdapSearchResults searchByName(final RdapSearchPattern partialStringQuery, DateTime now) { + private RdapSearchResults searchByName( + final RdapSearchPattern partialStringQuery, + CursorType cursorType, + Optional cursorQueryString, + DateTime now) { // For wildcard searches, make sure the initial string is long enough, and don't allow suffixes. if (partialStringQuery.getHasWildcard() && (partialStringQuery.getSuffix() != null)) { throw new UnprocessableEntityException( @@ -166,21 +237,27 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { ? "Initial search string required in wildcard entity name searches" : "Initial search string required when searching for deleted entities"); } - // Get the registrar matches. + // Get the registrar matches. If we have a registrar cursor, weed out registrars up to and + // including the one we ended with last time. ImmutableList registrars = Streams.stream(Registrar.loadAllCached()) .filter( registrar -> partialStringQuery.matches(registrar.getRegistrarName()) + && ((cursorType != CursorType.REGISTRAR) + || (registrar.getRegistrarName().compareTo(cursorQueryString.get()) + > 0)) && shouldBeVisible(registrar)) .limit(rdapResultSetMaxSize + 1) .collect(toImmutableList()); // Get the contact matches and return the results, fetching an additional contact to detect // truncation. Don't bother searching for contacts by name if the request would not be able to - // see any names anyway. + // see any names anyway. Also, if a registrar cursor is present, we have already moved past the + // contacts, and don't need to fetch them this time. RdapResultSet resultSet; RdapAuthorization authorization = getAuthorization(); - if (authorization.role() == RdapAuthorization.Role.PUBLIC) { + if ((authorization.role() == RdapAuthorization.Role.PUBLIC) + || (cursorType == CursorType.REGISTRAR)) { resultSet = RdapResultSet.create(ImmutableList.of()); } else { Query query = @@ -188,6 +265,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { ContactResource.class, "searchName", partialStringQuery, + cursorQueryString, // if we get this far, and there's a cursor, it must be a contact DeletedItemHandling.EXCLUDE, rdapResultSetMaxSize + 1); if (authorization.role() != RdapAuthorization.Role.ADMINISTRATOR) { @@ -195,7 +273,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { } resultSet = getMatchingResources(query, false, now, rdapResultSetMaxSize + 1); } - return makeSearchResults(resultSet, registrars, now); + return makeSearchResults(resultSet, registrars, QueryType.FULL_NAME, now); } /** @@ -209,7 +287,9 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { * there is no equivalent string suffix that can be used as a query filter, so we disallow use. */ private RdapSearchResults searchByHandle( - final RdapSearchPattern partialStringQuery, DateTime now) { + final RdapSearchPattern partialStringQuery, + Optional cursorQueryString, + DateTime now) { if (partialStringQuery.getSuffix() != null) { throw new UnprocessableEntityException("Suffixes not allowed in entity handle searches"); } @@ -228,6 +308,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { IncompletenessWarningType.COMPLETE, contactResourceList.size(), getMatchingRegistrars(partialStringQuery.getInitialString()), + QueryType.HANDLE, now); // Handle queries with a wildcard (or including deleted), but no suffix. Because the handle // for registrars is the IANA identifier number, don't allow wildcard searches for registrars, @@ -240,14 +321,20 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { : getMatchingRegistrars(partialStringQuery.getInitialString()); // Get the contact matches and return the results, fetching an additional contact to detect // truncation. If we are including deleted entries, we must fetch more entries, in case some - // get excluded due to permissioning. + // get excluded due to permissioning. Any cursor present must be a contact cursor, because we + // would never return a registrar for this search. int querySizeLimit = getStandardQuerySizeLimit(); Query query = queryItemsByKey( - ContactResource.class, partialStringQuery, getDeletedItemHandling(), querySizeLimit); + ContactResource.class, + partialStringQuery, + cursorQueryString, + getDeletedItemHandling(), + querySizeLimit); return makeSearchResults( getMatchingResources(query, shouldIncludeDeleted(), now, querySizeLimit), registrars, + QueryType.HANDLE, now); } } @@ -271,12 +358,16 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { * properties of the {@link RdapResultSet} structure and passes them as separate arguments. */ private RdapSearchResults makeSearchResults( - RdapResultSet resultSet, List registrars, DateTime now) { + RdapResultSet resultSet, + List registrars, + QueryType queryType, + DateTime now) { return makeSearchResults( resultSet.resources(), resultSet.incompletenessWarningType(), resultSet.numResourcesRetrieved(), registrars, + queryType, now); } @@ -292,6 +383,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { * @param numContactsRetrieved the number of contacts retrieved in the process of generating the * results * @param registrars the list of registrars which can be returned + * @param queryType whether the query was by full name or by handle * @param now the current date and time * @return an {@link RdapSearchResults} object */ @@ -300,6 +392,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { IncompletenessWarningType incompletenessWarningType, int numContactsRetrieved, List registrars, + QueryType queryType, DateTime now) { metricInformationBuilder.setNumContactsRetrieved(numContactsRetrieved); @@ -314,12 +407,16 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { // so we can tell whether to display the truncation notification. RdapAuthorization authorization = getAuthorization(); List> jsonOutputList = new ArrayList<>(); + // Each time we add a contact or registrar to the output data set, remember what the appropriate + // cursor would be if it were the last item returned. When we stop adding items, the last cursor + // value we remembered will be the right one to pass back. + Optional newCursor = Optional.empty(); for (ContactResource contact : contacts) { if (jsonOutputList.size() >= rdapResultSetMaxSize) { return RdapSearchResults.create( ImmutableList.copyOf(jsonOutputList), IncompletenessWarningType.TRUNCATED, - Optional.empty()); + newCursor); } // As per Andy Newton on the regext mailing list, contacts by themselves have no role, since // they are global, and might have different roles for different domains. @@ -332,16 +429,28 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { now, outputDataType, authorization)); + newCursor = + Optional.of( + CONTACT_CURSOR_PREFIX + + ((queryType == QueryType.FULL_NAME) + ? contact.getSearchName() + : contact.getRepoId())); } for (Registrar registrar : registrars) { if (jsonOutputList.size() >= rdapResultSetMaxSize) { return RdapSearchResults.create( ImmutableList.copyOf(jsonOutputList), IncompletenessWarningType.TRUNCATED, - Optional.empty()); + newCursor); } jsonOutputList.add(rdapJsonFormatter.makeRdapJsonForRegistrar( registrar, false, fullServletPath, rdapWhoisServer, now, outputDataType)); + newCursor = + Optional.of( + REGISTRAR_CURSOR_PREFIX + + ((queryType == QueryType.FULL_NAME) + ? registrar.getRegistrarName() + : registrar.getIanaIdentifier())); } return RdapSearchResults.create( ImmutableList.copyOf(jsonOutputList), diff --git a/java/google/registry/rdap/RdapNameserverSearchAction.java b/java/google/registry/rdap/RdapNameserverSearchAction.java index 1c0f472da..faa2058ec 100644 --- a/java/google/registry/rdap/RdapNameserverSearchAction.java +++ b/java/google/registry/rdap/RdapNameserverSearchAction.java @@ -139,21 +139,12 @@ public class RdapNameserverSearchAction extends RdapSearchActionBase { ImmutableMap.Builder jsonBuilder = new ImmutableMap.Builder<>(); jsonBuilder.put("nameserverSearchResults", results.jsonList()); - ImmutableList> notices = results.getIncompletenessWarnings(); - if (results.nextCursor().isPresent()) { - ImmutableList.Builder> noticesBuilder = - new ImmutableList.Builder<>(); - noticesBuilder.addAll(notices); - noticesBuilder.add( - RdapJsonFormatter.makeRdapJsonNavigationLinkNotice( - Optional.of( - getRequestUrlWithExtraParameter( - "cursor", encodeCursorToken(results.nextCursor().get()))))); - notices = noticesBuilder.build(); - } - rdapJsonFormatter.addTopLevelEntries( - jsonBuilder, BoilerplateType.NAMESERVER, notices, ImmutableList.of(), fullServletPath); + jsonBuilder, + BoilerplateType.NAMESERVER, + getNotices(results), + ImmutableList.of(), + fullServletPath); return jsonBuilder.build(); } diff --git a/java/google/registry/rdap/RdapSearchActionBase.java b/java/google/registry/rdap/RdapSearchActionBase.java index 41c79d914..e99b8c717 100644 --- a/java/google/registry/rdap/RdapSearchActionBase.java +++ b/java/google/registry/rdap/RdapSearchActionBase.java @@ -18,6 +18,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ImmutableMap; import google.registry.request.Parameter; import google.registry.request.ParameterMap; import google.registry.request.RequestUrl; @@ -112,4 +113,20 @@ public abstract class RdapSearchActionBase extends RdapActionBase { throw new RuntimeException(e); } } + + ImmutableList> getNotices(RdapSearchResults results) { + ImmutableList> notices = results.getIncompletenessWarnings(); + if (results.nextCursor().isPresent()) { + ImmutableList.Builder> noticesBuilder = + new ImmutableList.Builder<>(); + noticesBuilder.addAll(notices); + noticesBuilder.add( + RdapJsonFormatter.makeRdapJsonNavigationLinkNotice( + Optional.of( + getRequestUrlWithExtraParameter( + "cursor", encodeCursorToken(results.nextCursor().get()))))); + notices = noticesBuilder.build(); + } + return notices; + } } diff --git a/javatests/google/registry/rdap/RdapEntitySearchActionTest.java b/javatests/google/registry/rdap/RdapEntitySearchActionTest.java index 5474eb231..c0386d86b 100644 --- a/javatests/google/registry/rdap/RdapEntitySearchActionTest.java +++ b/javatests/google/registry/rdap/RdapEntitySearchActionTest.java @@ -34,8 +34,10 @@ import static org.mockito.Mockito.when; import com.google.appengine.api.users.User; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ImmutableMap; import google.registry.model.ImmutableObject; +import google.registry.model.contact.ContactResource; import google.registry.model.ofy.Ofy; import google.registry.model.registrar.Registrar; import google.registry.rdap.RdapMetrics.EndpointType; @@ -50,12 +52,14 @@ import google.registry.testing.FakeClock; import google.registry.testing.FakeResponse; import google.registry.testing.InjectRule; import google.registry.ui.server.registrar.SessionUtils; +import java.net.URLDecoder; import java.util.List; import java.util.Map; import java.util.Optional; import javax.annotation.Nullable; import javax.servlet.http.HttpServletRequest; import org.joda.time.DateTime; +import org.json.simple.JSONArray; import org.json.simple.JSONObject; import org.json.simple.JSONValue; import org.junit.Before; @@ -71,8 +75,12 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { @Rule public final AppEngineRule appEngine = AppEngineRule.builder().withDatastore().build(); @Rule public final InjectRule inject = new InjectRule(); + private enum QueryType { + FULL_NAME, + HANDLE + } + private final HttpServletRequest request = mock(HttpServletRequest.class); - private final FakeResponse response = new FakeResponse(); private final FakeClock clock = new FakeClock(DateTime.parse("2000-01-01T00:00:00Z")); private final SessionUtils sessionUtils = mock(SessionUtils.class); private final User user = new User("rdap.user@example.com", "gmail.com", "12345"); @@ -80,20 +88,44 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { private final UserAuthInfo adminUserAuthInfo = UserAuthInfo.create(user, true); private final RdapEntitySearchAction action = new RdapEntitySearchAction(); + private FakeResponse response = new FakeResponse(); + private Registrar registrarDeleted; private Registrar registrarInactive; private Registrar registrarTest; private Object generateActualJsonWithFullName(String fn) { + return generateActualJsonWithFullName(fn, null); + } + + private Object generateActualJsonWithFullName(String fn, String cursor) { metricSearchType = SearchType.BY_FULL_NAME; action.fnParam = Optional.of(fn); + if (cursor == null) { + action.parameterMap = ImmutableListMultimap.of("fn", fn); + action.cursorTokenParam = Optional.empty(); + } else { + action.parameterMap = ImmutableListMultimap.of("fn", fn, "cursor", cursor); + action.cursorTokenParam = Optional.of(cursor); + } action.run(); return JSONValue.parse(response.getPayload()); } private Object generateActualJsonWithHandle(String handle) { + return generateActualJsonWithHandle(handle, null); + } + + private Object generateActualJsonWithHandle(String handle, String cursor) { metricSearchType = SearchType.BY_HANDLE; action.handleParam = Optional.of(handle); + if (cursor == null) { + action.parameterMap = ImmutableListMultimap.of("handle", handle); + action.cursorTokenParam = Optional.empty(); + } else { + action.parameterMap = ImmutableListMultimap.of("handle", handle, "cursor", cursor); + action.cursorTokenParam = Optional.of(cursor); + } action.run(); return JSONValue.parse(response.getPayload()); } @@ -151,7 +183,9 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { action.request = request; action.requestMethod = Action.Method.GET; action.fullServletPath = "https://example.com/rdap"; + action.requestUrl = "https://example.com/rdap/entities"; action.requestPath = RdapEntitySearchAction.PATH; + action.parameterMap = ImmutableListMultimap.of(); action.response = response; action.rdapJsonFormatter = RdapTestHelper.getTestRdapJsonFormatter(); action.rdapResultSetMaxSize = 4; @@ -164,6 +198,7 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { action.sessionUtils = sessionUtils; action.authResult = AuthResult.create(AuthLevel.USER, userAuthInfo); action.rdapMetrics = rdapMetrics; + action.cursorTokenParam = Optional.empty(); } private void login(String registrar) { @@ -236,11 +271,17 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { int numContacts, int numRegistrars, Registrar contactRegistrar) { ImmutableList.Builder resourcesBuilder = new ImmutableList.Builder<>(); for (int i = 1; i <= numContacts; i++) { - resourcesBuilder.add(makeContactResource( - String.format("contact%d", i), - String.format("Entity %d", i), - String.format("contact%d@gmail.com", i), - contactRegistrar)); + // Set the ROIDs to a known value for later use. + ContactResource contact = + makeContactResource( + String.format("contact%d", i), + String.format("Entity %d", i), + String.format("contact%d@gmail.com", i), + contactRegistrar) + .asBuilder() + .setRepoId(String.format("%04d-ROID", i)) + .build(); + resourcesBuilder.add(contact); } persistResources(resourcesBuilder.build()); for (int i = 1; i <= numRegistrars; i++) { @@ -374,6 +415,45 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { assertThat(response.getStatus()).isEqualTo(404); } + /** + * Checks multi-page result set navigation using the cursor. + * + *

If there are more results than the max result set size, the RDAP code returns a cursor token + * which can be used in a subsequent call to get the next chunk of results. This method starts by + * making the query without a cursor, then follows the chain of pages using each returned cursor + * to ask for the next one, and makes sure that the expected number of pages are fetched. + * + * @param queryType type of query being run + * @param queryString the full name or handle query string + * @param expectedPageCount how many pages we expect to retrieve; all but the last will have a + * cursor + */ + private void checkCursorNavigation(QueryType queryType, String queryString, int expectedPageCount) + throws Exception { + String cursor = null; + for (int i = 0; i < expectedPageCount; i++) { + Object results = + (queryType == QueryType.FULL_NAME) + ? generateActualJsonWithFullName(queryString, cursor) + : generateActualJsonWithHandle(queryString, cursor); + assertThat(response.getStatus()).isEqualTo(200); + String linkToNext = RdapTestHelper.getLinkToNext(results); + if (i == expectedPageCount - 1) { + assertThat(linkToNext).isNull(); + } else { + assertThat(linkToNext).isNotNull(); + int pos = linkToNext.indexOf("cursor="); + assertThat(pos).isAtLeast(0); + cursor = URLDecoder.decode(linkToNext.substring(pos + 7), "UTF-8"); + Object nameserverSearchResults = ((JSONObject) results).get("entitySearchResults"); + assertThat(nameserverSearchResults).isInstanceOf(JSONArray.class); + assertThat(((JSONArray) nameserverSearchResults)).hasSize(action.rdapResultSetMaxSize); + response = new FakeResponse(); + action.response = response; + } + } + } + @Test public void testInvalidPath_rejected() throws Exception { action.requestPath = RdapEntitySearchAction.PATH + "/path"; @@ -591,7 +671,9 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { createManyContactsAndRegistrars(5, 0, registrarTest); rememberWildcardType("Entity *"); assertThat(generateActualJsonWithFullName("Entity *")) - .isEqualTo(generateExpectedJson("rdap_truncated_contacts.json")); + .isEqualTo( + generateExpectedJson( + "fn=Entity+*&cursor=YzpFbnRpdHkgNA%3D%3D", "rdap_truncated_contacts.json")); assertThat(response.getStatus()).isEqualTo(200); verifyMetrics(5); } @@ -602,12 +684,21 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { createManyContactsAndRegistrars(9, 0, registrarTest); rememberWildcardType("Entity *"); assertThat(generateActualJsonWithFullName("Entity *")) - .isEqualTo(generateExpectedJson("rdap_truncated_contacts.json")); + .isEqualTo( + generateExpectedJson( + "fn=Entity+*&cursor=YzpFbnRpdHkgNA%3D%3D", "rdap_truncated_contacts.json")); assertThat(response.getStatus()).isEqualTo(200); // For contacts, we only need to fetch one result set's worth (plus one). verifyMetrics(5); } + @Test + public void testNameMatchContacts_cursorNavigation() throws Exception { + login("2-RegistrarTest"); + createManyContactsAndRegistrars(9, 0, registrarTest); + checkCursorNavigation(QueryType.FULL_NAME, "Entity *", 3); + } + @Test public void testNameMatchRegistrars_nonTruncated() throws Exception { createManyContactsAndRegistrars(0, 4, registrarTest); @@ -623,7 +714,9 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { createManyContactsAndRegistrars(0, 5, registrarTest); rememberWildcardType("Entity *"); assertThat(generateActualJsonWithFullName("Entity *")) - .isEqualTo(generateExpectedJson("rdap_truncated_registrars.json")); + .isEqualTo( + generateExpectedJson( + "fn=Entity+*&cursor=cjpFbnRpdHkgNA%3D%3D", "rdap_truncated_registrars.json")); assertThat(response.getStatus()).isEqualTo(200); verifyMetrics(0); } @@ -633,22 +726,39 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { createManyContactsAndRegistrars(0, 9, registrarTest); rememberWildcardType("Entity *"); assertThat(generateActualJsonWithFullName("Entity *")) - .isEqualTo(generateExpectedJson("rdap_truncated_registrars.json")); + .isEqualTo( + generateExpectedJson( + "fn=Entity+*&cursor=cjpFbnRpdHkgNA%3D%3D", "rdap_truncated_registrars.json")); assertThat(response.getStatus()).isEqualTo(200); verifyMetrics(0); } + @Test + public void testNameMatchRegistrars_cursorNavigation() throws Exception { + createManyContactsAndRegistrars(0, 13, registrarTest); + checkCursorNavigation(QueryType.FULL_NAME, "Entity *", 4); + } + @Test public void testNameMatchMix_truncated() throws Exception { login("2-RegistrarTest"); createManyContactsAndRegistrars(3, 3, registrarTest); rememberWildcardType("Entity *"); assertThat(generateActualJsonWithFullName("Entity *")) - .isEqualTo(generateExpectedJson("rdap_truncated_mixed_entities.json")); + .isEqualTo( + generateExpectedJson( + "fn=Entity+*&cursor=cjpFbnRpdHkgNA%3D%3D", "rdap_truncated_mixed_entities.json")); assertThat(response.getStatus()).isEqualTo(200); verifyMetrics(3); } + @Test + public void testNameMatchMix_cursorNavigation() throws Exception { + login("2-RegistrarTest"); + createManyContactsAndRegistrars(3, 3, registrarTest); + checkCursorNavigation(QueryType.FULL_NAME, "Entity *", 2); + } + @Test public void testNameMatchRegistrar_notFound_inactive() throws Exception { runNotFoundNameTest("No Way"); @@ -890,6 +1000,18 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { verifyErrorMetrics(0); } + @Test + public void testHandleMatchContact_cursorNavigationWithFullLastPage() throws Exception { + createManyContactsAndRegistrars(12, 0, registrarTest); + checkCursorNavigation(QueryType.HANDLE, "00*", 3); + } + + @Test + public void testHandleMatchContact_cursorNavigationWithPartialLastPage() throws Exception { + createManyContactsAndRegistrars(13, 0, registrarTest); + checkCursorNavigation(QueryType.HANDLE, "00*", 4); + } + @Test public void testHandleMatchRegistrar_notFound_wildcard() throws Exception { runNotFoundHandleTest("3test*"); @@ -898,9 +1020,9 @@ public class RdapEntitySearchActionTest extends RdapSearchActionTestCase { @Test public void testHandleMatchMix_found_truncated() throws Exception { - createManyContactsAndRegistrars(300, 0, registrarTest); - rememberWildcardType("10*"); - Object obj = generateActualJsonWithHandle("10*"); + createManyContactsAndRegistrars(30, 0, registrarTest); + rememberWildcardType("00*"); + Object obj = generateActualJsonWithHandle("00*"); assertThat(response.getStatus()).isEqualTo(200); checkNumberOfEntitiesInResult(obj, 4); verifyMetrics(5); diff --git a/javatests/google/registry/rdap/RdapNameserverSearchActionTest.java b/javatests/google/registry/rdap/RdapNameserverSearchActionTest.java index 5d7674f47..eb837a6b8 100644 --- a/javatests/google/registry/rdap/RdapNameserverSearchActionTest.java +++ b/javatests/google/registry/rdap/RdapNameserverSearchActionTest.java @@ -709,34 +709,6 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase { verifyErrorMetrics(); } - private String getLinkToNext(Object results) { - assertThat(results).isInstanceOf(JSONObject.class); - Object notices = ((JSONObject) results).get("notices"); - assertThat(notices).isInstanceOf(JSONArray.class); - for (Object notice : (JSONArray) notices) { - assertThat(notice).isInstanceOf(JSONObject.class); - Object title = ((JSONObject) notice).get("title"); - assertThat(title).isInstanceOf(String.class); - if (!title.equals("Navigation Links")) { - continue; - } - Object links = ((JSONObject) notice).get("links"); - assertThat(links).isInstanceOf(JSONArray.class); - for (Object link : (JSONArray) links) { - assertThat(link).isInstanceOf(JSONObject.class); - Object rel = ((JSONObject) link).get("rel"); - assertThat(rel).isInstanceOf(String.class); - if (!rel.equals("next")) { - continue; - } - Object href = ((JSONObject) link).get("href"); - assertThat(href).isInstanceOf(String.class); - return (String) href; - } - } - return null; - } - /** * Checks multi-page result set navigation using the cursor. * @@ -757,7 +729,7 @@ public class RdapNameserverSearchActionTest extends RdapSearchActionTestCase { ? generateActualJsonWithName(queryString, cursor) : generateActualJsonWithIp(queryString, cursor); assertThat(response.getStatus()).isEqualTo(200); - String linkToNext = getLinkToNext(results); + String linkToNext = RdapTestHelper.getLinkToNext(results); if (i == expectedPageCount - 1) { assertThat(linkToNext).isNull(); } else { diff --git a/javatests/google/registry/rdap/RdapTestHelper.java b/javatests/google/registry/rdap/RdapTestHelper.java index 572a17214..2ce35350e 100644 --- a/javatests/google/registry/rdap/RdapTestHelper.java +++ b/javatests/google/registry/rdap/RdapTestHelper.java @@ -14,6 +14,8 @@ package google.registry.rdap; +import static com.google.common.truth.Truth.assertThat; + import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import google.registry.config.RdapNoticeDescriptor; @@ -21,6 +23,8 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Set; import javax.annotation.Nullable; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; public class RdapTestHelper { @@ -245,4 +249,32 @@ public class RdapTestHelper { .build()); return rdapJsonFormatter; } + + static String getLinkToNext(Object results) { + assertThat(results).isInstanceOf(JSONObject.class); + Object notices = ((JSONObject) results).get("notices"); + assertThat(notices).isInstanceOf(JSONArray.class); + for (Object notice : (JSONArray) notices) { + assertThat(notice).isInstanceOf(JSONObject.class); + Object title = ((JSONObject) notice).get("title"); + assertThat(title).isInstanceOf(String.class); + if (!title.equals("Navigation Links")) { + continue; + } + Object links = ((JSONObject) notice).get("links"); + assertThat(links).isInstanceOf(JSONArray.class); + for (Object link : (JSONArray) links) { + assertThat(link).isInstanceOf(JSONObject.class); + Object rel = ((JSONObject) link).get("rel"); + assertThat(rel).isInstanceOf(String.class); + if (!rel.equals("next")) { + continue; + } + Object href = ((JSONObject) link).get("href"); + assertThat(href).isInstanceOf(String.class); + return (String) href; + } + } + return null; + } } diff --git a/javatests/google/registry/rdap/testdata/rdap_nontruncated_contacts.json b/javatests/google/registry/rdap/testdata/rdap_nontruncated_contacts.json index ed9a6e9b3..263b6f002 100644 --- a/javatests/google/registry/rdap/testdata/rdap_nontruncated_contacts.json +++ b/javatests/google/registry/rdap/testdata/rdap_nontruncated_contacts.json @@ -3,14 +3,14 @@ [ { "objectClassName" : "entity", - "handle" : "9-ROID", + "handle" : "0001-ROID", "status" : ["active"], "links" : [ { - "value" : "https://example.com/rdap/entity/9-ROID", + "value" : "https://example.com/rdap/entity/0001-ROID", "rel" : "self", - "href": "https://example.com/rdap/entity/9-ROID", + "href": "https://example.com/rdap/entity/0001-ROID", "type" : "application/rdap+json" } ], @@ -49,14 +49,14 @@ }, { "objectClassName" : "entity", - "handle" : "A-ROID", + "handle" : "0002-ROID", "status" : ["active"], "links" : [ { - "value" : "https://example.com/rdap/entity/A-ROID", + "value" : "https://example.com/rdap/entity/0002-ROID", "rel" : "self", - "href": "https://example.com/rdap/entity/A-ROID", + "href": "https://example.com/rdap/entity/0002-ROID", "type" : "application/rdap+json" } ], @@ -95,14 +95,14 @@ }, { "objectClassName" : "entity", - "handle" : "B-ROID", + "handle" : "0003-ROID", "status" : ["active"], "links" : [ { - "value" : "https://example.com/rdap/entity/B-ROID", + "value" : "https://example.com/rdap/entity/0003-ROID", "rel" : "self", - "href": "https://example.com/rdap/entity/B-ROID", + "href": "https://example.com/rdap/entity/0003-ROID", "type" : "application/rdap+json" } ], @@ -141,14 +141,14 @@ }, { "objectClassName" : "entity", - "handle" : "C-ROID", + "handle" : "0004-ROID", "status" : ["active"], "links" : [ { - "value" : "https://example.com/rdap/entity/C-ROID", + "value" : "https://example.com/rdap/entity/0004-ROID", "rel" : "self", - "href": "https://example.com/rdap/entity/C-ROID", + "href": "https://example.com/rdap/entity/0004-ROID", "type" : "application/rdap+json" } ], diff --git a/javatests/google/registry/rdap/testdata/rdap_truncated_contacts.json b/javatests/google/registry/rdap/testdata/rdap_truncated_contacts.json index 520c3f262..80ed18563 100644 --- a/javatests/google/registry/rdap/testdata/rdap_truncated_contacts.json +++ b/javatests/google/registry/rdap/testdata/rdap_truncated_contacts.json @@ -3,14 +3,14 @@ [ { "objectClassName" : "entity", - "handle" : "9-ROID", + "handle" : "0001-ROID", "status" : ["active"], "links" : [ { - "value" : "https://example.com/rdap/entity/9-ROID", + "value" : "https://example.com/rdap/entity/0001-ROID", "rel" : "self", - "href": "https://example.com/rdap/entity/9-ROID", + "href": "https://example.com/rdap/entity/0001-ROID", "type" : "application/rdap+json" } ], @@ -49,14 +49,14 @@ }, { "objectClassName" : "entity", - "handle" : "A-ROID", + "handle" : "0002-ROID", "status" : ["active"], "links" : [ { - "value" : "https://example.com/rdap/entity/A-ROID", + "value" : "https://example.com/rdap/entity/0002-ROID", "rel" : "self", - "href": "https://example.com/rdap/entity/A-ROID", + "href": "https://example.com/rdap/entity/0002-ROID", "type" : "application/rdap+json" } ], @@ -95,14 +95,14 @@ }, { "objectClassName" : "entity", - "handle" : "B-ROID", + "handle" : "0003-ROID", "status" : ["active"], "links" : [ { - "value" : "https://example.com/rdap/entity/B-ROID", + "value" : "https://example.com/rdap/entity/0003-ROID", "rel" : "self", - "href": "https://example.com/rdap/entity/B-ROID", + "href": "https://example.com/rdap/entity/0003-ROID", "type" : "application/rdap+json" } ], @@ -141,14 +141,14 @@ }, { "objectClassName" : "entity", - "handle" : "C-ROID", + "handle" : "0004-ROID", "status" : ["active"], "links" : [ { - "value" : "https://example.com/rdap/entity/C-ROID", + "value" : "https://example.com/rdap/entity/0004-ROID", "rel" : "self", - "href": "https://example.com/rdap/entity/C-ROID", + "href": "https://example.com/rdap/entity/0004-ROID", "type" : "application/rdap+json" } ], @@ -197,6 +197,18 @@ "Search results per query are limited." ] }, + { + "title" : "Navigation Links", + "links" : + [ + { + "type" : "application/rdap+json", + "href" : "https://example.com/rdap/entities?%NAME%", + "rel" : "next" + } + ], + "description" : [ "Links to related pages." ], + }, { "title" : "RDAP Terms of Service", "description" : diff --git a/javatests/google/registry/rdap/testdata/rdap_truncated_mixed_entities.json b/javatests/google/registry/rdap/testdata/rdap_truncated_mixed_entities.json index 02ee622cd..78f3c6f67 100644 --- a/javatests/google/registry/rdap/testdata/rdap_truncated_mixed_entities.json +++ b/javatests/google/registry/rdap/testdata/rdap_truncated_mixed_entities.json @@ -3,14 +3,14 @@ [ { "objectClassName" : "entity", - "handle" : "9-ROID", + "handle" : "0001-ROID", "status" : ["active"], "links" : [ { - "value" : "https://example.com/rdap/entity/9-ROID", + "value" : "https://example.com/rdap/entity/0001-ROID", "rel" : "self", - "href": "https://example.com/rdap/entity/9-ROID", + "href": "https://example.com/rdap/entity/0001-ROID", "type" : "application/rdap+json" } ], @@ -49,14 +49,14 @@ }, { "objectClassName" : "entity", - "handle" : "A-ROID", + "handle" : "0002-ROID", "status" : ["active"], "links" : [ { - "value" : "https://example.com/rdap/entity/A-ROID", + "value" : "https://example.com/rdap/entity/0002-ROID", "rel" : "self", - "href": "https://example.com/rdap/entity/A-ROID", + "href": "https://example.com/rdap/entity/0002-ROID", "type" : "application/rdap+json" } ], @@ -95,14 +95,14 @@ }, { "objectClassName" : "entity", - "handle" : "B-ROID", + "handle" : "0003-ROID", "status" : ["active"], "links" : [ { - "value" : "https://example.com/rdap/entity/B-ROID", + "value" : "https://example.com/rdap/entity/0003-ROID", "rel" : "self", - "href": "https://example.com/rdap/entity/B-ROID", + "href": "https://example.com/rdap/entity/0003-ROID", "type" : "application/rdap+json" } ], @@ -204,6 +204,18 @@ "Search results per query are limited." ] }, + { + "title" : "Navigation Links", + "links" : + [ + { + "type" : "application/rdap+json", + "href" : "https://example.com/rdap/entities?%NAME%", + "rel" : "next" + } + ], + "description" : [ "Links to related pages." ], + }, { "title" : "RDAP Terms of Service", "description" : diff --git a/javatests/google/registry/rdap/testdata/rdap_truncated_registrars.json b/javatests/google/registry/rdap/testdata/rdap_truncated_registrars.json index 63d2eb6e8..e0f1e5fef 100644 --- a/javatests/google/registry/rdap/testdata/rdap_truncated_registrars.json +++ b/javatests/google/registry/rdap/testdata/rdap_truncated_registrars.json @@ -225,6 +225,18 @@ "Search results per query are limited." ] }, + { + "title" : "Navigation Links", + "links" : + [ + { + "type" : "application/rdap+json", + "href" : "https://example.com/rdap/entities?%NAME%", + "rel" : "next" + } + ], + "description" : [ "Links to related pages." ], + }, { "title" : "RDAP Terms of Service", "description" :