From 52f26d43fbb852131baeede73e732aad716b34fb Mon Sep 17 00:00:00 2001 From: njshah301 Date: Fri, 17 Oct 2025 09:15:29 +0000 Subject: [PATCH 1/4] Add MoSAPI client This commit introduces a new client for interacting with the ICANN Monitoring System API (MoSAPI), along with a command-line tool for testing the login and logout functionality. The key changes in this commit are: - **`MosApiClient`**: A new client that handles the session lifecycle (login/logout) for the MoSAPI service. It uses a `Function` provider to dynamically fetch credentials from Secret Manager for each TLD. - **`MosApiCredentialModule`**: A new Dagger module that provides the MoSAPI URL and the credential providers for the username and password. This module securely retrieves credentials from Google Cloud Secret Manager. - **`HttpModule`**: A new Dagger module that provides a configured `HttpClient.Builder` for making HTTP requests. This work is part of the effort to create a MoSAPI client --- .../registry/config/RegistryConfig.java | 22 ++ .../config/RegistryConfigSettings.java | 7 + .../registry/config/files/default-config.yaml | 5 + .../reporting/mosapi/MosApiClient.java | 201 +++++++++++++++++ .../mosapi/MosApiCredentialModule.java | 70 ++++++ .../reporting/mosapi/MosApiClientTest.java | 202 ++++++++++++++++++ .../mosapi/MosApiCredentialModuleTest.java | 75 +++++++ .../java/google/registry/util/HttpModule.java | 29 +++ .../google/registry/util/HttpModuleTest.java | 31 +++ 9 files changed, 642 insertions(+) create mode 100644 core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java create mode 100644 core/src/main/java/google/registry/reporting/mosapi/MosApiCredentialModule.java create mode 100644 core/src/test/java/google/registry/reporting/mosapi/MosApiClientTest.java create mode 100644 core/src/test/java/google/registry/reporting/mosapi/MosApiCredentialModuleTest.java create mode 100644 util/src/main/java/google/registry/util/HttpModule.java create mode 100644 util/src/test/java/google/registry/util/HttpModuleTest.java diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index f5421975d10..a6ca6aad4b5 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -653,6 +653,28 @@ public static String provideIcannActivityReportingUploadUrl(RegistryConfigSettin return config.icannReporting.icannActivityReportingUploadUrl; } + /** + * Returns the URL we send HTTP requests for MoSAPI. + * + * @see google.registry.reporting.mosapi.MosApiClient + */ + @Provides + @Config("mosapiUrl") + public static String provideMosapiUrl(RegistryConfigSettings config) { + return config.mosapi.mosapiUrl; + } + + /** + * Returns the entityType we send HTTP requests for MoSAPI. + * + * @see google.registry.reporting.mosapi.MosApiClient + */ + @Provides + @Config("entityType") + public static String provideMosapiEntityType(RegistryConfigSettings config) { + return config.mosapi.entityType; + } + /** * Returns name of the GCS bucket we store invoices and detail reports in. * diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index 32dd08ee84d..32490866d3b 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -31,6 +31,7 @@ public class RegistryConfigSettings { public CloudDns cloudDns; public Caching caching; public IcannReporting icannReporting; + public Mosapi mosapi; public Billing billing; public Rde rde; public RegistrarConsole registrarConsole; @@ -169,6 +170,12 @@ public static class IcannReporting { public String icannActivityReportingUploadUrl; } + /** Configuration for Mosapi. */ + public static class Mosapi { + public String mosapiUrl; + public String entityType; + } + /** Configuration for monthly invoices. */ public static class Billing { public List invoiceEmailRecipients; diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index 03828e34a3e..3f9fd0ac0c2 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -657,3 +657,8 @@ bsa: unblockableDomainsUrl: "https://" # API endpoint for uploading the list of unavailable domain names. uploadUnavailableDomainsUrl: "https://" + +mosapi: + # URL for the MosAPI OT&E environment. + mosapiUrl: https://mosapi-ote.icann.org + entityType: ry diff --git a/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java b/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java new file mode 100644 index 00000000000..bedfca2e9dd --- /dev/null +++ b/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java @@ -0,0 +1,201 @@ +// 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.reporting.mosapi; + +import com.google.common.flogger.FluentLogger; +import google.registry.config.RegistryConfig.Config; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.function.Function; + +/** + * A client for interacting with the ICANN Monitoring System API (MoSAPI). + * + *

This client handles the session lifecycle (login/logout) and provides methods to access the + * various MoSAPI endpoints. It is designed to be reusable and can be injected where needed. + */ +public final class MosApiClient { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private final String baseUrl; + + private final Function usernameProvider; + private final Function passwordProvider; + + private final HttpClient httpClient; + private final CookieManager cookieManager; + + private boolean isLoggedIn = false; + + /** + * Constructs a new MosApiClient. + * + * @param entityType "ry" for registries or "rr" for registrars. + * @param usernameProvider The usernameProvider for authentication. + * @param passwordProvider The passwordProvider for authentication. + */ + @Inject + public MosApiClient( + HttpClient.Builder httpClientBuilder, + @Config("mosapiUrl") String mosapiUrl, + @Config("entityType") String entityType, + @Named("mosapiUsernameProvider") Function usernameProvider, + @Named("mosapiPasswordProvider") Function passwordProvider) { + this.baseUrl = String.format("%s/%s", mosapiUrl, entityType); + this.usernameProvider = usernameProvider; + this.passwordProvider = passwordProvider; + this.cookieManager = new CookieManager(); + this.cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL); + + // Build the final HttpClient using the injected builder and our session-specific + // CookieManager. + this.httpClient = httpClientBuilder.cookieHandler(cookieManager).build(); + } + + /** + * Authenticates with the MoSAPI to create a session. + * + *

A successful login stores a session cookie that is used for subsequent requests. + * + * @throws MosApiException if the login request fails. + */ + public void login(String entityId) throws MosApiException { + + String loginUrl = baseUrl + "/" + entityId + "/login"; + String username = usernameProvider.apply(entityId); + String password = passwordProvider.apply(entityId); + String auth = username + ":" + password; + String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(loginUrl)) + .header("Authorization", "Basic " + encodedAuth) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + try { + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + switch (response.statusCode()) { + case 200: + isLoggedIn = true; + logger.atInfo().log("MoSAPI login successful"); + break; + case 401: + throw new InvalidCredentialsException(response.body()); + case 403: + throw new IpAddressNotAllowedException(response.body()); + case 429: + throw new RateLimitExceededException(response.body()); + default: + throw new MosApiException( + String.format( + "Login failed with unexpected status code: %d - %s", + response.statusCode(), response.body())); + } + } catch (MosApiException e) { + throw e; + } catch (Exception e) { + throw new MosApiException("An error occurred during login.", e); + } + } + + /** + * Logs out and terminates the current session. + * + * @throws MosApiException if the logout request fails. + */ + public void logout(String entityId) throws MosApiException { + String logoutUrl = baseUrl + "/" + entityId + "/logout"; + if (!isLoggedIn) { + return; // Already logged out. + } + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(logoutUrl)) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + try { + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + switch (response.statusCode()) { + case 200: + logger.atInfo().log("Logout successful."); + break; + case 401: + logger.atWarning().log( + "Warning: %s (Session may have already expired).", response.body()); + break; + case 403: + throw new IpAddressNotAllowedException(response.body()); + default: + throw new MosApiException( + String.format( + "Logout failed with unexpected status code: %d - %s", + response.statusCode(), response.body())); + } + } catch (MosApiException e) { + throw e; + } catch (Exception e) { + throw new MosApiException("An error occurred during logout.", e); + } finally { + isLoggedIn = false; + cookieManager.getCookieStore().removeAll(); // Clear local cookies. + } + } + + /** Custom exception for MoSAPI client errors. */ + public static class MosApiException extends Exception { + public MosApiException(String message) { + super(message); + } + + public MosApiException(String message, Throwable cause) { + super(message, cause); + } + } + + /** Thrown when MoSAPI returns a 401 Unauthorized error. */ + public static class InvalidCredentialsException extends MosApiException { + public InvalidCredentialsException(String message) { + super(message); + } + } + + /** Thrown when MoSAPI returns a 403 Forbidden error. */ + public static class IpAddressNotAllowedException extends MosApiException { + public IpAddressNotAllowedException(String message) { + super(message); + } + } + + /** Thrown when MoSAPI returns a 429 Too Many Requests error. */ + public static class RateLimitExceededException extends MosApiException { + public RateLimitExceededException(String message) { + super(message); + } + } +} diff --git a/core/src/main/java/google/registry/reporting/mosapi/MosApiCredentialModule.java b/core/src/main/java/google/registry/reporting/mosapi/MosApiCredentialModule.java new file mode 100644 index 00000000000..586c547b175 --- /dev/null +++ b/core/src/main/java/google/registry/reporting/mosapi/MosApiCredentialModule.java @@ -0,0 +1,70 @@ +// 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.reporting.mosapi; + +import dagger.Module; +import dagger.Provides; +import google.registry.privileges.secretmanager.SecretManagerClient; +import jakarta.inject.Named; +import jakarta.inject.Provider; +import java.util.Optional; +import java.util.function.Function; + +/** Dagger module for providing MoSAPI credentials from Secret Manager. */ +@Module +public class MosApiCredentialModule { + + /** + * Returns the default entityType. + * + * @return "ry" for registry, "rr" for registrar. + */ + + /** + * Provides a Provider for the MoSAPI username. + * + *

This method returns a Dagger {@link Provider} that can be used to fetch the username for a + * specific TLD. The secret name is constructed dynamically using the TLD. + * + * @param secretManagerClient The injected Secret Manager client. + * @return A Provider for the MoSAPI username. + */ + @Provides + @Named("mosapiUsernameProvider") + static Function provideMosapiUsernameProvider( + SecretManagerClient secretManagerClient) { + // This lambda is the implementation of the Function + return (tld) -> { + String secretName = String.format("mosapi_username_%s", tld); + // This call likely throws a checked exception, so we must handle it. + return secretManagerClient.getSecretData(secretName, Optional.of("latest")); + }; + } + + /** + * Provides the shared MoSAPI password. + * + * @param secretManagerClient The injected Secret Manager client. + * @return The MoSAPI password. + */ + @Provides + @Named("mosapiPasswordProvider") + static Function provideMosapiPassword(SecretManagerClient secretManagerClient) { + return (tld) -> { + String secretName = String.format("mosapi_password_%s", tld); + return secretManagerClient.getSecretData(secretName, Optional.of("latest")); + }; + } +} diff --git a/core/src/test/java/google/registry/reporting/mosapi/MosApiClientTest.java b/core/src/test/java/google/registry/reporting/mosapi/MosApiClientTest.java new file mode 100644 index 00000000000..81fffe42ed4 --- /dev/null +++ b/core/src/test/java/google/registry/reporting/mosapi/MosApiClientTest.java @@ -0,0 +1,202 @@ +// 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.reporting.mosapi; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import google.registry.reporting.mosapi.MosApiClient.InvalidCredentialsException; +import google.registry.reporting.mosapi.MosApiClient.IpAddressNotAllowedException; +import google.registry.reporting.mosapi.MosApiClient.MosApiException; +import google.registry.reporting.mosapi.MosApiClient.RateLimitExceededException; +import java.io.IOException; +import java.net.CookieManager; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class MosApiClientTest { + private static final String TEST_URL = "https://example.com"; + private static final String ENTITY_TYPE = "ry"; + private static final String ENTITY_ID = "test-id"; + private static final String USERNAME = "testuser"; + private static final String PASSWORD = "testpassword"; + + @Mock private HttpClient.Builder mockHttpClientBuilder; + @Mock private HttpClient mockHttpClient; + @Mock private HttpResponse mockHttpResponse; + @Mock private Function mockUsernameProvider; + @Mock private Function mockPasswordProvider; + + @Captor private ArgumentCaptor httpRequestCaptor; + + private MosApiClient mosApiClient; + + @BeforeEach + void setUp() { + when(mockHttpClientBuilder.cookieHandler(any(CookieManager.class))) + .thenReturn(mockHttpClientBuilder); + when(mockHttpClientBuilder.build()).thenReturn(mockHttpClient); + when(mockUsernameProvider.apply(ENTITY_ID)).thenReturn(USERNAME); + when(mockPasswordProvider.apply(ENTITY_ID)).thenReturn(PASSWORD); + + mosApiClient = + new MosApiClient( + mockHttpClientBuilder, + TEST_URL, + ENTITY_TYPE, + mockUsernameProvider, + mockPasswordProvider); + } + + @Test + void login_success_isLoggedIn() throws Exception { + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockHttpResponse); + + assertDoesNotThrow(() -> mosApiClient.login(ENTITY_ID)); + + verify(mockHttpClient).send(httpRequestCaptor.capture(), any()); + HttpRequest capturedRequest = httpRequestCaptor.getValue(); + assertThat(capturedRequest.uri()).isEqualTo(URI.create("https://example.com/ry/test-id/login")); + assertThat(capturedRequest.headers().firstValue("Authorization")).isPresent(); + } + + @Test + void login_failure_throwsInvalidCredentialsException() throws Exception { + when(mockHttpResponse.statusCode()).thenReturn(401); + when(mockHttpResponse.body()).thenReturn("Auth Failed"); + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockHttpResponse); + + InvalidCredentialsException e = + assertThrows(InvalidCredentialsException.class, () -> mosApiClient.login(ENTITY_ID)); + assertThat(e).hasMessageThat().isEqualTo("Auth Failed"); + } + + @Test + void login_failure_throwsIpAddressNotAllowedException() throws Exception { + when(mockHttpResponse.statusCode()).thenReturn(403); + when(mockHttpResponse.body()).thenReturn("Forbidden"); + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockHttpResponse); + + IpAddressNotAllowedException e = + assertThrows(IpAddressNotAllowedException.class, () -> mosApiClient.login(ENTITY_ID)); + assertThat(e).hasMessageThat().isEqualTo("Forbidden"); + } + + @Test + void login_failure_throwsRateLimitExceededException() throws Exception { + when(mockHttpResponse.statusCode()).thenReturn(429); + when(mockHttpResponse.body()).thenReturn("Too Many Requests"); + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockHttpResponse); + + RateLimitExceededException e = + assertThrows(RateLimitExceededException.class, () -> mosApiClient.login(ENTITY_ID)); + assertThat(e).hasMessageThat().isEqualTo("Too Many Requests"); + } + + @Test + void login_failure_throwsMosApiExceptionForUnexpectedCode() throws Exception { + when(mockHttpResponse.statusCode()).thenReturn(500); + when(mockHttpResponse.body()).thenReturn("Internal Server Error"); + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockHttpResponse); + + MosApiException exception = + assertThrows(MosApiException.class, () -> mosApiClient.login(ENTITY_ID)); + assertThat(exception) + .hasMessageThat() + .contains("Login failed with unexpected status code: 500 - Internal Server Error"); + } + + @Test + void login_failure_throwsMosApiExceptionForNetworkError() throws Exception { + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new IOException("Network failed")); + + MosApiException exception = + assertThrows(MosApiException.class, () -> mosApiClient.login(ENTITY_ID)); + assertThat(exception).hasMessageThat().isEqualTo("An error occurred during login."); + assertThat(exception).hasCauseThat().isInstanceOf(IOException.class); + } + + @Test + void logout_success() throws Exception { + // First, login successfully + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockHttpResponse); + mosApiClient.login(ENTITY_ID); + + // Then, test logout + assertDoesNotThrow(() -> mosApiClient.logout(ENTITY_ID)); + verify(mockHttpClient, times(2)).send(httpRequestCaptor.capture(), any()); + HttpRequest logoutRequest = httpRequestCaptor.getValue(); + assertThat(logoutRequest.uri()).isEqualTo(URI.create("https://example.com/ry/test-id/logout")); + } + + @Test + void logout_failure_logsWarningOn401() throws Exception { + // First, login + HttpResponse loginResponse = mock(HttpResponse.class); + when(loginResponse.statusCode()).thenReturn(200); + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(loginResponse) + .thenReturn(mockHttpResponse); // For the logout call + mosApiClient.login(ENTITY_ID); + + // Then, mock 401 for logout + when(mockHttpResponse.statusCode()).thenReturn(401); + when(mockHttpResponse.body()).thenReturn("Session may have already expired"); + + assertDoesNotThrow(() -> mosApiClient.logout(ENTITY_ID)); + } + + @Test + void logout_failure_throwsMosApiExceptionForNetworkError() throws Exception { + // First, login + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockHttpResponse) + .thenThrow(new IOException("Network failed on logout")); + mosApiClient.login(ENTITY_ID); + + // Then, test logout failure + MosApiException exception = + assertThrows(MosApiException.class, () -> mosApiClient.logout(ENTITY_ID)); + assertThat(exception).hasMessageThat().isEqualTo("An error occurred during logout."); + assertThat(exception).hasCauseThat().isInstanceOf(IOException.class); + } +} diff --git a/core/src/test/java/google/registry/reporting/mosapi/MosApiCredentialModuleTest.java b/core/src/test/java/google/registry/reporting/mosapi/MosApiCredentialModuleTest.java new file mode 100644 index 00000000000..d390385ed5f --- /dev/null +++ b/core/src/test/java/google/registry/reporting/mosapi/MosApiCredentialModuleTest.java @@ -0,0 +1,75 @@ +// 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.reporting.mosapi; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import google.registry.privileges.secretmanager.SecretManagerClient; +import java.util.Optional; +import java.util.function.Function; +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; + +@ExtendWith(MockitoExtension.class) +public class MosApiCredentialModuleTest { + @Mock private SecretManagerClient mockSecretManagerClient; + + private Function usernameProvider; + private Function passwordProvider; + + @BeforeEach + void setUp() { + usernameProvider = + MosApiCredentialModule.provideMosapiUsernameProvider(mockSecretManagerClient); + passwordProvider = MosApiCredentialModule.provideMosapiPassword(mockSecretManagerClient); + } + + @Test + void provideMosapiUsernameProvider_success_returnsUsername() { + when(mockSecretManagerClient.getSecretData("mosapi_username_test", Optional.of("latest"))) + .thenReturn("test_user"); + String username = usernameProvider.apply("test"); + assertThat(username).isEqualTo("test_user"); + } + + @Test + void provideMosapiUsernameProvider_secretNotFound_throwsException() { + when(mockSecretManagerClient.getSecretData( + "mosapi_username_nonexistent", Optional.of("latest"))) + .thenThrow(new IllegalStateException("Secret not found")); + assertThrows(IllegalStateException.class, () -> usernameProvider.apply("nonexistent")); + } + + @Test + void provideMosapiPasswordProvider_success_returnsPassword() { + when(mockSecretManagerClient.getSecretData("mosapi_password_test", Optional.of("latest"))) + .thenReturn("test_password"); + String password = passwordProvider.apply("test"); + assertThat(password).isEqualTo("test_password"); + } + + @Test + void provideMosapiPasswordProvider_secretNotFound_throwsException() { + when(mockSecretManagerClient.getSecretData( + "mosapi_password_nonexistent", Optional.of("latest"))) + .thenThrow(new IllegalStateException("Secret not found")); + assertThrows(IllegalStateException.class, () -> passwordProvider.apply("nonexistent")); + } +} diff --git a/util/src/main/java/google/registry/util/HttpModule.java b/util/src/main/java/google/registry/util/HttpModule.java new file mode 100644 index 00000000000..2bcb2d06441 --- /dev/null +++ b/util/src/main/java/google/registry/util/HttpModule.java @@ -0,0 +1,29 @@ +// 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.util; + +import dagger.Module; +import dagger.Provides; +import java.net.http.HttpClient; + +/** Dagger module for providing HTTP client related dependencies. */ +@Module +public class HttpModule { + + @Provides + static HttpClient.Builder provideHttpClientBuilder() { + return HttpClient.newBuilder(); + } +} diff --git a/util/src/test/java/google/registry/util/HttpModuleTest.java b/util/src/test/java/google/registry/util/HttpModuleTest.java new file mode 100644 index 00000000000..11de53eaf6d --- /dev/null +++ b/util/src/test/java/google/registry/util/HttpModuleTest.java @@ -0,0 +1,31 @@ +// 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.util; + +import static com.google.common.truth.Truth.assertThat; + +import java.net.http.HttpClient; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link HttpModule}. */ +public class HttpModuleTest { + @Test + void testProvideHttpClientBuilder_returnsBuilder() { + HttpClient.Builder builder = HttpModule.provideHttpClientBuilder(); + assertThat(builder).isNotNull(); + // We can also build the client to ensure the builder is valid + assertThat(builder.build()).isNotNull(); + } +} From 2a613659c214dd2c63c1a9f0761b8ffa6a685ea1 Mon Sep 17 00:00:00 2001 From: njshah301 Date: Sun, 19 Oct 2025 17:55:44 +0000 Subject: [PATCH 2/4] Add HttpUtils class Add a reusable HttpUtils class for making HTTP requests. This class provides simple methods for sending GET and POST requests and handles common exceptions --- .../reporting/mosapi/MosApiClient.java | 22 +--- .../java/google/registry/util/HttpUtils.java | 110 ++++++++++++++++++ .../google/registry/util/HttpUtilsTest.java | 90 ++++++++++++++ 3 files changed, 205 insertions(+), 17 deletions(-) create mode 100644 util/src/main/java/google/registry/util/HttpUtils.java create mode 100644 util/src/test/java/google/registry/util/HttpUtilsTest.java diff --git a/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java b/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java index bedfca2e9dd..b0d27c53614 100644 --- a/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java +++ b/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java @@ -14,15 +14,15 @@ package google.registry.reporting.mosapi; +import com.google.common.collect.ImmutableMap; import com.google.common.flogger.FluentLogger; import google.registry.config.RegistryConfig.Config; +import google.registry.util.HttpUtils; import jakarta.inject.Inject; import jakarta.inject.Named; import java.net.CookieManager; import java.net.CookiePolicy; -import java.net.URI; import java.net.http.HttpClient; -import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.util.Base64; @@ -86,16 +86,10 @@ public void login(String entityId) throws MosApiException { String auth = username + ":" + password; String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create(loginUrl)) - .header("Authorization", "Basic " + encodedAuth) - .POST(HttpRequest.BodyPublishers.noBody()) - .build(); - try { HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpUtils.sendPostRequest( + httpClient, loginUrl, ImmutableMap.of("Authorization", "Basic " + encodedAuth)); switch (response.statusCode()) { case 200: @@ -131,15 +125,9 @@ public void logout(String entityId) throws MosApiException { if (!isLoggedIn) { return; // Already logged out. } - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create(logoutUrl)) - .POST(HttpRequest.BodyPublishers.noBody()) - .build(); try { - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = HttpUtils.sendPostRequest(httpClient, logoutUrl); switch (response.statusCode()) { case 200: diff --git a/util/src/main/java/google/registry/util/HttpUtils.java b/util/src/main/java/google/registry/util/HttpUtils.java new file mode 100644 index 00000000000..33c2b276205 --- /dev/null +++ b/util/src/main/java/google/registry/util/HttpUtils.java @@ -0,0 +1,110 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.util; +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import org.apache.http.HttpException; + +public final class HttpUtils { + /** Private constructor to prevent instantiation. */ + private HttpUtils() {} + + /** + * Sends an HTTP GET request to the specified URL without any custom headers. + * + * @param httpClient the {@link HttpClient} to use for sending the request + * @param url the target URL + * @return the {@link HttpResponse} as a String + */ + public static HttpResponse sendGetRequest(HttpClient httpClient, String url) + throws IOException, InterruptedException { + return sendGetRequest(httpClient, url, ImmutableMap.of()); + } + + /** + * Sends an HTTP GET request with custom headers to the specified URL. + * + * @param httpClient the {@link HttpClient} to use for sending the request + * @param url the target URL + * @param headers a {@link Map} of header keys and values to add to the request + * @return the {@link HttpResponse} as a String + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the request is interrupted + */ + public static HttpResponse sendGetRequest( + HttpClient httpClient, String url, Map headers) + throws IOException, InterruptedException { + HttpRequest.Builder requestBuilder = + HttpRequest.newBuilder().uri(URI.create(url)).GET(); + for (Map.Entry header : headers.entrySet()) { + requestBuilder.header(header.getKey(), header.getValue()); + } + return send(httpClient, requestBuilder.build()); + } + + /** + * Sends an HTTP POST request with an empty body to the specified URL. + * + * @param httpClient the {@link HttpClient} to use for sending the request + * @param url the target URL + * @return the {@link HttpResponse} as a String + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the request is interrupted + */ + public static HttpResponse sendPostRequest(HttpClient httpClient, String url) + throws HttpException, IOException, InterruptedException { + return sendPostRequest(httpClient, url, ImmutableMap.of()); + } + + /** + * Sends an HTTP POST request with an empty body and custom headers to the specified URL. + * + * @param httpClient the {@link HttpClient} to use for sending the request + * @param url the target URL + * @param headers a {@link Map} of header keys and values to add to the request + * @return the {@link HttpResponse} as a String + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the request is interrupted + */ + public static HttpResponse sendPostRequest( + HttpClient httpClient, String url, Map headers) + throws IOException, InterruptedException { + HttpRequest.Builder requestBuilder = + HttpRequest.newBuilder().uri(URI.create(url)).POST(HttpRequest.BodyPublishers.noBody()); + for (Map.Entry header : headers.entrySet()) { + requestBuilder.header(header.getKey(), header.getValue()); + } + return send(httpClient, requestBuilder.build()); + } + + /** + * Sends a pre-built {@link HttpRequest} and handles exceptions. + * + * @param httpClient the client + * @param request the request + * @return the {@link HttpResponse} + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the request is interrupted + */ + private static HttpResponse send(HttpClient httpClient, HttpRequest request) + throws IOException, InterruptedException { + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/util/src/test/java/google/registry/util/HttpUtilsTest.java b/util/src/test/java/google/registry/util/HttpUtilsTest.java new file mode 100644 index 00000000000..72e8a071dd6 --- /dev/null +++ b/util/src/test/java/google/registry/util/HttpUtilsTest.java @@ -0,0 +1,90 @@ +// 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.util; + +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.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class HttpUtilsTest { + @Mock private HttpClient mockHttpClient; + @Mock private HttpResponse mockHttpResponse; + @Captor private ArgumentCaptor requestCaptor; + + @Test + void sendGetRequest_success_returnsResponse() throws Exception { + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockHttpResponse); + HttpResponse response = HttpUtils.sendGetRequest(mockHttpClient, "https://example.com"); + assertThat(response).isSameInstanceAs(mockHttpResponse); + } + + @Test + void sendPostRequest_success_returnsResponse() throws Exception { + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockHttpResponse); + HttpResponse response = + HttpUtils.sendPostRequest(mockHttpClient, "https://example.com"); + assertThat(response).isSameInstanceAs(mockHttpResponse); + } + + @Test + void sendPostRequest_withHeaders_headersAreSet() throws Exception { + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockHttpResponse); + HttpUtils.sendPostRequest( + mockHttpClient, "https://example.com", ImmutableMap.of("Authorization", "Basic 12345")); + verify(mockHttpClient).send(requestCaptor.capture(), any()); + assertThat(requestCaptor.getValue().headers().firstValue("Authorization")) + .hasValue("Basic 12345"); + } + + @Test + void send_ioException_throwsIOException() throws Exception { + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new IOException("Network failed")); + IOException e = + assertThrows( + IOException.class, + () -> HttpUtils.sendGetRequest(mockHttpClient, "https://example.com")); + assertThat(e).hasMessageThat().isEqualTo("Network failed"); + } + + @Test + void send_interruptedException_throwsInterruptedException() throws Exception { + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new InterruptedException("Request interrupted")); + InterruptedException e = + assertThrows( + InterruptedException.class, + () -> HttpUtils.sendGetRequest(mockHttpClient, "https://example.com")); + assertThat(e).hasMessageThat().isEqualTo("Request interrupted"); + } +} From 59b191b1097f35a1bf8583f0ee04a90447ec36bd Mon Sep 17 00:00:00 2001 From: njshah301 Date: Tue, 21 Oct 2025 07:45:29 +0000 Subject: [PATCH 3/4] Updating MosAPI client to singleton and data cleanup - The `MosApiClient` is now a singleton to address the MoSAPI rate-limiting requirements. This ensures that a single, shared instance of the client is used throughout the application, preventing multiple login attempts in a short period. - Data cleanup and java format check issue resolved --- .../java/google/registry/reporting/mosapi/MosApiClient.java | 2 ++ .../registry/reporting/mosapi/MosApiCredentialModule.java | 6 ------ util/src/main/java/google/registry/util/HttpUtils.java | 6 +++--- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java b/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java index b0d27c53614..bc9a84174a3 100644 --- a/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java +++ b/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java @@ -20,6 +20,7 @@ import google.registry.util.HttpUtils; import jakarta.inject.Inject; import jakarta.inject.Named; +import jakarta.inject.Singleton; import java.net.CookieManager; import java.net.CookiePolicy; import java.net.http.HttpClient; @@ -34,6 +35,7 @@ *

This client handles the session lifecycle (login/logout) and provides methods to access the * various MoSAPI endpoints. It is designed to be reusable and can be injected where needed. */ +@Singleton public final class MosApiClient { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private final String baseUrl; diff --git a/core/src/main/java/google/registry/reporting/mosapi/MosApiCredentialModule.java b/core/src/main/java/google/registry/reporting/mosapi/MosApiCredentialModule.java index 586c547b175..e7b5785c646 100644 --- a/core/src/main/java/google/registry/reporting/mosapi/MosApiCredentialModule.java +++ b/core/src/main/java/google/registry/reporting/mosapi/MosApiCredentialModule.java @@ -26,12 +26,6 @@ @Module public class MosApiCredentialModule { - /** - * Returns the default entityType. - * - * @return "ry" for registry, "rr" for registrar. - */ - /** * Provides a Provider for the MoSAPI username. * diff --git a/util/src/main/java/google/registry/util/HttpUtils.java b/util/src/main/java/google/registry/util/HttpUtils.java index 33c2b276205..d804d5db428 100644 --- a/util/src/main/java/google/registry/util/HttpUtils.java +++ b/util/src/main/java/google/registry/util/HttpUtils.java @@ -13,6 +13,7 @@ // limitations under the License. package google.registry.util; + import com.google.common.collect.ImmutableMap; import java.io.IOException; import java.net.URI; @@ -51,8 +52,7 @@ public static HttpResponse sendGetRequest(HttpClient httpClient, String public static HttpResponse sendGetRequest( HttpClient httpClient, String url, Map headers) throws IOException, InterruptedException { - HttpRequest.Builder requestBuilder = - HttpRequest.newBuilder().uri(URI.create(url)).GET(); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(url)).GET(); for (Map.Entry header : headers.entrySet()) { requestBuilder.header(header.getKey(), header.getValue()); } @@ -105,6 +105,6 @@ public static HttpResponse sendPostRequest( */ private static HttpResponse send(HttpClient httpClient, HttpRequest request) throws IOException, InterruptedException { - return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); } } From 652bed5a04f7dafb3522cdc4e6668577c32dffb7 Mon Sep 17 00:00:00 2001 From: njshah301 Date: Fri, 24 Oct 2025 20:29:50 +0000 Subject: [PATCH 4/4] Refactor MosApiClient for statelessness and resilience Problem: The existing MosApiClient was stateful, using an in-memory CookieManager. This design is incompatible with a multi-pod environment, leading to authentication failures as session state wasn't shared. It also lacked automatic handling for session expiry (401 errors). Solution: - Introduced `MosApiSessionCache` to store session cookies externally in Secret Manager, enabling shared state across pods. - Refactored `MosApiClient` into a stateless "engine" that utilizes `MosApiSessionCache` for session management. - Implemented automatic re-login and retry logic within `MosApiClient` to handle 401 Unauthorized errors transparently. It now attempts to log in and retries the original request once upon encountering a 401. - Added specific handling for 429 Rate Limit Exceeded errors during login. - Refactored status codes into constants, using standard `HttpURLConnection` constants where applicable. This change makes the MoSAPI integration robust, scalable in a multi-pod setup, and significantly more maintainable. --- .../reporting/mosapi/MosApiClient.java | 293 ++++++++++- .../reporting/mosapi/MosApiSessionCache.java | 84 +++ .../mosapi/MosApiSessionCacheModule.java | 29 + .../reporting/mosapi/MosApiClientTest.java | 495 ++++++++++++++++-- .../mosapi/MosApiSessionCacheModuleTest.java | 50 ++ .../mosapi/MosApiSessionCacheTest.java | 111 ++++ .../java/google/registry/util/HttpUtils.java | 28 + .../google/registry/util/HttpUtilsTest.java | 170 +++++- 8 files changed, 1155 insertions(+), 105 deletions(-) create mode 100644 core/src/main/java/google/registry/reporting/mosapi/MosApiSessionCache.java create mode 100644 core/src/main/java/google/registry/reporting/mosapi/MosApiSessionCacheModule.java create mode 100644 core/src/test/java/google/registry/reporting/mosapi/MosApiSessionCacheModuleTest.java create mode 100644 core/src/test/java/google/registry/reporting/mosapi/MosApiSessionCacheTest.java diff --git a/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java b/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java index bc9a84174a3..2d5d515735c 100644 --- a/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java +++ b/core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java @@ -14,6 +14,8 @@ package google.registry.reporting.mosapi; +import com.google.common.base.Splitter; +import com.google.common.base.Strings; import com.google.common.collect.ImmutableMap; import com.google.common.flogger.FluentLogger; import google.registry.config.RegistryConfig.Config; @@ -21,13 +23,20 @@ import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; -import java.net.CookieManager; -import java.net.CookiePolicy; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; import java.util.function.Function; +import java.util.stream.Collectors; +import javax.annotation.Nullable; /** * A client for interacting with the ICANN Monitoring System API (MoSAPI). @@ -43,10 +52,26 @@ public final class MosApiClient { private final Function usernameProvider; private final Function passwordProvider; - private final HttpClient httpClient; - private final CookieManager cookieManager; + // API Endpoints + private static final String LOGIN_PATH = "/login"; + private static final String LOGOUT_PATH = "/logout"; + // HTTP Headers + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_CONTENT_TYPE = "Content-Type"; + private static final String HEADER_COOKIE = "Cookie"; + private static final String HEADER_SET_COOKIE = "Set-Cookie"; + + // HTTP Header Prefixes and Values + private static final String AUTH_BASIC_PREFIX = "Basic "; + private static final String CONTENT_TYPE_JSON = "application/json"; - private boolean isLoggedIn = false; + // Cookie Parsing + private static final String COOKIE_ID_PREFIX = "id="; + private static final char COOKIE_DELIMITER = ';'; + private final int RATE_LIMIT = 429; + + private final HttpClient httpClient; + private final MosApiSessionCache mosApiSessionCache; /** * Constructs a new MosApiClient. @@ -61,16 +86,13 @@ public MosApiClient( @Config("mosapiUrl") String mosapiUrl, @Config("entityType") String entityType, @Named("mosapiUsernameProvider") Function usernameProvider, - @Named("mosapiPasswordProvider") Function passwordProvider) { + @Named("mosapiPasswordProvider") Function passwordProvider, + MosApiSessionCache mosApiSessionCache) { this.baseUrl = String.format("%s/%s", mosapiUrl, entityType); this.usernameProvider = usernameProvider; this.passwordProvider = passwordProvider; - this.cookieManager = new CookieManager(); - this.cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL); - - // Build the final HttpClient using the injected builder and our session-specific - // CookieManager. - this.httpClient = httpClientBuilder.cookieHandler(cookieManager).build(); + this.mosApiSessionCache = mosApiSessionCache; + this.httpClient = httpClientBuilder.build(); } /** @@ -82,7 +104,7 @@ public MosApiClient( */ public void login(String entityId) throws MosApiException { - String loginUrl = baseUrl + "/" + entityId + "/login"; + String loginUrl = buildUrl(entityId, LOGIN_PATH, Collections.emptyMap()); String username = usernameProvider.apply(entityId); String password = passwordProvider.apply(entityId); String auth = username + ":" + password; @@ -91,18 +113,26 @@ public void login(String entityId) throws MosApiException { try { HttpResponse response = HttpUtils.sendPostRequest( - httpClient, loginUrl, ImmutableMap.of("Authorization", "Basic " + encodedAuth)); + httpClient, + loginUrl, + ImmutableMap.of(HEADER_AUTHORIZATION, AUTH_BASIC_PREFIX + encodedAuth)); switch (response.statusCode()) { - case 200: - isLoggedIn = true; + case HttpURLConnection.HTTP_OK: + Optional setCookieHeader = response.headers().firstValue(HEADER_SET_COOKIE); + if (setCookieHeader.isEmpty()) { + throw new MosApiException( + "Login succeeded but server did not return a Set-Cookie header."); + } + String cookieValue = parseCookieValue(setCookieHeader.get()); + mosApiSessionCache.store(entityId, cookieValue); logger.atInfo().log("MoSAPI login successful"); break; - case 401: + case HttpURLConnection.HTTP_UNAUTHORIZED: throw new InvalidCredentialsException(response.body()); - case 403: + case HttpURLConnection.HTTP_FORBIDDEN: throw new IpAddressNotAllowedException(response.body()); - case 429: + case RATE_LIMIT: throw new RateLimitExceededException(response.body()); default: throw new MosApiException( @@ -123,23 +153,23 @@ public void login(String entityId) throws MosApiException { * @throws MosApiException if the logout request fails. */ public void logout(String entityId) throws MosApiException { - String logoutUrl = baseUrl + "/" + entityId + "/logout"; - if (!isLoggedIn) { - return; // Already logged out. - } + String logoutUrl = buildUrl(entityId, LOGOUT_PATH, Collections.emptyMap()); + Optional cookie = mosApiSessionCache.get(entityId); + Map headers = + cookie.isPresent() ? ImmutableMap.of(HEADER_COOKIE, cookie.get()) : ImmutableMap.of(); try { - HttpResponse response = HttpUtils.sendPostRequest(httpClient, logoutUrl); + HttpResponse response = HttpUtils.sendPostRequest(httpClient, logoutUrl, headers); switch (response.statusCode()) { - case 200: + case HttpURLConnection.HTTP_OK: logger.atInfo().log("Logout successful."); break; - case 401: + case HttpURLConnection.HTTP_UNAUTHORIZED: logger.atWarning().log( "Warning: %s (Session may have already expired).", response.body()); break; - case 403: + case HttpURLConnection.HTTP_FORBIDDEN: throw new IpAddressNotAllowedException(response.body()); default: throw new MosApiException( @@ -152,9 +182,214 @@ public void logout(String entityId) throws MosApiException { } catch (Exception e) { throw new MosApiException("An error occurred during logout.", e); } finally { - isLoggedIn = false; - cookieManager.getCookieStore().removeAll(); // Clear local cookies. + mosApiSessionCache.clear(entityId); + logger.atInfo().log("Cleared session cache for %s", entityId); + } + } + + /** + * Executes a GET request with automatic session handling and re-login. + * + * @param entityId The entityId (e.g., TLD) for this request. + * @param path The API path, e.g., "/monitoring/state". + * @param queryParams A map of query parameters. + * @param additionalHeaders Any custom headers for this specific request (e.g., "Accept"). + * @return The response body as a String. + * @throws MosApiException if the request fails. + */ + public String executeGetRequest( + String entityId, + String path, + Map queryParams, + Map additionalHeaders) + throws MosApiException { + + String url = buildUrl(entityId, path, queryParams); + + BiFunction> requestExecutor = + (requestUrl, cookie) -> { + try { + ImmutableMap.Builder headers = ImmutableMap.builder(); + headers.put(HEADER_COOKIE, cookie); + if (additionalHeaders != null) { + headers.putAll(additionalHeaders); + } + return HttpUtils.sendGetRequest(httpClient, requestUrl, headers.build()); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(new MosApiException("HTTP GET request failed", e)); + } + }; + + HttpResponse response = executeRequestWithRetry(entityId, url, requestExecutor); + + if (response.statusCode() != HttpURLConnection.HTTP_OK) { + throw new MosApiException( + String.format( + "GET request to %s failed with status code: %d - %s", + path, response.statusCode(), response.body())); + } + return response.body(); + } + + /** + * Executes a POST request with automatic session handling and re-login. + * + * @param entityId The entityId (e.g., TLD) for this request. + * @param path The API path, e.g., "/monitoring/incident/123/falsePositive". + * @param body An optional request body (e.g., JSON string). Use null or empty for no body. + * @param additionalHeaders Any custom headers for this specific request. + * @return The response body as a String. + * @throws MosApiException if the request fails. + */ + public String executePostRequest( + String entityId, String path, @Nullable String body, Map additionalHeaders) + throws MosApiException { + + String url = buildUrl(entityId, path, Collections.emptyMap()); + final String requestBody = Strings.nullToEmpty(body); + + // Define the request-executing lambda + BiFunction> requestExecutor = + (requestUrl, cookie) -> { + try { + // Build the headers map + ImmutableMap.Builder headers = ImmutableMap.builder(); + headers.put(HEADER_COOKIE, cookie); + if (additionalHeaders != null) { + headers.putAll(additionalHeaders); + } + if (!requestBody.isEmpty()) { + headers.put(HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON); + } + + return HttpUtils.sendPostRequest(httpClient, requestUrl, headers.build(), requestBody); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(new MosApiException("HTTP POST request failed", e)); + } + }; + + HttpResponse response = executeRequestWithRetry(entityId, url, requestExecutor); + + if (response.statusCode() != HttpURLConnection.HTTP_OK) { + throw new MosApiException( + String.format( + "POST request to %s failed with status code: %d - %s", + path, response.statusCode(), response.body())); + } + return response.body(); + } + + /** + * Executes a request function with automatic session caching and re-login on expiry. + * + * @param entityId The entityId for the request. + * @param url The full URL to request. + * @param requestExecutor A function that takes (URL, CookieString) and returns an HttpResponse. + * @return The HttpResponse from the successful request. + * @throws MosApiException if the request fails permanently. + */ + private HttpResponse executeRequestWithRetry( + String entityId, String url, BiFunction> requestExecutor) + throws MosApiException { + + // 1. Try with existing cookie from cache + Optional cookie = mosApiSessionCache.get(entityId); + if (cookie.isPresent()) { + try { + HttpResponse response = requestExecutor.apply(url, cookie.get()); + if (isSessionExpiredError(response)) { + logger.atWarning().log("Session expired for %s. Re-logging in.", entityId); + } else { + return response; // Success or other non-session-expired error + } + } catch (RuntimeException e) { + if (e.getCause() instanceof MosApiException) { + throw (MosApiException) e.getCause(); + } + throw new MosApiException("Request failed", e); + } + } else { + logger.atInfo().log("No session cookie cached for %s. Logging in.", entityId); + } + + // 2. If no cookie, or if session was expired, perform login. + try { + login(entityId); + } catch (RateLimitExceededException e) { + throw new MosApiException("Try running after some time", e); + } catch (MosApiException e) { + throw new MosApiException("Automatic re-login failed.", e); + } + + // 3. Retry the original request with the new cookie + logger.atInfo().log("Login successful. Retrying original request for %s.", entityId); + cookie = mosApiSessionCache.get(entityId); + if (cookie.isEmpty()) { + throw new MosApiException("Login succeeded but failed to retrieve new session cookie."); + } + + try { + HttpResponse response = requestExecutor.apply(url, cookie.get()); + if (isSessionExpiredError(response)) { + throw new MosApiException( + "Authentication failed even after re-login.", + new InvalidCredentialsException(response.body())); + } + return response; + } catch (RuntimeException e) { + if (e.getCause() instanceof MosApiException) { + throw (MosApiException) e.getCause(); + } + throw new MosApiException("Request failed after re-login", e); + } + } + + /** + * Parses the "id=..." cookie value from the "Set-Cookie" header. + * + * @param setCookieHeader The raw value of the "Set-Cookie" header. + * @return The "id=..." part of the cookie. + * @throws MosApiException if the "id" part cannot be found. + */ + private String parseCookieValue(String setCookieHeader) throws MosApiException { + for (String part : Splitter.on(COOKIE_DELIMITER).trimResults().split(setCookieHeader)) { + if (part.startsWith(COOKIE_ID_PREFIX)) { + return part; + } + } + throw new MosApiException( + String.format("Could not parse 'id' from Set-Cookie header: %s", setCookieHeader)); + } + + /** + * Checks if an HTTP response indicates an expired session. + * + * @param response The HTTP response. + * @return True if the response is a 401 with the specific session expired message. + */ + private boolean isSessionExpiredError(HttpResponse response) { + return (response.statusCode() == HttpURLConnection.HTTP_UNAUTHORIZED); + } + + /** + * Builds the full URL for a request, including the base URL, entityId, path, and query params. + */ + private String buildUrl(String entityId, String path, Map queryParams) { + String sanitizedPath = path.startsWith("/") ? path : "/" + path; + String fullPath = "/" + entityId + sanitizedPath; + + if (queryParams == null || queryParams.isEmpty()) { + return baseUrl + fullPath; } + String queryString = + queryParams.entrySet().stream() + .map( + entry -> + entry.getKey() + + "=" + + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); + return baseUrl + fullPath + "?" + queryString; } /** Custom exception for MoSAPI client errors. */ diff --git a/core/src/main/java/google/registry/reporting/mosapi/MosApiSessionCache.java b/core/src/main/java/google/registry/reporting/mosapi/MosApiSessionCache.java new file mode 100644 index 00000000000..7ad3a6e07b7 --- /dev/null +++ b/core/src/main/java/google/registry/reporting/mosapi/MosApiSessionCache.java @@ -0,0 +1,84 @@ +// 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.reporting.mosapi; + +import com.google.common.base.Strings; +import com.google.common.flogger.FluentLogger; +import google.registry.privileges.secretmanager.SecretManagerClient; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import java.util.Optional; + +/** + * Caches MoSAPI session cookies in Secret Manager to share state across pods. + * + *

This assumes that secrets named "mosapi_session_cookie_ENTITYID" are pre-created in Secret + * Manager and that the service account has permission to read and add new versions to them. + */ +@Singleton +public class MosApiSessionCache { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private static final String SECRET_PREFIX = "mosapi_session_cookie_"; + + private final SecretManagerClient secretManagerClient; + + @Inject + public MosApiSessionCache(SecretManagerClient secretManagerClient) { + this.secretManagerClient = secretManagerClient; + } + + private String getSecretName(String entityId) { + return SECRET_PREFIX + entityId; + } + + /** + * Retrieves the session cookie for a given entityId. + * + * @return The cookie string (e.g., "id=...") or Optional.empty() if not found or invalid. + */ + public Optional get(String entityId) { + String secretName = getSecretName(entityId); + try { + String cookie = secretManagerClient.getSecretData(secretName, Optional.of("latest")); + // An empty string is considered an invalid/cleared cookie + return Strings.isNullOrEmpty(cookie) ? Optional.empty() : Optional.of(cookie); + } catch (Exception e) { + // This is expected if the secret or version doesn't exist + logger.atInfo().log("No session cookie found in Secret Manager for %s.", entityId); + return Optional.empty(); + } + } + + /** + * Stores a new session cookie value for a given entityId. + * + *

This will create a new secret version. + */ + public void store(String entityId, String cookieValue) { + String secretName = getSecretName(entityId); + try { + secretManagerClient.addSecretVersion(secretName, cookieValue); + logger.atInfo().log("Stored new MoSAPI session cookie for %s.", entityId); + } catch (Exception e) { + logger.atSevere().withCause(e).log("Failed to store MoSAPI session cookie for %s.", entityId); + throw new RuntimeException("Failed to store session cookie in Secret Manager.", e); + } + } + + /** Clears the cached session cookie for a given entityId by storing an empty value. */ + public void clear(String entityId) { + store(entityId, ""); + } +} diff --git a/core/src/main/java/google/registry/reporting/mosapi/MosApiSessionCacheModule.java b/core/src/main/java/google/registry/reporting/mosapi/MosApiSessionCacheModule.java new file mode 100644 index 00000000000..d287c60b19a --- /dev/null +++ b/core/src/main/java/google/registry/reporting/mosapi/MosApiSessionCacheModule.java @@ -0,0 +1,29 @@ +// 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.reporting.mosapi; + +import dagger.Module; +import dagger.Provides; +import google.registry.privileges.secretmanager.SecretManagerClient; +import jakarta.inject.Singleton; + +/** Dagger module for providing {@link MosApiSessionCache}. */ +@Module +public class MosApiSessionCacheModule { + @Provides + @Singleton + static MosApiSessionCache provideMosApiSessionCache(SecretManagerClient secretManagerClient) { + return new MosApiSessionCache(secretManagerClient); + } +} diff --git a/core/src/test/java/google/registry/reporting/mosapi/MosApiClientTest.java b/core/src/test/java/google/registry/reporting/mosapi/MosApiClientTest.java index 81fffe42ed4..4c49ea99b7a 100644 --- a/core/src/test/java/google/registry/reporting/mosapi/MosApiClientTest.java +++ b/core/src/test/java/google/registry/reporting/mosapi/MosApiClientTest.java @@ -18,28 +18,40 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +import com.google.common.collect.ImmutableMap; import google.registry.reporting.mosapi.MosApiClient.InvalidCredentialsException; import google.registry.reporting.mosapi.MosApiClient.IpAddressNotAllowedException; import google.registry.reporting.mosapi.MosApiClient.MosApiException; import google.registry.reporting.mosapi.MosApiClient.RateLimitExceededException; +import google.registry.util.HttpUtils; import java.io.IOException; -import java.net.CookieManager; -import java.net.URI; import java.net.http.HttpClient; -import java.net.http.HttpRequest; +import java.net.http.HttpHeaders; import java.net.http.HttpResponse; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.function.Function; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -49,24 +61,35 @@ public class MosApiClientTest { private static final String ENTITY_ID = "test-id"; private static final String USERNAME = "testuser"; private static final String PASSWORD = "testpassword"; + private static final String LOGIN_URL = "https://example.com/ry/test-id/login"; + private static final String LOGOUT_URL = "https://example.com/ry/test-id/logout"; + private static final String GET_URL = "https://example.com/ry/test-id/get-path"; + private static final String POST_URL = "https://example.com/ry/test-id/post-path"; + private static final String POST_BODY = "{\"key\":\"value\"}"; + private static final String COOKIE_HEADER = "id=test-cookie-123"; + private static final String SET_COOKIE_HEADER = "id=test-cookie-123; expires=...; path=/"; + private static final String EXPIRED_COOKIE_HEADER = "id=expired-cookie-456"; @Mock private HttpClient.Builder mockHttpClientBuilder; @Mock private HttpClient mockHttpClient; @Mock private HttpResponse mockHttpResponse; @Mock private Function mockUsernameProvider; @Mock private Function mockPasswordProvider; + @Mock private MosApiSessionCache mockMosApiSessionCache; - @Captor private ArgumentCaptor httpRequestCaptor; + @Captor private ArgumentCaptor> headersCaptor; private MosApiClient mosApiClient; + private MockedStatic httpUtilsMock; @BeforeEach void setUp() { - when(mockHttpClientBuilder.cookieHandler(any(CookieManager.class))) - .thenReturn(mockHttpClientBuilder); + // Mock the static HttpUtils class + httpUtilsMock = Mockito.mockStatic(HttpUtils.class); + // Mock the HttpClient builder chain when(mockHttpClientBuilder.build()).thenReturn(mockHttpClient); - when(mockUsernameProvider.apply(ENTITY_ID)).thenReturn(USERNAME); - when(mockPasswordProvider.apply(ENTITY_ID)).thenReturn(PASSWORD); + // Mock provider functions. Use lenient() to avoid UnnecessaryStubbingException + // since not all tests will trigger a login and use these mocks. mosApiClient = new MosApiClient( @@ -74,28 +97,63 @@ void setUp() { TEST_URL, ENTITY_TYPE, mockUsernameProvider, - mockPasswordProvider); + mockPasswordProvider, + mockMosApiSessionCache); + } + + @AfterEach + void tearDown() { + // Close the static mock + httpUtilsMock.close(); } @Test - void login_success_isLoggedIn() throws Exception { + void login_success() throws Exception { when(mockHttpResponse.statusCode()).thenReturn(200); - when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + // Mock the headers() call + HttpHeaders mockHeaders = + HttpHeaders.of(Map.of("Set-Cookie", List.of(SET_COOKIE_HEADER)), (a, b) -> true); + when(mockHttpResponse.headers()).thenReturn(mockHeaders); + // Mock the underlying HttpUtils.sendPostRequest call + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(any(HttpClient.class), anyString(), anyMap())) .thenReturn(mockHttpResponse); assertDoesNotThrow(() -> mosApiClient.login(ENTITY_ID)); - verify(mockHttpClient).send(httpRequestCaptor.capture(), any()); - HttpRequest capturedRequest = httpRequestCaptor.getValue(); - assertThat(capturedRequest.uri()).isEqualTo(URI.create("https://example.com/ry/test-id/login")); - assertThat(capturedRequest.headers().firstValue("Authorization")).isPresent(); + // Verify sendPostRequest was called with the correct URL and Auth + httpUtilsMock.verify( + () -> + HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGIN_URL), headersCaptor.capture())); + assertThat(headersCaptor.getValue()).containsKey("Authorization"); + + // Verify the cookie was stored in the cache + verify(mockMosApiSessionCache).store(ENTITY_ID, COOKIE_HEADER); + } + + @Test + void login_success_noCookieHeader_throwsException() throws Exception { + when(mockHttpResponse.statusCode()).thenReturn(200); + // Mock empty headers + HttpHeaders mockHeaders = HttpHeaders.of(Collections.emptyMap(), (a, b) -> true); + when(mockHttpResponse.headers()).thenReturn(mockHeaders); + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(any(HttpClient.class), anyString(), anyMap())) + .thenReturn(mockHttpResponse); + + MosApiException e = assertThrows(MosApiException.class, () -> mosApiClient.login(ENTITY_ID)); + assertThat(e) + .hasMessageThat() + .isEqualTo("Login succeeded but server did not return a Set-Cookie header."); + verifyNoInteractions(mockMosApiSessionCache); } @Test void login_failure_throwsInvalidCredentialsException() throws Exception { when(mockHttpResponse.statusCode()).thenReturn(401); when(mockHttpResponse.body()).thenReturn("Auth Failed"); - when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(any(HttpClient.class), anyString(), anyMap())) .thenReturn(mockHttpResponse); InvalidCredentialsException e = @@ -107,7 +165,8 @@ void login_failure_throwsInvalidCredentialsException() throws Exception { void login_failure_throwsIpAddressNotAllowedException() throws Exception { when(mockHttpResponse.statusCode()).thenReturn(403); when(mockHttpResponse.body()).thenReturn("Forbidden"); - when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(any(HttpClient.class), anyString(), anyMap())) .thenReturn(mockHttpResponse); IpAddressNotAllowedException e = @@ -119,7 +178,8 @@ void login_failure_throwsIpAddressNotAllowedException() throws Exception { void login_failure_throwsRateLimitExceededException() throws Exception { when(mockHttpResponse.statusCode()).thenReturn(429); when(mockHttpResponse.body()).thenReturn("Too Many Requests"); - when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(any(HttpClient.class), anyString(), anyMap())) .thenReturn(mockHttpResponse); RateLimitExceededException e = @@ -131,7 +191,8 @@ void login_failure_throwsRateLimitExceededException() throws Exception { void login_failure_throwsMosApiExceptionForUnexpectedCode() throws Exception { when(mockHttpResponse.statusCode()).thenReturn(500); when(mockHttpResponse.body()).thenReturn("Internal Server Error"); - when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(any(HttpClient.class), anyString(), anyMap())) .thenReturn(mockHttpResponse); MosApiException exception = @@ -143,7 +204,8 @@ void login_failure_throwsMosApiExceptionForUnexpectedCode() throws Exception { @Test void login_failure_throwsMosApiExceptionForNetworkError() throws Exception { - when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(any(HttpClient.class), anyString(), anyMap())) .thenThrow(new IOException("Network failed")); MosApiException exception = @@ -153,50 +215,387 @@ void login_failure_throwsMosApiExceptionForNetworkError() throws Exception { } @Test - void logout_success() throws Exception { - // First, login successfully + void logout_success_withCookie() throws Exception { + // Mock that we have a cached cookie + when(mockMosApiSessionCache.get(ENTITY_ID)).thenReturn(Optional.of(COOKIE_HEADER)); + when(mockHttpResponse.statusCode()).thenReturn(200); + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(any(HttpClient.class), anyString(), anyMap())) + .thenReturn(mockHttpResponse); + + assertDoesNotThrow(() -> mosApiClient.logout(ENTITY_ID)); + + // Verify the POST request was sent with the cookie + httpUtilsMock.verify( + () -> + HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGOUT_URL), headersCaptor.capture())); + assertThat(headersCaptor.getValue()).containsEntry("Cookie", COOKIE_HEADER); + + // Verify the cache was cleared in the 'finally' block + verify(mockMosApiSessionCache).clear(ENTITY_ID); + } + + @Test + void logout_success_noCookie() throws Exception { + // Mock that we have no cached cookie + when(mockMosApiSessionCache.get(ENTITY_ID)).thenReturn(Optional.empty()); when(mockHttpResponse.statusCode()).thenReturn(200); - when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(any(HttpClient.class), anyString(), anyMap())) .thenReturn(mockHttpResponse); - mosApiClient.login(ENTITY_ID); - // Then, test logout assertDoesNotThrow(() -> mosApiClient.logout(ENTITY_ID)); - verify(mockHttpClient, times(2)).send(httpRequestCaptor.capture(), any()); - HttpRequest logoutRequest = httpRequestCaptor.getValue(); - assertThat(logoutRequest.uri()).isEqualTo(URI.create("https://example.com/ry/test-id/logout")); + + // Verify the POST request was sent *without* a cookie + httpUtilsMock.verify( + () -> + HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGOUT_URL), headersCaptor.capture())); + assertThat(headersCaptor.getValue()).isEmpty(); + + // Verify the cache was cleared + verify(mockMosApiSessionCache).clear(ENTITY_ID); } @Test void logout_failure_logsWarningOn401() throws Exception { - // First, login - HttpResponse loginResponse = mock(HttpResponse.class); - when(loginResponse.statusCode()).thenReturn(200); - when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(loginResponse) - .thenReturn(mockHttpResponse); // For the logout call - mosApiClient.login(ENTITY_ID); - - // Then, mock 401 for logout + when(mockMosApiSessionCache.get(ENTITY_ID)).thenReturn(Optional.of(COOKIE_HEADER)); when(mockHttpResponse.statusCode()).thenReturn(401); when(mockHttpResponse.body()).thenReturn("Session may have already expired"); + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(any(HttpClient.class), anyString(), anyMap())) + .thenReturn(mockHttpResponse); assertDoesNotThrow(() -> mosApiClient.logout(ENTITY_ID)); + // Verify the cache was *still* cleared in the 'finally' block + verify(mockMosApiSessionCache).clear(ENTITY_ID); } @Test - void logout_failure_throwsMosApiExceptionForNetworkError() throws Exception { - // First, login + void executeGetRequest_success_withCachedCookie() throws Exception { + when(mockMosApiSessionCache.get(ENTITY_ID)).thenReturn(Optional.of(COOKIE_HEADER)); when(mockHttpResponse.statusCode()).thenReturn(200); - when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(mockHttpResponse) - .thenThrow(new IOException("Network failed on logout")); - mosApiClient.login(ENTITY_ID); + when(mockHttpResponse.body()).thenReturn("Success Data"); + httpUtilsMock + .when(() -> HttpUtils.sendGetRequest(any(HttpClient.class), anyString(), anyMap())) + .thenReturn(mockHttpResponse); - // Then, test logout failure - MosApiException exception = - assertThrows(MosApiException.class, () -> mosApiClient.logout(ENTITY_ID)); - assertThat(exception).hasMessageThat().isEqualTo("An error occurred during logout."); - assertThat(exception).hasCauseThat().isInstanceOf(IOException.class); + String result = + mosApiClient.executeGetRequest( + ENTITY_ID, "/get-path", Collections.emptyMap(), Collections.emptyMap()); + + assertThat(result).isEqualTo("Success Data"); + // Verify HttpUtils was called with the correct URL and headers + httpUtilsMock.verify( + () -> HttpUtils.sendGetRequest(eq(mockHttpClient), eq(GET_URL), headersCaptor.capture())); + assertThat(headersCaptor.getValue()).containsEntry("Cookie", COOKIE_HEADER); + // Verify login was NOT called (no HttpUtils.sendPostRequest) + httpUtilsMock.verify( + () -> HttpUtils.sendPostRequest(any(HttpClient.class), anyString(), anyMap()), times(0)); + } + + @Test + void executeGetRequest_noCookie_logsInAndSucceeds() throws Exception { + // 1. Mock cache returning empty + when(mockMosApiSessionCache.get(ENTITY_ID)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(COOKIE_HEADER)); // 2. Mock cache returning new cookie after login + + // 3. Mock login response + HttpResponse mockLoginResponse = mock(HttpResponse.class); + when(mockLoginResponse.statusCode()).thenReturn(200); + HttpHeaders mockHeaders = + HttpHeaders.of(Map.of("Set-Cookie", List.of(SET_COOKIE_HEADER)), (a, b) -> true); + when(mockLoginResponse.headers()).thenReturn(mockHeaders); + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGIN_URL), anyMap())) + .thenReturn(mockLoginResponse); + + // 4. Mock GET response + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn("Success Data"); + httpUtilsMock + .when(() -> HttpUtils.sendGetRequest(eq(mockHttpClient), eq(GET_URL), anyMap())) + .thenReturn(mockHttpResponse); + + String result = + mosApiClient.executeGetRequest( + ENTITY_ID, "/get-path", Collections.emptyMap(), Collections.emptyMap()); + + assertThat(result).isEqualTo("Success Data"); + // Verify login was called + httpUtilsMock.verify( + () -> HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGIN_URL), anyMap())); + // Verify cookie was stored + verify(mockMosApiSessionCache).store(ENTITY_ID, COOKIE_HEADER); + // Verify GET was called + httpUtilsMock.verify( + () -> + HttpUtils.sendGetRequest( + eq(mockHttpClient), eq(GET_URL), eq(ImmutableMap.of("Cookie", COOKIE_HEADER)))); + } + + @Test + void executeGetRequest_sessionExpired_relogsInAndSucceeds() throws Exception { + // 1. Mock cache returning expired cookie + when(mockMosApiSessionCache.get(ENTITY_ID)) + .thenReturn(Optional.of(EXPIRED_COOKIE_HEADER)) // 5. Mock cache returning new cookie + .thenReturn(Optional.of(COOKIE_HEADER)); + + // 2. Mock 401 response for GET + HttpResponse mock401Response = mock(HttpResponse.class); + when(mock401Response.statusCode()).thenReturn(401); + // This is the specific "session expired" message + + // 3. Mock 200 response for GET (the retry) + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn("Success Data"); + httpUtilsMock + .when(() -> HttpUtils.sendGetRequest(eq(mockHttpClient), eq(GET_URL), anyMap())) + .thenReturn(mock401Response) // First call fails + .thenReturn(mockHttpResponse); // Second call succeeds + + // 4. Mock login response + HttpResponse mockLoginResponse = mock(HttpResponse.class); + when(mockLoginResponse.statusCode()).thenReturn(200); + HttpHeaders mockHeaders = + HttpHeaders.of(Map.of("Set-Cookie", List.of(SET_COOKIE_HEADER)), (a, b) -> true); + when(mockLoginResponse.headers()).thenReturn(mockHeaders); + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGIN_URL), anyMap())) + .thenReturn(mockLoginResponse); + + String result = + mosApiClient.executeGetRequest( + ENTITY_ID, "/get-path", Collections.emptyMap(), Collections.emptyMap()); + + assertThat(result).isEqualTo("Success Data"); + // Verify GET was called twice + httpUtilsMock.verify( + () -> + HttpUtils.sendGetRequest( + eq(mockHttpClient), + eq(GET_URL), + eq(ImmutableMap.of("Cookie", EXPIRED_COOKIE_HEADER)))); + httpUtilsMock.verify( + () -> + HttpUtils.sendGetRequest( + eq(mockHttpClient), eq(GET_URL), eq(ImmutableMap.of("Cookie", COOKIE_HEADER)))); + // Verify login was called + httpUtilsMock.verify( + () -> HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGIN_URL), anyMap())); + // Verify cookie was stored + verify(mockMosApiSessionCache).store(ENTITY_ID, COOKIE_HEADER); + } + + @Test + void executeGetRequest_reloginFails429_throwsTryAgainLater() throws Exception { + // 1. Mock cache returning no cookie + when(mockMosApiSessionCache.get(ENTITY_ID)).thenReturn(Optional.empty()); + + // 2. Mock 429 response for login + HttpResponse mock429Response = mock(HttpResponse.class); + when(mock429Response.statusCode()).thenReturn(429); + when(mock429Response.body()).thenReturn("Too Many Requests"); + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGIN_URL), anyMap())) + .thenReturn(mock429Response); + + MosApiException e = + assertThrows( + MosApiException.class, + () -> + mosApiClient.executeGetRequest( + ENTITY_ID, "/get-path", Collections.emptyMap(), Collections.emptyMap())); + + assertThat(e).hasMessageThat().isEqualTo("Try running after some time"); + // Verify GET was *not* called + httpUtilsMock.verify( + () -> HttpUtils.sendGetRequest(any(HttpClient.class), anyString(), anyMap()), times(0)); + // Verify login was attempted + httpUtilsMock.verify( + () -> HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGIN_URL), anyMap())); + } + + @Test + void executeGetRequest_reloginFails401_throwsAuthFailed() throws Exception { + // 1. Mock cache returning no cookie + when(mockMosApiSessionCache.get(ENTITY_ID)).thenReturn(Optional.empty()); + + // 2. Mock 401 response for login (Invalid Credentials) + HttpResponse mock401Response = mock(HttpResponse.class); + when(mock401Response.statusCode()).thenReturn(401); + when(mock401Response.body()).thenReturn("Invalid Credentials"); + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGIN_URL), anyMap())) + .thenReturn(mock401Response); + + MosApiException e = + assertThrows( + MosApiException.class, + () -> + mosApiClient.executeGetRequest( + ENTITY_ID, "/get-path", Collections.emptyMap(), Collections.emptyMap())); + + assertThat(e).hasMessageThat().isEqualTo("Automatic re-login failed."); + assertThat(e).hasCauseThat().isInstanceOf(InvalidCredentialsException.class); + assertThat(e.getCause()).hasMessageThat().isEqualTo("Invalid Credentials"); + } + + @Test + void executeGetRequest_any401Error_relogsInAndSucceeds() throws Exception { + // This test verifies that *any* 401 error message triggers a re-login, + // per the new simplified logic in the active MosApiClient.java file. + + // 1. Mock cache returning expired cookie + when(mockMosApiSessionCache.get(ENTITY_ID)) + .thenReturn(Optional.of(EXPIRED_COOKIE_HEADER)) + .thenReturn(Optional.of(COOKIE_HEADER)); // After login + + // 2. Mock 401 response for GET + HttpResponse mock401Response = mock(HttpResponse.class); + when(mock401Response.statusCode()).thenReturn(401); + + // 3. Mock 200 response for GET (the retry) + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn("Success Data"); + httpUtilsMock + .when(() -> HttpUtils.sendGetRequest(eq(mockHttpClient), eq(GET_URL), anyMap())) + .thenReturn(mock401Response) // First call fails + .thenReturn(mockHttpResponse); // Second call succeeds + + // 4. Mock login response + HttpResponse mockLoginResponse = mock(HttpResponse.class); + when(mockLoginResponse.statusCode()).thenReturn(200); + HttpHeaders mockHeaders = + HttpHeaders.of(Map.of("Set-Cookie", List.of(SET_COOKIE_HEADER)), (a, b) -> true); + when(mockLoginResponse.headers()).thenReturn(mockHeaders); + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGIN_URL), anyMap())) + .thenReturn(mockLoginResponse); + + // The call should NOT throw an exception + String result = + assertDoesNotThrow( + () -> + mosApiClient.executeGetRequest( + ENTITY_ID, "/get-path", Collections.emptyMap(), Collections.emptyMap())); + + assertThat(result).isEqualTo("Success Data"); + + // Verify GET was called twice (initial attempt + retry) + httpUtilsMock.verify( + () -> + HttpUtils.sendGetRequest( + eq(mockHttpClient), + eq(GET_URL), + eq(ImmutableMap.of("Cookie", EXPIRED_COOKIE_HEADER)))); + httpUtilsMock.verify( + () -> + HttpUtils.sendGetRequest( + eq(mockHttpClient), eq(GET_URL), eq(ImmutableMap.of("Cookie", COOKIE_HEADER)))); + + // Verify login WAS called + httpUtilsMock.verify( + () -> HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGIN_URL), anyMap()), times(1)); + // Verify cookie was stored + verify(mockMosApiSessionCache).store(ENTITY_ID, COOKIE_HEADER); + } + + @Test + void executePostRequest_success_withCachedCookie() throws Exception { + when(mockMosApiSessionCache.get(ENTITY_ID)).thenReturn(Optional.of(COOKIE_HEADER)); + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn("POST Success"); + httpUtilsMock + .when( + () -> + HttpUtils.sendPostRequest( + any(HttpClient.class), anyString(), anyMap(), anyString())) + .thenReturn(mockHttpResponse); + + String result = + mosApiClient.executePostRequest(ENTITY_ID, "/post-path", POST_BODY, Collections.emptyMap()); + + assertThat(result).isEqualTo("POST Success"); + // Verify sendPostRequest (4-arg) was called + httpUtilsMock.verify( + () -> + HttpUtils.sendPostRequest( + eq(mockHttpClient), eq(POST_URL), headersCaptor.capture(), eq(POST_BODY))); + + // Verify headers + Map capturedHeaders = headersCaptor.getValue(); + assertThat(capturedHeaders).containsEntry("Cookie", COOKIE_HEADER); + assertThat(capturedHeaders).containsEntry("Content-Type", "application/json"); + + // Verify login was NOT called + verify(mockMosApiSessionCache, times(1)).get(ENTITY_ID); + verifyNoMoreInteractions(mockMosApiSessionCache); + } + + @Test + void executePostRequest_noCookie_logsInAndSucceeds() throws Exception { + // 1. Mock cache + when(mockMosApiSessionCache.get(ENTITY_ID)) + .thenReturn(Optional.empty()) // first call + .thenReturn(Optional.of(COOKIE_HEADER)); // second call + + // 2. Mock login response + HttpResponse mockLoginResponse = mock(HttpResponse.class); + when(mockLoginResponse.statusCode()).thenReturn(200); + HttpHeaders mockHeaders = + HttpHeaders.of(Map.of("Set-Cookie", List.of(SET_COOKIE_HEADER)), (a, b) -> true); + when(mockLoginResponse.headers()).thenReturn(mockHeaders); + httpUtilsMock + .when(() -> HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGIN_URL), anyMap())) + .thenReturn(mockLoginResponse); + + // 3. Mock POST response + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn("POST Success"); + httpUtilsMock + .when( + () -> + HttpUtils.sendPostRequest(eq(mockHttpClient), eq(POST_URL), anyMap(), anyString())) + .thenReturn(mockHttpResponse); + + String result = + mosApiClient.executePostRequest(ENTITY_ID, "/post-path", POST_BODY, Collections.emptyMap()); + + assertThat(result).isEqualTo("POST Success"); + // Verify login was called + httpUtilsMock.verify( + () -> HttpUtils.sendPostRequest(eq(mockHttpClient), eq(LOGIN_URL), anyMap())); + // Verify POST was called + httpUtilsMock.verify( + () -> HttpUtils.sendPostRequest(eq(mockHttpClient), eq(POST_URL), anyMap(), eq(POST_BODY))); + } + + @Test + void executePostRequest_noBody() throws Exception { + when(mockMosApiSessionCache.get(ENTITY_ID)).thenReturn(Optional.of(COOKIE_HEADER)); + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn("POST Success"); + httpUtilsMock + .when( + () -> + HttpUtils.sendPostRequest( + any(HttpClient.class), anyString(), anyMap(), anyString())) + .thenReturn(mockHttpResponse); + + String result = + mosApiClient.executePostRequest(ENTITY_ID, "/post-path", null, Collections.emptyMap()); + + assertThat(result).isEqualTo("POST Success"); + // Verify sendPostRequest (4-arg) was called with an empty body + httpUtilsMock.verify( + () -> + HttpUtils.sendPostRequest( + eq(mockHttpClient), eq(POST_URL), headersCaptor.capture(), eq(""))); + + // Verify headers (no Content-Type) + Map capturedHeaders = headersCaptor.getValue(); + assertThat(capturedHeaders).containsEntry("Cookie", COOKIE_HEADER); + assertThat(capturedHeaders).doesNotContainKey("Content-Type"); } } diff --git a/core/src/test/java/google/registry/reporting/mosapi/MosApiSessionCacheModuleTest.java b/core/src/test/java/google/registry/reporting/mosapi/MosApiSessionCacheModuleTest.java new file mode 100644 index 00000000000..b211ce624df --- /dev/null +++ b/core/src/test/java/google/registry/reporting/mosapi/MosApiSessionCacheModuleTest.java @@ -0,0 +1,50 @@ +// 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.reporting.mosapi; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; + +import dagger.Provides; +import google.registry.privileges.secretmanager.SecretManagerClient; +import jakarta.inject.Singleton; +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link MosApiSessionCacheModule}. */ +public class MosApiSessionCacheModuleTest { + @Test + void testProviderMethod_returnsInstance() { + // Mock the dependency + SecretManagerClient mockClient = mock(SecretManagerClient.class); + // Call the static provider method + MosApiSessionCache cache = MosApiSessionCacheModule.provideMosApiSessionCache(mockClient); + // Verify it returns a non-null instance + assertThat(cache).isNotNull(); + assertThat(cache).isInstanceOf(MosApiSessionCache.class); + } + + @Test + void testProviderMethod_hasCorrectAnnotations() throws Exception { + // This test ensures the Dagger annotations are present, which is important for + // the injection framework. + Method providerMethod = + MosApiSessionCacheModule.class.getDeclaredMethod( + "provideMosApiSessionCache", SecretManagerClient.class); + + assertThat(providerMethod.isAnnotationPresent(Provides.class)).isTrue(); + assertThat(providerMethod.isAnnotationPresent(Singleton.class)).isTrue(); + } +} diff --git a/core/src/test/java/google/registry/reporting/mosapi/MosApiSessionCacheTest.java b/core/src/test/java/google/registry/reporting/mosapi/MosApiSessionCacheTest.java new file mode 100644 index 00000000000..1d7a7fa5885 --- /dev/null +++ b/core/src/test/java/google/registry/reporting/mosapi/MosApiSessionCacheTest.java @@ -0,0 +1,111 @@ +// 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.reporting.mosapi; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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 google.registry.privileges.secretmanager.SecretManagerClient; +import java.util.Optional; +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 MosApiSessionCache}. */ +@ExtendWith(MockitoExtension.class) +public class MosApiSessionCacheTest { + private static final String ENTITY_ID = "test-tld"; + private static final String COOKIE_VALUE = "id=test-cookie-123"; + private static final String SECRET_NAME = "mosapi_session_cookie_test-tld"; + + @Mock private SecretManagerClient mockSecretManagerClient; + + private MosApiSessionCache mosApiSessionCache; + + @BeforeEach + void setUp() { + mosApiSessionCache = new MosApiSessionCache(mockSecretManagerClient); + } + + @Test + void get_success_cookieExists() { + when(mockSecretManagerClient.getSecretData(SECRET_NAME, Optional.of("latest"))) + .thenReturn(COOKIE_VALUE); + Optional cookie = mosApiSessionCache.get(ENTITY_ID); + assertThat(cookie).isPresent(); + assertThat(cookie.get()).isEqualTo(COOKIE_VALUE); + verify(mockSecretManagerClient).getSecretData(SECRET_NAME, Optional.of("latest")); + } + + @Test + void get_cookieIsEmpty_returnsEmpty() { + when(mockSecretManagerClient.getSecretData(SECRET_NAME, Optional.of("latest"))).thenReturn(""); + Optional cookie = mosApiSessionCache.get(ENTITY_ID); + assertThat(cookie).isEmpty(); + } + + @Test + void get_cookieIsNull_returnsEmpty() { + when(mockSecretManagerClient.getSecretData(SECRET_NAME, Optional.of("latest"))) + .thenReturn(null); + Optional cookie = mosApiSessionCache.get(ENTITY_ID); + assertThat(cookie).isEmpty(); + } + + @Test + void get_secretNotFound_exceptionHandled() { + when(mockSecretManagerClient.getSecretData(SECRET_NAME, Optional.of("latest"))) + .thenThrow(new RuntimeException("Secret not found")); + // The exception should be caught and logged, returning empty. + Optional cookie = mosApiSessionCache.get(ENTITY_ID); + assertThat(cookie).isEmpty(); + } + + @Test + void store_success() { + // A successful call to the (void) mock method will do nothing. + assertDoesNotThrow(() -> mosApiSessionCache.store(ENTITY_ID, COOKIE_VALUE)); + // Verify the client was called with the correct secret name and value. + verify(mockSecretManagerClient).addSecretVersion(SECRET_NAME, COOKIE_VALUE); + } + + @Test + void store_failure_throwsRuntimeException() { + doThrow(new RuntimeException("Permission denied")) + .when(mockSecretManagerClient) + .addSecretVersion(SECRET_NAME, COOKIE_VALUE); + + RuntimeException e = + assertThrows( + RuntimeException.class, () -> mosApiSessionCache.store(ENTITY_ID, COOKIE_VALUE)); + + assertThat(e).hasMessageThat().isEqualTo("Failed to store session cookie in Secret Manager."); + assertThat(e).hasCauseThat().isInstanceOf(RuntimeException.class); + assertThat(e.getCause()).hasMessageThat().isEqualTo("Permission denied"); + } + + @Test + void clear_callsStoreWithEmptyString() { + assertDoesNotThrow(() -> mosApiSessionCache.clear(ENTITY_ID)); + // Verify store() was called with the correct secret name and an empty string + verify(mockSecretManagerClient).addSecretVersion(SECRET_NAME, ""); + } +} diff --git a/util/src/main/java/google/registry/util/HttpUtils.java b/util/src/main/java/google/registry/util/HttpUtils.java index d804d5db428..9237eac854c 100644 --- a/util/src/main/java/google/registry/util/HttpUtils.java +++ b/util/src/main/java/google/registry/util/HttpUtils.java @@ -94,6 +94,34 @@ public static HttpResponse sendPostRequest( return send(httpClient, requestBuilder.build()); } + /** + * Sends an HTTP POST request with a String body and custom headers to the specified URL. + * + * @param httpClient the {@link HttpClient} to use for sending the request + * @param url the target URL + * @param headers a {@link Map} of header keys and values to add to the request + * @param body the String request body to send (can be null or empty for no body) + * @return the {@link HttpResponse} as a String + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the request is interrupted + */ + public static HttpResponse sendPostRequest( + HttpClient httpClient, String url, Map headers, String body) + throws IOException, InterruptedException { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(url)); + + if (body == null || body.isEmpty()) { + requestBuilder.POST(HttpRequest.BodyPublishers.noBody()); + } else { + requestBuilder.POST(HttpRequest.BodyPublishers.ofString(body)); + } + + for (Map.Entry header : headers.entrySet()) { + requestBuilder.header(header.getKey(), header.getValue()); + } + return send(httpClient, requestBuilder.build()); + } + /** * Sends a pre-built {@link HttpRequest} and handles exceptions. * diff --git a/util/src/test/java/google/registry/util/HttpUtilsTest.java b/util/src/test/java/google/registry/util/HttpUtilsTest.java index 72e8a071dd6..926248f76e6 100644 --- a/util/src/test/java/google/registry/util/HttpUtilsTest.java +++ b/util/src/test/java/google/registry/util/HttpUtilsTest.java @@ -17,14 +17,19 @@ 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.verify; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import com.google.common.collect.ImmutableMap; import java.io.IOException; +import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; +import org.apache.http.HttpException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -34,57 +39,166 @@ @ExtendWith(MockitoExtension.class) public class HttpUtilsTest { + private static final String TEST_URL = "https://example.com/test"; + @Mock private HttpClient mockHttpClient; @Mock private HttpResponse mockHttpResponse; - @Captor private ArgumentCaptor requestCaptor; + @Captor private ArgumentCaptor httpRequestCaptor; @Test - void sendGetRequest_success_returnsResponse() throws Exception { - when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + void sendGetRequest_noHeaders_success() throws IOException, InterruptedException { + when(mockHttpClient.send(httpRequestCaptor.capture(), eq(HttpResponse.BodyHandlers.ofString()))) .thenReturn(mockHttpResponse); - HttpResponse response = HttpUtils.sendGetRequest(mockHttpClient, "https://example.com"); + + HttpResponse response = HttpUtils.sendGetRequest(mockHttpClient, TEST_URL); + assertThat(response).isSameInstanceAs(mockHttpResponse); + HttpRequest request = httpRequestCaptor.getValue(); + + assertThat(request.uri()).isEqualTo(URI.create(TEST_URL)); + assertThat(request.method()).isEqualTo("GET"); + assertThat(request.headers().map()).isEmpty(); } @Test - void sendPostRequest_success_returnsResponse() throws Exception { - when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + void sendGetRequest_withHeaders_success() throws IOException, InterruptedException { + when(mockHttpClient.send(httpRequestCaptor.capture(), eq(HttpResponse.BodyHandlers.ofString()))) + .thenReturn(mockHttpResponse); + Map headers = + ImmutableMap.of("Auth", "Bearer token", "Content-Type", "application/json"); + + HttpResponse response = HttpUtils.sendGetRequest(mockHttpClient, TEST_URL, headers); + + assertThat(response).isSameInstanceAs(mockHttpResponse); + HttpRequest request = httpRequestCaptor.getValue(); + + assertThat(request.uri()).isEqualTo(URI.create(TEST_URL)); + assertThat(request.method()).isEqualTo("GET"); + assertThat(request.headers().firstValue("Auth")).hasValue("Bearer token"); + assertThat(request.headers().firstValue("Content-Type")).hasValue("application/json"); + } + + @Test + void sendPostRequest_noBody_noHeaders_success() + throws HttpException, IOException, InterruptedException { + when(mockHttpClient.send(httpRequestCaptor.capture(), eq(HttpResponse.BodyHandlers.ofString()))) + .thenReturn(mockHttpResponse); + + HttpResponse response = HttpUtils.sendPostRequest(mockHttpClient, TEST_URL); + + assertThat(response).isSameInstanceAs(mockHttpResponse); + HttpRequest request = httpRequestCaptor.getValue(); + + assertThat(request.uri()).isEqualTo(URI.create(TEST_URL)); + assertThat(request.method()).isEqualTo("POST"); + assertThat(request.headers().map()).isEmpty(); + assertThat(request.bodyPublisher()).isPresent(); + assertThat(request.bodyPublisher().get().contentLength()).isEqualTo(0); + } + + @Test + void sendPostRequest_noBody_withHeaders_success() throws IOException, InterruptedException { + when(mockHttpClient.send(httpRequestCaptor.capture(), eq(HttpResponse.BodyHandlers.ofString()))) + .thenReturn(mockHttpResponse); + Map headers = ImmutableMap.of("X-Request-ID", "12345"); + + HttpResponse response = HttpUtils.sendPostRequest(mockHttpClient, TEST_URL, headers); + + assertThat(response).isSameInstanceAs(mockHttpResponse); + HttpRequest request = httpRequestCaptor.getValue(); + + assertThat(request.uri()).isEqualTo(URI.create(TEST_URL)); + assertThat(request.method()).isEqualTo("POST"); + assertThat(request.headers().firstValue("X-Request-ID")).hasValue("12345"); + assertThat(request.bodyPublisher()).isPresent(); + assertThat(request.bodyPublisher().get().contentLength()).isEqualTo(0); + } + + @Test + void sendPostRequest_withBody_success() throws IOException, InterruptedException { + when(mockHttpClient.send(httpRequestCaptor.capture(), eq(HttpResponse.BodyHandlers.ofString()))) .thenReturn(mockHttpResponse); + Map headers = ImmutableMap.of("Content-Type", "application/json"); + String body = "{\"key\":\"value\"}"; + HttpResponse response = - HttpUtils.sendPostRequest(mockHttpClient, "https://example.com"); + HttpUtils.sendPostRequest(mockHttpClient, TEST_URL, headers, body); + assertThat(response).isSameInstanceAs(mockHttpResponse); + HttpRequest request = httpRequestCaptor.getValue(); + + assertThat(request.uri()).isEqualTo(URI.create(TEST_URL)); + assertThat(request.method()).isEqualTo("POST"); + assertThat(request.headers().firstValue("Content-Type")).hasValue("application/json"); + assertThat(request.bodyPublisher()).isPresent(); + // Corrected line with StandardCharsets.UTF_8 + assertThat(request.bodyPublisher().get().contentLength()) + .isEqualTo(body.getBytes(StandardCharsets.UTF_8).length); } @Test - void sendPostRequest_withHeaders_headersAreSet() throws Exception { - when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + void sendPostRequest_withNullBody_success() throws IOException, InterruptedException { + when(mockHttpClient.send(httpRequestCaptor.capture(), eq(HttpResponse.BodyHandlers.ofString()))) + .thenReturn(mockHttpResponse); + Map headers = ImmutableMap.of("X-Request-ID", "abc"); + + HttpResponse response = + HttpUtils.sendPostRequest(mockHttpClient, TEST_URL, headers, null); + + assertThat(response).isSameInstanceAs(mockHttpResponse); + HttpRequest request = httpRequestCaptor.getValue(); + + assertThat(request.uri()).isEqualTo(URI.create(TEST_URL)); + assertThat(request.method()).isEqualTo("POST"); + assertThat(request.headers().firstValue("X-Request-ID")).hasValue("abc"); + assertThat(request.bodyPublisher()).isPresent(); + assertThat(request.bodyPublisher().get().contentLength()).isEqualTo(0); + } + + @Test + void sendPostRequest_withEmptyBody_success() throws IOException, InterruptedException { + when(mockHttpClient.send(httpRequestCaptor.capture(), eq(HttpResponse.BodyHandlers.ofString()))) .thenReturn(mockHttpResponse); - HttpUtils.sendPostRequest( - mockHttpClient, "https://example.com", ImmutableMap.of("Authorization", "Basic 12345")); - verify(mockHttpClient).send(requestCaptor.capture(), any()); - assertThat(requestCaptor.getValue().headers().firstValue("Authorization")) - .hasValue("Basic 12345"); + Map headers = Collections.emptyMap(); + + HttpResponse response = + HttpUtils.sendPostRequest(mockHttpClient, TEST_URL, headers, ""); + + assertThat(response).isSameInstanceAs(mockHttpResponse); + HttpRequest request = httpRequestCaptor.getValue(); + + assertThat(request.uri()).isEqualTo(URI.create(TEST_URL)); + assertThat(request.method()).isEqualTo("POST"); + assertThat(request.headers().map()).isEmpty(); + assertThat(request.bodyPublisher()).isPresent(); + assertThat(request.bodyPublisher().get().contentLength()).isEqualTo(0); } @Test - void send_ioException_throwsIOException() throws Exception { + void send_throwsIOException() throws IOException, InterruptedException { when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenThrow(new IOException("Network failed")); - IOException e = - assertThrows( - IOException.class, - () -> HttpUtils.sendGetRequest(mockHttpClient, "https://example.com")); - assertThat(e).hasMessageThat().isEqualTo("Network failed"); + + assertThrows( + IOException.class, + () -> HttpUtils.sendGetRequest(mockHttpClient, TEST_URL, Collections.emptyMap())); + + assertThrows( + IOException.class, + () -> HttpUtils.sendPostRequest(mockHttpClient, TEST_URL, Collections.emptyMap(), "body")); } @Test - void send_interruptedException_throwsInterruptedException() throws Exception { + void send_throwsInterruptedException() throws IOException, InterruptedException { when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenThrow(new InterruptedException("Request interrupted")); - InterruptedException e = - assertThrows( - InterruptedException.class, - () -> HttpUtils.sendGetRequest(mockHttpClient, "https://example.com")); - assertThat(e).hasMessageThat().isEqualTo("Request interrupted"); + .thenThrow(new InterruptedException("Request cancelled")); + + assertThrows( + InterruptedException.class, + () -> HttpUtils.sendGetRequest(mockHttpClient, TEST_URL, Collections.emptyMap())); + + assertThrows( + InterruptedException.class, + () -> HttpUtils.sendPostRequest(mockHttpClient, TEST_URL, Collections.emptyMap(), "body")); } }