arthexis 0.1.10__py3-none-any.whl → 0.1.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

nodes/tests.py CHANGED
@@ -7,6 +7,7 @@ django.setup()
7
7
 
8
8
  from pathlib import Path
9
9
  from types import SimpleNamespace
10
+ import unittest.mock as mock
10
11
  from unittest.mock import patch, call, MagicMock
11
12
  from django.core import mail
12
13
  from django.core.mail import EmailMessage
@@ -21,7 +22,7 @@ import stat
21
22
  import time
22
23
  from datetime import timedelta
23
24
 
24
- from django.test import Client, TestCase, TransactionTestCase, override_settings
25
+ from django.test import Client, SimpleTestCase, TestCase, TransactionTestCase, override_settings
25
26
  from django.urls import reverse
26
27
  from django.contrib.auth import get_user_model
27
28
  from django.contrib import admin
@@ -29,9 +30,12 @@ from django.contrib.sites.models import Site
29
30
  from django_celery_beat.models import PeriodicTask
30
31
  from django.conf import settings
31
32
  from django.utils import timezone
33
+ from dns import resolver as dns_resolver
32
34
  from .actions import NodeAction
35
+ from . import dns as dns_utils
33
36
  from selenium.common.exceptions import WebDriverException
34
37
  from .utils import capture_screenshot
38
+ from django.db.utils import DatabaseError
35
39
 
36
40
  from .models import (
37
41
  Node,
@@ -41,6 +45,8 @@ from .models import (
41
45
  NodeFeature,
42
46
  NodeFeatureAssignment,
43
47
  NetMessage,
48
+ NodeManager,
49
+ DNSRecord,
44
50
  )
45
51
  from .backends import OutboxEmailBackend
46
52
  from .tasks import capture_node_screenshot, sample_clipboard
@@ -57,7 +63,23 @@ class NodeTests(TestCase):
57
63
  self.client.force_login(self.user)
58
64
  NodeRole.objects.get_or_create(name="Terminal")
59
65
 
66
+
67
+ class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
68
+ def test_get_local_handles_database_errors(self):
69
+ with patch.object(Node.objects, "filter", side_effect=DatabaseError("fail")):
70
+ with self.assertLogs("nodes.models", level="DEBUG") as logs:
71
+ result = Node.get_local()
72
+
73
+ self.assertIsNone(result)
74
+ self.assertTrue(
75
+ any("Node.get_local skipped: database unavailable" in message for message in logs.output)
76
+ )
77
+
78
+
79
+ class NodeGetLocalTests(TestCase):
60
80
  def test_register_current_does_not_create_release(self):
81
+ node = None
82
+ created = False
61
83
  with TemporaryDirectory() as tmp:
62
84
  base = Path(tmp)
63
85
  with override_settings(BASE_DIR=base):
@@ -73,8 +95,11 @@ class NodeTests(TestCase):
73
95
  patch("nodes.models.revision.get_revision", return_value="rev"),
74
96
  patch.object(Node, "ensure_keys"),
75
97
  ):
76
- Node.register_current()
98
+ node, created = Node.register_current()
77
99
  self.assertEqual(PackageRelease.objects.count(), 0)
100
+ self.assertIsNotNone(node)
101
+ self.assertTrue(created)
102
+ self.assertEqual(node.current_relation, Node.Relation.SELF)
78
103
 
79
104
  def test_register_and_list_node(self):
80
105
  response = self.client.post(
@@ -89,6 +114,8 @@ class NodeTests(TestCase):
89
114
  )
90
115
  self.assertEqual(response.status_code, 200)
91
116
  self.assertEqual(Node.objects.count(), 1)
117
+ node = Node.objects.get(mac_address="00:11:22:33:44:55")
118
+ self.assertEqual(node.current_relation, Node.Relation.PEER)
92
119
 
93
120
  # allow same IP with different MAC
