diff --git a/.coveragerc b/.coveragerc index 571f8bb9..bbd92e24 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,4 @@ [run] -omit=yoti/tests/* \ No newline at end of file +omit = + yoti_python_sdk/tests/** + examples/** \ No newline at end of file diff --git a/.gitignore b/.gitignore index 538ee107..8a197872 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ htmlcov/ .cache nosetests.xml coverage.* +coverage-reports *,cover .hypothesis/ @@ -103,4 +104,4 @@ examples/yoti_example_flask/static/YotiSelfie.jpg examples/yoti_example_django/*.pem examples/yoti_example_flask/*.pem -.scannerwork \ No newline at end of file +.scannerwork diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..407cd4b1 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,2 @@ +[MASTER] +ignore=yoti_python_sdk/tests/**,examples/** \ No newline at end of file diff --git a/README.md b/README.md index 797b464a..5ebe3139 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,11 @@ Running the tests is done by the following process, ensuring you are using Pytho * [X] Address `postal_address` * [X] Gender `gender` * [X] Nationality `nationality` + * [X] Application Profile `application_profile` + * [X] Name `application_name` + * [X] URL `application_url` + * [X] Logo `application_logo` + * [X] Receipt Background Color `application_receipt_bg_color` * [X] Base64 Selfie URI `base64_selfie_uri` ## Support diff --git a/examples/yoti_example_django/yoti_example/templates/attribute_snippet.html b/examples/yoti_example_django/yoti_example/templates/attribute_snippet.html index 0b5c894a..02683ece 100644 --- a/examples/yoti_example_django/yoti_example/templates/attribute_snippet.html +++ b/examples/yoti_example_django/yoti_example/templates/attribute_snippet.html @@ -6,7 +6,7 @@ {{ name }} - +
{% if prop.name == "document_images" %} @@ -34,6 +34,21 @@ {% endfor %} + {% elif prop.name == "age_verified" %} + + + + + + + + + + + + + +
Check Type{{ prop.value.check_type }}
Age{{ prop.value.age }}
Result{{ prop.value.result }}
{% else %} {{ prevalue }} {{ prop.value }} diff --git a/examples/yoti_example_django/yoti_example/urls.py b/examples/yoti_example_django/yoti_example/urls.py index 4aa44902..f8063333 100644 --- a/examples/yoti_example_django/yoti_example/urls.py +++ b/examples/yoti_example_django/yoti_example/urls.py @@ -16,11 +16,16 @@ from django.conf.urls import url from django.contrib import admin -from .views import IndexView, AuthView, DynamicShareView +from .views import IndexView, AuthView, DynamicShareView, SourceConstraintsView urlpatterns = [ url(r"^$", IndexView.as_view(), name="index"), url(r"^yoti/auth/$", AuthView.as_view(), name="auth"), url(r"^admin/", admin.site.urls), url(r"^dynamic-share/$", DynamicShareView.as_view(), name="dynamic-share"), + url( + r"^source-constraint/$", + SourceConstraintsView.as_view(), + name="source-constraints", + ), ] diff --git a/examples/yoti_example_django/yoti_example/views.py b/examples/yoti_example_django/yoti_example/views.py index 7b908f77..3c04a4c4 100644 --- a/examples/yoti_example_django/yoti_example/views.py +++ b/examples/yoti_example_django/yoti_example/views.py @@ -7,7 +7,10 @@ DynamicScenarioBuilder, create_share_url, ) -from yoti_python_sdk.dynamic_sharing_service.policy import DynamicPolicyBuilder +from yoti_python_sdk.dynamic_sharing_service.policy import ( + DynamicPolicyBuilder, + SourceConstraintBuilder, +) load_dotenv(find_dotenv()) @@ -53,6 +56,34 @@ def get(self, request, *args, **kwargs): return self.render_to_response(context) +class SourceConstraintsView(TemplateView): + template_name = "dynamic-share.html" + + def get(self, request, *args, **kwargs): + client = Client(YOTI_CLIENT_SDK_ID, YOTI_KEY_FILE_PATH) + constraint = ( + SourceConstraintBuilder().with_driving_licence().with_passport().build() + ) + policy = ( + DynamicPolicyBuilder() + .with_full_name(constraints=constraint) + .with_structured_postal_address(constraints=constraint) + .build() + ) + scenario = ( + DynamicScenarioBuilder() + .with_policy(policy) + .with_callback_endpoint("/yoti/auth") + .build() + ) + share = create_share_url(client, scenario) + context = { + "yoti_client_sdk_id": YOTI_CLIENT_SDK_ID, + "yoti_share_url": share.share_url, + } + return self.render_to_response(context) + + class AuthView(TemplateView): template_name = "profile.html" @@ -71,10 +102,18 @@ def get(self, request, *args, **kwargs): context["receipt_id"] = getattr(activity_details, "receipt_id") context["timestamp"] = getattr(activity_details, "timestamp") - # change this string according to the age condition defined in Yoti Hub - age_verified = profile.get_attribute("age_over:18") + # change this number according to the age condition defined in Yoti Hub + age_verified = profile.find_age_over_verification(18) + + # Age verification objects don't have the same properties as an attribute, + # so for this example we had to mock an object with the same properties if age_verified is not None: - context["age_verified"] = age_verified + context["age_verified"] = { + "name": "age_verified", + "value": age_verified, + "sources": age_verified.attribute.sources, + "verifiers": age_verified.attribute.verifiers, + } selfie = context.get("selfie") if selfie is not None: diff --git a/examples/yoti_example_flask/app.py b/examples/yoti_example_flask/app.py index e6ade6c3..3a53249d 100644 --- a/examples/yoti_example_flask/app.py +++ b/examples/yoti_example_flask/app.py @@ -6,7 +6,10 @@ from flask import Flask, render_template, request from yoti_python_sdk import Client -from yoti_python_sdk.dynamic_sharing_service.policy import DynamicPolicyBuilder +from yoti_python_sdk.dynamic_sharing_service.policy import ( + DynamicPolicyBuilder, + SourceConstraintBuilder, +) from yoti_python_sdk.dynamic_sharing_service import DynamicScenarioBuilder from yoti_python_sdk.dynamic_sharing_service import create_share_url @@ -52,6 +55,32 @@ def dynamic_share(): ) +@app.route("/source-constraints") +def source_constraints(): + client = Client(YOTI_CLIENT_SDK_ID, YOTI_KEY_FILE_PATH) + constraint = ( + SourceConstraintBuilder().with_driving_licence().with_passport().build() + ) + policy = ( + DynamicPolicyBuilder() + .with_full_name(constraints=constraint) + .with_structured_postal_address(constraints=constraint) + .build() + ) + scenario = ( + DynamicScenarioBuilder() + .with_policy(policy) + .with_callback_endpoint("/yoti/auth") + .build() + ) + share = create_share_url(client, scenario) + return render_template( + "dynamic-share.html", + yoti_client_sdk_id=YOTI_CLIENT_SDK_ID, + yoti_share_url=share.share_url, + ) + + @app.route("/yoti/auth") def auth(): client = Client(YOTI_CLIENT_SDK_ID, YOTI_KEY_FILE_PATH) @@ -68,10 +97,18 @@ def auth(): context["receipt_id"] = getattr(activity_details, "receipt_id") context["timestamp"] = getattr(activity_details, "timestamp") - # change this string according to the age condition defined in Yoti Hub - age_verified = profile.get_attribute("age_over:18") + # change this number according to the age condition defined in Yoti Hub + age_verified = profile.find_age_over_verification(18) + + # Age verification objects don't have the same properties as an attribute, + # so for this example we had to mock an object with the same properties if age_verified is not None: - context["age_verified"] = age_verified + context["age_verified"] = { + "name": "age_verified", + "value": age_verified, + "sources": age_verified.attribute.sources, + "verifiers": age_verified.attribute.verifiers, + } selfie = context.get("selfie") if selfie is not None: diff --git a/examples/yoti_example_flask/templates/profile.html b/examples/yoti_example_flask/templates/profile.html index fe0136b2..5819341c 100644 --- a/examples/yoti_example_flask/templates/profile.html +++ b/examples/yoti_example_flask/templates/profile.html @@ -29,6 +29,23 @@ {% endmacro %} +{% macro parse_age_verification(prop) %} + + + + + + + + + + + + + +
Check Type{{ prop.value.check_type }}
Age{{ prop.value.age }}
Result{{ prop.value.result }}
+{% endmacro %} + {% macro attribute(name, icon, prop, prevalue="") %} {% if prop %} {% if prop.value %} @@ -48,6 +65,8 @@ {{ parse_structured_address(prop) }} {% elif prop.name == "document_details" %} {{ parse_document_details(prop) }} + {% elif prop.name == "age_verified" %} + {{ parse_age_verification(prop) }} {% else %} {{ prevalue }} {{ prop.value }} diff --git a/pytest.ini b/pytest.ini index 7cf4cee5..8ca9e032 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,3 @@ [pytest] -testpaths = yoti_python_sdk/tests -norecursedirs = venv +testpaths = yoti_python_sdk/tests/ +norecursedirs = venv \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..20048c32 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,7 @@ +sonar.projectKey = yoti-web-sdk:python +sonar.projectName = python-sdk +sonar.projectVersion = 2.9.0 +sonar.exclusions=yoti_python_sdk/tests/**,examples/** + +sonar.python.pylint.reportPath=coverage.out +sonar.verbose = true diff --git a/yoti_python_sdk/activity_details.py b/yoti_python_sdk/activity_details.py index 8359dab8..b027bf70 100644 --- a/yoti_python_sdk/activity_details.py +++ b/yoti_python_sdk/activity_details.py @@ -10,7 +10,9 @@ class ActivityDetails: - def __init__(self, receipt, decrypted_profile=None, decrypted_application_profile=None): + def __init__( + self, receipt, decrypted_profile=None, decrypted_application_profile=None + ): self.decrypted_profile = decrypted_profile self.user_profile = {} # will be removed in v3.0.0 self.base64_selfie_uri = None @@ -57,9 +59,15 @@ def __init__(self, receipt, decrypted_profile=None, decrypted_application_profil self.ensure_postal_address() - if decrypted_application_profile and hasattr(decrypted_application_profile, "attributes"): - decrypted_application_profile_attributes = decrypted_application_profile.attributes - self.application_profile = ApplicationProfile(decrypted_application_profile_attributes) + if decrypted_application_profile and hasattr( + decrypted_application_profile, "attributes" + ): + decrypted_application_profile_attributes = ( + decrypted_application_profile.attributes + ) + self.application_profile = ApplicationProfile( + decrypted_application_profile_attributes + ) self.__remember_me_id = receipt.get("remember_me_id") self.parent_remember_me_id = receipt.get("parent_remember_me_id") @@ -84,6 +92,7 @@ def user_id(self): def user_id(self, value): self.__remember_me_id = value + @deprecated def try_parse_age_verified_field(self, field): if field is not None: age_verified = attribute_parser.value_based_on_content_type( diff --git a/yoti_python_sdk/age_verification.py b/yoti_python_sdk/age_verification.py new file mode 100644 index 00000000..e181eb34 --- /dev/null +++ b/yoti_python_sdk/age_verification.py @@ -0,0 +1,44 @@ +from yoti_python_sdk.exceptions import MalformedAgeVerificationException +from yoti_python_sdk import config + + +class AgeVerification(object): + def __init__(self, derived_attribute): + self.__derived_attribute = derived_attribute + + split = derived_attribute.name.split(":") + if len(split) != 2: + raise MalformedAgeVerificationException + + if ( + split[0] in config.ATTRIBUTE_AGE_OVER + or split[0] in config.ATTRIBUTE_AGE_UNDER + ): + self.__check_type = split[0] + else: + raise MalformedAgeVerificationException + + try: + self.__age_verified = int(split[1]) + if derived_attribute.value == "true": + self.__result = True + elif derived_attribute.value == "false": + self.__result = False + except Exception: + raise MalformedAgeVerificationException + + @property + def age(self): + return self.__age_verified + + @property + def check_type(self): + return self.__check_type + + @property + def result(self): + return self.__result + + @property + def attribute(self): + return self.__derived_attribute diff --git a/yoti_python_sdk/anchor.py b/yoti_python_sdk/anchor.py index fd1f18ea..924330ae 100644 --- a/yoti_python_sdk/anchor.py +++ b/yoti_python_sdk/anchor.py @@ -19,12 +19,21 @@ class Anchor: def __init__( self, - anchor_type=UNKNOWN_ANCHOR_TYPE, - sub_type="", - value="", + anchor_type=None, + sub_type=None, + value=None, signed_timestamp=None, origin_server_certs=None, ): + if sub_type is None: + sub_type = "" + + if value is None: + value = "" + + if anchor_type is None: + anchor_type = UNKNOWN_ANCHOR_TYPE + self.__anchor_type = anchor_type self.__sub_type = sub_type self.__value = value diff --git a/yoti_python_sdk/attribute.py b/yoti_python_sdk/attribute.py index c933154a..5a5a5bf7 100644 --- a/yoti_python_sdk/attribute.py +++ b/yoti_python_sdk/attribute.py @@ -2,7 +2,11 @@ class Attribute: - def __init__(self, name="", value="", anchors=None): + def __init__(self, name=None, value=None, anchors=None): + if name is None: + name = "" + if value is None: + value = "" if anchors is None: anchors = {} self.__name = name diff --git a/yoti_python_sdk/client.py b/yoti_python_sdk/client.py index 3d82bed5..61eb0bf4 100644 --- a/yoti_python_sdk/client.py +++ b/yoti_python_sdk/client.py @@ -3,26 +3,14 @@ import json from os import environ -from os.path import isfile, expanduser - -import requests -from cryptography.fernet import base64 -from past.builtins import basestring import yoti_python_sdk from yoti_python_sdk import aml from yoti_python_sdk.activity_details import ActivityDetails from yoti_python_sdk.crypto import Crypto from yoti_python_sdk.endpoint import Endpoint +from yoti_python_sdk.http import SignedRequest, YotiResponse from yoti_python_sdk.protobuf import protobuf -from .config import ( - X_YOTI_AUTH_KEY, - X_YOTI_AUTH_DIGEST, - X_YOTI_SDK, - SDK_IDENTIFIER, - X_YOTI_SDK_VERSION, - JSON_CONTENT_TYPE, -) NO_KEY_FILE_SPECIFIED_ERROR = ( "Please specify the correct private key file " @@ -36,47 +24,46 @@ class Client(object): - def __init__(self, sdk_id=None, pem_file_path=None): + def __init__(self, sdk_id=None, pem_file_path=None, request_handler=None): self.sdk_id = sdk_id or environ.get("YOTI_CLIENT_SDK_ID") - pem_file_path_env = environ.get("YOTI_KEY_FILE_PATH") + pem_file_path_env = environ.get("YOTI_KEY_FILE_PATH", pem_file_path) if pem_file_path is not None: error_source = "argument specified in Client()" - pem = self.__read_pem_file(pem_file_path, error_source) + self.__crypto = Crypto.read_pem_file(pem_file_path, error_source) elif pem_file_path_env is not None: error_source = "specified by the YOTI_KEY_FILE_PATH env variable" - pem = self.__read_pem_file(pem_file_path_env, error_source) + self.__crypto = Crypto.read_pem_file(pem_file_path_env, error_source) else: raise RuntimeError(NO_KEY_FILE_SPECIFIED_ERROR) - self.__crypto = Crypto(pem) self.__endpoint = Endpoint(sdk_id) + self.__request_handler = request_handler - @staticmethod - def __read_pem_file(key_file_path, error_source): - try: - key_file_path = expanduser(key_file_path) - - if not isinstance(key_file_path, basestring) or not isfile(key_file_path): - raise IOError("File not found: {0}".format(key_file_path)) - with open(key_file_path, "rb") as pem_file: - return pem_file.read().strip() - except (AttributeError, IOError, TypeError, OSError) as exc: - error = 'Could not read private key file: "{0}", passed as: {1} '.format( - key_file_path, error_source - ) - exception = "{0}: {1}".format(type(exc).__name__, exc) - raise RuntimeError("{0}: {1}".format(error, exception)) + def make_request(self, http_method, endpoint, body): + signed_request = ( + SignedRequest.builder() + .with_pem_file(self.__crypto) + .with_base_url(yoti_python_sdk.YOTI_API_ENDPOINT) + .with_endpoint(endpoint) + .with_http_method(http_method) + .with_payload(body) + .with_request_handler(self.__request_handler) + .build() + ) + + response = signed_request.execute() + + if not isinstance(response, YotiResponse): + raise TypeError("Response must be of type YotiResponse") + + return response def get_activity_details(self, encrypted_request_token): - proto = protobuf.Protobuf() - http_method = "GET" - content = None - response = self.__make_activity_details_request( - encrypted_request_token, http_method, content - ) + response = self.__make_activity_details_request(encrypted_request_token) receipt = json.loads(response.text).get("receipt") + proto = protobuf.Protobuf() encrypted_data = proto.current_user(receipt) encrypted_application_profile = proto.current_application(receipt) @@ -89,32 +76,33 @@ def get_activity_details(self, encrypted_request_token): unwrapped_key, encrypted_data.iv, encrypted_data.cipher_text ) decrypted_application_data = self.__crypto.decipher( - unwrapped_key, encrypted_application_profile.iv, encrypted_application_profile.cipher_text + unwrapped_key, + encrypted_application_profile.iv, + encrypted_application_profile.cipher_text, ) user_profile_attribute_list = proto.attribute_list(decrypted_profile_data) - application_profile_attribute_list = proto.attribute_list(decrypted_application_data) + application_profile_attribute_list = proto.attribute_list( + decrypted_application_data + ) return ActivityDetails( - receipt=receipt, decrypted_profile=user_profile_attribute_list, decrypted_application_profile=application_profile_attribute_list + receipt=receipt, + decrypted_profile=user_profile_attribute_list, + decrypted_application_profile=application_profile_attribute_list, ) def perform_aml_check(self, aml_profile): if aml_profile is None: raise TypeError("aml_profile not set") - http_method = "POST" + response = self.__make_aml_check_request(aml_profile) - response = self.__make_aml_check_request(http_method, aml_profile) + if not isinstance(response, YotiResponse): + raise TypeError("Response must be of type YotiResponse") return aml.AmlResult(response.text) - def make_request(self, http_method, endpoint, body): - url = yoti_python_sdk.YOTI_API_ENDPOINT + endpoint - headers = self.__get_request_headers(endpoint, http_method, body) - response = requests.request(http_method, url, headers=headers, data=body, verify=yoti_python_sdk.YOTI_API_VERIFY_SSL) - return response - @property def endpoints(self): return self.__endpoint @@ -135,65 +123,56 @@ def http_error_handler(response, error_messages={}): else: raise RuntimeError(UNKNOWN_HTTP_ERROR.format(status_code, response.text)) - def __make_activity_details_request( - self, encrypted_request_token, http_method, content - ): + def __make_activity_details_request(self, encrypted_request_token): decrypted_token = self.__crypto.decrypt_token(encrypted_request_token).decode( "utf-8" ) - path = self.__endpoint.get_activity_details_request_path(decrypted_token) - url = yoti_python_sdk.YOTI_API_ENDPOINT + path - headers = self.__get_request_headers(path, http_method, content) - response = requests.get(url=url, headers=headers, verify=yoti_python_sdk.YOTI_API_VERIFY_SSL) + path = self.__endpoint.get_activity_details_request_path( + decrypted_token, no_params=True + ) + + signed_request = ( + SignedRequest.builder() + .with_get() + .with_pem_file(self.__crypto) + .with_base_url(yoti_python_sdk.YOTI_API_ENDPOINT) + .with_endpoint(path) + .with_param("appId", self.sdk_id) + .with_request_handler(self.__request_handler) + .build() + ) + + response = signed_request.execute() self.http_error_handler( - response, {"default": "Unsuccessful Yoti API call: {1}"} + response, {"default": "Unsuccessful Yoti API call: {} {}"} ) return response - def __make_aml_check_request(self, http_method, aml_profile): + def __make_aml_check_request(self, aml_profile): aml_profile_json = json.dumps(aml_profile.__dict__, sort_keys=True) aml_profile_bytes = aml_profile_json.encode() - path = self.__endpoint.get_aml_request_url() - url = yoti_python_sdk.YOTI_API_ENDPOINT + path - headers = self.__get_request_headers(path, http_method, aml_profile_bytes) + path = self.__endpoint.get_aml_request_url(no_params=True) + + signed_request = ( + SignedRequest.builder() + .with_pem_file(self.__crypto) + .with_base_url(yoti_python_sdk.YOTI_API_ENDPOINT) + .with_endpoint(path) + .with_payload(aml_profile_bytes) + .with_param("appId", self.sdk_id) + .with_post() + .with_request_handler(self.__request_handler) + .build() + ) - response = requests.post(url=url, headers=headers, data=aml_profile_bytes, verify=yoti_python_sdk.YOTI_API_VERIFY_SSL) + response = signed_request.execute() + if not isinstance(response, YotiResponse): + raise TypeError("Response must be of type YotiResponse") self.http_error_handler( - response, {"default": "Unsuccessful Yoti API call: {1}"} + response, {"default": "Unsuccessful Yoti API call: {} {}"} ) return response - - def __get_request_headers(self, path, http_method, content): - request = self.__create_request(http_method, path, content) - sdk_version = yoti_python_sdk.__version__ - - return { - X_YOTI_AUTH_KEY: self.__crypto.get_public_key(), - X_YOTI_AUTH_DIGEST: self.__crypto.sign(request), - X_YOTI_SDK: SDK_IDENTIFIER, - X_YOTI_SDK_VERSION: "{0}-{1}".format(SDK_IDENTIFIER, sdk_version), - "Content-Type": JSON_CONTENT_TYPE, - "Accept": JSON_CONTENT_TYPE, - } - - @staticmethod - def __create_request(http_method, path, content): - if http_method not in HTTP_SUPPORTED_METHODS: - raise ValueError( - "{} is not in the list of supported methods: {}".format( - http_method, HTTP_SUPPORTED_METHODS - ) - ) - - request = "{}&{}".format(http_method, path) - - if content is not None: - b64encoded = base64.b64encode(content) - b64ascii = b64encoded.decode("ascii") - request += "&" + b64ascii - - return request diff --git a/yoti_python_sdk/config.py b/yoti_python_sdk/config.py index 06e430ad..6500f78c 100644 --- a/yoti_python_sdk/config.py +++ b/yoti_python_sdk/config.py @@ -30,3 +30,8 @@ X_YOTI_SDK = "X-Yoti-SDK" X_YOTI_SDK_VERSION = X_YOTI_SDK + "-Version" JSON_CONTENT_TYPE = "application/json" + +ANCHOR_VALUE_PASSPORT = "PASSPORT" +ANCHOR_VALUE_NATIONAL_ID = "NATIONAL_ID" +ANCHOR_VALUE_PASS_CARD = "PASS_CARD" +ANCHOR_VALUE_DRIVING_LICENCE = "DRIVING_LICENCE" diff --git a/yoti_python_sdk/crypto.py b/yoti_python_sdk/crypto.py index abaebfe9..02958072 100644 --- a/yoti_python_sdk/crypto.py +++ b/yoti_python_sdk/crypto.py @@ -5,6 +5,9 @@ from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from os.path import isfile, expanduser +from past.builtins import basestring + class Crypto: def __init__(self, pem_container): @@ -65,3 +68,20 @@ def strip_pkcs5_padding(data): if isinstance(stripped, bytearray): stripped = str(stripped) return stripped + + @staticmethod + def read_pem_file(key_file_path, error_source=None): + try: + key_file_path = expanduser(key_file_path) + + if not isinstance(key_file_path, basestring) or not isfile(key_file_path): + raise IOError("File not found: {0}".format(key_file_path)) + + with open(key_file_path, "rb") as pem_file: + return Crypto(pem_file.read().strip()) + except (AttributeError, IOError, TypeError, OSError) as exc: + error = "Could not read private key file: '{0}', passed as: {1}".format( + key_file_path, error_source + ) + exception = "{0}: {1}".format(type(exc).__name__, exc) + raise RuntimeError("{0}: {1}".format(error, exception)) diff --git a/yoti_python_sdk/dynamic_sharing_service/dynamic_scenario_builder.py b/yoti_python_sdk/dynamic_sharing_service/dynamic_scenario_builder.py index 49400752..854387da 100644 --- a/yoti_python_sdk/dynamic_sharing_service/dynamic_scenario_builder.py +++ b/yoti_python_sdk/dynamic_sharing_service/dynamic_scenario_builder.py @@ -12,33 +12,31 @@ def __init__(self): "callback_endpoint": "", } - """ - @param policy A DynamicPolicy defining the attributes to be shared - """ - def with_policy(self, policy): + """ + :param policy: A DynamicPolicy defining the attributes to be requested + """ self.__scenario["policy"] = policy return self - """ - @param extension An extension to be activated for the scenario - """ - def with_extension(self, extension): + """ + :param extension: An extension to be activated for the scenario + """ self.__scenario["extensions"].append(extension) return self - """ - @param callback_endpoint A string with the callback endpoint - """ - def with_callback_endpoint(self, callback_endpoint): + """ + :param callback_endpoint: A string with the callback endpoint + """ self.__scenario["callback_endpoint"] = callback_endpoint return self - """ - @return Dictionary representation of dynamic scenario - """ - def build(self): - return self.__scenario.copy() + """ + :returns: Dictionary representation of dynamic scenario + """ + scenario = self.__scenario.copy() + scenario["extensions"] = scenario["extensions"][:] + return scenario diff --git a/yoti_python_sdk/dynamic_sharing_service/policy/__init__.py b/yoti_python_sdk/dynamic_sharing_service/policy/__init__.py index 17ebaaf9..2cbd92ad 100644 --- a/yoti_python_sdk/dynamic_sharing_service/policy/__init__.py +++ b/yoti_python_sdk/dynamic_sharing_service/policy/__init__.py @@ -1,6 +1,13 @@ # -*- coding: utf-8 -*- -from .dynamic_policy_builder import DynamicPolicyBuilder from .wanted_attribute_builder import WantedAttributeBuilder +from .wanted_anchor_builder import WantedAnchorBuilder +from .source_constraint_builder import SourceConstraintBuilder +from .dynamic_policy_builder import DynamicPolicyBuilder -__all__ = ["DynamicPolicyBuilder", "WantedAttributeBuilder"] +__all__ = [ + "DynamicPolicyBuilder", + "WantedAttributeBuilder", + "WantedAnchorBuilder", + "SourceConstraintBuilder", +] diff --git a/yoti_python_sdk/dynamic_sharing_service/policy/dynamic_policy_builder.py b/yoti_python_sdk/dynamic_sharing_service/policy/dynamic_policy_builder.py index 3c5a2974..bf03ad8a 100644 --- a/yoti_python_sdk/dynamic_sharing_service/policy/dynamic_policy_builder.py +++ b/yoti_python_sdk/dynamic_sharing_service/policy/dynamic_policy_builder.py @@ -7,12 +7,12 @@ from .wanted_attribute_builder import WantedAttributeBuilder -""" -Builder for DynamicPolicy -""" - class DynamicPolicyBuilder(object): + """ + Builder for DynamicPolicy + """ + SELFIE_AUTH_TYPE = 1 PIN_AUTH_TYPE = 2 @@ -21,11 +21,10 @@ def __init__(self): self.__wanted_auth_types = {} self.__is_wanted_remember_me = False - """ - @param wanted_attribute - """ - def with_wanted_attribute(self, wanted_attribute): + """ + @param wanted_attribute + """ key = ( wanted_attribute["derivation"] if wanted_attribute.get("derivation", False) @@ -34,68 +33,94 @@ def with_wanted_attribute(self, wanted_attribute): self.__wanted_attributes[key] = wanted_attribute return self - """ - @param wanted_name The name of the attribute to include - """ + def __attribute_keyword_parser(self, attributeBuilder, **kwargs): + constraints = kwargs.get("constraints", False) + if constraints: + attributeBuilder.with_constraint(constraints) - def with_wanted_attribute_by_name(self, wanted_name): - attribute = WantedAttributeBuilder().with_name(wanted_name).build() - return self.with_wanted_attribute(attribute) + def with_wanted_attribute_by_name(self, wanted_name, **kwargs): + """ + @param wanted_name The name of the attribute to include + """ + attributeBuilder = WantedAttributeBuilder().with_name(wanted_name) + self.__attribute_keyword_parser(attributeBuilder, **kwargs) - def with_family_name(self): - return self.with_wanted_attribute_by_name(config.ATTRIBUTE_FAMILY_NAME) + return self.with_wanted_attribute(attributeBuilder.build()) - def with_given_names(self): - return self.with_wanted_attribute_by_name(config.ATTRIBUTE_GIVEN_NAMES) + def with_family_name(self, **kwargs): + return self.with_wanted_attribute_by_name( + config.ATTRIBUTE_FAMILY_NAME, **kwargs + ) + + def with_given_names(self, **kwargs): + return self.with_wanted_attribute_by_name( + config.ATTRIBUTE_GIVEN_NAMES, **kwargs + ) - def with_full_name(self): - return self.with_wanted_attribute_by_name(config.ATTRIBUTE_FULL_NAME) + def with_full_name(self, **kwargs): + return self.with_wanted_attribute_by_name(config.ATTRIBUTE_FULL_NAME, **kwargs) - def with_date_of_birth(self): - return self.with_wanted_attribute_by_name(config.ATTRIBUTE_DATE_OF_BIRTH) + def with_date_of_birth(self, **kwargs): + return self.with_wanted_attribute_by_name( + config.ATTRIBUTE_DATE_OF_BIRTH, **kwargs + ) - def with_age_derived_attribute(self, derivation): - attribute = ( + def with_age_derived_attribute(self, derivation, **kwargs): + attributeBuilder = ( WantedAttributeBuilder() .with_name(config.ATTRIBUTE_DATE_OF_BIRTH) .with_derivation(derivation) - .build() ) - return self.with_wanted_attribute(attribute) + self.__attribute_keyword_parser(attributeBuilder, **kwargs) + return self.with_wanted_attribute(attributeBuilder.build()) - def with_age_over(self, age): + def with_age_over(self, age, **kwargs): assert self.__is_number(age) - return self.with_age_derived_attribute(config.ATTRIBUTE_AGE_OVER + str(age)) + return self.with_age_derived_attribute( + config.ATTRIBUTE_AGE_OVER + str(age), **kwargs + ) - def with_age_under(self, age): + def with_age_under(self, age, **kwargs): assert self.__is_number(age) - return self.with_age_derived_attribute(config.ATTRIBUTE_AGE_UNDER + str(age)) + return self.with_age_derived_attribute( + config.ATTRIBUTE_AGE_UNDER + str(age), **kwargs + ) - def with_gender(self): - return self.with_wanted_attribute_by_name(config.ATTRIBUTE_GENDER) + def with_gender(self, **kwargs): + return self.with_wanted_attribute_by_name(config.ATTRIBUTE_GENDER, **kwargs) - def with_postal_address(self): - return self.with_wanted_attribute_by_name(config.ATTRIBUTE_POSTAL_ADDRESS) + def with_postal_address(self, **kwargs): + return self.with_wanted_attribute_by_name( + config.ATTRIBUTE_POSTAL_ADDRESS, **kwargs + ) - def with_structured_postal_address(self): + def with_structured_postal_address(self, **kwargs): return self.with_wanted_attribute_by_name( - config.ATTRIBUTE_STRUCTURED_POSTAL_ADDRESS + config.ATTRIBUTE_STRUCTURED_POSTAL_ADDRESS, **kwargs ) - def with_nationality(self): - return self.with_wanted_attribute_by_name(config.ATTRIBUTE_NATIONALITY) + def with_nationality(self, **kwargs): + return self.with_wanted_attribute_by_name( + config.ATTRIBUTE_NATIONALITY, **kwargs + ) - def with_phone_number(self): - return self.with_wanted_attribute_by_name(config.ATTRIBUTE_PHONE_NUMBER) + def with_phone_number(self, **kwargs): + return self.with_wanted_attribute_by_name( + config.ATTRIBUTE_PHONE_NUMBER, **kwargs + ) - def with_selfie(self): - return self.with_wanted_attribute_by_name(config.ATTRIBUTE_SELFIE) + def with_selfie(self, **kwargs): + return self.with_wanted_attribute_by_name(config.ATTRIBUTE_SELFIE, **kwargs) - def with_email(self): - return self.with_wanted_attribute_by_name(config.ATTRIBUTE_EMAIL_ADDRESS) + def with_email(self, **kwargs): + return self.with_wanted_attribute_by_name( + config.ATTRIBUTE_EMAIL_ADDRESS, **kwargs + ) - def with_document_details(self): - return self.with_wanted_attribute_by_name(config.ATTRIBUTE_DOCUMENT_DETAILS) + def with_document_details(self, **kwargs): + return self.with_wanted_attribute_by_name( + config.ATTRIBUTE_DOCUMENT_DETAILS, **kwargs + ) """ @param wanted_auth_type diff --git a/yoti_python_sdk/dynamic_sharing_service/policy/source_constraint_builder.py b/yoti_python_sdk/dynamic_sharing_service/policy/source_constraint_builder.py new file mode 100644 index 00000000..273afbfc --- /dev/null +++ b/yoti_python_sdk/dynamic_sharing_service/policy/source_constraint_builder.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from yoti_python_sdk.config import ( + ANCHOR_VALUE_PASSPORT, + ANCHOR_VALUE_PASS_CARD, + ANCHOR_VALUE_NATIONAL_ID, + ANCHOR_VALUE_DRIVING_LICENCE, +) + +from .wanted_anchor_builder import WantedAnchorBuilder + + +class SourceConstraintBuilder(object): + def __init__(self): + self.__soft_preference = False + self.__anchors = [] + + def with_soft_preference(self, value=True): + """ + :param value: If true, this will be treated as a soft preference, otherwise + this will be treated as a hard requirement + """ + self.__soft_preference = value + return self + + def with_anchor(self, anchor): + """ + :param anchor: Add an anchor to the source constraint. + It is recommended to use the other helper methods instead of this one + """ + self.__anchors.append(anchor) + return self + + def with_anchor_by_name(self, value, subtype=""): + """ + :param value: The type of anchor wanted, represented by a string + :param subtype: The subtype information for the anchor as a string + """ + anchor = WantedAnchorBuilder().with_value(value).with_subtype(subtype).build() + return self.with_anchor(anchor) + + def with_passport(self, subtype=""): + """ + :param subtype: Subtype information as a string + """ + return self.with_anchor_by_name(ANCHOR_VALUE_PASSPORT, subtype) + + def with_national_id(self, subtype=""): + """ + :param subtype: Subtype information as a string + """ + return self.with_anchor_by_name(ANCHOR_VALUE_NATIONAL_ID, subtype) + + def with_passcard(self, subtype=""): + """ + :param subtype: Subtype information as a string + """ + return self.with_anchor_by_name(ANCHOR_VALUE_PASS_CARD, subtype) + + def with_driving_licence(self, subtype=""): + """ + :param subtype: Subtype information as a string + """ + return self.with_anchor_by_name(ANCHOR_VALUE_DRIVING_LICENCE, subtype) + + def build(self): + """ + :returns: A dict describing the source constraint + """ + return { + "type": "SOURCE", + "preferred_sources": { + "soft_preference": self.__soft_preference, + "anchors": self.__anchors, + }, + } diff --git a/yoti_python_sdk/dynamic_sharing_service/policy/wanted_anchor_builder.py b/yoti_python_sdk/dynamic_sharing_service/policy/wanted_anchor_builder.py new file mode 100644 index 00000000..47c05c81 --- /dev/null +++ b/yoti_python_sdk/dynamic_sharing_service/policy/wanted_anchor_builder.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + + +class WantedAnchorBuilder(object): + def __init__(self): + self.__name = "" + self.__subtype = "" + + def with_value(self, name): + """ + :param name: The type of anchor as a string + """ + self.__name = name + return self + + def with_subtype(self, subtype): + """ + :param subtype: Subtype information as a string + """ + self.__subtype = subtype + return self + + def build(self): + """ + :returns: A dict containing the anchor specification + """ + return {"name": self.__name, "sub_type": self.__subtype} diff --git a/yoti_python_sdk/dynamic_sharing_service/policy/wanted_attribute_builder.py b/yoti_python_sdk/dynamic_sharing_service/policy/wanted_attribute_builder.py index 90d77165..7cacf7f6 100644 --- a/yoti_python_sdk/dynamic_sharing_service/policy/wanted_attribute_builder.py +++ b/yoti_python_sdk/dynamic_sharing_service/policy/wanted_attribute_builder.py @@ -1,14 +1,15 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -""" -Builder for WantedAttribute -""" - class WantedAttributeBuilder(object): + """ + Builder for WantedAttribute + """ + def __init__(self): self.__attribute = {} + self.__constraints = [] def with_name(self, name): """ @@ -24,8 +25,28 @@ def with_derivation(self, derivation): self.__attribute["derivation"] = derivation return self + def with_accept_self_asserted(self, value=True): + """ + :param value: True if self-asserted details are allowed + """ + self.__attribute["accept_self_asserted"] = value + return self + + def with_constraint(self, constraint): + """ + :param constraint: Adds a constraint (e.g. a source constraint) to the + wanted attribute + """ + if isinstance(constraint, list): + self.__constraints.extend(constraint) + else: + self.__constraints.append(constraint) + return self + def build(self): """ :return: The wanted attribute object """ - return self.__attribute.copy() + attribute = self.__attribute.copy() + attribute["constraints"] = self.__constraints[:] + return attribute diff --git a/yoti_python_sdk/dynamic_sharing_service/share_url.py b/yoti_python_sdk/dynamic_sharing_service/share_url.py index 31df805a..913fedb0 100644 --- a/yoti_python_sdk/dynamic_sharing_service/share_url.py +++ b/yoti_python_sdk/dynamic_sharing_service/share_url.py @@ -19,7 +19,7 @@ def create_share_url(yoti_client, dynamic_scenario): http_method = "POST" payload = json.dumps(dynamic_scenario, sort_keys=True).encode() - endpoint = yoti_client.endpoints.get_dynamic_share_request_url() + endpoint = yoti_client.endpoints.get_dynamic_share_request_url(no_params=True) response = yoti_client.make_request(http_method, endpoint, payload) client.Client.http_error_handler(response, SHARE_URL_ERRORS) diff --git a/yoti_python_sdk/endpoint.py b/yoti_python_sdk/endpoint.py index 0ba3f2e2..0244d128 100644 --- a/yoti_python_sdk/endpoint.py +++ b/yoti_python_sdk/endpoint.py @@ -1,35 +1,32 @@ -import time -import uuid +from yoti_python_sdk.utils import create_timestamp, create_nonce class Endpoint(object): def __init__(self, sdk_id): self.sdk_id = sdk_id - def get_activity_details_request_path(self, decrypted_request_token): + def get_activity_details_request_path( + self, decrypted_request_token, no_params=False + ): + if no_params: + return "/profile/{0}".format(decrypted_request_token) + return "/profile/{0}?nonce={1}×tamp={2}&appId={3}".format( - decrypted_request_token, - self.__create_nonce(), - self.__create_timestamp(), - self.sdk_id, + decrypted_request_token, create_nonce(), create_timestamp(), self.sdk_id ) - def get_aml_request_url(self): + def get_aml_request_url(self, no_params=False): + if no_params: + return "/aml-check" + return "/aml-check?appId={0}×tamp={1}&nonce={2}".format( - self.sdk_id, self.__create_timestamp(), self.__create_nonce() + self.sdk_id, create_timestamp(), create_nonce() ) - def get_dynamic_share_request_url(self): + def get_dynamic_share_request_url(self, no_params=False): + if no_params: + return "/qrcodes/app/{appid}".format(appid=self.sdk_id) + return "/qrcodes/apps/{appid}?nonce={nonce}×tamp={timestamp}".format( - appid=self.sdk_id, - nonce=self.__create_nonce(), - timestamp=self.__create_timestamp(), + appid=self.sdk_id, nonce=create_nonce(), timestamp=create_timestamp() ) - - @staticmethod - def __create_nonce(): - return uuid.uuid4() - - @staticmethod - def __create_timestamp(): - return int(time.time() * 1000) diff --git a/yoti_python_sdk/exceptions.py b/yoti_python_sdk/exceptions.py new file mode 100644 index 00000000..e445e88b --- /dev/null +++ b/yoti_python_sdk/exceptions.py @@ -0,0 +1,2 @@ +class MalformedAgeVerificationException(Exception): + pass diff --git a/yoti_python_sdk/http.py b/yoti_python_sdk/http.py new file mode 100644 index 00000000..9db9ad4a --- /dev/null +++ b/yoti_python_sdk/http.py @@ -0,0 +1,317 @@ +from yoti_python_sdk.crypto import Crypto +from yoti_python_sdk.utils import create_nonce, create_timestamp +from yoti_python_sdk.config import ( + X_YOTI_AUTH_KEY, + X_YOTI_AUTH_DIGEST, + X_YOTI_SDK, + SDK_IDENTIFIER, + X_YOTI_SDK_VERSION, + JSON_CONTENT_TYPE, +) +from cryptography.fernet import base64 +from abc import ABCMeta, abstractmethod + +import yoti_python_sdk +import requests + +try: # pragma: no cover + from urllib.parse import urlencode +except ImportError: # pragma: no cover + from urllib import urlencode + +HTTP_POST = "POST" +HTTP_GET = "GET" +HTTP_SUPPORTED_METHODS = ["POST", "PUT", "PATCH", "GET", "DELETE"] + + +class YotiResponse(object): + def __init__(self, status_code, text): + self.status_code = status_code + self.text = text + + +class RequestHandler: + """ + Default request handler for signing requests using the requests library. + This type can be inherited and the execute method overridden to use any + preferred HTTP library. Must return type YotiResponse for use in the SDK. + """ + + __metaclass__ = ABCMeta # Python 2 compatability + + @staticmethod + @abstractmethod + def execute(request): + return NotImplemented + + +class DefaultRequestHandler(RequestHandler): + @staticmethod + def execute(request): + """ + Execute the HTTP request supplied + """ + if not isinstance(request, SignedRequest): + raise TypeError("RequestHandler expects instance of SignedRequest") + + response = requests.request( + url=request.url, + method=request.method, + data=request.data, + headers=request.headers, + ) + + return YotiResponse(status_code=response.status_code, text=response.text) + + +class SignedRequest(object): + def __init__(self, url, http_method, payload, headers, request_handler=None): + self.__url = url + self.__http_method = http_method + self.__payload = payload + self.__headers = headers + self.__request_handler = request_handler + + @property + def url(self): + """ + Returns the URL for the SignedRequest + """ + return self.__url + + @property + def method(self): + """ + Returns the HTTP method for the SignedRequest + """ + return self.__http_method + + @property + def data(self): + """ + Returns the payload data for the SignedRequest + """ + return self.__payload + + @property + def headers(self): + """ + Returns the HTTP headers for the SignedRequest + """ + return self.__headers + + def execute(self): + """ + Send the signed request, using the default RequestHandler if one has not be supplied + """ + if self.__request_handler is None: + return DefaultRequestHandler.execute(self) + + return self.__request_handler.execute(self) + + @staticmethod + def builder(): + """ + Returns an instance of SignedRequestBuilder + """ + return SignedRequestBuilder() + + +class SignedRequestBuilder(object): + def __init__(self): + self.__crypto = None + self.__base_url = None + self.__endpoint = None + self.__http_method = None + self.__params = None + self.__headers = None + self.__payload = None + self.__request_handler = None + + def with_pem_file(self, pem_file): + """ + Sets the PEM file to be used for signing the request. Can be an instance of yoti_python_sdk.crypto.Crypto + or a path to a PEM file + """ + if isinstance(pem_file, Crypto): + self.__crypto = pem_file + else: + self.__crypto = Crypto.read_pem_file(pem_file) + + return self + + def with_base_url(self, base_url): + """ + Sets the base URL for the signed request + """ + self.__base_url = base_url + return self + + def with_endpoint(self, endpoint): + """ + Sets the endpoint for the signed request + """ + self.__endpoint = endpoint + return self + + def with_payload(self, payload): + """ + Sets the payload for the signed request. Must be a valid JSON string + """ + self.__payload = payload + return self + + def with_param(self, name, value): + """ + Sets a query param to be used with the endpoint + """ + if self.__params is None: + self.__params = {} + + self.__params[name] = value + return self + + def with_header(self, name, value): + """ + Sets a HTTP header to be used in the request + """ + if self.__headers is None: + self.__headers = {} + + self.__headers[name] = value + return self + + def with_http_method(self, http_method): + """ + Sets the HTTP method to be used in the request + """ + if http_method not in HTTP_SUPPORTED_METHODS: + raise ValueError("{} is an unsupported HTTP method".format(http_method)) + + self.__http_method = http_method + return self + + def with_request_handler(self, handler): + # If no handler is passed, just return as the default will be used + if handler is None: + return self + + try: + if not issubclass(handler, RequestHandler): + raise TypeError( + "Handler must be instance of yoti_python_sdk.http.RequestHandler" + ) + except Exception: + # ABC + raise TypeError( + "Handler must be instance of yoti_python_sdk.http.RequestHandler" + ) + + self.__request_handler = handler + return self + + def with_post(self): + """ + Sets the HTTP method for a POST request + """ + self.with_http_method(HTTP_POST) + return self + + def with_get(self): + """ + Sets the HTTP method for a GET request + """ + self.with_http_method(HTTP_GET) + return self + + def __append_query_params(self, query_params=None): + """ + Appends supplied query params in a dict to default query params. + Returns a url encoded query param string + """ + required = {"nonce": create_nonce(), "timestamp": create_timestamp()} + + query_params = self.__merge_dictionary(query_params, required) + return "?{}".format(urlencode(query_params)) + + @staticmethod + def __merge_dictionary(a, b): + """ + Merges two dictionaries a and b, with b taking precedence over a + """ + if a is None: + return b + + merged = a.copy() + merged.update(b) + return merged + + def __get_request_headers(self, path, http_method, content): + """ + Returns a dictionary of request headers, also using supplied headers from builder. Default headers take precedence. + """ + request = self.__create_request(http_method, path, content) + sdk_version = yoti_python_sdk.__version__ + + default = { + X_YOTI_AUTH_KEY: self.__crypto.get_public_key(), + X_YOTI_AUTH_DIGEST: self.__crypto.sign(request), + X_YOTI_SDK: SDK_IDENTIFIER, + X_YOTI_SDK_VERSION: "{0}-{1}".format(SDK_IDENTIFIER, sdk_version), + "Content-Type": JSON_CONTENT_TYPE, + "Accept": JSON_CONTENT_TYPE, + } + + if self.__headers is not None: + return self.__merge_dictionary(self.__headers, default) + + return default + + @staticmethod + def __create_request(http_method, path, content): + """ + Creates a concatenated string that is used in the X-YOTI-AUTH-DIGEST header + :param http_method: + :param path: + :param content: + :return: + """ + + request = "{}&{}".format(http_method, path) + + if content is not None: + b64encoded = base64.b64encode(content) + b64ascii = b64encoded.decode("ascii") + request += "&" + b64ascii + + return request + + def __validate_request(self): + """ + Validates the request object to ensure the required values + have been supplied. + """ + if self.__base_url is None: + raise ValueError("Base URL must not be None") + if self.__endpoint is None: + raise ValueError("Endpoint must not be None") + if self.__crypto is None: + raise ValueError("PEM file must not be None") + if self.__http_method is None: + raise ValueError("HTTP method must be specified") + + def build(self): + """ + Builds a SignedRequest object with the supplied values + """ + self.__validate_request() + + endpoint = self.__endpoint + self.__append_query_params(self.__params) + headers = self.__get_request_headers( + endpoint, self.__http_method, self.__payload + ) + url = self.__base_url + endpoint + + return SignedRequest( + url, self.__http_method, self.__payload, headers, self.__request_handler + ) diff --git a/yoti_python_sdk/profile.py b/yoti_python_sdk/profile.py index 4a10d498..3dfb4693 100644 --- a/yoti_python_sdk/profile.py +++ b/yoti_python_sdk/profile.py @@ -1,17 +1,17 @@ # -*- coding: utf-8 -*- import logging -from yoti_python_sdk import attribute_parser, config, multivalue +from yoti_python_sdk import attribute_parser, config, multivalue, document_details from yoti_python_sdk.anchor import Anchor from yoti_python_sdk.attribute import Attribute from yoti_python_sdk.image import Image -from yoti_python_sdk import document_details +from yoti_python_sdk.age_verification import AgeVerification class BaseProfile(object): - def __init__(self, profile_attributes): self.attributes = {} + self.verifications = None if profile_attributes: for field in profile_attributes: @@ -161,6 +161,31 @@ def document_images(self): def document_details(self): return self.get_attribute(config.ATTRIBUTE_DOCUMENT_DETAILS) + def get_age_verifications(self): + self.__find_all_age_verifications() + return [self.verifications[key] for key in self.verifications.keys()] + + def find_age_over_verification(self, age): + self.__find_all_age_verifications() + return self.verifications[config.ATTRIBUTE_AGE_OVER + str(age)] + + def find_age_under_verification(self, age): + self.__find_all_age_verifications() + return self.verifications[config.ATTRIBUTE_AGE_UNDER + str(age)] + + def __find_all_age_verifications(self): + if self.verifications is None: + self.verifications = {} + for key in self.attributes: + attribute = self.attributes[key] + if hasattr( + attribute, "name" + ): # This will be changed in v3 as selfie will be an object rather than a string + if ( + config.ATTRIBUTE_AGE_OVER in attribute.name + or config.ATTRIBUTE_AGE_UNDER in attribute.name + ): + self.verifications[attribute.name] = AgeVerification(attribute) def ensure_postal_address(self): if ( @@ -185,7 +210,7 @@ def ensure_postal_address(self): class ApplicationProfile(BaseProfile): def __init__(self, profile_attributes): super(ApplicationProfile, self).__init__(profile_attributes) - + @property def application_name(self): """ diff --git a/yoti_python_sdk/sandbox/__init__.py b/yoti_python_sdk/sandbox/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/yoti_python_sdk/sandbox/age_verification.py b/yoti_python_sdk/sandbox/age_verification.py new file mode 100644 index 00000000..2cb5882c --- /dev/null +++ b/yoti_python_sdk/sandbox/age_verification.py @@ -0,0 +1,97 @@ +from yoti_python_sdk.sandbox.attribute import SandboxAttribute +from yoti_python_sdk import config + + +class SandboxAgeVerification(object): + def __init__(self, date_of_birth, supported_age_derivation, anchors=None): + + if anchors is None: + anchors = [] + + self.__date_of_birth = date_of_birth + self.__supported_age_derivation = supported_age_derivation + self.__anchors = anchors + + def to_attribute(self): + """ + Converts the age verification object into an Attribute + + :return: Instance of SandboxAttribute + """ + return ( + SandboxAttribute.builder() + .with_name(config.ATTRIBUTE_DATE_OF_BIRTH) + .with_value(self.__date_of_birth) + .with_derivation(self.__supported_age_derivation) + .with_anchors(self.__anchors) + .build() + ) + + @staticmethod + def builder(): + """ + Creates a sandbox age verification builder + + :return: Instance of SandboxAgeVerificationBuilder + """ + return SandboxAgeVerificationBuilder() + + +class SandboxAgeVerificationBuilder(object): + def __init__(self): + self.__date_of_birth = None + self.__derivation = None + self.__anchors = None + + def with_date_of_birth(self, date_of_birth): + """ + Set the date of birth on the builder + + :param str date_of_birth: the date of birth + :return: the updated builder + """ + self.__date_of_birth = date_of_birth + return self + + def with_derivation(self, derivation): + """ + Set the derivation of the age verification + + :param str derivation: the derivation + :return: the updated builder + """ + self.__derivation = derivation + return self + + def with_age_over(self, age_over): + """ + Set the age over value of the age verification + + :param int age_over: the age over value + :return: the updated builder + """ + return self.with_derivation(config.ATTRIBUTE_AGE_OVER + str(age_over)) + + def with_age_under(self, age_under): + """ + Set the age under value of the age verification + + :param int age_under: + :return: the updated builder + """ + return self.with_derivation(config.ATTRIBUTE_AGE_UNDER + str(age_under)) + + def with_anchors(self, anchors): + """ + Set the anchors for the age verification + + :param list[SandboxAnchor] anchors: + :return: the updated builder + """ + self.__anchors = anchors + return self + + def build(self): + return SandboxAgeVerification( + self.__date_of_birth, self.__derivation, self.__anchors + ) diff --git a/yoti_python_sdk/sandbox/anchor.py b/yoti_python_sdk/sandbox/anchor.py new file mode 100644 index 00000000..4d85a2aa --- /dev/null +++ b/yoti_python_sdk/sandbox/anchor.py @@ -0,0 +1,127 @@ +from yoti_python_sdk.anchor import UNKNOWN_ANCHOR_TYPE + + +class SandboxAnchor(object): + def __init__(self, anchor_type=None, sub_type=None, value=None, timestamp=None): + if anchor_type is None: + anchor_type = UNKNOWN_ANCHOR_TYPE + if sub_type is None: + sub_type = "" + if value is None: + value = "" + + self.__anchor_type = anchor_type + self.__sub_type = sub_type + self.__value = value + self.__timestamp = timestamp + + @property + def anchor_type(self): + """ + Returns the anchor type + + :return: the type + """ + return self.__anchor_type + + @property + def sub_type(self): + """ + Returns the anchor sub-type + + :return: the sub-type + """ + return self.__sub_type + + @property + def value(self): + """ + Returns the anchor value + + :return: the value + """ + return self.__value + + @property + def timestamp(self): + """ + Returns the anchor timestamp + + :return: the timestamp + """ + return self.__timestamp + + def __dict__(self): + return { + "type": self.anchor_type, + "value": self.value, + "sub_type": self.sub_type, + "timestamp": self.timestamp, + } + + @staticmethod + def builder(): + """ + Creates an instance of the sandbox anchor builder + + :return: instance of SandboxAnchorBuilder + """ + return SandboxAnchorBuilder() + + +class SandboxAnchorBuilder(object): + def __init__(self): + self.__type = None + self.__value = None + self.__sub_type = None + self.__timestamp = None + + def with_type(self, type): + """ + Sets the type of the anchor on the builder + + :param str type: the anchor type + :return: the updated builder + """ + self.__type = type + return self + + def with_value(self, value): + """ + Sets the value of the anchor on the builder + + :param str value: the anchor value + :return: the updated builder + """ + self.__value = value + return self + + def with_sub_type(self, sub_type): + """ + Sets the sub type of the anchor on the builder + + :param str sub_type: the anchor sub type + :return: the updated builder + """ + self.__sub_type = sub_type + return self + + def with_timestamp(self, timestamp): + """ + Sets the timestamp of the anchor on the builder + + :param int timestamp: the anchor timestamp + :return: the updated builder + """ + self.__timestamp = timestamp + return self + + def build(self): + """ + Creates a SandboxAnchor using values supplied to the builder + + :return: the sandbox anchor + """ + return SandboxAnchor( + self.__type, self.__sub_type, self.__value, self.__timestamp + ) diff --git a/yoti_python_sdk/sandbox/attribute.py b/yoti_python_sdk/sandbox/attribute.py new file mode 100644 index 00000000..fb6eb505 --- /dev/null +++ b/yoti_python_sdk/sandbox/attribute.py @@ -0,0 +1,154 @@ +from yoti_python_sdk import config + + +class SandboxAttribute(object): + def __init__(self, name=None, value=None, anchors=None, derivation=None): + if name is None: + name = "" + + if value is None: + value = "" + + if anchors is None: + anchors = [] + + if derivation is None: + derivation = "" + + self.__name = name + self.__value = value + self.__anchors = anchors + self.__derivation = derivation + + @property + def name(self): + """ + Returns the name of the attribute + + :return: the name + """ + return self.__name + + @property + def value(self): + """ + Returns the value of the attribute + + :return: the value + """ + return self.__value + + @property + def anchors(self): + """ + Returns the anchors associated with the attribute + + :return: the anchors + """ + return self.__anchors + + @property + def sources(self): + """ + Returns a filtered list of the associated anchors, only returning source anchors + + :return: list of filtered source anchors + """ + return list( + filter(lambda a: a.anchor_type == config.ANCHOR_SOURCE, self.__anchors) + ) + + @property + def verifiers(self): + """ + Returns a filtered list of the associated anchors, only returning verifier anchors + + :return: list of filtered verifier anchors + """ + return list( + filter(lambda a: a.anchor_type == config.ANCHOR_VERIFIER, self.__anchors) + ) + + @property + def derivation(self): + """ + Returns the derivation of the attribute + + :return: the derivation + """ + return self.__derivation + + def __dict__(self): + return { + "name": self.name, + "value": self.value, + "anchors": self.anchors, + "derivation": self.derivation, + } + + @staticmethod + def builder(): + """ + Creates an instance of the sandbox attribute builder + + :return: the sandbox attribute builder + """ + return SandboxAttributeBuilder() + + +class SandboxAttributeBuilder(object): + def __init__(self): + self.__name = None + self.__value = None + self.__anchors = None + self.__derivation = None + + def with_name(self, name): + """ + Sets the name of the attribute on the builder + + :param str name: the name of the attribute + :return: the updated builder + """ + self.__name = name + return self + + def with_value(self, value): + """ + Sets the value of the attribute on the builder + + :param value: the value of the attribute + :return: the updated builder + """ + self.__value = value + return self + + def with_anchors(self, anchors): + """ + Sets the list of anchors associated with the attribute + + :param list[SandboxAnchor] anchors: the associated anchors + :return: + """ + self.__anchors = anchors + return self + + def with_derivation(self, derivation): + """ + Sets the derivation of the attribute on the builder + + :param str derivation: the derivation + :return: the updated builder + """ + self.__derivation = derivation + return self + + def build(self): + """ + Create an instance of SandboxAttribute using values supplied to the builder + + :return: instance of SandboxAttribute + """ + return SandboxAttribute( + self.__name, self.__value, self.__anchors, self.__derivation + ) diff --git a/yoti_python_sdk/sandbox/client.py b/yoti_python_sdk/sandbox/client.py new file mode 100644 index 00000000..fa0a819f --- /dev/null +++ b/yoti_python_sdk/sandbox/client.py @@ -0,0 +1,182 @@ +from yoti_python_sdk.sandbox.endpoint import SandboxEndpoint +from cryptography.fernet import base64 +from os.path import expanduser, isfile +from past.builtins import basestring + +import json +import requests +import yoti_python_sdk +from yoti_python_sdk.config import ( + X_YOTI_AUTH_KEY, + X_YOTI_AUTH_DIGEST, + X_YOTI_SDK, + SDK_IDENTIFIER, + X_YOTI_SDK_VERSION, + JSON_CONTENT_TYPE, +) +from yoti_python_sdk.client import HTTP_SUPPORTED_METHODS +from yoti_python_sdk.sandbox.token import YotiTokenRequest, YotiTokenResponse +from yoti_python_sdk.sandbox.attribute import SandboxAttribute +from yoti_python_sdk.sandbox.anchor import SandboxAnchor +from yoti_python_sdk.crypto import Crypto +from json import JSONEncoder + + +class SandboxEncoder(JSONEncoder): + def default(self, o): + if isinstance(o, YotiTokenRequest): + return o.__dict__() + if isinstance(o, SandboxAttribute): + return o.__dict__() + if isinstance(o, SandboxAnchor): + return o.__dict__() + + return json.JSONEncoder.default(self, o) + + +class SandboxClient(object): + def __init__(self, sdk_id, pem_file, sandbox_url): + self.sdk_id = sdk_id + self.__endpoint = SandboxEndpoint(sdk_id) + self.__sandbox_url = sandbox_url + + pem_data = SandboxClient.__read_pem_file( + pem_file, "failed in SandboxClient __init__" + ) + + self.__crypto = Crypto(pem_data) + + def setup_sharing_profile(self, request_token): + """ + Using the supplied YotiTokenRequest, this function will make a request + to the defined sandbox environment to create a profile with the supplied values. + The returned token can be used against the sandbox environment to retrieve the profile + using the standard YotiClient. + + :param YotiTokenRequest request_token: + :return: the token for accessing a profile + """ + request_path = self.__endpoint.get_sandbox_path() + response = SandboxClient.post( + self.__sandbox_url, request_path, self.__crypto, request_token + ) + response_payload = response.json() + return YotiTokenResponse(response_payload["token"]) + + @staticmethod + def builder(): + """ + Creates an instance of the sandbox client builder + + :return: instance of SandboxClientBuilder + """ + return SandboxClientBuilder() + + @staticmethod + def post(host, path, key, content): + payload = json.dumps(content, cls=SandboxEncoder) + print(payload) + payload_bytes = payload.encode() + headers = SandboxClient.__get_request_headers(path, "POST", payload_bytes, key) + return requests.post(host + path, payload_bytes, headers=headers, verify=False) + + @staticmethod + def __get_request_headers(path, http_method, content, crypto): + request = SandboxClient.__create_request(http_method, path, content) + sdk_version = yoti_python_sdk.__version__ + + return { + X_YOTI_AUTH_KEY: crypto.get_public_key(), + X_YOTI_AUTH_DIGEST: crypto.sign(request), + X_YOTI_SDK: SDK_IDENTIFIER, + X_YOTI_SDK_VERSION: "{0}-{1}".format(SDK_IDENTIFIER, sdk_version), + "Content-Type": JSON_CONTENT_TYPE, + "Accept": JSON_CONTENT_TYPE, + } + + @staticmethod + def __read_pem_file(key_file_path, error_source): + try: + key_file_path = expanduser(key_file_path) + + if not isinstance(key_file_path, basestring) or not isfile(key_file_path): + raise IOError("File not found: {0}".format(key_file_path)) + with open(key_file_path, "rb") as pem_file: + return pem_file.read().strip() + except (AttributeError, IOError, TypeError, OSError) as exc: + error = 'Could not read private key file: "{0}", passed as: {1} '.format( + key_file_path, error_source + ) + exception = "{0}: {1}".format(type(exc).__name__, exc) + raise RuntimeError("{0}: {1}".format(error, exception)) + + @staticmethod + def __create_request(http_method, path, content): + if http_method not in HTTP_SUPPORTED_METHODS: + raise ValueError( + "{} is not in the list of supported methods: {}".format( + http_method, HTTP_SUPPORTED_METHODS + ) + ) + + request = "{}&{}".format(http_method, path) + + if content is not None: + b64encoded = base64.b64encode(content) + b64ascii = b64encoded.decode("ascii") + request += "&" + b64ascii + + return request + + +class SandboxClientBuilder(object): + def __init__(self): + self.__sdk_id = None + self.__pem_file = None + self.__sandbox_url = None + + def for_application(self, sdk_id): + """ + Sets the application ID on the builder + + :param str sdk_id: the SDK ID supplied from Yoti Hub + :return: the updated builder + """ + self.__sdk_id = sdk_id + return self + + def with_pem_file(self, pem_file): + """ + Sets the pem file to be used on the builder + + :param str pem_file: path to the PEM file + :return: the updated builder + """ + self.__pem_file = pem_file + return self + + def with_sandbox_url(self, sandbox_url): + """ + Sets the URL of the sandbox environment on the builder + + :param str sandbox_url: the sandbox environment URL + :return: the updated builder + """ + self.__sandbox_url = sandbox_url + return self + + def build(self): + """ + Using all supplied values, create an instance of the SandboxClient. + + :raises ValueError: one or more of the values is None + :return: instance of SandboxClient + """ + if ( + self.__sdk_id is None + or self.__pem_file is None + or self.__sandbox_url is None + ): + raise ValueError("SDK ID/PEM file/sandbox url must not be None") + + return SandboxClient(self.__sdk_id, self.__pem_file, self.__sandbox_url) diff --git a/yoti_python_sdk/sandbox/endpoint.py b/yoti_python_sdk/sandbox/endpoint.py new file mode 100644 index 00000000..194dd812 --- /dev/null +++ b/yoti_python_sdk/sandbox/endpoint.py @@ -0,0 +1,12 @@ +from yoti_python_sdk.endpoint import Endpoint +from yoti_python_sdk.utils import create_timestamp, create_nonce + + +class SandboxEndpoint(Endpoint): + def __init__(self, sdk_id): + super(SandboxEndpoint, self).__init__(sdk_id) + + def get_sandbox_path(self): + return "/apps/{sdk_id}/tokens?timestamp={timestamp}&nonce={nonce}".format( + sdk_id=self.sdk_id, nonce=create_nonce(), timestamp=create_timestamp() + ) diff --git a/yoti_python_sdk/sandbox/token.py b/yoti_python_sdk/sandbox/token.py new file mode 100644 index 00000000..0ec77b27 --- /dev/null +++ b/yoti_python_sdk/sandbox/token.py @@ -0,0 +1,252 @@ +from yoti_python_sdk.sandbox.attribute import SandboxAttribute +from yoti_python_sdk import config +import base64 + + +class YotiTokenResponse(object): + def __init__(self, token): + self.__token = token + + @property + def token(self): + """ + The token to be used by the Client + + :return: the token + """ + return self.__token + + +class YotiTokenRequest(object): + def __init__(self, remember_me_id=None, sandbox_attributes=None): + if remember_me_id is None: + remember_me_id = "" + + if sandbox_attributes is None: + sandbox_attributes = [] + + self.remember_me_id = remember_me_id + self.sandbox_attributes = sandbox_attributes + + def __dict__(self): + return { + "remember_me_id": self.remember_me_id, + "profile_attributes": self.sandbox_attributes, + } + + @staticmethod + def builder(): + """ + Creates an instance of the yoti token request builder + + :return: instance of YotiTokenRequestBuilder + """ + return YotiTokenRequestBuilder() + + +class YotiTokenRequestBuilder(object): + def __init__(self): + self.remember_me_id = None + self.attributes = [] + + def with_remember_me_id(self, remember_me_id): + """ + Sets the remember me id on the builder + + :param remember_me_id: the remember me id + :return: the updated builder + """ + self.remember_me_id = remember_me_id + return self + + def with_attribute(self, sandbox_attribute): + """ + Appends a SandboxAttribute to the list of attributes on the builder + + :param SandboxAttribute sandbox_attribute: + :return: the updated builder + """ + self.attributes.append(sandbox_attribute) + return self + + def with_given_names(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for given names + + :param str value: the value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + attribute = self.__create_attribute( + config.ATTRIBUTE_GIVEN_NAMES, value, anchors + ) + return self.with_attribute(attribute) + + def with_family_name(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for family name + + :param str value: the value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + attribute = self.__create_attribute( + config.ATTRIBUTE_FAMILY_NAME, value, anchors + ) + return self.with_attribute(attribute) + + def with_full_name(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for full name + + :param str value: the value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + attribute = self.__create_attribute(config.ATTRIBUTE_FULL_NAME, value, anchors) + return self.with_attribute(attribute) + + def with_date_of_birth(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for date of birth + + :param str value: the value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + attribute = self.__create_attribute( + config.ATTRIBUTE_DATE_OF_BIRTH, value, anchors + ) + return self.with_attribute(attribute) + + def with_age_verification(self, age_verification): + """ + Creates and appends a SandboxAttribute with a given age verification + + :param SandboxAgeVerification age_verification: the age verification + :return: + """ + return self.with_attribute(age_verification.to_attribute()) + + def with_gender(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for gender + + :param str value: the value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + attribute = self.__create_attribute(config.ATTRIBUTE_GENDER, value, anchors) + return self.with_attribute(attribute) + + def with_phone_number(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for phone number + + :param str value: the value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + attribute = self.__create_attribute( + config.ATTRIBUTE_PHONE_NUMBER, value, anchors + ) + return self.with_attribute(attribute) + + def with_nationality(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for nationality + + :param str value: the value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + attribute = self.__create_attribute( + config.ATTRIBUTE_NATIONALITY, value, anchors + ) + return self.with_attribute(attribute) + + def with_postal_address(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for postal address + + :param str value: the value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + attribute = self.__create_attribute( + config.ATTRIBUTE_POSTAL_ADDRESS, value, anchors + ) + return self.with_attribute(attribute) + + def with_structured_postal_address(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for structured postal address + + :param str value: the value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + attribute = self.__create_attribute( + config.ATTRIBUTE_STRUCTURED_POSTAL_ADDRESS, value, anchors + ) + return self.with_attribute(attribute) + + def with_selfie(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for a selfie + + :param str value: the value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + base64_selfie = base64.b64encode(value).decode("utf-8") + return self.with_base64_selfie(base64_selfie, anchors) + + def with_base64_selfie(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for given names + + :param str value: base64 encoded value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + attribute = self.__create_attribute(config.ATTRIBUTE_SELFIE, value, anchors) + return self.with_attribute(attribute) + + def with_email_address(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for email address + + :param str value: the value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + attribute = self.__create_attribute( + config.ATTRIBUTE_EMAIL_ADDRESS, value, anchors + ) + return self.with_attribute(attribute) + + def with_document_details(self, value, anchors=None): + """ + Creates and appends a SandboxAttribute for document details + + :param str value: the value + :param list[SandboxAnchor] anchors: optional list of anchors + :return: + """ + attribute = self.__create_attribute( + config.ATTRIBUTE_DOCUMENT_DETAILS, value, anchors + ) + return self.with_attribute(attribute) + + def build(self): + """ + Creates an instance of YotiTokenRequest using the supplied values + + :return: instance of YotiTokenRequest + """ + return YotiTokenRequest(self.remember_me_id, self.attributes) + + @staticmethod + def __create_attribute(name, value, anchors=None): + return SandboxAttribute(name, value, anchors) diff --git a/yoti_python_sdk/tests/conftest.py b/yoti_python_sdk/tests/conftest.py index c7c87b77..335e201e 100644 --- a/yoti_python_sdk/tests/conftest.py +++ b/yoti_python_sdk/tests/conftest.py @@ -6,6 +6,7 @@ from yoti_python_sdk import Client from yoti_python_sdk.crypto import Crypto +from yoti_python_sdk.tests.mocks import MockRequestHandler FIXTURES_DIR = join(dirname(abspath(__file__)), "fixtures") PEM_FILE_PATH = join(FIXTURES_DIR, "sdk-test.pem") @@ -17,6 +18,11 @@ YOTI_CLIENT_SDK_ID = "737204aa-d54e-49a4-8bde-26ddbe6d880c" +@pytest.fixture(scope="module") +def mock_request_handler(): + return MockRequestHandler + + @pytest.fixture(scope="module") def client(): return Client(YOTI_CLIENT_SDK_ID, PEM_FILE_PATH) @@ -63,22 +69,22 @@ def timestamp(): @pytest.fixture(scope="module") -def successful_receipt(): +def successful_receipt(user_id, parent_remember_me_id, receipt_id, timestamp): return { - "remember_me_id": user_id(), - "parent_remember_me_id": parent_remember_me_id(), - "receipt_id": receipt_id(), - "timestamp": timestamp(), + "remember_me_id": user_id, + "parent_remember_me_id": parent_remember_me_id, + "receipt_id": receipt_id, + "timestamp": timestamp, "sharing_outcome": "SUCCESS", } @pytest.fixture(scope="module") -def failure_receipt(): +def failure_receipt(user_id, timestamp): return { - "remember_me_id": user_id(), + "remember_me_id": user_id, "sharing_outcome": "FAILURE", - "timestamp": timestamp(), + "timestamp": timestamp, } diff --git a/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_dynamic_policy_builder.py b/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_dynamic_policy_builder.py index e53edbdc..d383962d 100644 --- a/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_dynamic_policy_builder.py +++ b/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_dynamic_policy_builder.py @@ -4,6 +4,9 @@ from yoti_python_sdk.dynamic_sharing_service.policy.wanted_attribute_builder import ( WantedAttributeBuilder, ) +from yoti_python_sdk.dynamic_sharing_service.policy.source_constraint_builder import ( + SourceConstraintBuilder, +) from yoti_python_sdk import config @@ -119,3 +122,9 @@ def test_auth_types_can_exist_only_once(): assert len(policy["wanted_auth_types"]) == 1 assert DynamicPolicyBuilder.SELFIE_AUTH_TYPE not in policy["wanted_auth_types"] assert DynamicPolicyBuilder.PIN_AUTH_TYPE in policy["wanted_auth_types"] + + +def test_attributes_with_constraints(): + constraint = SourceConstraintBuilder().with_national_id().build() + policy = DynamicPolicyBuilder().with_nationality(constraints=constraint).build() + assert len(policy["wanted"][0]["constraints"]) == 1 diff --git a/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_source_constraint_builder.py b/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_source_constraint_builder.py new file mode 100644 index 00000000..689fc003 --- /dev/null +++ b/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_source_constraint_builder.py @@ -0,0 +1,35 @@ +from yoti_python_sdk.dynamic_sharing_service.policy.source_constraint_builder import ( + SourceConstraintBuilder, +) +from yoti_python_sdk.config import ANCHOR_VALUE_DRIVING_LICENCE, ANCHOR_VALUE_PASSPORT + + +def test_build(): + constraint = SourceConstraintBuilder().build() + + assert constraint["type"] == "SOURCE" + assert not constraint["preferred_sources"]["soft_preference"] + assert constraint["preferred_sources"]["anchors"] == [] + + +def test_with_driving_licence(): + constraint = SourceConstraintBuilder().with_driving_licence().build() + + anchors = constraint["preferred_sources"]["anchors"] + assert len(anchors) == 1 + assert ANCHOR_VALUE_DRIVING_LICENCE in [a["name"] for a in anchors] + + +def test_with_soft_preference(): + constraint = ( + SourceConstraintBuilder() + .with_passport() + .with_driving_licence() + .with_soft_preference() + .build() + ) + anchors = constraint["preferred_sources"]["anchors"] + assert len(anchors) == 2 + assert ANCHOR_VALUE_DRIVING_LICENCE in [a["name"] for a in anchors] + assert ANCHOR_VALUE_PASSPORT in [a["name"] for a in anchors] + assert constraint["preferred_sources"]["soft_preference"] diff --git a/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_wanted_anchor_builder.py b/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_wanted_anchor_builder.py new file mode 100644 index 00000000..b30c4eef --- /dev/null +++ b/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_wanted_anchor_builder.py @@ -0,0 +1,17 @@ +from yoti_python_sdk.dynamic_sharing_service.policy.wanted_anchor_builder import ( + WantedAnchorBuilder, +) + + +def test_build(): + TEST_VALUE = "TEST VALUE" + TEST_SUB_TYPE = "TEST SUB TYPE" + + builder = WantedAnchorBuilder() + builder.with_value(TEST_VALUE) + builder.with_subtype(TEST_SUB_TYPE) + + anchor = builder.build() + + assert anchor["name"] == TEST_VALUE + assert anchor["sub_type"] == TEST_SUB_TYPE diff --git a/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_wanted_attribute_builder.py b/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_wanted_attribute_builder.py index e061460f..1c273306 100644 --- a/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_wanted_attribute_builder.py +++ b/yoti_python_sdk/tests/dynamic_sharing_service/policy/test_wanted_attribute_builder.py @@ -1,6 +1,9 @@ from yoti_python_sdk.dynamic_sharing_service.policy.wanted_attribute_builder import ( WantedAttributeBuilder, ) +from yoti_python_sdk.dynamic_sharing_service.policy.source_constraint_builder import ( + SourceConstraintBuilder, +) def test_build(): @@ -13,3 +16,42 @@ def test_build(): assert attribute["name"] == NAME assert attribute["derivation"] == DERIVATION + + +def test_with_constraint(): + constraint = SourceConstraintBuilder().with_driving_licence().build() + attribute = ( + WantedAttributeBuilder() + .with_name("test name") + .with_constraint(constraint) + .build() + ) + + constraints = attribute["constraints"] + assert len(constraints) == 1 + assert len(constraints[0]["preferred_sources"]["anchors"]) == 1 + + +def test_with_multiple_constraints(): + constraintA = SourceConstraintBuilder().with_driving_licence().build() + constraintB = SourceConstraintBuilder().with_passport().build() + + attribute = ( + WantedAttributeBuilder() + .with_name("test name") + .with_constraint([constraintA, constraintB]) + .build() + ) + + constraints = attribute["constraints"] + assert len(constraints) == 2 + + +def test_acccept_self_assert(): + attribute = ( + WantedAttributeBuilder() + .with_name("test name") + .with_accept_self_asserted() + .build() + ) + assert attribute["accept_self_asserted"] diff --git a/yoti_python_sdk/tests/dynamic_sharing_service/test_share_url.py b/yoti_python_sdk/tests/dynamic_sharing_service/test_share_url.py index 11f6d3d4..36c63f01 100644 --- a/yoti_python_sdk/tests/dynamic_sharing_service/test_share_url.py +++ b/yoti_python_sdk/tests/dynamic_sharing_service/test_share_url.py @@ -48,7 +48,7 @@ def test_create_share_url_invalid_json(mock_uuid4, mock_time, mock_get): with pytest.raises(RuntimeError) as err: share_url.create_share_url(yoti_client, dynamic_scenario) - assert share_url.INVALID_DATA in str(err) + assert share_url.INVALID_DATA in str(err.value) @mock.patch( @@ -62,4 +62,4 @@ def test_create_share_url_app_not_found(mock_uuid4, mock_time, mock_get): with pytest.raises(RuntimeError) as err: share_url.create_share_url(yoti_client, dynamic_scenario) - assert share_url.APPLICATION_NOT_FOUND in str(err) + assert share_url.APPLICATION_NOT_FOUND in str(err.value) diff --git a/yoti_python_sdk/tests/mocks.py b/yoti_python_sdk/tests/mocks.py index 8d916fb3..f2373d07 100644 --- a/yoti_python_sdk/tests/mocks.py +++ b/yoti_python_sdk/tests/mocks.py @@ -1,10 +1,23 @@ from uuid import UUID +from yoti_python_sdk.http import RequestHandler, SignedRequest +from yoti_python_sdk.http import YotiResponse -class MockResponse: +class MockResponse(YotiResponse): def __init__(self, status_code, text): - self.status_code = status_code - self.text = text + super(MockResponse, self).__init__(status_code, text) + + +class MockRequestHandler(RequestHandler): + @staticmethod + def execute(request): + """ + Execute the HTTP request supplied + """ + if not isinstance(request, SignedRequest): + raise RuntimeError("RequestHandler expects instance of SignedRequest") + + return mocked_requests_get() def mocked_requests_get(*args, **kwargs): diff --git a/yoti_python_sdk/tests/sandbox/test_sandbox_client.py b/yoti_python_sdk/tests/sandbox/test_sandbox_client.py new file mode 100644 index 00000000..faa62c17 --- /dev/null +++ b/yoti_python_sdk/tests/sandbox/test_sandbox_client.py @@ -0,0 +1,49 @@ +from yoti_python_sdk.sandbox.client import SandboxClient +from yoti_python_sdk.sandbox.token import YotiTokenRequest, YotiTokenResponse +from yoti_python_sdk.tests.conftest import PEM_FILE_PATH + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + +import pytest + + +def test_builder_should_throw_error_for_missing_sdk_id(): + builder = SandboxClient.builder().with_pem_file("some_pem.pem") + with pytest.raises(ValueError): + builder.build() + + +def test_builder_should_throw_error_for_missing_pem_file(): + builder = SandboxClient.builder().for_application("my_application") + with pytest.raises(ValueError): + builder.build() + + +def test_builder_should_build_client(): + client = ( + SandboxClient.builder() + .for_application("some_app") + .with_pem_file(PEM_FILE_PATH) + .with_sandbox_url("https://localhost") + .build() + ) + + assert client.sdk_id == "some_app" + assert isinstance(client, SandboxClient) + + +@patch("yoti_python_sdk.sandbox.client.SandboxClient") +def test_client_should_return_token_from_sandbox(sandbox_client_mock): + sandbox_client_mock.setup_profile_share.return_value = YotiTokenResponse( + "some-token" + ) + + token_request = ( + YotiTokenRequest.builder().with_remember_me_id("remember_me_pls").build() + ) + response = sandbox_client_mock.setup_profile_share(token_request) + + assert response.token == "some-token" diff --git a/yoti_python_sdk/tests/test_activity_details.py b/yoti_python_sdk/tests/test_activity_details.py index 1337255f..1735b030 100644 --- a/yoti_python_sdk/tests/test_activity_details.py +++ b/yoti_python_sdk/tests/test_activity_details.py @@ -98,8 +98,8 @@ def create_structured_postal_address_field(activity_details, json_address_value) activity_details.field.content_type = Protobuf.CT_JSON -def test_try_parse_age_verified_both_missing_not_parsed(): - activity_details = ActivityDetails(successful_receipt()) +def test_try_parse_age_verified_both_missing_not_parsed(successful_receipt): + activity_details = ActivityDetails(successful_receipt) field = None ActivityDetails.try_parse_age_verified_field(activity_details, field) @@ -108,52 +108,52 @@ def test_try_parse_age_verified_both_missing_not_parsed(): ) -def test_failure_receipt_handled(): - activity_details = ActivityDetails(failure_receipt()) +def test_failure_receipt_handled(failure_receipt, user_id): + activity_details = ActivityDetails(failure_receipt) - assert activity_details.user_id == user_id() - assert activity_details.remember_me_id == user_id() + assert activity_details.user_id == user_id + assert activity_details.remember_me_id == user_id assert activity_details.outcome == "FAILURE" assert activity_details.timestamp == datetime(2016, 11, 14, 11, 35, 33) -def test_missing_values_handled(): - activity_details = ActivityDetails(no_values_receipt()) +def test_missing_values_handled(no_values_receipt): + activity_details = ActivityDetails(no_values_receipt) assert isinstance(activity_details, ActivityDetails) -def test_remember_me_id_empty(): - activity_details = ActivityDetails(empty_strings()) +def test_remember_me_id_empty(empty_strings): + activity_details = ActivityDetails(empty_strings) assert activity_details.user_id == "" assert activity_details.remember_me_id == "" assert isinstance(activity_details, ActivityDetails) -def test_remember_me_id_valid(): - activity_details = ActivityDetails(successful_receipt()) +def test_remember_me_id_valid(successful_receipt, user_id): + activity_details = ActivityDetails(successful_receipt) - assert activity_details.user_id == user_id() - assert activity_details.remember_me_id == user_id() + assert activity_details.user_id == user_id + assert activity_details.remember_me_id == user_id -def test_parent_remember_me_id_empty(): - activity_details = ActivityDetails(empty_strings()) +def test_parent_remember_me_id_empty(empty_strings): + activity_details = ActivityDetails(empty_strings) assert activity_details.user_id == "" assert activity_details.remember_me_id == "" assert isinstance(activity_details, ActivityDetails) -def test_parent_remember_me_id_valid(): - activity_details = ActivityDetails(successful_receipt()) +def test_parent_remember_me_id_valid(successful_receipt, parent_remember_me_id): + activity_details = ActivityDetails(successful_receipt) - assert activity_details.parent_remember_me_id == parent_remember_me_id() + assert activity_details.parent_remember_me_id == parent_remember_me_id -def test_try_parse_age_verified_field_age_over(): - activity_details = ActivityDetails(successful_receipt()) +def test_try_parse_age_verified_field_age_over(successful_receipt): + activity_details = ActivityDetails(successful_receipt) create_age_verified_field(activity_details, True, "true".encode(), 18) ActivityDetails.try_parse_age_verified_field( @@ -162,8 +162,8 @@ def test_try_parse_age_verified_field_age_over(): assert activity_details.user_profile[config.KEY_AGE_VERIFIED] is True -def test_try_parse_age_verified_field_age_under(): - activity_details = ActivityDetails(successful_receipt()) +def test_try_parse_age_verified_field_age_under(successful_receipt): + activity_details = ActivityDetails(successful_receipt) create_age_verified_field(activity_details, False, "false".encode(), 55) ActivityDetails.try_parse_age_verified_field( @@ -172,8 +172,8 @@ def test_try_parse_age_verified_field_age_under(): assert activity_details.user_profile[config.KEY_AGE_VERIFIED] is False -def test_try_parse_age_verified_field_non_bool_value_not_parsed(): - activity_details = ActivityDetails(successful_receipt()) +def test_try_parse_age_verified_field_non_bool_value_not_parsed(successful_receipt): + activity_details = ActivityDetails(successful_receipt) create_age_verified_field(activity_details, True, "18".encode(), 18) sys.stdout = open(os.devnull, "w") # disable print ActivityDetails.try_parse_age_verified_field( @@ -185,8 +185,8 @@ def test_try_parse_age_verified_field_non_bool_value_not_parsed(): ) -def test_try_parse_structured_postal_address_uk(): - activity_details = ActivityDetails(successful_receipt()) +def test_try_parse_structured_postal_address_uk(successful_receipt): + activity_details = ActivityDetails(successful_receipt) structured_postal_address = { ADDRESS_FORMAT_KEY: ADDRESS_FORMAT_VALUE, BUILDING_NUMBER_KEY: BUILDING_NUMBER_VALUE, @@ -247,8 +247,8 @@ def test_try_parse_structured_postal_address_uk(): ) -def test_try_parse_structured_postal_address_india(): - activity_details = ActivityDetails(successful_receipt()) +def test_try_parse_structured_postal_address_india(successful_receipt): + activity_details = ActivityDetails(successful_receipt) structured_postal_address = { ADDRESS_FORMAT_KEY: INDIA_FORMAT_VALUE, CARE_OF_KEY: CARE_OF_VALUE, @@ -322,8 +322,8 @@ def test_try_parse_structured_postal_address_india(): ) -def test_try_parse_structured_postal_address_usa(): - activity_details = ActivityDetails(successful_receipt()) +def test_try_parse_structured_postal_address_usa(successful_receipt): + activity_details = ActivityDetails(successful_receipt) structured_postal_address = { ADDRESS_FORMAT_KEY: USA_FORMAT_VALUE, ADDRESS_LINE_1_KEY: ADDRESS_LINE_1_VALUE, @@ -383,13 +383,13 @@ def test_try_parse_structured_postal_address_usa(): ) -def test_try_parse_structured_postal_address_nested_json(): +def test_try_parse_structured_postal_address_nested_json(successful_receipt): formatted_address_json = { "item1": [[1, "a1"], [2, "a2"]], "item2": [[3, "b3"], [4, "b4"]], } - activity_details = ActivityDetails(successful_receipt()) + activity_details = ActivityDetails(successful_receipt) structured_postal_address = { ADDRESS_FORMAT_KEY: ADDRESS_FORMAT_VALUE, BUILDING_NUMBER_KEY: BUILDING_NUMBER_VALUE, @@ -451,8 +451,8 @@ def test_try_parse_structured_postal_address_nested_json(): ) -def test_set_address_to_be_formatted_address(): - activity_details = ActivityDetails(successful_receipt()) +def test_set_address_to_be_formatted_address(successful_receipt): + activity_details = ActivityDetails(successful_receipt) structured_postal_address = {config.KEY_FORMATTED_ADDRESS: FORMATTED_ADDRESS_VALUE} structured_postal_address_json = json.dumps(structured_postal_address) diff --git a/yoti_python_sdk/tests/test_age_verification.py b/yoti_python_sdk/tests/test_age_verification.py new file mode 100644 index 00000000..020205c3 --- /dev/null +++ b/yoti_python_sdk/tests/test_age_verification.py @@ -0,0 +1,41 @@ +from yoti_python_sdk.attribute import Attribute +from yoti_python_sdk.age_verification import AgeVerification +from yoti_python_sdk.exceptions import MalformedAgeVerificationException +from yoti_python_sdk import config +import pytest + + +@pytest.fixture(scope="module") +def age_over_attribute(): + return Attribute(config.ATTRIBUTE_AGE_OVER + "18", "true", None) + + +@pytest.fixture(scope="module") +def age_under_attribute(): + return Attribute(config.ATTRIBUTE_AGE_UNDER + "18", "false", None) + + +@pytest.mark.parametrize( + "age_verification_name", + [":age_over:18", "age_over:18:", "ageover:18", "age_over:", "age_over::18"], +) +def test_malformed_age_verification_attributes(age_verification_name): + with pytest.raises(MalformedAgeVerificationException): + attribute = Attribute(age_verification_name, "true", None) + age_verification_name = AgeVerification(attribute) + + +def test_create_age_verification_from_age_over_attribute(age_over_attribute): + age_verification = AgeVerification(age_over_attribute) + + assert age_verification.age == 18 + assert age_verification.check_type in config.ATTRIBUTE_AGE_OVER + assert age_verification.result is True + + +def test_create_age_verification_from_age_under_attribute(age_under_attribute): + age_verification = AgeVerification(age_under_attribute) + + assert age_verification.age == 18 + assert age_verification.check_type in config.ATTRIBUTE_AGE_UNDER + assert age_verification.result is False diff --git a/yoti_python_sdk/tests/test_aml.py b/yoti_python_sdk/tests/test_aml.py index 77cf1947..075cc948 100644 --- a/yoti_python_sdk/tests/test_aml.py +++ b/yoti_python_sdk/tests/test_aml.py @@ -20,7 +20,7 @@ def test_getting_aml_result_with_invalid_format_response(): with pytest.raises(RuntimeError) as exc: aml.AmlResult(INVALID_FORMAT_RESPONSE) expected_error = "Could not parse AML result from response" - assert expected_error in str(exc) + assert expected_error in str(exc.value) def test_getting_aml_result_with_missing_value(): diff --git a/yoti_python_sdk/tests/test_attribute_parser.py b/yoti_python_sdk/tests/test_attribute_parser.py index 071ac175..9caaab91 100644 --- a/yoti_python_sdk/tests/test_attribute_parser.py +++ b/yoti_python_sdk/tests/test_attribute_parser.py @@ -19,9 +19,9 @@ def proto(): @pytest.mark.parametrize( "content_type, expected_value", [ - (proto().CT_STRING, STRING_VALUE), - (proto().CT_DATE, STRING_VALUE), - (proto().CT_INT, INT_VALUE), + (protobuf.Protobuf.CT_STRING, STRING_VALUE), + (protobuf.Protobuf.CT_DATE, STRING_VALUE), + (protobuf.Protobuf.CT_INT, INT_VALUE), ], ) def test_attribute_parser_values_based_on_content_type(content_type, expected_value): @@ -48,13 +48,13 @@ def test_attribute_parser_values_based_on_other_content_types(proto): logger.propagate = True -def test_png_image_value_based_on_content_type(): - result = attribute_parser.value_based_on_content_type(BYTE_VALUE, proto().CT_PNG) +def test_png_image_value_based_on_content_type(proto): + result = attribute_parser.value_based_on_content_type(BYTE_VALUE, proto.CT_PNG) assert result.data == BYTE_VALUE assert result.content_type == "png" -def test_jpeg_image_value_based_on_content_type(): - result = attribute_parser.value_based_on_content_type(BYTE_VALUE, proto().CT_JPEG) +def test_jpeg_image_value_based_on_content_type(proto): + result = attribute_parser.value_based_on_content_type(BYTE_VALUE, proto.CT_JPEG) assert result.data == BYTE_VALUE assert result.content_type == "jpeg" diff --git a/yoti_python_sdk/tests/test_client.py b/yoti_python_sdk/tests/test_client.py index 8fd91c5b..52ff9115 100644 --- a/yoti_python_sdk/tests/test_client.py +++ b/yoti_python_sdk/tests/test_client.py @@ -1,33 +1,19 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import json -from datetime import datetime -from os import environ - -import pytest -from cryptography.fernet import base64 -from past.builtins import basestring - -from yoti_python_sdk import config - try: from unittest import mock except ImportError: import mock -import yoti_python_sdk +from datetime import datetime +from os import environ +from past.builtins import basestring + +from yoti_python_sdk import config from yoti_python_sdk import YOTI_API_ENDPOINT from yoti_python_sdk import Client from yoti_python_sdk import aml -from yoti_python_sdk.config import ( - JSON_CONTENT_TYPE, - X_YOTI_AUTH_KEY, - X_YOTI_AUTH_DIGEST, - X_YOTI_SDK, - SDK_IDENTIFIER, - X_YOTI_SDK_VERSION, -) from yoti_python_sdk.client import NO_KEY_FILE_SPECIFIED_ERROR from yoti_python_sdk.activity_details import ActivityDetails from yoti_python_sdk.tests.conftest import YOTI_CLIENT_SDK_ID, PEM_FILE_PATH @@ -41,6 +27,17 @@ mocked_timestamp, mocked_uuid4, ) +from yoti_python_sdk.config import ( + JSON_CONTENT_TYPE, + X_YOTI_AUTH_KEY, + X_YOTI_AUTH_DIGEST, + X_YOTI_SDK, + SDK_IDENTIFIER, + X_YOTI_SDK_VERSION, +) + +import pytest +import yoti_python_sdk INVALID_KEY_FILE_PATH = "/invalid/path/to/file.txt" INVALID_KEY_FILES = ( @@ -119,8 +116,8 @@ def test_creating_client_instance_with_invalid_key_file_arg(key_file): with pytest.raises(RuntimeError) as exc: Client(YOTI_CLIENT_SDK_ID, key_file) expected_error = "Could not read private key file" - assert expected_error in str(exc) - assert str(key_file) in str(exc) + assert expected_error in str(exc.value) + assert str(key_file) in str(exc.value) @pytest.mark.parametrize("key_file", INVALID_KEY_FILES) @@ -130,9 +127,9 @@ def test_creating_client_instance_with_invalid_key_file_env(key_file): Client(YOTI_CLIENT_SDK_ID) expected_error = "Could not read private key file" expected_error_source = "specified by the YOTI_KEY_FILE_PATH env variable" - assert expected_error in str(exc) - assert expected_error_source in str(exc) - assert str(key_file) in str(exc) + assert expected_error in str(exc.value) + assert expected_error_source in str(exc.value) + assert str(key_file) in str(exc.value) def test_creating_client_instance_with_invalid_key_file_env_but_valid_key_file_arg(): @@ -145,11 +142,13 @@ def test_creating_client_instance_with_valid_key_file_env_but_invalid_key_file_a with pytest.raises(RuntimeError) as exc: Client(YOTI_CLIENT_SDK_ID, INVALID_KEY_FILE_PATH) expected_error = "Could not read private key file" - assert expected_error in str(exc) - assert str(INVALID_KEY_FILE_PATH) in str(exc) + assert expected_error in str(exc.value) + assert str(INVALID_KEY_FILE_PATH) in str(exc.value) -@mock.patch("requests.get", side_effect=mocked_requests_get) +@mock.patch( + "yoti_python_sdk.http.SignedRequest.execute", side_effect=mocked_requests_get +) @mock.patch("time.time", side_effect=mocked_timestamp) @mock.patch("uuid.uuid4", side_effect=mocked_uuid4) def test_requesting_activity_details_with_correct_data( @@ -163,9 +162,6 @@ def test_requesting_activity_details_with_correct_data( ): activity_details = client.get_activity_details(encrypted_request_token) - mock_get.assert_called_once_with( - url=expected_activity_details_url, headers=expected_get_headers, verify=yoti_python_sdk.YOTI_API_VERIFY_SSL - ) assert isinstance(activity_details, ActivityDetails) assert ( @@ -199,7 +195,10 @@ def test_requesting_activity_details_with_correct_data( assert "" in [anchor.value for anchor in phone_number.anchors] -@mock.patch("requests.get", side_effect=mocked_requests_get_null_profile) +@mock.patch( + "yoti_python_sdk.http.SignedRequest.execute", + side_effect=mocked_requests_get_null_profile, +) @mock.patch("time.time", side_effect=mocked_timestamp) @mock.patch("uuid.uuid4", side_effect=mocked_uuid4) def test_requesting_activity_details_with_null_profile( @@ -213,9 +212,6 @@ def test_requesting_activity_details_with_null_profile( ): activity_details = client.get_activity_details(encrypted_request_token) - mock_get.assert_called_once_with( - url=expected_activity_details_url, headers=expected_get_headers, verify=yoti_python_sdk.YOTI_API_VERIFY_SSL - ) assert ( activity_details.user_id == "ijH4kkqMKTG0FSNUgQIvd2Z3Nx1j8f5RjVQMyoKOvO/hkv43Ik+t6d6mGfP2tdrN" @@ -228,7 +224,10 @@ def test_requesting_activity_details_with_null_profile( assert isinstance(activity_details, ActivityDetails) -@mock.patch("requests.get", side_effect=mocked_requests_get_empty_profile) +@mock.patch( + "yoti_python_sdk.http.SignedRequest.execute", + side_effect=mocked_requests_get_empty_profile, +) @mock.patch("time.time", side_effect=mocked_timestamp) @mock.patch("uuid.uuid4", side_effect=mocked_uuid4) def test_requesting_activity_details_with_empty_profile( @@ -242,9 +241,6 @@ def test_requesting_activity_details_with_empty_profile( ): activity_details = client.get_activity_details(encrypted_request_token) - mock_get.assert_called_once_with( - url=expected_activity_details_url, headers=expected_get_headers, verify=yoti_python_sdk.YOTI_API_VERIFY_SSL - ) assert ( activity_details.user_id == "ijH4kkqMKTG0FSNUgQIvd2Z3Nx1j8f5RjVQMyoKOvO/hkv43Ik+t6d6mGfP2tdrN" @@ -257,7 +253,10 @@ def test_requesting_activity_details_with_empty_profile( assert isinstance(activity_details, ActivityDetails) -@mock.patch("requests.get", side_effect=mocked_requests_get_missing_profile) +@mock.patch( + "yoti_python_sdk.http.SignedRequest.execute", + side_effect=mocked_requests_get_missing_profile, +) @mock.patch("time.time", side_effect=mocked_timestamp) @mock.patch("uuid.uuid4", side_effect=mocked_uuid4) def test_requesting_activity_details_with_missing_profile( @@ -271,9 +270,6 @@ def test_requesting_activity_details_with_missing_profile( ): activity_details = client.get_activity_details(encrypted_request_token) - mock_get.assert_called_once_with( - url=expected_activity_details_url, headers=expected_get_headers, verify=yoti_python_sdk.YOTI_API_VERIFY_SSL - ) assert ( activity_details.user_id == "ijH4kkqMKTG0FSNUgQIvd2Z3Nx1j8f5RjVQMyoKOvO/hkv43Ik+t6d6mGfP2tdrN" @@ -286,48 +282,10 @@ def test_requesting_activity_details_with_missing_profile( assert isinstance(activity_details, ActivityDetails) -@mock.patch("requests.get", side_effect=mocked_requests_get) -@mock.patch("time.time", side_effect=mocked_timestamp) -@mock.patch("uuid.uuid4", side_effect=mocked_uuid4) -def test_creating_request_with_unsupported_http_method( - mock_uuid4, mock_time, mock_get, client, expected_get_headers -): - with pytest.raises(ValueError): - client._Client__create_request( - http_method="UNSUPPORTED_METHOD", path=YOTI_API_ENDPOINT, content=None - ) - - -@mock.patch("requests.get", side_effect=mocked_requests_get) -@mock.patch("uuid.uuid4", side_effect=mocked_uuid4) -@mock.patch("time.time", side_effect=mocked_timestamp) -def test_creating_request_with_supported_http_method( - mock_uuid4, mock_time, mock_get, client, expected_get_headers -): - client._Client__create_request( - http_method="GET", path=YOTI_API_ENDPOINT, content=None - ) - - -@mock.patch("requests.get", side_effect=mocked_requests_get) -@mock.patch("uuid.uuid4", side_effect=mocked_uuid4) -@mock.patch("time.time", side_effect=mocked_timestamp) -def test_creating_request_content_is_added( - mock_uuid4, mock_time, mock_get, client, expected_get_headers -): - content = '{"Content"}' - content_bytes = content.encode() - request = client._Client__create_request( - http_method="GET", path=YOTI_API_ENDPOINT, content=content_bytes - ) - - b64encoded = base64.b64encode(content_bytes) - b64ascii = b64encoded.decode("ascii") - - assert request.endswith("&" + b64ascii) - - -@mock.patch("requests.post", side_effect=mocked_requests_post_aml_profile) +@mock.patch( + "yoti_python_sdk.http.SignedRequest.execute", + side_effect=mocked_requests_post_aml_profile, +) @mock.patch("time.time", side_effect=mocked_timestamp) @mock.patch("uuid.uuid4", side_effect=mocked_uuid4) def test_perform_aml_check_details_with_correct_data( @@ -341,12 +299,7 @@ def test_perform_aml_check_details_with_correct_data( aml_result = client.perform_aml_check(aml_profile) - aml_profile_json = json.dumps(aml_profile.__dict__, sort_keys=True) - aml_profile_bytes = aml_profile_json.encode() - - mock_post.assert_called_once_with( - url=expected_aml_url, headers=expected_post_headers, data=aml_profile_bytes, verify=yoti_python_sdk.YOTI_API_VERIFY_SSL - ) + mock_post.assert_called_once_with() assert isinstance(aml_result, aml.AmlResult) assert isinstance(aml_result.on_watch_list, bool) @@ -360,10 +313,13 @@ def test_perform_aml_check_with_null_profile(client): with pytest.raises(TypeError) as exc: client.perform_aml_check(aml_profile) expected_error = "aml_profile not set" - assert expected_error in str(exc) + assert expected_error in str(exc.value) -@mock.patch("requests.post", side_effect=mocked_requests_post_aml_profile_not_found) +@mock.patch( + "yoti_python_sdk.http.SignedRequest.execute", + side_effect=mocked_requests_post_aml_profile_not_found, +) @mock.patch("time.time", side_effect=mocked_timestamp) @mock.patch("uuid.uuid4", side_effect=mocked_uuid4) def test_perform_aml_check_with_unsuccessful_call( @@ -378,4 +334,4 @@ def test_perform_aml_check_with_unsuccessful_call( with pytest.raises(RuntimeError) as exc: client.perform_aml_check(aml_profile) expected_error = "Unsuccessful Yoti API call:" - assert expected_error in str(exc) + assert expected_error in str(exc.value) diff --git a/yoti_python_sdk/tests/test_http.py b/yoti_python_sdk/tests/test_http.py new file mode 100644 index 00000000..2dde002a --- /dev/null +++ b/yoti_python_sdk/tests/test_http.py @@ -0,0 +1,298 @@ +try: + from unittest import mock +except ImportError: + import mock + +from yoti_python_sdk.http import SignedRequest, DefaultRequestHandler +from yoti_python_sdk.crypto import Crypto +from yoti_python_sdk.tests import conftest + +from yoti_python_sdk.tests.mocks import mocked_requests_get + +import pytest +import json + + +@pytest.fixture(scope="module") +def json_payload(): + payload = {"Hello": "World"} + return json.dumps(payload).encode() + + +@pytest.fixture(scope="module") +def valid_base_url(): + return "https://localhost:8443/api/v1" + + +@pytest.fixture(scope="module") +def valid_endpoint(): + return "/profile" + + +@pytest.fixture(scope="module") +def expected_request_headers(): + return ["X-Yoti-Auth-Key", "X-Yoti-Auth-Digest", "X-Yoti-SDK", "X-Yoti-SDK-Version"] + + +def test_create_signed_request_get_required_properties( + valid_base_url, valid_endpoint, expected_request_headers +): + http_method = "GET" + + signed_request = ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .with_http_method(http_method) + .build() + ) + + assert (valid_base_url + valid_endpoint) in signed_request.url + assert signed_request.method == http_method + assert signed_request.data is None + + header_keys = signed_request.headers.keys() + for header in expected_request_headers: + assert header in header_keys + + +def test_create_signed_request_missing_pem_file(valid_base_url, valid_endpoint): + http_method = "GET" + + with pytest.raises(ValueError) as ex: + ( + SignedRequest.builder() + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .with_http_method(http_method) + .build() + ) + + assert "PEM file" in str(ex.value) + + +def test_create_signed_request_missing_base_url(valid_endpoint): + http_method = "GET" + + with pytest.raises(ValueError) as ex: + ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_endpoint(valid_endpoint) + .with_http_method(http_method) + .build() + ) + + assert "Base URL" in str(ex.value) + + +def test_create_signed_request_missing_endpoint(valid_base_url): + http_method = "GET" + + with pytest.raises(ValueError) as ex: + ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_base_url(valid_base_url) + .with_http_method(http_method) + .build() + ) + + assert "Endpoint" in str(ex.value) + + +def test_create_signed_request_missing_http_method(valid_base_url, valid_endpoint): + with pytest.raises(ValueError) as ex: + ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .build() + ) + + assert "HTTP method" in str(ex.value) + + +def test_create_signed_request_with_invalid_http_method(): + + with pytest.raises(ValueError) as ex: + SignedRequest.builder().with_http_method("INVALID").build() + + assert str(ex.value) == "INVALID is an unsupported HTTP method" + + +def test_create_signed_request_with_payload( + valid_base_url, valid_endpoint, json_payload +): + http_method = "POST" + + signed_request = ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .with_http_method(http_method) + .with_payload(json_payload) + .build() + ) + + assert signed_request.data == json_payload + + +def test_create_signed_request_with_header(valid_base_url, valid_endpoint): + http_method = "POST" + + signed_request = ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .with_http_method(http_method) + .with_header("My-Http-Header", "someValue") + .build() + ) + + headers = signed_request.headers + + assert "My-Http-Header" in headers + assert headers["My-Http-Header"] == "someValue" + + +def test_create_signed_request_with_query_param(valid_base_url, valid_endpoint): + http_method = "POST" + + signed_request = ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .with_http_method(http_method) + .with_param("sdkId", "mySdkId") + .build() + ) + + assert "sdkId=mySdkId" in signed_request.url + + +def test_create_signed_request_with_crypto_object(valid_base_url, valid_endpoint): + http_method = "GET" + crypto = Crypto.read_pem_file(conftest.PEM_FILE_PATH) + + ( + SignedRequest.builder() + .with_pem_file(crypto) + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .with_http_method(http_method) + .build() + ) + + +def test_create_signed_request_with_get_convenience_method( + valid_base_url, valid_endpoint +): + ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .with_get() + .build() + ) + + +def test_create_signed_request_with_post_convenience_method( + valid_base_url, valid_endpoint, json_payload +): + ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .with_post() + .with_payload(json_payload) + .build() + ) + + +@mock.patch("requests.request", side_effect=mocked_requests_get) +def test_execute_signed_request(valid_base_url, valid_endpoint): + signed_request = ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .with_post() + .build() + ) + + response = signed_request.execute() + + assert response.status_code == 200 + assert response.text is not None + + +def test_signed_request_with_custom_request_handler( + valid_base_url, valid_endpoint, mock_request_handler +): + signed_request = ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .with_request_handler(mock_request_handler) + .with_post() + .build() + ) + + response = signed_request.execute() + + assert response.status_code == 200 + assert response.text is not None + + +@mock.patch("requests.request", side_effect=mocked_requests_get) +def test_signed_request_passing_request_handler_as_none(valid_base_url, valid_endpoint): + signed_request = ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .with_request_handler(None) + .with_post() + .build() + ) + + response = signed_request.execute() + + assert response.status_code == 200 + assert response.text is not None + + +def test_signed_request_should_throw_error_when_request_handler_wrong_type( + valid_base_url, valid_endpoint +): + + with pytest.raises(TypeError) as ex: + ( + SignedRequest.builder() + .with_pem_file(conftest.PEM_FILE_PATH) + .with_base_url(valid_base_url) + .with_endpoint(valid_endpoint) + .with_request_handler("myRequestHandler") + .with_post() + .build() + ) + + assert "yoti_python_sdk.http.RequestHandler" in str(ex.value) + + +def test_default_request_handler_should_raise_exception_when_passed_non_signed_request( + valid_base_url, valid_endpoint +): + with pytest.raises(TypeError) as ex: + DefaultRequestHandler.execute("someBadValue") + + assert "RequestHandler expects instance of SignedRequest" == str(ex.value) diff --git a/yoti_python_sdk/tests/test_profile.py b/yoti_python_sdk/tests/test_profile.py index 78be7e59..8e59d367 100644 --- a/yoti_python_sdk/tests/test_profile.py +++ b/yoti_python_sdk/tests/test_profile.py @@ -8,6 +8,7 @@ from yoti_python_sdk import config from yoti_python_sdk.attribute import Attribute from yoti_python_sdk.profile import Profile, ApplicationProfile +from yoti_python_sdk.age_verification import AgeVerification from yoti_python_sdk.protobuf.protobuf import Protobuf from yoti_python_sdk.tests import attribute_fixture_parser, image_helper from yoti_python_sdk.tests.protobuf_attribute import ProtobufAttribute @@ -602,42 +603,51 @@ def test_get_document_details_india(): def test_create_application_profile_with_name(): attribute_list = create_single_attribute_list( name=config.ATTRIBUTE_APPLICATION_NAME, - value="yoti-sdk-test", + value="yoti-sdk-test".encode(), anchors=None, - content_type=Protobuf.CT_STRING + content_type=Protobuf.CT_STRING, ) app_profile = ApplicationProfile(attribute_list) - assert (app_profile.get_attribute(config.ATTRIBUTE_APPLICATION_NAME) == app_profile.application_name) + assert ( + app_profile.get_attribute(config.ATTRIBUTE_APPLICATION_NAME) + == app_profile.application_name + ) assert isinstance(app_profile, ApplicationProfile) def test_create_application_profile_with_url(): attribute_list = create_single_attribute_list( name=config.ATTRIBUTE_APPLICATION_URL, - value="https://yoti.com", + value="https://yoti.com".encode(), anchors=None, - content_type=Protobuf.CT_STRING + content_type=Protobuf.CT_STRING, ) app_profile = ApplicationProfile(attribute_list) - assert (app_profile.get_attribute(config.ATTRIBUTE_APPLICATION_URL) == app_profile.application_url) + assert ( + app_profile.get_attribute(config.ATTRIBUTE_APPLICATION_URL) + == app_profile.application_url + ) assert isinstance(app_profile, ApplicationProfile) def test_create_application_profile_with_receipt_bgcolor(): attribute_list = create_single_attribute_list( name=config.ATTRIBUTE_APPLICATION_RECEIPT_BGCOLOR, - value="#FFFFFF", + value="#FFFFFF".encode(), anchors=None, - content_type=Protobuf.CT_STRING + content_type=Protobuf.CT_STRING, ) app_profile = ApplicationProfile(attribute_list) - assert (app_profile.get_attribute(config.ATTRIBUTE_APPLICATION_RECEIPT_BGCOLOR) == app_profile.application_receipt_bg_color) + assert ( + app_profile.get_attribute(config.ATTRIBUTE_APPLICATION_RECEIPT_BGCOLOR) + == app_profile.application_receipt_bg_color + ) assert isinstance(app_profile, ApplicationProfile) @@ -648,7 +658,70 @@ def test_create_application_profile_with_logo(): app_logo = app_profile.application_logo assert isinstance(app_logo.value, Image) - assert (app_profile.get_attribute(config.ATTRIBUTE_APPLICATION_LOGO) == app_profile.application_logo) + assert ( + app_profile.get_attribute(config.ATTRIBUTE_APPLICATION_LOGO) + == app_profile.application_logo + ) assert isinstance(app_profile, ApplicationProfile) +@pytest.mark.parametrize( + "attribute_value,expected_age_over,expected_value", + [("true", 18, True), ("true", 21, True), ("false", 18, False)], +) +def test_get_age_over_verification(attribute_value, expected_age_over, expected_value): + attribute_list = create_single_attribute_list( + name=config.ATTRIBUTE_AGE_OVER + str(expected_age_over), + value=attribute_value.encode(), + anchors=None, + content_type=Protobuf.CT_STRING, + ) + + human_profile = Profile(attribute_list) + print(human_profile.attributes) + + age_verifications = human_profile.get_age_verifications() + age_verification = human_profile.find_age_over_verification(expected_age_over) + + assert len(age_verifications) == 1 + assert isinstance(age_verification, AgeVerification) + assert age_verification.result is expected_value + + +@pytest.mark.parametrize( + "attribute_value,expected_age_under,expected_value", + [("true", 18, True), ("true", 21, True), ("false", 18, False)], +) +def test_get_age_under_verification( + attribute_value, expected_age_under, expected_value +): + attribute_list = create_single_attribute_list( + name=config.ATTRIBUTE_AGE_UNDER + str(expected_age_under), + value=attribute_value.encode(), + anchors=None, + content_type=Protobuf.CT_STRING, + ) + + human_profile = Profile(attribute_list) + print(human_profile.attributes) + + age_verifications = human_profile.get_age_verifications() + age_verification = human_profile.find_age_under_verification(expected_age_under) + + assert len(age_verifications) == 1 + assert isinstance(age_verification, AgeVerification) + assert age_verification.result is expected_value + + +def test_get_age_verifications(): + attribute_list = create_single_attribute_list( + name=config.ATTRIBUTE_AGE_UNDER + str(18), + value="true".encode(), + anchors=None, + content_type=Protobuf.CT_STRING, + ) + + human_profile = Profile(attribute_list) + age_verifications = human_profile.get_age_verifications() + + assert len(age_verifications) == 1 diff --git a/yoti_python_sdk/utils.py b/yoti_python_sdk/utils.py new file mode 100644 index 00000000..773b4112 --- /dev/null +++ b/yoti_python_sdk/utils.py @@ -0,0 +1,20 @@ +import uuid +import time + + +def create_nonce(): + """ + Create and return a nonce + \ + :return: the nonce + """ + return uuid.uuid4() + + +def create_timestamp(): + """ + Create and return a timestamp + + :return: the timestamp as a int + """ + return int(time.time() * 1000) diff --git a/yoti_python_sdk/version.py b/yoti_python_sdk/version.py index 8b02bf07..06ae705b 100644 --- a/yoti_python_sdk/version.py +++ b/yoti_python_sdk/version.py @@ -1,2 +1,2 @@ # -*- coding: utf-8 -*- -__version__ = "2.8.2" +__version__ = "2.9.0"