mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-06-20 14:40:36 +00:00
Adding --api-test for CI/CD
This commit is contained in:
parent
d570f8e91f
commit
91bf58b54e
8 changed files with 292 additions and 10 deletions
114
tests/test_openapi_drift.py
Normal file
114
tests/test_openapi_drift.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
Contract test: the OpenAPI spec (sqlmapapi.yaml) must stay in lock-step with the
|
||||
REST API actually served by lib/utils/api.py. The spec is hand-maintained, so it
|
||||
is the exact thing that silently drifts when an endpoint is added/renamed/retyped.
|
||||
|
||||
This walks the live Bottle route table (every @get/@post registers at import time)
|
||||
and the spec's `paths:` block, and asserts the (method, path) sets are identical
|
||||
in BOTH directions - no undocumented route, no phantom spec entry - plus that the
|
||||
spec's advertised version matches the runtime RESTAPI_VERSION.
|
||||
|
||||
PyYAML is not bundled (and the suite is stdlib-only / no pip), so the spec is read
|
||||
with a tiny indentation-aware scanner that only needs the paths + info.version.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _testutils import bootstrap
|
||||
bootstrap()
|
||||
|
||||
import lib.utils.api # noqa: F401 (importing registers every route on Bottle's default app)
|
||||
from lib.core.settings import RESTAPI_VERSION
|
||||
from thirdparty.bottle.bottle import default_app
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SPEC = os.path.join(ROOT, "sqlmapapi.yaml")
|
||||
|
||||
# Bottle-only routes that are not part of the documented public contract
|
||||
INTERNAL_RULES = ("/error/401",)
|
||||
|
||||
HTTP_METHODS = ("get", "post", "put", "delete", "patch", "head", "options")
|
||||
|
||||
|
||||
def _normalize_rule(rule):
|
||||
# Bottle '<taskid>' / '<filename:path>' -> OpenAPI '{taskid}' / '{filename}'
|
||||
return re.sub(r"<([^:>]+)(?::[^>]+)?>", r"{\1}", rule)
|
||||
|
||||
|
||||
def _app_pairs():
|
||||
pairs = set()
|
||||
for route in default_app().routes:
|
||||
rule = _normalize_rule(route.rule)
|
||||
if rule in INTERNAL_RULES:
|
||||
continue
|
||||
pairs.add((route.method.lower(), rule))
|
||||
return pairs
|
||||
|
||||
|
||||
def _spec_paths_and_version(text):
|
||||
"""Returns (set of (method, path), info.version) from the YAML text."""
|
||||
pairs = set()
|
||||
version = None
|
||||
section = None
|
||||
current_path = None
|
||||
|
||||
for line in text.splitlines():
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
|
||||
top = re.match(r"^(\S[^:]*):", line) # a column-0 key starts a new top-level section
|
||||
if top:
|
||||
section = top.group(1)
|
||||
current_path = None
|
||||
continue
|
||||
|
||||
if section == "info":
|
||||
m = re.match(r"^ version:\s*(.+?)\s*$", line)
|
||||
if m:
|
||||
version = m.group(1).strip().strip('"').strip("'")
|
||||
elif section == "paths":
|
||||
m = re.match(r"^ (/\S*):\s*$", line) # 2-space path key
|
||||
if m:
|
||||
current_path = m.group(1)
|
||||
continue
|
||||
m = re.match(r"^ (\w+):\s*$", line) # 4-space method key
|
||||
if m and current_path and m.group(1).lower() in HTTP_METHODS:
|
||||
pairs.add((m.group(1).lower(), current_path))
|
||||
|
||||
return pairs, version
|
||||
|
||||
|
||||
class TestOpenAPIDrift(unittest.TestCase):
|
||||
def setUp(self):
|
||||
with open(SPEC) as f:
|
||||
self.spec_pairs, self.spec_version = _spec_paths_and_version(f.read())
|
||||
self.app_pairs = _app_pairs()
|
||||
|
||||
def test_parsers_found_something(self):
|
||||
# guard against a silently-empty parse making the equality checks vacuously pass
|
||||
self.assertTrue(len(self.app_pairs) >= 15, self.app_pairs)
|
||||
self.assertEqual(len(self.spec_pairs), len(self.app_pairs))
|
||||
|
||||
def test_no_undocumented_endpoint(self):
|
||||
missing = self.app_pairs - self.spec_pairs
|
||||
self.assertEqual(missing, set(), "served but absent from sqlmapapi.yaml: %s" % sorted(missing))
|
||||
|
||||
def test_no_phantom_spec_entry(self):
|
||||
extra = self.spec_pairs - self.app_pairs
|
||||
self.assertEqual(extra, set(), "in sqlmapapi.yaml but not served: %s" % sorted(extra))
|
||||
|
||||
def test_version_matches_runtime(self):
|
||||
self.assertEqual(self.spec_version, RESTAPI_VERSION, "sqlmapapi.yaml version '%s' != RESTAPI_VERSION '%s'" % (self.spec_version, RESTAPI_VERSION))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue