arthexis 0.1.16__py3-none-any.whl → 0.1.26__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.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

Files changed (63) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +15 -30
  6. config/urls.py +53 -1
  7. core/admin.py +540 -450
  8. core/apps.py +0 -6
  9. core/auto_upgrade.py +19 -4
  10. core/backends.py +13 -3
  11. core/changelog.py +66 -5
  12. core/environment.py +4 -5
  13. core/models.py +1566 -203
  14. core/notifications.py +1 -1
  15. core/reference_utils.py +10 -11
  16. core/release.py +55 -7
  17. core/sigil_builder.py +2 -2
  18. core/sigil_resolver.py +1 -66
  19. core/system.py +268 -2
  20. core/tasks.py +174 -48
  21. core/tests.py +314 -16
  22. core/user_data.py +42 -2
  23. core/views.py +278 -183
  24. nodes/admin.py +557 -65
  25. nodes/apps.py +11 -0
  26. nodes/models.py +658 -113
  27. nodes/rfid_sync.py +1 -1
  28. nodes/tasks.py +97 -2
  29. nodes/tests.py +1212 -116
  30. nodes/urls.py +15 -1
  31. nodes/utils.py +51 -3
  32. nodes/views.py +1239 -154
  33. ocpp/admin.py +979 -152
  34. ocpp/consumers.py +268 -28
  35. ocpp/models.py +488 -3
  36. ocpp/network.py +398 -0
  37. ocpp/store.py +6 -4
  38. ocpp/tasks.py +296 -2
  39. ocpp/test_export_import.py +1 -0
  40. ocpp/test_rfid.py +121 -4
  41. ocpp/tests.py +950 -11
  42. ocpp/transactions_io.py +9 -1
  43. ocpp/urls.py +3 -3
  44. ocpp/views.py +596 -51
  45. pages/admin.py +262 -30
  46. pages/apps.py +35 -0
  47. pages/context_processors.py +26 -21
  48. pages/defaults.py +1 -1
  49. pages/forms.py +31 -8
  50. pages/middleware.py +6 -2
  51. pages/models.py +77 -2
  52. pages/module_defaults.py +5 -5
  53. pages/site_config.py +137 -0
  54. pages/tests.py +885 -109
  55. pages/urls.py +13 -2
  56. pages/utils.py +70 -0
  57. pages/views.py +558 -55
  58. arthexis-0.1.16.dist-info/RECORD +0 -111
  59. core/workgroup_urls.py +0 -17
  60. core/workgroup_views.py +0 -94
  61. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  62. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
  63. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
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
@@ -14,10 +15,11 @@ else: # pragma: no cover - fallback when pytest fixtures are unavailable
14
15
  django.setup()
15
16
 
16
17
  from pathlib import Path
17
- from types import SimpleNamespace
18
+ from types import MethodType, 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
@@ -30,14 +32,23 @@ import stat
30
32
  import time
31
33
  from datetime import datetime, timedelta
32
34
 
33
- from django.test import Client, SimpleTestCase, TestCase, TransactionTestCase, override_settings
35
+ from django.test import (
36
+ Client,
37
+ RequestFactory,
38
+ SimpleTestCase,
39
+ TestCase,
40
+ TransactionTestCase,
41
+ override_settings,
42
+ )
34
43
  from django.urls import reverse
35
44
  from django.contrib.auth import get_user_model
36
45
  from django.contrib import admin
46
+ from django.contrib.admin import helpers
37
47
  from django.contrib.auth.models import Permission
38
48
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
39
49
  from django.conf import settings
40
50
  from django.utils import timezone
51
+ from urllib.parse import urlparse
41
52
  from dns import resolver as dns_resolver
42
53
  from . import dns as dns_utils
43
54
  from selenium.common.exceptions import WebDriverException
@@ -45,6 +56,7 @@ from .classifiers import run_default_classifiers
45
56
  from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
46
57
  from django.db.utils import DatabaseError
47
58
 
59
+ from .admin import NodeAdmin
48
60
  from .models import (
49
61
  Node,
50
62
  EmailOutbox,
@@ -56,28 +68,31 @@ from .models import (
56
68
  NodeFeature,
57
69
  NodeFeatureAssignment,
58
70
  NetMessage,
71
+ PendingNetMessage,
59
72
  NodeManager,
60
73
  DNSRecord,
61
74
  )
62
75
  from .backends import OutboxEmailBackend
63
- from .tasks import capture_node_screenshot, sample_clipboard
76
+ from .tasks import capture_node_screenshot, poll_unreachable_upstream, sample_clipboard
77
+ from ocpp.models import Charger
64
78
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
65
79
  from cryptography.hazmat.primitives import serialization, hashes
66
- from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount
80
+ from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount, Todo
81
+ from requests.exceptions import SSLError
67
82
 
68
83
 
69
84
  class NodeBadgeColorTests(TestCase):
70
85
  def setUp(self):
71
- self.constellation, _ = NodeRole.objects.get_or_create(name="Constellation")
86
+ self.watchtower, _ = NodeRole.objects.get_or_create(name="Watchtower")
72
87
  self.control, _ = NodeRole.objects.get_or_create(name="Control")
73
88
 
74
- def test_constellation_role_defaults_to_goldenrod(self):
89
+ def test_watchtower_role_defaults_to_goldenrod(self):
75
90
  node = Node.objects.create(
76
- hostname="constellation",
91
+ hostname="watchtower",
77
92
  address="10.1.0.1",
78
93
  port=8000,
79
94
  mac_address="00:aa:bb:cc:dd:01",
80
- role=self.constellation,
95
+ role=self.watchtower,
81
96
  )
82
97
  self.assertEqual(node.badge_color, "#daa520")
83
98
 
@@ -97,7 +112,7 @@ class NodeBadgeColorTests(TestCase):
97
112
  address="10.1.0.3",
98
113
  port=8002,
99
114
  mac_address="00:aa:bb:cc:dd:03",
100
- role=self.constellation,
115
+ role=self.watchtower,
101
116
  badge_color="#123456",
102
117
  )
103
118
  self.assertEqual(node.badge_color, "#123456")
@@ -110,6 +125,7 @@ class NodeTests(TestCase):
110
125
  self.user = User.objects.create_user(username="nodeuser", password="pwd")
111
126
  self.client.force_login(self.user)
112
127
  NodeRole.objects.get_or_create(name="Terminal")
128
+ NodeRole.objects.get_or_create(name="Interface")
113
129
 
114
130
 
115
131
  class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
@@ -125,6 +141,12 @@ class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
125
141
 
126
142
 
127
143
  class NodeGetLocalTests(TestCase):
144
+ def setUp(self):
145
+ super().setUp()
146
+ User = get_user_model()
147
+ self.user = User.objects.create_user(username="localtester", password="pwd")
148
+ self.client.force_login(self.user)
149
+
128
150
  def test_normalize_relation_handles_various_inputs(self):
129
151
  self.assertEqual(
130
152
  Node.normalize_relation(Node.Relation.UPSTREAM),
@@ -166,6 +188,7 @@ class NodeGetLocalTests(TestCase):
166
188
  patch(
167
189
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
168
190
  ),
191
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
169
192
  patch("nodes.models.revision.get_revision", return_value="rev"),
170
193
  patch.object(Node, "ensure_keys"),
171
194
  ):
@@ -177,7 +200,7 @@ class NodeGetLocalTests(TestCase):
177
200
 
178
201
  def test_register_current_updates_role_from_lock_file(self):
179
202
  NodeRole.objects.get_or_create(name="Terminal")
180
- NodeRole.objects.get_or_create(name="Constellation")
203
+ NodeRole.objects.get_or_create(name="Watchtower")
181
204
  with TemporaryDirectory() as tmp:
182
205
  base = Path(tmp)
183
206
  lock_dir = base / "locks"
@@ -194,6 +217,7 @@ class NodeGetLocalTests(TestCase):
194
217
  patch(
195
218
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
196
219
  ),
220
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
197
221
  patch("nodes.models.revision.get_revision", return_value="rev"),
198
222
  patch.object(Node, "ensure_keys"),
199
223
  patch.object(Node, "notify_peers_of_update"),
@@ -202,7 +226,7 @@ class NodeGetLocalTests(TestCase):
202
226
  self.assertTrue(created)
203
227
  self.assertEqual(node.role.name, "Terminal")
204
228
 
205
- role_file.write_text("Constellation")
229
+ role_file.write_text("Watchtower")
206
230
  with override_settings(BASE_DIR=base):
