arthexis 0.1.19__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,27 @@ 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")
225
250
 
226
251
  def test_register_current_respects_node_hostname_env(self):
227
252
  with TemporaryDirectory() as tmp:
@@ -349,6 +374,43 @@ class NodeGetLocalTests(TestCase):
349
374
  self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
350
375
  self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
351
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
+
352
414
  def test_register_node_feature_toggle(self):
353
415
  NodeFeature.objects.get_or_create(
354
416
  slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
@@ -1276,6 +1338,7 @@ class NodeRegisterCurrentTests(TestCase):
1276
1338
  existing_role.refresh_from_db()
1277
1339
  self.assertEqual(existing_role.description, "updated via attachment")
1278
1340
 
1341
+ @pytest.mark.feature("clipboard-poll")
1279
1342
  def test_clipboard_polling_creates_task(self):
1280
1343
  feature, _ = NodeFeature.objects.get_or_create(
1281
1344
  slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
@@ -1293,6 +1356,7 @@ class NodeRegisterCurrentTests(TestCase):
1293
1356
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
1294
1357
  self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
1295
1358
 
1359
+ @pytest.mark.feature("screenshot-poll")
1296
1360
  def test_screenshot_polling_creates_task(self):
1297
1361
  feature, _ = NodeFeature.objects.get_or_create(
1298
1362
  slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
@@ -1421,6 +1485,7 @@ class NodeAdminTests(TestCase):
1421
1485
  action_url = reverse("admin:core_rfid_scan")
1422
1486
  self.assertContains(response, f'href="{action_url}"')
1423
1487
 
1488
+ @pytest.mark.feature("rpi-camera")
1424
1489
  def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
1425
1490
  node = self._create_local_node()
1426
1491
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1433,6 +1498,7 @@ class NodeAdminTests(TestCase):
1433
1498
  self.assertContains(response, f'href="{snapshot_url}"')
1434
1499
  self.assertContains(response, f'href="{stream_url}"')
1435
1500
 
1501
+ @pytest.mark.feature("audio-capture")
1436
1502
  def test_node_feature_list_shows_waveform_action_when_enabled(self):
1437
1503
  node = self._create_local_node()
1438
1504
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1443,6 +1509,7 @@ class NodeAdminTests(TestCase):
1443
1509
  action_url = reverse("admin:nodes_nodefeature_view_waveform")
1444
1510
  self.assertContains(response, f'href="{action_url}"')
1445
1511
 
1512
+ @pytest.mark.feature("screenshot-poll")
1446
1513
  def test_node_feature_list_hides_default_action_when_disabled(self):
1447
1514
  self._create_local_node()
1448
1515
  NodeFeature.objects.get_or_create(
@@ -1535,6 +1602,7 @@ class NodeAdminTests(TestCase):
1535
1602
  response, reverse("admin:nodes_node_register_current")
1536
1603
  )
1537
1604
 
1605
+ @pytest.mark.feature("screenshot-poll")
1538
1606
  @patch("nodes.admin.capture_screenshot")
1539
1607
  def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
1540
1608
  screenshot_dir = settings.LOG_DIR / "screenshots"
@@ -1580,6 +1648,48 @@ class NodeAdminTests(TestCase):
1580
1648
  self.assertEqual(response.status_code, 200)
1581
1649
  self.assertContains(response, "data:image/png;base64")
1582
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")
1583
1693
  @override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
1584
1694
  @patch("nodes.admin.capture_screenshot")
1585
1695
  def test_take_screenshots_action(self, mock_capture):
@@ -1612,6 +1722,7 @@ class NodeAdminTests(TestCase):
1612
1722
  samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
1613
1723
  self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
1614
1724
 
1725
+ @pytest.mark.feature("screenshot-poll")
1615
1726
  @patch("nodes.admin.capture_screenshot")
1616
1727
  def test_take_screenshot_default_action_creates_sample(
1617
1728
  self, mock_capture_screenshot
@@ -1686,6 +1797,7 @@ class NodeAdminTests(TestCase):
1686
1797
  response, "Completed 0 of 1 feature check(s) successfully.", html=False
1687
1798
  )
1688
1799
 
1800
+ @pytest.mark.feature("screenshot-poll")
1689
1801
  def test_enable_selected_features_enables_manual_feature(self):
1690
1802
  node = self._create_local_node()
1691
1803
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1730,6 +1842,7 @@ class NodeAdminTests(TestCase):
1730
1842
  html=False,
1731
1843
  )
1732
1844
 
1845
+ @pytest.mark.feature("screenshot-poll")
1733
1846
  def test_take_screenshot_default_action_requires_enabled_feature(self):
1734
1847
  self._create_local_node()
1735
1848
  NodeFeature.objects.get_or_create(
@@ -1744,6 +1857,7 @@ class NodeAdminTests(TestCase):
1744
1857
  self.assertEqual(ContentSample.objects.count(), 0)
1745
1858
  self.assertContains(response, "Screenshot Poll feature is not enabled")
1746
1859
 
1860
+ @pytest.mark.feature("rpi-camera")
1747
1861
  @patch("nodes.admin.capture_rpi_snapshot")
1748
1862
  def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
1749
1863
  node = self._create_local_node()
@@ -1766,6 +1880,7 @@ class NodeAdminTests(TestCase):
1766
1880
  change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
1767
1881
  self.assertEqual(response.redirect_chain[-1][0], change_url)
1768
1882
 
1883
+ @pytest.mark.feature("rpi-camera")
1769
1884
  def test_view_stream_requires_enabled_feature(self):
1770
1885
  self._create_local_node()
1771
1886
  NodeFeature.objects.get_or_create(
@@ -1781,6 +1896,7 @@ class NodeAdminTests(TestCase):
1781
1896
  response, "Raspberry Pi Camera feature is not enabled on this node."
1782
1897
  )
1783
1898
 
1899
+ @pytest.mark.feature("rpi-camera")
1784
1900
  def test_view_stream_renders_when_feature_enabled(self):
1785
1901
  node = self._create_local_node()
1786
1902
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1796,6 +1912,7 @@ class NodeAdminTests(TestCase):
1796
1912
  self.assertContains(response, expected_stream)
1797
1913
  self.assertContains(response, "camera-stream__frame")
1798
1914
 
1915
+ @pytest.mark.feature("rpi-camera")
1799
1916
  def test_view_stream_uses_configured_stream_url(self):
1800
1917
  node = self._create_local_node()
1801
1918
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1813,6 +1930,7 @@ class NodeAdminTests(TestCase):
1813
1930
  self.assertEqual(response.context_data["stream_embed"], "iframe")
1814
1931
  self.assertContains(response, configured_stream)
1815
1932
 
1933
+ @pytest.mark.feature("rpi-camera")
1816
1934
  def test_view_stream_detects_mjpeg_stream(self):
1817
1935
  node = self._create_local_node()
1818
1936
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1829,6 +1947,7 @@ class NodeAdminTests(TestCase):
1829
1947
  self.assertEqual(response.context_data["stream_embed"], "mjpeg")
1830
1948
  self.assertContains(response, "<img", html=False)
1831
1949
 
1950
+ @pytest.mark.feature("rpi-camera")
1832
1951
  def test_view_stream_marks_rtsp_stream_as_unsupported(self):
1833
1952
  node = self._create_local_node()
1834
1953
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1845,6 +1964,7 @@ class NodeAdminTests(TestCase):
1845
1964
  self.assertEqual(response.context_data["stream_embed"], "unsupported")
1846
1965
  self.assertContains(response, "camera-stream__unsupported")
1847
1966
 
1967
+ @pytest.mark.feature("audio-capture")
1848
1968
  def test_view_waveform_requires_enabled_feature(self):
1849
1969
  self._create_local_node()
1850
1970
  NodeFeature.objects.get_or_create(
@@ -1860,6 +1980,7 @@ class NodeAdminTests(TestCase):
1860
1980
  response, "Audio Capture feature is not enabled on this node."
1861
1981
  )
1862
1982
 
1983
+ @pytest.mark.feature("audio-capture")
1863
1984
  def test_view_waveform_renders_when_feature_enabled(self):
1864
1985
  node = self._create_local_node()
1865
1986
  feature, _ = NodeFeature.objects.get_or_create(
@@ -2182,6 +2303,160 @@ class NodeAdminTests(TestCase):
2182
2303
  self.assertEqual(post_data["mac_address"], local.mac_address)
2183
2304
 
2184
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
+
2185
2460
  class NodeRFIDAPITests(TestCase):
2186
2461
  def test_import_endpoint_applies_payload_without_creating_accounts(self):
2187
2462
  remote = Node.objects.create(
@@ -2358,11 +2633,11 @@ class NetMessageAdminTests(TransactionTestCase):
2358
2633
  class NetMessageReachTests(TestCase):
2359
2634
  def setUp(self):
2360
2635
  self.roles = {}
2361
- for name in ["Terminal", "Control", "Satellite", "Constellation"]:
2636
+ for name in ["Terminal", "Control", "Satellite", "Watchtower"]:
2362
2637
  self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
2363
2638
  self.nodes = {}
2364
2639
  for idx, name in enumerate(
2365
- ["Terminal", "Control", "Satellite", "Constellation"], start=1
2640
+ ["Terminal", "Control", "Satellite", "Watchtower"], start=1
2366
2641
  ):
2367
2642
  self.nodes[name] = Node.objects.create(
2368
2643
  hostname=name.lower(),
@@ -2406,15 +2681,15 @@ class NetMessageReachTests(TestCase):
2406
2681
  self.assertEqual(mock_post.call_count, 3)
2407
2682
 
2408
2683
  @patch("requests.post")
2409
- def test_constellation_reach_prioritizes_constellation(self, mock_post):
2684
+ def test_watchtower_reach_prioritizes_watchtower(self, mock_post):
2410
2685
  msg = NetMessage.objects.create(
2411
- subject="s", body="b", reach=self.roles["Constellation"]
2686
+ subject="s", body="b", reach=self.roles["Watchtower"]
2412
2687
  )
2413
2688
  with patch.object(Node, "get_local", return_value=None):
2414
2689
  msg.propagate()
2415
2690
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
2416
2691
  self.assertEqual(
2417
- roles, {"Constellation", "Satellite", "Control", "Terminal"}
2692
+ roles, {"Watchtower", "Satellite", "Control", "Terminal"}
2418
2693
  )
2419
2694
  self.assertEqual(mock_post.call_count, 4)
2420
2695
 
@@ -2703,6 +2978,238 @@ class NetMessagePropagationTests(TestCase):
2703
2978
  self.assertTrue(msg.complete)
2704
2979
 
2705
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,
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()
3211
+
3212
+
2706
3213
  class NetMessageSignatureTests(TestCase):
2707
3214
  def setUp(self):
2708
3215
  self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
@@ -2938,6 +3445,7 @@ class ContentSampleTransactionTests(TestCase):
2938
3445
  sample1.save()
2939
3446
 
2940
3447
 
3448
+ @pytest.mark.feature("clipboard-poll")
2941
3449
  class ContentSampleAdminTests(TestCase):
2942
3450
  def setUp(self):
2943
3451
  User = get_user_model()
@@ -3114,6 +3622,7 @@ class EmailOutboxTests(TestCase):
3114
3622
 
3115
3623
 
3116
3624
  class ClipboardTaskTests(TestCase):
3625
+ @pytest.mark.feature("clipboard-poll")
3117
3626
  @patch("nodes.tasks.pyperclip.paste")
3118
3627
  def test_sample_clipboard_task_creates_sample(self, mock_paste):
3119
3628
  mock_paste.return_value = "task text"
@@ -3138,6 +3647,7 @@ class ClipboardTaskTests(TestCase):
3138
3647
  ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
3139
3648
  )
3140
3649
 
3650
+ @pytest.mark.feature("screenshot-poll")
3141
3651
  @patch("nodes.tasks.capture_screenshot")
3142
3652
  def test_capture_node_screenshot_task(self, mock_capture):
3143
3653
  node = Node.objects.create(
@@ -3160,6 +3670,7 @@ class ClipboardTaskTests(TestCase):
3160
3670
  self.assertEqual(screenshot.path, "screenshots/test.png")
3161
3671
  self.assertEqual(screenshot.method, "TASK")
3162
3672
 
3673
+ @pytest.mark.feature("screenshot-poll")
3163
3674
  @patch("nodes.tasks.capture_screenshot")
3164
3675
  def test_capture_node_screenshot_handles_error(self, mock_capture):
3165
3676
  Node.objects.create(
@@ -3239,7 +3750,7 @@ class NodeRoleAdminTests(TestCase):
3239
3750
 
3240
3751
  class NodeFeatureFixtureTests(TestCase):
3241
3752
  def test_rfid_scanner_fixture_includes_control_role(self):
3242
- for name in ("Terminal", "Satellite", "Constellation", "Control"):
3753
+ for name in ("Terminal", "Satellite", "Watchtower", "Control"):
3243
3754
  NodeRole.objects.get_or_create(name=name)
3244
3755
  fixture_path = (
3245
3756
  Path(__file__).resolve().parent
@@ -3251,6 +3762,7 @@ class NodeFeatureFixtureTests(TestCase):
3251
3762
  role_names = set(feature.roles.values_list("name", flat=True))
3252
3763
  self.assertIn("Control", role_names)
3253
3764
 
3765
+ @pytest.mark.feature("ap-router")
3254
3766
  def test_ap_router_fixture_limits_roles(self):
3255
3767
  for name in ("Control", "Satellite"):
3256
3768
  NodeRole.objects.get_or_create(name=name)
@@ -3301,6 +3813,7 @@ class NodeFeatureTests(TestCase):
3301
3813
  self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
3302
3814
  self.assertEqual(feature.get_default_action(), action)
3303
3815
 
3816
+ @pytest.mark.feature("rpi-camera")
3304
3817
  def test_rpi_camera_feature_has_multiple_actions(self):
3305
3818
  feature = NodeFeature.objects.create(
3306
3819
  slug="rpi-camera", display="Raspberry Pi Camera"
@@ -3311,6 +3824,7 @@ class NodeFeatureTests(TestCase):
3311
3824
  self.assertIn("Take a Snapshot", labels)
3312
3825
  self.assertIn("View stream", labels)
3313
3826
 
3827
+ @pytest.mark.feature("audio-capture")
3314
3828
  def test_audio_capture_feature_has_view_waveform_action(self):
3315
3829
  feature = NodeFeature.objects.create(
3316
3830
  slug="audio-capture", display="Audio Capture"
@@ -3488,6 +4002,7 @@ class NodeFeatureTests(TestCase):
3488
4002
  )
3489
4003
  self.assertEqual(mock_find_command.call_count, 2)
3490
4004
 
4005
+ @pytest.mark.feature("ap-router")
3491
4006
  @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
3492
4007
  def test_ap_router_detection(self, mock_hosts):
3493
4008
  control_role, _ = NodeRole.objects.get_or_create(name="Control")
@@ -3507,6 +4022,7 @@ class NodeFeatureTests(TestCase):
3507
4022
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
3508
4023
  )
3509
4024
 
4025
+ @pytest.mark.feature("ap-router")
3510
4026
  @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
3511
4027
  def test_ap_router_detection_with_public_mode_lock(self, mock_hosts):
3512
4028
  control_role, _ = NodeRole.objects.get_or_create(name="Control")
@@ -3531,6 +4047,7 @@ class NodeFeatureTests(TestCase):
3531
4047
  NodeFeatureAssignment.objects.filter(node=node, feature=router).exists()
3532
4048
  )
3533
4049
 
4050
+ @pytest.mark.feature("ap-router")
3534
4051
  @patch("nodes.models.Node._hosts_gelectriic_ap", side_effect=[True, False])
3535
4052
  def test_ap_router_removed_when_not_hosting(self, mock_hosts):
3536
4053
  control_role, _ = NodeRole.objects.get_or_create(name="Control")