From 1d6b119340638f2dafa71886dc5998d1f7660104 Mon Sep 17 00:00:00 2001 From: gbrodman Date: Mon, 30 Oct 2023 17:01:31 -0400 Subject: [PATCH] Add a console action to retrieve a paged list of domains (#2193) In the future we'll want to add searching capability but for now we can go with straightforward pagination. --- .../registry/model/domain/DomainBase.java | 10 +- .../frontend/FrontendRequestComponent.java | 3 + .../console/ConsoleDomainListAction.java | 148 ++++++++++++ .../registrar/RegistrarConsoleModule.java | 25 ++ .../console/ConsoleDomainGetActionTest.java | 12 +- .../console/ConsoleDomainListActionTest.java | 215 ++++++++++++++++++ .../module/frontend/frontend_routing.txt | 1 + 7 files changed, 403 insertions(+), 11 deletions(-) create mode 100644 core/src/main/java/google/registry/ui/server/console/ConsoleDomainListAction.java create mode 100644 core/src/test/java/google/registry/ui/server/console/ConsoleDomainListActionTest.java diff --git a/core/src/main/java/google/registry/model/domain/DomainBase.java b/core/src/main/java/google/registry/model/domain/DomainBase.java index 95b84ff9c..ec0a13b2d 100644 --- a/core/src/main/java/google/registry/model/domain/DomainBase.java +++ b/core/src/main/java/google/registry/model/domain/DomainBase.java @@ -128,14 +128,14 @@ public class DomainBase extends EppResource String tld; /** References to hosts that are the nameservers for the domain. */ - @Expose @Transient Set> nsHosts; + @Transient Set> nsHosts; /** Contacts. */ - @Expose VKey adminContact; + VKey adminContact; - @Expose VKey billingContact; - @Expose VKey techContact; - @Expose VKey registrantContact; + VKey billingContact; + VKey techContact; + VKey registrantContact; /** Authorization info (aka transfer secret) of the domain. */ @Embedded diff --git a/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java b/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java index 7df6a1bb1..4204683be 100644 --- a/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java +++ b/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java @@ -26,6 +26,7 @@ import google.registry.request.RequestComponentBuilder; import google.registry.request.RequestModule; import google.registry.request.RequestScope; import google.registry.ui.server.console.ConsoleDomainGetAction; +import google.registry.ui.server.console.ConsoleDomainListAction; import google.registry.ui.server.console.ConsoleUserDataAction; import google.registry.ui.server.console.RegistrarsAction; import google.registry.ui.server.console.settings.ContactAction; @@ -55,6 +56,8 @@ import google.registry.ui.server.registrar.RegistryLockVerifyAction; interface FrontendRequestComponent { ConsoleDomainGetAction consoleDomainGetAction(); + ConsoleDomainListAction consoleDomainListAction(); + ConsoleOteSetupAction consoleOteSetupAction(); ConsoleRegistrarCreatorAction consoleRegistrarCreatorAction(); ConsoleUiAction consoleUiAction(); diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleDomainListAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleDomainListAction.java new file mode 100644 index 000000000..4ec4a2206 --- /dev/null +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleDomainListAction.java @@ -0,0 +1,148 @@ +// Copyright 2023 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.ui.server.console; + +import static google.registry.model.console.ConsolePermission.DOWNLOAD_DOMAINS; +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; + +import com.google.api.client.http.HttpStatusCodes; +import com.google.common.annotations.VisibleForTesting; +import com.google.gson.Gson; +import com.google.gson.annotations.Expose; +import google.registry.model.CreateAutoTimestamp; +import google.registry.model.console.User; +import google.registry.model.domain.Domain; +import google.registry.request.Action; +import google.registry.request.Parameter; +import google.registry.request.Response; +import google.registry.request.auth.Auth; +import google.registry.request.auth.AuthResult; +import google.registry.ui.server.registrar.JsonGetAction; +import java.util.List; +import java.util.Optional; +import javax.inject.Inject; +import org.joda.time.DateTime; + +/** Returns a (paginated) list of domains for a particular registrar. */ +@Action( + service = Action.Service.DEFAULT, + path = ConsoleDomainListAction.PATH, + method = Action.Method.GET, + auth = Auth.AUTH_PUBLIC_LOGGED_IN) +public class ConsoleDomainListAction implements JsonGetAction { + + public static final String PATH = "/console-api/domain-list"; + + private static final int DEFAULT_RESULTS_PER_PAGE = 50; + private static final String DOMAIN_QUERY_TEMPLATE = + "FROM Domain WHERE currentSponsorRegistrarId = :registrarId AND deletionTime >" + + " :deletedAfterTime AND creationTime <= :createdBeforeTime"; + + private final AuthResult authResult; + private final Response response; + private final Gson gson; + private final String registrarId; + private final Optional checkpointTime; + private final int pageNumber; + private final int resultsPerPage; + private final Optional totalResults; + + @Inject + public ConsoleDomainListAction( + AuthResult authResult, + Response response, + Gson gson, + @Parameter("registrarId") String registrarId, + @Parameter("checkpointTime") Optional checkpointTime, + @Parameter("pageNumber") Optional pageNumber, + @Parameter("resultsPerPage") Optional resultsPerPage, + @Parameter("totalResults") Optional totalResults) { + this.authResult = authResult; + this.response = response; + this.gson = gson; + this.registrarId = registrarId; + this.checkpointTime = checkpointTime; + this.pageNumber = pageNumber.orElse(0); + this.resultsPerPage = resultsPerPage.orElse(DEFAULT_RESULTS_PER_PAGE); + this.totalResults = totalResults; + } + + @Override + public void run() { + User user = authResult.userAuthInfo().get().consoleUser().get(); + if (!user.getUserRoles().hasPermission(registrarId, DOWNLOAD_DOMAINS)) { + response.setStatus(HttpStatusCodes.STATUS_CODE_FORBIDDEN); + return; + } + + if (resultsPerPage < 1 || resultsPerPage > 500) { + writeBadRequest("Results per page must be between 1 and 500 inclusive"); + return; + } + if (pageNumber < 0) { + writeBadRequest("Page number must be non-negative"); + return; + } + + tm().transact(this::runInTransaction); + } + + private void runInTransaction() { + int numResultsToSkip = resultsPerPage * pageNumber; + + // We have to use a constant checkpoint time in order to have stable pagination, since domains + // can be constantly created or deleted + DateTime checkpoint = checkpointTime.orElseGet(tm()::getTransactionTime); + CreateAutoTimestamp checkpointTimestamp = CreateAutoTimestamp.create(checkpoint); + // Don't compute the number of total results over and over if we don't need to + long actualTotalResults = + totalResults.orElseGet( + () -> + tm().query("SELECT COUNT(*) " + DOMAIN_QUERY_TEMPLATE, Long.class) + .setParameter("registrarId", registrarId) + .setParameter("createdBeforeTime", checkpointTimestamp) + .setParameter("deletedAfterTime", checkpoint) + .getSingleResult()); + List domains = + tm().query(DOMAIN_QUERY_TEMPLATE + " ORDER BY creationTime DESC", Domain.class) + .setParameter("registrarId", registrarId) + .setParameter("createdBeforeTime", checkpointTimestamp) + .setParameter("deletedAfterTime", checkpoint) + .setFirstResult(numResultsToSkip) + .setMaxResults(resultsPerPage) + .getResultList(); + response.setPayload(gson.toJson(new DomainListResult(domains, checkpoint, actualTotalResults))); + response.setStatus(HttpStatusCodes.STATUS_CODE_OK); + } + + private void writeBadRequest(String message) { + response.setPayload(message); + response.setStatus(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + } + + /** Container result class that allows for pagination. */ + @VisibleForTesting + static final class DomainListResult { + @Expose List domains; + @Expose DateTime checkpointTime; + @Expose long totalResults; + + private DomainListResult(List domains, DateTime checkpointTime, long totalResults) { + this.domains = domains; + this.checkpointTime = checkpointTime; + this.totalResults = totalResults; + } + } +} diff --git a/core/src/main/java/google/registry/ui/server/registrar/RegistrarConsoleModule.java b/core/src/main/java/google/registry/ui/server/registrar/RegistrarConsoleModule.java index 2fe928fa3..400901861 100644 --- a/core/src/main/java/google/registry/ui/server/registrar/RegistrarConsoleModule.java +++ b/core/src/main/java/google/registry/ui/server/registrar/RegistrarConsoleModule.java @@ -30,6 +30,7 @@ import google.registry.request.OptionalJsonPayload; import google.registry.request.Parameter; import java.util.Optional; import javax.servlet.http.HttpServletRequest; +import org.joda.time.DateTime; /** Dagger module for the Registrar Console parameters. */ @Module @@ -188,4 +189,28 @@ public final class RegistrarConsoleModule { Gson gson, @OptionalJsonPayload Optional payload) { return payload.map(s -> gson.fromJson(s, Registrar.class)); } + + @Provides + @Parameter("checkpointTime") + public static Optional provideCheckpointTime(HttpServletRequest req) { + return extractOptionalParameter(req, "checkpointTime").map(DateTime::parse); + } + + @Provides + @Parameter("pageNumber") + public static Optional providePageNumber(HttpServletRequest req) { + return extractOptionalIntParameter(req, "pageNumber"); + } + + @Provides + @Parameter("resultsPerPage") + public static Optional provideResultsPerPage(HttpServletRequest req) { + return extractOptionalIntParameter(req, "resultsPerPage"); + } + + @Provides + @Parameter("totalResults") + public static Optional provideTotalResults(HttpServletRequest req) { + return extractOptionalParameter(req, "totalResults").map(Long::valueOf); + } } diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleDomainGetActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleDomainGetActionTest.java index 8ccdcc2b5..46658e833 100644 --- a/core/src/test/java/google/registry/ui/server/console/ConsoleDomainGetActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleDomainGetActionTest.java @@ -66,12 +66,12 @@ public class ConsoleDomainGetActionTest { assertThat(RESPONSE.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_OK); assertThat(RESPONSE.getPayload()) .isEqualTo( - "{\"domainName\":\"exists.tld\",\"adminContact\":{\"key\":\"3-ROID\"},\"techContact\":" - + "{\"key\":\"3-ROID\"},\"registrantContact\":{\"key\":\"3-ROID\"},\"registrationExpirationTime\":" - + "\"294247-01-10T04:00:54.775Z\",\"lastTransferTime\":\"null\",\"repoId\":\"2-TLD\"," - + "\"currentSponsorRegistrarId\":\"TheRegistrar\",\"creationRegistrarId\":\"TheRegistrar\"," - + "\"creationTime\":{\"creationTime\":\"1970-01-01T00:00:00.000Z\"},\"lastEppUpdateTime\":\"null\"," - + "\"statuses\":[\"INACTIVE\"]}"); + "{\"domainName\":\"exists.tld\",\"registrationExpirationTime\":" + + "\"294247-01-10T04:00:54.775Z\",\"lastTransferTime\":\"null\",\"repoId\":" + + "\"2-TLD\",\"currentSponsorRegistrarId\":\"TheRegistrar\",\"creationRegistrarId\"" + + ":\"TheRegistrar\",\"creationTime\":{\"creationTime\":" + + "\"1970-01-01T00:00:00.000Z\"},\"lastEppUpdateTime\":\"null\",\"statuses\":" + + "[\"INACTIVE\"]}"); } @Test diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleDomainListActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleDomainListActionTest.java new file mode 100644 index 000000000..3cfa9d845 --- /dev/null +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleDomainListActionTest.java @@ -0,0 +1,215 @@ +// Copyright 2023 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.ui.server.console; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.truth.Truth.assertThat; +import static google.registry.testing.DatabaseHelper.createTld; +import static google.registry.testing.DatabaseHelper.persistActiveDomain; +import static google.registry.testing.DatabaseHelper.persistDomainAsDeleted; + +import com.google.api.client.http.HttpStatusCodes; +import com.google.gson.Gson; +import google.registry.model.EppResourceUtils; +import google.registry.model.console.GlobalRole; +import google.registry.model.console.User; +import google.registry.model.console.UserRoles; +import google.registry.model.domain.Domain; +import google.registry.persistence.transaction.JpaTestExtensions; +import google.registry.request.auth.AuthResult; +import google.registry.request.auth.UserAuthInfo; +import google.registry.testing.DatabaseHelper; +import google.registry.testing.FakeClock; +import google.registry.testing.FakeResponse; +import google.registry.tools.GsonUtils; +import google.registry.ui.server.console.ConsoleDomainListAction.DomainListResult; +import java.util.Optional; +import javax.annotation.Nullable; +import org.joda.time.DateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** Tests for {@link ConsoleDomainListAction}. */ +public class ConsoleDomainListActionTest { + + private static final Gson GSON = GsonUtils.provideGson(); + + private final FakeClock clock = new FakeClock(DateTime.parse("2023-10-20T00:00:00.000Z")); + + private FakeResponse response; + + @RegisterExtension + final JpaTestExtensions.JpaIntegrationTestExtension jpa = + new JpaTestExtensions.Builder().withClock(clock).buildIntegrationTestExtension(); + + @BeforeEach + void beforeEach() { + createTld("tld"); + for (int i = 0; i < 10; i++) { + DatabaseHelper.persistActiveDomain(i + "exists.tld", clock.nowUtc()); + clock.advanceOneMilli(); + } + DatabaseHelper.persistDeletedDomain("deleted.tld", clock.nowUtc().minusDays(1)); + } + + @Test + void testSuccess_allDomains() { + ConsoleDomainListAction action = createAction("TheRegistrar"); + action.run(); + DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class); + assertThat(result.domains).hasSize(10); + assertThat(result.totalResults).isEqualTo(10); + assertThat(result.checkpointTime).isEqualTo(clock.nowUtc()); + assertThat(result.domains.stream().anyMatch(d -> d.getDomainName().equals("deleted.tld"))) + .isFalse(); + } + + @Test + void testSuccess_noDomains() { + ConsoleDomainListAction action = createAction("NewRegistrar"); + action.run(); + DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class); + assertThat(result.domains).hasSize(0); + assertThat(result.totalResults).isEqualTo(0); + assertThat(result.checkpointTime).isEqualTo(clock.nowUtc()); + } + + @Test + void testSuccess_pages() { + // Two pages of results should go in reverse chronological order + ConsoleDomainListAction action = createAction("TheRegistrar", null, 0, 5, null); + action.run(); + DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class); + assertThat(result.domains.stream().map(Domain::getDomainName).collect(toImmutableList())) + .containsExactly("9exists.tld", "8exists.tld", "7exists.tld", "6exists.tld", "5exists.tld"); + assertThat(result.totalResults).isEqualTo(10); + + // Now do the second page + action = createAction("TheRegistrar", result.checkpointTime, 1, 5, 10L); + action.run(); + result = GSON.fromJson(response.getPayload(), DomainListResult.class); + assertThat(result.domains.stream().map(Domain::getDomainName).collect(toImmutableList())) + .containsExactly("4exists.tld", "3exists.tld", "2exists.tld", "1exists.tld", "0exists.tld"); + } + + @Test + void testSuccess_partialPage() { + ConsoleDomainListAction action = createAction("TheRegistrar", null, 1, 8, null); + action.run(); + DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class); + assertThat(result.domains.stream().map(Domain::getDomainName).collect(toImmutableList())) + .containsExactly("1exists.tld", "0exists.tld"); + } + + @Test + void testSuccess_checkpointTime_createdBefore() { + ConsoleDomainListAction action = createAction("TheRegistrar", null, 0, 10, null); + action.run(); + + DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class); + assertThat(result.domains).hasSize(10); + assertThat(result.totalResults).isEqualTo(10); + + clock.advanceOneMilli(); + persistActiveDomain("newdomain.tld", clock.nowUtc()); + + // Even though we persisted a new domain, the old checkpoint should return no more results + action = createAction("TheRegistrar", result.checkpointTime, 1, 10, null); + action.run(); + result = GSON.fromJson(response.getPayload(), DomainListResult.class); + assertThat(result.domains).isEmpty(); + assertThat(result.totalResults).isEqualTo(10); + } + + @Test + void testSuccess_checkpointTime_deletion() { + ConsoleDomainListAction action = createAction("TheRegistrar", null, 0, 5, null); + action.run(); + DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class); + + clock.advanceOneMilli(); + Domain toDelete = + EppResourceUtils.loadByForeignKey(Domain.class, "0exists.tld", clock.nowUtc()).get(); + persistDomainAsDeleted(toDelete, clock.nowUtc()); + + // Second page should include the domain that is now deleted due to the checkpoint time + action = createAction("TheRegistrar", result.checkpointTime, 1, 5, null); + action.run(); + result = GSON.fromJson(response.getPayload(), DomainListResult.class); + assertThat(result.domains.stream().map(Domain::getDomainName).collect(toImmutableList())) + .containsExactly("4exists.tld", "3exists.tld", "2exists.tld", "1exists.tld", "0exists.tld"); + } + + @Test + void testPartialSuccess_pastEnd() { + ConsoleDomainListAction action = createAction("TheRegistrar", null, 5, 5, null); + action.run(); + DomainListResult result = GSON.fromJson(response.getPayload(), DomainListResult.class); + assertThat(result.domains).isEmpty(); + } + + @Test + void testFailure_invalidResultsPerPage() { + ConsoleDomainListAction action = createAction("TheRegistrar", null, 0, 0, null); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + assertThat(response.getPayload()) + .isEqualTo("Results per page must be between 1 and 500 inclusive"); + + action = createAction("TheRegistrar", null, 0, 501, null); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + assertThat(response.getPayload()) + .isEqualTo("Results per page must be between 1 and 500 inclusive"); + } + + @Test + void testFailure_invalidPageNumber() { + ConsoleDomainListAction action = createAction("TheRegistrar", null, -1, 10, null); + action.run(); + assertThat(response.getStatus()).isEqualTo(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + assertThat(response.getPayload()).isEqualTo("Page number must be non-negative"); + } + + private ConsoleDomainListAction createAction(String registrarId) { + return createAction(registrarId, null, null, null, null); + } + + private ConsoleDomainListAction createAction( + String registrarId, + @Nullable DateTime checkpointTime, + @Nullable Integer pageNumber, + @Nullable Integer resultsPerPage, + @Nullable Long totalResults) { + response = new FakeResponse(); + AuthResult authResult = + AuthResult.createUser( + UserAuthInfo.create( + new User.Builder() + .setEmailAddress("email@email.example") + .setUserRoles(new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).build()) + .build())); + return new ConsoleDomainListAction( + authResult, + response, + GSON, + registrarId, + Optional.ofNullable(checkpointTime), + Optional.ofNullable(pageNumber), + Optional.ofNullable(resultsPerPage), + Optional.ofNullable(totalResults)); + } +} diff --git a/core/src/test/resources/google/registry/module/frontend/frontend_routing.txt b/core/src/test/resources/google/registry/module/frontend/frontend_routing.txt index 23d1886c6..2615a5198 100644 --- a/core/src/test/resources/google/registry/module/frontend/frontend_routing.txt +++ b/core/src/test/resources/google/registry/module/frontend/frontend_routing.txt @@ -1,6 +1,7 @@ PATH CLASS METHODS OK AUTH_METHODS MIN USER_POLICY /_dr/epp EppTlsAction POST n API APP ADMIN /console-api/domain ConsoleDomainGetAction GET n API,LEGACY USER PUBLIC +/console-api/domain-list ConsoleDomainListAction GET n API,LEGACY USER PUBLIC /console-api/registrars RegistrarsAction GET,POST n API,LEGACY USER PUBLIC /console-api/settings/contacts ContactAction GET,POST n API,LEGACY USER PUBLIC /console-api/settings/security SecurityAction POST n API,LEGACY USER PUBLIC