arthexis 0.1.16__py3-none-any.whl → 0.1.28__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 (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {arthexis-0.1.16.dist-info → arthexis-0.1.28.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,21 +32,37 @@ 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 import messages
47
+ from django.contrib.admin import helpers
37
48
  from django.contrib.auth.models import Permission
38
49
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
50
+ from core.celery_utils import (
51
+ periodic_task_name_variants,
52
+ slugify_task_name,
53
+ )
39
54
  from django.conf import settings
40
55
  from django.utils import timezone
56
+ from urllib.parse import urlparse
41
57
  from dns import resolver as dns_resolver
42
58
  from . import dns as dns_utils
43
59
  from selenium.common.exceptions import WebDriverException
44
60
  from .classifiers import run_default_classifiers
45
61
  from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
62
+ from .feature_checks import feature_checks
46
63
  from django.db.utils import DatabaseError
47
64
 
65
+ from .admin import NodeAdmin
48
66
  from .models import (
49
67
  Node,
50
68
  EmailOutbox,
@@ -56,28 +74,31 @@ from .models import (
56
74
  NodeFeature,
57
75
  NodeFeatureAssignment,
58
76
  NetMessage,
77
+ PendingNetMessage,
59
78
  NodeManager,
60
79
  DNSRecord,
61
80
  )
62
81
  from .backends import OutboxEmailBackend
63
- from .tasks import capture_node_screenshot, sample_clipboard
82
+ from .tasks import capture_node_screenshot, poll_unreachable_upstream, sample_clipboard
83
+ from ocpp.models import Charger
64
84
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
65
85
  from cryptography.hazmat.primitives import serialization, hashes
66
- from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount
86
+ from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount, Todo
87
+ from requests.exceptions import SSLError
67
88
 
68
89
 
69
90
  class NodeBadgeColorTests(TestCase):
70
91
  def setUp(self):
71
- self.constellation, _ = NodeRole.objects.get_or_create(name="Constellation")
92
+ self.watchtower, _ = NodeRole.objects.get_or_create(name="Watchtower")
72
93
  self.control, _ = NodeRole.objects.get_or_create(name="Control")
73
94
 
74
- def test_constellation_role_defaults_to_goldenrod(self):
95
+ def test_watchtower_role_defaults_to_goldenrod(self):
75
96
  node = Node.objects.create(
76
- hostname="constellation",
97
+ hostname="watchtower",
77
98
  address="10.1.0.1",
78
- port=8000,
99
+ port=8888,
79
100
  mac_address="00:aa:bb:cc:dd:01",
80
- role=self.constellation,
101
+ role=self.watchtower,
81
102
  )
82
103
  self.assertEqual(node.badge_color, "#daa520")
83
104
 
@@ -97,7 +118,7 @@ class NodeBadgeColorTests(TestCase):
97
118
  address="10.1.0.3",
98
119
  port=8002,
99
120
  mac_address="00:aa:bb:cc:dd:03",
100
- role=self.constellation,
121
+ role=self.watchtower,
101
122
  badge_color="#123456",
102
123
  )
103
124
  self.assertEqual(node.badge_color, "#123456")
@@ -110,6 +131,24 @@ class NodeTests(TestCase):
110
131
  self.user = User.objects.create_user(username="nodeuser", password="pwd")
111
132
  self.client.force_login(self.user)
112
133
  NodeRole.objects.get_or_create(name="Terminal")
134
+ NodeRole.objects.get_or_create(name="Interface")
135
+
136
+ def test_terminal_role_enables_clipboard_feature_by_default(self):
137
+ role = NodeRole.objects.get(name="Terminal")
138
+ feature, _ = NodeFeature.objects.get_or_create(
139
+ slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
140
+ )
141
+ feature.roles.add(role)
142
+
143
+ node = Node.objects.create(
144
+ hostname="terminal-node",
145
+ address="10.0.0.5",
146
+ port=8888,
147
+ mac_address="aa:bb:cc:dd:ee:ff",
148
+ role=role,
149
+ )
150
+
151
+ self.assertTrue(node.has_feature("clipboard-poll"))
113
152
 
114
153
 
115
154
  class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
@@ -125,6 +164,12 @@ class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
125
164
 
126
165
 
127
166
  class NodeGetLocalTests(TestCase):
167
+ def setUp(self):
168
+ super().setUp()
169
+ User = get_user_model()
170
+ self.user = User.objects.create_user(username="localtester", password="pwd")
171
+ self.client.force_login(self.user)
172
+
128
173
  def test_normalize_relation_handles_various_inputs(self):
129
174
  self.assertEqual(
130
175
  Node.normalize_relation(Node.Relation.UPSTREAM),
@@ -166,6 +211,7 @@ class NodeGetLocalTests(TestCase):
166
211
  patch(
167
212
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
168
213
  ),
214
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
169
215
  patch("nodes.models.revision.get_revision", return_value="rev"),
170
216
  patch.object(Node, "ensure_keys"),
171
217
  ):
@@ -177,7 +223,7 @@ class NodeGetLocalTests(TestCase):
177
223
 
178
224
  def test_register_current_updates_role_from_lock_file(self):
179
225
  NodeRole.objects.get_or_create(name="Terminal")
180
- NodeRole.objects.get_or_create(name="Constellation")
226
+ NodeRole.objects.get_or_create(name="Watchtower")
181
227
  with TemporaryDirectory() as tmp:
182
228
  base = Path(tmp)
183
229
  lock_dir = base / "locks"
@@ -194,6 +240,7 @@ class NodeGetLocalTests(TestCase):
194
240
  patch(
195
241
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
196
242
  ),
243
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
197
244
  patch("nodes.models.revision.get_revision", return_value="rev"),
198
245
  patch.object(Node, "ensure_keys"),
199
246
  patch.object(Node, "notify_peers_of_update"),
@@ -202,7 +249,7 @@ class NodeGetLocalTests(TestCase):
202
249
  self.assertTrue(created)
203
250
  self.assertEqual(node.role.name, "Terminal")
204
251
 
205
- role_file.write_text("Constellation")
252
+ role_file.write_text("Watchtower")
206
253
  with override_settings(BASE_DIR=base):
207
254
  with (
208
255
  patch(
@@ -213,6 +260,7 @@ class NodeGetLocalTests(TestCase):
213
260
  patch(
214
261
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
215
262
  ),
263
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
216
264
  patch("nodes.models.revision.get_revision", return_value="rev"),
217
265
  patch.object(Node, "ensure_keys"),
218
266
  patch.object(Node, "notify_peers_of_update"),
@@ -221,7 +269,70 @@ class NodeGetLocalTests(TestCase):
221
269
 
222
270
  self.assertFalse(created_again)
223
271
  node.refresh_from_db()
224
- self.assertEqual(node.role.name, "Constellation")
272
+ self.assertEqual(node.role.name, "Watchtower")
273
+
274
+ role_file.write_text("Constellation")
275
+ with override_settings(BASE_DIR=base):
276
+ with (
277
+ patch(
278
+ "nodes.models.Node.get_current_mac",
279
+ return_value="00:aa:bb:cc:dd:ee",
280
+ ),
281
+ patch("nodes.models.socket.gethostname", return_value="role-host"),
282
+ patch(
283
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
284
+ ),
285
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
286
+ patch("nodes.models.revision.get_revision", return_value="rev"),
287
+ patch.object(Node, "ensure_keys"),
288
+ patch.object(Node, "notify_peers_of_update"),
289
+ ):
290
+ Node.register_current()
291
+
292
+ node.refresh_from_db()
293
+ self.assertEqual(node.role.name, "Watchtower")
294
+
295
+ def test_register_current_respects_node_hostname_env(self):
296
+ with TemporaryDirectory() as tmp:
297
+ base = Path(tmp)
298
+ with override_settings(BASE_DIR=base):
299
+ with (
300
+ patch.dict(os.environ, {"NODE_HOSTNAME": "gway-002"}, clear=False),
301
+ patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"),
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-002")
312
+ self.assertEqual(node.public_endpoint, "gway-002")
313
+
314
+ def test_register_current_respects_public_endpoint_env(self):
315
+ with TemporaryDirectory() as tmp:
316
+ base = Path(tmp)
317
+ with override_settings(BASE_DIR=base):
318
+ with (
319
+ patch.dict(
320
+ os.environ,
321
+ {"NODE_HOSTNAME": "gway-alpha", "NODE_PUBLIC_ENDPOINT": "gway-002"},
322
+ clear=False,
323
+ ),
324
+ patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:56"),
325
+ patch("nodes.models.socket.gethostname", return_value="localhost"),
326
+ patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
327
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
328
+ patch("nodes.models.revision.get_revision", return_value="rev"),
329
+ patch.object(Node, "ensure_keys"),
330
+ patch.object(Node, "notify_peers_of_update"),
331
+ ):
332
+ node, created = Node.register_current()
333
+ self.assertTrue(created)
334
+ self.assertEqual(node.hostname, "gway-alpha")
335
+ self.assertEqual(node.public_endpoint, "gway-002")
225
336
 
226
337
  def test_register_and_list_node(self):
227
338
  response = self.client.post(
@@ -229,7 +340,7 @@ class NodeGetLocalTests(TestCase):
229
340
  data={
230
341
  "hostname": "local",
231
342
  "address": "127.0.0.1",
232
- "port": 8000,
343
+ "port": 8888,
233
344
  "mac_address": "00:11:22:33:44:55",
234
345
  },
235
346
  content_type="application/json",
@@ -309,6 +420,101 @@ class NodeGetLocalTests(TestCase):
309
420
  self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
310
421
  self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
311
422
 
423
+ def test_register_node_accepts_network_hostname_without_address(self):
424
+ response = self.client.post(
425
+ reverse("register-node"),
426
+ data={
427
+ "hostname": "domain-node",
428
+ "network_hostname": "domain-node.example.com",
429
+ "port": 8050,
430
+ "mac_address": "aa:bb:cc:dd:ee:ff",
431
+ },
432
+ content_type="application/json",
433
+ )
434
+ self.assertEqual(response.status_code, 200)
435
+ node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:ff")
436
+ self.assertEqual(node.network_hostname, "domain-node.example.com")
437
+ self.assertIsNone(node.address)
438
+ self.assertIsNone(node.ipv4_address)
439
+ self.assertIsNone(node.ipv6_address)
440
+
441
+ def test_register_node_populates_missing_ip_fields_from_address(self):
442
+ response = self.client.post(
443
+ reverse("register-node"),
444
+ data={
445
+ "hostname": "address-node",
446
+ "address": "203.0.113.10",
447
+ "port": 8040,
448
+ "mac_address": "aa:bb:cc:dd:ee:01",
449
+ },
450
+ content_type="application/json",
451
+ )
452
+ self.assertEqual(response.status_code, 200)
453
+ node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:01")
454
+ self.assertEqual(node.address, "203.0.113.10")
455
+ self.assertEqual(node.ipv4_address, "203.0.113.10")
456
+ self.assertIsNone(node.ipv6_address)
457
+
458
+ def test_get_best_ip_ignores_non_ip_values(self):
459
+ node = Node.objects.create(
460
+ hostname="best-ip",
461
+ address="gateway.local",
462
+ ipv4_address="198.51.100.5",
463
+ port=8888,
464
+ mac_address="00:11:22:33:44:77",
465
+ )
466
+ self.assertEqual(node.get_best_ip(), "198.51.100.5")
467
+
468
+ def test_register_node_requires_contact_information(self):
469
+ response = self.client.post(
470
+ reverse("register-node"),
471
+ data={
472
+ "hostname": "missing-host",
473
+ "port": 8051,
474
+ "mac_address": "aa:bb:cc:dd:ee:00",
475
+ },
476
+ content_type="application/json",
477
+ )
478
+ self.assertEqual(response.status_code, 400)
479
+ self.assertIn("at least one", response.json()["detail"])
480
+
481
+ def test_register_node_assigns_interface_role_and_returns_uuid(self):
482
+ NodeRole.objects.get_or_create(name="Interface")
483
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
484
+ public_bytes = private_key.public_key().public_bytes(
485
+ encoding=serialization.Encoding.PEM,
486
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
487
+ ).decode()
488
+ token = "interface-token"
489
+ signature = base64.b64encode(
490
+ private_key.sign(
491
+ token.encode(),
492
+ padding.PKCS1v15(),
493
+ hashes.SHA256(),
494
+ )
495
+ ).decode()
496
+ mac = "aa:bb:cc:dd:ee:99"
497
+ payload = {
498
+ "hostname": "interface",
499
+ "address": "127.0.0.1",
500
+ "port": 8443,
501
+ "mac_address": mac,
502
+ "public_key": public_bytes,
503
+ "token": token,
504
+ "signature": signature,
505
+ "role": "Interface",
506
+ }
507
+ response = self.client.post(
508
+ reverse("register-node"),
509
+ data=json.dumps(payload),
510
+ content_type="application/json",
511
+ )
512
+ self.assertEqual(response.status_code, 200)
513
+ data = response.json()
514
+ self.assertIn("uuid", data)
515
+ node = Node.objects.get(mac_address=mac)
516
+ self.assertEqual(node.role.name, "Interface")
517
+
312
518
  def test_register_node_feature_toggle(self):
313
519
  NodeFeature.objects.get_or_create(
314
520
  slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
@@ -319,7 +525,7 @@ class NodeGetLocalTests(TestCase):
319
525
  data={
320
526
  "hostname": "lcd",
321
527
  "address": "127.0.0.1",
322
- "port": 8000,
528
+ "port": 8888,
323
529
  "mac_address": "00:aa:bb:cc:dd:ee",
324
530
  "features": ["clipboard-poll"],
325
531
  },
@@ -334,7 +540,7 @@ class NodeGetLocalTests(TestCase):
334
540
  data={
335
541
  "hostname": "lcd",
336
542
  "address": "127.0.0.1",
337
- "port": 8000,
543
+ "port": 8888,
338
544
  "mac_address": "00:aa:bb:cc:dd:ee",
339
545
  "features": [],
340
546
  },
@@ -501,7 +707,7 @@ class NodeGetLocalTests(TestCase):
501
707
  payload = {
502
708
  "hostname": "cors",
503
709
  "address": "127.0.0.1",
504
- "port": 8000,
710
+ "port": 8888,
505
711
  "mac_address": "10:20:30:40:50:60",
506
712
  }
507
713
  response = self.client.post(
@@ -519,7 +725,7 @@ class NodeGetLocalTests(TestCase):
519
725
  payload = {
520
726
  "hostname": "visitor",
521
727
  "address": "127.0.0.1",
522
- "port": 8000,
728
+ "port": 8888,
523
729
  "mac_address": "aa:bb:cc:dd:ee:00",
524
730
  }
525
731
  response = self.client.post(
@@ -563,7 +769,7 @@ class NodeGetLocalTests(TestCase):
563
769
  payload = {
564
770
  "hostname": "visitor",
565
771
  "address": "127.0.0.1",
566
- "port": 8000,
772
+ "port": 8888,
567
773
  "mac_address": "aa:bb:cc:dd:ee:11",
568
774
  "public_key": public_bytes,
569
775
  "token": token,
@@ -640,6 +846,125 @@ class NodeGetLocalTests(TestCase):
640
846
  self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
641
847
 
642
848
 
849
+ class NodeEnsureKeysTests(TestCase):
850
+ def setUp(self):
851
+ self.tempdir = TemporaryDirectory()
852
+ self.base = Path(self.tempdir.name)
853
+ self.override = override_settings(BASE_DIR=self.base)
854
+ self.override.enable()
855
+ self.node = Node.objects.create(
856
+ hostname="ensure-host",
857
+ address="127.0.0.1",
858
+ port=8888,
859
+ mac_address="00:11:22:33:44:55",
860
+ )
861
+
862
+ def tearDown(self):
863
+ self.override.disable()
864
+ self.tempdir.cleanup()
865
+
866
+ def test_regenerates_missing_keys(self):
867
+ self.node.ensure_keys()
868
+ security_dir = self.base / "security"
869
+ priv_path = security_dir / self.node.public_endpoint
870
+ pub_path = security_dir / f"{self.node.public_endpoint}.pub"
871
+ original_public = self.node.public_key
872
+ priv_path.unlink()
873
+ pub_path.unlink()
874
+
875
+ self.node.ensure_keys()
876
+
877
+ self.assertTrue(priv_path.exists())
878
+ self.assertTrue(pub_path.exists())
879
+ self.assertNotEqual(self.node.public_key, original_public)
880
+
881
+ def test_regenerates_outdated_keys(self):
882
+ self.node.ensure_keys()
883
+ security_dir = self.base / "security"
884
+ priv_path = security_dir / self.node.public_endpoint
885
+ pub_path = security_dir / f"{self.node.public_endpoint}.pub"
886
+ original_private = priv_path.read_bytes()
887
+ original_public = pub_path.read_bytes()
888
+
889
+ old_time = (timezone.now() - timedelta(seconds=5)).timestamp()
890
+ os.utime(priv_path, (old_time, old_time))
891
+ os.utime(pub_path, (old_time, old_time))
892
+
893
+ with override_settings(NODE_KEY_MAX_AGE=timedelta(seconds=1)):
894
+ self.node.ensure_keys()
895
+
896
+ self.node.refresh_from_db()
897
+ self.assertNotEqual(priv_path.read_bytes(), original_private)
898
+ self.assertNotEqual(pub_path.read_bytes(), original_public)
899
+ self.assertNotEqual(self.node.public_key, original_public.decode())
900
+
901
+
902
+ class NodeInfoViewTests(TestCase):
903
+ def setUp(self):
904
+ self.mac = "02:00:00:00:00:01"
905
+ self.patcher = patch("nodes.models.Node.get_current_mac", return_value=self.mac)
906
+ self.patcher.start()
907
+ self.addCleanup(self.patcher.stop)
908
+ self.node = Node.objects.create(
909
+ hostname="local",
910
+ network_hostname="local.example.com",
911
+ address="10.0.0.10",
912
+ ipv4_address="10.0.0.10",
913
+ ipv6_address="2001:db8::10",
914
+ port=8888,
915
+ mac_address=self.mac,
916
+ public_endpoint="local",
917
+ current_relation=Node.Relation.SELF,
918
+ )
919
+ self.url = reverse("node-info")
920
+
921
+ def test_returns_https_port_for_secure_domain_request(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",
927
+ )
928
+ self.assertEqual(response.status_code, 200)
929
+ payload = response.json()
930
+ self.assertEqual(payload["port"], 443)
931
+
932
+ def test_returns_http_port_for_plain_domain_request(self):
933
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
934
+ response = self.client.get(
935
+ self.url,
936
+ HTTP_HOST="arthexis.com",
937
+ )
938
+ self.assertEqual(response.status_code, 200)
939
+ payload = response.json()
940
+ self.assertEqual(payload["port"], 80)
941
+ self.assertEqual(payload.get("network_hostname"), "local.example.com")
942
+ self.assertIn("local.example.com", payload.get("contact_hosts", []))
943
+
944
+ def test_preserves_explicit_port_in_host_header(self):
945
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
946
+ response = self.client.get(
947
+ self.url,
948
+ secure=True,
949
+ HTTP_HOST="arthexis.com:8443",
950
+ )
951
+ self.assertEqual(response.status_code, 200)
952
+ payload = response.json()
953
+ self.assertEqual(payload["port"], 8443)
954
+
955
+ def test_includes_role_in_payload(self):
956
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
957
+ self.node.role = role
958
+ self.node.save(update_fields=["role"])
959
+
960
+ response = self.client.get(self.url)
961
+ self.assertEqual(response.status_code, 200)
962
+ payload = response.json()
963
+ self.assertEqual(payload.get("role"), "Terminal")
964
+ self.assertEqual(payload.get("ipv4_address"), "10.0.0.10")
965
+ self.assertEqual(payload.get("ipv6_address"), "2001:db8::10")
966
+
967
+
643
968
  class RegisterVisitorNodeMessageTests(TestCase):
644
969
  def setUp(self):
645
970
  self.client = Client()
@@ -650,7 +975,7 @@ class RegisterVisitorNodeMessageTests(TestCase):
650
975
  self.visitor = Node.objects.create(
651
976
  hostname="visitor-node",
652
977
  address="10.0.0.100",
653
- port=8000,
978
+ port=8888,
654
979
  mac_address="00:10:20:30:40:50",
655
980
  role=self.role,
656
981
  )
@@ -719,6 +1044,7 @@ class NodeRegisterCurrentTests(TestCase):
719
1044
  patch(
720
1045
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
721
1046
  ),
1047
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
722
1048
  patch("nodes.models.revision.get_revision", return_value="rev"),
723
1049
  patch.object(Node, "ensure_keys"),
724
1050
  patch.object(Node, "notify_peers_of_update") as mock_notify,
@@ -746,6 +1072,7 @@ class NodeRegisterCurrentTests(TestCase):
746
1072
  patch(
747
1073
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
748
1074
  ),
1075
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
749
1076
  patch("nodes.models.revision.get_revision", return_value="rev"),
750
1077
  patch.object(Node, "ensure_keys"),
751
1078
  ):
@@ -764,6 +1091,7 @@ class NodeRegisterCurrentTests(TestCase):
764
1091
  patch(
765
1092
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
766
1093
  ),
1094
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
767
1095
  patch("nodes.models.revision.get_revision", return_value="rev"),
768
1096
  patch.object(Node, "ensure_keys"),
769
1097
  ):
@@ -783,6 +1111,7 @@ class NodeRegisterCurrentTests(TestCase):
783
1111
  patch(
784
1112
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
785
1113
  ),
1114
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
786
1115
  patch("nodes.models.revision.get_revision", return_value="rev"),
787
1116
  patch.object(Node, "ensure_keys"),
788
1117
  ):
@@ -808,17 +1137,27 @@ class NodeRegisterCurrentTests(TestCase):
808
1137
  return_value="00:ff:ee:dd:cc:bb",
809
1138
  ),
810
1139
  patch("nodes.models.socket.gethostname", return_value="localnode"),
1140
+ patch("nodes.models.socket.getfqdn", return_value="localnode.example.com"),
811
1141
  patch(
812
1142
  "nodes.models.socket.gethostbyname",
813
1143
  return_value="192.168.1.5",
814
1144
  ),
1145
+ patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
1146
+ patch.object(
1147
+ Node,
1148
+ "_resolve_ip_addresses",
1149
+ return_value=(
1150
+ ["192.168.1.5", "93.184.216.34"],
1151
+ ["fe80::1", "2001:4860:4860::8888"],
1152
+ ),
1153
+ ),
815
1154
  patch("nodes.models.revision.get_revision", return_value="newrev"),
816
1155
  patch("requests.post") as mock_post,
817
1156
  ):
818
1157
  Node.objects.create(
819
1158
  hostname="localnode",
820
1159
  address="192.168.1.5",
821
- port=8000,
1160
+ port=8888,
822
1161
  mac_address="00:ff:ee:dd:cc:bb",
823
1162
  installed_version="1.9.0",
824
1163
  installed_revision="oldrev",
@@ -835,6 +1174,9 @@ class NodeRegisterCurrentTests(TestCase):
835
1174
  self.assertEqual(payload["hostname"], "localnode")
836
1175
  self.assertEqual(payload["installed_version"], "2.0.0")
837
1176
  self.assertEqual(payload["installed_revision"], "newrev")
1177
+ self.assertEqual(payload.get("network_hostname"), "localnode.example.com")
1178
+ self.assertEqual(payload.get("ipv4_address"), "93.184.216.34")
1179
+ self.assertEqual(payload.get("ipv6_address"), "2001:4860:4860::8888")
838
1180
 
839
1181
  def test_register_current_notifies_peers_without_version_change(self):
840
1182
  Node.objects.create(
@@ -853,17 +1195,27 @@ class NodeRegisterCurrentTests(TestCase):
853
1195
  return_value="00:ff:ee:dd:cc:cc",
854
1196
  ),
855
1197
  patch("nodes.models.socket.gethostname", return_value="samever"),
1198
+ patch("nodes.models.socket.getfqdn", return_value="samever.example.com"),
856
1199
  patch(
857
1200
  "nodes.models.socket.gethostbyname",
858
1201
  return_value="192.168.1.6",
859
1202
  ),
1203
+ patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
1204
+ patch.object(
1205
+ Node,
1206
+ "_resolve_ip_addresses",
1207
+ return_value=(
1208
+ ["192.168.1.6", "93.184.216.35"],
1209
+ ["fe80::2", "2001:4860:4860::8844"],
1210
+ ),
1211
+ ),
860
1212
  patch("nodes.models.revision.get_revision", return_value="rev1"),
861
1213
  patch("requests.post") as mock_post,
862
1214
  ):
863
1215
  Node.objects.create(
864
1216
  hostname="samever",
865
1217
  address="192.168.1.6",
866
- port=8000,
1218
+ port=8888,
867
1219
  mac_address="00:ff:ee:dd:cc:cc",
868
1220
  installed_version="1.0.0",
869
1221
  installed_revision="rev1",
@@ -878,6 +1230,44 @@ class NodeRegisterCurrentTests(TestCase):
878
1230
  payload = json.loads(kwargs["data"])
879
1231
  self.assertEqual(payload["installed_version"], "1.0.0")
880
1232
  self.assertEqual(payload.get("installed_revision"), "rev1")
1233
+ self.assertEqual(payload.get("network_hostname"), "samever.example.com")
1234
+ self.assertEqual(payload.get("ipv4_address"), "93.184.216.35")
1235
+ self.assertEqual(payload.get("ipv6_address"), "2001:4860:4860::8844")
1236
+
1237
+ def test_register_current_populates_network_fields(self):
1238
+ with TemporaryDirectory() as tmp:
1239
+ base = Path(tmp)
1240
+ with override_settings(BASE_DIR=base):
1241
+ with (
1242
+ patch(
1243
+ "nodes.models.Node.get_current_mac",
1244
+ return_value="00:12:34:56:78:90",
1245
+ ),
1246
+ patch("nodes.models.socket.gethostname", return_value="nodehost"),
1247
+ patch("nodes.models.socket.getfqdn", return_value="nodehost.example.com"),
1248
+ patch(
1249
+ "nodes.models.socket.gethostbyname",
1250
+ return_value="10.0.0.5",
1251
+ ),
1252
+ patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
1253
+ patch.object(
1254
+ Node,
1255
+ "_resolve_ip_addresses",
1256
+ return_value=(
1257
+ ["10.0.0.5", "93.184.216.36"],
1258
+ ["fe80::5", "2001:4860:4860::1"],
1259
+ ),
1260
+ ),
1261
+ patch("nodes.models.revision.get_revision", return_value="revX"),
1262
+ patch.object(Node, "ensure_keys"),
1263
+ patch.object(Node, "notify_peers_of_update"),
1264
+ ):
1265
+ node, created = Node.register_current()
1266
+ self.assertTrue(created)
1267
+ self.assertEqual(node.network_hostname, "nodehost.example.com")
1268
+ self.assertEqual(node.ipv4_address, "93.184.216.36")
1269
+ self.assertEqual(node.ipv6_address, "2001:4860:4860::1")
1270
+ self.assertEqual(node.address, "93.184.216.36")
881
1271
 
882
1272
  @patch("nodes.views.capture_screenshot")
883
1273
  def test_capture_screenshot(self, mock_capture):
@@ -1005,7 +1395,7 @@ class NodeRegisterCurrentTests(TestCase):
1005
1395
  sender = Node.objects.create(
1006
1396
  hostname="sender",
1007
1397
  address="10.0.0.1",
1008
- port=8000,
1398
+ port=8888,
1009
1399
  mac_address="00:11:22:33:44:cc",
1010
1400
  public_key=public_key,
1011
1401
  )
@@ -1236,6 +1626,7 @@ class NodeRegisterCurrentTests(TestCase):
1236
1626
  existing_role.refresh_from_db()
1237
1627
  self.assertEqual(existing_role.description, "updated via attachment")
1238
1628
 
1629
+ @pytest.mark.feature("clipboard-poll")
1239
1630
  def test_clipboard_polling_creates_task(self):
1240
1631
  feature, _ = NodeFeature.objects.get_or_create(
1241
1632
  slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
@@ -1246,13 +1637,17 @@ class NodeRegisterCurrentTests(TestCase):
1246
1637
  port=9000,
1247
1638
  mac_address="00:11:22:33:44:99",
1248
1639
  )
1249
- task_name = f"poll_clipboard_node_{node.pk}"
1250
- PeriodicTask.objects.filter(name=task_name).delete()
1640
+ raw_name = f"poll_clipboard_node_{node.pk}"
1641
+ task_name = slugify_task_name(raw_name)
1642
+ PeriodicTask.objects.filter(
1643
+ name__in=periodic_task_name_variants(raw_name)
1644
+ ).delete()
1251
1645
  NodeFeatureAssignment.objects.create(node=node, feature=feature)
1252
1646
  self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
1253
1647
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
1254
1648
  self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
1255
1649
 
1650
+ @pytest.mark.feature("screenshot-poll")
1256
1651
  def test_screenshot_polling_creates_task(self):
1257
1652
  feature, _ = NodeFeature.objects.get_or_create(
1258
1653
  slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
@@ -1263,8 +1658,11 @@ class NodeRegisterCurrentTests(TestCase):
1263
1658
  port=9100,
1264
1659
  mac_address="00:11:22:33:44:aa",
1265
1660
  )
1266
- task_name = f"capture_screenshot_node_{node.pk}"
1267
- PeriodicTask.objects.filter(name=task_name).delete()
1661
+ raw_name = f"capture_screenshot_node_{node.pk}"
1662
+ task_name = slugify_task_name(raw_name)
1663
+ PeriodicTask.objects.filter(
1664
+ name__in=periodic_task_name_variants(raw_name)
1665
+ ).delete()
1268
1666
  NodeFeatureAssignment.objects.create(node=node, feature=feature)
1269
1667
  self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
1270
1668
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
@@ -1283,14 +1681,18 @@ class NodeRegisterCurrentTests(TestCase):
1283
1681
  "base_path": settings.BASE_DIR,
1284
1682
  },
1285
1683
  )
1286
- PeriodicTask.objects.filter(name="pages_purge_landing_leads").delete()
1684
+ raw_name = "pages_purge_landing_leads"
1685
+ task_name = slugify_task_name(raw_name)
1686
+ PeriodicTask.objects.filter(
1687
+ name__in=periodic_task_name_variants(raw_name)
1688
+ ).delete()
1287
1689
  NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1288
1690
  self.assertTrue(
1289
- PeriodicTask.objects.filter(name="pages_purge_landing_leads").exists()
1691
+ PeriodicTask.objects.filter(name=task_name).exists()
1290
1692
  )
1291
1693
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
1292
1694
  self.assertFalse(
1293
- PeriodicTask.objects.filter(name="pages_purge_landing_leads").exists()
1695
+ PeriodicTask.objects.filter(name=task_name).exists()
1294
1696
  )
1295
1697
 
1296
1698
  def test_ocpp_session_report_task_syncs_with_feature(self):
@@ -1306,8 +1708,11 @@ class NodeRegisterCurrentTests(TestCase):
1306
1708
  "base_path": settings.BASE_DIR,
1307
1709
  },
1308
1710
  )
1309
- task_name = "ocpp_send_daily_session_report"
1310
- PeriodicTask.objects.filter(name=task_name).delete()
1711
+ raw_name = "ocpp_send_daily_session_report"
1712
+ task_name = slugify_task_name(raw_name)
1713
+ PeriodicTask.objects.filter(
1714
+ name__in=periodic_task_name_variants(raw_name)
1715
+ ).delete()
1311
1716
 
1312
1717
  with patch("nodes.models.mailer.can_send_email", return_value=True):
1313
1718
  NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
@@ -1330,8 +1735,11 @@ class NodeRegisterCurrentTests(TestCase):
1330
1735
  "base_path": settings.BASE_DIR,
1331
1736
  },
1332
1737
  )