207
231
  with (
208
232
  patch(
@@ -213,6 +237,7 @@ class NodeGetLocalTests(TestCase):
213
237
  patch(
214
238
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
215
239
  ),
240
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
216
241
  patch("nodes.models.revision.get_revision", return_value="rev"),
217
242
  patch.object(Node, "ensure_keys"),
218
243
  patch.object(Node, "notify_peers_of_update"),
@@ -221,7 +246,70 @@ class NodeGetLocalTests(TestCase):
221
246
 
222
247
  self.assertFalse(created_again)
223
248
  node.refresh_from_db()
224
- self.assertEqual(node.role.name, "Constellation")
249
+ self.assertEqual(node.role.name, "Watchtower")
250
+
251
+ role_file.write_text("Constellation")
252
+ with override_settings(BASE_DIR=base):
253
+ with (
254
+ patch(
255
+ "nodes.models.Node.get_current_mac",
256
+ return_value="00:aa:bb:cc:dd:ee",
257
+ ),
258
+ patch("nodes.models.socket.gethostname", return_value="role-host"),
259
+ patch(
260
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
261
+ ),
262
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
263
+ patch("nodes.models.revision.get_revision", return_value="rev"),
264
+ patch.object(Node, "ensure_keys"),
265
+ patch.object(Node, "notify_peers_of_update"),
266
+ ):
267
+ Node.register_current()
268
+
269
+ node.refresh_from_db()
270
+ self.assertEqual(node.role.name, "Watchtower")
271
+
272
+ def test_register_current_respects_node_hostname_env(self):
273
+ with TemporaryDirectory() as tmp:
274
+ base = Path(tmp)
275
+ with override_settings(BASE_DIR=base):
276
+ with (
277
+ patch.dict(os.environ, {"NODE_HOSTNAME": "gway-002"}, clear=False),
278
+ patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"),
279
+ patch("nodes.models.socket.gethostname", return_value="localhost"),
280
+ patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
281
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
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-002")
289
+ self.assertEqual(node.public_endpoint, "gway-002")
290
+
291
+ def test_register_current_respects_public_endpoint_env(self):
292
+ with TemporaryDirectory() as tmp:
293
+ base = Path(tmp)
294
+ with override_settings(BASE_DIR=base):
295
+ with (
296
+ patch.dict(
297
+ os.environ,
298
+ {"NODE_HOSTNAME": "gway-alpha", "NODE_PUBLIC_ENDPOINT": "gway-002"},
299
+ clear=False,
300
+ ),
301
+ patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:56"),
302
+ patch("nodes.models.socket.gethostname", return_value="localhost"),
303
+ patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
304
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
305
+ patch("nodes.models.revision.get_revision", return_value="rev"),
306
+ patch.object(Node, "ensure_keys"),
307
+ patch.object(Node, "notify_peers_of_update"),
308
+ ):
309
+ node, created = Node.register_current()
310
+ self.assertTrue(created)
311
+ self.assertEqual(node.hostname, "gway-alpha")
312
+ self.assertEqual(node.public_endpoint, "gway-002")
225
313
 
226
314
  def test_register_and_list_node(self):
227
315
  response = self.client.post(
@@ -309,6 +397,101 @@ class NodeGetLocalTests(TestCase):
309
397
  self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
310
398
  self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
311
399
 
400
+ def test_register_node_accepts_network_hostname_without_address(self):
401
+ response = self.client.post(
402
+ reverse("register-node"),
403
+ data={
404
+ "hostname": "domain-node",
405
+ "network_hostname": "domain-node.example.com",
406
+ "port": 8050,
407
+ "mac_address": "aa:bb:cc:dd:ee:ff",
408
+ },
409
+ content_type="application/json",
410
+ )
411
+ self.assertEqual(response.status_code, 200)
412
+ node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:ff")
413
+ self.assertEqual(node.network_hostname, "domain-node.example.com")
414
+ self.assertIsNone(node.address)
415
+ self.assertIsNone(node.ipv4_address)
416
+ self.assertIsNone(node.ipv6_address)
417
+
418
+ def test_register_node_populates_missing_ip_fields_from_address(self):
419
+ response = self.client.post(
420
+ reverse("register-node"),
421
+ data={
422
+ "hostname": "address-node",
423
+ "address": "203.0.113.10",
424
+ "port": 8040,
425
+ "mac_address": "aa:bb:cc:dd:ee:01",
426
+ },
427
+ content_type="application/json",
428
+ )
429
+ self.assertEqual(response.status_code, 200)
430
+ node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:01")
431
+ self.assertEqual(node.address, "203.0.113.10")
432
+ self.assertEqual(node.ipv4_address, "203.0.113.10")
433
+ self.assertIsNone(node.ipv6_address)
434
+
435
+ def test_get_best_ip_ignores_non_ip_values(self):
436
+ node = Node.objects.create(
437
+ hostname="best-ip",
438
+ address="gateway.local",
439
+ ipv4_address="198.51.100.5",
440
+ port=8000,
441
+ mac_address="00:11:22:33:44:77",
442
+ )
443
+ self.assertEqual(node.get_best_ip(), "198.51.100.5")
444
+
445
+ def test_register_node_requires_contact_information(self):
446
+ response = self.client.post(
447
+ reverse("register-node"),
448
+ data={
449
+ "hostname": "missing-host",
450
+ "port": 8051,
451
+ "mac_address": "aa:bb:cc:dd:ee:00",
452
+ },
453
+ content_type="application/json",
454
+ )
455
+ self.assertEqual(response.status_code, 400)
456
+ self.assertIn("at least one", response.json()["detail"])
457
+
458
+ def test_register_node_assigns_interface_role_and_returns_uuid(self):
459
+ NodeRole.objects.get_or_create(name="Interface")
460
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
461
+ public_bytes = private_key.public_key().public_bytes(
462
+ encoding=serialization.Encoding.PEM,
463
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
464
+ ).decode()
465
+ token = "interface-token"
466
+ signature = base64.b64encode(
467
+ private_key.sign(
468
+ token.encode(),
469
+ padding.PKCS1v15(),
470
+ hashes.SHA256(),
471
+ )
472
+ ).decode()
473
+ mac = "aa:bb:cc:dd:ee:99"
474
+ payload = {
475
+ "hostname": "interface",
476
+ "address": "127.0.0.1",
477
+ "port": 8443,
478
+ "mac_address": mac,
479
+ "public_key": public_bytes,
480
+ "token": token,
481
+ "signature": signature,
482
+ "role": "Interface",
483
+ }
484
+ response = self.client.post(
485
+ reverse("register-node"),
486
+ data=json.dumps(payload),
487
+ content_type="application/json",
488
+ )
489
+ self.assertEqual(response.status_code, 200)
490
+ data = response.json()
491
+ self.assertIn("uuid", data)
492
+ node = Node.objects.get(mac_address=mac)
493
+ self.assertEqual(node.role.name, "Interface")
494
+
312
495
  def test_register_node_feature_toggle(self):
313
496
  NodeFeature.objects.get_or_create(
314
497
  slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
@@ -640,6 +823,125 @@ class NodeGetLocalTests(TestCase):
640
823
  self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
641
824
 
642
825
 
826
+ class NodeEnsureKeysTests(TestCase):
827
+ def setUp(self):
828
+ self.tempdir = TemporaryDirectory()
829
+ self.base = Path(self.tempdir.name)
830
+ self.override = override_settings(BASE_DIR=self.base)
831
+ self.override.enable()
832
+ self.node = Node.objects.create(
833
+ hostname="ensure-host",
834
+ address="127.0.0.1",
835
+ port=8000,
836
+ mac_address="00:11:22:33:44:55",
837
+ )
838
+
839
+ def tearDown(self):
840
+ self.override.disable()
841
+ self.tempdir.cleanup()
842
+
843
+ def test_regenerates_missing_keys(self):
844
+ self.node.ensure_keys()
845
+ security_dir = self.base / "security"
846
+ priv_path = security_dir / self.node.public_endpoint
847
+ pub_path = security_dir / f"{self.node.public_endpoint}.pub"
848
+ original_public = self.node.public_key
849
+ priv_path.unlink()
850
+ pub_path.unlink()
851
+
852
+ self.node.ensure_keys()
853
+
854
+ self.assertTrue(priv_path.exists())
855
+ self.assertTrue(pub_path.exists())
856
+ self.assertNotEqual(self.node.public_key, original_public)
857
+
858
+ def test_regenerates_outdated_keys(self):
859
+ self.node.ensure_keys()
860
+ security_dir = self.base / "security"
861
+ priv_path = security_dir / self.node.public_endpoint
862
+ pub_path = security_dir / f"{self.node.public_endpoint}.pub"
863
+ original_private = priv_path.read_bytes()
864
+ original_public = pub_path.read_bytes()
865
+
866
+ old_time = (timezone.now() - timedelta(seconds=5)).timestamp()
867
+ os.utime(priv_path, (old_time, old_time))
868
+ os.utime(pub_path, (old_time, old_time))
869
+
870
+ with override_settings(NODE_KEY_MAX_AGE=timedelta(seconds=1)):
871
+ self.node.ensure_keys()
872
+
873
+ self.node.refresh_from_db()
874
+ self.assertNotEqual(priv_path.read_bytes(), original_private)
875
+ self.assertNotEqual(pub_path.read_bytes(), original_public)
876
+ self.assertNotEqual(self.node.public_key, original_public.decode())
877
+
878
+
879
+ class NodeInfoViewTests(TestCase):
880
+ def setUp(self):
881
+ self.mac = "02:00:00:00:00:01"
882
+ self.patcher = patch("nodes.models.Node.get_current_mac", return_value=self.mac)
883
+ self.patcher.start()
884
+ self.addCleanup(self.patcher.stop)
885
+ self.node = Node.objects.create(
886
+ hostname="local",
887
+ network_hostname="local.example.com",
888
+ address="10.0.0.10",
889
+ ipv4_address="10.0.0.10",
890
+ ipv6_address="2001:db8::10",
891
+ port=8000,
892
+ mac_address=self.mac,
893
+ public_endpoint="local",
894
+ current_relation=Node.Relation.SELF,
895
+ )
896
+ self.url = reverse("node-info")
897
+
898
+ def test_returns_https_port_for_secure_domain_request(self):
899
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
900
+ response = self.client.get(
901
+ self.url,
902
+ secure=True,
903
+ HTTP_HOST="arthexis.com",
904
+ )
905
+ self.assertEqual(response.status_code, 200)
906
+ payload = response.json()
907
+ self.assertEqual(payload["port"], 443)
908
+
909
+ def test_returns_http_port_for_plain_domain_request(self):
910
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
911
+ response = self.client.get(
912
+ self.url,
913
+ HTTP_HOST="arthexis.com",
914
+ )
915
+ self.assertEqual(response.status_code, 200)
916
+ payload = response.json()
917
+ self.assertEqual(payload["port"], 80)
918
+ self.assertEqual(payload.get("network_hostname"), "local.example.com")
919
+ self.assertIn("local.example.com", payload.get("contact_hosts", []))
920
+
921
+ def test_preserves_explicit_port_in_host_header(self):
922
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
923
+ response = self.client.get(
924
+ self.url,
925
+ secure=True,
926
+ HTTP_HOST="arthexis.com:8443",
927
+ )
928
+ self.assertEqual(response.status_code, 200)
929
+ payload = response.json()
930
+ self.assertEqual(payload["port"], 8443)
931
+
932
+ def test_includes_role_in_payload(self):
933
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
934
+ self.node.role = role
935
+ self.node.save(update_fields=["role"])
936
+
937
+ response = self.client.get(self.url)
938
+ self.assertEqual(response.status_code, 200)
939
+ payload = response.json()
940
+ self.assertEqual(payload.get("role"), "Terminal")
941
+ self.assertEqual(payload.get("ipv4_address"), "10.0.0.10")
942
+ self.assertEqual(payload.get("ipv6_address"), "2001:db8::10")
943
+
944
+
643
945
  class RegisterVisitorNodeMessageTests(TestCase):
644
946
  def setUp(self):
645
947
  self.client = Client()
@@ -719,6 +1021,7 @@ class NodeRegisterCurrentTests(TestCase):
719
1021
  patch(
720
1022
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
721
1023
  ),
1024
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
722
1025
  patch("nodes.models.revision.get_revision", return_value="rev"),
723
1026
  patch.object(Node, "ensure_keys"),
724
1027
  patch.object(Node, "notify_peers_of_update") as mock_notify,
@@ -746,6 +1049,7 @@ class NodeRegisterCurrentTests(TestCase):
746
1049
  patch(
747
1050
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
748
1051
  ),
1052
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
749
1053
  patch("nodes.models.revision.get_revision", return_value="rev"),
750
1054
  patch.object(Node, "ensure_keys"),
751
1055
  ):
@@ -764,6 +1068,7 @@ class NodeRegisterCurrentTests(TestCase):
764
1068
  patch(
765
1069
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
766
1070
  ),
1071
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
767
1072
  patch("nodes.models.revision.get_revision", return_value="rev"),
768
1073
  patch.object(Node, "ensure_keys"),
769
1074
  ):
@@ -783,6 +1088,7 @@ class NodeRegisterCurrentTests(TestCase):
783
1088
  patch(
784
1089
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
785
1090
  ),
1091
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
786
1092
  patch("nodes.models.revision.get_revision", return_value="rev"),
787
1093
  patch.object(Node, "ensure_keys"),
788
1094
  ):
@@ -808,10 +1114,20 @@ class NodeRegisterCurrentTests(TestCase):
808
1114
  return_value="00:ff:ee:dd:cc:bb",
809
1115
  ),
810
1116
  patch("nodes.models.socket.gethostname", return_value="localnode"),
1117
+ patch("nodes.models.socket.getfqdn", return_value="localnode.example.com"),
811
1118
  patch(
812
1119
  "nodes.models.socket.gethostbyname",
813
1120
  return_value="192.168.1.5",
814
1121
  ),
1122
+ patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
1123
+ patch.object(
1124
+ Node,
1125
+ "_resolve_ip_addresses",
1126
+ return_value=(
1127
+ ["192.168.1.5", "93.184.216.34"],
1128
+ ["fe80::1", "2001:4860:4860::8888"],
1129
+ ),
1130
+ ),
815
1131
  patch("nodes.models.revision.get_revision", return_value="newrev"),
816
1132
  patch("requests.post") as mock_post,
817
1133
  ):
