arthexis 0.1.9__py3-none-any.whl → 0.1.11__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 (51) hide show
  1. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
  2. arthexis-0.1.11.dist-info/RECORD +99 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +245 -26
  5. config/urls.py +11 -4
  6. core/admin.py +585 -57
  7. core/apps.py +29 -1
  8. core/auto_upgrade.py +57 -0
  9. core/backends.py +115 -3
  10. core/environment.py +23 -5
  11. core/fields.py +93 -0
  12. core/mailer.py +3 -1
  13. core/models.py +482 -38
  14. core/reference_utils.py +108 -0
  15. core/sigil_builder.py +23 -5
  16. core/sigil_resolver.py +35 -4
  17. core/system.py +400 -140
  18. core/tasks.py +151 -8
  19. core/temp_passwords.py +181 -0
  20. core/test_system_info.py +97 -1
  21. core/tests.py +393 -15
  22. core/user_data.py +154 -16
  23. core/views.py +499 -20
  24. nodes/admin.py +149 -6
  25. nodes/backends.py +125 -18
  26. nodes/dns.py +203 -0
  27. nodes/models.py +498 -9
  28. nodes/tests.py +682 -3
  29. nodes/views.py +154 -7
  30. ocpp/admin.py +63 -3
  31. ocpp/consumers.py +255 -41
  32. ocpp/evcs.py +6 -3
  33. ocpp/models.py +52 -7
  34. ocpp/reference_utils.py +42 -0
  35. ocpp/simulator.py +62 -5
  36. ocpp/store.py +30 -0
  37. ocpp/test_rfid.py +169 -7
  38. ocpp/tests.py +414 -8
  39. ocpp/views.py +109 -76
  40. pages/admin.py +9 -1
  41. pages/context_processors.py +24 -4
  42. pages/defaults.py +14 -0
  43. pages/forms.py +131 -0
  44. pages/models.py +53 -14
  45. pages/tests.py +450 -14
  46. pages/urls.py +4 -0
  47. pages/views.py +419 -110
  48. arthexis-0.1.9.dist-info/RECORD +0 -92
  49. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
  50. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
  51. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
nodes/tests.py CHANGED
@@ -7,8 +7,10 @@ django.setup()
7
7
 
8
8
  from pathlib import Path
9
9
  from types import SimpleNamespace
10
+ import unittest.mock as mock
10
11
  from unittest.mock import patch, call, MagicMock
11
12
  from django.core import mail
13
+ from django.core.mail import EmailMessage
12
14
  from django.core.management import call_command
13
15
  import socket
14
16
  import base64
@@ -18,17 +20,22 @@ from tempfile import TemporaryDirectory
18
20
  import shutil
19
21
  import stat
20
22
  import time
23
+ from datetime import timedelta
21
24
 
22
- from django.test import Client, TestCase, TransactionTestCase, override_settings
25
+ from django.test import Client, SimpleTestCase, TestCase, TransactionTestCase, override_settings
23
26
  from django.urls import reverse
24
27
  from django.contrib.auth import get_user_model
25
28
  from django.contrib import admin
26
29
  from django.contrib.sites.models import Site
27
30
  from django_celery_beat.models import PeriodicTask
28
31
  from django.conf import settings
32
+ from django.utils import timezone
33
+ from dns import resolver as dns_resolver
29
34
  from .actions import NodeAction
35
+ from . import dns as dns_utils
30
36
  from selenium.common.exceptions import WebDriverException
31
37
  from .utils import capture_screenshot
38
+ from django.db.utils import DatabaseError
32
39
 
33
40
  from .models import (
34
41
  Node,
@@ -38,12 +45,14 @@ from .models import (
38
45
  NodeFeature,
39
46
  NodeFeatureAssignment,
40
47
  NetMessage,
48
+ NodeManager,
49
+ DNSRecord,
41
50
  )
42
51
  from .backends import OutboxEmailBackend
43
52
  from .tasks import capture_node_screenshot, sample_clipboard
44
53
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
45
54
  from cryptography.hazmat.primitives import serialization, hashes
46
- from core.models import PackageRelease
55
+ from core.models import PackageRelease, SecurityGroup
47
56
 
48
57
 
49
58
  class NodeTests(TestCase):
@@ -54,7 +63,23 @@ class NodeTests(TestCase):
54
63
  self.client.force_login(self.user)
55
64
  NodeRole.objects.get_or_create(name="Terminal")
56
65
 
66
+
67
+ class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
68
+ def test_get_local_handles_database_errors(self):
69
+ with patch.object(Node.objects, "filter", side_effect=DatabaseError("fail")):
70
+ with self.assertLogs("nodes.models", level="DEBUG") as logs:
71
+ result = Node.get_local()
72
+
73
+ self.assertIsNone(result)
74
+ self.assertTrue(
75
+ any("Node.get_local skipped: database unavailable" in message for message in logs.output)
76
+ )
77
+
78
+
79
+ class NodeGetLocalTests(TestCase):
57
80
  def test_register_current_does_not_create_release(self):
81
+ node = None
82
+ created = False
58
83
  with TemporaryDirectory() as tmp:
59
84
  base = Path(tmp)
60
85
  with override_settings(BASE_DIR=base):
@@ -70,8 +95,11 @@ class NodeTests(TestCase):
70
95
  patch("nodes.models.revision.get_revision", return_value="rev"),
71
96
  patch.object(Node, "ensure_keys"),
72
97
  ):