1333
- task_name = "ocpp_send_daily_session_report"
1334
- PeriodicTask.objects.filter(name=task_name).delete()
1738
+ raw_name = "ocpp_send_daily_session_report"
1739
+ task_name = slugify_task_name(raw_name)
1740
+ PeriodicTask.objects.filter(
1741
+ name__in=periodic_task_name_variants(raw_name)
1742
+ ).delete()
1335
1743
 
1336
1744
  with patch("nodes.models.mailer.can_send_email", return_value=False):
1337
1745
  NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
@@ -1367,7 +1775,7 @@ class NodeAdminTests(TestCase):
1367
1775
  return Node.objects.create(
1368
1776
  hostname="localnode",
1369
1777
  address="127.0.0.1",
1370
- port=8000,
1778
+ port=8888,
1371
1779
  mac_address=Node.get_current_mac(),
1372
1780
  )
1373
1781
 
@@ -1381,6 +1789,7 @@ class NodeAdminTests(TestCase):
1381
1789
  action_url = reverse("admin:core_rfid_scan")
1382
1790
  self.assertContains(response, f'href="{action_url}"')
1383
1791
 
1792
+ @pytest.mark.feature("rpi-camera")
1384
1793
  def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
1385
1794
  node = self._create_local_node()
1386
1795
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1393,6 +1802,7 @@ class NodeAdminTests(TestCase):
1393
1802
  self.assertContains(response, f'href="{snapshot_url}"')