@@ -835,6 +1151,9 @@ class NodeRegisterCurrentTests(TestCase):
835
1151
  self.assertEqual(payload["hostname"], "localnode")
836
1152
  self.assertEqual(payload["installed_version"], "2.0.0")
837
1153
  self.assertEqual(payload["installed_revision"], "newrev")
1154
+ self.assertEqual(payload.get("network_hostname"), "localnode.example.com")
1155
+ self.assertEqual(payload.get("ipv4_address"), "93.184.216.34")
1156
+ self.assertEqual(payload.get("ipv6_address"), "2001:4860:4860::8888")
838
1157
 
839
1158
  def test_register_current_notifies_peers_without_version_change(self):
840
1159
  Node.objects.create(
@@ -853,10 +1172,20 @@ class NodeRegisterCurrentTests(TestCase):
853
1172
  return_value="00:ff:ee:dd:cc:cc",
854
1173
  ),
855
1174
  patch("nodes.models.socket.gethostname", return_value="samever"),
1175
+ patch("nodes.models.socket.getfqdn", return_value="samever.example.com"),
856
1176
  patch(
857
1177
  "nodes.models.socket.gethostbyname",
858
1178
  return_value="192.168.1.6",
859
1179
  ),
1180
+ patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
1181
+ patch.object(
1182
+ Node,
1183
+ "_resolve_ip_addresses",
1184
+ return_value=(
1185
+ ["192.168.1.6", "93.184.216.35"],
1186
+ ["fe80::2", "2001:4860:4860::8844"],
1187
+ ),
1188
+ ),
860
1189
  patch("nodes.models.revision.get_revision", return_value="rev1"),
861
1190
  patch("requests.post") as mock_post,
862
1191
  ):
@@ -878,6 +1207,44 @@ class NodeRegisterCurrentTests(TestCase):
878
1207
  payload = json.loads(kwargs["data"])
879
1208
  self.assertEqual(payload["installed_version"], "1.0.0")
880
1209
  self.assertEqual(payload.get("installed_revision"), "rev1")
1210
+ self.assertEqual(payload.get("network_hostname"), "samever.example.com")
1211
+ self.assertEqual(payload.get("ipv4_address"), "93.184.216.35")
1212
+ self.assertEqual(payload.get("ipv6_address"), "2001:4860:4860::8844")
1213
+
1214
+ def test_register_current_populates_network_fields(self):
1215
+ with TemporaryDirectory() as tmp:
1216
+ base = Path(tmp)
1217
+ with override_settings(BASE_DIR=base):
1218
+ with (
1219
+ patch(
1220
+ "nodes.models.Node.get_current_mac",
1221
+ return_value="00:12:34:56:78:90",
1222
+ ),
1223
+ patch("nodes.models.socket.gethostname", return_value="nodehost"),
1224
+ patch("nodes.models.socket.getfqdn", return_value="nodehost.example.com"),
1225
+ patch(
1226
+ "nodes.models.socket.gethostbyname",
1227
+ return_value="10.0.0.5",
1228
+ ),
1229
+ patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
1230
+ patch.object(
1231
+ Node,
1232
+ "_resolve_ip_addresses",
1233
+ return_value=(
1234
+ ["10.0.0.5", "93.184.216.36"],
1235
+ ["fe80::5", "2001:4860:4860::1"],
1236
+ ),
1237
+ ),
1238
+ patch("nodes.models.revision.get_revision", return_value="revX"),
1239
+ patch.object(Node, "ensure_keys"),
1240
+ patch.object(Node, "notify_peers_of_update"),
1241
+ ):
1242
+ node, created = Node.register_current()
1243
+ self.assertTrue(created)
1244
+ self.assertEqual(node.network_hostname, "nodehost.example.com")
1245
+ self.assertEqual(node.ipv4_address, "93.184.216.36")
1246
+ self.assertEqual(node.ipv6_address, "2001:4860:4860::1")
1247
+ self.assertEqual(node.address, "93.184.216.36")
881
1248
 
882
1249
  @patch("nodes.views.capture_screenshot")
883
1250
  def test_capture_screenshot(self, mock_capture):
@@ -1236,6 +1603,7 @@ class NodeRegisterCurrentTests(TestCase):
1236
1603
  existing_role.refresh_from_db()
1237
1604
  self.assertEqual(existing_role.description, "updated via attachment")
1238
1605
 
1606
+ @pytest.mark.feature("clipboard-poll")
1239
1607
  def test_clipboard_polling_creates_task(self):
