Merge pull request #22184 from netbox-community/22098-add-tests-for-signal-handlers
Closes #22098: Add signal handler test coverage
This commit is contained in:
commit
6df7298b58
|
|
@ -0,0 +1,92 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
|
||||
from circuits import signals
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
from dcim.models import (
|
||||
Cable,
|
||||
CablePath,
|
||||
Device,
|
||||
DeviceRole,
|
||||
DeviceType,
|
||||
Interface,
|
||||
Manufacturer,
|
||||
Site,
|
||||
)
|
||||
|
||||
|
||||
class RebuildCablepathsSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify circuits.signals.rebuild_cablepaths retraces paths that cross the peer termination
|
||||
when a CircuitTermination is saved or deleted.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.site = Site.objects.create(name='Site', slug='site')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
|
||||
device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
|
||||
cls.device = Device.objects.create(site=cls.site, device_type=device_type, role=device_role, name='Device 1')
|
||||
provider = Provider.objects.create(name='Provider', slug='provider')
|
||||
circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
|
||||
cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
|
||||
|
||||
def test_saving_termination_rebuilds_peer_path(self):
|
||||
interface = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
site_z = Site.objects.create(name='Site Z', slug='site-z')
|
||||
termination_a = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
|
||||
termination_z = CircuitTermination.objects.create(circuit=self.circuit, termination=site_z, term_side='Z')
|
||||
Cable(a_terminations=[interface], b_terminations=[termination_a]).save()
|
||||
original_path = CablePath.objects.get()
|
||||
|
||||
# Saving the Z (peer) termination should cause rebuild_paths to run for the A peer.
|
||||
with patch('circuits.signals.rebuild_paths') as rebuild_paths:
|
||||
termination_z.save()
|
||||
|
||||
rebuild_paths.assert_called_once()
|
||||
rebuilt_for = rebuild_paths.call_args.args[0]
|
||||
self.assertEqual([t.pk for t in rebuilt_for], [termination_a.pk])
|
||||
|
||||
# Without patching, the real signal should retrace the path successfully.
|
||||
termination_z.save()
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
self.assertNotEqual(CablePath.objects.get().pk, original_path.pk)
|
||||
|
||||
def test_deleting_termination_rebuilds_peer_path(self):
|
||||
site_z = Site.objects.create(name='Site Z', slug='site-z')
|
||||
termination_a = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
|
||||
termination_z = CircuitTermination.objects.create(circuit=self.circuit, termination=site_z, term_side='Z')
|
||||
|
||||
with patch('circuits.signals.rebuild_paths') as rebuild_paths:
|
||||
termination_z.delete()
|
||||
|
||||
rebuild_paths.assert_called_once()
|
||||
rebuilt_for = rebuild_paths.call_args.args[0]
|
||||
self.assertEqual([t.pk for t in rebuilt_for], [termination_a.pk])
|
||||
|
||||
def test_saving_termination_without_peer_does_not_rebuild(self):
|
||||
termination = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
|
||||
|
||||
with patch('circuits.signals.rebuild_paths') as rebuild_paths:
|
||||
termination.save()
|
||||
|
||||
rebuild_paths.assert_not_called()
|
||||
|
||||
|
||||
class RebuildCablepathsDirectHandlerTestCase(SimpleTestCase):
|
||||
"""
|
||||
Direct-call tests for rebuild_cablepaths branches that are not reachable through
|
||||
normal model operations (e.g. raw=True is only set by Django's loaddata pathway).
|
||||
"""
|
||||
|
||||
def test_raw_import_skips_peer_lookup_and_rebuild(self):
|
||||
instance = SimpleNamespace(get_peer_termination=MagicMock())
|
||||
|
||||
with patch.object(signals, 'rebuild_paths') as rebuild_paths:
|
||||
signals.rebuild_cablepaths(instance=instance, raw=True)
|
||||
|
||||
instance.get_peer_termination.assert_not_called()
|
||||
rebuild_paths.assert_not_called()
|
||||
|
|
@ -0,0 +1,505 @@
|
|||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.signals import request_finished
|
||||
from django.db import transaction
|
||||
from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
|
||||
|
||||
from core import signals
|
||||
from core.choices import DataSourceStatusChoices, JobStatusChoices, ObjectChangeActionChoices
|
||||
from core.models import ConfigRevision, DataSource, ObjectChange, ObjectType
|
||||
from core.signals import _signals_received, clear_events, post_sync
|
||||
from dcim.models import Site, SiteGroup
|
||||
from extras.models import Tag
|
||||
from extras.validators import CustomValidator
|
||||
from netbox.context import events_queue
|
||||
from netbox.context_managers import event_tracking
|
||||
from users.models import User
|
||||
from utilities.exceptions import AbortRequest
|
||||
|
||||
|
||||
def _build_request(user):
|
||||
request = RequestFactory().get('/')
|
||||
request.id = uuid.uuid4()
|
||||
request.user = user
|
||||
return request
|
||||
|
||||
|
||||
class UpdateObjectTypesSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify core.signals.update_object_types has registered an ObjectType for known
|
||||
models, with the expected public flag and feature set.
|
||||
"""
|
||||
|
||||
def test_public_model_object_type_is_registered(self):
|
||||
ot = ObjectType.objects.get(app_label='dcim', model='site')
|
||||
self.assertTrue(ot.public)
|
||||
# Site supports several features — verify a couple representative ones.
|
||||
self.assertIn('custom_fields', ot.features)
|
||||
self.assertIn('tags', ot.features)
|
||||
|
||||
def test_private_model_object_type_is_registered_as_non_public(self):
|
||||
ot = ObjectType.objects.get(app_label='dcim', model='cablepath')
|
||||
self.assertFalse(ot.public)
|
||||
|
||||
|
||||
class HandleChangedObjectSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify core.signals.handle_changed_object writes an ObjectChange and increments
|
||||
metric counters whenever a tracked object is created or updated within a request.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create_user(username='alice', password='pw')
|
||||
|
||||
def test_create_records_an_objectchange(self):
|
||||
request = _build_request(self.user)
|
||||
with event_tracking(request):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
oc = ObjectChange.objects.get(
|
||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||
changed_object_id=site.pk,
|
||||
)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(oc.user, self.user)
|
||||
self.assertEqual(oc.request_id, request.id)
|
||||
|
||||
def test_update_records_an_objectchange(self):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
request = _build_request(self.user)
|
||||
|
||||
with event_tracking(request):
|
||||
site.description = 'updated'
|
||||
site.save()
|
||||
|
||||
ocs = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||
changed_object_id=site.pk,
|
||||
).order_by('-pk')
|
||||
self.assertEqual(ocs.first().action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
|
||||
def test_no_request_skips_objectchange(self):
|
||||
# Saving outside a request context (no event_tracking) should not record any
|
||||
# ObjectChange entries.
|
||||
Site.objects.create(name='Site 1', slug='site-1')
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
|
||||
def test_m2m_tag_change_records_objectchange_with_postchange_tags(self):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
tag = Tag.objects.create(name='Important', slug='important')
|
||||
request = _build_request(self.user)
|
||||
|
||||
with event_tracking(request):
|
||||
site.tags.add(tag)
|
||||
|
||||
oc = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||
changed_object_id=site.pk,
|
||||
).first()
|
||||
self.assertEqual(oc.postchange_data['tags'], ['Important'])
|
||||
|
||||
|
||||
class HandleDeletedObjectSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify core.signals.handle_deleted_object writes a delete-type ObjectChange and
|
||||
respects PROTECTION_RULES.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create_user(username='alice', password='pw')
|
||||
|
||||
def setUp(self):
|
||||
# Reset the in-memory pre_delete bookkeeping; the signal's de-dup set lives in a
|
||||
# threading.local that is not rolled back between TestCase methods.
|
||||
_signals_received.pre_delete = set()
|
||||
|
||||
def test_delete_records_an_objectchange(self):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
site_pk = site.pk
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
request = _build_request(self.user)
|
||||
|
||||
with event_tracking(request):
|
||||
site.delete()
|
||||
|
||||
oc = ObjectChange.objects.get(changed_object_type=site_type, changed_object_id=site_pk)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertIsNone(oc.postchange_data)
|
||||
|
||||
@override_settings(PROTECTION_RULES={'dcim.site': [CustomValidator({'name': {'neq': 'protected'}})]})
|
||||
def test_protection_rule_violation_aborts_deletion(self):
|
||||
site = Site.objects.create(name='protected', slug='protected')
|
||||
request = _build_request(self.user)
|
||||
|
||||
with event_tracking(request):
|
||||
# The signal raises AbortRequest from a pre_delete handler, which can poison
|
||||
# the surrounding transaction; isolate the delete in its own atomic block.
|
||||
with self.assertRaises((AbortRequest, ValidationError)):
|
||||
with transaction.atomic():
|
||||
site.delete()
|
||||
|
||||
self.assertTrue(Site.objects.filter(pk=site.pk).exists())
|
||||
|
||||
def test_delete_records_single_objectchange(self):
|
||||
# A delete should record exactly one ObjectChange for the deleted object.
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
site_pk = site.pk
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
request = _build_request(self.user)
|
||||
|
||||
with event_tracking(request):
|
||||
site.delete()
|
||||
|
||||
ocs = ObjectChange.objects.filter(changed_object_type=site_type, changed_object_id=site_pk)
|
||||
self.assertEqual(ocs.count(), 1)
|
||||
|
||||
def test_duplicate_pre_delete_for_same_instance_is_ignored(self):
|
||||
# Exercise the dedup short-circuit by invoking the handler twice for the
|
||||
# same instance, mirroring what happens when a parent and its child are
|
||||
# deleted simultaneously and the same pre_delete fires more than once.
|
||||
# Only the first invocation should record an ObjectChange.
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
site_pk = site.pk
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
request = _build_request(self.user)
|
||||
|
||||
with event_tracking(request):
|
||||
signals.handle_deleted_object(sender=Site, instance=site)
|
||||
signals.handle_deleted_object(sender=Site, instance=site)
|
||||
|
||||
ocs = ObjectChange.objects.filter(
|
||||
changed_object_type=site_type,
|
||||
changed_object_id=site_pk,
|
||||
action=ObjectChangeActionChoices.ACTION_DELETE,
|
||||
)
|
||||
self.assertEqual(ocs.count(), 1)
|
||||
|
||||
def test_delete_records_change_for_objects_with_nulled_fk(self):
|
||||
# When a parent is deleted, related objects with on_delete=SET_NULL have
|
||||
# their FK cleared by the signal *and* receive a change-log entry via
|
||||
# snapshot()+save(). Without the signal, Django's SET_NULL would clear
|
||||
# the FK silently with no ObjectChange.
|
||||
group = SiteGroup.objects.create(name='Group', slug='group')
|
||||
group_pk = group.pk
|
||||
site = Site.objects.create(name='Site', slug='site', group=group)
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
request = _build_request(self.user)
|
||||
|
||||
with event_tracking(request):
|
||||
group.delete()
|
||||
|
||||
site.refresh_from_db()
|
||||
self.assertIsNone(site.group)
|
||||
oc = ObjectChange.objects.get(
|
||||
changed_object_type=site_type,
|
||||
changed_object_id=site.pk,
|
||||
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
||||
)
|
||||
self.assertEqual(oc.prechange_data['group'], group_pk)
|
||||
self.assertIsNone(oc.postchange_data['group'])
|
||||
|
||||
|
||||
class ClearSignalHistorySignalTestCase(TestCase):
|
||||
"""
|
||||
Verify core.signals.clear_signal_history resets the pre_delete bookkeeping at the
|
||||
end of every request.
|
||||
"""
|
||||
|
||||
def test_request_finished_clears_history(self):
|
||||
_signals_received.pre_delete = {('a', 1), ('b', 2)}
|
||||
|
||||
request_finished.send(sender=self.__class__)
|
||||
|
||||
self.assertEqual(_signals_received.pre_delete, set())
|
||||
|
||||
|
||||
class ClearEventsQueueSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify core.signals.clear_events_queue empties the in-flight events queue when the
|
||||
clear_events signal fires (e.g. during a rolled-back bulk transaction).
|
||||
"""
|
||||
|
||||
def test_clear_events_signal_empties_the_queue(self):
|
||||
events_queue.set({'event-1': object(), 'event-2': object()})
|
||||
|
||||
clear_events.send(sender='test-suite')
|
||||
|
||||
self.assertEqual(events_queue.get(), {})
|
||||
|
||||
|
||||
class EnqueueSyncJobSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify core.signals.enqueue_sync_job schedules a recurring sync job when a
|
||||
DataSource is saved with a sync_interval, and removes any existing schedule
|
||||
otherwise.
|
||||
"""
|
||||
|
||||
def test_saving_datasource_with_interval_enqueues_sync_job(self):
|
||||
with patch('core.jobs.SyncDataSourceJob') as sync_job:
|
||||
DataSource.objects.create(
|
||||
name='DS 1',
|
||||
type='local',
|
||||
source_url='/tmp/ds1',
|
||||
enabled=True,
|
||||
sync_interval=60,
|
||||
)
|
||||
|
||||
sync_job.enqueue_once.assert_called_once()
|
||||
_, kwargs = sync_job.enqueue_once.call_args
|
||||
self.assertEqual(kwargs.get('interval'), 60)
|
||||
|
||||
def test_disabled_datasource_clears_scheduled_jobs(self):
|
||||
class ScheduledJobQueryset:
|
||||
def __init__(self, jobs):
|
||||
self.jobs = jobs
|
||||
self.defer = Mock(return_value=self)
|
||||
self.filter = Mock(return_value=self)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.jobs)
|
||||
|
||||
with patch('core.jobs.SyncDataSourceJob') as sync_job:
|
||||
ds = DataSource.objects.create(
|
||||
name='DS 1',
|
||||
type='local',
|
||||
source_url='/tmp/ds1',
|
||||
enabled=True,
|
||||
sync_interval=60,
|
||||
)
|
||||
sync_job.reset_mock()
|
||||
|
||||
scheduled_job = Mock()
|
||||
scheduled_jobs = ScheduledJobQueryset([scheduled_job])
|
||||
sync_job.get_jobs.return_value = scheduled_jobs
|
||||
ds.enabled = False
|
||||
ds.sync_interval = None
|
||||
ds.save()
|
||||
|
||||
sync_job.get_jobs.assert_called_once_with(ds)
|
||||
scheduled_jobs.defer.assert_called_once_with('data')
|
||||
scheduled_jobs.filter.assert_called_once_with(interval__isnull=False, status=JobStatusChoices.STATUS_SCHEDULED)
|
||||
scheduled_job.delete.assert_called_once_with()
|
||||
|
||||
def test_creating_disabled_datasource_does_not_enqueue(self):
|
||||
with patch('core.jobs.SyncDataSourceJob') as sync_job:
|
||||
DataSource.objects.create(
|
||||
name='DS 1',
|
||||
type='local',
|
||||
source_url='/tmp/ds1',
|
||||
enabled=False,
|
||||
sync_interval=None,
|
||||
)
|
||||
|
||||
sync_job.enqueue_once.assert_not_called()
|
||||
sync_job.get_jobs.assert_not_called()
|
||||
|
||||
|
||||
class AutoSyncSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify core.signals.auto_sync re-syncs every AutoSyncRecord linked to the
|
||||
DataSource when post_sync fires.
|
||||
"""
|
||||
|
||||
def test_post_sync_resyncs_dependent_records(self):
|
||||
ds = DataSource.objects.create(
|
||||
name='DS 1',
|
||||
type='local',
|
||||
source_url='/tmp/ds1',
|
||||
status=DataSourceStatusChoices.COMPLETED,
|
||||
)
|
||||
record_a = SimpleNamespace(object=SimpleNamespace(synced=False))
|
||||
record_a.object.sync = lambda save: setattr(record_a.object, 'synced', save)
|
||||
record_b = SimpleNamespace(object=SimpleNamespace(synced=False))
|
||||
record_b.object.sync = lambda save: setattr(record_b.object, 'synced', save)
|
||||
|
||||
with patch('core.models.AutoSyncRecord') as autosync_model:
|
||||
autosync_model.objects.filter.return_value.prefetch_related.return_value = [
|
||||
record_a,
|
||||
record_b,
|
||||
]
|
||||
post_sync.send(sender=ds.__class__, instance=ds)
|
||||
|
||||
self.assertTrue(record_a.object.synced)
|
||||
self.assertTrue(record_b.object.synced)
|
||||
|
||||
|
||||
class UpdateConfigSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify core.signals.update_config invokes activate() on a newly-saved
|
||||
ConfigRevision.
|
||||
"""
|
||||
|
||||
def test_saving_config_revision_activates_it(self):
|
||||
with patch.object(ConfigRevision, 'activate') as activate:
|
||||
ConfigRevision.objects.create(data={'foo': 1}, comment='test')
|
||||
|
||||
activate.assert_called_once()
|
||||
|
||||
|
||||
class HandleChangedObjectDirectHandlerTestCase(SimpleTestCase):
|
||||
"""
|
||||
Direct-call tests for handle_changed_object branches that are not naturally
|
||||
reachable through ORM operations (a real .save() always produces changes, and
|
||||
Django collapses every m2m_changed action into a single dispatch the handler
|
||||
cannot fully simulate via real m2m operations).
|
||||
"""
|
||||
|
||||
def _instance(self):
|
||||
objectchange = MagicMock()
|
||||
objectchange.has_changes = True
|
||||
objectchange.postchange_data = {'name': 'Device 1'}
|
||||
instance = SimpleNamespace(
|
||||
pk=123,
|
||||
_meta=SimpleNamespace(model_name='device'),
|
||||
refresh_from_db=MagicMock(),
|
||||
)
|
||||
instance.to_objectchange = MagicMock(return_value=objectchange)
|
||||
return instance, objectchange
|
||||
|
||||
def test_unhandled_m2m_action_returns_without_recording(self):
|
||||
request = SimpleNamespace(id='request-id', user='alice')
|
||||
instance, _ = self._instance()
|
||||
current_request = MagicMock()
|
||||
current_request.get.return_value = request
|
||||
|
||||
with patch.object(signals, 'current_request', current_request):
|
||||
signals.handle_changed_object(
|
||||
sender=None,
|
||||
instance=instance,
|
||||
action='pre_add',
|
||||
pk_set={1},
|
||||
)
|
||||
|
||||
instance.to_objectchange.assert_not_called()
|
||||
|
||||
def test_objectchange_without_changes_is_not_saved(self):
|
||||
request = SimpleNamespace(id='request-id', user='alice')
|
||||
instance, objectchange = self._instance()
|
||||
objectchange.has_changes = False
|
||||
current_request = MagicMock()
|
||||
current_request.get.return_value = request
|
||||
events_queue_mock = MagicMock()
|
||||
events_queue_mock.get.return_value = {}
|
||||
update_metric = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(signals, 'current_request', current_request),
|
||||
patch.object(signals, 'events_queue', events_queue_mock),
|
||||
patch.object(signals, 'enqueue_event') as enqueue_event,
|
||||
patch.object(signals.model_updates, 'labels', return_value=update_metric),
|
||||
):
|
||||
signals.handle_changed_object(sender=None, instance=instance, created=False)
|
||||
|
||||
instance.to_objectchange.assert_called_once_with(ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
# has_changes is False, so the ObjectChange should never be saved …
|
||||
objectchange.save.assert_not_called()
|
||||
# … but metric counters and event enqueueing still run.
|
||||
update_metric.inc.assert_called_once_with()
|
||||
enqueue_event.assert_called_once()
|
||||
|
||||
def test_m2m_change_updates_existing_objectchange_in_same_request(self):
|
||||
request = SimpleNamespace(id='request-id', user='alice')
|
||||
instance, objectchange = self._instance()
|
||||
previous_change = MagicMock()
|
||||
current_request = MagicMock()
|
||||
current_request.get.return_value = request
|
||||
events_queue_mock = MagicMock()
|
||||
events_queue_mock.get.return_value = {}
|
||||
objectchange_model = MagicMock()
|
||||
objectchange_model.objects.filter.return_value.first.return_value = previous_change
|
||||
content_type_model = MagicMock()
|
||||
content_type_model.objects.get_for_model.return_value = object()
|
||||
|
||||
with (
|
||||
patch.object(signals, 'current_request', current_request),
|
||||
patch.object(signals, 'events_queue', events_queue_mock),
|
||||
patch.object(signals, 'ObjectChange', objectchange_model),
|
||||
patch.object(signals, 'ContentType', content_type_model),
|
||||
patch.object(signals, 'enqueue_event'),
|
||||
patch.object(signals.model_updates, 'labels', return_value=MagicMock()),
|
||||
):
|
||||
signals.handle_changed_object(
|
||||
sender=None,
|
||||
instance=instance,
|
||||
action='post_add',
|
||||
pk_set={1},
|
||||
)
|
||||
|
||||
# The handler should update the existing ObjectChange instead of creating a new one.
|
||||
self.assertEqual(previous_change.postchange_data, objectchange.postchange_data)
|
||||
previous_change.save.assert_called_once_with()
|
||||
objectchange.save.assert_not_called()
|
||||
instance.refresh_from_db.assert_called_once_with()
|
||||
|
||||
|
||||
class HandleDeletedObjectDirectHandlerTestCase(SimpleTestCase):
|
||||
"""
|
||||
Direct-call tests for handle_deleted_object branches that are hard to construct
|
||||
via real model operations (notably _netbox_private skip, which requires a
|
||||
private-model instance with reverse relations the test can introspect).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
_signals_received.pre_delete = set()
|
||||
|
||||
def test_private_model_skips_reverse_relation_processing(self):
|
||||
# Build a relation the handler would normally process — a ManyToOneRel-typed
|
||||
# instance pointing at a ChangeLoggingMixin subclass. The handler narrows by
|
||||
# exact type (`type(relation) is ManyToOneRel`), so do not use
|
||||
# MagicMock(spec=ManyToOneRel): it would be skipped before reaching the
|
||||
# _netbox_private branch. Patching ManyToOneRel to this fake class keeps the
|
||||
# exact-type check meaningful, so related_model.objects.filter() would be
|
||||
# called if the private-model skip failed.
|
||||
class FakeManyToOneRel:
|
||||
pass
|
||||
|
||||
class FakeChangeLoggingMixin:
|
||||
pass
|
||||
|
||||
class FakeRelatedModel(FakeChangeLoggingMixin):
|
||||
pass
|
||||
|
||||
FakeRelatedModel.objects = MagicMock()
|
||||
|
||||
fake_relation = FakeManyToOneRel()
|
||||
fake_relation.related_model = FakeRelatedModel
|
||||
fake_relation.remote_field = SimpleNamespace(name='parent')
|
||||
fake_relation.null = True
|
||||
fake_relation.on_delete = object()
|
||||
|
||||
sender = SimpleNamespace(_meta=SimpleNamespace(app_label='dcim', model_name='cablepath'))
|
||||
request = SimpleNamespace(id='request-id', user='alice')
|
||||
instance = SimpleNamespace(
|
||||
pk=1,
|
||||
_meta=SimpleNamespace(model_name='cablepath', related_objects=[fake_relation]),
|
||||
_netbox_private=True,
|
||||
)
|
||||
# Private models typically don't have to_objectchange, so skip change-log too.
|
||||
config = SimpleNamespace(PROTECTION_RULES={})
|
||||
current_request = MagicMock()
|
||||
current_request.get.return_value = request
|
||||
events_queue_mock = MagicMock()
|
||||
events_queue_mock.get.return_value = {}
|
||||
|
||||
with (
|
||||
patch.object(signals, 'ManyToOneRel', FakeManyToOneRel),
|
||||
patch.object(signals, 'get_config', return_value=config),
|
||||
patch.object(signals, 'get_config_value_ci', return_value=[]),
|
||||
patch.object(signals, 'run_validators'),
|
||||
patch.object(signals, 'current_request', current_request),
|
||||
patch.object(signals, 'events_queue', events_queue_mock),
|
||||
patch.object(signals, 'ContentType') as content_type_model,
|
||||
patch.object(signals, 'ChangeLoggingMixin', FakeChangeLoggingMixin),
|
||||
patch.object(signals, 'enqueue_event'),
|
||||
patch.object(signals.model_deletes, 'labels', return_value=MagicMock()),
|
||||
):
|
||||
content_type_model.objects.get_for_model.return_value = object()
|
||||
signals.handle_deleted_object(sender=sender, instance=instance)
|
||||
|
||||
FakeRelatedModel.objects.filter.assert_not_called()
|
||||
|
|
@ -0,0 +1,488 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
|
||||
from dcim import signals
|
||||
from dcim.choices import CableEndChoices, LinkStatusChoices
|
||||
from dcim.models import (
|
||||
Cable,
|
||||
CablePath,
|
||||
Device,
|
||||
DeviceRole,
|
||||
DeviceType,
|
||||
FrontPort,
|
||||
Interface,
|
||||
Location,
|
||||
MACAddress,
|
||||
Manufacturer,
|
||||
PortMapping,
|
||||
PowerPanel,
|
||||
Rack,
|
||||
RearPort,
|
||||
Site,
|
||||
SiteGroup,
|
||||
VirtualChassis,
|
||||
)
|
||||
from ipam.models import Prefix
|
||||
from virtualization.models import Cluster, ClusterType
|
||||
from wireless.models import WirelessLAN
|
||||
|
||||
|
||||
class LocationSiteChangeSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify dcim.signals.handle_location_site_change propagates a Location's new Site to
|
||||
every descendant Location, Rack, Device, PowerPanel, and component when the parent
|
||||
Location's site assignment changes.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
cls.site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
|
||||
cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
|
||||
cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
|
||||
|
||||
def test_changing_location_site_propagates_to_children(self):
|
||||
parent_location = Location.objects.create(name='Parent', slug='parent', site=self.site_a)
|
||||
child_location = Location.objects.create(name='Child', slug='child', site=self.site_a, parent=parent_location)
|
||||
rack = Rack.objects.create(name='Rack', site=self.site_a, location=parent_location)
|
||||
device = Device.objects.create(
|
||||
name='Device',
|
||||
site=self.site_a,
|
||||
location=parent_location,
|
||||
device_type=self.device_type,
|
||||
role=self.device_role,
|
||||
)
|
||||
interface = Interface.objects.create(device=device, name='Interface 1')
|
||||
power_panel = PowerPanel.objects.create(name='Panel', site=self.site_a, location=parent_location)
|
||||
|
||||
parent_location.site = self.site_b
|
||||
parent_location.save()
|
||||
|
||||
child_location.refresh_from_db()
|
||||
rack.refresh_from_db()
|
||||
device.refresh_from_db()
|
||||
interface.refresh_from_db()
|
||||
power_panel.refresh_from_db()
|
||||
self.assertEqual(child_location.site, self.site_b)
|
||||
self.assertEqual(rack.site, self.site_b)
|
||||
self.assertEqual(device.site, self.site_b)
|
||||
self.assertEqual(interface._site, self.site_b)
|
||||
self.assertEqual(power_panel.site, self.site_b)
|
||||
|
||||
def test_creating_location_does_not_attempt_to_propagate(self):
|
||||
# Should not raise — newly-created locations have no descendants.
|
||||
Location.objects.create(name='New', slug='new', site=self.site_a)
|
||||
|
||||
|
||||
class RackSiteChangeSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify dcim.signals.handle_rack_site_change propagates a Rack's site/location to its
|
||||
Devices and their components when the Rack is moved.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
cls.site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
cls.location_b = Location.objects.create(name='Loc B', slug='loc-b', site=cls.site_b)
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
|
||||
cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
|
||||
cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
|
||||
|
||||
def test_changing_rack_site_propagates_to_devices_and_components(self):
|
||||
rack = Rack.objects.create(name='Rack', site=self.site_a)
|
||||
device = Device.objects.create(
|
||||
name='Device',
|
||||
site=self.site_a,
|
||||
rack=rack,
|
||||
device_type=self.device_type,
|
||||
role=self.device_role,
|
||||
)
|
||||
interface = Interface.objects.create(device=device, name='Interface 1')
|
||||
|
||||
rack.site = self.site_b
|
||||
rack.location = self.location_b
|
||||
rack.save()
|
||||
|
||||
device.refresh_from_db()
|
||||
interface.refresh_from_db()
|
||||
self.assertEqual(device.site, self.site_b)
|
||||
self.assertEqual(device.location, self.location_b)
|
||||
self.assertEqual(interface._site, self.site_b)
|
||||
self.assertEqual(interface._location, self.location_b)
|
||||
|
||||
|
||||
class DeviceSiteChangeSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify dcim.signals.handle_device_site_change propagates a Device's site/location/rack
|
||||
to its components on save.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
cls.site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
|
||||
cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
|
||||
cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
|
||||
|
||||
def test_moving_device_updates_components_cached_scope(self):
|
||||
device = Device.objects.create(
|
||||
name='Device',
|
||||
site=self.site_a,
|
||||
device_type=self.device_type,
|
||||
role=self.device_role,
|
||||
)
|
||||
interface = Interface.objects.create(device=device, name='Interface 1')
|
||||
self.assertEqual(interface._site, self.site_a)
|
||||
|
||||
device.site = self.site_b
|
||||
device.save()
|
||||
|
||||
interface.refresh_from_db()
|
||||
self.assertEqual(interface._site, self.site_b)
|
||||
|
||||
|
||||
class VirtualChassisMasterSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify dcim.signals.assign_virtualchassis_master links the master device back to a
|
||||
newly-created VirtualChassis.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.site = Site.objects.create(name='Site', slug='site')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
|
||||
cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
|
||||
cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
|
||||
|
||||
def test_master_is_assigned_to_new_virtual_chassis(self):
|
||||
master = Device.objects.create(
|
||||
name='Master',
|
||||
site=self.site,
|
||||
device_type=self.device_type,
|
||||
role=self.device_role,
|
||||
)
|
||||
vc = VirtualChassis.objects.create(name='VC 1', master=master)
|
||||
|
||||
master.refresh_from_db()
|
||||
self.assertEqual(master.virtual_chassis, vc)
|
||||
self.assertEqual(master.vc_position, 1)
|
||||
|
||||
def test_updating_virtual_chassis_does_not_reassign_master(self):
|
||||
master = Device.objects.create(
|
||||
name='Master',
|
||||
site=self.site,
|
||||
device_type=self.device_type,
|
||||
role=self.device_role,
|
||||
)
|
||||
vc = VirtualChassis.objects.create(name='VC 1', master=master)
|
||||
|
||||
# Detach the master, then save the VC again — the signal should not re-link.
|
||||
master.virtual_chassis = None
|
||||
master.vc_position = None
|
||||
master.save()
|
||||
|
||||
vc.domain = 'updated'
|
||||
vc.save()
|
||||
|
||||
master.refresh_from_db()
|
||||
self.assertIsNone(master.virtual_chassis)
|
||||
|
||||
|
||||
class CableSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify dcim.signals.update_connected_endpoints, retrace_cable_paths, and
|
||||
nullify_connected_endpoints maintain CablePaths in response to Cable lifecycle events.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.site = Site.objects.create(name='Site', slug='site')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
|
||||
role = DeviceRole.objects.create(name='Device Role', slug='device-role')
|
||||
cls.device = Device.objects.create(
|
||||
name='Device',
|
||||
site=cls.site,
|
||||
device_type=device_type,
|
||||
role=role,
|
||||
)
|
||||
|
||||
def test_creating_cable_creates_endpoint_paths(self):
|
||||
interface_a = Interface.objects.create(device=self.device, name='Interface A')
|
||||
interface_b = Interface.objects.create(device=self.device, name='Interface B')
|
||||
cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
|
||||
cable.save()
|
||||
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
interface_a.refresh_from_db()
|
||||
self.assertIsNotNone(interface_a._path_id)
|
||||
|
||||
def test_changing_cable_status_marks_paths_inactive(self):
|
||||
interface_a = Interface.objects.create(device=self.device, name='Interface A')
|
||||
interface_b = Interface.objects.create(device=self.device, name='Interface B')
|
||||
cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
|
||||
cable.save()
|
||||
self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
|
||||
|
||||
# Reload the cable so _orig_status reflects the persisted value and
|
||||
# _terminations_modified resets to False.
|
||||
cable = Cable.objects.get(pk=cable.pk)
|
||||
cable.status = LinkStatusChoices.STATUS_PLANNED
|
||||
cable.save()
|
||||
|
||||
self.assertFalse(any(cp.is_active for cp in CablePath.objects.all()))
|
||||
|
||||
def test_reconnecting_cable_marks_paths_active(self):
|
||||
interface_a = Interface.objects.create(device=self.device, name='Interface A')
|
||||
interface_b = Interface.objects.create(device=self.device, name='Interface B')
|
||||
cable = Cable(
|
||||
a_terminations=[interface_a],
|
||||
b_terminations=[interface_b],
|
||||
status=LinkStatusChoices.STATUS_PLANNED,
|
||||
)
|
||||
cable.save()
|
||||
self.assertFalse(any(cp.is_active for cp in CablePath.objects.all()))
|
||||
|
||||
cable = Cable.objects.get(pk=cable.pk)
|
||||
cable.status = LinkStatusChoices.STATUS_CONNECTED
|
||||
cable.save()
|
||||
|
||||
self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
|
||||
|
||||
def test_deleting_cable_retraces_paths(self):
|
||||
interface_a = Interface.objects.create(device=self.device, name='Interface A')
|
||||
interface_b = Interface.objects.create(device=self.device, name='Interface B')
|
||||
cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
|
||||
cable.save()
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
cable.delete()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
interface_a.refresh_from_db()
|
||||
interface_b.refresh_from_db()
|
||||
# Cable deletion must fully detach both endpoints, even though the
|
||||
# nullify_connected_endpoints signal short-circuits during Cable cascade.
|
||||
self.assertIsNone(interface_a._path_id)
|
||||
self.assertIsNone(interface_b._path_id)
|
||||
self.assertIsNone(interface_a.cable_id)
|
||||
self.assertIsNone(interface_b.cable_id)
|
||||
self.assertEqual(interface_a.cable_end, '')
|
||||
self.assertEqual(interface_b.cable_end, '')
|
||||
|
||||
def test_deleting_cable_skips_per_termination_retrace(self):
|
||||
"""
|
||||
When a Cable is deleted, nullify_connected_endpoints (post_delete on each
|
||||
cascaded CableTermination) must skip retracing — retrace_cable_paths
|
||||
retraces each affected path once on Cable post_delete instead. See #22104.
|
||||
|
||||
Without the short-circuit, retrace would fire (n_terminations * n_paths)
|
||||
times from the per-termination handler plus n_paths times from the Cable
|
||||
handler — for this 2-termination, 2-path cable, 6 calls total. With the
|
||||
short-circuit, only the n_paths calls from retrace_cable_paths remain.
|
||||
"""
|
||||
interface_a = Interface.objects.create(device=self.device, name='Interface A')
|
||||
interface_b = Interface.objects.create(device=self.device, name='Interface B')
|
||||
cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
|
||||
cable.save()
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
self.assertFalse(Cable._is_being_deleted(cable.pk))
|
||||
|
||||
with patch('dcim.models.cables.CablePath.retrace') as retrace:
|
||||
cable.delete()
|
||||
|
||||
# Exactly one retrace per affected CablePath (from retrace_cable_paths),
|
||||
# not the n*m calls the per-termination handler would have made.
|
||||
self.assertEqual(retrace.call_count, 2)
|
||||
# The deletion-tracking set must be cleaned up after delete() returns,
|
||||
# even when the cascade runs to completion.
|
||||
self.assertFalse(Cable._is_being_deleted(cable.pk))
|
||||
|
||||
def test_creating_portmapping_retraces_dependent_paths(self):
|
||||
interface = Interface.objects.create(device=self.device, name='Interface A')
|
||||
front_port = FrontPort.objects.create(device=self.device, name='Front Port 1')
|
||||
rear_port = RearPort.objects.create(device=self.device, name='Rear Port 1')
|
||||
Cable(a_terminations=[interface], b_terminations=[front_port]).save()
|
||||
|
||||
# Creating a PortMapping connecting the front and rear ports should retrace paths
|
||||
# that traverse either port (i.e. the incomplete path through front_port).
|
||||
PortMapping.objects.create(
|
||||
device=self.device,
|
||||
front_port=front_port,
|
||||
front_port_position=1,
|
||||
rear_port=rear_port,
|
||||
rear_port_position=1,
|
||||
)
|
||||
|
||||
path = CablePath.objects.filter(_nodes__contains=front_port).first()
|
||||
self.assertIsNotNone(path)
|
||||
# The retraced path should now extend through to the rear port. Path nodes are
|
||||
# encoded as "<content_type_id>:<object_id>".
|
||||
rear_port_node = f'{ContentType.objects.get_for_model(RearPort).pk}:{rear_port.pk}'
|
||||
flat_nodes = [n for step in path.path for n in step]
|
||||
self.assertIn(rear_port_node, flat_nodes)
|
||||
|
||||
def test_deleting_cabletermination_nullifies_endpoints(self):
|
||||
interface_a = Interface.objects.create(device=self.device, name='Interface A')
|
||||
interface_b = Interface.objects.create(device=self.device, name='Interface B')
|
||||
cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
|
||||
cable.save()
|
||||
termination = cable.terminations.get(cable_end=CableEndChoices.SIDE_A)
|
||||
|
||||
termination.delete()
|
||||
interface_a.refresh_from_db()
|
||||
self.assertIsNone(interface_a.cable_id)
|
||||
self.assertEqual(interface_a.cable_end, '')
|
||||
|
||||
|
||||
class MACAddressInterfaceSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify dcim.signals.update_mac_address_interface assigns a designated primary MAC to
|
||||
the newly-created Interface or VMInterface.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.site = Site.objects.create(name='Site', slug='site')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
|
||||
role = DeviceRole.objects.create(name='Device Role', slug='device-role')
|
||||
cls.device = Device.objects.create(
|
||||
name='Device',
|
||||
site=cls.site,
|
||||
device_type=device_type,
|
||||
role=role,
|
||||
)
|
||||
|
||||
def test_primary_mac_is_assigned_to_new_interface(self):
|
||||
mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55')
|
||||
interface = Interface(device=self.device, name='Interface 1', primary_mac_address=mac)
|
||||
interface.save()
|
||||
|
||||
mac.refresh_from_db()
|
||||
self.assertEqual(mac.assigned_object, interface)
|
||||
|
||||
def test_primary_mac_is_not_reassigned_on_interface_update(self):
|
||||
mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55')
|
||||
interface = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
mac.assigned_object = interface
|
||||
mac.save()
|
||||
# Detach (simulate the MAC having been moved off the interface).
|
||||
mac.assigned_object = None
|
||||
mac.save()
|
||||
|
||||
interface.primary_mac_address = mac
|
||||
interface.description = 'updated'
|
||||
interface.save()
|
||||
|
||||
mac.refresh_from_db()
|
||||
# Updating an existing interface should not re-assign the MAC.
|
||||
self.assertIsNone(mac.assigned_object)
|
||||
|
||||
|
||||
class SyncCachedScopeFieldsSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify dcim.signals.sync_cached_scope_fields recomputes cached scope fields on
|
||||
Prefix, Cluster, and WirelessLAN when a Site or Location is modified.
|
||||
"""
|
||||
|
||||
def test_site_group_change_updates_prefix_cached_scope(self):
|
||||
group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
|
||||
group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
|
||||
site = Site.objects.create(name='Site', slug='site', group=group_a)
|
||||
prefix = Prefix.objects.create(
|
||||
prefix='10.0.0.0/24',
|
||||
scope_type=ContentType.objects.get_for_model(Site),
|
||||
scope_id=site.pk,
|
||||
)
|
||||
self.assertEqual(prefix._site_group, group_a)
|
||||
|
||||
site.group = group_b
|
||||
site.save()
|
||||
|
||||
prefix.refresh_from_db()
|
||||
self.assertEqual(prefix._site, site)
|
||||
self.assertEqual(prefix._site_group, group_b)
|
||||
|
||||
def test_location_site_change_updates_prefix_cached_scope(self):
|
||||
site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
location = Location.objects.create(name='Loc', slug='loc', site=site_a)
|
||||
prefix = Prefix.objects.create(
|
||||
prefix='10.0.0.0/24',
|
||||
scope_type=ContentType.objects.get_for_model(Location),
|
||||
scope_id=location.pk,
|
||||
)
|
||||
self.assertEqual(prefix._site, site_a)
|
||||
self.assertEqual(prefix._location, location)
|
||||
|
||||
location.site = site_b
|
||||
location.save()
|
||||
|
||||
prefix.refresh_from_db()
|
||||
self.assertEqual(prefix._location, location)
|
||||
self.assertEqual(prefix._site, site_b)
|
||||
|
||||
def test_signal_updates_cluster_and_wirelesslan_cached_scope(self):
|
||||
# Lock down the explicit (Prefix, Cluster, WirelessLAN) tuple in the
|
||||
# signal by exercising Cluster and WirelessLAN alongside Prefix. If a
|
||||
# future change drops Cluster or WirelessLAN from that tuple, this test
|
||||
# will catch it.
|
||||
group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
|
||||
group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
|
||||
site = Site.objects.create(name='Site', slug='site', group=group_a)
|
||||
cluster_type = ClusterType.objects.create(name='CT', slug='ct')
|
||||
cluster = Cluster.objects.create(name='Cluster', type=cluster_type, scope=site)
|
||||
wireless_lan = WirelessLAN.objects.create(ssid='LAN', scope=site)
|
||||
|
||||
self.assertEqual(cluster._site_group, group_a)
|
||||
self.assertEqual(wireless_lan._site_group, group_a)
|
||||
|
||||
site.group = group_b
|
||||
site.save()
|
||||
|
||||
cluster.refresh_from_db()
|
||||
wireless_lan.refresh_from_db()
|
||||
self.assertEqual(cluster._site_group, group_b)
|
||||
self.assertEqual(wireless_lan._site_group, group_b)
|
||||
|
||||
def test_create_site_does_not_attempt_to_resync(self):
|
||||
# Should not raise — newly-created sites have nothing to sync.
|
||||
Site.objects.create(name='New Site', slug='new-site')
|
||||
|
||||
|
||||
class CableSignalDirectHandlerTestCase(SimpleTestCase):
|
||||
"""
|
||||
Direct-call tests for dcim signal branches that are not reachable through normal
|
||||
model operations (raw=True is set only by Django's loaddata pathway).
|
||||
"""
|
||||
|
||||
def test_update_connected_endpoints_raw_import_is_a_no_op(self):
|
||||
cable = SimpleNamespace(_terminations_modified=True)
|
||||
logger = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(signals.logging, 'getLogger', return_value=logger),
|
||||
patch.object(signals, 'CableTermination') as cabletermination_model,
|
||||
patch.object(signals, 'create_cablepaths') as create_cablepaths,
|
||||
patch.object(signals, 'rebuild_paths') as rebuild_paths,
|
||||
):
|
||||
signals.update_connected_endpoints(instance=cable, created=True, raw=True)
|
||||
|
||||
logger.debug.assert_called_once()
|
||||
cabletermination_model.objects.filter.assert_not_called()
|
||||
create_cablepaths.assert_not_called()
|
||||
rebuild_paths.assert_not_called()
|
||||
|
||||
def test_update_mac_address_interface_raw_import_is_a_no_op(self):
|
||||
primary_mac = SimpleNamespace(save=MagicMock())
|
||||
interface = SimpleNamespace(primary_mac_address=primary_mac)
|
||||
|
||||
signals.update_mac_address_interface(instance=interface, created=True, raw=True)
|
||||
|
||||
primary_mac.save.assert_not_called()
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
|
||||
from core.events import JOB_COMPLETED, JOB_STARTED, OBJECT_DELETED, OBJECT_UPDATED
|
||||
from core.models import Job, ObjectType
|
||||
from core.signals import job_end, job_start
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Region, Site
|
||||
from extras import signals
|
||||
from extras.choices import CustomFieldTypeChoices, EventRuleActionChoices
|
||||
from extras.models import CustomField, EventRule, Notification, Subscription, Tag, Webhook
|
||||
from extras.validators import CustomValidator
|
||||
from netbox.context_managers import event_tracking
|
||||
from users.models import User
|
||||
from utilities.exceptions import AbortRequest
|
||||
|
||||
|
||||
def _build_request(user=None):
|
||||
request = RequestFactory().get('/')
|
||||
request.id = uuid.uuid4()
|
||||
request.user = user
|
||||
return request
|
||||
|
||||
|
||||
class CustomFieldRenameSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify extras.signals.handle_cf_renamed migrates stored custom-field data when a
|
||||
CustomField is renamed.
|
||||
"""
|
||||
|
||||
def test_renaming_custom_field_moves_object_data(self):
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
cf = CustomField.objects.create(
|
||||
name='asset_tag',
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
)
|
||||
cf.object_types.set([site_type])
|
||||
site = Site.objects.create(name='Site 1', slug='site-1', custom_field_data={'asset_tag': 'A123'})
|
||||
|
||||
cf.name = 'inventory_id'
|
||||
cf.save()
|
||||
|
||||
site.refresh_from_db()
|
||||
self.assertNotIn('asset_tag', site.custom_field_data)
|
||||
self.assertEqual(site.custom_field_data['inventory_id'], 'A123')
|
||||
|
||||
def test_creating_custom_field_does_not_attempt_rename(self):
|
||||
# Should not raise — newly-created custom fields have no prior name to migrate.
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
cf = CustomField.objects.create(
|
||||
name='cf1',
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
)
|
||||
cf.object_types.set([site_type])
|
||||
|
||||
|
||||
class CustomFieldDeletedSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify extras.signals.handle_cf_deleted strips stale data from associated objects
|
||||
when a CustomField is deleted.
|
||||
"""
|
||||
|
||||
def test_deleting_custom_field_clears_object_data(self):
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
cf = CustomField.objects.create(
|
||||
name='asset_tag',
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
)
|
||||
cf.object_types.set([site_type])
|
||||
site = Site.objects.create(name='Site 1', slug='site-1', custom_field_data={'asset_tag': 'A123'})
|
||||
|
||||
cf.delete()
|
||||
|
||||
site.refresh_from_db()
|
||||
self.assertNotIn('asset_tag', site.custom_field_data)
|
||||
|
||||
|
||||
class CustomFieldObjectTypeSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify extras.signals.handle_cf_added_obj_types and handle_cf_removed_obj_types
|
||||
populate or strip default values when a CustomField's object_types m2m changes.
|
||||
"""
|
||||
|
||||
def test_adding_object_type_populates_default_value(self):
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
Site.objects.create(name='Site 1', slug='site-1')
|
||||
cf = CustomField.objects.create(
|
||||
name='asset_tag',
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
default='UNTAGGED',
|
||||
)
|
||||
|
||||
cf.object_types.set([site_type])
|
||||
|
||||
site = Site.objects.get(slug='site-1')
|
||||
self.assertEqual(site.custom_field_data.get('asset_tag'), 'UNTAGGED')
|
||||
|
||||
def test_removing_object_type_clears_stored_data(self):
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
cf = CustomField.objects.create(
|
||||
name='asset_tag',
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
)
|
||||
cf.object_types.set([site_type])
|
||||
site = Site.objects.create(name='Site 1', slug='site-1', custom_field_data={'asset_tag': 'A123'})
|
||||
|
||||
cf.object_types.remove(site_type)
|
||||
|
||||
site.refresh_from_db()
|
||||
self.assertNotIn('asset_tag', site.custom_field_data)
|
||||
|
||||
|
||||
class RunSaveValidatorsSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify extras.signals.run_save_validators invokes any configured CUSTOM_VALIDATORS
|
||||
when a model emits the post_clean signal.
|
||||
"""
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [CustomValidator({'name': {'eq': 'allowed'}})]})
|
||||
def test_validation_failure_raises_validation_error(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
Site(name='blocked', slug='blocked', status=SiteStatusChoices.STATUS_ACTIVE).clean()
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [CustomValidator({'name': {'eq': 'allowed'}})]})
|
||||
def test_validation_success_does_not_raise(self):
|
||||
Site(name='allowed', slug='allowed', status=SiteStatusChoices.STATUS_ACTIVE).clean()
|
||||
|
||||
|
||||
class ValidateAssignedTagsSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify extras.signals.validate_assigned_tags rejects Tags that are restricted to
|
||||
object types incompatible with the target object.
|
||||
"""
|
||||
|
||||
def test_restricted_tag_blocks_incompatible_object(self):
|
||||
region_type = ContentType.objects.get_for_model(Region)
|
||||
tag = Tag.objects.create(name='RegionOnly', slug='regiononly')
|
||||
tag.object_types.set([region_type])
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
with self.assertRaises(AbortRequest):
|
||||
site.tags.add(tag)
|
||||
|
||||
def test_restricted_tag_allows_compatible_object(self):
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
tag = Tag.objects.create(name='SiteOnly', slug='siteonly')
|
||||
tag.object_types.set([site_type])
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
site.tags.add(tag)
|
||||
self.assertEqual(list(site.tags.all()), [tag])
|
||||
|
||||
def test_unrestricted_tag_is_always_permitted(self):
|
||||
tag = Tag.objects.create(name='Anywhere', slug='anywhere')
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
site.tags.add(tag)
|
||||
self.assertEqual(list(site.tags.all()), [tag])
|
||||
|
||||
|
||||
class JobEventRulesSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify extras.signals.process_job_start_event_rules and process_job_end_event_rules
|
||||
invoke any EventRule registered for JOB_STARTED / JOB_COMPLETED on the sender's
|
||||
object_type.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.site_type = ObjectType.objects.get_for_model(Site)
|
||||
webhook = Webhook.objects.create(
|
||||
name='Webhook',
|
||||
payload_url='http://localhost/',
|
||||
secret='secret',
|
||||
)
|
||||
webhook_type = ObjectType.objects.get_for_model(Webhook)
|
||||
cls.start_rule = EventRule.objects.create(
|
||||
name='Job Start Rule',
|
||||
event_types=[JOB_STARTED],
|
||||
action_type=EventRuleActionChoices.WEBHOOK,
|
||||
action_object_type=webhook_type,
|
||||
action_object_id=webhook.pk,
|
||||
)
|
||||
cls.start_rule.object_types.set([cls.site_type])
|
||||
cls.end_rule = EventRule.objects.create(
|
||||
name='Job End Rule',
|
||||
event_types=[JOB_COMPLETED],
|
||||
action_type=EventRuleActionChoices.WEBHOOK,
|
||||
action_object_type=webhook_type,
|
||||
action_object_id=webhook.pk,
|
||||
)
|
||||
cls.end_rule.object_types.set([cls.site_type])
|
||||
|
||||
def _create_job(self):
|
||||
return Job.objects.create(
|
||||
object_type=self.site_type,
|
||||
name='test-job',
|
||||
job_id=uuid.uuid4(),
|
||||
data={'foo': 1},
|
||||
)
|
||||
|
||||
def test_job_start_filters_to_matching_event_rules(self):
|
||||
sender = self._create_job()
|
||||
|
||||
with patch('extras.signals.process_event_rules') as process_event_rules:
|
||||
job_start.send(sender=sender)
|
||||
|
||||
process_event_rules.assert_called_once()
|
||||
rules_qs = process_event_rules.call_args.args[0]
|
||||
self.assertEqual(list(rules_qs.values_list('pk', flat=True)), [self.start_rule.pk])
|
||||
|
||||
def test_job_end_filters_to_matching_event_rules(self):
|
||||
sender = self._create_job()
|
||||
|
||||
with patch('extras.signals.process_event_rules') as process_event_rules:
|
||||
job_end.send(sender=sender)
|
||||
|
||||
process_event_rules.assert_called_once()
|
||||
rules_qs = process_event_rules.call_args.args[0]
|
||||
self.assertEqual(list(rules_qs.values_list('pk', flat=True)), [self.end_rule.pk])
|
||||
|
||||
|
||||
class NotifyObjectChangedSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify extras.signals.notify_object_changed creates Notifications for subscribed
|
||||
users on object update and deletion, and skips creation otherwise.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create_user(username='alice', password='pw')
|
||||
|
||||
def test_creating_object_does_not_create_notifications(self):
|
||||
# Subscribe a user to the object BEFORE invoking the handler so the
|
||||
# discriminating branch (created=True early return) is genuinely
|
||||
# exercised. Without the subscription this assertion passes trivially.
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
Subscription.objects.create(user=self.user, object_type=site_type, object_id=site.pk)
|
||||
Notification.objects.all().delete()
|
||||
|
||||
signals.notify_object_changed(sender=Site, instance=site, created=True)
|
||||
|
||||
self.assertFalse(Notification.objects.exists())
|
||||
|
||||
def test_updating_subscribed_object_creates_update_notification(self):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
Subscription.objects.create(user=self.user, object_type=site_type, object_id=site.pk)
|
||||
|
||||
site.description = 'updated'
|
||||
site.save()
|
||||
|
||||
notification = Notification.objects.get(user=self.user)
|
||||
self.assertEqual(notification.object_type, site_type)
|
||||
self.assertEqual(notification.object_id, site.pk)
|
||||
self.assertEqual(notification.event_type, OBJECT_UPDATED)
|
||||
|
||||
def test_deleting_subscribed_object_creates_delete_notification(self):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
Subscription.objects.create(user=self.user, object_type=site_type, object_id=site.pk)
|
||||
|
||||
site.delete()
|
||||
|
||||
notification = Notification.objects.get(user=self.user)
|
||||
self.assertEqual(notification.event_type, OBJECT_DELETED)
|
||||
|
||||
def test_updating_unsubscribed_object_creates_no_notification(self):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
site.description = 'updated'
|
||||
site.save()
|
||||
|
||||
self.assertEqual(Notification.objects.count(), 0)
|
||||
|
||||
def test_updating_object_replaces_existing_notification(self):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
site_type = ContentType.objects.get_for_model(Site)
|
||||
Subscription.objects.create(user=self.user, object_type=site_type, object_id=site.pk)
|
||||
|
||||
# Trigger two updates within a single request; only one Notification should remain.
|
||||
request = _build_request(user=self.user)
|
||||
with event_tracking(request):
|
||||
site.description = 'first'
|
||||
site.save()
|
||||
site.description = 'second'
|
||||
site.save()
|
||||
|
||||
self.assertEqual(
|
||||
Notification.objects.filter(user=self.user, object_type=site_type, object_id=site.pk).count(),
|
||||
1,
|
||||
)
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
import uuid
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.models import ObjectChange
|
||||
from ipam.models import IPAddress, Prefix
|
||||
from netbox.context_managers import event_tracking
|
||||
from users.models import User
|
||||
from utilities.testing.utils import create_test_device, create_test_virtualmachine
|
||||
|
||||
|
||||
def _build_request(user):
|
||||
request = RequestFactory().get('/')
|
||||
request.id = uuid.uuid4()
|
||||
request.user = user
|
||||
return request
|
||||
|
||||
|
||||
class PrefixHierarchySignalTestCase(TestCase):
|
||||
"""
|
||||
Verify ipam.signals.handle_prefix_saved / handle_prefix_deleted keep the cached
|
||||
`_children` and `_depth` counters up to date as prefixes are added, modified, and
|
||||
removed.
|
||||
"""
|
||||
|
||||
def _refresh_counters(self, *prefixes):
|
||||
for prefix in prefixes:
|
||||
prefix.refresh_from_db()
|
||||
return prefixes
|
||||
|
||||
def test_creating_prefix_initializes_hierarchy_counters(self):
|
||||
parent = Prefix.objects.create(prefix='10.0.0.0/16')
|
||||
child = Prefix.objects.create(prefix='10.0.1.0/24')
|
||||
|
||||
self._refresh_counters(parent, child)
|
||||
self.assertEqual(parent._children, 1)
|
||||
self.assertEqual(child._depth, 1)
|
||||
self.assertEqual(child._children, 0)
|
||||
|
||||
def test_modifying_prefix_recomputes_old_and_new_position(self):
|
||||
parent_a = Prefix.objects.create(prefix='10.0.0.0/16')
|
||||
parent_b = Prefix.objects.create(prefix='192.168.0.0/16')
|
||||
child = Prefix.objects.create(prefix='10.0.1.0/24')
|
||||
|
||||
self._refresh_counters(parent_a, parent_b, child)
|
||||
self.assertEqual(parent_a._children, 1)
|
||||
self.assertEqual(parent_b._children, 0)
|
||||
|
||||
# Move the child under parent_b.
|
||||
child.prefix = '192.168.1.0/24'
|
||||
child.save()
|
||||
|
||||
self._refresh_counters(parent_a, parent_b, child)
|
||||
self.assertEqual(parent_a._children, 0)
|
||||
self.assertEqual(parent_b._children, 1)
|
||||
self.assertEqual(child._depth, 1)
|
||||
|
||||
def test_unchanged_save_does_not_disturb_counters(self):
|
||||
parent = Prefix.objects.create(prefix='10.0.0.0/16')
|
||||
child = Prefix.objects.create(prefix='10.0.1.0/24')
|
||||
|
||||
self._refresh_counters(parent, child)
|
||||
original_children = parent._children
|
||||
original_depth = child._depth
|
||||
|
||||
# Save with no field changes.
|
||||
parent.description = ''
|
||||
parent.save()
|
||||
|
||||
self._refresh_counters(parent, child)
|
||||
self.assertEqual(parent._children, original_children)
|
||||
self.assertEqual(child._depth, original_depth)
|
||||
|
||||
def test_deleting_prefix_recomputes_neighbor_counters(self):
|
||||
parent = Prefix.objects.create(prefix='10.0.0.0/16')
|
||||
child = Prefix.objects.create(prefix='10.0.1.0/24')
|
||||
|
||||
self._refresh_counters(parent)
|
||||
self.assertEqual(parent._children, 1)
|
||||
|
||||
child.delete()
|
||||
|
||||
self._refresh_counters(parent)
|
||||
self.assertEqual(parent._children, 0)
|
||||
|
||||
|
||||
class ClearPrimaryIPSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify ipam.signals.clear_primary_ip detaches deleted IPAddresses from the Device or
|
||||
VirtualMachine they were assigned as primary. The behavior under test is the
|
||||
signal-driven snapshot+save (and resulting change-log entry), not the FK's
|
||||
on_delete=SET_NULL fallback.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create_user(username='alice', password='pw')
|
||||
|
||||
def test_device_primary_ip4_delete_records_device_update(self):
|
||||
device = create_test_device('Device 1')
|
||||
ip = IPAddress.objects.create(address='192.0.2.1/24')
|
||||
device.primary_ip4 = ip
|
||||
device.save()
|
||||
|
||||
request = _build_request(self.user)
|
||||
with event_tracking(request):
|
||||
ip.delete()
|
||||
|
||||
device.refresh_from_db()
|
||||
self.assertIsNone(device.primary_ip4)
|
||||
|
||||
oc = ObjectChange.objects.get(
|
||||
changed_object_type=ContentType.objects.get_for_model(device),
|
||||
changed_object_id=device.pk,
|
||||
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
||||
)
|
||||
self.assertIsNone(oc.postchange_data['primary_ip4'])
|
||||
|
||||
def test_device_primary_ip6_delete_clears_field_and_saves(self):
|
||||
device = create_test_device('Device 1')
|
||||
ip = IPAddress.objects.create(address='2001:db8::1/64')
|
||||
device.primary_ip6 = ip
|
||||
device.save()
|
||||
|
||||
request = _build_request(self.user)
|
||||
with event_tracking(request):
|
||||
ip.delete()
|
||||
|
||||
device.refresh_from_db()
|
||||
self.assertIsNone(device.primary_ip6)
|
||||
oc = ObjectChange.objects.get(
|
||||
changed_object_type=ContentType.objects.get_for_model(device),
|
||||
changed_object_id=device.pk,
|
||||
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
||||
)
|
||||
self.assertIsNone(oc.postchange_data['primary_ip6'])
|
||||
|
||||
def test_vm_primary_ip4_delete_records_vm_update(self):
|
||||
vm = create_test_virtualmachine('VM 1')
|
||||
ip = IPAddress.objects.create(address='192.0.2.10/24')
|
||||
vm.primary_ip4 = ip
|
||||
vm.save()
|
||||
|
||||
request = _build_request(self.user)
|
||||
with event_tracking(request):
|
||||
ip.delete()
|
||||
|
||||
vm.refresh_from_db()
|
||||
self.assertIsNone(vm.primary_ip4)
|
||||
oc = ObjectChange.objects.get(
|
||||
changed_object_type=ContentType.objects.get_for_model(vm),
|
||||
changed_object_id=vm.pk,
|
||||
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
||||
)
|
||||
self.assertIsNone(oc.postchange_data['primary_ip4'])
|
||||
|
||||
def test_unrelated_ip_delete_records_no_device_change(self):
|
||||
device = create_test_device('Device 1')
|
||||
device_type = ContentType.objects.get_for_model(device)
|
||||
assigned = IPAddress.objects.create(address='192.0.2.1/24')
|
||||
unrelated = IPAddress.objects.create(address='192.0.2.2/24')
|
||||
device.primary_ip4 = assigned
|
||||
device.save()
|
||||
|
||||
request = _build_request(self.user)
|
||||
with event_tracking(request):
|
||||
unrelated.delete()
|
||||
|
||||
device.refresh_from_db()
|
||||
self.assertEqual(device.primary_ip4, assigned)
|
||||
self.assertFalse(
|
||||
ObjectChange.objects.filter(
|
||||
changed_object_type=device_type,
|
||||
changed_object_id=device.pk,
|
||||
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class ClearOOBIPSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify ipam.signals.clear_oob_ip detaches a deleted IPAddress from any Device on
|
||||
which it was set as the OOB IP, and records a Device update change-log entry.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create_user(username='alice', password='pw')
|
||||
|
||||
def test_device_oob_ip_delete_records_device_update(self):
|
||||
device = create_test_device('Device 1')
|
||||
ip = IPAddress.objects.create(address='192.0.2.1/24')
|
||||
device.oob_ip = ip
|
||||
device.save()
|
||||
|
||||
request = _build_request(self.user)
|
||||
with event_tracking(request):
|
||||
ip.delete()
|
||||
|
||||
device.refresh_from_db()
|
||||
self.assertIsNone(device.oob_ip)
|
||||
oc = ObjectChange.objects.get(
|
||||
changed_object_type=ContentType.objects.get_for_model(device),
|
||||
changed_object_id=device.pk,
|
||||
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
||||
)
|
||||
self.assertIsNone(oc.postchange_data['oob_ip'])
|
||||
|
||||
def test_unrelated_ip_delete_records_no_device_change(self):
|
||||
device = create_test_device('Device 1')
|
||||
device_type = ContentType.objects.get_for_model(device)
|
||||
oob = IPAddress.objects.create(address='192.0.2.1/24')
|
||||
unrelated = IPAddress.objects.create(address='192.0.2.2/24')
|
||||
device.oob_ip = oob
|
||||
device.save()
|
||||
|
||||
request = _build_request(self.user)
|
||||
with event_tracking(request):
|
||||
unrelated.delete()
|
||||
|
||||
device.refresh_from_db()
|
||||
self.assertEqual(device.oob_ip, oob)
|
||||
self.assertFalse(
|
||||
ObjectChange.objects.filter(
|
||||
changed_object_type=device_type,
|
||||
changed_object_id=device.pk,
|
||||
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
||||
).exists()
|
||||
)
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
from django.contrib.auth.signals import user_logged_in, user_login_failed
|
||||
from django.db.models.signals import post_save
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
|
||||
from users.models import User, UserConfig
|
||||
from users.signals import create_userconfig
|
||||
|
||||
|
||||
class LogUserLoginFailedSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify users.signals.log_user_login_failed emits the expected log records when the
|
||||
user_login_failed signal fires.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_log_includes_client_ip_when_available(self):
|
||||
request = self.factory.post('/login/', REMOTE_ADDR='192.0.2.100')
|
||||
|
||||
with self.assertLogs('netbox.auth.login', level='INFO') as cm:
|
||||
user_login_failed.send(
|
||||
sender=self.__class__,
|
||||
credentials={'username': 'alice'},
|
||||
request=request,
|
||||
)
|
||||
|
||||
self.assertEqual(len(cm.records), 1)
|
||||
self.assertEqual(cm.records[0].levelname, 'INFO')
|
||||
self.assertIn('alice', cm.records[0].getMessage())
|
||||
self.assertIn('192.0.2.100', cm.records[0].getMessage())
|
||||
|
||||
def test_log_warns_when_client_ip_missing(self):
|
||||
request = self.factory.post('/login/')
|
||||
# RequestFactory sets REMOTE_ADDR by default; strip it to simulate missing IP.
|
||||
request.META.pop('REMOTE_ADDR', None)
|
||||
|
||||
with self.assertLogs('netbox.auth.login', level='INFO') as cm:
|
||||
user_login_failed.send(
|
||||
sender=self.__class__,
|
||||
credentials={'username': 'alice'},
|
||||
request=request,
|
||||
)
|
||||
|
||||
levels = [record.levelname for record in cm.records]
|
||||
self.assertIn('WARNING', levels)
|
||||
self.assertIn('INFO', levels)
|
||||
info_message = next(r.getMessage() for r in cm.records if r.levelname == 'INFO')
|
||||
self.assertEqual(info_message, 'Failed login attempt for username: alice')
|
||||
|
||||
|
||||
class SetLanguageOnLoginSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify users.signals.set_language_on_login stores the user's preferred language on the
|
||||
request when the user logs in.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.user = User.objects.create_user(username='alice', password='pw')
|
||||
|
||||
def test_language_cookie_is_set_from_user_config(self):
|
||||
# Assign a fresh dict to avoid mutating the shared DEFAULT_USER_PREFERENCES reference.
|
||||
self.user.config.data = {'locale': {'language': 'de'}}
|
||||
self.user.config.save()
|
||||
request = self.factory.post('/login/')
|
||||
|
||||
user_logged_in.send(sender=self.__class__, user=self.user, request=request)
|
||||
|
||||
self.assertEqual(request._language_cookie, 'de')
|
||||
|
||||
def test_missing_language_preference_leaves_request_untouched(self):
|
||||
self.user.config.data = {}
|
||||
self.user.config.save()
|
||||
request = self.factory.post('/login/')
|
||||
|
||||
user_logged_in.send(sender=self.__class__, user=self.user, request=request)
|
||||
|
||||
self.assertFalse(hasattr(request, '_language_cookie'))
|
||||
|
||||
def test_user_without_config_leaves_request_untouched(self):
|
||||
request = self.factory.post('/login/')
|
||||
# Drop the UserConfig that was auto-created so `hasattr(user, 'config')` returns False.
|
||||
self.user.config.delete()
|
||||
# Reload the user instance from the DB so the cached 'config' related attribute is gone.
|
||||
user = User.objects.get(pk=self.user.pk)
|
||||
|
||||
user_logged_in.send(sender=self.__class__, user=user, request=request)
|
||||
|
||||
self.assertFalse(hasattr(request, '_language_cookie'))
|
||||
|
||||
|
||||
class CreateUserConfigSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify users.signals.create_userconfig creates a default UserConfig for new users only.
|
||||
"""
|
||||
|
||||
@override_settings(DEFAULT_USER_PREFERENCES={'pagination.per_page': 42})
|
||||
def test_userconfig_is_created_with_default_preferences(self):
|
||||
user = User.objects.create_user(username='alice', password='pw')
|
||||
|
||||
config = UserConfig.objects.get(user=user)
|
||||
self.assertEqual(config.data, {'pagination.per_page': 42})
|
||||
|
||||
def test_userconfig_is_not_created_for_existing_user(self):
|
||||
user = User.objects.create_user(username='alice', password='pw')
|
||||
UserConfig.objects.filter(user=user).delete()
|
||||
|
||||
user.email = 'alice@example.com'
|
||||
user.save()
|
||||
|
||||
self.assertFalse(UserConfig.objects.filter(user=user).exists())
|
||||
|
||||
def test_userconfig_is_not_created_for_raw_imports(self):
|
||||
"""
|
||||
When loading a fixture (`raw=True`), the signal must skip UserConfig creation.
|
||||
"""
|
||||
user = User(username='bob')
|
||||
# Simulate the post_save signal Django emits during loaddata with raw=True.
|
||||
post_save.disconnect(create_userconfig, sender=User)
|
||||
try:
|
||||
user.save()
|
||||
UserConfig.objects.filter(user=user).delete()
|
||||
create_userconfig(instance=user, created=True, raw=True)
|
||||
finally:
|
||||
post_save.connect(create_userconfig, sender=User)
|
||||
|
||||
self.assertFalse(UserConfig.objects.filter(user=user).exists())
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Site
|
||||
from virtualization.models import Cluster, ClusterType, VirtualDisk, VirtualMachine
|
||||
|
||||
|
||||
class UpdateVirtualMachineDiskSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify virtualization.signals.update_virtualmachine_disk keeps VirtualMachine.disk in sync
|
||||
with the aggregate size of its VirtualDisks.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cluster_type = ClusterType.objects.create(name='Cluster Type', slug='cluster-type')
|
||||
cls.cluster = Cluster.objects.create(name='Cluster', type=cluster_type)
|
||||
|
||||
def test_disk_size_is_aggregated_on_save(self):
|
||||
vm = VirtualMachine.objects.create(name='VM 1', cluster=self.cluster)
|
||||
VirtualDisk.objects.create(virtual_machine=vm, name='disk0', size=50)
|
||||
VirtualDisk.objects.create(virtual_machine=vm, name='disk1', size=75)
|
||||
|
||||
vm.refresh_from_db()
|
||||
self.assertEqual(vm.disk, 125)
|
||||
|
||||
def test_disk_size_is_recalculated_on_delete(self):
|
||||
vm = VirtualMachine.objects.create(name='VM 1', cluster=self.cluster)
|
||||
disk = VirtualDisk.objects.create(virtual_machine=vm, name='disk0', size=50)
|
||||
VirtualDisk.objects.create(virtual_machine=vm, name='disk1', size=75)
|
||||
vm.refresh_from_db()
|
||||
self.assertEqual(vm.disk, 125)
|
||||
|
||||
disk.delete()
|
||||
vm.refresh_from_db()
|
||||
self.assertEqual(vm.disk, 75)
|
||||
|
||||
def test_disk_size_is_none_when_all_disks_removed(self):
|
||||
vm = VirtualMachine.objects.create(name='VM 1', cluster=self.cluster)
|
||||
disk = VirtualDisk.objects.create(virtual_machine=vm, name='disk0', size=50)
|
||||
|
||||
disk.delete()
|
||||
vm.refresh_from_db()
|
||||
self.assertIsNone(vm.disk)
|
||||
|
||||
|
||||
class UpdateVirtualMachineSiteSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify virtualization.signals.update_virtualmachine_site propagates a Cluster's cached
|
||||
site to all of its VirtualMachines on save.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
cls.site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
cls.cluster_type = ClusterType.objects.create(name='Cluster Type', slug='cluster-type')
|
||||
|
||||
def test_cluster_site_change_propagates_to_vms(self):
|
||||
cluster = Cluster.objects.create(name='Cluster', type=self.cluster_type, scope=self.site_a)
|
||||
vm1 = VirtualMachine.objects.create(name='VM 1', cluster=cluster)
|
||||
vm2 = VirtualMachine.objects.create(name='VM 2', cluster=cluster)
|
||||
self.assertEqual(vm1.site, self.site_a)
|
||||
self.assertEqual(vm2.site, self.site_a)
|
||||
|
||||
# Re-scope the cluster to site B; both VMs should follow.
|
||||
cluster.scope_type = ContentType.objects.get_for_model(Site)
|
||||
cluster.scope_id = self.site_b.pk
|
||||
cluster.save()
|
||||
|
||||
vm1.refresh_from_db()
|
||||
vm2.refresh_from_db()
|
||||
self.assertEqual(vm1.site, self.site_b)
|
||||
self.assertEqual(vm2.site, self.site_b)
|
||||
|
||||
def test_cluster_without_site_does_not_overwrite_vm_site(self):
|
||||
cluster = Cluster.objects.create(name='Cluster', type=self.cluster_type)
|
||||
vm = VirtualMachine.objects.create(name='VM 1', cluster=cluster, site=self.site_a)
|
||||
self.assertEqual(vm.site, self.site_a)
|
||||
|
||||
cluster.description = 'updated'
|
||||
cluster.save()
|
||||
|
||||
vm.refresh_from_db()
|
||||
self.assertEqual(vm.site, self.site_a)
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
|
||||
from dcim.choices import InterfaceTypeChoices
|
||||
from dcim.models import CablePath, Interface
|
||||
from utilities.testing.utils import create_test_device
|
||||
from wireless import signals
|
||||
from wireless.choices import WirelessChannelChoices
|
||||
from wireless.models import WirelessLink
|
||||
|
||||
|
||||
class WirelessLinkSignalTestCase(TestCase):
|
||||
"""
|
||||
Verify wireless.signals.update_connected_interfaces and nullify_connected_interfaces
|
||||
keep the connected interfaces and CablePaths consistent with the WirelessLink lifecycle.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.device = create_test_device('Device 1')
|
||||
# Eight interfaces — one distinct pair per test method so no test sees stale
|
||||
# in-memory state mutated by a previous test.
|
||||
cls.interfaces = [
|
||||
Interface.objects.create(
|
||||
device=cls.device,
|
||||
name=f'radio{i}',
|
||||
type=InterfaceTypeChoices.TYPE_80211AC,
|
||||
rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
|
||||
rf_channel_frequency=5160,
|
||||
rf_channel_width=20,
|
||||
)
|
||||
for i in range(8)
|
||||
]
|
||||
|
||||
def test_creating_link_assigns_wireless_link_to_both_interfaces(self):
|
||||
interface_a, interface_b = self.interfaces[0], self.interfaces[1]
|
||||
link = WirelessLink(interface_a=interface_a, interface_b=interface_b, ssid='LINK1')
|
||||
link.save()
|
||||
|
||||
interface_a.refresh_from_db()
|
||||
interface_b.refresh_from_db()
|
||||
self.assertEqual(interface_a.wireless_link, link)
|
||||
self.assertEqual(interface_b.wireless_link, link)
|
||||
|
||||
def test_creating_link_creates_cablepaths(self):
|
||||
interface_a, interface_b = self.interfaces[2], self.interfaces[3]
|
||||
link = WirelessLink(interface_a=interface_a, interface_b=interface_b, ssid='LINK1')
|
||||
link.save()
|
||||
|
||||
self.assertEqual(CablePath.objects.filter(_nodes__contains=link).count(), 2)
|
||||
|
||||
def test_saving_existing_link_does_not_create_extra_paths(self):
|
||||
interface_a, interface_b = self.interfaces[4], self.interfaces[5]
|
||||
link = WirelessLink(interface_a=interface_a, interface_b=interface_b, ssid='LINK1')
|
||||
link.save()
|
||||
path_count = CablePath.objects.count()
|
||||
|
||||
link.description = 'updated'
|
||||
link.save()
|
||||
|
||||
self.assertEqual(CablePath.objects.count(), path_count)
|
||||
|
||||
def test_deleting_link_clears_interfaces_and_paths(self):
|
||||
interface_a, interface_b = self.interfaces[6], self.interfaces[7]
|
||||
link = WirelessLink(interface_a=interface_a, interface_b=interface_b, ssid='LINK1')
|
||||
link.save()
|
||||
self.assertEqual(CablePath.objects.filter(_nodes__contains=link).count(), 2)
|
||||
|
||||
link.delete()
|
||||
|
||||
interface_a.refresh_from_db()
|
||||
interface_b.refresh_from_db()
|
||||
self.assertIsNone(interface_a.wireless_link)
|
||||
self.assertIsNone(interface_b.wireless_link)
|
||||
# All wireless cable paths should be gone.
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
|
||||
class UpdateConnectedInterfacesDirectHandlerTestCase(SimpleTestCase):
|
||||
"""
|
||||
Direct-call tests for update_connected_interfaces branches not reachable through
|
||||
normal model operations (raw=True is only set during Django's loaddata pathway).
|
||||
"""
|
||||
|
||||
def test_raw_import_skips_interface_assignment_and_path_creation(self):
|
||||
interface_a = SimpleNamespace(wireless_link=None, save=MagicMock())
|
||||
interface_b = SimpleNamespace(wireless_link=None, cable=None, save=MagicMock())
|
||||
instance = SimpleNamespace(interface_a=interface_a, interface_b=interface_b)
|
||||
|
||||
with patch.object(signals, 'create_cablepaths') as create_cablepaths:
|
||||
signals.update_connected_interfaces(instance=instance, created=True, raw=True)
|
||||
|
||||
interface_a.save.assert_not_called()
|
||||
interface_b.save.assert_not_called()
|
||||
create_cablepaths.assert_not_called()
|
||||
Loading…
Reference in New Issue