Create a "testing" subproject for faster recompilation (#3246)

This is another instance of a grouping of code that is frequently used and
referred to, but infrequently changed. If it's part of the same
compilation unit as core, then it'll need to get recompiled every time
but if we move it out into a different project we won't need to

Also moves some utility classes (required by testing) from util to
common, and removes the unnecessary core:testJar task
This commit is contained in:
gbrodman
2026-10-07 17:58:24 +00:00
committed by GitHub
parent a6bb0a48f5
commit d1e895c46b
71 changed files with 1144 additions and 904 deletions
@@ -0,0 +1,44 @@
// Copyright 2017 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.testing;
import java.security.Security;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
/**
* JUnit extension for registering {@link BouncyCastleProvider} with Java Security.
*
* <p>This extension is necessary in order to use the {@code "BC"} provider of cryptographic
* functions. Normally you would perform this registration in your {@code main()} function.
*
* @see BouncyCastleProvider
* @see org.junit.jupiter.api.extension.Extension
* @see java.security.Security#addProvider(java.security.Provider)
*/
public class BouncyCastleProviderExtension implements BeforeEachCallback, AfterEachCallback {
@Override
public void beforeEach(ExtensionContext context) {
Security.addProvider(new BouncyCastleProvider());
}
@Override
public void afterEach(ExtensionContext context) {
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME);
}
}
@@ -0,0 +1,132 @@
// Copyright 2017 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.testing;
/** Utility methods for tests that involve certificates. */
public final class CertificateSamples {
/*
* openssl req -new -nodes -x509 -days 10000 -newkey rsa:2048 -keyout client1.key -out client1.crt
* -subj "/C=US/ST=New York/L=New York/O=Google/OU=domain-registry-test/CN=client1"
*/
public static final String SAMPLE_CERT =
"""
-----BEGIN CERTIFICATE-----
MIIDvTCCAqWgAwIBAgIJAK/PgPT0jTwRMA0GCSqGSIb3DQEBCwUAMHUxCzAJBgNV
BAYTAlVTMREwDwYDVQQIDAhOZXcgWW9yazERMA8GA1UEBwwITmV3IFlvcmsxDzAN
BgNVBAoMBkdvb2dsZTEdMBsGA1UECwwUZG9tYWluLXJlZ2lzdHJ5LXRlc3QxEDAO
BgNVBAMMB2NsaWVudDEwHhcNMTUwODI2MTkxODA4WhcNNDMwMTExMTkxODA4WjB1
MQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxETAPBgNVBAcMCE5ldyBZ
b3JrMQ8wDQYDVQQKDAZHb29nbGUxHTAbBgNVBAsMFGRvbWFpbi1yZWdpc3RyeS10
ZXN0MRAwDgYDVQQDDAdjbGllbnQxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAvoE/IoFJyzb0dU4NFhL8FYgy+B/GnUd5aA66CMx5xKRMbEAtIgxU8TTO
W+9jdTsE00Grk3Ct4KdY73CYW+6IFXL4O0K/m5S+uajh+I2UMVZJV38RAIqNxue0
Egv9M4haSsCVIPcX9b+6McywfYSF1bzPb2Gb2FAQO7Jb0BjlPhPMIROCrbG40qPg
LWrl33dz+O52kO+DyZEzHqI55xH6au77sMITsJe+X23lzQcMFUUm8moiOw0EKrj/
GaMTZLHP46BCRoJDAPTNx55seIwgAHbKA2VVtqrvmA2XYJQA6ipdhfKRoJFy8Z8H
DYsorGtazQL2HhF/5uJD25z1m5eQHQIDAQABo1AwTjAdBgNVHQ4EFgQUParEmiSR
U/Oqy8hr7k+MBKhZwVkwHwYDVR0jBBgwFoAUParEmiSRU/Oqy8hr7k+MBKhZwVkw
DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAojsUhF6PtZrStnHBFWNR
ryzvANB8krZlYeX9Hkqn8zIVfAkpbVmL8aZQ7yj17jSpw47PQh3x5gwA9yc/SS0G
E1rGuxYH02UGbua8G0+vviSQfLtskPQzK7EIR63WNhHEo/Q9umLJkZ0LguWEBf3L
q8CoXv2i/RNvqVPcTNp/zCKXJZAa8wAjNRJs834AZj4k5xwyYZ3F8D5PGz+YMOmV
M9Qd+NdXSC/Qn7HQzFhE8p5elBV35P8oX5dXEfn0S7zOXDenp5JvvLoggOWOcKsq
KiWDQrsT+TMKmHL94/h4t7FghtQLMzY5SGYJsYTv/LG8tewrz6KRb/Wj3JNojyEw
Ug==
-----END CERTIFICATE-----
""";
/*
* python -c "import sys;print sys.argv[1].decode('hex').encode('base64').strip('\n=')" $(openssl
* x509 -fingerprint -sha256 -in client1.cert | grep -Po '(?<=Fingerprint=).*' | sed s/://g)
*/
public static final String SAMPLE_CERT_HASH = "vue+ZFJC2R7/LedIDQ53NbMoIMSVpqjEJA1CAJVumos";
/*
* openssl req -new -nodes -x509 -days 10000 -newkey rsa:2048 -keyout client2.key -out client2.crt
* -subj "/C=US/ST=New York/L=New York/O=Google/OU=domain-registry-test/CN=client2"
*/
public static final String SAMPLE_CERT2 =
"""
-----BEGIN CERTIFICATE-----
MIIDvTCCAqWgAwIBAgIJANoEy6mYwalPMA0GCSqGSIb3DQEBCwUAMHUxCzAJBgNV
BAYTAlVTMREwDwYDVQQIDAhOZXcgWW9yazERMA8GA1UEBwwITmV3IFlvcmsxDzAN
BgNVBAoMBkdvb2dsZTEdMBsGA1UECwwUZG9tYWluLXJlZ2lzdHJ5LXRlc3QxEDAO
BgNVBAMMB2NsaWVudDIwHhcNMTUwODI2MTkyODU3WhcNNDMwMTExMTkyODU3WjB1
MQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxETAPBgNVBAcMCE5ldyBZ
b3JrMQ8wDQYDVQQKDAZHb29nbGUxHTAbBgNVBAsMFGRvbWFpbi1yZWdpc3RyeS10
ZXN0MRAwDgYDVQQDDAdjbGllbnQyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAw2FtuDyoR+rUJHp6k7KwaoHGHPV1xnC8IpG9O0SZubOXrFrnBHggBsbu
+DsknbHXjmoihSFFem0KQqJg5y34aDAHXQV3iqa7nDfb1x4oc5voVz9gqjdmGKNm
WF4MTIPNMu8KY52M852mMCxODK+6MZYp7wCmVa63KdCm0bW/XsLgoA/+FVGwKLhf
UqFzt10Cf+87zl4VHrSaJqcHBYM6yAO5lvkr5VC6g8rRQ+dJ+pBT2D99YpSF1aFc
rWbBreIypixZAnXm/Xoogu6RnohS29VCJp2dXFAJmKXGwyKNQFXfEKxZBaBi8uKH
XF459795eyF9xHgSckEgu7jZlxOk6wIDAQABo1AwTjAdBgNVHQ4EFgQUv26AsQyc
kLOjkhqcFLOuueB33l4wHwYDVR0jBBgwFoAUv26AsQyckLOjkhqcFLOuueB33l4w
DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEANBuV+QDISSnGAEHKbR40
zUYdOjdZ399zcFNqTSPHwmE0Qu8pbmXhofpBfjzrcv0tkVbhSLYnT22qhx7aDmhb
bOS8CeVYCwl5eiDTkJly3pRZLzJpy+UT5z8SPxO3MrTqn+wuj0lBpWRTBCWYAUpr
IFRmgVB3IwVb60UIuxhmuk8TVss2SzNrdhdt36eAIPJ0RWEb0KHYHi35Y6lt4f+t
iVk+ZR0cCbHUs7Q1RqREXHd/ICuMRLY/MsadVQ9WDqVOridh198X/OIqdx/p9kvJ
1R80jDcVGNhYVXLmHu4ho4xrOaliSYvUJSCmaaSEGVZ/xE5PI7S6A8RMdj0iXLSt
Bg==
-----END CERTIFICATE-----
""";
/*
* python -c "import sys;print sys.argv[1].decode('hex').encode('base64').strip('\n=')" $(openssl
* x509 -fingerprint -sha256 -in client2.crt | grep -Po '(?<=Fingerprint=).*' | sed s/://g)
*/
public static final String SAMPLE_CERT2_HASH = "GNd6ZP8/n91t9UTnpxR8aH7aAW4+CpvufYx9ViGbcMY";
/*
* openssl req -new -nodes -x509 -days 200 -newkey rsa:2048 -keyout client1.key -out client1.crt
* -subj "/C=US/ST=New York/L=New York/O=Google/OU=domain-registry-test/CN=client1"
*/
public static final String SAMPLE_CERT3 =
"""
-----BEGIN CERTIFICATE-----
MIIDyzCCArOgAwIBAgIUJnhiVrxAxgwkLJzHPm1w/lBoNs4wDQYJKoZIhvcNAQEL
BQAwdTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMREwDwYDVQQHDAhO
ZXcgWW9yazEPMA0GA1UECgwGR29vZ2xlMR0wGwYDVQQLDBRkb21haW4tcmVnaXN0
cnktdGVzdDEQMA4GA1UEAwwHY2xpZW50MTAeFw0yMDEwMTIxNzU5NDFaFw0yMTA0
MzAxNzU5NDFaMHUxCzAJBgNVBAYTAlVTMREwDwYDVQQIDAhOZXcgWW9yazERMA8G
A1UEBwwITmV3IFlvcmsxDzANBgNVBAoMBkdvb2dsZTEdMBsGA1UECwwUZG9tYWlu
LXJlZ2lzdHJ5LXRlc3QxEDAOBgNVBAMMB2NsaWVudDEwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQC0msirO7kXyGEC93stsNYGc02Z77Q2qfHFwaGYkUG8
QvOF5SWN+jwTo5Td6Jj26A26a8MLCtK45TCBuMRNcUsHhajhT19ocphO20iY3zhi
ycwV1id0iwME4kPd1m57BELRE9tUPOxF81/JQXdR1fwT5KRVHYRDWZhaZ5aBmlZY
3t/H9Ly0RBYyApkMaGs3nlb94OOug6SouUfRt02S59ja3wsE2SVF/Eui647OXP7O
QdYXofxuqLoNkE8EnAdl43/enGLiCIVd0G2lABibFF+gbxTtfgbg7YtfUZJdL+Mb
RAcAtuLXEamNQ9H63JgVF16PlQVCDz2XyI3uCfPpDDiBAgMBAAGjUzBRMB0GA1Ud
DgQWBBQ26bWk8qfEBjXs/xZ4m8JZyalnITAfBgNVHSMEGDAWgBQ26bWk8qfEBjXs
/xZ4m8JZyalnITAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAZ
VcsgslBKanKOieJ5ik2d9qzOMXKfBuWPRFWbkC3t9i5awhHqnGAaj6nICnnMZIyt
rdx5lZW5aaQyf0EP/90JAA8Xmty4A6MXmEjQAMiCOpP3A7eeS6Xglgi8IOZl4/bg
LonW62TUkilo5IiFt/QklFTeHIjXB+OvA8+2Quqyd+zp7v6KnhXjvaomim78DhwE
0PIUnjmiRpGpHfTVioTdfhPHZ2Y93Y8K7juL93sQog9aBu5m9XRJCY6wGyWPE83i
kmLfGzjcnaJ6kqCd9xQRFZ0JwHmGlkAQvFoeengbNUqSyjyVgsOoNkEsrWwe/JFO
iqBvjEhJlvRoefvkdR98
-----END CERTIFICATE-----
""";
/*
* python -c "import sys;print sys.argv[1].decode('hex').encode('base64').strip('\n=')" $(openssl
* x509 -fingerprint -sha256 -in client1.crt | grep -Po '(?<=Fingerprint=).*' | sed s/://g)
*/
public static final String SAMPLE_CERT3_HASH = "GM2tYFuzdpDXN0lqpUXlsvrqk8OdMayryV+4/DOFZ0M";
private CertificateSamples() {}
}
@@ -0,0 +1,41 @@
// Copyright 2020 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.testing;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
/**
* A JUnit extension that captures the {@link ExtensionContext} when {@link
* BeforeEachCallback#beforeEach(ExtensionContext)} is called.
*
* <p>This is useful when writing tests of JUnit extensions themselves (very meta), i.e., when you
* want to be able to control the lifecycle of another extension yourself by manually calling the
* before/after callbacks, and need the {@link ExtensionContext} instance to pass to those
* callbacks.
*/
public class ContextCapturingMetaExtension implements BeforeEachCallback {
private ExtensionContext context;
@Override
public void beforeEach(ExtensionContext context) {
this.context = context;
}
public ExtensionContext getContext() {
return this.context;
}
}
@@ -0,0 +1,85 @@
// Copyright 2017 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.testing;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.Lists.charactersOf;
import com.google.common.collect.Iterators;
import google.registry.util.RandomStringGenerator;
import google.registry.util.StringGenerator;
import jakarta.inject.Named;
import java.util.Iterator;
/**
* A string generator that produces strings using sequential characters in its alphabet. This is
* most useful in tests as a "fake" password generator (which would otherwise use
* {@link RandomStringGenerator}.
*
* <p>Note that consecutive calls to createString will continue where the last call left off in
* the alphabet.
*/
public class DeterministicStringGenerator extends StringGenerator {
private Iterator<Character> iterator;
private final Rule rule;
private int counter = 0;
/** String generation rules. */
public enum Rule {
/**
* Simple string generation, cycling through sequential letters in the alphabet. May produce
* duplicates.
*/
DEFAULT,
/**
* Same cyclical pattern as {@link Rule#DEFAULT}, prepending the iteration number and an
* underscore. Intended to avoid duplicates.
*/
PREPEND_COUNTER
}
/**
* Generates a string using sequential characters in the generator's alphabet, cycling back to the
* beginning of the alphabet if necessary.
*/
@Override
public String createString(int length) {
checkArgument(length > 0, "String length must be positive.");
StringBuilder password = new StringBuilder();
for (int i = 0; i < length; i++) {
password.append(iterator.next());
}
return switch (rule) {
case PREPEND_COUNTER -> String.format("%04d_%s", counter++, password.toString());
case DEFAULT -> password.toString();
default ->
throw new IllegalStateException(
String.format("Unknown string generation rule: %s", rule));
};
}
public DeterministicStringGenerator(@Named("alphabetBase64") String alphabet, Rule rule) {
super(alphabet);
iterator = Iterators.cycle(charactersOf(alphabet));
this.rule = rule;
}
public DeterministicStringGenerator(@Named("alphabetBase64") String alphabet) {
this(alphabet, Rule.DEFAULT);
}
}
@@ -0,0 +1,56 @@
// Copyright 2017 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.testing;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
/**
* Display helpful failure message if a mocked method is called.
*
* <p>One important problem this solves is when you mock servlets and the test fails, you usually
* end up with failure messages like {@code Wanted but not invoked: rsp.setStatus(200)} which aren't
* very helpful. This is because servlets normally report problems by calling {@link
* jakarta.servlet.http.HttpServletResponse#sendError(int, String) rsp.sendError()} so it'd be nice
* if we could have the error message be whatever arguments get passed to {@code sendError}.
*
* <p>And that's where {@link FailAnswer} comes to the rescue! Here's an example of what you could
* put at the beginning of a servlet test method to have better error messages:
*
* <pre>{@code
* doAnswer(new FailAnswer<>()).when(rsp).sendError(anyInt());
* doAnswer(new FailAnswer<>()).when(rsp).sendError(anyInt(), anyString());
*
* }</pre>
*
* @param <T> The return type of the mocked method (which doesn't actually return).
*/
public class FailAnswer<T> implements Answer<T> {
@Override
public T answer(@SuppressWarnings("null") InvocationOnMock args) {
StringBuilder msg = new StringBuilder();
boolean first = true;
for (Object arg : args.getArguments()) {
if (first) {
first = false;
} else {
msg.append(", ");
}
msg.append(arg);
}
throw new AssertionError(msg.toString());
}
}
@@ -0,0 +1,99 @@
// Copyright 2017 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.testing;
import static google.registry.util.DateTimeUtils.START_INSTANT;
import google.registry.util.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicLong;
import javax.annotation.concurrent.ThreadSafe;
/** A mock clock for testing purposes that supports telling, setting, and advancing the time. */
@ThreadSafe
public final class FakeClock implements Clock {
private static final long serialVersionUID = 675054721685304599L;
// Clock isn't a thread synchronization primitive, but tests involving
// threads should see a consistent flow.
private final AtomicLong currentTimeMillis = new AtomicLong();
private volatile long autoIncrementStepMs;
/** Creates a FakeClock that starts at START_INSTANT. */
public FakeClock() {
this(START_INSTANT);
}
/** Creates a FakeClock initialized to a specific time. */
public FakeClock(Instant startTime) {
setTo(startTime);
}
@Override
public Instant now() {
return Instant.ofEpochMilli(currentTimeMillis.addAndGet(autoIncrementStepMs));
}
/**
* Sets the increment applied to the clock whenever it is queried. The increment is zero by
* default: the clock is left unchanged when queried.
*
* <p>Passing a duration of zero to this method effectively unsets the auto increment mode.
*
* @param autoIncrementStep the new auto increment duration
* @return this
*/
/**
* Sets the increment applied to the clock whenever it is queried. The increment is zero by
* default: the clock is left unchanged when queried.
*
* <p>Passing a duration of zero to this method effectively unsets the auto increment mode.
*
* @param autoIncrementStep the new auto increment duration
* @return this
*/
public FakeClock setAutoIncrementStep(Duration autoIncrementStep) {
this.autoIncrementStepMs = autoIncrementStep.toMillis();
return this;
}
/** Advances clock by one millisecond. */
public void advanceOneMilli() {
advanceBy(Duration.ofMillis(1));
}
/** Advances clock by some duration. */
public void advanceBy(Duration duration) {
currentTimeMillis.addAndGet(duration.toMillis());
}
/** Sets the time to the specified instant. */
public void setTo(Instant time) {
currentTimeMillis.set(time.toEpochMilli());
}
/** Invokes {@link #setAutoIncrementStep} with one millisecond-step. */
public FakeClock setAutoIncrementByOneMilli() {
return setAutoIncrementStep(Duration.ofMillis(1));
}
/** Disables the auto-increment mode. */
public FakeClock disableAutoIncrement() {
return setAutoIncrementStep(Duration.ZERO);
}
}
@@ -0,0 +1,97 @@
// Copyright 2017 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.testing;
import static com.google.common.base.Preconditions.checkState;
import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpSession;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import javax.annotation.Nullable;
/** A fake {@link HttpSession} that only provides support for getting/setting attributes. */
@SuppressWarnings("deprecation")
public class FakeHttpSession implements HttpSession {
private final Map<String, Object> map = new HashMap<>();
boolean isValid = true;
@Override
public long getCreationTime() {
throw new UnsupportedOperationException();
}
@Override
public String getId() {
throw new UnsupportedOperationException();
}
@Override
public long getLastAccessedTime() {
throw new UnsupportedOperationException();
}
@Override
public ServletContext getServletContext() {
throw new UnsupportedOperationException();
}
@Override
public void setMaxInactiveInterval(int interval) {
throw new UnsupportedOperationException();
}
@Override
public int getMaxInactiveInterval() {
throw new UnsupportedOperationException();
}
@Override
public Object getAttribute(@Nullable String name) {
checkState(isValid, "This session has been invalidated.");
return map.get(name);
}
@Override
public Enumeration<String> getAttributeNames() {
throw new UnsupportedOperationException();
}
@Override
public void setAttribute(@Nullable String name, @Nullable Object value) {
checkState(isValid, "This session has been invalidated.");
map.put(name, value);
}
@Override
public void removeAttribute(@Nullable String name) {
checkState(isValid, "This session has been invalidated.");
map.remove(name);
}
@Override
public void invalidate() {
isValid = false;
map.clear();
}
@Override
public boolean isNew() {
throw new UnsupportedOperationException();
}
}
@@ -0,0 +1,51 @@
// Copyright 2017 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.testing;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import google.registry.util.Sleeper;
import java.io.Serializable;
import java.time.Duration;
import javax.annotation.concurrent.ThreadSafe;
/** Sleeper implementation for unit tests that advances {@link FakeClock} rather than sleep. */
@ThreadSafe
public final class FakeSleeper implements Sleeper, Serializable {
private static final long serialVersionUID = -8975804222581077291L;
private final FakeClock clock;
public FakeSleeper(FakeClock clock) {
this.clock = checkNotNull(clock, "clock");
}
@Override
public void sleep(Duration duration) throws InterruptedException {
checkArgument(!duration.isNegative(), "Duration must be non-negative");
if (Thread.interrupted()) {
throw new InterruptedException();
}
clock.advanceBy(duration);
}
@Override
public void sleepUninterruptibly(Duration duration) {
checkArgument(!duration.isNegative(), "Duration must be non-negative");
clock.advanceBy(duration);
}
}
@@ -0,0 +1,156 @@
// Copyright 2017 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.testing;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.base.Strings.isNullOrEmpty;
import static com.google.common.truth.Truth.assertWithMessage;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.ByteSource;
import com.google.common.io.CharStreams;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Objects;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
/**
* GnuPG system command JUnit extension.
*
* <p>This extension creates a isolated environment for running the {@code gpg} command inside
* system integration tests. It reduces a lot of the boilerplate of setting up the shell environment
* and importing your keyrings into a temporary config folder.
*/
public final class GpgSystemCommandExtension implements BeforeEachCallback, AfterEachCallback {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final File DEV_NULL = new File("/dev/null");
private static final String TEMP_FILE_PREFIX = "gpgtest";
public static final String GPG_BINARY = "gpg2";
private File cwd = DEV_NULL;
private File conf = DEV_NULL;
private String[] env = {};
private final ByteSource publicKeyring;
private final ByteSource privateKeyring;
private final Runtime runtime = Runtime.getRuntime();
/** Constructs a new {@link GpgSystemCommandExtension} instance. */
public GpgSystemCommandExtension(ByteSource publicKeyring, ByteSource privateKeyring) {
this.publicKeyring = checkNotNull(publicKeyring, "publicKeyring");
this.privateKeyring = checkNotNull(privateKeyring, "privateKeyring");
}
/** Returns the temporary directory from which commands are run. */
public File getCwd() {
checkState(!Objects.equals(cwd, DEV_NULL));
return cwd;
}
/** Returns the temporary directory in which GnuPG configs are stored. */
public File getConf() {
checkState(!Objects.equals(conf, DEV_NULL));
return conf;
}
/**
* Runs specified system command and arguments within the GPG testing environment.
*
* @see Runtime#exec(String[])
*/
public Process exec(String... args) throws IOException {
checkState(!Objects.equals(cwd, DEV_NULL));
checkArgument(args.length > 0, "args");
return runtime.exec(args, env, cwd);
}
@Override
public void beforeEach(ExtensionContext context) throws IOException, InterruptedException {
checkState(Objects.equals(cwd, DEV_NULL));
String tmpRootDirString = System.getenv("TMPDIR");
// Create the working directory for the forked process on Temp file system. Create under the
// path specified by 'TMPDIR' envrionment variable if defined, otherwise create under the
// runtime's default (typically /tmp).
cwd =
isNullOrEmpty(tmpRootDirString)
? File.createTempFile(TEMP_FILE_PREFIX, "")
: File.createTempFile(TEMP_FILE_PREFIX, "", new File(tmpRootDirString));
cwd.delete();
cwd.mkdir();
conf = new File(cwd, ".gnupg");
conf.mkdir();
Files.setPosixFilePermissions(conf.toPath(), PosixFilePermissions.fromString("rwx------"));
env =
new String[] {
"PATH=" + System.getenv("PATH"), "GNUPGHOME=" + conf.getAbsolutePath(),
};
Process pid = exec(GPG_BINARY, "--import");
publicKeyring.copyTo(pid.getOutputStream());
pid.getOutputStream().close();
int returnValue = pid.waitFor();
assertWithMessage("Failed to import public keyring: \n%s", slurp(pid.getErrorStream()))
.that(returnValue)
.isEqualTo(0);
pid = exec(GPG_BINARY, "--allow-secret-key-import", "--import");
privateKeyring.copyTo(pid.getOutputStream());
pid.getOutputStream().close();
returnValue = pid.waitFor();
assertWithMessage("Failed to import private keyring: \n%s", slurp(pid.getErrorStream()))
.that(returnValue)
.isEqualTo(0);
}
static void deleteTree(File dir) {
for (File file : dir.listFiles()) {
if (file.isDirectory()) {
deleteTree(file);
} else {
file.delete();
}
}
dir.delete();
}
@Override
public void afterEach(ExtensionContext context) {
// Kill the gpg-agent.
try {
exec("gpgconf", "--homedir", conf.getPath(), "--kill", "gpg-agent");
} catch (IOException e) {
logger.atInfo().log("Unable to kill gpg-agent: %s", e);
}
// Clean up the temporary directory.
deleteTree(cwd);
cwd = DEV_NULL;
conf = DEV_NULL;
}
private String slurp(InputStream is) throws IOException {
return CharStreams.toString(new InputStreamReader(is, UTF_8));
}
}
@@ -0,0 +1,118 @@
// Copyright 2017 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.testing;
import static com.google.common.truth.Truth.assertAbout;
import com.google.common.collect.Sets;
import com.google.common.truth.FailureMetadata;
import com.google.common.truth.SimpleSubjectBuilder;
import com.google.common.truth.Subject;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.Objects;
import java.util.Optional;
/** A Truth subject to show nicer "huge JSON" diffs. */
public class GsonSubject extends Subject {
private JsonObject actual;
private static final Gson GSON =
new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
private String customDisplaySubject;
public GsonSubject(FailureMetadata failureMetadata, JsonObject actual) {
super(failureMetadata, actual);
this.actual = actual;
}
@Override
protected String actualCustomStringRepresentation() {
return Optional.ofNullable(customDisplaySubject).orElse(String.valueOf(actual));
}
public GsonSubject withCustomDisplaySubject(String customDisplaySubject) {
this.customDisplaySubject = customDisplaySubject;
return this;
}
private String jsonifyAndIndent(JsonElement element) {
String json = GSON.toJson(element);
return json.replaceAll("\n", "\n ");
}
private JsonElement getOrNull(JsonArray jsonArray, int index) {
if (index >= jsonArray.size()) {
return null;
}
return jsonArray.get(index);
}
/** Writes down a human-readable diff between actual and expected into the StringBuilder. */
private void diff(String name, JsonElement actual, JsonElement expected, StringBuilder builder) {
if (Objects.equals(actual, expected)) {
return;
}
if (actual == null) {
builder.append(String.format("Missing: %s ->%s\n\n", name, jsonifyAndIndent(expected)));
return;
}
if (expected == null) {
builder.append(String.format("Unexpected: %s -> %s\n\n", name, jsonifyAndIndent(actual)));
return;
}
if (actual.isJsonObject() && expected.isJsonObject()) {
// We put the "expected" first in the union so that the "expected" keys will all be first
// and in order
for (String key :
Sets.union(expected.getAsJsonObject().keySet(), actual.getAsJsonObject().keySet())) {
diff(
name + "." + key,
actual.getAsJsonObject().get(key),
expected.getAsJsonObject().get(key),
builder);
}
return;
}
if (actual.isJsonArray() && expected.isJsonArray()) {
int commonSize = Math.max(actual.getAsJsonArray().size(), expected.getAsJsonArray().size());
for (int i = 0; i < commonSize; i++) {
diff(
String.format("%s[%s]", name, i),
getOrNull(actual.getAsJsonArray(), i),
getOrNull(expected.getAsJsonArray(), i),
builder);
}
return;
}
builder.append(
String.format(
"Actual: %s -> %s\nExpected: %s\n\n",
name, jsonifyAndIndent(actual), jsonifyAndIndent(expected)));
}
public static SimpleSubjectBuilder<GsonSubject, JsonObject> assertAboutJson() {
return assertAbout(jsonObject());
}
public static Factory<GsonSubject, JsonObject> jsonObject() {
return GsonSubject::new;
}
}
@@ -0,0 +1,28 @@
// Copyright 2017 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.testing;
import dagger.Lazy;
/** Helper functions for {@link Lazy}. */
public final class Lazies {
/**
* Returns a {@link Lazy} that supplies a constant value.
*/
public static <T> Lazy<T> of(final T instance) {
return () -> instance;
}
}
@@ -0,0 +1,100 @@
// Copyright 2017 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.testing;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.truth.Truth.assertAbout;
import static com.google.common.truth.Truth.assertWithMessage;
import com.google.common.collect.ImmutableList;
import com.google.common.testing.TestLogHandler;
import com.google.common.truth.Correspondence;
import com.google.common.truth.FailureMetadata;
import com.google.common.truth.SimpleSubjectBuilder;
import com.google.common.truth.StringSubject;
import com.google.common.truth.Subject;
import google.registry.testing.TruthChainer.Which;
import java.util.List;
import java.util.Objects;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
/** Utility methods for asserting things about logging {@link Handler} instances. */
public class LogsSubject extends Subject {
private final TestLogHandler actual;
public LogsSubject(FailureMetadata failureMetadata, TestLogHandler subject) {
super(failureMetadata, subject);
this.actual = subject;
}
private static final Correspondence<String, String> CONTAINS_CORRESPONDENCE =
Correspondence.from(String::contains, "contains");
private static final Correspondence<Throwable, Throwable> THROWABLE_CORRESPONDENCE =
Correspondence.from(
(t1, t2) ->
t1.getClass().equals(t2.getClass()) && t1.getMessage().equals(t2.getMessage()),
"throwableEquivalent");
private List<String> getMessagesAtLevel(Level level) {
ImmutableList.Builder<String> builder = new ImmutableList.Builder<>();
for (LogRecord log : actual.getStoredLogRecords()) {
if (log.getLevel().equals(level)) {
builder.add(log.getMessage());
}
}
return builder.build();
}
public void hasNoLogsAtLevel(Level level) {
check("atLevel(%s)", level).that(getMessagesAtLevel(level)).isEmpty();
}
public void hasSevereLogWithCause(Throwable throwable) {
ImmutableList<Throwable> actualThrowables =
actual.getStoredLogRecords().stream()
.filter(record -> record.getLevel().equals(Level.SEVERE))
.map(LogRecord::getThrown)
.filter(Objects::nonNull)
.collect(toImmutableList());
check("atSevere")
.that(actualThrowables)
.comparingElementsUsing(THROWABLE_CORRESPONDENCE)
.contains(throwable);
}
public Which<StringSubject> hasLogAtLevelWithMessage(Level level, String message) {
List<String> messagesAtLevel = getMessagesAtLevel(level);
check("atLevel(%s)", level)
.that(messagesAtLevel)
.comparingElementsUsing(CONTAINS_CORRESPONDENCE)
.contains(message);
for (String messageCandidate : messagesAtLevel) {
if (messageCandidate.contains(message)) {
return new Which<>(
assertWithMessage("log message at %s matching '%s'", level, message)
.that(messageCandidate));
}
}
throw new AssertionError("Message check passed yet matching message not found");
}
public static SimpleSubjectBuilder<LogsSubject, TestLogHandler> assertAboutLogs() {
return assertAbout(LogsSubject::new);
}
}
@@ -0,0 +1,37 @@
// Copyright 2017 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.testing;
import static com.google.common.base.Preconditions.checkArgument;
import java.lang.reflect.Field;
/** Utility class for extracting encapsulated contents of objects for testing. */
public final class ReflectiveFieldExtractor {
/** Extracts private {@code fieldName} on {@code object} without public getter. */
public static <T> T extractField(Class<T> returnType, Object object, String fieldName)
throws NoSuchFieldException, SecurityException, IllegalAccessException {
Field field = object.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
Object value = field.get(object);
checkArgument(value == null || returnType.isInstance(value));
@SuppressWarnings("unchecked")
T result = (T) value;
return result;
}
private ReflectiveFieldExtractor() {}
}
@@ -0,0 +1,41 @@
// Copyright 2017 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.testing;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
/** Utility class for capturing channel output bytes from google cloud storage library mock. */
public final class SlurpAnswer implements Answer<Integer> {
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
@Override
public Integer answer(@SuppressWarnings("null") InvocationOnMock invocation) {
ByteBuffer bytes = (ByteBuffer) invocation.getArguments()[0];
int count = 0;
while (bytes.hasRemaining()) {
out.write(bytes.get());
++count;
}
return count;
}
public byte[] toByteArray() {
return out.toByteArray();
}
}
@@ -0,0 +1,58 @@
// Copyright 2017 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.testing;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.google.common.flogger.FluentLogger;
import java.io.IOException;
import java.util.concurrent.ExecutionException;
import javax.annotation.concurrent.ThreadSafe;
/** Utility class for getting system information in tests. */
@ThreadSafe
public final class SystemInfo {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final LoadingCache<String, Boolean> hasCommandCache =
Caffeine.newBuilder()
.build(
cmd -> {
try {
Process pid = Runtime.getRuntime().exec(cmd.split(" "));
pid.getOutputStream().close();
pid.waitFor();
} catch (IOException e) {
logger.atWarning().withCause(e).log("%s command not available.", cmd);
return false;
}
return true;
});
/**
* Returns {@code true} if system command can be run from the path.
*
* <p><b>Warning:</b> The command is actually run! So there could be side effects. You might need
* to specify a version flag or something. Return code is ignored.
*
* <p>This result is a memoized. If multiple threads try to get the same result at once, the heavy
* lifting will only be performed by the first thread and the rest will wait.
*/
public static boolean hasCommand(String cmd) throws ExecutionException {
return hasCommandCache.get(cmd);
}
}
@@ -0,0 +1,102 @@
// Copyright 2017 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.testing;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import google.registry.util.SystemPropertySetter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nullable;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
/**
* JUnit extension for overriding the values Java system properties during tests.
*
* <p>In most scenarios this class should be the last extension/extension to apply. In JUnit 5,
* apply {@code @Order(value = Integer.MAX_VALUE)} to the extension.
*/
public final class SystemPropertyExtension
implements SystemPropertySetter, BeforeEachCallback, AfterEachCallback {
/** Class representing a system property key value pair. */
private static class Property {
final String key;
final Optional<String> value;
Property(String key, Optional<String> value) {
this.key = checkNotNull(key, "key");
this.value = checkNotNull(value, "value");
}
void set() {
if (value.isPresent()) {
System.setProperty(key, value.get());
} else {
System.clearProperty(key);
}
}
}
private boolean isRunning = false;
private final List<Property> pendings = new ArrayList<>();
private final Map<String, Property> originals = new HashMap<>();
/**
* Change the value of a system property which is restored to its original value after the test.
*
* <p>It's safe to call this method multiple times with the same {@code key} within a single test.
* Only the truly original property value will be restored at the end.
*
* <p>This method can be called fluently when declaring the Extension field, or within a Test
* method.
*
* @see java.lang.System#setProperty(String, String)
*/
@Override
public SystemPropertyExtension setProperty(String key, @Nullable String value) {
originals.computeIfAbsent(
key, k -> new Property(k, Optional.ofNullable(System.getProperty(k))));
Property property = new Property(key, Optional.ofNullable(value));
if (isRunning) {
property.set();
} else {
pendings.add(property);
}
return this;
}
@Override
public void beforeEach(ExtensionContext context) {
checkState(!isRunning);
for (Property pending : pendings) {
pending.set();
}
isRunning = true;
}
@Override
public void afterEach(ExtensionContext context) {
for (Property original : originals.values()) {
original.set();
}
}
}
@@ -0,0 +1,146 @@
// Copyright 2017 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.testing;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.collect.Maps.newHashMap;
import static google.registry.util.CollectionUtils.nullToEmpty;
import static google.registry.util.ResourceUtils.readResourceBytes;
import static google.registry.util.ResourceUtils.readResourceUtf8;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.ByteSource;
import com.google.common.io.MoreFiles;
import com.google.common.io.Resources;
import java.io.IOException;
import java.net.URI;
import java.nio.file.FileSystemAlreadyExistsException;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.ConcurrentHashMap;
import javax.annotation.Nullable;
/** Contains helper methods for dealing with test data. */
public final class TestDataHelper {
record FileKey(Class<?> context, String filename) {
static FileKey create(Class<?> context, String filename) {
return new FileKey(context, filename);
}
}
private static final Map<FileKey, String> fileCache = new ConcurrentHashMap<>();
private static final Map<FileKey, ByteSource> byteCache = new ConcurrentHashMap<>();
/**
* Returns a copy of the given substitution map, updated with the new keys and values.
*
* <p>If an existing key is given, its value will be overridden with the new value.
*/
public static ImmutableMap<String, String> updateSubstitutions(
@Nullable Map<String, String> baseSubstitutions, String... keysAndValues) {
checkArgument(
keysAndValues.length % 2 == 0,
"keysAndValues must have even number of parameters, but has %s",
keysAndValues.length);
Map<String, String> newSubstitutions = newHashMap(nullToEmpty(baseSubstitutions));
for (int i = 0; i < keysAndValues.length; i += 2) {
newSubstitutions.put(checkNotNull(keysAndValues[i]), checkNotNull(keysAndValues[i + 1]));
}
return ImmutableMap.copyOf(newSubstitutions);
}
/**
* Loads a text file from a directory with a relative path to the location of the specified
* context class under {@code src/test/resources/}.
*/
public static String loadFile(Class<?> context, String filename) {
return fileCache.computeIfAbsent(
FileKey.create(context, filename), k -> readResourceUtf8(context, filename));
}
/**
* Loads a text file from the "testdata" directory relative to the location of the specified
* context class, and substitutes in values for placeholders of the form <code>%tagname%</code>.
*/
public static String loadFile(
Class<?> context, String filename, @Nullable Map<String, String> substitutions) {
String fileContents = loadFile(context, filename);
for (Entry<String, String> entry : nullToEmpty(substitutions).entrySet()) {
fileContents = fileContents.replaceAll("%" + entry.getKey() + "%", entry.getValue());
}
return fileContents;
}
/**
* Loads a {@link ByteSource} from the "testdata" directory relative to the location of the
* specified context class.
*/
public static ByteSource loadBytes(Class<?> context, String filename) {
return byteCache.computeIfAbsent(
FileKey.create(context, filename), k -> readResourceBytes(context, filename));
}
/**
* Returns the "real" location of the file loaded by the other commands, starting from
* src/test/resources/.
*/
public static String filePath(Class<?> context, String filename) {
String packagePath = context.getPackage().getName().replace('.', '/');
return String.format("src/test/resources/%s/%s", packagePath, filename);
}
/**
* Constructs the relative classpath for the given {@code filename} under the {@code context}'s
* package.
*
* <p>For example, if the {@code context} is a class with name com.google.registry.FileClasspath,
* and the given {@code filename} is testdata.txt, then the return value would be
* com/google/registry/testdata.txt.
*
* <p>This function is useful when you just need a relative path starting from the Java root
* package to the given {@code filename}. The other utility functions in this class either return
* an absolute path or a relative path but starting from src/ directory.
*/
public static String fileClassPath(Class<?> context, String filename) {
String packagePath = context.getPackage().getName().replace('.', '/');
return String.format("%s/%s", packagePath, filename);
}
/** Returns a recursive iterable of all files in the given directory. */
public static Iterable<Path> listFiles(Class<?> context, String directory) throws Exception {
URI dir = Resources.getResource(context, directory).toURI();
ensureFileSystemPresentForUri(dir);
return MoreFiles.fileTraverser().breadthFirst(Paths.get(dir));
}
private static void ensureFileSystemPresentForUri(URI uri) throws IOException {
if (uri.getScheme().equals(FileSystems.getDefault().provider().getScheme())) {
// URI maps to default file system (file://...), which must be present. Besides, calling
// FileSystem.newFileSystem on this URI may trigger FileSystemAlreadyExistsException.
return;
}
try {
// URI maps to a special file system, e.g., jar:...
FileSystems.newFileSystem(uri, ImmutableMap.of("create", "true"));
} catch (FileSystemAlreadyExistsException e) {
// ignore.
}
}
}
@@ -0,0 +1,63 @@
// Copyright 2017 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.testing;
import static com.google.common.truth.Truth.assertWithMessage;
import com.google.common.collect.Iterables;
import com.google.common.testing.TestLogHandler;
import google.registry.util.CapturingLogHandler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
/** Utility methods for working with Guava's {@link TestLogHandler}. */
public final class TestLogHandlerUtils {
private TestLogHandlerUtils() {}
/**
* Find the first log message stored in the handler that has the provided prefix, and return that
* message with the prefix stripped off.
*/
public static String findFirstLogMessageByPrefix(TestLogHandler handler, String prefix) {
return findFirstLogRecordWithMessagePrefix(handler, prefix)
.getMessage()
.replaceFirst("^" + prefix, "");
}
/** Returns the first log record stored in handler whose message has the provided prefix. */
public static LogRecord findFirstLogRecordWithMessagePrefix(
TestLogHandler handler, final String prefix) {
return Iterables.find(
handler.getStoredLogRecords(), logRecord -> logRecord.getMessage().startsWith(prefix));
}
/** Assert that the specified log message is <em>not</em> found. */
public static void assertNoLogMessage(CapturingLogHandler handler, Level level, String message) {
for (LogRecord logRecord : handler.getRecords()) {
if (logRecord.getLevel().equals(level) && logRecord.getMessage().contains(message)) {
assertWithMessage("Log message \"%s\" found: %s", message, logRecord.getMessage()).fail();
}
}
}
public static void assertLogMessage(CapturingLogHandler handler, Level level, String message) {
for (LogRecord logRecord : handler.getRecords()) {
if (logRecord.getLevel().equals(level) && logRecord.getMessage().contains(message)) {
return;
}
}
assertWithMessage("Log message \"%s\" not found", message).fail();
}
}
@@ -0,0 +1,49 @@
// Copyright 2017 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.testing;
import com.google.common.truth.Subject;
/** Shim classes to enable fluent chained assertions. */
public final class TruthChainer {
/** Add another assertion. */
public static class And<S extends Subject> {
private final S subject;
public And(S subject) {
this.subject = subject;
}
public S and() {
return subject;
}
}
/** Move the word "which" to after a parameterized assertion. */
public static class Which<S extends Subject> {
private final S subject;
public Which(S subject) {
this.subject = subject;
}
public S which() {
return subject;
}
}
}
@@ -0,0 +1,78 @@
// Copyright 2017 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.testing;
import static com.google.common.base.Preconditions.checkNotNull;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ListMultimap;
import java.net.URLDecoder;
/**
* Utility class for working with <a href="http://goo.gl/OQEc8">application/x-www-form-urlencoded
* content type</a> data.
*/
public final class UriParameters {
/**
* Constructs a new parameter map populated with parameters parsed from the specified query string
* using the specified encoding.
*
* @param query the query string, e.g., {@code "q=flowers&n=20"}
* @return a mutable parameter map representing the query string
*/
public static ListMultimap<String, String> parse(String query) {
checkNotNull(query);
ListMultimap<String, String> map = ArrayListMultimap.create();
if (!query.isEmpty()) {
int start = 0;
while (start <= query.length()) {
// Find the end of the current parameter.
int ampersandIndex = query.indexOf('&', start);
if (ampersandIndex == -1) {
ampersandIndex = query.length();
}
int equalsIndex = query.indexOf('=', start);
if (equalsIndex > ampersandIndex) {
// Equal is in the next parameter, so this parameter has no value.
equalsIndex = -1;
}
int paramNameEndIndex = (equalsIndex == -1) ? ampersandIndex : equalsIndex;
String name = decodeString(query, start, paramNameEndIndex);
String value = (equalsIndex == -1)
? ""
: decodeString(query, equalsIndex + 1, ampersandIndex);
map.put(name, value);
start = ampersandIndex + 1;
}
}
return map;
}
private static String decodeString(String str, int start, int end) {
try {
return URLDecoder.decode(str.substring(start, end), UTF_8);
} catch (IllegalArgumentException iae) {
// According to the javadoc of URLDecoder, when the input string is
// illegal, it could either leave the illegal characters alone or throw
// an IllegalArgumentException! To deal with both consistently, we
// ignore IllegalArgumentException and just return the original string.
return str.substring(start, end);
}
}
private UriParameters() {}
}
@@ -0,0 +1,16 @@
// Copyright 2017 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.
@javax.annotation.ParametersAreNonnullByDefault
package google.registry.testing;
@@ -0,0 +1,63 @@
// Copyright 2017 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.testing.sftp;
import static com.google.common.base.Preconditions.checkState;
import google.registry.util.NetworkUtils;
import java.io.File;
import javax.annotation.Nullable;
import org.apache.ftpserver.FtpServer;
import org.apache.ftpserver.ftplet.FtpException;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
/**
* JUnit extension for creating an in-process {@link TestSftpServer SFTP Server}.
*
* @see TestSftpServer
*/
public final class SftpServerExtension implements AfterEachCallback {
@Nullable
private FtpServer server;
/**
* Starts an SFTP server on a randomly selected port.
*
* @return the port on which the server is listening
*/
public int serve(String user, String pass, File home) throws FtpException {
checkState(server == null, "You already have an SFTP server!");
int port = NetworkUtils.pickUnusedPort();
server = createSftpServer(user, pass, home, port);
return port;
}
@Override
public void afterEach(ExtensionContext context) {
if (server != null) {
server.stop();
server = null;
}
}
private static FtpServer createSftpServer(String user, String pass, File home, int port)
throws FtpException {
FtpServer server = TestSftpServer.createSftpServer(user, pass, null, port, home);
server.start();
return server;
}
}
@@ -0,0 +1,210 @@
// Copyright 2017 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.testing.sftp;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.flogger.FluentLogger;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.security.KeyPair;
import java.security.PublicKey;
import java.security.Security;
import java.util.Arrays;
import javax.annotation.Nullable;
import org.apache.ftpserver.FtpServer;
import org.apache.ftpserver.ftplet.FtpException;
import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
import org.apache.sshd.common.keyprovider.KeyPairProvider;
import org.apache.sshd.common.random.SingletonRandomFactory;
import org.apache.sshd.common.session.SessionContext;
import org.apache.sshd.scp.server.ScpCommandFactory;
import org.apache.sshd.server.ServerBuilder;
import org.apache.sshd.server.SshServer;
import org.apache.sshd.sftp.server.SftpSubsystemFactory;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
/** In-process SFTP server using Apache SSHD. */
public class TestSftpServer implements FtpServer {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static SingletonRandomFactory secureRandomFactory;
static {
Security.addProvider(new BouncyCastleProvider());
}
private static final String HOST_KEY =
"""
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAx7uuoDyMR3c+sIg0fPfBeyoUjPa6hh3yN5S4/HLEOOaXcA2c
uhNm8WwIXF/FwC32CJHZcCC3XhNeQDIpEt5wj9NHr++pjYWjp4Ue+n/2QbDO4LKB
fb67zRbL++BZNswBKYwmzaiHoyWYERj+ZrZpf7hqHnKTAs4NIUCkhUvUHQzgdfT6
vfEYv2HrM2VVVaqpUALrkogZHqQYfLA+InHXXZ3x+GmdSBCxaxUHHZypwETv+yBJ
YAP+9Ofcmu3mpedK/3o3KJ2pD4OXwoXAtbHDOfnJMJylCKcCT3aodpdULSf4A8Zm
o4fxppQCbGUw43D7u18he5qNdLDkgCV8iXewbQIDAQABAoIBAH/Fs9e0BDVvtj3u
VE2hnTeyWsU2zWog3CPsU07ECH0yHqzDOIDdCpzk9JBLgFEJ1fvzebs+Yq+fCktd
C2OTw0Ru78xAMCJl3KS9B21O0PWDK0UZTLdpffCcZc/y3H+ukAvJKcWky2h2E0rU
x2JjzSe0jMZ/m0ZPFJ0yIk1XjhEqWgp3OCP9EHaO82+GM8UTuoBrfOs8gcv6aqiO
L06BU6a6i75chUtZbSadaUnZOE8tIyXit6p63bBHp2oN4D8FiOPBuTDwFEI4zrA4
vNcfRvKQWnQjlWm91BCajNxVqWi7XtWK7ikmwefZWNcd83RSf6vfb8ThpjwHmBaE
dbbL9i0CgYEA+KwB5qiaRxryBI1xqQaH6HiQ5WtW06ft43l2FrTZGPocjyF7KyWn
lS5q0J4tdSSkNfXqhT8aosm3VjfIxoDuQQO0ptD+km5qe1xu/qxLHJYd5FP67X9O
66e8sxcDSuKSvZ6wNeKNOL5SC7dDLCuMZoRTvXxGneg9a1w2x1MVrZsCgYEAzZ56
cqqNvavj4x2yDG+4oAB4/sQvVFK2+PopFpO5w8ezLDVfnoIv56gfmqMN9UdZH8Ew
PJhuEFRcdePo2ZnNjsWf9NhniJSMsEPTIc5qOPyoo5DcQM6DU8f4HC236uR+5P0h
jLfKRpvZl+N3skJi98QQr9PeLyb0sM8zRbZ0fpcCgYA9nuIZtk4EsLioSCSSLfwf
r0C4mRC7AjIA3GhW2Bm0BsZs8W8EEiCk5wuxBoFdNec7N+UVf72p+TJlOw2Vov1n
PvPVIpTy1EmuqAkZMriqLMjbe7QChjmYS8iG2H0IYXzbYCdqMumr1f2eyZrrpx7z
iHb3zYPyPUp7AC7S1dPZYQKBgQCK0p+LQVk3IJFYalkmiltVM1x9bUkjHkFIseUB
yDUYWIDArTxkkTL0rY7A4atv2X7zsIP3tVZCEiLmuTwhhfTBmu3W6jBkhx7Bdtla
LrmKxhK5c/kwi/0gmJcLt1Y/8Ys24SxAjGm16E0tfjb3FFkrPKWjgGC25w83PH06
aOgX+wKBgBLkLh/rwpiD4e8ZVjGuCn9A0H/2KZknXyNbkVjPho0FXBKIlfMa6c34
fRLDvHVZ0xo4dQxp38Wg+ZzIwGoAHhuJpSsfMsWKVXwNwV4iMEt2zyZRomui3TzT
+8awtDyALwvL05EBuxctT3iFGQcUj/fNCi0PeLoTSscdH2pdvHTb
-----END RSA PRIVATE KEY-----
""";
private static final String KEY_TYPE = "ssh-rsa";
private static final KeyPair HOST_KEY_PAIR = createKeyPair(HOST_KEY);
@Nullable
private static KeyPair createKeyPair(String key) {
try (PEMParser pemParser = new PEMParser(new StringReader(key))) {
PEMKeyPair pemPair = (PEMKeyPair) pemParser.readObject();
KeyPair result = new JcaPEMKeyConverter().setProvider("BC").getKeyPair(pemPair);
logger.atInfo().log("Read key pair successfully.");
return result;
} catch (IOException e) {
logger.atSevere().withCause(e).log("Couldn't read key pair from string.");
return null;
}
}
public static FtpServer createSftpServer(
final String authorizedUser,
@Nullable final String authorizedPassword,
@Nullable final PublicKey authorizedPublicKey,
int port,
final File home) {
ServerBuilder serverBuilder = ServerBuilder.builder();
serverBuilder.randomFactory(secureRandomFactory);
if (authorizedPublicKey != null) {
// This authenticator checks that the user is presenting the right key. If authenticate
// returns true, then the server will make sure that the user can prove they have that key.
// Not that you would know this from the Apache javadocs.
serverBuilder.publickeyAuthenticator(
(username, publicKey, session) ->
Arrays.equals(publicKey.getEncoded(), authorizedPublicKey.getEncoded()));
}
serverBuilder.fileSystemFactory(new VirtualFileSystemFactory(home.toPath()));
SshServer server = serverBuilder.build();
server.setCommandFactory(new ScpCommandFactory());
server.setPort(port);
SftpSubsystemFactory sftpSubsystemFactory = new SftpSubsystemFactory.Builder().build();
server.setSubsystemFactories(ImmutableList.of(sftpSubsystemFactory));
if (authorizedPassword != null) {
server.setPasswordAuthenticator(
(username, password, session) ->
username.equals(authorizedUser) && password.equals(authorizedPassword));
}
KeyPairProvider keyPairProvider =
new KeyPairProvider() {
final ImmutableMap<String, KeyPair> keyPairByTypeMap =
ImmutableMap.of(KEY_TYPE, HOST_KEY_PAIR);
@Override
public Iterable<KeyPair> loadKeys(SessionContext context) {
return keyPairByTypeMap.values();
}
@Override
public Iterable<String> getKeyTypes(SessionContext context) {
return keyPairByTypeMap.keySet();
}
@Override
public KeyPair loadKey(SessionContext context, final String type) {
return keyPairByTypeMap.get(type);
}
};
server.setKeyPairProvider(keyPairProvider);
return new TestSftpServer(server);
}
private final SshServer server;
private boolean stopped = true;
private TestSftpServer(SshServer server) {
this.server = server;
}
@Override
public void suspend() {
// NOP
}
@Override
public synchronized void stop() {
try {
logger.atInfo().log("Stopping server");
server.stop(true);
stopped = true;
} catch (IOException e) {
logger.atWarning().withCause(e).log("Error shutting down server");
}
}
@Override
public synchronized void start() throws FtpException {
try {
logger.atInfo().log("Starting server");
server.start();
stopped = false;
} catch (IOException e) {
throw new FtpException("Couldn't start server", e);
}
}
@Override
public void resume() {
// NOP
}
@Override
public boolean isSuspended() {
return false;
}
@Override
public boolean isStopped() {
return stopped;
}
}
@@ -0,0 +1,223 @@
// Copyright 2019 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.testing.truth;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.truth.Truth.assertAbout;
import static com.google.common.truth.Truth.assertWithMessage;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.github.difflib.DiffUtils;
import com.github.difflib.UnifiedDiffUtils;
import com.github.difflib.patch.Patch;
import com.github.difflib.text.DiffRow;
import com.github.difflib.text.DiffRowGenerator;
import com.google.common.base.Ascii;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.io.Resources;
import com.google.common.truth.Fact;
import com.google.common.truth.FailureMetadata;
import com.google.common.truth.SimpleSubjectBuilder;
import com.google.common.truth.Subject;
import com.google.errorprone.annotations.FormatMethod;
import com.google.errorprone.annotations.FormatString;
import java.io.IOException;
import java.net.URL;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/**
* Compares two multi-line text blocks, and displays their diffs in readable formats.
*
* <p>User may choose one of the following diff formats:
*
* <ul>
* <li>{@link DiffFormat#UNIFIED_DIFF} displays the differences in the unified-diff format
* <li>{@link DiffFormat#SIDE_BY_SIDE_MARKDOWN} displays the two text blocks side by side, with
* markdown annotations to highlight the differences.
* </ul>
*
* <p>Note that if one text block has one trailing newline at the end while another has none, this
* difference will not be shown in the generated diffs. This is the case where two texts may be
* reported as unequal but the diffs appear equal. Fixing this requires special treatment of the
* last line of text. The fix would not be useful in our environment, where all important files are
* covered by a style checker that ensures the presence of a trailing newline.
*/
// TODO(weiminyu): move this class and test to a standalone 'testing' project. Note that the util
// project is not good since it depends back to core.
public class TextDiffSubject extends Subject {
private final ImmutableList<String> actual;
private DiffFormat diffFormat = DiffFormat.SIDE_BY_SIDE_MARKDOWN;
private ImmutableList<String> comments = ImmutableList.of();
protected TextDiffSubject(FailureMetadata metadata, List<String> actual) {
super(metadata, actual);
this.actual = ImmutableList.copyOf(actual);
}
protected TextDiffSubject(FailureMetadata metadata, URL actual) {
super(metadata, actual);
try {
this.actual = ImmutableList.copyOf(Resources.asCharSource(actual, UTF_8).readLines());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public TextDiffSubject withDiffFormat(DiffFormat format) {
this.diffFormat = format;
return this;
}
/** If set, ignore lines that start with the given string. */
public TextDiffSubject ignoringLinesStartingWith(String... comments) {
this.comments = ImmutableList.copyOf(comments);
return this;
}
private ImmutableList<String> filterComments(List<String> lines) {
return lines.stream()
.filter(line -> !line.isBlank())
.filter(line -> comments.stream().noneMatch(line::startsWith))
.collect(ImmutableList.toImmutableList());
}
public void hasSameContentAs(List<String> expectedContent) {
checkNotNull(expectedContent, "expectedContent");
ImmutableList<String> filteredExpected = filterComments(expectedContent);
ImmutableList<String> filteredActual = filterComments(actual);
if (filteredExpected.equals(filteredActual)) {
return;
}
String diffString = diffFormat.generateDiff(filteredExpected, filteredActual);
failWithoutActual(
Fact.simpleFact(
Joiner.on('\n')
.join(
"Files differ in content. Displaying " + Ascii.toLowerCase(diffFormat.name()),
diffString)));
}
public void hasSameContentAs(URL resourceUrl) throws IOException {
hasSameContentAs(Resources.asCharSource(resourceUrl, UTF_8).readLines());
}
public static TextDiffSubject assertThat(List<String> actual) {
return assertAbout(textFactory()).that(ImmutableList.copyOf(checkNotNull(actual, "actual")));
}
public static TextDiffSubject assertThat(URL resourceUrl) throws IOException {
return assertThat(Resources.asCharSource(resourceUrl, UTF_8).readLines());
}
@FormatMethod
public static SimpleSubjectBuilder<TextDiffSubject, URL> assertWithMessageAboutUrlSource(
@FormatString String format, Object... params) {
return assertWithMessage(format, params).about(urlFactory());
}
private static final Subject.Factory<TextDiffSubject, ImmutableList<String>>
TEXT_DIFF_SUBJECT_TEXT_FACTORY = TextDiffSubject::new;
public static Subject.Factory<TextDiffSubject, ImmutableList<String>> textFactory() {
return TEXT_DIFF_SUBJECT_TEXT_FACTORY;
}
private static final Subject.Factory<TextDiffSubject, URL> TEXT_DIFF_SUBJECT_URL_FACTORY =
TextDiffSubject::new;
public static Subject.Factory<TextDiffSubject, URL> urlFactory() {
return TEXT_DIFF_SUBJECT_URL_FACTORY;
}
static String generateUnifiedDiff(
ImmutableList<String> expectedContent, ImmutableList<String> actualContent) {
Patch<String> diff = DiffUtils.diff(expectedContent, actualContent);
List<String> unifiedDiff =
UnifiedDiffUtils.generateUnifiedDiff("expected", "actual", expectedContent, diff, 0);
return Joiner.on('\n').join(unifiedDiff);
}
static String generateSideBySideDiff(
ImmutableList<String> expectedContent, ImmutableList<String> actualContent) {
DiffRowGenerator generator =
DiffRowGenerator.create()
.showInlineDiffs(true)
.inlineDiffByWord(true)
.oldTag(f -> "~")
.newTag(f -> "**")
.build();
List<DiffRow> rows = generator.generateDiffRows(expectedContent, actualContent);
int maxExpectedLineLength =
findMaxLineLength(rows.stream().map(DiffRow::getOldLine).collect(Collectors.toList()));
int maxActualLineLength =
findMaxLineLength(rows.stream().map(DiffRow::getNewLine).collect(Collectors.toList()));
SideBySideRowFormatter sideBySideRowFormatter =
new SideBySideRowFormatter(maxExpectedLineLength, maxActualLineLength);
return Joiner.on('\n')
.join(
sideBySideRowFormatter.formatRow("Expected", "Actual", ' '),
sideBySideRowFormatter.formatRow("", "", '-'),
rows.stream()
.map(
row ->
sideBySideRowFormatter.formatRow(row.getOldLine(), row.getNewLine(), ' '))
.toArray());
}
private static int findMaxLineLength(Collection<String> lines) {
return lines.stream()
.max(Comparator.comparingInt(String::length))
.map(String::length)
.orElse(0);
}
private record SideBySideRowFormatter(int maxExpectedLineLength, int maxActualLineLength) {
String formatRow(String expected, String actual, char padChar) {
return String.format(
"|%s|%s|",
Strings.padEnd(expected, maxExpectedLineLength, padChar),
Strings.padEnd(actual, maxActualLineLength, padChar));
}
}
/** The format used to display diffs when two text blocks are different. */
public enum DiffFormat {
UNIFIED_DIFF {
@Override
String generateDiff(ImmutableList<String> expected, ImmutableList<String> actual) {
return generateUnifiedDiff(expected, actual);
}
},
SIDE_BY_SIDE_MARKDOWN {
@Override
String generateDiff(ImmutableList<String> expected, ImmutableList<String> actual) {
return generateSideBySideDiff(expected, actual);
}
};
abstract String generateDiff(ImmutableList<String> expected, ImmutableList<String> actual);
}
}
@@ -0,0 +1,47 @@
// Copyright 2020 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.testing.truth;
import static com.google.common.truth.Truth.assertWithMessage;
import com.google.common.truth.Truth;
import javax.annotation.Nullable;
/** Utils class containing helper functions for {@link Truth}. */
public class TruthUtils {
/** Asserts that both of the given objects are either null or nonnull. */
public static void assertNullnessParity(@Nullable Object thisObj, @Nullable Object thatObj) {
if (thisObj == null) {
assertWithMessage("Expects both objects are null but thatObj is not null")
.that(thatObj)
.isNull();
} else {
assertWithMessage("Expects both objects are not null but thatObj is null")
.that(thatObj)
.isNotNull();
}
}
/** Asserts that both of the given objects are either null or nonnull. */
public static void assertNullnessParity(
@Nullable Object thisObj, @Nullable Object thatObj, String errorMessage) {
if (thisObj == null) {
assertWithMessage(errorMessage).that(thatObj).isNull();
} else {
assertWithMessage(errorMessage).that(thatObj).isNotNull();
}
}
}
@@ -0,0 +1,45 @@
// Copyright 2017 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.util;
import com.google.common.collect.Iterables;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import javax.annotation.Nullable;
/** A log handler that captures logs. */
public final class CapturingLogHandler extends Handler {
private final List<LogRecord> records = new ArrayList<>();
@Override
public void publish(@Nullable LogRecord record) {
if (record != null) {
records.add(record);
}
}
@Override
public void flush() {}
@Override
public void close() {}
public Iterable<LogRecord> getRecords() {
return Iterables.unmodifiableIterable(records);
}
}
@@ -0,0 +1,281 @@
// Copyright 2017 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.xml;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.truth.Truth.assertWithMessage;
import static google.registry.util.DiffUtils.prettyPrintXmlDeepDiff;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.net.InetAddresses;
import com.google.common.net.InternetDomainName;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.AbstractMap;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import org.json.XML;
/** Helper class for unit tests that need XML. */
public class XmlTestUtils {
/**
* Asserts that the two XML strings match.
*
* <p>Note that the actual XML must start with a UTF-8 standalone XML header, but the expected XML
* has no such restriction (and typically lacks the header entirely).
*/
public static void assertXmlEquals(String expected, String actual, String... ignoredPaths)
throws Exception {
assertXmlEqualsWithMessage(expected, actual, "", ignoredPaths);
}
/**
* Asserts that the two XML strings match, but ignoring the XML header.
*
* <p>Do NOT use this for assertions about results of EPP flows, as the XML header is required per
* the EPP spec for those. Rather, use this for raw operations on EPP XMLs, in situations where
* the header may be absent or incorrect (e.g. because you did operations on raw EPP XML directly
* loaded from a file without passing it through an EPP flow).
*/
public static void assertXmlEqualsIgnoreHeader(
String expected, String actual, String... ignoredPaths) throws Exception {
assertXmlEqualsWithMessageHelper(expected, actual, "", ignoredPaths);
}
public static void assertXmlEqualsWithMessage(
String expected, String actual, String message, String... ignoredPaths) throws Exception {
if (!actual.startsWith("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>")) {
assertWithMessage("XML declaration not found at beginning:\n%s", actual).fail();
}
assertXmlEqualsWithMessageHelper(expected, actual, message, ignoredPaths);
}
private static void assertXmlEqualsWithMessageHelper(
String expected, String actual, String message, String... ignoredPaths) throws Exception {
Map<String, Object> expectedMap = toComparableJson(expected, ignoredPaths);
Map<String, Object> actualMap = toComparableJson(actual, ignoredPaths);
if (!expectedMap.equals(actualMap)) {
assertWithMessage(
"%s: Expected:\n%s\n\nActual:\n%s\n\nDiff:\n%s\n\n",
message, expected, actual, prettyPrintXmlDeepDiff(expectedMap, actualMap, null))
.fail();
}
}
/**
* Map an element or attribute name using a namespace map to replace the namespace identifier
* with the complete URI as given in the map. If the name has no namespace identifier, the default
* namespace mapping is used. If the namespace identifier does not exist in the map, the name is
* left unchanged.
*/
private static String mapName(
@Nullable String name, Map<String, String> nsMap, boolean mapDefaultNamespace) {
if (name == null) {
return null;
}
String ns;
String simpleKey;
List<String> components = Splitter.on(':').splitToList(name);
// Handle names without identifiers, meaning they are in the default namespace.
if (components.size() < 2) {
if (!mapDefaultNamespace) {
return name;
}
ns = "";
simpleKey = name;
// Handle names with identifiers.
} else {
ns = components.get(0);
simpleKey = components.get(1);
}
// If the map does not contain the specified identifier (or "" for the default), don't do
// anything.
if (nsMap.containsKey(ns)) {
ns = nsMap.get(ns);
}
return ns.isEmpty() ? simpleKey : (ns + ':' + simpleKey);
}
/**
* Deeply explore the object and normalize values so that things we consider equal compare so.
* The return value consists of two parts: the updated key and the value. The value is
* straightforward enough: it is the rendering of the subtree to be attached at the current point.
* The key is more complicated, because of namespaces. When an XML element specifies namespaces
* using xmlns attributes, those namespaces apply to the element as well as all of its
* descendants. That means that, when prefixing the element name with the full namespace path,
* as required to do proper comparison, the element name depends on its children. When looping
* through a JSONObject map, we can't just recursively generate the value and store it using the
* key. We may have to update the key as well, to get the namespaces correct. A returned key of
* null indicates that we should use the existing key. A non-null key indicates that we should
* replace the existing key.
*
* @param elementName the name under which the current subtree was found, or null if the current
* subtree's name is nonexistent or irrelevant
* @param obj the current subtree
* @param path the (non-namespaced) element path used for ignoredPaths purposes
* @param ignoredPaths the set of paths whose values should be set to IGNORED
* @param nsMap the inherited namespace identifier-to-URI map
* @return the key under which the rendered subtree should be stored (or null), and the rendered
* subtree
*/
private static Map.Entry<String, Object> normalize(
@Nullable String elementName,
Object obj,
@Nullable String path,
Set<String> ignoredPaths,
Map<String, String> nsMap) throws Exception {
if (obj instanceof JSONObject jsonObject) {
Map<String, Object> map = new HashMap<>();
String[] names = JSONObject.getNames(jsonObject);
if (names != null) {
// Separate all elements and keys into namespace specifications, which we must process
// first, and everything else.
ImmutableList.Builder<String> namespacesBuilder = new ImmutableList.Builder<>();
ImmutableList.Builder<String> othersBuilder = new ImmutableList.Builder<>();
for (String key : names) {
(key.startsWith("xmlns") ? namespacesBuilder : othersBuilder).add(key);
}
// First, handle all namespace specifications, updating our ns-to-URI map. Use a HashMap
// rather than an ImmutableMap.Builder so that we can override existing map entries.
HashMap<String, String> newNsMap = new HashMap<>(nsMap);
for (String key : namespacesBuilder.build()) {
// Parse the attribute name, of the form xmlns:nsid, and extract the namespace identifier.
// If there's no colon, we are setting the default namespace.
List<String> components = Splitter.on(':').splitToList(key);
String ns = (components.size() >= 2) ? components.get(1) : "";
newNsMap.put(ns, jsonObject.get(key).toString());
}
nsMap = ImmutableMap.copyOf(newNsMap);
// Now, handle the non-namespace items, recursively transforming the map and mapping all
// namespaces to the full URI for proper comparison.
for (String key : othersBuilder.build()) {
String simpleKey = Iterables.getLast(Splitter.on(':').split(key));
String newPath = (path == null) ? simpleKey : (path + "." + simpleKey);
String mappedKey;
Object value;
if (ignoredPaths.contains(newPath)) {
mappedKey = null;
// Set ignored fields to a value that will compare equal.
value = "IGNORED";
} else {
Map.Entry<String, Object> simpleEntry =
normalize(key, jsonObject.get(key), newPath, ignoredPaths, nsMap);
mappedKey = simpleEntry.getKey();
value = simpleEntry.getValue();
}
if (mappedKey == null) {
// Note that this does not follow the XML rules exactly. I read somewhere that attribute
// names, unlike element names, never use the default namespace. But after
// JSONification, we cannot distinguish between attributes and child elements, so we
// apply the default namespace to everything. Hopefully that will not cause a problem.
mappedKey = key.equals("content") ? key : mapName(key, nsMap, true);
}
map.put(mappedKey, value);
}
}
// Map the namespace of the element name of the map we are normalizing.
elementName = mapName(elementName, nsMap, true);
// If a node has both text content and attributes, the text content will end up under a key
// called "content". If that's the only thing left (which will only happen if there was an
// "xmlns:*" key that we removed), treat the node as just text and recurse.
if (map.size() == 1 && map.containsKey("content")) {
return new AbstractMap.SimpleEntry<>(
elementName,
normalize(null, jsonObject.get("content"), path, ignoredPaths, nsMap).getValue());
}
// The conversion to JSON converts <a/> into "" and the semantically equivalent <a></a> into
// an empty map, so normalize that here.
return new AbstractMap.SimpleEntry<>(elementName, map.isEmpty() ? "" : map);
}
if (obj instanceof JSONArray jsonArray) {
// Another problem resulting from JSONification: If the array contains elements whose names
// are the same before URI expansion, but different after URI expansion, because they use
// xmlns attribute that define the namespaces differently, we will screw up. Again, hopefully
// that doesn't happen much. The reverse is also true: If the array contains names that are
// different before URI expansion, but the same after, we may have a problem, because the
// elements will wind up in different JSONArrays as a result of JSONification. We wave our
// hands and just assume that the URI expansion of the first element holds for all others.
Set<Object> set = new HashSet<>();
String mappedKey = null;
for (int i = 0; i < jsonArray.length(); ++i) {
Map.Entry<String, Object> simpleEntry =
normalize(null, jsonArray.get(i), path, ignoredPaths, nsMap);
if (i == 0) {
mappedKey = simpleEntry.getKey();
}
set.add(simpleEntry.getValue());
}
return new AbstractMap.SimpleEntry<>(mappedKey, set);
}
if (obj instanceof Number) {
return new AbstractMap.SimpleEntry<>(null, obj.toString());
}
if (obj instanceof Boolean b) {
return new AbstractMap.SimpleEntry<>(null, b ? "1" : "0");
}
if (obj instanceof String) {
// Turn stringified booleans into integers. Both are acceptable as xml boolean values, but
// we use "true" and "false" whereas the samples use "1" and "0".
if (obj.equals("true")) {
return new AbstractMap.SimpleEntry<>(null, "1");
}
if (obj.equals("false")) {
return new AbstractMap.SimpleEntry<>(null, "0");
}
String string = obj.toString();
// We use a slightly different datetime format (both legal) than the samples, so normalize
// both into Instant objects.
try {
return new AbstractMap.SimpleEntry<>(
null, Instant.parse(string).truncatedTo(ChronoUnit.SECONDS));
} catch (DateTimeParseException e) {
// It wasn't an Instant.
}
try {
if (!InternetDomainName.isValid(string)) {
// It's not a domain name, but it is an InetAddress. Ergo, it's an ip address.
return new AbstractMap.SimpleEntry<>(null, InetAddresses.forString(string));
}
} catch (IllegalArgumentException e) {
// Not an ip address.
}
return new AbstractMap.SimpleEntry<>(null, string);
}
return new AbstractMap.SimpleEntry<>(null, checkNotNull(obj));
}
@SuppressWarnings("unchecked")
private static Map<String, Object> toComparableJson(
String xml, String... ignoredPaths) throws Exception {
return (Map<String, Object>) normalize(
null,
XML.toJSONObject(xml),
null,
ImmutableSet.copyOf(ignoredPaths),
ImmutableMap.of()).getValue();
}
}
@@ -0,0 +1,104 @@
// Copyright 2019 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.testing.truth;
import static com.google.common.io.Resources.getResource;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.testing.truth.TextDiffSubject.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.io.Resources;
import google.registry.testing.truth.TextDiffSubject.DiffFormat;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link TextDiffSubject}. */
class TextDiffSubjectTest {
private static final String RESOURCE_FOLDER = "google/registry/testing/truth/";
// Resources for input data.
private static final String ACTUAL_RESOURCE = RESOURCE_FOLDER + "text-diff-actual.txt";
private static final String EXPECTED_RESOURCE = RESOURCE_FOLDER + "text-diff-expected.txt";
// Resources for expected diff texts.
private static final String UNIFIED_DIFF_RESOURCE = RESOURCE_FOLDER + "text-unified-diff.txt";
private static final String SIDE_BY_SIDE_DIFF_RESOURCE =
RESOURCE_FOLDER + "text-sidebyside-diff.txt";
@Test
void unifiedDiff_equal() throws IOException {
assertThat(getResource(ACTUAL_RESOURCE))
.withDiffFormat(DiffFormat.UNIFIED_DIFF)
.hasSameContentAs(getResource(ACTUAL_RESOURCE));
}
@Test
void sideBySideDiff_equal() throws IOException {
assertThat(getResource(ACTUAL_RESOURCE))
.withDiffFormat(DiffFormat.SIDE_BY_SIDE_MARKDOWN)
.hasSameContentAs(getResource(ACTUAL_RESOURCE));
}
@Test
void unifedDiff_notEqual() throws IOException {
assertThrows(
AssertionError.class,
() ->
assertThat(getResource(ACTUAL_RESOURCE))
.withDiffFormat(DiffFormat.UNIFIED_DIFF)
.hasSameContentAs(getResource(EXPECTED_RESOURCE)));
}
@Test
void sideBySideDiff_notEqual() throws IOException {
assertThrows(
AssertionError.class,
() ->
assertThat(getResource(ACTUAL_RESOURCE))
.withDiffFormat(DiffFormat.SIDE_BY_SIDE_MARKDOWN)
.hasSameContentAs(getResource(EXPECTED_RESOURCE)));
}
@Test
void displayed_unifiedDiff_noDiff() throws IOException {
ImmutableList<String> actual = readAllLinesFromResource(ACTUAL_RESOURCE);
assertThat(TextDiffSubject.generateUnifiedDiff(actual, actual)).isEqualTo("");
}
@Test
void displayed_unifiedDiff_hasDiff() throws IOException {
ImmutableList<String> actual = readAllLinesFromResource(ACTUAL_RESOURCE);
ImmutableList<String> expected = readAllLinesFromResource(EXPECTED_RESOURCE);
String diff = Joiner.on('\n').join(readAllLinesFromResource(UNIFIED_DIFF_RESOURCE));
assertThat(TextDiffSubject.generateUnifiedDiff(expected, actual)).isEqualTo(diff);
}
@Test
void displayed_sideBySideDiff_hasDiff() throws IOException {
ImmutableList<String> actual = readAllLinesFromResource(ACTUAL_RESOURCE);
ImmutableList<String> expected = readAllLinesFromResource(EXPECTED_RESOURCE);
String diff = Joiner.on('\n').join(readAllLinesFromResource(SIDE_BY_SIDE_DIFF_RESOURCE));
assertThat(TextDiffSubject.generateSideBySideDiff(expected, actual)).isEqualTo(diff);
}
private static ImmutableList<String> readAllLinesFromResource(String resourceName)
throws IOException {
return ImmutableList.copyOf(
Resources.readLines(getResource(resourceName), StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,52 @@
// Copyright 2017 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.xml;
import static google.registry.testing.TestDataHelper.loadFile;
import static google.registry.xml.XmlTestUtils.assertXmlEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link XmlTestUtils}. */
class XmlTestUtilsTest {
void runTest(String file1, String file2) throws Exception {
assertXmlEquals(loadFile(getClass(), file1), loadFile(getClass(), file2));
}
@Test
void testSelfEquality() throws Exception {
runTest("simple.xml", "simple.xml");
}
@Test
void testInequality() {
assertThrows(
AssertionError.class, () -> runTest("simple.xml", "twoextensions_feeThenLaunch.xml"));
}
@Test
void testMultipleElementsInDifferentNamespaces() throws Exception {
runTest("twoextensions_feeThenLaunch.xml", "twoextensions_launchThenFee.xml");
}
@Test
void testMultipleElementsInDifferentNamespaces_differentValues() {
assertThrows(
AssertionError.class,
() -> runTest("twoextensions_feeThenLaunch.xml", "twoextensions_feeThenLaunch2.xml"));
}
}
@@ -0,0 +1,2 @@
This is a random file,
with two lines and terminates with a newline.
@@ -0,0 +1,2 @@
This is a random file,
with three lines and terminates without a newline.
@@ -0,0 +1,4 @@
|Expected |Actual |
|------------------------------------------------------|-----------------------------------------------------|
|This is a random file, |This is a random file, |
|with ~three~ lines and terminates ~without~ a newline.|with **two** lines and terminates **with** a newline.|
@@ -0,0 +1,5 @@
--- expected
+++ actual
@@ -2,1 +2,1 @@
-with three lines and terminates without a newline.
+with two lines and terminates with a newline.
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<test xmlns="urn:ietf:params:xml:ns:fake">
<response>
<result code="1000">
<msg>Command completed successfully</msg>
</result>
<resData>
<green:creData xmlns:green="urn:ietf:params:xml:ns:color">
<green:name>example.tld</green:name>
<green:crDate>1999-04-03T22:00:00.0Z</green:crDate>
<green:exDate>2001-04-03T22:00:00.0Z</green:exDate>
</green:creData>
</resData>
<trID>
<clTRID>ABC-12345</clTRID>
<svTRID>server-trid</svTRID>
</trID>
</response>
</test>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<test xmlns="urn:ietf:params:xml:ns:fake">
<response>
<result code="1000">
<msg>Command completed successfully</msg>
</result>
<resData>
<red:creData xmlns:red="urn:ietf:params:xml:ns:color">
<red:name>example.tld</red:name>
<red:crDate>1999-04-03T22:00:00.0Z</red:crDate>
<red:exDate>2001-04-03T22:00:00.0Z</red:exDate>
</red:creData>
</resData>
<extension>
<fee:creData xmlns:fee="urn:ietf:params:xml:ns:fee">
<fee:currency>USD</fee:currency>
<fee:fee description="create">26.00</fee:fee>
</fee:creData>
<launch:creData xmlns:launch="urn:ietf:params:xml:ns:launch">
<launch:phase name="landrush">sunrise</launch:phase>
<launch:applicationID>1-TLD</launch:applicationID>
</launch:creData>
</extension>
<trID>
<clTRID>ABC-12345</clTRID>
<svTRID>server-trid</svTRID>
</trID>
</response>
</test>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<test xmlns="urn:ietf:params:xml:ns:fake">
<response>
<result code="1000">
<msg>Command completed successfully</msg>
</result>
<resData>
<blue:creData xmlns:blue="urn:ietf:params:xml:ns:color">
<blue:name>example.tld</blue:name>
<blue:crDate>1999-04-03T22:00:00.0Z</blue:crDate>
<blue:exDate>2001-04-03T22:00:00.0Z</blue:exDate>
</blue:creData>
</resData>
<extension>
<fee:creData xmlns:fee="urn:ietf:params:xml:ns:fee">
<fee:currency>USD</fee:currency>
<fee:fee description="create">26.00</fee:fee>
</fee:creData>
<launch:creData xmlns:launch="urn:ietf:params:xml:ns:launch">
<launch:applicationID>2-TLD</launch:applicationID>
</launch:creData>
</extension>
<trID>
<clTRID>ABC-12345</clTRID>
<svTRID>server-trid</svTRID>
</trID>
</response>
</test>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<test xmlns="urn:ietf:params:xml:ns:fake">
<response>
<result code="1000">
<msg>Command completed successfully</msg>
</result>
<resData>
<orange:creData xmlns:orange="urn:ietf:params:xml:ns:color">
<orange:name>example.tld</orange:name>
<orange:crDate>1999-04-03T22:00:00.0Z</orange:crDate>
<orange:exDate>2001-04-03T22:00:00.0Z</orange:exDate>
</orange:creData>
</resData>
<extension>
<launch:creData xmlns:launch="urn:ietf:params:xml:ns:launch">
<launch:phase name="landrush">sunrise</launch:phase>
<launch:applicationID>1-TLD</launch:applicationID>
</launch:creData>
<fee:creData xmlns:fee="urn:ietf:params:xml:ns:fee">
<fee:currency>USD</fee:currency>
<fee:fee description="create">26.00</fee:fee>
</fee:creData>
</extension>
<trID>
<clTRID>ABC-12345</clTRID>
<svTRID>server-trid</svTRID>
</trID>
</response>
</test>