From f58211402a88fd76891921c2fbc70a8eb3751c15 Mon Sep 17 00:00:00 2001 From: mcilwain Date: Tue, 11 Dec 2018 07:31:30 -0800 Subject: [PATCH] Add an unrenew_domain command to nomulus tool This is used to reduce the expiration time of domain(s) by some number of years (if enough length remains in the registration term to do so). This does not back out the previously saved BillingEvent entities as they may have already been sent out and invoiced, so any related refunds must be handled out of band. In addition to reducing the registration expiration time on the domain itself, this command writes out a new history entry, one-time poll message informing the registrar of this change, auto-renew billing event and poll message, and updates/ends the old auto-renew billing event and poll message. ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=224999285 --- .../flows/domain/DomainFlowUtils.java | 14 +- .../flows/domain/DomainRenewFlow.java | 2 +- java/google/registry/tools/GtechTool.java | 1 + java/google/registry/tools/RegistryTool.java | 1 + .../registry/tools/RegistryToolComponent.java | 1 + .../registry/tools/RenewDomainCommand.java | 2 +- .../registry/tools/UnrenewDomainCommand.java | 225 ++++++++++++++++++ java/google/registry/util/DateTimeUtils.java | 9 + .../flows/EppLifecycleDomainTest.java | 6 +- .../google/registry/flows/EppTestCase.java | 14 +- .../domain_info_response_inactive.xml | 31 +++ ...in_info_response_inactive_grace_period.xml | 36 +++ .../flows/testdata/domain_renew_response.xml | 18 ++ .../flows/testdata/poll_response_unrenew.xml | 15 ++ .../registry/testing/DatastoreHelper.java | 2 +- .../registry/testing/HistoryEntrySubject.java | 9 + javatests/google/registry/tools/BUILD | 1 + .../registry/tools/CommandTestCase.java | 4 + .../registry/tools/EppLifecycleToolsTest.java | 193 +++++++++++++++ .../tools/UnrenewDomainCommandTest.java | 217 +++++++++++++++++ .../registry/util/DateTimeUtilsTest.java | 15 ++ 21 files changed, 801 insertions(+), 15 deletions(-) create mode 100644 java/google/registry/tools/UnrenewDomainCommand.java create mode 100644 javatests/google/registry/flows/testdata/domain_info_response_inactive.xml create mode 100644 javatests/google/registry/flows/testdata/domain_info_response_inactive_grace_period.xml create mode 100644 javatests/google/registry/flows/testdata/domain_renew_response.xml create mode 100644 javatests/google/registry/flows/testdata/poll_response_unrenew.xml create mode 100644 javatests/google/registry/tools/EppLifecycleToolsTest.java create mode 100644 javatests/google/registry/tools/UnrenewDomainCommandTest.java diff --git a/java/google/registry/flows/domain/DomainFlowUtils.java b/java/google/registry/flows/domain/DomainFlowUtils.java index 476734247..8223441ed 100644 --- a/java/google/registry/flows/domain/DomainFlowUtils.java +++ b/java/google/registry/flows/domain/DomainFlowUtils.java @@ -520,7 +520,7 @@ public class DomainFlowUtils { * Fills in a builder with the data needed for an autorenew billing event for this domain. This * does not copy over the id of the current autorenew billing event. */ - static BillingEvent.Recurring.Builder newAutorenewBillingEvent(DomainResource domain) { + public static BillingEvent.Recurring.Builder newAutorenewBillingEvent(DomainResource domain) { return new BillingEvent.Recurring.Builder() .setReason(Reason.RENEW) .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) @@ -533,7 +533,7 @@ public class DomainFlowUtils { * Fills in a builder with the data needed for an autorenew poll message for this domain. This * does not copy over the id of the current autorenew poll message. */ - static PollMessage.Autorenew.Builder newAutorenewPollMessage(DomainResource domain) { + public static PollMessage.Autorenew.Builder newAutorenewPollMessage(DomainResource domain) { return new PollMessage.Autorenew.Builder() .setTargetId(domain.getFullyQualifiedDomainName()) .setClientId(domain.getCurrentSponsorClientId()) @@ -542,12 +542,14 @@ public class DomainFlowUtils { } /** - * Re-saves the current autorenew billing event and poll message with a new end time. This may end - * up deleting the poll message (if closing the message interval) or recreating it (if opening the - * message interval). + * Re-saves the current autorenew billing event and poll message with a new end time. + * + *

This may end up deleting the poll message (if closing the message interval) or recreating it + * (if opening the message interval). This may cause an autorenew billing event to have an end + * time earlier than its event time (i.e. if it's being ended before it was ever triggered). */ @SuppressWarnings("unchecked") - static void updateAutorenewRecurrenceEndTime(DomainResource domain, DateTime newEndTime) { + public static void updateAutorenewRecurrenceEndTime(DomainResource domain, DateTime newEndTime) { Optional autorenewPollMessage = Optional.ofNullable(ofy().load().key(domain.getAutorenewPollMessage()).now()); diff --git a/java/google/registry/flows/domain/DomainRenewFlow.java b/java/google/registry/flows/domain/DomainRenewFlow.java index 0cf32130b..3fadf8026 100644 --- a/java/google/registry/flows/domain/DomainRenewFlow.java +++ b/java/google/registry/flows/domain/DomainRenewFlow.java @@ -226,7 +226,7 @@ public final class DomainRenewFlow implements TransactionalFlow { .setType(HistoryEntry.Type.DOMAIN_RENEW) .setPeriod(period) .setModificationTime(now) - .setParent(Key.create(existingDomain)) + .setParent(existingDomain) .setDomainTransactionRecords( ImmutableSet.of( DomainTransactionRecord.create( diff --git a/java/google/registry/tools/GtechTool.java b/java/google/registry/tools/GtechTool.java index 82b9d2157..60173099d 100644 --- a/java/google/registry/tools/GtechTool.java +++ b/java/google/registry/tools/GtechTool.java @@ -64,6 +64,7 @@ public final class GtechTool { "setup_ote", "uniform_rapid_suspension", "unlock_domain", + "unrenew_domain", "update_domain", "update_registrar", "update_sandbox_tld", diff --git a/java/google/registry/tools/RegistryTool.java b/java/google/registry/tools/RegistryTool.java index 0c1d57e5d..28acc36c9 100644 --- a/java/google/registry/tools/RegistryTool.java +++ b/java/google/registry/tools/RegistryTool.java @@ -108,6 +108,7 @@ public final class RegistryTool { .put("setup_ote", SetupOteCommand.class) .put("uniform_rapid_suspension", UniformRapidSuspensionCommand.class) .put("unlock_domain", UnlockDomainCommand.class) + .put("unrenew_domain", UnrenewDomainCommand.class) .put("update_application_status", UpdateApplicationStatusCommand.class) .put("update_claims_notice", UpdateClaimsNoticeCommand.class) .put("update_cursors", UpdateCursorsCommand.class) diff --git a/java/google/registry/tools/RegistryToolComponent.java b/java/google/registry/tools/RegistryToolComponent.java index 1590a9910..f892a3be6 100644 --- a/java/google/registry/tools/RegistryToolComponent.java +++ b/java/google/registry/tools/RegistryToolComponent.java @@ -101,6 +101,7 @@ interface RegistryToolComponent { void inject(SetNumInstancesCommand command); void inject(SetupOteCommand command); void inject(UnlockDomainCommand command); + void inject(UnrenewDomainCommand command); void inject(UpdateCursorsCommand command); void inject(UpdateDomainCommand command); void inject(UpdateKmsKeyringCommand command); diff --git a/java/google/registry/tools/RenewDomainCommand.java b/java/google/registry/tools/RenewDomainCommand.java index 6cb956527..fd1ffcb7b 100644 --- a/java/google/registry/tools/RenewDomainCommand.java +++ b/java/google/registry/tools/RenewDomainCommand.java @@ -37,7 +37,7 @@ import org.joda.time.format.DateTimeFormatter; final class RenewDomainCommand extends MutatingEppToolCommand { @Parameter( - names = "--period", + names = {"-p", "--period"}, description = "Number of years to renew the registration for (defaults to 1).") private int period = 1; diff --git a/java/google/registry/tools/UnrenewDomainCommand.java b/java/google/registry/tools/UnrenewDomainCommand.java new file mode 100644 index 000000000..3de1cf9b4 --- /dev/null +++ b/java/google/registry/tools/UnrenewDomainCommand.java @@ -0,0 +1,225 @@ +// Copyright 2018 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.tools; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static google.registry.flows.domain.DomainFlowUtils.newAutorenewBillingEvent; +import static google.registry.flows.domain.DomainFlowUtils.newAutorenewPollMessage; +import static google.registry.flows.domain.DomainFlowUtils.updateAutorenewRecurrenceEndTime; +import static google.registry.model.EppResourceUtils.loadByForeignKey; +import static google.registry.model.ofy.ObjectifyService.ofy; +import static google.registry.util.DateTimeUtils.isBeforeOrAt; +import static google.registry.util.DateTimeUtils.leapSafeSubtractYears; + +import com.beust.jcommander.Parameter; +import com.beust.jcommander.Parameters; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; +import com.googlecode.objectify.Key; +import google.registry.model.billing.BillingEvent; +import google.registry.model.domain.DomainResource; +import google.registry.model.domain.Period; +import google.registry.model.domain.Period.Unit; +import google.registry.model.eppcommon.StatusValue; +import google.registry.model.index.ForeignKeyIndex.ForeignKeyDomainIndex; +import google.registry.model.poll.PollMessage; +import google.registry.model.reporting.HistoryEntry; +import google.registry.model.reporting.HistoryEntry.Type; +import google.registry.util.Clock; +import google.registry.util.NonFinalForTesting; +import java.util.List; +import javax.inject.Inject; +import org.joda.time.DateTime; + +/** + * Command to unrenew a domain. + * + *

This removes years off a domain's registration period. Note that the expiration time cannot be + * set to prior than the present. Reversal of the charges for these years (if desired) must happen + * out of band, as they may already have been billed out and thus cannot and won't be reversed in + * Datastore. + */ +@Parameters(separators = " =", commandDescription = "Unrenew a domain.") +@NonFinalForTesting +class UnrenewDomainCommand extends ConfirmingCommand implements CommandWithRemoteApi { + + @Parameter( + names = {"-p", "--period"}, + description = "Number of years to unrenew the registration for (defaults to 1).") + int period = 1; + + @Parameter(description = "Names of the domains to unrenew.", required = true) + List mainParameters; + + @Inject Clock clock; + + private static final ImmutableSet DISALLOWED_STATUSES = + ImmutableSet.of( + StatusValue.PENDING_TRANSFER, + StatusValue.SERVER_RENEW_PROHIBITED, + StatusValue.SERVER_UPDATE_PROHIBITED); + + @Override + protected void init() { + checkArgument(period >= 1 && period <= 9, "Period must be in the range 1-9"); + DateTime now = clock.nowUtc(); + ImmutableSet.Builder domainsNonexistentBuilder = new ImmutableSet.Builder<>(); + ImmutableSet.Builder domainsDeletingBuilder = new ImmutableSet.Builder<>(); + ImmutableMultimap.Builder domainsWithDisallowedStatusesBuilder = + new ImmutableMultimap.Builder<>(); + ImmutableMap.Builder domainsExpiringTooSoonBuilder = + new ImmutableMap.Builder<>(); + + for (String domainName : mainParameters) { + if (ofy().load().type(ForeignKeyDomainIndex.class).id(domainName).now() == null) { + domainsNonexistentBuilder.add(domainName); + continue; + } + DomainResource domain = loadByForeignKey(DomainResource.class, domainName, now); + if (domain == null || domain.getStatusValues().contains(StatusValue.PENDING_DELETE)) { + domainsDeletingBuilder.add(domainName); + continue; + } + domainsWithDisallowedStatusesBuilder.putAll( + domainName, Sets.intersection(domain.getStatusValues(), DISALLOWED_STATUSES)); + if (isBeforeOrAt( + leapSafeSubtractYears(domain.getRegistrationExpirationTime(), period), now)) { + domainsExpiringTooSoonBuilder.put(domainName, domain.getRegistrationExpirationTime()); + } + } + + ImmutableSet domainsNonexistent = domainsNonexistentBuilder.build(); + ImmutableSet domainsDeleting = domainsDeletingBuilder.build(); + ImmutableMultimap domainsWithDisallowedStatuses = + domainsWithDisallowedStatusesBuilder.build(); + ImmutableMap domainsExpiringTooSoon = domainsExpiringTooSoonBuilder.build(); + + boolean foundInvalidDomains = + !(domainsNonexistent.isEmpty() + && domainsDeleting.isEmpty() + && domainsWithDisallowedStatuses.isEmpty() + && domainsExpiringTooSoon.isEmpty()); + if (foundInvalidDomains) { + System.err.print("Found domains that cannot be unrenewed for the following reasons:\n\n"); + } + if (!domainsNonexistent.isEmpty()) { + System.err.printf("Domains that don't exist: %s\n\n", domainsNonexistent); + } + if (!domainsDeleting.isEmpty()) { + System.err.printf("Domains that are deleted or pending delete: %s\n\n", domainsDeleting); + } + if (!domainsWithDisallowedStatuses.isEmpty()) { + System.err.printf("Domains with disallowed statuses: %s\n\n", domainsWithDisallowedStatuses); + } + if (!domainsExpiringTooSoon.isEmpty()) { + System.err.printf("Domains expiring too soon: %s\n\n", domainsExpiringTooSoon); + } + checkArgument(!foundInvalidDomains, "Aborting because some domains cannot be unrewed"); + } + + @Override + protected String prompt() { + return String.format("Unrenew these domain(s) for %d years?", period); + } + + @Override + protected String execute() { + for (String domainName : mainParameters) { + ofy().transact(() -> unrenewDomain(domainName)); + System.out.printf("Unrenewed %s\n", domainName); + } + return "Successfully unrenewed all domains."; + } + + private void unrenewDomain(String domainName) { + ofy().assertInTransaction(); + DateTime now = ofy().getTransactionTime(); + DomainResource domain = loadByForeignKey(DomainResource.class, domainName, now); + // Transactional sanity checks on the off chance that something changed between init() running + // and here. + checkState( + domain != null && !domain.getStatusValues().contains(StatusValue.PENDING_DELETE), + "Domain %s was deleted or is pending deletion", + domainName); + checkState( + Sets.intersection(domain.getStatusValues(), DISALLOWED_STATUSES).isEmpty(), + "Domain %s has prohibited status values", + domainName); + checkState( + leapSafeSubtractYears(domain.getRegistrationExpirationTime(), period).isAfter(now), + "Domain %s expires too soon", + domainName); + + DateTime newExpirationTime = + leapSafeSubtractYears(domain.getRegistrationExpirationTime(), period); + HistoryEntry historyEntry = + new HistoryEntry.Builder() + .setParent(domain) + .setModificationTime(now) + .setBySuperuser(true) + .setType(Type.SYNTHETIC) + .setClientId(domain.getCurrentSponsorClientId()) + .setReason("Domain unrenewal") + .setPeriod(Period.create(period, Unit.YEARS)) + .setRequestedByRegistrar(false) + .build(); + PollMessage oneTimePollMessage = + new PollMessage.OneTime.Builder() + .setClientId(domain.getCurrentSponsorClientId()) + .setMsg( + String.format( + "Domain %s was unrenewed by %d years; now expires at %s.", + domainName, period, newExpirationTime)) + .setParent(historyEntry) + .setEventTime(now) + .build(); + // Create a new autorenew billing event and poll message starting at the new expiration time. + BillingEvent.Recurring newAutorenewEvent = + newAutorenewBillingEvent(domain) + .setEventTime(newExpirationTime) + .setParent(historyEntry) + .build(); + PollMessage.Autorenew newAutorenewPollMessage = + newAutorenewPollMessage(domain) + .setEventTime(newExpirationTime) + .setParent(historyEntry) + .build(); + // End the old autorenew billing event and poll message now. + updateAutorenewRecurrenceEndTime(domain, now); + DomainResource newDomain = + domain + .asBuilder() + .setRegistrationExpirationTime(newExpirationTime) + .setLastEppUpdateTime(now) + .setLastEppUpdateClientId(domain.getCurrentSponsorClientId()) + .setAutorenewBillingEvent(Key.create(newAutorenewEvent)) + .setAutorenewPollMessage(Key.create(newAutorenewPollMessage)) + .build(); + // In order to do it'll need to write out a new HistoryEntry (likely of type SYNTHETIC), a new + // autorenew billing event and poll message, and a new one time poll message at the present time + // informing the registrar of this out-of-band change. + ofy() + .save() + .entities( + newDomain, + historyEntry, + oneTimePollMessage, + newAutorenewEvent, + newAutorenewPollMessage); + } +} diff --git a/java/google/registry/util/DateTimeUtils.java b/java/google/registry/util/DateTimeUtils.java index f98eeba7c..0aa0fa617 100644 --- a/java/google/registry/util/DateTimeUtils.java +++ b/java/google/registry/util/DateTimeUtils.java @@ -77,4 +77,13 @@ public class DateTimeUtils { checkArgument(years >= 0); return years == 0 ? now : now.plusYears(1).plusYears(years - 1); } + + /** + * Subtracts years from a date, in the {@code Duration} sense of semantic years. Use this instead + * of {@link DateTime#minusYears} to ensure that we never end up on February 29. + */ + public static DateTime leapSafeSubtractYears(DateTime now, int years) { + checkArgument(years >= 0); + return years == 0 ? now : now.minusYears(1).minusYears(years - 1); + } } diff --git a/javatests/google/registry/flows/EppLifecycleDomainTest.java b/javatests/google/registry/flows/EppLifecycleDomainTest.java index f553e35fd..a43c4f9a4 100644 --- a/javatests/google/registry/flows/EppLifecycleDomainTest.java +++ b/javatests/google/registry/flows/EppLifecycleDomainTest.java @@ -130,7 +130,7 @@ public class EppLifecycleDomainTest extends EppTestCase { domain, // Check the existence of the expected create one-time billing event. oneTimeCreateBillingEvent, - makeRecurringCreateBillingEvent(domain, createTime, deleteTime), + makeRecurringCreateBillingEvent(domain, createTime.plusYears(2), deleteTime), // Check for the existence of a cancellation for the given one-time billing event. makeCancellationBillingEventFor( domain, oneTimeCreateBillingEvent, createTime, deleteTime)); @@ -189,7 +189,7 @@ public class EppLifecycleDomainTest extends EppTestCase { assertBillingEventsForResource( domain, makeOneTimeCreateBillingEvent(domain, createTime), - makeRecurringCreateBillingEvent(domain, createTime, deleteTime)); + makeRecurringCreateBillingEvent(domain, createTime.plusYears(2), deleteTime)); assertThatLogoutSucceeds(); } @@ -248,7 +248,7 @@ public class EppLifecycleDomainTest extends EppTestCase { expectedOneTimeCreateBillingEvent, // ... and the expected one-time EAP fee billing event ... expectedCreateEapBillingEvent, - makeRecurringCreateBillingEvent(domain, createTime, deleteTime), + makeRecurringCreateBillingEvent(domain, createTime.plusYears(2), deleteTime), // ... and verify that the create one-time billing event was canceled ... makeCancellationBillingEventFor( domain, expectedOneTimeCreateBillingEvent, createTime, deleteTime)); diff --git a/javatests/google/registry/flows/EppTestCase.java b/javatests/google/registry/flows/EppTestCase.java index 0f1e604b2..cc78dad27 100644 --- a/javatests/google/registry/flows/EppTestCase.java +++ b/javatests/google/registry/flows/EppTestCase.java @@ -37,6 +37,7 @@ import google.registry.model.billing.BillingEvent.Reason; import google.registry.model.domain.DomainResource; import google.registry.model.ofy.Ofy; import google.registry.model.registry.Registry; +import google.registry.model.reporting.HistoryEntry; import google.registry.model.reporting.HistoryEntry.Type; import google.registry.monitoring.whitebox.EppMetric; import google.registry.testing.FakeClock; @@ -289,15 +290,22 @@ public class EppTestCase extends ShardableTestCase { /** Makes a recurring billing event corresponding to the given domain's creation. */ protected static BillingEvent.Recurring makeRecurringCreateBillingEvent( - DomainResource domain, DateTime createTime, DateTime endTime) { + DomainResource domain, DateTime eventTime, DateTime endTime) { + return makeRecurringCreateBillingEvent( + domain, getOnlyHistoryEntryOfType(domain, Type.DOMAIN_CREATE), eventTime, endTime); + } + + /** Makes a recurring billing event corresponding to the given history entry. */ + protected static BillingEvent.Recurring makeRecurringCreateBillingEvent( + DomainResource domain, HistoryEntry historyEntry, DateTime eventTime, DateTime endTime) { return new BillingEvent.Recurring.Builder() .setReason(Reason.RENEW) .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) .setTargetId(domain.getFullyQualifiedDomainName()) .setClientId(domain.getCurrentSponsorClientId()) - .setEventTime(createTime.plusYears(2)) + .setEventTime(eventTime) .setRecurrenceEndTime(endTime) - .setParent(getOnlyHistoryEntryOfType(domain, Type.DOMAIN_CREATE)) + .setParent(historyEntry) .build(); } diff --git a/javatests/google/registry/flows/testdata/domain_info_response_inactive.xml b/javatests/google/registry/flows/testdata/domain_info_response_inactive.xml new file mode 100644 index 000000000..1500fb96c --- /dev/null +++ b/javatests/google/registry/flows/testdata/domain_info_response_inactive.xml @@ -0,0 +1,31 @@ + + + + Command completed successfully + + + + %DOMAIN% + 8-TLD + + jd1234 + sh8013 + sh8013 + NewRegistrar + NewRegistrar + 2000-06-01T00:02:00Z + NewRegistrar + %UPDATE% + %EXDATE% + + 2fooBAR + + + + + ABC-12345 + server-trid + + + diff --git a/javatests/google/registry/flows/testdata/domain_info_response_inactive_grace_period.xml b/javatests/google/registry/flows/testdata/domain_info_response_inactive_grace_period.xml new file mode 100644 index 000000000..918626850 --- /dev/null +++ b/javatests/google/registry/flows/testdata/domain_info_response_inactive_grace_period.xml @@ -0,0 +1,36 @@ + + + + Command completed successfully + + + + %DOMAIN% + 8-TLD + + jd1234 + sh8013 + sh8013 + NewRegistrar + NewRegistrar + 2000-06-01T00:02:00Z + NewRegistrar + %UPDATE% + %EXDATE% + + 2fooBAR + + + + + + + + + + ABC-12345 + server-trid + + + diff --git a/javatests/google/registry/flows/testdata/domain_renew_response.xml b/javatests/google/registry/flows/testdata/domain_renew_response.xml new file mode 100644 index 000000000..9a498c1ee --- /dev/null +++ b/javatests/google/registry/flows/testdata/domain_renew_response.xml @@ -0,0 +1,18 @@ + + + + Command completed successfully + + + + %DOMAIN% + %EXDATE% + + + + ABC-12345 + server-trid + + + diff --git a/javatests/google/registry/flows/testdata/poll_response_unrenew.xml b/javatests/google/registry/flows/testdata/poll_response_unrenew.xml new file mode 100644 index 000000000..704fcc389 --- /dev/null +++ b/javatests/google/registry/flows/testdata/poll_response_unrenew.xml @@ -0,0 +1,15 @@ + + + + Command completed successfully; ack to dequeue + + + 2001-06-07T00:00:00Z + Domain example.tld was unrenewed by 3 years; now expires at 2003-06-01T00:02:00.000Z. + + + ABC-12345 + server-trid + + + diff --git a/javatests/google/registry/testing/DatastoreHelper.java b/javatests/google/registry/testing/DatastoreHelper.java index 56b3b52fc..af66dda89 100644 --- a/javatests/google/registry/testing/DatastoreHelper.java +++ b/javatests/google/registry/testing/DatastoreHelper.java @@ -560,7 +560,7 @@ public class DatastoreHelper { String domainName = String.format("%s.%s", label, tld); DomainResource domain = new DomainResource.Builder() - .setRepoId("1-".concat(Ascii.toUpperCase(tld))) + .setRepoId(generateNewDomainRoid(tld)) .setFullyQualifiedDomainName(domainName) .setPersistedCurrentSponsorClientId("TheRegistrar") .setCreationClientId("TheRegistrar") diff --git a/javatests/google/registry/testing/HistoryEntrySubject.java b/javatests/google/registry/testing/HistoryEntrySubject.java index 419cb23a6..06880b0d0 100644 --- a/javatests/google/registry/testing/HistoryEntrySubject.java +++ b/javatests/google/registry/testing/HistoryEntrySubject.java @@ -24,6 +24,7 @@ import google.registry.model.reporting.HistoryEntry; import google.registry.testing.TruthChainer.And; import java.util.Objects; import java.util.Optional; +import org.joda.time.DateTime; /** Utility methods for asserting things about {@link HistoryEntry} instances. */ public class HistoryEntrySubject extends Subject { @@ -56,6 +57,14 @@ public class HistoryEntrySubject extends Subject hasModificationTime(DateTime modificationTime) { + return hasValue(modificationTime, actual().getModificationTime(), "has modification time"); + } + + public And bySuperuser(boolean superuser) { + return hasValue(superuser, actual().getBySuperuser(), "has modification time"); + } + public And hasPeriod() { if (actual().getPeriod() == null) { fail("has a period"); diff --git a/javatests/google/registry/tools/BUILD b/javatests/google/registry/tools/BUILD index ccb01b431..07954200e 100644 --- a/javatests/google/registry/tools/BUILD +++ b/javatests/google/registry/tools/BUILD @@ -30,6 +30,7 @@ java_library( "//java/google/registry/tools/server", "//java/google/registry/util", "//java/google/registry/xml", + "//javatests/google/registry/flows", "//javatests/google/registry/rde", "//javatests/google/registry/testing", "//javatests/google/registry/tmch", diff --git a/javatests/google/registry/tools/CommandTestCase.java b/javatests/google/registry/tools/CommandTestCase.java index 80ca4fcaa..da5a59824 100644 --- a/javatests/google/registry/tools/CommandTestCase.java +++ b/javatests/google/registry/tools/CommandTestCase.java @@ -191,6 +191,10 @@ public abstract class CommandTestCase { assertThat(getStdoutAsString()).doesNotContain(expected); } + protected void assertNotInStderr(String expected) { + assertThat(getStderrAsString()).doesNotContain(expected); + } + protected String getStdoutAsString() { return new String(stdout.toByteArray(), UTF_8); } diff --git a/javatests/google/registry/tools/EppLifecycleToolsTest.java b/javatests/google/registry/tools/EppLifecycleToolsTest.java new file mode 100644 index 000000000..798d89b22 --- /dev/null +++ b/javatests/google/registry/tools/EppLifecycleToolsTest.java @@ -0,0 +1,193 @@ +// Copyright 2018 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.tools; + +import static google.registry.model.EppResourceUtils.loadByForeignKey; +import static google.registry.testing.DatastoreHelper.assertBillingEventsForResource; +import static google.registry.testing.DatastoreHelper.createTlds; +import static google.registry.testing.DatastoreHelper.getOnlyHistoryEntryOfType; +import static google.registry.util.DateTimeUtils.END_OF_TIME; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import google.registry.flows.EppTestCase; +import google.registry.model.billing.BillingEvent; +import google.registry.model.billing.BillingEvent.Reason; +import google.registry.model.domain.DomainResource; +import google.registry.model.reporting.HistoryEntry.Type; +import google.registry.testing.AppEngineRule; +import google.registry.util.Clock; +import java.util.List; +import org.joda.money.Money; +import org.joda.time.DateTime; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for tools that affect EPP lifecycle. */ +@RunWith(JUnit4.class) +public class EppLifecycleToolsTest extends EppTestCase { + + @Rule + public final AppEngineRule appEngine = + AppEngineRule.builder().withDatastore().withTaskQueue().build(); + + @Before + public void initTld() { + createTlds("example", "tld"); + } + + @Test + public void test_renewDomainThenUnrenew() throws Exception { + assertThatLoginSucceeds("NewRegistrar", "foo-BAR2"); + createContacts(DateTime.parse("2000-06-01T00:00:00Z")); + + // Create the domain for 2 years. + assertThatCommand( + "domain_create_no_hosts_or_dsdata.xml", ImmutableMap.of("DOMAIN", "example.tld")) + .atTime("2000-06-01T00:02:00Z") + .hasResponse( + "domain_create_response.xml", + ImmutableMap.of( + "DOMAIN", "example.tld", + "CRDATE", "2000-06-01T00:02:00Z", + "EXDATE", "2002-06-01T00:02:00Z")); + + // Explicitly renew it for 4 more years. + assertThatCommand( + "domain_renew.xml", + ImmutableMap.of("DOMAIN", "example.tld", "EXPDATE", "2002-06-01", "YEARS", "4")) + .atTime("2000-06-07T00:00:00Z") + .hasResponse( + "domain_renew_response.xml", + ImmutableMap.of("DOMAIN", "example.tld", "EXDATE", "2006-06-01T00:02:00Z")); + + // Run an info command and verify its registration term is 6 years in total. + assertThatCommand("domain_info.xml", ImmutableMap.of("DOMAIN", "example.tld")) + .atTime("2000-08-07T00:01:00Z") + .hasResponse( + "domain_info_response_inactive.xml", + ImmutableMap.of( + "DOMAIN", "example.tld", + "UPDATE", "2000-06-12T00:00:00Z", + "EXDATE", "2006-06-01T00:02:00Z")); + + assertThatCommand("poll.xml") + .atTime("2001-01-01T00:01:00Z") + .hasResponse("poll_response_empty.xml"); + + // Run the nomulus unrenew_domain command to take 3 years off the registration. + clock.setTo(DateTime.parse("2001-06-07T00:00:00.0Z")); + UnrenewDomainCommand unrenewCmd = + new ForcedUnrenewDomainCommand(ImmutableList.of("example.tld"), 3, clock); + unrenewCmd.run(); + + // Run an info command and verify that the registration term is now 3 years in total. + assertThatCommand("domain_info.xml", ImmutableMap.of("DOMAIN", "example.tld")) + .atTime("2001-06-07T00:01:00.0Z") + .hasResponse( + "domain_info_response_inactive.xml", + ImmutableMap.of( + "DOMAIN", "example.tld", + "UPDATE", "2001-06-07T00:00:00Z", + "EXDATE", "2003-06-01T00:02:00Z")); + + // Verify that the correct one-time poll message for the unrenew was sent. + assertThatCommand("poll.xml") + .atTime("2001-06-08T00:00:00Z") + .hasResponse("poll_response_unrenew.xml"); + + assertThatCommand("poll_ack.xml", ImmutableMap.of("ID", "1-8-TLD-17-18-2001")) + .atTime("2001-06-08T00:00:01Z") + .hasResponse("poll_ack_response_empty.xml"); + + // Run an info command after the 3 years to verify that the domain successfully autorenewed. + assertThatCommand("domain_info.xml", ImmutableMap.of("DOMAIN", "example.tld")) + .atTime("2003-06-02T00:00:00.0Z") + .hasResponse( + "domain_info_response_inactive_grace_period.xml", + ImmutableMap.of( + "DOMAIN", "example.tld", + "UPDATE", "2003-06-01T00:02:00Z", + "EXDATE", "2004-06-01T00:02:00Z", + "RGPSTATUS", "autoRenewPeriod")); + + // And verify that the autorenew poll message worked as well. + assertThatCommand("poll.xml") + .atTime("2003-06-02T00:01:00Z") + .hasResponse( + "poll_response_autorenew.xml", + ImmutableMap.of( + "ID", "1-8-TLD-17-20-2003", + "QDATE", "2003-06-01T00:02:00Z", + "DOMAIN", "example.tld", + "EXDATE", "2004-06-01T00:02:00Z")); + + // Assert about billing events. + DateTime createTime = DateTime.parse("2000-06-01T00:02:00Z"); + DomainResource domain = + loadByForeignKey( + DomainResource.class, "example.tld", DateTime.parse("2003-06-02T00:02:00Z")); + BillingEvent.OneTime renewBillingEvent = + new BillingEvent.OneTime.Builder() + .setReason(Reason.RENEW) + .setTargetId(domain.getFullyQualifiedDomainName()) + .setClientId(domain.getCurrentSponsorClientId()) + .setCost(Money.parse("USD 44.00")) + .setPeriodYears(4) + .setEventTime(DateTime.parse("2000-06-07T00:00:00Z")) + .setBillingTime(DateTime.parse("2000-06-12T00:00:00Z")) + .setParent(getOnlyHistoryEntryOfType(domain, Type.DOMAIN_RENEW)) + .build(); + + assertBillingEventsForResource( + domain, + makeOneTimeCreateBillingEvent(domain, createTime), + renewBillingEvent, + // The initial autorenew billing event, which was closed at the time of the explicit renew. + makeRecurringCreateBillingEvent( + domain, + getOnlyHistoryEntryOfType(domain, Type.DOMAIN_CREATE), + createTime.plusYears(2), + DateTime.parse("2000-06-07T00:00:00.000Z")), + // The renew's autorenew billing event, which was closed at the time of the unrenew. + makeRecurringCreateBillingEvent( + domain, + getOnlyHistoryEntryOfType(domain, Type.DOMAIN_RENEW), + DateTime.parse("2006-06-01T00:02:00.000Z"), + DateTime.parse("2001-06-07T00:00:00.000Z")), + // The remaining active autorenew billing event which was created by the unrenew. + makeRecurringCreateBillingEvent( + domain, + getOnlyHistoryEntryOfType(domain, Type.SYNTHETIC), + DateTime.parse("2003-06-01T00:02:00.000Z"), + END_OF_TIME)); + + assertThatLogoutSucceeds(); + } + + static class ForcedUnrenewDomainCommand extends UnrenewDomainCommand { + + ForcedUnrenewDomainCommand(List domainNames, int period, Clock clock) { + super(); + this.clock = clock; + this.force = true; + this.mainParameters = domainNames; + this.period = period; + } + } +} diff --git a/javatests/google/registry/tools/UnrenewDomainCommandTest.java b/javatests/google/registry/tools/UnrenewDomainCommandTest.java new file mode 100644 index 000000000..680eb3c75 --- /dev/null +++ b/javatests/google/registry/tools/UnrenewDomainCommandTest.java @@ -0,0 +1,217 @@ +// Copyright 2018 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.tools; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.model.EppResourceUtils.loadByForeignKey; +import static google.registry.model.eppcommon.StatusValue.PENDING_DELETE; +import static google.registry.model.eppcommon.StatusValue.PENDING_TRANSFER; +import static google.registry.model.ofy.ObjectifyService.ofy; +import static google.registry.model.reporting.HistoryEntry.Type.SYNTHETIC; +import static google.registry.testing.DatastoreHelper.assertBillingEventsEqual; +import static google.registry.testing.DatastoreHelper.assertPollMessagesEqual; +import static google.registry.testing.DatastoreHelper.createTld; +import static google.registry.testing.DatastoreHelper.getOnlyHistoryEntryOfType; +import static google.registry.testing.DatastoreHelper.newDomainResource; +import static google.registry.testing.DatastoreHelper.persistActiveContact; +import static google.registry.testing.DatastoreHelper.persistActiveDomain; +import static google.registry.testing.DatastoreHelper.persistDeletedDomain; +import static google.registry.testing.DatastoreHelper.persistDomainWithDependentResources; +import static google.registry.testing.DatastoreHelper.persistResource; +import static google.registry.testing.HistoryEntrySubject.assertAboutHistoryEntries; +import static google.registry.testing.JUnitBackports.assertThrows; + +import com.google.common.collect.ImmutableSet; +import com.googlecode.objectify.Key; +import google.registry.model.billing.BillingEvent; +import google.registry.model.billing.BillingEvent.Flag; +import google.registry.model.billing.BillingEvent.Reason; +import google.registry.model.contact.ContactResource; +import google.registry.model.domain.DomainResource; +import google.registry.model.eppcommon.StatusValue; +import google.registry.model.ofy.Ofy; +import google.registry.model.poll.PollMessage; +import google.registry.model.reporting.HistoryEntry; +import google.registry.testing.FakeClock; +import google.registry.testing.InjectRule; +import org.joda.time.DateTime; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** Unit tests for {@link UnrenewDomainCommand}. */ +public class UnrenewDomainCommandTest extends CommandTestCase { + + @Rule public final InjectRule inject = new InjectRule(); + + private final FakeClock clock = new FakeClock(DateTime.parse("2016-12-06T13:55:01Z")); + + @Before + public void before() { + createTld("tld"); + inject.setStaticField(Ofy.class, "clock", clock); + command.clock = clock; + } + + @Test + public void test_unrenewTwoDomains_worksSuccessfully() throws Exception { + ContactResource contact = persistActiveContact("jd1234"); + clock.advanceOneMilli(); + persistDomainWithDependentResources( + "foo", "tld", contact, clock.nowUtc(), clock.nowUtc(), clock.nowUtc().plusYears(5)); + clock.advanceOneMilli(); + persistDomainWithDependentResources( + "bar", "tld", contact, clock.nowUtc(), clock.nowUtc(), clock.nowUtc().plusYears(4)); + clock.advanceOneMilli(); + runCommandForced("-p", "2", "foo.tld", "bar.tld"); + clock.advanceOneMilli(); + assertThat( + loadByForeignKey(DomainResource.class, "foo.tld", clock.nowUtc()) + .getRegistrationExpirationTime()) + .isEqualTo(DateTime.parse("2019-12-06T13:55:01.001Z")); + assertThat( + loadByForeignKey(DomainResource.class, "bar.tld", clock.nowUtc()) + .getRegistrationExpirationTime()) + .isEqualTo(DateTime.parse("2018-12-06T13:55:01.002Z")); + assertInStdout("Successfully unrenewed all domains."); + } + + @Test + public void test_unrenewDomain_savesDependentEntitiesCorrectly() throws Exception { + ContactResource contact = persistActiveContact("jd1234"); + clock.advanceOneMilli(); + persistDomainWithDependentResources( + "foo", "tld", contact, clock.nowUtc(), clock.nowUtc(), clock.nowUtc().plusYears(5)); + DateTime newExpirationTime = clock.nowUtc().plusYears(3); + clock.advanceOneMilli(); + runCommandForced("-p", "2", "foo.tld"); + DateTime unrenewTime = clock.nowUtc(); + clock.advanceOneMilli(); + DomainResource domain = loadByForeignKey(DomainResource.class, "foo.tld", clock.nowUtc()); + + assertAboutHistoryEntries() + .that(getOnlyHistoryEntryOfType(domain, SYNTHETIC)) + .hasModificationTime(unrenewTime) + .and() + .hasMetadataReason("Domain unrenewal") + .and() + .hasPeriodYears(2) + .and() + .hasClientId("TheRegistrar") + .and() + .bySuperuser(true) + .and() + .hasMetadataRequestedByRegistrar(false); + HistoryEntry synthetic = getOnlyHistoryEntryOfType(domain, SYNTHETIC); + + assertBillingEventsEqual( + ofy().load().key(domain.getAutorenewBillingEvent()).now(), + new BillingEvent.Recurring.Builder() + .setParent(synthetic) + .setReason(Reason.RENEW) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setTargetId(domain.getFullyQualifiedDomainName()) + .setClientId("TheRegistrar") + .setEventTime(newExpirationTime) + .build()); + assertPollMessagesEqual( + ofy().load().type(PollMessage.class).ancestor(synthetic).list(), + ImmutableSet.of( + new PollMessage.OneTime.Builder() + .setParent(synthetic) + .setClientId("TheRegistrar") + .setMsg( + "Domain foo.tld was unrenewed by 2 years; " + + "now expires at 2019-12-06T13:55:01.001Z.") + .setEventTime(unrenewTime) + .build(), + new PollMessage.Autorenew.Builder() + .setParent(synthetic) + .setTargetId("foo.tld") + .setClientId("TheRegistrar") + .setEventTime(newExpirationTime) + .setMsg("Domain was auto-renewed.") + .build())); + + // Check that fields on domain were updated correctly. + assertThat(domain.getAutorenewPollMessage().getParent()).isEqualTo(Key.create(synthetic)); + assertThat(domain.getRegistrationExpirationTime()).isEqualTo(newExpirationTime); + assertThat(domain.getLastEppUpdateTime()).isEqualTo(unrenewTime); + assertThat(domain.getLastEppUpdateClientId()).isEqualTo("TheRegistrar"); + } + + @Test + public void test_periodTooLow_fails() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, () -> runCommandForced("--period", "0", "domain.tld")); + assertThat(thrown).hasMessageThat().isEqualTo("Period must be in the range 1-9"); + } + + @Test + public void test_periodTooHigh_fails() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, () -> runCommandForced("--period", "10", "domain.tld")); + assertThat(thrown).hasMessageThat().isEqualTo("Period must be in the range 1-9"); + } + + @Test + public void test_varietyOfInvalidDomains_displaysErrors() { + DateTime now = clock.nowUtc(); + persistResource( + newDomainResource("deleting.tld") + .asBuilder() + .setDeletionTime(now.plusHours(1)) + .setStatusValues(ImmutableSet.of(PENDING_DELETE)) + .build()); + persistDeletedDomain("deleted.tld", now.minusHours(1)); + persistResource( + newDomainResource("transferring.tld") + .asBuilder() + .setStatusValues(ImmutableSet.of(PENDING_TRANSFER)) + .build()); + persistResource( + newDomainResource("locked.tld") + .asBuilder() + .setStatusValues(ImmutableSet.of(StatusValue.SERVER_UPDATE_PROHIBITED)) + .build()); + persistActiveDomain("expiring.tld", now.minusDays(4), now.plusMonths(11)); + persistActiveDomain("valid.tld", now.minusDays(4), now.plusYears(3)); + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + runCommandForced( + "nonexistent.tld", + "deleting.tld", + "deleted.tld", + "transferring.tld", + "locked.tld", + "expiring.tld", + "valid.tld")); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("Aborting because some domains cannot be unrewed"); + assertInStderr( + "Found domains that cannot be unrenewed for the following reasons:", + "Domains that don't exist: [nonexistent.tld]", + "Domains that are deleted or pending delete: [deleting.tld, deleted.tld]", + "Domains with disallowed statuses: " + + "{transferring.tld=[PENDING_TRANSFER], locked.tld=[SERVER_UPDATE_PROHIBITED]}", + "Domains expiring too soon: {expiring.tld=2017-11-06T13:55:01.000Z}"); + assertNotInStderr("valid.tld"); + } +} diff --git a/javatests/google/registry/util/DateTimeUtilsTest.java b/javatests/google/registry/util/DateTimeUtilsTest.java index ae672b023..f4e41da7d 100644 --- a/javatests/google/registry/util/DateTimeUtilsTest.java +++ b/javatests/google/registry/util/DateTimeUtilsTest.java @@ -23,6 +23,7 @@ import static google.registry.util.DateTimeUtils.isAtOrAfter; import static google.registry.util.DateTimeUtils.isBeforeOrAt; import static google.registry.util.DateTimeUtils.latestOf; import static google.registry.util.DateTimeUtils.leapSafeAddYears; +import static google.registry.util.DateTimeUtils.leapSafeSubtractYears; import com.google.common.collect.ImmutableList; import org.joda.time.DateTime; @@ -70,6 +71,20 @@ public class DateTimeUtilsTest { assertThat(leapSafeAddYears(startDate, 4)).isEqualTo(DateTime.parse("2016-02-28T00:00:00Z")); } + @Test + public void testSuccess_leapSafeSubtractYears() { + DateTime startDate = DateTime.parse("2012-02-29T00:00:00Z"); + assertThat(startDate.minusYears(4)).isEqualTo(DateTime.parse("2008-02-29T00:00:00Z")); + assertThat(leapSafeSubtractYears(startDate, 4)) + .isEqualTo(DateTime.parse("2008-02-28T00:00:00Z")); + } + + @Test + public void testSuccess_leapSafeSubtractYears_zeroYears() { + DateTime leapDay = DateTime.parse("2012-02-29T00:00:00Z"); + assertThat(leapDay.minusYears(0)).isEqualTo(leapDay); + } + @Test public void testFailure_earliestOfEmpty() { assertThrows(IllegalArgumentException.class, () -> earliestOf(ImmutableList.of()));