feat: add multi-device provisioning and local remote-config UI
- add multi-device portal device management, key rotation, and sync request APIs - add Pi settings UI for remote server URL, device name, and API key - add local test-connection and sync-now actions - make upload and config sync reuse dynamic shared credentials - add Phase 25.5c migration and docs updates Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from modules import app_device_config, app_device_sync, app_remote_config
|
||||
|
||||
|
||||
class RemoteConfigTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.creds_path = os.path.join(self.tmpdir.name, "upload_creds.env")
|
||||
|
||||
def tearDown(self):
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def test_update_and_read_remote_config(self):
|
||||
with patch.object(app_remote_config, "_CREDS_PATH", self.creds_path):
|
||||
errors = {}
|
||||
app_remote_config.update_remote_config(
|
||||
{
|
||||
"server_url": "https://example.test/",
|
||||
"device_name": "yaris-2013",
|
||||
"api_key": "secret-key",
|
||||
},
|
||||
errors,
|
||||
)
|
||||
|
||||
self.assertEqual(errors, {})
|
||||
config = app_remote_config.get_remote_config()
|
||||
self.assertEqual(config["server_url"], "https://example.test")
|
||||
self.assertEqual(config["device_name"], "yaris-2013")
|
||||
self.assertTrue(config["api_key_configured"])
|
||||
|
||||
def test_blank_api_key_preserves_existing_until_cleared(self):
|
||||
with patch.object(app_remote_config, "_CREDS_PATH", self.creds_path):
|
||||
app_remote_config.update_remote_config(
|
||||
{
|
||||
"server_url": "https://example.test",
|
||||
"device_name": "yaris-2013",
|
||||
"api_key": "secret-key",
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
errors = {}
|
||||
app_remote_config.update_remote_config(
|
||||
{
|
||||
"server_url": "https://example.test",
|
||||
"device_name": "yaris-2013",
|
||||
"api_key": "",
|
||||
"clear_api_key": False,
|
||||
},
|
||||
errors,
|
||||
)
|
||||
self.assertEqual(errors, {})
|
||||
self.assertTrue(app_remote_config.get_remote_config()["api_key_configured"])
|
||||
|
||||
app_remote_config.update_remote_config(
|
||||
{"clear_api_key": True},
|
||||
{},
|
||||
)
|
||||
self.assertFalse(app_remote_config.get_remote_config()["api_key_configured"])
|
||||
|
||||
@patch("modules.app_remote_config.requests.get")
|
||||
def test_test_connection_checks_health_and_device_auth(self, requests_get):
|
||||
class Response:
|
||||
def __init__(self, status_code):
|
||||
self.status_code = status_code
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError("bad status")
|
||||
|
||||
requests_get.side_effect = [Response(200), Response(200)]
|
||||
with patch.object(app_remote_config, "_CREDS_PATH", self.creds_path):
|
||||
app_remote_config.update_remote_config(
|
||||
{
|
||||
"server_url": "https://example.test",
|
||||
"device_name": "yaris-2013",
|
||||
"api_key": "secret-key",
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
result = app_remote_config.test_connection()
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertTrue(result["authenticated"])
|
||||
self.assertEqual(requests_get.call_count, 2)
|
||||
|
||||
|
||||
class DeviceConfigSyncPrereqTest(unittest.TestCase):
|
||||
@patch("modules.app_remote_config.get_remote_config")
|
||||
def test_sync_enable_requires_remote_credentials(self, get_remote_config):
|
||||
get_remote_config.return_value = {
|
||||
"server_url": "",
|
||||
"device_name": "",
|
||||
"api_key_configured": False,
|
||||
}
|
||||
errors = {}
|
||||
|
||||
with patch("modules.app_device_config._get_sync_enabled", return_value=False):
|
||||
app_device_config.update_device_config({"sync_enabled": True}, errors)
|
||||
|
||||
self.assertIn("sync_enabled", errors)
|
||||
|
||||
|
||||
class DeviceSyncNowTest(unittest.TestCase):
|
||||
@patch("modules.app_device_sync._has_internet", return_value=True)
|
||||
@patch("modules.app_device_sync._acknowledge_sync")
|
||||
@patch("modules.app_device_sync._on_config_changed")
|
||||
@patch("modules.app_device_sync._store_synced_config")
|
||||
@patch("modules.app_device_sync.requests.get")
|
||||
@patch("modules.app_remote_config.server_credentials")
|
||||
def test_sync_now_fetches_and_applies_config(
|
||||
self,
|
||||
server_credentials,
|
||||
requests_get,
|
||||
store_synced_config,
|
||||
on_config_changed,
|
||||
acknowledge_sync,
|
||||
_has_internet,
|
||||
):
|
||||
class Response:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"owner_name": "alice", "config_version": 4}
|
||||
|
||||
server_credentials.return_value = {
|
||||
"server_url": "https://example.test",
|
||||
"device_name": "yaris-2013",
|
||||
"api_key": "secret-key",
|
||||
}
|
||||
requests_get.return_value = Response()
|
||||
original_version = app_device_sync._SYNC_CONFIG_VERSION
|
||||
app_device_sync._SYNC_CONFIG_VERSION = 0
|
||||
try:
|
||||
result = app_device_sync.sync_now()
|
||||
finally:
|
||||
app_device_sync._SYNC_CONFIG_VERSION = original_version
|
||||
|
||||
self.assertEqual(result["status"], "synced")
|
||||
store_synced_config.assert_called_once()
|
||||
on_config_changed.assert_called_once()
|
||||
acknowledge_sync.assert_called_once_with(
|
||||
"https://example.test",
|
||||
"yaris-2013",
|
||||
"secret-key",
|
||||
4,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user