1
0
mirror of https://github.com/google/nomulus synced 2026-04-23 01:30:51 +00:00

Add GetServiceState action for MoSAPI service monitoring (#2906)

* Add GetServiceState action for MoSAPI service monitoring

Implements the `/api/mosapi/getServiceState` endpoint to retrieve service health summaries for TLDs from the MoSAPI system.

- Introduces `GetServiceStateAction` to fetch TLD service status.
- Implements `MosApiStateService` to transform raw MoSAPI responses into a curated `ServiceStateSummary`.
- Uses concurrent processing with a fixed thread pool to fetch states for all configured TLDs efficiently while respecting MoSAPI rate limits.

junit test added

* Refactor MoSAPI models to records and address review nits

- Convert model classes to Java records for conciseness and immutability.
- Update unit tests to use Java text blocks for improved JSON readability.
- Simplify service and action layers by removing redundant logic and logging.
- Fix configuration nits regarding primitive types and comment formatting.

* Consolidate MoSAPI models and enhance null-safety

- Moves model records into a single MosApiModels.java file.
- Switches to ImmutableList/ImmutableMap with non-null defaults in constructors.
- Removes redundant pass-through methods in MosApiStateService.
- Updates tests to use Java Text Blocks and non-null collection assertions.

* Improve MoSAPI client error handling and clean up data models

Refactors the MoSAPI monitoring client to be more robust against
infrastructure failures

* Refactor: use nullToEmptyImmutableCopy() for MoSAPI models

Standardize null-handling in model classes by using the Nomulus
`nullToEmptyImmutableCopy()` utility. This ensures consistent API
responses with empty lists instead of omitted fields.
This commit is contained in:
Nilay Shah
2026-01-05 21:14:01 +05:30
committed by GitHub
parent 7e9d4c27d1
commit 81d222e7d6
21 changed files with 1059 additions and 5 deletions

View File

@@ -1462,6 +1462,12 @@ public final class RegistryConfig {
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)

View File

@@ -272,5 +272,6 @@ public class RegistryConfigSettings {
public String entityType;
public List<String> tlds;
public List<String> services;
public int tldThreadCnt;
}
}

View File

@@ -642,4 +642,8 @@ mosapi:
- "epp"
- "dnssec"
# Provides a fixed thread pool for parallel TLD processing.
# @see <a href="https://www.icann.org/mosapi-specification.pdf">
# ICANN MoSAPI Specification, Section 12.3</a>
tldThreadCnt: 4

View File

@@ -62,6 +62,8 @@ import google.registry.module.ReadinessProbeAction.ReadinessProbeActionFrontend;
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 @@ import google.registry.ui.server.console.settings.SecurityAction;
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();

View File

@@ -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<String> tld;
@Inject
public GetServiceStateAction(
MosApiStateService stateService,
Response response,
Gson gson,
@Parameter(TLD_PARAM) Optional<String> 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.");
}
}
}

View File

@@ -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 @@ package google.registry.mosapi.model;
* @see <a href="https://www.icann.org/mosapi-specification.pdf">ICANN MoSAPI Specification, Section
* 8</a>
*/
public record MosApiErrorResponse(String resultCode, String message, String description) {}
public record MosApiErrorResponse(
@Expose String resultCode, @Expose String message, @Expose String description) {}

View File

@@ -17,7 +17,6 @@ package google.registry.mosapi;
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 class MosApiException extends IOException {
this.errorResponse = null;
}
public MosApiException(String message) {
super(message);
this.errorResponse = null;
}
public Optional<MosApiErrorResponse> getErrorResponse() {
return Optional.ofNullable(errorResponse);
}

View File

