arthexis 0.1.11__py3-none-any.whl → 0.1.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
- config/asgi.py +15 -1
- config/celery.py +8 -1
- config/settings.py +49 -78
- config/settings_helpers.py +109 -0
- core/admin.py +293 -78
- core/apps.py +21 -0
- core/auto_upgrade.py +2 -2
- core/form_fields.py +75 -0
- core/models.py +203 -47
- core/reference_utils.py +1 -1
- core/release.py +42 -20
- core/system.py +6 -3
- core/tasks.py +92 -40
- core/tests.py +75 -1
- core/views.py +178 -29
- core/widgets.py +43 -0
- nodes/admin.py +583 -10
- nodes/apps.py +15 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +287 -49
- nodes/reports.py +411 -0
- nodes/tests.py +990 -42
- nodes/urls.py +1 -0
- nodes/utils.py +32 -0
- nodes/views.py +173 -5
- ocpp/admin.py +424 -17
- ocpp/consumers.py +630 -15
- ocpp/evcs.py +7 -94
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +236 -4
- ocpp/routing.py +4 -2
- ocpp/simulator.py +346 -26
- ocpp/status_display.py +26 -0
- ocpp/store.py +110 -2
- ocpp/tests.py +1425 -33
- ocpp/transactions_io.py +27 -3
- ocpp/views.py +344 -38
- pages/admin.py +138 -3
- pages/context_processors.py +15 -1
- pages/defaults.py +1 -2
- pages/forms.py +67 -0
- pages/models.py +136 -1
- pages/tests.py +379 -4
- pages/urls.py +1 -0
- pages/views.py +64 -7
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
nodes/tests.py
CHANGED
|
@@ -3,7 +3,15 @@ import os
|
|
|
3
3
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
4
4
|
import django
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
try: # Use the pytest-specific setup when available for database readiness
|
|
7
|
+
from tests.conftest import safe_setup as _safe_setup # type: ignore
|
|
8
|
+
except Exception: # pragma: no cover - fallback for direct execution
|
|
9
|
+
_safe_setup = None
|
|
10
|
+
|
|
11
|
+
if _safe_setup is not None:
|
|
12
|
+
_safe_setup()
|
|
13
|
+
else: # pragma: no cover - fallback when pytest fixtures are unavailable
|
|
14
|
+
django.setup()
|
|
7
15
|
|
|
8
16
|
from pathlib import Path
|
|
9
17
|
from types import SimpleNamespace
|
|
@@ -26,8 +34,9 @@ from django.test import Client, SimpleTestCase, TestCase, TransactionTestCase, o
|
|
|
26
34
|
from django.urls import reverse
|
|
27
35
|
from django.contrib.auth import get_user_model
|
|
28
36
|
from django.contrib import admin
|
|
37
|
+
from django.contrib.auth.models import Permission
|
|
29
38
|
from django.contrib.sites.models import Site
|
|
30
|
-
from django_celery_beat.models import PeriodicTask
|
|
39
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
31
40
|
from django.conf import settings
|
|
32
41
|
from django.utils import timezone
|
|
33
42
|
from dns import resolver as dns_resolver
|
|
@@ -52,7 +61,44 @@ from .backends import OutboxEmailBackend
|
|
|
52
61
|
from .tasks import capture_node_screenshot, sample_clipboard
|
|
53
62
|
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
54
63
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
55
|
-
from core.models import PackageRelease, SecurityGroup
|
|
64
|
+
from core.models import Package, PackageRelease, SecurityGroup, RFID
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class NodeBadgeColorTests(TestCase):
|
|
68
|
+
def setUp(self):
|
|
69
|
+
self.constellation, _ = NodeRole.objects.get_or_create(name="Constellation")
|
|
70
|
+
self.control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
71
|
+
|
|
72
|
+
def test_constellation_role_defaults_to_goldenrod(self):
|
|
73
|
+
node = Node.objects.create(
|
|
74
|
+
hostname="constellation",
|
|
75
|
+
address="10.1.0.1",
|
|
76
|
+
port=8000,
|
|
77
|
+
mac_address="00:aa:bb:cc:dd:01",
|
|
78
|
+
role=self.constellation,
|
|
79
|
+
)
|
|
80
|
+
self.assertEqual(node.badge_color, "#daa520")
|
|
81
|
+
|
|
82
|
+
def test_control_role_defaults_to_deep_purple(self):
|
|
83
|
+
node = Node.objects.create(
|
|
84
|
+
hostname="control",
|
|
85
|
+
address="10.1.0.2",
|
|
86
|
+
port=8001,
|
|
87
|
+
mac_address="00:aa:bb:cc:dd:02",
|
|
88
|
+
role=self.control,
|
|
89
|
+
)
|
|
90
|
+
self.assertEqual(node.badge_color, "#673ab7")
|
|
91
|
+
|
|
92
|
+
def test_custom_badge_color_is_preserved(self):
|
|
93
|
+
node = Node.objects.create(
|
|
94
|
+
hostname="custom",
|
|
95
|
+
address="10.1.0.3",
|
|
96
|
+
port=8002,
|
|
97
|
+
mac_address="00:aa:bb:cc:dd:03",
|
|
98
|
+
role=self.constellation,
|
|
99
|
+
badge_color="#123456",
|
|
100
|
+
)
|
|
101
|
+
self.assertEqual(node.badge_color, "#123456")
|
|
56
102
|
|
|
57
103
|
|
|
58
104
|
class NodeTests(TestCase):
|
|
@@ -77,6 +123,32 @@ class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
|
|
|
77
123
|
|
|
78
124
|
|
|
79
125
|
class NodeGetLocalTests(TestCase):
|
|
126
|
+
def test_normalize_relation_handles_various_inputs(self):
|
|
127
|
+
self.assertEqual(
|
|
128
|
+
Node.normalize_relation(Node.Relation.UPSTREAM),
|
|
129
|
+
Node.Relation.UPSTREAM,
|
|
130
|
+
)
|
|
131
|
+
self.assertEqual(
|
|
132
|
+
Node.normalize_relation(None),
|
|
133
|
+
Node.Relation.PEER,
|
|
134
|
+
)
|
|
135
|
+
self.assertEqual(
|
|
136
|
+
Node.normalize_relation("Upstream"),
|
|
137
|
+
Node.Relation.UPSTREAM,
|
|
138
|
+
)
|
|
139
|
+
self.assertEqual(
|
|
140
|
+
Node.normalize_relation("DOWNSTREAM"),
|
|
141
|
+
Node.Relation.DOWNSTREAM,
|
|
142
|
+
)
|
|
143
|
+
self.assertEqual(
|
|
144
|
+
Node.normalize_relation("peer"),
|
|
145
|
+
Node.Relation.PEER,
|
|
146
|
+
)
|
|
147
|
+
self.assertEqual(
|
|
148
|
+
Node.normalize_relation("unexpected"),
|
|
149
|
+
Node.Relation.PEER,
|
|
150
|
+
)
|
|
151
|
+
|
|
80
152
|
def test_register_current_does_not_create_release(self):
|
|
81
153
|
node = None
|
|
82
154
|
created = False
|
|
@@ -101,6 +173,54 @@ class NodeGetLocalTests(TestCase):
|
|
|
101
173
|
self.assertTrue(created)
|
|
102
174
|
self.assertEqual(node.current_relation, Node.Relation.SELF)
|
|
103
175
|
|
|
176
|
+
def test_register_current_updates_role_from_lock_file(self):
|
|
177
|
+
NodeRole.objects.get_or_create(name="Terminal")
|
|
178
|
+
NodeRole.objects.get_or_create(name="Constellation")
|
|
179
|
+
with TemporaryDirectory() as tmp:
|
|
180
|
+
base = Path(tmp)
|
|
181
|
+
lock_dir = base / "locks"
|
|
182
|
+
lock_dir.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
role_file = lock_dir / "role.lck"
|
|
184
|
+
role_file.write_text("Terminal")
|
|
185
|
+
with override_settings(BASE_DIR=base):
|
|
186
|
+
with (
|
|
187
|
+
patch(
|
|
188
|
+
"nodes.models.Node.get_current_mac",
|
|
189
|
+
return_value="00:aa:bb:cc:dd:ee",
|
|
190
|
+
),
|
|
191
|
+
patch("nodes.models.socket.gethostname", return_value="role-host"),
|
|
192
|
+
patch(
|
|
193
|
+
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
194
|
+
),
|
|
195
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
196
|
+
patch.object(Node, "ensure_keys"),
|
|
197
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
198
|
+
):
|
|
199
|
+
node, created = Node.register_current()
|
|
200
|
+
self.assertTrue(created)
|
|
201
|
+
self.assertEqual(node.role.name, "Terminal")
|
|
202
|
+
|
|
203
|
+
role_file.write_text("Constellation")
|
|
204
|
+
with override_settings(BASE_DIR=base):
|
|
205
|
+
with (
|
|
206
|
+
patch(
|
|
207
|
+
"nodes.models.Node.get_current_mac",
|
|
208
|
+
return_value="00:aa:bb:cc:dd:ee",
|
|
209
|
+
),
|
|
210
|
+
patch("nodes.models.socket.gethostname", return_value="role-host"),
|
|
211
|
+
patch(
|
|
212
|
+
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
213
|
+
),
|
|
214
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
215
|
+
patch.object(Node, "ensure_keys"),
|
|
216
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
217
|
+
):
|
|
218
|
+
_, created_again = Node.register_current()
|
|
219
|
+
|
|
220
|
+
self.assertFalse(created_again)
|
|
221
|
+
node.refresh_from_db()
|
|
222
|
+
self.assertEqual(node.role.name, "Constellation")
|
|
223
|
+
|
|
104
224
|
def test_register_and_list_node(self):
|
|
105
225
|
response = self.client.post(
|
|
106
226
|
reverse("register-node"),
|
|
@@ -152,6 +272,41 @@ class NodeGetLocalTests(TestCase):
|
|
|
152
272
|
hostnames = {n["hostname"] for n in data["nodes"]}
|
|
153
273
|
self.assertEqual(hostnames, {"dup", "local2"})
|
|
154
274
|
|
|
275
|
+
def test_register_node_generates_unique_public_endpoint(self):
|
|
276
|
+
url = reverse("register-node")
|
|
277
|
+
User = get_user_model()
|
|
278
|
+
user = User.objects.create_user(username="registrar", password="pwd")
|
|
279
|
+
self.client.force_login(user)
|
|
280
|
+
first = self.client.post(
|
|
281
|
+
url,
|
|
282
|
+
data={
|
|
283
|
+
"hostname": "duplicate-host",
|
|
284
|
+
"address": "10.0.0.10",
|
|
285
|
+
"port": 8080,
|
|
286
|
+
"mac_address": "00:11:22:33:aa:bb",
|
|
287
|
+
},
|
|
288
|
+
content_type="application/json",
|
|
289
|
+
)
|
|
290
|
+
self.assertEqual(first.status_code, 200)
|
|
291
|
+
node_one = Node.objects.get(mac_address="00:11:22:33:aa:bb")
|
|
292
|
+
self.assertEqual(node_one.public_endpoint, "duplicate-host")
|
|
293
|
+
|
|
294
|
+
second = self.client.post(
|
|
295
|
+
url,
|
|
296
|
+
data={
|
|
297
|
+
"hostname": "duplicate-host",
|
|
298
|
+
"address": "10.0.0.11",
|
|
299
|
+
"port": 8081,
|
|
300
|
+
"mac_address": "00:11:22:33:aa:cc",
|
|
301
|
+
},
|
|
302
|
+
content_type="application/json",
|
|
303
|
+
)
|
|
304
|
+
self.assertEqual(second.status_code, 200)
|
|
305
|
+
node_two = Node.objects.get(mac_address="00:11:22:33:aa:cc")
|
|
306
|
+
|
|
307
|
+
self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
|
|
308
|
+
self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
|
|
309
|
+
|
|
155
310
|
def test_register_node_feature_toggle(self):
|
|
156
311
|
NodeFeature.objects.get_or_create(
|
|
157
312
|
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
@@ -248,6 +403,48 @@ class NodeGetLocalTests(TestCase):
|
|
|
248
403
|
self.assertEqual(subject, "UP friend")
|
|
249
404
|
self.assertEqual(body, "v2.0.0 r123456")
|
|
250
405
|
|
|
406
|
+
def test_register_node_marks_nonrelease_version(self):
|
|
407
|
+
package = Package.objects.create(name="pkg-node", is_active=False)
|
|
408
|
+
PackageRelease.objects.create(
|
|
409
|
+
package=package,
|
|
410
|
+
version="2.0.0",
|
|
411
|
+
revision="0" * 40,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
node = Node.objects.create(
|
|
415
|
+
hostname="friend",
|
|
416
|
+
address="10.1.1.5",
|
|
417
|
+
port=8123,
|
|
418
|
+
mac_address="aa:bb:cc:dd:ee:11",
|
|
419
|
+
installed_version="1.0.0",
|
|
420
|
+
installed_revision="rev-old",
|
|
421
|
+
)
|
|
422
|
+
user = get_user_model().objects.create_user(
|
|
423
|
+
username="node-registrar", password="pwd"
|
|
424
|
+
)
|
|
425
|
+
self.client.force_login(user)
|
|
426
|
+
url = reverse("register-node")
|
|
427
|
+
payload = {
|
|
428
|
+
"hostname": "friend",
|
|
429
|
+
"address": "10.1.1.5",
|
|
430
|
+
"port": 8123,
|
|
431
|
+
"mac_address": "aa:bb:cc:dd:ee:11",
|
|
432
|
+
"installed_version": "2.0.0",
|
|
433
|
+
"installed_revision": "1" * 40,
|
|
434
|
+
}
|
|
435
|
+
with patch("nodes.models.notify_async") as mock_notify:
|
|
436
|
+
response = self.client.post(
|
|
437
|
+
url, data=json.dumps(payload), content_type="application/json"
|
|
438
|
+
)
|
|
439
|
+
self.assertEqual(response.status_code, 200)
|
|
440
|
+
node.refresh_from_db()
|
|
441
|
+
self.assertEqual(node.installed_version, "2.0.0")
|
|
442
|
+
self.assertEqual(node.installed_revision, "1" * 40)
|
|
443
|
+
mock_notify.assert_called_once()
|
|
444
|
+
subject, body = mock_notify.call_args[0]
|
|
445
|
+
self.assertEqual(subject, "UP friend")
|
|
446
|
+
self.assertEqual(body, "v2.0.0+ r111111")
|
|
447
|
+
|
|
251
448
|
def test_register_node_update_without_version_change_still_notifies(self):
|
|
252
449
|
node = Node.objects.create(
|
|
253
450
|
hostname="friend",
|
|
@@ -441,6 +638,64 @@ class NodeGetLocalTests(TestCase):
|
|
|
441
638
|
self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
|
|
442
639
|
|
|
443
640
|
|
|
641
|
+
class RegisterVisitorNodeMessageTests(TestCase):
|
|
642
|
+
def setUp(self):
|
|
643
|
+
self.client = Client()
|
|
644
|
+
User = get_user_model()
|
|
645
|
+
self.user = User.objects.create_user(username="visitor", password="pwd")
|
|
646
|
+
self.client.force_login(self.user)
|
|
647
|
+
self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
648
|
+
self.visitor = Node.objects.create(
|
|
649
|
+
hostname="visitor-node",
|
|
650
|
+
address="10.0.0.100",
|
|
651
|
+
port=8000,
|
|
652
|
+
mac_address="00:10:20:30:40:50",
|
|
653
|
+
role=self.role,
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
def test_register_node_emits_join_message_when_upstream_added(self):
|
|
657
|
+
payload = {
|
|
658
|
+
"hostname": "host-node",
|
|
659
|
+
"address": "10.0.0.10",
|
|
660
|
+
"port": 8100,
|
|
661
|
+
"mac_address": "aa:bb:cc:dd:ee:01",
|
|
662
|
+
"current_relation": "Upstream",
|
|
663
|
+
}
|
|
664
|
+
with patch("nodes.views.Node.get_local", return_value=self.visitor), patch.object(
|
|
665
|
+
NetMessage, "broadcast"
|
|
666
|
+
) as mock_broadcast:
|
|
667
|
+
response = self.client.post(
|
|
668
|
+
reverse("register-node"),
|
|
669
|
+
data=json.dumps(payload),
|
|
670
|
+
content_type="application/json",
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
self.assertEqual(response.status_code, 200)
|
|
674
|
+
mock_broadcast.assert_called_once_with(
|
|
675
|
+
subject="NODE visitor-node", body="JOINS host-node"
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
def test_register_node_skips_message_when_not_upstream(self):
|
|
679
|
+
payload = {
|
|
680
|
+
"hostname": "remote-node",
|
|
681
|
+
"address": "10.0.0.11",
|
|
682
|
+
"port": 8101,
|
|
683
|
+
"mac_address": "aa:bb:cc:dd:ee:02",
|
|
684
|
+
"current_relation": "Downstream",
|
|
685
|
+
}
|
|
686
|
+
with patch("nodes.views.Node.get_local", return_value=self.visitor), patch.object(
|
|
687
|
+
NetMessage, "broadcast"
|
|
688
|
+
) as mock_broadcast:
|
|
689
|
+
response = self.client.post(
|
|
690
|
+
reverse("register-node"),
|
|
691
|
+
data=json.dumps(payload),
|
|
692
|
+
content_type="application/json",
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
self.assertEqual(response.status_code, 200)
|
|
696
|
+
mock_broadcast.assert_not_called()
|
|
697
|
+
|
|
698
|
+
|
|
444
699
|
class NodeRegisterCurrentTests(TestCase):
|
|
445
700
|
def setUp(self):
|
|
446
701
|
User = get_user_model()
|
|
@@ -752,6 +1007,17 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
752
1007
|
mac_address="00:11:22:33:44:cc",
|
|
753
1008
|
public_key=public_key,
|
|
754
1009
|
)
|
|
1010
|
+
target_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1011
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1012
|
+
slug="net-message", defaults={"display": "Net Message"}
|
|
1013
|
+
)
|
|
1014
|
+
target = Node.objects.create(
|
|
1015
|
+
hostname="target",
|
|
1016
|
+
address="10.0.0.2",
|
|
1017
|
+
port=8001,
|
|
1018
|
+
mac_address="00:11:22:33:44:dd",
|
|
1019
|
+
role=target_role,
|
|
1020
|
+
)
|
|
755
1021
|
msg_id = str(uuid.uuid4())
|
|
756
1022
|
payload = {
|
|
757
1023
|
"uuid": msg_id,
|
|
@@ -760,6 +1026,12 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
760
1026
|
"seen": [],
|
|
761
1027
|
"sender": str(sender.uuid),
|
|
762
1028
|
"origin": str(sender.uuid),
|
|
1029
|
+
"filter_node": str(target.uuid),
|
|
1030
|
+
"filter_node_feature": feature.slug,
|
|
1031
|
+
"filter_node_role": target_role.name,
|
|
1032
|
+
"filter_current_relation": Node.Relation.UPSTREAM,
|
|
1033
|
+
"filter_installed_version": "1.0.0",
|
|
1034
|
+
"filter_installed_revision": "rev123",
|
|
763
1035
|
}
|
|
764
1036
|
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
765
1037
|
signature = key.sign(payload_json.encode(), padding.PKCS1v15(), hashes.SHA256())
|
|
@@ -773,6 +1045,12 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
773
1045
|
self.assertTrue(NetMessage.objects.filter(uuid=msg_id).exists())
|
|
774
1046
|
message = NetMessage.objects.get(uuid=msg_id)
|
|
775
1047
|
self.assertEqual(message.node_origin, sender)
|
|
1048
|
+
self.assertEqual(message.filter_node, target)
|
|
1049
|
+
self.assertEqual(message.filter_node_feature, feature)
|
|
1050
|
+
self.assertEqual(message.filter_node_role, target_role)
|
|
1051
|
+
self.assertEqual(message.filter_current_relation, Node.Relation.UPSTREAM)
|
|
1052
|
+
self.assertEqual(message.filter_installed_version, "1.0.0")
|
|
1053
|
+
self.assertEqual(message.filter_installed_revision, "rev123")
|
|
776
1054
|
|
|
777
1055
|
def test_clipboard_polling_creates_task(self):
|
|
778
1056
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -833,6 +1111,45 @@ class NodeAdminTests(TestCase):
|
|
|
833
1111
|
if security_dir.exists():
|
|
834
1112
|
shutil.rmtree(security_dir)
|
|
835
1113
|
|
|
1114
|
+
def _create_local_node(self):
|
|
1115
|
+
return Node.objects.create(
|
|
1116
|
+
hostname="localnode",
|
|
1117
|
+
address="127.0.0.1",
|
|
1118
|
+
port=8000,
|
|
1119
|
+
mac_address=Node.get_current_mac(),
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
def test_node_feature_list_shows_default_action_when_enabled(self):
|
|
1123
|
+
node = self._create_local_node()
|
|
1124
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1125
|
+
slug="rfid-scanner", defaults={"display": "RFID Scanner"}
|
|
1126
|
+
)
|
|
1127
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1128
|
+
response = self.client.get(reverse("admin:nodes_nodefeature_changelist"))
|
|
1129
|
+
action_url = reverse("admin:core_rfid_scan")
|
|
1130
|
+
self.assertContains(response, f'href="{action_url}"')
|
|
1131
|
+
|
|
1132
|
+
def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
|
|
1133
|
+
node = self._create_local_node()
|
|
1134
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1135
|
+
slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
|
|
1136
|
+
)
|
|
1137
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1138
|
+
response = self.client.get(reverse("admin:nodes_nodefeature_changelist"))
|
|
1139
|
+
snapshot_url = reverse("admin:nodes_nodefeature_take_snapshot")
|
|
1140
|
+
stream_url = reverse("admin:nodes_nodefeature_view_stream")
|
|
1141
|
+
self.assertContains(response, f'href="{snapshot_url}"')
|
|
1142
|
+
self.assertContains(response, f'href="{stream_url}"')
|
|
1143
|
+
|
|
1144
|
+
def test_node_feature_list_hides_default_action_when_disabled(self):
|
|
1145
|
+
self._create_local_node()
|
|
1146
|
+
NodeFeature.objects.get_or_create(
|
|
1147
|
+
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
1148
|
+
)
|
|
1149
|
+
response = self.client.get(reverse("admin:nodes_nodefeature_changelist"))
|
|
1150
|
+
action_url = reverse("admin:nodes_nodefeature_take_screenshot")
|
|
1151
|
+
self.assertNotContains(response, f'href="{action_url}"')
|
|
1152
|
+
|
|
836
1153
|
def test_register_current_host(self):
|
|
837
1154
|
url = reverse("admin:nodes_node_register_current")
|
|
838
1155
|
hostname = socket.gethostname()
|
|
@@ -890,6 +1207,34 @@ class NodeAdminTests(TestCase):
|
|
|
890
1207
|
)
|
|
891
1208
|
self.assertIn(node.public_key.strip(), resp.content.decode())
|
|
892
1209
|
|
|
1210
|
+
def test_register_current_requires_superuser(self):
|
|
1211
|
+
User = get_user_model()
|
|
1212
|
+
staff = User.objects.create_user(
|
|
1213
|
+
username="staff", password="pass", is_staff=True
|
|
1214
|
+
)
|
|
1215
|
+
permission = Permission.objects.get(codename="view_node")
|
|
1216
|
+
staff.user_permissions.add(permission)
|
|
1217
|
+
self.client.force_login(staff)
|
|
1218
|
+
|
|
1219
|
+
response = self.client.get(reverse("admin:nodes_node_register_current"))
|
|
1220
|
+
|
|
1221
|
+
self.assertEqual(response.status_code, 403)
|
|
1222
|
+
|
|
1223
|
+
def test_register_current_link_hidden_for_non_superusers(self):
|
|
1224
|
+
User = get_user_model()
|
|
1225
|
+
staff = User.objects.create_user(
|
|
1226
|
+
username="linkstaff", password="pass", is_staff=True
|
|
1227
|
+
)
|
|
1228
|
+
permission = Permission.objects.get(codename="view_node")
|
|
1229
|
+
staff.user_permissions.add(permission)
|
|
1230
|
+
self.client.force_login(staff)
|
|
1231
|
+
|
|
1232
|
+
response = self.client.get(reverse("admin:nodes_node_changelist"))
|
|
1233
|
+
|
|
1234
|
+
self.assertNotContains(
|
|
1235
|
+
response, reverse("admin:nodes_node_register_current")
|
|
1236
|
+
)
|
|
1237
|
+
|
|
893
1238
|
@patch("nodes.admin.capture_screenshot")
|
|
894
1239
|
def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
|
|
895
1240
|
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
@@ -967,6 +1312,349 @@ class NodeAdminTests(TestCase):
|
|
|
967
1312
|
samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
|
|
968
1313
|
self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
|
|
969
1314
|
|
|
1315
|
+
@patch("nodes.admin.capture_screenshot")
|
|
1316
|
+
def test_take_screenshot_default_action_creates_sample(
|
|
1317
|
+
self, mock_capture_screenshot
|
|
1318
|
+
):
|
|
1319
|
+
node = self._create_local_node()
|
|
1320
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1321
|
+
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
1322
|
+
)
|
|
1323
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1324
|
+
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
1325
|
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
1326
|
+
file_path = screenshot_dir / "default.png"
|
|
1327
|
+
file_path.write_bytes(b"default")
|
|
1328
|
+
mock_capture_screenshot.return_value = Path("screenshots/default.png")
|
|
1329
|
+
response = self.client.get(
|
|
1330
|
+
reverse("admin:nodes_nodefeature_take_screenshot"), follow=True
|
|
1331
|
+
)
|
|
1332
|
+
self.assertEqual(response.status_code, 200)
|
|
1333
|
+
sample = ContentSample.objects.get(kind=ContentSample.IMAGE)
|
|
1334
|
+
self.assertEqual(sample.node, node)
|
|
1335
|
+
self.assertEqual(sample.method, "DEFAULT_ACTION")
|
|
1336
|
+
mock_capture_screenshot.assert_called_once_with("http://testserver/")
|
|
1337
|
+
change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
|
|
1338
|
+
self.assertEqual(response.redirect_chain[-1][0], change_url)
|
|
1339
|
+
|
|
1340
|
+
def test_check_features_for_eligibility_action_success(self):
|
|
1341
|
+
node = self._create_local_node()
|
|
1342
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1343
|
+
slug="rfid-scanner", defaults={"display": "RFID Scanner"}
|
|
1344
|
+
)
|
|
1345
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1346
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1347
|
+
response = self.client.post(
|
|
1348
|
+
changelist_url,
|
|
1349
|
+
{
|
|
1350
|
+
"action": "check_features_for_eligibility",
|
|
1351
|
+
"_selected_action": [str(feature.pk)],
|
|
1352
|
+
},
|
|
1353
|
+
follow=True,
|
|
1354
|
+
)
|
|
1355
|
+
self.assertEqual(response.status_code, 200)
|
|
1356
|
+
self.assertContains(
|
|
1357
|
+
response,
|
|
1358
|
+
"RFID Scanner is enabled on localnode. This feature cannot be enabled manually.",
|
|
1359
|
+
html=False,
|
|
1360
|
+
)
|
|
1361
|
+
self.assertContains(
|
|
1362
|
+
response, "Completed 1 of 1 feature check(s) successfully.", html=False
|
|
1363
|
+
)
|
|
1364
|
+
|
|
1365
|
+
def test_check_features_for_eligibility_action_warns_when_disabled(self):
|
|
1366
|
+
self._create_local_node()
|
|
1367
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1368
|
+
slug="rfid-scanner", defaults={"display": "RFID Scanner"}
|
|
1369
|
+
)
|
|
1370
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1371
|
+
response = self.client.post(
|
|
1372
|
+
changelist_url,
|
|
1373
|
+
{
|
|
1374
|
+
"action": "check_features_for_eligibility",
|
|
1375
|
+
"_selected_action": [str(feature.pk)],
|
|
1376
|
+
},
|
|
1377
|
+
follow=True,
|
|
1378
|
+
)
|
|
1379
|
+
self.assertEqual(response.status_code, 200)
|
|
1380
|
+
self.assertContains(
|
|
1381
|
+
response,
|
|
1382
|
+
"RFID Scanner is not enabled on localnode. This feature cannot be enabled manually.",
|
|
1383
|
+
html=False,
|
|
1384
|
+
)
|
|
1385
|
+
self.assertContains(
|
|
1386
|
+
response, "Completed 0 of 1 feature check(s) successfully.", html=False
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
def test_enable_selected_features_enables_manual_feature(self):
|
|
1390
|
+
node = self._create_local_node()
|
|
1391
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1392
|
+
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
1393
|
+
)
|
|
1394
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1395
|
+
response = self.client.post(
|
|
1396
|
+
changelist_url,
|
|
1397
|
+
{
|
|
1398
|
+
"action": "enable_selected_features",
|
|
1399
|
+
"_selected_action": [str(feature.pk)],
|
|
1400
|
+
},
|
|
1401
|
+
follow=True,
|
|
1402
|
+
)
|
|
1403
|
+
self.assertEqual(response.status_code, 200)
|
|
1404
|
+
self.assertTrue(Node.objects.get(pk=node.pk).has_feature("screenshot-poll"))
|
|
1405
|
+
self.assertContains(
|
|
1406
|
+
response, "Enabled 1 feature(s): Screenshot Poll", html=False
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
def test_enable_selected_features_warns_for_non_manual(self):
|
|
1410
|
+
self._create_local_node()
|
|
1411
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1412
|
+
slug="rfid-scanner", defaults={"display": "RFID Scanner"}
|
|
1413
|
+
)
|
|
1414
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1415
|
+
response = self.client.post(
|
|
1416
|
+
changelist_url,
|
|
1417
|
+
{
|
|
1418
|
+
"action": "enable_selected_features",
|
|
1419
|
+
"_selected_action": [str(feature.pk)],
|
|
1420
|
+
},
|
|
1421
|
+
follow=True,
|
|
1422
|
+
)
|
|
1423
|
+
self.assertEqual(response.status_code, 200)
|
|
1424
|
+
self.assertContains(
|
|
1425
|
+
response, "RFID Scanner cannot be enabled manually.", html=False
|
|
1426
|
+
)
|
|
1427
|
+
self.assertContains(
|
|
1428
|
+
response,
|
|
1429
|
+
"None of the selected features can be enabled manually.",
|
|
1430
|
+
html=False,
|
|
1431
|
+
)
|
|
1432
|
+
|
|
1433
|
+
def test_take_screenshot_default_action_requires_enabled_feature(self):
|
|
1434
|
+
self._create_local_node()
|
|
1435
|
+
NodeFeature.objects.get_or_create(
|
|
1436
|
+
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
1437
|
+
)
|
|
1438
|
+
response = self.client.get(
|
|
1439
|
+
reverse("admin:nodes_nodefeature_take_screenshot"), follow=True
|
|
1440
|
+
)
|
|
1441
|
+
self.assertEqual(response.status_code, 200)
|
|
1442
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1443
|
+
self.assertEqual(response.wsgi_request.path, changelist_url)
|
|
1444
|
+
self.assertEqual(ContentSample.objects.count(), 0)
|
|
1445
|
+
self.assertContains(response, "Screenshot Poll feature is not enabled")
|
|
1446
|
+
|
|
1447
|
+
@patch("nodes.admin.capture_rpi_snapshot")
|
|
1448
|
+
def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
|
|
1449
|
+
node = self._create_local_node()
|
|
1450
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1451
|
+
slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
|
|
1452
|
+
)
|
|
1453
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1454
|
+
camera_dir = settings.LOG_DIR / "camera"
|
|
1455
|
+
camera_dir.mkdir(parents=True, exist_ok=True)
|
|
1456
|
+
file_path = camera_dir / "snap.jpg"
|
|
1457
|
+
file_path.write_bytes(b"camera")
|
|
1458
|
+
mock_snapshot.return_value = file_path
|
|
1459
|
+
response = self.client.get(
|
|
1460
|
+
reverse("admin:nodes_nodefeature_take_snapshot"), follow=True
|
|
1461
|
+
)
|
|
1462
|
+
self.assertEqual(response.status_code, 200)
|
|
1463
|
+
sample = ContentSample.objects.get(kind=ContentSample.IMAGE)
|
|
1464
|
+
self.assertEqual(sample.node, node)
|
|
1465
|
+
self.assertEqual(sample.method, "RPI_CAMERA")
|
|
1466
|
+
change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
|
|
1467
|
+
self.assertEqual(response.redirect_chain[-1][0], change_url)
|
|
1468
|
+
|
|
1469
|
+
def test_view_stream_requires_enabled_feature(self):
|
|
1470
|
+
self._create_local_node()
|
|
1471
|
+
NodeFeature.objects.get_or_create(
|
|
1472
|
+
slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
|
|
1473
|
+
)
|
|
1474
|
+
response = self.client.get(
|
|
1475
|
+
reverse("admin:nodes_nodefeature_view_stream"), follow=True
|
|
1476
|
+
)
|
|
1477
|
+
self.assertEqual(response.status_code, 200)
|
|
1478
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1479
|
+
self.assertEqual(response.wsgi_request.path, changelist_url)
|
|
1480
|
+
self.assertContains(
|
|
1481
|
+
response, "Raspberry Pi Camera feature is not enabled on this node."
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
def test_view_stream_renders_when_feature_enabled(self):
|
|
1485
|
+
node = self._create_local_node()
|
|
1486
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1487
|
+
slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
|
|
1488
|
+
)
|
|
1489
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1490
|
+
response = self.client.get(reverse("admin:nodes_nodefeature_view_stream"))
|
|
1491
|
+
self.assertEqual(response.status_code, 200)
|
|
1492
|
+
response.render()
|
|
1493
|
+
expected_stream = "http://testserver:8554/"
|
|
1494
|
+
self.assertEqual(response.context_data["stream_url"], expected_stream)
|
|
1495
|
+
self.assertContains(response, expected_stream)
|
|
1496
|
+
self.assertContains(response, "camera-stream__frame")
|
|
1497
|
+
|
|
1498
|
+
def test_view_stream_uses_configured_stream_url(self):
|
|
1499
|
+
node = self._create_local_node()
|
|
1500
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1501
|
+
slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
|
|
1502
|
+
)
|
|
1503
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1504
|
+
configured_stream = "https://camera.local/stream"
|
|
1505
|
+
with self.settings(RPI_CAMERA_STREAM_URL=configured_stream):
|
|
1506
|
+
response = self.client.get(
|
|
1507
|
+
reverse("admin:nodes_nodefeature_view_stream")
|
|
1508
|
+
)
|
|
1509
|
+
self.assertEqual(response.status_code, 200)
|
|
1510
|
+
response.render()
|
|
1511
|
+
self.assertEqual(response.context_data["stream_url"], configured_stream)
|
|
1512
|
+
self.assertContains(response, configured_stream)
|
|
1513
|
+
|
|
1514
|
+
@patch("nodes.admin.requests.post")
|
|
1515
|
+
def test_fetch_rfids_action_fetches_and_imports(self, mock_post):
|
|
1516
|
+
local = self._create_local_node()
|
|
1517
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
1518
|
+
private_bytes = key.private_bytes(
|
|
1519
|
+
encoding=serialization.Encoding.PEM,
|
|
1520
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
1521
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
1522
|
+
)
|
|
1523
|
+
public_bytes = key.public_key().public_bytes(
|
|
1524
|
+
encoding=serialization.Encoding.PEM,
|
|
1525
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
1526
|
+
)
|
|
1527
|
+
security_dir = Path(settings.BASE_DIR) / "security"
|
|
1528
|
+
security_dir.mkdir(parents=True, exist_ok=True)
|
|
1529
|
+
(security_dir / f"{local.public_endpoint}").write_bytes(private_bytes)
|
|
1530
|
+
(security_dir / f"{local.public_endpoint}.pub").write_bytes(public_bytes)
|
|
1531
|
+
local.public_key = public_bytes.decode()
|
|
1532
|
+
local.save(update_fields=["public_key"])
|
|
1533
|
+
|
|
1534
|
+
remote = Node.objects.create(
|
|
1535
|
+
hostname="remote",
|
|
1536
|
+
address="127.0.0.2",
|
|
1537
|
+
port=8010,
|
|
1538
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
mock_response = MagicMock()
|
|
1542
|
+
mock_response.status_code = 200
|
|
1543
|
+
mock_response.json.return_value = {
|
|
1544
|
+
"rfids": [
|
|
1545
|
+
{
|
|
1546
|
+
"rfid": "abc123",
|
|
1547
|
+
"custom_label": "Remote tag",
|
|
1548
|
+
"key_a": "A1B2C3D4E5F6",
|
|
1549
|
+
"key_b": "FFFFFFFFFFFF",
|
|
1550
|
+
"data": ["sector"],
|
|
1551
|
+
"key_a_verified": True,
|
|
1552
|
+
"key_b_verified": False,
|
|
1553
|
+
"allowed": True,
|
|
1554
|
+
"color": RFID.BLACK,
|
|
1555
|
+
"kind": RFID.CLASSIC,
|
|
1556
|
+
"released": False,
|
|
1557
|
+
"last_seen_on": None,
|
|
1558
|
+
}
|
|
1559
|
+
]
|
|
1560
|
+
}
|
|
1561
|
+
mock_response.text = ""
|
|
1562
|
+
mock_post.return_value = mock_response
|
|
1563
|
+
|
|
1564
|
+
response = self.client.post(
|
|
1565
|
+
reverse("admin:nodes_node_changelist"),
|
|
1566
|
+
{"action": "fetch_rfids", "_selected_action": [str(remote.pk)]},
|
|
1567
|
+
follow=True,
|
|
1568
|
+
)
|
|
1569
|
+
self.assertEqual(response.status_code, 200)
|
|
1570
|
+
self.assertTrue(RFID.objects.filter(rfid="ABC123").exists())
|
|
1571
|
+
tag = RFID.objects.get(rfid="ABC123")
|
|
1572
|
+
self.assertEqual(tag.custom_label, "Remote tag")
|
|
1573
|
+
self.assertEqual(tag.origin_node, remote)
|
|
1574
|
+
self.assertEqual(tag.data, ["sector"])
|
|
1575
|
+
|
|
1576
|
+
self.assertTrue(mock_post.called)
|
|
1577
|
+
call_kwargs = mock_post.call_args.kwargs
|
|
1578
|
+
payload = call_kwargs["data"]
|
|
1579
|
+
headers = call_kwargs["headers"]
|
|
1580
|
+
signature = base64.b64decode(headers["X-Signature"])
|
|
1581
|
+
key.public_key().verify(
|
|
1582
|
+
signature,
|
|
1583
|
+
payload.encode(),
|
|
1584
|
+
padding.PKCS1v15(),
|
|
1585
|
+
hashes.SHA256(),
|
|
1586
|
+
)
|
|
1587
|
+
self.assertContains(response, "Fetched RFIDs from 1 node(s)")
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
class RFIDExportViewTests(TestCase):
|
|
1591
|
+
def setUp(self):
|
|
1592
|
+
self.client = Client()
|
|
1593
|
+
NodeRole.objects.get_or_create(name="Terminal")
|
|
1594
|
+
self.local_node = Node.objects.create(
|
|
1595
|
+
hostname="local",
|
|
1596
|
+
address="127.0.0.1",
|
|
1597
|
+
port=8000,
|
|
1598
|
+
mac_address=Node.get_current_mac(),
|
|
1599
|
+
)
|
|
1600
|
+
self.remote_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
1601
|
+
self.remote_public = (
|
|
1602
|
+
self.remote_key.public_key()
|
|
1603
|
+
.public_bytes(
|
|
1604
|
+
encoding=serialization.Encoding.PEM,
|
|
1605
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
1606
|
+
)
|
|
1607
|
+
.decode()
|
|
1608
|
+
)
|
|
1609
|
+
self.remote_node = Node.objects.create(
|
|
1610
|
+
hostname="remote",
|
|
1611
|
+
address="10.0.0.2",
|
|
1612
|
+
port=8100,
|
|
1613
|
+
mac_address="00:11:22:33:44:55",
|
|
1614
|
+
public_key=self.remote_public,
|
|
1615
|
+
)
|
|
1616
|
+
|
|
1617
|
+
def _sign_payload(self, payload):
|
|
1618
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
1619
|
+
signature = self.remote_key.sign(
|
|
1620
|
+
payload_json.encode(),
|
|
1621
|
+
padding.PKCS1v15(),
|
|
1622
|
+
hashes.SHA256(),
|
|
1623
|
+
)
|
|
1624
|
+
return payload_json, base64.b64encode(signature).decode()
|
|
1625
|
+
|
|
1626
|
+
def test_export_requires_signature(self):
|
|
1627
|
+
payload_json = json.dumps(
|
|
1628
|
+
{"requester": str(self.remote_node.uuid)},
|
|
1629
|
+
separators=(",", ":"),
|
|
1630
|
+
sort_keys=True,
|
|
1631
|
+
)
|
|
1632
|
+
response = self.client.post(
|
|
1633
|
+
reverse("node-rfid-export"),
|
|
1634
|
+
data=payload_json,
|
|
1635
|
+
content_type="application/json",
|
|
1636
|
+
)
|
|
1637
|
+
self.assertEqual(response.status_code, 403)
|
|
1638
|
+
|
|
1639
|
+
def test_export_returns_serialized_data(self):
|
|
1640
|
+
RFID.objects.create(rfid="ABCDEF")
|
|
1641
|
+
payload_json, signature = self._sign_payload(
|
|
1642
|
+
{"requester": str(self.remote_node.uuid)}
|
|
1643
|
+
)
|
|
1644
|
+
response = self.client.post(
|
|
1645
|
+
reverse("node-rfid-export"),
|
|
1646
|
+
data=payload_json,
|
|
1647
|
+
content_type="application/json",
|
|
1648
|
+
HTTP_X_SIGNATURE=signature,
|
|
1649
|
+
)
|
|
1650
|
+
self.assertEqual(response.status_code, 200)
|
|
1651
|
+
body = response.json()
|
|
1652
|
+
self.assertIn("rfids", body)
|
|
1653
|
+
self.assertEqual(len(body["rfids"]), 1)
|
|
1654
|
+
tag_data = body["rfids"][0]
|
|
1655
|
+
self.assertEqual(tag_data["rfid"], "ABCDEF")
|
|
1656
|
+
self.assertIn("custom_label", tag_data)
|
|
1657
|
+
|
|
970
1658
|
|
|
971
1659
|
class NetMessageAdminTests(TransactionTestCase):
|
|
972
1660
|
reset_sequences = True
|
|
@@ -999,6 +1687,27 @@ class NetMessageAdminTests(TransactionTestCase):
|
|
|
999
1687
|
self.assertEqual(response.status_code, 302)
|
|
1000
1688
|
mock_propagate.assert_called_once()
|
|
1001
1689
|
|
|
1690
|
+
def test_reply_action_prefills_initial_data(self):
|
|
1691
|
+
role = NodeRole.objects.get(name="Terminal")
|
|
1692
|
+
node = Node.objects.create(
|
|
1693
|
+
hostname="remote",
|
|
1694
|
+
address="10.0.0.10",
|
|
1695
|
+
port=8100,
|
|
1696
|
+
mac_address="00:11:22:33:44:55",
|
|
1697
|
+
role=role,
|
|
1698
|
+
)
|
|
1699
|
+
original = NetMessage.objects.create(
|
|
1700
|
+
subject="Ping",
|
|
1701
|
+
body="Hello",
|
|
1702
|
+
node_origin=node,
|
|
1703
|
+
)
|
|
1704
|
+
url = f"{reverse('admin:nodes_netmessage_add')}?reply_to={original.pk}"
|
|
1705
|
+
response = self.client.get(url)
|
|
1706
|
+
self.assertEqual(response.status_code, 200)
|
|
1707
|
+
form = response.context_data["adminform"].form
|
|
1708
|
+
self.assertEqual(form["subject"].value(), "Re: Ping")
|
|
1709
|
+
self.assertEqual(str(form["filter_node"].value()), str(node.pk))
|
|
1710
|
+
|
|
1002
1711
|
|
|
1003
1712
|
class LastNetMessageViewTests(TestCase):
|
|
1004
1713
|
def setUp(self):
|
|
@@ -1007,10 +1716,19 @@ class LastNetMessageViewTests(TestCase):
|
|
|
1007
1716
|
|
|
1008
1717
|
def test_returns_latest_message(self):
|
|
1009
1718
|
NetMessage.objects.create(subject="old", body="msg1")
|
|
1010
|
-
NetMessage.objects.create(subject="new", body="msg2")
|
|
1719
|
+
latest = NetMessage.objects.create(subject="new", body="msg2")
|
|
1011
1720
|
resp = self.client.get(reverse("last-net-message"))
|
|
1012
1721
|
self.assertEqual(resp.status_code, 200)
|
|
1013
|
-
self.assertEqual(
|
|
1722
|
+
self.assertEqual(
|
|
1723
|
+
resp.json(),
|
|
1724
|
+
{
|
|
1725
|
+
"subject": "new",
|
|
1726
|
+
"body": "msg2",
|
|
1727
|
+
"admin_url": reverse(
|
|
1728
|
+
"admin:nodes_netmessage_change", args=[latest.pk]
|
|
1729
|
+
),
|
|
1730
|
+
},
|
|
1731
|
+
)
|
|
1014
1732
|
|
|
1015
1733
|
|
|
1016
1734
|
class NetMessageReachTests(TestCase):
|
|
@@ -1071,8 +1789,10 @@ class NetMessageReachTests(TestCase):
|
|
|
1071
1789
|
with patch.object(Node, "get_local", return_value=None):
|
|
1072
1790
|
msg.propagate()
|
|
1073
1791
|
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
1074
|
-
self.assertEqual(
|
|
1075
|
-
|
|
1792
|
+
self.assertEqual(
|
|
1793
|
+
roles, {"Constellation", "Satellite", "Control", "Terminal"}
|
|
1794
|
+
)
|
|
1795
|
+
self.assertEqual(mock_post.call_count, 4)
|
|
1076
1796
|
|
|
1077
1797
|
@patch("requests.post")
|
|
1078
1798
|
def test_default_reach_not_limited_to_terminal(self, mock_post):
|
|
@@ -1083,7 +1803,105 @@ class NetMessageReachTests(TestCase):
|
|
|
1083
1803
|
msg.propagate()
|
|
1084
1804
|
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
1085
1805
|
self.assertIn("Control", roles)
|
|
1086
|
-
self.assertEqual(mock_post.call_count,
|
|
1806
|
+
self.assertEqual(mock_post.call_count, 4)
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
class NetMessageFilterTests(TestCase):
|
|
1810
|
+
def setUp(self):
|
|
1811
|
+
self.terminal_role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
1812
|
+
self.control_role, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1813
|
+
self.nodes = {
|
|
1814
|
+
"terminal": Node.objects.create(
|
|
1815
|
+
hostname="terminal-filter",
|
|
1816
|
+
address="10.20.0.1",
|
|
1817
|
+
port=8020,
|
|
1818
|
+
mac_address="00:11:22:33:55:01",
|
|
1819
|
+
role=self.terminal_role,
|
|
1820
|
+
),
|
|
1821
|
+
"control": Node.objects.create(
|
|
1822
|
+
hostname="control-filter",
|
|
1823
|
+
address="10.20.0.2",
|
|
1824
|
+
port=8021,
|
|
1825
|
+
mac_address="00:11:22:33:55:02",
|
|
1826
|
+
role=self.control_role,
|
|
1827
|
+
),
|
|
1828
|
+
}
|
|
1829
|
+
self.feature, _ = NodeFeature.objects.get_or_create(
|
|
1830
|
+
slug="filter-test", defaults={"display": "Filter Test"}
|
|
1831
|
+
)
|
|
1832
|
+
NodeFeatureAssignment.objects.get_or_create(
|
|
1833
|
+
node=self.nodes["control"], feature=self.feature
|
|
1834
|
+
)
|
|
1835
|
+
self.nodes["control"].current_relation = Node.Relation.UPSTREAM
|
|
1836
|
+
self.nodes["control"].installed_version = "1.2.3"
|
|
1837
|
+
self.nodes["control"].installed_revision = "abc123"
|
|
1838
|
+
self.nodes["control"].save(
|
|
1839
|
+
update_fields=[
|
|
1840
|
+
"current_relation",
|
|
1841
|
+
"installed_version",
|
|
1842
|
+
"installed_revision",
|
|
1843
|
+
]
|
|
1844
|
+
)
|
|
1845
|
+
|
|
1846
|
+
@patch("requests.post")
|
|
1847
|
+
def test_filter_node_limits_targets(self, mock_post):
|
|
1848
|
+
msg = NetMessage.objects.create(
|
|
1849
|
+
subject="s", body="b", filter_node=self.nodes["control"]
|
|
1850
|
+
)
|
|
1851
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
1852
|
+
msg.propagate()
|
|
1853
|
+
self.assertEqual(
|
|
1854
|
+
list(msg.propagated_to.values_list("pk", flat=True)),
|
|
1855
|
+
[self.nodes["control"].pk],
|
|
1856
|
+
)
|
|
1857
|
+
mock_post.assert_called_once()
|
|
1858
|
+
|
|
1859
|
+
@patch("requests.post")
|
|
1860
|
+
def test_filter_fields_limit_queryset(self, mock_post):
|
|
1861
|
+
msg = NetMessage.objects.create(
|
|
1862
|
+
subject="s",
|
|
1863
|
+
body="b",
|
|
1864
|
+
filter_node_feature=self.feature,
|
|
1865
|
+
filter_node_role=self.control_role,
|
|
1866
|
+
filter_current_relation=Node.Relation.UPSTREAM,
|
|
1867
|
+
filter_installed_version="1.2.3",
|
|
1868
|
+
filter_installed_revision="abc123",
|
|
1869
|
+
)
|
|
1870
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
1871
|
+
msg.propagate()
|
|
1872
|
+
self.assertEqual(
|
|
1873
|
+
list(msg.propagated_to.values_list("pk", flat=True)),
|
|
1874
|
+
[self.nodes["control"].pk],
|
|
1875
|
+
)
|
|
1876
|
+
mock_post.assert_called_once()
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
class NetMessageBroadcastStringReachTests(TestCase):
|
|
1880
|
+
def test_broadcast_uses_role_lookup_for_string_reach(self):
|
|
1881
|
+
role = NodeRole.objects.create(name="Terminal")
|
|
1882
|
+
local = Node.objects.create(
|
|
1883
|
+
hostname="terminal-local",
|
|
1884
|
+
address="10.10.0.1",
|
|
1885
|
+
port=8010,
|
|
1886
|
+
mac_address="00:aa:bb:cc:dd:ff",
|
|
1887
|
+
role=role,
|
|
1888
|
+
public_endpoint="terminal-local",
|
|
1889
|
+
)
|
|
1890
|
+
seen = ["existing"]
|
|
1891
|
+
|
|
1892
|
+
with patch.object(Node, "get_local", return_value=local), patch.object(
|
|
1893
|
+
NetMessage, "propagate"
|
|
1894
|
+
) as mock_propagate:
|
|
1895
|
+
msg = NetMessage.broadcast(
|
|
1896
|
+
"Subject", "Body", reach="Terminal", seen=seen
|
|
1897
|
+
)
|
|
1898
|
+
|
|
1899
|
+
self.assertEqual(msg.reach, role)
|
|
1900
|
+
self.assertEqual(msg.node_origin, local)
|
|
1901
|
+
mock_propagate.assert_called_once()
|
|
1902
|
+
called_args = mock_propagate.call_args
|
|
1903
|
+
self.assertIn("seen", called_args.kwargs)
|
|
1904
|
+
self.assertIs(called_args.kwargs["seen"], seen)
|
|
1087
1905
|
|
|
1088
1906
|
|
|
1089
1907
|
class NetMessagePropagationTests(TestCase):
|
|
@@ -1138,6 +1956,29 @@ class NetMessagePropagationTests(TestCase):
|
|
|
1138
1956
|
self.assertEqual(msg.propagated_to.count(), 4)
|
|
1139
1957
|
self.assertTrue(msg.complete)
|
|
1140
1958
|
|
|
1959
|
+
@patch("requests.post")
|
|
1960
|
+
@patch("core.notifications.notify", return_value=False)
|
|
1961
|
+
def test_propagate_defaults_to_six_when_available(
|
|
1962
|
+
self, mock_notify, mock_post
|
|
1963
|
+
):
|
|
1964
|
+
for idx in range(6, 12):
|
|
1965
|
+
self.remotes.append(
|
|
1966
|
+
Node.objects.create(
|
|
1967
|
+
hostname=f"n{idx}",
|
|
1968
|
+
address=f"10.0.0.{idx}",
|
|
1969
|
+
port=8000 + idx,
|
|
1970
|
+
mac_address=f"00:11:22:33:44:{idx:02x}",
|
|
1971
|
+
role=self.role,
|
|
1972
|
+
public_endpoint=f"n{idx}",
|
|
1973
|
+
)
|
|
1974
|
+
)
|
|
1975
|
+
msg = NetMessage.objects.create(subject="s", body="b", reach=self.role)
|
|
1976
|
+
with patch.object(Node, "get_local", return_value=self.local):
|
|
1977
|
+
msg.propagate()
|
|
1978
|
+
self.assertEqual(mock_post.call_count, 6)
|
|
1979
|
+
self.assertEqual(msg.propagated_to.count(), 6)
|
|
1980
|
+
self.assertFalse(msg.complete)
|
|
1981
|
+
|
|
1141
1982
|
@patch("requests.post")
|
|
1142
1983
|
@patch("core.notifications.notify", return_value=True)
|
|
1143
1984
|
def test_propagate_prunes_old_local_messages(self, mock_notify, mock_post):
|
|
@@ -1271,6 +2112,36 @@ class StartupNotificationTests(TestCase):
|
|
|
1271
2112
|
self.assertEqual(kwargs["subject"], "host:9000")
|
|
1272
2113
|
self.assertTrue(kwargs["body"].startswith("1.2.3 r"))
|
|
1273
2114
|
|
|
2115
|
+
def test_startup_notification_marks_nonrelease_version(self):
|
|
2116
|
+
from nodes.apps import _startup_notification
|
|
2117
|
+
|
|
2118
|
+
package = Package.objects.create(name="pkg-start", is_active=False)
|
|
2119
|
+
PackageRelease.objects.create(
|
|
2120
|
+
package=package,
|
|
2121
|
+
version="1.2.3",
|
|
2122
|
+
revision="0" * 40,
|
|
2123
|
+
)
|
|
2124
|
+
|
|
2125
|
+
with TemporaryDirectory() as tmp:
|
|
2126
|
+
tmp_path = Path(tmp)
|
|
2127
|
+
(tmp_path / "VERSION").write_text("1.2.3")
|
|
2128
|
+
with self.settings(BASE_DIR=tmp_path):
|
|
2129
|
+
with patch(
|
|
2130
|
+
"nodes.apps.revision.get_revision", return_value="1" * 40
|
|
2131
|
+
):
|
|
2132
|
+
with patch("nodes.models.NetMessage.broadcast") as mock_broadcast:
|
|
2133
|
+
with patch(
|
|
2134
|
+
"nodes.apps.socket.gethostname", return_value="host"
|
|
2135
|
+
):
|
|
2136
|
+
with patch.dict(os.environ, {"PORT": "9000"}):
|
|
2137
|
+
_startup_notification()
|
|
2138
|
+
time.sleep(0.1)
|
|
2139
|
+
|
|
2140
|
+
mock_broadcast.assert_called_once()
|
|
2141
|
+
_, kwargs = mock_broadcast.call_args
|
|
2142
|
+
self.assertEqual(kwargs["subject"], "host:9000")
|
|
2143
|
+
self.assertEqual(kwargs["body"], "1.2.3+ r111111")
|
|
2144
|
+
|
|
1274
2145
|
|
|
1275
2146
|
class StartupHandlerTests(TestCase):
|
|
1276
2147
|
def test_handler_logs_db_errors(self):
|
|
@@ -1494,6 +2365,16 @@ class EmailOutboxTests(TestCase):
|
|
|
1494
2365
|
|
|
1495
2366
|
self.assertEqual(str(outbox), "mailer@example.com")
|
|
1496
2367
|
|
|
2368
|
+
def test_string_representation_trims_trailing_at_symbol(self):
|
|
2369
|
+
outbox = EmailOutbox.objects.create(
|
|
2370
|
+
host="smtp.example.com",
|
|
2371
|
+
port=587,
|
|
2372
|
+
username="mailer@",
|
|
2373
|
+
password="secret",
|
|
2374
|
+
)
|
|
2375
|
+
|
|
2376
|
+
self.assertEqual(str(outbox), "mailer@smtp.example.com")
|
|
2377
|
+
|
|
1497
2378
|
def test_unattached_outbox_used_as_fallback(self):
|
|
1498
2379
|
EmailOutbox.objects.create(
|
|
1499
2380
|
group=SecurityGroup.objects.create(name="Attached"),
|
|
@@ -1713,6 +2594,45 @@ class NodeFeatureTests(TestCase):
|
|
|
1713
2594
|
role=self.role,
|
|
1714
2595
|
)
|
|
1715
2596
|
|
|
2597
|
+
def test_default_action_mapping_for_known_feature(self):
|
|
2598
|
+
feature = NodeFeature.objects.create(
|
|
2599
|
+
slug="rfid-scanner", display="RFID Scanner"
|
|
2600
|
+
)
|
|
2601
|
+
actions = feature.get_default_actions()
|
|
2602
|
+
self.assertEqual(len(actions), 1)
|
|
2603
|
+
action = actions[0]
|
|
2604
|
+
self.assertEqual(action.label, "Scan RFIDs")
|
|
2605
|
+
self.assertEqual(action.url_name, "admin:core_rfid_scan")
|
|
2606
|
+
self.assertEqual(feature.get_default_action(), action)
|
|
2607
|
+
|
|
2608
|
+
def test_celery_feature_default_action(self):
|
|
2609
|
+
feature = NodeFeature.objects.create(
|
|
2610
|
+
slug="celery-queue", display="Celery Queue"
|
|
2611
|
+
)
|
|
2612
|
+
actions = feature.get_default_actions()
|
|
2613
|
+
self.assertEqual(len(actions), 1)
|
|
2614
|
+
action = actions[0]
|
|
2615
|
+
self.assertEqual(action.label, "Celery Report")
|
|
2616
|
+
self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
|
|
2617
|
+
self.assertEqual(feature.get_default_action(), action)
|
|
2618
|
+
|
|
2619
|
+
def test_rpi_camera_feature_has_multiple_actions(self):
|
|
2620
|
+
feature = NodeFeature.objects.create(
|
|
2621
|
+
slug="rpi-camera", display="Raspberry Pi Camera"
|
|
2622
|
+
)
|
|
2623
|
+
actions = feature.get_default_actions()
|
|
2624
|
+
self.assertEqual(len(actions), 2)
|
|
2625
|
+
labels = {action.label for action in actions}
|
|
2626
|
+
self.assertIn("Take a Snapshot", labels)
|
|
2627
|
+
self.assertIn("View stream", labels)
|
|
2628
|
+
|
|
2629
|
+
def test_default_action_missing_when_unconfigured(self):
|
|
2630
|
+
feature = NodeFeature.objects.create(
|
|
2631
|
+
slug="custom-feature", display="Custom Feature"
|
|
2632
|
+
)
|
|
2633
|
+
self.assertEqual(feature.get_default_actions(), ())
|
|
2634
|
+
self.assertIsNone(feature.get_default_action())
|
|
2635
|
+
|
|
1716
2636
|
def test_lcd_screen_enabled(self):
|
|
1717
2637
|
feature = NodeFeature.objects.create(slug="lcd-screen", display="LCD")
|
|
1718
2638
|
feature.roles.add(self.role)
|
|
@@ -1727,6 +2647,13 @@ class NodeFeatureTests(TestCase):
|
|
|
1727
2647
|
):
|
|
1728
2648
|
self.assertFalse(feature.is_enabled)
|
|
1729
2649
|
|
|
2650
|
+
def test_feature_disabled_when_local_node_missing(self):
|
|
2651
|
+
feature = NodeFeature.objects.create(slug="lcd-screen", display="LCD")
|
|
2652
|
+
with patch("nodes.models.Node.get_local", return_value=None):
|
|
2653
|
+
with patch("core.notifications.supports_gui_toast") as mock_toast:
|
|
2654
|
+
self.assertFalse(feature.is_enabled)
|
|
2655
|
+
mock_toast.assert_not_called()
|
|
2656
|
+
|
|
1730
2657
|
def test_rfid_scanner_lock(self):
|
|
1731
2658
|
feature = NodeFeature.objects.create(slug="rfid-scanner", display="RFID")
|
|
1732
2659
|
feature.roles.add(self.role)
|
|
@@ -1884,45 +2811,66 @@ class NodeFeatureTests(TestCase):
|
|
|
1884
2811
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
|
|
1885
2812
|
)
|
|
1886
2813
|
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
2814
|
+
|
|
2815
|
+
class CeleryReportAdminViewTests(TestCase):
|
|
2816
|
+
def setUp(self):
|
|
2817
|
+
User = get_user_model()
|
|
2818
|
+
self.superuser = User.objects.create_superuser(
|
|
2819
|
+
username="admin", email="admin@example.com", password="secret"
|
|
1891
2820
|
)
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
)
|
|
1896
|
-
|
|
1897
|
-
self.
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
2821
|
+
self.client.force_login(self.superuser)
|
|
2822
|
+
|
|
2823
|
+
self.log_file = Path(settings.LOG_DIR) / settings.LOG_FILE_NAME
|
|
2824
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
2825
|
+
self._original_log_contents: str | None = None
|
|
2826
|
+
if self.log_file.exists():
|
|
2827
|
+
self._original_log_contents = self.log_file.read_text(encoding="utf-8")
|
|
2828
|
+
self.addCleanup(self._restore_log_file)
|
|
2829
|
+
|
|
2830
|
+
PeriodicTask.objects.all().delete()
|
|
2831
|
+
|
|
2832
|
+
def _restore_log_file(self):
|
|
2833
|
+
if self._original_log_contents is None:
|
|
2834
|
+
try:
|
|
2835
|
+
self.log_file.unlink()
|
|
2836
|
+
except FileNotFoundError:
|
|
2837
|
+
pass
|
|
2838
|
+
else:
|
|
2839
|
+
self.log_file.write_text(
|
|
2840
|
+
self._original_log_contents, encoding="utf-8"
|
|
2841
|
+
)
|
|
2842
|
+
|
|
2843
|
+
def test_report_includes_tasks_and_logs(self):
|
|
2844
|
+
now = timezone.now()
|
|
2845
|
+
schedule = IntervalSchedule.objects.create(
|
|
2846
|
+
every=1, period=IntervalSchedule.HOURS
|
|
2847
|
+
)
|
|
2848
|
+
PeriodicTask.objects.create(
|
|
2849
|
+
name="test-task",
|
|
2850
|
+
task="core.tasks.heartbeat",
|
|
2851
|
+
interval=schedule,
|
|
2852
|
+
enabled=True,
|
|
2853
|
+
last_run_at=now - timedelta(minutes=30),
|
|
1901
2854
|
)
|
|
1902
2855
|
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
2856
|
+
localized = timezone.localtime(now)
|
|
2857
|
+
log_line = (
|
|
2858
|
+
f"{localized.strftime('%Y-%m-%d %H:%M:%S,%f')} "
|
|
2859
|
+
"[INFO] core.tasks: Heartbeat task executed\n"
|
|
1907
2860
|
)
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
self.node.refresh_features()
|
|
1913
|
-
self.assertTrue(
|
|
1914
|
-
NodeFeatureAssignment.objects.filter(
|
|
1915
|
-
node=self.node, feature=feature
|
|
1916
|
-
).exists()
|
|
2861
|
+
self.log_file.write_text(log_line, encoding="utf-8")
|
|
2862
|
+
|
|
2863
|
+
response = self.client.get(
|
|
2864
|
+
reverse("admin:nodes_nodefeature_celery_report")
|
|
1917
2865
|
)
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
)
|
|
1921
|
-
|
|
1922
|
-
self.
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
2866
|
+
|
|
2867
|
+
self.assertEqual(response.status_code, 200)
|
|
2868
|
+
self.assertContains(response, "Celery Report")
|
|
2869
|
+
self.assertContains(response, "test-task")
|
|
2870
|
+
self.assertContains(response, settings.LOG_FILE_NAME)
|
|
2871
|
+
entries = response.context_data["log_entries"]
|
|
2872
|
+
self.assertTrue(
|
|
2873
|
+
any("Heartbeat task executed" in entry.message for entry in entries)
|
|
1926
2874
|
)
|
|
1927
2875
|
|
|
1928
2876
|
|