test_local_config.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. """
  2. Tests for backend.app.core.local_config — the reader for
  3. /etc/bambuddy/local.toml that the appliance setup wizard writes.
  4. Defensive on bad input: every failure mode returns an empty dict
  5. (never raises), so a malformed file never blocks startup.
  6. """
  7. from __future__ import annotations
  8. from pathlib import Path
  9. import pytest
  10. from backend.app.core.local_config import read_local_toml, read_ntp_gate
  11. def test_missing_file_returns_empty(tmp_path: Path):
  12. assert read_local_toml(tmp_path / "nope.toml") == {}
  13. def test_empty_file_returns_empty(tmp_path: Path):
  14. path = tmp_path / "local.toml"
  15. path.write_text("")
  16. assert read_local_toml(path) == {}
  17. def test_comment_only_file_returns_empty(tmp_path: Path):
  18. path = tmp_path / "local.toml"
  19. path.write_text("# Written by bambuddy-wizard during firstboot.\n")
  20. assert read_local_toml(path) == {}
  21. def test_full_config_parses(tmp_path: Path):
  22. path = tmp_path / "local.toml"
  23. path.write_text(
  24. "# Written by bambuddy-wizard during firstboot.\n"
  25. 'hostname = "workshop-pi"\n'
  26. 'timezone = "Europe/Berlin"\n'
  27. 'locale = "de"\n'
  28. )
  29. result = read_local_toml(path)
  30. assert result == {
  31. "hostname": "workshop-pi",
  32. "timezone": "Europe/Berlin",
  33. "locale": "de",
  34. }
  35. def test_partial_config_only_returns_present_keys(tmp_path: Path):
  36. path = tmp_path / "local.toml"
  37. path.write_text('locale = "ja"\n')
  38. result = read_local_toml(path)
  39. assert result == {"locale": "ja"}
  40. assert "hostname" not in result
  41. assert "timezone" not in result
  42. def test_invalid_toml_returns_empty(tmp_path: Path, caplog: pytest.LogCaptureFixture):
  43. path = tmp_path / "local.toml"
  44. path.write_text("not = valid = toml = at all\n")
  45. result = read_local_toml(path)
  46. assert result == {}
  47. assert any("could not be parsed" in r.message for r in caplog.records)
  48. def test_non_string_value_is_dropped(tmp_path: Path, caplog: pytest.LogCaptureFixture):
  49. path = tmp_path / "local.toml"
  50. path.write_text(
  51. "hostname = 42\n" # not a string
  52. 'locale = "de"\n'
  53. )
  54. result = read_local_toml(path)
  55. assert result == {"locale": "de"}
  56. assert any("expected str" in r.message for r in caplog.records)
  57. def test_unknown_keys_are_ignored(tmp_path: Path):
  58. """A hand-edited config with extra keys must not leak them to the response."""
  59. path = tmp_path / "local.toml"
  60. path.write_text('locale = "de"\nunknown_key = "value"\nadmin_password = "should not surface"\n')
  61. result = read_local_toml(path)
  62. assert set(result.keys()) <= {"hostname", "timezone", "locale"}
  63. assert "admin_password" not in result
  64. def test_escaped_characters_round_trip(tmp_path: Path):
  65. """The wizard escapes backslash and quote when writing; the reader parses them back."""
  66. path = tmp_path / "local.toml"
  67. path.write_text('hostname = "with\\"quote"\n')
  68. result = read_local_toml(path)
  69. assert result == {"hostname": 'with"quote'}
  70. # ---------------------------------------------------------------------------
  71. # read_ntp_gate
  72. # ---------------------------------------------------------------------------
  73. def test_ntp_gate_missing_returns_none(tmp_path: Path):
  74. assert read_ntp_gate(tmp_path / "absent") is None
  75. def test_ntp_gate_ok(tmp_path: Path):
  76. path = tmp_path / "time-synced"
  77. path.write_text("ok\n")
  78. assert read_ntp_gate(path) == "ok"
  79. def test_ntp_gate_warning(tmp_path: Path):
  80. path = tmp_path / "time-synced"
  81. path.write_text("warning: ntp sync timed out\n")
  82. assert read_ntp_gate(path) == "warning"
  83. def test_ntp_gate_warning_no_suffix(tmp_path: Path):
  84. """Just 'warning' on its own is also accepted."""
  85. path = tmp_path / "time-synced"
  86. path.write_text("warning\n")
  87. assert read_ntp_gate(path) == "warning"
  88. def test_ntp_gate_empty_returns_none(tmp_path: Path):
  89. """Empty / surprise content is treated as unknown rather than misclassified."""
  90. path = tmp_path / "time-synced"
  91. path.write_text("")
  92. assert read_ntp_gate(path) is None
  93. def test_ntp_gate_unknown_marker_returns_none(tmp_path: Path):
  94. path = tmp_path / "time-synced"
  95. path.write_text("synced via remote NTP\n") # neither 'ok' nor 'warning'
  96. assert read_ntp_gate(path) is None
  97. def test_ntp_gate_strips_whitespace(tmp_path: Path):
  98. """Leading whitespace shouldn't trick a startswith check."""
  99. path = tmp_path / "time-synced"
  100. path.write_text(" ok\n")
  101. assert read_ntp_gate(path) == "ok"
  102. def test_ntp_gate_binary_garbage_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture):
  103. """Defensive read mode survives non-utf8 content without crashing."""
  104. path = tmp_path / "time-synced"
  105. path.write_bytes(b"\xff\xfe\x00\x01ok\n")
  106. # errors="replace" maps the bytes through but the prefix is no longer 'ok'.
  107. assert read_ntp_gate(path) is None