diff --git a/CHANGELOG.md b/CHANGELOG.md index cca166b..0ab2cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,24 @@ **Implemented enhancements:** +- Python SDK PSD2 [\#123](https://github.com/bunq/sdk_python/pull/123) ([angelomelonas](https://github.com/angelomelonas)) - Python SDK Refactor [\#117](https://github.com/bunq/sdk_python/pull/117) ([angelomelonas](https://github.com/angelomelonas)) +**Fixed bugs:** + +- Fix notification adapter and test. [\#126](https://github.com/bunq/sdk_python/pull/126) ([NickvandeGroes](https://github.com/NickvandeGroes)) + +**Closed issues:** + +- Dependencies severely out of date \(and vulnerable: CVEs\) [\#121](https://github.com/bunq/sdk_python/issues/121) +- Typo in EXCEPTIONS.md [\#110](https://github.com/bunq/sdk_python/issues/110) + +**Merged pull requests:** + +- feature/fix\_typo: fix typo. [\#129](https://github.com/bunq/sdk_python/pull/129) ([angelomelonas](https://github.com/angelomelonas)) +- Feature/dependency upgrades [\#128](https://github.com/bunq/sdk_python/pull/128) ([angelomelonas](https://github.com/angelomelonas)) +- Add internal NotificationFilters [\#127](https://github.com/bunq/sdk_python/pull/127) ([angelomelonas](https://github.com/angelomelonas)) + ## [1.10.16](https://github.com/bunq/sdk_python/tree/1.10.16) (2019-06-17) [Full Changelog](https://github.com/bunq/sdk_python/compare/1.10.2...1.10.16) @@ -132,10 +148,8 @@ **Merged pull requests:** -- Regenerate code for release [\#74](https://github.com/bunq/sdk_python/pull/74) ([OGKevin](https://github.com/OGKevin)) - Regenerated code to add object types. \(bunq/sdk\_python\#53\) [\#70](https://github.com/bunq/sdk_python/pull/70) ([OGKevin](https://github.com/OGKevin)) - Bunq/sdk python\#67 add missing token qr id field [\#69](https://github.com/bunq/sdk_python/pull/69) ([OGKevin](https://github.com/OGKevin)) -- Added missing id field to mastercard action. \(bunq/sdk\_python\#54\) [\#66](https://github.com/bunq/sdk_python/pull/66) ([OGKevin](https://github.com/OGKevin)) - Feature/bunq/sdk python\#59 add response id to request error [\#64](https://github.com/bunq/sdk_python/pull/64) ([OGKevin](https://github.com/OGKevin)) - Configure Zappr [\#63](https://github.com/bunq/sdk_python/pull/63) ([OGKevin](https://github.com/OGKevin)) - \(bunq/sdk\_python\#60\) improve issue and pr template [\#61](https://github.com/bunq/sdk_python/pull/61) ([OGKevin](https://github.com/OGKevin)) @@ -159,6 +173,8 @@ **Merged pull requests:** +- Regenerate code for release [\#74](https://github.com/bunq/sdk_python/pull/74) ([OGKevin](https://github.com/OGKevin)) +- Added missing id field to mastercard action. \(bunq/sdk\_python\#54\) [\#66](https://github.com/bunq/sdk_python/pull/66) ([OGKevin](https://github.com/OGKevin)) - Feature/make sure headers are correctly cased bunq/sdk python\#51 [\#57](https://github.com/bunq/sdk_python/pull/57) ([OGKevin](https://github.com/OGKevin)) - Feature/improve decoder bunq/sdk python\#42 [\#56](https://github.com/bunq/sdk_python/pull/56) ([OGKevin](https://github.com/OGKevin)) - Renamed camelCase methods. \(bunq/sdk\_python\#45\) [\#48](https://github.com/bunq/sdk_python/pull/48) ([OGKevin](https://github.com/OGKevin)) diff --git a/README.md b/README.md index dc61aea..c8ae003 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ This SDK is in **beta**. We cannot guarantee constant availability or stability. Thanks to your feedback we will make improvements on it. ## Installation -``pip install bunq_sdk --upgrade`` + pip install bunq_sdk --upgrade ## Usage @@ -30,19 +30,37 @@ In order to start making calls with the bunq API, you must first register your A and create a session. In the SDKs, we group these actions and call it "creating an API context". The context can be created by using the following code snippet: -``` -apiContext = context.ApiContext(ENVIRONMENT_TYPE, API_KEY, - DEVICE_DESCRIPTION); -apiContext.save(API_CONTEXT_FILE_PATH) -context.BunqContext.loadApiContext(apiContext) -``` -This code snippet, except for `context.BunqContext.loadApiContext(apiContext)` should be called once per API key. + apiContext = ApiContext.create(ENVIRONMENT_TYPE, API_KEY, DEVICE_DESCRIPTION) + apiContext.save(API_CONTEXT_FILE_PATH) + + +**Please note**: initialising your application is a heavy task and it is recommended to do it only once per device. + + apiContext = ApiContext.restore(self.API_CONTEXT_FILE_PATH) + BunqContext.loadApiContext(apiContext) + +After saving the context, you can restore it at any time: #### Example See [`tinker/setup_context`](https://github.com/bunq/tinker_python/blob/2182b8be276fda921657ad22cfe0b8b48a585ccf/tinker/libs/bunq_lib.py#L44-L59) +#### PSD2 +It is possible to create an ApiContext as PSD2 Service Provider. Although this might seem a complex task, we wrote some +helper implementations to get you started. You need to create a certificate and private key to get you started. +Our sandbox environment currently accepts all certificates, if these criteria are met: + +- Up to 64 characters +- PISP and/or AISP used in the end. + +Make sure you have your unique eIDAS certificate number and certificates ready when you want to perform these tasks on +our production environment. + +Creating a PSD2 context is very easy: + + apiContext = ApiContext.create_for_psd2(ENVIRONMENT_TYPE, CERTIFICATE, PRIVATE_KEY, CERTIFICATE_CHAIN, DEVICE_DESCRIPTION) + #### Safety considerations The file storing the context details (i.e. `bunq.conf`) is a key to your account. Anyone having access to it is able to perform any Public API actions with your account. Therefore, we recommend @@ -62,14 +80,11 @@ Creating objects through the API requires an `ApiContext`, a `requestMap` and id dependencies (such as User ID required for accessing a Monetary Account). Optionally, custom headers can be passed to requests. - -``` -payment_id = endpoint.Payment.create( - amount=Amount(amount_string, self._CURRENCY_EURL), - counterparty_alias=Pointer(self._POINTER_TYPE_EMAIL, recipient), - description=description + payment_id = endpoint.Payment.create( + amount=Amount(amount_string, self._CURRENCY_EURL), + counterparty_alias=Pointer(self._POINTER_TYPE_EMAIL, recipient), + description=description ) -``` ##### Example See [`tinker/make_payment`](https://github.com/bunq/tinker_python/blob/2182b8be276fda921657ad22cfe0b8b48a585ccf/tinker/libs/bunq_lib.py#L140-L151) @@ -81,11 +96,9 @@ UUID) Optionally, custom headers can be passed to requests. This type of calls always returns a model. -``` -monetary_account = generated.MonetaryAccountBank.get( - _MONETARY_ACCOUNT_ITEM_ID -) -``` + monetary_account = generated.MonetaryAccountBank.get( + _MONETARY_ACCOUNT_ITEM_ID + ) ##### Example See [`tinker/list_all_payment`](https://github.com/bunq/tinker_python/blob/2182b8be276fda921657ad22cfe0b8b48a585ccf/tinker/libs/bunq_lib.py#L85-L103) @@ -94,12 +107,10 @@ See [`tinker/list_all_payment`](https://github.com/bunq/tinker_python/blob/2182b Updating objects through the API goes the same way as creating objects, except that also the object to update identifier (ID or UUID) is needed. -``` -endpoint.Card.update( - card_id=int(card_id), - monetary_account_current_id=int(account_id) - ) -``` + endpoint.Card.update( + card_id=int(card_id), + monetary_account_current_id=int(account_id) + ) ##### Example See [`tinker/update_card`](https://github.com/bunq/tinker_python/blob/2182b8be276fda921657ad22cfe0b8b48a585ccf/tinker/libs/bunq_lib.py#L167-L174) @@ -109,20 +120,15 @@ Deleting objects through the API requires an `ApiContext`, identifiers of all de accessing a Monetary Account), and the identifier of the object to delete (ID or UUID) Optionally, custom headers can be passed to requests. -``` -Session.delete(self._SESSION_ID) -``` + Session.delete(self._SESSION_ID) ##### Example - #### Listing objects Listing objects through the API requires an `ApiContext` and identifiers of all dependencies (such as User ID required for accessing a Monetary Account). Optionally, custom headers can be passed to requests. -``` -users = generated.User.list(api_context) -``` + users = endpoint.User.list(api_context) ##### Example See [`UserListExample.py`](./examples/user_list_example.py) @@ -133,8 +139,8 @@ To get an indication on how the SDK works you can use the python tinker which is ## Running Tests Information regarding the test cases can be found in the [README.md](./tests/README.md) -located in [test](/tests) +located in [test](/tests). ## Exceptions The SDK can raise multiple exceptions. For an overview of these exceptions please -take a look at [EXCEPTIONS.md](./bunq/sdk/exception/EXCEPTIONS.md) +take a look at [EXCEPTIONS.md](./bunq/sdk/exception/EXCEPTIONS.md). diff --git a/VERSION b/VERSION index f88cf52..da38e07 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.13.0 \ No newline at end of file +1.13.1 \ No newline at end of file diff --git a/bunq/sdk/context/api_context.py b/bunq/sdk/context/api_context.py index bdd3351..39ebe71 100644 --- a/bunq/sdk/context/api_context.py +++ b/bunq/sdk/context/api_context.py @@ -11,7 +11,9 @@ from bunq.sdk.context.session_context import SessionContext from bunq.sdk.exception.bunq_exception import BunqException from bunq.sdk.json import converter +from bunq.sdk.model.core.payment_service_provider_credential_internal import PaymentServiceProviderCredentialInternal from bunq.sdk.model.generated import endpoint +from bunq.sdk.model.generated.endpoint import UserCredentialPasswordIp, UserPaymentServiceProvider from bunq.sdk.security import security if typing.TYPE_CHECKING: @@ -21,9 +23,9 @@ class ApiContext: """ :type _environment_type: ApiEnvironmentType - :type _api_key: str - :type _session_context: SessionContext - :type _installation_context: InstallationContext + :type _api_key: str|None + :type _session_context: SessionContext|None + :type _installation_context: InstallationContext|None :type _proxy_url: str|None """ @@ -42,28 +44,56 @@ class ApiContext: def __init__(self, environment_type: ApiEnvironmentType, - api_key: str, - device_description: str, - permitted_ips: List[str] = None, proxy_url: List[str] = None) -> None: - if permitted_ips is None: - permitted_ips = [] - self._environment_type = environment_type - self._api_key = api_key + self._proxy_url = proxy_url + self._api_key = None self._installation_context = None self._session_context = None - self._proxy_url = proxy_url - self._initialize(device_description, permitted_ips) - def _initialize(self, - device_description: str, - permitted_ips: List[str]) -> None: - self._initialize_installation() - self._register_device(device_description, permitted_ips) - self._initialize_session() + @classmethod + def create(cls, + environment_type: ApiEnvironmentType, + api_key: str, + description: str, + all_permitted_ip: List[str] = None, + proxy_url: List[str] = None) -> ApiContext: + api_context = cls(environment_type, proxy_url) + + api_context._api_key = api_key + + api_context.__initialize_installation() + api_context.__register_device(description, all_permitted_ip) + api_context.__initialize_session() + + return api_context + + @classmethod + def create_for_psd2(cls, + environment_type: ApiEnvironmentType, + certificate: str, + private_key: str, + all_chain_certificate: List[str], + description: str, + all_permitted_ip: List[str] = None, + proxy_url: List[str] = None) -> ApiContext: + api_context = cls(environment_type, proxy_url) + + api_context.__initialize_installation() + + service_provider_credential = api_context.__initialize_psd2_credential( + certificate, + private_key, + all_chain_certificate) + + api_context._api_key = service_provider_credential.token_value + + api_context.__register_device(description, all_permitted_ip) + api_context.__initialize_session_for_psd2(service_provider_credential) + + return api_context - def _initialize_installation(self) -> None: + def __initialize_installation(self) -> None: from bunq.sdk.model.core.installation import Installation private_key_client = security.generate_rsa_private_key() @@ -83,9 +113,28 @@ def _initialize_installation(self) -> None: public_key_server ) - def _register_device(self, - device_description: str, - permitted_ips: List[str]) -> None: + def __initialize_psd2_credential(self, + certificate: str, + private_key: str, + all_chain_certificate: List[str], ) -> UserCredentialPasswordIp: + session_token = self.installation_context.token + client_key_pair = self.installation_context.private_key_client + + string_to_sign = security.public_key_to_string(client_key_pair.publickey()) + "\n" + session_token + encoded_signature = security.generate_signature(string_to_sign, security.rsa_key_from_string(private_key)) + + payment_response_provider = PaymentServiceProviderCredentialInternal.create_with_api_context( + certificate, + security.get_certificate_chain_string(all_chain_certificate), + encoded_signature, + self + ) + + return payment_response_provider + + def __register_device(self, + device_description: str, + permitted_ips: List[str]) -> None: from bunq.sdk.model.core.device_server_internal import DeviceServerInternal DeviceServerInternal.create( @@ -95,7 +144,7 @@ def _register_device(self, api_context=self ) - def _initialize_session(self) -> None: + def __initialize_session(self) -> None: from bunq.sdk.model.core.session_server import SessionServer session_server = SessionServer.create(self).value @@ -105,6 +154,17 @@ def _initialize_session(self) -> None: self._session_context = SessionContext(token, expiry_time, user_id) + def __initialize_session_for_psd2(self, user_payment_service_provider: UserPaymentServiceProvider) -> None: + from bunq.sdk.model.core.session_server import SessionServer + + session_server = SessionServer.create(self).value + + token = session_server.token.token + expiry_time = self._get_expiry_timestamp(session_server) + user_id = session_server.get_referenced_user().id_ + + self._session_context = SessionContext(token, expiry_time, user_id) + @classmethod def _get_expiry_timestamp(cls, session_server: SessionServer) -> datetime.datetime: timeout_seconds = cls._get_session_timeout_seconds(session_server) @@ -118,6 +178,8 @@ def _get_session_timeout_seconds(cls, session_server: SessionServer) -> int: return session_server.user_company.session_timeout elif session_server.user_person is not None: return session_server.user_person.session_timeout + elif session_server.user_payment_service_provider is not None: + return session_server.user_payment_service_provider.session_timeout elif session_server.user_api_key is not None: return session_server \ .user_api_key \ @@ -159,7 +221,7 @@ def reset_session(self) -> None: """ self._drop_session_context() - self._initialize_session() + self.__initialize_session() def _drop_session_context(self) -> None: self._session_context = None diff --git a/bunq/sdk/context/user_context.py b/bunq/sdk/context/user_context.py index 4775c28..e467d41 100644 --- a/bunq/sdk/context/user_context.py +++ b/bunq/sdk/context/user_context.py @@ -14,6 +14,7 @@ def __init__(self, user_id: int) -> None: self._user_person = None self._user_company = None self._user_api_key = None + self._user_payment_service_provider = None self._primary_monetary_account = None self._set_user(self.__get_user_object()) @@ -32,11 +33,17 @@ def _set_user(self, user: BunqModel) -> None: elif isinstance(user, endpoint.UserApiKey): self._user_api_key = user + elif isinstance(user, endpoint.UserPaymentServiceProvider): + self._user_payment_service_provider = user + else: raise BunqException( self._ERROR_UNEXPECTED_USER_INSTANCE.format(user.__class__)) def init_main_monetary_account(self) -> None: + if self._user_payment_service_provider is not None: + return + all_monetary_account = endpoint.MonetaryAccountBank.list().value for account in all_monetary_account: @@ -73,6 +80,10 @@ def is_all_user_type_set(self) -> bool: def refresh_user_context(self) -> None: self._set_user(self.__get_user_object()) + + if self._user_payment_service_provider is not None: + return + self.init_main_monetary_account() @property diff --git a/bunq/sdk/exception/EXCEPTIONS.md b/bunq/sdk/exception/EXCEPTIONS.md index 1e0a560..e97471d 100644 --- a/bunq/sdk/exception/EXCEPTIONS.md +++ b/bunq/sdk/exception/EXCEPTIONS.md @@ -50,11 +50,11 @@ from bunq.sdk.exception.bad_request_exception import BadRequestException from bunq.sdk.context.api_context import ApiEnvironmentType, ApiContext API_KEY = "Some invalid API key" -DESCRIPTION = "This wil raise a BadRequestException" +DESCRIPTION = "This will raise a BadRequestException" try: # Make a call that might raise an exception - ApiContext(ApiEnvironmentType.SANDBOX, API_KEY, DESCRIPTION) + ApiContext.create(ApiEnvironmentType.SANDBOX, API_KEY, DESCRIPTION) except BadRequestException as error: # Do something if exception is raised print(error.response_code) diff --git a/bunq/sdk/http/api_client.py b/bunq/sdk/http/api_client.py index 316ff19..3459625 100644 --- a/bunq/sdk/http/api_client.py +++ b/bunq/sdk/http/api_client.py @@ -33,10 +33,12 @@ class ApiClient: _URL_DEVICE_SERVER = 'device-server' _URI_INSTALLATION = 'installation' _URI_SESSION_SERVER = 'session-server' + _URL_PAYMENT_SERVICE_PROVIDER_CREDENTIAL = 'payment-service-provider-credential' _URIS_NOT_REQUIRING_ACTIVE_SESSION = [ _URI_INSTALLATION, _URI_SESSION_SERVER, _URL_DEVICE_SERVER, + _URL_PAYMENT_SERVICE_PROVIDER_CREDENTIAL, ] # HTTPS type of proxy, the only used at bunq @@ -57,7 +59,7 @@ class ApiClient: HEADER_RESPONSE_ID_LOWER_CASED = 'x-bunq-client-response-id' # Default header values - USER_AGENT_BUNQ = 'bunq-sdk-python/1.13.0' + USER_AGENT_BUNQ = 'bunq-sdk-python/1.13.1' GEOLOCATION_ZERO = '0 0 0 0 NL' LANGUAGE_EN_US = 'en_US' REGION_NL_NL = 'nl_NL' diff --git a/bunq/sdk/json/anchor_object_adapter.py b/bunq/sdk/json/anchor_object_adapter.py index a587ab2..b9854d9 100644 --- a/bunq/sdk/json/anchor_object_adapter.py +++ b/bunq/sdk/json/anchor_object_adapter.py @@ -18,6 +18,8 @@ class AnchorObjectAdapter(converter.JsonAdapter): _override_field_map = { 'ScheduledPayment': 'SchedulePayment', 'ScheduledInstance': 'ScheduleInstance', + 'ShareInviteBankInquiry': 'ShareInviteMonetaryAccountInquiry', + 'ShareInviteBankResponse': 'ShareInviteMonetaryAccountResponse' } @classmethod diff --git a/bunq/sdk/json/converter.py b/bunq/sdk/json/converter.py index c464c0f..ce5055b 100644 --- a/bunq/sdk/json/converter.py +++ b/bunq/sdk/json/converter.py @@ -175,7 +175,7 @@ def _deserialize_key(cls, key: str) -> str: @classmethod def _get_value_specs(cls, - cls_in: Type[Any], + cls_in: Type[T], attribute_name: str) -> ValueSpecs: if cls_in in {dict, list}: return ValueSpecs(None, ValueTypes(None, None)) diff --git a/bunq/sdk/json/session_server_adapter.py b/bunq/sdk/json/session_server_adapter.py index 6580ffa..a07fbd6 100644 --- a/bunq/sdk/json/session_server_adapter.py +++ b/bunq/sdk/json/session_server_adapter.py @@ -37,6 +37,10 @@ class SessionServerAdapter(converter.JsonAdapter): _ATTRIBUTE_USER_API_KEY = '_user_api_key' _FIELD_USER_API_KEY = 'UserApiKey' + # UserPaymentServiceProvider constants + _ATTRIBUTE_USER_PAYMENT_SERVER_PROVIDER = '_user_payment_service_provider' + _FIELD_USER_PAYMENT_SERVER_PROVIDER = 'UserPaymentServiceProvider' + @classmethod def deserialize(cls, target_class: Type[SessionServer], @@ -75,6 +79,12 @@ def deserialize(cls, endpoint.UserApiKey, user_dict_wrapped[cls._FIELD_USER_API_KEY] ) + elif cls._FIELD_USER_PAYMENT_SERVER_PROVIDER in user_dict_wrapped: + session_server.__dict__[cls._ATTRIBUTE_USER_PAYMENT_SERVER_PROVIDER] = \ + converter.deserialize( + endpoint.UserPaymentServiceProvider, + user_dict_wrapped[cls._FIELD_USER_PAYMENT_SERVER_PROVIDER] + ) else: raise BunqException(cls._ERROR_COULD_NOT_DETERMINE_USER) @@ -97,4 +107,8 @@ def serialize(cls, session_server: SessionServer) -> List: cls._FIELD_USER_API_KEY: converter.serialize(session_server.user_api_key), }, + { + cls._FIELD_USER_PAYMENT_SERVER_PROVIDER: + converter.serialize(session_server.user_payment_service_provider), + }, ] diff --git a/bunq/sdk/model/core/bunq_model.py b/bunq/sdk/model/core/bunq_model.py index e6b8e4e..5b2ac00 100644 --- a/bunq/sdk/model/core/bunq_model.py +++ b/bunq/sdk/model/core/bunq_model.py @@ -3,6 +3,7 @@ import typing from typing import Dict, List +from bunq import T from bunq.sdk.http.bunq_response import BunqResponse from bunq.sdk.http.bunq_response_raw import BunqResponseRaw from bunq.sdk.json import converter @@ -90,7 +91,7 @@ def _process_for_uuid(cls, response_raw: BunqResponseRaw) -> BunqResponse[str]: @classmethod def _from_json_list(cls, response_raw: BunqResponseRaw, - wrapper: str = None) -> BunqResponse[List[BunqModel]]: + wrapper: str = None) -> BunqResponse[List[T]]: from bunq import Pagination json = response_raw.body_bytes.decode() @@ -103,7 +104,10 @@ def _from_json_list(cls, item_deserialized = converter.deserialize(cls, item_unwrapped) array_deserialized.append(item_deserialized) - pagination = converter.deserialize(Pagination, obj[cls._FIELD_PAGINATION]) + pagination = None + + if cls._FIELD_PAGINATION in obj: + pagination = converter.deserialize(Pagination, obj[cls._FIELD_PAGINATION]) return BunqResponse(array_deserialized, response_raw.headers, pagination) diff --git a/bunq/sdk/model/core/notification_filter_push_user_internal.py b/bunq/sdk/model/core/notification_filter_push_user_internal.py new file mode 100644 index 0000000..a1ecfa2 --- /dev/null +++ b/bunq/sdk/model/core/notification_filter_push_user_internal.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import List, Dict + +from bunq.sdk.http.api_client import ApiClient +from bunq.sdk.http.bunq_response import BunqResponse +from bunq.sdk.json import converter +from bunq.sdk.model.generated.endpoint import NotificationFilterPushUser +from bunq.sdk.model.generated.object_ import NotificationFilterPush, NotificationFilterUrl + + +class NotificationFilterPushUserInternal(NotificationFilterPushUser): + @classmethod + def create_with_list_response(cls, + all_notification_filter: List[NotificationFilterPush] = None, + custom_headers: Dict[str, str] = None + ) -> BunqResponse[List[NotificationFilterPush]]: + if all_notification_filter is None: + all_notification_filter = [] + + if custom_headers is None: + custom_headers = {} + + request_map = { + cls.FIELD_NOTIFICATION_FILTERS: all_notification_filter + } + request_map_string = converter.class_to_json(request_map) + request_map_string = cls._remove_field_for_request(request_map_string) + + api_client = ApiClient(cls._get_api_context()) + request_bytes = request_map_string.encode() + endpoint_url = cls._ENDPOINT_URL_CREATE.format(cls._determine_user_id()) + response_raw = api_client.post(endpoint_url, request_bytes, custom_headers) + + return NotificationFilterUrl._from_json_list(response_raw, cls._OBJECT_TYPE_GET) diff --git a/bunq/sdk/model/core/notification_filter_url_monetary_account_internal.py b/bunq/sdk/model/core/notification_filter_url_monetary_account_internal.py new file mode 100644 index 0000000..efa7947 --- /dev/null +++ b/bunq/sdk/model/core/notification_filter_url_monetary_account_internal.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import List, Dict + +from bunq.sdk.http.api_client import ApiClient +from bunq.sdk.http.bunq_response import BunqResponse +from bunq.sdk.json import converter +from bunq.sdk.model.generated.endpoint import NotificationFilterUrlMonetaryAccount +from bunq.sdk.model.generated.object_ import NotificationFilterUrl + + +class NotificationFilterUrlMonetaryAccountInternal(NotificationFilterUrlMonetaryAccount): + @classmethod + def create_with_list_response(cls, + monetary_account_id: int = None, + all_notification_filter: List[NotificationFilterUrl] = None, + custom_headers: Dict[str, str] = None + ) -> BunqResponse[List[NotificationFilterUrl]]: + if all_notification_filter is None: + all_notification_filter = [] + + if custom_headers is None: + custom_headers = {} + + request_map = { + cls.FIELD_NOTIFICATION_FILTERS: all_notification_filter + } + request_map_string = converter.class_to_json(request_map) + request_map_string = cls._remove_field_for_request(request_map_string) + + api_client = ApiClient(cls._get_api_context()) + request_bytes = request_map_string.encode() + endpoint_url = cls._ENDPOINT_URL_CREATE.format(cls._determine_user_id(), + cls._determine_monetary_account_id(monetary_account_id)) + response_raw = api_client.post(endpoint_url, request_bytes, custom_headers) + + return NotificationFilterUrl._from_json_list(response_raw, cls._OBJECT_TYPE_GET) diff --git a/bunq/sdk/model/core/notification_filter_url_user_internal.py b/bunq/sdk/model/core/notification_filter_url_user_internal.py new file mode 100644 index 0000000..d87ed94 --- /dev/null +++ b/bunq/sdk/model/core/notification_filter_url_user_internal.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import List, Dict + +from bunq.sdk.http.api_client import ApiClient +from bunq.sdk.http.bunq_response import BunqResponse +from bunq.sdk.json import converter +from bunq.sdk.model.generated.endpoint import NotificationFilterUrlUser +from bunq.sdk.model.generated.object_ import NotificationFilterUrl + + +class NotificationFilterUrlUserInternal(NotificationFilterUrlUser): + @classmethod + def create_with_list_response(cls, + all_notification_filter: List[NotificationFilterUrl] = None, + custom_headers: Dict[str, str] = None + ) -> BunqResponse[List[NotificationFilterUrl]]: + if all_notification_filter is None: + all_notification_filter = [] + + if custom_headers is None: + custom_headers = {} + + request_map = { + cls.FIELD_NOTIFICATION_FILTERS: all_notification_filter + } + request_map_string = converter.class_to_json(request_map) + request_map_string = cls._remove_field_for_request(request_map_string) + + api_client = ApiClient(cls._get_api_context()) + request_bytes = request_map_string.encode() + endpoint_url = cls._ENDPOINT_URL_CREATE.format(cls._determine_user_id()) + response_raw = api_client.post(endpoint_url, request_bytes, custom_headers) + + return NotificationFilterUrl._from_json_list(response_raw, cls._OBJECT_TYPE_GET) diff --git a/bunq/sdk/model/core/payment_service_provider_credential_internal.py b/bunq/sdk/model/core/payment_service_provider_credential_internal.py new file mode 100644 index 0000000..43dc317 --- /dev/null +++ b/bunq/sdk/model/core/payment_service_provider_credential_internal.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import json +import typing + +from bunq.sdk.http.api_client import ApiClient +from bunq.sdk.json import converter +from bunq.sdk.model.generated.endpoint import PaymentServiceProviderCredential, UserCredentialPasswordIp + +if typing.TYPE_CHECKING: + from bunq.sdk.context.api_context import ApiContext + + +class PaymentServiceProviderCredentialInternal(PaymentServiceProviderCredential): + @classmethod + def create_with_api_context(cls, + client_payment_service_provider_certificate: str, + client_payment_service_provider_certificate_chain: str, + client_public_key_signature: str, + api_context: ApiContext, + all_custom_header=None) -> UserCredentialPasswordIp: + request_map = { + cls.FIELD_CLIENT_PAYMENT_SERVICE_PROVIDER_CERTIFICATE: client_payment_service_provider_certificate, + cls.FIELD_CLIENT_PAYMENT_SERVICE_PROVIDER_CERTIFICATE_CHAIN: client_payment_service_provider_certificate_chain, + cls.FIELD_CLIENT_PUBLIC_KEY_SIGNATURE: client_public_key_signature + } + + if all_custom_header is None: + all_custom_header = {} + + api_client = ApiClient(api_context) + request_bytes = converter.class_to_json(request_map).encode() + endpoint_url = cls._ENDPOINT_URL_CREATE + response_raw = api_client.post(endpoint_url, request_bytes, all_custom_header) + + response_body = converter.json_to_class(dict, response_raw.body_bytes.decode()) + response_body_dict = converter.deserialize(cls, response_body[cls._FIELD_RESPONSE])[0] + + return UserCredentialPasswordIp.from_json(json.dumps(response_body_dict[cls._OBJECT_TYPE_GET])) diff --git a/bunq/sdk/model/core/session_server.py b/bunq/sdk/model/core/session_server.py index 86296f9..c49ed4b 100644 --- a/bunq/sdk/model/core/session_server.py +++ b/bunq/sdk/model/core/session_server.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Optional + from bunq.sdk.context.api_context import ApiContext from bunq.sdk.exception.bunq_exception import BunqException from bunq.sdk.http.api_client import ApiClient @@ -8,16 +10,17 @@ from bunq.sdk.model.core.bunq_model import BunqModel from bunq.sdk.model.core.id import Id from bunq.sdk.model.core.session_token import SessionToken -from bunq.sdk.model.generated.endpoint import UserPerson, UserCompany, UserApiKey +from bunq.sdk.model.generated.endpoint import UserPerson, UserCompany, UserApiKey, UserPaymentServiceProvider class SessionServer(BunqModel): """ - :type _id_: Id - :type _token: SessionToken - :type _user_person: bunq.sdk.model.generated.UserPerson - :type _user_company: bunq.sdk.model.generated.UserCompany - :type _user_api_key: bunq.sdk.model.generated.UserApiKey + :type _id_: Id|None + :type _token: SessionToken|None + :type _user_person: UserPerson|None + :type _user_company: UserCompany|None + :type _user_api_key: UserApiKey|None + :type _user_payment_service_provider:UserPaymentServiceProvider|None """ # Endpoint name. @@ -35,6 +38,7 @@ def __init__(self) -> None: self._user_person = None self._user_company = None self._user_api_key = None + self._user_payment_service_provider = None @property def id_(self) -> Id: @@ -53,9 +57,13 @@ def user_company(self) -> UserCompany: return self._user_company @property - def user_api_key(self) -> UserApiKey: + def user_api_key(self) -> Optional[UserApiKey]: return self._user_api_key + @property + def user_payment_service_provider(self) -> UserPaymentServiceProvider: + return self._user_payment_service_provider + @classmethod def create(cls, api_context: ApiContext) -> BunqResponse[SessionServer]: api_client = ApiClient(api_context) @@ -81,6 +89,9 @@ def is_all_field_none(self) -> bool: if self.user_company is not None: return False + if self.user_payment_service_provider is not None: + return False + if self.user_api_key is not None: return False @@ -93,6 +104,9 @@ def get_referenced_user(self) -> BunqModel: if self._user_company is not None: return self._user_company + if self._user_payment_service_provider is not None: + return self._user_payment_service_provider + if self._user_api_key is not None: return self._user_api_key diff --git a/bunq/sdk/security/security.py b/bunq/sdk/security/security.py index 16406d2..1febf65 100644 --- a/bunq/sdk/security/security.py +++ b/bunq/sdk/security/security.py @@ -6,7 +6,7 @@ import typing from base64 import b64encode from hashlib import sha1 -from typing import Dict +from typing import Dict, List from Cryptodome import Cipher from Cryptodome import Random @@ -111,6 +111,16 @@ def _should_sign_request_header(header_name: str) -> bool: return False +def generate_signature(string_to_sign: str, key_pair: RsaKey) -> str: + bytes_to_sign = string_to_sign.encode() + signer = PKCS1_v1_5.new(key_pair) + digest = SHA256.new() + digest.update(bytes_to_sign) + sign = signer.sign(digest) + + return b64encode(sign) + + def encrypt(api_context: ApiContext, request_bytes: bytes, custom_headers: Dict[str, str]) -> bytes: @@ -177,15 +187,14 @@ def validate_response(public_key_server: RsaKey, def is_valid_response_header_with_body(public_key_server: RsaKey, - status_code: int, - body_bytes: bytes, - headers: Dict[str, str]) -> bool: + status_code: int, + body_bytes: bytes, + headers: Dict[str, str]) -> bool: head_bytes = _generate_response_head_bytes(status_code, headers) bytes_signed = head_bytes + body_bytes signer = PKCS1_v1_5.pkcs1_15.new(public_key_server) digest = SHA256.new() digest.update(bytes_signed) - signer.verify(digest, base64.b64decode(headers[_HEADER_SERVER_SIGNATURE])) try: signer.verify(digest, base64.b64decode(headers[_HEADER_SERVER_SIGNATURE])) @@ -243,3 +252,7 @@ def _should_sign_response_header(header_name: str) -> bool: return True return False + + +def get_certificate_chain_string(all_chain_certificate: List[str]): + return _DELIMITER_NEWLINE.join(all_chain_certificate) diff --git a/bunq/sdk/util/util.py b/bunq/sdk/util/util.py index bed330d..250eb80 100644 --- a/bunq/sdk/util/util.py +++ b/bunq/sdk/util/util.py @@ -23,11 +23,10 @@ def automatic_sandbox_install() -> ApiContext: sandbox_user = __generate_new_sandbox_user() - return ApiContext( - ApiEnvironmentType.SANDBOX, - sandbox_user.api_key, - socket.gethostname() - ) + return ApiContext.create(ApiEnvironmentType.SANDBOX, + sandbox_user.api_key, + socket.gethostname() + ) def __generate_new_sandbox_user() -> SandboxUser: diff --git a/requirements.txt b/requirements.txt index faa2ad6..c13f7e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -aenum==2.0.8 +aenum==2.2.3 chardet==3.0.4 -pycryptodomex==3.4.6 -requests[socks]==2.18.1 -simplejson==3.11.1 -urllib3==1.21.1 +pycryptodomex==3.9.7 +requests[socks]==2.23.0 +simplejson==3.17.0 +urllib3==1.25.8 diff --git a/setup.py b/setup.py index fd6d8a0..eb519a6 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version='1.13.0', + version='1.13.1', description='bunq Python SDK', long_description=long_description, @@ -70,11 +70,19 @@ keywords='open-banking sepa bunq finance api payment', # Packages of the project. "find_packages()" lists all the project packages. - packages=find_packages(exclude=['contrib', 'docs', 'tests', 'examples', - 'assets', '.idea', 'run.py']), + packages=find_packages(exclude=['contrib', + 'docs', + 'tests', + 'examples', + 'assets', + '.idea', + 'run.py']), # Run-time dependencies of the project. These will be installed by pip. - install_requires=['aenum==2.0.8', 'chardet==3.0.4', 'pycryptodomex==3.4.6', - 'requests==2.18.1', 'simplejson==3.11.1', - 'urllib3==1.21.1'], + install_requires=['aenum==2.2.3', + 'chardet==3.0.4', + 'pycryptodomex==3.9.7', + 'requests==2.23.0', + 'simplejson==3.17.0', + 'urllib3==1.25.8'], ) diff --git a/tests/README.md b/tests/README.md index 617ebfd..ed3e146 100644 --- a/tests/README.md +++ b/tests/README.md @@ -48,14 +48,14 @@ Note: ## Execution -You can run the tests via command line: +You can run all the tests via command line: -``` -python -m unittest discover -s tests/model/generated -``` + python -m unittest discover -s tests/context && \ + python -m unittest discover -s tests/http && \ + python -m unittest discover -s tests/model/generated or via PyCharm, but first you must configure PyCharm by doing the following: -* Got to preferences --> tools --> Python integrated tools and change default +* Go to `Preferences` --> `Tools` --> `Python integrated tools` and change default test runner to `unittests`. * Configure your Python interpreter to an supported Python version. Python 3 is recommended. diff --git a/tests/context/test_api_context.py b/tests/context/test_api_context.py index 2e543e7..2123974 100644 --- a/tests/context/test_api_context.py +++ b/tests/context/test_api_context.py @@ -13,7 +13,7 @@ class TestApiContext(BunqSdkTestCase): ApiContext """ - _TMP_FILE_PATH = '/context-save-test.conf' + _TMP_FILE_PATH = '/assets/context-save-test.conf' __FIELD_SESSION_CONTEXT = 'session_context' __FIELD_EXPIRE_TIME = 'expiry_time' @@ -99,15 +99,12 @@ def test_auto_bunq_context_update(self): api_context: ApiContext = BunqContext.api_context() api_context_json: object = json.loads(api_context.to_json()) - api_context_json[self.__FIELD_SESSION_CONTEXT][ - self.__FIELD_EXPIRE_TIME] = self.__TIME_STAMP_IN_PAST + api_context_json[self.__FIELD_SESSION_CONTEXT][self.__FIELD_EXPIRE_TIME] = self.__TIME_STAMP_IN_PAST expired_api_context = ApiContext.from_json(json.dumps(api_context_json)) - self.assertNotEqual(api_context.session_context.expiry_time, - expired_api_context.session_context.expiry_time) - self.assertEqual(BunqContext.api_context().session_context.expiry_time, - api_context.session_context.expiry_time) + self.assertNotEqual(api_context.session_context.expiry_time, expired_api_context.session_context.expiry_time) + self.assertEqual(BunqContext.api_context().session_context.expiry_time, api_context.session_context.expiry_time) BunqContext.update_api_context(expired_api_context) BunqContext.user_context().refresh_user_context() diff --git a/tests/context/test_psd2_context.py b/tests/context/test_psd2_context.py index 20c2e1b..e5642e1 100644 --- a/tests/context/test_psd2_context.py +++ b/tests/context/test_psd2_context.py @@ -1,10 +1,113 @@ +import os +import unittest + +from bunq import ApiEnvironmentType +from bunq.sdk.context.api_context import ApiContext +from bunq.sdk.context.bunq_context import BunqContext +from bunq.sdk.json import converter +from bunq.sdk.model.generated.endpoint import OauthClient from tests.bunq_test import BunqSdkTestCase -class TestPsd2Context(BunqSdkTestCase): +class TestPsd2Context(unittest.TestCase): """ Tests: Psd2Context """ - # TODO: Implement PSD2 + _FILE_TEST_CONFIGURATION = '/assets/bunq-psd2-test.conf' + _FILE_TEST_OAUTH = '/assets/bunq-oauth-psd2-test.conf' + + _FILE_TEST_CERTIFICATE = '/assets/cert.pem' + _FILE_TEST_CERTIFICATE_CHAIN = '/assets/cert.pem' + _FILE_TEST_PRIVATE_KEY = '/assets/key.pem' + + _TEST_DEVICE_DESCRIPTION = 'PSD2TestDevice' + + @classmethod + def setUpClass(cls) -> None: + cls._FILE_MODE_READ = ApiContext._FILE_MODE_READ + cls._FILE_TEST_CONFIGURATION_PATH_FULL = ( + BunqSdkTestCase._get_directory_test_root() + + cls._FILE_TEST_CONFIGURATION) + cls._FILE_TEST_OAUTH_PATH_FULL = ( + BunqSdkTestCase._get_directory_test_root() + + cls._FILE_TEST_OAUTH) + cls._FILE_TEST_CERTIFICATE_PATH_FULL = ( + BunqSdkTestCase._get_directory_test_root() + + cls._FILE_TEST_CERTIFICATE) + cls._FILE_TEST_CERTIFICATE_CHAIN_PATH_FULL = ( + BunqSdkTestCase._get_directory_test_root() + + cls._FILE_TEST_CERTIFICATE_CHAIN) + cls._FILE_TEST_PRIVATE_KEY_PATH_FULL = ( + BunqSdkTestCase._get_directory_test_root() + + cls._FILE_TEST_PRIVATE_KEY) + cls.setup_test_data() + + @classmethod + def setup_test_data(cls) -> None: + if not os.path.isfile(cls._FILE_TEST_CONFIGURATION_PATH_FULL): + try: + BunqContext.load_api_context(cls._create_api_context()) + except FileNotFoundError: + return + + api_context = ApiContext.restore(cls._FILE_TEST_CONFIGURATION_PATH_FULL) + BunqContext.load_api_context(api_context) + + def test_create_psd2_context(self) -> None: + if os.path.isfile(self._FILE_TEST_CONFIGURATION_PATH_FULL): + return + + try: + api_context = self._create_api_context() + BunqContext.load_api_context(api_context) + + self.assertTrue(os.path.isfile(self._FILE_TEST_CONFIGURATION_PATH_FULL)) + + except AssertionError: + raise AssertionError + + def test_create_oauth_client(self) -> None: + if os.path.isfile(self._FILE_TEST_OAUTH_PATH_FULL): + return + + try: + client_id = OauthClient.create().value + oauth_client = OauthClient.get(client_id).value + + self.assertIsNotNone(oauth_client) + + serialized_client = converter.class_to_json(oauth_client) + + file = open(self._FILE_TEST_OAUTH_PATH_FULL, ApiContext._FILE_MODE_WRITE) + file.write(serialized_client) + file.close() + + self.assertTrue(os.path.isfile(self._FILE_TEST_OAUTH_PATH_FULL)) + + except AssertionError: + raise AssertionError + + @classmethod + def _create_api_context(cls) -> ApiContext: + with open(cls._FILE_TEST_CERTIFICATE_PATH_FULL, cls._FILE_MODE_READ) as file_: + certificate = file_.read() + + with open(cls._FILE_TEST_PRIVATE_KEY_PATH_FULL, cls._FILE_MODE_READ) as file_: + private_key = file_.read() + + with open(cls._FILE_TEST_CERTIFICATE_PATH_FULL, cls._FILE_MODE_READ) as file_: + all_certificate_chain = file_.read() + + api_context = ApiContext.create_for_psd2( + ApiEnvironmentType.SANDBOX, + certificate, + private_key, + [all_certificate_chain], + cls._TEST_DEVICE_DESCRIPTION + ) + + api_context.save(cls._FILE_TEST_CONFIGURATION_PATH_FULL) + + return api_context diff --git a/tests/model/core/test_notification_filter.py b/tests/model/core/test_notification_filter.py new file mode 100644 index 0000000..5a2c4f5 --- /dev/null +++ b/tests/model/core/test_notification_filter.py @@ -0,0 +1,69 @@ +from bunq.sdk.context.bunq_context import BunqContext +from bunq.sdk.model.core.notification_filter_push_user_internal import NotificationFilterPushUserInternal +from bunq.sdk.model.core.notification_filter_url_monetary_account_internal import \ + NotificationFilterUrlMonetaryAccountInternal +from bunq.sdk.model.core.notification_filter_url_user_internal import NotificationFilterUrlUserInternal +from bunq.sdk.model.generated.object_ import NotificationFilterUrl, NotificationFilterPush +from tests.bunq_test import BunqSdkTestCase + + +class TestNotificationFilter(BunqSdkTestCase): + _FILTER_CATEGORY_MUTATION = 'MUTATION' + _FILTER_CALLBACK_URL = 'https://test.com/callback' + + def test_notification_filter_url_monetary_account(self): + notification_filter = self.get_notification_filter_url() + all_notification_filter = [notification_filter] + + all_created_notification_filter = NotificationFilterUrlMonetaryAccountInternal.create_with_list_response( + self.get_primary_monetary_account().id_, + all_notification_filter + ).value + + self.assertEqual(1, len(all_created_notification_filter)) + + def test_notification_filter_url_user(self): + notification_filter = self.get_notification_filter_url() + all_notification_filter = [notification_filter] + + all_created_notification_filter = NotificationFilterUrlUserInternal.create_with_list_response( + all_notification_filter + ).value + + self.assertEqual(1, len(all_created_notification_filter)) + + def test_notification_filter_push_user(self): + notification_filter = self.get_notification_filter_push() + all_notification_filter = [notification_filter] + + all_created_notification_filter = NotificationFilterPushUserInternal.create_with_list_response( + all_notification_filter + ).value + + self.assertEqual(1, len(all_created_notification_filter)) + + def test_notification_filter_clear(self): + all_created_notification_filter_push_user = \ + NotificationFilterPushUserInternal.create_with_list_response().value + all_created_notification_filter_url_user = \ + NotificationFilterUrlUserInternal.create_with_list_response().value + all_created_notification_filter_url_monetary_account = \ + NotificationFilterUrlMonetaryAccountInternal.create_with_list_response().value + + self.assertFalse(all_created_notification_filter_push_user) + self.assertFalse(all_created_notification_filter_url_user) + self.assertFalse(all_created_notification_filter_url_monetary_account) + + self.assertEqual(0, len(NotificationFilterPushUserInternal.list().value)) + self.assertEqual(0, len(NotificationFilterUrlUserInternal.list().value)) + self.assertEqual(0, len(NotificationFilterUrlMonetaryAccountInternal.list().value)) + + def get_notification_filter_url(self): + return NotificationFilterUrl(self._FILTER_CATEGORY_MUTATION, self._FILTER_CALLBACK_URL) + + def get_notification_filter_push(self): + return NotificationFilterPush(self._FILTER_CATEGORY_MUTATION) + + @staticmethod + def get_primary_monetary_account(): + return BunqContext.user_context().primary_monetary_account diff --git a/tests/model/generated/object/test_notification_url.py b/tests/model/generated/object/test_notification_url.py index 5a325d7..b923866 100644 --- a/tests/model/generated/object/test_notification_url.py +++ b/tests/model/generated/object/test_notification_url.py @@ -21,8 +21,8 @@ class TestNotificationUrl(bunq_test.BunqSdkTestCase): _GETTER_REQUEST_RESPONSE = 'RequestResponse' _GETTER_SCHEDULE_PAYMENT = 'ScheduledPayment' _GETTER_SCHEDULE_INSTANCE = 'ScheduledInstance' - _GETTER_SHARE_INVITE_BANK_INQUIRY = 'ShareInviteMonetaryAccountInquiry' - _GETTER_SHARE_INVITE_BANK_RESPONSE = 'ShareInviteMonetaryAccountResponse' + _GETTER_SHARE_INVITE_BANK_INQUIRY = 'ShareInviteBankInquiry' + _GETTER_SHARE_INVITE_BANK_RESPONSE = 'ShareInviteBankResponse' # Model json paths constants. BASE_PATH_JSON_MODEL = '../../../assets/NotificationUrlJsons'