arthexis 0.1.8__py3-none-any.whl → 0.1.10__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 (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
nodes/tests.py CHANGED
@@ -6,14 +6,20 @@ import django
6
6
  django.setup()
7
7
 
8
8
  from pathlib import Path
9
+ from types import SimpleNamespace
9
10
  from unittest.mock import patch, call, MagicMock
11
+ from django.core import mail
12
+ from django.core.mail import EmailMessage
13
+ from django.core.management import call_command
10
14
  import socket
11
15
  import base64
12
16
  import json
13
17
  import uuid
14
18
  from tempfile import TemporaryDirectory
15
19
  import shutil
20
+ import stat
16
21
  import time
22
+ from datetime import timedelta
17
23
 
18
24
  from django.test import Client, TestCase, TransactionTestCase, override_settings
19
25
  from django.urls import reverse
@@ -22,6 +28,7 @@ from django.contrib import admin
22
28
  from django.contrib.sites.models import Site
23
29
  from django_celery_beat.models import PeriodicTask
24
30
  from django.conf import settings
31
+ from django.utils import timezone
25
32
  from .actions import NodeAction
26
33
  from selenium.common.exceptions import WebDriverException
27
34
  from .utils import capture_screenshot
@@ -31,23 +38,22 @@ from .models import (
31
38
  EmailOutbox,
32
39
  ContentSample,
33
40
  NodeRole,
41
+ NodeFeature,
42
+ NodeFeatureAssignment,
34
43
  NetMessage,
35
44
  )
45
+ from .backends import OutboxEmailBackend
36
46
  from .tasks import capture_node_screenshot, sample_clipboard
37
47
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
38
48
  from cryptography.hazmat.primitives import serialization, hashes
39
- from core.models import PackageRelease
40
- from .models import Operation
41
- from .admin import RUN_CONTEXTS
49
+ from core.models import PackageRelease, SecurityGroup
42
50
 
43
51
 
44
52
  class NodeTests(TestCase):
45
53
  def setUp(self):
46
54
  self.client = Client()
47
55
  User = get_user_model()
48
- self.user = User.objects.create_user(
49
- username="nodeuser", password="pwd"
50
- )
56
+ self.user = User.objects.create_user(username="nodeuser", password="pwd")
51
57
  self.client.force_login(self.user)
52
58
  NodeRole.objects.get_or_create(name="Terminal")
53
59
 
@@ -55,16 +61,18 @@ class NodeTests(TestCase):
55
61
  with TemporaryDirectory() as tmp:
56
62
  base = Path(tmp)
57
63
  with override_settings(BASE_DIR=base):
58
- with patch(
59
- "nodes.models.Node.get_current_mac",
60
- return_value="00:ff:ee:dd:cc:bb",
61
- ), patch(
62
- "nodes.models.socket.gethostname", return_value="testhost"
63
- ), patch(
64
- "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
65
- ), patch(
66
- "nodes.models.revision.get_revision", return_value="rev"
67
- ), patch.object(Node, "ensure_keys"):
64
+ with (
65
+ patch(
66
+ "nodes.models.Node.get_current_mac",
67
+ return_value="00:ff:ee:dd:cc:bb",
68
+ ),
69
+ patch("nodes.models.socket.gethostname", return_value="testhost"),
70
+ patch(
71
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
72
+ ),
73
+ patch("nodes.models.revision.get_revision", return_value="rev"),
74
+ patch.object(Node, "ensure_keys"),
75
+ ):
68
76
  Node.register_current()
69
77
  self.assertEqual(PackageRelease.objects.count(), 0)
70
78
 
@@ -117,7 +125,10 @@ class NodeTests(TestCase):
117
125
  hostnames = {n["hostname"] for n in data["nodes"]}
118
126
  self.assertEqual(hostnames, {"dup", "local2"})
119
127
 
120
- def test_register_node_has_lcd_screen_toggle(self):
128
+ def test_register_node_feature_toggle(self):
129
+ NodeFeature.objects.get_or_create(
130
+ slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
131
+ )
121
132
  url = reverse("register-node")
122
133
  first = self.client.post(
123
134
  url,
@@ -126,13 +137,13 @@ class NodeTests(TestCase):
126
137
  "address": "127.0.0.1",
127
138
  "port": 8000,
128
139
  "mac_address": "00:aa:bb:cc:dd:ee",
129
- "has_lcd_screen": True,
140
+ "features": ["clipboard-poll"],
130
141
  },
131
142
  content_type="application/json",
132
143
  )
133
144
  self.assertEqual(first.status_code, 200)
134
145
  node = Node.objects.get(mac_address="00:aa:bb:cc:dd:ee")
135
- self.assertTrue(node.has_lcd_screen)
146
+ self.assertTrue(node.has_feature("clipboard-poll"))
136
147
 
137
148
  self.client.post(
138
149
  url,
@@ -141,12 +152,224 @@ class NodeTests(TestCase):
141
152
  "address": "127.0.0.1",
142
153
  "port": 8000,
143
154
  "mac_address": "00:aa:bb:cc:dd:ee",
144
- "has_lcd_screen": False,
155
+ "features": [],
145
156
  },
146
157
  content_type="application/json",
147
158
  )
148
159
  node.refresh_from_db()
