1
0
mirror of https://github.com/google/nomulus synced 2026-04-24 02:00:50 +00:00

Add mosapi client to intract with ICANN's monitoring system (#2892)

* Add mosapi client to intract with ICANN's monitoring system

This change introduces a comprehensive client to interact with ICANN's Monitoring System API (MoSAPI). This provides direct, automated access to critical registry health and compliance data, moving Nomulus towards a more proactive monitoring posture.

A core, stateless MosApiClient that manages communication and authentication with the MoSAPI service using TLS client certificates.

* Resolve review feedback & upgrade to OkHttp3 client

This commit addresses and resolves all outstanding review comments, primarily encompassing a shift to OkHttp3, security configuration cleanup, and general code improvements.

* **Review:** Addressed and resolved all pending review comments.
* **Feature:** Switched the underlying HTTP client implementation to [OkHttp3](https://square.github.io/okhttp/).
* **Configuration:** Consolidated TLS Certificates-related configuration into the dedicated configuration area.
* **Cleanup:** Removed unused components (`HttpUtils` and `HttpModule`) and performed general code cleanup.
* **Quality:** Improved exception handling logic for better robustness.

* Refactor and fix Mosapi exception handling

Addresses code review feedback and resulting test failures.

- Flattens package structure by moving MosApiException and its test.
- Corrects exception handling to ensure MosApiAuthorizationException
  propagates correctly, before the general exception handler.
- Adds a default case to the MosApiException factory for robustness.
- Uses lowercase for placeholder TLDs in default-config.yaml.

* Refactor and improve Mosapi client implementation

Simplifying URL validation with Guava
Preconditions and refining exception handling to use `Throwables`.

* Refactor precondition checks using project specific utility
This commit is contained in:
Nilay Shah
2025-12-09 21:59:05 +05:30
committed by GitHub
parent 28e72bd0d0
commit d98d65eee5
16 changed files with 1180 additions and 0 deletions

View File

@@ -34,6 +34,7 @@ import google.registry.keyring.KeyringModule;
import google.registry.keyring.api.KeyModule;
import google.registry.module.TestRequestComponent.TestRequestComponentModule;
import google.registry.monitoring.whitebox.StackdriverModule;
import google.registry.mosapi.module.MosApiModule;
import google.registry.persistence.PersistenceModule;
import google.registry.privileges.secretmanager.SecretManagerModule;
import google.registry.rde.JSchModule;
@@ -61,6 +62,7 @@ import jakarta.inject.Singleton;
GroupsModule.class,
GroupssettingsModule.class,
GsonModule.class,
MosApiModule.class,
JSchModule.class,
KeyModule.class,
KeyringModule.class,

View File

@@ -0,0 +1,203 @@
// 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.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableMap;
import google.registry.mosapi.MosApiException.MosApiAuthorizationException;
import java.io.IOException;
import java.util.Map;
import okhttp3.Call;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.Buffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
public class MosApiClientTest {
private static final String SERVICE_URL = "https://mosapi.example.com/v1";
private static final String ENTITY_TYPE = "registries";
// Mocks
private OkHttpClient mockHttpClient;
private Call mockCall;
private MosApiClient mosApiClient;
@BeforeEach
void setUp() {
mockHttpClient = mock(OkHttpClient.class);
mockCall = mock(Call.class);
when(mockHttpClient.newCall(any(Request.class))).thenReturn(mockCall);
mosApiClient = new MosApiClient(mockHttpClient, SERVICE_URL, ENTITY_TYPE);
}
@Test
void testConstructor_throwsOnInvalidUrl() {
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> new MosApiClient(mockHttpClient, "ht tp://bad-url", ENTITY_TYPE));
assertThat(thrown).hasMessageThat().contains("Invalid MoSAPI Service URL");
}
// --- GET Request Tests ---
@Test
void testSendGetRequest_success() throws Exception {
// 1. Prepare Success Response
Response successResponse = createResponse(200, "{\"status\":\"ok\"}");
when(mockCall.execute()).thenReturn(successResponse);
Map<String, String> params = ImmutableMap.of("since", "2024-01-01");
Map<String, String> headers = ImmutableMap.of("Authorization", "Bearer token123");
// 2. Execute
try (Response response =
mosApiClient.sendGetRequest("tld-1", "monitoring/state", params, headers)) {
// Verify Response
assertThat(response.isSuccessful()).isTrue();
assertThat(response.body().string()).isEqualTo("{\"status\":\"ok\"}");
// 3. Verify Request Construction
ArgumentCaptor<Request> requestCaptor = ArgumentCaptor.forClass(Request.class);
verify(mockHttpClient).newCall(requestCaptor.capture());
Request capturedRequest = requestCaptor.getValue();
// Check URL:
assertThat(capturedRequest.method()).isEqualTo("GET");
assertThat(capturedRequest.url().encodedPath())
.isEqualTo("/v1/registries/tld-1/monitoring/state");
assertThat(capturedRequest.url().queryParameter("since")).isEqualTo("2024-01-01");
// Check Headers
assertThat(capturedRequest.header("Authorization")).isEqualTo("Bearer token123");
}
}
@Test
void testSendGetRequest_throwsOn401() throws IOException {
// Prepare 401 Response
Response unauthorizedResponse = createResponse(401, "Unauthorized");
when(mockCall.execute()).thenReturn(unauthorizedResponse);
MosApiAuthorizationException thrown =
assertThrows(
MosApiAuthorizationException.class,
() ->
mosApiClient.sendGetRequest("tld-1", "path", ImmutableMap.of(), ImmutableMap.of()));
assertThat(thrown).hasMessageThat().contains("Authorization failed");
}
@Test
void testSendGetRequest_wrapsIoException() throws IOException {
// Simulate Network Failure
when(mockCall.execute()).thenThrow(new IOException("Network error"));
// Execute & Assert
MosApiException thrown =
assertThrows(
MosApiException.class,
() ->
mosApiClient.sendGetRequest("tld-1", "path", ImmutableMap.of(), ImmutableMap.of()));
assertThat(thrown).hasMessageThat().contains("Error during GET request to");
assertThat(thrown).hasCauseThat().isInstanceOf(IOException.class);
}
// --- POST Request Tests ---
@Test
void testSendPostRequest_success() throws Exception {
// 1. Prepare Response
Response successResponse = createResponse(200, "{\"updated\":true}");
when(mockCall.execute()).thenReturn(successResponse);
String requestBody = "{\"data\":\"update\"}";
Map<String, String> headers = ImmutableMap.of("Content-Type", "application/json");
try (Response response =
mosApiClient.sendPostRequest("tld-1", "update", null, headers, requestBody)) {
assertThat(response.isSuccessful()).isTrue();
ArgumentCaptor<Request> requestCaptor = ArgumentCaptor.forClass(Request.class);
verify(mockHttpClient).newCall(requestCaptor.capture());
Request capturedRequest = requestCaptor.getValue();
assertThat(capturedRequest.method()).isEqualTo("POST");
assertThat(capturedRequest.url().encodedPath()).isEqualTo("/v1/registries/tld-1/update");
// Verify Body content
Buffer buffer = new Buffer();
capturedRequest.body().writeTo(buffer);
assertThat(buffer.readUtf8()).isEqualTo(requestBody);
}
}
@Test
void testSendPostRequest_throwsOn401() throws IOException {
// Prepare 401 Response
Response unauthorizedResponse = createResponse(401, "Unauthorized");
when(mockCall.execute()).thenReturn(unauthorizedResponse);
MosApiAuthorizationException thrown =
assertThrows(
MosApiAuthorizationException.class,
() ->
mosApiClient.sendPostRequest(
"tld-1", "path", ImmutableMap.of(), ImmutableMap.of(), "{}"));
assertThat(thrown).hasMessageThat().contains("Authorization failed");
}
@Test
void testSendPostRequest_wrapsIoException() throws IOException {
// Simulate Network Failure
when(mockCall.execute()).thenThrow(new IOException("Network error"));
MosApiException thrown =
assertThrows(
MosApiException.class,
() ->
mosApiClient.sendPostRequest(
"tld-1", "path", ImmutableMap.of(), ImmutableMap.of(), "{}"));
assertThat(thrown).hasMessageThat().contains("Error during POST request to");
assertThat(thrown).hasCauseThat().isInstanceOf(IOException.class);
}
/** Helper to build a real OkHttp Response object manually. */
private Response createResponse(int code, String bodyContent) {
return new Response.Builder()
.request(new Request.Builder().url("http://localhost/").build())
.protocol(Protocol.HTTP_1_1)
.code(code)
.message("Msg")
.body(ResponseBody.create(bodyContent, MediaType.parse("application/json")))
.build();
}
}

