arthexis 0.1.24__py3-none-any.whl → 0.1.25__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
@@ -36,6 +36,7 @@ from django.test import Client, SimpleTestCase, TestCase, TransactionTestCase, o
36
36
  from django.urls import reverse
37
37
  from django.contrib.auth import get_user_model
38
38
  from django.contrib import admin
39
+ from django.contrib.admin import helpers
39
40
  from django.contrib.auth.models import Permission
40
41
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
41
42
  from django.conf import settings
@@ -133,6 +134,12 @@ class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
133
134
 
134
135
 
135
136
  class NodeGetLocalTests(TestCase):
137
+ def setUp(self):
138
+ super().setUp()
139
+ User = get_user_model()
140
+ self.user = User.objects.create_user(username="localtester", password="pwd")
141
+ self.client.force_login(self.user)
142
+
136
143
  def test_normalize_relation_handles_various_inputs(self):
137
144
  self.assertEqual(
138
145
  Node.normalize_relation(Node.Relation.UPSTREAM),
@@ -174,6 +181,7 @@ class NodeGetLocalTests(TestCase):
174
181
  patch(
175
182
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
176
183
  ),
184
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
177
185
  patch("nodes.models.revision.get_revision", return_value="rev"),
178
186
  patch.object(Node, "ensure_keys"),
179
187
  ):
@@ -202,6 +210,7 @@ class NodeGetLocalTests(TestCase):
202
210
  patch(
203
211
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
204
212
  ),
213
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
205
214
  patch("nodes.models.revision.get_revision", return_value="rev"),
206
215
  patch.object(Node, "ensure_keys"),
207
216
  patch.object(Node, "notify_peers_of_update"),
@@ -221,6 +230,7 @@ class NodeGetLocalTests(TestCase):
221
230
  patch(
222
231
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
223
232
  ),
233
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
224
234
  patch("nodes.models.revision.get_revision", return_value="rev"),
225
235
  patch.object(Node, "ensure_keys"),
226
236
  patch.object(Node, "notify_peers_of_update"),
@@ -242,6 +252,7 @@ class NodeGetLocalTests(TestCase):
242
252
  patch(
243
253
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
244
254
  ),
255
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
245
256
  patch("nodes.models.revision.get_revision", return_value="rev"),
246
257
  patch.object(Node, "ensure_keys"),
247
258
  patch.object(Node, "notify_peers_of_update"),
@@ -260,6 +271,7 @@ class NodeGetLocalTests(TestCase):
260
271
  patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"),
261
272
  patch("nodes.models.socket.gethostname", return_value="localhost"),
262
273
  patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
274
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
263
275
  patch("nodes.models.revision.get_revision", return_value="rev"),
264
276
  patch.object(Node, "ensure_keys"),
265
277
  patch.object(Node, "notify_peers_of_update"),
@@ -282,6 +294,7 @@ class NodeGetLocalTests(TestCase):
282
294
  patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:56"),
283
295
  patch("nodes.models.socket.gethostname", return_value="localhost"),
284
296
  patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
297
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
285
298
  patch("nodes.models.revision.get_revision", return_value="rev"),
286
299
  patch.object(Node, "ensure_keys"),
287
300
  patch.object(Node, "notify_peers_of_update"),
@@ -377,6 +390,37 @@ class NodeGetLocalTests(TestCase):
377
390
  self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
378
391
  self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
379
392
 
393
+ def test_register_node_accepts_network_hostname_without_address(self):
394
+ response = self.client.post(
395
+ reverse("register-node"),
396
+ data={
397
+ "hostname": "domain-node",
398
+ "network_hostname": "domain-node.example.com",
399
+ "port": 8050,
400
+ "mac_address": "aa:bb:cc:dd:ee:ff",
401
+ },
402
+ content_type="application/json",
403
+ )
404
+ self.assertEqual(response.status_code, 200)
405
+ node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:ff")
406
+ self.assertEqual(node.network_hostname, "domain-node.example.com")
407
+ self.assertIsNone(node.address)
408
+ self.assertIsNone(node.ipv4_address)
409
+ self.assertIsNone(node.ipv6_address)
410
+
411
+ def test_register_node_requires_contact_information(self):
412
+ response = self.client.post(
413
+ reverse("register-node"),
414
+ data={
415
+ "hostname": "missing-host",
416
+ "port": 8051,
417
+ "mac_address": "aa:bb:cc:dd:ee:00",
418
+ },
419
+ content_type="application/json",
420
+ )
421
+ self.assertEqual(response.status_code, 400)
422
+ self.assertIn("at least one", response.json()["detail"])
423
+
380
424
  def test_register_node_assigns_interface_role_and_returns_uuid(self):
