arthexis 0.1.12__py3-none-any.whl → 0.1.13__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
@@ -34,6 +34,7 @@ from django.test import Client, SimpleTestCase, TestCase, TransactionTestCase, o
34
34
  from django.urls import reverse
35
35
  from django.contrib.auth import get_user_model
36
36
  from django.contrib import admin
37
+ from django.contrib.auth.models import Permission
37
38
  from django.contrib.sites.models import Site
38
39
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
39
40
  from django.conf import settings
@@ -60,7 +61,7 @@ from .backends import OutboxEmailBackend
60
61
  from .tasks import capture_node_screenshot, sample_clipboard
61
62
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
62
63
  from cryptography.hazmat.primitives import serialization, hashes
63
- from core.models import PackageRelease, SecurityGroup
64
+ from core.models import Package, PackageRelease, SecurityGroup, RFID
64
65
 
65
66
 
66
67
  class NodeBadgeColorTests(TestCase):
@@ -122,6 +123,32 @@ class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
122
123
 
123
124
 
124
125
  class NodeGetLocalTests(TestCase):
126
+ def test_normalize_relation_handles_various_inputs(self):
127
+ self.assertEqual(
128
+ Node.normalize_relation(Node.Relation.UPSTREAM),
129
+ Node.Relation.UPSTREAM,
130
+ )
131
+ self.assertEqual(
132
+ Node.normalize_relation(None),
133
+ Node.Relation.PEER,
134
+ )
135
+ self.assertEqual(
136
+ Node.normalize_relation("Upstream"),
137
+ Node.Relation.UPSTREAM,
138
+ )
139
+ self.assertEqual(
140
+ Node.normalize_relation("DOWNSTREAM"),
141
+ Node.Relation.DOWNSTREAM,
142
+ )
143
+ self.assertEqual(
144
+ Node.normalize_relation("peer"),
145
+ Node.Relation.PEER,
146
+ )
147
+ self.assertEqual(
148
+ Node.normalize_relation("unexpected"),
149
+ Node.Relation.PEER,
150
+ )
151
+
125
152
  def test_register_current_does_not_create_release(self):
126
153
  node = None
127
154
  created = False
@@ -245,6 +272,41 @@ class NodeGetLocalTests(TestCase):
245
272
  hostnames = {n["hostname"] for n in data["nodes"]}
246
273
  self.assertEqual(hostnames, {"dup", "local2"})
247
274
 
275
+ def test_register_node_generates_unique_public_endpoint(self):
276
+ url = reverse("register-node")
277
+ User = get_user_model()
278
+ user = User.objects.create_user(username="registrar", password="pwd")
279
+ self.client.force_login(user)
280
+ first = self.client.post(
281
+ url,
282
+ data={
283
+ "hostname": "duplicate-host",
284
+ "address": "10.0.0.10",
285
+ "port": 8080,
286
+ "mac_address": "00:11:22:33:aa:bb",
287
+ },
288
+ content_type="application/json",
289
+ )
290
+ self.assertEqual(first.status_code, 200)
291
+ node_one = Node.objects.get(mac_address="00:11:22:33:aa:bb")
292
+ self.assertEqual(node_one.public_endpoint, "duplicate-host")
293
+
294
+ second = self.client.post(
295
+ url,
296
+ data={
297
+ "hostname": "duplicate-host",
298
+ "address": "10.0.0.11",
299
+ "port": 8081,
300
+ "mac_address": "00:11:22:33:aa:cc",
301
+ },
302
+ content_type="application/json",
303
+ )
304
+ self.assertEqual(second.status_code, 200)
305
+ node_two = Node.objects.get(mac_address="00:11:22:33:aa:cc")
306
+
307
+ self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
308
+ self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
309
+
248
310
  def test_register_node_feature_toggle(self):
