arthexis 0.1.19__py3-none-any.whl → 0.1.21__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"}
@@ -680,6 +742,55 @@ class NodeGetLocalTests(TestCase):
680
742
  self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
681
743
 
682
744
 
745
+ class NodeInfoViewTests(TestCase):
746
+ def setUp(self):
747
+ self.mac = "02:00:00:00:00:01"
748
+ self.patcher = patch("nodes.models.Node.get_current_mac", return_value=self.mac)
749
+ self.patcher.start()
750
+ self.addCleanup(self.patcher.stop)
751
+ self.node = Node.objects.create(
752
+ hostname="local",
753
+ address="10.0.0.10",
754
+ port=8000,
755
+ mac_address=self.mac,
756
+ public_endpoint="local",
757
+ current_relation=Node.Relation.SELF,
758
+ )
759
+ self.url = reverse("node-info")
760
+
761
+ def test_returns_https_port_for_secure_domain_request(self):
762
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
763
+ response = self.client.get(
764
+ self.url,
765
+ secure=True,
766
+ HTTP_HOST="arthexis.com",
767
+ )
768
+ self.assertEqual(response.status_code, 200)
769
+ payload = response.json()
770
+ self.assertEqual(payload["port"], 443)
771
+
772
+ def test_returns_http_port_for_plain_domain_request(self):
773
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
774
+ response = self.client.get(
775
+ self.url,
776
+ HTTP_HOST="arthexis.com",
777
+ )
778
+ self.assertEqual(response.status_code, 200)
779
+ payload = response.json()
780
+ self.assertEqual(payload["port"], 80)
781
+
782
+ def test_preserves_explicit_port_in_host_header(self):
783
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
784
+ response = self.client.get(
785
+ self.url,
786
+ secure=True,
787
+ HTTP_HOST="arthexis.com:8443",
788
+ )
789
+ self.assertEqual(response.status_code, 200)
790
+ payload = response.json()
791
+ self.assertEqual(payload["port"], 8443)
792
+
793
+
683
794
  class RegisterVisitorNodeMessageTests(TestCase):
684
795
  def setUp(self):
685
796
  self.client = Client()
@@ -1276,6 +1387,7 @@ class NodeRegisterCurrentTests(TestCase):
1276
1387
  existing_role.refresh_from_db()
1277
1388
  self.assertEqual(existing_role.description, "updated via attachment")
1278
1389
 
1390
+ @pytest.mark.feature("clipboard-poll")
1279
1391
  def test_clipboard_polling_creates_task(self):