149
- self.assertFalse(node.has_lcd_screen)
160
+ self.assertFalse(node.has_feature("clipboard-poll"))
161
+
162
+ def test_register_node_records_version_details(self):
163
+ url = reverse("register-node")
164
+ payload = {
165
+ "hostname": "versioned",
166
+ "address": "127.0.0.5",
167
+ "port": 8100,
168
+ "mac_address": "aa:bb:cc:dd:ee:10",
169
+ "installed_version": "2.0.1",
170
+ "installed_revision": "rev-abcdef",
171
+ }
172
+ response = self.client.post(
173
+ url, data=json.dumps(payload), content_type="application/json"
174
+ )
175
+ self.assertEqual(response.status_code, 200)
176
+ node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:10")
177
+ self.assertEqual(node.installed_version, "2.0.1")
178
+ self.assertEqual(node.installed_revision, "rev-abcdef")
179
+
180
+ update_payload = {
181
+ **payload,
182
+ "installed_version": "2.1.0",
183
+ "installed_revision": "rev-fedcba",
184
+ }
185
+ second = self.client.post(
186
+ url, data=json.dumps(update_payload), content_type="application/json"
187
+ )
188
+ self.assertEqual(second.status_code, 200)
189
+ node.refresh_from_db()
190
+ self.assertEqual(node.installed_version, "2.1.0")
191
+ self.assertEqual(node.installed_revision, "rev-fedcba")
192
+
193
+ def test_register_node_update_triggers_notification(self):
194
+ node = Node.objects.create(
195
+ hostname="friend",
196
+ address="10.1.1.5",
197
+ port=8123,
198
+ mac_address="aa:bb:cc:dd:ee:01",
199
+ installed_version="1.0.0",
200
+ installed_revision="rev-old",
201
+ )
202
+ url = reverse("register-node")
203
+ payload = {
204
+ "hostname": "friend",
205
+ "address": "10.1.1.5",
206
+ "port": 8123,
207
+ "mac_address": "aa:bb:cc:dd:ee:01",
208
+ "installed_version": "2.0.0",
209
+ "installed_revision": "abcdef123456",
210
+ }
211
+ with patch("nodes.models.notify_async") as mock_notify:
212
+ response = self.client.post(
213
+ url, data=json.dumps(payload), content_type="application/json"
214
+ )
215
+ self.assertEqual(response.status_code, 200)
216
+ node.refresh_from_db()
217
+ self.assertEqual(node.installed_version, "2.0.0")
218
+ self.assertEqual(node.installed_revision, "abcdef123456")
219
+ mock_notify.assert_called_once()
220
+ subject, body = mock_notify.call_args[0]
221
+ self.assertEqual(subject, "UP friend")
222
+ self.assertEqual(body, "v2.0.0 r123456")
223
+
224
+ def test_register_node_update_without_version_change_still_notifies(self):
225
+ node = Node.objects.create(
226
+ hostname="friend",
227
+ address="10.1.1.5",
228
+ port=8123,
229
+ mac_address="aa:bb:cc:dd:ee:02",
230
+ installed_version="2.0.0",
231
+ installed_revision="abcdef123456",
232
+ )
233
+ url = reverse("register-node")
234
+ payload = {
235
+ "hostname": "friend",
236
+ "address": "10.1.1.5",
237
+ "port": 8123,
238
+ "mac_address": "aa:bb:cc:dd:ee:02",
239
+ "installed_version": "2.0.0",
240
+ "installed_revision": "abcdef123456",
241
+ }
242
+ with patch("nodes.models.notify_async") as mock_notify:
243
+ response = self.client.post(
244
+ url, data=json.dumps(payload), content_type="application/json"
245
+ )
246
+ self.assertEqual(response.status_code, 200)
247
+ node.refresh_from_db()
248
+ mock_notify.assert_called_once()
249
+ subject, body = mock_notify.call_args[0]
250
+ self.assertEqual(subject, "UP friend")
251
+ self.assertEqual(body, "v2.0.0 r123456")
252
+
253
+ def test_register_node_creation_triggers_notification(self):
254
+ url = reverse("register-node")
255
+ payload = {
256
+ "hostname": "newbie",
257
+ "address": "10.1.1.6",
258
+ "port": 8124,
259
+ "mac_address": "aa:bb:cc:dd:ee:03",
260
+ "installed_version": "3.0.0",
261
+ "installed_revision": "rev-1234567890",
262
+ }
263
+ with patch("nodes.models.notify_async") as mock_notify:
264
+ response = self.client.post(
265
+ url, data=json.dumps(payload), content_type="application/json"
266
+ )
267
+ self.assertEqual(response.status_code, 200)
268
+ self.assertTrue(Node.objects.filter(mac_address="aa:bb:cc:dd:ee:03").exists())
269
+ mock_notify.assert_called_once()
270
+ subject, body = mock_notify.call_args[0]
271
+ self.assertEqual(subject, "UP newbie")
272
+ self.assertEqual(body, "v3.0.0 r567890")
273
+
274
+ def test_register_node_sets_cors_headers(self):
275
+ payload = {
276
+ "hostname": "cors",
277
+ "address": "127.0.0.1",
278
+ "port": 8000,
279
+ "mac_address": "10:20:30:40:50:60",
280
+ }
281
+ response = self.client.post(
282
+ reverse("register-node"),
283
+ data=json.dumps(payload),
284
+ content_type="application/json",
285
+ HTTP_ORIGIN="http://example.com",
286
+ )
287
+ self.assertEqual(response.status_code, 200)
288
+ self.assertEqual(response["Access-Control-Allow-Origin"], "http://example.com")
289
+ self.assertEqual(response["Access-Control-Allow-Credentials"], "true")
290
+
291
+ def test_register_node_requires_auth_without_signature(self):
292
+ self.client.logout()
293
+ payload = {
294
+ "hostname": "visitor",
295
+ "address": "127.0.0.1",
296
+ "port": 8000,
297
+ "mac_address": "aa:bb:cc:dd:ee:00",
298
+ }
299
+ response = self.client.post(
300
+ reverse("register-node"),
301
+ data=json.dumps(payload),
302
+ content_type="application/json",
303
+ HTTP_ORIGIN="http://example.com",
304
+ )
305
+ self.assertEqual(response.status_code, 401)
306
+ data = response.json()
307
+ self.assertEqual(data["detail"], "authentication required")
308
+ self.assertEqual(response["Access-Control-Allow-Origin"], "http://example.com")
309
+
310
+ def test_register_node_allows_preflight_without_authentication(self):
311
+ self.client.logout()
312
+ response = self.client.options(
313
+ reverse("register-node"), HTTP_ORIGIN="https://example.com"
314
+ )
315
+ self.assertEqual(response.status_code, 200)
316
+ self.assertEqual(response["Access-Control-Allow-Origin"], "https://example.com")
317
+ self.assertEqual(response["Access-Control-Allow-Credentials"], "true")
318
+
319
+ def test_register_node_accepts_signed_payload_without_login(self):
320
+ self.client.logout()
321
+ NodeFeature.objects.get_or_create(
322
+ slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
323
+ )
324
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
325
+ public_bytes = private_key.public_key().public_bytes(
326
+ encoding=serialization.Encoding.PEM,
327
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
328
+ ).decode()
329
+ token = "visitor-token"
330
+ signature = base64.b64encode(
331
+ private_key.sign(
332
+ token.encode(),
333
+ padding.PKCS1v15(),
334
+ hashes.SHA256(),
335
+ )
336
+ ).decode()
337
+ payload = {
338
+ "hostname": "visitor",
339
+ "address": "127.0.0.1",
340
+ "port": 8000,
341
+ "mac_address": "aa:bb:cc:dd:ee:11",
342
+ "public_key": public_bytes,
343
+ "token": token,
344
+ "signature": signature,
345
+ "features": ["clipboard-poll"],
346
+ }
347
+ response = self.client.post(
348
+ reverse("register-node"),
349
+ data=json.dumps(payload),
350
+ content_type="application/json",
351
+ HTTP_ORIGIN="http://example.com",
352
+ )
353
+ self.assertEqual(response.status_code, 200)
354
+ self.assertEqual(response["Access-Control-Allow-Origin"], "http://example.com")
355
+ node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:11")
356
+ self.assertEqual(node.public_key, public_bytes)
357
+ self.assertTrue(node.has_feature("clipboard-poll"))
358
+
359
+ def test_register_node_accepts_text_plain_payload(self):
360
+ payload = {
361
+ "hostname": "plain",
362
+ "address": "127.0.0.1",
363
+ "port": 8001,
364
+ "mac_address": "aa:bb:cc:dd:ee:ff",
365
+ }
366
+ response = self.client.post(
367
+ reverse("register-node"),
368
+ data=json.dumps(payload),
369
+ content_type="text/plain",
370
+ )
371
+ self.assertEqual(response.status_code, 200)
372
+ self.assertTrue(Node.objects.filter(mac_address="aa:bb:cc:dd:ee:ff").exists())
150
373
 
151
374
 
152
375
  class NodeRegisterCurrentTests(TestCase):
@@ -156,39 +379,179 @@ class NodeRegisterCurrentTests(TestCase):
156
379
  self.user = User.objects.create_user(username="nodeuser", password="pwd")
