From 0c123e167638ac8cd54b6a9a559e22e53b3d1ca8 Mon Sep 17 00:00:00 2001 From: Pavlo Tkach <3469726+ptkach@users.noreply.github.com> Date: Fri, 31 May 2024 15:20:56 -0400 Subject: [PATCH] Unify email notifications for console updates (#2459) --- .../registry/ui/server/SendEmailUtils.java | 2 +- .../ui/server/console/ConsoleApiAction.java | 125 ++++++++++++++++++ .../console/ConsoleEppPasswordAction.java | 25 ++-- .../console/ConsoleUpdateRegistrarAction.java | 81 ++++-------- .../console/settings/ContactAction.java | 12 +- .../console/settings/SecurityAction.java | 14 +- .../settings/WhoisRegistrarFieldsAction.java | 23 +++- .../ui/server/registrar/ConsoleApiParams.java | 5 +- .../registrar/RegistrarConsoleModule.java | 4 +- .../testing/ConsoleApiParamsUtils.java | 9 +- .../console/ConsoleEppPasswordActionTest.java | 42 +++--- .../ConsoleUpdateRegistrarActionTest.java | 45 ++++--- .../console/settings/ContactActionTest.java | 83 +++++++++++- .../java/google/registry/util/DiffUtils.java | 2 +- 14 files changed, 337 insertions(+), 135 deletions(-) diff --git a/core/src/main/java/google/registry/ui/server/SendEmailUtils.java b/core/src/main/java/google/registry/ui/server/SendEmailUtils.java index 071826a53..7fcfdabe0 100644 --- a/core/src/main/java/google/registry/ui/server/SendEmailUtils.java +++ b/core/src/main/java/google/registry/ui/server/SendEmailUtils.java @@ -35,7 +35,7 @@ import javax.mail.internet.InternetAddress; public class SendEmailUtils { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); - private final GmailClient gmailClient; + public final GmailClient gmailClient; private final ImmutableList registrarChangesNotificationEmailAddresses; @Inject diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleApiAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleApiAction.java index 54e0a4bc5..63fba82b3 100644 --- a/core/src/main/java/google/registry/ui/server/console/ConsoleApiAction.java +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleApiAction.java @@ -14,26 +14,44 @@ package google.registry.ui.server.console; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableSet.toImmutableSet; import static google.registry.request.Action.Method.GET; import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST; import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; import static jakarta.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; +import com.google.common.base.Ascii; import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; import com.google.common.flogger.FluentLogger; +import google.registry.batch.CloudTasksUtils; +import google.registry.export.sheet.SyncRegistrarsSheetAction; import google.registry.model.console.ConsolePermission; import google.registry.model.console.GlobalRole; import google.registry.model.console.User; +import google.registry.model.registrar.Registrar; +import google.registry.model.registrar.RegistrarPoc; +import google.registry.model.registrar.RegistrarPocBase; +import google.registry.request.Action.Service; import google.registry.request.HttpException; import google.registry.security.XsrfTokenManager; import google.registry.ui.server.registrar.ConsoleApiParams; import google.registry.ui.server.registrar.ConsoleUiAction; +import google.registry.util.DiffUtils; import google.registry.util.RegistryEnvironment; import jakarta.servlet.http.Cookie; import java.io.IOException; import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; +import javax.inject.Inject; /** Base class for handling Console API requests */ public abstract class ConsoleApiAction implements Runnable { @@ -42,6 +60,8 @@ public abstract class ConsoleApiAction implements Runnable { protected ConsoleApiParams consoleApiParams; + @Inject CloudTasksUtils cloudTasksUtils; + public ConsoleApiAction(ConsoleApiParams consoleApiParams) { this.consoleApiParams = consoleApiParams; } @@ -124,10 +144,115 @@ public abstract class ConsoleApiAction implements Runnable { return true; } + private Map expandRegistrarWithContacts( + ImmutableSet contacts, Registrar registrar) { + + ImmutableSet> expandedContacts = + contacts.stream() + .map(RegistrarPoc::toDiffableFieldMap) + // Note: per the javadoc, toDiffableFieldMap includes sensitive data, but we don't want + // to display it here + .peek( + map -> { + map.remove("registryLockPasswordHash"); + map.remove("registryLockPasswordSalt"); + }) + .collect(toImmutableSet()); + + Map registrarDiffMap = registrar.toDiffableFieldMap(); + Stream.of("passwordHash", "salt") // fields to remove from final diff + .forEach(fieldToBeRemoved -> registrarDiffMap.remove(fieldToBeRemoved)); + + // Use LinkedHashMap here to preserve ordering; null values mean we can't use ImmutableMap. + LinkedHashMap result = new LinkedHashMap<>(registrarDiffMap); + result.put("contacts", expandedContacts); + return result; + } + + protected void sendExternalUpdates( + Map diffs, Registrar registrar, ImmutableSet contacts) { + + if (!consoleApiParams.sendEmailUtils().hasRecipients() && contacts.isEmpty()) { + return; + } + + if (!RegistryEnvironment.UNITTEST.equals(RegistryEnvironment.get()) + && cloudTasksUtils != null) { + // Enqueues a sync registrar sheet task if enqueuing is not triggered by console tests and + // there's an update besides the lastUpdateTime + cloudTasksUtils.enqueue( + SyncRegistrarsSheetAction.QUEUE, + cloudTasksUtils.createGetTask( + SyncRegistrarsSheetAction.PATH, Service.BACKEND, ImmutableMultimap.of())); + } + + String environment = Ascii.toLowerCase(String.valueOf(RegistryEnvironment.get())); + consoleApiParams + .sendEmailUtils() + .sendEmail( + String.format( + "Registrar %s (%s) updated in registry %s environment", + registrar.getRegistrarName(), registrar.getRegistrarId(), environment), + String.format( + """ + The following changes were made in registry %s environment to the registrar %s by\ + %s: + + %s""", + environment, + registrar.getRegistrarId(), + consoleApiParams.authResult().userIdForLogging(), + DiffUtils.prettyPrintDiffedMap(diffs, null)), + contacts.stream() + .filter(c -> c.getTypes().contains(RegistrarPocBase.Type.ADMIN)) + .map(RegistrarPoc::getEmailAddress) + .collect(toImmutableList())); + } + + /** + * Determines if any changes were made to the registrar besides the lastUpdateTime, and if so, + * sends an email with a diff of the changes to the configured notification email address and all + * contact addresses and enqueues a task to re-sync the registrar sheet. + */ + protected void sendExternalUpdatesIfNecessary(EmailInfo emailInfo) { + ImmutableSet existingContacts = emailInfo.contacts(); + Registrar existingRegistrar = emailInfo.registrar(); + + Map diffs = + DiffUtils.deepDiff( + expandRegistrarWithContacts(existingContacts, existingRegistrar), + expandRegistrarWithContacts(emailInfo.updatedContacts(), emailInfo.updatedRegistrar()), + true); + + @SuppressWarnings("unchecked") + Set changedKeys = (Set) diffs.keySet(); + if (Sets.difference(changedKeys, ImmutableSet.of("lastUpdateTime")).isEmpty()) { + return; + } + + sendExternalUpdates(diffs, existingRegistrar, existingContacts); + } + + protected record EmailInfo( + Registrar registrar, + Registrar updatedRegistrar, + ImmutableSet contacts, + ImmutableSet updatedContacts) { + + public static EmailInfo create( + Registrar registrar, + Registrar updatedRegistrar, + ImmutableSet contacts, + ImmutableSet updatedContacts) { + return new EmailInfo(registrar, updatedRegistrar, contacts, updatedContacts); + } + } + /** Specialized exception class used for failure when a user doesn't have the right permission. */ private static class ConsolePermissionForbiddenException extends RuntimeException { private ConsolePermissionForbiddenException(String message) { super(message); } } + } diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleEppPasswordAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleEppPasswordAction.java index d748ac8eb..d4d69d145 100644 --- a/core/src/main/java/google/registry/ui/server/console/ConsoleEppPasswordAction.java +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleEppPasswordAction.java @@ -22,10 +22,11 @@ import static jakarta.servlet.http.HttpServletResponse.SC_NOT_FOUND; import static jakarta.servlet.http.HttpServletResponse.SC_OK; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.gson.annotations.Expose; import google.registry.flows.EppException.AuthenticationErrorException; import google.registry.flows.PasswordOnlyTransportCredentials; -import google.registry.groups.GmailClient; import google.registry.model.console.User; import google.registry.model.registrar.Registrar; import google.registry.request.Action; @@ -34,10 +35,9 @@ import google.registry.request.auth.Auth; import google.registry.request.auth.AuthenticatedRegistrarAccessor; import google.registry.request.auth.AuthenticatedRegistrarAccessor.RegistrarAccessDeniedException; import google.registry.ui.server.registrar.ConsoleApiParams; -import google.registry.util.EmailMessage; +import google.registry.util.DiffUtils; import java.util.Optional; import javax.inject.Inject; -import javax.mail.internet.InternetAddress; @Action( service = Action.Service.DEFAULT, @@ -45,16 +45,12 @@ import javax.mail.internet.InternetAddress; method = {POST}, auth = Auth.AUTH_PUBLIC_LOGGED_IN) public class ConsoleEppPasswordAction extends ConsoleApiAction { - protected static final String EMAIL_SUBJ = "EPP password update confirmation"; - protected static final String EMAIL_BODY = - "Dear %s,\n" + "This is to confirm that your account password has been changed."; public static final String PATH = "/console-api/eppPassword"; private final PasswordOnlyTransportCredentials credentials = new PasswordOnlyTransportCredentials(); private final AuthenticatedRegistrarAccessor registrarAccessor; - private final GmailClient gmailClient; private final Optional eppPasswordChangeRequest; @@ -62,11 +58,9 @@ public class ConsoleEppPasswordAction extends ConsoleApiAction { public ConsoleEppPasswordAction( ConsoleApiParams consoleApiParams, AuthenticatedRegistrarAccessor registrarAccessor, - GmailClient gmailClient, @Parameter("eppPasswordChangeRequest") Optional eppPasswordChangeRequest) { super(consoleApiParams); this.registrarAccessor = registrarAccessor; - this.gmailClient = gmailClient; this.eppPasswordChangeRequest = eppPasswordChangeRequest; } @@ -107,12 +101,13 @@ public class ConsoleEppPasswordAction extends ConsoleApiAction { tm().transact( () -> { - tm().put(registrar.asBuilder().setPassword(eppRequestBody.newPassword()).build()); - this.gmailClient.sendEmail( - EmailMessage.create( - EMAIL_SUBJ, - String.format(EMAIL_BODY, registrar.getRegistrarName()), - new InternetAddress(registrar.getEmailAddress(), true))); + Registrar updatedRegistrar = + registrar.asBuilder().setPassword(eppRequestBody.newPassword()).build(); + tm().put(updatedRegistrar); + sendExternalUpdates( + ImmutableMap.of("password", new DiffUtils.DiffPair("********", "••••••••")), + registrar, + ImmutableSet.of()); }); consoleApiParams.response().setStatus(SC_OK); diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleUpdateRegistrarAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleUpdateRegistrarAction.java index 670f3abf9..95de960c9 100644 --- a/core/src/main/java/google/registry/ui/server/console/ConsoleUpdateRegistrarAction.java +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleUpdateRegistrarAction.java @@ -21,7 +21,7 @@ import static google.registry.util.PreconditionsUtils.checkArgumentPresent; import static org.apache.http.HttpStatus.SC_OK; import com.google.common.base.Strings; -import google.registry.groups.GmailClient; +import com.google.common.collect.ImmutableSet; import google.registry.model.console.ConsolePermission; import google.registry.model.console.User; import google.registry.model.registrar.Registrar; @@ -31,13 +31,10 @@ import google.registry.request.Parameter; import google.registry.request.auth.Auth; import google.registry.ui.server.registrar.ConsoleApiParams; import google.registry.util.DomainNameUtils; -import google.registry.util.EmailMessage; import google.registry.util.RegistryEnvironment; import java.util.Optional; import java.util.stream.Collectors; import javax.inject.Inject; -import javax.mail.internet.AddressException; -import javax.mail.internet.InternetAddress; @Action( service = Action.Service.DEFAULT, @@ -46,45 +43,37 @@ import javax.mail.internet.InternetAddress; auth = Auth.AUTH_PUBLIC_LOGGED_IN) public class ConsoleUpdateRegistrarAction extends ConsoleApiAction { static final String PATH = "/console-api/registrar"; - private static final String EMAIL_SUBJ = "Registrar %s has been updated"; - private static final String EMAIL_BODY = - "The following changes were made in registry %s environment to the registrar %s:"; private final Optional registrar; - private final GmailClient gmailClient; - @Inject ConsoleUpdateRegistrarAction( ConsoleApiParams consoleApiParams, - GmailClient gmailClient, @Parameter("registrar") Optional registrar) { super(consoleApiParams); this.registrar = registrar; - this.gmailClient = gmailClient; } @Override protected void postHandler(User user) { var errorMsg = "Missing param(s): %s"; - Registrar updatedRegistrar = + Registrar registrarParam = registrar.orElseThrow(() -> new BadRequestException(String.format(errorMsg, "registrar"))); - checkArgument( - !Strings.isNullOrEmpty(updatedRegistrar.getRegistrarId()), errorMsg, "registrarId"); + checkArgument(!Strings.isNullOrEmpty(registrarParam.getRegistrarId()), errorMsg, "registrarId"); checkPermission( - user, updatedRegistrar.getRegistrarId(), ConsolePermission.EDIT_REGISTRAR_DETAILS); + user, registrarParam.getRegistrarId(), ConsolePermission.EDIT_REGISTRAR_DETAILS); tm().transact( () -> { Optional existingRegistrar = - Registrar.loadByRegistrarId(updatedRegistrar.getRegistrarId()); + Registrar.loadByRegistrarId(registrarParam.getRegistrarId()); checkArgument( !existingRegistrar.isEmpty(), "Registrar with registrarId %s doesn't exists", - updatedRegistrar.getRegistrarId()); + registrarParam.getRegistrarId()); // Only allow modifying allowed TLDs if we're in a non-PRODUCTION environment, if the // registrar is not REAL, or the registrar has a WHOIS abuse contact set. - if (!updatedRegistrar.getAllowedTlds().isEmpty()) { + if (!registrarParam.getAllowedTlds().isEmpty()) { boolean isRealRegistrar = Registrar.Type.REAL.equals(existingRegistrar.get().getType()); if (RegistryEnvironment.PRODUCTION.equals(RegistryEnvironment.get()) @@ -97,49 +86,27 @@ public class ConsoleUpdateRegistrarAction extends ConsoleApiAction { } } - tm().put( - existingRegistrar - .get() - .asBuilder() - .setAllowedTlds( - updatedRegistrar.getAllowedTlds().stream() - .map(DomainNameUtils::canonicalizeHostname) - .collect(Collectors.toSet())) - .setRegistryLockAllowed(updatedRegistrar.isRegistryLockAllowed()) - .build()); + Registrar updatedRegistrar = + existingRegistrar + .get() + .asBuilder() + .setAllowedTlds( + registrarParam.getAllowedTlds().stream() + .map(DomainNameUtils::canonicalizeHostname) + .collect(Collectors.toSet())) + .setRegistryLockAllowed(registrarParam.isRegistryLockAllowed()) + .build(); - sendEmail(existingRegistrar.get(), updatedRegistrar); + tm().put(updatedRegistrar); + sendExternalUpdatesIfNecessary( + EmailInfo.create( + existingRegistrar.get(), + updatedRegistrar, + ImmutableSet.of(), + ImmutableSet.of())); }); consoleApiParams.response().setStatus(SC_OK); } - void sendEmail(Registrar oldRegistrar, Registrar updatedRegistrar) throws AddressException { - String emailBody = - String.format(EMAIL_BODY, RegistryEnvironment.get(), oldRegistrar.getRegistrarId()); - - StringBuilder diff = new StringBuilder(); - if (oldRegistrar.isRegistryLockAllowed() != updatedRegistrar.isRegistryLockAllowed()) { - diff.append("/n"); - diff.append( - String.format( - "Registry Lock Allowed: %s -> %s", - oldRegistrar.isRegistryLockAllowed(), updatedRegistrar.isRegistryLockAllowed())); - } - if (!oldRegistrar.getAllowedTlds().equals(updatedRegistrar.getAllowedTlds())) { - diff.append("/n"); - diff.append( - String.format( - "Allowed TLDs: %s -> %s", - oldRegistrar.getAllowedTlds(), updatedRegistrar.getAllowedTlds())); - } - - if (diff.length() > 0) { - this.gmailClient.sendEmail( - EmailMessage.create( - String.format(EMAIL_SUBJ, oldRegistrar.getRegistrarId()), - emailBody + diff, - new InternetAddress(oldRegistrar.getEmailAddress(), true))); - } - } } diff --git a/core/src/main/java/google/registry/ui/server/console/settings/ContactAction.java b/core/src/main/java/google/registry/ui/server/console/settings/ContactAction.java index 2c8b8ee1d..a8ab55517 100644 --- a/core/src/main/java/google/registry/ui/server/console/settings/ContactAction.java +++ b/core/src/main/java/google/registry/ui/server/console/settings/ContactAction.java @@ -103,6 +103,7 @@ public class ContactAction extends ConsoleApiAction { Collections.singletonMap( "contacts", contacts.get().stream().map(RegistrarPoc::toJsonMap).collect(toImmutableList()))); + try { RegistrarSettingsAction.checkContactRequirements(oldContacts, updatedContacts); } catch (FormException e) { @@ -111,7 +112,16 @@ public class ContactAction extends ConsoleApiAction { throw new IllegalArgumentException(e); } - RegistrarPoc.updateContacts(registrar, updatedContacts); + tm().transact( + () -> { + RegistrarPoc.updateContacts(registrar, updatedContacts); + Registrar updatedRegistrar = + registrar.asBuilder().setContactsRequireSyncing(true).build(); + tm().put(updatedRegistrar); + sendExternalUpdatesIfNecessary( + EmailInfo.create(registrar, updatedRegistrar, oldContacts, updatedContacts)); + }); + consoleApiParams.response().setStatus(SC_OK); } } diff --git a/core/src/main/java/google/registry/ui/server/console/settings/SecurityAction.java b/core/src/main/java/google/registry/ui/server/console/settings/SecurityAction.java index 4d98222f1..0984a65c0 100644 --- a/core/src/main/java/google/registry/ui/server/console/settings/SecurityAction.java +++ b/core/src/main/java/google/registry/ui/server/console/settings/SecurityAction.java @@ -21,6 +21,7 @@ import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST; import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; import static jakarta.servlet.http.HttpServletResponse.SC_OK; +import com.google.common.collect.ImmutableSet; import google.registry.flows.certs.CertificateChecker; import google.registry.flows.certs.CertificateChecker.InsecureCertificateException; import google.registry.model.console.ConsolePermission; @@ -81,7 +82,7 @@ public class SecurityAction extends ConsoleApiAction { private void setResponse(Registrar savedRegistrar) { Registrar registrarParameter = registrar.get(); - Registrar.Builder updatedRegistrar = + Registrar.Builder updatedRegistrarBuilder = savedRegistrar .asBuilder() .setIpAddressAllowList(registrarParameter.getIpAddressAllowList()); @@ -93,7 +94,7 @@ public class SecurityAction extends ConsoleApiAction { if (registrarParameter.getClientCertificate().isPresent()) { String newClientCert = registrarParameter.getClientCertificate().get(); certificateChecker.validateCertificate(newClientCert); - updatedRegistrar.setClientCertificate(newClientCert, tm().getTransactionTime()); + updatedRegistrarBuilder.setClientCertificate(newClientCert, tm().getTransactionTime()); } } if (!savedRegistrar @@ -102,7 +103,8 @@ public class SecurityAction extends ConsoleApiAction { if (registrarParameter.getFailoverClientCertificate().isPresent()) { String newFailoverCert = registrarParameter.getFailoverClientCertificate().get(); certificateChecker.validateCertificate(newFailoverCert); - updatedRegistrar.setFailoverClientCertificate(newFailoverCert, tm().getTransactionTime()); + updatedRegistrarBuilder.setFailoverClientCertificate( + newFailoverCert, tm().getTransactionTime()); } } } catch (InsecureCertificateException e) { @@ -110,7 +112,11 @@ public class SecurityAction extends ConsoleApiAction { return; } - tm().put(updatedRegistrar.build()); + Registrar updatedRegistrar = updatedRegistrarBuilder.build(); + tm().put(updatedRegistrar); + + sendExternalUpdatesIfNecessary( + EmailInfo.create(savedRegistrar, updatedRegistrar, ImmutableSet.of(), ImmutableSet.of())); consoleApiParams.response().setStatus(SC_OK); } } diff --git a/core/src/main/java/google/registry/ui/server/console/settings/WhoisRegistrarFieldsAction.java b/core/src/main/java/google/registry/ui/server/console/settings/WhoisRegistrarFieldsAction.java index 189a2ff6d..a08e90707 100644 --- a/core/src/main/java/google/registry/ui/server/console/settings/WhoisRegistrarFieldsAction.java +++ b/core/src/main/java/google/registry/ui/server/console/settings/WhoisRegistrarFieldsAction.java @@ -78,13 +78,22 @@ public class WhoisRegistrarFieldsAction extends ConsoleApiAction { return; } - Registrar.Builder newRegistrar = savedRegistrar.asBuilder(); - newRegistrar.setWhoisServer(providedRegistrar.getWhoisServer()); - newRegistrar.setUrl(providedRegistrar.getUrl()); - newRegistrar.setLocalizedAddress(providedRegistrar.getLocalizedAddress()); - newRegistrar.setPhoneNumber(providedRegistrar.getPhoneNumber()); - newRegistrar.setFaxNumber(providedRegistrar.getFaxNumber()); - tm().put(newRegistrar.build()); + Registrar newRegistrar = + savedRegistrar + .asBuilder() + .setWhoisServer(providedRegistrar.getWhoisServer()) + .setUrl(providedRegistrar.getUrl()) + .setLocalizedAddress(providedRegistrar.getLocalizedAddress()) + .setPhoneNumber(providedRegistrar.getPhoneNumber()) + .setFaxNumber(providedRegistrar.getFaxNumber()) + .build(); + tm().put(newRegistrar); + sendExternalUpdatesIfNecessary( + EmailInfo.create( + savedRegistrar, + newRegistrar, + savedRegistrar.getContacts(), + savedRegistrar.getContacts())); consoleApiParams.response().setStatus(SC_OK); } } diff --git a/core/src/main/java/google/registry/ui/server/registrar/ConsoleApiParams.java b/core/src/main/java/google/registry/ui/server/registrar/ConsoleApiParams.java index 70349c0da..de247f1fe 100644 --- a/core/src/main/java/google/registry/ui/server/registrar/ConsoleApiParams.java +++ b/core/src/main/java/google/registry/ui/server/registrar/ConsoleApiParams.java @@ -17,6 +17,7 @@ package google.registry.ui.server.registrar; import google.registry.request.Response; import google.registry.request.auth.AuthResult; import google.registry.security.XsrfTokenManager; +import google.registry.ui.server.SendEmailUtils; import jakarta.servlet.http.HttpServletRequest; /** Groups necessary dependencies for Console API actions * */ @@ -24,12 +25,14 @@ public record ConsoleApiParams( HttpServletRequest request, Response response, AuthResult authResult, + SendEmailUtils sendEmailUtils, XsrfTokenManager xsrfTokenManager) { public static ConsoleApiParams create( HttpServletRequest request, Response response, AuthResult authResult, + SendEmailUtils sendEmailUtils, XsrfTokenManager xsrfTokenManager) { - return new ConsoleApiParams(request, response, authResult, xsrfTokenManager); + return new ConsoleApiParams(request, response, authResult, sendEmailUtils, xsrfTokenManager); } } diff --git a/core/src/main/java/google/registry/ui/server/registrar/RegistrarConsoleModule.java b/core/src/main/java/google/registry/ui/server/registrar/RegistrarConsoleModule.java index 2c24bb3fa..dcffb4fdc 100644 --- a/core/src/main/java/google/registry/ui/server/registrar/RegistrarConsoleModule.java +++ b/core/src/main/java/google/registry/ui/server/registrar/RegistrarConsoleModule.java @@ -32,6 +32,7 @@ import google.registry.request.RequestScope; import google.registry.request.Response; import google.registry.request.auth.AuthResult; import google.registry.security.XsrfTokenManager; +import google.registry.ui.server.SendEmailUtils; import google.registry.ui.server.console.ConsoleEppPasswordAction.EppPasswordData; import jakarta.servlet.http.HttpServletRequest; import java.util.Optional; @@ -48,8 +49,9 @@ public final class RegistrarConsoleModule { HttpServletRequest request, Response response, AuthResult authResult, + SendEmailUtils sendEmailUtils, XsrfTokenManager xsrfTokenManager) { - return ConsoleApiParams.create(request, response, authResult, xsrfTokenManager); + return ConsoleApiParams.create(request, response, authResult, sendEmailUtils, xsrfTokenManager); } @Provides diff --git a/core/src/test/java/google/registry/testing/ConsoleApiParamsUtils.java b/core/src/test/java/google/registry/testing/ConsoleApiParamsUtils.java index 0ecf6c8cc..22dc78f40 100644 --- a/core/src/test/java/google/registry/testing/ConsoleApiParamsUtils.java +++ b/core/src/test/java/google/registry/testing/ConsoleApiParamsUtils.java @@ -17,9 +17,12 @@ package google.registry.testing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.google.common.collect.ImmutableList; +import google.registry.groups.GmailClient; import google.registry.model.console.User; import google.registry.request.auth.AuthResult; import google.registry.security.XsrfTokenManager; +import google.registry.ui.server.SendEmailUtils; import google.registry.ui.server.registrar.ConsoleApiParams; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; @@ -29,6 +32,9 @@ public final class ConsoleApiParamsUtils { public static ConsoleApiParams createFake(AuthResult authResult) { HttpServletRequest request = mock(HttpServletRequest.class); + GmailClient gmailClient = mock(GmailClient.class); + SendEmailUtils sendEmailUtils = + new SendEmailUtils(ImmutableList.of("notification@test.example"), gmailClient); XsrfTokenManager xsrfTokenManager = new XsrfTokenManager(new FakeClock(DateTime.parse("2020-02-02T01:23:45Z"))); when(request.getCookies()) @@ -39,6 +45,7 @@ public final class ConsoleApiParamsUtils { xsrfTokenManager.generateToken( authResult.user().map(User::getEmailAddress).orElse(""))) }); - return ConsoleApiParams.create(request, new FakeResponse(), authResult, xsrfTokenManager); + return ConsoleApiParams.create( + request, new FakeResponse(), authResult, sendEmailUtils, xsrfTokenManager); } } diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleEppPasswordActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleEppPasswordActionTest.java index b1f1e394d..340070497 100644 --- a/core/src/test/java/google/registry/ui/server/console/ConsoleEppPasswordActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleEppPasswordActionTest.java @@ -17,22 +17,20 @@ package google.registry.ui.server.console; import static com.google.common.truth.Truth.assertThat; import static google.registry.request.auth.AuthenticatedRegistrarAccessor.Role.OWNER; import static google.registry.testing.DatabaseHelper.loadRegistrar; -import static google.registry.testing.DatabaseHelper.persistNewRegistrar; import static google.registry.testing.DatabaseHelper.persistResource; import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST; import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; import static jakarta.servlet.http.HttpServletResponse.SC_OK; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.Mockito.doReturn; -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 com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSetMultimap; import com.google.gson.Gson; import google.registry.flows.PasswordOnlyTransportCredentials; -import google.registry.groups.GmailClient; import google.registry.model.console.GlobalRole; import google.registry.model.console.User; import google.registry.model.console.UserRoles; @@ -67,7 +65,6 @@ class ConsoleEppPasswordActionTest { private ConsoleApiParams consoleApiParams; protected PasswordOnlyTransportCredentials credentials = new PasswordOnlyTransportCredentials(); private FakeResponse response; - private GmailClient gmailClient = mock(GmailClient.class); @RegisterExtension final JpaTestExtensions.JpaIntegrationTestExtension jpa = @@ -75,14 +72,11 @@ class ConsoleEppPasswordActionTest { @BeforeEach void beforeEach() { - Registrar registrar = persistNewRegistrar("registrarId"); + Registrar registrar = Registrar.loadByRegistrarId("TheRegistrar").get(); registrar = registrar .asBuilder() - .setType(Registrar.Type.TEST) - .setIanaIdentifier(null) .setPassword("foobar") - .setEmailAddress("testEmail@google.com") .build(); persistResource(registrar); } @@ -99,7 +93,7 @@ class ConsoleEppPasswordActionTest { @Test void testFailure_passwordsDontMatch() throws IOException { ConsoleEppPasswordAction action = - createAction("registrarId", "oldPassword", "newPassword", "newPasswordRepeat"); + createAction("TheRegistrar", "oldPassword", "newPassword", "newPasswordRepeat"); action.run(); assertThat(((FakeResponse) consoleApiParams.response()).getStatus()).isEqualTo(SC_BAD_REQUEST); assertThat(((FakeResponse) consoleApiParams.response()).getPayload()) @@ -109,7 +103,7 @@ class ConsoleEppPasswordActionTest { @Test void testFailure_existingPasswordIncorrect() throws IOException { ConsoleEppPasswordAction action = - createAction("registrarId", "oldPassword", "randomPasword", "randomPasword"); + createAction("TheRegistrar", "oldPassword", "randomPasword", "randomPasword"); action.run(); assertThat(((FakeResponse) consoleApiParams.response()).getStatus()).isEqualTo(SC_FORBIDDEN); assertThat(((FakeResponse) consoleApiParams.response()).getPayload()) @@ -119,27 +113,33 @@ class ConsoleEppPasswordActionTest { @Test void testSuccess_sendsConfirmationEmail() throws IOException, AddressException { ConsoleEppPasswordAction action = - createAction("registrarId", "foobar", "randomPassword", "randomPassword"); + createAction("TheRegistrar", "foobar", "randomPassword", "randomPassword"); action.run(); - verify(gmailClient, times(1)) + verify(consoleApiParams.sendEmailUtils().gmailClient, times(1)) .sendEmail( - EmailMessage.create( - "EPP password update confirmation", - "Dear registrarId name,\n" - + "This is to confirm that your account password has been changed.", - new InternetAddress("testEmail@google.com"))); + EmailMessage.newBuilder() + .setSubject( + "Registrar The Registrar (TheRegistrar) updated in registry unittest" + + " environment") + .setBody( + "The following changes were made in registry unittest environment to the" + + " registrar TheRegistrar by user email@email.com:\n" + + "\n" + + "password: ******** -> ••••••••\n") + .setRecipients(ImmutableList.of(new InternetAddress("notification@test.example"))) + .build()); assertThat(((FakeResponse) consoleApiParams.response()).getStatus()).isEqualTo(SC_OK); } @Test void testSuccess_passwordUpdated() throws IOException { ConsoleEppPasswordAction action = - createAction("registrarId", "foobar", "randomPassword", "randomPassword"); + createAction("TheRegistrar", "foobar", "randomPassword", "randomPassword"); action.run(); assertThat(((FakeResponse) consoleApiParams.response()).getStatus()).isEqualTo(SC_OK); assertDoesNotThrow( () -> { - credentials.validate(loadRegistrar("registrarId"), "randomPassword"); + credentials.validate(loadRegistrar("TheRegistrar"), "randomPassword"); }); } @@ -157,7 +157,7 @@ class ConsoleEppPasswordActionTest { consoleApiParams = ConsoleApiParamsUtils.createFake(authResult); AuthenticatedRegistrarAccessor authenticatedRegistrarAccessor = AuthenticatedRegistrarAccessor.createForTesting( - ImmutableSetMultimap.of("registrarId", OWNER)); + ImmutableSetMultimap.of("TheRegistrar", OWNER)); when(consoleApiParams.request().getMethod()).thenReturn(Action.Method.POST.toString()); doReturn( new BufferedReader( @@ -171,6 +171,6 @@ class ConsoleEppPasswordActionTest { GSON, RequestModule.provideJsonBody(consoleApiParams.request(), GSON)); return new ConsoleEppPasswordAction( - consoleApiParams, authenticatedRegistrarAccessor, gmailClient, maybePasswordChangeRequest); + consoleApiParams, authenticatedRegistrarAccessor, maybePasswordChangeRequest); } } diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java index c3662e7c4..46cbb5fc8 100644 --- a/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java @@ -17,19 +17,17 @@ package google.registry.ui.server.console; import static com.google.common.truth.Truth.assertThat; import static google.registry.model.registrar.RegistrarPocBase.Type.WHOIS; import static google.registry.testing.DatabaseHelper.createTlds; -import static google.registry.testing.DatabaseHelper.persistNewRegistrar; import static google.registry.testing.DatabaseHelper.persistResource; import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST; import static jakarta.servlet.http.HttpServletResponse.SC_OK; import static org.mockito.Mockito.doReturn; -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 com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.gson.Gson; -import google.registry.groups.GmailClient; import google.registry.model.console.GlobalRole; import google.registry.model.console.User; import google.registry.model.console.UserRoles; @@ -73,8 +71,6 @@ class ConsoleUpdateRegistrarActionTest { private static String registrarPostData = "{\"registrarId\":\"%s\",\"allowedTlds\":[%s],\"registryLockAllowed\":%s}"; - private GmailClient gmailClient = mock(GmailClient.class); - @RegisterExtension @Order(Integer.MAX_VALUE) final SystemPropertyExtension systemPropertyExtension = new SystemPropertyExtension(); @@ -82,17 +78,17 @@ class ConsoleUpdateRegistrarActionTest { @BeforeEach void beforeEach() throws Exception { createTlds("app", "dev"); - registrar = persistNewRegistrar("registrarId"); + registrar = Registrar.loadByRegistrarId("TheRegistrar").get(); persistResource( registrar .asBuilder() .setType(RegistrarBase.Type.REAL) - .setEmailAddress("testEmail@google.com") + .setAllowedTlds(ImmutableSet.of()) + .setRegistryLockAllowed(false) .build()); user = new User.Builder() .setEmailAddress("user@registrarId.com") - .setRegistryLockEmailAddress("registryedit@registrarId.com") .setUserRoles(new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).build()) .build(); consoleApiParams = createParams(); @@ -104,9 +100,9 @@ class ConsoleUpdateRegistrarActionTest { @Test void testSuccess__updatesRegistrar() throws IOException { - var action = createAction(String.format(registrarPostData, "registrarId", "app, dev", false)); + var action = createAction(String.format(registrarPostData, "TheRegistrar", "app, dev", false)); action.run(); - Registrar newRegistrar = Registrar.loadByRegistrarId("registrarId").get(); + Registrar newRegistrar = Registrar.loadByRegistrarId("TheRegistrar").get(); assertThat(newRegistrar.getAllowedTlds()).containsExactly("app", "dev"); assertThat(newRegistrar.isRegistryLockAllowed()).isFalse(); assertThat(((FakeResponse) consoleApiParams.response()).getStatus()).isEqualTo(SC_OK); @@ -115,7 +111,7 @@ class ConsoleUpdateRegistrarActionTest { @Test void testFails__missingWhoisContact() throws IOException { RegistryEnvironment.PRODUCTION.setup(systemPropertyExtension); - var action = createAction(String.format(registrarPostData, "registrarId", "app, dev", false)); + var action = createAction(String.format(registrarPostData, "TheRegistrar", "app, dev", false)); action.run(); assertThat(((FakeResponse) consoleApiParams.response()).getStatus()).isEqualTo(SC_BAD_REQUEST); assertThat((String) ((FakeResponse) consoleApiParams.response()).getPayload()) @@ -138,9 +134,9 @@ class ConsoleUpdateRegistrarActionTest { .setVisibleInDomainWhoisAsAbuse(true) .build(); persistResource(contact); - var action = createAction(String.format(registrarPostData, "registrarId", "app, dev", false)); + var action = createAction(String.format(registrarPostData, "TheRegistrar", "app, dev", false)); action.run(); - Registrar newRegistrar = Registrar.loadByRegistrarId("registrarId").get(); + Registrar newRegistrar = Registrar.loadByRegistrarId("TheRegistrar").get(); assertThat(newRegistrar.getAllowedTlds()).containsExactly("app", "dev"); assertThat(newRegistrar.isRegistryLockAllowed()).isFalse(); assertThat(((FakeResponse) consoleApiParams.response()).getStatus()).isEqualTo(SC_OK); @@ -148,15 +144,21 @@ class ConsoleUpdateRegistrarActionTest { @Test void testSuccess__sendsEmail() throws AddressException, IOException { - var action = createAction(String.format(registrarPostData, "registrarId", "app, dev", false)); + var action = createAction(String.format(registrarPostData, "TheRegistrar", "app, dev", false)); action.run(); - verify(gmailClient, times(1)) + verify(consoleApiParams.sendEmailUtils().gmailClient, times(1)) .sendEmail( - EmailMessage.create( - "Registrar registrarId has been updated", - "The following changes were made in registry UNITTEST environment to the registrar" - + " registrarId:/nAllowed TLDs: [] -> [app, dev]", - new InternetAddress("testEmail@google.com"))); + EmailMessage.newBuilder() + .setSubject( + "Registrar The Registrar (TheRegistrar) updated in registry unittest" + + " environment") + .setBody( + "The following changes were made in registry unittest environment to the" + + " registrar TheRegistrar by user user@registrarId.com:\n" + + "\n" + + "allowedTlds: null -> [app, dev]\n") + .setRecipients(ImmutableList.of(new InternetAddress("notification@test.example"))) + .build()); } private ConsoleApiParams createParams() { @@ -172,7 +174,6 @@ class ConsoleUpdateRegistrarActionTest { Optional maybeRegistrarUpdateData = RegistrarConsoleModule.provideRegistrar( GSON, RequestModule.provideJsonBody(consoleApiParams.request(), GSON)); - return new ConsoleUpdateRegistrarAction( - consoleApiParams, gmailClient, maybeRegistrarUpdateData); + return new ConsoleUpdateRegistrarAction(consoleApiParams, maybeRegistrarUpdateData); } } diff --git a/core/src/test/java/google/registry/ui/server/console/settings/ContactActionTest.java b/core/src/test/java/google/registry/ui/server/console/settings/ContactActionTest.java index 67bf53955..a579b7db4 100644 --- a/core/src/test/java/google/registry/ui/server/console/settings/ContactActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/settings/ContactActionTest.java @@ -16,6 +16,7 @@ package google.registry.ui.server.console.settings; import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.truth.Truth.assertThat; +import static google.registry.model.registrar.RegistrarPocBase.Type.ADMIN; import static google.registry.model.registrar.RegistrarPocBase.Type.WHOIS; import static google.registry.testing.DatabaseHelper.createAdminUser; import static google.registry.testing.DatabaseHelper.insertInDb; @@ -23,9 +24,14 @@ import static google.registry.testing.DatabaseHelper.loadAllOf; import static google.registry.testing.SqlHelper.saveRegistrar; import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; import static jakarta.servlet.http.HttpServletResponse.SC_OK; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.gson.Gson; @@ -42,11 +48,14 @@ import google.registry.testing.ConsoleApiParamsUtils; import google.registry.testing.FakeResponse; import google.registry.ui.server.registrar.ConsoleApiParams; import google.registry.ui.server.registrar.RegistrarConsoleModule; +import google.registry.util.EmailMessage; import java.io.BufferedReader; import java.io.IOException; import java.io.StringReader; import java.util.HashMap; import java.util.Optional; +import javax.mail.internet.AddressException; +import javax.mail.internet.InternetAddress; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -58,7 +67,7 @@ class ContactActionTest { + "\"emailAddress\":\"test.registrar1@example.com\"," + "\"registrarId\":\"registrarId\"," + "\"phoneNumber\":\"+1.9999999999\",\"faxNumber\":\"+1.9999999991\"," - + "\"types\":[\"WHOIS\"],\"visibleInWhoisAsAdmin\":true," + + "\"types\":[\"WHOIS\",\"ADMIN\"],\"visibleInWhoisAsAdmin\":true," + "\"visibleInWhoisAsTech\":false,\"visibleInDomainWhoisAsAbuse\":false}"; private static String jsonRegistrar2 = @@ -66,7 +75,7 @@ class ContactActionTest { + "\"emailAddress\":\"test.registrar2@example.com\"," + "\"registrarId\":\"registrarId\"," + "\"phoneNumber\":\"+1.1234567890\",\"faxNumber\":\"+1.1234567891\"," - + "\"types\":[\"WHOIS\"],\"visibleInWhoisAsAdmin\":true," + + "\"types\":[\"WHOIS\",\"ADMIN\"],\"visibleInWhoisAsAdmin\":true," + "\"visibleInWhoisAsTech\":false,\"visibleInDomainWhoisAsAbuse\":false}"; private Registrar testRegistrar; @@ -88,7 +97,7 @@ class ContactActionTest { .setEmailAddress("test.registrar1@example.com") .setPhoneNumber("+1.9999999999") .setFaxNumber("+1.9999999991") - .setTypes(ImmutableSet.of(WHOIS)) + .setTypes(ImmutableSet.of(WHOIS, ADMIN)) .setVisibleInWhoisAsAdmin(true) .setVisibleInWhoisAsTech(false) .setVisibleInDomainWhoisAsAbuse(false) @@ -110,6 +119,20 @@ class ContactActionTest { .isEqualTo("[" + jsonRegistrar1 + "]"); } + @Test + void testSuccess_noOp() throws IOException { + insertInDb(testRegistrarPoc); + ContactAction action = + createAction( + Action.Method.POST, + AuthResult.createUser(createAdminUser("email@email.com")), + testRegistrar.getRegistrarId(), + "[" + jsonRegistrar1 + "]"); + action.run(); + assertThat(((FakeResponse) consoleApiParams.response()).getStatus()).isEqualTo(SC_OK); + verify(consoleApiParams.sendEmailUtils().gmailClient, never()).sendEmail(any()); + } + @Test void testSuccess_onlyContactsWithNonEmptyType() throws IOException { testRegistrarPoc = testRegistrarPoc.asBuilder().setTypes(ImmutableSet.of()).build(); @@ -127,6 +150,7 @@ class ContactActionTest { @Test void testSuccess_postCreateContactInfo() throws IOException { + insertInDb(testRegistrarPoc); ContactAction action = createAction( Action.Method.POST, @@ -167,6 +191,59 @@ class ContactActionTest { "test.registrar2@example.com"); } + @Test + void testSuccess_sendsEmail() throws IOException, AddressException { + testRegistrarPoc = testRegistrarPoc.asBuilder().setEmailAddress("incorrect@email.com").build(); + insertInDb(testRegistrarPoc); + ContactAction action = + createAction( + Action.Method.POST, + AuthResult.createUser(createAdminUser("email@email.com")), + testRegistrar.getRegistrarId(), + "[" + jsonRegistrar1 + "]"); + action.run(); + assertThat(((FakeResponse) consoleApiParams.response()).getStatus()).isEqualTo(SC_OK); + verify(consoleApiParams.sendEmailUtils().gmailClient, times(1)) + .sendEmail( + EmailMessage.newBuilder() + .setSubject( + "Registrar New Registrar (registrarId) updated in registry unittest" + + " environment") + .setBody( + "The following changes were made in registry unittest environment to the" + + " registrar registrarId by admin email@email.com:\n" + + "\n" + + "contacts:\n" + + " ADDED:\n" + + " {name=Test Registrar 1," + + " emailAddress=test.registrar1@example.com, registrarId=registrarId," + + " registryLockEmailAddress=null, phoneNumber=+1.9999999999," + + " faxNumber=+1.9999999991, types=[ADMIN, WHOIS], loginEmailAddress=null," + + " visibleInWhoisAsAdmin=true, visibleInWhoisAsTech=false," + + " visibleInDomainWhoisAsAbuse=false," + + " allowedToSetRegistryLockPassword=false}\n" + + " REMOVED:\n" + + " {name=Test Registrar 1, emailAddress=incorrect@email.com," + + " registrarId=registrarId, registryLockEmailAddress=null," + + " phoneNumber=+1.9999999999, faxNumber=+1.9999999991, types=[WHOIS," + + " ADMIN], loginEmailAddress=null, visibleInWhoisAsAdmin=true," + + " visibleInWhoisAsTech=false, visibleInDomainWhoisAsAbuse=false," + + " allowedToSetRegistryLockPassword=false}\n" + + " FINAL CONTENTS:\n" + + " {name=Test Registrar 1," + + " emailAddress=test.registrar1@example.com, registrarId=registrarId," + + " registryLockEmailAddress=null, phoneNumber=+1.9999999999," + + " faxNumber=+1.9999999991, types=[ADMIN, WHOIS], loginEmailAddress=null," + + " visibleInWhoisAsAdmin=true, visibleInWhoisAsTech=false," + + " visibleInDomainWhoisAsAbuse=false," + + " allowedToSetRegistryLockPassword=false}\n") + .setRecipients( + ImmutableList.of( + new InternetAddress("notification@test.example"), + new InternetAddress("incorrect@email.com"))) + .build()); + } + @Test void testSuccess_postDeleteContactInfo() throws IOException { insertInDb(testRegistrarPoc); diff --git a/util/src/main/java/google/registry/util/DiffUtils.java b/util/src/main/java/google/registry/util/DiffUtils.java index 4f953749d..3afd68a1c 100644 --- a/util/src/main/java/google/registry/util/DiffUtils.java +++ b/util/src/main/java/google/registry/util/DiffUtils.java @@ -36,7 +36,7 @@ public final class DiffUtils { * A helper record to store the two sides of a diff. If both sides are Sets then they will be * diffed, otherwise the two objects are toStringed in Collection format "[a, b]". */ - private record DiffPair(@Nullable Object a, @Nullable Object b) { + public record DiffPair(@Nullable Object a, @Nullable Object b) { @Override public String toString() {