381
425
  NodeRole.objects.get_or_create(name="Interface")
382
426
  private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
@@ -806,7 +850,10 @@ class NodeInfoViewTests(TestCase):
806
850
  self.addCleanup(self.patcher.stop)
807
851
  self.node = Node.objects.create(
808
852
  hostname="local",
853
+ network_hostname="local.example.com",
809
854
  address="10.0.0.10",
855
+ ipv4_address="10.0.0.10",
856
+ ipv6_address="2001:db8::10",
810
857
  port=8000,
811
858
  mac_address=self.mac,
812
859
  public_endpoint="local",
@@ -834,6 +881,8 @@ class NodeInfoViewTests(TestCase):
834
881
  self.assertEqual(response.status_code, 200)
835
882
  payload = response.json()
836
883
  self.assertEqual(payload["port"], 80)
884
+ self.assertEqual(payload.get("network_hostname"), "local.example.com")
885
+ self.assertIn("local.example.com", payload.get("contact_hosts", []))
837
886
 
838
887
  def test_preserves_explicit_port_in_host_header(self):
839
888
  with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
@@ -855,6 +904,8 @@ class NodeInfoViewTests(TestCase):
855
904
  self.assertEqual(response.status_code, 200)
856
905
  payload = response.json()
857
906
  self.assertEqual(payload.get("role"), "Terminal")
907
+ self.assertEqual(payload.get("ipv4_address"), "10.0.0.10")
908
+ self.assertEqual(payload.get("ipv6_address"), "2001:db8::10")
858
909
 
859
910
 
860
911
  class RegisterVisitorNodeMessageTests(TestCase):
@@ -936,6 +987,7 @@ class NodeRegisterCurrentTests(TestCase):
936
987
  patch(
937
988
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
938
989
  ),
990
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
939
991
  patch("nodes.models.revision.get_revision", return_value="rev"),
940
992
  patch.object(Node, "ensure_keys"),
941
993
  patch.object(Node, "notify_peers_of_update") as mock_notify,
@@ -963,6 +1015,7 @@ class NodeRegisterCurrentTests(TestCase):
963
1015
  patch(
964
1016
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
965
1017
  ),
1018
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
966
1019
  patch("nodes.models.revision.get_revision", return_value="rev"),
967
1020
  patch.object(Node, "ensure_keys"),
968
1021
  ):
@@ -981,6 +1034,7 @@ class NodeRegisterCurrentTests(TestCase):
981
1034
  patch(
982
1035
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
983
1036
  ),
1037
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
984
1038
  patch("nodes.models.revision.get_revision", return_value="rev"),
985
1039
  patch.object(Node, "ensure_keys"),
986
1040
  ):
@@ -1000,6 +1054,7 @@ class NodeRegisterCurrentTests(TestCase):
1000
1054
  patch(
1001
1055
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
1002
1056
  ),
1057
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
1003
1058
  patch("nodes.models.revision.get_revision", return_value="rev"),
1004
1059
  patch.object(Node, "ensure_keys"),
1005
1060
  ):
@@ -1025,10 +1080,20 @@ class NodeRegisterCurrentTests(TestCase):
1025
1080
  return_value="00:ff:ee:dd:cc:bb",
1026
1081
  ),
1027
1082
  patch("nodes.models.socket.gethostname", return_value="localnode"),
1083
+ patch("nodes.models.socket.getfqdn", return_value="localnode.example.com"),
1028
1084
  patch(
1029
1085
  "nodes.models.socket.gethostbyname",
1030
1086
  return_value="192.168.1.5",
1031
1087
  ),
1088
+ patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
1089
+ patch.object(
1090
+ Node,
1091
+ "_resolve_ip_addresses",
1092
+ return_value=(
1093
+ ["192.168.1.5", "93.184.216.34"],
1094
+ ["fe80::1", "2001:4860:4860::8888"],
1095
+ ),
1096
+ ),
1032
1097
  patch("nodes.models.revision.get_revision", return_value="newrev"),
1033
1098
  patch("requests.post") as mock_post,
1034
1099
  ):
@@ -1052,6 +1117,9 @@ class NodeRegisterCurrentTests(TestCase):
1052
1117
  self.assertEqual(payload["hostname"], "localnode")
1053
1118
  self.assertEqual(payload["installed_version"], "2.0.0")
1054
1119
  self.assertEqual(payload["installed_revision"], "newrev")
