Files
shopdb-flask/tests/test_openapi_spec.py
cproudlock 8fc6df8f0a Describe the API this branch actually serves
docs/openapi.json is served live at /api/docs on the production box and
documented none of the routes added since 2026-09-11. It is generated from
docs/api-inventory.json, which is not tracked on this branch, so the generator
had no input - and tests/test_api_inventory_parity.py skipped itself for the
same reason. No route here had ever been checked against the published contract.

The inventory is now generated from this branch's own url_map rather than
carried over from elsewhere, with each route's auth derived from the decorators
and gates on its view rather than guessed. A route whose gate cannot be
classified is marked for review, not quietly called public. gen_openapi.py can
refresh it from the live app, so this does not rot again, and human-authored
prose survives a refresh.

The parity test now RUNS instead of skipping.

The thing that mattered more than the missing pages: nine operations were
published with "security: []" - the machine-readable claim that no credentials
are needed - while their handlers require one. Two of them serve the bootstrap
installer and the agent package. A generated client, a firewall plan or anything
else reading the spec would have been told those are anonymous. Required auth is
now expressed as required, and a token-or-network gate is expressed as optional
auth with the network half named in the description, which is what it actually
is.

A test re-derives auth from the live app and fails when the committed inventory
disagrees, because the parity check only ever compared route lists: a wrong auth
string passed everything.

test_openapi_spec.py asserted that optional auth was the majority of the
surface, a premise the guest-role and permission model has made false. It now
holds the property that matters - an optional-auth route is expressed as
optional - rather than a count that was true once.

gen_openapi.py also loads .env before building the app, like every other script
that builds one, or a plain python run falls back to the hardcoded localhost URL.
2026-09-18 13:31:11 -04:00

192 lines
8.1 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 documented_auth(operation):
"""The auth string the generator wrote into this operation's description.
The spec is the only thing this module reads, and the description is where
the human-readable auth lands ('**Auth:** jwt-optional (...)'). Comparing
the published `security` against it is how a lie gets caught: the two come
from the same inventory entry and must agree.
"""
match = re.search(r'\*\*Auth:\*\*\s*(.+)', operation.get('description') or '')
return match.group(1).strip().lower() if match else ''
def test_optional_auth_is_expressed_as_optional(spec):
"""A route whose auth is optional is published as optional, either way round.
WHY THE COUNT IS GONE. This asserted `len(optional) > 50`, on the premise
that optional auth was the majority case here - it said so in as many
words. It is not any more. The guest role and the permission model moved
the read surface to `permission:*`, which publishes as bearer-required and
is correct, and the optional count fell to 14. The assertion went red
without a single route being wrong.
A count was never the property anyone cares about. It goes red when the
codebase legitimately changes shape, and it stays green while an individual
optional route is published as bearer-required, which is the actual defect.
Lowering the number to 14 would only buy time until the next legitimate
move. So the premise is dropped and the per-route property is checked
instead, whatever the total: an operation whose auth says optional carries
BOTH the empty requirement and a scheme, and an operation that publishes
the empty requirement says optional in its auth.
"""
offenders = []
for path, verb, operation in operations(spec):
auth = documented_auth(operation)
security = operation.get('security') or []
openly = any(item == {} for item in security)
scheme = any(item for item in security)
if 'optional' in auth:
if not openly:
offenders.append(
'%s %s: auth says optional, published as required (%r)'
% (verb.upper(), path, security))
elif not scheme:
offenders.append(
'%s %s: auth says optional, published with no scheme at '
'all, so a caller is never told what the credential is'
% (verb.upper(), path))
elif openly:
offenders.append(
'%s %s: published as optional, but its auth says %r'
% (verb.upper(), path, auth))
assert not offenders, (
'Optional auth means "works without a credential, does more with one", '
'and OpenAPI writes it as the empty requirement ALONGSIDE the '
'scheme:\n ' + '\n '.join(offenders[:15]))
def test_an_operation_needing_no_credential_says_so_in_its_auth(spec):
"""`security: []` is a machine-readable claim that no credential is needed.
Nine operations published it while their handler required one, including
the two that hand a bootstrapping PC its agent package - `security: []` and
a 404 for anyone who does not present the single-use token. The generator
reached that by substring: the auth string started with the word 'none', in
a sentence that went on to name the token. This is the gate on that class
of defect from the spec side, where a reader or a generated client sees it.
"""
offenders = [
'%s %s: %r' % (verb.upper(), path, documented_auth(operation))
for path, verb, operation in operations(spec)
if not operation.get('security')
and 'none' not in documented_auth(operation)
and 'public' not in documented_auth(operation)
]
assert not offenders, (
'These operations publish "no credentials needed" while their auth '
'names a credential:\n ' + '\n '.join(offenders[:15]))
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])