1
0
mirror of https://github.com/google/nomulus synced 2026-07-08 00:56:53 +00:00

Compare commits

...

10 Commits

Author SHA1 Message Date
gbrodman 0ab612ab23 Forbid mismatched inner element in XML flow inputs (#3104) 2026-06-26 19:04:12 +00:00
gbrodman 403c7ad275 Add test enforcing host references in redemption domains (#3109)
Domains in the redemption grace period can still be restored, so hosts
referencing them cannot be deleted. Fortunately this is already the
case. This just adds a test.
2026-06-26 19:04:09 +00:00
gbrodman 11d625b837 Enforce additional pw reset permissions (#3098)
This should not have been an issue in practice due to defense in depth,
but we should check these just in case.
2026-06-25 20:45:58 +00:00
gbrodman 6a47287da7 Forbid non-routable IPs for host glue records (#3105)
Reject loopback, link-local, site-local, wildcard, and multicast IP
addresses during host creation and update flows.

Glue records (A/AAAA records published in the parent zone for subordinate
name servers) must point to globally routable, public IP addresses to
ensure that recursive DNS resolvers on the public internet can reach the
authoritative name servers.

Using non-public or non-routable IP addresses in glue records is invalid
for the following reasons:
- Loopback (127.0.0.1, ::1) and Any-Local (0.0.0.0, ::) addresses point
  back to the client or are unspecified, causing resolvers to query
  themselves and fail.
- Private/Site-Local (e.g., 10.0.0.0/8, 192.168.0.0/16) and Link-Local
  (169.254.0.0/16) addresses are not routable on the public internet,
  rendering the delegated domain completely unreachable to external clients.
- Multicast addresses are designed for one-to-many delivery and cannot
  be used for standard unicast DNS queries to a specific name server.

Rename LoopbackIpNotValidForHostException to IpAddressNotRoutableException
to reflect the broader set of forbidden non-routable IP addresses.
2026-06-25 18:06:33 +00:00
gbrodman cdc0ffe831 Use filename regex in CopyDetailReportsAction (#3102)
No registrars have an underscore in their name, but as far as I'm aware
there's nothing explicitly preventing that.
2026-06-25 16:58:52 +00:00
Ben McIlwain 7c23413d83 Fix Console API and Angular XSS security flaws (#3076)
This commit addresses the following security vulnerabilities identified in the recent audit of the Console App and Backend APIs:

1. Angular XSS: Removed unsafe [innerHTML] bindings across all console-webapp templates (Contact, Registrars, Registrar Details, Users List) in favor of standard Angular interpolation.
2. Broken Access Control (IDOR): PasswordResetRequestAction and PasswordResetVerifyAction now explicitly verify that the target user's email belongs to the authorized registrarId.
3. Missing Permission Check: ConsoleEppPasswordAction now explicitly checks for CONFIGURE_EPP_CONNECTION permission before updating the EPP password.
4. Denial of Service (DoS): ConsoleBulkDomainAction now strictly limits the size of bulk domain lists (configurable, default 500) to prevent thread exhaustion.
5. Denial of Service (OOM): ConsoleHistoryDataAction now uses .setMaxResults() (configurable, default 500) on JPA native queries to prevent eager loading of the entire database into memory.

Makes the history query limit and bulk domain action limit configurable via RegistryConfig, allowing smaller limits to be used in tests to avoid heavy resource persistence.

Also removes an outdated Joda-Time migration reference from GEMINI.md.
2026-06-24 20:39:42 +00:00
gbrodman fe222bbdcf Fix a couple edge cases with token pricing (#3103)
It's unlikely we'd run into this because our tokens are generally just
valid for one year, but we should fix these regardless
2026-06-24 15:55:01 +00:00
Ben McIlwain f770f6a46d Improve db/README.md with refactoring guide (#3096)
This commit improves the database documentation in db/README.md by adding comprehensive guidelines for refactoring column types and managing two-PR schema deployments.

Key additions:
- Added a section on the "Expand and Contract" pattern for refactoring column types, explaining when it is safe to drop columns immediately vs. when a three-step release process is required.
- Added a section on writing safe NOT NULL migrations for timed transition properties, explaining the "Temporary Database Default" pattern to maintain backward compatibility with running servers during Two-PR deployments, and demonstrating the required explicit PostgreSQL `::hstore` casting syntax.
- Added a step-by-step "Recommended Git Workflow" section to help developers cleanly split their database and Java changes into chained PRs using Git.

TAG=agy
CONV=88271e71-e272-40e0-85f8-a075a423b7c2
2026-06-23 21:32:22 +00:00
Ben McIlwain e071f5579c Implement database schema for scheduled XAP launch (#3095)
This commit implements the database schema changes for the Expiry Access Period (XAP) launch configuration on TLDs. It represents the first step of a Two-PR deployment strategy, deploying the database schema changes in advance of the server logic.

Specifically, it replaces the `expiry_access_period_enabled` boolean column (originally introduced in PR #2804) with a new `expiry_access_period_transitions` hstore column.

Why we are making this change:
A basic boolean flag only allows an immediate, manual on/off toggle. To launch XAP on a TLD, registry operators would have to manually flip the flag at the exact launch time, which is operationally fragile and cannot be planned in advance. Refactoring this to an hstore-backed timed transition map (mapping Instant to ExpiryAccessPeriodMode) allows operators to schedule the XAP launch in advance via TLD YAML configurations. The registry will automatically transition the TLD to the ENABLED mode at the scheduled timestamp, aligning with how other scheduled TLD changes (like TLD states and EAP fee schedules) are managed.

Since the original boolean column was never mapped in Java (PR #2804 only added the database column), it is completely safe to drop it immediately in this migration.

To ensure backward compatibility with running servers (which are still executing the old Java code during the deployment transition), the new column is added as `NOT NULL` with a temporary `DEFAULT` constraint. This prevents constraint violations on inserts from old servers. A TODO has been left in the SQL migration to drop this default in a subsequent schema release once the Java changes have been deployed.

TAG=agy
CONV=88271e71-e272-40e0-85f8-a075a423b7c2
2026-06-23 19:46:17 +00:00
Ben McIlwain 6080cd2f7a Fix flaky RdapDomainSearchActionTest (#3097)
Refactor RdapDomainSearchActionTest to dynamically resolve all domain
and host Repository IDs (ROIDs) instead of asserting on hardcoded,
sequence-generated strings (like "2E-LOL" or "6-LOL").

When tests are executed in parallel (as is common in CI environments like
Kokoro), multiple test threads concurrently reset and allocate from the
shared database sequence 'project_wide_unique_id_seq'. This interleaves
ID allocations non-deterministically, causing any test asserting on
exact, hardcoded sequence values to flake.

To fix this, createManyDomainsAndHosts was updated to return the list of
persisted domains, allowing tests to dynamically resolve their ROIDs.
All other test cases were refactored to dynamically fetch the ROIDs of
pre-created domains and hosts (stored in fields or in hostNameToHostMap,
using punycode keys for IDN hosts) for their JSON assertions, rendering
the entire suite robust against sequence shifts.
2026-06-23 16:30:04 +00:00
38 changed files with 1010 additions and 403 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ Do not wait for the user to tell you to improve the skills; it is your responsib
# Gemini Engineering Guide: Nomulus Codebase
This document captures high-level architectural patterns, lessons learned from large-scale refactorings (like the Joda-Time to `java.time` migration), and specific instructions to avoid common pitfalls in this environment.
This document captures high-level architectural patterns, lessons learned from large-scale refactorings, and specific instructions to avoid common pitfalls in this environment.
## 🏛 Architecture Overview
@@ -48,7 +48,11 @@ interface DomainData {
selector: 'app-response-dialog',
template: `
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content [innerHTML]="data.content" />
<mat-dialog-content>
@for (line of data.content; track line) {
<div>{{ line }}</div>
}
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button (click)="onClose()">Close</button>
</mat-dialog-actions>
@@ -59,7 +63,7 @@ export class ResponseDialogComponent {
constructor(
public dialogRef: MatDialogRef<ReasonDialogComponent>,
@Inject(MAT_DIALOG_DATA)
public data: { title: string; content: string }
public data: { title: string; content: string[] }
) {}
onClose(): void {
@@ -312,11 +316,13 @@ export class DomainListComponent {
this.dialog.open(ResponseDialogComponent, {
data: {
title: 'Domain Deletion Results',
content: `Successfully deleted - ${successCount} domain(s)<br/>Failed to delete - ${failureCount} domain(s)<br/>${
content: [
`Successfully deleted - ${successCount} domain(s)`,
`Failed to delete - ${failureCount} domain(s)`,
failureCount
? 'Some domains could not be deleted due to ongoing processes or server errors. '
: ''
}Please check the table for more information.`,
? 'Some domains could not be deleted due to ongoing processes or server errors. Please check the table for more information.'
: 'Please check the table for more information.',
],
},
});
this.selection.clear();
@@ -97,10 +97,9 @@
@for (column of columns; track column.columnDef) {
<mat-list-item role="listitem">
<span class="console-app__list-key">{{ column.header }} </span>
<span
class="console-app__list-value"
[innerHTML]="column.cell(registrarInEdit).replace('<br/>', ' ')"
></span>
<span class="console-app__list-value">{{
column.cell(registrarInEdit)
}}</span>
</mat-list-item>
<mat-divider></mat-divider>
}
@@ -49,10 +49,9 @@
<mat-header-cell *matHeaderCellDef>
{{ column.header }}
</mat-header-cell>
<mat-cell
*matCellDef="let row"
[innerHTML]="column.cell(row)"
></mat-cell>
<mat-cell *matCellDef="let row" style="white-space: pre-wrap">{{
column.cell(row)
}}</mat-cell>
</ng-container>
}
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
@@ -56,7 +56,7 @@ export const columns = [
cell: (record: Registrar) =>
`${Object.entries(record.billingAccountMap || {}).reduce(
(acc, [key, val]) => {
return `${acc}${key}=${val}<br/>`;
return `${acc}${key}=${val}\n`;
},
''
)}`,
@@ -25,7 +25,18 @@
@for (column of columns; track column) {
<ng-container [matColumnDef]="column.columnDef">
<mat-header-cell *matHeaderCellDef> {{ column.header }} </mat-header-cell>
<mat-cell *matCellDef="let row" [innerHTML]="column.cell(row)"></mat-cell>
<mat-cell *matCellDef="let row">
@if (column.columnDef === 'name') {
<div class="contact__name-column">
<div class="contact__name-column-title">{{ row.name }}</div>
<div class="contact__name-column-roles">
{{ row.userFriendlyTypes.join(" • ") }}
</div>
</div>
} @else {
{{ column.cell(row) }}
}
</mat-cell>
</ng-container>
}
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
@@ -34,14 +34,7 @@ export default class ContactComponent {
{
columnDef: 'name',
header: 'Name',
cell: (contact: ViewReadyContact) => `
<div class="contact__name-column">
<div class="contact__name-column-title">${contact.name}</div>
<div class="contact__name-column-roles">${contact.userFriendlyTypes.join(
' • '
)}</div>
</div>
`,
cell: (contact: ViewReadyContact) => `${contact.name}`,
},
{
columnDef: 'emailAddress',
@@ -10,7 +10,7 @@
<mat-header-cell *matHeaderCellDef>
{{ column.header }}
</mat-header-cell>
<mat-cell *matCellDef="let row" [innerHTML]="column.cell(row)"></mat-cell>
<mat-cell *matCellDef="let row">{{ column.cell(row) }}</mat-cell>
</ng-container>
}
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
@@ -173,6 +173,18 @@ public final class RegistryConfig {
return config.registrarConsole.supportEmailAddress;
}
@Provides
@Config("consoleHistoryQueryLimit")
public static int provideConsoleHistoryQueryLimit(RegistryConfigSettings config) {
return config.registrarConsole.historyQueryLimit;
}
@Provides
@Config("consoleBulkDomainActionLimit")
public static int provideConsoleBulkDomainActionLimit(RegistryConfigSettings config) {
return config.registrarConsole.bulkDomainActionLimit;
}
/**
* The DUM file name, used as a file name base for DUM csv file
*
@@ -181,6 +181,8 @@ public class RegistryConfigSettings {
public String supportPhoneNumber;
public String supportEmailAddress;
public String technicalDocsUrl;
public int historyQueryLimit;
public int bulkDomainActionLimit;
}
/** Configuration for monitoring. */
@@ -380,6 +380,12 @@ registrarConsole:
# URL linking to directory of technical support docs on the registry.
technicalDocsUrl: http://example.com/your_support_docs/
# Maximum number of history records returned in a single query.
historyQueryLimit: 500
# Maximum number of domains allowed in a single bulk action.
bulkDomainActionLimit: 500
monitoring:
# Max queries per second for the Google Cloud Monitoring V3 (aka Stackdriver)
# API. The limit can be adjusted by contacting Cloud Support.
@@ -283,7 +283,7 @@ public final class DomainPricingLogic {
return tld.getStandardRenewCost(dateTime).multipliedBy(years);
}
if (token.getRenewalPriceBehavior().equals(RenewalPriceBehavior.SPECIFIED)) {
return token.getRenewalPrice().get();
return token.getRenewalPrice().get().multipliedBy(years);
}
}
return getDomainCostWithDiscount(
@@ -328,12 +328,13 @@ public final class DomainPricingLogic {
// Apply the allocation token discount, if applicable.
if (token.getDiscountPrice().isPresent()
&& tld.getCurrency().equals(token.getDiscountPrice().get().getCurrencyUnit())) {
int nonDiscountedYears = Math.max(0, years - token.getDiscountYears());
int discountedYears = Math.min(years, token.getDiscountYears());
int nonDiscountedYears = years - discountedYears;
totalDomainFlowCost =
token
.getDiscountPrice()
.get()
.multipliedBy(token.getDiscountYears())
.multipliedBy(discountedYears)
.plus(subsequentYearCost.orElse(firstYearCost).multipliedBy(nonDiscountedYears));
} else if (token.getDiscountFraction() > 0) {
int discountedYears = Math.min(years, token.getDiscountYears());
@@ -116,15 +116,21 @@ public class HostFlowUtils {
if (inetAddresses == null) {
return;
}
if (inetAddresses.stream().anyMatch(InetAddress::isLoopbackAddress)) {
throw new LoopbackIpNotValidForHostException();
for (InetAddress inetAddress : inetAddresses) {
if (inetAddress.isLoopbackAddress()
|| inetAddress.isLinkLocalAddress()
|| inetAddress.isSiteLocalAddress()
|| inetAddress.isAnyLocalAddress()
|| inetAddress.isMulticastAddress()) {
throw new IpAddressNotRoutableException(inetAddress.getHostAddress());
}
}
}
/** Loopback IPs are not valid for hosts. */
static class LoopbackIpNotValidForHostException extends ParameterValuePolicyErrorException {
public LoopbackIpNotValidForHostException() {
super("Loopback IPs are not valid for hosts");
/** IP address is not a public, routable address. */
static class IpAddressNotRoutableException extends ParameterValuePolicyErrorException {
public IpAddressNotRoutableException(String ipAddress) {
super(String.format("IP address %s is not a public, globally routable address", ipAddress));
}
}
@@ -14,6 +14,7 @@
package google.registry.flows.picker;
import com.google.common.base.Ascii;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableTable;
@@ -255,6 +256,17 @@ public class FlowPicker {
if (innerCommand == null && !(eppInput.getCommandWrapper() instanceof Hello)) {
throw new MissingCommandException();
}
if (innerCommand instanceof ResourceCommandWrapper resourceCommandWrapper) {
ResourceCommand resourceCommand = resourceCommandWrapper.getResourceCommand();
if (resourceCommand != null) {
String wrapperName = innerCommand.getClass().getSimpleName();
String commandName = resourceCommand.getClass().getSimpleName();
if (!wrapperName.equals(commandName)) {
throw new MismatchedCommandException(
Ascii.toLowerCase(wrapperName), Ascii.toLowerCase(commandName));
}
}
}
// Try the FlowProviders until we find a match. The order matters because it's possible to
// match multiple FlowProviders and so more specific matches are tried first.
for (FlowProvider flowProvider : FLOW_PROVIDERS) {
@@ -279,4 +291,14 @@ public class FlowPicker {
super("Command missing");
}
}
/** Command wrapper and inner resource command do not match. */
static class MismatchedCommandException extends SyntaxErrorException {
public MismatchedCommandException(String wrapperName, String commandName) {
super(
String.format(
"EPP command wrapper <%s> does not match resource command <%s>",
wrapperName, commandName));
}
}
}
@@ -21,10 +21,8 @@ import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static java.util.stream.Collectors.joining;
import com.google.cloud.storage.BlobId;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.ByteStreams;
import com.google.common.net.MediaType;
@@ -41,6 +39,8 @@ import jakarta.inject.Inject;
import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Copy all registrar detail reports in a given bucket's subdirectory from GCS to Drive. */
@Action(
@@ -54,6 +54,9 @@ public final class CopyDetailReportsAction implements Runnable {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final Pattern FILENAME_PATTERN =
Pattern.compile("^invoice_details_[0-9]{4}-[0-9]{2}_(.+)_.+\\.csv$");
private final String billingBucket;
private final String invoiceDirectoryPrefix;
private final DriveConnection driveConnection;
@@ -101,8 +104,13 @@ public final class CopyDetailReportsAction implements Runnable {
new ImmutableMultimap.Builder<>();
for (String detailReportName : detailReportObjectNames) {
// The standard report format is "invoice_details_yyyy-MM_registrarId_tld.csv
// TODO(larryruili): Determine a safer way of enforcing this.
String registrarId = Iterables.get(Splitter.on('_').split(detailReportName), 3);
Matcher matcher = FILENAME_PATTERN.matcher(detailReportName);
if (!matcher.matches()) {
logger.atWarning().log(
"Detail report filename '%s' does not match the expected pattern.", detailReportName);
continue;
}
String registrarId = matcher.group(1);
Optional<Registrar> registrar = Registrar.loadByRegistrarId(registrarId);
if (registrar.isEmpty()) {
logger.atWarning().log(
@@ -105,7 +105,8 @@ public abstract class ConsoleApiAction implements Runnable {
}
}
protected void checkPermission(User user, String registrarId, ConsolePermission permission) {
protected static void checkPermission(
User user, String registrarId, ConsolePermission permission) {
if (!user.getUserRoles().hasPermission(registrarId, permission)) {
throw new ConsolePermissionForbiddenException(
String.format(
@@ -27,6 +27,7 @@ import com.google.common.collect.ImmutableSet;
import com.google.gson.annotations.Expose;
import google.registry.flows.EppException.AuthenticationErrorException;
import google.registry.flows.PasswordOnlyTransportCredentials;
import google.registry.model.console.ConsolePermission;
import google.registry.model.console.ConsoleUpdateHistory;
import google.registry.model.console.User;
import google.registry.model.registrar.Registrar;
@@ -84,6 +85,8 @@ public class ConsoleEppPasswordAction extends ConsoleApiAction {
eppRequestBody.newPassword().equals(eppRequestBody.newPasswordRepeat()),
"New password fields don't match");
checkPermission(user, eppRequestBody.registrarId(), ConsolePermission.CONFIGURE_EPP_CONNECTION);
Registrar registrar;
try {
registrar = registrarAccessor.getRegistrar(eppRequestBody.registrarId());
@@ -62,17 +62,20 @@ public class ConsoleHistoryDataAction extends ConsoleApiAction {
private final Optional<String> consoleUserEmail;
private final String supportEmail;
private final int historyQueryLimit;
@Inject
public ConsoleHistoryDataAction(
ConsoleApiParams consoleApiParams,
@Config("supportEmail") String supportEmail,
@Config("consoleHistoryQueryLimit") int historyQueryLimit,
@Parameter("registrarId") String registrarId,
@Parameter("consoleUserEmail") Optional<String> consoleUserEmail) {
super(consoleApiParams);
this.registrarId = registrarId;
this.consoleUserEmail = consoleUserEmail;
this.supportEmail = supportEmail;
this.historyQueryLimit = historyQueryLimit;
}
@Override
@@ -117,6 +120,7 @@ public class ConsoleHistoryDataAction extends ConsoleApiAction {
.createNativeQuery(SQL_REGISTRAR_HISTORY, ConsoleUpdateHistory.class)
.setParameter("registrarId", registrarId)
.setHint("org.hibernate.fetchSize", 1000)
.setMaxResults(historyQueryLimit)
.getResultList());
List<ConsoleUpdateHistory> formattedHistoryList =
@@ -86,7 +86,7 @@ public class PasswordResetRequestAction extends ConsoleApiAction {
"Must provide registry lock email to reset");
requiredPermission = ConsolePermission.MANAGE_USERS;
destinationEmail = passwordResetRequestData.registryLockEmail;
checkUserExistsWithRegistryLockEmail(destinationEmail);
checkUserExistsWithRegistryLockEmail(destinationEmail, registrarId);
emailSubject = "Registry lock password reset request";
}
default -> throw new IllegalArgumentException("Unknown type " + type);
@@ -121,12 +121,23 @@ public class PasswordResetRequestAction extends ConsoleApiAction {
.sendEmail(EmailMessage.create(emailSubject, body, destinationAddress));
}
static User checkUserExistsWithRegistryLockEmail(String destinationEmail) {
return tm().createQueryComposer(User.class)
.where("registryLockEmailAddress", QueryComposer.Comparator.EQ, destinationEmail)
.first()
.orElseThrow(
() -> new IllegalArgumentException("Unknown user with lock email " + destinationEmail));
static User checkUserExistsWithRegistryLockEmail(String destinationEmail, String registrarId) {
User targetUser =
tm().createQueryComposer(User.class)
.where("registryLockEmailAddress", QueryComposer.Comparator.EQ, destinationEmail)
.first()
.orElseThrow(
() ->
new IllegalArgumentException(
"Unknown user with lock email " + destinationEmail));
// Prevent IDOR: Ensure the resolved user has the right permission
checkArgument(
targetUser.getUserRoles().hasPermission(registrarId, ConsolePermission.REGISTRY_LOCK),
"User %s does not have permission REGISTRY_LOCK on registrar %s",
targetUser.getEmailAddress(),
registrarId);
return targetUser;
}
private String getAdminPocEmail(String registrarId) {
@@ -98,7 +98,9 @@ public class PasswordResetVerifyAction extends ConsoleApiAction {
}
private void handleRegistryLockPasswordReset(PasswordResetRequest request) {
User affectedUser = checkUserExistsWithRegistryLockEmail(request.getDestinationEmail());
User affectedUser =
checkUserExistsWithRegistryLockEmail(
request.getDestinationEmail(), request.getRegistrarId());
tm().put(
affectedUser
.asBuilder()
@@ -119,7 +121,15 @@ public class PasswordResetVerifyAction extends ConsoleApiAction {
ConsolePermission requiredVerifyPermission =
switch (request.getType()) {
case EPP -> ConsolePermission.MANAGE_USERS;
case REGISTRY_LOCK -> ConsolePermission.REGISTRY_LOCK;
case REGISTRY_LOCK -> {
checkArgument(
user.getRegistryLockEmailAddress()
.map(address -> address.equals(request.getDestinationEmail()))
.orElse(false),
"User %s has the wrong registry lock email address",
user.getEmailAddress());
yield ConsolePermission.REGISTRY_LOCK;
}
};
checkPermission(user, request.getRegistrarId(), requiredVerifyPermission);
if (plusHours(request.getRequestTime(), 1).isBefore(tm().getTxTime())) {
@@ -14,6 +14,7 @@
package google.registry.ui.server.console.domains;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
@@ -22,6 +23,7 @@ import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.collect.ImmutableMap;
import com.google.gson.JsonElement;
import com.google.gson.annotations.Expose;
import google.registry.config.RegistryConfig.Config;
import google.registry.flows.EppController;
import google.registry.flows.EppRequestSource;
import google.registry.flows.PasswordOnlyTransportCredentials;
@@ -64,11 +66,13 @@ public class ConsoleBulkDomainAction extends ConsoleApiAction {
private final String registrarId;
private final String bulkDomainAction;
private final Optional<JsonElement> optionalJsonPayload;
private final int bulkDomainActionLimit;
@Inject
public ConsoleBulkDomainAction(
ConsoleApiParams consoleApiParams,
EppController eppController,
@Config("consoleBulkDomainActionLimit") int bulkDomainActionLimit,
@Parameter("registrarId") String registrarId,
@Parameter("bulkDomainAction") String bulkDomainAction,
@OptionalJsonPayload Optional<JsonElement> optionalJsonPayload) {
@@ -77,6 +81,7 @@ public class ConsoleBulkDomainAction extends ConsoleApiAction {
this.registrarId = registrarId;
this.bulkDomainAction = bulkDomainAction;
this.optionalJsonPayload = optionalJsonPayload;
this.bulkDomainActionLimit = bulkDomainActionLimit;
}
@Override
@@ -85,6 +90,13 @@ public class ConsoleBulkDomainAction extends ConsoleApiAction {
optionalJsonPayload.orElseThrow(
() -> new IllegalArgumentException("Bulk action payload must be present"));
BulkDomainList domainList = consoleApiParams.gson().fromJson(jsonPayload, BulkDomainList.class);
checkArgument(
domainList.domainList != null && !domainList.domainList.isEmpty(),
"Domain list cannot be empty");
checkArgument(
domainList.domainList.size() <= bulkDomainActionLimit,
"Cannot process more than %s domains in a single bulk action",
bulkDomainActionLimit);
ConsoleDomainActionType actionType =
ConsoleDomainActionType.parseActionType(bulkDomainAction, jsonPayload);
@@ -203,6 +203,29 @@ public class DomainPricingLogicTest {
.build());
}
@Test
void testGetDomainCreatePrice_discountPriceAllocationToken_oneYearCreate_moreDiscountYears()
throws EppException {
AllocationToken allocationToken =
persistResource(
new AllocationToken.Builder()
.setToken("abc123_more_discount")
.setTokenType(SINGLE_USE)
.setDomainName("default.example")
.setDiscountPrice(Money.of(USD, 5))
.setDiscountYears(2)
.setRegistrationBehavior(RegistrationBehavior.DEFAULT)
.build());
assertThat(
domainPricingLogic.getCreatePrice(
tld, "default.example", clock.now(), 1, false, false, Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
.addFeeOrCredit(Fee.create(new BigDecimal("5.00"), CREATE, false))
.build());
}
@Test
void testGetDomainRenewPrice_oneYear_standardDomain_noBilling_isStandardPrice()
throws EppException {
@@ -1093,4 +1116,23 @@ public class DomainPricingLogicTest {
.getRenewCost())
.isEqualTo(Money.of(USD, 5));
}
@Test
void testDomainRenewPrice_specifiedToken_multiYear() throws Exception {
AllocationToken allocationToken =
persistResource(
new AllocationToken.Builder()
.setToken("abc123_multi")
.setTokenType(SINGLE_USE)
.setDomainName("premium.example")
.setRenewalPriceBehavior(SPECIFIED)
.setRenewalPrice(Money.of(USD, 5))
.build());
assertThat(
domainPricingLogic
.getRenewPrice(
tld, "premium.example", clock.now(), 5, null, Optional.of(allocationToken))
.getRenewCost())
.isEqualTo(Money.of(USD, 25));
}
}
@@ -48,7 +48,7 @@ import google.registry.flows.host.HostFlowUtils.HostNameNotPunyCodedException;
import google.registry.flows.host.HostFlowUtils.HostNameTooLongException;
import google.registry.flows.host.HostFlowUtils.HostNameTooShallowException;
import google.registry.flows.host.HostFlowUtils.InvalidHostNameException;
import google.registry.flows.host.HostFlowUtils.LoopbackIpNotValidForHostException;
import google.registry.flows.host.HostFlowUtils.IpAddressNotRoutableException;
import google.registry.flows.host.HostFlowUtils.SuperordinateDomainDoesNotExistException;
import google.registry.flows.host.HostFlowUtils.SuperordinateDomainInPendingDeleteException;
import google.registry.model.ForeignKeyUtils;
@@ -354,22 +354,62 @@ class HostCreateFlowTest extends ResourceFlowTestCase<HostCreateFlow, Host> {
}
@Test
void testFailure_localhostInetAddress_ipv4() {
void testFailure_loopbackInetAddress_ipv4() {
createTld("tld");
persistActiveDomain("example.tld");
setEppHostCreateInput("ns1.example.tld", "<host:addr ip=\"v4\">127.0.0.1</host:addr>");
assertAboutEppExceptions()
.that(assertThrows(LoopbackIpNotValidForHostException.class, this::runFlow))
.that(assertThrows(IpAddressNotRoutableException.class, this::runFlow))
.marshalsToXml();
}
@Test
void testFailure_localhostInetAddress_ipv6() {
void testFailure_loopbackInetAddress_ipv6() {
createTld("tld");
persistActiveDomain("example.tld");
setEppHostCreateInput("ns1.example.tld", "<host:addr ip=\"v6\">::1</host:addr>");
assertAboutEppExceptions()
.that(assertThrows(LoopbackIpNotValidForHostException.class, this::runFlow))
.that(assertThrows(IpAddressNotRoutableException.class, this::runFlow))
.marshalsToXml();
}
@Test
void testFailure_linkLocalInetAddress_ipv4() {
createTld("tld");
persistActiveDomain("example.tld");
setEppHostCreateInput("ns1.example.tld", "<host:addr ip=\"v4\">169.254.1.1</host:addr>");
assertAboutEppExceptions()
.that(assertThrows(IpAddressNotRoutableException.class, this::runFlow))
.marshalsToXml();
}
@Test
void testFailure_linkLocalInetAddress_ipv6() {
createTld("tld");
persistActiveDomain("example.tld");
setEppHostCreateInput("ns1.example.tld", "<host:addr ip=\"v6\">fe80::1</host:addr>");
assertAboutEppExceptions()
.that(assertThrows(IpAddressNotRoutableException.class, this::runFlow))
.marshalsToXml();
}
@Test
void testFailure_privateInetAddress_ipv4() {
createTld("tld");
persistActiveDomain("example.tld");
setEppHostCreateInput("ns1.example.tld", "<host:addr ip=\"v4\">192.168.1.1</host:addr>");
assertAboutEppExceptions()
.that(assertThrows(IpAddressNotRoutableException.class, this::runFlow))
.marshalsToXml();
}
@Test
void testFailure_anyLocalInetAddress_ipv4() {
createTld("tld");
persistActiveDomain("example.tld");
setEppHostCreateInput("ns1.example.tld", "<host:addr ip=\"v4\">0.0.0.0</host:addr>");
assertAboutEppExceptions()
.that(assertThrows(IpAddressNotRoutableException.class, this::runFlow))
.marshalsToXml();
}
@@ -27,6 +27,7 @@ import static google.registry.testing.DatabaseHelper.persistResource;
import static google.registry.testing.EppExceptionSubject.assertAboutEppExceptions;
import static google.registry.testing.HostSubject.assertAboutHosts;
import static google.registry.util.DateTimeUtils.minusDays;
import static google.registry.util.DateTimeUtils.plusDays;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.common.collect.ImmutableMap;
@@ -42,6 +43,8 @@ import google.registry.flows.host.HostFlowUtils.HostNameNotLowerCaseException;
import google.registry.flows.host.HostFlowUtils.HostNameNotNormalizedException;
import google.registry.flows.host.HostFlowUtils.HostNameNotPunyCodedException;
import google.registry.model.domain.Domain;
import google.registry.model.domain.GracePeriod;
import google.registry.model.domain.rgp.GracePeriodStatus;
import google.registry.model.eppcommon.StatusValue;
import google.registry.model.host.Host;
import google.registry.model.reporting.HistoryEntry.Type;
@@ -311,6 +314,30 @@ class HostDeleteFlowTest extends ResourceFlowTestCase<HostDeleteFlow, Host> {
assertAboutEppExceptions().that(thrown).marshalsToXml();
}
@Test
void testFailure_failfastWhenLinkedToDomainInRedemption() throws Exception {
createTld("tld");
Host host = persistActiveHost("ns1.example.tld");
Domain domain = persistResource(DatabaseHelper.newDomain("example.tld"));
persistResource(
domain
.asBuilder()
.setNameservers(ImmutableSet.of(host.createVKey()))
.setDeletionTime(plusDays(clock.now(), 35))
.addGracePeriod(
GracePeriod.create(
GracePeriodStatus.REDEMPTION,
domain.getRepoId(),
plusDays(clock.now(), 1),
"TheRegistrar",
null))
.setStatusValues(ImmutableSet.of(StatusValue.PENDING_DELETE))
.build());
EppException thrown = assertThrows(ResourceToDeleteIsReferencedException.class, this::runFlow);
assertAboutEppExceptions().that(thrown).marshalsToXml();
}
@Test
void testFailure_nonLowerCaseHostname() {
setEppInput("host_delete.xml", ImmutableMap.of("HOSTNAME", "NS1.EXAMPLE.NET"));
@@ -65,7 +65,7 @@ import google.registry.flows.host.HostFlowUtils.HostNameNotNormalizedException;
import google.registry.flows.host.HostFlowUtils.HostNameNotPunyCodedException;
import google.registry.flows.host.HostFlowUtils.HostNameTooLongException;
import google.registry.flows.host.HostFlowUtils.HostNameTooShallowException;
import google.registry.flows.host.HostFlowUtils.LoopbackIpNotValidForHostException;
import google.registry.flows.host.HostFlowUtils.IpAddressNotRoutableException;
import google.registry.flows.host.HostFlowUtils.SuperordinateDomainDoesNotExistException;
import google.registry.flows.host.HostFlowUtils.SuperordinateDomainInPendingDeleteException;
import google.registry.flows.host.HostUpdateFlow.CannotAddIpToExternalHostException;
@@ -1391,24 +1391,68 @@ class HostUpdateFlowTest extends ResourceFlowTestCase<HostUpdateFlow, Host> {
}
@Test
void testFailure_localhostInetAddress_ipv4() throws Exception {
void testFailure_loopbackInetAddress_ipv4() throws Exception {
createTld("tld");
persistActiveSubordinateHost(oldHostName(), persistActiveDomain("example.tld"));
setEppHostUpdateInput(
"ns1.example.tld", "ns2.example.tld", "<host:addr ip=\"v4\">127.0.0.1</host:addr>", null);
assertAboutEppExceptions()
.that(assertThrows(LoopbackIpNotValidForHostException.class, this::runFlow))
.that(assertThrows(IpAddressNotRoutableException.class, this::runFlow))
.marshalsToXml();
}
@Test
void testFailure_localhostInetAddress_ipv6() throws Exception {
void testFailure_loopbackInetAddress_ipv6() throws Exception {
createTld("tld");
persistActiveSubordinateHost(oldHostName(), persistActiveDomain("example.tld"));
setEppHostUpdateInput(
"ns1.example.tld", "ns2.example.tld", "<host:addr ip=\"v6\">::1</host:addr>", null);
assertAboutEppExceptions()
.that(assertThrows(LoopbackIpNotValidForHostException.class, this::runFlow))
.that(assertThrows(IpAddressNotRoutableException.class, this::runFlow))
.marshalsToXml();
}
@Test
void testFailure_linkLocalInetAddress_ipv4() throws Exception {
createTld("tld");
persistActiveSubordinateHost(oldHostName(), persistActiveDomain("example.tld"));
setEppHostUpdateInput(
"ns1.example.tld", "ns2.example.tld", "<host:addr ip=\"v4\">169.254.1.1</host:addr>", null);
assertAboutEppExceptions()
.that(assertThrows(IpAddressNotRoutableException.class, this::runFlow))
.marshalsToXml();
}
@Test
void testFailure_linkLocalInetAddress_ipv6() throws Exception {
createTld("tld");
persistActiveSubordinateHost(oldHostName(), persistActiveDomain("example.tld"));
setEppHostUpdateInput(
"ns1.example.tld", "ns2.example.tld", "<host:addr ip=\"v6\">fe80::1</host:addr>", null);
assertAboutEppExceptions()
.that(assertThrows(IpAddressNotRoutableException.class, this::runFlow))
.marshalsToXml();
}
@Test
void testFailure_privateInetAddress_ipv4() throws Exception {
createTld("tld");
persistActiveSubordinateHost(oldHostName(), persistActiveDomain("example.tld"));
setEppHostUpdateInput(
"ns1.example.tld", "ns2.example.tld", "<host:addr ip=\"v4\">192.168.1.1</host:addr>", null);
assertAboutEppExceptions()
.that(assertThrows(IpAddressNotRoutableException.class, this::runFlow))
.marshalsToXml();
}
@Test
void testFailure_anyLocalInetAddress_ipv4() throws Exception {
createTld("tld");
persistActiveSubordinateHost(oldHostName(), persistActiveDomain("example.tld"));
setEppHostUpdateInput(
"ns1.example.tld", "ns2.example.tld", "<host:addr ip=\"v4\">0.0.0.0</host:addr>", null);
assertAboutEppExceptions()
.that(assertThrows(IpAddressNotRoutableException.class, this::runFlow))
.marshalsToXml();
}
@@ -0,0 +1,96 @@
// Copyright 2026 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.flows.picker;
import static com.google.common.truth.Truth.assertThat;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertThrows;
import google.registry.flows.Flow;
import google.registry.flows.domain.DomainCheckFlow;
import google.registry.flows.domain.DomainCreateFlow;
import google.registry.model.domain.DomainCommand;
import google.registry.model.eppcommon.EppXmlTransformer;
import google.registry.model.eppinput.EppInput;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link FlowPicker}. */
class FlowPickerTest {
@Test
void testGetFlowClass_matchingWrapperAndCommand_returnsFlow() throws Exception {
EppInput checkEppInput = EppInput.create(EppInput.Check.create(new DomainCommand.Check()));
Class<? extends Flow> checkFlow = FlowPicker.getFlowClass(checkEppInput);
assertThat(checkFlow).isEqualTo(DomainCheckFlow.class);
EppInput createEppInput = EppInput.create(EppInput.Create.create(new DomainCommand.Create()));
Class<? extends Flow> createFlow = FlowPicker.getFlowClass(createEppInput);
assertThat(createFlow).isEqualTo(DomainCreateFlow.class);
}
@Test
void testGetFlowClass_mismatchedWrapperAndCommand_throwsSyntaxErrorException() {
EppInput mismatchedEppInput1 =
EppInput.create(EppInput.Check.create(new DomainCommand.Create()));
assertThat(
assertThrows(
FlowPicker.MismatchedCommandException.class,
() -> FlowPicker.getFlowClass(mismatchedEppInput1)))
.hasMessageThat()
.isEqualTo("EPP command wrapper <check> does not match resource command <create>");
EppInput mismatchedEppInput2 =
EppInput.create(EppInput.Create.create(new DomainCommand.Check()));
assertThat(
assertThrows(
FlowPicker.MismatchedCommandException.class,
() -> FlowPicker.getFlowClass(mismatchedEppInput2)))
.hasMessageThat()
.contains("EPP command wrapper <create> does not match resource command <check>");
}
@Test
void testGetFlowClass_mismatchedXml_throwsMismatchedCommandException() throws Exception {
String mismatchedXml =
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
<command>
<check>
<domain:create xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
<domain:name>example.com</domain:name>
<domain:authInfo>
<domain:pw>fooBAR123</domain:pw>
</domain:authInfo>
</domain:create>
</check>
<clTRID>ABC-12345</clTRID>
</command>
</epp>
""";
// Verify JAXB successfully unmarshals the mismatched XML without throwing any errors
EppInput eppInput = EppXmlTransformer.unmarshal(EppInput.class, mismatchedXml.getBytes(UTF_8));
assertThat(eppInput).isNotNull();
// Verify that FlowPicker intercepts the unmarshalled input and blocks it
assertThat(
assertThrows(
FlowPicker.MismatchedCommandException.class,
() -> FlowPicker.getFlowClass(eppInput)))
.hasMessageThat()
.contains("EPP command wrapper <check> does not match resource command <create>");
}
}
@@ -283,15 +283,15 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
private JsonObject generateExpectedJsonForTwoDomainsNsReply() {
return jsonFileBuilder()
.addDomain("cat.example", "F-EXAMPLE")
.addDomain("cat.lol", "6-LOL")
.addDomain("cat.example", domainCatExample.getRepoId())
.addDomain("cat.lol", domainCatLol.getRepoId())
.load("rdap_domains_two.json");
}
private JsonObject generateExpectedJsonForTwoDomainsCatStarReplySql() {
return jsonFileBuilder()
.addDomain("cat.lol", "6-LOL")
.addDomain("cat2.lol", "B-LOL")
.addDomain("cat.lol", domainCatLol.getRepoId())
.addDomain("cat2.lol", domainCatLol2.getRepoId())
.load("rdap_domains_two.json");
}
@@ -306,7 +306,7 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
minusMonths(clock.now(), 6)));
}
private void createManyDomainsAndHosts(
private ImmutableList<Domain> createManyDomainsAndHosts(
int numActiveDomains, int numTotalDomainsPerActiveDomain, int numHosts) {
ImmutableSet.Builder<VKey<Host>> hostKeysBuilder = new ImmutableSet.Builder<>();
ImmutableSet.Builder<String> subordinateHostnamesBuilder = new ImmutableSet.Builder<>();
@@ -340,7 +340,7 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
}
domainsBuilder.add(builder.build());
}
persistResources(domainsBuilder.build());
return persistResources(domainsBuilder.build());
}
private void checkNumberOfDomainsInResult(JsonObject obj, int expected) {
@@ -353,10 +353,10 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
requestType,
queryString,
jsonFileBuilder()
.addDomain("cat.lol", "6-LOL")
.addDomain("cat.lol", domainCatLol.getRepoId())
.addRegistrar("Yes Virginia <script>")
.addNameserver("ns1.cat.lol", "2-ROID")
.addNameserver("ns2.cat.lol", "4-ROID")
.addNameserver("ns1.cat.lol", hostNs1CatLol.getRepoId())
.addNameserver("ns2.cat.lol", hostNs2CatLol.getRepoId())
.setNextQuery(queryString)
.load(filename));
}
@@ -367,10 +367,10 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
requestType,
queryString,
jsonFileBuilder()
.addDomain("cat2.lol", "B-LOL")
.addDomain("cat2.lol", domainCatLol2.getRepoId())
.addRegistrar("Yes Virginia <script>")
.addNameserver("ns1.cat.example", "7-ROID")
.addNameserver("ns2.dog.lol", "9-ROID")
.addNameserver("ns1.cat.example", hostNameToHostMap.get("ns1.cat.example").getRepoId())
.addNameserver("ns2.dog.lol", hostNameToHostMap.get("ns2.dog.lol").getRepoId())
.setNextQuery(queryString)
.load(filename));
}
@@ -679,10 +679,11 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
RequestType.NAME,
"cat.example",
jsonFileBuilder()
.addDomain("cat.example", "F-EXAMPLE")
.addDomain("cat.example", domainCatExample.getRepoId())
.addRegistrar("St. John Chrysostom")
.addNameserver("ns1.cat.lol", "2-ROID")
.addNameserver("ns2.external.tld", "D-ROID")
.addNameserver("ns1.cat.lol", hostNs1CatLol.getRepoId())
.addNameserver(
"ns2.external.tld", hostNameToHostMap.get("ns2.external.tld").getRepoId())
.load("rdap_domain.json"));
verifyMetrics(SearchType.BY_DOMAIN_NAME, Optional.of(1L));
}
@@ -693,10 +694,10 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
RequestType.NAME,
"cat.みんな",
jsonFileBuilder()
.addDomain("cat.みんな", "15-Q9JYB4C")
.addDomain("cat.みんな", domainIdn.getRepoId())
.addRegistrar("みんな")
.addNameserver("ns1.cat.みんな", "11-ROID")
.addNameserver("ns2.cat.みんな", "13-ROID")
.addNameserver("ns1.cat.みんな", hostNameToHostMap.get("ns1.cat.xn--q9jyb4c").getRepoId())
.addNameserver("ns2.cat.みんな", hostNameToHostMap.get("ns2.cat.xn--q9jyb4c").getRepoId())
.load("rdap_domain_unicode_with_unicode_nameservers.json"));
// The unicode gets translated to ASCII before getting parsed into a search pattern.
metricPrefixLength = 15;
@@ -709,10 +710,10 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
RequestType.NAME,
"cat.xn--q9jyb4c",
jsonFileBuilder()
.addDomain("cat.みんな", "15-Q9JYB4C")
.addDomain("cat.みんな", domainIdn.getRepoId())
.addRegistrar("みんな")
.addNameserver("ns1.cat.みんな", "11-ROID")
.addNameserver("ns2.cat.みんな", "13-ROID")
.addNameserver("ns1.cat.みんな", hostNameToHostMap.get("ns1.cat.xn--q9jyb4c").getRepoId())
.addNameserver("ns2.cat.みんな", hostNameToHostMap.get("ns2.cat.xn--q9jyb4c").getRepoId())
.load("rdap_domain_unicode_with_unicode_nameservers.json"));
verifyMetrics(SearchType.BY_DOMAIN_NAME, Optional.of(1L));
}
@@ -723,10 +724,10 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
RequestType.NAME,
"cat.1.test",
jsonFileBuilder()
.addDomain("cat.1.test", "1B-1TEST")
.addDomain("cat.1.test", domainMultipart.getRepoId())
.addRegistrar("1.test")
.addNameserver("ns1.cat.1.test", "17-ROID")
.addNameserver("ns2.cat.2.test", "19-ROID")
.addNameserver("ns1.cat.1.test", hostNameToHostMap.get("ns1.cat.1.test").getRepoId())
.addNameserver("ns2.cat.2.test", hostNameToHostMap.get("ns2.cat.2.test").getRepoId())
.load("rdap_domain.json"));
verifyMetrics(SearchType.BY_DOMAIN_NAME, Optional.of(1L));
}
@@ -737,10 +738,10 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
RequestType.NAME,
"ca*.1.test",
jsonFileBuilder()
.addDomain("cat.1.test", "1B-1TEST")
.addDomain("cat.1.test", domainMultipart.getRepoId())
.addRegistrar("1.test")
.addNameserver("ns1.cat.1.test", "17-ROID")
.addNameserver("ns2.cat.2.test", "19-ROID")
.addNameserver("ns1.cat.1.test", hostNameToHostMap.get("ns1.cat.1.test").getRepoId())
.addNameserver("ns2.cat.2.test", hostNameToHostMap.get("ns2.cat.2.test").getRepoId())
.load("rdap_domain.json"));
verifyMetrics(SearchType.BY_DOMAIN_NAME, Optional.of(1L));
}
@@ -813,10 +814,10 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
.that(generateActualJson(RequestType.NAME, "cat.*"))
.isEqualTo(
jsonFileBuilder()
.addDomain("cat.1.test", "1B-1TEST")
.addDomain("cat.example", "F-EXAMPLE")
.addDomain("cat.lol", "6-LOL")
.addDomain("cat.みんな", "15-Q9JYB4C")
.addDomain("cat.1.test", domainMultipart.getRepoId())
.addDomain("cat.example", domainCatExample.getRepoId())
.addDomain("cat.lol", domainCatLol.getRepoId())
.addDomain("cat.みんな", domainIdn.getRepoId())
.load("rdap_domains_four_with_one_unicode.json"));
assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(SearchType.BY_DOMAIN_NAME, Optional.of(4L));
@@ -851,10 +852,10 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
.that(generateActualJson(RequestType.NAME, "cat*"))
.isEqualTo(
jsonFileBuilder()
.addDomain("cat.1.test", "1B-1TEST")
.addDomain("cat.example", "F-EXAMPLE")
.addDomain("cat.lol", "6-LOL")
.addDomain("cat.みんな", "15-Q9JYB4C")
.addDomain("cat.1.test", domainMultipart.getRepoId())
.addDomain("cat.example", domainCatExample.getRepoId())
.addDomain("cat.lol", domainCatLol.getRepoId())
.addDomain("cat.みんな", domainIdn.getRepoId())
.setNextQuery("name=cat*&cursor=Y2F0LnhuLS1xOWp5YjRj")
.load("rdap_domains_four_with_one_unicode_truncated.json"));
assertThat(response.getStatus()).isEqualTo(200);
@@ -970,15 +971,15 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
// This is not exactly desired behavior, but expected: There are enough domains to fill a full
// result set, but there are so many deleted domains that we run out of patience before we work
// our way through all of them.
createManyDomainsAndHosts(4, 50, 2);
ImmutableList<Domain> domains = createManyDomainsAndHosts(4, 50, 2);
rememberWildcardType("domain*.lol");
assertAboutJson()
.that(generateActualJson(RequestType.NAME, "domain*.lol"))
.isEqualTo(
jsonFileBuilder()
.addDomain("domain100.lol", "8E-LOL")
.addDomain("domain150.lol", "5C-LOL")
.addDomain("domain200.lol", "2A-LOL")
.addDomain("domain100.lol", domains.get(100).getRepoId())
.addDomain("domain150.lol", domains.get(50).getRepoId())
.addDomain("domain200.lol", domains.get(0).getRepoId())
.addDomain("domainunused.lol", "unused-LOL")
.load("rdap_incomplete_domain_result_set.json"));
assertThat(response.getStatus()).isEqualTo(200);
@@ -990,28 +991,28 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
@Test
void testDomainMatch_nontruncatedResultsSet() {
createManyDomainsAndHosts(4, 1, 2);
ImmutableList<Domain> domains = createManyDomainsAndHosts(4, 1, 2);
runSuccessfulTestWithFourDomains(
RequestType.NAME,
"domain*.lol",
"2D-LOL",
"2C-LOL",
"2B-LOL",
"2A-LOL",
domains.get(3).getRepoId(),
domains.get(2).getRepoId(),
domains.get(1).getRepoId(),
domains.get(0).getRepoId(),
"rdap_nontruncated_domains.json");
verifyMetrics(SearchType.BY_DOMAIN_NAME, Optional.of(4L));
}
@Test
void testDomainMatch_truncatedResultsSet() {
createManyDomainsAndHosts(5, 1, 2);
ImmutableList<Domain> domains = createManyDomainsAndHosts(5, 1, 2);
runSuccessfulTestWithFourDomains(
RequestType.NAME,
"domain*.lol",
"2E-LOL",
"2D-LOL",
"2C-LOL",
"2B-LOL",
domains.get(4).getRepoId(),
domains.get(3).getRepoId(),
domains.get(2).getRepoId(),
domains.get(1).getRepoId(),
"name=domain*.lol&cursor=ZG9tYWluNC5sb2w%3D",
"rdap_domains_four_truncated.json");
verifyMetrics(SearchType.BY_DOMAIN_NAME, Optional.of(5L), IncompletenessWarningType.TRUNCATED);
@@ -1019,16 +1020,16 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
@Test
void testDomainMatch_tldSearchOrderedProperly_sql() {
createManyDomainsAndHosts(4, 1, 2);
ImmutableList<Domain> domains = createManyDomainsAndHosts(4, 1, 2);
rememberWildcardType("*.lol");
assertAboutJson()
.that(generateActualJson(RequestType.NAME, "*.lol"))
.isEqualTo(
jsonFileBuilder()
.addDomain("cat.lol", "6-LOL")
.addDomain("cat2.lol", "B-LOL")
.addDomain("domain1.lol", "2D-LOL")
.addDomain("domain2.lol", "2C-LOL")
.addDomain("cat.lol", domainCatLol.getRepoId())
.addDomain("cat2.lol", domainCatLol2.getRepoId())
.addDomain("domain1.lol", domains.get(3).getRepoId())
.addDomain("domain2.lol", domains.get(2).getRepoId())
.setNextQuery("name=*.lol&cursor=ZG9tYWluMi5sb2w%3D")
.load("rdap_domains_four_truncated.json"));
verifyMetrics(SearchType.BY_DOMAIN_NAME, Optional.of(5L), IncompletenessWarningType.TRUNCATED);
@@ -1038,14 +1039,14 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
void testDomainMatch_reallyTruncatedResultsSet() {
// Don't use 10 or more domains for this test, because domain10.lol will come before
// domain2.lol, and you'll get the wrong domains in the result set.
createManyDomainsAndHosts(9, 1, 2);
ImmutableList<Domain> domains = createManyDomainsAndHosts(9, 1, 2);
runSuccessfulTestWithFourDomains(
RequestType.NAME,
"domain*.lol",
"32-LOL",
"31-LOL",
"30-LOL",
"2F-LOL",
domains.get(8).getRepoId(),
domains.get(7).getRepoId(),
domains.get(6).getRepoId(),
domains.get(5).getRepoId(),
"name=domain*.lol&cursor=ZG9tYWluNC5sb2w%3D",
"rdap_domains_four_truncated.json");
verifyMetrics(SearchType.BY_DOMAIN_NAME, Optional.of(5L), IncompletenessWarningType.TRUNCATED);
@@ -1053,16 +1054,16 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
@Test
void testDomainMatch_truncatedResultsAfterMultipleChunks_sql() {
createManyDomainsAndHosts(5, 6, 2);
ImmutableList<Domain> domains = createManyDomainsAndHosts(5, 6, 2);
rememberWildcardType("domain*.lol");
assertAboutJson()
.that(generateActualJson(RequestType.NAME, "domain*.lol"))
.isEqualTo(
jsonFileBuilder()
.addDomain("domain12.lol", "3C-LOL")
.addDomain("domain18.lol", "36-LOL")
.addDomain("domain24.lol", "30-LOL")
.addDomain("domain30.lol", "2A-LOL")
.addDomain("domain12.lol", domains.get(18).getRepoId())
.addDomain("domain18.lol", domains.get(12).getRepoId())
.addDomain("domain24.lol", domains.get(6).getRepoId())
.addDomain("domain30.lol", domains.get(0).getRepoId())
.setNextQuery("name=domain*.lol&cursor=ZG9tYWluMzAubG9s")
.load("rdap_domains_four_truncated.json"));
assertThat(response.getStatus()).isEqualTo(200);
@@ -1261,10 +1262,10 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
RequestType.NS_LDH_NAME,
"ns1.cat.xn--q9jyb4c",
jsonFileBuilder()
.addDomain("cat.みんな", "15-Q9JYB4C")
.addDomain("cat.みんな", domainIdn.getRepoId())
.addRegistrar("みんな")
.addNameserver("ns1.cat.みんな", "11-ROID")
.addNameserver("ns2.cat.みんな", "13-ROID")
.addNameserver("ns1.cat.みんな", hostNameToHostMap.get("ns1.cat.xn--q9jyb4c").getRepoId())
.addNameserver("ns2.cat.みんな", hostNameToHostMap.get("ns2.cat.xn--q9jyb4c").getRepoId())
.load("rdap_domain_unicode_with_unicode_nameservers.json"));
verifyMetrics(SearchType.BY_NAMESERVER_NAME, 1, 1);
}
@@ -1275,10 +1276,10 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
RequestType.NS_LDH_NAME,
"ns1.cat.1.test",
jsonFileBuilder()
.addDomain("cat.1.test", "1B-1TEST")
.addDomain("cat.1.test", domainMultipart.getRepoId())
.addRegistrar("1.test")
.addNameserver("ns1.cat.1.test", "17-ROID")
.addNameserver("ns2.cat.2.test", "19-ROID")
.addNameserver("ns1.cat.1.test", hostNameToHostMap.get("ns1.cat.1.test").getRepoId())
.addNameserver("ns2.cat.2.test", hostNameToHostMap.get("ns2.cat.2.test").getRepoId())
.load("rdap_domain.json"));
verifyMetrics(SearchType.BY_NAMESERVER_NAME, 1, 1);
}
@@ -1289,10 +1290,10 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
RequestType.NS_LDH_NAME,
"ns*.cat.1.test",
jsonFileBuilder()
.addDomain("cat.1.test", "1B-1TEST")
.addDomain("cat.1.test", domainMultipart.getRepoId())
.addRegistrar("1.test")
.addNameserver("ns1.cat.1.test", "17-ROID")
.addNameserver("ns2.cat.2.test", "19-ROID")
.addNameserver("ns1.cat.1.test", hostNameToHostMap.get("ns1.cat.1.test").getRepoId())
.addNameserver("ns2.cat.2.test", hostNameToHostMap.get("ns2.cat.2.test").getRepoId())
.load("rdap_domain.json"));
verifyMetrics(SearchType.BY_NAMESERVER_NAME, 1, 1);
}
@@ -1430,28 +1431,28 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
@Test
void testNameserverMatch_nontruncatedResultsSet() {
createManyDomainsAndHosts(4, 1, 2);
ImmutableList<Domain> domains = createManyDomainsAndHosts(4, 1, 2);
runSuccessfulTestWithFourDomains(
RequestType.NS_LDH_NAME,
"ns1.domain1.lol",
"2D-LOL",
"2C-LOL",
"2B-LOL",
"2A-LOL",
domains.get(3).getRepoId(),
domains.get(2).getRepoId(),
domains.get(1).getRepoId(),
domains.get(0).getRepoId(),
"rdap_nontruncated_domains.json");
verifyMetrics(SearchType.BY_NAMESERVER_NAME, Optional.of(4L), Optional.of(1L));
}
@Test
void testNameserverMatch_truncatedResultsSet() {
createManyDomainsAndHosts(5, 1, 2);
ImmutableList<Domain> domains = createManyDomainsAndHosts(5, 1, 2);
runSuccessfulTestWithFourDomains(
RequestType.NS_LDH_NAME,
"ns1.domain1.lol",
"2E-LOL",
"2D-LOL",
"2C-LOL",
"2B-LOL",
domains.get(4).getRepoId(),
domains.get(3).getRepoId(),
domains.get(2).getRepoId(),
domains.get(1).getRepoId(),
"nsLdhName=ns1.domain1.lol&cursor=ZG9tYWluNC5sb2w%3D",
"rdap_domains_four_truncated.json");
verifyMetrics(
@@ -1463,14 +1464,14 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
@Test
void testNameserverMatch_reallyTruncatedResultsSet() {
createManyDomainsAndHosts(9, 1, 2);
ImmutableList<Domain> domains = createManyDomainsAndHosts(9, 1, 2);
runSuccessfulTestWithFourDomains(
RequestType.NS_LDH_NAME,
"ns1.domain1.lol",
"32-LOL",
"31-LOL",
"30-LOL",
"2F-LOL",
domains.get(8).getRepoId(),
domains.get(7).getRepoId(),
domains.get(6).getRepoId(),
domains.get(5).getRepoId(),
"nsLdhName=ns1.domain1.lol&cursor=ZG9tYWluNC5sb2w%3D",
"rdap_domains_four_truncated.json");
verifyMetrics(
@@ -1484,16 +1485,16 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
void testNameserverMatch_duplicatesNotTruncated() {
// 36 nameservers for each of 4 domains; these should translate into two fetches, which should
// not trigger the truncation warning because all the domains will be duplicates.
createManyDomainsAndHosts(4, 1, 36);
ImmutableList<Domain> domains = createManyDomainsAndHosts(4, 1, 36);
rememberWildcardType("ns*.domain1.lol");
assertAboutJson()
.that(generateActualJson(RequestType.NS_LDH_NAME, "ns*.domain1.lol"))
.isEqualTo(
jsonFileBuilder()
.addDomain("domain1.lol", "71-LOL")
.addDomain("domain2.lol", "70-LOL")
.addDomain("domain3.lol", "6F-LOL")
.addDomain("domain4.lol", "6E-LOL")
.addDomain("domain1.lol", domains.get(3).getRepoId())
.addDomain("domain2.lol", domains.get(2).getRepoId())
.addDomain("domain3.lol", domains.get(1).getRepoId())
.addDomain("domain4.lol", domains.get(0).getRepoId())
.load("rdap_nontruncated_domains.json"));
assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(SearchType.BY_NAMESERVER_NAME, Optional.of(4L), Optional.of(36L));
@@ -1501,14 +1502,14 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
@Test
void testNameserverMatch_incompleteResultsSet() {
createManyDomainsAndHosts(2, 1, 41);
ImmutableList<Domain> domains = createManyDomainsAndHosts(2, 1, 41);
rememberWildcardType("ns*.domain1.lol");
assertAboutJson()
.that(generateActualJson(RequestType.NS_LDH_NAME, "ns*.domain1.lol"))
.isEqualTo(
jsonFileBuilder()
.addDomain("domain1.lol", "79-LOL")
.addDomain("domain2.lol", "78-LOL")
.addDomain("domain1.lol", domains.get(1).getRepoId())
.addDomain("domain2.lol", domains.get(0).getRepoId())
.load("rdap_incomplete_domains.json"));
assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(
@@ -1641,10 +1642,10 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
.isEqualTo(
wrapInSearchReply(
jsonFileBuilder()
.addDomain("cat.lol", "6-LOL")
.addDomain("cat.lol", domainCatLol.getRepoId())
.addRegistrar("Yes Virginia <script>")
.addNameserver("ns1.cat.lol", "2-ROID")
.addNameserver("ns2.cat.lol", "4-ROID")
.addNameserver("ns1.cat.lol", hostNs1CatLol.getRepoId())
.addNameserver("ns2.cat.lol", hostNs2CatLol.getRepoId())
.load("rdap_domain.json")));
assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(SearchType.BY_NAMESERVER_ADDRESS, 1, 1);
@@ -1667,28 +1668,28 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
@Test
void testAddressMatch_nontruncatedResultsSet() {
createManyDomainsAndHosts(4, 1, 2);
ImmutableList<Domain> domains = createManyDomainsAndHosts(4, 1, 2);
runSuccessfulTestWithFourDomains(
RequestType.NS_IP,
"5.5.5.1",
"2D-LOL",
"2C-LOL",
"2B-LOL",
"2A-LOL",
domains.get(3).getRepoId(),
domains.get(2).getRepoId(),
domains.get(1).getRepoId(),
domains.get(0).getRepoId(),
"rdap_nontruncated_domains.json");
verifyMetrics(SearchType.BY_NAMESERVER_ADDRESS, 4, 1);
}
@Test
void testAddressMatch_truncatedResultsSet() {
createManyDomainsAndHosts(5, 1, 2);
ImmutableList<Domain> domains = createManyDomainsAndHosts(5, 1, 2);
runSuccessfulTestWithFourDomains(
RequestType.NS_IP,
"5.5.5.1",
"2E-LOL",
"2D-LOL",
"2C-LOL",
"2B-LOL",
domains.get(4).getRepoId(),
domains.get(3).getRepoId(),
domains.get(2).getRepoId(),
domains.get(1).getRepoId(),
"nsIp=5.5.5.1&cursor=ZG9tYWluNC5sb2w%3D",
"rdap_domains_four_truncated.json");
verifyMetrics(
@@ -1700,14 +1701,14 @@ class RdapDomainSearchActionTest extends RdapSearchActionTestCase<RdapDomainSear
@Test
void testAddressMatch_reallyTruncatedResultsSet() {
createManyDomainsAndHosts(9, 1, 2);
ImmutableList<Domain> domains = createManyDomainsAndHosts(9, 1, 2);
runSuccessfulTestWithFourDomains(
RequestType.NS_IP,
"5.5.5.1",
"32-LOL",
"31-LOL",
"30-LOL",
"2F-LOL",
domains.get(8).getRepoId(),
domains.get(7).getRepoId(),
domains.get(6).getRepoId(),
domains.get(5).getRepoId(),
"nsIp=5.5.5.1&cursor=ZG9tYWluNC5sb2w%3D",
"rdap_domains_four_truncated.json");
verifyMetrics(
@@ -248,4 +248,38 @@ class CopyDetailReportsActionTest {
action.run();
verifyNoInteractions(driveConnection);
}
@Test
void testSuccess_registrarIdWithUnderscores() throws IOException {
persistResource(
loadRegistrar("TheRegistrar")
.asBuilder()
.setRegistrarId("The_Registrar")
.setDriveFolderId("0B-99999")
.build());
gcsUtils.createFromBytes(
BlobId.of("test-bucket", "results/invoice_details_2017-10_The_Registrar_test.csv"),
"hello,world\n1,2".getBytes(UTF_8));
action.run();
verify(driveConnection)
.createOrUpdateFile(
"invoice_details_2017-10_The_Registrar_test.csv",
MediaType.CSV_UTF_8,
"0B-99999",
"hello,world\n1,2".getBytes(UTF_8));
assertThat(response.getStatus()).isEqualTo(SC_OK);
}
@Test
void testSuccess_invalidFilenamePattern_skipped() throws IOException {
gcsUtils.createFromBytes(
BlobId.of("test-bucket", "results/invoice_details_2017-10.csv"),
"hello,world\n1,2".getBytes(UTF_8));
action.run();
verifyNoInteractions(driveConnection);
assertThat(response.getStatus()).isEqualTo(SC_OK);
}
}
@@ -23,6 +23,8 @@ import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableMap;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonParser;
import google.registry.model.console.ConsoleUpdateHistory;
import google.registry.model.console.RegistrarRole;
import google.registry.model.console.User;
@@ -162,6 +164,27 @@ class ConsoleHistoryDataActionTest extends ConsoleActionBaseTestCase {
assertThat(response.getPayload()).isEqualTo("[]");
}
@Test
void testSuccess_limitsResults() {
for (int i = 0; i < 10; i++) {
DatabaseHelper.persistResource(
new ConsoleUpdateHistory.Builder()
.setType(ConsoleUpdateHistory.Type.REGISTRAR_UPDATE)
.setDescription("TheRegistrar|some detail " + i)
.setActingUser(fteUser)
.setUrl("https://test.com")
.setMethod("GET")
.setModificationTime(clock.now())
.build());
}
ConsoleHistoryDataAction action =
createAction(AuthResult.createUser(fteUser), "TheRegistrar", Optional.empty(), 5);
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
JsonArray payload = JsonParser.parseString(response.getPayload()).getAsJsonArray();
assertThat(payload.size()).isEqualTo(5);
}
@Test
void testFailure_getByRegistrar_noPermission() {
ConsoleHistoryDataAction action =
@@ -192,10 +215,15 @@ class ConsoleHistoryDataActionTest extends ConsoleActionBaseTestCase {
private ConsoleHistoryDataAction createAction(
AuthResult authResult, String registrarId, Optional<String> consoleUserEmail) {
return createAction(authResult, registrarId, consoleUserEmail, 500);
}
private ConsoleHistoryDataAction createAction(
AuthResult authResult, String registrarId, Optional<String> consoleUserEmail, int limit) {
consoleApiParams = ConsoleApiParamsUtils.createFake(authResult);
when(consoleApiParams.request().getMethod()).thenReturn("GET");
response = (FakeResponse) consoleApiParams.response();
return new ConsoleHistoryDataAction(
consoleApiParams, SUPPORT_EMAIL, registrarId, consoleUserEmail);
consoleApiParams, SUPPORT_EMAIL, limit, registrarId, consoleUserEmail);
}
}
@@ -158,6 +158,31 @@ public class PasswordResetRequestActionTest extends ConsoleActionBaseTestCase {
.isEqualTo("Unknown user with lock email nonexistent@email.com");
}
@Test
void testFailure_registryLock_userNoLockPermission() throws Exception {
DatabaseHelper.persistResource(
new User.Builder()
.setEmailAddress("email@registry.tld")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(
ImmutableMap.of("TheRegistrar", RegistrarRole.ACCOUNT_MANAGER))
.build())
.setRegistryLockEmailAddress("registrylock@theregistrar.com")
.build());
PasswordResetRequestAction action =
createAction(
PasswordResetRequest.Type.REGISTRY_LOCK,
"TheRegistrar",
"registrylock@theregistrar.com");
action.run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
assertThat(response.getPayload())
.isEqualTo(
"User email@registry.tld does not have permission REGISTRY_LOCK on registrar"
+ " TheRegistrar");
}
@Test
@Disabled("Enable when testing is done in sandbox and isAdmin check is removed")
void testFailure_epp_noPermission() throws Exception {
@@ -157,6 +157,48 @@ public class PasswordResetVerifyActionTest extends ConsoleActionBaseTestCase {
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
}
@Test
void testFailure_post_lock_differentRegistryLockEmail() throws Exception {
User user =
persistResource(
new User.Builder()
.setEmailAddress("anotheruser@example.tld")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(
ImmutableMap.of(
"TheRegistrar", RegistrarRole.ACCOUNT_MANAGER_WITH_REGISTRY_LOCK))
.build())
.setRegistryLockEmailAddress("anotherregistrylock@theregistrar.com")
.build());
verificationCode = saveRequest(PasswordResetRequest.Type.REGISTRY_LOCK).getVerificationCode();
createAction(user, "POST", verificationCode, "newPassword").run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
assertThat(response.getPayload())
.isEqualTo("User anotheruser@example.tld has the wrong registry lock email address");
}
@Test
void testFailure_get_lock_differentRegistryLockEmail() throws Exception {
User user =
persistResource(
new User.Builder()
.setEmailAddress("anotheruser@example.tld")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(
ImmutableMap.of(
"TheRegistrar", RegistrarRole.ACCOUNT_MANAGER_WITH_REGISTRY_LOCK))
.build())
.setRegistryLockEmailAddress("anotherregistrylock@theregistrar.com")
.build());
verificationCode = saveRequest(PasswordResetRequest.Type.REGISTRY_LOCK).getVerificationCode();
createAction(user, "GET", verificationCode, null).run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
assertThat(response.getPayload())
.isEqualTo("User anotheruser@example.tld has the wrong registry lock email address");
}
private User createTechUser() {
return new User.Builder()
.setEmailAddress("tech@example.tld")
@@ -45,6 +45,7 @@ import google.registry.testing.ConsoleApiParamsUtils;
import google.registry.testing.FakeResponse;
import google.registry.ui.server.console.ConsoleActionBaseTestCase;
import google.registry.ui.server.console.ConsoleApiParams;
import java.util.Collections;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -173,7 +174,9 @@ public class ConsoleBulkDomainActionTest extends ConsoleActionBaseTestCase {
@Test
void testFailure_badActionString() {
ConsoleBulkDomainAction action = createAction("bad", GSON.toJsonTree(ImmutableMap.of()));
ConsoleBulkDomainAction action =
createAction(
"bad", GSON.toJsonTree(ImmutableMap.of("domainList", ImmutableList.of("domain.tld"))));
action.run();
assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST);
assertThat(response.getPayload())
@@ -190,6 +193,29 @@ public class ConsoleBulkDomainActionTest extends ConsoleActionBaseTestCase {
assertThat(response.getPayload()).isEqualTo("Bulk action payload must be present");
}
@Test
void testFailure_listTooLarge() {
JsonElement payload =
GSON.toJsonTree(
ImmutableMap.of(
"domainList", Collections.nCopies(6, "domain.tld"), "reason", "reason"));
ConsoleBulkDomainAction action = createAction("DELETE", payload, fteUser, 5);
action.run();
assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST);
assertThat(response.getPayload())
.isEqualTo("Cannot process more than 5 domains in a single bulk action");
}
@Test
void testFailure_emptyList() {
JsonElement payload =
GSON.toJsonTree(ImmutableMap.of("domainList", ImmutableList.of(), "reason", "reason"));
ConsoleBulkDomainAction action = createAction("DELETE", payload);
action.run();
assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST);
assertThat(response.getPayload()).isEqualTo("Domain list cannot be empty");
}
@Test
void testFailure_noPermission() {
JsonElement payload =
@@ -232,15 +258,20 @@ public class ConsoleBulkDomainActionTest extends ConsoleActionBaseTestCase {
// }
private ConsoleBulkDomainAction createAction(String action, JsonElement payload) {
return createAction(action, payload, fteUser);
return createAction(action, payload, fteUser, 500);
}
private ConsoleBulkDomainAction createAction(String action, JsonElement payload, User user) {
return createAction(action, payload, user, 500);
}
private ConsoleBulkDomainAction createAction(
String action, JsonElement payload, User user, int limit) {
AuthResult authResult = AuthResult.createUser(user);
ConsoleApiParams params = ConsoleApiParamsUtils.createFake(authResult);
when(params.request().getMethod()).thenReturn("POST");
response = (FakeResponse) params.response();
return new ConsoleBulkDomainAction(
params, eppController, "TheRegistrar", action, Optional.ofNullable(payload));
params, eppController, limit, "TheRegistrar", action, Optional.ofNullable(payload));
}
}
+69 -4
View File
@@ -7,11 +7,11 @@ utilities.
The following links are the ER diagrams generated from the current SQL schema:
* [Full ER diagram](https://storage.googleapis.com/domain-registry-dev-er-diagram/full_er_diagram.html):
* [Full ER diagram](https://storage.googleapis.com/domain-registry-dev-er-diagram/full_er_diagram.html):
shows all columns, foreign keys and indexes.
* [Brief ER diagram](https://storage.googleapis.com/domain-registry-dev-er-diagram/brief_er_diagram.html):
shows only significant columns, such as primary and foreign key columns, and
* [Brief ER diagram](https://storage.googleapis.com/domain-registry-dev-er-diagram/brief_er_diagram.html):
shows only significant columns, such as primary and foreign key columns, and
columns that are part of unique indexes.
### Database roles and privileges
@@ -55,7 +55,7 @@ following steps:
one. The generated SQL file from the previous step should help. New create
table statements can be used as is, whereas alter table statements should be
written to change any existing tables.
If an incremental file changes more than one schema element (table, index,
or sequence), it MAY hit deadlocks when applied on sandbox/production where
it'll be competing against live traffic that may also be locking said
@@ -132,6 +132,71 @@ update the Java to no longer contain the old column, wait for a deployment, and
then remove the old column. A rename operation requires the most complicated
series of steps to complete, as it is effectively an add followed by a remove.
### Refactoring Column Types (Expand and Contract)
When refactoring a column (such as changing its type, e.g., from a basic `boolean` to an `hstore` timed transition map), you must be extremely careful to avoid breaking running servers during rolling deployments.
Because the database schema change is deployed *before* the new server code is running on all instances, the database must remain compatible with both the old and new Java code at all times. This is achieved using the **Expand and Contract** pattern:
* **Case A: The old column is NOT mapped in Java.**
If the old column exists in the database but was never mapped as a field in any ORM entity (i.e., it is "dead weight" in the schema), you can safely drop it immediately in the first PR. The running servers do not know it exists, so dropping it will not cause any errors.
* **Case B: The old column IS mapped in Java.**
If the old column is actively mapped in Java, you **cannot** drop it in the first PR. Doing so will immediately crash the running servers. Instead, you must perform a three-step migration across separate releases:
1. **First PR (DB-only):** Add the new column as nullable, migrate data from the old column, and keep the old column.
2. **Second PR (Java-only):** Update the Java ORM classes to map only the new column and ignore the old one. Wait for this to be fully deployed to 100% of production instances.
3. **Third PR (DB-only Cleanup):** Create a new Flyway migration to safely drop the old column from the database.
### Writing Safe NOT NULL Migrations for Transition Maps
Nomulus avoids database-level `DEFAULT` constraints on timed transition properties (like `create_billing_cost_transitions` or `expiry_access_period_transitions`) in the long run to ensure that the application layer explicitly manages the data and fails fast if it fails to initialize a field.
However, during a Two-PR deployment, the database schema change (PR 1) is live in production *before* the new Java code (PR 2) is deployed. If you add a new column as `NOT NULL` with no default in the first PR, any new inserts from the running old Java code (which doesn't know about the column) will immediately fail with a constraint violation, causing production write downtime.
To satisfy both the `NOT NULL` requirement (for consistency) and backward compatibility, you must use the **Temporary Database Default** pattern across three phases:
1. **PR 1 (DB Schema Change):**
* Add the column as `NOT NULL` with a temporary database-level `DEFAULT` value matching the initial state. This allows the old Java code to continue inserting rows (the database will automatically apply the default value for the missing column).
* Leave a `TODO` comment in the Flyway SQL migration script to remind developers to drop the default in a subsequent release.
* **PostgreSQL hstore Cast:** When writing transition map updates or defaults in SQL, **PostgreSQL requires an explicit `::hstore` cast** on string literals, otherwise the migration will fail with a type mismatch:
```sql
ALTER TABLE "Tld" ADD COLUMN expiry_access_period_transitions hstore
DEFAULT '"1970-01-01T00:00:00.000Z"=>"DISABLED"'::hstore NOT NULL;
```
2. **PR 2 (Java Implementation):**
* Deploy the Java changes that map the column and ensure the application layer always explicitly sets the value (e.g., in the entity builder or via Java-level defaults).
* Leave a `TODO` comment in the Java code near the `@Column` annotation referencing the plan to drop the DB default.
3. **PR 3 (DB Cleanup - Contract Phase):**
* Once the new Java code is fully deployed to 100% of production instances, create a new subsequent Flyway migration to safely drop the temporary database-level default constraint:
```sql
-- Drop the temporary default constraint to restore the fail-fast invariant in Java
ALTER TABLE "Tld" ALTER COLUMN expiry_access_period_transitions DROP DEFAULT;
```
* Remove the `TODO` comments from the SQL and Java files.
### Recommended Git Workflow for Two-PR Splits
To cleanly manage a two-PR split where the second branch depends on the first, use the following chained branch workflow:
1. Implement and verify all your changes (both DB and Java) on a single implementation branch (e.g., `feature-impl`). Commit all changes in a single commit.
2. Create the database-only branch off `master`:
```shell
$ git checkout master
$ git checkout -b feature-db
```
3. Checkout only the database-related files from your implementation branch and commit them:
```shell
$ git checkout feature-impl -- db/src/main/resources/sql/flyway/ db/src/main/resources/sql/flyway.txt db/src/main/resources/sql/schema/nomulus.golden.sql db/src/main/resources/sql/er_diagram/
$ git commit -m "Implement database schema for feature"
```
4. Switch back to your implementation branch and rebase it against the database branch:
```shell
$ git checkout feature-impl
$ git rebase feature-db
```
Git will automatically detect that the database changes are already present in the parent branch and will skip them, leaving your implementation commit containing **only** the Java changes, tests, and the ORM-generated `db-schema.sql.generated`!
### Summary of Schema Tests
#### The Golden Schema Test
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -220,3 +220,4 @@ V219__domain_history_package_token_idx.sql
V220__domain_package_token_idx.sql
V221__remove_contact_history.sql
V222__remove_contact.sql
V223__tld_change_xap_enabled_to_transitions.sql
@@ -0,0 +1,25 @@
-- Copyright 2026 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.
-- Drop the old unused boolean column. Since it was never mapped in Tld.java
-- on master, no running servers map or use it, making it completely safe to drop.
ALTER TABLE "Tld" DROP COLUMN expiry_access_period_enabled;
-- Add the new transitions column as NOT NULL with a temporary DEFAULT value to
-- ensure backward compatibility with the running servers (old Java code) during
-- the transition phase of the deployment.
-- TODO(mcilwain): Drop this DEFAULT constraint in a subsequent schema release
-- once the Java code mapping this column has been fully deployed.
ALTER TABLE "Tld" ADD COLUMN expiry_access_period_transitions hstore
DEFAULT '"1970-01-01T00:00:00.000Z"=>"DISABLED"'::hstore NOT NULL;
@@ -1205,7 +1205,7 @@ CREATE TABLE public."Tld" (
breakglass_mode boolean DEFAULT false NOT NULL,
bsa_enroll_start_time timestamp with time zone,
create_billing_cost_transitions public.hstore NOT NULL,
expiry_access_period_enabled boolean DEFAULT false NOT NULL
expiry_access_period_transitions public.hstore DEFAULT '"1970-01-01T00:00:00.000Z"=>"DISABLED"'::public.hstore NOT NULL
);