A refusal's detail now stands where it is sent (HTTPException's detail, a JSONResponse's "detail") or is returned by a factory that only ever is, with any spread before its code and reason, so no spread can replace them. The scan follows no variable and no kwargs any more; what it cannot read in a reason (a lambda's or comprehension's name, a nonlocal or global, a local that reaches itself, a constant or map changed elsewhere, an app import it cannot resolve) is reported. The detail guard reads the same slots and fails on anything but a code dict or a factory's call.
89 lines
3.8 KiB
Python
89 lines
3.8 KiB
Python
"""No refusal answers in a way the client cannot say.
|
|
|
|
The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it
|
|
can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error
|
|
dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they
|
|
became; this test is what keeps a new one from being written the old way. It reads the same slots the
|
|
refusal scan does (`refusal_sites.detail_slots`), so the two cannot disagree on where a detail is sent.
|
|
Pure: it reads the sources.
|
|
"""
|
|
|
|
import textwrap
|
|
|
|
from tests.refusal_sites import Scan, app_sources
|
|
|
|
# Answers that are no refusal of the app's, by the function they are sent from: the client shows a
|
|
# detail only when it is an object (`lib/api/core.ts`), so these read as its generic line, and no screen
|
|
# needs more from them.
|
|
NO_REFUSAL = {
|
|
("main.py", "setup_gate"): "the 503 before setup: the setup screen is what the user sees",
|
|
("main.py", "spa_fallback"): "a bare 404 for an api path no route matches: no screen calls one",
|
|
("main.py", "_validation_error"): "FastAPI's own 422 list: a body the client itself got wrong",
|
|
("routes/diagnostics.py", "_parsed_events"): "the client-events beacon's 422: nothing shows it",
|
|
}
|
|
|
|
|
|
def _offenders(sources: dict[str, str]) -> list[str]:
|
|
return [
|
|
f"{path}:{line}"
|
|
for path, line, fn in Scan(sources).other_details()
|
|
if (path, fn) not in NO_REFUSAL
|
|
]
|
|
|
|
|
|
def test_every_detail_is_one_the_client_can_say():
|
|
offenders = _offenders(app_sources())
|
|
assert offenders == [], (
|
|
"a refusal the client can only answer with its generic line — send `{code, reason}` "
|
|
f"(`refused(status, reason)`), or list it in NO_REFUSAL with why: {offenders}"
|
|
)
|
|
|
|
|
|
def test_the_detail_scan_sees_each_shape_the_client_cannot_say():
|
|
"""A guard that matches nothing is green over nothing."""
|
|
src = textwrap.dedent(
|
|
"""
|
|
def f(x):
|
|
raise HTTPException(409, detail={"reason": "view_name_taken"})
|
|
def g(x):
|
|
raise HTTPException(409, dict(reason="taken"))
|
|
def h(x):
|
|
raise HTTPException(status_code=404)
|
|
def i(x):
|
|
return JSONResponse({"detail": "This instance isn't set up yet."}, status_code=503)
|
|
def j(x):
|
|
return JSONResponse(content={"detail": {"reason": "x"}}, status_code=409)
|
|
def k(x):
|
|
raise HTTPException(400, detail=f"bad {x}")
|
|
def l(x):
|
|
raise HTTPException(404, None)
|
|
def n(x, msg):
|
|
raise HTTPException(400, detail=msg)
|
|
def o(x):
|
|
raise HTTPException(400, detail=["a sentence"])
|
|
def p(x):
|
|
raise HTTPException(400, detail=describe(x))
|
|
def q(x):
|
|
raise HTTPException(400, detail={"code": "a"} if x else "text")
|
|
def r(x):
|
|
raise HTTPException(400, detail=nothing(x))
|
|
def nothing(x):
|
|
pass
|
|
def ok(x):
|
|
raise HTTPException(409, {"code": "refused", "reason": "x"})
|
|
raise HTTPException(409, detail=quota(x))
|
|
raise HTTPException(409, detail={"code": "a"} if x else {"code": "b"})
|
|
return JSONResponse({"detail": {"code": "too_large"}}, status_code=413)
|
|
return JSONResponse({"ok": True})
|
|
def quota(x):
|
|
return {"code": "quota", "reason": "files"}
|
|
def setup_gate(x):
|
|
return JSONResponse({"detail": "listed"}, status_code=503)
|
|
"""
|
|
)
|
|
lines = (3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25)
|
|
assert _offenders({"main.py": src}) == [f"main.py:{n}" for n in lines]
|
|
# A file whose code dicts are data still answers its refusals in a way the client can say.
|
|
data = 'def f():\n raise HTTPException(400, "text")\n'
|
|
assert _offenders({"plex/tmdb.py": data}) == ["plex/tmdb.py:2"]
|