arthexis 0.1.18__py3-none-any.whl → 0.1.20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nodes/tests.py CHANGED
@@ -2,6 +2,7 @@ import os
2
2
 
3
3
  os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
4
  import django
5
+ import pytest
5
6
 
6
7
  try: # Use the pytest-specific setup when available for database readiness
7
8
  from tests.conftest import safe_setup as _safe_setup # type: ignore
@@ -18,6 +19,7 @@ from types import SimpleNamespace
18
19
  import unittest.mock as mock
19
20
  from unittest.mock import patch, call, MagicMock
20
21
  from django.core import mail
22
+ from django.core.cache import cache
21
23
  from django.core.mail import EmailMessage
22
24
  from django.core.management import call_command
23
25
  import socket
@@ -38,6 +40,7 @@ from django.contrib.auth.models import Permission
38
40
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
39
41
  from django.conf import settings
40
42
  from django.utils import timezone
43
+ from urllib.parse import urlparse
41
44
  from dns import resolver as dns_resolver
42
45
  from . import dns as dns_utils
43
46
  from selenium.common.exceptions import WebDriverException
@@ -56,11 +59,12 @@ from .models import (
56
59
  NodeFeature,
57
60
  NodeFeatureAssignment,
58
61
  NetMessage,
62
+ PendingNetMessage,
59
63
  NodeManager,
60
64
  DNSRecord,
61
65
  )
62
66
  from .backends import OutboxEmailBackend
63
- from .tasks import capture_node_screenshot, sample_clipboard
67
+ from .tasks import capture_node_screenshot, poll_unreachable_upstream, sample_clipboard
64
68
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
65
69
  from cryptography.hazmat.primitives import serialization, hashes
66
70
  from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount
@@ -68,16 +72,16 @@ from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAcco
68
72
 
69
73
  class NodeBadgeColorTests(TestCase):
70
74
  def setUp(self):
71
- self.constellation, _ = NodeRole.objects.get_or_create(name="Constellation")
75
+ self.watchtower, _ = NodeRole.objects.get_or_create(name="Watchtower")
72
76
  self.control, _ = NodeRole.objects.get_or_create(name="Control")
73
77
 
74
- def test_constellation_role_defaults_to_goldenrod(self):
78
+ def test_watchtower_role_defaults_to_goldenrod(self):
75
79
  node = Node.objects.create(
76
- hostname="constellation",
80
+ hostname="watchtower",
77
81
  address="10.1.0.1",
78
82
  port=8000,
79
83
  mac_address="00:aa:bb:cc:dd:01",
80
- role=self.constellation,
84
+ role=self.watchtower,
81
85
  )
82
86
  self.assertEqual(node.badge_color, "#daa520")
83
87
 
@@ -97,7 +101,7 @@ class NodeBadgeColorTests(TestCase):
97
101
  address="10.1.0.3",
98
102
  port=8002,
99
103
  mac_address="00:aa:bb:cc:dd:03",
100
- role=self.constellation,
104
+ role=self.watchtower,
101
105
  badge_color="#123456",
102
106
  )
103
107
  self.assertEqual(node.badge_color, "#123456")
@@ -110,6 +114,7 @@ class NodeTests(TestCase):
110
114
  self.user = User.objects.create_user(username="nodeuser", password="pwd")
111
115
  self.client.force_login(self.user)
112
116
  NodeRole.objects.get_or_create(name="Terminal")
117
+ NodeRole.objects.get_or_create(name="Interface")
113
118
 
114
119
 
115
120
  class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
@@ -177,7 +182,7 @@ class NodeGetLocalTests(TestCase):
177
182
 
178
183
  def test_register_current_updates_role_from_lock_file(self):
179
184
  NodeRole.objects.get_or_create(name="Terminal")
180
- NodeRole.objects.get_or_create(name="Constellation")
185
+ NodeRole.objects.get_or_create(name="Watchtower")
181
186
  with TemporaryDirectory() as tmp:
182
187
  base = Path(tmp)
183
188
  lock_dir = base / "locks"
@@ -202,7 +207,7 @@ class NodeGetLocalTests(TestCase):
202
207
  self.assertTrue(created)
203
208
  self.assertEqual(node.role.name, "Terminal")
204
209
 
205
- role_file.write_text("Constellation")
210
+ role_file.write_text("Watchtower")
206
211
  with override_settings(BASE_DIR=base):
