arthexis 0.1.16__py3-none-any.whl → 0.1.26__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.26.dist-info}/METADATA +84 -35
- arthexis-0.1.26.dist-info/RECORD +111 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +15 -30
- config/urls.py +53 -1
- core/admin.py +540 -450
- core/apps.py +0 -6
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1566 -203
- 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 +268 -2
- core/tasks.py +174 -48
- core/tests.py +314 -16
- core/user_data.py +42 -2
- core/views.py +278 -183
- nodes/admin.py +557 -65
- nodes/apps.py +11 -0
- nodes/models.py +658 -113
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +97 -2
- nodes/tests.py +1212 -116
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1239 -154
- ocpp/admin.py +979 -152
- ocpp/consumers.py +268 -28
- ocpp/models.py +488 -3
- ocpp/network.py +398 -0
- ocpp/store.py +6 -4
- ocpp/tasks.py +296 -2
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +121 -4
- ocpp/tests.py +950 -11
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +596 -51
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +26 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +77 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +885 -109
- pages/urls.py +13 -2
- pages/utils.py +70 -0
- pages/views.py +558 -55
- 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.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.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,14 +32,23 @@ 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.admin import helpers
|
|
37
47
|
from django.contrib.auth.models import Permission
|
|
38
48
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
39
49
|
from django.conf import settings
|
|
40
50
|
from django.utils import timezone
|
|
51
|
+
from urllib.parse import urlparse
|
|
41
52
|
from dns import resolver as dns_resolver
|
|
42
53
|
from . import dns as dns_utils
|
|
43
54
|
from selenium.common.exceptions import WebDriverException
|
|
@@ -45,6 +56,7 @@ from .classifiers import run_default_classifiers
|
|
|
45
56
|
from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
|
|
46
57
|
from django.db.utils import DatabaseError
|
|
47
58
|
|
|
59
|
+
from .admin import NodeAdmin
|
|
48
60
|
from .models import (
|
|
49
61
|
Node,
|
|
50
62
|
EmailOutbox,
|
|
@@ -56,28 +68,31 @@ from .models import (
|
|
|
56
68
|
NodeFeature,
|
|
57
69
|
NodeFeatureAssignment,
|
|
58
70
|
NetMessage,
|
|
71
|
+
PendingNetMessage,
|
|
59
72
|
NodeManager,
|
|
60
73
|
DNSRecord,
|
|
61
74
|
)
|
|
62
75
|
from .backends import OutboxEmailBackend
|
|
63
|
-
from .tasks import capture_node_screenshot, sample_clipboard
|
|
76
|
+
from .tasks import capture_node_screenshot, poll_unreachable_upstream, sample_clipboard
|
|
77
|
+
from ocpp.models import Charger
|
|
64
78
|
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
65
79
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
66
|
-
from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount
|
|
80
|
+
from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount, Todo
|
|
81
|
+
from requests.exceptions import SSLError
|
|
67
82
|
|
|
68
83
|
|
|
69
84
|
class NodeBadgeColorTests(TestCase):
|
|
70
85
|
def setUp(self):
|
|
71
|
-
self.
|
|
86
|
+
self.watchtower, _ = NodeRole.objects.get_or_create(name="Watchtower")
|
|
72
87
|
self.control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
73
88
|
|
|
74
|
-
def
|
|
89
|
+
def test_watchtower_role_defaults_to_goldenrod(self):
|
|
75
90
|
node = Node.objects.create(
|
|
76
|
-
hostname="
|
|
91
|
+
hostname="watchtower",
|
|
77
92
|
address="10.1.0.1",
|
|
78
93
|
port=8000,
|
|
79
94
|
mac_address="00:aa:bb:cc:dd:01",
|
|
80
|
-
role=self.
|
|
95
|
+
role=self.watchtower,
|
|
81
96
|
)
|
|
82
97
|
self.assertEqual(node.badge_color, "#daa520")
|
|
83
98
|
|
|
@@ -97,7 +112,7 @@ class NodeBadgeColorTests(TestCase):
|
|
|
97
112
|
address="10.1.0.3",
|
|
98
113
|
port=8002,
|
|
99
114
|
mac_address="00:aa:bb:cc:dd:03",
|
|
100
|
-
role=self.
|
|
115
|
+
role=self.watchtower,
|
|
101
116
|
badge_color="#123456",
|
|
102
117
|
)
|
|
103
118
|
self.assertEqual(node.badge_color, "#123456")
|
|
@@ -110,6 +125,7 @@ class NodeTests(TestCase):
|
|
|
110
125
|
self.user = User.objects.create_user(username="nodeuser", password="pwd")
|
|
111
126
|
self.client.force_login(self.user)
|
|
112
127
|
NodeRole.objects.get_or_create(name="Terminal")
|
|
128
|
+
NodeRole.objects.get_or_create(name="Interface")
|
|
113
129
|
|
|
114
130
|
|
|
115
131
|
class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
|
|
@@ -125,6 +141,12 @@ class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
|
|
|
125
141
|
|
|
126
142
|
|
|
127
143
|
class NodeGetLocalTests(TestCase):
|
|
144
|
+
def setUp(self):
|
|
145
|
+
super().setUp()
|
|
146
|
+
User = get_user_model()
|
|
147
|
+
self.user = User.objects.create_user(username="localtester", password="pwd")
|
|
148
|
+
self.client.force_login(self.user)
|
|
149
|
+
|
|
128
150
|
def test_normalize_relation_handles_various_inputs(self):
|
|
129
151
|
self.assertEqual(
|
|
130
152
|
Node.normalize_relation(Node.Relation.UPSTREAM),
|
|
@@ -166,6 +188,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
166
188
|
patch(
|
|
167
189
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
168
190
|
),
|
|
191
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
169
192
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
170
193
|
patch.object(Node, "ensure_keys"),
|
|
171
194
|
):
|
|
@@ -177,7 +200,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
177
200
|
|
|
178
201
|
def test_register_current_updates_role_from_lock_file(self):
|
|
179
202
|
NodeRole.objects.get_or_create(name="Terminal")
|
|
180
|
-
NodeRole.objects.get_or_create(name="
|
|
203
|
+
NodeRole.objects.get_or_create(name="Watchtower")
|
|
181
204
|
with TemporaryDirectory() as tmp:
|
|
182
205
|
base = Path(tmp)
|
|
183
206
|
lock_dir = base / "locks"
|
|
@@ -194,6 +217,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
194
217
|
patch(
|
|
195
218
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
196
219
|
),
|
|
220
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
197
221
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
198
222
|
patch.object(Node, "ensure_keys"),
|
|
199
223
|
patch.object(Node, "notify_peers_of_update"),
|
|
@@ -202,7 +226,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
202
226
|
self.assertTrue(created)
|
|
203
227
|
self.assertEqual(node.role.name, "Terminal")
|
|
204
228
|
|
|
205
|
-
role_file.write_text("
|
|
229
|
+
role_file.write_text("Watchtower")
|
|
206
230
|
with override_settings(BASE_DIR=base):
|
|
207
231
|
with (
|
|
208
232
|
patch(
|
|
@@ -213,6 +237,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
213
237
|
patch(
|
|
214
238
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
215
239
|
),
|
|
240
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
216
241
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
217
242
|
patch.object(Node, "ensure_keys"),
|
|
218
243
|
patch.object(Node, "notify_peers_of_update"),
|
|
@@ -221,7 +246,70 @@ class NodeGetLocalTests(TestCase):
|
|
|
221
246
|
|
|
222
247
|
self.assertFalse(created_again)
|
|
223
248
|
node.refresh_from_db()
|
|
224
|
-
self.assertEqual(node.role.name, "
|
|
249
|
+
self.assertEqual(node.role.name, "Watchtower")
|
|
250
|
+
|
|
251
|
+
role_file.write_text("Constellation")
|
|
252
|
+
with override_settings(BASE_DIR=base):
|
|
253
|
+
with (
|
|
254
|
+
patch(
|
|
255
|
+
"nodes.models.Node.get_current_mac",
|
|
256
|
+
return_value="00:aa:bb:cc:dd:ee",
|
|
257
|
+
),
|
|
258
|
+
patch("nodes.models.socket.gethostname", return_value="role-host"),
|
|
259
|
+
patch(
|
|
260
|
+
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
261
|
+
),
|
|
262
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
263
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
264
|
+
patch.object(Node, "ensure_keys"),
|
|
265
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
266
|
+
):
|
|
267
|
+
Node.register_current()
|
|
268
|
+
|
|
269
|
+
node.refresh_from_db()
|
|
270
|
+
self.assertEqual(node.role.name, "Watchtower")
|
|
271
|
+
|
|
272
|
+
def test_register_current_respects_node_hostname_env(self):
|
|
273
|
+
with TemporaryDirectory() as tmp:
|
|
274
|
+
base = Path(tmp)
|
|
275
|
+
with override_settings(BASE_DIR=base):
|
|
276
|
+
with (
|
|
277
|
+
patch.dict(os.environ, {"NODE_HOSTNAME": "gway-002"}, clear=False),
|
|
278
|
+
patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"),
|
|
279
|
+
patch("nodes.models.socket.gethostname", return_value="localhost"),
|
|
280
|
+
patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
|
|
281
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
282
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
283
|
+
patch.object(Node, "ensure_keys"),
|
|
284
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
285
|
+
):
|
|
286
|
+
node, created = Node.register_current()
|
|
287
|
+
self.assertTrue(created)
|
|
288
|
+
self.assertEqual(node.hostname, "gway-002")
|
|
289
|
+
self.assertEqual(node.public_endpoint, "gway-002")
|
|
290
|
+
|
|
291
|
+
def test_register_current_respects_public_endpoint_env(self):
|
|
292
|
+
with TemporaryDirectory() as tmp:
|
|
293
|
+
base = Path(tmp)
|
|
294
|
+
with override_settings(BASE_DIR=base):
|
|
295
|
+
with (
|
|
296
|
+
patch.dict(
|
|
297
|
+
os.environ,
|
|
298
|
+
{"NODE_HOSTNAME": "gway-alpha", "NODE_PUBLIC_ENDPOINT": "gway-002"},
|
|
299
|
+
clear=False,
|
|
300
|
+
),
|
|
301
|
+
patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:56"),
|
|
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-alpha")
|
|
312
|
+
self.assertEqual(node.public_endpoint, "gway-002")
|
|
225
313
|
|
|
226
314
|
def test_register_and_list_node(self):
|
|
227
315
|
response = self.client.post(
|
|
@@ -309,6 +397,101 @@ class NodeGetLocalTests(TestCase):
|
|
|
309
397
|
self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
|
|
310
398
|
self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
|
|
311
399
|
|
|
400
|
+
def test_register_node_accepts_network_hostname_without_address(self):
|
|
401
|
+
response = self.client.post(
|
|
402
|
+
reverse("register-node"),
|
|
403
|
+
data={
|
|
404
|
+
"hostname": "domain-node",
|
|
405
|
+
"network_hostname": "domain-node.example.com",
|
|
406
|
+
"port": 8050,
|
|
407
|
+
"mac_address": "aa:bb:cc:dd:ee:ff",
|
|
408
|
+
},
|
|
409
|
+
content_type="application/json",
|
|
410
|
+
)
|
|
411
|
+
self.assertEqual(response.status_code, 200)
|
|
412
|
+
node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:ff")
|
|
413
|
+
self.assertEqual(node.network_hostname, "domain-node.example.com")
|
|
414
|
+
self.assertIsNone(node.address)
|
|
415
|
+
self.assertIsNone(node.ipv4_address)
|
|
416
|
+
self.assertIsNone(node.ipv6_address)
|
|
417
|
+
|
|
418
|
+
def test_register_node_populates_missing_ip_fields_from_address(self):
|
|
419
|
+
response = self.client.post(
|
|
420
|
+
reverse("register-node"),
|
|
421
|
+
data={
|
|
422
|
+
"hostname": "address-node",
|
|
423
|
+
"address": "203.0.113.10",
|
|
424
|
+
"port": 8040,
|
|
425
|
+
"mac_address": "aa:bb:cc:dd:ee:01",
|
|
426
|
+
},
|
|
427
|
+
content_type="application/json",
|
|
428
|
+
)
|
|
429
|
+
self.assertEqual(response.status_code, 200)
|
|
430
|
+
node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:01")
|
|
431
|
+
self.assertEqual(node.address, "203.0.113.10")
|
|
432
|
+
self.assertEqual(node.ipv4_address, "203.0.113.10")
|
|
433
|
+
self.assertIsNone(node.ipv6_address)
|
|
434
|
+
|
|
435
|
+
def test_get_best_ip_ignores_non_ip_values(self):
|
|
436
|
+
node = Node.objects.create(
|
|
437
|
+
hostname="best-ip",
|
|
438
|
+
address="gateway.local",
|
|
439
|
+
ipv4_address="198.51.100.5",
|
|
440
|
+
port=8000,
|
|
441
|
+
mac_address="00:11:22:33:44:77",
|
|
442
|
+
)
|
|
443
|
+
self.assertEqual(node.get_best_ip(), "198.51.100.5")
|
|
444
|
+
|
|
445
|
+
def test_register_node_requires_contact_information(self):
|
|
446
|
+
response = self.client.post(
|
|
447
|
+
reverse("register-node"),
|
|
448
|
+
data={
|
|
449
|
+
"hostname": "missing-host",
|
|
450
|
+
"port": 8051,
|
|
451
|
+
"mac_address": "aa:bb:cc:dd:ee:00",
|
|
452
|
+
},
|
|
453
|
+
content_type="application/json",
|
|
454
|
+
)
|
|
455
|
+
self.assertEqual(response.status_code, 400)
|
|
456
|
+
self.assertIn("at least one", response.json()["detail"])
|
|
457
|
+
|
|
458
|
+
def test_register_node_assigns_interface_role_and_returns_uuid(self):
|
|
459
|
+
NodeRole.objects.get_or_create(name="Interface")
|
|
460
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
461
|
+
public_bytes = private_key.public_key().public_bytes(
|
|
462
|
+
encoding=serialization.Encoding.PEM,
|
|
463
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
464
|
+
).decode()
|
|
465
|
+
token = "interface-token"
|
|
466
|
+
signature = base64.b64encode(
|
|
467
|
+
private_key.sign(
|
|
468
|
+
token.encode(),
|
|
469
|
+
padding.PKCS1v15(),
|
|
470
|
+
hashes.SHA256(),
|
|
471
|
+
)
|
|
472
|
+
).decode()
|
|
473
|
+
mac = "aa:bb:cc:dd:ee:99"
|
|
474
|
+
payload = {
|
|
475
|
+
"hostname": "interface",
|
|
476
|
+
"address": "127.0.0.1",
|
|
477
|
+
"port": 8443,
|
|
478
|
+
"mac_address": mac,
|
|
479
|
+
"public_key": public_bytes,
|
|
480
|
+
"token": token,
|
|
481
|
+
"signature": signature,
|
|
482
|
+
"role": "Interface",
|
|
483
|
+
}
|
|
484
|
+
response = self.client.post(
|
|
485
|
+
reverse("register-node"),
|
|
486
|
+
data=json.dumps(payload),
|
|
487
|
+
content_type="application/json",
|
|
488
|
+
)
|
|
489
|
+
self.assertEqual(response.status_code, 200)
|
|
490
|
+
data = response.json()
|
|
491
|
+
self.assertIn("uuid", data)
|
|
492
|
+
node = Node.objects.get(mac_address=mac)
|
|
493
|
+
self.assertEqual(node.role.name, "Interface")
|
|
494
|
+
|
|
312
495
|
def test_register_node_feature_toggle(self):
|
|
313
496
|
NodeFeature.objects.get_or_create(
|
|
314
497
|
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
@@ -640,6 +823,125 @@ class NodeGetLocalTests(TestCase):
|
|
|
640
823
|
self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
|
|
641
824
|
|
|
642
825
|
|
|
826
|
+
class NodeEnsureKeysTests(TestCase):
|
|
827
|
+
def setUp(self):
|
|
828
|
+
self.tempdir = TemporaryDirectory()
|
|
829
|
+
self.base = Path(self.tempdir.name)
|
|
830
|
+
self.override = override_settings(BASE_DIR=self.base)
|
|
831
|
+
self.override.enable()
|
|
832
|
+
self.node = Node.objects.create(
|
|
833
|
+
hostname="ensure-host",
|
|
834
|
+
address="127.0.0.1",
|
|
835
|
+
port=8000,
|
|
836
|
+
mac_address="00:11:22:33:44:55",
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
def tearDown(self):
|
|
840
|
+
self.override.disable()
|
|
841
|
+
self.tempdir.cleanup()
|
|
842
|
+
|
|
843
|
+
def test_regenerates_missing_keys(self):
|
|
844
|
+
self.node.ensure_keys()
|
|
845
|
+
security_dir = self.base / "security"
|
|
846
|
+
priv_path = security_dir / self.node.public_endpoint
|
|
847
|
+
pub_path = security_dir / f"{self.node.public_endpoint}.pub"
|
|
848
|
+
original_public = self.node.public_key
|
|
849
|
+
priv_path.unlink()
|
|
850
|
+
pub_path.unlink()
|
|
851
|
+
|
|
852
|
+
self.node.ensure_keys()
|
|
853
|
+
|
|
854
|
+
self.assertTrue(priv_path.exists())
|
|
855
|
+
self.assertTrue(pub_path.exists())
|
|
856
|
+
self.assertNotEqual(self.node.public_key, original_public)
|
|
857
|
+
|
|
858
|
+
def test_regenerates_outdated_keys(self):
|
|
859
|
+
self.node.ensure_keys()
|
|
860
|
+
security_dir = self.base / "security"
|
|
861
|
+
priv_path = security_dir / self.node.public_endpoint
|
|
862
|
+
pub_path = security_dir / f"{self.node.public_endpoint}.pub"
|
|
863
|
+
original_private = priv_path.read_bytes()
|
|
864
|
+
original_public = pub_path.read_bytes()
|
|
865
|
+
|
|
866
|
+
old_time = (timezone.now() - timedelta(seconds=5)).timestamp()
|
|
867
|
+
os.utime(priv_path, (old_time, old_time))
|
|
868
|
+
os.utime(pub_path, (old_time, old_time))
|
|
869
|
+
|
|
870
|
+
with override_settings(NODE_KEY_MAX_AGE=timedelta(seconds=1)):
|
|
871
|
+
self.node.ensure_keys()
|
|
872
|
+
|
|
873
|
+
self.node.refresh_from_db()
|
|
874
|
+
self.assertNotEqual(priv_path.read_bytes(), original_private)
|
|
875
|
+
self.assertNotEqual(pub_path.read_bytes(), original_public)
|
|
876
|
+
self.assertNotEqual(self.node.public_key, original_public.decode())
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
class NodeInfoViewTests(TestCase):
|
|
880
|
+
def setUp(self):
|
|
881
|
+
self.mac = "02:00:00:00:00:01"
|
|
882
|
+
self.patcher = patch("nodes.models.Node.get_current_mac", return_value=self.mac)
|
|
883
|
+
self.patcher.start()
|
|
884
|
+
self.addCleanup(self.patcher.stop)
|
|
885
|
+
self.node = Node.objects.create(
|
|
886
|
+
hostname="local",
|
|
887
|
+
network_hostname="local.example.com",
|
|
888
|
+
address="10.0.0.10",
|
|
889
|
+
ipv4_address="10.0.0.10",
|
|
890
|
+
ipv6_address="2001:db8::10",
|
|
891
|
+
port=8000,
|
|
892
|
+
mac_address=self.mac,
|
|
893
|
+
public_endpoint="local",
|
|
894
|
+
current_relation=Node.Relation.SELF,
|
|
895
|
+
)
|
|
896
|
+
self.url = reverse("node-info")
|
|
897
|
+
|
|
898
|
+
def test_returns_https_port_for_secure_domain_request(self):
|
|
899
|
+
with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
|
|
900
|
+
response = self.client.get(
|
|
901
|
+
self.url,
|
|
902
|
+
secure=True,
|
|
903
|
+
HTTP_HOST="arthexis.com",
|
|
904
|
+
)
|
|
905
|
+
self.assertEqual(response.status_code, 200)
|
|
906
|
+
payload = response.json()
|
|
907
|
+
self.assertEqual(payload["port"], 443)
|
|
908
|
+
|
|
909
|
+
def test_returns_http_port_for_plain_domain_request(self):
|
|
910
|
+
with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
|
|
911
|
+
response = self.client.get(
|
|
912
|
+
self.url,
|
|
913
|
+
HTTP_HOST="arthexis.com",
|
|
914
|
+
)
|
|
915
|
+
self.assertEqual(response.status_code, 200)
|
|
916
|
+
payload = response.json()
|
|
917
|
+
self.assertEqual(payload["port"], 80)
|
|
918
|
+
self.assertEqual(payload.get("network_hostname"), "local.example.com")
|
|
919
|
+
self.assertIn("local.example.com", payload.get("contact_hosts", []))
|
|
920
|
+
|
|
921
|
+
def test_preserves_explicit_port_in_host_header(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:8443",
|
|
927
|
+
)
|
|
928
|
+
self.assertEqual(response.status_code, 200)
|
|
929
|
+
payload = response.json()
|
|
930
|
+
self.assertEqual(payload["port"], 8443)
|
|
931
|
+
|
|
932
|
+
def test_includes_role_in_payload(self):
|
|
933
|
+
role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
934
|
+
self.node.role = role
|
|
935
|
+
self.node.save(update_fields=["role"])
|
|
936
|
+
|
|
937
|
+
response = self.client.get(self.url)
|
|
938
|
+
self.assertEqual(response.status_code, 200)
|
|
939
|
+
payload = response.json()
|
|
940
|
+
self.assertEqual(payload.get("role"), "Terminal")
|
|
941
|
+
self.assertEqual(payload.get("ipv4_address"), "10.0.0.10")
|
|
942
|
+
self.assertEqual(payload.get("ipv6_address"), "2001:db8::10")
|
|
943
|
+
|
|
944
|
+
|
|
643
945
|
class RegisterVisitorNodeMessageTests(TestCase):
|
|
644
946
|
def setUp(self):
|
|
645
947
|
self.client = Client()
|
|
@@ -719,6 +1021,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
719
1021
|
patch(
|
|
720
1022
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
721
1023
|
),
|
|
1024
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
722
1025
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
723
1026
|
patch.object(Node, "ensure_keys"),
|
|
724
1027
|
patch.object(Node, "notify_peers_of_update") as mock_notify,
|
|
@@ -746,6 +1049,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
746
1049
|
patch(
|
|
747
1050
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
748
1051
|
),
|
|
1052
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
749
1053
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
750
1054
|
patch.object(Node, "ensure_keys"),
|
|
751
1055
|
):
|
|
@@ -764,6 +1068,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
764
1068
|
patch(
|
|
765
1069
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
766
1070
|
),
|
|
1071
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
767
1072
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
768
1073
|
patch.object(Node, "ensure_keys"),
|
|
769
1074
|
):
|
|
@@ -783,6 +1088,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
783
1088
|
patch(
|
|
784
1089
|
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
785
1090
|
),
|
|
1091
|
+
patch.object(Node, "_resolve_ip_addresses", return_value=([], [])),
|
|
786
1092
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
787
1093
|
patch.object(Node, "ensure_keys"),
|
|
788
1094
|
):
|
|
@@ -808,10 +1114,20 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
808
1114
|
return_value="00:ff:ee:dd:cc:bb",
|
|
809
1115
|
),
|
|
810
1116
|
patch("nodes.models.socket.gethostname", return_value="localnode"),
|
|
1117
|
+
patch("nodes.models.socket.getfqdn", return_value="localnode.example.com"),
|
|
811
1118
|
patch(
|
|
812
1119
|
"nodes.models.socket.gethostbyname",
|
|
813
1120
|
return_value="192.168.1.5",
|
|
814
1121
|
),
|
|
1122
|
+
patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
|
|
1123
|
+
patch.object(
|
|
1124
|
+
Node,
|
|
1125
|
+
"_resolve_ip_addresses",
|
|
1126
|
+
return_value=(
|
|
1127
|
+
["192.168.1.5", "93.184.216.34"],
|
|
1128
|
+
["fe80::1", "2001:4860:4860::8888"],
|
|
1129
|
+
),
|
|
1130
|
+
),
|
|
815
1131
|
patch("nodes.models.revision.get_revision", return_value="newrev"),
|
|
816
1132
|
patch("requests.post") as mock_post,
|
|
817
1133
|
):
|
|
@@ -835,6 +1151,9 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
835
1151
|
self.assertEqual(payload["hostname"], "localnode")
|
|
836
1152
|
self.assertEqual(payload["installed_version"], "2.0.0")
|
|
837
1153
|
self.assertEqual(payload["installed_revision"], "newrev")
|
|
1154
|
+
self.assertEqual(payload.get("network_hostname"), "localnode.example.com")
|
|
1155
|
+
self.assertEqual(payload.get("ipv4_address"), "93.184.216.34")
|
|
1156
|
+
self.assertEqual(payload.get("ipv6_address"), "2001:4860:4860::8888")
|
|
838
1157
|
|
|
839
1158
|
def test_register_current_notifies_peers_without_version_change(self):
|
|
840
1159
|
Node.objects.create(
|
|
@@ -853,10 +1172,20 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
853
1172
|
return_value="00:ff:ee:dd:cc:cc",
|
|
854
1173
|
),
|
|
855
1174
|
patch("nodes.models.socket.gethostname", return_value="samever"),
|
|
1175
|
+
patch("nodes.models.socket.getfqdn", return_value="samever.example.com"),
|
|
856
1176
|
patch(
|
|
857
1177
|
"nodes.models.socket.gethostbyname",
|
|
858
1178
|
return_value="192.168.1.6",
|
|
859
1179
|
),
|
|
1180
|
+
patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
|
|
1181
|
+
patch.object(
|
|
1182
|
+
Node,
|
|
1183
|
+
"_resolve_ip_addresses",
|
|
1184
|
+
return_value=(
|
|
1185
|
+
["192.168.1.6", "93.184.216.35"],
|
|
1186
|
+
["fe80::2", "2001:4860:4860::8844"],
|
|
1187
|
+
),
|
|
1188
|
+
),
|
|
860
1189
|
patch("nodes.models.revision.get_revision", return_value="rev1"),
|
|
861
1190
|
patch("requests.post") as mock_post,
|
|
862
1191
|
):
|
|
@@ -878,6 +1207,44 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
878
1207
|
payload = json.loads(kwargs["data"])
|
|
879
1208
|
self.assertEqual(payload["installed_version"], "1.0.0")
|
|
880
1209
|
self.assertEqual(payload.get("installed_revision"), "rev1")
|
|
1210
|
+
self.assertEqual(payload.get("network_hostname"), "samever.example.com")
|
|
1211
|
+
self.assertEqual(payload.get("ipv4_address"), "93.184.216.35")
|
|
1212
|
+
self.assertEqual(payload.get("ipv6_address"), "2001:4860:4860::8844")
|
|
1213
|
+
|
|
1214
|
+
def test_register_current_populates_network_fields(self):
|
|
1215
|
+
with TemporaryDirectory() as tmp:
|
|
1216
|
+
base = Path(tmp)
|
|
1217
|
+
with override_settings(BASE_DIR=base):
|
|
1218
|
+
with (
|
|
1219
|
+
patch(
|
|
1220
|
+
"nodes.models.Node.get_current_mac",
|
|
1221
|
+
return_value="00:12:34:56:78:90",
|
|
1222
|
+
),
|
|
1223
|
+
patch("nodes.models.socket.gethostname", return_value="nodehost"),
|
|
1224
|
+
patch("nodes.models.socket.getfqdn", return_value="nodehost.example.com"),
|
|
1225
|
+
patch(
|
|
1226
|
+
"nodes.models.socket.gethostbyname",
|
|
1227
|
+
return_value="10.0.0.5",
|
|
1228
|
+
),
|
|
1229
|
+
patch.dict(os.environ, {"HOSTNAME": ""}, clear=False),
|
|
1230
|
+
patch.object(
|
|
1231
|
+
Node,
|
|
1232
|
+
"_resolve_ip_addresses",
|
|
1233
|
+
return_value=(
|
|
1234
|
+
["10.0.0.5", "93.184.216.36"],
|
|
1235
|
+
["fe80::5", "2001:4860:4860::1"],
|
|
1236
|
+
),
|
|
1237
|
+
),
|
|
1238
|
+
patch("nodes.models.revision.get_revision", return_value="revX"),
|
|
1239
|
+
patch.object(Node, "ensure_keys"),
|
|
1240
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
1241
|
+
):
|
|
1242
|
+
node, created = Node.register_current()
|
|
1243
|
+
self.assertTrue(created)
|
|
1244
|
+
self.assertEqual(node.network_hostname, "nodehost.example.com")
|
|
1245
|
+
self.assertEqual(node.ipv4_address, "93.184.216.36")
|
|
1246
|
+
self.assertEqual(node.ipv6_address, "2001:4860:4860::1")
|
|
1247
|
+
self.assertEqual(node.address, "93.184.216.36")
|
|
881
1248
|
|
|
882
1249
|
@patch("nodes.views.capture_screenshot")
|
|
883
1250
|
def test_capture_screenshot(self, mock_capture):
|
|
@@ -1236,6 +1603,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1236
1603
|
existing_role.refresh_from_db()
|
|
1237
1604
|
self.assertEqual(existing_role.description, "updated via attachment")
|
|
1238
1605
|
|
|
1606
|
+
@pytest.mark.feature("clipboard-poll")
|
|
1239
1607
|
def test_clipboard_polling_creates_task(self):
|
|
1240
1608
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
1241
1609
|
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
@@ -1253,6 +1621,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1253
1621
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
|
|
1254
1622
|
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
1255
1623
|
|
|
1624
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1256
1625
|
def test_screenshot_polling_creates_task(self):
|
|
1257
1626
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
1258
1627
|
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
@@ -1381,6 +1750,7 @@ class NodeAdminTests(TestCase):
|
|
|
1381
1750
|
action_url = reverse("admin:core_rfid_scan")
|
|
1382
1751
|
self.assertContains(response, f'href="{action_url}"')
|
|
1383
1752
|
|
|
1753
|
+
@pytest.mark.feature("rpi-camera")
|
|
1384
1754
|
def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
|
|
1385
1755
|
node = self._create_local_node()
|
|
1386
1756
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1393,6 +1763,7 @@ class NodeAdminTests(TestCase):
|
|
|
1393
1763
|
self.assertContains(response, f'href="{snapshot_url}"')
|
|
1394
1764
|
self.assertContains(response, f'href="{stream_url}"')
|
|
1395
1765
|
|
|
1766
|
+
@pytest.mark.feature("audio-capture")
|
|
1396
1767
|
def test_node_feature_list_shows_waveform_action_when_enabled(self):
|
|
1397
1768
|
node = self._create_local_node()
|
|
1398
1769
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1403,6 +1774,7 @@ class NodeAdminTests(TestCase):
|
|
|
1403
1774
|
action_url = reverse("admin:nodes_nodefeature_view_waveform")
|
|
1404
1775
|
self.assertContains(response, f'href="{action_url}"')
|
|
1405
1776
|
|
|
1777
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1406
1778
|
def test_node_feature_list_hides_default_action_when_disabled(self):
|
|
1407
1779
|
self._create_local_node()
|
|
1408
1780
|
NodeFeature.objects.get_or_create(
|
|
@@ -1495,6 +1867,129 @@ class NodeAdminTests(TestCase):
|
|
|
1495
1867
|
response, reverse("admin:nodes_node_register_current")
|
|
1496
1868
|
)
|
|
1497
1869
|
|
|
1870
|
+
def test_apply_remote_node_info_updates_role(self):
|
|
1871
|
+
terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
1872
|
+
control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1873
|
+
node = Node.objects.create(
|
|
1874
|
+
hostname="remote-node",
|
|
1875
|
+
address="10.0.0.20",
|
|
1876
|
+
port=8001,
|
|
1877
|
+
mac_address="00:11:22:33:44:aa",
|
|
1878
|
+
role=terminal,
|
|
1879
|
+
)
|
|
1880
|
+
admin_instance = NodeAdmin(Node, admin.site)
|
|
1881
|
+
|
|
1882
|
+
payload = {
|
|
1883
|
+
"hostname": node.hostname,
|
|
1884
|
+
"network_hostname": "remote-node.example.com",
|
|
1885
|
+
"address": node.address,
|
|
1886
|
+
"ipv4_address": "198.51.100.10",
|
|
1887
|
+
"ipv6_address": "2001:db8::10",
|
|
1888
|
+
"port": node.port,
|
|
1889
|
+
"role": "Control",
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
changed = admin_instance._apply_remote_node_info(node, payload)
|
|
1893
|
+
node.refresh_from_db()
|
|
1894
|
+
|
|
1895
|
+
self.assertIn("role", changed)
|
|
1896
|
+
self.assertIn("network_hostname", changed)
|
|
1897
|
+
self.assertIn("ipv4_address", changed)
|
|
1898
|
+
self.assertIn("ipv6_address", changed)
|
|
1899
|
+
self.assertEqual(node.role, control)
|
|
1900
|
+
self.assertEqual(node.network_hostname, "remote-node.example.com")
|
|
1901
|
+
self.assertEqual(node.ipv4_address, "198.51.100.10")
|
|
1902
|
+
self.assertEqual(node.ipv6_address, "2001:db8::10")
|
|
1903
|
+
self.assertTrue(control.node_set.filter(pk=node.pk).exists())
|
|
1904
|
+
self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
|
|
1905
|
+
|
|
1906
|
+
def test_apply_remote_node_info_accepts_role_name_key(self):
|
|
1907
|
+
terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
1908
|
+
control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1909
|
+
node = Node.objects.create(
|
|
1910
|
+
hostname="role-name-node",
|
|
1911
|
+
address="10.0.0.21",
|
|
1912
|
+
port=8002,
|
|
1913
|
+
mac_address="00:11:22:33:44:bb",
|
|
1914
|
+
role=terminal,
|
|
1915
|
+
)
|
|
1916
|
+
admin_instance = NodeAdmin(Node, admin.site)
|
|
1917
|
+
|
|
1918
|
+
payload = {
|
|
1919
|
+
"hostname": node.hostname,
|
|
1920
|
+
"network_hostname": "role-name-node.example.com",
|
|
1921
|
+
"address": node.address,
|
|
1922
|
+
"ipv4_address": "198.51.100.11",
|
|
1923
|
+
"ipv6_address": "2001:db8::11",
|
|
1924
|
+
"port": node.port,
|
|
1925
|
+
"role_name": "Control",
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
changed = admin_instance._apply_remote_node_info(node, payload)
|
|
1929
|
+
node.refresh_from_db()
|
|
1930
|
+
|
|
1931
|
+
self.assertIn("role", changed)
|
|
1932
|
+
self.assertEqual(node.network_hostname, "role-name-node.example.com")
|
|
1933
|
+
self.assertEqual(node.ipv4_address, "198.51.100.11")
|
|
1934
|
+
self.assertEqual(node.ipv6_address, "2001:db8::11")
|
|
1935
|
+
self.assertEqual(node.role, control)
|
|
1936
|
+
self.assertTrue(control.node_set.filter(pk=node.pk).exists())
|
|
1937
|
+
self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
|
|
1938
|
+
|
|
1939
|
+
@patch("nodes.admin.requests.post")
|
|
1940
|
+
def test_send_forwarding_metadata_retries_until_success(self, mock_post):
|
|
1941
|
+
request = RequestFactory().get("/")
|
|
1942
|
+
local_node = Node.objects.create(
|
|
1943
|
+
hostname="local",
|
|
1944
|
+
address="127.0.0.1",
|
|
1945
|
+
port=8000,
|
|
1946
|
+
mac_address="00:11:22:33:44:aa",
|
|
1947
|
+
public_key="LOCAL-PUB",
|
|
1948
|
+
)
|
|
1949
|
+
class DummyNode:
|
|
1950
|
+
def __init__(self):
|
|
1951
|
+
self.port = 8443
|
|
1952
|
+
self.urls: list[str] = []
|
|
1953
|
+
|
|
1954
|
+
def iter_remote_urls(self, path: str):
|
|
1955
|
+
self.urls.append(path)
|
|
1956
|
+
yield "https://unreachable.example"
|
|
1957
|
+
yield "https://reachable.example"
|
|
1958
|
+
|
|
1959
|
+
def __str__(self): # pragma: no cover - trivial representation
|
|
1960
|
+
return "dummy-node"
|
|
1961
|
+
|
|
1962
|
+
target = DummyNode()
|
|
1963
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
1964
|
+
charger = Charger.objects.create(charger_id="FORWARD-1")
|
|
1965
|
+
|
|
1966
|
+
failure = MagicMock()
|
|
1967
|
+
failure.ok = False
|
|
1968
|
+
failure.status_code = 404
|
|
1969
|
+
failure.json.return_value = {"detail": "not found"}
|
|
1970
|
+
success = MagicMock()
|
|
1971
|
+
success.ok = True
|
|
1972
|
+
success.status_code = 200
|
|
1973
|
+
success.json.return_value = {"status": "ok"}
|
|
1974
|
+
|
|
1975
|
+
mock_post.side_effect = [failure, success]
|
|
1976
|
+
|
|
1977
|
+
admin_instance = NodeAdmin(Node, admin.site)
|
|
1978
|
+
|
|
1979
|
+
result = admin_instance._send_forwarding_metadata(
|
|
1980
|
+
request, target, [charger], local_node, private_key
|
|
1981
|
+
)
|
|
1982
|
+
|
|
1983
|
+
self.assertTrue(result)
|
|
1984
|
+
self.assertEqual(
|
|
1985
|
+
target.urls, ["/nodes/network/chargers/forward/"]
|
|
1986
|
+
)
|
|
1987
|
+
self.assertEqual(mock_post.call_count, 2)
|
|
1988
|
+
self.assertEqual(
|
|
1989
|
+
mock_post.call_args_list[1].args[0], "https://reachable.example"
|
|
1990
|
+
)
|
|
1991
|
+
|
|
1992
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1498
1993
|
@patch("nodes.admin.capture_screenshot")
|
|
1499
1994
|
def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
|
|
1500
1995
|
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
@@ -1540,6 +2035,53 @@ class NodeAdminTests(TestCase):
|
|
|
1540
2035
|
self.assertEqual(response.status_code, 200)
|
|
1541
2036
|
self.assertContains(response, "data:image/png;base64")
|
|
1542
2037
|
|
|
2038
|
+
def test_visit_link_uses_local_admin_dashboard_for_local_node(self):
|
|
2039
|
+
node_admin = admin.site._registry[Node]
|
|
2040
|
+
local_node = self._create_local_node()
|
|
2041
|
+
|
|
2042
|
+
link_html = node_admin.visit_link(local_node)
|
|
2043
|
+
|
|
2044
|
+
self.assertIn(reverse("admin:index"), link_html)
|
|
2045
|
+
self.assertIn("target=\"_blank\"", link_html)
|
|
2046
|
+
|
|
2047
|
+
def test_visit_link_prefers_remote_hostname_for_dashboard(self):
|
|
2048
|
+
node_admin = admin.site._registry[Node]
|
|
2049
|
+
remote = Node.objects.create(
|
|
2050
|
+
hostname="remote.example.com",
|
|
2051
|
+
address="198.51.100.20",
|
|
2052
|
+
port=8443,
|
|
2053
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
2054
|
+
)
|
|
2055
|
+
|
|
2056
|
+
link_html = node_admin.visit_link(remote)
|
|
2057
|
+
|
|
2058
|
+
self.assertIn("https://remote.example.com:8443/admin/", link_html)
|
|
2059
|
+
self.assertIn("target=\"_blank\"", link_html)
|
|
2060
|
+
|
|
2061
|
+
def test_iter_remote_urls_handles_hostname_with_path_and_port(self):
|
|
2062
|
+
node_admin = admin.site._registry[Node]
|
|
2063
|
+
remote = SimpleNamespace(
|
|
2064
|
+
public_endpoint="",
|
|
2065
|
+
address="",
|
|
2066
|
+
hostname="example.com/interface",
|
|
2067
|
+
port=8443,
|
|
2068
|
+
)
|
|
2069
|
+
|
|
2070
|
+
urls = list(node_admin._iter_remote_urls(remote, "/nodes/info/"))
|
|
2071
|
+
|
|
2072
|
+
self.assertIn(
|
|
2073
|
+
"https://example.com:8443/interface/nodes/info/",
|
|
2074
|
+
urls,
|
|
2075
|
+
)
|
|
2076
|
+
self.assertIn(
|
|
2077
|
+
"http://example.com:8443/interface/nodes/info/",
|
|
2078
|
+
urls,
|
|
2079
|
+
)
|
|
2080
|
+
combined = "".join(urls)
|
|
2081
|
+
self.assertNotIn("interface:8443", combined)
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1543
2085
|
@override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
|
|
1544
2086
|
@patch("nodes.admin.capture_screenshot")
|
|
1545
2087
|
def test_take_screenshots_action(self, mock_capture):
|
|
@@ -1572,6 +2114,7 @@ class NodeAdminTests(TestCase):
|
|
|
1572
2114
|
samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
|
|
1573
2115
|
self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
|
|
1574
2116
|
|
|
2117
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1575
2118
|
@patch("nodes.admin.capture_screenshot")
|
|
1576
2119
|
def test_take_screenshot_default_action_creates_sample(
|
|
1577
2120
|
self, mock_capture_screenshot
|
|
@@ -1646,6 +2189,7 @@ class NodeAdminTests(TestCase):
|
|
|
1646
2189
|
response, "Completed 0 of 1 feature check(s) successfully.", html=False
|
|
1647
2190
|
)
|
|
1648
2191
|
|
|
2192
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1649
2193
|
def test_enable_selected_features_enables_manual_feature(self):
|
|
1650
2194
|
node = self._create_local_node()
|
|
1651
2195
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1690,6 +2234,7 @@ class NodeAdminTests(TestCase):
|
|
|
1690
2234
|
html=False,
|
|
1691
2235
|
)
|
|
1692
2236
|
|
|
2237
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1693
2238
|
def test_take_screenshot_default_action_requires_enabled_feature(self):
|
|
1694
2239
|
self._create_local_node()
|
|
1695
2240
|
NodeFeature.objects.get_or_create(
|
|
@@ -1704,6 +2249,7 @@ class NodeAdminTests(TestCase):
|
|
|
1704
2249
|
self.assertEqual(ContentSample.objects.count(), 0)
|
|
1705
2250
|
self.assertContains(response, "Screenshot Poll feature is not enabled")
|
|
1706
2251
|
|
|
2252
|
+
@pytest.mark.feature("rpi-camera")
|
|
1707
2253
|
@patch("nodes.admin.capture_rpi_snapshot")
|
|
1708
2254
|
def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
|
|
1709
2255
|
node = self._create_local_node()
|
|
@@ -1726,6 +2272,7 @@ class NodeAdminTests(TestCase):
|
|
|
1726
2272
|
change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
|
|
1727
2273
|
self.assertEqual(response.redirect_chain[-1][0], change_url)
|
|
1728
2274
|
|
|
2275
|
+
@pytest.mark.feature("rpi-camera")
|
|
1729
2276
|
def test_view_stream_requires_enabled_feature(self):
|
|
1730
2277
|
self._create_local_node()
|
|
1731
2278
|
NodeFeature.objects.get_or_create(
|
|
@@ -1741,6 +2288,7 @@ class NodeAdminTests(TestCase):
|
|
|
1741
2288
|
response, "Raspberry Pi Camera feature is not enabled on this node."
|
|
1742
2289
|
)
|
|
1743
2290
|
|
|
2291
|
+
@pytest.mark.feature("rpi-camera")
|
|
1744
2292
|
def test_view_stream_renders_when_feature_enabled(self):
|
|
1745
2293
|
node = self._create_local_node()
|
|
1746
2294
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1756,6 +2304,7 @@ class NodeAdminTests(TestCase):
|
|
|
1756
2304
|
self.assertContains(response, expected_stream)
|
|
1757
2305
|
self.assertContains(response, "camera-stream__frame")
|
|
1758
2306
|
|
|
2307
|
+
@pytest.mark.feature("rpi-camera")
|
|
1759
2308
|
def test_view_stream_uses_configured_stream_url(self):
|
|
1760
2309
|
node = self._create_local_node()
|
|
1761
2310
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1773,6 +2322,7 @@ class NodeAdminTests(TestCase):
|
|
|
1773
2322
|
self.assertEqual(response.context_data["stream_embed"], "iframe")
|
|
1774
2323
|
self.assertContains(response, configured_stream)
|
|
1775
2324
|
|
|
2325
|
+
@pytest.mark.feature("rpi-camera")
|
|
1776
2326
|
def test_view_stream_detects_mjpeg_stream(self):
|
|
1777
2327
|
node = self._create_local_node()
|
|
1778
2328
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1789,6 +2339,7 @@ class NodeAdminTests(TestCase):
|
|
|
1789
2339
|
self.assertEqual(response.context_data["stream_embed"], "mjpeg")
|
|
1790
2340
|
self.assertContains(response, "<img", html=False)
|
|
1791
2341
|
|
|
2342
|
+
@pytest.mark.feature("rpi-camera")
|
|
1792
2343
|
def test_view_stream_marks_rtsp_stream_as_unsupported(self):
|
|
1793
2344
|
node = self._create_local_node()
|
|
1794
2345
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1805,6 +2356,7 @@ class NodeAdminTests(TestCase):
|
|
|
1805
2356
|
self.assertEqual(response.context_data["stream_embed"], "unsupported")
|
|
1806
2357
|
self.assertContains(response, "camera-stream__unsupported")
|
|
1807
2358
|
|
|
2359
|
+
@pytest.mark.feature("audio-capture")
|
|
1808
2360
|
def test_view_waveform_requires_enabled_feature(self):
|
|
1809
2361
|
self._create_local_node()
|
|
1810
2362
|
NodeFeature.objects.get_or_create(
|
|
@@ -1820,6 +2372,7 @@ class NodeAdminTests(TestCase):
|
|
|
1820
2372
|
response, "Audio Capture feature is not enabled on this node."
|
|
1821
2373
|
)
|
|
1822
2374
|
|
|
2375
|
+
@pytest.mark.feature("audio-capture")
|
|
1823
2376
|
def test_view_waveform_renders_when_feature_enabled(self):
|
|
1824
2377
|
node = self._create_local_node()
|
|
1825
2378
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -2072,6 +2625,53 @@ class NodeAdminTests(TestCase):
|
|
|
2072
2625
|
)
|
|
2073
2626
|
self.assertContains(response, str(remote))
|
|
2074
2627
|
|
|
2628
|
+
def test_send_net_message_action_displays_form(self):
|
|
2629
|
+
target = Node.objects.create(
|
|
2630
|
+
hostname="remote-one", address="10.0.0.10", port=8020
|
|
2631
|
+
)
|
|
2632
|
+
response = self.client.post(
|
|
2633
|
+
reverse("admin:nodes_node_changelist"),
|
|
2634
|
+
{
|
|
2635
|
+
"action": "send_net_message",
|
|
2636
|
+
helpers.ACTION_CHECKBOX_NAME: [str(target.pk)],
|
|
2637
|
+
},
|
|
2638
|
+
follow=False,
|
|
2639
|
+
)
|
|
2640
|
+
self.assertEqual(response.status_code, 200)
|
|
2641
|
+
response.render()
|
|
2642
|
+
self.assertContains(response, "Send Net Message")
|
|
2643
|
+
self.assertContains(response, str(target))
|
|
2644
|
+
self.assertContains(response, 'name="apply"')
|
|
2645
|
+
self.assertContains(response, "Selected node (1)")
|
|
2646
|
+
|
|
2647
|
+
@patch("nodes.admin.NetMessage.propagate")
|
|
2648
|
+
def test_send_net_message_action_creates_messages(self, mock_propagate):
|
|
2649
|
+
first = Node.objects.create(
|
|
2650
|
+
hostname="remote-two", address="10.0.0.11", port=8021
|
|
2651
|
+
)
|
|
2652
|
+
second = Node.objects.create(
|
|
2653
|
+
hostname="remote-three", address="10.0.0.12", port=8022
|
|
2654
|
+
)
|
|
2655
|
+
url = reverse("admin:nodes_node_changelist")
|
|
2656
|
+
payload = {
|
|
2657
|
+
"action": "send_net_message",
|
|
2658
|
+
"apply": "1",
|
|
2659
|
+
helpers.ACTION_CHECKBOX_NAME: [str(first.pk), str(second.pk)],
|
|
2660
|
+
"subject": "Maintenance",
|
|
2661
|
+
"body": "We will reboot tonight.",
|
|
2662
|
+
}
|
|
2663
|
+
existing_ids = set(NetMessage.objects.values_list("pk", flat=True))
|
|
2664
|
+
response = self.client.post(url, payload, follow=True)
|
|
2665
|
+
self.assertEqual(response.status_code, 200)
|
|
2666
|
+
new_messages = NetMessage.objects.exclude(pk__in=existing_ids)
|
|
2667
|
+
self.assertEqual(new_messages.count(), 2)
|
|
2668
|
+
self.assertEqual(mock_propagate.call_count, 2)
|
|
2669
|
+
for node in (first, second):
|
|
2670
|
+
message = new_messages.get(filter_node=node)
|
|
2671
|
+
self.assertEqual(message.subject, "Maintenance")
|
|
2672
|
+
self.assertEqual(message.body, "We will reboot tonight.")
|
|
2673
|
+
self.assertContains(response, "Sent 2 net messages.")
|
|
2674
|
+
|
|
2075
2675
|
@patch("nodes.admin.requests.post")
|
|
2076
2676
|
@patch("nodes.admin.requests.get")
|
|
2077
2677
|
def test_update_selected_nodes_progress_updates_remote(
|
|
@@ -2142,22 +2742,202 @@ class NodeAdminTests(TestCase):
|
|
|
2142
2742
|
self.assertEqual(post_data["mac_address"], local.mac_address)
|
|
2143
2743
|
|
|
2144
2744
|
|
|
2145
|
-
class
|
|
2146
|
-
def
|
|
2147
|
-
|
|
2148
|
-
|
|
2745
|
+
class NodeProxyGatewayTests(TestCase):
|
|
2746
|
+
def setUp(self):
|
|
2747
|
+
cache.clear()
|
|
2748
|
+
self.client = Client()
|
|
2749
|
+
self.private_key = rsa.generate_private_key(
|
|
2750
|
+
public_exponent=65537, key_size=2048
|
|
2149
2751
|
)
|
|
2150
|
-
|
|
2151
|
-
remote.public_key = key.public_key().public_bytes(
|
|
2752
|
+
public_key = self.private_key.public_key().public_bytes(
|
|
2152
2753
|
encoding=serialization.Encoding.PEM,
|
|
2153
2754
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
2154
2755
|
).decode()
|
|
2155
|
-
|
|
2756
|
+
self.node = Node.objects.create(
|
|
2757
|
+
hostname="requester",
|
|
2758
|
+
address="127.0.0.1",
|
|
2759
|
+
port=8000,
|
|
2760
|
+
mac_address="aa:bb:cc:dd:ee:aa",
|
|
2761
|
+
public_key=public_key,
|
|
2762
|
+
)
|
|
2763
|
+
patcher = patch("requests.post")
|
|
2764
|
+
self.addCleanup(patcher.stop)
|
|
2765
|
+
self.mock_requests_post = patcher.start()
|
|
2766
|
+
self.mock_requests_post.return_value = SimpleNamespace(
|
|
2767
|
+
ok=True,
|
|
2768
|
+
status_code=200,
|
|
2769
|
+
json=lambda: {},
|
|
2770
|
+
text="",
|
|
2771
|
+
)
|
|
2156
2772
|
|
|
2157
|
-
|
|
2773
|
+
def tearDown(self):
|
|
2774
|
+
cache.clear()
|
|
2158
2775
|
|
|
2159
|
-
|
|
2160
|
-
|
|
2776
|
+
def _sign(self, payload):
|
|
2777
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
2778
|
+
signature = base64.b64encode(
|
|
2779
|
+
self.private_key.sign(
|
|
2780
|
+
body.encode(), padding.PKCS1v15(), hashes.SHA256()
|
|
2781
|
+
)
|
|
2782
|
+
).decode()
|
|
2783
|
+
return body, signature
|
|
2784
|
+
|
|
2785
|
+
def test_proxy_session_creates_login_url(self):
|
|
2786
|
+
payload = {
|
|
2787
|
+
"requester": str(self.node.uuid),
|
|
2788
|
+
"user": {
|
|
2789
|
+
"username": "proxy-user",
|
|
2790
|
+
"email": "proxy@example.com",
|
|
2791
|
+
"first_name": "Proxy",
|
|
2792
|
+
"last_name": "User",
|
|
2793
|
+
"is_staff": True,
|
|
2794
|
+
"is_superuser": True,
|
|
2795
|
+
"groups": [],
|
|
2796
|
+
"permissions": [],
|
|
2797
|
+
},
|
|
2798
|
+
"target": "/admin/",
|
|
2799
|
+
}
|
|
2800
|
+
body, signature = self._sign(payload)
|
|
2801
|
+
response = self.client.post(
|
|
2802
|
+
reverse("node-proxy-session"),
|
|
2803
|
+
data=body,
|
|
2804
|
+
content_type="application/json",
|
|
2805
|
+
HTTP_X_SIGNATURE=signature,
|
|
2806
|
+
)
|
|
2807
|
+
self.assertEqual(response.status_code, 200)
|
|
2808
|
+
data = response.json()
|
|
2809
|
+
self.assertIn("login_url", data)
|
|
2810
|
+
user = get_user_model().objects.get(username="proxy-user")
|
|
2811
|
+
self.assertTrue(user.is_staff)
|
|
2812
|
+
parsed = urlparse(data["login_url"])
|
|
2813
|
+
login_response = self.client.get(parsed.path)
|
|
2814
|
+
self.assertEqual(login_response.status_code, 302)
|
|
2815
|
+
self.assertEqual(login_response["Location"], "/admin/")
|
|
2816
|
+
self.assertEqual(self.client.session.get("_auth_user_id"), str(user.pk))
|
|
2817
|
+
second = self.client.get(parsed.path)
|
|
2818
|
+
self.assertEqual(second.status_code, 410)
|
|
2819
|
+
|
|
2820
|
+
def test_proxy_session_accepts_mac_hint_when_uuid_unknown(self):
|
|
2821
|
+
payload = {
|
|
2822
|
+
"requester": str(uuid.uuid4()),
|
|
2823
|
+
"requester_mac": self.node.mac_address,
|
|
2824
|
+
"requester_public_key": self.node.public_key,
|
|
2825
|
+
"user": {
|
|
2826
|
+
"username": "proxy-user",
|
|
2827
|
+
"email": "proxy@example.com",
|
|
2828
|
+
"first_name": "Proxy",
|
|
2829
|
+
"last_name": "User",
|
|
2830
|
+
"is_staff": True,
|
|
2831
|
+
"is_superuser": True,
|
|
2832
|
+
"groups": [],
|
|
2833
|
+
"permissions": [],
|
|
2834
|
+
},
|
|
2835
|
+
"target": "/admin/",
|
|
2836
|
+
}
|
|
2837
|
+
body, signature = self._sign(payload)
|
|
2838
|
+
response = self.client.post(
|
|
2839
|
+
reverse("node-proxy-session"),
|
|
2840
|
+
data=body,
|
|
2841
|
+
content_type="application/json",
|
|
2842
|
+
HTTP_X_SIGNATURE=signature,
|
|
2843
|
+
)
|
|
2844
|
+
self.assertEqual(response.status_code, 200)
|
|
2845
|
+
|
|
2846
|
+
def test_proxy_execute_lists_nodes(self):
|
|
2847
|
+
Node.objects.create(
|
|
2848
|
+
hostname="target",
|
|
2849
|
+
address="127.0.0.5",
|
|
2850
|
+
port=8010,
|
|
2851
|
+
mac_address="aa:bb:cc:dd:ee:bb",
|
|
2852
|
+
)
|
|
2853
|
+
payload = {
|
|
2854
|
+
"requester": str(self.node.uuid),
|
|
2855
|
+
"action": "list",
|
|
2856
|
+
"model": "nodes.Node",
|
|
2857
|
+
"filters": {"hostname": "target"},
|
|
2858
|
+
"credentials": {
|
|
2859
|
+
"username": "suite-user",
|
|
2860
|
+
"password": "secret",
|
|
2861
|
+
"first_name": "Suite",
|
|
2862
|
+
"last_name": "User",
|
|
2863
|
+
},
|
|
2864
|
+
}
|
|
2865
|
+
body, signature = self._sign(payload)
|
|
2866
|
+
response = self.client.post(
|
|
2867
|
+
reverse("node-proxy-execute"),
|
|
2868
|
+
data=body,
|
|
2869
|
+
content_type="application/json",
|
|
2870
|
+
HTTP_X_SIGNATURE=signature,
|
|
2871
|
+
)
|
|
2872
|
+
self.assertEqual(response.status_code, 200)
|
|
2873
|
+
data = response.json()
|
|
2874
|
+
self.assertEqual(len(data.get("objects", [])), 1)
|
|
2875
|
+
record = data["objects"][0]
|
|
2876
|
+
self.assertEqual(record["fields"]["hostname"], "target")
|
|
2877
|
+
user = get_user_model().objects.get(username="suite-user")
|
|
2878
|
+
self.assertTrue(user.is_superuser)
|
|
2879
|
+
|
|
2880
|
+
def test_proxy_execute_requires_valid_password_for_existing_user(self):
|
|
2881
|
+
User = get_user_model()
|
|
2882
|
+
User.objects.create_user(username="suite-user", password="correct")
|
|
2883
|
+
payload = {
|
|
2884
|
+
"requester": str(self.node.uuid),
|
|
2885
|
+
"action": "list",
|
|
2886
|
+
"model": "nodes.Node",
|
|
2887
|
+
"credentials": {
|
|
2888
|
+
"username": "suite-user",
|
|
2889
|
+
"password": "wrong",
|
|
2890
|
+
},
|
|
2891
|
+
}
|
|
2892
|
+
body, signature = self._sign(payload)
|
|
2893
|
+
response = self.client.post(
|
|
2894
|
+
reverse("node-proxy-execute"),
|
|
2895
|
+
data=body,
|
|
2896
|
+
content_type="application/json",
|
|
2897
|
+
HTTP_X_SIGNATURE=signature,
|
|
2898
|
+
)
|
|
2899
|
+
self.assertEqual(response.status_code, 403)
|
|
2900
|
+
|
|
2901
|
+
def test_proxy_execute_schema_returns_models(self):
|
|
2902
|
+
payload = {
|
|
2903
|
+
"requester": str(self.node.uuid),
|
|
2904
|
+
"action": "schema",
|
|
2905
|
+
"credentials": {
|
|
2906
|
+
"username": "suite-user",
|
|
2907
|
+
"password": "secret",
|
|
2908
|
+
},
|
|
2909
|
+
}
|
|
2910
|
+
body, signature = self._sign(payload)
|
|
2911
|
+
response = self.client.post(
|
|
2912
|
+
reverse("node-proxy-execute"),
|
|
2913
|
+
data=body,
|
|
2914
|
+
content_type="application/json",
|
|
2915
|
+
HTTP_X_SIGNATURE=signature,
|
|
2916
|
+
)
|
|
2917
|
+
self.assertEqual(response.status_code, 200)
|
|
2918
|
+
data = response.json()
|
|
2919
|
+
models = data.get("models", [])
|
|
2920
|
+
self.assertTrue(models)
|
|
2921
|
+
suite_names = {entry.get("suite_name") for entry in models}
|
|
2922
|
+
self.assertIn("Nodes", suite_names)
|
|
2923
|
+
|
|
2924
|
+
|
|
2925
|
+
class NodeRFIDAPITests(TestCase):
|
|
2926
|
+
def test_import_endpoint_applies_payload_without_creating_accounts(self):
|
|
2927
|
+
remote = Node.objects.create(
|
|
2928
|
+
hostname="remote", address="127.0.0.10", port=8050
|
|
2929
|
+
)
|
|
2930
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
2931
|
+
remote.public_key = key.public_key().public_bytes(
|
|
2932
|
+
encoding=serialization.Encoding.PEM,
|
|
2933
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
2934
|
+
).decode()
|
|
2935
|
+
remote.save(update_fields=["public_key"])
|
|
2936
|
+
|
|
2937
|
+
existing = EnergyAccount.objects.create(name="KNOWN")
|
|
2938
|
+
|
|
2939
|
+
payload = {
|
|
2940
|
+
"requester": str(remote.uuid),
|
|
2161
2941
|
"rfids": [
|
|
2162
2942
|
{
|
|
2163
2943
|
"rfid": "deadface",
|
|
@@ -2315,37 +3095,14 @@ class NetMessageAdminTests(TransactionTestCase):
|
|
|
2315
3095
|
self.assertEqual(form["subject"].value(), "Re: Ping")
|
|
2316
3096
|
self.assertEqual(str(form["filter_node"].value()), str(node.pk))
|
|
2317
3097
|
|
|
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
3098
|
class NetMessageReachTests(TestCase):
|
|
2342
3099
|
def setUp(self):
|
|
2343
3100
|
self.roles = {}
|
|
2344
|
-
for name in ["Terminal", "Control", "Satellite", "
|
|
3101
|
+
for name in ["Terminal", "Control", "Satellite", "Watchtower"]:
|
|
2345
3102
|
self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
|
|
2346
3103
|
self.nodes = {}
|
|
2347
3104
|
for idx, name in enumerate(
|
|
2348
|
-
["Terminal", "Control", "Satellite", "
|
|
3105
|
+
["Terminal", "Control", "Satellite", "Watchtower"], start=1
|
|
2349
3106
|
):
|
|
2350
3107
|
self.nodes[name] = Node.objects.create(
|
|
2351
3108
|
hostname=name.lower(),
|
|
@@ -2389,15 +3146,15 @@ class NetMessageReachTests(TestCase):
|
|
|
2389
3146
|
self.assertEqual(mock_post.call_count, 3)
|
|
2390
3147
|
|
|
2391
3148
|
@patch("requests.post")
|
|
2392
|
-
def
|
|
3149
|
+
def test_watchtower_reach_prioritizes_watchtower(self, mock_post):
|
|
2393
3150
|
msg = NetMessage.objects.create(
|
|
2394
|
-
subject="s", body="b", reach=self.roles["
|
|
3151
|
+
subject="s", body="b", reach=self.roles["Watchtower"]
|
|
2395
3152
|
)
|
|
2396
3153
|
with patch.object(Node, "get_local", return_value=None):
|
|
2397
3154
|
msg.propagate()
|
|
2398
3155
|
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
2399
3156
|
self.assertEqual(
|
|
2400
|
-
roles, {"
|
|
3157
|
+
roles, {"Watchtower", "Satellite", "Control", "Terminal"}
|
|
2401
3158
|
)
|
|
2402
3159
|
self.assertEqual(mock_post.call_count, 4)
|
|
2403
3160
|
|
|
@@ -2572,7 +3329,7 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2572
3329
|
with patch.object(Node, "get_local", return_value=self.local):
|
|
2573
3330
|
msg = NetMessage.broadcast(subject="subject", body="body")
|
|
2574
3331
|
self.assertEqual(msg.node_origin, self.local)
|
|
2575
|
-
self.
|
|
3332
|
+
self.assertEqual(msg.reach, self.role)
|
|
2576
3333
|
|
|
2577
3334
|
@patch("requests.post")
|
|
2578
3335
|
@patch("core.notifications.notify")
|
|
@@ -2597,13 +3354,6 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2597
3354
|
self.assertNotIn(sender_addr, targets)
|
|
2598
3355
|
self.assertEqual(msg.propagated_to.count(), 4)
|
|
2599
3356
|
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
3357
|
|
|
2608
3358
|
@patch("requests.post")
|
|
2609
3359
|
@patch("core.notifications.notify", return_value=False)
|
|
@@ -2689,10 +3439,240 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2689
3439
|
):
|
|
2690
3440
|
msg.propagate()
|
|
2691
3441
|
|
|
2692
|
-
self.
|
|
2693
|
-
self.assertTrue(
|
|
2694
|
-
|
|
3442
|
+
self.assertEqual(msg.propagated_to.count(), len(self.remotes))
|
|
3443
|
+
self.assertTrue(msg.complete)
|
|
3444
|
+
|
|
3445
|
+
|
|
3446
|
+
class NetMessageQueueTests(TestCase):
|
|
3447
|
+
def setUp(self):
|
|
3448
|
+
self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
3449
|
+
self.feature, _ = NodeFeature.objects.get_or_create(
|
|
3450
|
+
slug="celery-queue", defaults={"display": "Celery Queue"}
|
|
3451
|
+
)
|
|
3452
|
+
|
|
3453
|
+
def test_propagate_queues_unreachable_downstream(self):
|
|
3454
|
+
local = Node.objects.create(
|
|
3455
|
+
hostname="local",
|
|
3456
|
+
address="10.0.0.1",
|
|
3457
|
+
port=8000,
|
|
3458
|
+
mac_address="00:11:22:33:44:10",
|
|
3459
|
+
role=self.role,
|
|
3460
|
+
public_endpoint="local",
|
|
3461
|
+
)
|
|
3462
|
+
downstream = Node.objects.create(
|
|
3463
|
+
hostname="downstream",
|
|
3464
|
+
address="10.0.0.2",
|
|
3465
|
+
port=8001,
|
|
3466
|
+
mac_address="00:11:22:33:44:11",
|
|
3467
|
+
role=self.role,
|
|
3468
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3469
|
+
)
|
|
3470
|
+
message = NetMessage.objects.create(subject="Queued", body="Body", reach=self.role)
|
|
3471
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
3472
|
+
Node, "get_private_key", return_value=None
|
|
3473
|
+
), patch("core.notifications.notify", return_value=False), patch(
|
|
3474
|
+
"requests.post", side_effect=Exception("fail")
|
|
3475
|
+
):
|
|
3476
|
+
message.propagate()
|
|
3477
|
+
|
|
3478
|
+
entry = PendingNetMessage.objects.get(node=downstream, message=message)
|
|
3479
|
+
self.assertIn(str(downstream.uuid), entry.seen)
|
|
3480
|
+
self.assertGreater(entry.stale_at, timezone.now())
|
|
3481
|
+
|
|
3482
|
+
def test_queue_limit_enforced(self):
|
|
3483
|
+
downstream = Node.objects.create(
|
|
3484
|
+
hostname="limit",
|
|
3485
|
+
address="10.0.0.3",
|
|
3486
|
+
port=8002,
|
|
3487
|
+
mac_address="00:11:22:33:44:12",
|
|
3488
|
+
role=self.role,
|
|
3489
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3490
|
+
message_queue_length=1,
|
|
3491
|
+
)
|
|
3492
|
+
msg1 = NetMessage.objects.create(subject="Old", body="One", reach=self.role)
|
|
3493
|
+
msg2 = NetMessage.objects.create(subject="New", body="Two", reach=self.role)
|
|
3494
|
+
|
|
3495
|
+
msg1.queue_for_node(downstream, [str(downstream.uuid)])
|
|
3496
|
+
msg2.queue_for_node(downstream, [str(downstream.uuid)])
|
|
3497
|
+
|
|
3498
|
+
entries = list(PendingNetMessage.objects.filter(node=downstream))
|
|
3499
|
+
self.assertEqual(len(entries), 1)
|
|
3500
|
+
self.assertEqual(entries[0].message, msg2)
|
|
3501
|
+
|
|
3502
|
+
def test_queue_duplicate_updates_stale(self):
|
|
3503
|
+
downstream = Node.objects.create(
|
|
3504
|
+
hostname="dup",
|
|
3505
|
+
address="10.0.0.4",
|
|
3506
|
+
port=8003,
|
|
3507
|
+
mac_address="00:11:22:33:44:13",
|
|
3508
|
+
role=self.role,
|
|
3509
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3510
|
+
)
|
|
3511
|
+
message = NetMessage.objects.create(subject="Dup", body="Dup", reach=self.role)
|
|
3512
|
+
first = timezone.now()
|
|
3513
|
+
second = first + timedelta(minutes=5)
|
|
3514
|
+
with patch(
|
|
3515
|
+
"nodes.models.timezone.now", side_effect=[first, second, second]
|
|
3516
|
+
):
|
|
3517
|
+
message.queue_for_node(downstream, ["first"])
|
|
3518
|
+
message.queue_for_node(downstream, ["second"])
|
|
3519
|
+
|
|
3520
|
+
entry = PendingNetMessage.objects.get(node=downstream, message=message)
|
|
3521
|
+
self.assertEqual(entry.seen, ["second"])
|
|
3522
|
+
self.assertEqual(entry.queued_at, second)
|
|
3523
|
+
self.assertEqual(entry.stale_at, second + timedelta(hours=1))
|
|
3524
|
+
|
|
3525
|
+
def test_pull_endpoint_returns_and_clears_messages(self):
|
|
3526
|
+
local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3527
|
+
downstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3528
|
+
local = Node.objects.create(
|
|
3529
|
+
hostname="hub",
|
|
3530
|
+
address="10.0.0.5",
|
|
3531
|
+
port=8004,
|
|
3532
|
+
mac_address="00:11:22:33:44:14",
|
|
3533
|
+
role=self.role,
|
|
3534
|
+
public_endpoint="hub",
|
|
3535
|
+
)
|
|
3536
|
+
downstream = Node.objects.create(
|
|
3537
|
+
hostname="remote",
|
|
3538
|
+
address="10.0.0.6",
|
|
3539
|
+
port=8005,
|
|
3540
|
+
mac_address="00:11:22:33:44:15",
|
|
3541
|
+
role=self.role,
|
|
3542
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3543
|
+
public_key=downstream_key.public_key()
|
|
3544
|
+
.public_bytes(
|
|
3545
|
+
serialization.Encoding.PEM,
|
|
3546
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
3547
|
+
)
|
|
3548
|
+
.decode(),
|
|
3549
|
+
)
|
|
3550
|
+
message = NetMessage.objects.create(subject="Fresh", body="Body", reach=self.role)
|
|
3551
|
+
stale_message = NetMessage.objects.create(subject="Stale", body="Body", reach=self.role)
|
|
3552
|
+
now = timezone.now()
|
|
3553
|
+
PendingNetMessage.objects.create(
|
|
3554
|
+
node=downstream,
|
|
3555
|
+
message=message,
|
|
3556
|
+
seen=[str(downstream.uuid)],
|
|
3557
|
+
stale_at=now + timedelta(minutes=30),
|
|
3558
|
+
)
|
|
3559
|
+
stale_entry = PendingNetMessage.objects.create(
|
|
3560
|
+
node=downstream,
|
|
3561
|
+
message=stale_message,
|
|
3562
|
+
seen=["stale"],
|
|
3563
|
+
stale_at=now - timedelta(minutes=5),
|
|
3564
|
+
)
|
|
3565
|
+
PendingNetMessage.objects.filter(pk=stale_entry.pk).update(
|
|
3566
|
+
queued_at=now - timedelta(minutes=5)
|
|
3567
|
+
)
|
|
3568
|
+
|
|
3569
|
+
def fake_get_private(node_obj):
|
|
3570
|
+
if node_obj.pk == local.pk:
|
|
3571
|
+
return local_key
|
|
3572
|
+
return None
|
|
3573
|
+
|
|
3574
|
+
payload = {"requester": str(downstream.uuid)}
|
|
3575
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
3576
|
+
signature = base64.b64encode(
|
|
3577
|
+
downstream_key.sign(
|
|
3578
|
+
body.encode(),
|
|
3579
|
+
padding.PKCS1v15(),
|
|
3580
|
+
hashes.SHA256(),
|
|
3581
|
+
)
|
|
3582
|
+
).decode()
|
|
3583
|
+
|
|
3584
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
3585
|
+
Node, "get_private_key", return_value=local_key
|
|
3586
|
+
):
|
|
3587
|
+
response = self.client.post(
|
|
3588
|
+
reverse("net-message-pull"),
|
|
3589
|
+
data=body,
|
|
3590
|
+
content_type="application/json",
|
|
3591
|
+
HTTP_X_SIGNATURE=signature,
|
|
3592
|
+
)
|
|
3593
|
+
|
|
3594
|
+
self.assertEqual(response.status_code, 200)
|
|
3595
|
+
data = response.json()
|
|
3596
|
+
self.assertEqual(len(data.get("messages", [])), 1)
|
|
3597
|
+
payload_data = data["messages"][0]["payload"]
|
|
3598
|
+
self.assertEqual(payload_data["uuid"], str(message.uuid))
|
|
3599
|
+
self.assertFalse(
|
|
3600
|
+
PendingNetMessage.objects.filter(node=downstream, message=message).exists()
|
|
3601
|
+
)
|
|
3602
|
+
self.assertFalse(
|
|
3603
|
+
PendingNetMessage.objects.filter(
|
|
3604
|
+
node=downstream, message=stale_message
|
|
3605
|
+
).exists()
|
|
3606
|
+
)
|
|
3607
|
+
response_signature = data["messages"][0]["signature"]
|
|
3608
|
+
local_public = local_key.public_key()
|
|
3609
|
+
local_public.verify(
|
|
3610
|
+
base64.b64decode(response_signature),
|
|
3611
|
+
json.dumps(payload_data, separators=(",", ":"), sort_keys=True).encode(),
|
|
3612
|
+
padding.PKCS1v15(),
|
|
3613
|
+
hashes.SHA256(),
|
|
3614
|
+
)
|
|
3615
|
+
|
|
3616
|
+
def test_poll_task_fetches_messages(self):
|
|
3617
|
+
local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3618
|
+
upstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3619
|
+
local = Node.objects.create(
|
|
3620
|
+
hostname="downstream",
|
|
3621
|
+
address="10.0.0.7",
|
|
3622
|
+
port=8006,
|
|
3623
|
+
mac_address="00:11:22:33:44:16",
|
|
3624
|
+
role=self.role,
|
|
3625
|
+
public_endpoint="downstream",
|
|
3626
|
+
)
|
|
3627
|
+
upstream = Node.objects.create(
|
|
3628
|
+
hostname="upstream",
|
|
3629
|
+
address="127.0.0.2",
|
|
3630
|
+
port=8010,
|
|
3631
|
+
mac_address="00:11:22:33:44:17",
|
|
3632
|
+
role=self.role,
|
|
3633
|
+
current_relation=Node.Relation.UPSTREAM,
|
|
3634
|
+
public_key=upstream_key.public_key()
|
|
3635
|
+
.public_bytes(
|
|
3636
|
+
serialization.Encoding.PEM,
|
|
3637
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
3638
|
+
)
|
|
3639
|
+
.decode(),
|
|
2695
3640
|
)
|
|
3641
|
+
NodeFeatureAssignment.objects.create(node=local, feature=self.feature)
|
|
3642
|
+
payload = {
|
|
3643
|
+
"uuid": str(uuid.uuid4()),
|
|
3644
|
+
"subject": "Update",
|
|
3645
|
+
"body": "Body",
|
|
3646
|
+
"seen": [str(local.uuid)],
|
|
3647
|
+
"origin": str(upstream.uuid),
|
|
3648
|
+
"sender": str(upstream.uuid),
|
|
3649
|
+
}
|
|
3650
|
+
payload_text = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
3651
|
+
payload_signature = base64.b64encode(
|
|
3652
|
+
upstream_key.sign(
|
|
3653
|
+
payload_text.encode(),
|
|
3654
|
+
padding.PKCS1v15(),
|
|
3655
|
+
hashes.SHA256(),
|
|
3656
|
+
)
|
|
3657
|
+
).decode()
|
|
3658
|
+
response = MagicMock()
|
|
3659
|
+
response.ok = True
|
|
3660
|
+
response.json.return_value = {
|
|
3661
|
+
"messages": [{"payload": payload, "signature": payload_signature}]
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
3665
|
+
Node, "get_private_key", return_value=local_key
|
|
3666
|
+
), patch("nodes.tasks.requests.post", return_value=response) as mock_post, patch.object(
|
|
3667
|
+
NetMessage, "propagate"
|
|
3668
|
+
) as mock_propagate:
|
|
3669
|
+
poll_unreachable_upstream()
|
|
3670
|
+
|
|
3671
|
+
created = NetMessage.objects.get(uuid=payload["uuid"])
|
|
3672
|
+
self.assertEqual(created.subject, "Update")
|
|
3673
|
+
self.assertEqual(created.node_origin, upstream)
|
|
3674
|
+
mock_post.assert_called_once()
|
|
3675
|
+
mock_propagate.assert_called_once()
|
|
2696
3676
|
|
|
2697
3677
|
|
|
2698
3678
|
class NetMessageSignatureTests(TestCase):
|
|
@@ -2765,6 +3745,82 @@ class NetMessageSignatureTests(TestCase):
|
|
|
2765
3745
|
self.assertTrue(signature_one)
|
|
2766
3746
|
self.assertTrue(signature_two)
|
|
2767
3747
|
self.assertNotEqual(signature_one, signature_two)
|
|
3748
|
+
|
|
3749
|
+
|
|
3750
|
+
class NetworkChargerActionSecurityTests(TestCase):
|
|
3751
|
+
def setUp(self):
|
|
3752
|
+
self.client = Client()
|
|
3753
|
+
self.local_node = Node.objects.create(
|
|
3754
|
+
hostname="local-node",
|
|
3755
|
+
address="127.0.0.1",
|
|
3756
|
+
port=8000,
|
|
3757
|
+
mac_address="00:aa:bb:cc:dd:10",
|
|
3758
|
+
public_endpoint="local-endpoint",
|
|
3759
|
+
)
|
|
3760
|
+
self.authorized_node = Node.objects.create(
|
|
3761
|
+
hostname="authorized-node",
|
|
3762
|
+
address="127.0.0.2",
|
|
3763
|
+
port=8001,
|
|
3764
|
+
mac_address="00:aa:bb:cc:dd:11",
|
|
3765
|
+
public_endpoint="authorized-endpoint",
|
|
3766
|
+
)
|
|
3767
|
+
self.unauthorized_node, self.unauthorized_key = self._create_signed_node(
|
|
3768
|
+
"unauthorized-node",
|
|
3769
|
+
mac_suffix=0x12,
|
|
3770
|
+
)
|
|
3771
|
+
self.charger = Charger.objects.create(
|
|
3772
|
+
charger_id="SECURE-TEST-1",
|
|
3773
|
+
allow_remote=True,
|
|
3774
|
+
manager_node=self.authorized_node,
|
|
3775
|
+
node_origin=self.local_node,
|
|
3776
|
+
)
|
|
3777
|
+
|
|
3778
|
+
def _create_signed_node(self, hostname: str, *, mac_suffix: int):
|
|
3779
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3780
|
+
public_bytes = key.public_key().public_bytes(
|
|
3781
|
+
encoding=serialization.Encoding.PEM,
|
|
3782
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
3783
|
+
)
|
|
3784
|
+
node = Node.objects.create(
|
|
3785
|
+
hostname=hostname,
|
|
3786
|
+
address="10.0.0.{:d}".format(mac_suffix),
|
|
3787
|
+
port=8020,
|
|
3788
|
+
mac_address="00:aa:bb:cc:dd:{:02x}".format(mac_suffix),
|
|
3789
|
+
public_key=public_bytes.decode(),
|
|
3790
|
+
public_endpoint=f"{hostname}-endpoint",
|
|
3791
|
+
)
|
|
3792
|
+
return node, key
|
|
3793
|
+
|
|
3794
|
+
def test_rejects_requests_from_unmanaged_nodes(self):
|
|
3795
|
+
url = reverse("node-network-charger-action")
|
|
3796
|
+
payload = {
|
|
3797
|
+
"requester": str(self.unauthorized_node.uuid),
|
|
3798
|
+
"charger_id": self.charger.charger_id,
|
|
3799
|
+
"action": "reset",
|
|
3800
|
+
}
|
|
3801
|
+
body = json.dumps(payload).encode()
|
|
3802
|
+
signature = self.unauthorized_key.sign(
|
|
3803
|
+
body,
|
|
3804
|
+
padding.PKCS1v15(),
|
|
3805
|
+
hashes.SHA256(),
|
|
3806
|
+
)
|
|
3807
|
+
headers = {"HTTP_X_SIGNATURE": base64.b64encode(signature).decode()}
|
|
3808
|
+
|
|
3809
|
+
with patch.object(Node, "get_local", return_value=self.local_node):
|
|
3810
|
+
response = self.client.post(
|
|
3811
|
+
url,
|
|
3812
|
+
data=body,
|
|
3813
|
+
content_type="application/json",
|
|
3814
|
+
**headers,
|
|
3815
|
+
)
|
|
3816
|
+
|
|
3817
|
+
self.assertEqual(response.status_code, 403)
|
|
3818
|
+
self.assertEqual(
|
|
3819
|
+
response.json().get("detail"),
|
|
3820
|
+
"requester does not manage this charger",
|
|
3821
|
+
)
|
|
3822
|
+
|
|
3823
|
+
|
|
2768
3824
|
class StartupNotificationTests(TestCase):
|
|
2769
3825
|
def test_startup_notification_uses_hostname_and_revision(self):
|
|
2770
3826
|
from nodes.apps import _startup_notification
|
|
@@ -2848,6 +3904,17 @@ class StartupHandlerTests(TestCase):
|
|
|
2848
3904
|
|
|
2849
3905
|
mock_start.assert_called_once()
|
|
2850
3906
|
|
|
3907
|
+
def test_handler_skips_during_migrate_command(self):
|
|
3908
|
+
import sys
|
|
3909
|
+
|
|
3910
|
+
from nodes.apps import _trigger_startup_notification
|
|
3911
|
+
|
|
3912
|
+
with patch("nodes.apps._startup_notification") as mock_start:
|
|
3913
|
+
with patch.object(sys, "argv", ["manage.py", "migrate"]):
|
|
3914
|
+
_trigger_startup_notification()
|
|
3915
|
+
|
|
3916
|
+
mock_start.assert_not_called()
|
|
3917
|
+
|
|
2851
3918
|
|
|
2852
3919
|
class NotificationManagerTests(TestCase):
|
|
2853
3920
|
def test_send_writes_trimmed_lines(self):
|
|
@@ -2930,6 +3997,7 @@ class ContentSampleTransactionTests(TestCase):
|
|
|
2930
3997
|
sample1.save()
|
|
2931
3998
|
|
|
2932
3999
|
|
|
4000
|
+
@pytest.mark.feature("clipboard-poll")
|
|
2933
4001
|
class ContentSampleAdminTests(TestCase):
|
|
2934
4002
|
def setUp(self):
|
|
2935
4003
|
User = get_user_model()
|
|
@@ -3106,6 +4174,7 @@ class EmailOutboxTests(TestCase):
|
|
|
3106
4174
|
|
|
3107
4175
|
|
|
3108
4176
|
class ClipboardTaskTests(TestCase):
|
|
4177
|
+
@pytest.mark.feature("clipboard-poll")
|
|
3109
4178
|
@patch("nodes.tasks.pyperclip.paste")
|
|
3110
4179
|
def test_sample_clipboard_task_creates_sample(self, mock_paste):
|
|
3111
4180
|
mock_paste.return_value = "task text"
|
|
@@ -3130,6 +4199,7 @@ class ClipboardTaskTests(TestCase):
|
|
|
3130
4199
|
ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
|
|
3131
4200
|
)
|
|
3132
4201
|
|
|
4202
|
+
@pytest.mark.feature("screenshot-poll")
|
|
3133
4203
|
@patch("nodes.tasks.capture_screenshot")
|
|
3134
4204
|
def test_capture_node_screenshot_task(self, mock_capture):
|
|
3135
4205
|
node = Node.objects.create(
|
|
@@ -3152,6 +4222,7 @@ class ClipboardTaskTests(TestCase):
|
|
|
3152
4222
|
self.assertEqual(screenshot.path, "screenshots/test.png")
|
|
3153
4223
|
self.assertEqual(screenshot.method, "TASK")
|
|
3154
4224
|
|
|
4225
|
+
@pytest.mark.feature("screenshot-poll")
|
|
3155
4226
|
@patch("nodes.tasks.capture_screenshot")
|
|
3156
4227
|
def test_capture_node_screenshot_handles_error(self, mock_capture):
|
|
3157
4228
|
Node.objects.create(
|
|
@@ -3169,6 +4240,17 @@ class ClipboardTaskTests(TestCase):
|
|
|
3169
4240
|
|
|
3170
4241
|
|
|
3171
4242
|
class CaptureScreenshotTests(TestCase):
|
|
4243
|
+
def setUp(self):
|
|
4244
|
+
super().setUp()
|
|
4245
|
+
self.firefox_patcher = patch(
|
|
4246
|
+
"nodes.utils._find_firefox_binary", return_value="/usr/bin/firefox"
|
|
4247
|
+
)
|
|
4248
|
+
self.ensure_geckodriver_patcher = patch("nodes.utils._ensure_geckodriver")
|
|
4249
|
+
self.firefox_patcher.start()
|
|
4250
|
+
self.ensure_geckodriver_patcher.start()
|
|
4251
|
+
self.addCleanup(self.firefox_patcher.stop)
|
|
4252
|
+
self.addCleanup(self.ensure_geckodriver_patcher.stop)
|
|
4253
|
+
|
|
3172
4254
|
@patch("nodes.utils.webdriver.Firefox")
|
|
3173
4255
|
def test_connection_failure_does_not_raise(self, mock_firefox):
|
|
3174
4256
|
browser = MagicMock()
|
|
@@ -3181,6 +4263,19 @@ class CaptureScreenshotTests(TestCase):
|
|
|
3181
4263
|
self.assertEqual(result.parent, screenshot_dir)
|
|
3182
4264
|
browser.save_screenshot.assert_called_once()
|
|
3183
4265
|
|
|
4266
|
+
def test_missing_firefox_reports_clear_error(self):
|
|
4267
|
+
with patch("nodes.utils._find_firefox_binary", return_value=None):
|
|
4268
|
+
with self.assertRaises(RuntimeError) as excinfo:
|
|
4269
|
+
capture_screenshot("http://example.com")
|
|
4270
|
+
self.assertIn("Firefox is not installed", str(excinfo.exception))
|
|
4271
|
+
|
|
4272
|
+
@patch("nodes.utils.webdriver.Firefox")
|
|
4273
|
+
def test_driver_install_hint_on_failure(self, mock_firefox):
|
|
4274
|
+
mock_firefox.side_effect = WebDriverException("Unable to obtain driver for firefox")
|
|
4275
|
+
with self.assertRaises(RuntimeError) as excinfo:
|
|
4276
|
+
capture_screenshot("http://example.com")
|
|
4277
|
+
self.assertIn("Firefox WebDriver is unavailable", str(excinfo.exception))
|
|
4278
|
+
|
|
3184
4279
|
|
|
3185
4280
|
class NodeRoleAdminTests(TestCase):
|
|
3186
4281
|
def setUp(self):
|
|
@@ -3231,7 +4326,7 @@ class NodeRoleAdminTests(TestCase):
|
|
|
3231
4326
|
|
|
3232
4327
|
class NodeFeatureFixtureTests(TestCase):
|
|
3233
4328
|
def test_rfid_scanner_fixture_includes_control_role(self):
|
|
3234
|
-
for name in ("Terminal", "Satellite", "
|
|
4329
|
+
for name in ("Terminal", "Satellite", "Watchtower", "Control"):
|
|
3235
4330
|
NodeRole.objects.get_or_create(name=name)
|
|
3236
4331
|
fixture_path = (
|
|
3237
4332
|
Path(__file__).resolve().parent
|
|
@@ -3243,6 +4338,7 @@ class NodeFeatureFixtureTests(TestCase):
|
|
|
3243
4338
|
role_names = set(feature.roles.values_list("name", flat=True))
|
|
3244
4339
|
self.assertIn("Control", role_names)
|
|
3245
4340
|
|
|
4341
|
+
@pytest.mark.feature("ap-router")
|
|
3246
4342
|
def test_ap_router_fixture_limits_roles(self):
|
|
3247
4343
|
for name in ("Control", "Satellite"):
|
|
3248
4344
|
NodeRole.objects.get_or_create(name=name)
|
|
@@ -3256,6 +4352,23 @@ class NodeFeatureFixtureTests(TestCase):
|
|
|
3256
4352
|
role_names = set(feature.roles.values_list("name", flat=True))
|
|
3257
4353
|
self.assertEqual(role_names, {"Satellite"})
|
|
3258
4354
|
|
|
4355
|
+
@pytest.mark.feature("graphql")
|
|
4356
|
+
def test_graphql_fixture_excludes_terminal_role(self):
|
|
4357
|
+
for name in ("Control", "Interface", "Satellite", "Terminal", "Watchtower"):
|
|
4358
|
+
NodeRole.objects.get_or_create(name=name)
|
|
4359
|
+
fixture_path = (
|
|
4360
|
+
Path(__file__).resolve().parent
|
|
4361
|
+
/ "fixtures"
|
|
4362
|
+
/ "node_features__nodefeature_graphql.json"
|
|
4363
|
+
)
|
|
4364
|
+
call_command("loaddata", str(fixture_path), verbosity=0)
|
|
4365
|
+
feature = NodeFeature.objects.get(slug="graphql")
|
|
4366
|
+
role_names = set(feature.roles.values_list("name", flat=True))
|
|
4367
|
+
self.assertEqual(
|
|
4368
|
+
role_names,
|
|
4369
|
+
{"Control", "Interface", "Satellite", "Watchtower"},
|
|
4370
|
+
)
|
|
4371
|
+
|
|
3259
4372
|
|
|
3260
4373
|
class NodeFeatureTests(TestCase):
|
|
3261
4374
|
def setUp(self):
|
|
@@ -3293,6 +4406,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3293
4406
|
self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
|
|
3294
4407
|
self.assertEqual(feature.get_default_action(), action)
|
|
3295
4408
|
|
|
4409
|
+
@pytest.mark.feature("rpi-camera")
|
|
3296
4410
|
def test_rpi_camera_feature_has_multiple_actions(self):
|
|
3297
4411
|
feature = NodeFeature.objects.create(
|
|
3298
4412
|
slug="rpi-camera", display="Raspberry Pi Camera"
|
|
@@ -3303,6 +4417,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3303
4417
|
self.assertIn("Take a Snapshot", labels)
|
|
3304
4418
|
self.assertIn("View stream", labels)
|
|
3305
4419
|
|
|
4420
|
+
@pytest.mark.feature("audio-capture")
|
|
3306
4421
|
def test_audio_capture_feature_has_view_waveform_action(self):
|
|
3307
4422
|
feature = NodeFeature.objects.create(
|
|
3308
4423
|
slug="audio-capture", display="Audio Capture"
|
|
@@ -3388,17 +4503,6 @@ class NodeFeatureTests(TestCase):
|
|
|
3388
4503
|
):
|
|
3389
4504
|
self.assertTrue(feature.is_enabled)
|
|
3390
4505
|
|
|
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
4506
|
@patch("nodes.models.Node._has_rpi_camera", return_value=True)
|
|
3403
4507
|
def test_rpi_camera_detection(self, mock_camera):
|
|
3404
4508
|
feature = NodeFeature.objects.create(
|
|
@@ -3437,49 +4541,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3437
4541
|
).exists()
|
|
3438
4542
|
)
|
|
3439
4543
|
|
|
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
|
-
|
|
4544
|
+
@pytest.mark.feature("ap-router")
|
|
3483
4545
|
@patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
|
|
3484
4546
|
def test_ap_router_detection(self, mock_hosts):
|
|
3485
4547
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
@@ -3499,6 +4561,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3499
4561
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
|
|
3500
4562
|
)
|
|
3501
4563
|
|
|
4564
|
+
@pytest.mark.feature("ap-router")
|
|
3502
4565
|
@patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
|
|
3503
4566
|
def test_ap_router_detection_with_public_mode_lock(self, mock_hosts):
|
|
3504
4567
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
@@ -3523,6 +4586,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3523
4586
|
NodeFeatureAssignment.objects.filter(node=node, feature=router).exists()
|
|
3524
4587
|
)
|
|
3525
4588
|
|
|
4589
|
+
@pytest.mark.feature("ap-router")
|
|
3526
4590
|
@patch("nodes.models.Node._hosts_gelectriic_ap", side_effect=[True, False])
|
|
3527
4591
|
def test_ap_router_removed_when_not_hosting(self, mock_hosts):
|
|
3528
4592
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
@@ -3895,6 +4959,38 @@ class ContentClassifierTests(TestCase):
|
|
|
3895
4959
|
tags = ContentClassification.objects.filter(sample=sample)
|
|
3896
4960
|
self.assertTrue(tags.filter(tag__slug="screenshot-tag").exists())
|
|
3897
4961
|
|
|
4962
|
+
def test_save_screenshot_returns_none_for_duplicate_without_linking(self):
|
|
4963
|
+
with TemporaryDirectory() as tmp:
|
|
4964
|
+
base = Path(tmp)
|
|
4965
|
+
first_path = base / "capture.png"
|
|
4966
|
+
first_path.write_bytes(b"binary image data")
|
|
4967
|
+
duplicate_path = base / "duplicate.png"
|
|
4968
|
+
duplicate_path.write_bytes(b"binary image data")
|
|
4969
|
+
with override_settings(LOG_DIR=base):
|
|
4970
|
+
original = save_screenshot(first_path, method="TEST")
|
|
4971
|
+
duplicate = save_screenshot(duplicate_path, method="TEST")
|
|
4972
|
+
|
|
4973
|
+
self.assertIsNotNone(original)
|
|
4974
|
+
self.assertIsNone(duplicate)
|
|
4975
|
+
self.assertEqual(ContentSample.objects.count(), 1)
|
|
4976
|
+
|
|
4977
|
+
def test_save_screenshot_reuses_existing_sample_when_linking(self):
|
|
4978
|
+
with TemporaryDirectory() as tmp:
|
|
4979
|
+
base = Path(tmp)
|
|
4980
|
+
first_path = base / "capture.png"
|
|
4981
|
+
first_path.write_bytes(b"binary image data")
|
|
4982
|
+
duplicate_path = base / "duplicate.png"
|
|
4983
|
+
duplicate_path.write_bytes(b"binary image data")
|
|
4984
|
+
with override_settings(LOG_DIR=base):
|
|
4985
|
+
original = save_screenshot(first_path, method="TEST")
|
|
4986
|
+
reused = save_screenshot(
|
|
4987
|
+
duplicate_path, method="TEST", link_duplicates=True
|
|
4988
|
+
)
|
|
4989
|
+
|
|
4990
|
+
self.assertIsNotNone(original)
|
|
4991
|
+
self.assertEqual(reused, original)
|
|
4992
|
+
self.assertEqual(ContentSample.objects.count(), 1)
|
|
4993
|
+
|
|
3898
4994
|
def test_text_sample_runs_default_classifiers_without_duplicates(self):
|
|
3899
4995
|
sample = ContentSample.objects.create(
|
|
3900
4996
|
content="Example content", kind=ContentSample.TEXT
|