arthexis 0.1.23__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
@@ -66,6 +67,7 @@ from .models import (
66
67
  )
67
68
  from .backends import OutboxEmailBackend
68
69
  from .tasks import capture_node_screenshot, poll_unreachable_upstream, sample_clipboard
70
+ from ocpp.models import Charger
69
71
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
70
72
  from cryptography.hazmat.primitives import serialization, hashes
71
73
  from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount, Todo
@@ -132,6 +134,12 @@ class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
132
134
 
133
135
 
134
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
+
135
143
  def test_normalize_relation_handles_various_inputs(self):
136
144
  self.assertEqual(
137
145
  Node.normalize_relation(Node.Relation.UPSTREAM),
@@ -173,6 +181,7 @@ class NodeGetLocalTests(TestCase):
173
181
  patch(
174
182
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
175
183
  ),
184
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
176
185
  patch("nodes.models.revision.get_revision", return_value="rev"),
177
186
  patch.object(Node, "ensure_keys"),
178
187
  ):
@@ -201,6 +210,7 @@ class NodeGetLocalTests(TestCase):
201
210
  patch(
202
211
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
203
212
  ),
213
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
204
214
  patch("nodes.models.revision.get_revision", return_value="rev"),
205
215
  patch.object(Node, "ensure_keys"),
206
216
  patch.object(Node, "notify_peers_of_update"),
@@ -220,6 +230,7 @@ class NodeGetLocalTests(TestCase):
220
230
  patch(
221
231
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
222
232
  ),
233
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
223
234
  patch("nodes.models.revision.get_revision", return_value="rev"),
224
235
  patch.object(Node, "ensure_keys"),
225
236
  patch.object(Node, "notify_peers_of_update"),
@@ -241,6 +252,7 @@ class NodeGetLocalTests(TestCase):
241
252
  patch(
242
253
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
243
254
  ),
255
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
244
256
  patch("nodes.models.revision.get_revision", return_value="rev"),
245
257
  patch.object(Node, "ensure_keys"),
246
258
  patch.object(Node, "notify_peers_of_update"),
@@ -259,6 +271,7 @@ class NodeGetLocalTests(TestCase):
259
271
  patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"),
260
272
  patch("nodes.models.socket.gethostname", return_value="localhost"),
261
273
  patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
274
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
262
275
  patch("nodes.models.revision.get_revision", return_value="rev"),
263
276
  patch.object(Node, "ensure_keys"),
264
277
  patch.object(Node, "notify_peers_of_update"),
@@ -281,6 +294,7 @@ class NodeGetLocalTests(TestCase):
281
294
  patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:56"),
282
295
  patch("nodes.models.socket.gethostname", return_value="localhost"),
283
296
  patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
297
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
284
298
  patch("nodes.models.revision.get_revision", return_value="rev"),
285
299
  patch.object(Node, "ensure_keys"),
286
300
  patch.object(Node, "notify_peers_of_update"),
@@ -376,6 +390,37 @@ class NodeGetLocalTests(TestCase):
376
390
  self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
377
391
  self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
378
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
+
379
424
  def test_register_node_assigns_interface_role_and_returns_uuid(self):
380
425
  NodeRole.objects.get_or_create(name="Interface")
381
426
  private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
@@ -805,7 +850,10 @@ class NodeInfoViewTests(TestCase):
805
850
  self.addCleanup(self.patcher.stop)
806
851
  self.node = Node.objects.create(
807
852
  hostname="local",
853
+ network_hostname="local.example.com",
808
854
  address="10.0.0.10",
855
+ ipv4_address="10.0.0.10",
856
+ ipv6_address="2001:db8::10",
809
857
  port=8000,
810
858
  mac_address=self.mac,
811
859
  public_endpoint="local",
@@ -833,6 +881,8 @@ class NodeInfoViewTests(TestCase):
833
881
  self.assertEqual(response.status_code, 200)
834
882
  payload = response.json()
835
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", []))
836
886
 
837
887
  def test_preserves_explicit_port_in_host_header(self):
838
888
  with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
@@ -854,6 +904,8 @@ class NodeInfoViewTests(TestCase):
854
904
  self.assertEqual(response.status_code, 200)
855
905
  payload = response.json()
856
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")
857
909
 
858
910
 
859
911
  class RegisterVisitorNodeMessageTests(TestCase):
@@ -935,6 +987,7 @@ class NodeRegisterCurrentTests(TestCase):
935
987
  patch(
936
988
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
937
989
  ),
990
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
938
991
  patch("nodes.models.revision.get_revision", return_value="rev"),
939
992
  patch.object(Node, "ensure_keys"),
940
993
  patch.object(Node, "notify_peers_of_update") as mock_notify,
@@ -962,6 +1015,7 @@ class NodeRegisterCurrentTests(TestCase):
962
1015
  patch(
963
1016
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
964
1017
  ),
1018
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
965
1019
  patch("nodes.models.revision.get_revision", return_value="rev"),
966
1020
  patch.object(Node, "ensure_keys"),
967
1021
  ):
