From 907bdaf0437dea5032cc5db32a87337867437656 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 23 May 2026 01:13:44 +0300 Subject: [PATCH] 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"). --- .../tests/api/routes/test_devices_routes.py | 87 +++++++++++++++++++ server/tests/test_url_scheme.py | 54 ++++++++++++ 2 files changed, 141 insertions(+) diff --git a/server/tests/api/routes/test_devices_routes.py b/server/tests/api/routes/test_devices_routes.py index afcdf5f..edac22b 100644 --- a/server/tests/api/routes/test_devices_routes.py +++ b/server/tests/api/routes/test_devices_routes.py @@ -342,6 +342,93 @@ class TestPairDevice: 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: """End-to-end coverage: pair, then persist; assert the token is encrypted at rest and decrypted in to_config(), and that the API diff --git a/server/tests/test_url_scheme.py b/server/tests/test_url_scheme.py index d546a2d..379c84d 100644 --- a/server/tests/test_url_scheme.py +++ b/server/tests/test_url_scheme.py @@ -132,3 +132,57 @@ def test_userinfo_does_not_get_https(): """``evil.com@192.168.1.1`` must not be coerced to ``https://`` (which would send credentials ``evil.com`` to ``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"