Skip to content

Commit e78d04b

Browse files
committed
chore: pytest migration, CI/CD, HTTP retry, unit tests
- Add pytest infrastructure: conftest.py, pyproject.toml config - Add 46 unit tests: db.py (tokenizer, sanitizer, identifier validation), all collectors (data integrity) - Add pytest integration wrapper for existing 224 integration tests - Add GitHub Actions CI: ruff + pytest on Python 3.11/3.12 - Add http_utils.py: shared HTTP fetch with exponential backoff retry (3 attempts) - Migrate collectors (projects, asvs, wstg, cheatsheets) to use retry-enabled fetch - Total: 270 tests (46 unit + 224 integration), 0 failures
1 parent de44efc commit e78d04b

11 files changed

Lines changed: 294 additions & 17 deletions

File tree

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.11", "3.12"]
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Set up Python ${{ matrix.python-version }}
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: ${{ matrix.python-version }}
23+
24+
- name: Install dependencies
25+
run: |
26+
pip install -e ".[dev]"
27+
28+
- name: Run unit tests
29+
run: |
30+
python -m pytest tests/test_unit_db.py tests/test_unit_collectors.py -v
31+
32+
- name: Run integration tests
33+
run: |
34+
python tests/test_comprehensive.py

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,8 @@ build-backend = "hatchling.build"
2222

2323
[tool.hatch.build.targets.wheel]
2424
packages = ["src/owasp_mcp"]
25+
26+
[tool.pytest.ini_options]
27+
asyncio_mode = "auto"
28+
testpaths = ["tests"]
29+

src/owasp_mcp/collectors/asvs.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import sqlite3
55

6-
import httpx
6+
from owasp_mcp.http_utils import fetch_json
77

88
log = logging.getLogger(__name__)
99

@@ -33,9 +33,7 @@
3333

3434

3535
def scrape_asvs(conn: sqlite3.Connection) -> int:
36-
resp = httpx.get(ASVS_URL, timeout=30, follow_redirects=True)
37-
resp.raise_for_status()
38-
data = resp.json()
36+
data = fetch_json(ASVS_URL)
3937

4038
requirements = data.get("requirements", [])
4139
rows = []

src/owasp_mcp/collectors/cheatsheets.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sqlite3
55

66
import httpx
7+
from owasp_mcp.http_utils import fetch_json
78

89
log = logging.getLogger(__name__)
910

@@ -32,14 +33,10 @@
3233

3334

