arthexis 0.1.19__py3-none-any.whl → 0.1.21__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.19.dist-info → arthexis-0.1.21.dist-info}/METADATA +5 -6
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/RECORD +42 -44
- config/asgi.py +1 -15
- config/settings.py +0 -26
- config/urls.py +0 -1
- core/admin.py +143 -234
- core/apps.py +0 -6
- core/backends.py +8 -2
- core/environment.py +240 -4
- core/models.py +132 -102
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/sigil_builder.py +2 -2
- core/tasks.py +24 -1
- core/tests.py +2 -7
- core/views.py +70 -132
- nodes/admin.py +162 -7
- nodes/models.py +294 -48
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +581 -15
- nodes/urls.py +4 -0
- nodes/views.py +560 -96
- ocpp/admin.py +144 -4
- ocpp/consumers.py +106 -9
- ocpp/models.py +131 -1
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +183 -9
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +186 -31
- pages/context_processors.py +15 -21
- pages/defaults.py +1 -1
- pages/module_defaults.py +5 -5
- pages/tests.py +110 -79
- pages/urls.py +1 -1
- pages/views.py +108 -13
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/WHEEL +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.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,27 @@ 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")
|
|
225
250
|
|
|
226
251
|
def test_register_current_respects_node_hostname_env(self):
|
|
227
252
|
with TemporaryDirectory() as tmp:
|
|
@@ -349,6 +374,43 @@ class NodeGetLocalTests(TestCase):
|
|
|
349
374
|
self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
|
|
350
375
|
self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
|
|
351
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
|
+
|
|
352
414
|
def test_register_node_feature_toggle(self):
|
|
353
415
|
NodeFeature.objects.get_or_create(
|
|
354
416
|
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
@@ -680,6 +742,55 @@ class NodeGetLocalTests(TestCase):
|
|
|
680
742
|
self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
|
|
681
743
|
|
|
682
744
|
|
|
745
|
+
class NodeInfoViewTests(TestCase):
|
|
746
|
+
def setUp(self):
|
|
747
|
+
self.mac = "02:00:00:00:00:01"
|
|
748
|
+
self.patcher = patch("nodes.models.Node.get_current_mac", return_value=self.mac)
|
|
749
|
+
self.patcher.start()
|
|
750
|
+
self.addCleanup(self.patcher.stop)
|
|
751
|
+
self.node = Node.objects.create(
|
|
752
|
+
hostname="local",
|
|
753
|
+
address="10.0.0.10",
|
|
754
|
+
port=8000,
|
|
755
|
+
mac_address=self.mac,
|
|
756
|
+
public_endpoint="local",
|
|
757
|
+
current_relation=Node.Relation.SELF,
|
|
758
|
+
)
|
|
759
|
+
self.url = reverse("node-info")
|
|
760
|
+
|
|
761
|
+
def test_returns_https_port_for_secure_domain_request(self):
|
|
762
|
+
with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
|
|
763
|
+
response = self.client.get(
|
|
764
|
+
self.url,
|
|
765
|
+
secure=True,
|
|
766
|
+
HTTP_HOST="arthexis.com",
|
|
767
|
+
)
|
|
768
|
+
self.assertEqual(response.status_code, 200)
|
|
769
|
+
payload = response.json()
|
|
770
|
+
self.assertEqual(payload["port"], 443)
|
|
771
|
+
|
|
772
|
+
def test_returns_http_port_for_plain_domain_request(self):
|
|
773
|
+
with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
|
|
774
|
+
response = self.client.get(
|
|
775
|
+
self.url,
|
|
776
|
+
HTTP_HOST="arthexis.com",
|
|
777
|
+
)
|
|
778
|
+
self.assertEqual(response.status_code, 200)
|
|
779
|
+
payload = response.json()
|
|
780
|
+
self.assertEqual(payload["port"], 80)
|
|
781
|
+
|
|
782
|
+
def test_preserves_explicit_port_in_host_header(self):
|
|
783
|
+
with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
|
|
784
|
+
response = self.client.get(
|
|
785
|
+
self.url,
|
|
786
|
+
secure=True,
|
|
787
|
+
HTTP_HOST="arthexis.com:8443",
|
|
788
|
+
)
|
|
789
|
+
self.assertEqual(response.status_code, 200)
|
|
790
|
+
payload = response.json()
|
|
791
|
+
self.assertEqual(payload["port"], 8443)
|
|
792
|
+
|
|
793
|
+
|
|
683
794
|
class RegisterVisitorNodeMessageTests(TestCase):
|
|
684
795
|
def setUp(self):
|
|
685
796
|
self.client = Client()
|
|
@@ -1276,6 +1387,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1276
1387
|
existing_role.refresh_from_db()
|
|
1277
1388
|
self.assertEqual(existing_role.description, "updated via attachment")
|
|
1278
1389
|
|
|
1390
|
+
@pytest.mark.feature("clipboard-poll")
|
|
1279
1391
|
def test_clipboard_polling_creates_task(self):
|
|
1280
1392
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
1281
1393
|
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
@@ -1293,6 +1405,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1293
1405
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
|
|
1294
1406
|
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
1295
1407
|
|
|
1408
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1296
1409
|
def test_screenshot_polling_creates_task(self):
|
|
1297
1410
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
1298
1411
|
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
@@ -1421,6 +1534,7 @@ class NodeAdminTests(TestCase):
|
|
|
1421
1534
|
action_url = reverse("admin:core_rfid_scan")
|
|
1422
1535
|
self.assertContains(response, f'href="{action_url}"')
|
|
1423
1536
|
|
|
1537
|
+
@pytest.mark.feature("rpi-camera")
|
|
1424
1538
|
def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
|
|
1425
1539
|
node = self._create_local_node()
|
|
1426
1540
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1433,6 +1547,7 @@ class NodeAdminTests(TestCase):
|
|
|
1433
1547
|
self.assertContains(response, f'href="{snapshot_url}"')
|
|
1434
1548
|
self.assertContains(response, f'href="{stream_url}"')
|
|
1435
1549
|
|
|
1550
|
+
@pytest.mark.feature("audio-capture")
|
|
1436
1551
|
def test_node_feature_list_shows_waveform_action_when_enabled(self):
|
|
1437
1552
|
node = self._create_local_node()
|
|
1438
1553
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1443,6 +1558,7 @@ class NodeAdminTests(TestCase):
|
|
|
1443
1558
|
action_url = reverse("admin:nodes_nodefeature_view_waveform")
|
|
1444
1559
|
self.assertContains(response, f'href="{action_url}"')
|
|
1445
1560
|
|
|
1561
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1446
1562
|
def test_node_feature_list_hides_default_action_when_disabled(self):
|
|
1447
1563
|
self._create_local_node()
|
|
1448
1564
|
NodeFeature.objects.get_or_create(
|
|
@@ -1535,6 +1651,7 @@ class NodeAdminTests(TestCase):
|
|
|
1535
1651
|
response, reverse("admin:nodes_node_register_current")
|
|
1536
1652
|
)
|
|
1537
1653
|
|
|
1654
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1538
1655
|
@patch("nodes.admin.capture_screenshot")
|
|
1539
1656
|
def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
|
|
1540
1657
|
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
@@ -1580,6 +1697,48 @@ class NodeAdminTests(TestCase):
|
|
|
1580
1697
|
self.assertEqual(response.status_code, 200)
|
|
1581
1698
|
self.assertContains(response, "data:image/png;base64")
|
|
1582
1699
|
|
|
1700
|
+
@patch("nodes.admin.requests.post")
|
|
1701
|
+
def test_proxy_view_uses_remote_login_url(self, mock_post):
|
|
1702
|
+
self.client.get(reverse("admin:nodes_node_register_current"))
|
|
1703
|
+
local_node = Node.objects.get()
|
|
1704
|
+
remote = Node.objects.create(
|
|
1705
|
+
hostname="remote",
|
|
1706
|
+
address="192.0.2.10",
|
|
1707
|
+
port=8443,
|
|
1708
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
1709
|
+
)
|
|
1710
|
+
mock_post.return_value = SimpleNamespace(
|
|
1711
|
+
ok=True,
|
|
1712
|
+
json=lambda: {
|
|
1713
|
+
"login_url": "https://remote.example/nodes/proxy/login/token",
|
|
1714
|
+
"expires": "2025-01-01T00:00:00",
|
|
1715
|
+
},
|
|
1716
|
+
status_code=200,
|
|
1717
|
+
text="ok",
|
|
1718
|
+
)
|
|
1719
|
+
response = self.client.get(
|
|
1720
|
+
reverse("admin:nodes_node_proxy", args=[remote.pk])
|
|
1721
|
+
)
|
|
1722
|
+
self.assertEqual(response.status_code, 200)
|
|
1723
|
+
self.assertTemplateUsed(response, "admin/nodes/node/proxy.html")
|
|
1724
|
+
self.assertContains(response, "<iframe", html=False)
|
|
1725
|
+
mock_post.assert_called()
|
|
1726
|
+
payload = json.loads(mock_post.call_args[1]["data"])
|
|
1727
|
+
self.assertEqual(payload.get("requester"), str(local_node.uuid))
|
|
1728
|
+
|
|
1729
|
+
def test_proxy_link_displayed_for_remote_nodes(self):
|
|
1730
|
+
Node.objects.create(
|
|
1731
|
+
hostname="remote",
|
|
1732
|
+
address="203.0.113.1",
|
|
1733
|
+
port=8000,
|
|
1734
|
+
mac_address="aa:aa:aa:aa:aa:01",
|
|
1735
|
+
)
|
|
1736
|
+
response = self.client.get(reverse("admin:nodes_node_changelist"))
|
|
1737
|
+
proxy_url = reverse("admin:nodes_node_proxy", args=[1])
|
|
1738
|
+
self.assertContains(response, proxy_url)
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1583
1742
|
@override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
|
|
1584
1743
|
@patch("nodes.admin.capture_screenshot")
|
|
1585
1744
|
def test_take_screenshots_action(self, mock_capture):
|
|
@@ -1612,6 +1771,7 @@ class NodeAdminTests(TestCase):
|
|
|
1612
1771
|
samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
|
|
1613
1772
|
self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
|
|
1614
1773
|
|
|
1774
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1615
1775
|
@patch("nodes.admin.capture_screenshot")
|
|
1616
1776
|
def test_take_screenshot_default_action_creates_sample(
|
|
1617
1777
|
self, mock_capture_screenshot
|
|
@@ -1686,6 +1846,7 @@ class NodeAdminTests(TestCase):
|
|
|
1686
1846
|
response, "Completed 0 of 1 feature check(s) successfully.", html=False
|
|
1687
1847
|
)
|
|
1688
1848
|
|
|
1849
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1689
1850
|
def test_enable_selected_features_enables_manual_feature(self):
|
|
1690
1851
|
node = self._create_local_node()
|
|
1691
1852
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1730,6 +1891,7 @@ class NodeAdminTests(TestCase):
|
|
|
1730
1891
|
html=False,
|
|
1731
1892
|
)
|
|
1732
1893
|
|
|
1894
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1733
1895
|
def test_take_screenshot_default_action_requires_enabled_feature(self):
|
|
1734
1896
|
self._create_local_node()
|
|
1735
1897
|
NodeFeature.objects.get_or_create(
|
|
@@ -1744,6 +1906,7 @@ class NodeAdminTests(TestCase):
|
|
|
1744
1906
|
self.assertEqual(ContentSample.objects.count(), 0)
|
|
1745
1907
|
self.assertContains(response, "Screenshot Poll feature is not enabled")
|
|
1746
1908
|
|
|
1909
|
+
@pytest.mark.feature("rpi-camera")
|
|
1747
1910
|
@patch("nodes.admin.capture_rpi_snapshot")
|
|
1748
1911
|
def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
|
|
1749
1912
|
node = self._create_local_node()
|
|
@@ -1766,6 +1929,7 @@ class NodeAdminTests(TestCase):
|
|
|
1766
1929
|
change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
|
|
1767
1930
|
self.assertEqual(response.redirect_chain[-1][0], change_url)
|
|
1768
1931
|
|
|
1932
|
+
@pytest.mark.feature("rpi-camera")
|
|
1769
1933
|
def test_view_stream_requires_enabled_feature(self):
|
|
1770
1934
|
self._create_local_node()
|
|
1771
1935
|
NodeFeature.objects.get_or_create(
|
|
@@ -1781,6 +1945,7 @@ class NodeAdminTests(TestCase):
|
|
|
1781
1945
|
response, "Raspberry Pi Camera feature is not enabled on this node."
|
|
1782
1946
|
)
|
|
1783
1947
|
|
|
1948
|
+
@pytest.mark.feature("rpi-camera")
|
|
1784
1949
|
def test_view_stream_renders_when_feature_enabled(self):
|
|
1785
1950
|
node = self._create_local_node()
|
|
1786
1951
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1796,6 +1961,7 @@ class NodeAdminTests(TestCase):
|
|
|
1796
1961
|
self.assertContains(response, expected_stream)
|
|
1797
1962
|
self.assertContains(response, "camera-stream__frame")
|
|
1798
1963
|
|
|
1964
|
+
@pytest.mark.feature("rpi-camera")
|
|
1799
1965
|
def test_view_stream_uses_configured_stream_url(self):
|
|
1800
1966
|
node = self._create_local_node()
|
|
1801
1967
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1813,6 +1979,7 @@ class NodeAdminTests(TestCase):
|
|
|
1813
1979
|
self.assertEqual(response.context_data["stream_embed"], "iframe")
|
|
1814
1980
|
self.assertContains(response, configured_stream)
|
|
1815
1981
|
|
|
1982
|
+
@pytest.mark.feature("rpi-camera")
|
|
1816
1983
|
def test_view_stream_detects_mjpeg_stream(self):
|
|
1817
1984
|
node = self._create_local_node()
|
|
1818
1985
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1829,6 +1996,7 @@ class NodeAdminTests(TestCase):
|
|
|
1829
1996
|
self.assertEqual(response.context_data["stream_embed"], "mjpeg")
|
|
1830
1997
|
self.assertContains(response, "<img", html=False)
|
|
1831
1998
|
|
|
1999
|
+
@pytest.mark.feature("rpi-camera")
|
|
1832
2000
|
def test_view_stream_marks_rtsp_stream_as_unsupported(self):
|
|
1833
2001
|
node = self._create_local_node()
|
|
1834
2002
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1845,6 +2013,7 @@ class NodeAdminTests(TestCase):
|
|
|
1845
2013
|
self.assertEqual(response.context_data["stream_embed"], "unsupported")
|
|
1846
2014
|
self.assertContains(response, "camera-stream__unsupported")
|
|
1847
2015
|
|
|
2016
|
+
@pytest.mark.feature("audio-capture")
|
|
1848
2017
|
def test_view_waveform_requires_enabled_feature(self):
|
|
1849
2018
|
self._create_local_node()
|
|
1850
2019
|
NodeFeature.objects.get_or_create(
|
|
@@ -1860,6 +2029,7 @@ class NodeAdminTests(TestCase):
|
|
|
1860
2029
|
response, "Audio Capture feature is not enabled on this node."
|
|
1861
2030
|
)
|
|
1862
2031
|
|
|
2032
|
+
@pytest.mark.feature("audio-capture")
|
|
1863
2033
|
def test_view_waveform_renders_when_feature_enabled(self):
|
|
1864
2034
|
node = self._create_local_node()
|
|
1865
2035
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -2182,6 +2352,160 @@ class NodeAdminTests(TestCase):
|
|
|
2182
2352
|
self.assertEqual(post_data["mac_address"], local.mac_address)
|
|
2183
2353
|
|
|
2184
2354
|
|
|
2355
|
+
class NodeProxyGatewayTests(TestCase):
|
|
2356
|
+
def setUp(self):
|
|
2357
|
+
cache.clear()
|
|
2358
|
+
self.client = Client()
|
|
2359
|
+
self.private_key = rsa.generate_private_key(
|
|
2360
|
+
public_exponent=65537, key_size=2048
|
|
2361
|
+
)
|
|
2362
|
+
public_key = self.private_key.public_key().public_bytes(
|
|
2363
|
+
encoding=serialization.Encoding.PEM,
|
|
2364
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
2365
|
+
).decode()
|
|
2366
|
+
self.node = Node.objects.create(
|
|
2367
|
+
hostname="requester",
|
|
2368
|
+
address="127.0.0.1",
|
|
2369
|
+
port=8000,
|
|
2370
|
+
mac_address="aa:bb:cc:dd:ee:aa",
|
|
2371
|
+
public_key=public_key,
|
|
2372
|
+
)
|
|
2373
|
+
patcher = patch("requests.post")
|
|
2374
|
+
self.addCleanup(patcher.stop)
|
|
2375
|
+
self.mock_requests_post = patcher.start()
|
|
2376
|
+
self.mock_requests_post.return_value = SimpleNamespace(
|
|
2377
|
+
ok=True,
|
|
2378
|
+
status_code=200,
|
|
2379
|
+
json=lambda: {},
|
|
2380
|
+
text="",
|
|
2381
|
+
)
|
|
2382
|
+
|
|
2383
|
+
def tearDown(self):
|
|
2384
|
+
cache.clear()
|
|
2385
|
+
|
|
2386
|
+
def _sign(self, payload):
|
|
2387
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
2388
|
+
signature = base64.b64encode(
|
|
2389
|
+
self.private_key.sign(
|
|
2390
|
+
body.encode(), padding.PKCS1v15(), hashes.SHA256()
|
|
2391
|
+
)
|
|
2392
|
+
).decode()
|
|
2393
|
+
return body, signature
|
|
2394
|
+
|
|
2395
|
+
def test_proxy_session_creates_login_url(self):
|
|
2396
|
+
payload = {
|
|
2397
|
+
"requester": str(self.node.uuid),
|
|
2398
|
+
"user": {
|
|
2399
|
+
"username": "proxy-user",
|
|
2400
|
+
"email": "proxy@example.com",
|
|
2401
|
+
"first_name": "Proxy",
|
|
2402
|
+
"last_name": "User",
|
|
2403
|
+
"is_staff": True,
|
|
2404
|
+
"is_superuser": True,
|
|
2405
|
+
"groups": [],
|
|
2406
|
+
"permissions": [],
|
|
2407
|
+
},
|
|
2408
|
+
"target": "/admin/",
|
|
2409
|
+
}
|
|
2410
|
+
body, signature = self._sign(payload)
|
|
2411
|
+
response = self.client.post(
|
|
2412
|
+
reverse("node-proxy-session"),
|
|
2413
|
+
data=body,
|
|
2414
|
+
content_type="application/json",
|
|
2415
|
+
HTTP_X_SIGNATURE=signature,
|
|
2416
|
+
)
|
|
2417
|
+
self.assertEqual(response.status_code, 200)
|
|
2418
|
+
data = response.json()
|
|
2419
|
+
self.assertIn("login_url", data)
|
|
2420
|
+
user = get_user_model().objects.get(username="proxy-user")
|
|
2421
|
+
self.assertTrue(user.is_staff)
|
|
2422
|
+
parsed = urlparse(data["login_url"])
|
|
2423
|
+
login_response = self.client.get(parsed.path)
|
|
2424
|
+
self.assertEqual(login_response.status_code, 302)
|
|
2425
|
+
self.assertEqual(login_response["Location"], "/admin/")
|
|
2426
|
+
self.assertEqual(self.client.session.get("_auth_user_id"), str(user.pk))
|
|
2427
|
+
second = self.client.get(parsed.path)
|
|
2428
|
+
self.assertEqual(second.status_code, 410)
|
|
2429
|
+
|
|
2430
|
+
def test_proxy_execute_lists_nodes(self):
|
|
2431
|
+
Node.objects.create(
|
|
2432
|
+
hostname="target",
|
|
2433
|
+
address="127.0.0.5",
|
|
2434
|
+
port=8010,
|
|
2435
|
+
mac_address="aa:bb:cc:dd:ee:bb",
|
|
2436
|
+
)
|
|
2437
|
+
payload = {
|
|
2438
|
+
"requester": str(self.node.uuid),
|
|
2439
|
+
"action": "list",
|
|
2440
|
+
"model": "nodes.Node",
|
|
2441
|
+
"filters": {"hostname": "target"},
|
|
2442
|
+
"credentials": {
|
|
2443
|
+
"username": "suite-user",
|
|
2444
|
+
"password": "secret",
|
|
2445
|
+
"first_name": "Suite",
|
|
2446
|
+
"last_name": "User",
|
|
2447
|
+
},
|
|
2448
|
+
}
|
|
2449
|
+
body, signature = self._sign(payload)
|
|
2450
|
+
response = self.client.post(
|
|
2451
|
+
reverse("node-proxy-execute"),
|
|
2452
|
+
data=body,
|
|
2453
|
+
content_type="application/json",
|
|
2454
|
+
HTTP_X_SIGNATURE=signature,
|
|
2455
|
+
)
|
|
2456
|
+
self.assertEqual(response.status_code, 200)
|
|
2457
|
+
data = response.json()
|
|
2458
|
+
self.assertEqual(len(data.get("objects", [])), 1)
|
|
2459
|
+
record = data["objects"][0]
|
|
2460
|
+
self.assertEqual(record["fields"]["hostname"], "target")
|
|
2461
|
+
user = get_user_model().objects.get(username="suite-user")
|
|
2462
|
+
self.assertTrue(user.is_superuser)
|
|
2463
|
+
|
|
2464
|
+
def test_proxy_execute_requires_valid_password_for_existing_user(self):
|
|
2465
|
+
User = get_user_model()
|
|
2466
|
+
User.objects.create_user(username="suite-user", password="correct")
|
|
2467
|
+
payload = {
|
|
2468
|
+
"requester": str(self.node.uuid),
|
|
2469
|
+
"action": "list",
|
|
2470
|
+
"model": "nodes.Node",
|
|
2471
|
+
"credentials": {
|
|
2472
|
+
"username": "suite-user",
|
|
2473
|
+
"password": "wrong",
|
|
2474
|
+
},
|
|
2475
|
+
}
|
|
2476
|
+
body, signature = self._sign(payload)
|
|
2477
|
+
response = self.client.post(
|
|
2478
|
+
reverse("node-proxy-execute"),
|
|
2479
|
+
data=body,
|
|
2480
|
+
content_type="application/json",
|
|
2481
|
+
HTTP_X_SIGNATURE=signature,
|
|
2482
|
+
)
|
|
2483
|
+
self.assertEqual(response.status_code, 403)
|
|
2484
|
+
|
|
2485
|
+
def test_proxy_execute_schema_returns_models(self):
|
|
2486
|
+
payload = {
|
|
2487
|
+
"requester": str(self.node.uuid),
|
|
2488
|
+
"action": "schema",
|
|
2489
|
+
"credentials": {
|
|
2490
|
+
"username": "suite-user",
|
|
2491
|
+
"password": "secret",
|
|
2492
|
+
},
|
|
2493
|
+
}
|
|
2494
|
+
body, signature = self._sign(payload)
|
|
2495
|
+
response = self.client.post(
|
|
2496
|
+
reverse("node-proxy-execute"),
|
|
2497
|
+
data=body,
|
|
2498
|
+
content_type="application/json",
|
|
2499
|
+
HTTP_X_SIGNATURE=signature,
|
|
2500
|
+
)
|
|
2501
|
+
self.assertEqual(response.status_code, 200)
|
|
2502
|
+
data = response.json()
|
|
2503
|
+
models = data.get("models", [])
|
|
2504
|
+
self.assertTrue(models)
|
|
2505
|
+
suite_names = {entry.get("suite_name") for entry in models}
|
|
2506
|
+
self.assertIn("Nodes", suite_names)
|
|
2507
|
+
|
|
2508
|
+
|
|
2185
2509
|
class NodeRFIDAPITests(TestCase):
|
|
2186
2510
|
def test_import_endpoint_applies_payload_without_creating_accounts(self):
|
|
2187
2511
|
remote = Node.objects.create(
|
|
@@ -2358,11 +2682,11 @@ class NetMessageAdminTests(TransactionTestCase):
|
|
|
2358
2682
|
class NetMessageReachTests(TestCase):
|
|
2359
2683
|
def setUp(self):
|
|
2360
2684
|
self.roles = {}
|
|
2361
|
-
for name in ["Terminal", "Control", "Satellite", "
|
|
2685
|
+
for name in ["Terminal", "Control", "Satellite", "Watchtower"]:
|
|
2362
2686
|
self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
|
|
2363
2687
|
self.nodes = {}
|
|
2364
2688
|
for idx, name in enumerate(
|
|
2365
|
-
["Terminal", "Control", "Satellite", "
|
|
2689
|
+
["Terminal", "Control", "Satellite", "Watchtower"], start=1
|
|
2366
2690
|
):
|
|
2367
2691
|
self.nodes[name] = Node.objects.create(
|
|
2368
2692
|
hostname=name.lower(),
|
|
@@ -2406,15 +2730,15 @@ class NetMessageReachTests(TestCase):
|
|
|
2406
2730
|
self.assertEqual(mock_post.call_count, 3)
|
|
2407
2731
|
|
|
2408
2732
|
@patch("requests.post")
|
|
2409
|
-
def
|
|
2733
|
+
def test_watchtower_reach_prioritizes_watchtower(self, mock_post):
|
|
2410
2734
|
msg = NetMessage.objects.create(
|
|
2411
|
-
subject="s", body="b", reach=self.roles["
|
|
2735
|
+
subject="s", body="b", reach=self.roles["Watchtower"]
|
|
2412
2736
|
)
|
|
2413
2737
|
with patch.object(Node, "get_local", return_value=None):
|
|
2414
2738
|
msg.propagate()
|
|
2415
2739
|
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
2416
2740
|
self.assertEqual(
|
|
2417
|
-
roles, {"
|
|
2741
|
+
roles, {"Watchtower", "Satellite", "Control", "Terminal"}
|
|
2418
2742
|
)
|
|
2419
2743
|
self.assertEqual(mock_post.call_count, 4)
|
|
2420
2744
|
|
|
@@ -2703,6 +3027,238 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2703
3027
|
self.assertTrue(msg.complete)
|
|
2704
3028
|
|
|
2705
3029
|
|
|
3030
|
+
class NetMessageQueueTests(TestCase):
|
|
3031
|
+
def setUp(self):
|
|
3032
|
+
self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
3033
|
+
self.feature, _ = NodeFeature.objects.get_or_create(
|
|
3034
|
+
slug="celery-queue", defaults={"display": "Celery Queue"}
|
|
3035
|
+
)
|
|
3036
|
+
|
|
3037
|
+
def test_propagate_queues_unreachable_downstream(self):
|
|
3038
|
+
local = Node.objects.create(
|
|
3039
|
+
hostname="local",
|
|
3040
|
+
address="10.0.0.1",
|
|
3041
|
+
port=8000,
|
|
3042
|
+
mac_address="00:11:22:33:44:10",
|
|
3043
|
+
role=self.role,
|
|
3044
|
+
public_endpoint="local",
|
|
3045
|
+
)
|
|
3046
|
+
downstream = Node.objects.create(
|
|
3047
|
+
hostname="downstream",
|
|
3048
|
+
address="10.0.0.2",
|
|
3049
|
+
port=8001,
|
|
3050
|
+
mac_address="00:11:22:33:44:11",
|
|
3051
|
+
role=self.role,
|
|
3052
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3053
|
+
)
|
|
3054
|
+
message = NetMessage.objects.create(subject="Queued", body="Body", reach=self.role)
|
|
3055
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
3056
|
+
Node, "get_private_key", return_value=None
|
|
3057
|
+
), patch("core.notifications.notify", return_value=False), patch(
|
|
3058
|
+
"requests.post", side_effect=Exception("fail")
|
|
3059
|
+
):
|
|
3060
|
+
message.propagate()
|
|
3061
|
+
|
|
3062
|
+
entry = PendingNetMessage.objects.get(node=downstream, message=message)
|
|
3063
|
+
self.assertIn(str(downstream.uuid), entry.seen)
|
|
3064
|
+
self.assertGreater(entry.stale_at, timezone.now())
|
|
3065
|
+
|
|
3066
|
+
def test_queue_limit_enforced(self):
|
|
3067
|
+
downstream = Node.objects.create(
|
|
3068
|
+
hostname="limit",
|
|
3069
|
+
address="10.0.0.3",
|
|
3070
|
+
port=8002,
|
|
3071
|
+
mac_address="00:11:22:33:44:12",
|
|
3072
|
+
role=self.role,
|
|
3073
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3074
|
+
message_queue_length=1,
|
|
3075
|
+
)
|
|
3076
|
+
msg1 = NetMessage.objects.create(subject="Old", body="One", reach=self.role)
|
|
3077
|
+
msg2 = NetMessage.objects.create(subject="New", body="Two", reach=self.role)
|
|
3078
|
+
|
|
3079
|
+
msg1.queue_for_node(downstream, [str(downstream.uuid)])
|
|
3080
|
+
msg2.queue_for_node(downstream, [str(downstream.uuid)])
|
|
3081
|
+
|
|
3082
|
+
entries = list(PendingNetMessage.objects.filter(node=downstream))
|
|
3083
|
+
self.assertEqual(len(entries), 1)
|
|
3084
|
+
self.assertEqual(entries[0].message, msg2)
|
|
3085
|
+
|
|
3086
|
+
def test_queue_duplicate_updates_stale(self):
|
|
3087
|
+
downstream = Node.objects.create(
|
|
3088
|
+
hostname="dup",
|
|
3089
|
+
address="10.0.0.4",
|
|
3090
|
+
port=8003,
|
|
3091
|
+
mac_address="00:11:22:33:44:13",
|
|
3092
|
+
role=self.role,
|
|
3093
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3094
|
+
)
|
|
3095
|
+
message = NetMessage.objects.create(subject="Dup", body="Dup", reach=self.role)
|
|
3096
|
+
first = timezone.now()
|
|
3097
|
+
second = first + timedelta(minutes=5)
|
|
3098
|
+
with patch(
|
|
3099
|
+
"nodes.models.timezone.now", side_effect=[first, second, second]
|
|
3100
|
+
):
|
|
3101
|
+
message.queue_for_node(downstream, ["first"])
|
|
3102
|
+
message.queue_for_node(downstream, ["second"])
|
|
3103
|
+
|
|
3104
|
+
entry = PendingNetMessage.objects.get(node=downstream, message=message)
|
|
3105
|
+
self.assertEqual(entry.seen, ["second"])
|
|
3106
|
+
self.assertEqual(entry.queued_at, second)
|
|
3107
|
+
self.assertEqual(entry.stale_at, second + timedelta(hours=1))
|
|
3108
|
+
|
|
3109
|
+
def test_pull_endpoint_returns_and_clears_messages(self):
|
|
3110
|
+
local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3111
|
+
downstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3112
|
+
local = Node.objects.create(
|
|
3113
|
+
hostname="hub",
|
|
3114
|
+
address="10.0.0.5",
|
|
3115
|
+
port=8004,
|
|
3116
|
+
mac_address="00:11:22:33:44:14",
|
|
3117
|
+
role=self.role,
|
|
3118
|
+
public_endpoint="hub",
|
|
3119
|
+
)
|
|
3120
|
+
downstream = Node.objects.create(
|
|
3121
|
+
hostname="remote",
|
|
3122
|
+
address="10.0.0.6",
|
|
3123
|
+
port=8005,
|
|
3124
|
+
mac_address="00:11:22:33:44:15",
|
|
3125
|
+
role=self.role,
|
|
3126
|
+
current_relation=Node.Relation.DOWNSTREAM,
|
|
3127
|
+
public_key=downstream_key.public_key()
|
|
3128
|
+
.public_bytes(
|
|
3129
|
+
serialization.Encoding.PEM,
|
|
3130
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
3131
|
+
)
|
|
3132
|
+
.decode(),
|
|
3133
|
+
)
|
|
3134
|
+
message = NetMessage.objects.create(subject="Fresh", body="Body", reach=self.role)
|
|
3135
|
+
stale_message = NetMessage.objects.create(subject="Stale", body="Body", reach=self.role)
|
|
3136
|
+
now = timezone.now()
|
|
3137
|
+
PendingNetMessage.objects.create(
|
|
3138
|
+
node=downstream,
|
|
3139
|
+
message=message,
|
|
3140
|
+
seen=[str(downstream.uuid)],
|
|
3141
|
+
stale_at=now + timedelta(minutes=30),
|
|
3142
|
+
)
|
|
3143
|
+
stale_entry = PendingNetMessage.objects.create(
|
|
3144
|
+
node=downstream,
|
|
3145
|
+
message=stale_message,
|
|
3146
|
+
seen=["stale"],
|
|
3147
|
+
stale_at=now - timedelta(minutes=5),
|
|
3148
|
+
)
|
|
3149
|
+
PendingNetMessage.objects.filter(pk=stale_entry.pk).update(
|
|
3150
|
+
queued_at=now - timedelta(minutes=5)
|
|
3151
|
+
)
|
|
3152
|
+
|
|
3153
|
+
def fake_get_private(node_obj):
|
|
3154
|
+
if node_obj.pk == local.pk:
|
|
3155
|
+
return local_key
|
|
3156
|
+
return None
|
|
3157
|
+
|
|
3158
|
+
payload = {"requester": str(downstream.uuid)}
|
|
3159
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
3160
|
+
signature = base64.b64encode(
|
|
3161
|
+
downstream_key.sign(
|
|
3162
|
+
body.encode(),
|
|
3163
|
+
padding.PKCS1v15(),
|
|
3164
|
+
hashes.SHA256(),
|
|
3165
|
+
)
|
|
3166
|
+
).decode()
|
|
3167
|
+
|
|
3168
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
3169
|
+
Node, "get_private_key", return_value=local_key
|
|
3170
|
+
):
|
|
3171
|
+
response = self.client.post(
|
|
3172
|
+
reverse("net-message-pull"),
|
|
3173
|
+
data=body,
|
|
3174
|
+
content_type="application/json",
|
|
3175
|
+
HTTP_X_SIGNATURE=signature,
|
|
3176
|
+
)
|
|
3177
|
+
|
|
3178
|
+
self.assertEqual(response.status_code, 200)
|
|
3179
|
+
data = response.json()
|
|
3180
|
+
self.assertEqual(len(data.get("messages", [])), 1)
|
|
3181
|
+
payload_data = data["messages"][0]["payload"]
|
|
3182
|
+
self.assertEqual(payload_data["uuid"], str(message.uuid))
|
|
3183
|
+
self.assertFalse(
|
|
3184
|
+
PendingNetMessage.objects.filter(node=downstream, message=message).exists()
|
|
3185
|
+
)
|
|
3186
|
+
self.assertFalse(
|
|
3187
|
+
PendingNetMessage.objects.filter(
|
|
3188
|
+
node=downstream, message=stale_message
|
|
3189
|
+
).exists()
|
|
3190
|
+
)
|
|
3191
|
+
response_signature = data["messages"][0]["signature"]
|
|
3192
|
+
local_public = local_key.public_key()
|
|
3193
|
+
local_public.verify(
|
|
3194
|
+
base64.b64decode(response_signature),
|
|
3195
|
+
json.dumps(payload_data, separators=(",", ":"), sort_keys=True).encode(),
|
|
3196
|
+
padding.PKCS1v15(),
|
|
3197
|
+
hashes.SHA256(),
|
|
3198
|
+
)
|
|
3199
|
+
|
|
3200
|
+
def test_poll_task_fetches_messages(self):
|
|
3201
|
+
local_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3202
|
+
upstream_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
3203
|
+
local = Node.objects.create(
|
|
3204
|
+
hostname="downstream",
|
|
3205
|
+
address="10.0.0.7",
|
|
3206
|
+
port=8006,
|
|
3207
|
+
mac_address="00:11:22:33:44:16",
|
|
3208
|
+
role=self.role,
|
|
3209
|
+
public_endpoint="downstream",
|
|
3210
|
+
)
|
|
3211
|
+
upstream = Node.objects.create(
|
|
3212
|
+
hostname="upstream",
|
|
3213
|
+
address="127.0.0.2",
|
|
3214
|
+
port=8010,
|
|
3215
|
+
mac_address="00:11:22:33:44:17",
|
|
3216
|
+
role=self.role,
|
|
3217
|
+
current_relation=Node.Relation.UPSTREAM,
|
|
3218
|
+
public_key=upstream_key.public_key()
|
|
3219
|
+
.public_bytes(
|
|
3220
|
+
serialization.Encoding.PEM,
|
|
3221
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
3222
|
+
)
|
|
3223
|
+
.decode(),
|
|
3224
|
+
)
|
|
3225
|
+
NodeFeatureAssignment.objects.create(node=local, feature=self.feature)
|
|
3226
|
+
payload = {
|
|
3227
|
+
"uuid": str(uuid.uuid4()),
|
|
3228
|
+
"subject": "Update",
|
|
3229
|
+
"body": "Body",
|
|
3230
|
+
"seen": [str(local.uuid)],
|
|
3231
|
+
"origin": str(upstream.uuid),
|
|
3232
|
+
"sender": str(upstream.uuid),
|
|
3233
|
+
}
|
|
3234
|
+
payload_text = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
3235
|
+
payload_signature = base64.b64encode(
|
|
3236
|
+
upstream_key.sign(
|
|
3237
|
+
payload_text.encode(),
|
|
3238
|
+
padding.PKCS1v15(),
|
|
3239
|
+
hashes.SHA256(),
|
|
3240
|
+
)
|
|
3241
|
+
).decode()
|
|
3242
|
+
response = MagicMock()
|
|
3243
|
+
response.ok = True
|
|
3244
|
+
response.json.return_value = {
|
|
3245
|
+
"messages": [{"payload": payload, "signature": payload_signature}]
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
3249
|
+
Node, "get_private_key", return_value=local_key
|
|
3250
|
+
), patch("nodes.tasks.requests.post", return_value=response) as mock_post, patch.object(
|
|
3251
|
+
NetMessage, "propagate"
|
|
3252
|
+
) as mock_propagate:
|
|
3253
|
+
poll_unreachable_upstream()
|
|
3254
|
+
|
|
3255
|
+
created = NetMessage.objects.get(uuid=payload["uuid"])
|
|
3256
|
+
self.assertEqual(created.subject, "Update")
|
|
3257
|
+
self.assertEqual(created.node_origin, upstream)
|
|
3258
|
+
mock_post.assert_called_once()
|
|
3259
|
+
mock_propagate.assert_called_once()
|
|
3260
|
+
|
|
3261
|
+
|
|
2706
3262
|
class NetMessageSignatureTests(TestCase):
|
|
2707
3263
|
def setUp(self):
|
|
2708
3264
|
self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
@@ -2938,6 +3494,7 @@ class ContentSampleTransactionTests(TestCase):
|
|
|
2938
3494
|
sample1.save()
|
|
2939
3495
|
|
|
2940
3496
|
|
|
3497
|
+
@pytest.mark.feature("clipboard-poll")
|
|
2941
3498
|
class ContentSampleAdminTests(TestCase):
|
|
2942
3499
|
def setUp(self):
|
|
2943
3500
|
User = get_user_model()
|
|
@@ -3114,6 +3671,7 @@ class EmailOutboxTests(TestCase):
|
|
|
3114
3671
|
|
|
3115
3672
|
|
|
3116
3673
|
class ClipboardTaskTests(TestCase):
|
|
3674
|
+
@pytest.mark.feature("clipboard-poll")
|
|
3117
3675
|
@patch("nodes.tasks.pyperclip.paste")
|
|
3118
3676
|
def test_sample_clipboard_task_creates_sample(self, mock_paste):
|
|
3119
3677
|
mock_paste.return_value = "task text"
|
|
@@ -3138,6 +3696,7 @@ class ClipboardTaskTests(TestCase):
|
|
|
3138
3696
|
ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
|
|
3139
3697
|
)
|
|
3140
3698
|
|
|
3699
|
+
@pytest.mark.feature("screenshot-poll")
|
|
3141
3700
|
@patch("nodes.tasks.capture_screenshot")
|
|
3142
3701
|
def test_capture_node_screenshot_task(self, mock_capture):
|
|
3143
3702
|
node = Node.objects.create(
|
|
@@ -3160,6 +3719,7 @@ class ClipboardTaskTests(TestCase):
|
|
|
3160
3719
|
self.assertEqual(screenshot.path, "screenshots/test.png")
|
|
3161
3720
|
self.assertEqual(screenshot.method, "TASK")
|
|
3162
3721
|
|
|
3722
|
+
@pytest.mark.feature("screenshot-poll")
|
|
3163
3723
|
@patch("nodes.tasks.capture_screenshot")
|
|
3164
3724
|
def test_capture_node_screenshot_handles_error(self, mock_capture):
|
|
3165
3725
|
Node.objects.create(
|
|
@@ -3239,7 +3799,7 @@ class NodeRoleAdminTests(TestCase):
|
|
|
3239
3799
|
|
|
3240
3800
|
class NodeFeatureFixtureTests(TestCase):
|
|
3241
3801
|
def test_rfid_scanner_fixture_includes_control_role(self):
|
|
3242
|
-
for name in ("Terminal", "Satellite", "
|
|
3802
|
+
for name in ("Terminal", "Satellite", "Watchtower", "Control"):
|
|
3243
3803
|
NodeRole.objects.get_or_create(name=name)
|
|
3244
3804
|
fixture_path = (
|
|
3245
3805
|
Path(__file__).resolve().parent
|
|
@@ -3251,6 +3811,7 @@ class NodeFeatureFixtureTests(TestCase):
|
|
|
3251
3811
|
role_names = set(feature.roles.values_list("name", flat=True))
|
|
3252
3812
|
self.assertIn("Control", role_names)
|
|
3253
3813
|
|
|
3814
|
+
@pytest.mark.feature("ap-router")
|
|
3254
3815
|
def test_ap_router_fixture_limits_roles(self):
|
|
3255
3816
|
for name in ("Control", "Satellite"):
|
|
3256
3817
|
NodeRole.objects.get_or_create(name=name)
|
|
@@ -3301,6 +3862,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3301
3862
|
self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
|
|
3302
3863
|
self.assertEqual(feature.get_default_action(), action)
|
|
3303
3864
|
|
|
3865
|
+
@pytest.mark.feature("rpi-camera")
|
|
3304
3866
|
def test_rpi_camera_feature_has_multiple_actions(self):
|
|
3305
3867
|
feature = NodeFeature.objects.create(
|
|
3306
3868
|
slug="rpi-camera", display="Raspberry Pi Camera"
|
|
@@ -3311,6 +3873,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3311
3873
|
self.assertIn("Take a Snapshot", labels)
|
|
3312
3874
|
self.assertIn("View stream", labels)
|
|
3313
3875
|
|
|
3876
|
+
@pytest.mark.feature("audio-capture")
|
|
3314
3877
|
def test_audio_capture_feature_has_view_waveform_action(self):
|
|
3315
3878
|
feature = NodeFeature.objects.create(
|
|
3316
3879
|
slug="audio-capture", display="Audio Capture"
|
|
@@ -3488,6 +4051,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3488
4051
|
)
|
|
3489
4052
|
self.assertEqual(mock_find_command.call_count, 2)
|
|
3490
4053
|
|
|
4054
|
+
@pytest.mark.feature("ap-router")
|
|
3491
4055
|
@patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
|
|
3492
4056
|
def test_ap_router_detection(self, mock_hosts):
|
|
3493
4057
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
@@ -3507,6 +4071,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3507
4071
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
|
|
3508
4072
|
)
|
|
3509
4073
|
|
|
4074
|
+
@pytest.mark.feature("ap-router")
|
|
3510
4075
|
@patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
|
|
3511
4076
|
def test_ap_router_detection_with_public_mode_lock(self, mock_hosts):
|
|
3512
4077
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
@@ -3531,6 +4096,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3531
4096
|
NodeFeatureAssignment.objects.filter(node=node, feature=router).exists()
|
|
3532
4097
|
)
|
|
3533
4098
|
|
|
4099
|
+
@pytest.mark.feature("ap-router")
|
|
3534
4100
|
@patch("nodes.models.Node._hosts_gelectriic_ap", side_effect=[True, False])
|
|
3535
4101
|
def test_ap_router_removed_when_not_hosting(self, mock_hosts):
|
|
3536
4102
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|