@@ -980,6 +1034,7 @@ class NodeRegisterCurrentTests(TestCase):
980
1034
  patch(
981
1035
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
982
1036
  ),
1037
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
983
1038
  patch("nodes.models.revision.get_revision", return_value="rev"),
984
1039
  patch.object(Node, "ensure_keys"),
985
1040
  ):
@@ -999,6 +1054,7 @@ class NodeRegisterCurrentTests(TestCase):
999
1054
  patch(
1000
1055
  "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
1001
1056
  ),
1057
+ patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
1002
1058
  patch("nodes.models.revision.get_revision", return_value="rev"),
1003
1059
  patch.object(Node, "ensure_keys"),
1004
1060
  ):
@@ -1024,10 +1080,20 @@ class NodeRegisterCurrentTests(TestCase):
1024
1080
  return_value="00:ff:ee:dd:cc:bb",
1025
1081
  ),
1026
1082
  patch("nodes.models.socket.gethostname", return_value="localnode"),
1083
+ patch("nodes.models.socket.getfqdn", return_value="localnode.example.com"),
1027
1084
  patch(
1028
1085
  "nodes.models.socket.gethostbyname",
1029
1086
  return_value="192.168.1.5",
1030
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
+ ),
1031
1097
  patch("nodes.models.revision.get_revision", return_value="newrev"),
1032
1098
  patch("requests.post") as mock_post,
1033
1099
  ):
@@ -1051,6 +1117,9 @@ class NodeRegisterCurrentTests(TestCase):
1051
1117
  self.assertEqual(payload["hostname"], "localnode")
1052
1118
  self.assertEqual(payload["installed_version"], "2.0.0")
1053
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")
1054
1123
 
1055
1124
  def test_register_current_notifies_peers_without_version_change(self):
1056
1125
  Node.objects.create(
@@ -1069,10 +1138,20 @@ class NodeRegisterCurrentTests(TestCase):
1069
1138
  return_value="00:ff:ee:dd:cc:cc",
1070
1139
  ),
1071
1140
  patch("nodes.models.socket.gethostname", return_value="samever"),
1141
+ patch("nodes.models.socket.getfqdn", return_value="samever.example.com"),
1072
1142
  patch(
1073
1143
  "nodes.models.socket.gethostbyname",
1074
1144
  return_value="192.168.1.6",
1075
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
+ ),
1076
1155
  patch("nodes.models.revision.get_revision", return_value="rev1"),
1077
1156
  patch("requests.post") as mock_post,
1078
1157
  ):
@@ -1094,6 +1173,44 @@ class NodeRegisterCurrentTests(TestCase):
1094
1173
  payload = json.loads(kwargs["data"])
1095
1174
  self.assertEqual(payload["installed_version"], "1.0.0")
1096
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")
1097
1214
 
1098
1215
  @patch("nodes.views.capture_screenshot")
1099
1216
  def test_capture_screenshot(self, mock_capture):
@@ -1730,7 +1847,10 @@ class NodeAdminTests(TestCase):
1730
1847
 
1731
1848
  payload = {
1732
1849
  "hostname": node.hostname,
1850
+ "network_hostname": "remote-node.example.com",
1733
1851
  "address": node.address,
1852
+ "ipv4_address": "198.51.100.10",
1853
+ "ipv6_address": "2001:db8::10",
1734
1854
  "port": node.port,
1735
1855
  "role": "Control",
1736
1856
  }
@@ -1739,7 +1859,13 @@ class NodeAdminTests(TestCase):
1739
1859
  node.refresh_from_db()
1740
1860
 
1741
1861
  self.assertIn("role", changed)
1862
+ self.assertIn("network_hostname", changed)
1863
+ self.assertIn("ipv4_address", changed)
1864
+ self.assertIn("ipv6_address", changed)
1742
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")
1743
1869
  self.assertTrue(control.node_set.filter(pk=node.pk).exists())
1744
1870
  self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
1745
1871
 
