test(url-scheme): WLED route-level integration + IPv6 regression
TestWLEDSchemeInference in test_devices_routes covers the POST/PUT create-and-update flow with a stubbed WLED provider so the infer_http_scheme integration hop has end-to-end coverage instead of just the unit tests. test_url_scheme grows public IPv6 (Cloudflare / Google / Quad9 DNS), bracketed-form, and ULA cases. Adds an explicit pin for the Python ipaddress documentation-prefix quirk (2001:db8::/32 is is_private, so it routes to http:// even though some audits colloquially call it "public").
This commit is contained in:
@@ -342,6 +342,93 @@ class TestPairDevice:
|
|||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
class TestWLEDSchemeInference:
|
||||||
|
"""End-to-end pin for the URL-scheme inference applied to WLED devices.
|
||||||
|
|
||||||
|
The :func:`infer_http_scheme` helper has its own exhaustive unit
|
||||||
|
coverage in :file:`tests/test_url_scheme.py`; this class adds the
|
||||||
|
*integration* hop that REVIEW_TODO calls out — POST/PUT a bare host
|
||||||
|
and assert the stored device carries the inferred scheme so a future
|
||||||
|
refactor of the route handler can't quietly drop the call.
|
||||||
|
|
||||||
|
The WLED provider is stubbed out so the test does not need a real
|
||||||
|
device on the network — we just need ``validate_device`` to return a
|
||||||
|
successful payload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _stub_wled_validate(self, monkeypatch):
|
||||||
|
async def fake_validate(self, url): # noqa: ARG001 — provider self
|
||||||
|
return {"led_count": 30}
|
||||||
|
|
||||||
|
from ledgrab.core.devices.wled_provider import WLEDDeviceProvider
|
||||||
|
|
||||||
|
monkeypatch.setattr(WLEDDeviceProvider, "validate_device", fake_validate)
|
||||||
|
return fake_validate
|
||||||
|
|
||||||
|
def test_create_wled_with_bare_private_ip_normalises_to_http(
|
||||||
|
self, client, device_store, _stub_wled_validate
|
||||||
|
):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/devices",
|
||||||
|
json={
|
||||||
|
"name": "WLED desk",
|
||||||
|
"device_type": "wled",
|
||||||
|
"url": "192.168.1.42",
|
||||||
|
"led_count": 30,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
device_id = resp.json()["id"]
|
||||||
|
assert device_store.get_device(device_id).url == "http://192.168.1.42"
|
||||||
|
|
||||||
|
def test_create_wled_with_public_host_normalises_to_https(
|
||||||
|
self, client, device_store, _stub_wled_validate
|
||||||
|
):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/devices",
|
||||||
|
json={
|
||||||
|
"name": "WLED cloud",
|
||||||
|
"device_type": "wled",
|
||||||
|
"url": "wled.example.com",
|
||||||
|
"led_count": 30,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
device_id = resp.json()["id"]
|
||||||
|
assert device_store.get_device(device_id).url == "https://wled.example.com"
|
||||||
|
|
||||||
|
def test_create_wled_strips_trailing_slash_then_infers(
|
||||||
|
self, client, device_store, _stub_wled_validate
|
||||||
|
):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/devices",
|
||||||
|
json={
|
||||||
|
"name": "WLED rack",
|
||||||
|
"device_type": "wled",
|
||||||
|
"url": "wled-rack.local/",
|
||||||
|
"led_count": 30,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
device_id = resp.json()["id"]
|
||||||
|
assert device_store.get_device(device_id).url == "http://wled-rack.local"
|
||||||
|
|
||||||
|
def test_update_wled_with_bare_host_normalises_url(self, client, device_store):
|
||||||
|
existing = device_store.create_device(
|
||||||
|
name="WLED desk",
|
||||||
|
url="http://192.168.1.42",
|
||||||
|
led_count=30,
|
||||||
|
device_type="wled",
|
||||||
|
)
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/v1/devices/{existing.id}",
|
||||||
|
json={"url": "10.0.0.5"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert device_store.get_device(existing.id).url == "http://10.0.0.5"
|
||||||
|
|
||||||
|
|
||||||
class TestPairThenCreateFlow:
|
class TestPairThenCreateFlow:
|
||||||
"""End-to-end coverage: pair, then persist; assert the token is
|
"""End-to-end coverage: pair, then persist; assert the token is
|
||||||
encrypted at rest and decrypted in to_config(), and that the API
|
encrypted at rest and decrypted in to_config(), and that the API
|
||||||
|
|||||||
@@ -132,3 +132,57 @@ def test_userinfo_does_not_get_https():
|
|||||||
"""``evil.com@192.168.1.1`` must not be coerced to ``https://`` (which
|
"""``evil.com@192.168.1.1`` must not be coerced to ``https://`` (which
|
||||||
would send credentials ``evil.com`` to ``192.168.1.1``)."""
|
would send credentials ``evil.com`` to ``192.168.1.1``)."""
|
||||||
assert "://" not in infer_http_scheme("evil.com@192.168.1.1")
|
assert "://" not in infer_http_scheme("evil.com@192.168.1.1")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# IPv6 literal regression — pre-hardening, the bare-label fallback split on
|
||||||
|
# the first ``:`` (``2606:4700:4700::1111`` → host="2606" → single label →
|
||||||
|
# treated as local mDNS-style) and quietly coerced public IPv6 LED targets
|
||||||
|
# to ``http://``. The bracketless ipaddress probe in ``_extract_hostname``
|
||||||
|
# fixes that; pin both the public and ULA branches so a future helper
|
||||||
|
# refactor can't regress either side.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw",
|
||||||
|
[
|
||||||
|
"2606:4700:4700::1111", # Cloudflare public DNS
|
||||||
|
"2001:4860:4860::8888", # Google public DNS
|
||||||
|
"2620:fe::fe", # Quad9
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_public_ipv6_literal_gets_https(raw):
|
||||||
|
assert infer_http_scheme(raw) == f"https://{raw}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw",
|
||||||
|
[
|
||||||
|
"[2606:4700:4700::1111]",
|
||||||
|
"[2606:4700:4700::1111]:443",
|
||||||
|
"[2001:4860:4860::8888]:8080",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_bracketed_public_ipv6_gets_https(raw):
|
||||||
|
assert infer_http_scheme(raw) == f"https://{raw}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw",
|
||||||
|
[
|
||||||
|
"fc00::1", # ULA — RFC 4193
|
||||||
|
"fd12:3456:789a::1",
|
||||||
|
"fdc7::42",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ula_ipv6_literal_gets_http(raw):
|
||||||
|
assert infer_http_scheme(raw) == f"http://{raw}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_documentation_prefix_ipv6_treated_as_private():
|
||||||
|
"""Python's :mod:`ipaddress` classifies ``2001:db8::/32`` as private
|
||||||
|
(RFC 3849 documentation range) so the scheme inferrer reaches for the
|
||||||
|
LAN-default ``http://``. Test pins the actual behaviour rather than
|
||||||
|
the colloquial "public" label the address is sometimes given."""
|
||||||
|
assert infer_http_scheme("2001:db8::1") == "http://2001:db8::1"
|
||||||
|
|||||||
Reference in New Issue
Block a user