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