1240
1608
  feature, _ = NodeFeature.objects.get_or_create(
1241
1609
  slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
@@ -1253,6 +1621,7 @@ class NodeRegisterCurrentTests(TestCase):
1253
1621
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
1254
1622
  self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
1255
1623
 
1624
+ @pytest.mark.feature("screenshot-poll")
1256
1625
  def test_screenshot_polling_creates_task(self):
1257
1626
  feature, _ = NodeFeature.objects.get_or_create(
1258
1627
  slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
@@ -1381,6 +1750,7 @@ class NodeAdminTests(TestCase):
1381
1750
  action_url = reverse("admin:core_rfid_scan")
1382
1751
  self.assertContains(response, f'href="{action_url}"')
1383
1752
 
1753
+ @pytest.mark.feature("rpi-camera")
1384
1754
  def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
1385
1755
  node = self._create_local_node()
1386
1756
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1393,6 +1763,7 @@ class NodeAdminTests(TestCase):
1393
1763
  self.assertContains(response, f'href="{snapshot_url}"')
1394
1764
  self.assertContains(response, f'href="{stream_url}"')
1395
1765
 
1766
+ @pytest.mark.feature("audio-capture")
1396
1767
  def test_node_feature_list_shows_waveform_action_when_enabled(self):
1397
1768
  node = self._create_local_node()
1398
1769
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1403,6 +1774,7 @@ class NodeAdminTests(TestCase):
1403
1774
  action_url = reverse("admin:nodes_nodefeature_view_waveform")
1404
1775
  self.assertContains(response, f'href="{action_url}"')
1405
1776
 
1777
+ @pytest.mark.feature("screenshot-poll")
1406
1778
  def test_node_feature_list_hides_default_action_when_disabled(self):
1407
1779
  self._create_local_node()
1408
1780
  NodeFeature.objects.get_or_create(
@@ -1495,6 +1867,129 @@ class NodeAdminTests(TestCase):
1495
1867
  response, reverse("admin:nodes_node_register_current")
1496
1868
  )
1497
1869
 
1870
+ def test_apply_remote_node_info_updates_role(self):
1871
+ terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
1872
+ control, _ = NodeRole.objects.get_or_create(name="Control")
1873
+ node = Node.objects.create(
1874
+ hostname="remote-node",
1875
+ address="10.0.0.20",
1876
+ port=8001,
1877
+ mac_address="00:11:22:33:44:aa",
1878
+ role=terminal,
1879
+ )
1880
+ admin_instance = NodeAdmin(Node, admin.site)
1881
+
1882
+ payload = {
1883
+ "hostname": node.hostname,
1884
+ "network_hostname": "remote-node.example.com",
1885
+ "address": node.address,
1886
+ "ipv4_address": "198.51.100.10",
1887
+ "ipv6_address": "2001:db8::10",
1888
+ "port": node.port,
1889
+ "role": "Control",
1890
+ }
1891
+
1892
+ changed = admin_instance._apply_remote_node_info(node, payload)
1893
+ node.refresh_from_db()
1894
+
1895
+ self.assertIn("role", changed)
1896
+ self.assertIn("network_hostname", changed)
1897
+ self.assertIn("ipv4_address", changed)
1898
+ self.assertIn("ipv6_address", changed)
1899
+ self.assertEqual(node.role, control)
1900
+ self.assertEqual(node.network_hostname, "remote-node.example.com")
1901
+ self.assertEqual(node.ipv4_address, "198.51.100.10")
1902
+ self.assertEqual(node.ipv6_address, "2001:db8::10")
1903
+ self.assertTrue(control.node_set.filter(pk=node.pk).exists())
1904
+ self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
1905
+
1906
+ def test_apply_remote_node_info_accepts_role_name_key(self):
1907
+ terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
1908
+ control, _ = NodeRole.objects.get_or_create(name="Control")
1909
+ node = Node.objects.create(
1910
+ hostname="role-name-node",
1911
+ address="10.0.0.21",
1912
+ port=8002,
1913
+ mac_address="00:11:22:33:44:bb",
1914
+ role=terminal,
1915
+ )
1916
+ admin_instance = NodeAdmin(Node, admin.site)
1917
+
1918
+ payload = {
1919
+ "hostname": node.hostname,
1920
+ "network_hostname": "role-name-node.example.com",
1921
+ "address": node.address,
1922
+ "ipv4_address": "198.51.100.11",
1923
+ "ipv6_address": "2001:db8::11",
1924
+ "port": node.port,
1925
+ "role_name": "Control",
1926
+ }
1927
+
1928
+ changed = admin_instance._apply_remote_node_info(node, payload)
1929
+ node.refresh_from_db()
1930
+
1931
+ self.assertIn("role", changed)
1932
+ self.assertEqual(node.network_hostname, "role-name-node.example.com")
1933
+ self.assertEqual(node.ipv4_address, "198.51.100.11")
1934
+ self.assertEqual(node.ipv6_address, "2001:db8::11")
1935
+ self.assertEqual(node.role, control)
1936
+ self.assertTrue(control.node_set.filter(pk=node.pk).exists())
1937
+ self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
1938
+
1939
+ @patch("nodes.admin.requests.post")
1940
+ def test_send_forwarding_metadata_retries_until_success(self, mock_post):
1941
+ request = RequestFactory().get("/")
1942
+ local_node = Node.objects.create(
1943
+ hostname="local",
1944
+ address="127.0.0.1",
1945
+ port=8000,
1946
+ mac_address="00:11:22:33:44:aa",
1947
+ public_key="LOCAL-PUB",
1948
+ )
1949
+ class DummyNode:
1950
+ def __init__(self):
1951
+ self.port = 8443
1952
+ self.urls: list[str] = []
1953
+
1954
+ def iter_remote_urls(self, path: str):
1955
+ self.urls.append(path)
1956
+ yield "https://unreachable.example"
1957
+ yield "https://reachable.example"
1958
+
1959
+ def __str__(self): # pragma: no cover - trivial representation
1960
+ return "dummy-node"
1961
+
1962
+ target = DummyNode()
1963
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
1964
+ charger = Charger.objects.create(charger_id="FORWARD-1")
1965
+
1966
+ failure = MagicMock()
1967
+ failure.ok = False
1968
+ failure.status_code = 404
1969
+ failure.json.return_value = {"detail": "not found"}
1970
+ success = MagicMock()
1971
+ success.ok = True
1972
+ success.status_code = 200
1973
+ success.json.return_value = {"status": "ok"}
1974
+
1975
+ mock_post.side_effect = [failure, success]
1976
+
1977
+ admin_instance = NodeAdmin(Node, admin.site)
1978
+
1979
+ result = admin_instance._send_forwarding_metadata(
1980
+ request, target, [charger], local_node, private_key
1981
+ )
1982
+
1983
+ self.assertTrue(result)
1984
+ self.assertEqual(
1985
+ target.urls, ["/nodes/network/chargers/forward/"]
1986
+ )
1987
+ self.assertEqual(mock_post.call_count, 2)
1988
+ self.assertEqual(
1989
+ mock_post.call_args_list[1].args[0], "https://reachable.example"
1990
+ )
1991
+
1992
+ @pytest.mark.feature("screenshot-poll")
1498
1993
  @patch("nodes.admin.capture_screenshot")
1499
1994
  def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
1500
1995
  screenshot_dir = settings.LOG_DIR / "screenshots"
@@ -1540,6 +2035,53 @@ class NodeAdminTests(TestCase):
1540
2035
  self.assertEqual(response.status_code, 200)
1541
2036
  self.assertContains(response, "data:image/png;base64")
1542
2037
 
2038
+ def test_visit_link_uses_local_admin_dashboard_for_local_node(self):
2039
+ node_admin = admin.site._registry[Node]
2040
+ local_node = self._create_local_node()
2041
+
2042
+ link_html = node_admin.visit_link(local_node)
2043
+
2044
+ self.assertIn(reverse("admin:index"), link_html)
2045
+ self.assertIn("target=\"_blank\"", link_html)
2046
+
2047
+ def test_visit_link_prefers_remote_hostname_for_dashboard(self):
2048
+ node_admin = admin.site._registry[Node]
2049
+ remote = Node.objects.create(
2050
+ hostname="remote.example.com",
2051
+ address="198.51.100.20",
2052
+ port=8443,
2053
+ mac_address="aa:bb:cc:dd:ee:ff",
2054
+ )
2055
+
2056
+ link_html = node_admin.visit_link(remote)
2057
+
2058
+ self.assertIn("https://remote.example.com:8443/admin/", link_html)
2059
+ self.assertIn("target=\"_blank\"", link_html)
2060
+
2061
+ def test_iter_remote_urls_handles_hostname_with_path_and_port(self):
2062
+ node_admin = admin.site._registry[Node]
2063
+ remote = SimpleNamespace(
2064
+ public_endpoint="",
2065
+ address="",
2066
+ hostname="example.com/interface",
2067
+ port=8443,
2068
+ )
2069
+
2070
+ urls = list(node_admin._iter_remote_urls(remote, "/nodes/info/"))
2071
+
2072
+ self.assertIn(
2073
+ "https://example.com:8443/interface/nodes/info/",
2074
+ urls,
2075
+ )
2076
+ self.assertIn(
2077
+ "http://example.com:8443/interface/nodes/info/",
2078
+ urls,
2079
+ )
2080
+ combined = "".join(urls)
2081
+ self.assertNotIn("interface:8443", combined)
2082
+
2083
+
2084
+ @pytest.mark.feature("screenshot-poll")
1543
2085
  @override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
1544
2086
  @patch("nodes.admin.capture_screenshot")
1545
2087
  def test_take_screenshots_action(self, mock_capture):
@@ -1572,6 +2114,7 @@ class NodeAdminTests(TestCase):
1572
2114
  samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
1573
2115
  self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
1574
2116
 
2117
+ @pytest.mark.feature("screenshot-poll")
1575
2118
  @patch("nodes.admin.capture_screenshot")
1576
2119
  def test_take_screenshot_default_action_creates_sample(
1577
2120
  self, mock_capture_screenshot
@@ -1646,6 +2189,7 @@ class NodeAdminTests(TestCase):
1646
2189
  response, "Completed 0 of 1 feature check(s) successfully.", html=False
1647
2190
  )
1648
2191
 
2192
+ @pytest.mark.feature("screenshot-poll")
1649
2193
  def test_enable_selected_features_enables_manual_feature(self):
1650
2194
  node = self._create_local_node()
1651
2195
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1690,6 +2234,7 @@ class NodeAdminTests(TestCase):
1690
2234
  html=False,
1691
2235
  )
1692
2236
 
2237
+ @pytest.mark.feature("screenshot-poll")
1693
2238
  def test_take_screenshot_default_action_requires_enabled_feature(self):
1694
2239
  self._create_local_node()
1695
2240
  NodeFeature.objects.get_or_create(
@@ -1704,6 +2249,7 @@ class NodeAdminTests(TestCase):
1704
2249
  self.assertEqual(ContentSample.objects.count(), 0)
1705
2250
  self.assertContains(response, "Screenshot Poll feature is not enabled")
1706
2251
 
2252
+ @pytest.mark.feature("rpi-camera")
1707
2253
  @patch("nodes.admin.capture_rpi_snapshot")
1708
2254
  def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
1709
2255
  node = self._create_local_node()
@@ -1726,6 +2272,7 @@ class NodeAdminTests(TestCase):
1726
2272
  change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
1727
2273
  self.assertEqual(response.redirect_chain[-1][0], change_url)
1728
2274
 
2275
+ @pytest.mark.feature("rpi-camera")
1729
2276
  def test_view_stream_requires_enabled_feature(self):
1730
2277
  self._create_local_node()
1731
2278
  NodeFeature.objects.get_or_create(
@@ -1741,6 +2288,7 @@ class NodeAdminTests(TestCase):
1741
2288
  response, "Raspberry Pi Camera feature is not enabled on this node."
1742
2289
  )
1743
2290
 
2291
+ @pytest.mark.feature("rpi-camera")
1744
2292
  def test_view_stream_renders_when_feature_enabled(self):
1745
2293
  node = self._create_local_node()
1746
2294
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1756,6 +2304,7 @@ class NodeAdminTests(TestCase):
1756
2304
  self.assertContains(response, expected_stream)
1757
2305
  self.assertContains(response, "camera-stream__frame")
1758
2306
 
2307
+ @pytest.mark.feature("rpi-camera")
1759
2308
  def test_view_stream_uses_configured_stream_url(self):
1760
2309
  node = self._create_local_node()
1761
2310
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1773,6 +2322,7 @@ class NodeAdminTests(TestCase):
1773
2322
  self.assertEqual(response.context_data["stream_embed"], "iframe")
1774
2323
  self.assertContains(response, configured_stream)
1775
2324
 
2325
+ @pytest.mark.feature("rpi-camera")
1776
2326
  def test_view_stream_detects_mjpeg_stream(self):
1777
2327
  node = self._create_local_node()
1778
2328
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1789,6 +2339,7 @@ class NodeAdminTests(TestCase):
1789
2339
  self.assertEqual(response.context_data["stream_embed"], "mjpeg")
1790
2340
  self.assertContains(response, "<img", html=False)
1791
2341
 
2342
+ @pytest.mark.feature("rpi-camera")
1792
2343
  def test_view_stream_marks_rtsp_stream_as_unsupported(self):
1793
2344
  node = self._create_local_node()
1794
2345
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1805,6 +2356,7 @@ class NodeAdminTests(TestCase):
1805
2356
  self.assertEqual(response.context_data["stream_embed"], "unsupported")
1806
2357
  self.assertContains(response, "camera-stream__unsupported")
1807
2358
 
2359
+ @pytest.mark.feature("audio-capture")
1808
2360
  def test_view_waveform_requires_enabled_feature(self):
1809
2361
  self._create_local_node()
1810
2362
  NodeFeature.objects.get_or_create(
@@ -1820,6 +2372,7 @@ class NodeAdminTests(TestCase):
1820
2372
  response, "Audio Capture feature is not enabled on this node."
1821
2373
  )
1822
2374
 
2375
+ @pytest.mark.feature("audio-capture")
1823
2376
  def test_view_waveform_renders_when_feature_enabled(self):
1824
2377
  node = self._create_local_node()
1825
2378
  feature, _ = NodeFeature.objects.get_or_create(
@@ -2072,6 +2625,53 @@ class NodeAdminTests(TestCase):
2072
2625
  )
2073
2626
  self.assertContains(response, str(remote))
2074
2627
 
2628
+ def test_send_net_message_action_displays_form(self):
2629
+ target = Node.objects.create(
2630
+ hostname="remote-one", address="10.0.0.10", port=8020
2631
+ )
2632
+ response = self.client.post(
2633
+ reverse("admin:nodes_node_changelist"),
2634
+ {
2635
+ "action": "send_net_message",
2636
+ helpers.ACTION_CHECKBOX_NAME: [str(target.pk)],
2637
+ },
2638
+ follow=False,
2639
+ )
2640
+ self.assertEqual(response.status_code, 200)
2641
+ response.render()
2642
+ self.assertContains(response, "Send Net Message")
2643
+ self.assertContains(response, str(target))
2644
+ self.assertContains(response, 'name="apply"')
2645
+ self.assertContains(response, "Selected node (1)")
2646
+
2647
+ @patch("nodes.admin.NetMessage.propagate")
2648
+ def test_send_net_message_action_creates_messages(self, mock_propagate):
2649
+ first = Node.objects.create(
2650
+ hostname="remote-two", address="10.0.0.11", port=8021
2651
+ )
2652
+ second = Node.objects.create(
2653
+ hostname="remote-three", address="10.0.0.12", port=8022
2654
+ )
2655
+ url = reverse("admin:nodes_node_changelist")
2656
+ payload = {
2657
+ "action": "send_net_message",
2658
+ "apply": "1",
2659
+ helpers.ACTION_CHECKBOX_NAME: [str(first.pk), str(second.pk)],
2660
+ "subject": "Maintenance",
2661
+ "body": "We will reboot tonight.",
2662
+ }
2663
+ existing_ids = set(NetMessage.objects.values_list("pk", flat=True))
2664
+ response = self.client.post(url, payload, follow=True)
2665
+ self.assertEqual(response.status_code, 200)
2666
+ new_messages = NetMessage.objects.exclude(pk__in=existing_ids)
2667
+ self.assertEqual(new_messages.count(), 2)
2668
+ self.assertEqual(mock_propagate.call_count, 2)
2669
+ for node in (first, second):
2670
+ message = new_messages.get(filter_node=node)
2671
+ self.assertEqual(message.subject, "Maintenance")
2672
+ self.assertEqual(message.body, "We will reboot tonight.")
2673
+ self.assertContains(response, "Sent 2 net messages.")
2674
+
2075
2675
  @patch("nodes.admin.requests.post")
2076
2676
  @patch("nodes.admin.requests.get")
2077
2677
  def test_update_selected_nodes_progress_updates_remote(
@@ -2142,22 +2742,202 @@ class NodeAdminTests(TestCase):
2142
2742
  self.assertEqual(post_data["mac_address"], local.mac_address)
2143
2743
 
2144
2744
 
2145
- class NodeRFIDAPITests(TestCase):
2146
- def test_import_endpoint_applies_payload_without_creating_accounts(self):
2147
- remote = Node.objects.create(
2148
- hostname="remote", address="127.0.0.10", port=8050
2745
+ class NodeProxyGatewayTests(TestCase):
2746
+ def setUp(self):
2747
+ cache.clear()
2748
+ self.client = Client()
2749
+ self.private_key = rsa.generate_private_key(
2750
+ public_exponent=65537, key_size=2048
2149
2751
  )
2150
- key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
2151
- remote.public_key = key.public_key().public_bytes(
2752
+ public_key = self.private_key.public_key().public_bytes(
2152
2753
  encoding=serialization.Encoding.PEM,
2153
2754
  format=serialization.PublicFormat.SubjectPublicKeyInfo,
2154
2755
  ).decode()
2155
- remote.save(update_fields=["public_key"])
2756
+ self.node = Node.objects.create(
2757
+ hostname="requester",
2758
+ address="127.0.0.1",
2759
+ port=8000,
2760
+ mac_address="aa:bb:cc:dd:ee:aa",
2761
+ public_key=public_key,
2762
+ )
2763
+ patcher = patch("requests.post")
2764
+ self.addCleanup(patcher.stop)
2765
+ self.mock_requests_post = patcher.start()
2766
+ self.mock_requests_post.return_value = SimpleNamespace(
2767
+ ok=True,
2768
+ status_code=200,
2769
+ json=lambda: {},
2770
+ text="",
2771
+ )
2156
2772
 
2157
- existing = EnergyAccount.objects.create(name="KNOWN")
2773
+ def tearDown(self):
2774
+ cache.clear()
2158
2775
 
2159
- payload = {
2160
- "requester": str(remote.uuid),
2776
+ def _sign(self, payload):
2777
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
2778
+ signature = base64.b64encode(
2779
+ self.private_key.sign(
2780
+ body.encode(), padding.PKCS1v15(), hashes.SHA256()
2781
+ )
2782
+ ).decode()
2783
+ return body, signature
2784
+
2785
+ def test_proxy_session_creates_login_url(self):
2786
+ payload = {
2787
+ "requester": str(self.node.uuid),
2788
+ "user": {
2789
+ "username": "proxy-user",
2790
+ "email": "proxy@example.com",
2791
+ "first_name": "Proxy",
2792
+ "last_name": "User",
2793
+ "is_staff": True,
2794
+ "is_superuser": True,
2795
+ "groups": [],
2796
+ "permissions": [],
2797
+ },
2798
+ "target": "/admin/",
2799
+ }
2800
+ body, signature = self._sign(payload)
2801
+ response = self.client.post(
2802
+ reverse("node-proxy-session"),
2803
+ data=body,
2804
+ content_type="application/json",
2805
+ HTTP_X_SIGNATURE=signature,
2806
+ )
2807
+ self.assertEqual(response.status_code, 200)
2808
+ data = response.json()
2809
+ self.assertIn("login_url", data)
2810
+ user = get_user_model().objects.get(username="proxy-user")
2811
+ self.assertTrue(user.is_staff)
2812
+ parsed = urlparse(data["login_url"])
2813
+ login_response = self.client.get(parsed.path)
2814
+ self.assertEqual(login_response.status_code, 302)
2815
+ self.assertEqual(login_response["Location"], "/admin/")
2816
+ self.assertEqual(self.client.session.get("_auth_user_id"), str(user.pk))
2817
+ second = self.client.get(parsed.path)
2818
+ self.assertEqual(second.status_code, 410)
2819
+
2820
+ def test_proxy_session_accepts_mac_hint_when_uuid_unknown(self):
2821
+ payload = {
2822
+ "requester": str(uuid.uuid4()),
2823
+ "requester_mac": self.node.mac_address,
2824
+ "requester_public_key": self.node.public_key,
2825
+ "user": {
2826
+ "username": "proxy-user",
2827
+ "email": "proxy@example.com",
2828
+ "first_name": "Proxy",
2829
+ "last_name": "User",
2830
+ "is_staff": True,
2831
+ "is_superuser": True,
2832
+ "groups": [],
2833
+ "permissions": [],
2834
+ },
2835
+ "target": "/admin/",
2836
+ }
2837
+ body, signature = self._sign(payload)
2838
+ response = self.client.post(
2839
+ reverse("node-proxy-session"),
2840
+ data=body,
2841
+ content_type="application/json",
2842
+ HTTP_X_SIGNATURE=signature,
2843
+ )
2844
+ self.assertEqual(response.status_code, 200)
2845
+
2846
+ def test_proxy_execute_lists_nodes(self):
2847
+ Node.objects.create(
2848
+ hostname="target",
2849
+ address="127.0.0.5",
2850
+ port=8010,
2851
+ mac_address="aa:bb:cc:dd:ee:bb",
2852
+ )
2853
+ payload = {
2854
+ "requester": str(self.node.uuid),
2855
+ "action": "list",
2856
+ "model": "nodes.Node",
2857
+ "filters": {"hostname": "target"},
2858
+ "credentials": {
2859
+ "username": "suite-user",
2860
+ "password": "secret",
2861
+ "first_name": "Suite",
2862
+ "last_name": "User",
2863
+ },
2864
+ }
2865
+ body, signature = self._sign(payload)
2866
+ response = self.client.post(
2867
+ reverse("node-proxy-execute"),
2868
+ data=body,
2869
+ content_type="application/json",
2870
+ HTTP_X_SIGNATURE=signature,
2871
+ )
2872
+ self.assertEqual(response.status_code, 200)
2873
+ data = response.json()
2874
+ self.assertEqual(len(data.get("objects", [])), 1)
2875
+ record = data["objects"][0]
2876
+ self.assertEqual(record["fields"]["hostname"], "target")
2877
+ user = get_user_model().objects.get(username="suite-user")
2878
+ self.assertTrue(user.is_superuser)
2879
+
2880
+ def test_proxy_execute_requires_valid_password_for_existing_user(self):
2881
+ User = get_user_model()
2882
+ User.objects.create_user(username="suite-user", password="correct")
2883
+ payload = {
2884
+ "requester": str(self.node.uuid),
2885
+ "action": "list",
2886
+ "model": "nodes.Node",
2887
+ "credentials": {
2888
+ "username": "suite-user",
2889
+ "password": "wrong",
2890
+ },
2891
+ }
2892
+ body, signature = self._sign(payload)
2893
+ response = self.client.post(
2894
+ reverse("node-proxy-execute"),
2895
+ data=body,
2896
+ content_type="application/json",
2897
+ HTTP_X_SIGNATURE=signature,
2898
+ )
2899
+ self.assertEqual(response.status_code, 403)
2900
+
2901
+ def test_proxy_execute_schema_returns_models(self):
2902
+ payload = {
2903
+ "requester": str(self.node.uuid),
2904
+ "action": "schema",
2905
+ "credentials": {
2906
+ "username": "suite-user",
2907
+ "password": "secret",
2908
+ },
2909
+ }
2910
+ body, signature = self._sign(payload)
2911
+ response = self.client.post(
2912
+ reverse("node-proxy-execute"),
2913
+ data=body,
2914
+ content_type="application/json",
2915
+ HTTP_X_SIGNATURE=signature,
2916
+ )
2917
+ self.assertEqual(response.status_code, 200)
2918
+ data = response.json()
2919
+ models = data.get("models", [])
2920
+ self.assertTrue(models)
2921
+ suite_names = {entry.get("suite_name") for entry in models}
2922
+ self.assertIn("Nodes", suite_names)
2923
+
2924
+
2925
+ class NodeRFIDAPITests(TestCase):
2926
+ def test_import_endpoint_applies_payload_without_creating_accounts(self):
2927
+ remote = Node.objects.create(
2928
+ hostname="remote", address="127.0.0.10", port=8050
2929
+ )
2930
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
2931
+ remote.public_key = key.public_key().public_bytes(
2932
+ encoding=serialization.Encoding.PEM,
2933
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
2934
+ ).decode()
2935
+ remote.save(update_fields=["public_key"])
2936
+
2937
+ existing = EnergyAccount.objects.create(name="KNOWN")
2938
+
2939
+ payload = {
2940
+ "requester": str(remote.uuid),
2161
2941
  "rfids": [
2162
2942
  {
2163
2943
  "rfid": "deadface",
@@ -2315,37 +3095,14 @@ class NetMessageAdminTests(TransactionTestCase):
2315
3095
  self.assertEqual(form["subject"].value(), "Re: Ping")
2316
3096
  self.assertEqual(str(form["filter_node"].value()), str(node.pk))
2317
3097
 
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
3098
  class NetMessageReachTests(TestCase):
2342
3099
  def setUp(self):
2343
3100
  self.roles = {}
2344
- for name in ["Terminal", "Control", "Satellite", "Constellation"]:
3101
+ for name in ["Terminal", "Control", "Satellite", "Watchtower"]:
2345
3102
  self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
2346
3103
  self.nodes = {}
2347
3104
  for idx, name in enumerate(
2348
- ["Terminal", "Control", "Satellite", "Constellation"], start=1
3105
+ ["Terminal", "Control", "Satellite", "Watchtower"], start=1
2349
3106
  ):
2350
3107
  self.nodes[name] = Node.objects.create(
2351
3108
  hostname=name.lower(),
@@ -2389,15 +3146,15 @@ class NetMessageReachTests(TestCase):
2389
3146
  self.assertEqual(mock_post.call_count, 3)
2390
3147
 
2391
3148
  @patch("requests.post")
2392
- def test_constellation_reach_prioritizes_constellation(self, mock_post):
3149
+ def test_watchtower_reach_prioritizes_watchtower(self, mock_post):
2393
3150
  msg = NetMessage.objects.create(
2394
- subject="s", body="b", reach=self.roles["Constellation"]
3151
+ subject="s", body="b", reach=self.roles["Watchtower"]
2395
3152
  )
2396
3153
  with patch.object(Node, "get_local", return_value=None):
2397
3154
  msg.propagate()
2398
3155
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
2399
3156
  self.assertEqual(
2400
- roles, {"Constellation", "Satellite", "Control", "Terminal"}
3157
+ roles, {"Watchtower", "Satellite", "Control", "Terminal"}
2401
3158
  )
2402
3159
  self.assertEqual(mock_post.call_count, 4)
2403
3160
 
@@ -2572,7 +3329,7 @@ class NetMessagePropagationTests(TestCase):
2572
3329
  with patch.object(Node, "get_local", return_value=self.local):
2573
3330
  msg = NetMessage.broadcast(subject="subject", body="body")
2574
3331
  self.assertEqual(msg.node_origin, self.local)
2575
- self.assertIsNone(msg.reach)
3332
+ self.assertEqual(msg.reach, self.role)
2576
3333
 
2577
3334
  @patch("requests.post")
2578
3335
  @patch("core.notifications.notify")
@@ -2597,13 +3354,6 @@ class NetMessagePropagationTests(TestCase):
2597
3354
  self.assertNotIn(sender_addr, targets)
2598
3355
  self.assertEqual(msg.propagated_to.count(), 4)
2599
3356
  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
3357
 
2608
3358
  @patch("requests.post")
2609
3359
  @patch("core.notifications.notify", return_value=False)
@@ -2689,10 +3439,240 @@ class NetMessagePropagationTests(TestCase):
2689
3439
  ):
2690
3440
  msg.propagate()
2691
3441
 
2692
- self.assertTrue(msg.confirmed_peers)
2693
- self.assertTrue(
2694
- all(entry["status"] == "error" for entry in msg.confirmed_peers.values())
3442
+ self.assertEqual(msg.propagated_to.count(), len(self.remotes))
3443
+ self.assertTrue(msg.complete)
3444
+
3445
+
3446
+ class NetMessageQueueTests(TestCase):
3447
+ def setUp(self):
3448
+ self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
3449
+ self.feature, _ = NodeFeature.objects.get_or_create(
3450
+ slug="celery-queue", defaults={"display": "Celery Queue"}
3451
+ )
3452
+
3453
+ def test_propagate_queues_unreachable_downstream(self):
3454
+ local = Node.objects.create(
3455
+ hostname="local",
3456
+ address="10.0.0.1",
3457
+ port=8000,
3458
+ mac_address="00:11:22:33:44:10",
3459
+ role=self.role,
3460
+ public_endpoint="local",
3461
+ )
3462
+ downstream = Node.objects.create(
3463
+ hostname="downstream",
3464
+ address="10.0.0.2",
3465
+ port=8001,
3466
+ mac_address="00:11:22:33:44:11",
3467
+ role=self.role,
3468
+ current_relation=Node.Relation.DOWNSTREAM,
3469
+ )
3470
+ message = NetMessage.objects.create(subject="Queued", body="Body", reach=self.role)
3471
+ with patch.object(Node, "get_local", return_value=local), patch.object(
3472
+ Node, "get_private_key", return_value=None
3473
+ ), patch("core.notifications.notify", return_value=False), patch(
3474
+ "requests.post", side_effect=Exception("fail")
3475
+ ):
3476
+ message.propagate()
3477
+
3478
+ entry = PendingNetMessage.objects.get(node=downstream, message=message)
3479
+ self.assertIn(str(downstream.uuid), entry.seen)
3480
+ self.assertGreater(entry.stale_at, timezone.now())
3481
+
3482
+ def test_queue_limit_enforced(self):
3483
+ downstream = Node.objects.create(
3484
+ hostname="limit",
3485
+ address="10.0.0.3",
3486
+ port=8002,
3487
+ mac_address="00:11:22:33:44:12",
3488
+ role=self.role,
3489
+ current_relation=Node.Relation.DOWNSTREAM,
3490
+ message_queue_length=1,
3491
+ )
3492
+ msg1 = NetMessage.objects.create(subject="Old", body="One", reach=self.role)
3493
+ msg2 = NetMessage.objects.create(subject="New", body="Two", reach=self.role)
3494
+
3495
+ msg1.queue_for_node(downstream, [str(downstream.uuid)])
3496
+ msg2.queue_for_node(downstream, [str(downstream.uuid)])
3497
+
3498
+ entries = list(PendingNetMessage.objects.filter(node=downstream))
3499
+ self.assertEqual(len(entries), 1)
3500
+ self.assertEqual(entries[0].message, msg2)
3501
+
3502
+ def test_queue_duplicate_updates_stale(self):
3503
+ downstream = Node.objects.create(
3504
+ hostname="dup",
3505
+ address="10.0.0.4",
3506
+ port=8003,
3507
+ mac_address="00:11:22:33:44:13",
3508
+ role=self.role,
3509
+ current_relation=Node.Relation.DOWNSTREAM,
3510
+ )
3511
+ message = NetMessage.objects.create(subject="Dup", body="Dup", reach=self.role)
3512
+ first = timezone.now()
3513
+ second = first + timedelta(minutes=5)
3514
+ with patch(
3515
+ "nodes.models.timezone.now", side_effect=[first, second, second]
3516
+ ):
3517
+ message.queue_for_node(downstream, ["first"])
3518
+ message.queue_for_node(downstream, ["second"])
3519
+
3520
+ entry = PendingNetMessage.objects.get(node=downstream, message=message)
3521
+ self.assertEqual(entry.seen, ["second"])
3522
+ self.assertEqual(entry.queued_at, second)
3523
+ self.assertEqual(entry.stale_at, second + timedelta(hours=1))
3524
+
3525
+ def test_pull_endpoint_returns_and_clears_messages(self):
3526
+ local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3527
+ downstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3528
+ local = Node.objects.create(
3529
+ hostname="hub",
3530
+ address="10.0.0.5",
3531
+ port=8004,
3532
+ mac_address="00:11:22:33:44:14",
3533
+ role=self.role,
3534
+ public_endpoint="hub",
3535
+ )
3536
+ downstream = Node.objects.create(
3537
+ hostname="remote",
3538
+ address="10.0.0.6",
3539
+ port=8005,
3540
+ mac_address="00:11:22:33:44:15",
3541
+ role=self.role,
3542
+ current_relation=Node.Relation.DOWNSTREAM,
3543
+ public_key=downstream_key.public_key()
3544
+ .public_bytes(
3545
+ serialization.Encoding.PEM,
3546
+ serialization.PublicFormat.SubjectPublicKeyInfo,
3547
+ )
3548
+ .decode(),
3549
+ )
3550
+ message = NetMessage.objects.create(subject="Fresh", body="Body", reach=self.role)
3551
+ stale_message = NetMessage.objects.create(subject="Stale", body="Body", reach=self.role)
3552
+ now = timezone.now()
3553
+ PendingNetMessage.objects.create(
3554
+ node=downstream,
3555
+ message=message,
3556
+ seen=[str(downstream.uuid)],
3557
+ stale_at=now + timedelta(minutes=30),
3558
+ )
3559
+ stale_entry = PendingNetMessage.objects.create(
3560
+ node=downstream,
3561
+ message=stale_message,
3562
+ seen=["stale"],
3563
+ stale_at=now - timedelta(minutes=5),
3564
+ )
3565
+ PendingNetMessage.objects.filter(pk=stale_entry.pk).update(
3566
+ queued_at=now - timedelta(minutes=5)
3567
+ )
3568
+
3569
+ def fake_get_private(node_obj):
3570
+ if node_obj.pk == local.pk:
3571
+ return local_key
3572
+ return None
3573
+
3574
+ payload = {"requester": str(downstream.uuid)}
3575
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
3576
+ signature = base64.b64encode(
3577
+ downstream_key.sign(
3578
+ body.encode(),
3579
+ padding.PKCS1v15(),
3580
+ hashes.SHA256(),
3581
+ )
3582
+ ).decode()
3583
+
3584
+ with patch.object(Node, "get_local", return_value=local), patch.object(
3585
+ Node, "get_private_key", return_value=local_key
3586
+ ):
3587
+ response = self.client.post(
3588
+ reverse("net-message-pull"),
3589
+ data=body,
3590
+ content_type="application/json",
3591
+ HTTP_X_SIGNATURE=signature,
3592
+ )
3593
+
3594
+ self.assertEqual(response.status_code, 200)
3595
+ data = response.json()
3596
+ self.assertEqual(len(data.get("messages", [])), 1)
3597
+ payload_data = data["messages"][0]["payload"]
3598
+ self.assertEqual(payload_data["uuid"], str(message.uuid))
3599
+ self.assertFalse(
3600
+ PendingNetMessage.objects.filter(node=downstream, message=message).exists()
3601
+ )
3602
+ self.assertFalse(
3603
+ PendingNetMessage.objects.filter(
3604
+ node=downstream, message=stale_message
3605
+ ).exists()
3606
+ )
3607
+ response_signature = data["messages"][0]["signature"]
3608
+ local_public = local_key.public_key()
3609
+ local_public.verify(
3610
+ base64.b64decode(response_signature),
3611
+ json.dumps(payload_data, separators=(",", ":"), sort_keys=True).encode(),
3612
+ padding.PKCS1v15(),
3613
+ hashes.SHA256(),
3614
+ )
3615
+
3616
+ def test_poll_task_fetches_messages(self):
3617
+ local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3618
+ upstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3619
+ local = Node.objects.create(
3620
+ hostname="downstream",
3621
+ address="10.0.0.7",
3622
+ port=8006,
3623
+ mac_address="00:11:22:33:44:16",
3624
+ role=self.role,
3625
+ public_endpoint="downstream",
3626
+ )
3627
+ upstream = Node.objects.create(
3628
+ hostname="upstream",
3629
+ address="127.0.0.2",
3630
+ port=8010,
3631
+ mac_address="00:11:22:33:44:17",
3632
+ role=self.role,
3633
+ current_relation=Node.Relation.UPSTREAM,
3634
+ public_key=upstream_key.public_key()
3635
+ .public_bytes(
3636
+ serialization.Encoding.PEM,
3637
+ serialization.PublicFormat.SubjectPublicKeyInfo,
3638
+ )
3639
+ .decode(),
2695
3640
  )
3641
+ NodeFeatureAssignment.objects.create(node=local, feature=self.feature)
3642
+ payload = {
3643
+ "uuid": str(uuid.uuid4()),
3644
+ "subject": "Update",
3645
+ "body": "Body",
3646
+ "seen": [str(local.uuid)],
3647
+ "origin": str(upstream.uuid),
3648
+ "sender": str(upstream.uuid),
3649
+ }
3650
+ payload_text = json.dumps(payload, separators=(",", ":"), sort_keys=True)
3651
+ payload_signature = base64.b64encode(
3652
+ upstream_key.sign(
3653
+ payload_text.encode(),
3654
+ padding.PKCS1v15(),
3655
+ hashes.SHA256(),
3656
+ )
3657
+ ).decode()
3658
+ response = MagicMock()
3659
+ response.ok = True
3660
+ response.json.return_value = {
3661
+ "messages": [{"payload": payload, "signature": payload_signature}]
3662
+ }
3663
+
3664
+ with patch.object(Node, "get_local", return_value=local), patch.object(
3665
+ Node, "get_private_key", return_value=local_key
3666
+ ), patch("nodes.tasks.requests.post", return_value=response) as mock_post, patch.object(
3667
+ NetMessage, "propagate"
3668
+ ) as mock_propagate:
3669
+ poll_unreachable_upstream()
3670
+
3671
+ created = NetMessage.objects.get(uuid=payload["uuid"])
3672
+ self.assertEqual(created.subject, "Update")
3673
+ self.assertEqual(created.node_origin, upstream)
3674
+ mock_post.assert_called_once()
3675
+ mock_propagate.assert_called_once()
2696
3676
 
2697
3677
 
2698
3678
  class NetMessageSignatureTests(TestCase):
@@ -2765,6 +3745,82 @@ class NetMessageSignatureTests(TestCase):
2765
3745
  self.assertTrue(signature_one)
2766
3746
  self.assertTrue(signature_two)
2767
3747
  self.assertNotEqual(signature_one, signature_two)
3748
+
3749
+
3750
+ class NetworkChargerActionSecurityTests(TestCase):
3751
+ def setUp(self):
3752
+ self.client = Client()
3753
+ self.local_node = Node.objects.create(
3754
+ hostname="local-node",
3755
+ address="127.0.0.1",
3756
+ port=8000,
3757
+ mac_address="00:aa:bb:cc:dd:10",
3758
+ public_endpoint="local-endpoint",
3759
+ )
3760
+ self.authorized_node = Node.objects.create(
3761
+ hostname="authorized-node",
3762
+ address="127.0.0.2",
3763
+ port=8001,
3764
+ mac_address="00:aa:bb:cc:dd:11",
3765
+ public_endpoint="authorized-endpoint",
3766
+ )
3767
+ self.unauthorized_node, self.unauthorized_key = self._create_signed_node(
3768
+ "unauthorized-node",
3769
+ mac_suffix=0x12,
3770
+ )
3771
+ self.charger = Charger.objects.create(
3772
+ charger_id="SECURE-TEST-1",
3773
+ allow_remote=True,
3774
+ manager_node=self.authorized_node,
3775
+ node_origin=self.local_node,
3776
+ )
3777
+
3778
+ def _create_signed_node(self, hostname: str, *, mac_suffix: int):
3779
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3780
+ public_bytes = key.public_key().public_bytes(
3781
+ encoding=serialization.Encoding.PEM,
3782
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
3783
+ )
3784
+ node = Node.objects.create(
3785
+ hostname=hostname,
3786
+ address="10.0.0.{:d}".format(mac_suffix),
3787
+ port=8020,
3788
+ mac_address="00:aa:bb:cc:dd:{:02x}".format(mac_suffix),
3789
+ public_key=public_bytes.decode(),
3790
+ public_endpoint=f"{hostname}-endpoint",
3791
+ )
3792
+ return node, key
3793
+
3794
+ def test_rejects_requests_from_unmanaged_nodes(self):
3795
+ url = reverse("node-network-charger-action")
3796
+ payload = {
3797
+ "requester": str(self.unauthorized_node.uuid),
3798
+ "charger_id": self.charger.charger_id,
3799
+ "action": "reset",
3800
+ }
3801
+ body = json.dumps(payload).encode()
3802
+ signature = self.unauthorized_key.sign(
3803
+ body,
3804
+ padding.PKCS1v15(),
3805
+ hashes.SHA256(),
3806
+ )
3807
+ headers = {"HTTP_X_SIGNATURE": base64.b64encode(signature).decode()}
3808
+
3809
+ with patch.object(Node, "get_local", return_value=self.local_node):
3810
+ response = self.client.post(
3811
+ url,
3812
+ data=body,
3813
+ content_type="application/json",
3814
+ **headers,
3815
+ )
3816
+
3817
+ self.assertEqual(response.status_code, 403)
3818
+ self.assertEqual(
3819
+ response.json().get("detail"),
3820
+ "requester does not manage this charger",
3821
+ )
3822
+
3823
+
2768
3824
  class StartupNotificationTests(TestCase):
2769
3825
  def test_startup_notification_uses_hostname_and_revision(self):
2770
3826
  from nodes.apps import _startup_notification
@@ -2848,6 +3904,17 @@ class StartupHandlerTests(TestCase):
2848
3904
 
2849
3905
  mock_start.assert_called_once()
2850
3906
 
3907
+ def test_handler_skips_during_migrate_command(self):
3908
+ import sys
3909
+
3910
+ from nodes.apps import _trigger_startup_notification
3911
+
3912
+ with patch("nodes.apps._startup_notification") as mock_start:
3913
+ with patch.object(sys, "argv", ["manage.py", "migrate"]):
3914
+ _trigger_startup_notification()
3915
+
3916
+ mock_start.assert_not_called()
3917
+
2851
3918
 
2852
3919
  class NotificationManagerTests(TestCase):
2853
3920
  def test_send_writes_trimmed_lines(self):
@@ -2930,6 +3997,7 @@ class ContentSampleTransactionTests(TestCase):
2930
3997
  sample1.save()
2931
3998
 
2932
3999
 
4000
+ @pytest.mark.feature("clipboard-poll")
2933
4001
  class ContentSampleAdminTests(TestCase):
2934
4002
  def setUp(self):
2935
4003
  User = get_user_model()
@@ -3106,6 +4174,7 @@ class EmailOutboxTests(TestCase):
3106
4174
 
3107
4175
 
3108
4176
  class ClipboardTaskTests(TestCase):
4177
+ @pytest.mark.feature("clipboard-poll")
3109
4178
  @patch("nodes.tasks.pyperclip.paste")
3110
4179
  def test_sample_clipboard_task_creates_sample(self, mock_paste):
3111
4180
  mock_paste.return_value = "task text"
@@ -3130,6 +4199,7 @@ class ClipboardTaskTests(TestCase):
3130
4199
  ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
3131
4200
  )
3132
4201
 
4202
+ @pytest.mark.feature("screenshot-poll")
3133
4203
  @patch("nodes.tasks.capture_screenshot")
3134
4204
  def test_capture_node_screenshot_task(self, mock_capture):
3135
4205
  node = Node.objects.create(
@@ -3152,6 +4222,7 @@ class ClipboardTaskTests(TestCase):
3152
4222
  self.assertEqual(screenshot.path, "screenshots/test.png")
3153
4223
  self.assertEqual(screenshot.method, "TASK")
3154
4224
 
4225
+ @pytest.mark.feature("screenshot-poll")
3155
4226
  @patch("nodes.tasks.capture_screenshot")
3156
4227
  def test_capture_node_screenshot_handles_error(self, mock_capture):
3157
4228
  Node.objects.create(
@@ -3169,6 +4240,17 @@ class ClipboardTaskTests(TestCase):
3169
4240
 
3170
4241
 
3171
4242
  class CaptureScreenshotTests(TestCase):
4243
+ def setUp(self):
4244
+ super().setUp()
4245
+ self.firefox_patcher = patch(
4246
+ "nodes.utils._find_firefox_binary", return_value="/usr/bin/firefox"
4247
+ )
4248
+ self.ensure_geckodriver_patcher = patch("nodes.utils._ensure_geckodriver")
4249
+ self.firefox_patcher.start()
4250
+ self.ensure_geckodriver_patcher.start()
4251
+ self.addCleanup(self.firefox_patcher.stop)
4252
+ self.addCleanup(self.ensure_geckodriver_patcher.stop)
4253
+
3172
4254
  @patch("nodes.utils.webdriver.Firefox")
3173
4255
  def test_connection_failure_does_not_raise(self, mock_firefox):
3174
4256
  browser = MagicMock()
@@ -3181,6 +4263,19 @@ class CaptureScreenshotTests(TestCase):
3181
4263
  self.assertEqual(result.parent, screenshot_dir)
3182
4264
  browser.save_screenshot.assert_called_once()
3183
4265
 
4266
+ def test_missing_firefox_reports_clear_error(self):
4267
+ with patch("nodes.utils._find_firefox_binary", return_value=None):
4268
+ with self.assertRaises(RuntimeError) as excinfo:
4269
+ capture_screenshot("http://example.com")
4270
+ self.assertIn("Firefox is not installed", str(excinfo.exception))
4271
+
4272
+ @patch("nodes.utils.webdriver.Firefox")
4273
+ def test_driver_install_hint_on_failure(self, mock_firefox):
4274
+ mock_firefox.side_effect = WebDriverException("Unable to obtain driver for firefox")
4275
+ with self.assertRaises(RuntimeError) as excinfo:
4276
+ capture_screenshot("http://example.com")
4277
+ self.assertIn("Firefox WebDriver is unavailable", str(excinfo.exception))
4278
+
3184
4279
 
3185
4280
  class NodeRoleAdminTests(TestCase):
3186
4281
  def setUp(self):
@@ -3231,7 +4326,7 @@ class NodeRoleAdminTests(TestCase):
3231
4326
 
3232
4327
  class NodeFeatureFixtureTests(TestCase):
3233
4328
  def test_rfid_scanner_fixture_includes_control_role(self):
3234
- for name in ("Terminal", "Satellite", "Constellation", "Control"):
4329
+ for name in ("Terminal", "Satellite", "Watchtower", "Control"):
3235
4330
  NodeRole.objects.get_or_create(name=name)
3236
4331
  fixture_path = (
3237
4332
  Path(__file__).resolve().parent
@@ -3243,6 +4338,7 @@ class NodeFeatureFixtureTests(TestCase):
3243
4338
  role_names = set(feature.roles.values_list("name", flat=True))
3244
4339
  self.assertIn("Control", role_names)
3245
4340
 
4341
+ @pytest.mark.feature("ap-router")
3246
4342
  def test_ap_router_fixture_limits_roles(self):
3247
4343
  for name in ("Control", "Satellite"):
3248
4344
  NodeRole.objects.get_or_create(name=name)
@@ -3256,6 +4352,23 @@ class NodeFeatureFixtureTests(TestCase):
3256
4352
  role_names = set(feature.roles.values_list("name", flat=True))
3257
4353
  self.assertEqual(role_names, {"Satellite"})
3258
4354
 
4355
+ @pytest.mark.feature("graphql")
4356
+ def test_graphql_fixture_excludes_terminal_role(self):
4357
+ for name in ("Control", "Interface", "Satellite", "Terminal", "Watchtower"):
4358
+ NodeRole.objects.get_or_create(name=name)
4359
+ fixture_path = (
4360
+ Path(__file__).resolve().parent
4361
+ / "fixtures"
4362
+ / "node_features__nodefeature_graphql.json"
4363
+ )
4364
+ call_command("loaddata", str(fixture_path), verbosity=0)
4365
+ feature = NodeFeature.objects.get(slug="graphql")
4366
+ role_names = set(feature.roles.values_list("name", flat=True))
4367
+ self.assertEqual(
4368
+ role_names,
4369
+ {"Control", "Interface", "Satellite", "Watchtower"},
4370
+ )
4371
+
3259
4372
 
3260
4373
  class NodeFeatureTests(TestCase):
3261
4374
  def setUp(self):
@@ -3293,6 +4406,7 @@ class NodeFeatureTests(TestCase):
3293
4406
  self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
3294
4407
  self.assertEqual(feature.get_default_action(), action)
3295
4408
 
4409
+ @pytest.mark.feature("rpi-camera")
3296
4410
  def test_rpi_camera_feature_has_multiple_actions(self):
3297
4411
  feature = NodeFeature.objects.create(
3298
4412
  slug="rpi-camera", display="Raspberry Pi Camera"
@@ -3303,6 +4417,7 @@ class NodeFeatureTests(TestCase):
3303
4417
  self.assertIn("Take a Snapshot", labels)
3304
4418
  self.assertIn("View stream", labels)
3305
4419
 
4420
+ @pytest.mark.feature("audio-capture")
3306
4421
  def test_audio_capture_feature_has_view_waveform_action(self):
3307
4422
  feature = NodeFeature.objects.create(
3308
4423
  slug="audio-capture", display="Audio Capture"
@@ -3388,17 +4503,6 @@ class NodeFeatureTests(TestCase):
3388
4503
  ):
3389
4504
  self.assertTrue(feature.is_enabled)
3390
4505
 
3391
- @patch("nodes.models.Node._has_gway_runner", return_value=True)
3392
- def test_gway_runner_enabled_when_command_available(self, mock_has_runner):
3393
- feature = NodeFeature.objects.create(
3394
- slug="gway-runner", display="gway Runner"
3395
- )
3396
- with patch(
3397
- "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
3398
- ):
3399
- self.assertTrue(feature.is_enabled)
3400
- mock_has_runner.assert_called_once_with()
3401
-
3402
4506
  @patch("nodes.models.Node._has_rpi_camera", return_value=True)
3403
4507
  def test_rpi_camera_detection(self, mock_camera):
3404
4508
  feature = NodeFeature.objects.create(
@@ -3437,49 +4541,7 @@ class NodeFeatureTests(TestCase):
3437
4541
  ).exists()
3438
4542
  )
3439
4543
 
3440
- @patch("nodes.models.Node._find_gway_runner_command", return_value="/usr/bin/gway")
3441
- def test_gway_runner_detection(self, mock_find_command):
3442
- feature = NodeFeature.objects.create(
3443
- slug="gway-runner", display="gway Runner"
3444
- )
3445
- feature.roles.add(self.role)
3446
- with patch(
3447
- "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
3448
- ):
3449
- self.node.refresh_features()
3450
- self.assertTrue(
3451
- NodeFeatureAssignment.objects.filter(
3452
- node=self.node, feature=feature
3453
- ).exists()
3454
- )
3455
- mock_find_command.assert_called_with()
3456
-
3457
- @patch(
3458
- "nodes.models.Node._find_gway_runner_command",
3459
- side_effect=["/usr/bin/gway", None],
3460
- )
3461
- def test_gway_runner_removed_when_command_missing(self, mock_find_command):
3462
- feature = NodeFeature.objects.create(
3463
- slug="gway-runner", display="gway Runner"
3464
- )
3465
- feature.roles.add(self.role)
3466
- with patch(
3467
- "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
3468
- ):
3469
- self.node.refresh_features()
3470
- self.assertTrue(
3471
- NodeFeatureAssignment.objects.filter(
3472
- node=self.node, feature=feature
3473
- ).exists()
3474
- )
3475
- self.node.refresh_features()
3476
- self.assertFalse(
3477
- NodeFeatureAssignment.objects.filter(
3478
- node=self.node, feature=feature
3479
- ).exists()
3480
- )
3481
- self.assertEqual(mock_find_command.call_count, 2)
3482
-
4544
+ @pytest.mark.feature("ap-router")
3483
4545
  @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
3484
4546
  def test_ap_router_detection(self, mock_hosts):
3485
4547
  control_role, _ = NodeRole.objects.get_or_create(name="Control")
@@ -3499,6 +4561,7 @@ class NodeFeatureTests(TestCase):
3499
4561
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
3500
4562
  )
3501
4563
 
4564
+ @pytest.mark.feature("ap-router")
3502
4565
  @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
3503
4566
  def test_ap_router_detection_with_public_mode_lock(self, mock_hosts):
3504
4567
  control_role, _ = NodeRole.objects.get_or_create(name="Control")
@@ -3523,6 +4586,7 @@ class NodeFeatureTests(TestCase):
3523
4586
  NodeFeatureAssignment.objects.filter(node=node, feature=router).exists()
3524
4587
  )
3525
4588
 
4589
+ @pytest.mark.feature("ap-router")
3526
4590
  @patch("nodes.models.Node._hosts_gelectriic_ap", side_effect=[True, False])
3527
4591
  def test_ap_router_removed_when_not_hosting(self, mock_hosts):
3528
4592
  control_role, _ = NodeRole.objects.get_or_create(name="Control")
@@ -3895,6 +4959,38 @@ class ContentClassifierTests(TestCase):
3895
4959
  tags = ContentClassification.objects.filter(sample=sample)
3896
4960
  self.assertTrue(tags.filter(tag__slug="screenshot-tag").exists())
3897
4961
 
4962
+ def test_save_screenshot_returns_none_for_duplicate_without_linking(self):
4963
+ with TemporaryDirectory() as tmp:
4964
+ base = Path(tmp)
4965
+ first_path = base / "capture.png"
4966
+ first_path.write_bytes(b"binary image data")
4967
+ duplicate_path = base / "duplicate.png"
4968
+ duplicate_path.write_bytes(b"binary image data")
4969
+ with override_settings(LOG_DIR=base):
4970
+ original = save_screenshot(first_path, method="TEST")
4971
+ duplicate = save_screenshot(duplicate_path, method="TEST")
4972
+
4973
+ self.assertIsNotNone(original)
4974
+ self.assertIsNone(duplicate)
4975
+ self.assertEqual(ContentSample.objects.count(), 1)
4976
+
4977
+ def test_save_screenshot_reuses_existing_sample_when_linking(self):
4978
+ with TemporaryDirectory() as tmp:
4979
+ base = Path(tmp)
4980
+ first_path = base / "capture.png"
4981
+ first_path.write_bytes(b"binary image data")
4982
+ duplicate_path = base / "duplicate.png"
4983
+ duplicate_path.write_bytes(b"binary image data")
4984
+ with override_settings(LOG_DIR=base):
4985
+ original = save_screenshot(first_path, method="TEST")
4986
+ reused = save_screenshot(
4987
+ duplicate_path, method="TEST", link_duplicates=True
4988
+ )
4989
+
4990
+ self.assertIsNotNone(original)
4991
+ self.assertEqual(reused, original)
4992
+ self.assertEqual(ContentSample.objects.count(), 1)
4993
+
3898
4994
  def test_text_sample_runs_default_classifiers_without_duplicates(self):
3899
4995
  sample = ContentSample.objects.create(
3900
4996
  content="Example content", kind=ContentSample.TEXT