@@ -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.
*
* <p>This corresponds to the collection of service statuses returned when monitoring the state of
* a TLD
*
* @see <a href="https://www.icann.org/mosapi-specification.pdf">ICANN MoSAPI Specification,
* Section 5.1</a>
*/
public record AllServicesStateResponse(
// A list of state summaries for each monitored service (e.g. DNS, RDDS, etc.)
@Expose List<ServiceStateSummary> serviceStates) {
public AllServicesStateResponse {
serviceStates = nullToEmptyImmutableCopy(serviceStates);
}
}
/**
* A summary of a service incident.
*
* @see <a href="https://www.icann.org/mosapi-specification.pdf">ICANN MoSAPI Specification,
* Section 5.1</a>
*/
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.
*
* <p>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 <a href="https://www.icann.org/mosapi-specification.pdf">ICANN MoSAPI Specification,
* Section 5.1</a>
*/
public record ServiceStateSummary(
@Expose String tld,
@Expose String overallStatus,
@Expose List<ServiceStatus> 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<IncidentSummary> incidents) {
public ServiceStatus {
incidents = nullToEmptyImmutableCopy(incidents);
}
}
/**
* Represents the overall health of all monitored services for a TLD.
*
* @see <a href="https://www.icann.org/mosapi-specification.pdf">ICANN MoSAPI Specification,
* Section 5.1</a>
*/
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<String, ServiceStatus> serviceStatuses) {
public TldServiceState {
serviceStatuses = nullToEmptyImmutableCopy(serviceStatuses);
}
}
}

View File

@@ -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<String> tlds;
private static final String DOWN_STATUS = "Down";
private static final String FETCH_ERROR_STATUS = "ERROR";
@Inject
public MosApiStateService(
ServiceMonitoringClient serviceMonitoringClient,
@Config("mosapiTlds") ImmutableSet<String> 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<CompletableFuture<ServiceStateSummary>> 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<ServiceStateSummary> summaries =
futures.stream()
.map(CompletableFuture::join) // Waits for all tasks to complete
.collect(toImmutableList());
return new AllServicesStateResponse(summaries);
}
private ServiceStateSummary transformToSummary(TldServiceState rawState) {
ImmutableList<ServiceStatus> 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);
}
}

View File

@@ -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 <a href="https://www.icann.org/mosapi-specification.pdf">ICANN MoSAPI Specification,
* Section 5.1</a>
*/
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);
}
}
}

View File

@@ -32,6 +32,8 @@ import java.security.cert.Certificate;
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 @@ public final class MosApiModule {
.sslSocketFactory(sslContext.getSocketFactory(), trustManager)
.build();
}
/**
* Provides a fixed thread pool for parallel TLD processing.
*
* <p>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 <a href="https://www.icann.org/mosapi-specification.pdf">ICANN MoSAPI Specification,
* Section 12.3</a>
*/
@Provides
@Singleton
@Named("mosapiTldExecutor")
static ExecutorService provideMosapiTldExecutor(
@Config("mosapiTldThreadCnt") int threadPoolSize) {
return Executors.newFixedThreadPool(threadPoolSize);
}
}

View File

@@ -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<String> provideTld(HttpServletRequest req) {
return extractOptionalParameter(req, "tld");
}
}

View File

@@ -28,6 +28,7 @@ import google.registry.flows.TlsCredentials.EppTlsModule;
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 @@ import google.registry.ui.server.console.ConsoleModule;
EppToolModule.class,
IcannReportingModule.class,
LoadTestModule.class,
MosApiRequestModule.class,
RdapModule.class,
RdeModule.class,
ReportingModule.class,

View File

@@ -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.");
}
}

View File

@@ -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;

View File

@@ -20,7 +20,6 @@ import google.registry.mosapi.MosApiException.DateOrderInvalidException;
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}. */

View File

@@ -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());
}
}

View File

@@ -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();
}
}

View File

@@ -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 =
"""
<html>
<body>502 Bad Gateway</body>
</html>
""";
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();
}
}

View File

@@ -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<String> 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<String> result = MosApiRequestModule.provideTld(req);
assertThat(result).isEmpty();
}
@Test
void testProvideTld_paramEmptyString() {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getParameter("tld")).thenReturn("");
Optional<String> result = MosApiRequestModule.provideTld(req);
assertThat(result).isEmpty();
}
}

View File

@@ -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