Security hardening, multi-site portability, and a documentation and API-surface sweep
This commit is contained in:
95
tests/test_api_inventory_parity.py
Normal file
95
tests/test_api_inventory_parity.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Every route the app serves is described in docs/api-inventory.json.
|
||||
|
||||
The inventory is hand-maintained, and deliberately so: its value is the prose -
|
||||
what each endpoint is FOR, what it accepts, a worked example - and none of that
|
||||
can be derived from the code. An audit of all 372 documented operations found
|
||||
zero phantom routes and zero wrong parameter names, so the maintenance itself is
|
||||
in good order.
|
||||
|
||||
What it cannot do is notice a route somebody added. Twenty-two were serving
|
||||
traffic undocumented, including an entire plugin's surface and every one of the
|
||||
dashboard card endpoints - each of them added after its surface was written.
|
||||
|
||||
So the prose stays hand-written and the COVERAGE is enforced here. Adding an
|
||||
endpoint now means describing it, in the same commit, which is when the author
|
||||
still knows what it is for.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb import create_app
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
INVENTORY = REPO / 'docs' / 'api-inventory.json'
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not INVENTORY.is_file(),
|
||||
reason='docs/ is excluded from publication; nothing to check here')
|
||||
|
||||
# Flask's own machinery, not this product's API.
|
||||
EXEMPT_ENDPOINTS = {'static'}
|
||||
|
||||
# Documented as a family rather than per-file: these serve the frontend bundle
|
||||
# and its assets, not JSON.
|
||||
EXEMPT_RULE_PREFIXES = ('/static', '/assets')
|
||||
|
||||
|
||||
def normalise(rule):
|
||||
"""Flask `<int:id>` and inventory `<id>` describe the same path.
|
||||
|
||||
Converters are an implementation detail of the route, and the inventory was
|
||||
written without them. Comparing on the converter would report a difference
|
||||
that is not one.
|
||||
"""
|
||||
return re.sub(r'<(?:[^:>]+:)?([^>]+)>', r'<\1>', rule)
|
||||
|
||||
|
||||
def served_operations():
|
||||
app = create_app('testing')
|
||||
served = set()
|
||||
for rule in app.url_map.iter_rules():
|
||||
if rule.endpoint in EXEMPT_ENDPOINTS:
|
||||
continue
|
||||
if any(rule.rule.startswith(prefix) for prefix in EXEMPT_RULE_PREFIXES):
|
||||
continue
|
||||
if not rule.rule.startswith('/api'):
|
||||
continue
|
||||
for method in sorted((rule.methods or set()) - {'HEAD', 'OPTIONS'}):
|
||||
served.add((method, normalise(rule.rule)))
|
||||
return served
|
||||
|
||||
|
||||
def documented_operations():
|
||||
documented = set()
|
||||
for surface in json.loads(INVENTORY.read_text()):
|
||||
for entry in surface.get('endpoints', []):
|
||||
path = normalise(entry.get('path') or '')
|
||||
# The inventory writes multi-verb routes as 'PUT|PATCH'. Splitting
|
||||
# on comma and slash alone left those undocumented in this check
|
||||
# AND dropped from the generated spec entirely.
|
||||
for method in re.split(r'[/,|]', entry.get('method') or 'GET'):
|
||||
method = method.strip().upper()
|
||||
if method:
|
||||
documented.add((method, path))
|
||||
return documented
|
||||
|
||||
|
||||
def test_every_served_route_is_documented():
|
||||
missing = sorted(served_operations() - documented_operations())
|
||||
assert not missing, (
|
||||
'%d route(s) serve traffic with no entry in docs/api-inventory.json. '
|
||||
'Describe them there (purpose, auth, params, example) and regenerate '
|
||||
'the spec with scripts/gen_openapi.py:\n %s'
|
||||
% (len(missing), '\n '.join('%s %s' % row for row in missing)))
|
||||
|
||||
|
||||
def test_nothing_documented_has_been_removed():
|
||||
"""A documented route that no longer exists sends a reader to a 404."""
|
||||
stale = sorted(documented_operations() - served_operations())
|
||||
assert not stale, (
|
||||
'%d documented route(s) are no longer served:\n %s'
|
||||
% (len(stale), '\n '.join('%s %s' % row for row in stale)))
|
||||
Reference in New Issue
Block a user