1120
+ self.assertEqual(payload.get("network_hostname"), "localnode.example.com")
1121
+ self.assertEqual(payload.get("ipv4_address"), "93.184.216.34")
1122
+ self.assertEqual(payload.get("ipv6_address"), "2001:4860:4860::8888")
1055
1123
 
1056
1124
  def test_register_current_notifies_peers_without_version_change(self):
1057
1125
  Node.objects.create(
@@ -1070,10 +1138,20 @@ class NodeRegisterCurrentTests(TestCase):
1070
1138
  return_value="00:ff:ee:dd:cc:cc",
1071
1139
  ),
1072
1140
  patch("nodes.models.socket.gethostname", return_value="samever"),
1141
+ patch("nodes.models.socket.getfqdn", return_value="samever.example.com"),
1073
1142
  patch(
1074
1143
  "nodes.models.socket.gethostbyname",
1075
1144
  return_value="192.168.1.6",
1076
1145
  ),
1146
+ patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
1147
+ patch.object(
1148
+ Node,
1149
+ "_resolve_ip_addresses",
1150
+ return_value=(
1151
+ ["192.168.1.6", "93.184.216.35"],
1152
+ ["fe80::2", "2001:4860:4860::8844"],
1153
+ ),
1154
+ ),
1077
1155
  patch("nodes.models.revision.get_revision", return_value="rev1"),
1078
1156
  patch("requests.post") as mock_post,
1079
1157
  ):
@@ -1095,6 +1173,44 @@ class NodeRegisterCurrentTests(TestCase):
1095
1173
  payload = json.loads(kwargs["data"])
1096
1174
  self.assertEqual(payload["installed_version"], "1.0.0")
1097
1175
  self.assertEqual(payload.get("installed_revision"), "rev1")
1176
+ self.assertEqual(payload.get("network_hostname"), "samever.example.com")
1177
+ self.assertEqual(payload.get("ipv4_address"), "93.184.216.35")
1178
+ self.assertEqual(payload.get("ipv6_address"), "2001:4860:4860::8844")
1179
+
1180
+ def test_register_current_populates_network_fields(self):
1181
+ with TemporaryDirectory() as tmp:
1182
+ base = Path(tmp)
1183
+ with override_settings(BASE_DIR=base):
1184
+ with (
1185
+ patch(
1186
+ "nodes.models.Node.get_current_mac",
1187
+ return_value="00:12:34:56:78:90",
1188
+ ),
1189
+ patch("nodes.models.socket.gethostname", return_value="nodehost"),
1190
+ patch("nodes.models.socket.getfqdn", return_value="nodehost.example.com"),
1191
+ patch(
1192
+ "nodes.models.socket.gethostbyname",
1193
+ return_value="10.0.0.5",
1194
+ ),
1195
+ patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
1196
+ patch.object(
1197
+ Node,
1198
+ "_resolve_ip_addresses",
1199
+ return_value=(
1200
+ ["10.0.0.5", "93.184.216.36"],
1201
+ ["fe80::5", "2001:4860:4860::1"],
1202
+ ),
1203
+ ),
1204
+ patch("nodes.models.revision.get_revision", return_value="revX"),
1205
+ patch.object(Node, "ensure_keys"),
1206
+ patch.object(Node, "notify_peers_of_update"),
1207
+ ):
1208
+ node, created = Node.register_current()
1209
+ self.assertTrue(created)
1210
+ self.assertEqual(node.network_hostname, "nodehost.example.com")
1211
+ self.assertEqual(node.ipv4_address, "93.184.216.36")
1212
+ self.assertEqual(node.ipv6_address, "2001:4860:4860::1")
1213
+ self.assertEqual(node.address, "93.184.216.36")
1098
1214
 
1099
1215
  @patch("nodes.views.capture_screenshot")
1100
1216
  def test_capture_screenshot(self, mock_capture):
@@ -1731,7 +1847,10 @@ class NodeAdminTests(TestCase):
1731
1847
 
1732
1848
  payload = {
1733
1849
  "hostname": node.hostname,
1850
+ "network_hostname": "remote-node.example.com",
1734
1851
  "address": node.address,
1852
+ "ipv4_address": "198.51.100.10",
1853
+ "ipv6_address": "2001:db8::10",
1735
1854
  "port": node.port,
1736
1855
  "role": "Control",
1737
1856
  }
@@ -1740,7 +1859,13 @@ class NodeAdminTests(TestCase):
1740
1859
  node.refresh_from_db()
1741
1860
 