@@ -1757,7 +1883,10 @@ class NodeAdminTests(TestCase):
1757
1883
 
1758
1884
  payload = {
1759
1885
  "hostname": node.hostname,
1886
+ "network_hostname": "role-name-node.example.com",
1760
1887
  "address": node.address,
1888
+ "ipv4_address": "198.51.100.11",
1889
+ "ipv6_address": "2001:db8::11",
1761
1890
  "port": node.port,
1762
1891
  "role_name": "Control",
1763
1892
  }
@@ -1766,6 +1895,9 @@ class NodeAdminTests(TestCase):
1766
1895
  node.refresh_from_db()
1767
1896
 
1768
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")
1769
1901
  self.assertEqual(node.role, control)
1770
1902
  self.assertTrue(control.node_set.filter(pk=node.pk).exists())
1771
1903
  self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
@@ -1816,132 +1948,6 @@ class NodeAdminTests(TestCase):
1816
1948
  self.assertEqual(response.status_code, 200)
1817
1949
  self.assertContains(response, "data:image/png;base64")
1818
1950
 
1819
- @patch("nodes.admin.requests.post")
1820
- def test_proxy_view_uses_remote_login_url(self, mock_post):
1821
- self.client.get(reverse("admin:nodes_node_register_current"))
1822
- local_node = Node.objects.get()
1823
- remote = Node.objects.create(
1824
- hostname="remote",
1825
- address="192.0.2.10",
1826
- port=8443,
1827
- mac_address="aa:bb:cc:dd:ee:ff",
1828
- )
1829
- mock_post.return_value = SimpleNamespace(
1830
- ok=True,
1831
- json=lambda: {
1832
- "login_url": "https://remote.example/nodes/proxy/login/token",
1833
- "expires": "2025-01-01T00:00:00",
1834
- },
1835
- status_code=200,
1836
- text="ok",
1837
- )
1838
- response = self.client.get(
1839
- reverse("admin:nodes_node_proxy", args=[remote.pk])
1840
- )
1841
- self.assertEqual(response.status_code, 200)
1842
- self.assertTemplateUsed(response, "admin/nodes/node/proxy.html")
1843
- self.assertContains(response, "<iframe", html=False)
1844
- mock_post.assert_called()
1845
- payload = json.loads(mock_post.call_args[1]["data"])
1846
- self.assertEqual(payload.get("requester"), str(local_node.uuid))
1847
- self.assertEqual(payload.get("requester_mac"), local_node.mac_address)
1848
- self.assertEqual(payload.get("requester_public_key"), local_node.public_key)
1849
-
1850
- @patch("nodes.admin.requests.post")
1851
- def test_proxy_view_falls_back_to_http_after_ssl_error(self, mock_post):
1852
- self.client.get(reverse("admin:nodes_node_register_current"))
1853
- remote = Node.objects.create(
1854
- hostname="remote-https",
1855
- address="198.51.100.20",
1856
- port=443,
1857
- mac_address="aa:bb:cc:dd:ee:10",
1858
- )
1859
- local_node = Node.get_local()
1860
- success_response = SimpleNamespace(
1861
- ok=True,
1862
- json=lambda: {
1863
- "login_url": "http://remote.example/nodes/proxy/login/token",
1864
- "expires": "2025-01-01T00:00:00",
1865
- },
1866
- status_code=200,
1867
- text="ok",
1868
- )
1869
- mock_post.side_effect = [
1870
- SSLError("wrong version number"),
1871
- success_response,
1872
- ]
1873
-
1874
- response = self.client.get(
1875
- reverse("admin:nodes_node_proxy", args=[remote.pk])
1876
- )
1877
-
1878
- self.assertEqual(response.status_code, 200)
1879
- self.assertEqual(mock_post.call_count, 2)
1880
- first_url = mock_post.call_args_list[0].args[0]
1881
- second_url = mock_post.call_args_list[1].args[0]
1882
- self.assertTrue(first_url.startswith("https://"))
1883
- self.assertTrue(second_url.startswith("http://"))
1884
- self.assertIn("/nodes/proxy/session/", second_url)
1885
- payload = json.loads(mock_post.call_args_list[-1].kwargs["data"])
1886
- self.assertEqual(payload.get("requester"), str(local_node.uuid))
1887
- self.assertEqual(payload.get("requester_mac"), local_node.mac_address)
1888
- self.assertEqual(payload.get("requester_public_key"), local_node.public_key)
1889
-
1890
- @patch("nodes.admin.requests.post")
1891
- def test_proxy_view_retries_post_after_redirect(self, mock_post):
1892
- self.client.get(reverse("admin:nodes_node_register_current"))
1893
- remote = Node.objects.create(
1894
- hostname="redirect-node",
1895
- public_endpoint="http://remote.example",
1896
- address="198.51.100.30",
1897
- mac_address="aa:bb:cc:dd:ee:20",
1898
- )
1899
-
1900
- redirect_response = SimpleNamespace(
1901
- status_code=301,
1902
- ok=True,
1903
- text="redirect",
1904
- headers={"Location": "https://remote.example/nodes/proxy/session/"},
1905
- )
1906
- success_response = SimpleNamespace(
1907
- status_code=200,
1908
- ok=True,
1909
- text="ok",
1910
- headers={},
1911
- json=lambda: {
1912
- "login_url": "https://remote.example/nodes/proxy/login/token",
1913
- "expires": "2025-01-01T00:00:00",
1914
- },
1915
- )
1916
-
1917
- mock_post.side_effect = [redirect_response, success_response]
1918
-
1919
- response = self.client.get(
1920
- reverse("admin:nodes_node_proxy", args=[remote.pk])
1921
- )
1922
-
1923
- self.assertEqual(response.status_code, 200)
1924
- self.assertEqual(mock_post.call_count, 2)
1925
-
1926
- first_call_kwargs = mock_post.call_args_list[0].kwargs
1927
- self.assertFalse(first_call_kwargs.get("allow_redirects", True))
1928
-
1929
- second_url = mock_post.call_args_list[1].args[0]
1930
- self.assertEqual(second_url, "https://remote.example/nodes/proxy/session/")
1931
- second_call_kwargs = mock_post.call_args_list[1].kwargs
1932
- self.assertFalse(second_call_kwargs.get("allow_redirects", True))
1933
-
1934
- def test_proxy_link_displayed_for_remote_nodes(self):
1935
- Node.objects.create(
1936
- hostname="remote",
1937
- address="203.0.113.1",
1938
- port=8000,
1939
- mac_address="aa:aa:aa:aa:aa:01",
1940
- )
1941
- response = self.client.get(reverse("admin:nodes_node_changelist"))
1942
- proxy_url = reverse("admin:nodes_node_proxy", args=[1])
1943
- self.assertContains(response, proxy_url)
1944
-
1945
1951
  def test_visit_link_uses_local_admin_dashboard_for_local_node(self):
