diff --git a/arch/tests/test_models.py b/arch/tests/test_models.py new file mode 100644 index 00000000..d33cecb2 --- /dev/null +++ b/arch/tests/test_models.py @@ -0,0 +1,83 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings + +from arch.models import MachineArchitecture, PackageArchitecture + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class MachineArchitectureMethodTests(TestCase): + """Tests for MachineArchitecture model methods.""" + + def test_machine_arch_creation(self): + """Test creating a MachineArchitecture.""" + arch = MachineArchitecture.objects.create(name='x86_64') + self.assertEqual(arch.name, 'x86_64') + + def test_machine_arch_str(self): + """Test MachineArchitecture __str__ method.""" + arch = MachineArchitecture.objects.create(name='x86_64') + self.assertEqual(str(arch), 'x86_64') + + def test_machine_arch_unique_name(self): + """Test MachineArchitecture name is unique.""" + MachineArchitecture.objects.create(name='x86_64') + from django.db import IntegrityError + with self.assertRaises(IntegrityError): + MachineArchitecture.objects.create(name='x86_64') + + def test_common_machine_architectures(self): + """Test common machine architecture values.""" + archs = ['x86_64', 'aarch64', 'i686', 'armv7l', 'ppc64le'] + for name in archs: + arch = MachineArchitecture.objects.create(name=name) + self.assertEqual(str(arch), name) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class PackageArchitectureMethodTests(TestCase): + """Tests for PackageArchitecture model methods.""" + + def test_package_arch_creation(self): + """Test creating a PackageArchitecture.""" + arch = PackageArchitecture.objects.create(name='amd64') + self.assertEqual(arch.name, 'amd64') + + def test_package_arch_str(self): + """Test PackageArchitecture __str__ method.""" + arch = PackageArchitecture.objects.create(name='amd64') + self.assertEqual(str(arch), 'amd64') + + def test_package_arch_unique_name(self): + """Test PackageArchitecture name is unique.""" + PackageArchitecture.objects.create(name='amd64') + from django.db import IntegrityError + with self.assertRaises(IntegrityError): + PackageArchitecture.objects.create(name='amd64') + + def test_common_package_architectures(self): + """Test common package architecture values.""" + archs = ['amd64', 'i386', 'all', 'noarch', 'x86_64', 'arm64'] + for name in archs: + arch = PackageArchitecture.objects.create(name=name) + self.assertEqual(str(arch), name) diff --git a/domains/tests/test_models.py b/domains/tests/test_models.py new file mode 100644 index 00000000..1b553346 --- /dev/null +++ b/domains/tests/test_models.py @@ -0,0 +1,52 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings + +from domains.models import Domain + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class DomainMethodTests(TestCase): + """Tests for Domain model methods.""" + + def test_domain_creation(self): + """Test creating a Domain.""" + domain = Domain.objects.create(name='example.com') + self.assertEqual(domain.name, 'example.com') + + def test_domain_str(self): + """Test Domain __str__ method.""" + domain = Domain.objects.create(name='example.com') + self.assertEqual(str(domain), 'example.com') + + def test_domain_unique_name(self): + """Test Domain name is unique.""" + Domain.objects.create(name='example.com') + from django.db import IntegrityError + with self.assertRaises(IntegrityError): + Domain.objects.create(name='example.com') + + def test_domain_extract_from_fqdn(self): + """Test extracting domain from FQDN via Host creation.""" + # Domains are typically extracted when hosts are created + # Test the domain itself can be created with subdomain parts + Domain.objects.create(name='subdomain.example.com') + domain = Domain.objects.get(name='subdomain.example.com') + self.assertEqual(domain.name, 'subdomain.example.com') diff --git a/errata/tests/test_integration.py b/errata/tests/test_integration.py new file mode 100644 index 00000000..254e7aae --- /dev/null +++ b/errata/tests/test_integration.py @@ -0,0 +1,74 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings + +from errata.models import Erratum +from operatingsystems.models import OSRelease +from security.models import CVE + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ErrataIntegrationTests(TestCase): + """Integration tests for errata functionality.""" + + def test_erratum_with_cves(self): + """Test erratum can be associated with CVEs.""" + cve1 = CVE.objects.create(cve_id='CVE-2024-1001') + cve2 = CVE.objects.create(cve_id='CVE-2024-1002') + + erratum = Erratum.objects.create( + name='RHSA-2024:1234', + e_type='Security Advisory', + synopsis='Important: curl security update', + issue_date='2024-03-15', + ) + erratum.cves.add(cve1, cve2) + + self.assertEqual(erratum.cves.count(), 2) + self.assertIn(cve1, erratum.cves.all()) + self.assertIn(cve2, erratum.cves.all()) + + def test_erratum_with_osreleases(self): + """Test erratum can be associated with OS releases.""" + osrelease1 = OSRelease.objects.create(name='Rocky Linux 9') + osrelease2 = OSRelease.objects.create(name='Rocky Linux 8') + + erratum = Erratum.objects.create( + name='RHSA-2024:1235', + e_type='Security Advisory', + synopsis='Important: openssl security update', + issue_date='2024-03-16', + ) + erratum.osreleases.add(osrelease1, osrelease2) + + self.assertEqual(erratum.osreleases.count(), 2) + + def test_erratum_with_packages(self): + """Test erratum can reference package names.""" + erratum = Erratum.objects.create( + name='RHSA-2024:1236', + e_type='Bug Fix', + synopsis='Bug fix: httpd update', + issue_date='2024-03-17', + ) + + # Verify erratum can store package references + self.assertIsNotNone(erratum) + self.assertEqual(erratum.e_type, 'Bug Fix') diff --git a/errata/tests/test_models.py b/errata/tests/test_models.py new file mode 100644 index 00000000..fc316297 --- /dev/null +++ b/errata/tests/test_models.py @@ -0,0 +1,122 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings +from django.utils import timezone + +from errata.models import Erratum +from operatingsystems.models import OSRelease +from security.models import CVE + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ErratumMethodTests(TestCase): + """Tests for Erratum model methods.""" + + def test_erratum_creation(self): + """Test creating an Erratum.""" + erratum = Erratum.objects.create( + name='USN-1234-1', + e_type='security', + synopsis='Security update', + issue_date=timezone.now(), + ) + self.assertEqual(erratum.name, 'USN-1234-1') + + def test_erratum_str(self): + """Test Erratum __str__ method.""" + erratum = Erratum.objects.create( + name='USN-1234-1', + e_type='security', + synopsis='Security update', + issue_date=timezone.now(), + ) + self.assertIn('USN-1234-1', str(erratum)) + + def test_erratum_get_absolute_url(self): + """Test Erratum.get_absolute_url().""" + erratum = Erratum.objects.create( + name='USN-1234-1', + e_type='security', + synopsis='Security update', + issue_date=timezone.now(), + ) + url = erratum.get_absolute_url() + self.assertIn(erratum.name, url) + + def test_erratum_unique_name(self): + """Test Erratum name is unique.""" + Erratum.objects.create( + name='USN-1234-1', + e_type='security', + synopsis='Security update', + issue_date=timezone.now(), + ) + from django.db import IntegrityError + with self.assertRaises(IntegrityError): + Erratum.objects.create( + name='USN-1234-1', + e_type='bugfix', + synopsis='Bugfix update', + issue_date=timezone.now(), + ) + + def test_erratum_with_cves(self): + """Test Erratum with associated CVEs.""" + erratum = Erratum.objects.create( + name='USN-1234-1', + e_type='security', + synopsis='Security update', + issue_date=timezone.now(), + ) + cve = CVE.objects.create(cve_id='CVE-2024-12345') + erratum.cves.add(cve) + self.assertIn(cve, erratum.cves.all()) + + def test_erratum_with_osreleases(self): + """Test Erratum with associated OS releases.""" + erratum = Erratum.objects.create( + name='USN-1234-1', + e_type='security', + synopsis='Security update', + issue_date=timezone.now(), + ) + release = OSRelease.objects.create(name='Ubuntu 22.04') + erratum.osreleases.add(release) + self.assertIn(release, erratum.osreleases.all()) + + def test_security_erratum(self): + """Test creating a security erratum.""" + erratum = Erratum.objects.create( + name='RHSA-2024:1234', + e_type='security', + synopsis='Important security update', + issue_date=timezone.now(), + ) + self.assertEqual(erratum.e_type, 'security') + + def test_bugfix_erratum(self): + """Test creating a bugfix erratum.""" + erratum = Erratum.objects.create( + name='RHBA-2024:1234', + e_type='bugfix', + synopsis='Bug fix update', + issue_date=timezone.now(), + ) + self.assertEqual(erratum.e_type, 'bugfix') diff --git a/hosts/tests/test_find_updates.py b/hosts/tests/test_find_updates.py new file mode 100644 index 00000000..fd253079 --- /dev/null +++ b/hosts/tests/test_find_updates.py @@ -0,0 +1,364 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings +from django.utils import timezone + +from arch.models import MachineArchitecture, PackageArchitecture +from domains.models import Domain +from hosts.models import Host, HostRepo +from operatingsystems.models import OSRelease, OSVariant +from packages.models import Package, PackageName, PackageUpdate +from repos.models import Mirror, MirrorPackage, Repository + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class HostFindUpdatesTests(TestCase): + """Tests for Host.find_updates() method.""" + + def setUp(self): + """Set up test data.""" + self.machine_arch = MachineArchitecture.objects.create(name='x86_64') + self.pkg_arch = PackageArchitecture.objects.create(name='x86_64') + self.osrelease = OSRelease.objects.create(name='Rocky Linux 9') + self.osvariant = OSVariant.objects.create( + name='Rocky Linux 9 x86_64', + osrelease=self.osrelease, + arch=self.machine_arch, + ) + self.domain = Domain.objects.create(name='example.com') + self.host = Host.objects.create( + hostname='updatetest.example.com', + ipaddress='192.168.1.100', + arch=self.machine_arch, + osvariant=self.osvariant, + domain=self.domain, + kernel='5.14.0-362.el9.x86_64', + lastreport=timezone.now(), + ) + + # Create a repository + self.repo = Repository.objects.create( + name='baseos', + arch=self.machine_arch, + repotype=Repository.RPM, + enabled=True, + ) + self.mirror = Mirror.objects.create( + repo=self.repo, + url='http://mirror.rockylinux.org/rocky/9/BaseOS/x86_64/os', + enabled=True, + refresh=True, + ) + + # Associate repo with host + HostRepo.objects.create( + host=self.host, + repo=self.repo, + enabled=True, + ) + + def test_find_updates_no_packages(self): + """Test find_updates with no packages installed.""" + # Host has no packages, should not crash + self.host.find_updates() + self.assertEqual(self.host.updates.count(), 0) + + def test_find_updates_no_updates_available(self): + """Test find_updates when no updates are available.""" + # Install a package on host + pkg_name = PackageName.objects.create(name='httpd') + installed_pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='2.4.57', + release='5.el9', + packagetype=Package.RPM, + ) + self.host.packages.add(installed_pkg) + + # No newer package in mirror + MirrorPackage.objects.create( + mirror=self.mirror, + package=installed_pkg, + ) + + self.host.find_updates() + # No updates should be found (same version in repo) + self.assertEqual(self.host.updates.count(), 0) + + def test_find_updates_with_available_update(self): + """Test find_updates when an update is available.""" + # Install an old package on host + pkg_name = PackageName.objects.create(name='openssl') + old_pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='1', + version='3.0.0', + release='1.el9', + packagetype=Package.RPM, + ) + self.host.packages.add(old_pkg) + + # Add old package to mirror + MirrorPackage.objects.create( + mirror=self.mirror, + package=old_pkg, + ) + + # Add newer package to mirror + new_pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='1', + version='3.0.7', + release='1.el9', + packagetype=Package.RPM, + ) + MirrorPackage.objects.create( + mirror=self.mirror, + package=new_pkg, + ) + + self.host.find_updates() + # Should find the update + self.assertEqual(self.host.updates.count(), 1) + update = self.host.updates.first() + self.assertEqual(update.oldpackage, old_pkg) + self.assertEqual(update.newpackage, new_pkg) + + def test_find_updates_security_repo(self): + """Test find_updates marks security repo updates correctly.""" + # Create a security repository + security_repo = Repository.objects.create( + name='baseos-security', + arch=self.machine_arch, + repotype=Repository.RPM, + enabled=True, + security=True, # Mark as security repo + ) + security_mirror = Mirror.objects.create( + repo=security_repo, + url='http://mirror.rockylinux.org/rocky/9/BaseOS/x86_64/security', + enabled=True, + refresh=True, + ) + HostRepo.objects.create( + host=self.host, + repo=security_repo, + enabled=True, + ) + + # Install an old package + pkg_name = PackageName.objects.create(name='curl') + old_pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='7.76.0', + release='1.el9', + packagetype=Package.RPM, + ) + self.host.packages.add(old_pkg) + MirrorPackage.objects.create(mirror=self.mirror, package=old_pkg) + + # Add security update + new_pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='7.76.1', + release='1.el9_security', + packagetype=Package.RPM, + ) + MirrorPackage.objects.create(mirror=security_mirror, package=new_pkg) + + self.host.find_updates() + # Update should be marked as security + if self.host.updates.count() > 0: + update = self.host.updates.first() + self.assertTrue(update.security) + + def test_find_updates_excludes_kernel_packages(self): + """Test find_updates handles kernel packages separately.""" + # Kernel packages have special handling + pkg_name = PackageName.objects.create(name='kernel') + old_kernel = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='5.14.0', + release='362.el9', + packagetype=Package.RPM, + ) + self.host.packages.add(old_kernel) + MirrorPackage.objects.create(mirror=self.mirror, package=old_kernel) + + new_kernel = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='5.14.0', + release='427.el9', + packagetype=Package.RPM, + ) + MirrorPackage.objects.create(mirror=self.mirror, package=new_kernel) + + # Should not crash when processing kernels + self.host.find_updates() + + def test_find_updates_removes_stale_updates(self): + """Test find_updates removes updates that are no longer valid.""" + # Create an update that's no longer valid + pkg_name = PackageName.objects.create(name='stale-pkg') + old_pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='1.0.0', + release='1.el9', + packagetype=Package.RPM, + ) + new_pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='1.0.1', + release='1.el9', + packagetype=Package.RPM, + ) + + # Create a stale update manually + stale_update = PackageUpdate.objects.create( + oldpackage=old_pkg, + newpackage=new_pkg, + security=False, + ) + self.host.updates.add(stale_update) + self.assertEqual(self.host.updates.count(), 1) + + # Host doesn't have the old package installed + # find_updates should remove this stale update + self.host.find_updates() + self.assertEqual(self.host.updates.count(), 0) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class HostProcessUpdateTests(TestCase): + """Tests for Host.process_update() method.""" + + def setUp(self): + """Set up test data.""" + self.machine_arch = MachineArchitecture.objects.create(name='x86_64') + self.pkg_arch = PackageArchitecture.objects.create(name='x86_64') + self.osrelease = OSRelease.objects.create(name='Rocky Linux 9') + self.osvariant = OSVariant.objects.create( + name='Rocky Linux 9 x86_64', + osrelease=self.osrelease, + arch=self.machine_arch, + ) + self.domain = Domain.objects.create(name='example.com') + self.host = Host.objects.create( + hostname='processupdate.example.com', + ipaddress='192.168.1.101', + arch=self.machine_arch, + osvariant=self.osvariant, + domain=self.domain, + lastreport=timezone.now(), + ) + + def test_process_update_creates_update(self): + """Test process_update creates PackageUpdate.""" + pkg_name = PackageName.objects.create(name='testpkg') + old_pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='1.0.0', + release='1.el9', + packagetype=Package.RPM, + ) + new_pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='1.0.1', + release='1.el9', + packagetype=Package.RPM, + ) + + # Create a repo and mirror for the new package + repo = Repository.objects.create( + name='test-repo', + arch=self.machine_arch, + repotype=Repository.RPM, + ) + mirror = Mirror.objects.create( + repo=repo, + url='http://example.com/repo', + ) + MirrorPackage.objects.create(mirror=mirror, package=new_pkg) + HostRepo.objects.create(host=self.host, repo=repo) + + update_id = self.host.process_update(old_pkg, new_pkg) + self.assertIsNotNone(update_id) + self.assertEqual(self.host.updates.count(), 1) + + def test_process_update_marks_security_from_repo(self): + """Test process_update marks security based on repo.""" + pkg_name = PackageName.objects.create(name='secpkg') + old_pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='1.0.0', + release='1.el9', + packagetype=Package.RPM, + ) + new_pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='1.0.1', + release='1.el9', + packagetype=Package.RPM, + ) + + # Create a security repo + sec_repo = Repository.objects.create( + name='security-repo', + arch=self.machine_arch, + repotype=Repository.RPM, + security=True, + ) + mirror = Mirror.objects.create( + repo=sec_repo, + url='http://example.com/security', + ) + MirrorPackage.objects.create(mirror=mirror, package=new_pkg) + HostRepo.objects.create(host=self.host, repo=sec_repo) + + update_id = self.host.process_update(old_pkg, new_pkg) + update = PackageUpdate.objects.get(id=update_id) + self.assertTrue(update.security) diff --git a/hosts/tests/test_managers.py b/hosts/tests/test_managers.py new file mode 100644 index 00000000..8b9e8a2d --- /dev/null +++ b/hosts/tests/test_managers.py @@ -0,0 +1,205 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings +from django.utils import timezone + +from arch.models import MachineArchitecture, PackageArchitecture +from domains.models import Domain +from hosts.models import Host +from operatingsystems.models import OSRelease, OSVariant +from packages.models import Package, PackageName + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class HostManagerTests(TestCase): + """Tests for HostManager custom queryset.""" + + def setUp(self): + """Set up test data.""" + self.arch = MachineArchitecture.objects.create(name='x86_64') + self.osrelease = OSRelease.objects.create(name='Ubuntu 22.04') + self.osvariant = OSVariant.objects.create( + name='Ubuntu 22.04 x86_64', + osrelease=self.osrelease, + arch=self.arch, + ) + self.domain = Domain.objects.create(name='example.com') + self.now = timezone.now() + + def test_host_manager_select_related(self): + """Test HostManager uses select_related for efficiency.""" + Host.objects.create( + hostname='test1.example.com', + ipaddress='192.168.1.1', + arch=self.arch, + osvariant=self.osvariant, + domain=self.domain, + lastreport=self.now, + ) + Host.objects.create( + hostname='test2.example.com', + ipaddress='192.168.1.2', + arch=self.arch, + osvariant=self.osvariant, + domain=self.domain, + lastreport=self.now, + ) + # Manager should return queryset with select_related + hosts = Host.objects.all() + self.assertEqual(hosts.count(), 2) + + def test_host_manager_returns_all_hosts(self): + """Test HostManager returns all hosts.""" + Host.objects.create( + hostname='host1.example.com', + ipaddress='192.168.1.1', + arch=self.arch, + osvariant=self.osvariant, + domain=self.domain, + lastreport=self.now, + ) + Host.objects.create( + hostname='host2.example.com', + ipaddress='192.168.1.2', + arch=self.arch, + osvariant=self.osvariant, + domain=self.domain, + lastreport=self.now, + ) + Host.objects.create( + hostname='host3.example.com', + ipaddress='192.168.1.3', + arch=self.arch, + osvariant=self.osvariant, + domain=self.domain, + lastreport=self.now, + ) + self.assertEqual(Host.objects.count(), 3) + + def test_host_manager_filter_works(self): + """Test HostManager filtering works correctly.""" + Host.objects.create( + hostname='web1.example.com', + ipaddress='192.168.1.1', + arch=self.arch, + osvariant=self.osvariant, + domain=self.domain, + lastreport=self.now, + ) + Host.objects.create( + hostname='db1.example.com', + ipaddress='192.168.1.2', + arch=self.arch, + osvariant=self.osvariant, + domain=self.domain, + lastreport=self.now, + ) + web_hosts = Host.objects.filter(hostname__startswith='web') + self.assertEqual(web_hosts.count(), 1) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class PackageManagerTests(TestCase): + """Tests for PackageManager custom queryset.""" + + def setUp(self): + """Set up test data.""" + self.arch = PackageArchitecture.objects.create(name='amd64') + + def test_package_manager_select_related(self): + """Test PackageManager uses select_related for efficiency.""" + pkg_name = PackageName.objects.create(name='nginx') + Package.objects.create( + name=pkg_name, + arch=self.arch, + epoch='', + version='1.18.0', + release='1', + packagetype=Package.DEB, + ) + # Manager should return queryset with select_related + packages = Package.objects.all() + self.assertEqual(packages.count(), 1) + + def test_package_manager_returns_all_packages(self): + """Test PackageManager returns all packages.""" + for name in ['nginx', 'curl', 'vim']: + pkg_name = PackageName.objects.create(name=name) + Package.objects.create( + name=pkg_name, + arch=self.arch, + epoch='', + version='1.0.0', + release='1', + packagetype=Package.DEB, + ) + self.assertEqual(Package.objects.count(), 3) + + def test_package_manager_filter_by_name(self): + """Test PackageManager filtering by name.""" + nginx_name = PackageName.objects.create(name='nginx') + curl_name = PackageName.objects.create(name='curl') + Package.objects.create( + name=nginx_name, + arch=self.arch, + epoch='', + version='1.18.0', + release='1', + packagetype=Package.DEB, + ) + Package.objects.create( + name=curl_name, + arch=self.arch, + epoch='', + version='7.81.0', + release='1', + packagetype=Package.DEB, + ) + nginx_packages = Package.objects.filter(name=nginx_name) + self.assertEqual(nginx_packages.count(), 1) + + def test_package_manager_filter_by_type(self): + """Test PackageManager filtering by package type.""" + deb_name = PackageName.objects.create(name='debpkg') + rpm_name = PackageName.objects.create(name='rpmpkg') + rpm_arch = PackageArchitecture.objects.create(name='x86_64') + Package.objects.create( + name=deb_name, + arch=self.arch, + epoch='', + version='1.0.0', + release='1', + packagetype=Package.DEB, + ) + Package.objects.create( + name=rpm_name, + arch=rpm_arch, + epoch='', + version='1.0.0', + release='1.el9', + packagetype=Package.RPM, + ) + deb_packages = Package.objects.filter(packagetype=Package.DEB) + rpm_packages = Package.objects.filter(packagetype=Package.RPM) + self.assertEqual(deb_packages.count(), 1) + self.assertEqual(rpm_packages.count(), 1) diff --git a/hosts/tests/test_models.py b/hosts/tests/test_models.py new file mode 100644 index 00000000..09d0e4b3 --- /dev/null +++ b/hosts/tests/test_models.py @@ -0,0 +1,130 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings +from django.utils import timezone + +from arch.models import MachineArchitecture, PackageArchitecture +from domains.models import Domain +from hosts.models import Host, HostRepo +from operatingsystems.models import OSRelease, OSVariant +from packages.models import Package, PackageName, PackageUpdate +from repos.models import Repository + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class HostMethodTests(TestCase): + """Tests for Host model methods.""" + + def setUp(self): + """Set up test data.""" + self.arch = MachineArchitecture.objects.create(name='x86_64') + self.domain = Domain.objects.create(name='example.com') + self.os_release = OSRelease.objects.create( + name='Ubuntu 22.04', codename='jammy' + ) + self.os_variant = OSVariant.objects.create( + name='Ubuntu 22.04.3 LTS', osrelease=self.os_release + ) + self.host = Host.objects.create( + hostname='testhost.example.com', + ipaddress='192.168.1.100', + osvariant=self.os_variant, + kernel='5.15.0-91-generic', + arch=self.arch, + domain=self.domain, + lastreport=timezone.now(), + ) + + def test_get_num_packages_empty(self): + """Test get_num_packages() with no packages.""" + self.assertEqual(self.host.get_num_packages(), 0) + + def test_get_num_packages_with_packages(self): + """Test get_num_packages() with packages.""" + pkg_arch = PackageArchitecture.objects.create(name='amd64') + pkg_name = PackageName.objects.create(name='nginx') + pkg = Package.objects.create( + name=pkg_name, arch=pkg_arch, epoch='', + version='1.18.0', release='1', packagetype='D' + ) + self.host.packages.add(pkg) + self.assertEqual(self.host.get_num_packages(), 1) + + def test_get_num_repos_empty(self): + """Test get_num_repos() with no repos.""" + self.assertEqual(self.host.get_num_repos(), 0) + + def test_get_num_repos_with_repos(self): + """Test get_num_repos() with repos.""" + repo = Repository.objects.create( + name='ubuntu-main', arch=self.arch, repotype='D' + ) + HostRepo.objects.create(host=self.host, repo=repo, enabled=True) + self.assertEqual(self.host.get_num_repos(), 1) + + def test_get_num_updates_empty(self): + """Test get_num_updates() with no updates.""" + self.assertEqual(self.host.get_num_updates(), 0) + + def test_get_num_updates_with_updates(self): + """Test get_num_updates() with updates.""" + pkg_arch = PackageArchitecture.objects.create(name='amd64') + pkg_name = PackageName.objects.create(name='openssl') + old_pkg = Package.objects.create( + name=pkg_name, arch=pkg_arch, epoch='', + version='3.0.0', release='1', packagetype='D' + ) + new_pkg = Package.objects.create( + name=pkg_name, arch=pkg_arch, epoch='', + version='3.0.1', release='1', packagetype='D' + ) + update = PackageUpdate.objects.create( + oldpackage=old_pkg, newpackage=new_pkg, security=True + ) + self.host.updates.add(update) + self.assertEqual(self.host.get_num_updates(), 1) + + def test_get_num_security_updates(self): + """Test get_num_security_updates() counts only security updates.""" + pkg_arch = PackageArchitecture.objects.create(name='amd64') + pkg_name = PackageName.objects.create(name='openssl') + old_pkg = Package.objects.create( + name=pkg_name, arch=pkg_arch, epoch='', + version='3.0.0', release='1', packagetype='D' + ) + sec_pkg = Package.objects.create( + name=pkg_name, arch=pkg_arch, epoch='', + version='3.0.1', release='1', packagetype='D' + ) + bug_pkg = Package.objects.create( + name=pkg_name, arch=pkg_arch, epoch='', + version='3.0.2', release='1', packagetype='D' + ) + sec_update = PackageUpdate.objects.create( + oldpackage=old_pkg, newpackage=sec_pkg, security=True + ) + bug_update = PackageUpdate.objects.create( + oldpackage=old_pkg, newpackage=bug_pkg, security=False + ) + self.host.updates.add(sec_update, bug_update) + + self.assertEqual(self.host.get_num_security_updates(), 1) + self.assertEqual(self.host.get_num_bugfix_updates(), 1) + self.assertEqual(self.host.get_num_updates(), 2) diff --git a/operatingsystems/tests/test_models.py b/operatingsystems/tests/test_models.py new file mode 100644 index 00000000..bec9e1fa --- /dev/null +++ b/operatingsystems/tests/test_models.py @@ -0,0 +1,102 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings + +from operatingsystems.models import OSRelease, OSVariant + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class OSReleaseMethodTests(TestCase): + """Tests for OSRelease model methods.""" + + def test_osrelease_creation(self): + """Test creating an OSRelease.""" + release = OSRelease.objects.create(name='Ubuntu 22.04') + self.assertEqual(release.name, 'Ubuntu 22.04') + + def test_osrelease_str(self): + """Test OSRelease __str__ method.""" + release = OSRelease.objects.create(name='Ubuntu 22.04') + self.assertEqual(str(release), 'Ubuntu 22.04') + + def test_osrelease_get_absolute_url(self): + """Test OSRelease.get_absolute_url().""" + release = OSRelease.objects.create(name='Rocky Linux 9') + url = release.get_absolute_url() + self.assertIn(str(release.id), url) + + def test_osrelease_unique_name(self): + """Test OSRelease name is unique.""" + OSRelease.objects.create(name='Ubuntu 22.04') + from django.db import IntegrityError + with self.assertRaises(IntegrityError): + OSRelease.objects.create(name='Ubuntu 22.04') + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class OSVariantMethodTests(TestCase): + """Tests for OSVariant model methods.""" + + def setUp(self): + """Set up test data.""" + self.release = OSRelease.objects.create(name='Ubuntu 22.04') + + def test_osvariant_creation(self): + """Test creating an OSVariant.""" + variant = OSVariant.objects.create( + name='Ubuntu 22.04.3 LTS', + osrelease=self.release, + ) + self.assertEqual(variant.name, 'Ubuntu 22.04.3 LTS') + self.assertEqual(variant.osrelease, self.release) + + def test_osvariant_str(self): + """Test OSVariant __str__ method.""" + variant = OSVariant.objects.create( + name='Ubuntu 22.04.3 LTS', + osrelease=self.release, + ) + # __str__ returns 'name arch' format + self.assertIn('Ubuntu 22.04.3 LTS', str(variant)) + + def test_osvariant_get_absolute_url(self): + """Test OSVariant.get_absolute_url().""" + variant = OSVariant.objects.create( + name='Ubuntu 22.04.3 LTS', + osrelease=self.release, + ) + url = variant.get_absolute_url() + self.assertIn(str(variant.id), url) + + def test_osvariant_unique_name(self): + """Test OSVariant name is unique.""" + OSVariant.objects.create( + name='Ubuntu 22.04.3 LTS', + osrelease=self.release, + ) + from django.db import IntegrityError + with self.assertRaises(IntegrityError): + OSVariant.objects.create( + name='Ubuntu 22.04.3 LTS', + osrelease=self.release, + ) diff --git a/packages/tests/test_models.py b/packages/tests/test_models.py new file mode 100644 index 00000000..0eec91f0 --- /dev/null +++ b/packages/tests/test_models.py @@ -0,0 +1,202 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings + +from arch.models import PackageArchitecture +from packages.models import Package, PackageName, PackageUpdate + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class PackageMethodTests(TestCase): + """Tests for Package model methods.""" + + def setUp(self): + """Set up test data.""" + self.arch = PackageArchitecture.objects.create(name='amd64') + self.pkg_name = PackageName.objects.create(name='nginx') + + def test_get_version_string_deb(self): + """Test get_version_string() for deb packages.""" + pkg = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='', version='1.18.0', release='6ubuntu14', packagetype='D' + ) + self.assertEqual(pkg.get_version_string(), '1.18.0-6ubuntu14') + + def test_get_version_string_deb_with_epoch(self): + """Test get_version_string() for deb packages with epoch.""" + pkg = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='1', version='1.18.0', release='6ubuntu14', packagetype='D' + ) + self.assertEqual(pkg.get_version_string(), '1:1.18.0-6ubuntu14') + + def test_get_version_string_deb_no_release(self): + """Test get_version_string() for deb packages without release.""" + pkg = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='', version='1.18.0', release='', packagetype='D' + ) + self.assertEqual(pkg.get_version_string(), '1.18.0') + + def test_get_version_string_rpm(self): + """Test get_version_string() for rpm packages.""" + pkg = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='0', version='1.18.0', release='1.el9', packagetype='R' + ) + version_tuple = pkg.get_version_string() + self.assertEqual(version_tuple, ('0', '1.18.0', '1.el9')) + + def test_compare_version_deb_equal(self): + """Test compare_version() for equal deb packages.""" + pkg1 = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='', version='1.18.0', release='1', packagetype='D' + ) + pkg_name2 = PackageName.objects.create(name='nginx2') + pkg2 = Package.objects.create( + name=pkg_name2, arch=self.arch, + epoch='', version='1.18.0', release='1', packagetype='D' + ) + self.assertEqual(pkg1.compare_version(pkg2), 0) + + def test_compare_version_deb_greater(self): + """Test compare_version() for newer deb package.""" + pkg1 = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='', version='1.20.0', release='1', packagetype='D' + ) + pkg_name2 = PackageName.objects.create(name='nginx-old') + pkg2 = Package.objects.create( + name=pkg_name2, arch=self.arch, + epoch='', version='1.18.0', release='1', packagetype='D' + ) + self.assertEqual(pkg1.compare_version(pkg2), 1) + + def test_compare_version_deb_lesser(self): + """Test compare_version() for older deb package.""" + pkg1 = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='', version='1.16.0', release='1', packagetype='D' + ) + pkg_name2 = PackageName.objects.create(name='nginx-new') + pkg2 = Package.objects.create( + name=pkg_name2, arch=self.arch, + epoch='', version='1.18.0', release='1', packagetype='D' + ) + self.assertEqual(pkg1.compare_version(pkg2), -1) + + def test_str_deb_package(self): + """Test __str__ for deb package.""" + pkg = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='', version='1.18.0', release='6ubuntu14', packagetype='D' + ) + self.assertEqual(str(pkg), 'nginx_1.18.0-6ubuntu14_amd64.deb') + + def test_str_deb_package_with_epoch(self): + """Test __str__ for deb package with epoch.""" + pkg = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='1', version='1.18.0', release='6ubuntu14', packagetype='D' + ) + self.assertEqual(str(pkg), 'nginx_1:1.18.0-6ubuntu14_amd64.deb') + + def test_str_rpm_package(self): + """Test __str__ for rpm package.""" + pkg = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='', version='1.18.0', release='1.el9', packagetype='R' + ) + self.assertEqual(str(pkg), 'nginx-1.18.0-1.el9-amd64.rpm') + + def test_package_equality(self): + """Test Package __eq__ method.""" + pkg1 = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='', version='1.18.0', release='1', packagetype='D' + ) + # Same attributes = equal + self.assertEqual(pkg1, pkg1) + + def test_package_inequality(self): + """Test Package __ne__ method.""" + pkg1 = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='', version='1.18.0', release='1', packagetype='D' + ) + pkg_name2 = PackageName.objects.create(name='curl') + pkg2 = Package.objects.create( + name=pkg_name2, arch=self.arch, + epoch='', version='7.81.0', release='1', packagetype='D' + ) + self.assertNotEqual(pkg1, pkg2) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class PackageUpdateMethodTests(TestCase): + """Tests for PackageUpdate model.""" + + def setUp(self): + """Set up test data.""" + self.arch = PackageArchitecture.objects.create(name='amd64') + self.pkg_name = PackageName.objects.create(name='openssl') + self.old_pkg = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='', version='3.0.0', release='1', packagetype='D' + ) + self.new_pkg = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='', version='3.0.1', release='1', packagetype='D' + ) + + def test_package_update_str(self): + """Test PackageUpdate __str__ method.""" + update = PackageUpdate.objects.create( + oldpackage=self.old_pkg, + newpackage=self.new_pkg, + security=True, + ) + str_repr = str(update) + self.assertIn('openssl', str_repr) + + def test_package_update_security_flag(self): + """Test PackageUpdate security flag.""" + sec_update = PackageUpdate.objects.create( + oldpackage=self.old_pkg, + newpackage=self.new_pkg, + security=True, + ) + self.assertTrue(sec_update.security) + + bug_pkg = Package.objects.create( + name=self.pkg_name, arch=self.arch, + epoch='', version='3.0.2', release='1', packagetype='D' + ) + bug_update = PackageUpdate.objects.create( + oldpackage=self.old_pkg, + newpackage=bug_pkg, + security=False, + ) + self.assertFalse(bug_update.security) diff --git a/packages/tests/test_version_compare.py b/packages/tests/test_version_compare.py new file mode 100644 index 00000000..f12f5690 --- /dev/null +++ b/packages/tests/test_version_compare.py @@ -0,0 +1,316 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings + +from arch.models import PackageArchitecture +from packages.models import Package, PackageName + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class RPMVersionCompareTests(TestCase): + """Tests for RPM package version comparison.""" + + def setUp(self): + """Set up test data.""" + self.arch = PackageArchitecture.objects.create(name='x86_64') + self.pkg_name = PackageName.objects.create(name='httpd') + + def _create_rpm(self, epoch, version, release): + """Helper to create RPM package.""" + return Package.objects.create( + name=self.pkg_name, + arch=self.arch, + epoch=epoch, + version=version, + release=release, + packagetype=Package.RPM, + ) + + def test_compare_same_version(self): + """Test comparing identical versions returns 0.""" + pkg1 = self._create_rpm('0', '2.4.57', '5.el9') + pkg2 = self._create_rpm('0', '2.4.57', '5.el9') + self.assertEqual(pkg1.compare_version(pkg2), 0) + + def test_compare_newer_version(self): + """Test comparing newer version returns 1.""" + pkg1 = self._create_rpm('0', '2.4.58', '1.el9') + pkg2 = self._create_rpm('0', '2.4.57', '5.el9') + self.assertEqual(pkg1.compare_version(pkg2), 1) + + def test_compare_older_version(self): + """Test comparing older version returns -1.""" + pkg1 = self._create_rpm('0', '2.4.56', '1.el9') + pkg2 = self._create_rpm('0', '2.4.57', '5.el9') + self.assertEqual(pkg1.compare_version(pkg2), -1) + + def test_compare_epoch_takes_precedence(self): + """Test that epoch takes precedence over version.""" + pkg1 = self._create_rpm('1', '1.0.0', '1.el9') + pkg2 = self._create_rpm('0', '9.9.9', '99.el9') + self.assertEqual(pkg1.compare_version(pkg2), 1) + + def test_compare_release_difference(self): + """Test release number comparison when version is same.""" + pkg1 = self._create_rpm('0', '2.4.57', '6.el9') + pkg2 = self._create_rpm('0', '2.4.57', '5.el9') + self.assertEqual(pkg1.compare_version(pkg2), 1) + + def test_compare_empty_epoch_vs_zero(self): + """Test empty epoch vs explicit 0 epoch comparison.""" + pkg1 = self._create_rpm('', '2.4.57', '5.el9') + pkg2 = self._create_rpm('0', '2.4.57', '5.el9') + # RPM treats empty string and '0' differently in labelCompare + result = pkg1.compare_version(pkg2) + self.assertIn(result, [-1, 0, 1]) # Implementation dependent + + def test_compare_complex_version(self): + """Test complex version strings.""" + pkg1 = self._create_rpm('0', '1.2.3.4.5', '1.el9') + pkg2 = self._create_rpm('0', '1.2.3.4.4', '1.el9') + self.assertEqual(pkg1.compare_version(pkg2), 1) + + def test_compare_alpha_version(self): + """Test version with alpha characters.""" + pkg1 = self._create_rpm('0', '1.0.0', '1.el9') + pkg2 = self._create_rpm('0', '1.0.0', '1.el9_1') + # el9 vs el9_1 comparison + result = pkg1.compare_version(pkg2) + self.assertIn(result, [-1, 0, 1]) + + def test_version_string_rpm(self): + """Test _version_string_rpm returns correct tuple.""" + pkg = self._create_rpm('1', '2.4.57', '5.el9') + result = pkg._version_string_rpm() + self.assertEqual(result, ('1', '2.4.57', '5.el9')) + + def test_version_string_rpm_empty_epoch(self): + """Test _version_string_rpm with empty epoch.""" + pkg = self._create_rpm('', '2.4.57', '5.el9') + result = pkg._version_string_rpm() + self.assertEqual(result, ('', '2.4.57', '5.el9')) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class DEBVersionCompareTests(TestCase): + """Tests for DEB package version comparison.""" + + def setUp(self): + """Set up test data.""" + self.arch = PackageArchitecture.objects.create(name='amd64') + self.pkg_name = PackageName.objects.create(name='nginx') + + def _create_deb(self, epoch, version, release): + """Helper to create DEB package.""" + return Package.objects.create( + name=self.pkg_name, + arch=self.arch, + epoch=epoch, + version=version, + release=release, + packagetype=Package.DEB, + ) + + def test_compare_same_version(self): + """Test comparing identical versions returns 0.""" + pkg1 = self._create_deb('', '1.18.0', '6ubuntu14') + pkg2 = self._create_deb('', '1.18.0', '6ubuntu14') + self.assertEqual(pkg1.compare_version(pkg2), 0) + + def test_compare_newer_version(self): + """Test comparing newer version returns 1.""" + pkg1 = self._create_deb('', '1.18.1', '1ubuntu1') + pkg2 = self._create_deb('', '1.18.0', '6ubuntu14') + self.assertEqual(pkg1.compare_version(pkg2), 1) + + def test_compare_older_version(self): + """Test comparing older version returns -1.""" + pkg1 = self._create_deb('', '1.17.0', '1ubuntu1') + pkg2 = self._create_deb('', '1.18.0', '6ubuntu14') + self.assertEqual(pkg1.compare_version(pkg2), -1) + + def test_compare_ubuntu_revision(self): + """Test Ubuntu revision comparison.""" + pkg1 = self._create_deb('', '1.18.0', '6ubuntu15') + pkg2 = self._create_deb('', '1.18.0', '6ubuntu14') + self.assertEqual(pkg1.compare_version(pkg2), 1) + + def test_compare_tilde_version(self): + """Test tilde in version (sorts before everything).""" + pkg1 = self._create_deb('', '1.0.0~beta1', '1') + pkg2 = self._create_deb('', '1.0.0', '1') + self.assertEqual(pkg1.compare_version(pkg2), -1) + + def test_compare_epoch_deb(self): + """Test epoch comparison for DEB packages.""" + pkg1 = self._create_deb('2', '1.0.0', '1') + pkg2 = self._create_deb('1', '9.0.0', '1') + self.assertEqual(pkg1.compare_version(pkg2), 1) + + def test_version_string_deb(self): + """Test _version_string_deb_arch returns correct format.""" + pkg = self._create_deb('1', '1.18.0', '6ubuntu14') + result = pkg._version_string_deb_arch() + self.assertEqual(result, '1:1.18.0-6ubuntu14') + + def test_version_string_deb_no_epoch(self): + """Test _version_string_deb_arch without epoch.""" + pkg = self._create_deb('', '1.18.0', '6ubuntu14') + result = pkg._version_string_deb_arch() + self.assertEqual(result, '1.18.0-6ubuntu14') + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ArchVersionCompareTests(TestCase): + """Tests for Arch package version comparison.""" + + def setUp(self): + """Set up test data.""" + self.arch = PackageArchitecture.objects.create(name='x86_64') + self.pkg_name = PackageName.objects.create(name='pacman') + + def _create_arch(self, epoch, version, release): + """Helper to create Arch package.""" + return Package.objects.create( + name=self.pkg_name, + arch=self.arch, + epoch=epoch, + version=version, + release=release, + packagetype=Package.ARCH, + ) + + def test_compare_same_version(self): + """Test comparing identical versions returns 0.""" + pkg1 = self._create_arch('', '6.0.2', '1') + pkg2 = self._create_arch('', '6.0.2', '1') + self.assertEqual(pkg1.compare_version(pkg2), 0) + + def test_compare_newer_version(self): + """Test comparing newer version returns 1.""" + pkg1 = self._create_arch('', '6.0.3', '1') + pkg2 = self._create_arch('', '6.0.2', '1') + self.assertEqual(pkg1.compare_version(pkg2), 1) + + def test_compare_release_bump(self): + """Test release number bump.""" + pkg1 = self._create_arch('', '6.0.2', '2') + pkg2 = self._create_arch('', '6.0.2', '1') + self.assertEqual(pkg1.compare_version(pkg2), 1) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class GentooVersionCompareTests(TestCase): + """Tests for Gentoo package version comparison.""" + + def setUp(self): + """Set up test data.""" + self.arch = PackageArchitecture.objects.create(name='amd64') + self.pkg_name = PackageName.objects.create(name='dev-libs/openssl') + + def _create_gentoo(self, epoch, version, release): + """Helper to create Gentoo package.""" + return Package.objects.create( + name=self.pkg_name, + arch=self.arch, + epoch=epoch, + version=version, + release=release, + packagetype=Package.GENTOO, + ) + + def test_compare_same_version(self): + """Test comparing identical versions returns 0.""" + pkg1 = self._create_gentoo('', '3.0.10', 'r1') + pkg2 = self._create_gentoo('', '3.0.10', 'r1') + self.assertEqual(pkg1.compare_version(pkg2), 0) + + def test_compare_newer_version(self): + """Test comparing newer version returns 1.""" + pkg1 = self._create_gentoo('', '3.0.11', 'r0') + pkg2 = self._create_gentoo('', '3.0.10', 'r1') + self.assertEqual(pkg1.compare_version(pkg2), 1) + + def test_compare_revision_bump(self): + """Test revision bump.""" + pkg1 = self._create_gentoo('', '3.0.10', 'r2') + pkg2 = self._create_gentoo('', '3.0.10', 'r1') + self.assertEqual(pkg1.compare_version(pkg2), 1) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class GetVersionStringTests(TestCase): + """Tests for get_version_string method.""" + + def setUp(self): + """Set up test data.""" + self.arch = PackageArchitecture.objects.create(name='x86_64') + self.pkg_name = PackageName.objects.create(name='testpkg') + + def test_get_version_string_rpm(self): + """Test get_version_string for RPM.""" + pkg = Package.objects.create( + name=self.pkg_name, + arch=self.arch, + epoch='1', + version='2.0.0', + release='3.el9', + packagetype=Package.RPM, + ) + result = pkg.get_version_string() + self.assertEqual(result, ('1', '2.0.0', '3.el9')) + + def test_get_version_string_deb(self): + """Test get_version_string for DEB.""" + pkg = Package.objects.create( + name=self.pkg_name, + arch=self.arch, + epoch='1', + version='2.0.0', + release='3ubuntu1', + packagetype=Package.DEB, + ) + result = pkg.get_version_string() + self.assertEqual(result, '1:2.0.0-3ubuntu1') + + def test_get_version_string_gentoo(self): + """Test get_version_string for Gentoo.""" + pkg = Package.objects.create( + name=self.pkg_name, + arch=self.arch, + epoch='', + version='2.0.0', + release='r1', + packagetype=Package.GENTOO, + ) + result = pkg.get_version_string() + self.assertEqual(result, ('', '2.0.0', 'r1')) diff --git a/reports/tests/test_edge_cases.py b/reports/tests/test_edge_cases.py new file mode 100644 index 00000000..fb647b82 --- /dev/null +++ b/reports/tests/test_edge_cases.py @@ -0,0 +1,302 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings +from rest_framework import status +from rest_framework.test import APITestCase + +from reports.models import Report +from reports.utils import parse_packages, process_package + + +@override_settings( + REQUIRE_API_KEY=False, + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class EdgeCasePackageTests(TestCase): + """Edge case tests for package processing.""" + + def test_package_with_unicode_name(self): + """Test package with unicode characters in name.""" + # Some packages may have unicode in description, handled gracefully + package = process_package( + name='test-pkg', + epoch='', + version='1.0.0', + release='1', + arch='amd64', + p_type='D', + ) + self.assertIsNotNone(package) + + def test_package_with_very_long_version(self): + """Test package with very long version string.""" + long_version = '1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20' + package = process_package( + name='longver-pkg', + epoch='', + version=long_version, + release='1', + arch='amd64', + p_type='D', + ) + self.assertIsNotNone(package) + self.assertEqual(package.version, long_version) + + def test_package_with_special_chars_in_version(self): + """Test package with special characters in version.""" + package = process_package( + name='special-pkg', + epoch='', + version='1.0.0~beta+git20240101', + release='1ubuntu1~22.04.1', + arch='amd64', + p_type='D', + ) + self.assertIsNotNone(package) + + def test_package_with_empty_epoch(self): + """Test package with empty epoch.""" + package = process_package( + name='noepoch-pkg', + epoch='', + version='1.0.0', + release='1', + arch='amd64', + p_type='D', + ) + self.assertEqual(package.epoch, '') + + def test_package_with_empty_release(self): + """Test package with empty release.""" + package = process_package( + name='norelease-pkg', + epoch='', + version='1.0.0', + release='', + arch='amd64', + p_type='D', + ) + self.assertEqual(package.release, '') + + def test_package_with_numeric_epoch(self): + """Test package with large numeric epoch.""" + package = process_package( + name='bigepoch-pkg', + epoch='999', + version='1.0.0', + release='1', + arch='amd64', + p_type='D', + ) + self.assertEqual(package.epoch, '999') + + +@override_settings( + REQUIRE_API_KEY=False, + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class EdgeCaseReportAPITests(APITestCase): + """Edge case tests for Report API.""" + + def test_report_with_empty_packages_array(self): + """Test report with empty packages array.""" + data = { + 'hostname': 'empty-pkgs.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + 'packages': [], + 'repos': [], + } + response = self.client.post('/api/report/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + def test_report_with_many_packages(self): + """Test report with many packages (stress test).""" + packages = [ + {'name': f'pkg{i}', 'version': '1.0.0', 'arch': 'amd64', 'type': 'deb'} + for i in range(100) + ] + data = { + 'hostname': 'many-pkgs.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + 'packages': packages, + 'repos': [], + } + response = self.client.post('/api/report/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + def test_report_with_unicode_hostname(self): + """Test report with hostname containing valid chars.""" + data = { + 'hostname': 'test-host-123.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + 'packages': [], + } + response = self.client.post('/api/report/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + def test_report_with_unicode_os(self): + """Test report with unicode in OS name.""" + data = { + 'hostname': 'unicode-os.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04 LTS "Jammy Jellyfish"', + 'kernel': '5.15.0-91-generic', + 'packages': [], + } + response = self.client.post('/api/report/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + def test_report_with_very_long_kernel(self): + """Test report with very long kernel string.""" + data = { + 'hostname': 'longkernel.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic-with-extra-long-suffix-for-testing', + 'packages': [], + } + response = self.client.post('/api/report/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + def test_report_with_special_tags(self): + """Test report with special characters in tags.""" + data = { + 'hostname': 'tagged.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + 'tags': ['web-server', 'prod_env', 'tier1'], + 'packages': [], + } + response = self.client.post('/api/report/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + +@override_settings( + REQUIRE_API_KEY=False, + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class EdgeCaseMalformedInputTests(APITestCase): + """Tests for handling malformed input.""" + + def test_report_with_null_packages(self): + """Test report with null packages field.""" + data = { + 'hostname': 'null-pkgs.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + 'packages': None, + } + response = self.client.post('/api/report/', data, format='json') + # Should either accept or return validation error + self.assertIn(response.status_code, [status.HTTP_202_ACCEPTED, status.HTTP_400_BAD_REQUEST]) + + def test_report_with_invalid_json_type(self): + """Test report with invalid type for packages (string instead of array).""" + data = { + 'hostname': 'invalid-type.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + 'packages': 'not-an-array', + } + response = self.client.post('/api/report/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_report_with_missing_required_package_field(self): + """Test report with package missing required field.""" + data = { + 'hostname': 'missing-field.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + 'packages': [ + {'name': 'pkg1'}, # Missing version and arch + ], + } + response = self.client.post('/api/report/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_report_with_extra_unknown_fields(self): + """Test report with extra unknown fields (should be ignored).""" + data = { + 'hostname': 'extra-fields.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + 'unknown_field': 'should be ignored', + 'another_unknown': 123, + 'packages': [], + } + response = self.client.post('/api/report/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + +@override_settings( + REQUIRE_API_KEY=False, + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class BoundaryConditionTests(TestCase): + """Tests for boundary conditions.""" + + def test_parse_packages_with_newlines_only(self): + """Test parsing string with only newlines.""" + packages = parse_packages('\n\n\n') + # Should handle gracefully + self.assertIsInstance(packages, list) + + def test_parse_packages_with_mixed_formats(self): + """Test parsing packages with inconsistent formatting.""" + pkg_str = """'nginx' '' '1.18.0' '6ubuntu14' 'amd64' 'deb' +'curl' '' '7.81.0' '1' 'amd64'""" # Missing type + packages = parse_packages(pkg_str) + self.assertEqual(len(packages), 2) + + def test_report_duplicate_hostname(self): + """Test creating reports for same hostname.""" + Report.objects.create( + host='duplicate.example.com', + domain='example.com', + report_ip='192.168.1.1', + os='Ubuntu 22.04', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + ) + # Second report for same host should work + report2 = Report.objects.create( + host='duplicate.example.com', + domain='example.com', + report_ip='192.168.1.1', + os='Ubuntu 22.04', + kernel='5.15.0-92-generic', # Newer kernel + arch='x86_64', + protocol='2', + ) + self.assertIsNotNone(report2) + self.assertEqual(Report.objects.filter(host='duplicate.example.com').count(), 2) diff --git a/reports/tests/test_integration.py b/reports/tests/test_integration.py new file mode 100644 index 00000000..958b0109 --- /dev/null +++ b/reports/tests/test_integration.py @@ -0,0 +1,274 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APITestCase + +from arch.models import MachineArchitecture, PackageArchitecture +from domains.models import Domain +from hosts.models import Host, HostRepo +from operatingsystems.models import OSRelease, OSVariant +from packages.models import Package, PackageName +from reports.models import Report +from repos.models import Repository + + +@override_settings( + REQUIRE_API_KEY=False, + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class EndToEndProtocol2Tests(APITestCase): + """End-to-end integration tests for Protocol 2.""" + + def test_full_report_creates_host_packages_repos(self): + """Test full report creates all expected database records.""" + data = { + 'hostname': 'e2e-test.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04 LTS', + 'kernel': '5.15.0-91-generic', + 'packages': [ + { + 'name': 'nginx', + 'epoch': '', + 'version': '1.18.0', + 'release': '6ubuntu14', + 'arch': 'amd64', + 'type': 'deb', + }, + { + 'name': 'curl', + 'epoch': '', + 'version': '7.81.0', + 'release': '1ubuntu1.15', + 'arch': 'amd64', + 'type': 'deb', + }, + ], + 'repos': [ + { + 'id': 'ubuntu-main', + 'name': 'Ubuntu 22.04 main', + 'type': 'deb', + 'url': 'http://archive.ubuntu.com/ubuntu/dists/jammy/main/binary-amd64', + }, + ], + } + + response = self.client.post('/api/report/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + # Verify report was created + report_id = response.data.get('report_id') + self.assertIsNotNone(report_id) + report = Report.objects.get(id=report_id) + self.assertEqual(report.host, 'e2e-test.example.com') + self.assertEqual(report.protocol, '2') + + def test_report_upload_and_retrieve(self): + """Test report can be uploaded and then retrieved via API.""" + data = { + 'hostname': 'retrieve-test.example.com', + 'arch': 'x86_64', + 'os': 'Rocky Linux 9', + 'kernel': '5.14.0-362.el9.x86_64', + 'packages': [], + } + + # Upload + response = self.client.post('/api/report/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + report_id = response.data['report_id'] + + # Retrieve + response = self.client.get(f'/api/report/{report_id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['host'], 'retrieve-test.example.com') + + +@override_settings( + REQUIRE_API_KEY=False, + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class Protocol1CompatibilityTests(TestCase): + """Tests for Protocol 1 backward compatibility.""" + + def test_protocol1_report_stored(self): + """Test Protocol 1 report can be created and stored.""" + # Protocol 1 reports store raw text data + packages_text = "'nginx' '' '1.18.0' '6ubuntu14' 'amd64' 'deb'" + repos_text = "deb : Ubuntu 22.04 : ubuntu-main : http://archive.ubuntu.com/ubuntu/" + + report = Report.objects.create( + host='protocol1.example.com', + domain='example.com', + report_ip='192.168.1.10', + os='Ubuntu 22.04', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='1', + packages=packages_text, + repos=repos_text, + ) + + self.assertEqual(report.protocol, '1') + self.assertIn('nginx', report.packages) + self.assertIn('ubuntu-main', report.repos) + + +@override_settings( + REQUIRE_API_KEY=False, + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class HostUpdateIntegrationTests(TestCase): + """Integration tests for host update detection.""" + + def setUp(self): + """Set up test data.""" + self.machine_arch = MachineArchitecture.objects.create(name='x86_64') + self.pkg_arch = PackageArchitecture.objects.create(name='x86_64') + self.osrelease = OSRelease.objects.create(name='Rocky Linux 9') + self.osvariant = OSVariant.objects.create( + name='Rocky Linux 9 x86_64', + osrelease=self.osrelease, + arch=self.machine_arch, + ) + self.domain = Domain.objects.create(name='example.com') + + def test_report_processing_associates_packages_with_host(self): + """Test that processing a report associates packages with the host.""" + host = Host.objects.create( + hostname='pkg-assoc.example.com', + ipaddress='192.168.1.50', + arch=self.machine_arch, + osvariant=self.osvariant, + domain=self.domain, + lastreport=timezone.now(), + ) + + # Create a package + pkg_name = PackageName.objects.create(name='testpkg') + pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='', + version='1.0.0', + release='1', + packagetype=Package.DEB, + ) + + # Add package to host + host.packages.add(pkg) + self.assertIn(pkg, host.packages.all()) + self.assertEqual(host.packages.count(), 1) + + def test_host_repo_association(self): + """Test that repos are correctly associated with hosts.""" + host = Host.objects.create( + hostname='repo-assoc.example.com', + ipaddress='192.168.1.51', + arch=self.machine_arch, + osvariant=self.osvariant, + domain=self.domain, + lastreport=timezone.now(), + ) + + repo = Repository.objects.create( + name='test-repo', + arch=self.machine_arch, + repotype=Repository.DEB, + ) + + HostRepo.objects.create( + host=host, + repo=repo, + enabled=True, + ) + + self.assertEqual(host.repos.count(), 1) + self.assertEqual(host.repos.first(), repo) + + +@override_settings( + REQUIRE_API_KEY=False, + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ConcurrentReportTests(APITestCase): + """Tests for concurrent report handling.""" + + def test_multiple_reports_same_host_sequential(self): + """Test multiple reports for same host create separate Report records.""" + data1 = { + 'hostname': 'concurrent.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-90-generic', + 'packages': [], + } + + data2 = { + 'hostname': 'concurrent.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', # Newer kernel + 'packages': [], + } + + response1 = self.client.post('/api/report/', data1, format='json') + response2 = self.client.post('/api/report/', data2, format='json') + + self.assertEqual(response1.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response2.status_code, status.HTTP_202_ACCEPTED) + + # Both reports should exist + reports = Report.objects.filter(host='concurrent.example.com') + self.assertEqual(reports.count(), 2) + + def test_reports_from_different_hosts(self): + """Test reports from different hosts don't interfere.""" + data1 = { + 'hostname': 'host1.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + 'packages': [], + } + + data2 = { + 'hostname': 'host2.example.com', + 'arch': 'x86_64', + 'os': 'Rocky Linux 9', + 'kernel': '5.14.0-362.el9.x86_64', + 'packages': [], + } + + response1 = self.client.post('/api/report/', data1, format='json') + response2 = self.client.post('/api/report/', data2, format='json') + + self.assertEqual(response1.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response2.status_code, status.HTTP_202_ACCEPTED) + + report1 = Report.objects.get(id=response1.data['report_id']) + report2 = Report.objects.get(id=response2.data['report_id']) + + self.assertEqual(report1.host, 'host1.example.com') + self.assertEqual(report2.host, 'host2.example.com') diff --git a/reports/tests/test_models.py b/reports/tests/test_models.py new file mode 100644 index 00000000..b42e780d --- /dev/null +++ b/reports/tests/test_models.py @@ -0,0 +1,476 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +import json + +from django.test import TestCase, override_settings + +from reports.models import Report + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ReportModelTests(TestCase): + """Tests for the Report model.""" + + def test_report_creation(self): + """Test creating a report.""" + report = Report.objects.create( + host='testhost.example.com', + domain='example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + ) + self.assertEqual(report.host, 'testhost.example.com') + self.assertEqual(report.protocol, '2') + self.assertFalse(report.processed) + + def test_report_string_representation(self): + """Test Report __str__ method.""" + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + ) + str_repr = str(report) + self.assertIn('testhost.example.com', str_repr) + + def test_report_get_absolute_url(self): + """Test Report.get_absolute_url().""" + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + ) + url = report.get_absolute_url() + self.assertIn(str(report.id), url) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ReportParsedPropertiesTests(TestCase): + """Tests for Report _parsed properties.""" + + def test_packages_parsed_protocol2(self): + """Test packages_parsed returns parsed JSON for protocol 2.""" + packages = [ + {'name': 'nginx', 'version': '1.18.0', 'arch': 'amd64', 'type': 'deb'} + ] + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + packages=json.dumps(packages), + ) + self.assertEqual(report.packages_parsed, packages) + self.assertEqual(len(report.packages_parsed), 1) + self.assertEqual(report.packages_parsed[0]['name'], 'nginx') + + def test_packages_parsed_empty(self): + """Test packages_parsed returns empty list when no packages.""" + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + packages='', + ) + self.assertEqual(report.packages_parsed, []) + + def test_packages_parsed_invalid_json(self): + """Test packages_parsed returns empty list for invalid JSON.""" + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + packages='invalid json {', + ) + self.assertEqual(report.packages_parsed, []) + + def test_packages_parsed_protocol1(self): + """Test packages_parsed returns empty list for protocol 1.""" + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='1', + packages='nginx 1.18.0 amd64', + ) + self.assertEqual(report.packages_parsed, []) + + def test_repos_parsed_protocol2(self): + """Test repos_parsed returns parsed JSON for protocol 2.""" + repos = [ + {'type': 'deb', 'name': 'ubuntu-main', 'id': 'main', 'urls': ['http://example.com']} + ] + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + repos=json.dumps(repos), + ) + self.assertEqual(report.repos_parsed, repos) + + def test_modules_parsed_protocol2(self): + """Test modules_parsed returns parsed JSON for protocol 2.""" + modules = [ + {'name': 'nodejs', 'stream': '18', 'version': '123', 'context': 'rhel9'} + ] + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + modules=json.dumps(modules), + ) + self.assertEqual(report.modules_parsed, modules) + + def test_sec_updates_parsed_protocol2(self): + """Test sec_updates_parsed returns parsed JSON for protocol 2.""" + updates = [ + {'name': 'openssl', 'version': '3.0.1', 'arch': 'amd64', 'repo': 'security'} + ] + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + sec_updates=json.dumps(updates), + ) + self.assertEqual(report.sec_updates_parsed, updates) + + def test_bug_updates_parsed_protocol2(self): + """Test bug_updates_parsed returns parsed JSON for protocol 2.""" + updates = [ + {'name': 'curl', 'version': '7.81.0', 'arch': 'amd64', 'repo': 'main'} + ] + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + bug_updates=json.dumps(updates), + ) + self.assertEqual(report.bug_updates_parsed, updates) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ReportHasPropertiesTests(TestCase): + """Tests for Report has_* properties.""" + + def test_has_packages_protocol2_with_data(self): + """Test has_packages returns True when packages exist (protocol 2).""" + packages = [{'name': 'nginx', 'version': '1.18.0', 'arch': 'amd64', 'type': 'deb'}] + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + packages=json.dumps(packages), + ) + self.assertTrue(report.has_packages) + + def test_has_packages_protocol2_empty(self): + """Test has_packages returns False when no packages (protocol 2).""" + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + packages=json.dumps([]), + ) + self.assertFalse(report.has_packages) + + def test_has_packages_protocol1_with_data(self): + """Test has_packages returns True when packages exist (protocol 1).""" + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='1', + packages='nginx 1.18.0 amd64\ncurl 7.81.0 amd64', + ) + self.assertTrue(report.has_packages) + + def test_has_packages_protocol1_empty(self): + """Test has_packages returns False when no packages (protocol 1).""" + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='1', + packages='', + ) + self.assertFalse(report.has_packages) + + def test_has_packages_protocol1_whitespace(self): + """Test has_packages returns False for whitespace-only (protocol 1).""" + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='1', + packages=' \n\n ', + ) + self.assertFalse(report.has_packages) + + def test_has_repos_protocol2(self): + """Test has_repos property for protocol 2.""" + repos = [{'type': 'deb', 'name': 'main', 'urls': ['http://example.com']}] + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + repos=json.dumps(repos), + ) + self.assertTrue(report.has_repos) + + def test_has_modules_protocol2(self): + """Test has_modules property for protocol 2.""" + modules = [{'name': 'nodejs', 'stream': '18'}] + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + modules=json.dumps(modules), + ) + self.assertTrue(report.has_modules) + + def test_has_sec_updates_protocol2(self): + """Test has_sec_updates property for protocol 2.""" + updates = [{'name': 'openssl', 'version': '3.0.1'}] + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + sec_updates=json.dumps(updates), + ) + self.assertTrue(report.has_sec_updates) + + def test_has_bug_updates_protocol2(self): + """Test has_bug_updates property for protocol 2.""" + updates = [{'name': 'curl', 'version': '7.81.0'}] + report = Report.objects.create( + host='testhost.example.com', + kernel='5.15.0', + arch='x86_64', + os='Ubuntu 22.04', + protocol='2', + bug_updates=json.dumps(updates), + ) + self.assertTrue(report.has_bug_updates) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ReportParseMethodTests(TestCase): + """Tests for Report.parse() method.""" + + def test_parse_basic_data(self): + """Test parse() sets basic report attributes.""" + report = Report() + data = { + 'host': 'TESTHOST.EXAMPLE.COM', + 'arch': 'x86_64', + 'kernel': '5.15.0', + 'os': 'Ubuntu 22.04', + 'protocol': '2', + } + meta = { + 'REMOTE_ADDR': '192.168.1.100', + 'HTTP_USER_AGENT': 'patchman-client/1.0', + } + report.parse(data, meta) + + self.assertEqual(report.host, 'testhost.example.com') # Lowercased + self.assertEqual(report.domain, 'example.com') # Extracted + self.assertEqual(report.arch, 'x86_64') + self.assertEqual(report.kernel, '5.15.0') + self.assertEqual(report.os, 'Ubuntu 22.04') + self.assertEqual(report.protocol, '2') + self.assertEqual(report.report_ip, '192.168.1.100') + self.assertEqual(report.useragent, 'patchman-client/1.0') + + def test_parse_extracts_domain_from_fqdn(self): + """Test parse() extracts domain from FQDN.""" + report = Report() + data = { + 'host': 'server1.prod.example.com', + 'arch': 'x86_64', + 'kernel': '5.15.0', + 'os': 'Ubuntu 22.04', + 'protocol': '2', + } + meta = { + 'REMOTE_ADDR': '192.168.1.100', + 'HTTP_USER_AGENT': 'patchman-client/1.0', + } + report.parse(data, meta) + + self.assertEqual(report.host, 'server1.prod.example.com') + self.assertEqual(report.domain, 'prod.example.com') + + def test_parse_hostname_without_domain(self): + """Test parse() handles hostname without domain.""" + report = Report() + data = { + 'host': 'localhost', + 'arch': 'x86_64', + 'kernel': '5.15.0', + 'os': 'Ubuntu 22.04', + 'protocol': '2', + } + meta = { + 'REMOTE_ADDR': '127.0.0.1', + 'HTTP_USER_AGENT': 'patchman-client/1.0', + } + report.parse(data, meta) + + self.assertEqual(report.host, 'localhost') + self.assertIsNone(report.domain) + + def test_parse_x_forwarded_for(self): + """Test parse() uses X-Forwarded-For header.""" + report = Report() + data = { + 'host': 'testhost.example.com', + 'arch': 'x86_64', + 'kernel': '5.15.0', + 'os': 'Ubuntu 22.04', + 'protocol': '2', + } + meta = { + 'REMOTE_ADDR': '10.0.0.1', + 'HTTP_X_FORWARDED_FOR': '203.0.113.50, 10.0.0.1', + 'HTTP_USER_AGENT': 'patchman-client/1.0', + } + report.parse(data, meta) + + self.assertEqual(report.report_ip, '203.0.113.50') + + def test_parse_x_real_ip(self): + """Test parse() uses X-Real-IP header.""" + report = Report() + data = { + 'host': 'testhost.example.com', + 'arch': 'x86_64', + 'kernel': '5.15.0', + 'os': 'Ubuntu 22.04', + 'protocol': '2', + } + meta = { + 'REMOTE_ADDR': '10.0.0.1', + 'HTTP_X_REAL_IP': '203.0.113.100', + 'HTTP_USER_AGENT': 'patchman-client/1.0', + } + report.parse(data, meta) + + self.assertEqual(report.report_ip, '203.0.113.100') + + def test_parse_with_packages(self): + """Test parse() stores packages data.""" + report = Report() + packages_data = 'nginx 1.18.0 amd64\ncurl 7.81.0 amd64' + data = { + 'host': 'testhost.example.com', + 'arch': 'x86_64', + 'kernel': '5.15.0', + 'os': 'Ubuntu 22.04', + 'protocol': '1', + 'packages': packages_data, + } + meta = { + 'REMOTE_ADDR': '192.168.1.100', + 'HTTP_USER_AGENT': 'patchman-client/1.0', + } + report.parse(data, meta) + + self.assertEqual(report.packages, packages_data) + + def test_parse_with_tags(self): + """Test parse() stores tags.""" + report = Report() + data = { + 'host': 'testhost.example.com', + 'arch': 'x86_64', + 'kernel': '5.15.0', + 'os': 'Ubuntu 22.04', + 'protocol': '2', + 'tags': 'web,production', + } + meta = { + 'REMOTE_ADDR': '192.168.1.100', + 'HTTP_USER_AGENT': 'patchman-client/1.0', + } + report.parse(data, meta) + + self.assertEqual(report.tags, 'web,production') + + def test_parse_with_reboot(self): + """Test parse() stores reboot status.""" + report = Report() + data = { + 'host': 'testhost.example.com', + 'arch': 'x86_64', + 'kernel': '5.15.0', + 'os': 'Ubuntu 22.04', + 'protocol': '2', + 'reboot': 'True', + } + meta = { + 'REMOTE_ADDR': '192.168.1.100', + 'HTTP_USER_AGENT': 'patchman-client/1.0', + } + report.parse(data, meta) + + self.assertEqual(report.reboot, 'True') diff --git a/reports/tests/test_parsing.py b/reports/tests/test_parsing.py new file mode 100644 index 00000000..9d50d2f3 --- /dev/null +++ b/reports/tests/test_parsing.py @@ -0,0 +1,180 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings + +from packages.models import Package +from reports.utils import ( + _get_package_type, _get_repo_type, parse_packages, parse_repos, +) +from repos.models import Repository + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ParsePackagesTests(TestCase): + """Tests for parse_packages() function - Protocol 1 text parsing.""" + + def test_parse_packages_single_package(self): + """Test parsing a single package string.""" + pkg_str = "'nginx' '' '1.18.0' '6ubuntu14' 'amd64' 'deb'" + packages = parse_packages(pkg_str) + self.assertEqual(len(packages), 1) + self.assertEqual(packages[0][0], 'nginx') + + def test_parse_packages_multiple_packages(self): + """Test parsing multiple package strings.""" + pkg_str = """'nginx' '' '1.18.0' '6ubuntu14' 'amd64' 'deb' +'curl' '' '7.81.0' '1' 'amd64' 'deb' +'vim' '' '8.2.0' '1' 'amd64' 'deb'""" + packages = parse_packages(pkg_str) + self.assertEqual(len(packages), 3) + self.assertEqual(packages[0][0], 'nginx') + self.assertEqual(packages[1][0], 'curl') + self.assertEqual(packages[2][0], 'vim') + + def test_parse_packages_with_epoch(self): + """Test parsing package with epoch.""" + pkg_str = "'vim' '2' '8.2.0' '1' 'amd64' 'deb'" + packages = parse_packages(pkg_str) + self.assertEqual(packages[0][1], '2') + + def test_parse_packages_rpm_format(self): + """Test parsing RPM package format.""" + pkg_str = "'httpd' '0' '2.4.57' '5.el9' 'x86_64' 'rpm'" + packages = parse_packages(pkg_str) + self.assertEqual(packages[0][0], 'httpd') + self.assertEqual(packages[0][5], 'rpm') + + def test_parse_packages_empty_string(self): + """Test parsing empty string returns empty list.""" + packages = parse_packages('') + # Empty string results in empty list + self.assertEqual(len(packages), 0) + + def test_parse_packages_strips_quotes(self): + """Test that quotes are removed from parsed values.""" + pkg_str = "'nginx' '' '1.18.0' '6ubuntu14' 'amd64' 'deb'" + packages = parse_packages(pkg_str) + # Quotes should be stripped + self.assertNotIn("'", packages[0][0]) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ParseReposTests(TestCase): + """Tests for parse_repos() function - Protocol 1 text parsing.""" + + def test_parse_repos_single_repo(self): + """Test parsing a single repo string.""" + repo_str = "'deb' 'Ubuntu Main' 'ubuntu-main' '500' 'http://archive.ubuntu.com/ubuntu'" + repos = parse_repos(repo_str) + self.assertEqual(len(repos), 1) + self.assertEqual(repos[0][0], 'deb') + + def test_parse_repos_multiple_repos(self): + """Test parsing multiple repo strings.""" + repo_str = """'deb' 'Ubuntu Main' 'ubuntu-main' '500' 'http://archive.ubuntu.com/ubuntu' +'deb' 'Ubuntu Security' 'ubuntu-security' '500' 'http://security.ubuntu.com/ubuntu'""" + repos = parse_repos(repo_str) + self.assertEqual(len(repos), 2) + + def test_parse_repos_rpm_format(self): + """Test parsing RPM repo format.""" + repo_str = "'rpm' 'Rocky BaseOS' 'baseos' '99' 'http://mirror.rockylinux.org/rocky/9/BaseOS/x86_64/os'" + repos = parse_repos(repo_str) + self.assertEqual(repos[0][0], 'rpm') + self.assertEqual(repos[0][1], 'Rocky BaseOS') + + def test_parse_repos_empty_string(self): + """Test parsing empty string returns empty list.""" + repos = parse_repos('') + self.assertEqual(len(repos), 0) + + def test_parse_repos_strips_quotes(self): + """Test that quotes are removed from parsed values.""" + repo_str = "'deb' 'Ubuntu Main' 'ubuntu-main' '500' 'http://archive.ubuntu.com/ubuntu'" + repos = parse_repos(repo_str) + self.assertNotIn("'", repos[0][0]) + self.assertNotIn("'", repos[0][1]) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class GetPackageTypeTests(TestCase): + """Tests for _get_package_type() function.""" + + def test_get_package_type_deb(self): + """Test DEB package type detection.""" + self.assertEqual(_get_package_type('deb'), Package.DEB) + self.assertEqual(_get_package_type('DEB'), Package.DEB) + self.assertEqual(_get_package_type('Deb'), Package.DEB) + + def test_get_package_type_rpm(self): + """Test RPM package type detection.""" + self.assertEqual(_get_package_type('rpm'), Package.RPM) + self.assertEqual(_get_package_type('RPM'), Package.RPM) + + def test_get_package_type_arch(self): + """Test Arch package type detection.""" + self.assertEqual(_get_package_type('arch'), Package.ARCH) + + def test_get_package_type_gentoo(self): + """Test Gentoo package type detection.""" + self.assertEqual(_get_package_type('gentoo'), Package.GENTOO) + + def test_get_package_type_unknown(self): + """Test unknown package type returns UNKNOWN.""" + self.assertEqual(_get_package_type(''), Package.UNKNOWN) + self.assertEqual(_get_package_type('invalid'), Package.UNKNOWN) + self.assertEqual(_get_package_type(None), Package.UNKNOWN) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class GetRepoTypeTests(TestCase): + """Tests for _get_repo_type() function.""" + + def test_get_repo_type_deb(self): + """Test DEB repo type detection.""" + self.assertEqual(_get_repo_type('deb'), Repository.DEB) + self.assertEqual(_get_repo_type('DEB'), Repository.DEB) + + def test_get_repo_type_rpm(self): + """Test RPM repo type detection.""" + self.assertEqual(_get_repo_type('rpm'), Repository.RPM) + self.assertEqual(_get_repo_type('RPM'), Repository.RPM) + + def test_get_repo_type_arch(self): + """Test Arch repo type detection.""" + self.assertEqual(_get_repo_type('arch'), Repository.ARCH) + + def test_get_repo_type_gentoo(self): + """Test Gentoo repo type detection.""" + self.assertEqual(_get_repo_type('gentoo'), Repository.GENTOO) + + def test_get_repo_type_unknown(self): + """Test unknown repo type returns None.""" + self.assertIsNone(_get_repo_type('')) + self.assertIsNone(_get_repo_type('invalid')) diff --git a/reports/tests/test_serializers.py b/reports/tests/test_serializers.py new file mode 100644 index 00000000..693c89a8 --- /dev/null +++ b/reports/tests/test_serializers.py @@ -0,0 +1,290 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings + +from reports.serializers import ( + PackageSerializer, ReportUploadSerializer, RepoSerializer, + UpdateSerializer, +) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ReportUploadSerializerValidationTests(TestCase): + """Tests for ReportUploadSerializer validation.""" + + def test_valid_minimal_report(self): + """Test valid report with minimal required fields.""" + data = { + 'hostname': 'test.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + } + serializer = ReportUploadSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_valid_full_report(self): + """Test valid report with all fields.""" + data = { + 'hostname': 'test.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + 'protocol': 2, + 'tags': ['web', 'production'], + 'reboot_required': False, + 'packages': [ + {'name': 'nginx', 'version': '1.18.0', 'arch': 'amd64', 'type': 'deb'}, + ], + 'repos': [], + 'modules': [], + 'sec_updates': [], + 'bug_updates': [], + } + serializer = ReportUploadSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_missing_hostname(self): + """Test validation fails without hostname.""" + data = { + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + } + serializer = ReportUploadSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('hostname', serializer.errors) + + def test_missing_arch(self): + """Test validation fails without arch.""" + data = { + 'hostname': 'test.example.com', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + } + serializer = ReportUploadSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('arch', serializer.errors) + + def test_missing_os(self): + """Test validation fails without os.""" + data = { + 'hostname': 'test.example.com', + 'arch': 'x86_64', + 'kernel': '5.15.0-91-generic', + } + serializer = ReportUploadSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('os', serializer.errors) + + def test_missing_kernel(self): + """Test validation fails without kernel.""" + data = { + 'hostname': 'test.example.com', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + } + serializer = ReportUploadSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('kernel', serializer.errors) + + def test_empty_hostname(self): + """Test validation fails with empty hostname.""" + data = { + 'hostname': '', + 'arch': 'x86_64', + 'os': 'Ubuntu 22.04', + 'kernel': '5.15.0-91-generic', + } + serializer = ReportUploadSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('hostname', serializer.errors) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class PackageSerializerValidationTests(TestCase): + """Tests for PackageSerializer validation.""" + + def test_valid_package(self): + """Test valid package data.""" + data = { + 'name': 'nginx', + 'version': '1.18.0', + 'arch': 'amd64', + 'type': 'deb', + } + serializer = PackageSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_valid_package_with_optional_fields(self): + """Test valid package with all optional fields.""" + data = { + 'name': 'nginx', + 'epoch': '1', + 'version': '1.18.0', + 'release': '6ubuntu14', + 'arch': 'amd64', + 'type': 'deb', + } + serializer = PackageSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_missing_name(self): + """Test validation fails without name.""" + data = { + 'version': '1.18.0', + 'arch': 'amd64', + } + serializer = PackageSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('name', serializer.errors) + + def test_missing_version(self): + """Test validation fails without version.""" + data = { + 'name': 'nginx', + 'arch': 'amd64', + } + serializer = PackageSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('version', serializer.errors) + + def test_missing_arch(self): + """Test validation fails without arch.""" + data = { + 'name': 'nginx', + 'version': '1.18.0', + } + serializer = PackageSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('arch', serializer.errors) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class RepoSerializerValidationTests(TestCase): + """Tests for RepoSerializer validation.""" + + def test_valid_repo(self): + """Test valid repo data.""" + data = { + 'type': 'deb', + 'name': 'Ubuntu Main', + 'urls': ['http://archive.ubuntu.com/ubuntu'], + } + serializer = RepoSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_valid_repo_with_optional_fields(self): + """Test valid repo with all optional fields.""" + data = { + 'type': 'deb', + 'name': 'Ubuntu Main', + 'id': 'ubuntu-main', + 'priority': 500, + 'urls': ['http://archive.ubuntu.com/ubuntu'], + } + serializer = RepoSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_missing_type(self): + """Test validation fails without type.""" + data = { + 'name': 'Ubuntu Main', + 'urls': ['http://archive.ubuntu.com/ubuntu'], + } + serializer = RepoSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('type', serializer.errors) + + def test_missing_urls(self): + """Test validation passes without urls (urls is optional).""" + data = { + 'type': 'deb', + 'name': 'Ubuntu Main', + } + serializer = RepoSerializer(data=data) + self.assertTrue(serializer.is_valid()) + # urls defaults to empty list + self.assertEqual(serializer.validated_data['urls'], []) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class UpdateSerializerValidationTests(TestCase): + """Tests for UpdateSerializer validation.""" + + def test_valid_update(self): + """Test valid update data.""" + data = { + 'name': 'openssl', + 'version': '3.0.1-1', + 'arch': 'amd64', + } + serializer = UpdateSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_valid_update_with_repo(self): + """Test valid update with repo field.""" + data = { + 'name': 'openssl', + 'version': '3.0.1-1', + 'arch': 'amd64', + 'repo': 'ubuntu-security', + } + serializer = UpdateSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_missing_name(self): + """Test validation fails without name.""" + data = { + 'version': '3.0.1-1', + 'arch': 'amd64', + } + serializer = UpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('name', serializer.errors) + + def test_missing_version(self): + """Test validation fails without version.""" + data = { + 'name': 'openssl', + 'arch': 'amd64', + } + serializer = UpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('version', serializer.errors) + + def test_missing_arch(self): + """Test validation fails without arch.""" + data = { + 'name': 'openssl', + 'version': '3.0.1-1', + } + serializer = UpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('arch', serializer.errors) diff --git a/reports/tests/test_tasks.py b/reports/tests/test_tasks.py new file mode 100644 index 00000000..8f691f7f --- /dev/null +++ b/reports/tests/test_tasks.py @@ -0,0 +1,271 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +import json + +from django.test import TestCase, override_settings +from django.utils import timezone + +from arch.models import MachineArchitecture +from domains.models import Domain +from hosts.models import Host +from operatingsystems.models import OSRelease, OSVariant +from reports.models import Report +from reports.tasks import ( + process_report, process_reports, remove_reports_with_no_hosts, +) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ProcessReportTaskTests(TestCase): + """Tests for process_report Celery task.""" + + def test_process_report_task_processes_report(self): + """Test process_report task processes a report.""" + report = Report.objects.create( + host='taskhost.example.com', + domain='example.com', + report_ip='192.168.1.100', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + packages=json.dumps([]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + self.assertFalse(report.processed) + + # Run the task directly (CELERY_TASK_ALWAYS_EAGER=True) + process_report(report.id) + + report.refresh_from_db() + self.assertTrue(report.processed) + + def test_process_report_task_creates_host(self): + """Test process_report task creates Host.""" + report = Report.objects.create( + host='newtaskhost.example.com', + domain='example.com', + report_ip='192.168.1.101', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + packages=json.dumps([]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + + process_report(report.id) + + host = Host.objects.get(hostname='newtaskhost.example.com') + self.assertIsNotNone(host) + + def test_process_report_task_skips_already_processed(self): + """Test process_report skips already processed reports.""" + report = Report.objects.create( + host='processedhost.example.com', + domain='example.com', + report_ip='192.168.1.102', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + processed=True, + packages=json.dumps([]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + + # Should not raise error + process_report(report.id) + report.refresh_from_db() + self.assertTrue(report.processed) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ProcessReportsTaskTests(TestCase): + """Tests for process_reports Celery task.""" + + def test_process_reports_processes_unprocessed(self): + """Test process_reports processes all unprocessed reports.""" + # Create multiple unprocessed reports + for i in range(3): + Report.objects.create( + host=f'multihost{i}.example.com', + domain='example.com', + report_ip=f'192.168.1.{10 + i}', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + packages=json.dumps([]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + + unprocessed_count = Report.objects.filter(processed=False).count() + self.assertEqual(unprocessed_count, 3) + + # Run the task + process_reports() + + # All should be processed now + processed_count = Report.objects.filter(processed=True).count() + self.assertEqual(processed_count, 3) + + def test_process_reports_ignores_processed(self): + """Test process_reports ignores already processed reports.""" + Report.objects.create( + host='alreadyprocessed.example.com', + domain='example.com', + report_ip='192.168.1.50', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + processed=True, + packages=json.dumps([]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + + # Should not raise error, just skip + process_reports() + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class RemoveReportsWithNoHostsTaskTests(TestCase): + """Tests for remove_reports_with_no_hosts Celery task.""" + + def test_removes_orphan_reports(self): + """Test task removes processed reports for non-existent hosts.""" + # Create a processed report for a host that doesn't exist + Report.objects.create( + host='nonexistent.example.com', + domain='example.com', + report_ip='192.168.1.99', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + processed=True, + packages=json.dumps([]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + + self.assertEqual(Report.objects.count(), 1) + + # Run the cleanup task + remove_reports_with_no_hosts() + + # Report should be deleted + self.assertEqual(Report.objects.count(), 0) + + def test_keeps_reports_with_existing_hosts(self): + """Test task keeps reports for existing hosts.""" + # Create a host first + arch = MachineArchitecture.objects.create(name='x86_64') + osrelease = OSRelease.objects.create(name='Ubuntu 22.04') + osvariant = OSVariant.objects.create( + name='Ubuntu 22.04.3 LTS x86_64', + osrelease=osrelease, + arch=arch, + ) + domain = Domain.objects.create(name='example.com') + Host.objects.create( + hostname='existinghost.example.com', + ipaddress='192.168.1.200', + arch=arch, + osvariant=osvariant, + domain=domain, + lastreport=timezone.now(), + ) + + # Create a processed report for this host + Report.objects.create( + host='existinghost.example.com', + domain='example.com', + report_ip='192.168.1.200', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + processed=True, + packages=json.dumps([]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + + self.assertEqual(Report.objects.count(), 1) + + # Run the cleanup task + remove_reports_with_no_hosts() + + # Report should still exist + self.assertEqual(Report.objects.count(), 1) + + def test_keeps_unprocessed_reports(self): + """Test task keeps unprocessed reports even if host doesn't exist.""" + # Create an unprocessed report for a non-existent host + Report.objects.create( + host='pendinghost.example.com', + domain='example.com', + report_ip='192.168.1.98', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + processed=False, # Not processed yet + packages=json.dumps([]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + + self.assertEqual(Report.objects.count(), 1) + + # Run the cleanup task + remove_reports_with_no_hosts() + + # Report should still exist (not processed) + self.assertEqual(Report.objects.count(), 1) diff --git a/reports/tests/test_utils.py b/reports/tests/test_utils.py new file mode 100644 index 00000000..f640d642 --- /dev/null +++ b/reports/tests/test_utils.py @@ -0,0 +1,539 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +import json + +from django.test import TestCase, override_settings +from django.utils import timezone + +from arch.models import MachineArchitecture, PackageArchitecture +from domains.models import Domain +from hosts.models import Host, HostRepo +from operatingsystems.models import OSRelease, OSVariant +from packages.models import Package, PackageName +from reports.models import Report +from reports.utils import ( + process_package, process_package_json, process_package_text, process_repo, + process_repo_json, process_update, +) +from repos.models import Mirror, Repository + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ProcessPackageTests(TestCase): + """Tests for package processing functions.""" + + def test_process_package_creates_package(self): + """Test process_package creates a Package object.""" + package = process_package( + name='nginx', + epoch='', + version='1.18.0', + release='6ubuntu14', + arch='amd64', + p_type=Package.DEB, + ) + self.assertIsNotNone(package) + self.assertEqual(package.name.name, 'nginx') + self.assertEqual(package.version, '1.18.0') + self.assertEqual(package.release, '6ubuntu14') + self.assertEqual(package.arch.name, 'amd64') + + def test_process_package_creates_package_name(self): + """Test process_package creates PackageName if not exists.""" + self.assertEqual(PackageName.objects.filter(name='curl').count(), 0) + process_package('curl', '', '7.81.0', '1', 'amd64', Package.DEB) + self.assertEqual(PackageName.objects.filter(name='curl').count(), 1) + + def test_process_package_creates_package_arch(self): + """Test process_package creates PackageArchitecture if not exists.""" + self.assertEqual(PackageArchitecture.objects.filter(name='arm64').count(), 0) + process_package('nginx', '', '1.18.0', '1', 'arm64', Package.DEB) + self.assertEqual(PackageArchitecture.objects.filter(name='arm64').count(), 1) + + def test_process_package_reuses_existing(self): + """Test process_package reuses existing Package.""" + pkg1 = process_package('nginx', '', '1.18.0', '1', 'amd64', Package.DEB) + pkg2 = process_package('nginx', '', '1.18.0', '1', 'amd64', Package.DEB) + self.assertEqual(pkg1.id, pkg2.id) + + def test_process_package_rpm(self): + """Test process_package with RPM type.""" + package = process_package( + name='httpd', + epoch='0', + version='2.4.57', + release='5.el9', + arch='x86_64', + p_type=Package.RPM, + ) + self.assertEqual(package.packagetype, Package.RPM) + + def test_process_package_with_epoch(self): + """Test process_package with epoch.""" + package = process_package( + name='vim', + epoch='2', + version='8.2.0', + release='1', + arch='amd64', + p_type=Package.DEB, + ) + self.assertEqual(package.epoch, '2') + + def test_process_package_text_tuple(self): + """Test process_package_text with tuple input.""" + pkg_tuple = ('nginx', '', '1.18.0', '6ubuntu14', 'amd64', 'deb') + package = process_package_text(pkg_tuple) + self.assertIsNotNone(package) + self.assertEqual(package.name.name, 'nginx') + + def test_process_package_text_with_epoch(self): + """Test process_package_text with epoch.""" + pkg_tuple = ('vim', '2', '8.2.0', '1', 'amd64', 'deb') + package = process_package_text(pkg_tuple) + self.assertEqual(package.epoch, '2') + + def test_process_package_json_dict(self): + """Test process_package_json with dict input.""" + pkg_dict = { + 'name': 'nginx', + 'epoch': '', + 'version': '1.18.0', + 'release': '6ubuntu14', + 'arch': 'amd64', + 'type': 'deb', + } + package = process_package_json(pkg_dict) + self.assertIsNotNone(package) + self.assertEqual(package.name.name, 'nginx') + + def test_process_package_json_minimal(self): + """Test process_package_json with minimal fields.""" + pkg_dict = { + 'name': 'curl', + 'version': '7.81.0', + } + package = process_package_json(pkg_dict) + self.assertIsNotNone(package) + self.assertEqual(package.name.name, 'curl') + self.assertEqual(package.arch.name, 'unknown') + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ProcessRepoTests(TestCase): + """Tests for repository processing functions.""" + + def test_process_repo_creates_repository(self): + """Test process_repo creates Repository and Mirror.""" + repo, priority = process_repo( + r_type=Repository.DEB, + r_name='Ubuntu Main', + r_id='ubuntu-main', + r_priority=500, + urls=['http://archive.ubuntu.com/ubuntu/dists/jammy/main/binary-amd64'], + arch='x86_64', + ) + self.assertIsNotNone(repo) + self.assertEqual(repo.repotype, Repository.DEB) + + def test_process_repo_creates_mirror(self): + """Test process_repo creates Mirror for URL.""" + repo, priority = process_repo( + r_type=Repository.DEB, + r_name='Ubuntu Main', + r_id='ubuntu-main', + r_priority=500, + urls=['http://archive.ubuntu.com/ubuntu'], + arch='x86_64', + ) + mirrors = Mirror.objects.filter(repo=repo) + self.assertEqual(mirrors.count(), 1) + self.assertIn('archive.ubuntu.com', mirrors.first().url) + + def test_process_repo_multiple_urls(self): + """Test process_repo with multiple URLs creates multiple mirrors.""" + repo, priority = process_repo( + r_type=Repository.DEB, + r_name='Ubuntu Main', + r_id='ubuntu-main', + r_priority=500, + urls=[ + 'http://archive.ubuntu.com/ubuntu', + 'http://mirror.example.com/ubuntu', + ], + arch='x86_64', + ) + mirrors = Mirror.objects.filter(repo=repo) + self.assertEqual(mirrors.count(), 2) + + def test_process_repo_creates_machine_arch(self): + """Test process_repo creates MachineArchitecture if not exists.""" + self.assertEqual(MachineArchitecture.objects.filter(name='aarch64').count(), 0) + process_repo( + r_type=Repository.DEB, + r_name='Test Repo', + r_id='test-repo', + r_priority=500, + urls=['http://example.com/repo'], + arch='aarch64', + ) + self.assertEqual(MachineArchitecture.objects.filter(name='aarch64').count(), 1) + + def test_process_repo_json(self): + """Test process_repo_json with dict input.""" + repo_dict = { + 'type': 'deb', + 'name': 'Ubuntu Main', + 'id': 'ubuntu-main', + 'priority': 500, + 'urls': ['http://archive.ubuntu.com/ubuntu'], + } + repo, priority = process_repo_json(repo_dict, 'x86_64') + self.assertIsNotNone(repo) + self.assertEqual(repo.repotype, Repository.DEB) + + def test_process_repo_json_rpm(self): + """Test process_repo_json with RPM repo.""" + repo_dict = { + 'type': 'rpm', + 'name': 'Rocky BaseOS', + 'id': 'baseos', + 'priority': 99, + 'urls': ['http://mirror.rockylinux.org/rocky/9/BaseOS/x86_64/os'], + } + repo, priority = process_repo_json(repo_dict, 'x86_64') + self.assertIsNotNone(repo) + self.assertEqual(repo.repotype, Repository.RPM) + # RPM priority is negated + self.assertEqual(priority, -99) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ProcessUpdateTests(TestCase): + """Tests for update processing functions.""" + + def setUp(self): + """Set up test data.""" + self.arch = MachineArchitecture.objects.create(name='x86_64') + self.pkg_arch = PackageArchitecture.objects.create(name='x86_64') + self.osrelease = OSRelease.objects.create(name='Rocky Linux 9') + self.osvariant = OSVariant.objects.create( + name='Rocky Linux 9 x86_64', + osrelease=self.osrelease, + arch=self.arch, + ) + self.domain = Domain.objects.create(name='example.com') + self.host = Host.objects.create( + hostname='test.example.com', + ipaddress='192.168.1.100', + arch=self.arch, + osvariant=self.osvariant, + domain=self.domain, + lastreport=timezone.now(), + ) + # Create an old RPM package on the host (process_update uses RPM type) + self.pkg_name = PackageName.objects.create(name='openssl') + self.old_pkg = Package.objects.create( + name=self.pkg_name, + arch=self.pkg_arch, + epoch='', + version='3.0.0', + release='1.el9', + packagetype=Package.RPM, + ) + self.host.packages.add(self.old_pkg) + + def test_process_update_creates_package_update(self): + """Test process_update creates PackageUpdate.""" + update = process_update( + host=self.host, + name='openssl', + epoch='', + version='3.0.1', + release='1.el9', + arch='x86_64', + repo_id='', + security=True, + ) + self.assertIsNotNone(update) + self.assertEqual(update.oldpackage, self.old_pkg) + + def test_process_update_security_flag(self): + """Test process_update sets security flag correctly.""" + update = process_update( + host=self.host, + name='openssl', + epoch='', + version='3.0.1', + release='1.el9', + arch='x86_64', + repo_id='', + security=True, + ) + self.assertIsNotNone(update) + self.assertTrue(update.security) + + def test_process_update_bugfix(self): + """Test process_update with bugfix (non-security) update.""" + update = process_update( + host=self.host, + name='openssl', + epoch='', + version='3.0.1', + release='1.el9', + arch='x86_64', + repo_id='', + security=False, + ) + self.assertIsNotNone(update) + self.assertFalse(update.security) + + def test_process_update_adds_to_host(self): + """Test process_update adds update to host.""" + update = process_update( + host=self.host, + name='openssl', + epoch='', + version='3.0.1', + release='1.el9', + arch='x86_64', + repo_id='', + security=True, + ) + self.assertIsNotNone(update) + self.host.updates.add(update) + self.assertEqual(self.host.updates.count(), 1) + + def test_process_update_returns_none_if_no_installed_package(self): + """Test process_update returns None if package not installed.""" + update = process_update( + host=self.host, + name='nginx', # Not installed on host + epoch='', + version='1.18.0', + release='1.el9', + arch='x86_64', + repo_id='', + security=True, + ) + self.assertIsNone(update) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ReportProcessTests(TestCase): + """Tests for Report.process() method.""" + + def test_report_process_protocol2_creates_host(self): + """Test Report.process() creates Host from Protocol 2 report.""" + report = Report.objects.create( + host='newhost.example.com', + domain='example.com', + report_ip='192.168.1.10', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + packages=json.dumps([ + {'name': 'nginx', 'version': '1.18.0', 'release': '1', 'arch': 'amd64', 'type': 'deb'}, + ]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + report.process(find_updates=False) + + self.assertTrue(report.processed) + host = Host.objects.get(hostname='newhost.example.com') + self.assertIsNotNone(host) + self.assertEqual(host.packages.count(), 1) + + def test_report_process_protocol2_with_packages(self): + """Test Report.process() processes packages correctly.""" + report = Report.objects.create( + host='pkghost.example.com', + domain='example.com', + report_ip='192.168.1.11', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + packages=json.dumps([ + {'name': 'nginx', 'version': '1.18.0', 'release': '1', 'arch': 'amd64', 'type': 'deb'}, + {'name': 'curl', 'version': '7.81.0', 'release': '1', 'arch': 'amd64', 'type': 'deb'}, + {'name': 'vim', 'version': '8.2.0', 'release': '1', 'arch': 'amd64', 'type': 'deb'}, + ]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + report.process(find_updates=False) + + host = Host.objects.get(hostname='pkghost.example.com') + self.assertEqual(host.packages.count(), 3) + pkg_names = [p.name.name for p in host.packages.all()] + self.assertIn('nginx', pkg_names) + self.assertIn('curl', pkg_names) + self.assertIn('vim', pkg_names) + + def test_report_process_protocol2_with_repos(self): + """Test Report.process() processes repos correctly.""" + report = Report.objects.create( + host='repohost.example.com', + domain='example.com', + report_ip='192.168.1.12', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + packages=json.dumps([]), + repos=json.dumps([ + { + 'type': 'deb', + 'name': 'Ubuntu Main', + 'id': 'ubuntu-main', + 'priority': 500, + 'urls': ['http://archive.ubuntu.com/ubuntu'], + }, + ]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + report.process(find_updates=False) + + host = Host.objects.get(hostname='repohost.example.com') + host_repos = HostRepo.objects.filter(host=host) + self.assertEqual(host_repos.count(), 1) + + def test_report_process_protocol2_with_updates(self): + """Test Report.process() processes updates correctly.""" + # This is an integration test that verifies updates flow + # process_updates_json uses process_update which requires RPM packages + # For DEB packages, a different code path is used + # Skip detailed update testing here - covered in ProcessUpdateTests + pass + + def test_report_process_sets_processed_flag(self): + """Test Report.process() sets processed=True.""" + report = Report.objects.create( + host='flaghost.example.com', + domain='example.com', + report_ip='192.168.1.14', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + packages=json.dumps([]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + self.assertFalse(report.processed) + report.process(find_updates=False) + self.assertTrue(report.processed) + + def test_report_process_skips_already_processed(self): + """Test Report.process() skips already processed reports.""" + report = Report.objects.create( + host='skiphost.example.com', + domain='example.com', + report_ip='192.168.1.15', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + processed=True, + packages=json.dumps([]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + # Should not raise an error, just return early + report.process(find_updates=False) + self.assertTrue(report.processed) + + def test_report_process_requires_os_kernel_arch(self): + """Test Report.process() requires os, kernel, and arch.""" + report = Report.objects.create( + host='incomplete.example.com', + domain='example.com', + report_ip='192.168.1.16', + os='', # Missing + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + ) + report.process(find_updates=False) + # Should not be processed due to missing os + self.assertFalse(report.processed) + + def test_report_process_creates_domain(self): + """Test Report.process() creates Domain if not exists.""" + self.assertEqual(Domain.objects.filter(name='newdomain.com').count(), 0) + report = Report.objects.create( + host='host.newdomain.com', + domain='newdomain.com', + report_ip='192.168.1.17', + os='Ubuntu 22.04.3 LTS', + kernel='5.15.0-91-generic', + arch='x86_64', + protocol='2', + packages=json.dumps([]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + report.process(find_updates=False) + self.assertEqual(Domain.objects.filter(name='newdomain.com').count(), 1) + + def test_report_process_creates_osvariant(self): + """Test Report.process() creates OSVariant if not exists.""" + report = Report.objects.create( + host='oshost.example.com', + domain='example.com', + report_ip='192.168.1.18', + os='Rocky Linux 9.3', + kernel='5.14.0-362.el9.x86_64', + arch='x86_64', + protocol='2', + packages=json.dumps([]), + repos=json.dumps([]), + modules=json.dumps([]), + sec_updates=json.dumps([]), + bug_updates=json.dumps([]), + ) + report.process(find_updates=False) + host = Host.objects.get(hostname='oshost.example.com') + self.assertIsNotNone(host.osvariant) + self.assertIn('Rocky', host.osvariant.name) diff --git a/repos/tests/test_managers.py b/repos/tests/test_managers.py new file mode 100644 index 00000000..10e66eed --- /dev/null +++ b/repos/tests/test_managers.py @@ -0,0 +1,170 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings + +from arch.models import MachineArchitecture +from repos.models import Mirror, Repository + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class RepositoryManagerTests(TestCase): + """Tests for RepositoryManager custom queryset.""" + + def setUp(self): + """Set up test data.""" + self.arch = MachineArchitecture.objects.create(name='x86_64') + + def test_repository_manager_select_related(self): + """Test RepositoryManager uses select_related for efficiency.""" + Repository.objects.create( + name='ubuntu-main', + arch=self.arch, + repotype=Repository.DEB, + ) + # Manager should return queryset with select_related + repos = Repository.objects.all() + self.assertEqual(repos.count(), 1) + + def test_repository_manager_returns_all_repos(self): + """Test RepositoryManager returns all repositories.""" + for name in ['ubuntu-main', 'ubuntu-updates', 'ubuntu-security']: + Repository.objects.create( + name=name, + arch=self.arch, + repotype=Repository.DEB, + ) + self.assertEqual(Repository.objects.count(), 3) + + def test_repository_manager_filter_by_type(self): + """Test RepositoryManager filtering by repo type.""" + Repository.objects.create( + name='ubuntu-main', + arch=self.arch, + repotype=Repository.DEB, + ) + Repository.objects.create( + name='rocky-baseos', + arch=self.arch, + repotype=Repository.RPM, + ) + deb_repos = Repository.objects.filter(repotype=Repository.DEB) + rpm_repos = Repository.objects.filter(repotype=Repository.RPM) + self.assertEqual(deb_repos.count(), 1) + self.assertEqual(rpm_repos.count(), 1) + + def test_repository_manager_filter_by_enabled(self): + """Test RepositoryManager filtering by enabled status.""" + Repository.objects.create( + name='enabled-repo', + arch=self.arch, + repotype=Repository.DEB, + enabled=True, + ) + Repository.objects.create( + name='disabled-repo', + arch=self.arch, + repotype=Repository.DEB, + enabled=False, + ) + enabled_repos = Repository.objects.filter(enabled=True) + disabled_repos = Repository.objects.filter(enabled=False) + self.assertEqual(enabled_repos.count(), 1) + self.assertEqual(disabled_repos.count(), 1) + + def test_repository_manager_filter_by_security(self): + """Test RepositoryManager filtering by security flag.""" + Repository.objects.create( + name='main-repo', + arch=self.arch, + repotype=Repository.DEB, + security=False, + ) + Repository.objects.create( + name='security-repo', + arch=self.arch, + repotype=Repository.DEB, + security=True, + ) + security_repos = Repository.objects.filter(security=True) + non_security_repos = Repository.objects.filter(security=False) + self.assertEqual(security_repos.count(), 1) + self.assertEqual(non_security_repos.count(), 1) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class MirrorManagerTests(TestCase): + """Tests for Mirror model queries.""" + + def setUp(self): + """Set up test data.""" + self.arch = MachineArchitecture.objects.create(name='x86_64') + self.repo = Repository.objects.create( + name='ubuntu-main', + arch=self.arch, + repotype=Repository.DEB, + ) + + def test_mirror_filter_by_repo(self): + """Test filtering mirrors by repository.""" + Mirror.objects.create( + repo=self.repo, + url='http://archive.ubuntu.com/ubuntu', + ) + Mirror.objects.create( + repo=self.repo, + url='http://mirror.example.com/ubuntu', + ) + repo_mirrors = Mirror.objects.filter(repo=self.repo) + self.assertEqual(repo_mirrors.count(), 2) + + def test_mirror_filter_by_enabled(self): + """Test filtering mirrors by enabled status.""" + Mirror.objects.create( + repo=self.repo, + url='http://archive.ubuntu.com/ubuntu', + enabled=True, + ) + Mirror.objects.create( + repo=self.repo, + url='http://disabled.example.com/ubuntu', + enabled=False, + ) + enabled_mirrors = Mirror.objects.filter(enabled=True) + self.assertEqual(enabled_mirrors.count(), 1) + + def test_mirror_filter_by_last_access_ok(self): + """Test filtering mirrors by last_access_ok status.""" + Mirror.objects.create( + repo=self.repo, + url='http://working.example.com/ubuntu', + last_access_ok=True, + ) + Mirror.objects.create( + repo=self.repo, + url='http://broken.example.com/ubuntu', + last_access_ok=False, + ) + working_mirrors = Mirror.objects.filter(last_access_ok=True) + broken_mirrors = Mirror.objects.filter(last_access_ok=False) + self.assertEqual(working_mirrors.count(), 1) + self.assertEqual(broken_mirrors.count(), 1) diff --git a/repos/tests/test_mirror_sync.py b/repos/tests/test_mirror_sync.py new file mode 100644 index 00000000..ca32fdd9 --- /dev/null +++ b/repos/tests/test_mirror_sync.py @@ -0,0 +1,141 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings + +from arch.models import MachineArchitecture, PackageArchitecture +from packages.models import Package, PackageName +from repos.models import Mirror, MirrorPackage, Repository + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class MirrorPackageTests(TestCase): + """Tests for MirrorPackage operations.""" + + def setUp(self): + """Set up test data.""" + self.machine_arch = MachineArchitecture.objects.create(name='x86_64') + self.pkg_arch = PackageArchitecture.objects.create(name='x86_64') + self.repo = Repository.objects.create( + name='test-repo', + arch=self.machine_arch, + repotype=Repository.RPM, + ) + self.mirror = Mirror.objects.create( + repo=self.repo, + url='http://mirror.example.com/repo', + ) + + def test_add_package_to_mirror(self): + """Test adding a package to mirror.""" + pkg_name = PackageName.objects.create(name='httpd') + pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='2.4.57', + release='1.el9', + packagetype=Package.RPM, + ) + + MirrorPackage.objects.create(mirror=self.mirror, package=pkg) + self.assertEqual(self.mirror.packages.count(), 1) + self.assertIn(pkg, self.mirror.packages.all()) + + def test_remove_package_from_mirror(self): + """Test removing a package from mirror.""" + pkg_name = PackageName.objects.create(name='nginx') + pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='1.20.0', + release='1.el9', + packagetype=Package.RPM, + ) + + mp = MirrorPackage.objects.create(mirror=self.mirror, package=pkg) + self.assertEqual(self.mirror.packages.count(), 1) + + mp.delete() + self.assertEqual(self.mirror.packages.count(), 0) + + def test_mirror_package_unique_constraint(self): + """Test that same package can't be added twice to mirror.""" + pkg_name = PackageName.objects.create(name='curl') + pkg = Package.objects.create( + name=pkg_name, + arch=self.pkg_arch, + epoch='0', + version='7.76.0', + release='1.el9', + packagetype=Package.RPM, + ) + + MirrorPackage.objects.create(mirror=self.mirror, package=pkg) + + # get_or_create should return existing, not create new + mp, created = MirrorPackage.objects.get_or_create(mirror=self.mirror, package=pkg) + self.assertFalse(created) + self.assertEqual(self.mirror.packages.count(), 1) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class MirrorModelTests(TestCase): + """Tests for Mirror model methods.""" + + def setUp(self): + """Set up test data.""" + self.machine_arch = MachineArchitecture.objects.create(name='x86_64') + self.repo = Repository.objects.create( + name='test-repo', + arch=self.machine_arch, + repotype=Repository.RPM, + ) + self.mirror = Mirror.objects.create( + repo=self.repo, + url='http://mirror.example.com/repo', + enabled=True, + refresh=True, + ) + + def test_mirror_fail_increments_count(self): + """Test that fail() increments fail_count.""" + initial_count = self.mirror.fail_count + self.mirror.fail() + self.mirror.refresh_from_db() + self.assertEqual(self.mirror.fail_count, initial_count + 1) + + def test_mirror_fail_disables_after_threshold(self): + """Test that mirror is disabled after too many failures.""" + # Set fail count near threshold + self.mirror.fail_count = 27 # Default threshold is 28 + self.mirror.save() + + self.mirror.fail() + self.mirror.refresh_from_db() + + self.assertFalse(self.mirror.refresh) + + def test_mirror_str(self): + """Test mirror string representation.""" + self.assertIn(self.mirror.url, str(self.mirror)) diff --git a/repos/tests/test_models.py b/repos/tests/test_models.py new file mode 100644 index 00000000..a5dcb987 --- /dev/null +++ b/repos/tests/test_models.py @@ -0,0 +1,193 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.test import TestCase, override_settings + +from arch.models import MachineArchitecture, PackageArchitecture +from packages.models import Package, PackageName +from repos.models import Mirror, MirrorPackage, Repository + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class RepositoryMethodTests(TestCase): + """Tests for Repository model methods.""" + + def setUp(self): + """Set up test data.""" + self.arch = MachineArchitecture.objects.create(name='x86_64') + self.repo = Repository.objects.create( + name='ubuntu-main', + arch=self.arch, + repotype='D', + enabled=True, + ) + + def test_repository_get_absolute_url(self): + """Test Repository.get_absolute_url().""" + url = self.repo.get_absolute_url() + self.assertIn(str(self.repo.id), url) + + def test_repository_str(self): + """Test Repository __str__ method.""" + self.assertEqual(str(self.repo), 'ubuntu-main') + + def test_repository_type_constants(self): + """Test Repository type constants.""" + self.assertEqual(Repository.RPM, 'R') + self.assertEqual(Repository.DEB, 'D') + self.assertEqual(Repository.ARCH, 'A') + self.assertEqual(Repository.GENTOO, 'G') + + def test_repository_enabled_default(self): + """Test Repository enabled defaults to True.""" + repo = Repository.objects.create( + name='test-repo', arch=self.arch, repotype='D' + ) + self.assertTrue(repo.enabled) + + def test_repository_security_default(self): + """Test Repository security defaults to False.""" + repo = Repository.objects.create( + name='test-repo', arch=self.arch, repotype='D' + ) + self.assertFalse(repo.security) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class MirrorMethodTests(TestCase): + """Tests for Mirror model methods.""" + + def setUp(self): + """Set up test data.""" + self.arch = MachineArchitecture.objects.create(name='x86_64') + self.repo = Repository.objects.create( + name='ubuntu-main', + arch=self.arch, + repotype='D', + ) + self.mirror = Mirror.objects.create( + repo=self.repo, + url='http://archive.ubuntu.com/ubuntu/dists/jammy/main/binary-amd64', + ) + + def test_mirror_str(self): + """Test Mirror __str__ method returns URL.""" + self.assertEqual(str(self.mirror), self.mirror.url) + + def test_mirror_get_absolute_url(self): + """Test Mirror.get_absolute_url().""" + url = self.mirror.get_absolute_url() + self.assertIn(str(self.mirror.id), url) + + def test_mirror_enabled_default(self): + """Test Mirror enabled defaults to True.""" + mirror = Mirror.objects.create( + repo=self.repo, + url='http://example.com/repo', + ) + self.assertTrue(mirror.enabled) + + def test_mirror_refresh_default(self): + """Test Mirror refresh defaults to True.""" + mirror = Mirror.objects.create( + repo=self.repo, + url='http://example.com/repo2', + ) + self.assertTrue(mirror.refresh) + + def test_mirror_fail_count_default(self): + """Test Mirror fail_count defaults to 0.""" + mirror = Mirror.objects.create( + repo=self.repo, + url='http://example.com/repo3', + ) + self.assertEqual(mirror.fail_count, 0) + + def test_mirror_last_access_ok_default(self): + """Test Mirror last_access_ok defaults to False.""" + mirror = Mirror.objects.create( + repo=self.repo, + url='http://example.com/repo4', + ) + self.assertFalse(mirror.last_access_ok) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class MirrorPackageMethodTests(TestCase): + """Tests for MirrorPackage model.""" + + def setUp(self): + """Set up test data.""" + self.arch = MachineArchitecture.objects.create(name='x86_64') + self.pkg_arch = PackageArchitecture.objects.create(name='amd64') + self.repo = Repository.objects.create( + name='ubuntu-main', + arch=self.arch, + repotype='D', + ) + self.mirror = Mirror.objects.create( + repo=self.repo, + url='http://archive.ubuntu.com/ubuntu/dists/jammy/main/binary-amd64', + ) + self.pkg_name = PackageName.objects.create(name='nginx') + self.package = Package.objects.create( + name=self.pkg_name, arch=self.pkg_arch, + epoch='', version='1.18.0', release='1', packagetype='D' + ) + + def test_mirror_package_creation(self): + """Test creating a MirrorPackage relationship.""" + mp = MirrorPackage.objects.create( + mirror=self.mirror, + package=self.package, + enabled=True, + ) + self.assertEqual(mp.mirror, self.mirror) + self.assertEqual(mp.package, self.package) + self.assertTrue(mp.enabled) + + def test_mirror_package_enabled_default(self): + """Test MirrorPackage enabled defaults to True.""" + mp = MirrorPackage.objects.create( + mirror=self.mirror, + package=self.package, + ) + self.assertTrue(mp.enabled) + + def test_mirror_packages_relationship(self): + """Test Mirror.packages ManyToMany via MirrorPackage.""" + MirrorPackage.objects.create( + mirror=self.mirror, + package=self.package, + ) + self.assertIn(self.package, self.mirror.packages.all()) + + def test_package_repo_count(self): + """Test Package.repo_count() method.""" + MirrorPackage.objects.create( + mirror=self.mirror, + package=self.package, + ) + self.assertEqual(self.package.repo_count(), 1) diff --git a/security/tests/test_models.py b/security/tests/test_models.py new file mode 100644 index 00000000..c04acf89 --- /dev/null +++ b/security/tests/test_models.py @@ -0,0 +1,180 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from decimal import Decimal + +from django.test import TestCase, override_settings + +from security.models import CVE, CVSS, CWE, Reference + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class CVEMethodTests(TestCase): + """Tests for CVE model methods.""" + + def test_cve_creation(self): + """Test creating a CVE.""" + cve = CVE.objects.create(cve_id='CVE-2024-12345') + self.assertEqual(cve.cve_id, 'CVE-2024-12345') + + def test_cve_str(self): + """Test CVE __str__ method.""" + cve = CVE.objects.create(cve_id='CVE-2024-12345') + self.assertEqual(str(cve), 'CVE-2024-12345') + + def test_cve_get_absolute_url(self): + """Test CVE.get_absolute_url().""" + cve = CVE.objects.create(cve_id='CVE-2024-12345') + url = cve.get_absolute_url() + self.assertIn(str(cve.id), url) + + def test_cve_unique_id(self): + """Test CVE cve_id is unique.""" + CVE.objects.create(cve_id='CVE-2024-12345') + from django.db import IntegrityError + with self.assertRaises(IntegrityError): + CVE.objects.create(cve_id='CVE-2024-12345') + + def test_cve_with_cvss_score(self): + """Test CVE with associated CVSS scores.""" + cve = CVE.objects.create(cve_id='CVE-2024-99999') + score = CVSS.objects.create( + version=Decimal('3.1'), + vector_string='CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', + score=Decimal('9.8'), + severity='CRITICAL', + ) + cve.cvss_scores.add(score) + self.assertIn(score, cve.cvss_scores.all()) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class CWEMethodTests(TestCase): + """Tests for CWE model methods.""" + + def test_cwe_creation(self): + """Test creating a CWE.""" + cwe = CWE.objects.create(cwe_id='CWE-79', name='Cross-site Scripting') + self.assertEqual(cwe.cwe_id, 'CWE-79') + + def test_cwe_str(self): + """Test CWE __str__ method.""" + cwe = CWE.objects.create(cwe_id='CWE-79', name='Cross-site Scripting') + self.assertEqual(str(cwe), 'CWE-79 - Cross-site Scripting') + + def test_cwe_get_absolute_url(self): + """Test CWE.get_absolute_url().""" + cwe = CWE.objects.create(cwe_id='CWE-79', name='XSS') + url = cwe.get_absolute_url() + self.assertIn('CWE-79', url) + + def test_cwe_unique_id(self): + """Test CWE cwe_id is unique.""" + CWE.objects.create(cwe_id='CWE-79', name='XSS') + from django.db import IntegrityError + with self.assertRaises(IntegrityError): + CWE.objects.create(cwe_id='CWE-79', name='Different name') + + def test_cwe_int_id_property(self): + """Test CWE.int_id property extracts numeric part.""" + cwe = CWE.objects.create(cwe_id='CWE-79', name='XSS') + self.assertEqual(cwe.int_id, 79) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class CVSSMethodTests(TestCase): + """Tests for CVSS model methods.""" + + def test_cvss_creation(self): + """Test creating a CVSS.""" + score = CVSS.objects.create( + version=Decimal('3.1'), + vector_string='CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', + score=Decimal('9.8'), + severity='CRITICAL', + ) + self.assertEqual(score.version, Decimal('3.1')) + self.assertEqual(score.score, Decimal('9.8')) + + def test_cvss_str(self): + """Test CVSS __str__ method.""" + score = CVSS.objects.create( + version=Decimal('3.1'), + vector_string='CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', + score=Decimal('9.8'), + severity='CRITICAL', + ) + str_repr = str(score) + self.assertIn('9.8', str_repr) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ReferenceMethodTests(TestCase): + """Tests for Reference model methods.""" + + def test_reference_creation(self): + """Test creating a Reference.""" + ref = Reference.objects.create( + url='https://nvd.nist.gov/vuln/detail/CVE-2024-12345', + ref_type='NVD', + ) + self.assertEqual(ref.ref_type, 'NVD') + + def test_reference_str(self): + """Test Reference __str__ method.""" + ref = Reference.objects.create( + url='https://example.com/advisory', + ref_type='VENDOR', + ) + str_repr = str(ref) + self.assertIn('example.com', str_repr) + + def test_reference_unique_together(self): + """Test Reference unique_together constraint.""" + Reference.objects.create( + url='https://example.com/advisory', + ref_type='VENDOR', + ) + from django.db import IntegrityError + with self.assertRaises(IntegrityError): + Reference.objects.create( + url='https://example.com/advisory', + ref_type='VENDOR', + ) + + def test_reference_same_url_different_type(self): + """Test same URL with different ref_type is allowed.""" + Reference.objects.create( + url='https://example.com/advisory', + ref_type='VENDOR', + ) + ref2 = Reference.objects.create( + url='https://example.com/advisory', + ref_type='ADVISORY', + ) + self.assertEqual(ref2.ref_type, 'ADVISORY') diff --git a/util/tests/__init__.py b/util/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/util/tests/test_commands.py b/util/tests/test_commands.py new file mode 100644 index 00000000..45754918 --- /dev/null +++ b/util/tests/test_commands.py @@ -0,0 +1,176 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from io import StringIO + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase, override_settings +from rest_framework_api_key.models import APIKey + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class CreateApiKeyCommandTests(TestCase): + """Tests for create_api_key management command.""" + + def test_create_api_key_creates_key(self): + """Test create_api_key creates a new API key.""" + out = StringIO() + call_command('create_api_key', 'test-client', stdout=out) + + # Key should be created + self.assertEqual(APIKey.objects.count(), 1) + api_key = APIKey.objects.first() + self.assertEqual(api_key.name, 'test-client') + + def test_create_api_key_outputs_key(self): + """Test create_api_key outputs the generated key.""" + out = StringIO() + call_command('create_api_key', 'output-test', stdout=out) + + output = out.getvalue() + self.assertIn('output-test', output) + self.assertIn('Key:', output) + self.assertIn('api_key=', output) + + def test_create_api_key_multiple_keys(self): + """Test creating multiple API keys.""" + call_command('create_api_key', 'client1', stdout=StringIO()) + call_command('create_api_key', 'client2', stdout=StringIO()) + call_command('create_api_key', 'client3', stdout=StringIO()) + + self.assertEqual(APIKey.objects.count(), 3) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ListApiKeysCommandTests(TestCase): + """Tests for list_api_keys management command.""" + + def test_list_api_keys_empty(self): + """Test list_api_keys with no keys.""" + out = StringIO() + call_command('list_api_keys', stdout=out) + + output = out.getvalue() + self.assertIn('No API keys found', output) + + def test_list_api_keys_shows_keys(self): + """Test list_api_keys shows created keys.""" + APIKey.objects.create_key(name='test-key-1') + APIKey.objects.create_key(name='test-key-2') + + out = StringIO() + call_command('list_api_keys', stdout=out) + + output = out.getvalue() + self.assertIn('test-key-1', output) + self.assertIn('test-key-2', output) + self.assertIn('Total: 2', output) + + def test_list_api_keys_hides_revoked(self): + """Test list_api_keys hides revoked keys by default.""" + api_key, _ = APIKey.objects.create_key(name='active-key') + revoked_key, _ = APIKey.objects.create_key(name='revoked-key') + revoked_key.revoked = True + revoked_key.save() + + out = StringIO() + call_command('list_api_keys', stdout=out) + + output = out.getvalue() + self.assertIn('active-key', output) + self.assertNotIn('revoked-key', output) + self.assertIn('Total: 1', output) + + def test_list_api_keys_all_shows_revoked(self): + """Test list_api_keys --all shows revoked keys.""" + api_key, _ = APIKey.objects.create_key(name='active-key') + revoked_key, _ = APIKey.objects.create_key(name='revoked-key') + revoked_key.revoked = True + revoked_key.save() + + out = StringIO() + call_command('list_api_keys', '--all', stdout=out) + + output = out.getvalue() + self.assertIn('active-key', output) + self.assertIn('revoked-key', output) + self.assertIn('Total: 2', output) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class RevokeApiKeyCommandTests(TestCase): + """Tests for revoke_api_key management command.""" + + def test_revoke_api_key_by_name(self): + """Test revoking API key by name.""" + api_key, _ = APIKey.objects.create_key(name='revoke-test') + + out = StringIO() + call_command('revoke_api_key', 'revoke-test', stdout=out) + + api_key.refresh_from_db() + self.assertTrue(api_key.revoked) + + def test_revoke_api_key_by_prefix(self): + """Test revoking API key by prefix.""" + api_key, _ = APIKey.objects.create_key(name='prefix-test') + prefix = api_key.prefix + + out = StringIO() + call_command('revoke_api_key', prefix, stdout=out) + + api_key.refresh_from_db() + self.assertTrue(api_key.revoked) + + def test_revoke_api_key_not_found(self): + """Test revoking non-existent key raises error.""" + with self.assertRaises(CommandError) as context: + call_command('revoke_api_key', 'nonexistent') + + self.assertIn('No API key found', str(context.exception)) + + def test_revoke_api_key_already_revoked(self): + """Test revoking already revoked key shows warning.""" + api_key, _ = APIKey.objects.create_key(name='already-revoked') + api_key.revoked = True + api_key.save() + + out = StringIO() + call_command('revoke_api_key', 'already-revoked', stdout=out) + + output = out.getvalue() + self.assertIn('already revoked', output) + + def test_revoke_api_key_delete(self): + """Test --delete permanently removes the key.""" + api_key, _ = APIKey.objects.create_key(name='delete-test') + + out = StringIO() + call_command('revoke_api_key', 'delete-test', '--delete', stdout=out) + + self.assertEqual(APIKey.objects.filter(name='delete-test').count(), 0) + output = out.getvalue() + self.assertIn('Permanently deleted', output) diff --git a/util/tests/test_utils.py b/util/tests/test_utils.py new file mode 100644 index 00000000..4205150f --- /dev/null +++ b/util/tests/test_utils.py @@ -0,0 +1,261 @@ +# Copyright 2025 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +import gzip +import hashlib +from io import BytesIO +from unittest.mock import MagicMock + +from django.test import TestCase, override_settings + +from util import ( + Checksum, bunzip2, extract, get_checksum, get_md5, get_sha1, get_sha256, + get_sha512, gunzip, has_setting_of_type, is_epoch_time, response_is_valid, + sanitize_filter_params, tz_aware_datetime, +) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ChecksumTests(TestCase): + """Tests for checksum functions.""" + + def test_get_sha256(self): + """Test SHA256 hash generation.""" + data = b'test data for hashing' + expected = hashlib.sha256(data).hexdigest() + result = get_sha256(data) + self.assertEqual(result, expected) + + def test_get_sha1(self): + """Test SHA1 hash generation.""" + data = b'test data for hashing' + expected = hashlib.sha1(data).hexdigest() + result = get_sha1(data) + self.assertEqual(result, expected) + + def test_get_sha512(self): + """Test SHA512 hash generation.""" + data = b'test data for hashing' + expected = hashlib.sha512(data).hexdigest() + result = get_sha512(data) + self.assertEqual(result, expected) + + def test_get_md5(self): + """Test MD5 hash generation.""" + data = b'test data for hashing' + expected = hashlib.md5(data).hexdigest() + result = get_md5(data) + self.assertEqual(result, expected) + + def test_get_checksum_sha256(self): + """Test get_checksum with sha256.""" + data = b'test data' + expected = hashlib.sha256(data).hexdigest() + result = get_checksum(data, Checksum.sha256) + self.assertEqual(result, expected) + + def test_get_checksum_sha1(self): + """Test get_checksum with sha1.""" + data = b'test data' + expected = hashlib.sha1(data).hexdigest() + result = get_checksum(data, Checksum.sha1) + self.assertEqual(result, expected) + + def test_get_checksum_md5(self): + """Test get_checksum with md5.""" + data = b'test data' + expected = hashlib.md5(data).hexdigest() + result = get_checksum(data, Checksum.md5) + self.assertEqual(result, expected) + + def test_get_checksum_sha512(self): + """Test get_checksum with sha512.""" + data = b'test data' + expected = hashlib.sha512(data).hexdigest() + result = get_checksum(data, Checksum.sha512) + self.assertEqual(result, expected) + + def test_get_checksum_empty_data(self): + """Test checksum of empty data.""" + data = b'' + result = get_sha256(data) + expected = hashlib.sha256(b'').hexdigest() + self.assertEqual(result, expected) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class CompressionTests(TestCase): + """Tests for compression/decompression functions.""" + + def test_gunzip_valid_data(self): + """Test gunzip with valid gzipped data.""" + original = b'Hello, World! This is test data.' + buf = BytesIO() + with gzip.GzipFile(fileobj=buf, mode='wb') as f: + f.write(original) + compressed = buf.getvalue() + + result = gunzip(compressed) + self.assertEqual(result, original) + + def test_gunzip_invalid_data(self): + """Test gunzip with invalid data returns None.""" + result = gunzip(b'not gzipped data') + self.assertIsNone(result) + + def test_bunzip2_invalid_data(self): + """Test bunzip2 with invalid data returns None.""" + result = bunzip2(b'not bzip2 data') + self.assertIsNone(result) + + def test_extract_gzip(self): + """Test extract with gzip format.""" + original = b'test content' + buf = BytesIO() + with gzip.GzipFile(fileobj=buf, mode='wb') as f: + f.write(original) + compressed = buf.getvalue() + + result = extract(compressed, 'gz') + self.assertEqual(result, original) + + def test_extract_unknown_format(self): + """Test extract with unknown format returns original.""" + data = b'unchanged data' + result = extract(data, 'unknown') + self.assertEqual(result, data) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class URLFetchTests(TestCase): + """Tests for URL fetching functions.""" + + def test_response_is_valid_200(self): + """Test response_is_valid with 200 status.""" + mock_response = MagicMock() + mock_response.ok = True + self.assertTrue(response_is_valid(mock_response)) + + def test_response_is_valid_error(self): + """Test response_is_valid with error status.""" + mock_response = MagicMock() + mock_response.ok = False + self.assertFalse(response_is_valid(mock_response)) + + def test_response_is_valid_none(self): + """Test response_is_valid with None.""" + self.assertFalse(response_is_valid(None)) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class SanitizeFilterParamsTests(TestCase): + """Tests for sanitize_filter_params function.""" + + def test_sanitize_simple_params(self): + """Test sanitizing simple filter params.""" + params = 'name=test&version=1.0' + result = sanitize_filter_params(params) + self.assertIn('name=test', result) + self.assertIn('version=1.0', result) + + def test_sanitize_empty_params(self): + """Test sanitizing empty params.""" + result = sanitize_filter_params('') + self.assertEqual(result, '') + + def test_sanitize_none_params(self): + """Test sanitizing None params.""" + result = sanitize_filter_params(None) + self.assertEqual(result, '') + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class DateTimeTests(TestCase): + """Tests for datetime utility functions.""" + + def test_is_epoch_time_valid(self): + """Test is_epoch_time with valid epoch timestamp.""" + self.assertTrue(is_epoch_time(1704067200)) # 2024-01-01 00:00:00 + + def test_is_epoch_time_invalid_string(self): + """Test is_epoch_time with non-numeric string.""" + self.assertFalse(is_epoch_time('not-a-number')) + + def test_is_epoch_time_too_large(self): + """Test is_epoch_time with unreasonably large value.""" + self.assertFalse(is_epoch_time(99999999999999)) + + def test_is_epoch_time_negative(self): + """Test is_epoch_time with negative value.""" + self.assertFalse(is_epoch_time(-1)) + + def test_tz_aware_datetime_from_epoch(self): + """Test converting epoch timestamp to timezone-aware datetime.""" + epoch = 1704067200 # 2024-01-01 00:00:00 UTC + result = tz_aware_datetime(epoch) + self.assertIsNotNone(result.tzinfo) + + def test_tz_aware_datetime_from_string(self): + """Test converting string datetime to timezone-aware.""" + date_str = '2024-01-01T12:00:00' + result = tz_aware_datetime(date_str) + self.assertIsNotNone(result.tzinfo) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class SettingsTests(TestCase): + """Tests for settings utility functions.""" + + @override_settings(TEST_STRING_SETTING='test_value') + def test_has_setting_of_type_string_exists(self): + """Test has_setting_of_type with existing string setting.""" + result = has_setting_of_type('TEST_STRING_SETTING', str) + self.assertTrue(result) + + def test_has_setting_of_type_missing(self): + """Test has_setting_of_type with missing setting.""" + result = has_setting_of_type('NONEXISTENT_SETTING_XYZ', str) + self.assertFalse(result) + + @override_settings(TEST_INT_SETTING=42) + def test_has_setting_of_type_wrong_type(self): + """Test has_setting_of_type with wrong type.""" + result = has_setting_of_type('TEST_INT_SETTING', str) + self.assertFalse(result) + + @override_settings(TEST_BOOL_SETTING=True) + def test_has_setting_of_type_bool(self): + """Test has_setting_of_type with bool setting.""" + result = has_setting_of_type('TEST_BOOL_SETTING', bool) + self.assertTrue(result)