From e4e86c216ae0facb1af78d26cadb76be3ca7303c Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Sat, 6 Jul 2024 18:45:34 +0200 Subject: [PATCH 01/16] Updates CONTRIBUTING.md Updates the pytest command to include the asyncio mode (required). --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b3cea311..787332b23 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ We use the basic feature branch GIT flow. Fork this repository and create a feat # Run the tests Tests can be run by executing: ``` -python -m pytest +python -m pytest --asyncio-mode=auto ``` This will run all unit tests in your current development environment. Depending on the level of the change, you might need to run the test suite on various versions of Python. The unit testing pipeline will run the entire suite across multiple Python versions that we support when you submit your PR. From b32fd04c33a7e953ef23616feac61deccf67aae8 Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Sat, 6 Jul 2024 18:47:58 +0200 Subject: [PATCH 02/16] Fixes crash when running API v2 tests Jinja templates where not initialized in the test server. --- tests/conftest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index fb7f9adea..d96595e86 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import asyncio import os.path +import jinja2 import pytest import random import string @@ -14,8 +15,8 @@ from unittest import mock from aiohttp_apispec import validation_middleware from aiohttp import web +import aiohttp_jinja2 from pathlib import Path - from app.api.v2.handlers.agent_api import AgentApi from app.api.v2.handlers.ability_api import AbilityApi from app.api.v2.handlers.objective_api import ObjectiveApi @@ -392,6 +393,10 @@ async def initialize(): ) app_svc.application.middlewares.append(apispec_request_validation_middleware) app_svc.application.middlewares.append(validation_middleware) + templates = ['plugins/%s/templates' % p.lower() for p in app_svc.get_config('plugins')] + templates.append('plugins/magma/dist') + templates.append("templates") + aiohttp_jinja2.setup(app_svc.application, loader=jinja2.FileSystemLoader(templates)) return app_svc app_svc = await initialize() From fed2b3120d1ba7065b040ec5ed5ec76c43b71463 Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Sat, 6 Jul 2024 19:33:59 +0200 Subject: [PATCH 03/16] Fixes API v2 routes unreachable in tests A catch-all route was set in RestApi class before adding API v2 routes. --- app/api/rest_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/rest_api.py b/app/api/rest_api.py index 2d52069d4..1de5e1150 100644 --- a/app/api/rest_api.py +++ b/app/api/rest_api.py @@ -43,7 +43,7 @@ async def enable(self): self.app_svc.application.router.add_route('*', '/api/rest', self.rest_core) self.app_svc.application.router.add_route('GET', '/api/{index}', self.rest_core_info) self.app_svc.application.router.add_route('GET', '/file/download_exfil', self.download_exfil_file) - self.app_svc.application.router.add_route('GET', '/{tail:(?!plugin/).*}', self.handle_catch) + self.app_svc.application.router.add_route('GET', '/{tail:(?!plugin/|api/v2/).*}', self.handle_catch) async def validate_login(self, request): return await self.auth_svc.login_user(request) From 2fd623a8dd15173d02c4e2e6c5623c0c80034775 Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Sat, 6 Jul 2024 21:44:52 +0200 Subject: [PATCH 04/16] Fixes a broken test (test_health_api.py) Method TestHealthApi.test_get_health. The new "access" field was not taken into account. --- tests/api/v2/handlers/test_health_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/api/v2/handlers/test_health_api.py b/tests/api/v2/handlers/test_health_api.py index c2e4abd77..2cc5ce650 100644 --- a/tests/api/v2/handlers/test_health_api.py +++ b/tests/api/v2/handlers/test_health_api.py @@ -7,6 +7,7 @@ @pytest.fixture def expected_caldera_info(): return { + 'access': 'RED', 'application': 'Caldera', 'plugins': [], 'version': app.get_version() From a4cd739ae1faf7f2b8c7ba531f1d088509a4b7a9 Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Sat, 6 Jul 2024 22:11:56 +0200 Subject: [PATCH 05/16] Fixes a broken test (test_health_api.py) Method TestHealthApi.test_unauthorized_get_health. Empty access table was not taken into account. --- app/api/v2/handlers/health_api.py | 2 +- tests/api/v2/handlers/test_health_api.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/api/v2/handlers/health_api.py b/app/api/v2/handlers/health_api.py index c9229ca22..37458c36d 100644 --- a/app/api/v2/handlers/health_api.py +++ b/app/api/v2/handlers/health_api.py @@ -29,7 +29,7 @@ async def get_health_info(self, request): mapping = { 'application': 'Caldera', 'version': app.get_version(), - 'access': access[0].name, + 'access': access[0].name if len(access) > 0 else None, # 0 when not authenticated. 'plugins': loaded_plugins_sorted } diff --git a/tests/api/v2/handlers/test_health_api.py b/tests/api/v2/handlers/test_health_api.py index 2cc5ce650..11a4e2321 100644 --- a/tests/api/v2/handlers/test_health_api.py +++ b/tests/api/v2/handlers/test_health_api.py @@ -1,3 +1,5 @@ +import copy + import pytest import app @@ -14,6 +16,13 @@ def expected_caldera_info(): } +@pytest.fixture +def expected_unauthorized_caldera_info(expected_caldera_info): + new_info = copy.deepcopy(expected_caldera_info) + new_info['access'] = None + return new_info + + class TestHealthApi: async def test_get_health(self, api_v2_client, api_cookies, expected_caldera_info): resp = await api_v2_client.get('/api/v2/health', cookies=api_cookies) @@ -21,8 +30,8 @@ async def test_get_health(self, api_v2_client, api_cookies, expected_caldera_inf output_info = await resp.json() assert output_info == expected_caldera_info - async def test_unauthorized_get_health(self, api_v2_client, expected_caldera_info): + async def test_unauthorized_get_health(self, api_v2_client, expected_unauthorized_caldera_info): resp = await api_v2_client.get('/api/v2/health') assert resp.status == HTTPStatus.OK output_info = await resp.json() - assert output_info == expected_caldera_info + assert output_info == expected_unauthorized_caldera_info From 457c92f464f5b13ba7588c64a013ac8387d5aba3 Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Sat, 6 Jul 2024 23:37:19 +0200 Subject: [PATCH 06/16] Fixes a broken test (test_payloads_api.py) Method TestPayloadsApi.test_get_payloads. Adds missing payloads routes. Updates the test to retrieve payloads (real temporary files). --- tests/api/v2/handlers/test_payloads_api.py | 49 +++++++++++++++++++--- tests/conftest.py | 2 + 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/tests/api/v2/handlers/test_payloads_api.py b/tests/api/v2/handlers/test_payloads_api.py index 1ada24071..8570f35ba 100644 --- a/tests/api/v2/handlers/test_payloads_api.py +++ b/tests/api/v2/handlers/test_payloads_api.py @@ -1,14 +1,53 @@ +import os +import tempfile from http import HTTPStatus +import pytest + + +@pytest.fixture +def expected_payload_file_paths(): + """ + Generates (and deletes) real dummy files because the payload API looks for payload files in + "data/payloads" and/or in "plugins//payloads". + :return: A set of relative paths of dummy payloads. + """ + directory = "data/payloads" + os.makedirs(directory, exist_ok=True) + + file_paths = set() + current_working_dir = os.getcwd() + + try: + for _ in range(3): + fd, file_path = tempfile.mkstemp(prefix="payload_", dir=directory) + os.close(fd) + relative_path = os.path.relpath(file_path, start=current_working_dir) + file_paths.add(relative_path) + yield file_paths + finally: + for file_path in file_paths: + os.remove(file_path) + + +@pytest.fixture +def expected_payload_file_names(expected_payload_file_paths): + return {os.path.basename(path) for path in expected_payload_file_paths} + class TestPayloadsApi: - async def test_get_payloads(self, api_v2_client, api_cookies): + async def test_get_payloads(self, api_v2_client, api_cookies, expected_payload_file_names): resp = await api_v2_client.get('/api/v2/payloads', cookies=api_cookies) - payloads_list = await resp.json() - assert len(payloads_list) > 0 - payload = payloads_list[0] - assert type(payload) is str + payload_file_names = await resp.json() + assert len(payload_file_names) >= len(expected_payload_file_names) + + filtered_payload_file_names = { # Excluding any other real files in data/payloads... + file_name for file_name in payload_file_names + if file_name in expected_payload_file_names + } + + assert filtered_payload_file_names == expected_payload_file_names async def test_unauthorized_get_payloads(self, api_v2_client): resp = await api_v2_client.get('/api/v2/payloads') diff --git a/tests/conftest.py b/tests/conftest.py index d96595e86..c83aca648 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,6 +30,7 @@ from app.api.v2.handlers.planner_api import PlannerApi from app.api.v2.handlers.health_api import HealthApi from app.api.v2.handlers.schedule_api import ScheduleApi +from app.api.v2.handlers.payload_api import PayloadApi from app.objects.c_obfuscator import Obfuscator from app.objects.c_objective import Objective from app.objects.c_planner import PlannerSchema @@ -357,6 +358,7 @@ def make_app(svcs): PlannerApi(svcs).add_routes(app) HealthApi(svcs).add_routes(app) ScheduleApi(svcs).add_routes(app) + PayloadApi(svcs).add_routes(app) return app async def initialize(): From 2babbff197ee494aa0995f98851d53754941bb8a Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Sat, 6 Jul 2024 23:46:17 +0200 Subject: [PATCH 07/16] Fixes a broken test (test_core_endpoints.py) Function test_access_denied. Deleted because there is no GET "/enter" endpoint. A catch-all route redirects "/enter" to "/". --- tests/web_server/test_core_endpoints.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/web_server/test_core_endpoints.py b/tests/web_server/test_core_endpoints.py index a1b9e6eaf..caf5496ec 100644 --- a/tests/web_server/test_core_endpoints.py +++ b/tests/web_server/test_core_endpoints.py @@ -68,11 +68,6 @@ async def test_home(aiohttp_client): assert resp.content_type == 'text/html' -async def test_access_denied(aiohttp_client): - resp = await aiohttp_client.get('/enter') - assert resp.status == HTTPStatus.UNAUTHORIZED - - async def test_login(aiohttp_client): resp = await aiohttp_client.post('/enter', allow_redirects=False, data=dict(username='admin', password='admin')) assert resp.status == HTTPStatus.FOUND From b5a478ac384841c12b5040bcb24003a4d21994e1 Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Sat, 6 Jul 2024 23:58:15 +0200 Subject: [PATCH 08/16] Fixes a broken test (test_core_endpoints.py) Function test_custom_rejecting_login_handler. Using an endpoint that requires an authentication to check the redirection to the login page ("/"). --- tests/web_server/test_core_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/web_server/test_core_endpoints.py b/tests/web_server/test_core_endpoints.py index caf5496ec..33798dae8 100644 --- a/tests/web_server/test_core_endpoints.py +++ b/tests/web_server/test_core_endpoints.py @@ -147,7 +147,7 @@ async def handle_login_redirect(self, request, **kwargs): assert resp.status == HTTPStatus.UNAUTHORIZED assert await resp.text() == 'Automatic rejection' - resp = await aiohttp_client.get('/', allow_redirects=False) + resp = await aiohttp_client.get('/api/v2', allow_redirects=False) assert resp.status == HTTPStatus.UNAUTHORIZED assert await resp.text() == 'Automatic rejection' From 86ef164774e75357824c7ae436a898e2e183d02e Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Sun, 7 Jul 2024 00:49:20 +0200 Subject: [PATCH 09/16] Fixes a broken test (test_core_endpoints.py) Function test_home (only in CI/CD). The "index.html" template was missing in "plugins/magma/dist". Adds Node.js 20 (active LTS) dependency to the CI/CD. Adds "npm install" and "npm run build" commands to the "Install dependencies" step. --- .github/workflows/quality.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 039bdd963..f8ce8d85a 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -41,10 +41,16 @@ jobs: uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c with: python-version: ${{ matrix.python-version }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' - name: Install dependencies run: | pip install --upgrade virtualenv pip install tox + npm --prefix plugins/magma install + npm --prefix plugins/magma run build - name: Run tests env: TOXENV: ${{ matrix.toxenv }} From 15803582b92a45ff3d9984231eec86d780d7d6cb Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Mon, 7 Oct 2024 20:55:20 +0200 Subject: [PATCH 10/16] Fixes 2 broken tests (test_link.py) Thanks to uruwhy: > Those tests in particular need to manually store the operation fact > sources in the data service - the data service loads fact sources on > server startup, so I essentially had to replicate that behavior with > the dummy fact sources for the unit test --- tests/objects/test_link.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/objects/test_link.py b/tests/objects/test_link.py index 37266d751..77df89603 100644 --- a/tests/objects/test_link.py +++ b/tests/objects/test_link.py @@ -138,7 +138,7 @@ def test_link_knowledge_svc_synchronization(self, event_loop, executor, ability, knowledge_base_r = event_loop.run_until_complete(knowledge_svc.get_relationships(dict(edge='has_admin'))) assert len(knowledge_base_r) == 1 - def test_create_relationship_source_fact(self, event_loop, ability, executor, operation, knowledge_svc, fire_event_mock): + def test_create_relationship_source_fact(self, event_loop, ability, executor, operation, data_svc, knowledge_svc, fire_event_mock): test_executor = executor(name='psh', platform='windows') test_ability = ability(ability_id='123', executors=[test_executor]) fact1 = Fact(trait='remote.host.fqdn', value='dc') @@ -149,6 +149,7 @@ def test_create_relationship_source_fact(self, event_loop, ability, executor, op adversary=Adversary(name='sample', adversary_id='XYZ', atomic_ordering=[], description='test'), source=Source(id='test-source', facts=[fact1])) + event_loop.run_until_complete(data_svc.store(operation.source)) event_loop.run_until_complete(operation._init_source()) event_loop.run_until_complete(link1.create_relationships([relationship], operation)) @@ -161,7 +162,7 @@ def test_create_relationship_source_fact(self, event_loop, ability, executor, op assert len(fact_store_operation) == 1 assert len(fact_store_operation_source[0].collected_by) == 2 - def test_save_discover_seeded_fact_not_in_command(self, event_loop, ability, executor, operation, knowledge_svc, fire_event_mock): + def test_save_discover_seeded_fact_not_in_command(self, event_loop, ability, executor, operation, knowledge_svc, data_svc, fire_event_mock): test_executor = executor(name='psh', platform='windows') test_ability = ability(ability_id='123', executors=[test_executor]) fact1 = Fact(trait='remote.host.fqdn', value='dc') @@ -172,6 +173,7 @@ def test_save_discover_seeded_fact_not_in_command(self, event_loop, ability, exe adversary=Adversary(name='sample', adversary_id='XYZ', atomic_ordering=[], description='test'), source=Source(id='test-source', facts=[fact1, fact2])) + event_loop.run_until_complete(data_svc.store(operation.source)) event_loop.run_until_complete(operation._init_source()) event_loop.run_until_complete(link.save_fact(operation, fact2, 1, relationship)) From 6d3ede91b435fd6c6374105cc6b91a78f934ae5e Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Sun, 7 Jul 2024 01:13:12 +0200 Subject: [PATCH 11/16] Fixes a broken flake8 test (payload_api.py) --- app/api/v2/handlers/payload_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/v2/handlers/payload_api.py b/app/api/v2/handlers/payload_api.py index 51fee89cd..b8e9841dc 100644 --- a/app/api/v2/handlers/payload_api.py +++ b/app/api/v2/handlers/payload_api.py @@ -83,7 +83,7 @@ async def post_payloads(self, request: web.Request): tags=['payloads'], summary='Delete a payload', description='Deletes a given payload.', - responses = { + responses={ 204: {"description": "Payload has been properly deleted."}, 404: {"description": "Payload not found."}, }) From 0bbe133dce6deb49b024a7d7eede653ab188273a Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Mon, 7 Oct 2024 22:03:57 +0200 Subject: [PATCH 12/16] Fixes a broken flake8 test (fact_source_manager.py) --- app/api/v2/managers/fact_source_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/api/v2/managers/fact_source_manager.py b/app/api/v2/managers/fact_source_manager.py index 2a0afbb97..43291c8c5 100644 --- a/app/api/v2/managers/fact_source_manager.py +++ b/app/api/v2/managers/fact_source_manager.py @@ -1,5 +1,6 @@ from app.api.v2.managers.base_api_manager import BaseApiManager + class FactSourceApiManager(BaseApiManager): def __init__(self, data_svc, file_svc, knowledge_svc): super().__init__(data_svc=data_svc, file_svc=file_svc) From ed826e40077d70fcc75a50e346377b6fd3569449 Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Mon, 7 Oct 2024 22:04:17 +0200 Subject: [PATCH 13/16] Fixes a broken flake8 test (data_svc.py) --- app/service/data_svc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/service/data_svc.py b/app/service/data_svc.py index f768823bd..9a2b938d1 100644 --- a/app/service/data_svc.py +++ b/app/service/data_svc.py @@ -481,7 +481,7 @@ async def _verify_adversary_profiles(self): def _get_plugin_name(self, filename): plugin_path = pathlib.PurePath(filename).parts return plugin_path[1] if 'plugins' in plugin_path else '' - + async def get_facts_from_source(self, fact_source_id): fact_sources = await self.locate('sources', match=dict(id=fact_source_id)) if len(fact_sources) == 0: From 608457e627fe182468712e743957fb7f05509d2d Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Mon, 8 Jul 2024 09:27:14 +0200 Subject: [PATCH 14/16] Fixes marshmallow warnings (payload_schemas.py) --- app/api/v2/schemas/payload_schemas.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/api/v2/schemas/payload_schemas.py b/app/api/v2/schemas/payload_schemas.py index 99b8213e6..22d8701f4 100644 --- a/app/api/v2/schemas/payload_schemas.py +++ b/app/api/v2/schemas/payload_schemas.py @@ -2,9 +2,9 @@ class PayloadQuerySchema(schema.Schema): - sort = fields.Boolean(required=False, default=False) - exclude_plugins = fields.Boolean(required=False, default=False) - add_path = fields.Boolean(required=False, default=False) + sort = fields.Boolean(required=False, load_default=False) + exclude_plugins = fields.Boolean(required=False, load_default=False) + add_path = fields.Boolean(required=False, load_default=False) class PayloadSchema(schema.Schema): @@ -12,7 +12,7 @@ class PayloadSchema(schema.Schema): class PayloadCreateRequestSchema(schema.Schema): - file = fields.Raw(type="file", required=True) + file = fields.Raw(required=True, metadata={'type': 'file'}) class PayloadDeleteRequestSchema(schema.Schema): From 5df1ba30276ebb2059d4769fd21a8c943df78ff6 Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Tue, 24 Sep 2024 21:08:09 +0200 Subject: [PATCH 15/16] Updates /api/v2/health endpoint Makes it require authenticated users. Simplifies back the management of the returned "access" field. --- app/api/v2/handlers/health_api.py | 5 ++--- tests/api/v2/handlers/test_health_api.py | 15 ++------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/app/api/v2/handlers/health_api.py b/app/api/v2/handlers/health_api.py index 37458c36d..cb7e7c7cd 100644 --- a/app/api/v2/handlers/health_api.py +++ b/app/api/v2/handlers/health_api.py @@ -4,7 +4,6 @@ from aiohttp import web import app -from app.api.v2 import security from app.api.v2.handlers.base_api import BaseApi from app.api.v2.schemas.caldera_info_schemas import CalderaInfoSchema @@ -16,7 +15,7 @@ def __init__(self, services): def add_routes(self, app: web.Application): router = app.router - router.add_get('/health', security.authentication_exempt(self.get_health_info)) + router.add_get('/health', self.get_health_info) @aiohttp_apispec.docs(tags=['health'], summary='Health endpoints returns the status of Caldera', @@ -29,7 +28,7 @@ async def get_health_info(self, request): mapping = { 'application': 'Caldera', 'version': app.get_version(), - 'access': access[0].name if len(access) > 0 else None, # 0 when not authenticated. + 'access': access[0].name, 'plugins': loaded_plugins_sorted } diff --git a/tests/api/v2/handlers/test_health_api.py b/tests/api/v2/handlers/test_health_api.py index 11a4e2321..52cb87baf 100644 --- a/tests/api/v2/handlers/test_health_api.py +++ b/tests/api/v2/handlers/test_health_api.py @@ -1,5 +1,3 @@ -import copy - import pytest import app @@ -16,13 +14,6 @@ def expected_caldera_info(): } -@pytest.fixture -def expected_unauthorized_caldera_info(expected_caldera_info): - new_info = copy.deepcopy(expected_caldera_info) - new_info['access'] = None - return new_info - - class TestHealthApi: async def test_get_health(self, api_v2_client, api_cookies, expected_caldera_info): resp = await api_v2_client.get('/api/v2/health', cookies=api_cookies) @@ -30,8 +21,6 @@ async def test_get_health(self, api_v2_client, api_cookies, expected_caldera_inf output_info = await resp.json() assert output_info == expected_caldera_info - async def test_unauthorized_get_health(self, api_v2_client, expected_unauthorized_caldera_info): + async def test_unauthorized_get_health(self, api_v2_client): resp = await api_v2_client.get('/api/v2/health') - assert resp.status == HTTPStatus.OK - output_info = await resp.json() - assert output_info == expected_unauthorized_caldera_info + assert resp.status == HTTPStatus.UNAUTHORIZED From e330950af1b306657fa02b54ea9391b8cb90233e Mon Sep 17 00:00:00 2001 From: jean-baptiste-perez-bib Date: Mon, 7 Oct 2024 20:55:37 +0200 Subject: [PATCH 16/16] Fixes a broken test (test_operation.py) Thanks to uruwhy: > Those tests in particular need to manually store the operation fact > sources in the data service - the data service loads fact sources on > server startup, so I essentially had to replicate that behavior with > the dummy fact sources for the unit test --- tests/objects/test_operation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/objects/test_operation.py b/tests/objects/test_operation.py index d3d4ecc8a..be95d9d16 100644 --- a/tests/objects/test_operation.py +++ b/tests/objects/test_operation.py @@ -427,6 +427,7 @@ def test_without_learning_parser(self, event_loop, app_svc, contact_svc, data_sv def test_facts(self, event_loop, app_svc, contact_svc, file_svc, data_svc, learning_svc, fire_event_mock, op_with_learning_and_seeded, make_test_link, make_test_result, knowledge_svc): + event_loop.run_until_complete(data_svc.store(op_with_learning_and_seeded.source)) test_link = make_test_link(9876) op_with_learning_and_seeded.add_link(test_link)