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

Compare commits

...

19 Commits

Author SHA1 Message Date
Ben McIlwain 6608ee282d Add XAP registrar opt-in database schema (#3134)
This commit implements the first stage of the Two-PR database schema
deployment for registrar opt-in to the Expiry Access Period (XAP), along
with updating GEMINI.md to mandate strict adherence to db/README.md for all
future database schema modifications.

Specifically, it:
- Adds Flyway migration
  V224__add_registrar_expiry_access_period_enabled.sql, which adds the
  expiry_access_period_enabled boolean column (DEFAULT false NOT NULL) to
  the Registrar table.
- Updates flyway.txt, nomulus.golden.sql, and ER diagrams to reflect the
  schema changes.
- Updates GEMINI.md with explicit instructions for AI agents to always
  consult db/README.md, follow the mandatory Two-PR deployment split when
  altering database schemas, and run both :db:test and
  :core:sqlIntegrationTest whenever database schema or ORM mappings are
  touched.
- Updates golden screenshot
  ConsoleScreenshotTest_globalRole_registrars_registrarsPage.png to reflect
  the new default database ordering after adding the column. (Note: A
  separate pull request, PR #3136 / http://go/r3pr/3136, permanently fixes
  this underlying sort non-determinism across our persistence layer by
  enforcing deterministic alphabetical ordering in Registrar.loadAllSorted).

TAG=agy
CONV=f2488c74-8b4a-43f1-9c22-d1dddbdbb4e0
BUG=http://b/437398822
2026-07-07 03:44:37 +00:00
gbrodman 22c867f3f2 Add TLD verification to GenerateZoneFilesAction (#3132)
1. this is a good idea in general
2. it can prevent GCS path traversal if someone somehow bypasses auth to
   add a weird path
2026-07-06 19:26:42 +00:00
gbrodman 160abef731 Throw on invalid responses from SafeBrowsing (#3111)
this allows us to
1. use the built-in retrier to retry
2. fail loudly if it keeps actually throwing an exception if we're doing
   something invalid
2026-07-06 18:52:13 +00:00
gbrodman a6e4017971 Join cancellations on billing time as well (#3124)
This is a small edge case where a cancellation for year 2 could cancel
out events in year 1 if we re-ran billing for year 1 for some reason. I
don't think we would ever re-run one year later so this is kind of moot,
but it's probably still a good thing to do.
2026-07-06 18:51:51 +00:00
gbrodman d9a857133a Remove class name option in GetRoutingMapCommand (#3101)
we only have the one component now, and in general it's a good idea to
remove class loading based on user input, even if that user is one of us
2026-07-01 18:55:26 +00:00
gbrodman c7a27061d8 Remove entries from token store atomically (#3112)
just in case some other thread comes in while we're iterating
2026-07-01 18:42:59 +00:00
Weimin Yu 7766db36a7 Support custom npm location in local environment (#3127)
Make sure Gradle can find the npm executable at custom locations,
e.g., when installed by nvm.
2026-07-01 18:39:29 +00:00
Ben McIlwain eda0f7ad7c Harden EppXmlSanitizer against XXE and entity expansion (#3117)
Configure a throwing XMLResolver on XMLInputFactory in XmlTransformer
to act as an additional defense-in-depth security layer against XML
External Entity (XXE) and entity expansion/resolution attacks.
This prevents resolution of external and internal entities in StAX
parsing pipelines, ensuring that malformed payloads containing entity
definitions are safely blocked and rejected.

Also add a unit test to EppXmlSanitizerTest verifying that EPP
messages containing DTD and external entities fail parsing and fall
back safely to their Base64 representation.

TAG=agy
CONV=610c2358-a99f-4605-94cd-ff0d4ee08176

BUG= http://b/529387728
2026-06-30 18:19:50 +00:00
gbrodman 67527f1560 Invalidate TLD cache on any type of update (#3123) 2026-06-30 18:15:45 +00:00
Ben McIlwain 4aeba6e3f7 Validate non-empty tlds in refresh DNS action (#3114)
Add validation to RefreshDnsForAllDomainsAction to ensure the tlds parameter is not empty when invoked. Previously, invoking the action without specifying tlds would result in a no-op that logged an empty list and exited without error. This change makes missing tlds an explicit error, throwing an IllegalArgumentException so users running the command via nomulus curl receive clear feedback that the parameter is required.
2026-06-30 16:34:15 +00:00
Ben McIlwain d6f1f5894b Restore RFC schemas, prober XML, and OT&E route (#3120)
In commit 17b851de42 (#2979), <element name="contact"> and related types were incorrectly removed from core/src/main/java/google/registry/xml/xsd/domain.xsd and rde-domain.xsd. Because those XSD schemas are published standards (RFC 5731 and RFC 5831), altering them caused JAXB unmarshaling to reject historical database records (HistoryEntry) containing <domain:contact> elements during OT&E status checks.

Restore <element name="contact" minOccurs="0" maxOccurs="unbounded"/> to domain.xsd and rde-domain.xsd so all XSD schemas conform strictly to published EPP RFCs and historical contact elements unmarshal cleanly without throwing cvc-complex-type.2.4.a schema validation errors.

Remove deprecated <domain:contact> elements from prober creation payload (prober/.../create.xml) to match current flow rules where contact associations are forbidden.

Remove trailing slash from ote-status route parameter in RegistrarDetailsComponent to prevent double-slash (/ote-status//<id>) URL generation when checking OT&E status.

BUG= http://b/529391412
2026-06-30 16:07:31 +00:00
gbrodman 47ad569cb0 Verify that marksdb verify URL starts with ry.marksdb.org (#3115) 2026-06-30 16:02:00 +00:00
gbrodman 06934daf94 Don't allow registrar-based ops on global users (#3100) 2026-06-29 19:13:20 +00:00
gbrodman 9a032e4bb9 Add extra perm check for EPP password changes (#3099) 2026-06-29 18:30:52 +00:00
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
55 changed files with 853 additions and 174 deletions
+6
View File
@@ -44,6 +44,11 @@ This document outlines foundational mandates, architectural patterns, and projec
- **Test Helpers & Timestamps:** If a static test helper method (like in `DatabaseHelper`) needs the database transaction time but might be called from outside a transaction, using `tm().reTransact(tm()::getTxTime)` is acceptable. However, NEVER wrap it redundantly like `tm().transact(() -> tm().reTransact(tm()::getTxTime))`. If you are just setting an arbitrary timestamp in a test where the exact DB transaction time isn't strictly required, prefer `Instant.now()` or `clock.now()` to avoid creating unnecessary database transactions.
- **Production Code:** In production code, if a flow fails because it is calling `getTxTime()` outside of a transaction, you must wrap the *caller* in a transaction instead of adding an unnecessary `reTransact()` around `getTxTime()`.
- **Transactional Time:** Ensure code that relies on `tm().getTransactionTime()` (or `tm().getTxTime()`) is executed within a transaction context.
- **Database Schema Migrations & 2-PR Split Mandate:**
- **Mandatory Consultation of `db/README.md`:** Before planning, drafting, or executing any database schema modifications (e.g., adding/altering columns, creating Flyway `.sql` migration scripts, or modifying JPA entity `@Column` mappings), you **MUST read and strictly adhere to `db/README.md`**.
- **Strict 2-PR Deployment Split:** Never propose or submit combining Flyway SQL scripts (`db/src/main/resources/sql/flyway/V*.sql`) and Java ORM changes (`.java` entity files + `db-schema.sql.generated`) into a single PR for submission to `master`. Because live servers during a rolling deployment will fail if Java code attempts to access unmigrated database columns/constraints, all schema additions must be split into two sequential PRs per `db/README.md`:
1. **PR #1 (Database Schema Only):** Contains *only* the new Flyway `.sql` script, the `flyway.txt` index update (`:db:generateFlywayIndex`), the `nomulus.golden.sql` dump (`:nom:generate_golden_file`), and ER diagrams (`er_diagram/`). Must contain **zero `.java` files or `db-schema.sql.generated` changes**.
2. **PR #2 (Java ORM, EPP Flows & Generated Schema Map):** Submitted *only after* PR #1 is deployed to production. Contains all `.java` entity/flow modifications, tests, and the regenerated `db-schema.sql.generated` (`generateSqlSchema`).
### 5. Testing Best Practices
- **Mandatory Proactive Testing:** You MUST automatically write and update tests alongside your code changes WITHOUT waiting for the user to prompt you. If you add a new feature, fix a bug, or change core logic, you are explicitly required to identify the corresponding `*Test.java` file and implement comprehensive test coverage for your changes.
@@ -51,6 +56,7 @@ This document outlines foundational mandates, architectural patterns, and projec
- **Empirical Reproduction:** Before fixing a bug, always create a test case that reproduces the failure.
- **Base Classes:** Leverage `CommandTestCase`, `EppToolCommandTestCase`, etc., to reduce boilerplate and ensure consistent setup (e.g., clock initialization).
- **Gradle Test Patterns:** When running tests to investigate fixes in the "core" directory, try to first use the "standardTest" Gradle task. It is faster than the "test" task, which includes the "fragileTest" task. Only run the full "test" task after "standardTest" succeeds.
- **Mandatory SQL Integration Verification:** Whenever you modify any database schema, Flyway script (`.sql`), or JPA entity class, you MUST explicitly run both `./gradlew :db:test` and `./gradlew :core:sqlIntegrationTest` **in addition to** the standard test suites (`./gradlew standardTest` or `./gradlew test`) before finalizing the task or declaring completion. Do not rely solely on `standardTest` when database schemas or ORM mappings are touched; all three test suites (`standardTest`, `:db:test`, and `:core:sqlIntegrationTest`) are mandatory.
### 6. Project Dependencies
- **Common Module:** When using `Clock` or other core utilities in a new or separate module (like `load-testing`), ensure `implementation project(':common')` is added to the module's `build.gradle`.
+7 -14
View File
@@ -21,27 +21,23 @@ clean {
task npmInstallDeps(type: Exec) {
workingDir "${consoleDir}/"
executable 'npm'
args 'i', '--no-audit', '--no-fund', '--loglevel=error'
commandLine 'sh', '-c', 'npm i --no-audit --no-fund --loglevel=error'
}
task runConsoleWebappLocally(type: Exec) {
workingDir "${consoleDir}/"
executable 'npm'
args 'run', 'start:dev'
commandLine 'sh', '-c', 'npm run start:dev'
}
task runConsoleWebappUnitTests(type: Exec) {
workingDir "${consoleDir}/"
executable 'npm'
args 'run', 'test'
commandLine 'sh', '-c', 'npm run test'
}
task buildConsoleWebapp(type: Exec) {
workingDir "${consoleDir}/"
executable 'npx'
def configuration = project.getProperty('configuration')
args 'ng', 'build', '--base-href=/console/', "--configuration=${configuration}", "--output-path=staged/dist"
commandLine 'sh', '-c', "npx ng build --base-href=/console/ --configuration=${configuration} --output-path=staged/dist"
doFirst {
println "Building console for environment: ${configuration}"
}
@@ -52,8 +48,7 @@ task buildConsoleForAll() {}
def createConsoleTask = { env ->
project.tasks.register("buildConsoleFor${env.capitalize()}", Exec) {
workingDir "${consoleDir}/"
executable 'npx'
args 'ng', 'build', '--base-href=/console/', "--configuration=${env}"
commandLine 'sh', '-c', "npx ng build --base-href=/console/ --configuration=${env}"
doFirst {
println "Building console for environment: ${env}"
}
@@ -91,14 +86,12 @@ tasks.buildConsoleWebapp.mustRunAfter(tasks.buildConsoleForProduction)
task applyFormatting(type: Exec) {
workingDir "${consoleDir}/"
executable 'npm'
args 'run', 'prettify'
commandLine 'sh', '-c', 'npm run prettify'
}
task checkFormatting(type: Exec) {
workingDir "${consoleDir}/"
executable 'npm'
args 'run', 'prettify:check'
commandLine 'sh', '-c', 'npm run prettify:check'
}
tasks.buildConsoleWebapp.dependsOn(tasks.npmInstallDeps)
@@ -75,7 +75,7 @@ export class RegistrarDetailsComponent implements OnInit {
}
checkOteStatus() {
this.router.navigate(['ote-status/', this.registrarInEdit.registrarId], {
this.router.navigate(['ote-status', this.registrarInEdit.registrarId], {
queryParamsHandling: 'merge',
});
}
@@ -226,18 +226,17 @@ public class SafeBrowsingTransforms {
private void processResponse(
CloseableHttpResponse response,
ImmutableSet.Builder<KV<DomainNameInfo, ThreatMatch>> resultBuilder)
throws JSONException, IOException {
throws IOException {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode != SC_OK) {
logger.atWarning().log("Got unexpected status code %s from response.", statusCode);
} else {
// Unpack the response body
JSONObject responseBody =
new JSONObject(
CharStreams.toString(
new InputStreamReader(response.getEntity().getContent(), UTF_8)));
logger.atInfo().log("Got response: %s", responseBody);
if (responseBody.length() == 0) {
throw new IOException(
String.format("Got unexpected status code %s from response.", statusCode));
}
// Unpack the response body
try (InputStreamReader reader =
new InputStreamReader(response.getEntity().getContent(), UTF_8)) {
JSONObject responseBody = new JSONObject(CharStreams.toString(reader));
if (responseBody.isEmpty()) {
logger.atInfo().log("Response was empty, no threats detected.");
} else {
// Emit all DomainNameInfos with their API results.
@@ -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));
}
}
}
@@ -158,12 +158,12 @@ public class DomainCommand {
throws InvalidReferencesException, ParameterValuePolicyErrorException {
Create clone = clone(this);
clone.nameservers = linkHosts(nullSafeImmutableCopy(clone.nameserverHostNames), now);
if (registrantContactId != null) {
throw new RegistrantProhibitedException();
}
if (!isNullOrEmpty(foreignKeyedDesignatedContacts)) {
throw new ContactsProhibitedException();
}
if (registrantContactId != null) {
throw new RegistrantProhibitedException();
}
return clone;
}
@@ -71,6 +71,8 @@ import google.registry.model.domain.token.VKeyConverter_AllocationToken;
import google.registry.model.tld.label.PremiumList;
import google.registry.model.tld.label.ReservedList;
import google.registry.persistence.EntityCallbacksListener.RecursivePostPersist;
import google.registry.persistence.EntityCallbacksListener.RecursivePostRemove;
import google.registry.persistence.EntityCallbacksListener.RecursivePostUpdate;
import google.registry.persistence.VKey;
import google.registry.persistence.converter.AllocationTokenVkeyListUserType;
import google.registry.persistence.converter.BillingCostTransitionUserType;
@@ -219,9 +221,12 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
* Invalidates the cache entry.
*
* <p>This is called automatically when the tld is saved. One should also call it when a tld is
* deleted.
* deleted. This only affects the pod-local cache so most pods won't catch it, but it's still the
* right thing to do.
*/
@RecursivePostPersist
@RecursivePostRemove
@RecursivePostUpdate
public void invalidateInCache() {
CACHE.invalidate(tldStr);
}
@@ -164,7 +164,7 @@ import google.registry.ui.server.console.settings.SecurityAction;
ToolsServerModule.class,
WhiteboxModule.class
})
interface RequestComponent {
public interface RequestComponent {
FlowComponent.Builder flowComponentBuilder();
BrdaCopyAction brdaCopyAction();
@@ -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(
@@ -14,11 +14,9 @@
package google.registry.request;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static java.util.stream.Collectors.joining;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Streams;
import java.util.Comparator;
@@ -64,18 +62,6 @@ public class RouterDisplayHelper {
return formatRoutes(Router.extractRoutesFromComponent(componentClass).values());
}
public static ImmutableList<String> extractHumanReadableRoutesWithWrongService(
Class<?> componentClass, Action.Service expectedService) {
return Router.extractRoutesFromComponent(componentClass).values().stream()
.filter(route -> route.action().service() != expectedService)
.map(
route ->
String.format(
"%s (%s%s)",
route.actionClass(), route.action().service(), route.action().path()))
.collect(toImmutableList());
}
private static String getFormatString(Map<String, Integer> columnWidths) {
return String.format(
FORMAT,
@@ -14,12 +14,14 @@
package google.registry.tmch;
import static com.google.common.base.Preconditions.checkArgument;
import static google.registry.request.UrlConnectionUtils.getResponseBytes;
import static jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Ascii;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.ByteSource;
import google.registry.request.Action;
@@ -62,6 +64,8 @@ public final class NordnVerifyAction implements Runnable {
static final String NORDN_URL_PARAM = "nordnUrl";
static final String NORDN_LOG_ID_PARAM = "nordnLogId";
private static final String MARKSDB_URL_BEGINNING = "ry.marksdb.org";
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@Inject LordnRequestInitializer lordnRequestInitializer;
@@ -104,6 +108,12 @@ public final class NordnVerifyAction implements Runnable {
*/
@VisibleForTesting
LordnLog verify() throws IOException, GeneralSecurityException {
String host = Ascii.toLowerCase(url.getHost());
checkArgument(
host.startsWith(MARKSDB_URL_BEGINNING),
"URL %s must start with %s",
url,
MARKSDB_URL_BEGINNING);
logger.atInfo().log("LORDN verify task %s: Sending request to URL %s", actionLogId, url);
HttpURLConnection connection = urlConnectionService.createConnection(url);
lordnRequestInitializer.initialize(connection, tld);
@@ -25,6 +25,7 @@ import google.registry.request.HttpException.BadRequestException;
import google.registry.request.Parameter;
import jakarta.servlet.http.HttpServletRequest;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import org.bouncycastle.openpgp.PGPPublicKey;
@@ -53,7 +54,7 @@ public final class TmchModule {
@Parameter(NordnVerifyAction.NORDN_URL_PARAM)
static URL provideNordnUrl(HttpServletRequest req) {
try {
return new URL(extractRequiredParameter(req, NordnVerifyAction.NORDN_URL_PARAM));
return URI.create(extractRequiredParameter(req, NordnVerifyAction.NORDN_URL_PARAM)).toURL();
} catch (MalformedURLException e) {
throw new BadRequestException("Bad URL: " + NordnVerifyAction.NORDN_URL_PARAM);
}
@@ -14,27 +14,17 @@
package google.registry.tools;
import com.beust.jcommander.Parameter;
import com.beust.jcommander.Parameters;
import google.registry.module.RequestComponent;
import google.registry.request.RouterDisplayHelper;
/** Generates the routing map file used for unit testing. */
@Parameters(commandDescription = "Generate a routing map file")
final class GetRoutingMapCommand implements Command {
@Parameter(
names = {"-c", "--class"},
description =
"Request component class (e.g. google.registry.module.backend.BackendRequestComponent)"
+ " for which routing map should be generated",
required = true
)
private String serviceClassName;
@Override
public void run() throws Exception {
System.out.println(
RouterDisplayHelper.extractHumanReadableRoutesFromComponent(
Class.forName(serviceClassName)));
RouterDisplayHelper.extractHumanReadableRoutesFromComponent(RequestComponent.class));
}
}
@@ -33,6 +33,7 @@ import google.registry.model.domain.Domain;
import google.registry.model.domain.secdns.DomainDsData;
import google.registry.model.host.Host;
import google.registry.model.tld.Tld;
import google.registry.model.tld.Tlds;
import google.registry.request.Action;
import google.registry.request.HttpException.BadRequestException;
import google.registry.request.JsonActionRunner;
@@ -119,6 +120,7 @@ public class GenerateZoneFilesAction implements Runnable, JsonActionRunner.JsonA
public Map<String, Object> handleJsonRequest(Map<String, ?> json) {
@SuppressWarnings("unchecked")
ImmutableSet<String> tlds = ImmutableSet.copyOf((List<String>) json.get("tlds"));
Tlds.assertTldsExist(tlds);
Instant exportTime = Instant.parse(json.get("exportTime").toString());
// We disallow exporting within the past 2 minutes because there might be outstanding writes.
// We can only reliably call loadAtPointInTime at times that are UTC midnight and >
@@ -105,6 +105,7 @@ public class RefreshDnsForAllDomainsAction implements Runnable {
@Override
public void run() {
checkArgument(!tlds.isEmpty(), "Must specify TLDs to refresh");
assertTldsExist(tlds);
checkArgument(batchSize > 0, "Must specify a positive number for batch size");
logger.atInfo().log("Enqueueing DNS refresh tasks for TLDs %s.", tlds);
@@ -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(
@@ -37,6 +37,7 @@ import com.google.gson.annotations.Expose;
import google.registry.config.RegistryConfig.Config;
import google.registry.model.console.ConsolePermission;
import google.registry.model.console.ConsoleUpdateHistory;
import google.registry.model.console.GlobalRole;
import google.registry.model.console.RegistrarRole;
import google.registry.model.console.User;
import google.registry.model.console.UserRoles;
@@ -149,10 +150,15 @@ public class ConsoleUsersAction extends ConsoleApiAction {
throw new BadRequestException("Total users amount per registrar is limited to 4");
}
User userToAppend = verifyUserExists(this.userData.get().emailAddress);
if (userToAppend.getUserRoles().isAdmin()
|| !userToAppend.getUserRoles().getGlobalRole().equals(GlobalRole.NONE)) {
throw new BadRequestException(
"Cannot append a global administrator or user with a global role to a registrar");
}
updateUserRegistrarRoles(
this.userData.get().emailAddress,
registrarId,
requestRoleToAllowedRoles(this.userData.get().role));
userToAppend, registrarId, requestRoleToAllowedRoles(this.userData.get().role));
sendConfirmationEmail(registrarId, this.userData.get().emailAddress, "Added existing user");
consoleApiParams.response().setStatus(SC_OK);
@@ -164,7 +170,14 @@ public class ConsoleUsersAction extends ConsoleApiAction {
}
String email = this.userData.get().emailAddress;
User updatedUser = updateUserRegistrarRoles(email, registrarId, null);
User userToDelete = verifyUserExists(email);
if (userToDelete.getUserRoles().isAdmin()
|| !userToDelete.getUserRoles().getGlobalRole().equals(GlobalRole.NONE)) {
throw new BadRequestException(
"Cannot delete a global administrator or user with a global role");
}
User updatedUser = updateUserRegistrarRoles(userToDelete, registrarId, null);
// User has no registrars assigned
if (updatedUser.getUserRoles().getRegistrarRoles().isEmpty()) {
@@ -251,7 +264,7 @@ public class ConsoleUsersAction extends ConsoleApiAction {
}
updateUserRegistrarRoles(
this.userData.get().emailAddress,
verifyUserExists(this.userData.get().emailAddress),
registrarId,
requestRoleToAllowedRoles(this.userData.get().role));
@@ -297,9 +310,8 @@ public class ConsoleUsersAction extends ConsoleApiAction {
return false;
}
private User updateUserRegistrarRoles(String email, String registrarId, RegistrarRole newRole) {
private User updateUserRegistrarRoles(User user, String registrarId, RegistrarRole newRole) {
Map<String, RegistrarRole> updatedRegistrarRoles;
User user = verifyUserExists(email);
if (newRole == null) {
updatedRegistrarRoles =
user.getUserRoles().getRegistrarRoles().entrySet().stream()
@@ -131,13 +131,12 @@ public class PasswordResetRequestAction extends ConsoleApiAction {
new IllegalArgumentException(
"Unknown user with lock email " + destinationEmail));
// Prevent IDOR: Ensure the resolved user actually belongs to the registrar the requester
// has permissions for, or is a global admin.
if (!targetUser.getUserRoles().isAdmin()
&& !targetUser.getUserRoles().getRegistrarRoles().containsKey(registrarId)) {
throw new IllegalArgumentException(
"User with lock email " + destinationEmail + " is not associated with " + registrarId);
}
// 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;
}
@@ -121,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())) {
@@ -115,6 +115,10 @@ public class XmlTransformer {
// Prevent XXE attacks.
xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xmlInputFactory.setXMLResolver(
(publicID, systemID, baseURI, namespace) -> {
throw new XMLStreamException("Entity resolution disabled.");
});
return xmlInputFactory;
}
@@ -47,6 +47,8 @@ Child elements of the <create> command.
minOccurs="0"/>
<element name="registrant" type="eppcom:clIDType"
minOccurs="0"/>
<element name="contact" type="domain:contactType"
minOccurs="0" maxOccurs="unbounded"/>
<element name="authInfo" type="domain:authInfoType"/>
</sequence>
</complexType>
@@ -98,6 +100,22 @@ If attributes, addresses are optional and follow the
structure defined in the host mapping.
-->
<complexType name="contactType">
<simpleContent>
<extension base="eppcom:clIDType">
<attribute name="type" type="domain:contactAttrType"/>
</extension>
</simpleContent>
</complexType>
<simpleType name="contactAttrType">
<restriction base="token">
<enumeration value="admin"/>
<enumeration value="billing"/>
<enumeration value="tech"/>
</restriction>
</simpleType>
<complexType name="authInfoType">
<choice>
<element name="pw" type="eppcom:pwAuthInfoType"/>
@@ -198,6 +216,8 @@ Data elements that can be added or removed.
<sequence>
<element name="ns" type="domain:nsType"
minOccurs="0"/>
<element name="contact" type="domain:contactType"
minOccurs="0" maxOccurs="unbounded"/>
<element name="status" type="domain:statusType"
minOccurs="0" maxOccurs="11"/>
</sequence>
@@ -299,6 +319,8 @@ Child response elements.
minOccurs="0" maxOccurs="11"/>
<element name="registrant" type="eppcom:clIDType"
minOccurs="0"/>
<element name="contact" type="domain:contactType"
minOccurs="0" maxOccurs="unbounded"/>
<element name="ns" type="domain:nsType"
minOccurs="0"/>
<element name="host" type="eppcom:labelType"
@@ -60,6 +60,9 @@
maxOccurs="unbounded"/>
<element name="registrant"
type="eppcom:clIDType" minOccurs="0"/>
<element name="contact"
type="domain:contactType"
minOccurs="0" maxOccurs="unbounded"/>
<element name="ns"
type="domain:nsType" minOccurs="0"/>
<element name="clID"
@@ -30,7 +30,7 @@ JOIN Registrar r ON b.clientId = r.registrarId
JOIN Domain d ON b.domainRepoId = d.repoId
JOIN Tld t ON t.tldStr = d.tld
LEFT JOIN BillingCancellation c ON b.id = c.billingEvent
LEFT JOIN BillingCancellation cr ON b.cancellationMatchingBillingEvent = cr.billingRecurrence
LEFT JOIN BillingCancellation cr ON b.cancellationMatchingBillingEvent = cr.billingRecurrence AND b.billingTime = cr.billingTime
WHERE r.billingAccountMap IS NOT NULL
AND r.type = 'REAL'
AND t.invoicingEnabled IS TRUE
@@ -229,7 +229,22 @@ class InvoicingPipelineTest {
3,
"USD",
20.5,
""));
""),
google.registry.beam.billing.BillingEvent.create(
17,
Instant.parse("2017-10-04T00:00:00Z"),
Instant.parse("2017-10-04T00:00:00Z"),
"theRegistrar",
"234",
"",
"test",
"RENEW",
"recurrence-collision.test",
"REPO-ID",
3,
"USD",
20.5,
"SYNTHETIC"));
private static final ImmutableMap<String, ImmutableList<String>> EXPECTED_DETAILED_REPORT_MAP =
ImmutableMap.of(
@@ -239,6 +254,8 @@ class InvoicingPipelineTest {
+ "test,RENEW,mydomain2.test,REPO-ID,3,USD,20.50,",
"1,2017-10-04 00:00:00 UTC,2017-10-04 00:00:00 UTC,theRegistrar,234,,"
+ "test,RENEW,mydomain.test,REPO-ID,3,USD,20.50,",
"17,2017-10-04 00:00:00 UTC,2017-10-04 00:00:00 UTC,theRegistrar,234,,"
+ "test,RENEW,recurrence-collision.test,REPO-ID,3,USD,20.50,",
"7,2017-10-04 00:00:00 UTC,2017-10-04 00:00:00 UTC,theRegistrar,234,,"
+ "test,SERVER_STATUS,update-prohibited.test,REPO-ID,0,USD,20.00,",
"6,2017-10-04 00:00:00 UTC,2017-10-04 00:00:00 UTC,theRegistrar,234,,"
@@ -264,7 +281,7 @@ class InvoicingPipelineTest {
private static final ImmutableList<String> EXPECTED_INVOICE_OUTPUT =
ImmutableList.of(
"2017-10-01,2020-09-30,234,61.50,USD,10125,1,PURCHASE,,3,"
"2017-10-01,2020-09-30,234,82.00,USD,10125,1,PURCHASE,,4,"
+ "RENEW | TLD: test | TERM: 3-year,20.50,USD,",
"2017-10-01,2022-09-30,234,70.00,JPY,10125,1,PURCHASE,,1,"
+ "CREATE | TLD: hello | TERM: 5-year,70.00,JPY,",
@@ -398,7 +415,8 @@ JOIN Registrar r ON b.clientId = r.registrarId
JOIN Domain d ON b.domainRepoId = d.repoId
JOIN Tld t ON t.tldStr = d.tld
LEFT JOIN BillingCancellation c ON b.id = c.billingEvent
LEFT JOIN BillingCancellation cr ON b.cancellationMatchingBillingEvent = cr.billingRecurrence
LEFT JOIN BillingCancellation cr ON b.cancellationMatchingBillingEvent = cr.billingRecurrence AND \
b.billingTime = cr.billingTime
WHERE r.billingAccountMap IS NOT NULL
AND r.type = 'REAL'
AND t.invoicingEnabled IS TRUE
@@ -607,6 +625,51 @@ AND cr.id IS NULL
Instant.parse("2017-10-04T00:00:00.0Z"),
Instant.parse("2017-10-02T00:00:00.0Z"));
persistBillingEvent(16, domain15, registrar11, Reason.RENEW, 3, Money.of(USD, 20.5));
// Add a billing event in Year 1 and a cancellation in Year 2 for the same recurrence.
// The Year 1 event should NOT be cancelled.
Domain domain17 = persistActiveDomain("recurrence-collision.test");
DomainHistory domainHistoryCollision = persistDomainHistory(domain17, registrar1);
BillingRecurrence billingRecurrenceCollision =
new BillingRecurrence()
.asBuilder()
.setRegistrarId(registrar1.getRegistrarId())
.setRecurrenceEndTime(END_INSTANT)
.setId(100)
.setDomainHistory(domainHistoryCollision)
.setTargetId(domain17.getDomainName())
.setEventTime(Instant.parse("2017-10-04T00:00:00.0Z"))
.setReason(Reason.RENEW)
.build();
persistResource(billingRecurrenceCollision);
// Year 1 Billing Event (October 2017)
BillingEvent billingEventYear1 =
persistBillingEvent(17, domain17, registrar1, Reason.RENEW, 3, Money.of(USD, 20.5));
billingEventYear1 =
billingEventYear1
.asBuilder()
.setCancellationMatchingBillingEvent(billingRecurrenceCollision)
.setFlags(ImmutableSet.of(Flag.SYNTHETIC))
.setSyntheticCreationTime(Instant.parse("2017-10-03T00:00:00.0Z"))
.build();
persistResource(billingEventYear1);
// Year 2 Billing Cancellation (October 2018)
BillingCancellation cancellationYear2 =
new BillingCancellation()
.asBuilder()
.setId(101)
.setRegistrarId(registrar1.getRegistrarId())
.setEventTime(Instant.parse("2018-10-05T00:00:00.0Z"))
.setBillingTime(Instant.parse("2018-10-04T00:00:00.0Z"))
.setBillingRecurrence(billingRecurrenceCollision.createVKey())
.setTargetId(domain17.getDomainName())
.setReason(Reason.RENEW)
.setDomainHistory(domainHistoryCollision)
.build();
persistResource(cancellationYear2);
}
private static DomainHistory persistDomainHistory(Domain domain, Registrar registrar) {
@@ -116,4 +116,13 @@ class EppXmlSanitizerTest {
String sanitizedXml = sanitizeEppXml(inputXml.getBytes(UTF_16LE));
assertThat(sanitizedXml).isEqualTo(inputXml);
}
@Test
void testSanitize_withDtd_returnsBase64() {
String inputXml = "<!DOCTYPE foo [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]><pw>&xxe;</pw>";
byte[] inputXmlBytes = inputXml.getBytes(UTF_8);
// Since DTDs are disabled, parsing should fail and fallback to base64 encoding of input.
String expectedBase64 = Base64.getMimeEncoder().encodeToString(inputXmlBytes);
assertThat(sanitizeEppXml(inputXmlBytes).trim()).isEqualTo(expectedBase64.trim());
}
}
@@ -81,7 +81,6 @@ import google.registry.flows.EppException;
import google.registry.flows.EppException.UnimplementedExtensionException;
import google.registry.flows.EppRequestSource;
import google.registry.flows.ExtensionManager.UndeclaredServiceExtensionException;
import google.registry.flows.FlowUtils;
import google.registry.flows.FlowUtils.NotLoggedInException;
import google.registry.flows.FlowUtils.UnknownCurrencyEppException;
import google.registry.flows.ResourceFlowTestCase;
@@ -144,6 +143,7 @@ import google.registry.flows.domain.token.AllocationTokenFlowUtils.AllocationTok
import google.registry.flows.domain.token.AllocationTokenFlowUtils.AllocationTokenNotValidForRegistrarException;
import google.registry.flows.domain.token.AllocationTokenFlowUtils.AlreadyRedeemedAllocationTokenException;
import google.registry.flows.domain.token.AllocationTokenFlowUtils.NonexistentAllocationTokenException;
import google.registry.flows.exceptions.ContactsProhibitedException;
import google.registry.flows.exceptions.OnlyToolCanPassMetadataException;
import google.registry.flows.exceptions.ResourceCreateContentionException;
import google.registry.model.billing.BillingBase;
@@ -1937,8 +1937,7 @@ class DomainCreateFlowTest extends ResourceFlowTestCase<DomainCreateFlow, Domain
void testFailure_minimumDataset_noRegistrantButSomeOtherContactTypes() throws Exception {
setEppInput("domain_create_other_contact_types.xml");
persistHosts();
EppException thrown =
assertThrows(FlowUtils.GenericXmlSyntaxErrorException.class, this::runFlow);
EppException thrown = assertThrows(ContactsProhibitedException.class, this::runFlow);
assertAboutEppExceptions().that(thrown).marshalsToXml();
}
@@ -67,7 +67,6 @@ import google.registry.config.RegistryConfig;
import google.registry.flows.EppException;
import google.registry.flows.EppException.UnimplementedExtensionException;
import google.registry.flows.EppRequestSource;
import google.registry.flows.FlowUtils;
import google.registry.flows.FlowUtils.NotLoggedInException;
import google.registry.flows.ResourceFlowTestCase;
import google.registry.flows.ResourceFlowUtils.AddExistingValueException;
@@ -90,6 +89,7 @@ import google.registry.flows.domain.DomainFlowUtils.SecDnsAllUsageException;
import google.registry.flows.domain.DomainFlowUtils.TooManyDsRecordsException;
import google.registry.flows.domain.DomainFlowUtils.TooManyNameserversException;
import google.registry.flows.domain.DomainFlowUtils.UrgentAttributeNotSupportedException;
import google.registry.flows.exceptions.ContactsProhibitedException;
import google.registry.flows.exceptions.OnlyToolCanPassMetadataException;
import google.registry.flows.exceptions.ResourceHasClientUpdateProhibitedException;
import google.registry.flows.exceptions.ResourceStatusProhibitsOperationException;
@@ -283,8 +283,10 @@ class DomainUpdateFlowTest extends ResourceFlowTestCase<DomainUpdateFlow, Domain
// This EPP adds a new technical contact mak21 that wasn't already present.
setEppInput("domain_update_empty_registrant.xml");
persistReferencedEntities();
persistDomain();
// Fails because the update adds some new contacts, although the registrant has been removed.
assertThrows(FlowUtils.GenericXmlSyntaxErrorException.class, this::persistDomain);
EppException thrown = assertThrows(ContactsProhibitedException.class, this::runFlow);
assertAboutEppExceptions().that(thrown).marshalsToXml();
}
private void modifyDomainToHave13Nameservers() throws Exception {
@@ -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>");
}
}
@@ -17,8 +17,8 @@ package google.registry.model.domain;
import static google.registry.testing.DatabaseHelper.persistActiveHost;
import static org.junit.jupiter.api.Assertions.assertThrows;
import google.registry.flows.FlowUtils;
import google.registry.flows.domain.DomainFlowUtils.RegistrantProhibitedException;
import google.registry.flows.exceptions.ContactsProhibitedException;
import google.registry.model.ResourceCommandTestCase;
import google.registry.model.eppinput.EppInput;
import google.registry.model.eppinput.EppInput.ResourceCommandWrapper;
@@ -88,9 +88,10 @@ class DomainCommandTest extends ResourceCommandTestCase {
void testCreate_cloneAndLinkReferences_failsWithContacts() throws Exception {
persistActiveHost("ns1.example.net");
persistActiveHost("ns2.example.net");
DomainCommand.Create create =
(DomainCommand.Create) loadEppResourceCommand("domain_create_with_contacts.xml");
assertThrows(
FlowUtils.GenericXmlSyntaxErrorException.class,
() -> loadEppResourceCommand("domain_create_with_contacts.xml"));
ContactsProhibitedException.class, () -> create.cloneAndLinkReferences(fakeClock.now()));
}
@Test
@@ -138,9 +139,10 @@ class DomainCommandTest extends ResourceCommandTestCase {
void testUpdate_cloneAndLinkReferences_failsWithContacts() throws Exception {
persistActiveHost("ns1.example.com");
persistActiveHost("ns2.example.com");
DomainCommand.Update update =
(DomainCommand.Update) loadEppResourceCommand("domain_update_with_contacts.xml");
assertThrows(
FlowUtils.GenericXmlSyntaxErrorException.class,
() -> loadEppResourceCommand("domain_update_with_contacts.xml"));
ContactsProhibitedException.class, () -> update.cloneAndLinkReferences(fakeClock.now()));
}
@Test
@@ -37,7 +37,7 @@ import google.registry.testing.FakeUrlConnectionService;
import google.registry.util.UrlConnectionException;
import java.io.ByteArrayInputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -88,7 +88,8 @@ public final class UpdateRegistrarRdapBaseUrlsActionTest {
private void assertCorrectRequestSent() throws Exception {
assertThat(urlConnectionService.getConnectedUrls())
.containsExactly(
new URL("https://www.iana.org/assignments/registrar-ids/registrar-ids-1.csv"));
URI.create("https://www.iana.org/assignments/registrar-ids/registrar-ids-1.csv")
.toURL());
verify(connection).setRequestProperty("Accept-Encoding", "gzip");
}
@@ -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);
}
}
@@ -34,7 +34,7 @@ import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationT
import google.registry.testing.FakeUrlConnectionService;
import java.io.ByteArrayOutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URI;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -74,7 +74,7 @@ class IcannHttpReporterTest {
assertThat(reporter.send(FAKE_PAYLOAD, "test-transactions-201706.csv")).isTrue();
assertThat(urlConnectionService.getConnectedUrls())
.containsExactly(new URL("https://fake-transactions.url/test/2017-06"));
.containsExactly(URI.create("https://fake-transactions.url/test/2017-06").toURL());
String userPass = "test_ry:fakePass";
String expectedAuth =
String.format("Basic %s", BaseEncoding.base64().encode(StringUtils.getBytesUtf8(userPass)));
@@ -88,7 +88,7 @@ class IcannHttpReporterTest {
assertThat(reporter.send(FAKE_PAYLOAD, "xn--abc123-transactions-201706.csv")).isTrue();
assertThat(urlConnectionService.getConnectedUrls())
.containsExactly(new URL("https://fake-transactions.url/xn--abc123/2017-06"));
.containsExactly(URI.create("https://fake-transactions.url/xn--abc123/2017-06").toURL());
String userPass = "xn--abc123_ry:fakePass";
String expectedAuth =
String.format("Basic %s", BaseEncoding.base64().encode(StringUtils.getBytesUtf8(userPass)));
@@ -55,7 +55,7 @@ public class GoldenFileTestHelper {
public static GoldenFileTestHelper assertThatRoutesFromComponent(Class<?> component) {
return assertThat(RouterDisplayHelper.extractHumanReadableRoutesFromComponent(component))
.createdByNomulusCommand("get_routing_map -c " + component.getName());
.createdByNomulusCommand("get_routing_map");
}
public GoldenFileTestHelper createdByNomulusCommand(String nomulusCommand) {
@@ -57,7 +57,7 @@ import google.registry.util.UrlConnectionException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URI;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
@@ -249,7 +249,7 @@ class NordnUploadActionTest {
.setRequestProperty(eq(CONTENT_TYPE), startsWith("multipart/form-data; boundary="));
verify(httpUrlConnection).setRequestMethod("POST");
assertThat(httpUrlConnection.getURL())
.isEqualTo(new URL("http://127.0.0.1/LORDN/tld/" + phase));
.isEqualTo(URI.create("http://127.0.0.1/LORDN/tld/" + phase).toURL());
assertThat(connectionOutputStream.toString(UTF_8)).contains(csv);
verifyColumnCleared(domain1);
verifyColumnCleared(domain2);
@@ -38,7 +38,7 @@ import google.registry.testing.FakeResponse;
import google.registry.testing.FakeUrlConnectionService;
import java.io.ByteArrayInputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URI;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -49,33 +49,35 @@ class NordnVerifyActionTest {
private static final String LOG_ACCEPTED =
"""
1,2012-08-16T02:15:00.0Z,2012-08-16T00:00:00.0Z,0000000000000478Nzs+3VMkR8ckuUynOLmyeqTmZQSbzDuf/R50n2n5QX4=,accepted,no-warnings,1
roid,result-code
SH8013-REP,2000""";
1,2012-08-16T02:15:00.0Z,2012-08-16T00:00:00.0Z,0000000000000478Nzs+3VMkR8ckuUynOLmyeqTmZQSbzDuf/R50n2n5QX4=,accepted,no-warnings,1
roid,result-code
SH8013-REP,2000\
""";
private static final String LOG_REJECTED =
"""
1,2012-08-16T02:15:00.0Z,2012-08-16T00:00:00.0Z,0000000000000478Nzs+3VMkR8ckuUynOLmyeqTmZQSbzDuf/R50n2n5QX4=,rejected,no-warnings,1
roid,result-code
SH8013-REP,2001""";
1,2012-08-16T02:15:00.0Z,2012-08-16T00:00:00.0Z,0000000000000478Nzs+3VMkR8ckuUynOLmyeqTmZQSbzDuf/R50n2n5QX4=,rejected,no-warnings,1
roid,result-code
SH8013-REP,2001\
""";
private static final String LOG_WARNINGS =
"""
1,2012-08-16T02:15:00.0Z,2012-08-16T00:00:00.0Z,0000000000000478Nzs+3VMkR8ckuUynOLmyeqTmZQSbzDuf/R50n2n5QX4=,accepted,warnings-present,3
roid,result-code
SH8013-REP,2001
lulz-roid,3609
sabokitty-roid,3610
""";
1,2012-08-16T02:15:00.0Z,2012-08-16T00:00:00.0Z,0000000000000478Nzs+3VMkR8ckuUynOLmyeqTmZQSbzDuf/R50n2n5QX4=,accepted,warnings-present,3
roid,result-code
SH8013-REP,2001
lulz-roid,3609
sabokitty-roid,3610
""";
private static final String LOG_ERRORS =
"""
1,2012-08-16T02:15:00.0Z,2012-08-16T00:00:00.0Z,0000000000000478Nzs+3VMkR8ckuUynOLmyeqTmZQSbzDuf/R50n2n5QX4=,accepted,warnings-present,3
roid,result-code
SH8013-REP,2000
lulz-roid,4601
bogpog,4611
""";
1,2012-08-16T02:15:00.0Z,2012-08-16T00:00:00.0Z,0000000000000478Nzs+3VMkR8ckuUynOLmyeqTmZQSbzDuf/R50n2n5QX4=,accepted,warnings-present,3
roid,result-code
SH8013-REP,2000
lulz-roid,4601
bogpog,4611
""";
@RegisterExtension
final JpaIntegrationTestExtension jpa =
@@ -101,14 +103,15 @@ class NordnVerifyActionTest {
.thenReturn(new ByteArrayInputStream(LOG_ACCEPTED.getBytes(UTF_8)));
action.lordnRequestInitializer = lordnRequestInitializer;
action.response = response;
action.url = new URL("http://127.0.0.1/blobio");
action.url = URI.create("http://ry.marksdb.org/blobio").toURL();
}
@Test
@SuppressWarnings("DirectInvocationOnMock")
void testSuccess_sendHttpRequest_urlIsCorrect() throws Exception {
action.run();
assertThat(httpUrlConnection.getURL()).isEqualTo(new URL("http://127.0.0.1/blobio"));
assertThat(httpUrlConnection.getURL())
.isEqualTo(URI.create("http://ry.marksdb.org/blobio").toURL());
}
@Test
@@ -162,4 +165,22 @@ class NordnVerifyActionTest {
ConflictException thrown = assertThrows(ConflictException.class, action::run);
assertThat(thrown).hasMessageThat().contains("Not ready");
}
@Test
void testFailure_badUrl() throws Exception {
action.url = URI.create("http://example.com/blobio").toURL();
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, action::run);
assertThat(thrown)
.hasMessageThat()
.isEqualTo("URL http://example.com/blobio must start with ry.marksdb.org");
}
@Test
@SuppressWarnings("DirectInvocationOnMock")
void testSuccess_uppercaseUrl() throws Exception {
action.url = URI.create("http://RY.MARKSDB.ORG/blobio").toURL();
action.run();
assertThat(httpUrlConnection.getURL())
.isEqualTo(URI.create("http://RY.MARKSDB.ORG/blobio").toURL());
}
}
@@ -24,7 +24,7 @@ import static org.mockito.Mockito.when;
import google.registry.config.RegistryConfig.ConfigModule.TmchCaMode;
import java.io.ByteArrayInputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URI;
import java.security.SignatureException;
import java.security.cert.CRLException;
import java.security.cert.CertificateNotYetValidException;
@@ -39,7 +39,7 @@ class TmchCrlActionTest extends TmchActionTestCase {
TmchCrlAction action = new TmchCrlAction();
action.marksdb = marksdb;
action.tmchCertificateAuthority = new TmchCertificateAuthority(tmchCaMode, clock);
action.tmchCrlUrl = new URL("https://sloth.lol/tmch.crl");
action.tmchCrlUrl = URI.create("https://sloth.lol/tmch.crl").toURL();
return action;
}
@@ -57,7 +57,7 @@ class TmchCrlActionTest extends TmchActionTestCase {
newTmchCrlAction(TmchCaMode.PILOT).run();
verify(httpUrlConnection).getInputStream();
assertThat(urlConnectionService.getConnectedUrls())
.containsExactly(new URL("https://sloth.lol/tmch.crl"));
.containsExactly(URI.create("https://sloth.lol/tmch.crl").toURL());
}
@Test
@@ -23,6 +23,7 @@ import static google.registry.testing.DatabaseHelper.persistResource;
import static google.registry.testing.TestDataHelper.loadFile;
import static google.registry.util.DateTimeUtils.plusMinutes;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.cloud.storage.BlobId;
import com.google.cloud.storage.contrib.nio.testing.LocalStorageHelper;
@@ -56,6 +57,20 @@ class GenerateZoneFilesActionTest {
private final GcsUtils gcsUtils = new GcsUtils(LocalStorageHelper.getOptions());
@Test
void testGenerate_nonexistentTld_throwsException() {
GenerateZoneFilesAction action = new GenerateZoneFilesAction();
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() ->
action.handleJsonRequest(
ImmutableMap.<String, Object>of(
"tlds", ImmutableList.of("nonexistent-tld"),
"exportTime", Instant.parse("2024-03-27T00:00:00Z"))));
assertThat(thrown).hasMessageThat().contains("TLDs do not exist: nonexistent-tld");
}
@Test
void testGenerate_defaultTtls() throws Exception {
createTlds("tld", "com");
@@ -24,6 +24,7 @@ import static google.registry.testing.DatabaseHelper.createTld;
import static google.registry.testing.DatabaseHelper.persistActiveDomain;
import static google.registry.testing.DatabaseHelper.persistDeletedDomain;
import static google.registry.util.DateTimeUtils.minusYears;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
@@ -151,4 +152,18 @@ public class RefreshDnsForAllDomainsActionTest {
action.run();
assertDnsRequestsWithRequestTime(clock.now(), 11);
}
@Test
void test_runAction_emptyTlds_throwsException() {
action =
new RefreshDnsForAllDomainsAction(
response,
ImmutableSet.of(),
Optional.of(10),
Optional.empty(),
Optional.empty(),
new Random());
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, action::run);
assertThat(thrown).hasMessageThat().isEqualTo("Must specify TLDs to refresh");
}
}
@@ -29,13 +29,20 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSetMultimap;
import google.registry.flows.PasswordOnlyTransportCredentials;
import google.registry.model.console.ConsoleUpdateHistory;
import google.registry.model.console.RegistrarRole;
import google.registry.model.console.User;
import google.registry.model.console.UserRoles;
import google.registry.model.registrar.Registrar;
import google.registry.request.Action;
import google.registry.request.RequestModule;
import google.registry.request.auth.AuthResult;
import google.registry.request.auth.AuthenticatedRegistrarAccessor;
import google.registry.testing.ConsoleApiParamsUtils;
import google.registry.testing.FakeResponse;
import google.registry.ui.server.console.ConsoleEppPasswordAction.EppPasswordData;
import google.registry.util.EmailMessage;
import jakarta.mail.internet.AddressException;
@@ -123,6 +130,36 @@ class ConsoleEppPasswordActionTest extends ConsoleActionBaseTestCase {
assertThat(history.getDescription()).hasValue("TheRegistrar");
}
@Test
void testFailure_noPermission() throws IOException {
User user =
persistResource(
new User.Builder()
.setEmailAddress("no.permission@example.tld")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(
ImmutableMap.of("TheRegistrar", RegistrarRole.ACCOUNT_MANAGER))
.build())
.build());
ConsoleEppPasswordAction action =
createAction(user, "TheRegistrar", "foobar", "randomPassword", "randomPassword");
action.run();
assertThat(response.getStatus()).isEqualTo(SC_FORBIDDEN);
}
private ConsoleEppPasswordAction createAction(
User user,
String registrarId,
String oldPassword,
String newPassword,
String newPasswordRepeat)
throws IOException {
consoleApiParams = ConsoleApiParamsUtils.createFake(AuthResult.createUser(user));
response = (FakeResponse) consoleApiParams.response();
return createAction(registrarId, oldPassword, newPassword, newPasswordRepeat);
}
private ConsoleEppPasswordAction createAction(
String registrarId, String oldPassword, String newPassword, String newPasswordRepeat)
throws IOException {
@@ -407,6 +407,103 @@ class ConsoleUsersActionTest extends ConsoleActionBaseTestCase {
.contains("Can't update user not associated with registrarId TheRegistrar");
}
@Test
void testSuccess_appendUser() throws IOException {
User user = DatabaseHelper.createAdminUser("email@email.com");
AuthResult authResult = AuthResult.createUser(user);
ConsoleUsersAction action =
createAction(
Optional.of(ConsoleApiParamsUtils.createFake(authResult)),
Optional.of("POST"),
Optional.of(
new UserData("test3@test.com", null, RegistrarRole.TECH_CONTACT.name(), null)));
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
User appendedUser = DatabaseHelper.loadByKey(VKey.create(User.class, "test3@test.com"));
assertThat(appendedUser.getUserRoles().getRegistrarRoles().get("TheRegistrar"))
.isEqualTo(RegistrarRole.TECH_CONTACT);
}
@Test
void testFailure_appendUser_globalAdmin() throws IOException {
User user = DatabaseHelper.createAdminUser("email@email.com");
AuthResult authResult = AuthResult.createUser(user);
DatabaseHelper.persistResource(
new User.Builder()
.setEmailAddress("globaladmin@test.com")
.setUserRoles(
new UserRoles.Builder().setIsAdmin(true).setGlobalRole(GlobalRole.NONE).build())
.build());
ConsoleUsersAction action =
createAction(
Optional.of(ConsoleApiParamsUtils.createFake(authResult)),
Optional.of("POST"),
Optional.of(
new UserData(
"globaladmin@test.com", null, RegistrarRole.TECH_CONTACT.name(), null)));
action.run();
assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST);
assertThat(response.getPayload())
.contains("Cannot append a global administrator or user with a global role");
}
@Test
void testFailure_appendUser_globalRole() throws IOException {
User user = DatabaseHelper.createAdminUser("email@email.com");
AuthResult authResult = AuthResult.createUser(user);
DatabaseHelper.persistResource(
new User.Builder()
.setEmailAddress("support@test.com")
.setUserRoles(
new UserRoles.Builder()
.setIsAdmin(false)
.setGlobalRole(GlobalRole.SUPPORT_AGENT)
.build())
.build());
ConsoleUsersAction action =
createAction(
Optional.of(ConsoleApiParamsUtils.createFake(authResult)),
Optional.of("POST"),
Optional.of(
new UserData("support@test.com", null, RegistrarRole.TECH_CONTACT.name(), null)));
action.run();
assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST);
assertThat(response.getPayload())
.contains("Cannot append a global administrator or user with a global role");
}
@Test
void testFailure_deleteUser_globalAdmin() throws IOException {
User user = DatabaseHelper.createAdminUser("email@email.com");
AuthResult authResult = AuthResult.createUser(user);
// Historically associated global admin
DatabaseHelper.persistResource(
new User.Builder()
.setEmailAddress("globaladmin@test.com")
.setUserRoles(
new UserRoles.Builder()
.setIsAdmin(true)
.setGlobalRole(GlobalRole.NONE)
.setRegistrarRoles(
ImmutableMap.of("TheRegistrar", RegistrarRole.PRIMARY_CONTACT))
.build())
.build());
ConsoleUsersAction action =
createAction(
Optional.of(ConsoleApiParamsUtils.createFake(authResult)),
Optional.of("DELETE"),
Optional.of(
new UserData(
"globaladmin@test.com", null, RegistrarRole.ACCOUNT_MANAGER.toString(), null)));
action.run();
assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST);
assertThat(response.getPayload())
.contains("Cannot delete a global administrator or user with a global role");
}
private ConsoleUsersAction createAction(
Optional<ConsoleApiParams> maybeConsoleApiParams,
Optional<String> method,
@@ -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")
@@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkNotNull;
import google.registry.util.UrlChecker;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
@@ -55,11 +56,12 @@ class DockerWebDriverExtension implements BeforeAllCallback, AfterAllCallback {
URL url;
try {
url =
new URL(
String.format(
"http://%s:%d",
container.getContainerIpAddress(),
container.getMappedPort(CHROME_DRIVER_SERVICE_PORT)));
URI.create(
String.format(
"http://%s:%d",
container.getContainerIpAddress(),
container.getMappedPort(CHROME_DRIVER_SERVICE_PORT)))
.toURL();
} catch (MalformedURLException e) {
throw new IllegalArgumentException(e);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 73 KiB

@@ -261,11 +261,11 @@ td.section {
</tr>
<tr>
<td class="property_name">generated on</td>
<td class="property_value">2026-06-23 01:40:35</td>
<td class="property_value">2026-07-06 17:57:29</td>
</tr>
<tr>
<td class="property_name">last flyway file</td>
<td id="lastFlywayFile" class="property_value">V223__tld_change_xap_enabled_to_transitions.sql</td>
<td id="lastFlywayFile" class="property_value">V224__add_registrar_expiry_access_period_enabled.sql</td>
</tr>
</tbody>
</table>
@@ -273,7 +273,7 @@ td.section {
<p>&nbsp;</p>
<svg viewBox="0.00 0.00 4783.00 3613.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="erDiagram" style="overflow: hidden; width: 100%; height: 800px">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 3608.5)">
<title>SchemaCrawler_Diagram</title> <polygon fill="white" stroke="none" points="-4,4 -4,-3608.5 4778.75,-3608.5 4778.75,4 -4,4" /> <text xml:space="preserve" text-anchor="start" x="4535.5" y="-29.2" font-family="Helvetica,sans-Serif" font-size="14.00">generated by</text> <text xml:space="preserve" text-anchor="start" x="4618.25" y="-29.2" font-family="Helvetica,sans-Serif" font-size="14.00">SchemaCrawler 17.11.1</text> <text xml:space="preserve" text-anchor="start" x="4534.75" y="-9.45" font-family="Helvetica,sans-Serif" font-size="14.00">generated on</text> <text xml:space="preserve" text-anchor="start" x="4618.25" y="-9.45" font-family="Helvetica,sans-Serif" font-size="14.00">2026-06-23 01:40:35</text> <polygon fill="none" stroke="#888888" points="4531.75,-4 4531.75,-45.5 4766.75,-45.5 4766.75,-4 4531.75,-4" /> <!-- allocationtoken_a08ccbef -->
<title>SchemaCrawler_Diagram</title> <polygon fill="white" stroke="none" points="-4,4 -4,-3608.5 4778.75,-3608.5 4778.75,4 -4,4" /> <text xml:space="preserve" text-anchor="start" x="4535.5" y="-29.2" font-family="Helvetica,sans-Serif" font-size="14.00">generated by</text> <text xml:space="preserve" text-anchor="start" x="4618.25" y="-29.2" font-family="Helvetica,sans-Serif" font-size="14.00">SchemaCrawler 17.11.1</text> <text xml:space="preserve" text-anchor="start" x="4534.75" y="-9.45" font-family="Helvetica,sans-Serif" font-size="14.00">generated on</text> <text xml:space="preserve" text-anchor="start" x="4618.25" y="-9.45" font-family="Helvetica,sans-Serif" font-size="14.00">2026-07-06 17:57:29</text> <polygon fill="none" stroke="#888888" points="4531.75,-4 4531.75,-45.5 4766.75,-45.5 4766.75,-4 4531.75,-4" /> <!-- allocationtoken_a08ccbef -->
<g id="node1" class="node">
<title>allocationtoken_a08ccbef</title> <polygon fill="#e9c2f2" stroke="none" points="479.25,-1017.62 479.25,-1037.38 664.25,-1037.38 664.25,-1017.62 479.25,-1017.62" /> <text xml:space="preserve" text-anchor="start" x="481.25" y="-1023.08" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">public."AllocationToken"</text> <polygon fill="#e9c2f2" stroke="none" points="664.25,-1017.62 664.25,-1037.38 737.25,-1037.38 737.25,-1017.62 664.25,-1017.62" /> <text xml:space="preserve" text-anchor="start" x="698.5" y="-1022.08" font-family="Helvetica,sans-Serif" font-size="14.00">[table]</text> <text xml:space="preserve" text-anchor="start" x="481.25" y="-1003.33" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">token</text> <text xml:space="preserve" text-anchor="start" x="658.5" y="-1002.33" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="666.25" y="-1002.33" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <text xml:space="preserve" text-anchor="start" x="481.25" y="-982.58" font-family="Helvetica,sans-Serif" font-size="14.00">domain_name</text> <text xml:space="preserve" text-anchor="start" x="658.5" y="-982.58" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="666.25" y="-982.58" font-family="Helvetica,sans-Serif" font-size="14.00">text</text> <text xml:space="preserve" text-anchor="start" x="481.25" y="-962.83" font-family="Helvetica,sans-Serif" font-size="14.00">redemption_domain_repo_id</text> <text xml:space="preserve" text-anchor="start" x="658.5" y="-962.83" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="666.25" y="-962.83" font-family="Helvetica,sans-Serif" font-size="14.00">text</text> <text xml:space="preserve" text-anchor="start" x="481.25" y="-943.08" font-family="Helvetica,sans-Serif" font-size="14.00">token_type</text> <text xml:space="preserve" text-anchor="start" x="658.5" y="-943.08" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="666.25" y="-943.08" font-family="Helvetica,sans-Serif" font-size="14.00">text</text> <polygon fill="none" stroke="#888888" points="478.25,-937.62 478.25,-1038.38 738.25,-1038.38 738.25,-937.62 478.25,-937.62" />
</g>
File diff suppressed because one or more lines are too long
+1
View File
@@ -221,3 +221,4 @@ V220__domain_package_token_idx.sql
V221__remove_contact_history.sql
V222__remove_contact.sql
V223__tld_change_xap_enabled_to_transitions.sql
V224__add_registrar_expiry_access_period_enabled.sql
@@ -0,0 +1,20 @@
-- 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.
-- Add the XAP opt-in column to Registrar, defaulting to false for all existing registrars.
-- To ensure backward compatibility with running servers (old Java code) during
-- the transition phase of the deployment, we set DEFAULT false NOT NULL.
-- TODO(mcilwain): Drop this DEFAULT constraint in a subsequent schema release once the Java code has been fully deployed.
ALTER TABLE "Registrar" ADD COLUMN expiry_access_period_enabled boolean
DEFAULT false NOT NULL;
@@ -855,7 +855,8 @@ CREATE TABLE public."Registrar" (
whois_server text,
last_expiring_cert_notification_sent_date timestamp with time zone,
last_expiring_failover_cert_notification_sent_date timestamp with time zone,
last_poc_verification_date timestamp with time zone
last_poc_verification_date timestamp with time zone,
expiry_access_period_enabled boolean DEFAULT false NOT NULL
);
@@ -9,8 +9,6 @@
<domain:hostObj>ns.fake-domain.tld</domain:hostObj>
</domain:ns>
<domain:registrant>google-mon</domain:registrant>
<domain:contact type="admin">google-mon</domain:contact>
<domain:contact type="tech">google-mon</domain:contact>
<domain:authInfo>
<domain:pw>insecure</domain:pw>
</domain:authInfo>
@@ -185,7 +185,7 @@ public class TokenStore {
if (Duration.between(availableTokens.timestamp(), clock.now())
.compareTo(config.getRefreshPeriod())
>= 0) {
tokensMap.remove(user);
tokensMap.remove(user, availableTokens);
}
});
}