test_spoolbuddy_schema_validation.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. """Unit tests for SpoolBuddy schema validation (security fixes M2, M4).
  2. Tests Pydantic model validation without requiring a running server or DB.
  3. """
  4. import pytest
  5. from pydantic import ValidationError
  6. from backend.app.schemas.spoolbuddy import (
  7. DeviceRegisterRequest,
  8. HeartbeatRequest,
  9. ScaleReadingRequest,
  10. WriteTagResultRequest,
  11. )
  12. # ---------------------------------------------------------------------------
  13. # M2 — HeartbeatRequest: system_stats size limit (4096 bytes)
  14. # ---------------------------------------------------------------------------
  15. class TestHeartbeatSystemStatsValidation:
  16. def test_none_accepted(self):
  17. req = HeartbeatRequest(system_stats=None)
  18. assert req.system_stats is None
  19. def test_small_dict_accepted(self):
  20. req = HeartbeatRequest(system_stats={"cpu": 12.5, "mem": 60.0})
  21. assert req.system_stats["cpu"] == 12.5
  22. def test_oversized_dict_rejected(self):
  23. """system_stats exceeding 4096 bytes JSON-encoded must be rejected (M2)."""
  24. huge = {"data": "x" * 5000}
  25. with pytest.raises(ValidationError, match="4096"):
  26. HeartbeatRequest(system_stats=huge)
  27. def test_exactly_4096_bytes_accepted(self):
  28. """A dict whose JSON is exactly 4096 bytes must pass."""
  29. import json
  30. # Build a dict whose JSON is exactly 4096 bytes
  31. filler = "x" * (4096 - len('{"k": ""}'))
  32. d = {"k": filler}
  33. assert len(json.dumps(d)) == 4096
  34. req = HeartbeatRequest(system_stats=d)
  35. assert req.system_stats is not None
  36. def test_one_byte_over_limit_rejected(self):
  37. import json
  38. filler = "x" * (4097 - len('{"k": ""}'))
  39. d = {"k": filler}
  40. assert len(json.dumps(d)) == 4097
  41. with pytest.raises(ValidationError):
  42. HeartbeatRequest(system_stats=d)
  43. # ---------------------------------------------------------------------------
  44. # M4 — DeviceRegisterRequest: max_length on device-sourced string fields
  45. # ---------------------------------------------------------------------------
  46. class TestDeviceRegisterRequestValidation:
  47. VALID_BASE = {"device_id": "dev1", "hostname": "spoolbuddy.local", "ip_address": "192.168.1.50"}
  48. def test_valid_minimal_accepted(self):
  49. req = DeviceRegisterRequest(**self.VALID_BASE)
  50. assert req.device_id == "dev1"
  51. def test_firmware_version_too_long_rejected(self):
  52. with pytest.raises(ValidationError):
  53. DeviceRegisterRequest(**self.VALID_BASE, firmware_version="x" * 21)
  54. def test_firmware_version_at_max_accepted(self):
  55. req = DeviceRegisterRequest(**self.VALID_BASE, firmware_version="x" * 20)
  56. assert req.firmware_version == "x" * 20
  57. def test_nfc_reader_type_too_long_rejected(self):
  58. with pytest.raises(ValidationError):
  59. DeviceRegisterRequest(**self.VALID_BASE, nfc_reader_type="x" * 21)
  60. def test_nfc_connection_too_long_rejected(self):
  61. with pytest.raises(ValidationError):
  62. DeviceRegisterRequest(**self.VALID_BASE, nfc_connection="x" * 21)
  63. def test_backend_url_too_long_rejected(self):
  64. with pytest.raises(ValidationError):
  65. DeviceRegisterRequest(**self.VALID_BASE, backend_url="http://" + "x" * 249)
  66. def test_backend_url_at_max_accepted(self):
  67. url = "http://" + "x" * (255 - len("http://"))
  68. req = DeviceRegisterRequest(**self.VALID_BASE, backend_url=url)
  69. assert req.backend_url == url
  70. def test_device_id_too_long_rejected(self):
  71. with pytest.raises(ValidationError):
  72. DeviceRegisterRequest(device_id="x" * 51, hostname="h", ip_address="1.2.3.4")
  73. # ---------------------------------------------------------------------------
  74. # M4 — WriteTagResultRequest: device_id max_length
  75. # ---------------------------------------------------------------------------
  76. class TestWriteTagResultRequestValidation:
  77. def test_device_id_too_long_rejected(self):
  78. with pytest.raises(ValidationError):
  79. WriteTagResultRequest(device_id="x" * 51, spool_id=1, tag_uid="AABBCCDD", success=True)
  80. def test_device_id_at_max_accepted(self):
  81. req = WriteTagResultRequest(device_id="x" * 50, spool_id=1, tag_uid="AABBCCDD", success=True)
  82. assert len(req.device_id) == 50
  83. def test_tag_uid_hex_pattern_accepted(self):
  84. req = WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="AABBCCDD", success=True)
  85. assert req.tag_uid == "AABBCCDD"
  86. def test_tag_uid_non_hex_rejected(self):
  87. """Non-hex characters in tag_uid must be rejected (prevents injection via NFC write-back)."""
  88. with pytest.raises(ValidationError):
  89. WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="AABB; DROP", success=True)
  90. def test_tag_uid_too_short_rejected(self):
  91. with pytest.raises(ValidationError):
  92. WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="AABB", success=True)
  93. def test_tag_uid_max_length_accepted(self):
  94. req = WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="A" * 30, success=True)
  95. assert len(req.tag_uid) == 30
  96. def test_tag_uid_over_max_length_rejected(self):
  97. with pytest.raises(ValidationError):
  98. WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="A" * 31, success=True)
  99. # ---------------------------------------------------------------------------
  100. # M4 — ScaleReadingRequest: weight_grams accepts any float (raw uncalibrated ADC)
  101. # ---------------------------------------------------------------------------
  102. class TestScaleReadingRequestValidation:
  103. def test_valid_weight_accepted(self):
  104. req = ScaleReadingRequest(device_id="sb1", weight_grams=250.0)
  105. assert req.weight_grams == 250.0
  106. def test_zero_weight_accepted(self):
  107. req = ScaleReadingRequest(device_id="sb1", weight_grams=0.0)
  108. assert req.weight_grams == 0.0
  109. def test_large_raw_adc_weight_accepted(self):
  110. # Uncalibrated scale with factor=1.0 produces raw ADC values in the millions
  111. req = ScaleReadingRequest(device_id="sb1", weight_grams=5_000_000.0)
  112. assert req.weight_grams == 5_000_000.0
  113. def test_negative_weight_accepted(self):
  114. # Scale can legitimately read negative values when tare is not calibrated
  115. req = ScaleReadingRequest(device_id="sb1", weight_grams=-50_000.0)
  116. assert req.weight_grams == -50_000.0
  117. def test_nan_weight_rejected(self):
  118. import math
  119. with pytest.raises(ValidationError):
  120. ScaleReadingRequest(device_id="sb1", weight_grams=math.nan)
  121. def test_inf_weight_rejected(self):
  122. import math
  123. with pytest.raises(ValidationError):
  124. ScaleReadingRequest(device_id="sb1", weight_grams=math.inf)