View File

@@ -0,0 +1,100 @@
// 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 google.registry.mosapi.MosApiException.DateDurationInvalidException;
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}. */
public class MosApiExceptionTest {
@Test
void testConstructor_withErrorResponse() {
MosApiErrorResponse errorResponse =
new MosApiErrorResponse("1234", "Test Message", "Test Description");
MosApiException exception = new MosApiException(errorResponse);
assertThat(exception)
.hasMessageThat()
.isEqualTo("MoSAPI returned an error (code: 1234): Test Message");
assertThat(exception.getErrorResponse()).hasValue(errorResponse);
}
@Test
void testConstructor_withMessageAndCause() {
RuntimeException cause = new RuntimeException("Root Cause");
MosApiException exception = new MosApiException("Wrapper Message", cause);
assertThat(exception).hasMessageThat().isEqualTo("Wrapper Message");
assertThat(exception).hasCauseThat().isEqualTo(cause);
assertThat(exception.getErrorResponse()).isEmpty();
}
@Test
void testAuthorizationException() {
MosApiAuthorizationException exception = new MosApiAuthorizationException("Unauthorized");
assertThat(exception).isInstanceOf(MosApiException.class);
assertThat(exception).hasMessageThat().isEqualTo("Unauthorized");
assertThat(exception.getErrorResponse()).isEmpty();
}
@Test
void testCreate_forDateDurationInvalid() {
MosApiErrorResponse errorResponse =
new MosApiErrorResponse("2011", "Duration invalid", "Description");
MosApiException exception = MosApiException.create(errorResponse);
assertThat(exception).isInstanceOf(DateDurationInvalidException.class);
assertThat(exception.getErrorResponse()).hasValue(errorResponse);
}
@Test
void testCreate_forDateOrderInvalid() {
MosApiErrorResponse errorResponse =
new MosApiErrorResponse("2012", "End date before start date", "Description");
MosApiException exception = MosApiException.create(errorResponse);
assertThat(exception).isInstanceOf(DateOrderInvalidException.class);
assertThat(exception.getErrorResponse()).hasValue(errorResponse);
}
@Test
void testCreate_forStartDateSyntaxInvalid() {
MosApiErrorResponse errorResponse =
new MosApiErrorResponse("2013", "Invalid start date format", "Description");
MosApiException exception = MosApiException.create(errorResponse);
assertThat(exception).isInstanceOf(StartDateSyntaxInvalidException.class);
assertThat(exception.getErrorResponse()).hasValue(errorResponse);
}
@Test
void testCreate_forEndDateSyntaxInvalid() {
MosApiErrorResponse errorResponse =
new MosApiErrorResponse("2014", "Invalid end date format", "Description");
MosApiException exception = MosApiException.create(errorResponse);
assertThat(exception).isInstanceOf(EndDateSyntaxInvalidException.class);
assertThat(exception.getErrorResponse()).hasValue(errorResponse);
}
@Test
void testCreate_forUnknownCode() {
MosApiErrorResponse errorResponse = new MosApiErrorResponse("9999", "Unknown", "Description");
MosApiException exception = MosApiException.create(errorResponse);
assertThat(exception.getClass()).isEqualTo(MosApiException.class);
assertThat(exception.getErrorResponse()).hasValue(errorResponse);
}
}