249
311
  NodeFeature.objects.get_or_create(
250
312
  slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
@@ -341,6 +403,48 @@ class NodeGetLocalTests(TestCase):
341
403
  self.assertEqual(subject, "UP friend")
342
404
  self.assertEqual(body, "v2.0.0 r123456")
343
405
 
406
+ def test_register_node_marks_nonrelease_version(self):
407
+ package = Package.objects.create(name="pkg-node", is_active=False)
408
+ PackageRelease.objects.create(
409
+ package=package,
410
+ version="2.0.0",
411
+ revision="0" * 40,
412
+ )
413
+
414
+ node = Node.objects.create(
415
+ hostname="friend",
416
+ address="10.1.1.5",
417
+ port=8123,
418
+ mac_address="aa:bb:cc:dd:ee:11",
419
+ installed_version="1.0.0",
420
+ installed_revision="rev-old",
421
+ )
422
+ user = get_user_model().objects.create_user(
423
+ username="node-registrar", password="pwd"
424
+ )
425
+ self.client.force_login(user)
426
+ url = reverse("register-node")
427
+ payload = {
428
+ "hostname": "friend",
429
+ "address": "10.1.1.5",
430
+ "port": 8123,
431
+ "mac_address": "aa:bb:cc:dd:ee:11",
432
+ "installed_version": "2.0.0",
433
+ "installed_revision": "1" * 40,
434
+ }
435
+ with patch("nodes.models.notify_async") as mock_notify:
436
+ response = self.client.post(
437
+ url, data=json.dumps(payload), content_type="application/json"
438
+ )
439
+ self.assertEqual(response.status_code, 200)
440
+ node.refresh_from_db()
441
+ self.assertEqual(node.installed_version, "2.0.0")
442
+ self.assertEqual(node.installed_revision, "1" * 40)
443
+ mock_notify.assert_called_once()
444
+ subject, body = mock_notify.call_args[0]
445
+ self.assertEqual(subject, "UP friend")
446
+ self.assertEqual(body, "v2.0.0+ r111111")
447
+
344
448
  def test_register_node_update_without_version_change_still_notifies(self):
345
449
  node = Node.objects.create(
346
450
  hostname="friend",
@@ -534,6 +638,64 @@ class NodeGetLocalTests(TestCase):
534
638
  self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
535
639
 
536
640
 
641
+ class RegisterVisitorNodeMessageTests(TestCase):
642
+ def setUp(self):
643
+ self.client = Client()
644
+ User = get_user_model()
645
+ self.user = User.objects.create_user(username="visitor", password="pwd")
646
+ self.client.force_login(self.user)
647
+ self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
648
+ self.visitor = Node.objects.create(
649
+ hostname="visitor-node",
650
+ address="10.0.0.100",
651
+ port=8000,
652
+ mac_address="00:10:20:30:40:50",
653
+ role=self.role,
654
+ )
655
+
656
+ def test_register_node_emits_join_message_when_upstream_added(self):
657
+ payload = {
658
+ "hostname": "host-node",
659
+ "address": "10.0.0.10",
660
+ "port": 8100,
661
+ "mac_address": "aa:bb:cc:dd:ee:01",
662
+ "current_relation": "Upstream",
663
+ }
664
+ with patch("nodes.views.Node.get_local", return_value=self.visitor), patch.object(
665
+ NetMessage, "broadcast"
666
+ ) as mock_broadcast:
667
+ response = self.client.post(
668
+ reverse("register-node"),
669
+ data=json.dumps(payload),
670
+ content_type="application/json",
671
+ )
672
+
673
+ self.assertEqual(response.status_code, 200)
674
+ mock_broadcast.assert_called_once_with(
675
+ subject="NODE visitor-node", body="JOINS host-node"
676
+ )
677
+
678
+ def test_register_node_skips_message_when_not_upstream(self):
679
+ payload = {
680
+ "hostname": "remote-node",
681
+ "address": "10.0.0.11",
682
+ "port": 8101,
683
+ "mac_address": "aa:bb:cc:dd:ee:02",
684
+ "current_relation": "Downstream",
685
+ }
686
+ with patch("nodes.views.Node.get_local", return_value=self.visitor), patch.object(
687
+ NetMessage, "broadcast"
688
+ ) as mock_broadcast:
689
+ response = self.client.post(
690
+ reverse("register-node"),
691
+ data=json.dumps(payload),
692
+ content_type="application/json",
693
+ )
694
+
695
+ self.assertEqual(response.status_code, 200)
696
+ mock_broadcast.assert_not_called()
697
+
698
+
537
699
  class NodeRegisterCurrentTests(TestCase):
538
700
  def setUp(self):
539
701
  User = get_user_model()
@@ -845,6 +1007,17 @@ class NodeRegisterCurrentTests(TestCase):
845
1007
  mac_address="00:11:22:33:44:cc",
846
1008
  public_key=public_key,
847
1009
  )
1010
+ target_role, _ = NodeRole.objects.get_or_create(name="Control")
1011
+ feature, _ = NodeFeature.objects.get_or_create(
1012
+ slug="net-message", defaults={"display": "Net Message"}
1013
+ )
1014
+ target = Node.objects.create(
1015
+ hostname="target",
1016
+ address="10.0.0.2",
1017
+ port=8001,
1018
+ mac_address="00:11:22:33:44:dd",
1019
+ role=target_role,
1020
+ )
848
1021
  msg_id = str(uuid.uuid4())
849
1022
  payload = {
850
1023
  "uuid": msg_id,
@@ -853,6 +1026,12 @@ class NodeRegisterCurrentTests(TestCase):
853
1026
  "seen": [],
854
1027
  "sender": str(sender.uuid),
855
1028
  "origin": str(sender.uuid),
1029
+ "filter_node": str(target.uuid),
1030
+ "filter_node_feature": feature.slug,
1031
+ "filter_node_role": target_role.name,
1032
+ "filter_current_relation": Node.Relation.UPSTREAM,
1033
+ "filter_installed_version": "1.0.0",
1034
+ "filter_installed_revision": "rev123",
856
1035
  }
857
1036
  payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
858
1037
  signature = key.sign(payload_json.encode(), padding.PKCS1v15(), hashes.SHA256())
@@ -866,6 +1045,12 @@ class NodeRegisterCurrentTests(TestCase):
866
1045
  self.assertTrue(NetMessage.objects.filter(uuid=msg_id).exists())
867
1046
  message = NetMessage.objects.get(uuid=msg_id)
868
1047
  self.assertEqual(message.node_origin, sender)
1048
+ self.assertEqual(message.filter_node, target)
1049
+ self.assertEqual(message.filter_node_feature, feature)
1050
+ self.assertEqual(message.filter_node_role, target_role)
1051
+ self.assertEqual(message.filter_current_relation, Node.Relation.UPSTREAM)
1052
+ self.assertEqual(message.filter_installed_version, "1.0.0")
1053
+ self.assertEqual(message.filter_installed_revision, "rev123")
869
1054
 
870
1055
  def test_clipboard_polling_creates_task(self):
871
1056
  feature, _ = NodeFeature.objects.get_or_create(
@@ -944,6 +1129,18 @@ class NodeAdminTests(TestCase):
944
1129
  action_url = reverse("admin:core_rfid_scan")
945
1130
  self.assertContains(response, f'href="{action_url}"')
946
1131
 
1132
+ def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
1133
+ node = self._create_local_node()
1134
+ feature, _ = NodeFeature.objects.get_or_create(
1135
+ slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
1136
+ )
1137
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1138
+ response = self.client.get(reverse("admin:nodes_nodefeature_changelist"))
1139
+ snapshot_url = reverse("admin:nodes_nodefeature_take_snapshot")
1140
+ stream_url = reverse("admin:nodes_nodefeature_view_stream")
1141
+ self.assertContains(response, f'href="{snapshot_url}"')
1142
+ self.assertContains(response, f'href="{stream_url}"')
1143
+
947
1144
  def test_node_feature_list_hides_default_action_when_disabled(self):
948
1145
  self._create_local_node()
949
1146
  NodeFeature.objects.get_or_create(
@@ -1010,6 +1207,34 @@ class NodeAdminTests(TestCase):
1010
1207
  )
1011
1208
  self.assertIn(node.public_key.strip(), resp.content.decode())
1012
1209
 
1210
+ def test_register_current_requires_superuser(self):
1211
+ User = get_user_model()
1212
+ staff = User.objects.create_user(
1213
+ username="staff", password="pass", is_staff=True
1214
+ )
1215
+ permission = Permission.objects.get(codename="view_node")
1216
+ staff.user_permissions.add(permission)
1217
+ self.client.force_login(staff)
1218
+
1219
+ response = self.client.get(reverse("admin:nodes_node_register_current"))
1220
+
1221
+ self.assertEqual(response.status_code, 403)
1222
+
1223
+ def test_register_current_link_hidden_for_non_superusers(self):
1224
+ User = get_user_model()
1225
+ staff = User.objects.create_user(
1226
+ username="linkstaff", password="pass", is_staff=True
1227
+ )
1228
+ permission = Permission.objects.get(codename="view_node")
1229
+ staff.user_permissions.add(permission)
1230
+ self.client.force_login(staff)
1231
+
1232
+ response = self.client.get(reverse("admin:nodes_node_changelist"))
1233
+
1234
+ self.assertNotContains(
1235
+ response, reverse("admin:nodes_node_register_current")
1236
+ )
1237
+
1013
1238
  @patch("nodes.admin.capture_screenshot")
1014
1239
  def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
1015
1240
  screenshot_dir = settings.LOG_DIR / "screenshots"
@@ -1241,6 +1466,195 @@ class NodeAdminTests(TestCase):
1241
1466
  change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
1242
1467
  self.assertEqual(response.redirect_chain[-1][0], change_url)
1243
1468
 
1469
+ def test_view_stream_requires_enabled_feature(self):
1470
+ self._create_local_node()
1471
+ NodeFeature.objects.get_or_create(
1472
+ slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
1473
+ )
1474
+ response = self.client.get(
1475
+ reverse("admin:nodes_nodefeature_view_stream"), follow=True
1476
+ )
1477
+ self.assertEqual(response.status_code, 200)
1478
+ changelist_url = reverse("admin:nodes_nodefeature_changelist")
1479
+ self.assertEqual(response.wsgi_request.path, changelist_url)
1480
+ self.assertContains(
1481
+ response, "Raspberry Pi Camera feature is not enabled on this node."
1482
+ )
1483
+
1484
+ def test_view_stream_renders_when_feature_enabled(self):
1485
+ node = self._create_local_node()
1486
+ feature, _ = NodeFeature.objects.get_or_create(
1487
+ slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
1488
+ )
1489
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1490
+ response = self.client.get(reverse("admin:nodes_nodefeature_view_stream"))
1491
+ self.assertEqual(response.status_code, 200)
1492
+ response.render()
1493
+ expected_stream = "http://testserver:8554/"
1494
+ self.assertEqual(response.context_data["stream_url"], expected_stream)
1495
+ self.assertContains(response, expected_stream)
1496
+ self.assertContains(response, "camera-stream__frame")
1497
+
1498
+ def test_view_stream_uses_configured_stream_url(self):
1499
+ node = self._create_local_node()
1500
+ feature, _ = NodeFeature.objects.get_or_create(
1501
+ slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
1502
+ )
1503
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1504
+ configured_stream = "https://camera.local/stream"
1505
+ with self.settings(RPI_CAMERA_STREAM_URL=configured_stream):
1506
+ response = self.client.get(
1507
+ reverse("admin:nodes_nodefeature_view_stream")
1508
+ )
1509
+ self.assertEqual(response.status_code, 200)
1510
+ response.render()
1511
+ self.assertEqual(response.context_data["stream_url"], configured_stream)
1512
+ self.assertContains(response, configured_stream)
1513
+
1514
+ @patch("nodes.admin.requests.post")
1515
+ def test_fetch_rfids_action_fetches_and_imports(self, mock_post):
1516
+ local = self._create_local_node()
1517
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
1518
+ private_bytes = key.private_bytes(
1519
+ encoding=serialization.Encoding.PEM,
1520
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
1521
+ encryption_algorithm=serialization.NoEncryption(),
1522
+ )
1523
+ public_bytes = key.public_key().public_bytes(
1524
+ encoding=serialization.Encoding.PEM,
1525
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
1526
+ )
1527
+ security_dir = Path(settings.BASE_DIR) / "security"
1528
+ security_dir.mkdir(parents=True, exist_ok=True)
1529
+ (security_dir / f"{local.public_endpoint}").write_bytes(private_bytes)
1530
+ (security_dir / f"{local.public_endpoint}.pub").write_bytes(public_bytes)
1531
+ local.public_key = public_bytes.decode()
1532
+ local.save(update_fields=["public_key"])
1533
+
1534
+ remote = Node.objects.create(
1535
+ hostname="remote",
1536
+ address="127.0.0.2",
1537
+ port=8010,
1538
+ mac_address="aa:bb:cc:dd:ee:ff",
1539
+ )
1540
+
1541
+ mock_response = MagicMock()
1542
+ mock_response.status_code = 200
1543
+ mock_response.json.return_value = {
1544
+ "rfids": [
1545
+ {
1546
+ "rfid": "abc123",
1547
+ "custom_label": "Remote tag",
1548
+ "key_a": "A1B2C3D4E5F6",
1549
+ "key_b": "FFFFFFFFFFFF",
1550
+ "data": ["sector"],
1551
+ "key_a_verified": True,
1552
+ "key_b_verified": False,
1553
+ "allowed": True,
1554
+ "color": RFID.BLACK,
1555
+ "kind": RFID.CLASSIC,
1556
+ "released": False,
1557
+ "last_seen_on": None,
1558
+ }
1559
+ ]
1560
+ }
1561
+ mock_response.text = ""
1562
+ mock_post.return_value = mock_response
1563
+
1564
+ response = self.client.post(
1565
+ reverse("admin:nodes_node_changelist"),
1566
+ {"action": "fetch_rfids", "_selected_action": [str(remote.pk)]},
1567
+ follow=True,
1568
+ )
1569
+ self.assertEqual(response.status_code, 200)
1570
+ self.assertTrue(RFID.objects.filter(rfid="ABC123").exists())
1571
+ tag = RFID.objects.get(rfid="ABC123")
1572
+ self.assertEqual(tag.custom_label, "Remote tag")
1573
+ self.assertEqual(tag.origin_node, remote)
1574
+ self.assertEqual(tag.data, ["sector"])
1575
+
1576
+ self.assertTrue(mock_post.called)
1577
+ call_kwargs = mock_post.call_args.kwargs
1578
+ payload = call_kwargs["data"]
1579
+ headers = call_kwargs["headers"]
1580
+ signature = base64.b64decode(headers["X-Signature"])
1581
+ key.public_key().verify(
1582
+ signature,
1583
+ payload.encode(),
1584
+ padding.PKCS1v15(),
1585
+ hashes.SHA256(),
1586
+ )
1587
+ self.assertContains(response, "Fetched RFIDs from 1 node(s)")
1588
+
1589
+
1590
+ class RFIDExportViewTests(TestCase):
1591
+ def setUp(self):
1592
+ self.client = Client()
1593
+ NodeRole.objects.get_or_create(name="Terminal")
1594
+ self.local_node = Node.objects.create(
1595
+ hostname="local",
1596
+ address="127.0.0.1",
1597
+ port=8000,
1598
+ mac_address=Node.get_current_mac(),
1599
+ )
1600
+ self.remote_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
1601
+ self.remote_public = (
1602
+ self.remote_key.public_key()
1603
+ .public_bytes(
1604
+ encoding=serialization.Encoding.PEM,
1605
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
1606
+ )
1607
+ .decode()
1608
+ )
1609
+ self.remote_node = Node.objects.create(
1610
+ hostname="remote",
1611
+ address="10.0.0.2",
1612
+ port=8100,
1613
+ mac_address="00:11:22:33:44:55",
1614
+ public_key=self.remote_public,
1615
+ )
1616
+
1617
+ def _sign_payload(self, payload):
1618
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
1619
+ signature = self.remote_key.sign(
1620
+ payload_json.encode(),
1621
+ padding.PKCS1v15(),
1622
+ hashes.SHA256(),
1623
+ )
1624
+ return payload_json, base64.b64encode(signature).decode()
1625
+
1626
+ def test_export_requires_signature(self):
1627
+ payload_json = json.dumps(
1628
+ {"requester": str(self.remote_node.uuid)},
1629
+ separators=(",", ":"),
1630
+ sort_keys=True,
1631
+ )
1632
+ response = self.client.post(
1633
+ reverse("node-rfid-export"),
1634
+ data=payload_json,
1635
+ content_type="application/json",
1636
+ )
1637
+ self.assertEqual(response.status_code, 403)
1638
+
1639
+ def test_export_returns_serialized_data(self):
1640
+ RFID.objects.create(rfid="ABCDEF")
1641
+ payload_json, signature = self._sign_payload(
1642
+ {"requester": str(self.remote_node.uuid)}
1643
+ )
1644
+ response = self.client.post(
1645
+ reverse("node-rfid-export"),
1646
+ data=payload_json,
1647
+ content_type="application/json",
1648
+ HTTP_X_SIGNATURE=signature,
1649
+ )
1650
+ self.assertEqual(response.status_code, 200)
1651
+ body = response.json()
1652
+ self.assertIn("rfids", body)
1653
+ self.assertEqual(len(body["rfids"]), 1)
1654
+ tag_data = body["rfids"][0]
1655
+ self.assertEqual(tag_data["rfid"], "ABCDEF")
1656
+ self.assertIn("custom_label", tag_data)
1657
+
1244
1658
 
1245
1659
  class NetMessageAdminTests(TransactionTestCase):
1246
1660
  reset_sequences = True
@@ -1273,6 +1687,27 @@ class NetMessageAdminTests(TransactionTestCase):
1273
1687
  self.assertEqual(response.status_code, 302)
1274
1688
  mock_propagate.assert_called_once()
1275
1689
 
1690
+ def test_reply_action_prefills_initial_data(self):
1691
+ role = NodeRole.objects.get(name="Terminal")
1692
+ node = Node.objects.create(
1693
+ hostname="remote",
1694
+ address="10.0.0.10",
1695
+ port=8100,
1696
+ mac_address="00:11:22:33:44:55",
1697
+ role=role,
1698
+ )
1699
+ original = NetMessage.objects.create(
1700
+ subject="Ping",
1701
+ body="Hello",
1702
+ node_origin=node,
1703
+ )
1704
+ url = f"{reverse('admin:nodes_netmessage_add')}?reply_to={original.pk}"
1705
+ response = self.client.get(url)
1706
+ self.assertEqual(response.status_code, 200)
1707
+ form = response.context_data["adminform"].form
1708
+ self.assertEqual(form["subject"].value(), "Re: Ping")
1709
+ self.assertEqual(str(form["filter_node"].value()), str(node.pk))
1710
+
1276
1711
 
1277
1712
  class LastNetMessageViewTests(TestCase):
1278
1713
  def setUp(self):
@@ -1281,10 +1716,19 @@ class LastNetMessageViewTests(TestCase):
1281
1716
 
1282
1717
  def test_returns_latest_message(self):
1283
1718
  NetMessage.objects.create(subject="old", body="msg1")
1284
- NetMessage.objects.create(subject="new", body="msg2")
1719
+ latest = NetMessage.objects.create(subject="new", body="msg2")
1285
1720
  resp = self.client.get(reverse("last-net-message"))
1286
1721
  self.assertEqual(resp.status_code, 200)
1287
- self.assertEqual(resp.json(), {"subject": "new", "body": "msg2"})
1722
+ self.assertEqual(
1723
+ resp.json(),
1724
+ {
1725
+ "subject": "new",
1726
+ "body": "msg2",
1727
+ "admin_url": reverse(
1728
+ "admin:nodes_netmessage_change", args=[latest.pk]
1729
+ ),
1730
+ },
1731
+ )
1288
1732
 
1289
1733
 
1290
1734
  class NetMessageReachTests(TestCase):
@@ -1345,8 +1789,10 @@ class NetMessageReachTests(TestCase):
1345
1789
  with patch.object(Node, "get_local", return_value=None):
1346
1790
  msg.propagate()
1347
1791
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
1348
- self.assertEqual(roles, {"Constellation", "Satellite", "Control"})
1349
- self.assertEqual(mock_post.call_count, 3)
1792
+ self.assertEqual(
1793
+ roles, {"Constellation", "Satellite", "Control", "Terminal"}
1794
+ )
1795
+ self.assertEqual(mock_post.call_count, 4)
1350
1796
 
1351
1797
  @patch("requests.post")
1352
1798
  def test_default_reach_not_limited_to_terminal(self, mock_post):
@@ -1357,7 +1803,105 @@ class NetMessageReachTests(TestCase):
1357
1803
  msg.propagate()
1358
1804
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
1359
1805
  self.assertIn("Control", roles)
1360
- self.assertEqual(mock_post.call_count, 3)
1806
+ self.assertEqual(mock_post.call_count, 4)
1807
+
1808
+
1809
+ class NetMessageFilterTests(TestCase):
1810
+ def setUp(self):
1811
+ self.terminal_role, _ = NodeRole.objects.get_or_create(name="Terminal")
1812
+ self.control_role, _ = NodeRole.objects.get_or_create(name="Control")
1813
+ self.nodes = {
1814
+ "terminal": Node.objects.create(
1815
+ hostname="terminal-filter",
1816
+ address="10.20.0.1",
1817
+ port=8020,
1818
+ mac_address="00:11:22:33:55:01",
1819
+ role=self.terminal_role,
1820
+ ),
1821
+ "control": Node.objects.create(
1822
+ hostname="control-filter",
1823
+ address="10.20.0.2",
1824
+ port=8021,
1825
+ mac_address="00:11:22:33:55:02",
1826
+ role=self.control_role,
1827
+ ),
1828
+ }
1829
+ self.feature, _ = NodeFeature.objects.get_or_create(
1830
+ slug="filter-test", defaults={"display": "Filter Test"}
1831
+ )
1832
+ NodeFeatureAssignment.objects.get_or_create(
1833
+ node=self.nodes["control"], feature=self.feature
1834
+ )
1835
+ self.nodes["control"].current_relation = Node.Relation.UPSTREAM
1836
+ self.nodes["control"].installed_version = "1.2.3"
1837
+ self.nodes["control"].installed_revision = "abc123"
1838
+ self.nodes["control"].save(
1839
+ update_fields=[
1840
+ "current_relation",
1841
+ "installed_version",
1842
+ "installed_revision",
1843
+ ]
1844
+ )
1845
+
1846
+ @patch("requests.post")
1847
+ def test_filter_node_limits_targets(self, mock_post):
1848
+ msg = NetMessage.objects.create(
1849
+ subject="s", body="b", filter_node=self.nodes["control"]
1850
+ )
1851
+ with patch.object(Node, "get_local", return_value=None):
1852
+ msg.propagate()
1853
+ self.assertEqual(
1854
+ list(msg.propagated_to.values_list("pk", flat=True)),
1855
+ [self.nodes["control"].pk],
1856
+ )
1857
+ mock_post.assert_called_once()
1858
+
1859
+ @patch("requests.post")
1860
+ def test_filter_fields_limit_queryset(self, mock_post):
1861
+ msg = NetMessage.objects.create(
1862
+ subject="s",
1863
+ body="b",
1864
+ filter_node_feature=self.feature,
1865
+ filter_node_role=self.control_role,
1866
+ filter_current_relation=Node.Relation.UPSTREAM,
1867
+ filter_installed_version="1.2.3",
1868
+ filter_installed_revision="abc123",
1869
+ )
1870
+ with patch.object(Node, "get_local", return_value=None):
1871
+ msg.propagate()
1872
+ self.assertEqual(
1873
+ list(msg.propagated_to.values_list("pk", flat=True)),
1874
+ [self.nodes["control"].pk],
1875
+ )
1876
+ mock_post.assert_called_once()
1877
+
1878
+
1879
+ class NetMessageBroadcastStringReachTests(TestCase):
1880
+ def test_broadcast_uses_role_lookup_for_string_reach(self):
1881
+ role = NodeRole.objects.create(name="Terminal")
1882
+ local = Node.objects.create(
1883
+ hostname="terminal-local",
1884
+ address="10.10.0.1",
1885
+ port=8010,
1886
+ mac_address="00:aa:bb:cc:dd:ff",
1887
+ role=role,
1888
+ public_endpoint="terminal-local",
1889
+ )
1890
+ seen = ["existing"]
1891
+
1892
+ with patch.object(Node, "get_local", return_value=local), patch.object(
1893
+ NetMessage, "propagate"
1894
+ ) as mock_propagate:
1895
+ msg = NetMessage.broadcast(
1896
+ "Subject", "Body", reach="Terminal", seen=seen
1897
+ )
1898
+
1899
+ self.assertEqual(msg.reach, role)
1900
+ self.assertEqual(msg.node_origin, local)
1901
+ mock_propagate.assert_called_once()
1902
+ called_args = mock_propagate.call_args
1903
+ self.assertIn("seen", called_args.kwargs)
1904
+ self.assertIs(called_args.kwargs["seen"], seen)
1361
1905
 
1362
1906
 
1363
1907
  class NetMessagePropagationTests(TestCase):
@@ -1412,6 +1956,29 @@ class NetMessagePropagationTests(TestCase):
1412
1956
  self.assertEqual(msg.propagated_to.count(), 4)
1413
1957
  self.assertTrue(msg.complete)
1414
1958
 
1959
+ @patch("requests.post")
1960
+ @patch("core.notifications.notify", return_value=False)
1961
+ def test_propagate_defaults_to_six_when_available(
1962
+ self, mock_notify, mock_post
1963
+ ):
1964
+ for idx in range(6, 12):
1965
+ self.remotes.append(
1966
+ Node.objects.create(
1967
+ hostname=f"n{idx}",
1968
+ address=f"10.0.0.{idx}",
1969
+ port=8000 + idx,
1970
+ mac_address=f"00:11:22:33:44:{idx:02x}",
1971
+ role=self.role,
1972
+ public_endpoint=f"n{idx}",
1973
+ )
1974
+ )
1975
+ msg = NetMessage.objects.create(subject="s", body="b", reach=self.role)
1976
+ with patch.object(Node, "get_local", return_value=self.local):
1977
+ msg.propagate()
1978
+ self.assertEqual(mock_post.call_count, 6)
1979
+ self.assertEqual(msg.propagated_to.count(), 6)
1980
+ self.assertFalse(msg.complete)
1981
+
1415
1982
  @patch("requests.post")
1416
1983
  @patch("core.notifications.notify", return_value=True)
1417
1984
  def test_propagate_prunes_old_local_messages(self, mock_notify, mock_post):
@@ -1545,6 +2112,36 @@ class StartupNotificationTests(TestCase):
1545
2112
  self.assertEqual(kwargs["subject"], "host:9000")
1546
2113
  self.assertTrue(kwargs["body"].startswith("1.2.3 r"))
1547
2114
 
2115
+ def test_startup_notification_marks_nonrelease_version(self):
2116
+ from nodes.apps import _startup_notification
2117
+
2118
+ package = Package.objects.create(name="pkg-start", is_active=False)
2119
+ PackageRelease.objects.create(
2120
+ package=package,
2121
+ version="1.2.3",
2122
+ revision="0" * 40,
2123
+ )
2124
+
2125
+ with TemporaryDirectory() as tmp:
2126
+ tmp_path = Path(tmp)
2127
+ (tmp_path / "VERSION").write_text("1.2.3")
2128
+ with self.settings(BASE_DIR=tmp_path):
2129
+ with patch(
2130
+ "nodes.apps.revision.get_revision", return_value="1" * 40
2131
+ ):
2132
+ with patch("nodes.models.NetMessage.broadcast") as mock_broadcast:
2133
+ with patch(
2134
+ "nodes.apps.socket.gethostname", return_value="host"
2135
+ ):
2136
+ with patch.dict(os.environ, {"PORT": "9000"}):
2137
+ _startup_notification()
2138
+ time.sleep(0.1)
2139
+
2140
+ mock_broadcast.assert_called_once()
2141
+ _, kwargs = mock_broadcast.call_args
2142
+ self.assertEqual(kwargs["subject"], "host:9000")
2143
+ self.assertEqual(kwargs["body"], "1.2.3+ r111111")
2144
+
1548
2145
 
1549
2146
  class StartupHandlerTests(TestCase):
1550
2147
  def test_handler_logs_db_errors(self):
@@ -2001,24 +2598,39 @@ class NodeFeatureTests(TestCase):
2001
2598
  feature = NodeFeature.objects.create(
2002
2599
  slug="rfid-scanner", display="RFID Scanner"
2003
2600
  )
2004
- action = feature.get_default_action()
2005
- self.assertIsNotNone(action)
2601
+ actions = feature.get_default_actions()
2602
+ self.assertEqual(len(actions), 1)
2603
+ action = actions[0]
2006
2604
  self.assertEqual(action.label, "Scan RFIDs")
2007
2605
  self.assertEqual(action.url_name, "admin:core_rfid_scan")
2606
+ self.assertEqual(feature.get_default_action(), action)
2008
2607
 
2009
2608
  def test_celery_feature_default_action(self):
2010
2609
  feature = NodeFeature.objects.create(
2011
2610
  slug="celery-queue", display="Celery Queue"
2012
2611
  )
2013
- action = feature.get_default_action()
2014
- self.assertIsNotNone(action)
2612
+ actions = feature.get_default_actions()
2613
+ self.assertEqual(len(actions), 1)
2614
+ action = actions[0]
2015
2615
  self.assertEqual(action.label, "Celery Report")
2016
2616
  self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
2617
+ self.assertEqual(feature.get_default_action(), action)
2618
+
2619
+ def test_rpi_camera_feature_has_multiple_actions(self):
2620
+ feature = NodeFeature.objects.create(
2621
+ slug="rpi-camera", display="Raspberry Pi Camera"
2622
+ )
2623
+ actions = feature.get_default_actions()
2624
+ self.assertEqual(len(actions), 2)
2625
+ labels = {action.label for action in actions}
2626
+ self.assertIn("Take a Snapshot", labels)
2627
+ self.assertIn("View stream", labels)
2017
2628
 
2018
2629
  def test_default_action_missing_when_unconfigured(self):
2019
2630
  feature = NodeFeature.objects.create(
2020
2631
  slug="custom-feature", display="Custom Feature"
2021
2632
  )
2633
+ self.assertEqual(feature.get_default_actions(), ())
2022
2634
  self.assertIsNone(feature.get_default_action())
2023
2635
 
2024
2636
  def test_lcd_screen_enabled(self):
@@ -2035,6 +2647,13 @@ class NodeFeatureTests(TestCase):
2035
2647
  ):
2036
2648
  self.assertFalse(feature.is_enabled)
2037
2649
 
2650
+ def test_feature_disabled_when_local_node_missing(self):
2651
+ feature = NodeFeature.objects.create(slug="lcd-screen", display="LCD")
2652
+ with patch("nodes.models.Node.get_local", return_value=None):
2653
+ with patch("core.notifications.supports_gui_toast") as mock_toast:
2654
+ self.assertFalse(feature.is_enabled)
2655
+ mock_toast.assert_not_called()
2656
+
2038
2657
  def test_rfid_scanner_lock(self):
2039
2658
  feature = NodeFeature.objects.create(slug="rfid-scanner", display="RFID")
2040
2659
  feature.roles.add(self.role)