diff --git a/java/google/registry/model/OteAccountBuilder.java b/java/google/registry/model/OteAccountBuilder.java index e3c795022..52997c497 100644 --- a/java/google/registry/model/OteAccountBuilder.java +++ b/java/google/registry/model/OteAccountBuilder.java @@ -361,7 +361,7 @@ public final class OteAccountBuilder { } /** Returns the ClientIds of the OT&E, with the TLDs each has access to. */ - static ImmutableMap createClientIdToTldMap(String baseClientId) { + public static ImmutableMap createClientIdToTldMap(String baseClientId) { checkArgument( REGISTRAR_PATTERN.matcher(baseClientId).matches(), "Invalid registrar name: %s", @@ -374,4 +374,17 @@ public final class OteAccountBuilder { .put(baseClientId + "-5", baseClientId + "-eap") .build(); } + + /** Returns the base client ID that correspond to a given OT&E client ID. */ + public static String getBaseClientId(String oteClientId) { + int index = oteClientId.lastIndexOf('-'); + checkArgument(index > 0, "Invalid OT&E client ID: %s", oteClientId); + String baseClientId = oteClientId.substring(0, index); + checkArgument( + createClientIdToTldMap(baseClientId).containsKey(oteClientId), + "ID %s is not one of the OT&E client IDs for base %s", + oteClientId, + baseClientId); + return baseClientId; + } } diff --git a/java/google/registry/model/OteStats.java b/java/google/registry/model/OteStats.java index 393d45bf0..55301624b 100644 --- a/java/google/registry/model/OteStats.java +++ b/java/google/registry/model/OteStats.java @@ -177,7 +177,7 @@ public class OteStats { } /** Returns a more human-readable translation of the enum constant. */ - private String description() { + public String getDescription() { return Ascii.toLowerCase(this.name().replace('_', ' ')); } @@ -270,7 +270,7 @@ public class OteStats { return String.format( "%s\nTOTAL: %d", EnumSet.allOf(StatType.class).stream() - .map(stat -> String.format("%s: %d", stat.description(), statCounts.count(stat))) + .map(stat -> String.format("%s: %d", stat.getDescription(), statCounts.count(stat))) .collect(Collectors.joining("\n")), statCounts.size()); } diff --git a/java/google/registry/module/frontend/FrontendRequestComponent.java b/java/google/registry/module/frontend/FrontendRequestComponent.java index 3869425a1..277f2d745 100644 --- a/java/google/registry/module/frontend/FrontendRequestComponent.java +++ b/java/google/registry/module/frontend/FrontendRequestComponent.java @@ -27,6 +27,7 @@ import google.registry.request.RequestModule; import google.registry.request.RequestScope; import google.registry.ui.server.otesetup.ConsoleOteSetupAction; import google.registry.ui.server.registrar.ConsoleUiAction; +import google.registry.ui.server.registrar.OteStatusAction; import google.registry.ui.server.registrar.RegistrarConsoleModule; import google.registry.ui.server.registrar.RegistrarSettingsAction; @@ -46,6 +47,7 @@ interface FrontendRequestComponent { EppConsoleAction eppConsoleAction(); EppTlsAction eppTlsAction(); FlowComponent.Builder flowComponentBuilder(); + OteStatusAction oteStatusAction(); RegistrarSettingsAction registrarSettingsAction(); @Subcomponent.Builder diff --git a/java/google/registry/ui/server/registrar/OteStatusAction.java b/java/google/registry/ui/server/registrar/OteStatusAction.java new file mode 100644 index 000000000..fcd46cd56 --- /dev/null +++ b/java/google/registry/ui/server/registrar/OteStatusAction.java @@ -0,0 +1,121 @@ +// Copyright 2019 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.registrar; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static google.registry.security.JsonResponseHelper.Status.ERROR; +import static google.registry.security.JsonResponseHelper.Status.SUCCESS; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.common.flogger.FluentLogger; +import google.registry.model.OteAccountBuilder; +import google.registry.model.OteStats; +import google.registry.model.OteStats.StatType; +import google.registry.model.registrar.Registrar; +import google.registry.model.registrar.Registrar.Type; +import google.registry.request.Action; +import google.registry.request.JsonActionRunner; +import google.registry.request.auth.Auth; +import google.registry.request.auth.AuthenticatedRegistrarAccessor; +import google.registry.request.auth.AuthenticatedRegistrarAccessor.RegistrarAccessDeniedException; +import google.registry.security.JsonResponseHelper; +import java.util.Map; +import java.util.Optional; +import javax.inject.Inject; + +/** + * Admin servlet that allows creating or updating a registrar. Deletes are not allowed so as to + * preserve history. + */ +@Action(path = OteStatusAction.PATH, method = Action.Method.POST, auth = Auth.AUTH_PUBLIC_LOGGED_IN) +public final class OteStatusAction implements Runnable, JsonActionRunner.JsonAction { + + public static final String PATH = "/registrar-ote-status"; + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private static final String CLIENT_ID_PARAM = "clientId"; + private static final String COMPLETED_PARAM = "completed"; + private static final String DETAILS_PARAM = "details"; + private static final String STAT_TYPE_DESCRIPTION_PARAM = "description"; + private static final String STAT_TYPE_REQUIREMENT_PARAM = "requirement"; + private static final String STAT_TYPE_TIMES_PERFORMED_PARAM = "timesPerformed"; + + @Inject AuthenticatedRegistrarAccessor registrarAccessor; + @Inject JsonActionRunner jsonActionRunner; + + @Inject + OteStatusAction() {} + + @Override + public void run() { + jsonActionRunner.run(this); + } + + @Override + public Map handleJsonRequest(Map input) { + try { + checkArgument(input != null, "Malformed JSON"); + + String oteClientId = (String) input.get(CLIENT_ID_PARAM); + checkArgument( + !Strings.isNullOrEmpty(oteClientId), "Missing key for OT&E client: %s", CLIENT_ID_PARAM); + + String baseClientId = OteAccountBuilder.getBaseClientId(oteClientId); + Registrar oteRegistrar = registrarAccessor.getRegistrar(oteClientId); + verifyOteAccess(baseClientId); + checkArgument( + Type.OTE.equals(oteRegistrar.getType()), + "Registrar with ID %s is not an OT&E registrar", + oteClientId); + + OteStats oteStats = OteStats.getFromRegistrar(baseClientId); + return JsonResponseHelper.create( + SUCCESS, "OT&E check completed successfully", convertOteStats(baseClientId, oteStats)); + } catch (Throwable e) { + logger.atWarning().withCause(e).log( + "Failed to verify OT&E status for registrar with input %s", input); + return JsonResponseHelper.create( + ERROR, Optional.ofNullable(e.getMessage()).orElse("Unspecified error")); + } + } + + private void verifyOteAccess(String baseClientId) throws RegistrarAccessDeniedException { + for (String oteClientId : OteAccountBuilder.createClientIdToTldMap(baseClientId).keySet()) { + registrarAccessor.verifyAccess(oteClientId); + } + } + + private Map convertOteStats(String baseClientId, OteStats oteStats) { + return ImmutableMap.of( + CLIENT_ID_PARAM, baseClientId, + COMPLETED_PARAM, oteStats.getFailures().isEmpty(), + DETAILS_PARAM, + StatType.REQUIRED_STAT_TYPES.stream() + .map(statType -> convertSingleRequirement(statType, oteStats.getCount(statType))) + .collect(toImmutableList())); + } + + private Map convertSingleRequirement(StatType statType, int count) { + int requirement = statType.getRequirement(); + return ImmutableMap.of( + STAT_TYPE_DESCRIPTION_PARAM, statType.getDescription(), + STAT_TYPE_REQUIREMENT_PARAM, requirement, + STAT_TYPE_TIMES_PERFORMED_PARAM, count, + COMPLETED_PARAM, count >= requirement); + } +} diff --git a/javatests/google/registry/model/OteAccountBuilderTest.java b/javatests/google/registry/model/OteAccountBuilderTest.java index 792f8cd84..cf8ff12b4 100644 --- a/javatests/google/registry/model/OteAccountBuilderTest.java +++ b/javatests/google/registry/model/OteAccountBuilderTest.java @@ -299,4 +299,53 @@ public final class OteAccountBuilderTest { assertContactExists("myclientid-3", "other@example.com"); assertContactExists("myclientid-3", "email@example.com"); } + + @Test + public void testCreateClientIdToTldMap_validEntries() { + assertThat(OteAccountBuilder.createClientIdToTldMap("myclientid")) + .containsExactly( + "myclientid-1", "myclientid-sunrise", + "myclientid-2", "myclientid-landrush", + "myclientid-3", "myclientid-ga", + "myclientid-4", "myclientid-ga", + "myclientid-5", "myclientid-eap"); + } + + @Test + public void testCreateClientIdToTldMap_invalidId() { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, () -> OteAccountBuilder.createClientIdToTldMap("a")); + assertThat(exception).hasMessageThat().isEqualTo("Invalid registrar name: a"); + } + + @Test + public void testGetBaseClientId_validOteId() { + assertThat(OteAccountBuilder.getBaseClientId("myclientid-4")).isEqualTo("myclientid"); + } + + @Test + public void testGetBaseClientId_multipleDashesValid() { + assertThat(OteAccountBuilder.getBaseClientId("two-dashes-3")).isEqualTo("two-dashes"); + } + + @Test + public void testGetBaseClientId_invalidInput_malformed() { + assertThat( + assertThrows( + IllegalArgumentException.class, + () -> OteAccountBuilder.getBaseClientId("myclientid"))) + .hasMessageThat() + .isEqualTo("Invalid OT&E client ID: myclientid"); + } + + @Test + public void testGetBaseClientId_invalidInput_wrongForBase() { + assertThat( + assertThrows( + IllegalArgumentException.class, + () -> OteAccountBuilder.getBaseClientId("myclientid-7"))) + .hasMessageThat() + .isEqualTo("ID myclientid-7 is not one of the OT&E client IDs for base myclientid"); + } } diff --git a/javatests/google/registry/module/frontend/testdata/frontend_routing.txt b/javatests/google/registry/module/frontend/testdata/frontend_routing.txt index f60c5ccf9..22d10ca49 100644 --- a/javatests/google/registry/module/frontend/testdata/frontend_routing.txt +++ b/javatests/google/registry/module/frontend/testdata/frontend_routing.txt @@ -1,6 +1,7 @@ -PATH CLASS METHODS OK AUTH_METHODS MIN USER_POLICY -/_dr/epp EppTlsAction POST n INTERNAL,API APP PUBLIC -/registrar ConsoleUiAction GET n INTERNAL,API,LEGACY NONE PUBLIC -/registrar-ote-setup ConsoleOteSetupAction POST,GET n INTERNAL,API,LEGACY NONE PUBLIC -/registrar-settings RegistrarSettingsAction POST n API,LEGACY USER PUBLIC -/registrar-xhr EppConsoleAction POST n API,LEGACY USER PUBLIC +PATH CLASS METHODS OK AUTH_METHODS MIN USER_POLICY +/_dr/epp EppTlsAction POST n INTERNAL,API APP PUBLIC +/registrar ConsoleUiAction GET n INTERNAL,API,LEGACY NONE PUBLIC +/registrar-ote-setup ConsoleOteSetupAction POST,GET n INTERNAL,API,LEGACY NONE PUBLIC +/registrar-ote-status OteStatusAction POST n API,LEGACY USER PUBLIC +/registrar-settings RegistrarSettingsAction POST n API,LEGACY USER PUBLIC +/registrar-xhr EppConsoleAction POST n API,LEGACY USER PUBLIC diff --git a/javatests/google/registry/testing/DatastoreHelper.java b/javatests/google/registry/testing/DatastoreHelper.java index af66dda89..1145be6e5 100644 --- a/javatests/google/registry/testing/DatastoreHelper.java +++ b/javatests/google/registry/testing/DatastoreHelper.java @@ -701,7 +701,7 @@ public class DatastoreHelper { /** Persists and returns a {@link Registrar} with the specified attributes. */ public static Registrar persistNewRegistrar( - String clientId, String registrarName, Registrar.Type type, long ianaIdentifier) { + String clientId, String registrarName, Registrar.Type type, @Nullable Long ianaIdentifier) { return persistSimpleResource( new Registrar.Builder() .setClientId(clientId) diff --git a/javatests/google/registry/ui/server/registrar/BUILD b/javatests/google/registry/ui/server/registrar/BUILD index fd60ec5c6..16a8bdf8c 100644 --- a/javatests/google/registry/ui/server/registrar/BUILD +++ b/javatests/google/registry/ui/server/registrar/BUILD @@ -22,6 +22,7 @@ java_library( "//java/google/registry/ui/server", "//java/google/registry/ui/server/registrar", "//java/google/registry/util", + "//javatests/google/registry/model", "//javatests/google/registry/security", "//javatests/google/registry/testing", "//third_party/objectify:objectify-v4_1", diff --git a/javatests/google/registry/ui/server/registrar/OteStatusActionTest.java b/javatests/google/registry/ui/server/registrar/OteStatusActionTest.java new file mode 100644 index 000000000..e9d5ff2d1 --- /dev/null +++ b/javatests/google/registry/ui/server/registrar/OteStatusActionTest.java @@ -0,0 +1,149 @@ +// Copyright 2019 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.registrar; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableSetMultimap.toImmutableSetMultimap; +import static com.google.common.truth.Truth.assertThat; +import static google.registry.testing.DatastoreHelper.persistNewRegistrar; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSetMultimap; +import com.google.common.collect.Iterables; +import google.registry.model.OteAccountBuilder; +import google.registry.model.OteStats.StatType; +import google.registry.model.OteStatsTestHelper; +import google.registry.model.registrar.Registrar.Type; +import google.registry.request.auth.AuthenticatedRegistrarAccessor; +import google.registry.request.auth.AuthenticatedRegistrarAccessor.Role; +import google.registry.testing.AppEngineRule; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link OteStatusAction} */ +@RunWith(JUnit4.class) +public final class OteStatusActionTest { + + private static final String CLIENT_ID = "blobio-1"; + private static final String BASE_CLIENT_ID = "blobio"; + + private final OteStatusAction action = new OteStatusAction(); + + @Rule public final AppEngineRule appEngine = AppEngineRule.builder().withDatastore().build(); + + @Before + public void init() throws Exception { + OteStatsTestHelper.setupHistoryEntries(); + persistNewRegistrar(CLIENT_ID, "SomeRegistrar", Type.OTE, null); + + ImmutableSetMultimap authValues = + OteAccountBuilder.createClientIdToTldMap("blobio").keySet().stream() + .collect(toImmutableSetMultimap(Function.identity(), ignored -> Role.OWNER)); + action.registrarAccessor = AuthenticatedRegistrarAccessor.createForTesting(authValues); + } + + @Test + @SuppressWarnings("unchecked") + public void testSuccess_finishedOte() { + Map actionResult = action.handleJsonRequest(ImmutableMap.of("clientId", CLIENT_ID)); + assertThat(actionResult).containsEntry("status", "SUCCESS"); + assertThat(actionResult).containsEntry("message", "OT&E check completed successfully"); + Map results = + Iterables.getOnlyElement((List>) actionResult.get("results")); + assertThat(results).containsEntry("clientId", BASE_CLIENT_ID); + assertThat(results).containsEntry("completed", true); + assertThat(getFailingResultDetails(results)).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + public void testSuccess_unfinishedOte() { + OteStatsTestHelper.deleteHostDeleteHistoryEntry(); + + Map actionResult = action.handleJsonRequest(ImmutableMap.of("clientId", CLIENT_ID)); + assertThat(actionResult).containsEntry("status", "SUCCESS"); + assertThat(actionResult).containsEntry("message", "OT&E check completed successfully"); + Map results = + Iterables.getOnlyElement((List>) actionResult.get("results")); + assertThat(results).containsEntry("clientId", BASE_CLIENT_ID); + assertThat(results).containsEntry("completed", false); + assertThat(getFailingResultDetails(results)) + .containsExactly( + ImmutableMap.of( + "description", StatType.HOST_DELETES.getDescription(), + "requirement", StatType.HOST_DELETES.getRequirement(), + "timesPerformed", 0, + "completed", false)); + } + + @Test + public void testFailure_malformedInput() { + assertThat(action.handleJsonRequest(null)) + .containsExactlyEntriesIn(errorResultWithMessage("Malformed JSON")); + assertThat(action.handleJsonRequest(ImmutableMap.of())) + .containsExactlyEntriesIn(errorResultWithMessage("Missing key for OT&E client: clientId")); + } + + @Test + public void testFailure_noRegistrar() { + assertThat(action.handleJsonRequest(ImmutableMap.of("clientId", "nonexistent-id-2"))) + .containsExactlyEntriesIn( + errorResultWithMessage("TestUserId doesn't have access to registrar nonexistent-id-2")); + } + + @Test + public void testFailure_notAuthorized() { + action.registrarAccessor = + AuthenticatedRegistrarAccessor.createForTesting(ImmutableSetMultimap.of()); + assertThat(action.handleJsonRequest(ImmutableMap.of("clientId", CLIENT_ID))) + .containsExactlyEntriesIn( + errorResultWithMessage("TestUserId doesn't have access to registrar blobio-1")); + } + + @Test + public void testFailure_malformedRegistrarName() { + assertThat(action.handleJsonRequest(ImmutableMap.of("clientId", "bad-client-id"))) + .containsExactlyEntriesIn( + errorResultWithMessage( + "ID bad-client-id is not one of the OT&E client IDs for base bad-client")); + } + + @Test + public void testFailure_nonOteRegistrar() { + persistNewRegistrar(CLIENT_ID, "SomeRegistrar", Type.REAL, 1L); + assertThat(action.handleJsonRequest(ImmutableMap.of("clientId", CLIENT_ID))) + .containsExactlyEntriesIn( + errorResultWithMessage("Registrar with ID blobio-1 is not an OT&E registrar")); + } + + @SuppressWarnings("unchecked") + private List> getFailingResultDetails(Map results) { + return ((List>) results.get("details")) + .stream() + .filter(result -> !Boolean.TRUE.equals(result.get("completed"))) + .collect(toImmutableList()); + } + + private ImmutableMap errorResultWithMessage(String message) { + return ImmutableMap.of("status", "ERROR", "message", message, "results", ImmutableList.of()); + } +}