arthexis 0.1.9__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.

nodes/tests.py CHANGED
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from types import SimpleNamespace
10
10
  from unittest.mock import patch, call, MagicMock
11
11
  from django.core import mail
12
+ from django.core.mail import EmailMessage
12
13
  from django.core.management import call_command
13
14
  import socket
14
15
  import base64
@@ -18,6 +19,7 @@ from tempfile import TemporaryDirectory
18
19
  import shutil
19
20
  import stat
20
21
  import time
22
+ from datetime import timedelta
21
23
 
22
24
  from django.test import Client, TestCase, TransactionTestCase, override_settings
23
25
  from django.urls import reverse
@@ -26,6 +28,7 @@ from django.contrib import admin
26
28
  from django.contrib.sites.models import Site
27
29
  from django_celery_beat.models import PeriodicTask
28
30
  from django.conf import settings
31
+ from django.utils import timezone
29
32
  from .actions import NodeAction
30
33
  from selenium.common.exceptions import WebDriverException
31
34
  from .utils import capture_screenshot
@@ -43,7 +46,7 @@ from .backends import OutboxEmailBackend
43
46
  from .tasks import capture_node_screenshot, sample_clipboard
44
47
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
45
48
  from cryptography.hazmat.primitives import serialization, hashes
46
- from core.models import PackageRelease
49
+ from core.models import PackageRelease, SecurityGroup
47
50
 
48
51
 
49
52
  class NodeTests(TestCase):
@@ -156,6 +159,118 @@ class NodeTests(TestCase):
156
159
  node.refresh_from_db()
157
160
  self.assertFalse(node.has_feature("clipboard-poll"))
158
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
+
159
274
  def test_register_node_sets_cors_headers(self):
160
275
  payload = {
161
276
  "hostname": "cors",
@@ -173,6 +288,74 @@ class NodeTests(TestCase):
173
288
  self.assertEqual(response["Access-Control-Allow-Origin"], "http://example.com")
174
289
  self.assertEqual(response["Access-Control-Allow-Credentials"], "true")
175
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
+
176
359
  def test_register_node_accepts_text_plain_payload(self):
177
360
  payload = {
178
361
  "hostname": "plain",
@@ -197,6 +380,26 @@ class NodeRegisterCurrentTests(TestCase):
197
380
  self.client.force_login(self.user)
198
381
  NodeRole.objects.get_or_create(name="Terminal")
199
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
+
200
403
  def test_register_current_refreshes_lcd_feature(self):
201
404
  NodeFeature.objects.get_or_create(
202
405
  slug="lcd-screen", defaults={"display": "LCD Screen"}
@@ -262,6 +465,94 @@ class NodeRegisterCurrentTests(TestCase):
262
465
  node.refresh_from_db()
263
466
  self.assertTrue(node.has_feature("lcd-screen"))
264
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")
555
+
265
556
  @patch("nodes.views.capture_screenshot")
266
557
  def test_capture_screenshot(self, mock_capture):
267
558
  hostname = socket.gethostname()
@@ -399,6 +690,7 @@ class NodeRegisterCurrentTests(TestCase):
399
690
  "body": "world",
400
691
  "seen": [],
401
692
  "sender": str(sender.uuid),
693
+ "origin": str(sender.uuid),
402
694
  }
403
695
  payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
404
696
  signature = key.sign(payload_json.encode(), padding.PKCS1v15(), hashes.SHA256())
@@ -410,6 +702,8 @@ class NodeRegisterCurrentTests(TestCase):
410
702
  )
411
703
  self.assertEqual(resp.status_code, 200)
412
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)
413
707
 
414
708
  def test_clipboard_polling_creates_task(self):
415
709
  feature, _ = NodeFeature.objects.get_or_create(
@@ -446,6 +740,15 @@ class NodeRegisterCurrentTests(TestCase):
446
740
  self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
447
741
 
448
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
+
449
752
  class NodeAdminTests(TestCase):
450
753
 
451
754
  def setUp(self):
@@ -727,6 +1030,11 @@ class NetMessagePropagationTests(TestCase):
727
1030
  )
728
1031
  )
729
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
+
730
1038
  @patch("requests.post")
731
1039
  @patch("core.notifications.notify")
732
1040
  def test_propagate_forwards_to_three_and_notifies_local(
@@ -737,6 +1045,9 @@ class NetMessagePropagationTests(TestCase):
737
1045
  msg.propagate(seen=[str(self.remotes[0].uuid)])
738
1046
  mock_notify.assert_called_once_with("s", "b")
739
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))
740
1051
  targets = {
741
1052
  call.args[0].split("//")[1].split("/")[0]
742
1053
  for call in mock_post.call_args_list
@@ -746,6 +1057,41 @@ class NetMessagePropagationTests(TestCase):
746
1057
  self.assertEqual(msg.propagated_to.count(), 4)
747
1058
  self.assertTrue(msg.complete)
748
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
+
749
1095
 
750
1096
  class NodeActionTests(TestCase):
751
1097
  def setUp(self):
@@ -1034,6 +1380,29 @@ class EmailOutboxTests(TestCase):
1034
1380
  self.assertEqual(email.subject, "sub")
1035
1381
  self.assertEqual(email.to, ["to@example.com"])
1036
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
+
1037
1406
 
1038
1407
  class ClipboardTaskTests(TestCase):
1039
1408
  @patch("nodes.tasks.pyperclip.paste")