124 lines
4.8 KiB
Python
124 lines
4.8 KiB
Python
"""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])
|