arthexis 0.1.3__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.3.dist-info/METADATA +126 -0
- arthexis-0.1.3.dist-info/RECORD +73 -0
- arthexis-0.1.3.dist-info/WHEEL +5 -0
- arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
- arthexis-0.1.3.dist-info/top_level.txt +5 -0
- config/__init__.py +6 -0
- config/active_app.py +15 -0
- config/asgi.py +29 -0
- config/auth_app.py +8 -0
- config/celery.py +19 -0
- config/context_processors.py +68 -0
- config/loadenv.py +11 -0
- config/logging.py +43 -0
- config/middleware.py +25 -0
- config/offline.py +47 -0
- config/settings.py +374 -0
- config/urls.py +91 -0
- config/wsgi.py +17 -0
- core/__init__.py +0 -0
- core/admin.py +830 -0
- core/apps.py +67 -0
- core/backends.py +82 -0
- core/entity.py +97 -0
- core/environment.py +43 -0
- core/fields.py +70 -0
- core/lcd_screen.py +77 -0
- core/middleware.py +34 -0
- core/models.py +1277 -0
- core/notifications.py +95 -0
- core/release.py +451 -0
- core/system.py +111 -0
- core/tasks.py +100 -0
- core/tests.py +483 -0
- core/urls.py +11 -0
- core/user_data.py +333 -0
- core/views.py +431 -0
- nodes/__init__.py +0 -0
- nodes/actions.py +72 -0
- nodes/admin.py +347 -0
- nodes/apps.py +76 -0
- nodes/lcd.py +151 -0
- nodes/models.py +577 -0
- nodes/tasks.py +50 -0
- nodes/tests.py +1072 -0
- nodes/urls.py +13 -0
- nodes/utils.py +62 -0
- nodes/views.py +262 -0
- ocpp/__init__.py +0 -0
- ocpp/admin.py +392 -0
- ocpp/apps.py +24 -0
- ocpp/consumers.py +267 -0
- ocpp/evcs.py +911 -0
- ocpp/models.py +300 -0
- ocpp/routing.py +9 -0
- ocpp/simulator.py +357 -0
- ocpp/store.py +175 -0
- ocpp/tasks.py +27 -0
- ocpp/test_export_import.py +129 -0
- ocpp/test_rfid.py +345 -0
- ocpp/tests.py +1229 -0
- ocpp/transactions_io.py +119 -0
- ocpp/urls.py +17 -0
- ocpp/views.py +359 -0
- pages/__init__.py +0 -0
- pages/admin.py +231 -0
- pages/apps.py +10 -0
- pages/checks.py +41 -0
- pages/context_processors.py +72 -0
- pages/models.py +224 -0
- pages/tests.py +628 -0
- pages/urls.py +17 -0
- pages/utils.py +13 -0
- pages/views.py +191 -0
nodes/tests.py
ADDED
|
@@ -0,0 +1,1072 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
4
|
+
import django
|
|
5
|
+
|
|
6
|
+
django.setup()
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch, call, MagicMock
|
|
10
|
+
import socket
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import uuid
|
|
14
|
+
from tempfile import TemporaryDirectory
|
|
15
|
+
import shutil
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from django.test import Client, TestCase, TransactionTestCase, override_settings
|
|
19
|
+
from django.urls import reverse
|
|
20
|
+
from django.contrib.auth import get_user_model
|
|
21
|
+
from django.contrib import admin
|
|
22
|
+
from django.contrib.sites.models import Site
|
|
23
|
+
from django_celery_beat.models import PeriodicTask
|
|
24
|
+
from django.conf import settings
|
|
25
|
+
from .actions import NodeAction
|
|
26
|
+
from selenium.common.exceptions import WebDriverException
|
|
27
|
+
from .utils import capture_screenshot
|
|
28
|
+
|
|
29
|
+
from .models import (
|
|
30
|
+
Node,
|
|
31
|
+
EmailOutbox,
|
|
32
|
+
ContentSample,
|
|
33
|
+
NodeRole,
|
|
34
|
+
NetMessage,
|
|
35
|
+
)
|
|
36
|
+
from .tasks import capture_node_screenshot, sample_clipboard
|
|
37
|
+
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
38
|
+
from cryptography.hazmat.primitives import serialization, hashes
|
|
39
|
+
from core.models import PackageRelease
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NodeTests(TestCase):
|
|
43
|
+
def setUp(self):
|
|
44
|
+
self.client = Client()
|
|
45
|
+
User = get_user_model()
|
|
46
|
+
self.user = User.objects.create_user(
|
|
47
|
+
username="nodeuser", password="pwd"
|
|
48
|
+
)
|
|
49
|
+
self.client.force_login(self.user)
|
|
50
|
+
NodeRole.objects.get_or_create(name="Terminal")
|
|
51
|
+
|
|
52
|
+
def test_register_current_does_not_create_release(self):
|
|
53
|
+
with TemporaryDirectory() as tmp:
|
|
54
|
+
base = Path(tmp)
|
|
55
|
+
with override_settings(BASE_DIR=base):
|
|
56
|
+
with patch(
|
|
57
|
+
"nodes.models.Node.get_current_mac",
|
|
58
|
+
return_value="00:ff:ee:dd:cc:bb",
|
|
59
|
+
), patch(
|
|
60
|
+
"nodes.models.socket.gethostname", return_value="testhost"
|
|
61
|
+
), patch(
|
|
62
|
+
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
63
|
+
), patch(
|
|
64
|
+
"nodes.models.revision.get_revision", return_value="rev"
|
|
65
|
+
), patch.object(Node, "ensure_keys"):
|
|
66
|
+
Node.register_current()
|
|
67
|
+
self.assertEqual(PackageRelease.objects.count(), 0)
|
|
68
|
+
|
|
69
|
+
def test_register_and_list_node(self):
|
|
70
|
+
response = self.client.post(
|
|
71
|
+
reverse("register-node"),
|
|
72
|
+
data={
|
|
73
|
+
"hostname": "local",
|
|
74
|
+
"address": "127.0.0.1",
|
|
75
|
+
"port": 8000,
|
|
76
|
+
"mac_address": "00:11:22:33:44:55",
|
|
77
|
+
},
|
|
78
|
+
content_type="application/json",
|
|
79
|
+
)
|
|
80
|
+
self.assertEqual(response.status_code, 200)
|
|
81
|
+
self.assertEqual(Node.objects.count(), 1)
|
|
82
|
+
|
|
83
|
+
# allow same IP with different MAC
|
|
84
|
+
self.client.post(
|
|
85
|
+
reverse("register-node"),
|
|
86
|
+
data={
|
|
87
|
+
"hostname": "local2",
|
|
88
|
+
"address": "127.0.0.1",
|
|
89
|
+
"port": 8001,
|
|
90
|
+
"mac_address": "00:11:22:33:44:66",
|
|
91
|
+
},
|
|
92
|
+
content_type="application/json",
|
|
93
|
+
)
|
|
94
|
+
self.assertEqual(Node.objects.count(), 2)
|
|
95
|
+
|
|
96
|
+
# duplicate MAC should not create new node
|
|
97
|
+
dup = self.client.post(
|
|
98
|
+
reverse("register-node"),
|
|
99
|
+
data={
|
|
100
|
+
"hostname": "dup",
|
|
101
|
+
"address": "127.0.0.2",
|
|
102
|
+
"port": 8002,
|
|
103
|
+
"mac_address": "00:11:22:33:44:55",
|
|
104
|
+
},
|
|
105
|
+
content_type="application/json",
|
|
106
|
+
)
|
|
107
|
+
self.assertEqual(Node.objects.count(), 2)
|
|
108
|
+
self.assertIn("already exists", dup.json()["detail"])
|
|
109
|
+
self.assertEqual(dup.json()["id"], response.json()["id"])
|
|
110
|
+
|
|
111
|
+
list_resp = self.client.get(reverse("node-list"))
|
|
112
|
+
self.assertEqual(list_resp.status_code, 200)
|
|
113
|
+
data = list_resp.json()
|
|
114
|
+
self.assertEqual(len(data["nodes"]), 2)
|
|
115
|
+
hostnames = {n["hostname"] for n in data["nodes"]}
|
|
116
|
+
self.assertEqual(hostnames, {"dup", "local2"})
|
|
117
|
+
|
|
118
|
+
def test_register_node_has_lcd_screen_toggle(self):
|
|
119
|
+
url = reverse("register-node")
|
|
120
|
+
first = self.client.post(
|
|
121
|
+
url,
|
|
122
|
+
data={
|
|
123
|
+
"hostname": "lcd",
|
|
124
|
+
"address": "127.0.0.1",
|
|
125
|
+
"port": 8000,
|
|
126
|
+
"mac_address": "00:aa:bb:cc:dd:ee",
|
|
127
|
+
"has_lcd_screen": True,
|
|
128
|
+
},
|
|
129
|
+
content_type="application/json",
|
|
130
|
+
)
|
|
131
|
+
self.assertEqual(first.status_code, 200)
|
|
132
|
+
node = Node.objects.get(mac_address="00:aa:bb:cc:dd:ee")
|
|
133
|
+
self.assertTrue(node.has_lcd_screen)
|
|
134
|
+
|
|
135
|
+
self.client.post(
|
|
136
|
+
url,
|
|
137
|
+
data={
|
|
138
|
+
"hostname": "lcd",
|
|
139
|
+
"address": "127.0.0.1",
|
|
140
|
+
"port": 8000,
|
|
141
|
+
"mac_address": "00:aa:bb:cc:dd:ee",
|
|
142
|
+
"has_lcd_screen": False,
|
|
143
|
+
},
|
|
144
|
+
content_type="application/json",
|
|
145
|
+
)
|
|
146
|
+
node.refresh_from_db()
|
|
147
|
+
self.assertFalse(node.has_lcd_screen)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class NodeRegisterCurrentTests(TestCase):
|
|
151
|
+
def setUp(self):
|
|
152
|
+
User = get_user_model()
|
|
153
|
+
self.client = Client()
|
|
154
|
+
self.user = User.objects.create_user(username="nodeuser", password="pwd")
|
|
155
|
+
self.client.force_login(self.user)
|
|
156
|
+
NodeRole.objects.get_or_create(name="Terminal")
|
|
157
|
+
def test_register_current_sets_and_retains_lcd(self):
|
|
158
|
+
with TemporaryDirectory() as tmp:
|
|
159
|
+
base = Path(tmp)
|
|
160
|
+
locks = base / "locks"
|
|
161
|
+
locks.mkdir()
|
|
162
|
+
(locks / "lcd_screen.lck").touch()
|
|
163
|
+
with override_settings(BASE_DIR=base):
|
|
164
|
+
with patch("nodes.models.Node.get_current_mac", return_value="00:ff:ee:dd:cc:bb"), patch(
|
|
165
|
+
"nodes.models.socket.gethostname", return_value="testhost"
|
|
166
|
+
), patch(
|
|
167
|
+
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
168
|
+
), patch(
|
|
169
|
+
"nodes.models.revision.get_revision", return_value="rev"
|
|
170
|
+
), patch.object(Node, "ensure_keys"):
|
|
171
|
+
node, created = Node.register_current()
|
|
172
|
+
self.assertTrue(created)
|
|
173
|
+
self.assertTrue(node.has_lcd_screen)
|
|
174
|
+
|
|
175
|
+
node.has_lcd_screen = False
|
|
176
|
+
node.save(update_fields=["has_lcd_screen"])
|
|
177
|
+
|
|
178
|
+
with override_settings(BASE_DIR=base):
|
|
179
|
+
with patch("nodes.models.Node.get_current_mac", return_value="00:ff:ee:dd:cc:bb"), patch(
|
|
180
|
+
"nodes.models.socket.gethostname", return_value="testhost"
|
|
181
|
+
), patch(
|
|
182
|
+
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
183
|
+
), patch(
|
|
184
|
+
"nodes.models.revision.get_revision", return_value="rev"
|
|
185
|
+
), patch.object(Node, "ensure_keys"):
|
|
186
|
+
node2, created2 = Node.register_current()
|
|
187
|
+
self.assertFalse(created2)
|
|
188
|
+
node.refresh_from_db()
|
|
189
|
+
self.assertFalse(node.has_lcd_screen)
|
|
190
|
+
|
|
191
|
+
@patch("nodes.views.capture_screenshot")
|
|
192
|
+
def test_capture_screenshot(self, mock_capture):
|
|
193
|
+
hostname = socket.gethostname()
|
|
194
|
+
node = Node.objects.create(
|
|
195
|
+
hostname=hostname,
|
|
196
|
+
address="127.0.0.1",
|
|
197
|
+
port=80,
|
|
198
|
+
mac_address=Node.get_current_mac(),
|
|
199
|
+
)
|
|
200
|
+
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
201
|
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
file_path = screenshot_dir / "test.png"
|
|
203
|
+
file_path.write_bytes(b"test")
|
|
204
|
+
mock_capture.return_value = Path("screenshots/test.png")
|
|
205
|
+
response = self.client.get(reverse("node-screenshot"))
|
|
206
|
+
self.assertEqual(response.status_code, 200)
|
|
207
|
+
data = response.json()
|
|
208
|
+
self.assertEqual(data["screenshot"], "screenshots/test.png")
|
|
209
|
+
self.assertEqual(data["node"], node.id)
|
|
210
|
+
mock_capture.assert_called_once()
|
|
211
|
+
self.assertEqual(
|
|
212
|
+
ContentSample.objects.filter(kind=ContentSample.IMAGE).count(), 1
|
|
213
|
+
)
|
|
214
|
+
screenshot = ContentSample.objects.filter(kind=ContentSample.IMAGE).first()
|
|
215
|
+
self.assertEqual(screenshot.node, node)
|
|
216
|
+
self.assertEqual(screenshot.method, "GET")
|
|
217
|
+
|
|
218
|
+
@patch("nodes.views.capture_screenshot")
|
|
219
|
+
def test_duplicate_screenshot_skipped(self, mock_capture):
|
|
220
|
+
hostname = socket.gethostname()
|
|
221
|
+
Node.objects.create(
|
|
222
|
+
hostname=hostname,
|
|
223
|
+
address="127.0.0.1",
|
|
224
|
+
port=80,
|
|
225
|
+
mac_address=Node.get_current_mac(),
|
|
226
|
+
)
|
|
227
|
+
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
228
|
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
file_path = screenshot_dir / "dup.png"
|
|
230
|
+
file_path.write_bytes(b"dup")
|
|
231
|
+
mock_capture.return_value = Path("screenshots/dup.png")
|
|
232
|
+
self.client.get(reverse("node-screenshot"))
|
|
233
|
+
self.client.get(reverse("node-screenshot"))
|
|
234
|
+
self.assertEqual(
|
|
235
|
+
ContentSample.objects.filter(kind=ContentSample.IMAGE).count(), 1
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
@patch("nodes.views.capture_screenshot")
|
|
239
|
+
def test_capture_screenshot_error(self, mock_capture):
|
|
240
|
+
hostname = socket.gethostname()
|
|
241
|
+
Node.objects.create(
|
|
242
|
+
hostname=hostname,
|
|
243
|
+
address="127.0.0.1",
|
|
244
|
+
port=80,
|
|
245
|
+
mac_address=Node.get_current_mac(),
|
|
246
|
+
)
|
|
247
|
+
mock_capture.side_effect = RuntimeError("fail")
|
|
248
|
+
response = self.client.get(reverse("node-screenshot"))
|
|
249
|
+
self.assertEqual(response.status_code, 500)
|
|
250
|
+
data = response.json()
|
|
251
|
+
self.assertEqual(data["detail"], "fail")
|
|
252
|
+
self.assertEqual(
|
|
253
|
+
ContentSample.objects.filter(kind=ContentSample.IMAGE).count(), 0
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def test_public_api_get_and_post(self):
|
|
257
|
+
node = Node.objects.create(
|
|
258
|
+
hostname="public",
|
|
259
|
+
address="127.0.0.1",
|
|
260
|
+
port=8001,
|
|
261
|
+
enable_public_api=True,
|
|
262
|
+
mac_address="00:11:22:33:44:77",
|
|
263
|
+
)
|
|
264
|
+
url = reverse("node-public-endpoint", args=[node.public_endpoint])
|
|
265
|
+
|
|
266
|
+
get_resp = self.client.get(url)
|
|
267
|
+
self.assertEqual(get_resp.status_code, 200)
|
|
268
|
+
self.assertEqual(get_resp.json()["hostname"], "public")
|
|
269
|
+
|
|
270
|
+
pre_count = NetMessage.objects.count()
|
|
271
|
+
post_resp = self.client.post(
|
|
272
|
+
url, data="hello", content_type="text/plain"
|
|
273
|
+
)
|
|
274
|
+
self.assertEqual(post_resp.status_code, 200)
|
|
275
|
+
self.assertEqual(NetMessage.objects.count(), pre_count + 1)
|
|
276
|
+
msg = NetMessage.objects.order_by("-created").first()
|
|
277
|
+
self.assertEqual(msg.body, "hello")
|
|
278
|
+
self.assertEqual(msg.reach.name, "Terminal")
|
|
279
|
+
|
|
280
|
+
def test_public_api_disabled(self):
|
|
281
|
+
node = Node.objects.create(
|
|
282
|
+
hostname="nopublic",
|
|
283
|
+
address="127.0.0.2",
|
|
284
|
+
port=8002,
|
|
285
|
+
mac_address="00:11:22:33:44:88",
|
|
286
|
+
)
|
|
287
|
+
url = reverse("node-public-endpoint", args=[node.public_endpoint])
|
|
288
|
+
resp = self.client.get(url)
|
|
289
|
+
self.assertEqual(resp.status_code, 404)
|
|
290
|
+
|
|
291
|
+
def test_net_message_requires_signature(self):
|
|
292
|
+
payload = {
|
|
293
|
+
"uuid": str(uuid.uuid4()),
|
|
294
|
+
"subject": "s",
|
|
295
|
+
"body": "b",
|
|
296
|
+
"seen": [],
|
|
297
|
+
"sender": str(uuid.uuid4()),
|
|
298
|
+
}
|
|
299
|
+
resp = self.client.post(
|
|
300
|
+
reverse("net-message"),
|
|
301
|
+
data=json.dumps(payload),
|
|
302
|
+
content_type="application/json",
|
|
303
|
+
)
|
|
304
|
+
self.assertEqual(resp.status_code, 403)
|
|
305
|
+
|
|
306
|
+
def test_net_message_with_valid_signature(self):
|
|
307
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
308
|
+
public_key = key.public_key().public_bytes(
|
|
309
|
+
encoding=serialization.Encoding.PEM,
|
|
310
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
311
|
+
).decode()
|
|
312
|
+
sender = Node.objects.create(
|
|
313
|
+
hostname="sender",
|
|
314
|
+
address="10.0.0.1",
|
|
315
|
+
port=8000,
|
|
316
|
+
mac_address="00:11:22:33:44:cc",
|
|
317
|
+
public_key=public_key,
|
|
318
|
+
)
|
|
319
|
+
msg_id = str(uuid.uuid4())
|
|
320
|
+
payload = {
|
|
321
|
+
"uuid": msg_id,
|
|
322
|
+
"subject": "hello",
|
|
323
|
+
"body": "world",
|
|
324
|
+
"seen": [],
|
|
325
|
+
"sender": str(sender.uuid),
|
|
326
|
+
}
|
|
327
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
328
|
+
signature = key.sign(
|
|
329
|
+
payload_json.encode(), padding.PKCS1v15(), hashes.SHA256()
|
|
330
|
+
)
|
|
331
|
+
resp = self.client.post(
|
|
332
|
+
reverse("net-message"),
|
|
333
|
+
data=payload_json,
|
|
334
|
+
content_type="application/json",
|
|
335
|
+
HTTP_X_SIGNATURE=base64.b64encode(signature).decode(),
|
|
336
|
+
)
|
|
337
|
+
self.assertEqual(resp.status_code, 200)
|
|
338
|
+
self.assertTrue(NetMessage.objects.filter(uuid=msg_id).exists())
|
|
339
|
+
|
|
340
|
+
def test_clipboard_polling_creates_task(self):
|
|
341
|
+
node = Node.objects.create(
|
|
342
|
+
hostname="clip",
|
|
343
|
+
address="127.0.0.1",
|
|
344
|
+
port=9000,
|
|
345
|
+
mac_address="00:11:22:33:44:99",
|
|
346
|
+
)
|
|
347
|
+
task_name = f"poll_clipboard_node_{node.pk}"
|
|
348
|
+
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
349
|
+
node.clipboard_polling = True
|
|
350
|
+
node.save()
|
|
351
|
+
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
|
352
|
+
node.clipboard_polling = False
|
|
353
|
+
node.save()
|
|
354
|
+
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
355
|
+
|
|
356
|
+
def test_screenshot_polling_creates_task(self):
|
|
357
|
+
node = Node.objects.create(
|
|
358
|
+
hostname="shot",
|
|
359
|
+
address="127.0.0.1",
|
|
360
|
+
port=9100,
|
|
361
|
+
mac_address="00:11:22:33:44:aa",
|
|
362
|
+
)
|
|
363
|
+
task_name = f"capture_screenshot_node_{node.pk}"
|
|
364
|
+
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
365
|
+
node.screenshot_polling = True
|
|
366
|
+
node.save()
|
|
367
|
+
self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
|
|
368
|
+
node.screenshot_polling = False
|
|
369
|
+
node.save()
|
|
370
|
+
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
371
|
+
|
|
372
|
+
class NodeAdminTests(TestCase):
|
|
373
|
+
|
|
374
|
+
def setUp(self):
|
|
375
|
+
self.client = Client()
|
|
376
|
+
User = get_user_model()
|
|
377
|
+
self.admin = User.objects.create_superuser(
|
|
378
|
+
username="nodes-admin", password="adminpass", email="admin@example.com"
|
|
379
|
+
)
|
|
380
|
+
self.client.force_login(self.admin)
|
|
381
|
+
|
|
382
|
+
def tearDown(self):
|
|
383
|
+
security_dir = Path(settings.BASE_DIR) / "security"
|
|
384
|
+
if security_dir.exists():
|
|
385
|
+
shutil.rmtree(security_dir)
|
|
386
|
+
|
|
387
|
+
def test_register_current_host(self):
|
|
388
|
+
url = reverse("admin:nodes_node_register_current")
|
|
389
|
+
hostname = socket.gethostname()
|
|
390
|
+
with patch("utils.revision.get_revision", return_value="abcdef123456"):
|
|
391
|
+
response = self.client.get(url)
|
|
392
|
+
self.assertEqual(response.status_code, 200)
|
|
393
|
+
self.assertTemplateUsed(response, "admin/nodes/node/register_remote.html")
|
|
394
|
+
self.assertEqual(Node.objects.count(), 1)
|
|
395
|
+
node = Node.objects.first()
|
|
396
|
+
ver = Path('VERSION').read_text().strip()
|
|
397
|
+
rev = "abcdef123456"
|
|
398
|
+
self.assertEqual(node.base_path, str(settings.BASE_DIR))
|
|
399
|
+
self.assertEqual(node.installed_version, ver)
|
|
400
|
+
self.assertEqual(node.installed_revision, rev)
|
|
401
|
+
self.assertEqual(node.mac_address, Node.get_current_mac())
|
|
402
|
+
sec_dir = Path(settings.BASE_DIR) / "security"
|
|
403
|
+
priv = sec_dir / f"{node.public_endpoint}"
|
|
404
|
+
pub = sec_dir / f"{node.public_endpoint}.pub"
|
|
405
|
+
self.assertTrue(sec_dir.exists())
|
|
406
|
+
self.assertTrue(priv.exists())
|
|
407
|
+
self.assertTrue(pub.exists())
|
|
408
|
+
self.assertTrue(node.public_key)
|
|
409
|
+
self.assertTrue(
|
|
410
|
+
Site.objects.filter(domain=hostname, name="host").exists()
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def test_register_current_updates_existing_node(self):
|
|
414
|
+
hostname = socket.gethostname()
|
|
415
|
+
Node.objects.create(
|
|
416
|
+
hostname=hostname,
|
|
417
|
+
address="127.0.0.1",
|
|
418
|
+
port=8000,
|
|
419
|
+
mac_address=None,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
response = self.client.get(
|
|
423
|
+
reverse("admin:nodes_node_register_current"), follow=False
|
|
424
|
+
)
|
|
425
|
+
self.assertEqual(response.status_code, 200)
|
|
426
|
+
self.assertEqual(Node.objects.count(), 1)
|
|
427
|
+
node = Node.objects.first()
|
|
428
|
+
self.assertEqual(node.mac_address, Node.get_current_mac())
|
|
429
|
+
self.assertEqual(node.hostname, hostname)
|
|
430
|
+
|
|
431
|
+
def test_public_key_download_link(self):
|
|
432
|
+
self.client.get(reverse("admin:nodes_node_register_current"))
|
|
433
|
+
node = Node.objects.first()
|
|
434
|
+
change_url = reverse("admin:nodes_node_change", args=[node.pk])
|
|
435
|
+
response = self.client.get(change_url)
|
|
436
|
+
download_url = reverse("admin:nodes_node_public_key", args=[node.pk])
|
|
437
|
+
self.assertContains(response, download_url)
|
|
438
|
+
resp = self.client.get(download_url)
|
|
439
|
+
self.assertEqual(resp.status_code, 200)
|
|
440
|
+
self.assertEqual(
|
|
441
|
+
resp["Content-Disposition"],
|
|
442
|
+
f'attachment; filename="{node.public_endpoint}.pub"',
|
|
443
|
+
)
|
|
444
|
+
self.assertIn(node.public_key.strip(), resp.content.decode())
|
|
445
|
+
|
|
446
|
+
@patch("nodes.admin.capture_screenshot")
|
|
447
|
+
def test_capture_site_screenshot_from_admin(
|
|
448
|
+
self, mock_capture_screenshot
|
|
449
|
+
):
|
|
450
|
+
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
451
|
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
file_path = screenshot_dir / "test.png"
|
|
453
|
+
file_path.write_bytes(b"admin")
|
|
454
|
+
mock_capture_screenshot.return_value = Path("screenshots/test.png")
|
|
455
|
+
hostname = socket.gethostname()
|
|
456
|
+
node = Node.objects.create(
|
|
457
|
+
hostname=hostname,
|
|
458
|
+
address="127.0.0.1",
|
|
459
|
+
port=80,
|
|
460
|
+
mac_address=Node.get_current_mac(),
|
|
461
|
+
)
|
|
462
|
+
url = reverse("admin:nodes_contentsample_capture")
|
|
463
|
+
response = self.client.get(url, follow=True)
|
|
464
|
+
self.assertEqual(response.status_code, 200)
|
|
465
|
+
self.assertEqual(
|
|
466
|
+
ContentSample.objects.filter(kind=ContentSample.IMAGE).count(), 1
|
|
467
|
+
)
|
|
468
|
+
screenshot = ContentSample.objects.filter(kind=ContentSample.IMAGE).first()
|
|
469
|
+
self.assertEqual(screenshot.node, node)
|
|
470
|
+
self.assertEqual(screenshot.path, "screenshots/test.png")
|
|
471
|
+
self.assertEqual(screenshot.method, "ADMIN")
|
|
472
|
+
mock_capture_screenshot.assert_called_once_with("http://testserver/")
|
|
473
|
+
self.assertContains(
|
|
474
|
+
response, "Screenshot saved to screenshots/test.png"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def test_view_screenshot_in_change_admin(self):
|
|
478
|
+
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
479
|
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
480
|
+
file_path = screenshot_dir / "test.png"
|
|
481
|
+
with file_path.open("wb") as fh:
|
|
482
|
+
fh.write(
|
|
483
|
+
base64.b64decode(
|
|
484
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR42mP8/5+hHgAFgwJ/lSdX6QAAAABJRU5ErkJggg=="
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
screenshot = ContentSample.objects.create(
|
|
488
|
+
path="screenshots/test.png", kind=ContentSample.IMAGE
|
|
489
|
+
)
|
|
490
|
+
url = reverse("admin:nodes_contentsample_change", args=[screenshot.id])
|
|
491
|
+
response = self.client.get(url)
|
|
492
|
+
self.assertEqual(response.status_code, 200)
|
|
493
|
+
self.assertContains(response, "data:image/png;base64")
|
|
494
|
+
|
|
495
|
+
@override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
|
|
496
|
+
@patch("nodes.admin.capture_screenshot")
|
|
497
|
+
def test_take_screenshots_action(self, mock_capture):
|
|
498
|
+
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
499
|
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
500
|
+
file1 = screenshot_dir / "one.png"
|
|
501
|
+
file1.write_bytes(b"1")
|
|
502
|
+
file2 = screenshot_dir / "two.png"
|
|
503
|
+
file2.write_bytes(b"2")
|
|
504
|
+
mock_capture.side_effect = [
|
|
505
|
+
Path("screenshots/one.png"),
|
|
506
|
+
Path("screenshots/two.png"),
|
|
507
|
+
]
|
|
508
|
+
node = Node.objects.create(
|
|
509
|
+
hostname="host",
|
|
510
|
+
address="127.0.0.1",
|
|
511
|
+
port=80,
|
|
512
|
+
mac_address=Node.get_current_mac(),
|
|
513
|
+
)
|
|
514
|
+
url = reverse("admin:nodes_node_changelist")
|
|
515
|
+
resp = self.client.post(
|
|
516
|
+
url,
|
|
517
|
+
{"action": "take_screenshots", "_selected_action": [str(node.pk)]},
|
|
518
|
+
follow=True,
|
|
519
|
+
)
|
|
520
|
+
self.assertEqual(resp.status_code, 200)
|
|
521
|
+
self.assertEqual(
|
|
522
|
+
ContentSample.objects.filter(kind=ContentSample.IMAGE).count(), 2
|
|
523
|
+
)
|
|
524
|
+
samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
|
|
525
|
+
self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class NetMessageAdminTests(TransactionTestCase):
|
|
529
|
+
reset_sequences = True
|
|
530
|
+
|
|
531
|
+
def setUp(self):
|
|
532
|
+
self.client = Client()
|
|
533
|
+
User = get_user_model()
|
|
534
|
+
self.admin = User.objects.create_superuser(
|
|
535
|
+
username="netmsg-admin", password="adminpass", email="admin@example.com"
|
|
536
|
+
)
|
|
537
|
+
self.client.force_login(self.admin)
|
|
538
|
+
NodeRole.objects.get_or_create(name="Terminal")
|
|
539
|
+
|
|
540
|
+
def test_complete_flag_not_editable(self):
|
|
541
|
+
msg = NetMessage.objects.create(subject="s", body="b")
|
|
542
|
+
url = reverse("admin:nodes_netmessage_change", args=[msg.pk])
|
|
543
|
+
data = {"subject": "s2", "body": "b2", "complete": "on", "_save": "Save"}
|
|
544
|
+
self.client.post(url, data)
|
|
545
|
+
msg.refresh_from_db()
|
|
546
|
+
self.assertFalse(msg.complete)
|
|
547
|
+
self.assertEqual(msg.subject, "s2")
|
|
548
|
+
|
|
549
|
+
def test_send_action_calls_propagate(self):
|
|
550
|
+
msg = NetMessage.objects.create(subject="s", body="b")
|
|
551
|
+
with patch.object(NetMessage, "propagate") as mock_propagate:
|
|
552
|
+
response = self.client.post(
|
|
553
|
+
reverse("admin:nodes_netmessage_changelist"),
|
|
554
|
+
{"action": "send_messages", "_selected_action": [str(msg.pk)]},
|
|
555
|
+
)
|
|
556
|
+
self.assertEqual(response.status_code, 302)
|
|
557
|
+
mock_propagate.assert_called_once()
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class LastNetMessageViewTests(TestCase):
|
|
561
|
+
def setUp(self):
|
|
562
|
+
self.client = Client()
|
|
563
|
+
User = get_user_model()
|
|
564
|
+
self.user = User.objects.create_user(
|
|
565
|
+
username="lastmsg", password="pwd"
|
|
566
|
+
)
|
|
567
|
+
self.client.force_login(self.user)
|
|
568
|
+
NodeRole.objects.get_or_create(name="Terminal")
|
|
569
|
+
|
|
570
|
+
def test_returns_latest_message(self):
|
|
571
|
+
NetMessage.objects.create(subject="old", body="msg1")
|
|
572
|
+
NetMessage.objects.create(subject="new", body="msg2")
|
|
573
|
+
resp = self.client.get(reverse("last-net-message"))
|
|
574
|
+
self.assertEqual(resp.status_code, 200)
|
|
575
|
+
self.assertEqual(resp.json(), {"subject": "new", "body": "msg2"})
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class NetMessageReachTests(TestCase):
|
|
579
|
+
def setUp(self):
|
|
580
|
+
self.roles = {}
|
|
581
|
+
for name in ["Terminal", "Control", "Satellite", "Constellation"]:
|
|
582
|
+
self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
|
|
583
|
+
self.nodes = {}
|
|
584
|
+
for idx, name in enumerate(["Terminal", "Control", "Satellite", "Constellation"], start=1):
|
|
585
|
+
self.nodes[name] = Node.objects.create(
|
|
586
|
+
hostname=name.lower(),
|
|
587
|
+
address=f"10.0.0.{idx}",
|
|
588
|
+
port=8000 + idx,
|
|
589
|
+
mac_address=f"00:11:22:33:44:{idx:02x}",
|
|
590
|
+
role=self.roles[name],
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
@patch("requests.post")
|
|
594
|
+
def test_terminal_reach_limits_nodes(self, mock_post):
|
|
595
|
+
msg = NetMessage.objects.create(subject="s", body="b", reach=self.roles["Terminal"])
|
|
596
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
597
|
+
msg.propagate()
|
|
598
|
+
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
599
|
+
self.assertEqual(roles, {"Terminal"})
|
|
600
|
+
self.assertEqual(mock_post.call_count, 1)
|
|
601
|
+
|
|
602
|
+
@patch("requests.post")
|
|
603
|
+
def test_control_reach_includes_control_and_terminal(self, mock_post):
|
|
604
|
+
msg = NetMessage.objects.create(subject="s", body="b", reach=self.roles["Control"])
|
|
605
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
606
|
+
msg.propagate()
|
|
607
|
+
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
608
|
+
self.assertEqual(roles, {"Control", "Terminal"})
|
|
609
|
+
self.assertEqual(mock_post.call_count, 2)
|
|
610
|
+
|
|
611
|
+
@patch("requests.post")
|
|
612
|
+
def test_satellite_reach_includes_lower_roles(self, mock_post):
|
|
613
|
+
msg = NetMessage.objects.create(subject="s", body="b", reach=self.roles["Satellite"])
|
|
614
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
615
|
+
msg.propagate()
|
|
616
|
+
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
617
|
+
self.assertEqual(roles, {"Satellite", "Control", "Terminal"})
|
|
618
|
+
self.assertEqual(mock_post.call_count, 3)
|
|
619
|
+
|
|
620
|
+
@patch("requests.post")
|
|
621
|
+
def test_constellation_reach_prioritizes_constellation(self, mock_post):
|
|
622
|
+
msg = NetMessage.objects.create(subject="s", body="b", reach=self.roles["Constellation"])
|
|
623
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
624
|
+
msg.propagate()
|
|
625
|
+
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
626
|
+
self.assertEqual(roles, {"Constellation", "Satellite", "Control"})
|
|
627
|
+
self.assertEqual(mock_post.call_count, 3)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
class NetMessagePropagationTests(TestCase):
|
|
631
|
+
def setUp(self):
|
|
632
|
+
self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
633
|
+
self.local = Node.objects.create(
|
|
634
|
+
hostname="local",
|
|
635
|
+
address="10.0.0.1",
|
|
636
|
+
port=8001,
|
|
637
|
+
mac_address="00:11:22:33:44:00",
|
|
638
|
+
role=self.role,
|
|
639
|
+
public_endpoint="local",
|
|
640
|
+
)
|
|
641
|
+
self.remotes = []
|
|
642
|
+
for idx in range(2, 6):
|
|
643
|
+
self.remotes.append(
|
|
644
|
+
Node.objects.create(
|
|
645
|
+
hostname=f"n{idx}",
|
|
646
|
+
address=f"10.0.0.{idx}",
|
|
647
|
+
port=8000 + idx,
|
|
648
|
+
mac_address=f"00:11:22:33:44:{idx:02x}",
|
|
649
|
+
role=self.role,
|
|
650
|
+
public_endpoint=f"n{idx}",
|
|
651
|
+
)
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
@patch("requests.post")
|
|
655
|
+
@patch("core.notifications.notify")
|
|
656
|
+
def test_propagate_forwards_to_three_and_notifies_local(self, mock_notify, mock_post):
|
|
657
|
+
msg = NetMessage.objects.create(subject="s", body="b", reach=self.role)
|
|
658
|
+
with patch.object(Node, "get_local", return_value=self.local):
|
|
659
|
+
msg.propagate(seen=[str(self.remotes[0].uuid)])
|
|
660
|
+
mock_notify.assert_called_once_with("s", "b")
|
|
661
|
+
self.assertEqual(mock_post.call_count, 3)
|
|
662
|
+
targets = {
|
|
663
|
+
call.args[0].split("//")[1].split("/")[0]
|
|
664
|
+
for call in mock_post.call_args_list
|
|
665
|
+
}
|
|
666
|
+
sender_addr = f"{self.remotes[0].address}:{self.remotes[0].port}"
|
|
667
|
+
self.assertNotIn(sender_addr, targets)
|
|
668
|
+
self.assertEqual(msg.propagated_to.count(), 4)
|
|
669
|
+
self.assertTrue(msg.complete)
|
|
670
|
+
|
|
671
|
+
class NodeActionTests(TestCase):
|
|
672
|
+
def setUp(self):
|
|
673
|
+
self.client = Client()
|
|
674
|
+
User = get_user_model()
|
|
675
|
+
self.admin = User.objects.create_superuser(
|
|
676
|
+
username="action-admin", password="adminpass", email="admin@example.com"
|
|
677
|
+
)
|
|
678
|
+
self.client.force_login(self.admin)
|
|
679
|
+
|
|
680
|
+
def test_registry_and_local_execution(self):
|
|
681
|
+
hostname = socket.gethostname()
|
|
682
|
+
node = Node.objects.create(
|
|
683
|
+
hostname=hostname,
|
|
684
|
+
address="127.0.0.1",
|
|
685
|
+
port=8000,
|
|
686
|
+
mac_address=Node.get_current_mac(),
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
class DummyAction(NodeAction):
|
|
690
|
+
display_name = "Dummy Action"
|
|
691
|
+
|
|
692
|
+
def execute(self, node, **kwargs):
|
|
693
|
+
DummyAction.executed = node
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
DummyAction.executed = None
|
|
697
|
+
DummyAction.run()
|
|
698
|
+
self.assertEqual(DummyAction.executed, node)
|
|
699
|
+
self.assertIn("dummyaction", NodeAction.registry)
|
|
700
|
+
finally:
|
|
701
|
+
NodeAction.registry.pop("dummyaction", None)
|
|
702
|
+
|
|
703
|
+
def test_remote_not_supported(self):
|
|
704
|
+
node = Node.objects.create(
|
|
705
|
+
hostname="remote",
|
|
706
|
+
address="10.0.0.1",
|
|
707
|
+
port=8000,
|
|
708
|
+
mac_address="00:11:22:33:44:bb",
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
class DummyAction(NodeAction):
|
|
712
|
+
def execute(self, node, **kwargs):
|
|
713
|
+
pass
|
|
714
|
+
|
|
715
|
+
try:
|
|
716
|
+
with self.assertRaises(NotImplementedError):
|
|
717
|
+
DummyAction.run(node)
|
|
718
|
+
finally:
|
|
719
|
+
NodeAction.registry.pop("dummyaction", None)
|
|
720
|
+
|
|
721
|
+
def test_admin_change_view_lists_actions(self):
|
|
722
|
+
hostname = socket.gethostname()
|
|
723
|
+
node = Node.objects.create(
|
|
724
|
+
hostname=hostname,
|
|
725
|
+
address="127.0.0.1",
|
|
726
|
+
port=8000,
|
|
727
|
+
mac_address=Node.get_current_mac(),
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
class DummyAction(NodeAction):
|
|
731
|
+
display_name = "Dummy Action"
|
|
732
|
+
|
|
733
|
+
def execute(self, node, **kwargs):
|
|
734
|
+
pass
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
url = reverse("admin:nodes_node_change", args=[node.pk])
|
|
738
|
+
response = self.client.get(url)
|
|
739
|
+
self.assertContains(response, "Dummy Action")
|
|
740
|
+
finally:
|
|
741
|
+
NodeAction.registry.pop("dummyaction", None)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
class StartupNotificationTests(TestCase):
|
|
745
|
+
def test_startup_notification_uses_ip_and_revision(self):
|
|
746
|
+
from nodes.apps import _startup_notification
|
|
747
|
+
|
|
748
|
+
with TemporaryDirectory() as tmp:
|
|
749
|
+
tmp_path = Path(tmp)
|
|
750
|
+
(tmp_path / "VERSION").write_text("1.2.3")
|
|
751
|
+
with self.settings(BASE_DIR=tmp_path):
|
|
752
|
+
with patch(
|
|
753
|
+
"nodes.apps.revision.get_revision", return_value="abcdef123456"
|
|
754
|
+
):
|
|
755
|
+
with patch("nodes.models.NetMessage.broadcast") as mock_broadcast:
|
|
756
|
+
with patch("nodes.apps.socket.gethostname", return_value="host"):
|
|
757
|
+
with patch(
|
|
758
|
+
"nodes.apps.socket.gethostbyname", return_value="1.2.3.4"
|
|
759
|
+
):
|
|
760
|
+
with patch.dict(
|
|
761
|
+
os.environ, {"PORT": "9000"}
|
|
762
|
+
):
|
|
763
|
+
_startup_notification()
|
|
764
|
+
time.sleep(0.1)
|
|
765
|
+
|
|
766
|
+
mock_broadcast.assert_called_once()
|
|
767
|
+
_, kwargs = mock_broadcast.call_args
|
|
768
|
+
self.assertEqual(kwargs["subject"], "1.2.3.4:9000")
|
|
769
|
+
self.assertTrue(kwargs["body"].startswith("v1.2.3 r"))
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
class StartupHandlerTests(TestCase):
|
|
773
|
+
def test_handler_logs_db_errors(self):
|
|
774
|
+
from nodes.apps import _trigger_startup_notification
|
|
775
|
+
from django.db.utils import OperationalError
|
|
776
|
+
|
|
777
|
+
with patch("nodes.apps._startup_notification") as mock_start:
|
|
778
|
+
with patch("nodes.apps.connections") as mock_connections:
|
|
779
|
+
mock_connections.__getitem__.return_value.ensure_connection.side_effect = OperationalError(
|
|
780
|
+
"fail"
|
|
781
|
+
)
|
|
782
|
+
with self.assertLogs("nodes.apps", level="ERROR") as log:
|
|
783
|
+
_trigger_startup_notification()
|
|
784
|
+
|
|
785
|
+
mock_start.assert_not_called()
|
|
786
|
+
self.assertTrue(any("Startup notification skipped" in m for m in log.output))
|
|
787
|
+
|
|
788
|
+
def test_handler_calls_startup_notification(self):
|
|
789
|
+
from nodes.apps import _trigger_startup_notification
|
|
790
|
+
|
|
791
|
+
with patch("nodes.apps._startup_notification") as mock_start:
|
|
792
|
+
with patch("nodes.apps.connections") as mock_connections:
|
|
793
|
+
mock_connections.__getitem__.return_value.ensure_connection.return_value = None
|
|
794
|
+
_trigger_startup_notification()
|
|
795
|
+
|
|
796
|
+
mock_start.assert_called_once()
|
|
797
|
+
|
|
798
|
+
class NotificationManagerTests(TestCase):
|
|
799
|
+
def test_send_writes_trimmed_lines(self):
|
|
800
|
+
from core.notifications import NotificationManager
|
|
801
|
+
|
|
802
|
+
with TemporaryDirectory() as tmp:
|
|
803
|
+
lock = Path(tmp) / "lcd_screen.lck"
|
|
804
|
+
lock.touch()
|
|
805
|
+
manager = NotificationManager(lock_file=lock)
|
|
806
|
+
result = manager.send("a" * 70, "b" * 70)
|
|
807
|
+
self.assertTrue(result)
|
|
808
|
+
content = lock.read_text().splitlines()
|
|
809
|
+
self.assertEqual(content[0], "a" * 64)
|
|
810
|
+
self.assertEqual(content[1], "b" * 64)
|
|
811
|
+
|
|
812
|
+
def test_send_falls_back_to_gui(self):
|
|
813
|
+
from core.notifications import NotificationManager
|
|
814
|
+
|
|
815
|
+
with TemporaryDirectory() as tmp:
|
|
816
|
+
lock = Path(tmp) / "lcd_screen.lck"
|
|
817
|
+
lock.touch()
|
|
818
|
+
manager = NotificationManager(lock_file=lock)
|
|
819
|
+
manager._gui_display = MagicMock()
|
|
820
|
+
with patch.object(
|
|
821
|
+
manager, "_write_lock_file", side_effect=RuntimeError("boom")
|
|
822
|
+
):
|
|
823
|
+
result = manager.send("hi", "there")
|
|
824
|
+
self.assertTrue(result)
|
|
825
|
+
manager._gui_display.assert_called_once_with("hi", "there")
|
|
826
|
+
|
|
827
|
+
def test_send_uses_gui_when_lock_missing(self):
|
|
828
|
+
from core.notifications import NotificationManager
|
|
829
|
+
|
|
830
|
+
with TemporaryDirectory() as tmp:
|
|
831
|
+
lock = Path(tmp) / "lcd_screen.lck"
|
|
832
|
+
manager = NotificationManager(lock_file=lock)
|
|
833
|
+
manager._gui_display = MagicMock()
|
|
834
|
+
result = manager.send("hi", "there")
|
|
835
|
+
self.assertTrue(result)
|
|
836
|
+
manager._gui_display.assert_called_once_with("hi", "there")
|
|
837
|
+
|
|
838
|
+
def test_gui_display_uses_windows_toast(self):
|
|
839
|
+
from core.notifications import NotificationManager
|
|
840
|
+
|
|
841
|
+
with patch("core.notifications.sys.platform", "win32"):
|
|
842
|
+
mock_notify = MagicMock()
|
|
843
|
+
with patch(
|
|
844
|
+
"core.notifications.plyer_notification",
|
|
845
|
+
MagicMock(notify=mock_notify),
|
|
846
|
+
):
|
|
847
|
+
manager = NotificationManager()
|
|
848
|
+
manager._gui_display("hi", "there")
|
|
849
|
+
mock_notify.assert_called_once_with(
|
|
850
|
+
title="Arthexis", message="hi\nthere", timeout=6
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
def test_gui_display_logs_when_toast_unavailable(self):
|
|
854
|
+
from core.notifications import NotificationManager
|
|
855
|
+
|
|
856
|
+
with patch("core.notifications.sys.platform", "win32"):
|
|
857
|
+
with patch("core.notifications.plyer_notification", None):
|
|
858
|
+
with patch("core.notifications.logger") as mock_logger:
|
|
859
|
+
manager = NotificationManager()
|
|
860
|
+
manager._gui_display("hi", "there")
|
|
861
|
+
mock_logger.info.assert_called_once_with("%s %s", "hi", "there")
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
class ContentSampleTransactionTests(TestCase):
|
|
865
|
+
def test_transaction_uuid_behaviour(self):
|
|
866
|
+
sample1 = ContentSample.objects.create(content="a", kind=ContentSample.TEXT)
|
|
867
|
+
self.assertIsNotNone(sample1.transaction_uuid)
|
|
868
|
+
sample2 = ContentSample.objects.create(
|
|
869
|
+
content="b",
|
|
870
|
+
kind=ContentSample.TEXT,
|
|
871
|
+
transaction_uuid=sample1.transaction_uuid,
|
|
872
|
+
)
|
|
873
|
+
self.assertEqual(sample1.transaction_uuid, sample2.transaction_uuid)
|
|
874
|
+
with self.assertRaises(Exception):
|
|
875
|
+
sample1.transaction_uuid = uuid.uuid4()
|
|
876
|
+
sample1.save()
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
class ContentSampleAdminTests(TestCase):
|
|
880
|
+
def setUp(self):
|
|
881
|
+
User = get_user_model()
|
|
882
|
+
self.user = User.objects.create_superuser(
|
|
883
|
+
"clipboard_admin", "admin@example.com", "pass"
|
|
884
|
+
)
|
|
885
|
+
self.client.login(username="clipboard_admin", password="pass")
|
|
886
|
+
|
|
887
|
+
@patch("pyperclip.paste")
|
|
888
|
+
def test_add_from_clipboard_creates_sample(self, mock_paste):
|
|
889
|
+
mock_paste.return_value = "clip text"
|
|
890
|
+
url = reverse("admin:nodes_contentsample_from_clipboard")
|
|
891
|
+
response = self.client.get(url, follow=True)
|
|
892
|
+
self.assertEqual(
|
|
893
|
+
ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
|
|
894
|
+
)
|
|
895
|
+
sample = ContentSample.objects.filter(kind=ContentSample.TEXT).first()
|
|
896
|
+
self.assertEqual(sample.content, "clip text")
|
|
897
|
+
self.assertEqual(sample.user, self.user)
|
|
898
|
+
self.assertIsNone(sample.node)
|
|
899
|
+
self.assertContains(response, "Text sample added from clipboard")
|
|
900
|
+
|
|
901
|
+
@patch("pyperclip.paste")
|
|
902
|
+
def test_add_from_clipboard_sets_node_when_local_exists(self, mock_paste):
|
|
903
|
+
mock_paste.return_value = "clip text"
|
|
904
|
+
Node.objects.create(
|
|
905
|
+
hostname="host",
|
|
906
|
+
address="127.0.0.1",
|
|
907
|
+
port=8000,
|
|
908
|
+
mac_address=Node.get_current_mac(),
|
|
909
|
+
)
|
|
910
|
+
url = reverse("admin:nodes_contentsample_from_clipboard")
|
|
911
|
+
self.client.get(url, follow=True)
|
|
912
|
+
sample = ContentSample.objects.filter(kind=ContentSample.TEXT).first()
|
|
913
|
+
self.assertIsNotNone(sample.node)
|
|
914
|
+
|
|
915
|
+
@patch("pyperclip.paste")
|
|
916
|
+
def test_add_from_clipboard_skips_duplicate(self, mock_paste):
|
|
917
|
+
mock_paste.return_value = "clip text"
|
|
918
|
+
url = reverse("admin:nodes_contentsample_from_clipboard")
|
|
919
|
+
self.client.get(url, follow=True)
|
|
920
|
+
resp = self.client.get(url, follow=True)
|
|
921
|
+
self.assertEqual(
|
|
922
|
+
ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
|
|
923
|
+
)
|
|
924
|
+
self.assertContains(resp, "Duplicate sample not created")
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
class EmailOutboxTests(TestCase):
|
|
928
|
+
def test_node_send_mail_uses_outbox(self):
|
|
929
|
+
node = Node.objects.create(
|
|
930
|
+
hostname="outboxhost",
|
|
931
|
+
address="127.0.0.1",
|
|
932
|
+
port=8000,
|
|
933
|
+
mac_address="00:11:22:33:aa:bb",
|
|
934
|
+
)
|
|
935
|
+
EmailOutbox.objects.create(
|
|
936
|
+
node=node, host="smtp.example.com", port=25, username="u", password="p"
|
|
937
|
+
)
|
|
938
|
+
with patch("nodes.models.get_connection") as gc, patch(
|
|
939
|
+
"nodes.models.send_mail"
|
|
940
|
+
) as sm:
|
|
941
|
+
conn = MagicMock()
|
|
942
|
+
gc.return_value = conn
|
|
943
|
+
node.send_mail("sub", "msg", ["to@example.com"])
|
|
944
|
+
gc.assert_called_once_with(
|
|
945
|
+
host="smtp.example.com",
|
|
946
|
+
port=25,
|
|
947
|
+
username="u",
|
|
948
|
+
password="p",
|
|
949
|
+
use_tls=True,
|
|
950
|
+
use_ssl=False,
|
|
951
|
+
)
|
|
952
|
+
sm.assert_called_once_with(
|
|
953
|
+
"sub",
|
|
954
|
+
"msg",
|
|
955
|
+
settings.DEFAULT_FROM_EMAIL,
|
|
956
|
+
["to@example.com"],
|
|
957
|
+
connection=conn,
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
class ClipboardTaskTests(TestCase):
|
|
962
|
+
@patch("nodes.tasks.pyperclip.paste")
|
|
963
|
+
def test_sample_clipboard_task_creates_sample(self, mock_paste):
|
|
964
|
+
mock_paste.return_value = "task text"
|
|
965
|
+
Node.objects.create(
|
|
966
|
+
hostname="host",
|
|
967
|
+
address="127.0.0.1",
|
|
968
|
+
port=8000,
|
|
969
|
+
mac_address=Node.get_current_mac(),
|
|
970
|
+
)
|
|
971
|
+
sample_clipboard()
|
|
972
|
+
self.assertEqual(
|
|
973
|
+
ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
|
|
974
|
+
)
|
|
975
|
+
sample = ContentSample.objects.filter(kind=ContentSample.TEXT).first()
|
|
976
|
+
self.assertEqual(sample.content, "task text")
|
|
977
|
+
self.assertIsNone(sample.user)
|
|
978
|
+
self.assertIsNotNone(sample.node)
|
|
979
|
+
self.assertEqual(sample.node.hostname, "host")
|
|
980
|
+
# Duplicate should not create another sample
|
|
981
|
+
sample_clipboard()
|
|
982
|
+
self.assertEqual(
|
|
983
|
+
ContentSample.objects.filter(kind=ContentSample.TEXT).count(), 1
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
@patch("nodes.tasks.capture_screenshot")
|
|
987
|
+
def test_capture_node_screenshot_task(self, mock_capture):
|
|
988
|
+
node = Node.objects.create(
|
|
989
|
+
hostname="host",
|
|
990
|
+
address="127.0.0.1",
|
|
991
|
+
port=8000,
|
|
992
|
+
mac_address=Node.get_current_mac(),
|
|
993
|
+
)
|
|
994
|
+
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
995
|
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
996
|
+
file_path = screenshot_dir / "test.png"
|
|
997
|
+
file_path.write_bytes(b"task")
|
|
998
|
+
mock_capture.return_value = Path("screenshots/test.png")
|
|
999
|
+
capture_node_screenshot("http://example.com")
|
|
1000
|
+
self.assertEqual(
|
|
1001
|
+
ContentSample.objects.filter(kind=ContentSample.IMAGE).count(), 1
|
|
1002
|
+
)
|
|
1003
|
+
screenshot = ContentSample.objects.filter(kind=ContentSample.IMAGE).first()
|
|
1004
|
+
self.assertEqual(screenshot.node, node)
|
|
1005
|
+
self.assertEqual(screenshot.path, "screenshots/test.png")
|
|
1006
|
+
self.assertEqual(screenshot.method, "TASK")
|
|
1007
|
+
|
|
1008
|
+
@patch("nodes.tasks.capture_screenshot")
|
|
1009
|
+
def test_capture_node_screenshot_handles_error(self, mock_capture):
|
|
1010
|
+
Node.objects.create(
|
|
1011
|
+
hostname="host",
|
|
1012
|
+
address="127.0.0.1",
|
|
1013
|
+
port=8000,
|
|
1014
|
+
mac_address=Node.get_current_mac(),
|
|
1015
|
+
)
|
|
1016
|
+
mock_capture.side_effect = RuntimeError("boom")
|
|
1017
|
+
result = capture_node_screenshot("http://example.com")
|
|
1018
|
+
self.assertEqual(result, "")
|
|
1019
|
+
self.assertEqual(
|
|
1020
|
+
ContentSample.objects.filter(kind=ContentSample.IMAGE).count(), 0
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
class CaptureScreenshotTests(TestCase):
|
|
1025
|
+
@patch("nodes.utils.webdriver.Firefox")
|
|
1026
|
+
def test_connection_failure_does_not_raise(self, mock_firefox):
|
|
1027
|
+
browser = MagicMock()
|
|
1028
|
+
mock_firefox.return_value.__enter__.return_value = browser
|
|
1029
|
+
browser.get.side_effect = WebDriverException("boom")
|
|
1030
|
+
browser.save_screenshot.return_value = True
|
|
1031
|
+
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
1032
|
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
1033
|
+
result = capture_screenshot("http://example.com")
|
|
1034
|
+
self.assertEqual(result.parent, screenshot_dir)
|
|
1035
|
+
browser.save_screenshot.assert_called_once()
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
class NodeRoleAdminTests(TestCase):
|
|
1039
|
+
def setUp(self):
|
|
1040
|
+
User = get_user_model()
|
|
1041
|
+
self.user = User.objects.create_superuser(
|
|
1042
|
+
"role_admin", "admin@example.com", "pass"
|
|
1043
|
+
)
|
|
1044
|
+
self.client.login(username="role_admin", password="pass")
|
|
1045
|
+
|
|
1046
|
+
def test_change_role_nodes(self):
|
|
1047
|
+
role = NodeRole.objects.create(name="TestRole")
|
|
1048
|
+
node1 = Node.objects.create(
|
|
1049
|
+
hostname="n1",
|
|
1050
|
+
address="127.0.0.1",
|
|
1051
|
+
port=8000,
|
|
1052
|
+
mac_address="00:11:22:33:44:55",
|
|
1053
|
+
role=role,
|
|
1054
|
+
)
|
|
1055
|
+
node2 = Node.objects.create(
|
|
1056
|
+
hostname="n2",
|
|
1057
|
+
address="127.0.0.2",
|
|
1058
|
+
port=8000,
|
|
1059
|
+
mac_address="00:11:22:33:44:66",
|
|
1060
|
+
)
|
|
1061
|
+
url = reverse("admin:nodes_noderole_change", args=[role.pk])
|
|
1062
|
+
resp = self.client.get(url)
|
|
1063
|
+
self.assertContains(resp, f'<option value="{node1.pk}" selected>')
|
|
1064
|
+
post_data = {"name": "TestRole", "description": "", "nodes": [node2.pk]}
|
|
1065
|
+
resp = self.client.post(url, post_data, follow=True)
|
|
1066
|
+
self.assertRedirects(resp, reverse("admin:nodes_noderole_changelist"))
|
|
1067
|
+
node1.refresh_from_db()
|
|
1068
|
+
node2.refresh_from_db()
|
|
1069
|
+
self.assertIsNone(node1.role)
|
|
1070
|
+
self.assertEqual(node2.role, role)
|
|
1071
|
+
|
|
1072
|
+
|