94
121
  self.client.post(
@@ -371,6 +398,48 @@ class NodeTests(TestCase):
371
398
  self.assertEqual(response.status_code, 200)
372
399
  self.assertTrue(Node.objects.filter(mac_address="aa:bb:cc:dd:ee:ff").exists())
373
400
 
401
+ def test_register_node_respects_relation_payload(self):
402
+ payload = {
403
+ "hostname": "relation",
404
+ "address": "127.0.0.2",
405
+ "port": 8100,
406
+ "mac_address": "11:22:33:44:55:66",
407
+ "current_relation": "Downstream",
408
+ }
409
+ response = self.client.post(
410
+ reverse("register-node"),
411
+ data=json.dumps(payload),
412
+ content_type="application/json",
413
+ )
414
+ self.assertEqual(response.status_code, 200)
415
+ node = Node.objects.get(mac_address="11:22:33:44:55:66")
416
+ self.assertEqual(node.current_relation, Node.Relation.DOWNSTREAM)
417
+
418
+ update_payload = {
419
+ **payload,
420
+ "hostname": "relation-updated",
421
+ "current_relation": "Upstream",
422
+ }
423
+ second = self.client.post(
424
+ reverse("register-node"),
425
+ data=json.dumps(update_payload),
426
+ content_type="application/json",
427
+ )
428
+ self.assertEqual(second.status_code, 200)
429
+ node.refresh_from_db()
430
+ self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
431
+
432
+ final_payload = {**update_payload, "hostname": "relation-final"}
433
+ final_payload.pop("current_relation")
434
+ third = self.client.post(
435
+ reverse("register-node"),
436
+ data=json.dumps(final_payload),
437
+ content_type="application/json",
438
+ )
439
+ self.assertEqual(third.status_code, 200)
440
+ node.refresh_from_db()
441
+ self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
442
+
374
443
 
375
444
  class NodeRegisterCurrentTests(TestCase):
376
445
  def setUp(self):
@@ -1005,6 +1074,17 @@ class NetMessageReachTests(TestCase):
1005
1074
  self.assertEqual(roles, {"Constellation", "Satellite", "Control"})
1006
1075
  self.assertEqual(mock_post.call_count, 3)
1007
1076
 
1077
+ @patch("requests.post")
1078
+ def test_default_reach_not_limited_to_terminal(self, mock_post):
1079
+ msg = NetMessage.objects.create(subject="s", body="b")
1080
+ with patch.object(Node, "get_local", return_value=None), patch(
1081
+ "random.shuffle", side_effect=lambda seq: None
1082
+ ):
1083
+ msg.propagate()
1084
+ roles = set(msg.propagated_to.values_list("role__name", flat=True))
1085
+ self.assertIn("Control", roles)
1086
+ self.assertEqual(mock_post.call_count, 3)
1087
+
1008
1088
 
1009
1089
  class NetMessagePropagationTests(TestCase):
1010
1090
  def setUp(self):
@@ -1034,6 +1114,7 @@ class NetMessagePropagationTests(TestCase):
1034
1114
  with patch.object(Node, "get_local", return_value=self.local):
1035
1115
  msg = NetMessage.broadcast(subject="subject", body="body")
1036
1116
  self.assertEqual(msg.node_origin, self.local)
1117
+ self.assertIsNone(msg.reach)
1037
1118
 
1038
1119
  @patch("requests.post")
1039
1120
  @patch("core.notifications.notify")
@@ -1403,6 +1484,68 @@ class EmailOutboxTests(TestCase):
1403
1484
 
1404
1485
  self.assertEqual(str(outbox), "mailer@smtp.example.com")
1405
1486
 
1487
+ def test_string_representation_does_not_duplicate_email_hostname(self):
1488
+ outbox = EmailOutbox.objects.create(
1489
+ host="smtp.example.com",
1490
+ port=587,
1491
+ username="mailer@example.com",
1492
+ password="secret",
1493
+ )
1494
+
1495
+ self.assertEqual(str(outbox), "mailer@example.com")
1496
+
1497
+ def test_unattached_outbox_used_as_fallback(self):
1498
+ EmailOutbox.objects.create(
1499
+ group=SecurityGroup.objects.create(name="Attached"),
1500
+ host="smtp.attached.example.com",
1501
+ port=587,
1502
+ username="attached",
1503
+ password="secret",
1504
+ )
1505
+ fallback = EmailOutbox.objects.create(
1506
+ host="smtp.fallback.example.com",
1507
+ port=587,
1508
+ username="fallback",
1509
+ password="secret",
1510
+ )
1511
+
1512
+ backend = OutboxEmailBackend()
1513
+ message = EmailMessage("subject", "body", to=["to@example.com"])
1514
+
1515
+ selected, fallbacks = backend._select_outbox(message)
1516
+
1517
+ self.assertEqual(selected, fallback)
1518
+ self.assertEqual(fallbacks, [])
1519
+
1520
+ def test_disabled_outbox_excluded_from_selection(self):
1521
+ EmailOutbox.objects.create(
1522
+ host="smtp.disabled.example.com",
1523
+ port=587,
1524
+ username="disabled@example.com",
1525
+ password="secret",
1526
+ from_email="disabled@example.com",
1527
+ is_enabled=False,
1528
+ )
1529
+ enabled = EmailOutbox.objects.create(
1530
+ host="smtp.enabled.example.com",
1531
+ port=587,
1532
+ username="enabled@example.com",
1533
+ password="secret",
1534
+ )
1535
+
1536
+ backend = OutboxEmailBackend()
1537
+ message = EmailMessage(
1538
+ "subject",
1539
+ "body",
1540
+ from_email="disabled@example.com",
1541
+ to=["to@example.com"],
1542
+ )
1543
+
1544
+ selected, fallbacks = backend._select_outbox(message)
1545
+
1546
+ self.assertEqual(selected, enabled)
1547
+ self.assertEqual(fallbacks, [])
1548
+
1406
1549
 
1407
1550
  class ClipboardTaskTests(TestCase):
1408
1551
  @patch("nodes.tasks.pyperclip.paste")
@@ -1856,3 +1999,170 @@ class NodeRpiCameraDetectionTests(TestCase):
1856
1999
  self.assertFalse(Node._has_rpi_camera())
1857
2000
  missing_index = Node.RPI_CAMERA_BINARIES.index(Node.RPI_CAMERA_BINARIES[-1])
1858
2001
  self.assertEqual(mock_run.call_count, missing_index)
2002
+
2003
+
2004
+ class DNSIntegrationTests(TestCase):
2005
+ def setUp(self):
2006
+ self.group = SecurityGroup.objects.create(name="Infra")
2007
+
2008
+ def test_deploy_records_success(self):
2009
+ manager = NodeManager.objects.create(
2010
+ group=self.group,
2011
+ api_key="test-key",
2012
+ api_secret="test-secret",
2013
+ )
2014
+ record_a = DNSRecord.objects.create(
2015
+ domain="example.com",
2016
+ name="@",
2017
+ record_type=DNSRecord.Type.A,
2018
+ data="1.2.3.4",
2019
+ ttl=600,
2020
+ )
2021
+ record_b = DNSRecord.objects.create(
2022
+ domain="example.com",
2023
+ name="@",
2024
+ record_type=DNSRecord.Type.A,
2025
+ data="5.6.7.8",
2026
+ ttl=600,
2027
+ )
2028
+
2029
+ calls = []
2030
+
2031
+ class DummyResponse:
2032
+ status_code = 200
2033
+ reason = "OK"
2034
+
2035
+ def json(self):
2036
+ return {}
2037
+
2038
+ class DummySession:
2039
+ def __init__(self):
2040
+ self.headers = {}
2041
+
2042
+ def put(self, url, json, timeout):
2043
+ calls.append((url, json, timeout, dict(self.headers)))
2044
+ return DummyResponse()
2045
+
2046
+ with mock.patch.object(dns_utils.requests, "Session", DummySession):
2047
+ result = manager.publish_dns_records([record_a, record_b])
2048
+
2049
+ self.assertEqual(len(result.deployed), 2)
2050
+ self.assertFalse(result.failures)
2051
+ self.assertFalse(result.skipped)
2052
+ self.assertTrue(calls)
2053
+ url, payload, timeout, headers = calls[0]
2054
+ self.assertTrue(url.endswith("/v1/domains/example.com/records/A/@"))
2055
+ self.assertEqual(len(payload), 2)
2056
+ self.assertEqual(headers["Authorization"], "sso-key test-key:test-secret")
2057
+
2058
+ record_a.refresh_from_db()
2059
+ record_b.refresh_from_db()
2060
+ self.assertIsNotNone(record_a.last_synced_at)
2061
+ self.assertIsNotNone(record_b.last_synced_at)
2062
+ self.assertEqual(record_a.node_manager_id, manager.pk)
2063
+ self.assertEqual(record_b.node_manager_id, manager.pk)
2064
+
2065
+ def test_deploy_records_handles_error(self):
2066
+ manager = NodeManager.objects.create(
2067
+ group=self.group,
2068
+ api_key="test-key",
2069
+ api_secret="test-secret",
2070
+ )
2071
+ record = DNSRecord.objects.create(
2072
+ domain="example.com",
2073
+ name="www",
2074
+ record_type=DNSRecord.Type.CNAME,
2075
+ data="target.example.com",
2076
+ )
2077
+
2078
+ class DummyResponse:
2079
+ status_code = 400
2080
+ reason = "Bad Request"
2081
+
2082
+ def json(self):
2083
+ return {"message": "Invalid data"}
2084
+
2085
+ class DummySession:
2086
+ def __init__(self):
2087
+ self.headers = {}
2088
+
2089
+ def put(self, url, json, timeout):
2090
+ return DummyResponse()
2091
+
2092
+ with mock.patch.object(dns_utils.requests, "Session", DummySession):
2093
+ result = manager.publish_dns_records([record])
2094
+
2095
+ self.assertFalse(result.deployed)
2096
+ self.assertIn(record, result.failures)
2097
+ record.refresh_from_db()
2098
+ self.assertEqual(record.last_error, "Invalid data")
2099
+ self.assertIsNone(record.last_synced_at)
2100
+
2101
+ def test_validate_record_success(self):
2102
+ record = DNSRecord.objects.create(
2103
+ domain="example.com",
2104
+ name="www",
2105
+ record_type=DNSRecord.Type.A,
2106
+ data="1.2.3.4",
2107
+ )
2108
+
2109
+ class DummyRdata:
2110
+ address = "1.2.3.4"
2111
+
2112
+ class DummyResolver:
2113
+ def resolve(self, name, rtype):
2114
+ self_calls.append((name, rtype))
2115
+ return [DummyRdata()]
2116
+
2117
+ self_calls = []
2118
+ ok, message = dns_utils.validate_record(record, resolver=DummyResolver())
2119
+
2120
+ self.assertTrue(ok)
2121
+ self.assertEqual(message, "")
2122
+ record.refresh_from_db()
2123
+ self.assertIsNotNone(record.last_verified_at)
2124
+ self.assertEqual(record.last_error, "")
2125
+ self.assertEqual(self_calls, [("www.example.com", "A")])
2126
+
2127
+ def test_validate_record_mismatch(self):
2128
+ record = DNSRecord.objects.create(
2129
+ domain="example.com",
2130
+ name="www",
2131
+ record_type=DNSRecord.Type.A,
2132
+ data="1.2.3.4",
2133
+ )
2134
+
2135
+ class DummyRdata:
2136
+ address = "5.6.7.8"
2137
+
2138
+ class DummyResolver:
2139
+ def resolve(self, name, rtype):
2140
+ return [DummyRdata()]
2141
+
2142
+ ok, message = dns_utils.validate_record(record, resolver=DummyResolver())
2143
+
2144
+ self.assertFalse(ok)
2145
+ self.assertEqual(message, "DNS record does not match expected value")
2146
+ record.refresh_from_db()
2147
+ self.assertEqual(record.last_error, message)
2148
+ self.assertIsNone(record.last_verified_at)
2149
+
2150
+ def test_validate_record_handles_exception(self):
2151
+ record = DNSRecord.objects.create(
2152
+ domain="example.com",
2153
+ name="www",
2154
+ record_type=DNSRecord.Type.A,
2155
+ data="1.2.3.4",
2156
+ )
2157
+
2158
+ class DummyResolver:
2159
+ def resolve(self, name, rtype):
2160
+ raise dns_resolver.NXDOMAIN()
2161
+
2162
+ ok, message = dns_utils.validate_record(record, resolver=DummyResolver())
2163
+
2164
+ self.assertFalse(ok)
2165
+ self.assertEqual(message, "The DNS query name does not exist.")
2166
+ record.refresh_from_db()
2167
+ self.assertEqual(record.last_error, message)
2168
+ self.assertIsNone(record.last_verified_at)
nodes/views.py CHANGED
@@ -204,6 +204,15 @@ def register_node(request):
204
204
  signature = data.get("signature")
205
205
  installed_version = data.get("installed_version")
206
206
  installed_revision = data.get("installed_revision")
207
+ relation_present = False
208
+ if hasattr(data, "getlist"):
209
+ relation_present = "current_relation" in data
210
+ else:
211
+ relation_present = "current_relation" in data
212
+ raw_relation = data.get("current_relation")
213
+ relation_value = (
214
+ Node.normalize_relation(raw_relation) if relation_present else None
215
+ )
207
216
 
208
217
  if not hostname or not address or not mac_address:
209
218
  response = JsonResponse(
@@ -242,6 +251,8 @@ def register_node(request):
242
251
  defaults["installed_version"] = str(installed_version)[:20]
243
252
  if installed_revision is not None:
244
253
  defaults["installed_revision"] = str(installed_revision)[:40]
254
+ if relation_value is not None:
255
+ defaults["current_relation"] = relation_value
245
256
 
246
257
  node, created = Node.objects.get_or_create(
247
258
  mac_address=mac_address,
@@ -265,6 +276,9 @@ def register_node(request):
265
276
  node.installed_revision = str(installed_revision)[:40]
266
277
  if "installed_revision" not in update_fields:
267
278
  update_fields.append("installed_revision")
279
+ if relation_value is not None and node.current_relation != relation_value:
280
+ node.current_relation = relation_value
281
+ update_fields.append("current_relation")
268
282
  node.save(update_fields=update_fields)
269
283
  current_version = (node.installed_version or "").strip()
270
284
  current_revision = (node.installed_revision or "").strip()
ocpp/consumers.py CHANGED
@@ -17,6 +17,7 @@ from . import store
17
17
  from decimal import Decimal
18
18
  from django.utils.dateparse import parse_datetime
19
19
  from .models import Transaction, Charger, MeterValue
20
+ from .reference_utils import host_is_local_loopback
20
21
 
21
22
  FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
22
23
 
@@ -287,19 +288,21 @@ class CSMSConsumer(AsyncWebsocketConsumer):
287
288
  serial = (self.charger_id or "").strip()
288
289
  if not ip or not serial:
289
290
  return
291
+ if host_is_local_loopback(ip):
292
+ return
290
293
  host = ip
291
294
  if ":" in host and not host.startswith("["):
292
295
  host = f"[{host}]"
293
296
  url = f"http://{host}:8900"
294
297
  alt_text = f"{serial} Console"
295
- reference, _ = Reference.objects.get_or_create(
296
- alt_text=alt_text,
297
- defaults={
298
- "value": url,
299
- "show_in_header": True,
300
- "method": "link",
301
- },
302
- )
298
+ reference = Reference.objects.filter(alt_text=alt_text).order_by("id").first()
299
+ if reference is None:
300
+ reference = Reference.objects.create(
301
+ alt_text=alt_text,
302
+ value=url,
303
+ show_in_header=True,
304
+ method="link",
305
+ )
303
306
  updated_fields: list[str] = []
304
307
  if reference.value != url:
305
308
  reference.value = url
ocpp/models.py CHANGED
@@ -18,6 +18,7 @@ from core.models import (
18
18
  Brand as CoreBrand,
19
19
  EVModel as CoreEVModel,
20
20
  )
21
+ from .reference_utils import url_targets_local_loopback
21
22
 
22
23
 
23
24
  class Location(Entity):
@@ -279,6 +280,8 @@ class Charger(Entity):
279
280
  kwargs["update_fields"] = update_list
280
281
  super().save(*args, **kwargs)
281
282
  ref_value = self._full_url()
283
+ if url_targets_local_loopback(ref_value):
284
+ return
282
285
  if not self.reference or self.reference.value != ref_value:
283
286
  self.reference = Reference.objects.create(
284
287
  value=ref_value, alt_text=self.charger_id
@@ -0,0 +1,42 @@
1
+ """Helpers related to console Reference creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ from urllib.parse import urlparse
7
+
8
+
9
+ def _normalize_host(host: str | None) -> str:
10
+ """Return a trimmed host string without surrounding brackets."""
11
+
12
+ if not host:
13
+ return ""
14
+ host = host.strip()
15
+ if host.startswith("[") and host.endswith("]"):
16
+ return host[1:-1]
17
+ return host
18
+
19
+
20
+ def host_is_local_loopback(host: str | None) -> bool:
21
+ """Return ``True`` when the host string points to 127.0.0.1."""
22
+
23
+ normalized = _normalize_host(host)
24
+ if not normalized:
25
+ return False
26
+ try:
27
+ return ipaddress.ip_address(normalized) == ipaddress.ip_address("127.0.0.1")
28
+ except ValueError:
29
+ return False
30
+
31
+
32
+ def url_targets_local_loopback(url: str | None) -> bool:
33
+ """Return ``True`` when the parsed URL host equals 127.0.0.1."""
34
+
35
+ if not url:
36
+ return False
37
+ parsed = urlparse(url)
38
+ return host_is_local_loopback(parsed.hostname)
39
+
40
+
41
+ __all__ = ["host_is_local_loopback", "url_targets_local_loopback"]
42
+