Security hardening, multi-site portability, and a documentation and API-surface sweep
This commit is contained in:
123
tests/test_openapi_spec.py
Normal file
123
tests/test_openapi_spec.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""The generated spec has to be usable by a machine, not just readable.
|
||||
|
||||
docs/openapi.json is the only machine-readable description of this API, and it
|
||||
is what the MCP server builds its tools from. It was published with no
|
||||
`parameters` and no `requestBody` on any operation - invalid OpenAPI 3.1, and
|
||||
worse in practice: a tool built from an operation with no parameters has nowhere
|
||||
to put the id its caller supplied, so every argument was dropped in silence. A
|
||||
call for one asset returned the list.
|
||||
|
||||
These tests are the floor. They check the spec's shape, not its prose.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SPEC = REPO / 'docs' / 'openapi.json'
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not SPEC.is_file(),
|
||||
reason='no generated spec in this checkout')
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def spec():
|
||||
return json.loads(SPEC.read_text())
|
||||
|
||||
|
||||
def operations(spec):
|
||||
for path, item in spec['paths'].items():
|
||||
for verb, operation in item.items():
|
||||
yield path, verb, operation
|
||||
|
||||
|
||||
def test_every_templated_path_declares_its_parameters(spec):
|
||||
"""`/api/assets/{id}` with no parameters is not a description of anything."""
|
||||
offenders = [
|
||||
'%s %s' % (verb.upper(), path)
|
||||
for path, verb, operation in operations(spec)
|
||||
if '{' in path and not operation.get('parameters')
|
||||
]
|
||||
assert not offenders, (
|
||||
'These operations have a templated path and no parameters, so a '
|
||||
'generated client cannot fill it in:\n ' + '\n '.join(offenders[:15]))
|
||||
|
||||
|
||||
def test_declared_parameters_match_the_template(spec):
|
||||
"""A parameter that is not in the path, or a placeholder with no parameter,
|
||||
is the same defect wearing different clothes."""
|
||||
offenders = []
|
||||
for path, verb, operation in operations(spec):
|
||||
intemplate = set(re.findall(r'\{([^}]+)\}', path))
|
||||
declared = {p['name'] for p in operation.get('parameters', [])
|
||||
if p.get('in') == 'path'}
|
||||
if intemplate != declared:
|
||||
offenders.append('%s %s: template %s, declared %s'
|
||||
% (verb.upper(), path, sorted(intemplate), sorted(declared)))
|
||||
assert not offenders, '\n '.join(offenders[:15])
|
||||
|
||||
|
||||
def test_write_verbs_can_carry_a_body(spec):
|
||||
offenders = [
|
||||
'%s %s' % (verb.upper(), path)
|
||||
for path, verb, operation in operations(spec)
|
||||
if verb in ('post', 'put') and 'requestBody' not in operation
|
||||
]
|
||||
assert not offenders, (
|
||||
'These write operations declare no request body, so a client built '
|
||||
'from this spec cannot send one:\n ' + '\n '.join(offenders[:15]))
|
||||
|
||||
|
||||
def test_optional_auth_is_expressed_as_optional(spec):
|
||||
"""An endpoint that works logged out must not be published as requiring a
|
||||
token - that is a lie to every reader, and it is the majority case here."""
|
||||
optional = [operation for _, _, operation in operations(spec)
|
||||
if any(item == {} for item in operation.get('security') or [])]
|
||||
assert len(optional) > 50, (
|
||||
'The inventory documents over a hundred jwt-optional endpoints; the '
|
||||
'spec should express them with an empty security requirement '
|
||||
'alongside the scheme, not as bearer-required.')
|
||||
|
||||
|
||||
def test_an_endpoint_that_needs_no_token_cannot_fail_with_401(spec):
|
||||
offenders = [
|
||||
'%s %s' % (verb.upper(), path)
|
||||
for path, verb, operation in operations(spec)
|
||||
if not operation.get('security') and '401' in operation.get('responses', {})
|
||||
]
|
||||
assert not offenders, '\n '.join(offenders[:15])
|
||||
|
||||
|
||||
def test_every_operation_documents_failure(spec):
|
||||
offenders = [
|
||||
'%s %s' % (verb.upper(), path)
|
||||
for path, verb, operation in operations(spec)
|
||||
if 'default' not in operation.get('responses', {})
|
||||
]
|
||||
assert not offenders, (
|
||||
'These operations document only success:\n ' + '\n '.join(offenders[:15]))
|
||||
|
||||
|
||||
def test_the_envelopes_are_defined_and_referenced(spec):
|
||||
schemas = spec.get('components', {}).get('schemas', {})
|
||||
assert 'SuccessEnvelope' in schemas and 'ErrorEnvelope' in schemas
|
||||
# The error shape is the one people get wrong: nested under data.
|
||||
error = schemas['ErrorEnvelope']['properties']['data']['properties']['error']
|
||||
assert set(error['required']) == {'code', 'message'}
|
||||
|
||||
|
||||
def test_summaries_are_not_cut_mid_word(spec):
|
||||
"""A truncated summary is what a tool picker shows as the whole description
|
||||
of a call."""
|
||||
offenders = []
|
||||
for path, verb, operation in operations(spec):
|
||||
summary = operation.get('summary', '')
|
||||
if summary.endswith('...'):
|
||||
body = summary[:-3]
|
||||
if body and not body[-1].isalnum() and body[-1] not in ')]"\'':
|
||||
offenders.append('%s %s: %r' % (verb.upper(), path, summary[-40:]))
|
||||
assert not offenders, '\n '.join(offenders[:15])
|
||||
Reference in New Issue
Block a user