157
380
  self.client.force_login(self.user)
158
381
  NodeRole.objects.get_or_create(name="Terminal")
159
- def test_register_current_sets_and_retains_lcd(self):
382
+
383
+ def test_register_current_notifies_peers_on_start(self):
384
+ with TemporaryDirectory() as tmp:
385
+ base = Path(tmp)
386
+ with override_settings(BASE_DIR=base):
387
+ with (
388
+ patch(
389
+ "nodes.models.Node.get_current_mac",
390
+ return_value="00:ff:ee:dd:cc:bb",
391
+ ),
392
+ patch("nodes.models.socket.gethostname", return_value="testhost"),
393
+ patch(
394
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
395
+ ),
396
+ patch("nodes.models.revision.get_revision", return_value="rev"),
397
+ patch.object(Node, "ensure_keys"),
398
+ patch.object(Node, "notify_peers_of_update") as mock_notify,
399
+ ):
400
+ Node.register_current()
401
+ mock_notify.assert_called_once()
402
+
403
+ def test_register_current_refreshes_lcd_feature(self):
404
+ NodeFeature.objects.get_or_create(
405
+ slug="lcd-screen", defaults={"display": "LCD Screen"}
406
+ )
160
407
  with TemporaryDirectory() as tmp:
161
408
  base = Path(tmp)
162
409
  locks = base / "locks"
163
410
  locks.mkdir()
164
- (locks / "lcd_screen.lck").touch()
411
+ lock = locks / "lcd_screen.lck"
412
+ lock.touch()
165
413
  with override_settings(BASE_DIR=base):
166
- with patch("nodes.models.Node.get_current_mac", return_value="00:ff:ee:dd:cc:bb"), patch(
167
- "nodes.models.socket.gethostname", return_value="testhost"
168
- ), patch(
169
- "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
170
- ), patch(
171
- "nodes.models.revision.get_revision", return_value="rev"
172
- ), patch.object(Node, "ensure_keys"):
414
+ with (
415
+ patch(
416
+ "nodes.models.Node.get_current_mac",
417
+ return_value="00:ff:ee:dd:cc:bb",
418
+ ),
419
+ patch("nodes.models.socket.gethostname", return_value="testhost"),
420
+ patch(
421
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
422
+ ),
423
+ patch("nodes.models.revision.get_revision", return_value="rev"),
424
+ patch.object(Node, "ensure_keys"),
425
+ ):
173
426
  node, created = Node.register_current()
174
427
  self.assertTrue(created)
175
- self.assertTrue(node.has_lcd_screen)
176
-
177
- node.has_lcd_screen = False
178
- node.save(update_fields=["has_lcd_screen"])
428
+ self.assertTrue(node.has_feature("lcd-screen"))
179
429
 
430
+ lock.unlink()
180
431
  with override_settings(BASE_DIR=base):
181
- with patch("nodes.models.Node.get_current_mac", return_value="00:ff:ee:dd:cc:bb"), patch(
182
- "nodes.models.socket.gethostname", return_value="testhost"
183
- ), patch(
184
- "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
185
- ), patch(
186
- "nodes.models.revision.get_revision", return_value="rev"
187
- ), patch.object(Node, "ensure_keys"):
188
- node2, created2 = Node.register_current()
432
+ with (
433
+ patch(
434
+ "nodes.models.Node.get_current_mac",
435
+ return_value="00:ff:ee:dd:cc:bb",
436
+ ),
437
+ patch("nodes.models.socket.gethostname", return_value="testhost"),
438
+ patch(
439
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
440
+ ),
441
+ patch("nodes.models.revision.get_revision", return_value="rev"),
442
+ patch.object(Node, "ensure_keys"),
443
+ ):
444
+ _, created2 = Node.register_current()
189
445
  self.assertFalse(created2)
190
446
  node.refresh_from_db()
191
- self.assertFalse(node.has_lcd_screen)
447
+ self.assertFalse(node.has_feature("lcd-screen"))
448
+
449
+ lock.touch()
450
+ with override_settings(BASE_DIR=base):
451
+ with (
452
+ patch(
453
+ "nodes.models.Node.get_current_mac",
454
+ return_value="00:ff:ee:dd:cc:bb",
455
+ ),
456
+ patch("nodes.models.socket.gethostname", return_value="testhost"),
457
+ patch(
458
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
459
+ ),
460
+ patch("nodes.models.revision.get_revision", return_value="rev"),
461
+ patch.object(Node, "ensure_keys"),
462
+ ):
463
+ node, created3 = Node.register_current()
464
+ self.assertFalse(created3)
465
+ node.refresh_from_db()
466
+ self.assertTrue(node.has_feature("lcd-screen"))
467
+
468
+ def test_register_current_notifies_peers_on_version_upgrade(self):
469
+ remote = Node.objects.create(
470
+ hostname="remote",
471
+ address="10.0.0.2",
472
+ port=9100,
473
+ mac_address="aa:bb:cc:dd:ee:ff",
474
+ )
475
+ with TemporaryDirectory() as tmp:
476
+ base = Path(tmp)
477
+ (base / "VERSION").write_text("2.0.0")
478
+ with override_settings(BASE_DIR=base):
479
+ with (
480
+ patch(
481
+ "nodes.models.Node.get_current_mac",
482
+ return_value="00:ff:ee:dd:cc:bb",
483
+ ),
484
+ patch("nodes.models.socket.gethostname", return_value="localnode"),
485
+ patch(
486
+ "nodes.models.socket.gethostbyname",
487
+ return_value="192.168.1.5",
488
+ ),
489
+ patch("nodes.models.revision.get_revision", return_value="newrev"),
490
+ patch("requests.post") as mock_post,
491
+ ):
492
+ Node.objects.create(
493
+ hostname="localnode",
494
+ address="192.168.1.5",
495
+ port=8000,
496
+ mac_address="00:ff:ee:dd:cc:bb",
497
+ installed_version="1.9.0",
498
+ installed_revision="oldrev",
499
+ )
500
+ mock_post.return_value = SimpleNamespace(
501
+ ok=True, status_code=200, text=""
502
+ )
503
+ node, created = Node.register_current()
504
+ self.assertFalse(created)
505
+ self.assertGreaterEqual(mock_post.call_count, 1)
506
+ args, kwargs = mock_post.call_args
507
+ self.assertIn(str(remote.port), args[0])
508
+ payload = json.loads(kwargs["data"])
509
+ self.assertEqual(payload["hostname"], "localnode")
510
+ self.assertEqual(payload["installed_version"], "2.0.0")
511
+ self.assertEqual(payload["installed_revision"], "newrev")
512
+
513
+ def test_register_current_notifies_peers_without_version_change(self):
514
+ Node.objects.create(
515
+ hostname="remote",
516
+ address="10.0.0.3",
517
+ port=9200,
518
+ mac_address="aa:bb:cc:dd:ee:11",
519
+ )
520
+ with TemporaryDirectory() as tmp:
521
+ base = Path(tmp)
522
+ (base / "VERSION").write_text("1.0.0")
523
+ with override_settings(BASE_DIR=base):
524
+ with (
525
+ patch(
526
+ "nodes.models.Node.get_current_mac",
527
+ return_value="00:ff:ee:dd:cc:cc",
528
+ ),
529
+ patch("nodes.models.socket.gethostname", return_value="samever"),
530
+ patch(
531
+ "nodes.models.socket.gethostbyname",
532
+ return_value="192.168.1.6",
533
+ ),
534
+ patch("nodes.models.revision.get_revision", return_value="rev1"),
535
+ patch("requests.post") as mock_post,
536
+ ):
537
+ Node.objects.create(
538
+ hostname="samever",
539
+ address="192.168.1.6",
540
+ port=8000,
541
+ mac_address="00:ff:ee:dd:cc:cc",
542
+ installed_version="1.0.0",
543
+ installed_revision="rev1",
544
+ )
545
+ mock_post.return_value = SimpleNamespace(
546
+ ok=True, status_code=200, text=""
547
+ )
548
+ Node.register_current()
549
+ self.assertEqual(mock_post.call_count, 1)
550
+ args, kwargs = mock_post.call_args
551
+ self.assertIn("/nodes/register/", args[0])
552
+ payload = json.loads(kwargs["data"])
553
+ self.assertEqual(payload["installed_version"], "1.0.0")
554
+ self.assertEqual(payload.get("installed_revision"), "rev1")
192
555
 
