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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user