1946
1952
  node_admin = admin.site._registry[Node]
1947
1953
  local_node = self._create_local_node()
@@ -1974,14 +1980,14 @@ class NodeAdminTests(TestCase):
1974
1980
  port=8443,
1975
1981
  )
1976
1982
 
1977
- urls = list(node_admin._iter_remote_urls(remote, "/nodes/proxy/session/"))
1983
+ urls = list(node_admin._iter_remote_urls(remote, "/nodes/info/"))
1978
1984
 
1979
1985
  self.assertIn(
1980
- "https://example.com:8443/interface/nodes/proxy/session/",
1986
+ "https://example.com:8443/interface/nodes/info/",
1981
1987
  urls,
1982
1988
  )
1983
1989
  self.assertIn(
1984
- "http://example.com:8443/interface/nodes/proxy/session/",
1990
+ "http://example.com:8443/interface/nodes/info/",
1985
1991
  urls,
1986
1992
  )
1987
1993
  combined = "".join(urls)
@@ -2532,6 +2538,53 @@ class NodeAdminTests(TestCase):
2532
2538
  )
2533
2539
  self.assertContains(response, str(remote))
2534
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
+
2535
2588
  @patch("nodes.admin.requests.post")
2536
2589
  @patch("nodes.admin.requests.get")