193
556
  @patch("nodes.views.capture_screenshot")
194
557
  def test_capture_screenshot(self, mock_capture):
@@ -270,9 +633,7 @@ class NodeRegisterCurrentTests(TestCase):
270
633
  self.assertEqual(get_resp.json()["hostname"], "public")
271
634
 
272
635
  pre_count = NetMessage.objects.count()
273
- post_resp = self.client.post(
274
- url, data="hello", content_type="text/plain"
275
- )
636
+ post_resp = self.client.post(url, data="hello", content_type="text/plain")
276
637
  self.assertEqual(post_resp.status_code, 200)
277
638
  self.assertEqual(NetMessage.objects.count(), pre_count + 1)
278
639
  msg = NetMessage.objects.order_by("-created").first()
@@ -307,10 +668,14 @@ class NodeRegisterCurrentTests(TestCase):
307
668
 
308
669
  def test_net_message_with_valid_signature(self):
309
670
  key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
310
- public_key = key.public_key().public_bytes(
311
- encoding=serialization.Encoding.PEM,
312
- format=serialization.PublicFormat.SubjectPublicKeyInfo,
313
- ).decode()
671
+ public_key = (
672
+ key.public_key()
673
+ .public_bytes(
674
+ encoding=serialization.Encoding.PEM,
675
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
676
+ )
677
+ .decode()
678
+ )
314
679
  sender = Node.objects.create(
315
680
  hostname="sender",
316
681
  address="10.0.0.1",
@@ -325,11 +690,10 @@ class NodeRegisterCurrentTests(TestCase):
325
690
  "body": "world",
326
691
  "seen": [],
327
692
  "sender": str(sender.uuid),
693
+ "origin": str(sender.uuid),
328
694
  }
329
695
  payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
330
- signature = key.sign(
331
- payload_json.encode(), padding.PKCS1v15(), hashes.SHA256()
332
- )
696
+ signature = key.sign(payload_json.encode(), padding.PKCS1v15(), hashes.SHA256())
333
697
  resp = self.client.post(
334
698
  reverse("net-message"),
335
699
  data=payload_json,
@@ -338,8 +702,13 @@ class NodeRegisterCurrentTests(TestCase):
338
702
  )
339
703
  self.assertEqual(resp.status_code, 200)
340
704
  self.assertTrue(NetMessage.objects.filter(uuid=msg_id).exists())
705
+ message = NetMessage.objects.get(uuid=msg_id)
706
+ self.assertEqual(message.node_origin, sender)
341
707
 
342
708
  def test_clipboard_polling_creates_task(self):
709
+ feature, _ = NodeFeature.objects.get_or_create(
710
+ slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
711
+ )
343
712
  node = Node.objects.create(
344
713
  hostname="clip",
345
714
  address="127.0.0.1",
@@ -347,15 +716,16 @@ class NodeRegisterCurrentTests(TestCase):
347
716
  mac_address="00:11:22:33:44:99",
348
717
  )
349
718
  task_name = f"poll_clipboard_node_{node.pk}"
350
- self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
351
- node.clipboard_polling = True
352
- node.save()
719
+ PeriodicTask.objects.filter(name=task_name).delete()
720
+ NodeFeatureAssignment.objects.create(node=node, feature=feature)
353
721
  self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
354
- node.clipboard_polling = False
355
- node.save()
722
+ NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
356
723
  self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
357
724
 
358
725
  def test_screenshot_polling_creates_task(self):
726
+ feature, _ = NodeFeature.objects.get_or_create(
727
+ slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
728
+ )
359
729
  node = Node.objects.create(
360
730
  hostname="shot",
361
731
  address="127.0.0.1",
@@ -363,14 +733,22 @@ class NodeRegisterCurrentTests(TestCase):
363
733
  mac_address="00:11:22:33:44:aa",
364
734
  )
365
735
  task_name = f"capture_screenshot_node_{node.pk}"
366
- self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
367
- node.screenshot_polling = True
368
- node.save()
736
+ PeriodicTask.objects.filter(name=task_name).delete()
737
+ NodeFeatureAssignment.objects.create(node=node, feature=feature)
369
738
  self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
370
- node.screenshot_polling = False
371
- node.save()
739
+ NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
372
740
  self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
373
741
 
742
+
743
+ class CheckRegistrationReadyCommandTests(TestCase):
744
+ def test_command_completes_successfully(self):
745
+ NodeRole.objects.get_or_create(name="Terminal")
746
+ with TemporaryDirectory() as tmp:
747
+ base = Path(tmp)
748
+ with override_settings(BASE_DIR=base):
749
+ call_command("check_registration_ready")
750
+
751
+
374
752
  class NodeAdminTests(TestCase):
375
753
 
376
754
  def setUp(self):
@@ -395,7 +773,7 @@ class NodeAdminTests(TestCase):
395
773
  self.assertTemplateUsed(response, "admin/nodes/node/register_remote.html")
396
774
  self.assertEqual(Node.objects.count(), 1)
397
775
  node = Node.objects.first()
398
- ver = Path('VERSION').read_text().strip()
776
+ ver = Path("VERSION").read_text().strip()
399
777
  rev = "abcdef123456"
400
778
  self.assertEqual(node.base_path, str(settings.BASE_DIR))
401
779
  self.assertEqual(node.installed_version, ver)
@@ -408,9 +786,7 @@ class NodeAdminTests(TestCase):
408
786
  self.assertTrue(priv.exists())
409
787
  self.assertTrue(pub.exists())
410
788
  self.assertTrue(node.public_key)
411
- self.assertTrue(
412
- Site.objects.filter(domain=hostname, name="host").exists()
413
- )
789
+ self.assertTrue(Site.objects.filter(domain=hostname, name="host").exists())
414
790
 
415
791
  def test_register_current_updates_existing_node(self):
416
792
  hostname = socket.gethostname()
@@ -446,9 +822,7 @@ class NodeAdminTests(TestCase):
446
822
  self.assertIn(node.public_key.strip(), resp.content.decode())
447
823
 
448
824
  @patch("nodes.admin.capture_screenshot")
