From fe06592a1cb2d90b04f77609951cf39ab6678bbd Mon Sep 17 00:00:00 2001 From: Ben McIlwain Date: Thu, 27 Aug 2026 14:41:28 -0400 Subject: [PATCH] Add RDAP special notice for domains in XAP When an RDAP lookup is performed for a domain that is currently in the Expiry Access Period (XAP), return HTTP 404 with a custom status notice: "This domain is currently available for registration in the Expiry Access Period". This behavior mirrors how BSA-blocked domains return specialized RDAP notices via a shared DomainNotFoundErrorResponse base class. To optimize performance and eliminate redundant Cloud SQL database calls on 404 queries: - Expose loadByDomainNameIncludingDeleted in DomainCache and MultilayerDomainCache (backed by loadFromCachesIncludingDeleted and ForeignKeyUtils.loadResourceByCacheIncludingDeleted) to retrieve soft-deleted domains without filtering them out, while preserving temporal projections. - Update SyncRemoteCacheAction, MultilayerDomainCache, and MultilayerEppResourceCache to set the Valkey expiration time (pxAt) and retention cutoff for all Domain resources to deletionTime.plus(domainExpiryAccessPeriodTotalLength), ensuring pending-delete domains remain cached across the XAP transition and recently deleted domains are warm if XAP is enabled on a TLD. - Evaluate XAP eligibility entirely in-memory in RdapDomainAction using the already-loaded domain object and constructor-injected domainExpiryAccessPeriodTotalLength configuration. - Add comprehensive unit tests in RdapDomainActionTest, MultilayerDomainCacheTest, MultilayerHostCacheTest, SimplifiedJedisClientTest, SyncRemoteCacheActionTest, ForeignKeyUtilsTest, and DomainFlowUtilsTest. TAG=agy CONV=214408e9-2dbb-44b8-af91-1ca29401d1f5 BUG=http://b/553509095 --- .../registry/batch/SyncRemoteCacheAction.java | 24 +- .../google/registry/cache/CacheModule.java | 23 +- .../google/registry/cache/DomainCache.java | 8 + .../registry/cache/MultilayerDomainCache.java | 27 +- .../cache/MultilayerEppResourceCache.java | 24 +- .../registry/cache/SimplifiedJedisClient.java | 48 +- .../flows/domain/DomainCheckFlow.java | 9 +- .../flows/domain/DomainCreateFlow.java | 11 + .../flows/domain/DomainFlowUtils.java | 18 +- .../registry/model/ForeignKeyUtils.java | 16 +- .../google/registry/rdap/RdapActionBase.java | 12 +- .../registry/rdap/RdapDomainAction.java | 30 +- .../rdap/RdapIcannStandardInformation.java | 12 + .../registry/rdap/RdapObjectClasses.java | 26 +- .../batch/SyncRemoteCacheActionTest.java | 220 ++++- .../cache/MultilayerDomainCacheTest.java | 210 ++++- .../cache/MultilayerHostCacheTest.java | 15 + .../cache/SimplifiedJedisClientTest.java | 141 ++- .../registry/flows/EppTestComponent.java | 17 + .../flows/domain/DomainCreateFlowTest.java | 183 +++- .../flows/domain/DomainFlowUtilsTest.java | 195 ++++ .../registry/model/ForeignKeyUtilsTest.java | 30 +- .../registry/rdap/RdapActionBaseTestCase.java | 16 +- .../registry/rdap/RdapDomainActionTest.java | 881 +++++++++++++++++- 24 files changed, 2118 insertions(+), 78 deletions(-) diff --git a/core/src/main/java/google/registry/batch/SyncRemoteCacheAction.java b/core/src/main/java/google/registry/batch/SyncRemoteCacheAction.java index 8f3c610736a..4b05eaedc8e 100644 --- a/core/src/main/java/google/registry/batch/SyncRemoteCacheAction.java +++ b/core/src/main/java/google/registry/batch/SyncRemoteCacheAction.java @@ -32,6 +32,7 @@ import com.google.monitoring.metrics.LabelDescriptor; import com.google.monitoring.metrics.MetricRegistryImpl; import google.registry.cache.SimplifiedJedisClient; +import google.registry.config.RegistryConfig.Config; import google.registry.model.EppResource; import google.registry.model.common.Cursor; import google.registry.model.domain.Domain; @@ -87,13 +88,18 @@ public enum SyncStatus { private final LockHandler lockHandler; private final Response response; private final Optional jedisClient; + private final Duration domainExpiryAccessPeriodTotalLength; @Inject public SyncRemoteCacheAction( - LockHandler lockHandler, Response response, Optional jedisClient) { + LockHandler lockHandler, + Response response, + Optional jedisClient, + @Config("domainExpiryAccessPeriodTotalLength") Duration domainExpiryAccessPeriodTotalLength) { this.lockHandler = lockHandler; this.response = response; this.jedisClient = jedisClient; + this.domainExpiryAccessPeriodTotalLength = domainExpiryAccessPeriodTotalLength; } @Override @@ -187,10 +193,12 @@ private void processResources( ImmutableList.Builder> toSaveBuilder = new ImmutableList.Builder<>(); + Instant now = tm().getTxTime(); for (T resource : resources) { String key = getKeyFunction.apply(resource); - if (resource.getDeletionTime().isAfter(tm().getTxTime())) { - toSaveBuilder.add(new SimplifiedJedisClient.JedisResource<>(key, resource)); + if (shouldSaveResourceInRemoteCache(resource, now)) { + toSaveBuilder.add( + new SimplifiedJedisClient.JedisResource<>(key, resource, getExpirationTime(resource))); } else { toDeleteBuilder.add(key); } @@ -204,6 +212,16 @@ private void processResources( logger.atInfo().log("Set %d in the remote cache", toSave.size()); } + private Optional getExpirationTime(EppResource resource) { + return resource instanceof Domain domain + ? Optional.of(domain.getDeletionTime().plus(domainExpiryAccessPeriodTotalLength)) + : Optional.empty(); + } + + private boolean shouldSaveResourceInRemoteCache(EppResource resource, Instant now) { + return getExpirationTime(resource).orElseGet(resource::getDeletionTime).isAfter(now); + } + private Instant getPreviousCursorTime(Cursor.CursorType cursorType) { return tm().loadByKeyIfPresent(Cursor.createGlobalVKey(cursorType)) .map(Cursor::getCursorTime) diff --git a/core/src/main/java/google/registry/cache/CacheModule.java b/core/src/main/java/google/registry/cache/CacheModule.java index ec8e46e1806..9b461cc420c 100644 --- a/core/src/main/java/google/registry/cache/CacheModule.java +++ b/core/src/main/java/google/registry/cache/CacheModule.java @@ -38,6 +38,7 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.time.Duration; import java.time.Instant; import java.util.Optional; import javax.net.ssl.SSLContext; @@ -96,12 +97,26 @@ public static Optional provideJedisClient(Optional jedisClient, Clock clock, CacheMetrics cacheMetrics) { + Optional jedisClient, + Clock clock, + CacheMetrics cacheMetrics, + @Config("domainExpiryAccessPeriodTotalLength") Duration domainExpiryAccessPeriodTotalLength) { if (jedisClient.isEmpty()) { - return domainName -> - ForeignKeyUtils.loadResourceByCache(Domain.class, domainName, clock.now()); + return new DomainCache() { + @Override + public Optional loadByDomainName(String domainName) { + return ForeignKeyUtils.loadResourceByCache(Domain.class, domainName, clock.now()); + } + + @Override + public Optional loadByDomainNameIncludingDeleted(String domainName) { + return ForeignKeyUtils.loadResourceByCacheIncludingDeleted( + Domain.class, domainName, clock.now()); + } + }; } - return new MultilayerDomainCache(jedisClient.get(), clock, cacheMetrics); + return new MultilayerDomainCache( + jedisClient.get(), clock, cacheMetrics, domainExpiryAccessPeriodTotalLength); } @Provides diff --git a/core/src/main/java/google/registry/cache/DomainCache.java b/core/src/main/java/google/registry/cache/DomainCache.java index c92d339f7cc..3b59702571d 100644 --- a/core/src/main/java/google/registry/cache/DomainCache.java +++ b/core/src/main/java/google/registry/cache/DomainCache.java @@ -19,5 +19,13 @@ /** Interface for some type of cache that loads {@link Domain}s by domain name. */ public interface DomainCache { + + /** Loads the active domain by domain name, filtering out soft-deleted domains. */ Optional loadByDomainName(String domainName); + + /** + * Loads the most recent domain by domain name from cache or database, including soft-deleted + * domains. + */ + Optional loadByDomainNameIncludingDeleted(String domainName); } diff --git a/core/src/main/java/google/registry/cache/MultilayerDomainCache.java b/core/src/main/java/google/registry/cache/MultilayerDomainCache.java index fdef3fb9c2f..ce103ee68ca 100644 --- a/core/src/main/java/google/registry/cache/MultilayerDomainCache.java +++ b/core/src/main/java/google/registry/cache/MultilayerDomainCache.java @@ -15,10 +15,15 @@ package google.registry.cache; import com.google.common.collect.ImmutableList; +import google.registry.config.RegistryConfig.Config; import google.registry.model.ForeignKeyUtils; import google.registry.model.domain.Domain; import google.registry.model.tld.Tld; +import google.registry.model.tld.Tld.TldType; import google.registry.util.Clock; +import jakarta.inject.Inject; +import java.time.Duration; +import java.time.Instant; import java.util.Optional; /** @@ -29,9 +34,16 @@ public class MultilayerDomainCache extends MultilayerEppResourceCache implements DomainCache { + private final Duration domainExpiryAccessPeriodTotalLength; + + @Inject public MultilayerDomainCache( - SimplifiedJedisClient jedisClient, Clock clock, CacheMetrics cacheMetrics) { + SimplifiedJedisClient jedisClient, + Clock clock, + CacheMetrics cacheMetrics, + @Config("domainExpiryAccessPeriodTotalLength") Duration domainExpiryAccessPeriodTotalLength) { super(jedisClient, clock, cacheMetrics); + this.domainExpiryAccessPeriodTotalLength = domainExpiryAccessPeriodTotalLength; } @Override @@ -39,6 +51,11 @@ public Optional loadByDomainName(String domainName) { return loadFromCaches(Domain.class, domainName); } + @Override + public Optional loadByDomainNameIncludingDeleted(String domainName) { + return loadFromCachesIncludingDeleted(Domain.class, domainName); + } + @Override protected Optional loadFromDatabase(String domainName) { // Don't use the cache (avoid caching the same domain twice). Do use the replica SQL instance. @@ -50,6 +67,12 @@ protected Optional loadFromDatabase(String domainName) { @Override protected boolean shouldPersistToRemoteCache(Domain domain) { - return Tld.get(domain.getTld()).getTldType().equals(Tld.TldType.REAL); + return Tld.get(domain.getTld()).getTldType().equals(TldType.REAL) + && super.shouldPersistToRemoteCache(domain); + } + + @Override + protected Optional getExpirationTime(Domain domain) { + return Optional.of(domain.getDeletionTime().plus(domainExpiryAccessPeriodTotalLength)); } } diff --git a/core/src/main/java/google/registry/cache/MultilayerEppResourceCache.java b/core/src/main/java/google/registry/cache/MultilayerEppResourceCache.java index 54f5b895a79..bc3a968fe79 100644 --- a/core/src/main/java/google/registry/cache/MultilayerEppResourceCache.java +++ b/core/src/main/java/google/registry/cache/MultilayerEppResourceCache.java @@ -38,7 +38,7 @@ public abstract class MultilayerEppResourceCache { .build(); private final SimplifiedJedisClient jedisClient; - private final Clock clock; + protected final Clock clock; private final CacheMetrics cacheMetrics; protected MultilayerEppResourceCache( @@ -51,16 +51,23 @@ protected MultilayerEppResourceCache( protected abstract Optional loadFromDatabase(String key); protected boolean shouldPersistToRemoteCache(V value) { - return true; + return getExpirationTime(value).orElseGet(value::getDeletionTime).isAfter(clock.now()); + } + + protected Optional getExpirationTime(V resource) { + return Optional.empty(); } - @SuppressWarnings("unchecked") protected Optional loadFromCaches(Class clazz, String key) { Instant now = clock.now(); - return (Optional) - loadFromCachesInternal(clazz, key) - .filter(v -> now.isBefore(v.getDeletionTime())) - .map(v -> v.cloneProjectedAtTime(now)); + return loadFromCachesIncludingDeleted(clazz, key) + .filter(v -> now.isBefore(v.getDeletionTime())); + } + + @SuppressWarnings("unchecked") + protected Optional loadFromCachesIncludingDeleted(Class clazz, String key) { + Instant now = clock.now(); + return (Optional) loadFromCachesInternal(clazz, key).map(v -> v.cloneProjectedAtTime(now)); } private Optional loadFromCachesInternal(Class clazz, String key) { @@ -87,7 +94,8 @@ private Optional loadFromCachesInternal(Class clazz, String key) { } V value = possibleValue.get(); if (shouldPersistToRemoteCache(value)) { - jedisClient.set(new SimplifiedJedisClient.JedisResource<>(key, value)); + jedisClient.set( + new SimplifiedJedisClient.JedisResource<>(key, value, getExpirationTime(value))); } localCache.put(key, value); cacheMetrics.recordLookup(clazz.getSimpleName(), CacheMetrics.CacheHitType.MISS); diff --git a/core/src/main/java/google/registry/cache/SimplifiedJedisClient.java b/core/src/main/java/google/registry/cache/SimplifiedJedisClient.java index 5f724211d25..529a7b1b0e8 100644 --- a/core/src/main/java/google/registry/cache/SimplifiedJedisClient.java +++ b/core/src/main/java/google/registry/cache/SimplifiedJedisClient.java @@ -40,6 +40,7 @@ import java.net.Inet6Address; import java.net.InetAddress; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.Optional; import redis.clients.jedis.AbstractPipeline; import redis.clients.jedis.UnifiedJedis; @@ -55,7 +56,27 @@ */ public class SimplifiedJedisClient { - public record JedisResource(String key, V value) {} + public record JedisResource( + String key, V value, Optional expirationTime) { + + public JedisResource { + checkNotNull(key, "Key cannot be null"); + checkNotNull(value, "Value cannot be null"); + checkNotNull(expirationTime, "expirationTime cannot be null"); + } + + public JedisResource(String key, V value) { + this(key, value, Optional.empty()); + } + + public JedisResource(String key, V value, Instant expirationTime) { + this(key, value, Optional.ofNullable(expirationTime)); + } + + public Instant getExpirationTime() { + return expirationTime.orElseGet(() -> value.getDeletionTime()); + } + } private static final ImmutableMap, String> TYPE_PREFIXES = ImmutableMap.of( @@ -101,7 +122,7 @@ public void set(JedisResource resource) { jedis.set( convertKey(resource.value.getClass(), resource.key), serialize(resource.value), - new SetParams().pxAt(resource.value.getDeletionTime().toEpochMilli())); + new SetParams().pxAt(resource.getExpirationTime().toEpochMilli())); } /** Sets multiple values in the remote cache using a Jedis {@link AbstractPipeline}. */ @@ -114,12 +135,33 @@ public void setAll(ImmutableCollection> pipeline.set( convertKey(resource.value.getClass(), resource.key), serialize(resource.value), - new SetParams().pxAt(resource.value.getDeletionTime().toEpochMilli()))); + new SetParams().pxAt(resource.getExpirationTime().toEpochMilli()))); pipeline.sync(); } } } + /** + * Deletes the value associated with the given key in Valkey. + * + *

