From bbf989e5aeb1e4a61d15f7fdfe860b41f3e3e1ae Mon Sep 17 00:00:00 2001 From: Lyndsey Jane Moulds <2042238+Apophenia@users.noreply.github.com> Date: Tue, 16 Jan 2024 20:17:03 -0500 Subject: [PATCH 1/6] Add venv to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4a012b5286..7ac25b8825 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ config/local* tags # Vim *.swp +/*venv* \ No newline at end of file From 40ec2cb34cb5e880a765dd3ae276e835261cc090 Mon Sep 17 00:00:00 2001 From: Lyndsey Jane Moulds <2042238+Apophenia@users.noreply.github.com> Date: Tue, 16 Jan 2024 20:40:27 -0500 Subject: [PATCH 2/6] Refactor and complete fulfill endpoint --- api/blueprints/drbFulfill.py | 118 ++++++++++++++--------- api/db.py | 7 ++ api/utils.py | 50 ++++++++-- config/sample-compose.yaml | 15 ++- tests/unit/test_api_fulfill_blueprint.py | 18 ++-- 5 files changed, 143 insertions(+), 65 deletions(-) diff --git a/api/blueprints/drbFulfill.py b/api/blueprints/drbFulfill.py index d00aa0cab2..944e4c4d4a 100644 --- a/api/blueprints/drbFulfill.py +++ b/api/blueprints/drbFulfill.py @@ -3,60 +3,86 @@ import jwt -from flask import Blueprint, request +from flask import Blueprint, request, redirect, current_app +from ..db import DBClient from ..utils import APIUtils +from managers import S3Manager from logger import createLog -JWT_ALGORITHM = '' logger = createLog(__name__) -fulfill = Blueprint('fulfill', __name__, url_prefix='/fulfill') +fulfill = Blueprint("fulfill", __name__, url_prefix="/fulfill") -@fulfill.route('/', methods=['GET']) -def workFulfill(uuid): - logger.info('Checking if authorization is needed for work {}'.format(uuid)) - requires_authorization = True +@fulfill.route("/", methods=["GET"]) +def itemFulfill(link_id): + with DBClient(current_app.config["DB_CLIENT"]) as dbClient: + link = dbClient.fetchSingleLink(link_id) + if not link: + return APIUtils.formatResponseObject( + 404, "fulfill", "No link exists for this ID" + ) - if requires_authorization: - try: - bearer = request.headers.get('Authorization') - token = bearer.split()[1] + requires_authorization = ( + # Might not have edd flag if edd is not true + link.flags.get("edd", False) is False and link.flags["nypl_login"] is True + ) - jwt_secret = os.environ['NYPL_API_CLIENT_PUBLIC_KEY'] - decoded_token =(jwt.decode(token, jwt_secret, 'RS256', - audience="app_myaccount")) - if json.loads(json.dumps(decoded_token))['iss'] == "https://www.nypl.org": - statusCode = 200 - responseBody = uuid + if requires_authorization: + decodedToken = None + try: + bearer = request.headers.get("Authorization") + if bearer is None: + return APIUtils.formatResponseObject( + 401, + "fulfill", + "Invalid access token", + headers={"WWW-Authenticate": "Bearer"}, + ) + token = bearer.split()[1] + + jwt_secret = os.environ["NYPL_API_CLIENT_PUBLIC_KEY"] + decodedToken = jwt.decode( + token, jwt_secret, algorithms=["RS256"], audience="app_myaccount" + ) + + except jwt.exceptions.ExpiredSignatureError: + return APIUtils.formatResponseObject( + 401, + "fulfill", + "Expired access token", + headers={"WWW-Authenticate": "Bearer"}, + ) + except ( + jwt.exceptions.DecodeError, + UnicodeDecodeError, + IndexError, + AttributeError, + ): + return APIUtils.formatResponseObject( + 401, + "fulfill", + "Invalid access token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if decodedToken["iss"] == "https://www.nypl.org": + storageManager = S3Manager() + storageManager.createS3Client() + presignedObjectUrl = APIUtils.getPresignedUrlFromObjectUrl( + storageManager.s3Client, link.url + ) + return redirect(presignedObjectUrl) else: - statusCode = 401 - responseBody = 'Invalid access token' - - except jwt.exceptions.ExpiredSignatureError: - statusCode = 401 - responseBody = 'Expired access token' - except (jwt.exceptions.DecodeError, UnicodeDecodeError, IndexError, AttributeError): - statusCode = 401 - responseBody = 'Invalid access token' - except ValueError: - logger.warning("Could not deserialize NYPL-issued public key") - statusCode = 500 - responseBody = 'Server error' - - else: - # TODO: In the future, this could record an analytics timestamp - # and redirect to URL of a work if authentication is not required. - # For now, only use /fulfill endpoint in response if authentication is required. - statusCode = 400 - responseBody = "Bad Request" - - response = APIUtils.formatResponseObject( - statusCode, 'fulfill', responseBody - ) - - if statusCode == 401: - response[0].headers['WWW-Authenticate'] = 'Bearer' - - return response + return APIUtils.formatResponseObject( + 401, + "fulfill", + "Invalid access token", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + # TODO: In the future, this could record an analytics timestamp + # and redirect to URL of an item if authentication is not required. + # For now, only use /fulfill endpoint in response if authentication is required. + return APIUtils.formatResponseObject(400, "fulfill", "Bad request") diff --git a/api/db.py b/api/db.py index 4bfb8498d2..dd77b89eea 100644 --- a/api/db.py +++ b/api/db.py @@ -18,6 +18,13 @@ def createSession(self): def closeSession(self): self.session.close() + def __enter__(self): + self.createSession() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self.closeSession() + def fetchSearchedWorks(self, ids): uuids = [i[0] for i in ids] editionIds = list(set(APIUtils.flatten([i[1] for i in ids]))) diff --git a/api/utils.py b/api/utils.py index 5af6829fbf..c918bfe04f 100644 --- a/api/utils.py +++ b/api/utils.py @@ -8,6 +8,7 @@ from model.postgres.collection import COLLECTION_EDITIONS from logger import createLog from botocore.exceptions import ClientError +from urllib.parse import urlparse logger = createLog(__name__) @@ -481,14 +482,16 @@ def flatten(cls, nested): yield from cls.flatten(elem) @staticmethod - def formatResponseObject(status, responseType, datablock): + def formatResponseObject(status, responseType, datablock, headers = {}): + response = jsonify({ + 'status': status, + 'timestamp': datetime.utcnow(), + 'responseType': responseType, + 'data': datablock + }) + response.headers.extend(headers) return ( - jsonify({ - 'status': status, - 'timestamp': datetime.utcnow(), - 'responseType': responseType, - 'data': datablock - }), + response, status ) @@ -535,4 +538,35 @@ def generate_presigned_url(s3_client, client_method, method_parameters, expires_ "Couldn't get a presigned URL for client method '%s'.", client_method ) raise - return url \ No newline at end of file + return url + + @staticmethod + def getPresignedUrlFromObjectUrl(s3Client, url): + """ + Given the URL of an S3 resource, generate a presigned Amazon S3 URL + that can be used to access that resource. + + :param s3_client: A Boto3 Amazon S3 client + :param url: The URL of the desired resource + """ + + if "//" not in url: + url = "//" + url + + parsedUrl = urlparse(url) + + if "s3" not in parsedUrl.hostname: + raise ValueError( + "s3 helper function given a non-s3 or malformed URL" + ) + + bucketName = parsedUrl.hostname.split('.')[0] + objectKey = parsedUrl.path[1:] + timeValid = 1000 * 30 + + return APIUtils.generate_presigned_url( + s3Client, + "get_object", + {'Bucket': bucketName,'Key': objectKey}, + timeValid + ) diff --git a/config/sample-compose.yaml b/config/sample-compose.yaml index 91ed2d7726..e27b35137e 100644 --- a/config/sample-compose.yaml +++ b/config/sample-compose.yaml @@ -41,14 +41,25 @@ HATHI_API_ROOT: https://babel.hathitrust.org/cgi/htd #HATHI_API_KEY: #HATHI_API_SECRET: +# AWS CONFIGURATION +#AWS_ACCESS: +#AWS_SECRET: +#AWS_REGION: + # OCLC CONFIGURATION OCLC_QUERY_LIMIT: '390000' #OCLC_API_KEY: # NYPL AUTH CONFIGURATION -NYPL_API_CLIENT_PUBLIC_KEY: > +NYPL_API_CLIENT_PUBLIC_KEY: | -----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA44ilHg/PxcJYsISHMRyoxsmez178qZpkJVXg7rOMVTLZuf05an7Pl+lX4nw/rqcvGQDXyrimciLgLkWu00xhm6h6klTeJSNq2DgseF8OMw2olfuBKq1NBQ/vC8U0l5NJu34oSN4/iipgpovqAHHBGV4zDt0EWSXE5xpnBWi+w1NMAX/muB2QRfRxkkhueDkAmwKvz5MXJPay7FB/WRjf+7r2EN78x5iQKyCw0tpEZ5hpBX831SEnVULCnpFOcJWMPLdg0Ff6tBmgDxKQBVFIQ9RrzMLTqxKnVVn2+hVpk4F/8tMsGCdd4s/AJqEQBy5lsq7ji1B63XYqi5fc1SnJEQIDAQAB + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA44ilHg/PxcJYsISHMRyo + xsmez178qZpkJVXg7rOMVTLZuf05an7Pl+lX4nw/rqcvGQDXyrimciLgLkWu00xh + m6h6klTeJSNq2DgseF8OMw2olfuBKq1NBQ/vC8U0l5NJu34oSN4/iipgpovqAHHB + GV4zDt0EWSXE5xpnBWi+w1NMAX/muB2QRfRxkkhueDkAmwKvz5MXJPay7FB/WRjf + +7r2EN78x5iQKyCw0tpEZ5hpBX831SEnVULCnpFOcJWMPLdg0Ff6tBmgDxKQBVFI + Q9RrzMLTqxKnVVn2+hVpk4F/8tMsGCdd4s/AJqEQBy5lsq7ji1B63XYqi5fc1SnJ + EQIDAQAB -----END PUBLIC KEY----- # Bardo CCE API URL diff --git a/tests/unit/test_api_fulfill_blueprint.py b/tests/unit/test_api_fulfill_blueprint.py index dc61dfcdab..31b740c72d 100644 --- a/tests/unit/test_api_fulfill_blueprint.py +++ b/tests/unit/test_api_fulfill_blueprint.py @@ -3,7 +3,7 @@ import jwt -from api.blueprints.drbFulfill import workFulfill +from api.blueprints.drbFulfill import itemFulfill from api.utils import APIUtils @@ -23,31 +23,31 @@ def testApp(self): return flaskApp - def test_workFulfill_invalid_token(self, testApp, mockUtils, monkeypatch): + def test_itemFulfill_invalid_token(self, testApp, mockUtils, monkeypatch): with testApp.test_request_context('/fulfill/12345', headers={'Authorization': 'Bearer Whatever'}): monkeypatch.setenv('NYPL_API_CLIENT_PUBLIC_KEY', "SomeKeyValue") - workFulfill('12345') + itemFulfill('12345') mockUtils['formatResponseObject'].assert_called_once_with( 401, 'fulfill', 'Invalid access token') - def test_workFulfill_no_bearer_auth(self, testApp, mockUtils): + def test_itemFulfill_no_bearer_auth(self, testApp, mockUtils): with testApp.test_request_context('/fulfill/12345', headers={'Authorization': 'Whatever'}): - workFulfill('12345') + itemFulfill('12345') mockUtils['formatResponseObject'].assert_called_once_with( 401, 'fulfill', 'Invalid access token') - def test_workFulfill_empty_token(self, testApp, mockUtils): + def test_itemFulfill_empty_token(self, testApp, mockUtils): with testApp.test_request_context('/fulfill/12345', headers={'Authorization': ''}): - workFulfill('12345') + itemFulfill('12345') mockUtils['formatResponseObject'].assert_called_once_with( 401, 'fulfill', 'Invalid access token') - def test_workFulfill_no_header(self, testApp, mockUtils): + def test_itemFulfill_no_header(self, testApp, mockUtils): with testApp.test_request_context('/fulfill/12345'): - workFulfill('12345') + itemFulfill('12345') mockUtils['formatResponseObject'].assert_called_once_with( 401, 'fulfill', 'Invalid access token') From 66b85950d7433999e698388f216aaafe8fdf8a40 Mon Sep 17 00:00:00 2001 From: Lyndsey Jane Moulds <2042238+Apophenia@users.noreply.github.com> Date: Tue, 16 Jan 2024 21:47:34 -0500 Subject: [PATCH 3/6] Fix tests to work with DB mocks --- tests/unit/test_api_fulfill_blueprint.py | 40 +++++++++++++++--------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/tests/unit/test_api_fulfill_blueprint.py b/tests/unit/test_api_fulfill_blueprint.py index 31b740c72d..f1b312ee72 100644 --- a/tests/unit/test_api_fulfill_blueprint.py +++ b/tests/unit/test_api_fulfill_blueprint.py @@ -5,6 +5,7 @@ from api.blueprints.drbFulfill import itemFulfill from api.utils import APIUtils +from model.postgres.link import Link class TestSearchBlueprint: @@ -23,33 +24,44 @@ def testApp(self): return flaskApp - def test_itemFulfill_invalid_token(self, testApp, mockUtils, monkeypatch): - with testApp.test_request_context('/fulfill/12345', + @pytest.fixture + def mockDB(self, mocker, monkeypatch): + mockDB = mocker.MagicMock() + mockDB.__enter__.return_value = mockDB + mockDB.fetchSingleLink.return_value = Link(flags = {"nypl_login": True}) + mocker.patch('api.blueprints.drbFulfill.DBClient').return_value = mockDB + + monkeypatch.setenv('NYPL_API_CLIENT_PUBLIC_KEY', "SomeKeyValue") + + def test_itemFulfill_invalid_token(self, testApp, mockUtils, mockDB): + with testApp.test_request_context('/fulfill/12345', headers={'Authorization': 'Bearer Whatever'}): - monkeypatch.setenv('NYPL_API_CLIENT_PUBLIC_KEY', "SomeKeyValue") itemFulfill('12345') mockUtils['formatResponseObject'].assert_called_once_with( - 401, 'fulfill', 'Invalid access token') + 401, 'fulfill', 'Invalid access token', headers={'WWW-Authenticate': 'Bearer'}) - def test_itemFulfill_no_bearer_auth(self, testApp, mockUtils): - with testApp.test_request_context('/fulfill/12345', + def test_itemFulfill_no_bearer_auth(self, testApp, mockUtils, mockDB): + with testApp.test_request_context('/fulfill/12345', headers={'Authorization': 'Whatever'}): itemFulfill('12345') mockUtils['formatResponseObject'].assert_called_once_with( - 401, 'fulfill', 'Invalid access token') + 401, 'fulfill', 'Invalid access token', headers={'WWW-Authenticate': 'Bearer'}) - def test_itemFulfill_empty_token(self, testApp, mockUtils): - with testApp.test_request_context('/fulfill/12345', + def test_itemFulfill_empty_token(self, testApp, mockUtils, mockDB): + with testApp.test_request_context('/fulfill/12345', headers={'Authorization': ''}): itemFulfill('12345') mockUtils['formatResponseObject'].assert_called_once_with( - 401, 'fulfill', 'Invalid access token') + 401, 'fulfill', 'Invalid access token', headers={'WWW-Authenticate': 'Bearer'}) - def test_itemFulfill_no_header(self, testApp, mockUtils): + def test_itemFulfill_no_header(self, testApp, mockUtils, mockDB): with testApp.test_request_context('/fulfill/12345'): itemFulfill('12345') mockUtils['formatResponseObject'].assert_called_once_with( - 401, 'fulfill', 'Invalid access token') - + 401, 'fulfill', 'Invalid access token', headers={'WWW-Authenticate': 'Bearer'}) + + #def test_itemFulfill_valid(self, testApp, mockUtils, mockDB): + # with testApp.test_request_context('/fulfill/12345'): + # itemFulfill('12345') + - \ No newline at end of file From ec054b6c8297457589e78a0df6b777b948955dfc Mon Sep 17 00:00:00 2001 From: Lyndsey Jane Moulds <2042238+Apophenia@users.noreply.github.com> Date: Thu, 18 Jan 2024 20:33:34 -0500 Subject: [PATCH 4/6] Add and update tests --- tests/unit/test_api_fulfill_blueprint.py | 33 +++++++++++++---- tests/unit/test_api_utils.py | 45 +++++++++++++++--------- 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/tests/unit/test_api_fulfill_blueprint.py b/tests/unit/test_api_fulfill_blueprint.py index f1b312ee72..7fc8033f3b 100644 --- a/tests/unit/test_api_fulfill_blueprint.py +++ b/tests/unit/test_api_fulfill_blueprint.py @@ -3,7 +3,7 @@ import jwt -from api.blueprints.drbFulfill import itemFulfill +from api.blueprints.drbFulfill import itemFulfill, fulfill from api.utils import APIUtils from model.postgres.link import Link @@ -13,7 +13,8 @@ class TestSearchBlueprint: def mockUtils(self, mocker): return mocker.patch.multiple( APIUtils, - formatResponseObject=mocker.DEFAULT + formatResponseObject=mocker.DEFAULT, + getPresignedUrlFromObjectUrl="example.com/example.pdf" ) @pytest.fixture @@ -21,6 +22,7 @@ def testApp(self): flaskApp = Flask('test') flaskApp.config['DB_CLIENT'] = 'testDBClient' flaskApp.config['READER_VERSION'] = 'test' + flaskApp.register_blueprint(fulfill) return flaskApp @@ -28,7 +30,8 @@ def testApp(self): def mockDB(self, mocker, monkeypatch): mockDB = mocker.MagicMock() mockDB.__enter__.return_value = mockDB - mockDB.fetchSingleLink.return_value = Link(flags = {"nypl_login": True}) + mockDB.fetchSingleLink.return_value = Link(flags = {"nypl_login": True}, + url="https://doc-example-bucket1.s3.us-west-2.amazonaws.com/puppy.png") mocker.patch('api.blueprints.drbFulfill.DBClient').return_value = mockDB monkeypatch.setenv('NYPL_API_CLIENT_PUBLIC_KEY', "SomeKeyValue") @@ -60,8 +63,24 @@ def test_itemFulfill_no_header(self, testApp, mockUtils, mockDB): mockUtils['formatResponseObject'].assert_called_once_with( 401, 'fulfill', 'Invalid access token', headers={'WWW-Authenticate': 'Bearer'}) - #def test_itemFulfill_valid(self, testApp, mockUtils, mockDB): - # with testApp.test_request_context('/fulfill/12345'): - # itemFulfill('12345') - + def test_itemFulfill_invalid_iss(self, testApp, mockUtils, mockDB, mocker): + with testApp.test_request_context('/fulfill/12345'): + mocker.patch("jwt.decode", return_value={ + "iss": "https://example.com" + }) + itemFulfill('12345') + mockUtils['formatResponseObject'].assert_called_once_with( + 401, 'fulfill', 'Invalid access token', headers={'WWW-Authenticate': 'Bearer'}) + def test_itemFulfill_redirect(self, testApp, mockDB, mocker): + mocker.patch("api.utils.APIUtils.getPresignedUrlFromObjectUrl", return_value="example.com/example.pdf") + mocker.patch("jwt.decode", return_value={ + "iss": "https://www.nypl.org" + }) + response = testApp.test_client().get( + '/fulfill/12345', + follow_redirects=False, + headers={'Authorization': 'Bearer Whatever'} + ) + assert response.status_code == 302 + assert response.location == "example.com/example.pdf" \ No newline at end of file diff --git a/tests/unit/test_api_utils.py b/tests/unit/test_api_utils.py index c41a703f8c..f22a40249e 100644 --- a/tests/unit/test_api_utils.py +++ b/tests/unit/test_api_utils.py @@ -1,6 +1,7 @@ from hashlib import scrypt import pytest from random import shuffle +from flask import Flask from api.utils import APIUtils from datetime import datetime @@ -574,22 +575,23 @@ def test_flatten_nested(self): assert flatArray == [1, 2, 3, 4, 5] def test_formatResponseObject(self, mocker): - mockDatetime = mocker.patch('api.utils.datetime') - mockDatetime.utcnow.return_value = 'presentTimestamp' - mockJsonify = mocker.patch('api.utils.jsonify') - mockJsonify.return_value = 'jsonBlock' - - testResponse = APIUtils.formatResponseObject(200, 'test', 'testData') - - assert testResponse[0] == 'jsonBlock' - assert testResponse[1] == 200 - mockDatetime.utcnow.assert_called_once - mockJsonify.assert_called_once_with({ - 'status': 200, - 'timestamp': 'presentTimestamp', - 'responseType': 'test', - 'data': 'testData' - }) + testApp = Flask('test') + with testApp.test_request_context('/'): + mockDatetime = mocker.patch('api.utils.datetime') + mockDatetime.utcnow.return_value = 'presentTimestamp' + + testResponse = APIUtils.formatResponseObject(200, 'test', {"test": "test data"}) + + assert testResponse[0].json == { + 'status': 200, + 'timestamp': 'presentTimestamp', + 'responseType': 'test', + 'data': { + 'test': 'test data' + } + } + assert testResponse[1] == 200 + mockDatetime.utcnow.assert_called_once def test_formatPipeDelimitedData_string(self): assert APIUtils.formatPipeDelimitedData('test|object', ['one', 'two'])\ @@ -643,3 +645,14 @@ def test_sortByMediaType(self): shuffle(testList) testList.sort(key=APIUtils.sortByMediaType) assert [i['id'] for i in testList] == [5, 2, 3, 4, 1, 1] + + def test_getPresignedUrlFromObjectUrl(self, mocker): + mockGenerateUrl = mocker.patch.object(APIUtils, 'generate_presigned_url') + mockGenerateUrl.return_value = 'https://example.com/mypresignedurl' + assert APIUtils.getPresignedUrlFromObjectUrl({"Some Client"}, "https://doc-example-bucket1.s3.us-west-2.amazonaws.com/puppy.png") == "https://example.com/mypresignedurl" + mockGenerateUrl.assert_called_once_with({"Some Client"}, "get_object", {'Bucket': "doc-example-bucket1",'Key': "puppy.png"}, 30000) + + def test_getPresignedUrlFromNons3Url(self): + with pytest.raises(ValueError): + APIUtils.getPresignedUrlFromObjectUrl({"Some Client"}, "https://example.com") + From a1ad3a17bba2da67bbfa047f249cbdf32d38c286 Mon Sep 17 00:00:00 2001 From: Lyndsey Jane Moulds <2042238+Apophenia@users.noreply.github.com> Date: Thu, 18 Jan 2024 21:30:33 -0500 Subject: [PATCH 5/6] Mock s3 client --- tests/unit/test_api_fulfill_blueprint.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_api_fulfill_blueprint.py b/tests/unit/test_api_fulfill_blueprint.py index 7fc8033f3b..e13e4b925b 100644 --- a/tests/unit/test_api_fulfill_blueprint.py +++ b/tests/unit/test_api_fulfill_blueprint.py @@ -36,6 +36,12 @@ def mockDB(self, mocker, monkeypatch): monkeypatch.setenv('NYPL_API_CLIENT_PUBLIC_KEY', "SomeKeyValue") + @pytest.fixture + def mockS3(self, mocker): + mockS3Manager = mocker.MagicMock() + mocker.patch('api.blueprints.drbFulfill.S3Manager').return_value = mockS3Manager + return mockS3Manager + def test_itemFulfill_invalid_token(self, testApp, mockUtils, mockDB): with testApp.test_request_context('/fulfill/12345', headers={'Authorization': 'Bearer Whatever'}): @@ -72,7 +78,7 @@ def test_itemFulfill_invalid_iss(self, testApp, mockUtils, mockDB, mocker): mockUtils['formatResponseObject'].assert_called_once_with( 401, 'fulfill', 'Invalid access token', headers={'WWW-Authenticate': 'Bearer'}) - def test_itemFulfill_redirect(self, testApp, mockDB, mocker): + def test_itemFulfill_redirect(self, testApp, mockS3, mockDB, mocker): mocker.patch("api.utils.APIUtils.getPresignedUrlFromObjectUrl", return_value="example.com/example.pdf") mocker.patch("jwt.decode", return_value={ "iss": "https://www.nypl.org" From 8f616cd222c60b46da2f938deb9bb7b6ef4d9c8c Mon Sep 17 00:00:00 2001 From: Lyndsey Jane Moulds <2042238+Apophenia@users.noreply.github.com> Date: Fri, 19 Jan 2024 10:39:00 -0500 Subject: [PATCH 6/6] Add changelog changes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5be001d0..e63ee57a53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## unreleased version -- v0.12.4 ## Added New /fulfill endpoint with ability to check for NYPL login in Bearer authorization header +Fulfill endpoint returns pre-signed URLs for objects in private buckets when user is logged in ## unreleased version -- v0.12.4 ## Added