449
- def test_capture_site_screenshot_from_admin(
450
- self, mock_capture_screenshot
451
- ):
825
+ def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
452
826
  screenshot_dir = settings.LOG_DIR / "screenshots"
453
827
  screenshot_dir.mkdir(parents=True, exist_ok=True)
454
828
  file_path = screenshot_dir / "test.png"
@@ -472,9 +846,7 @@ class NodeAdminTests(TestCase):
472
846
  self.assertEqual(screenshot.path, "screenshots/test.png")
473
847
  self.assertEqual(screenshot.method, "ADMIN")
474
848
  mock_capture_screenshot.assert_called_once_with("http://testserver/")
475
- self.assertContains(
476
- response, "Screenshot saved to screenshots/test.png"
477
- )
849
+ self.assertContains(response, "Screenshot saved to screenshots/test.png")
478
850
 
479
851
  def test_view_screenshot_in_change_admin(self):
480
852
  screenshot_dir = settings.LOG_DIR / "screenshots"
@@ -578,7 +950,9 @@ class NetMessageReachTests(TestCase):
578
950
  for name in ["Terminal", "Control", "Satellite", "Constellation"]:
579
951
  self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
580
952
  self.nodes = {}
581
- for idx, name in enumerate(["Terminal", "Control", "Satellite", "Constellation"], start=1):
953
+ for idx, name in enumerate(
954
+ ["Terminal", "Control", "Satellite", "Constellation"], start=1
955
+ ):
582
956
  self.nodes[name] = Node.objects.create(
583
957
  hostname=name.lower(),
584
958
  address=f"10.0.0.{idx}",
@@ -589,7 +963,9 @@ class NetMessageReachTests(TestCase):
589
963
 
590
964
  @patch("requests.post")
591
965
  def test_terminal_reach_limits_nodes(self, mock_post):
592
- msg = NetMessage.objects.create(subject="s", body="b", reach=self.roles["Terminal"])
966
+ msg = NetMessage.objects.create(
967
+ subject="s", body="b", reach=self.roles["Terminal"]
968
+ )
593
969
  with patch.object(Node, "get_local", return_value=None):
594
970
  msg.propagate()
595
971
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
@@ -598,7 +974,9 @@ class NetMessageReachTests(TestCase):
598
974
 
599
975
  @patch("requests.post")
600
976
  def test_control_reach_includes_control_and_terminal(self, mock_post):
601
- msg = NetMessage.objects.create(subject="s", body="b", reach=self.roles["Control"])
977
+ msg = NetMessage.objects.create(
978
+ subject="s", body="b", reach=self.roles["Control"]
979
+ )
602
980
  with patch.object(Node, "get_local", return_value=None):
603
981
  msg.propagate()
604
982
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
@@ -607,7 +985,9 @@ class NetMessageReachTests(TestCase):
607
985
 
608
986
  @patch("requests.post")
609
987
  def test_satellite_reach_includes_lower_roles(self, mock_post):
610
- msg = NetMessage.objects.create(subject="s", body="b", reach=self.roles["Satellite"])
988
+ msg = NetMessage.objects.create(
989
+ subject="s", body="b", reach=self.roles["Satellite"]
990
+ )
611
991
  with patch.object(Node, "get_local", return_value=None):
612
992
  msg.propagate()
613
993
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
@@ -616,7 +996,9 @@ class NetMessageReachTests(TestCase):
616
996
 
617
997
  @patch("requests.post")
618
998
  def test_constellation_reach_prioritizes_constellation(self, mock_post):
619
- msg = NetMessage.objects.create(subject="s", body="b", reach=self.roles["Constellation"])
999
+ msg = NetMessage.objects.create(
1000
+ subject="s", body="b", reach=self.roles["Constellation"]
1001
+ )
620
1002
  with patch.object(Node, "get_local", return_value=None):
621
1003
  msg.propagate()
622
1004
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
@@ -648,14 +1030,24 @@ class NetMessagePropagationTests(TestCase):
648
1030
  )
649
1031
  )
650
1032
 
1033
+ def test_broadcast_sets_node_origin(self):
1034
+ with patch.object(Node, "get_local", return_value=self.local):
1035
+ msg = NetMessage.broadcast(subject="subject", body="body")
1036
+ self.assertEqual(msg.node_origin, self.local)
1037
+
651
1038
  @patch("requests.post")
652
1039
  @patch("core.notifications.notify")
653
- def test_propagate_forwards_to_three_and_notifies_local(self, mock_notify, mock_post):
1040
+ def test_propagate_forwards_to_three_and_notifies_local(
1041
+ self, mock_notify, mock_post
1042
+ ):
654
1043
  msg = NetMessage.objects.create(subject="s", body="b", reach=self.role)
655
1044
  with patch.object(Node, "get_local", return_value=self.local):
656
1045
  msg.propagate(seen=[str(self.remotes[0].uuid)])
657
1046
  mock_notify.assert_called_once_with("s", "b")
658
1047
  self.assertEqual(mock_post.call_count, 3)
1048
+ for call_args in mock_post.call_args_list:
1049
+ payload = json.loads(call_args.kwargs["data"])
1050
+ self.assertEqual(payload.get("origin"), str(self.local.uuid))
659
1051
  targets = {
660
1052
  call.args[0].split("//")[1].split("/")[0]
661
1053
  for call in mock_post.call_args_list
@@ -665,6 +1057,42 @@ class NetMessagePropagationTests(TestCase):
665
1057
  self.assertEqual(msg.propagated_to.count(), 4)
666
1058
  self.assertTrue(msg.complete)
667
1059
 
1060
+ @patch("requests.post")
1061
+ @patch("core.notifications.notify", return_value=True)
1062
+ def test_propagate_prunes_old_local_messages(self, mock_notify, mock_post):
1063
+ old_local = NetMessage.objects.create(
1064
+ subject="old local",
1065
+ body="body",
1066
+ reach=self.role,
1067
+ node_origin=self.local,
1068
+ )
1069
+ NetMessage.objects.filter(pk=old_local.pk).update(
1070
+ created=timezone.now() - timedelta(days=8)
1071
+ )
1072
+ old_remote = NetMessage.objects.create(
1073
+ subject="old remote",
1074
+ body="body",
1075
+ reach=self.role,
1076
+ node_origin=self.remotes[0],
1077
+ )
1078
+ NetMessage.objects.filter(pk=old_remote.pk).update(
1079
+ created=timezone.now() - timedelta(days=8)
1080
+ )
1081
+ msg = NetMessage.objects.create(
1082
+ subject="fresh",
1083
+ body="body",
1084
+ reach=self.role,
1085
+ node_origin=self.local,
1086
+ )
1087
+ with patch.object(Node, "get_local", return_value=self.local):
1088
+ msg.propagate()
1089
+
1090
+ mock_notify.assert_called_once_with("fresh", "body")
1091
+ self.assertFalse(NetMessage.objects.filter(pk=old_local.pk).exists())
1092
+ self.assertTrue(NetMessage.objects.filter(pk=old_remote.pk).exists())
1093
+ self.assertTrue(NetMessage.objects.filter(pk=msg.pk).exists())
1094
+
1095
+
668
1096
  class NodeActionTests(TestCase):
669
1097
  def setUp(self):
670
1098
  self.client = Client()
@@ -739,7 +1167,7 @@ class NodeActionTests(TestCase):
739
1167
 
740
1168
 
741
1169
  class StartupNotificationTests(TestCase):
742
- def test_startup_notification_uses_ip_and_revision(self):
1170
+ def test_startup_notification_uses_hostname_and_revision(self):
743
1171
  from nodes.apps import _startup_notification
744
1172
 
745
1173
  with TemporaryDirectory() as tmp:
@@ -750,20 +1178,17 @@ class StartupNotificationTests(TestCase):
750
1178
  "nodes.apps.revision.get_revision", return_value="abcdef123456"
751
1179
  ):