1394
1803
  self.assertContains(response, f'href="{stream_url}"')
1395
1804
 
1805
+ @pytest.mark.feature("audio-capture")
1396
1806
  def test_node_feature_list_shows_waveform_action_when_enabled(self):
1397
1807
  node = self._create_local_node()
1398
1808
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1403,6 +1813,7 @@ class NodeAdminTests(TestCase):
1403
1813
  action_url = reverse("admin:nodes_nodefeature_view_waveform")
1404
1814
  self.assertContains(response, f'href="{action_url}"')
1405
1815
 
1816
+ @pytest.mark.feature("screenshot-poll")
1406
1817
  def test_node_feature_list_hides_default_action_when_disabled(self):
1407
1818
  self._create_local_node()
1408
1819
  NodeFeature.objects.get_or_create(
@@ -1439,7 +1850,7 @@ class NodeAdminTests(TestCase):
1439
1850
  Node.objects.create(
1440
1851
  hostname=hostname,
1441
1852
  address="127.0.0.1",
1442
- port=8000,
1853
+ port=8888,
1443
1854
  mac_address=None,
1444
1855
  )
1445
1856
 
@@ -1495,6 +1906,129 @@ class NodeAdminTests(TestCase):
1495
1906
  response, reverse("admin:nodes_node_register_current")
1496
1907
  )
1497
1908
 
