diff --git a/NEWS.md b/NEWS.md index cc5d883..e358e9a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,9 @@ +## 5.2.1 2025-01-13 + +* Implement secure endpoints to support congressional loans ([EDGPATRON-157](https://issues.folio.org/browse/EDGPATRON-157)) +* Add put API for /patron/{externalSystemId} ([EDGPATRON-160](https://issues.folio.org/browse/EDGPATRON-160)) +* Extend get API for /patron/registration-status with externalSystemId ([EDGPATRON-161](https://issues.folio.org/browse/EDGPATRON-161)) + ## 5.2.0 2024-10-31 * Add new API contract for POST and GET API of LC User registration ([EDGPATRON-151](https://issues.folio.org/browse/EDGPATRON-151)) diff --git a/README.md b/README.md index 1762413..39c655f 100644 --- a/README.md +++ b/README.md @@ -32,20 +32,28 @@ Configuration information is specified in two forms: ### System Properties -| Property | 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) | -| `null_token_cache_ttl_ms` | `30000` | How long to cache login failures (null JWTs), in milliseconds (ms) | -| `token_cache_capacity` | `100` | Max token cache size | -| `patron_id_cache_ttl_ms` | `3600000` | How long to cache patron ID mappings in milliseconds (ms) | -| `null_patron_id_cache_ttl_ms` | `30000` | How long to cache patron lookup failures in milliseconds (ms) | -| `patron_id_cache_capacity` | `1000` | Max token cache size | -| `log_level` | `INFO` | Log4j Log Level | -| `request_timeout_ms` | `30000` | Request Timeout | +| Property | 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) | +| `null_token_cache_ttl_ms` | `30000` | How long to cache login failures (null JWTs), in milliseconds (ms) | +| `token_cache_capacity` | `100` | Max token cache size | +| `patron_id_cache_ttl_ms` | `3600000` | How long to cache patron ID mappings in milliseconds (ms) | +| `null_patron_id_cache_ttl_ms` | `30000` | How long to cache patron lookup failures in milliseconds (ms) | +| `patron_id_cache_capacity` | `1000` | Max token cache size | +| `keycloak_key_cache_ttl_ms` | `3600000` | How long to cache patron ID mappings in milliseconds (ms) | +| `null_keycloak_key_cache_ttl_ms` | `30000` | How long to cache patron lookup failures in milliseconds (ms) | +| `keycloak_key_cache_capacity` | `1000` | Max token cache size | +| `log_level` | `INFO` | Log4j Log Level | +| `request_timeout_ms` | `30000` | Request Timeout | + +### Env variables for secure requests +| Property | Default | Description | +|----------|----------------|------------------------------------------| +| `KC_URL` | `` | Keycloak url for secure token validation | ### Env variables for TLS configuration for Http server diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 0cdb237..3cbe691 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -5,7 +5,7 @@ "requires": [ { "id": "patron", - "version": "6.0" + "version": "6.3" }, { "id": "circulation", diff --git a/pom.xml b/pom.xml index f0f5c9b..11b73af 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 org.folio edge-patron - 5.2.1-SNAPSHOT + 5.2.2-SNAPSHOT jar Edge API - Patron Empowerment @@ -86,8 +86,13 @@ io.jsonwebtoken - jjwt-api - 0.11.5 + jjwt-impl + 0.12.6 + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 com.fasterxml.jackson.core diff --git a/ramls/edge-patron.raml b/ramls/edge-patron.raml index 6973136..f08bbc5 100644 --- a/ramls/edge-patron.raml +++ b/ramls/edge-patron.raml @@ -25,6 +25,7 @@ types: hold-cancellation: !include hold-cancellation.json errors: !include raml-util/schemas/errors.schema external_patron_error_404: !include schemas/external_patron_error_404.schema + staging_user_error_404: !include schemas/staging_user_error_404.schema external_patron_error_get_422: !include schemas/external_patron_error_get_422.schema external_patron_error_post_422: !include schemas/external_patron_error_post_422.schema external_patron_error_put_422: !include schemas/external_patron_error_put_422.schema @@ -87,6 +88,66 @@ types: body: text/plain: example: internal server error, contact administrator + + /{externalSystemId}: + uriParameters: + externalSystemId: + description: The UUID of a staging user + 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}$ + put: + description: | + Update a staging user based on external system ID. + queryParameters: + apikey: + description: "API Key" + type: string + body: + application/json: + type: staging_user + example: !include examples/staging_user.json + responses: + 200: + description: | + staging user updated successfully + body: + application/json: + type: staging_user + example: !include examples/staging_user.json + 400: + description: Bad request + body: + text/plain: + example: unable to process request + 401: + description: Not authorized to perform requested action + body: + text/plain: + example: unable to create request + 403: + description: Access Denied + body: + text/plain: + example: Access Denied + 422: + description: Validation error + body: + text/plain: + example: Validation error + 500: + description: | + Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator + 404: + description: Staging user with a given external system Id not found + body: + application/json: + type: staging_user_error_404 + example: !include examples/staging_user_error.json + + /account: post: description: | @@ -139,6 +200,324 @@ types: body: text/plain: example: internal server error, contact administrator + get: + description: (Secure) Return account details for the user provided in access token(x-okapi-token) + queryParameters: + includeLoans: + description: | + Indicates whether or not to include the loans array in + the response + required: false + type: boolean + default: false + includeCharges: + description: | + Indicates whether or not to include the charges array in + the response + required: false + type: boolean + default: false + includeHolds: + description: | + Indicates whether or not to include the holds array in + the response + required: false + type: boolean + default: false + apikey: + description: "API Key" + type: string + sortBy: + description: | + Part of CQL query, indicates the order of records within the lists of holds, charges, loans + example: item.title/sort.ascending + required: false + type: string + offset: + description: | + Skip over a number of elements by specifying an offset value for the query + type: integer + required: false + example: 1 + minimum: 0 + maximum: 2147483647 + limit: + description: | + Limit the number of elements returned in the response + type: integer + required: false + example: 10 + minimum: 0 + maximum: 2147483647 + responses: + 200: + description: Returns the user account info + body: + application/json: + type: account + example: !include examples/account.json + 400: + description: Bad request + body: + text/plain: + example: unable to process request -- constraint violation + 401: + description: Not authorized to perform requested action + body: + text/plain: + example: unable to get account -- unauthorized + 404: + description: Item with a given ID not found + body: + text/plain: + example: account not found + 403: + description: Access Denied + body: + text/plain: + example: Access Denied + 500: + description: Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator + /item: + /{itemId}: + uriParameters: + itemId: + description: The UUID of a FOLIO item + 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}$ + /hold: + displayName: Hold Management + description: (Secure) Services that provide hold management + post: + description: | + (Secure) Creates a hold request on an existing item for the user + queryParameters: + apikey: + description: "API Key" + type: string + body: + application/json: + type: hold + example: !include examples/hold.json + responses: + 201: + description: | + Returns data for a new hold request on the specified item + body: + application/json: + type: hold + example: !include examples/hold.json + 400: + description: Bad request + body: + text/plain: + example: unable to process request -- constraint violation + 401: + description: Not authorized to perform requested action + body: + text/plain: + example: unable to create hold -- unauthorized + 404: + description: Item with a given ID not found + body: + text/plain: + example: item not found + 403: + description: Access Denied + body: + text/plain: + example: Access Denied + 500: + description: | + Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator + /allowed-service-points: + displayName: Allowed service points + description: Service that provides a list of allowed pickup service points + get: + description: | + (Secure) Returns a list of pickup service points allowed for a particular patron and instance + queryParameters: + apikey: + description: "API Key" + type: string + body: + application/json: + type: allowedServicePoints + example: !include examples/allowed-service-points-response.json + responses: + 200: + description: | + Successfully returns a list of allowed service points + body: + application/json: + type: allowedServicePoints + example: !include examples/allowed-service-points-response.json + 422: + description: Validation error + body: + application/json: + type: errors + 500: + description: | + Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator + /instance: + /{instanceId}: + uriParameters: + instanceId: + 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}$ + /hold: + displayName: Hold Management + description: Services that provide hold management + post: + description: | + (Secure) Creates a hold request on an existing item by instance ID for the user + queryParameters: + apikey: + description: "API Key" + type: string + body: + application/json: + type: hold + example: !include examples/hold.json + responses: + 201: + description: | + Returns data for a new hold request on the selected item + body: + application/json: + type: hold + example: !include examples/hold.json + 400: + description: Bad request + body: + text/plain: + example: unable to process request -- constraint violation + 401: + description: Not authorized to perform requested action + body: + text/plain: + example: unable to create hold -- unauthorized + 404: + description: Instance with a given ID not found + body: + text/plain: + example: item not found + 403: + description: Access Denied + body: + text/plain: + example: Access Denied + 422: + description: Validation error + body: + application/json: + type : errors + 500: + description: | + Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator + /allowed-service-points: + displayName: Allowed service points + description: Service that provides a list of allowed pickup service points + get: + description: | + (Secure) Returns a list of pickup service points allowed for a particular patron and instance + queryParameters: + apikey: + description: "API Key" + type: string + body: + application/json: + type: allowedServicePoints + example: !include examples/allowed-service-points-response.json + responses: + 200: + description: | + Successfully returns a list of allowed service points + body: + application/json: + type: allowedServicePoints + example: !include examples/allowed-service-points-response.json + 422: + description: Validation error + body: + application/json: + type: errors + 500: + description: | + Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator + /hold: + displayName: Hold Management + description: Services that provide hold management + /{holdId}: + displayName: Hold Management By Id + description: Services that provide hold management by Id + uriParameters: + holdId: + description: The UUID of a FOLIO hold request + 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}$ + /cancel: + post: + description: (Secure) Removes the specified hold request + queryParameters: + apikey: + description: "API Key" + type: string + body: + application/json: + type: hold-cancellation + example: !include examples/hold-cancellation.json + responses: + 201: + description: The specified hold request was removed + body: + application/json: + type: hold + example: !include examples/hold.json + 400: + description: Bad request + body: + text/plain: + example: | + unable to process request -- constraint violation + 401: + description: Not authorized to perform requested action + body: + text/plain: + example: unable to cancel hold -- unauthorized + 404: + description: hold with a given ID not found + body: + text/plain: + example: hold not found + 403: + description: Access denied + body: + text/plain: + example: access denied + 500: + description: | + Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator /external-patrons: displayName: Get Accounts of External Patrons description: Get accounts of external patrons based on flag @@ -657,7 +1036,7 @@ types: example: internal server error, contact administrator /registration-status: get: - description: Get the patron details by email ID + description: Get the patron details by email ID or externalSystemId queryParameters: apikey: description: "API Key" @@ -665,7 +1044,10 @@ types: emailId: description: The email ID of the patron. type: string - required: true + externalSystemId: + description: The UUID of a staging user + 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}$ responses: 200: description: patron information retrieved successfully diff --git a/ramls/examples/staging_user.json b/ramls/examples/staging_user.json index 771b0e5..ee53a21 100644 --- a/ramls/examples/staging_user.json +++ b/ramls/examples/staging_user.json @@ -1,6 +1,7 @@ { "isEmailVerified": true, "status": "TIER-1", + "externalSystemId": "9eb67301-6f6e-468f-9b1a-6134dc39a684", "generalInfo": { "firstName": "John", "preferredFirstName": "John", diff --git a/ramls/examples/staging_user_error.json b/ramls/examples/staging_user_error.json new file mode 100644 index 0000000..2460b8f --- /dev/null +++ b/ramls/examples/staging_user_error.json @@ -0,0 +1,4 @@ +{ + "code": "STAGING_USER_NOT_FOUND", + "errorMessage": "Staging user does not exist" +} diff --git a/ramls/schemas/staging_user_error_404.schema b/ramls/schemas/staging_user_error_404.schema new file mode 100644 index 0000000..58bbc14 --- /dev/null +++ b/ramls/schemas/staging_user_error_404.schema @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "external_patron_error.schema", + "description": "An external_patron user error", + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Error code" + }, + "errorMessage": { + "type": "string", + "description": "Error message text", + "examples": [ + { + "code": "STAGING_USER_NOT_FOUND", + "errorMessage": "Staging user does not exist" + } + ] + } + }, + "required": [ + "code", + "errorMessage" + ] +} diff --git a/ramls/schemas/user_error_400.schema b/ramls/schemas/user_error_400.schema index 55f2f61..972195a 100644 --- a/ramls/schemas/user_error_400.schema +++ b/ramls/schemas/user_error_400.schema @@ -9,7 +9,8 @@ "description": "Error code", "examples": [ "MULTIPLE_USER_WITH_EMAIL", - "EMAIL_NOT_PROVIDED" + "EMAIL_NOT_PROVIDED", + "INVALID_IDENTIFIERS" ] }, "errorMessage": { @@ -17,7 +18,9 @@ "description": "Error code description", "examples": [ "Multiple users found with the same email", - "emailId is missing in the request" + "emailId is missing in the request", + "Either emailId or externalSystemId must be provided in the request.", + "Provide either emailId or externalSystemId, not both." ] } }, diff --git a/ramls/staging_user.json b/ramls/staging_user.json index 330f13d..1dcfc17 100644 --- a/ramls/staging_user.json +++ b/ramls/staging_user.json @@ -13,6 +13,10 @@ "type": "string", "enum": ["TIER-1", "TIER-2"] }, + "externalSystemId": { + "description": "A unique ID (UUID) that corresponds to an external authority", + "type": "string" + }, "generalInfo": { "type": "object", "description": "General info of external patron", diff --git a/src/main/java/org/folio/edge/patron/Constants.java b/src/main/java/org/folio/edge/patron/Constants.java index 48c960c..55acea8 100644 --- a/src/main/java/org/folio/edge/patron/Constants.java +++ b/src/main/java/org/folio/edge/patron/Constants.java @@ -4,15 +4,24 @@ public class Constants { + public static final String KEYCLOAK_URL = "KC_URL"; public static final String SYS_PATRON_ID_CACHE_TTL_MS = "patron_id_cache_ttl_ms"; public static final String SYS_NULL_PATRON_ID_CACHE_TTL_MS = "null_patron_id_cache_ttl_ms"; public static final String SYS_PATRON_ID_CACHE_CAPACITY = "patron_id_cache_capacity"; + public static final String SYS_KEYCLOAK_KEY_CACHE_TTL_MS = "keycloak_key_cache_ttl_ms"; + public static final String SYS_NULL_KEYCLOAK_KEY_CACHE_TTL_MS = "null_keycloak_key_cache_ttl_ms"; + public static final String SYS_KEYCLOAK_KEY_CACHE_CAPACITY = "keycloak_key_cache_capacity"; public static final String PARAM_EXPIRED = "expired"; + public static final String VIP_CLAIM = "vip"; + public static final String EXTERNAL_SYSTEM_ID_CLAIM = "externalSystemId"; public static final String DEFAULT_CURRENCY_CODE = Currency.getInstance("USD").getCurrencyCode(); public static final long DEFAULT_PATRON_ID_CACHE_TTL_MS = 60 * 60 * 1000L; public static final long DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS = 30 * 1000L; public static final int DEFAULT_PATRON_ID_CACHE_CAPACITY = 1000; + public static final long DEFAULT_KEYCLOAK_KEY_CACHE_TTL_MS = 60 * 60 * 1000L; + public static final long DEFAULT_NULL_KEYCLOAK_KEY_CACHE_TTL_MS = 30 * 1000L; + public static final int DEFAULT_KEYCLOAK_KEY_CACHE_CAPACITY = 50; public static final String PARAM_SORT_BY = "sortBy"; public static final String PARAM_LIMIT = "limit"; @@ -25,6 +34,7 @@ public class Constants { public static final String PARAM_INSTANCE_ID = "instanceId"; public static final String PARAM_HOLD_ID = "holdId"; public static final String PARAM_EMAIL_ID = "emailId"; + public static final String PARAM_EXTERNAL_SYSTEM_ID = "externalSystemId"; public static final String PARAM_REQUEST_ID = "requestId"; public static final String MSG_ACCESS_DENIED = "Access Denied"; diff --git a/src/main/java/org/folio/edge/patron/MainVerticle.java b/src/main/java/org/folio/edge/patron/MainVerticle.java index daab13f..b5a3bd6 100644 --- a/src/main/java/org/folio/edge/patron/MainVerticle.java +++ b/src/main/java/org/folio/edge/patron/MainVerticle.java @@ -1,21 +1,31 @@ package org.folio.edge.patron; +import static org.folio.edge.patron.Constants.DEFAULT_KEYCLOAK_KEY_CACHE_CAPACITY; +import static org.folio.edge.patron.Constants.DEFAULT_KEYCLOAK_KEY_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.DEFAULT_NULL_KEYCLOAK_KEY_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.DEFAULT_PATRON_ID_CACHE_CAPACITY; +import static org.folio.edge.patron.Constants.DEFAULT_PATRON_ID_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.KEYCLOAK_URL; +import static org.folio.edge.patron.Constants.SYS_KEYCLOAK_KEY_CACHE_CAPACITY; +import static org.folio.edge.patron.Constants.SYS_KEYCLOAK_KEY_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.SYS_NULL_KEYCLOAK_KEY_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.SYS_NULL_PATRON_ID_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.SYS_PATRON_ID_CACHE_CAPACITY; +import static org.folio.edge.patron.Constants.SYS_PATRON_ID_CACHE_TTL_MS; + import io.vertx.core.http.HttpMethod; import io.vertx.ext.web.Router; +import io.vertx.ext.web.client.WebClient; import io.vertx.ext.web.handler.BodyHandler; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.folio.edge.core.EdgeVerticleHttp; import org.folio.edge.core.utils.OkapiClientFactory; import org.folio.edge.core.utils.OkapiClientFactoryInitializer; +import org.folio.edge.patron.cache.KeycloakPublicKeyCache; import org.folio.edge.patron.cache.PatronIdCache; - -import static org.folio.edge.patron.Constants.DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS; -import static org.folio.edge.patron.Constants.DEFAULT_PATRON_ID_CACHE_CAPACITY; -import static org.folio.edge.patron.Constants.DEFAULT_PATRON_ID_CACHE_TTL_MS; -import static org.folio.edge.patron.Constants.SYS_NULL_PATRON_ID_CACHE_TTL_MS; -import static org.folio.edge.patron.Constants.SYS_PATRON_ID_CACHE_CAPACITY; -import static org.folio.edge.patron.Constants.SYS_PATRON_ID_CACHE_TTL_MS; +import org.folio.edge.patron.utils.KeycloakClient; public class MainVerticle extends EdgeVerticleHttp { @@ -23,29 +33,52 @@ public class MainVerticle extends EdgeVerticleHttp { public MainVerticle() { super(); + initializePatronIdCache(); + initializeKeycloakKeyCache(); + } + private void initializePatronIdCache() { final String patronIdCacheTtlMs = System.getProperty(SYS_PATRON_ID_CACHE_TTL_MS); final long cacheTtlMs = patronIdCacheTtlMs != null ? Long.parseLong(patronIdCacheTtlMs) - : DEFAULT_PATRON_ID_CACHE_TTL_MS; - logger.info("Using patronId cache TTL (ms): " + patronIdCacheTtlMs); + : DEFAULT_PATRON_ID_CACHE_TTL_MS; final String nullTokenCacheTtlMs = System.getProperty(SYS_NULL_PATRON_ID_CACHE_TTL_MS); final long failureCacheTtlMs = nullTokenCacheTtlMs != null ? Long.parseLong(nullTokenCacheTtlMs) - : DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS; - logger.info("Using patronId cache TTL (ms): " + failureCacheTtlMs); + : DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS; final String patronIdCacheCapacity = System.getProperty(SYS_PATRON_ID_CACHE_CAPACITY); final int cacheCapacity = patronIdCacheCapacity != null ? Integer.parseInt(patronIdCacheCapacity) - : DEFAULT_PATRON_ID_CACHE_CAPACITY; - logger.info("Using patronId cache capacity: " + patronIdCacheCapacity); + : DEFAULT_PATRON_ID_CACHE_CAPACITY; PatronIdCache.initialize(cacheTtlMs, failureCacheTtlMs, cacheCapacity); } + private void initializeKeycloakKeyCache() { + final String keycloakKeyCacheTtlMs = retriveProperty(SYS_KEYCLOAK_KEY_CACHE_TTL_MS); + final long cacheTtlMs = keycloakKeyCacheTtlMs != null ? Long.parseLong(keycloakKeyCacheTtlMs) + : DEFAULT_KEYCLOAK_KEY_CACHE_TTL_MS; + + final String nullTokenCacheTtlMs = retriveProperty(SYS_NULL_KEYCLOAK_KEY_CACHE_TTL_MS); + final long failureCacheTtlMs = nullTokenCacheTtlMs != null ? Long.parseLong(nullTokenCacheTtlMs) + : DEFAULT_NULL_KEYCLOAK_KEY_CACHE_TTL_MS; + + final String keycloakKeyCacheCapacity = retriveProperty(SYS_KEYCLOAK_KEY_CACHE_CAPACITY); + final int cacheCapacity = keycloakKeyCacheCapacity != null ? Integer.parseInt(keycloakKeyCacheCapacity) + : DEFAULT_KEYCLOAK_KEY_CACHE_CAPACITY; + + KeycloakPublicKeyCache.initialize(cacheTtlMs, failureCacheTtlMs, cacheCapacity); + } + @Override public Router defineRoutes() { OkapiClientFactory ocf = OkapiClientFactoryInitializer.createInstance(vertx, config()); - PatronHandler patronHandler = new PatronHandler(secureStore, ocf); + final String keycloakUrl = retriveProperty(KEYCLOAK_URL); + if (keycloakUrl == null || keycloakUrl.isEmpty()) { + logger.warn("Keycloak url is not defined. Secure endpoints will not work"); + } + logger.info("Using keycloak url: {}", keycloakUrl); + KeycloakClient keycloakClient = new KeycloakClient(keycloakUrl, WebClient.create(vertx)); + PatronHandler patronHandler = new PatronHandler(secureStore, ocf, keycloakClient); Router router = Router.router(vertx); router.route().handler(BodyHandler.create()); @@ -56,30 +89,59 @@ public Router defineRoutes() { router.route(HttpMethod.GET, "/patron/account/:patronId") .handler(patronHandler::handleGetAccount); + router.route(HttpMethod.GET, "/patron/account") + .handler(patronHandler::handleSecureGetAccount); + router.route(HttpMethod.POST, "/patron/account/:patronId/item/:itemId/renew") .handler(patronHandler::handleRenew); router.route(HttpMethod.POST, "/patron/account/:patronId/item/:itemId/hold") .handler(patronHandler::handlePlaceItemHold); + router.route(HttpMethod.POST, "/patron/account/item/:itemId/hold") + .handler(patronHandler::handleSecurePlaceItemHold); + router.route(HttpMethod.POST, "/patron") .handler(patronHandler::handlePostPatronRequest); + router.route(HttpMethod.PUT, "/patron/:externalSystemId") + .handler(patronHandler::handlePutPatronRequest); + router.route(HttpMethod.POST, "/patron/account/:patronId/instance/:instanceId/hold") .handler(patronHandler::handlePlaceInstanceHold); - router.route(HttpMethod.GET, "/patron/account/:patronId/instance/:instanceId/" + - "allowed-service-points").handler(patronHandler::handleGetAllowedServicePointsForInstance); + router.route(HttpMethod.POST, "/patron/account/instance/:instanceId/hold") + .handler(patronHandler::handleSecurePlaceInstanceHold); + + router.route(HttpMethod.GET, "/patron/account/:patronId/instance/:instanceId/allowed-service-points") + .handler(patronHandler::handleGetAllowedServicePointsForInstance); + + router.route(HttpMethod.GET, "/patron/account/instance/:instanceId/allowed-service-points") + .handler(patronHandler::handleSecureGetAllowedServicePointsForInstance); + + router.route(HttpMethod.GET, "/patron/account/:patronId/item/:itemId/allowed-service-points") + .handler(patronHandler::handleGetAllowedServicePointsForItem); - router.route(HttpMethod.GET, "/patron/account/:patronId/item/:itemId/" + - "allowed-service-points").handler(patronHandler::handleGetAllowedServicePointsForItem); + router.route(HttpMethod.GET, "/patron/account/item/:itemId/allowed-service-points") + .handler(patronHandler::handleSecureGetAllowedServicePointsForItem); router.route(HttpMethod.POST, "/patron/account/:patronId/hold/:holdId/cancel") .handler(patronHandler::handleCancelHold); + router.route(HttpMethod.POST, "/patron/account/hold/:holdId/cancel") + .handler(patronHandler::handleSecureCancelHold); + router.route(HttpMethod.GET, "/patron/registration-status") .handler(patronHandler::handleGetPatronRegistrationStatus); return router; } + + private String retriveProperty(String name) { + var property = System.getProperty(name); + if (property == null) { + property = System.getenv().get(name); + } + return property; + } } diff --git a/src/main/java/org/folio/edge/patron/PatronHandler.java b/src/main/java/org/folio/edge/patron/PatronHandler.java index 4c4c362..2440512 100644 --- a/src/main/java/org/folio/edge/patron/PatronHandler.java +++ b/src/main/java/org/folio/edge/patron/PatronHandler.java @@ -1,5 +1,11 @@ package org.folio.edge.patron; +import static org.folio.edge.core.Constants.APPLICATION_JSON; +import static org.folio.edge.core.Constants.X_OKAPI_TENANT; +import static org.folio.edge.core.Constants.X_OKAPI_TOKEN; +import static org.folio.edge.patron.Constants.*; +import static org.folio.edge.patron.model.HoldCancellationValidator.validateCancelHoldRequest; + import com.amazonaws.util.StringUtils; import com.fasterxml.jackson.core.JsonProcessingException; import io.vertx.core.buffer.Buffer; @@ -9,6 +15,18 @@ import io.vertx.core.json.JsonObject; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.client.HttpResponse; +import java.text.SimpleDateFormat; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TimeZone; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.folio.edge.core.Handler; @@ -19,46 +37,20 @@ import org.folio.edge.patron.model.error.Error; import org.folio.edge.patron.model.error.ErrorMessage; import org.folio.edge.patron.model.error.Errors; +import org.folio.edge.patron.utils.KeycloakClient; +import org.folio.edge.patron.utils.KeycloakTokenHelper; import org.folio.edge.patron.utils.PatronIdHelper; import org.folio.edge.patron.utils.PatronOkapiClient; -import java.text.SimpleDateFormat; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.TimeZone; - -import static org.folio.edge.core.Constants.APPLICATION_JSON; -import static org.folio.edge.patron.Constants.FIELD_EXPIRATION_DATE; -import static org.folio.edge.patron.Constants.FIELD_REQUEST_DATE; -import static org.folio.edge.patron.Constants.MSG_ACCESS_DENIED; -import static org.folio.edge.patron.Constants.MSG_HOLD_NOBODY; -import static org.folio.edge.patron.Constants.MSG_INTERNAL_SERVER_ERROR; -import static org.folio.edge.patron.Constants.MSG_REQUEST_TIMEOUT; -import static org.folio.edge.patron.Constants.PARAM_EMAIL_ID; -import static org.folio.edge.patron.Constants.PARAM_HOLD_ID; -import static org.folio.edge.patron.Constants.PARAM_INCLUDE_CHARGES; -import static org.folio.edge.patron.Constants.PARAM_INCLUDE_HOLDS; -import static org.folio.edge.patron.Constants.PARAM_INCLUDE_LOANS; -import static org.folio.edge.patron.Constants.PARAM_INSTANCE_ID; -import static org.folio.edge.patron.Constants.PARAM_ITEM_ID; -import static org.folio.edge.patron.Constants.PARAM_LIMIT; -import static org.folio.edge.patron.Constants.PARAM_OFFSET; -import static org.folio.edge.patron.Constants.PARAM_PATRON_ID; -import static org.folio.edge.patron.Constants.PARAM_SORT_BY; -import static org.folio.edge.patron.model.HoldCancellationValidator.validateCancelHoldRequest; - public class PatronHandler extends Handler { public static final String WRONG_INTEGER_PARAM_MESSAGE = "'%s' parameter is incorrect." + " parameter value {%s} is not valid: must be an integer, greater than or equal to 0"; private static final Logger logger = LogManager.getLogger(Handler.class); - - public PatronHandler(SecureStore secureStore, OkapiClientFactory ocf) { + private final KeycloakClient keycloakClient; + public PatronHandler(SecureStore secureStore, OkapiClientFactory ocf, KeycloakClient keycloakClient) { super(secureStore, ocf); + this.keycloakClient = keycloakClient; } @Override @@ -103,6 +95,41 @@ protected void handleCommon(RoutingContext ctx, String[] requiredParams, String[ }); } + private void handleSecureCommon(RoutingContext ctx, Consumer handler) { + var token = ctx.request().getHeader(X_OKAPI_TOKEN); + var tenant = ctx.request().getHeader(X_OKAPI_TENANT); + + if (token == null || token.isEmpty()) { + badRequest(ctx, "Missing access token"); + return; + } + + if (tenant == null || tenant.isEmpty()) { + badRequest(ctx, "Missing tenant id"); + return; + } + KeycloakTokenHelper.getClaimsFromToken(token, tenant, keycloakClient) + .onSuccess(claims -> { + var vip = claims.get(VIP_CLAIM, Boolean.class); + var externalSystemId = claims.get(EXTERNAL_SYSTEM_ID_CLAIM, String.class); + if (vip == null || externalSystemId == null) { + logger.error("Token doesn't contain required claims"); + badRequest(ctx, "Token doesn't contain required claims"); + return; + } + if (!vip) { + accessDenied(ctx, "Patron is not allowed to call secure endpoints"); + return; + } + ctx.request().params().add(PARAM_PATRON_ID, externalSystemId); + handler.accept(ctx); + }) + .onFailure(ex -> { + logger.error("Failed to get claims from token", ex); + badRequest(ctx, "Failed to validate access token"); + }); + } + public void handleGetAccount(RoutingContext ctx) { handleCommon(ctx, new String[] {}, @@ -128,6 +155,10 @@ public void handleGetAccount(RoutingContext ctx) { }); } + public void handleSecureGetAccount(RoutingContext ctx) { + handleSecureCommon(ctx, this::handleGetAccount); + } + public void handleRenew(RoutingContext ctx) { handleCommon(ctx, new String[] { PARAM_ITEM_ID }, @@ -157,13 +188,17 @@ public void handlePlaceItemHold(RoutingContext ctx) { t -> handleProxyException(ctx, t))); } - public void handlePostPatronRequest(RoutingContext ctx) { + public void handleSecurePlaceItemHold(RoutingContext ctx) { + handleSecureCommon(ctx, this::handlePlaceItemHold); + } + + public void handlePatronRequest(RoutingContext ctx, BiConsumer patronAction) { if (ctx.body().asJsonObject() == null) { - logger.warn("handlePostPatronRequest:: missing body found"); + logger.warn("handlePatronRequest:: missing body found"); ctx.response() .setStatusCode(400) .putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) - .end(getErrorMsg("MISSING_BODY", "Request body must not null")); + .end(getErrorMsg("MISSING_BODY", "Request body must not be null")); return; } @@ -171,12 +206,25 @@ public void handlePostPatronRequest(RoutingContext ctx) { super.handleCommon(ctx, new String[]{}, new String[]{}, (client, params) -> { String alternateTenantId = ctx.request().getParam("alternateTenantId", client.tenant); final PatronOkapiClient patronClient = new PatronOkapiClient(client, alternateTenantId); + patronAction.accept(patronClient, body); + }); + } + + public void handlePostPatronRequest(RoutingContext ctx) { + handlePatronRequest(ctx, (patronClient, body) -> patronClient.postPatron(body, resp -> handleProxyResponse(ctx, resp), - t -> handleProxyException(ctx, t)); - }); + t -> handleProxyException(ctx, t))); } + public void handlePutPatronRequest(RoutingContext ctx) { + handlePatronRequest(ctx, (patronClient, body) -> + patronClient.putPatron(ctx.request().getParam(PARAM_EXTERNAL_SYSTEM_ID), body, + resp -> handlePutPatronResponse(ctx, resp), + t -> handleProxyException(ctx, t))); + } + + public void handleCancelHold(RoutingContext ctx) { String validationResult = validateCancelHoldRequest(ctx.body().asJsonObject()); if ( validationResult != null) { @@ -202,6 +250,10 @@ public void handleCancelHold(RoutingContext ctx) { ); } + public void handleSecureCancelHold(RoutingContext ctx) { + handleSecureCommon(ctx, this::handleCancelHold); + } + public void handlePlaceInstanceHold(RoutingContext ctx) { if (ctx.body().asJsonObject() == null) { badRequest(ctx, MSG_HOLD_NOBODY); @@ -219,6 +271,10 @@ public void handlePlaceInstanceHold(RoutingContext ctx) { t -> handleProxyException(ctx, t))); } + public void handleSecurePlaceInstanceHold(RoutingContext ctx) { + handleSecureCommon(ctx, this::handlePlaceInstanceHold); + } + public void handleGetAllowedServicePointsForInstance(RoutingContext ctx) { handleCommon(ctx, new String[] { PARAM_PATRON_ID, PARAM_INSTANCE_ID }, @@ -230,6 +286,10 @@ public void handleGetAllowedServicePointsForInstance(RoutingContext ctx) { t -> handleProxyException(ctx, t))); } + public void handleSecureGetAllowedServicePointsForInstance(RoutingContext ctx) { + handleSecureCommon(ctx, this::handleGetAllowedServicePointsForInstance); + } + public void handleGetAllowedServicePointsForItem(RoutingContext ctx) { handleCommon(ctx, @@ -242,26 +302,49 @@ public void handleGetAllowedServicePointsForItem(RoutingContext ctx) { t -> handleProxyException(ctx, t))); } + public void handleSecureGetAllowedServicePointsForItem(RoutingContext ctx) { + handleSecureCommon(ctx, this::handleGetAllowedServicePointsForItem); + } + public void handleGetPatronRegistrationStatus(RoutingContext ctx) { logger.debug("handleGetPatronRegistrationStatus:: Fetching patron registration"); + String emailId = ctx.request().getParam(PARAM_EMAIL_ID); - if(StringUtils.isNullOrEmpty(emailId)) { - logger.warn("handleGetPatronRegistrationStatus:: Missing or empty emailId"); + String externalSystemId = ctx.request().getParam(PARAM_EXTERNAL_SYSTEM_ID); + + String validationError = validateIdentifiers(emailId, externalSystemId); + if (validationError != null) { ctx.response() .setStatusCode(400) .putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) - .end(getErrorMsg("EMAIL_NOT_PROVIDED", "emailId is missing in the request")); + .end(getErrorMsg("INVALID_IDENTIFIERS", validationError)); return; } + super.handleCommon(ctx, new String[]{}, new String[]{}, (client, params) -> { String alternateTenantId = ctx.request().getParam("alternateTenantId", client.tenant); final PatronOkapiClient patronClient = new PatronOkapiClient(client, alternateTenantId); - patronClient.getPatronRegistrationStatus(emailId, + + patronClient.getPatronRegistrationStatus( + emailId != null ? emailId : externalSystemId, resp -> handleRegistrationStatusResponse(ctx, resp), - t -> handleProxyException(ctx, t)); + t -> handleProxyException(ctx, t) + ); }); } + private String validateIdentifiers(String emailId, String externalSystemId) { + if (StringUtils.isNullOrEmpty(emailId) && StringUtils.isNullOrEmpty(externalSystemId)) { + return "Either emailId or externalSystemId must be provided in the request."; + } + + if (!StringUtils.isNullOrEmpty(emailId) && !StringUtils.isNullOrEmpty(externalSystemId)) { + return "Provide either emailId or externalSystemId, not both."; + } + + return null; + } + @Override protected void invalidApiKey(RoutingContext ctx, String msg) { accessDenied(ctx, msg); @@ -334,32 +417,41 @@ protected void handleProxyResponse(RoutingContext ctx, HttpResponse resp } } - protected void handleRegistrationStatusResponse(RoutingContext ctx, HttpResponse resp) { + protected void handleResponse(RoutingContext ctx, HttpResponse resp, String logPrefix, + UnaryOperator errorMessageFunction) { HttpServerResponse serverResponse = ctx.response(); int statusCode = resp.statusCode(); serverResponse.setStatusCode(statusCode); String respBody = resp.bodyAsString(); - if (logger.isDebugEnabled() ) { - logger.debug("handleRegistrationStatusResponse:: response {} ", respBody); + if (logger.isDebugEnabled()) { + logger.debug("{}:: response {}", logPrefix, respBody); } String contentType = resp.getHeader(HttpHeaders.CONTENT_TYPE.toString()); - if (resp.statusCode() < 400 && Objects.nonNull(respBody)){ + if (statusCode < 400 && Objects.nonNull(respBody)) { setContentType(serverResponse, contentType); - serverResponse.end(respBody); //not an error case, pass on the response body as received - } - else { + serverResponse.end(respBody); // Not an error case, pass on the response body as received + } else { String errorMsg = (statusCode == 404 || statusCode == 400) ? getFormattedErrorMsg(statusCode, respBody) - : getStructuredErrorMessage(statusCode, respBody); + : errorMessageFunction.apply(respBody); setContentType(serverResponse, APPLICATION_JSON); serverResponse.end(errorMsg); } } + protected void handleRegistrationStatusResponse(RoutingContext ctx, HttpResponse resp) { + handleResponse(ctx, resp, "handleRegistrationStatusResponse", body -> getStructuredErrorMessage(resp.statusCode(), body)); + } + + protected void handlePutPatronResponse(RoutingContext ctx, HttpResponse resp) { + handleResponse(ctx, resp, "handlePutPatronResponse", body -> getErrorMessage(resp.statusCode(), body)); + } + + @Override protected void handleProxyException(RoutingContext ctx, Throwable t) { logger.error("Exception retrieving data from mod-patron:", t); diff --git a/src/main/java/org/folio/edge/patron/cache/KeycloakPublicKeyCache.java b/src/main/java/org/folio/edge/patron/cache/KeycloakPublicKeyCache.java new file mode 100644 index 0000000..80f0bf9 --- /dev/null +++ b/src/main/java/org/folio/edge/patron/cache/KeycloakPublicKeyCache.java @@ -0,0 +1,79 @@ +package org.folio.edge.patron.cache; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.edge.core.cache.Cache; +import org.folio.edge.core.cache.Cache.Builder; +import org.folio.edge.core.cache.Cache.CacheValue; + +public class KeycloakPublicKeyCache { + + private static final Logger logger = LogManager.getLogger(KeycloakPublicKeyCache.class); + + private static KeycloakPublicKeyCache instance = null; + + private Cache cache; + + private KeycloakPublicKeyCache(long ttl, long nullTokenTtl, int capacity) { + logger.info("Using TTL: {}", ttl); + logger.info("Using null token TTL: {}", nullTokenTtl); + logger.info("Using capacity: {}", capacity); + cache = new Builder() + .withTTL(ttl) + .withNullValueTTL(nullTokenTtl) + .withCapacity(capacity) + .build(); + } + + /** + * Get the KeycloakPublicKeyCache singleton. the singleton must be initialized before + * calling this method. + * + * @see {@link #initialize(long, long, int)} + * + * @return the KeycloakPublicKeyCache singleton instance. + */ + public static synchronized KeycloakPublicKeyCache getInstance() { + if (instance == null) { + throw new KeycloakPublicKeyCacheNotInitializedException( + "You must call KeycloakPublicKeyCache.initialize(ttl, capacity) before you can get the singleton instance"); + } + return instance; + } + + /** + * Creates a new KeycloakPublicKeyCache instance, replacing the existing one if it + * already exists; in which case all pre-existing cache entries will be lost. + * + * @param ttl + * cache entry time to live in ms + * @param capacity + * maximum number of entries this cache will hold before pruning + * @return the new KeycloakPublicKeyCache singleton instance + */ + public static synchronized KeycloakPublicKeyCache initialize(long ttl, long nullValueTtl, int capacity) { + if (instance != null) { + logger.warn("Reinitializing cache. All cached entries will be lost"); + } + instance = new KeycloakPublicKeyCache(ttl, nullValueTtl, capacity); + return instance; + } + + public String get(String realm) { + return cache.get(realm); + } + + public CacheValue put(String realm, String publicKey) { + return cache.put(realm, publicKey); + } + + public static class KeycloakPublicKeyCacheNotInitializedException extends RuntimeException { + + private static final long serialVersionUID = -8622978462142499585L; + + public KeycloakPublicKeyCacheNotInitializedException(String msg) { + super(msg); + } + } + +} diff --git a/src/main/java/org/folio/edge/patron/cache/PatronIdCache.java b/src/main/java/org/folio/edge/patron/cache/PatronIdCache.java index 13bc25a..c392800 100644 --- a/src/main/java/org/folio/edge/patron/cache/PatronIdCache.java +++ b/src/main/java/org/folio/edge/patron/cache/PatronIdCache.java @@ -15,9 +15,9 @@ public class PatronIdCache { private Cache cache; private PatronIdCache(long ttl, long nullTokenTtl, int capacity) { - logger.info("Using TTL: {0}", ttl); - logger.info("Using null token TTL: {0}", nullTokenTtl); - logger.info("Using capcity: {0}", capacity); + logger.info("Using TTL: {}", ttl); + logger.info("Using null token TTL: {}", nullTokenTtl); + logger.info("Using capacity: {}", capacity); cache = new Builder() .withTTL(ttl) .withNullValueTTL(nullTokenTtl) diff --git a/src/main/java/org/folio/edge/patron/utils/KeycloakClient.java b/src/main/java/org/folio/edge/patron/utils/KeycloakClient.java new file mode 100644 index 0000000..4440d70 --- /dev/null +++ b/src/main/java/org/folio/edge/patron/utils/KeycloakClient.java @@ -0,0 +1,36 @@ +package org.folio.edge.patron.utils; + +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.ext.web.client.WebClient; +import org.apache.http.HttpStatus; +import org.folio.edge.patron.cache.KeycloakPublicKeyCache; + +public class KeycloakClient { + + private static final String REALM_INFO_URI = "/realms/%s/protocol/openid-connect/certs"; + private final String keycloakUrl; + private final WebClient webClient; + + public KeycloakClient(String keycloakUrl, WebClient webClient) { + this.keycloakUrl = keycloakUrl; + this.webClient = webClient; + } + + public Future getPublicKeys(String realm) { + Promise promise = Promise.promise(); + String uri = String.format(REALM_INFO_URI, realm); + webClient.getAbs(keycloakUrl + uri).send() + .onSuccess(response -> { + if (HttpStatus.SC_OK == response.statusCode()) { + var body = response.bodyAsString(); + KeycloakPublicKeyCache.getInstance().put(realm, body); + promise.complete(body); + } else { + promise.fail(new RuntimeException("Request failed with status: " + response.statusCode())); + } + }) + .onFailure(promise::fail); + return promise.future(); + } +} diff --git a/src/main/java/org/folio/edge/patron/utils/KeycloakTokenHelper.java b/src/main/java/org/folio/edge/patron/utils/KeycloakTokenHelper.java new file mode 100644 index 0000000..4ac29f0 --- /dev/null +++ b/src/main/java/org/folio/edge/patron/utils/KeycloakTokenHelper.java @@ -0,0 +1,70 @@ +package org.folio.edge.patron.utils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.Locator; +import io.jsonwebtoken.ProtectedHeader; +import io.jsonwebtoken.security.JwkSet; +import io.jsonwebtoken.security.Jwks; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import java.security.Key; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.edge.patron.cache.KeycloakPublicKeyCache; + +public class KeycloakTokenHelper { + + private static final Logger logger = LogManager.getLogger(KeycloakTokenHelper.class); + + private KeycloakTokenHelper() { + } + + public static Future getClaimsFromToken(String accessToken, String realm, KeycloakClient client) { + Promise promise = Promise.promise(); + getKeycloakPublicKey(realm, client).onSuccess(keys -> { + var jwks = Jwks.setParser().build().parse(keys); + var parser = Jwts.parser().keyLocator(locateKey(jwks)).build(); + try { + var claims = parser.parseSignedClaims(accessToken).getPayload(); + promise.complete(claims); + } catch (Exception ex) { + promise.fail(ex); + } + }).onFailure(ex -> { + logger.error("Failed to get public key from keycloak", ex); + promise.fail(ex); + }); + return promise.future(); + } + + private static Locator locateKey(JwkSet jwks) { + return header -> { + if (header instanceof ProtectedHeader ph) { + var key = jwks.getKeys().stream().filter(jwk -> jwk.getId().equals(ph.getKeyId())).findFirst(); + if (key.isEmpty()) { + return null; + } + return key.get().toKey(); + } else { + return null; + } + }; + } + + private static Future getKeycloakPublicKey(String realm, KeycloakClient client) { + String publicKey = null; + try { + var cache = KeycloakPublicKeyCache.getInstance(); + publicKey = cache.get(realm); + } catch (KeycloakPublicKeyCache.KeycloakPublicKeyCacheNotInitializedException ex) { + logger.warn("Keycloak cache not initialized"); + } + if (publicKey != null) { + return Future.succeededFuture(publicKey); + } + + return client.getPublicKeys(realm); + } + +} diff --git a/src/main/java/org/folio/edge/patron/utils/PatronOkapiClient.java b/src/main/java/org/folio/edge/patron/utils/PatronOkapiClient.java index 81a32c1..028cf8d 100644 --- a/src/main/java/org/folio/edge/patron/utils/PatronOkapiClient.java +++ b/src/main/java/org/folio/edge/patron/utils/PatronOkapiClient.java @@ -175,6 +175,17 @@ public void postPatron(String requestBody, exceptionHandler); } + public void putPatron(String externalSystemId, String requestBody, + Handler> responseHandler, Handler exceptionHandler) { + put( + format("%s/patron/%s", okapiURL, externalSystemId), + tenant, + requestBody, + null, + responseHandler, + exceptionHandler); + } + public void cancelHold(String patronId, String holdId, JsonObject holdCancellationRequest, Handler> responseHandler, Handler exceptionHandler) { getRequest(holdId, @@ -240,11 +251,11 @@ public void placeInstanceHold(String patronId, String instanceId, String request exceptionHandler); } - public void getPatronRegistrationStatus(String emailId, - Handler> responseHandler, Handler exceptionHandler) { + public void getPatronRegistrationStatus(String identifier, + Handler> responseHandler, Handler exceptionHandler) { get(format("%s/patron/registration-status/%s", okapiURL, - emailId), tenant, null, responseHandler, exceptionHandler); + identifier), tenant, null, responseHandler, exceptionHandler); } private Hold createCancellationHoldRequest(JsonObject cancellationRequest, JsonObject baseRequest, String patronId) { diff --git a/src/test/java/org/folio/edge/patron/MainVerticleTest.java b/src/test/java/org/folio/edge/patron/MainVerticleTest.java index 9eaadaa..0d39e9c 100644 --- a/src/test/java/org/folio/edge/patron/MainVerticleTest.java +++ b/src/test/java/org/folio/edge/patron/MainVerticleTest.java @@ -1,41 +1,5 @@ package org.folio.edge.patron; -import io.restassured.RestAssured; -import io.restassured.config.DecoderConfig; -import io.restassured.config.DecoderConfig.ContentDecoder; -import io.restassured.response.Response; -import io.vertx.core.DeploymentOptions; -import io.vertx.core.Vertx; -import io.vertx.core.json.JsonObject; -import io.vertx.ext.unit.TestContext; -import io.vertx.ext.unit.junit.VertxUnitRunner; -import org.apache.commons.lang3.StringUtils; -import org.apache.http.HttpHeaders; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.folio.edge.core.utils.ApiKeyUtils; -import org.folio.edge.core.utils.test.TestUtils; -import org.folio.edge.patron.model.Account; -import org.folio.edge.patron.model.Hold; -import org.folio.edge.patron.model.Loan; -import org.folio.edge.patron.model.error.ErrorMessage; -import org.folio.edge.patron.utils.PatronMockOkapi; -import org.junit.After; -import org.junit.AfterClass; -import org.junit.Assert; -import org.junit.BeforeClass; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - import static org.folio.edge.core.Constants.APPLICATION_JSON; import static org.folio.edge.core.Constants.DAY_IN_MILLIS; import static org.folio.edge.core.Constants.SYS_LOG_LEVEL; @@ -45,6 +9,9 @@ import static org.folio.edge.core.Constants.SYS_RESPONSE_COMPRESSION; import static org.folio.edge.core.Constants.SYS_SECURE_STORE_PROP_FILE; import static org.folio.edge.core.Constants.TEXT_PLAIN; +import static org.folio.edge.core.Constants.X_OKAPI_TENANT; +import static org.folio.edge.core.Constants.X_OKAPI_TOKEN; +import static org.folio.edge.patron.Constants.KEYCLOAK_URL; import static org.folio.edge.patron.Constants.MSG_ACCESS_DENIED; import static org.folio.edge.patron.Constants.MSG_HOLD_NOBODY; import static org.folio.edge.patron.Constants.MSG_REQUEST_TIMEOUT; @@ -69,6 +36,42 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; +import io.restassured.RestAssured; +import io.restassured.config.DecoderConfig; +import io.restassured.config.DecoderConfig.ContentDecoder; +import io.restassured.response.Response; +import io.vertx.core.DeploymentOptions; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpHeaders; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.edge.core.utils.ApiKeyUtils; +import org.folio.edge.core.utils.test.TestUtils; +import org.folio.edge.patron.model.Account; +import org.folio.edge.patron.model.Hold; +import org.folio.edge.patron.model.Loan; +import org.folio.edge.patron.model.error.ErrorMessage; +import org.folio.edge.patron.utils.JwtTokenUtil; +import org.folio.edge.patron.utils.PatronMockOkapi; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + @RunWith(VertxUnitRunner.class) public class MainVerticleTest { @@ -78,6 +81,7 @@ public class MainVerticleTest { private static final String itemId = UUID.randomUUID().toString(); private static final String instanceId = UUID.randomUUID().toString(); private static final String holdId = UUID.randomUUID().toString(); + private static final String EXTERNAL_SYSTEM_ID = UUID.randomUUID().toString(); private static final String apiKey = ApiKeyUtils.generateApiKey(10, "diku", "diku"); private static final String badApiKey = apiKey + "0000"; private static final String unknownTenantApiKey = ApiKeyUtils.generateApiKey(10, "bogus", "diku"); @@ -86,6 +90,7 @@ public class MainVerticleTest { private static Vertx vertx; private static PatronMockOkapi mockOkapi; + private static JwtTokenUtil jwtTokenUtil; @BeforeClass public static void setUpOnce(TestContext context) throws Exception { @@ -96,6 +101,7 @@ public static void setUpOnce(TestContext context) throws Exception { knownTenants.add(ApiKeyUtils.parseApiKey(apiKey).tenantId); vertx = Vertx.vertx(); + jwtTokenUtil = new JwtTokenUtil(); System.setProperty(SYS_PORT, String.valueOf(serverPort)); System.setProperty(SYS_OKAPI_URL, "http://localhost:" + okapiPort); @@ -103,6 +109,7 @@ public static void setUpOnce(TestContext context) throws Exception { System.setProperty(SYS_LOG_LEVEL, "DEBUG"); System.setProperty(SYS_RESPONSE_COMPRESSION, "true"); System.setProperty(SYS_REQUEST_TIMEOUT_MS, String.valueOf(requestTimeoutMs)); + System.setProperty(KEYCLOAK_URL, "http://localhost:" + okapiPort); mockOkapi = spy(new PatronMockOkapi(okapiPort, knownTenants)); mockOkapi.start() @@ -254,6 +261,126 @@ public void testGetAccountPatronNotFound(TestContext context) throws Exception { assertEquals(expectedStatusCode, msg.httpStatusCode); } + @Test + public void testSecureGetAccount(TestContext context) { + final String expected = PatronMockOkapi.getAccountJson(patronId, false, false, false); + RestAssured + .given() + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, true)) + .header(X_OKAPI_TENANT, "diku") + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .body(is(expected)); + } + + @Test + public void testSecureGetAccountInvalidToken(TestContext context) throws Exception { + var resp = RestAssured + .given() + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, true) + "001") + .header(X_OKAPI_TENANT, "diku") + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var error = ErrorMessage.fromJson(resp.body().asString()); + assertEquals("Failed to validate access token", error.message); + assertEquals(400, error.httpStatusCode); + } + + @Test + public void testSecureGetAccountMissingTenant(TestContext context) throws Exception { + var resp = RestAssured + .given() + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, true) + "001") + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var error = ErrorMessage.fromJson(resp.body().asString()); + assertEquals("Missing tenant id", error.message); + assertEquals(400, error.httpStatusCode); + } + + @Test + public void testSecureGetAccountMissingToken(TestContext context) throws Exception { + var resp = RestAssured + .given() + .header(X_OKAPI_TENANT, "diku") + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var error = ErrorMessage.fromJson(resp.body().asString()); + assertEquals("Missing access token", error.message); + assertEquals(400, error.httpStatusCode); + } + + @Test + public void testSecureGetAccountMissingKeycloakPublicKey(TestContext context) throws Exception { + var resp = RestAssured + .given() + .header(X_OKAPI_TENANT, "test") + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, true)) + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var error = ErrorMessage.fromJson(resp.body().asString()); + assertEquals("Failed to validate access token", error.message); + assertEquals(400, error.httpStatusCode); + } + + @Test + public void testSecureGetAccountWithMissingClaims(TestContext context) throws Exception { + var resp = RestAssured + .given() + .header(X_OKAPI_TENANT, "diku") + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken()) + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var error = ErrorMessage.fromJson(resp.body().asString()); + assertEquals("Token doesn't contain required claims", error.message); + assertEquals(400, error.httpStatusCode); + } + + @Test + public void testSecureGetAccountWithPatronNotVip(TestContext context) throws Exception { + var resp = RestAssured + .given() + .header(X_OKAPI_TENANT, "diku") + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, false)) + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(401) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var error = ErrorMessage.fromJson(resp.body().asString()); + assertEquals("Access Denied", error.message); + assertEquals(401, error.httpStatusCode); + } + @Test public void testGetPatronRegistrationStatusWithoutEmail(TestContext context) { @@ -266,8 +393,8 @@ public void testGetPatronRegistrationStatusWithoutEmail(TestContext context) { .response(); var jsonResponse = new JsonObject(response.body().asString()); - assertEquals("EMAIL_NOT_PROVIDED", jsonResponse.getString("code")); - assertEquals("emailId is missing in the request", jsonResponse.getString("errorMessage")); + assertEquals("INVALID_IDENTIFIERS", jsonResponse.getString("code")); + assertEquals("Either emailId or externalSystemId must be provided in the request.", jsonResponse.getString("errorMessage")); response = RestAssured .get(String.format("/patron/registration-status?emailId=%s&apikey=%s", "", apiKey)) @@ -278,10 +405,28 @@ public void testGetPatronRegistrationStatusWithoutEmail(TestContext context) { .response(); jsonResponse = new JsonObject(response.body().asString()); - assertEquals("EMAIL_NOT_PROVIDED", jsonResponse.getString("code")); - assertEquals("emailId is missing in the request", jsonResponse.getString("errorMessage")); + assertEquals("INVALID_IDENTIFIERS", jsonResponse.getString("code")); + assertEquals("Either emailId or externalSystemId must be provided in the request.", jsonResponse.getString("errorMessage")); + } + + @Test + public void testGetPatronRegistrationStatusWithEmailAndESID(TestContext context) { + + var response = RestAssured + .get(String.format("/patron/registration-status?emailId=%s&externalSystemId=%s&apikey=%s", "abc@abc.com", "9eb67301-6f6e-468f-9b1a-6134dc39a670", apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var jsonResponse = new JsonObject(response.body().asString()); + assertEquals("INVALID_IDENTIFIERS", jsonResponse.getString("code")); + assertEquals("Provide either emailId or externalSystemId, not both.", jsonResponse.getString("errorMessage")); + } + @Test public void testGetPatronRegistrationStatusWithActiveEmail(TestContext context) { @@ -299,6 +444,23 @@ public void testGetPatronRegistrationStatusWithActiveEmail(TestContext context) assertEquals(expected, actual); } + @Test + public void testGetPatronRegistrationStatusWithExternalSystemId(TestContext context) { + + final var response = RestAssured + .get(String.format("/patron/registration-status?externalSystemId=%s&apikey=%s", "9eb67301-6f6e-468f-9b1a-6134dc39a699", apiKey)) + .then() + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var expected = new JsonObject(readMockFile( + "/user_active.json")); + var actual = new JsonObject(response.body().asString()); + assertEquals(expected, actual); + } + @Test public void testGetPatronRegistrationStatusWithInvalidEmail() { @@ -315,6 +477,23 @@ public void testGetPatronRegistrationStatusWithInvalidEmail() { assertEquals("User does not exist", jsonResponse.getString("errorMessage")); } + @Test + public void testGetPatronRegistrationStatusWithInvalidExternalSystemId() { + + final var response = RestAssured + .get(String.format("/patron/registration-status?externalSystemId=%s&apikey=%s", "9eb67301-6f6e-468f-9b1a-6134dc39a700", apiKey)) + .then() + .statusCode(404) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var jsonResponse = new JsonObject(response.body().asString()); + assertEquals("USER_NOT_FOUND", jsonResponse.getString("code")); + assertEquals("User does not exist", jsonResponse.getString("errorMessage")); + } + + @Test public void testGetPatronRegistrationStatusWithMultipleUserEmail() { @@ -780,6 +959,28 @@ public void testPlaceInstanceHoldSuccess(TestContext context) throws Exception { validateHolds(expected, actual); } + @Test + public void testSecurePlaceInstanceHoldSuccess(TestContext context) throws Exception { + Hold hold = PatronMockOkapi.getHold(instanceId); + + final Response resp = RestAssured + .with() + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(patronId, true)) + .header(X_OKAPI_TENANT, "diku") + .body(hold.toJson()) + .contentType(APPLICATION_JSON) + .post( + String.format("/patron/account/instance/%s/hold?apikey=%s", instanceId, apiKey)) + .then() + .statusCode(201) + .extract() + .response(); + + Hold expected = Hold.fromJson(PatronMockOkapi.getPlacedHoldJson(hold)); + Hold actual = Hold.fromJson(resp.body().asString()); + validateHolds(expected, actual); + } + @Test public void testPlaceInstanceHoldPatronNotFound(TestContext context) throws Exception { logger.info("=== Test place instance hold w/ patron not found ==="); @@ -822,6 +1023,23 @@ public void testPostPatron_201(TestContext context) { .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON); } + @Test + public void testPutPatron_200(TestContext context) { + logger.info("=== testPutPatron_200 ==="); + JsonObject jsonObject = new JsonObject(readMockFile("/staging-users-put-request.json")); + jsonObject.getJsonObject("generalInfo").put("firstName", "TEST_STATUS_CODE_200"); + RestAssured + .with() + .body(jsonObject.encode()) + .contentType(APPLICATION_JSON) + .put( + String.format("/patron/%s?apikey=%s", EXTERNAL_SYSTEM_ID, apiKey)) + .then() + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON); + } + + @Test public void testPostPatron_200(TestContext context) { logger.info("=== testPostPatron_200 ==="); @@ -856,6 +1074,25 @@ public void testPostPatron_400(TestContext context) { .body("code", is(400)); } + @Test + public void testPutPatron_400(TestContext context) { + logger.info("=== testPutPatron_400 ==="); + JsonObject jsonObject = new JsonObject(readMockFile("/staging-users-put-request.json")); + jsonObject.getJsonObject("generalInfo").put("firstName", "TEST_STATUS_CODE_400"); + RestAssured + .with() + .body(jsonObject.encode()) + .contentType(APPLICATION_JSON) + .put( + String.format("/patron/%s?apikey=%s", EXTERNAL_SYSTEM_ID, apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .body("errorMessage", is("A bad exception occurred")) + .body("code", is(400)); + } + + @Test public void testPostPatron_422(TestContext context) { logger.info("=== testPostPatron_422 ==="); @@ -874,6 +1111,24 @@ public void testPostPatron_422(TestContext context) { .body("code", is(422)); } + @Test + public void testPutPatron_422(TestContext context) { + logger.info("=== testPutPatron_422 ==="); + JsonObject jsonObject = new JsonObject(readMockFile("/staging-users-put-request.json")); + jsonObject.getJsonObject("generalInfo").put("firstName", "TEST_STATUS_CODE_422"); + RestAssured + .with() + .body(jsonObject.encode()) + .contentType(APPLICATION_JSON) + .put( + String.format("/patron/%s?apikey=%s", EXTERNAL_SYSTEM_ID, apiKey)) + .then() + .statusCode(422) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .body("errorMessage", is("ABC is required")) + .body("code", is(422)); + } + @Test public void testPostPatron_500(TestContext context) { logger.info("=== testPostPatron_500 ==="); @@ -903,10 +1158,58 @@ public void testPostPatron_NoRequestBody(TestContext context) { .then() .statusCode(400) .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) - .body("errorMessage", is("Request body must not null")) + .body("errorMessage", is("Request body must not be null")) .body("code", is("MISSING_BODY")); } + @Test + public void testPutPatron_500(TestContext context) { + logger.info("=== testPutPatron_500 ==="); + JsonObject jsonObject = new JsonObject(readMockFile("/staging-users-put-request.json")); + jsonObject.getJsonObject("generalInfo").put("firstName", "TEST_STATUS_CODE_500"); + RestAssured + .with() + .body(jsonObject.encode()) + .contentType(APPLICATION_JSON) + .put( + String.format("/patron/%s?apikey=%s", EXTERNAL_SYSTEM_ID, apiKey)) + .then() + .statusCode(500) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .body("errorMessage", is("Server exception occurred")) + .body("code", is(500)); + } + + @Test + public void testPutPatron_NoRequestBody(TestContext context) { + logger.info("=== testPutPatron_NoRequestBody ==="); + RestAssured + .with() + .contentType(APPLICATION_JSON) + .put( + String.format("/patron/%s?apikey=%s", EXTERNAL_SYSTEM_ID, apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .body("errorMessage", is("Request body must not be null")) + .body("code", is("MISSING_BODY")); + } + + @Test + public void testPutPatron_NoParam(TestContext context) { + logger.info("=== testPutPatron_NoParam ==="); + JsonObject jsonObject = new JsonObject(readMockFile("/staging-users-put-request.json")); + jsonObject.getJsonObject("generalInfo").put("firstName", "TEST_STATUS_CODE_405"); + RestAssured + .with() + .body(jsonObject.encode()) + .contentType(APPLICATION_JSON) + .put( + String.format("/patron/%s?apikey=%s", "", apiKey)) + .then() + .statusCode(405); + } + @Test public void testPlaceInstanceHoldInstanceNotFound(TestContext context) throws Exception { logger.info("=== Test place instance hold w/ instance not found ==="); @@ -1277,6 +1580,28 @@ public void testAllowedServicePointsSuccess(TestContext context) throws Exceptio JsonObject actual = new JsonObject(resp.body().asString()); assertEquals(expected, actual); } + + @Test + public void testSecureAllowedServicePointsSuccess(TestContext context) { + + final Response resp = RestAssured + .with() + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(patronId, true)) + .header(X_OKAPI_TENANT, "diku") + .get(String.format("/patron/account/instance/%s/allowed-service-points?apikey=%s", + instanceId, apiKey)) + .then() + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + JsonObject expected = new JsonObject(readMockFile( + "/allowed_sp_mod_patron_expected_response.json")); + JsonObject actual = new JsonObject(resp.body().asString()); + assertEquals(expected, actual); + } + @Test public void testAllowedServicePointsForItemError(TestContext context) throws Exception { logger.info("=== Test validation error during allowed service points request ==="); @@ -1346,6 +1671,35 @@ public void testCancelHoldSuccess(TestContext context) throws Exception { assertEquals(expected.canceledByUserId, actual.canceledByUserId); } + @Test + public void testSecureCancelHoldSuccess(TestContext context) throws Exception { + String cancedHoldJson = PatronMockOkapi.getHoldCancellation(holdCancellationHoldId, patronId); + + final Response resp = RestAssured + .with() + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(patronId, true)) + .header(X_OKAPI_TENANT, "diku") + .contentType(APPLICATION_JSON) + .body(cancedHoldJson) + .post( + String.format("/patron/account/hold/%s/cancel?apikey=%s", holdCancellationHoldId, apiKey)) + .then() + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + Hold expected = Hold.fromJson(PatronMockOkapi.getRemovedHoldJson(holdCancellationHoldId)); + Hold actual = Hold.fromJson(resp.body().asString()); + + assertEquals(expected, actual); + assertEquals(holdCancellationHoldId, expected.requestId); + assertEquals(PatronMockOkapi.holdCancellationReasonId, actual.cancellationReasonId); + assertEquals(Hold.Status.CLOSED_CANCELED, actual.status); + assertEquals(0, actual.queuePosition); + assertEquals(expected.canceledByUserId, actual.canceledByUserId); + } + @Test public void testCancelHoldSuccessWithNonUUIDCanceledById(TestContext context) throws Exception { logger.info("=== Test cancel hold success ==="); diff --git a/src/test/java/org/folio/edge/patron/utils/JwtTokenUtil.java b/src/test/java/org/folio/edge/patron/utils/JwtTokenUtil.java new file mode 100644 index 0000000..67f248f --- /dev/null +++ b/src/test/java/org/folio/edge/patron/utils/JwtTokenUtil.java @@ -0,0 +1,31 @@ +package org.folio.edge.patron.utils; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Jwk; +import io.jsonwebtoken.security.Jwks; + +public class JwtTokenUtil { + private Jwk key; + + public JwtTokenUtil() { + key = Jwks.parser().build().parse(this.getClass().getResourceAsStream("/test_jwk.json")); + } + + public String generateToken(String externalSystemId, boolean vip) { + return Jwts.builder() + .header().keyId(key.getId()).and() + .subject("test") + .signWith(key.toKey()) + .claim("externalSystemId", externalSystemId) + .claim("vip", vip) + .compact(); + } + + public String generateToken() { + return Jwts.builder() + .header().keyId(key.getId()).and() + .subject("test") + .signWith(key.toKey()) + .compact(); + } +} diff --git a/src/test/java/org/folio/edge/patron/utils/PatronMockOkapi.java b/src/test/java/org/folio/edge/patron/utils/PatronMockOkapi.java index 33f1bbe..d3943ff 100644 --- a/src/test/java/org/folio/edge/patron/utils/PatronMockOkapi.java +++ b/src/test/java/org/folio/edge/patron/utils/PatronMockOkapi.java @@ -1,5 +1,12 @@ package org.folio.edge.patron.utils; +import static java.util.Collections.singletonList; +import static org.folio.edge.core.Constants.APPLICATION_JSON; +import static org.folio.edge.core.Constants.DAY_IN_MILLIS; +import static org.folio.edge.core.Constants.TEXT_PLAIN; +import static org.folio.edge.core.Constants.X_OKAPI_TOKEN; +import static org.folio.edge.patron.Constants.*; + import com.fasterxml.jackson.core.JsonProcessingException; import io.vertx.core.http.HttpHeaders; import io.vertx.core.http.HttpMethod; @@ -7,6 +14,16 @@ import io.vertx.core.json.JsonObject; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Currency; +import java.util.Date; +import java.util.List; +import java.util.UUID; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -20,35 +37,6 @@ import org.folio.edge.patron.model.Loan; import org.folio.edge.patron.model.Money; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Currency; -import java.util.Date; -import java.util.List; -import java.util.UUID; - -import static java.util.Collections.singletonList; -import static org.folio.edge.core.Constants.APPLICATION_JSON; -import static org.folio.edge.core.Constants.DAY_IN_MILLIS; -import static org.folio.edge.core.Constants.TEXT_PLAIN; -import static org.folio.edge.core.Constants.X_OKAPI_TOKEN; -import static org.folio.edge.patron.Constants.PARAM_EMAIL_ID; -import static org.folio.edge.patron.Constants.PARAM_HOLD_ID; -import static org.folio.edge.patron.Constants.PARAM_INCLUDE_CHARGES; -import static org.folio.edge.patron.Constants.PARAM_INCLUDE_HOLDS; -import static org.folio.edge.patron.Constants.PARAM_INCLUDE_LOANS; -import static org.folio.edge.patron.Constants.PARAM_INSTANCE_ID; -import static org.folio.edge.patron.Constants.PARAM_ITEM_ID; -import static org.folio.edge.patron.Constants.PARAM_LIMIT; -import static org.folio.edge.patron.Constants.PARAM_OFFSET; -import static org.folio.edge.patron.Constants.PARAM_PATRON_ID; -import static org.folio.edge.patron.Constants.PARAM_REQUEST_ID; -import static org.folio.edge.patron.Constants.PARAM_SORT_BY; - public class PatronMockOkapi extends MockOkapi { private static final Logger logger = LogManager.getLogger(PatronMockOkapi.class); @@ -148,6 +136,9 @@ public Router defineRoutes() { router.route(HttpMethod.POST, "/patron") .handler(this::postPatronMock); + router.route(HttpMethod.PUT, "/patron/:externalSystemId") + .handler(this::putPatronMock); + router.route(HttpMethod.POST, "/patron/account/:patronId/instance/:instanceId/hold") .handler(this::placeInstanceHoldHandler); @@ -168,9 +159,19 @@ public Router defineRoutes() { router.route(HttpMethod.GET, "/patron/registration-status/:emailId") .handler(this::getRegistrationStatusHandler); + router.route(HttpMethod.GET, "/realms/diku/protocol/openid-connect/certs") + .handler(this::getKeycloakPublicKeysHandler); + return router; } + public void getKeycloakPublicKeysHandler(RoutingContext ctx) { + ctx.response() + .setStatusCode(200) + .putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .end(readMockFile("/keycloak_certs_response.json")); + } + public void getPatronHandler(RoutingContext ctx) { String query = ctx.request().getParam(PARAM_QUERY); String token = ctx.request().getHeader(X_OKAPI_TOKEN); @@ -265,22 +266,34 @@ public void getRegistrationStatusHandler(RoutingContext ctx) { .setStatusCode(403) .putHeader(HttpHeaders.CONTENT_TYPE, TEXT_PLAIN) .end("Access requires permission: patron.account.get"); - } else if(emailId.equals("active@folio.com")) { + } else if(emailId!=null && emailId.equals("active@folio.com")) { + ctx.response() + .setStatusCode(200) + .putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .end(readMockFile("/user_active.json")); + } else if(emailId!=null && emailId.equals("9eb67301-6f6e-468f-9b1a-6134dc39a699")) { ctx.response() .setStatusCode(200) .putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) .end(readMockFile("/user_active.json")); - } else if(emailId.equals("multipleuser@folio.com")) { + } else if(emailId!=null && emailId.equals("multipleuser@folio.com")) { ctx.response() .setStatusCode(400) .putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) .end(readMockFile("/multiple_user_error.json")); - } else if(emailId.equals("usernotfound@folio.com")) { + } else if(emailId!=null && emailId.equals("usernotfound@folio.com")) { + ctx.response() + .setStatusCode(404) + .putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .end(readMockFile("/user_not_found_error.json")); + } + else if(emailId!=null && emailId.equals("9eb67301-6f6e-468f-9b1a-6134dc39a700")) { ctx.response() .setStatusCode(404) .putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) .end(readMockFile("/user_not_found_error.json")); - } else if(emailId.equals("invalid@folio.com")) { + } + else if(emailId!=null && emailId.equals("invalid@folio.com")) { ctx.response() .setStatusCode(404) .putHeader(HttpHeaders.CONTENT_TYPE, TEXT_PLAIN) @@ -431,6 +444,42 @@ public void postPatronMock(RoutingContext ctx) { } } + public void putPatronMock(RoutingContext ctx) { + try { + String firstName = ctx.body().asJsonObject().getJsonObject("generalInfo").getString("firstName"); + String mockResponseBody = readMockFile("/staging-users-put-response.json"); + String mockResponse422ErrorBody = readMockFile("/staging-users-put-error-response.json"); + if ("TEST_STATUS_CODE_200".equals(firstName)) { + ctx.response() + .setStatusCode(200) + .putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .end(mockResponseBody); + } else if ("TEST_STATUS_CODE_400".equals(firstName)) { + ctx.response() + .setStatusCode(400) + .putHeader(HttpHeaders.CONTENT_TYPE, TEXT_PLAIN) + .end("A bad exception occurred"); + } else if ("TEST_STATUS_CODE_422".equals(firstName)) { + ctx.response() + .setStatusCode(422) + .putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .end(mockResponse422ErrorBody); + } else if ("TEST_STATUS_CODE_500".equals(firstName)) { + ctx.response() + .setStatusCode(500) + .putHeader(HttpHeaders.CONTENT_TYPE, TEXT_PLAIN) + .end("Server exception occurred"); + } + } catch (Exception e) { + logger.error("Exception parsing request payload", e); + ctx.response() + .setStatusCode(400) + .putHeader(HttpHeaders.CONTENT_TYPE, TEXT_PLAIN) + .end("Bad Request: " + e.toString()); + } + } + + public void getRequestHandler(RoutingContext ctx) { String requestId = ctx.request().getParam(PARAM_REQUEST_ID); String token = ctx.request().getHeader(X_OKAPI_TOKEN); diff --git a/src/test/resources/keycloak_certs_response.json b/src/test/resources/keycloak_certs_response.json new file mode 100644 index 0000000..206692b --- /dev/null +++ b/src/test/resources/keycloak_certs_response.json @@ -0,0 +1,16 @@ +{ + "keys": [ + { + "kty": "RSA", + "use": "sig", + "key_ops": [ + "sign", + "verify" + ], + "alg": "RS256", + "kid": "5f1fdf63-2a16-47c8-b882-66d17f1f0d39", + "n": "wl-JE22eQrjVGiq1kR2KouA1ne6mddotGqG5n89ejnw3FPUWRk7TlWG87O0mXsjmvnjnnJTKdItaFJlHwoQy4bMWPGDnwDc4kGi2iQ52UgpUq0M4Ss3U_RDsCoNVsclmJMmfzmS1SK9NOExdWmaWXj9wuItzwy4vZ-skg-R_ldd-_go6kP_og5Xn9_DRQI1Zv68rTxm-2usjinoJHE6aqta6_JZURQU1PF34SkUqFLF0vBgOUwTF_94-q7z2BDIJgYnk3yudVQOEDXLo7mspdOcNiavyH4w3gKqnT02FSaszeWlZ1hmGPt0Lz1tMPAoNjZx8esE1b8ze7aKeMGi5NQ", + "e": "AQAB" + } + ] +} diff --git a/src/test/resources/staging-users-put-error-response.json b/src/test/resources/staging-users-put-error-response.json new file mode 100644 index 0000000..dabed5f --- /dev/null +++ b/src/test/resources/staging-users-put-error-response.json @@ -0,0 +1,27 @@ +{ + "total_records": 2, + "errors": [ + { + "message": "ABC is required", + "type": "STANDARD_TYPE", + "code": "ERROR_CODE", + "parameters": [ + { + "key": "KEY-1", + "value": "VALUE-1" + } + ] + }, + { + "message": "XYZ is required", + "type": "STANDARD_TYPE", + "code": "ERROR_CODE", + "parameters": [ + { + "key": "KEY-2", + "value": "VALUE-2" + } + ] + } + ] +} diff --git a/src/test/resources/staging-users-put-request.json b/src/test/resources/staging-users-put-request.json new file mode 100644 index 0000000..2405a0b --- /dev/null +++ b/src/test/resources/staging-users-put-request.json @@ -0,0 +1,27 @@ +{ + "isEmailVerified": false, + "status": "TIER-1", + "generalInfo": { + "firstName": "TEST_STATUS_CODE_200", + "middleName": "www", + "lastName": "new-record-1" + }, + "addressInfo": { + "addressLine0": "123 Main St", + "addressLine1": "Apt 4B", + "city": "Metropolis", + "province": "NY", + "zip": "12345", + "country": "USA" + }, + "contactInfo": { + "phone": "555-123456", + "mobilePhone": "555-5678", + "email": "new-record-kapil_new3@test.com" + }, + "preferredEmailCommunication": [ + "Programs", + "Support", + "Services" + ] +} diff --git a/src/test/resources/staging-users-put-response.json b/src/test/resources/staging-users-put-response.json new file mode 100644 index 0000000..9e2bc31 --- /dev/null +++ b/src/test/resources/staging-users-put-response.json @@ -0,0 +1,34 @@ +{ + "id": "http_status_200", + "isEmailVerified": true, + "status": "TIER-1", + "generalInfo": { + "firstName": "test1", + "middleName": "www", + "lastName": "new-record-1" + }, + "addressInfo": { + "addressLine0": "123 Main St", + "addressLine1": "Apt 4B", + "city": "Metropolis", + "province": "NY", + "zip": "12345", + "country": "USA" + }, + "contactInfo": { + "phone": "555-123456", + "mobilePhone": "555-5678", + "email": "new-record-kapil_new3@test.com" + }, + "preferredEmailCommunication": [ + "Programs", + "Support", + "Services" + ], + "metadata": { + "createdDate": "2024-10-15T10:50:36.267+00:00", + "createdByUserId": "21457ab5-4635-4e56-906a-908f05e9233b", + "updatedDate": "2024-10-17T09:32:24.840+00:00", + "updatedByUserId": "21457ab5-4635-4e56-906a-908f05e9233b" + } +} diff --git a/src/test/resources/test_jwk.json b/src/test/resources/test_jwk.json new file mode 100644 index 0000000..80f33a0 --- /dev/null +++ b/src/test/resources/test_jwk.json @@ -0,0 +1,18 @@ +{ + "kty": "RSA", + "use": "sig", + "key_ops": [ + "sign", + "verify" + ], + "alg": "RS256", + "kid": "5f1fdf63-2a16-47c8-b882-66d17f1f0d39", + "d": "Bg8qW8OwCk5uvjkUneh42Oj6Yuj8oXHitBDUk4nIXdK7eNjHD_wHFoIMfKpL5uqGXuuv9K6ivQ1XCotYtFSgrW6Cw_EVaGWQStgo0I7120rdJtWanKJcAGOVqCI9-qPXnk_2vl1fVVu5oYan5liKWXfK0MXwHuaCrc-jvMTTwVz_acDojcJbPoFK03D2BUxMBH6Gon6MI09NESk7Ni09BHgrlR2IHGUv_Q2C14uo8RH86h_7kjyW5x0lInTuimKqtvat0DXK9Nt2Rs9y1Gcbtlxu9yplv1E0XrodCwvoJwnK6gIVRvAlFUT4f8Bw1q9Lkp-ARp6BG9a3F2Q5zbTVwQ", + "n": "wl-JE22eQrjVGiq1kR2KouA1ne6mddotGqG5n89ejnw3FPUWRk7TlWG87O0mXsjmvnjnnJTKdItaFJlHwoQy4bMWPGDnwDc4kGi2iQ52UgpUq0M4Ss3U_RDsCoNVsclmJMmfzmS1SK9NOExdWmaWXj9wuItzwy4vZ-skg-R_ldd-_go6kP_og5Xn9_DRQI1Zv68rTxm-2usjinoJHE6aqta6_JZURQU1PF34SkUqFLF0vBgOUwTF_94-q7z2BDIJgYnk3yudVQOEDXLo7mspdOcNiavyH4w3gKqnT02FSaszeWlZ1hmGPt0Lz1tMPAoNjZx8esE1b8ze7aKeMGi5NQ", + "e": "AQAB", + "p": "yQGC_gFCE3DojmUdHkJ9sLiVNNQ3wM7Y6oudme-jvcmQY2kKhJVc9QuUYn0xStzIT4o11NzQpNizUOdjJja3ZRDkx8ay7j4hc3NaZVZb9-ukLAYbltoDxgo4xFIoQ5Y5rj0nCid_3FnY1tqbIlEJG4Fd178RPWOZrW5nX8h3mSU", + "q": "9413iOoZLg65trWQOgotEU71e41nvWg9yCmNkfVZ5A8c_BE2s0A5Dmn9WYWXIaoSv67TmguMU9BTHu_S24qqPv0QRzZX9U33GXbrUt5dHo5haiXKIir5Yg8BxETTqFVYdxnM3SZW_41LqQYs-wmv1x4HZXBH37IAdAVk8jqAStE", + "dp": "DGy2fyLuxareBSdE5IDxqgHO30Qa6iUfDWhx5nkEow-ZiDuO9eERrOf5VRkt-dWp4BjH-Q9pKjdm5iJXY55QOcQQkDS9DLL0eGFx_f-XkbyUGlCKVgnF3_Dzz1bQvFTF3fpTtnH4mlNHbwh2PGnL6VJWzaY215eXgTvo0effVK0", + "dq": "EgZ3Ab0qADSKSUeHLPK4vV3megyd1SjV9tEvwcT_up9vGNuYBA1VGjuVewNDMexUWSi9t6XHngK5SrNjwyChrNx4ZvcKCI6Yw33pPKt8VFFBvpzpzvsaFY3KLyRj1QoB2wpB5Ih6JTmAnNoaRF08NIm3OCeo1Bz983TBGPIxjUE", + "qi": "PmUhgOYkxBPWgNCIPzbls1tW0SHjRi1hpljZX-VBKd_UYqJtJldEijt6pFRXZiPOKEt0j1bACdQoF3owUGPXK8Z7IUPqN3FR0cAQ_zIV74gJMDku_Sqtw5BccP2nNzQ_CNWsSHX2qn9lSNeTa1HtCFSqZ0ypXk2ajKQjuiugfPY" +} diff --git a/src/test/resources/user_active.json b/src/test/resources/user_active.json index cc3e1ae..753e657 100644 --- a/src/test/resources/user_active.json +++ b/src/test/resources/user_active.json @@ -1,6 +1,6 @@ { "id": "cacc29d8-cade-4312-a5f2-4eeac55d8697", - "externalSystemId": "active@folio.com", + "externalSystemId": "9eb67301-6f6e-468f-9b1a-6134dc39a699", "active": true, "type": "patron", "patronGroup": "63f8065f-df84-4e76-a36b-3ba32dbdc9e5",