View File

@@ -0,0 +1,41 @@
// 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.model;
import static com.google.common.truth.Truth.assertThat;
import com.google.gson.Gson;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link MosApiErrorResponse}. */
public class MosApiErrorResponseTest {
@Test
void testJsonDeserialization() {
String json =
"""
{
"resultCode": "2012",
"message": "The endDate is before the startDate.",
"description": "Validation failed"
}
""";
MosApiErrorResponse response = new Gson().fromJson(json, MosApiErrorResponse.class);
assertThat(response.resultCode()).isEqualTo("2012");
assertThat(response.message()).isEqualTo("The endDate is before the startDate.");
assertThat(response.description()).isEqualTo("Validation failed");
}
}

View File

@@ -0,0 +1,190 @@
// 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.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import google.registry.privileges.secretmanager.SecretManagerClient;
import java.io.StringWriter;
import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Optional;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.X509TrustManager;
import okhttp3.OkHttpClient;
import org.bouncycastle.asn1.ASN1GeneralizedTime;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.asn1.x509.Time;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class MosApiModuleTest {
private static final String TEST_CERT_SECRET_NAME = "testCert";
private static final String TEST_KEY_SECRET_NAME = "testKey";
private SecretManagerClient secretManagerClient;
private String validCertPem;
private String validKeyPem;
private PrivateKey generatedPrivateKey;
private X509Certificate generatedCertificate;
@BeforeAll
static void setupStatics() {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
@BeforeEach
void setUp() throws Exception {
secretManagerClient = mock(SecretManagerClient.class);
generateTestCredentials();
}
@Test
void testProvideMosapiTlsCert_fetchesFromConfiguredSecretName() {
when(secretManagerClient.getSecretData(any(), any())).thenReturn(validCertPem);
String result = MosApiModule.provideMosapiTlsCert(secretManagerClient, TEST_CERT_SECRET_NAME);
assertThat(result).isEqualTo(validCertPem);
verify(secretManagerClient).getSecretData(eq(TEST_CERT_SECRET_NAME), eq(Optional.of("latest")));
}
@Test
void testProvideMosapiTlsKey_fetchesFromConfiguredSecretName() {
when(secretManagerClient.getSecretData(any(), any())).thenReturn(validKeyPem);
String result = MosApiModule.provideMosapiTlsKey(secretManagerClient, TEST_KEY_SECRET_NAME);
assertThat(result).isEqualTo(validKeyPem);
verify(secretManagerClient).getSecretData(eq(TEST_KEY_SECRET_NAME), eq(Optional.of("latest")));
}
@Test
void testProvideCertificate_parsesValidPem() {
Certificate cert = MosApiModule.provideCertificate(validCertPem);
assertThat(cert).isInstanceOf(X509Certificate.class);
// Verify the public key matches to ensure we parsed the correct cert
assertThat(cert.getPublicKey()).isEqualTo(generatedCertificate.getPublicKey());
}
@Test
void testProvideCertificate_throwsOnInvalidPem() {
RuntimeException thrown =
assertThrows(
RuntimeException.class, () -> MosApiModule.provideCertificate("NOT A REAL CERT"));
assertThat(thrown).hasMessageThat().contains("Could not create X.509 certificate");
}
@Test
void testProvidePrivateKey_parsesValidPem() {
PrivateKey key = MosApiModule.providePrivateKey(validKeyPem);
assertThat(key).isNotNull();
assertThat(key.getAlgorithm()).isEqualTo("RSA");
assertThat(key.getEncoded()).isEqualTo(generatedPrivateKey.getEncoded());
}
@Test
void testProvidePrivateKey_throwsOnInvalidPem() {
RuntimeException thrown =
assertThrows(
RuntimeException.class, () -> MosApiModule.providePrivateKey("NOT A REAL KEY"));
assertThat(thrown).hasMessageThat().contains("Could not parse TLS private key");
}
@Test
void testProvideKeyStore_createsWithCorrectAlias() throws Exception {
KeyStore keyStore = MosApiModule.provideKeyStore(generatedPrivateKey, generatedCertificate);
assertThat(keyStore).isNotNull();
assertThat(keyStore.getType()).isEqualTo("PKCS12");
assertThat(keyStore.containsAlias("client")).isTrue();
assertThat(keyStore.getCertificate("client")).isEqualTo(generatedCertificate);
}
@Test
void testProvideMosapiHttpClient_usesConfiguredSslContext() {
SSLContext mockSslContext = mock(SSLContext.class);
SSLSocketFactory mockSocketFactory = mock(SSLSocketFactory.class);
X509TrustManager mockTrustManager = mock(X509TrustManager.class);
when(mockTrustManager.getAcceptedIssuers()).thenReturn(new X509Certificate[0]);
when(mockSslContext.getSocketFactory()).thenReturn(mockSocketFactory);
OkHttpClient client = MosApiModule.provideMosapiHttpClient(mockSslContext, mockTrustManager);
assertThat(client).isNotNull();
assertThat(client.sslSocketFactory()).isEqualTo(mockSocketFactory);
}
private void generateTestCredentials() throws Exception {
// 1. Generate KeyPair
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
KeyPair keyPair = keyGen.generateKeyPair();
this.generatedPrivateKey = keyPair.getPrivate();
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss'Z'").withZone(ZoneId.of("UTC"));
Instant now = Instant.now();
Instant end = now.plus(Duration.ofDays(365));
// Convert string to Bouncy Castle Time objects
Time notBefore = new Time(new ASN1GeneralizedTime(formatter.format(now)));
Time notAfter = new Time(new ASN1GeneralizedTime(formatter.format(end)));
X509v3CertificateBuilder certBuilder =
new X509v3CertificateBuilder(
new X500Name("CN=Test"),
BigInteger.valueOf(now.toEpochMilli()),
notBefore,
notAfter,
new X500Name("CN=Test"),
SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded()));
ContentSigner contentSigner =
new JcaContentSignerBuilder("SHA256WithRSAEncryption").build(keyPair.getPrivate());
this.generatedCertificate =
new JcaX509CertificateConverter()
.setProvider("BC")
.getCertificate(certBuilder.build(contentSigner));
// 4. Convert to PEM Strings
this.validCertPem = toPem(generatedCertificate);
this.validKeyPem = toPem(generatedPrivateKey);
}
private String toPem(Object object) throws Exception {
StringWriter stringWriter = new StringWriter();
try (JcaPEMWriter pemWriter = new JcaPEMWriter(stringWriter)) {
pemWriter.writeObject(object);
}
return stringWriter.toString();
}
}