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.
279 lines
9.4 KiB
Python
279 lines
9.4 KiB
Python
"""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"] == []
|