From 60b9e200cf11f85ee053d293c880f393e68fc397 Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Tue, 7 Aug 2018 02:18:54 -0700 Subject: [PATCH 01/18] Add error handling to /register API endpoint. Start of work on #25. --- src/ocspdash/manager.py | 6 ++--- src/ocspdash/web/blueprints/api.py | 37 +++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/ocspdash/manager.py b/src/ocspdash/manager.py index e62b4a4..713ec92 100644 --- a/src/ocspdash/manager.py +++ b/src/ocspdash/manager.py @@ -524,11 +524,11 @@ def process_location(self, invite_token: bytes, public_key: str) -> Optional[Loc location = self.get_location_by_selector(selector) if location is None: - raise Exception(f'location not found for selector: {selector}') + raise ValueError(f'location not found for selector: {selector}') if location.pubkey: # this invite has already been used - return None + raise ValueError(f'invite has already been used: {invite_token}') if not location.verify(validator): - return None + raise ValueError(f'invalid invite validator: {validator}') location.set_public_key(public_key) diff --git a/src/ocspdash/web/blueprints/api.py b/src/ocspdash/web/blueprints/api.py index a63b968..184fe59 100644 --- a/src/ocspdash/web/blueprints/api.py +++ b/src/ocspdash/web/blueprints/api.py @@ -2,6 +2,7 @@ """The OCSPdash API blueprint.""" +import binascii import io import logging import uuid @@ -29,21 +30,41 @@ def register_location_key(): """Register a public key for an invited location.""" # TODO: error handling (what if no invite, what if duplicate name, etc.) - unverified_claims = jwt.get_unverified_claims(request.data) - unverified_public_key = b64decode(unverified_claims['pk']).decode('utf-8') + print(request.data) + try: + unverified_claims = jwt.get_unverified_claims(request.data) + except jwt.JWTError: + return 'malformed JWT', HTTPStatus.BAD_REQUEST + + try: + unverified_public_key = b64decode(unverified_claims['pk']).decode('utf-8') + except KeyError: + return "'pk' missing from unverified claims", HTTPStatus.BAD_REQUEST + except (binascii.Error, UnicodeError): + abort(400) # bad data in 'pk' claim + return "failed to decode 'pk' claim", HTTPStatus.BAD_REQUEST try: claims = jwt.decode(request.data, unverified_public_key) except JWTError: - return abort(400) # bad input + return 'failed to validate JWT', HTTPStatus.BAD_REQUEST - public_key = claims['pk'] - invite_token = b64decode(claims['token']) + try: + public_key = claims['pk'] + except KeyError: + return "'pk' misisng from verified claims", HTTPStatus.BAD_REQUEST - new_location = manager.process_location(invite_token, public_key) + try: + invite_token = b64decode(claims['token']) + except KeyError: + return "'token' missing from verified claims", HTTPStatus.BAD_REQUEST + except binascii.Error: + return "failed to decode 'token' claim", HTTPStatus.BAD_REQUEST - if new_location is None: - return abort(400) + try: + manager.process_location(invite_token, public_key) + except ValueError: + return 'bad invite or public key', HTTPStatus.BAD_REQUEST return '', HTTPStatus.NO_CONTENT From 5a5d3a0b6eddc08012efeece156216771237acc3 Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Tue, 7 Aug 2018 02:26:53 -0700 Subject: [PATCH 02/18] Add :raises: to docstrings where appropriate. --- src/ocspdash/manager.py | 1 + src/ocspdash/models.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/ocspdash/manager.py b/src/ocspdash/manager.py index 713ec92..ac00bf2 100644 --- a/src/ocspdash/manager.py +++ b/src/ocspdash/manager.py @@ -516,6 +516,7 @@ def process_location(self, invite_token: bytes, public_key: str) -> Optional[Loc :parameter public_key: The public key to be associated with the Location. :returns: The Location if a valid invite was provided, otherwise None. + :raises ValueError: with an appropriate message for failures to process the location invite. """ if len(invite_token) != 32: raise ValueError('invite_token of wrong length') diff --git a/src/ocspdash/models.py b/src/ocspdash/models.py index cfb532a..18c288f 100644 --- a/src/ocspdash/models.py +++ b/src/ocspdash/models.py @@ -226,6 +226,8 @@ def set_public_key(self, public_key: str): """Set the pubkey and key_id for the Location based on an input public key. :param public_key: The public key for the Location. + + :raises ValueError: if the passed-in public key is not of a valid algorithm as defined in constants.OCSPSCRAPE_PRIVATE_KEY_ALGORITHMS. """ pubkey = b64decode(public_key) loaded_pubkey = serialization.load_pem_public_key(pubkey, default_backend()) From 7901d139d7713d6c641c85a0cc693620b9a240cd Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Tue, 7 Aug 2018 02:27:04 -0700 Subject: [PATCH 03/18] Ignore McCabe complexity on register_location_key. --- src/ocspdash/web/blueprints/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ocspdash/web/blueprints/api.py b/src/ocspdash/web/blueprints/api.py index 184fe59..833ce28 100644 --- a/src/ocspdash/web/blueprints/api.py +++ b/src/ocspdash/web/blueprints/api.py @@ -26,7 +26,7 @@ api = Blueprint('api', __name__) -@api.route('/register', methods=['POST']) +@api.route('/register', methods=['POST']) # noqa: C901 def register_location_key(): """Register a public key for an invited location.""" # TODO: error handling (what if no invite, what if duplicate name, etc.) From 92b4709bb36f3348959bf183dcc13f437ee3c9c4 Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Tue, 7 Aug 2018 02:43:09 -0700 Subject: [PATCH 04/18] Add more error checking to the API. --- src/ocspdash/web/blueprints/api.py | 53 +++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/src/ocspdash/web/blueprints/api.py b/src/ocspdash/web/blueprints/api.py index 833ce28..8b88c28 100644 --- a/src/ocspdash/web/blueprints/api.py +++ b/src/ocspdash/web/blueprints/api.py @@ -34,37 +34,36 @@ def register_location_key(): try: unverified_claims = jwt.get_unverified_claims(request.data) except jwt.JWTError: - return 'malformed JWT', HTTPStatus.BAD_REQUEST + abort(HTTPStatus.BAD_REQUEST, 'malformed JWT') try: unverified_public_key = b64decode(unverified_claims['pk']).decode('utf-8') except KeyError: - return "'pk' missing from unverified claims", HTTPStatus.BAD_REQUEST + abort(HTTPStatus.BAD_REQUEST, "'pk' missing from claims") except (binascii.Error, UnicodeError): - abort(400) # bad data in 'pk' claim - return "failed to decode 'pk' claim", HTTPStatus.BAD_REQUEST + abort(HTTPStatus.BAD_REQUEST, "failed to decode 'pk' claim") try: claims = jwt.decode(request.data, unverified_public_key) except JWTError: - return 'failed to validate JWT', HTTPStatus.BAD_REQUEST + abort(HTTPStatus.BAD_REQUEST, 'failed to validate JWT') try: public_key = claims['pk'] except KeyError: - return "'pk' misisng from verified claims", HTTPStatus.BAD_REQUEST + abort(HTTPStatus.BAD_REQUEST, "'pk' misisng from claims") try: invite_token = b64decode(claims['token']) except KeyError: - return "'token' missing from verified claims", HTTPStatus.BAD_REQUEST + abort(HTTPStatus.BAD_REQUEST, "'token' missing from claims") except binascii.Error: - return "failed to decode 'token' claim", HTTPStatus.BAD_REQUEST + abort(HTTPStatus.BAD_REQUEST, "failed to decode 'token' claim") try: manager.process_location(invite_token, public_key) except ValueError: - return 'bad invite or public key', HTTPStatus.BAD_REQUEST + abort(HTTPStatus.BAD_REQUEST, 'bad invite or public key') return '', HTTPStatus.NO_CONTENT @@ -86,7 +85,7 @@ def get_manifest(): """ n = request.args.get('n', type=int, default=10) # TODO make configurable at app level if n > 10: - abort(400, 'n too large, max is 10') # TODO get the max config value here too + abort(HTTPStatus.BAD_REQUEST, 'n too large, max is 10') # TODO get the max config value here too manifest_lines = io.StringIO() with jsonlines.Writer(manifest_lines, sort_keys=True) as writer: writer.write_all( @@ -101,7 +100,10 @@ def get_manifest(): def _prepare_result_dictionary(result_data): certificate_chain_uuid: uuid.UUID = uuid.UUID(result_data['certificate_chain_uuid']) + chain = manager.get_chain_by_certificate_chain_uuid(certificate_chain_uuid) + if not chain: + raise ValueError(f'No chain with certificate_chain_uuid: {certificate_chain_uuid}') retrieved = datetime.strptime(result_data['time'], '%Y-%m-%dT%H:%M:%SZ') @@ -121,18 +123,37 @@ def submit(): tags: - ocsp """ - submitted_token_header = jwt.get_unverified_header(request.data) + try: + submitted_token_header = jwt.get_unverified_header(request.data) + except jwt.JWTError: + abort(HTTPStatus.BAD_REQUEST, 'malformed JWT') + + try: + key_id = uuid.UUID(submitted_token_header['kid']) + except KeyError: + abort(HTTPStatus.BAD_REQUEST, "'kid' missing from JWT header") - key_id = uuid.UUID(submitted_token_header['kid']) submitting_location = manager.get_location_by_key_id(key_id) + if not submitting_location: + abort(HTTPStatus.BAD_REQUEST, f'no location with key id: {key_id}') try: claims = jwt.decode(request.data, submitting_location.pubkey.decode('utf-8')) except JWTError: - return abort(400) + abort(HTTPStatus.BAD_REQUEST, 'failed to validate JWT') - prepared_result_dicts = (_prepare_result_dictionary(result_data) - for result_data in claims[OCSP_RESULTS_JWT_CLAIM]) + try: + results = claims[OCSP_RESULTS_JWT_CLAIM] + except KeyError: + abort(HTTPStatus.BAD_REQUEST, f"'{OCSP_RESULTS_JWT_CLAIM}' missing from claims") + + try: + prepared_result_dicts = (_prepare_result_dictionary(result_data) + for result_data in results) + except (KeyError, ValueError): + abort(HTTPStatus.BAD_REQUEST, 'invalid result data') + + # TODO: can this raise an exception? I think yes if there's a constraint broken on the DB when commit is called() manager.insert_payload(submitting_location, prepared_result_dicts) - return ('', HTTPStatus.NO_CONTENT) + return '', HTTPStatus.NO_CONTENT From a68698525079a8892288098d7abad44028b3bbd8 Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Tue, 7 Aug 2018 02:43:41 -0700 Subject: [PATCH 05/18] Ignore McCabe complexity on submit. --- src/ocspdash/web/blueprints/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ocspdash/web/blueprints/api.py b/src/ocspdash/web/blueprints/api.py index 8b88c28..bd04fa9 100644 --- a/src/ocspdash/web/blueprints/api.py +++ b/src/ocspdash/web/blueprints/api.py @@ -115,7 +115,7 @@ def _prepare_result_dictionary(result_data): } -@api.route('/submit', methods=['POST']) +@api.route('/submit', methods=['POST']) # noqa: C901 def submit(): """Submit scrape results. From b0defeac9a506ac051a7a04f441c2f359e666bd0 Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Wed, 8 Aug 2018 00:38:02 -0700 Subject: [PATCH 06/18] Switch to using an Exception instead of manually calling abort() http://flask.pocoo.org/docs/1.0/patterns/apierrors/ Changes for flake and mypy. --- src/ocspdash/web/blueprints/api.py | 44 +++++++++++++++++++----------- src/ocspdash/web/exceptions.py | 28 +++++++++++++++++++ 2 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 src/ocspdash/web/exceptions.py diff --git a/src/ocspdash/web/blueprints/api.py b/src/ocspdash/web/blueprints/api.py index bd04fa9..a1c786c 100644 --- a/src/ocspdash/web/blueprints/api.py +++ b/src/ocspdash/web/blueprints/api.py @@ -12,11 +12,12 @@ from http import HTTPStatus import jsonlines -from flask import Blueprint, abort, request +from flask import Blueprint, jsonify, request from jose import jwt from jose.exceptions import JWTError from ocspdash.constants import OCSP_JWT_ALGORITHM, OCSP_RESULTS_JWT_CLAIM +from ocspdash.web.exceptions import InvalidUsage from ocspdash.web.proxies import manager jwt.decode = partial(jwt.decode, algorithms=OCSP_JWT_ALGORITHM) @@ -34,36 +35,36 @@ def register_location_key(): try: unverified_claims = jwt.get_unverified_claims(request.data) except jwt.JWTError: - abort(HTTPStatus.BAD_REQUEST, 'malformed JWT') + raise InvalidUsage('malformed JWT') try: unverified_public_key = b64decode(unverified_claims['pk']).decode('utf-8') except KeyError: - abort(HTTPStatus.BAD_REQUEST, "'pk' missing from claims") + raise InvalidUsage("'pk' missing from claims") except (binascii.Error, UnicodeError): - abort(HTTPStatus.BAD_REQUEST, "failed to decode 'pk' claim") + raise InvalidUsage("failed to decode 'pk' claim") try: claims = jwt.decode(request.data, unverified_public_key) except JWTError: - abort(HTTPStatus.BAD_REQUEST, 'failed to validate JWT') + raise InvalidUsage('failed to validate JWT') try: public_key = claims['pk'] except KeyError: - abort(HTTPStatus.BAD_REQUEST, "'pk' misisng from claims") + raise InvalidUsage("'pk' missing from claims") try: invite_token = b64decode(claims['token']) except KeyError: - abort(HTTPStatus.BAD_REQUEST, "'token' missing from claims") + raise InvalidUsage("'token' missing from claims") except binascii.Error: - abort(HTTPStatus.BAD_REQUEST, "failed to decode 'token' claim") + raise InvalidUsage("failed to decode 'token' claim") try: manager.process_location(invite_token, public_key) except ValueError: - abort(HTTPStatus.BAD_REQUEST, 'bad invite or public key') + raise InvalidUsage('bad invite or public key') return '', HTTPStatus.NO_CONTENT @@ -85,7 +86,7 @@ def get_manifest(): """ n = request.args.get('n', type=int, default=10) # TODO make configurable at app level if n > 10: - abort(HTTPStatus.BAD_REQUEST, 'n too large, max is 10') # TODO get the max config value here too + raise InvalidUsage(f'n too large, max is 10: {n}') # TODO get the max config value here too manifest_lines = io.StringIO() with jsonlines.Writer(manifest_lines, sort_keys=True) as writer: writer.write_all( @@ -126,34 +127,45 @@ def submit(): try: submitted_token_header = jwt.get_unverified_header(request.data) except jwt.JWTError: - abort(HTTPStatus.BAD_REQUEST, 'malformed JWT') + raise InvalidUsage('malformed JWT') try: key_id = uuid.UUID(submitted_token_header['kid']) except KeyError: - abort(HTTPStatus.BAD_REQUEST, "'kid' missing from JWT header") + raise InvalidUsage("'kid' missing from JWT header") submitting_location = manager.get_location_by_key_id(key_id) if not submitting_location: - abort(HTTPStatus.BAD_REQUEST, f'no location with key id: {key_id}') + raise InvalidUsage(f'no location with key id: {key_id}') try: claims = jwt.decode(request.data, submitting_location.pubkey.decode('utf-8')) except JWTError: - abort(HTTPStatus.BAD_REQUEST, 'failed to validate JWT') + raise InvalidUsage('failed to validate JWT') try: results = claims[OCSP_RESULTS_JWT_CLAIM] except KeyError: - abort(HTTPStatus.BAD_REQUEST, f"'{OCSP_RESULTS_JWT_CLAIM}' missing from claims") + raise InvalidUsage(f"'{OCSP_RESULTS_JWT_CLAIM}' missing from claims") try: prepared_result_dicts = (_prepare_result_dictionary(result_data) for result_data in results) except (KeyError, ValueError): - abort(HTTPStatus.BAD_REQUEST, 'invalid result data') + raise InvalidUsage('invalid result data') # TODO: can this raise an exception? I think yes if there's a constraint broken on the DB when commit is called() manager.insert_payload(submitting_location, prepared_result_dicts) return '', HTTPStatus.NO_CONTENT + + +@api.errorhandler(InvalidUsage) +def handle_invalid_usage(error: InvalidUsage): + """Handle InvalidUsage exceptions raised by views in the blueprint. + + :param error: The exception that caused this handler to be called. + """ + response = jsonify(error) + response.status_code = error.status_code + return response diff --git a/src/ocspdash/web/exceptions.py b/src/ocspdash/web/exceptions.py new file mode 100644 index 0000000..88dde1b --- /dev/null +++ b/src/ocspdash/web/exceptions.py @@ -0,0 +1,28 @@ +"""Exceptions and Errors for the OCSPdash web package.""" +from http import HTTPStatus +from typing import Mapping + + +class InvalidUsage(Exception): + """An Exception to be raised by a view for invalid usage of the endpoint.""" + + status_code = HTTPStatus.BAD_REQUEST + + def __init__(self, message: str, status_code: HTTPStatus=None, payload: Mapping=None) -> None: + """Create an InvalidUsage exception. + + :param message: The message for the exception; will be placed in the `message` key of the JSON returned in the response. + :param status_code: An HTTP status code for the response; default is 400 BAD REQUEST. + :param payload: A mapping that can be jsonified by Flask; will be returned as JSON in the response alongside the message. + """ + super().__init__(self) + self.message = message + if status_code is not None: + self.status_code = status_code + self.payload = payload + + def to_json(self) -> Mapping: + """Return a representation of the exception suitable to passed for JSON conversion.""" + rv = dict(self.payload or ()) + rv['message'] = self.message + return rv From 631884862f0c2f2fe056c6e247bcc54ab995063f Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Wed, 8 Aug 2018 00:46:13 -0700 Subject: [PATCH 07/18] Remove some print() debugging. Sorry @cthoyt --- src/ocspdash/web/blueprints/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ocspdash/web/blueprints/api.py b/src/ocspdash/web/blueprints/api.py index a1c786c..0814ae3 100644 --- a/src/ocspdash/web/blueprints/api.py +++ b/src/ocspdash/web/blueprints/api.py @@ -31,7 +31,6 @@ def register_location_key(): """Register a public key for an invited location.""" # TODO: error handling (what if no invite, what if duplicate name, etc.) - print(request.data) try: unverified_claims = jwt.get_unverified_claims(request.data) except jwt.JWTError: From 83b28506c5f12f5709a573dc1bc379da39e7080f Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Wed, 8 Aug 2018 01:08:14 -0700 Subject: [PATCH 08/18] Fancier error messages for the API. --- src/ocspdash/manager.py | 6 +-- src/ocspdash/web/blueprints/api.py | 68 ++++++++++++++++-------------- 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/ocspdash/manager.py b/src/ocspdash/manager.py index ac00bf2..cd14a6f 100644 --- a/src/ocspdash/manager.py +++ b/src/ocspdash/manager.py @@ -525,11 +525,11 @@ def process_location(self, invite_token: bytes, public_key: str) -> Optional[Loc location = self.get_location_by_selector(selector) if location is None: - raise ValueError(f'location not found for selector: {selector}') + raise ValueError(f'invalid invite token') if location.pubkey: # this invite has already been used - raise ValueError(f'invite has already been used: {invite_token}') + raise ValueError(f'invite expired') if not location.verify(validator): - raise ValueError(f'invalid invite validator: {validator}') + raise ValueError(f'invalid invite token') location.set_public_key(public_key) diff --git a/src/ocspdash/web/blueprints/api.py b/src/ocspdash/web/blueprints/api.py index 0814ae3..5536db0 100644 --- a/src/ocspdash/web/blueprints/api.py +++ b/src/ocspdash/web/blueprints/api.py @@ -33,37 +33,39 @@ def register_location_key(): # TODO: error handling (what if no invite, what if duplicate name, etc.) try: unverified_claims = jwt.get_unverified_claims(request.data) - except jwt.JWTError: - raise InvalidUsage('malformed JWT') + except jwt.JWTError as e: + raise InvalidUsage(f'failed to decode JWT: {str(e)}') try: unverified_public_key = b64decode(unverified_claims['pk']).decode('utf-8') - except KeyError: - raise InvalidUsage("'pk' missing from claims") - except (binascii.Error, UnicodeError): - raise InvalidUsage("failed to decode 'pk' claim") + except KeyError as e: + raise InvalidUsage(f'missing claim: {str(e)}') + except binascii.Error as e: + raise InvalidUsage(f"failed to decode 'pk' claim: {str(e)}") + except UnicodeError as e: + raise InvalidUsage(f"failed to decode 'pk' claim: {e.reason}") try: claims = jwt.decode(request.data, unverified_public_key) - except JWTError: - raise InvalidUsage('failed to validate JWT') + except JWTError as e: + raise InvalidUsage(f'failed to decode JWT: {str(e)}') try: public_key = claims['pk'] - except KeyError: - raise InvalidUsage("'pk' missing from claims") + except KeyError as e: + raise InvalidUsage(f'missing claim: {str(e)}') try: invite_token = b64decode(claims['token']) - except KeyError: - raise InvalidUsage("'token' missing from claims") - except binascii.Error: - raise InvalidUsage("failed to decode 'token' claim") + except KeyError as e: + raise InvalidUsage(f'missing claim: {str(e)}') + except binascii.Error as e: + raise InvalidUsage(f"failed to decode 'token' claim: {str(e)}") try: manager.process_location(invite_token, public_key) - except ValueError: - raise InvalidUsage('bad invite or public key') + except ValueError as e: + raise InvalidUsage(f'failed to process invite: {str(e)}') return '', HTTPStatus.NO_CONTENT @@ -103,7 +105,7 @@ def _prepare_result_dictionary(result_data): chain = manager.get_chain_by_certificate_chain_uuid(certificate_chain_uuid) if not chain: - raise ValueError(f'No chain with certificate_chain_uuid: {certificate_chain_uuid}') + raise ValueError(f'no chain with certificate_chain_uuid: {certificate_chain_uuid}') retrieved = datetime.strptime(result_data['time'], '%Y-%m-%dT%H:%M:%SZ') @@ -125,33 +127,35 @@ def submit(): """ try: submitted_token_header = jwt.get_unverified_header(request.data) - except jwt.JWTError: - raise InvalidUsage('malformed JWT') + except jwt.JWTError as e: + raise InvalidUsage(f'failed to decode JWT: {str(e)}') try: key_id = uuid.UUID(submitted_token_header['kid']) - except KeyError: - raise InvalidUsage("'kid' missing from JWT header") + except KeyError as e: + raise InvalidUsage(f'missing header claim: {str(e)}') submitting_location = manager.get_location_by_key_id(key_id) if not submitting_location: - raise InvalidUsage(f'no location with key id: {key_id}') + raise InvalidUsage(f'no location for key id', payload={'key_id': key_id}) try: claims = jwt.decode(request.data, submitting_location.pubkey.decode('utf-8')) - except JWTError: - raise InvalidUsage('failed to validate JWT') + except JWTError as e: + raise InvalidUsage(f'failed to decode JWT: {str(e)}') try: results = claims[OCSP_RESULTS_JWT_CLAIM] - except KeyError: - raise InvalidUsage(f"'{OCSP_RESULTS_JWT_CLAIM}' missing from claims") - - try: - prepared_result_dicts = (_prepare_result_dictionary(result_data) - for result_data in results) - except (KeyError, ValueError): - raise InvalidUsage('invalid result data') + except KeyError as e: + raise InvalidUsage(f'missing claim: {str(e)}') + + prepared_result_dicts = [] + for result_data in results: + try: + prepared_dict = _prepare_result_dictionary(result_data) + prepared_result_dicts.append(prepared_dict) + except (KeyError, ValueError): + raise InvalidUsage('invalid result data', payload={'result': result_data}) # TODO: can this raise an exception? I think yes if there's a constraint broken on the DB when commit is called() manager.insert_payload(submitting_location, prepared_result_dicts) From e9e86e9194cd404d51dd9ef43f2d8b9e805f7d87 Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Wed, 8 Aug 2018 01:19:04 -0700 Subject: [PATCH 09/18] Add `status` key to the response JSON of InvalidUsage exception. --- src/ocspdash/web/exceptions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ocspdash/web/exceptions.py b/src/ocspdash/web/exceptions.py index 88dde1b..57de00a 100644 --- a/src/ocspdash/web/exceptions.py +++ b/src/ocspdash/web/exceptions.py @@ -12,7 +12,7 @@ def __init__(self, message: str, status_code: HTTPStatus=None, payload: Mapping= """Create an InvalidUsage exception. :param message: The message for the exception; will be placed in the `message` key of the JSON returned in the response. - :param status_code: An HTTP status code for the response; default is 400 BAD REQUEST. + :param status_code: An HTTP status code for the response; default is 400 BAD REQUEST. It will also be placed in the `status` key of the returned JSON. :param payload: A mapping that can be jsonified by Flask; will be returned as JSON in the response alongside the message. """ super().__init__(self) @@ -25,4 +25,5 @@ def to_json(self) -> Mapping: """Return a representation of the exception suitable to passed for JSON conversion.""" rv = dict(self.payload or ()) rv['message'] = self.message + rv['status'] = self.status_code return rv From 2cbb4c9a369e8e2ec4f4179d92ac0508818104cf Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Wed, 8 Aug 2018 01:47:23 -0700 Subject: [PATCH 10/18] Broken client_session fixture first try. --- tests/conftest.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 448192d..2520860 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ from ocspdash.manager import Manager from ocspdash.models import Base +from ocspdash.web import create_application from .constants import TEST_CONNECTION logger = logging.getLogger(__name__) @@ -131,3 +132,15 @@ def manager_function(manager_session): # is rolled back logger.debug('rolling back transaction from function') transaction.rollback() + + +@pytest.fixture(scope='session') +def client_session(): + app = create_application() + app.testing = True + + transaction = app.db.engine.begin() + app.manager.session.begin_nested() + + yield app.test_client() + From efbf40298598c5a2de0312d13985dd55634308a2 Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Wed, 8 Aug 2018 02:54:34 -0700 Subject: [PATCH 11/18] Style fixes. --- src/ocspdash/web/exceptions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ocspdash/web/exceptions.py b/src/ocspdash/web/exceptions.py index 57de00a..841c0dc 100644 --- a/src/ocspdash/web/exceptions.py +++ b/src/ocspdash/web/exceptions.py @@ -1,4 +1,7 @@ +# -*- coding: utf-8 -*- + """Exceptions and Errors for the OCSPdash web package.""" + from http import HTTPStatus from typing import Mapping From e6a19d69b3d4e88fd0ce2520bbc1600910b2cc9d Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Wed, 8 Aug 2018 02:56:51 -0700 Subject: [PATCH 12/18] Add the ability to do tests on the Flask portion of OCSPdash. - Add client_session and client_function fixtures to create Flask test clients and rollback the db between tests - Add get_db staticmethod to OCSPSQLAlchemy class - Add the ability to pass session_options into the flask-sqlalchemy SQLAlchemy constructor from the create_application function so the test fixtures can take control of rollbacks. - Add stub test to start using the test client. --- src/ocspdash/web/app.py | 9 ++-- src/ocspdash/web/extension.py | 10 +++++ tests/conftest.py | 82 +++++++++++++++++++++++++++++++++-- tests/test_api.py | 9 ++++ 4 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 tests/test_api.py diff --git a/src/ocspdash/web/app.py b/src/ocspdash/web/app.py index 3eee694..bfe87ff 100644 --- a/src/ocspdash/web/app.py +++ b/src/ocspdash/web/app.py @@ -4,7 +4,7 @@ import logging import os -from typing import Optional +from typing import Mapping, Optional from flasgger import Swagger from flask import Flask @@ -23,11 +23,12 @@ logger = logging.getLogger('web') -def create_application(connection: Optional[str] = None, flask_debug: bool = False) -> Flask: +def create_application(connection: Optional[str] = None, flask_debug: bool = False, db_session_options: Optional[Mapping] = None) -> Flask: """Create the OCSPdash Flask application. :param connection: Database connection string :param flask_debug: Enable Flask debug mode, overridden by env $DEBUG + :param db_session_options: Mapping of options passed to the flask-sqlalchemy constructor for eventual passage to sqlalchemy.sessionmaker. """ app = Flask(__name__) app.config.update(dict( @@ -38,7 +39,9 @@ def create_application(connection: Optional[str] = None, flask_debug: bool = Fal CENSYS_API_ID=os.environ.get('CENSYS_API_ID'), CENSYS_API_SECRET=os.environ.get('CENSYS_API_SECRET'), )) - db = OCSPSQLAlchemy(app=app) + if db_session_options is None: + db_session_options = {} + db = OCSPSQLAlchemy(app=app, session_options=db_session_options) Bootstrap(app) Swagger(app) # Adds Swagger UI diff --git a/src/ocspdash/web/extension.py b/src/ocspdash/web/extension.py index 8637677..1f5d1d5 100644 --- a/src/ocspdash/web/extension.py +++ b/src/ocspdash/web/extension.py @@ -2,6 +2,8 @@ """An extension of Flask-SQLAlchemy to support the OCSPdash manager.""" +from __future__ import annotations + from flask import Flask from flask_sqlalchemy import SQLAlchemy, get_state @@ -26,6 +28,14 @@ def init_app(self, app: Flask): self.manager = Manager(engine=self.engine, session=self.session) + @staticmethod + def get_db(app: Flask) -> OCSPSQLAlchemy: # noqa: F821 + """Get the db from an app. + + :param app: A Flask app + """ + return get_state(app).db + @staticmethod def get_manager(app: Flask) -> Manager: """Get the manager from an app. diff --git a/tests/conftest.py b/tests/conftest.py index 2520860..cdf32d2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -135,12 +135,86 @@ def manager_function(manager_session): @pytest.fixture(scope='session') -def client_session(): - app = create_application() +def client_session(rfc): + """Create a Flask test client with a temporary SQLite database for a test session. + + All DB operations will be rolled back upon the end of a test session. + See below for a fixture that rolls back after every test function. + + Note that a connection object is yielded as well, which is necessary for the function-scoped version, so you must unpack the actual test client object if using this fixture directly. + + :yields: a 2-tuple of test client and Connection + """ + logger.debug('creating engine for web client') + engine = create_engine(rfc) + + logger.debug('creating connection for web client') + connection = engine.connect() + + app = create_application(connection=rfc, db_session_options={'bind': connection}) app.testing = True - transaction = app.db.engine.begin() + @event.listens_for(app.manager.session, 'after_transaction_end') + def restart_savepoint(session, transaction): + logger.debug('called restart_savepoint for web client') + if transaction.nested and not transaction._parent.nested: + logger.debug('restarting savepoint for web client') + # ensure that state is expired the way + # session.commit() normally does + logger.debug('expiring for web client') + session.expire_all() + + logger.debug('beginning nested in restart_savepoint for web client') + session.begin_nested() + logger.debug('end of restart_savepoint if statement for web client') + logger.debug('end of restart_savepoint for web client') + + logger.debug('beginning transaction in session for web client') + transaction = connection.begin() + + logger.debug('beginning nested in session for web client') app.manager.session.begin_nested() - yield app.test_client() + logger.debug('yielding from session for web client') + yield app.test_client(), connection + + logger.debug('closing session from session for web client') + app.manager.session.close() + logger.debug('rolling back transaction from session for web client') + transaction.rollback() + + logger.debug('closing connection for web client') + connection.close() + + +@pytest.fixture(scope='function') +def client_function(client_session): + """Create a test client fixture with a temporary SQLite database for a test function. + + All DB operations will be rolled back upon the end of the test function. + + :yields: a Flask test client + """ + logger.debug('unpacking client and connection for web client') + client = client_session[0] + connection = client_session[1] + + logger.debug('beginning transaction in function for web client') + transaction = connection.begin() + logger.debug('beginning nested in function for web client') + client.application.manager.session.begin_nested() + + logger.debug('yielding client for web client') + yield client + + logger.debug('closing session from function for web client') + client.application.manager.session.close() + + # rollback - everything that happened with the + # Session above (including all calls to commit()) + # is rolled back + logger.debug('rolling back transaction from function for web client') + transaction.rollback() + +# TODO: fixture to pre-fill DB with some stuff for the client to test on diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..3c18193 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + +"""Test the functionality of the Flask API.""" + + +def test_get_manifest_jsonl(client_function): + """Test that /manifest.jsonl returns a 200.""" + resp = client_function.get('/api/v0/manifest.jsonl') + assert resp.status_code == 200 From a67cb9135fa2d0038a3d61dbf77a0cfc370ca500 Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Wed, 8 Aug 2018 02:58:17 -0700 Subject: [PATCH 13/18] Since this static method is kinda generic, use a more generic return type annotation. --- src/ocspdash/web/extension.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ocspdash/web/extension.py b/src/ocspdash/web/extension.py index 1f5d1d5..d0d01b1 100644 --- a/src/ocspdash/web/extension.py +++ b/src/ocspdash/web/extension.py @@ -29,7 +29,7 @@ def init_app(self, app: Flask): self.manager = Manager(engine=self.engine, session=self.session) @staticmethod - def get_db(app: Flask) -> OCSPSQLAlchemy: # noqa: F821 + def get_db(app: Flask) -> SQLAlchemy: # noqa: F821 """Get the db from an app. :param app: A Flask app From cc4281ff9baaa6a694fd0ef767103e248b20be90 Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Wed, 8 Aug 2018 18:34:01 +0200 Subject: [PATCH 14/18] Update type hints and documentation Scoped sessions act like sessions... why don't they just inherit from them?!? --- src/ocspdash/manager.py | 16 ++++++++++------ tests/conftest.py | 4 ++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/ocspdash/manager.py b/src/ocspdash/manager.py index cd14a6f..a1386cb 100644 --- a/src/ocspdash/manager.py +++ b/src/ocspdash/manager.py @@ -11,11 +11,11 @@ from dataclasses import dataclass from itertools import groupby from operator import attrgetter -from typing import Iterable, List, Mapping, Optional, Tuple +from typing import Iterable, List, Mapping, Optional, Tuple, Union from sqlalchemy import and_, create_engine, func from sqlalchemy.engine import Engine -from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.orm import Session, scoped_session, sessionmaker from ocspdash.constants import OCSPDASH_DEFAULT_CONNECTION, OCSPDASH_USER_AGENT_IDENTIFIER from ocspdash.models import Authority, Base, Chain, Location, Responder, Result @@ -28,6 +28,8 @@ logger = logging.getLogger(__name__) +_SessionHint = Union[Session, scoped_session] + def _workaround_pysqlite_transaction_bug(): """Work around pysqlite transaction bug. @@ -88,12 +90,13 @@ class ResponderPayload: class Manager: """Manager for interacting with the database.""" - def __init__(self, engine: Engine, session: scoped_session, server_query: Optional[ServerQuery] = None) -> None: + def __init__(self, engine: Engine, session: _SessionHint, server_query: Optional[ServerQuery] = None) -> None: """Instantiate a Manager with instances of the objects it needs. :param engine: The database engine. :param session: The database session. - :param server_query: The server_query instance. If None, using server_query-related functionality will raise an error. + :param server_query: The server_query instance. If None, using server_query-related functionality will raise + an error. """ self.engine = engine self.session = session @@ -108,7 +111,8 @@ def from_args(cls, connection: Optional[str] = None, echo: bool = False, api_id: :param connection: An SQLAlchemy-compatible connection string. :param echo: True to echo SQL emitted by SQLAlchemy. :param api_id: The Censys API id. If None, the value will be obtained from configuration or the environment. - :param api_secret: The Censys API secret. If none, the value will be obtained from configuration or the environment. + :param api_secret: The Censys API secret. If none, the value will be obtained from configuration or the + environment. :returns: An instance of Manager configured according to the arguments provided. """ @@ -510,7 +514,7 @@ def get_location_by_selector(self, selector: bytes) -> Optional[Location]: return self.session.query(Location).filter(Location.selector == selector).one_or_none() def process_location(self, invite_token: bytes, public_key: str) -> Optional[Location]: - """Given an invite token and public key, check for a valid invite and associate the public key with the corresponding location. + """Check for a valid invite and associate the public key with the corresponding location. :parameter invite_token: a 32-byte string corresponding to an invited Location. :parameter public_key: The public key to be associated with the Location. diff --git a/tests/conftest.py b/tests/conftest.py index cdf32d2..8c53a44 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ import pytest from sqlalchemy import create_engine, event -from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.orm import Session, scoped_session, sessionmaker from ocspdash.manager import Manager from ocspdash.models import Base @@ -62,7 +62,7 @@ def manager_session(rfc): logger.debug('creating sessionmaker') session_maker = sessionmaker(bind=connection) logger.debug('creating scoped_session') - session = scoped_session(session_maker) + session: Session = scoped_session(session_maker) @event.listens_for(session, 'after_transaction_end') def restart_savepoint(session, transaction): From 5d4cf22de919fbd5a7b82c35562a9ad86ec5524b Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Wed, 8 Aug 2018 18:39:26 +0200 Subject: [PATCH 15/18] Remove unnecessary default The SQLAlchemy class takes a none, so you can pass it none (unless i'm an idiot and none means something special) --- src/ocspdash/web/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ocspdash/web/app.py b/src/ocspdash/web/app.py index bfe87ff..153bff3 100644 --- a/src/ocspdash/web/app.py +++ b/src/ocspdash/web/app.py @@ -28,7 +28,8 @@ def create_application(connection: Optional[str] = None, flask_debug: bool = Fal :param connection: Database connection string :param flask_debug: Enable Flask debug mode, overridden by env $DEBUG - :param db_session_options: Mapping of options passed to the flask-sqlalchemy constructor for eventual passage to sqlalchemy.sessionmaker. + :param db_session_options: Mapping of options passed to the flask-sqlalchemy constructor for eventual passage to + sqlalchemy.sessionmaker. """ app = Flask(__name__) app.config.update(dict( @@ -39,8 +40,7 @@ def create_application(connection: Optional[str] = None, flask_debug: bool = Fal CENSYS_API_ID=os.environ.get('CENSYS_API_ID'), CENSYS_API_SECRET=os.environ.get('CENSYS_API_SECRET'), )) - if db_session_options is None: - db_session_options = {} + db = OCSPSQLAlchemy(app=app, session_options=db_session_options) Bootstrap(app) From e5eea9a4e037f7d9e1d5082d07233637d40f9317 Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Wed, 8 Aug 2018 18:39:36 +0200 Subject: [PATCH 16/18] Combine logic --- src/ocspdash/manager.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/ocspdash/manager.py b/src/ocspdash/manager.py index a1386cb..6817445 100644 --- a/src/ocspdash/manager.py +++ b/src/ocspdash/manager.py @@ -310,10 +310,7 @@ def ensure_chain(self, responder: Responder) -> Optional[Chain]: most_recent_chain = self.get_most_recent_chain_by_responder(responder) if most_recent_chain and not most_recent_chain.old: - if not most_recent_chain.expired: - return most_recent_chain - - if not responder.current: + if not most_recent_chain.expired or not responder.current: return most_recent_chain subject, issuer = self.server_query.get_certs_for_issuer_and_url(responder.authority.name, responder.url) From bb2b14a7e42ec294c615822c0c9362e024e11438 Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Wed, 8 Aug 2018 18:44:43 +0200 Subject: [PATCH 17/18] Add function to make some test data @scolby33 not sure where you want this, but here's the idea with making some test data --- tests/conftest.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8c53a44..1f99b46 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker from ocspdash.manager import Manager -from ocspdash.models import Base +from ocspdash.models import Authority, Base, Chain, Responder from ocspdash.web import create_application from .constants import TEST_CONNECTION @@ -218,3 +218,28 @@ def client_function(client_session): # TODO: fixture to pre-fill DB with some stuff for the client to test on +def prefill_my_database(s: Session): + """Add some test data to the database. + + :param s: A session to the database. Could be wrapped for a manager or transaction or whatever + """ + a1 = Authority(name='a1', cardinality=5) + a2 = Authority(name='a2', cardinality=5) + a3 = Authority(name='a3', cardinality=5) + s.add_all([a1, a2, a3]) + s.commit() + + r1 = Responder(authority=a1, url='url1', cardinality=5) + r2 = Responder(authority=a1, url='url2', cardinality=5) + r3 = Responder(authority=a2, url='url3', cardinality=5) + r4 = Responder(authority=a2, url='url4', cardinality=5) + + s.add_all([r1, r2, r3, r4]) + + c1 = Chain(responder=r1, subject=b'c1s', issuer=b'c1i') + c2 = Chain(responder=r2, subject=b'c2s', issuer=b'c2i') + c3 = Chain(responder=r3, subject=b'c3s', issuer=b'c3i') + c4 = Chain(responder=r4, subject=b'c4s', issuer=b'c4i') + + s.add_all([c1, c2, c3, c4]) + s.commit() From fcfdceefc3d77df94b0929c1e3a2132bcb6d31ad Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Sun, 12 Aug 2018 23:40:49 +0200 Subject: [PATCH 18/18] Address TODOs - Use app configuration for default and maximum manifest size - Handle integrity error when inserting payload - Also add raising of integrity errors to documentation - make secret key more secret --- src/ocspdash/manager.py | 5 ++++- src/ocspdash/web/app.py | 10 +++++++--- src/ocspdash/web/blueprints/api.py | 16 ++++++++++------ tests/conftest.py | 4 +++- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/ocspdash/manager.py b/src/ocspdash/manager.py index 6817445..5717ae2 100644 --- a/src/ocspdash/manager.py +++ b/src/ocspdash/manager.py @@ -579,7 +579,10 @@ def get_most_recent_chains_for_authorities(self, n: Optional[int] = 10) -> List[ return query.all() def insert_payload(self, location: Location, results: Iterable[Mapping]): - """Take the submitted payload and insert its results into the database.""" + """Take the submitted payload and insert its results into the database. + + :raises sqlalchemy.IntegrityError: If a duplicate location is trying to be inserted. Rolls back. + """ for prepared_result_dict in results: result = Result(**prepared_result_dict) result.location = location diff --git a/src/ocspdash/web/app.py b/src/ocspdash/web/app.py index 153bff3..3a4aec4 100644 --- a/src/ocspdash/web/app.py +++ b/src/ocspdash/web/app.py @@ -23,10 +23,11 @@ logger = logging.getLogger('web') -def create_application(connection: Optional[str] = None, flask_debug: bool = False, db_session_options: Optional[Mapping] = None) -> Flask: +def create_application(connection: Optional[str] = None, flask_debug: bool = False, + db_session_options: Optional[Mapping] = None, secret_key=None) -> Flask: """Create the OCSPdash Flask application. - :param connection: Database connection string + :param connection: Database connection string, overridden by env $OCSPDASH_CONNECTION :param flask_debug: Enable Flask debug mode, overridden by env $DEBUG :param db_session_options: Mapping of options passed to the flask-sqlalchemy constructor for eventual passage to sqlalchemy.sessionmaker. @@ -35,10 +36,13 @@ def create_application(connection: Optional[str] = None, flask_debug: bool = Fal app.config.update(dict( SQLALCHEMY_DATABASE_URI=connection or OCSPDASH_CONNECTION, SQLALCHEMY_TRACK_MODIFICATIONS=False, - SECRET_KEY=os.environ.get('SECRET_KEY', 'test key'), + SECRET_KEY=secret_key or os.environ['SECRET_KEY'], # keep it secret; keep it safe DEBUG=os.environ.get('DEBUG', flask_debug), CENSYS_API_ID=os.environ.get('CENSYS_API_ID'), CENSYS_API_SECRET=os.environ.get('CENSYS_API_SECRET'), + OCSPDASH_API_MANIFEST_DEFAULT_SIZE=int(os.environ.get('OCSPDASH_API_MANIFEST_DEFAULT_SIZE', default=10)), + OCSPDASH_API_MANIFEST_MAX_SIZE=int(os.environ.get('OCSPDASH_API_MANIFEST_MAX_SIZE', default=10)), + )) db = OCSPSQLAlchemy(app=app, session_options=db_session_options) diff --git a/src/ocspdash/web/blueprints/api.py b/src/ocspdash/web/blueprints/api.py index 5536db0..1c9fc29 100644 --- a/src/ocspdash/web/blueprints/api.py +++ b/src/ocspdash/web/blueprints/api.py @@ -12,9 +12,10 @@ from http import HTTPStatus import jsonlines -from flask import Blueprint, jsonify, request +from flask import Blueprint, current_app, jsonify, request from jose import jwt from jose.exceptions import JWTError +from sqlalchemy.exc import IntegrityError from ocspdash.constants import OCSP_JWT_ALGORITHM, OCSP_RESULTS_JWT_CLAIM from ocspdash.web.exceptions import InvalidUsage @@ -85,9 +86,9 @@ def get_manifest(): required: false type: integer """ - n = request.args.get('n', type=int, default=10) # TODO make configurable at app level - if n > 10: - raise InvalidUsage(f'n too large, max is 10: {n}') # TODO get the max config value here too + n = request.args.get('n', type=int, default=current_app.config['OCSPDASH_API_MANIFEST_DEFAULT_SIZE']) + if n > current_app.config['OCSPDASH_API_MANIFEST_MAX_SIZE']: + raise InvalidUsage(f'n too large, max is {current_app.config["OCSPDASH_API_MANIFEST_MAX_SIZE"]}: {n}') manifest_lines = io.StringIO() with jsonlines.Writer(manifest_lines, sort_keys=True) as writer: writer.write_all( @@ -157,8 +158,11 @@ def submit(): except (KeyError, ValueError): raise InvalidUsage('invalid result data', payload={'result': result_data}) - # TODO: can this raise an exception? I think yes if there's a constraint broken on the DB when commit is called() - manager.insert_payload(submitting_location, prepared_result_dicts) + try: + manager.insert_payload(submitting_location, prepared_result_dicts) + except IntegrityError: + manager.session.rollback() + raise return '', HTTPStatus.NO_CONTENT diff --git a/tests/conftest.py b/tests/conftest.py index 1f99b46..a7180b5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ """Test configuration module for OCSPdash.""" import logging +import os import pytest from sqlalchemy import create_engine, event @@ -151,7 +152,8 @@ def client_session(rfc): logger.debug('creating connection for web client') connection = engine.connect() - app = create_application(connection=rfc, db_session_options={'bind': connection}) + secret_key = os.urandom(8) + app = create_application(connection=rfc, db_session_options={'bind': connection}, secret_key=secret_key) app.testing = True @event.listens_for(app.manager.session, 'after_transaction_end')