diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index 9ea98c4bf13..7d1da470e05 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -1462,6 +1462,12 @@ public static ImmutableSet provideMosapiServices(RegistryConfigSettings return ImmutableSet.copyOf(config.mosapi.services); } + @Provides + @Config("mosapiTldThreadCnt") + public static int provideMosapiTldThreads(RegistryConfigSettings config) { + return config.mosapi.tldThreadCnt; + } + private static String formatComments(String text) { return Splitter.on('\n').omitEmptyStrings().trimResults().splitToList(text).stream() .map(s -> "# " + s) diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index 1d94be17149..7cdb42ee45b 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -272,5 +272,6 @@ public static class MosApi { public String entityType; public List tlds; public List services; + public int tldThreadCnt; } } diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index 060665e9f25..99f532df1cd 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -642,4 +642,8 @@ mosapi: - "epp" - "dnssec" + # Provides a fixed thread pool for parallel TLD processing. + # @see + # ICANN MoSAPI Specification, Section 12.3 + tldThreadCnt: 4 diff --git a/core/src/main/java/google/registry/module/RequestComponent.java b/core/src/main/java/google/registry/module/RequestComponent.java index 2444567c8ee..8ff3a3ae492 100644 --- a/core/src/main/java/google/registry/module/RequestComponent.java +++ b/core/src/main/java/google/registry/module/RequestComponent.java @@ -62,6 +62,8 @@ import google.registry.module.ReadinessProbeAction.ReadinessProbeActionPubApi; import google.registry.module.ReadinessProbeAction.ReadinessProbeConsoleAction; import google.registry.monitoring.whitebox.WhiteboxModule; +import google.registry.mosapi.GetServiceStateAction; +import google.registry.mosapi.module.MosApiRequestModule; import google.registry.rdap.RdapAutnumAction; import google.registry.rdap.RdapDomainAction; import google.registry.rdap.RdapDomainSearchAction; @@ -151,6 +153,7 @@ EppToolModule.class, IcannReportingModule.class, LoadTestModule.class, + MosApiRequestModule.class, RdapModule.class, RdeModule.class, ReportingModule.class, @@ -232,6 +235,8 @@ interface RequestComponent { GenerateZoneFilesAction generateZoneFilesAction(); + GetServiceStateAction getServiceStateAction(); + IcannReportingStagingAction icannReportingStagingAction(); IcannReportingUploadAction icannReportingUploadAction(); diff --git a/core/src/main/java/google/registry/mosapi/GetServiceStateAction.java b/core/src/main/java/google/registry/mosapi/GetServiceStateAction.java new file mode 100644 index 00000000000..a72b8977b29 --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/GetServiceStateAction.java @@ -0,0 +1,68 @@ +// Copyright 2025 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.mosapi; + +import com.google.common.net.MediaType; +import com.google.gson.Gson; +import google.registry.request.Action; +import google.registry.request.HttpException.ServiceUnavailableException; +import google.registry.request.Parameter; +import google.registry.request.Response; +import google.registry.request.auth.Auth; +import jakarta.inject.Inject; +import java.util.Optional; + +/** An action that returns the current MoSAPI service state for a given TLD or all TLDs. */ +@Action( + service = Action.Service.BACKEND, + path = GetServiceStateAction.PATH, + method = Action.Method.GET, + auth = Auth.AUTH_ADMIN) +public class GetServiceStateAction implements Runnable { + + public static final String PATH = "/_dr/mosapi/getServiceState"; + public static final String TLD_PARAM = "tld"; + + private final MosApiStateService stateService; + private final Response response; + private final Gson gson; + private final Optional tld; + + @Inject + public GetServiceStateAction( + MosApiStateService stateService, + Response response, + Gson gson, + @Parameter(TLD_PARAM) Optional tld) { + this.stateService = stateService; + this.response = response; + this.gson = gson; + this.tld = tld; + } + + @Override + public void run() { + response.setContentType(MediaType.JSON_UTF_8); + try { + if (tld.isPresent()) { + response.setPayload(gson.toJson(stateService.getServiceStateSummary(tld.get()))); + } else { + response.setPayload(gson.toJson(stateService.getAllServiceStateSummaries())); + } + } catch (MosApiException e) { + throw new ServiceUnavailableException("Error fetching MoSAPI service state."); + } + } +} diff --git a/core/src/main/java/google/registry/mosapi/model/MosApiErrorResponse.java b/core/src/main/java/google/registry/mosapi/MosApiErrorResponse.java similarity index 80% rename from core/src/main/java/google/registry/mosapi/model/MosApiErrorResponse.java rename to core/src/main/java/google/registry/mosapi/MosApiErrorResponse.java index afd41e69e56..34181115da4 100644 --- a/core/src/main/java/google/registry/mosapi/model/MosApiErrorResponse.java +++ b/core/src/main/java/google/registry/mosapi/MosApiErrorResponse.java @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -package google.registry.mosapi.model; +package google.registry.mosapi; + +import com.google.gson.annotations.Expose; /** * Represents the generic JSON error response from the MoSAPI service for a 400 Bad Request. @@ -20,4 +22,5 @@ * @see ICANN MoSAPI Specification, Section * 8 */ -public record MosApiErrorResponse(String resultCode, String message, String description) {} +public record MosApiErrorResponse( + @Expose String resultCode, @Expose String message, @Expose String description) {} diff --git a/core/src/main/java/google/registry/mosapi/MosApiException.java b/core/src/main/java/google/registry/mosapi/MosApiException.java index e38e0236e2c..fd0f7810b3d 100644 --- a/core/src/main/java/google/registry/mosapi/MosApiException.java +++ b/core/src/main/java/google/registry/mosapi/MosApiException.java @@ -17,7 +17,6 @@ import static java.lang.annotation.ElementType.TYPE; import static java.lang.annotation.RetentionPolicy.RUNTIME; -import google.registry.mosapi.model.MosApiErrorResponse; import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -42,6 +41,11 @@ public MosApiException(String message, Throwable cause) { this.errorResponse = null; } + public MosApiException(String message) { + super(message); + this.errorResponse = null; + } + public Optional getErrorResponse() { return Optional.ofNullable(errorResponse); } diff --git a/core/src/main/java/google/registry/mosapi/MosApiModels.java b/core/src/main/java/google/registry/mosapi/MosApiModels.java new file mode 100644 index 00000000000..dba9598a177 --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/MosApiModels.java @@ -0,0 +1,122 @@ +// Copyright 2025 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.mosapi; + +import static google.registry.util.CollectionUtils.nullToEmptyImmutableCopy; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; + +/** Data models for ICANN MoSAPI. */ +public final class MosApiModels { + + private MosApiModels() {} + + /** + * A wrapper response containing the state summaries of all monitored services. + * + *

