Add local settings management

Add cellular-first ntfy delivery with WiFi fallback and safe SIM868 HTTP header framing.

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-09 21:26:15 -04:00
co-authored by Copilot
parent b77ed838bd
commit 3a6f12ba9d
15 changed files with 1118 additions and 103 deletions
+116
View File
@@ -139,6 +139,122 @@ class NtfyTransportTest(unittest.TestCase):
)
response.raise_for_status.assert_called_once_with()
@patch("modules.app_alerts.app_cellular.http_post")
@patch(
"modules.app_alerts.app_cellular.is_configured",
return_value=True,
)
@patch("modules.app_alerts.requests.post")
def test_cellular_is_primary_when_configured(
self,
post,
_is_configured,
cellular_post,
):
cellular_post.return_value = (200, b"ok")
app_alerts.send_ntfy(
"https://ntfy.example/topic",
"secret",
"Alert title",
"Alert body",
"high",
["warning"],
)
cellular_post.assert_called_once_with(
"https://ntfy.example/topic",
b"Alert body",
{
"Title": "Alert title",
"Priority": "high",
"Tags": "warning",
"Authorization": "Bearer secret",
},
content_type="text/plain; charset=utf-8",
)
post.assert_not_called()
@patch("modules.app_alerts.app_cellular.http_post")
@patch(
"modules.app_alerts.app_cellular.is_configured",
return_value=True,
)
@patch("modules.app_alerts.requests.post")
def test_cellular_http_error_falls_back_to_wifi(
self,
post,
_is_configured,
cellular_post,
):
cellular_post.return_value = (503, b"error")
post.return_value.raise_for_status.return_value = None
app_alerts.send_ntfy(
"https://ntfy.example/topic",
"",
"Alert title",
"Alert body",
)
cellular_post.assert_called_once()
post.assert_called_once()
@patch("modules.app_alerts.app_cellular.http_post")
@patch(
"modules.app_alerts.app_cellular.is_configured",
return_value=True,
)
@patch("modules.app_alerts.requests.post")
def test_wifi_error_is_reported_after_cellular_failure(
self,
post,
_is_configured,
cellular_post,
):
cellular_post.side_effect = app_alerts.app_cellular.CellularError(
"modem offline"
)
post.side_effect = requests.ReadTimeout("response timed out")
with self.assertRaises(requests.ReadTimeout):
app_alerts.send_ntfy(
"https://ntfy.example/topic",
"",
"Alert title",
"Alert body",
)
cellular_post.assert_called_once()
@patch("modules.app_alerts.app_cellular.http_post")
@patch(
"modules.app_alerts.app_cellular.is_configured",
return_value=True,
)
@patch("modules.app_alerts.requests.post")
def test_both_transport_failures_do_not_expose_private_url(
self,
post,
_is_configured,
cellular_post,
):
post.side_effect = requests.ConnectionError("offline")
cellular_post.return_value = (503, b"error")
with self.assertRaises(requests.ConnectionError) as raised:
app_alerts.send_ntfy(
"https://ntfy.example/private-topic",
"",
"Alert title",
"Alert body",
)
self.assertEqual(
app_alerts._transport_error(raised.exception),
"Notification connection failed",
)
def test_transport_error_does_not_expose_url(self):
response = requests.Response()
response.status_code = 401
+114
View File
@@ -0,0 +1,114 @@
import unittest
from contextlib import nullcontext
from unittest.mock import MagicMock, patch
from modules import app_cellular
class CellularHttpTest(unittest.TestCase):
def test_rejects_at_command_injection_before_modem_access(self):
with (
patch(
"modules.app_cellular.is_configured",
return_value=True,
),
patch("modules.app_cellular._ensure_gprs_context") as ensure,
self.assertRaises(app_cellular.CellularError),
):
app_cellular.http_post(
'https://example.test/"\r\nAT+RESET',
b"body",
{},
)
ensure.assert_not_called()
@patch("modules.app_cellular.http_post")
def test_json_wrapper_uses_json_content_type(self, post):
post.return_value = (200, b"ok")
result = app_cellular.http_post_json(
"https://example.test/upload",
b"{}",
{"X-Api-Key": "key"},
timeout=30,
)
self.assertEqual(result, (200, b"ok"))
post.assert_called_once_with(
"https://example.test/upload",
b"{}",
{"X-Api-Key": "key"},
content_type="application/json",
timeout=30,
)
@patch("modules.app_cellular._read_until", return_value="OK")
@patch("modules.app_cellular._ensure_gprs_context")
@patch("modules.app_cellular.is_configured", return_value=True)
@patch("modules.app_cellular._at")
def test_multiple_headers_use_literal_crlf_escape_sequence(
self,
at,
_is_configured,
_ensure_context,
_read_until,
):
serial = MagicMock()
at.side_effect = lambda _serial, command, **_kwargs: (
"+HTTPACTION: 1,200,0"
if command == "AT+HTTPACTION=1"
else "OK"
)
with (
patch(
"modules.app_cellular.app_gps._serial_lock",
nullcontext(),
),
patch(
"modules.app_cellular.app_gps._get_serial",
return_value=serial,
),
):
status, _body = app_cellular.http_post(
"https://example.test/topic",
b"message",
{"Title": "Alert", "Priority": "high"},
content_type="text/plain",
)
self.assertEqual(status, 200)
user_data_commands = [
call.args[1]
for call in at.call_args_list
if 'AT+HTTPPARA="USERDATA"' in call.args[1]
]
self.assertEqual(
user_data_commands,
[
'AT+HTTPPARA="USERDATA","'
r'Title: Alert\r\nPriority: high\r\n"'
],
)
self.assertNotIn("\r", user_data_commands[0])
self.assertNotIn("\n", user_data_commands[0])
@patch("modules.app_cellular._ensure_gprs_context")
@patch("modules.app_cellular.is_configured", return_value=True)
def test_rejects_headers_over_modem_limit(
self,
_is_configured,
ensure_context,
):
with self.assertRaises(app_cellular.CellularError):
app_cellular.http_post(
"https://example.test/topic",
b"message",
{"X-Large": "x" * 1024},
)
ensure_context.assert_not_called()
if __name__ == "__main__":
unittest.main()
+171
View File
@@ -0,0 +1,171 @@
import os
import unittest
from unittest.mock import patch
from modules import app_cellular, app_settings
def valid_payload():
return {
"dashcam_upload_enabled": False,
"local_video_retention_days": 14,
"cellular_enabled": True,
"cellular_apn": "carrier.apn",
"ntfy_enabled": True,
"ntfy_url": "https://ntfy.example/private-topic",
"ntfy_token": "secret-token",
"clear_ntfy_url": False,
"clear_ntfy_token": False,
"alert_thresholds": {
"temperature_c": 75.5,
"disk_percent": 88,
"tracking_backlog": 500,
"video_backlog": 8,
"gps_loss_seconds": 90,
"repeat_seconds": 7200,
},
}
class SettingsUpdateTest(unittest.TestCase):
def setUp(self):
self.settings = {}
def get_setting(self, key, default=None):
return self.settings.get(key, default)
def save_settings(self, settings):
self.settings.update(settings)
def update(self, payload):
with (
patch(
"modules.app_settings.app_sql.get_setting",
side_effect=self.get_setting,
),
patch(
"modules.app_settings.app_sql.set_settings",
side_effect=self.save_settings,
),
):
return app_settings.update_settings(payload)
def test_valid_settings_are_saved_together(self):
self.assertEqual(self.update(valid_payload()), {})
self.assertEqual(self.settings["dashcam_upload_enabled"], "0")
self.assertEqual(self.settings["local_video_retention_days"], "14")
self.assertEqual(self.settings["cellular_enabled"], "1")
self.assertEqual(self.settings["cellular_apn"], "carrier.apn")
self.assertEqual(self.settings["alerts_enabled"], "1")
self.assertEqual(
self.settings["alert_ntfy_url"],
"https://ntfy.example/private-topic",
)
self.assertEqual(self.settings["alert_ntfy_token"], "secret-token")
self.assertEqual(self.settings["alert_temperature_c"], "75.5")
def test_invalid_payload_does_not_save_any_settings(self):
payload = valid_payload()
payload["cellular_apn"] = "bad apn"
payload["alert_thresholds"]["repeat_seconds"] = 30
errors = self.update(payload)
self.assertIn("cellular_apn", errors)
self.assertIn("repeat_seconds", errors)
self.assertEqual(self.settings, {})
def test_enabled_transports_require_configuration(self):
payload = valid_payload()
payload["cellular_apn"] = ""
payload["ntfy_url"] = ""
errors = self.update(payload)
self.assertIn("cellular_apn", errors)
self.assertIn("ntfy_url", errors)
def test_blank_secrets_are_preserved_and_explicit_clear_removes_them(self):
self.settings.update(
{
"alert_ntfy_url": "https://ntfy.example/existing",
"alert_ntfy_token": "existing-token",
}
)
payload = valid_payload()
payload["ntfy_url"] = ""
payload["ntfy_token"] = ""
payload["ntfy_enabled"] = False
self.assertEqual(self.update(payload), {})
self.assertEqual(
self.settings["alert_ntfy_url"],
"https://ntfy.example/existing",
)
self.assertEqual(self.settings["alert_ntfy_token"], "existing-token")
payload["clear_ntfy_url"] = True
payload["clear_ntfy_token"] = True
self.assertEqual(self.update(payload), {})
self.assertEqual(self.settings["alert_ntfy_url"], "")
self.assertEqual(self.settings["alert_ntfy_token"], "")
def test_fractional_integer_field_is_rejected(self):
payload = valid_payload()
payload["alert_thresholds"]["video_backlog"] = 1.5
errors = self.update(payload)
self.assertIn("video_backlog", errors)
class SettingsReadTest(unittest.TestCase):
@patch("modules.app_settings.app_video.retention_days", return_value=30)
@patch(
"modules.app_settings.app_upload.is_dashcam_upload_enabled",
return_value=True,
)
@patch("modules.app_settings.app_sql.get_setting")
def test_get_settings_does_not_return_ntfy_secrets(
self,
get_setting,
_upload_enabled,
_retention_days,
):
stored = {
"cellular_enabled": "0",
"cellular_apn": "",
"alerts_enabled": "1",
"alert_ntfy_url": "https://ntfy.example/private",
"alert_ntfy_token": "secret-token",
}
get_setting.side_effect = lambda key, default=None: stored.get(key, default)
settings = app_settings.get_settings()
self.assertTrue(settings["ntfy_url_configured"])
self.assertTrue(settings["ntfy_token_configured"])
self.assertNotIn("ntfy_url", settings)
self.assertNotIn("ntfy_token", settings)
class CellularSettingsTest(unittest.TestCase):
@patch.dict(
os.environ,
{
"YARIS_CELLULAR_ENABLED": "0",
"YARIS_CELLULAR_APN": "legacy.apn",
},
)
@patch("modules.app_cellular.app_sql.get_setting")
def test_persisted_cellular_settings_apply_without_restart(self, get_setting):
settings = {"cellular_enabled": "1", "cellular_apn": "live.apn"}
get_setting.side_effect = lambda key, default=None: settings.get(key, default)
self.assertTrue(app_cellular.is_configured())
self.assertEqual(app_cellular.get_apn(), "live.apn")
if __name__ == "__main__":
unittest.main()