Skip to content

Commit 5ef50b5

Browse files
authored
fix: add plugin tests for functionality and security patterns (#86)
1 parent c2304cf commit 5ef50b5

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

tests/__init__.py

Whitespace-only changes.

tests/test_functional.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Functional and security regression tests for the debrief 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 TestRequirements:
60+
"""Check requirements.txt for known issues."""
61+
62+
def test_requirements_file_exists(self):
63+
"""requirements.txt should exist."""
64+
assert os.path.isfile(os.path.join(PLUGIN_DIR, "requirements.txt"))
65+
66+
def test_requirements_parseable(self):
67+
"""Each line in requirements.txt should be a valid dependency spec."""
68+
req_path = os.path.join(PLUGIN_DIR, "requirements.txt")
69+
with open(req_path, "r") as fh:
70+
for lineno, line in enumerate(fh, 1):
71+
line = line.strip()
72+
if not line or line.startswith("#"):
73+
continue
74+
for ch in [";", "|", "&", "`", "$"]:
75+
assert ch not in line, (
76+
f"Suspicious character '{ch}' in requirements.txt line {lineno}: {line}"
77+
)
78+
79+
def test_no_pinned_vulnerable_packages(self):
80+
"""requirements.txt should not pin known-vulnerable package versions."""
81+
known_vulnerable = {
82+
"pyyaml": ["5.3", "5.3.1"],
83+
"jinja2": ["2.10", "2.10.1", "2.11.1"],
84+
"cryptography": ["3.3", "3.3.1"],
85+
"urllib3": ["1.25.7", "1.25.8"],
86+
"requests": ["2.19.1", "2.20.0"],
87+
"setuptools": ["49.1.0"],
88+
}
89+
req_path = os.path.join(PLUGIN_DIR, "requirements.txt")
90+
with open(req_path, "r") as fh:
91+
for line in fh:
92+
line = line.strip().lower()
93+
if not line or line.startswith("#"):
94+
continue
95+
if "==" in line:
96+
pkg, ver = line.split("==", 1)
97+
pkg = pkg.strip()
98+
ver = ver.strip()
99+
if pkg in known_vulnerable and ver in known_vulnerable[pkg]:
100+
pytest.fail(
101+
f"Pinned vulnerable version: {pkg}=={ver}"
102+
)
103+
104+
105+
class TestSecurityPatterns:
106+
"""Scan Python source for risky patterns."""
107+
108+
@staticmethod
109+
def _py_files():
110+
"""Collect non-test Python source files."""
111+
result = []
112+
for root, dirs, files in os.walk(PLUGIN_DIR):
113+
dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__" and d != "tests"]
114+
for fname in files:
115+
if fname.endswith(".py"):
116+
result.append(os.path.join(root, fname))
117+
return result
118+
119+
def test_no_verify_false(self):
120+
"""No Python file should use verify=False (disables TLS verification)."""
121+
for fpath in self._py_files():
122+
with open(fpath, "r") as fh:
123+
for lineno, line in enumerate(fh, 1):
124+
if "verify=False" in line and not line.strip().startswith("#"):
125+
rel = os.path.relpath(fpath, PLUGIN_DIR)
126+
pytest.fail(
127+
f"verify=False found at {rel}:{lineno}"
128+
)
129+
130+
def test_no_unguarded_shell_true(self):
131+
"""No Python file should use shell=True outside of known-safe patterns."""
132+
allowlist = {"test_", "conftest.py"}
133+
for fpath in self._py_files():
134+
fname = os.path.basename(fpath)
135+
if any(fname.startswith(a) or fname == a for a in allowlist):
136+
continue
137+
with open(fpath, "r") as fh:
138+
for lineno, line in enumerate(fh, 1):
139+
stripped = line.strip()
140+
if stripped.startswith("#"):
141+
continue
142+
if "shell=True" in stripped:
143+
rel = os.path.relpath(fpath, PLUGIN_DIR)
144+
pytest.fail(
145+
f"shell=True found at {rel}:{lineno}"
146+
)
147+
148+
def test_requests_have_timeout(self):
149+
"""requests.get/post/put/delete calls should include a timeout parameter."""
150+
pattern = re.compile(r"requests\.(get|post|put|delete|patch|head)\(")
151+
for fpath in self._py_files():
152+
with open(fpath, "r") as fh:
153+
source = fh.read()
154+
for match in pattern.finditer(source):
155+
start = match.start()
156+
depth = 0
157+
end = start
158+
for i in range(start, min(start + 500, len(source))):
159+
if source[i] == "(":
160+
depth += 1
161+
elif source[i] == ")":
162+
depth -= 1
163+
if depth == 0:
164+
end = i
165+
break
166+
call_text = source[start:end]
167+
if "timeout" not in call_text:
168+
line_num = source[:start].count("\n") + 1
169+
rel = os.path.relpath(fpath, PLUGIN_DIR)
170+
pytest.fail(
171+
f"requests call without timeout at {rel}:{line_num}"
172+
)
173+

0 commit comments

Comments
 (0)