207
212
  with (
208
213
  patch(
@@ -221,7 +226,67 @@ class NodeGetLocalTests(TestCase):
221
226
 
222
227
  self.assertFalse(created_again)
223
228
  node.refresh_from_db()
224
- self.assertEqual(node.role.name, "Constellation")
229
+ self.assertEqual(node.role.name, "Watchtower")
230
+
231
+ role_file.write_text("Constellation")
232
+ with override_settings(BASE_DIR=base):
233
+ with (
234
+ patch(
235
+ "nodes.models.Node.get_current_mac",
236
+ return_value="00:aa:bb:cc:dd:ee",
237
+ ),
238
+ patch("nodes.models.socket.gethostname", return_value="role-host"),
239
+ patch(
240
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
241
+ ),
242
+ patch("nodes.models.revision.get_revision", return_value="rev"),
243
+ patch.object(Node, "ensure_keys"),
244
+ patch.object(Node, "notify_peers_of_update"),
245
+ ):
246
+ Node.register_current()
247
+
248
+ node.refresh_from_db()
249
+ self.assertEqual(node.role.name, "Watchtower")
250
+
251
+ def test_register_current_respects_node_hostname_env(self):
252
+ with TemporaryDirectory() as tmp:
253
+ base = Path(tmp)
254
+ with override_settings(BASE_DIR=base):
255
+ with (
256
+ patch.dict(os.environ, {"NODE_HOSTNAME": "gway-002"}, clear=False),
257
+ patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"),
258
+ patch("nodes.models.socket.gethostname", return_value="localhost"),
259
+ patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
260
+ patch("nodes.models.revision.get_revision", return_value="rev"),
261
+ patch.object(Node, "ensure_keys"),
262
+ patch.object(Node, "notify_peers_of_update"),
263
+ ):
264
+ node, created = Node.register_current()
265
+ self.assertTrue(created)
266
+ self.assertEqual(node.hostname, "gway-002")
267
+ self.assertEqual(node.public_endpoint, "gway-002")
268
+
269
+ def test_register_current_respects_public_endpoint_env(self):
270
+ with TemporaryDirectory() as tmp:
271
+ base = Path(tmp)
272
+ with override_settings(BASE_DIR=base):
273
+ with (
274
+ patch.dict(
275
+ os.environ,
276
+ {"NODE_HOSTNAME": "gway-alpha", "NODE_PUBLIC_ENDPOINT": "gway-002"},
277
+ clear=False,
278
+ ),
279
+ patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:56"),
280
+ patch("nodes.models.socket.gethostname", return_value="localhost"),
281
+ patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
282
+ patch("nodes.models.revision.get_revision", return_value="rev"),
283
+ patch.object(Node, "ensure_keys"),
284
+ patch.object(Node, "notify_peers_of_update"),
285
+ ):
286
+ node, created = Node.register_current()
287
+ self.assertTrue(created)
288
+ self.assertEqual(node.hostname, "gway-alpha")
289
+ self.assertEqual(node.public_endpoint, "gway-002")
225
290
 
226
291
  def test_register_and_list_node(self):
227
292
  response = self.client.post(
@@ -309,6 +374,43 @@ class NodeGetLocalTests(TestCase):
309
374
  self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
310
375
  self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
311
376
 
377
+ def test_register_node_assigns_interface_role_and_returns_uuid(self):
378
+ NodeRole.objects.get_or_create(name="Interface")
379
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
380
+ public_bytes = private_key.public_key().public_bytes(
381
+ encoding=serialization.Encoding.PEM,
382
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
383
+ ).decode()
384
+ token = "interface-token"
385
+ signature = base64.b64encode(
386
+ private_key.sign(
387
+ token.encode(),
388
+ padding.PKCS1v15(),
389
+ hashes.SHA256(),
390
+ )
391
+ ).decode()
392
+ mac = "aa:bb:cc:dd:ee:99"
393
+ payload = {
394
+ "hostname": "interface",
395
+ "address": "127.0.0.1",
396
+ "port": 8443,
397
+ "mac_address": mac,
398
+ "public_key": public_bytes,
399
+ "token": token,
400
+ "signature": signature,
401
+ "role": "Interface",
402
+ }
403
+ response = self.client.post(
404
+ reverse("register-node"),
405
+ data=json.dumps(payload),
406
+ content_type="application/json",
407
+ )
408
+ self.assertEqual(response.status_code, 200)
409
+ data = response.json()
410
+ self.assertIn("uuid", data)
411
+ node = Node.objects.get(mac_address=mac)
412
+ self.assertEqual(node.role.name, "Interface")
413
+
312
414
  def test_register_node_feature_toggle(self):
313
415
  NodeFeature.objects.get_or_create(
314
416
  slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
@@ -1236,6 +1338,7 @@ class NodeRegisterCurrentTests(TestCase):
1236
1338
  existing_role.refresh_from_db()
1237
1339
  self.assertEqual(existing_role.description, "updated via attachment")
1238
1340
 
1341
+ @pytest.mark.feature("clipboard-poll")
1239
1342
  def test_clipboard_polling_creates_task(self):
1240
1343
  feature, _ = NodeFeature.objects.get_or_create(
1241
1344
  slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
@@ -1253,6 +1356,7 @@ class NodeRegisterCurrentTests(TestCase):
1253
1356
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
1254
1357
  self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
1255
1358
 
1359
+ @pytest.mark.feature("screenshot-poll")
1256
1360
  def test_screenshot_polling_creates_task(self):
1257
1361
  feature, _ = NodeFeature.objects.get_or_create(
1258
1362
  slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
@@ -1381,6 +1485,7 @@ class NodeAdminTests(TestCase):
1381
1485
  action_url = reverse("admin:core_rfid_scan")
1382
1486
  self.assertContains(response, f'href="{action_url}"')
1383
1487
 
1488
+ @pytest.mark.feature("rpi-camera")
1384
1489
  def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
1385
1490
  node = self._create_local_node()
1386
1491
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1393,6 +1498,7 @@ class NodeAdminTests(TestCase):
1393
1498
  self.assertContains(response, f'href="{snapshot_url}"')
1394
1499
  self.assertContains(response, f'href="{stream_url}"')
1395
1500
 
1501
+ @pytest.mark.feature("audio-capture")
1396
1502
  def test_node_feature_list_shows_waveform_action_when_enabled(self):
1397
1503
  node = self._create_local_node()
1398
1504
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1403,6 +1509,7 @@ class NodeAdminTests(TestCase):
1403
1509
  action_url = reverse("admin:nodes_nodefeature_view_waveform")
1404
1510
  self.assertContains(response, f'href="{action_url}"')
1405
1511
 
1512
+ @pytest.mark.feature("screenshot-poll")
1406
1513
  def test_node_feature_list_hides_default_action_when_disabled(self):
1407
1514
  self._create_local_node()
1408
1515
  NodeFeature.objects.get_or_create(
@@ -1495,6 +1602,7 @@ class NodeAdminTests(TestCase):
1495
1602
  response, reverse("admin:nodes_node_register_current")
1496
1603
  )
1497
1604
 
1605
+ @pytest.mark.feature("screenshot-poll")
1498
1606
  @patch("nodes.admin.capture_screenshot")
1499
1607
  def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
1500
1608
  screenshot_dir = settings.LOG_DIR / "screenshots"
@@ -1540,6 +1648,48 @@ class NodeAdminTests(TestCase):
1540
1648
  self.assertEqual(response.status_code, 200)
1541
1649
  self.assertContains(response, "data:image/png;base64")
1542
1650
 
1651
+ @patch("nodes.admin.requests.post")
1652
+ def test_proxy_view_uses_remote_login_url(self, mock_post):
1653
+ self.client.get(reverse("admin:nodes_node_register_current"))
1654
+ local_node = Node.objects.get()
1655
+ remote = Node.objects.create(
1656
+ hostname="remote",
1657
+ address="192.0.2.10",
1658
+ port=8443,
1659
+ mac_address="aa:bb:cc:dd:ee:ff",
1660
+ )
1661
+ mock_post.return_value = SimpleNamespace(
1662
+ ok=True,
1663
+ json=lambda: {
1664
+ "login_url": "https://remote.example/nodes/proxy/login/token",
1665
+ "expires": "2025-01-01T00:00:00",
1666
+ },
1667
+ status_code=200,
1668
+ text="ok",
1669
+ )
1670
+ response = self.client.get(
1671
+ reverse("admin:nodes_node_proxy", args=[remote.pk])
1672
+ )
1673
+ self.assertEqual(response.status_code, 200)
1674
+ self.assertTemplateUsed(response, "admin/nodes/node/proxy.html")
1675
+ self.assertContains(response, "<iframe", html=False)
1676
+ mock_post.assert_called()
1677
+ payload = json.loads(mock_post.call_args[1]["data"])
1678
+ self.assertEqual(payload.get("requester"), str(local_node.uuid))
1679
+
1680
+ def test_proxy_link_displayed_for_remote_nodes(self):
1681
+ Node.objects.create(
1682
+ hostname="remote",
1683
+ address="203.0.113.1",
1684
+ port=8000,
1685
+ mac_address="aa:aa:aa:aa:aa:01",
1686
+ )
1687
+ response = self.client.get(reverse("admin:nodes_node_changelist"))
1688
+ proxy_url = reverse("admin:nodes_node_proxy", args=[1])
1689
+ self.assertContains(response, proxy_url)
1690
+
1691
+
1692
+ @pytest.mark.feature("screenshot-poll")
1543
1693
  @override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
1544
1694
  @patch("nodes.admin.capture_screenshot")
1545
1695
  def test_take_screenshots_action(self, mock_capture):
@@ -1572,6 +1722,7 @@ class NodeAdminTests(TestCase):
1572
1722
  samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
1573
1723
  self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
1574
1724
 
1725
+ @pytest.mark.feature("screenshot-poll")
1575
1726
  @patch("nodes.admin.capture_screenshot")
1576
1727
  def test_take_screenshot_default_action_creates_sample(
1577
1728
  self, mock_capture_screenshot
@@ -1646,6 +1797,7 @@ class NodeAdminTests(TestCase):
1646
1797
  response, "Completed 0 of 1 feature check(s) successfully.", html=False
1647
1798
  )
1648
1799
 
1800
+ @pytest.mark.feature("screenshot-poll")
1649
1801
  def test_enable_selected_features_enables_manual_feature(self):
1650
1802
  node = self._create_local_node()
1651
1803
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1690,6 +1842,7 @@ class NodeAdminTests(TestCase):
1690
1842
  html=False,
1691
1843
  )
1692
1844
 
1845
+ @pytest.mark.feature("screenshot-poll")
1693
1846
  def test_take_screenshot_default_action_requires_enabled_feature(self):
1694
1847
  self._create_local_node()
1695
1848
  NodeFeature.objects.get_or_create(
@@ -1704,6 +1857,7 @@ class NodeAdminTests(TestCase):
1704
1857
  self.assertEqual(ContentSample.objects.count(), 0)
1705
1858
  self.assertContains(response, "Screenshot Poll feature is not enabled")
1706
1859
 
1860
+ @pytest.mark.feature("rpi-camera")
1707
1861
  @patch("nodes.admin.capture_rpi_snapshot")
1708
1862
  def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
1709
1863
  node = self._create_local_node()
@@ -1726,6 +1880,7 @@ class NodeAdminTests(TestCase):
1726
1880
  change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
1727
1881
  self.assertEqual(response.redirect_chain[-1][0], change_url)
1728
1882
 
1883
+ @pytest.mark.feature("rpi-camera")
1729
1884
  def test_view_stream_requires_enabled_feature(self):
1730
1885
  self._create_local_node()
1731
1886
  NodeFeature.objects.get_or_create(
@@ -1741,6 +1896,7 @@ class NodeAdminTests(TestCase):
1741
1896
  response, "Raspberry Pi Camera feature is not enabled on this node."
1742
1897
  )
1743
1898
 
1899
+ @pytest.mark.feature("rpi-camera")
1744
1900
  def test_view_stream_renders_when_feature_enabled(self):
1745
1901
  node = self._create_local_node()
1746
1902
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1756,6 +1912,7 @@ class NodeAdminTests(TestCase):
1756
1912
  self.assertContains(response, expected_stream)
1757
1913
  self.assertContains(response, "camera-stream__frame")
1758
1914
 
1915
+ @pytest.mark.feature("rpi-camera")
1759
1916
  def test_view_stream_uses_configured_stream_url(self):
1760
1917
  node = self._create_local_node()
1761
1918
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1773,6 +1930,7 @@ class NodeAdminTests(TestCase):
1773
1930
  self.assertEqual(response.context_data["stream_embed"], "iframe")
1774
1931
  self.assertContains(response, configured_stream)
1775
1932
 
1933
+ @pytest.mark.feature("rpi-camera")
1776
1934
  def test_view_stream_detects_mjpeg_stream(self):
1777
1935
  node = self._create_local_node()
1778
1936
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1789,6 +1947,7 @@ class NodeAdminTests(TestCase):
1789
1947
  self.assertEqual(response.context_data["stream_embed"], "mjpeg")
1790
1948
  self.assertContains(response, "<img", html=False)
1791
1949
 
1950
+ @pytest.mark.feature("rpi-camera")
1792
1951
  def test_view_stream_marks_rtsp_stream_as_unsupported(self):
1793
1952
  node = self._create_local_node()
1794
1953
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1805,6 +1964,7 @@ class NodeAdminTests(TestCase):
1805
1964
  self.assertEqual(response.context_data["stream_embed"], "unsupported")
1806
1965
  self.assertContains(response, "camera-stream__unsupported")
1807
1966
 
1967
+ @pytest.mark.feature("audio-capture")
1808
1968
  def test_view_waveform_requires_enabled_feature(self):
1809
1969
  self._create_local_node()
1810
1970
  NodeFeature.objects.get_or_create(
@@ -1820,6 +1980,7 @@ class NodeAdminTests(TestCase):
1820
1980
  response, "Audio Capture feature is not enabled on this node."
1821
1981
  )
1822
1982
 
1983
+ @pytest.mark.feature("audio-capture")
1823
1984
  def test_view_waveform_renders_when_feature_enabled(self):
1824
1985
  node = self._create_local_node()
1825
1986
  feature, _ = NodeFeature.objects.get_or_create(
@@ -2142,6 +2303,160 @@ class NodeAdminTests(TestCase):
2142
2303
  self.assertEqual(post_data["mac_address"], local.mac_address)
2143
2304
 
2144
2305
 
2306
+ class NodeProxyGatewayTests(TestCase):
2307
+ def setUp(self):
2308
+ cache.clear()
2309
+ self.client = Client()
2310
+ self.private_key = rsa.generate_private_key(
2311
+ public_exponent=65537, key_size=2048
2312
+ )
2313
+ public_key = self.private_key.public_key().public_bytes(
2314
+ encoding=serialization.Encoding.PEM,
2315
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
2316
+ ).decode()
2317
+ self.node = Node.objects.create(
2318
+ hostname="requester",
2319
+ address="127.0.0.1",
2320
+ port=8000,
2321
+ mac_address="aa:bb:cc:dd:ee:aa",
2322
+ public_key=public_key,
2323
+ )
2324
+ patcher = patch("requests.post")
2325
+ self.addCleanup(patcher.stop)
2326
+ self.mock_requests_post = patcher.start()
2327
+ self.mock_requests_post.return_value = SimpleNamespace(
2328
+ ok=True,
2329
+ status_code=200,
2330
+ json=lambda: {},
2331
+ text="",
2332
+ )
2333
+
2334
+ def tearDown(self):
2335
+ cache.clear()
2336
+
2337
+ def _sign(self, payload):
2338
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
2339
+ signature = base64.b64encode(
2340
+ self.private_key.sign(
2341
+ body.encode(), padding.PKCS1v15(), hashes.SHA256()
2342
+ )
2343
+ ).decode()
2344
+ return body, signature
2345
+
2346
+ def test_proxy_session_creates_login_url(self):
2347
+ payload = {
2348
+ "requester": str(self.node.uuid),
2349
+ "user": {
2350
+ "username": "proxy-user",
2351
+ "email": "proxy@example.com",
2352
+ "first_name": "Proxy",
2353
+ "last_name": "User",
2354
+ "is_staff": True,
2355
+ "is_superuser": True,
2356
+ "groups": [],
2357
+ "permissions": [],
2358
+ },
2359
+ "target": "/admin/",
2360
+ }
2361
+ body, signature = self._sign(payload)
2362
+ response = self.client.post(
2363
+ reverse("node-proxy-session"),
2364
+ data=body,
2365
+ content_type="application/json",
2366
+ HTTP_X_SIGNATURE=signature,
2367
+ )
2368
+ self.assertEqual(response.status_code, 200)
2369
+ data = response.json()
2370
+ self.assertIn("login_url", data)
2371
+ user = get_user_model().objects.get(username="proxy-user")
2372
+ self.assertTrue(user.is_staff)
2373
+ parsed = urlparse(data["login_url"])
2374
+ login_response = self.client.get(parsed.path)
2375
+ self.assertEqual(login_response.status_code, 302)
2376
+ self.assertEqual(login_response["Location"], "/admin/")
2377
+ self.assertEqual(self.client.session.get("_auth_user_id"), str(user.pk))
2378
+ second = self.client.get(parsed.path)
2379
+ self.assertEqual(second.status_code, 410)
2380
+
2381
+ def test_proxy_execute_lists_nodes(self):
2382
+ Node.objects.create(
2383
+ hostname="target",
2384
+ address="127.0.0.5",
2385
+ port=8010,
2386
+ mac_address="aa:bb:cc:dd:ee:bb",
2387
+ )
2388
+ payload = {
2389
+ "requester": str(self.node.uuid),
2390
+ "action": "list",
2391
+ "model": "nodes.Node",
2392
+ "filters": {"hostname": "target"},
2393
+ "credentials": {
2394
+ "username": "suite-user",
2395
+ "password": "secret",
2396
+ "first_name": "Suite",
2397
+ "last_name": "User",
2398
+ },
2399
+ }
2400
+ body, signature = self._sign(payload)
2401
+ response = self.client.post(
2402
+ reverse("node-proxy-execute"),
2403
+ data=body,
2404
+ content_type="application/json",
2405
+ HTTP_X_SIGNATURE=signature,
2406
+ )
2407
+ self.assertEqual(response.status_code, 200)
2408
+ data = response.json()
2409
+ self.assertEqual(len(data.get("objects", [])), 1)
2410
+ record = data["objects"][0]
2411
+ self.assertEqual(record["fields"]["hostname"], "target")
2412
+ user = get_user_model().objects.get(username="suite-user")
2413
+ self.assertTrue(user.is_superuser)
2414
+
2415
+ def test_proxy_execute_requires_valid_password_for_existing_user(self):
2416
+ User = get_user_model()
2417
+ User.objects.create_user(username="suite-user", password="correct")
2418
+ payload = {
2419
+ "requester": str(self.node.uuid),
2420
+ "action": "list",
2421
+ "model": "nodes.Node",
2422
+ "credentials": {
2423
+ "username": "suite-user",
2424
+ "password": "wrong",
2425
+ },
2426
+ }
2427
+ body, signature = self._sign(payload)
2428
+ response = self.client.post(
2429
+ reverse("node-proxy-execute"),
2430
+ data=body,
2431
+ content_type="application/json",
2432
+ HTTP_X_SIGNATURE=signature,
2433
+ )
2434
+ self.assertEqual(response.status_code, 403)
2435
+
2436
+ def test_proxy_execute_schema_returns_models(self):
2437
+ payload = {
2438
+ "requester": str(self.node.uuid),
2439
+ "action": "schema",
2440
+ "credentials": {
2441
+ "username": "suite-user",
2442
+ "password": "secret",
2443
+ },
2444
+ }
2445
+ body, signature = self._sign(payload)
2446
+ response = self.client.post(
2447
+ reverse("node-proxy-execute"),
2448
+ data=body,
2449
+ content_type="application/json",
2450
+ HTTP_X_SIGNATURE=signature,
2451
+ )
2452
+ self.assertEqual(response.status_code, 200)
2453
+ data = response.json()
2454
+ models = data.get("models", [])
2455
+ self.assertTrue(models)
2456
+ suite_names = {entry.get("suite_name") for entry in models}
2457
+ self.assertIn("Nodes", suite_names)
2458
+
2459
+
2145
2460
  class NodeRFIDAPITests(TestCase):
2146
2461
  def test_import_endpoint_applies_payload_without_creating_accounts(self):
2147
2462
  remote = Node.objects.create(
@@ -2315,37 +2630,14 @@ class NetMessageAdminTests(TransactionTestCase):
2315
2630
  self.assertEqual(form["subject"].value(), "Re: Ping")
2316
2631
  self.assertEqual(str(form["filter_node"].value()), str(node.pk))
2317
2632
 
2318
-
2319
- class LastNetMessageViewTests(TestCase):
2320
- def setUp(self):
2321
- self.client = Client()
2322
- NodeRole.objects.get_or_create(name="Terminal")
2323
-
2324
- def test_returns_latest_message(self):
2325
- NetMessage.objects.create(subject="old", body="msg1")
2326
- latest = NetMessage.objects.create(subject="new", body="msg2")
2327
- resp = self.client.get(reverse("last-net-message"))
2328
- self.assertEqual(resp.status_code, 200)
2329
- self.assertEqual(
2330
- resp.json(),
2331
- {
2332
- "subject": "new",
2333
- "body": "msg2",
2334
- "admin_url": reverse(
2335
- "admin:nodes_netmessage_change", args=[latest.pk]
2336
- ),
2337
- },
2338
- )
2339
-
2340
-
2341
2633
  class NetMessageReachTests(TestCase):
2342
2634
  def setUp(self):
2343
2635
  self.roles = {}
2344
- for name in ["Terminal", "Control", "Satellite", "Constellation"]:
2636
+ for name in ["Terminal", "Control", "Satellite", "Watchtower"]:
2345
2637
  self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
2346
2638
  self.nodes = {}
2347
2639
  for idx, name in enumerate(
2348
- ["Terminal", "Control", "Satellite", "Constellation"], start=1
2640
+ ["Terminal", "Control", "Satellite", "Watchtower"], start=1
2349
2641
  ):
2350
2642
  self.nodes[name] = Node.objects.create(
2351
2643
  hostname=name.lower(),
@@ -2389,15 +2681,15 @@ class NetMessageReachTests(TestCase):
2389
2681
  self.assertEqual(mock_post.call_count, 3)
2390
2682
 
2391
2683
  @patch("requests.post")
2392
- def test_constellation_reach_prioritizes_constellation(self, mock_post):
2684
+ def test_watchtower_reach_prioritizes_watchtower(self, mock_post):
2393
2685
  msg = NetMessage.objects.create(
2394
- subject="s", body="b", reach=self.roles["Constellation"]
2686
+ subject="s", body="b", reach=self.roles["Watchtower"]
2395
2687
  )
2396
2688
  with patch.object(Node, "get_local", return_value=None):
2397
2689
  msg.propagate()
2398
2690
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
2399
2691
  self.assertEqual(
2400
- roles, {"Constellation", "Satellite", "Control", "Terminal"}
2692
+ roles, {"Watchtower", "Satellite", "Control", "Terminal"}
2401
2693
  )
2402
2694
  self.assertEqual(mock_post.call_count, 4)
2403
2695
 
@@ -2597,13 +2889,6 @@ class NetMessagePropagationTests(TestCase):
2597
2889
  self.assertNotIn(sender_addr, targets)
2598
2890
  self.assertEqual(msg.propagated_to.count(), 4)
2599
2891
  self.assertTrue(msg.complete)
2600
- self.assertEqual(len(msg.confirmed_peers), mock_post.call_count)
2601
- self.assertTrue(
2602
- all(entry["status"] == "acknowledged" for entry in msg.confirmed_peers.values())
2603
- )
2604
- self.assertTrue(
2605
- all(entry["status_code"] == 200 for entry in msg.confirmed_peers.values())
2606
- )
2607
2892
 
2608
2893
  @patch("requests.post")
2609
2894
  @patch("core.notifications.notify", return_value=False)
@@ -2689,10 +2974,240 @@ class NetMessagePropagationTests(TestCase):
2689
2974
  ):
2690
2975
  msg.propagate()
2691
2976
 
2692
- self.assertTrue(msg.confirmed_peers)
2693
- self.assertTrue(
2694
- all(entry["status"] == "error" for entry in msg.confirmed_peers.values())
2977
+ self.assertEqual(msg.propagated_to.count(), len(self.remotes))
2978
+ self.assertTrue(msg.complete)
2979
+
2980
+
2981
+ class NetMessageQueueTests(TestCase):
2982
+ def setUp(self):
2983
+ self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
2984
+ self.feature, _ = NodeFeature.objects.get_or_create(
2985
+ slug="celery-queue", defaults={"display": "Celery Queue"}
2986
+ )
2987
+
2988
+ def test_propagate_queues_unreachable_downstream(self):
2989
+ local = Node.objects.create(
2990
+ hostname="local",
2991
+ address="10.0.0.1",
2992
+ port=8000,
2993
+ mac_address="00:11:22:33:44:10",
2994
+ role=self.role,
2995
+ public_endpoint="local",
2996
+ )
2997
+ downstream = Node.objects.create(
2998
+ hostname="downstream",
2999
+ address="10.0.0.2",
3000
+ port=8001,
3001
+ mac_address="00:11:22:33:44:11",
3002
+ role=self.role,
3003
+ current_relation=Node.Relation.DOWNSTREAM,
3004
+ )
3005
+ message = NetMessage.objects.create(subject="Queued", body="Body", reach=self.role)
3006
+ with patch.object(Node, "get_local", return_value=local), patch.object(
3007
+ Node, "get_private_key", return_value=None
3008
+ ), patch("core.notifications.notify", return_value=False), patch(
3009
+ "requests.post", side_effect=Exception("fail")
3010
+ ):
3011
+ message.propagate()
3012
+
3013
+ entry = PendingNetMessage.objects.get(node=downstream, message=message)
3014
+ self.assertIn(str(downstream.uuid), entry.seen)
3015
+ self.assertGreater(entry.stale_at, timezone.now())
3016
+
3017
+ def test_queue_limit_enforced(self):
3018
+ downstream = Node.objects.create(
3019
+ hostname="limit",
3020
+ address="10.0.0.3",
3021
+ port=8002,
3022
+ mac_address="00:11:22:33:44:12",
3023
+ role=self.role,
3024
+ current_relation=Node.Relation.DOWNSTREAM,
3025
+ message_queue_length=1,
3026
+ )
3027
+ msg1 = NetMessage.objects.create(subject="Old", body="One", reach=self.role)
3028
+ msg2 = NetMessage.objects.create(subject="New", body="Two", reach=self.role)
3029
+
3030
+ msg1.queue_for_node(downstream, [str(downstream.uuid)])
3031
+ msg2.queue_for_node(downstream, [str(downstream.uuid)])
3032
+
3033
+ entries = list(PendingNetMessage.objects.filter(node=downstream))
3034
+ self.assertEqual(len(entries), 1)
3035
+ self.assertEqual(entries[0].message, msg2)
3036
+
3037
+ def test_queue_duplicate_updates_stale(self):
3038
+ downstream = Node.objects.create(
3039
+ hostname="dup",
3040
+ address="10.0.0.4",
3041
+ port=8003,
3042
+ mac_address="00:11:22:33:44:13",
3043
+ role=self.role,
3044
+ current_relation=Node.Relation.DOWNSTREAM,
2695
3045
  )
3046
+ message = NetMessage.objects.create(subject="Dup", body="Dup", reach=self.role)
3047
+ first = timezone.now()
3048
+ second = first + timedelta(minutes=5)
3049
+ with patch(
3050
+ "nodes.models.timezone.now", side_effect=[first, second, second]
3051
+ ):
3052
+ message.queue_for_node(downstream, ["first"])
3053
+ message.queue_for_node(downstream, ["second"])
3054
+
3055
+ entry = PendingNetMessage.objects.get(node=downstream, message=message)
3056
+ self.assertEqual(entry.seen, ["second"])
3057
+ self.assertEqual(entry.queued_at, second)
3058
+ self.assertEqual(entry.stale_at, second + timedelta(hours=1))
3059
+
3060
+ def test_pull_endpoint_returns_and_clears_messages(self):
3061
+ local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3062
+ downstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3063
+ local = Node.objects.create(
3064
+ hostname="hub",
3065
+ address="10.0.0.5",
3066
+ port=8004,
3067
+ mac_address="00:11:22:33:44:14",
3068
+ role=self.role,
3069
+ public_endpoint="hub",
3070
+ )
3071
+ downstream = Node.objects.create(
3072
+ hostname="remote",
3073
+ address="10.0.0.6",
3074
+ port=8005,
3075
+ mac_address="00:11:22:33:44:15",
3076
+ role=self.role,
3077
+ current_relation=Node.Relation.DOWNSTREAM,
3078
+ public_key=downstream_key.public_key()
3079
+ .public_bytes(
3080
+ serialization.Encoding.PEM,
3081
+ serialization.PublicFormat.SubjectPublicKeyInfo,
3082
+ )
3083
+ .decode(),
3084
+ )
3085
+ message = NetMessage.objects.create(subject="Fresh", body="Body", reach=self.role)
3086
+ stale_message = NetMessage.objects.create(subject="Stale", body="Body", reach=self.role)
3087
+ now = timezone.now()
3088
+ PendingNetMessage.objects.create(
3089
+ node=downstream,
3090
+ message=message,
3091
+ seen=[str(downstream.uuid)],
3092
+ stale_at=now + timedelta(minutes=30),
3093
+ )
3094
+ stale_entry = PendingNetMessage.objects.create(
3095
+ node=downstream,
3096
+ message=stale_message,
3097
+ seen=["stale"],
3098
+ stale_at=now - timedelta(minutes=5),
3099
+ )
3100
+ PendingNetMessage.objects.filter(pk=stale_entry.pk).update(
3101
+ queued_at=now - timedelta(minutes=5)
3102
+ )
3103
+
3104
+ def fake_get_private(node_obj):
3105
+ if node_obj.pk == local.pk:
3106
+ return local_key
3107
+ return None
3108
+
3109
+ payload = {"requester": str(downstream.uuid)}
3110
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
3111
+ signature = base64.b64encode(
3112
+ downstream_key.sign(
3113
+ body.encode(),
3114
+ padding.PKCS1v15(),
3115
+ hashes.SHA256(),
3116
+ )
3117
+ ).decode()
3118
+
3119
+ with patch.object(Node, "get_local", return_value=local), patch.object(
3120
+ Node, "get_private_key", return_value=local_key
3121
+ ):
3122
+ response = self.client.post(
3123
+ reverse("net-message-pull"),
3124
+ data=body,
3125
+ content_type="application/json",
3126
+ HTTP_X_SIGNATURE=signature,
3127
+ )
3128
+
3129
+ self.assertEqual(response.status_code, 200)
3130
+ data = response.json()
3131
+ self.assertEqual(len(data.get("messages", [])), 1)
3132
+ payload_data = data["messages"][0]["payload"]
3133
+ self.assertEqual(payload_data["uuid"], str(message.uuid))
3134
+ self.assertFalse(
3135
+ PendingNetMessage.objects.filter(node=downstream, message=message).exists()
3136
+ )
3137
+ self.assertFalse(
3138
+ PendingNetMessage.objects.filter(
3139
+ node=downstream, message=stale_message
3140
+ ).exists()
3141
+ )
3142
+ response_signature = data["messages"][0]["signature"]
3143
+ local_public = local_key.public_key()
3144
+ local_public.verify(
3145
+ base64.b64decode(response_signature),
3146
+ json.dumps(payload_data, separators=(",", ":"), sort_keys=True).encode(),
3147
+ padding.PKCS1v15(),
3148
+ hashes.SHA256(),
3149
+ )
3150
+
3151
+ def test_poll_task_fetches_messages(self):
3152
+ local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3153
+ upstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3154
+ local = Node.objects.create(
3155
+ hostname="downstream",
3156
+ address="10.0.0.7",
3157
+ port=8006,
3158
+ mac_address="00:11:22:33:44:16",
3159
+ role=self.role,
3160
+ public_endpoint="downstream",
3161
+ )
3162
+ upstream = Node.objects.create(
3163
+ hostname="upstream",
3164
+ address="127.0.0.2",
3165
+ port=8010,
3166
+ mac_address="00:11:22:33:44:17",
3167
+ role=self.role,
3168
+ current_relation=Node.Relation.UPSTREAM,
3169
+ public_key=upstream_key.public_key()
3170
+ .public_bytes(
3171
+ serialization.Encoding.PEM,
3172
+ serialization.PublicFormat.SubjectPublicKeyInfo,
3173
+ )
3174
+ .decode(),
3175
+ )
3176
+ NodeFeatureAssignment.objects.create(node=local, feature=self.feature)
3177
+ payload = {
3178
+ "uuid": str(uuid.uuid4()),
3179
+ "subject": "Update",
3180
+ "body": "Body",
3181
+ "seen": [str(local.uuid)],
3182
+ "origin": str(upstream.uuid),
3183
+ "sender": str(upstream.uuid),
3184
+ }
3185
+ payload_text = json.dumps(payload, separators=(",", ":"), sort_keys=True)
3186
+ payload_signature = base64.b64encode(
3187
+ upstream_key.sign(
3188
+ payload_text.encode(),
3189
+ padding.PKCS1v15(),
3190
+ hashes.SHA256(),
3191
+ )
3192
+ ).decode()
3193
+ response = MagicMock()
3194
+ response.ok = True
3195
+ response.json.return_value = {
3196
+ "messages": [{"payload": payload, "signature": payload_signature}]
3197
+ }
3198
+
3199
+ with patch.object(Node, "get_local", return_value=local), patch.object(
3200
+ Node, "get_private_key", return_value=local_key
3201
+ ), patch("nodes.tasks.requests.post", return_value=response) as mock_post, patch.object(
3202
+ NetMessage, "propagate"
3203
+ ) as mock_propagate:
3204
+ poll_unreachable_upstream()
3205
+
3206
+ created = NetMessage.objects.get(uuid=payload["uuid"])
3207
+ self.assertEqual(created.subject, "Update")
3208
+ self.assertEqual(created.node_origin, upstream)
3209
+ mock_post.assert_called_once()
3210
+ mock_propagate.assert_called_once()
2696
3211
 
2697
3212
 
2698
3213
  class NetMessageSignatureTests(TestCase):
@@ -2930,6 +3445,7 @@ class ContentSampleTransactionTests(TestCase):
2930
3445
  sample1.save()
2931
3446
 
2932
3447
 
3448
+ @pytest.mark.feature("clipboard-poll")
2933
3449
  class ContentSampleAdminTests(TestCase):
2934
3450
  def setUp(self):
2935
3451
  User = get_user_model()
@@ -3106,6 +3622,7 @@ class EmailOutboxTests(TestCase):
3106
3622
 
3107
3623
 
3108
3624
  class ClipboardTaskTests(TestCase):
3625
+ @pytest.mark.feature("clipboard-poll")
3109
3626
  @patch("nodes.tasks.pyperclip.paste")
3110
3627
  def test_sample_clipboard_task_creates_sample(self, mock_paste):
3111
3628
  mock_paste.return_value = "task text"
@@ -3130,6 +3647,7 @@ class ClipboardTaskTests(TestCase):
3130
3647
  ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
3131
3648
  )
3132
3649
 
3650
+ @pytest.mark.feature("screenshot-poll")
3133
3651
  @patch("nodes.tasks.capture_screenshot")
3134
3652
  def test_capture_node_screenshot_task(self, mock_capture):
3135
3653
  node = Node.objects.create(
@@ -3152,6 +3670,7 @@ class ClipboardTaskTests(TestCase):
3152
3670
  self.assertEqual(screenshot.path, "screenshots/test.png")
3153
3671
  self.assertEqual(screenshot.method, "TASK")
3154
3672
 
3673
+ @pytest.mark.feature("screenshot-poll")
3155
3674
  @patch("nodes.tasks.capture_screenshot")
3156
3675
  def test_capture_node_screenshot_handles_error(self, mock_capture):
3157
3676
  Node.objects.create(
@@ -3231,7 +3750,7 @@ class NodeRoleAdminTests(TestCase):
3231
3750
 
3232
3751
  class NodeFeatureFixtureTests(TestCase):
3233
3752
  def test_rfid_scanner_fixture_includes_control_role(self):
3234
- for name in ("Terminal", "Satellite", "Constellation", "Control"):
3753
+ for name in ("Terminal", "Satellite", "Watchtower", "Control"):
3235
3754
  NodeRole.objects.get_or_create(name=name)
3236
3755
  fixture_path = (
3237
3756
  Path(__file__).resolve().parent
@@ -3243,6 +3762,7 @@ class NodeFeatureFixtureTests(TestCase):
3243
3762
  role_names = set(feature.roles.values_list("name", flat=True))
3244
3763
  self.assertIn("Control", role_names)
3245
3764
 
3765
+ @pytest.mark.feature("ap-router")
3246
3766
  def test_ap_router_fixture_limits_roles(self):
3247
3767
  for name in ("Control", "Satellite"):
3248
3768
  NodeRole.objects.get_or_create(name=name)
@@ -3293,6 +3813,7 @@ class NodeFeatureTests(TestCase):
3293
3813
  self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
3294
3814
  self.assertEqual(feature.get_default_action(), action)
3295
3815
 
3816
+ @pytest.mark.feature("rpi-camera")
3296
3817
  def test_rpi_camera_feature_has_multiple_actions(self):
3297
3818
  feature = NodeFeature.objects.create(
3298
3819
  slug="rpi-camera", display="Raspberry Pi Camera"
@@ -3303,6 +3824,7 @@ class NodeFeatureTests(TestCase):
3303
3824
  self.assertIn("Take a Snapshot", labels)
3304
3825
  self.assertIn("View stream", labels)
3305
3826
 
3827
+ @pytest.mark.feature("audio-capture")
3306
3828
  def test_audio_capture_feature_has_view_waveform_action(self):
3307
3829
  feature = NodeFeature.objects.create(
3308
3830
  slug="audio-capture", display="Audio Capture"
@@ -3480,6 +4002,7 @@ class NodeFeatureTests(TestCase):
3480
4002
  )
3481
4003
  self.assertEqual(mock_find_command.call_count, 2)
3482
4004
 
4005
+ @pytest.mark.feature("ap-router")
3483
4006
  @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
3484
4007
  def test_ap_router_detection(self, mock_hosts):
3485
4008
  control_role, _ = NodeRole.objects.get_or_create(name="Control")
@@ -3499,6 +4022,7 @@ class NodeFeatureTests(TestCase):
3499
4022
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
3500
4023
  )
3501
4024
 
4025
+ @pytest.mark.feature("ap-router")
3502
4026
  @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
3503
4027
  def test_ap_router_detection_with_public_mode_lock(self, mock_hosts):
3504
4028
  control_role, _ = NodeRole.objects.get_or_create(name="Control")
@@ -3523,6 +4047,7 @@ class NodeFeatureTests(TestCase):
3523
4047
  NodeFeatureAssignment.objects.filter(node=node, feature=router).exists()
3524
4048
  )
3525
4049
 
4050
+ @pytest.mark.feature("ap-router")
3526
4051
  @patch("nodes.models.Node._hosts_gelectriic_ap", side_effect=[True, False])
3527
4052
  def test_ap_router_removed_when_not_hosting(self, mock_hosts):
3528
4053
  control_role, _ = NodeRole.objects.get_or_create(name="Control")