mirror of
https://github.com/google/nomulus
synced 2025-12-23 06:15:42 +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:
@@ -36,6 +36,7 @@ import dagger.Provides;
|
||||
import google.registry.bsa.UploadBsaUnavailableDomainsAction;
|
||||
import google.registry.dns.ReadDnsRefreshRequestsAction;
|
||||
import google.registry.model.common.DnsRefreshRequest;
|
||||
import google.registry.mosapi.MosApiClient;
|
||||
import google.registry.persistence.transaction.JpaTransactionManager;
|
||||
import google.registry.request.Action.Service;
|
||||
import google.registry.util.RegistryEnvironment;
|
||||
@@ -1415,6 +1416,52 @@ public final class RegistryConfig {
|
||||
return config.bsa.uploadUnavailableDomainsUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL we send HTTP requests for MoSAPI.
|
||||
*
|
||||
* @see MosApiClient
|
||||
*/
|
||||
@Provides
|
||||
@Config("mosapiServiceUrl")
|
||||
public static String provideMosapiServiceUrl(RegistryConfigSettings config) {
|
||||
return config.mosapi.serviceUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the entityType we send HTTP requests for MoSAPI.
|
||||
*
|
||||
* @see MosApiClient
|
||||
*/
|
||||
@Provides
|
||||
@Config("mosapiEntityType")
|
||||
public static String provideMosapiEntityType(RegistryConfigSettings config) {
|
||||
return config.mosapi.entityType;
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Config("mosapiTlsCertSecretName")
|
||||
public static String provideMosapiTlsCertSecretName(RegistryConfigSettings config) {
|
||||
return config.mosapi.tlsCertSecretName;
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Config("mosapiTlsCertKeyName")
|
||||
public static String provideMosapiTlsKeySecretName(RegistryConfigSettings config) {
|
||||
return config.mosapi.tlsKeySecretName;
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Config("mosapiTlds")
|
||||
public static ImmutableSet<String> provideMosapiTlds(RegistryConfigSettings config) {
|
||||
return ImmutableSet.copyOf(config.mosapi.tlds);
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Config("mosapiServices")
|
||||
public static ImmutableSet<String> provideMosapiServices(RegistryConfigSettings config) {
|
||||
return ImmutableSet.copyOf(config.mosapi.services);
|
||||
}
|
||||
|
||||
private static String formatComments(String text) {
|
||||
return Splitter.on('\n').omitEmptyStrings().trimResults().splitToList(text).stream()
|
||||
.map(s -> "# " + s)
|
||||
|
||||
@@ -43,6 +43,7 @@ public class RegistryConfigSettings {
|
||||
public DnsUpdate dnsUpdate;
|
||||
public BulkPricingPackageMonitoring bulkPricingPackageMonitoring;
|
||||
public Bsa bsa;
|
||||
public MosApi mosapi;
|
||||
|
||||
/** Configuration options that apply to the entire GCP project. */
|
||||
public static class GcpProject {
|
||||
@@ -262,4 +263,14 @@ public class RegistryConfigSettings {
|
||||
public String unblockableDomainsUrl;
|
||||
public String uploadUnavailableDomainsUrl;
|
||||
}
|
||||
|
||||
/** Configuration for Mosapi. */
|
||||
public static class MosApi {
|
||||
public String serviceUrl;
|
||||
public String tlsCertSecretName;
|
||||
public String tlsKeySecretName;
|
||||
public String entityType;
|
||||
public List<String> tlds;
|
||||
public List<String> services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,3 +616,30 @@ bsa:
|
||||
unblockableDomainsUrl: "https://"
|
||||
# API endpoint for uploading the list of unavailable domain names.
|
||||
uploadUnavailableDomainsUrl: "https://"
|
||||
|
||||
mosapi:
|
||||
# URL for the MosAPI
|
||||
serviceUrl: https://mosapi.icann.org
|
||||
# The type of entity being monitored.
|
||||
# For registries, this is 'ry'
|
||||
# For registrars, this is 'rr'
|
||||
entityType: ry
|
||||
# Add your List of TLDs to be monitored
|
||||
tlds:
|
||||
- your_tld1
|
||||
- your_tld2
|
||||
# Add tls cert secret name
|
||||
# you configured in secret manager
|
||||
tlsCertSecretName: YOUR_TLS_CERT_SECRET_NAME
|
||||
# Add tls key secret name
|
||||
# you configured in secret manager
|
||||
tlsKeySecretName: YOUR_TLS_KEY_SECRET_NAME
|
||||
# List of services to check for each TLD.
|
||||
services:
|
||||
- "dns"
|
||||
- "rdap"
|
||||
- "rdds"
|
||||
- "epp"
|
||||
- "dnssec"
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import google.registry.keyring.api.KeyModule;
|
||||
import google.registry.module.RegistryComponent.RegistryModule;
|
||||
import google.registry.module.RequestComponent.RequestComponentModule;
|
||||
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;
|
||||
@@ -71,6 +72,7 @@ import jakarta.inject.Singleton;
|
||||
GroupsModule.class,
|
||||
GroupssettingsModule.class,
|
||||
GsonModule.class,
|
||||
MosApiModule.class,
|
||||
JSchModule.class,
|
||||
KeyModule.class,
|
||||
KeyringModule.class,
|
||||
|
||||
150
core/src/main/java/google/registry/mosapi/MosApiClient.java
Normal file
150
core/src/main/java/google/registry/mosapi/MosApiClient.java
Normal file
@@ -0,0 +1,150 @@
|
||||
// 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 org.apache.beam.sdk.util.Preconditions.checkArgumentNotNull;
|
||||
|
||||
import com.google.common.base.Throwables;
|
||||
import google.registry.config.RegistryConfig.Config;
|
||||
import google.registry.mosapi.MosApiException.MosApiAuthorizationException;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Named;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.util.Map;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
@Singleton
|
||||
public class MosApiClient {
|
||||
|
||||
private final OkHttpClient httpClient;
|
||||
private final String baseUrl;
|
||||
|
||||
@Inject
|
||||
public MosApiClient(
|
||||
@Named("mosapiHttpClient") OkHttpClient httpClient,
|
||||
@Config("mosapiServiceUrl") String mosapiUrl,
|
||||
@Config("mosapiEntityType") String entityType) {
|
||||
this.httpClient = httpClient;
|
||||
// Pre-calculate base URL and validate it to fail fast on bad config
|
||||
String fullUrl = String.format("%s/%s", mosapiUrl, entityType);
|
||||
checkArgumentNotNull(
|
||||
HttpUrl.parse(fullUrl), "Invalid MoSAPI Service URL configuration: %s", fullUrl);
|
||||
|
||||
this.baseUrl = fullUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a GET request to the specified MoSAPI endpoint.
|
||||
*
|
||||
* @param entityId The TLD or registrar ID the request is for.
|
||||
* @param endpoint The specific API endpoint path (e.g., "v2/monitoring/state").
|
||||
* @param params A map of query parameters to be URL-encoded and appended to the request.
|
||||
* @param headers A map of HTTP headers to be included in the request.
|
||||
* @return The {@link Response} from the server if the request is successful. <b>The caller is
|
||||
* responsible for closing this response.</b>
|
||||
* @throws MosApiException if the request fails due to a network error or an unhandled HTTP
|
||||
* status.
|
||||
* @throws MosApiAuthorizationException if the server returns a 401 Unauthorized status.
|
||||
*/
|
||||
public Response sendGetRequest(
|
||||
String entityId, String endpoint, Map<String, String> params, Map<String, String> headers)
|
||||
throws MosApiException {
|
||||
HttpUrl url = buildUri(entityId, endpoint, params);
|
||||
Request.Builder requestBuilder = new Request.Builder().url(url).get();
|
||||
headers.forEach(requestBuilder::addHeader);
|
||||
try {
|
||||
Response response = httpClient.newCall(requestBuilder.build()).execute();
|
||||
return checkResponseForAuthError(response);
|
||||
} catch (RuntimeException | IOException e) {
|
||||
// Check if it's the specific authorization exception (re-thrown or caught here)
|
||||
Throwables.throwIfInstanceOf(e, MosApiAuthorizationException.class);
|
||||
// Otherwise, treat as a generic connection/API error
|
||||
throw new MosApiException("Error during GET request to " + url, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a POST request to the specified MoSAPI endpoint.
|
||||
*
|
||||
* <p><b>Note:</b> This method is for future use. There are currently no MoSAPI endpoints in the
|
||||
* project scope that require a POST request.
|
||||
*
|
||||
* @param entityId The TLD or registrar ID the request is for.
|
||||
* @param endpoint The specific API endpoint path.
|
||||
* @param params A map of query parameters to be URL-encoded.
|
||||
* @param headers A map of HTTP headers to be included in the request.
|
||||
* @param body The request body to be sent with the POST request.
|
||||
* @return The {@link Response} from the server. <b>The caller is responsible for closing this
|
||||
* response.</b>
|
||||
* @throws MosApiException if the request fails.
|
||||
* @throws MosApiAuthorizationException if the server returns a 401 Unauthorized status.
|
||||
*/
|
||||
public Response sendPostRequest(
|
||||
String entityId,
|
||||
String endpoint,
|
||||
Map<String, String> params,
|
||||
Map<String, String> headers,
|
||||
String body)
|
||||
throws MosApiException {
|
||||
HttpUrl url = buildUri(entityId, endpoint, params);
|
||||
RequestBody requestBody = RequestBody.create(body, MediaType.parse("application/json"));
|
||||
|
||||
Request.Builder requestBuilder = new Request.Builder().url(url).post(requestBody);
|
||||
headers.forEach(requestBuilder::addHeader);
|
||||
try {
|
||||
Response response = httpClient.newCall(requestBuilder.build()).execute();
|
||||
return checkResponseForAuthError(response);
|
||||
} catch (RuntimeException | IOException e) {
|
||||
// Check if it's the specific authorization exception (re-thrown or caught here)
|
||||
Throwables.throwIfInstanceOf(e, MosApiAuthorizationException.class);
|
||||
// Otherwise, treat as a generic connection/API error
|
||||
throw new MosApiException("Error during POST request to " + url, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Response checkResponseForAuthError(Response response)
|
||||
throws MosApiAuthorizationException {
|
||||
if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) {
|
||||
response.close();
|
||||
throw new MosApiAuthorizationException(
|
||||
"Authorization failed for the requested resource. The client certificate may not be"
|
||||
+ " authorized for the specified TLD or Registrar.");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full URL for a request, including the base URL, entityId, path, and query params.
|
||||
*/
|
||||
private HttpUrl buildUri(String entityId, String path, Map<String, String> queryParams) {
|
||||
String sanitizedPath = path.startsWith("/") ? path.substring(1) : path;
|
||||
|
||||
// We can safely use get() here because we validated baseUrl in the constructor
|
||||
HttpUrl.Builder urlBuilder =
|
||||
HttpUrl.get(baseUrl).newBuilder().addPathSegment(entityId).addPathSegments(sanitizedPath);
|
||||
|
||||
if (queryParams != null) {
|
||||
queryParams.forEach(urlBuilder::addQueryParameter);
|
||||
}
|
||||
return urlBuilder.build();
|
||||
}
|
||||
}
|
||||
110
core/src/main/java/google/registry/mosapi/MosApiException.java
Normal file
110
core/src/main/java/google/registry/mosapi/MosApiException.java
Normal 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 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;
|
||||
import java.lang.annotation.Target;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Custom exception for MoSAPI client errors. */
|
||||
public class MosApiException extends IOException {
|
||||
|
||||
private final MosApiErrorResponse errorResponse;
|
||||
|
||||
public MosApiException(MosApiErrorResponse errorResponse) {
|
||||
super(
|
||||
String.format(
|
||||
"MoSAPI returned an error (code: %s): %s",
|
||||
errorResponse.resultCode(), errorResponse.message()));
|
||||
this.errorResponse = errorResponse;
|
||||
}
|
||||
|
||||
public MosApiException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorResponse = null;
|
||||
}
|
||||
|
||||
public Optional<MosApiErrorResponse> getErrorResponse() {
|
||||
return Optional.ofNullable(errorResponse);
|
||||
}
|
||||
|
||||
/** Annotation for associating a MoSAPI result code with an exception subclass. */
|
||||
@Documented
|
||||
@Retention(RUNTIME)
|
||||
@Target(TYPE)
|
||||
public @interface MosApiResultCode {
|
||||
String value();
|
||||
}
|
||||
|
||||
/** Thrown when MoSAPI returns a 401 Unauthorized error. */
|
||||
public static class MosApiAuthorizationException extends MosApiException {
|
||||
public MosApiAuthorizationException(String message) {
|
||||
super(message, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a specific exception based on the MoSAPI error response. */
|
||||
public static MosApiException create(MosApiErrorResponse errorResponse) {
|
||||
Optional<MosApiResponse> responseEnum = MosApiResponse.fromCode(errorResponse.resultCode());
|
||||
if (responseEnum.isPresent()) {
|
||||
return switch (responseEnum.get()) {
|
||||
case DATE_DURATION_INVALID -> new DateDurationInvalidException(errorResponse);
|
||||
case DATE_ORDER_INVALID -> new DateOrderInvalidException(errorResponse);
|
||||
case START_DATE_SYNTAX_INVALID -> new StartDateSyntaxInvalidException(errorResponse);
|
||||
case END_DATE_SYNTAX_INVALID -> new EndDateSyntaxInvalidException(errorResponse);
|
||||
default -> new MosApiException(errorResponse);
|
||||
};
|
||||
}
|
||||
return new MosApiException(errorResponse);
|
||||
}
|
||||
|
||||
/** Thrown when the date duration in a MoSAPI request is invalid. */
|
||||
@MosApiResultCode("2011")
|
||||
public static class DateDurationInvalidException extends MosApiException {
|
||||
public DateDurationInvalidException(MosApiErrorResponse errorResponse) {
|
||||
super(errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the date order in a MoSAPI request is invalid. */
|
||||
@MosApiResultCode("2012")
|
||||
public static class DateOrderInvalidException extends MosApiException {
|
||||
public DateOrderInvalidException(MosApiErrorResponse errorResponse) {
|
||||
super(errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the startDate syntax in a MoSAPI request is invalid. */
|
||||
@MosApiResultCode("2013")
|
||||
public static class StartDateSyntaxInvalidException extends MosApiException {
|
||||
public StartDateSyntaxInvalidException(MosApiErrorResponse errorResponse) {
|
||||
super(errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the endDate syntax in a MoSAPI request is invalid. */
|
||||
@MosApiResultCode("2014")
|
||||
public static class EndDateSyntaxInvalidException extends MosApiException {
|
||||
public EndDateSyntaxInvalidException(MosApiErrorResponse errorResponse) {
|
||||
super(errorResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2024 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 java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Represents known MoSAPI API result codes and their default messages.
|
||||
*
|
||||
* <p>The definitions for these codes can be found in the official ICANN MoSAPI Specification,
|
||||
* specifically in the 'Result Codes' section.
|
||||
*
|
||||
* @see <a href="https://www.icann.org/mosapi-specification.pdf">ICANN MoSAPI Specification</a>
|
||||
*/
|
||||
public enum MosApiResponse {
|
||||
DATE_DURATION_INVALID(
|
||||
"2011", "The difference between endDate and startDate is " + "more than 31 days"),
|
||||
DATE_ORDER_INVALID("2012", "The EndDate is before startDate"),
|
||||
START_DATE_SYNTAX_INVALID("2013", "StartDate syntax is invalid"),
|
||||
END_DATE_SYNTAX_INVALID("2014", "EndDate syntax is invalid");
|
||||
|
||||
private final String code;
|
||||
private final String defaultMessage;
|
||||
|
||||
private static final Map<String, MosApiResponse> CODE_MAP =
|
||||
Arrays.stream(values()).collect(Collectors.toMap(e -> e.code, Function.identity()));
|
||||
|
||||
MosApiResponse(String code, String defaultMessage) {
|
||||
this.code = code;
|
||||
this.defaultMessage = defaultMessage;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getDefaultMessage() {
|
||||
return defaultMessage;
|
||||
}
|
||||
|
||||
// Returns the enum constant associated with the given result code string
|
||||
public static Optional<MosApiResponse> fromCode(String code) {
|
||||
|
||||
return Optional.ofNullable(CODE_MAP.get(code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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;
|
||||
|
||||
/**
|
||||
* Represents the generic JSON error response from the MoSAPI service for a 400 Bad Request.
|
||||
*
|
||||
* @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) {}
|
||||
@@ -0,0 +1,187 @@
|
||||
// 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 dagger.Module;
|
||||
import dagger.Provides;
|
||||
import google.registry.config.RegistryConfig.Config;
|
||||
import google.registry.privileges.secretmanager.SecretManagerClient;
|
||||
import jakarta.inject.Named;
|
||||
import jakarta.inject.Provider;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.util.Optional;
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import okhttp3.OkHttpClient;
|
||||
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
|
||||
import org.bouncycastle.openssl.PEMKeyPair;
|
||||
import org.bouncycastle.openssl.PEMParser;
|
||||
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
|
||||
|
||||
@Module
|
||||
public final class MosApiModule {
|
||||
|
||||
// Secret Manager constants
|
||||
private static final String LATEST_SECRET_VERSION = "latest";
|
||||
|
||||
// @Named annotations for Dagger
|
||||
private static final String MOSAPI_TLS_CERT = "mosapiTlsCert";
|
||||
private static final String MOSAPI_TLS_KEY = "mosapiTlsKey";
|
||||
private static final String MOSAPI_SSL_CONTEXT = "mosapiSslContext";
|
||||
private static final String MOSAPI_HTTP_CLIENT = "mosapiHttpClient";
|
||||
|
||||
// Cryptography-related constants
|
||||
private static final String CERTIFICATE_TYPE = "X.509";
|
||||
private static final String KEY_STORE_TYPE = "PKCS12";
|
||||
private static final String KEY_STORE_ALIAS = "client";
|
||||
private static final String SSL_CONTEXT_PROTOCOL = "TLS";
|
||||
|
||||
/**
|
||||
* Provides a Provider for the MoSAPI TLS Cert.
|
||||
*
|
||||
* <p>This method returns a Dagger {@link Provider} that can be used to fetch the TLS Certs for a
|
||||
* MosAPI.
|
||||
*
|
||||
* @param secretManagerClient The injected Secret Manager client.
|
||||
* @param tlsCertSecretName The name of the secret in Secret Manager (from config).
|
||||
* @return A Provider for the MoSAPI TLS Certs.
|
||||
*/
|
||||
@Provides
|
||||
@Named(MOSAPI_TLS_CERT)
|
||||
public static String provideMosapiTlsCert(
|
||||
SecretManagerClient secretManagerClient,
|
||||
@Config("mosapiTlsCertSecretName") String tlsCertSecretName) {
|
||||
return secretManagerClient.getSecretData(tlsCertSecretName, Optional.of(LATEST_SECRET_VERSION));
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a Provider for the MoSAPI TLS Key.
|
||||
*
|
||||
* <p>This method returns a Dagger {@link Provider} that can be used to fetch the TLS Key for a
|
||||
* MosAPI.
|
||||
*
|
||||
* @param secretManagerClient The injected Secret Manager client.
|
||||
* @param tlsKeySecretName The name of the secret in Secret Manager (from config).
|
||||
* @return A Provider for the MoSAPI TLS Key.
|
||||
*/
|
||||
@Provides
|
||||
@Named(MOSAPI_TLS_KEY)
|
||||
public static String provideMosapiTlsKey(
|
||||
SecretManagerClient secretManagerClient,
|
||||
@Config("mosapiTlsKeySecretName") String tlsKeySecretName) {
|
||||
return secretManagerClient.getSecretData(tlsKeySecretName, Optional.of(LATEST_SECRET_VERSION));
|
||||
}
|
||||
|
||||
@Provides
|
||||
static Certificate provideCertificate(@Named(MOSAPI_TLS_CERT) String tlsCert) {
|
||||
try {
|
||||
CertificateFactory cf = CertificateFactory.getInstance(CERTIFICATE_TYPE);
|
||||
return cf.generateCertificate(
|
||||
new ByteArrayInputStream(tlsCert.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (CertificateException e) {
|
||||
throw new RuntimeException("Could not create X.509 certificate from provided PEM", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
static PrivateKey providePrivateKey(@Named(MOSAPI_TLS_KEY) String tlsKey) {
|
||||
try (PEMParser pemParser = new PEMParser(new StringReader(tlsKey))) {
|
||||
Object parsedObj = pemParser.readObject();
|
||||
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
|
||||
if (parsedObj instanceof PEMKeyPair) {
|
||||
return converter.getPrivateKey(((PEMKeyPair) parsedObj).getPrivateKeyInfo());
|
||||
} else if (parsedObj instanceof PrivateKeyInfo) {
|
||||
return converter.getPrivateKey((PrivateKeyInfo) parsedObj);
|
||||
}
|
||||
throw new IllegalArgumentException(
|
||||
String.format(
|
||||
"Could not parse TLS private key; unexpected format %s",
|
||||
parsedObj != null ? parsedObj.getClass().getName() : "null"));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Could not parse TLS private key from PEM string", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
static KeyStore provideKeyStore(PrivateKey privateKey, Certificate certificate) {
|
||||
try {
|
||||
KeyStore keyStore = KeyStore.getInstance(KEY_STORE_TYPE);
|
||||
keyStore.load(null, null);
|
||||
keyStore.setKeyEntry(
|
||||
KEY_STORE_ALIAS, privateKey, new char[0], new Certificate[] {certificate});
|
||||
return keyStore;
|
||||
} catch (GeneralSecurityException | IOException e) {
|
||||
throw new RuntimeException("Could not create KeyStore for mTLS", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
static KeyManagerFactory provideKeyManagerFactory(KeyStore keyStore) {
|
||||
try {
|
||||
KeyManagerFactory kmf =
|
||||
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
kmf.init(keyStore, new char[0]);
|
||||
return kmf;
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new RuntimeException("Could not initialize KeyManagerFactory", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Named(MOSAPI_SSL_CONTEXT)
|
||||
static SSLContext provideSslContext(KeyManagerFactory keyManagerFactory) {
|
||||
try {
|
||||
SSLContext sslContext = SSLContext.getInstance(SSL_CONTEXT_PROTOCOL);
|
||||
sslContext.init(keyManagerFactory.getKeyManagers(), null, null);
|
||||
return sslContext;
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new RuntimeException("Could not initialize SSLContext", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
static X509TrustManager provideTrustManager() {
|
||||
try {
|
||||
TrustManagerFactory trustManagerFactory =
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
trustManagerFactory.init((KeyStore) null);
|
||||
return (X509TrustManager) trustManagerFactory.getTrustManagers()[0];
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new RuntimeException("Could not initialize TrustManager", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named(MOSAPI_HTTP_CLIENT)
|
||||
static OkHttpClient provideMosapiHttpClient(
|
||||
@Named(MOSAPI_SSL_CONTEXT) SSLContext sslContext, X509TrustManager trustManager) {
|
||||
return new OkHttpClient.Builder()
|
||||
.sslSocketFactory(sslContext.getSocketFactory(), trustManager)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
16
core/src/main/java/google/registry/mosapi/package-info.java
Normal file
16
core/src/main/java/google/registry/mosapi/package-info.java
Normal file
@@ -0,0 +1,16 @@
|
||||
// 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.
|
||||
|
||||
@javax.annotation.ParametersAreNonnullByDefault
|
||||
package google.registry.mosapi;
|
||||
@@ -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,
|
||||
|
||||
203
core/src/test/java/google/registry/mosapi/MosApiClientTest.java
Normal file
203
core/src/test/java/google/registry/mosapi/MosApiClientTest.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ spec:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /_dr/loadtest
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /_dr/mosapi
|
||||
backendRefs:
|
||||
- group: net.gke.io
|
||||
kind: ServiceImport
|
||||
@@ -62,6 +65,12 @@ spec:
|
||||
headers:
|
||||
- name: "canary"
|
||||
value: "true"
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /_dr/mosapi
|
||||
headers:
|
||||
- name: "canary"
|
||||
value: "true"
|
||||
backendRefs:
|
||||
- group: net.gke.io
|
||||
kind: ServiceImport
|
||||
|
||||
Reference in New Issue
Block a user