752
1180
  with patch("nodes.models.NetMessage.broadcast") as mock_broadcast:
753
- with patch("nodes.apps.socket.gethostname", return_value="host"):
754
- with patch(
755
- "nodes.apps.socket.gethostbyname", return_value="1.2.3.4"
756
- ):
757
- with patch.dict(
758
- os.environ, {"PORT": "9000"}
759
- ):
760
- _startup_notification()
761
- time.sleep(0.1)
1181
+ with patch(
1182
+ "nodes.apps.socket.gethostname", return_value="host"
1183
+ ):
1184
+ with patch.dict(os.environ, {"PORT": "9000"}):
1185
+ _startup_notification()
1186
+ time.sleep(0.1)
762
1187
 
763
1188
  mock_broadcast.assert_called_once()
764
1189
  _, kwargs = mock_broadcast.call_args
765
- self.assertEqual(kwargs["subject"], "1.2.3.4:9000")
766
- self.assertTrue(kwargs["body"].startswith("v1.2.3 r"))
1190
+ self.assertEqual(kwargs["subject"], "host:9000")
1191
+ self.assertTrue(kwargs["body"].startswith("1.2.3 r"))
767
1192
 
768
1193
 
769
1194
  class StartupHandlerTests(TestCase):
@@ -787,11 +1212,14 @@ class StartupHandlerTests(TestCase):
787
1212
 
788
1213
  with patch("nodes.apps._startup_notification") as mock_start:
789
1214
  with patch("nodes.apps.connections") as mock_connections:
790
- mock_connections.__getitem__.return_value.ensure_connection.return_value = None
1215
+ mock_connections.__getitem__.return_value.ensure_connection.return_value = (
1216
+ None
1217
+ )
791
1218
  _trigger_startup_notification()
792
1219
 
793
1220
  mock_start.assert_called_once()
794
1221
 
1222
+
795
1223
  class NotificationManagerTests(TestCase):
796
1224
  def test_send_writes_trimmed_lines(self):
797
1225
  from core.notifications import NotificationManager
@@ -921,6 +1349,7 @@ class ContentSampleAdminTests(TestCase):
921
1349
  self.assertContains(resp, "Duplicate sample not created")
922
1350
 
923
1351
 
1352
+ @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
924
1353
  class EmailOutboxTests(TestCase):
925
1354
  def test_node_send_mail_uses_outbox(self):
926
1355
  node = Node.objects.create(
@@ -929,31 +1358,51 @@ class EmailOutboxTests(TestCase):
929
1358
  port=8000,
930
1359
  mac_address="00:11:22:33:aa:bb",
931
1360
  )