2537
2590
  def test_update_selected_nodes_progress_updates_remote(
@@ -3189,7 +3242,7 @@ class NetMessagePropagationTests(TestCase):
3189
3242
  with patch.object(Node, "get_local", return_value=self.local):
3190
3243
  msg = NetMessage.broadcast(subject="subject", body="body")
3191
3244
  self.assertEqual(msg.node_origin, self.local)
3192
- self.assertIsNone(msg.reach)
3245
+ self.assertEqual(msg.reach, self.role)
3193
3246
 
3194
3247
  @patch("requests.post")
3195
3248
  @patch("core.notifications.notify")
@@ -3605,6 +3658,82 @@ class NetMessageSignatureTests(TestCase):
3605
3658
  self.assertTrue(signature_one)
3606
3659
  self.assertTrue(signature_two)
3607
3660
  self.assertNotEqual(signature_one, signature_two)
3661
+
3662
+
3663
+ class NetworkChargerActionSecurityTests(TestCase):
3664
+ def setUp(self):
3665
+ self.client = Client()
3666
+ self.local_node = Node.objects.create(
3667
+ hostname="local-node",
3668
+ address="127.0.0.1",
3669
+ port=8000,
3670
+ mac_address="00:aa:bb:cc:dd:10",
3671
+ public_endpoint="local-endpoint",
3672
+ )
3673
+ self.authorized_node = Node.objects.create(
3674
+ hostname="authorized-node",
3675
+ address="127.0.0.2",
3676
+ port=8001,
3677
+ mac_address="00:aa:bb:cc:dd:11",
3678
+ public_endpoint="authorized-endpoint",
3679
+ )
3680
+ self.unauthorized_node, self.unauthorized_key = self._create_signed_node(
3681
+ "unauthorized-node",
3682
+ mac_suffix=0x12,
3683
+ )
3684
+ self.charger = Charger.objects.create(
3685
+ charger_id="SECURE-TEST-1",
3686
+ allow_remote=True,
3687
+ manager_node=self.authorized_node,
3688
+ node_origin=self.local_node,
3689
+ )
3690
+
3691
+ def _create_signed_node(self, hostname: str, *, mac_suffix: int):
3692
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3693
+ public_bytes = key.public_key().public_bytes(
3694
+ encoding=serialization.Encoding.PEM,
3695
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
3696
+ )
3697
+ node = Node.objects.create(
3698
+ hostname=hostname,
3699
+ address="10.0.0.{:d}".format(mac_suffix),
3700
+ port=8020,
3701
+ mac_address="00:aa:bb:cc:dd:{:02x}".format(mac_suffix),
3702
+ public_key=public_bytes.decode(),
3703
+ public_endpoint=f"{hostname}-endpoint",
3704
+ )
3705
+ return node, key
3706
+
3707
+ def test_rejects_requests_from_unmanaged_nodes(self):
3708
+ url = reverse("node-network-charger-action")
3709
+ payload = {
3710
+ "requester": str(self.unauthorized_node.uuid),
3711
+ "charger_id": self.charger.charger_id,
3712
+ "action": "reset",
3713
+ }
3714
+ body = json.dumps(payload).encode()
3715
+ signature = self.unauthorized_key.sign(
3716
+ body,
3717
+ padding.PKCS1v15(),
3718
+ hashes.SHA256(),
3719
+ )
3720
+ headers = {"HTTP_X_SIGNATURE": base64.b64encode(signature).decode()}
3721
+
3722
+ with patch.object(Node, "get_local", return_value=self.local_node):
3723
+ response = self.client.post(
3724
+ url,
3725
+ data=body,
3726
+ content_type="application/json",
3727
+ **headers,
3728
+ )
3729
+
3730
+ self.assertEqual(response.status_code, 403)
3731
+ self.assertEqual(
3732
+ response.json().get("detail"),
3733
+ "requester does not manage this charger",
3734
+ )
3735
+
3736
+
3608
3737
  class StartupNotificationTests(TestCase):
3609
3738
  def test_startup_notification_uses_hostname_and_revision(self):
3610
3739
  from nodes.apps import _startup_notification
@@ -4756,6 +4885,38 @@ class ContentClassifierTests(TestCase):
4756
4885
  tags = ContentClassification.objects.filter(sample=sample)
4757
4886
  self.assertTrue(tags.filter(tag__slug="screenshot-tag").exists())
4758
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
+
4759
4920
  def test_text_sample_runs_default_classifiers_without_duplicates(self):
4760
4921
  sample = ContentSample.objects.create(
4761
4922
  content="Example content", kind=ContentSample.TEXT
nodes/urls.py CHANGED
@@ -11,6 +11,17 @@ urlpatterns = [
11
11
  path("net-message/pull/", views.net_message_pull, name="net-message-pull"),
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
+ 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
+ ),
20
+ path(
21
+ "network/chargers/action/",
22
+ views.network_charger_action,
23
+ name="node-network-charger-action",
24
+ ),
14
25
  path("proxy/session/", views.proxy_session, name="node-proxy-session"),
15
26
  path("proxy/login/<str:token>/", views.proxy_login, name="node-proxy-login"),
16
27
  path("proxy/execute/", views.proxy_execute, name="node-proxy-execute"),