This corresponds to the collection of service statuses returned when monitoring the state of + * a TLD + * + * @see ICANN MoSAPI Specification, + * Section 5.1 + */ + public record AllServicesStateResponse( + // A list of state summaries for each monitored service (e.g. DNS, RDDS, etc.) + @Expose List serviceStates) { + + public AllServicesStateResponse { + serviceStates = nullToEmptyImmutableCopy(serviceStates); + } + } + + /** + * A summary of a service incident. + * + * @see ICANN MoSAPI Specification, + * Section 5.1 + */ + public record IncidentSummary( + @Expose String incidentID, + @Expose long startTime, + @Expose boolean falsePositive, + @Expose String state, + @Expose @Nullable Long endTime) {} + + /** + * A curated summary of the service state for a TLD. + * + *

This class aggregates the high-level status of a TLD and details of any active incidents + * affecting specific services (like DNS or RDDS), based on the data structures defined in the + * MoSAPI specification. + * + * @see ICANN MoSAPI Specification, + * Section 5.1 + */ + public record ServiceStateSummary( + @Expose String tld, + @Expose String overallStatus, + @Expose List activeIncidents) { + + public ServiceStateSummary { + activeIncidents = nullToEmptyImmutableCopy(activeIncidents); + } + } + + /** Represents the status of a single monitored service. */ + public record ServiceStatus( + /** + * A JSON string that contains the status of the Service as seen from the monitoring system. + * Possible values include "Up", "Down", "Disabled", "UP-inconclusive-no-data", etc. + */ + @Expose String status, + + // A JSON number that contains the current percentage of the Emergency Threshold + // of the Service. A value of "0" specifies that there are no Incidents + // affecting the threshold. + @Expose double emergencyThreshold, + @Expose List incidents) { + + public ServiceStatus { + incidents = nullToEmptyImmutableCopy(incidents); + } + } + + /** + * Represents the overall health of all monitored services for a TLD. + * + * @see ICANN MoSAPI Specification, + * Section 5.1 + */ + public record TldServiceState( + @Expose String tld, + long lastUpdateApiDatabase, + + // A JSON string that contains the status of the TLD as seen from the monitoring system + @Expose String status, + + // A JSON object containing detailed information for each potential monitored service (i.e., + // DNS, + // RDDS, EPP, DNSSEC, RDAP). + @Expose @SerializedName("testedServices") Map serviceStatuses) { + + public TldServiceState { + serviceStatuses = nullToEmptyImmutableCopy(serviceStatuses); + } + } +} diff --git a/core/src/main/java/google/registry/mosapi/MosApiStateService.java b/core/src/main/java/google/registry/mosapi/MosApiStateService.java new file mode 100644 index 00000000000..51cf34f29ea --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/MosApiStateService.java @@ -0,0 +1,110 @@ +// Copyright 2025 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.mosapi; + +import static com.google.common.collect.ImmutableList.toImmutableList; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.flogger.FluentLogger; +import google.registry.config.RegistryConfig.Config; +import google.registry.mosapi.MosApiModels.AllServicesStateResponse; +import google.registry.mosapi.MosApiModels.ServiceStateSummary; +import google.registry.mosapi.MosApiModels.ServiceStatus; +import google.registry.mosapi.MosApiModels.TldServiceState; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; + +/** A service that provides business logic for interacting with MoSAPI Service State. */ +public class MosApiStateService { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private final ServiceMonitoringClient serviceMonitoringClient; + private final ExecutorService tldExecutor; + + private final ImmutableSet tlds; + + private static final String DOWN_STATUS = "Down"; + private static final String FETCH_ERROR_STATUS = "ERROR"; + + @Inject + public MosApiStateService( + ServiceMonitoringClient serviceMonitoringClient, + @Config("mosapiTlds") ImmutableSet tlds, + @Named("mosapiTldExecutor") ExecutorService tldExecutor) { + this.serviceMonitoringClient = serviceMonitoringClient; + this.tlds = tlds; + this.tldExecutor = tldExecutor; + } + + /** Fetches and transforms the service state for a given TLD into a summary. */ + public ServiceStateSummary getServiceStateSummary(String tld) throws MosApiException { + TldServiceState rawState = serviceMonitoringClient.getTldServiceState(tld); + return transformToSummary(rawState); + } + + /** Fetches and transforms the service state for all configured TLDs. */ + public AllServicesStateResponse getAllServiceStateSummaries() { + ImmutableList> futures = + tlds.stream() + .map( + tld -> + CompletableFuture.supplyAsync( + () -> { + try { + return getServiceStateSummary(tld); + } catch (MosApiException e) { + logger.atWarning().withCause(e).log( + "Failed to get service state for TLD %s.", tld); + // we don't want to throw exception if fetch failed + return new ServiceStateSummary(tld, FETCH_ERROR_STATUS, null); + } + }, + tldExecutor)) + .collect(ImmutableList.toImmutableList()); + + ImmutableList summaries = + futures.stream() + .map(CompletableFuture::join) // Waits for all tasks to complete + .collect(toImmutableList()); + + return new AllServicesStateResponse(summaries); + } + + private ServiceStateSummary transformToSummary(TldServiceState rawState) { + ImmutableList activeIncidents = ImmutableList.of(); + if (DOWN_STATUS.equalsIgnoreCase(rawState.status())) { + activeIncidents = + rawState.serviceStatuses().entrySet().stream() + .filter( + entry -> { + ServiceStatus serviceStatus = entry.getValue(); + return serviceStatus.incidents() != null + && !serviceStatus.incidents().isEmpty(); + }) + .map( + entry -> + new ServiceStatus( + // key is the service name + entry.getKey(), + entry.getValue().emergencyThreshold(), + entry.getValue().incidents())) + .collect(toImmutableList()); + } + return new ServiceStateSummary(rawState.tld(), rawState.status(), activeIncidents); + } +} diff --git a/core/src/main/java/google/registry/mosapi/ServiceMonitoringClient.java b/core/src/main/java/google/registry/mosapi/ServiceMonitoringClient.java new file mode 100644 index 00000000000..d066b00d125 --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/ServiceMonitoringClient.java @@ -0,0 +1,80 @@ +// Copyright 2025 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.mosapi; + +import com.google.common.base.Throwables; +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import google.registry.mosapi.MosApiModels.TldServiceState; +import jakarta.inject.Inject; +import java.io.IOException; +import java.util.Collections; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** Facade for MoSAPI's service monitoring endpoints. */ +public class ServiceMonitoringClient { + + private static final String MONITORING_STATE_ENDPOINT = "v2/monitoring/state"; + private final MosApiClient mosApiClient; + private final Gson gson; + + @Inject + public ServiceMonitoringClient(MosApiClient mosApiClient, Gson gson) { + this.mosApiClient = mosApiClient; + this.gson = gson; + } + + /** + * Fetches the current state of all monitored services for a given TLD. + * + * @see ICANN MoSAPI Specification, + * Section 5.1 + */ + public TldServiceState getTldServiceState(String tld) throws MosApiException { + try (Response response = + mosApiClient.sendGetRequest( + tld, MONITORING_STATE_ENDPOINT, Collections.emptyMap(), Collections.emptyMap())) { + + ResponseBody responseBody = response.body(); + if (responseBody == null) { + throw new MosApiException( + String.format( + "MoSAPI Service Monitoring API " + "returned an empty body with status: %d", + response.code())); + } + String bodyString = responseBody.string(); + if (!response.isSuccessful()) { + throw parseErrorResponse(response.code(), bodyString); + } + return gson.fromJson(bodyString, TldServiceState.class); + } catch (IOException | JsonParseException e) { + Throwables.throwIfInstanceOf(e, MosApiException.class); + // Catch Gson's runtime exceptions (parsing errors) and wrap them + throw new MosApiException("Failed to parse TLD service state response", e); + } + } + + /** Parses an unsuccessful MoSAPI response into a domain-specific {@link MosApiException}. */ + private MosApiException parseErrorResponse(int statusCode, String bodyString) { + try { + MosApiErrorResponse error = gson.fromJson(bodyString, MosApiErrorResponse.class); + return MosApiException.create(error); + } catch (JsonParseException e) { + return new MosApiException( + String.format("MoSAPI json parsing error (%d): %s", statusCode, bodyString), e); + } + } +} diff --git a/core/src/main/java/google/registry/mosapi/module/MosApiModule.java b/core/src/main/java/google/registry/mosapi/module/MosApiModule.java index 69f331f17b4..31ef9dd4a62 100644 --- a/core/src/main/java/google/registry/mosapi/module/MosApiModule.java +++ b/core/src/main/java/google/registry/mosapi/module/MosApiModule.java @@ -32,6 +32,8 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManagerFactory; @@ -184,4 +186,21 @@ static OkHttpClient provideMosapiHttpClient( .sslSocketFactory(sslContext.getSocketFactory(), trustManager) .build(); } + + /** + * Provides a fixed thread pool for parallel TLD processing. + * + *

Strictly bound to 4 threads to comply with MoSAPI session limits (4 concurrent sessions per + * certificate). This is used by MosApiStateService to fetch data in parallel. + * + * @see ICANN MoSAPI Specification, + * Section 12.3 + */ + @Provides + @Singleton + @Named("mosapiTldExecutor") + static ExecutorService provideMosapiTldExecutor( + @Config("mosapiTldThreadCnt") int threadPoolSize) { + return Executors.newFixedThreadPool(threadPoolSize); + } } diff --git a/core/src/main/java/google/registry/mosapi/module/MosApiRequestModule.java b/core/src/main/java/google/registry/mosapi/module/MosApiRequestModule.java new file mode 100644 index 00000000000..4c8c7db0fb4 --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/module/MosApiRequestModule.java @@ -0,0 +1,33 @@ +// Copyright 2025 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.mosapi.module; + +import static google.registry.request.RequestParameters.extractOptionalParameter; + +import dagger.Module; +import dagger.Provides; +import google.registry.request.Parameter; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +/** Dagger module for MoSAPI requests. */ +@Module +public final class MosApiRequestModule { + @Provides + @Parameter("tld") + static Optional provideTld(HttpServletRequest req) { + return extractOptionalParameter(req, "tld"); + } +} diff --git a/core/src/test/java/google/registry/module/TestRequestComponent.java b/core/src/test/java/google/registry/module/TestRequestComponent.java index 85f4e4d508f..fdd099a7191 100644 --- a/core/src/test/java/google/registry/module/TestRequestComponent.java +++ b/core/src/test/java/google/registry/module/TestRequestComponent.java @@ -28,6 +28,7 @@ import google.registry.flows.custom.CustomLogicModule; import google.registry.loadtest.LoadTestModule; import google.registry.monitoring.whitebox.WhiteboxModule; +import google.registry.mosapi.module.MosApiRequestModule; import google.registry.rdap.RdapModule; import google.registry.rde.RdeModule; import google.registry.reporting.ReportingModule; @@ -60,6 +61,7 @@ EppToolModule.class, IcannReportingModule.class, LoadTestModule.class, + MosApiRequestModule.class, RdapModule.class, RdeModule.class, ReportingModule.class, diff --git a/core/src/test/java/google/registry/mosapi/GetServiceStateActionTest.java b/core/src/test/java/google/registry/mosapi/GetServiceStateActionTest.java new file mode 100644 index 00000000000..ed3b3bfb705 --- /dev/null +++ b/core/src/test/java/google/registry/mosapi/GetServiceStateActionTest.java @@ -0,0 +1,98 @@ +// Copyright 2025 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.mosapi; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.common.net.MediaType; +import com.google.gson.Gson; +import google.registry.mosapi.MosApiModels.AllServicesStateResponse; +import google.registry.mosapi.MosApiModels.ServiceStateSummary; +import google.registry.request.HttpException.ServiceUnavailableException; +import google.registry.testing.FakeResponse; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** Unit tests for {@link GetServiceStateAction}. */ +@ExtendWith(MockitoExtension.class) +public class GetServiceStateActionTest { + + @Mock private MosApiStateService stateService; + private final FakeResponse response = new FakeResponse(); + private final Gson gson = new Gson(); + + @Test + void testRun_singleTld_returnsStateForTld() throws Exception { + GetServiceStateAction action = + new GetServiceStateAction(stateService, response, gson, Optional.of("example")); + + ServiceStateSummary summary = new ServiceStateSummary("example", "Up", null); + when(stateService.getServiceStateSummary("example")).thenReturn(summary); + + action.run(); + + assertThat(response.getContentType()).isEqualTo(MediaType.JSON_UTF_8); + assertThat(response.getPayload()) + .contains( + """ + "overallStatus":"Up" + """ + .trim()); + verify(stateService).getServiceStateSummary("example"); + } + + @Test + void testRun_noTld_returnsStateForAll() { + GetServiceStateAction action = + new GetServiceStateAction(stateService, response, gson, Optional.empty()); + + AllServicesStateResponse allStates = new AllServicesStateResponse(ImmutableList.of()); + when(stateService.getAllServiceStateSummaries()).thenReturn(allStates); + + action.run(); + + assertThat(response.getContentType()).isEqualTo(MediaType.JSON_UTF_8); + assertThat(response.getPayload()) + .contains( + """ + "serviceStates":[] + """ + .trim()); + verify(stateService).getAllServiceStateSummaries(); + } + + @Test + void testRun_serviceThrowsException_throwsServiceUnavailable() throws Exception { + GetServiceStateAction action = + new GetServiceStateAction(stateService, response, gson, Optional.of("example")); + + doThrow(new MosApiException("Backend error", null)) + .when(stateService) + .getServiceStateSummary("example"); + + ServiceUnavailableException thrown = + assertThrows(ServiceUnavailableException.class, action::run); + + assertThat(thrown).hasMessageThat().isEqualTo("Error fetching MoSAPI service state."); + } +} diff --git a/core/src/test/java/google/registry/mosapi/model/MosApiErrorResponseTest.java b/core/src/test/java/google/registry/mosapi/MosApiErrorResponseTest.java similarity index 97% rename from core/src/test/java/google/registry/mosapi/model/MosApiErrorResponseTest.java rename to core/src/test/java/google/registry/mosapi/MosApiErrorResponseTest.java index 1c0078ed0b3..e71fb5788c8 100644 --- a/core/src/test/java/google/registry/mosapi/model/MosApiErrorResponseTest.java +++ b/core/src/test/java/google/registry/mosapi/MosApiErrorResponseTest.java @@ -11,7 +11,7 @@ // 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.mosapi.model; +package google.registry.mosapi; import static com.google.common.truth.Truth.assertThat; diff --git a/core/src/test/java/google/registry/mosapi/MosApiExceptionTest.java b/core/src/test/java/google/registry/mosapi/MosApiExceptionTest.java index 38fb9d0ed3f..39d82dbd58a 100644 --- a/core/src/test/java/google/registry/mosapi/MosApiExceptionTest.java +++ b/core/src/test/java/google/registry/mosapi/MosApiExceptionTest.java @@ -20,7 +20,6 @@ import google.registry.mosapi.MosApiException.EndDateSyntaxInvalidException; import google.registry.mosapi.MosApiException.MosApiAuthorizationException; import google.registry.mosapi.MosApiException.StartDateSyntaxInvalidException; -import google.registry.mosapi.model.MosApiErrorResponse; import org.junit.jupiter.api.Test; /** Unit tests for {@link MosApiException}. */ diff --git a/core/src/test/java/google/registry/mosapi/MosApiModelsTest.java b/core/src/test/java/google/registry/mosapi/MosApiModelsTest.java new file mode 100644 index 00000000000..6b10f338cd3 --- /dev/null +++ b/core/src/test/java/google/registry/mosapi/MosApiModelsTest.java @@ -0,0 +1,172 @@ +// Copyright 2025 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.mosapi; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import google.registry.mosapi.MosApiModels.AllServicesStateResponse; +import google.registry.mosapi.MosApiModels.IncidentSummary; +import google.registry.mosapi.MosApiModels.ServiceStateSummary; +import google.registry.mosapi.MosApiModels.ServiceStatus; +import google.registry.mosapi.MosApiModels.TldServiceState; +import org.junit.Test; + +/** Tests for {@link MosApiModels}. */ +public final class MosApiModelsTest { + + private static final Gson gson = + new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); + + @Test + public void testAllServicesStateResponse_nullCollection_initializedToEmpty() { + AllServicesStateResponse response = new AllServicesStateResponse(null); + assertThat(response.serviceStates()).isEmpty(); + assertThat(response.serviceStates()).isNotNull(); + } + + @Test + public void testServiceStateSummary_nullCollection_initializedToEmpty() { + ServiceStateSummary summary = new ServiceStateSummary("example", "Up", null); + assertThat(summary.activeIncidents()).isEmpty(); + assertThat(summary.activeIncidents()).isNotNull(); + } + + @Test + public void testServiceStatus_nullCollection_initializedToEmpty() { + ServiceStatus status = new ServiceStatus("Up", 0.0, null); + assertThat(status.incidents()).isEmpty(); + assertThat(status.incidents()).isNotNull(); + } + + @Test + public void testTldServiceState_nullCollection_initializedToEmpty() { + TldServiceState state = new TldServiceState("example", 123456L, "Up", null); + assertThat(state.serviceStatuses()).isEmpty(); + assertThat(state.serviceStatuses()).isNotNull(); + } + + @Test + public void testIncidentSummary_jsonSerialization() { + IncidentSummary incident = new IncidentSummary("inc-123", 1000L, false, "Active", 2000L); + String json = gson.toJson(incident); + // Using Text Blocks to avoid escaping quotes + assertThat(json) + .contains( + """ + "incidentID":"inc-123" + """ + .trim()); + assertThat(json) + .contains( + """ + "startTime":1000 + """ + .trim()); + assertThat(json) + .contains( + """ + "falsePositive":false + """ + .trim()); + assertThat(json) + .contains( + """ + "state":"Active" + """ + .trim()); + assertThat(json) + .contains( + """ + "endTime":2000 + """ + .trim()); + } + + @Test + public void testServiceStatus_jsonSerialization() { + IncidentSummary incident = new IncidentSummary("inc-1", 1000L, false, "Resolved", null); + ServiceStatus status = new ServiceStatus("Down", 75.5, ImmutableList.of(incident)); + String json = gson.toJson(status); + assertThat(json) + .contains( + """ + "status":"Down" + """ + .trim()); + assertThat(json) + .contains( + """ + "emergencyThreshold":75.5 + """ + .trim()); + assertThat(json) + .contains( + """ + "incidents":[ + """ + .trim()); + } + + @Test + public void testTldServiceState_jsonSerialization() { + ServiceStatus dnsStatus = new ServiceStatus("Up", 0.0, ImmutableList.of()); + TldServiceState state = + new TldServiceState("app", 1700000000L, "Up", ImmutableMap.of("DNS", dnsStatus)); + + String json = gson.toJson(state); + assertThat(json) + .contains( + """ + "tld":"app" + """ + .trim()); + assertThat(json) + .contains( + """ + "status":"Up" + """ + .trim()); + assertThat(json) + .contains( + """ + "testedServices":{"DNS":{ + """ + .trim()); + } + + @Test + public void testAllServicesStateResponse_jsonSerialization() { + ServiceStateSummary summary = new ServiceStateSummary("dev", "Up", ImmutableList.of()); + AllServicesStateResponse response = new AllServicesStateResponse(ImmutableList.of(summary)); + + String json = gson.toJson(response); + assertThat(json) + .contains( + """ + "serviceStates":[ + """ + .trim()); + assertThat(json) + .contains( + """ + "tld":"dev" + """ + .trim()); + } +} diff --git a/core/src/test/java/google/registry/mosapi/MosApiStateServiceTest.java b/core/src/test/java/google/registry/mosapi/MosApiStateServiceTest.java new file mode 100644 index 00000000000..e4108a74cb7 --- /dev/null +++ b/core/src/test/java/google/registry/mosapi/MosApiStateServiceTest.java @@ -0,0 +1,130 @@ +// Copyright 2025 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.mosapi; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.util.concurrent.MoreExecutors; +import google.registry.mosapi.MosApiModels.AllServicesStateResponse; +import google.registry.mosapi.MosApiModels.IncidentSummary; +import google.registry.mosapi.MosApiModels.ServiceStateSummary; +import google.registry.mosapi.MosApiModels.ServiceStatus; +import google.registry.mosapi.MosApiModels.TldServiceState; +import java.util.concurrent.ExecutorService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** Unit tests for {@link MosApiStateService}. */ +@ExtendWith(MockitoExtension.class) +class MosApiStateServiceTest { + + @Mock private ServiceMonitoringClient client; + + private final ExecutorService executor = MoreExecutors.newDirectExecutorService(); + + private MosApiStateService service; + + @BeforeEach + void setUp() { + service = new MosApiStateService(client, ImmutableSet.of("tld1", "tld2"), executor); + } + + @Test + void testGetServiceStateSummary_upStatus_returnsEmptyIncidents() throws Exception { + TldServiceState rawState = new TldServiceState("tld1", 12345L, "Up", ImmutableMap.of()); + when(client.getTldServiceState("tld1")).thenReturn(rawState); + + ServiceStateSummary result = service.getServiceStateSummary("tld1"); + + assertThat(result.tld()).isEqualTo("tld1"); + assertThat(result.overallStatus()).isEqualTo("Up"); + assertThat(result.activeIncidents()).isEmpty(); + } + + @Test + void testGetServiceStateSummary_downStatus_filtersActiveIncidents() throws Exception { + IncidentSummary dnsIncident = new IncidentSummary("inc-1", 100L, false, "Open", null); + ServiceStatus dnsService = new ServiceStatus("Down", 50.0, ImmutableList.of(dnsIncident)); + + ServiceStatus rdapService = new ServiceStatus("Up", 0.0, ImmutableList.of()); + + TldServiceState rawState = + new TldServiceState( + "tld1", 12345L, "Down", ImmutableMap.of("DNS", dnsService, "RDAP", rdapService)); + + when(client.getTldServiceState("tld1")).thenReturn(rawState); + + ServiceStateSummary result = service.getServiceStateSummary("tld1"); + + assertThat(result.overallStatus()).isEqualTo("Down"); + assertThat(result.activeIncidents()).hasSize(1); + + ServiceStatus incidentSummary = result.activeIncidents().get(0); + assertThat(incidentSummary.status()).isEqualTo("DNS"); + assertThat(incidentSummary.incidents()).containsExactly(dnsIncident); + } + + @Test + void testGetServiceStateSummary_throwsException_whenClientFails() throws Exception { + when(client.getTldServiceState("tld1")).thenThrow(new MosApiException("Network error", null)); + + assertThrows(MosApiException.class, () -> service.getServiceStateSummary("tld1")); + } + + @Test + void testGetAllServiceStateSummaries_success() throws Exception { + TldServiceState state1 = new TldServiceState("tld1", 1L, "Up", ImmutableMap.of()); + TldServiceState state2 = new TldServiceState("tld2", 2L, "Up", ImmutableMap.of()); + + when(client.getTldServiceState("tld1")).thenReturn(state1); + when(client.getTldServiceState("tld2")).thenReturn(state2); + + AllServicesStateResponse response = service.getAllServiceStateSummaries(); + + assertThat(response.serviceStates()).hasSize(2); + assertThat(response.serviceStates().stream().map(ServiceStateSummary::tld)) + .containsExactly("tld1", "tld2"); + } + + @Test + void testGetAllServiceStateSummaries_partialFailure_returnsErrorState() throws Exception { + TldServiceState state1 = new TldServiceState("tld1", 1L, "Up", ImmutableMap.of()); + when(client.getTldServiceState("tld1")).thenReturn(state1); + + when(client.getTldServiceState("tld2")).thenThrow(new MosApiException("Failure", null)); + + AllServicesStateResponse response = service.getAllServiceStateSummaries(); + + assertThat(response.serviceStates()).hasSize(2); + + ServiceStateSummary summary1 = + response.serviceStates().stream().filter(s -> s.tld().equals("tld1")).findFirst().get(); + assertThat(summary1.overallStatus()).isEqualTo("Up"); + + ServiceStateSummary summary2 = + response.serviceStates().stream().filter(s -> s.tld().equals("tld2")).findFirst().get(); + + assertThat(summary2.overallStatus()).isEqualTo("ERROR"); + assertThat(summary2.activeIncidents()).isEmpty(); + } +} diff --git a/core/src/test/java/google/registry/mosapi/ServiceMonitoringClientTest.java b/core/src/test/java/google/registry/mosapi/ServiceMonitoringClientTest.java new file mode 100644 index 00000000000..ba01a3a17cc --- /dev/null +++ b/core/src/test/java/google/registry/mosapi/ServiceMonitoringClientTest.java @@ -0,0 +1,140 @@ +// Copyright 2025 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.mosapi; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.gson.Gson; +import google.registry.mosapi.MosApiModels.TldServiceState; +import google.registry.tools.GsonUtils; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class ServiceMonitoringClientTest { + + private static final String TLD = "example"; + private static final String ENDPOINT = "v2/monitoring/state"; + private final MosApiClient mosApiClient = mock(MosApiClient.class); + private final Gson gson = GsonUtils.provideGson(); + private ServiceMonitoringClient client; + + @BeforeEach + void beforeEach() { + client = new ServiceMonitoringClient(mosApiClient, gson); + } + + @Test + void testGetTldServiceState_success() throws Exception { + String jsonResponse = + """ + { + "tld": "example", + "services": [ + { + "service": "DNS", + "status": "OPERATIONAL" + } + ] + } + """; + + try (Response response = createMockResponse(200, jsonResponse)) { + when(mosApiClient.sendGetRequest(eq(TLD), eq(ENDPOINT), anyMap(), anyMap())) + .thenReturn(response); + + TldServiceState result = client.getTldServiceState(TLD); + assertThat(gson.toJson(result)).contains("example"); + } + } + + @Test + void testGetTldServiceState_apiError_throwsMosApiException() throws Exception { + String errorJson = + """ + { + "resultCode": "2011", + "message": "Invalid duration" + } + """; + + try (Response response = createMockResponse(400, errorJson)) { + when(mosApiClient.sendGetRequest(eq(TLD), eq(ENDPOINT), anyMap(), anyMap())) + .thenReturn(response); + + MosApiException thrown = + assertThrows(MosApiException.class, () -> client.getTldServiceState(TLD)); + assertThat(thrown.getMessage()).contains("2011"); + assertThat(thrown.getMessage()).contains("Invalid duration"); + } + } + + @Test + void testGetTldServiceState_nonJsonError_throwsMosApiException() throws Exception { + String htmlError = + """ + + 502 Bad Gateway + + """; + + try (Response response = createMockResponse(502, htmlError)) { + when(mosApiClient.sendGetRequest(eq(TLD), eq(ENDPOINT), anyMap(), anyMap())) + .thenReturn(response); + + MosApiException thrown = + assertThrows(MosApiException.class, () -> client.getTldServiceState(TLD)); + assertThat(thrown.getMessage()).contains("MoSAPI json parsing error (502)"); + assertThat(thrown.getMessage()).contains("502 Bad Gateway"); + } + } + + @Test + void testGetTldServiceState_emptyBody_throwsMosApiException() throws Exception { + Response response = + new Response.Builder() + .request(new Request.Builder().url("http://localhost").build()) + .protocol(Protocol.HTTP_1_1) + .code(204) + .message("No Content") + .build(); + + when(mosApiClient.sendGetRequest(eq(TLD), eq(ENDPOINT), anyMap(), anyMap())) + .thenReturn(response); + + MosApiException thrown = + assertThrows(MosApiException.class, () -> client.getTldServiceState(TLD)); + assertThat(thrown.getMessage()).contains("returned an empty body"); + } + + private Response createMockResponse(int code, String body) { + return new Response.Builder() + .request(new Request.Builder().url("http://localhost").build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message(code == 200 ? "OK" : "Error") + .body(ResponseBody.create(body, MediaType.parse("application/json"))) + .build(); + } +} diff --git a/core/src/test/java/google/registry/mosapi/module/MosApiRequestModuleTest.java b/core/src/test/java/google/registry/mosapi/module/MosApiRequestModuleTest.java new file mode 100644 index 00000000000..a8f59ac097a --- /dev/null +++ b/core/src/test/java/google/registry/mosapi/module/MosApiRequestModuleTest.java @@ -0,0 +1,57 @@ +// Copyright 2025 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.mosapi.module; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link MosApiRequestModule}. */ +public class MosApiRequestModuleTest { + + @Test + void testProvideTld_paramPresent() { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getParameter("tld")).thenReturn("example.tld"); + + Optional result = MosApiRequestModule.provideTld(req); + + assertThat(result).hasValue("example.tld"); + } + + @Test + void testProvideTld_paramMissing() { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getParameter("tld")).thenReturn(null); + + Optional result = MosApiRequestModule.provideTld(req); + + assertThat(result).isEmpty(); + } + + @Test + void testProvideTld_paramEmptyString() { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getParameter("tld")).thenReturn(""); + + Optional result = MosApiRequestModule.provideTld(req); + + assertThat(result).isEmpty(); + } +} diff --git a/core/src/test/resources/google/registry/module/routing.txt b/core/src/test/resources/google/registry/module/routing.txt index 10048de4b26..db704bf75a9 100644 --- a/core/src/test/resources/google/registry/module/routing.txt +++ b/core/src/test/resources/google/registry/module/routing.txt @@ -13,6 +13,7 @@ BACKEND /_dr/admin/verifyOte VerifyOteAction BACKEND /_dr/cron/fanout TldFanoutAction GET y APP ADMIN BACKEND /_dr/epptool EppToolAction POST n APP ADMIN BACKEND /_dr/loadtest LoadTestAction POST y APP ADMIN +BACKEND /_dr/mosapi/getServiceState GetServiceStateAction GET n APP ADMIN BACKEND /_dr/task/brdaCopy BrdaCopyAction POST y APP ADMIN BACKEND /_dr/task/bsaDownload BsaDownloadAction GET,POST n APP ADMIN BACKEND /_dr/task/bsaRefresh BsaRefreshAction GET,POST n APP ADMIN