From 3c31ce05df55ac9a9dff22b8778a32408374184e 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. To optimize performance and eliminate redundant Cloud SQL database calls on 404 queries: - Expose loadMostRecentByDomainName in DomainCache and MultilayerDomainCache to retrieve soft-deleted domains without filtering them out, while preserving temporal projections. - Update SyncRemoteCacheAction to retain soft-deleted domains in Valkey for the duration of the XAP window on XAP-enabled TLDs instead of immediately deleting them, allowing RDAP lookups to be served directly from Valkey with 0 database queries. - Evaluate XAP eligibility entirely in-memory in RdapDomainAction using the already-loaded domain object. - Add comprehensive unit tests in RdapDomainActionTest, MultilayerDomainCacheTest, SyncRemoteCacheActionTest, and DomainFlowUtilsTest. TAG=agy CONV=214408e9-2dbb-44b8-af91-1ca29401d1f5 BUG=http://b/553509095 --- .../registry/batch/SyncRemoteCacheAction.java | 47 +- .../google/registry/cache/CacheModule.java | 9 +- .../google/registry/cache/DomainCache.java | 10 + .../registry/cache/MultilayerDomainCache.java | 53 +- .../cache/MultilayerEppResourceCache.java | 16 +- .../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 | 32 +- .../rdap/RdapIcannStandardInformation.java | 12 + .../registry/rdap/RdapObjectClasses.java | 21 + .../batch/SyncRemoteCacheActionTest.java | 328 +++++++ .../cache/MultilayerDomainCacheTest.java | 294 +++++- .../cache/SimplifiedJedisClientTest.java | 117 +++ .../registry/flows/EppTestComponent.java | 17 + .../flows/domain/DomainCreateFlowTest.java | 181 +++- .../flows/domain/DomainFlowUtilsTest.java | 193 ++++ .../registry/rdap/RdapActionBaseTestCase.java | 16 +- .../registry/rdap/RdapDomainActionTest.java | 873 +++++++++++++++++- 22 files changed, 2294 insertions(+), 39 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..a2c48b042db 100644 --- a/core/src/main/java/google/registry/batch/SyncRemoteCacheAction.java +++ b/core/src/main/java/google/registry/batch/SyncRemoteCacheAction.java @@ -14,6 +14,7 @@ package google.registry.batch; +import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap; import static google.registry.model.common.Cursor.CursorType.REMOTE_CACHE_DOMAIN_SYNC; import static google.registry.model.common.Cursor.CursorType.REMOTE_CACHE_HOST_SYNC; import static google.registry.persistence.transaction.TransactionManagerFactory.tm; @@ -32,6 +33,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 +89,23 @@ 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; + } + + public SyncRemoteCacheAction( + LockHandler lockHandler, Response response, Optional jedisClient) { + this(lockHandler, response, jedisClient, Duration.ofDays(10)); } @Override @@ -187,10 +199,13 @@ 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, now))); } else { toDeleteBuilder.add(key); } @@ -204,6 +219,32 @@ private void processResources( logger.atInfo().log("Set %d in the remote cache", toSave.size()); } + private Optional getExpirationTime(T resource, Instant now) { + if (resource instanceof Domain domain) { + Tld tld = Tld.get(domain.getTld()); + if (isDomainInXap(domain, tld, now)) { + return Optional.of(domain.getDeletionTime().plus(domainExpiryAccessPeriodTotalLength)); + } + } + return Optional.empty(); + } + + private boolean shouldSaveResourceInRemoteCache(T resource, Instant now) { + if (resource.getDeletionTime().isAfter(now)) { + return true; + } + if (resource instanceof Domain domain) { + return isDomainInXap(domain, Tld.get(domain.getTld()), now); + } + return false; + } + + private boolean isDomainInXap(Domain domain, Tld tld, Instant now) { + return tld.getExpiryAccessPeriodModeAt(now) == Tld.ExpiryAccessPeriodMode.ENABLED + && isDomainEligibleForXap(domain, tld, now) + && domain.getDeletionTime().isAfter(now.minus(domainExpiryAccessPeriodTotalLength)); + } + 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..5bdb0c47ff9 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,16 @@ 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 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..6c922cfb2d7 100644 --- a/core/src/main/java/google/registry/cache/DomainCache.java +++ b/core/src/main/java/google/registry/cache/DomainCache.java @@ -19,5 +19,15 @@ /** 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. + */ + default Optional loadMostRecentByDomainName(String domainName) { + return loadByDomainName(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..2ec10b64f8d 100644 --- a/core/src/main/java/google/registry/cache/MultilayerDomainCache.java +++ b/core/src/main/java/google/registry/cache/MultilayerDomainCache.java @@ -14,11 +14,19 @@ package google.registry.cache; +import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap; + 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.ExpiryAccessPeriodMode; +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 +37,21 @@ 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; + } + + public MultilayerDomainCache( + SimplifiedJedisClient jedisClient, Clock clock, CacheMetrics cacheMetrics) { + this(jedisClient, clock, cacheMetrics, Duration.ofDays(10)); } @Override @@ -39,6 +59,11 @@ public Optional loadByDomainName(String domainName) { return loadFromCaches(Domain.class, domainName); } + @Override + public Optional loadMostRecentByDomainName(String domainName) { + return loadMostRecentFromCaches(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 +75,30 @@ protected Optional loadFromDatabase(String domainName) { @Override protected boolean shouldPersistToRemoteCache(Domain domain) { - return Tld.get(domain.getTld()).getTldType().equals(Tld.TldType.REAL); + Tld tld = Tld.get(domain.getTld()); + if (!tld.getTldType().equals(TldType.REAL)) { + return false; + } + Instant now = clock.now(); + if (domain.getDeletionTime().isAfter(now)) { + return true; + } + return isDomainInXap(domain, tld, now); + } + + @Override + protected Optional getExpirationTime(Domain domain) { + Instant now = clock.now(); + Tld tld = Tld.get(domain.getTld()); + if (isDomainInXap(domain, tld, now)) { + return Optional.of(domain.getDeletionTime().plus(domainExpiryAccessPeriodTotalLength)); + } + return Optional.empty(); + } + + private boolean isDomainInXap(Domain domain, Tld tld, Instant now) { + return tld.getExpiryAccessPeriodModeAt(now) == ExpiryAccessPeriodMode.ENABLED + && isDomainEligibleForXap(domain, tld, now) + && domain.getDeletionTime().isAfter(now.minus(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..082dbb3af8b 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( @@ -54,6 +54,10 @@ protected boolean shouldPersistToRemoteCache(V value) { return true; } + protected Optional getExpirationTime(V resource) { + return Optional.empty(); + } + @SuppressWarnings("unchecked") protected Optional loadFromCaches(Class clazz, String key) { Instant now = clock.now(); @@ -63,6 +67,12 @@ protected Optional loadFromCaches(Class clazz, String key) { .map(v -> v.cloneProjectedAtTime(now)); } + @SuppressWarnings("unchecked") + protected Optional loadMostRecentFromCaches(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) { // hopefully the resource is in the local cache Optional possibleValue = Optional.ofNullable(localCache.getIfPresent(key)); @@ -87,7 +97,9 @@ 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).orElse(null))); } 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..e65877de761 100644 --- a/core/src/main/java/google/registry/model/ForeignKeyUtils.java +++ b/core/src/main/java/google/registry/model/ForeignKeyUtils.java @@ -402,6 +402,22 @@ public static Optional loadResourceByCache( .map(e -> e.cloneProjectedAtTime(now)); } + /** + * 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 loadMostRecentResourceByCache( + Class clazz, String foreignKey, Instant now) { + return (Optional) + foreignKeyToResourceCache + .get(VKey.create(clazz, foreignKey)) + .map(e -> e.cloneProjectedAtTime(now)); + } + /** * Loads the last created version of multiple {@link EppResource}s from the replica database by * foreign keys, using a cache. 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..b2dbbbdfa52 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,7 @@ import google.registry.request.HttpException.NotFoundException; import google.registry.request.auth.Auth; import jakarta.inject.Inject; +import java.time.Duration; import java.util.Optional; /** RDAP action for domain requests. */ @@ -44,6 +47,10 @@ auth = Auth.AUTH_PUBLIC) public class RdapDomainAction extends RdapActionBase { + @Inject + @Config("domainExpiryAccessPeriodTotalLength") + Duration domainExpiryAccessPeriodTotalLength = Duration.ofDays(10); + @Inject public RdapDomainAction() { super("domain name", EndpointType.DOMAIN); } @@ -69,8 +76,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.loadMostRecentByDomainName(pathSearchString); if (domain.isEmpty() || !isAuthorized(domain.get())) { + handlePossibleExpiryAccessPeriod(domainName, domain); 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 +90,22 @@ public RdapDomain getJsonObjectForResource(String pathSearchString, boolean isHe return rdapJsonFormatter.createRdapDomain(domain.get(), OutputDataType.FULL); } + private void handlePossibleExpiryAccessPeriod( + InternetDomainName domainName, Optional domain) { + if (domain.isEmpty()) { + return; + } + Tld tld = Tld.get(domainName.parent().toString()); + if (tld.getExpiryAccessPeriodModeAt(clock.now()) == Tld.ExpiryAccessPeriodMode.ENABLED + && isDomainEligibleForXap(domain.get(), tld, clock.now()) + && domain + .get() + .getDeletionTime() + .isAfter(clock.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 +113,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..f861a84e1de 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()); @@ -552,6 +554,25 @@ public static class DomainBlockedByBsaErrorResponse extends ReplyPayloadBase { } } + /** Specialized error response body for when a domain is in the Expiry Access Period. */ + @RestrictJsonNames({}) + @SuppressWarnings("UnusedVariable") + public static class DomainInExpiryAccessPeriodErrorResponse extends ReplyPayloadBase { + + @JsonableElement private static final LanguageIdentifier lang = LanguageIdentifier.EN; + + @JsonableElement private static final int errorCode = HttpServletResponse.SC_NOT_FOUND; + + @JsonableElement private static final String title = "Not Found"; + + @JsonableElement private final ImmutableList description; + + DomainInExpiryAccessPeriodErrorResponse(String message) { + super(BoilerplateType.DOMAIN_IN_EXPIRY_ACCESS_PERIOD); + this.description = ImmutableList.of(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..f0b8d9be94e 100644 --- a/core/src/test/java/google/registry/batch/SyncRemoteCacheActionTest.java +++ b/core/src/test/java/google/registry/batch/SyncRemoteCacheActionTest.java @@ -26,26 +26,34 @@ 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.START_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; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSortedMap; import google.registry.cache.SimplifiedJedisClient; import google.registry.model.common.Cursor; import google.registry.model.domain.Domain; import google.registry.model.host.Host; +import google.registry.model.tld.Tld; +import google.registry.model.tld.Tld.ExpiryAccessPeriodMode; import google.registry.persistence.transaction.JpaTestExtensions; import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationTestExtension; import google.registry.testing.DatabaseHelper; 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; @@ -166,6 +174,222 @@ void test_syncDomains_withDeletedDomains() { verifyMetrics(SUCCESS); } + @Test + void test_syncDomains_withXapEnabled_keepsDeletedDomainInRemoteCache() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + Domain activeDomain = persistActiveDomain("active.tld"); + Domain xapDomain = persistDeletedDomain("xap.tld", minusDays(clock.now(), 1)); + + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient) + .setAll( + ImmutableList.of( + new SimplifiedJedisClient.JedisResource<>("active.tld", activeDomain), + new SimplifiedJedisClient.JedisResource<>( + "xap.tld", xapDomain, xapDomain.getDeletionTime().plus(Duration.ofDays(10))))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); + verifyMetrics(SUCCESS); + } + + @Test + void test_syncDomains_withXapEnabled_deletedDuringAgp_deletedFromRemoteCache() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + Tld tld = Tld.get("tld"); + persistResource( + persistActiveDomain("agp.tld") + .asBuilder() + .setCreationTimeForTest(clock.now().minus(tld.getAddGracePeriodLength())) + .setDeletionTime(clock.now()) + .build()); + + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient).setAll(ImmutableList.of()); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of("agp.tld")); + verifyMetrics(SUCCESS); + } + + @Test + void test_syncDomains_withXapEnabled_atDeletionTime_keepsDomainInRemoteCache() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + Domain xapDomain = persistDeletedDomain("xap-now.tld", clock.now()); + + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient) + .setAll( + ImmutableList.of( + new SimplifiedJedisClient.JedisResource<>( + "xap-now.tld", + xapDomain, + xapDomain.getDeletionTime().plus(Duration.ofDays(10))))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); + verifyMetrics(SUCCESS); + } + + @Test + void test_syncDomains_withXapEnabled_atExactExpiry_deletedFromRemoteCache() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + persistDeletedDomain("exact-expiry.tld", minusDays(clock.now(), 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_withXapEnabled_insideExpiry_keepsDomainInRemoteCache() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + Domain xapDomain = + persistDeletedDomain( + "inside-expiry.tld", clock.now().minus(Duration.ofDays(10)).plusMillis(1)); + + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient) + .setAll( + ImmutableList.of( + new SimplifiedJedisClient.JedisResource<>( + "inside-expiry.tld", + xapDomain, + xapDomain.getDeletionTime().plus(Duration.ofDays(10))))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); + verifyMetrics(SUCCESS); + } + + @Test + void test_syncDomains_withXapDisabled_deletedDomainDeletedFromRemoteCache() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.DISABLED)) + .build()); + persistDeletedDomain("xap-disabled.tld", minusDays(clock.now(), 1)); + + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient).setAll(ImmutableList.of()); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of("xap-disabled.tld")); + verifyMetrics(SUCCESS); + } + + @Test + void test_syncDomains_cursorAdvances_skipsUnchangedExpiredXapDomain() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + + Instant t0 = clock.now(); + Domain xapDomain = persistDeletedDomain("xap.tld", minusDays(t0, 1)); + + // Run 1: Initial synchronization at T0 + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient) + .setAll( + ImmutableList.of( + new SimplifiedJedisClient.JedisResource<>( + "xap.tld", xapDomain, xapDomain.getDeletionTime().plus(Duration.ofDays(10))))); + Cursor cursor = DatabaseHelper.loadByKey(Cursor.createGlobalVKey(REMOTE_CACHE_DOMAIN_SYNC)); + assertThat(cursor.getCursorTime()).isEqualTo(t0); + + // 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)); + + 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(t0); + + // 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)); + + action.run(); + + assertThat(response3.getStatus()).isEqualTo(SC_OK); + assertThat(response3.getPayload()).contains("Synced 1 domains"); + verify(jedisClient) + .setAll( + ImmutableList.of( + new SimplifiedJedisClient.JedisResource<>("newactive.tld", newActive))); + assertThat( + DatabaseHelper.loadByKey(Cursor.createGlobalVKey(REMOTE_CACHE_DOMAIN_SYNC)) + .getCursorTime()) + .isEqualTo(clock.now()); + } + + @Test + void test_syncDomains_withXapEnabled_deletesDomainDeletedOutsideWindow() { + DatabaseHelper.persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + Domain activeDomain = persistActiveDomain("active.tld"); + persistDeletedDomain("expired.tld", minusDays(clock.now(), 15)); + + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient) + .setAll( + ImmutableList.of( + new SimplifiedJedisClient.JedisResource<>("active.tld", activeDomain))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of("expired.tld")); + verifyMetrics(SUCCESS); + } + @Test void testCursorTime_skipsOldChange() { persistActiveDomain("example1.tld"); @@ -235,4 +459,108 @@ void test_syncHosts_withDeletedHosts() { verify(jedisClient).deleteAll(Host.class, ImmutableList.of(deleted.getRepoId())); verifyMetrics(SUCCESS); } + + @Test + void test_syncDomains_withXapEnabled_deletedJustAfterAgp_retainedInCache() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + Tld tld = Tld.get("tld"); + Domain xapDomain = + persistResource( + persistActiveDomain("agp-outside.tld") + .asBuilder() + .setCreationTimeForTest( + clock.now().minus(tld.getAddGracePeriodLength()).minusMillis(1)) + .setDeletionTime(clock.now()) + .build()); + + action.run(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient) + .setAll( + ImmutableList.of( + new SimplifiedJedisClient.JedisResource<>( + "agp-outside.tld", + xapDomain, + xapDomain.getDeletionTime().plus(Duration.ofDays(10))))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); + verifyMetrics(SUCCESS); + } + + @Test + void test_syncDomains_rapidRecreationAndDeletion_transitionsCacheState() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, ExpiryAccessPeriodMode.ENABLED)) + .build()); + + Instant t0 = clock.now(); + + // Run 1: Deleted in XAP at t0 - 1d (creation t0 - 7d) + Instant t1Del = t0.minus(Duration.ofDays(1)); + Domain v1 = + persistResource( + persistActiveDomain("cycle-sync.tld") + .asBuilder() + .setCreationTimeForTest(t0.minus(Duration.ofDays(7))) + .setDeletionTime(t1Del) + .build()); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(jedisClient) + .setAll( + ImmutableList.of( + new SimplifiedJedisClient.JedisResource<>( + "cycle-sync.tld", v1, t1Del.plus(Duration.ofDays(10))))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); + + // Run 2: Re-registered at t0 + 2d (active) + clock.advanceBy(Duration.ofDays(2)); + clearInvocations(jedisClient); + Instant t2Create = clock.now(); + Domain v2 = + persistResource( + v1.asBuilder().setCreationTimeForTest(t2Create).setDeletionTime(END_INSTANT).build()); + FakeResponse resp2 = new FakeResponse(); + new SyncRemoteCacheAction(lockHandler, resp2, Optional.of(jedisClient)).run(); + assertThat(resp2.getStatus()).isEqualTo(SC_OK); + verify(jedisClient) + .setAll(ImmutableList.of(new SimplifiedJedisClient.JedisResource<>("cycle-sync.tld", v2))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); + + // Run 3: Deleted again at t2Create + 6d (outside 5d AGP) -> new TTL + clock.advanceBy(Duration.ofDays(6)); + clearInvocations(jedisClient); + Instant t3Del = clock.now(); + Domain v3 = persistResource(v2.asBuilder().setDeletionTime(t3Del).build()); + FakeResponse resp3 = new FakeResponse(); + new SyncRemoteCacheAction(lockHandler, resp3, Optional.of(jedisClient)).run(); + assertThat(resp3.getStatus()).isEqualTo(SC_OK); + verify(jedisClient) + .setAll( + ImmutableList.of( + new SimplifiedJedisClient.JedisResource<>( + "cycle-sync.tld", v3, t3Del.plus(Duration.ofDays(10))))); + verify(jedisClient).deleteAll(Domain.class, ImmutableList.of()); + + // Run 4: Re-registered at t3Del + 1d and deleted inside AGP -> purged + clock.advanceBy(Duration.ofDays(1)); + clearInvocations(jedisClient); + Instant t4Create = clock.now(); + Instant t4Del = t4Create.plus(Duration.ofDays(1)); // inside 5d AGP + clock.setTo(t4Del); + persistResource(v3.asBuilder().setCreationTimeForTest(t4Create).setDeletionTime(t4Del).build()); + FakeResponse resp4 = new FakeResponse(); + new SyncRemoteCacheAction(lockHandler, resp4, Optional.of(jedisClient)).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..5f502e9e1a9 100644 --- a/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java +++ b/core/src/test/java/google/registry/cache/MultilayerDomainCacheTest.java @@ -17,12 +17,19 @@ 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 google.registry.util.DateTimeUtils.START_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; +import com.google.common.collect.ImmutableSortedMap; import google.registry.model.domain.Domain; import google.registry.model.domain.GracePeriod; import google.registry.model.domain.rgp.GracePeriodStatus; @@ -32,6 +39,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,12 +48,13 @@ /** Tests for {@link MultilayerDomainCache}. */ public class MultilayerDomainCacheTest { + private final FakeClock clock = new FakeClock(Instant.parse("2025-01-01T00:00:00Z")); + @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; @@ -138,4 +147,285 @@ void testLoad_projectsToCurrentTime() { clock.advanceBy(Duration.ofDays(10)); assertThat(cache.loadByDomainName("example.tld").get().getGracePeriods()).isEmpty(); } + + @Test + void testLoadMostRecent_includesDeletedDomain() { + Domain domain = + persistActiveDomain("example.tld") + .asBuilder() + .setDeletionTime(clock.now().minus(Duration.ofDays(1))) + .build(); + when(jedisClient.get(Domain.class, "example.tld")).thenReturn(Optional.of(domain)); + assertThat(cache.loadByDomainName("example.tld")).isEmpty(); + assertThat(cache.loadMostRecentByDomainName("example.tld")).hasValue(domain); + } + + @Test + void testLoadMostRecent_xapDomain_populatesValkeyWithCalculatedTtl() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED)) + .build()); + + Instant deletionTime = clock.now().minus(Duration.ofDays(2)); + Domain domain = persistDeletedDomain("xap.tld", deletionTime); + + assertThat(cache.loadMostRecentByDomainName("xap.tld")).hasValue(domain); + + Instant expectedExpiration = deletionTime.plus(Duration.ofDays(10)); + verify(jedisClient).get(Domain.class, "xap.tld"); + verify(jedisClient) + .set(new SimplifiedJedisClient.JedisResource<>("xap.tld", domain, expectedExpiration)); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS); + } + + @Test + void testLoadMostRecent_softDeleted_agpDelete_doesNotPersistToValkey() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED)) + .build()); + + Domain domain = + persistResource( + persistDeletedDomain("agp.tld", clock.now().minus(Duration.ofDays(1))) + .asBuilder() + .setCreationTimeForTest(clock.now().minus(Duration.ofDays(2))) + .build()); + + assertThat(cache.loadMostRecentByDomainName("agp.tld")).hasValue(domain); + + verify(jedisClient).get(Domain.class, "agp.tld"); + verify(jedisClient, never()).set(any()); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS); + } + + @Test + void testLoadMostRecent_softDeleted_pastXapWindow_doesNotPersistToValkey() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED)) + .build()); + + Domain domain = persistDeletedDomain("past-xap.tld", clock.now().minus(Duration.ofDays(11))); + + assertThat(cache.loadMostRecentByDomainName("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 testLoadMostRecent_softDeleted_xapDisabled_doesNotPersistToValkey() { + Domain domain = persistDeletedDomain("disabled-xap.tld", clock.now().minus(Duration.ofDays(2))); + + assertThat(cache.loadMostRecentByDomainName("disabled-xap.tld")).hasValue(domain); + + verify(jedisClient).get(Domain.class, "disabled-xap.tld"); + verify(jedisClient, never()).set(any()); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS); + } + + @Test + void testLoadMostRecent_softDeleted_testTld_doesNotPersistToValkey() { + persistResource( + Tld.get("tld") + .asBuilder() + .setTldType(Tld.TldType.TEST) + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED)) + .build()); + + Domain domain = persistDeletedDomain("test-tld.tld", clock.now().minus(Duration.ofDays(2))); + + assertThat(cache.loadMostRecentByDomainName("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() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED)) + .build()); + + Instant now = clock.now(); + + // 1. Active domain + Domain activeDomain = persistActiveDomain("active.tld"); + assertThat(cache.shouldPersistToRemoteCache(activeDomain)).isTrue(); + assertThat(cache.getExpirationTime(activeDomain)).isEmpty(); + + // 2. AGP-deleted domain + Domain agpDomain = + persistResource( + persistDeletedDomain("agp-bound.tld", now.minus(Duration.ofDays(1))) + .asBuilder() + .setCreationTimeForTest(now.minus(Duration.ofDays(2))) + .build()); + assertThat(cache.shouldPersistToRemoteCache(agpDomain)).isFalse(); + assertThat(cache.getExpirationTime(agpDomain)).isEmpty(); + + // 3. Boundary: deletionTime == now + Domain deletedAtNow = persistDeletedDomain("del-now.tld", now); + assertThat(cache.shouldPersistToRemoteCache(deletedAtNow)).isTrue(); + assertThat(cache.getExpirationTime(deletedAtNow)).hasValue(now.plus(Duration.ofDays(10))); + + // 4. Boundary: deletionTime == now - 10d + 1ms (strictly inside 10d window) + Instant insideWindow = now.minus(Duration.ofDays(10)).plusMillis(1); + Domain deletedInsideWindow = persistDeletedDomain("inside.tld", insideWindow); + assertThat(cache.shouldPersistToRemoteCache(deletedInsideWindow)).isTrue(); + assertThat(cache.getExpirationTime(deletedInsideWindow)) + .hasValue(insideWindow.plus(Duration.ofDays(10))); + + // 5. Boundary: deletionTime == now - 10d (exact edge of 10d window) + Instant exactEdge = now.minus(Duration.ofDays(10)); + Domain deletedExactEdge = persistDeletedDomain("exact-edge.tld", exactEdge); + assertThat(cache.shouldPersistToRemoteCache(deletedExactEdge)).isFalse(); + assertThat(cache.getExpirationTime(deletedExactEdge)).isEmpty(); + + // 6. Boundary: deletionTime == now - 10d - 1ms (strictly outside window) + Instant outsideWindow = now.minus(Duration.ofDays(10)).minusMillis(1); + Domain deletedOutsideWindow = persistDeletedDomain("outside.tld", outsideWindow); + assertThat(cache.shouldPersistToRemoteCache(deletedOutsideWindow)).isFalse(); + assertThat(cache.getExpirationTime(deletedOutsideWindow)).isEmpty(); + + // 7. Custom length constructor (15 days) + MultilayerDomainCache customCache = + new MultilayerDomainCache(jedisClient, clock, cacheMetrics, Duration.ofDays(15)); + Instant twelveDaysAgo = now.minus(Duration.ofDays(12)); + Domain deletedTwelveDaysAgo = persistDeletedDomain("twelve-days.tld", twelveDaysAgo); + // In default 10-day cache: not in XAP + assertThat(cache.shouldPersistToRemoteCache(deletedTwelveDaysAgo)).isFalse(); + assertThat(cache.getExpirationTime(deletedTwelveDaysAgo)).isEmpty(); + // In custom 15-day cache: in XAP + assertThat(customCache.shouldPersistToRemoteCache(deletedTwelveDaysAgo)).isTrue(); + assertThat(customCache.getExpirationTime(deletedTwelveDaysAgo)) + .hasValue(twelveDaysAgo.plus(Duration.ofDays(15))); + } + + @Test + void testLoadMostRecent_softDeleted_exactAgp_doesNotPersistToValkey() { + Tld tld = + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED)) + .build()); + + Instant creationTime = clock.now().minus(Duration.ofDays(6)); + Instant deletionTime = creationTime.plus(tld.getAddGracePeriodLength()); + Domain domain = + persistResource( + persistDeletedDomain("agp-exact-cache.tld", deletionTime) + .asBuilder() + .setCreationTimeForTest(creationTime) + .build()); + + assertThat(cache.loadMostRecentByDomainName("agp-exact-cache.tld")).hasValue(domain); + + verify(jedisClient).get(Domain.class, "agp-exact-cache.tld"); + verify(jedisClient, never()).set(any()); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS); + } + + @Test + void testLoadMostRecent_softDeleted_justAfterAgp_persistsToValkey() { + Tld tld = + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED)) + .build()); + + Instant creationTime = clock.now().minus(Duration.ofDays(6)); + Instant deletionTime = creationTime.plus(tld.getAddGracePeriodLength()).plusMillis(1); + Domain domain = + persistResource( + persistDeletedDomain("agp-after-cache.tld", deletionTime) + .asBuilder() + .setCreationTimeForTest(creationTime) + .build()); + + assertThat(cache.loadMostRecentByDomainName("agp-after-cache.tld")).hasValue(domain); + + verify(jedisClient).get(Domain.class, "agp-after-cache.tld"); + verify(jedisClient) + .set( + new SimplifiedJedisClient.JedisResource<>( + "agp-after-cache.tld", domain, deletionTime.plus(Duration.ofDays(10)))); + verify(cacheMetrics).recordLookup("Domain", CacheMetrics.CacheHitType.MISS); + } + + @Test + void testRapidRecreationAndDeletionCycle_transitionsCacheAndTtlCorrectly() { + persistResource( + Tld.get("tld") + .asBuilder() + .setExpiryAccessPeriodTransitions( + ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED)) + .build()); + + Instant t0 = clock.now(); + + // 1. Initial soft deletion outside AGP (in XAP) + Instant t1Del = t0.minus(Duration.ofDays(2)); + Domain domainV1 = + persistResource( + persistDeletedDomain("cycle.tld", t1Del) + .asBuilder() + .setCreationTimeForTest(t0.minus(Duration.ofDays(8))) + .build()); + + MultilayerDomainCache cache1 = new MultilayerDomainCache(jedisClient, clock, cacheMetrics); + assertThat(cache1.loadMostRecentByDomainName("cycle.tld")).hasValue(domainV1); + verify(jedisClient) + .set( + new SimplifiedJedisClient.JedisResource<>( + "cycle.tld", domainV1, t1Del.plus(Duration.ofDays(10)))); + + // 2. Domain re-registered (active) + clearInvocations(jedisClient, cacheMetrics); + Instant t2Create = t0.minus(Duration.ofDays(1)); + Domain domainV2 = + persistResource( + persistActiveDomain("cycle.tld") + .asBuilder() + .setCreationTimeForTest(t2Create) + .setDeletionTime(END_INSTANT) + .build()); + + MultilayerDomainCache cache2 = new MultilayerDomainCache(jedisClient, clock, cacheMetrics); + assertThat(cache2.loadMostRecentByDomainName("cycle.tld")).hasValue(domainV2); + verify(jedisClient).set(new SimplifiedJedisClient.JedisResource<>("cycle.tld", domainV2)); + + // 3. Domain deleted again outside new AGP (new deletionTime & new TTL) + clearInvocations(jedisClient, cacheMetrics); + Instant t3Del = t0.plus(Duration.ofDays(6)); // > t2Create + 5d AGP + clock.setTo(t3Del.plus(Duration.ofDays(1))); + Domain domainV3 = + persistResource( + domainV2.asBuilder().setCreationTimeForTest(t2Create).setDeletionTime(t3Del).build()); + + MultilayerDomainCache cache3 = new MultilayerDomainCache(jedisClient, clock, cacheMetrics); + assertThat(cache3.loadMostRecentByDomainName("cycle.tld")).hasValue(domainV3); + verify(jedisClient) + .set( + new SimplifiedJedisClient.JedisResource<>( + "cycle.tld", domainV3, t3Del.plus(Duration.ofDays(10)))); + } } diff --git a/core/src/test/java/google/registry/cache/SimplifiedJedisClientTest.java b/core/src/test/java/google/registry/cache/SimplifiedJedisClientTest.java index 3545f150902..0f2e553bae2 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; @@ -153,6 +156,120 @@ void testClient_nonexistent() { assertThat(hostClient.get(Host.class, "ns1.nonexistent.tld")).isEmpty(); } + @Test + void testSet_softDeletedDomain_withExplicitExpiration_retainedInValkey() { + SimplifiedJedisClient client = createJedisClient(); + Instant deletionTime = fakeClock.now(); + Domain softDeletedDomain = persistDeletedDomain("xap-retained.tld", deletionTime); + + Instant futureExpiration = Instant.parse("2035-01-01T00:00:00.000Z"); + 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); + } + + @Test + void testSet_softDeletedDomain_withoutExplicitExpiration_evictedImmediately() { + SimplifiedJedisClient client = createJedisClient(); + Domain softDeletedDomain = persistDeletedDomain("evicted-immediate.tld", fakeClock.now()); + + client.set( + new SimplifiedJedisClient.JedisResource<>("evicted-immediate.tld", softDeletedDomain)); + + assertThat(client.get(Domain.class, "evicted-immediate.tld")).isEmpty(); + } + + @Test + void testSetAll_mixedResources_pipelineSetsCorrectTtls() { + SimplifiedJedisClient client = createJedisClient(); + + Domain activeDomain = persistActiveDomain("active.tld"); + Domain xapDomain = persistDeletedDomain("xap.tld", fakeClock.now()); + Instant xapExpiration = Instant.parse("2035-01-01T00:00:00.000Z"); + Domain expiredDomain = persistDeletedDomain("expired.tld", fakeClock.now()); + + 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); + + Optional cachedXap = client.get(Domain.class, "xap.tld"); + assertThat(cachedXap).isPresent(); + assertThat(cachedXap.get().getDeletionTime()).isEqualTo(fakeClock.now()); + + assertThat(client.get(Domain.class, "expired.tld")).isEmpty(); + } + + @Test + void testJedisResource_expirationResolution() { + Domain domain = persistDeletedDomain("test.tld", fakeClock.now().minus(Duration.ofDays(2))); + Instant explicitTime = fakeClock.now().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 + SimplifiedJedisClient.JedisResource nullInstantResource = + new SimplifiedJedisClient.JedisResource<>("test.tld", domain, (Instant) null); + 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 SimplifiedJedisClient createJedisClient() { return new SimplifiedJedisClient( RedisClient.builder() diff --git a/core/src/test/java/google/registry/flows/EppTestComponent.java b/core/src/test/java/google/registry/flows/EppTestComponent.java index 8fa0779dab9..4f3835529f3 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; @@ -40,6 +41,7 @@ import google.registry.util.Clock; import google.registry.util.Sleeper; import jakarta.inject.Singleton; +import java.util.Optional; /** Dagger component for running EPP tests. */ @Singleton @@ -134,6 +136,21 @@ ServerTridProvider provideServerTridProvider() { DomainDeletionTimeCache provideDomainDeletionTimeCache() { return DomainDeletionTimeCache.create(); } + + 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..e9e5eca2ee8 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)); + Optional domainBefore = cacheBefore.loadMostRecentByDomainName("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)); + Optional domainAfter = cacheAfter.loadMostRecentByDomainName("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..51dfc29c300 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; @@ -196,4 +205,188 @@ 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"), clock.now())).isFalse(); + } + + @Test + void testIsDomainEligibleForXap_deletedOutsideAgp_returnsTrue() { + Domain domain = persistDeletedDomain("deleted.tld", minusDays(clock.now(), 1)); + assertThat(isDomainEligibleForXap(domain, Tld.get("tld"), clock.now())).isTrue(); + } + + @Test + void testIsDomainEligibleForXap_deletedDuringAgp_returnsFalse() { + Domain domain = + persistActiveDomain("agp.tld") + .asBuilder() + .setCreationTimeForTest(minusDays(clock.now(), 2)) + .setDeletionTime(minusDays(clock.now(), 1)) + .build(); + persistResource(domain); + assertThat(isDomainEligibleForXap(domain, Tld.get("tld"), clock.now())).isFalse(); + } + + @Test + void testLoadDomainIfInXap_eligibleAndWithinWindow_returnsDomain() { + Domain domain = persistDeletedDomain("xap.tld", minusDays(clock.now(), 2)); + assertThat(loadDomainIfInXap("xap.tld", clock.now(), Duration.ofDays(10))).hasValue(domain); + } + + @Test + void testLoadDomainIfInXap_deletedOutsideWindow_returnsEmpty() { + persistDeletedDomain("expired.tld", minusDays(clock.now(), 15)); + assertThat(loadDomainIfInXap("expired.tld", clock.now(), Duration.ofDays(10))).isEmpty(); + } + + @Test + void testLoadDomainIfInXap_deletedDuringAgp_returnsEmpty() { + Domain domain = + persistActiveDomain("agp.tld") + .asBuilder() + .setCreationTimeForTest(minusDays(clock.now(), 2)) + .setDeletionTime(minusDays(clock.now(), 1)) + .build(); + persistResource(domain); + assertThat(loadDomainIfInXap("agp.tld", clock.now(), Duration.ofDays(10))).isEmpty(); + } + + @Test + void testIsDomainEligibleForXap_deletedAtNow_returnsTrue() { + Domain domain = persistDeletedDomain("deleted-now.tld", clock.now()); + assertThat(isDomainEligibleForXap(domain, Tld.get("tld"), clock.now())).isTrue(); + } + + @Test + void testIsDomainEligibleForXap_deletedInFuture_returnsFalse() { + Domain domain = persistDeletedDomain("future.tld", clock.now().plus(Duration.ofDays(1))); + assertThat(isDomainEligibleForXap(domain, Tld.get("tld"), clock.now())).isFalse(); + } + + @Test + void testIsDomainEligibleForXap_agpBoundaryExact_returnsFalse() { + Tld tld = Tld.get("tld"); + Domain domain = + persistActiveDomain("agp-exact.tld") + .asBuilder() + .setCreationTimeForTest(clock.now().minus(tld.getAddGracePeriodLength())) + .setDeletionTime(clock.now()) + .build(); + persistResource(domain); + assertThat(isDomainEligibleForXap(domain, tld, clock.now())).isFalse(); + } + + @Test + void testIsDomainEligibleForXap_deletedJustAfterAgp_returnsTrue() { + Tld tld = Tld.get("tld"); + Domain domain = + persistActiveDomain("agp-after.tld") + .asBuilder() + .setCreationTimeForTest(clock.now().minus(tld.getAddGracePeriodLength()).minusMillis(1)) + .setDeletionTime(clock.now()) + .build(); + persistResource(domain); + assertThat(isDomainEligibleForXap(domain, tld, clock.now())).isTrue(); + } + + @Test + void testWasDeletedDuringAddGracePeriod_boundaries() { + Tld tld = Tld.get("tld"); + Domain domainExact = + persistActiveDomain("agp-exact-fn.tld") + .asBuilder() + .setCreationTimeForTest(clock.now().minus(tld.getAddGracePeriodLength())) + .setDeletionTime(clock.now()) + .build(); + persistResource(domainExact); + assertThat(wasDeletedDuringAddGracePeriod(domainExact, tld)).isTrue(); + + Domain domainAfter = + persistActiveDomain("agp-after-fn.tld") + .asBuilder() + .setCreationTimeForTest(clock.now().minus(tld.getAddGracePeriodLength()).minusMillis(1)) + .setDeletionTime(clock.now()) + .build(); + persistResource(domainAfter); + assertThat(wasDeletedDuringAddGracePeriod(domainAfter, tld)).isFalse(); + } + + @Test + void testLoadDomainIfInXap_deletedAtNow_returnsDomain() { + Domain domain = persistDeletedDomain("xap-now.tld", clock.now()); + assertThat(loadDomainIfInXap("xap-now.tld", clock.now(), Duration.ofDays(10))).hasValue(domain); + } + + @Test + void testLoadDomainIfInXap_exactWindowBoundary_returnsEmpty() { + persistDeletedDomain("expired-exact.tld", minusDays(clock.now(), 10)); + assertThat(loadDomainIfInXap("expired-exact.tld", clock.now(), Duration.ofDays(10))).isEmpty(); + } + + @Test + void testLoadDomainIfInXap_justInsideWindowBoundary_returnsDomain() { + Domain domain = + persistDeletedDomain("inside-window.tld", minusDays(clock.now(), 10).plusSeconds(1)); + assertThat(loadDomainIfInXap("inside-window.tld", clock.now(), Duration.ofDays(10))) + .hasValue(domain); + } + + @Test + void testLoadDomainIfInXap_justOutsideWindowBoundary_returnsEmpty() { + persistDeletedDomain("outside-window.tld", minusDays(clock.now(), 10).minusSeconds(1)); + assertThat(loadDomainIfInXap("outside-window.tld", clock.now(), Duration.ofDays(10))).isEmpty(); + } + + @Test + void testIsDomainEligibleForXap_deletedOneMilliInFuture_returnsFalse() { + Instant now = clock.now(); + Domain domain = persistDeletedDomain("future-milli.tld", now.plusMillis(1)); + assertThat(isDomainEligibleForXap(domain, Tld.get("tld"), now)).isFalse(); + } + + @Test + void testLoadDomainIfInXap_oneMilliInsideWindow_returnsDomain() { + Domain domain = persistDeletedDomain("inside-milli.tld", clock.now()); + 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", clock.now()); + 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(clock.now().minus(tld.getAddGracePeriodLength())) + .setDeletionTime(clock.now()) + .build(); + persistResource(domain); + assertThat(loadDomainIfInXap("agp-exact-load.tld", clock.now(), Duration.ofDays(10))).isEmpty(); + } + + @Test + void testLoadDomainIfInXap_oneMilliAfterAgpBoundary_returnsDomain() { + Tld tld = Tld.get("tld"); + Domain domain = + persistResource( + persistActiveDomain("agp-after-load.tld") + .asBuilder() + .setCreationTimeForTest( + clock.now().minus(tld.getAddGracePeriodLength()).minusMillis(1)) + .setDeletionTime(clock.now()) + .build()); + assertThat(loadDomainIfInXap("agp-after-load.tld", clock.now(), Duration.ofDays(10))) + .hasValue(domain); + } } diff --git a/core/src/test/java/google/registry/rdap/RdapActionBaseTestCase.java b/core/src/test/java/google/registry/rdap/RdapActionBaseTestCase.java index f9cc8304a63..6a01374d54f 100644 --- a/core/src/test/java/google/registry/rdap/RdapActionBaseTestCase.java +++ b/core/src/test/java/google/registry/rdap/RdapActionBaseTestCase.java @@ -28,6 +28,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import google.registry.cache.DomainCache; import google.registry.model.ForeignKeyUtils; import google.registry.model.console.User; import google.registry.model.console.UserRoles; @@ -95,8 +96,19 @@ 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")); + new DomainCache() { + @Override + public Optional loadByDomainName(String domainName) { + return ForeignKeyUtils.loadResourceByCache(Domain.class, domainName, clock.now()); + } + + @Override + public Optional loadMostRecentByDomainName(String domainName) { + return ForeignKeyUtils.loadMostRecentResourceByCache( + Domain.class, domainName, clock.now()); + } + }; + 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..7ca7e3201b2 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; @@ -67,6 +85,8 @@ class RdapDomainActionTest extends RdapActionBaseTestCase { } private Host host1; + private Domain domainDeleted; + private Domain domainIdn; @BeforeEach void beforeEach() { @@ -91,7 +111,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 +124,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(clock.now(), 3)) + .setCreationRegistrarId("TheRegistrar") + .build()); // 1.tld createTld("1.tld"); @@ -474,6 +495,844 @@ 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(clock.now(), 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(clock.now(), 2)) + .setDeletionTime(minusDays(clock.now(), 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