test_vp_wire_dump.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. """Tests for the env-flagged VP wire-payload dump helper used to triage
  2. shape-of-payload bugs like #1622."""
  3. import json
  4. import os
  5. from unittest.mock import patch
  6. import pytest
  7. from backend.app.core.config import settings as app_settings
  8. from backend.app.services.virtual_printer import _debug
  9. @pytest.fixture
  10. def _isolated_log_dir(tmp_path, monkeypatch):
  11. with patch.object(app_settings, "log_dir", tmp_path):
  12. yield tmp_path
  13. def test_disabled_by_default_writes_nothing(_isolated_log_dir, monkeypatch):
  14. monkeypatch.delenv("BAMBUDDY_VP_DUMP_WIRE", raising=False)
  15. _debug.dump_wire("VP1", "out", {"hello": "world"})
  16. assert not (_isolated_log_dir / "vp_wire").exists()
  17. def test_enabled_writes_dict_as_pretty_json(_isolated_log_dir, monkeypatch):
  18. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  19. payload = {"print": {"ams": {"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]}}}
  20. _debug.dump_wire("Bambuddy P1S", "out", payload)
  21. out = _isolated_log_dir / "vp_wire" / "Bambuddy_P1S_out.json"
  22. assert out.is_file()
  23. assert json.loads(out.read_text()) == payload
  24. def test_overwrites_on_repeat_call(_isolated_log_dir, monkeypatch):
  25. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  26. _debug.dump_wire("VP1", "out", {"v": 1})
  27. _debug.dump_wire("VP1", "out", {"v": 2})
  28. out = _isolated_log_dir / "vp_wire" / "VP1_out.json"
  29. assert json.loads(out.read_text()) == {"v": 2}
  30. def test_separate_files_per_direction(_isolated_log_dir, monkeypatch):
  31. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  32. _debug.dump_wire("VP1", "in", {"src": "printer"})
  33. _debug.dump_wire("VP1", "out", {"src": "slicer"})
  34. assert (_isolated_log_dir / "vp_wire" / "VP1_in.json").is_file()
  35. assert (_isolated_log_dir / "vp_wire" / "VP1_out.json").is_file()
  36. def test_sanitizes_path_traversal_in_vp_name(_isolated_log_dir, monkeypatch):
  37. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  38. _debug.dump_wire("../../etc/passwd", "out", {"x": 1})
  39. vp_wire = _isolated_log_dir / "vp_wire"
  40. files = list(vp_wire.glob("*"))
  41. assert len(files) == 1
  42. # The actual safety property: the written file is inside vp_wire/.
  43. # `..` as a substring of a single filename component is harmless because
  44. # the path separator (/) is collapsed to _ before construction.
  45. assert files[0].resolve().parent == vp_wire.resolve()
  46. assert "/" not in files[0].name
  47. def test_empty_vp_name_falls_back_to_default(_isolated_log_dir, monkeypatch):
  48. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  49. _debug.dump_wire("", "out", {"x": 1})
  50. assert (_isolated_log_dir / "vp_wire" / "vp_out.json").is_file()
  51. def test_bytes_payload_decoded(_isolated_log_dir, monkeypatch):
  52. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  53. _debug.dump_wire("VP1", "in", b'{"raw": true}')
  54. out = _isolated_log_dir / "vp_wire" / "VP1_in.json"
  55. assert out.read_text() == '{"raw": true}'
  56. def test_unwritable_dir_is_swallowed(_isolated_log_dir, monkeypatch):
  57. """A debug-instrumentation failure must not crash the bridge or 1Hz loop."""
  58. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  59. # Point log_dir at a location that mkdir refuses (a regular file occupying
  60. # the path). Failure must be swallowed.
  61. blocker = _isolated_log_dir / "blocker"
  62. blocker.write_text("not a dir")
  63. with patch.object(app_settings, "log_dir", blocker):
  64. _debug.dump_wire("VP1", "out", {"x": 1}) # must not raise
  65. @pytest.mark.parametrize("flag_value", ["0", "false", "off", "", "no"])
  66. def test_falsy_flag_values_disable(_isolated_log_dir, monkeypatch, flag_value):
  67. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", flag_value)
  68. _debug.dump_wire("VP1", "out", {"x": 1})
  69. assert not (_isolated_log_dir / "vp_wire").exists()
  70. @pytest.mark.parametrize("flag_value", ["1", "true", "TRUE", "yes", "on", "On"])
  71. def test_truthy_flag_values_enable(_isolated_log_dir, monkeypatch, flag_value):
  72. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", flag_value)
  73. _debug.dump_wire("VP1", "out", {"x": 1})
  74. assert (_isolated_log_dir / "vp_wire" / "VP1_out.json").is_file()
  75. def test_idempotent_atomic_no_partial_file_visible(_isolated_log_dir, monkeypatch):
  76. """tmp+rename pattern means a reader never sees a half-written .json file."""
  77. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  78. _debug.dump_wire("VP1", "out", {"x": 1})
  79. files = sorted(p.name for p in (_isolated_log_dir / "vp_wire").iterdir())
  80. # No leftover .tmp file after a successful write.
  81. assert files == ["VP1_out.json"]
  82. def test_env_check_is_per_call_not_module_load(_isolated_log_dir, monkeypatch):
  83. """Flag toggle must take effect on the next call without restarting; we
  84. re-read the env var inside ``dump_wire`` rather than caching at import."""
  85. monkeypatch.delenv("BAMBUDDY_VP_DUMP_WIRE", raising=False)
  86. _debug.dump_wire("VP1", "out", {"v": 1})
  87. assert not (_isolated_log_dir / "vp_wire").exists()
  88. os.environ["BAMBUDDY_VP_DUMP_WIRE"] = "1"
  89. try:
  90. _debug.dump_wire("VP1", "out", {"v": 2})
  91. assert (_isolated_log_dir / "vp_wire" / "VP1_out.json").is_file()
  92. finally:
  93. os.environ.pop("BAMBUDDY_VP_DUMP_WIRE", None)
  94. # --- append_event (command-flow trace) --------------------------------------
  95. def _read_jsonl(path):
  96. return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
  97. def test_append_event_disabled_by_default_writes_nothing(_isolated_log_dir, monkeypatch):
  98. monkeypatch.delenv("BAMBUDDY_VP_DUMP_WIRE", raising=False)
  99. _debug.append_event("VP1", "slicer_to_bridge", "device/X/request", {"hello": "world"})
  100. assert not (_isolated_log_dir / "vp_wire").exists()
  101. def test_append_event_appends_one_jsonl_line_per_call(_isolated_log_dir, monkeypatch):
  102. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  103. _debug.append_event("VP1", "slicer_to_bridge", "device/X/request", {"print": {"command": "ams_filament_setting"}})
  104. _debug.append_event(
  105. "VP1", "printer_to_slicer", "device/X/report", {"print": {"command": "ams_filament_setting", "result": "ok"}}
  106. )
  107. path = _isolated_log_dir / "vp_wire" / "VP1_cmd.jsonl"
  108. rows = _read_jsonl(path)
  109. assert len(rows) == 2
  110. assert rows[0]["dir"] == "slicer_to_bridge"
  111. assert rows[0]["topic"] == "device/X/request"
  112. assert rows[0]["cmd"] == "print.ams_filament_setting"
  113. assert rows[0]["payload"] == {"print": {"command": "ams_filament_setting"}}
  114. assert rows[1]["dir"] == "printer_to_slicer"
  115. assert rows[1]["cmd"] == "print.ams_filament_setting"
  116. def test_append_event_parses_bytes_payload(_isolated_log_dir, monkeypatch):
  117. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  118. raw = b'{"info": {"command": "get_version", "sequence_id": "0"}}'
  119. _debug.append_event("VP1", "slicer_to_bridge", "device/X/request", raw)
  120. rows = _read_jsonl(_isolated_log_dir / "vp_wire" / "VP1_cmd.jsonl")
  121. assert rows[0]["payload"] == {"info": {"command": "get_version", "sequence_id": "0"}}
  122. assert rows[0]["cmd"] == "info.get_version"
  123. def test_append_event_unparseable_payload_kept_as_raw(_isolated_log_dir, monkeypatch):
  124. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  125. _debug.append_event("VP1", "printer_to_slicer", "device/X/report", b"not-json-just-bytes")
  126. rows = _read_jsonl(_isolated_log_dir / "vp_wire" / "VP1_cmd.jsonl")
  127. assert rows[0]["payload"] == {"raw": "not-json-just-bytes"}
  128. assert rows[0]["cmd"] == "?"
  129. def test_append_event_handles_trailing_null_from_orca(_isolated_log_dir, monkeypatch):
  130. """Same #927 quirk as ``_handle_publish``: OrcaSlicer can ship publishes with a
  131. trailing C-string null. The trace must still parse so the dump matches what
  132. the bridge actually saw, not raw text."""
  133. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  134. _debug.append_event("VP1", "slicer_to_bridge", "device/X/request", b'{"info":{"command":"get_version"}}\x00')
  135. rows = _read_jsonl(_isolated_log_dir / "vp_wire" / "VP1_cmd.jsonl")
  136. assert rows[0]["payload"] == {"info": {"command": "get_version"}}
  137. def test_append_event_sanitizes_vp_name(_isolated_log_dir, monkeypatch):
  138. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  139. _debug.append_event("../../etc/passwd", "slicer_to_bridge", "device/X/request", {"x": 1})
  140. files = list((_isolated_log_dir / "vp_wire").glob("*_cmd.jsonl"))
  141. assert len(files) == 1
  142. assert "/" not in files[0].name
  143. def test_append_event_includes_iso_timestamp(_isolated_log_dir, monkeypatch):
  144. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  145. _debug.append_event("VP1", "slicer_to_bridge", "device/X/request", {"x": 1})
  146. rows = _read_jsonl(_isolated_log_dir / "vp_wire" / "VP1_cmd.jsonl")
  147. ts = rows[0]["ts"]
  148. # ISO-8601 with timezone (Z or +00:00 suffix from UTC).
  149. assert "T" in ts and (ts.endswith("+00:00") or ts.endswith("Z"))
  150. def test_append_event_failure_swallowed(_isolated_log_dir, monkeypatch):
  151. """Debug instrumentation must never crash the bridge or slicer loop."""
  152. monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
  153. blocker = _isolated_log_dir / "blocker"
  154. blocker.write_text("not a dir")
  155. with patch.object(app_settings, "log_dir", blocker):
  156. _debug.append_event("VP1", "slicer_to_bridge", "device/X/request", {"x": 1}) # must not raise