fix(i18n): read every refusal from the backend's syntax tree, not by regex

The contract read Python with regexes, and each guard added for a missed shape had shapes of its own
it missed: a reason after another key, a status passed by name, a second `e.reason` handler with the
same text, a pin naming a type nothing checked.

backend/tests/refusal_sites.py (stdlib `ast`, run by the contract on the host's Python) reads every
`{"code": ...}` detail and follows each reason to its values: literals, both arms of a conditional,
module constants, the `Literal` on the parameter or attribute it travels in (on the class an `except`
catches), every call of a `str` helper with its defaults, a map's values with its keys held to the
index's type. What it cannot follow fails the contract, unless no screen shows that code; no pins.

The reason attributes are typed with their Literal, so a subclass or an assignment cannot widen
them; InvalidSetting takes a SettingReason, and the settings map is held to it (a reason the map
lacked was a 500). The per-family reverse checks are one check by prefix (the admin actions
included), and the leaf-key filter applies only to the suffixed sign-in family.
This commit is contained in:
2026-10-31 15:07:38 +01:00
parent ceadf69e43
commit 5e8589bbf8
13 changed files with 1022 additions and 254 deletions
+278
View File
@@ -0,0 +1,278 @@
"""The refusal scan (`refusal_sites.py`) on small sources: every shape a reason travels in is followed to
its values or reported unread. A scan that silently drops a shape is a guard green over nothing, so each
shape that once went past the regex it replaced is here. Pure: no app import, no database."""
import textwrap
from tests.refusal_sites import scan, scan_app
def _scan(**files: str) -> dict:
return scan(
{f"{name.replace('__', '/')}.py": textwrap.dedent(src) for name, src in files.items()}
)
def _code(result: dict, code: str) -> dict:
return result["codes"][code]
REFUSAL = """
from fastapi import HTTPException
def refused(status: int, reason: str, **params: object) -> HTTPException:
return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params})
"""
def test_a_literal_wherever_it_sits_in_the_dict():
r = _scan(
a="""
def f(x):
raise HTTPException(409, {"code": "clip", "limit_s": 5, "reason": "too_long"})
def g(x):
raise HTTPException(409, {"reason": "before", "code": "clip"})
def h(x):
raise HTTPException(409, {
"code": "clip", # a comment between
"reason": "after_comment",
})
def i(x):
raise HTTPException(409, detail=dict(code="clip", reason="by_call"))
"""
)
assert _code(r, "clip")["reasons"] == ["after_comment", "before", "by_call", "too_long"]
assert _code(r, "clip")["unread"] == []
def test_every_arm_of_a_conditional_and_every_operand_of_or():
r = _scan(
a="""
def f(x, e):
raise HTTPException(409, {"code": "edit", "reason": "a" if x else "b"})
def g(x):
raise HTTPException(409, {"code": "edit", "reason": x.why or "c"})
"""
)
assert _code(r, "edit")["reasons"] == ["a", "b", "c"]
assert [u.split(": ", 1)[1] for u in _code(r, "edit")["unread"]] == ["x.why"]
def test_a_helper_is_followed_to_every_call_however_it_is_written():
r = _scan(
refusal=REFUSAL,
routes__a="""
from http import HTTPStatus
from app import refusal
from app.refusal import refused
def f(status):
raise refused(status, "by_name_status")
def g():
raise refused(HTTPStatus.CONFLICT, reason="by_keyword")
def h():
raise refusal.refused(409, "qualified")
def i(x):
raise refused(409, "a" if x else "second_arm")
""",
)
assert _code(r, "refused")["reasons"] == [
"a",
"by_keyword",
"by_name_status",
"qualified",
"second_arm",
]
assert _code(r, "refused")["unread"] == []
def test_a_helper_passed_as_a_value_or_spread_into_is_unread():
r = _scan(
refusal=REFUSAL,
routes__a="""
from app.refusal import refused
def f(args):
pick = refused
raise refused(*args)
""",
)
unread = _code(r, "refused")["unread"]
assert any("the helper used as a value" in u for u in unread)
assert any("refused(*args)" in u for u in unread)
def test_a_default_is_what_a_call_that_omits_it_sends():
r = _scan(
routes___common="""
def not_found(what: str = "generic"):
return HTTPException(404, {"code": "not_found", "reason": what})
def get_or_404(db, id_, *, what: str = "generic"):
raise not_found(what)
""",
routes__a="""
from app.routes._common import get_or_404, not_found
def f(db):
get_or_404(db, 1, what="user")
get_or_404(db, 2)
raise not_found("debug_session")
""",
)
assert _code(r, "not_found")["reasons"] == ["debug_session", "generic", "user"]
def test_a_caught_exception_answers_with_its_attribute_type_every_class_it_catches():
r = _scan(
errors="""
from typing import Literal
ClipReason = Literal["bad_range", "too_short"]
EditReason = Literal["not_media"]
class ClipError(Exception):
def __init__(self, reason: ClipReason, **params: float):
self.reason: ClipReason = reason
self.params = params
class EditError(Exception):
def __init__(self, reason: EditReason):
self.reason: EditReason = reason
class Untyped(Exception):
def __init__(self, reason: str):
self.reason = reason
class Narrower(ClipError):
pass
""",
routes__a="""
from app import errors
def f():
try:
pass
except (errors.ClipError, errors.EditError) as e:
raise HTTPException(422, {"code": "clip", "reason": e.reason, **e.params})
except errors.Untyped as e:
raise HTTPException(422, {"code": "clip", "reason": e.reason})
except errors.Narrower as e:
raise HTTPException(422, {"code": "edit", "reason": e.reason})
""",
)
assert _code(r, "clip")["reasons"] == ["bad_range", "not_media", "too_short"]
unread = _code(r, "clip")["unread"]
assert any("Untyped.reason has no Literal type" in u for u in unread)
# EditError keeps no `**params`, so its `e.params` could be anything
assert any("e.params" in u for u in unread)
assert _code(r, "edit")["reasons"] == ["bad_range", "too_short"]
def test_a_typed_parameter_is_its_literal_and_an_untyped_one_is_unread_when_never_called():
r = _scan(
a="""
from typing import Literal
Why = Literal["one", "two"]
def typed(reason: Why):
return HTTPException(409, {"code": "playlist", "reason": reason})
class C:
def method(self, reason: str):
return HTTPException(409, {"code": "playlist", "reason": reason})
"""
)
assert _code(r, "playlist")["reasons"] == ["one", "two"]
assert any("is not a module-level helper" in u for u in _code(r, "playlist")["unread"])
def test_a_value_the_scan_cannot_follow_is_unread():
r = _scan(
a="""
def f(svc):
hit, code = svc.fetch()
raise HTTPException(422, {"code": "subtitle", "reason": code})
def g(svc):
raise HTTPException(422, {"code": "subtitle", "reason": svc.reason()})
def h(e):
raise HTTPException(422, {"code": "subtitle", "reason": e.reason})
"""
)
assert len(_code(r, "subtitle")["unread"]) == 3
assert _code(r, "subtitle")["reasons"] == []
def test_a_map_lookup_is_its_values_and_its_keys_must_be_what_the_index_can_be():
src = """
from typing import Literal
SettingReason = Literal["too_low", "too_high"]
class InvalidSetting(ValueError):
def __init__(self, key: str, reason: SettingReason, **params: object):
self.reason: SettingReason = reason
self.params = params
_MAP: dict[SettingReason, str] = {{{keys}}}
def refused(status: int, reason: str, **params: object):
return HTTPException(status, {{"code": "refused", "reason": reason, **params}})
def setting_refused(exc: InvalidSetting):
return refused(400, _MAP[exc.reason], **exc.params)
"""
whole = _scan(a=src.format(keys='"too_low": "setting_too_low", "too_high": "setting_too_high"'))
assert _code(whole, "refused")["reasons"] == ["setting_too_high", "setting_too_low"]
assert whole["errors"] == []
short = _scan(a=src.format(keys='"too_low": "setting_too_low"'))
assert len(short["errors"]) == 1 and "not what its index can be" in short["errors"][0]
def test_a_detail_built_up_in_steps_is_read_through_its_variable():
r = _scan(
a="""
def f(status: int, reason: str, user=None, **extra):
detail: dict = {"code": "login", "reason": "invalid"}
if user is not None:
detail |= {"email": user.email, "reason": "suspended"}
detail |= {k: v for k, v in extra.items() if v is not None}
return HTTPException(status, detail)
def g(more):
detail = {"code": "login"}
detail["reason"] = "late"
detail.update(more)
detail |= more
return HTTPException(401, detail)
"""
)
entry = _code(r, "login")
assert entry["reasons"] == ["invalid", "late", "suspended"]
assert sum("may carry a reason" in u for u in entry["unread"]) == 2
def test_a_code_must_be_one_the_scan_can_read():
r = _scan(
a="""
BUSY = "busy"
def f(code):
raise HTTPException(409, {"code": BUSY})
def g(code):
raise HTTPException(409, {"code": code})
"""
)
assert "busy" in r["codes"] and r["codes"]["busy"]["reasonless"]
assert len(r["unreadCodes"]) == 1 and r["unreadCodes"][0].endswith(": code")
def test_the_data_files_are_excluded_by_name_only():
r = _scan(plex__tmdb='x = {"code": "HU", "name": "Hungary"}', plex__other='y = {"code": "HU"}')
assert r["excluded"] == {"plex/tmdb.py": 1}
assert "HU" in r["codes"]
def test_the_app_scans():
"""The real tree, on the interpreter the backend runs: every refusal it holds is read (the
frontend's contract judges what is read)."""
result = scan_app()
assert {"refused", "not_found", "login", "clip"} <= set(result["codes"])
assert result["errors"] == []