From baa87944419f3f27c13db5489844265763f250ca Mon Sep 17 00:00:00 2001 From: Weii Wang Date: Thu, 19 Dec 2024 19:38:30 +0800 Subject: [PATCH] Add more tests --- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- .../charms/opencti/v0/opencti_connector.py | 2 +- lib/charms/opencti/v0/opencti_connector.py | 2 +- opencti_rock/rockcraft.yaml | 4 + src/charm.py | 14 ++- src/opencti.py | 104 ++++++++++++++---- tests/conftest.py | 24 +++- tests/integration/conftest.py | 37 +++++++ tests/integration/test_charm.py | 82 +++++++++++++- tests/unit/conftest.py | 40 ++++++- tests/unit/state.py | 11 +- tests/unit/test_charm.py | 4 +- tests/unit/test_connectors.py | 8 +- tox.ini | 1 + 30 files changed, 306 insertions(+), 61 deletions(-) diff --git a/connectors/abuseipdb_ipblacklist/lib/charms/opencti/v0/opencti_connector.py b/connectors/abuseipdb_ipblacklist/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/abuseipdb_ipblacklist/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/abuseipdb_ipblacklist/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/alienvault/lib/charms/opencti/v0/opencti_connector.py b/connectors/alienvault/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/alienvault/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/alienvault/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/cisa_known_exploited_vulnerabilities/lib/charms/opencti/v0/opencti_connector.py b/connectors/cisa_known_exploited_vulnerabilities/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/cisa_known_exploited_vulnerabilities/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/cisa_known_exploited_vulnerabilities/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/crowdstrike/lib/charms/opencti/v0/opencti_connector.py b/connectors/crowdstrike/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/crowdstrike/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/crowdstrike/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/cyber_campaign_collection/lib/charms/opencti/v0/opencti_connector.py b/connectors/cyber_campaign_collection/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/cyber_campaign_collection/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/cyber_campaign_collection/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/export_file_csv/lib/charms/opencti/v0/opencti_connector.py b/connectors/export_file_csv/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/export_file_csv/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/export_file_csv/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/export_file_stix/lib/charms/opencti/v0/opencti_connector.py b/connectors/export_file_stix/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/export_file_stix/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/export_file_stix/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/export_file_txt/lib/charms/opencti/v0/opencti_connector.py b/connectors/export_file_txt/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/export_file_txt/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/export_file_txt/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/import_document/lib/charms/opencti/v0/opencti_connector.py b/connectors/import_document/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/import_document/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/import_document/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/import_file_stix/lib/charms/opencti/v0/opencti_connector.py b/connectors/import_file_stix/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/import_file_stix/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/import_file_stix/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/malwarebazaar_recent_additions/lib/charms/opencti/v0/opencti_connector.py b/connectors/malwarebazaar_recent_additions/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/malwarebazaar_recent_additions/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/malwarebazaar_recent_additions/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/misp_feed/lib/charms/opencti/v0/opencti_connector.py b/connectors/misp_feed/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/misp_feed/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/misp_feed/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/mitre/lib/charms/opencti/v0/opencti_connector.py b/connectors/mitre/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/mitre/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/mitre/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/sekoia/lib/charms/opencti/v0/opencti_connector.py b/connectors/sekoia/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/sekoia/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/sekoia/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/urlscan/lib/charms/opencti/v0/opencti_connector.py b/connectors/urlscan/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/urlscan/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/urlscan/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/urlscan_enrichment/lib/charms/opencti/v0/opencti_connector.py b/connectors/urlscan_enrichment/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/urlscan_enrichment/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/urlscan_enrichment/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/virustotal_livehunt_notifications/lib/charms/opencti/v0/opencti_connector.py b/connectors/virustotal_livehunt_notifications/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/virustotal_livehunt_notifications/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/virustotal_livehunt_notifications/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/connectors/vxvault/lib/charms/opencti/v0/opencti_connector.py b/connectors/vxvault/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/connectors/vxvault/lib/charms/opencti/v0/opencti_connector.py +++ b/connectors/vxvault/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/lib/charms/opencti/v0/opencti_connector.py b/lib/charms/opencti/v0/opencti_connector.py index 2979737..06fd7a5 100644 --- a/lib/charms/opencti/v0/opencti_connector.py +++ b/lib/charms/opencti/v0/opencti_connector.py @@ -74,7 +74,7 @@ def _config_metadata(self) -> dict: Raises: RuntimeError: If charm metadata file doesn't exist. """ - config_file = self.charm_dir / "metadata.yaml" + config_file = self.charm_dir / "config.yaml" if config_file.exists(): return yaml.safe_load(config_file.read_text())["options"] config_file = self.charm_dir / "charmcraft.yaml" diff --git a/opencti_rock/rockcraft.yaml b/opencti_rock/rockcraft.yaml index 39e6674..1d25490 100644 --- a/opencti_rock/rockcraft.yaml +++ b/opencti_rock/rockcraft.yaml @@ -13,6 +13,10 @@ description: >- platforms: amd64: +service: + opencti: + command: + parts: platform/graphql: plugin: nil diff --git a/src/charm.py b/src/charm.py index d9b674f..eb607be 100755 --- a/src/charm.py +++ b/src/charm.py @@ -657,7 +657,7 @@ def _dump_integration(self, name: str) -> str: dump["unit-data"] = {unit.name: dict(integration.data[unit]) for unit in units} return json.dumps(dump) - def _reconcile_connector(self): + def _reconcile_connector(self) -> None: """Run charm reconcile function for OpenCTI connectors.""" client = opencti.OpenctiClient( url="http://localhost:8080", @@ -671,9 +671,11 @@ def _reconcile_connector(self): user = self._setup_connector_integration_and_user(client, integration) if user: current_using_users.add(user) - for user in client.list_users(): - if user.name not in current_using_users and user.name.startswith("charm-connector-"): - client.set_account_status(user.id, "Inactive") + for opencti_user in client.list_users(): + if opencti_user.name not in current_using_users and opencti_user.name.startswith( + "charm-connector-" + ): + client.set_account_status(opencti_user.id, "Inactive") def _setup_connector_integration_and_user( self, client: opencti.OpenctiClient, integration: ops.Relation @@ -693,7 +695,7 @@ def _setup_connector_integration_and_user( integration_data.get("connector_type"), ) if not connector_charm_name or not connector_type: - return + return None opencti_url = f"http://{self.app.name}-endpoints.{self.model.name}.svc:8080" integration.data[self.app]["opencti_url"] = opencti_url connector_user = f"charm-connector-{connector_charm_name.replace('_', '-').lower()}" @@ -715,7 +717,7 @@ def _setup_connector_integration_and_user( if not opencti_token_id: secret = self.app.add_secret(content={"token": api_token}) secret.grant(integration) - integration.data[self.app]["opencti_token"] = secret.id + integration.data[self.app]["opencti_token"] = typing.cast(str, secret.id) if opencti_token_id: secret = self.model.get_secret(id=opencti_token_id) if secret.get_content(refresh=True)["token"] != api_token: diff --git a/src/opencti.py b/src/opencti.py index 5e63f0e..c0740e4 100644 --- a/src/opencti.py +++ b/src/opencti.py @@ -3,7 +3,6 @@ """OpenCTI API client.""" -import collections import secrets import textwrap import typing @@ -11,32 +10,81 @@ import requests -OpenctiUser = collections.namedtuple("OpenctiUser", "id,name,user_email,account_status,api_token") -OpenctiGroup = collections.namedtuple("OpenctiGroup", "id,name") + +class OpenctiUser(typing.NamedTuple): + """Opencti user. + + Attributes: + id: opencti user id + name: opencti username + user_email: opencti user email + account_status: opencti account status + api_token: opencti user api token + """ + + id: str + name: str + user_email: str + account_status: str + api_token: str + + +class OpenctiGroup(typing.NamedTuple): + """Opencti group. + + Attributes: + id: opencti group id + name: opencti group name + """ + + id: str + name: str class GraphqlError(Exception): - pass + """GraphQL error.""" class OpenctiClient: - def __init__(self, url, api_token): + """Opencti API client.""" + + def __init__(self, url: str, api_token: str) -> None: + """Construct the Opencti client. + + Args: + url: URL of the Opencti API. + api_token: Opencti API token. + """ self._query_url = urllib.parse.urljoin(url, "graphql") self._api_token = api_token - self._cached_users = None - self._cached_groups = None + self._cached_users: list[OpenctiUser] | None = None + self._cached_groups: list[OpenctiGroup] | None = None def _graphql( self, - id: str, + query_id: str, query: str, - variables: dict = None, - ): + variables: dict | None = None, + ) -> dict: + """Call the OpenCTI GraphQL endpoint. + + Args: + query_id: GraphQL id. + query: GraphQL query. + variables: GraphQL variables. + + Returns: + data in GraphQL response. + + Raises: + GraphqlError: errors returned in GraphQL response. + """ variables = variables or {} response = requests.post( self._query_url, - json={"id": id, "query": query, "variables": variables}, + json={"id": query_id, "query": query, "variables": variables}, headers={"Authorization": f"Bearer {self._api_token}"}, + timeout=10, ) response.raise_for_status() result = response.json() @@ -45,6 +93,11 @@ def _graphql( return result["data"] def list_users(self) -> list[OpenctiUser]: + """List OpenCTI users. + + Returns: + list of OpenctiUser objects. + """ if self._cached_users is not None: return self._cached_users query = textwrap.dedent( @@ -86,6 +139,13 @@ def create_user( user_email: str | None = None, groups: list[str] | None = None, ) -> None: + """Create a OpenCTI user. + + Args: + name: User name. + user_email: User's email address. + groups: User's groups. + """ self._cached_users = None if user_email is None: user_email = f"{name}@opencti.local" @@ -101,7 +161,6 @@ def create_user( id } } - fragment UserLine_node on User { id name @@ -135,6 +194,11 @@ def create_user( self._graphql("UserCreationMutation", query=query, variables=variables) def list_groups(self) -> list[OpenctiGroup]: + """List OpenCTI groups. + + Returns: + list of OpenctiGroup objects. + """ if self._cached_groups is not None: return self._cached_groups query = textwrap.dedent( @@ -163,7 +227,13 @@ def set_account_status( self, user_id: str, status: typing.Literal["Active", "Inactive"], - ): + ) -> None: + """Set Opencti account status. + + Args: + user_id: Opencti user id. + status: Opencti account status. + """ self._cached_users = None query = textwrap.dedent( """ @@ -179,7 +249,6 @@ def set_account_status( } } } - fragment UserEditionGroups_user_2AtC8h on User { id objectOrganization(orderBy: name, orderMode: asc) { @@ -229,7 +298,6 @@ def set_account_status( } } } - fragment UserEditionOrganizationsAdmin_user_Z483F on User { id user_email @@ -244,7 +312,6 @@ def set_account_status( } } } - fragment UserEditionOverview_user on User { id name @@ -282,7 +349,6 @@ def set_account_status( } } } - fragment UserEditionOverview_user_2AtC8h on User { id name @@ -320,11 +386,9 @@ def set_account_status( } } } - fragment UserEditionPassword_user on User { id } - fragment UserEdition_user on User { id external @@ -394,7 +458,7 @@ def set_account_status( """ ) self._graphql( - id="UserEditionOverviewFieldPatchMutation", + query_id="UserEditionOverviewFieldPatchMutation", query=query, variables={ "id": user_id, diff --git a/tests/conftest.py b/tests/conftest.py index 847adfc..b18d02a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,26 @@ """Fixtures for charm tests.""" +import pathlib + +import pytest +import yaml + + +def list_connectors() -> list[str]: + """Return a list of opencti connector charms.""" + connectors = [] + for file in pathlib.Path("connectors").glob("**/charmcraft.yaml"): + charmcraft_yaml = yaml.safe_load(file.read_text()) + connectors.append(charmcraft_yaml["name"]) + return connectors + + +@pytest.fixture(scope="session", name="connectors") +def connectors_fixture() -> list[str]: + """Return a list of opencti connector charms.""" + return list_connectors() + def pytest_addoption(parser): """Parse additional pytest options. @@ -10,6 +30,8 @@ def pytest_addoption(parser): Args: parser: Pytest parser. """ - parser.addoption("--charm-file", action="store") + parser.addoption("--charm-file", action="append") parser.addoption("--opencti-image", action="store") parser.addoption("--machine-controller", action="store", default="localhost") + for connector in list_connectors(): + parser.addoption(f"--{connector}-image", action="store") diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d08a2b3..cf81eb7 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -5,6 +5,7 @@ import json import logging +import pathlib import secrets import typing @@ -100,3 +101,39 @@ async def machine_charm_dependencies_fixture(machine_model: Model): rabbitmq_server = await machine_model.deploy("rabbitmq-server", channel="3.9/stable") await machine_model.create_offer(f"{rabbitmq_server.name}:amqp", "amqp") await machine_model.wait_for_idle(timeout=1200) + + +@pytest.fixture(name="opencti_charm", scope="module") +def opencti_charm_fixture(pytestconfig) -> dict[str, str]: + """Opencti charm file.""" + charm_files = pytestconfig.getoption("--charm-file") + assert charm_files + for charm_file in charm_files: + if "connector" not in pathlib.Path(charm_file).name: + return charm_file + raise ValueError("opencti charm file not provided") + + +@pytest.fixture(name="opencti_connector_charms", scope="module") +def opencti_connector_charms_fixture(connectors, pytestconfig) -> dict[str, str]: + """Get opencti connector charm files.""" + charms = {} + charm_files = pytestconfig.getoption("--charm-file") + for charm_file in charm_files: + name = pathlib.Path(charm_file).name.split("_")[0] + if name in connectors: + charms[name] = charm_file + logger.info("load opencti connector charms: %s", charms) + return charms + + +@pytest.fixture(name="opencti_connector_images", scope="module") +def opencti_connector_images_fixture(connectors, pytestconfig) -> dict[str, str]: + """Get opencti connector charm images.""" + images = {} + for connector in connectors: + image = pytestconfig.getoption(f"--{connector}-image") + if image: + images[connector] = image + logger.info("load opencti connector images: %s", images) + return images diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 081931f..a739080 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -16,6 +16,8 @@ import yaml from juju.model import Model +from opencti import OpenctiClient + @pytest.mark.abort_on_fail @pytest.mark.usefixtures("machine_charm_dependencies") @@ -25,6 +27,7 @@ async def test_deploy_charm( machine_model: Model, machine_controller_name: str, get_unit_ips, + opencti_charm, ): """ arrange: deploy dependencies of the OpenCTI charm @@ -63,7 +66,7 @@ async def test_deploy_charm( ) await action.wait() opencti = await model.deploy( - f"./{pytestconfig.getoption('--charm-file')}", + f"./{opencti_charm}", resources={ "opencti-image": pytestconfig.getoption("--opencti-image"), }, @@ -128,3 +131,80 @@ async def test_opencti_workers(get_unit_ips, ops_test): ) worker_count = resp.json()["data"]["rabbitMQMetrics"]["consumers"] assert worker_count == str(3) + + +async def test_opencti_client(get_unit_ips, ops_test): + """ + arrange: deploy the OpenCTI charm + act: use the OpenCTI client to create some users + assert: users are created normally + """ + _, stdout, _ = await ops_test.juju( + "ssh", "--container", "opencti", "opencti/0", "pebble", "plan" + ) + plan = yaml.safe_load(stdout) + api_token = plan["services"]["platform"]["environment"]["APP__ADMIN__TOKEN"] + client = OpenctiClient( + url=f"http://{(await get_unit_ips('opencti'))[0]}:8080", api_token=api_token + ) + assert {u.name for u in client.list_users()} == {"admin"} + assert {g.name for g in client.list_groups()} == {"Administrators", "Connectors", "Default"} + client.create_user(name="testing") + user = {u.name: u for u in client.list_users()}["testing"] + client.set_account_status(user.id, "Inactive") + user = {u.name: u for u in client.list_users()}["testing"] + assert user.account_status == "Inactive" + + +async def test_opencti_connectors( + get_unit_ips, ops_test, model, opencti_connector_charms, opencti_connector_images +): + """ + arrange: deploy the OpenCTI charm and OpenCTI connector charm. + act: integrate the OpenCTI connector charm with the OpenCTI charm. + assert: OpenCTI connector should register itself inside the OpenCTI platform + """ + connector = "opencti-export-file-stix-connector" + charm = opencti_connector_charms[connector] + image = opencti_connector_images[connector] + connector_charm = await model.deploy( + f"./{charm}", + resources={ + f"{connector}-image": image, + }, + config={"connector-scope": "application/json"}, + ) + await model.integrate(connector_charm.name, "opencti") + await model.wait_for_idle(status="active") + query = { + "id": "WorkersStatusQuery", + "query": textwrap.dedent( + """\ + query ConnectorsStatusQuery { + ...ConnectorsStatus_data + } + + fragment ConnectorsStatus_data on Query { + connectors { + name + active + } + } + """ + ), + "variables": {}, + } + _, stdout, _ = await ops_test.juju( + "ssh", "--container", "opencti", "opencti/0", "pebble", "plan" + ) + plan = yaml.safe_load(stdout) + api_token = plan["services"]["platform"]["environment"]["APP__ADMIN__TOKEN"] + resp = requests.post( + f"http://{(await get_unit_ips('opencti'))[0]}:8080/graphql", + json=query, + headers={"Authorization": f"Bearer {api_token}"}, + timeout=5, + ) + connectors = {c["name"]: c for c in resp.json()["data"]["connectors"]} + assert connector in connectors + assert connectors[connector]["active"] diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 40f0fe6..eaad5d1 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -35,6 +35,8 @@ def patch_opencti_client(): class OpenctiClientMock: + """A mock for OpenctiClient.""" + _users = [ { "id": "88ec0c6a-13ce-5e39-b486-354fe4a7084f", @@ -50,21 +52,38 @@ class OpenctiClientMock: {"name": "Default", "id": "1e257543-6bfb-46f2-a25f-5d50bb0819bd"}, ] - def __init__(self, *args, **kwargs): - pass + def __init__(self, *_args, **_kwargs): + """Initialize OpenctiClientMock.""" + + def list_users(self) -> list[OpenctiUser]: + """List OpenCTI users. - def list_users(self): + Returns: + A list of OpenctiUser objects. + """ return [OpenctiUser(**u) for u in self._users] - def list_groups(self): + def list_groups(self) -> list[OpenctiGroup]: + """List OpenCTI groups. + + Returns: + A list of OpenctiGroup objects. + """ return [OpenctiGroup(**g) for g in self._groups] def create_user( self, name: str, user_email: str | None = None, - groups: list[str] | None = None, - ): + groups: list[str] | None = None, # pylint: disable=unused-argument + ) -> None: + """Create a user. + + Args: + name: The name of the user. + user_email: The email address of the user. + groups: The groups associated with the user. + """ new_user = { "name": name, "id": "00000000-0000-0000-0000-000000000000", @@ -79,6 +98,15 @@ def set_account_status( user_id: str, status: typing.Literal["Active", "Inactive"], ) -> None: + """Set OpenCTI account status. + + Args: + user_id: The ID of the user. + status: The status of the user. + + Raises: + RuntimeError: If user doesn't exist. + """ for user in self._users: if user["id"] == user_id: user["account_status"] = status diff --git a/tests/unit/state.py b/tests/unit/state.py index 94a638e..e568da0 100644 --- a/tests/unit/state.py +++ b/tests/unit/state.py @@ -279,13 +279,16 @@ def __init__(self, container_name: str) -> None: Args: container_name: name of the container. """ - self._integrations = [] - self._config = {} - self._secrets = [] + self._integrations: list[ops.testing.RelationBase] = [] + self._config: dict[str, str | int | float | bool] = {} + self._secrets: list[ops.testing.Secret] = [] self._container_name = container_name def add_opencti_connector_integration(self) -> "ConnectorStateBuilder": - """Add opencti-connector integration.""" + """Add opencti-connector integration. + + Returns: self + """ secret = ops.testing.Secret( tracked_content={"token": "00000000-0000-0000-0000-000000000000"} ) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index f91b6b1..bd63517 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -365,9 +365,9 @@ def test_opencti_connector(patch_opencti_client): assert "charm-connector-test" in users integration_out = state_out.get_relation(opencti_connector_integration.id) assert ( - integration_out.local_app_data["opencti_url"] + integration_out.local_app_data["opencti_url"] # type: ignore == "http://opencti-endpoints.test-opencti.svc:8080" ) - secret_id = integration_out.local_app_data["opencti_token"] + secret_id = integration_out.local_app_data["opencti_token"] # type: ignore secret = state_out.get_secret(id=secret_id) assert secret.tracked_content == {"token": "00000000-0000-0000-0000-000000000000"} diff --git a/tests/unit/test_connectors.py b/tests/unit/test_connectors.py index 367da36..3557dfe 100644 --- a/tests/unit/test_connectors.py +++ b/tests/unit/test_connectors.py @@ -7,12 +7,16 @@ import ops.testing +from connectors.export_file_stix.src.charm import OpenctiExportFileStixConnectorCharm from tests.unit.state import ConnectorStateBuilder def test_export_file_stix_connector(): - from connectors.export_file_stix.src.charm import OpenctiExportFileStixConnectorCharm - + """ + arrange: provide the connector charm with the required integrations and configurations + act: simulate a config-changed event + assert: the installed Pebble plan matches the expectation + """ container = "opencti-export-file-stix-connector" ctx = ops.testing.Context(OpenctiExportFileStixConnectorCharm) state_in = ( diff --git a/tox.ini b/tox.ini index 3327d76..a5741cd 100644 --- a/tox.ini +++ b/tox.ini @@ -113,6 +113,7 @@ deps = pytest pytest-asyncio pytest-operator + PyYAML -r{toxinidir}/requirements.txt commands = pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs}