mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Extends the appliance endpoint that landed in the previous commit with a
time_synced field, sourced from /run/bambuddy/time-synced (the appliance's
ntp-gate.sh writes this once chronyd reports sync, or with a "warning"
marker after the 3-minute timeout). The RPi 5 has no battery-backed RTC,
so on a fresh boot the system clock is wrong until NTP catches up -- JWT
expiries and TLS certificate validity windows depend on this being right.
Exposing the gate lets the SPA render a "time not synced" indicator while
that's still true and clear it once "ok" comes through.
backend/app/core/local_config.py
New read_ntp_gate(path) function alongside read_local_toml. Three states:
"ok" chrony reported sync within the 3-minute window
"warning" 3-minute timeout elapsed without sync; user already waited
and the wizard proceeded with a degraded clock
None file absent (non-appliance install), OSError, empty content,
unknown marker, or binary garbage -- "unknown / don't gate"
Defensive read mode (errors="replace") survives non-utf8 content without
crashing. Module docstring broadened from "local.toml reader" to "small
readers for appliance-set state files".
backend/app/api/routes/system.py
/system/appliance now returns:
{hostname, timezone, locale, time_synced}
with the same no-auth posture: bootstrap surfaces (i18n init, time-sync
banner) read this before auth might be set up, and the contents are
non-secret (user-set defaults + a public sync flag). The endpoint
docstring expands to explain the RTC motivation -- otherwise the
time_synced field reads like a leftover.
149 lines
4.8 KiB
Python
149 lines
4.8 KiB
Python
"""
|
|
Tests for backend.app.core.local_config — the reader for
|
|
/etc/bambuddy/local.toml that the appliance setup wizard writes.
|
|
|
|
Defensive on bad input: every failure mode returns an empty dict
|
|
(never raises), so a malformed file never blocks startup.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from backend.app.core.local_config import read_local_toml, read_ntp_gate
|
|
|
|
|
|
def test_missing_file_returns_empty(tmp_path: Path):
|
|
assert read_local_toml(tmp_path / "nope.toml") == {}
|
|
|
|
|
|
def test_empty_file_returns_empty(tmp_path: Path):
|
|
path = tmp_path / "local.toml"
|
|
path.write_text("")
|
|
assert read_local_toml(path) == {}
|
|
|
|
|
|
def test_comment_only_file_returns_empty(tmp_path: Path):
|
|
path = tmp_path / "local.toml"
|
|
path.write_text("# Written by bambuddy-wizard during firstboot.\n")
|
|
assert read_local_toml(path) == {}
|
|
|
|
|
|
def test_full_config_parses(tmp_path: Path):
|
|
path = tmp_path / "local.toml"
|
|
path.write_text(
|
|
"# Written by bambuddy-wizard during firstboot.\n"
|
|
'hostname = "workshop-pi"\n'
|
|
'timezone = "Europe/Berlin"\n'
|
|
'locale = "de"\n'
|
|
)
|
|
result = read_local_toml(path)
|
|
assert result == {
|
|
"hostname": "workshop-pi",
|
|
"timezone": "Europe/Berlin",
|
|
"locale": "de",
|
|
}
|
|
|
|
|
|
def test_partial_config_only_returns_present_keys(tmp_path: Path):
|
|
path = tmp_path / "local.toml"
|
|
path.write_text('locale = "ja"\n')
|
|
result = read_local_toml(path)
|
|
assert result == {"locale": "ja"}
|
|
assert "hostname" not in result
|
|
assert "timezone" not in result
|
|
|
|
|
|
def test_invalid_toml_returns_empty(tmp_path: Path, caplog: pytest.LogCaptureFixture):
|
|
path = tmp_path / "local.toml"
|
|
path.write_text("not = valid = toml = at all\n")
|
|
result = read_local_toml(path)
|
|
assert result == {}
|
|
assert any("could not be parsed" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_non_string_value_is_dropped(tmp_path: Path, caplog: pytest.LogCaptureFixture):
|
|
path = tmp_path / "local.toml"
|
|
path.write_text(
|
|
"hostname = 42\n" # not a string
|
|
'locale = "de"\n'
|
|
)
|
|
result = read_local_toml(path)
|
|
assert result == {"locale": "de"}
|
|
assert any("expected str" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_unknown_keys_are_ignored(tmp_path: Path):
|
|
"""A hand-edited config with extra keys must not leak them to the response."""
|
|
path = tmp_path / "local.toml"
|
|
path.write_text('locale = "de"\nunknown_key = "value"\nadmin_password = "should not surface"\n')
|
|
result = read_local_toml(path)
|
|
assert set(result.keys()) <= {"hostname", "timezone", "locale"}
|
|
assert "admin_password" not in result
|
|
|
|
|
|
def test_escaped_characters_round_trip(tmp_path: Path):
|
|
"""The wizard escapes backslash and quote when writing; the reader parses them back."""
|
|
path = tmp_path / "local.toml"
|
|
path.write_text('hostname = "with\\"quote"\n')
|
|
result = read_local_toml(path)
|
|
assert result == {"hostname": 'with"quote'}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# read_ntp_gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_ntp_gate_missing_returns_none(tmp_path: Path):
|
|
assert read_ntp_gate(tmp_path / "absent") is None
|
|
|
|
|
|
def test_ntp_gate_ok(tmp_path: Path):
|
|
path = tmp_path / "time-synced"
|
|
path.write_text("ok\n")
|
|
assert read_ntp_gate(path) == "ok"
|
|
|
|
|
|
def test_ntp_gate_warning(tmp_path: Path):
|
|
path = tmp_path / "time-synced"
|
|
path.write_text("warning: ntp sync timed out\n")
|
|
assert read_ntp_gate(path) == "warning"
|
|
|
|
|
|
def test_ntp_gate_warning_no_suffix(tmp_path: Path):
|
|
"""Just 'warning' on its own is also accepted."""
|
|
path = tmp_path / "time-synced"
|
|
path.write_text("warning\n")
|
|
assert read_ntp_gate(path) == "warning"
|
|
|
|
|
|
def test_ntp_gate_empty_returns_none(tmp_path: Path):
|
|
"""Empty / surprise content is treated as unknown rather than misclassified."""
|
|
path = tmp_path / "time-synced"
|
|
path.write_text("")
|
|
assert read_ntp_gate(path) is None
|
|
|
|
|
|
def test_ntp_gate_unknown_marker_returns_none(tmp_path: Path):
|
|
path = tmp_path / "time-synced"
|
|
path.write_text("synced via remote NTP\n") # neither 'ok' nor 'warning'
|
|
assert read_ntp_gate(path) is None
|
|
|
|
|
|
def test_ntp_gate_strips_whitespace(tmp_path: Path):
|
|
"""Leading whitespace shouldn't trick a startswith check."""
|
|
path = tmp_path / "time-synced"
|
|
path.write_text(" ok\n")
|
|
assert read_ntp_gate(path) == "ok"
|
|
|
|
|
|
def test_ntp_gate_binary_garbage_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture):
|
|
"""Defensive read mode survives non-utf8 content without crashing."""
|
|
path = tmp_path / "time-synced"
|
|
path.write_bytes(b"\xff\xfe\x00\x01ok\n")
|
|
# errors="replace" maps the bytes through but the prefix is no longer 'ok'.
|
|
assert read_ntp_gate(path) is None
|