arthexis 0.1.9__py3-none-any.whl → 0.1.11__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.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
- arthexis-0.1.11.dist-info/RECORD +99 -0
- config/context_processors.py +1 -0
- config/settings.py +245 -26
- config/urls.py +11 -4
- core/admin.py +585 -57
- core/apps.py +29 -1
- core/auto_upgrade.py +57 -0
- core/backends.py +115 -3
- core/environment.py +23 -5
- core/fields.py +93 -0
- core/mailer.py +3 -1
- core/models.py +482 -38
- core/reference_utils.py +108 -0
- core/sigil_builder.py +23 -5
- core/sigil_resolver.py +35 -4
- core/system.py +400 -140
- core/tasks.py +151 -8
- core/temp_passwords.py +181 -0
- core/test_system_info.py +97 -1
- core/tests.py +393 -15
- core/user_data.py +154 -16
- core/views.py +499 -20
- nodes/admin.py +149 -6
- nodes/backends.py +125 -18
- nodes/dns.py +203 -0
- nodes/models.py +498 -9
- nodes/tests.py +682 -3
- nodes/views.py +154 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +255 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +52 -7
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +414 -8
- ocpp/views.py +109 -76
- pages/admin.py +9 -1
- pages/context_processors.py +24 -4
- pages/defaults.py +14 -0
- pages/forms.py +131 -0
- pages/models.py +53 -14
- pages/tests.py +450 -14
- pages/urls.py +4 -0
- pages/views.py +419 -110
- arthexis-0.1.9.dist-info/RECORD +0 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
nodes/tests.py
CHANGED
|
@@ -7,8 +7,10 @@ django.setup()
|
|
|
7
7
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from types import SimpleNamespace
|
|
10
|
+
import unittest.mock as mock
|
|
10
11
|
from unittest.mock import patch, call, MagicMock
|
|
11
12
|
from django.core import mail
|
|
13
|
+
from django.core.mail import EmailMessage
|
|
12
14
|
from django.core.management import call_command
|
|
13
15
|
import socket
|
|
14
16
|
import base64
|
|
@@ -18,17 +20,22 @@ from tempfile import TemporaryDirectory
|
|
|
18
20
|
import shutil
|
|
19
21
|
import stat
|
|
20
22
|
import time
|
|
23
|
+
from datetime import timedelta
|
|
21
24
|
|
|
22
|
-
from django.test import Client, TestCase, TransactionTestCase, override_settings
|
|
25
|
+
from django.test import Client, SimpleTestCase, TestCase, TransactionTestCase, override_settings
|
|
23
26
|
from django.urls import reverse
|
|
24
27
|
from django.contrib.auth import get_user_model
|
|
25
28
|
from django.contrib import admin
|
|
26
29
|
from django.contrib.sites.models import Site
|
|
27
30
|
from django_celery_beat.models import PeriodicTask
|
|
28
31
|
from django.conf import settings
|
|
32
|
+
from django.utils import timezone
|
|
33
|
+
from dns import resolver as dns_resolver
|
|
29
34
|
from .actions import NodeAction
|
|
35
|
+
from . import dns as dns_utils
|
|
30
36
|
from selenium.common.exceptions import WebDriverException
|
|
31
37
|
from .utils import capture_screenshot
|
|
38
|
+
from django.db.utils import DatabaseError
|
|
32
39
|
|
|
33
40
|
from .models import (
|
|
34
41
|
Node,
|
|
@@ -38,12 +45,14 @@ from .models import (
|
|
|
38
45
|
NodeFeature,
|
|
39
46
|
NodeFeatureAssignment,
|
|
40
47
|
NetMessage,
|
|
48
|
+
NodeManager,
|
|
49
|
+
DNSRecord,
|
|
41
50
|
)
|
|
42
51
|
from .backends import OutboxEmailBackend
|
|
43
52
|
from .tasks import capture_node_screenshot, sample_clipboard
|
|
44
53
|
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
45
54
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
46
|
-
from core.models import PackageRelease
|
|
55
|
+
from core.models import PackageRelease, SecurityGroup
|
|
47
56
|
|
|
48
57
|
|
|
49
58
|
class NodeTests(TestCase):
|
|
@@ -54,7 +63,23 @@ class NodeTests(TestCase):
|
|
|
54
63
|
self.client.force_login(self.user)
|
|
55
64
|
NodeRole.objects.get_or_create(name="Terminal")
|
|
56
65
|
|
|
66
|
+
|
|
67
|
+
class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
|
|
68
|
+
def test_get_local_handles_database_errors(self):
|
|
69
|
+
with patch.object(Node.objects, "filter", side_effect=DatabaseError("fail")):
|
|
70
|
+
with self.assertLogs("nodes.models", level="DEBUG") as logs:
|
|
71
|
+
result = Node.get_local()
|
|
72
|
+
|
|
73
|
+
self.assertIsNone(result)
|
|
74
|
+
self.assertTrue(
|
|
75
|
+
any("Node.get_local skipped: database unavailable" in message for message in logs.output)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class NodeGetLocalTests(TestCase):
|
|
57
80
|
def test_register_current_does_not_create_release(self):
|
|
81
|
+
node = None
|
|
82
|
+
created = False
|
|
58
83
|
with TemporaryDirectory() as tmp:
|
|
59
84
|
base = Path(tmp)
|
|
60
85
|
with override_settings(BASE_DIR=base):
|
|
@@ -70,8 +95,11 @@ class NodeTests(TestCase):
|
|
|
70
95
|
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
71
96
|
patch.object(Node, "ensure_keys"),
|
|
72
97
|
):
|
|
73
|
-
Node.register_current()
|
|
98
|
+
node, created = Node.register_current()
|
|
74
99
|
self.assertEqual(PackageRelease.objects.count(), 0)
|
|
100
|
+
self.assertIsNotNone(node)
|
|
101
|
+
self.assertTrue(created)
|
|
102
|
+
self.assertEqual(node.current_relation, Node.Relation.SELF)
|
|
75
103
|
|
|
76
104
|
def test_register_and_list_node(self):
|
|
77
105
|
response = self.client.post(
|
|
@@ -86,6 +114,8 @@ class NodeTests(TestCase):
|
|
|
86
114
|
)
|
|
87
115
|
self.assertEqual(response.status_code, 200)
|
|
88
116
|
self.assertEqual(Node.objects.count(), 1)
|
|
117
|
+
node = Node.objects.get(mac_address="00:11:22:33:44:55")
|
|
118
|
+
self.assertEqual(node.current_relation, Node.Relation.PEER)
|
|
89
119
|
|
|
90
120
|
# allow same IP with different MAC
|
|
91
121
|
self.client.post(
|
|
@@ -156,6 +186,118 @@ class NodeTests(TestCase):
|
|
|
156
186
|
node.refresh_from_db()
|
|
157
187
|
self.assertFalse(node.has_feature("clipboard-poll"))
|
|
158
188
|
|
|
189
|
+
def test_register_node_records_version_details(self):
|
|
190
|
+
url = reverse("register-node")
|
|
191
|
+
payload = {
|
|
192
|
+
"hostname": "versioned",
|
|
193
|
+
"address": "127.0.0.5",
|
|
194
|
+
"port": 8100,
|
|
195
|
+
"mac_address": "aa:bb:cc:dd:ee:10",
|
|
196
|
+
"installed_version": "2.0.1",
|
|
197
|
+
"installed_revision": "rev-abcdef",
|
|
198
|
+
}
|
|
199
|
+
response = self.client.post(
|
|
200
|
+
url, data=json.dumps(payload), content_type="application/json"
|
|
201
|
+
)
|
|
202
|
+
self.assertEqual(response.status_code, 200)
|
|
203
|
+
node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:10")
|
|
204
|
+
self.assertEqual(node.installed_version, "2.0.1")
|
|
205
|
+
self.assertEqual(node.installed_revision, "rev-abcdef")
|
|
206
|
+
|
|
207
|
+
update_payload = {
|
|
208
|
+
**payload,
|
|
209
|
+
"installed_version": "2.1.0",
|
|
210
|
+
"installed_revision": "rev-fedcba",
|
|
211
|
+
}
|
|
212
|
+
second = self.client.post(
|
|
213
|
+
url, data=json.dumps(update_payload), content_type="application/json"
|
|
214
|
+
)
|
|
215
|
+
self.assertEqual(second.status_code, 200)
|
|
216
|
+
node.refresh_from_db()
|
|
217
|
+
self.assertEqual(node.installed_version, "2.1.0")
|
|
218
|
+
self.assertEqual(node.installed_revision, "rev-fedcba")
|
|
219
|
+
|
|
220
|
+
def test_register_node_update_triggers_notification(self):
|
|
221
|
+
node = Node.objects.create(
|
|
222
|
+
hostname="friend",
|
|
223
|
+
address="10.1.1.5",
|
|
224
|
+
port=8123,
|
|
225
|
+
mac_address="aa:bb:cc:dd:ee:01",
|
|
226
|
+
installed_version="1.0.0",
|
|
227
|
+
installed_revision="rev-old",
|
|
228
|
+
)
|
|
229
|
+
url = reverse("register-node")
|
|
230
|
+
payload = {
|
|
231
|
+
"hostname": "friend",
|
|
232
|
+
"address": "10.1.1.5",
|
|
233
|
+
"port": 8123,
|
|
234
|
+
"mac_address": "aa:bb:cc:dd:ee:01",
|
|
235
|
+
"installed_version": "2.0.0",
|
|
236
|
+
"installed_revision": "abcdef123456",
|
|
237
|
+
}
|
|
238
|
+
with patch("nodes.models.notify_async") as mock_notify:
|
|
239
|
+
response = self.client.post(
|
|
240
|
+
url, data=json.dumps(payload), content_type="application/json"
|
|
241
|
+
)
|
|
242
|
+
self.assertEqual(response.status_code, 200)
|
|
243
|
+
node.refresh_from_db()
|
|
244
|
+
self.assertEqual(node.installed_version, "2.0.0")
|
|
245
|
+
self.assertEqual(node.installed_revision, "abcdef123456")
|
|
246
|
+
mock_notify.assert_called_once()
|
|
247
|
+
subject, body = mock_notify.call_args[0]
|
|
248
|
+
self.assertEqual(subject, "UP friend")
|
|
249
|
+
self.assertEqual(body, "v2.0.0 r123456")
|
|
250
|
+
|
|
251
|
+
def test_register_node_update_without_version_change_still_notifies(self):
|
|
252
|
+
node = Node.objects.create(
|
|
253
|
+
hostname="friend",
|
|
254
|
+
address="10.1.1.5",
|
|
255
|
+
port=8123,
|
|
256
|
+
mac_address="aa:bb:cc:dd:ee:02",
|
|
257
|
+
installed_version="2.0.0",
|
|
258
|
+
installed_revision="abcdef123456",
|
|
259
|
+
)
|
|
260
|
+
url = reverse("register-node")
|
|
261
|
+
payload = {
|
|
262
|
+
"hostname": "friend",
|
|
263
|
+
"address": "10.1.1.5",
|
|
264
|
+
"port": 8123,
|
|
265
|
+
"mac_address": "aa:bb:cc:dd:ee:02",
|
|
266
|
+
"installed_version": "2.0.0",
|
|
267
|
+
"installed_revision": "abcdef123456",
|
|
268
|
+
}
|
|
269
|
+
with patch("nodes.models.notify_async") as mock_notify:
|
|
270
|
+
response = self.client.post(
|
|
271
|
+
url, data=json.dumps(payload), content_type="application/json"
|
|
272
|
+
)
|
|
273
|
+
self.assertEqual(response.status_code, 200)
|
|
274
|
+
node.refresh_from_db()
|
|
275
|
+
mock_notify.assert_called_once()
|
|
276
|
+
subject, body = mock_notify.call_args[0]
|
|
277
|
+
self.assertEqual(subject, "UP friend")
|
|
278
|
+
self.assertEqual(body, "v2.0.0 r123456")
|
|
279
|
+
|
|
280
|
+
def test_register_node_creation_triggers_notification(self):
|
|
281
|
+
url = reverse("register-node")
|
|
282
|
+
payload = {
|
|
283
|
+
"hostname": "newbie",
|
|
284
|
+
"address": "10.1.1.6",
|
|
285
|
+
"port": 8124,
|
|
286
|
+
"mac_address": "aa:bb:cc:dd:ee:03",
|
|
287
|
+
"installed_version": "3.0.0",
|
|
288
|
+
"installed_revision": "rev-1234567890",
|
|
289
|
+
}
|
|
290
|
+
with patch("nodes.models.notify_async") as mock_notify:
|
|
291
|
+
response = self.client.post(
|
|
292
|
+
url, data=json.dumps(payload), content_type="application/json"
|
|
293
|
+
)
|
|
294
|
+
self.assertEqual(response.status_code, 200)
|
|
295
|
+
self.assertTrue(Node.objects.filter(mac_address="aa:bb:cc:dd:ee:03").exists())
|
|
296
|
+
mock_notify.assert_called_once()
|
|
297
|
+
subject, body = mock_notify.call_args[0]
|
|
298
|
+
self.assertEqual(subject, "UP newbie")
|
|
299
|
+
self.assertEqual(body, "v3.0.0 r567890")
|
|
300
|
+
|
|
159
301
|
def test_register_node_sets_cors_headers(self):
|
|
160
302
|
payload = {
|
|
161
303
|
"hostname": "cors",
|
|
@@ -173,6 +315,74 @@ class NodeTests(TestCase):
|
|
|
173
315
|
self.assertEqual(response["Access-Control-Allow-Origin"], "http://example.com")
|
|
174
316
|
self.assertEqual(response["Access-Control-Allow-Credentials"], "true")
|
|
175
317
|
|
|
318
|
+
def test_register_node_requires_auth_without_signature(self):
|
|
319
|
+
self.client.logout()
|
|
320
|
+
payload = {
|
|
321
|
+
"hostname": "visitor",
|
|
322
|
+
"address": "127.0.0.1",
|
|
323
|
+
"port": 8000,
|
|
324
|
+
"mac_address": "aa:bb:cc:dd:ee:00",
|
|
325
|
+
}
|
|
326
|
+
response = self.client.post(
|
|
327
|
+
reverse("register-node"),
|
|
328
|
+
data=json.dumps(payload),
|
|
329
|
+
content_type="application/json",
|
|
330
|
+
HTTP_ORIGIN="http://example.com",
|
|
331
|
+
)
|
|
332
|
+
self.assertEqual(response.status_code, 401)
|
|
333
|
+
data = response.json()
|
|
334
|
+
self.assertEqual(data["detail"], "authentication required")
|
|
335
|
+
self.assertEqual(response["Access-Control-Allow-Origin"], "http://example.com")
|
|
336
|
+
|
|
337
|
+
def test_register_node_allows_preflight_without_authentication(self):
|
|
338
|
+
self.client.logout()
|
|
339
|
+
response = self.client.options(
|
|
340
|
+
reverse("register-node"), HTTP_ORIGIN="https://example.com"
|
|
341
|
+
)
|
|
342
|
+
self.assertEqual(response.status_code, 200)
|
|
343
|
+
self.assertEqual(response["Access-Control-Allow-Origin"], "https://example.com")
|
|
344
|
+
self.assertEqual(response["Access-Control-Allow-Credentials"], "true")
|
|
345
|
+
|
|
346
|
+
def test_register_node_accepts_signed_payload_without_login(self):
|
|
347
|
+
self.client.logout()
|
|
348
|
+
NodeFeature.objects.get_or_create(
|
|
349
|
+
slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
|
|
350
|
+
)
|
|
351
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
352
|
+
public_bytes = private_key.public_key().public_bytes(
|
|
353
|
+
encoding=serialization.Encoding.PEM,
|
|
354
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
355
|
+
).decode()
|
|
356
|
+
token = "visitor-token"
|
|
357
|
+
signature = base64.b64encode(
|
|
358
|
+
private_key.sign(
|
|
359
|
+
token.encode(),
|
|
360
|
+
padding.PKCS1v15(),
|
|
361
|
+
hashes.SHA256(),
|
|
362
|
+
)
|
|
363
|
+
).decode()
|
|
364
|
+
payload = {
|
|
365
|
+
"hostname": "visitor",
|
|
366
|
+
"address": "127.0.0.1",
|
|
367
|
+
"port": 8000,
|
|
368
|
+
"mac_address": "aa:bb:cc:dd:ee:11",
|
|
369
|
+
"public_key": public_bytes,
|
|
370
|
+
"token": token,
|
|
371
|
+
"signature": signature,
|
|
372
|
+
"features": ["clipboard-poll"],
|
|
373
|
+
}
|
|
374
|
+
response = self.client.post(
|
|
375
|
+
reverse("register-node"),
|
|
376
|
+
data=json.dumps(payload),
|
|
377
|
+
content_type="application/json",
|
|
378
|
+
HTTP_ORIGIN="http://example.com",
|
|
379
|
+
)
|
|
380
|
+
self.assertEqual(response.status_code, 200)
|
|
381
|
+
self.assertEqual(response["Access-Control-Allow-Origin"], "http://example.com")
|
|
382
|
+
node = Node.objects.get(mac_address="aa:bb:cc:dd:ee:11")
|
|
383
|
+
self.assertEqual(node.public_key, public_bytes)
|
|
384
|
+
self.assertTrue(node.has_feature("clipboard-poll"))
|
|
385
|
+
|
|
176
386
|
def test_register_node_accepts_text_plain_payload(self):
|
|
177
387
|
payload = {
|
|
178
388
|
"hostname": "plain",
|
|
@@ -188,6 +398,48 @@ class NodeTests(TestCase):
|
|
|
188
398
|
self.assertEqual(response.status_code, 200)
|
|
189
399
|
self.assertTrue(Node.objects.filter(mac_address="aa:bb:cc:dd:ee:ff").exists())
|
|
190
400
|
|
|
401
|
+
def test_register_node_respects_relation_payload(self):
|
|
402
|
+
payload = {
|
|
403
|
+
"hostname": "relation",
|
|
404
|
+
"address": "127.0.0.2",
|
|
405
|
+
"port": 8100,
|
|
406
|
+
"mac_address": "11:22:33:44:55:66",
|
|
407
|
+
"current_relation": "Downstream",
|
|
408
|
+
}
|
|
409
|
+
response = self.client.post(
|
|
410
|
+
reverse("register-node"),
|
|
411
|
+
data=json.dumps(payload),
|
|
412
|
+
content_type="application/json",
|
|
413
|
+
)
|
|
414
|
+
self.assertEqual(response.status_code, 200)
|
|
415
|
+
node = Node.objects.get(mac_address="11:22:33:44:55:66")
|
|
416
|
+
self.assertEqual(node.current_relation, Node.Relation.DOWNSTREAM)
|
|
417
|
+
|
|
418
|
+
update_payload = {
|
|
419
|
+
**payload,
|
|
420
|
+
"hostname": "relation-updated",
|
|
421
|
+
"current_relation": "Upstream",
|
|
422
|
+
}
|
|
423
|
+
second = self.client.post(
|
|
424
|
+
reverse("register-node"),
|
|
425
|
+
data=json.dumps(update_payload),
|
|
426
|
+
content_type="application/json",
|
|
427
|
+
)
|
|
428
|
+
self.assertEqual(second.status_code, 200)
|
|
429
|
+
node.refresh_from_db()
|
|
430
|
+
self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
|
|
431
|
+
|
|
432
|
+
final_payload = {**update_payload, "hostname": "relation-final"}
|
|
433
|
+
final_payload.pop("current_relation")
|
|
434
|
+
third = self.client.post(
|
|
435
|
+
reverse("register-node"),
|
|
436
|
+
data=json.dumps(final_payload),
|
|
437
|
+
content_type="application/json",
|
|
438
|
+
)
|
|
439
|
+
self.assertEqual(third.status_code, 200)
|
|
440
|
+
node.refresh_from_db()
|
|
441
|
+
self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
|
|
442
|
+
|
|
191
443
|
|
|
192
444
|
class NodeRegisterCurrentTests(TestCase):
|
|
193
445
|
def setUp(self):
|
|
@@ -197,6 +449,26 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
197
449
|
self.client.force_login(self.user)
|
|
198
450
|
NodeRole.objects.get_or_create(name="Terminal")
|
|
199
451
|
|
|
452
|
+
def test_register_current_notifies_peers_on_start(self):
|
|
453
|
+
with TemporaryDirectory() as tmp:
|
|
454
|
+
base = Path(tmp)
|
|
455
|
+
with override_settings(BASE_DIR=base):
|
|
456
|
+
with (
|
|
457
|
+
patch(
|
|
458
|
+
"nodes.models.Node.get_current_mac",
|
|
459
|
+
return_value="00:ff:ee:dd:cc:bb",
|
|
460
|
+
),
|
|
461
|
+
patch("nodes.models.socket.gethostname", return_value="testhost"),
|
|
462
|
+
patch(
|
|
463
|
+
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
464
|
+
),
|
|
465
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
466
|
+
patch.object(Node, "ensure_keys"),
|
|
467
|
+
patch.object(Node, "notify_peers_of_update") as mock_notify,
|
|
468
|
+
):
|
|
469
|
+
Node.register_current()
|
|
470
|
+
mock_notify.assert_called_once()
|
|
471
|
+
|
|
200
472
|
def test_register_current_refreshes_lcd_feature(self):
|
|
201
473
|
NodeFeature.objects.get_or_create(
|
|
202
474
|
slug="lcd-screen", defaults={"display": "LCD Screen"}
|
|
@@ -262,6 +534,94 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
262
534
|
node.refresh_from_db()
|
|
263
535
|
self.assertTrue(node.has_feature("lcd-screen"))
|
|
264
536
|
|
|
537
|
+
def test_register_current_notifies_peers_on_version_upgrade(self):
|
|
538
|
+
remote = Node.objects.create(
|
|
539
|
+
hostname="remote",
|
|
540
|
+
address="10.0.0.2",
|
|
541
|
+
port=9100,
|
|
542
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
543
|
+
)
|
|
544
|
+
with TemporaryDirectory() as tmp:
|
|
545
|
+
base = Path(tmp)
|
|
546
|
+
(base / "VERSION").write_text("2.0.0")
|
|
547
|
+
with override_settings(BASE_DIR=base):
|
|
548
|
+
with (
|
|
549
|
+
patch(
|
|
550
|
+
"nodes.models.Node.get_current_mac",
|
|
551
|
+
return_value="00:ff:ee:dd:cc:bb",
|
|
552
|
+
),
|
|
553
|
+
patch("nodes.models.socket.gethostname", return_value="localnode"),
|
|
554
|
+
patch(
|
|
555
|
+
"nodes.models.socket.gethostbyname",
|
|
556
|
+
return_value="192.168.1.5",
|
|
557
|
+
),
|
|
558
|
+
patch("nodes.models.revision.get_revision", return_value="newrev"),
|
|
559
|
+
patch("requests.post") as mock_post,
|
|
560
|
+
):
|
|
561
|
+
Node.objects.create(
|
|
562
|
+
hostname="localnode",
|
|
563
|
+
address="192.168.1.5",
|
|
564
|
+
port=8000,
|
|
565
|
+
mac_address="00:ff:ee:dd:cc:bb",
|
|
566
|
+
installed_version="1.9.0",
|
|
567
|
+
installed_revision="oldrev",
|
|
568
|
+
)
|
|
569
|
+
mock_post.return_value = SimpleNamespace(
|
|
570
|
+
ok=True, status_code=200, text=""
|
|
571
|
+
)
|
|
572
|
+
node, created = Node.register_current()
|
|
573
|
+
self.assertFalse(created)
|
|
574
|
+
self.assertGreaterEqual(mock_post.call_count, 1)
|
|
575
|
+
args, kwargs = mock_post.call_args
|
|
576
|
+
self.assertIn(str(remote.port), args[0])
|
|
577
|
+
payload = json.loads(kwargs["data"])
|
|
578
|
+
self.assertEqual(payload["hostname"], "localnode")
|
|
579
|
+
self.assertEqual(payload["installed_version"], "2.0.0")
|
|
580
|
+
self.assertEqual(payload["installed_revision"], "newrev")
|
|
581
|
+
|
|
582
|
+
def test_register_current_notifies_peers_without_version_change(self):
|
|
583
|
+
Node.objects.create(
|
|
584
|
+
hostname="remote",
|
|
585
|
+
address="10.0.0.3",
|
|
586
|
+
port=9200,
|
|
587
|
+
mac_address="aa:bb:cc:dd:ee:11",
|
|
588
|
+
)
|
|
589
|
+
with TemporaryDirectory() as tmp:
|
|
590
|
+
base = Path(tmp)
|
|
591
|
+
(base / "VERSION").write_text("1.0.0")
|
|
592
|
+
with override_settings(BASE_DIR=base):
|
|
593
|
+
with (
|
|
594
|
+
patch(
|
|
595
|
+
"nodes.models.Node.get_current_mac",
|
|
596
|
+
return_value="00:ff:ee:dd:cc:cc",
|
|
597
|
+
),
|
|
598
|
+
patch("nodes.models.socket.gethostname", return_value="samever"),
|
|
599
|
+
patch(
|
|
600
|
+
"nodes.models.socket.gethostbyname",
|
|
601
|
+
return_value="192.168.1.6",
|
|
602
|
+
),
|
|
603
|
+
patch("nodes.models.revision.get_revision", return_value="rev1"),
|
|
604
|
+
patch("requests.post") as mock_post,
|
|
605
|
+
):
|
|
606
|
+
Node.objects.create(
|
|
607
|
+
hostname="samever",
|
|
608
|
+
address="192.168.1.6",
|
|
609
|
+
port=8000,
|
|
610
|
+
mac_address="00:ff:ee:dd:cc:cc",
|
|
611
|
+
installed_version="1.0.0",
|
|
612
|
+
installed_revision="rev1",
|
|
613
|
+
)
|
|
614
|
+
mock_post.return_value = SimpleNamespace(
|
|
615
|
+
ok=True, status_code=200, text=""
|
|
616
|
+
)
|
|
617
|
+
Node.register_current()
|
|
618
|
+
self.assertEqual(mock_post.call_count, 1)
|
|
619
|
+
args, kwargs = mock_post.call_args
|
|
620
|
+
self.assertIn("/nodes/register/", args[0])
|
|
621
|
+
payload = json.loads(kwargs["data"])
|
|
622
|
+
self.assertEqual(payload["installed_version"], "1.0.0")
|
|
623
|
+
self.assertEqual(payload.get("installed_revision"), "rev1")
|
|
624
|
+
|
|
265
625
|
@patch("nodes.views.capture_screenshot")
|
|
266
626
|
def test_capture_screenshot(self, mock_capture):
|
|
267
627
|
hostname = socket.gethostname()
|
|
@@ -399,6 +759,7 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
399
759
|
"body": "world",
|
|
400
760
|
"seen": [],
|
|
401
761
|
"sender": str(sender.uuid),
|
|
762
|
+
"origin": str(sender.uuid),
|
|
402
763
|
}
|
|
403
764
|
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
404
765
|
signature = key.sign(payload_json.encode(), padding.PKCS1v15(), hashes.SHA256())
|
|
@@ -410,6 +771,8 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
410
771
|
)
|
|
411
772
|
self.assertEqual(resp.status_code, 200)
|
|
412
773
|
self.assertTrue(NetMessage.objects.filter(uuid=msg_id).exists())
|
|
774
|
+
message = NetMessage.objects.get(uuid=msg_id)
|
|
775
|
+
self.assertEqual(message.node_origin, sender)
|
|
413
776
|
|
|
414
777
|
def test_clipboard_polling_creates_task(self):
|
|
415
778
|
feature, _ = NodeFeature.objects.get_or_create(
|
|
@@ -446,6 +809,15 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
446
809
|
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
447
810
|
|
|
448
811
|
|
|
812
|
+
class CheckRegistrationReadyCommandTests(TestCase):
|
|
813
|
+
def test_command_completes_successfully(self):
|
|
814
|
+
NodeRole.objects.get_or_create(name="Terminal")
|
|
815
|
+
with TemporaryDirectory() as tmp:
|
|
816
|
+
base = Path(tmp)
|
|
817
|
+
with override_settings(BASE_DIR=base):
|
|
818
|
+
call_command("check_registration_ready")
|
|
819
|
+
|
|
820
|
+
|
|
449
821
|
class NodeAdminTests(TestCase):
|
|
450
822
|
|
|
451
823
|
def setUp(self):
|
|
@@ -702,6 +1074,17 @@ class NetMessageReachTests(TestCase):
|
|
|
702
1074
|
self.assertEqual(roles, {"Constellation", "Satellite", "Control"})
|
|
703
1075
|
self.assertEqual(mock_post.call_count, 3)
|
|
704
1076
|
|
|
1077
|
+
@patch("requests.post")
|
|
1078
|
+
def test_default_reach_not_limited_to_terminal(self, mock_post):
|
|
1079
|
+
msg = NetMessage.objects.create(subject="s", body="b")
|
|
1080
|
+
with patch.object(Node, "get_local", return_value=None), patch(
|
|
1081
|
+
"random.shuffle", side_effect=lambda seq: None
|
|
1082
|
+
):
|
|
1083
|
+
msg.propagate()
|
|
1084
|
+
roles = set(msg.propagated_to.values_list("role__name", flat=True))
|
|
1085
|
+
self.assertIn("Control", roles)
|
|
1086
|
+
self.assertEqual(mock_post.call_count, 3)
|
|
1087
|
+
|
|
705
1088
|
|
|
706
1089
|
class NetMessagePropagationTests(TestCase):
|
|
707
1090
|
def setUp(self):
|
|
@@ -727,6 +1110,12 @@ class NetMessagePropagationTests(TestCase):
|
|
|
727
1110
|
)
|
|
728
1111
|
)
|
|
729
1112
|
|
|
1113
|
+
def test_broadcast_sets_node_origin(self):
|
|
1114
|
+
with patch.object(Node, "get_local", return_value=self.local):
|
|
1115
|
+
msg = NetMessage.broadcast(subject="subject", body="body")
|
|
1116
|
+
self.assertEqual(msg.node_origin, self.local)
|
|
1117
|
+
self.assertIsNone(msg.reach)
|
|
1118
|
+
|
|
730
1119
|
@patch("requests.post")
|
|
731
1120
|
@patch("core.notifications.notify")
|
|
732
1121
|
def test_propagate_forwards_to_three_and_notifies_local(
|
|
@@ -737,6 +1126,9 @@ class NetMessagePropagationTests(TestCase):
|
|
|
737
1126
|
msg.propagate(seen=[str(self.remotes[0].uuid)])
|
|
738
1127
|
mock_notify.assert_called_once_with("s", "b")
|
|
739
1128
|
self.assertEqual(mock_post.call_count, 3)
|
|
1129
|
+
for call_args in mock_post.call_args_list:
|
|
1130
|
+
payload = json.loads(call_args.kwargs["data"])
|
|
1131
|
+
self.assertEqual(payload.get("origin"), str(self.local.uuid))
|
|
740
1132
|
targets = {
|
|
741
1133
|
call.args[0].split("//")[1].split("/")[0]
|
|
742
1134
|
for call in mock_post.call_args_list
|
|
@@ -746,6 +1138,41 @@ class NetMessagePropagationTests(TestCase):
|
|
|
746
1138
|
self.assertEqual(msg.propagated_to.count(), 4)
|
|
747
1139
|
self.assertTrue(msg.complete)
|
|
748
1140
|
|
|
1141
|
+
@patch("requests.post")
|
|
1142
|
+
@patch("core.notifications.notify", return_value=True)
|
|
1143
|
+
def test_propagate_prunes_old_local_messages(self, mock_notify, mock_post):
|
|
1144
|
+
old_local = NetMessage.objects.create(
|
|
1145
|
+
subject="old local",
|
|
1146
|
+
body="body",
|
|
1147
|
+
reach=self.role,
|
|
1148
|
+
node_origin=self.local,
|
|
1149
|
+
)
|
|
1150
|
+
NetMessage.objects.filter(pk=old_local.pk).update(
|
|
1151
|
+
created=timezone.now() - timedelta(days=8)
|
|
1152
|
+
)
|
|
1153
|
+
old_remote = NetMessage.objects.create(
|
|
1154
|
+
subject="old remote",
|
|
1155
|
+
body="body",
|
|
1156
|
+
reach=self.role,
|
|
1157
|
+
node_origin=self.remotes[0],
|
|
1158
|
+
)
|
|
1159
|
+
NetMessage.objects.filter(pk=old_remote.pk).update(
|
|
1160
|
+
created=timezone.now() - timedelta(days=8)
|
|
1161
|
+
)
|
|
1162
|
+
msg = NetMessage.objects.create(
|
|
1163
|
+
subject="fresh",
|
|
1164
|
+
body="body",
|
|
1165
|
+
reach=self.role,
|
|
1166
|
+
node_origin=self.local,
|
|
1167
|
+
)
|
|
1168
|
+
with patch.object(Node, "get_local", return_value=self.local):
|
|
1169
|
+
msg.propagate()
|
|
1170
|
+
|
|
1171
|
+
mock_notify.assert_called_once_with("fresh", "body")
|
|
1172
|
+
self.assertFalse(NetMessage.objects.filter(pk=old_local.pk).exists())
|
|
1173
|
+
self.assertTrue(NetMessage.objects.filter(pk=old_remote.pk).exists())
|
|
1174
|
+
self.assertTrue(NetMessage.objects.filter(pk=msg.pk).exists())
|
|
1175
|
+
|
|
749
1176
|
|
|
750
1177
|
class NodeActionTests(TestCase):
|
|
751
1178
|
def setUp(self):
|
|
@@ -1034,6 +1461,91 @@ class EmailOutboxTests(TestCase):
|
|
|
1034
1461
|
self.assertEqual(email.subject, "sub")
|
|
1035
1462
|
self.assertEqual(email.to, ["to@example.com"])
|
|
1036
1463
|
|
|
1464
|
+
def test_string_representation_prefers_from_email(self):
|
|
1465
|
+
outbox = EmailOutbox.objects.create(
|
|
1466
|
+
host="smtp.example.com",
|
|
1467
|
+
port=587,
|
|
1468
|
+
username="mailer",
|
|
1469
|
+
password="secret",
|
|
1470
|
+
from_email="noreply@example.com",
|
|
1471
|
+
)
|
|
1472
|
+
|
|
1473
|
+
self.assertEqual(str(outbox), "noreply@example.com")
|
|
1474
|
+
|
|
1475
|
+
def test_string_representation_prefers_username_over_owner(self):
|
|
1476
|
+
group = SecurityGroup.objects.create(name="Operators")
|
|
1477
|
+
outbox = EmailOutbox.objects.create(
|
|
1478
|
+
group=group,
|
|
1479
|
+
host="smtp.example.com",
|
|
1480
|
+
port=587,
|
|
1481
|
+
username="mailer",
|
|
1482
|
+
password="secret",
|
|
1483
|
+
)
|
|
1484
|
+
|
|
1485
|
+
self.assertEqual(str(outbox), "mailer@smtp.example.com")
|
|
1486
|
+
|
|
1487
|
+
def test_string_representation_does_not_duplicate_email_hostname(self):
|
|
1488
|
+
outbox = EmailOutbox.objects.create(
|
|
1489
|
+
host="smtp.example.com",
|
|
1490
|
+
port=587,
|
|
1491
|
+
username="mailer@example.com",
|
|
1492
|
+
password="secret",
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
self.assertEqual(str(outbox), "mailer@example.com")
|
|
1496
|
+
|
|
1497
|
+
def test_unattached_outbox_used_as_fallback(self):
|
|
1498
|
+
EmailOutbox.objects.create(
|
|
1499
|
+
group=SecurityGroup.objects.create(name="Attached"),
|
|
1500
|
+
host="smtp.attached.example.com",
|
|
1501
|
+
port=587,
|
|
1502
|
+
username="attached",
|
|
1503
|
+
password="secret",
|
|
1504
|
+
)
|
|
1505
|
+
fallback = EmailOutbox.objects.create(
|
|
1506
|
+
host="smtp.fallback.example.com",
|
|
1507
|
+
port=587,
|
|
1508
|
+
username="fallback",
|
|
1509
|
+
password="secret",
|
|
1510
|
+
)
|
|
1511
|
+
|
|
1512
|
+
backend = OutboxEmailBackend()
|
|
1513
|
+
message = EmailMessage("subject", "body", to=["to@example.com"])
|
|
1514
|
+
|
|
1515
|
+
selected, fallbacks = backend._select_outbox(message)
|
|
1516
|
+
|
|
1517
|
+
self.assertEqual(selected, fallback)
|
|
1518
|
+
self.assertEqual(fallbacks, [])
|
|
1519
|
+
|
|
1520
|
+
def test_disabled_outbox_excluded_from_selection(self):
|
|
1521
|
+
EmailOutbox.objects.create(
|
|
1522
|
+
host="smtp.disabled.example.com",
|
|
1523
|
+
port=587,
|
|
1524
|
+
username="disabled@example.com",
|
|
1525
|
+
password="secret",
|
|
1526
|
+
from_email="disabled@example.com",
|
|
1527
|
+
is_enabled=False,
|
|
1528
|
+
)
|
|
1529
|
+
enabled = EmailOutbox.objects.create(
|
|
1530
|
+
host="smtp.enabled.example.com",
|
|
1531
|
+
port=587,
|
|
1532
|
+
username="enabled@example.com",
|
|
1533
|
+
password="secret",
|
|
1534
|
+
)
|
|
1535
|
+
|
|
1536
|
+
backend = OutboxEmailBackend()
|
|
1537
|
+
message = EmailMessage(
|
|
1538
|
+
"subject",
|
|
1539
|
+
"body",
|
|
1540
|
+
from_email="disabled@example.com",
|
|
1541
|
+
to=["to@example.com"],
|
|
1542
|
+
)
|
|
1543
|
+
|
|
1544
|
+
selected, fallbacks = backend._select_outbox(message)
|
|
1545
|
+
|
|
1546
|
+
self.assertEqual(selected, enabled)
|
|
1547
|
+
self.assertEqual(fallbacks, [])
|
|
1548
|
+
|
|
1037
1549
|
|
|
1038
1550
|
class ClipboardTaskTests(TestCase):
|
|
1039
1551
|
@patch("nodes.tasks.pyperclip.paste")
|
|
@@ -1487,3 +1999,170 @@ class NodeRpiCameraDetectionTests(TestCase):
|
|
|
1487
1999
|
self.assertFalse(Node._has_rpi_camera())
|
|
1488
2000
|
missing_index = Node.RPI_CAMERA_BINARIES.index(Node.RPI_CAMERA_BINARIES[-1])
|
|
1489
2001
|
self.assertEqual(mock_run.call_count, missing_index)
|
|
2002
|
+
|
|
2003
|
+
|
|
2004
|
+
class DNSIntegrationTests(TestCase):
|
|
2005
|
+
def setUp(self):
|
|
2006
|
+
self.group = SecurityGroup.objects.create(name="Infra")
|
|
2007
|
+
|
|
2008
|
+
def test_deploy_records_success(self):
|
|
2009
|
+
manager = NodeManager.objects.create(
|
|
2010
|
+
group=self.group,
|
|
2011
|
+
api_key="test-key",
|
|
2012
|
+
api_secret="test-secret",
|
|
2013
|
+
)
|
|
2014
|
+
record_a = DNSRecord.objects.create(
|
|
2015
|
+
domain="example.com",
|
|
2016
|
+
name="@",
|
|
2017
|
+
record_type=DNSRecord.Type.A,
|
|
2018
|
+
data="1.2.3.4",
|
|
2019
|
+
ttl=600,
|
|
2020
|
+
)
|
|
2021
|
+
record_b = DNSRecord.objects.create(
|
|
2022
|
+
domain="example.com",
|
|
2023
|
+
name="@",
|
|
2024
|
+
record_type=DNSRecord.Type.A,
|
|
2025
|
+
data="5.6.7.8",
|
|
2026
|
+
ttl=600,
|
|
2027
|
+
)
|
|
2028
|
+
|
|
2029
|
+
calls = []
|
|
2030
|
+
|
|
2031
|
+
class DummyResponse:
|
|
2032
|
+
status_code = 200
|
|
2033
|
+
reason = "OK"
|
|
2034
|
+
|
|
2035
|
+
def json(self):
|
|
2036
|
+
return {}
|
|
2037
|
+
|
|
2038
|
+
class DummySession:
|
|
2039
|
+
def __init__(self):
|
|
2040
|
+
self.headers = {}
|
|
2041
|
+
|
|
2042
|
+
def put(self, url, json, timeout):
|
|
2043
|
+
calls.append((url, json, timeout, dict(self.headers)))
|
|
2044
|
+
return DummyResponse()
|
|
2045
|
+
|
|
2046
|
+
with mock.patch.object(dns_utils.requests, "Session", DummySession):
|
|
2047
|
+
result = manager.publish_dns_records([record_a, record_b])
|
|
2048
|
+
|
|
2049
|
+
self.assertEqual(len(result.deployed), 2)
|
|
2050
|
+
self.assertFalse(result.failures)
|
|
2051
|
+
self.assertFalse(result.skipped)
|
|
2052
|
+
self.assertTrue(calls)
|
|
2053
|
+
url, payload, timeout, headers = calls[0]
|
|
2054
|
+
self.assertTrue(url.endswith("/v1/domains/example.com/records/A/@"))
|
|
2055
|
+
self.assertEqual(len(payload), 2)
|
|
2056
|
+
self.assertEqual(headers["Authorization"], "sso-key test-key:test-secret")
|
|
2057
|
+
|
|
2058
|
+
record_a.refresh_from_db()
|
|
2059
|
+
record_b.refresh_from_db()
|
|
2060
|
+
self.assertIsNotNone(record_a.last_synced_at)
|
|
2061
|
+
self.assertIsNotNone(record_b.last_synced_at)
|
|
2062
|
+
self.assertEqual(record_a.node_manager_id, manager.pk)
|
|
2063
|
+
self.assertEqual(record_b.node_manager_id, manager.pk)
|
|
2064
|
+
|
|
2065
|
+
def test_deploy_records_handles_error(self):
|
|
2066
|
+
manager = NodeManager.objects.create(
|
|
2067
|
+
group=self.group,
|
|
2068
|
+
api_key="test-key",
|
|
2069
|
+
api_secret="test-secret",
|
|
2070
|
+
)
|
|
2071
|
+
record = DNSRecord.objects.create(
|
|
2072
|
+
domain="example.com",
|
|
2073
|
+
name="www",
|
|
2074
|
+
record_type=DNSRecord.Type.CNAME,
|
|
2075
|
+
data="target.example.com",
|
|
2076
|
+
)
|
|
2077
|
+
|
|
2078
|
+
class DummyResponse:
|
|
2079
|
+
status_code = 400
|
|
2080
|
+
reason = "Bad Request"
|
|
2081
|
+
|
|
2082
|
+
def json(self):
|
|
2083
|
+
return {"message": "Invalid data"}
|
|
2084
|
+
|
|
2085
|
+
class DummySession:
|
|
2086
|
+
def __init__(self):
|
|
2087
|
+
self.headers = {}
|
|
2088
|
+
|
|
2089
|
+
def put(self, url, json, timeout):
|
|
2090
|
+
return DummyResponse()
|
|
2091
|
+
|
|
2092
|
+
with mock.patch.object(dns_utils.requests, "Session", DummySession):
|
|
2093
|
+
result = manager.publish_dns_records([record])
|
|
2094
|
+
|
|
2095
|
+
self.assertFalse(result.deployed)
|
|
2096
|
+
self.assertIn(record, result.failures)
|
|
2097
|
+
record.refresh_from_db()
|
|
2098
|
+
self.assertEqual(record.last_error, "Invalid data")
|
|
2099
|
+
self.assertIsNone(record.last_synced_at)
|
|
2100
|
+
|
|
2101
|
+
def test_validate_record_success(self):
|
|
2102
|
+
record = DNSRecord.objects.create(
|
|
2103
|
+
domain="example.com",
|
|
2104
|
+
name="www",
|
|
2105
|
+
record_type=DNSRecord.Type.A,
|
|
2106
|
+
data="1.2.3.4",
|
|
2107
|
+
)
|
|
2108
|
+
|
|
2109
|
+
class DummyRdata:
|
|
2110
|
+
address = "1.2.3.4"
|
|
2111
|
+
|
|
2112
|
+
class DummyResolver:
|
|
2113
|
+
def resolve(self, name, rtype):
|
|
2114
|
+
self_calls.append((name, rtype))
|
|
2115
|
+
return [DummyRdata()]
|
|
2116
|
+
|
|
2117
|
+
self_calls = []
|
|
2118
|
+
ok, message = dns_utils.validate_record(record, resolver=DummyResolver())
|
|
2119
|
+
|
|
2120
|
+
self.assertTrue(ok)
|
|
2121
|
+
self.assertEqual(message, "")
|
|
2122
|
+
record.refresh_from_db()
|
|
2123
|
+
self.assertIsNotNone(record.last_verified_at)
|
|
2124
|
+
self.assertEqual(record.last_error, "")
|
|
2125
|
+
self.assertEqual(self_calls, [("www.example.com", "A")])
|
|
2126
|
+
|
|
2127
|
+
def test_validate_record_mismatch(self):
|
|
2128
|
+
record = DNSRecord.objects.create(
|
|
2129
|
+
domain="example.com",
|
|
2130
|
+
name="www",
|
|
2131
|
+
record_type=DNSRecord.Type.A,
|
|
2132
|
+
data="1.2.3.4",
|
|
2133
|
+
)
|
|
2134
|
+
|
|
2135
|
+
class DummyRdata:
|
|
2136
|
+
address = "5.6.7.8"
|
|
2137
|
+
|
|
2138
|
+
class DummyResolver:
|
|
2139
|
+
def resolve(self, name, rtype):
|
|
2140
|
+
return [DummyRdata()]
|
|
2141
|
+
|
|
2142
|
+
ok, message = dns_utils.validate_record(record, resolver=DummyResolver())
|
|
2143
|
+
|
|
2144
|
+
self.assertFalse(ok)
|
|
2145
|
+
self.assertEqual(message, "DNS record does not match expected value")
|
|
2146
|
+
record.refresh_from_db()
|
|
2147
|
+
self.assertEqual(record.last_error, message)
|
|
2148
|
+
self.assertIsNone(record.last_verified_at)
|
|
2149
|
+
|
|
2150
|
+
def test_validate_record_handles_exception(self):
|
|
2151
|
+
record = DNSRecord.objects.create(
|
|
2152
|
+
domain="example.com",
|
|
2153
|
+
name="www",
|
|
2154
|
+
record_type=DNSRecord.Type.A,
|
|
2155
|
+
data="1.2.3.4",
|
|
2156
|
+
)
|
|
2157
|
+
|
|
2158
|
+
class DummyResolver:
|
|
2159
|
+
def resolve(self, name, rtype):
|
|
2160
|
+
raise dns_resolver.NXDOMAIN()
|
|
2161
|
+
|
|
2162
|
+
ok, message = dns_utils.validate_record(record, resolver=DummyResolver())
|
|
2163
|
+
|
|
2164
|
+
self.assertFalse(ok)
|
|
2165
|
+
self.assertEqual(message, "The DNS query name does not exist.")
|
|
2166
|
+
record.refresh_from_db()
|
|
2167
|
+
self.assertEqual(record.last_error, message)
|
|
2168
|
+
self.assertIsNone(record.last_verified_at)
|