arthexis 0.1.19__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.19.dist-info → arthexis-0.1.20.dist-info}/METADATA +3 -3
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/RECORD +38 -38
- 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/tasks.py +24 -1
- core/tests.py +1 -0
- core/views.py +70 -36
- nodes/admin.py +133 -1
- nodes/models.py +294 -48
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +532 -15
- nodes/urls.py +4 -0
- nodes/views.py +500 -95
- ocpp/admin.py +101 -3
- ocpp/consumers.py +106 -9
- ocpp/models.py +83 -1
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +100 -9
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +101 -28
- pages/context_processors.py +15 -9
- pages/defaults.py +1 -1
- pages/module_defaults.py +5 -5
- pages/tests.py +110 -38
- pages/urls.py +1 -0
- pages/views.py +108 -8
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/WHEEL +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.19.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,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"}
|
|
@@ -1276,6 +1338,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1276
1338
|
existing_role.refresh_from_db()
|
|
1277
1339
|
self.assertEqual(existing_role.description, "updated via attachment")
|
|
1278
1340
|
|
|
1341
|
+
@pytest.mark.feature("clipboard-poll")
|
|
1279
1342
|
def test_clipboard_polling_creates_task(self):
|
|
1280
1343
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
1281
1344
|
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
@@ -1293,6 +1356,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1293
1356
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
|
|
1294
1357
|
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
1295
1358
|
|
|
1359
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1296
1360
|
def test_screenshot_polling_creates_task(self):
|
|
1297
1361
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
1298
1362
|
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
@@ -1421,6 +1485,7 @@ class NodeAdminTests(TestCase):
|
|
|
1421
1485
|
action_url = reverse("admin:core_rfid_scan")
|
|
1422
1486
|
self.assertContains(response, f'href="{action_url}"')
|
|
1423
1487
|
|
|
1488
|
+
@pytest.mark.feature("rpi-camera")
|
|
1424
1489
|
def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
|
|
1425
1490
|
node = self._create_local_node()
|
|
1426
1491
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1433,6 +1498,7 @@ class NodeAdminTests(TestCase):
|
|
|
1433
1498
|
self.assertContains(response, f'href="{snapshot_url}"')
|
|
1434
1499
|
self.assertContains(response, f'href="{stream_url}"')
|
|
1435
1500
|
|
|
1501
|
+
@pytest.mark.feature("audio-capture")
|
|
1436
1502
|
def test_node_feature_list_shows_waveform_action_when_enabled(self):
|
|
1437
1503
|
node = self._create_local_node()
|
|
1438
1504
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1443,6 +1509,7 @@ class NodeAdminTests(TestCase):
|
|
|
1443
1509
|
action_url = reverse("admin:nodes_nodefeature_view_waveform")
|
|
1444
1510
|
self.assertContains(response, f'href="{action_url}"')
|
|
1445
1511
|
|
|
1512
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1446
1513
|
def test_node_feature_list_hides_default_action_when_disabled(self):
|
|
1447
1514
|
self._create_local_node()
|
|
1448
1515
|
NodeFeature.objects.get_or_create(
|
|
@@ -1535,6 +1602,7 @@ class NodeAdminTests(TestCase):
|
|
|
1535
1602
|
response, reverse("admin:nodes_node_register_current")
|
|
1536
1603
|
)
|
|
1537
1604
|
|
|
1605
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1538
1606
|
@patch("nodes.admin.capture_screenshot")
|
|
1539
1607
|
def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
|
|
1540
1608
|
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
@@ -1580,6 +1648,48 @@ class NodeAdminTests(TestCase):
|
|
|
1580
1648
|
self.assertEqual(response.status_code, 200)
|
|
1581
1649
|
self.assertContains(response, "data:image/png;base64")
|
|
1582
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")
|
|
1583
1693
|
@override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
|
|
1584
1694
|
@patch("nodes.admin.capture_screenshot")
|
|
1585
1695
|
def test_take_screenshots_action(self, mock_capture):
|
|
@@ -1612,6 +1722,7 @@ class NodeAdminTests(TestCase):
|
|
|
1612
1722
|
samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
|
|
1613
1723
|
self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
|
|
1614
1724
|
|
|
1725
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1615
1726
|
@patch("nodes.admin.capture_screenshot")
|
|
1616
1727
|
def test_take_screenshot_default_action_creates_sample(
|
|
1617
1728
|
self, mock_capture_screenshot
|
|
@@ -1686,6 +1797,7 @@ class NodeAdminTests(TestCase):
|
|
|
1686
1797
|
response, "Completed 0 of 1 feature check(s) successfully.", html=False
|
|
1687
1798
|
)
|
|
1688
1799
|
|
|
1800
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1689
1801
|
def test_enable_selected_features_enables_manual_feature(self):
|
|
1690
1802
|
node = self._create_local_node()
|
|
1691
1803
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1730,6 +1842,7 @@ class NodeAdminTests(TestCase):
|
|
|
1730
1842
|
html=False,
|
|
1731
1843
|
)
|
|
1732
1844
|
|
|
1845
|
+
@pytest.mark.feature("screenshot-poll")
|
|
1733
1846
|
def test_take_screenshot_default_action_requires_enabled_feature(self):
|
|
1734
1847
|
self._create_local_node()
|
|
1735
1848
|
NodeFeature.objects.get_or_create(
|
|
@@ -1744,6 +1857,7 @@ class NodeAdminTests(TestCase):
|
|
|
1744
1857
|
self.assertEqual(ContentSample.objects.count(), 0)
|
|
1745
1858
|
self.assertContains(response, "Screenshot Poll feature is not enabled")
|
|
1746
1859
|
|
|
1860
|
+
@pytest.mark.feature("rpi-camera")
|
|
1747
1861
|
@patch("nodes.admin.capture_rpi_snapshot")
|
|
1748
1862
|
def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
|
|
1749
1863
|
node = self._create_local_node()
|
|
@@ -1766,6 +1880,7 @@ class NodeAdminTests(TestCase):
|
|
|
1766
1880
|
change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
|
|
1767
1881
|
self.assertEqual(response.redirect_chain[-1][0], change_url)
|
|
1768
1882
|
|
|
1883
|
+
@pytest.mark.feature("rpi-camera")
|
|
1769
1884
|
def test_view_stream_requires_enabled_feature(self):
|
|
1770
1885
|
self._create_local_node()
|
|
1771
1886
|
NodeFeature.objects.get_or_create(
|
|
@@ -1781,6 +1896,7 @@ class NodeAdminTests(TestCase):
|
|
|
1781
1896
|
response, "Raspberry Pi Camera feature is not enabled on this node."
|
|
1782
1897
|
)
|
|
1783
1898
|
|
|
1899
|
+
@pytest.mark.feature("rpi-camera")
|
|
1784
1900
|
def test_view_stream_renders_when_feature_enabled(self):
|
|
1785
1901
|
node = self._create_local_node()
|
|
1786
1902
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1796,6 +1912,7 @@ class NodeAdminTests(TestCase):
|
|
|
1796
1912
|
self.assertContains(response, expected_stream)
|
|
1797
1913
|
self.assertContains(response, "camera-stream__frame")
|
|
1798
1914
|
|
|
1915
|
+
@pytest.mark.feature("rpi-camera")
|
|
1799
1916
|
def test_view_stream_uses_configured_stream_url(self):
|
|
1800
1917
|
node = self._create_local_node()
|
|
1801
1918
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1813,6 +1930,7 @@ class NodeAdminTests(TestCase):
|
|
|
1813
1930
|
self.assertEqual(response.context_data["stream_embed"], "iframe")
|
|
1814
1931
|
self.assertContains(response, configured_stream)
|
|
1815
1932
|
|
|
1933
|
+
@pytest.mark.feature("rpi-camera")
|
|
1816
1934
|
def test_view_stream_detects_mjpeg_stream(self):
|
|
1817
1935
|
node = self._create_local_node()
|
|
1818
1936
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1829,6 +1947,7 @@ class NodeAdminTests(TestCase):
|
|
|
1829
1947
|
self.assertEqual(response.context_data["stream_embed"], "mjpeg")
|
|
1830
1948
|
self.assertContains(response, "<img", html=False)
|
|
1831
1949
|
|
|
1950
|
+
@pytest.mark.feature("rpi-camera")
|
|
1832
1951
|
def test_view_stream_marks_rtsp_stream_as_unsupported(self):
|
|
1833
1952
|
node = self._create_local_node()
|
|
1834
1953
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -1845,6 +1964,7 @@ class NodeAdminTests(TestCase):
|
|
|
1845
1964
|
self.assertEqual(response.context_data["stream_embed"], "unsupported")
|
|
1846
1965
|
self.assertContains(response, "camera-stream__unsupported")
|
|
1847
1966
|
|
|
1967
|
+
@pytest.mark.feature("audio-capture")
|
|
1848
1968
|
def test_view_waveform_requires_enabled_feature(self):
|
|
1849
1969
|
self._create_local_node()
|
|
1850
1970
|
NodeFeature.objects.get_or_create(
|
|
@@ -1860,6 +1980,7 @@ class NodeAdminTests(TestCase):
|
|
|
1860
1980
|
response, "Audio Capture feature is not enabled on this node."
|
|
1861
1981
|
)
|
|
1862
1982
|
|
|
1983
|
+
@pytest.mark.feature("audio-capture")
|
|
1863
1984
|
def test_view_waveform_renders_when_feature_enabled(self):
|
|
1864
1985
|
node = self._create_local_node()
|
|
1865
1986
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -2182,6 +2303,160 @@ class NodeAdminTests(TestCase):
|
|
|
2182
2303
|
self.assertEqual(post_data["mac_address"], local.mac_address)
|
|
2183
2304
|
|
|
2184
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
|
+
|
|
2185
2460
|
class NodeRFIDAPITests(TestCase):
|
|
2186
2461
|
def test_import_endpoint_applies_payload_without_creating_accounts(self):
|
|
2187
2462
|
remote = Node.objects.create(
|
|
@@ -2358,11 +2633,11 @@ class NetMessageAdminTests(TransactionTestCase):
|
|
|
2358
2633
|
class NetMessageReachTests(TestCase):
|
|
2359
2634
|
def setUp(self):
|
|
2360
2635
|
self.roles = {}
|
|
2361
|
-
for name in ["Terminal", "Control", "Satellite", "
|
|
2636
|
+
for name in ["Terminal", "Control", "Satellite", "Watchtower"]:
|
|
2362
2637
|
self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
|
|
2363
2638
|
self.nodes = {}
|
|
2364
2639
|
for idx, name in enumerate(
|
|
2365
|
-
["Terminal", "Control", "Satellite", "
|
|
2640
|
+
["Terminal", "Control", "Satellite", "Watchtower"], start=1
|
|
2366
2641
|
):
|
|
2367
2642
|
self.nodes[name] = Node.objects.create(
|
|
2368
2643
|
hostname=name.lower(),
|
|
@@ -2406,15 +2681,15 @@ class NetMessageReachTests(TestCase):
|
|
|
2406
2681
|
self.assertEqual(mock_post.call_count, 3)
|
|
2407
2682
|
|
|
2408
2683
|
@patch("requests.post")
|
|
2409
|
-
def
|
|
2684
|
+
def test_watchtower_reach_prioritizes_watchtower(self, mock_post):
|
|
2410
2685
|
msg = NetMessage.objects.create(
|
|
2411
|
-
subject="s", body="b", reach=self.roles["
|
|
2686
|
+
subject="s", body="b", reach=self.roles["Watchtower"]
|
|
2412
2687
|
)
|
|
2413
2688
|
with patch.object(Node, "get_local", return_value=None):
|
|
2414
2689
|
msg.propagate()
|
|
2415
2690
|
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
2416
2691
|
self.assertEqual(
|
|
2417
|
-
roles, {"
|
|
2692
|
+
roles, {"Watchtower", "Satellite", "Control", "Terminal"}
|
|
2418
2693
|
)
|
|
2419
2694
|
self.assertEqual(mock_post.call_count, 4)
|
|
2420
2695
|
|
|
@@ -2703,6 +2978,238 @@ class NetMessagePropagationTests(TestCase):
|
|
|
2703
2978
|
self.assertTrue(msg.complete)
|
|
2704
2979
|
|
|
2705
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,
|
|
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()
|
|
3211
|
+
|
|
3212
|
+
|
|
2706
3213
|
class NetMessageSignatureTests(TestCase):
|
|
2707
3214
|
def setUp(self):
|
|
2708
3215
|
self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
@@ -2938,6 +3445,7 @@ class ContentSampleTransactionTests(TestCase):
|
|
|
2938
3445
|
sample1.save()
|
|
2939
3446
|
|
|
2940
3447
|
|
|
3448
|
+
@pytest.mark.feature("clipboard-poll")
|
|
2941
3449
|
class ContentSampleAdminTests(TestCase):
|
|
2942
3450
|
def setUp(self):
|
|
2943
3451
|
User = get_user_model()
|
|
@@ -3114,6 +3622,7 @@ class EmailOutboxTests(TestCase):
|
|
|
3114
3622
|
|
|
3115
3623
|
|
|
3116
3624
|
class ClipboardTaskTests(TestCase):
|
|
3625
|
+
@pytest.mark.feature("clipboard-poll")
|
|
3117
3626
|
@patch("nodes.tasks.pyperclip.paste")
|
|
3118
3627
|
def test_sample_clipboard_task_creates_sample(self, mock_paste):
|
|
3119
3628
|
mock_paste.return_value = "task text"
|
|
@@ -3138,6 +3647,7 @@ class ClipboardTaskTests(TestCase):
|
|
|
3138
3647
|
ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
|
|
3139
3648
|
)
|
|
3140
3649
|
|
|
3650
|
+
@pytest.mark.feature("screenshot-poll")
|
|
3141
3651
|
@patch("nodes.tasks.capture_screenshot")
|
|
3142
3652
|
def test_capture_node_screenshot_task(self, mock_capture):
|
|
3143
3653
|
node = Node.objects.create(
|
|
@@ -3160,6 +3670,7 @@ class ClipboardTaskTests(TestCase):
|
|
|
3160
3670
|
self.assertEqual(screenshot.path, "screenshots/test.png")
|
|
3161
3671
|
self.assertEqual(screenshot.method, "TASK")
|
|
3162
3672
|
|
|
3673
|
+
@pytest.mark.feature("screenshot-poll")
|
|
3163
3674
|
@patch("nodes.tasks.capture_screenshot")
|
|
3164
3675
|
def test_capture_node_screenshot_handles_error(self, mock_capture):
|
|
3165
3676
|
Node.objects.create(
|
|
@@ -3239,7 +3750,7 @@ class NodeRoleAdminTests(TestCase):
|
|
|
3239
3750
|
|
|
3240
3751
|
class NodeFeatureFixtureTests(TestCase):
|
|
3241
3752
|
def test_rfid_scanner_fixture_includes_control_role(self):
|
|
3242
|
-
for name in ("Terminal", "Satellite", "
|
|
3753
|
+
for name in ("Terminal", "Satellite", "Watchtower", "Control"):
|
|
3243
3754
|
NodeRole.objects.get_or_create(name=name)
|
|
3244
3755
|
fixture_path = (
|
|
3245
3756
|
Path(__file__).resolve().parent
|
|
@@ -3251,6 +3762,7 @@ class NodeFeatureFixtureTests(TestCase):
|
|
|
3251
3762
|
role_names = set(feature.roles.values_list("name", flat=True))
|
|
3252
3763
|
self.assertIn("Control", role_names)
|
|
3253
3764
|
|
|
3765
|
+
@pytest.mark.feature("ap-router")
|
|
3254
3766
|
def test_ap_router_fixture_limits_roles(self):
|
|
3255
3767
|
for name in ("Control", "Satellite"):
|
|
3256
3768
|
NodeRole.objects.get_or_create(name=name)
|
|
@@ -3301,6 +3813,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3301
3813
|
self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
|
|
3302
3814
|
self.assertEqual(feature.get_default_action(), action)
|
|
3303
3815
|
|
|
3816
|
+
@pytest.mark.feature("rpi-camera")
|
|
3304
3817
|
def test_rpi_camera_feature_has_multiple_actions(self):
|
|
3305
3818
|
feature = NodeFeature.objects.create(
|
|
3306
3819
|
slug="rpi-camera", display="Raspberry Pi Camera"
|
|
@@ -3311,6 +3824,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3311
3824
|
self.assertIn("Take a Snapshot", labels)
|
|
3312
3825
|
self.assertIn("View stream", labels)
|
|
3313
3826
|
|
|
3827
|
+
@pytest.mark.feature("audio-capture")
|
|
3314
3828
|
def test_audio_capture_feature_has_view_waveform_action(self):
|
|
3315
3829
|
feature = NodeFeature.objects.create(
|
|
3316
3830
|
slug="audio-capture", display="Audio Capture"
|
|
@@ -3488,6 +4002,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3488
4002
|
)
|
|
3489
4003
|
self.assertEqual(mock_find_command.call_count, 2)
|
|
3490
4004
|
|
|
4005
|
+
@pytest.mark.feature("ap-router")
|
|
3491
4006
|
@patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
|
|
3492
4007
|
def test_ap_router_detection(self, mock_hosts):
|
|
3493
4008
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
@@ -3507,6 +4022,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3507
4022
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
|
|
3508
4023
|
)
|
|
3509
4024
|
|
|
4025
|
+
@pytest.mark.feature("ap-router")
|
|
3510
4026
|
@patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
|
|
3511
4027
|
def test_ap_router_detection_with_public_mode_lock(self, mock_hosts):
|
|
3512
4028
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
@@ -3531,6 +4047,7 @@ class NodeFeatureTests(TestCase):
|
|
|
3531
4047
|
NodeFeatureAssignment.objects.filter(node=node, feature=router).exists()
|
|
3532
4048
|
)
|
|
3533
4049
|
|
|
4050
|
+
@pytest.mark.feature("ap-router")
|
|
3534
4051
|
@patch("nodes.models.Node._hosts_gelectriic_ap", side_effect=[True, False])
|
|
3535
4052
|
def test_ap_router_removed_when_not_hosting(self, mock_hosts):
|
|
3536
4053
|
control_role, _ = NodeRole.objects.get_or_create(name="Control")
|