73
- Node.register_current()
98
+ node, created = Node.register_current()
74
99
  self.assertEqual(PackageRelease.objects.count(), 0)
100
+ self.assertIsNotNone(node)
101
+ self.assertTrue(created)
102
+ self.assertEqual(node.current_relation, Node.Relation.SELF)
75
103
 
76
104
  def test_register_and_list_node(self):
77
105
  response = self.client.post(
@@ -86,6 +114,8 @@ class NodeTests(TestCase):
86
114
  )
87
115
  self.assertEqual(response.status_code, 200)
88
116
  self.assertEqual(Node.objects.count(), 1)
117
+ node = Node.objects.get(mac_address="00:11:22:33:44:55")
118
+ self.assertEqual(node.current_relation, Node.Relation.PEER)
89
119
 
90
120
  # allow same IP with different MAC
91
121
  self.client.post(
@@ -156,6 +186,118 @@ class NodeTests(TestCase):
156
186
  node.refresh_from_db()
157
187
  self.assertFalse(node.has_feature("clipboard-poll"))
158
188
 
189
+ def test_register_node_records_version_details(self):
190
+ url = reverse("register-node")
191
+ payload = {
192
+ "hostname": "versioned",
193
+ "address": "127.0.0.5",
194
+ "port": 8100,
195
+ "mac_address": "aa:bb:cc:dd:ee:10",
196
+ "installed_version": "2.0.1",
197
+ "installed_revision": "rev-abcdef",
198
+ }
199
+ response = self.client.post(
200
+ url, data=json.dumps(payload), content_type="application/json"
201
+ )
202
+ self.assertEqual(response.status_code, 200)
203
+ node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:10")
204
+ self.assertEqual(node.installed_version, "2.0.1")
205
+ self.assertEqual(node.installed_revision, "rev-abcdef")
206
+
207
+ update_payload = {
208
+ **payload,
209
+ "installed_version": "2.1.0",
210
+ "installed_revision": "rev-fedcba",
211
+ }
212
+ second = self.client.post(
213
+ url, data=json.dumps(update_payload), content_type="application/json"
214
+ )
215
+ self.assertEqual(second.status_code, 200)
216
+ node.refresh_from_db()
217
+ self.assertEqual(node.installed_version, "2.1.0")
218
+ self.assertEqual(node.installed_revision, "rev-fedcba")
219
+
220
+ def test_register_node_update_triggers_notification(self):
221
+ node = Node.objects.create(
222
+ hostname="friend",
223
+ address="10.1.1.5",
224
+ port=8123,
225
+ mac_address="aa:bb:cc:dd:ee:01",
226
+ installed_version="1.0.0",
227
+ installed_revision="rev-old",
228
+ )
229
+ url = reverse("register-node")
230
+ payload = {
231
+ "hostname": "friend",
232
+ "address": "10.1.1.5",
233
+ "port": 8123,
234
+ "mac_address": "aa:bb:cc:dd:ee:01",
235
+ "installed_version": "2.0.0",
236
+ "installed_revision": "abcdef123456",
237
+ }
238
+ with patch("nodes.models.notify_async") as mock_notify:
239
+ response = self.client.post(
240
+ url, data=json.dumps(payload), content_type="application/json"
241
+ )
242
+ self.assertEqual(response.status_code, 200)
243
+ node.refresh_from_db()
244
+ self.assertEqual(node.installed_version, "2.0.0")
245
+ self.assertEqual(node.installed_revision, "abcdef123456")
246
+ mock_notify.assert_called_once()
247
+ subject, body = mock_notify.call_args[0]
248
+ self.assertEqual(subject, "UP friend")
249
+ self.assertEqual(body, "v2.0.0 r123456")
250
+
251
+ def test_register_node_update_without_version_change_still_notifies(self):
252
+ node = Node.objects.create(
253
+ hostname="friend",
254
+ address="10.1.1.5",
255
+ port=8123,
256
+ mac_address="aa:bb:cc:dd:ee:02",
257
+ installed_version="2.0.0",
258
+ installed_revision="abcdef123456",
259
+ )
260
+ url = reverse("register-node")
261
+ payload = {
262
+ "hostname": "friend",
263
+ "address": "10.1.1.5",
264
+ "port": 8123,
265
+ "mac_address": "aa:bb:cc:dd:ee:02",
266
+ "installed_version": "2.0.0",
267
+ "installed_revision": "abcdef123456",
268
+ }
269
+ with patch("nodes.models.notify_async") as mock_notify:
270
+ response = self.client.post(
271
+ url, data=json.dumps(payload), content_type="application/json"
272
+ )
273
+ self.assertEqual(response.status_code, 200)
274
+ node.refresh_from_db()
275
+ mock_notify.assert_called_once()
276
+ subject, body = mock_notify.call_args[0]
277
+ self.assertEqual(subject, "UP friend")
278
+ self.assertEqual(body, "v2.0.0 r123456")
279
+
280
+ def test_register_node_creation_triggers_notification(self):
281
+ url = reverse("register-node")
282
+ payload = {
283
+ "hostname": "newbie",
284
+ "address": "10.1.1.6",
285
+ "port": 8124,
286
+ "mac_address": "aa:bb:cc:dd:ee:03",
287
+ "installed_version": "3.0.0",
288
+ "installed_revision": "rev-1234567890",
289
+ }
290
+ with patch("nodes.models.notify_async") as mock_notify:
291
+ response = self.client.post(
292
+ url, data=json.dumps(payload), content_type="application/json"
293
+ )
294
+ self.assertEqual(response.status_code, 200)
295
+ self.assertTrue(Node.objects.filter(mac_address="aa:bb:cc:dd:ee:03").exists())
296
+ mock_notify.assert_called_once()
297
+ subject, body = mock_notify.call_args[0]
298
+ self.assertEqual(subject, "UP newbie")
299
+ self.assertEqual(body, "v3.0.0 r567890")
300
+
159
301
  def test_register_node_sets_cors_headers(self):
160
302
  payload = {
161
303
  "hostname": "cors",
@@ -173,6 +315,74 @@ class NodeTests(TestCase):
173
315
  self.assertEqual(response["Access-Control-Allow-Origin"], "http://example.com")
174
316
  self.assertEqual(response["Access-Control-Allow-Credentials"], "true")
175
317
 
318
+ def test_register_node_requires_auth_without_signature(self):
319
+ self.client.logout()
320
+ payload = {
321
+ "hostname": "visitor",
322
+ "address": "127.0.0.1",
323
+ "port": 8000,
324
+ "mac_address": "aa:bb:cc:dd:ee:00",
325
+ }
326
+ response = self.client.post(
327
+ reverse("register-node"),
328
+ data=json.dumps(payload),
329
+ content_type="application/json",
330
+ HTTP_ORIGIN="http://example.com",
331
+ )
332
+ self.assertEqual(response.status_code, 401)
333
+ data = response.json()
334
+ self.assertEqual(data["detail"], "authentication required")
335
+ self.assertEqual(response["Access-Control-Allow-Origin"], "http://example.com")
336
+
337
+ def test_register_node_allows_preflight_without_authentication(self):
338
+ self.client.logout()
339
+ response = self.client.options(
340
+ reverse("register-node"), HTTP_ORIGIN="https://example.com"
341
+ )
342
+ self.assertEqual(response.status_code, 200)
343
+ self.assertEqual(response["Access-Control-Allow-Origin"], "https://example.com")
344
+ self.assertEqual(response["Access-Control-Allow-Credentials"], "true")
345
+
346
+ def test_register_node_accepts_signed_payload_without_login(self):
347
+ self.client.logout()
348
+ NodeFeature.objects.get_or_create(
349
+ slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
350
+ )
351
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
352
+ public_bytes = private_key.public_key().public_bytes(
353
+ encoding=serialization.Encoding.PEM,
354
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
355
+ ).decode()
356
+ token = "visitor-token"
357
+ signature = base64.b64encode(
358
+ private_key.sign(
359
+ token.encode(),
360
+ padding.PKCS1v15(),
361
+ hashes.SHA256(),
362
+ )
363
+ ).decode()
364
+ payload = {
365
+ "hostname": "visitor",
366
+ "address": "127.0.0.1",
367
+ "port": 8000,
368
+ "mac_address": "aa:bb:cc:dd:ee:11",
369
+ "public_key": public_bytes,
370
+ "token": token,
371
+ "signature": signature,
372
+ "features": ["clipboard-poll"],
373
+ }
374
+ response = self.client.post(
375
+ reverse("register-node"),
376
+ data=json.dumps(payload),
377
+ content_type="application/json",
378
+ HTTP_ORIGIN="http://example.com",
379
+ )
380
+ self.assertEqual(response.status_code, 200)
381
+ self.assertEqual(response["Access-Control-Allow-Origin"], "http://example.com")
382
+ node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:11")
383
+ self.assertEqual(node.public_key, public_bytes)
384
+ self.assertTrue(node.has_feature("clipboard-poll"))
385
+
176
386
  def test_register_node_accepts_text_plain_payload(self):
177
387
  payload = {
178
388
  "hostname": "plain",
@@ -188,6 +398,48 @@ class NodeTests(TestCase):
188
398
  self.assertEqual(response.status_code, 200)
189
399
  self.assertTrue(Node.objects.filter(mac_address="aa:bb:cc:dd:ee:ff").exists())
190
400
 
401
+ def test_register_node_respects_relation_payload(self):
402
+ payload = {
403
+ "hostname": "relation",
404
+ "address": "127.0.0.2",
405
+ "port": 8100,
406
+ "mac_address": "11:22:33:44:55:66",
407
+ "current_relation": "Downstream",
408
+ }
409
+ response = self.client.post(
410
+ reverse("register-node"),
411
+ data=json.dumps(payload),
412
+ content_type="application/json",
413
+ )
414
+ self.assertEqual(response.status_code, 200)
415
+ node = Node.objects.get(mac_address="11:22:33:44:55:66")
416
+ self.assertEqual(node.current_relation, Node.Relation.DOWNSTREAM)
417
+
418
+ update_payload = {
419
+ **payload,
420
+ "hostname": "relation-updated",
421
+ "current_relation": "Upstream",
422
+ }
423
+ second = self.client.post(
424
+ reverse("register-node"),
425
+ data=json.dumps(update_payload),
426
+ content_type="application/json",
427
+ )
428
+ self.assertEqual(second.status_code, 200)
429
+ node.refresh_from_db()
430
+ self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
431
+
432
+ final_payload = {**update_payload, "hostname": "relation-final"}
433
+ final_payload.pop("current_relation")
434
+ third = self.client.post(
435
+ reverse("register-node"),
436
+ data=json.dumps(final_payload),
437
+ content_type="application/json",
438
+ )
439
+ self.assertEqual(third.status_code, 200)
440
+ node.refresh_from_db()
441
+ self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
442
+
191
443
 
192
444
  class NodeRegisterCurrentTests(TestCase):
193
445
  def setUp(self):
@@ -197,6 +449,26 @@ class NodeRegisterCurrentTests(TestCase):
197
449
  self.client.force_login(self.user)
198
450
  NodeRole.objects.get_or_create(name="Terminal")
199
451
 
452
+ def test_register_current_notifies_peers_on_start(self):
453
+ with TemporaryDirectory() as tmp:
454
+ base = Path(tmp)
455
+ with override_settings(BASE_DIR=base):
456
+ with (
457
+ patch(
458
+ "nodes.models.Node.get_current_mac",
459
+ return_value="00:ff:ee:dd:cc:bb",
460
+ ),
461
+ patch("nodes.models.socket.gethostname", return_value="testhost"),
462
+ patch(
463
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
464
+ ),
465
+ patch("nodes.models.revision.get_revision", return_value="rev"),
466
+ patch.object(Node, "ensure_keys"),
467
+ patch.object(Node, "notify_peers_of_update") as mock_notify,
468
+ ):
469
+ Node.register_current()
470
+ mock_notify.assert_called_once()
471
+
200
472
  def test_register_current_refreshes_lcd_feature(self):
201
473
  NodeFeature.objects.get_or_create(
202
474
  slug="lcd-screen", defaults={"display": "LCD Screen"}
@@ -262,6 +534,94 @@ class NodeRegisterCurrentTests(TestCase):
262
534
  node.refresh_from_db()
263
535
  self.assertTrue(node.has_feature("lcd-screen"))
264
536
 
537
+ def test_register_current_notifies_peers_on_version_upgrade(self):
538
+ remote = Node.objects.create(
539
+ hostname="remote",
540
+ address="10.0.0.2",
541
+ port=9100,
542
+ mac_address="aa:bb:cc:dd:ee:ff",
543
+ )
544
+ with TemporaryDirectory() as tmp:
545
+ base = Path(tmp)
546
+ (base / "VERSION").write_text("2.0.0")
547
+ with override_settings(BASE_DIR=base):
548
+ with (
549
+ patch(
550
+ "nodes.models.Node.get_current_mac",
551
+ return_value="00:ff:ee:dd:cc:bb",
552
+ ),
553
+ patch("nodes.models.socket.gethostname", return_value="localnode"),
554
+ patch(
555
+ "nodes.models.socket.gethostbyname",
556
+ return_value="192.168.1.5",
557
+ ),
558
+ patch("nodes.models.revision.get_revision", return_value="newrev"),
559
+ patch("requests.post") as mock_post,
560
+ ):
561
+ Node.objects.create(
562
+ hostname="localnode",
563
+ address="192.168.1.5",
564
+ port=8000,
565
+ mac_address="00:ff:ee:dd:cc:bb",
566
+ installed_version="1.9.0",
567
+ installed_revision="oldrev",
568
+ )
569
+ mock_post.return_value = SimpleNamespace(
570
+ ok=True, status_code=200, text=""
571
+ )
572
+ node, created = Node.register_current()
573
+ self.assertFalse(created)
574
+ self.assertGreaterEqual(mock_post.call_count, 1)
575
+ args, kwargs = mock_post.call_args
576
+ self.assertIn(str(remote.port), args[0])
577
+ payload = json.loads(kwargs["data"])
578
+ self.assertEqual(payload["hostname"], "localnode")
579
+ self.assertEqual(payload["installed_version"], "2.0.0")
580
+ self.assertEqual(payload["installed_revision"], "newrev")
581
+
582
+ def test_register_current_notifies_peers_without_version_change(self):
583
+ Node.objects.create(
584
+ hostname="remote",
585
+ address="10.0.0.3",
586
+ port=9200,
587
+ mac_address="aa:bb:cc:dd:ee:11",
588
+ )
589
+ with TemporaryDirectory() as tmp:
590
+ base = Path(tmp)
591
+ (base / "VERSION").write_text("1.0.0")
592
+ with override_settings(BASE_DIR=base):
593
+ with (
594
+ patch(
595
+ "nodes.models.Node.get_current_mac",
596
+ return_value="00:ff:ee:dd:cc:cc",
597
+ ),
598
+ patch("nodes.models.socket.gethostname", return_value="samever"),
599
+ patch(
600
+ "nodes.models.socket.gethostbyname",
601
+ return_value="192.168.1.6",
602
+ ),
603
+ patch("nodes.models.revision.get_revision", return_value="rev1"),
604
+ patch("requests.post") as mock_post,
605
+ ):
606
+ Node.objects.create(
607
+ hostname="samever",
608
+ address="192.168.1.6",
609
+ port=8000,
610
+ mac_address="00:ff:ee:dd:cc:cc",
611
+ installed_version="1.0.0",
612
+ installed_revision="rev1",
613
+ )
614
+ mock_post.return_value = SimpleNamespace(
615
+ ok=True, status_code=200, text=""
616
+ )
617
+ Node.register_current()
618
+ self.assertEqual(mock_post.call_count, 1)
619
+ args, kwargs = mock_post.call_args
620
+ self.assertIn("/nodes/register/", args[0])
621
+ payload = json.loads(kwargs["data"])
622
+ self.assertEqual(payload["installed_version"], "1.0.0")
623
+ self.assertEqual(payload.get("installed_revision"), "rev1")
624
+
265
625
  @patch("nodes.views.capture_screenshot")
266
626
  def test_capture_screenshot(self, mock_capture):
267
627
  hostname = socket.gethostname()
@@ -399,6 +759,7 @@ class NodeRegisterCurrentTests(TestCase):
399
759
  "body": "world",
400
760
  "seen": [],
401
761
  "sender": str(sender.uuid),
762
+ "origin": str(sender.uuid),
402
763
  }
403
764
  payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
404
765
  signature = key.sign(payload_json.encode(), padding.PKCS1v15(), hashes.SHA256())
@@ -410,6 +771,8 @@ class NodeRegisterCurrentTests(TestCase):
410
771
  )
411
772
  self.assertEqual(resp.status_code, 200)
412
773
  self.assertTrue(NetMessage.objects.filter(uuid=msg_id).exists())
774
+ message = NetMessage.objects.get(uuid=msg_id)
775
+ self.assertEqual(message.node_origin, sender)
413
776
 
414
777
  def test_clipboard_polling_creates_task(self):
415
778
  feature, _ = NodeFeature.objects.get_or_create(
@@ -446,6 +809,15 @@ class NodeRegisterCurrentTests(TestCase):
446
809
  self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
447
810
 
448
811
 
812
+ class CheckRegistrationReadyCommandTests(TestCase):
813
+ def test_command_completes_successfully(self):
814
+ NodeRole.objects.get_or_create(name="Terminal")
815
+ with TemporaryDirectory() as tmp:
816
+ base = Path(tmp)
817
+ with override_settings(BASE_DIR=base):
818
+ call_command("check_registration_ready")
819
+
820
+
449
821
  class NodeAdminTests(TestCase):
450
822
 
451
823
  def setUp(self):
@@ -702,6 +1074,17 @@ class NetMessageReachTests(TestCase):
702
1074
  self.assertEqual(roles, {"Constellation", "Satellite", "Control"})
703
1075
  self.assertEqual(mock_post.call_count, 3)
704
1076
 
1077
+ @patch("requests.post")
1078
+ def test_default_reach_not_limited_to_terminal(self, mock_post):
1079
+ msg = NetMessage.objects.create(subject="s", body="b")
1080
+ with patch.object(Node, "get_local", return_value=None), patch(
1081
+ "random.shuffle", side_effect=lambda seq: None
1082
+ ):
1083
+ msg.propagate()
1084
+ roles = set(msg.propagated_to.values_list("role__name", flat=True))
1085
+ self.assertIn("Control", roles)
1086
+ self.assertEqual(mock_post.call_count, 3)
1087
+
705
1088
 
706
1089
  class NetMessagePropagationTests(TestCase):
707
1090
  def setUp(self):
@@ -727,6 +1110,12 @@ class NetMessagePropagationTests(TestCase):
727
1110
  )
728
1111
  )
729
1112
 
1113
+ def test_broadcast_sets_node_origin(self):
1114
+ with patch.object(Node, "get_local", return_value=self.local):
1115
+ msg = NetMessage.broadcast(subject="subject", body="body")
1116
+ self.assertEqual(msg.node_origin, self.local)
1117
+ self.assertIsNone(msg.reach)
1118
+
730
1119
  @patch("requests.post")
731
1120
  @patch("core.notifications.notify")
732
1121
  def test_propagate_forwards_to_three_and_notifies_local(
@@ -737,6 +1126,9 @@ class NetMessagePropagationTests(TestCase):
737
1126
  msg.propagate(seen=[str(self.remotes[0].uuid)])
738
1127
  mock_notify.assert_called_once_with("s", "b")
739
1128
  self.assertEqual(mock_post.call_count, 3)
1129
+ for call_args in mock_post.call_args_list:
1130
+ payload = json.loads(call_args.kwargs["data"])
1131
+ self.assertEqual(payload.get("origin"), str(self.local.uuid))
740
1132
  targets = {
741
1133
  call.args[0].split("//")[1].split("/")[0]
742
1134
  for call in mock_post.call_args_list
@@ -746,6 +1138,41 @@ class NetMessagePropagationTests(TestCase):
746
1138
  self.assertEqual(msg.propagated_to.count(), 4)
747
1139
  self.assertTrue(msg.complete)
748
1140
 
1141
+ @patch("requests.post")
1142
+ @patch("core.notifications.notify", return_value=True)
1143
+ def test_propagate_prunes_old_local_messages(self, mock_notify, mock_post):
1144
+ old_local = NetMessage.objects.create(
1145
+ subject="old local",
1146
+ body="body",
1147
+ reach=self.role,
1148
+ node_origin=self.local,
1149
+ )
1150
+ NetMessage.objects.filter(pk=old_local.pk).update(
1151
+ created=timezone.now() - timedelta(days=8)
1152
+ )
1153
+ old_remote = NetMessage.objects.create(
1154
+ subject="old remote",
1155
+ body="body",
1156
+ reach=self.role,
1157
+ node_origin=self.remotes[0],
1158
+ )
1159
+ NetMessage.objects.filter(pk=old_remote.pk).update(
1160
+ created=timezone.now() - timedelta(days=8)
1161
+ )
1162
+ msg = NetMessage.objects.create(
1163
+ subject="fresh",
1164
+ body="body",
1165
+ reach=self.role,
1166
+ node_origin=self.local,
1167
+ )
1168
+ with patch.object(Node, "get_local", return_value=self.local):
1169
+ msg.propagate()
1170
+
1171
+ mock_notify.assert_called_once_with("fresh", "body")
1172
+ self.assertFalse(NetMessage.objects.filter(pk=old_local.pk).exists())
1173
+ self.assertTrue(NetMessage.objects.filter(pk=old_remote.pk).exists())
1174
+ self.assertTrue(NetMessage.objects.filter(pk=msg.pk).exists())
1175
+
749
1176
 
750
1177
  class NodeActionTests(TestCase):
751
1178
  def setUp(self):
@@ -1034,6 +1461,91 @@ class EmailOutboxTests(TestCase):
1034
1461
  self.assertEqual(email.subject, "sub")
1035
1462
  self.assertEqual(email.to, ["to@example.com"])
1036
1463
 
1464
+ def test_string_representation_prefers_from_email(self):
1465
+ outbox = EmailOutbox.objects.create(
1466
+ host="smtp.example.com",
1467
+ port=587,
1468
+ username="mailer",
1469
+ password="secret",
1470
+ from_email="noreply@example.com",
1471
+ )
1472
+
1473
+ self.assertEqual(str(outbox), "noreply@example.com")
1474
+
1475
+ def test_string_representation_prefers_username_over_owner(self):
1476
+ group = SecurityGroup.objects.create(name="Operators")
1477
+ outbox = EmailOutbox.objects.create(
1478
+ group=group,
1479
+ host="smtp.example.com",
1480
+ port=587,
1481
+ username="mailer",
1482
+ password="secret",
1483
+ )
1484
+
1485
+ self.assertEqual(str(outbox), "mailer@smtp.example.com")
1486
+
1487
+ def test_string_representation_does_not_duplicate_email_hostname(self):
1488
+ outbox = EmailOutbox.objects.create(
1489
+ host="smtp.example.com",
1490
+ port=587,
1491
+ username="mailer@example.com",
1492
+ password="secret",
1493
+ )
1494
+
1495
+ self.assertEqual(str(outbox), "mailer@example.com")
1496
+
1497
+ def test_unattached_outbox_used_as_fallback(self):
1498
+ EmailOutbox.objects.create(
1499
+ group=SecurityGroup.objects.create(name="Attached"),
1500
+ host="smtp.attached.example.com",
1501
+ port=587,
1502
+ username="attached",
1503
+ password="secret",
1504
+ )
1505
+ fallback = EmailOutbox.objects.create(
1506
+ host="smtp.fallback.example.com",
1507
+ port=587,
1508
+ username="fallback",
1509
+ password="secret",
1510
+ )
1511
+
1512
+ backend = OutboxEmailBackend()
1513
+ message = EmailMessage("subject", "body", to=["to@example.com"])
1514
+
1515
+ selected, fallbacks = backend._select_outbox(message)
1516
+
1517
+ self.assertEqual(selected, fallback)
1518
+ self.assertEqual(fallbacks, [])
1519
+
1520
+ def test_disabled_outbox_excluded_from_selection(self):
1521
+ EmailOutbox.objects.create(
1522
+ host="smtp.disabled.example.com",
1523
+ port=587,
1524
+ username="disabled@example.com",
1525
+ password="secret",
1526
+ from_email="disabled@example.com",
1527
+ is_enabled=False,
1528
+ )
1529
+ enabled = EmailOutbox.objects.create(
1530
+ host="smtp.enabled.example.com",
1531
+ port=587,
1532
+ username="enabled@example.com",
1533
+ password="secret",
1534
+ )
1535
+
1536
+ backend = OutboxEmailBackend()
1537
+ message = EmailMessage(
1538
+ "subject",
1539
+ "body",
1540
+ from_email="disabled@example.com",
1541
+ to=["to@example.com"],
1542
+ )
1543
+
1544
+ selected, fallbacks = backend._select_outbox(message)
1545
+
1546
+ self.assertEqual(selected, enabled)
1547
+ self.assertEqual(fallbacks, [])
1548
+
1037
1549
 
1038
1550
  class ClipboardTaskTests(TestCase):
1039
1551
  @patch("nodes.tasks.pyperclip.paste")
@@ -1487,3 +1999,170 @@ class NodeRpiCameraDetectionTests(TestCase):
1487
1999
  self.assertFalse(Node._has_rpi_camera())
1488
2000
  missing_index = Node.RPI_CAMERA_BINARIES.index(Node.RPI_CAMERA_BINARIES[-1])
1489
2001
  self.assertEqual(mock_run.call_count, missing_index)
2002
+
2003
+
2004
+ class DNSIntegrationTests(TestCase):
2005
+ def setUp(self):
2006
+ self.group = SecurityGroup.objects.create(name="Infra")
2007
+
2008
+ def test_deploy_records_success(self):
2009
+ manager = NodeManager.objects.create(
2010
+ group=self.group,
2011
+ api_key="test-key",
2012
+ api_secret="test-secret",
2013
+ )
2014
+ record_a = DNSRecord.objects.create(
2015
+ domain="example.com",
2016
+ name="@",
2017
+ record_type=DNSRecord.Type.A,
2018
+ data="1.2.3.4",
2019
+ ttl=600,
2020
+ )
2021
+ record_b = DNSRecord.objects.create(
2022
+ domain="example.com",
2023
+ name="@",
2024
+ record_type=DNSRecord.Type.A,
2025
+ data="5.6.7.8",
2026
+ ttl=600,
2027
+ )
2028
+
2029
+ calls = []
2030
+
2031
+ class DummyResponse:
2032
+ status_code = 200
2033
+ reason = "OK"
2034
+
2035
+ def json(self):
2036
+ return {}
2037
+
2038
+ class DummySession:
2039
+ def __init__(self):
2040
+ self.headers = {}
2041
+
2042
+ def put(self, url, json, timeout):
2043
+ calls.append((url, json, timeout, dict(self.headers)))
2044
+ return DummyResponse()
2045
+
2046
+ with mock.patch.object(dns_utils.requests, "Session", DummySession):
2047
+ result = manager.publish_dns_records([record_a, record_b])
2048
+
2049
+ self.assertEqual(len(result.deployed), 2)
2050
+ self.assertFalse(result.failures)
2051
+ self.assertFalse(result.skipped)
2052
+ self.assertTrue(calls)
2053
+ url, payload, timeout, headers = calls[0]
2054
+ self.assertTrue(url.endswith("/v1/domains/example.com/records/A/@"))
2055
+ self.assertEqual(len(payload), 2)
2056
+ self.assertEqual(headers["Authorization"], "sso-key test-key:test-secret")
2057
+
2058
+ record_a.refresh_from_db()
2059
+ record_b.refresh_from_db()
2060
+ self.assertIsNotNone(record_a.last_synced_at)
2061
+ self.assertIsNotNone(record_b.last_synced_at)
2062
+ self.assertEqual(record_a.node_manager_id, manager.pk)
2063
+ self.assertEqual(record_b.node_manager_id, manager.pk)
2064
+
2065
+ def test_deploy_records_handles_error(self):
2066
+ manager = NodeManager.objects.create(
2067
+ group=self.group,
2068
+ api_key="test-key",
2069
+ api_secret="test-secret",
2070
+ )
2071
+ record = DNSRecord.objects.create(
2072
+ domain="example.com",
2073
+ name="www",
2074
+ record_type=DNSRecord.Type.CNAME,
2075
+ data="target.example.com",
2076
+ )
2077
+
2078
+ class DummyResponse:
2079
+ status_code = 400
2080
+ reason = "Bad Request"
2081
+
2082
+ def json(self):
2083
+ return {"message": "Invalid data"}
2084
+
2085
+ class DummySession:
2086
+ def __init__(self):
2087
+ self.headers = {}
2088
+
2089
+ def put(self, url, json, timeout):
2090
+ return DummyResponse()
2091
+
2092
+ with mock.patch.object(dns_utils.requests, "Session", DummySession):
2093
+ result = manager.publish_dns_records([record])
2094
+
2095
+ self.assertFalse(result.deployed)
2096
+ self.assertIn(record, result.failures)
2097
+ record.refresh_from_db()
2098
+ self.assertEqual(record.last_error, "Invalid data")
2099
+ self.assertIsNone(record.last_synced_at)
2100
+
2101
+ def test_validate_record_success(self):
2102
+ record = DNSRecord.objects.create(
2103
+ domain="example.com",
2104
+ name="www",
2105
+ record_type=DNSRecord.Type.A,
2106
+ data="1.2.3.4",
2107
+ )
2108
+
2109
+ class DummyRdata:
2110
+ address = "1.2.3.4"
2111
+
2112
+ class DummyResolver:
2113
+ def resolve(self, name, rtype):
2114
+ self_calls.append((name, rtype))
2115
+ return [DummyRdata()]
2116
+
2117
+ self_calls = []
2118
+ ok, message = dns_utils.validate_record(record, resolver=DummyResolver())
2119
+
2120
+ self.assertTrue(ok)
2121
+ self.assertEqual(message, "")
2122
+ record.refresh_from_db()
2123
+ self.assertIsNotNone(record.last_verified_at)
2124
+ self.assertEqual(record.last_error, "")
2125
+ self.assertEqual(self_calls, [("www.example.com", "A")])
2126
+
2127
+ def test_validate_record_mismatch(self):
2128
+ record = DNSRecord.objects.create(
2129
+ domain="example.com",
2130
+ name="www",
2131
+ record_type=DNSRecord.Type.A,
2132
+ data="1.2.3.4",
2133
+ )
2134
+
2135
+ class DummyRdata:
2136
+ address = "5.6.7.8"
2137
+
2138
+ class DummyResolver:
2139
+ def resolve(self, name, rtype):
2140
+ return [DummyRdata()]
2141
+
2142
+ ok, message = dns_utils.validate_record(record, resolver=DummyResolver())
2143
+
2144
+ self.assertFalse(ok)
2145
+ self.assertEqual(message, "DNS record does not match expected value")
2146
+ record.refresh_from_db()
2147
+ self.assertEqual(record.last_error, message)
2148
+ self.assertIsNone(record.last_verified_at)
2149
+
2150
+ def test_validate_record_handles_exception(self):
2151
+ record = DNSRecord.objects.create(
2152
+ domain="example.com",
2153
+ name="www",
2154
+ record_type=DNSRecord.Type.A,
2155
+ data="1.2.3.4",
2156
+ )
2157
+
2158
+ class DummyResolver:
2159
+ def resolve(self, name, rtype):
2160
+ raise dns_resolver.NXDOMAIN()
2161
+
2162
+ ok, message = dns_utils.validate_record(record, resolver=DummyResolver())
2163
+
2164
+ self.assertFalse(ok)
2165
+ self.assertEqual(message, "The DNS query name does not exist.")
2166
+ record.refresh_from_db()
2167
+ self.assertEqual(record.last_error, message)
2168
+ self.assertIsNone(record.last_verified_at)