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.
This commit is contained in:
4787
docs/api-inventory.json
Normal file
4787
docs/api-inventory.json
Normal file
File diff suppressed because it is too large
Load Diff
42074
docs/openapi.json
42074
docs/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,54 @@
|
|||||||
"""Generate docs/openapi.json from docs/api-inventory.json.
|
"""Generate docs/openapi.json from docs/api-inventory.json, and refresh that
|
||||||
|
inventory from the routes the app actually serves.
|
||||||
|
|
||||||
The inventory is a list of {surface, endpoints:[{method,path,auth,params,purpose,
|
The inventory is a list of {surface, endpoints:[{method,path,auth,params,purpose,
|
||||||
example}]} objects (one per API surface). It is HAND-MAINTAINED - nothing
|
example}]} objects (one per API surface). Two halves, maintained differently:
|
||||||
generates it, and the docstring here used to imply otherwise. That is deliberate:
|
|
||||||
its value is the prose, and what an endpoint is FOR cannot be derived from the
|
|
||||||
code. Coverage is enforced instead, by tests/test_api_inventory_parity.py, which
|
|
||||||
fails when a served route has no entry.
|
|
||||||
|
|
||||||
So: describe the endpoint in api-inventory.json, then re-run this. Served
|
STRUCTURE (surface, method, path, auth) is DERIVED. It is walked out of the
|
||||||
interactively at /api/docs (see shopdb/core/api/docs.py).
|
live url_map and the decorators on each view, so it cannot drift from the
|
||||||
|
code. It had: this branch carried no inventory at all, so the generator had no
|
||||||
|
input, the parity test skipped, and 24 routes served traffic documented
|
||||||
|
nowhere.
|
||||||
|
|
||||||
venv/bin/python scripts/gen_openapi.py
|
PROSE (purpose, params, example) is HUMAN. What an endpoint is FOR cannot be
|
||||||
|
derived, so a refresh never overwrites text that is already there - it fills
|
||||||
|
in a new route from its docstring and leaves every existing entry's words
|
||||||
|
alone.
|
||||||
|
|
||||||
|
So: add a route, run --refresh, and replace the seeded prose with the reason the
|
||||||
|
route exists. Coverage is enforced on top of that by
|
||||||
|
tests/test_api_inventory_parity.py, which fails when a served route has no entry
|
||||||
|
AND when a committed auth string disagrees with what this file re-derives.
|
||||||
|
Served interactively at /api/docs (see shopdb/core/api/docs.py).
|
||||||
|
|
||||||
|
venv/bin/python scripts/gen_openapi.py # spec from the inventory
|
||||||
|
venv/bin/python scripts/gen_openapi.py --refresh # inventory first, then spec
|
||||||
|
|
||||||
|
THE AUTH STRING IS A GRAMMAR, not free text. Its first word names the mechanism
|
||||||
|
and everything after it is prose; '-optional' on that word means the gate can be
|
||||||
|
satisfied without a credential. See AUTH_SCHEME and _security. Derived strings
|
||||||
|
come from DECORATOR_AUTH, HANDLER_AUTH and INLINE_AUTH below, so a route whose
|
||||||
|
gate this cannot see says so rather than guessing.
|
||||||
|
|
||||||
|
KNOWN LIMITATION: QUERY PARAMETERS ARE NOT DERIVED. Path arguments come out of
|
||||||
|
the rule, and a body is declared for the verbs that take one, but nothing here
|
||||||
|
reads request.args out of a handler - so every route whose real interface is a
|
||||||
|
query string ('?q=', '?page=', '?status=') publishes params as 'not documented'
|
||||||
|
and an operation with no `parameters` beyond its path. That is a gap in the
|
||||||
|
generated spec, not in the route: a machine client built from this document can
|
||||||
|
call those endpoints but cannot filter, search or page them. Filling it means
|
||||||
|
either a handler sweep for request.args reads - which cannot see a key built at
|
||||||
|
runtime, so it would be right most of the time and silently wrong the rest - or
|
||||||
|
writing the query contract into the inventory's PROSE half by hand, where a
|
||||||
|
human owns it. The prose half is the honest option and is where it should land.
|
||||||
|
The one query credential that IS expressed is the kiosk setup code, because it
|
||||||
|
is auth rather than a filter and rides in as a securityScheme.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
REPO = os.path.dirname(HERE)
|
REPO = os.path.dirname(HERE)
|
||||||
@@ -68,23 +101,62 @@ def _norm_path(p):
|
|||||||
return normalised
|
return normalised
|
||||||
|
|
||||||
|
|
||||||
|
# The machine-readable half of an auth string is its FIRST word. Everything
|
||||||
|
# after it is prose for a human reader. An optional gate is spelled by suffixing
|
||||||
|
# that word with '-optional'.
|
||||||
|
#
|
||||||
|
# Substring matching is what this replaces, and it is why nine operations
|
||||||
|
# published `security: []` - "no credentials needed" - while their handler
|
||||||
|
# required one. 'none (single-use bootstrap token...)' starts with 'none', so
|
||||||
|
# the old sweep published the two bootstrap routes as open, and one of them
|
||||||
|
# hands out the installable agent package and spends a use. A leading token
|
||||||
|
# cannot be fooled by the prose that explains it.
|
||||||
|
AUTH_SCHEME = {
|
||||||
|
'jwt': 'bearerAuth',
|
||||||
|
'permission': 'bearerAuth',
|
||||||
|
'api-key': 'apiKeyAuth',
|
||||||
|
'setup-code': 'setupCodeAuth',
|
||||||
|
}
|
||||||
|
|
||||||
|
OPTIONAL_SUFFIX = '-optional'
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_head(auth):
|
||||||
|
"""The mechanism word at the front of an auth string, lowercased."""
|
||||||
|
match = re.match(r'[a-z-]+', (auth or '').strip().lower())
|
||||||
|
return match.group(0) if match else ''
|
||||||
|
|
||||||
|
|
||||||
def _security(auth):
|
def _security(auth):
|
||||||
"""OpenAPI security for one endpoint's documented auth string.
|
"""OpenAPI security for one endpoint's documented auth string.
|
||||||
|
|
||||||
`jwt-optional` is the interesting case, and the common one - 121 endpoints.
|
Optional auth is the interesting case. It means "works without a
|
||||||
It means "works logged out, returns more when logged in", which OpenAPI
|
credential, does more with one", which OpenAPI expresses as a list carrying
|
||||||
expresses as a list containing BOTH the empty requirement and the scheme.
|
BOTH the empty requirement and the scheme. Two shapes reach it:
|
||||||
Publishing those as bearer-required told every reader, human and machine,
|
|
||||||
that a public endpoint needs a token.
|
jwt-optional works signed out; the handler decides what shows.
|
||||||
|
api-key-optional token OR network. A kiosk admits either the per-device
|
||||||
|
token or a source address inside the site's CIDR
|
||||||
|
allowlist, so an allowlisted caller sends no
|
||||||
|
credential. Publishing that as `[]` is not a lie, but
|
||||||
|
it is not the truth either: it tells a reader there is
|
||||||
|
no token to send, and the allowlist is invisible.
|
||||||
|
Optional says both halves exist.
|
||||||
|
|
||||||
|
An unrecognised word publishes as bearer-required, which is the safe
|
||||||
|
direction to be wrong in: a reader who brings a token they did not need
|
||||||
|
loses nothing, a reader told none was needed is locked out on the day.
|
||||||
"""
|
"""
|
||||||
a = (auth or '').lower()
|
head = _auth_head(auth)
|
||||||
if 'optional' in a:
|
optional = head.endswith(OPTIONAL_SUFFIX)
|
||||||
return [{}, {'bearerAuth': []}]
|
if optional:
|
||||||
if a in ('none', 'public', '') or a.startswith('none') or a.startswith('public'):
|
head = head[:-len(OPTIONAL_SUFFIX)]
|
||||||
|
if not head or head in ('none', 'public'):
|
||||||
return []
|
return []
|
||||||
if 'api-key' in a or 'api_key' in a or 'x-api-key' in a:
|
scheme = AUTH_SCHEME.get(head, 'bearerAuth')
|
||||||
return [{'apiKeyAuth': []}]
|
if optional:
|
||||||
return [{'bearerAuth': []}]
|
return [{scheme: []}, {}]
|
||||||
|
return [{scheme: []}]
|
||||||
|
|
||||||
|
|
||||||
def _path_parameters(rawpath):
|
def _path_parameters(rawpath):
|
||||||
@@ -237,7 +309,10 @@ def build(surfaces):
|
|||||||
'`success_response` envelope: `{status, data, meta}`. Auth: Bearer '
|
'`success_response` envelope: `{status, data, meta}`. Auth: Bearer '
|
||||||
'JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; '
|
'JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; '
|
||||||
'`X-API-Key` for collector/managed-token endpoints; public endpoints '
|
'`X-API-Key` for collector/managed-token endpoints; public endpoints '
|
||||||
'need neither.'),
|
'need neither. A permission-gated route is published as '
|
||||||
|
'bearer-required, which is what a default install does - a site '
|
||||||
|
'that grants the permission to the `guest` role makes that same '
|
||||||
|
'route answer a caller with no token.'),
|
||||||
},
|
},
|
||||||
# One site's production hostname was hardcoded here, so every generated
|
# One site's production hostname was hardcoded here, so every generated
|
||||||
# spec published it to the public wiki and offered a second site a
|
# spec published it to the public wiki and offered a second site a
|
||||||
@@ -302,18 +377,478 @@ def build(surfaces):
|
|||||||
'securitySchemes': {
|
'securitySchemes': {
|
||||||
'bearerAuth': {'type': 'http', 'scheme': 'bearer', 'bearerFormat': 'JWT',
|
'bearerAuth': {'type': 'http', 'scheme': 'bearer', 'bearerFormat': 'JWT',
|
||||||
'description': 'Login token or a managed Personal Access Token (scoped).'},
|
'description': 'Login token or a managed Personal Access Token (scoped).'},
|
||||||
|
# One scheme covers every non-JWT header credential in this API,
|
||||||
|
# because the header NAME varies per surface and each operation's
|
||||||
|
# **Auth:** line says which one it wants. Publishing a scheme per
|
||||||
|
# header would be tidier and no more informative.
|
||||||
'apiKeyAuth': {'type': 'apiKey', 'in': 'header', 'name': 'X-API-Key',
|
'apiKeyAuth': {'type': 'apiKey', 'in': 'header', 'name': 'X-API-Key',
|
||||||
'description': 'Managed service token (e.g. collector.ingest, geenforce.fetch).'},
|
'description': 'A managed service or device token in a header. '
|
||||||
|
'X-API-Key for collector and managed tokens '
|
||||||
|
'(collector.ingest, geenforce.fetch); '
|
||||||
|
'X-Endpoint-Bootstrap-Token for agent bootstrap; '
|
||||||
|
'X-Kiosk-Token for a kiosk screen. The operation '
|
||||||
|
'description names the header and, where the gate '
|
||||||
|
'is token-OR-network, the allowlist that is the '
|
||||||
|
'other half.'},
|
||||||
|
'setupCodeAuth': {'type': 'apiKey', 'in': 'query', 'name': 'code',
|
||||||
|
'description': 'A single-use provisioning code, redeemed in the '
|
||||||
|
'query string because the caller is a browser being '
|
||||||
|
'pointed at a URL. Spent on any attempt, right or '
|
||||||
|
'wrong, and short-lived.'},
|
||||||
}},
|
}},
|
||||||
'tags': tags,
|
'tags': tags,
|
||||||
'paths': paths,
|
'paths': paths,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The derived half: refresh the inventory from the live url_map (--refresh)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Only the API. The frontend shell and its assets are served by this app but are
|
||||||
|
# not the contract this document describes, and tests/test_api_inventory_parity
|
||||||
|
# walks the same /api-only surface - an entry for anything else reports there as
|
||||||
|
# a documented route that is not served.
|
||||||
|
API_PREFIX = '/api'
|
||||||
|
|
||||||
|
# The auth string for a route whose gate this cannot see. Deliberately not
|
||||||
|
# 'none': an unclassified route is the one case where a guess writes a lie into
|
||||||
|
# the published contract, and a site reads that contract to decide what its
|
||||||
|
# guest role and its firewall may reach. This greps, and it renders at /api/docs.
|
||||||
|
# Hand-editing the inventory does NOT clear this: auth is derived, so the next
|
||||||
|
# --refresh writes it straight back. Teaching the sweep below is the fix, which
|
||||||
|
# is also the only way the classification survives the route moving.
|
||||||
|
NEEDS_REVIEW = ('review: no gate found on the view - teach the sweep in '
|
||||||
|
'scripts/gen_openapi.py, or gate the view')
|
||||||
|
|
||||||
|
# The jwt-optional wording, in one place because three branches reach it.
|
||||||
|
JWT_OPTIONAL = 'jwt-optional (works signed out; the handler decides what shows)'
|
||||||
|
|
||||||
|
# Decorators that ARE the gate. require_permission and require_role are not
|
||||||
|
# here: they stamp _requires_permission onto the wrapper, which is read first
|
||||||
|
# and carries the permission name itself.
|
||||||
|
DECORATOR_AUTH = {
|
||||||
|
'require_api_key': 'api-key (per-plugin collector token)',
|
||||||
|
'require_api_key_legacy': 'api-key (collector token, legacy shared key)',
|
||||||
|
'require_fetch_token': 'api-key (geenforce fetch token, or the IP allowlist)',
|
||||||
|
'require_report_token': 'api-key (geenforce report token)',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gates that live INSIDE the handler, found by the call the handler makes. An
|
||||||
|
# agent, a kiosk screen and a bootstrapping PC each authenticate on something
|
||||||
|
# that is not a JWT, so none of them can be gated by a decorator - and from
|
||||||
|
# outside the view, an inline gate and no gate at all look exactly alike. That
|
||||||
|
# is how 165 GET routes came to answer anybody.
|
||||||
|
HANDLER_AUTH = (
|
||||||
|
('_agent_auth_or_error', 'api-key (endpoint agent credential, or the fleet '
|
||||||
|
'key while sharedkeyallowed is on)'),
|
||||||
|
('authenticate_agent', 'api-key (endpoint agent credential, or the fleet '
|
||||||
|
'key while sharedkeyallowed is on)'),
|
||||||
|
('collector_ingest', 'api-key (endpoints collector token)'),
|
||||||
|
('_check_collector_auth', 'api-key (per-plugin collector token)'),
|
||||||
|
('collector_authorized', 'api-key (per-plugin collector token)'),
|
||||||
|
# Token OR network, either sufficient, so neither 'none' nor 'api-key' is
|
||||||
|
# the truth on its own: an allowlisted PC sends no credential at all, and a
|
||||||
|
# screen by the cabinet sends the device token from anywhere. 'none' was
|
||||||
|
# published here, which told a reader there is no token to send and left
|
||||||
|
# the allowlist invisible. Optional says both halves exist.
|
||||||
|
('_kiosk_open', 'api-key-optional (kiosk: the per-device token in the '
|
||||||
|
'X-Kiosk-Token header, OR a caller address inside the '
|
||||||
|
"site's kiosk CIDR allowlist - an allowlisted caller sends "
|
||||||
|
"no credential; 404 while the site's kiosk flag is off)"),
|
||||||
|
# REQUIRED, not 'none'. The handler reads the token and 404s without it,
|
||||||
|
# and /bootstrap/package hands out the installable agent package and spends
|
||||||
|
# a use doing it.
|
||||||
|
('_presented_token', 'api-key (single-use bootstrap token in the '
|
||||||
|
'X-Endpoint-Bootstrap-Token header, or ?t= while that '
|
||||||
|
'is enabled; per-IP rate limited, 404 when not usable)'),
|
||||||
|
# The code IS the credential: unguessable, spent on any attempt, and gone in
|
||||||
|
# minutes. No header and no session, because the caller is a browser being
|
||||||
|
# pointed at a URL by an administrator.
|
||||||
|
('_redeem_kiosk_setup_code', 'setup-code (single-use kiosk setup code in '
|
||||||
|
'?code=, issued by the permission-gated '
|
||||||
|
'provisioning link; spent on any attempt, 410 '
|
||||||
|
'once used or expired)'),
|
||||||
|
('_bootstrap_allowed', 'none (first run only: refused once an admin exists)'),
|
||||||
|
('verify_jwt_in_request', 'jwt'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Routes whose gate is inline AND conditional, where the call sweep above would
|
||||||
|
# name the wrong thing. One line each, and the line is the point: these are the
|
||||||
|
# handlers where "what authenticates this?" has an answer no sweep can derive.
|
||||||
|
INLINE_AUTH = {
|
||||||
|
# The key may be absent, or invalid, and the call still proceeds: a PC
|
||||||
|
# installed without the shared key must still be able to reach an admin. A
|
||||||
|
# valid key only decides whether auto-enroll may issue on a clean match.
|
||||||
|
'endpoints.enroll_endpoint': 'none (per-IP rate limited and capped; a valid '
|
||||||
|
'fleet key only decides auto-enroll)',
|
||||||
|
# The unguessable token in the path IS the authorisation - the recipient of
|
||||||
|
# a secure send has no account here.
|
||||||
|
'tools.peek_secret': 'none (the unguessable token in the path is the '
|
||||||
|
'authorisation)',
|
||||||
|
'tools.redeem_secret': 'none (the unguessable token in the path is the '
|
||||||
|
'authorisation; redeeming spends the secret)',
|
||||||
|
# This IS the auth surface, so a token cannot gate it. The bounds it carries
|
||||||
|
# instead are a per-IP rate limit and the 5-strike account lockout.
|
||||||
|
'auth.login': 'none (the login surface itself; rate limited, 5-strike lockout)',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verbs that carry a body, for the seeded params line.
|
||||||
|
BODY_VERBS = ('POST', 'PUT', 'PATCH')
|
||||||
|
|
||||||
|
|
||||||
|
def _literal_set(filename, name):
|
||||||
|
"""One module-level set literal, read without importing the module.
|
||||||
|
|
||||||
|
Imported, tests/test_core/test_authz.py builds a second app at import time,
|
||||||
|
which rebinds the SQLAlchemy extension out from under the one this script
|
||||||
|
is walking.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
try:
|
||||||
|
with open(filename, 'r', encoding='utf-8') as handle:
|
||||||
|
tree = ast.parse(handle.read())
|
||||||
|
except (OSError, SyntaxError):
|
||||||
|
return set()
|
||||||
|
for node in tree.body:
|
||||||
|
if not isinstance(node, ast.Assign):
|
||||||
|
continue
|
||||||
|
for target in node.targets:
|
||||||
|
if isinstance(target, ast.Name) and target.id == name:
|
||||||
|
try:
|
||||||
|
return set(ast.literal_eval(node.value))
|
||||||
|
except ValueError:
|
||||||
|
return set()
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _reviewed_exceptions():
|
||||||
|
"""(endpoints, blueprints) already reviewed as needing no permission.
|
||||||
|
|
||||||
|
Read from the two sweeps in tests/ rather than restated here, so the
|
||||||
|
published contract cannot disagree with the gate that enforces it. A route
|
||||||
|
on one of those lists was argued about in front of somebody, in a file whose
|
||||||
|
whole job is to make that argument visible; a route on neither, with no
|
||||||
|
gate, is marked for review instead. That is the difference between a
|
||||||
|
decision and an oversight, which is what those lists exist to keep.
|
||||||
|
"""
|
||||||
|
tests = os.path.join(REPO, 'tests', 'test_core')
|
||||||
|
reads = os.path.join(tests, 'test_public_read_surface.py')
|
||||||
|
writes = os.path.join(tests, 'test_authz.py')
|
||||||
|
endpoints = (_literal_set(reads, 'PUBLIC_ENDPOINTS')
|
||||||
|
| _literal_set(writes, 'EXEMPT_ENDPOINTS'))
|
||||||
|
return endpoints, _literal_set(writes, 'EXEMPT_BLUEPRINTS')
|
||||||
|
|
||||||
|
|
||||||
|
def _source_index(filename, _cache={}):
|
||||||
|
"""{function name: (decorator sources, call sources)} for one file.
|
||||||
|
|
||||||
|
Read from the source, not from the live object: functools.wraps copies the
|
||||||
|
name and the docstring onto the wrapper and nothing else, so by the time a
|
||||||
|
view is in url_map its decorators are invisible. _requires_permission
|
||||||
|
survives only because require_permission deliberately stamps it on.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
if filename in _cache:
|
||||||
|
return _cache[filename]
|
||||||
|
index = {}
|
||||||
|
try:
|
||||||
|
with open(filename, 'r', encoding='utf-8') as handle:
|
||||||
|
tree = ast.parse(handle.read())
|
||||||
|
except (OSError, SyntaxError):
|
||||||
|
_cache[filename] = index
|
||||||
|
return index
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
continue
|
||||||
|
index[node.name] = (
|
||||||
|
[ast.unparse(item) for item in node.decorator_list],
|
||||||
|
[ast.unparse(item) for item in ast.walk(node)
|
||||||
|
if isinstance(item, ast.Call)],
|
||||||
|
)
|
||||||
|
_cache[filename] = index
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _view_source(view):
|
||||||
|
"""(decorators, calls) for the undecorated view behind a url_map entry."""
|
||||||
|
import inspect
|
||||||
|
original = inspect.unwrap(view)
|
||||||
|
filename = inspect.getsourcefile(original) or ''
|
||||||
|
return _source_index(filename).get(original.__name__, ([], []))
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_for(view, endpoint, reviewed, blueprints):
|
||||||
|
"""The honest auth string for one route, derived in gate order.
|
||||||
|
|
||||||
|
Nothing here guesses. Every branch names a mechanism that is in the code - a
|
||||||
|
marker the decorator stamped on, a decorator in the source, a call the
|
||||||
|
handler makes, or a name on a reviewed exemption list - and a route that
|
||||||
|
matches none of them says exactly that.
|
||||||
|
"""
|
||||||
|
marker = getattr(view, '_requires_permission', None)
|
||||||
|
if marker:
|
||||||
|
roles = [name.split(':', 1)[1] for name in marker
|
||||||
|
if name.startswith('role:')]
|
||||||
|
if roles:
|
||||||
|
return 'jwt + role:' + ' or '.join(sorted(roles))
|
||||||
|
return 'permission:' + ' or '.join(marker)
|
||||||
|
|
||||||
|
decorators, calls = _view_source(view)
|
||||||
|
for text in decorators:
|
||||||
|
name = text.split('(', 1)[0].strip().split('.')[-1]
|
||||||
|
if name in DECORATOR_AUTH:
|
||||||
|
return DECORATOR_AUTH[name]
|
||||||
|
if name == 'jwt_required':
|
||||||
|
if 'refresh=True' in text:
|
||||||
|
return 'jwt (refresh token)'
|
||||||
|
return JWT_OPTIONAL if 'optional=True' in text else 'jwt'
|
||||||
|
|
||||||
|
if endpoint in INLINE_AUTH:
|
||||||
|
return INLINE_AUTH[endpoint]
|
||||||
|
|
||||||
|
for call in calls:
|
||||||
|
tail = call.split('(', 1)[0].split('.')[-1]
|
||||||
|
for needle, auth in HANDLER_AUTH:
|
||||||
|
if tail != needle:
|
||||||
|
continue
|
||||||
|
# Optional verification is a read that works signed out, not a gate.
|
||||||
|
# Only the required form authenticates anybody.
|
||||||
|
if needle == 'verify_jwt_in_request' and 'optional' in call:
|
||||||
|
return JWT_OPTIONAL
|
||||||
|
return auth
|
||||||
|
|
||||||
|
blueprint, _, shortname = endpoint.partition('.')
|
||||||
|
if endpoint in reviewed or shortname in reviewed or blueprint in blueprints:
|
||||||
|
return 'none (reviewed: named in the public-read or mutation sweep)'
|
||||||
|
return NEEDS_REVIEW
|
||||||
|
|
||||||
|
|
||||||
|
def _surface_for(view, endpoint):
|
||||||
|
"""The tag this route is grouped under: one surface per plugin, and one per
|
||||||
|
core blueprint.
|
||||||
|
|
||||||
|
Derived, not preserved: a route that moves into a plugin moves tag with it,
|
||||||
|
which is the whole reason the reader is looking at the tag.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
module = getattr(inspect.unwrap(view), '__module__', '') or ''
|
||||||
|
if module.startswith('plugins.'):
|
||||||
|
return 'plugin-' + module.split('.')[1]
|
||||||
|
return 'core-' + endpoint.partition('.')[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_purpose(view, endpoint):
|
||||||
|
"""First paragraph of the view's docstring, as a starting point.
|
||||||
|
|
||||||
|
A docstring says what the handler does; the inventory wants what the
|
||||||
|
endpoint is FOR, which is usually more. This seeds the entry so a new route
|
||||||
|
is never published blank, and the prose is preserved from then on.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
doc = inspect.getdoc(inspect.unwrap(view)) or ''
|
||||||
|
first = ' '.join(doc.split('\n\n')[0].split())
|
||||||
|
return first or 'UNDOCUMENTED: %s has no docstring.' % endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def _path_arguments(rule):
|
||||||
|
"""[(name, converter)] for one rule, in the order they appear."""
|
||||||
|
return [(name, converter or 'string') for converter, name
|
||||||
|
in re.findall(r'<(?:([^:>]+):)?([^>]+)>', rule)]
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_params(rule, methods):
|
||||||
|
parts = []
|
||||||
|
arguments = _path_arguments(rule)
|
||||||
|
if arguments:
|
||||||
|
parts.append('path: ' + ', '.join(
|
||||||
|
'%s (%s)' % (name, converter) for name, converter in arguments))
|
||||||
|
if any(method in BODY_VERBS for method in methods):
|
||||||
|
parts.append('body: JSON (fields not yet documented)')
|
||||||
|
# Path arguments are in the rule; query arguments are read out of the
|
||||||
|
# handler and cannot be derived, so an entry that has neither says it does
|
||||||
|
# not know rather than claiming the route takes nothing.
|
||||||
|
return '; '.join(parts) if parts else 'not documented'
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_example(rule, methods, auth):
|
||||||
|
"""A curl that is the right SHAPE for this route: its verb, a filled path
|
||||||
|
and the header its auth actually wants.
|
||||||
|
|
||||||
|
Placeholder ids, because the generator has no database - but a reader who
|
||||||
|
substitutes one gets a call that works, which is more than a blank field
|
||||||
|
gives them.
|
||||||
|
"""
|
||||||
|
def fill(match):
|
||||||
|
converter = (match.group(1) or '').split('(')[0]
|
||||||
|
if converter.startswith(('int', 'float')):
|
||||||
|
return '1'
|
||||||
|
return 'example.png' if converter == 'path' else 'x'
|
||||||
|
|
||||||
|
url = 'http://localhost:5001' + re.sub(r'<(?:([^:>]+):)?[^>]+>', fill, rule)
|
||||||
|
method = sorted(methods)[0] if methods else 'GET'
|
||||||
|
command = 'curl' if method == 'GET' else 'curl -X ' + method
|
||||||
|
header = ''
|
||||||
|
if auth.startswith(('permission:', 'jwt')):
|
||||||
|
header = ' -H "Authorization: Bearer $TOK"'
|
||||||
|
elif auth.startswith('api-key'):
|
||||||
|
# The header name varies per surface (X-API-Key, X-Kiosk-Token,
|
||||||
|
# X-Endpoint-Bootstrap-Token), and the auth string names the one this
|
||||||
|
# route wants. A pasted example with the wrong header is worse than
|
||||||
|
# none: it looks like it should work.
|
||||||
|
named = re.search(r'\bX-[A-Za-z0-9-]+\b', auth)
|
||||||
|
header = ' -H "%s: $KEY"' % (named.group(0) if named else 'X-API-Key')
|
||||||
|
elif auth.startswith('setup-code'):
|
||||||
|
url += '?code=$CODE'
|
||||||
|
body = ''
|
||||||
|
if any(verb in methods for verb in BODY_VERBS):
|
||||||
|
body = " -H 'Content-Type: application/json' -d '{}'"
|
||||||
|
return '%s %s%s%s' % (command, url, header, body)
|
||||||
|
|
||||||
|
|
||||||
|
def _served_routes(app):
|
||||||
|
"""Every /api rule, as (path, sorted methods, endpoint, view).
|
||||||
|
|
||||||
|
HEAD and OPTIONS are Werkzeug's, not the product's. A rule with several
|
||||||
|
verbs stays one entry, written 'PUT|PATCH', which is how the inventory has
|
||||||
|
always spelled it and what the parity test splits on.
|
||||||
|
"""
|
||||||
|
routes = []
|
||||||
|
for rule in app.url_map.iter_rules():
|
||||||
|
if rule.endpoint == 'static' or not rule.rule.startswith(API_PREFIX):
|
||||||
|
continue
|
||||||
|
methods = sorted((rule.methods or set()) - {'HEAD', 'OPTIONS'})
|
||||||
|
if not methods:
|
||||||
|
continue
|
||||||
|
routes.append((rule.rule, methods, rule.endpoint,
|
||||||
|
app.view_functions[rule.endpoint]))
|
||||||
|
return sorted(routes, key=lambda row: (row[0], row[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_prose():
|
||||||
|
"""{(path, METHOD): entry} for whatever the inventory already says.
|
||||||
|
|
||||||
|
Keyed per method rather than per entry so prose survives a route that gains
|
||||||
|
or loses a verb: 'PUT' still finds the words written when the entry said
|
||||||
|
'PUT|PATCH'.
|
||||||
|
"""
|
||||||
|
prose = {}
|
||||||
|
if not os.path.isfile(INVENTORY):
|
||||||
|
return prose
|
||||||
|
with open(INVENTORY, 'r', encoding='utf-8') as handle:
|
||||||
|
surfaces = json.load(handle)
|
||||||
|
for surface in surfaces:
|
||||||
|
for entry in surface.get('endpoints', []):
|
||||||
|
path = entry.get('path') or ''
|
||||||
|
for method in re.split(r'[/,|]', entry.get('method') or 'GET'):
|
||||||
|
method = method.strip().upper()
|
||||||
|
if method:
|
||||||
|
prose.setdefault((path, method), entry)
|
||||||
|
return prose
|
||||||
|
|
||||||
|
|
||||||
|
def _inventory_app():
|
||||||
|
"""The app, with every bundled plugin enabled.
|
||||||
|
|
||||||
|
The inventory describes the PRODUCT, not one site's switched-on subset: a
|
||||||
|
plugin disabled on the machine that happens to run this would drop its whole
|
||||||
|
surface out of the published contract without a word, and the next refresh
|
||||||
|
somewhere else would put it back. So a disabled plugin is refused here
|
||||||
|
rather than quietly omitted.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
# .env BEFORE create_app, not after: config is read while the app is built,
|
||||||
|
# so a load underneath it changes nothing. A plain `python scripts/...` run
|
||||||
|
# gets neither wsgi.py's load nor the flask CLI's, and falls back to the
|
||||||
|
# hardcoded root@localhost URL - which surfaces as a bare 1045 access-denied
|
||||||
|
# that says nothing about the real cause.
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(REPO, '.env'))
|
||||||
|
os.environ.setdefault('FLASK_ENV', 'testing')
|
||||||
|
if REPO not in sys.path:
|
||||||
|
sys.path.insert(0, REPO)
|
||||||
|
from shopdb import create_app
|
||||||
|
app = create_app('testing')
|
||||||
|
plugins = os.path.join(REPO, 'plugins')
|
||||||
|
bundled = sorted(
|
||||||
|
name for name in os.listdir(plugins)
|
||||||
|
if os.path.isfile(os.path.join(plugins, name, 'manifest.json')))
|
||||||
|
manager = app.extensions.get('plugin_manager')
|
||||||
|
off = [name for name in bundled
|
||||||
|
if not (manager and manager.registry.is_enabled(name))]
|
||||||
|
if off:
|
||||||
|
raise SystemExit(
|
||||||
|
'these bundled plugins are disabled, so their routes would be '
|
||||||
|
'missing\nfrom the inventory: %s\nenable them and re-run:\n'
|
||||||
|
' FLASK_APP=shopdb venv/bin/flask plugins enable %s'
|
||||||
|
% (', '.join(off), off[0]))
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_inventory():
|
||||||
|
"""Rewrite the inventory's derived half from the routes the app serves.
|
||||||
|
|
||||||
|
Structure (surface, method, path, auth) is replaced outright. Prose
|
||||||
|
(purpose, params, example) is carried over untouched wherever an entry for
|
||||||
|
that route already exists, and seeded from the view's docstring where one
|
||||||
|
does not.
|
||||||
|
"""
|
||||||
|
app = _inventory_app()
|
||||||
|
reviewed, blueprints = _reviewed_exceptions()
|
||||||
|
prose = _existing_prose()
|
||||||
|
|
||||||
|
surfaces = {}
|
||||||
|
seeded = []
|
||||||
|
unclassified = []
|
||||||
|
for path, methods, endpoint, view in _served_routes(app):
|
||||||
|
auth = _auth_for(view, endpoint, reviewed, blueprints)
|
||||||
|
if auth == NEEDS_REVIEW:
|
||||||
|
unclassified.append('%s %s (%s)' % ('|'.join(methods), path, endpoint))
|
||||||
|
known = prose.get((path, methods[0]))
|
||||||
|
if known is None:
|
||||||
|
seeded.append('%s %s' % ('|'.join(methods), path))
|
||||||
|
entry = dict(known or {})
|
||||||
|
entry.update({
|
||||||
|
'method': '|'.join(methods),
|
||||||
|
'path': path,
|
||||||
|
'auth': auth,
|
||||||
|
'params': entry.get('params') or _seed_params(path, methods),
|
||||||
|
'purpose': entry.get('purpose') or _seed_purpose(view, endpoint),
|
||||||
|
'example': entry.get('example') or _seed_example(path, methods, auth),
|
||||||
|
})
|
||||||
|
# Fixed key order, so a refresh that changes nothing changes no bytes.
|
||||||
|
ordered = {key: entry[key] for key in
|
||||||
|
('method', 'path', 'auth', 'params', 'purpose', 'example')}
|
||||||
|
ordered.update({key: value for key, value in entry.items()
|
||||||
|
if key not in ordered})
|
||||||
|
surfaces.setdefault(_surface_for(view, endpoint), []).append(ordered)
|
||||||
|
|
||||||
|
document = [{'surface': name, 'endpoints': entries}
|
||||||
|
for name, entries in sorted(surfaces.items())]
|
||||||
|
with open(INVENTORY, 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump(document, handle, indent=2)
|
||||||
|
handle.write('\n')
|
||||||
|
|
||||||
|
total = sum(len(surface['endpoints']) for surface in document)
|
||||||
|
print('refreshed %s: %d entries in %d surfaces (%d new)'
|
||||||
|
% (INVENTORY, total, len(document), len(seeded)))
|
||||||
|
for line in seeded:
|
||||||
|
print(' seeded %s' % line)
|
||||||
|
if unclassified:
|
||||||
|
print('%d route(s) have no gate this can see. Classify each one by hand '
|
||||||
|
'in the inventory, or gate it:' % len(unclassified))
|
||||||
|
for line in unclassified:
|
||||||
|
print(' review %s' % line)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
surfaces = json.load(open(INVENTORY))
|
if '--refresh' in sys.argv[1:]:
|
||||||
|
refresh_inventory()
|
||||||
|
with open(INVENTORY, 'r', encoding='utf-8') as handle:
|
||||||
|
surfaces = json.load(handle)
|
||||||
spec = build(surfaces)
|
spec = build(surfaces)
|
||||||
json.dump(spec, open(OUT, 'w'), indent=1)
|
with open(OUT, 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump(spec, handle, indent=1)
|
||||||
ops = sum(len(v) for v in spec['paths'].values())
|
ops = sum(len(v) for v in spec['paths'].values())
|
||||||
print('wrote %s: %d paths, %d operations from %d surfaces'
|
print('wrote %s: %d paths, %d operations from %d surfaces'
|
||||||
% (OUT, len(spec['paths']), ops, len(surfaces)))
|
% (OUT, len(spec['paths']), ops, len(surfaces)))
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
"""Every route the app serves is described in docs/api-inventory.json.
|
"""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 -
|
The inventory has two halves. Its STRUCTURE - surface, method, path and the auth
|
||||||
what each endpoint is FOR, what it accepts, a worked example - and none of that
|
each route actually enforces - is derived from the live url_map and the
|
||||||
can be derived from the code. An audit of all 372 documented operations found
|
decorators on each view by `gen_openapi.py --refresh`. Its PROSE - what an
|
||||||
zero phantom routes and zero wrong parameter names, so the maintenance itself is
|
endpoint is FOR, what it accepts, a worked example - is human, and a refresh
|
||||||
in good order.
|
never overwrites words that are already there.
|
||||||
|
|
||||||
What it cannot do is notice a route somebody added. Twenty-two were serving
|
This module is the coverage half of that, and it is the half a generator cannot
|
||||||
traffic undocumented, including an entire plugin's surface and every one of the
|
do for you: a refresh describes whatever is served at the moment it runs, so
|
||||||
dashboard card endpoints - each of them added after its surface was written.
|
without a gate the file rots the moment the next route lands.
|
||||||
|
|
||||||
So the prose stays hand-written and the COVERAGE is enforced here. Adding an
|
It had. On this branch the inventory was not tracked at all, so the skipif below
|
||||||
endpoint now means describing it, in the same commit, which is when the author
|
fired, this module reported green while checking nothing, and 24 routes went to
|
||||||
still knows what it is for.
|
production documented nowhere - including the whole endpoint-agent delivery
|
||||||
|
surface and both kiosks. scripts/check-gates-ran.sh now asserts this module RAN,
|
||||||
|
in every checkout rather than only in one with wiki prose in it.
|
||||||
|
|
||||||
|
So: add a route, run `gen_openapi.py --refresh`, and replace the seeded purpose
|
||||||
|
with the reason the route exists - which is while the author still knows it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -93,3 +99,87 @@ def test_nothing_documented_has_been_removed():
|
|||||||
assert not stale, (
|
assert not stale, (
|
||||||
'%d documented route(s) are no longer served:\n %s'
|
'%d documented route(s) are no longer served:\n %s'
|
||||||
% (len(stale), '\n '.join('%s %s' % row for row in stale)))
|
% (len(stale), '\n '.join('%s %s' % row for row in stale)))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The other half: the auth string, which nothing gated until now
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The two tests above check that a route EXISTS in both directions. Nothing
|
||||||
|
# checked what the entry SAYS about it, so a wrong or stale auth string passed
|
||||||
|
# every gate in the repo - and auth is the field a site reads to decide what its
|
||||||
|
# guest role and its firewall may reach, and the field the published spec turns
|
||||||
|
# into a machine-readable security requirement.
|
||||||
|
#
|
||||||
|
# It had rotted exactly that way: nine operations documented as needing no
|
||||||
|
# credential while the handler required one, and a kiosk route documented as
|
||||||
|
# ungated when its gate is a single-use setup code. Both survived a refresh,
|
||||||
|
# because a refresh only rewrites; nothing compared.
|
||||||
|
#
|
||||||
|
# So this re-derives auth from the live app with the generator's own classifier
|
||||||
|
# and fails on any disagreement with the committed file. The derivation is
|
||||||
|
# authoritative: hand-editing an auth string in the inventory does not survive
|
||||||
|
# the next --refresh, so the fix for a wrong one is always to teach the sweep in
|
||||||
|
# scripts/gen_openapi.py, or to gate the view.
|
||||||
|
|
||||||
|
|
||||||
|
def _generator():
|
||||||
|
"""scripts/gen_openapi.py, loaded by path.
|
||||||
|
|
||||||
|
It is a script, not a package module, so there is nothing to import. Loading
|
||||||
|
it runs its imports and nothing else - it builds no app until it is asked
|
||||||
|
to.
|
||||||
|
"""
|
||||||
|
path = REPO / 'scripts' / 'gen_openapi.py'
|
||||||
|
spec = importlib.util.spec_from_file_location('gen_openapi', path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def derived_auth():
|
||||||
|
"""{(METHOD, path): auth} as the classifier reads the code right now."""
|
||||||
|
generator = _generator()
|
||||||
|
app = create_app('testing')
|
||||||
|
reviewed, blueprints = generator._reviewed_exceptions()
|
||||||
|
derived = {}
|
||||||
|
for rule, methods, endpoint, view in generator._served_routes(app):
|
||||||
|
auth = generator._auth_for(view, endpoint, reviewed, blueprints)
|
||||||
|
for method in methods:
|
||||||
|
derived[(method, normalise(rule))] = auth
|
||||||
|
return derived
|
||||||
|
|
||||||
|
|
||||||
|
def documented_auth():
|
||||||
|
"""{(METHOD, path): auth} as docs/api-inventory.json has it committed."""
|
||||||
|
documented = {}
|
||||||
|
for surface in json.loads(INVENTORY.read_text()):
|
||||||
|
for entry in surface.get('endpoints', []):
|
||||||
|
path = normalise(entry.get('path') or '')
|
||||||
|
for method in re.split(r'[/,|]', entry.get('method') or 'GET'):
|
||||||
|
method = method.strip().upper()
|
||||||
|
if method:
|
||||||
|
documented[(method, path)] = entry.get('auth') or ''
|
||||||
|
return documented
|
||||||
|
|
||||||
|
|
||||||
|
def test_documented_auth_is_what_the_code_enforces():
|
||||||
|
"""A stale auth string is a lie about who may call a route.
|
||||||
|
|
||||||
|
Only routes present on both sides are compared. A route missing from either
|
||||||
|
is a coverage fault, and the two tests above already name it; reporting it
|
||||||
|
twice here would bury this one.
|
||||||
|
"""
|
||||||
|
derived = derived_auth()
|
||||||
|
documented = documented_auth()
|
||||||
|
disagreements = []
|
||||||
|
for key in sorted(set(derived) & set(documented)):
|
||||||
|
if derived[key] != documented[key]:
|
||||||
|
disagreements.append(
|
||||||
|
'%s %s\n documented: %s\n enforced: %s'
|
||||||
|
% (key[0], key[1], documented[key] or '(blank)', derived[key]))
|
||||||
|
assert not disagreements, (
|
||||||
|
'%d route(s) document an auth that is not what the code enforces. The '
|
||||||
|
'inventory is derived, so fix the code or teach the classifier in '
|
||||||
|
'scripts/gen_openapi.py, then re-run it with --refresh:\n %s'
|
||||||
|
% (len(disagreements), '\n '.join(disagreements[:15])))
|
||||||
|
|||||||
@@ -72,15 +72,83 @@ def test_write_verbs_can_carry_a_body(spec):
|
|||||||
'from this spec cannot send one:\n ' + '\n '.join(offenders[:15]))
|
'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):
|
def test_optional_auth_is_expressed_as_optional(spec):
|
||||||
"""An endpoint that works logged out must not be published as requiring a
|
"""A route whose auth is optional is published as optional, either way round.
|
||||||
token - that is a lie to every reader, and it is the majority case here."""
|
|
||||||
optional = [operation for _, _, operation in operations(spec)
|
WHY THE COUNT IS GONE. This asserted `len(optional) > 50`, on the premise
|
||||||
if any(item == {} for item in operation.get('security') or [])]
|
that optional auth was the majority case here - it said so in as many
|
||||||
assert len(optional) > 50, (
|
words. It is not any more. The guest role and the permission model moved
|
||||||
'The inventory documents over a hundred jwt-optional endpoints; the '
|
the read surface to `permission:*`, which publishes as bearer-required and
|
||||||
'spec should express them with an empty security requirement '
|
is correct, and the optional count fell to 14. The assertion went red
|
||||||
'alongside the scheme, not as bearer-required.')
|
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):
|
def test_an_endpoint_that_needs_no_token_cannot_fail_with_401(spec):
|
||||||
|
|||||||
Reference in New Issue
Block a user