1
0
mirror of https://github.com/google/nomulus synced 2026-01-16 02:33:16 +00:00

Compare commits

...

6 Commits

Author SHA1 Message Date
Weimin Yu
5aa40b2208 Fix error handling in CopyDetailReportsAction (#2793)
* Fix error handling in CopyDetailReportsAction

The action tries to record errors per registrar in an ImmutableMap, without realizing that
there may be duplicate keys due to retries.

Switched to the `buildKeepingLast` method to build the map.

* Addressing comments and rebase
2025-08-06 16:43:29 +00:00
Pavlo Tkach
95c89bc856 Add registrar id header to proxy requests (#2791) 2025-08-05 17:57:04 +00:00
gbrodman
c21b66f0fb Add reset-EPP-password frontend component (#2786) 2025-08-01 19:53:20 +00:00
gbrodman
b070c46231 Allow superuser status to override EPP resource delete prohibited status (#2789) 2025-08-01 19:51:44 +00:00
Weimin Yu
a20eb8d1e9 Fix failures in retries when inserting new objects (#2788)
Given an entity with auto-filled id fields (annotated with
@GeneratedValue, with null as initial value), when inserting it
using Hibernate, the id fields will be filled with non-nulls even
if the transaction fails.

If the same entity instance is used again in a retry, Hibernate mistakes
it as a detached entity and raises an error.

The work around is to make a new copy of the entity in each transaction.
This PR applies this pattern to affected entity types.

We considered applying this pattern to JpaTransactionManagerImpl's insert
method so that individual call sites do not have to change. However, we
decided against it because:

- It is unnecessary for entity types that do not have auto-filled id

- The JpaTransactionManager cannot tell if copying is cheap or
  expensive. It is better exposing this to the user.

- The JpaTransactionManager needs to know how to clone entities. A new
  interface may need to be introduced just for a handful of use cases.
2025-08-01 18:53:50 +00:00
Ben McIlwain
338b8edb97 Make the contacts prohibited feature flag for min reg data set more lenient (#2787)
It will now only throw errors on domain updates if a new contact/registrant has
been specified where none was previously present. This means that domain updates
on unrelated fields (e.g. nameserver changes) will succeed even if there is
existing contact data that the update is not removing.

This is a follow-up to #2781.

BUG=http://b/434958659
2025-07-29 20:54:58 +00:00
33 changed files with 724 additions and 127 deletions

View File

@@ -77,4 +77,17 @@
Save
</button>
</form>
@if(userDataService.userData()?.isAdmin) {
<div class="settings-security__reset-password-field">
<h2>Need to reset your EPP password?</h2>
<button
mat-flat-button
color="primary"
aria-label="Reset EPP password via email"
(click)="requestEppPasswordReset()"
>
Reset EPP password via email
</button>
</div>
}
</div>

View File

@@ -1,16 +1,35 @@
.settings-security__edit-password {
max-width: 616px;
&-field {
width: 100%;
mat-form-field {
margin-bottom: 20px;
// 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.
.settings-security {
&__edit-password {
max-width: 616px;
&-field {
width: 100%;
mat-form-field {
margin-bottom: 20px;
width: 100%;
}
}
&-form {
margin-top: 30px;
}
&-save {
margin-top: 30px;
}
}
&-form {
margin-top: 30px;
}
&-save {
margin-top: 30px;
&__reset-password-field {
margin-top: 60px;
}
}

View File

@@ -24,11 +24,43 @@ import {
import { MatSnackBar } from '@angular/material/snack-bar';
import { RegistrarService } from 'src/app/registrar/registrar.service';
import { SecurityService } from './security.service';
import { UserDataService } from 'src/app/shared/services/userData.service';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { CommonModule } from '@angular/common';
import { MaterialModule } from 'src/app/material.module';
import { filter, switchMap, take } from 'rxjs';
import { BackendService } from 'src/app/shared/services/backend.service';
type errorCode = 'required' | 'maxlength' | 'minlength' | 'passwordsDontMatch';
type errorFriendlyText = { [type in errorCode]: String };
@Component({
selector: 'app-reset-epp-password-dialog',
template: `
<h2 mat-dialog-title>Please confirm the password reset:</h2>
<mat-dialog-content>
This will send an EPP password reset email to the admin POC.
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button (click)="onCancel()">Cancel</button>
<button mat-button color="warn" (click)="onSave()">Confirm</button>
</mat-dialog-actions>
`,
imports: [CommonModule, MaterialModule],
})
export class ResetEppPasswordComponent {
constructor(public dialogRef: MatDialogRef<ResetEppPasswordComponent>) {}
onSave(): void {
this.dialogRef.close(true);
}
onCancel(): void {
this.dialogRef.close(false);
}
}
@Component({
selector: 'app-epp-password-edit',
templateUrl: './eppPasswordEdit.component.html',
@@ -48,9 +80,12 @@ export default class EppPasswordEditComponent {
};
constructor(
public registrarService: RegistrarService,
public securityService: SecurityService,
private _snackBar: MatSnackBar,
public registrarService: RegistrarService
protected userDataService: UserDataService,
private backendService: BackendService,
private resetPasswordDialog: MatDialog,
private _snackBar: MatSnackBar
) {}
hasError(controlName: string) {
@@ -120,4 +155,26 @@ export default class EppPasswordEditComponent {
goBack() {
this.securityService.isEditingPassword = false;
}
sendEppPasswordResetRequest() {
return this.backendService.requestEppPasswordReset(
this.registrarService.registrarId()
);
}
requestEppPasswordReset() {
const dialogRef = this.resetPasswordDialog.open(ResetEppPasswordComponent);
dialogRef
.afterClosed()
.pipe(
take(1),
filter((result) => !!result)
)
.pipe(switchMap((_) => this.sendEppPasswordResetRequest()))
.subscribe({
next: (_) => this.goBack(),
error: (err: HttpErrorResponse) =>
this._snackBar.open(err.error || err.message),
});
}
}

View File

@@ -291,4 +291,11 @@ export class BackendService {
registryLockEmail,
});
}
requestEppPasswordReset(registrarId: string) {
return this.http.post('/console-api/password-reset-request', {
type: 'EPP',
registrarId,
});
}
}

View File

@@ -21,6 +21,7 @@ import static google.registry.xml.ValidationMode.STRICT;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
import google.registry.flows.EppException.CommandUseErrorException;
import google.registry.flows.EppException.ParameterValueRangeErrorException;
@@ -30,6 +31,7 @@ import google.registry.flows.custom.EntityChanges;
import google.registry.model.EppResource;
import google.registry.model.adapters.CurrencyUnitAdapter.UnknownCurrencyException;
import google.registry.model.eppcommon.EppXmlTransformer;
import google.registry.model.eppcommon.StatusValue;
import google.registry.model.eppinput.EppInput.WrongProtocolVersionException;
import google.registry.model.eppoutput.EppOutput;
import google.registry.model.host.InetAddressAdapter.IpVersionMismatchException;
@@ -40,6 +42,9 @@ import java.util.List;
/** Static utility functions for flows. */
public final class FlowUtils {
public static final ImmutableSet<StatusValue> DELETE_PROHIBITED_STATUSES =
ImmutableSet.of(StatusValue.CLIENT_DELETE_PROHIBITED, StatusValue.SERVER_DELETE_PROHIBITED);
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private FlowUtils() {}

View File

@@ -14,6 +14,7 @@
package google.registry.flows.contact;
import static google.registry.flows.FlowUtils.DELETE_PROHIBITED_STATUSES;
import static google.registry.flows.FlowUtils.validateRegistrarIsLoggedIn;
import static google.registry.flows.ResourceFlowUtils.checkLinkedDomains;
import static google.registry.flows.ResourceFlowUtils.loadAndVerifyExistence;
@@ -65,12 +66,6 @@ import org.joda.time.DateTime;
@ReportingSpec(ActivityReportField.CONTACT_DELETE)
public final class ContactDeleteFlow implements MutatingFlow {
private static final ImmutableSet<StatusValue> DISALLOWED_STATUSES =
ImmutableSet.of(
StatusValue.CLIENT_DELETE_PROHIBITED,
StatusValue.PENDING_DELETE,
StatusValue.SERVER_DELETE_PROHIBITED);
@Inject ExtensionManager extensionManager;
@Inject @RegistrarId String registrarId;
@Inject @TargetId String targetId;
@@ -91,9 +86,10 @@ public final class ContactDeleteFlow implements MutatingFlow {
DateTime now = tm().getTransactionTime();
checkLinkedDomains(targetId, now, Contact.class);
Contact existingContact = loadAndVerifyExistence(Contact.class, targetId, now);
verifyNoDisallowedStatuses(existingContact, DISALLOWED_STATUSES);
verifyOptionalAuthInfo(authInfo, existingContact);
verifyNoDisallowedStatuses(existingContact, ImmutableSet.of(StatusValue.PENDING_DELETE));
if (!isSuperuser) {
verifyNoDisallowedStatuses(existingContact, DELETE_PROHIBITED_STATUSES);
verifyResourceOwnership(registrarId, existingContact);
}
// Handle pending transfers on contact deletion.

View File

@@ -17,6 +17,7 @@ package google.registry.flows.domain;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Strings.isNullOrEmpty;
import static google.registry.dns.DnsUtils.requestDomainDnsRefresh;
import static google.registry.flows.FlowUtils.DELETE_PROHIBITED_STATUSES;
import static google.registry.flows.FlowUtils.createHistoryEntryId;
import static google.registry.flows.FlowUtils.persistEntityChanges;
import static google.registry.flows.FlowUtils.validateRegistrarIsLoggedIn;
@@ -122,11 +123,6 @@ public final class DomainDeleteFlow implements MutatingFlow, SqlStatementLogging
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final ImmutableSet<StatusValue> DISALLOWED_STATUSES = ImmutableSet.of(
StatusValue.CLIENT_DELETE_PROHIBITED,
StatusValue.PENDING_DELETE,
StatusValue.SERVER_DELETE_PROHIBITED);
@Inject ExtensionManager extensionManager;
@Inject EppInput eppInput;
@Inject SessionMetadata sessionMetadata;
@@ -304,9 +300,10 @@ public final class DomainDeleteFlow implements MutatingFlow, SqlStatementLogging
private void verifyDeleteAllowed(Domain existingDomain, Tld tld, DateTime now)
throws EppException {
verifyNoDisallowedStatuses(existingDomain, DISALLOWED_STATUSES);
verifyOptionalAuthInfo(authInfo, existingDomain);
verifyNoDisallowedStatuses(existingDomain, ImmutableSet.of(StatusValue.PENDING_DELETE));
if (!isSuperuser) {
verifyNoDisallowedStatuses(existingDomain, DELETE_PROHIBITED_STATUSES);
verifyResourceOwnership(registrarId, existingDomain);
verifyNotInPredelegation(tld, now);
checkAllowedAccessToTld(registrarId, tld.getTld().toString());

View File

@@ -481,10 +481,10 @@ public class DomainFlowUtils {
}
/**
* Enforces the presence/absence of contact data depending on the minimum data set migration
* schedule.
* Enforces the presence/absence of contact data on domain creates depending on the minimum data
* set migration schedule.
*/
static void validateContactDataPresence(
static void validateCreateContactData(
Optional<VKey<Contact>> registrant, Set<DesignatedContact> contacts)
throws RequiredParameterMissingException, ParameterValuePolicyErrorException {
// TODO(b/353347632): Change these flag checks to a registry config check once minimum data set
@@ -514,6 +514,45 @@ public class DomainFlowUtils {
}
}
/**
* Enforces the presence/absence of contact data on domain updates depending on the minimum data
* set migration schedule.
*/
static void validateUpdateContactData(
Optional<VKey<Contact>> existingRegistrant,
Optional<VKey<Contact>> newRegistrant,
Set<DesignatedContact> existingContacts,
Set<DesignatedContact> newContacts)
throws RequiredParameterMissingException, ParameterValuePolicyErrorException {
// TODO(b/353347632): Change these flag checks to a registry config check once minimum data set
// migration is completed.
if (FeatureFlag.isActiveNow(MINIMUM_DATASET_CONTACTS_PROHIBITED)) {
// Throw if the update specifies a new registrant that is different from the existing one.
if (newRegistrant.isPresent() && !newRegistrant.equals(existingRegistrant)) {
throw new RegistrantProhibitedException();
}
// Throw if the update specifies any new contacts that weren't already present on the domain.
if (!Sets.difference(newContacts, existingContacts).isEmpty()) {
throw new ContactsProhibitedException();
}
} else if (!FeatureFlag.isActiveNow(MINIMUM_DATASET_CONTACTS_OPTIONAL)) {
// Throw if the update empties out a registrant that had been present.
if (newRegistrant.isEmpty() && existingRegistrant.isPresent()) {
throw new MissingRegistrantException();
}
// Throw if the update contains no admin contact when one had been present.
if (existingContacts.stream().anyMatch(c -> c.getType().equals(Type.ADMIN))
&& newContacts.stream().noneMatch(c -> c.getType().equals(Type.ADMIN))) {
throw new MissingAdminContactException();
}
// Throw if the update contains no tech contact when one had been present.
if (existingContacts.stream().anyMatch(c -> c.getType().equals(Type.TECH))
&& newContacts.stream().noneMatch(c -> c.getType().equals(Type.TECH))) {
throw new MissingTechnicalContactException();
}
}
}
static void validateRegistrantAllowedOnTld(String tld, Optional<String> registrantContactId)
throws RegistrantNotAllowedException {
ImmutableSet<String> allowedRegistrants = Tld.get(tld).getAllowedRegistrantContactIds();
@@ -1054,7 +1093,7 @@ public class DomainFlowUtils {
String tldStr = tld.getTldStr();
validateRegistrantAllowedOnTld(tldStr, command.getRegistrantContactId());
validateNoDuplicateContacts(command.getContacts());
validateContactDataPresence(command.getRegistrant(), command.getContacts());
validateCreateContactData(command.getRegistrant(), command.getContacts());
ImmutableSet<String> hostNames = command.getNameserverHostNames();
validateNameserversCountForTld(tldStr, domainName, hostNames.size());
validateNameserversAllowedOnTld(tldStr, hostNames);

View File

@@ -30,7 +30,6 @@ import static google.registry.flows.ResourceFlowUtils.verifyResourceOwnership;
import static google.registry.flows.domain.DomainFlowUtils.checkAllowedAccessToTld;
import static google.registry.flows.domain.DomainFlowUtils.cloneAndLinkReferences;
import static google.registry.flows.domain.DomainFlowUtils.updateDsData;
import static google.registry.flows.domain.DomainFlowUtils.validateContactDataPresence;
import static google.registry.flows.domain.DomainFlowUtils.validateContactsHaveTypes;
import static google.registry.flows.domain.DomainFlowUtils.validateDsData;
import static google.registry.flows.domain.DomainFlowUtils.validateFeesAckedIfPresent;
@@ -38,6 +37,7 @@ import static google.registry.flows.domain.DomainFlowUtils.validateNameserversAl
import static google.registry.flows.domain.DomainFlowUtils.validateNameserversCountForTld;
import static google.registry.flows.domain.DomainFlowUtils.validateNoDuplicateContacts;
import static google.registry.flows.domain.DomainFlowUtils.validateRegistrantAllowedOnTld;
import static google.registry.flows.domain.DomainFlowUtils.validateUpdateContactData;
import static google.registry.flows.domain.DomainFlowUtils.verifyClientUpdateNotProhibited;
import static google.registry.flows.domain.DomainFlowUtils.verifyNotInPendingDelete;
import static google.registry.model.common.FeatureFlag.FeatureName.MINIMUM_DATASET_CONTACTS_OPTIONAL;
@@ -186,7 +186,7 @@ public final class DomainUpdateFlow implements MutatingFlow {
Domain newDomain = performUpdate(command, existingDomain, now);
DomainHistory domainHistory =
historyBuilder.setType(DOMAIN_UPDATE).setDomain(newDomain).build();
validateNewState(newDomain);
validateNewState(existingDomain, newDomain);
if (requiresDnsUpdate(existingDomain, newDomain)) {
requestDomainDnsRefresh(targetId);
}
@@ -328,8 +328,13 @@ public final class DomainUpdateFlow implements MutatingFlow {
* compliant with the additions or amendments, otherwise existing data can become invalid and
* cause Domain update failure.
*/
private static void validateNewState(Domain newDomain) throws EppException {
validateContactDataPresence(newDomain.getRegistrant(), newDomain.getContacts());
private static void validateNewState(Domain existingDomain, Domain newDomain)
throws EppException {
validateUpdateContactData(
existingDomain.getRegistrant(),
newDomain.getRegistrant(),
existingDomain.getContacts(),
newDomain.getContacts());
validateDsData(newDomain.getDsData());
validateNameserversCountForTld(
newDomain.getTld(),

View File

@@ -15,6 +15,7 @@
package google.registry.flows.host;
import static google.registry.dns.DnsUtils.requestHostDnsRefresh;
import static google.registry.flows.FlowUtils.DELETE_PROHIBITED_STATUSES;
import static google.registry.flows.FlowUtils.validateRegistrarIsLoggedIn;
import static google.registry.flows.ResourceFlowUtils.checkLinkedDomains;
import static google.registry.flows.ResourceFlowUtils.loadAndVerifyExistence;
@@ -65,12 +66,6 @@ import org.joda.time.DateTime;
@ReportingSpec(ActivityReportField.HOST_DELETE)
public final class HostDeleteFlow implements MutatingFlow {
private static final ImmutableSet<StatusValue> DISALLOWED_STATUSES =
ImmutableSet.of(
StatusValue.CLIENT_DELETE_PROHIBITED,
StatusValue.PENDING_DELETE,
StatusValue.SERVER_DELETE_PROHIBITED);
@Inject ExtensionManager extensionManager;
@Inject @RegistrarId String registrarId;
@Inject @TargetId String targetId;
@@ -91,8 +86,9 @@ public final class HostDeleteFlow implements MutatingFlow {
validateHostName(targetId);
checkLinkedDomains(targetId, now, Host.class);
Host existingHost = loadAndVerifyExistence(Host.class, targetId, now);
verifyNoDisallowedStatuses(existingHost, DISALLOWED_STATUSES);
verifyNoDisallowedStatuses(existingHost, ImmutableSet.of(StatusValue.PENDING_DELETE));
if (!isSuperuser) {
verifyNoDisallowedStatuses(existingHost, DELETE_PROHIBITED_STATUSES);
// Hosts transfer with their superordinate domains, so for hosts with a superordinate domain,
// the client id, needs to be read off of it.
EppResource owningResource =

View File

@@ -95,10 +95,4 @@ public class SignedMarkRevocationList extends ImmutableObject {
public int size() {
return revokes.size();
}
/** Save this list to Cloud SQL. Returns {@code this}. */
public SignedMarkRevocationList save() {
SignedMarkRevocationListDao.save(this);
return this;
}
}

View File

@@ -44,11 +44,25 @@ public class SignedMarkRevocationListDao {
return smdrl.orElseGet(() -> SignedMarkRevocationList.create(START_OF_TIME, ImmutableMap.of()));
}
/** Save the given {@link SignedMarkRevocationList} */
static void save(SignedMarkRevocationList signedMarkRevocationList) {
tm().transact(() -> tm().insert(signedMarkRevocationList));
/**
* Persists a {@link SignedMarkRevocationList} instance and returns the persisted entity.
*
* <p>Note that the input parameter is untouched. Use the returned object if metadata fields like
* {@code revisionId} are needed.
*/
public static SignedMarkRevocationList save(SignedMarkRevocationList signedMarkRevocationList) {
var persisted =
tm().transact(
() -> {
var entity =
SignedMarkRevocationList.create(
signedMarkRevocationList.getCreationTime(),
ImmutableMap.copyOf(signedMarkRevocationList.revokes));
tm().insert(entity);
return entity;
});
logger.atInfo().log(
"Inserted %,d signed mark revocations into Cloud SQL.",
signedMarkRevocationList.revokes.size());
"Inserted %,d signed mark revocations into Cloud SQL.", persisted.revokes.size());
return persisted;
}
}

View File

@@ -133,24 +133,30 @@ public final class PremiumListDao {
}
/** Saves the given premium list (and its premium list entries) to Cloud SQL. */
public static PremiumList save(PremiumList premiumList) {
tm().transact(
() -> {
tm().insert(premiumList);
tm().getEntityManager().flush(); // This populates the revisionId.
long revisionId = premiumList.getRevisionId();
public static PremiumList save(PremiumList premiumListToPersist) {
PremiumList persisted =
tm().transact(
() -> {
// Make a new copy in each attempt to insert. See javadoc of the insert method for
// more information.
PremiumList premiumList = premiumListToPersist.asBuilder().build();
tm().insert(premiumList);
tm().getEntityManager().flush(); // This populates the revisionId.
long revisionId = premiumList.getRevisionId();
if (!isNullOrEmpty(premiumList.getLabelsToPrices())) {
ImmutableSet.Builder<PremiumEntry> entries = new ImmutableSet.Builder<>();
premiumList
.getLabelsToPrices()
.forEach(
(key, value) -> entries.add(PremiumEntry.create(revisionId, value, key)));
tm().insertAll(entries.build());
}
});
premiumListCache.invalidate(premiumList.getName());
return premiumList;
if (!isNullOrEmpty(premiumList.getLabelsToPrices())) {
ImmutableSet.Builder<PremiumEntry> entries = new ImmutableSet.Builder<>();
premiumList
.getLabelsToPrices()
.forEach(
(key, value) ->
entries.add(PremiumEntry.create(revisionId, value, key)));
tm().insertAll(entries.build());
}
return premiumList;
});
premiumListCache.invalidate(persisted.getName());
return persisted;
}
public static void delete(PremiumList premiumList) {

View File

@@ -27,14 +27,26 @@ public class ReservedListDao {
private ReservedListDao() {}
/** Persist a new reserved list to Cloud SQL. */
public static void save(ReservedList reservedList) {
/**
* Persists a new reserved list to Cloud SQL and returns the persisted entity.
*
* <p>Note that the input parameter is untouched. Use the returned object if metadata fields like
* {@code revisionId} are needed.
*/
public static ReservedList save(ReservedList reservedList) {
checkArgumentNotNull(reservedList, "Must specify reservedList");
logger.atInfo().log("Saving reserved list %s to Cloud SQL.", reservedList.getName());
tm().transact(() -> tm().insert(reservedList));
var persisted =
tm().transact(
() -> {
var entity = reservedList.asBuilder().build();
tm().insert(entity);
return entity;
});
logger.atInfo().log(
"Saved reserved list %s with %d entries to Cloud SQL.",
reservedList.getName(), reservedList.getReservedListEntries().size());
return persisted;
}
/** Deletes a reserved list from Cloud SQL. */

View File

@@ -49,10 +49,23 @@ public class ClaimsListDao {
return CacheUtils.newCacheBuilder(expiry).build(ignored -> ClaimsListDao.getUncached());
}
/** Saves the given {@link ClaimsList} to Cloud SQL. */
public static void save(ClaimsList claimsList) {
tm().transact(() -> tm().insert(claimsList));
CACHE.put(ClaimsListDao.class, claimsList);
/**
* Persists a {@link ClaimsList} instance and returns the persisted entity.
*
* <p>Note that the input parameter is untouched. Use the returned object if metadata fields like
* {@code revisionId} are needed.
*/
public static ClaimsList save(ClaimsList claimsList) {
var persisted =
tm().transact(
() -> {
var entity =
ClaimsList.create(claimsList.tmdbGenerationTime, claimsList.labelsToKeys);
tm().insert(entity);
return entity;
});
CACHE.put(ClaimsListDao.class, persisted);
return persisted;
}
/** Returns the most recent revision of the {@link ClaimsList} from the cache. */

View File

@@ -344,6 +344,18 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
return txnInfo.transactionTime;
}
/**
* Inserts an object into the database.
*
* <p>If {@code entity} has an auto-generated identity field (i.e., a field annotated with {@link
* jakarta.persistence.GeneratedValue}), the caller must not assign a value to this field,
* otherwise Hibernate would mistake the entity as detached and raise an error.
*
* <p>The practical implication of the above is that when inserting such an entity using a
* retriable transaction , the entity should be instantiated inside the transaction body. A failed
* attempt may still assign and ID to the entity, therefore reusing the same entity would cause
* retries to fail.
*/
@Override
public void insert(Object entity) {
checkArgumentNotNull(entity, "entity must be specified");

View File

@@ -18,11 +18,12 @@ import static com.google.common.base.Throwables.getRootCause;
import static google.registry.request.Action.Method.POST;
import static jakarta.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static java.util.stream.Collectors.joining;
import com.google.cloud.storage.BlobId;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.ByteStreams;
@@ -41,7 +42,6 @@ import jakarta.inject.Inject;
import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;
import java.util.stream.Collectors;
/** Copy all registrar detail reports in a given bucket's subdirectory from GCS to Drive. */
@Action(
@@ -98,7 +98,8 @@ public final class CopyDetailReportsAction implements Runnable {
response.setPayload(String.format("Failure, encountered %s", e.getMessage()));
return;
}
ImmutableMap.Builder<String, Throwable> copyErrorsBuilder = new ImmutableMap.Builder<>();
ImmutableMultimap.Builder<String, Throwable> copyErrorsBuilder =
new ImmutableMultimap.Builder<>();
for (String detailReportName : detailReportObjectNames) {
// The standard report format is "invoice_details_yyyy-MM_registrarId_tld.csv
// TODO(larryruili): Determine a safer way of enforcing this.
@@ -145,17 +146,18 @@ public final class CopyDetailReportsAction implements Runnable {
response.setStatus(SC_OK);
response.setContentType(MediaType.PLAIN_TEXT_UTF_8);
StringBuilder payload = new StringBuilder().append("Copied detail reports.\n");
ImmutableMap<String, Throwable> copyErrors = copyErrorsBuilder.build();
ImmutableMultimap<String, Throwable> copyErrors = copyErrorsBuilder.build();
if (!copyErrors.isEmpty()) {
payload.append("The following errors were encountered:\n");
payload.append(
copyErrors.entrySet().stream()
.map(
entrySet ->
String.format(
"Registrar: %s\nError: %s\n",
entrySet.getKey(), entrySet.getValue().getMessage()))
.collect(Collectors.joining()));
for (var registrarId : copyErrors.keySet()) {
payload.append(
String.format(
"Registrar: %s\nError: %s\n",
registrarId,
copyErrors.get(registrarId).stream()
.map(Throwable::getMessage)
.collect(joining("\n\t"))));
}
}
response.setPayload(payload.toString());
emailUtils.sendAlertEmail(payload.toString());

View File

@@ -20,6 +20,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
import google.registry.keyring.api.KeyModule.Key;
import google.registry.model.smd.SignedMarkRevocationList;
import google.registry.model.smd.SignedMarkRevocationListDao;
import google.registry.request.Action;
import google.registry.request.Action.GaeService;
import google.registry.request.auth.Auth;
@@ -57,7 +58,7 @@ public final class TmchSmdrlAction implements Runnable {
} catch (GeneralSecurityException | IOException | PGPException e) {
throw new RuntimeException(e);
}
smdrl.save();
smdrl = SignedMarkRevocationListDao.save(smdrl);
logger.atInfo().log(
"Inserted %,d smd revocations into the database, created at %s.",
smdrl.size(), smdrl.getCreationTime());

View File

@@ -182,6 +182,43 @@ class ContactDeleteFlowTest extends ResourceFlowTestCase<ContactDeleteFlow, Cont
assertAboutEppExceptions().that(thrown).marshalsToXml();
}
@Test
void testSuccess_clientDeleteProhibited_superuser() throws Exception {
persistResource(
persistActiveContact(getUniqueIdFromCommand())
.asBuilder()
.addStatusValue(StatusValue.CLIENT_DELETE_PROHIBITED)
.build());
runFlowAssertResponse(
CommitMode.LIVE, UserPrivileges.SUPERUSER, loadFile("contact_delete_response.xml"));
}
@Test
void testSuccess_serverDeleteProhibited_superuser() throws Exception {
persistResource(
persistActiveContact(getUniqueIdFromCommand())
.asBuilder()
.addStatusValue(StatusValue.SERVER_DELETE_PROHIBITED)
.build());
runFlowAssertResponse(
CommitMode.LIVE, UserPrivileges.SUPERUSER, loadFile("contact_delete_response.xml"));
}
@Test
void testFailure_pendingDelete_superuser() throws Exception {
persistResource(
persistActiveContact(getUniqueIdFromCommand())
.asBuilder()
.addStatusValue(StatusValue.PENDING_DELETE)
.build());
assertAboutEppExceptions()
.that(
assertThrows(
ResourceStatusProhibitsOperationException.class,
() -> runFlow(CommitMode.LIVE, UserPrivileges.SUPERUSER)))
.marshalsToXml();
}
@Test
void testFailure_unauthorizedClient() throws Exception {
sessionMetadata.setRegistrarId("NewRegistrar");

View File

@@ -179,6 +179,7 @@ import google.registry.model.reporting.DomainTransactionRecord;
import google.registry.model.reporting.DomainTransactionRecord.TransactionReportField;
import google.registry.model.reporting.HistoryEntry;
import google.registry.model.reporting.HistoryEntry.HistoryEntryId;
import google.registry.model.smd.SignedMarkRevocationListDao;
import google.registry.model.tld.Tld;
import google.registry.model.tld.Tld.TldState;
import google.registry.model.tld.Tld.TldType;
@@ -2727,8 +2728,9 @@ class DomainCreateFlowTest extends ResourceFlowTestCase<DomainCreateFlow, Domain
@Test
void testFail_startDateSunriseRegistration_revokedSignedMark() throws Exception {
SmdrlCsvParser.parse(TmchTestData.loadFile("smd/smdrl.csv").lines().collect(toImmutableList()))
.save();
SignedMarkRevocationListDao.save(
SmdrlCsvParser.parse(
TmchTestData.loadFile("smd/smdrl.csv").lines().collect(toImmutableList())));
createTld("tld", START_DATE_SUNRISE);
clock.setTo(SMD_VALID_TIME);
String revokedSmd =
@@ -2753,9 +2755,9 @@ class DomainCreateFlowTest extends ResourceFlowTestCase<DomainCreateFlow, Domain
if (labels.isEmpty()) {
return;
}
SmdrlCsvParser.parse(
TmchTestData.loadFile("idn/idn_smdrl.csv").lines().collect(toImmutableList()))
.save();
SignedMarkRevocationListDao.save(
SmdrlCsvParser.parse(
TmchTestData.loadFile("idn/idn_smdrl.csv").lines().collect(toImmutableList())));
createTld("tld", START_DATE_SUNRISE);
clock.setTo(SMD_VALID_TIME);
String revokedSmd =

View File

@@ -917,6 +917,52 @@ class DomainDeleteFlowTest extends ResourceFlowTestCase<DomainDeleteFlow, Domain
assertThat(thrown).hasMessageThat().contains("pendingDelete");
}
@Test
void testSuccess_clientDeleteProhibited_superuser() throws Exception {
eppRequestSource = EppRequestSource.TOOL;
setEppInput(
"domain_delete_superuser_extension.xml",
ImmutableMap.of("REDEMPTION_GRACE_PERIOD_DAYS", "15", "PENDING_DELETE_DAYS", "0"));
setUpSuccessfulTest();
persistResource(
domain.asBuilder().addStatusValue(StatusValue.CLIENT_DELETE_PROHIBITED).build());
clock.advanceOneMilli();
runFlowAssertResponse(
CommitMode.LIVE, UserPrivileges.SUPERUSER, loadFile("domain_delete_response_pending.xml"));
}
@Test
void testSuccess_serverDeleteProhibited_superuser() throws Exception {
eppRequestSource = EppRequestSource.TOOL;
setEppInput(
"domain_delete_superuser_extension.xml",
ImmutableMap.of("REDEMPTION_GRACE_PERIOD_DAYS", "15", "PENDING_DELETE_DAYS", "0"));
setUpSuccessfulTest();
persistResource(
domain.asBuilder().addStatusValue(StatusValue.SERVER_DELETE_PROHIBITED).build());
clock.advanceOneMilli();
runFlowAssertResponse(
CommitMode.LIVE, UserPrivileges.SUPERUSER, loadFile("domain_delete_response_pending.xml"));
}
@Test
void testFailure_pendingDelete_superuser() throws Exception {
eppRequestSource = EppRequestSource.TOOL;
setEppInput(
"domain_delete_superuser_extension.xml",
ImmutableMap.of("REDEMPTION_GRACE_PERIOD_DAYS", "15", "PENDING_DELETE_DAYS", "0"));
setUpSuccessfulTest();
persistResource(domain.asBuilder().addStatusValue(StatusValue.PENDING_DELETE).build());
ResourceStatusProhibitsOperationException thrown =
assertThrows(
ResourceStatusProhibitsOperationException.class,
() -> runFlow(CommitMode.LIVE, UserPrivileges.SUPERUSER));
assertThat(thrown).hasMessageThat().contains("pendingDelete");
}
@Test
void testSuccess_metadata() throws Exception {
eppRequestSource = EppRequestSource.TOOL;

View File

@@ -346,18 +346,18 @@ class DomainUpdateFlowTest extends ResourceFlowTestCase<DomainUpdateFlow, Domain
}
@Test
void testFailure_minimumDatasetPhase2_nonRegistrantContactsStillExist() throws Exception {
void testFailure_minimumDatasetPhase2_whenAddingNewContacts() throws Exception {
persistResource(
new FeatureFlag.Builder()
.setFeatureName(MINIMUM_DATASET_CONTACTS_PROHIBITED)
.setStatusMap(
ImmutableSortedMap.of(START_OF_TIME, INACTIVE, clock.nowUtc().minusDays(5), ACTIVE))
.build());
// This EPP adds a new technical contact mak21 that wasn't already present.
setEppInput("domain_update_empty_registrant.xml");
persistReferencedEntities();
persistDomain();
// Fails because after the update the domain would still have some contacts on it even though
// the registrant has been removed.
// Fails because the update adds some new contacts, although the registrant has been removed.
ContactsProhibitedException thrown =
assertThrows(ContactsProhibitedException.class, this::runFlow);
assertAboutEppExceptions().that(thrown).marshalsToXml();
@@ -1574,14 +1574,13 @@ class DomainUpdateFlowTest extends ResourceFlowTestCase<DomainUpdateFlow, Domain
}
@Test
void testFailure_minimumDatasetPhase2_registrantStillExists() throws Exception {
void testFailure_minimumDatasetPhase2_addingNewRegistrantFails() throws Exception {
persistResource(
new FeatureFlag.Builder()
.setFeatureName(MINIMUM_DATASET_CONTACTS_PROHIBITED)
.setStatusMap(
ImmutableSortedMap.of(START_OF_TIME, INACTIVE, clock.nowUtc().minusDays(5), ACTIVE))
.build());
setEppInput("domain_update_remove_admin.xml");
persistReferencedEntities();
persistResource(
DatabaseHelper.newDomain(getUniqueIdFromCommand())
@@ -1590,7 +1589,10 @@ class DomainUpdateFlowTest extends ResourceFlowTestCase<DomainUpdateFlow, Domain
ImmutableSet.of(
DesignatedContact.create(Type.ADMIN, sh8013Contact.createVKey()),
DesignatedContact.create(Type.TECH, sh8013Contact.createVKey())))
.setRegistrant(Optional.empty())
.build());
// This EPP sets the registrant to sh8013, whereas in our test setup it is absent.
setEppInput("domain_update_registrant.xml");
RegistrantProhibitedException thrown =
assertThrows(RegistrantProhibitedException.class, this::runFlow);
assertAboutEppExceptions().that(thrown).marshalsToXml();
@@ -1657,6 +1659,32 @@ class DomainUpdateFlowTest extends ResourceFlowTestCase<DomainUpdateFlow, Domain
assertThat(updatedDomain.getContacts()).isEmpty();
}
@Test
void testSuccess_minimumDatasetPhase2_removeOneContact() throws Exception {
persistResource(
new FeatureFlag.Builder()
.setFeatureName(MINIMUM_DATASET_CONTACTS_PROHIBITED)
.setStatusMap(
ImmutableSortedMap.of(START_OF_TIME, INACTIVE, clock.nowUtc().minusDays(5), ACTIVE))
.build());
setEppInput("domain_update_remove_admin.xml");
persistReferencedEntities();
persistResource(
DatabaseHelper.newDomain(getUniqueIdFromCommand())
.asBuilder()
.setContacts(
ImmutableSet.of(
DesignatedContact.create(Type.ADMIN, sh8013Contact.createVKey()),
DesignatedContact.create(Type.TECH, sh8013Contact.createVKey())))
.build());
assertThat(reloadResourceByForeignKey().getRegistrant()).isPresent();
assertThat(reloadResourceByForeignKey().getContacts()).hasSize(2);
runFlowAssertResponse(loadFile("generic_success_response.xml"));
Domain updatedDomain = reloadResourceByForeignKey();
assertThat(updatedDomain.getRegistrant()).isPresent();
assertThat(updatedDomain.getContacts()).hasSize(1);
}
@Test
void testFailure_addPendingDeleteContact() throws Exception {
persistReferencedEntities();

View File

@@ -153,6 +153,43 @@ class HostDeleteFlowTest extends ResourceFlowTestCase<HostDeleteFlow, Host> {
assertSqlDeleteSuccess();
}
@Test
void testSuccess_clientDeleteProhibited_superuser() throws Exception {
persistResource(
persistActiveHost("ns1.example.tld")
.asBuilder()
.addStatusValue(StatusValue.CLIENT_DELETE_PROHIBITED)
.build());
runFlowAssertResponse(
CommitMode.LIVE, UserPrivileges.SUPERUSER, loadFile("host_delete_response.xml"));
}
@Test
void testSuccess_serverDeleteProhibited_superuser() throws Exception {
persistResource(
persistActiveHost("ns1.example.tld")
.asBuilder()
.addStatusValue(StatusValue.SERVER_DELETE_PROHIBITED)
.build());
runFlowAssertResponse(
CommitMode.LIVE, UserPrivileges.SUPERUSER, loadFile("host_delete_response.xml"));
}
@Test
void testFailure_pendingDelete_superuser() throws Exception {
persistResource(
persistActiveHost("ns1.example.tld")
.asBuilder()
.addStatusValue(StatusValue.PENDING_DELETE)
.build());
assertAboutEppExceptions()
.that(
assertThrows(
ResourceStatusProhibitsOperationException.class,
() -> runFlow(CommitMode.LIVE, UserPrivileges.SUPERUSER)))
.marshalsToXml();
}
@Test
void testSuccess_authorizedClientReadFromSuperordinate() throws Exception {
sessionMetadata.setRegistrarId("TheRegistrar");

View File

@@ -16,9 +16,12 @@ package google.registry.model.smd;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.model.ImmutableObjectSubject.assertAboutImmutableObjects;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import com.google.common.collect.ImmutableMap;
import google.registry.model.EntityTestCase;
import jakarta.persistence.OptimisticLockException;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
public class SignedMarkRevocationListDaoTest extends EntityTestCase {
@@ -32,11 +35,29 @@ public class SignedMarkRevocationListDaoTest extends EntityTestCase {
SignedMarkRevocationList list =
SignedMarkRevocationList.create(
fakeClock.nowUtc(), ImmutableMap.of("mark", fakeClock.nowUtc().minusHours(1)));
SignedMarkRevocationListDao.save(list);
list = SignedMarkRevocationListDao.save(list);
SignedMarkRevocationList fromDb = SignedMarkRevocationListDao.load();
assertAboutImmutableObjects().that(fromDb).isEqualExceptFields(list);
}
@Test
void testSave_retrySuccess() {
SignedMarkRevocationList list =
SignedMarkRevocationList.create(
fakeClock.nowUtc(), ImmutableMap.of("mark", fakeClock.nowUtc().minusHours(1)));
AtomicBoolean isFirstAttempt = new AtomicBoolean(true);
tm().transact(
() -> {
SignedMarkRevocationListDao.save(list);
if (isFirstAttempt.get()) {
isFirstAttempt.set(false);
throw new OptimisticLockException();
}
});
SignedMarkRevocationList fromDb = SignedMarkRevocationListDao.load();
assertAboutImmutableObjects().that(fromDb).isEqualExceptFields(list, "revisionId");
}
@Test
void testSaveAndLoad_emptyList() {
SignedMarkRevocationList list =

View File

@@ -48,7 +48,8 @@ public class SignedMarkRevocationListTest {
for (int i = 0; i < rows; i++) {
revokes.put(Integer.toString(i), clock.nowUtc());
}
SignedMarkRevocationList.create(clock.nowUtc(), revokes.build()).save();
SignedMarkRevocationListDao.save(
SignedMarkRevocationList.create(clock.nowUtc(), revokes.build()));
SignedMarkRevocationList res = SignedMarkRevocationList.get();
assertThat(res.size()).isEqualTo(rows);
return res;

View File

@@ -31,10 +31,12 @@ import google.registry.persistence.transaction.JpaTestExtensions;
import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationWithCoverageExtension;
import google.registry.testing.FakeClock;
import google.registry.testing.TestCacheExtension;
import jakarta.persistence.OptimisticLockException;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.IntStream;
import org.joda.money.CurrencyUnit;
import org.joda.money.Money;
@@ -93,6 +95,27 @@ public class PremiumListDaoTest {
});
}
@Test
void saveNew_retry_success() {
AtomicBoolean isFirstAttempt = new AtomicBoolean(true);
tm().transact(
() -> {
PremiumListDao.save(testList);
if (isFirstAttempt.get()) {
isFirstAttempt.set(false);
throw new OptimisticLockException();
}
});
tm().transact(
() -> {
Optional<PremiumList> persistedListOpt = PremiumListDao.getLatestRevision("testname");
assertThat(persistedListOpt).isPresent();
PremiumList persistedList = persistedListOpt.get();
assertThat(persistedList.getLabelsToPrices()).containsExactlyEntriesIn(TEST_PRICES);
assertThat(persistedList.getCreationTimestamp()).isEqualTo(fakeClock.nowUtc());
});
}
@Test
void update_worksSuccessfully() {
PremiumListDao.save(testList);

View File

@@ -22,6 +22,8 @@ import google.registry.model.tld.label.ReservedList.ReservedListEntry;
import google.registry.persistence.transaction.JpaTestExtensions;
import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationWithCoverageExtension;
import google.registry.testing.FakeClock;
import jakarta.persistence.OptimisticLockException;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -71,11 +73,34 @@ public class ReservedListDaoTest {
});
}
@Test
void save_withRetry_worksSuccessfully() {
AtomicBoolean isFirstAttempt = new AtomicBoolean(true);
tm().transact(
() -> {
ReservedListDao.save(testReservedList);
if (isFirstAttempt.get()) {
isFirstAttempt.set(false);
throw new OptimisticLockException();
}
});
tm().transact(
() -> {
ReservedList persistedList =
tm().query("FROM ReservedList WHERE name = :name", ReservedList.class)
.setParameter("name", "testlist")
.getSingleResult();
assertThat(persistedList.getReservedListEntries())
.containsExactlyEntriesIn(testReservations);
assertThat(persistedList.getCreationTimestamp()).isEqualTo(fakeClock.nowUtc());
});
}
@Test
void delete_worksSuccessfully() {
ReservedListDao.save(testReservedList);
var persisted = ReservedListDao.save(testReservedList);
assertThat(ReservedListDao.checkExists("testlist")).isTrue();
ReservedListDao.delete(testReservedList);
ReservedListDao.delete(persisted);
assertThat(ReservedListDao.checkExists("testlist")).isFalse();
}

View File

@@ -16,15 +16,15 @@ package google.registry.model.tmch;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.common.collect.ImmutableMap;
import google.registry.persistence.transaction.JpaTestExtensions;
import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationWithCoverageExtension;
import google.registry.testing.FakeClock;
import google.registry.testing.TestCacheExtension;
import jakarta.persistence.PersistenceException;
import jakarta.persistence.OptimisticLockException;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -49,27 +49,36 @@ public class ClaimsListDaoTest {
void save_insertsClaimsListSuccessfully() {
ClaimsList claimsList =
ClaimsList.create(fakeClock.nowUtc(), ImmutableMap.of("label1", "key1", "label2", "key2"));
ClaimsListDao.save(claimsList);
claimsList = ClaimsListDao.save(claimsList);
ClaimsList insertedClaimsList = ClaimsListDao.get();
assertClaimsListEquals(claimsList, insertedClaimsList);
assertThat(insertedClaimsList.getCreationTimestamp()).isEqualTo(fakeClock.nowUtc());
}
@Test
void save_fail_duplicateId() {
void save_insertsClaimsListSuccessfully_withRetries() {
ClaimsList claimsList =
ClaimsList.create(fakeClock.nowUtc(), ImmutableMap.of("label1", "key1", "label2", "key2"));
ClaimsListDao.save(claimsList);
AtomicBoolean isFirstAttempt = new AtomicBoolean(true);
tm().transact(
() -> {
ClaimsListDao.save(claimsList);
if (isFirstAttempt.get()) {
isFirstAttempt.set(false);
throw new OptimisticLockException();
}
});
ClaimsList insertedClaimsList = ClaimsListDao.get();
assertClaimsListEquals(claimsList, insertedClaimsList);
// Save ClaimsList with existing revisionId should fail because revisionId is the primary key.
assertThrows(PersistenceException.class, () -> ClaimsListDao.save(insertedClaimsList));
assertThat(insertedClaimsList.getTmdbGenerationTime())
.isEqualTo(claimsList.getTmdbGenerationTime());
assertThat(insertedClaimsList.getLabelsToKeys()).isEqualTo(claimsList.getLabelsToKeys());
assertThat(insertedClaimsList.getCreationTimestamp()).isEqualTo(fakeClock.nowUtc());
}
@Test
void save_claimsListWithNoEntries() {
ClaimsList claimsList = ClaimsList.create(fakeClock.nowUtc(), ImmutableMap.of());
ClaimsListDao.save(claimsList);
claimsList = ClaimsListDao.save(claimsList);
ClaimsList insertedClaimsList = ClaimsListDao.get();
assertClaimsListEquals(claimsList, insertedClaimsList);
assertThat(insertedClaimsList.getLabelsToKeys()).isEmpty();
@@ -86,8 +95,8 @@ public class ClaimsListDaoTest {
ClaimsList.create(fakeClock.nowUtc(), ImmutableMap.of("label1", "key1", "label2", "key2"));
ClaimsList newClaimsList =
ClaimsList.create(fakeClock.nowUtc(), ImmutableMap.of("label3", "key3", "label4", "key4"));
ClaimsListDao.save(oldClaimsList);
ClaimsListDao.save(newClaimsList);
oldClaimsList = ClaimsListDao.save(oldClaimsList);
newClaimsList = ClaimsListDao.save(newClaimsList);
assertClaimsListEquals(newClaimsList, ClaimsListDao.get());
}
@@ -96,11 +105,11 @@ public class ClaimsListDaoTest {
assertThat(ClaimsListDao.CACHE.getIfPresent(ClaimsListDao.class)).isNull();
ClaimsList oldList =
ClaimsList.create(fakeClock.nowUtc(), ImmutableMap.of("label1", "key1", "label2", "key2"));
ClaimsListDao.save(oldList);
oldList = ClaimsListDao.save(oldList);
assertThat(ClaimsListDao.CACHE.getIfPresent(ClaimsListDao.class)).isEqualTo(oldList);
ClaimsList newList =
ClaimsList.create(fakeClock.nowUtc(), ImmutableMap.of("label3", "key3", "label4", "key4"));
ClaimsListDao.save(newList);
newList = ClaimsListDao.save(newList);
assertThat(ClaimsListDao.CACHE.getIfPresent(ClaimsListDao.class)).isEqualTo(newList);
}

View File

@@ -178,21 +178,56 @@ class CopyDetailReportsActionTest {
verify(emailUtils)
.sendAlertEmail(
"""
Copied detail reports.
The following errors were encountered:
Registrar: TheRegistrar
Error: java.io.IOException: expected
""");
Copied detail reports.
The following errors were encountered:
Registrar: TheRegistrar
Error: java.io.IOException: expected
""");
assertThat(response.getStatus()).isEqualTo(SC_OK);
assertThat(response.getContentType()).isEqualTo(MediaType.PLAIN_TEXT_UTF_8);
assertThat(response.getPayload())
.isEqualTo(
"""
Copied detail reports.
The following errors were encountered:
Registrar: TheRegistrar
Error: java.io.IOException: expected
""");
Copied detail reports.
The following errors were encountered:
Registrar: TheRegistrar
Error: java.io.IOException: expected
""");
}
@Test
void testFail_tooManyFailures_one_registrar_sendsAlertEmail_continues() throws IOException {
gcsUtils.createFromBytes(
BlobId.of("test-bucket", "results/invoice_details_2017-10_TheRegistrar_hello.csv"),
"hola,mundo\n3,4".getBytes(UTF_8));
gcsUtils.createFromBytes(
BlobId.of("test-bucket", "results/invoice_details_2017-10_TheRegistrar_test.csv"),
"hello,world\n1,2".getBytes(UTF_8));
when(driveConnection.createOrUpdateFile(any(), any(), any(), any()))
.thenThrow(new IOException("expected"));
action.run();
verify(emailUtils)
.sendAlertEmail(
"""
Copied detail reports.
The following errors were encountered:
Registrar: TheRegistrar
Error: java.io.IOException: expected
\tjava.io.IOException: expected
""");
assertThat(response.getStatus()).isEqualTo(SC_OK);
assertThat(response.getContentType()).isEqualTo(MediaType.PLAIN_TEXT_UTF_8);
assertThat(response.getPayload())
.isEqualTo(
"""
Copied detail reports.
The following errors were encountered:
Registrar: TheRegistrar
Error: java.io.IOException: expected
\tjava.io.IOException: expected
""");
}
@Test

View File

@@ -19,8 +19,11 @@ import static com.google.common.base.Preconditions.checkNotNull;
import static google.registry.networking.handler.SslServerInitializer.CLIENT_CERTIFICATE_PROMISE_KEY;
import static google.registry.proxy.handler.ProxyProtocolHandler.REMOTE_ADDRESS_KEY;
import static google.registry.util.X509Utils.getCertificateHash;
import static java.nio.charset.StandardCharsets.US_ASCII;
import com.google.common.base.Strings;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.BaseEncoding;
import google.registry.proxy.metric.FrontendMetrics;
import google.registry.util.ProxyHttpHeaders;
import io.netty.buffer.ByteBuf;
@@ -36,7 +39,11 @@ import io.netty.handler.ssl.SslHandshakeCompletionEvent;
import io.netty.util.AttributeKey;
import io.netty.util.concurrent.Promise;
import java.security.cert.X509Certificate;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
/** Handler that processes EPP protocol logic. */
public class EppServiceHandler extends HttpsRelayServiceHandler {
@@ -57,6 +64,8 @@ public class EppServiceHandler extends HttpsRelayServiceHandler {
private String sslClientCertificateHash;
private String clientAddress;
private Optional<String> maybeRegistrarId = Optional.empty();
public EppServiceHandler(
String relayHost,
String relayPath,
@@ -128,6 +137,9 @@ public class EppServiceHandler extends HttpsRelayServiceHandler {
.set(ProxyHttpHeaders.FALLBACK_IP_ADDRESS, clientAddress)
.set(HttpHeaderNames.CONTENT_TYPE, EPP_CONTENT_TYPE)
.set(HttpHeaderNames.ACCEPT, EPP_CONTENT_TYPE);
maybeSetRegistrarIdHeader(request);
return request;
}
@@ -142,4 +154,54 @@ public class EppServiceHandler extends HttpsRelayServiceHandler {
}
super.write(ctx, msg, promise);
}
/**
* Sets and caches the Registrar-ID header on the request if the ID can be found.
*
* <p>This method first checks if the registrar ID has already been determined. If not, it
* inspects the cookies for a "SESSION_INFO" cookie, from which it attempts to extract the
* registrar ID.
*
* @param request The {@link FullHttpRequest} on which to potentially set the registrar ID header.
* @see #extractRegistrarIdFromSessionInfo(String)
*/
private void maybeSetRegistrarIdHeader(FullHttpRequest request) {
if (maybeRegistrarId.isEmpty()) {
maybeRegistrarId =
cookieStore.entrySet().stream()
.map(e -> e.getValue())
.filter(cookie -> "SESSION_INFO".equals(cookie.name()))
.findFirst()
.flatMap(cookie -> extractRegistrarIdFromSessionInfo(cookie.value()));
}
if (maybeRegistrarId.isPresent() && !Strings.isNullOrEmpty(maybeRegistrarId.get())) {
request.headers().set(ProxyHttpHeaders.REGISTRAR_ID, maybeRegistrarId.get());
}
}
/** Extracts the registrar ID from a Base64-encoded session info string. */
private Optional<String> extractRegistrarIdFromSessionInfo(@Nullable String sessionInfo) {
if (sessionInfo == null) {
return Optional.empty();
}
try {
String decodedString = new String(BaseEncoding.base64Url().decode(sessionInfo), US_ASCII);
Pattern pattern = Pattern.compile("clientId=([^,\\s]+)?");
Matcher matcher = pattern.matcher(decodedString);
if (matcher.find()) {
String maybeRegistrarIdMatch = matcher.group(1);
if (!maybeRegistrarIdMatch.equals("null")) {
return Optional.of(maybeRegistrarIdMatch);
}
}
} catch (Throwable e) {
logger.atSevere().withCause(e).log("Failed to decode session info from Base64");
}
return Optional.empty();
}
}

View File

@@ -70,7 +70,7 @@ public abstract class HttpsRelayServiceHandler extends ByteToMessageCodec<FullHt
protected static final ImmutableSet<Class<? extends Exception>> NON_FATAL_OUTBOUND_EXCEPTIONS =
ImmutableSet.of(NonOkHttpResponseException.class);
private final Map<String, Cookie> cookieStore = new LinkedHashMap<>();
protected final Map<String, Cookie> cookieStore = new LinkedHashMap<>();
private final String relayHost;
private final String relayPath;
private final boolean canary;

View File

@@ -20,6 +20,7 @@ import static google.registry.proxy.TestUtils.assertHttpRequestEquivalent;
import static google.registry.proxy.TestUtils.makeEppHttpResponse;
import static google.registry.proxy.handler.ProxyProtocolHandler.REMOTE_ADDRESS_KEY;
import static google.registry.util.X509Utils.getCertificateHash;
import static java.nio.charset.StandardCharsets.US_ASCII;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
@@ -27,6 +28,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import com.google.common.base.Throwables;
import com.google.common.io.BaseEncoding;
import google.registry.proxy.TestUtils;
import google.registry.proxy.handler.HttpsRelayServiceHandler.NonOkHttpResponseException;
import google.registry.proxy.metric.FrontendMetrics;
@@ -357,4 +359,82 @@ class EppServiceHandlerTest {
assertThat((Object) channel.readOutbound()).isNull();
assertThat(channel.isActive()).isTrue();
}
@Test
void testSuccess_registrarIdHeader_isSetFromSessionInfoCookie() throws Exception {
setHandshakeSuccess();
channel.readInbound(); // Read and discard the initial hello request.
// Simulate a server response that sets the SESSION_INFO cookie.
String registrarId = "TheRegistrar";
String sessionInfoValue =
BaseEncoding.base64Url()
.encode(("alpha,clientId=" + registrarId + ",beta").getBytes(US_ASCII));
Cookie sessionCookie = new DefaultCookie("SESSION_INFO", sessionInfoValue);
channel.writeOutbound(
makeEppHttpResponse("<epp>greeting</epp>", HttpResponseStatus.OK, sessionCookie));
channel.readOutbound(); // Read and discard the response sent to the client.
// Simulate a subsequent client request and check for the registrar ID header.
String clientRequestContent = "<epp>login</epp>";
channel.writeInbound(Unpooled.wrappedBuffer(clientRequestContent.getBytes(UTF_8)));
FullHttpRequest relayedRequest = channel.readInbound();
FullHttpRequest expectedRequest = makeEppHttpRequest(clientRequestContent, sessionCookie);
expectedRequest.headers().set(ProxyHttpHeaders.REGISTRAR_ID, registrarId);
assertHttpRequestEquivalent(relayedRequest, expectedRequest);
assertThat((Object) channel.readInbound()).isNull();
assertThat(channel.isActive()).isTrue();
}
@Test
void testSuccess_registrarIdHeader_isNotSetWhenSessionInfoCookieIsMissing() throws Exception {
setHandshakeSuccess();
channel.readInbound(); // Read and discard the initial hello request.
// Simulate a server response that does NOT set the SESSION_INFO cookie.
Cookie otherCookie = new DefaultCookie("some_other_cookie", "some_value");
channel.writeOutbound(
makeEppHttpResponse("<epp>greeting</epp>", HttpResponseStatus.OK, otherCookie));
channel.readOutbound(); // Read and discard the response sent to the client.
// Simulate a subsequent client request and verify the header is absent.
String clientRequestContent = "<epp>login</epp>";
channel.writeInbound(Unpooled.wrappedBuffer(clientRequestContent.getBytes(UTF_8)));
FullHttpRequest relayedRequest = channel.readInbound();
FullHttpRequest expectedRequest = makeEppHttpRequest(clientRequestContent, otherCookie);
assertHttpRequestEquivalent(relayedRequest, expectedRequest);
assertThat(relayedRequest.headers().contains(ProxyHttpHeaders.REGISTRAR_ID)).isFalse();
assertThat((Object) channel.readInbound()).isNull();
assertThat(channel.isActive()).isTrue();
}
@Test
void testSuccess_registrarIdHeader_isNotSetWhenClientIdIsNull() throws Exception {
setHandshakeSuccess();
channel.readInbound(); // Read and discard the initial hello request.
// Simulate a server response with a SESSION_INFO cookie where clientId is "null".
String sessionInfoValue =
BaseEncoding.base64Url().encode("alpha,clientId=null,beta".getBytes(US_ASCII));
Cookie sessionCookie = new DefaultCookie("SESSION_INFO", sessionInfoValue);
channel.writeOutbound(
makeEppHttpResponse("<epp>greeting</epp>", HttpResponseStatus.OK, sessionCookie));
channel.readOutbound(); // Read and discard the response sent to the client.
// Simulate a subsequent client request and verify the header is absent.
String clientRequestContent = "<epp>login</epp>";
channel.writeInbound(Unpooled.wrappedBuffer(clientRequestContent.getBytes(UTF_8)));
FullHttpRequest relayedRequest = channel.readInbound();
FullHttpRequest expectedRequest = makeEppHttpRequest(clientRequestContent, sessionCookie);
assertHttpRequestEquivalent(relayedRequest, expectedRequest);
assertThat(relayedRequest.headers().contains(ProxyHttpHeaders.REGISTRAR_ID)).isFalse();
assertThat((Object) channel.readInbound()).isNull();
assertThat(channel.isActive()).isTrue();
}
}

View File

@@ -30,6 +30,9 @@ public final class ProxyHttpHeaders {
/** HTTP header name used to pass the client IP address from the proxy to Nomulus. */
public static final String IP_ADDRESS = "Nomulus-Client-Address";
/** HTTP header name used to pass the Registrar Id from the proxy to Nomulus. */
public static final String REGISTRAR_ID = "Nomulus-Registrar-Id";
/**
* Fallback HTTP header name used to pass the client IP address from the proxy to Nomulus.
*