diff --git a/.circleci/config.yml b/.circleci/config.yml index 0408fc0..3edff16 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ --- version: 2.1 orbs: - slack: circleci/slack@5.2.0 + slack: circleci/slack@5.2.3 jobs: ensure_formatting: docker: diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e4fe6b2..d42f022 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v6 - name: Setup uv - uses: astral-sh/setup-uv@v8.0.0 + uses: astral-sh/setup-uv@v8.1.0 with: python-version: "3.12" diff --git a/docs/pyoaev/pyoaev.apis.inject_expectation.model.rst b/docs/pyoaev/pyoaev.apis.inject_expectation.model.rst new file mode 100644 index 0000000..1d43a74 --- /dev/null +++ b/docs/pyoaev/pyoaev.apis.inject_expectation.model.rst @@ -0,0 +1,45 @@ +======================================== +``pyoaev.apis.inject_expectation.model`` +======================================== + +.. automodule:: pyoaev.apis.inject_expectation.model + + .. contents:: + :local: + +.. currentmodule:: pyoaev.apis.inject_expectation.model + + +Classes +======= + +- :py:class:`DetectionExpectation`: + An expectation that is specific to Detection, i.e. that is used + +- :py:class:`ExpectationTypeEnum`: + Types of Expectations + +- :py:class:`PreventionExpectation`: + An expectation that is specific to Prevention, i.e. that is used + + +.. autoclass:: DetectionExpectation + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: DetectionExpectation + :parts: 1 + +.. autoclass:: ExpectationTypeEnum + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: ExpectationTypeEnum + :parts: 1 + +.. autoclass:: PreventionExpectation + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: PreventionExpectation + :parts: 1 diff --git a/docs/pyoaev/pyoaev.apis.inject_expectation.rst b/docs/pyoaev/pyoaev.apis.inject_expectation.rst new file mode 100644 index 0000000..d67bd2e --- /dev/null +++ b/docs/pyoaev/pyoaev.apis.inject_expectation.rst @@ -0,0 +1,18 @@ +================================== +``pyoaev.apis.inject_expectation`` +================================== + +.. automodule:: pyoaev.apis.inject_expectation + + .. contents:: + :local: + + +Submodules +========== + +.. toctree:: + + pyoaev.apis.inject_expectation.model + +.. currentmodule:: pyoaev.apis.inject_expectation diff --git a/docs/pyoaev/pyoaev.apis.rst b/docs/pyoaev/pyoaev.apis.rst new file mode 100644 index 0000000..908359c --- /dev/null +++ b/docs/pyoaev/pyoaev.apis.rst @@ -0,0 +1,732 @@ +=============== +``pyoaev.apis`` +=============== + +.. automodule:: pyoaev.apis + + .. contents:: + :local: + + +Submodules +========== + +.. toctree:: + + pyoaev.apis.inject_expectation + +.. currentmodule:: pyoaev.apis + + +Functions +========= + +- :py:func:`cast`: + Cast a value to a type. + + +.. autofunction:: cast + + +Classes +======= + +- :py:class:`Any`: + Special type indicating an unconstrained type. + +- :py:class:`AttackPattern`: + Undocumented. + +- :py:class:`AttackPatternManager`: + Base class for CRUD operations on objects. + +- :py:class:`Collector`: + Undocumented. + +- :py:class:`CollectorManager`: + Base class for CRUD operations on objects. + +- :py:class:`CreateMixin`: + Undocumented. + +- :py:class:`Cve`: + Undocumented. + +- :py:class:`CveManager`: + Base class for CRUD operations on objects. + +- :py:class:`DeleteMixin`: + Undocumented. + +- :py:class:`DetectionExpectation`: + An expectation that is specific to Detection, i.e. that is used + +- :py:class:`Document`: + Undocumented. + +- :py:class:`DocumentManager`: + Base class for CRUD operations on objects. + +- :py:class:`Endpoint`: + Undocumented. + +- :py:class:`EndpointManager`: + Base class for CRUD operations on objects. + +- :py:class:`ExpectationTypeEnum`: + Types of Expectations + +- :py:class:`GetMixin`: + Undocumented. + +- :py:class:`GetWithoutIdMixin`: + Undocumented. + +- :py:class:`Inject`: + Undocumented. + +- :py:class:`InjectExpectation`: + Undocumented. + +- :py:class:`InjectExpectationManager`: + Base class for CRUD operations on objects. + +- :py:class:`InjectExpectationTrace`: + Undocumented. + +- :py:class:`InjectExpectationTraceManager`: + Base class for CRUD operations on objects. + +- :py:class:`InjectManager`: + Base class for CRUD operations on objects. + +- :py:class:`Injector`: + Undocumented. + +- :py:class:`InjectorContract`: + Undocumented. + +- :py:class:`InjectorContractManager`: + Base class for CRUD operations on objects. + +- :py:class:`InjectorContractSearchPaginationInput`: + Undocumented. + +- :py:class:`InjectorManager`: + Base class for CRUD operations on objects. + +- :py:class:`KillChainPhase`: + Undocumented. + +- :py:class:`KillChainPhaseManager`: + Base class for CRUD operations on objects. + +- :py:class:`ListMixin`: + Undocumented. + +- :py:class:`Me`: + Undocumented. + +- :py:class:`MeManager`: + Base class for CRUD operations on objects. + +- :py:class:`Organization`: + Undocumented. + +- :py:class:`OrganizationManager`: + Base class for CRUD operations on objects. + +- :py:class:`Payload`: + Undocumented. + +- :py:class:`PayloadManager`: + Base class for CRUD operations on objects. + +- :py:class:`PreventionExpectation`: + An expectation that is specific to Prevention, i.e. that is used + +- :py:class:`RESTManager`: + Base class for CRUD operations on objects. + +- :py:class:`RESTObject`: + Undocumented. + +- :py:class:`RequiredOptional`: + RequiredOptional(required: Tuple[str, ...] = (), optional: Tuple[str, ...] = (), exclusive: Tuple[str, ...] = ()) + +- :py:class:`SearchPaginationInput`: + Undocumented. + +- :py:class:`SecurityPlatform`: + Undocumented. + +- :py:class:`SecurityPlatformManager`: + Base class for CRUD operations on objects. + +- :py:class:`Tag`: + Undocumented. + +- :py:class:`TagManager`: + Base class for CRUD operations on objects. + +- :py:class:`Team`: + Undocumented. + +- :py:class:`TeamManager`: + Base class for CRUD operations on objects. + +- :py:class:`UpdateMixin`: + Undocumented. + +- :py:class:`User`: + Undocumented. + +- :py:class:`UserManager`: + Base class for CRUD operations on objects. + + +.. autoclass:: Any + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Any + :parts: 1 + +.. autoclass:: AttackPattern + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: AttackPattern + :parts: 1 + +.. autoclass:: AttackPatternManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: AttackPatternManager + :parts: 1 + +.. autoclass:: Collector + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Collector + :parts: 1 + +.. autoclass:: CollectorManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: CollectorManager + :parts: 1 + +.. autoclass:: CreateMixin + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: CreateMixin + :parts: 1 + +.. autoclass:: Cve + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Cve + :parts: 1 + +.. autoclass:: CveManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: CveManager + :parts: 1 + +.. autoclass:: DeleteMixin + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: DeleteMixin + :parts: 1 + +.. autoclass:: DetectionExpectation + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: DetectionExpectation + :parts: 1 + +.. autoclass:: Document + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Document + :parts: 1 + +.. autoclass:: DocumentManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: DocumentManager + :parts: 1 + +.. autoclass:: Endpoint + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Endpoint + :parts: 1 + +.. autoclass:: EndpointManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: EndpointManager + :parts: 1 + +.. autoclass:: ExpectationTypeEnum + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: ExpectationTypeEnum + :parts: 1 + +.. autoclass:: GetMixin + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: GetMixin + :parts: 1 + +.. autoclass:: GetWithoutIdMixin + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: GetWithoutIdMixin + :parts: 1 + +.. autoclass:: Inject + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Inject + :parts: 1 + +.. autoclass:: InjectExpectation + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: InjectExpectation + :parts: 1 + +.. autoclass:: InjectExpectationManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: InjectExpectationManager + :parts: 1 + +.. autoclass:: InjectExpectationTrace + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: InjectExpectationTrace + :parts: 1 + +.. autoclass:: InjectExpectationTraceManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: InjectExpectationTraceManager + :parts: 1 + +.. autoclass:: InjectManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: InjectManager + :parts: 1 + +.. autoclass:: Injector + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Injector + :parts: 1 + +.. autoclass:: InjectorContract + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: InjectorContract + :parts: 1 + +.. autoclass:: InjectorContractManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: InjectorContractManager + :parts: 1 + +.. autoclass:: InjectorContractSearchPaginationInput + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: InjectorContractSearchPaginationInput + :parts: 1 + +.. autoclass:: InjectorManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: InjectorManager + :parts: 1 + +.. autoclass:: KillChainPhase + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: KillChainPhase + :parts: 1 + +.. autoclass:: KillChainPhaseManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: KillChainPhaseManager + :parts: 1 + +.. autoclass:: ListMixin + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: ListMixin + :parts: 1 + +.. autoclass:: Me + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Me + :parts: 1 + +.. autoclass:: MeManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: MeManager + :parts: 1 + +.. autoclass:: Organization + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Organization + :parts: 1 + +.. autoclass:: OrganizationManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: OrganizationManager + :parts: 1 + +.. autoclass:: Payload + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Payload + :parts: 1 + +.. autoclass:: PayloadManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: PayloadManager + :parts: 1 + +.. autoclass:: PreventionExpectation + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: PreventionExpectation + :parts: 1 + +.. autoclass:: RESTManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: RESTManager + :parts: 1 + +.. autoclass:: RESTObject + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: RESTObject + :parts: 1 + +.. autoclass:: RequiredOptional + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: RequiredOptional + :parts: 1 + +.. autoclass:: SearchPaginationInput + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: SearchPaginationInput + :parts: 1 + +.. autoclass:: SecurityPlatform + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: SecurityPlatform + :parts: 1 + +.. autoclass:: SecurityPlatformManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: SecurityPlatformManager + :parts: 1 + +.. autoclass:: Tag + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Tag + :parts: 1 + +.. autoclass:: TagManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: TagManager + :parts: 1 + +.. autoclass:: Team + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Team + :parts: 1 + +.. autoclass:: TeamManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: TeamManager + :parts: 1 + +.. autoclass:: UpdateMixin + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: UpdateMixin + :parts: 1 + +.. autoclass:: User + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: User + :parts: 1 + +.. autoclass:: UserManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: UserManager + :parts: 1 + + +Variables +========= + +- :py:data:`Dict` +- :py:data:`List` +- :py:data:`attack_pattern` +- :py:data:`collector` +- :py:data:`cve` +- :py:data:`document` +- :py:data:`endpoint` +- :py:data:`exc` +- :py:data:`inject` +- :py:data:`inject_expectation` +- :py:data:`inject_expectation_trace` +- :py:data:`injector` +- :py:data:`injector_contract` +- :py:data:`inputs` +- :py:data:`kill_chain_phase` +- :py:data:`me` +- :py:data:`model` +- :py:data:`organization` +- :py:data:`payload` +- :py:data:`security_platform` +- :py:data:`tag` +- :py:data:`team` +- :py:data:`user` + +.. autodata:: Dict + :annotation: + + .. code-block:: text + + typing.Dict + +.. autodata:: List + :annotation: + + .. code-block:: text + + typing.List + +.. autodata:: attack_pattern + :annotation: + + .. code-block:: text + + + +.. autodata:: collector + :annotation: + + .. code-block:: text + + + +.. autodata:: cve + :annotation: + + .. code-block:: text + + + +.. autodata:: document + :annotation: + + .. code-block:: text + + + +.. autodata:: endpoint + :annotation: + + .. code-block:: text + + + +.. autodata:: exc + :annotation: + + .. code-block:: text + + + +.. autodata:: inject + :annotation: + + .. code-block:: text + + + +.. autodata:: inject_expectation + :annotation: + + .. code-block:: text + + + +.. autodata:: inject_expectation_trace + :annotation: + + .. code-block:: text + + + +.. autodata:: injector + :annotation: + + .. code-block:: text + + + +.. autodata:: injector_contract + :annotation: + + .. code-block:: text + + + +.. autodata:: inputs + :annotation: + + .. code-block:: text + + + +.. autodata:: kill_chain_phase + :annotation: + + .. code-block:: text + + + +.. autodata:: me + :annotation: + + .. code-block:: text + + + +.. autodata:: model + :annotation: + + .. code-block:: text + + + +.. autodata:: organization + :annotation: + + .. code-block:: text + + + +.. autodata:: payload + :annotation: + + .. code-block:: text + + + +.. autodata:: security_platform + :annotation: + + .. code-block:: text + + + +.. autodata:: tag + :annotation: + + .. code-block:: text + + + +.. autodata:: team + :annotation: + + .. code-block:: text + + + +.. autodata:: user + :annotation: + + .. code-block:: text + + diff --git a/docs/pyoaev/pyoaev.backends.rst b/docs/pyoaev/pyoaev.backends.rst new file mode 100644 index 0000000..bd37503 --- /dev/null +++ b/docs/pyoaev/pyoaev.backends.rst @@ -0,0 +1,45 @@ +=================== +``pyoaev.backends`` +=================== + +.. automodule:: pyoaev.backends + + .. contents:: + :local: + +.. currentmodule:: pyoaev.backends + + +Classes +======= + +- :py:class:`DefaultBackend`: + Base class for protocol classes. + +- :py:class:`DefaultResponse`: + Base class for protocol classes. + +- :py:class:`TokenAuth`: + Base class that all auth implementations derive from + + +.. autoclass:: DefaultBackend + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: DefaultBackend + :parts: 1 + +.. autoclass:: DefaultResponse + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: DefaultResponse + :parts: 1 + +.. autoclass:: TokenAuth + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: TokenAuth + :parts: 1 diff --git a/docs/pyoaev/pyoaev.base.rst b/docs/pyoaev/pyoaev.base.rst new file mode 100644 index 0000000..87f6c4b --- /dev/null +++ b/docs/pyoaev/pyoaev.base.rst @@ -0,0 +1,45 @@ +=============== +``pyoaev.base`` +=============== + +.. automodule:: pyoaev.base + + .. contents:: + :local: + +.. currentmodule:: pyoaev.base + + +Classes +======= + +- :py:class:`RESTObject`: + Undocumented. + +- :py:class:`RESTObjectList`: + Undocumented. + +- :py:class:`RESTManager`: + Base class for CRUD operations on objects. + + +.. autoclass:: RESTObject + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: RESTObject + :parts: 1 + +.. autoclass:: RESTObjectList + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: RESTObjectList + :parts: 1 + +.. autoclass:: RESTManager + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: RESTManager + :parts: 1 diff --git a/docs/pyoaev/pyoaev.configuration.rst b/docs/pyoaev/pyoaev.configuration.rst new file mode 100644 index 0000000..d96d234 --- /dev/null +++ b/docs/pyoaev/pyoaev.configuration.rst @@ -0,0 +1,65 @@ +======================== +``pyoaev.configuration`` +======================== + +.. automodule:: pyoaev.configuration + + .. contents:: + :local: + +.. currentmodule:: pyoaev.configuration + + +Classes +======= + +- :py:class:`Configuration`: + A configuration object providing ways to an interface for getting + +- :py:class:`ConfigLoaderOAEV`: + OpenAEV/OpenAEV platform configuration settings. + +- :py:class:`ConfigLoaderCollector`: + Base collector configuration settings. + +- :py:class:`SettingsLoader`: + Base class for settings, allowing values to be overridden by environment variables. + +- :py:class:`BaseConfigModel`: + Base class for global config models + + +.. autoclass:: Configuration + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: Configuration + :parts: 1 + +.. autoclass:: ConfigLoaderOAEV + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: ConfigLoaderOAEV + :parts: 1 + +.. autoclass:: ConfigLoaderCollector + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: ConfigLoaderCollector + :parts: 1 + +.. autoclass:: SettingsLoader + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: SettingsLoader + :parts: 1 + +.. autoclass:: BaseConfigModel + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: BaseConfigModel + :parts: 1 diff --git a/docs/pyoaev/pyoaev.contracts.rst b/docs/pyoaev/pyoaev.contracts.rst new file mode 100644 index 0000000..af19346 --- /dev/null +++ b/docs/pyoaev/pyoaev.contracts.rst @@ -0,0 +1,25 @@ +==================== +``pyoaev.contracts`` +==================== + +.. automodule:: pyoaev.contracts + + .. contents:: + :local: + +.. currentmodule:: pyoaev.contracts + + +Classes +======= + +- :py:class:`ContractBuilder`: + Undocumented. + + +.. autoclass:: ContractBuilder + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: ContractBuilder + :parts: 1 diff --git a/docs/pyoaev/pyoaev.daemons.rst b/docs/pyoaev/pyoaev.daemons.rst new file mode 100644 index 0000000..cbc7c1a --- /dev/null +++ b/docs/pyoaev/pyoaev.daemons.rst @@ -0,0 +1,35 @@ +================== +``pyoaev.daemons`` +================== + +.. automodule:: pyoaev.daemons + + .. contents:: + :local: + +.. currentmodule:: pyoaev.daemons + + +Classes +======= + +- :py:class:`BaseDaemon`: + A base class for implementing a kind of daemon that periodically polls + +- :py:class:`CollectorDaemon`: + Implementation of a daemon of Collector type. Note that it requires + + +.. autoclass:: BaseDaemon + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: BaseDaemon + :parts: 1 + +.. autoclass:: CollectorDaemon + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: CollectorDaemon + :parts: 1 diff --git a/docs/pyoaev/pyoaev.exceptions.rst b/docs/pyoaev/pyoaev.exceptions.rst new file mode 100644 index 0000000..381f5df --- /dev/null +++ b/docs/pyoaev/pyoaev.exceptions.rst @@ -0,0 +1,96 @@ +===================== +``pyoaev.exceptions`` +===================== + +.. automodule:: pyoaev.exceptions + + .. contents:: + :local: + +.. currentmodule:: pyoaev.exceptions + + +Exceptions +========== + +- :py:exc:`ConfigurationError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVAuthenticationError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVHttpError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVParsingError`: + Common base class for all non-exit exceptions. + +- :py:exc:`RedirectError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVHeadError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVListError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVGetError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVUpdateError`: + Common base class for all non-exit exceptions. + + +.. autoexception:: ConfigurationError + + .. rubric:: Inheritance + .. inheritance-diagram:: ConfigurationError + :parts: 1 + +.. autoexception:: OpenAEVAuthenticationError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVAuthenticationError + :parts: 1 + +.. autoexception:: OpenAEVHttpError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVHttpError + :parts: 1 + +.. autoexception:: OpenAEVParsingError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVParsingError + :parts: 1 + +.. autoexception:: RedirectError + + .. rubric:: Inheritance + .. inheritance-diagram:: RedirectError + :parts: 1 + +.. autoexception:: OpenAEVHeadError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVHeadError + :parts: 1 + +.. autoexception:: OpenAEVListError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVListError + :parts: 1 + +.. autoexception:: OpenAEVGetError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVGetError + :parts: 1 + +.. autoexception:: OpenAEVUpdateError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVUpdateError + :parts: 1 diff --git a/docs/pyoaev/pyoaev.mixins.rst b/docs/pyoaev/pyoaev.mixins.rst new file mode 100644 index 0000000..903bc34 --- /dev/null +++ b/docs/pyoaev/pyoaev.mixins.rst @@ -0,0 +1,35 @@ +================= +``pyoaev.mixins`` +================= + +.. automodule:: pyoaev.mixins + + .. contents:: + :local: + +.. currentmodule:: pyoaev.mixins + + +Classes +======= + +- :py:class:`GetMixin`: + Undocumented. + +- :py:class:`GetWithoutIdMixin`: + Undocumented. + + +.. autoclass:: GetMixin + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: GetMixin + :parts: 1 + +.. autoclass:: GetWithoutIdMixin + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: GetWithoutIdMixin + :parts: 1 diff --git a/docs/pyoaev/pyoaev.rst b/docs/pyoaev/pyoaev.rst new file mode 100644 index 0000000..0d796f1 --- /dev/null +++ b/docs/pyoaev/pyoaev.rst @@ -0,0 +1,179 @@ +========== +``pyoaev`` +========== + +.. automodule:: pyoaev + + .. contents:: + :local: + + +Submodules +========== + +.. toctree:: + + pyoaev.apis + pyoaev.backends + pyoaev.base + pyoaev.configuration + pyoaev.contracts + pyoaev.daemons + pyoaev.exceptions + pyoaev.mixins + +.. currentmodule:: pyoaev + + +Classes +======= + +- :py:class:`OpenAEV`: + Undocumented. + + +.. autoclass:: OpenAEV + :members: + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEV + :parts: 1 + + +Exceptions +========== + +- :py:exc:`ConfigurationError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVAuthenticationError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVHttpError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVParsingError`: + Common base class for all non-exit exceptions. + +- :py:exc:`RedirectError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVHeadError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVListError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVGetError`: + Common base class for all non-exit exceptions. + +- :py:exc:`OpenAEVUpdateError`: + Common base class for all non-exit exceptions. + + +.. autoexception:: ConfigurationError + + .. rubric:: Inheritance + .. inheritance-diagram:: ConfigurationError + :parts: 1 + +.. autoexception:: OpenAEVAuthenticationError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVAuthenticationError + :parts: 1 + +.. autoexception:: OpenAEVHttpError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVHttpError + :parts: 1 + +.. autoexception:: OpenAEVParsingError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVParsingError + :parts: 1 + +.. autoexception:: RedirectError + + .. rubric:: Inheritance + .. inheritance-diagram:: RedirectError + :parts: 1 + +.. autoexception:: OpenAEVHeadError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVHeadError + :parts: 1 + +.. autoexception:: OpenAEVListError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVListError + :parts: 1 + +.. autoexception:: OpenAEVGetError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVGetError + :parts: 1 + +.. autoexception:: OpenAEVUpdateError + + .. rubric:: Inheritance + .. inheritance-diagram:: OpenAEVUpdateError + :parts: 1 + + +Variables +========= + +- :py:data:`__author__` +- :py:data:`__copyright__` +- :py:data:`__email__` +- :py:data:`__license__` +- :py:data:`__title__` +- :py:data:`__version__` + +.. autodata:: __author__ + :annotation: + + .. code-block:: text + + 'Filigran team' + +.. autodata:: __copyright__ + :annotation: + + .. code-block:: text + + 'Copyright 2025 Filigran' + +.. autodata:: __email__ + :annotation: + + .. code-block:: text + + 'contact@filigran.io' + +.. autodata:: __license__ + :annotation: + + .. code-block:: text + + 'Apache 2.0' + +.. autodata:: __title__ + :annotation: + + .. code-block:: text + + 'python-openaev' + +.. autodata:: __version__ + :annotation: + + .. code-block:: text + + '2.0.4' diff --git a/docs/requirements.txt b/docs/requirements.txt index 622bdef..9c69684 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ sphinx==9.1.0 sphinx-autoapi==3.4.0 astroid==3.3.8 -sphinx-autodoc-typehints==3.2.0 +sphinx-autodoc-typehints==3.6.1 sphinx_rtd_theme==3.1.0 diff --git a/pyoaev/client.py b/pyoaev/client.py index a86a76b..450e99d 100644 --- a/pyoaev/client.py +++ b/pyoaev/client.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, Optional, Union from urllib import parse +from uuid import UUID import requests @@ -23,6 +24,7 @@ def __init__( pagination: Optional[str] = None, order_by: Optional[str] = None, ssl_verify: Union[bool, str] = True, + tenant_id: Optional[UUID] = None, **kwargs: Any, ) -> None: @@ -32,6 +34,7 @@ def __init__( raise ValueError("A TOKEN must be set") self.url = url + self.tenant_id = tenant_id self.timeout = timeout #: Headers that will be used in request to OpenAEV self.headers = { @@ -109,9 +112,14 @@ def _build_url(self, path: str) -> str: Returns: The full URL """ - if path.startswith("http://") or path.startswith("https://"): + if parse.urlparse(path).scheme in ("http", "https"): return path - return f"{self.url}/api{path}" + base_url = self.url.rstrip("/") + normalized_path = path.lstrip("/") + if self.tenant_id: + return f"{base_url}/api/tenants/{self.tenant_id}/{normalized_path}" + else: + return f"{base_url}/api/{normalized_path}" def _get_session_opts(self) -> Dict[str, Any]: return { diff --git a/pyoaev/configuration/settings_loader.py b/pyoaev/configuration/settings_loader.py index 8d41e4f..8e4dcdc 100644 --- a/pyoaev/configuration/settings_loader.py +++ b/pyoaev/configuration/settings_loader.py @@ -3,6 +3,7 @@ from datetime import timedelta from pathlib import Path from typing import Annotated, Literal +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, HttpUrl, PlainSerializer from pydantic_settings import ( @@ -99,6 +100,11 @@ class ConfigLoaderOAEV(BaseConfigModel): token: str = Field( description="The token for the OpenAEV platform.", ) + tenant_id: UUID | None = Field( + default=None, + description="Identifier of the tenant within the OpenAEV platform. Used in multi-tenant environments to scope " + "API requests and ensure data isolation between different tenants.", + ) class ConfigLoaderCollector(BaseConfigModel): diff --git a/pyoaev/contracts/contract_config.py b/pyoaev/contracts/contract_config.py index 22697d4..161008a 100644 --- a/pyoaev/contracts/contract_config.py +++ b/pyoaev/contracts/contract_config.py @@ -46,6 +46,19 @@ class ContractOutputType(str, Enum): IPv6: str = "ipv6" CVE: str = "cve" Asset: str = "asset" + Credentials: str = "credentials" + Username: str = "username" + Share: str = "share" + AdminUsername: str = "admin_username" + Group: str = "group" + Computer: str = "computer" + PasswordPolicy: str = "password_policy" + Delegation: str = "delegation" + Sid: str = "sid" + Vulnerability: str = "vulnerability" + AccountWithPasswordNotRequired: str = "account_with_password_not_required" + AsreproastableAccount: str = "asreproastable_account" + KerberoastableAccount: str = "kerberoastable_account" class ExpectationType(str, Enum): diff --git a/pyoaev/daemons/base_daemon.py b/pyoaev/daemons/base_daemon.py index 7dcc4e4..9728a87 100644 --- a/pyoaev/daemons/base_daemon.py +++ b/pyoaev/daemons/base_daemon.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from inspect import signature from types import FunctionType +from uuid import UUID from pyoaev.client import OpenAEV from pyoaev.configuration import Configuration @@ -37,6 +38,7 @@ def __init__( self.api = api_client or BaseDaemon.__get_default_api_client( url=self._configuration.get("openaev_url"), token=self._configuration.get("openaev_token"), + tenant_id=self._configuration.get("openaev_tenant_id"), ) # logging @@ -131,8 +133,8 @@ def get_id(self): ) @classmethod - def __get_default_api_client(cls, url, token): - return OpenAEV(url=url, token=token) + def __get_default_api_client(cls, url, token, tenant_id: UUID | None): + return OpenAEV(url=url, token=token, tenant_id=tenant_id) @classmethod def __get_default_logger(cls, log_level, name): diff --git a/pyoaev/helpers.py b/pyoaev/helpers.py index a422151..a85971e 100644 --- a/pyoaev/helpers.py +++ b/pyoaev/helpers.py @@ -322,6 +322,7 @@ def __init__(self, config: OpenAEVConfigHelper, icon) -> None: self.api = OpenAEV( url=config.get_conf("openaev_url"), token=config.get_conf("openaev_token"), + tenant_id=config.get_conf("openaev_tenant_id"), ) # Get the mq configuration from api self.config = { diff --git a/pyoaev/utils.py b/pyoaev/utils.py index 83227b3..21e7c7b 100644 --- a/pyoaev/utils.py +++ b/pyoaev/utils.py @@ -186,6 +186,7 @@ def __init__(self, api, config, logger, ping_type) -> None: threading.Thread.__init__(self) self.ping_type = ping_type self.api = api + self.tenant_id = getattr(self.api, "tenant_id", None) self.config = config self.logger = logger self.in_error = False @@ -203,9 +204,15 @@ def ping(self) -> None: self.exit_event.wait(40) def run(self) -> None: - self.logger.info("Starting PingAlive thread") + self.logger.info( + "Starting PingAlive thread", + {"tenant_id": str(self.tenant_id) if self.tenant_id else None}, + ) self.ping() def stop(self) -> None: - self.logger.info("Preparing PingAlive for clean shutdown") + self.logger.info( + "Preparing PingAlive for clean shutdown", + {"tenant_id": str(self.tenant_id) if self.tenant_id else None}, + ) self.exit_event.set() diff --git a/pyproject.toml b/pyproject.toml index 3d936cb..ef190eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,10 +31,10 @@ dependencies = [ "python-magic-bin (>=0.4.14,<0.5); sys_platform == 'win32'", "python_json_logger (>=3.3.0,<3.4.0)", "PyYAML (>=6.0,<6.1)", - "pydantic (>=2.11.3,<2.12.0)", - "pydantic-settings (>=2.11.0,<2.12.0)", - "requests (>=2.33.1,<2.34.0)", - "setuptools (>=80.9.0,<80.10.0)", + "pydantic (>=2.13.3,<2.14.0)", + "pydantic-settings (>=2.14.0,<2.15.0)", + "requests (>=2.32.3,<2.33.0)", + "setuptools (>=82.0.1,<82.1.0)", "cachetools (>=5.5.0,<5.6.0)", "prometheus-client (>=0.22.1,<0.23.0)", "opentelemetry-api (>=1.35.0,<1.36.0)", @@ -47,19 +47,19 @@ dependencies = [ [project.optional-dependencies] dev = [ - "black (>=25.11.0,<25.12.0)", - "build (>=1.3.0,<1.4.0)", - "isort (>=6.1.0,<6.2.0)", + "black (>=26.3.1,<26.4.0)", + "build (>=1.4.4,<1.5.0)", + "isort (>=8.0.1,<8.1.0)", "types-pytz (>=2025.2.0.20250326,<2025.3.0.0)", - "pre-commit (>=4.2.0,<4.3.0)", + "pre-commit (>=4.6.0,<4.7.0)", "types-python-dateutil (>=2.9.0,<2.10.0)", - "wheel (>=0.45.1,<0.46.0)", + "wheel (>=0.47.0,<0.48.0)", "coverage>=7.13.5" ] doc = [ "autoapi (>=2.0.1,<2.1.0)", - "sphinx-autodoc-typehints (>=3.2.0,<3.3.0)", - "sphinx-rtd-theme (>=3.0.2,<3.1.0)" + "sphinx-autodoc-typehints (>=3.6.1,<3.6.2)", + "sphinx-rtd-theme (>=3.1.0,<3.2.0)" ] [build-system] diff --git a/test/bdd/constraints/multi_tenant_api_routing_constraint.feature b/test/bdd/constraints/multi_tenant_api_routing_constraint.feature new file mode 100644 index 0000000..ccfbf54 --- /dev/null +++ b/test/bdd/constraints/multi_tenant_api_routing_constraint.feature @@ -0,0 +1,12 @@ +Feature: URL normalization in OpenAEV client + + Scenario Outline: URL normalization combines base_url and path correctly + Given an OpenAEV client with base_url "" + When I build the URL for "" + Then the resulting URL should be "" + + Examples: + | base_url | path | expected | + | base_url | path | base_url/api/path | + | base_url/ | /path | base_url/api/path | + | base_url// | //path | base_url/api/path | \ No newline at end of file diff --git a/test/bdd/constraints/multi_tenant_validation_uuid_constraint.feature b/test/bdd/constraints/multi_tenant_validation_uuid_constraint.feature new file mode 100644 index 0000000..027f07f --- /dev/null +++ b/test/bdd/constraints/multi_tenant_validation_uuid_constraint.feature @@ -0,0 +1,30 @@ +Feature: Tenant ID handling in OpenAEV configuration + + Scenario: tenant_id is not provided + Given a configuration without tenant_id + When the configuration is loaded + Then tenant_id should be None + + + Scenario: tenant_id is explicitly set to None + Given a configuration with tenant_id set to None + When the configuration is loaded + Then tenant_id should be None + + + Scenario Outline: tenant_id is invalid and should raise a validation error + Given a configuration with tenant_id "" invalid + When the configuration is loaded + Then a validation error should be raised + + Examples: + | tenant_id | + | ChangeMe | + | "" | + | 550-e29-41d-a71-446 | + + + Scenario: tenant_id is a valid UUID + Given a configuration with tenant_id "2cffad3a-0001-4078-b0e2-ef74274022c3" + When the configuration is loaded + Then tenant_id should be a valid UUID \ No newline at end of file diff --git a/test/bdd/constraints/test_multi_tenant_api_routing_constraint.py b/test/bdd/constraints/test_multi_tenant_api_routing_constraint.py new file mode 100644 index 0000000..3956afb --- /dev/null +++ b/test/bdd/constraints/test_multi_tenant_api_routing_constraint.py @@ -0,0 +1,63 @@ +"""URL normalization in OpenAEV client feature tests.""" + +import pytest +from pytest_bdd import given, parsers, scenario, then, when + +from pyoaev import OpenAEV + +# -------------------------------------------------- +# SCENARIOS +# -------------------------------------------------- + + +@scenario( + "multi_tenant_api_routing_constraint.feature", + "URL normalization combines base_url and path correctly", +) +def test_url_normalization(): + pass + + +# -------------------------------------------------- +# FIXTURE CONTEXT +# -------------------------------------------------- + + +@pytest.fixture +def context(): + return {} + + +# -------------------------------------------------- +# GIVEN +# -------------------------------------------------- + + +@given(parsers.parse('an OpenAEV client with base_url "{base_url}"')) +def client(context, base_url): + context["client"] = OpenAEV( + url=base_url, + token="token", + tenant_id=None, + ) + + +# -------------------------------------------------- +# WHEN +# -------------------------------------------------- + + +@when(parsers.parse('I build the URL for "{path}"')) +def build_url(context, path): + client = context["client"] + context["result"] = client._build_url(path) + + +# -------------------------------------------------- +# THEN +# -------------------------------------------------- + + +@then(parsers.parse('the resulting URL should be "{expected}"')) +def check_url(context, expected): + assert context["result"] == expected diff --git a/test/bdd/constraints/test_multi_tenant_validation_uuid_constraint.py b/test/bdd/constraints/test_multi_tenant_validation_uuid_constraint.py new file mode 100644 index 0000000..df866e5 --- /dev/null +++ b/test/bdd/constraints/test_multi_tenant_validation_uuid_constraint.py @@ -0,0 +1,132 @@ +"""Tenant ID handling in OpenAEV configuration feature tests.""" + +from uuid import UUID + +from pydantic import ValidationError +from pytest_bdd import given, parsers, scenario, then, when + +from pyoaev.configuration.settings_loader import ConfigLoaderOAEV + +# -------------------------------------------------- +# SCENARIOS +# -------------------------------------------------- + + +@scenario( + "multi_tenant_validation_uuid_constraint.feature", + "tenant_id is a valid UUID", +) +def test_tenant_id_is_a_valid_uuid(): + pass + + +@scenario( + "multi_tenant_validation_uuid_constraint.feature", + "tenant_id is explicitly set to None", +) +def test_tenant_id_is_explicitly_set_to_none(): + pass + + +@scenario( + "multi_tenant_validation_uuid_constraint.feature", + "tenant_id is invalid and should raise a validation error", +) +def test_tenant_id_is_invalid_and_should_raise_a_validation_error(): + pass + + +@scenario( + "multi_tenant_validation_uuid_constraint.feature", + "tenant_id is not provided", +) +def test_tenant_id_is_not_provided(): + pass + + +# -------------------------------------------------- +# GIVEN +# -------------------------------------------------- + + +@given( + "a configuration without tenant_id", + target_fixture="config", +) +def config_without(): + return { + "url": "https://example.com", + "token": "token", + } + + +@given( + "a configuration with tenant_id set to None", + target_fixture="config", +) +def config_none(): + return { + "url": "https://example.com", + "token": "token", + "tenant_id": None, + } + + +@given( + parsers.parse('a configuration with tenant_id "{tenant_id}" invalid'), + target_fixture="config", +) +def config_with_tenant_invalid(tenant_id): + return { + "url": "https://example.com", + "token": "token", + "tenant_id": tenant_id, + } + + +@given( + 'a configuration with tenant_id "2cffad3a-0001-4078-b0e2-ef74274022c3"', + target_fixture="config", +) +def config_with_tenant_valid(): + return { + "url": "https://example.com", + "token": "token", + "tenant_id": "2cffad3a-0001-4078-b0e2-ef74274022c3", + } + + +# -------------------------------------------------- +# WHEN +# -------------------------------------------------- + + +@when( + "the configuration is loaded", + target_fixture="result", +) +def load_config(config): + try: + return ConfigLoaderOAEV(**config) + except ValidationError as err: + return err + + +# -------------------------------------------------- +# THEN +# -------------------------------------------------- + + +@then("tenant_id should be None") +def assert_none(result): + assert result.tenant_id is None + + +@then("tenant_id should be a valid UUID") +def assert_uuid(result): + assert isinstance(result.tenant_id, UUID) + + +@then("a validation error should be raised") +def assert_validation_error(result): + assert isinstance(result, ValidationError) diff --git a/test/bdd/features/multi_tenant_api_routing.feature b/test/bdd/features/multi_tenant_api_routing.feature new file mode 100644 index 0000000..fb15e07 --- /dev/null +++ b/test/bdd/features/multi_tenant_api_routing.feature @@ -0,0 +1,17 @@ +Feature: Multi-tenant API routing in OpenAEV client + + Scenario: Full URL bypasses tenant routing + Given an OpenAEV client with any tenant configuration + When I build the URL for "https://external.service/api/path" + Then the resulting URL should be "https://external.service/api/path" + + + Scenario Outline: Relative path routing depends on tenant configuration + Given an OpenAEV client with tenant_id "" + When I build the URL for "/path" + Then the resulting URL should be "" + + Examples: + | tenant_id | output | + | None | base_url/api/path | + | 2cffad3a-0001-4078-b0e2-ef74274022c3 | base_url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/path | diff --git a/test/bdd/features/multi_tenant_base_daemon_propagation.feature b/test/bdd/features/multi_tenant_base_daemon_propagation.feature new file mode 100644 index 0000000..69bc76c --- /dev/null +++ b/test/bdd/features/multi_tenant_base_daemon_propagation.feature @@ -0,0 +1,12 @@ +Feature: Tenant propagation in BaseDaemon API client initialization + + Scenario Outline: BaseDaemon propagates tenant_id correctly from configuration + Given a daemon configuration with "" + When the BaseDaemon is initialized + Then the API client should be created with tenant_id "" + + Examples: + | tenant_case | expected_tenant_id | + | missing_key | None | + | explicit_none | None | + | valid_uuid | 2cffad3a-0001-4078-b0e2-ef74274022c3 | \ No newline at end of file diff --git a/test/bdd/features/multi_tenant_endpoint_search_targets.feature b/test/bdd/features/multi_tenant_endpoint_search_targets.feature new file mode 100644 index 0000000..e30bd66 --- /dev/null +++ b/test/bdd/features/multi_tenant_endpoint_search_targets.feature @@ -0,0 +1,12 @@ +Feature: searchTargets API routing with and without tenant_id + + Scenario Outline: searchTargets routing behavior + Given an OpenAEV client with tenant_id "" + And a valid SearchPaginationInput + When I call searchTargets on endpoint + Then the request URL should be "" + + Examples: + | tenant_id | expected_url | + | None | url/api/endpoints/targets | + | 2cffad3a-0001-4078-b0e2-ef74274022c3 | url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/endpoints/targets | \ No newline at end of file diff --git a/test/bdd/features/test_multi_tenant_api_routing.py b/test/bdd/features/test_multi_tenant_api_routing.py new file mode 100644 index 0000000..8e6e2b1 --- /dev/null +++ b/test/bdd/features/test_multi_tenant_api_routing.py @@ -0,0 +1,81 @@ +from uuid import UUID + +from pytest_bdd import given, parsers, scenario, then, when + +from pyoaev import OpenAEV + +# -------------------------------------------------- +# SCENARIOS +# -------------------------------------------------- + + +@scenario( + "multi_tenant_api_routing.feature", + "Full URL bypasses tenant routing", +) +def test_full_url_bypasses_tenant_routing(): + pass + + +@scenario( + "multi_tenant_api_routing.feature", + "Relative path routing depends on tenant configuration", +) +def test_relative_path_routing(): + pass + + +# -------------------------------------------------- +# GIVEN +# -------------------------------------------------- + + +@given( + "an OpenAEV client with any tenant configuration", + target_fixture="client", +) +def client_any(): + return OpenAEV( + "base_url", + "token", + tenant_id=None, + ) + + +@given( + parsers.parse('an OpenAEV client with tenant_id "{tenant_id}"'), + target_fixture="client", +) +def client_with_tenant(tenant_id): + if tenant_id is None or tenant_id == "None": + tenant_id_value = None + else: + tenant_id_value = UUID(tenant_id) + return OpenAEV( + "base_url", + "token", + tenant_id=tenant_id_value, + ) + + +# -------------------------------------------------- +# WHEN +# -------------------------------------------------- + + +@when( + parsers.parse('I build the URL for "{path}"'), + target_fixture="result", +) +def build_url(client, path): + return client._build_url(path) + + +# -------------------------------------------------- +# THEN +# -------------------------------------------------- + + +@then(parsers.parse('the resulting URL should be "{output}"')) +def assert_url(result, output): + assert result == output diff --git a/test/bdd/features/test_multi_tenant_base_daemon_propagation.py b/test/bdd/features/test_multi_tenant_base_daemon_propagation.py new file mode 100644 index 0000000..ca85f22 --- /dev/null +++ b/test/bdd/features/test_multi_tenant_base_daemon_propagation.py @@ -0,0 +1,120 @@ +from unittest.mock import MagicMock +from uuid import UUID + +import pytest +from pytest_bdd import given, parsers, scenario, then, when + +from pyoaev.daemons.base_daemon import BaseDaemon + +# -------------------------------------------------- +# SCENARIO +# -------------------------------------------------- + + +@scenario( + "multi_tenant_base_daemon_propagation.feature", + "BaseDaemon propagates tenant_id correctly from configuration", +) +def test_base_daemon_propagates_tenant_id(): + pass + + +# -------------------------------------------------- +# FIXTURE CONTEXT +# -------------------------------------------------- + + +@pytest.fixture +def context(): + return {} + + +# -------------------------------------------------- +# HELPERS +# -------------------------------------------------- + + +def build_config(tenant_case): + base = { + "openaev_url": "url", + "openaev_token": "token", + } + + if tenant_case == "missing_key": + return base + + if tenant_case == "explicit_none": + base["openaev_tenant_id"] = None + return base + + if tenant_case == "valid_uuid": + base["openaev_tenant_id"] = UUID("2cffad3a-0001-4078-b0e2-ef74274022c3") + return base + return base + + +# -------------------------------------------------- +# GIVEN +# -------------------------------------------------- + + +@given(parsers.parse('a daemon configuration with "{tenant_case}"')) +def daemon_config(context, monkeypatch, tenant_case): + captured = {} + config_map = build_config(tenant_case) + + def _fake_client(url, token, tenant_id=None): + captured["url"] = url + captured["token"] = token + captured["tenant_id"] = tenant_id + return MagicMock() + + mock_client = MagicMock(side_effect=_fake_client) + monkeypatch.setattr("pyoaev.daemons.base_daemon.OpenAEV", mock_client) + context["mock_client"] = mock_client + + config = MagicMock() + config.get.side_effect = lambda key: config_map.get(key) + + context["config"] = config + context["captured"] = captured + + +# -------------------------------------------------- +# WHEN +# -------------------------------------------------- + + +@when("the BaseDaemon is initialized") +def init_daemon(context): + class DummyDaemon(BaseDaemon): + def _setup(self): + pass + + def _start_loop(self): + pass + + context["daemon"] = DummyDaemon(configuration=context["config"]) + + +# -------------------------------------------------- +# THEN +# -------------------------------------------------- + + +@then( + parsers.parse( + 'the API client should be created with tenant_id "{expected_tenant_id}"' + ) +) +def check_tenant(context, expected_tenant_id): + captured = context["captured"] + + mock_client = context["mock_client"] + assert mock_client.call_count == 1 + + assert captured["url"] == "url" + assert captured["token"] == "token" + + expected = None if expected_tenant_id == "None" else UUID(expected_tenant_id) + assert captured["tenant_id"] == expected diff --git a/test/bdd/features/test_multi_tenant_endpoint_search_targets.py b/test/bdd/features/test_multi_tenant_endpoint_search_targets.py new file mode 100644 index 0000000..081364c --- /dev/null +++ b/test/bdd/features/test_multi_tenant_endpoint_search_targets.py @@ -0,0 +1,127 @@ +from unittest.mock import MagicMock +from uuid import UUID + +import pytest +from pytest_bdd import given, parsers, scenario, then, when + +from pyoaev import OpenAEV +from pyoaev.apis.inputs.search import Filter, FilterGroup, SearchPaginationInput + + +# -------------------------------------------------- +# SCENARIO +# -------------------------------------------------- +@scenario( + "multi_tenant_endpoint_search_targets.feature", + "searchTargets routing behavior", +) +def test_search_targets_routing(): + pass + + +# -------------------------------------------------- +# FIXTURE CONTEXT +# -------------------------------------------------- + + +@pytest.fixture +def context(): + return {} + + +# -------------------------------------------------- +# HELPERS +# -------------------------------------------------- + + +class MockResponse: + def __init__(self, json_data=None, status_code=200): + self._json_data = json_data + self.status_code = status_code + self.history = None + self.content = None + self.headers = {"Content-Type": "application/json"} + + def json(self): + return self._json_data or {} + + +def build_search_input(): + return SearchPaginationInput( + 0, + 20, + FilterGroup( + "or", + [ + Filter( + "targets", + "and", + "eq", + ["target_1", "target_2", "target_3"], + ) + ], + ), + None, + None, + ) + + +# -------------------------------------------------- +# GIVEN +# -------------------------------------------------- + + +@given(parsers.parse('an OpenAEV client with tenant_id "{tenant_id}"')) +def client(context, monkeypatch, tenant_id): + captured = {} + + def _fake_request(method, url, **kwargs): + captured["method"] = method + captured["url"] = url + captured["json"] = kwargs.get("json") + return MockResponse() + + mock_request = MagicMock(side_effect=_fake_request) + monkeypatch.setattr("requests.Session.request", mock_request) + context["mock_request"] = mock_request + + context["tenant_id"] = None if tenant_id == "None" else UUID(tenant_id) + context["captured"] = captured + + +@given("a valid SearchPaginationInput") +def search_input(context): + context["search_input"] = build_search_input() + + +# -------------------------------------------------- +# WHEN +# -------------------------------------------------- + + +@when("I call searchTargets on endpoint") +def call_search_targets(context): + api_client = OpenAEV( + "url", + "token", + tenant_id=context["tenant_id"], + ) + + api_client.endpoint.searchTargets(context["search_input"]) + + +# -------------------------------------------------- +# THEN +# -------------------------------------------------- + + +@then(parsers.parse('the request URL should be "{expected_url}"')) +def check_request(context, expected_url): + captured = context["captured"] + search_input = context["search_input"] + mock_request = context["mock_request"] + + assert mock_request.call_count == 1 + assert captured["method"] == "post" + assert captured["url"] == expected_url + assert captured["json"] == search_input.to_dict() diff --git a/test/test_utils.py b/test/test_utils.py index 13fab9a..4314f55 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -250,8 +250,13 @@ def test_pingalive_run_and_stop(self): ping_alive.run() ping_alive.stop() - ping_alive.logger.info.assert_any_call("Starting PingAlive thread") - ping_alive.logger.info.assert_any_call("Preparing PingAlive for clean shutdown") + ping_alive.logger.info.assert_any_call( + "Starting PingAlive thread", {"tenant_id": str(ping_alive.tenant_id)} + ) + ping_alive.logger.info.assert_any_call( + "Preparing PingAlive for clean shutdown", + {"tenant_id": str(ping_alive.tenant_id)}, + ) self.assertTrue(ping_alive.exit_event.is_set())