1280
1392
  feature, _ = NodeFeature.objects.get_or_create(
1281
1393
  slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
@@ -1293,6 +1405,7 @@ class NodeRegisterCurrentTests(TestCase):
1293
1405
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
1294
1406
  self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
1295
1407
 
1408
+ @pytest.mark.feature("screenshot-poll")
1296
1409
  def test_screenshot_polling_creates_task(self):
1297
1410
  feature, _ = NodeFeature.objects.get_or_create(
1298
1411
  slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
@@ -1421,6 +1534,7 @@ class NodeAdminTests(TestCase):
1421
1534
  action_url = reverse("admin:core_rfid_scan")
1422
1535
  self.assertContains(response, f'href="{action_url}"')
1423
1536
 
1537
+ @pytest.mark.feature("rpi-camera")
1424
1538
  def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
1425
1539
  node = self._create_local_node()
1426
1540
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1433,6 +1547,7 @@ class NodeAdminTests(TestCase):
1433
1547
  self.assertContains(response, f'href="{snapshot_url}"')
1434
1548
  self.assertContains(response, f'href="{stream_url}"')
1435
1549
 
1550
+ @pytest.mark.feature("audio-capture")
1436
1551
  def test_node_feature_list_shows_waveform_action_when_enabled(self):
1437
1552
  node = self._create_local_node()
1438
1553
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1443,6 +1558,7 @@ class NodeAdminTests(TestCase):
1443
1558
  action_url = reverse("admin:nodes_nodefeature_view_waveform")
1444
1559
  self.assertContains(response, f'href="{action_url}"')
1445
1560
 
1561
+ @pytest.mark.feature("screenshot-poll")
1446
1562
  def test_node_feature_list_hides_default_action_when_disabled(self):
1447
1563
  self._create_local_node()
1448
1564
  NodeFeature.objects.get_or_create(
@@ -1535,6 +1651,7 @@ class NodeAdminTests(TestCase):
1535
1651
  response, reverse("admin:nodes_node_register_current")
1536
1652
  )
1537
1653
 
1654
+ @pytest.mark.feature("screenshot-poll")
1538
1655
  @patch("nodes.admin.capture_screenshot")
1539
1656
  def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
1540
1657
  screenshot_dir = settings.LOG_DIR / "screenshots"
@@ -1580,6 +1697,48 @@ class NodeAdminTests(TestCase):
1580
1697
  self.assertEqual(response.status_code, 200)
1581
1698
  self.assertContains(response, "data:image/png;base64")
1582
1699
 
1700
+ @patch("nodes.admin.requests.post")
1701
+ def test_proxy_view_uses_remote_login_url(self, mock_post):
1702
+ self.client.get(reverse("admin:nodes_node_register_current"))
1703
+ local_node = Node.objects.get()
1704
+ remote = Node.objects.create(
1705
+ hostname="remote",
1706
+ address="192.0.2.10",
1707
+ port=8443,
1708
+ mac_address="aa:bb:cc:dd:ee:ff",
1709
+ )
1710
+ mock_post.return_value = SimpleNamespace(
1711
+ ok=True,
1712
+ json=lambda: {
1713
+ "login_url": "https://remote.example/nodes/proxy/login/token",
1714
+ "expires": "2025-01-01T00:00:00",
1715
+ },
1716
+ status_code=200,
1717
+ text="ok",
1718
+ )
1719
+ response = self.client.get(
1720
+ reverse("admin:nodes_node_proxy", args=[remote.pk])
1721
+ )
1722
+ self.assertEqual(response.status_code, 200)
1723
+ self.assertTemplateUsed(response, "admin/nodes/node/proxy.html")
1724
+ self.assertContains(response, "<iframe", html=False)
1725
+ mock_post.assert_called()
1726
+ payload = json.loads(mock_post.call_args[1]["data"])
1727
+ self.assertEqual(payload.get("requester"), str(local_node.uuid))
1728
+
1729
+ def test_proxy_link_displayed_for_remote_nodes(self):
1730
+ Node.objects.create(
1731
+ hostname="remote",
1732
+ address="203.0.113.1",
1733
+ port=8000,
1734
+ mac_address="aa:aa:aa:aa:aa:01",
1735
+ )
1736
+ response = self.client.get(reverse("admin:nodes_node_changelist"))
1737
+ proxy_url = reverse("admin:nodes_node_proxy", args=[1])
1738
+ self.assertContains(response, proxy_url)
1739
+
1740
+
1741
+ @pytest.mark.feature("screenshot-poll")
1583
1742
  @override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
1584
1743
  @patch("nodes.admin.capture_screenshot")
1585
1744
  def test_take_screenshots_action(self, mock_capture):
@@ -1612,6 +1771,7 @@ class NodeAdminTests(TestCase):
1612
1771
  samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
1613
1772
  self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
1614
1773
 
1774
+ @pytest.mark.feature("screenshot-poll")
1615
1775
  @patch("nodes.admin.capture_screenshot")
1616
1776
  def test_take_screenshot_default_action_creates_sample(
1617
1777
  self, mock_capture_screenshot
@@ -1686,6 +1846,7 @@ class NodeAdminTests(TestCase):
1686
1846
  response, "Completed 0 of 1 feature check(s) successfully.", html=False
1687
1847
  )
1688
1848
 
1849
+ @pytest.mark.feature("screenshot-poll")
1689
1850
  def test_enable_selected_features_enables_manual_feature(self):
1690
1851
  node = self._create_local_node()
1691
1852
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1730,6 +1891,7 @@ class NodeAdminTests(TestCase):
1730
1891
  html=False,
1731
1892
  )
1732
1893
 
1894
+ @pytest.mark.feature("screenshot-poll")
1733
1895
  def test_take_screenshot_default_action_requires_enabled_feature(self):
1734
1896
  self._create_local_node()
1735
1897
  NodeFeature.objects.get_or_create(
@@ -1744,6 +1906,7 @@ class NodeAdminTests(TestCase):
1744
1906
  self.assertEqual(ContentSample.objects.count(), 0)
1745
1907
  self.assertContains(response, "Screenshot Poll feature is not enabled")
1746
1908
 
1909
+ @pytest.mark.feature("rpi-camera")
1747
1910
  @patch("nodes.admin.capture_rpi_snapshot")
1748
1911
  def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
1749
1912
  node = self._create_local_node()
@@ -1766,6 +1929,7 @@ class NodeAdminTests(TestCase):
1766
1929
  change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
1767
1930
  self.assertEqual(response.redirect_chain[-1][0], change_url)
1768
1931
 
1932
+ @pytest.mark.feature("rpi-camera")
1769
1933
  def test_view_stream_requires_enabled_feature(self):
1770
1934
  self._create_local_node()
1771
1935
  NodeFeature.objects.get_or_create(
@@ -1781,6 +1945,7 @@ class NodeAdminTests(TestCase):
1781
1945
  response, "Raspberry Pi Camera feature is not enabled on this node."
1782
1946
  )
1783
1947
 
1948
+ @pytest.mark.feature("rpi-camera")
1784
1949
  def test_view_stream_renders_when_feature_enabled(self):
1785
1950
  node = self._create_local_node()
1786
1951
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1796,6 +1961,7 @@ class NodeAdminTests(TestCase):
1796
1961
  self.assertContains(response, expected_stream)
1797
1962
  self.assertContains(response, "camera-stream__frame")
1798
1963
 
1964
+ @pytest.mark.feature("rpi-camera")
1799
1965
  def test_view_stream_uses_configured_stream_url(self):
1800
1966
  node = self._create_local_node()
1801
1967
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1813,6 +1979,7 @@ class NodeAdminTests(TestCase):
1813
1979
  self.assertEqual(response.context_data["stream_embed"], "iframe")
1814
1980
  self.assertContains(response, configured_stream)
1815
1981
 
1982
+ @pytest.mark.feature("rpi-camera")
1816
1983
  def test_view_stream_detects_mjpeg_stream(self):
1817
1984
  node = self._create_local_node()
1818
1985
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1829,6 +1996,7 @@ class NodeAdminTests(TestCase):
1829
1996
  self.assertEqual(response.context_data["stream_embed"], "mjpeg")
1830
1997
  self.assertContains(response, "<img", html=False)
1831
1998
 
1999
+ @pytest.mark.feature("rpi-camera")
1832
2000
  def test_view_stream_marks_rtsp_stream_as_unsupported(self):
1833
2001
  node = self._create_local_node()
1834
2002
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1845,6 +2013,7 @@ class NodeAdminTests(TestCase):
1845
2013
  self.assertEqual(response.context_data["stream_embed"], "unsupported")
1846
2014
  self.assertContains(response, "camera-stream__unsupported")
1847
2015
 
2016
+ @pytest.mark.feature("audio-capture")
1848
2017
  def test_view_waveform_requires_enabled_feature(self):
1849
2018
  self._create_local_node()
1850
2019
  NodeFeature.objects.get_or_create(
@@ -1860,6 +2029,7 @@ class NodeAdminTests(TestCase):
1860
2029
  response, "Audio Capture feature is not enabled on this node."
1861
2030
  )
1862
2031
 
2032
+ @pytest.mark.feature("audio-capture")
1863
2033
  def test_view_waveform_renders_when_feature_enabled(self):
1864
2034
  node = self._create_local_node()
1865
2035
  feature, _ = NodeFeature.objects.get_or_create(
@@ -2182,6 +2352,160 @@ class NodeAdminTests(TestCase):
2182
2352
  self.assertEqual(post_data["mac_address"], local.mac_address)
2183
2353
 
2184
2354
 
2355
+ class NodeProxyGatewayTests(TestCase):
2356
+ def setUp(self):
2357
+ cache.clear()
2358
+ self.client = Client()
2359
+ self.private_key = rsa.generate_private_key(
2360
+ public_exponent=65537, key_size=2048
2361
+ )
2362
+ public_key = self.private_key.public_key().public_bytes(
2363
+ encoding=serialization.Encoding.PEM,
2364
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
2365
+ ).decode()
2366
+ self.node = Node.objects.create(
2367
+ hostname="requester",
2368
+ address="127.0.0.1",
2369
+ port=8000,
2370
+ mac_address="aa:bb:cc:dd:ee:aa",
2371
+ public_key=public_key,
2372
+ )
2373
+ patcher = patch("requests.post")
2374
+ self.addCleanup(patcher.stop)
2375
+ self.mock_requests_post = patcher.start()
2376
+ self.mock_requests_post.return_value = SimpleNamespace(
2377
+ ok=True,
2378
+ status_code=200,
2379
+ json=lambda: {},
2380
+ text="",
2381
+ )
2382
+
2383
+ def tearDown(self):
2384
+ cache.clear()
2385
+
2386
+ def _sign(self, payload):
2387
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
2388
+ signature = base64.b64encode(
2389
+ self.private_key.sign(
2390
+ body.encode(), padding.PKCS1v15(), hashes.SHA256()
2391
+ )
2392
+ ).decode()
2393
+ return body, signature
2394
+
2395
+ def test_proxy_session_creates_login_url(self):
2396
+ payload = {
2397
+ "requester": str(self.node.uuid),
2398
+ "user": {
2399
+ "username": "proxy-user",
2400
+ "email": "proxy@example.com",
2401
+ "first_name": "Proxy",
2402
+ "last_name": "User",
2403
+ "is_staff": True,
2404
+ "is_superuser": True,
2405
+ "groups": [],
2406
+ "permissions": [],
2407
+ },
2408
+ "target": "/admin/",
2409
+ }
2410
+ body, signature = self._sign(payload)
2411
+ response = self.client.post(
2412
+ reverse("node-proxy-session"),
2413
+ data=body,
2414
+ content_type="application/json",
2415
+ HTTP_X_SIGNATURE=signature,
2416
+ )
2417
+ self.assertEqual(response.status_code, 200)
2418
+ data = response.json()
2419
+ self.assertIn("login_url", data)
2420
+ user = get_user_model().objects.get(username="proxy-user")
2421
+ self.assertTrue(user.is_staff)
2422
+ parsed = urlparse(data["login_url"])
2423
+ login_response = self.client.get(parsed.path)
2424
+ self.assertEqual(login_response.status_code, 302)
2425
+ self.assertEqual(login_response["Location"], "/admin/")
2426
+ self.assertEqual(self.client.session.get("_auth_user_id"), str(user.pk))
2427
+ second = self.client.get(parsed.path)
2428
+ self.assertEqual(second.status_code, 410)
2429
+
2430
+ def test_proxy_execute_lists_nodes(self):
2431
+ Node.objects.create(
2432
+ hostname="target",
2433
+ address="127.0.0.5",
2434
+ port=8010,
2435
+ mac_address="aa:bb:cc:dd:ee:bb",
2436
+ )
2437
+ payload = {
2438
+ "requester": str(self.node.uuid),
2439
+ "action": "list",
2440
+ "model": "nodes.Node",
2441
+ "filters": {"hostname": "target"},
2442
+ "credentials": {
2443
+ "username": "suite-user",
2444
+ "password": "secret",
2445
+ "first_name": "Suite",
2446
+ "last_name": "User",
2447
+ },
2448
+ }
2449
+ body, signature = self._sign(payload)
2450
+ response = self.client.post(
2451
+ reverse("node-proxy-execute"),
2452
+ data=body,
2453
+ content_type="application/json",
2454
+ HTTP_X_SIGNATURE=signature,
2455
+ )
2456
+ self.assertEqual(response.status_code, 200)
2457
+ data = response.json()
2458
+ self.assertEqual(len(data.get("objects", [])), 1)
2459
+ record = data["objects"][0]
2460
+ self.assertEqual(record["fields"]["hostname"], "target")
2461
+ user = get_user_model().objects.get(username="suite-user")
2462
+ self.assertTrue(user.is_superuser)
2463
+
2464
+ def test_proxy_execute_requires_valid_password_for_existing_user(self):
2465
+ User = get_user_model()
2466
+ User.objects.create_user(username="suite-user", password="correct")
2467
+ payload = {
2468
+ "requester": str(self.node.uuid),
2469
+ "action": "list",
2470
+ "model": "nodes.Node",
2471
+ "credentials": {
2472
+ "username": "suite-user",
2473
+ "password": "wrong",
2474
+ },
2475
+ }
2476
+ body, signature = self._sign(payload)
2477
+ response = self.client.post(
2478
+ reverse("node-proxy-execute"),
2479
+ data=body,
2480
+ content_type="application/json",
2481
+ HTTP_X_SIGNATURE=signature,
2482
+ )
2483
+ self.assertEqual(response.status_code, 403)
2484
+
2485
+ def test_proxy_execute_schema_returns_models(self):
2486
+ payload = {
2487
+ "requester": str(self.node.uuid),
2488
+ "action": "schema",
2489
+ "credentials": {
2490
+ "username": "suite-user",
2491
+ "password": "secret",
2492
+ },
2493
+ }
2494
+ body, signature = self._sign(payload)
2495
+ response = self.client.post(
2496
+ reverse("node-proxy-execute"),
2497
+ data=body,
2498
+ content_type="application/json",
2499
+ HTTP_X_SIGNATURE=signature,
2500
+ )
2501
+ self.assertEqual(response.status_code, 200)
2502
+ data = response.json()
2503
+ models = data.get("models", [])
2504
+ self.assertTrue(models)
2505
+ suite_names = {entry.get("suite_name") for entry in models}
2506
+ self.assertIn("Nodes", suite_names)
2507
+
2508
+
2185
2509
  class NodeRFIDAPITests(TestCase):
2186
2510
  def test_import_endpoint_applies_payload_without_creating_accounts(self):
2187
2511
  remote = Node.objects.create(
@@ -2358,11 +2682,11 @@ class NetMessageAdminTests(TransactionTestCase):
2358
2682
  class NetMessageReachTests(TestCase):
2359
2683
  def setUp(self):
2360
2684
  self.roles = {}
2361
- for name in ["Terminal", "Control", "Satellite", "Constellation"]:
2685
+ for name in ["Terminal", "Control", "Satellite", "Watchtower"]:
2362
2686
  self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
2363
2687
  self.nodes = {}
2364
2688
  for idx, name in enumerate(
2365
- ["Terminal", "Control", "Satellite", "Constellation"], start=1
2689
+ ["Terminal", "Control", "Satellite", "Watchtower"], start=1
2366
2690
  ):
2367
2691
  self.nodes[name] = Node.objects.create(
2368
2692
  hostname=name.lower(),
@@ -2406,15 +2730,15 @@ class NetMessageReachTests(TestCase):
2406
2730
  self.assertEqual(mock_post.call_count, 3)
2407
2731
 
2408
2732
  @patch("requests.post")
2409
- def test_constellation_reach_prioritizes_constellation(self, mock_post):
2733
+ def test_watchtower_reach_prioritizes_watchtower(self, mock_post):
2410
2734
  msg = NetMessage.objects.create(
2411
- subject="s", body="b", reach=self.roles["Constellation"]
2735
+ subject="s", body="b", reach=self.roles["Watchtower"]
2412
2736
  )
2413
2737
  with patch.object(Node, "get_local", return_value=None):
2414
2738
  msg.propagate()
2415
2739
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
2416
2740
  self.assertEqual(
2417
- roles, {"Constellation", "Satellite", "Control", "Terminal"}
2741
+ roles, {"Watchtower", "Satellite", "Control", "Terminal"}
2418
2742
  )
2419
2743
  self.assertEqual(mock_post.call_count, 4)
2420
2744
 
@@ -2703,6 +3027,238 @@ class NetMessagePropagationTests(TestCase):
2703
3027
  self.assertTrue(msg.complete)
2704
3028
 
2705
3029
 
3030
+ class NetMessageQueueTests(TestCase):
3031
+ def setUp(self):
3032
+ self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
3033
+ self.feature, _ = NodeFeature.objects.get_or_create(
3034
+ slug="celery-queue", defaults={"display": "Celery Queue"}
3035
+ )
3036
+
3037
+ def test_propagate_queues_unreachable_downstream(self):
3038
+ local = Node.objects.create(
3039
+ hostname="local",
3040
+ address="10.0.0.1",
3041
+ port=8000,
3042
+ mac_address="00:11:22:33:44:10",
3043
+ role=self.role,
3044
+ public_endpoint="local",
3045
+ )
3046
+ downstream = Node.objects.create(
3047
+ hostname="downstream",
3048
+ address="10.0.0.2",
3049
+ port=8001,
3050
+ mac_address="00:11:22:33:44:11",
3051
+ role=self.role,
3052
+ current_relation=Node.Relation.DOWNSTREAM,
3053
+ )
3054
+ message = NetMessage.objects.create(subject="Queued", body="Body", reach=self.role)
3055
+ with patch.object(Node, "get_local", return_value=local), patch.object(
3056
+ Node, "get_private_key", return_value=None
3057
+ ), patch("core.notifications.notify", return_value=False), patch(
3058
+ "requests.post", side_effect=Exception("fail")
3059
+ ):
3060
+ message.propagate()
3061
+
3062
+ entry = PendingNetMessage.objects.get(node=downstream, message=message)
3063
+ self.assertIn(str(downstream.uuid), entry.seen)
3064
+ self.assertGreater(entry.stale_at, timezone.now())
3065
+
3066
+ def test_queue_limit_enforced(self):
3067
+ downstream = Node.objects.create(
3068
+ hostname="limit",
3069
+ address="10.0.0.3",
3070
+ port=8002,
3071
+ mac_address="00:11:22:33:44:12",
3072
+ role=self.role,
3073
+ current_relation=Node.Relation.DOWNSTREAM,
3074
+ message_queue_length=1,
3075
+ )
3076
+ msg1 = NetMessage.objects.create(subject="Old", body="One", reach=self.role)
3077
+ msg2 = NetMessage.objects.create(subject="New", body="Two", reach=self.role)
3078
+
3079
+ msg1.queue_for_node(downstream, [str(downstream.uuid)])
3080
+ msg2.queue_for_node(downstream, [str(downstream.uuid)])
3081
+
3082
+ entries = list(PendingNetMessage.objects.filter(node=downstream))
3083
+ self.assertEqual(len(entries), 1)
3084
+ self.assertEqual(entries[0].message, msg2)
3085
+
3086
+ def test_queue_duplicate_updates_stale(self):
3087
+ downstream = Node.objects.create(
3088
+ hostname="dup",
3089
+ address="10.0.0.4",
3090
+ port=8003,
3091
+ mac_address="00:11:22:33:44:13",
3092
+ role=self.role,
3093
+ current_relation=Node.Relation.DOWNSTREAM,
3094
+ )
3095
+ message = NetMessage.objects.create(subject="Dup", body="Dup", reach=self.role)
3096
+ first = timezone.now()
3097
+ second = first + timedelta(minutes=5)
3098
+ with patch(
3099
+ "nodes.models.timezone.now", side_effect=[first, second, second]
3100
+ ):
3101
+ message.queue_for_node(downstream, ["first"])
3102
+ message.queue_for_node(downstream, ["second"])
3103
+
3104
+ entry = PendingNetMessage.objects.get(node=downstream, message=message)
3105
+ self.assertEqual(entry.seen, ["second"])
3106
+ self.assertEqual(entry.queued_at, second)
3107
+ self.assertEqual(entry.stale_at, second + timedelta(hours=1))
3108
+
3109
+ def test_pull_endpoint_returns_and_clears_messages(self):
3110
+ local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3111
+ downstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3112
+ local = Node.objects.create(
3113
+ hostname="hub",
3114
+ address="10.0.0.5",
3115
+ port=8004,
3116
+ mac_address="00:11:22:33:44:14",
3117
+ role=self.role,
3118
+ public_endpoint="hub",
3119
+ )
3120
+ downstream = Node.objects.create(
3121
+ hostname="remote",
3122
+ address="10.0.0.6",
3123
+ port=8005,
3124
+ mac_address="00:11:22:33:44:15",
3125
+ role=self.role,
3126
+ current_relation=Node.Relation.DOWNSTREAM,
3127
+ public_key=downstream_key.public_key()
3128
+ .public_bytes(
3129
+ serialization.Encoding.PEM,
3130
+ serialization.PublicFormat.SubjectPublicKeyInfo,
3131
+ )
3132
+ .decode(),
3133
+ )
3134
+ message = NetMessage.objects.create(subject="Fresh", body="Body", reach=self.role)
3135
+ stale_message = NetMessage.objects.create(subject="Stale", body="Body", reach=self.role)
3136
+ now = timezone.now()
3137
+ PendingNetMessage.objects.create(
3138
+ node=downstream,
3139
+ message=message,
3140
+ seen=[str(downstream.uuid)],
3141
+ stale_at=now + timedelta(minutes=30),
3142
+ )
3143
+ stale_entry = PendingNetMessage.objects.create(
3144
+ node=downstream,
3145
+ message=stale_message,
3146
+ seen=["stale"],
3147
+ stale_at=now - timedelta(minutes=5),
3148
+ )
3149
+ PendingNetMessage.objects.filter(pk=stale_entry.pk).update(
3150
+ queued_at=now - timedelta(minutes=5)
3151
+ )
3152
+
3153
+ def fake_get_private(node_obj):
3154
+ if node_obj.pk == local.pk:
3155
+ return local_key
3156
+ return None
3157
+
3158
+ payload = {"requester": str(downstream.uuid)}
3159
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
3160
+ signature = base64.b64encode(
3161
+ downstream_key.sign(
3162
+ body.encode(),
3163
+ padding.PKCS1v15(),
3164
+ hashes.SHA256(),
3165
+ )
3166
+ ).decode()
3167
+
3168
+ with patch.object(Node, "get_local", return_value=local), patch.object(
3169
+ Node, "get_private_key", return_value=local_key
3170
+ ):
3171
+ response = self.client.post(
3172
+ reverse("net-message-pull"),
3173
+ data=body,
3174
+ content_type="application/json",
3175
+ HTTP_X_SIGNATURE=signature,
3176
+ )
3177
+
3178
+ self.assertEqual(response.status_code, 200)
3179
+ data = response.json()
3180
+ self.assertEqual(len(data.get("messages", [])), 1)
3181
+ payload_data = data["messages"][0]["payload"]
3182
+ self.assertEqual(payload_data["uuid"], str(message.uuid))
3183
+ self.assertFalse(
3184
+ PendingNetMessage.objects.filter(node=downstream, message=message).exists()
3185
+ )
3186
+ self.assertFalse(
3187
+ PendingNetMessage.objects.filter(
3188
+ node=downstream, message=stale_message
3189
+ ).exists()
3190
+ )
3191
+ response_signature = data["messages"][0]["signature"]
3192
+ local_public = local_key.public_key()
3193
+ local_public.verify(
3194
+ base64.b64decode(response_signature),
3195
+ json.dumps(payload_data, separators=(",", ":"), sort_keys=True).encode(),
3196
+ padding.PKCS1v15(),
3197
+ hashes.SHA256(),
3198
+ )
3199
+
3200
+ def test_poll_task_fetches_messages(self):
3201
+ local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3202
+ upstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3203
+ local = Node.objects.create(
3204
+ hostname="downstream",
3205
+ address="10.0.0.7",
3206
+ port=8006,
3207
+ mac_address="00:11:22:33:44:16",
3208
+ role=self.role,
3209
+ public_endpoint="downstream",
3210
+ )
3211
+ upstream = Node.objects.create(
3212
+ hostname="upstream",
3213
+ address="127.0.0.2",
3214
+ port=8010,
3215
+ mac_address="00:11:22:33:44:17",
3216
+ role=self.role,
3217
+ current_relation=Node.Relation.UPSTREAM,
3218
+ public_key=upstream_key.public_key()
3219
+ .public_bytes(
3220
+ serialization.Encoding.PEM,
3221
+ serialization.PublicFormat.SubjectPublicKeyInfo,
3222
+ )
3223
+ .decode(),
3224
+ )
3225
+ NodeFeatureAssignment.objects.create(node=local, feature=self.feature)
3226
+ payload = {
3227
+ "uuid": str(uuid.uuid4()),
3228
+ "subject": "Update",
3229
+ "body": "Body",
3230
+ "seen": [str(local.uuid)],
3231
+ "origin": str(upstream.uuid),
3232
+ "sender": str(upstream.uuid),
3233
+ }
3234
+ payload_text = json.dumps(payload, separators=(",", ":"), sort_keys=True)
3235
+ payload_signature = base64.b64encode(
3236
+ upstream_key.sign(
3237
+ payload_text.encode(),
3238
+ padding.PKCS1v15(),
3239
+ hashes.SHA256(),
3240
+ )
3241
+ ).decode()
3242
+ response = MagicMock()
3243
+ response.ok = True
3244
+ response.json.return_value = {
3245
+ "messages": [{"payload": payload, "signature": payload_signature}]
3246
+ }
3247
+
3248
+ with patch.object(Node, "get_local", return_value=local), patch.object(
3249
+ Node, "get_private_key", return_value=local_key
3250
+ ), patch("nodes.tasks.requests.post", return_value=response) as mock_post, patch.object(
3251
+ NetMessage, "propagate"
3252
+ ) as mock_propagate:
3253
+ poll_unreachable_upstream()
3254
+
3255
+ created = NetMessage.objects.get(uuid=payload["uuid"])
3256
+ self.assertEqual(created.subject, "Update")
3257
+ self.assertEqual(created.node_origin, upstream)
3258
+ mock_post.assert_called_once()
3259
+ mock_propagate.assert_called_once()
3260
+
3261
+
2706
3262
  class NetMessageSignatureTests(TestCase):
2707
3263
  def setUp(self):
2708
3264
  self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
@@ -2938,6 +3494,7 @@ class ContentSampleTransactionTests(TestCase):
2938
3494
  sample1.save()
2939
3495
 
2940
3496
 
3497
+ @pytest.mark.feature("clipboard-poll")
2941
3498
  class ContentSampleAdminTests(TestCase):
2942
3499
  def setUp(self):
2943
3500
  User = get_user_model()
@@ -3114,6 +3671,7 @@ class EmailOutboxTests(TestCase):
3114
3671
 
3115
3672
 
3116
3673
  class ClipboardTaskTests(TestCase):
3674
+ @pytest.mark.feature("clipboard-poll")
3117
3675
  @patch("nodes.tasks.pyperclip.paste")
3118
3676
  def test_sample_clipboard_task_creates_sample(self, mock_paste):
3119
3677
  mock_paste.return_value = "task text"
@@ -3138,6 +3696,7 @@ class ClipboardTaskTests(TestCase):
3138
3696
  ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
3139
3697
  )
3140
3698
 
3699
+ @pytest.mark.feature("screenshot-poll")
3141
3700
  @patch("nodes.tasks.capture_screenshot")
3142
3701
  def test_capture_node_screenshot_task(self, mock_capture):
3143
3702
  node = Node.objects.create(
@@ -3160,6 +3719,7 @@ class ClipboardTaskTests(TestCase):
3160
3719
  self.assertEqual(screenshot.path, "screenshots/test.png")
3161
3720
  self.assertEqual(screenshot.method, "TASK")
3162
3721
 
3722
+ @pytest.mark.feature("screenshot-poll")
3163
3723
  @patch("nodes.tasks.capture_screenshot")
3164
3724
  def test_capture_node_screenshot_handles_error(self, mock_capture):
3165
3725
  Node.objects.create(
@@ -3239,7 +3799,7 @@ class NodeRoleAdminTests(TestCase):
3239
3799
 
3240
3800
  class NodeFeatureFixtureTests(TestCase):
3241
3801
  def test_rfid_scanner_fixture_includes_control_role(self):
3242
- for name in ("Terminal", "Satellite", "Constellation", "Control"):
3802
+ for name in ("Terminal", "Satellite", "Watchtower", "Control"):
3243
3803
  NodeRole.objects.get_or_create(name=name)
3244
3804
  fixture_path = (
3245
3805
  Path(__file__).resolve().parent
@@ -3251,6 +3811,7 @@ class NodeFeatureFixtureTests(TestCase):
3251
3811
  role_names = set(feature.roles.values_list("name", flat=True))
3252
3812
  self.assertIn("Control", role_names)
3253
3813
 
3814
+ @pytest.mark.feature("ap-router")
3254
3815
  def test_ap_router_fixture_limits_roles(self):
3255
3816
  for name in ("Control", "Satellite"):
3256
3817
  NodeRole.objects.get_or_create(name=name)
@@ -3301,6 +3862,7 @@ class NodeFeatureTests(TestCase):
3301
3862
  self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
3302
3863
  self.assertEqual(feature.get_default_action(), action)
3303
3864
 
3865
+ @pytest.mark.feature("rpi-camera")
3304
3866
  def test_rpi_camera_feature_has_multiple_actions(self):
3305
3867
  feature = NodeFeature.objects.create(
3306
3868
  slug="rpi-camera", display="Raspberry Pi Camera"
@@ -3311,6 +3873,7 @@ class NodeFeatureTests(TestCase):
3311
3873
  self.assertIn("Take a Snapshot", labels)
3312
3874
  self.assertIn("View stream", labels)
3313
3875
 
3876
+ @pytest.mark.feature("audio-capture")
3314
3877
  def test_audio_capture_feature_has_view_waveform_action(self):
3315
3878
  feature = NodeFeature.objects.create(
3316
3879
  slug="audio-capture", display="Audio Capture"
@@ -3488,6 +4051,7 @@ class NodeFeatureTests(TestCase):
3488
4051
  )
3489
4052
  self.assertEqual(mock_find_command.call_count, 2)
3490
4053
 
4054
+ @pytest.mark.feature("ap-router")
3491
4055
  @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
3492
4056
  def test_ap_router_detection(self, mock_hosts):
3493
4057
  control_role, _ = NodeRole.objects.get_or_create(name="Control")
@@ -3507,6 +4071,7 @@ class NodeFeatureTests(TestCase):
3507
4071
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
3508
4072
  )
3509
4073
 
4074
+ @pytest.mark.feature("ap-router")
3510
4075
  @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
3511
4076
  def test_ap_router_detection_with_public_mode_lock(self, mock_hosts):
3512
4077
  control_role, _ = NodeRole.objects.get_or_create(name="Control")
@@ -3531,6 +4096,7 @@ class NodeFeatureTests(TestCase):
3531
4096
  NodeFeatureAssignment.objects.filter(node=node, feature=router).exists()
3532
4097
  )
3533
4098
 
4099
+ @pytest.mark.feature("ap-router")
3534
4100
  @patch("nodes.models.Node._hosts_gelectriic_ap", side_effect=[True, False])
3535
4101
  def test_ap_router_removed_when_not_hosting(self, mock_hosts):
3536
4102
  control_role, _ = NodeRole.objects.get_or_create(name="Control")