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",