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()));