3435
def scrape_cheatsheets(conn: sqlite3.Connection) -> int:
35-
resp = httpx.get(
36+
files = fetch_json(
3637
CHEATSHEETS_API_URL,
37-
timeout=30,
38-
follow_redirects=True,
3938
headers={"Accept": "application/vnd.github.v3+json"},
4039
)
41-
resp.raise_for_status()
42-
files = resp.json()
4340

4441
rows = []
4542
for f in files:

src/owasp_mcp/collectors/projects.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import sqlite3
55

6-
import httpx
6+
from owasp_mcp.http_utils import fetch_json
77

88
log = logging.getLogger(__name__)
99

@@ -46,9 +46,7 @@
4646

4747

4848
def scrape_projects(conn: sqlite3.Connection) -> int:
49-
resp = httpx.get(PROJECTS_URL, timeout=30, follow_redirects=True)
50-
resp.raise_for_status()
51-
projects = resp.json()
49+
projects = fetch_json(PROJECTS_URL)
5250

5351
rows = []
5452
for p in projects:

src/owasp_mcp/collectors/wstg.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import sqlite3
55

6-
import httpx
6+
from owasp_mcp.http_utils import fetch_json
77

88
log = logging.getLogger(__name__)
99

@@ -31,9 +31,7 @@
3131

3232

3333
def scrape_wstg(conn: sqlite3.Connection) -> int:
34-
resp = httpx.get(WSTG_URL, timeout=30, follow_redirects=True)
35-
resp.raise_for_status()
36-
data = resp.json()
34+
data = fetch_json(WSTG_URL)
3735

3836
rows = []
3937
categories = data.get("categories", {})

src/owasp_mcp/http_utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import time
5+
6+
import httpx
7+
8+
log = logging.getLogger(__name__)
9+
10+
_MAX_RETRIES = 3
11+
_BACKOFF_BASE = 2.0
12+
13+
14+
def fetch_json(url: str, timeout: int = 30, headers: dict | None = None) -> dict | list:
15+
for attempt in range(_MAX_RETRIES):
16+
try:
17+
resp = httpx.get(url, timeout=timeout, follow_redirects=True, headers=headers or {})
18+
resp.raise_for_status()
19+
return resp.json()
20+
except (httpx.HTTPStatusError, httpx.ConnectError, httpx.ReadTimeout) as exc:
21+
if attempt == _MAX_RETRIES - 1:
22+
raise
23+
wait = _BACKOFF_BASE ** attempt
24+
log.warning("HTTP request failed (attempt %d/%d), retrying in %.1fs: %s", attempt + 1, _MAX_RETRIES, wait, exc)
25+
time.sleep(wait)
26+
raise RuntimeError("unreachable")

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from __future__ import annotations

tests/test_integration.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from __future__ import annotations
2+
3+
import subprocess
4+
import sys
5+
6+
import pytest
7+
8+
9+
def test_comprehensive_integration():
10+
result = subprocess.run(
11+
[sys.executable, "tests/test_comprehensive.py"],
12+
capture_output=True,
13+
text=True,
14+
timeout=300,
15+
)
16+
assert "0 failed" in result.stdout, f"Integration tests failed:\n{result.stdout[-500:]}"
17+
assert result.returncode == 0

tests/test_unit_collectors.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
from owasp_mcp.collectors.top10 import TOP10_2021
4+
from owasp_mcp.collectors.api_top10 import API_TOP10_2023
5+
from owasp_mcp.collectors.llm_top10 import LLM_TOP10_2025
6+
from owasp_mcp.collectors.proactive_controls import PROACTIVE_CONTROLS_2024
7+
from owasp_mcp.collectors.mcp_top10 import MCP_TOP10_2025
8+
from owasp_mcp.collectors.masvs import MASVS_DATA
9+
from owasp_mcp.collectors.cwe_data import CWE_DATABASE
10+
11+
12+
class TestTop10Data:
13+
def test_has_10_items(self):
14+
assert len(TOP10_2021) == 10
15+
16+
def test_all_have_required_fields(self):
17+
for item in TOP10_2021:
18+
assert "id" in item and "name" in item and "cwes" in item
19+
20+
def test_ids_sequential(self):
21+
for i, item in enumerate(TOP10_2021, 1):
22+
assert item["id"] == f"A{str(i).zfill(2)}:2021"
23+
24+
25+
class TestApiTop10Data:
26+
def test_has_10_items(self):
27+
assert len(API_TOP10_2023) == 10
28+
29+
def test_all_have_required_fields(self):
30+
for item in API_TOP10_2023:
31+
assert all(k in item for k in ["id", "name", "description", "cwes", "url"])
32+
33+
def test_ids_sequential(self):
34+
for i, item in enumerate(API_TOP10_2023, 1):
35+
assert item["id"] == f"API{i}:2023"
36+
37+
38+
class TestLlmTop10Data:
39+
def test_has_10_items(self):
40+
assert len(LLM_TOP10_2025) == 10
41+
42+
def test_ids_format(self):
43+
for i, item in enumerate(LLM_TOP10_2025, 1):
44+
assert item["id"] == f"LLM{str(i).zfill(2)}:2025"
45+
46+
47+
class TestMcpTop10Data:
48+
def test_has_10_items(self):
49+
assert len(MCP_TOP10_2025) == 10
50+
51+
def test_ids_format(self):
52+
for i, item in enumerate(MCP_TOP10_2025, 1):
53+
assert item["id"] == f"MCP{str(i).zfill(2)}:2025"
54+
55+
def test_all_have_impact(self):
56+
for item in MCP_TOP10_2025:
57+
assert "impact" in item and len(item["impact"]) > 10
58+
59+
60+
class TestProactiveControlsData:
61+
def test_has_10_items(self):
62+
assert len(PROACTIVE_CONTROLS_2024) == 10
63+
64+
def test_ids_format(self):
65+
for i, item in enumerate(PROACTIVE_CONTROLS_2024, 1):
66+
assert item["id"] == f"C{i}"
67+
68+
69+
class TestMasvsData:
70+
def test_has_8_categories(self):
71+
assert len(MASVS_DATA) == 8
72+
73+
def test_category_ids(self):
74+
expected = ["MASVS-STORAGE", "MASVS-CRYPTO", "MASVS-AUTH", "MASVS-NETWORK",
75+
"MASVS-PLATFORM", "MASVS-CODE", "MASVS-RESILIENCE", "MASVS-PRIVACY"]
76+
actual = [cat_id for cat_id, _, _ in MASVS_DATA]
77+
assert actual == expected
78+
79+
def test_total_controls(self):
80+
total = sum(len(controls) for _, _, controls in MASVS_DATA)
81+
assert total == 23
82+
83+
84+
class TestCweDatabase:
85+
def test_has_entries(self):
86+
assert len(CWE_DATABASE) >= 30
87+
88+
def test_all_tuples(self):
89+
for entry in CWE_DATABASE:
90+
assert len(entry) == 3
91+
cwe_id, name, desc = entry
92+
assert cwe_id.startswith("CWE-")
93+
assert len(name) > 5
94+
assert len(desc) > 20
95+
96+
def test_key_cwes_present(self):
97+
ids = {e[0] for e in CWE_DATABASE}
98+
for key in ["CWE-79", "CWE-89", "CWE-918", "CWE-352", "CWE-287"]:
99+
assert key in ids, f"{key} missing from CWE database"

0 commit comments

Comments
 (0)