mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
Merge branch 'dev' into fix/external-camera-capture-coalescing
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
"""Tests for the P2S/X2D left auxiliary part cooling fan (#2576).
|
||||
|
||||
The "Auxiliary Part Cooling Fan - Left" (also fits X2D) is reported ONLY as
|
||||
device.airduct part with raw id 160 (decoded id = 160 >> 4 = 10,
|
||||
AIR_FUN.FAN_REMOTE_COOLING_1 in Bambu Studio) — the firmware does NOT mirror
|
||||
it into any flat big_fanX_speed field, which is why it was previously dropped.
|
||||
It is controlled with "M106 P10", exactly like Bambu's official P2S machine-
|
||||
profile gcode does.
|
||||
|
||||
The airduct payloads below are verbatim captures from a live P2S
|
||||
(fw 01.02.00.00) with the accessory installed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mqtt_client():
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
|
||||
return BambuMQTTClient(
|
||||
ip_address="192.168.1.100",
|
||||
serial_number="TESTP2S",
|
||||
access_code="12345678",
|
||||
)
|
||||
|
||||
|
||||
def _airduct_device(parts):
|
||||
"""Wrap airduct parts in the device envelope as pushed by a P2S."""
|
||||
return {
|
||||
"device": {
|
||||
"airduct": {
|
||||
"modeCur": 0,
|
||||
"modeFunc": 0,
|
||||
"modeList": [
|
||||
{"ctrl": [16, 32, 160, 48], "modeId": 0, "off": []},
|
||||
{"ctrl": [16, 32, 48], "modeId": 1, "off": [160]},
|
||||
],
|
||||
"modeVisable": 7,
|
||||
"parts": parts,
|
||||
"subFunc": 0,
|
||||
"subMode": 0,
|
||||
"subVisable": 7,
|
||||
"version": 1,
|
||||
},
|
||||
"type": 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Verbatim parts list from a live P2S: part cooling ramping (state 30,
|
||||
# target 90), right aux at 40%, left aux OFF, chamber at 70%.
|
||||
P2S_PARTS_LEFT_AUX_OFF = [
|
||||
{"func": 0, "id": 16, "range": 6553600, "state": 30, "tar_state": 90},
|
||||
{"func": 6, "id": 32, "range": 6553600, "state": 40, "tar_state": 40},
|
||||
{"func": 5, "id": 160, "range": 6553600, "state": 0, "tar_state": 0},
|
||||
{"func": 2, "id": 48, "range": 6553600, "state": 70, "tar_state": 70},
|
||||
]
|
||||
|
||||
# Same printer later in the print: left aux running at 80%.
|
||||
P2S_PARTS_LEFT_AUX_80 = [
|
||||
{"func": 0, "id": 16, "range": 6553600, "state": 60, "tar_state": 60},
|
||||
{"func": 6, "id": 32, "range": 6553600, "state": 100, "tar_state": 100},
|
||||
{"func": 5, "id": 160, "range": 6553600, "state": 80, "tar_state": 80},
|
||||
{"func": 2, "id": 48, "range": 6553600, "state": 80, "tar_state": 80},
|
||||
]
|
||||
|
||||
|
||||
class TestLeftAuxFanParsing:
|
||||
"""device.airduct part id 10 (raw 160) -> state.left_aux_fan_speed."""
|
||||
|
||||
def test_defaults_to_none(self, mqtt_client):
|
||||
assert mqtt_client.state.left_aux_fan_speed is None
|
||||
|
||||
def test_parses_left_aux_running(self, mqtt_client):
|
||||
mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
|
||||
assert mqtt_client.state.left_aux_fan_speed == 80
|
||||
|
||||
def test_parses_left_aux_off(self, mqtt_client):
|
||||
mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_OFF))
|
||||
assert mqtt_client.state.left_aux_fan_speed == 0
|
||||
|
||||
def test_raw_id_is_bit_unpacked(self, mqtt_client):
|
||||
"""Raw id 160 must decode to part id 10 (id >> 4), NOT match on 160."""
|
||||
# A hypothetical raw id of 10 would decode to part id 0 — must not match.
|
||||
parts = [{"func": 5, "id": 10, "range": 6553600, "state": 50, "tar_state": 50}]
|
||||
mqtt_client._update_state(_airduct_device(parts))
|
||||
assert mqtt_client.state.left_aux_fan_speed is None
|
||||
|
||||
def test_parts_without_left_aux_reports_none(self, mqtt_client):
|
||||
"""A full parts list without id 10 means the fan is not installed."""
|
||||
mqtt_client.state.left_aux_fan_speed = 80 # previously seen
|
||||
parts = [p for p in P2S_PARTS_LEFT_AUX_80 if p["id"] != 160]
|
||||
mqtt_client._update_state(_airduct_device(parts))
|
||||
assert mqtt_client.state.left_aux_fan_speed is None
|
||||
|
||||
def test_diff_push_without_device_preserves_value(self, mqtt_client):
|
||||
"""P-series diff pushes omit device.airduct — value must survive."""
|
||||
mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
|
||||
mqtt_client._update_state({"nozzle_temper": 250.0})
|
||||
assert mqtt_client.state.left_aux_fan_speed == 80
|
||||
|
||||
def test_state_clamped_to_0_100(self, mqtt_client):
|
||||
parts = [{"func": 5, "id": 160, "range": 6553600, "state": 250, "tar_state": 0}]
|
||||
mqtt_client._update_state(_airduct_device(parts))
|
||||
assert mqtt_client.state.left_aux_fan_speed == 100
|
||||
|
||||
def test_packed_state_decodes_from_low_8_bits(self, mqtt_client):
|
||||
"""`state` is bit-packed like its sibling `range` (end << 16 | start).
|
||||
|
||||
Bambu Studio decodes it with get_flag_bits(state, 0, 8), so only the low
|
||||
byte carries the percentage. Without the mask a packed value would clamp
|
||||
to 100 instead of decoding to the real speed.
|
||||
"""
|
||||
packed = (60 << 16) | 45 # sibling field in the high bits, 45% in the low byte
|
||||
parts = [{"func": 5, "id": 160, "range": 6553600, "state": packed, "tar_state": 0}]
|
||||
mqtt_client._update_state(_airduct_device(parts))
|
||||
assert mqtt_client.state.left_aux_fan_speed == 45
|
||||
|
||||
def test_unpacked_state_is_unaffected_by_the_mask(self, mqtt_client):
|
||||
# Plain 0-100 values (what a P2S actually sends) must round-trip exactly.
|
||||
for speed in (0, 30, 80, 100):
|
||||
parts = [{"func": 5, "id": 160, "range": 6553600, "state": speed, "tar_state": speed}]
|
||||
mqtt_client._update_state(_airduct_device(parts))
|
||||
assert mqtt_client.state.left_aux_fan_speed == speed
|
||||
|
||||
def test_malformed_part_entries_ignored(self, mqtt_client):
|
||||
parts = [
|
||||
"not-a-dict",
|
||||
{"func": 5}, # no id/state
|
||||
{"id": "garbage", "state": 10},
|
||||
{"func": 5, "id": 160, "range": 6553600, "state": 30, "tar_state": 30},
|
||||
]
|
||||
mqtt_client._update_state(_airduct_device(parts))
|
||||
assert mqtt_client.state.left_aux_fan_speed == 30
|
||||
|
||||
def test_flat_fan_fields_unaffected(self, mqtt_client):
|
||||
"""Regression: flat fields keep coming from the flat MQTT keys."""
|
||||
payload = {
|
||||
"cooling_fan_speed": "4",
|
||||
"big_fan1_speed": "6",
|
||||
"big_fan2_speed": "10",
|
||||
"heatbreak_fan_speed": "14",
|
||||
**_airduct_device(P2S_PARTS_LEFT_AUX_OFF),
|
||||
}
|
||||
mqtt_client._update_state(payload)
|
||||
assert mqtt_client.state.cooling_fan_speed == 27 # 4/15
|
||||
assert mqtt_client.state.big_fan1_speed == 40 # 6/15
|
||||
assert mqtt_client.state.big_fan2_speed == 67 # 10/15
|
||||
assert mqtt_client.state.heatbreak_fan_speed == 93 # 14/15
|
||||
assert mqtt_client.state.left_aux_fan_speed == 0
|
||||
|
||||
|
||||
class TestExhaustFanPresence:
|
||||
"""device.airduct part id 3 (raw 48) presence -> state.exhaust_fan_present.
|
||||
|
||||
The chamber exhaust fan is a P2S/X2D add-on kit (get_version module "eef").
|
||||
Its speed rides on the flat big_fan2_speed field, but the airduct only lists
|
||||
part id 3 when the kit is physically installed — so part-3 presence is the
|
||||
signal the UI uses to show/hide the Exhaust tile.
|
||||
"""
|
||||
|
||||
def test_defaults_to_false(self, mqtt_client):
|
||||
assert mqtt_client.state.exhaust_fan_present is False
|
||||
|
||||
def test_present_when_part_3_reported(self, mqtt_client):
|
||||
# Full P2S parts list includes id 48 (>>4 = 3).
|
||||
mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
|
||||
assert mqtt_client.state.exhaust_fan_present is True
|
||||
|
||||
def test_absent_when_part_3_missing(self, mqtt_client):
|
||||
mqtt_client.state.exhaust_fan_present = True # previously seen
|
||||
parts = [p for p in P2S_PARTS_LEFT_AUX_80 if p["id"] != 48]
|
||||
mqtt_client._update_state(_airduct_device(parts))
|
||||
assert mqtt_client.state.exhaust_fan_present is False
|
||||
|
||||
def test_base_p2s_only_part_cooling_and_aux(self, mqtt_client):
|
||||
# A base P2S (no exhaust kit, no left aux kit) lists only ids 1 and 2.
|
||||
parts = [
|
||||
{"func": 0, "id": 16, "range": 6553600, "state": 0, "tar_state": 0},
|
||||
{"func": 6, "id": 32, "range": 6553600, "state": 0, "tar_state": 0},
|
||||
]
|
||||
mqtt_client._update_state(_airduct_device(parts))
|
||||
assert mqtt_client.state.exhaust_fan_present is False
|
||||
assert mqtt_client.state.left_aux_fan_speed is None
|
||||
|
||||
def test_diff_push_without_device_preserves_value(self, mqtt_client):
|
||||
mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
|
||||
mqtt_client._update_state({"nozzle_temper": 250.0})
|
||||
assert mqtt_client.state.exhaust_fan_present is True
|
||||
|
||||
|
||||
class TestPartialPartsFrames:
|
||||
"""A `parts` list that is not a full inventory must not retract presence.
|
||||
|
||||
`device.airduct` is pushed field by field — the `modeCur` handler reads it
|
||||
with an `in` check for exactly that reason — so a frame can carry `parts`
|
||||
without carrying every fan. Absence is what tells us a kit is not fitted, so
|
||||
it is only trustworthy on a complete list. Read as gospel, a truncated frame
|
||||
would make both accessory badges vanish mid-print and start rejecting
|
||||
``fan=aux2`` on a printer that does have the fan.
|
||||
|
||||
Completeness is judged on ids 1 (part cooling) and 2 (aux) being present:
|
||||
neither is optional on any machine that reports an airduct at all, and both
|
||||
appear in every layout in the support-package archive (P2S base 1,2 /
|
||||
P2S+kit 1,2,3 / X2D 1,2,3,10 / H2C,H2D,H2S 1,2,3,6).
|
||||
"""
|
||||
|
||||
def test_partial_frame_does_not_retract_the_left_aux_fan(self, mqtt_client):
|
||||
mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
|
||||
assert mqtt_client.state.left_aux_fan_speed == 80
|
||||
|
||||
# Only the part cooling fan changed — the frame says nothing about the
|
||||
# left aux fan, which is not the same as saying it is gone.
|
||||
mqtt_client._update_state(
|
||||
_airduct_device([{"func": 0, "id": 16, "range": 6553600, "state": 70, "tar_state": 70}])
|
||||
)
|
||||
|
||||
assert mqtt_client.state.left_aux_fan_speed == 80
|
||||
|
||||
def test_partial_frame_does_not_retract_the_exhaust_fan(self, mqtt_client):
|
||||
mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
|
||||
assert mqtt_client.state.exhaust_fan_present is True
|
||||
|
||||
mqtt_client._update_state(
|
||||
_airduct_device([{"func": 0, "id": 16, "range": 6553600, "state": 70, "tar_state": 70}])
|
||||
)
|
||||
|
||||
assert mqtt_client.state.exhaust_fan_present is True
|
||||
|
||||
def test_a_partial_frame_still_applies_the_speed_it_carries(self, mqtt_client):
|
||||
"""Not-authoritative-for-absence is not the same as ignored."""
|
||||
mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
|
||||
assert mqtt_client.state.left_aux_fan_speed == 80
|
||||
|
||||
mqtt_client._update_state(
|
||||
_airduct_device([{"func": 5, "id": 160, "range": 6553600, "state": 25, "tar_state": 25}])
|
||||
)
|
||||
|
||||
assert mqtt_client.state.left_aux_fan_speed == 25
|
||||
|
||||
def test_a_partial_frame_can_still_reveal_a_fan(self, mqtt_client):
|
||||
"""Presence may always be added — only retraction needs a full list."""
|
||||
mqtt_client._update_state(
|
||||
_airduct_device([{"func": 2, "id": 48, "range": 6553600, "state": 70, "tar_state": 70}])
|
||||
)
|
||||
|
||||
assert mqtt_client.state.exhaust_fan_present is True
|
||||
|
||||
def test_a_full_frame_still_retracts_both(self, mqtt_client):
|
||||
"""The kits really can be removed, and a complete list must say so —
|
||||
this is the behaviour the presence gate exists for."""
|
||||
mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
|
||||
assert mqtt_client.state.left_aux_fan_speed == 80
|
||||
assert mqtt_client.state.exhaust_fan_present is True
|
||||
|
||||
# Base P2S layout: part cooling + aux only.
|
||||
mqtt_client._update_state(
|
||||
_airduct_device(
|
||||
[
|
||||
{"func": 0, "id": 16, "range": 6553600, "state": 0, "tar_state": 0},
|
||||
{"func": 6, "id": 32, "range": 6553600, "state": 0, "tar_state": 0},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert mqtt_client.state.left_aux_fan_speed is None
|
||||
assert mqtt_client.state.exhaust_fan_present is False
|
||||
|
||||
def test_an_empty_parts_list_changes_nothing(self, mqtt_client):
|
||||
mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
|
||||
mqtt_client._update_state(_airduct_device([]))
|
||||
|
||||
assert mqtt_client.state.left_aux_fan_speed == 80
|
||||
assert mqtt_client.state.exhaust_fan_present is True
|
||||
|
||||
|
||||
class TestLeftAuxFanCommand:
|
||||
"""set_fan_speed must accept index 10 and emit M106 P10."""
|
||||
|
||||
def test_set_fan_speed_10_sends_m106_p10(self, mqtt_client, monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
|
||||
assert mqtt_client.set_fan_speed(10, 204) is True
|
||||
assert sent == ["M106 P10 S204"]
|
||||
|
||||
def test_set_left_aux_fan_helper(self, mqtt_client, monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
|
||||
assert mqtt_client.set_left_aux_fan(255) is True
|
||||
assert sent == ["M106 P10 S255"]
|
||||
|
||||
def test_speed_clamped_to_255(self, mqtt_client, monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
|
||||
mqtt_client.set_left_aux_fan(999)
|
||||
assert sent == ["M106 P10 S255"]
|
||||
|
||||
def test_invalid_fan_index_rejected(self, mqtt_client, monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
|
||||
assert mqtt_client.set_fan_speed(4, 100) is False
|
||||
assert mqtt_client.set_fan_speed(11, 100) is False
|
||||
assert sent == []
|
||||
|
||||
def test_existing_fan_indexes_still_accepted(self, mqtt_client, monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
|
||||
for idx in (1, 2, 3):
|
||||
assert mqtt_client.set_fan_speed(idx, 128) is True
|
||||
assert sent == ["M106 P1 S128", "M106 P2 S128", "M106 P3 S128"]
|
||||
|
||||
|
||||
class TestExhaustFanLabelModels:
|
||||
"""P2S/X2D call the big_fan2 enclosure fan "Exhaust"; others say "Chamber"."""
|
||||
|
||||
def test_p2s_and_x2d_use_exhaust_label(self):
|
||||
from backend.app.utils.printer_models import uses_exhaust_fan_label
|
||||
|
||||
for model in ("P2S", "X2D", "p2s", " P2S ", "N7", "N6"):
|
||||
assert uses_exhaust_fan_label(model) is True, model
|
||||
|
||||
def test_other_enclosed_models_keep_chamber_label(self):
|
||||
from backend.app.utils.printer_models import uses_exhaust_fan_label
|
||||
|
||||
for model in ("X1C", "X1", "X1E", "P1S", "H2D", "H2C", "H2S", "A1"):
|
||||
assert uses_exhaust_fan_label(model) is False, model
|
||||
|
||||
def test_unknown_or_missing_model_defaults_to_chamber(self):
|
||||
from backend.app.utils.printer_models import uses_exhaust_fan_label
|
||||
|
||||
assert uses_exhaust_fan_label(None) is False
|
||||
assert uses_exhaust_fan_label("") is False
|
||||
assert uses_exhaust_fan_label("SomeFutureModel") is False
|
||||
|
||||
|
||||
class TestExhaustLabelModelListsAgree:
|
||||
"""The exhaust-label model list is duplicated across the stack.
|
||||
|
||||
The backend keeps ``EXHAUST_FAN_LABEL_MODELS`` (display names plus the N7/N6
|
||||
internal codes, since the API can be handed either) and the frontend keeps
|
||||
``MODELS_WITH_EXHAUST_LABEL`` in PrintersPage.tsx (display names only —
|
||||
``printer.model`` is always a display name by the time it reaches the card).
|
||||
Both are correct as written, but nothing stopped them drifting apart: adding
|
||||
a model to one and forgetting the other silently produces a card labelled
|
||||
"Exhaust" whose control toast says "Chamber fan", or vice versa.
|
||||
"""
|
||||
|
||||
def _frontend_models(self) -> set[str]:
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Walk up rather than hard-coding a parent depth, so the test survives
|
||||
# the file being moved and works whatever directory pytest runs from.
|
||||
relative = Path("frontend") / "src" / "pages" / "PrintersPage.tsx"
|
||||
source = next(
|
||||
(candidate for parent in Path(__file__).resolve().parents if (candidate := parent / relative).is_file()),
|
||||
None,
|
||||
)
|
||||
if source is None:
|
||||
pytest.skip("frontend sources not present in this checkout")
|
||||
text = source.read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
r"const MODELS_WITH_EXHAUST_LABEL:[^=]*=\s*new Set\(\[(.*?)\]\)",
|
||||
text,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "MODELS_WITH_EXHAUST_LABEL not found in PrintersPage.tsx"
|
||||
return set(re.findall(r"['\"]([^'\"]+)['\"]", match.group(1)))
|
||||
|
||||
def test_frontend_list_is_the_display_name_subset_of_the_backend_list(self):
|
||||
from backend.app.utils.printer_models import EXHAUST_FAN_LABEL_MODELS
|
||||
|
||||
frontend = self._frontend_models()
|
||||
assert frontend, "frontend list parsed as empty"
|
||||
missing = frontend - set(EXHAUST_FAN_LABEL_MODELS)
|
||||
assert not missing, (
|
||||
f"models {sorted(missing)} label the fan 'Exhaust' in the UI but the backend "
|
||||
f"would report 'Chamber fan' — add them to EXHAUST_FAN_LABEL_MODELS"
|
||||
)
|
||||
|
||||
def test_every_backend_display_name_is_handled_by_the_frontend(self):
|
||||
from backend.app.utils.printer_models import EXHAUST_FAN_LABEL_MODELS
|
||||
|
||||
# N7/N6 are internal codes that never reach the card, so exclude them.
|
||||
internal_codes = {"N7", "N6"}
|
||||
backend_display = set(EXHAUST_FAN_LABEL_MODELS) - internal_codes
|
||||
missing = backend_display - self._frontend_models()
|
||||
assert not missing, (
|
||||
f"models {sorted(missing)} say 'Exhaust fan' in the API response but the card "
|
||||
f"would still show 'Chamber Fan' — add them to MODELS_WITH_EXHAUST_LABEL"
|
||||
)
|
||||
@@ -49,11 +49,39 @@ class TestURLValidation:
|
||||
def test_hostname_url(self, service):
|
||||
assert service._validate_url("http://openhab.local:8080/api") is True
|
||||
|
||||
def test_loopback_blocked(self, service):
|
||||
assert service._validate_url("http://127.0.0.1/api") is False
|
||||
def test_loopback_allowed(self, service):
|
||||
"""Deliberate change: the LAN-service policy permits loopback, because
|
||||
an openHAB/Node-RED bridge on the same host is the normal topology.
|
||||
|
||||
def test_link_local_blocked(self, service):
|
||||
assert service._validate_url("http://169.254.1.1/api") is False
|
||||
The previous check rejected a literal 127.0.0.1 while accepting the
|
||||
equivalent "localhost", so the same target was configurable one way and
|
||||
not the other. See test_outbound_url_ssrf_guards.py for the policy.
|
||||
"""
|
||||
assert service._validate_url("http://127.0.0.1/api") is True
|
||||
|
||||
def test_link_local_allowed(self, service):
|
||||
"""Also deliberate: a generic APIPA address is a LAN host like any
|
||||
other. The cloud-metadata address inside that range is blocked by
|
||||
name, not by rejecting the whole /16 — see test_metadata_blocked."""
|
||||
assert service._validate_url("http://169.254.1.1/api") is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://100.100.100.200/",
|
||||
"http://[fd00:ec2::254]/",
|
||||
"http://metadata.google.internal/",
|
||||
"http://[::ffff:169.254.169.254]/",
|
||||
"http://2130706433/",
|
||||
"http://0.0.0.0/",
|
||||
],
|
||||
)
|
||||
def test_metadata_and_encoded_targets_blocked(self, service, url):
|
||||
"""The gap the previous hand-rolled check left: anything that was not a
|
||||
bare IP literal fell through to True, and the literals it did parse were
|
||||
only tested for loopback/link-local."""
|
||||
assert service._validate_url(url) is False
|
||||
|
||||
def test_empty_hostname(self, service):
|
||||
assert service._validate_url("http:///api") is False
|
||||
@@ -405,6 +433,10 @@ class TestTestConnection:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_invalid_url(self, service):
|
||||
result = await service.test_connection("http://127.0.0.1/api")
|
||||
"""127.0.0.1 is now permitted (see TestURLValidation), so the rejection
|
||||
case here is a target that is out of policy under any topology. The
|
||||
error is the guard's own message rather than a fixed sentence, so the
|
||||
user learns which rule the URL broke."""
|
||||
result = await service.test_connection("http://169.254.169.254/latest/meta-data/")
|
||||
assert result["success"] is False
|
||||
assert "blocked" in result["error"].lower()
|
||||
assert "cloud metadata" in result["error"].lower()
|
||||
|
||||
@@ -40,6 +40,9 @@ from backend.app.api.routes._url_safety import assert_safe_lan_service_url
|
||||
from backend.app.schemas.auth import OIDCProviderCreate, OIDCProviderUpdate
|
||||
from backend.app.schemas.settings import LAN_SERVICE_URL_SETTINGS, AppSettingsUpdate
|
||||
from backend.app.services import notification_service as ns
|
||||
from backend.app.services.homeassistant import HomeAssistantService
|
||||
from backend.app.services.rest_smart_plug import RESTSmartPlugService
|
||||
from backend.app.services.tasmota import TasmotaService
|
||||
|
||||
# Dangerous under any topology — both tiers must reject all of these.
|
||||
UNIVERSALLY_BLOCKED = [
|
||||
@@ -54,6 +57,11 @@ UNIVERSALLY_BLOCKED = [
|
||||
"http://[::ffff:169.254.169.254]/",
|
||||
"http://0.0.0.0/",
|
||||
"http://239.255.255.250/",
|
||||
# The DNS-name form of the same target. Neither tier resolves hostnames,
|
||||
# but these are a fixed literal set, so matching them costs no lookup.
|
||||
"http://metadata.google.internal/",
|
||||
"http://METADATA.GOOGLE.INTERNAL/computeMetadata/v1/",
|
||||
"http://metadata.goog/",
|
||||
]
|
||||
|
||||
# The normal self-hosted topology — the LAN tier must permit all of these.
|
||||
@@ -374,3 +382,269 @@ async def test_test_config_refuses_metadata_targets_without_a_request(provider_t
|
||||
assert success is False
|
||||
assert called is False
|
||||
assert "cloud metadata" in message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Smart plugs: the same request-body-URL shape as the notification test endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# POST /smart-plugs/{ha,rest}/test-connection take their URL from the request
|
||||
# body and never persist it, so the schema-layer validator on ``ha_url`` does
|
||||
# not apply. Both are reachable with only ``SMART_PLUGS_CONTROL``, which the
|
||||
# default Operators group carries and which does NOT imply ``SETTINGS_UPDATE``
|
||||
# — identical to the notification case above.
|
||||
#
|
||||
# Both previously used hand-rolled checks that got the policy wrong in both
|
||||
# directions: the REST one rejected a literal ``127.0.0.1`` while allowing
|
||||
# every non-literal hostname, and the HA one matched three literal strings and
|
||||
# never parsed the hostname as an IP at all.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
|
||||
def test_rest_plug_guard_rejects_dangerous_targets(url: str):
|
||||
assert RESTSmartPlugService._validate_url(url) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", LAN_ALLOWED)
|
||||
def test_rest_plug_guard_permits_the_normal_self_hosted_topology(url: str):
|
||||
"""Includes literal 127.0.0.1, which the previous implementation rejected
|
||||
while accepting the equivalent "localhost" — a plug bridge on the same
|
||||
host could only be configured by spelling it one particular way."""
|
||||
assert RESTSmartPlugService._validate_url(url) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
|
||||
def test_ha_guard_rejects_dangerous_targets(url: str):
|
||||
assert HomeAssistantService._validate_url(url) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", LAN_ALLOWED)
|
||||
def test_ha_guard_permits_the_normal_self_hosted_topology(url: str):
|
||||
assert HomeAssistantService._validate_url(url) is not None
|
||||
|
||||
|
||||
def test_ha_guard_still_normalises_the_url_it_returns():
|
||||
"""Delegating the policy must not change what the caller gets back:
|
||||
scheme+host+port+path, with query and fragment dropped."""
|
||||
assert HomeAssistantService._validate_url("http://192.168.1.5:8123/base?x=1#f") == "http://192.168.1.5:8123/base"
|
||||
assert HomeAssistantService._validate_url("http://ha.lan") == "http://ha.lan"
|
||||
|
||||
|
||||
def test_ha_guard_keeps_ipv6_literals_bracketed():
|
||||
"""urlparse strips the brackets off an IPv6 host; re-emitting it without
|
||||
them yields an unparseable URL that httpx cannot dial."""
|
||||
assert HomeAssistantService._validate_url("http://[fd00::1]:8123/api") == "http://[fd00::1]:8123/api"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip", ["169.254.169.254", "100.100.100.200", "fd00:ec2::254", "0.0.0.0", "239.255.255.250"])
|
||||
def test_tasmota_guard_rejects_metadata_and_misuse_addresses(ip: str):
|
||||
"""Tasmota keeps its own stricter rule (bare IP literals only, loopback
|
||||
rejected — a plug is always a separate LAN device), but must not miss the
|
||||
destinations that are dangerous regardless of topology."""
|
||||
assert TasmotaService._validate_ip(ip) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip", ["::ffff:169.254.169.254", "::ffff:100.100.100.200"])
|
||||
def test_tasmota_guard_unwraps_ipv4_mapped_ipv6(ip: str):
|
||||
assert TasmotaService._validate_ip(ip) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip", ["192.168.1.50", "10.0.0.7", "172.16.4.9"])
|
||||
def test_tasmota_guard_still_permits_a_normal_lan_plug(ip: str):
|
||||
assert TasmotaService._validate_ip(ip) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip", ["127.0.0.1", "tasmota.local", "not-an-ip"])
|
||||
def test_tasmota_guard_keeps_failing_closed_on_non_lan_device_values(ip: str):
|
||||
"""Deliberately stricter than the shared LAN guard, and unchanged here."""
|
||||
assert TasmotaService._validate_ip(ip) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
["http://169.254.169.254/", "http://100.100.100.200/", "http://metadata.google.internal/"],
|
||||
)
|
||||
async def test_rest_test_connection_refuses_metadata_without_a_request(target: str, monkeypatch):
|
||||
"""End-to-end shape of the reported attack, mirroring the notification
|
||||
test above: an unsaved URL aimed at IMDS via the test endpoint must be
|
||||
refused before any HTTP call is made."""
|
||||
|
||||
def _fail_if_called(*_a, **_kw):
|
||||
raise AssertionError("outbound request should not have been attempted")
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _fail_if_called)
|
||||
result = await RESTSmartPlugService().test_connection(target, "GET", None)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "cloud metadata" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
["http://169.254.169.254", "http://100.100.100.200", "http://metadata.google.internal"],
|
||||
)
|
||||
async def test_ha_test_connection_refuses_metadata_without_a_request(target: str, monkeypatch):
|
||||
def _fail_if_called(*_a, **_kw):
|
||||
raise AssertionError("outbound request should not have been attempted")
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _fail_if_called)
|
||||
result = await HomeAssistantService().test_connection(target, "token")
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("target", ["http://169.254.169.254", "http://metadata.google.internal"])
|
||||
async def test_obico_test_connection_refuses_metadata_without_a_request(target: str, monkeypatch):
|
||||
"""Same shape again: obico_ml_url is guarded when saved via settings, but
|
||||
this route takes the URL from the request body and echoes the response."""
|
||||
from backend.app.services.obico_detection import ObicoDetectionService
|
||||
|
||||
def _fail_if_called(*_a, **_kw):
|
||||
raise AssertionError("outbound request should not have been attempted")
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _fail_if_called)
|
||||
result = await ObicoDetectionService().test_connection(target)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["body"] is None
|
||||
assert "cloud metadata" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drift backstop, part 2: URLs that arrive in a request body
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `test_every_url_setting_is_either_guarded_or_explicitly_exempt` above only
|
||||
# walks `AppSettingsUpdate`. That is why the notification test endpoint, and
|
||||
# then the two smart-plug test endpoints, each had to be found by hand: a URL
|
||||
# that arrives in a request body and is never persisted is not a settings
|
||||
# field, so nothing enumerated it. This walks the live route table instead.
|
||||
|
||||
|
||||
def _request_body_url_fields() -> set[tuple[str, str]]:
|
||||
"""Every (model, field) pair on a mutating route whose body carries a URL."""
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.app.main import app
|
||||
|
||||
found: set[tuple[str, str]] = set()
|
||||
for route in app.routes:
|
||||
if not isinstance(route, APIRoute) or not ({"POST", "PUT", "PATCH"} & set(route.methods or ())):
|
||||
continue
|
||||
for param in route.dependant.body_params:
|
||||
# FastAPI moved the resolved annotation from `type_` onto
|
||||
# `field_info.annotation`; read both so this can't silently
|
||||
# enumerate nothing (which would make the assertions vacuous).
|
||||
annotation = getattr(param, "type_", None)
|
||||
if annotation is None:
|
||||
annotation = getattr(getattr(param, "field_info", None), "annotation", None)
|
||||
if not (isinstance(annotation, type) and issubclass(annotation, BaseModel)):
|
||||
continue
|
||||
for field in annotation.model_fields:
|
||||
if field == "url" or field.endswith("_url"):
|
||||
found.add((annotation.__name__, field))
|
||||
return found
|
||||
|
||||
|
||||
# Guarded: the handler (or the service it calls) puts the value through one of
|
||||
# the two tiers before any request is issued.
|
||||
GUARDED_BODY_URLS = {
|
||||
("AppSettingsUpdate", "bambu_studio_api_url"),
|
||||
("AppSettingsUpdate", "ha_url"),
|
||||
("AppSettingsUpdate", "obico_ml_url"),
|
||||
("AppSettingsUpdate", "orcaslicer_api_url"),
|
||||
("AppSettingsUpdate", "spoolman_url"), # assert_safe_spoolman_url at each consumer
|
||||
("HATestConnectionRequest", "url"), # homeassistant._validate_url
|
||||
("RESTTestConnectionRequest", "url"), # rest_smart_plug._validate_url
|
||||
("TestConnectionRequest", "url"), # obico_detection.test_connection
|
||||
("OIDCProviderCreate", "issuer_url"), # public tier, via schemas.auth
|
||||
("OIDCProviderCreate", "icon_url"),
|
||||
("OIDCProviderUpdate", "issuer_url"),
|
||||
("OIDCProviderUpdate", "icon_url"),
|
||||
# Gitea/Forgejo derive their API base from this and request it with the
|
||||
# stored token, so it is a real fetch target — guarded in
|
||||
# github_backup._enforce_private_repo, which both POST and PATCH funnel through.
|
||||
("GitHubBackupConfigCreate", "repository_url"),
|
||||
("GitHubBackupConfigUpdate", "repository_url"),
|
||||
# SmartPlug{Create,Update} persist these; every read goes back out through
|
||||
# RESTSmartPlugService._send_request, which applies the same guard.
|
||||
("SmartPlugCreate", "rest_on_url"),
|
||||
("SmartPlugCreate", "rest_off_url"),
|
||||
("SmartPlugCreate", "rest_status_url"),
|
||||
("SmartPlugCreate", "rest_power_url"),
|
||||
("SmartPlugCreate", "rest_energy_url"),
|
||||
("SmartPlugUpdate", "rest_on_url"),
|
||||
("SmartPlugUpdate", "rest_off_url"),
|
||||
("SmartPlugUpdate", "rest_status_url"),
|
||||
("SmartPlugUpdate", "rest_power_url"),
|
||||
("SmartPlugUpdate", "rest_energy_url"),
|
||||
}
|
||||
|
||||
# Not a destination Bambuddy requests — no guard applies.
|
||||
NOT_A_FETCH_TARGET = {
|
||||
("AppSettingsUpdate", "external_url"), # Bambuddy's own address (see exempt list above)
|
||||
("AppSettingsUpdate", "ldap_server_url"), # ldap://, handed to an LDAP client
|
||||
("ProjectCreate", "url"), # stored link, rendered in the UI, never fetched
|
||||
("ProjectUpdate", "url"),
|
||||
("BOMItemCreate", "sourcing_url"), # stored supplier link, never fetched
|
||||
("BOMItemUpdate", "sourcing_url"),
|
||||
("MakerWorldResolveRequest", "url"), # parsed for a model id; fetches go to a pinned CDN allowlist
|
||||
("DeviceRegisterRequest", "backend_url"), # the device's view of Bambuddy's own address
|
||||
("HeartbeatRequest", "backend_url"),
|
||||
("SystemConfigRequest", "backend_url"),
|
||||
("ExternalLinkCreate", "url"), # sidebar link, rendered in the UI, never requested
|
||||
("ExternalLinkUpdate", "url"),
|
||||
("MaintenanceTypeCreate", "wiki_url"), # documentation link surfaced in the UI/notifications
|
||||
("MaintenanceTypeUpdate", "wiki_url"),
|
||||
("ArchiveUpdate", "external_url"), # stored source link for the model, never fetched
|
||||
}
|
||||
|
||||
# Genuinely unguarded, and deliberately recorded rather than quietly exempted.
|
||||
# These reach `external_camera.capture_frame`, which dials rtsp:// as well as
|
||||
# http(s):// — the LAN-service guard rejects any non-HTTP scheme, so wiring it
|
||||
# up as-is would break every RTSP camera. Closing these needs a scheme-aware
|
||||
# variant of the guard, not a one-line delegation.
|
||||
KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD = {
|
||||
("PrinterCreate", "external_camera_url"),
|
||||
("PrinterCreate", "external_camera_snapshot_url"),
|
||||
("PrinterUpdate", "external_camera_url"),
|
||||
("PrinterUpdate", "external_camera_snapshot_url"),
|
||||
}
|
||||
|
||||
|
||||
def test_the_route_walk_actually_finds_something():
|
||||
"""Guards the guard. If FastAPI's internals move again and the walk starts
|
||||
returning nothing, both assertions below pass vacuously and the backstop
|
||||
silently stops working — which is the exact failure it exists to prevent."""
|
||||
found = _request_body_url_fields()
|
||||
assert ("RESTTestConnectionRequest", "url") in found
|
||||
assert ("HATestConnectionRequest", "url") in found
|
||||
assert len(found) > 20
|
||||
|
||||
|
||||
def test_every_request_body_url_is_classified():
|
||||
"""A new URL-bearing request field can't land without a decision.
|
||||
|
||||
Add it to GUARDED_BODY_URLS once the handler runs it through a guard, or
|
||||
to NOT_A_FETCH_TARGET with the reason it is never requested. Do not add
|
||||
anything to KNOWN_UNGUARDED_* without also raising it.
|
||||
"""
|
||||
classified = GUARDED_BODY_URLS | NOT_A_FETCH_TARGET | KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD
|
||||
unclassified = _request_body_url_fields() - classified
|
||||
assert not unclassified, (
|
||||
f"Unclassified request-body URL field(s): {sorted(unclassified)}. Route the value "
|
||||
f"through a guard and list it in GUARDED_BODY_URLS, or list it in NOT_A_FETCH_TARGET "
|
||||
f"with the reason it is never fetched."
|
||||
)
|
||||
|
||||
|
||||
def test_classification_lists_do_not_drift_from_the_routes():
|
||||
"""The reverse direction: a stale entry means a route was renamed or
|
||||
removed and the list was not updated, which would hide the next one."""
|
||||
actual = _request_body_url_fields()
|
||||
stale = (GUARDED_BODY_URLS | NOT_A_FETCH_TARGET | KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD) - actual
|
||||
assert not stale, f"Classification entries no longer match any route: {sorted(stale)}"
|
||||
|
||||
@@ -44,6 +44,8 @@ def _state() -> SimpleNamespace:
|
||||
big_fan1_speed=0,
|
||||
big_fan2_speed=0,
|
||||
heatbreak_fan_speed=0,
|
||||
left_aux_fan_speed=None,
|
||||
exhaust_fan_present=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ def _fake_state(**overrides):
|
||||
"firmware_version": None,
|
||||
"gcode_file": None,
|
||||
"heatbreak_fan_speed": None,
|
||||
"left_aux_fan_speed": None,
|
||||
"exhaust_fan_present": False,
|
||||
"layer_num": None,
|
||||
"remaining_time": None,
|
||||
"speed_level": None,
|
||||
|
||||
Reference in New Issue
Block a user