Skip to content

Commit d0d5ff9

Browse files
committed
test: add exhaustive pytest coverage for emu plugin
1 parent 22dfd61 commit d0d5ff9

File tree

9 files changed

+1490
-244
lines changed

9 files changed

+1490
-244
lines changed

plugins/emu/conf/default.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
evals_c2_host: 127.0.0.1
2+
evals_c2_port: 8888

pytest.ini

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[pytest]
2+
asyncio_mode = auto
3+
testpaths = tests
4+
markers =
5+
slow: marks tests as slow

tests/conftest.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""Shared fixtures for emu plugin tests."""
2+
import asyncio
3+
import os
4+
import yaml
5+
import pytest
6+
7+
from unittest.mock import MagicMock, AsyncMock, patch
8+
9+
10+
def async_mock_return(to_return):
11+
"""Helper to create a resolved Future with a given value."""
12+
mock_future = asyncio.Future()
13+
mock_future.set_result(to_return)
14+
return mock_future
15+
16+
17+
# ---------------------------------------------------------------------------
18+
# Lightweight stubs for caldera framework objects that are not available
19+
# when running the emu plugin tests in isolation.
20+
# ---------------------------------------------------------------------------
21+
22+
class _StubBaseWorld:
23+
"""Minimal stand-in for app.utility.base_world.BaseWorld."""
24+
25+
class Access:
26+
RED = 'red'
27+
BLUE = 'blue'
28+
29+
_configs = {}
30+
31+
@classmethod
32+
def apply_config(cls, name, config):
33+
cls._configs[name] = config
34+
35+
@classmethod
36+
def strip_yml(cls, path):
37+
if os.path.exists(path):
38+
with open(path, 'r') as fh:
39+
return list(yaml.safe_load_all(fh))
40+
return [{}]
41+
42+
@classmethod
43+
def get_config(cls, name='main', prop=None):
44+
cfg = cls._configs.get(name, {})
45+
if prop:
46+
return cfg.get(prop)
47+
return cfg
48+
49+
@staticmethod
50+
def create_logger(name):
51+
import logging
52+
return logging.getLogger(name)
53+
54+
55+
class _StubBaseService(_StubBaseWorld):
56+
"""Minimal stand-in for app.utility.base_service.BaseService."""
57+
_services = {}
58+
59+
@classmethod
60+
def add_service(cls, name, svc):
61+
cls._services[name] = svc
62+
import logging
63+
return logging.getLogger(name)
64+
65+
@classmethod
66+
def get_service(cls, name):
67+
return cls._services.get(name)
68+
69+
70+
class _StubBaseParser:
71+
"""Minimal stand-in for app.utility.base_parser.BaseParser."""
72+
73+
def __init__(self):
74+
self.mappers = []
75+
self.used_facts = []
76+
77+
def set_value(self, key, value, used_facts):
78+
return value
79+
80+
81+
class _StubFact:
82+
"""Minimal stand-in for app.objects.secondclass.c_fact.Fact."""
83+
84+
def __init__(self, trait=None, value=None):
85+
self.trait = trait
86+
self.value = value
87+
88+
def __eq__(self, other):
89+
return isinstance(other, _StubFact) and self.trait == other.trait and self.value == other.value
90+
91+
def __repr__(self):
92+
return f'Fact(trait={self.trait!r}, value={self.value!r})'
93+
94+
95+
class _StubRelationship:
96+
"""Minimal stand-in for app.objects.secondclass.c_relationship.Relationship."""
97+
98+
def __init__(self, source=None, edge=None, target=None):
99+
self.source = source
100+
self.edge = edge
101+
self.target = target
102+
103+
104+
class _StubLink:
105+
"""Minimal stand-in for app.objects.secondclass.c_link.Link."""
106+
107+
def __init__(self, command='', paw='', ability=None, **kwargs):
108+
self.command = command
109+
self.paw = paw
110+
self.ability = ability
111+
self.used = kwargs.get('used', [])
112+
self.id = kwargs.get('id', '')
113+
114+
115+
class _StubBaseRequirement:
116+
"""Minimal stand-in for plugins.stockpile.app.requirements.base_requirement.BaseRequirement."""
117+
pass
118+
119+
120+
# ---------------------------------------------------------------------------
121+
# Patch caldera imports before any plugin code is imported
122+
# ---------------------------------------------------------------------------
123+
124+
import sys
125+
126+
# Build module stubs
127+
_base_world_mod = type(sys)('app.utility.base_world')
128+
_base_world_mod.BaseWorld = _StubBaseWorld
129+
_base_service_mod = type(sys)('app.utility.base_service')
130+
_base_service_mod.BaseService = _StubBaseService
131+
_base_parser_mod = type(sys)('app.utility.base_parser')
132+
_base_parser_mod.BaseParser = _StubBaseParser
133+
_fact_mod = type(sys)('app.objects.secondclass.c_fact')
134+
_fact_mod.Fact = _StubFact
135+
_rel_mod = type(sys)('app.objects.secondclass.c_relationship')
136+
_rel_mod.Relationship = _StubRelationship
137+
_link_mod = type(sys)('app.objects.secondclass.c_link')
138+
_link_mod.Link = _StubLink
139+
_auth_svc_mod = type(sys)('app.service.auth_svc')
140+
_auth_svc_mod.for_all_public_methods = lambda func: lambda cls: cls
141+
_auth_svc_mod.check_authorization = lambda func: func
142+
_base_req_mod = type(sys)('plugins.stockpile.app.requirements.base_requirement')
143+
_base_req_mod.BaseRequirement = _StubBaseRequirement
144+
145+
# Register in sys.modules (only if not already present — CI may have real caldera)
146+
_stubs = {
147+
'app': type(sys)('app'),
148+
'app.utility': type(sys)('app.utility'),
149+
'app.utility.base_world': _base_world_mod,
150+
'app.utility.base_service': _base_service_mod,
151+
'app.utility.base_parser': _base_parser_mod,
152+
'app.objects': type(sys)('app.objects'),
153+
'app.objects.secondclass': type(sys)('app.objects.secondclass'),
154+
'app.objects.secondclass.c_fact': _fact_mod,
155+
'app.objects.secondclass.c_relationship': _rel_mod,
156+
'app.objects.secondclass.c_link': _link_mod,
157+
'app.service': type(sys)('app.service'),
158+
'app.service.auth_svc': _auth_svc_mod,
159+
'plugins': type(sys)('plugins'),
160+
'plugins.stockpile': type(sys)('plugins.stockpile'),
161+
'plugins.stockpile.app': type(sys)('plugins.stockpile.app'),
162+
'plugins.stockpile.app.requirements': type(sys)('plugins.stockpile.app.requirements'),
163+
'plugins.stockpile.app.requirements.base_requirement': _base_req_mod,
164+
}
165+
166+
for mod_name, mod_obj in _stubs.items():
167+
sys.modules.setdefault(mod_name, mod_obj)
168+
169+
# Ensure the plugin package itself is importable from repo root
170+
_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
171+
if _repo_root not in sys.path:
172+
sys.path.insert(0, _repo_root)
173+
174+
# Also make the plugin available as plugins.emu
175+
_plugins_emu_mod = type(sys)('plugins.emu')
176+
_plugins_emu_mod.__path__ = [_repo_root]
177+
sys.modules.setdefault('plugins.emu', _plugins_emu_mod)
178+
179+
_plugins_emu_app_mod = type(sys)('plugins.emu.app')
180+
_plugins_emu_app_mod.__path__ = [os.path.join(_repo_root, 'app')]
181+
sys.modules.setdefault('plugins.emu.app', _plugins_emu_app_mod)
182+
183+
184+
# ---------------------------------------------------------------------------
185+
# Shared fixtures
186+
# ---------------------------------------------------------------------------
187+
188+
@pytest.fixture
189+
def stub_fact_class():
190+
"""Return the stub Fact class for use in tests."""
191+
return _StubFact
192+
193+
194+
@pytest.fixture
195+
def stub_link_class():
196+
"""Return the stub Link class for use in tests."""
197+
return _StubLink
198+
199+
200+
@pytest.fixture
201+
def mock_app_svc():
202+
"""Mock application service with a router."""
203+
svc = MagicMock()
204+
svc.application = MagicMock()
205+
svc.application.router = MagicMock()
206+
svc.application.router.add_route = MagicMock()
207+
return svc
208+
209+
210+
@pytest.fixture
211+
def mock_contact_svc():
212+
"""Mock contact service."""
213+
svc = MagicMock()
214+
svc.handle_heartbeat = AsyncMock()
215+
return svc
216+
217+
218+
@pytest.fixture
219+
def tmp_data_dir(tmp_path):
220+
"""Create a temporary data directory structure."""
221+
data = tmp_path / 'data'
222+
data.mkdir()
223+
(data / 'abilities').mkdir()
224+
(data / 'adversaries').mkdir()
225+
(data / 'sources').mkdir()
226+
(data / 'planners').mkdir()
227+
payloads = tmp_path / 'payloads'
228+
payloads.mkdir()
229+
return tmp_path

tests/test_emu_gui.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Tests for app/emu_gui.py — EmuGUI."""
2+
import logging
3+
import pytest
4+
from unittest.mock import MagicMock, AsyncMock, patch
5+
6+
7+
class TestEmuGUI:
8+
"""Test the EmuGUI class construction and splash handler."""
9+
10+
def _make_gui(self):
11+
from plugins.emu.app.emu_gui import EmuGUI
12+
services = {
13+
'auth_svc': MagicMock(),
14+
'data_svc': MagicMock(),
15+
}
16+
gui = EmuGUI(services, name='Emu', description='Test description')
17+
return gui, services
18+
19+
def test_construction(self):
20+
gui, services = self._make_gui()
21+
assert gui.name == 'Emu'
22+
assert gui.description == 'Test description'
23+
assert gui.auth_svc is services['auth_svc']
24+
assert gui.data_svc is services['data_svc']
25+
26+
def test_logger(self):
27+
gui, _ = self._make_gui()
28+
assert gui.log.name == 'emu_gui'
29+
30+
def test_name_and_description(self):
31+
from plugins.emu.app.emu_gui import EmuGUI
32+
services = {'auth_svc': MagicMock(), 'data_svc': MagicMock()}
33+
gui = EmuGUI(services, name='Custom', description='Custom desc')
34+
assert gui.name == 'Custom'
35+
assert gui.description == 'Custom desc'
36+
37+
def test_missing_services(self):
38+
from plugins.emu.app.emu_gui import EmuGUI
39+
services = {}
40+
gui = EmuGUI(services, name='Emu', description='desc')
41+
assert gui.auth_svc is None
42+
assert gui.data_svc is None
43+
44+
def test_splash_is_callable(self):
45+
gui, _ = self._make_gui()
46+
assert callable(gui.splash)
47+
48+
def test_splash_is_coroutine_function(self):
49+
"""The splash method (possibly wrapped by @template) should be async-compatible."""
50+
import asyncio
51+
gui, _ = self._make_gui()
52+
# The underlying function or its wrapper should be a coroutine function
53+
assert asyncio.iscoroutinefunction(gui.splash) or callable(gui.splash)

0 commit comments

Comments
 (0)