From 6ca1856b49edd821426fb71485035bfa6fceaebf Mon Sep 17 00:00:00 2001 From: Kevin Doran Date: Tue, 24 Oct 2017 20:42:36 -0400 Subject: [PATCH] Refactor integration test framework to sandbox configuration classes in Spring profiles. --- nifi-registry-framework/pom.xml | 5 + nifi-registry-web-api/pom.xml | 6 + .../nifi/registry/web/api/BucketsIT.java | 6 +- .../apache/nifi/registry/web/api/FlowsIT.java | 7 +- .../registry/web/api/IntegrationTestBase.java | 138 ++++++++++++++--- .../nifi/registry/web/api/SecureFileIT.java | 92 +++++++++++ .../web/api/SecureITClientConfiguration.java | 91 +++++++++++ .../registry/web/api/UnsecuredITBase.java | 42 +++++ .../web/api/UnsecuredIntegrationTestBase.java | 50 ------ .../application-ITSecureFile.properties | 36 +++++ .../application-ITUnsecured.properties | 21 +++ .../src/test/resources/application.properties | 2 + .../conf/secure-file/authorizers.xml | 143 ++++++++++++++++++ .../nifi-registry-client.properties | 25 +++ .../conf/secure-file/nifi-registry.properties | 33 ++++ .../src/test/resources/keys/README.md | 46 ++++++ .../src/test/resources/keys/client-ks.jks | Bin 0 -> 3048 bytes .../src/test/resources/keys/localhost-ks.jks | Bin 0 -> 3077 bytes .../src/test/resources/keys/localhost-ts.jks | Bin 0 -> 911 bytes 19 files changed, 658 insertions(+), 85 deletions(-) create mode 100644 nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureFileIT.java create mode 100644 nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureITClientConfiguration.java create mode 100644 nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredITBase.java delete mode 100644 nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredIntegrationTestBase.java create mode 100644 nifi-registry-web-api/src/test/resources/application-ITSecureFile.properties create mode 100644 nifi-registry-web-api/src/test/resources/application-ITUnsecured.properties create mode 100644 nifi-registry-web-api/src/test/resources/conf/secure-file/authorizers.xml create mode 100644 nifi-registry-web-api/src/test/resources/conf/secure-file/nifi-registry-client.properties create mode 100644 nifi-registry-web-api/src/test/resources/conf/secure-file/nifi-registry.properties create mode 100644 nifi-registry-web-api/src/test/resources/keys/README.md create mode 100644 nifi-registry-web-api/src/test/resources/keys/client-ks.jks create mode 100644 nifi-registry-web-api/src/test/resources/keys/localhost-ks.jks create mode 100644 nifi-registry-web-api/src/test/resources/keys/localhost-ts.jks diff --git a/nifi-registry-framework/pom.xml b/nifi-registry-framework/pom.xml index 68a7d8f57..117918812 100644 --- a/nifi-registry-framework/pom.xml +++ b/nifi-registry-framework/pom.xml @@ -199,5 +199,10 @@ 2.2.2 test + + org.apache.nifi.registry + nifi-registry-security-utils + 0.0.1-SNAPSHOT + diff --git a/nifi-registry-web-api/pom.xml b/nifi-registry-web-api/pom.xml index caa74b650..0174bd579 100644 --- a/nifi-registry-web-api/pom.xml +++ b/nifi-registry-web-api/pom.xml @@ -205,5 +205,11 @@ ${spring.boot.version} test + + org.apache.nifi.registry + nifi-registry-client + 0.0.1-SNAPSHOT + test + diff --git a/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/BucketsIT.java b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/BucketsIT.java index 88ce2dab7..15477f946 100644 --- a/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/BucketsIT.java +++ b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/BucketsIT.java @@ -21,8 +21,6 @@ import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.test.context.jdbc.Sql; -import javax.ws.rs.client.Client; -import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.client.Entity; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; @@ -31,9 +29,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; -public class BucketsIT extends UnsecuredIntegrationTestBase { - - private final Client client = ClientBuilder.newClient(); +public class BucketsIT extends UnsecuredITBase { @Test public void testGetBucketsEmpty() throws Exception { diff --git a/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/FlowsIT.java b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/FlowsIT.java index 630a07f18..0a238b26c 100644 --- a/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/FlowsIT.java +++ b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/FlowsIT.java @@ -26,8 +26,6 @@ import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.test.context.jdbc.Sql; -import javax.ws.rs.client.Client; -import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.client.Entity; import javax.ws.rs.client.WebTarget; import javax.ws.rs.core.MediaType; @@ -38,11 +36,8 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; - @Sql(executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, scripts = {"classpath:db/clearDB.sql", "classpath:db/FlowsIT.sql"}) -public class FlowsIT extends UnsecuredIntegrationTestBase { - - private final Client client = ClientBuilder.newClient(); +public class FlowsIT extends UnsecuredITBase { @Test public void testGetFlowsEmpty() throws Exception { diff --git a/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/IntegrationTestBase.java b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/IntegrationTestBase.java index 8be31bbc6..5b1207463 100644 --- a/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/IntegrationTestBase.java +++ b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/IntegrationTestBase.java @@ -16,30 +16,28 @@ */ package org.apache.nifi.registry.web.api; -import org.apache.nifi.registry.NiFiRegistryApiTestApplication; +import org.apache.nifi.registry.client.NiFiRegistryClientConfig; import org.apache.nifi.registry.properties.NiFiRegistryProperties; -import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.embedded.LocalServerPort; import org.springframework.boot.context.embedded.jetty.JettyEmbeddedServletContainerFactory; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.context.jdbc.Sql; -import org.springframework.test.context.junit4.SpringRunner; + +import javax.annotation.PostConstruct; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; +import java.io.FileReader; +import java.io.IOException; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; /** - * Deploy the Web API Application using an embedded Jetty Server for local integration testing, with the follow characteristics: - * - * - A NiFiRegistryProperties Bean has to be explicitly provided to the application context, i.e. using an inline @TestConfiguration class - * - @DirtiesContext is providing that each (sub)class gets a fresh context - * - The database is embed H2 using volatile (in-memory) persistence - * - Custom SQL is clearing the DB before each test method by default, unless method overrides this behavior + * A base class to simplify creating integration tests against an API application running with an embedded server and volatile DB. */ -@RunWith(SpringRunner.class) -@SpringBootTest(classes = NiFiRegistryApiTestApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) -@Sql(executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, scripts = "classpath:db/clearDB.sql") public abstract class IntegrationTestBase { private static final String CONTEXT_PATH = "/nifi-registry-api-test"; @@ -47,6 +45,14 @@ public abstract class IntegrationTestBase { @TestConfiguration public static class TestConfigurationClass { + /* REQUIRED: Any subclass extending IntegrationTestBase must add a Spring profile that defines a + * property value for this key containing the path to the nifi-registy.properties file to use to + * create a NiFiRegistryProperties Bean in the ApplicationContext. */ + @Value("${nifi.registry.properties.file}") + private String propertiesFileLocation; + + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private final Lock readLock = lock.readLock(); private NiFiRegistryProperties testProperties; @Bean @@ -56,23 +62,107 @@ public JettyEmbeddedServletContainerFactory jettyEmbeddedServletContainerFactory return jettyContainerFactory; } + @Bean + public NiFiRegistryProperties getNiFiRegistryProperties() { + readLock.lock(); + try { + if (testProperties == null) { + testProperties = loadNiFiRegistryProperties(propertiesFileLocation); + } + } finally { + readLock.unlock(); + } + return testProperties; + } + } + @Autowired + private NiFiRegistryProperties properties; + + /* OPTIONAL: Any subclass that extends this base class MAY provide or specify a @TestConfiguration that provides a + * NiFiRegistryClientConfig @Bean. The properties specified should correspond with the integration test cases in + * the concrete subclass. See SecureFileIT for an example. */ + @Autowired(required = false) + private NiFiRegistryClientConfig clientConfig; + + /* This will be injected with the random port assigned to the embedded Jetty container. */ @LocalServerPort - int port; + private int port; + + /** + * Subclasses can access this auto-configured JAX-RS client to communicate to the NiFi Registry Server + */ + protected Client client; + + @PostConstruct + void initialize() { + if (this.clientConfig != null) { + this.client = createClientFromConfig(this.clientConfig); + } else { + this.client = ClientBuilder.newClient(); + } + + } - String createURL(String resourcePathRelativeToBaseUrl) { - if (resourcePathRelativeToBaseUrl == null) { + /** + * Subclasses can utilize this method to build a URL that has the correct protocol, hostname, and port + * for a given path. + * + * @param relativeResourcePath the path component of the resource you wish to access, relative to the + * base API URL, where the base includes the servlet context path. + * + * @return a String containing the absolute URL of the resource. + */ + String createURL(String relativeResourcePath) { + if (relativeResourcePath == null) { throw new IllegalArgumentException("Resource path cannot be null"); } - StringBuilder baseUri = new StringBuilder().append("http://localhost:").append(port).append(CONTEXT_PATH); - if (!resourcePathRelativeToBaseUrl.startsWith("/")) { - baseUri.append('/'); + final boolean isSecure = this.properties.getSslPort() != null; + final String protocolSchema = isSecure ? "https" : "http"; + + final StringBuilder baseUriBuilder = new StringBuilder() + .append(protocolSchema).append("://localhost:").append(port).append(CONTEXT_PATH); + + if (!relativeResourcePath.startsWith("/")) { + baseUriBuilder.append('/'); + } + baseUriBuilder.append(relativeResourcePath); + + return baseUriBuilder.toString(); + } + + /** + * A helper method for loading NiFiRegistryProperties by reading *.properties files from disk. + * + * @param propertiesFilePath The location of the properties file + * @return A NiFIRegistryProperties instance based on the properties file contents + */ + static NiFiRegistryProperties loadNiFiRegistryProperties(String propertiesFilePath) { + NiFiRegistryProperties properties = new NiFiRegistryProperties(); + try (final FileReader reader = new FileReader(propertiesFilePath)) { + properties.load(reader); + } catch (final IOException ioe) { + throw new RuntimeException("Unable to load properties: " + ioe, ioe); + } + return properties; + } + + private static Client createClientFromConfig(NiFiRegistryClientConfig registryClientConfig) { + + final SSLContext sslContext = registryClientConfig.getSslContext(); + final HostnameVerifier hostnameVerifier = registryClientConfig.getHostnameVerifier(); + + final ClientBuilder clientBuilder = ClientBuilder.newBuilder(); + if (sslContext != null) { + clientBuilder.sslContext(sslContext); + } + if (hostnameVerifier != null) { + clientBuilder.hostnameVerifier(hostnameVerifier); } - baseUri.append(resourcePathRelativeToBaseUrl); - return baseUri.toString(); + return clientBuilder.build(); } } diff --git a/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureFileIT.java b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureFileIT.java new file mode 100644 index 000000000..d04e79b96 --- /dev/null +++ b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureFileIT.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.registry.web.api; + +import org.apache.nifi.registry.NiFiRegistryApiTestApplication; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit4.SpringRunner; + +import javax.ws.rs.core.Response; + +import static org.junit.Assert.assertEquals; + +/** + * Deploy the Web API Application using an embedded Jetty Server for local integration testing, with the follow characteristics: + * + * - A NiFiRegistryProperties has to be explicitly provided to the ApplicationContext using a profile unique to this test suite. + * - A NiFiRegistryClientConfig has been configured to create a client capable of completing two-way TLS + * - The database is embed H2 using volatile (in-memory) persistence + * - Custom SQL is clearing the DB before each test method by default, unless method overrides this behavior + */ +@RunWith(SpringRunner.class) +@SpringBootTest( + classes = NiFiRegistryApiTestApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.include=ITSecureFile") +@Import(SecureITClientConfiguration.class) +@Sql(executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, scripts = "classpath:db/clearDB.sql") +public class SecureFileIT extends IntegrationTestBase { + + @Test + public void testAccessStatus() throws Exception { + + // Given: the client and server have been configured correctly for two-way TLS + String expectedJson = "{" + + "\"identity\":\"CN=user1, OU=nifi\"," + + "\"status\":\"ACTIVE\"" + + "}"; + + // When: the /access endpoint is queried + final Response response = client + .target(createURL("access")) + .request() + .get(Response.class); + + // Then: the server returns 200 OK with the expected client identity + assertEquals(200, response.getStatus()); + String actualJson = response.readEntity(String.class); + JSONAssert.assertEquals(expectedJson, actualJson, false); + } + + @Test + public void testRetrieveResources() throws Exception { + + // Given: an empty registry returns these resources + String expected = "[" + + "{\"identifier\":\"/policies\",\"name\":\"Access Policies\"}," + + "{\"identifier\":\"/tenants\",\"name\":\"Tenant\"}," + + "{\"identifier\":\"/proxy\",\"name\":\"Proxy User Requests\"}," + + "{\"identifier\":\"/resources\",\"name\":\"NiFi Resources\"}," + + "{\"identifier\":\"/buckets\",\"name\":\"Buckets\"}" + + "]"; + + // When: the /resources endpoint is queried + final String resourcesJson = client + .target(createURL("resources")) + .request() + .get(String.class); + + // Then: the expected array of resources is returned + JSONAssert.assertEquals(expected, resourcesJson, false); + } + +} diff --git a/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureITClientConfiguration.java b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureITClientConfiguration.java new file mode 100644 index 000000000..ab07a0875 --- /dev/null +++ b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureITClientConfiguration.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.registry.web.api; + +import org.apache.nifi.registry.client.NiFiRegistryClientConfig; +import org.apache.nifi.registry.properties.NiFiRegistryProperties; +import org.apache.nifi.registry.security.util.KeystoreType; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; + +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import static org.apache.nifi.registry.web.api.IntegrationTestBase.loadNiFiRegistryProperties; + +// Do not add Spring annotations that would cause this class to be picked up by a ComponentScan. It must be imported manually. +public class SecureITClientConfiguration { + + @Value("${nifi.registry.client.properties.file}") + String clientPropertiesFileLocation; + + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private final Lock readLock = lock.readLock(); + private NiFiRegistryClientConfig clientConfig; + + @Bean + public NiFiRegistryClientConfig getNiFiRegistryClientConfig() { + readLock.lock(); + try { + if (clientConfig == null) { + final NiFiRegistryProperties clientProperties = loadNiFiRegistryProperties(clientPropertiesFileLocation); + clientConfig = createNiFiRegistryClientConfig(clientProperties); + } + } finally { + readLock.unlock(); + } + return clientConfig; + } + + /** + * A helper method for loading a NiFiRegistryClientConfig corresponding to a NiFiRegistryProperties object + * holding the values needed to create a client configuration context. + * + * @param clientProperties A NiFiRegistryProperties object holding the config for client keystore, truststore, etc. + * @return A NiFiRegistryClientConfig instance based on the properties file contents + */ + private static NiFiRegistryClientConfig createNiFiRegistryClientConfig(NiFiRegistryProperties clientProperties) { + + NiFiRegistryClientConfig.Builder configBuilder = new NiFiRegistryClientConfig.Builder(); + + // load keystore/truststore if applicable + if (clientProperties.getKeyStorePath() != null) { + configBuilder.keystoreFilename(clientProperties.getKeyStorePath()); + } + if (clientProperties.getKeyStoreType() != null) { + configBuilder.keystoreType(KeystoreType.valueOf(clientProperties.getKeyStoreType())); + } + if (clientProperties.getKeyStorePassword() != null) { + configBuilder.keystorePassword(clientProperties.getKeyStorePassword()); + } + if (clientProperties.getKeyPassword() != null) { + configBuilder.keyPassword(clientProperties.getKeyPassword()); + } + if (clientProperties.getTrustStorePath() != null) { + configBuilder.truststoreFilename(clientProperties.getTrustStorePath()); + } + if (clientProperties.getTrustStoreType() != null) { + configBuilder.truststoreType(KeystoreType.valueOf(clientProperties.getTrustStoreType())); + } + if (clientProperties.getTrustStorePassword() != null) { + configBuilder.truststorePassword(clientProperties.getTrustStorePassword()); + } + + return configBuilder.build(); + } + +} diff --git a/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredITBase.java b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredITBase.java new file mode 100644 index 000000000..e546511fc --- /dev/null +++ b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredITBase.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.registry.web.api; + +import org.apache.nifi.registry.NiFiRegistryApiTestApplication; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit4.SpringRunner; + +/** + * Deploy the Web API Application using an embedded Jetty Server for local integration testing, with the follow characteristics: + * + * - A NiFiRegistryProperties has to be explicitly provided to the ApplicationContext using a profile unique to this test suite. + * - The database is embed H2 using volatile (in-memory) persistence + * - Custom SQL is clearing the DB before each test method by default, unless method overrides this behavior + */ +@RunWith(SpringRunner.class) +@SpringBootTest( + classes = NiFiRegistryApiTestApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.include=ITUnsecured") +@Sql(executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, scripts = "classpath:db/clearDB.sql") +public class UnsecuredITBase extends IntegrationTestBase { + + // Tests cases defined in subclasses + +} diff --git a/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredIntegrationTestBase.java b/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredIntegrationTestBase.java deleted file mode 100644 index c868bf1ab..000000000 --- a/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredIntegrationTestBase.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You 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 org.apache.nifi.registry.web.api; - -import org.apache.nifi.registry.properties.NiFiRegistryProperties; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; - -import java.io.FileReader; -import java.io.IOException; - -/** - * Extends the base integration and provide a NiFiRegistryProperties Bean that configures the NiFi Registry to be unsecured - */ -public class UnsecuredIntegrationTestBase extends IntegrationTestBase { - - @TestConfiguration - public static class TestConfigurationClass { - - private NiFiRegistryProperties testProperties; - - @Bean - public synchronized NiFiRegistryProperties getNiFiRegistryProperties() { - if (testProperties == null) { - testProperties = new NiFiRegistryProperties(); - try (final FileReader reader = new FileReader("src/test/resources/conf/unsecured/nifi-registry.properties")) { - testProperties.load(reader); - } catch (final IOException ioe) { - throw new RuntimeException("Unable to load properties: " + ioe, ioe); - } - } - return testProperties; - } - } - -} diff --git a/nifi-registry-web-api/src/test/resources/application-ITSecureFile.properties b/nifi-registry-web-api/src/test/resources/application-ITSecureFile.properties new file mode 100644 index 000000000..3ea53987e --- /dev/null +++ b/nifi-registry-web-api/src/test/resources/application-ITSecureFile.properties @@ -0,0 +1,36 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + + +# Properties for Spring Boot integration tests +# Documentation for common Spring Boot application properties can be found at: +# https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html + + +# Custom (non-standard to Spring Boot) properties +nifi.registry.properties.file: src/test/resources/conf/secure-file/nifi-registry.properties +nifi.registry.client.properties.file: src/test/resources/conf/secure-file/nifi-registry-client.properties + + +# Embedded Server SSL Context Config +server.ssl.client-auth: need +server.ssl.key-store: ./target/test-classes/keys/localhost-ks.jks +server.ssl.key-store-password: localhostKeystorePassword +server.ssl.key-password: localhostKeystorePassword +server.ssl.protocol: TLS +server.ssl.trust-store: ./target/test-classes/keys/localhost-ts.jks +server.ssl.trust-store-password: localhostTruststorePassword diff --git a/nifi-registry-web-api/src/test/resources/application-ITUnsecured.properties b/nifi-registry-web-api/src/test/resources/application-ITUnsecured.properties new file mode 100644 index 000000000..bcd338c97 --- /dev/null +++ b/nifi-registry-web-api/src/test/resources/application-ITUnsecured.properties @@ -0,0 +1,21 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +# Integration Test Profile for running an unsecured NiFi Registry instance + +# Custom (non-standard to Spring Boot) properties +nifi.registry.properties.file = src/test/resources/conf/unsecured/nifi-registry.properties diff --git a/nifi-registry-web-api/src/test/resources/application.properties b/nifi-registry-web-api/src/test/resources/application.properties index 29ef09461..9cc9f51f4 100644 --- a/nifi-registry-web-api/src/test/resources/application.properties +++ b/nifi-registry-web-api/src/test/resources/application.properties @@ -16,6 +16,8 @@ # # Properties for Spring Boot integration tests +# Documentation for commoon Spring Boot application properties can be found at: +# https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html # These verbose log levels can be enabled locally for dev testing, but disable them in the repo to minimize travis logs. #logging.level.org.springframework.core.io.support: DEBUG diff --git a/nifi-registry-web-api/src/test/resources/conf/secure-file/authorizers.xml b/nifi-registry-web-api/src/test/resources/conf/secure-file/authorizers.xml new file mode 100644 index 000000000..70d75b106 --- /dev/null +++ b/nifi-registry-web-api/src/test/resources/conf/secure-file/authorizers.xml @@ -0,0 +1,143 @@ + + + + + + + + file-user-group-provider + org.apache.nifi.registry.authorization.file.FileUserGroupProvider + ./target/test-classes/conf/secure-file/users.xml + CN=user1, OU=nifi + + + + + + + + + + + file-access-policy-provider + org.apache.nifi.registry.authorization.file.FileAccessPolicyProvider + file-user-group-provider + ./target/test-classes/conf/secure-file/authorizations.xml + CN=user1, OU=nifi + + + + + + + managed-authorizer + org.apache.nifi.registry.authorization.StandardManagedAuthorizer + file-access-policy-provider + + + \ No newline at end of file diff --git a/nifi-registry-web-api/src/test/resources/conf/secure-file/nifi-registry-client.properties b/nifi-registry-web-api/src/test/resources/conf/secure-file/nifi-registry-client.properties new file mode 100644 index 000000000..8eb6b56e0 --- /dev/null +++ b/nifi-registry-web-api/src/test/resources/conf/secure-file/nifi-registry-client.properties @@ -0,0 +1,25 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +# client security properties # +nifi.registry.security.keystore=./target/test-classes/keys/client-ks.jks +nifi.registry.security.keystoreType=JKS +nifi.registry.security.keystorePasswd=clientKeystorePassword +nifi.registry.security.keyPasswd=u1Pass +nifi.registry.security.truststore=./target/test-classes/keys/localhost-ts.jks +nifi.registry.security.truststoreType=JKS +nifi.registry.security.truststorePasswd=localhostTruststorePassword diff --git a/nifi-registry-web-api/src/test/resources/conf/secure-file/nifi-registry.properties b/nifi-registry-web-api/src/test/resources/conf/secure-file/nifi-registry.properties new file mode 100644 index 000000000..65271c653 --- /dev/null +++ b/nifi-registry-web-api/src/test/resources/conf/secure-file/nifi-registry.properties @@ -0,0 +1,33 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +# web properties # +nifi.registry.web.https.host=localhost +nifi.registry.web.https.port=0 + +# security properties # +# +# ** Server KeyStore and TrustStore configuration set in Spring profile properties for embedded Jetty ** +# +nifi.registry.security.authorizers.configuration.file=./target/test-classes/conf/secure-file/authorizers.xml +nifi.registry.security.authorizer=managed-authorizer + +# providers properties # +nifi.registry.providers.configuration.file=./target/test-classes/conf/providers.xml + +# database properties +nifi.registry.db.url.append=;LOCK_TIMEOUT=25000;WRITE_DELAY=0;AUTO_SERVER=FALSE \ No newline at end of file diff --git a/nifi-registry-web-api/src/test/resources/keys/README.md b/nifi-registry-web-api/src/test/resources/keys/README.md new file mode 100644 index 000000000..c49bc1885 --- /dev/null +++ b/nifi-registry-web-api/src/test/resources/keys/README.md @@ -0,0 +1,46 @@ + +# Integration Test Keys + +The integration tests that run a secure NiFi require keystores and truststores for the server and client in order +to establish a two-way TLS connection. + +The keys/certs for these tests were generated with the tls-toolkit included with NiFi Toolkit v1.4.0. + +The steps for generating replacements are: + + # use NiFi tls-toolkit to generate CA, server key/cert, client key/cert + ./nifi-toolkit-1.4.0/bin/tls-toolkit.sh standalone --certificateAuthorityHostname localhost --hostnames localhost --nifiDnSuffix ", OU=nifi" --keyStorePassword localhostKeystorePassword --trustStorePassword localhostTruststorePassword --clientCertDn "CN=user1, OU=nifi" --clientCertPassword u1Pass --days 3650 --outputDirectory nifireg-integrationtest + + # change to tls-toolkit output directory + cd ./nifireg-integrationtest + + # copy server's key/trust stores + mkdir keys + cp localhost/keystore.jks keys/localhost-ks.jks + cp localhost/truststore.jks keys/localhost-ts.jks + + # create a Java Key Store (JKS) from the client key + keytool -importkeystore -destkeystore keys/client-ks.jks -deststorepass clientKeystorePassword -destkeypass u1Pass -srckeystore CN=user1_OU=nifi.p12 -srcstorepass u1Pass -srcstoretype PKCS12 + + +You should now have a directory with the following contents: + keys/ + +-- client-ks.jks # client keystore: keystorePass=clientKeystorePassword, keyPass=u1Pass + +-- localhost-ks.jks # server keystore: keystorePass=localhostKeystorePassword, keyPass=localhostKeystorePassword + +-- localhost-ts.jks # server/client truststore (contains CA): truststorePass=localhostTruststorePassword + +Copy these files to the test/resources/keys/ directory. + diff --git a/nifi-registry-web-api/src/test/resources/keys/client-ks.jks b/nifi-registry-web-api/src/test/resources/keys/client-ks.jks new file mode 100644 index 0000000000000000000000000000000000000000..f2e0a1ad8ff3724b290effc12c17efd355597744 GIT binary patch literal 3048 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 KgXL(6ul)l{&jHu~ literal 0 HcmV?d00001 diff --git a/nifi-registry-web-api/src/test/resources/keys/localhost-ks.jks b/nifi-registry-web-api/src/test/resources/keys/localhost-ks.jks new file mode 100644 index 0000000000000000000000000000000000000000..7421aaadc013d97b1388140b1ad3a580bacf040f GIT binary patch literal 3077 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 E0C4#ELI3~& literal 0 HcmV?d00001 diff --git a/nifi-registry-web-api/src/test/resources/keys/localhost-ts.jks b/nifi-registry-web-api/src/test/resources/keys/localhost-ts.jks new file mode 100644 index 0000000000000000000000000000000000000000..21eb2c0b9c9d81bff5632850a7dfd23a7b92e732 GIT binary patch literal 911 zcmezO_TO6u1_mY|W(3omd6{XMy2+_UB|wq*kkqs~2G$5YQv*u|24-J_CT35ACMLrL z%uI|-Ok5!ORBNC%1_NF;POUbNw(q=*jNGgY2FixK2Hb4Sp)A}yEFc37g$x8hTxK55 zoc!d(oQ(Y95(7DLUPE(310z#IGh=gOqbPA+BM{dZ$|cT_CPpP>%Nbc2n41{+84Q{j zxtN+585t&@_h>CDHGjB0e4So?aNxni-Sckq?&WIG-`Bxm=u`5O{eMfrdfwX;z57MX zP8h^xY>GW3asQ^=Rj!5K(+r;T3E9ku`s(uitR4gBpM{GjXo?EjGZ?4-vQ5sPqIb&U z``x?{UyV8OZpY8;tCPwN%MJd){o>4=XR32oeOMj3@zJ(x9i~&>-k5&!ckotIw@5N^ft0$b}W&O@uoPT(|JmWE0^9YHpD=G{3 za?bfw-B=+%|IbIQ21{j?Y0^{s-Ew9&FU$(}DKDRK<3L~E1P9KjO}CHyW#=h8@pp2H zxXSkFb1Xt0g%vd1THx~Bm5G^=fpKxYL9GEFFal)x85#exumGd)p8*euFU-Pfzzh^K zkOlGhSj1RF+ICkSN>Pu^d3y1wvvty?xW}n2-3Ia?X=N4(1F;6|3c&hRS;P!P*f_M= z7+G1_nVH}$M&u|0rVn5gF)~1B?P7N$;)v#xdalz4X02d3S$(>3k%ouA z_e-UJjr*eu_&?kfY!C>OonWgbV;3ns|KQWUfcrQ9rerKQAoQrS?kn4JSCMD`wTw$| zZI6`NwOH5j^VjyDuX``>h$($3zP6ZUp5qFZmYn3kwKvSZ@>vQeJkh!q@Nw3Qm7jBe zd~Q5)<2zT=Y9=MYgGs`2r3okQ)E)Iys(*Q1X$!~St+gw@osu`3b?4}+8_R^Rtefq~ zpda090a literal 0 HcmV?d00001