1909
+ def test_apply_remote_node_info_updates_role(self):
1910
+ terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
1911
+ control, _ = NodeRole.objects.get_or_create(name="Control")
1912
+ node = Node.objects.create(
1913
+ hostname="remote-node",
1914
+ address="10.0.0.20",
1915
+ port=8001,
1916
+ mac_address="00:11:22:33:44:aa",
1917
+ role=terminal,
1918
+ )
1919
+ admin_instance = NodeAdmin(Node, admin.site)
1920
+
1921
+ payload = {
1922
+ "hostname": node.hostname,
1923
+ "network_hostname": "remote-node.example.com",
1924
+ "address": node.address,
1925
+ "ipv4_address": "198.51.100.10",
1926
+ "ipv6_address": "2001:db8::10",
1927
+ "port": node.port,
1928
+ "role": "Control",
1929
+ }
1930
+
1931
+ changed = admin_instance._apply_remote_node_info(node, payload)
1932
+ node.refresh_from_db()
1933
+
1934
+ self.assertIn("role", changed)
1935
+ self.assertIn("network_hostname", changed)
1936
+ self.assertIn("ipv4_address", changed)
1937
+ self.assertIn("ipv6_address", changed)
1938
+ self.assertEqual(node.role, control)
1939
+ self.assertEqual(node.network_hostname, "remote-node.example.com")
1940
+ self.assertEqual(node.ipv4_address, "198.51.100.10")
1941
+ self.assertEqual(node.ipv6_address, "2001:db8::10")
1942
+ self.assertTrue(control.node_set.filter(pk=node.pk).exists())
1943
+ self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
1944
+
1945
+ def test_apply_remote_node_info_accepts_role_name_key(self):
1946
+ terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
1947
+ control, _ = NodeRole.objects.get_or_create(name="Control")
1948
+ node = Node.objects.create(
1949
+ hostname="role-name-node",
1950
+ address="10.0.0.21",
1951
+ port=8002,
1952
+ mac_address="00:11:22:33:44:bb",
1953
+ role=terminal,
1954
+ )
1955
+ admin_instance = NodeAdmin(Node, admin.site)
1956
+
1957
+ payload = {
1958
+ "hostname": node.hostname,
1959
+ "network_hostname": "role-name-node.example.com",
1960
+ "address": node.address,
1961
+ "ipv4_address": "198.51.100.11",
1962
+ "ipv6_address": "2001:db8::11",
1963
+ "port": node.port,
1964
+ "role_name": "Control",
1965
+ }
1966
+
1967
+ changed = admin_instance._apply_remote_node_info(node, payload)
1968
+ node.refresh_from_db()
1969
+
1970
+ self.assertIn("role", changed)
1971
+ self.assertEqual(node.network_hostname, "role-name-node.example.com")
1972
+ self.assertEqual(node.ipv4_address, "198.51.100.11")
1973
+ self.assertEqual(node.ipv6_address, "2001:db8::11")
1974
+ self.assertEqual(node.role, control)
1975
+ self.assertTrue(control.node_set.filter(pk=node.pk).exists())
1976
+ self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
1977
+
1978
+ @patch("nodes.admin.requests.post")
1979
+ def test_send_forwarding_metadata_retries_until_success(self, mock_post):
1980
+ request = RequestFactory().get("/")
1981
+ local_node = Node.objects.create(
1982
+ hostname="local",
1983
+ address="127.0.0.1",
1984
+ port=8888,
1985
+ mac_address="00:11:22:33:44:aa",
1986
+ public_key="LOCAL-PUB",
1987
+ )
1988
+ class DummyNode:
1989
+ def __init__(self):
1990
+ self.port = 8443
1991
+ self.urls: list[str] = []
1992
+
1993
+ def iter_remote_urls(self, path: str):
1994
+ self.urls.append(path)
1995
+ yield "https://unreachable.example"
1996
+ yield "https://reachable.example"
1997
+
1998
+ def __str__(self): # pragma: no cover - trivial representation
1999
+ return "dummy-node"
2000
+
2001
+ target = DummyNode()
2002
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
2003
+ charger = Charger.objects.create(charger_id="FORWARD-1")
2004
+
2005
+ failure = MagicMock()
2006
+ failure.ok = False
2007
+ failure.status_code = 404
2008
+ failure.json.return_value = {"detail": "not found"}
2009
+ success = MagicMock()
2010
+ success.ok = True
2011
+ success.status_code = 200
2012
+ success.json.return_value = {"status": "ok"}
2013
+
2014
+ mock_post.side_effect = [failure, success]
2015
+
2016
+ admin_instance = NodeAdmin(Node, admin.site)
2017
+
2018
+ result = admin_instance._send_forwarding_metadata(
2019
+ request, target, [charger], local_node, private_key
2020
+ )
2021
+
2022
+ self.assertTrue(result)
2023
+ self.assertEqual(
2024
+ target.urls, ["/nodes/network/chargers/forward/"]
2025
+ )
2026
+ self.assertEqual(mock_post.call_count, 2)
2027
+ self.assertEqual(
2028
+ mock_post.call_args_list[1].args[0], "https://reachable.example"
2029
+ )
2030
+
2031
+ @pytest.mark.feature("screenshot-poll")
1498
2032
  @patch("nodes.admin.capture_screenshot")
1499
2033
  def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
1500
2034
  screenshot_dir = settings.LOG_DIR / "screenshots"
@@ -1540,6 +2074,53 @@ class NodeAdminTests(TestCase):
1540
2074
  self.assertEqual(response.status_code, 200)
1541
2075
  self.assertContains(response, "data:image/png;base64")
1542
2076
 
2077
+ def test_visit_link_uses_local_admin_dashboard_for_local_node(self):
2078
+ node_admin = admin.site._registry[Node]
2079
+ local_node = self._create_local_node()
2080
+
2081
+ link_html = node_admin.visit_link(local_node)
2082
+
2083
+ self.assertIn(reverse("admin:index"), link_html)
2084
+ self.assertIn("target=\"_blank\"", link_html)
2085
+
2086
+ def test_visit_link_prefers_remote_hostname_for_dashboard(self):
2087
+ node_admin = admin.site._registry[Node]
2088
+ remote = Node.objects.create(
2089
+ hostname="remote.example.com",
2090
+ address="198.51.100.20",
2091
+ port=8443,
2092
+ mac_address="aa:bb:cc:dd:ee:ff",
2093
+ )
2094
+
2095
+ link_html = node_admin.visit_link(remote)
2096
+
2097
+ self.assertIn("https://remote.example.com:8443/admin/", link_html)
2098
+ self.assertIn("target=\"_blank\"", link_html)
2099
+
2100
+ def test_iter_remote_urls_handles_hostname_with_path_and_port(self):
2101
+ node_admin = admin.site._registry[Node]
2102
+ remote = SimpleNamespace(
2103
+ public_endpoint="",
2104
+ address="",
2105
+ hostname="example.com/interface",
2106
+ port=8443,
2107
+ )
2108
+
2109
+ urls = list(node_admin._iter_remote_urls(remote, "/nodes/info/"))
2110
+
2111
+ self.assertIn(
2112
+ "https://example.com:8443/interface/nodes/info/",
2113
+ urls,
2114
+ )
2115
+ self.assertIn(
2116
+ "http://example.com:8443/interface/nodes/info/",
2117
+ urls,
2118
+ )
2119
+ combined = "".join(urls)
2120
+ self.assertNotIn("interface:8443", combined)
2121
+
2122
+
2123
+ @pytest.mark.feature("screenshot-poll")
1543
2124
  @override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
1544
2125
  @patch("nodes.admin.capture_screenshot")
1545
2126
  def test_take_screenshots_action(self, mock_capture):
@@ -1572,6 +2153,7 @@ class NodeAdminTests(TestCase):
1572
2153
  samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
1573
2154
  self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
1574
2155
 
2156
+ @pytest.mark.feature("screenshot-poll")
1575
2157
  @patch("nodes.admin.capture_screenshot")
1576
2158
  def test_take_screenshot_default_action_creates_sample(
1577
2159
  self, mock_capture_screenshot
@@ -1646,6 +2228,35 @@ class NodeAdminTests(TestCase):
1646
2228
  response, "Completed 0 of 1 feature check(s) successfully.", html=False
1647
2229
  )
1648
2230
 
2231
+ @pytest.mark.feature("audio-capture")
2232
+ @patch("nodes.models.Node._has_audio_capture_device", return_value=False)
2233
+ def test_check_features_for_eligibility_audio_capture_requires_device(
2234
+ self, mock_device
2235
+ ):
2236
+ self._create_local_node()
2237
+ feature, _ = NodeFeature.objects.get_or_create(
2238
+ slug="audio-capture", defaults={"display": "Audio Capture"}
2239
+ )
2240
+ changelist_url = reverse("admin:nodes_nodefeature_changelist")
2241
+ response = self.client.post(
2242
+ changelist_url,
2243
+ {
2244
+ "action": "check_features_for_eligibility",
2245
+ "_selected_action": [str(feature.pk)],
2246
+ },
2247
+ follow=True,
2248
+ )
2249
+ self.assertEqual(response.status_code, 200)
2250
+ self.assertContains(
2251
+ response,
2252
+ "No audio recording device detected on localnode for Audio Capture. This feature can be enabled manually.",
2253
+ html=False,
2254
+ )
2255
+ self.assertContains(
2256
+ response, "Completed 0 of 1 feature check(s) successfully.", html=False
2257
+ )
2258
+
2259
+ @pytest.mark.feature("screenshot-poll")
1649
2260
  def test_enable_selected_features_enables_manual_feature(self):
1650
2261
  node = self._create_local_node()
1651
2262
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1690,6 +2301,7 @@ class NodeAdminTests(TestCase):
1690
2301
  html=False,
1691
2302
  )
1692
2303
 
2304
+ @pytest.mark.feature("screenshot-poll")
1693
2305
  def test_take_screenshot_default_action_requires_enabled_feature(self):
1694
2306
  self._create_local_node()