1742
1861
  self.assertIn("role", changed)
1862
+ self.assertIn("network_hostname", changed)
1863
+ self.assertIn("ipv4_address", changed)
1864
+ self.assertIn("ipv6_address", changed)
1743
1865
  self.assertEqual(node.role, control)
1866
+ self.assertEqual(node.network_hostname, "remote-node.example.com")
1867
+ self.assertEqual(node.ipv4_address, "198.51.100.10")
1868
+ self.assertEqual(node.ipv6_address, "2001:db8::10")
1744
1869
  self.assertTrue(control.node_set.filter(pk=node.pk).exists())
1745
1870
  self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
1746
1871
 
@@ -1758,7 +1883,10 @@ class NodeAdminTests(TestCase):
1758
1883
 
1759
1884
  payload = {
1760
1885
  "hostname": node.hostname,
1886
+ "network_hostname": "role-name-node.example.com",
1761
1887
  "address": node.address,
1888
+ "ipv4_address": "198.51.100.11",
1889
+ "ipv6_address": "2001:db8::11",
1762
1890
  "port": node.port,
1763
1891
  "role_name": "Control",
1764
1892
  }
@@ -1767,6 +1895,9 @@ class NodeAdminTests(TestCase):
1767
1895
  node.refresh_from_db()
1768
1896
 
1769
1897
  self.assertIn("role", changed)
1898
+ self.assertEqual(node.network_hostname, "role-name-node.example.com")
1899
+ self.assertEqual(node.ipv4_address, "198.51.100.11")
1900
+ self.assertEqual(node.ipv6_address, "2001:db8::11")
1770
1901
  self.assertEqual(node.role, control)
1771
1902
  self.assertTrue(control.node_set.filter(pk=node.pk).exists())
1772
1903
  self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
@@ -2407,6 +2538,53 @@ class NodeAdminTests(TestCase):
2407
2538
  )
2408
2539
  self.assertContains(response, str(remote))
2409
2540
 
2541
+ def test_send_net_message_action_displays_form(self):
2542
+ target = Node.objects.create(
2543
+ hostname="remote-one", address="10.0.0.10", port=8020
2544
+ )
2545
+ response = self.client.post(
2546
+ reverse("admin:nodes_node_changelist"),
2547
+ {
2548
+ "action": "send_net_message",
2549
+ helpers.ACTION_CHECKBOX_NAME: [str(target.pk)],
2550
+ },
2551
+ follow=False,
2552
+ )
2553
+ self.assertEqual(response.status_code, 200)
2554
+ response.render()
2555
+ self.assertContains(response, "Send Net Message")
2556
+ self.assertContains(response, str(target))
2557
+ self.assertContains(response, 'name="apply"')
2558
+ self.assertContains(response, "Selected node (1)")
2559
+
2560
+ @patch("nodes.admin.NetMessage.propagate")
2561
+ def test_send_net_message_action_creates_messages(self, mock_propagate):
2562
+ first = Node.objects.create(
2563
+ hostname="remote-two", address="10.0.0.11", port=8021
2564
+ )
2565
+ second = Node.objects.create(
2566
+ hostname="remote-three", address="10.0.0.12", port=8022
2567
+ )
2568
+ url = reverse("admin:nodes_node_changelist")
2569
+ payload = {
2570
+ "action": "send_net_message",
2571
+ "apply": "1",
2572
+ helpers.ACTION_CHECKBOX_NAME: [str(first.pk), str(second.pk)],
2573
+ "subject": "Maintenance",
2574
+ "body": "We will reboot tonight.",
2575
+ }
2576
+ existing_ids = set(NetMessage.objects.values_list("pk", flat=True))
2577
+ response = self.client.post(url, payload, follow=True)
2578
+ self.assertEqual(response.status_code, 200)
2579
+ new_messages = NetMessage.objects.exclude(pk__in=existing_ids)
2580
+ self.assertEqual(new_messages.count(), 2)
2581
+ self.assertEqual(mock_propagate.call_count, 2)
2582
+ for node in (first, second):
2583
+ message = new_messages.get(filter_node=node)
2584
+ self.assertEqual(message.subject, "Maintenance")
2585
+ self.assertEqual(message.body, "We will reboot tonight.")
2586
+ self.assertContains(response, "Sent 2 net messages.")
2587
+
2410
2588
  @patch("nodes.admin.requests.post")
2411
2589
  @patch("nodes.admin.requests.get")
