From 98ec7feafa3e6bc1b906946fe149eae43c7a5eab Mon Sep 17 00:00:00 2001 From: Roald Nefs Date: Wed, 6 Jan 2021 23:02:10 +0100 Subject: [PATCH 01/12] Use Python 3 super() method Signed-off-by: Roald Nefs --- transip/exceptions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/transip/exceptions.py b/transip/exceptions.py index 88c37e7..9321f5d 100644 --- a/transip/exceptions.py +++ b/transip/exceptions.py @@ -23,7 +23,7 @@ class TransIPError(Exception): def __init__(self, message: str = "") -> None: - Exception.__init__(self, message) + super().__init__(message) self.message = message def __str__(self) -> str: @@ -38,7 +38,7 @@ def __init__( response_code: Optional[int] = None ) -> None: - TransIPError.__init__(self, message) + super().__init__(message) self.response_code = response_code def __str__(self) -> str: From 59571dc6f62f9a39e4a4eaa2515918367a35437f Mon Sep 17 00:00:00 2001 From: Roald Nefs Date: Thu, 7 Jan 2021 08:20:47 +0100 Subject: [PATCH 02/12] Add ObjectDeleteMixin to delete ApiObjects Add `transip.mixins.ObjectDeleteMixin` to allow `transip.base.ApiObjects` instances to call `self.delete()`. Signed-off-by: Roald Nefs --- tests/services/test_ssh_keys.py | 10 ++++++++++ transip/mixins.py | 12 +++++++++++- transip/v6/objects.py | 3 ++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/services/test_ssh_keys.py b/tests/services/test_ssh_keys.py index fbc9fd8..4cb08bc 100644 --- a/tests/services/test_ssh_keys.py +++ b/tests/services/test_ssh_keys.py @@ -61,6 +61,16 @@ def test_delete(self) -> None: except Exception as exc: assert False, f"'transip.TransIP.ssh_keys.delete' raised an exception {exc}" + @responses.activate + def test_delete_object(self) -> None: + ssh_key_id: int = 123 + ssh_key: Type[SshKey] = self.client.ssh_keys.get(ssh_key_id) + + try: + ssh_key.delete() # type: ignore + except Exception as exc: + assert False, f"'transip.v6.objects.SshKey.delete' raised an exception {exc}" + @responses.activate def test_create(self) -> None: ssh_key_data: Dict[str, str] = { diff --git a/transip/mixins.py b/transip/mixins.py index 251cab2..b9b00c6 100644 --- a/transip/mixins.py +++ b/transip/mixins.py @@ -20,7 +20,7 @@ from typing import Optional, List, Type, Dict, Any, Tuple, Union from transip import TransIP -from transip.base import ApiObject +from transip.base import ApiObject, ApiService # Typing alias for the _create_attrs attribute in the CreateMixin @@ -66,6 +66,16 @@ def delete(self, id: str, **kwargs) -> None: self.client.delete(f"{self.path}/{id}") +class ObjectDeleteMixin: + """Delete a single ApiObject.""" + + service: ApiService + + def delete(self) -> None: + if self.get_id(): # type: ignore + self.service.delete(self.get_id()) # type: ignore + + class ListMixin: """Retrieve a list of ApiObjects. diff --git a/transip/v6/objects.py b/transip/v6/objects.py index dbed380..6686637 100644 --- a/transip/v6/objects.py +++ b/transip/v6/objects.py @@ -22,6 +22,7 @@ from transip.base import ApiService, ApiObject from transip.mixins import ( GetMixin, DeleteMixin, ListMixin, CreateMixin, UpdateMixin, + ObjectDeleteMixin, CreateAttrsTuple, UpdateAttrsTuple ) @@ -39,7 +40,7 @@ class AvailabilityZoneService(ListMixin, ApiService): _resp_list_attr: str = "availabilityZones" -class SshKey(ApiObject): +class SshKey(ObjectDeleteMixin, ApiObject): _id_attr: str = "id" From cdcf3f1f11c1baab94c0e751a8381c7d6a348797 Mon Sep 17 00:00:00 2001 From: Roald Nefs Date: Thu, 7 Jan 2021 22:40:46 +0100 Subject: [PATCH 03/12] Fix link for license badge in README.md Signed-off-by: Roald Nefs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d64288f..4650de9 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-transip?color=187dc1&logo=python&logoColor=white&style=for-the-badge)](https://pypi.org/project/python-transip/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/python-transip?color=187dc1&logo=python&logoColor=white&style=for-the-badge)](https://pypi.org/project/python-transip/) [![PyPI - Format](https://img.shields.io/pypi/format/python-transip?color=187dc1&logo=python&logoColor=white&style=for-the-badge)](https://pypi.org/project/python-transip/) -[![License](https://img.shields.io/github/license/roaldnefs/python-transip?color=187dc1&style=for-the-badge)](https://raw.githubusercontent.com/roaldnefs/python-transip/main/COPYING) +[![License](https://img.shields.io/github/license/roaldnefs/python-transip?color=187dc1&style=for-the-badge)](https://raw.githubusercontent.com/roaldnefs/python-transip/main/COPYING.LESSER) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/roaldnefs/python-transip/tests?color=187dc1&label=CI&logo=github&style=for-the-badge)](https://github.com/roaldnefs/python-transip/actions) [![GitHub contributors](https://img.shields.io/github/contributors/roaldnefs/python-transip?color=187dc1&logo=github&style=for-the-badge)](https://github.com/roaldnefs/python-transip/graphs/contributors) From 7585cc8adc42a9c1772a936c70cd11c208b88189 Mon Sep 17 00:00:00 2001 From: Roald Nefs Date: Fri, 8 Jan 2021 22:22:24 +0100 Subject: [PATCH 04/12] Update setup.py to install data files in the package Signed-off-by: Roald Nefs --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a2f5209..04dfcfe 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,7 @@ def get_long_description() -> str: license="LGPLv3", url="https://github.com/roaldnefs/python-transip", packages=find_packages(), + include_package_data=True, install_requires=["requests>=2.25.1"], python_requires=">=3.6.12", entry_points={}, @@ -44,4 +45,4 @@ def get_long_description() -> str: "Programming Language :: Python :: 3.9", ], extras_require={}, -) \ No newline at end of file +) From b263eeca7eda740ee34ebd2f224f60b501651059 Mon Sep 17 00:00:00 2001 From: Roald Nefs Date: Sat, 9 Jan 2021 14:08:41 +0100 Subject: [PATCH 05/12] Update types and readability of test utils Signed-off-by: Roald Nefs --- MANIFEST.in | 3 +- tests/fixtures/account.json | 4 +-- tests/fixtures/colocations.json | 6 ++-- tests/fixtures/domains.json | 16 ++++----- tests/utils.py | 61 +++++++++++++++++++-------------- 5 files changed, 51 insertions(+), 39 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index aa74a39..c3e963b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ include COPYING COPYING.LESSER README.md include tox.ini recursive-include requirements * -recursive-include docs *.py *.rst user/*rst Makefile make.bat \ No newline at end of file +recursive-include docs *.py *.rst user/*rst Makefile make.bat +recursive-include tests/fixtures *.json diff --git a/tests/fixtures/account.json b/tests/fixtures/account.json index 28590eb..bb3619f 100644 --- a/tests/fixtures/account.json +++ b/tests/fixtures/account.json @@ -110,7 +110,7 @@ "url": "https://api.transip.nl/v6/ssh-keys", "status": 201, "content_type": "application/json", - "match_json": { + "match_json_params": { "sshKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDf2pxWX/yhUBDyk2LPhvRtI0LnVO8PyR5Zt6AHrnhtLGqK+8YG9EMlWbCCWrASR+Q1hFQG example", "description": "Jim key" } @@ -120,7 +120,7 @@ "url": "https://api.transip.nl/v6/ssh-keys/123", "status": 204, "content_type": "application/json", - "match_json": { + "match_json_params": { "description": "Jim key" } }, diff --git a/tests/fixtures/colocations.json b/tests/fixtures/colocations.json index e480139..34917d5 100644 --- a/tests/fixtures/colocations.json +++ b/tests/fixtures/colocations.json @@ -68,7 +68,7 @@ "url": "https://api.transip.nl/v6/colocations/example2/ip-addresses", "status": 201, "content_type": "application/json", - "match_json": { + "match_json_params": { "ipAddress": "2a01:7c8:3:1337::6", "reverseDns": "example.com" } @@ -78,7 +78,7 @@ "url": "https://api.transip.nl/v6/colocations/example2/ip-addresses/37.97.254.6", "status": 204, "content_type": "application/json", - "match_json": { + "match_json_params": { "ipAddress": { "address": "37.97.254.6", "subnetMask": "255.255.255.0", @@ -102,7 +102,7 @@ "url": "https://api.transip.nl/v6/colocations/example2/remote-hands", "status": 201, "content_type": "application/json", - "match_json": { + "match_json_params": { "remoteHands": { "coloName": "example2", "contactName": "Herman Kaakdorst", diff --git a/tests/fixtures/domains.json b/tests/fixtures/domains.json index 8ee4a85..bf138d1 100644 --- a/tests/fixtures/domains.json +++ b/tests/fixtures/domains.json @@ -52,7 +52,7 @@ "url": "https://api.transip.nl/v6/domains", "status": 201, "content_type": "application/json", - "match_json": { + "match_json_params": { "domainName": "example.com", "contacts": [ { @@ -94,7 +94,7 @@ "url": "https://api.transip.nl/v6/domains", "status": 201, "content_type": "application/json", - "match_json": { + "match_json_params": { "domainName": "example.com", "authCode": "CYPMaVH+9MRjXGBc3InzHs7vNSUBPOjwpZm3GO+iDLHnFLtiP7sOKqW5JD1WeUpevZM6q1qS5YH9dGSp", "contacts": [ @@ -137,7 +137,7 @@ "url": "https://api.transip.nl/v6/domains/example.com", "status": 204, "content_type": "application/json", - "match_json": { + "match_json_params": { "domain": { "name": "example.com", "authCode": "kJqfuOXNOYQKqh/jO4bYSn54YDqgAt1ksCe+ZG4Ud4nfpzw8qBsfR2JqAj7Ce12SxKcGD09v+yXd6lrm", @@ -160,7 +160,7 @@ "url": "https://api.transip.nl/v6/domains/example.com", "status": 204, "content_type": "application/json", - "match_json": { + "match_json_params": { "endTime": "end" } }, @@ -186,7 +186,7 @@ "url": "https://api.transip.nl/v6/domains/example.com/branding", "status": 204, "content_type": "application/json", - "match_json": { + "match_json_params": { "branding": { "companyName": "Example B.V.", "supportEmail": "admin@example.com", @@ -229,7 +229,7 @@ "url": "https://api.transip.nl/v6/domains/example.com/contacts", "status": 204, "content_type": "application/json", - "match_json": { + "match_json_params": { "contacts": [ { "type": "registrant", @@ -271,7 +271,7 @@ "url": "https://api.transip.nl/v6/domains/example.com/dns", "status": 201, "content_type": "application/json", - "match_json": { + "match_json_params": { "dnsEntry": { "name": "www", "expire": 86400, @@ -300,7 +300,7 @@ "url": "https://api.transip.nl/v6/domains/example.com/nameservers", "status": 204, "content_type": "application/json", - "match_json": { + "match_json_params": { "nameservers": [ { "hostname": "ns0.transip.nl", diff --git a/tests/utils.py b/tests/utils.py index d5fdc08..b62590c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -17,27 +17,28 @@ # You should have received a copy of the GNU Lesser General Public License # along with python-transip. If not, see . -from typing import Any +from typing import Any, List, Dict import json import os import responses # type: ignore -def load_fixture(path) -> Any: - """Load a JSON fixture from the fixtures directory.""" - fixtures: str = os.path.join( - os.path.dirname(os.path.realpath(__file__)), - "fixtures" - ) - with open(os.path.join(fixtures, path)) as fixture: - return json.load(fixture) - - def load_responses_fixture(path) -> None: """Load a JSON fixture containing all the API response examples.""" - def get_responses_method(method: str) -> str: - """ + def _load_json_fixtures(path: str) -> List[Dict[str, Any]]: + """Load JSON fixtures from file.""" + cwd: str = os.path.dirname(os.path.realpath(__file__)) + fixtures: str = os.path.join(os.path.join(cwd, 'fixtures'), path) + with open(fixtures) as fixture: + return json.load(fixture) + + def _get_responses_method(method: str) -> str: + """Returns the responses method based upon the supplied method. + + Args: + method (str): The response method. + Raises: ValueError: if the specified method is invalid. """ @@ -54,17 +55,27 @@ def get_responses_method(method: str) -> str: return responses.PATCH raise ValueError(f"Unable to find method '{method}' in responses") - fixture = load_fixture(path) - for response in fixture: - match = [] - if response.get("match_json"): - match.append(responses.json_params_matcher(response["match_json"])) + fixtures: List[Dict[str, Any]] = _load_json_fixtures(path) + for fixture in fixtures: + # Add the matchers for the request parameters or JSON body + matchers: List[Any] = [] + if fixture.get('match_json_params'): + matchers.append( + responses.json_params_matcher(fixture['match_json_params']) + ) + if fixture.get('match_urlencoded_params'): + matchers.append( + responses.urlencoded_params_matcher( + fixture['match_urlencoded_params'] + ) + ) + # Register the mocked response responses.add( - get_responses_method(response["method"]), - url=response["url"], - json=response.get("json"), - status=response["status"], - content_type=response.get("content_type", "application/json"), - match=match - ) \ No newline at end of file + _get_responses_method(fixture["method"]), + url=fixture["url"], + json=fixture.get("json"), + status=fixture["status"], + content_type=fixture.get("content_type", "application/json"), + match=matchers + ) From 7771f01a2b39a9cc1e67042a5e6918f416da898b Mon Sep 17 00:00:00 2001 From: Roald Nefs Date: Sat, 9 Jan 2021 14:59:32 +0100 Subject: [PATCH 06/12] Use unittest.TestCase for all tests Signed-off-by: Roald Nefs --- tests/conftest.py | 38 --------------- tests/services/test_domains.py | 49 ++++++++++--------- tests/services/test_invoices.py | 84 ++++++++++++--------------------- tests/services/test_ssh_keys.py | 36 +++++++------- tests/test_transip.py | 33 ++++++++++--- tests/utils.py | 6 +-- 6 files changed, 105 insertions(+), 141 deletions(-) delete mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index eb1bfb7..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,38 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2020 Roald Nefs -# -# This file is part of python-transip. - -# python-transip is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# python-transip is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. - -# You should have received a copy of the GNU Lesser General Public License -# along with python-transip. If not, see . - -import pytest - -from typing import Type - -from transip import TransIP - - -@pytest.fixture(scope="class") -def minimal_client_class(request): - request.cls.client = TransIP( - access_token="access_token" - ) - - -@pytest.fixture -def transip_minimal_client() -> TransIP: - return TransIP( - access_token="access_token" - ) diff --git a/tests/services/test_domains.py b/tests/services/test_domains.py index a01fe98..5cc6cd7 100644 --- a/tests/services/test_domains.py +++ b/tests/services/test_domains.py @@ -1,72 +1,75 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2020 Roald Nefs +# Copyright (C) 2020, 2012 Roald Nefs # # This file is part of python-transip. - +# # python-transip is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. - +# # python-transip is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. - +# # You should have received a copy of the GNU Lesser General Public License # along with python-transip. If not, see . import responses # type: ignore import unittest -import pytest -from typing import Type, List, Dict, Any, Union +from typing import List, Dict, Any, Union from transip import TransIP from transip.v6.objects import Domain, WhoisContact, Nameserver, DnsEntry -from tests.utils import load_responses_fixture +from tests.utils import load_responses_fixtures -@pytest.mark.usefixtures("minimal_client_class") class DomainsTest(unittest.TestCase): """Test the DomainService.""" - client: Type[TransIP] + client: TransIP + + @classmethod + def setUpClass(cls) -> None: + """Set up a minimal TransIP client for using the domain services.""" + cls.client = TransIP(access_token='ACCESS_TOKEN') - def setUp(self): - # Setup mocked responses for the /domains endpoint - load_responses_fixture("domains.json") + def setUp(self) -> None: + """Setup mocked responses for the '/domains' endpoint.""" + load_responses_fixtures("domains.json") @responses.activate def test_get(self) -> None: - domain: Type[Domain] = self.client.domains.get("example.com") + domain: Domain = self.client.domains.get("example.com") assert domain.get_id() == "example.com" # type: ignore @responses.activate def test_contacts_list(self) -> None: - domain: Type[Domain] = self.client.domains.get("example.com") - contacts: List[Type[Domain]] = domain.contacts.list() # type: ignore - contact: Type[Domain] = contacts[0] + domain: Domain = self.client.domains.get("example.com") + contacts: List[Domain] = domain.contacts.list() # type: ignore + contact: Domain = contacts[0] assert len(contacts) == 1 assert contact.companyName == "Example B.V." # type: ignore @responses.activate def test_nameservers_list(self) -> None: - domain: Type[Domain] = self.client.domains.get("example.com") - nameservers: List[Type[Nameserver]] = domain.nameservers.list() # type: ignore - nameserver: Type[Nameserver] = nameservers[0] + domain: Domain = self.client.domains.get("example.com") + nameservers: List[Nameserver] = domain.nameservers.list() # type: ignore + nameserver: Nameserver = nameservers[0] assert len(nameservers) == 1 assert nameserver.get_id() == "ns0.transip.nl" # type: ignore @responses.activate def test_dns_list(self) -> None: - domain: Type[Domain] = self.client.domains.get("example.com") - entries: List[Type[DnsEntry]] = domain.dns.list() # type: ignore - entry: Type[DnsEntry] = entries[0] + domain: Domain = self.client.domains.get("example.com") + entries: List[DnsEntry] = domain.dns.list() # type: ignore + entry: DnsEntry = entries[0] assert len(entries) == 1 assert entry.name == "www" # type: ignore @@ -79,7 +82,7 @@ def test_dns_create(self) -> None: "type": "A", "content": "127.0.0.1" } - domain: Type[Domain] = self.client.domains.get("example.com") + domain: Domain = self.client.domains.get("example.com") domain.dns.create(dns_entry_data) # type: ignore assert len(responses.calls) == 2 diff --git a/tests/services/test_invoices.py b/tests/services/test_invoices.py index e82321d..9149a96 100644 --- a/tests/services/test_invoices.py +++ b/tests/services/test_invoices.py @@ -1,77 +1,55 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2020 Roald Nefs +# Copyright (C) 2020, 2021 Roald Nefs # # This file is part of python-transip. - +# # python-transip is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. - +# # python-transip is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. - +# # You should have received a copy of the GNU Lesser General Public License # along with python-transip. If not, see . -from typing import Type, List +from typing import List, Tuple, Any, Dict import responses # type: ignore +import unittest from transip import TransIP from transip.v6.objects import Invoice +from tests.utils import load_responses_fixtures +class InvoicesTest(unittest.TestCase): + """Test the InvoiceService.""" -@responses.activate -def test_invoices_list(transip_minimal_client: Type[TransIP]) -> None: - responses.add( - responses.GET, - "https://api.transip.nl/v6/invoices", - json={ - "invoices": [ - { - "invoiceNumber": "F0000.1911.0000.0004", - "creationDate": "2020-01-01", - "payDate": "2020-01-01", - "dueDate": "2020-02-01", - "invoiceStatus": "waitsforpayment", - "currency": "EUR", - "totalAmount": 1000, - "totalAmountInclVat": 1240 - } - ] - }, - status=200, - ) - - invoices: List[Type[Invoice]] = transip_minimal_client.invoices.list() - invoice: Type[Invoice] = invoices[0] - assert len(invoices) == 1 - assert invoice.get_id() == "F0000.1911.0000.0004" # type: ignore + client: TransIP + + @classmethod + def setUpClass(cls) -> None: + """Set up a minimal TransIP client for using the invoice services.""" + cls.client = TransIP(access_token='ACCESS_TOKEN') + def setUp(self) -> None: + """Setup mocked responses for the '/invoices' endpoint.""" + load_responses_fixtures("account.json") -@responses.activate -def test_invoices_get(transip_minimal_client: Type[TransIP]) -> None: - responses.add( - responses.GET, - "https://api.transip.nl/v6/invoices/F0000.1911.0000.0004", - json={ - "invoice": { - "invoiceNumber": "F0000.1911.0000.0004", - "creationDate": "2020-01-01", - "payDate": "2020-01-01", - "dueDate": "2020-02-01", - "invoiceStatus": "waitsforpayment", - "currency": "EUR", - "totalAmount": 1000, - "totalAmountInclVat": 1240 - } - }, - status=200, - ) + @responses.activate + def test_list(self) -> None: + invoices: List[Invoice] = self.client.invoices.list() + invoice: Invoice = invoices[0] - invoice_id: str = "F0000.1911.0000.0004" - invoice: Type[Invoice] = transip_minimal_client.invoices.get(invoice_id) - assert invoice.get_id() == "F0000.1911.0000.0004" # type: ignore + assert len(invoices) == 1 + assert invoice.get_id() == "F0000.1911.0000.0004" # type: ignore + + @responses.activate + def test_get(self) -> None: + invoice_id: str = "F0000.1911.0000.0004" + invoice: Invoice = self.client.invoices.get(invoice_id) + + assert invoice.get_id() == "F0000.1911.0000.0004" # type: ignore diff --git a/tests/services/test_ssh_keys.py b/tests/services/test_ssh_keys.py index 4cb08bc..9ec098c 100644 --- a/tests/services/test_ssh_keys.py +++ b/tests/services/test_ssh_keys.py @@ -1,47 +1,49 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2020 Roald Nefs +# Copyright (C) 2020, 2021 Roald Nefs # # This file is part of python-transip. - +# # python-transip is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. - +# # python-transip is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. - +# # You should have received a copy of the GNU Lesser General Public License # along with python-transip. If not, see . -from typing import Type, List, Tuple, Any, Dict +from typing import List, Tuple, Any, Dict import responses # type: ignore -import json -import pytest import unittest from transip import TransIP from transip.v6.objects import SshKey -from tests.utils import load_responses_fixture +from tests.utils import load_responses_fixtures -@pytest.mark.usefixtures("minimal_client_class") class SshKeysTest(unittest.TestCase): """Test the SshKeyService.""" - client: Type[TransIP] + client: TransIP + + @classmethod + def setUpClass(cls) -> None: + """Set up a minimal TransIP client for using the ssh-key services.""" + cls.client = TransIP(access_token='ACCESS_TOKEN') - def setUp(self): - # Setup mocked responses for the /ssh-keys endpoint - load_responses_fixture("account.json") + def setUp(self) -> None: + """Setup mocked responses for the '/ssh-keys' endpoint.""" + load_responses_fixtures("account.json") @responses.activate def test_list(self) -> None: - ssh_keys: List[Type[SshKey]] = self.client.ssh_keys.list() - ssh_key: Type[SshKey] = ssh_keys[0] + ssh_keys: List[SshKey] = self.client.ssh_keys.list() + ssh_key: SshKey = ssh_keys[0] assert len(ssh_keys) == 1 assert ssh_key.get_id() == 123 # type: ignore @@ -49,7 +51,7 @@ def test_list(self) -> None: @responses.activate def test_get(self) -> None: ssh_key_id: int = 123 - ssh_key: Type[SshKey] = self.client.ssh_keys.get(ssh_key_id) + ssh_key: SshKey = self.client.ssh_keys.get(ssh_key_id) assert ssh_key.get_id() == 123 # type: ignore @@ -64,7 +66,7 @@ def test_delete(self) -> None: @responses.activate def test_delete_object(self) -> None: ssh_key_id: int = 123 - ssh_key: Type[SshKey] = self.client.ssh_keys.get(ssh_key_id) + ssh_key: SshKey = self.client.ssh_keys.get(ssh_key_id) try: ssh_key.delete() # type: ignore diff --git a/tests/test_transip.py b/tests/test_transip.py index 0e1cd86..1ef23c0 100644 --- a/tests/test_transip.py +++ b/tests/test_transip.py @@ -1,26 +1,45 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2020 Roald Nefs +# Copyright (C) 2020, 2021 Roald Nefs # # This file is part of python-transip. - +# # python-transip is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. - +# # python-transip is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. - +# # You should have received a copy of the GNU Lesser General Public License # along with python-transip. If not, see . -from typing import Type +import unittest from transip import TransIP -def test_transip_url(transip_minimal_client: Type[TransIP]) -> None: - assert transip_minimal_client.url == "https://api.transip.nl/v6" +class TransIPTest(unittest.TestCase): + """Test the TransIP client class.""" + + client: TransIP + + @classmethod + def setUpClass(self) -> None: + """Set up a minimal TransIP client.""" + self.client = TransIP(access_token='ACCESS_TOKEN') + + def test_base_url(self) -> None: + """Test the base URL of the API.""" + url: str = self.client.url + + assert url == "https://api.transip.nl/v6" + + def test_authorization_header(self) -> None: + """Test if the Authorization header is set correctly.""" + auth_header: str = self.client.headers["Authorization"] + + assert auth_header == "Bearer ACCESS_TOKEN" diff --git a/tests/utils.py b/tests/utils.py index b62590c..d94e108 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -3,7 +3,7 @@ # Copyright (C) 2021 Roald Nefs # # This file is part of python-transip. - +# # python-transip is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, either version 3 of the License, or @@ -13,7 +13,7 @@ # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. - +# # You should have received a copy of the GNU Lesser General Public License # along with python-transip. If not, see . @@ -23,7 +23,7 @@ import responses # type: ignore -def load_responses_fixture(path) -> None: +def load_responses_fixtures(path) -> None: """Load a JSON fixture containing all the API response examples.""" def _load_json_fixtures(path: str) -> List[Dict[str, Any]]: From 92d90c90d9a74b9e2ff0628f028c6ef061d0fbb8 Mon Sep 17 00:00:00 2001 From: Roald Nefs Date: Sun, 10 Jan 2021 16:31:31 +0100 Subject: [PATCH 07/12] Add option to authentication using private key The TransIP API also allows the creation of an access token by using a private key. By allowing the user to specify either an access token or private key when initialising a new transip.TransIP client, the client could dynamically generate a new access token when a private key is used. Fixes #14 Signed-off-by: Roald Nefs --- setup.py | 2 +- transip/__init__.py | 210 ++++++++++++++++++++++++++++++++++---------- transip/utils.py | 84 ++++++++++++++++++ 3 files changed, 247 insertions(+), 49 deletions(-) create mode 100644 transip/utils.py diff --git a/setup.py b/setup.py index 04dfcfe..d8e0024 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ def get_long_description() -> str: url="https://github.com/roaldnefs/python-transip", packages=find_packages(), include_package_data=True, - install_requires=["requests>=2.25.1"], + install_requires=["cryptography>=3.3.1", "requests>=2.25.1"], python_requires=">=3.6.12", entry_points={}, classifiers=[ diff --git a/transip/__init__.py b/transip/__init__.py index 2ded4d6..f6a5543 100644 --- a/transip/__init__.py +++ b/transip/__init__.py @@ -3,27 +3,29 @@ # Copyright (C) 2020, 2021 Roald Nefs # # This file is part of python-transip. - +# # python-transip is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. - +# # python-transip is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. - +# # You should have received a copy of the GNU Lesser General Public License # along with python-transip. If not, see . """Wrapper for the TransIP API.""" import importlib import requests +import base64 from typing import Dict, Optional, Any, Type from transip.exceptions import TransIPHTTPError, TransIPParsingError +from transip.utils import generate_message_signature, generate_nonce __title__ = "python-transip" @@ -38,29 +40,41 @@ class TransIP: """Represents a TransIP server connection. Args: + login (str): The TransIP username api_version (str): TransIP API version to use access_token (str): The TransIP API access token + private_key (str): The content of the private key for accessing the + TransIP API + private_key_file (str): Path to the private key for accessing the + TransIP API """ def __init__( self, + login: str = None, api_version: str = "6", - access_token: Optional[str] = None + access_token: Optional[str] = None, + private_key: Optional[str] = None, + private_key_file: Optional[str] = None, ) -> None: - self._api_version: str = api_version self._url: str = f"https://api.transip.nl/v{api_version}" - self._access_token: Optional[str] = access_token # Headers to use when making a request to TransIP self.headers: Dict[str, str] = { - "User-Agent": f"{__title__}/{__version__}", - "Authorization": f"Bearer {access_token}" + "User-Agent": f"{__title__}/{__version__}" } # Initialize a session object for making requests self.session: requests.Session = requests.Session() + # Set authentication information + self._login: str = login + self._access_token: Optional[str] = access_token + self._private_key: Optional[str] = private_key + self._private_key_file: Optional[str] = private_key_file + self._set_auth_info() + # Dynamically import the services for the specified API version objects = importlib.import_module(f"transip.v{api_version}.objects") @@ -91,14 +105,133 @@ def _get_headers( def _build_url(self, path: str) -> str: return f"{self._url}{path}" - def _send( + def _request_access_token(self) -> str: + """ + Request an access token using the supplied private key. + + Returns: + str: The access token to use for authorization. + + Raises: + TransIPParsingError: If the requested access token couldn't be + extracted from the API response. + """ + + url: str = self._build_url('/auth') + payload: Dict[str, Any] = { + "login": self._login, + # The TransIP API requires that the length of the nonce is between + # 6 and 32 characters + "nonce": generate_nonce(32), + # TODO(roaldnefs): Allow the creation of read-only access tokens + "read_only": False, + # TODO(roaldnefs): Allow the expiration time of the access token + # to be overwritten + # "expiration_time": "30 minutes", + # TODO(roaldnefs): Allow a custom label to be specified when + # generating a new access token + # "label": "python-transip", + # TODO(roaldnefs): Allow the access token to only be use from + # whitelisted IP-addresses + "global_key": False + } + + headers: Dict[str, str] = self.headers.copy() + request: requests.Request = requests.Request("POST", url, headers=headers, json=payload) + prepped: requests.PreparedRequest = self.session.prepare_request(request) + + # Get the prepped body for signature generation + body: str = prepped.body + + # Generate a signature if the request body + signature: str = generate_message_signature(body, self._private_key) + + # Add 'Signature' header to the prepared request + prepped.headers["Signature"] = signature + + response: requests.Response = self.session.send(prepped) + data = self._validate_response(response) + + # Attempt to extract the access token from the result + try: + return data['token'] + except (AttributeError, KeyError) as exc: + raise TransIPParsingError( + "Failed to extract access token from the API response" + ) from exc + + def _read_private_key(self) -> str: + """Read the private key from file. + + Returns: + str: The private key content + + Raises: + RuntimeError: If the private key file doesn't exist + """ + if os.path.exists(self._private_key_file): + try: + with open(self._private_key_file) as keyfile: + return keyfile.read() + except IOError as exc: + raise RuntimeError("The private key couldn't be read") from exc + else: + raise RuntimeError("The private key doesn't exist") + + def _set_auth_info(self) -> None: + """ + Set authentication information based upon the defined attributes. + + Raises: + ValueError: If the required attributes are not defined. + """ + if not self._access_token and not self._private_key and not self._private_key_file: + raise ValueError( + "At least one of access_token, private_key and " + "private_key_file should be defined" + ) + if self._access_token and self._private_key: + raise ValueError( + "Only one of access_token and private_key should be defined" + ) + if self._access_token and self._private_key_file: + raise ValueError( + "Only one of access_token and private_key_file should be " + "defined" + ) + if self._private_key and self._private_key_file: + raise ValueError( + "Only one of private_key and private_key_file should be " + "defined" + ) + if self._private_key and not self._login: + raise ValueError( + "Both private_key and login should be defined" + ) + if self._private_key_file and not self._login: + raise ValueError( + "Both private_key_file and login should be defined" + ) + + # Read the private key from file + if self._private_key_file: + self._private_key = self._read_private_key() + + # Use the private key to request a new access token + if self._private_key: + self._access_token = self._request_access_token() + + # Set the 'Authorization' header + self.headers["Authorization"] = f"Bearer {self._access_token}" + + def request( self, method: str, path: str, data: Optional[Any] = None, json: Optional[Any] = None, params: Optional[Dict[str, Any]] = None - ) -> requests.Response: + ) -> Any: """Make an HTTP request to the TransIP API. Args: @@ -109,10 +242,11 @@ def _send( params (dict): URL parameters to append to the URL Returns: - A requests response object. + Returns the json-encoded content of a response, if any. Raises: TransIPHTTPError: When the return code of the request is not 2xx + TransIPParsingError: When the content couldn't be parsed as JSON """ url: str = self._build_url(path) @@ -131,9 +265,25 @@ def _send( request ) response: requests.Response = self.session.send(prepped) + return self._validate_response(response) + def _validate_response(self, response: requests.Response) -> Any: + """ + Validate the API response. + + Raises: + TransIPHTTPError: When the return code of the request is not 2xx + TransIPParsingError: When the content couldn't be parsed as JSON + """ if 200 <= response.status_code < 300: - return response + if response.text: + try: + return response.json() + except Exception: + raise TransIPParsingError( + message="Failed to parse the API response as JSON" + ) + return None error_message = str(response.content) try: @@ -148,42 +298,6 @@ def _send( response_code=response.status_code ) - def request( - self, - method: str, - path: str, - data: Optional[Any] = None, - json: Optional[Any] = None, - params: Optional[Dict[str, Any]] = None - ) -> Any: - """Make an HTTP request to the TransIP API. - - Args: - method (str): HTTP method to use - path (str): The path to append to the API URL - data (dict): The body to attach to the request - json (dict): The json body to attach to the request - params (dict): URL parameters to append to the URL - - Returns: - Returns the json-encoded content of a response, if any. - - Raises: - TransIPHTTPError: When the return code of the request is not 2xx - """ - response: requests.Response = self._send( - method, path, data=data, json=json, params=params - ) - - if response.text: - try: - return response.json() - except Exception: - raise TransIPParsingError( - message="Failed to parse the API response as JSON" - ) - return None - def get( self, path: str, diff --git a/transip/utils.py b/transip/utils.py new file mode 100644 index 0000000..47c8fed --- /dev/null +++ b/transip/utils.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Roald Nefs +# +# This file is part of python-transip. +# +# python-transip is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# python-transip is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with python-transip. If not, see . + +import base64 +import os +import secrets +import string + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from cryptography.hazmat.primitives.hashes import SHA512 +from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 + + +def load_rsa_private_key(key: str) -> RSAPrivateKey: + """ + Convert the private key string to RSAPrivateKey object. + + Returns: + RSAPrivateKey: The private RSA key. + """ + # Convert the key string to bytes + if isinstance(key, str): + key: bytes = key.encode() + + return serialization.load_pem_private_key( + key, password=None, backend=default_backend() + ) + + +def generate_message_signature(message: str, private_key: str) -> str: + """Return the BASE64 encoded SHA514 signature of a message. + + Args: + message (str): The message to sign. + private_key (str): The private key content used to sign the message. + + Returns: + str: The BASE64 encoded SHA514 signature of a message. + """ + # Convert the message string to bytes + if isinstance(message, str): + message: bytes = message.encode() + + # Convert the private key content to a RSAPrivateKey object + if isinstance(private_key, str): + private_key: RSAPrivateKey = load_rsa_private_key(private_key) + + # Sign the message using the RSAPrivateKey object + signature: str = private_key.sign(message, PKCS1v15(), SHA512()) + + # Return the BASE64 encoded SHA512 signature + return base64.b64encode(signature) + + +def generate_nonce(length: int) -> str: + """ + Generate a nonce. + + Args: + length (int): The number of characters to return. + + Returns: + str: The nonce of specified characters. + """ + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for i in range(length)) From 567d569bc35a15fdd6cc0af946530141287caefd Mon Sep 17 00:00:00 2001 From: Roald Nefs Date: Sun, 10 Jan 2021 19:10:33 +0100 Subject: [PATCH 08/12] Fix type hints and pep8 syntax errors Signed-off-by: Roald Nefs --- transip/__init__.py | 40 ++++++++++++++++++++++++---------------- transip/utils.py | 31 ++++++++++++++++++------------- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/transip/__init__.py b/transip/__init__.py index f6a5543..99fb314 100644 --- a/transip/__init__.py +++ b/transip/__init__.py @@ -18,11 +18,11 @@ # along with python-transip. If not, see . """Wrapper for the TransIP API.""" +from typing import Dict, Optional, Any, Type, Union + import importlib import requests -import base64 - -from typing import Dict, Optional, Any, Type +import os from transip.exceptions import TransIPHTTPError, TransIPParsingError from transip.utils import generate_message_signature, generate_nonce @@ -44,7 +44,7 @@ class TransIP: api_version (str): TransIP API version to use access_token (str): The TransIP API access token private_key (str): The content of the private key for accessing the - TransIP API + TransIP API private_key_file (str): Path to the private key for accessing the TransIP API """ @@ -69,7 +69,7 @@ def __init__( self.session: requests.Session = requests.Session() # Set authentication information - self._login: str = login + self._login: Optional[str] = login self._access_token: Optional[str] = access_token self._private_key: Optional[str] = private_key self._private_key_file: Optional[str] = private_key_file @@ -116,7 +116,6 @@ def _request_access_token(self) -> str: TransIPParsingError: If the requested access token couldn't be extracted from the API response. """ - url: str = self._build_url('/auth') payload: Dict[str, Any] = { "login": self._login, @@ -137,14 +136,22 @@ def _request_access_token(self) -> str: } headers: Dict[str, str] = self.headers.copy() - request: requests.Request = requests.Request("POST", url, headers=headers, json=payload) - prepped: requests.PreparedRequest = self.session.prepare_request(request) + request: requests.Request = requests.Request( + "POST", url, headers=headers, json=payload + ) + prepped: requests.PreparedRequest = self.session.prepare_request( + request + ) # Get the prepped body for signature generation - body: str = prepped.body + body: Union[bytes, str] = prepped.body or '' + if isinstance(body, bytes): + body = body.decode('ascii') # Generate a signature if the request body - signature: str = generate_message_signature(body, self._private_key) + signature: str = generate_message_signature( + body, self._private_key # type: ignore + ) # Add 'Signature' header to the prepared request prepped.headers["Signature"] = signature @@ -165,13 +172,13 @@ def _read_private_key(self) -> str: Returns: str: The private key content - + Raises: RuntimeError: If the private key file doesn't exist """ - if os.path.exists(self._private_key_file): + if os.path.exists(self._private_key_file): # type: ignore try: - with open(self._private_key_file) as keyfile: + with open(self._private_key_file) as keyfile: # type: ignore return keyfile.read() except IOError as exc: raise RuntimeError("The private key couldn't be read") from exc @@ -181,11 +188,12 @@ def _read_private_key(self) -> str: def _set_auth_info(self) -> None: """ Set authentication information based upon the defined attributes. - + Raises: ValueError: If the required attributes are not defined. """ - if not self._access_token and not self._private_key and not self._private_key_file: + if (not self._access_token and not self._private_key and not + self._private_key_file): raise ValueError( "At least one of access_token, private_key and " "private_key_file should be defined" @@ -270,7 +278,7 @@ def request( def _validate_response(self, response: requests.Response) -> Any: """ Validate the API response. - + Raises: TransIPHTTPError: When the return code of the request is not 2xx TransIPParsingError: When the content couldn't be parsed as JSON diff --git a/transip/utils.py b/transip/utils.py index 47c8fed..b462f1b 100644 --- a/transip/utils.py +++ b/transip/utils.py @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with python-transip. If not, see . +from typing import Union + import base64 -import os import secrets import string @@ -29,45 +30,49 @@ from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 -def load_rsa_private_key(key: str) -> RSAPrivateKey: +def load_rsa_private_key(key: Union[bytes, str]) -> RSAPrivateKey: """ Convert the private key string to RSAPrivateKey object. - + Returns: RSAPrivateKey: The private RSA key. """ # Convert the key string to bytes if isinstance(key, str): - key: bytes = key.encode() - + key = key.encode() + return serialization.load_pem_private_key( key, password=None, backend=default_backend() ) -def generate_message_signature(message: str, private_key: str) -> str: +def generate_message_signature( + message: Union[str, bytes], + private_key: Union[RSAPrivateKey, str] +) -> str: """Return the BASE64 encoded SHA514 signature of a message. - + Args: message (str): The message to sign. private_key (str): The private key content used to sign the message. - + Returns: str: The BASE64 encoded SHA514 signature of a message. """ # Convert the message string to bytes if isinstance(message, str): - message: bytes = message.encode() + message = message.encode() # Convert the private key content to a RSAPrivateKey object if isinstance(private_key, str): - private_key: RSAPrivateKey = load_rsa_private_key(private_key) + private_key = load_rsa_private_key(private_key) # Sign the message using the RSAPrivateKey object - signature: str = private_key.sign(message, PKCS1v15(), SHA512()) + signature: bytes = private_key.sign(message, PKCS1v15(), SHA512()) # Return the BASE64 encoded SHA512 signature - return base64.b64encode(signature) + b64_bytes: bytes = base64.b64encode(signature) + return b64_bytes.decode('ascii') def generate_nonce(length: int) -> str: @@ -80,5 +85,5 @@ def generate_nonce(length: int) -> str: Returns: str: The nonce of specified characters. """ - alphabet = string.ascii_letters + string.digits + alphabet: str = string.ascii_letters + string.digits return ''.join(secrets.choice(alphabet) for i in range(length)) From 024d32c4343fcc33a9787979f15cc639a524fc0f Mon Sep 17 00:00:00 2001 From: Roald Nefs Date: Sun, 10 Jan 2021 20:08:26 +0100 Subject: [PATCH 09/12] Add tests for transip.utils.* Signed-off-by: Roald Nefs --- tests/test_utils.py | 112 ++++++++++++++++++++++++++++++++++++++++++++ transip/utils.py | 11 ++++- 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 tests/test_utils.py diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..ff224d2 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Roald Nefs +# +# This file is part of python-transip. +# +# python-transip is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# python-transip is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with python-transip. If not, see . + +import unittest +import string + +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey + +from transip.utils import ( + load_rsa_private_key, generate_message_signature, generate_nonce +) + + +class UtilsTest(unittest.TestCase): + """Test the transip.utils functions.""" + + privkey: str + + @classmethod + def setUpClass(cls) -> None: + cls.privkey: str = ( # type: ignore + "-----BEGIN RSA PRIVATE KEY-----\n" + "MIIEpAIBAAKCAQEAsUSEHsMuB380OUZQWDyyND4q8lEuJAgNnMkO8s5NGwzP8XSi\n" + "2DdFglLGLe9kjpADs3XqZFsk8ZFFn7x0idFydGyh9tbJ2WkR9E+kNUJV5iQDzPOB\n" + "wvyygEREqnl/o1h3c1q8tD2HZKBcjChn9JbMzdWwAaIs3ppcGWrEI0jZFFfSAyIZ\n" + "GkC3k3umOykWIKflQcT/soAfdqW+2P9/KD/wb3AZCer2i6B2hiITiDbHh5q84Hgk\n" + "D/Zg1M4yrYDyxDeGkAJHkGKNaE0tgUPoz3XTGP7uFYIx00qJyhmnzQcyV/Xcw3ZQ\n" + "7DFUj1HQ5wG/kEF9a4F1+AAiO5C5QbGTFYSwBwIDAQABAoIBABbtIZlI7P8TOJHf\n" + "wixnTTTshWlpjmoikIAikMheXiKNeadkylrkaxz7z53JRFwbzB69tV7dWt3TSAns\n" + "ubXJXOAp3JisFtcDe8r5MeeheLKXHda396RcQknMioTxycw6eNh2d8ln28br5oxJ\n" + "/YfoqPxGEsljTCJOHHM9F7johwrWSQ6f+gmiOkABvIHKgTBLa++v0D+vNrUjM6rx\n" + "IE+dBrx8yIgkF4qSg4Dqnr7D0KqCZUGLZ/3K8ShQUtiQYzyHIWKUId3NUecIQcrT\n" + "2Ri2TITKuER0fa7Mr+3LMSh/3+HtP2AoM34ouxr9H98LFz/UXxuFIRFTx7UVRt4N\n" + "3zqhsEECgYEA+TnXanBJmFz3sNYtlQixtKrh496GB0NheuK4xeNEj9/3gJ6J/rtL\n" + "ZHI7VH8r6aqoqw7sO/WJdxkwZTBOz2fe1QJ5BN0HBI5S6jIBQv9Nfqar0TDvNLB+\n" + "pH6eYJZ/IEFIMObv9YmsPohXpGeXynecrpl8SazEIWLb8IzgLY0HpokCgYEAthX5\n" + "1th4Re0P9rzXp21bbEwcvOKcg5dcpSaTtA1eQEILl6qqT3FP7w8/Ed7NRRY9Gcs+\n" + "inAc96YRNAgIGgfT3R1BmxOMWfdFBT1zlCheS6egKKLzVPzKPiMoMP4zu4hy6uH5\n" + "YVqpDLu0YQu1J2L0VYdZ9xAC0//Rx8KRcs6m/g8CgYEA41VDja+HMhf7R67WPU+E\n" + "6YvGKRjdoNpxnKoaaUd5TtO46/WxYk5t4t3gCJ9H6wjkecRO8BJ0pdKwNlzuRno0\n" + "5JAw26LRt/Iq571dMUO36IMXzuWYDLPBkUJ+LRSaOU3TD+hXkd1W5GNxrmFgMCsT\n" + "HKCcooeZD+shPDcEdghipiECgYARBTDbYlSrxKMPX0uRPOmkz+CHz27t5gIk9dws\n" + "omtC+ml2/d75mg/surIci4UIhjGj7Zmk+yHaDE3jXTTUqhKlwoxVYJhn+HMdMEdT\n" + "fAqEa+DOq5yvPwnwkPy6x6gySWjkh8b10LGonQsZXyzJx7grHoHMVFTPWERVtdw+\n" + "rQ5zBQKBgQC0Iwx4eeQYx60OCZpioNEQ3QPaFgqoWYEexmcMlpgQ9ycdnHx3SkE8\n" + "SlMokcPIEJDhdF3632kIAHOOJeA4Tmshf+ol/O2U2PDgbJZL6W6FJlT28sZVUU8j\n" + "IjFmiAiW6IIEqkJxuR1diAjppEiMmSkjPavo7oQs0TZMMUkli1N9dw==\n" + "-----END RSA PRIVATE KEY-----" + ) + + def test_load_rsa_private_key(self) -> None: + """ + Test the content of a RSA private key can be converted to a + RSAPrivateKey instance. + """ + self.assertTrue(isinstance(load_rsa_private_key(self.privkey), RSAPrivateKey)) + + def test_generate_message_signature(self) -> None: + """ + Test message signature generation using the defined RSA private key. + + Example message encoded using: + https://8gwifi.org/rsasignverifyfunctions.jsp + """ + message: str = "A message for signing" + encoded: str = ("NFi2v07lhYmyTarOtIfpw50W25ukKWjtqsVzti/Y2RiGKPEzJQtFZ" + "QaYJCFfIn8HfYjdbzOTK5DIFxwL8NCJK3Mb+wxZOkO4NDJC7mVgdO" + "I6VuET4F3Er4ZjO4pkMLSaV6B0Mcm/yj8Wom1lfeRZxItDXPAbkMj" + "47Ywsx7enAEXfrZrYwHy+rWLPN6WWCrCDWAJGu7lz5+YIy7rpLyRx" + "Ff57QkMMJal0VCWyQUx+JBMdoW7rGVN1u+AxRY0yFj+QxWRB1z0JC" + "E0Xmur+gQ+4+rgIEDE6VU2VY0A8+SY7hyRb2JN8yoLAeI+21ODwo5" + "h/x1zw3Bstyzuvzo0QmHp7Mw==") + + self.assertTrue( + generate_message_signature(message, self.privkey) == encoded + ) + + def test_generate_nonce_length(self) -> None: + """ + Test the length of the generated nonce and whether or not an + exception is thrown for invalid lengths. + """ + # Test valid lengths + for length in [1, 2, 32]: + self.assertTrue(len(generate_nonce(length)) == length) + + # Test invalid lengths + for length in [0, -1]: + self.assertRaises(ValueError, generate_nonce, length) + + def test_generate_nonce_alphabet(self) -> None: + """ + Test if the generated nonce only contains characters from the alphabet. + """ + alphabet: str = 'a' + self.assertTrue(generate_nonce(3, alphabet) == 'aaa') diff --git a/transip/utils.py b/transip/utils.py index b462f1b..49c770e 100644 --- a/transip/utils.py +++ b/transip/utils.py @@ -75,15 +75,22 @@ def generate_message_signature( return b64_bytes.decode('ascii') -def generate_nonce(length: int) -> str: +def generate_nonce(length: int, alphabet: str = None) -> str: """ Generate a nonce. Args: length (int): The number of characters to return. + alphabet (str): The alphabet to choose characters from, defaults to + ascii leters and digits. Returns: str: The nonce of specified characters. """ - alphabet: str = string.ascii_letters + string.digits + if not length >= 1: + raise ValueError( + "The specified nonce length must greater or equal to 1" + ) + + alphabet = alphabet or (string.ascii_letters + string.digits) return ''.join(secrets.choice(alphabet) for i in range(length)) From d60a5dc3b36835e9e080155f2756f14fc56f3d5b Mon Sep 17 00:00:00 2001 From: Roald Nefs Date: Sun, 10 Jan 2021 20:49:32 +0100 Subject: [PATCH 10/12] Add test for authentication using private key Signed-off-by: Roald Nefs --- tests/fixtures/auth.json | 11 ++++++++ tests/test_transip.py | 58 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/auth.json diff --git a/tests/fixtures/auth.json b/tests/fixtures/auth.json new file mode 100644 index 0000000..955dbc0 --- /dev/null +++ b/tests/fixtures/auth.json @@ -0,0 +1,11 @@ +[ + { + "method": "POST", + "url": "https://api.transip.nl/v6/auth", + "json": { + "token": "ACCESS_TOKEN" + }, + "status": 200, + "content_type": "application/json" + } +] \ No newline at end of file diff --git a/tests/test_transip.py b/tests/test_transip.py index 1ef23c0..b4c12b3 100644 --- a/tests/test_transip.py +++ b/tests/test_transip.py @@ -18,19 +18,55 @@ # along with python-transip. If not, see . import unittest +import responses # type: ignore from transip import TransIP +from tests.utils import load_responses_fixtures class TransIPTest(unittest.TestCase): """Test the TransIP client class.""" client: TransIP + privkey: str @classmethod - def setUpClass(self) -> None: - """Set up a minimal TransIP client.""" - self.client = TransIP(access_token='ACCESS_TOKEN') + def setUpClass(cls) -> None: + """Set up a minimal TransIP client and RSA private key.""" + cls.client = TransIP(access_token='ACCESS_TOKEN') + cls.privkey: str = ( # type: ignore + "-----BEGIN RSA PRIVATE KEY-----\n" + "MIIEpAIBAAKCAQEAsUSEHsMuB380OUZQWDyyND4q8lEuJAgNnMkO8s5NGwzP8XSi\n" + "2DdFglLGLe9kjpADs3XqZFsk8ZFFn7x0idFydGyh9tbJ2WkR9E+kNUJV5iQDzPOB\n" + "wvyygEREqnl/o1h3c1q8tD2HZKBcjChn9JbMzdWwAaIs3ppcGWrEI0jZFFfSAyIZ\n" + "GkC3k3umOykWIKflQcT/soAfdqW+2P9/KD/wb3AZCer2i6B2hiITiDbHh5q84Hgk\n" + "D/Zg1M4yrYDyxDeGkAJHkGKNaE0tgUPoz3XTGP7uFYIx00qJyhmnzQcyV/Xcw3ZQ\n" + "7DFUj1HQ5wG/kEF9a4F1+AAiO5C5QbGTFYSwBwIDAQABAoIBABbtIZlI7P8TOJHf\n" + "wixnTTTshWlpjmoikIAikMheXiKNeadkylrkaxz7z53JRFwbzB69tV7dWt3TSAns\n" + "ubXJXOAp3JisFtcDe8r5MeeheLKXHda396RcQknMioTxycw6eNh2d8ln28br5oxJ\n" + "/YfoqPxGEsljTCJOHHM9F7johwrWSQ6f+gmiOkABvIHKgTBLa++v0D+vNrUjM6rx\n" + "IE+dBrx8yIgkF4qSg4Dqnr7D0KqCZUGLZ/3K8ShQUtiQYzyHIWKUId3NUecIQcrT\n" + "2Ri2TITKuER0fa7Mr+3LMSh/3+HtP2AoM34ouxr9H98LFz/UXxuFIRFTx7UVRt4N\n" + "3zqhsEECgYEA+TnXanBJmFz3sNYtlQixtKrh496GB0NheuK4xeNEj9/3gJ6J/rtL\n" + "ZHI7VH8r6aqoqw7sO/WJdxkwZTBOz2fe1QJ5BN0HBI5S6jIBQv9Nfqar0TDvNLB+\n" + "pH6eYJZ/IEFIMObv9YmsPohXpGeXynecrpl8SazEIWLb8IzgLY0HpokCgYEAthX5\n" + "1th4Re0P9rzXp21bbEwcvOKcg5dcpSaTtA1eQEILl6qqT3FP7w8/Ed7NRRY9Gcs+\n" + "inAc96YRNAgIGgfT3R1BmxOMWfdFBT1zlCheS6egKKLzVPzKPiMoMP4zu4hy6uH5\n" + "YVqpDLu0YQu1J2L0VYdZ9xAC0//Rx8KRcs6m/g8CgYEA41VDja+HMhf7R67WPU+E\n" + "6YvGKRjdoNpxnKoaaUd5TtO46/WxYk5t4t3gCJ9H6wjkecRO8BJ0pdKwNlzuRno0\n" + "5JAw26LRt/Iq571dMUO36IMXzuWYDLPBkUJ+LRSaOU3TD+hXkd1W5GNxrmFgMCsT\n" + "HKCcooeZD+shPDcEdghipiECgYARBTDbYlSrxKMPX0uRPOmkz+CHz27t5gIk9dws\n" + "omtC+ml2/d75mg/surIci4UIhjGj7Zmk+yHaDE3jXTTUqhKlwoxVYJhn+HMdMEdT\n" + "fAqEa+DOq5yvPwnwkPy6x6gySWjkh8b10LGonQsZXyzJx7grHoHMVFTPWERVtdw+\n" + "rQ5zBQKBgQC0Iwx4eeQYx60OCZpioNEQ3QPaFgqoWYEexmcMlpgQ9ycdnHx3SkE8\n" + "SlMokcPIEJDhdF3632kIAHOOJeA4Tmshf+ol/O2U2PDgbJZL6W6FJlT28sZVUU8j\n" + "IjFmiAiW6IIEqkJxuR1diAjppEiMmSkjPavo7oQs0TZMMUkli1N9dw==\n" + "-----END RSA PRIVATE KEY-----" + ) + + def setUp(self) -> None: + """Setup mocked responses for the '/auth' endpoint.""" + load_responses_fixtures("auth.json") def test_base_url(self) -> None: """Test the base URL of the API.""" @@ -43,3 +79,19 @@ def test_authorization_header(self) -> None: auth_header: str = self.client.headers["Authorization"] assert auth_header == "Bearer ACCESS_TOKEN" + + @responses.activate + def test_private_key_authorization_header(self) -> None: + """ + Test if TransIP instances initialized with a private key, results in + the 'Authorization' header containing the access token returned by the + mocked response. + """ + client: TransIP = TransIP(login="testuser", private_key=self.privkey) + auth_header: str = self.client.headers["Authorization"] + + # Assert a single request is made to the mocked TransIP API + self.assertEqual(len(responses.calls), 1) + # Assert the 'Authorization' header contains the access token returned + # by the mocked response + self.assertEqual(auth_header, "Bearer ACCESS_TOKEN") From 3bccf8c2f45c8d8c40ba9641e1b17a825cafca59 Mon Sep 17 00:00:00 2001 From: Roald Nefs Date: Sun, 10 Jan 2021 21:04:32 +0100 Subject: [PATCH 11/12] Add privkey auth examples to documentation Add private key authentication examples to the documentation and `README.md`. Signed-off-by: Roald Nefs --- README.md | 5 ++++- docs/index.rst | 5 ++++- docs/user/quickstart.rst | 19 ++++++++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4650de9..4277000 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,10 @@ ```python >>> import transip # Initialize a TransIP API client ->>> client = transip.TransIP(access_token="TOKEN") +>>> client = transip.TransIP( +... login="demouser", +... private_key_file="/path/to/private.key" +... ) # Retrieve a list of VPSs >>> for vps in client.vpss.list(): ... print(vps) diff --git a/docs/index.rst b/docs/index.rst index b8e0384..f9d1c35 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,7 +29,10 @@ Release v\ |version|. (:ref:`Installation `) >>> import transip # Initialize a TransIP API client - >>> client = transip.TransIP(access_token="TOKEN") + >>> client = transip.TransIP( + ... login="demouser", + ... private_key_file="/path/to/private.key" + ... ) # Retrieve a list of VPSs >>> for vps in client.vpss.list(): ... print(vps) diff --git a/docs/user/quickstart.rst b/docs/user/quickstart.rst index f2bfe4c..f999500 100644 --- a/docs/user/quickstart.rst +++ b/docs/user/quickstart.rst @@ -9,6 +9,10 @@ First, make sure that: * python-transip is :ref:`installed ` +Then you should be able to import the module:: + + >>> import transip + Below you'll find some simple example to get started. Authentication @@ -17,7 +21,20 @@ Authentication In order to make requests to the TransIP API we need to authenticate yourself using an access token. To get an access token, you should first login to the TransIP control panel. You can then generate a new token which will only be -valid for limited time. +valid for limited time or generate a private key to allow python-transip to +request a access token on initialization. + +Example of authentication using a private key:: + + >>> PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY----- + ... ... + ... -----END RSA PRIVATE KEY-----""" + >>> client = transip.TransIP(login="demouser", private_key=PRIVATE_KEY) + >>> client = transip.TransIP(login="demouser", private_key_file='/path/to/private.key') + +Example authentication using an access token:: + + >>> client = transip.TransIP(access_token="eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImN3MiFSbDU2eDNoUnkjelM4YmdOIn0.eyJpc3MiOiJhcGkudHJhbnNpcC5ubCIsImF1ZCI6ImFwaS50cmFuc2lwLm5sIiwianRpIjoiY3cyIVJsNTZ4M2hSeSN6UzhiZ04iLCJpYXQiOjE1ODIyMDE1NTAsIm5iZiI6MTU4MjIwMTU1MCwiZXhwIjoyMTE4NzQ1NTUwLCJjaWQiOiI2MDQ0OSIsInJvIjpmYWxzZSwiZ2siOmZhbHNlLCJrdiI6dHJ1ZX0.fYBWV4O5WPXxGuWG-vcrFWqmRHBm9yp0PHiYh_oAWxWxCaZX2Rf6WJfc13AxEeZ67-lY0TA2kSaOCp0PggBb_MGj73t4cH8gdwDJzANVxkiPL1Saqiw2NgZ3IHASJnisUWNnZp8HnrhLLe5ficvb1D9WOUOItmFC2ZgfGObNhlL2y-AMNLT4X7oNgrNTGm-mespo0jD_qH9dK5_evSzS3K8o03gu6p19jxfsnIh8TIVRvNdluYC2wo4qDl5EW5BEZ8OSuJ121ncOT1oRpzXB0cVZ9e5_UVAEr9X3f26_Eomg52-PjrgcRJ_jPIUYbrlo06KjjX2h0fzMr21ZE023Gw") TransIP also provide a **demo token** to authenticate yourself as the TransIP demo user in test mode:: From 25821fb64990bb9456f3527f0d76c6b6399f7214 Mon Sep 17 00:00:00 2001 From: Roald Nefs Date: Sun, 10 Jan 2021 21:12:37 +0100 Subject: [PATCH 12/12] Bump version to 0.3.0 Signed-off-by: Roald Nefs --- transip/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/transip/__init__.py b/transip/__init__.py index 99fb314..f4c8ee3 100644 --- a/transip/__init__.py +++ b/transip/__init__.py @@ -29,7 +29,7 @@ __title__ = "python-transip" -__version__ = "0.2.0" +__version__ = "0.3.0" __author__ = "Roald Nefs" __email__ = "info@roaldnefs.com" __copyright__ = "Copyright 2020-2021, Roald Nefs"