From 6de4f0aebce98e489c43492fbb8152dff96e88fa Mon Sep 17 00:00:00 2001 From: matosys Date: Tue, 14 Apr 2026 16:27:20 +0200 Subject: [PATCH 1/2] feat(contrib/auth): add support for permission tests of `balderhub-auth` package specific for http resources --- requirements.txt | 4 +- setup.cfg | 13 +++- src/balderhub/http/contrib/__init__.py | 0 src/balderhub/http/contrib/auth/__init__.py | 0 .../contrib/auth/setup_features/__init__.py | 0 .../auth/setup_features/client/__init__.py | 5 ++ ...ration_handling_over_websession_feature.py | 75 +++++++++++++++++++ .../auth/setup_features/server/__init__.py | 5 ++ .../server/simple_http_exist_for_config.py | 53 +++++++++++++ .../http/contrib/auth/utils/__init__.py | 12 +++ .../http/contrib/auth/utils/actions.py | 40 ++++++++++ .../http/contrib/auth/utils/functions.py | 21 ++++++ .../http/contrib/auth/utils/http_operation.py | 31 ++++++++ .../http/contrib/auth/utils/http_resource.py | 43 +++++++++++ .../auth/utils/unresolved_http_resource.py | 47 ++++++++++++ ...ed_http_resource_for_specific_data_item.py | 45 +++++++++++ 16 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 src/balderhub/http/contrib/__init__.py create mode 100644 src/balderhub/http/contrib/auth/__init__.py create mode 100644 src/balderhub/http/contrib/auth/setup_features/__init__.py create mode 100644 src/balderhub/http/contrib/auth/setup_features/client/__init__.py create mode 100644 src/balderhub/http/contrib/auth/setup_features/client/operation_handling_over_websession_feature.py create mode 100644 src/balderhub/http/contrib/auth/setup_features/server/__init__.py create mode 100644 src/balderhub/http/contrib/auth/setup_features/server/simple_http_exist_for_config.py create mode 100644 src/balderhub/http/contrib/auth/utils/__init__.py create mode 100644 src/balderhub/http/contrib/auth/utils/actions.py create mode 100644 src/balderhub/http/contrib/auth/utils/functions.py create mode 100644 src/balderhub/http/contrib/auth/utils/http_operation.py create mode 100644 src/balderhub/http/contrib/auth/utils/http_resource.py create mode 100644 src/balderhub/http/contrib/auth/utils/unresolved_http_resource.py create mode 100644 src/balderhub/http/contrib/auth/utils/unresolved_http_resource_for_specific_data_item.py diff --git a/requirements.txt b/requirements.txt index 6aec699..6a2d91f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,12 @@ alabaster==0.7.16 astroid==3.3.8 babel==2.18.0 +balderhub-auth==0.0.1b3 +balderhub-data==0.0.1b7 balderhub-unit==0.0.1b0 balderhub-url==0.0.1b0 balderplugin-junit==0.0.3 -baldertest==0.2.0 +baldertest==0.2.3 certifi==2026.2.25 charset-normalizer==3.4.7 dill==0.4.1 diff --git a/setup.cfg b/setup.cfg index dab9718..18164ad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,6 +31,12 @@ project_urls = [options] packages = balderhub.http + balderhub.http.contrib + balderhub.http.contrib.auth + balderhub.http.contrib.auth.setup_features + balderhub.http.contrib.auth.setup_features.client + balderhub.http.contrib.auth.setup_features.server + balderhub.http.contrib.auth.utils balderhub.http.lib balderhub.http.lib.scenario_features balderhub.http.lib.scenario_features.client @@ -43,7 +49,6 @@ packages = install_requires = baldertest balderhub-url - balderhub-auth python_requires = >=3.10 package_dir = =src @@ -51,3 +56,9 @@ setup_requires = setuptools setuptools-scm>=6.0 zip_safe = no +[options.extras_require] +auth = + balderhub-auth~=0.0.1b3 + balderhub-data~=0.0.1b7 +all = + balderhub-html[auth] \ No newline at end of file diff --git a/src/balderhub/http/contrib/__init__.py b/src/balderhub/http/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/balderhub/http/contrib/auth/__init__.py b/src/balderhub/http/contrib/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/balderhub/http/contrib/auth/setup_features/__init__.py b/src/balderhub/http/contrib/auth/setup_features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/balderhub/http/contrib/auth/setup_features/client/__init__.py b/src/balderhub/http/contrib/auth/setup_features/client/__init__.py new file mode 100644 index 0000000..f5e3a53 --- /dev/null +++ b/src/balderhub/http/contrib/auth/setup_features/client/__init__.py @@ -0,0 +1,5 @@ +from .operation_handling_over_websession_feature import OperationHandlingOverWebsessionFeature + +__all__ = [ + 'OperationHandlingOverWebsessionFeature' +] diff --git a/src/balderhub/http/contrib/auth/setup_features/client/operation_handling_over_websession_feature.py b/src/balderhub/http/contrib/auth/setup_features/client/operation_handling_over_websession_feature.py new file mode 100644 index 0000000..33663d2 --- /dev/null +++ b/src/balderhub/http/contrib/auth/setup_features/client/operation_handling_over_websession_feature.py @@ -0,0 +1,75 @@ +import balderhub.auth.lib.scenario_features.client +from balderhub.auth.lib.utils import Operation +from balderhub.url.lib.utils import Url + +from balderhub.http.contrib.auth.utils.actions import HttpAction +from balderhub.http.contrib.auth.utils.http_resource import HttpResource +from balderhub.http.lib.scenario_features.client.web_session_feature import WebSessionFeature + + +class OperationHandlingOverWebsessionFeature(balderhub.auth.lib.scenario_features.client.OperationHandlingFeature): + """ + Setup-Level Feature implementation of the + :class:`balderhub.auth.lib.scenario_features.client.OperationHandlingFeature`. It can be used to test + authentification / permissions for http resources by using the + :class:`balderhub.http.lib.scenario_features.client.WebSessionFeature`. + """ + #: inner feature reference to the web session feature + websession = WebSessionFeature() + + @property + def unauth_redirect_schema(self) -> list[Url]: + """ + This property provides the schema used for unauthenticated redirect URLs. + It describes a list of Url schemas that are treated the same as an + Unauthenticated error. + + :return: a list of `Url` objects representing the schema for unauthenticated + redirects (empty list by default). + """ + return [] + + def enter_operation(self, operation: Operation) -> bool: + resource = operation.resource + action = operation.action + if not isinstance(resource, HttpResource): + raise TypeError(f'can not operation with resource from type `{resource.__class__}`' + f' - this feature only work with resources from type `{HttpResource.__name__}`') + if not isinstance(action, HttpAction): + raise TypeError(f'expect http action type `{HttpAction.__name__}`, ' + f'got {type(action)} instead') + + response = self.websession.request(method=action.http_method, url=resource.url) # TODO should we send data? + + if len(response.history) > 0: + # all history elements need to have status code 302 + if {r.status_code for r in response.history} != {302}: + raise ValueError(f'got non 302 status codes in history responses: {response.history}') + + if self.unauth_redirect_schema: + # redirects to provided schema should be handled as unauth error too + for cur_schema in self.unauth_redirect_schema: + + if cur_schema.compare(response.url, allow_schemas=True): + # redirect to login page happened -> return UnauthorizedError + raise HttpResource.UnauthorizedError( + f'web request has an redirect to unauth URL schema `{cur_schema}`' + ) + return False + + if response.status_code == 401: + raise HttpResource.UnauthorizedError(f'web request returned 401: `{response.content}`') + if response.status_code == 403: + raise HttpResource.NoPermissionError(f'web request returned 403: `{response.content}`') + if response.status_code == 404: + raise HttpResource.DoesNotExistError(f'web request returned 404: `{response.content}`') + if response.status_code == 405: + raise HttpResource.NotAllowedMethodError(f'web request returned 405: `{response.content}`') + if response.status_code != 200: + raise HttpResource.ResourceEnterError(f'web request returned unexpected status code ' + f'{response.status_code}: `{response.content}`') + return True + + + def leave_operation(self, operation: Operation) -> bool: + return True diff --git a/src/balderhub/http/contrib/auth/setup_features/server/__init__.py b/src/balderhub/http/contrib/auth/setup_features/server/__init__.py new file mode 100644 index 0000000..73cad47 --- /dev/null +++ b/src/balderhub/http/contrib/auth/setup_features/server/__init__.py @@ -0,0 +1,5 @@ +from .simple_http_exist_for_config import SimpleHttpExistForConfig + +__all__ = [ + 'SimpleHttpExistForConfig' +] diff --git a/src/balderhub/http/contrib/auth/setup_features/server/simple_http_exist_for_config.py b/src/balderhub/http/contrib/auth/setup_features/server/simple_http_exist_for_config.py new file mode 100644 index 0000000..7314c44 --- /dev/null +++ b/src/balderhub/http/contrib/auth/setup_features/server/simple_http_exist_for_config.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import balderhub.auth.lib.scenario_features.server +from balderhub.auth.lib.utils import ResourceRule, ResourceRuleList + +from balderhub.http.contrib.auth.utils import actions +from ...utils import UnresolvedHttpResource +from ...utils.functions import get_reverse_rule_cb_for +from ...utils.http_resource import HttpResource + +class SimpleHttpExistForConfig(balderhub.auth.lib.scenario_features.server.ExistenceForConfig): + """ + This existence-for configuration feature automatically determines non-existing resources by creating a resource for + every normal HttpMethod and Url, that is used in + :meth:`balderhub.auth.lib.scenario_features.server.ExistenceForConfig.resources_that_exist`. + + Subclasses can overwrite the method :meth:`HttpExistenceForConfig.additional_non_existing_resources` to add + additional resources that should be tested. + """ + USE_HTTP_ACTIONS = [actions.GET, actions.POST, actions.PUT, actions.PATCH, actions.DELETE] + + def get_resource_rules_that_exist(self) -> ResourceRuleList: + raise NotImplementedError + + def get_resource_rules_that_not_exist(self) -> ResourceRuleList: + existing_rules = self.get_resource_rules_that_exist() + + result = [] + + resolved_resources_dict = {} + for cur_existing_rule in existing_rules: + resource = cur_existing_rule.resource + if isinstance(resource, HttpResource): + if resource.url not in resolved_resources_dict.keys(): + resolved_resources_dict[resource.url] = [] + for action in cur_existing_rule.actions: + resolved_resources_dict[resource.url].append(action) + elif isinstance(resource, UnresolvedHttpResource): + cur_rule = cur_existing_rule.cb_rule + # otherwise rule is none -> every item is active - no item is in reverse-rule + if cur_rule: + reverse_rule = get_reverse_rule_cb_for(cur_rule) + new_rule = cur_existing_rule.copy() + new_rule.update_rule(reverse_rule) + result.append(new_rule) + else: + raise TypeError(f'unexpected resource type `{type(resource)}`') + + for resource_url, existing_actions in resolved_resources_dict.items(): + not_existing_actions = list(set(self.USE_HTTP_ACTIONS) - set(existing_actions)) + if len(not_existing_actions) > 0: + result.append(ResourceRule(HttpResource(resource_url), actions=not_existing_actions)) + return ResourceRuleList(result) diff --git a/src/balderhub/http/contrib/auth/utils/__init__.py b/src/balderhub/http/contrib/auth/utils/__init__.py new file mode 100644 index 0000000..6be52c8 --- /dev/null +++ b/src/balderhub/http/contrib/auth/utils/__init__.py @@ -0,0 +1,12 @@ +from . import actions +from .http_operation import HttpOperation +from .http_resource import HttpResource +from .unresolved_http_resource import UnresolvedHttpResource +from .unresolved_http_resource_for_specific_data_item import UnresolvedDataItemHttpResource + +__all__ = [ + 'HttpOperation', + 'HttpResource', + 'UnresolvedHttpResource', + 'UnresolvedDataItemHttpResource', +] diff --git a/src/balderhub/http/contrib/auth/utils/actions.py b/src/balderhub/http/contrib/auth/utils/actions.py new file mode 100644 index 0000000..036add3 --- /dev/null +++ b/src/balderhub/http/contrib/auth/utils/actions.py @@ -0,0 +1,40 @@ +from typing import Union + +from balderhub.auth.lib.utils import Action + +from balderhub.http.lib.utils import HttpMethod + + +class HttpAction(Action): + """ + Represents an HTTP method implementing the :class:`balderhub.auth.lib.utils.Action` interface. + """ + def __init__(self, method: Union[HttpMethod, str]): + super().__init__() + self._method_str = method if isinstance(method, str) else method.value + + def __str__(self): + return f"HttpAction<{self._method_str}>" + + def __hash__(self): + return hash(self.__class__) + hash(self._method_str) + + def __eq__(self, other): + return self.__class__ == other.__class__ and self.http_method == other.http_method + + @property + def http_method(self) -> str: + """ + :return: the HTTP method associated with this action, as a string. + """ + return self._method_str + + +GET = HttpAction('GET') +POST = HttpAction('POST') +PUT = HttpAction('PUT') +PATCH = HttpAction('PATCH') +DELETE = HttpAction('DELETE') +HEAD = HttpAction('HEAD') +OPTIONS = HttpAction('OPTIONS') +TRACE = HttpAction('TRACE') diff --git a/src/balderhub/http/contrib/auth/utils/functions.py b/src/balderhub/http/contrib/auth/utils/functions.py new file mode 100644 index 0000000..9c1035e --- /dev/null +++ b/src/balderhub/http/contrib/auth/utils/functions.py @@ -0,0 +1,21 @@ +from collections.abc import Callable + +from balderhub.auth.lib.utils.unresolved_resource import UnresolvedResource + + +def get_reverse_rule_cb_for( + rule: Callable[[UnresolvedResource.Parameter], bool] +) -> Callable[[UnresolvedResource.Parameter], bool]: + """ + Returns a callable function that inverses the logic of the provided rule, + which takes an argument and returns the opposite boolean value. + + :param rule: A callable function that takes an instance of + ``UnresolvedResource.Parameter`` as input and returns a boolean value. + :return: A callable function that applies the inverse logic of the + provided ``rule`` on an instance of ``UnresolvedResource.Parameter``. + :raises ValueError: If rule is not a callable object. + """ + if not callable(rule): + raise ValueError(f'rule must be callable, but is {rule}') + return lambda x: not rule(x) diff --git a/src/balderhub/http/contrib/auth/utils/http_operation.py b/src/balderhub/http/contrib/auth/utils/http_operation.py new file mode 100644 index 0000000..1012921 --- /dev/null +++ b/src/balderhub/http/contrib/auth/utils/http_operation.py @@ -0,0 +1,31 @@ +import balderhub.auth.lib.utils + +from .actions import HttpAction +from .http_resource import HttpResource + + +class HttpOperation(balderhub.auth.lib.utils.Operation): + """ + Represents an HTTP operation consisting of a resource and an action. + """ + def __init__(self, resource: HttpResource, action: HttpAction): + + if not isinstance(resource, HttpResource): + raise TypeError(f'resource must be of type {HttpResource.__name__}') + if not isinstance(action, HttpAction): + raise TypeError(f'action must be of type {HttpAction.__name__}') + super().__init__(resource, action) + + @property + def resource(self) -> HttpResource: + """ + :return: the HTTP resource associated with the operation. + """ + return self._resource + + @property + def action(self) -> HttpAction: + """ + :return: the HTTP action to be performed on the resource. + """ + return self._action diff --git a/src/balderhub/http/contrib/auth/utils/http_resource.py b/src/balderhub/http/contrib/auth/utils/http_resource.py new file mode 100644 index 0000000..2733448 --- /dev/null +++ b/src/balderhub/http/contrib/auth/utils/http_resource.py @@ -0,0 +1,43 @@ +from typing import Union + +import balderhub.auth.lib.utils +from balderhub.url.lib.utils import Url + + +class HttpResource(balderhub.auth.lib.utils.Resource): + """ + Represents an HTTP resource with a URL. + + This class is designed to encapsulate an HTTP resource by managing its URL, + allowing comparison with other resources of the same type, and providing a + structured string representation. + """ + class NotAllowedMethodError(balderhub.auth.lib.utils.Resource.ResourceEnterError): + """Represents an error raised when a forbidden method is attempted to be used.""" + + def __init__(self, url: Union[Url, str]): + """ + Initialize a HttpResource object. + + :param url: the URL to which the resource is being requested + """ + super().__init__() + self._url = url if isinstance(url, Url) else Url(url) + + def __str__(self): + return f"{self.__class__.__name__}<{self._url}>" + + def __eq__(self, other): + if self.__class__ != other.__class__: + return False + return self.url == other.url + + def __hash__(self): + return hash(self.__class__) + hash(self.url) + + @property + def url(self) -> Url: + """ + :return: the URL of the HTTP resource + """ + return self._url diff --git a/src/balderhub/http/contrib/auth/utils/unresolved_http_resource.py b/src/balderhub/http/contrib/auth/utils/unresolved_http_resource.py new file mode 100644 index 0000000..2ba8dd6 --- /dev/null +++ b/src/balderhub/http/contrib/auth/utils/unresolved_http_resource.py @@ -0,0 +1,47 @@ +from abc import ABC +import balderhub.auth.lib.utils +from balderhub.url.lib.utils import Url + + +class UnresolvedHttpResource(balderhub.auth.lib.utils.UnresolvedResource, ABC): + """ + Represents an HTTP resource with an unresolved schema. + + This class is used to manage and represent HTTP resources where the schema + (such as URL structure) is not fully resolved. It provides utility methods + for equality comparison, string representation, and hashing based on the + URL schema. It inherits from `UnresolvedResource` and ensures compatibility + with existing resource utilities. + """ + class NotAllowedMethodError(balderhub.auth.lib.utils.Resource.ResourceEnterError): + """ + Represents an error raised when a method is not allowed for a specific + resource or context. + """ + + def __init__(self, url_schema: Url, **kwargs): + """ + Initiates an unresolved HTTP resource + + :param url_schema: represents the unresolved URL schema + """ + super().__init__(**kwargs) + self._url_schema = url_schema + + def __str__(self): + return f"{self.__class__.__name__}<{self._url_schema}>" + + def __eq__(self, other): + if self.__class__ != other.__class__: + return False + return self.url_schema == other.url_schema + + def __hash__(self): + return hash(self.__class__) + hash(self._url_schema) + + @property + def url_schema(self): + """ + :return: represents the unresolved URL schema for the resource + """ + return self._url_schema diff --git a/src/balderhub/http/contrib/auth/utils/unresolved_http_resource_for_specific_data_item.py b/src/balderhub/http/contrib/auth/utils/unresolved_http_resource_for_specific_data_item.py new file mode 100644 index 0000000..8c3c4e4 --- /dev/null +++ b/src/balderhub/http/contrib/auth/utils/unresolved_http_resource_for_specific_data_item.py @@ -0,0 +1,45 @@ +from balderhub.data.contrib.auth.utils import ResourceForSpecificDataItem + +from balderhub.data.lib.utils import SingleDataItem +from balderhub.url.lib.utils import Url + +from .http_resource import HttpResource +from .unresolved_http_resource import UnresolvedHttpResource + +class UnresolvedDataItemHttpResource(ResourceForSpecificDataItem, UnresolvedHttpResource): + """ + Represents an HTTP resource for unresolved data items within a specific data item type. + + This class combines functionality from the `ResourceForSpecificDataItem` and + `UnresolvedHttpResource` base classes to handle unresolved HTTP resources. + It provides mechanisms for resolving the resource into a fully defined HTTP + resource based on input parameters derived from the specific data item's fields. + """ + def __init__(self, url_schema: Url, data_item_type: type[SingleDataItem], **kwargs): + """ + Initialize the unresolved data item http resource. + + :param url_schema: The schema of the URL associated with the resource, containing + placeholders for parameters to be filled during resolution. + :param data_item_type: The type of the specific data item this resource is associated + with, used for dynamic field resolution. + """ + super().__init__(data_item_type=data_item_type, url_schema=url_schema, **kwargs) + + def __str__(self): + return f"{self.__class__.__name__}<{self._url_schema}@{self.data_item_type.__name__}>" + + def __eq__(self, other): + if self.__class__ != other.__class__: + return False + return self.url_schema == other.url_schema and self.data_item_type == other.data_item_type + + def __hash__(self): + return hash(self.__class__) + hash(self._url_schema) + hash(self.data_item_type) + + def get_resolved_resource(self, param: ResourceForSpecificDataItem.Parameter) -> HttpResource: + unfilled_params = self._url_schema.get_unfilled_parameters().keys() + resolved_url = self._url_schema.fill_parameters( + **{url_param: param.data_item.get_field_value(url_param) for url_param in unfilled_params} + ) + return HttpResource(resolved_url) From 3588835b8c0594efded0bd2b24ff275bd3f8f138 Mon Sep 17 00:00:00 2001 From: matosys Date: Tue, 12 May 2026 09:52:00 +0200 Subject: [PATCH 2/2] feat(contrib/auth): add new setup level feature implementation `AuthenticatedByTokenWithinWebsessionFeature` for feature `balderhub.auth.lib.scenario_features.client.AuthenticationFeature` --- .../auth/setup_features/client/__init__.py | 2 + ...ated_by_token_within_websession_feature.py | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/balderhub/http/contrib/auth/setup_features/client/authenticated_by_token_within_websession_feature.py diff --git a/src/balderhub/http/contrib/auth/setup_features/client/__init__.py b/src/balderhub/http/contrib/auth/setup_features/client/__init__.py index f5e3a53..0a3a55e 100644 --- a/src/balderhub/http/contrib/auth/setup_features/client/__init__.py +++ b/src/balderhub/http/contrib/auth/setup_features/client/__init__.py @@ -1,5 +1,7 @@ +from .authenticated_by_token_within_websession_feature import AuthenticatedByTokenWithinWebsessionFeature from .operation_handling_over_websession_feature import OperationHandlingOverWebsessionFeature __all__ = [ + 'AuthenticatedByTokenWithinWebsessionFeature', 'OperationHandlingOverWebsessionFeature' ] diff --git a/src/balderhub/http/contrib/auth/setup_features/client/authenticated_by_token_within_websession_feature.py b/src/balderhub/http/contrib/auth/setup_features/client/authenticated_by_token_within_websession_feature.py new file mode 100644 index 0000000..854a002 --- /dev/null +++ b/src/balderhub/http/contrib/auth/setup_features/client/authenticated_by_token_within_websession_feature.py @@ -0,0 +1,44 @@ +import balderhub.auth.lib.scenario_features.client.role +import balderhub.http.lib.setup_features.client + + +class AuthenticatedByTokenWithinWebsessionFeature(balderhub.auth.lib.scenario_features.client.AuthenticationFeature): + """ + Enables and manages token-based authentication within a web session. + + This feature provides mechanisms to handle token authentication by managing + the HTTP headers within a web session. The authentication state is verified + by checking the presence and correctness of a token in the session's headers. + """ + #: the key name used for the authentication header. + HEADER_KEY_NAME = 'Authorization' + #: the prefix added to the token value in the header. + TOKEN_PREFIX = 'Token' + + #: inner-feature reference to the configuration object for the user's token role. + user_config = balderhub.auth.lib.scenario_features.client.role.TokenRoleFeature() + #: inner-feature reference to the web session object where authentication headers + websession = balderhub.http.lib.setup_features.client.WebSessionWithRequestsFeature() + + def get_full_header_value(self): + """ + Constructs and returns the full header value by combining the token prefix and + the user's token. + + :return: A string representing the complete header value, consisting of the + ``TOKEN_PREFIX`` and the user's token separated by a space. + """ + return f'{self.TOKEN_PREFIX} {self.user_config.token}' + + @property + def is_authenticated(self) -> bool: + return self.HEADER_KEY_NAME in self.websession.session.headers and \ + self.websession.session.headers[self.HEADER_KEY_NAME] == self.get_full_header_value() + + def authenticate(self): + # TODO provide native method in feature directly to manage header + self.websession.session.headers[self.HEADER_KEY_NAME] = self.get_full_header_value() + + def unauthenticate(self): + # TODO provide native method in feature directly to manage header + del self.websession.session.headers[self.HEADER_KEY_NAME]