1695
2307
  NodeFeature.objects.get_or_create(
@@ -1704,6 +2316,7 @@ class NodeAdminTests(TestCase):
1704
2316
  self.assertEqual(ContentSample.objects.count(), 0)
1705
2317
  self.assertContains(response, "Screenshot Poll feature is not enabled")
1706
2318
 
2319
+ @pytest.mark.feature("rpi-camera")
1707
2320
  @patch("nodes.admin.capture_rpi_snapshot")
1708
2321
  def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
1709
2322
  node = self._create_local_node()
@@ -1726,6 +2339,7 @@ class NodeAdminTests(TestCase):
1726
2339
  change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
1727
2340
  self.assertEqual(response.redirect_chain[-1][0], change_url)
1728
2341
 
2342
+ @pytest.mark.feature("rpi-camera")
1729
2343
  def test_view_stream_requires_enabled_feature(self):
1730
2344
  self._create_local_node()
1731
2345
  NodeFeature.objects.get_or_create(
@@ -1741,6 +2355,7 @@ class NodeAdminTests(TestCase):
1741
2355
  response, "Raspberry Pi Camera feature is not enabled on this node."
1742
2356
  )
1743
2357
 
2358
+ @pytest.mark.feature("rpi-camera")
1744
2359
  def test_view_stream_renders_when_feature_enabled(self):
1745
2360
  node = self._create_local_node()
1746
2361
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1756,6 +2371,7 @@ class NodeAdminTests(TestCase):
1756
2371
  self.assertContains(response, expected_stream)
1757
2372
  self.assertContains(response, "camera-stream__frame")
1758
2373
 
2374
+ @pytest.mark.feature("rpi-camera")
1759
2375
  def test_view_stream_uses_configured_stream_url(self):
1760
2376
  node = self._create_local_node()
1761
2377
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1773,6 +2389,7 @@ class NodeAdminTests(TestCase):
1773
2389
  self.assertEqual(response.context_data["stream_embed"], "iframe")
1774
2390
  self.assertContains(response, configured_stream)
1775
2391
 
2392
+ @pytest.mark.feature("rpi-camera")
1776
2393
  def test_view_stream_detects_mjpeg_stream(self):
1777
2394
  node = self._create_local_node()
1778
2395
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1789,6 +2406,7 @@ class NodeAdminTests(TestCase):
1789
2406
  self.assertEqual(response.context_data["stream_embed"], "mjpeg")
1790
2407
  self.assertContains(response, "<img", html=False)
1791
2408
 
2409
+ @pytest.mark.feature("rpi-camera")
1792
2410
  def test_view_stream_marks_rtsp_stream_as_unsupported(self):
1793
2411
  node = self._create_local_node()
1794
2412
  feature, _ = NodeFeature.objects.get_or_create(
@@ -1805,6 +2423,7 @@ class NodeAdminTests(TestCase):
1805
2423
  self.assertEqual(response.context_data["stream_embed"], "unsupported")
1806
2424
  self.assertContains(response, "camera-stream__unsupported")
1807
2425
 
2426
+ @pytest.mark.feature("audio-capture")
1808
2427
  def test_view_waveform_requires_enabled_feature(self):
1809
2428
  self._create_local_node()
1810
2429
  NodeFeature.objects.get_or_create(
@@ -1820,6 +2439,7 @@ class NodeAdminTests(TestCase):
1820
2439
  response, "Audio Capture feature is not enabled on this node."
1821
2440
  )
1822
2441
 
2442
+ @pytest.mark.feature("audio-capture")
1823
2443
  def test_view_waveform_renders_when_feature_enabled(self):
1824
2444
  node = self._create_local_node()
1825
2445
  feature, _ = NodeFeature.objects.get_or_create(
@@ -2072,11 +2692,58 @@ class NodeAdminTests(TestCase):
2072
2692
  )
2073
2693
  self.assertContains(response, str(remote))
2074
2694
 
2075
- @patch("nodes.admin.requests.post")
2076
- @patch("nodes.admin.requests.get")
2077
- def test_update_selected_nodes_progress_updates_remote(
2078
- self, mock_get, mock_post
2079
- ):
2695
+ def test_send_net_message_action_displays_form(self):
2696
+ target = Node.objects.create(
2697
+ hostname="remote-one", address="10.0.0.10", port=8020
2698
+ )
2699
+ response = self.client.post(
2700
+ reverse("admin:nodes_node_changelist"),
2701
+ {
2702
+ "action": "send_net_message",
2703
+ helpers.ACTION_CHECKBOX_NAME: [str(target.pk)],
2704
+ },
2705
+ follow=False,
2706
+ )
2707
+ self.assertEqual(response.status_code, 200)
2708
+ response.render()
2709
+ self.assertContains(response, "Send Net Message")
2710
+ self.assertContains(response, str(target))
2711
+ self.assertContains(response, 'name="apply"')
2712
+ self.assertContains(response, "Selected node (1)")
2713
+
2714
+ @patch("nodes.admin.NetMessage.propagate")
2715
+ def test_send_net_message_action_creates_messages(self, mock_propagate):
2716
+ first = Node.objects.create(
2717
+ hostname="remote-two", address="10.0.0.11", port=8021
2718
+ )
2719
+ second = Node.objects.create(
2720
+ hostname="remote-three", address="10.0.0.12", port=8022
2721
+ )
2722
+ url = reverse("admin:nodes_node_changelist")
2723
+ payload = {
2724
+ "action": "send_net_message",
2725
+ "apply": "1",
2726
+ helpers.ACTION_CHECKBOX_NAME: [str(first.pk), str(second.pk)],
2727
+ "subject": "Maintenance",
2728
+ "body": "We will reboot tonight.",
2729
+ }
2730
+ existing_ids = set(NetMessage.objects.values_list("pk", flat=True))
2731
+ response = self.client.post(url, payload, follow=True)
2732
+ self.assertEqual(response.status_code, 200)
2733
+ new_messages = NetMessage.objects.exclude(pk__in=existing_ids)
2734
+ self.assertEqual(new_messages.count(), 2)
2735
+ self.assertEqual(mock_propagate.call_count, 2)
2736
+ for node in (first, second):
2737
+ message = new_messages.get(filter_node=node)
2738
+ self.assertEqual(message.subject, "Maintenance")
2739
+ self.assertEqual(message.body, "We will reboot tonight.")
2740
+ self.assertContains(response, "Sent 2 net messages.")
2741
+
2742
+ @patch("nodes.admin.requests.post")
2743
+ @patch("nodes.admin.requests.get")
2744
+ def test_update_selected_nodes_progress_updates_remote(
2745
+ self, mock_get, mock_post
2746
+ ):
2080
2747
  local = self._create_local_node()
2081
2748
  local.public_endpoint = "localnode"
2082
2749
  key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
@@ -2142,6 +2809,186 @@ class NodeAdminTests(TestCase):
2142
2809
  self.assertEqual(post_data["mac_address"], local.mac_address)
2143
2810
 
2144
2811
 
2812
+ class NodeProxyGatewayTests(TestCase):
2813
+ def setUp(self):
2814
+ cache.clear()
2815
+ self.client = Client()
2816
+ self.private_key = rsa.generate_private_key(
2817
+ public_exponent=65537, key_size=2048
2818
+ )
2819
+ public_key = self.private_key.public_key().public_bytes(
2820
+ encoding=serialization.Encoding.PEM,
2821
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
2822
+ ).decode()
2823
+ self.node = Node.objects.create(
2824
+ hostname="requester",
2825
+ address="127.0.0.1",
2826
+ port=8888,
2827
+ mac_address="aa:bb:cc:dd:ee:aa",
2828
+ public_key=public_key,
2829
+ )
2830
+ patcher = patch("requests.post")
2831
+ self.addCleanup(patcher.stop)
2832
+ self.mock_requests_post = patcher.start()
2833
+ self.mock_requests_post.return_value = SimpleNamespace(
2834
+ ok=True,
2835
+ status_code=200,
2836
+ json=lambda: {},
2837
+ text="",
2838
+ )
2839
+
2840
+ def tearDown(self):
2841
+ cache.clear()
2842
+
2843
+ def _sign(self, payload):
2844
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
2845
+ signature = base64.b64encode(
2846
+ self.private_key.sign(
2847
+ body.encode(), padding.PKCS1v15(), hashes.SHA256()
2848
+ )
2849
+ ).decode()
2850
+ return body, signature
2851
+
2852
+ def test_proxy_session_creates_login_url(self):
2853
+ payload = {
2854
+ "requester": str(self.node.uuid),
2855
+ "user": {
2856
+ "username": "proxy-user",
2857
+ "email": "proxy@example.com",
2858
+ "first_name": "Proxy",
2859
+ "last_name": "User",
2860
+ "is_staff": True,
2861
+ "is_superuser": True,
2862
+ "groups": [],
2863
+ "permissions": [],
2864
+ },
2865
+ "target": "/admin/",
2866
+ }
2867
+ body, signature = self._sign(payload)
2868
+ response = self.client.post(
2869
+ reverse("node-proxy-session"),
2870
+ data=body,
2871
+ content_type="application/json",
2872
+ HTTP_X_SIGNATURE=signature,
2873
+ )
2874
+ self.assertEqual(response.status_code, 200)
2875
+ data = response.json()
2876
+ self.assertIn("login_url", data)
2877
+ user = get_user_model().objects.get(username="proxy-user")
2878
+ self.assertTrue(user.is_staff)
2879
+ parsed = urlparse(data["login_url"])
2880
+ login_response = self.client.get(parsed.path)
2881
+ self.assertEqual(login_response.status_code, 302)
2882
+ self.assertEqual(login_response["Location"], "/admin/")
2883
+ self.assertEqual(self.client.session.get("_auth_user_id"), str(user.pk))
2884
+ second = self.client.get(parsed.path)
2885
+ self.assertEqual(second.status_code, 410)
2886
+
2887
+ def test_proxy_session_accepts_mac_hint_when_uuid_unknown(self):
2888
+ payload = {
2889
+ "requester": str(uuid.uuid4()),
2890
+ "requester_mac": self.node.mac_address,
2891
+ "requester_public_key": self.node.public_key,
2892
+ "user": {
2893
+ "username": "proxy-user",
2894
+ "email": "proxy@example.com",
2895
+ "first_name": "Proxy",
2896
+ "last_name": "User",
2897
+ "is_staff": True,
2898
+ "is_superuser": True,
2899
+ "groups": [],
2900
+ "permissions": [],
2901
+ },
2902
+ "target": "/admin/",
2903
+ }
2904
+ body, signature = self._sign(payload)
2905
+ response = self.client.post(
2906
+ reverse("node-proxy-session"),
2907
+ data=body,
2908
+ content_type="application/json",
2909
+ HTTP_X_SIGNATURE=signature,
2910
+ )
2911
+ self.assertEqual(response.status_code, 200)
2912
+
2913
+ def test_proxy_execute_lists_nodes(self):
2914
+ Node.objects.create(
2915
+ hostname="target",
2916
+ address="127.0.0.5",
2917
+ port=8010,
2918
+ mac_address="aa:bb:cc:dd:ee:bb",
2919
+ )
2920
+ payload = {
2921
+ "requester": str(self.node.uuid),
2922
+ "action": "list",
2923
+ "model": "nodes.Node",
2924
+ "filters": {"hostname": "target"},
2925
+ "credentials": {
2926
+ "username": "suite-user",
2927
+ "password": "secret",
2928
+ "first_name": "Suite",
2929
+ "last_name": "User",
2930
+ },
2931
+ }
2932
+ body, signature = self._sign(payload)
2933
+ response = self.client.post(
2934
+ reverse("node-proxy-execute"),
2935
+ data=body,
2936
+ content_type="application/json",
2937
+ HTTP_X_SIGNATURE=signature,
2938
+ )
2939
+ self.assertEqual(response.status_code, 200)
2940
+ data = response.json()
2941
+ self.assertEqual(len(data.get("objects", [])), 1)
2942
+ record = data["objects"][0]
2943
+ self.assertEqual(record["fields"]["hostname"], "target")
2944
+ user = get_user_model().objects.get(username="suite-user")
2945
+ self.assertTrue(user.is_superuser)
2946
+
2947
+ def test_proxy_execute_requires_valid_password_for_existing_user(self):
2948
+ User = get_user_model()
2949
+ User.objects.create_user(username="suite-user", password="correct")
2950
+ payload = {
2951
+ "requester": str(self.node.uuid),
2952
+ "action": "list",
2953
+ "model": "nodes.Node",
2954
+ "credentials": {
2955
+ "username": "suite-user",
2956
+ "password": "wrong",
2957
+ },
2958
+ }
2959
+ body, signature = self._sign(payload)
2960
+ response = self.client.post(
2961
+ reverse("node-proxy-execute"),
2962
+ data=body,
2963
+ content_type="application/json",
2964
+ HTTP_X_SIGNATURE=signature,
2965
+ )
2966
+ self.assertEqual(response.status_code, 403)
2967
+
2968
+ def test_proxy_execute_schema_returns_models(self):
2969
+ payload = {
2970
+ "requester": str(self.node.uuid),
2971
+ "action": "schema",
2972
+ "credentials": {
2973
+ "username": "suite-user",
2974
+ "password": "secret",
2975
+ },
2976
+ }
2977
+ body, signature = self._sign(payload)
2978
+ response = self.client.post(
2979
+ reverse("node-proxy-execute"),
2980
+ data=body,
2981
+ content_type="application/json",
2982
+ HTTP_X_SIGNATURE=signature,
2983
+ )
2984
+ self.assertEqual(response.status_code, 200)
2985
+ data = response.json()
2986
+ models = data.get("models", [])
2987
+ self.assertTrue(models)
2988
+ suite_names = {entry.get("suite_name") for entry in models}
2989
+ self.assertIn("Nodes", suite_names)
2990
+
2991
+
2145
2992
  class NodeRFIDAPITests(TestCase):
2146
2993
  def test_import_endpoint_applies_payload_without_creating_accounts(self):
2147
2994
  remote = Node.objects.create(
@@ -2201,7 +3048,7 @@ class RFIDExportViewTests(TestCase):
2201
3048
  self.local_node = Node.objects.create(
2202
3049
  hostname="local",
2203
3050
  address="127.0.0.1",
2204
- port=8000,
3051
+ port=8888,
2205
3052
  mac_address=Node.get_current_mac(),
2206
3053
  )
2207
3054
  self.remote_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
@@ -2315,42 +3162,19 @@ class NetMessageAdminTests(TransactionTestCase):
2315
3162
  self.assertEqual(form["subject"].value(), "Re: Ping")
2316
3163
  self.assertEqual(str(form["filter_node"].value()), str(node.pk))
2317
3164
 
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
3165
  class NetMessageReachTests(TestCase):
2342
3166
  def setUp(self):
2343
3167
  self.roles = {}
2344
- for name in ["Terminal", "Control", "Satellite", "Constellation"]:
3168
+ for name in ["Terminal", "Control", "Satellite", "Watchtower"]:
2345
3169
  self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
2346
3170
  self.nodes = {}
2347
3171
  for idx, name in enumerate(
2348
- ["Terminal", "Control", "Satellite", "Constellation"], start=1
3172
+ ["Terminal", "Control", "Satellite", "Watchtower"], start=1
2349
3173
  ):
2350
3174
  self.nodes[name] = Node.objects.create(
2351
3175
  hostname=name.lower(),
2352
3176
  address=f"10.0.0.{idx}",
2353
- port=8000 + idx,
3177
+ port=8888 + idx,
2354
3178
  mac_address=f"00:11:22:33:44:{idx:02x}",
2355
3179
  role=self.roles[name],
2356
3180
  )
@@ -2389,15 +3213,15 @@ class NetMessageReachTests(TestCase):
2389
3213
  self.assertEqual(mock_post.call_count, 3)
2390
3214
 
2391
3215
  @patch("requests.post")
2392
- def test_constellation_reach_prioritizes_constellation(self, mock_post):
3216
+ def test_watchtower_reach_prioritizes_watchtower(self, mock_post):
2393
3217
  msg = NetMessage.objects.create(
2394
- subject="s", body="b", reach=self.roles["Constellation"]
3218
+ subject="s", body="b", reach=self.roles["Watchtower"]
2395
3219
  )
2396
3220
  with patch.object(Node, "get_local", return_value=None):
2397
3221
  msg.propagate()
2398
3222
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
2399
3223
  self.assertEqual(
2400
- roles, {"Constellation", "Satellite", "Control", "Terminal"}
3224
+ roles, {"Watchtower", "Satellite", "Control", "Terminal"}
2401
3225
  )
2402
3226
  self.assertEqual(mock_post.call_count, 4)
2403
3227
 
@@ -2561,7 +3385,7 @@ class NetMessagePropagationTests(TestCase):
2561
3385
  Node.objects.create(
2562
3386
  hostname=f"n{idx}",
2563
3387
  address=f"10.0.0.{idx}",
2564
- port=8000 + idx,
3388
+ port=8888 + idx,
2565
3389
  mac_address=f"00:11:22:33:44:{idx:02x}",
2566
3390
  role=self.role,
2567
3391
  public_endpoint=f"n{idx}",
@@ -2572,7 +3396,7 @@ class NetMessagePropagationTests(TestCase):
2572
3396
  with patch.object(Node, "get_local", return_value=self.local):
2573
3397
  msg = NetMessage.broadcast(subject="subject", body="body")
2574
3398
  self.assertEqual(msg.node_origin, self.local)
2575
- self.assertIsNone(msg.reach)
3399
+ self.assertEqual(msg.reach, self.role)
2576
3400
 
2577
3401
  @patch("requests.post")
2578
3402
  @patch("core.notifications.notify")
@@ -2597,13 +3421,6 @@ class NetMessagePropagationTests(TestCase):
2597
3421
  self.assertNotIn(sender_addr, targets)
2598
3422
  self.assertEqual(msg.propagated_to.count(), 4)
2599
3423
  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
3424
 
2608
3425
  @patch("requests.post")
2609
3426
  @patch("core.notifications.notify", return_value=False)
@@ -2615,7 +3432,7 @@ class NetMessagePropagationTests(TestCase):
2615
3432
  Node.objects.create(
2616
3433
  hostname=f"n{idx}",
2617
3434
  address=f"10.0.0.{idx}",
2618
- port=8000 + idx,
3435
+ port=8888 + idx,
2619
3436
  mac_address=f"00:11:22:33:44:{idx:02x}",
2620
3437
  role=self.role,
2621
3438
  public_endpoint=f"n{idx}",
@@ -2689,10 +3506,240 @@ class NetMessagePropagationTests(TestCase):
2689
3506
  ):
2690
3507
  msg.propagate()
2691
3508
 
2692
- self.assertTrue(msg.confirmed_peers)
2693
- self.assertTrue(
2694
- all(entry["status"] == "error" for entry in msg.confirmed_peers.values())
3509
+ self.assertEqual(msg.propagated_to.count(), len(self.remotes))
3510
+ self.assertTrue(msg.complete)
3511
+
3512
+
3513
+ class NetMessageQueueTests(TestCase):
3514
+ def setUp(self):
3515
+ self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
3516
+ self.feature, _ = NodeFeature.objects.get_or_create(
3517
+ slug="celery-queue", defaults={"display": "Celery Queue"}
3518
+ )
3519
+
3520
+ def test_propagate_queues_unreachable_downstream(self):
3521
+ local = Node.objects.create(
3522
+ hostname="local",
3523
+ address="10.0.0.1",
3524
+ port=8888,
3525
+ mac_address="00:11:22:33:44:10",
3526
+ role=self.role,
3527
+ public_endpoint="local",
3528
+ )
3529
+ downstream = Node.objects.create(
3530
+ hostname="downstream",
3531
+ address="10.0.0.2",
3532
+ port=8001,
3533
+ mac_address="00:11:22:33:44:11",
3534
+ role=self.role,
3535
+ current_relation=Node.Relation.DOWNSTREAM,
3536
+ )
3537
+ message = NetMessage.objects.create(subject="Queued", body="Body", reach=self.role)
3538
+ with patch.object(Node, "get_local", return_value=local), patch.object(
3539
+ Node, "get_private_key", return_value=None
3540
+ ), patch("core.notifications.notify", return_value=False), patch(
3541
+ "requests.post", side_effect=Exception("fail")
3542
+ ):
3543
+ message.propagate()
3544
+
3545
+ entry = PendingNetMessage.objects.get(node=downstream, message=message)
3546
+ self.assertIn(str(downstream.uuid), entry.seen)
3547
+ self.assertGreater(entry.stale_at, timezone.now())
3548
+
3549
+ def test_queue_limit_enforced(self):
3550
+ downstream = Node.objects.create(
3551
+ hostname="limit",
3552
+ address="10.0.0.3",
3553
+ port=8002,
3554
+ mac_address="00:11:22:33:44:12",
3555
+ role=self.role,
3556
+ current_relation=Node.Relation.DOWNSTREAM,
3557
+ message_queue_length=1,
3558
+ )
3559
+ msg1 = NetMessage.objects.create(subject="Old", body="One", reach=self.role)
3560
+ msg2 = NetMessage.objects.create(subject="New", body="Two", reach=self.role)
3561
+
3562
+ msg1.queue_for_node(downstream, [str(downstream.uuid)])
3563
+ msg2.queue_for_node(downstream, [str(downstream.uuid)])
3564
+
3565
+ entries = list(PendingNetMessage.objects.filter(node=downstream))
3566
+ self.assertEqual(len(entries), 1)
3567
+ self.assertEqual(entries[0].message, msg2)
3568
+
3569
+ def test_queue_duplicate_updates_stale(self):
3570
+ downstream = Node.objects.create(
3571
+ hostname="dup",
3572
+ address="10.0.0.4",
3573
+ port=8003,
3574
+ mac_address="00:11:22:33:44:13",
3575
+ role=self.role,
3576
+ current_relation=Node.Relation.DOWNSTREAM,
2695
3577
  )
3578
+ message = NetMessage.objects.create(subject="Dup", body="Dup", reach=self.role)
3579
+ first = timezone.now()
3580
+ second = first + timedelta(minutes=5)
3581
+ with patch(
3582
+ "nodes.models.timezone.now", side_effect=[first, second, second]
3583
+ ):
3584
+ message.queue_for_node(downstream, ["first"])
3585
+ message.queue_for_node(downstream, ["second"])
3586
+
3587
+ entry = PendingNetMessage.objects.get(node=downstream, message=message)
3588
+ self.assertEqual(entry.seen, ["second"])
3589
+ self.assertEqual(entry.queued_at, second)
3590
+ self.assertEqual(entry.stale_at, second + timedelta(hours=1))
3591
+
3592
+ def test_pull_endpoint_returns_and_clears_messages(self):
3593
+ local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3594
+ downstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3595
+ local = Node.objects.create(
3596
+ hostname="hub",
3597
+ address="10.0.0.5",
3598
+ port=8004,
3599
+ mac_address="00:11:22:33:44:14",
3600
+ role=self.role,
3601
+ public_endpoint="hub",
3602
+ )
3603
+ downstream = Node.objects.create(
3604
+ hostname="remote",
3605
+ address="10.0.0.6",
3606
+ port=8005,
3607
+ mac_address="00:11:22:33:44:15",
3608
+ role=self.role,
3609
+ current_relation=Node.Relation.DOWNSTREAM,
3610
+ public_key=downstream_key.public_key()
3611
+ .public_bytes(
3612
+ serialization.Encoding.PEM,
3613
+ serialization.PublicFormat.SubjectPublicKeyInfo,
3614
+ )
3615
+ .decode(),
3616
+ )
3617
+ message = NetMessage.objects.create(subject="Fresh", body="Body", reach=self.role)
3618
+ stale_message = NetMessage.objects.create(subject="Stale", body="Body", reach=self.role)
3619
+ now = timezone.now()
3620
+ PendingNetMessage.objects.create(
3621
+ node=downstream,
3622
+ message=message,
3623
+ seen=[str(downstream.uuid)],
3624
+ stale_at=now + timedelta(minutes=30),
3625
+ )
3626
+ stale_entry = PendingNetMessage.objects.create(
3627
+ node=downstream,
3628
+ message=stale_message,
3629
+ seen=["stale"],
3630
+ stale_at=now - timedelta(minutes=5),
3631
+ )
3632
+ PendingNetMessage.objects.filter(pk=stale_entry.pk).update(
3633
+ queued_at=now - timedelta(minutes=5)
3634
+ )
3635
+
3636
+ def fake_get_private(node_obj):
3637
+ if node_obj.pk == local.pk:
3638
+ return local_key
3639
+ return None
3640
+
3641
+ payload = {"requester": str(downstream.uuid)}
3642
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
3643
+ signature = base64.b64encode(
3644
+ downstream_key.sign(
3645
+ body.encode(),
3646
+ padding.PKCS1v15(),
3647
+ hashes.SHA256(),
3648
+ )
3649
+ ).decode()
3650
+
3651
+ with patch.object(Node, "get_local", return_value=local), patch.object(
3652
+ Node, "get_private_key", return_value=local_key
3653
+ ):
3654
+ response = self.client.post(
3655
+ reverse("net-message-pull"),
3656
+ data=body,
3657
+ content_type="application/json",
3658
+ HTTP_X_SIGNATURE=signature,
3659
+ )
3660
+
3661
+ self.assertEqual(response.status_code, 200)
3662
+ data = response.json()
3663
+ self.assertEqual(len(data.get("messages", [])), 1)
3664
+ payload_data = data["messages"][0]["payload"]
3665
+ self.assertEqual(payload_data["uuid"], str(message.uuid))
3666
+ self.assertFalse(
3667
+ PendingNetMessage.objects.filter(node=downstream, message=message).exists()
3668
+ )
3669
+ self.assertFalse(
3670
+ PendingNetMessage.objects.filter(
3671
+ node=downstream, message=stale_message
3672
+ ).exists()
3673
+ )
3674
+ response_signature = data["messages"][0]["signature"]
3675
+ local_public = local_key.public_key()
3676
+ local_public.verify(
3677
+ base64.b64decode(response_signature),
3678
+ json.dumps(payload_data, separators=(",", ":"), sort_keys=True).encode(),
3679
+ padding.PKCS1v15(),
3680
+ hashes.SHA256(),
3681
+ )
3682
+
3683
+ def test_poll_task_fetches_messages(self):
3684
+ local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3685
+ upstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3686
+ local = Node.objects.create(
3687
+ hostname="downstream",
3688
+ address="10.0.0.7",
3689
+ port=8006,
3690
+ mac_address="00:11:22:33:44:16",
3691
+ role=self.role,
3692
+ public_endpoint="downstream",
3693
+ )
3694
+ upstream = Node.objects.create(
3695
+ hostname="upstream",
3696
+ address="127.0.0.2",
3697
+ port=8010,
3698
+ mac_address="00:11:22:33:44:17",
3699
+ role=self.role,
3700
+ current_relation=Node.Relation.UPSTREAM,
3701
+ public_key=upstream_key.public_key()
3702
+ .public_bytes(
3703
+ serialization.Encoding.PEM,
3704
+ serialization.PublicFormat.SubjectPublicKeyInfo,
3705
+ )
3706
+ .decode(),
3707
+ )
3708
+ NodeFeatureAssignment.objects.create(node=local, feature=self.feature)
3709
+ payload = {
3710
+ "uuid": str(uuid.uuid4()),
3711
+ "subject": "Update",
3712
+ "body": "Body",
3713
+ "seen": [str(local.uuid)],
3714
+ "origin": str(upstream.uuid),
3715
+ "sender": str(upstream.uuid),
3716
+ }
3717
+ payload_text = json.dumps(payload, separators=(",", ":"), sort_keys=True)
3718
+ payload_signature = base64.b64encode(
3719
+ upstream_key.sign(
3720
+ payload_text.encode(),
3721
+ padding.PKCS1v15(),
3722
+ hashes.SHA256(),
3723
+ )
3724
+ ).decode()
3725
+ response = MagicMock()
3726
+ response.ok = True
3727
+ response.json.return_value = {
3728
+ "messages": [{"payload": payload, "signature": payload_signature}]
3729
+ }
3730
+
3731
+ with patch.object(Node, "get_local", return_value=local), patch.object(
3732
+ Node, "get_private_key", return_value=local_key
3733
+ ), patch("nodes.tasks.requests.post", return_value=response) as mock_post, patch.object(
3734
+ NetMessage, "propagate"
3735
+ ) as mock_propagate:
3736
+ poll_unreachable_upstream()
3737
+
3738
+ created = NetMessage.objects.get(uuid=payload["uuid"])
3739
+ self.assertEqual(created.subject, "Update")
3740
+ self.assertEqual(created.node_origin, upstream)
3741
+ mock_post.assert_called_once()
3742
+ mock_propagate.assert_called_once()
2696
3743
 
2697
3744
 
2698
3745
  class NetMessageSignatureTests(TestCase):
@@ -2765,6 +3812,82 @@ class NetMessageSignatureTests(TestCase):
2765
3812
  self.assertTrue(signature_one)
2766
3813
  self.assertTrue(signature_two)
2767
3814
  self.assertNotEqual(signature_one, signature_two)
3815
+
3816
+
3817
+ class NetworkChargerActionSecurityTests(TestCase):
3818
+ def setUp(self):
3819
+ self.client = Client()
3820
+ self.local_node = Node.objects.create(
3821
+ hostname="local-node",
3822
+ address="127.0.0.1",
3823
+ port=8888,
3824
+ mac_address="00:aa:bb:cc:dd:10",
3825
+ public_endpoint="local-endpoint",
3826
+ )
3827
+ self.authorized_node = Node.objects.create(
3828
+ hostname="authorized-node",
3829
+ address="127.0.0.2",
3830
+ port=8001,
3831
+ mac_address="00:aa:bb:cc:dd:11",
3832
+ public_endpoint="authorized-endpoint",
3833
+ )
3834
+ self.unauthorized_node, self.unauthorized_key = self._create_signed_node(
3835
+ "unauthorized-node",
3836
+ mac_suffix=0x12,
3837
+ )
3838
+ self.charger = Charger.objects.create(
3839
+ charger_id="SECURE-TEST-1",
3840
+ allow_remote=True,
3841
+ manager_node=self.authorized_node,
3842
+ node_origin=self.local_node,
3843
+ )
3844
+
3845
+ def _create_signed_node(self, hostname: str, *, mac_suffix: int):
3846
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3847
+ public_bytes = key.public_key().public_bytes(
3848
+ encoding=serialization.Encoding.PEM,
3849
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
3850
+ )
3851
+ node = Node.objects.create(
3852
+ hostname=hostname,
3853
+ address="10.0.0.{:d}".format(mac_suffix),
3854
+ port=8020,
3855
+ mac_address="00:aa:bb:cc:dd:{:02x}".format(mac_suffix),
3856
+ public_key=public_bytes.decode(),
3857
+ public_endpoint=f"{hostname}-endpoint",
3858
+ )
3859
+ return node, key
3860
+
3861
+ def test_rejects_requests_from_unmanaged_nodes(self):
3862
+ url = reverse("node-network-charger-action")
3863
+ payload = {
3864
+ "requester": str(self.unauthorized_node.uuid),
3865
+ "charger_id": self.charger.charger_id,
3866
+ "action": "reset",
3867
+ }
3868
+ body = json.dumps(payload).encode()
3869
+ signature = self.unauthorized_key.sign(
3870
+ body,
3871
+ padding.PKCS1v15(),
3872
+ hashes.SHA256(),
3873
+ )
3874
+ headers = {"HTTP_X_SIGNATURE": base64.b64encode(signature).decode()}
3875
+
3876
+ with patch.object(Node, "get_local", return_value=self.local_node):
3877
+ response = self.client.post(
3878
+ url,
3879
+ data=body,
3880
+ content_type="application/json",
3881
+ **headers,
3882
+ )
3883
+
3884
+ self.assertEqual(response.status_code, 403)
3885
+ self.assertEqual(
3886
+ response.json().get("detail"),
3887
+ "requester does not manage this charger",
3888
+ )
3889
+
3890
+
2768
3891
  class StartupNotificationTests(TestCase):
2769
3892
  def test_startup_notification_uses_hostname_and_revision(self):
2770
3893
  from nodes.apps import _startup_notification
@@ -2848,6 +3971,17 @@ class StartupHandlerTests(TestCase):
2848
3971
 
2849
3972
  mock_start.assert_called_once()
2850
3973
 
3974
+ def test_handler_skips_during_migrate_command(self):
3975
+ import sys
3976
+
3977
+ from nodes.apps import _trigger_startup_notification
3978
+
3979
+ with patch("nodes.apps._startup_notification") as mock_start:
3980
+ with patch.object(sys, "argv", ["manage.py", "migrate"]):
3981
+ _trigger_startup_notification()
3982
+
3983
+ mock_start.assert_not_called()
3984
+
2851
3985
 
2852
3986
  class NotificationManagerTests(TestCase):
2853
3987
  def test_send_writes_trimmed_lines(self):
@@ -2930,6 +4064,7 @@ class ContentSampleTransactionTests(TestCase):
2930
4064
  sample1.save()
2931
4065
 
2932
4066
 
4067
+ @pytest.mark.feature("clipboard-poll")
2933
4068
  class ContentSampleAdminTests(TestCase):
2934
4069
  def setUp(self):
2935
4070
  User = get_user_model()
@@ -2958,7 +4093,7 @@ class ContentSampleAdminTests(TestCase):
2958
4093
  Node.objects.create(
2959
4094
  hostname="host",
2960
4095
  address="127.0.0.1",
2961
- port=8000,
4096
+ port=8888,
2962
4097
  mac_address=Node.get_current_mac(),
2963
4098
  )
2964
4099
  url = reverse("admin:nodes_contentsample_from_clipboard")
@@ -2984,7 +4119,7 @@ class EmailOutboxTests(TestCase):
2984
4119
  node = Node.objects.create(
2985
4120
  hostname="outboxhost",
2986
4121
  address="127.0.0.1",
2987
- port=8000,
4122
+ port=8888,
2988
4123
  mac_address="00:11:22:33:aa:bb",
2989
4124
  )
2990
4125
  outbox = EmailOutbox.objects.create(
@@ -3000,7 +4135,7 @@ class EmailOutboxTests(TestCase):
3000
4135
  node = Node.objects.create(
3001
4136
  hostname="host",
3002
4137
  address="127.0.0.1",
3003
- port=8000,
4138
+ port=8888,
3004
4139
  mac_address="00:11:22:33:cc:dd",
3005
4140
  )
3006
4141
  node.send_mail("sub", "msg", ["to@example.com"])
@@ -3106,13 +4241,14 @@ class EmailOutboxTests(TestCase):
3106
4241
 
3107
4242
 
3108
4243
  class ClipboardTaskTests(TestCase):
4244
+ @pytest.mark.feature("clipboard-poll")
3109
4245
  @patch("nodes.tasks.pyperclip.paste")
3110
4246
  def test_sample_clipboard_task_creates_sample(self, mock_paste):
3111
4247
  mock_paste.return_value = "task text"
3112
4248
  Node.objects.create(
3113
4249
  hostname="host",
3114
4250
  address="127.0.0.1",
3115
- port=8000,
4251
+ port=8888,
3116
4252
  mac_address=Node.get_current_mac(),
3117
4253
  )
3118
4254
  sample_clipboard()
@@ -3130,12 +4266,13 @@ class ClipboardTaskTests(TestCase):
3130
4266
  ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
3131
4267
  )
3132
4268
 
4269
+ @pytest.mark.feature("screenshot-poll")
3133
4270
  @patch("nodes.tasks.capture_screenshot")
3134
4271
  def test_capture_node_screenshot_task(self, mock_capture):
3135
4272
  node = Node.objects.create(
3136
4273
  hostname="host",
3137
4274
  address="127.0.0.1",
3138
- port=8000,
4275
+ port=8888,
3139
4276
  mac_address=Node.get_current_mac(),
3140
4277
  )
3141
4278
  screenshot_dir = settings.LOG_DIR / "screenshots"
@@ -3152,12 +4289,13 @@ class ClipboardTaskTests(TestCase):
3152
4289
  self.assertEqual(screenshot.path, "screenshots/test.png")
3153
4290
  self.assertEqual(screenshot.method, "TASK")
3154
4291
 
4292
+ @pytest.mark.feature("screenshot-poll")
3155
4293
  @patch("nodes.tasks.capture_screenshot")
3156
4294
  def test_capture_node_screenshot_handles_error(self, mock_capture):
3157
4295
  Node.objects.create(
3158
4296
  hostname="host",
3159
4297
  address="127.0.0.1",
3160
- port=8000,
4298
+ port=8888,
3161
4299
  mac_address=Node.get_current_mac(),
3162
4300
  )
3163
4301
  mock_capture.side_effect = RuntimeError("boom")
@@ -3169,6 +4307,17 @@ class ClipboardTaskTests(TestCase):
3169
4307
 
3170
4308
 
3171
4309
  class CaptureScreenshotTests(TestCase):
4310
+ def setUp(self):
4311
+ super().setUp()
4312
+ self.firefox_patcher = patch(
4313
+ "nodes.utils._find_firefox_binary", return_value="/usr/bin/firefox"
4314
+ )
4315
+ self.ensure_geckodriver_patcher = patch("nodes.utils._ensure_geckodriver")
4316
+ self.firefox_patcher.start()
4317
+ self.ensure_geckodriver_patcher.start()
4318
+ self.addCleanup(self.firefox_patcher.stop)
4319
+ self.addCleanup(self.ensure_geckodriver_patcher.stop)
4320
+
3172
4321
  @patch("nodes.utils.webdriver.Firefox")
3173
4322
  def test_connection_failure_does_not_raise(self, mock_firefox):
3174
4323
  browser = MagicMock()
@@ -3181,6 +4330,19 @@ class CaptureScreenshotTests(TestCase):
3181
4330
  self.assertEqual(result.parent, screenshot_dir)
3182
4331
  browser.save_screenshot.assert_called_once()
3183
4332
 
4333
+ def test_missing_firefox_reports_clear_error(self):
4334
+ with patch("nodes.utils._find_firefox_binary", return_value=None):
4335
+ with self.assertRaises(RuntimeError) as excinfo:
4336
+ capture_screenshot("http://example.com")
4337
+ self.assertIn("Firefox is not installed", str(excinfo.exception))
4338
+
4339
+ @patch("nodes.utils.webdriver.Firefox")
4340
+ def test_driver_install_hint_on_failure(self, mock_firefox):
4341
+ mock_firefox.side_effect = WebDriverException("Unable to obtain driver for firefox")
4342
+ with self.assertRaises(RuntimeError) as excinfo:
4343
+ capture_screenshot("http://example.com")
4344
+ self.assertIn("Firefox WebDriver is unavailable", str(excinfo.exception))
4345
+
3184
4346
 
3185
4347
  class NodeRoleAdminTests(TestCase):
3186
4348
  def setUp(self):
@@ -3195,14 +4357,14 @@ class NodeRoleAdminTests(TestCase):
3195
4357
  node1 = Node.objects.create(
3196
4358
  hostname="n1",
3197
4359
  address="127.0.0.1",
3198
- port=8000,
4360
+ port=8888,
3199
4361
  mac_address="00:11:22:33:44:55",
3200
4362
  role=role,
3201
4363
  )
3202
4364
  node2 = Node.objects.create(
3203
4365
  hostname="n2",
3204
4366
  address="127.0.0.2",
3205
- port=8000,
4367
+ port=8888,
3206
4368
  mac_address="00:11:22:33:44:66",
3207
4369
  )
3208
4370
  url = reverse("admin:nodes_noderole_change", args=[role.pk])
@@ -3221,7 +4383,7 @@ class NodeRoleAdminTests(TestCase):
3221
4383
  Node.objects.create(
3222
4384
  hostname="n1",
3223
4385
  address="127.0.0.1",
3224
- port=8000,
4386
+ port=8888,
3225
4387
  mac_address="00:11:22:33:44:77",
3226
4388
  role=role,
3227
4389
  )
@@ -3231,7 +4393,7 @@ class NodeRoleAdminTests(TestCase):
3231
4393
 
3232
4394
  class NodeFeatureFixtureTests(TestCase):
3233
4395
  def test_rfid_scanner_fixture_includes_control_role(self):
3234
- for name in ("Terminal", "Satellite", "Constellation", "Control"):
4396
+ for name in ("Terminal", "Satellite", "Watchtower", "Control"):
3235
4397
  NodeRole.objects.get_or_create(name=name)
3236
4398
  fixture_path = (
3237
4399
  Path(__file__).resolve().parent
@@ -3243,6 +4405,7 @@ class NodeFeatureFixtureTests(TestCase):
3243
4405
  role_names = set(feature.roles.values_list("name", flat=True))
3244
4406
  self.assertIn("Control", role_names)
3245
4407
 
4408
+ @pytest.mark.feature("ap-router")
3246
4409
  def test_ap_router_fixture_limits_roles(self):
3247
4410
  for name in ("Control", "Satellite"):
3248
4411
  NodeRole.objects.get_or_create(name=name)
@@ -3256,6 +4419,23 @@ class NodeFeatureFixtureTests(TestCase):
3256
4419
  role_names = set(feature.roles.values_list("name", flat=True))
3257
4420
  self.assertEqual(role_names, {"Satellite"})
3258
4421
 
4422
+ @pytest.mark.feature("graphql")
4423
+ def test_graphql_fixture_excludes_terminal_role(self):
4424
+ for name in ("Control", "Interface", "Satellite", "Terminal", "Watchtower"):
4425
+ NodeRole.objects.get_or_create(name=name)
4426
+ fixture_path = (
4427
+ Path(__file__).resolve().parent
4428
+ / "fixtures"
4429
+ / "node_features__nodefeature_graphql.json"
4430
+ )
4431
+ call_command("loaddata", str(fixture_path), verbosity=0)
4432
+ feature = NodeFeature.objects.get(slug="graphql")
4433
+ role_names = set(feature.roles.values_list("name", flat=True))
4434
+ self.assertEqual(
4435
+ role_names,
4436
+ {"Control", "Interface", "Satellite", "Watchtower"},
4437
+ )
4438
+
3259
4439
 
3260
4440
  class NodeFeatureTests(TestCase):
3261
4441
  def setUp(self):
@@ -3266,7 +4446,7 @@ class NodeFeatureTests(TestCase):
3266
4446
  self.node = Node.objects.create(
3267
4447
  hostname="local",
3268
4448
  address="127.0.0.1",
3269
- port=8000,
4449
+ port=8888,
3270
4450
  mac_address="00:11:22:33:44:55",
3271
4451
  role=self.role,
3272
4452
  )
@@ -3293,6 +4473,7 @@ class NodeFeatureTests(TestCase):
3293
4473
  self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
3294
4474
  self.assertEqual(feature.get_default_action(), action)
3295
4475
 
4476
+ @pytest.mark.feature("rpi-camera")
3296
4477
  def test_rpi_camera_feature_has_multiple_actions(self):
3297
4478
  feature = NodeFeature.objects.create(
3298
4479
  slug="rpi-camera", display="Raspberry Pi Camera"
@@ -3303,6 +4484,7 @@ class NodeFeatureTests(TestCase):
3303
4484
  self.assertIn("Take a Snapshot", labels)
3304
4485
  self.assertIn("View stream", labels)
3305
4486
 
4487
+ @pytest.mark.feature("audio-capture")
3306
4488
  def test_audio_capture_feature_has_view_waveform_action(self):
3307
4489
  feature = NodeFeature.objects.create(
3308
4490
  slug="audio-capture", display="Audio Capture"
@@ -3388,17 +4570,6 @@ class NodeFeatureTests(TestCase):
3388
4570
  ):
3389
4571
  self.assertTrue(feature.is_enabled)
3390
4572
 
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
4573
  @patch("nodes.models.Node._has_rpi_camera", return_value=True)
3403
4574
  def test_rpi_camera_detection(self, mock_camera):
3404
4575
  feature = NodeFeature.objects.create(
@@ -3437,49 +4608,7 @@ class NodeFeatureTests(TestCase):
3437
4608
  ).exists()
3438
4609
  )
3439
4610
 
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
-
4611
+ @pytest.mark.feature("ap-router")
3483
4612
  @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
3484
4613
  def test_ap_router_detection(self, mock_hosts):
3485
4614
  control_role, _ = NodeRole.objects.get_or_create(name="Control")
@@ -3490,7 +4619,7 @@ class NodeFeatureTests(TestCase):
3490
4619
  node = Node.objects.create(
3491
4620
  hostname="control",
3492
4621
  address="127.0.0.1",
3493
- port=8000,
4622
+ port=8888,
3494
4623
  mac_address=mac,
3495
4624
  role=control_role,
3496
4625
  )
@@ -3499,6 +4628,7 @@ class NodeFeatureTests(TestCase):
3499
4628
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
3500
4629
  )
3501
4630
 
4631
+ @pytest.mark.feature("ap-router")
3502
4632
  @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
3503
4633
  def test_ap_router_detection_with_public_mode_lock(self, mock_hosts):
3504
4634
  control_role, _ = NodeRole.objects.get_or_create(name="Control")
@@ -3513,7 +4643,7 @@ class NodeFeatureTests(TestCase):
3513
4643
  node = Node.objects.create(
3514
4644
  hostname="control",
3515
4645
  address="127.0.0.1",
3516
- port=8000,
4646
+ port=8888,
3517
4647
  mac_address=mac,
3518
4648
  role=control_role,
3519
4649
  base_path=str(Path(tmp)),
@@ -3523,6 +4653,7 @@ class NodeFeatureTests(TestCase):
3523
4653
  NodeFeatureAssignment.objects.filter(node=node, feature=router).exists()
3524
4654
  )
3525
4655
 
4656
+ @pytest.mark.feature("ap-router")
3526
4657
  @patch("nodes.models.Node._hosts_gelectriic_ap", side_effect=[True, False])
3527
4658
  def test_ap_router_removed_when_not_hosting(self, mock_hosts):
3528
4659
  control_role, _ = NodeRole.objects.get_or_create(name="Control")
@@ -3533,7 +4664,7 @@ class NodeFeatureTests(TestCase):
3533
4664
  node = Node.objects.create(
3534
4665
  hostname="control",
3535
4666
  address="127.0.0.1",
3536
- port=8000,
4667
+ port=8888,
3537
4668
  mac_address=mac,
3538
4669
  role=control_role,
3539
4670
  )
@@ -3548,6 +4679,72 @@ class NodeFeatureTests(TestCase):
3548
4679
  )
3549
4680
 
3550
4681
 
4682
+ class AudioCaptureDetectionTests(TestCase):
4683
+ def test_has_audio_capture_device_true(self):
4684
+ with TemporaryDirectory() as tmp:
4685
+ pcm_path = Path(tmp) / "pcm"
4686
+ pcm_path.write_text(
4687
+ "00-00: USB Audio : USB Audio : playback 1 : capture 1\n",
4688
+ encoding="utf-8",
4689
+ )
4690
+ with patch.object(Node, "AUDIO_CAPTURE_PCM_PATH", pcm_path):
4691
+ self.assertTrue(Node._has_audio_capture_device())
4692
+
4693
+ def test_has_audio_capture_device_false_without_capture(self):
4694
+ with TemporaryDirectory() as tmp:
4695
+ pcm_path = Path(tmp) / "pcm"
4696
+ pcm_path.write_text(
4697
+ "00-00: USB Audio : USB Audio : playback 1\n",
4698
+ encoding="utf-8",
4699
+ )
4700
+ with patch.object(Node, "AUDIO_CAPTURE_PCM_PATH", pcm_path):
4701
+ self.assertFalse(Node._has_audio_capture_device())
4702
+
4703
+ def test_has_audio_capture_device_false_when_file_missing(self):
4704
+ with TemporaryDirectory() as tmp:
4705
+ pcm_path = Path(tmp) / "pcm"
4706
+ with patch.object(Node, "AUDIO_CAPTURE_PCM_PATH", pcm_path):
4707
+ self.assertFalse(Node._has_audio_capture_device())
4708
+
4709
+
4710
+ class AudioCaptureFeatureCheckTests(TestCase):
4711
+ def setUp(self):
4712
+ self.node = Node.objects.create(
4713
+ hostname="localnode",
4714
+ address="127.0.0.1",
4715
+ port=8888,
4716
+ mac_address=Node.get_current_mac(),
4717
+ )
4718
+ self.feature, _ = NodeFeature.objects.get_or_create(
4719
+ slug="audio-capture", defaults={"display": "Audio Capture"}
4720
+ )
4721
+
4722
+ @pytest.mark.feature("audio-capture")
4723
+ @patch("nodes.models.Node._has_audio_capture_device", return_value=False)
4724
+ def test_feature_check_warns_without_device(self, mock_device):
4725
+ result = feature_checks.run(self.feature, node=self.node)
4726
+ self.assertFalse(result.success)
4727
+ self.assertIn("No audio recording device detected", result.message)
4728
+ self.assertEqual(result.level, messages.WARNING)
4729
+
4730
+ @pytest.mark.feature("audio-capture")
4731
+ @patch("nodes.models.Node._has_audio_capture_device", return_value=True)
4732
+ def test_feature_check_warns_when_feature_disabled(self, mock_device):
4733
+ result = feature_checks.run(self.feature, node=self.node)
4734
+ self.assertFalse(result.success)
4735
+ self.assertIn("is not enabled", result.message)
4736
+ self.assertEqual(result.level, messages.WARNING)
4737
+
4738
+ @pytest.mark.feature("audio-capture")
4739
+ @patch("nodes.models.Node._has_audio_capture_device", return_value=True)
4740
+ def test_feature_check_passes_when_enabled(self, mock_device):
4741
+ NodeFeatureAssignment.objects.get_or_create(node=self.node, feature=self.feature)
4742
+ result = feature_checks.run(self.feature, node=self.node)
4743
+ self.assertTrue(result.success)
4744
+ self.assertIn("recording device is available", result.message)
4745
+ self.assertEqual(result.level, messages.SUCCESS)
4746
+
4747
+
3551
4748
  class CeleryReportAdminViewTests(TestCase):
3552
4749
  def setUp(self):
3553
4750
  User = get_user_model()
@@ -3895,6 +5092,38 @@ class ContentClassifierTests(TestCase):
3895
5092
  tags = ContentClassification.objects.filter(sample=sample)
3896
5093
  self.assertTrue(tags.filter(tag__slug="screenshot-tag").exists())
3897
5094
 
5095
+ def test_save_screenshot_returns_none_for_duplicate_without_linking(self):
5096
+ with TemporaryDirectory() as tmp:
5097
+ base = Path(tmp)
5098
+ first_path = base / "capture.png"
5099
+ first_path.write_bytes(b"binary image data")
5100
+ duplicate_path = base / "duplicate.png"
5101
+ duplicate_path.write_bytes(b"binary image data")
5102
+ with override_settings(LOG_DIR=base):
5103
+ original = save_screenshot(first_path, method="TEST")
5104
+ duplicate = save_screenshot(duplicate_path, method="TEST")
5105
+
5106
+ self.assertIsNotNone(original)
5107
+ self.assertIsNone(duplicate)
5108
+ self.assertEqual(ContentSample.objects.count(), 1)
5109
+
5110
+ def test_save_screenshot_reuses_existing_sample_when_linking(self):
5111
+ with TemporaryDirectory() as tmp:
5112
+ base = Path(tmp)
5113
+ first_path = base / "capture.png"
5114
+ first_path.write_bytes(b"binary image data")
5115
+ duplicate_path = base / "duplicate.png"
5116
+ duplicate_path.write_bytes(b"binary image data")
5117
+ with override_settings(LOG_DIR=base):
5118
+ original = save_screenshot(first_path, method="TEST")
5119
+ reused = save_screenshot(
5120
+ duplicate_path, method="TEST", link_duplicates=True
5121
+ )
5122
+
5123
+ self.assertIsNotNone(original)
5124
+ self.assertEqual(reused, original)
5125
+ self.assertEqual(ContentSample.objects.count(), 1)
5126
+
3898
5127
  def test_text_sample_runs_default_classifiers_without_duplicates(self):
3899
5128
  sample = ContentSample.objects.create(
3900
5129
  content="Example content", kind=ContentSample.TEXT