932
- EmailOutbox.objects.create(
1361
+ outbox = EmailOutbox.objects.create(
933
1362
  node=node, host="smtp.example.com", port=25, username="u", password="p"
934
1363
  )
935
- with patch("nodes.models.get_connection") as gc, patch(
936
- "nodes.models.send_mail"
937
- ) as sm:
938
- conn = MagicMock()
939
- gc.return_value = conn
1364
+ with patch("nodes.models.mailer.send") as ms:
940
1365
  node.send_mail("sub", "msg", ["to@example.com"])
941
- gc.assert_called_once_with(
942
- host="smtp.example.com",
943
- port=25,
944
- username="u",
945
- password="p",
946
- use_tls=True,
947
- use_ssl=False,
948
- )
949
- sm.assert_called_once_with(
950
- "sub",
951
- "msg",
952
- settings.DEFAULT_FROM_EMAIL,
953
- ["to@example.com"],
954
- connection=conn,
1366
+ ms.assert_called_once_with(
1367
+ "sub", "msg", ["to@example.com"], None, outbox=outbox
955
1368
  )
956
1369
 
1370
+ def test_node_send_mail_queues_email(self):
1371
+ node = Node.objects.create(
1372
+ hostname="host",
1373
+ address="127.0.0.1",
1374
+ port=8000,
1375
+ mac_address="00:11:22:33:cc:dd",
1376
+ )
1377
+ node.send_mail("sub", "msg", ["to@example.com"])
1378
+ self.assertEqual(len(mail.outbox), 1)
1379
+ email = mail.outbox[0]
1380
+ self.assertEqual(email.subject, "sub")
1381
+ self.assertEqual(email.to, ["to@example.com"])
1382
+
1383
+ def test_string_representation_prefers_from_email(self):
1384
+ outbox = EmailOutbox.objects.create(
1385
+ host="smtp.example.com",
1386
+ port=587,
1387
+ username="mailer",
1388
+ password="secret",
1389
+ from_email="noreply@example.com",
1390
+ )
1391
+
1392
+ self.assertEqual(str(outbox), "noreply@example.com")
1393
+
1394
+ def test_string_representation_prefers_username_over_owner(self):
1395
+ group = SecurityGroup.objects.create(name="Operators")
1396
+ outbox = EmailOutbox.objects.create(
1397
+ group=group,
1398
+ host="smtp.example.com",
1399
+ port=587,
1400
+ username="mailer",
1401
+ password="secret",
1402
+ )
1403
+
1404
+ self.assertEqual(str(outbox), "mailer@smtp.example.com")
1405
+
957
1406
 
958
1407
  class ClipboardTaskTests(TestCase):
959
1408
  @patch("nodes.tasks.pyperclip.paste")
@@ -1066,29 +1515,344 @@ class NodeRoleAdminTests(TestCase):
1066
1515
  self.assertIsNone(node1.role)
1067
1516
  self.assertEqual(node2.role, role)
1068
1517
 
1518
+ def test_registered_count_displayed(self):
1519
+ role = NodeRole.objects.create(name="ViewRole")
1520
+ Node.objects.create(
1521
+ hostname="n1",
1522
+ address="127.0.0.1",
1523
+ port=8000,
1524
+ mac_address="00:11:22:33:44:77",
1525
+ role=role,
1526
+ )
1527
+ resp = self.client.get(reverse("admin:nodes_noderole_changelist"))
1528
+ self.assertContains(resp, '<td class="field-registered">1</td>', html=True)
1529
+
1530
+
1531
+ class NodeFeatureFixtureTests(TestCase):
1532
+ def test_rfid_scanner_fixture_includes_control_role(self):
1533
+ for name in ("Terminal", "Satellite", "Constellation", "Control"):
1534
+ NodeRole.objects.get_or_create(name=name)
1535
+ fixture_path = (
1536
+ Path(__file__).resolve().parent
1537
+ / "fixtures"
1538
+ / "node_features__nodefeature_rfid_scanner.json"
1539
+ )
1540
+ call_command("loaddata", str(fixture_path), verbosity=0)
1541
+ feature = NodeFeature.objects.get(slug="rfid-scanner")
1542
+ role_names = set(feature.roles.values_list("name", flat=True))
1543
+ self.assertIn("Control", role_names)
1544
+
1545
+ def test_ap_router_fixture_limits_roles(self):
1546
+ for name in ("Control", "Satellite"):
1547
+ NodeRole.objects.get_or_create(name=name)
1548
+ fixture_path = (
1549
+ Path(__file__).resolve().parent
1550
+ / "fixtures"
1551
+ / "node_features__nodefeature_ap_router.json"
1552
+ )
1553
+ call_command("loaddata", str(fixture_path), verbosity=0)
1554
+ feature = NodeFeature.objects.get(slug="ap-router")
1555
+ role_names = set(feature.roles.values_list("name", flat=True))
1556
+ self.assertEqual(role_names, {"Satellite"})
1069
1557
 
1070
- class OperationWorkflowTests(TestCase):
1558
+
1559
+ class NodeFeatureTests(TestCase):
1071
1560
  def setUp(self):
1072
- self.client = Client()
1073
- User = get_user_model()
1074
- self.user = User.objects.create_superuser(
1075
- username="admin", email="admin@example.com", password="pwd"
1561
+ self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
1562
+ with patch(
1563
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1564
+ ):
1565
+ self.node = Node.objects.create(
1566
+ hostname="local",
1567
+ address="127.0.0.1",
1568
+ port=8000,
1569
+ mac_address="00:11:22:33:44:55",
1570
+ role=self.role,
1571
+ )
1572
+
1573
+ def test_lcd_screen_enabled(self):
1574
+ feature = NodeFeature.objects.create(slug="lcd-screen", display="LCD")
1575
+ feature.roles.add(self.role)
1576
+ NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
1577
+ with patch(
1578
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1579
+ ):
1580
+ self.assertTrue(feature.is_enabled)
1581
+ NodeFeatureAssignment.objects.filter(node=self.node, feature=feature).delete()
1582
+ with patch(
1583
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1584
+ ):
1585
+ self.assertFalse(feature.is_enabled)
1586
+
1587
+ def test_rfid_scanner_lock(self):
1588
+ feature = NodeFeature.objects.create(slug="rfid-scanner", display="RFID")
1589
+ feature.roles.add(self.role)
1590
+ with TemporaryDirectory() as tmp:
1591
+ base = Path(tmp)
1592
+ locks = base / "locks"
1593
+ locks.mkdir()
1594
+ with override_settings(BASE_DIR=base):
1595
+ with patch(
1596
+ "nodes.models.Node.get_current_mac",
1597
+ return_value="00:11:22:33:44:55",
1598
+ ):
1599
+ self.assertFalse(feature.is_enabled)
1600
+ (locks / "rfid.lck").touch()
1601
+ self.assertTrue(feature.is_enabled)
1602
+
1603
+ def test_gui_toast_detection(self):
1604
+ feature = NodeFeature.objects.create(slug="gui-toast", display="GUI Toast")
1605
+ feature.roles.add(self.role)
1606
+ with patch(
1607
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1608
+ ):
1609
+ with patch("core.notifications.supports_gui_toast", return_value=True):
1610
+ self.assertTrue(feature.is_enabled)
1611
+ with patch(
1612
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1613
+ ):
1614
+ with patch("core.notifications.supports_gui_toast", return_value=False):
1615
+ self.assertFalse(feature.is_enabled)
1616
+
1617
+ def test_role_membership_alone_does_not_enable_feature(self):
1618
+ feature = NodeFeature.objects.create(
1619
+ slug="custom-feature", display="Custom Feature"
1620
+ )
1621
+ feature.roles.add(self.role)
1622
+ with patch(
1623
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1624
+ ):
1625
+ self.assertFalse(feature.is_enabled)
1626
+ NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
1627
+ with patch(
1628
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1629
+ ):
1630
+ self.assertTrue(feature.is_enabled)
1631
+
1632
+ @patch("nodes.models.Node._has_rpi_camera", return_value=True)
1633
+ def test_rpi_camera_detection(self, mock_camera):
1634
+ feature = NodeFeature.objects.create(
1635
+ slug="rpi-camera", display="Raspberry Pi Camera"
1636
+ )
1637
+ feature.roles.add(self.role)
1638
+ with patch(
1639
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1640
+ ):
1641
+ self.node.refresh_features()
1642
+ self.assertTrue(
1643
+ NodeFeatureAssignment.objects.filter(
1644
+ node=self.node, feature=feature
1645
+ ).exists()
1076
1646
  )
1077
- self.client.force_login(self.user)
1078
1647
 
1079
- def tearDown(self):
1080
- RUN_CONTEXTS.clear()
1648
+ @patch("nodes.models.Node._has_rpi_camera", side_effect=[True, False])
1649
+ def test_rpi_camera_removed_when_unavailable(self, mock_camera):
1650
+ feature = NodeFeature.objects.create(
1651
+ slug="rpi-camera", display="Raspberry Pi Camera"
1652
+ )
1653
+ feature.roles.add(self.role)
1654
+ with patch(
1655
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1656
+ ):
1657
+ self.node.refresh_features()
1658
+ self.assertTrue(
1659
+ NodeFeatureAssignment.objects.filter(
1660
+ node=self.node, feature=feature
1661
+ ).exists()
1662
+ )
1663
+ self.node.refresh_features()
1664
+ self.assertFalse(
1665
+ NodeFeatureAssignment.objects.filter(
1666
+ node=self.node, feature=feature
1667
+ ).exists()
1668
+ )
1669
+
1670
+ @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
1671
+ def test_ap_router_detection(self, mock_hosts):
1672
+ control_role, _ = NodeRole.objects.get_or_create(name="Control")
1673
+ feature = NodeFeature.objects.create(slug="ap-router", display="AP Router")
1674
+ feature.roles.add(control_role)
1675
+ mac = "00:11:22:33:44:66"
1676
+ with patch("nodes.models.Node.get_current_mac", return_value=mac):
1677
+ node = Node.objects.create(
1678
+ hostname="control",
1679
+ address="127.0.0.1",
1680
+ port=8000,
1681
+ mac_address=mac,
1682
+ role=control_role,
1683
+ )
1684
+ node.refresh_features()
1685
+ self.assertTrue(
1686
+ NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
1687
+ )
1081
1688
 
1082
- def test_unresolved_sigils_prompt(self):
1083
- op = Operation.objects.create(name="op1", command="[ENV.MISSING]")
1084
- url = reverse("admin:nodes_operation_run", args=[op.pk])
1085
- resp = self.client.post(url, follow=True)
1086
- self.assertContains(resp, 'name="ENV__MISSING"')
1689
+ @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
1690
+ def test_ap_public_wifi_detection(self, mock_hosts):
1691
+ control_role, _ = NodeRole.objects.get_or_create(name="Control")
1692
+ router = NodeFeature.objects.create(slug="ap-router", display="AP Router")
1693
+ router.roles.add(control_role)
1694
+ public = NodeFeature.objects.create(
1695
+ slug="ap-public-wifi", display="AP Public Wi-Fi"
1696
+ )
1697
+ public.roles.add(control_role)
1698
+ mac = "00:11:22:33:44:88"
1699
+ with TemporaryDirectory() as tmp, override_settings(BASE_DIR=Path(tmp)):
1700
+ locks = Path(tmp) / "locks"
1701
+ locks.mkdir(parents=True, exist_ok=True)
1702
+ (locks / "public_wifi_mode.lck").touch()
1703
+ with patch("nodes.models.Node.get_current_mac", return_value=mac):
1704
+ node = Node.objects.create(
1705
+ hostname="control",
1706
+ address="127.0.0.1",
1707
+ port=8000,
1708
+ mac_address=mac,
1709
+ role=control_role,
1710
+ base_path=str(Path(tmp)),
1711
+ )
1712
+ node.refresh_features()
1713
+ self.assertTrue(
1714
+ NodeFeatureAssignment.objects.filter(node=node, feature=public).exists()
1715
+ )
1716
+ self.assertFalse(
1717
+ NodeFeatureAssignment.objects.filter(node=node, feature=router).exists()
1718
+ )
1719
+
1720
+ @patch("nodes.models.Node._hosts_gelectriic_ap", side_effect=[True, False])
1721
+ def test_ap_router_removed_when_not_hosting(self, mock_hosts):
1722
+ control_role, _ = NodeRole.objects.get_or_create(name="Control")
1723
+ feature = NodeFeature.objects.create(slug="ap-router", display="AP Router")
1724
+ feature.roles.add(control_role)
1725
+ mac = "00:11:22:33:44:77"
1726
+ with patch("nodes.models.Node.get_current_mac", return_value=mac):
1727
+ node = Node.objects.create(
1728
+ hostname="control",
1729
+ address="127.0.0.1",
1730
+ port=8000,
1731
+ mac_address=mac,
1732
+ role=control_role,
1733
+ )
1734
+ self.assertTrue(
1735
+ NodeFeatureAssignment.objects.filter(
1736
+ node=node, feature=feature
1737
+ ).exists()
1738
+ )
1739
+ node.refresh_features()
1740
+ self.assertFalse(
1741
+ NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
1742
+ )
1743
+
1744
+ @patch("nodes.models.Node._uses_postgres", return_value=True)
1745
+ def test_postgres_detection(self, mock_postgres):
1746
+ feature = NodeFeature.objects.create(
1747
+ slug="postgres-db", display="PostgreSQL Database"
1748
+ )
1749
+ feature.roles.add(self.role)
1750
+ with patch(
1751
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1752
+ ):
1753
+ self.node.refresh_features()
1754
+ self.assertTrue(
1755
+ NodeFeatureAssignment.objects.filter(
1756
+ node=self.node, feature=feature
1757
+ ).exists()
1758
+ )
1759
+
1760
+ @patch("nodes.models.Node._uses_postgres", side_effect=[True, False])
1761
+ def test_postgres_removed_when_not_in_use(self, mock_postgres):
1762
+ feature = NodeFeature.objects.create(
1763
+ slug="postgres-db", display="PostgreSQL Database"
1764
+ )
1765
+ feature.roles.add(self.role)
1766
+ with patch(
1767
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1768
+ ):
1769
+ self.node.refresh_features()
1770
+ self.assertTrue(
1771
+ NodeFeatureAssignment.objects.filter(
1772
+ node=self.node, feature=feature
1773
+ ).exists()
1774
+ )
1775
+ with patch(
1776
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1777
+ ):
1778
+ self.node.refresh_features()
1779
+ self.assertFalse(
1780
+ NodeFeatureAssignment.objects.filter(
1781
+ node=self.node, feature=feature
1782
+ ).exists()
1783
+ )
1784
+
1785
+
1786
+ class NodeRpiCameraDetectionTests(TestCase):
1787
+ @patch("nodes.models.subprocess.run")
1788
+ @patch("nodes.models.shutil.which")
1789
+ @patch("nodes.models.os.access")
1790
+ @patch("nodes.models.os.stat")
1791
+ @patch("nodes.models.Path.exists")
1792
+ def test_has_rpi_camera_true(
1793
+ self,
1794
+ mock_exists,
1795
+ mock_stat,
1796
+ mock_access,
1797
+ mock_which,
1798
+ mock_run,
1799
+ ):
1800
+ mock_exists.return_value = True
1801
+ mock_stat.return_value = SimpleNamespace(st_mode=stat.S_IFCHR)
1802
+ mock_access.return_value = True
1803
+ mock_which.side_effect = lambda name: f"/usr/bin/{name}"
1804
+ mock_run.return_value = SimpleNamespace(returncode=0)
1805
+
1806
+ self.assertTrue(Node._has_rpi_camera())
1807
+ self.assertEqual(mock_which.call_count, len(Node.RPI_CAMERA_BINARIES))
1808
+ self.assertEqual(mock_run.call_count, len(Node.RPI_CAMERA_BINARIES))
1809
+
1810
+ @patch("nodes.models.subprocess.run")
1811
+ @patch("nodes.models.shutil.which")
1812
+ @patch("nodes.models.os.access")
1813
+ @patch("nodes.models.os.stat")
1814
+ @patch("nodes.models.Path.exists")
1815
+ def test_has_rpi_camera_missing_device(
1816
+ self,
1817
+ mock_exists,
1818
+ mock_stat,
1819
+ mock_access,
1820
+ mock_which,
1821
+ mock_run,
1822
+ ):
1823
+ mock_exists.return_value = False
1824
+
1825
+ self.assertFalse(Node._has_rpi_camera())
1826
+ mock_stat.assert_not_called()
1827
+ mock_access.assert_not_called()
1828
+ mock_which.assert_not_called()
1829
+ mock_run.assert_not_called()
1830
+
1831
+ @patch("nodes.models.subprocess.run")
1832
+ @patch("nodes.models.shutil.which")
1833
+ @patch("nodes.models.os.access")
1834
+ @patch("nodes.models.os.stat")
1835
+ @patch("nodes.models.Path.exists")
1836
+ def test_has_rpi_camera_missing_tool(
1837
+ self,
1838
+ mock_exists,
1839
+ mock_stat,
1840
+ mock_access,
1841
+ mock_which,
1842
+ mock_run,
1843
+ ):
1844
+ mock_exists.return_value = True
1845
+ mock_stat.return_value = SimpleNamespace(st_mode=stat.S_IFCHR)
1846
+ mock_access.return_value = True
1847
+ mock_run.return_value = SimpleNamespace(returncode=0)
1087
1848
 
1088
- def test_continue_effect(self):
1089
- op = Operation.objects.create(name="op2", command="...")
1090
- url = reverse("admin:nodes_operation_run", args=[op.pk])
1091
- resp = self.client.post(url, follow=True)
1092
- self.assertContains(resp, 'value="Continue"')
1849
+ def tool_lookup(name):
1850
+ if name == Node.RPI_CAMERA_BINARIES[-1]:
1851
+ return None
1852
+ return f"/usr/bin/{name}"
1093
1853
 
1854
+ mock_which.side_effect = tool_lookup
1094
1855
 
1856
+ self.assertFalse(Node._has_rpi_camera())
1857
+ missing_index = Node.RPI_CAMERA_BINARIES.index(Node.RPI_CAMERA_BINARIES[-1])
1858
+ self.assertEqual(mock_run.call_count, missing_index)