fix(web): une erreur sur un point JSON ne renvoie plus une page HTML
STD-09, trouve en recroisant l'audit anterieur -- celui mene sur le miroir GitHub, jamais repasse depuis qu'on a decouvert que ce n'etait pas la bonne source. Sept gestionnaires d'erreur portaient chacun leur copie d'une liste de prefixes d'URL decidant "JSON ou page HTML". Les copies avaient derive -- trois testaient /users/coach-availability, quatre non -- et toutes manquaient les memes points. Un fetch() qui recoit une page d'erreur HTML leve en la parsant : sur le calendrier, les listes de selections et d'equipes restaient vides, sans message dans la page et sans rien dans le journal. Deux choses apprises en ecrivant le test, aucune n'etait dans le constat. L'approche par prefixe ne pouvait pas etre reparee. Trois des seize vues JSON sont a des chemins qu'aucun prefixe ne distingue des pages HTML voisines -- /matches/<id>/toggle-presence/<id> et ses deux cousins, que les gabarits appellent justement en fetch(). Les vues se declarent donc elles-memes (@json_endpoint, app/api.py), et un test parcourt la carte des URL pour verifier qu'aucune vue appelant jsonify n'a ete oubliee. Et surtout : @login_required n'atteint jamais le gestionnaire 401. Flask-Login intercepte avant et redirige. Les seize points JSON repondaient donc a une session expiree par une 302 vers un formulaire HTML, quoi que dise la liste de prefixes. Reecrire la liste seule aurait eu l'air d'un correctif sans rien changer. Au passage, le message flash de ce gestionnaire etait la seule chaine de l'application qui n'avait jamais ete traduite. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""What a JSON endpoint answers when it fails.
|
||||
|
||||
STD-09. The seven error handlers each carried their own copy of a list of
|
||||
URL prefixes deciding "JSON or HTML page". The copies had drifted — three
|
||||
checked `/users/coach-availability`, four did not — and every one of them
|
||||
was missing the same five endpoints, the ones under `/matches/api/` and
|
||||
`/team-matches/api/`.
|
||||
|
||||
The failure was invisible from the server. A `fetch()` that receives an HTML
|
||||
error page throws while parsing it, so on the calendar the tryout and team
|
||||
selects simply stayed empty: no message on the page, nothing in the log, and
|
||||
the only trace a SyntaxError in a console nobody had open. An expired
|
||||
session produced exactly this, because the 401 handler answers a browser
|
||||
with a redirect to an HTML login form.
|
||||
|
||||
The first test here is the one that matters: it derives the list of JSON
|
||||
views from the code rather than from a second hand-written list, because a
|
||||
hand-written list is what failed. Writing it is what showed that the prefix
|
||||
approach could not be repaired — three of the sixteen JSON views sit at
|
||||
paths (`/matches/<id>/toggle-presence/<id>`) that no prefix can single out
|
||||
from the HTML pages beside them. Views mark themselves now (`app/api.py`).
|
||||
|
||||
It also showed the deeper half. `@login_required` never reaches Flask's 401
|
||||
handler: Flask-Login intercepts first and redirects. So every JSON endpoint
|
||||
answered an expired session with an HTML login page no matter what the
|
||||
prefix list said, and rewriting the list alone would have looked like a fix
|
||||
while changing nothing.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
ROUTES_DIR = pathlib.Path(__file__).resolve().parent.parent / 'app' / 'routes'
|
||||
|
||||
|
||||
def _view_functions_returning_json():
|
||||
"""Every route handler whose body calls jsonify(), found by AST.
|
||||
|
||||
Returns:
|
||||
set[str]: Function names. Matching them back to URL rules is the
|
||||
caller's job — the URL map knows the endpoint names.
|
||||
"""
|
||||
found = set()
|
||||
for path in ROUTES_DIR.rglob('*.py'):
|
||||
tree = ast.parse(path.read_text(encoding='utf-8'))
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
decorated = any(
|
||||
isinstance(dec, ast.Call)
|
||||
and isinstance(dec.func, ast.Attribute)
|
||||
and dec.func.attr == 'route'
|
||||
for dec in node.decorator_list
|
||||
)
|
||||
if not decorated:
|
||||
continue
|
||||
for inner in ast.walk(node):
|
||||
if (
|
||||
isinstance(inner, ast.Call)
|
||||
and isinstance(inner.func, ast.Name)
|
||||
and inner.func.id == 'jsonify'
|
||||
):
|
||||
found.add(node.name)
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
class TestEveryJsonViewIsCovered:
|
||||
def test_the_scan_finds_something(self):
|
||||
"""Premise. An AST walk that matches nothing would make the test
|
||||
below pass against any prefix list at all, including an empty one."""
|
||||
assert len(_view_functions_returning_json()) >= 5
|
||||
|
||||
def test_every_json_view_carries_the_mark(self, app):
|
||||
"""The regression. Derived from the code, not from a second list.
|
||||
|
||||
A view that calls jsonify and forgets `@json_endpoint` fails here
|
||||
rather than in a browser, silently, months later.
|
||||
"""
|
||||
json_views = _view_functions_returning_json()
|
||||
|
||||
unmarked = []
|
||||
for rule in app.url_map.iter_rules():
|
||||
if rule.endpoint.rsplit('.', 1)[-1] not in json_views:
|
||||
continue
|
||||
view = app.view_functions[rule.endpoint]
|
||||
if not getattr(view, 'returns_json', False):
|
||||
unmarked.append(f'{rule.endpoint} → {rule}')
|
||||
|
||||
assert unmarked == [], (
|
||||
'these views answer with jsonify but are not marked @json_endpoint, '
|
||||
'so their errors would be an HTML page a fetch() cannot parse: '
|
||||
+ ', '.join(sorted(unmarked))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'path',
|
||||
[
|
||||
'/matches/api/events',
|
||||
'/matches/api/manageable-tryouts',
|
||||
'/team-matches/api/manageable-teams',
|
||||
'/users/disponibilities',
|
||||
'/users/api/nothing-here',
|
||||
],
|
||||
)
|
||||
class TestTheApiPathsThemselves:
|
||||
def test_an_unauthenticated_call_is_answered_in_json(self, client, path):
|
||||
"""These are exactly the calls the calendar and the availability
|
||||
pages make. Before, an expired session answered every one of them
|
||||
with a 302 to an HTML login page."""
|
||||
response = client.get(path, follow_redirects=False)
|
||||
|
||||
assert response.status_code != 200
|
||||
assert response.is_json, (
|
||||
f'{path} answered {response.content_type}; a fetch() would throw '
|
||||
f'while parsing it and the page would fail silently'
|
||||
)
|
||||
assert 'error' in response.get_json()
|
||||
|
||||
|
||||
class TestHtmlPagesAreUnaffected:
|
||||
def test_a_missing_page_still_renders_html(self, client):
|
||||
response = client.get('/no-such-page')
|
||||
|
||||
assert response.status_code == 404
|
||||
assert 'text/html' in response.content_type
|
||||
|
||||
def test_a_page_needing_login_still_redirects(self, client):
|
||||
"""The behaviour a browser needs, and the one worth not breaking
|
||||
while fixing the JSON side."""
|
||||
response = client.get('/users/profile', follow_redirects=False)
|
||||
|
||||
assert response.status_code in (301, 302)
|
||||
assert '/auth/login' in response.headers['Location']
|
||||
|
||||
|
||||
class TestExplicitContentNegotiation:
|
||||
def test_a_caller_that_asks_for_json_gets_json(self, client):
|
||||
"""Belt and braces for a path the prefix list does not know about."""
|
||||
response = client.get('/no-such-page', headers={'Accept': 'application/json'})
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.is_json
|
||||
|
||||
def test_a_browser_accept_header_still_gets_html(self, client):
|
||||
"""Browsers send `*/*` alongside text/html; that must not read as a
|
||||
request for JSON."""
|
||||
response = client.get(
|
||||
'/no-such-page',
|
||||
headers={'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'},
|
||||
)
|
||||
|
||||
assert 'text/html' in response.content_type
|
||||
Reference in New Issue
Block a user