Skip to content

Commit bb0ea5e

Browse files
committed
fix: add plugin tests for functionality and security patterns
1 parent 27f9bde commit bb0ea5e

File tree

1 file changed

+192
-0
lines changed

1 file changed

+192
-0
lines changed

tests/test_functional.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Functional and security regression tests for the training plugin.
2+
3+
Tests cover:
4+
- hook.py syntax validation via ast.parse
5+
- Abilities YAML validation (if present)
6+
- Requirements.txt dependency checks (if present)
7+
- Security pattern scanning across Python source files
8+
"""
9+
import ast
10+
import os
11+
import glob
12+
import re
13+
14+
import pytest
15+
16+
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
17+
18+
19+
class TestHookSyntax:
20+
"""Verify hook.py can be parsed without syntax errors."""
21+
22+
def test_hook_parses(self):
23+
"""hook.py should be valid Python syntax."""
24+
hook_path = os.path.join(PLUGIN_DIR, "hook.py")
25+
with open(hook_path, "r") as fh:
26+
source = fh.read()
27+
tree = ast.parse(source, filename="hook.py")
28+
assert isinstance(tree, ast.Module)
29+
30+
def test_hook_has_no_bare_exec(self):
31+
"""hook.py should not contain bare exec() calls."""
32+
hook_path = os.path.join(PLUGIN_DIR, "hook.py")
33+
with open(hook_path, "r") as fh:
34+
source = fh.read()
35+
tree = ast.parse(source)
36+
for node in ast.walk(tree):
37+
if isinstance(node, ast.Call):
38+
func = node.func
39+
if isinstance(func, ast.Name) and func.id == "exec":
40+
pytest.fail("hook.py contains a bare exec() call")
41+
42+
def test_all_py_files_parse(self):
43+
"""All .py files in the plugin should be valid Python syntax."""
44+
for root, dirs, files in os.walk(PLUGIN_DIR):
45+
dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"]
46+
for fname in files:
47+
if not fname.endswith(".py"):
48+
continue
49+
fpath = os.path.join(root, fname)
50+
with open(fpath, "r") as fh:
51+
source = fh.read()
52+
try:
53+
ast.parse(source, filename=fname)
54+
except SyntaxError as exc:
55+
rel = os.path.relpath(fpath, PLUGIN_DIR)
56+
pytest.fail(f"Syntax error in {rel}: {exc}")
57+
58+
59+
class TestAbilitiesYaml:
60+
"""Validate abilities YAML files under data/abilities/."""
61+
62+
@staticmethod
63+
def _yaml_files():
64+
abilities_dir = os.path.join(PLUGIN_DIR, "data", "abilities")
65+
return glob.glob(os.path.join(abilities_dir, "**", "*.yml"), recursive=True)
66+
67+
def test_abilities_directory_exists(self):
68+
"""data/abilities/ directory should exist."""
69+
assert os.path.isdir(os.path.join(PLUGIN_DIR, "data", "abilities"))
70+
71+
def test_abilities_yaml_files_exist(self):
72+
"""There should be at least one YAML ability file."""
73+
assert len(self._yaml_files()) > 0, "No .yml files found in data/abilities/"
74+
75+
def test_abilities_yaml_parseable(self):
76+
"""Each abilities YAML file should be parseable."""
77+
import yaml
78+
for yf in self._yaml_files():
79+
with open(yf, "r") as fh:
80+
try:
81+
docs = list(yaml.safe_load_all(fh))
82+
except yaml.YAMLError as exc:
83+
rel = os.path.relpath(yf, PLUGIN_DIR)
84+
pytest.fail(f"YAML parse error in {rel}: {exc}")
85+
86+
def test_abilities_have_required_fields(self):
87+
"""Each ability must have id, name, and tactic fields."""
88+
import yaml
89+
for yf in self._yaml_files():
90+
with open(yf, "r") as fh:
91+
docs = list(yaml.safe_load_all(fh))
92+
for doc in docs:
93+
if doc is None:
94+
continue
95+
items = doc if isinstance(doc, list) else [doc]
96+
for item in items:
97+
if not isinstance(item, dict):
98+
continue
99+
rel = os.path.relpath(yf, PLUGIN_DIR)
100+
assert 'id' in item, f"Missing 'id' in {rel}"
101+
assert 'name' in item, f"Missing 'name' in {rel}"
102+
assert 'tactic' in item, f"Missing 'tactic' in {rel}"
103+
104+
def test_abilities_ids_are_unique(self):
105+
"""Ability IDs should not be duplicated within the plugin."""
106+
import yaml
107+
seen = {}
108+
for yf in self._yaml_files():
109+
with open(yf, "r") as fh:
110+
docs = list(yaml.safe_load_all(fh))
111+
for doc in docs:
112+
if doc is None:
113+
continue
114+
items = doc if isinstance(doc, list) else [doc]
115+
for item in items:
116+
if not isinstance(item, dict) or 'id' not in item:
117+
continue
118+
aid = item['id']
119+
rel = os.path.relpath(yf, PLUGIN_DIR)
120+
assert aid not in seen, f"Duplicate ability id {aid} in {rel} and {seen[aid]}"
121+
seen[aid] = rel
122+
123+
124+
class TestSecurityPatterns:
125+
"""Scan Python source for risky patterns."""
126+
127+
@staticmethod
128+
def _py_files():
129+
"""Collect non-test Python source files."""
130+
result = []
131+
for root, dirs, files in os.walk(PLUGIN_DIR):
132+
dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__" and d != "tests"]
133+
for fname in files:
134+
if fname.endswith(".py"):
135+
result.append(os.path.join(root, fname))
136+
return result
137+
138+
def test_no_verify_false(self):
139+
"""No Python file should use verify=False (disables TLS verification)."""
140+
for fpath in self._py_files():
141+
with open(fpath, "r") as fh:
142+
for lineno, line in enumerate(fh, 1):
143+
if "verify=False" in line and not line.strip().startswith("#"):
144+
rel = os.path.relpath(fpath, PLUGIN_DIR)
145+
pytest.fail(
146+
f"verify=False found at {rel}:{lineno}"
147+
)
148+
149+
def test_no_unguarded_shell_true(self):
150+
"""No Python file should use shell=True outside of known-safe patterns."""
151+
allowlist = {"test_", "conftest.py"}
152+
for fpath in self._py_files():
153+
fname = os.path.basename(fpath)
154+
if any(fname.startswith(a) or fname == a for a in allowlist):
155+
continue
156+
with open(fpath, "r") as fh:
157+
for lineno, line in enumerate(fh, 1):
158+
stripped = line.strip()
159+
if stripped.startswith("#"):
160+
continue
161+
if "shell=True" in stripped:
162+
rel = os.path.relpath(fpath, PLUGIN_DIR)
163+
pytest.fail(
164+
f"shell=True found at {rel}:{lineno}"
165+
)
166+
167+
def test_requests_have_timeout(self):
168+
"""requests.get/post/put/delete calls should include a timeout parameter."""
169+
pattern = re.compile(r"requests\.(get|post|put|delete|patch|head)\(")
170+
for fpath in self._py_files():
171+
with open(fpath, "r") as fh:
172+
source = fh.read()
173+
for match in pattern.finditer(source):
174+
start = match.start()
175+
depth = 0
176+
end = start
177+
for i in range(start, min(start + 500, len(source))):
178+
if source[i] == "(":
179+
depth += 1
180+
elif source[i] == ")":
181+
depth -= 1
182+
if depth == 0:
183+
end = i
184+
break
185+
call_text = source[start:end]
186+
if "timeout" not in call_text:
187+
line_num = source[:start].count("\n") + 1
188+
rel = os.path.relpath(fpath, PLUGIN_DIR)
189+
pytest.fail(
190+
f"requests call without timeout at {rel}:{line_num}"
191+
)
192+

0 commit comments

Comments
 (0)