test_config_backup.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. """运行数据备份/快照工具 (config_backup) 单元测试。"""
  2. import json
  3. import os
  4. import sys
  5. import pytest
  6. sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  7. from modules import config_backup # noqa: E402
  8. @pytest.fixture()
  9. def workdir(tmp_path):
  10. cfg = tmp_path / 'config'
  11. back = tmp_path / 'backups'
  12. cfg.mkdir()
  13. return cfg, back
  14. def write_text(path, text):
  15. with open(path, 'w', encoding='utf-8') as f:
  16. f.write(text)
  17. class TestWriteJsonAtomic:
  18. def test_write_and_read(self, workdir):
  19. cfg, _ = workdir
  20. path = str(cfg / 'demo.json')
  21. config_backup.write_json_atomic(path, {'a': 1, '中文': 'ok'})
  22. with open(path) as f:
  23. assert json.load(f) == {'a': 1, '中文': 'ok'}
  24. assert not os.path.exists(path + '.tmp')
  25. def test_read_json_file_missing_raises(self, workdir):
  26. cfg, _ = workdir
  27. with pytest.raises(FileNotFoundError):
  28. config_backup.read_json_file(str(cfg / 'nope.json'))
  29. class TestSnapshot:
  30. def test_snapshot_created_and_deduped(self, workdir):
  31. cfg, back = workdir
  32. path = str(cfg / 'dtu_config.json')
  33. write_text(path, '{"a": 1}')
  34. name = config_backup.snapshot_before_write(path, str(back), min_interval_s=0)
  35. assert name and os.path.exists(os.path.join(str(back), name))
  36. # 内容未变化 -> 去重
  37. assert config_backup.snapshot_before_write(path, str(back), min_interval_s=0) is None
  38. def test_snapshot_missing_file(self, workdir):
  39. cfg, back = workdir
  40. assert config_backup.snapshot_before_write(str(cfg / 'none.json'), str(back)) is None
  41. def test_snapshot_throttled(self, workdir):
  42. cfg, back = workdir
  43. path = str(cfg / 'dtu_config.json')
  44. write_text(path, '{"a": 1}')
  45. assert config_backup.snapshot_before_write(path, str(back), min_interval_s=3600) is not None
  46. write_text(path, '{"a": 2}')
  47. # 距上一份快照不足节流时间 -> 跳过
  48. assert config_backup.snapshot_before_write(path, str(back), min_interval_s=3600) is None
  49. def test_snapshot_rotation(self, workdir):
  50. cfg, back = workdir
  51. path = str(cfg / 'devices.json')
  52. for i in range(5):
  53. write_text(path, json.dumps({'round': i}))
  54. config_backup.snapshot_before_write(path, str(back), keep=2, min_interval_s=0)
  55. snaps = config_backup.list_snapshots(str(back), stem='devices')
  56. assert len(snaps) == 2
  57. def test_snapshot_stem_and_filter(self, workdir):
  58. cfg, back = workdir
  59. p1 = str(cfg / 'dtu_config.json')
  60. p2 = str(cfg / 'mqtt_config.json')
  61. write_text(p1, '{"a": 1}')
  62. write_text(p2, '{"b": 2}')
  63. n1 = config_backup.snapshot_before_write(p1, str(back), min_interval_s=0)
  64. n2 = config_backup.snapshot_before_write(p2, str(back), min_interval_s=0)
  65. assert config_backup.snapshot_stem(n1) == 'dtu_config'
  66. assert config_backup.snapshot_stem(n2) == 'mqtt_config'
  67. assert len(config_backup.list_snapshots(str(back), stem='dtu_config')) == 1
  68. def test_same_second_collision_suffix(self, workdir):
  69. cfg, back = workdir
  70. path = str(cfg / 'dtu_config.json')
  71. write_text(path, '{"a": 1}')
  72. n1 = config_backup.snapshot_before_write(path, str(back), min_interval_s=0, now=1000.0)
  73. write_text(path, '{"a": 2}')
  74. n2 = config_backup.snapshot_before_write(path, str(back), min_interval_s=0, now=1000.0)
  75. assert n1 != n2
  76. assert config_backup.snapshot_stem(n2) == 'dtu_config'
  77. def test_read_and_delete_snapshot(self, workdir):
  78. cfg, back = workdir
  79. path = str(cfg / 'serial_config.json')
  80. write_text(path, '{"port": "COM1"}')
  81. name = config_backup.snapshot_before_write(path, str(back), min_interval_s=0)
  82. data, err = config_backup.read_snapshot(name, str(back))
  83. assert err is None and data == {'port': 'COM1'}
  84. ok, _ = config_backup.delete_snapshot(name, str(back))
  85. assert ok and not os.path.exists(os.path.join(str(back), name))
  86. def test_reject_path_traversal(self, workdir):
  87. _, back = workdir
  88. for bad in ('../etc/passwd', 'a/b.json', '..{}', 'x.json'):
  89. assert config_backup.read_snapshot(bad, str(back))[1] is not None
  90. assert config_backup.delete_snapshot(bad, str(back))[0] is False
  91. def test_latest_snapshot_content(self, workdir):
  92. cfg, back = workdir
  93. path = str(cfg / 'devices.json')
  94. write_text(path, '{"aa": 1}')
  95. config_backup.snapshot_before_write(path, str(back), min_interval_s=0)
  96. data, name = config_backup.latest_snapshot_content(path, str(back))
  97. assert data == {'aa': 1} and name
  98. class TestBundle:
  99. def _bundle(self):
  100. return config_backup.build_bundle(
  101. {
  102. 'dtu_config': {'dtu_id': 'dtu_001', 'port_mappings': {'PANEL_dtu_001_1': {'1': 'AABBCCDDEEFF'}}},
  103. 'serial_config': {'port': '/dev/ttyS8', 'baudrate': 9600},
  104. 'mqtt_config': {'broker': 'xt.wenhq.top', 'port': 8581},
  105. 'devices': {'18' + '00' * 10 + '59': 1},
  106. 'system_env': 'DHT11_ENABLED=true\n',
  107. },
  108. device_meta={'dtu_id': 'dtu_001'},
  109. uid_prefix='a1b2',
  110. )
  111. def test_build_and_validate_ok(self):
  112. bundle = self._bundle()
  113. assert bundle['format'] == config_backup.BUNDLE_FORMAT
  114. assert bundle['effective_uid_prefix'] == 'A1B2'
  115. ok, msg = config_backup.validate_bundle(bundle)
  116. assert ok, msg
  117. def test_validate_rejects_wrong_format(self):
  118. assert config_backup.validate_bundle({'format': 'other'})[0] is False
  119. assert config_backup.validate_bundle([])[0] is False
  120. def test_validate_rejects_unknown_file_key(self):
  121. bundle = self._bundle()
  122. bundle['files']['shadow'] = {'x': 1}
  123. ok, msg = config_backup.validate_bundle(bundle)
  124. assert not ok and '未知文件项' in msg
  125. def test_validate_rejects_bad_devices(self):
  126. bundle = self._bundle()
  127. bundle['files']['devices'] = {'not-a-uid': 1}
  128. assert config_backup.validate_bundle(bundle)[0] is False
  129. bundle = self._bundle()
  130. bundle['files']['devices'] = {'a' * 24: 999}
  131. assert config_backup.validate_bundle(bundle)[0] is False
  132. def test_validate_rejects_bad_address_range(self):
  133. bundle = self._bundle()
  134. bundle['files']['dtu_config']['address_range'] = {'start': 50, 'end': 1}
  135. assert config_backup.validate_bundle(bundle)[0] is False
  136. bundle['files']['dtu_config']['address_range'] = {'start': 1, 'end': 50}
  137. assert config_backup.validate_bundle(bundle)[0] is True
  138. def test_validate_accepts_partial_files(self):
  139. bundle = config_backup.build_bundle({'devices': {'a' * 24: 2}})
  140. assert config_backup.validate_bundle(bundle)[0] is True
  141. def test_validate_rejects_empty_files(self):
  142. bundle = config_backup.build_bundle({'dtu_config': None, 'devices': {}, 'system_env': ''})
  143. assert config_backup.validate_bundle(bundle)[0] is False