http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/6f26290d/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/FlowsIT.java ---------------------------------------------------------------------- diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/FlowsIT.java b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/FlowsIT.java new file mode 100644 index 0000000..eea6969 --- /dev/null +++ b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/FlowsIT.java @@ -0,0 +1,445 @@ +/* + * 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.bucket.BucketItemType; +import org.apache.nifi.registry.flow.VersionedFlow; +import org.apache.nifi.registry.flow.VersionedFlowSnapshot; +import org.apache.nifi.registry.flow.VersionedFlowSnapshotMetadata; +import org.apache.nifi.registry.flow.VersionedProcessGroup; +import org.junit.Assert; +import org.junit.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.test.context.jdbc.Sql; + +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.client.Entity; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import static org.apache.nifi.registry.web.api.IntegrationTestUtils.assertFlowSnapshotMetadataEqual; +import static org.apache.nifi.registry.web.api.IntegrationTestUtils.assertFlowSnapshotsEqual; +import static org.apache.nifi.registry.web.api.IntegrationTestUtils.assertFlowsEqual; +import static org.junit.Assert.assertEquals; +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 UnsecuredITBase { + + @Test + public void testGetFlowsEmpty() throws Exception { + + // Given: an empty bucket with id "3" (see FlowsIT.sql) + final String emptyBucketId = "3"; + + // When: the /buckets/{id}/flows endpoint is queried + + final VersionedFlow[] flows = client + .target(createURL("buckets/{bucketId}/flows")) + .resolveTemplate("bucketId", emptyBucketId) + .request() + .get(VersionedFlow[].class); + + // Then: an empty array is returned + + assertNotNull(flows); + assertEquals(0, flows.length); + } + + @Test + public void testGetFlows() throws Exception { + + // Given: a few buckets and flows have been populated in the DB (see FlowsIT.sql) + + final String prePopulatedBucketId = "1"; + final String expected = "[" + + "{\"identifier\":\"1\"," + + "\"name\":\"Flow 1\"," + + "\"description\":\"This is flow 1\"," + + "\"bucketIdentifier\":\"1\"," + + "\"createdTimestamp\":1505091360000," + + "\"modifiedTimestamp\":1505091360000," + + "\"type\":\"Flow\"," + + "\"permissions\":{\"canRead\":true,\"canWrite\":true,\"canDelete\":true}," + + "\"link\":{\"params\":{\"rel\":\"self\"},\"href\":\"buckets/1/flows/1\"}}," + + "{\"identifier\":\"2\",\"name\":\"Flow 2\"," + + "\"description\":\"This is flow 2\"," + + "\"bucketIdentifier\":\"1\"," + + "\"createdTimestamp\":1505091360000," + + "\"modifiedTimestamp\":1505091360000," + + "\"type\":\"Flow\"," + + "\"permissions\":{\"canRead\":true,\"canWrite\":true,\"canDelete\":true}," + + "\"versionCount\":0," + + "\"link\":{\"params\":{\"rel\":\"self\"},\"href\":\"buckets/1/flows/2\"}}" + + "]"; + + // When: the /buckets/{id}/flows endpoint is queried + + final String flowsJson = client + .target(createURL("buckets/{bucketId}/flows")) + .resolveTemplate("bucketId", prePopulatedBucketId) + .request() + .get(String.class); + + // Then: the pre-populated list of flows is returned + + JSONAssert.assertEquals(expected, flowsJson, false); + } + + @Test + public void testCreateFlowGetFlow() throws Exception { + + // Given: an empty bucket with id "3" (see FlowsIT.sql) + + long testStartTime = System.currentTimeMillis(); + final String bucketId = "3"; + + // When: a flow is created + + final VersionedFlow flow = new VersionedFlow(); + flow.setBucketIdentifier(bucketId); + flow.setName("Test Flow"); + flow.setDescription("This is a flow created by an integration test."); + + final VersionedFlow createdFlow = client + .target(createURL("buckets/{bucketId}/flows")) + .resolveTemplate("bucketId", bucketId) + .request() + .post(Entity.entity(flow, MediaType.APPLICATION_JSON), VersionedFlow.class); + + // Then: the server returns the created flow, with server-set fields populated correctly + + assertFlowsEqual(flow, createdFlow, false); + assertNotNull(createdFlow.getIdentifier()); + assertNotNull(createdFlow.getBucketName()); + assertEquals(0, createdFlow.getVersionCount()); + assertEquals(createdFlow.getType(), BucketItemType.Flow); + assertTrue(createdFlow.getCreatedTimestamp() - testStartTime > 0L); // both server and client in same JVM, so there shouldn't be skew + assertEquals(createdFlow.getCreatedTimestamp(), createdFlow.getModifiedTimestamp()); + assertNotNull(createdFlow.getLink()); + assertNotNull(createdFlow.getLink().getUri()); + + // And when .../flows is queried, then the newly created flow is returned in the list + + final VersionedFlow[] flows = client + .target(createURL("buckets/{bucketId}/flows")) + .resolveTemplate("bucketId", bucketId) + .request() + .get(VersionedFlow[].class); + assertNotNull(flows); + assertEquals(1, flows.length); + assertFlowsEqual(createdFlow, flows[0], true); + + // And when the link URI is queried, then the newly created flow is returned + + final VersionedFlow flowByLink = client + .target(createURL(flows[0].getLink().getUri().toString())) + .request() + .get(VersionedFlow.class); + assertFlowsEqual(createdFlow, flowByLink, true); + + // And when the bucket is queried by .../flows/ID, then the newly created flow is returned + + final VersionedFlow flowById = client + .target(createURL("buckets/{bucketId}/flows/{flowId}")) + .resolveTemplate("bucketId", bucketId) + .resolveTemplate("flowId", createdFlow.getIdentifier()) + .request() + .get(VersionedFlow.class); + assertFlowsEqual(createdFlow, flowById, true); + + } + + @Test + public void testUpdateFlow() throws Exception { + + // Given: a flow exists on the server + + final String bucketId = "3"; + final VersionedFlow flow = new VersionedFlow(); + flow.setBucketIdentifier(bucketId); + flow.setName("Test Flow"); + flow.setDescription("This is a flow created by an integration test."); + final VersionedFlow createdFlow = client + .target(createURL("buckets/{bucketId}/flows")) + .resolveTemplate("bucketId", bucketId) + .request() + .post(Entity.entity(flow, MediaType.APPLICATION_JSON), VersionedFlow.class); + + // When: the flow is modified by the client and updated on the server + + createdFlow.setName("Renamed Flow"); + createdFlow.setDescription("This flow has been updated by an integration test."); + + final VersionedFlow updatedFlow = client + .target(createURL("buckets/{bucketId}/flows/{flowId}")) + .resolveTemplate("bucketId", bucketId) + .resolveTemplate("flowId", createdFlow.getIdentifier()) + .request() + .put(Entity.entity(createdFlow, MediaType.APPLICATION_JSON), VersionedFlow.class); + + // Then: the server returns the updated flow, with a new modified timestamp + + assertTrue(updatedFlow.getModifiedTimestamp() > createdFlow.getModifiedTimestamp()); + createdFlow.setModifiedTimestamp(updatedFlow.getModifiedTimestamp()); + assertFlowsEqual(createdFlow, updatedFlow, true); + + } + + @Test + public void testDeleteBucket() throws Exception { + + // Given: a flow exists on the server + + final String bucketId = "3"; + final VersionedFlow flow = new VersionedFlow(); + flow.setBucketIdentifier(bucketId); + flow.setName("Test Flow"); + flow.setDescription("This is a flow created by an integration test."); + final VersionedFlow createdFlow = client + .target(createURL("buckets/{bucketId}/flows")) + .resolveTemplate("bucketId", bucketId) + .request() + .post(Entity.entity(flow, MediaType.APPLICATION_JSON), VersionedFlow.class); + + // When: the flow is deleted + + final VersionedFlow deletedFlow = client + .target(createURL("buckets/{bucketId}/flows/{flowId}")) + .resolveTemplate("bucketId", bucketId) + .resolveTemplate("flowId", createdFlow.getIdentifier()) + .request() + .delete(VersionedFlow.class); + + // Then: the body of the server response matches the flow that was deleted + // and: the flow is no longer accessible (resource not found) + + createdFlow.setLink(null); // self URI will not be present in deletedBucket + assertFlowsEqual(createdFlow, deletedFlow, true); + + final Response response = client + .target(createURL("buckets/{bucketId}/flows/{flowId}")) + .resolveTemplate("bucketId", bucketId) + .resolveTemplate("flowId", createdFlow.getIdentifier()) + .request() + .get(); + assertEquals(404, response.getStatus()); + + } + + @Test + public void testGetFlowVersionsEmpty() throws Exception { + + // Given: a Bucket "2" containing a flow "3" with no snapshots (see FlowsIT.sql) + final String bucketId = "2"; + final String flowId = "3"; + + // When: the /buckets/{id}/flows/{id}/versions endpoint is queried + + final VersionedFlowSnapshot[] flowSnapshots = client + .target(createURL("buckets/{bucketId}/flows/{flowId}/versions")) + .resolveTemplate("bucketId", bucketId) + .resolveTemplate("flowId", flowId) + .request() + .get(VersionedFlowSnapshot[].class); + + // Then: an empty array is returned + + assertNotNull(flowSnapshots); + assertEquals(0, flowSnapshots.length); + } + + @Test + public void testGetFlowVersions() throws Exception { + + // Given: a bucket "1" with flow "1" with existing snapshots has been populated in the DB (see FlowsIT.sql) + + final String prePopulatedBucketId = "1"; + final String prePopulatedFlowId = "1"; + // For this test case, the order of the expected list matters as we are asserting a strict equality check + final String expected = "[" + + "{\"bucketIdentifier\":\"1\"," + + "\"flowIdentifier\":\"1\"," + + "\"version\":2," + + "\"timestamp\":1505091480000," + + "\"author\" : \"user2\"," + + "\"comments\":\"This is flow 1 snapshot 2\"," + + "\"link\":{\"params\":{\"rel\":\"content\"},\"href\":\"buckets/1/flows/1/versions/2\"}}," + + "{\"bucketIdentifier\":\"1\"," + + "\"flowIdentifier\":\"1\"," + + "\"version\":1," + + "\"timestamp\":1505091420000," + + "\"author\" : \"user1\"," + + "\"comments\":\"This is flow 1 snapshot 1\"," + + "\"link\":{\"params\":{\"rel\":\"content\"},\"href\":\"buckets/1/flows/1/versions/1\"}}" + + "]"; + + // When: the /buckets/{id}/flows/{id}/versions endpoint is queried + final String flowSnapshotsJson = client + .target(createURL("buckets/{bucketId}/flows/{flowId}/versions")) + .resolveTemplate("bucketId", prePopulatedBucketId) + .resolveTemplate("flowId", prePopulatedFlowId) + .request() + .get(String.class); + + // Then: the pre-populated list of flow versions is returned, in descending order + JSONAssert.assertEquals(expected, flowSnapshotsJson, true); + + } + + @Test + public void testCreateFlowVersionGetFlowVersion() throws Exception { + + // Given: an empty Bucket "3" (see FlowsIT.sql) with a newly created flow + + long testStartTime = System.currentTimeMillis(); + final String bucketId = "2"; + final VersionedFlow flow = new VersionedFlow(); + flow.setBucketIdentifier(bucketId); + flow.setName("Test Flow for creating snapshots"); + flow.setDescription("This is a randomly named flow created by an integration test for the purpose of holding snapshots."); + final VersionedFlow createdFlow = client + .target(createURL("buckets/{bucketId}/flows")) + .resolveTemplate("bucketId", bucketId) + .request() + .post(Entity.entity(flow, MediaType.APPLICATION_JSON), VersionedFlow.class); + final String flowId = createdFlow.getIdentifier(); + + // When: an initial flow snapshot is created *without* a version + + final VersionedFlowSnapshotMetadata flowSnapshotMetadata = new VersionedFlowSnapshotMetadata(); + flowSnapshotMetadata.setBucketIdentifier("2"); + flowSnapshotMetadata.setFlowIdentifier(flowId); + flowSnapshotMetadata.setComments("This is snapshot 1, created by an integration test."); + final VersionedFlowSnapshot flowSnapshot = new VersionedFlowSnapshot(); + flowSnapshot.setSnapshotMetadata(flowSnapshotMetadata); + flowSnapshot.setFlowContents(new VersionedProcessGroup()); // an empty root process group + + WebTarget clientRequestTarget = client + .target(createURL("buckets/{bucketId}/flows/{flowId}/versions")) + .resolveTemplate("bucketId", bucketId) + .resolveTemplate("flowId", flowId); + final Response response = + clientRequestTarget.request().post(Entity.entity(flowSnapshot, MediaType.APPLICATION_JSON), Response.class); + + // Then: an error is returned because version != 1 + + assertEquals(400, response.getStatus()); + + // But When: an initial flow snapshot is created with version == 1 + + flowSnapshot.getSnapshotMetadata().setVersion(1); + final VersionedFlowSnapshot createdFlowSnapshot = + clientRequestTarget.request().post(Entity.entity(flowSnapshot, MediaType.APPLICATION_JSON), VersionedFlowSnapshot.class); + + // Then: the server returns the created flow snapshot, with server-set fields populated correctly :) + + assertFlowSnapshotsEqual(flowSnapshot, createdFlowSnapshot, false); + assertTrue(createdFlowSnapshot.getSnapshotMetadata().getTimestamp() - testStartTime > 0L); // both server and client in same JVM, so there shouldn't be skew + assertEquals("anonymous", createdFlowSnapshot.getSnapshotMetadata().getAuthor()); + assertNotNull(createdFlowSnapshot.getSnapshotMetadata().getLink()); + assertNotNull(createdFlowSnapshot.getSnapshotMetadata().getLink().getUri()); + assertNotNull(createdFlowSnapshot.getFlow()); + assertEquals(1, createdFlowSnapshot.getFlow().getVersionCount()); + assertNotNull(createdFlowSnapshot.getBucket()); + + // And when .../flows/{id}/versions is queried, then the newly created flow snapshot is returned in the list + + final VersionedFlowSnapshotMetadata[] versionedFlowSnapshots = + clientRequestTarget.request().get(VersionedFlowSnapshotMetadata[].class); + assertNotNull(versionedFlowSnapshots); + assertEquals(1, versionedFlowSnapshots.length); + assertFlowSnapshotMetadataEqual(createdFlowSnapshot.getSnapshotMetadata(), versionedFlowSnapshots[0], true); + + // And when the link URI is queried, then the newly created flow snapshot is returned + + final VersionedFlowSnapshot flowSnapshotByLink = client + .target(createURL(versionedFlowSnapshots[0].getLink().getUri().toString())) + .request() + .get(VersionedFlowSnapshot.class); + assertFlowSnapshotsEqual(createdFlowSnapshot, flowSnapshotByLink, true); + assertNotNull(flowSnapshotByLink.getFlow()); + assertNotNull(flowSnapshotByLink.getBucket()); + + // And when the bucket is queried by .../versions/{v}, then the newly created flow snapshot is returned + + final VersionedFlowSnapshot flowSnapshotByVersionNumber = clientRequestTarget.path("/1").request().get(VersionedFlowSnapshot.class); + assertFlowSnapshotsEqual(createdFlowSnapshot, flowSnapshotByVersionNumber, true); + assertNotNull(flowSnapshotByVersionNumber.getFlow()); + assertNotNull(flowSnapshotByVersionNumber.getBucket()); + + // And when the latest URI is queried, then the newly created flow snapshot is returned + + final VersionedFlowSnapshot flowSnapshotByLatest = clientRequestTarget.path("/latest").request().get(VersionedFlowSnapshot.class); + assertFlowSnapshotsEqual(createdFlowSnapshot, flowSnapshotByLatest, true); + assertNotNull(flowSnapshotByLatest.getFlow()); + assertNotNull(flowSnapshotByLatest.getBucket()); + + } + + @Test + public void testFlowNameUniquePerBucket() throws Exception { + + final String flowName = "Flow 1"; + + // verify we have an existing flow with the name "Flow 1" in bucket 1 + final VersionedFlow existingFlow = client + .target(createURL("buckets/1/flows/1")) + .request() + .get(VersionedFlow.class); + + assertNotNull(existingFlow); + assertEquals(flowName, existingFlow.getName()); + + // create a new flow with the same name + + final String bucketId = "3"; + + final VersionedFlow flow = new VersionedFlow(); + flow.setBucketIdentifier(bucketId); + flow.setName(flowName); + flow.setDescription("This is a flow created by an integration test."); + + // saving this flow to bucket 3 should work because bucket 3 is empty + + final VersionedFlow createdFlow = client + .target(createURL("buckets/3/flows")) + .resolveTemplate("bucketId", bucketId) + .request() + .post(Entity.entity(flow, MediaType.APPLICATION_JSON), VersionedFlow.class); + + assertNotNull(createdFlow); + + // saving the flow to bucket 1 should not work because there is a flow with the same name + flow.setBucketIdentifier("1"); + try { + client.target(createURL("buckets/1/flows")) + .resolveTemplate("bucketId", bucketId) + .request() + .post(Entity.entity(flow, MediaType.APPLICATION_JSON), VersionedFlow.class); + + Assert.fail("Should have thrown exception"); + } catch (WebApplicationException e) { + final String errorMessage = e.getResponse().readEntity(String.class); + Assert.assertEquals("A versioned flow with the same name already exists in the selected bucket", errorMessage); + } + + } + +}
http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/6f26290d/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/IntegrationTestBase.java ---------------------------------------------------------------------- diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/IntegrationTestBase.java b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/IntegrationTestBase.java new file mode 100644 index 0000000..9f3d439 --- /dev/null +++ b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/IntegrationTestBase.java @@ -0,0 +1,219 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.module.jaxb.JaxbAnnotationIntrospector; +import org.apache.nifi.registry.client.NiFiRegistryClientConfig; +import org.apache.nifi.registry.properties.NiFiRegistryProperties; +import org.glassfish.jersey.client.ClientConfig; +import org.glassfish.jersey.jackson.internal.jackson.jaxrs.json.JacksonJaxbJsonProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; + +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; + +/** + * A base class to simplify creating integration tests against an API application running with an embedded server and volatile DB. + */ +public abstract class IntegrationTestBase { + + private static final String CONTEXT_PATH = "/nifi-registry-api"; + + @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 + public JettyServletWebServerFactory jettyEmbeddedServletContainerFactory() { + JettyServletWebServerFactory jettyContainerFactory = new JettyServletWebServerFactory(); + jettyContainerFactory.setContextPath(CONTEXT_PATH); + 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 + 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(); + } + + } + + /** + * 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"); + } + + final StringBuilder baseUriBuilder = new StringBuilder(createBaseURL()).append(CONTEXT_PATH); + + if (!relativeResourcePath.startsWith("/")) { + baseUriBuilder.append('/'); + } + baseUriBuilder.append(relativeResourcePath); + + return baseUriBuilder.toString(); + } + + /** + * Sub-classes can utilize this method to obtain the base-url for a client. + * + * @return a string containing the base url which includes the scheme, host, and port + */ + String createBaseURL() { + final boolean isSecure = this.properties.getSslPort() != null; + final String protocolSchema = isSecure ? "https" : "http"; + + final StringBuilder baseUriBuilder = new StringBuilder() + .append(protocolSchema).append("://localhost:").append(port); + + return baseUriBuilder.toString(); + } + + NiFiRegistryClientConfig createClientConfig(String baseUrl) { + final NiFiRegistryClientConfig.Builder builder = new NiFiRegistryClientConfig.Builder(); + builder.baseUrl(baseUrl); + + if (this.clientConfig != null) { + if (this.clientConfig.getSslContext() != null) { + builder.sslContext(this.clientConfig.getSslContext()); + } + + if (this.clientConfig.getHostnameVerifier() != null) { + builder.hostnameVerifier(this.clientConfig.getHostnameVerifier()); + } + } + + return builder.build(); + } + + /** + * 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 ClientConfig clientConfig = new ClientConfig(); + clientConfig.register(jacksonJaxbJsonProvider()); + + final ClientBuilder clientBuilder = ClientBuilder.newBuilder().withConfig(clientConfig); + + final SSLContext sslContext = registryClientConfig.getSslContext(); + if (sslContext != null) { + clientBuilder.sslContext(sslContext); + } + + final HostnameVerifier hostnameVerifier = registryClientConfig.getHostnameVerifier(); + if (hostnameVerifier != null) { + clientBuilder.hostnameVerifier(hostnameVerifier); + } + + return clientBuilder.build(); + } + + private static JacksonJaxbJsonProvider jacksonJaxbJsonProvider() { + JacksonJaxbJsonProvider jacksonJaxbJsonProvider = new JacksonJaxbJsonProvider(); + + ObjectMapper mapper = new ObjectMapper(); + mapper.setPropertyInclusion(JsonInclude.Value.construct(JsonInclude.Include.NON_NULL, JsonInclude.Include.NON_NULL)); + mapper.setAnnotationIntrospector(new JaxbAnnotationIntrospector(mapper.getTypeFactory())); + // Ignore unknown properties so that deployed client remain compatible with future versions of NiFi Registry that add new fields + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + jacksonJaxbJsonProvider.setMapper(mapper); + return jacksonJaxbJsonProvider; + } + +} http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/6f26290d/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/IntegrationTestUtils.java ---------------------------------------------------------------------- diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/IntegrationTestUtils.java b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/IntegrationTestUtils.java new file mode 100644 index 0000000..8cfcb38 --- /dev/null +++ b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/IntegrationTestUtils.java @@ -0,0 +1,120 @@ +/* + * 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.bucket.Bucket; +import org.apache.nifi.registry.flow.VersionedComponent; +import org.apache.nifi.registry.flow.VersionedFlow; +import org.apache.nifi.registry.flow.VersionedFlowSnapshot; +import org.apache.nifi.registry.flow.VersionedFlowSnapshotMetadata; +import org.apache.nifi.registry.flow.VersionedProcessGroup; + +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +class IntegrationTestUtils { + + public static void assertBucketsEqual(Bucket expected, Bucket actual, boolean checkServerSetFields) { + assertNotNull(actual); + assertEquals(expected.getName(), actual.getName()); + assertEquals(expected.getDescription(), actual.getDescription()); + if (checkServerSetFields) { + assertEquals(expected.getIdentifier(), actual.getIdentifier()); + assertEquals(expected.getCreatedTimestamp(), actual.getCreatedTimestamp()); + assertEquals(expected.getPermissions(), actual.getPermissions()); + assertEquals(expected.getLink(), actual.getLink()); + } + } + + public static void assertFlowsEqual(VersionedFlow expected, VersionedFlow actual, boolean checkServerSetFields) { + assertNotNull(actual); + assertEquals(expected.getName(), actual.getName()); + assertEquals(expected.getDescription(), actual.getDescription()); + assertEquals(expected.getBucketIdentifier(), actual.getBucketIdentifier()); + if (checkServerSetFields) { + assertEquals(expected.getIdentifier(), actual.getIdentifier()); + assertEquals(expected.getVersionCount(), actual.getVersionCount()); + assertEquals(expected.getCreatedTimestamp(), actual.getCreatedTimestamp()); + assertEquals(expected.getModifiedTimestamp(), actual.getModifiedTimestamp()); + assertEquals(expected.getType(), actual.getType()); + assertEquals(expected.getLink(), actual.getLink()); + } + } + + public static void assertFlowSnapshotsEqual(VersionedFlowSnapshot expected, VersionedFlowSnapshot actual, boolean checkServerSetFields) { + + assertNotNull(actual); + + if (expected.getSnapshotMetadata() != null) { + assertFlowSnapshotMetadataEqual(expected.getSnapshotMetadata(), actual.getSnapshotMetadata(), checkServerSetFields); + } + + if (expected.getFlowContents() != null) { + assertVersionedProcessGroupsEqual(expected.getFlowContents(), actual.getFlowContents()); + } + + if (checkServerSetFields) { + assertFlowsEqual(expected.getFlow(), actual.getFlow(), false); // false because if we are checking a newly created snapshot, the versionsCount won't match + assertBucketsEqual(expected.getBucket(), actual.getBucket(), true); + } + + } + + public static void assertFlowSnapshotMetadataEqual( + VersionedFlowSnapshotMetadata expected, VersionedFlowSnapshotMetadata actual, boolean checkServerSetFields) { + + assertNotNull(actual); + assertEquals(expected.getBucketIdentifier(), actual.getBucketIdentifier()); + assertEquals(expected.getFlowIdentifier(), actual.getFlowIdentifier()); + assertEquals(expected.getVersion(), actual.getVersion()); + assertEquals(expected.getComments(), actual.getComments()); + if (checkServerSetFields) { + assertEquals(expected.getTimestamp(), actual.getTimestamp()); + } + } + + private static void assertVersionedProcessGroupsEqual(VersionedProcessGroup expected, VersionedProcessGroup actual) { + assertNotNull(actual); + + assertEquals(((VersionedComponent)expected), ((VersionedComponent)actual)); + + // Poor man's set equality assertion as we are only checking the base type and not doing a recursive check + // TODO, this would be a stronger assertion by replacing this with a true VersionedProcessGroup.equals() method that does a deep equality check + assertSetsEqual(expected.getProcessGroups(), actual.getProcessGroups()); + assertSetsEqual(expected.getRemoteProcessGroups(), actual.getRemoteProcessGroups()); + assertSetsEqual(expected.getProcessors(), actual.getProcessors()); + assertSetsEqual(expected.getInputPorts(), actual.getInputPorts()); + assertSetsEqual(expected.getOutputPorts(), actual.getOutputPorts()); + assertSetsEqual(expected.getConnections(), actual.getConnections()); + assertSetsEqual(expected.getLabels(), actual.getLabels()); + assertSetsEqual(expected.getFunnels(), actual.getFunnels()); + assertSetsEqual(expected.getControllerServices(), actual.getControllerServices()); + } + + + private static void assertSetsEqual(Set<? extends VersionedComponent> expected, Set<? extends VersionedComponent> actual) { + if (expected != null) { + assertNotNull(actual); + assertEquals(expected.size(), actual.size()); + assertTrue(actual.containsAll(expected)); + } + } + +} http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/6f26290d/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureFileIT.java ---------------------------------------------------------------------- diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureFileIT.java b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureFileIT.java new file mode 100644 index 0000000..67cb2e2 --- /dev/null +++ b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureFileIT.java @@ -0,0 +1,172 @@ +/* + * 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.NiFiRegistryTestApiApplication; +import org.apache.nifi.registry.authorization.ResourcePermissions; +import org.apache.nifi.registry.authorization.Tenant; +import org.apache.nifi.registry.authorization.User; +import org.apache.nifi.registry.authorization.UserGroup; +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.client.Entity; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * 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 = NiFiRegistryTestApiApplication.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\"," + + "\"anonymous\":false," + + "\"resourcePermissions\":{" + + "\"anyTopLevelResource\":{\"canRead\":true,\"canWrite\":true,\"canDelete\":true}," + + "\"buckets\":{\"canRead\":true,\"canWrite\":true,\"canDelete\":true}," + + "\"tenants\":{\"canRead\":true,\"canWrite\":true,\"canDelete\":true}," + + "\"policies\":{\"canRead\":true,\"canWrite\":true,\"canDelete\":true}," + + "\"proxy\":{\"canRead\":false,\"canWrite\":true,\"canDelete\":false}}" + + "}"; + + // 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\":\"/actuator\",\"name\":\"Actuator\"}," + + "{\"identifier\":\"/swagger\",\"name\":\"Swagger\"}," + + "{\"identifier\":\"/policies\",\"name\":\"Access Policies\"}," + + "{\"identifier\":\"/tenants\",\"name\":\"Tenants\"}," + + "{\"identifier\":\"/proxy\",\"name\":\"Proxy User Requests\"}," + + "{\"identifier\":\"/buckets\",\"name\":\"Buckets\"}" + + "]"; + + // When: the /resources endpoint is queried + final String resourcesJson = client + .target(createURL("/policies/resources")) + .request() + .get(String.class); + + // Then: the expected array of resources is returned + JSONAssert.assertEquals(expected, resourcesJson, false); + } + + @Test + public void testCreateUser() throws Exception { + + // Given: the server has been configured with FileUserGroupProvider, which is configurable, + // and: the initial admin client wants to create a tenant + Tenant tenant = new Tenant(); + tenant.setIdentity("New User"); + + // When: the POST /tenants/users endpoint is accessed + final Response createUserResponse = client + .target(createURL("tenants/users")) + .request() + .post(Entity.entity(tenant, MediaType.APPLICATION_JSON_TYPE), Response.class); + + // Then: "201 created" is returned with the expected user + assertEquals(201, createUserResponse.getStatus()); + User actualUser = createUserResponse.readEntity(User.class); + assertNotNull(actualUser.getIdentifier()); + try { + assertEquals(tenant.getIdentity(), actualUser.getIdentity()); + assertEquals(true, actualUser.getConfigurable()); + assertEquals(0, actualUser.getUserGroups().size()); + assertEquals(0, actualUser.getAccessPolicies().size()); + assertEquals(new ResourcePermissions(), actualUser.getResourcePermissions()); + } finally { + // cleanup user for other tests + client.target(createURL("tenants/users/" + actualUser.getIdentifier())) + .request() + .delete(); + } + + } + + @Test + public void testCreateUserGroup() throws Exception { + + // Given: the server has been configured with FileUserGroupProvider, which is configurable, + // and: the initial admin client wants to create a tenant + Tenant tenant = new Tenant(); + tenant.setIdentity("New Group"); + + // When: the POST /tenants/user-groups endpoint is used + final Response createUserGroupResponse = client + .target(createURL("tenants/user-groups")) + .request() + .post(Entity.entity(tenant, MediaType.APPLICATION_JSON_TYPE), Response.class); + + // Then: 201 created is returned with the expected group + assertEquals(201, createUserGroupResponse.getStatus()); + UserGroup actualUserGroup = createUserGroupResponse.readEntity(UserGroup.class); + assertNotNull(actualUserGroup.getIdentifier()); + try { + assertEquals(tenant.getIdentity(), actualUserGroup.getIdentity()); + assertEquals(true, actualUserGroup.getConfigurable()); + assertEquals(0, actualUserGroup.getUsers().size()); + assertEquals(0, actualUserGroup.getAccessPolicies().size()); + assertEquals(new ResourcePermissions(), actualUserGroup.getResourcePermissions()); + } finally { + // cleanup user for other tests + client.target(createURL("tenants/user-groups/" + actualUserGroup.getIdentifier())) + .request() + .delete(); + } + + } + +} http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/6f26290d/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureITClientConfiguration.java ---------------------------------------------------------------------- diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureITClientConfiguration.java b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureITClientConfiguration.java new file mode 100644 index 0000000..ab07a08 --- /dev/null +++ b/nifi-registry-core/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(); + } + +} http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/6f26290d/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureKerberosIT.java ---------------------------------------------------------------------- diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureKerberosIT.java b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureKerberosIT.java new file mode 100644 index 0000000..8d8ea97 --- /dev/null +++ b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/SecureKerberosIT.java @@ -0,0 +1,216 @@ +/* + * 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.commons.lang3.StringUtils; +import org.apache.nifi.registry.NiFiRegistryTestApiApplication; +import org.junit.Before; +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.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.kerberos.authentication.KerberosTicketValidation; +import org.springframework.security.kerberos.authentication.KerberosTicketValidator; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit4.SpringRunner; + +import javax.ws.rs.core.Response; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Base64; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * 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 one-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( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.profiles.include=ITSecureKerberos") +@Sql(executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, scripts = "classpath:db/clearDB.sql") +public class SecureKerberosIT extends IntegrationTestBase { + + private static final String validKerberosTicket = "authenticate_me"; + private static final String invalidKerberosTicket = "do_not_authenticate_me"; + + public static class MockKerberosTicketValidator implements KerberosTicketValidator { + + @Override + public KerberosTicketValidation validateTicket(byte[] token) throws BadCredentialsException { + + boolean validTicket; + try { + validTicket = Arrays.equals(validKerberosTicket.getBytes("UTF-8"), token); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + + if (!validTicket) { + throw new BadCredentialsException(MockKerberosTicketValidator.class.getSimpleName() + " does not validate token"); + } + + return new KerberosTicketValidation( + "kerberosUser@LOCALHOST", + "HTTP/localhsot@LOCALHOST", + null, + null); + } + } + + @Configuration + @Profile("ITSecureKerberos") + @Import({NiFiRegistryTestApiApplication.class, SecureITClientConfiguration.class}) + public static class KerberosSpnegoTestConfiguration { + + @Primary + @Bean + public static KerberosTicketValidator kerberosTicketValidator() { + return new MockKerberosTicketValidator(); + } + + } + + private String adminAuthToken; + + @Before + public void generateAuthToken() { + String validTicket = new String(Base64.getEncoder().encode(validKerberosTicket.getBytes(Charset.forName("UTF-8")))); + final String token = client + .target(createURL("/access/token/kerberos")) + .request() + .header("Authorization", "Negotiate " + validTicket) + .post(null, String.class); + adminAuthToken = token; + } + + @Test + public void testTokenGenerationAndAccessStatus() throws Exception { + + // Note: this test intentionally does not use the token generated + // for nifiadmin by the @Before method + + // Given: the client and server have been configured correctly for Kerberos SPNEGO authentication + String expectedJwtPayloadJson = "{" + + "\"sub\":\"kerberosUser@LOCALHOST\"," + + "\"preferred_username\":\"kerberosUser@LOCALHOST\"," + + "\"iss\":\"KerberosSpnegoIdentityProvider\"" + + "}"; + String expectedAccessStatusJson = "{" + + "\"identity\":\"kerberosUser@LOCALHOST\"," + + "\"anonymous\":false}"; + + // When: the /access/token/kerberos endpoint is accessed with no credentials + final Response tokenResponse1 = client + .target(createURL("/access/token/kerberos")) + .request() + .post(null, Response.class); + + // Then: the server returns 401 Unauthorized with an authenticate challenge header + assertEquals(401, tokenResponse1.getStatus()); + assertNotNull(tokenResponse1.getHeaders().get("www-authenticate")); + assertEquals(1, tokenResponse1.getHeaders().get("www-authenticate").size()); + assertEquals("Negotiate", tokenResponse1.getHeaders().get("www-authenticate").get(0)); + + // When: the /access/token/kerberos endpoint is accessed again with an invalid ticket + String invalidTicket = new String(java.util.Base64.getEncoder().encode(invalidKerberosTicket.getBytes(Charset.forName("UTF-8")))); + final Response tokenResponse2 = client + .target(createURL("/access/token/kerberos")) + .request() + .header("Authorization", "Negotiate " + invalidTicket) + .post(null, Response.class); + + // Then: the server returns 401 Unauthorized + assertEquals(401, tokenResponse2.getStatus()); + + // When: the /access/token/kerberos endpoint is accessed with a valid ticket + String validTicket = new String(Base64.getEncoder().encode(validKerberosTicket.getBytes(Charset.forName("UTF-8")))); + final Response tokenResponse3 = client + .target(createURL("/access/token/kerberos")) + .request() + .header("Authorization", "Negotiate " + validTicket) + .post(null, Response.class); + + // Then: the server returns 200 OK with a JWT in the body + assertEquals(201, tokenResponse3.getStatus()); + String token = tokenResponse3.readEntity(String.class); + assertTrue(StringUtils.isNotEmpty(token)); + String[] jwtParts = token.split("\\."); + assertEquals(3, jwtParts.length); + String jwtPayload = new String(Base64.getDecoder().decode(jwtParts[1]), "UTF-8"); + JSONAssert.assertEquals(expectedJwtPayloadJson, jwtPayload, false); + + // When: the token is returned in the Authorization header + final Response accessResponse = client + .target(createURL("access")) + .request() + .header("Authorization", "Bearer " + token) + .get(Response.class); + + // Then: the server acknowledges the client has access + assertEquals(200, accessResponse.getStatus()); + String accessStatus = accessResponse.readEntity(String.class); + JSONAssert.assertEquals(expectedAccessStatusJson, accessStatus, false); + + } + + @Test + public void testGetCurrentUser() throws Exception { + + // Given: the client is connected to an unsecured NiFi Registry + String expectedJson = "{" + + "\"identity\":\"kerberosUser@LOCALHOST\"," + + "\"anonymous\":false," + + "\"resourcePermissions\":{" + + "\"anyTopLevelResource\":{\"canRead\":true,\"canWrite\":true,\"canDelete\":true}," + + "\"buckets\":{\"canRead\":true,\"canWrite\":true,\"canDelete\":true}," + + "\"tenants\":{\"canRead\":true,\"canWrite\":true,\"canDelete\":true}," + + "\"policies\":{\"canRead\":true,\"canWrite\":true,\"canDelete\":true}," + + "\"proxy\":{\"canRead\":false,\"canWrite\":true,\"canDelete\":false}}" + + "}"; + + // When: the /access endpoint is queried using a JWT for the kerberos user + final Response response = client + .target(createURL("/access")) + .request() + .header("Authorization", "Bearer " + adminAuthToken) + .get(Response.class); + + // Then: the server returns a 200 OK with the expected current user + assertEquals(200, response.getStatus()); + String actualJson = response.readEntity(String.class); + JSONAssert.assertEquals(expectedJson, actualJson, false); + + } + + +}