If the given key does not exist, it does nothing. + */ + public void delete(Class resourceClass, String key) { + checkNotNull(resourceClass, "resourceClass cannot be null"); + checkNotNull(key, "Key cannot be null"); + jedis.unlink(getRedisKey(resourceClass, key)); + } + + /** Deletes the given resource in Valkey. */ + public void delete(JedisResource resource) { + checkNotNull(resource, "resource cannot be null"); + delete(resource.value.getClass().asSubclass(EppResource.class), resource.key); + } + + byte[] getRedisKey(Class clazz, String key) { + return convertKey(clazz, key); + } + /** * Deletes all values associated with the given keys in Valkey. * diff --git a/core/src/main/java/google/registry/flows/domain/DomainCheckFlow.java b/core/src/main/java/google/registry/flows/domain/DomainCheckFlow.java index e3e93a5da27..28235d73ef5 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainCheckFlow.java +++ b/core/src/main/java/google/registry/flows/domain/DomainCheckFlow.java @@ -25,10 +25,10 @@ import static google.registry.flows.domain.DomainFlowUtils.getReservationTypes; import static google.registry.flows.domain.DomainFlowUtils.handleFeeRequest; import static google.registry.flows.domain.DomainFlowUtils.isAnchorTenant; -import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap; import static google.registry.flows.domain.DomainFlowUtils.isRegisterBsaCreate; import static google.registry.flows.domain.DomainFlowUtils.isReserved; import static google.registry.flows.domain.DomainFlowUtils.isValidReservedCreate; +import static google.registry.flows.domain.DomainFlowUtils.loadDomainIfInXap; import static google.registry.flows.domain.DomainFlowUtils.validateDomainName; import static google.registry.flows.domain.DomainFlowUtils.validateDomainNameWithIdnTables; import static google.registry.flows.domain.DomainFlowUtils.verifyNotInPredelegation; @@ -259,13 +259,6 @@ private Optional getMessageForCheck( idn, existingDomains, bsaBlockedDomainNames, tldStates, token, now); } - private static Optional loadDomainIfInXap( - String domainName, Instant now, Duration domainExpiryAccessPeriodTotalLength) { - return ForeignKeyUtils.loadResource( - Domain.class, domainName, now.minus(domainExpiryAccessPeriodTotalLength)) - .filter(domain -> isDomainEligibleForXap(domain, Tld.get(domain.getTld()), now)); - } - private Optional getMessageForCheckWithToken( InternetDomainName domainName, ImmutableMap> existingDomains, diff --git a/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java b/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java index 3d408ae918f..2acc34f3a7b 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java +++ b/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java @@ -59,12 +59,14 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.net.InternetDomainName; +import google.registry.cache.SimplifiedJedisClient; import google.registry.config.RegistryConfig; import google.registry.config.RegistryConfig.Config; import google.registry.flows.EppException; import google.registry.flows.EppException.CommandUseErrorException; import google.registry.flows.EppException.ParameterValuePolicyErrorException; import google.registry.flows.ExtensionManager; +import google.registry.flows.FlowModule.DryRun; import google.registry.flows.FlowModule.RegistrarId; import google.registry.flows.FlowModule.Superuser; import google.registry.flows.FlowModule.TargetId; @@ -224,6 +226,8 @@ public final class DomainCreateFlow implements MutatingFlow { @Inject DomainFlowTmchUtils tmchUtils; @Inject DomainPricingLogic pricingLogic; @Inject DomainDeletionTimeCache domainDeletionTimeCache; + @Inject Optional jedisClient; + @Inject @DryRun boolean isDryRun; @Inject @Config("domainExpiryAccessPeriodTotalLength") @@ -232,6 +236,10 @@ public final class DomainCreateFlow implements MutatingFlow { @Inject DomainCreateFlow() {} + public String getTargetId() { + return targetId; + } + @Override public EppResponse run() throws EppException { extensionManager.register( @@ -461,6 +469,9 @@ public EppResponse run() throws EppException { .setYears(years) .build()); persistEntityChanges(entityChanges); + if (!isDryRun) { + jedisClient.ifPresent(client -> client.delete(Domain.class, getTargetId())); + } // If the registrar is participating in tiered pricing promos, return the standard price in the // response (even if the actual charged price is less) diff --git a/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java b/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java index 2dfbcbe9a4e..55be0f4208a 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java +++ b/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java @@ -72,6 +72,7 @@ import google.registry.flows.EppException.UnimplementedOptionException; import google.registry.flows.exceptions.ResourceHasClientUpdateProhibitedException; import google.registry.model.EppResource; +import google.registry.model.ForeignKeyUtils; import google.registry.model.billing.BillingBase.Flag; import google.registry.model.billing.BillingBase.Reason; import google.registry.model.billing.BillingRecurrence; @@ -1210,16 +1211,27 @@ public static boolean wasDeletedDuringAddGracePeriod(Domain domain, Tld tld) { } /** - * Returns true if the domain was deleted before {@code now} and is eligible for Expiry Access - * Period (XAP) evaluation. + * Returns true if the domain was deleted at or before {@code now} and is eligible for Expiry + * Access Period (XAP) evaluation. */ public static boolean isDomainEligibleForXap(Domain domain, Tld tld, Instant now) { - if (domain.getDeletionTime() == null || !domain.getDeletionTime().isBefore(now)) { + if (domain.getDeletionTime() == null || domain.getDeletionTime().isAfter(now)) { return false; } return !wasDeletedDuringAddGracePeriod(domain, tld); } + /** + * Loads the domain if it was deleted within the Expiry Access Period (XAP) window and is eligible + * for XAP. + */ + public static Optional loadDomainIfInXap( + String domainName, Instant now, Duration domainExpiryAccessPeriodTotalLength) { + return ForeignKeyUtils.loadResource( + Domain.class, domainName, now.minus(domainExpiryAccessPeriodTotalLength)) + .filter(domain -> isDomainEligibleForXap(domain, Tld.get(domain.getTld()), now)); + } + /** Resource linked to this domain does not exist. */ static class LinkedResourcesDoNotExistException extends ObjectDoesNotExistException { public LinkedResourcesDoNotExistException(Class type, ImmutableSet resourceIds) { diff --git a/core/src/main/java/google/registry/model/ForeignKeyUtils.java b/core/src/main/java/google/registry/model/ForeignKeyUtils.java index ece9a743e7c..feae6ab88e3 100644 --- a/core/src/main/java/google/registry/model/ForeignKeyUtils.java +++ b/core/src/main/java/google/registry/model/ForeignKeyUtils.java @@ -392,13 +392,25 @@ public static Optional loadResourceByCacheIfEnabled( *

This method ignores the config setting for caching, and is reserved for use cases that can * tolerate slightly stale data. */ - @SuppressWarnings("unchecked") public static Optional loadResourceByCache( Class clazz, String foreignKey, Instant now) { + return loadResourceByCacheIncludingDeleted(clazz, foreignKey, now) + .filter(e -> now.isBefore(e.getDeletionTime())); + } + + /** + * Loads the last created version of an {@link EppResource} from the replica database by foreign + * key, using a cache, including soft-deleted resources. + * + *

This method ignores the config setting for caching, and is reserved for use cases that can + * tolerate slightly stale data. + */ + @SuppressWarnings("unchecked") + public static Optional loadResourceByCacheIncludingDeleted( + Class clazz, String foreignKey, Instant now) { return (Optional) foreignKeyToResourceCache .get(VKey.create(clazz, foreignKey)) - .filter(e -> now.isBefore(e.getDeletionTime())) .map(e -> e.cloneProjectedAtTime(now)); } diff --git a/core/src/main/java/google/registry/rdap/RdapActionBase.java b/core/src/main/java/google/registry/rdap/RdapActionBase.java index 7caa079e9ca..cb8e2826484 100644 --- a/core/src/main/java/google/registry/rdap/RdapActionBase.java +++ b/core/src/main/java/google/registry/rdap/RdapActionBase.java @@ -36,7 +36,11 @@ import google.registry.config.RegistryConfig.Config; import google.registry.model.EppResource; import google.registry.model.registrar.Registrar; +import google.registry.rdap.RdapDomainAction.DomainBlockedByBsaException; +import google.registry.rdap.RdapDomainAction.DomainInExpiryAccessPeriodException; import google.registry.rdap.RdapMetrics.EndpointType; +import google.registry.rdap.RdapObjectClasses.DomainBlockedByBsaErrorResponse; +import google.registry.rdap.RdapObjectClasses.DomainInExpiryAccessPeriodErrorResponse; import google.registry.rdap.RdapObjectClasses.ErrorResponse; import google.registry.rdap.RdapObjectClasses.ReplyPayloadBase; import google.registry.rdap.RdapObjectClasses.TopLevelReplyObject; @@ -169,10 +173,14 @@ public void run() { response.setStatus(SC_OK); setPayload(replyObject); metricInformationBuilder.setStatusCode(SC_OK); - } catch (RdapDomainAction.DomainBlockedByBsaException e) { + } catch (DomainBlockedByBsaException e) { logger.atInfo().withCause(e).log("Domain blocked by BSA"); setErrorCodes(SC_NOT_FOUND); - setPayload(new RdapObjectClasses.DomainBlockedByBsaErrorResponse(e.getMessage())); + setPayload(new DomainBlockedByBsaErrorResponse(e.getMessage())); + } catch (DomainInExpiryAccessPeriodException e) { + logger.atInfo().withCause(e).log("Domain in Expiry Access Period"); + setErrorCodes(SC_NOT_FOUND); + setPayload(new DomainInExpiryAccessPeriodErrorResponse(e.getMessage())); } catch (HttpException e) { logger.atInfo().withCause(e).log("Error in RDAP."); setError(e.getResponseCode(), e.getResponseCodeString(), e.getMessage()); diff --git a/core/src/main/java/google/registry/rdap/RdapDomainAction.java b/core/src/main/java/google/registry/rdap/RdapDomainAction.java index e47bf7c2c49..d914a57cb63 100644 --- a/core/src/main/java/google/registry/rdap/RdapDomainAction.java +++ b/core/src/main/java/google/registry/rdap/RdapDomainAction.java @@ -14,12 +14,14 @@ package google.registry.rdap; +import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap; import static google.registry.flows.domain.DomainFlowUtils.validateDomainName; import static google.registry.request.Action.Method.GET; import static google.registry.request.Action.Method.HEAD; import static google.registry.util.DateTimeUtils.START_INSTANT; import com.google.common.net.InternetDomainName; +import google.registry.config.RegistryConfig.Config; import google.registry.flows.EppException; import google.registry.flows.domain.DomainFlowUtils; import google.registry.model.ForeignKeyUtils; @@ -33,6 +35,8 @@ import google.registry.request.HttpException.NotFoundException; import google.registry.request.auth.Auth; import jakarta.inject.Inject; +import java.time.Duration; +import java.time.Instant; import java.util.Optional; /** RDAP action for domain requests. */ @@ -44,8 +48,13 @@ auth = Auth.AUTH_PUBLIC) public class RdapDomainAction extends RdapActionBase { - @Inject public RdapDomainAction() { + private final Duration domainExpiryAccessPeriodTotalLength; + + @Inject + public RdapDomainAction( + @Config("domainExpiryAccessPeriodTotalLength") Duration domainExpiryAccessPeriodTotalLength) { super("domain name", EndpointType.DOMAIN); + this.domainExpiryAccessPeriodTotalLength = domainExpiryAccessPeriodTotalLength; } @Override @@ -69,8 +78,9 @@ public RdapDomain getJsonObjectForResource(String pathSearchString, boolean isHe Optional domain = shouldIncludeDeleted() // the remote domain cache cannot handle times in the past ? ForeignKeyUtils.loadResourceByCache(Domain.class, pathSearchString, START_INSTANT) - : domainCache.loadByDomainName(pathSearchString); + : domainCache.loadByDomainNameIncludingDeleted(pathSearchString); if (domain.isEmpty() || !isAuthorized(domain.get())) { + domain.ifPresent(d -> handlePossibleExpiryAccessPeriod(domainName, d)); handlePossibleBsaBlock(domainName); // RFC7480 5.3 - if the server wishes to respond that it doesn't have data satisfying the // query, it MUST reply with 404 response code. @@ -82,6 +92,16 @@ public RdapDomain getJsonObjectForResource(String pathSearchString, boolean isHe return rdapJsonFormatter.createRdapDomain(domain.get(), OutputDataType.FULL); } + private void handlePossibleExpiryAccessPeriod(InternetDomainName domainName, Domain domain) { + Instant now = clock.now(); + Tld tld = Tld.get(domainName.parent().toString()); + if (tld.getExpiryAccessPeriodModeAt(now) == Tld.ExpiryAccessPeriodMode.ENABLED + && isDomainEligibleForXap(domain, tld, now) + && domain.getDeletionTime().isAfter(now.minus(domainExpiryAccessPeriodTotalLength))) { + throw new DomainInExpiryAccessPeriodException(domainName + " in Expiry Access Period"); + } + } + private void handlePossibleBsaBlock(InternetDomainName domainName) { Tld tld = Tld.get(domainName.parent().toString()); if (DomainFlowUtils.isBlockedByBsa(domainName.parts().getFirst(), tld, clock.now())) { @@ -89,6 +109,12 @@ private void handlePossibleBsaBlock(InternetDomainName domainName) { } } + static class DomainInExpiryAccessPeriodException extends RuntimeException { + DomainInExpiryAccessPeriodException(String message) { + super(message); + } + } + static class DomainBlockedByBsaException extends RuntimeException { DomainBlockedByBsaException(String message) { super(message); diff --git a/core/src/main/java/google/registry/rdap/RdapIcannStandardInformation.java b/core/src/main/java/google/registry/rdap/RdapIcannStandardInformation.java index 44dcc863ca2..d66e3900d39 100644 --- a/core/src/main/java/google/registry/rdap/RdapIcannStandardInformation.java +++ b/core/src/main/java/google/registry/rdap/RdapIcannStandardInformation.java @@ -80,6 +80,18 @@ public class RdapIcannStandardInformation { static final ImmutableList DOMAIN_BLOCKED_BY_BSA_BOILERPLATE_NOTICES = ImmutableList.of(DOMAIN_BLOCKED_BY_BSA_NOTICE); + /** Not required, but provided when a domain is in the Expiry Access Period. */ + private static final Notice DOMAIN_IN_EXPIRY_ACCESS_PERIOD_NOTICE = + Notice.builder() + .setTitle("Expiry Access Period") + .setDescription( + "This domain is currently available for registration in the Expiry Access Period") + .build(); + + /** Boilerplate notice for when a domain is in the Expiry Access Period. */ + static final ImmutableList DOMAIN_IN_EXPIRY_ACCESS_PERIOD_BOILERPLATE_NOTICES = + ImmutableList.of(DOMAIN_IN_EXPIRY_ACCESS_PERIOD_NOTICE); + /** Required by the RDAP Technical Implementation Guide 3.6. */ static final Remark SUMMARY_DATA_REMARK = Remark.builder() diff --git a/core/src/main/java/google/registry/rdap/RdapObjectClasses.java b/core/src/main/java/google/registry/rdap/RdapObjectClasses.java index 80f571a2c6e..79bcc07e313 100644 --- a/core/src/main/java/google/registry/rdap/RdapObjectClasses.java +++ b/core/src/main/java/google/registry/rdap/RdapObjectClasses.java @@ -136,6 +136,8 @@ Builder add(Vcard vcard) { public enum BoilerplateType { DOMAIN(RdapIcannStandardInformation.DOMAIN_BOILERPLATE_NOTICES), DOMAIN_BLOCKED_BY_BSA(RdapIcannStandardInformation.DOMAIN_BLOCKED_BY_BSA_BOILERPLATE_NOTICES), + DOMAIN_IN_EXPIRY_ACCESS_PERIOD( + RdapIcannStandardInformation.DOMAIN_IN_EXPIRY_ACCESS_PERIOD_BOILERPLATE_NOTICES), NAMESERVER(ImmutableList.of()), ENTITY(ImmutableList.of()), OTHER(ImmutableList.of()); @@ -533,10 +535,10 @@ abstract static class Builder extends RdapNamedObjectBase.Builder { } } - /** Specialized error response body for when a domain is blocked by BSA. */ + /** Base class for specialized 404 Not Found RDAP error responses. */ @RestrictJsonNames({}) @SuppressWarnings("UnusedVariable") - public static class DomainBlockedByBsaErrorResponse extends ReplyPayloadBase { + private abstract static class DomainNotFoundErrorResponse extends ReplyPayloadBase { @JsonableElement private static final LanguageIdentifier lang = LanguageIdentifier.EN; @@ -546,12 +548,28 @@ public static class DomainBlockedByBsaErrorResponse extends ReplyPayloadBase { @JsonableElement private final ImmutableList description; - DomainBlockedByBsaErrorResponse(String message) { - super(BoilerplateType.DOMAIN_BLOCKED_BY_BSA); + DomainNotFoundErrorResponse(BoilerplateType boilerplateType, String message) { + super(boilerplateType); this.description = ImmutableList.of(message); } } + /** Specialized error response body for when a domain is blocked by BSA. */ + public static class DomainBlockedByBsaErrorResponse extends DomainNotFoundErrorResponse { + + DomainBlockedByBsaErrorResponse(String message) { + super(BoilerplateType.DOMAIN_BLOCKED_BY_BSA, message); + } + } + + /** Specialized error response body for when a domain is in the Expiry Access Period. */ + public static class DomainInExpiryAccessPeriodErrorResponse extends DomainNotFoundErrorResponse { + + DomainInExpiryAccessPeriodErrorResponse(String message) { + super(BoilerplateType.DOMAIN_IN_EXPIRY_ACCESS_PERIOD, message); + } + } + /** Error Response Body defined in 6 of RFC 9083. */ @RestrictJsonNames({}) @AutoValue diff --git a/core/src/test/java/google/registry/batch/SyncRemoteCacheActionTest.java b/core/src/test/java/google/registry/batch/SyncRemoteCacheActionTest.java index 78f22e6802f..1116e87b16f 100644 --- a/core/src/test/java/google/registry/batch/SyncRemoteCacheActionTest.java +++ b/core/src/test/java/google/registry/batch/SyncRemoteCacheActionTest.java @@ -26,11 +26,14 @@ import static google.registry.testing.DatabaseHelper.persistActiveHost; import static google.registry.testing.DatabaseHelper.persistDeletedDomain; import static google.registry.testing.DatabaseHelper.persistDeletedHost; +import static google.registry.testing.DatabaseHelper.persistResource; +import static google.registry.util.DateTimeUtils.END_INSTANT; import static google.registry.util.DateTimeUtils.minusDays; import static jakarta.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; import static jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT; import static jakarta.servlet.http.HttpServletResponse.SC_OK; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -46,6 +49,7 @@ import google.registry.testing.FakeClock; import google.registry.testing.FakeLockHandler; import google.registry.testing.FakeResponse; +import java.time.Duration; import java.time.Instant; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -62,7 +66,9 @@ @MockitoSettings(strictness = Strictness.LENIENT) class SyncRemoteCacheActionTest { - private final FakeClock clock = new FakeClock(Instant.parse("2025-01-01T00:00:00Z")); + private static final Instant START_TIME = Instant.parse("2025-01-01T00:00:00Z"); + private static final Duration XAP_LENGTH = Duration.ofDays(10); + private final FakeClock clock = new FakeClock(START_TIME); @RegisterExtension final JpaIntegrationTestExtension jpa = @@ -78,7 +84,7 @@ class SyncRemoteCacheActionTest { void beforeEach() { createTld("tld"); SyncRemoteCacheAction.SYNC_CACHE_RUNS_METRIC.reset(); - action = new SyncRemoteCacheAction(lockHandler, response, Optional.of(jedisClient)); + action = new SyncRemoteCacheAction(lockHandler, response, Optional.of(jedisClient), XAP_LENGTH); } private static void verifyMetrics(SyncRemoteCacheAction.SyncStatus status) { @@ -88,9 +94,20 @@ private static void verifyMetrics(SyncRemoteCacheAction.SyncStatus status) { .hasNoOtherValues(); } + private static SimplifiedJedisClient.JedisResource expectedDomainResource( + String domainName, Domain domain) { + return expectedDomainResource(domainName, domain, XAP_LENGTH); + } + + private static SimplifiedJedisClient.JedisResource expectedDomainResource( + String domainName, Domain domain, Duration xapLength) { + return new SimplifiedJedisClient.JedisResource<>( + domainName, domain, domain.getDeletionTime().plus(xapLength)); + } + @Test void test_noJedisConfig() { - action = new SyncRemoteCacheAction(lockHandler, response, Optional.empty()); + action = new SyncRemoteCacheAction(lockHandler, response, Optional.empty(), XAP_LENGTH); action.run(); assertThat(response.getStatus()).isEqualTo(SC_NO_CONTENT); assertThat(response.getPayload()).contains("No Jedis/Valkey configuration found"); @@ -100,7 +117,7 @@ void test_noJedisConfig() { @Test void test_lockAcquisitionFails() { lockHandler = new FakeLockHandler(false); - action = new SyncRemoteCacheAction(lockHandler, response, Optional.of(jedisClient)); + action = new SyncRemoteCacheAction(lockHandler, response, Optional.of(jedisClient), XAP_LENGTH); action.run(); assertThat(response.getStatus()).isEqualTo(SC_NO_CONTENT); assertThat(response.getPayload()).contains("Could not acquire lock"); @@ -139,8 +156,8 @@ void test_syncDomains_withDomains() { verify(jedisClient) .setAll( ImmutableList.of( - new SimplifiedJedisClient.JedisResource<>("example1.tld", domain1), - new SimplifiedJedisClient.JedisResource<>("example2.tld", domain2))); + expectedDomainResource("example1.tld", domain1), + expectedDomainResource("example2.tld", domain2))); assertThat( DatabaseHelper.loadByKey(Cursor.createGlobalVKey(REMOTE_CACHE_DOMAIN_SYNC)) @@ -153,7 +170,49 @@ void test_syncDomains_withDomains() { @Test void test_syncDomains_withDeletedDomains() { Domain activeDomain = persistActiveDomain("active.tld"); - persistDeletedDomain("deleted.tld", minusDays(clock.now(), 1)); + Domain recentDeletedDomain = + persistDeletedDomain("recent-deleted.tld", minusDays(START_TIME, 1)); + persistDeletedDomain("expired-deleted.tld", minusDays(START_TIME, 11)); + + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + SimplifiedJedisClient.JedisResource activeResource = + expectedDomainResource("active.tld", activeDomain); + SimplifiedJedisClient.JedisResource recentDeletedResource = + expectedDomainResource("recent-deleted.tld", recentDeletedDomain); + assertThat(activeResource.getExpirationTime()).isEqualTo(END_INSTANT.plus(XAP_LENGTH)); + assertThat(recentDeletedResource.getExpirationTime()) + .isEqualTo(recentDeletedDomain.getDeletionTime().plus(XAP_LENGTH)); + verify(jedisClient).setAll(ImmutableList.of(activeResource, recentDeletedResource)); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of("expired-deleted.tld")); + verifyMetrics(SUCCESS); + } + + @Test + void test_syncDomains_pendingDeleteDomain_expiresAfterXapWindow() { + Instant futureDeletionTime = START_TIME.plus(Duration.ofDays(5)); + Domain pendingDeleteDomain = persistDeletedDomain("pending-delete.tld", futureDeletionTime); + + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + SimplifiedJedisClient.JedisResource pendingDeleteResource = + expectedDomainResource("pending-delete.tld", pendingDeleteDomain); + assertThat(pendingDeleteResource.getExpirationTime()) + .isEqualTo(futureDeletionTime.plus(XAP_LENGTH)); + verify(jedisClient).setAll(ImmutableList.of(pendingDeleteResource)); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); + verifyMetrics(SUCCESS); + } + + @Test + void test_syncDomains_withCustomXapLength_usesConfiguredDurationForRetentionAndTtl() { + Duration customXapLength = Duration.ofDays(15); + action = + new SyncRemoteCacheAction(lockHandler, response, Optional.of(jedisClient), customXapLength); + Domain withinCustomWindow = persistDeletedDomain("within-15d.tld", minusDays(START_TIME, 12)); + persistDeletedDomain("outside-15d.tld", minusDays(START_TIME, 16)); action.run(); @@ -161,11 +220,97 @@ void test_syncDomains_withDeletedDomains() { verify(jedisClient) .setAll( ImmutableList.of( - new SimplifiedJedisClient.JedisResource<>("active.tld", activeDomain))); - verify(jedisClient).deleteAll(Domain.class, ImmutableList.of("deleted.tld")); + expectedDomainResource("within-15d.tld", withinCustomWindow, customXapLength))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of("outside-15d.tld")); + verifyMetrics(SUCCESS); + } + + @Test + void test_syncDomains_atDeletionTime_keepsDomainInRemoteCache() { + Domain xapDomain = persistDeletedDomain("xap-now.tld", START_TIME); + + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient).setAll(ImmutableList.of(expectedDomainResource("xap-now.tld", xapDomain))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); + verifyMetrics(SUCCESS); + } + + @Test + void test_syncDomains_atExactExpiry_deletedFromRemoteCache() { + persistDeletedDomain("exact-expiry.tld", minusDays(START_TIME, 10)); + + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient).setAll(ImmutableList.of()); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of("exact-expiry.tld")); + verifyMetrics(SUCCESS); + } + + @Test + void test_syncDomains_insideExpiry_keepsDomainInRemoteCache() { + Domain xapDomain = + persistDeletedDomain("inside-expiry.tld", START_TIME.minus(XAP_LENGTH).plusMillis(1)); + + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient) + .setAll(ImmutableList.of(expectedDomainResource("inside-expiry.tld", xapDomain))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); verifyMetrics(SUCCESS); } + @Test + void test_syncDomains_cursorAdvances_skipsUnchangedExpiredXapDomain() { + Domain xapDomain = persistDeletedDomain("xap.tld", minusDays(START_TIME, 1)); + + // Run 1: Initial synchronization at START_TIME + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient).setAll(ImmutableList.of(expectedDomainResource("xap.tld", xapDomain))); + Cursor cursor = DatabaseHelper.loadByKey(Cursor.createGlobalVKey(REMOTE_CACHE_DOMAIN_SYNC)); + assertThat(cursor.getCursorTime()).isEqualTo(START_TIME); + + // Run 2: Advance clock past 10d XAP window + clock.advanceBy(Duration.ofDays(12)); + clearInvocations(jedisClient); + FakeResponse response2 = new FakeResponse(); + action = + new SyncRemoteCacheAction(lockHandler, response2, Optional.of(jedisClient), XAP_LENGTH); + + action.run(); + + assertThat(response2.getStatus()).isEqualTo(SC_OK); + assertThat(response2.getPayload()).contains("Synced 0 domains"); + verifyNoInteractions(jedisClient); + assertThat( + DatabaseHelper.loadByKey(Cursor.createGlobalVKey(REMOTE_CACHE_DOMAIN_SYNC)) + .getCursorTime()) + .isEqualTo(START_TIME); + + // Run 3: Subsequent mutation at new time + clock.advanceOneMilli(); + Domain newActive = persistActiveDomain("newactive.tld"); + FakeResponse response3 = new FakeResponse(); + action = + new SyncRemoteCacheAction(lockHandler, response3, Optional.of(jedisClient), XAP_LENGTH); + + action.run(); + + assertThat(response3.getStatus()).isEqualTo(SC_OK); + assertThat(response3.getPayload()).contains("Synced 1 domains"); + verify(jedisClient) + .setAll(ImmutableList.of(expectedDomainResource("newactive.tld", newActive))); + assertThat( + DatabaseHelper.loadByKey(Cursor.createGlobalVKey(REMOTE_CACHE_DOMAIN_SYNC)) + .getCursorTime()) + .isEqualTo(START_TIME.plus(Duration.ofDays(12)).plusMillis(1)); + } + @Test void testCursorTime_skipsOldChange() { persistActiveDomain("example1.tld"); @@ -181,9 +326,7 @@ void testCursorTime_skipsOldChange() { action.run(); assertThat(response.getStatus()).isEqualTo(SC_OK); - verify(jedisClient) - .setAll( - ImmutableList.of(new SimplifiedJedisClient.JedisResource<>("example2.tld", domain2))); + verify(jedisClient).setAll(ImmutableList.of(expectedDomainResource("example2.tld", domain2))); verifyMetrics(SUCCESS); } @@ -223,7 +366,7 @@ void test_syncHosts_withHosts() { @Test void test_syncHosts_withDeletedHosts() { Host active = persistActiveHost("ns1.example.tld"); - Host deleted = persistDeletedHost("ns2.example.tld", minusDays(clock.now(), 1)); + Host deleted = persistDeletedHost("ns2.example.tld", minusDays(START_TIME, 1)); action.run(); @@ -235,4 +378,55 @@ void test_syncHosts_withDeletedHosts() { verify(jedisClient).deleteAll(Host.class, ImmutableList.of(deleted.getRepoId())); verifyMetrics(SUCCESS); } + + @Test + void test_syncDomains_rapidRecreationAndDeletion_transitionsCacheState() { + // Run 1: Deleted at START_TIME - 1d + Instant t1Del = START_TIME.minus(Duration.ofDays(1)); + Domain v1 = + persistResource( + persistActiveDomain("cycle-sync.tld") + .asBuilder() + .setCreationTimeForTest(START_TIME.minus(Duration.ofDays(7))) + .setDeletionTime(t1Del) + .build()); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient).setAll(ImmutableList.of(expectedDomainResource("cycle-sync.tld", v1))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); + + // Run 2: Re-registered at START_TIME + 2d (active) + clock.advanceBy(Duration.ofDays(2)); + clearInvocations(jedisClient); + Instant t2Create = START_TIME.plus(Duration.ofDays(2)); + Domain v2 = + persistResource( + v1.asBuilder().setCreationTimeForTest(t2Create).setDeletionTime(END_INSTANT).build()); + FakeResponse resp2 = new FakeResponse(); + new SyncRemoteCacheAction(lockHandler, resp2, Optional.of(jedisClient), XAP_LENGTH).run(); + assertThat(resp2.getStatus()).isEqualTo(SC_OK); + verify(jedisClient).setAll(ImmutableList.of(expectedDomainResource("cycle-sync.tld", v2))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); + + // Run 3: Deleted again at t2Create + 6d -> new TTL + clock.advanceBy(Duration.ofDays(6)); + clearInvocations(jedisClient); + Instant t3Del = t2Create.plus(Duration.ofDays(6)); + Domain v3 = persistResource(v2.asBuilder().setDeletionTime(t3Del).build()); + FakeResponse resp3 = new FakeResponse(); + new SyncRemoteCacheAction(lockHandler, resp3, Optional.of(jedisClient), XAP_LENGTH).run(); + assertThat(resp3.getStatus()).isEqualTo(SC_OK); + verify(jedisClient).setAll(ImmutableList.of(expectedDomainResource("cycle-sync.tld", v3))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); + + // Run 4: Updated after XAP window has already elapsed -> purged from remote cache + clock.advanceBy(Duration.ofDays(11)); + clearInvocations(jedisClient); + persistResource(v3.asBuilder().setLastEppUpdateTime(t3Del).build()); + FakeResponse resp4 = new FakeResponse(); + new SyncRemoteCacheAction(lockHandler, resp4, Optional.of(jedisClient), XAP_LENGTH).run(); + assertThat(resp4.getStatus()).isEqualTo(SC_OK); + verify(jedisClient).setAll(ImmutableList.of()); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of("cycle-sync.tld")); + } } diff --git a/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java b/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java index 0955a713fab..7c73864e263 100644 --- a/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java +++ b/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java @@ -17,8 +17,13 @@ import static com.google.common.truth.Truth.assertThat; import static google.registry.testing.DatabaseHelper.createTld; import static google.registry.testing.DatabaseHelper.persistActiveDomain; +import static google.registry.testing.DatabaseHelper.persistDeletedDomain; import static google.registry.testing.DatabaseHelper.persistResource; +import static google.registry.util.DateTimeUtils.END_INSTANT; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -32,6 +37,7 @@ import google.registry.testing.DatabaseHelper; import google.registry.testing.FakeClock; import java.time.Duration; +import java.time.Instant; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -40,21 +46,30 @@ /** Tests for {@link MultilayerDomainCache}. */ public class MultilayerDomainCacheTest { + private static final Instant START_TIME = Instant.parse("2025-01-01T00:00:00Z"); + private static final Duration XAP_LENGTH = Duration.ofDays(10); + private final FakeClock clock = new FakeClock(START_TIME); + @RegisterExtension final JpaIntegrationTestExtension jpa = - new JpaTestExtensions.Builder().buildIntegrationTestExtension(); + new JpaTestExtensions.Builder().withClock(clock).buildIntegrationTestExtension(); private final SimplifiedJedisClient jedisClient = mock(SimplifiedJedisClient.class); - private final FakeClock clock = new FakeClock(); private final CacheMetrics cacheMetrics = mock(CacheMetrics.class); private MultilayerDomainCache cache; @BeforeEach void beforeEach() { - cache = new MultilayerDomainCache(jedisClient, clock, cacheMetrics); + cache = new MultilayerDomainCache(jedisClient, clock, cacheMetrics, XAP_LENGTH); createTld("tld"); } + private static SimplifiedJedisClient.JedisResource expectedDomainResource( + String domainName, Domain domain) { + return new SimplifiedJedisClient.JedisResource<>( + domainName, domain, domain.getDeletionTime().plus(XAP_LENGTH)); + } + @Test void testLoad_fromDatabase_populatesCaches() { Domain domain = persistActiveDomain("example.tld"); @@ -62,7 +77,7 @@ void testLoad_fromDatabase_populatesCaches() { // We should have filled the caches after one attempt to load from Valkey verify(jedisClient).get(Domain.class, "example.tld"); - verify(jedisClient).set(new SimplifiedJedisClient.JedisResource<>("example.tld", domain)); + verify(jedisClient).set(expectedDomainResource("example.tld", domain)); verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS); // Further loads hit the local cache @@ -138,4 +153,191 @@ void testLoad_projectsToCurrentTime() { clock.advanceBy(Duration.ofDays(10)); assertThat(cache.loadByDomainName("example.tld").get().getGracePeriods()).isEmpty(); } + + @Test + void testLoadIncludingDeleted_includesDeletedDomain() { + Domain domain = + persistActiveDomain("example.tld") + .asBuilder() + .setDeletionTime(START_TIME.minus(Duration.ofDays(1))) + .build(); + when(jedisClient.get(Domain.class, "example.tld")).thenReturn(Optional.of(domain)); + assertThat(cache.loadByDomainName("example.tld")).isEmpty(); + assertThat(cache.loadByDomainNameIncludingDeleted("example.tld")).hasValue(domain); + } + + @Test + void testProvideDomainCache_noJedisClient_loadsActiveAndDeletedDomains() { + DomainCache dbOnlyCache = + CacheModule.provideDomainCache(Optional.empty(), clock, cacheMetrics, XAP_LENGTH); + Domain activeDomain = persistActiveDomain("active-db.tld"); + Domain deletedDomain = + persistDeletedDomain("deleted-db.tld", START_TIME.minus(Duration.ofDays(1))); + + assertThat(dbOnlyCache.loadByDomainName("active-db.tld")).hasValue(activeDomain); + assertThat(dbOnlyCache.loadByDomainName("deleted-db.tld")).isEmpty(); + assertThat(dbOnlyCache.loadByDomainName("nonexistent-db.tld")).isEmpty(); + + assertThat(dbOnlyCache.loadByDomainNameIncludingDeleted("active-db.tld")) + .hasValue(activeDomain); + assertThat(dbOnlyCache.loadByDomainNameIncludingDeleted("deleted-db.tld")) + .hasValue(deletedDomain); + assertThat(dbOnlyCache.loadByDomainNameIncludingDeleted("nonexistent-db.tld")).isEmpty(); + } + + @Test + void testLoadIncludingDeleted_recentlyDeletedDomain_populatesValkeyWithCalculatedTtl() { + Instant deletionTime = START_TIME.minus(Duration.ofDays(2)); + Domain domain = persistDeletedDomain("xap.tld", deletionTime); + + assertThat(cache.loadByDomainNameIncludingDeleted("xap.tld")).hasValue(domain); + + verify(jedisClient).get(Domain.class, "xap.tld"); + verify(jedisClient).set(expectedDomainResource("xap.tld", domain)); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS); + } + + @Test + void testLoad_pendingDeleteDomain_populatesValkeyThroughEndOfXap() { + Instant futureDeletionTime = START_TIME.plus(Duration.ofDays(5)); + Domain pendingDeleteDomain = persistDeletedDomain("pending-del.tld", futureDeletionTime); + + assertThat(cache.loadByDomainName("pending-del.tld")).hasValue(pendingDeleteDomain); + + verify(jedisClient).get(Domain.class, "pending-del.tld"); + verify(jedisClient) + .set( + new SimplifiedJedisClient.JedisResource<>( + "pending-del.tld", pendingDeleteDomain, futureDeletionTime.plus(XAP_LENGTH))); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS); + } + + @Test + void testLoadIncludingDeleted_softDeleted_pastXapWindow_doesNotPersistToValkey() { + Domain domain = persistDeletedDomain("past-xap.tld", START_TIME.minus(Duration.ofDays(11))); + + assertThat(cache.loadByDomainNameIncludingDeleted("past-xap.tld")).hasValue(domain); + + verify(jedisClient).get(Domain.class, "past-xap.tld"); + verify(jedisClient, never()).set(any()); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS); + } + + @Test + void testLoadIncludingDeleted_softDeleted_testTld_doesNotPersistToValkey() { + persistResource(Tld.get("tld").asBuilder().setTldType(Tld.TldType.TEST).build()); + + Domain domain = persistDeletedDomain("test-tld.tld", START_TIME.minus(Duration.ofDays(2))); + + assertThat(cache.loadByDomainNameIncludingDeleted("test-tld.tld")).hasValue(domain); + + verify(jedisClient).get(Domain.class, "test-tld.tld"); + verify(jedisClient, never()).set(any()); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS); + } + + @Test + void testShouldPersistToRemoteCache_and_getExpirationTime_boundaries() { + // 1. Active domain (END_INSTANT and future pending-delete) + Domain activeDomain = persistActiveDomain("active.tld"); + assertThat(cache.shouldPersistToRemoteCache(activeDomain)).isTrue(); + assertThat(cache.getExpirationTime(activeDomain)).hasValue(END_INSTANT.plus(XAP_LENGTH)); + Instant futureDeletion = START_TIME.plus(Duration.ofDays(3)); + Domain pendingDeleteDomain = persistDeletedDomain("pending-del.tld", futureDeletion); + assertThat(cache.shouldPersistToRemoteCache(pendingDeleteDomain)).isTrue(); + assertThat(cache.getExpirationTime(pendingDeleteDomain)) + .hasValue(futureDeletion.plus(XAP_LENGTH)); + assertThat( + new SimplifiedJedisClient.JedisResource<>( + "pending-del.tld", + pendingDeleteDomain, + cache.getExpirationTime(pendingDeleteDomain)) + .getExpirationTime()) + .isEqualTo(futureDeletion.plus(XAP_LENGTH)); + + // 2. Boundary: deletionTime == START_TIME + Domain deletedAtNow = persistDeletedDomain("del-now.tld", START_TIME); + assertThat(cache.shouldPersistToRemoteCache(deletedAtNow)).isTrue(); + assertThat(cache.getExpirationTime(deletedAtNow)).hasValue(START_TIME.plus(XAP_LENGTH)); + + // 3. Boundary: deletionTime == START_TIME - 10d + 1ms (strictly inside 10d window) + Instant insideWindow = START_TIME.minus(XAP_LENGTH).plusMillis(1); + Domain deletedInsideWindow = persistDeletedDomain("inside.tld", insideWindow); + assertThat(cache.shouldPersistToRemoteCache(deletedInsideWindow)).isTrue(); + assertThat(cache.getExpirationTime(deletedInsideWindow)) + .hasValue(insideWindow.plus(XAP_LENGTH)); + + // 4. Boundary: deletionTime == START_TIME - 10d (exact edge of 10d window) + Instant exactEdge = START_TIME.minus(XAP_LENGTH); + Domain deletedExactEdge = persistDeletedDomain("exact-edge.tld", exactEdge); + assertThat(cache.shouldPersistToRemoteCache(deletedExactEdge)).isFalse(); + assertThat(cache.getExpirationTime(deletedExactEdge)).hasValue(exactEdge.plus(XAP_LENGTH)); + + // 5. Boundary: deletionTime == START_TIME - 10d - 1ms (strictly outside window) + Instant outsideWindow = START_TIME.minus(XAP_LENGTH).minusMillis(1); + Domain deletedOutsideWindow = persistDeletedDomain("outside.tld", outsideWindow); + assertThat(cache.shouldPersistToRemoteCache(deletedOutsideWindow)).isFalse(); + assertThat(cache.getExpirationTime(deletedOutsideWindow)) + .hasValue(outsideWindow.plus(XAP_LENGTH)); + + // 6. Custom length constructor (15 days) + MultilayerDomainCache customCache = + new MultilayerDomainCache(jedisClient, clock, cacheMetrics, Duration.ofDays(15)); + Instant twelveDaysAgo = START_TIME.minus(Duration.ofDays(12)); + Domain deletedTwelveDaysAgo = persistDeletedDomain("twelve-days.tld", twelveDaysAgo); + // In default 10-day cache: expired + assertThat(cache.shouldPersistToRemoteCache(deletedTwelveDaysAgo)).isFalse(); + assertThat(cache.getExpirationTime(deletedTwelveDaysAgo)) + .hasValue(twelveDaysAgo.plus(XAP_LENGTH)); + // In custom 15-day cache: still within window + assertThat(customCache.shouldPersistToRemoteCache(deletedTwelveDaysAgo)).isTrue(); + assertThat(customCache.getExpirationTime(deletedTwelveDaysAgo)) + .hasValue(twelveDaysAgo.plus(Duration.ofDays(15))); + } + + @Test + void testRapidRecreationAndDeletionCycle_transitionsCacheAndTtlCorrectly() { + // 1. Initial soft deletion within retention window + Instant t1Del = START_TIME.minus(Duration.ofDays(2)); + Domain domainV1 = + persistResource( + persistDeletedDomain("cycle.tld", t1Del) + .asBuilder() + .setCreationTimeForTest(START_TIME.minus(Duration.ofDays(8))) + .build()); + + MultilayerDomainCache cache1 = + new MultilayerDomainCache(jedisClient, clock, cacheMetrics, XAP_LENGTH); + assertThat(cache1.loadByDomainNameIncludingDeleted("cycle.tld")).hasValue(domainV1); + verify(jedisClient).set(expectedDomainResource("cycle.tld", domainV1)); + + // 2. Domain re-registered (active) + clearInvocations(jedisClient, cacheMetrics); + Instant t2Create = START_TIME.minus(Duration.ofDays(1)); + Domain domainV2 = + persistResource( + persistActiveDomain("cycle.tld") + .asBuilder() + .setCreationTimeForTest(t2Create) + .setDeletionTime(END_INSTANT) + .build()); + + MultilayerDomainCache cache2 = + new MultilayerDomainCache(jedisClient, clock, cacheMetrics, XAP_LENGTH); + assertThat(cache2.loadByDomainNameIncludingDeleted("cycle.tld")).hasValue(domainV2); + verify(jedisClient).set(expectedDomainResource("cycle.tld", domainV2)); + + // 3. Domain deleted again -> new deletionTime & new TTL + clearInvocations(jedisClient, cacheMetrics); + Instant t3Del = START_TIME.plus(Duration.ofDays(6)); + clock.setTo(t3Del.plus(Duration.ofDays(1))); + Domain domainV3 = + persistResource( + domainV2.asBuilder().setCreationTimeForTest(t2Create).setDeletionTime(t3Del).build()); + + MultilayerDomainCache cache3 = + new MultilayerDomainCache(jedisClient, clock, cacheMetrics, XAP_LENGTH); + assertThat(cache3.loadByDomainNameIncludingDeleted("cycle.tld")).hasValue(domainV3); + verify(jedisClient).set(expectedDomainResource("cycle.tld", domainV3)); + } } diff --git a/core/src/test/java/google/registry/cache/MultilayerHostCacheTest.java b/core/src/test/java/google/registry/cache/MultilayerHostCacheTest.java index 3f089166504..998e72ad0b5 100644 --- a/core/src/test/java/google/registry/cache/MultilayerHostCacheTest.java +++ b/core/src/test/java/google/registry/cache/MultilayerHostCacheTest.java @@ -16,7 +16,11 @@ import static com.google.common.truth.Truth.assertThat; import static google.registry.testing.DatabaseHelper.persistActiveHost; +import static google.registry.testing.DatabaseHelper.persistDeletedHost; +import static google.registry.util.DateTimeUtils.START_INSTANT; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -97,4 +101,15 @@ void testLoad_filtersOutDeletedHost() { clock.advanceBy(Duration.ofDays(2)); assertThat(cache.loadByRepoId(host.getRepoId())).isEmpty(); } + + @Test + void testLoad_softDeletedHostFromDatabase_doesNotPersistToValkey() { + Host deletedHost = + persistDeletedHost("ns1.deleted.tld", START_INSTANT.minus(Duration.ofDays(1))); + assertThat(cache.loadByRepoId(deletedHost.getRepoId())).isEmpty(); + + verify(jedisClient).get(Host.class, deletedHost.getRepoId()); + verify(jedisClient, never()).set(any()); + verify(cacheMetrics).recordLookup("Host", CacheMetrics.CacheHitType.MISS); + } } diff --git a/core/src/test/java/google/registry/cache/SimplifiedJedisClientTest.java b/core/src/test/java/google/registry/cache/SimplifiedJedisClientTest.java index 3545f150902..836378f161b 100644 --- a/core/src/test/java/google/registry/cache/SimplifiedJedisClientTest.java +++ b/core/src/test/java/google/registry/cache/SimplifiedJedisClientTest.java @@ -21,6 +21,7 @@ import static google.registry.testing.DatabaseHelper.persistActiveHost; import static google.registry.testing.DatabaseHelper.persistActiveSubordinateHost; import static google.registry.testing.DatabaseHelper.persistDeletedDomain; +import static google.registry.util.DateTimeUtils.END_INSTANT; import com.google.common.collect.ImmutableList; import google.registry.model.domain.Domain; @@ -29,7 +30,9 @@ import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationTestExtension; import google.registry.testing.FakeClock; import io.github.ss_bhatt.testcontainers.valkey.ValkeyContainer; +import java.time.Duration; import java.time.Instant; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -44,7 +47,8 @@ public class SimplifiedJedisClientTest { @Container private static final ValkeyContainer valkey = new ValkeyContainer(); - private final FakeClock fakeClock = new FakeClock(Instant.parse("2025-01-01T00:00:00.000Z")); + private static final Instant START_TIME = Instant.parse("2025-01-01T00:00:00.000Z"); + private final FakeClock fakeClock = new FakeClock(START_TIME); @RegisterExtension final JpaIntegrationTestExtension jpa = @@ -153,10 +157,137 @@ void testClient_nonexistent() { assertThat(hostClient.get(Host.class, "ns1.nonexistent.tld")).isEmpty(); } + @Test + void testSet_softDeletedDomain_withExplicitExpiration_retainedInValkey() { + RedisClient redisClient = createRedisClient(); + SimplifiedJedisClient client = new SimplifiedJedisClient(redisClient); + Instant deletionTime = Instant.parse("2035-01-01T00:00:00.000Z"); + Domain softDeletedDomain = persistDeletedDomain("xap-retained.tld", deletionTime); + + Instant futureExpiration = deletionTime.plus(Duration.ofDays(10)); + client.set( + new SimplifiedJedisClient.JedisResource<>( + "xap-retained.tld", softDeletedDomain, futureExpiration)); + + Optional cached = client.get(Domain.class, "xap-retained.tld"); + assertThat(cached).isPresent(); + assertAboutImmutableObjects() + .that(cached.get()) + .isEqualExceptFields(softDeletedDomain, "dsData", "gracePeriods", "nsHosts"); + assertThat(cached.get().getDeletionTime()).isEqualTo(deletionTime); + assertThat(redisClient.pexpireTime(client.getRedisKey(Domain.class, "xap-retained.tld"))) + .isEqualTo(futureExpiration.toEpochMilli()); + } + + @Test + void testSet_softDeletedDomain_withoutExplicitExpiration_evictedImmediately() { + SimplifiedJedisClient client = createJedisClient(); + Domain softDeletedDomain = persistDeletedDomain("evicted-immediate.tld", START_TIME); + + client.set( + new SimplifiedJedisClient.JedisResource<>("evicted-immediate.tld", softDeletedDomain)); + + assertThat(client.get(Domain.class, "evicted-immediate.tld")).isEmpty(); + } + + @Test + void testSetAll_mixedResources_pipelineSetsCorrectTtls() { + RedisClient redisClient = createRedisClient(); + SimplifiedJedisClient client = new SimplifiedJedisClient(redisClient); + + Domain activeDomain = persistActiveDomain("active.tld"); + Instant deletionTime = Instant.parse("2035-01-01T00:00:00.000Z"); + Domain xapDomain = persistDeletedDomain("xap.tld", deletionTime); + Instant xapExpiration = deletionTime.plus(Duration.ofDays(10)); + Domain expiredDomain = persistDeletedDomain("expired.tld", START_TIME); + + client.setAll( + ImmutableList.of( + new SimplifiedJedisClient.JedisResource<>("active.tld", activeDomain), + new SimplifiedJedisClient.JedisResource<>("xap.tld", xapDomain, xapExpiration), + new SimplifiedJedisClient.JedisResource<>("expired.tld", expiredDomain))); + + Optional cachedActive = client.get(Domain.class, "active.tld"); + assertThat(cachedActive).isPresent(); + assertThat(cachedActive.get().getDeletionTime()).isEqualTo(END_INSTANT); + assertThat(redisClient.pexpireTime(client.getRedisKey(Domain.class, "active.tld"))) + .isEqualTo(END_INSTANT.toEpochMilli()); + + Optional cachedXap = client.get(Domain.class, "xap.tld"); + assertThat(cachedXap).isPresent(); + assertThat(cachedXap.get().getDeletionTime()).isEqualTo(deletionTime); + assertThat(redisClient.pexpireTime(client.getRedisKey(Domain.class, "xap.tld"))) + .isEqualTo(xapExpiration.toEpochMilli()); + + assertThat(client.get(Domain.class, "expired.tld")).isEmpty(); + } + + @Test + void testJedisResource_expirationResolution() { + Domain domain = persistDeletedDomain("test.tld", START_TIME.minus(Duration.ofDays(2))); + Instant explicitTime = START_TIME.plus(Duration.ofDays(5)); + + // 2-arg constructor defaults expirationTime to Optional.empty() + SimplifiedJedisClient.JedisResource defaultResource = + new SimplifiedJedisClient.JedisResource<>("test.tld", domain); + assertThat(defaultResource.expirationTime()).isEmpty(); + assertThat(defaultResource.getExpirationTime()).isEqualTo(domain.getDeletionTime()); + + // 3-arg constructor with explicit Instant + SimplifiedJedisClient.JedisResource explicitResource = + new SimplifiedJedisClient.JedisResource<>("test.tld", domain, explicitTime); + assertThat(explicitResource.expirationTime()).hasValue(explicitTime); + assertThat(explicitResource.getExpirationTime()).isEqualTo(explicitTime); + + // 3-arg constructor with null Instant + Instant nullExpiration = null; + SimplifiedJedisClient.JedisResource nullInstantResource = + new SimplifiedJedisClient.JedisResource<>("test.tld", domain, nullExpiration); + assertThat(nullInstantResource.expirationTime()).isEmpty(); + assertThat(nullInstantResource.getExpirationTime()).isEqualTo(domain.getDeletionTime()); + } + + @Test + void testDelete_byClassAndKey_removesKeyFromValkey() { + SimplifiedJedisClient client = createJedisClient(); + Domain domain1 = persistActiveDomain("to-delete.tld"); + Domain domain2 = persistActiveDomain("to-keep.tld"); + + client.setAll( + ImmutableList.of( + new SimplifiedJedisClient.JedisResource<>("to-delete.tld", domain1), + new SimplifiedJedisClient.JedisResource<>("to-keep.tld", domain2))); + + assertThat(client.get(Domain.class, "to-delete.tld")).isPresent(); + assertThat(client.get(Domain.class, "to-keep.tld")).isPresent(); + + client.delete(Domain.class, "to-delete.tld"); + + assertThat(client.get(Domain.class, "to-delete.tld")).isEmpty(); + assertThat(client.get(Domain.class, "to-keep.tld")).isPresent(); + } + + @Test + void testDelete_byJedisResource_removesKeyFromValkey() { + SimplifiedJedisClient client = createJedisClient(); + Domain domain = persistActiveDomain("resource-delete.tld"); + SimplifiedJedisClient.JedisResource resource = + new SimplifiedJedisClient.JedisResource<>("resource-delete.tld", domain); + + client.set(resource); + assertThat(client.get(Domain.class, "resource-delete.tld")).isPresent(); + + client.delete(resource); + assertThat(client.get(Domain.class, "resource-delete.tld")).isEmpty(); + } + + private RedisClient createRedisClient() { + return RedisClient.builder() + .hostAndPort(new HostAndPort(valkey.getHost(), valkey.getFirstMappedPort())) + .build(); + } + private SimplifiedJedisClient createJedisClient() { - return new SimplifiedJedisClient( - RedisClient.builder() - .hostAndPort(new HostAndPort(valkey.getHost(), valkey.getFirstMappedPort())) - .build()); + return new SimplifiedJedisClient(createRedisClient()); } } diff --git a/core/src/test/java/google/registry/flows/EppTestComponent.java b/core/src/test/java/google/registry/flows/EppTestComponent.java index 85a3ed050b6..f0e4c3eb7fc 100644 --- a/core/src/test/java/google/registry/flows/EppTestComponent.java +++ b/core/src/test/java/google/registry/flows/EppTestComponent.java @@ -21,6 +21,7 @@ import google.registry.batch.AsyncTaskEnqueuer; import google.registry.batch.AsyncTaskEnqueuerTest; import google.registry.batch.CloudTasksUtils; +import google.registry.cache.SimplifiedJedisClient; import google.registry.config.RegistryConfig.ConfigModule; import google.registry.config.RegistryConfig.ConfigModule.TmchCaMode; import google.registry.flows.custom.CustomLogicFactory; @@ -43,6 +44,7 @@ import google.registry.util.Sleeper; import jakarta.inject.Singleton; import java.time.Duration; +import java.util.Optional; /** Dagger component for running EPP tests. */ @Singleton @@ -149,6 +151,21 @@ DomainDeletionTimeCache provideDomainDeletionTimeCache() { FlowQuotaManager provideFlowQuotaManager() { return flowQuotaManager; } + + private static Optional jedisClient = Optional.empty(); + + public static void setJedisClient(Optional client) { + jedisClient = client; + } + + public static void resetJedisClient() { + jedisClient = Optional.empty(); + } + + @Provides + static Optional provideJedisClient() { + return jedisClient; + } } class FakeServerTridProvider implements ServerTridProvider { diff --git a/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java b/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java index 537fa6a091d..a9b483abb91 100644 --- a/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java +++ b/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java @@ -69,6 +69,11 @@ import static org.joda.money.CurrencyUnit.JPY; import static org.joda.money.CurrencyUnit.USD; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -76,11 +81,15 @@ import com.google.common.collect.ImmutableSortedMap; import com.google.common.collect.Iterables; import com.google.common.collect.Ordering; +import google.registry.cache.CacheMetrics; +import google.registry.cache.MultilayerDomainCache; +import google.registry.cache.SimplifiedJedisClient; import google.registry.config.RegistryConfig; import google.registry.config.RegistryConfigSettings; import google.registry.flows.EppException; import google.registry.flows.EppException.UnimplementedExtensionException; import google.registry.flows.EppRequestSource; +import google.registry.flows.EppTestComponent; import google.registry.flows.ExtensionManager.UndeclaredServiceExtensionException; import google.registry.flows.FlowUtils.NotLoggedInException; import google.registry.flows.FlowUtils.UnknownCurrencyEppException; @@ -196,6 +205,7 @@ import java.util.Optional; import javax.annotation.Nullable; import org.joda.money.Money; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junitpioneer.jupiter.cartesian.CartesianTest; @@ -237,6 +247,11 @@ class DomainCreateFlowTest extends ResourceFlowTestCase() + .putAll(FEE_STD_1_0_MAP) + .put("DESCRIPTION_1", "create") + .put("DESCRIPTION_2", "Expiry Access Period") + .build()); + } + + @Test + void testSuccess_reRegisterXapDomain_invalidatesValkeyCache() throws Exception { + RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get(); + BigDecimal originalInitialFee = + settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD"); + BigDecimal originalFinalFee = + settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD"); + settings.registryPolicy.domainExpiryAccessPeriod.initialFee = + ImmutableMap.of("USD", new BigDecimal("100.00")); + settings.registryPolicy.domainExpiryAccessPeriod.finalFee = + ImmutableMap.of("USD", new BigDecimal("100.00")); + SimplifiedJedisClient jedisClient = mock(SimplifiedJedisClient.class); + EppTestComponent.FakesAndMocksModule.setJedisClient(Optional.of(jedisClient)); + try { + persistHosts(); + Instant deletionTime = Instant.parse("1999-04-03T21:00:00.0Z"); + setXapForTld("tld", deletionTime); + + setEppInputForXapCreate(); + clock.advanceOneMilli(); + runFlowAssertResponse(loadFile("domain_create_response_xap_fee.xml")); + + Domain domain = reloadResourceByForeignKey(); + assertThat(domain.getDeletionTime()).isEqualTo(END_INSTANT); + verify(jedisClient).delete(Domain.class, "example.tld"); + } finally { + settings.registryPolicy.domainExpiryAccessPeriod.initialFee = + ImmutableMap.of("USD", originalInitialFee); + settings.registryPolicy.domainExpiryAccessPeriod.finalFee = + ImmutableMap.of("USD", originalFinalFee); + } + } + + @Test + void testSuccess_reRegisterXapDomain_dryRun_doesNotInvalidateValkeyCache() throws Exception { + RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get(); + BigDecimal originalInitialFee = + settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD"); + BigDecimal originalFinalFee = + settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD"); + settings.registryPolicy.domainExpiryAccessPeriod.initialFee = + ImmutableMap.of("USD", new BigDecimal("100.00")); + settings.registryPolicy.domainExpiryAccessPeriod.finalFee = + ImmutableMap.of("USD", new BigDecimal("100.00")); + SimplifiedJedisClient jedisClient = mock(SimplifiedJedisClient.class); + EppTestComponent.FakesAndMocksModule.setJedisClient(Optional.of(jedisClient)); + try { + persistHosts(); + Instant deletionTime = Instant.parse("1999-04-03T21:00:00.0Z"); + setXapForTld("tld", deletionTime); + + setEppInputForXapCreate(); + clock.advanceOneMilli(); + runFlow(CommitMode.DRY_RUN, UserPrivileges.NORMAL); + + verifyNoInteractions(jedisClient); + } finally { + settings.registryPolicy.domainExpiryAccessPeriod.initialFee = + ImmutableMap.of("USD", originalInitialFee); + settings.registryPolicy.domainExpiryAccessPeriod.finalFee = + ImmutableMap.of("USD", originalFinalFee); + } + } + + @Test + void testSuccess_reRegisterXapDomain_cacheReflectsActiveDomainAfterInvalidation() + throws Exception { + RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get(); + BigDecimal originalInitialFee = + settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD"); + BigDecimal originalFinalFee = + settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD"); + settings.registryPolicy.domainExpiryAccessPeriod.initialFee = + ImmutableMap.of("USD", new BigDecimal("100.00")); + settings.registryPolicy.domainExpiryAccessPeriod.finalFee = + ImmutableMap.of("USD", new BigDecimal("100.00")); + SimplifiedJedisClient jedisClient = mock(SimplifiedJedisClient.class); + EppTestComponent.FakesAndMocksModule.setJedisClient(Optional.of(jedisClient)); + try { + persistHosts(); + Instant deletionTime = Instant.parse("1999-04-03T21:00:00.0Z"); + Domain softDeletedDomain = setXapForTld("tld", deletionTime); + setEppInputForXapCreate(); + + when(jedisClient.get(Domain.class, "example.tld")).thenReturn(Optional.of(softDeletedDomain)); + doAnswer( + invocation -> { + when(jedisClient.get(Domain.class, "example.tld")).thenReturn(Optional.empty()); + return null; + }) + .when(jedisClient) + .delete(Domain.class, "example.tld"); + + // Prior to re-registration, cache serves the soft-deleted XAP domain from Valkey + MultilayerDomainCache cacheBefore = + new MultilayerDomainCache( + jedisClient, clock, mock(CacheMetrics.class), Duration.ofDays(10)); + Optional domainBefore = cacheBefore.loadByDomainNameIncludingDeleted("example.tld"); + assertThat(domainBefore).isPresent(); + assertThat(domainBefore.get().getDeletionTime()).isEqualTo(deletionTime); + + // Re-register via DomainCreateFlow + clock.advanceOneMilli(); + runFlowAssertResponse(loadFile("domain_create_response_xap_fee.xml")); + + // Verify Valkey invalidation occurred + verify(jedisClient).delete(Domain.class, "example.tld"); + + // Post-registration, cache misses Valkey and loads the active domain from DB replica + MultilayerDomainCache cacheAfter = + new MultilayerDomainCache( + jedisClient, clock, mock(CacheMetrics.class), Duration.ofDays(10)); + Optional domainAfter = cacheAfter.loadByDomainNameIncludingDeleted("example.tld"); + assertThat(domainAfter).isPresent(); + assertThat(domainAfter.get().getDeletionTime()).isEqualTo(END_INSTANT); + } finally { + settings.registryPolicy.domainExpiryAccessPeriod.initialFee = + ImmutableMap.of("USD", originalInitialFee); + settings.registryPolicy.domainExpiryAccessPeriod.finalFee = + ImmutableMap.of("USD", originalFinalFee); + } + } + + @Test + void testSuccess_reRegisterXapDomain_noJedisClient_succeeds() throws Exception { + RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get(); + BigDecimal originalInitialFee = + settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD"); + BigDecimal originalFinalFee = + settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD"); + settings.registryPolicy.domainExpiryAccessPeriod.initialFee = + ImmutableMap.of("USD", new BigDecimal("100.00")); + settings.registryPolicy.domainExpiryAccessPeriod.finalFee = + ImmutableMap.of("USD", new BigDecimal("100.00")); + EppTestComponent.FakesAndMocksModule.resetJedisClient(); + try { + persistHosts(); + Instant deletionTime = Instant.parse("1999-04-03T21:00:00.0Z"); + setXapForTld("tld", deletionTime); + + setEppInputForXapCreate(); + clock.advanceOneMilli(); + runFlowAssertResponse(loadFile("domain_create_response_xap_fee.xml")); + + Domain domain = reloadResourceByForeignKey(); + assertThat(domain.getDeletionTime()).isEqualTo(END_INSTANT); + } finally { + settings.registryPolicy.domainExpiryAccessPeriod.initialFee = + ImmutableMap.of("USD", originalInitialFee); + settings.registryPolicy.domainExpiryAccessPeriod.finalFee = + ImmutableMap.of("USD", originalFinalFee); + } + } } diff --git a/core/src/test/java/google/registry/flows/domain/DomainFlowUtilsTest.java b/core/src/test/java/google/registry/flows/domain/DomainFlowUtilsTest.java index 03d51fda9a6..54e42a250b2 100644 --- a/core/src/test/java/google/registry/flows/domain/DomainFlowUtilsTest.java +++ b/core/src/test/java/google/registry/flows/domain/DomainFlowUtilsTest.java @@ -16,11 +16,17 @@ import static com.google.common.truth.Truth.assertThat; import static google.registry.flows.domain.DomainFlowUtils.checkHasBillingAccount; +import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap; +import static google.registry.flows.domain.DomainFlowUtils.loadDomainIfInXap; +import static google.registry.flows.domain.DomainFlowUtils.wasDeletedDuringAddGracePeriod; import static google.registry.testing.DatabaseHelper.createTld; import static google.registry.testing.DatabaseHelper.newTld; +import static google.registry.testing.DatabaseHelper.persistActiveDomain; +import static google.registry.testing.DatabaseHelper.persistDeletedDomain; import static google.registry.testing.DatabaseHelper.persistResource; import static google.registry.testing.EppExceptionSubject.assertAboutEppExceptions; import static google.registry.util.DateTimeUtils.START_INSTANT; +import static google.registry.util.DateTimeUtils.minusDays; import static org.joda.money.CurrencyUnit.CHF; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -38,8 +44,11 @@ import google.registry.flows.domain.DomainFlowUtils.TldDoesNotExistException; import google.registry.flows.domain.DomainFlowUtils.TrailingDashException; import google.registry.model.domain.Domain; +import google.registry.model.tld.Tld; import google.registry.model.tld.Tld.TldType; import google.registry.persistence.transaction.JpaTransactionManagerExtension; +import java.time.Duration; +import java.time.Instant; import org.joda.money.Money; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -47,8 +56,11 @@ /** Unit tests for {@link DomainFlowUtils}. */ class DomainFlowUtilsTest extends ResourceFlowTestCase { + private static final Instant START_TIME = Instant.parse("2025-01-01T00:00:00Z"); + @BeforeEach void setup() { + clock.setTo(START_TIME); setEppInput("domain_info.xml"); createTld("tld"); persistResource(JpaTransactionManagerExtension.makeRegistrar1().asBuilder().build()); @@ -196,4 +208,187 @@ private void persistFoobarTld(TldType tldType) { .setRestoreBillingCost(Money.ofMajor(CHF, 800)) .build()); } + + @Test + void testIsDomainEligibleForXap_activeDomain_returnsFalse() { + Domain domain = persistActiveDomain("active.tld"); + assertThat(isDomainEligibleForXap(domain, Tld.get("tld"), START_TIME)).isFalse(); + } + + @Test + void testIsDomainEligibleForXap_deletedOutsideAgp_returnsTrue() { + Domain domain = persistDeletedDomain("deleted.tld", minusDays(START_TIME, 1)); + assertThat(isDomainEligibleForXap(domain, Tld.get("tld"), START_TIME)).isTrue(); + } + + @Test + void testIsDomainEligibleForXap_deletedDuringAgp_returnsFalse() { + Domain domain = + persistActiveDomain("agp.tld") + .asBuilder() + .setCreationTimeForTest(minusDays(START_TIME, 2)) + .setDeletionTime(minusDays(START_TIME, 1)) + .build(); + persistResource(domain); + assertThat(isDomainEligibleForXap(domain, Tld.get("tld"), START_TIME)).isFalse(); + } + + @Test + void testLoadDomainIfInXap_eligibleAndWithinWindow_returnsDomain() { + Domain domain = persistDeletedDomain("xap.tld", minusDays(START_TIME, 2)); + assertThat(loadDomainIfInXap("xap.tld", START_TIME, Duration.ofDays(10))).hasValue(domain); + } + + @Test + void testLoadDomainIfInXap_deletedOutsideWindow_returnsEmpty() { + persistDeletedDomain("expired.tld", minusDays(START_TIME, 15)); + assertThat(loadDomainIfInXap("expired.tld", START_TIME, Duration.ofDays(10))).isEmpty(); + } + + @Test + void testLoadDomainIfInXap_deletedDuringAgp_returnsEmpty() { + Domain domain = + persistActiveDomain("agp.tld") + .asBuilder() + .setCreationTimeForTest(minusDays(START_TIME, 2)) + .setDeletionTime(minusDays(START_TIME, 1)) + .build(); + persistResource(domain); + assertThat(loadDomainIfInXap("agp.tld", START_TIME, Duration.ofDays(10))).isEmpty(); + } + + @Test + void testIsDomainEligibleForXap_deletedAtNow_returnsTrue() { + Domain domain = persistDeletedDomain("deleted-now.tld", START_TIME); + assertThat(isDomainEligibleForXap(domain, Tld.get("tld"), START_TIME)).isTrue(); + } + + @Test + void testIsDomainEligibleForXap_deletedInFuture_returnsFalse() { + Domain domain = persistDeletedDomain("future.tld", START_TIME.plus(Duration.ofDays(1))); + assertThat(isDomainEligibleForXap(domain, Tld.get("tld"), START_TIME)).isFalse(); + } + + @Test + void testIsDomainEligibleForXap_agpBoundaryExact_returnsFalse() { + Tld tld = Tld.get("tld"); + Domain domain = + persistActiveDomain("agp-exact.tld") + .asBuilder() + .setCreationTimeForTest(START_TIME.minus(tld.getAddGracePeriodLength())) + .setDeletionTime(START_TIME) + .build(); + persistResource(domain); + assertThat(isDomainEligibleForXap(domain, tld, START_TIME)).isFalse(); + } + + @Test + void testIsDomainEligibleForXap_deletedJustAfterAgp_returnsTrue() { + Tld tld = Tld.get("tld"); + Domain domain = + persistActiveDomain("agp-after.tld") + .asBuilder() + .setCreationTimeForTest(START_TIME.minus(tld.getAddGracePeriodLength()).minusMillis(1)) + .setDeletionTime(START_TIME) + .build(); + persistResource(domain); + assertThat(isDomainEligibleForXap(domain, tld, START_TIME)).isTrue(); + } + + @Test + void testWasDeletedDuringAddGracePeriod_boundaries() { + Tld tld = Tld.get("tld"); + Domain domainExact = + persistActiveDomain("agp-exact-fn.tld") + .asBuilder() + .setCreationTimeForTest(START_TIME.minus(tld.getAddGracePeriodLength())) + .setDeletionTime(START_TIME) + .build(); + persistResource(domainExact); + assertThat(wasDeletedDuringAddGracePeriod(domainExact, tld)).isTrue(); + + Domain domainAfter = + persistActiveDomain("agp-after-fn.tld") + .asBuilder() + .setCreationTimeForTest(START_TIME.minus(tld.getAddGracePeriodLength()).minusMillis(1)) + .setDeletionTime(START_TIME) + .build(); + persistResource(domainAfter); + assertThat(wasDeletedDuringAddGracePeriod(domainAfter, tld)).isFalse(); + } + + @Test + void testLoadDomainIfInXap_deletedAtNow_returnsDomain() { + Domain domain = persistDeletedDomain("xap-now.tld", START_TIME); + assertThat(loadDomainIfInXap("xap-now.tld", START_TIME, Duration.ofDays(10))).hasValue(domain); + } + + @Test + void testLoadDomainIfInXap_exactWindowBoundary_returnsEmpty() { + persistDeletedDomain("expired-exact.tld", minusDays(START_TIME, 10)); + assertThat(loadDomainIfInXap("expired-exact.tld", START_TIME, Duration.ofDays(10))).isEmpty(); + } + + @Test + void testLoadDomainIfInXap_justInsideWindowBoundary_returnsDomain() { + Domain domain = + persistDeletedDomain("inside-window.tld", minusDays(START_TIME, 10).plusSeconds(1)); + assertThat(loadDomainIfInXap("inside-window.tld", START_TIME, Duration.ofDays(10))) + .hasValue(domain); + } + + @Test + void testLoadDomainIfInXap_justOutsideWindowBoundary_returnsEmpty() { + persistDeletedDomain("outside-window.tld", minusDays(START_TIME, 10).minusSeconds(1)); + assertThat(loadDomainIfInXap("outside-window.tld", START_TIME, Duration.ofDays(10))).isEmpty(); + } + + @Test + void testIsDomainEligibleForXap_deletedOneMilliInFuture_returnsFalse() { + Domain domain = persistDeletedDomain("future-milli.tld", START_TIME.plusMillis(1)); + assertThat(isDomainEligibleForXap(domain, Tld.get("tld"), START_TIME)).isFalse(); + } + + @Test + void testLoadDomainIfInXap_oneMilliInsideWindow_returnsDomain() { + Domain domain = persistDeletedDomain("inside-milli.tld", START_TIME); + Instant queryNow = domain.getDeletionTime().plus(Duration.ofDays(10)).minusMillis(1); + assertThat(loadDomainIfInXap("inside-milli.tld", queryNow, Duration.ofDays(10))) + .hasValue(domain); + } + + @Test + void testLoadDomainIfInXap_oneMilliOutsideWindow_returnsEmpty() { + Domain domain = persistDeletedDomain("outside-milli.tld", START_TIME); + Instant queryNow = domain.getDeletionTime().plus(Duration.ofDays(10)).plusMillis(1); + assertThat(loadDomainIfInXap("outside-milli.tld", queryNow, Duration.ofDays(10))).isEmpty(); + } + + @Test + void testLoadDomainIfInXap_exactAgpBoundary_returnsEmpty() { + Tld tld = Tld.get("tld"); + Domain domain = + persistActiveDomain("agp-exact-load.tld") + .asBuilder() + .setCreationTimeForTest(START_TIME.minus(tld.getAddGracePeriodLength())) + .setDeletionTime(START_TIME) + .build(); + persistResource(domain); + assertThat(loadDomainIfInXap("agp-exact-load.tld", START_TIME, Duration.ofDays(10))).isEmpty(); + } + + @Test + void testLoadDomainIfInXap_oneMilliAfterAgpBoundary_returnsDomain() { + Tld tld = Tld.get("tld"); + Domain domain = + persistResource( + persistActiveDomain("agp-after-load.tld") + .asBuilder() + .setCreationTimeForTest( + START_TIME.minus(tld.getAddGracePeriodLength()).minusMillis(1)) + .setDeletionTime(START_TIME) + .build()); + assertThat(loadDomainIfInXap("agp-after-load.tld", START_TIME, Duration.ofDays(10))) + .hasValue(domain); + } } diff --git a/core/src/test/java/google/registry/model/ForeignKeyUtilsTest.java b/core/src/test/java/google/registry/model/ForeignKeyUtilsTest.java index bf0a62b81cc..54001a0c1c0 100644 --- a/core/src/test/java/google/registry/model/ForeignKeyUtilsTest.java +++ b/core/src/test/java/google/registry/model/ForeignKeyUtilsTest.java @@ -38,7 +38,8 @@ /** Unit tests for {@link ForeignKeyUtils}. */ class ForeignKeyUtilsTest { - private final FakeClock fakeClock = new FakeClock(Instant.parse("2024-03-27T10:15:30.105Z")); + private static final Instant START_TIME = Instant.parse("2024-03-27T10:15:30.105Z"); + private final FakeClock fakeClock = new FakeClock(START_TIME); @RegisterExtension public final JpaIntegrationTestExtension jpaIntegrationTestExtension = @@ -157,4 +158,31 @@ void testSuccess_loadResourcesByCache_skipsDeletedAndNonexistent() { fakeClock.now())) .containsExactlyEntriesIn(ImmutableMap.of("ns1.example.com", host1)); } + + @Test + void testSuccess_loadResourceByCacheIncludingDeleted_includesDeletedDomain() { + Domain activeDomain = persistActiveDomain("active.com"); + Domain deletedDomain = + persistResource( + persistActiveDomain("deleted.com") + .asBuilder() + .setDeletionTime(minusDays(START_TIME, 1)) + .build()); + assertThat(ForeignKeyUtils.loadResourceByCache(Domain.class, "active.com", START_TIME)) + .hasValue(activeDomain); + assertThat(ForeignKeyUtils.loadResourceByCache(Domain.class, "deleted.com", START_TIME)) + .isEmpty(); + assertThat( + ForeignKeyUtils.loadResourceByCacheIncludingDeleted( + Domain.class, "active.com", START_TIME)) + .hasValue(activeDomain); + assertThat( + ForeignKeyUtils.loadResourceByCacheIncludingDeleted( + Domain.class, "deleted.com", START_TIME)) + .hasValue(deletedDomain); + assertThat( + ForeignKeyUtils.loadResourceByCacheIncludingDeleted( + Domain.class, "nonexistent.com", START_TIME)) + .isEmpty(); + } } diff --git a/core/src/test/java/google/registry/rdap/RdapActionBaseTestCase.java b/core/src/test/java/google/registry/rdap/RdapActionBaseTestCase.java index f9cc8304a63..ed0fff01728 100644 --- a/core/src/test/java/google/registry/rdap/RdapActionBaseTestCase.java +++ b/core/src/test/java/google/registry/rdap/RdapActionBaseTestCase.java @@ -28,10 +28,10 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import google.registry.model.ForeignKeyUtils; +import google.registry.cache.CacheMetrics; +import google.registry.cache.CacheModule; import google.registry.model.console.User; import google.registry.model.console.UserRoles; -import google.registry.model.domain.Domain; import google.registry.persistence.transaction.JpaTestExtensions; import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationTestExtension; import google.registry.request.Actions; @@ -40,6 +40,7 @@ import google.registry.testing.FakeResponse; import google.registry.util.Idn; import google.registry.util.TypeUtils; +import java.time.Duration; import java.time.Instant; import java.util.HashMap; import java.util.Optional; @@ -85,9 +86,13 @@ abstract class RdapActionBaseTestCase { actionPath = Actions.getPathForAction(rdapActionClass); } + protected A createAction() { + return TypeUtils.instantiate(rdapActionClass); + } + @BeforeEach public void beforeEachRdapActionBaseTestCase() { - action = TypeUtils.instantiate(rdapActionClass); + action = createAction(); action.includeDeletedParam = Optional.empty(); action.formatOutputParam = Optional.empty(); action.response = response; @@ -95,8 +100,9 @@ public void beforeEachRdapActionBaseTestCase() { action.rdapMetrics = rdapMetrics; action.requestMethod = GET; action.domainCache = - (domainName) -> ForeignKeyUtils.loadResourceByCache(Domain.class, domainName, clock.now()); - action.clock = new FakeClock(Instant.parse("2025-01-01T00:00:00.000Z")); + CacheModule.provideDomainCache( + Optional.empty(), clock, mock(CacheMetrics.class), Duration.ofDays(10)); + action.clock = clock; logout(); } diff --git a/core/src/test/java/google/registry/rdap/RdapDomainActionTest.java b/core/src/test/java/google/registry/rdap/RdapDomainActionTest.java index 0ae1c4fe3cf..4455c1764f1 100644 --- a/core/src/test/java/google/registry/rdap/RdapDomainActionTest.java +++ b/core/src/test/java/google/registry/rdap/RdapDomainActionTest.java @@ -27,18 +27,29 @@ import static google.registry.testing.FullFieldsTestEntityHelper.makeRegistrar; import static google.registry.testing.FullFieldsTestEntityHelper.makeRegistrarPocs; import static google.registry.testing.GsonSubject.assertAboutJson; +import static google.registry.util.DateTimeUtils.END_INSTANT; import static google.registry.util.DateTimeUtils.START_INSTANT; import static google.registry.util.DateTimeUtils.minusDays; import static google.registry.util.DateTimeUtils.minusMonths; import static google.registry.util.DateTimeUtils.minusYears; import static google.registry.util.DateTimeUtils.plusDays; import static google.registry.util.DateTimeUtils.plusYears; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; 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.common.collect.ImmutableSortedMap; import com.google.gson.JsonObject; +import google.registry.cache.CacheMetrics; +import google.registry.cache.MultilayerDomainCache; +import google.registry.cache.SimplifiedJedisClient; import google.registry.model.domain.Domain; import google.registry.model.domain.GracePeriod; import google.registry.model.domain.Period; @@ -48,13 +59,20 @@ import google.registry.model.registrar.Registrar; import google.registry.model.reporting.HistoryEntry; import google.registry.model.tld.Tld; +import google.registry.model.tld.Tld.ExpiryAccessPeriodMode; +import google.registry.persistence.transaction.JpaTransactionManager; +import google.registry.persistence.transaction.TransactionManager.ThrowingRunnable; +import google.registry.persistence.transaction.TransactionManagerFactory; import google.registry.rdap.RdapMetrics.EndpointType; import google.registry.rdap.RdapMetrics.SearchType; import google.registry.rdap.RdapMetrics.WildcardType; import google.registry.rdap.RdapSearchResults.IncompletenessWarningType; import google.registry.request.Action; +import google.registry.testing.FakeResponse; +import java.time.Duration; import java.time.Instant; import java.util.Optional; +import java.util.concurrent.Callable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -62,11 +80,20 @@ /** Unit tests for {@link RdapDomainAction}. */ class RdapDomainActionTest extends RdapActionBaseTestCase { + private static final Instant START_TIME = Instant.parse("2000-01-01T00:00:00Z"); + RdapDomainActionTest() { super(RdapDomainAction.class); } + @Override + protected RdapDomainAction createAction() { + return new RdapDomainAction(Duration.ofDays(10)); + } + private Host host1; + private Domain domainDeleted; + private Domain domainIdn; @BeforeEach void beforeEach() { @@ -91,7 +118,7 @@ void beforeEach() { Host hostDodo2 = makeAndPersistHost( "ns2.dodo.lol", "bad:f00d:cafe:0:0:0:15:beef", minusYears(clock.now(), 2)); - Domain domainDeleted = + domainDeleted = persistResource( makeDomain("dodo.lol", host1, hostDodo2, registrarLol) .asBuilder() @@ -104,12 +131,13 @@ void beforeEach() { Registrar registrarIdn = persistResource(makeRegistrar("idnregistrar", "IDN Registrar", Registrar.State.ACTIVE)); persistResources(makeRegistrarPocs(registrarIdn)); - persistResource( - makeDomain("cat.みんな", host1, host2, registrarIdn) - .asBuilder() - .setCreationTimeForTest(minusYears(clock.now(), 3)) - .setCreationRegistrarId("TheRegistrar") - .build()); + domainIdn = + persistResource( + makeDomain("cat.みんな", host1, host2, registrarIdn) + .asBuilder() + .setCreationTimeForTest(minusYears(START_TIME, 3)) + .setCreationRegistrarId("TheRegistrar") + .build()); // 1.tld createTld("1.tld"); @@ -474,6 +502,845 @@ void testBlockedByBsa() { assertThat(response.getStatus()).isEqualTo(404); } + @Test + void testDomainInExpiryAccessPeriod() { + persistResource( + Tld.get("lol") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + ImmutableMap expectedXapNotice = + ImmutableMap.of( + "description", + ImmutableList.of( + "This domain is currently available for registration in the Expiry Access Period"), + "title", + "Expiry Access Period"); + JsonObject actualResponse = generateActualJson("dodo.lol"); + JsonObject expectedErrorResponse = + generateExpectedJsonError("dodo.lol in Expiry Access Period", 404); + expectedErrorResponse + .getAsJsonArray("notices") + .add(RdapTestHelper.GSON.toJsonTree(expectedXapNotice)); + assertAboutJson().that(actualResponse).isEqualTo(expectedErrorResponse); + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void testDomainInExpiryAccessPeriod_deletedOutsideXapWindow_notFound() { + persistResource( + Tld.get("lol") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + persistResource(domainDeleted.asBuilder().setDeletionTime(minusDays(START_TIME, 15)).build()); + assertAboutJson() + .that(generateActualJson("dodo.lol")) + .isEqualTo(generateExpectedJsonError("dodo.lol not found", 404)); + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void testDomainInExpiryAccessPeriod_deletedDuringAgp_notFound() { + persistResource( + Tld.get("lol") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + persistResource( + domainDeleted + .asBuilder() + .setCreationTimeForTest(minusDays(START_TIME, 2)) + .setDeletionTime(minusDays(START_TIME, 1)) + .build()); + assertAboutJson() + .that(generateActualJson("dodo.lol")) + .isEqualTo(generateExpectedJsonError("dodo.lol not found", 404)); + assertThat(response.getStatus()).isEqualTo(404); + } + + @Test + void testDomainInExpiryAccessPeriod_loggedInAsAdmin_includeDeleted() { + persistResource( + Tld.get("lol") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + loginAsAdmin(); + action.includeDeletedParam = Optional.of(true); + assertAboutJson() + .that(generateActualJson("dodo.lol")) + .isEqualTo( + addDomainBoilerplateNotices( + jsonFileBuilder() + .addDomain("dodo.lol", "9-LOL") + .addNameserver("ns1.cat.lol", "2-ROID") + .addNameserver("ns2.dodo.lol", "7-ROID") + .addRegistrar("Yes Virginia