arthexis 0.1.18__py3-none-any.whl → 0.1.20__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.
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/METADATA +39 -12
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/RECORD +44 -44
- config/settings.py +1 -5
- core/admin.py +142 -1
- core/backends.py +8 -2
- core/environment.py +221 -4
- core/models.py +124 -25
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/sigil_builder.py +2 -2
- core/system.py +125 -0
- core/tasks.py +24 -23
- core/tests.py +1 -0
- core/views.py +105 -40
- nodes/admin.py +134 -3
- nodes/models.py +310 -69
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +573 -48
- nodes/urls.py +4 -1
- nodes/views.py +498 -106
- ocpp/admin.py +124 -5
- ocpp/consumers.py +106 -9
- ocpp/models.py +90 -1
- ocpp/store.py +6 -4
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +114 -10
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +166 -40
- pages/admin.py +63 -10
- pages/context_processors.py +26 -9
- pages/defaults.py +1 -1
- pages/middleware.py +3 -0
- pages/models.py +35 -0
- pages/module_defaults.py +5 -5
- pages/tests.py +280 -65
- pages/urls.py +3 -1
- pages/views.py +176 -29
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/WHEEL +0 -0
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.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
|
|
@@ -18,6 +19,7 @@ from types import 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
|
|
@@ -38,6 +40,7 @@ from django.contrib.auth.models import Permission
|
|
|
38
40
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
39
41
|
from django.conf import settings
|
|
40
42
|
from django.utils import timezone
|
|
43
|
+
from urllib.parse import urlparse
|
|
41
44
|
from dns import resolver as dns_resolver
|
|
42
45
|
from . import dns as dns_utils
|
|
43
46
|
from selenium.common.exceptions import WebDriverException
|
|
@@ -56,11 +59,12 @@ from .models import (
|
|
|
56
59
|
NodeFeature,
|
|
57
60
|
NodeFeatureAssignment,
|
|
58
61
|
NetMessage,
|
|
62
|
+
PendingNetMessage,
|
|
59
63
|
NodeManager,
|
|
60
64
|
DNSRecord,
|
|
61
65
|
)
|
|
62
66
|
from .backends import OutboxEmailBackend
|
|
63
|
-
from .tasks import capture_node_screenshot, sample_clipboard
|
|
67
|
+
from .tasks import capture_node_screenshot, poll_unreachable_upstream, sample_clipboard
|
|
64
68
|
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
65
69
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
66
70
|
from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount
|
|
@@ -68,16 +72,16 @@ from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAcco
|
|
|
68
72
|
|
|
69
73
|
class NodeBadgeColorTests(TestCase):
|
|
70
74
|
def setUp(self):
|
|
71
|
-
self.
|
|
75
|
+
self.watchtower, _ = NodeRole.objects.get_or_create(name="Watchtower")
|
|
72
76
|
self.control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
73
77
|
|
|
74
|
-
def
|
|
78
|
+
def test_watchtower_role_defaults_to_goldenrod(self):
|
|
75
79
|
node = Node.objects.create(
|
|
76
|
-
hostname="
|
|
80
|
+
hostname="watchtower",
|
|
77
81
|
address="10.1.0.1",
|
|
78
82
|
port=8000,
|
|
79
83
|
mac_address="00:aa:bb:cc:dd:01",
|
|
80
|
-
role=self.
|
|
84
|
+
role=self.watchtower,
|
|
81
85
|
)
|
|
82
86
|
self.assertEqual(node.badge_color, "#daa520")
|
|
83
87
|
|
|
@@ -97,7 +101,7 @@ class NodeBadgeColorTests(TestCase):
|
|
|
97
101
|
address="10.1.0.3",
|
|
98
102
|
port=8002,
|
|
99
103
|
mac_address="00:aa:bb:cc:dd:03",
|
|
100
|
-
role=self.
|
|
104
|
+
role=self.watchtower,
|
|
101
105
|
badge_color="#123456",
|
|
102
106
|
)
|
|
103
107
|
self.assertEqual(node.badge_color, "#123456")
|
|
@@ -110,6 +114,7 @@ class NodeTests(TestCase):
|
|
|
110
114
|
self.user = User.objects.create_user(username="nodeuser", password="pwd")
|
|
111
115
|
self.client.force_login(self.user)
|
|
112
116
|
NodeRole.objects.get_or_create(name="Terminal")
|
|
117
|
+
NodeRole.objects.get_or_create(name="Interface")
|
|
113
118
|
|
|
114
119
|
|
|
115
120
|
class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
|
|
@@ -177,7 +182,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
177
182
|
|
|
178
183
|
def test_register_current_updates_role_from_lock_file(self):
|
|
179
184
|
NodeRole.objects.get_or_create(name="Terminal")
|
|
180
|
-
NodeRole.objects.get_or_create(name="
|
|
185
|
+
NodeRole.objects.get_or_create(name="Watchtower")
|
|
181
186
|
with TemporaryDirectory() as tmp:
|
|
182
187
|
base = Path(tmp)
|
|
183
188
|
lock_dir = base / "locks"
|
|
@@ -202,7 +207,7 @@ class NodeGetLocalTests(TestCase):
|
|
|
202
207
|
self.assertTrue(created)
|
|
203
208
|
self.assertEqual(node.role.name, "Terminal")
|
|
204
209
|
|
|
205
|
-
role_file.write_text("
|
|
210
|
+
role_file.write_text("Watchtower")
|
|
206
211
|
with override_settings(BASE_DIR=base):
|
|
207
212
|
with (
|
|
208
213
|
patch(
|
|
@@ -221,7 +226,67 @@ class NodeGetLocalTests(TestCase):
|
|
|
221
226
|
|
|
222
227
|
self.assertFalse(created_again)
|
|
223
228
|
node.refresh_from_db()
|
|
224
|
-
self.assertEqual(node.role.name, "
|
|
229
|
+
self.assertEqual(node.role.name, "Watchtower")
|
|
230
|
+
|
|
231
|
+
role_file.write_text("Constellation")
|
|
232
|
+
with override_settings(BASE_DIR=base):
|
|
233
|
+
with (
|
|
234
|
+
patch(
|
|
235
|
+
"nodes.models.Node.get_current_mac",
|
|
236
|
+
return_value="00:aa:bb:cc:dd:ee",
|
|
237
|
+
),
|
|
238
|
+
patch("nodes.models.socket.gethostname", return_value="role-host"),
|
|
239
|
+
patch(
|
|
240
|
+
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
241
|
+
),
|
|
242
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
243
|
+
patch.object(Node, "ensure_keys"),
|
|
244
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
245
|
+
):
|
|
246
|
+
Node.register_current()
|
|
247
|
+
|
|
248
|
+
node.refresh_from_db()
|
|
249
|
+
self.assertEqual(node.role.name, "Watchtower")
|
|
250
|
+
|
|
251
|
+
def test_register_current_respects_node_hostname_env(self):
|
|
252
|
+
with TemporaryDirectory() as tmp:
|
|
253
|
+
base = Path(tmp)
|
|
254
|
+
with override_settings(BASE_DIR=base):
|
|
255
|
+
with (
|
|
256
|
+
patch.dict(os.environ, {"NODE_HOSTNAME": "gway-002"}, clear=False),
|
|
257
|
+
patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"),
|
|
258
|
+
patch("nodes.models.socket.gethostname", return_value="localhost"),
|
|
259
|
+
patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
|
|
260
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
261
|
+
patch.object(Node, "ensure_keys"),
|
|
262
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
263
|
+
):
|
|
264
|
+
node, created = Node.register_current()
|
|
265
|
+
self.assertTrue(created)
|
|
266
|
+
self.assertEqual(node.hostname, "gway-002")
|
|
267
|
+
self.assertEqual(node.public_endpoint, "gway-002")
|
|
268
|
+
|
|
269
|
+
def test_register_current_respects_public_endpoint_env(self):
|
|
270
|
+
with TemporaryDirectory() as tmp:
|
|
271
|
+
base = Path(tmp)
|
|
272
|
+
with override_settings(BASE_DIR=base):
|
|
273
|
+
with (
|
|
274
|
+
patch.dict(
|
|
275
|
+
os.environ,
|
|
276
|
+
{"NODE_HOSTNAME": "gway-alpha", "NODE_PUBLIC_ENDPOINT": "gway-002"},
|
|
277
|
+
clear=False,
|
|
278
|
+
),
|
|
279
|
+
patch("nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:56"),
|
|
280
|
+
patch("nodes.models.socket.gethostname", return_value="localhost"),
|
|
281
|
+
patch("nodes.models.socket.gethostbyname", return_value="127.0.0.1"),
|
|
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-alpha")
|
|
289
|
+
self.assertEqual(node.public_endpoint, "gway-002")
|
|
225
290
|
|
|
226
291
|
def test_register_and_list_node(self):
|
|
227
292
|
response = self.client.post(
|
|
@@ -309,6 +374,43 @@ class NodeGetLocalTests(TestCase):
|
|
|
309
374
|
self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
|
|
310
375
|
self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
|
|
311
376
|
|
|
377
|
+
def test_register_node_assigns_interface_role_and_returns_uuid(self):
|
|
378
|
+
NodeRole.objects.get_or_create(name="Interface")
|
|
379
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
380
|
+
public_bytes = private_key.public_key().public_bytes(
|
|
381
|
+
encoding=serialization.Encoding.PEM,
|
|
382
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
383
|
+
).decode()
|
|
384
|
+
token = "interface-token"
|
|
385
|
+
signature = base64.b64encode(
|
|
386
|
+
private_key.sign(
|
|
387
|
+
token.encode(),
|
|
388
|
+
padding.PKCS1v15(),
|
|
389
|
+
hashes.SHA256(),
|
|
390
|
+
)
|
|
391
|
+
).decode()
|
|
392
|
+
mac = "aa:bb:cc:dd:ee:99"
|
|
393
|
+
payload = {
|
|
394
|
+
"hostname": "interface",
|
|
395
|
+
"address": "127.0.0.1",
|
|
396
|
+
"port": 8443,
|
|
397
|
+
"mac_address": mac,
|
|
398
|
+
"public_key": public_bytes,
|
|
399
|
+
"token": token,
|
|
400
|
+
"signature": signature,
|
|
401
|
+
"role": "Interface",
|
|
402
|
+
}
|
|
403
|
+
response = self.client.post(
|
|
404
|
+
reverse("register-node"),
|
|
405
|
+
data=json.dumps(payload),
|
|
406
|
+
content_type="application/json",
|
|
407
|
+
)
|
|
408
|
+
self.assertEqual(response.status_code, 200)
|
|
409
|
+
data = response.json()
|
|
410
|
+
self.assertIn("uuid", data)
|
|
411
|
+
node = Node.objects.get(mac_address=mac)
|
|
412
|
+
self.assertEqual(node.role.name, "Interface")
|
|
413
|
+
|
|
312
414
|
def test_register_node_feature_toggle(self):
|
|
313
415
|
NodeFeature.objects.get_or_create(
|
|
314
416
|
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
@@ -1236,6 +1338,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1236
1338
|
existing_role.refresh_from_db()
|
|
1237
1339
|
self.assertEqual(existing_role.description, "updated via attachment")
|
|
1238
1340
|
|
|
1341
|
+
@pytest.mark.feature("clipboard-poll")
|
|
1239
1342
|
def test_clipboard_polling_creates_task(self):
|
|
1240
1343
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
1241
1344
|
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
@@ -1253,6 +1356,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1253
1356
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
|
|
1254
1357
|
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
1255
1358
|
|
|
1359
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1256
1360
|
def test_screenshot_polling_creates_task(self):
|
|
1257
1361
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
1258
1362
|
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
@@ -1381,6 +1485,7 @@ class NodeAdminTests(TestCase):
|
|
|
1381
1485
|
action_url = reverse("admin:core_rfid_scan")
|
|
1382
1486
|
self.assertContains(response, f'href="{action_url}"')
|
|
1383
1487
|
|
|
1488
|
+
@pytest.mark.feature("rpi-camera")
|
|
1384
1489
|
def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
|
|
1385
1490
|
node = self._create_local_node()
|
|
1386
1491
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1393,6 +1498,7 @@ class NodeAdminTests(TestCase):
|
|
|
1393
1498
|
self.assertContains(response, f'href="{snapshot_url}"')
|
|
1394
1499
|
self.assertContains(response, f'href="{stream_url}"')
|
|
1395
1500
|
|
|
1501
|
+
@pytest.mark.feature("audio-capture")
|
|
1396
1502
|
def test_node_feature_list_shows_waveform_action_when_enabled(self):
|
|
1397
1503
|
node = self._create_local_node()
|
|
1398
1504
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1403,6 +1509,7 @@ class NodeAdminTests(TestCase):
|
|
|
1403
1509
|
action_url = reverse("admin:nodes_nodefeature_view_waveform")
|
|
1404
1510
|
self.assertContains(response, f'href="{action_url}"')
|
|
1405
1511
|
|
|
1512
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1406
1513
|
def test_node_feature_list_hides_default_action_when_disabled(self):
|
|
1407
1514
|
self._create_local_node()
|
|
1408
1515
|
NodeFeature.objects.get_or_create(
|
|
@@ -1495,6 +1602,7 @@ class NodeAdminTests(TestCase):
|
|
|
1495
1602
|
response, reverse("admin:nodes_node_register_current")
|
|
1496
1603
|
)
|
|
1497
1604
|
|
|
1605
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1498
1606
|
@patch("nodes.admin.capture_screenshot")
|
|
1499
1607
|
def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
|
|
1500
1608
|
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
@@ -1540,6 +1648,48 @@ class NodeAdminTests(TestCase):
|
|
|
1540
1648
|
self.assertEqual(response.status_code, 200)
|
|
1541
1649
|
self.assertContains(response, "data:image/png;base64")
|
|
1542
1650
|
|
|
1651
|
+
@patch("nodes.admin.requests.post")
|
|
1652
|
+
def test_proxy_view_uses_remote_login_url(self, mock_post):
|
|
1653
|
+
self.client.get(reverse("admin:nodes_node_register_current"))
|
|
1654
|
+
local_node = Node.objects.get()
|
|
1655
|
+
remote = Node.objects.create(
|
|
1656
|
+
hostname="remote",
|
|
1657
|
+
address="192.0.2.10",
|
|
1658
|
+
port=8443,
|
|
1659
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
1660
|
+
)
|
|
1661
|
+
mock_post.return_value = SimpleNamespace(
|
|
1662
|
+
ok=True,
|
|
1663
|
+
json=lambda: {
|
|
1664
|
+
"login_url": "https://remote.example/nodes/proxy/login/token",
|
|
1665
|
+
"expires": "2025-01-01T00:00:00",
|
|
1666
|
+
},
|
|
1667
|
+
status_code=200,
|
|
1668
|
+
text="ok",
|
|
1669
|
+
)
|
|
1670
|
+
response = self.client.get(
|
|
1671
|
+
reverse("admin:nodes_node_proxy", args=[remote.pk])
|
|
1672
|
+
)
|
|
1673
|
+
self.assertEqual(response.status_code, 200)
|
|
1674
|
+
self.assertTemplateUsed(response, "admin/nodes/node/proxy.html")
|
|
1675
|
+
self.assertContains(response, "<iframe", html=False)
|
|
1676
|
+
mock_post.assert_called()
|
|
1677
|
+
payload = json.loads(mock_post.call_args[1]["data"])
|
|
1678
|
+
self.assertEqual(payload.get("requester"), str(local_node.uuid))
|
|
1679
|
+
|
|
1680
|
+
def test_proxy_link_displayed_for_remote_nodes(self):
|
|
1681
|
+
Node.objects.create(
|
|
1682
|
+
hostname="remote",
|
|
1683
|
+
address="203.0.113.1",
|
|
1684
|
+
port=8000,
|
|
1685
|
+
mac_address="aa:aa:aa:aa:aa:01",
|
|
1686
|
+
)
|
|
1687
|
+
response = self.client.get(reverse("admin:nodes_node_changelist"))
|
|
1688
|
+
proxy_url = reverse("admin:nodes_node_proxy", args=[1])
|
|
1689
|
+
self.assertContains(response, proxy_url)
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1543
1693
|
@override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
|
|
1544
1694
|
@patch("nodes.admin.capture_screenshot")
|
|
1545
1695
|
def test_take_screenshots_action(self, mock_capture):
|
|
@@ -1572,6 +1722,7 @@ class NodeAdminTests(TestCase):
|
|
|
1572
1722
|
samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
|
|
1573
1723
|
self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
|
|
1574
1724
|
|
|
1725
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1575
1726
|
@patch("nodes.admin.capture_screenshot")
|
|
1576
1727
|
def test_take_screenshot_default_action_creates_sample(
|
|
1577
1728
|
self, mock_capture_screenshot
|
|
@@ -1646,6 +1797,7 @@ class NodeAdminTests(TestCase):
|
|
|
1646
1797
|
response, "Completed 0 of 1 feature check(s) successfully.", html=False
|
|
1647
1798
|
)
|
|
1648
1799
|
|
|
1800
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1649
1801
|
def test_enable_selected_features_enables_manual_feature(self):
|
|
1650
1802
|
node = self._create_local_node()
|
|
1651
1803
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1690,6 +1842,7 @@ class NodeAdminTests(TestCase):
|
|
|
1690
1842
|
html=False,
|
|
1691
1843
|
)
|
|
1692
1844
|
|
|
1845
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1693
1846
|
def test_take_screenshot_default_action_requires_enabled_feature(self):
|
|
1694
1847
|
self._create_local_node()
|
|
1695
1848
|
NodeFeature.objects.get_or_create(
|
|
@@ -1704,6 +1857,7 @@ class NodeAdminTests(TestCase):
|
|
|
1704
1857
|
self.assertEqual(ContentSample.objects.count(), 0)
|
|
1705
1858
|
self.assertContains(response, "Screenshot Poll feature is not enabled")
|
|
1706
1859
|
|
|
1860
|
+
@pytest.mark.feature("rpi-camera")
|
|
1707
1861
|
@patch("nodes.admin.capture_rpi_snapshot")
|
|
1708
1862
|
def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
|
|
1709
1863
|
node = self._create_local_node()
|
|
@@ -1726,6 +1880,7 @@ class NodeAdminTests(TestCase):
|
|
|
1726
1880
|
change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
|
|
1727
1881
|
self.assertEqual(response.redirect_chain[-1][0], change_url)
|
|
1728
1882
|
|
|
1883
|
+
@pytest.mark.feature("rpi-camera")
|
|
1729
1884
|
def test_view_stream_requires_enabled_feature(self):
|
|
1730
1885
|
self._create_local_node()
|
|
1731
1886
|
NodeFeature.objects.get_or_create(
|
|
@@ -1741,6 +1896,7 @@ class NodeAdminTests(TestCase):
|
|
|
1741
1896
|
response, "Raspberry Pi Camera feature is not enabled on this node."
|
|
1742
1897
|
)
|
|
1743
1898
|
|
|
1899
|
+
@pytest.mark.feature("rpi-camera")
|
|
1744
1900
|
def test_view_stream_renders_when_feature_enabled(self):
|
|
1745
1901
|
node = self._create_local_node()
|
|
1746
1902
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1756,6 +1912,7 @@ class NodeAdminTests(TestCase):
|
|
|
1756
1912
|
self.assertContains(response, expected_stream)
|
|
1757
1913
|
self.assertContains(response, "camera-stream__frame")
|
|
1758
1914
|
|
|
1915
|
+
@pytest.mark.feature("rpi-camera")
|
|
1759
1916
|
def test_view_stream_uses_configured_stream_url(self):
|
|
1760
1917
|
node = self._create_local_node()
|
|
1761
1918
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1773,6 +1930,7 @@ class NodeAdminTests(TestCase):
|
|
|
1773
1930
|
self.assertEqual(response.context_data["stream_embed"], "iframe")
|
|
1774
1931
|
self.assertContains(response, configured_stream)
|
|
1775
1932
|
|
|
1933
|
+
@pytest.mark.feature("rpi-camera")
|
|
1776
1934
|
def test_view_stream_detects_mjpeg_stream(self):
|
|
1777
1935
|
node = self._create_local_node()
|
|
1778
1936
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1789,6 +1947,7 @@ class NodeAdminTests(TestCase):
|
|
|
1789
1947
|
self.assertEqual(response.context_data["stream_embed"], "mjpeg")
|
|
1790
1948
|
self.assertContains(response, "<img", html=False)
|
|
1791
1949
|
|
|
1950
|
+
@pytest.mark.feature("rpi-camera")
|
|
1792
1951
|
def test_view_stream_marks_rtsp_stream_as_unsupported(self):
|
|
1793
1952
|
node = self._create_local_node()
|
|
1794
1953
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1805,6 +1964,7 @@ class NodeAdminTests(TestCase):
|
|
|
1805
1964
|
self.assertEqual(response.context_data["stream_embed"], "unsupported")
|
|
1806
1965
|
self.assertContains(response, "camera-stream__unsupported")
|
|
1807
1966
|
|
|
1967
|
+
@pytest.mark.feature("audio-capture")
|
|
1808
1968
|
def test_view_waveform_requires_enabled_feature(self):
|
|
1809
1969
|
self._create_local_node()
|
|
1810
1970
|
NodeFeature.objects.get_or_create(
|
|
@@ -1820,6 +1980,7 @@ class NodeAdminTests(TestCase):
|
|
|
1820
1980
|
response, "Audio Capture feature is not enabled on this node."
|
|
1821
1981
|
)
|
|
1822
1982
|
|
|
1983
|
+
@pytest.mark.feature("audio-capture")
|
|
1823
1984
|
def test_view_waveform_renders_when_feature_enabled(self):
|
|
1824
1985
|
node = self._create_local_node()
|
|
1825
1986
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -2142,6 +2303,160 @@ class NodeAdminTests(TestCase):
|
|
|
2142
2303
|
self.assertEqual(post_data["mac_address"], local.mac_address)
|
|
2143
2304
|
|
|
2144
2305
|
|
|
2306
|
+
class NodeProxyGatewayTests(TestCase):
|
|
2307
|
+
def setUp(self):
|
|
2308
|
+
cache.clear()
|
|
2309
|
+
self.client = Client()
|
|
2310
|
+
self.private_key = rsa.generate_private_key(
|
|
2311
|
+
public_exponent=65537, key_size=2048
|
|
2312
|
+
)
|
|
2313
|
+
public_key = self.private_key.public_key().public_bytes(
|
|
2314
|
+
encoding=serialization.Encoding.PEM,
|
|
2315
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
2316
|
+
).decode()
|
|
2317
|
+
self.node = Node.objects.create(
|
|
2318
|
+
hostname="requester",
|
|
2319
|
+
address="127.0.0.1",
|
|
2320
|
+
port=8000,
|
|
2321
|
+
mac_address="aa:bb:cc:dd:ee:aa",
|
|
2322
|
+
public_key=public_key,
|
|
2323
|
+
)
|
|
2324
|
+
patcher = patch("requests.post")
|
|
2325
|
+
self.addCleanup(patcher.stop)
|
|
2326
|
+
self.mock_requests_post = patcher.start()
|
|
2327
|
+
self.mock_requests_post.return_value = SimpleNamespace(
|
|
2328
|
+
ok=True,
|
|
2329
|
+
status_code=200,
|
|
2330
|
+
json=lambda: {},
|
|
2331
|
+
text="",
|
|
2332
|
+
)
|
|
2333
|
+
|
|
2334
|
+
def tearDown(self):
|
|
2335
|
+
cache.clear()
|
|
2336
|
+
|
|
2337
|
+
def _sign(self, payload):
|
|
2338
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
2339
|
+
signature = base64.b64encode(
|
|
2340
|
+
self.private_key.sign(
|
|
2341
|
+
body.encode(), padding.PKCS1v15(), hashes.SHA256()
|
|
2342
|
+
)
|
|
2343
|
+
).decode()
|
|
2344
|
+
return body, signature
|
|
2345
|
+
|
|
2346
|
+
def test_proxy_session_creates_login_url(self):
|
|
2347
|
+
payload = {
|
|
2348
|
+
"requester": str(self.node.uuid),
|
|
2349
|
+
"user": {
|
|
2350
|
+
"username": "proxy-user",
|
|
2351
|
+
"email": "proxy@example.com",
|
|
2352
|
+
"first_name": "Proxy",
|
|
2353
|
+
"last_name": "User",
|
|
2354
|
+
"is_staff": True,
|
|
2355
|
+
"is_superuser": True,
|
|
2356
|
+
"groups": [],
|
|
2357
|
+
"permissions": [],
|
|
2358
|
+
},
|
|
2359
|
+
"target": "/admin/",
|
|
2360
|
+
}
|
|
2361
|
+
body, signature = self._sign(payload)
|
|
2362
|
+
response = self.client.post(
|
|
2363
|
+
reverse("node-proxy-session"),
|
|
2364
|
+
data=body,
|
|
2365
|
+
content_type="application/json",
|
|
2366
|
+
HTTP_X_SIGNATURE=signature,
|
|
2367
|
+
)
|
|
2368
|
+
self.assertEqual(response.status_code, 200)
|
|
2369
|
+
data = response.json()
|
|
2370
|
+
self.assertIn("login_url", data)
|
|
2371
|
+
user = get_user_model().objects.get(username="proxy-user")
|
|
2372
|
+
self.assertTrue(user.is_staff)
|
|
2373
|
+
parsed = urlparse(data["login_url"])
|
|
2374
|
+
login_response = self.client.get(parsed.path)
|
|
2375
|
+
self.assertEqual(login_response.status_code, 302)
|
|
2376
|
+
self.assertEqual(login_response["Location"], "/admin/")
|
|
2377
|
+
self.assertEqual(self.client.session.get("_auth_user_id"), str(user.pk))
|
|
2378
|
+
second = self.client.get(parsed.path)
|
|
2379
|
+
self.assertEqual(second.status_code, 410)
|
|
2380
|
+
|
|
2381
|
+
def test_proxy_execute_lists_nodes(self):
|
|
2382
|
+
Node.objects.create(
|
|
2383
|
+
hostname="target",
|
|
2384
|
+
address="127.0.0.5",
|
|
2385
|
+
port=8010,
|
|
2386
|
+
mac_address="aa:bb:cc:dd:ee:bb",
|
|
2387
|
+
)
|
|
2388
|
+
payload = {
|
|
2389
|
+
"requester": str(self.node.uuid),
|
|
2390
|
+
"action": "list",
|
|
2391
|
+
"model": "nodes.Node",
|
|
2392
|
+
"filters": {"hostname": "target"},
|
|
2393
|
+
"credentials": {
|
|
2394
|
+
"username": "suite-user",
|
|
2395
|
+
"password": "secret",
|
|
2396
|
+
"first_name": "Suite",
|
|
2397
|
+
"last_name": "User",
|
|
2398
|
+
},
|
|
2399
|
+
}
|
|
2400
|
+
body, signature = self._sign(payload)
|
|
2401
|
+
response = self.client.post(
|
|
2402
|
+
reverse("node-proxy-execute"),
|
|
2403
|
+
data=body,
|
|
2404
|
+
content_type="application/json",
|
|
2405
|
+
HTTP_X_SIGNATURE=signature,
|
|
2406
|
+
)
|
|
2407
|
+
self.assertEqual(response.status_code, 200)
|
|
2408
|
+
data = response.json()
|
|
2409
|
+
self.assertEqual(len(data.get("objects", [])), 1)
|
|
2410
|
+
record = data["objects"][0]
|
|
2411
|
+
self.assertEqual(record["fields"]["hostname"], "target")
|
|
2412
|
+
user = get_user_model().objects.get(username="suite-user")
|
|
2413
|
+
self.assertTrue(user.is_superuser)
|
|
2414
|
+
|
|
2415
|
+
def test_proxy_execute_requires_valid_password_for_existing_user(self):
|
|
2416
|
+
User = get_user_model()
|
|
2417
|
+
User.objects.create_user(username="suite-user", password="correct")
|
|
2418
|
+
payload = {
|
|
2419
|
+
"requester": str(self.node.uuid),
|
|
2420
|
+
"action": "list",
|
|
2421
|
+
"model": "nodes.Node",
|
|
2422
|
+
"credentials": {
|
|
2423
|
+
"username": "suite-user",
|
|
2424
|
+
"password": "wrong",
|
|
2425
|
+
},
|
|
2426
|
+
}
|
|
2427
|
+
body, signature = self._sign(payload)
|
|
2428
|
+
response = self.client.post(
|
|
2429
|
+
reverse("node-proxy-execute"),
|
|
2430
|
+
data=body,
|
|
2431
|
+
content_type="application/json",
|
|
2432
|
+
HTTP_X_SIGNATURE=signature,
|
|
2433
|
+
)
|
|
2434
|
+
self.assertEqual(response.status_code, 403)
|
|
2435
|
+
|
|
2436
|
+
def test_proxy_execute_schema_returns_models(self):
|
|
2437
|
+
payload = {
|
|
2438
|
+
"requester": str(self.node.uuid),
|
|
2439
|
+
"action": "schema",
|
|
2440
|
+
"credentials": {
|
|
2441
|
+
"username": "suite-user",
|
|
2442
|
+
"password": "secret",
|
|
2443
|
+
},
|
|
2444
|
+
}
|
|
2445
|
+
body, signature = self._sign(payload)
|
|
2446
|
+
response = self.client.post(
|
|
2447
|
+
reverse("node-proxy-execute"),
|
|
2448
|
+
data=body,
|
|
2449
|
+
content_type="application/json",
|
|
2450
|
+
HTTP_X_SIGNATURE=signature,
|
|
2451
|
+
)
|
|
2452
|
+
self.assertEqual(response.status_code, 200)
|
|
2453
|
+
data = response.json()
|
|
2454
|
+
models = data.get("models", [])
|
|
2455
|
+
self.assertTrue(models)
|
|
2456
|
+
suite_names = {entry.get("suite_name") for entry in models}
|
|
2457
|
+
self.assertIn("Nodes", suite_names)
|
|
2458
|
+
|
|
2459
|
+
|
|
2145
2460
|
class NodeRFIDAPITests(TestCase):
|
|
2146
2461
|
def test_import_endpoint_applies_payload_without_creating_accounts(self):
|
|
2147
2462
|
remote = Node.objects.create(
|
|
@@ -2315,37 +2630,14 @@ class NetMessageAdminTests(TransactionTestCase):
|
|
|
2315
2630
|
self.assertEqual(form["subject"].value(), "Re: Ping")
|
|
2316
2631
|
self.assertEqual(str(form["filter_node"].value()), str(node.pk))
|
|
2317
2632
|
|
|
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
2633
|
class NetMessageReachTests(TestCase):
|
|
2342
2634
|
def setUp(self):
|
|
2343
2635
|
self.roles = {}
|
|
2344
|
-
for name in ["Terminal", "Control", "Satellite", "
|
|
2636
|
+
for name in ["Terminal", "Control", "Satellite", "Watchtower"]:
|
|
2345
2637
|
self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
|
|
2346
2638
|
self.nodes = {}
|
|
2347
2639
|
for idx, name in enumerate(
|
|
2348
|
-
["Terminal", "Control", "Satellite", "
|
|
2640
|
+
["Terminal", "Control", "Satellite", "Watchtower"], start=1
|
|
2349
2641
|
):
|
|
2350
2642
|
self.nodes[name] = Node.objects.create(
|
|
2351
2643
|
hostname=name.lower(),
|
|
@@ -2389,15 +2681,15 @@ class NetMessageReachTests(TestCase):
|
|
|
2389
2681
|
self.assertEqual(mock_post.call_count, 3)
|
|
2390
2682
|
|
|
2391
2683
|
@patch("requests.post")
|
|
2392
|
-
def
|
|
2684
|
+
def test_watchtower_reach_prioritizes_watchtower(self, mock_post):
|
|
2393
2685
|
msg = NetMessage.objects.create(
|
|
2394
|
-
subject="s", body="b", reach=self.roles["
|
|
2686
|
+
subject="s", body="b", reach=self.roles["Watchtower"]
|
|
2395
2687
|
)
|
|
2396
2688
|
with patch.object(Node, "get_local", return_value=None):
|
|
2397
2689
|
msg.propagate()
|
|
2398
2690
|
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
2399
2691
|
self.assertEqual(
|
|
2400
|
-
roles, {"
|
|
2692
|
+
roles, {"Watchtower", "Satellite", "Control", "Terminal"}
|
|
2401
2693
|
)
|
|
2402
2694
|
self.assertEqual(mock_post.call_count, 4)
|
|
2403
2695
|
|
|
@@ -2597,13 +2889,6 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2597
2889
|
self.assertNotIn(sender_addr, targets)
|
|
2598
2890
|
self.assertEqual(msg.propagated_to.count(), 4)
|
|
2599
2891
|
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
2892
|
|
|
2608
2893
|
@patch("requests.post")
|
|
2609
2894
|
@patch("core.notifications.notify", return_value=False)
|
|
@@ -2689,10 +2974,240 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2689
2974
|
):
|
|
2690
2975
|
msg.propagate()
|
|
2691
2976
|
|
|
2692
|
-
self.
|
|
2693
|
-
self.assertTrue(
|
|
2694
|
-
|
|
2977
|
+
self.assertEqual(msg.propagated_to.count(), len(self.remotes))
|
|
2978
|
+
self.assertTrue(msg.complete)
|
|
2979
|
+
|
|
2980
|
+
|
|
2981
|
+
class NetMessageQueueTests(TestCase):
|
|
2982
|
+
def setUp(self):
|
|
2983
|
+
self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
2984
|
+
self.feature, _ = NodeFeature.objects.get_or_create(
|
|
2985
|
+
slug="celery-queue", defaults={"display": "Celery Queue"}
|
|
2986
|
+
)
|
|
2987
|
+
|
|
2988
|
+
def test_propagate_queues_unreachable_downstream(self):
|
|
2989
|
+
local = Node.objects.create(
|
|
2990
|
+
hostname="local",
|
|
2991
|
+
address="10.0.0.1",
|
|
2992
|
+
port=8000,
|
|
2993
|
+
mac_address="00:11:22:33:44:10",
|
|
2994
|
+
role=self.role,
|
|
2995
|
+
public_endpoint="local",
|
|
2996
|
+
)
|
|
2997
|
+
downstream = Node.objects.create(
|
|
2998
|
+
hostname="downstream",
|
|
2999
|
+
address="10.0.0.2",
|
|
3000
|
+
port=8001,
|
|
3001
|
+
mac_address="00:11:22:33:44:11",
|
|
3002
|
+
role=self.role,
|
|
3003
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3004
|
+
)
|
|
3005
|
+
message = NetMessage.objects.create(subject="Queued", body="Body", reach=self.role)
|
|
3006
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
3007
|
+
Node, "get_private_key", return_value=None
|
|
3008
|
+
), patch("core.notifications.notify", return_value=False), patch(
|
|
3009
|
+
"requests.post", side_effect=Exception("fail")
|
|
3010
|
+
):
|
|
3011
|
+
message.propagate()
|
|
3012
|
+
|
|
3013
|
+
entry = PendingNetMessage.objects.get(node=downstream, message=message)
|
|
3014
|
+
self.assertIn(str(downstream.uuid), entry.seen)
|
|
3015
|
+
self.assertGreater(entry.stale_at, timezone.now())
|
|
3016
|
+
|
|
3017
|
+
def test_queue_limit_enforced(self):
|
|
3018
|
+
downstream = Node.objects.create(
|
|
3019
|
+
hostname="limit",
|
|
3020
|
+
address="10.0.0.3",
|
|
3021
|
+
port=8002,
|
|
3022
|
+
mac_address="00:11:22:33:44:12",
|
|
3023
|
+
role=self.role,
|
|
3024
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3025
|
+
message_queue_length=1,
|
|
3026
|
+
)
|
|
3027
|
+
msg1 = NetMessage.objects.create(subject="Old", body="One", reach=self.role)
|
|
3028
|
+
msg2 = NetMessage.objects.create(subject="New", body="Two", reach=self.role)
|
|
3029
|
+
|
|
3030
|
+
msg1.queue_for_node(downstream, [str(downstream.uuid)])
|
|
3031
|
+
msg2.queue_for_node(downstream, [str(downstream.uuid)])
|
|
3032
|
+
|
|
3033
|
+
entries = list(PendingNetMessage.objects.filter(node=downstream))
|
|
3034
|
+
self.assertEqual(len(entries), 1)
|
|
3035
|
+
self.assertEqual(entries[0].message, msg2)
|
|
3036
|
+
|
|
3037
|
+
def test_queue_duplicate_updates_stale(self):
|
|
3038
|
+
downstream = Node.objects.create(
|
|
3039
|
+
hostname="dup",
|
|
3040
|
+
address="10.0.0.4",
|
|
3041
|
+
port=8003,
|
|
3042
|
+
mac_address="00:11:22:33:44:13",
|
|
3043
|
+
role=self.role,
|
|
3044
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
2695
3045
|
)
|
|
3046
|
+
message = NetMessage.objects.create(subject="Dup", body="Dup", reach=self.role)
|
|
3047
|
+
first = timezone.now()
|
|
3048
|
+
second = first + timedelta(minutes=5)
|
|
3049
|
+
with patch(
|
|
3050
|
+
"nodes.models.timezone.now", side_effect=[first, second, second]
|
|
3051
|
+
):
|
|
3052
|
+
message.queue_for_node(downstream, ["first"])
|
|
3053
|
+
message.queue_for_node(downstream, ["second"])
|
|
3054
|
+
|
|
3055
|
+
entry = PendingNetMessage.objects.get(node=downstream, message=message)
|
|
3056
|
+
self.assertEqual(entry.seen, ["second"])
|
|
3057
|
+
self.assertEqual(entry.queued_at, second)
|
|
3058
|
+
self.assertEqual(entry.stale_at, second + timedelta(hours=1))
|
|
3059
|
+
|
|
3060
|
+
def test_pull_endpoint_returns_and_clears_messages(self):
|
|
3061
|
+
local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3062
|
+
downstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3063
|
+
local = Node.objects.create(
|
|
3064
|
+
hostname="hub",
|
|
3065
|
+
address="10.0.0.5",
|
|
3066
|
+
port=8004,
|
|
3067
|
+
mac_address="00:11:22:33:44:14",
|
|
3068
|
+
role=self.role,
|
|
3069
|
+
public_endpoint="hub",
|
|
3070
|
+
)
|
|
3071
|
+
downstream = Node.objects.create(
|
|
3072
|
+
hostname="remote",
|
|
3073
|
+
address="10.0.0.6",
|
|
3074
|
+
port=8005,
|
|
3075
|
+
mac_address="00:11:22:33:44:15",
|
|
3076
|
+
role=self.role,
|
|
3077
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3078
|
+
public_key=downstream_key.public_key()
|
|
3079
|
+
.public_bytes(
|
|
3080
|
+
serialization.Encoding.PEM,
|
|
3081
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
3082
|
+
)
|
|
3083
|
+
.decode(),
|
|
3084
|
+
)
|
|
3085
|
+
message = NetMessage.objects.create(subject="Fresh", body="Body", reach=self.role)
|
|
3086
|
+
stale_message = NetMessage.objects.create(subject="Stale", body="Body", reach=self.role)
|
|
3087
|
+
now = timezone.now()
|
|
3088
|
+
PendingNetMessage.objects.create(
|
|
3089
|
+
node=downstream,
|
|
3090
|
+
message=message,
|
|
3091
|
+
seen=[str(downstream.uuid)],
|
|
3092
|
+
stale_at=now + timedelta(minutes=30),
|
|
3093
|
+
)
|
|
3094
|
+
stale_entry = PendingNetMessage.objects.create(
|
|
3095
|
+
node=downstream,
|
|
3096
|
+
message=stale_message,
|
|
3097
|
+
seen=["stale"],
|
|
3098
|
+
stale_at=now - timedelta(minutes=5),
|
|
3099
|
+
)
|
|
3100
|
+
PendingNetMessage.objects.filter(pk=stale_entry.pk).update(
|
|
3101
|
+
queued_at=now - timedelta(minutes=5)
|
|
3102
|
+
)
|
|
3103
|
+
|
|
3104
|
+
def fake_get_private(node_obj):
|
|
3105
|
+
if node_obj.pk == local.pk:
|
|
3106
|
+
return local_key
|
|
3107
|
+
return None
|
|
3108
|
+
|
|
3109
|
+
payload = {"requester": str(downstream.uuid)}
|
|
3110
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
3111
|
+
signature = base64.b64encode(
|
|
3112
|
+
downstream_key.sign(
|
|
3113
|
+
body.encode(),
|
|
3114
|
+
padding.PKCS1v15(),
|
|
3115
|
+
hashes.SHA256(),
|
|
3116
|
+
)
|
|
3117
|
+
).decode()
|
|
3118
|
+
|
|
3119
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
3120
|
+
Node, "get_private_key", return_value=local_key
|
|
3121
|
+
):
|
|
3122
|
+
response = self.client.post(
|
|
3123
|
+
reverse("net-message-pull"),
|
|
3124
|
+
data=body,
|
|
3125
|
+
content_type="application/json",
|
|
3126
|
+
HTTP_X_SIGNATURE=signature,
|
|
3127
|
+
)
|
|
3128
|
+
|
|
3129
|
+
self.assertEqual(response.status_code, 200)
|
|
3130
|
+
data = response.json()
|
|
3131
|
+
self.assertEqual(len(data.get("messages", [])), 1)
|
|
3132
|
+
payload_data = data["messages"][0]["payload"]
|
|
3133
|
+
self.assertEqual(payload_data["uuid"], str(message.uuid))
|
|
3134
|
+
self.assertFalse(
|
|
3135
|
+
PendingNetMessage.objects.filter(node=downstream, message=message).exists()
|
|
3136
|
+
)
|
|
3137
|
+
self.assertFalse(
|
|
3138
|
+
PendingNetMessage.objects.filter(
|
|
3139
|
+
node=downstream, message=stale_message
|
|
3140
|
+
).exists()
|
|
3141
|
+
)
|
|
3142
|
+
response_signature = data["messages"][0]["signature"]
|
|
3143
|
+
local_public = local_key.public_key()
|
|
3144
|
+
local_public.verify(
|
|
3145
|
+
base64.b64decode(response_signature),
|
|
3146
|
+
json.dumps(payload_data, separators=(",", ":"), sort_keys=True).encode(),
|
|
3147
|
+
padding.PKCS1v15(),
|
|
3148
|
+
hashes.SHA256(),
|
|
3149
|
+
)
|
|
3150
|
+
|
|
3151
|
+
def test_poll_task_fetches_messages(self):
|
|
3152
|
+
local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3153
|
+
upstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3154
|
+
local = Node.objects.create(
|
|
3155
|
+
hostname="downstream",
|
|
3156
|
+
address="10.0.0.7",
|
|
3157
|
+
port=8006,
|
|
3158
|
+
mac_address="00:11:22:33:44:16",
|
|
3159
|
+
role=self.role,
|
|
3160
|
+
public_endpoint="downstream",
|
|
3161
|
+
)
|
|
3162
|
+
upstream = Node.objects.create(
|
|
3163
|
+
hostname="upstream",
|
|
3164
|
+
address="127.0.0.2",
|
|
3165
|
+
port=8010,
|
|
3166
|
+
mac_address="00:11:22:33:44:17",
|
|
3167
|
+
role=self.role,
|
|
3168
|
+
current_relation=Node.Relation.UPSTREAM,
|
|
3169
|
+
public_key=upstream_key.public_key()
|
|
3170
|
+
.public_bytes(
|
|
3171
|
+
serialization.Encoding.PEM,
|
|
3172
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
3173
|
+
)
|
|
3174
|
+
.decode(),
|
|
3175
|
+
)
|
|
3176
|
+
NodeFeatureAssignment.objects.create(node=local, feature=self.feature)
|
|
3177
|
+
payload = {
|
|
3178
|
+
"uuid": str(uuid.uuid4()),
|
|
3179
|
+
"subject": "Update",
|
|
3180
|
+
"body": "Body",
|
|
3181
|
+
"seen": [str(local.uuid)],
|
|
3182
|
+
"origin": str(upstream.uuid),
|
|
3183
|
+
"sender": str(upstream.uuid),
|
|
3184
|
+
}
|
|
3185
|
+
payload_text = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
3186
|
+
payload_signature = base64.b64encode(
|
|
3187
|
+
upstream_key.sign(
|
|
3188
|
+
payload_text.encode(),
|
|
3189
|
+
padding.PKCS1v15(),
|
|
3190
|
+
hashes.SHA256(),
|
|
3191
|
+
)
|
|
3192
|
+
).decode()
|
|
3193
|
+
response = MagicMock()
|
|
3194
|
+
response.ok = True
|
|
3195
|
+
response.json.return_value = {
|
|
3196
|
+
"messages": [{"payload": payload, "signature": payload_signature}]
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
3200
|
+
Node, "get_private_key", return_value=local_key
|
|
3201
|
+
), patch("nodes.tasks.requests.post", return_value=response) as mock_post, patch.object(
|
|
3202
|
+
NetMessage, "propagate"
|
|
3203
|
+
) as mock_propagate:
|
|
3204
|
+
poll_unreachable_upstream()
|
|
3205
|
+
|
|
3206
|
+
created = NetMessage.objects.get(uuid=payload["uuid"])
|
|
3207
|
+
self.assertEqual(created.subject, "Update")
|
|
3208
|
+
self.assertEqual(created.node_origin, upstream)
|
|
3209
|
+
mock_post.assert_called_once()
|
|
3210
|
+
mock_propagate.assert_called_once()
|
|
2696
3211
|
|
|
2697
3212
|
|
|
2698
3213
|
class NetMessageSignatureTests(TestCase):
|
|
@@ -2930,6 +3445,7 @@ class ContentSampleTransactionTests(TestCase):
|
|
|
2930
3445
|
sample1.save()
|
|
2931
3446
|
|
|
2932
3447
|
|
|
3448
|
+
@pytest.mark.feature("clipboard-poll")
|
|
2933
3449
|
class ContentSampleAdminTests(TestCase):
|
|
2934
3450
|
def setUp(self):
|
|
2935
3451
|
User = get_user_model()
|
|
@@ -3106,6 +3622,7 @@ class EmailOutboxTests(TestCase):
|
|
|
3106
3622
|
|
|
3107
3623
|
|
|
3108
3624
|
class ClipboardTaskTests(TestCase):
|
|
3625
|
+
@pytest.mark.feature("clipboard-poll")
|
|
3109
3626
|
@patch("nodes.tasks.pyperclip.paste")
|
|
3110
3627
|
def test_sample_clipboard_task_creates_sample(self, mock_paste):
|
|
3111
3628
|
mock_paste.return_value = "task text"
|
|
@@ -3130,6 +3647,7 @@ class ClipboardTaskTests(TestCase):
|
|
|
3130
3647
|
ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
|
|
3131
3648
|
)
|
|
3132
3649
|
|
|
3650
|
+
@pytest.mark.feature("screenshot-poll")
|
|
3133
3651
|
@patch("nodes.tasks.capture_screenshot")
|
|
3134
3652
|
def test_capture_node_screenshot_task(self, mock_capture):
|
|
3135
3653
|
node = Node.objects.create(
|
|
@@ -3152,6 +3670,7 @@ class ClipboardTaskTests(TestCase):
|
|
|
3152
3670
|
self.assertEqual(screenshot.path, "screenshots/test.png")
|
|
3153
3671
|
self.assertEqual(screenshot.method, "TASK")
|
|
3154
3672
|
|
|
3673
|
+
@pytest.mark.feature("screenshot-poll")
|
|
3155
3674
|
@patch("nodes.tasks.capture_screenshot")
|
|
3156
3675
|
def test_capture_node_screenshot_handles_error(self, mock_capture):
|
|
3157
3676
|
Node.objects.create(
|
|
@@ -3231,7 +3750,7 @@ class NodeRoleAdminTests(TestCase):
|
|
|
3231
3750
|
|
|
3232
3751
|
class NodeFeatureFixtureTests(TestCase):
|
|
3233
3752
|
def test_rfid_scanner_fixture_includes_control_role(self):
|
|
3234
|
-
for name in ("Terminal", "Satellite", "
|
|
3753
|
+
for name in ("Terminal", "Satellite", "Watchtower", "Control"):
|
|
3235
3754
|
NodeRole.objects.get_or_create(name=name)
|
|
3236
3755
|
fixture_path = (
|
|
3237
3756
|
Path(__file__).resolve().parent
|
|
@@ -3243,6 +3762,7 @@ class NodeFeatureFixtureTests(TestCase):
|
|
|
3243
3762
|
role_names = set(feature.roles.values_list("name", flat=True))
|
|
3244
3763
|
self.assertIn("Control", role_names)
|
|
3245
3764
|
|
|
3765
|
+
@pytest.mark.feature("ap-router")
|
|
3246
3766
|
def test_ap_router_fixture_limits_roles(self):
|
|
3247
3767
|
for name in ("Control", "Satellite"):
|
|
3248
3768
|
NodeRole.objects.get_or_create(name=name)
|
|
@@ -3293,6 +3813,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3293
3813
|
self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
|
|
3294
3814
|
self.assertEqual(feature.get_default_action(), action)
|
|
3295
3815
|
|
|
3816
|
+
@pytest.mark.feature("rpi-camera")
|
|
3296
3817
|
def test_rpi_camera_feature_has_multiple_actions(self):
|
|
3297
3818
|
feature = NodeFeature.objects.create(
|
|
3298
3819
|
slug="rpi-camera", display="Raspberry Pi Camera"
|
|
@@ -3303,6 +3824,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3303
3824
|
self.assertIn("Take a Snapshot", labels)
|
|
3304
3825
|
self.assertIn("View stream", labels)
|
|
3305
3826
|
|
|
3827
|
+
@pytest.mark.feature("audio-capture")
|
|
3306
3828
|
def test_audio_capture_feature_has_view_waveform_action(self):
|
|
3307
3829
|
feature = NodeFeature.objects.create(
|
|
3308
3830
|
slug="audio-capture", display="Audio Capture"
|
|
@@ -3480,6 +4002,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3480
4002
|
)
|
|
3481
4003
|
self.assertEqual(mock_find_command.call_count, 2)
|
|
3482
4004
|
|
|
4005
|
+
@pytest.mark.feature("ap-router")
|
|
3483
4006
|
@patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
|
|
3484
4007
|
def test_ap_router_detection(self, mock_hosts):
|
|
3485
4008
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
@@ -3499,6 +4022,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3499
4022
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
|
|
3500
4023
|
)
|
|
3501
4024
|
|
|
4025
|
+
@pytest.mark.feature("ap-router")
|
|
3502
4026
|
@patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
|
|
3503
4027
|
def test_ap_router_detection_with_public_mode_lock(self, mock_hosts):
|
|
3504
4028
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
@@ -3523,6 +4047,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3523
4047
|
NodeFeatureAssignment.objects.filter(node=node, feature=router).exists()
|
|
3524
4048
|
)
|
|
3525
4049
|
|
|
4050
|
+
@pytest.mark.feature("ap-router")
|
|
3526
4051
|
@patch("nodes.models.Node._hosts_gelectriic_ap", side_effect=[True, False])
|
|
3527
4052
|
def test_ap_router_removed_when_not_hosting(self, mock_hosts):
|
|
3528
4053
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|