diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml new file mode 100644 index 0000000..bcbf65a --- /dev/null +++ b/.github/workflows/run_tests.yml @@ -0,0 +1,27 @@ +name: Run peasant tests + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements/basic.txt + pip install -r requirements/all.txt + pip install -r requirements/tests.txt + - name: Run python unit tests + run: | + PYTHONPATH=$PYTHONPATH:. python tests/runtests.py diff --git a/LICENSE b/LICENSE index 6e716e4..a8ca86b 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 Flavio Goncalves Garcia + Copyright 2020-2024 Flavio Garcia Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/MANIFEST.in b/MANIFEST.in index 740e798..86fef0b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ include README.md include LICENSE +include requirements/all.txt include requirements/basic.txt +include requirements/tornado.txt diff --git a/README.md b/README.md index 6ab7efe..6fa1a44 100644 --- a/README.md +++ b/README.md @@ -3,28 +3,29 @@ Peasant is a protocol abstraction of how to control agents that need to communicate with a central entity or entities. -We define agents as peasants and central entities(bases) as bastions. +We define agents as peasants and central entities (bases) as bastions. -This project won't define the implementation, security level neither levels of -redundancies but instead a minimal contract of what should be implemented. +Peasant will define some transport definition to help developer with basic http +methods (i.e. head, post, get, etc), and avoid code duplication. Security level +and your business should be implemented. A bastion/peasant relationship could be defined as stateful or not. If stateful it is necessary to implement a session control in the bastion where peasants -need to perform knocks(as knock at the door) in order to get permission or a -valid session. In a stateless case we just ignore any knock implementation. +need to perform knocks (as knock at the door) to get permission or a valid +session. In a stateless case we just ignore any knock implementation. What must be implemented in the protocol are nonce generation, consumption and -validation on both sides and a directory list of available resources offered by -a bastion for peasants to consume. +validation on both sides. A directory list of available resources offered by +a bastion for peasants to consume could also be useful to have. ## Support -Automatoes is one of +Peasant is one of [Candango Open Source Group](http://www.candango.org/projects/) initiatives. Available under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.html). -This web site and all documentation is licensed under +This website and all documentation are licensed under [Creative Commons 3.0](http://creativecommons.org/licenses/by/3.0/). -Copyright © 2020 Flavio Goncalves Garcia +Copyright © 2020-2024 Flavio Garcia diff --git a/peasant/__init__.py b/peasant/__init__.py index aa93bbb..9401a93 100644 --- a/peasant/__init__.py +++ b/peasant/__init__.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2020-2022 Flávio Gonçalves Garcia +# Copyright 2020-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,12 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -__author__ = "Flavio Goncalves Garcia " -__version__ = (0, 6) +__author__ = "Flavio Garcia " +__version__ = (0, 6, 1) __licence__ = "Apache License V2.0" def get_version(): + if isinstance(__version__[-1], str): + return '.'.join(map(str, __version__[:-1])) + __version__[-1] return ".".join(map(str, __version__)) diff --git a/peasant/client.py b/peasant/client.py index e69d0d7..47a94ab 100644 --- a/peasant/client.py +++ b/peasant/client.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2020 Flavio Goncalves Garcia +# Copyright 2020-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,24 +12,24 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy import logging +from peasant import get_version +from urllib.parse import urlencode logger = logging.getLogger(__name__) -class PeasantTransport(object): - - _peasant: 'Peasant' +class PeasantTransport: - def __init__(self): - self._peasant = None + _peasant: "Peasant" @property def peasant(self): return self._peasant @peasant.setter - def peasant(self, peasant): + def peasant(self, peasant: "Peasant"): self._peasant = peasant def get(self, path, **kwargs): @@ -99,3 +97,112 @@ async def directory(self): "asynchronously.") await future return self._directory_cache + + +tornado_installed = False +try: + from tornado.httpclient import HTTPRequest + from tornado import version as tornado_version + from tornado.httputil import url_concat + from tornado.httpclient import AsyncHTTPClient, HTTPClientError + tornado_installed = True + + def get_tornado_request(url, **kwargs): + """ Return a HTTPRequest to help with AsyncHTTPClient and HTTPClient + execution. The HTTPRequest will use the provided url combined with path + if provided. The HTTPRequest method will be GET by default and can be + changed if method is informed. + If form_urlencoded is defined as True a Content-Type header will be + added to the request with application/x-www-form-urlencoded value. + + :param str url: Base url to be set to the HTTPRequest + :key form_urlencoded: If the true will add the header Content-Type + application/x-www-form-urlencoded to the form. Default is False. + :key method: Method to be used by the HTTPRequest. Default it GET. + :key path: If informed will add the path to the base url informed. + Default is None. + :return HTTPRequest: + """ + method = kwargs.get("method", "GET") + path = kwargs.get("path", None) + form_urlencoded = kwargs.get("form_urlencoded", False) + if not url.endswith("/"): + url = f"{url}/" + if path is not None and path != "/": + url = f"{url}{path}" % () + request = HTTPRequest(url, method=method) + if form_urlencoded: + request.headers.add("Content-Type", + "application/x-www-form-urlencoded") + return request +except ImportError: + pass + + +class TornadoTransport(PeasantTransport): + + def __init__(self, bastion_address): + super().__init__() + if not tornado_installed: + logger.warn("TornadoTransport cannot be used without tornado " + "installed.\nIt is necessary to install peasant " + "with extras modifiers all or tornado.\n\n Ex: pip " + "install peasant[all] or pip install peasant[tornado]" + "\n\nInstalling tornado manually will also work.\n") + raise NotImplementedError + self._client = AsyncHTTPClient() + self._bastion_address = bastion_address + self._directory = None + self.user_agent = (f"Peasant/{get_version()}" + f"Tornado/{tornado_version}") + self._basic_headers = { + 'User-Agent': self.user_agent + } + + def _get_path(self, path, **kwargs): + query_string = kwargs.get('query_string') + if query_string: + path = url_concat(path, query_string) + + def _get_headers(self, **kwargs): + headers = copy.deepcopy(self._basic_headers) + _headers = kwargs.get('headers') + if _headers: + headers.update(_headers) + return headers + + async def get(self, path, **kwargs): + path = self._get_path(path, **kwargs) + request = get_tornado_request(self._bastion_address, path=path) + headers = self._get_headers(**kwargs) + request.headers.update(headers) + try: + result = await self._client.fetch(request) + except HTTPClientError as error: + result = error.response + return result + + async def head(self, path, **kwargs): + path = self._get_path(path, **kwargs) + request = get_tornado_request(path, method="HEAD") + headers = self._get_headers(**kwargs) + request.headers.update(headers) + try: + result = await self._client.fetch(request) + except HTTPClientError as error: + result = error.response + return result + + async def post(self, path, **kwargs): + path = self._get_path(path, **kwargs) + form_data = kwargs.get("form_data", {}) + request = get_tornado_request(path, method="POST", + form_urlencoded=True) + headers = self._get_headers(**kwargs) + request.headers.update(headers) + request.body = urlencode(form_data) + try: + result = await self._client.fetch(request) + except HTTPClientError as error: + result = error.response + return result diff --git a/peasant/security/ec.py b/peasant/security/ec.py index ab93dd9..989d2a6 100644 --- a/peasant/security/ec.py +++ b/peasant/security/ec.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2020-2022 Flávio Gonçalves Garcia +# Copyright 2020-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/peasant/security/jwk.py b/peasant/security/jwk.py index d9bc45a..149eb85 100644 --- a/peasant/security/jwk.py +++ b/peasant/security/jwk.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2020-2022 Flávio Gonçalves Garcia +# Copyright 2020-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/peasant/security/keyring.py b/peasant/security/keyring.py index 0ebd291..dc6b78a 100644 --- a/peasant/security/keyring.py +++ b/peasant/security/keyring.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2020-2022 Flávio Gonçalves Garcia +# Copyright 2020-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/peasant/security/rsa.py b/peasant/security/rsa.py index 7c725ab..a1357e6 100644 --- a/peasant/security/rsa.py +++ b/peasant/security/rsa.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2020-2022 Flávio Goncalves Garcia +# Copyright 2020-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/peasant/server.py b/peasant/server.py index e08f102..a4acf60 100644 --- a/peasant/server.py +++ b/peasant/server.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2020 Flavio Goncalves Garcia +# Copyright 2020-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/requirements/all.txt b/requirements/all.txt new file mode 100644 index 0000000..4c7e720 --- /dev/null +++ b/requirements/all.txt @@ -0,0 +1,2 @@ +-r basic.txt +-r tornado.txt diff --git a/requirements/basic.txt b/requirements/basic.txt index e1d37c8..50ecb2c 100644 --- a/requirements/basic.txt +++ b/requirements/basic.txt @@ -1,2 +1,2 @@ -cartola>=0.14 -cryptography==36.0.1 +cartola>=0.18 +cryptography==42.0.5 diff --git a/requirements/development.txt b/requirements/development.txt index 84d4e3a..06e5af0 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -1,2 +1,2 @@ --r basic.txt +-r all.txt -r tests.txt diff --git a/requirements/tests.txt b/requirements/tests.txt index 272c049..96c9b06 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,2 +1 @@ -behave==1.2.6 -firenado[all]>=0.2.2 +firenado[all]>=0.9.4 diff --git a/requirements/tornado.txt b/requirements/tornado.txt new file mode 100644 index 0000000..7941d78 --- /dev/null +++ b/requirements/tornado.txt @@ -0,0 +1 @@ +tornado >= 6.3 diff --git a/scripts/build.sh b/scripts/build.sh index ed49cb4..0e190fe 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -18,6 +18,5 @@ ## ## Author: Flavio Garcia -python setup.py bdist_wheel --universal -python setup.py sdist +python -m build rm -rf build diff --git a/setup.py b/setup.py index 3124861..5c7ec73 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright 2020 Flavio Garcia +# Copyright 2020-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -57,6 +57,10 @@ def resolve_requires(requirements_file): url="https://github.com/candango/peasant", author=peasant.get_author(), author_email=peasant.get_author_email(), + extras_require={ + 'all': resolve_requires("requirements/all.txt"), + 'tornado': resolve_requires("requirements/tornado.txt"), + }, classifiers=[ "Development Status :: 1 - Planning", "License :: OSI Approved :: Apache Software License", @@ -65,15 +69,17 @@ def resolve_requires(requirements_file): "Intended Audience :: Developers", "Intended Audience :: System Administrators", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3 :: Only", "Topic :: Software Development :: Libraries :: Application Frameworks" ], packages=find_packages(), package_dir={'peasant': "peasant"}, + python_requires=">= 3.8", include_package_data=True, install_requires=resolve_requires("requirements/basic.txt") ) diff --git a/tests/__init__.py b/tests/__init__.py index 30ecb3a..d180120 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2020 Flavio Goncalves Garcia +# Copyright 2020-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,8 +12,47 @@ # See the License for the specific language governing permissions and # limitations under the License. +from importlib import reload import os TEST_ROOT = os.path.abspath(os.path.dirname(__file__)) FIXTURES_ROOT = os.path.join(TEST_ROOT, "fixtures") PROJECT_ROOT = os.path.abspath(os.path.join(TEST_ROOT, "..")) + + +def chdir_fixture_app(app_name, **kwargs): + dir_name = kwargs.get("dir_name", None) + suppress_log = kwargs.get("suppress_log", False) + fixture_root = kwargs.get("fixture_root", "fixtures") + test_app_dirname = os.path.join(TEST_ROOT, fixture_root, app_name) + if dir_name is not None: + test_app_dirname = os.path.join(TEST_ROOT, fixture_root, + dir_name, app_name) + os.chdir(test_app_dirname) + if suppress_log: + import logging + for handler in logging.root.handlers[:]: + # clearing loggers, solution from: https://bit.ly/2yTchyx + logging.root.removeHandler(handler) + return test_app_dirname + + +def chdir_app(app_name, directory=None): + """ Change to the application directory located at the resource directory + for conf tests. + + The conf resources directory is firenado/tests/resources/conf. + + :param app_name: The application name + :param directory: The directory to be changed + """ + import firenado.conf + + test_dirname, filename = os.path.split(os.path.abspath(__file__)) + if directory: + test_app_dirname = os.path.join(test_dirname, 'resources', + directory, app_name) + else: + test_app_dirname = os.path.join(test_dirname, 'resources', app_name) + os.chdir(test_app_dirname) + reload(firenado.conf) diff --git a/tests/fixtures/bastiontest/__init__.py b/tests/fixtures/bastiontest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/bastiontest/app.py b/tests/fixtures/bastiontest/app.py new file mode 100644 index 0000000..e68e887 --- /dev/null +++ b/tests/fixtures/bastiontest/app.py @@ -0,0 +1,10 @@ +from bastiontest import handlers +from firenado import tornadoweb + + +class BastiontestComponent(tornadoweb.TornadoComponent): + + def get_handlers(self): + return [ + (r"/", handlers.IndexHandler), + ] diff --git a/tests/fixtures/bastiontest/conf/firenado.yml b/tests/fixtures/bastiontest/conf/firenado.yml new file mode 100644 index 0000000..966019d --- /dev/null +++ b/tests/fixtures/bastiontest/conf/firenado.yml @@ -0,0 +1,32 @@ +app: + component: 'bastiontest' + pythonpath: ".." + #type: 'tornado' + port: 8888 + +data: + sources: + - name: session + connector: redis + # host: localhost + # port: 6379 + # db: 0 +components: + - id: bastiontest + class: bastiontest.app.BastiontestComponent + enabled: true + #- id: admin + # enabled: true + #- id: info + # enabled: true + +# Session types could be: +# file or redis. +session: + type: redis + enabled: false + # Redis session handler configuration + data: + source: session + # File session handler related configuration + # path: /tmp diff --git a/tests/fixtures/bastiontest/handlers.py b/tests/fixtures/bastiontest/handlers.py new file mode 100644 index 0000000..0d6ffc4 --- /dev/null +++ b/tests/fixtures/bastiontest/handlers.py @@ -0,0 +1,7 @@ +from firenado import tornadoweb + + +class IndexHandler(tornadoweb.TornadoHandler): + + def get(self): + self.write("IndexHandler output") diff --git a/tests/features/00_nonce.feature b/tests/runtests.py similarity index 53% rename from tests/features/00_nonce.feature rename to tests/runtests.py index d02abfd..f31bd96 100644 --- a/tests/features/00_nonce.feature +++ b/tests/runtests.py @@ -1,4 +1,6 @@ -# Copyright 2020 Flavio Goncalves Garcia +#!/usr/bin/env python +# +# Copyright 2020-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,10 +14,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -Feature: Replay Nonce +# print current directory +import unittest +from tests import tornado_test + + +def suite(): + testLoader = unittest.TestLoader() + alltests = unittest.TestSuite() + alltests.addTests(testLoader.loadTestsFromModule(tornado_test)) + return alltests + - Scenario: Serve a new nonce - # Enter steps here - Given We have a newNonce url from the directory - When We request nonce from the server - Then The server provides nonce in response headers +if __name__ == "__main__": + runner = unittest.TextTestRunner(verbosity=3) + result = runner.run(suite()) + if not result.wasSuccessful(): + exit(2) diff --git a/tests/tornado_test.py b/tests/tornado_test.py new file mode 100644 index 0000000..1e491f1 --- /dev/null +++ b/tests/tornado_test.py @@ -0,0 +1,41 @@ +# Copyright 2020-2024 Flavio Garcia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from firenado.testing import TornadoAsyncTestCase +from firenado.launcher import ProcessLauncher +from peasant.client import TornadoTransport +from tests import chdir_fixture_app, PROJECT_ROOT +from tornado.testing import gen_test + + +class TornadoTransportTestCase(TornadoAsyncTestCase): + """ Tornado based client test case. """ + + def get_launcher(self) -> ProcessLauncher: + application_dir = chdir_fixture_app("bastiontest") + return ProcessLauncher( + dir=application_dir, path=PROJECT_ROOT) + + def setUp(self) -> None: + super().setUp() + self.transport = TornadoTransport( + f"http://localhost:{self.http_port()}") + + @gen_test + async def test_get(self): + try: + response = await self.transport.get("/") + except Exception as e: + raise e + self.assertEqual(response.body, b"IndexHandler output")