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.

Files changed (73) hide show
  1. arthexis-0.1.3.dist-info/METADATA +126 -0
  2. arthexis-0.1.3.dist-info/RECORD +73 -0
  3. arthexis-0.1.3.dist-info/WHEEL +5 -0
  4. arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
  5. arthexis-0.1.3.dist-info/top_level.txt +5 -0
  6. config/__init__.py +6 -0
  7. config/active_app.py +15 -0
  8. config/asgi.py +29 -0
  9. config/auth_app.py +8 -0
  10. config/celery.py +19 -0
  11. config/context_processors.py +68 -0
  12. config/loadenv.py +11 -0
  13. config/logging.py +43 -0
  14. config/middleware.py +25 -0
  15. config/offline.py +47 -0
  16. config/settings.py +374 -0
  17. config/urls.py +91 -0
  18. config/wsgi.py +17 -0
  19. core/__init__.py +0 -0
  20. core/admin.py +830 -0
  21. core/apps.py +67 -0
  22. core/backends.py +82 -0
  23. core/entity.py +97 -0
  24. core/environment.py +43 -0
  25. core/fields.py +70 -0
  26. core/lcd_screen.py +77 -0
  27. core/middleware.py +34 -0
  28. core/models.py +1277 -0
  29. core/notifications.py +95 -0
  30. core/release.py +451 -0
  31. core/system.py +111 -0
  32. core/tasks.py +100 -0
  33. core/tests.py +483 -0
  34. core/urls.py +11 -0
  35. core/user_data.py +333 -0
  36. core/views.py +431 -0
  37. nodes/__init__.py +0 -0
  38. nodes/actions.py +72 -0
  39. nodes/admin.py +347 -0
  40. nodes/apps.py +76 -0
  41. nodes/lcd.py +151 -0
  42. nodes/models.py +577 -0
  43. nodes/tasks.py +50 -0
  44. nodes/tests.py +1072 -0
  45. nodes/urls.py +13 -0
  46. nodes/utils.py +62 -0
  47. nodes/views.py +262 -0
  48. ocpp/__init__.py +0 -0
  49. ocpp/admin.py +392 -0
  50. ocpp/apps.py +24 -0
  51. ocpp/consumers.py +267 -0
  52. ocpp/evcs.py +911 -0
  53. ocpp/models.py +300 -0
  54. ocpp/routing.py +9 -0
  55. ocpp/simulator.py +357 -0
  56. ocpp/store.py +175 -0
  57. ocpp/tasks.py +27 -0
  58. ocpp/test_export_import.py +129 -0
  59. ocpp/test_rfid.py +345 -0
  60. ocpp/tests.py +1229 -0
  61. ocpp/transactions_io.py +119 -0
  62. ocpp/urls.py +17 -0
  63. ocpp/views.py +359 -0
  64. pages/__init__.py +0 -0
  65. pages/admin.py +231 -0
  66. pages/apps.py +10 -0
  67. pages/checks.py +41 -0
  68. pages/context_processors.py +72 -0
  69. pages/models.py +224 -0
  70. pages/tests.py +628 -0
  71. pages/urls.py +17 -0
  72. pages/utils.py +13 -0
  73. 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
+