arthexis 0.1.16__py3-none-any.whl → 0.1.28__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.
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
- arthexis-0.1.28.dist-info/RECORD +112 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +21 -30
- config/settings_helpers.py +176 -1
- config/urls.py +69 -1
- core/admin.py +805 -473
- core/apps.py +6 -8
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/celery_utils.py +73 -0
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1825 -218
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +285 -4
- core/tasks.py +439 -138
- core/test_system_info.py +43 -5
- core/tests.py +516 -18
- core/user_data.py +94 -21
- core/views.py +348 -186
- nodes/admin.py +904 -67
- nodes/apps.py +12 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +800 -127
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +98 -3
- nodes/tests.py +1381 -152
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1382 -152
- ocpp/admin.py +1970 -152
- ocpp/consumers.py +839 -34
- ocpp/models.py +968 -17
- ocpp/network.py +398 -0
- ocpp/store.py +411 -43
- ocpp/tasks.py +261 -3
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +194 -6
- ocpp/tests.py +1918 -87
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +8 -3
- ocpp/views.py +700 -53
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +28 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +86 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +1050 -126
- pages/urls.py +14 -2
- pages/utils.py +70 -0
- pages/views.py +622 -56
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
nodes/tests.py
CHANGED
|
@@ -2,6 +2,7 @@ import os
|
|
|
2
2
|
|
|
3
3
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
4
4
|
import django
|
|
5
|
+
import pytest
|
|
5
6
|
|
|
6
7
|
try: # Use the pytest-specific setup when available for database readiness
|
|
7
8
|
from tests.conftest import safe_setup as _safe_setup # type: ignore
|
|
@@ -14,10 +15,11 @@ else: # pragma: no cover - fallback when pytest fixtures are unavailable
|
|
|
14
15
|
django.setup()
|
|
15
16
|
|
|
16
17
|
from pathlib import Path
|
|
17
|
-
from types import SimpleNamespace
|
|
18
|
+
from types import MethodType, SimpleNamespace
|
|
18
19
|
import unittest.mock as mock
|
|
19
20
|
from unittest.mock import patch, call, MagicMock
|
|
20
21
|
from django.core import mail
|
|
22
|
+
from django.core.cache import cache
|
|
21
23
|
from django.core.mail import EmailMessage
|
|
22
24
|
from django.core.management import call_command
|
|
23
25
|
import socket
|
|
@@ -30,21 +32,37 @@ import stat
|
|
|
30
32
|
import time
|
|
31
33
|
from datetime import datetime, timedelta
|
|
32
34
|
|
|
33
|
-
from django.test import
|
|
35
|
+
from django.test import (
|
|
36
|
+
Client,
|
|
37
|
+
RequestFactory,
|
|
38
|
+
SimpleTestCase,
|
|
39
|
+
TestCase,
|
|
40
|
+
TransactionTestCase,
|
|
41
|
+
override_settings,
|
|
42
|
+
)
|
|
34
43
|
from django.urls import reverse
|
|
35
44
|
from django.contrib.auth import get_user_model
|
|
36
45
|
from django.contrib import admin
|
|
46
|
+
from django.contrib import messages
|
|
47
|
+
from django.contrib.admin import helpers
|
|
37
48
|
from django.contrib.auth.models import Permission
|
|
38
49
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
50
|
+
from core.celery_utils import (
|
|
51
|
+
periodic_task_name_variants,
|
|
52
|
+
slugify_task_name,
|
|
53
|
+
)
|
|
39
54
|
from django.conf import settings
|
|
40
55
|
from django.utils import timezone
|
|
56
|
+
from urllib.parse import urlparse
|
|
41
57
|
from dns import resolver as dns_resolver
|
|
42
58
|
from . import dns as dns_utils
|
|
43
59
|
from selenium.common.exceptions import WebDriverException
|
|
44
60
|
from .classifiers import run_default_classifiers
|
|
45
61
|
from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
|
|
62
|
+
from .feature_checks import feature_checks
|
|
46
63
|
from django.db.utils import DatabaseError
|
|
47
64
|
|
|
65
|
+
from .admin import NodeAdmin
|
|
48
66
|
from .models import (
|
|
49
67
|
Node,
|
|
50
68
|
EmailOutbox,
|
|
@@ -56,28 +74,31 @@ from .models import (
|
|
|
56
74
|
NodeFeature,
|
|
57
75
|
NodeFeatureAssignment,
|
|
58
76
|
NetMessage,
|
|
77
|
+
PendingNetMessage,
|
|
59
78
|
NodeManager,
|
|
60
79
|
DNSRecord,
|
|
61
80
|
)
|
|
62
81
|
from .backends import OutboxEmailBackend
|
|
63
|
-
from .tasks import capture_node_screenshot, sample_clipboard
|
|
82
|
+
from .tasks import capture_node_screenshot, poll_unreachable_upstream, sample_clipboard
|
|
83
|
+
from ocpp.models import Charger
|
|
64
84
|
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
65
85
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
66
|
-
from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount
|
|
86
|
+
from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount, Todo
|
|
87
|
+
from requests.exceptions import SSLError
|
|
67
88
|
|
|
68
89
|
|
|
69
90
|
class NodeBadgeColorTests(TestCase):
|
|
70
91
|
def setUp(self):
|
|
71
|
-
self.
|
|
92
|
+
self.watchtower, _ = NodeRole.objects.get_or_create(name="Watchtower")
|
|
72
93
|
self.control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
73
94
|
|
|
74
|
-
def
|
|
95
|
+
def test_watchtower_role_defaults_to_goldenrod(self):
|
|
75
96
|
node = Node.objects.create(
|
|
76
|
-
hostname="
|
|
97
|
+
hostname="watchtower",
|
|
77
98
|
address="10.1.0.1",
|
|
78
|
-
port=
|
|
99
|
+
port=8888,
|
|
79
100
|
mac_address="00:aa:bb:cc:dd:01",
|
|
80
|
-
role=self.
|
|
101
|
+
role=self.watchtower,
|
|
81
102
|
)
|
|
82
103
|
self.assertEqual(node.badge_color, "#daa520")
|
|
83
104
|
|
|
@@ -97,7 +118,7 @@ class NodeBadgeColorTests(TestCase):
|
|
|
97
118
|
address="10.1.0.3",
|
|
98
119
|
port=8002,
|
|
99
120
|
mac_address="00:aa:bb:cc:dd:03",
|
|
100
|
-
role=self.
|
|
121
|
+
role=self.watchtower,
|
|
101
122
|
badge_color="#123456",
|
|
102
123
|
)
|
|
103
124
|
self.assertEqual(node.badge_color, "#123456")
|
|
@@ -110,6 +131,24 @@ class NodeTests(TestCase):
|
|
|
110
131
|
self.user = User.objects.create_user(username="nodeuser", password="pwd")
|
|
111
132
|
self.client.force_login(self.user)
|
|
112
133
|
NodeRole.objects.get_or_create(name="Terminal")
|
|
134
|
+
NodeRole.objects.get_or_create(name="Interface")
|
|
135
|
+
|
|
136
|
+
def test_terminal_role_enables_clipboard_feature_by_default(self):
|
|
137
|
+
role = NodeRole.objects.get(name="Terminal")
|
|
138
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
139
|
+
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
140
|
+
)
|
|
141
|
+
feature.roles.add(role)
|
|
142
|
+
|
|
143
|
+
node = Node.objects.create(
|
|
144
|
+
hostname="terminal-node",
|
|
145
|
+
address="10.0.0.5",
|
|
146
|
+
port=8888,
|
|
147
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
148
|
+
role=role,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
self.assertTrue(node.has_feature("clipboard-poll"))
|
|
113
152
|
|
|
114
153
|
|
|
115
154
|
class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
|
|
@@ -125,6 +164,12 @@ class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
|
|
|
125
164
|
|
|
126
165
|
|
|
127
166
|
class NodeGetLocalTests(TestCase):
|
|
167
|
+
def setUp(self):
|
|
168
|
+
super().setUp()
|
|
169
|
+
User = get_user_model()
|
|
170
|
+
self.user = User.objects.create_user(username="localtester", password="pwd")
|
|
171
|
+
self.client.force_login(self.user)
|
|
172
|
+
|
|
128
173
|
def test_normalize_relation_handles_various_inputs(self):
|
|
129
174
|
self.assertEqual(
|
|
130
175
|
Node.normalize_relation(Node.Relation.UPSTREAM),
|
|
@@ -166,6 +211,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
166
211
|
patch(
|
|
167
212
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
168
213
|
),
|
|
214
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
169
215
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
170
216
|
patch.object(Node, "ensure_keys"),
|
|
171
217
|
):
|
|
@@ -177,7 +223,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
177
223
|
|
|
178
224
|
def test_register_current_updates_role_from_lock_file(self):
|
|
179
225
|
NodeRole.objects.get_or_create(name="Terminal")
|
|
180
|
-
NodeRole.objects.get_or_create(name="
|
|
226
|
+
NodeRole.objects.get_or_create(name="Watchtower")
|
|
181
227
|
with TemporaryDirectory() as tmp:
|
|
182
228
|
base = Path(tmp)
|
|
183
229
|
lock_dir = base / "locks"
|
|
@@ -194,6 +240,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
194
240
|
patch(
|
|
195
241
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
196
242
|
),
|
|
243
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
197
244
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
198
245
|
patch.object(Node, "ensure_keys"),
|
|
199
246
|
patch.object(Node, "notify_peers_of_update"),
|
|
@@ -202,7 +249,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
202
249
|
self.assertTrue(created)
|
|
203
250
|
self.assertEqual(node.role.name, "Terminal")
|
|
204
251
|
|
|
205
|
-
role_file.write_text("
|
|
252
|
+
role_file.write_text("Watchtower")
|
|
206
253
|
with override_settings(BASE_DIR=base):
|
|
207
254
|
with (
|
|
208
255
|
patch(
|
|
@@ -213,6 +260,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
213
260
|
patch(
|
|
214
261
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
215
262
|
),
|
|
263
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
216
264
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
217
265
|
patch.object(Node, "ensure_keys"),
|
|
218
266
|
patch.object(Node, "notify_peers_of_update"),
|
|
@@ -221,7 +269,70 @@ class NodeGetLocalTests(TestCase):
|
|
|
221
269
|
|
|
222
270
|
self.assertFalse(created_again)
|
|
223
271
|
node.refresh_from_db()
|
|
224
|
-
self.assertEqual(node.role.name, "
|
|
272
|
+
self.assertEqual(node.role.name, "Watchtower")
|
|
273
|
+
|
|
274
|
+
role_file.write_text("Constellation")
|
|
275
|
+
with override_settings(BASE_DIR=base):
|
|
276
|
+
with (
|
|
277
|
+
patch(
|
|
278
|
+
"nodes.models.Node.get_current_mac",
|
|
279
|
+
return_value="00:aa:bb:cc:dd:ee",
|
|
280
|
+
),
|
|
281
|
+
patch("nodes.models.socket.gethostname", return_value="role-host"),
|
|
282
|
+
patch(
|
|
283
|
+
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
284
|
+
),
|
|
285
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
286
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
287
|
+
patch.object(Node, "ensure_keys"),
|
|
288
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
289
|
+
):
|
|
290
|
+
Node.register_current()
|
|
291
|
+
|
|
292
|
+
node.refresh_from_db()
|
|
293
|
+
self.assertEqual(node.role.name, "Watchtower")
|
|
294
|
+
|
|
295
|
+
def test_register_current_respects_node_hostname_env(self):
|
|
296
|
+
with TemporaryDirectory() as tmp:
|
|
297
|
+
base = Path(tmp)
|
|
298
|
+
with override_settings(BASE_DIR=base):
|
|
299
|
+
with (
|
|
300
|
+
patch.dict(os.environ, {"NODE_HOSTNAME": "gway-002"}, clear=False),
|
|
301
|
+
patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"),
|
|
302
|
+
patch("nodes.models.socket.gethostname", return_value="localhost"),
|
|
303
|
+
patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
|
|
304
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
305
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
306
|
+
patch.object(Node, "ensure_keys"),
|
|
307
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
308
|
+
):
|
|
309
|
+
node, created = Node.register_current()
|
|
310
|
+
self.assertTrue(created)
|
|
311
|
+
self.assertEqual(node.hostname, "gway-002")
|
|
312
|
+
self.assertEqual(node.public_endpoint, "gway-002")
|
|
313
|
+
|
|
314
|
+
def test_register_current_respects_public_endpoint_env(self):
|
|
315
|
+
with TemporaryDirectory() as tmp:
|
|
316
|
+
base = Path(tmp)
|
|
317
|
+
with override_settings(BASE_DIR=base):
|
|
318
|
+
with (
|
|
319
|
+
patch.dict(
|
|
320
|
+
os.environ,
|
|
321
|
+
{"NODE_HOSTNAME": "gway-alpha", "NODE_PUBLIC_ENDPOINT": "gway-002"},
|
|
322
|
+
clear=False,
|
|
323
|
+
),
|
|
324
|
+
patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:56"),
|
|
325
|
+
patch("nodes.models.socket.gethostname", return_value="localhost"),
|
|
326
|
+
patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
|
|
327
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
328
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
329
|
+
patch.object(Node, "ensure_keys"),
|
|
330
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
331
|
+
):
|
|
332
|
+
node, created = Node.register_current()
|
|
333
|
+
self.assertTrue(created)
|
|
334
|
+
self.assertEqual(node.hostname, "gway-alpha")
|
|
335
|
+
self.assertEqual(node.public_endpoint, "gway-002")
|
|
225
336
|
|
|
226
337
|
def test_register_and_list_node(self):
|
|
227
338
|
response = self.client.post(
|
|
@@ -229,7 +340,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
229
340
|
data={
|
|
230
341
|
"hostname": "local",
|
|
231
342
|
"address": "127.0.0.1",
|
|
232
|
-
"port":
|
|
343
|
+
"port": 8888,
|
|
233
344
|
"mac_address": "00:11:22:33:44:55",
|
|
234
345
|
},
|
|
235
346
|
content_type="application/json",
|
|
@@ -309,6 +420,101 @@ class NodeGetLocalTests(TestCase):
|
|
|
309
420
|
self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
|
|
310
421
|
self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
|
|
311
422
|
|
|
423
|
+
def test_register_node_accepts_network_hostname_without_address(self):
|
|
424
|
+
response = self.client.post(
|
|
425
|
+
reverse("register-node"),
|
|
426
|
+
data={
|
|
427
|
+
"hostname": "domain-node",
|
|
428
|
+
"network_hostname": "domain-node.example.com",
|
|
429
|
+
"port": 8050,
|
|
430
|
+
"mac_address": "aa:bb:cc:dd:ee:ff",
|
|
431
|
+
},
|
|
432
|
+
content_type="application/json",
|
|
433
|
+
)
|
|
434
|
+
self.assertEqual(response.status_code, 200)
|
|
435
|
+
node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:ff")
|
|
436
|
+
self.assertEqual(node.network_hostname, "domain-node.example.com")
|
|
437
|
+
self.assertIsNone(node.address)
|
|
438
|
+
self.assertIsNone(node.ipv4_address)
|
|
439
|
+
self.assertIsNone(node.ipv6_address)
|
|
440
|
+
|
|
441
|
+
def test_register_node_populates_missing_ip_fields_from_address(self):
|
|
442
|
+
response = self.client.post(
|
|
443
|
+
reverse("register-node"),
|
|
444
|
+
data={
|
|
445
|
+
"hostname": "address-node",
|
|
446
|
+
"address": "203.0.113.10",
|
|
447
|
+
"port": 8040,
|
|
448
|
+
"mac_address": "aa:bb:cc:dd:ee:01",
|
|
449
|
+
},
|
|
450
|
+
content_type="application/json",
|
|
451
|
+
)
|
|
452
|
+
self.assertEqual(response.status_code, 200)
|
|
453
|
+
node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:01")
|
|
454
|
+
self.assertEqual(node.address, "203.0.113.10")
|
|
455
|
+
self.assertEqual(node.ipv4_address, "203.0.113.10")
|
|
456
|
+
self.assertIsNone(node.ipv6_address)
|
|
457
|
+
|
|
458
|
+
def test_get_best_ip_ignores_non_ip_values(self):
|
|
459
|
+
node = Node.objects.create(
|
|
460
|
+
hostname="best-ip",
|
|
461
|
+
address="gateway.local",
|
|
462
|
+
ipv4_address="198.51.100.5",
|
|
463
|
+
port=8888,
|
|
464
|
+
mac_address="00:11:22:33:44:77",
|
|
465
|
+
)
|
|
466
|
+
self.assertEqual(node.get_best_ip(), "198.51.100.5")
|
|
467
|
+
|
|
468
|
+
def test_register_node_requires_contact_information(self):
|
|
469
|
+
response = self.client.post(
|
|
470
|
+
reverse("register-node"),
|
|
471
|
+
data={
|
|
472
|
+
"hostname": "missing-host",
|
|
473
|
+
"port": 8051,
|
|
474
|
+
"mac_address": "aa:bb:cc:dd:ee:00",
|
|
475
|
+
},
|
|
476
|
+
content_type="application/json",
|
|
477
|
+
)
|
|
478
|
+
self.assertEqual(response.status_code, 400)
|
|
479
|
+
self.assertIn("at least one", response.json()["detail"])
|
|
480
|
+
|
|
481
|
+
def test_register_node_assigns_interface_role_and_returns_uuid(self):
|
|
482
|
+
NodeRole.objects.get_or_create(name="Interface")
|
|
483
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
484
|
+
public_bytes = private_key.public_key().public_bytes(
|
|
485
|
+
encoding=serialization.Encoding.PEM,
|
|
486
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
487
|
+
).decode()
|
|
488
|
+
token = "interface-token"
|
|
489
|
+
signature = base64.b64encode(
|
|
490
|
+
private_key.sign(
|
|
491
|
+
token.encode(),
|
|
492
|
+
padding.PKCS1v15(),
|
|
493
|
+
hashes.SHA256(),
|
|
494
|
+
)
|
|
495
|
+
).decode()
|
|
496
|
+
mac = "aa:bb:cc:dd:ee:99"
|
|
497
|
+
payload = {
|
|
498
|
+
"hostname": "interface",
|
|
499
|
+
"address": "127.0.0.1",
|
|
500
|
+
"port": 8443,
|
|
501
|
+
"mac_address": mac,
|
|
502
|
+
"public_key": public_bytes,
|
|
503
|
+
"token": token,
|
|
504
|
+
"signature": signature,
|
|
505
|
+
"role": "Interface",
|
|
506
|
+
}
|
|
507
|
+
response = self.client.post(
|
|
508
|
+
reverse("register-node"),
|
|
509
|
+
data=json.dumps(payload),
|
|
510
|
+
content_type="application/json",
|
|
511
|
+
)
|
|
512
|
+
self.assertEqual(response.status_code, 200)
|
|
513
|
+
data = response.json()
|
|
514
|
+
self.assertIn("uuid", data)
|
|
515
|
+
node = Node.objects.get(mac_address=mac)
|
|
516
|
+
self.assertEqual(node.role.name, "Interface")
|
|
517
|
+
|
|
312
518
|
def test_register_node_feature_toggle(self):
|
|
313
519
|
NodeFeature.objects.get_or_create(
|
|
314
520
|
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
@@ -319,7 +525,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
319
525
|
data={
|
|
320
526
|
"hostname": "lcd",
|
|
321
527
|
"address": "127.0.0.1",
|
|
322
|
-
"port":
|
|
528
|
+
"port": 8888,
|
|
323
529
|
"mac_address": "00:aa:bb:cc:dd:ee",
|
|
324
530
|
"features": ["clipboard-poll"],
|
|
325
531
|
},
|
|
@@ -334,7 +540,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
334
540
|
data={
|
|
335
541
|
"hostname": "lcd",
|
|
336
542
|
"address": "127.0.0.1",
|
|
337
|
-
"port":
|
|
543
|
+
"port": 8888,
|
|
338
544
|
"mac_address": "00:aa:bb:cc:dd:ee",
|
|
339
545
|
"features": [],
|
|
340
546
|
},
|
|
@@ -501,7 +707,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
501
707
|
payload = {
|
|
502
708
|
"hostname": "cors",
|
|
503
709
|
"address": "127.0.0.1",
|
|
504
|
-
"port":
|
|
710
|
+
"port": 8888,
|
|
505
711
|
"mac_address": "10:20:30:40:50:60",
|
|
506
712
|
}
|
|
507
713
|
response = self.client.post(
|
|
@@ -519,7 +725,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
519
725
|
payload = {
|
|
520
726
|
"hostname": "visitor",
|
|
521
727
|
"address": "127.0.0.1",
|
|
522
|
-
"port":
|
|
728
|
+
"port": 8888,
|
|
523
729
|
"mac_address": "aa:bb:cc:dd:ee:00",
|
|
524
730
|
}
|
|
525
731
|
response = self.client.post(
|
|
@@ -563,7 +769,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
563
769
|
payload = {
|
|
564
770
|
"hostname": "visitor",
|
|
565
771
|
"address": "127.0.0.1",
|
|
566
|
-
"port":
|
|
772
|
+
"port": 8888,
|
|
567
773
|
"mac_address": "aa:bb:cc:dd:ee:11",
|
|
568
774
|
"public_key": public_bytes,
|
|
569
775
|
"token": token,
|
|
@@ -640,6 +846,125 @@ class NodeGetLocalTests(TestCase):
|
|
|
640
846
|
self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
|
|
641
847
|
|
|
642
848
|
|
|
849
|
+
class NodeEnsureKeysTests(TestCase):
|
|
850
|
+
def setUp(self):
|
|
851
|
+
self.tempdir = TemporaryDirectory()
|
|
852
|
+
self.base = Path(self.tempdir.name)
|
|
853
|
+
self.override = override_settings(BASE_DIR=self.base)
|
|
854
|
+
self.override.enable()
|
|
855
|
+
self.node = Node.objects.create(
|
|
856
|
+
hostname="ensure-host",
|
|
857
|
+
address="127.0.0.1",
|
|
858
|
+
port=8888,
|
|
859
|
+
mac_address="00:11:22:33:44:55",
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
def tearDown(self):
|
|
863
|
+
self.override.disable()
|
|
864
|
+
self.tempdir.cleanup()
|
|
865
|
+
|
|
866
|
+
def test_regenerates_missing_keys(self):
|
|
867
|
+
self.node.ensure_keys()
|
|
868
|
+
security_dir = self.base / "security"
|
|
869
|
+
priv_path = security_dir / self.node.public_endpoint
|
|
870
|
+
pub_path = security_dir / f"{self.node.public_endpoint}.pub"
|
|
871
|
+
original_public = self.node.public_key
|
|
872
|
+
priv_path.unlink()
|
|
873
|
+
pub_path.unlink()
|
|
874
|
+
|
|
875
|
+
self.node.ensure_keys()
|
|
876
|
+
|
|
877
|
+
self.assertTrue(priv_path.exists())
|
|
878
|
+
self.assertTrue(pub_path.exists())
|
|
879
|
+
self.assertNotEqual(self.node.public_key, original_public)
|
|
880
|
+
|
|
881
|
+
def test_regenerates_outdated_keys(self):
|
|
882
|
+
self.node.ensure_keys()
|
|
883
|
+
security_dir = self.base / "security"
|
|
884
|
+
priv_path = security_dir / self.node.public_endpoint
|
|
885
|
+
pub_path = security_dir / f"{self.node.public_endpoint}.pub"
|
|
886
|
+
original_private = priv_path.read_bytes()
|
|
887
|
+
original_public = pub_path.read_bytes()
|
|
888
|
+
|
|
889
|
+
old_time = (timezone.now() - timedelta(seconds=5)).timestamp()
|
|
890
|
+
os.utime(priv_path, (old_time, old_time))
|
|
891
|
+
os.utime(pub_path, (old_time, old_time))
|
|
892
|
+
|
|
893
|
+
with override_settings(NODE_KEY_MAX_AGE=timedelta(seconds=1)):
|
|
894
|
+
self.node.ensure_keys()
|
|
895
|
+
|
|
896
|
+
self.node.refresh_from_db()
|
|
897
|
+
self.assertNotEqual(priv_path.read_bytes(), original_private)
|
|
898
|
+
self.assertNotEqual(pub_path.read_bytes(), original_public)
|
|
899
|
+
self.assertNotEqual(self.node.public_key, original_public.decode())
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
class NodeInfoViewTests(TestCase):
|
|
903
|
+
def setUp(self):
|
|
904
|
+
self.mac = "02:00:00:00:00:01"
|
|
905
|
+
self.patcher = patch("nodes.models.Node.get_current_mac", return_value=self.mac)
|
|
906
|
+
self.patcher.start()
|
|
907
|
+
self.addCleanup(self.patcher.stop)
|
|
908
|
+
self.node = Node.objects.create(
|
|
909
|
+
hostname="local",
|
|
910
|
+
network_hostname="local.example.com",
|
|
911
|
+
address="10.0.0.10",
|
|
912
|
+
ipv4_address="10.0.0.10",
|
|
913
|
+
ipv6_address="2001:db8::10",
|
|
914
|
+
port=8888,
|
|
915
|
+
mac_address=self.mac,
|
|
916
|
+
public_endpoint="local",
|
|
917
|
+
current_relation=Node.Relation.SELF,
|
|
918
|
+
)
|
|
919
|
+
self.url = reverse("node-info")
|
|
920
|
+
|
|
921
|
+
def test_returns_https_port_for_secure_domain_request(self):
|
|
922
|
+
with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
|
|
923
|
+
response = self.client.get(
|
|
924
|
+
self.url,
|
|
925
|
+
secure=True,
|
|
926
|
+
HTTP_HOST="arthexis.com",
|
|
927
|
+
)
|
|
928
|
+
self.assertEqual(response.status_code, 200)
|
|
929
|
+
payload = response.json()
|
|
930
|
+
self.assertEqual(payload["port"], 443)
|
|
931
|
+
|
|
932
|
+
def test_returns_http_port_for_plain_domain_request(self):
|
|
933
|
+
with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
|
|
934
|
+
response = self.client.get(
|
|
935
|
+
self.url,
|
|
936
|
+
HTTP_HOST="arthexis.com",
|
|
937
|
+
)
|
|
938
|
+
self.assertEqual(response.status_code, 200)
|
|
939
|
+
payload = response.json()
|
|
940
|
+
self.assertEqual(payload["port"], 80)
|
|
941
|
+
self.assertEqual(payload.get("network_hostname"), "local.example.com")
|
|
942
|
+
self.assertIn("local.example.com", payload.get("contact_hosts", []))
|
|
943
|
+
|
|
944
|
+
def test_preserves_explicit_port_in_host_header(self):
|
|
945
|
+
with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
|
|
946
|
+
response = self.client.get(
|
|
947
|
+
self.url,
|
|
948
|
+
secure=True,
|
|
949
|
+
HTTP_HOST="arthexis.com:8443",
|
|
950
|
+
)
|
|
951
|
+
self.assertEqual(response.status_code, 200)
|
|
952
|
+
payload = response.json()
|
|
953
|
+
self.assertEqual(payload["port"], 8443)
|
|
954
|
+
|
|
955
|
+
def test_includes_role_in_payload(self):
|
|
956
|
+
role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
957
|
+
self.node.role = role
|
|
958
|
+
self.node.save(update_fields=["role"])
|
|
959
|
+
|
|
960
|
+
response = self.client.get(self.url)
|
|
961
|
+
self.assertEqual(response.status_code, 200)
|
|
962
|
+
payload = response.json()
|
|
963
|
+
self.assertEqual(payload.get("role"), "Terminal")
|
|
964
|
+
self.assertEqual(payload.get("ipv4_address"), "10.0.0.10")
|
|
965
|
+
self.assertEqual(payload.get("ipv6_address"), "2001:db8::10")
|
|
966
|
+
|
|
967
|
+
|
|
643
968
|
class RegisterVisitorNodeMessageTests(TestCase):
|
|
644
969
|
def setUp(self):
|
|
645
970
|
self.client = Client()
|
|
@@ -650,7 +975,7 @@ class RegisterVisitorNodeMessageTests(TestCase):
|
|
|
650
975
|
self.visitor = Node.objects.create(
|
|
651
976
|
hostname="visitor-node",
|
|
652
977
|
address="10.0.0.100",
|
|
653
|
-
port=
|
|
978
|
+
port=8888,
|
|
654
979
|
mac_address="00:10:20:30:40:50",
|
|
655
980
|
role=self.role,
|
|
656
981
|
)
|
|
@@ -719,6 +1044,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
719
1044
|
patch(
|
|
720
1045
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
721
1046
|
),
|
|
1047
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
722
1048
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
723
1049
|
patch.object(Node, "ensure_keys"),
|
|
724
1050
|
patch.object(Node, "notify_peers_of_update") as mock_notify,
|
|
@@ -746,6 +1072,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
746
1072
|
patch(
|
|
747
1073
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
748
1074
|
),
|
|
1075
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
749
1076
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
750
1077
|
patch.object(Node, "ensure_keys"),
|
|
751
1078
|
):
|
|
@@ -764,6 +1091,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
764
1091
|
patch(
|
|
765
1092
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
766
1093
|
),
|
|
1094
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
767
1095
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
768
1096
|
patch.object(Node, "ensure_keys"),
|
|
769
1097
|
):
|
|
@@ -783,6 +1111,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
783
1111
|
patch(
|
|
784
1112
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
785
1113
|
),
|
|
1114
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
786
1115
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
787
1116
|
patch.object(Node, "ensure_keys"),
|
|
788
1117
|
):
|
|
@@ -808,17 +1137,27 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
808
1137
|
return_value="00:ff:ee:dd:cc:bb",
|
|
809
1138
|
),
|
|
810
1139
|
patch("nodes.models.socket.gethostname", return_value="localnode"),
|
|
1140
|
+
patch("nodes.models.socket.getfqdn", return_value="localnode.example.com"),
|
|
811
1141
|
patch(
|
|
812
1142
|
"nodes.models.socket.gethostbyname",
|
|
813
1143
|
return_value="192.168.1.5",
|
|
814
1144
|
),
|
|
1145
|
+
patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
|
|
1146
|
+
patch.object(
|
|
1147
|
+
Node,
|
|
1148
|
+
"_resolve_ip_addresses",
|
|
1149
|
+
return_value=(
|
|
1150
|
+
["192.168.1.5", "93.184.216.34"],
|
|
1151
|
+
["fe80::1", "2001:4860:4860::8888"],
|
|
1152
|
+
),
|
|
1153
|
+
),
|
|
815
1154
|
patch("nodes.models.revision.get_revision", return_value="newrev"),
|
|
816
1155
|
patch("requests.post") as mock_post,
|
|
817
1156
|
):
|
|
818
1157
|
Node.objects.create(
|
|
819
1158
|
hostname="localnode",
|
|
820
1159
|
address="192.168.1.5",
|
|
821
|
-
port=
|
|
1160
|
+
port=8888,
|
|
822
1161
|
mac_address="00:ff:ee:dd:cc:bb",
|
|
823
1162
|
installed_version="1.9.0",
|
|
824
1163
|
installed_revision="oldrev",
|
|
@@ -835,6 +1174,9 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
835
1174
|
self.assertEqual(payload["hostname"], "localnode")
|
|
836
1175
|
self.assertEqual(payload["installed_version"], "2.0.0")
|
|
837
1176
|
self.assertEqual(payload["installed_revision"], "newrev")
|
|
1177
|
+
self.assertEqual(payload.get("network_hostname"), "localnode.example.com")
|
|
1178
|
+
self.assertEqual(payload.get("ipv4_address"), "93.184.216.34")
|
|
1179
|
+
self.assertEqual(payload.get("ipv6_address"), "2001:4860:4860::8888")
|
|
838
1180
|
|
|
839
1181
|
def test_register_current_notifies_peers_without_version_change(self):
|
|
840
1182
|
Node.objects.create(
|
|
@@ -853,17 +1195,27 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
853
1195
|
return_value="00:ff:ee:dd:cc:cc",
|
|
854
1196
|
),
|
|
855
1197
|
patch("nodes.models.socket.gethostname", return_value="samever"),
|
|
1198
|
+
patch("nodes.models.socket.getfqdn", return_value="samever.example.com"),
|
|
856
1199
|
patch(
|
|
857
1200
|
"nodes.models.socket.gethostbyname",
|
|
858
1201
|
return_value="192.168.1.6",
|
|
859
1202
|
),
|
|
1203
|
+
patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
|
|
1204
|
+
patch.object(
|
|
1205
|
+
Node,
|
|
1206
|
+
"_resolve_ip_addresses",
|
|
1207
|
+
return_value=(
|
|
1208
|
+
["192.168.1.6", "93.184.216.35"],
|
|
1209
|
+
["fe80::2", "2001:4860:4860::8844"],
|
|
1210
|
+
),
|
|
1211
|
+
),
|
|
860
1212
|
patch("nodes.models.revision.get_revision", return_value="rev1"),
|
|
861
1213
|
patch("requests.post") as mock_post,
|
|
862
1214
|
):
|
|
863
1215
|
Node.objects.create(
|
|
864
1216
|
hostname="samever",
|
|
865
1217
|
address="192.168.1.6",
|
|
866
|
-
port=
|
|
1218
|
+
port=8888,
|
|
867
1219
|
mac_address="00:ff:ee:dd:cc:cc",
|
|
868
1220
|
installed_version="1.0.0",
|
|
869
1221
|
installed_revision="rev1",
|
|
@@ -878,6 +1230,44 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
878
1230
|
payload = json.loads(kwargs["data"])
|
|
879
1231
|
self.assertEqual(payload["installed_version"], "1.0.0")
|
|
880
1232
|
self.assertEqual(payload.get("installed_revision"), "rev1")
|
|
1233
|
+
self.assertEqual(payload.get("network_hostname"), "samever.example.com")
|
|
1234
|
+
self.assertEqual(payload.get("ipv4_address"), "93.184.216.35")
|
|
1235
|
+
self.assertEqual(payload.get("ipv6_address"), "2001:4860:4860::8844")
|
|
1236
|
+
|
|
1237
|
+
def test_register_current_populates_network_fields(self):
|
|
1238
|
+
with TemporaryDirectory() as tmp:
|
|
1239
|
+
base = Path(tmp)
|
|
1240
|
+
with override_settings(BASE_DIR=base):
|
|
1241
|
+
with (
|
|
1242
|
+
patch(
|
|
1243
|
+
"nodes.models.Node.get_current_mac",
|
|
1244
|
+
return_value="00:12:34:56:78:90",
|
|
1245
|
+
),
|
|
1246
|
+
patch("nodes.models.socket.gethostname", return_value="nodehost"),
|
|
1247
|
+
patch("nodes.models.socket.getfqdn", return_value="nodehost.example.com"),
|
|
1248
|
+
patch(
|
|
1249
|
+
"nodes.models.socket.gethostbyname",
|
|
1250
|
+
return_value="10.0.0.5",
|
|
1251
|
+
),
|
|
1252
|
+
patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
|
|
1253
|
+
patch.object(
|
|
1254
|
+
Node,
|
|
1255
|
+
"_resolve_ip_addresses",
|
|
1256
|
+
return_value=(
|
|
1257
|
+
["10.0.0.5", "93.184.216.36"],
|
|
1258
|
+
["fe80::5", "2001:4860:4860::1"],
|
|
1259
|
+
),
|
|
1260
|
+
),
|
|
1261
|
+
patch("nodes.models.revision.get_revision", return_value="revX"),
|
|
1262
|
+
patch.object(Node, "ensure_keys"),
|
|
1263
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
1264
|
+
):
|
|
1265
|
+
node, created = Node.register_current()
|
|
1266
|
+
self.assertTrue(created)
|
|
1267
|
+
self.assertEqual(node.network_hostname, "nodehost.example.com")
|
|
1268
|
+
self.assertEqual(node.ipv4_address, "93.184.216.36")
|
|
1269
|
+
self.assertEqual(node.ipv6_address, "2001:4860:4860::1")
|
|
1270
|
+
self.assertEqual(node.address, "93.184.216.36")
|
|
881
1271
|
|
|
882
1272
|
@patch("nodes.views.capture_screenshot")
|
|
883
1273
|
def test_capture_screenshot(self, mock_capture):
|
|
@@ -1005,7 +1395,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1005
1395
|
sender = Node.objects.create(
|
|
1006
1396
|
hostname="sender",
|
|
1007
1397
|
address="10.0.0.1",
|
|
1008
|
-
port=
|
|
1398
|
+
port=8888,
|
|
1009
1399
|
mac_address="00:11:22:33:44:cc",
|
|
1010
1400
|
public_key=public_key,
|
|
1011
1401
|
)
|
|
@@ -1236,6 +1626,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1236
1626
|
existing_role.refresh_from_db()
|
|
1237
1627
|
self.assertEqual(existing_role.description, "updated via attachment")
|
|
1238
1628
|
|
|
1629
|
+
@pytest.mark.feature("clipboard-poll")
|
|
1239
1630
|
def test_clipboard_polling_creates_task(self):
|
|
1240
1631
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
1241
1632
|
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
@@ -1246,13 +1637,17 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1246
1637
|
port=9000,
|
|
1247
1638
|
mac_address="00:11:22:33:44:99",
|
|
1248
1639
|
)
|
|
1249
|
-
|
|
1250
|
-
|
|
1640
|
+
raw_name = f"poll_clipboard_node_{node.pk}"
|
|
1641
|
+
task_name = slugify_task_name(raw_name)
|
|
1642
|
+
PeriodicTask.objects.filter(
|
|
1643
|
+
name__in=periodic_task_name_variants(raw_name)
|
|
1644
|
+
).delete()
|
|
1251
1645
|
NodeFeatureAssignment.objects.create(node=node, feature=feature)
|
|
1252
1646
|
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
|
1253
1647
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
|
|
1254
1648
|
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
1255
1649
|
|
|
1650
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1256
1651
|
def test_screenshot_polling_creates_task(self):
|
|
1257
1652
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
1258
1653
|
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
@@ -1263,8 +1658,11 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1263
1658
|
port=9100,
|
|
1264
1659
|
mac_address="00:11:22:33:44:aa",
|
|
1265
1660
|
)
|
|
1266
|
-
|
|
1267
|
-
|
|
1661
|
+
raw_name = f"capture_screenshot_node_{node.pk}"
|
|
1662
|
+
task_name = slugify_task_name(raw_name)
|
|
1663
|
+
PeriodicTask.objects.filter(
|
|
1664
|
+
name__in=periodic_task_name_variants(raw_name)
|
|
1665
|
+
).delete()
|
|
1268
1666
|
NodeFeatureAssignment.objects.create(node=node, feature=feature)
|
|
1269
1667
|
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
|
1270
1668
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
|
|
@@ -1283,14 +1681,18 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1283
1681
|
"base_path": settings.BASE_DIR,
|
|
1284
1682
|
},
|
|
1285
1683
|
)
|
|
1286
|
-
|
|
1684
|
+
raw_name = "pages_purge_landing_leads"
|
|
1685
|
+
task_name = slugify_task_name(raw_name)
|
|
1686
|
+
PeriodicTask.objects.filter(
|
|
1687
|
+
name__in=periodic_task_name_variants(raw_name)
|
|
1688
|
+
).delete()
|
|
1287
1689
|
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1288
1690
|
self.assertTrue(
|
|
1289
|
-
PeriodicTask.objects.filter(name=
|
|
1691
|
+
PeriodicTask.objects.filter(name=task_name).exists()
|
|
1290
1692
|
)
|
|
1291
1693
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
|
|
1292
1694
|
self.assertFalse(
|
|
1293
|
-
PeriodicTask.objects.filter(name=
|
|
1695
|
+
PeriodicTask.objects.filter(name=task_name).exists()
|
|
1294
1696
|
)
|
|
1295
1697
|
|
|
1296
1698
|
def test_ocpp_session_report_task_syncs_with_feature(self):
|
|
@@ -1306,8 +1708,11 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1306
1708
|
"base_path": settings.BASE_DIR,
|
|
1307
1709
|
},
|
|
1308
1710
|
)
|
|
1309
|
-
|
|
1310
|
-
|
|
1711
|
+
raw_name = "ocpp_send_daily_session_report"
|
|
1712
|
+
task_name = slugify_task_name(raw_name)
|
|
1713
|
+
PeriodicTask.objects.filter(
|
|
1714
|
+
name__in=periodic_task_name_variants(raw_name)
|
|
1715
|
+
).delete()
|
|
1311
1716
|
|
|
1312
1717
|
with patch("nodes.models.mailer.can_send_email", return_value=True):
|
|
1313
1718
|
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
@@ -1330,8 +1735,11 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1330
1735
|
"base_path": settings.BASE_DIR,
|
|
1331
1736
|
},
|
|
1332
1737
|
)
|
|
1333
|
-
|
|
1334
|
-
|
|
1738
|
+
raw_name = "ocpp_send_daily_session_report"
|
|
1739
|
+
task_name = slugify_task_name(raw_name)
|
|
1740
|
+
PeriodicTask.objects.filter(
|
|
1741
|
+
name__in=periodic_task_name_variants(raw_name)
|
|
1742
|
+
).delete()
|
|
1335
1743
|
|
|
1336
1744
|
with patch("nodes.models.mailer.can_send_email", return_value=False):
|
|
1337
1745
|
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
@@ -1367,7 +1775,7 @@ class NodeAdminTests(TestCase):
|
|
|
1367
1775
|
return Node.objects.create(
|
|
1368
1776
|
hostname="localnode",
|
|
1369
1777
|
address="127.0.0.1",
|
|
1370
|
-
port=
|
|
1778
|
+
port=8888,
|
|
1371
1779
|
mac_address=Node.get_current_mac(),
|
|
1372
1780
|
)
|
|
1373
1781
|
|
|
@@ -1381,6 +1789,7 @@ class NodeAdminTests(TestCase):
|
|
|
1381
1789
|
action_url = reverse("admin:core_rfid_scan")
|
|
1382
1790
|
self.assertContains(response, f'href="{action_url}"')
|
|
1383
1791
|
|
|
1792
|
+
@pytest.mark.feature("rpi-camera")
|
|
1384
1793
|
def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
|
|
1385
1794
|
node = self._create_local_node()
|
|
1386
1795
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1393,6 +1802,7 @@ class NodeAdminTests(TestCase):
|
|
|
1393
1802
|
self.assertContains(response, f'href="{snapshot_url}"')
|
|
1394
1803
|
self.assertContains(response, f'href="{stream_url}"')
|
|
1395
1804
|
|
|
1805
|
+
@pytest.mark.feature("audio-capture")
|
|
1396
1806
|
def test_node_feature_list_shows_waveform_action_when_enabled(self):
|
|
1397
1807
|
node = self._create_local_node()
|
|
1398
1808
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1403,6 +1813,7 @@ class NodeAdminTests(TestCase):
|
|
|
1403
1813
|
action_url = reverse("admin:nodes_nodefeature_view_waveform")
|
|
1404
1814
|
self.assertContains(response, f'href="{action_url}"')
|
|
1405
1815
|
|
|
1816
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1406
1817
|
def test_node_feature_list_hides_default_action_when_disabled(self):
|
|
1407
1818
|
self._create_local_node()
|
|
1408
1819
|
NodeFeature.objects.get_or_create(
|
|
@@ -1439,7 +1850,7 @@ class NodeAdminTests(TestCase):
|
|
|
1439
1850
|
Node.objects.create(
|
|
1440
1851
|
hostname=hostname,
|
|
1441
1852
|
address="127.0.0.1",
|
|
1442
|
-
port=
|
|
1853
|
+
port=8888,
|
|
1443
1854
|
mac_address=None,
|
|
1444
1855
|
)
|
|
1445
1856
|
|
|
@@ -1495,6 +1906,129 @@ class NodeAdminTests(TestCase):
|
|
|
1495
1906
|
response, reverse("admin:nodes_node_register_current")
|
|
1496
1907
|
)
|
|
1497
1908
|
|
|
1909
|
+
def test_apply_remote_node_info_updates_role(self):
|
|
1910
|
+
terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
1911
|
+
control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1912
|
+
node = Node.objects.create(
|
|
1913
|
+
hostname="remote-node",
|
|
1914
|
+
address="10.0.0.20",
|
|
1915
|
+
port=8001,
|
|
1916
|
+
mac_address="00:11:22:33:44:aa",
|
|
1917
|
+
role=terminal,
|
|
1918
|
+
)
|
|
1919
|
+
admin_instance = NodeAdmin(Node, admin.site)
|
|
1920
|
+
|
|
1921
|
+
payload = {
|
|
1922
|
+
"hostname": node.hostname,
|
|
1923
|
+
"network_hostname": "remote-node.example.com",
|
|
1924
|
+
"address": node.address,
|
|
1925
|
+
"ipv4_address": "198.51.100.10",
|
|
1926
|
+
"ipv6_address": "2001:db8::10",
|
|
1927
|
+
"port": node.port,
|
|
1928
|
+
"role": "Control",
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
changed = admin_instance._apply_remote_node_info(node, payload)
|
|
1932
|
+
node.refresh_from_db()
|
|
1933
|
+
|
|
1934
|
+
self.assertIn("role", changed)
|
|
1935
|
+
self.assertIn("network_hostname", changed)
|
|
1936
|
+
self.assertIn("ipv4_address", changed)
|
|
1937
|
+
self.assertIn("ipv6_address", changed)
|
|
1938
|
+
self.assertEqual(node.role, control)
|
|
1939
|
+
self.assertEqual(node.network_hostname, "remote-node.example.com")
|
|
1940
|
+
self.assertEqual(node.ipv4_address, "198.51.100.10")
|
|
1941
|
+
self.assertEqual(node.ipv6_address, "2001:db8::10")
|
|
1942
|
+
self.assertTrue(control.node_set.filter(pk=node.pk).exists())
|
|
1943
|
+
self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
|
|
1944
|
+
|
|
1945
|
+
def test_apply_remote_node_info_accepts_role_name_key(self):
|
|
1946
|
+
terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
1947
|
+
control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1948
|
+
node = Node.objects.create(
|
|
1949
|
+
hostname="role-name-node",
|
|
1950
|
+
address="10.0.0.21",
|
|
1951
|
+
port=8002,
|
|
1952
|
+
mac_address="00:11:22:33:44:bb",
|
|
1953
|
+
role=terminal,
|
|
1954
|
+
)
|
|
1955
|
+
admin_instance = NodeAdmin(Node, admin.site)
|
|
1956
|
+
|
|
1957
|
+
payload = {
|
|
1958
|
+
"hostname": node.hostname,
|
|
1959
|
+
"network_hostname": "role-name-node.example.com",
|
|
1960
|
+
"address": node.address,
|
|
1961
|
+
"ipv4_address": "198.51.100.11",
|
|
1962
|
+
"ipv6_address": "2001:db8::11",
|
|
1963
|
+
"port": node.port,
|
|
1964
|
+
"role_name": "Control",
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
changed = admin_instance._apply_remote_node_info(node, payload)
|
|
1968
|
+
node.refresh_from_db()
|
|
1969
|
+
|
|
1970
|
+
self.assertIn("role", changed)
|
|
1971
|
+
self.assertEqual(node.network_hostname, "role-name-node.example.com")
|
|
1972
|
+
self.assertEqual(node.ipv4_address, "198.51.100.11")
|
|
1973
|
+
self.assertEqual(node.ipv6_address, "2001:db8::11")
|
|
1974
|
+
self.assertEqual(node.role, control)
|
|
1975
|
+
self.assertTrue(control.node_set.filter(pk=node.pk).exists())
|
|
1976
|
+
self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
|
|
1977
|
+
|
|
1978
|
+
@patch("nodes.admin.requests.post")
|
|
1979
|
+
def test_send_forwarding_metadata_retries_until_success(self, mock_post):
|
|
1980
|
+
request = RequestFactory().get("/")
|
|
1981
|
+
local_node = Node.objects.create(
|
|
1982
|
+
hostname="local",
|
|
1983
|
+
address="127.0.0.1",
|
|
1984
|
+
port=8888,
|
|
1985
|
+
mac_address="00:11:22:33:44:aa",
|
|
1986
|
+
public_key="LOCAL-PUB",
|
|
1987
|
+
)
|
|
1988
|
+
class DummyNode:
|
|
1989
|
+
def __init__(self):
|
|
1990
|
+
self.port = 8443
|
|
1991
|
+
self.urls: list[str] = []
|
|
1992
|
+
|
|
1993
|
+
def iter_remote_urls(self, path: str):
|
|
1994
|
+
self.urls.append(path)
|
|
1995
|
+
yield "https://unreachable.example"
|
|
1996
|
+
yield "https://reachable.example"
|
|
1997
|
+
|
|
1998
|
+
def __str__(self): # pragma: no cover - trivial representation
|
|
1999
|
+
return "dummy-node"
|
|
2000
|
+
|
|
2001
|
+
target = DummyNode()
|
|
2002
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
2003
|
+
charger = Charger.objects.create(charger_id="FORWARD-1")
|
|
2004
|
+
|
|
2005
|
+
failure = MagicMock()
|
|
2006
|
+
failure.ok = False
|
|
2007
|
+
failure.status_code = 404
|
|
2008
|
+
failure.json.return_value = {"detail": "not found"}
|
|
2009
|
+
success = MagicMock()
|
|
2010
|
+
success.ok = True
|
|
2011
|
+
success.status_code = 200
|
|
2012
|
+
success.json.return_value = {"status": "ok"}
|
|
2013
|
+
|
|
2014
|
+
mock_post.side_effect = [failure, success]
|
|
2015
|
+
|
|
2016
|
+
admin_instance = NodeAdmin(Node, admin.site)
|
|
2017
|
+
|
|
2018
|
+
result = admin_instance._send_forwarding_metadata(
|
|
2019
|
+
request, target, [charger], local_node, private_key
|
|
2020
|
+
)
|
|
2021
|
+
|
|
2022
|
+
self.assertTrue(result)
|
|
2023
|
+
self.assertEqual(
|
|
2024
|
+
target.urls, ["/nodes/network/chargers/forward/"]
|
|
2025
|
+
)
|
|
2026
|
+
self.assertEqual(mock_post.call_count, 2)
|
|
2027
|
+
self.assertEqual(
|
|
2028
|
+
mock_post.call_args_list[1].args[0], "https://reachable.example"
|
|
2029
|
+
)
|
|
2030
|
+
|
|
2031
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1498
2032
|
@patch("nodes.admin.capture_screenshot")
|
|
1499
2033
|
def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
|
|
1500
2034
|
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
@@ -1540,6 +2074,53 @@ class NodeAdminTests(TestCase):
|
|
|
1540
2074
|
self.assertEqual(response.status_code, 200)
|
|
1541
2075
|
self.assertContains(response, "data:image/png;base64")
|
|
1542
2076
|
|
|
2077
|
+
def test_visit_link_uses_local_admin_dashboard_for_local_node(self):
|
|
2078
|
+
node_admin = admin.site._registry[Node]
|
|
2079
|
+
local_node = self._create_local_node()
|
|
2080
|
+
|
|
2081
|
+
link_html = node_admin.visit_link(local_node)
|
|
2082
|
+
|
|
2083
|
+
self.assertIn(reverse("admin:index"), link_html)
|
|
2084
|
+
self.assertIn("target=\"_blank\"", link_html)
|
|
2085
|
+
|
|
2086
|
+
def test_visit_link_prefers_remote_hostname_for_dashboard(self):
|
|
2087
|
+
node_admin = admin.site._registry[Node]
|
|
2088
|
+
remote = Node.objects.create(
|
|
2089
|
+
hostname="remote.example.com",
|
|
2090
|
+
address="198.51.100.20",
|
|
2091
|
+
port=8443,
|
|
2092
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
2093
|
+
)
|
|
2094
|
+
|
|
2095
|
+
link_html = node_admin.visit_link(remote)
|
|
2096
|
+
|
|
2097
|
+
self.assertIn("https://remote.example.com:8443/admin/", link_html)
|
|
2098
|
+
self.assertIn("target=\"_blank\"", link_html)
|
|
2099
|
+
|
|
2100
|
+
def test_iter_remote_urls_handles_hostname_with_path_and_port(self):
|
|
2101
|
+
node_admin = admin.site._registry[Node]
|
|
2102
|
+
remote = SimpleNamespace(
|
|
2103
|
+
public_endpoint="",
|
|
2104
|
+
address="",
|
|
2105
|
+
hostname="example.com/interface",
|
|
2106
|
+
port=8443,
|
|
2107
|
+
)
|
|
2108
|
+
|
|
2109
|
+
urls = list(node_admin._iter_remote_urls(remote, "/nodes/info/"))
|
|
2110
|
+
|
|
2111
|
+
self.assertIn(
|
|
2112
|
+
"https://example.com:8443/interface/nodes/info/",
|
|
2113
|
+
urls,
|
|
2114
|
+
)
|
|
2115
|
+
self.assertIn(
|
|
2116
|
+
"http://example.com:8443/interface/nodes/info/",
|
|
2117
|
+
urls,
|
|
2118
|
+
)
|
|
2119
|
+
combined = "".join(urls)
|
|
2120
|
+
self.assertNotIn("interface:8443", combined)
|
|
2121
|
+
|
|
2122
|
+
|
|
2123
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1543
2124
|
@override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
|
|
1544
2125
|
@patch("nodes.admin.capture_screenshot")
|
|
1545
2126
|
def test_take_screenshots_action(self, mock_capture):
|
|
@@ -1572,6 +2153,7 @@ class NodeAdminTests(TestCase):
|
|
|
1572
2153
|
samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
|
|
1573
2154
|
self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
|
|
1574
2155
|
|
|
2156
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1575
2157
|
@patch("nodes.admin.capture_screenshot")
|
|
1576
2158
|
def test_take_screenshot_default_action_creates_sample(
|
|
1577
2159
|
self, mock_capture_screenshot
|
|
@@ -1646,6 +2228,35 @@ class NodeAdminTests(TestCase):
|
|
|
1646
2228
|
response, "Completed 0 of 1 feature check(s) successfully.", html=False
|
|
1647
2229
|
)
|
|
1648
2230
|
|
|
2231
|
+
@pytest.mark.feature("audio-capture")
|
|
2232
|
+
@patch("nodes.models.Node._has_audio_capture_device", return_value=False)
|
|
2233
|
+
def test_check_features_for_eligibility_audio_capture_requires_device(
|
|
2234
|
+
self, mock_device
|
|
2235
|
+
):
|
|
2236
|
+
self._create_local_node()
|
|
2237
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
2238
|
+
slug="audio-capture", defaults={"display": "Audio Capture"}
|
|
2239
|
+
)
|
|
2240
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
2241
|
+
response = self.client.post(
|
|
2242
|
+
changelist_url,
|
|
2243
|
+
{
|
|
2244
|
+
"action": "check_features_for_eligibility",
|
|
2245
|
+
"_selected_action": [str(feature.pk)],
|
|
2246
|
+
},
|
|
2247
|
+
follow=True,
|
|
2248
|
+
)
|
|
2249
|
+
self.assertEqual(response.status_code, 200)
|
|
2250
|
+
self.assertContains(
|
|
2251
|
+
response,
|
|
2252
|
+
"No audio recording device detected on localnode for Audio Capture. This feature can be enabled manually.",
|
|
2253
|
+
html=False,
|
|
2254
|
+
)
|
|
2255
|
+
self.assertContains(
|
|
2256
|
+
response, "Completed 0 of 1 feature check(s) successfully.", html=False
|
|
2257
|
+
)
|
|
2258
|
+
|
|
2259
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1649
2260
|
def test_enable_selected_features_enables_manual_feature(self):
|
|
1650
2261
|
node = self._create_local_node()
|
|
1651
2262
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1690,6 +2301,7 @@ class NodeAdminTests(TestCase):
|
|
|
1690
2301
|
html=False,
|
|
1691
2302
|
)
|
|
1692
2303
|
|
|
2304
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1693
2305
|
def test_take_screenshot_default_action_requires_enabled_feature(self):
|
|
1694
2306
|
self._create_local_node()
|
|
1695
2307
|
NodeFeature.objects.get_or_create(
|
|
@@ -1704,6 +2316,7 @@ class NodeAdminTests(TestCase):
|
|
|
1704
2316
|
self.assertEqual(ContentSample.objects.count(), 0)
|
|
1705
2317
|
self.assertContains(response, "Screenshot Poll feature is not enabled")
|
|
1706
2318
|
|
|
2319
|
+
@pytest.mark.feature("rpi-camera")
|
|
1707
2320
|
@patch("nodes.admin.capture_rpi_snapshot")
|
|
1708
2321
|
def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
|
|
1709
2322
|
node = self._create_local_node()
|
|
@@ -1726,6 +2339,7 @@ class NodeAdminTests(TestCase):
|
|
|
1726
2339
|
change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
|
|
1727
2340
|
self.assertEqual(response.redirect_chain[-1][0], change_url)
|
|
1728
2341
|
|
|
2342
|
+
@pytest.mark.feature("rpi-camera")
|
|
1729
2343
|
def test_view_stream_requires_enabled_feature(self):
|
|
1730
2344
|
self._create_local_node()
|
|
1731
2345
|
NodeFeature.objects.get_or_create(
|
|
@@ -1741,6 +2355,7 @@ class NodeAdminTests(TestCase):
|
|
|
1741
2355
|
response, "Raspberry Pi Camera feature is not enabled on this node."
|
|
1742
2356
|
)
|
|
1743
2357
|
|
|
2358
|
+
@pytest.mark.feature("rpi-camera")
|
|
1744
2359
|
def test_view_stream_renders_when_feature_enabled(self):
|
|
1745
2360
|
node = self._create_local_node()
|
|
1746
2361
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1756,6 +2371,7 @@ class NodeAdminTests(TestCase):
|
|
|
1756
2371
|
self.assertContains(response, expected_stream)
|
|
1757
2372
|
self.assertContains(response, "camera-stream__frame")
|
|
1758
2373
|
|
|
2374
|
+
@pytest.mark.feature("rpi-camera")
|
|
1759
2375
|
def test_view_stream_uses_configured_stream_url(self):
|
|
1760
2376
|
node = self._create_local_node()
|
|
1761
2377
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1773,6 +2389,7 @@ class NodeAdminTests(TestCase):
|
|
|
1773
2389
|
self.assertEqual(response.context_data["stream_embed"], "iframe")
|
|
1774
2390
|
self.assertContains(response, configured_stream)
|
|
1775
2391
|
|
|
2392
|
+
@pytest.mark.feature("rpi-camera")
|
|
1776
2393
|
def test_view_stream_detects_mjpeg_stream(self):
|
|
1777
2394
|
node = self._create_local_node()
|
|
1778
2395
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1789,6 +2406,7 @@ class NodeAdminTests(TestCase):
|
|
|
1789
2406
|
self.assertEqual(response.context_data["stream_embed"], "mjpeg")
|
|
1790
2407
|
self.assertContains(response, "<img", html=False)
|
|
1791
2408
|
|
|
2409
|
+
@pytest.mark.feature("rpi-camera")
|
|
1792
2410
|
def test_view_stream_marks_rtsp_stream_as_unsupported(self):
|
|
1793
2411
|
node = self._create_local_node()
|
|
1794
2412
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1805,6 +2423,7 @@ class NodeAdminTests(TestCase):
|
|
|
1805
2423
|
self.assertEqual(response.context_data["stream_embed"], "unsupported")
|
|
1806
2424
|
self.assertContains(response, "camera-stream__unsupported")
|
|
1807
2425
|
|
|
2426
|
+
@pytest.mark.feature("audio-capture")
|
|
1808
2427
|
def test_view_waveform_requires_enabled_feature(self):
|
|
1809
2428
|
self._create_local_node()
|
|
1810
2429
|
NodeFeature.objects.get_or_create(
|
|
@@ -1820,6 +2439,7 @@ class NodeAdminTests(TestCase):
|
|
|
1820
2439
|
response, "Audio Capture feature is not enabled on this node."
|
|
1821
2440
|
)
|
|
1822
2441
|
|
|
2442
|
+
@pytest.mark.feature("audio-capture")
|
|
1823
2443
|
def test_view_waveform_renders_when_feature_enabled(self):
|
|
1824
2444
|
node = self._create_local_node()
|
|
1825
2445
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -2072,11 +2692,58 @@ class NodeAdminTests(TestCase):
|
|
|
2072
2692
|
)
|
|
2073
2693
|
self.assertContains(response, str(remote))
|
|
2074
2694
|
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2695
|
+
def test_send_net_message_action_displays_form(self):
|
|
2696
|
+
target = Node.objects.create(
|
|
2697
|
+
hostname="remote-one", address="10.0.0.10", port=8020
|
|
2698
|
+
)
|
|
2699
|
+
response = self.client.post(
|
|
2700
|
+
reverse("admin:nodes_node_changelist"),
|
|
2701
|
+
{
|
|
2702
|
+
"action": "send_net_message",
|
|
2703
|
+
helpers.ACTION_CHECKBOX_NAME: [str(target.pk)],
|
|
2704
|
+
},
|
|
2705
|
+
follow=False,
|
|
2706
|
+
)
|
|
2707
|
+
self.assertEqual(response.status_code, 200)
|
|
2708
|
+
response.render()
|
|
2709
|
+
self.assertContains(response, "Send Net Message")
|
|
2710
|
+
self.assertContains(response, str(target))
|
|
2711
|
+
self.assertContains(response, 'name="apply"')
|
|
2712
|
+
self.assertContains(response, "Selected node (1)")
|
|
2713
|
+
|
|
2714
|
+
@patch("nodes.admin.NetMessage.propagate")
|
|
2715
|
+
def test_send_net_message_action_creates_messages(self, mock_propagate):
|
|
2716
|
+
first = Node.objects.create(
|
|
2717
|
+
hostname="remote-two", address="10.0.0.11", port=8021
|
|
2718
|
+
)
|
|
2719
|
+
second = Node.objects.create(
|
|
2720
|
+
hostname="remote-three", address="10.0.0.12", port=8022
|
|
2721
|
+
)
|
|
2722
|
+
url = reverse("admin:nodes_node_changelist")
|
|
2723
|
+
payload = {
|
|
2724
|
+
"action": "send_net_message",
|
|
2725
|
+
"apply": "1",
|
|
2726
|
+
helpers.ACTION_CHECKBOX_NAME: [str(first.pk), str(second.pk)],
|
|
2727
|
+
"subject": "Maintenance",
|
|
2728
|
+
"body": "We will reboot tonight.",
|
|
2729
|
+
}
|
|
2730
|
+
existing_ids = set(NetMessage.objects.values_list("pk", flat=True))
|
|
2731
|
+
response = self.client.post(url, payload, follow=True)
|
|
2732
|
+
self.assertEqual(response.status_code, 200)
|
|
2733
|
+
new_messages = NetMessage.objects.exclude(pk__in=existing_ids)
|
|
2734
|
+
self.assertEqual(new_messages.count(), 2)
|
|
2735
|
+
self.assertEqual(mock_propagate.call_count, 2)
|
|
2736
|
+
for node in (first, second):
|
|
2737
|
+
message = new_messages.get(filter_node=node)
|
|
2738
|
+
self.assertEqual(message.subject, "Maintenance")
|
|
2739
|
+
self.assertEqual(message.body, "We will reboot tonight.")
|
|
2740
|
+
self.assertContains(response, "Sent 2 net messages.")
|
|
2741
|
+
|
|
2742
|
+
@patch("nodes.admin.requests.post")
|
|
2743
|
+
@patch("nodes.admin.requests.get")
|
|
2744
|
+
def test_update_selected_nodes_progress_updates_remote(
|
|
2745
|
+
self, mock_get, mock_post
|
|
2746
|
+
):
|
|
2080
2747
|
local = self._create_local_node()
|
|
2081
2748
|
local.public_endpoint = "localnode"
|
|
2082
2749
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
@@ -2142,6 +2809,186 @@ class NodeAdminTests(TestCase):
|
|
|
2142
2809
|
self.assertEqual(post_data["mac_address"], local.mac_address)
|
|
2143
2810
|
|
|
2144
2811
|
|
|
2812
|
+
class NodeProxyGatewayTests(TestCase):
|
|
2813
|
+
def setUp(self):
|
|
2814
|
+
cache.clear()
|
|
2815
|
+
self.client = Client()
|
|
2816
|
+
self.private_key = rsa.generate_private_key(
|
|
2817
|
+
public_exponent=65537, key_size=2048
|
|
2818
|
+
)
|
|
2819
|
+
public_key = self.private_key.public_key().public_bytes(
|
|
2820
|
+
encoding=serialization.Encoding.PEM,
|
|
2821
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
2822
|
+
).decode()
|
|
2823
|
+
self.node = Node.objects.create(
|
|
2824
|
+
hostname="requester",
|
|
2825
|
+
address="127.0.0.1",
|
|
2826
|
+
port=8888,
|
|
2827
|
+
mac_address="aa:bb:cc:dd:ee:aa",
|
|
2828
|
+
public_key=public_key,
|
|
2829
|
+
)
|
|
2830
|
+
patcher = patch("requests.post")
|
|
2831
|
+
self.addCleanup(patcher.stop)
|
|
2832
|
+
self.mock_requests_post = patcher.start()
|
|
2833
|
+
self.mock_requests_post.return_value = SimpleNamespace(
|
|
2834
|
+
ok=True,
|
|
2835
|
+
status_code=200,
|
|
2836
|
+
json=lambda: {},
|
|
2837
|
+
text="",
|
|
2838
|
+
)
|
|
2839
|
+
|
|
2840
|
+
def tearDown(self):
|
|
2841
|
+
cache.clear()
|
|
2842
|
+
|
|
2843
|
+
def _sign(self, payload):
|
|
2844
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
2845
|
+
signature = base64.b64encode(
|
|
2846
|
+
self.private_key.sign(
|
|
2847
|
+
body.encode(), padding.PKCS1v15(), hashes.SHA256()
|
|
2848
|
+
)
|
|
2849
|
+
).decode()
|
|
2850
|
+
return body, signature
|
|
2851
|
+
|
|
2852
|
+
def test_proxy_session_creates_login_url(self):
|
|
2853
|
+
payload = {
|
|
2854
|
+
"requester": str(self.node.uuid),
|
|
2855
|
+
"user": {
|
|
2856
|
+
"username": "proxy-user",
|
|
2857
|
+
"email": "proxy@example.com",
|
|
2858
|
+
"first_name": "Proxy",
|
|
2859
|
+
"last_name": "User",
|
|
2860
|
+
"is_staff": True,
|
|
2861
|
+
"is_superuser": True,
|
|
2862
|
+
"groups": [],
|
|
2863
|
+
"permissions": [],
|
|
2864
|
+
},
|
|
2865
|
+
"target": "/admin/",
|
|
2866
|
+
}
|
|
2867
|
+
body, signature = self._sign(payload)
|
|
2868
|
+
response = self.client.post(
|
|
2869
|
+
reverse("node-proxy-session"),
|
|
2870
|
+
data=body,
|
|
2871
|
+
content_type="application/json",
|
|
2872
|
+
HTTP_X_SIGNATURE=signature,
|
|
2873
|
+
)
|
|
2874
|
+
self.assertEqual(response.status_code, 200)
|
|
2875
|
+
data = response.json()
|
|
2876
|
+
self.assertIn("login_url", data)
|
|
2877
|
+
user = get_user_model().objects.get(username="proxy-user")
|
|
2878
|
+
self.assertTrue(user.is_staff)
|
|
2879
|
+
parsed = urlparse(data["login_url"])
|
|
2880
|
+
login_response = self.client.get(parsed.path)
|
|
2881
|
+
self.assertEqual(login_response.status_code, 302)
|
|
2882
|
+
self.assertEqual(login_response["Location"], "/admin/")
|
|
2883
|
+
self.assertEqual(self.client.session.get("_auth_user_id"), str(user.pk))
|
|
2884
|
+
second = self.client.get(parsed.path)
|
|
2885
|
+
self.assertEqual(second.status_code, 410)
|
|
2886
|
+
|
|
2887
|
+
def test_proxy_session_accepts_mac_hint_when_uuid_unknown(self):
|
|
2888
|
+
payload = {
|
|
2889
|
+
"requester": str(uuid.uuid4()),
|
|
2890
|
+
"requester_mac": self.node.mac_address,
|
|
2891
|
+
"requester_public_key": self.node.public_key,
|
|
2892
|
+
"user": {
|
|
2893
|
+
"username": "proxy-user",
|
|
2894
|
+
"email": "proxy@example.com",
|
|
2895
|
+
"first_name": "Proxy",
|
|
2896
|
+
"last_name": "User",
|
|
2897
|
+
"is_staff": True,
|
|
2898
|
+
"is_superuser": True,
|
|
2899
|
+
"groups": [],
|
|
2900
|
+
"permissions": [],
|
|
2901
|
+
},
|
|
2902
|
+
"target": "/admin/",
|
|
2903
|
+
}
|
|
2904
|
+
body, signature = self._sign(payload)
|
|
2905
|
+
response = self.client.post(
|
|
2906
|
+
reverse("node-proxy-session"),
|
|
2907
|
+
data=body,
|
|
2908
|
+
content_type="application/json",
|
|
2909
|
+
HTTP_X_SIGNATURE=signature,
|
|
2910
|
+
)
|
|
2911
|
+
self.assertEqual(response.status_code, 200)
|
|
2912
|
+
|
|
2913
|
+
def test_proxy_execute_lists_nodes(self):
|
|
2914
|
+
Node.objects.create(
|
|
2915
|
+
hostname="target",
|
|
2916
|
+
address="127.0.0.5",
|
|
2917
|
+
port=8010,
|
|
2918
|
+
mac_address="aa:bb:cc:dd:ee:bb",
|
|
2919
|
+
)
|
|
2920
|
+
payload = {
|
|
2921
|
+
"requester": str(self.node.uuid),
|
|
2922
|
+
"action": "list",
|
|
2923
|
+
"model": "nodes.Node",
|
|
2924
|
+
"filters": {"hostname": "target"},
|
|
2925
|
+
"credentials": {
|
|
2926
|
+
"username": "suite-user",
|
|
2927
|
+
"password": "secret",
|
|
2928
|
+
"first_name": "Suite",
|
|
2929
|
+
"last_name": "User",
|
|
2930
|
+
},
|
|
2931
|
+
}
|
|
2932
|
+
body, signature = self._sign(payload)
|
|
2933
|
+
response = self.client.post(
|
|
2934
|
+
reverse("node-proxy-execute"),
|
|
2935
|
+
data=body,
|
|
2936
|
+
content_type="application/json",
|
|
2937
|
+
HTTP_X_SIGNATURE=signature,
|
|
2938
|
+
)
|
|
2939
|
+
self.assertEqual(response.status_code, 200)
|
|
2940
|
+
data = response.json()
|
|
2941
|
+
self.assertEqual(len(data.get("objects", [])), 1)
|
|
2942
|
+
record = data["objects"][0]
|
|
2943
|
+
self.assertEqual(record["fields"]["hostname"], "target")
|
|
2944
|
+
user = get_user_model().objects.get(username="suite-user")
|
|
2945
|
+
self.assertTrue(user.is_superuser)
|
|
2946
|
+
|
|
2947
|
+
def test_proxy_execute_requires_valid_password_for_existing_user(self):
|
|
2948
|
+
User = get_user_model()
|
|
2949
|
+
User.objects.create_user(username="suite-user", password="correct")
|
|
2950
|
+
payload = {
|
|
2951
|
+
"requester": str(self.node.uuid),
|
|
2952
|
+
"action": "list",
|
|
2953
|
+
"model": "nodes.Node",
|
|
2954
|
+
"credentials": {
|
|
2955
|
+
"username": "suite-user",
|
|
2956
|
+
"password": "wrong",
|
|
2957
|
+
},
|
|
2958
|
+
}
|
|
2959
|
+
body, signature = self._sign(payload)
|
|
2960
|
+
response = self.client.post(
|
|
2961
|
+
reverse("node-proxy-execute"),
|
|
2962
|
+
data=body,
|
|
2963
|
+
content_type="application/json",
|
|
2964
|
+
HTTP_X_SIGNATURE=signature,
|
|
2965
|
+
)
|
|
2966
|
+
self.assertEqual(response.status_code, 403)
|
|
2967
|
+
|
|
2968
|
+
def test_proxy_execute_schema_returns_models(self):
|
|
2969
|
+
payload = {
|
|
2970
|
+
"requester": str(self.node.uuid),
|
|
2971
|
+
"action": "schema",
|
|
2972
|
+
"credentials": {
|
|
2973
|
+
"username": "suite-user",
|
|
2974
|
+
"password": "secret",
|
|
2975
|
+
},
|
|
2976
|
+
}
|
|
2977
|
+
body, signature = self._sign(payload)
|
|
2978
|
+
response = self.client.post(
|
|
2979
|
+
reverse("node-proxy-execute"),
|
|
2980
|
+
data=body,
|
|
2981
|
+
content_type="application/json",
|
|
2982
|
+
HTTP_X_SIGNATURE=signature,
|
|
2983
|
+
)
|
|
2984
|
+
self.assertEqual(response.status_code, 200)
|
|
2985
|
+
data = response.json()
|
|
2986
|
+
models = data.get("models", [])
|
|
2987
|
+
self.assertTrue(models)
|
|
2988
|
+
suite_names = {entry.get("suite_name") for entry in models}
|
|
2989
|
+
self.assertIn("Nodes", suite_names)
|
|
2990
|
+
|
|
2991
|
+
|
|
2145
2992
|
class NodeRFIDAPITests(TestCase):
|
|
2146
2993
|
def test_import_endpoint_applies_payload_without_creating_accounts(self):
|
|
2147
2994
|
remote = Node.objects.create(
|
|
@@ -2201,7 +3048,7 @@ class RFIDExportViewTests(TestCase):
|
|
|
2201
3048
|
self.local_node = Node.objects.create(
|
|
2202
3049
|
hostname="local",
|
|
2203
3050
|
address="127.0.0.1",
|
|
2204
|
-
port=
|
|
3051
|
+
port=8888,
|
|
2205
3052
|
mac_address=Node.get_current_mac(),
|
|
2206
3053
|
)
|
|
2207
3054
|
self.remote_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
@@ -2315,42 +3162,19 @@ class NetMessageAdminTests(TransactionTestCase):
|
|
|
2315
3162
|
self.assertEqual(form["subject"].value(), "Re: Ping")
|
|
2316
3163
|
self.assertEqual(str(form["filter_node"].value()), str(node.pk))
|
|
2317
3164
|
|
|
2318
|
-
|
|
2319
|
-
class LastNetMessageViewTests(TestCase):
|
|
2320
|
-
def setUp(self):
|
|
2321
|
-
self.client = Client()
|
|
2322
|
-
NodeRole.objects.get_or_create(name="Terminal")
|
|
2323
|
-
|
|
2324
|
-
def test_returns_latest_message(self):
|
|
2325
|
-
NetMessage.objects.create(subject="old", body="msg1")
|
|
2326
|
-
latest = NetMessage.objects.create(subject="new", body="msg2")
|
|
2327
|
-
resp = self.client.get(reverse("last-net-message"))
|
|
2328
|
-
self.assertEqual(resp.status_code, 200)
|
|
2329
|
-
self.assertEqual(
|
|
2330
|
-
resp.json(),
|
|
2331
|
-
{
|
|
2332
|
-
"subject": "new",
|
|
2333
|
-
"body": "msg2",
|
|
2334
|
-
"admin_url": reverse(
|
|
2335
|
-
"admin:nodes_netmessage_change", args=[latest.pk]
|
|
2336
|
-
),
|
|
2337
|
-
},
|
|
2338
|
-
)
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
3165
|
class NetMessageReachTests(TestCase):
|
|
2342
3166
|
def setUp(self):
|
|
2343
3167
|
self.roles = {}
|
|
2344
|
-
for name in ["Terminal", "Control", "Satellite", "
|
|
3168
|
+
for name in ["Terminal", "Control", "Satellite", "Watchtower"]:
|
|
2345
3169
|
self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
|
|
2346
3170
|
self.nodes = {}
|
|
2347
3171
|
for idx, name in enumerate(
|
|
2348
|
-
["Terminal", "Control", "Satellite", "
|
|
3172
|
+
["Terminal", "Control", "Satellite", "Watchtower"], start=1
|
|
2349
3173
|
):
|
|
2350
3174
|
self.nodes[name] = Node.objects.create(
|
|
2351
3175
|
hostname=name.lower(),
|
|
2352
3176
|
address=f"10.0.0.{idx}",
|
|
2353
|
-
port=
|
|
3177
|
+
port=8888 + idx,
|
|
2354
3178
|
mac_address=f"00:11:22:33:44:{idx:02x}",
|
|
2355
3179
|
role=self.roles[name],
|
|
2356
3180
|
)
|
|
@@ -2389,15 +3213,15 @@ class NetMessageReachTests(TestCase):
|
|
|
2389
3213
|
self.assertEqual(mock_post.call_count, 3)
|
|
2390
3214
|
|
|
2391
3215
|
@patch("requests.post")
|
|
2392
|
-
def
|
|
3216
|
+
def test_watchtower_reach_prioritizes_watchtower(self, mock_post):
|
|
2393
3217
|
msg = NetMessage.objects.create(
|
|
2394
|
-
subject="s", body="b", reach=self.roles["
|
|
3218
|
+
subject="s", body="b", reach=self.roles["Watchtower"]
|
|
2395
3219
|
)
|
|
2396
3220
|
with patch.object(Node, "get_local", return_value=None):
|
|
2397
3221
|
msg.propagate()
|
|
2398
3222
|
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
2399
3223
|
self.assertEqual(
|
|
2400
|
-
roles, {"
|
|
3224
|
+
roles, {"Watchtower", "Satellite", "Control", "Terminal"}
|
|
2401
3225
|
)
|
|
2402
3226
|
self.assertEqual(mock_post.call_count, 4)
|
|
2403
3227
|
|
|
@@ -2561,7 +3385,7 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2561
3385
|
Node.objects.create(
|
|
2562
3386
|
hostname=f"n{idx}",
|
|
2563
3387
|
address=f"10.0.0.{idx}",
|
|
2564
|
-
port=
|
|
3388
|
+
port=8888 + idx,
|
|
2565
3389
|
mac_address=f"00:11:22:33:44:{idx:02x}",
|
|
2566
3390
|
role=self.role,
|
|
2567
3391
|
public_endpoint=f"n{idx}",
|
|
@@ -2572,7 +3396,7 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2572
3396
|
with patch.object(Node, "get_local", return_value=self.local):
|
|
2573
3397
|
msg = NetMessage.broadcast(subject="subject", body="body")
|
|
2574
3398
|
self.assertEqual(msg.node_origin, self.local)
|
|
2575
|
-
self.
|
|
3399
|
+
self.assertEqual(msg.reach, self.role)
|
|
2576
3400
|
|
|
2577
3401
|
@patch("requests.post")
|
|
2578
3402
|
@patch("core.notifications.notify")
|
|
@@ -2597,13 +3421,6 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2597
3421
|
self.assertNotIn(sender_addr, targets)
|
|
2598
3422
|
self.assertEqual(msg.propagated_to.count(), 4)
|
|
2599
3423
|
self.assertTrue(msg.complete)
|
|
2600
|
-
self.assertEqual(len(msg.confirmed_peers), mock_post.call_count)
|
|
2601
|
-
self.assertTrue(
|
|
2602
|
-
all(entry["status"] == "acknowledged" for entry in msg.confirmed_peers.values())
|
|
2603
|
-
)
|
|
2604
|
-
self.assertTrue(
|
|
2605
|
-
all(entry["status_code"] == 200 for entry in msg.confirmed_peers.values())
|
|
2606
|
-
)
|
|
2607
3424
|
|
|
2608
3425
|
@patch("requests.post")
|
|
2609
3426
|
@patch("core.notifications.notify", return_value=False)
|
|
@@ -2615,7 +3432,7 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2615
3432
|
Node.objects.create(
|
|
2616
3433
|
hostname=f"n{idx}",
|
|
2617
3434
|
address=f"10.0.0.{idx}",
|
|
2618
|
-
port=
|
|
3435
|
+
port=8888 + idx,
|
|
2619
3436
|
mac_address=f"00:11:22:33:44:{idx:02x}",
|
|
2620
3437
|
role=self.role,
|
|
2621
3438
|
public_endpoint=f"n{idx}",
|
|
@@ -2689,10 +3506,240 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2689
3506
|
):
|
|
2690
3507
|
msg.propagate()
|
|
2691
3508
|
|
|
2692
|
-
self.
|
|
2693
|
-
self.assertTrue(
|
|
2694
|
-
|
|
3509
|
+
self.assertEqual(msg.propagated_to.count(), len(self.remotes))
|
|
3510
|
+
self.assertTrue(msg.complete)
|
|
3511
|
+
|
|
3512
|
+
|
|
3513
|
+
class NetMessageQueueTests(TestCase):
|
|
3514
|
+
def setUp(self):
|
|
3515
|
+
self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
3516
|
+
self.feature, _ = NodeFeature.objects.get_or_create(
|
|
3517
|
+
slug="celery-queue", defaults={"display": "Celery Queue"}
|
|
3518
|
+
)
|
|
3519
|
+
|
|
3520
|
+
def test_propagate_queues_unreachable_downstream(self):
|
|
3521
|
+
local = Node.objects.create(
|
|
3522
|
+
hostname="local",
|
|
3523
|
+
address="10.0.0.1",
|
|
3524
|
+
port=8888,
|
|
3525
|
+
mac_address="00:11:22:33:44:10",
|
|
3526
|
+
role=self.role,
|
|
3527
|
+
public_endpoint="local",
|
|
3528
|
+
)
|
|
3529
|
+
downstream = Node.objects.create(
|
|
3530
|
+
hostname="downstream",
|
|
3531
|
+
address="10.0.0.2",
|
|
3532
|
+
port=8001,
|
|
3533
|
+
mac_address="00:11:22:33:44:11",
|
|
3534
|
+
role=self.role,
|
|
3535
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3536
|
+
)
|
|
3537
|
+
message = NetMessage.objects.create(subject="Queued", body="Body", reach=self.role)
|
|
3538
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
3539
|
+
Node, "get_private_key", return_value=None
|
|
3540
|
+
), patch("core.notifications.notify", return_value=False), patch(
|
|
3541
|
+
"requests.post", side_effect=Exception("fail")
|
|
3542
|
+
):
|
|
3543
|
+
message.propagate()
|
|
3544
|
+
|
|
3545
|
+
entry = PendingNetMessage.objects.get(node=downstream, message=message)
|
|
3546
|
+
self.assertIn(str(downstream.uuid), entry.seen)
|
|
3547
|
+
self.assertGreater(entry.stale_at, timezone.now())
|
|
3548
|
+
|
|
3549
|
+
def test_queue_limit_enforced(self):
|
|
3550
|
+
downstream = Node.objects.create(
|
|
3551
|
+
hostname="limit",
|
|
3552
|
+
address="10.0.0.3",
|
|
3553
|
+
port=8002,
|
|
3554
|
+
mac_address="00:11:22:33:44:12",
|
|
3555
|
+
role=self.role,
|
|
3556
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3557
|
+
message_queue_length=1,
|
|
3558
|
+
)
|
|
3559
|
+
msg1 = NetMessage.objects.create(subject="Old", body="One", reach=self.role)
|
|
3560
|
+
msg2 = NetMessage.objects.create(subject="New", body="Two", reach=self.role)
|
|
3561
|
+
|
|
3562
|
+
msg1.queue_for_node(downstream, [str(downstream.uuid)])
|
|
3563
|
+
msg2.queue_for_node(downstream, [str(downstream.uuid)])
|
|
3564
|
+
|
|
3565
|
+
entries = list(PendingNetMessage.objects.filter(node=downstream))
|
|
3566
|
+
self.assertEqual(len(entries), 1)
|
|
3567
|
+
self.assertEqual(entries[0].message, msg2)
|
|
3568
|
+
|
|
3569
|
+
def test_queue_duplicate_updates_stale(self):
|
|
3570
|
+
downstream = Node.objects.create(
|
|
3571
|
+
hostname="dup",
|
|
3572
|
+
address="10.0.0.4",
|
|
3573
|
+
port=8003,
|
|
3574
|
+
mac_address="00:11:22:33:44:13",
|
|
3575
|
+
role=self.role,
|
|
3576
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
2695
3577
|
)
|
|
3578
|
+
message = NetMessage.objects.create(subject="Dup", body="Dup", reach=self.role)
|
|
3579
|
+
first = timezone.now()
|
|
3580
|
+
second = first + timedelta(minutes=5)
|
|
3581
|
+
with patch(
|
|
3582
|
+
"nodes.models.timezone.now", side_effect=[first, second, second]
|
|
3583
|
+
):
|
|
3584
|
+
message.queue_for_node(downstream, ["first"])
|
|
3585
|
+
message.queue_for_node(downstream, ["second"])
|
|
3586
|
+
|
|
3587
|
+
entry = PendingNetMessage.objects.get(node=downstream, message=message)
|
|
3588
|
+
self.assertEqual(entry.seen, ["second"])
|
|
3589
|
+
self.assertEqual(entry.queued_at, second)
|
|
3590
|
+
self.assertEqual(entry.stale_at, second + timedelta(hours=1))
|
|
3591
|
+
|
|
3592
|
+
def test_pull_endpoint_returns_and_clears_messages(self):
|
|
3593
|
+
local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3594
|
+
downstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3595
|
+
local = Node.objects.create(
|
|
3596
|
+
hostname="hub",
|
|
3597
|
+
address="10.0.0.5",
|
|
3598
|
+
port=8004,
|
|
3599
|
+
mac_address="00:11:22:33:44:14",
|
|
3600
|
+
role=self.role,
|
|
3601
|
+
public_endpoint="hub",
|
|
3602
|
+
)
|
|
3603
|
+
downstream = Node.objects.create(
|
|
3604
|
+
hostname="remote",
|
|
3605
|
+
address="10.0.0.6",
|
|
3606
|
+
port=8005,
|
|
3607
|
+
mac_address="00:11:22:33:44:15",
|
|
3608
|
+
role=self.role,
|
|
3609
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3610
|
+
public_key=downstream_key.public_key()
|
|
3611
|
+
.public_bytes(
|
|
3612
|
+
serialization.Encoding.PEM,
|
|
3613
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
3614
|
+
)
|
|
3615
|
+
.decode(),
|
|
3616
|
+
)
|
|
3617
|
+
message = NetMessage.objects.create(subject="Fresh", body="Body", reach=self.role)
|
|
3618
|
+
stale_message = NetMessage.objects.create(subject="Stale", body="Body", reach=self.role)
|
|
3619
|
+
now = timezone.now()
|
|
3620
|
+
PendingNetMessage.objects.create(
|
|
3621
|
+
node=downstream,
|
|
3622
|
+
message=message,
|
|
3623
|
+
seen=[str(downstream.uuid)],
|
|
3624
|
+
stale_at=now + timedelta(minutes=30),
|
|
3625
|
+
)
|
|
3626
|
+
stale_entry = PendingNetMessage.objects.create(
|
|
3627
|
+
node=downstream,
|
|
3628
|
+
message=stale_message,
|
|
3629
|
+
seen=["stale"],
|
|
3630
|
+
stale_at=now - timedelta(minutes=5),
|
|
3631
|
+
)
|
|
3632
|
+
PendingNetMessage.objects.filter(pk=stale_entry.pk).update(
|
|
3633
|
+
queued_at=now - timedelta(minutes=5)
|
|
3634
|
+
)
|
|
3635
|
+
|
|
3636
|
+
def fake_get_private(node_obj):
|
|
3637
|
+
if node_obj.pk == local.pk:
|
|
3638
|
+
return local_key
|
|
3639
|
+
return None
|
|
3640
|
+
|
|
3641
|
+
payload = {"requester": str(downstream.uuid)}
|
|
3642
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
3643
|
+
signature = base64.b64encode(
|
|
3644
|
+
downstream_key.sign(
|
|
3645
|
+
body.encode(),
|
|
3646
|
+
padding.PKCS1v15(),
|
|
3647
|
+
hashes.SHA256(),
|
|
3648
|
+
)
|
|
3649
|
+
).decode()
|
|
3650
|
+
|
|
3651
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
3652
|
+
Node, "get_private_key", return_value=local_key
|
|
3653
|
+
):
|
|
3654
|
+
response = self.client.post(
|
|
3655
|
+
reverse("net-message-pull"),
|
|
3656
|
+
data=body,
|
|
3657
|
+
content_type="application/json",
|
|
3658
|
+
HTTP_X_SIGNATURE=signature,
|
|
3659
|
+
)
|
|
3660
|
+
|
|
3661
|
+
self.assertEqual(response.status_code, 200)
|
|
3662
|
+
data = response.json()
|
|
3663
|
+
self.assertEqual(len(data.get("messages", [])), 1)
|
|
3664
|
+
payload_data = data["messages"][0]["payload"]
|
|
3665
|
+
self.assertEqual(payload_data["uuid"], str(message.uuid))
|
|
3666
|
+
self.assertFalse(
|
|
3667
|
+
PendingNetMessage.objects.filter(node=downstream, message=message).exists()
|
|
3668
|
+
)
|
|
3669
|
+
self.assertFalse(
|
|
3670
|
+
PendingNetMessage.objects.filter(
|
|
3671
|
+
node=downstream, message=stale_message
|
|
3672
|
+
).exists()
|
|
3673
|
+
)
|
|
3674
|
+
response_signature = data["messages"][0]["signature"]
|
|
3675
|
+
local_public = local_key.public_key()
|
|
3676
|
+
local_public.verify(
|
|
3677
|
+
base64.b64decode(response_signature),
|
|
3678
|
+
json.dumps(payload_data, separators=(",", ":"), sort_keys=True).encode(),
|
|
3679
|
+
padding.PKCS1v15(),
|
|
3680
|
+
hashes.SHA256(),
|
|
3681
|
+
)
|
|
3682
|
+
|
|
3683
|
+
def test_poll_task_fetches_messages(self):
|
|
3684
|
+
local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3685
|
+
upstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3686
|
+
local = Node.objects.create(
|
|
3687
|
+
hostname="downstream",
|
|
3688
|
+
address="10.0.0.7",
|
|
3689
|
+
port=8006,
|
|
3690
|
+
mac_address="00:11:22:33:44:16",
|
|
3691
|
+
role=self.role,
|
|
3692
|
+
public_endpoint="downstream",
|
|
3693
|
+
)
|
|
3694
|
+
upstream = Node.objects.create(
|
|
3695
|
+
hostname="upstream",
|
|
3696
|
+
address="127.0.0.2",
|
|
3697
|
+
port=8010,
|
|
3698
|
+
mac_address="00:11:22:33:44:17",
|
|
3699
|
+
role=self.role,
|
|
3700
|
+
current_relation=Node.Relation.UPSTREAM,
|
|
3701
|
+
public_key=upstream_key.public_key()
|
|
3702
|
+
.public_bytes(
|
|
3703
|
+
serialization.Encoding.PEM,
|
|
3704
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
3705
|
+
)
|
|
3706
|
+
.decode(),
|
|
3707
|
+
)
|
|
3708
|
+
NodeFeatureAssignment.objects.create(node=local, feature=self.feature)
|
|
3709
|
+
payload = {
|
|
3710
|
+
"uuid": str(uuid.uuid4()),
|
|
3711
|
+
"subject": "Update",
|
|
3712
|
+
"body": "Body",
|
|
3713
|
+
"seen": [str(local.uuid)],
|
|
3714
|
+
"origin": str(upstream.uuid),
|
|
3715
|
+
"sender": str(upstream.uuid),
|
|
3716
|
+
}
|
|
3717
|
+
payload_text = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
3718
|
+
payload_signature = base64.b64encode(
|
|
3719
|
+
upstream_key.sign(
|
|
3720
|
+
payload_text.encode(),
|
|
3721
|
+
padding.PKCS1v15(),
|
|
3722
|
+
hashes.SHA256(),
|
|
3723
|
+
)
|
|
3724
|
+
).decode()
|
|
3725
|
+
response = MagicMock()
|
|
3726
|
+
response.ok = True
|
|
3727
|
+
response.json.return_value = {
|
|
3728
|
+
"messages": [{"payload": payload, "signature": payload_signature}]
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
3732
|
+
Node, "get_private_key", return_value=local_key
|
|
3733
|
+
), patch("nodes.tasks.requests.post", return_value=response) as mock_post, patch.object(
|
|
3734
|
+
NetMessage, "propagate"
|
|
3735
|
+
) as mock_propagate:
|
|
3736
|
+
poll_unreachable_upstream()
|
|
3737
|
+
|
|
3738
|
+
created = NetMessage.objects.get(uuid=payload["uuid"])
|
|
3739
|
+
self.assertEqual(created.subject, "Update")
|
|
3740
|
+
self.assertEqual(created.node_origin, upstream)
|
|
3741
|
+
mock_post.assert_called_once()
|
|
3742
|
+
mock_propagate.assert_called_once()
|
|
2696
3743
|
|
|
2697
3744
|
|
|
2698
3745
|
class NetMessageSignatureTests(TestCase):
|
|
@@ -2765,6 +3812,82 @@ class NetMessageSignatureTests(TestCase):
|
|
|
2765
3812
|
self.assertTrue(signature_one)
|
|
2766
3813
|
self.assertTrue(signature_two)
|
|
2767
3814
|
self.assertNotEqual(signature_one, signature_two)
|
|
3815
|
+
|
|
3816
|
+
|
|
3817
|
+
class NetworkChargerActionSecurityTests(TestCase):
|
|
3818
|
+
def setUp(self):
|
|
3819
|
+
self.client = Client()
|
|
3820
|
+
self.local_node = Node.objects.create(
|
|
3821
|
+
hostname="local-node",
|
|
3822
|
+
address="127.0.0.1",
|
|
3823
|
+
port=8888,
|
|
3824
|
+
mac_address="00:aa:bb:cc:dd:10",
|
|
3825
|
+
public_endpoint="local-endpoint",
|
|
3826
|
+
)
|
|
3827
|
+
self.authorized_node = Node.objects.create(
|
|
3828
|
+
hostname="authorized-node",
|
|
3829
|
+
address="127.0.0.2",
|
|
3830
|
+
port=8001,
|
|
3831
|
+
mac_address="00:aa:bb:cc:dd:11",
|
|
3832
|
+
public_endpoint="authorized-endpoint",
|
|
3833
|
+
)
|
|
3834
|
+
self.unauthorized_node, self.unauthorized_key = self._create_signed_node(
|
|
3835
|
+
"unauthorized-node",
|
|
3836
|
+
mac_suffix=0x12,
|
|
3837
|
+
)
|
|
3838
|
+
self.charger = Charger.objects.create(
|
|
3839
|
+
charger_id="SECURE-TEST-1",
|
|
3840
|
+
allow_remote=True,
|
|
3841
|
+
manager_node=self.authorized_node,
|
|
3842
|
+
node_origin=self.local_node,
|
|
3843
|
+
)
|
|
3844
|
+
|
|
3845
|
+
def _create_signed_node(self, hostname: str, *, mac_suffix: int):
|
|
3846
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3847
|
+
public_bytes = key.public_key().public_bytes(
|
|
3848
|
+
encoding=serialization.Encoding.PEM,
|
|
3849
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
3850
|
+
)
|
|
3851
|
+
node = Node.objects.create(
|
|
3852
|
+
hostname=hostname,
|
|
3853
|
+
address="10.0.0.{:d}".format(mac_suffix),
|
|
3854
|
+
port=8020,
|
|
3855
|
+
mac_address="00:aa:bb:cc:dd:{:02x}".format(mac_suffix),
|
|
3856
|
+
public_key=public_bytes.decode(),
|
|
3857
|
+
public_endpoint=f"{hostname}-endpoint",
|
|
3858
|
+
)
|
|
3859
|
+
return node, key
|
|
3860
|
+
|
|
3861
|
+
def test_rejects_requests_from_unmanaged_nodes(self):
|
|
3862
|
+
url = reverse("node-network-charger-action")
|
|
3863
|
+
payload = {
|
|
3864
|
+
"requester": str(self.unauthorized_node.uuid),
|
|
3865
|
+
"charger_id": self.charger.charger_id,
|
|
3866
|
+
"action": "reset",
|
|
3867
|
+
}
|
|
3868
|
+
body = json.dumps(payload).encode()
|
|
3869
|
+
signature = self.unauthorized_key.sign(
|
|
3870
|
+
body,
|
|
3871
|
+
padding.PKCS1v15(),
|
|
3872
|
+
hashes.SHA256(),
|
|
3873
|
+
)
|
|
3874
|
+
headers = {"HTTP_X_SIGNATURE": base64.b64encode(signature).decode()}
|
|
3875
|
+
|
|
3876
|
+
with patch.object(Node, "get_local", return_value=self.local_node):
|
|
3877
|
+
response = self.client.post(
|
|
3878
|
+
url,
|
|
3879
|
+
data=body,
|
|
3880
|
+
content_type="application/json",
|
|
3881
|
+
**headers,
|
|
3882
|
+
)
|
|
3883
|
+
|
|
3884
|
+
self.assertEqual(response.status_code, 403)
|
|
3885
|
+
self.assertEqual(
|
|
3886
|
+
response.json().get("detail"),
|
|
3887
|
+
"requester does not manage this charger",
|
|
3888
|
+
)
|
|
3889
|
+
|
|
3890
|
+
|
|
2768
3891
|
class StartupNotificationTests(TestCase):
|
|
2769
3892
|
def test_startup_notification_uses_hostname_and_revision(self):
|
|
2770
3893
|
from nodes.apps import _startup_notification
|
|
@@ -2848,6 +3971,17 @@ class StartupHandlerTests(TestCase):
|
|
|
2848
3971
|
|
|
2849
3972
|
mock_start.assert_called_once()
|
|
2850
3973
|
|
|
3974
|
+
def test_handler_skips_during_migrate_command(self):
|
|
3975
|
+
import sys
|
|
3976
|
+
|
|
3977
|
+
from nodes.apps import _trigger_startup_notification
|
|
3978
|
+
|
|
3979
|
+
with patch("nodes.apps._startup_notification") as mock_start:
|
|
3980
|
+
with patch.object(sys, "argv", ["manage.py", "migrate"]):
|
|
3981
|
+
_trigger_startup_notification()
|
|
3982
|
+
|
|
3983
|
+
mock_start.assert_not_called()
|
|
3984
|
+
|
|
2851
3985
|
|
|
2852
3986
|
class NotificationManagerTests(TestCase):
|
|
2853
3987
|
def test_send_writes_trimmed_lines(self):
|
|
@@ -2930,6 +4064,7 @@ class ContentSampleTransactionTests(TestCase):
|
|
|
2930
4064
|
sample1.save()
|
|
2931
4065
|
|
|
2932
4066
|
|
|
4067
|
+
@pytest.mark.feature("clipboard-poll")
|
|
2933
4068
|
class ContentSampleAdminTests(TestCase):
|
|
2934
4069
|
def setUp(self):
|
|
2935
4070
|
User = get_user_model()
|
|
@@ -2958,7 +4093,7 @@ class ContentSampleAdminTests(TestCase):
|
|
|
2958
4093
|
Node.objects.create(
|
|
2959
4094
|
hostname="host",
|
|
2960
4095
|
address="127.0.0.1",
|
|
2961
|
-
port=
|
|
4096
|
+
port=8888,
|
|
2962
4097
|
mac_address=Node.get_current_mac(),
|
|
2963
4098
|
)
|
|
2964
4099
|
url = reverse("admin:nodes_contentsample_from_clipboard")
|
|
@@ -2984,7 +4119,7 @@ class EmailOutboxTests(TestCase):
|
|
|
2984
4119
|
node = Node.objects.create(
|
|
2985
4120
|
hostname="outboxhost",
|
|
2986
4121
|
address="127.0.0.1",
|
|
2987
|
-
port=
|
|
4122
|
+
port=8888,
|
|
2988
4123
|
mac_address="00:11:22:33:aa:bb",
|
|
2989
4124
|
)
|
|
2990
4125
|
outbox = EmailOutbox.objects.create(
|
|
@@ -3000,7 +4135,7 @@ class EmailOutboxTests(TestCase):
|
|
|
3000
4135
|
node = Node.objects.create(
|
|
3001
4136
|
hostname="host",
|
|
3002
4137
|
address="127.0.0.1",
|
|
3003
|
-
port=
|
|
4138
|
+
port=8888,
|
|
3004
4139
|
mac_address="00:11:22:33:cc:dd",
|
|
3005
4140
|
)
|
|
3006
4141
|
node.send_mail("sub", "msg", ["to@example.com"])
|
|
@@ -3106,13 +4241,14 @@ class EmailOutboxTests(TestCase):
|
|
|
3106
4241
|
|
|
3107
4242
|
|
|
3108
4243
|
class ClipboardTaskTests(TestCase):
|
|
4244
|
+
@pytest.mark.feature("clipboard-poll")
|
|
3109
4245
|
@patch("nodes.tasks.pyperclip.paste")
|
|
3110
4246
|
def test_sample_clipboard_task_creates_sample(self, mock_paste):
|
|
3111
4247
|
mock_paste.return_value = "task text"
|
|
3112
4248
|
Node.objects.create(
|
|
3113
4249
|
hostname="host",
|
|
3114
4250
|
address="127.0.0.1",
|
|
3115
|
-
port=
|
|
4251
|
+
port=8888,
|
|
3116
4252
|
mac_address=Node.get_current_mac(),
|
|
3117
4253
|
)
|
|
3118
4254
|
sample_clipboard()
|
|
@@ -3130,12 +4266,13 @@ class ClipboardTaskTests(TestCase):
|
|
|
3130
4266
|
ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
|
|
3131
4267
|
)
|
|
3132
4268
|
|
|
4269
|
+
@pytest.mark.feature("screenshot-poll")
|
|
3133
4270
|
@patch("nodes.tasks.capture_screenshot")
|
|
3134
4271
|
def test_capture_node_screenshot_task(self, mock_capture):
|
|
3135
4272
|
node = Node.objects.create(
|
|
3136
4273
|
hostname="host",
|
|
3137
4274
|
address="127.0.0.1",
|
|
3138
|
-
port=
|
|
4275
|
+
port=8888,
|
|
3139
4276
|
mac_address=Node.get_current_mac(),
|
|
3140
4277
|
)
|
|
3141
4278
|
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
@@ -3152,12 +4289,13 @@ class ClipboardTaskTests(TestCase):
|
|
|
3152
4289
|
self.assertEqual(screenshot.path, "screenshots/test.png")
|
|
3153
4290
|
self.assertEqual(screenshot.method, "TASK")
|
|
3154
4291
|
|
|
4292
|
+
@pytest.mark.feature("screenshot-poll")
|
|
3155
4293
|
@patch("nodes.tasks.capture_screenshot")
|
|
3156
4294
|
def test_capture_node_screenshot_handles_error(self, mock_capture):
|
|
3157
4295
|
Node.objects.create(
|
|
3158
4296
|
hostname="host",
|
|
3159
4297
|
address="127.0.0.1",
|
|
3160
|
-
port=
|
|
4298
|
+
port=8888,
|
|
3161
4299
|
mac_address=Node.get_current_mac(),
|
|
3162
4300
|
)
|
|
3163
4301
|
mock_capture.side_effect = RuntimeError("boom")
|
|
@@ -3169,6 +4307,17 @@ class ClipboardTaskTests(TestCase):
|
|
|
3169
4307
|
|
|
3170
4308
|
|
|
3171
4309
|
class CaptureScreenshotTests(TestCase):
|
|
4310
|
+
def setUp(self):
|
|
4311
|
+
super().setUp()
|
|
4312
|
+
self.firefox_patcher = patch(
|
|
4313
|
+
"nodes.utils._find_firefox_binary", return_value="/usr/bin/firefox"
|
|
4314
|
+
)
|
|
4315
|
+
self.ensure_geckodriver_patcher = patch("nodes.utils._ensure_geckodriver")
|
|
4316
|
+
self.firefox_patcher.start()
|
|
4317
|
+
self.ensure_geckodriver_patcher.start()
|
|
4318
|
+
self.addCleanup(self.firefox_patcher.stop)
|
|
4319
|
+
self.addCleanup(self.ensure_geckodriver_patcher.stop)
|
|
4320
|
+
|
|
3172
4321
|
@patch("nodes.utils.webdriver.Firefox")
|
|
3173
4322
|
def test_connection_failure_does_not_raise(self, mock_firefox):
|
|
3174
4323
|
browser = MagicMock()
|
|
@@ -3181,6 +4330,19 @@ class CaptureScreenshotTests(TestCase):
|
|
|
3181
4330
|
self.assertEqual(result.parent, screenshot_dir)
|
|
3182
4331
|
browser.save_screenshot.assert_called_once()
|
|
3183
4332
|
|
|
4333
|
+
def test_missing_firefox_reports_clear_error(self):
|
|
4334
|
+
with patch("nodes.utils._find_firefox_binary", return_value=None):
|
|
4335
|
+
with self.assertRaises(RuntimeError) as excinfo:
|
|
4336
|
+
capture_screenshot("http://example.com")
|
|
4337
|
+
self.assertIn("Firefox is not installed", str(excinfo.exception))
|
|
4338
|
+
|
|
4339
|
+
@patch("nodes.utils.webdriver.Firefox")
|
|
4340
|
+
def test_driver_install_hint_on_failure(self, mock_firefox):
|
|
4341
|
+
mock_firefox.side_effect = WebDriverException("Unable to obtain driver for firefox")
|
|
4342
|
+
with self.assertRaises(RuntimeError) as excinfo:
|
|
4343
|
+
capture_screenshot("http://example.com")
|
|
4344
|
+
self.assertIn("Firefox WebDriver is unavailable", str(excinfo.exception))
|
|
4345
|
+
|
|
3184
4346
|
|
|
3185
4347
|
class NodeRoleAdminTests(TestCase):
|
|
3186
4348
|
def setUp(self):
|
|
@@ -3195,14 +4357,14 @@ class NodeRoleAdminTests(TestCase):
|
|
|
3195
4357
|
node1 = Node.objects.create(
|
|
3196
4358
|
hostname="n1",
|
|
3197
4359
|
address="127.0.0.1",
|
|
3198
|
-
port=
|
|
4360
|
+
port=8888,
|
|
3199
4361
|
mac_address="00:11:22:33:44:55",
|
|
3200
4362
|
role=role,
|
|
3201
4363
|
)
|
|
3202
4364
|
node2 = Node.objects.create(
|
|
3203
4365
|
hostname="n2",
|
|
3204
4366
|
address="127.0.0.2",
|
|
3205
|
-
port=
|
|
4367
|
+
port=8888,
|
|
3206
4368
|
mac_address="00:11:22:33:44:66",
|
|
3207
4369
|
)
|
|
3208
4370
|
url = reverse("admin:nodes_noderole_change", args=[role.pk])
|
|
@@ -3221,7 +4383,7 @@ class NodeRoleAdminTests(TestCase):
|
|
|
3221
4383
|
Node.objects.create(
|
|
3222
4384
|
hostname="n1",
|
|
3223
4385
|
address="127.0.0.1",
|
|
3224
|
-
port=
|
|
4386
|
+
port=8888,
|
|
3225
4387
|
mac_address="00:11:22:33:44:77",
|
|
3226
4388
|
role=role,
|
|
3227
4389
|
)
|
|
@@ -3231,7 +4393,7 @@ class NodeRoleAdminTests(TestCase):
|
|
|
3231
4393
|
|
|
3232
4394
|
class NodeFeatureFixtureTests(TestCase):
|
|
3233
4395
|
def test_rfid_scanner_fixture_includes_control_role(self):
|
|
3234
|
-
for name in ("Terminal", "Satellite", "
|
|
4396
|
+
for name in ("Terminal", "Satellite", "Watchtower", "Control"):
|
|
3235
4397
|
NodeRole.objects.get_or_create(name=name)
|
|
3236
4398
|
fixture_path = (
|
|
3237
4399
|
Path(__file__).resolve().parent
|
|
@@ -3243,6 +4405,7 @@ class NodeFeatureFixtureTests(TestCase):
|
|
|
3243
4405
|
role_names = set(feature.roles.values_list("name", flat=True))
|
|
3244
4406
|
self.assertIn("Control", role_names)
|
|
3245
4407
|
|
|
4408
|
+
@pytest.mark.feature("ap-router")
|
|
3246
4409
|
def test_ap_router_fixture_limits_roles(self):
|
|
3247
4410
|
for name in ("Control", "Satellite"):
|
|
3248
4411
|
NodeRole.objects.get_or_create(name=name)
|
|
@@ -3256,6 +4419,23 @@ class NodeFeatureFixtureTests(TestCase):
|
|
|
3256
4419
|
role_names = set(feature.roles.values_list("name", flat=True))
|
|
3257
4420
|
self.assertEqual(role_names, {"Satellite"})
|
|
3258
4421
|
|
|
4422
|
+
@pytest.mark.feature("graphql")
|
|
4423
|
+
def test_graphql_fixture_excludes_terminal_role(self):
|
|
4424
|
+
for name in ("Control", "Interface", "Satellite", "Terminal", "Watchtower"):
|
|
4425
|
+
NodeRole.objects.get_or_create(name=name)
|
|
4426
|
+
fixture_path = (
|
|
4427
|
+
Path(__file__).resolve().parent
|
|
4428
|
+
/ "fixtures"
|
|
4429
|
+
/ "node_features__nodefeature_graphql.json"
|
|
4430
|
+
)
|
|
4431
|
+
call_command("loaddata", str(fixture_path), verbosity=0)
|
|
4432
|
+
feature = NodeFeature.objects.get(slug="graphql")
|
|
4433
|
+
role_names = set(feature.roles.values_list("name", flat=True))
|
|
4434
|
+
self.assertEqual(
|
|
4435
|
+
role_names,
|
|
4436
|
+
{"Control", "Interface", "Satellite", "Watchtower"},
|
|
4437
|
+
)
|
|
4438
|
+
|
|
3259
4439
|
|
|
3260
4440
|
class NodeFeatureTests(TestCase):
|
|
3261
4441
|
def setUp(self):
|
|
@@ -3266,7 +4446,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3266
4446
|
self.node = Node.objects.create(
|
|
3267
4447
|
hostname="local",
|
|
3268
4448
|
address="127.0.0.1",
|
|
3269
|
-
port=
|
|
4449
|
+
port=8888,
|
|
3270
4450
|
mac_address="00:11:22:33:44:55",
|
|
3271
4451
|
role=self.role,
|
|
3272
4452
|
)
|
|
@@ -3293,6 +4473,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3293
4473
|
self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
|
|
3294
4474
|
self.assertEqual(feature.get_default_action(), action)
|
|
3295
4475
|
|
|
4476
|
+
@pytest.mark.feature("rpi-camera")
|
|
3296
4477
|
def test_rpi_camera_feature_has_multiple_actions(self):
|
|
3297
4478
|
feature = NodeFeature.objects.create(
|
|
3298
4479
|
slug="rpi-camera", display="Raspberry Pi Camera"
|
|
@@ -3303,6 +4484,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3303
4484
|
self.assertIn("Take a Snapshot", labels)
|
|
3304
4485
|
self.assertIn("View stream", labels)
|
|
3305
4486
|
|
|
4487
|
+
@pytest.mark.feature("audio-capture")
|
|
3306
4488
|
def test_audio_capture_feature_has_view_waveform_action(self):
|
|
3307
4489
|
feature = NodeFeature.objects.create(
|
|
3308
4490
|
slug="audio-capture", display="Audio Capture"
|
|
@@ -3388,17 +4570,6 @@ class NodeFeatureTests(TestCase):
|
|
|
3388
4570
|
):
|
|
3389
4571
|
self.assertTrue(feature.is_enabled)
|
|
3390
4572
|
|
|
3391
|
-
@patch("nodes.models.Node._has_gway_runner", return_value=True)
|
|
3392
|
-
def test_gway_runner_enabled_when_command_available(self, mock_has_runner):
|
|
3393
|
-
feature = NodeFeature.objects.create(
|
|
3394
|
-
slug="gway-runner", display="gway Runner"
|
|
3395
|
-
)
|
|
3396
|
-
with patch(
|
|
3397
|
-
"nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
|
|
3398
|
-
):
|
|
3399
|
-
self.assertTrue(feature.is_enabled)
|
|
3400
|
-
mock_has_runner.assert_called_once_with()
|
|
3401
|
-
|
|
3402
4573
|
@patch("nodes.models.Node._has_rpi_camera", return_value=True)
|
|
3403
4574
|
def test_rpi_camera_detection(self, mock_camera):
|
|
3404
4575
|
feature = NodeFeature.objects.create(
|
|
@@ -3437,49 +4608,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3437
4608
|
).exists()
|
|
3438
4609
|
)
|
|
3439
4610
|
|
|
3440
|
-
@
|
|
3441
|
-
def test_gway_runner_detection(self, mock_find_command):
|
|
3442
|
-
feature = NodeFeature.objects.create(
|
|
3443
|
-
slug="gway-runner", display="gway Runner"
|
|
3444
|
-
)
|
|
3445
|
-
feature.roles.add(self.role)
|
|
3446
|
-
with patch(
|
|
3447
|
-
"nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
|
|
3448
|
-
):
|
|
3449
|
-
self.node.refresh_features()
|
|
3450
|
-
self.assertTrue(
|
|
3451
|
-
NodeFeatureAssignment.objects.filter(
|
|
3452
|
-
node=self.node, feature=feature
|
|
3453
|
-
).exists()
|
|
3454
|
-
)
|
|
3455
|
-
mock_find_command.assert_called_with()
|
|
3456
|
-
|
|
3457
|
-
@patch(
|
|
3458
|
-
"nodes.models.Node._find_gway_runner_command",
|
|
3459
|
-
side_effect=["/usr/bin/gway", None],
|
|
3460
|
-
)
|
|
3461
|
-
def test_gway_runner_removed_when_command_missing(self, mock_find_command):
|
|
3462
|
-
feature = NodeFeature.objects.create(
|
|
3463
|
-
slug="gway-runner", display="gway Runner"
|
|
3464
|
-
)
|
|
3465
|
-
feature.roles.add(self.role)
|
|
3466
|
-
with patch(
|
|
3467
|
-
"nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
|
|
3468
|
-
):
|
|
3469
|
-
self.node.refresh_features()
|
|
3470
|
-
self.assertTrue(
|
|
3471
|
-
NodeFeatureAssignment.objects.filter(
|
|
3472
|
-
node=self.node, feature=feature
|
|
3473
|
-
).exists()
|
|
3474
|
-
)
|
|
3475
|
-
self.node.refresh_features()
|
|
3476
|
-
self.assertFalse(
|
|
3477
|
-
NodeFeatureAssignment.objects.filter(
|
|
3478
|
-
node=self.node, feature=feature
|
|
3479
|
-
).exists()
|
|
3480
|
-
)
|
|
3481
|
-
self.assertEqual(mock_find_command.call_count, 2)
|
|
3482
|
-
|
|
4611
|
+
@pytest.mark.feature("ap-router")
|
|
3483
4612
|
@patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
|
|
3484
4613
|
def test_ap_router_detection(self, mock_hosts):
|
|
3485
4614
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
@@ -3490,7 +4619,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3490
4619
|
node = Node.objects.create(
|
|
3491
4620
|
hostname="control",
|
|
3492
4621
|
address="127.0.0.1",
|
|
3493
|
-
port=
|
|
4622
|
+
port=8888,
|
|
3494
4623
|
mac_address=mac,
|
|
3495
4624
|
role=control_role,
|
|
3496
4625
|
)
|
|
@@ -3499,6 +4628,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3499
4628
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
|
|
3500
4629
|
)
|
|
3501
4630
|
|
|
4631
|
+
@pytest.mark.feature("ap-router")
|
|
3502
4632
|
@patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
|
|
3503
4633
|
def test_ap_router_detection_with_public_mode_lock(self, mock_hosts):
|
|
3504
4634
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
@@ -3513,7 +4643,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3513
4643
|
node = Node.objects.create(
|
|
3514
4644
|
hostname="control",
|
|
3515
4645
|
address="127.0.0.1",
|
|
3516
|
-
port=
|
|
4646
|
+
port=8888,
|
|
3517
4647
|
mac_address=mac,
|
|
3518
4648
|
role=control_role,
|
|
3519
4649
|
base_path=str(Path(tmp)),
|
|
@@ -3523,6 +4653,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3523
4653
|
NodeFeatureAssignment.objects.filter(node=node, feature=router).exists()
|
|
3524
4654
|
)
|
|
3525
4655
|
|
|
4656
|
+
@pytest.mark.feature("ap-router")
|
|
3526
4657
|
@patch("nodes.models.Node._hosts_gelectriic_ap", side_effect=[True, False])
|
|
3527
4658
|
def test_ap_router_removed_when_not_hosting(self, mock_hosts):
|
|
3528
4659
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
@@ -3533,7 +4664,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3533
4664
|
node = Node.objects.create(
|
|
3534
4665
|
hostname="control",
|
|
3535
4666
|
address="127.0.0.1",
|
|
3536
|
-
port=
|
|
4667
|
+
port=8888,
|
|
3537
4668
|
mac_address=mac,
|
|
3538
4669
|
role=control_role,
|
|
3539
4670
|
)
|
|
@@ -3548,6 +4679,72 @@ class NodeFeatureTests(TestCase):
|
|
|
3548
4679
|
)
|
|
3549
4680
|
|
|
3550
4681
|
|
|
4682
|
+
class AudioCaptureDetectionTests(TestCase):
|
|
4683
|
+
def test_has_audio_capture_device_true(self):
|
|
4684
|
+
with TemporaryDirectory() as tmp:
|
|
4685
|
+
pcm_path = Path(tmp) / "pcm"
|
|
4686
|
+
pcm_path.write_text(
|
|
4687
|
+
"00-00: USB Audio : USB Audio : playback 1 : capture 1\n",
|
|
4688
|
+
encoding="utf-8",
|
|
4689
|
+
)
|
|
4690
|
+
with patch.object(Node, "AUDIO_CAPTURE_PCM_PATH", pcm_path):
|
|
4691
|
+
self.assertTrue(Node._has_audio_capture_device())
|
|
4692
|
+
|
|
4693
|
+
def test_has_audio_capture_device_false_without_capture(self):
|
|
4694
|
+
with TemporaryDirectory() as tmp:
|
|
4695
|
+
pcm_path = Path(tmp) / "pcm"
|
|
4696
|
+
pcm_path.write_text(
|
|
4697
|
+
"00-00: USB Audio : USB Audio : playback 1\n",
|
|
4698
|
+
encoding="utf-8",
|
|
4699
|
+
)
|
|
4700
|
+
with patch.object(Node, "AUDIO_CAPTURE_PCM_PATH", pcm_path):
|
|
4701
|
+
self.assertFalse(Node._has_audio_capture_device())
|
|
4702
|
+
|
|
4703
|
+
def test_has_audio_capture_device_false_when_file_missing(self):
|
|
4704
|
+
with TemporaryDirectory() as tmp:
|
|
4705
|
+
pcm_path = Path(tmp) / "pcm"
|
|
4706
|
+
with patch.object(Node, "AUDIO_CAPTURE_PCM_PATH", pcm_path):
|
|
4707
|
+
self.assertFalse(Node._has_audio_capture_device())
|
|
4708
|
+
|
|
4709
|
+
|
|
4710
|
+
class AudioCaptureFeatureCheckTests(TestCase):
|
|
4711
|
+
def setUp(self):
|
|
4712
|
+
self.node = Node.objects.create(
|
|
4713
|
+
hostname="localnode",
|
|
4714
|
+
address="127.0.0.1",
|
|
4715
|
+
port=8888,
|
|
4716
|
+
mac_address=Node.get_current_mac(),
|
|
4717
|
+
)
|
|
4718
|
+
self.feature, _ = NodeFeature.objects.get_or_create(
|
|
4719
|
+
slug="audio-capture", defaults={"display": "Audio Capture"}
|
|
4720
|
+
)
|
|
4721
|
+
|
|
4722
|
+
@pytest.mark.feature("audio-capture")
|
|
4723
|
+
@patch("nodes.models.Node._has_audio_capture_device", return_value=False)
|
|
4724
|
+
def test_feature_check_warns_without_device(self, mock_device):
|
|
4725
|
+
result = feature_checks.run(self.feature, node=self.node)
|
|
4726
|
+
self.assertFalse(result.success)
|
|
4727
|
+
self.assertIn("No audio recording device detected", result.message)
|
|
4728
|
+
self.assertEqual(result.level, messages.WARNING)
|
|
4729
|
+
|
|
4730
|
+
@pytest.mark.feature("audio-capture")
|
|
4731
|
+
@patch("nodes.models.Node._has_audio_capture_device", return_value=True)
|
|
4732
|
+
def test_feature_check_warns_when_feature_disabled(self, mock_device):
|
|
4733
|
+
result = feature_checks.run(self.feature, node=self.node)
|
|
4734
|
+
self.assertFalse(result.success)
|
|
4735
|
+
self.assertIn("is not enabled", result.message)
|
|
4736
|
+
self.assertEqual(result.level, messages.WARNING)
|
|
4737
|
+
|
|
4738
|
+
@pytest.mark.feature("audio-capture")
|
|
4739
|
+
@patch("nodes.models.Node._has_audio_capture_device", return_value=True)
|
|
4740
|
+
def test_feature_check_passes_when_enabled(self, mock_device):
|
|
4741
|
+
NodeFeatureAssignment.objects.get_or_create(node=self.node, feature=self.feature)
|
|
4742
|
+
result = feature_checks.run(self.feature, node=self.node)
|
|
4743
|
+
self.assertTrue(result.success)
|
|
4744
|
+
self.assertIn("recording device is available", result.message)
|
|
4745
|
+
self.assertEqual(result.level, messages.SUCCESS)
|
|
4746
|
+
|
|
4747
|
+
|
|
3551
4748
|
class CeleryReportAdminViewTests(TestCase):
|
|
3552
4749
|
def setUp(self):
|
|
3553
4750
|
User = get_user_model()
|
|
@@ -3895,6 +5092,38 @@ class ContentClassifierTests(TestCase):
|
|
|
3895
5092
|
tags = ContentClassification.objects.filter(sample=sample)
|
|
3896
5093
|
self.assertTrue(tags.filter(tag__slug="screenshot-tag").exists())
|
|
3897
5094
|
|
|
5095
|
+
def test_save_screenshot_returns_none_for_duplicate_without_linking(self):
|
|
5096
|
+
with TemporaryDirectory() as tmp:
|
|
5097
|
+
base = Path(tmp)
|
|
5098
|
+
first_path = base / "capture.png"
|
|
5099
|
+
first_path.write_bytes(b"binary image data")
|
|
5100
|
+
duplicate_path = base / "duplicate.png"
|
|
5101
|
+
duplicate_path.write_bytes(b"binary image data")
|
|
5102
|
+
with override_settings(LOG_DIR=base):
|
|
5103
|
+
original = save_screenshot(first_path, method="TEST")
|
|
5104
|
+
duplicate = save_screenshot(duplicate_path, method="TEST")
|
|
5105
|
+
|
|
5106
|
+
self.assertIsNotNone(original)
|
|
5107
|
+
self.assertIsNone(duplicate)
|
|
5108
|
+
self.assertEqual(ContentSample.objects.count(), 1)
|
|
5109
|
+
|
|
5110
|
+
def test_save_screenshot_reuses_existing_sample_when_linking(self):
|
|
5111
|
+
with TemporaryDirectory() as tmp:
|
|
5112
|
+
base = Path(tmp)
|
|
5113
|
+
first_path = base / "capture.png"
|
|
5114
|
+
first_path.write_bytes(b"binary image data")
|
|
5115
|
+
duplicate_path = base / "duplicate.png"
|
|
5116
|
+
duplicate_path.write_bytes(b"binary image data")
|
|
5117
|
+
with override_settings(LOG_DIR=base):
|
|
5118
|
+
original = save_screenshot(first_path, method="TEST")
|
|
5119
|
+
reused = save_screenshot(
|
|
5120
|
+
duplicate_path, method="TEST", link_duplicates=True
|
|
5121
|
+
)
|
|
5122
|
+
|
|
5123
|
+
self.assertIsNotNone(original)
|
|
5124
|
+
self.assertEqual(reused, original)
|
|
5125
|
+
self.assertEqual(ContentSample.objects.count(), 1)
|
|
5126
|
+
|
|
3898
5127
|
def test_text_sample_runs_default_classifiers_without_duplicates(self):
|
|
3899
5128
|
sample = ContentSample.objects.create(
|
|
3900
5129
|
content="Example content", kind=ContentSample.TEXT
|