Files
haproxy-openmanager/backend/tests/test_router_path_shadowing.py
taylanbakircioglu 11e5bf57d9 fix(vip): make the adoption panel reachable again (v1.10.6)
Follow-up to #58. The "Unmanaged keepalived detected" panel it shipped never
appeared on any deployment. GET /discoveries was declared after GET /{vip_id} in
routers/vip.py, and FastAPI matches routes in declaration order, so every request
for the discovery list was routed into get_vip, which takes vip_id: int and
rejected "discoveries" with 422 before list_vip_discoveries ever ran.

The failure was completely silent. The agents reported their configs correctly,
the rows landed in vip_discoveries, and the HA/VIP page treats any non-OK
response as "nothing to show" - so the feature was invisible with no error in
any log. Confirmed against a real fleet: two discovery rows present in the
database, one with a parsed candidate, and an empty panel.

- move list_vip_discoveries above the /{vip_id} routes, with a comment stating
  the ordering requirement
- add tests/test_router_path_shadowing.py: a static source scan that fails if
  any literal path in any router is declared after a parameterised route that
  would swallow it. The whole router tree is clean; the detector is itself
  tested against the pre-fix ordering so the guard cannot pass vacuously.

POST /adopt is unaffected: no POST /{vip_id} route exists.

Also carries the release metadata for #58 (v1.10.4) and #60 (v1.10.5), which
are published together with this fix rather than as separate artifacts.

No schema, API-shape, frontend or agent change. Data reported under the earlier
code is not lost - existing rows show up as soon as this backend is deployed,
with no agent action needed.
2026-08-13 18:55:54 +03:00

142 lines
5.8 KiB
Python

"""
v1.10.6 — literal API paths must never be declared after a parameterised one.
Found in production on the v1.10.4 VIP-adoption feature: `@router.get("/discoveries")`
sat at the bottom of routers/vip.py, below `@router.get("/{vip_id}")`. FastAPI matches
routes in DECLARATION order, so every `GET /api/vip/discoveries` was answered by the
`/{vip_id}` handler, which declares `vip_id: int` and therefore rejected the request with
422 before `list_vip_discoveries` ever ran.
Nothing about that failure was visible. The agents reported their discoveries correctly,
the rows landed in `vip_discoveries`, and the HA/VIP page treats any non-OK response as
"nothing to show" — so the adoption feature simply did not exist as far as the UI was
concerned, with no error anywhere.
These tests are a STATIC source scan on purpose: no imports, no app construction, no DB.
They therefore also cover routers that cannot be imported in a bare test environment, and
they keep covering routes added in the future.
"""
import pathlib
import re
import pytest
ROUTERS_DIR = pathlib.Path(__file__).resolve().parents[1] / "routers"
# `@router.get("/x")`, `@some_router.post("/x", ...)` — the path is the first string arg.
_DECORATOR = re.compile(r'^@(?:\w+)\.(get|post|put|delete|patch)\(\s*[\'"]([^\'"]*)[\'"]')
def _routes(source: str):
"""[(line_no, verb, path)] in declaration order."""
out = []
for line_no, line in enumerate(source.splitlines(), 1):
match = _DECORATOR.match(line)
if match:
out.append((line_no, match.group(1), match.group(2)))
return out
def _shadows(earlier: str, later: str) -> bool:
"""True if `earlier` (declared first) swallows the literal path `later`.
Only literal paths can be silently swallowed, and only by a path that has the same
number of segments where every non-placeholder segment matches. The collection route
("" or "/") is its own path and never collides.
"""
if not later.strip("/") or not earlier.strip("/"):
return False
if "{" in later:
return False
if "{" not in earlier:
return False
earlier_segments = earlier.strip("/").split("/")
later_segments = later.strip("/").split("/")
if len(earlier_segments) != len(later_segments):
return False
return all(
e.startswith("{") or e == l
for e, l in zip(earlier_segments, later_segments)
)
def _shadowed_routes(path: pathlib.Path):
routes = _routes(path.read_text())
found = []
for index, (line_no, verb, route_path) in enumerate(routes):
for prior_line, prior_verb, prior_path in routes[:index]:
if prior_verb == verb and _shadows(prior_path, route_path):
found.append(
f"{path.name}:{line_no} {verb.upper()} {route_path} is swallowed by "
f"{prior_path} declared at line {prior_line}"
)
return found
# ----------------------------------------------------------------------------
# 1. The specific regression: /api/vip/discoveries must outrank /{vip_id}
# ----------------------------------------------------------------------------
def test_vip_discoveries_declared_before_vip_id():
routes = _routes((ROUTERS_DIR / "vip.py").read_text())
get_paths = [path for _line, verb, path in routes if verb == "get"]
assert "/discoveries" in get_paths, "the discoveries endpoint disappeared"
assert "/{vip_id}" in get_paths, "the get-one endpoint disappeared"
assert get_paths.index("/discoveries") < get_paths.index("/{vip_id}"), (
"GET /discoveries is declared after GET /{vip_id}; FastAPI will route "
"/api/vip/discoveries into get_vip and answer 422, silently emptying the "
"adoption panel"
)
# ----------------------------------------------------------------------------
# 2. The general guard: no literal path anywhere is shadowed
# ----------------------------------------------------------------------------
def test_no_literal_route_is_shadowed_in_any_router():
problems = []
for router_file in sorted(ROUTERS_DIR.glob("*.py")):
problems.extend(_shadowed_routes(router_file))
assert not problems, (
"literal route(s) declared after a parameterised route that swallows them:\n "
+ "\n ".join(problems)
)
# ----------------------------------------------------------------------------
# 3. The detector itself must actually detect (guards against a vacuous pass)
# ----------------------------------------------------------------------------
@pytest.mark.parametrize(
"earlier,later,expected",
[
("/{vip_id}", "/discoveries", True), # the v1.10.4 bug
("/{vip_id}", "/{other}", False), # two placeholders never shadow
("/{vip_id}", "", False), # collection route is its own path
("/{vip_id}/apply", "/adopt", False), # different segment counts
("/{vip_id}/apply", "/adopt/now", False), # literal mismatch in segment 2
("/{vip_id}/{action}", "/adopt/now", True), # both segments placeheld
("/vips", "/discoveries", False), # literal never shadows a literal
],
)
def test_shadow_detector_semantics(earlier, later, expected):
assert _shadows(earlier, later) is expected
def test_detector_flags_the_original_declaration_order():
"""A synthetic file in the pre-fix order must be reported, so a future refactor that
breaks the detector cannot make the guard above pass vacuously."""
source = (
'@router.get("")\n'
"async def list_vips(): ...\n"
'@router.get("/{vip_id}")\n'
"async def get_vip(vip_id: int): ...\n"
'@router.get("/discoveries")\n'
"async def list_vip_discoveries(): ...\n"
)
routes = _routes(source)
assert [verb for _l, verb, _p in routes] == ["get", "get", "get"]
assert _shadows(routes[1][2], routes[2][2]) is True