2412
2590
  def test_update_selected_nodes_progress_updates_remote(
@@ -3064,7 +3242,7 @@ class NetMessagePropagationTests(TestCase):
3064
3242
  with patch.object(Node, "get_local", return_value=self.local):
3065
3243
  msg = NetMessage.broadcast(subject="subject", body="body")
3066
3244
  self.assertEqual(msg.node_origin, self.local)
3067
- self.assertIsNone(msg.reach)
3245
+ self.assertEqual(msg.reach, self.role)
3068
3246
 
3069
3247
  @patch("requests.post")
3070
3248
  @patch("core.notifications.notify")
@@ -4707,6 +4885,38 @@ class ContentClassifierTests(TestCase):
4707
4885
  tags = ContentClassification.objects.filter(sample=sample)
4708
4886
  self.assertTrue(tags.filter(tag__slug="screenshot-tag").exists())
4709
4887
 
4888
+ def test_save_screenshot_returns_none_for_duplicate_without_linking(self):
4889
+ with TemporaryDirectory() as tmp:
4890
+ base = Path(tmp)
4891
+ first_path = base / "capture.png"
4892
+ first_path.write_bytes(b"binary image data")
4893
+ duplicate_path = base / "duplicate.png"
4894
+ duplicate_path.write_bytes(b"binary image data")
4895
+ with override_settings(LOG_DIR=base):
4896
+ original = save_screenshot(first_path, method="TEST")
4897
+ duplicate = save_screenshot(duplicate_path, method="TEST")
4898
+
4899
+ self.assertIsNotNone(original)
4900
+ self.assertIsNone(duplicate)
4901
+ self.assertEqual(ContentSample.objects.count(), 1)
4902
+
4903
+ def test_save_screenshot_reuses_existing_sample_when_linking(self):
4904
+ with TemporaryDirectory() as tmp:
4905
+ base = Path(tmp)
4906
+ first_path = base / "capture.png"
4907
+ first_path.write_bytes(b"binary image data")
4908
+ duplicate_path = base / "duplicate.png"
4909
+ duplicate_path.write_bytes(b"binary image data")
4910
+ with override_settings(LOG_DIR=base):
4911
+ original = save_screenshot(first_path, method="TEST")
4912
+ reused = save_screenshot(
4913
+ duplicate_path, method="TEST", link_duplicates=True
4914
+ )
4915
+
4916
+ self.assertIsNotNone(original)
4917
+ self.assertEqual(reused, original)
4918
+ self.assertEqual(ContentSample.objects.count(), 1)
4919
+
4710
4920
  def test_text_sample_runs_default_classifiers_without_duplicates(self):
4711
4921
  sample = ContentSample.objects.create(
4712
4922
  content="Example content", kind=ContentSample.TEXT
nodes/urls.py CHANGED
@@ -12,6 +12,11 @@ urlpatterns = [
12
12
  path("rfid/export/", views.export_rfids, name="node-rfid-export"),
13
13
  path("rfid/import/", views.import_rfids, name="node-rfid-import"),
14
14
  path("network/chargers/", views.network_chargers, name="node-network-chargers"),
15
+ path(
16
+ "network/chargers/forward/",
17
+ views.forward_chargers,
18
+ name="node-network-forward-chargers",
19
+ ),
15
20
  path(
16
21
  "network/chargers/action/",
17
22
  views.network_charger_action,
nodes/utils.py CHANGED
@@ -90,10 +90,13 @@ def save_screenshot(
90
90
  *,
91
91
  content: str | None = None,
92
92
  user=None,
93
+ link_duplicates: bool = False,
93
94
  ):
94
95
  """Save screenshot file info if not already recorded.
95
96
 
96
- Returns the created :class:`ContentSample` or ``None`` if duplicate.
97
+ Returns the created :class:`ContentSample`. If ``link_duplicates`` is ``True``
98
+ and a sample with identical content already exists, the existing record is
99
+ returned instead of ``None``.
97
100
  """
98
101
 
99
102
  original = path
@@ -101,7 +104,11 @@ def save_screenshot(
101
104
  path = settings.LOG_DIR / path
102
105
  with path.open("rb") as fh:
103
106
  digest = hashlib.sha256(fh.read()).hexdigest()
104
- if ContentSample.objects.filter(hash=digest).exists():
107
+ existing = ContentSample.objects.filter(hash=digest).first()
108
+ if existing:
109
+ if link_duplicates:
110
+ logger.info("Duplicate screenshot content; reusing existing sample")
111
+ return existing
105
112
  logger.info("Duplicate screenshot content; record not created")
106
113
  return None
107
114
  stored_path = (original if not original.is_absolute() else path).as_posix()