From 0bdcc0186fcfa2a2e145ce2efae53c1ed9f695ee Mon Sep 17 00:00:00 2001 From: craigmcnally Date: Mon, 14 May 2018 21:27:41 +0000 Subject: [PATCH] initial commit --- .editorconfig | 14 + .gitignore | 5 + CONTRIBUTING.md | 4 + Dockerfile | 12 + Jenkinsfile | 13 + LICENSE | 201 +++++++++++++++ NEWS.md | 2 + README.md | 52 ++++ docker/docker-entrypoint.sh | 19 ++ pom.xml | 241 ++++++++++++++++++ ramls/edge-rtac.raml | 39 +++ ramls/holdings.xsd | 25 ++ .../java/org/folio/edge/rtac/Constants.java | 28 ++ .../org/folio/edge/rtac/MainVerticle.java | 96 +++++++ .../java/org/folio/edge/rtac/RtacHandler.java | 86 +++++++ .../org/folio/edge/rtac/cache/TokenCache.java | 150 +++++++++++ .../org/folio/edge/rtac/model/Holdings.java | 134 ++++++++++ .../edge/rtac/security/AwsParamStore.java | 118 +++++++++ .../edge/rtac/security/EphemeralStore.java | 62 +++++ .../folio/edge/rtac/security/SecureStore.java | 15 ++ .../rtac/security/SecureStoreFactory.java | 33 +++ .../folio/edge/rtac/security/VaultStore.java | 91 +++++++ .../org/folio/edge/rtac/utils/HttpClient.java | 92 +++++++ .../org/folio/edge/rtac/utils/Mappers.java | 13 + .../folio/edge/rtac/utils/OkapiClient.java | 124 +++++++++ .../edge/rtac/utils/OkapiClientFactory.java | 18 ++ src/main/resources/aws_ss.properties | 5 + src/main/resources/ephemeral.properties | 13 + src/main/resources/log4j.properties | 8 + src/main/resources/vault.properties | 34 +++ .../org/folio/edge/rtac/MainVerticleTest.java | 209 +++++++++++++++ .../folio/edge/rtac/cache/TokenCacheTest.java | 125 +++++++++ .../folio/edge/rtac/model/HoldingsTest.java | 97 +++++++ .../edge/rtac/security/AwsParamStoreTest.java | 93 +++++++ .../rtac/security/EphemeralStoreTest.java | 59 +++++ .../rtac/security/SecureStoreFactoryTest.java | 46 ++++ .../edge/rtac/security/VaultStoreTest.java | 67 +++++ .../org/folio/edge/rtac/utils/MockOkapi.java | 121 +++++++++ .../rtac/utils/OkapiClientFactoryTest.java | 27 ++ .../edge/rtac/utils/OkapiClientTest.java | 77 ++++++ .../org/folio/edge/rtac/utils/TestUtils.java | 48 ++++ 41 files changed, 2716 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 Jenkinsfile create mode 100644 LICENSE create mode 100644 NEWS.md create mode 100644 README.md create mode 100644 docker/docker-entrypoint.sh create mode 100644 pom.xml create mode 100644 ramls/edge-rtac.raml create mode 100644 ramls/holdings.xsd create mode 100644 src/main/java/org/folio/edge/rtac/Constants.java create mode 100644 src/main/java/org/folio/edge/rtac/MainVerticle.java create mode 100644 src/main/java/org/folio/edge/rtac/RtacHandler.java create mode 100644 src/main/java/org/folio/edge/rtac/cache/TokenCache.java create mode 100644 src/main/java/org/folio/edge/rtac/model/Holdings.java create mode 100644 src/main/java/org/folio/edge/rtac/security/AwsParamStore.java create mode 100644 src/main/java/org/folio/edge/rtac/security/EphemeralStore.java create mode 100644 src/main/java/org/folio/edge/rtac/security/SecureStore.java create mode 100644 src/main/java/org/folio/edge/rtac/security/SecureStoreFactory.java create mode 100644 src/main/java/org/folio/edge/rtac/security/VaultStore.java create mode 100644 src/main/java/org/folio/edge/rtac/utils/HttpClient.java create mode 100644 src/main/java/org/folio/edge/rtac/utils/Mappers.java create mode 100644 src/main/java/org/folio/edge/rtac/utils/OkapiClient.java create mode 100644 src/main/java/org/folio/edge/rtac/utils/OkapiClientFactory.java create mode 100644 src/main/resources/aws_ss.properties create mode 100644 src/main/resources/ephemeral.properties create mode 100644 src/main/resources/log4j.properties create mode 100644 src/main/resources/vault.properties create mode 100644 src/test/java/org/folio/edge/rtac/MainVerticleTest.java create mode 100644 src/test/java/org/folio/edge/rtac/cache/TokenCacheTest.java create mode 100644 src/test/java/org/folio/edge/rtac/model/HoldingsTest.java create mode 100644 src/test/java/org/folio/edge/rtac/security/AwsParamStoreTest.java create mode 100644 src/test/java/org/folio/edge/rtac/security/EphemeralStoreTest.java create mode 100644 src/test/java/org/folio/edge/rtac/security/SecureStoreFactoryTest.java create mode 100644 src/test/java/org/folio/edge/rtac/security/VaultStoreTest.java create mode 100644 src/test/java/org/folio/edge/rtac/utils/MockOkapi.java create mode 100644 src/test/java/org/folio/edge/rtac/utils/OkapiClientFactoryTest.java create mode 100644 src/test/java/org/folio/edge/rtac/utils/OkapiClientTest.java create mode 100644 src/test/java/org/folio/edge/rtac/utils/TestUtils.java diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0049e29 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# http://editorconfig.org/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef7793f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target/ +.classpath +.project +.settings +.vertx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c6e1e05 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contribution guidelines + +Guidelines for Contributing Code: +[dev.folio.org/guidelines/contributing](https://dev.folio.org/guidelines/contributing) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b5292c6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM folioci/openjdk8-jre:latest + +ENV VERTICLE_FILE edge-rtac-fat.jar + +# Set the location of the verticles +ENV VERTICLE_HOME /usr/verticles + +# Copy your fat jar to the container +COPY target/${VERTICLE_FILE} ${VERTICLE_HOME}/${VERTICLE_FILE} + +# Expose this port locally in the container. +EXPOSE 8081 \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..207c96c --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,13 @@ +buildMvn { + publishModDescriptor = 'no' + publishAPI = 'no' + mvnDeploy = 'yes' + + doDocker = { + buildJavaDocker { + publishMaster = 'yes' + healthChk = 'yes' + healthChkCmd = 'curl -sS --fail -o /dev/null http://localhost:8081/admin/health || exit 1' + } + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NEWS.md b/NEWS.md new file mode 100644 index 0000000..137210f --- /dev/null +++ b/NEWS.md @@ -0,0 +1,2 @@ +## 0.0.1 2018-05-14 + * Initial Commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..1977088 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# edge-rtac + +Copyright (C) 2018 The Open Library Foundation + +This software is distributed under the terms of the Apache License, +Version 2.0. See the file "[LICENSE](LICENSE)" for more information. + +## Introduction + +Edge API to interface w/ FOLIO for 3rd party discovery services to determine holdings availability. + +## Overview + +Coming Soon! + +## Configuration + +Configuration information is specified in two forms: +1. System Properties - General configuration +1. Properties File - Configuration specific to the desired secure store + +### System Properties + +Proprety | Default | Description +--------------------- | ----------- | ------------- +`port` | `8081` | Server port to listen on +`okapi_url` | *required* | Where to find OKAPI (URL) +`secure_store` | `Ephemeral` | Type of secure store to use. Valid: `Ephemeral`, `AwsSsm`, `Vault` +`secure_store_props` | `NA` | Path to a properties file specifying secure store configuration +`token_cache_ttl_ms` | `3600000` | How long to cache JWTs, in milliseconds (ms) +`token_cache_capacity`| `100` | Max token cache size + +### Secure Stores + +Three secure stores currently implemented for safe retreival of encrypted credentials: + +* **EphemeralStore** - Only intended for _development purposes_. Credentials are defind in plain text in a specified properties file. See `src/main/resources/ephemeral.properties` +* **AwsParamStore** - Retreives credentials from Amazon Web Services Systems Manager (AWS SSM), more specifically the Parameter Store, where they're stored encrypted using a KMS key. See `src.main/resources/aws_ss.properties` +* **VaultStore** - Retreives credentials from a Vault (http://vaultproject.io). This was added as a more generic alternative for those not using AWS. See `src/main/resources/vault.properties` + +## Additional information + +### Issue tracker + +See project [FOLIO](https://issues.folio.org/browse/FOLIO) +at the [FOLIO issue tracker](https://dev.folio.org/guidelines/issue-tracker). + +### Other documentation + +Other [modules](https://dev.folio.org/source-code/#server-side) are described, +with further FOLIO Developer documentation at [dev.folio.org](https://dev.folio.org/) + diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 0000000..46e5d39 --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# This is the Docker entrypoint script specified in +# Dockerfile. It supports invoking the container +# with both optional JVM runtime options as well as +# optional module arguments. +# +# Example: +# +# 'docker run -d -e JAVA_OPTS="-Xmx256M" folio-module embed_mongo=true' +# + +set -e + +if [ -n "$JAVA_OPTS" ]; then + exec java "$JAVA_OPTS" -jar ${VERTICLE_HOME}/module.jar "$@" +else + exec java -jar ${VERTICLE_HOME}/module.jar "$@" +fi diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..e5642de --- /dev/null +++ b/pom.xml @@ -0,0 +1,241 @@ + + 4.0.0 + org.folio + edge-rtac + 0.0.1 + jar + + Edge API - Real Time Availability Check + https://github.com/folio-org/edge-rtac + Edge API to interface w/ FOLIO for 3rd party discovery services to determine holdings availability + 2018 + + The Open Library Foundation + https://dev.folio.org/ + + + https://github.com/folio-org/edge-rtac.git + scm:git:git://github.com/folio-org/edge-rtac.git + scm:git:git@github.com:folio-org/edge-rtac.git + + + + + Apache License 2.0 + https://spdx.org/licenses/Apache-2.0 + repo + + + + + 1.8 + 1.8 + + + org.folio.edge.rtac.MainVerticle + + + + + io.vertx + vertx-core + 3.4.0 + + + io.vertx + vertx-web + 3.4.0 + + + io.jsonwebtoken + jjwt + 0.6.0 + + + com.fasterxml.jackson.core + jackson-annotations + 2.9.4 + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + 2.9.4 + + + + + com.amazonaws + aws-java-sdk-ssm + 1.11.313 + + + org.apache.httpcomponents + httpcore + 4.4.9 + + + + + com.bettercloud + vault-java-driver + 3.1.0 + + + + + + org.slf4j + slf4j-api + 1.7.18 + + + + + org.slf4j + slf4j-log4j12 + 1.7.13 + + + log4j + log4j + 1.2.17 + + + + + org.slf4j + jcl-over-slf4j + 1.7.18 + + + + + junit + junit + 4.12 + test + + + io.vertx + vertx-unit + 3.2.1 + test + + + org.mockito + mockito-all + 1.9.5 + test + + + com.jayway.restassured + rest-assured + 2.9.0 + test + + + + + + + + + + maven-compiler-plugin + 3.1 + + ${maven.compiler.source} + ${maven.compiler.target} + + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.4 + + + package + + shade + + + + + + io.vertx.core.Launcher + ${exec.mainClass} + + + + log4j.properties + + + + ${project.build.directory}/${project.artifactId}-fat.jar + + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.4.0 + + + + run-app + + exec + + + java + + -jar + target/${project.artifactId}-${project.version}-fat.jar + + + + + + + + + + + folio-nexus + FOLIO Maven repository + https://repository.folio.org/repository/maven-folio + + + + + + folio-nexus + FOLIO Release Repository + https://repository.folio.org/repository/maven-releases/ + false + default + + + folio-nexus + FOLIO Snapshot Repository + true + https://repository.folio.org/repository/maven-snapshots/ + default + + + + diff --git a/ramls/edge-rtac.raml b/ramls/edge-rtac.raml new file mode 100644 index 0000000..8436c10 --- /dev/null +++ b/ramls/edge-rtac.raml @@ -0,0 +1,39 @@ +#%RAML 0.8 +title: Edge API: Real Time Availability Check +baseUri: https://github.com/folio-org/edge-rtac +version: v1 + +documentation: + - title: Edge API: Real Time Availability Check + - content: Edge API to interface w/ FOLIO for 3rd party discovery services to determine holdings availability + +schemas: + - holdings: !include holdings.xsd + +/prod/rtac: + displayName: RTAC + get: + description: RTAC for the specified holding id + responses: + 200: + description: "Success" + body: + application/xml: + schema: holdings + uriParameters: + mms_id: + description: "The UUID of a FOLIO instance" + type: string + pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + apikey: + description: "API Key" + type: string +/admin/health: + displayName: Health Check + get: + description: RTAC for the specified holding id + responses: + 200: + description: "Success" + body: + text/plain diff --git a/ramls/holdings.xsd b/ramls/holdings.xsd new file mode 100644 index 0000000..8fb1770 --- /dev/null +++ b/ramls/holdings.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/org/folio/edge/rtac/Constants.java b/src/main/java/org/folio/edge/rtac/Constants.java new file mode 100644 index 0000000..86a09df --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/Constants.java @@ -0,0 +1,28 @@ +package org.folio.edge.rtac; + +public class Constants { + // System Properties + public static final String SYS_SECURE_STORE_PROP_FILE = "secure_store_props"; + public static final String SYS_SECURE_STORE_TYPE = "secure_store"; + public static final String SYS_OKAPI_URL = "okapi_url"; + public static final String SYS_PORT = "port"; + public static final String SYS_TOKEN_CACHE_TTL_MS = "token_cache_ttl_ms"; + public static final String SYS_TOKEN_CACHE_CAPACITY = "token_cache_capacity"; + + // Property names + public static final String PROP_SECURE_STORE_TYPE = "secureStore.type"; + + // Defaults + public static final String DEFAULT_SECURE_STORE_TYPE = "ephemeral"; + public static final String DEFAULT_PORT = "8081"; + public static final String DEFAULT_TOKEN_CACHE_TTL_MS = String.valueOf(60 * 60 * 1000); + public static final String DEFAULT_TOKEN_CACHE_CAPACITY = "100"; + + // Headers + public static final String X_OKAPI_TENANT = "x-okapi-tenant"; + public static final String X_OKAPI_TOKEN = "x-okapi-token"; + + // Param names + public static final String PARAM_API_KEY = "apikey"; + public static final String PARAM_TITLE_ID = "mms_id"; +} diff --git a/src/main/java/org/folio/edge/rtac/MainVerticle.java b/src/main/java/org/folio/edge/rtac/MainVerticle.java new file mode 100644 index 0000000..0ac0aef --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/MainVerticle.java @@ -0,0 +1,96 @@ +package org.folio.edge.rtac; + +import static org.folio.edge.rtac.Constants.*; +import static org.folio.edge.rtac.Constants.DEFAULT_SECURE_STORE_TYPE; +import static org.folio.edge.rtac.Constants.PROP_SECURE_STORE_TYPE; +import static org.folio.edge.rtac.Constants.SYS_OKAPI_URL; +import static org.folio.edge.rtac.Constants.SYS_SECURE_STORE_PROP_FILE; +import static org.folio.edge.rtac.Constants.SYS_SECURE_STORE_TYPE; + +import java.io.FileInputStream; +import java.util.Properties; + +import org.apache.log4j.Logger; +import org.folio.edge.rtac.security.SecureStore; +import org.folio.edge.rtac.security.SecureStoreFactory; +import org.folio.edge.rtac.utils.OkapiClientFactory; + +import io.vertx.core.AbstractVerticle; +import io.vertx.core.Future; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServer; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.BodyHandler; + +public class MainVerticle extends AbstractVerticle { + + // Private Members + private static final Logger logger = Logger.getLogger(MainVerticle.class); + private SecureStore secureStore; + + @Override + public void start(Future future) { + Router router = Router.router(vertx); + HttpServer server = vertx.createHttpServer(); + + // Get properties from context too, for unit tests purposes. + final String portStr = System.getProperty(SYS_PORT, context.config().getString(SYS_PORT, DEFAULT_PORT)); + final int port = Integer.parseInt(portStr); + logger.info("Using port: " + port); + + final String okapiURL = System.getProperty(SYS_OKAPI_URL, context.config().getString(SYS_OKAPI_URL)); + logger.info("Using okapiURL: " + okapiURL); + + final String secureStorePropFile = System.getProperty(SYS_SECURE_STORE_PROP_FILE, + context.config().getString(SYS_SECURE_STORE_PROP_FILE)); + + initializeSecureStore(secureStorePropFile); + + OkapiClientFactory ocf = new OkapiClientFactory(vertx, okapiURL); + + RtacHandler rtacHandler = new RtacHandler(secureStore, ocf); + + router.route().handler(BodyHandler.create()); + router.route(HttpMethod.GET, "/admin/health").handler(this::healthCheckHandler); + router.route(HttpMethod.GET, "/prod/rtac/folioRTAC").handler(rtacHandler::rtacHandler); + + server.requestHandler(router::accept).listen(port, result -> { + if (result.succeeded()) { + future.complete(); + } else { + future.fail(result.cause()); + } + }); + } + + protected void initializeSecureStore(String secureStorePropFile) { + Properties secureStoreProps = new Properties(); + + if (secureStorePropFile != null) { + // TODO add support for s3://bucket/file.properties + try { + secureStoreProps.load(new FileInputStream(secureStorePropFile)); + logger.info("Successfully loaded properties from: " + secureStorePropFile); + } catch (Exception e) { + logger.warn("Failed to load secure store properties.", e); + } + } else { + logger.warn("No secure store properties file specified. Using defaults"); + } + + // Order of precedence: system property, properties file, default + String type = System.getProperty(SYS_SECURE_STORE_TYPE, + secureStoreProps.getProperty(PROP_SECURE_STORE_TYPE, DEFAULT_SECURE_STORE_TYPE)); + + secureStore = SecureStoreFactory.getSecureStore(type, secureStoreProps); + } + + protected void healthCheckHandler(RoutingContext ctx) { + ctx.response() + .setStatusCode(200) + .putHeader(HttpHeaders.CONTENT_TYPE, "text/plain") + .end("\"OK\""); + } +} diff --git a/src/main/java/org/folio/edge/rtac/RtacHandler.java b/src/main/java/org/folio/edge/rtac/RtacHandler.java new file mode 100644 index 0000000..a393dde --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/RtacHandler.java @@ -0,0 +1,86 @@ +package org.folio.edge.rtac; + +import static org.folio.edge.rtac.Constants.PARAM_API_KEY; +import static org.folio.edge.rtac.Constants.PARAM_TITLE_ID; + +import java.util.Base64; + +import org.apache.log4j.Logger; +import org.folio.edge.rtac.model.Holdings; +import org.folio.edge.rtac.security.SecureStore; +import org.folio.edge.rtac.utils.Mappers; +import org.folio.edge.rtac.utils.OkapiClient; +import org.folio.edge.rtac.utils.OkapiClientFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import io.vertx.core.http.HttpHeaders; +import io.vertx.ext.web.RoutingContext; + +public class RtacHandler { + + private static final Logger logger = Logger.getLogger(RtacHandler.class); + + private final SecureStore secureStore; + private final OkapiClientFactory ocf; + + public RtacHandler(SecureStore secureStore, OkapiClientFactory ocf) { + this.secureStore = secureStore; + this.ocf = ocf; + } + + protected void rtacHandler(RoutingContext ctx) { + + String key = ctx.request().getParam(PARAM_API_KEY); + String id = ctx.request().getParam(PARAM_TITLE_ID); + + if (id == null || id.isEmpty() || key == null || key.isEmpty()) { + // NOTE: We always return a 200 even if holdings is empty here + // because that's what the API we're trying to mimic does... + // Yes, even if the response from mod-rtac is non-200! + String xml = null; + try { + xml = new Holdings().toXml(); + } catch (JsonProcessingException e) { + // OK, we'll doing ourselves then + xml = Mappers.prolog + "\n"; + } + ctx.response() + .setStatusCode(200) + .putHeader(HttpHeaders.CONTENT_TYPE, "application/xml") + .end(xml); + } else { + String tenant = new String(Base64.getUrlDecoder().decode(key)); + + logger.info(String.format("API Key: %s, Tenant: %s", key, tenant)); + + OkapiClient client = ocf.getOkapiClient(tenant); + + String user = tenant; + String password = secureStore.get(tenant, user); + + // login + client.getToken(user, password).thenRun(() -> { + // call mod-rtac + client.rtac(id).thenAcceptAsync(body -> { + String xml = null; + try { + xml = Holdings.fromJson(body).toXml(); + logger.info("Converted Response: \n" + xml); + } catch (Exception e) { + xml = Mappers.prolog + "\n"; + logger.error("Exception translating JSON -> XML: " + e.getMessage()); + } + + // NOTE: Again, we return a 200 here because that's what the + // API + // we're trying to mimic does + ctx.response() + .setStatusCode(200) + .putHeader(HttpHeaders.CONTENT_TYPE, "application/xml") + .end(xml); + }); + }); + } + } +} diff --git a/src/main/java/org/folio/edge/rtac/cache/TokenCache.java b/src/main/java/org/folio/edge/rtac/cache/TokenCache.java new file mode 100644 index 0000000..8730918 --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/cache/TokenCache.java @@ -0,0 +1,150 @@ +package org.folio.edge.rtac.cache; + +import static org.folio.edge.rtac.Constants.DEFAULT_TOKEN_CACHE_CAPACITY; +import static org.folio.edge.rtac.Constants.DEFAULT_TOKEN_CACHE_TTL_MS; + +import java.util.Iterator; +import java.util.LinkedHashMap; + +import org.apache.log4j.Logger; + +/** + * A cache for storing (JWT) tokens. For now, cache entries are have a set TTL, + * but eventually should get their expiration time from the token itself (once + * OKAPI support expiring JWTs) + * + * @param + */ +public class TokenCache { + + public static final Logger logger = Logger.getLogger(TokenCache.class); + + private LinkedHashMap> cache; + private long ttl; + private int capacity; + + private TokenCache() { + ttl = Long.parseLong(DEFAULT_TOKEN_CACHE_TTL_MS); + capacity = Integer.parseInt(DEFAULT_TOKEN_CACHE_CAPACITY); + } + + public T get(String tenant, String username) { + String key = getKey(tenant, username); + CacheValue cached = cache.get(key); + + if (cached != null) { + if (cached.expired()) { + cache.remove(key); + return null; + } else { + T token = cached.value; + return token; + } + } else { + return null; + } + } + + public void put(String tenant, String username, T token) { + String key = getKey(tenant, username); + + // Double-checked locking... + CacheValue fromCache = cache.get(key); + if (fromCache == null || fromCache.expired()) { + + // lock to safeguard against multiple threads + // trying to cache the same key at the same time + synchronized (this) { + fromCache = cache.get(key); + if (fromCache == null || fromCache.expired()) { + cache.put(key, new CacheValue(token, System.currentTimeMillis() + ttl)); + } + } + } + + if (cache.size() >= capacity) { + prune(); + } + } + + private String getKey(String tenant, String username) { + return String.format("%s:%s", tenant, username); + } + + private void prune() { + logger.info("Cache size before pruning: " + cache.size()); + + LinkedHashMap> updated = new LinkedHashMap>(capacity); + Iterator keyIter = cache.keySet().iterator(); + while (keyIter.hasNext()) { + String key = keyIter.next(); + CacheValue val = cache.get(key); + if (val != null && !val.expired()) { + updated.put(key, val); + } else { + logger.info("Pruning expired cache entry: " + key); + } + } + + if (updated.size() > capacity) { + // this works because LinkedHashMap maintains order of insertion + String key = updated.keySet().iterator().next(); + logger.info(String + .format( + "Cache is above capacity and doesn't contain expired entries. Removing oldest entry (%s)", + key)); + updated.remove(key); + } + + // atomic swap-in updated cache. + cache = updated; + + logger.info("Cache size after pruning: " + updated.size()); + } + + /** + * A Generic, immutable cache entry. + * + * Expiration times are specified in ms since epoch.
+ * e.g. System.currentTimeMills() + TTL + * + * @param + * The class/type of value being cached + */ + public static final class CacheValue { + public final T value; + public final long expires; + + public CacheValue(T value, long expires) { + this.value = value; + this.expires = expires; + } + + public boolean expired() { + return expires < System.currentTimeMillis(); + } + } + + public static class TokenCacheBuilder { + private TokenCache instance; + + public TokenCacheBuilder() { + instance = new TokenCache(); + } + + public TokenCacheBuilder withTTL(long ttl) { + instance.ttl = ttl; + return this; + } + + public TokenCacheBuilder withCapacity(int capacity) { + instance.capacity = capacity; + return this; + } + + public TokenCache build() { + instance.cache = new LinkedHashMap>(instance.capacity); + return instance; + } + } +} \ No newline at end of file diff --git a/src/main/java/org/folio/edge/rtac/model/Holdings.java b/src/main/java/org/folio/edge/rtac/model/Holdings.java new file mode 100644 index 0000000..687ffd6 --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/model/Holdings.java @@ -0,0 +1,134 @@ +package org.folio.edge.rtac.model; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.folio.edge.rtac.utils.Mappers; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JacksonXmlRootElement(localName = "holdings") +public class Holdings { + + @JacksonXmlProperty(localName = "holding") + @JacksonXmlElementWrapper(useWrapping = false) + public List holdings; + + public Holdings() { + this.holdings = new ArrayList(); + } + + @JacksonXmlRootElement(localName = "holding") + public static class Holding { + public String id; + public String callNumber; + public String location; + public String status; + public String dueDate; + public String tempLocation; + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((callNumber == null) ? 0 : callNumber.hashCode()); + result = prime * result + ((dueDate == null) ? 0 : dueDate.hashCode()); + result = prime * result + ((id == null) ? 0 : id.hashCode()); + result = prime * result + ((location == null) ? 0 : location.hashCode()); + result = prime * result + ((status == null) ? 0 : status.hashCode()); + result = prime * result + ((tempLocation == null) ? 0 : tempLocation.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Holding other = (Holding) obj; + if (callNumber == null) { + if (other.callNumber != null) + return false; + } else if (!callNumber.equals(other.callNumber)) + return false; + if (dueDate == null) { + if (other.dueDate != null) + return false; + } else if (!dueDate.equals(other.dueDate)) + return false; + if (id == null) { + if (other.id != null) + return false; + } else if (!id.equals(other.id)) + return false; + if (location == null) { + if (other.location != null) + return false; + } else if (!location.equals(other.location)) + return false; + if (status == null) { + if (other.status != null) + return false; + } else if (!status.equals(other.status)) + return false; + if (tempLocation == null) { + if (other.tempLocation != null) + return false; + } else if (!tempLocation.equals(other.tempLocation)) + return false; + return true; + } + } + + public String toXml() throws JsonProcessingException { + return Mappers.prolog + Mappers.xmlMapper.writeValueAsString(this); + } + + public String toJson() throws JsonProcessingException { + return Mappers.jsonMapper.writeValueAsString(this); + } + + public static Holdings fromJson(String json) throws JsonParseException, JsonMappingException, IOException { + return Mappers.jsonMapper.readValue(json, Holdings.class); + } + + public static Holdings fromXml(String xml) throws JsonParseException, JsonMappingException, IOException { + return Mappers.xmlMapper.readValue(xml, Holdings.class); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((holdings == null) ? 0 : holdings.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Holdings other = (Holdings) obj; + if (holdings == null) { + if (other.holdings != null) + return false; + } else if (!holdings.equals(other.holdings)) + return false; + return true; + } +} diff --git a/src/main/java/org/folio/edge/rtac/security/AwsParamStore.java b/src/main/java/org/folio/edge/rtac/security/AwsParamStore.java new file mode 100644 index 0000000..8addbaf --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/security/AwsParamStore.java @@ -0,0 +1,118 @@ +package org.folio.edge.rtac.security; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Properties; + +import org.apache.log4j.Logger; + +import com.amazonaws.SdkClientException; +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.auth.ContainerCredentialsProvider; +import com.amazonaws.auth.EnvironmentVariableCredentialsProvider; +import com.amazonaws.auth.SystemPropertiesCredentialsProvider; +import com.amazonaws.internal.CredentialsEndpointProvider; +import com.amazonaws.services.simplesystemsmanagement.AWSSimpleSystemsManagement; +import com.amazonaws.services.simplesystemsmanagement.AWSSimpleSystemsManagementClientBuilder; +import com.amazonaws.services.simplesystemsmanagement.model.GetParameterRequest; +import com.amazonaws.services.simplesystemsmanagement.model.GetParameterResult; + +public class AwsParamStore extends SecureStore { + + protected static final Logger logger = Logger.getLogger(AwsParamStore.class); + + public static final String TYPE = "AwsSsm"; + + public static final String PROP_REGION = "region"; + public static final String PROP_KEY_ID = "keyId"; + + public static final String DEFAULT_REGION = "us-east-1"; + + private String region; + + private AWSCredentialsProvider credProvider; + + protected AWSSimpleSystemsManagement ssm; + + public AwsParamStore(Properties properties) { + super(properties); + logger.info("Initializing..."); + + if (properties != null) { + region = properties.getProperty(PROP_REGION, DEFAULT_REGION); + } else { + region = DEFAULT_REGION; + } + + credProvider = new EnvironmentVariableCredentialsProvider(); + try { + credProvider.getCredentials(); + } catch (Exception e) { + try { + credProvider = new SystemPropertiesCredentialsProvider(); + } catch (Exception e2) { + try { + credProvider.getCredentials(); + } catch (Exception e3) { + credProvider = new ContainerCredentialsProvider(new ECSCredentialsEndpointProvider()); + credProvider.getCredentials(); + } + } + } + + ssm = AWSSimpleSystemsManagementClientBuilder.standard() + .withRegion(region) + .withCredentials(credProvider) + .build(); + } + + @Override + public String get(String tenant, String username) { + String ret = null; + + String key = String.format("%s_%s", tenant, username); + GetParameterRequest req = new GetParameterRequest() + .withName(key) + .withWithDecryption(true); + + try { + GetParameterResult res = ssm.getParameter(req); + if (res != null) { + ret = res.getParameter().getValue(); + } + } catch (Exception e) { + logger.error(String.format( + "Exception retreiving password for %s: ", + key), + e); + } + + return ret; + } + + protected static class ECSCredentialsEndpointProvider extends CredentialsEndpointProvider { + + /** + * Environment variable to get the Amazon ECS credentials resource path. + */ + static final String ECS_CONTAINER_CREDENTIALS_PATH = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"; + + /** Default endpoint to retreive the Amazon ECS Credentials. */ + private static final String ECS_CREDENTIALS_ENDPOINT = "http://169.254.170.2"; + + @Override + public URI getCredentialsEndpoint() throws URISyntaxException { + String path = System.getenv(ECS_CONTAINER_CREDENTIALS_PATH); + if (path == null) { + throw new SdkClientException( + "The environment variable " + ECS_CONTAINER_CREDENTIALS_PATH + " is empty"); + } + + return new URI(ECS_CREDENTIALS_ENDPOINT + path); + } + } + + public String getRegion() { + return region; + } +} diff --git a/src/main/java/org/folio/edge/rtac/security/EphemeralStore.java b/src/main/java/org/folio/edge/rtac/security/EphemeralStore.java new file mode 100644 index 0000000..0a3e49a --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/security/EphemeralStore.java @@ -0,0 +1,62 @@ +package org.folio.edge.rtac.security; + +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +import org.apache.log4j.Logger; + +public class EphemeralStore extends SecureStore { + + public static final String TYPE = "Ephemeral"; + + public static final String PROP_TENANTS = "tenants"; + + // split on comma, ignoring surrounding whitespace + public static final Pattern COMMA = Pattern.compile("\\s*[,]\\s*"); + + protected static final Logger logger = Logger.getLogger(EphemeralStore.class); + protected final Map store = new ConcurrentHashMap(); + + public EphemeralStore(Properties properties) { + super(properties); + logger.info("Initializing..."); + + if (properties != null) { + String tenants = properties.getProperty(PROP_TENANTS); + if (tenants != null) { + for (String tenant : COMMA.split(tenants)) { + String pass = properties.getProperty(tenant); + put(tenant, tenant, pass == null ? "" : pass); + } + } + } + + if (store.isEmpty()) { + logger.warn("Attention: No credentials were found/loaded"); + } + } + + @Override + public String get(String tenant, String username) { + return store.get(getKey(tenant, username)); + } + + private void put(String tenant, String username, String value) { + store.put(getKey(tenant, username), value); + } + + public String getKey(String tenant, String username) { + return String.format("%s_%s", tenant, username); + } + + public static class NoCredentialsDefinedException extends RuntimeException { + + private static final long serialVersionUID = 1811051169841565668L; + + public NoCredentialsDefinedException() { + super("No tenants/credentials defined"); + } + } +} diff --git a/src/main/java/org/folio/edge/rtac/security/SecureStore.java b/src/main/java/org/folio/edge/rtac/security/SecureStore.java new file mode 100644 index 0000000..3779388 --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/security/SecureStore.java @@ -0,0 +1,15 @@ +package org.folio.edge.rtac.security; + +import java.util.Properties; + +public abstract class SecureStore { + + protected Properties properties; + + protected SecureStore(Properties properties) { + this.properties = properties; + } + + public abstract String get(String tenant, String username); + +} diff --git a/src/main/java/org/folio/edge/rtac/security/SecureStoreFactory.java b/src/main/java/org/folio/edge/rtac/security/SecureStoreFactory.java new file mode 100644 index 0000000..6733873 --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/security/SecureStoreFactory.java @@ -0,0 +1,33 @@ +package org.folio.edge.rtac.security; + +import java.util.Properties; + +import org.apache.log4j.Logger; + +public class SecureStoreFactory { + + private static final Logger logger = Logger.getLogger(SecureStoreFactory.class); + + public static SecureStore getSecureStore(String type, Properties props) { + SecureStore ret; + + if (type == null) + type = ""; + + switch (type) { + case VaultStore.TYPE: + ret = new VaultStore(props); + break; + case AwsParamStore.TYPE: + ret = new AwsParamStore(props); + break; + case EphemeralStore.TYPE: + default: + ret = new EphemeralStore(props); + } + + logger.info(String.format("type: %s, class: %s", type, ret.getClass().getName())); + return ret; + } + +} diff --git a/src/main/java/org/folio/edge/rtac/security/VaultStore.java b/src/main/java/org/folio/edge/rtac/security/VaultStore.java new file mode 100644 index 0000000..f834f31 --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/security/VaultStore.java @@ -0,0 +1,91 @@ +package org.folio.edge.rtac.security; + +import java.io.File; +import java.util.Properties; + +import org.apache.log4j.Logger; + +import com.bettercloud.vault.SslConfig; +import com.bettercloud.vault.Vault; +import com.bettercloud.vault.VaultConfig; +import com.bettercloud.vault.VaultException; + +public class VaultStore extends SecureStore { + + public static final String TYPE = "Vault"; + + public static final String PROP_VAULT_TOKEN = "token"; + public static final String PROP_VAULT_ADDRESS = "address"; + public static final String PROP_VAULT_USE_SSL = "enableSSL"; + public static final String PROP_SSL_PEM_FILE = "ssl.pem.path"; + public static final String PROP_TRUSTSTORE_JKS_FILE = "ssl.truststore.jks.path"; + public static final String PROP_KEYSTORE_JKS_FILE = "ssl.keystore.jks.path"; + public static final String PROP_KEYSTORE_PASSWORD = "ssl.keystore.password"; + + public static final String DEFAULT_VAULT_ADDRESS = "http://127.0.0.1:8200"; + public static final String DEFAULT_VAULT_USER_SSL = "false"; + + private static final Logger logger = Logger.getLogger(VaultStore.class); + + private Vault vault; + + public VaultStore(Properties properties) { + super(properties); + logger.info("Initializing..."); + + final String token = properties.getProperty(PROP_VAULT_TOKEN); + final String addr = properties.getProperty(PROP_VAULT_ADDRESS, DEFAULT_VAULT_ADDRESS); + final boolean useSSL = Boolean.getBoolean(properties.getProperty(PROP_VAULT_USE_SSL, DEFAULT_VAULT_USER_SSL)); + + try { + VaultConfig config = new VaultConfig() + .address(addr) + .token(token); + + if (useSSL) { + SslConfig sslConfig = new SslConfig(); + + final String pemPath = properties.getProperty(PROP_SSL_PEM_FILE); + if (pemPath != null) { + sslConfig.clientKeyPemFile(new File(pemPath)); + } + + final String truststorePath = properties.getProperty(PROP_TRUSTSTORE_JKS_FILE); + if (truststorePath != null) { + sslConfig.trustStoreFile(new File(truststorePath)); + } + + final String keystorePass = properties.getProperty(PROP_KEYSTORE_PASSWORD); + final String keystorePath = properties.getProperty(PROP_KEYSTORE_JKS_FILE); + if (keystorePath != null) { + sslConfig.keyStoreFile(new File(keystorePath), keystorePass); + } + + config.sslConfig(sslConfig); + } + + vault = new Vault(config.build()); + } catch (VaultException e) { + logger.error("Failed to initialize: ", e); + } + } + + @Override + public String get(String tenant, String username) { + String ret = null; + + try { + ret = vault.logical() + .read("secret/" + tenant) + .getData() + .get(username); + } catch (VaultException e) { + logger.error(String.format("Exception retreiving password for secret/%s:%s: %s", + tenant, + username, + e.getMessage())); + } + + return ret; + } +} diff --git a/src/main/java/org/folio/edge/rtac/utils/HttpClient.java b/src/main/java/org/folio/edge/rtac/utils/HttpClient.java new file mode 100644 index 0000000..d72da94 --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/utils/HttpClient.java @@ -0,0 +1,92 @@ +package org.folio.edge.rtac.utils; + +import java.util.Map; + +import org.apache.log4j.Logger; + +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.http.HttpHeaders; + +import static org.folio.edge.rtac.Constants.*; + +public class HttpClient { + private static final Logger logger = Logger.getLogger(HttpClient.class); + + public static final long DEFAULT_REQUEST_TIMEOUT = 3 * 1000; // ms + + private final Vertx vertx; + + public HttpClient(Vertx vertx) { + this.vertx = vertx; + } + + public void post(String url, String tenant, String payload, Handler responseHandler) { + post(url, tenant, payload, null, responseHandler); + } + + public void post(String url, String tenant, String payload, Map headers, + Handler responseHandler) { + io.vertx.core.http.HttpClient httpClient = null; + + try { + httpClient = vertx.createHttpClient(); + + if (logger.isTraceEnabled()) + logger.trace("POST " + url + " Request: " + payload); + + final HttpClientRequest request = httpClient.postAbs(url); + + // safe to assume application/json. I *think* Caller can still + // override + request.putHeader(HttpHeaders.CONTENT_TYPE.toString(), "application/json") + .putHeader(HttpHeaders.ACCEPT.toString(), "application/json, text/plain") + .putHeader("X-Okapi-Tenant", tenant); + + if (headers != null) { + request.headers().addAll(headers); + } + + request.handler(responseHandler); + + request.setTimeout(DEFAULT_REQUEST_TIMEOUT) + .end(payload); + } finally { + if (httpClient != null) + httpClient.close(); + } + } + + public void get(String url, String tenant, Handler responseHandler) { + get(url, tenant, null, responseHandler); + } + + public void get(String url, String tenant, Map headers, + Handler responseHandler) { + io.vertx.core.http.HttpClient httpClient = null; + + try { + httpClient = vertx.createHttpClient(); + + logger.info("GET " + url + " tenant: " + tenant + " token: " + headers.get(X_OKAPI_TOKEN)); + + final HttpClientRequest request = httpClient.getAbs(url); + + request.putHeader(HttpHeaders.ACCEPT.toString(), "application/json, text/plain") + .putHeader(X_OKAPI_TENANT, tenant); + + if (headers != null) { + request.headers().addAll(headers); + } + + request.handler(responseHandler) + .setTimeout(DEFAULT_REQUEST_TIMEOUT) + .end(); + } finally { + if (httpClient != null) + httpClient.close(); + } + } +} diff --git a/src/main/java/org/folio/edge/rtac/utils/Mappers.java b/src/main/java/org/folio/edge/rtac/utils/Mappers.java new file mode 100644 index 0000000..2f3d6ed --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/utils/Mappers.java @@ -0,0 +1,13 @@ +package org.folio.edge.rtac.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; + +public class Mappers { + + public static final ObjectMapper jsonMapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + public static final XmlMapper xmlMapper = (XmlMapper) new XmlMapper().enable(SerializationFeature.INDENT_OUTPUT); + + public static final String prolog = "\n"; +} \ No newline at end of file diff --git a/src/main/java/org/folio/edge/rtac/utils/OkapiClient.java b/src/main/java/org/folio/edge/rtac/utils/OkapiClient.java new file mode 100644 index 0000000..6c2ba55 --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/utils/OkapiClient.java @@ -0,0 +1,124 @@ +package org.folio.edge.rtac.utils; + +import static org.folio.edge.rtac.Constants.DEFAULT_TOKEN_CACHE_CAPACITY; +import static org.folio.edge.rtac.Constants.DEFAULT_TOKEN_CACHE_TTL_MS; +import static org.folio.edge.rtac.Constants.SYS_TOKEN_CACHE_CAPACITY; +import static org.folio.edge.rtac.Constants.SYS_TOKEN_CACHE_TTL_MS; +import static org.folio.edge.rtac.Constants.X_OKAPI_TOKEN; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.apache.log4j.Logger; +import org.folio.edge.rtac.cache.TokenCache; +import org.folio.edge.rtac.cache.TokenCache.TokenCacheBuilder; + +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; + +public class OkapiClient { + + private static final Logger logger = Logger.getLogger(OkapiClient.class); + + private final String okapiURL; + private final HttpClient httpClient; + private final String tenant; + private final TokenCache tokenCache; + + protected final Map defaultHeaders = new HashMap(); + + public OkapiClient(Vertx vertx, String okapiURL, String tenant) { + this.okapiURL = okapiURL; + this.tenant = tenant; + httpClient = new HttpClient(vertx); + + final String tokenCacheTtlMs = System.getProperty(SYS_TOKEN_CACHE_TTL_MS, + DEFAULT_TOKEN_CACHE_TTL_MS); + final long cacheTtlMs = Long.parseLong(tokenCacheTtlMs); + logger.info("Using token cache TTL (ms): " + tokenCacheTtlMs); + + final String tokenCacheCapacity = System.getProperty(SYS_TOKEN_CACHE_CAPACITY, + DEFAULT_TOKEN_CACHE_CAPACITY); + final int cacheCapacity = Integer.parseInt(tokenCacheCapacity); + logger.info("Using token cache capacity: " + tokenCacheCapacity); + + tokenCache = new TokenCacheBuilder() + .withCapacity(cacheCapacity) + .withTTL(cacheTtlMs) + .build(); + } + + public CompletableFuture getToken(String username, String password) { + CompletableFuture future = new CompletableFuture(); + + String token = tokenCache.get(tenant, username); + if (token != null) { + setToken(token); + future.complete(null); + } else { + JsonObject payload = new JsonObject(); + payload.put("username", username); + payload.put("password", password); + + httpClient.post( + okapiURL + "/authn/login", + tenant, + payload.encode(), + response -> response.bodyHandler(body -> { + + try { + if (response.statusCode() == 201) { + logger.info("Successfully logged into FOLIO"); + setToken(response.getHeader(X_OKAPI_TOKEN)); + tokenCache.put(tenant, username, token); + future.complete(null); + } else { + logger.warn(String.format( + "Failed to log into FOLIO: (%s) %s", + response.statusCode(), + body.toString())); + future.complete(null); + } + } catch (Throwable t) { + logger.warn("Exception during login: " + t.getMessage()); + future.completeExceptionally(t); + } + })); + } + return future; + } + + public CompletableFuture rtac(String titleId) { + CompletableFuture future = new CompletableFuture(); + httpClient.get( + okapiURL + "/rtac/" + titleId, + tenant, + defaultHeaders, response -> response.bodyHandler(body -> { + try { + if (response.statusCode() == 200) { + logger.info(String.format( + "Successfully retrieved title info from mod-rtac: (%s) %s", + response.statusCode(), + body.toString())); + future.complete(body.toString()); + } else { + String err = String.format( + "Failed to get title info from mod-rtac: (%s) %s", + response.statusCode(), + body.toString()); + logger.error(err); + future.complete("{}"); + } + } catch (Throwable t) { + logger.error("Exception calling mod-rtac: " + t.getMessage()); + future.complete("{}"); + } + })); + return future; + } + + private void setToken(String token) { + defaultHeaders.put(X_OKAPI_TOKEN, token); + } +} diff --git a/src/main/java/org/folio/edge/rtac/utils/OkapiClientFactory.java b/src/main/java/org/folio/edge/rtac/utils/OkapiClientFactory.java new file mode 100644 index 0000000..1163ad8 --- /dev/null +++ b/src/main/java/org/folio/edge/rtac/utils/OkapiClientFactory.java @@ -0,0 +1,18 @@ +package org.folio.edge.rtac.utils; + +import io.vertx.core.Vertx; + +public class OkapiClientFactory { + + public final String okapiURL; + public final Vertx vertx; + + public OkapiClientFactory(Vertx vertx, String okapiURL) { + this.vertx = vertx; + this.okapiURL = okapiURL; + } + + public OkapiClient getOkapiClient(String tenant) { + return new OkapiClient(vertx, okapiURL, tenant); + } +} \ No newline at end of file diff --git a/src/main/resources/aws_ss.properties b/src/main/resources/aws_ss.properties new file mode 100644 index 0000000..6a2b95e --- /dev/null +++ b/src/main/resources/aws_ss.properties @@ -0,0 +1,5 @@ +secureStore.type=AwsSsm + +# The AWS region to pass to the AWS SSM Client +# Default: us-east-1 +#region={REGION} diff --git a/src/main/resources/ephemeral.properties b/src/main/resources/ephemeral.properties new file mode 100644 index 0000000..68035e8 --- /dev/null +++ b/src/main/resources/ephemeral.properties @@ -0,0 +1,13 @@ +secureStore.type=Ephemeral + +# a comma separated list of tenants +tenants=fs00000000,diku + +####################################################### +# For each tenant, the institutional user password... +# +# Note: this is intended for development purposes only +####################################################### + +fs00000000={FS00000000_IU_PASSWORD} +diku={DIKU_IU_PASSWORD} diff --git a/src/main/resources/log4j.properties b/src/main/resources/log4j.properties new file mode 100644 index 0000000..ab03035 --- /dev/null +++ b/src/main/resources/log4j.properties @@ -0,0 +1,8 @@ +# variables are substituted from filters-[production|development].properties +log4j.rootLogger=INFO, CONSOLE +#log4j.rootLogger=DEBUG, CONSOLE + +# CONSOLE is set to be a ConsoleAppender using a PatternLayout. +log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender +log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout +log4j.appender.CONSOLE.layout.ConversionPattern=%d{HH:mm:ss} %-5p %-20.20C{1} %m%n diff --git a/src/main/resources/vault.properties b/src/main/resources/vault.properties new file mode 100644 index 0000000..f0ad29d --- /dev/null +++ b/src/main/resources/vault.properties @@ -0,0 +1,34 @@ +secureStore.type=Vault + +# token for accessing vault, may be a root token +# e.g. 52046e42-0679-e707-cd99-3694c6a8d4e9 +token={VAULT_TOKEN} + +# key used for unsealing the vault (not yet implemented) +# e.g. MnqOjQIwznFh1ddIBQfvm1munynyiAnizDRA+FMHlqE= +#key={VAULT_KEY} + +# the address of your vault +# Default: http://127.0.0.1:8200 +#address={VAULT_ADDR} + +# whether or not to use SSL [true|false] +# Default: false +#enableSSL=false +# +# NOTE: JKS-based config trumps PEM-based config. If you provide both JKS and PEM configs, +# then the JKS config will be used. +# You cannot "mix-and-match", providing a JKS-based truststore and PEM-based client auth data. +# +# the path to an X.509 certificate in unencrypted PEM format, using UTF-8 encoding +#ssl.pem.path={PEM_PATH} +# +# the path to a JKS truststore file containing Vault server certs that can be trusted +#ssl.truststore.jks.path={TRUSTSTORE_PATH} +# +# the path to a JKS keystore file containing a client cert and private key +#ssl.keystore.jks.path={KEYSTORE_PATH} +# +# the password used to access the JKS keystore (optional) +#ssl.keystore.password={KEYWORD_PASSWORD} + diff --git a/src/test/java/org/folio/edge/rtac/MainVerticleTest.java b/src/test/java/org/folio/edge/rtac/MainVerticleTest.java new file mode 100644 index 0000000..16f3107 --- /dev/null +++ b/src/test/java/org/folio/edge/rtac/MainVerticleTest.java @@ -0,0 +1,209 @@ +package org.folio.edge.rtac; + +import static org.folio.edge.rtac.Constants.*; +import static org.junit.Assert.*; + +import org.apache.http.HttpHeaders; +import org.apache.log4j.Logger; +import org.folio.edge.rtac.model.Holdings; +import org.folio.edge.rtac.utils.MockOkapi; +import org.folio.edge.rtac.utils.TestUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import com.jayway.restassured.RestAssured; +import com.jayway.restassured.http.ContentType; +import com.jayway.restassured.response.Response; + +import io.vertx.core.DeploymentOptions; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; + +@RunWith(VertxUnitRunner.class) +public class MainVerticleTest { + + private static final Logger logger = Logger.getLogger(MainVerticleTest.class); + + private final String titleId = "0c8e8ac5-6bcc-461e-a8d3-4b55a96addc8"; + private final String apiKey = "ZnMwMDAwMDAwMA=="; + + private Vertx vertx; + private MockOkapi mockOkapi; + + private int okapiPort = TestUtils.getPort(); + private int serverPort = TestUtils.getPort(); + + // ** setUp/tearDown **// + + @Before + public void setUp(TestContext context) throws Exception { + + mockOkapi = new MockOkapi(okapiPort); + mockOkapi.start(context); + vertx = Vertx.vertx(); + + final JsonObject conf = new JsonObject(); + conf.put(SYS_PORT, String.valueOf(serverPort)); + conf.put(SYS_OKAPI_URL, "http://localhost:" + okapiPort); + conf.put(SYS_SECURE_STORE_PROP_FILE, "src/main/resources/ephemeral.properties"); + + final DeploymentOptions opt = new DeploymentOptions().setConfig(conf); + vertx.deployVerticle(MainVerticle.class.getName(), opt, context.asyncAssertSuccess()); + + RestAssured.baseURI = "http://localhost:" + serverPort; + RestAssured.port = serverPort; + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @After + public void tearDown(TestContext context) { + logger.info("Closing Vertx"); + vertx.close(context.asyncAssertSuccess()); + } + + // ** Test cases **// + + @Test + public void testAdminHealth(TestContext context) { + final Async async = context.async(); + + final Response resp = RestAssured + .get("/admin/health") + .then() + .contentType(ContentType.TEXT) + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, "text/plain") + .extract() + .response(); + + assertEquals("\"OK\"", resp.body().asString()); + + async.complete(); + } + + @Test + public void testRtacTitleFound(TestContext context) throws Exception { + final Async async = context.async(); + + final Response resp = RestAssured + .get(String.format("/prod/rtac/folioRTAC?mms_id=%s&apikey=%s", titleId, apiKey)) + .then() + .contentType(ContentType.XML) + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, "application/xml") + .extract() + .response(); + + Holdings expected = Holdings.fromJson(MockOkapi.getHoldingsJson(titleId)); + Holdings actual = Holdings.fromXml(resp.body().asString()); + assertEquals(expected, actual); + + async.complete(); + } + + @Test + public void testRtacTitleNotFound(TestContext context) throws Exception { + final Async async = context.async(); + + final Response resp = RestAssured + .get(String.format("/prod/rtac/folioRTAC?mms_id=%s&apikey=%s", + MockOkapi.titleId_notFound, apiKey)) + .then() + .contentType(ContentType.XML) + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, "application/xml") + .extract() + .response(); + + Holdings expected = new Holdings(); + Holdings actual = Holdings.fromXml(resp.body().asString()); + assertEquals(expected, actual); + + async.complete(); + } + + @Test + public void testRtacNoApiKey(TestContext context) throws Exception { + final Async async = context.async(); + + final Response resp = RestAssured + .get(String.format("/prod/rtac/folioRTAC?mms_id=%s", MockOkapi.titleId_notFound)) + .then() + .contentType(ContentType.XML) + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, "application/xml") + .extract() + .response(); + + Holdings expected = new Holdings(); + Holdings actual = Holdings.fromXml(resp.body().asString()); + assertEquals(expected, actual); + + async.complete(); + } + + @Test + public void testRtacNoId(TestContext context) throws Exception { + final Async async = context.async(); + + final Response resp = RestAssured + .get(String.format("/prod/rtac/folioRTAC?apikey=%s", apiKey)) + .then() + .contentType(ContentType.XML) + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, "application/xml") + .extract() + .response(); + + Holdings expected = new Holdings(); + Holdings actual = Holdings.fromXml(resp.body().asString()); + assertEquals(expected, actual); + + async.complete(); + } + + @Test + public void testRtacNoQueryArgs(TestContext context) throws Exception { + final Async async = context.async(); + + final Response resp = RestAssured + .get("/prod/rtac/folioRTAC") + .then() + .contentType(ContentType.XML) + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, "application/xml") + .extract() + .response(); + + Holdings expected = new Holdings(); + Holdings actual = Holdings.fromXml(resp.body().asString()); + assertEquals(expected, actual); + + async.complete(); + } + + @Test + public void testRtacEmptyQueryArgs(TestContext context) throws Exception { + final Async async = context.async(); + + final Response resp = RestAssured + .get("/prod/rtac/folioRTAC?mms_id=&apikey=") + .then() + .contentType(ContentType.XML) + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, "application/xml") + .extract() + .response(); + + Holdings expected = new Holdings(); + Holdings actual = Holdings.fromXml(resp.body().asString()); + assertEquals(expected, actual); + + async.complete(); + } +} diff --git a/src/test/java/org/folio/edge/rtac/cache/TokenCacheTest.java b/src/test/java/org/folio/edge/rtac/cache/TokenCacheTest.java new file mode 100644 index 0000000..dd47ce5 --- /dev/null +++ b/src/test/java/org/folio/edge/rtac/cache/TokenCacheTest.java @@ -0,0 +1,125 @@ +package org.folio.edge.rtac.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.folio.edge.rtac.cache.TokenCache; +import org.folio.edge.rtac.cache.TokenCache.TokenCacheBuilder; +import org.junit.Before; +import org.junit.Test; + +public class TokenCacheTest { + + final int cap = 50; + final long ttl = 5000; + + TokenCache cache; + + private final String tenant = "diku"; + private final String user = "diku"; + private final String val = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJkaWt1IiwidXNlcl9pZCI6Ijk3ZTNmYzVmLTVjMzMtNGY2Ny1hZmRiLWEzYjI5YTVhYWZjOCIsInRlbmFudCI6ImRpa3UifQ.uda9KgBn82jCR3FXd73CnkmfDDk3OBQI0bjrJ5L7oJ8fS_7-TDNj7UKiFl-YxnqwFGHGACprsG5Bp7kkG8ArZA"; + + @Before + public void setUp() throws Exception { + cache = new TokenCacheBuilder() + .withCapacity(cap) + .withTTL(ttl) + .build(); + } + + @Test + public void testEmpty() throws Exception { + // empty cache... + assertNull(cache.get(tenant, user)); + } + + @Test + public void testGetPutGet() throws Exception { + // empty cache... + assertNull(cache.get(tenant, user)); + + // basic functionality + cache.put(tenant, user, 1L); + assertEquals(1L, cache.get(tenant, user).longValue()); + } + + @Test + public void testNoOverwrite() throws Exception { + // make sure we don't overwrite the cached value + Long val = 1L; + Long start = System.currentTimeMillis(); + + cache.put(tenant, user, val); + assertEquals(val.longValue(), cache.get(tenant, user).longValue()); + + while (System.currentTimeMillis() < (start + ttl)) { + cache.put(tenant, user, ++val); + assertEquals(1L, cache.get(tenant, user).longValue()); + } + + // wait a little longer just to be sure... + try { + Thread.sleep(10); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + // should have expired + assertNull(cache.get(tenant, user)); + } + + @Test + public void testPruneExpires() throws Exception { + cache.put(tenant, user + 0, 0L); + Thread.sleep(ttl + 10); // wait a tad longer than ttl just to be sure + + // load capacity + 1 entries triggering eviction of the first + for (Long i = 1L; i <= cap; i++) { + cache.put(tenant, user + i, i); + } + + // should be evicted as it's the oldest + assertNull(cache.get(tenant, user + 0)); + + // should still be cached + for (Long i = 1L; i <= cap; i++) { + assertEquals(i.longValue(), cache.get(tenant, user + i).longValue()); + } + } + + @Test + public void testPruneNoExpires() throws Exception { + // load capacity + 1 entries triggering eviction of the first + for (Long i = 0L; i <= cap; i++) { + cache.put(tenant, user + i, i); + } + + // should be evicted as it's the oldest + assertNull(cache.get(tenant, user + 0)); + + // should still be cached + for (Long i = 1L; i <= cap; i++) { + assertEquals(i.longValue(), cache.get(tenant, user + i).longValue()); + } + } + + @Test + public void testString() throws Exception { + TokenCache cache = new TokenCacheBuilder() + .withCapacity(cap) + .withTTL(ttl) + .build(); + + // empty cache... + assertNull(cache.get(tenant, user)); + + // basic functionality + cache.put(tenant, user, val); + assertEquals(val, cache.get(tenant, user)); + + Thread.sleep(ttl + 1); + + // empty cache... + assertNull(cache.get(tenant, user)); + } +} diff --git a/src/test/java/org/folio/edge/rtac/model/HoldingsTest.java b/src/test/java/org/folio/edge/rtac/model/HoldingsTest.java new file mode 100644 index 0000000..e063267 --- /dev/null +++ b/src/test/java/org/folio/edge/rtac/model/HoldingsTest.java @@ -0,0 +1,97 @@ +package org.folio.edge.rtac.model; + +import static org.junit.Assert.*; + +import java.io.File; +import java.io.IOException; +import java.io.StringReader; + +import javax.xml.XMLConstants; +import javax.xml.transform.Source; +import javax.xml.transform.stream.StreamSource; +import javax.xml.validation.Schema; +import javax.xml.validation.SchemaFactory; +import javax.xml.validation.Validator; + +import org.apache.log4j.Logger; +import org.folio.edge.rtac.model.Holdings.Holding; +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.SAXException; + +public class HoldingsTest { + + private static final Logger logger = Logger.getLogger(HoldingsTest.class); + + private static final String holdingsXSD = "ramls/holdings.xsd"; + private Validator validator; + + private Holdings holdings; + + @Before + public void setUp() throws Exception { + Holding h1 = new Holding(); + h1.id = "99712686103569"; + h1.callNumber = "PS3552.E796 D44x 1975"; + h1.location = "LC General Collection Millersville University Library"; + h1.status = "Item in place"; + h1.tempLocation = ""; + h1.dueDate = ""; + + Holding h2 = new Holding(); + h2.id = "99712686103569"; + h2.callNumber = "PS3552.E796 D45x 1975"; + h2.location = "LC General Collection Millersville University Library"; + h2.status = "Checked out"; + h2.tempLocation = ""; + h2.dueDate = "2018-04-23 12:00:00"; + + holdings = new Holdings(); + holdings.holdings.add(h1); + holdings.holdings.add(h2); + + SchemaFactory schemaFactory = SchemaFactory + .newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); + Schema schema = schemaFactory.newSchema(new File(holdingsXSD)); + validator = schema.newValidator(); + + } + + @Test + public void testToFromJson() throws IOException { + String json = holdings.toJson(); + logger.info("JSON: " + json); + + Holdings fromJson = Holdings.fromJson(json); + assertEquals(holdings, fromJson); + } + + @Test + public void testToFromXml() throws IOException { + String xml = holdings.toXml(); + logger.info("XML: " + xml); + + Source source = new StreamSource(new StringReader(xml)); + try { + validator.validate(source); + } catch (SAXException e) { + fail("XML validation failed: " + e.getMessage()); + } + + Holdings fromXml = Holdings.fromXml(xml); + assertEquals(holdings, fromXml); + } + + @Test + public void testEmpty() throws IOException { + String xml = new Holdings().toXml(); + logger.info("XML: " + xml); + + Source source = new StreamSource(new StringReader(xml)); + try { + validator.validate(source); + } catch (SAXException e) { + fail("XML validation failed: " + e.getMessage()); + } + } +} diff --git a/src/test/java/org/folio/edge/rtac/security/AwsParamStoreTest.java b/src/test/java/org/folio/edge/rtac/security/AwsParamStoreTest.java new file mode 100644 index 0000000..8c6a451 --- /dev/null +++ b/src/test/java/org/folio/edge/rtac/security/AwsParamStoreTest.java @@ -0,0 +1,93 @@ +package org.folio.edge.rtac.security; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.when; + +import java.util.Properties; + +import org.apache.log4j.Level; +import org.folio.edge.rtac.utils.TestUtils; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import com.amazonaws.services.simplesystemsmanagement.AWSSimpleSystemsManagement; +import com.amazonaws.services.simplesystemsmanagement.model.AWSSimpleSystemsManagementException; +import com.amazonaws.services.simplesystemsmanagement.model.GetParameterRequest; +import com.amazonaws.services.simplesystemsmanagement.model.GetParameterResult; +import com.amazonaws.services.simplesystemsmanagement.model.Parameter; + +public class AwsParamStoreTest { + + @Mock + AWSSimpleSystemsManagement ssm; + + @InjectMocks + AwsParamStore secureStore; + + @Before + public void setUp() throws Exception { + // Either use env vars or system props so that tests + // can be run in non-ECS container environments. + // + // Use system props since they're easier to deal with + // programmatically. + System.setProperty("aws.accessKeyId", "bogus"); + System.setProperty("aws.secretKey", "bogus"); + + // Use empty properties since the only thing configurable + // is related to AWS, which is mocked here + secureStore = new AwsParamStore(new Properties()); + + MockitoAnnotations.initMocks(this); + } + + @Test + public void testConstruction() { + assertEquals(AwsParamStore.DEFAULT_REGION, secureStore.getRegion()); + + String euCentral1 = "eu-central-1"; + + Properties diffProps = new Properties(); + diffProps.setProperty(AwsParamStore.PROP_REGION, euCentral1); + + secureStore = new AwsParamStore(diffProps); + assertEquals(euCentral1, secureStore.getRegion()); + } + + @Test + public void testGetFound() { + // test data & expected values + String tenant = "foo"; + String user = "bar"; + String val = "letmein"; + String key = tenant + "_" + user; + + // setup mocks/spys/etc. + GetParameterRequest req = new GetParameterRequest().withName(key).withWithDecryption(true); + GetParameterResult resp = new GetParameterResult().withParameter(new Parameter().withName(key).withValue(val)); + when(ssm.getParameter(req)).thenReturn(resp); + + // test & assertions + assertEquals(val, secureStore.get(tenant, user)); + } + + @Test + public void testGetNotFound() { + String exceptionMsg = "Parameter null_null not found. (Service: AWSSimpleSystemsManagement; Status Code: 400; Error Code: ParameterNotFound; Request ID: 25fc4a22-9839-4645-b7b4-ad40aa643821)"; + String logMsg = "Exception retreiving password for null_null: "; + Throwable exception = new AWSSimpleSystemsManagementException(exceptionMsg); + + when(ssm.getParameter(any())).thenThrow(exception); + + TestUtils.assertLogMessage(AwsParamStore.logger, 1, 1, Level.ERROR, logMsg, exception, () -> { + String val = secureStore.get(null, null); + assertNull(val); + }); + } + +} diff --git a/src/test/java/org/folio/edge/rtac/security/EphemeralStoreTest.java b/src/test/java/org/folio/edge/rtac/security/EphemeralStoreTest.java new file mode 100644 index 0000000..d3988e9 --- /dev/null +++ b/src/test/java/org/folio/edge/rtac/security/EphemeralStoreTest.java @@ -0,0 +1,59 @@ +package org.folio.edge.rtac.security; + +import static org.junit.Assert.assertEquals; +import static org.folio.edge.rtac.utils.TestUtils.assertLogMessage; + +import java.util.Properties; + +import org.apache.log4j.Level; +import org.junit.Before; +import org.junit.Test; + +public class EphemeralStoreTest { + + private Properties props; + private EphemeralStore store; + + @Before + public void setUp() throws Exception { + props = new Properties(); + props.setProperty(EphemeralStore.PROP_TENANTS, "dit ,dat, dot,done"); + props.setProperty("dit", "dit_password"); + props.setProperty("dat", "dat_password"); + props.setProperty("dot", "dot_password"); + + store = new EphemeralStore(props); + } + + @Test + public void testConstructor() { + assertLogMessage(EphemeralStore.logger, 1, 2, Level.WARN, "Attention: No credentials were found/loaded", null, + () -> { + new EphemeralStore(null); + }); + + assertLogMessage(EphemeralStore.logger, 1, 2, Level.WARN, "Attention: No credentials were found/loaded", null, + () -> { + new EphemeralStore(new Properties()); + }); + } + + @Test + public void testGet() { + assertEquals(4, store.store.size()); + assertEquals("dit_password", store.get("dit", "dit")); + assertEquals("dot_password", store.get("dot", "dot")); + assertEquals("dat_password", store.get("dat", "dat")); + assertEquals("", store.get("done", "done")); + assertEquals(null, store.get(null, null)); + } + + @Test + public void testGetKey() { + assertEquals("tenant_username", store.getKey("tenant", "username")); + assertEquals("tenant_null", store.getKey("tenant", null)); + assertEquals("null_username", store.getKey(null, "username")); + assertEquals("null_null", store.getKey(null, null)); + } + +} diff --git a/src/test/java/org/folio/edge/rtac/security/SecureStoreFactoryTest.java b/src/test/java/org/folio/edge/rtac/security/SecureStoreFactoryTest.java new file mode 100644 index 0000000..605dc84 --- /dev/null +++ b/src/test/java/org/folio/edge/rtac/security/SecureStoreFactoryTest.java @@ -0,0 +1,46 @@ +package org.folio.edge.rtac.security; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.Properties; + +public class SecureStoreFactoryTest { + + public static final Class DEFAULT_SS_CLASS = EphemeralStore.class; + + public void testGetSecureStoreKnownTypes() + throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, SecurityException { + Class[] stores = new Class[] { + AwsParamStore.class, + EphemeralStore.class, + VaultStore.class + }; + + SecureStore actual; + + for (Class clazz : stores) { + actual = SecureStoreFactory.getSecureStore((String) clazz.getField("TYPE").get(null), new Properties()); + assertThat(actual, instanceOf(clazz)); + actual = SecureStoreFactory.getSecureStore((String) clazz.getField("TYPE").get(null), null); + assertThat(actual, instanceOf(clazz)); + } + } + + public void testGetSecureStoreDefaultType() + throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, SecurityException { + SecureStore actual; + + // unknown type + actual = SecureStoreFactory.getSecureStore("foo", new Properties()); + assertThat(actual, instanceOf(DEFAULT_SS_CLASS)); + actual = SecureStoreFactory.getSecureStore("foo", null); + assertThat(actual, instanceOf(DEFAULT_SS_CLASS)); + + // null type + actual = SecureStoreFactory.getSecureStore(null, new Properties()); + assertThat(actual, instanceOf(DEFAULT_SS_CLASS)); + actual = SecureStoreFactory.getSecureStore(null, null); + assertThat(actual, instanceOf(DEFAULT_SS_CLASS)); + } +} diff --git a/src/test/java/org/folio/edge/rtac/security/VaultStoreTest.java b/src/test/java/org/folio/edge/rtac/security/VaultStoreTest.java new file mode 100644 index 0000000..b75bfc9 --- /dev/null +++ b/src/test/java/org/folio/edge/rtac/security/VaultStoreTest.java @@ -0,0 +1,67 @@ +package org.folio.edge.rtac.security; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Properties; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import com.bettercloud.vault.Vault; +import com.bettercloud.vault.api.Logical; +import com.bettercloud.vault.response.LogicalResponse; +import com.bettercloud.vault.rest.RestResponse; + +public class VaultStoreTest { + + @Mock + Vault vault; + + @InjectMocks + VaultStore secureStore; + + @Before + public void setUp() throws Exception { + + Properties props = new Properties(); + + secureStore = new VaultStore(props); + + MockitoAnnotations.initMocks(this); + } + + @Test + public void testGet() throws Exception { + String password = "Pa$$w0rd"; + + LogicalResponse successResp = new LogicalResponse( + new RestResponse( + 200, + "application/json", + ("{\"data\":{\"diku\":\"" + password + "\"}}").getBytes()), + 0); + + LogicalResponse failureResp = new LogicalResponse( + new RestResponse( + 404, + "application/json", + "{\"errors\":[]}".getBytes()), + 0); + + Logical logical = mock(Logical.class); + when(vault.logical()).thenReturn(logical); + when(logical.read("secret/diku")).thenReturn(successResp); + when(logical.read("secret/bogus")).thenReturn(failureResp); + + assertEquals(password, secureStore.get("diku", "diku")); + assertNull(secureStore.get("bogus", "bogus")); + } + + // TODO Add test coverage for SSL/TLS configuration +} diff --git a/src/test/java/org/folio/edge/rtac/utils/MockOkapi.java b/src/test/java/org/folio/edge/rtac/utils/MockOkapi.java new file mode 100644 index 0000000..c0a3946 --- /dev/null +++ b/src/test/java/org/folio/edge/rtac/utils/MockOkapi.java @@ -0,0 +1,121 @@ +package org.folio.edge.rtac.utils; + +import static org.folio.edge.rtac.Constants.X_OKAPI_TENANT; +import static org.folio.edge.rtac.Constants.X_OKAPI_TOKEN; + +import org.apache.log4j.Logger; +import org.folio.edge.rtac.model.Holdings; +import org.folio.edge.rtac.model.Holdings.Holding; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServer; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.handler.BodyHandler; + +public class MockOkapi { + + private static final Logger logger = Logger.getLogger(MockOkapi.class); + + public static final String mockToken = "mynameisyonyonsonicomefromwisconsoniworkatalumbermillthereallthepeopleimeetasiwalkdownthestreetaskhowinthehelldidyougethereisaymynameisyonyonsonicomefromwisconson"; + public static final String titleId_notFound = "0c8e8ac5-6bcc-461e-a8d3-4b55a96addc9"; + + public final int okapiPort; + public final Vertx vertx; + + public MockOkapi(int port) { + okapiPort = port; + vertx = Vertx.vertx(); + } + + public void start(TestContext context) { + + // Setup Mock Okapi... + Router router = Router.router(vertx); + HttpServer server = vertx.createHttpServer(); + + router.route().handler(BodyHandler.create()); + router.route(HttpMethod.POST, "/authn/login").handler(ctx -> { + JsonObject body = ctx.getBodyAsJson(); + + int status; + String resp = null; + if (ctx.request().getHeader(X_OKAPI_TENANT) == null) { + status = 400; + resp = "Unable to process request Tenant must be set"; + } else if (!body.containsKey("username") || !body.containsKey("password")) { + status = 400; + resp = "Json content error"; + } else if (ctx.request().getHeader(HttpHeaders.CONTENT_TYPE) == null || + !ctx.request().getHeader(HttpHeaders.CONTENT_TYPE).equals("application/json")) { + status = 400; + resp = "Content-type header must be [\"application/json\"]"; + } else { + status = 201; + resp = body.toString(); + } + + ctx.response() + .setStatusCode(status) + .putHeader(X_OKAPI_TOKEN, mockToken) + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(resp); + }); + router.route(HttpMethod.GET, "/rtac/:titleid").handler(ctx -> { + String titleId = ctx.request().getParam("titleid"); + String token = ctx.request().getHeader(X_OKAPI_TOKEN); + if (token == null || !token.equals(mockToken)) { + ctx.response() + .setStatusCode(403) + .end("Access requires permission: rtac.holdings.item.get"); + } else if (titleId.equals(titleId_notFound)) { + // Magic titleID signifying we want to mock a "not found" + // response. + ctx.response() + .setStatusCode(404) + .end("rtac not found"); + } else { + ctx.response() + .setStatusCode(200) + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(getHoldingsJson(titleId)); + } + }); + + final Async async = context.async(); + server.requestHandler(router::accept).listen(okapiPort, result -> { + if (result.failed()) { + logger.warn(result.cause()); + } + context.assertTrue(result.succeeded()); + async.complete(); + }); + } + + public static String getHoldingsJson(String titleId) { + Holding h = new Holding(); + h.id = titleId; + h.callNumber = "PS3552.E796 D44x 1975"; + h.location = "LC General Collection Millersville University Library"; + h.status = "Item in place"; + h.tempLocation = ""; + h.dueDate = ""; + + Holdings holdings = new Holdings(); + holdings.holdings.add(h); + + String ret = null; + try { + ret = holdings.toJson(); + } catch (JsonProcessingException e) { + logger.warn("Failed to generate holdings JSON", e); + } + return ret; + } +} diff --git a/src/test/java/org/folio/edge/rtac/utils/OkapiClientFactoryTest.java b/src/test/java/org/folio/edge/rtac/utils/OkapiClientFactoryTest.java new file mode 100644 index 0000000..ffaeee6 --- /dev/null +++ b/src/test/java/org/folio/edge/rtac/utils/OkapiClientFactoryTest.java @@ -0,0 +1,27 @@ +package org.folio.edge.rtac.utils; + +import static org.junit.Assert.assertNotNull; + +import org.junit.Before; +import org.junit.Test; + +import io.vertx.core.Vertx; + +public class OkapiClientFactoryTest { + + private OkapiClientFactory ocf; + + @Before + public void setUp() throws Exception { + + Vertx vertx = Vertx.vertx(); + ocf = new OkapiClientFactory(vertx, "http://mocked.okapi:9130"); + } + + @Test + public void testGetOkapiClient() { + OkapiClient client = ocf.getOkapiClient("tenant"); + assertNotNull(client); + } + +} diff --git a/src/test/java/org/folio/edge/rtac/utils/OkapiClientTest.java b/src/test/java/org/folio/edge/rtac/utils/OkapiClientTest.java new file mode 100644 index 0000000..6941408 --- /dev/null +++ b/src/test/java/org/folio/edge/rtac/utils/OkapiClientTest.java @@ -0,0 +1,77 @@ +package org.folio.edge.rtac.utils; + +import static org.folio.edge.rtac.Constants.X_OKAPI_TOKEN; +import static org.junit.Assert.assertEquals; + +import org.apache.log4j.Logger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import io.vertx.core.Vertx; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; + +@RunWith(VertxUnitRunner.class) +public class OkapiClientTest { + + private static final Logger logger = Logger.getLogger(OkapiClientTest.class); + + private final String mockToken = MockOkapi.mockToken; + private final String titleId = "0c8e8ac5-6bcc-461e-a8d3-4b55a96addc8"; + + private Vertx vertx; + private OkapiClient client; + private MockOkapi mockOkapi; + + // ** setUp/tearDown **// + + @Before + public void setUp(TestContext context) throws Exception { + int okapiPort = TestUtils.getPort(); + + mockOkapi = new MockOkapi(okapiPort); + mockOkapi.start(context); + vertx = Vertx.vertx(); + + client = new OkapiClientFactory(vertx, "http://localhost:" + okapiPort).getOkapiClient("testtenant"); + } + + @After + public void tearDown(TestContext context) { + logger.info("Closing Vertx"); + vertx.close(context.asyncAssertSuccess()); + } + + // ** Test cases **// + + @Test + public void testLogin(TestContext context) { + Async async = context.async(); + client.getToken("admin", "password").thenRun(() -> { + logger.info(client.defaultHeaders.get(X_OKAPI_TOKEN)); + + // Ensure that the client's default headers now contain the + // x-okapi-token for use in subsequent okapi calls + assertEquals(mockToken, client.defaultHeaders.get(X_OKAPI_TOKEN)); + async.complete(); + }); + } + + @Test + public void testRtac(TestContext context) { + Async async = context.async(); + client.getToken("admin", "password").thenAccept(v -> { + // Redundant - also checked in testLogin(...), but can't hurt + assertEquals(mockToken, client.defaultHeaders.get(X_OKAPI_TOKEN)); + + client.rtac(titleId).thenAccept(body -> { + logger.info("mod-rtac response body: " + body); + assertEquals(body, MockOkapi.getHoldingsJson(titleId)); + async.complete(); + }); + }); + } +} diff --git a/src/test/java/org/folio/edge/rtac/utils/TestUtils.java b/src/test/java/org/folio/edge/rtac/utils/TestUtils.java new file mode 100644 index 0000000..2167bfd --- /dev/null +++ b/src/test/java/org/folio/edge/rtac/utils/TestUtils.java @@ -0,0 +1,48 @@ +package org.folio.edge.rtac.utils; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.atMost; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import org.apache.log4j.Appender; +import org.apache.log4j.Level; +import org.apache.log4j.Logger; +import org.apache.log4j.spi.LoggingEvent; +import org.mockito.ArgumentCaptor; + +public class TestUtils { + + public static int getPort() { + return 1024 + (int) (Math.random() * 1000); + } + + public static void assertLogMessage(Logger logger, int minTimes, int maxTimes, Level logLevel, String expectedMsg, + Throwable t, Runnable func) { + Appender appender = mock(Appender.class); + + try { + logger.addAppender(appender); + ArgumentCaptor argument = ArgumentCaptor.forClass(LoggingEvent.class); + + func.run(); + + verify(appender, atLeast(minTimes)).doAppend(argument.capture()); + verify(appender, atMost(maxTimes)).doAppend(argument.capture()); + + if (logLevel != null) + assertEquals(logLevel, argument.getValue().getLevel()); + + if (expectedMsg != null) + assertEquals(expectedMsg, argument.getValue().getMessage()); + + if (t != null) + assertEquals(t, argument.getValue().getThrowableInformation().getThrowable()); + } finally { + if (logger != null && appender != null) { + logger.removeAppender(appender); + } + } + } +}