diff --git a/.github/workflows/check-test-names.yml b/.github/workflows/check-test-names.yml index c3fd0dbb9..decacf76f 100644 --- a/.github/workflows/check-test-names.yml +++ b/.github/workflows/check-test-names.yml @@ -11,11 +11,19 @@ jobs: runs-on: ubuntu-latest name: Check test naming convention steps: + # This job runs on pull_request_target, so it has a write token and access + # to secrets even when the pull request comes from a fork. Nothing from the + # pull request may be checked out or executed here: no build, no install, + # no running its tests or scripts. Leaving this checkout bare keeps us on + # the default branch, and that is the only reason it is safe to run + # check_test_names.py below. Adding a ref: pointing at the pull request + # head would hand its author our credentials. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false + # Fetch only. The head is read as data by git diff, never checked out. - name: Fetch PR head run: git fetch origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/head" env: @@ -23,8 +31,11 @@ jobs: - name: Check new test names id: check + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} run: | - merge_base=$(git merge-base HEAD refs/remotes/pull/head) + # Name the base branch explicitly; HEAD is not it + merge_base=$(git merge-base "origin/$BASE_REF" refs/remotes/pull/head) output=$(git diff "$merge_base" refs/remotes/pull/head --unified=0 | python3 checks/check_test_names.py --stdin --format markdown 2>&1) && has_violations=false || has_violations=true echo "$output" { diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 1a35dbe9f..a3e22f294 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -80,7 +80,7 @@ jobs: images: ${{ matrix.image }} - name: Build and push backend Docker image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: docker/Dockerfile diff --git a/.github/workflows/publish-test-results.yml b/.github/workflows/publish-test-results.yml index 89034727d..235dc0b28 100644 --- a/.github/workflows/publish-test-results.yml +++ b/.github/workflows/publish-test-results.yml @@ -34,7 +34,7 @@ jobs: done - name: "Publish test results" - uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2.24.0 + uses: EnricoMi/publish-unit-test-result-action@7622c5bf3d112a72ab4fa3951c2aa5723d8ab92d # v2.25.0 with: commit: ${{ github.event.workflow_run.head_sha }} check_name: "Test results" diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index fd38b6075..e1de5fc15 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -88,7 +88,7 @@ jobs: python -VV python -m site python -m pip install --upgrade pip "setuptools<46" wheel - python -m pip install --upgrade virtualenv "tox>=4" tox-gh-actions coverage + python -m pip install --upgrade virtualenv "tox>=4.22" tox-gh-actions coverage - name: Set up PostgreSQL uses: ankane/setup-postgres@e9cd5bc1babecb0e441a640646d782056dbb67f5 # v1 (has no specific tags) @@ -112,7 +112,7 @@ jobs: - name: Upload test reports (${{ matrix.python-version }}) if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-reports-${{ matrix.python-version }} path: | @@ -120,7 +120,7 @@ jobs: - name: Upload coverage reports to Codecov if: github.repository_owner == 'Uninett' - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/trigger-argus-docker.yml b/.github/workflows/trigger-argus-docker.yml index 763efd6e5..da458a2d6 100644 --- a/.github/workflows/trigger-argus-docker.yml +++ b/.github/workflows/trigger-argus-docker.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Generate an installation access token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index b19588dda..2a5b39480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,46 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang +## [2.10.2] - 2026-10-22 + +Mini-release. The most important bit is fixing a dependency problem +that affects the task queue. The most visible bit is "Level" in the +filter box should no longer magically flip to the middle position for +no reason. + +Do upgrade if: + +- using Argus to send notifications +- using the planned maintenance subsystem + +### Added + +- Added options to set base url for source created with management + command `create_source` + ([#2081](https://github.com/Uninett/Argus/issues/2081)) + +### Changed + +- Altered helper function `get_version()` so that it either returns + a valid version string for Argus, or "unknown", and nothing else. + ([#2061](https://github.com/Uninett/Argus/issues/2061)) +- We now explicitly depend on "django-tasks" since how "django-tasks-db" + depends on "django-tasks" has changed. We cannot do it the new way, by + depending on "django-tasks-db[compat]", since that conflicts with + Django 6.1, which we still want to be able to test on. + +### Fixed + +- Fixed a problem with `maxlevel` in the filter box caused by the + default of an HTML range-input defaulting to the middle value if no + value is given. It now defaults to the maximum level, "5", as it + should. This should cut down on maxlevel suddenly flipping to "3" for + apparently no reason. + ([#2072](https://github.com/Uninett/Argus/issues/2072)) +- Fixed a dependency issue with the most recent "django-tasks-db", by + always depending directly on "django-tasks". + + ## [2.10.1] - 2026-10-21 Security release! Install this version *after* having made sure that the OS diff --git a/changelog.d/+emudhra.changed.md b/changelog.d/+emudhra.changed.md deleted file mode 100644 index 5032b7d7a..000000000 --- a/changelog.d/+emudhra.changed.md +++ /dev/null @@ -1 +0,0 @@ -Security-release upgrading bundled TLS root certificates in the subdependency "certifi". diff --git a/docs/development/management-commands.rst b/docs/development/management-commands.rst index df8e5952a..043e6b643 100644 --- a/docs/development/management-commands.rst +++ b/docs/development/management-commands.rst @@ -259,6 +259,12 @@ To add a custom source system type, instead of the default `argus`, add the $ python manage.py create_source -t "Custom type" +To add a base_url add the `-u` flag to the command as such: + + .. code:: console + + $ python manage.py create_source -u "https://example.com" + .. _check-token-expiry: diff --git a/pyproject.toml b/pyproject.toml index 2bde4f575..175822ee2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "dj-database-url>=0.5.0", "django-cors-headers>=3.2", "django-filter", + "django-tasks", "django-tasks-db", "django-phonenumber-field[phonenumberslite]", "djangorestframework>=3.14", @@ -58,7 +59,7 @@ dynamic = ["version"] "Homepage" = "https://github.com/Uninett/Argus" [project.optional-dependencies] -apprise = ["apprise>=1.9.6"] # When updating version requirement also update it in dependency group "all-plugins" +apprise = ["apprise>=1.9.6"] # When updating version requirement also update it in the dependency groups "all-plugins" and "test" htmx = [] # for outdated install procedures docs = ["sphinx>=2.2.0"] dev = [ @@ -72,7 +73,7 @@ dev = [ "python-dotenv", "towncrier", "werkzeug", - "tox>=4", + "tox>=4.22", "build", # for debugging builds/installs ] jsonlogging = ["python-json-logger"] @@ -82,6 +83,11 @@ all-plugins = [ "argus-ticket-github", "argus-ticket-gitlab", "argus-ticket-jira", "argus-ticket-rt", "argus-notification-msteams", "apprise>=1.9.6" ] +test = [ + "coverage", + "unittest-xml-reporting", + "apprise>=1.9.6", +] [tool.hatch.version] source = "versioningit" diff --git a/requirements-django52.txt b/requirements-django52.txt index b15460432..5f1e4e8ce 100644 --- a/requirements-django52.txt +++ b/requirements-django52.txt @@ -72,8 +72,8 @@ django-stubs-ext==5.2.9 # django-tasks # django-tasks-db django-tasks==0.12.0 - # via django-tasks-db -django-tasks-db==0.12.0 + # via argus-server (pyproject.toml) +django-tasks-db==0.13.0 # via argus-server (pyproject.toml) django-widget-tweaks==1.5.0 # via argus-server (pyproject.toml) diff --git a/requirements-django61.txt b/requirements-django61.txt index 5d34e3d81..9a23562cd 100644 --- a/requirements-django61.txt +++ b/requirements-django61.txt @@ -74,8 +74,8 @@ django-stubs-ext==6.0.9 # django-tasks # django-tasks-db django-tasks==0.12.0 - # via django-tasks-db -django-tasks-db==0.12.0 + # via argus-server (pyproject.toml) +django-tasks-db==0.13.0 # via argus-server (pyproject.toml) django-widget-tweaks==1.5.0 # via argus-server (pyproject.toml) diff --git a/requirements.txt b/requirements.txt index 25de01c8f..7c2a38768 100644 --- a/requirements.txt +++ b/requirements.txt @@ -72,8 +72,8 @@ django-stubs-ext==5.2.9 # django-tasks # django-tasks-db django-tasks==0.12.0 - # via django-tasks-db -django-tasks-db==0.12.0 + # via argus-server (pyproject.toml) +django-tasks-db==0.13.0 # via argus-server (pyproject.toml) django-widget-tweaks==1.5.0 # via argus-server (pyproject.toml) diff --git a/src/argus/htmx/incident/filter.py b/src/argus/htmx/incident/filter.py index bc70742c2..b9272c463 100644 --- a/src/argus/htmx/incident/filter.py +++ b/src/argus/htmx/incident/filter.py @@ -3,6 +3,7 @@ from django import forms from django.contrib import messages from django.urls import reverse +from django.utils.datastructures import MultiValueDict from django.views.generic import ListView from argus.auth.utils import get_preference, get_preference_obj @@ -141,13 +142,20 @@ class IncidentFilterForm(forms.Form): "special_filters": [], } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, data=None, **kwargs): + if not data: + data = MultiValueDict() + if "maxlevel" not in data: + good_data = data.copy() + good_data["maxlevel"] = self.DEFAULT_VALUES["maxlevel"] + data = good_data + super().__init__(data, **kwargs) + # mollify tests partial_get = reverse("htmx:incident-filter") self._init_source_field(partial_get) - self._init_tag_field(partial_get, *args, **kwargs) + self._init_tag_field(partial_get, data) self.fields["source_types"].widget.partial_get = partial_get source_type_choices = SourceSystemType.objects.order_by("name").values_list("name", "name") @@ -160,13 +168,13 @@ def __init__(self, *args, **kwargs): self.fields["special_filters"].widget.partial_get = partial_get self.fields["special_filters"].choices = self.SPECIAL_FILTER_CHOICES - def _init_tag_field(self, partial_get, *args, **kwargs): + def _init_tag_field(self, partial_get, data=None): """ Initializes the 'tags' field widget and choices as key=value strings, and dynamically adds submitted tags. """ self.fields["tags"].widget.partial_get = partial_get self.fields["tags"].widget.extra["search_url"] = reverse("htmx:search-tags") - query_dict = args[0] if args else None + query_dict = data if not query_dict: self.fields["tags"].choices = [] return diff --git a/src/argus/incident/management/commands/create_source.py b/src/argus/incident/management/commands/create_source.py index 7971c908f..531181156 100644 --- a/src/argus/incident/management/commands/create_source.py +++ b/src/argus/incident/management/commands/create_source.py @@ -14,13 +14,15 @@ def add_arguments(self, parser): parser.add_argument( "-t", "--source-type", type=str, help="Create/use the source type instead of argus" ) + parser.add_argument("-u", "--base-url", type=str, help="Add this base-url to the created source") def handle(self, *args, **options): source = options["source"] source_type = options.get("source_type") or "argus" + base_url = options.get("base_url") or "" if SourceSystem.objects.filter(name=source, type__name=source_type).exists(): return with transaction.atomic(): user = SourceUserFactory(username=source) sst = SourceSystemTypeFactory(name=source_type.lower()) - SourceSystemFactory(user=user, name=source, type=sst) + SourceSystemFactory(user=user, name=source, type=sst, base_url=base_url) diff --git a/src/argus/site/settings/test_CI.py b/src/argus/site/settings/test_CI.py index d21719a9f..a3fd42d50 100644 --- a/src/argus/site/settings/test_CI.py +++ b/src/argus/site/settings/test_CI.py @@ -34,16 +34,6 @@ pass MIDDLEWARE = middleware -# Hack to bypass Apprise check for running tests. -# Remove as soon as the real Apprise library is included in the test environment. -try: - import apprise # noqa: F401 -except ImportError: - import sys - from unittest.mock import MagicMock - - sys.modules["apprise"] = MagicMock() - # Paths to plugins MEDIA_PLUGINS = [ "argus.notificationprofile.media.email.EmailNotification", diff --git a/src/argus/site/views.py b/src/argus/site/views.py index 82e22ab61..680099bb4 100644 --- a/src/argus/site/views.py +++ b/src/argus/site/views.py @@ -1,5 +1,7 @@ import logging -from importlib.metadata import version, PackageNotFoundError +from importlib.metadata import PackageNotFoundError, version as import_version + +from packaging.version import InvalidVersion, parse as parse_version from django.conf import settings from django.http import ( @@ -25,6 +27,14 @@ def get_version(): + version = _get_version() + try: + return str(parse_version(version)) + except InvalidVersion: + return "unknown" + + +def _get_version(): try: from argus.version import __version__ @@ -32,10 +42,10 @@ def get_version(): except (ModuleNotFoundError, ImportError): pass try: - return version("argus-server") - except PackageNotFoundError as e: - return str(e) - return "version not found" + return import_version("argus-server") + except PackageNotFoundError: + pass + return "unknown" # HTML diff --git a/tests/htmx/incident/test_customization.py b/tests/htmx/incident/test_customization.py index c05a1455c..34d95ca1b 100644 --- a/tests/htmx/incident/test_customization.py +++ b/tests/htmx/incident/test_customization.py @@ -7,7 +7,7 @@ class TestGetIncidentTableColumns(test.TestCase): @test.override_settings(INCIDENT_TABLE_COLUMNS=["status"]) def test_it_should_return_correct_builtin_columns_based_on_name(self): columns = get_incident_table_columns("default") - assert columns[0].name == "status" + self.assertEqual(columns[0].name, "status") @test.override_settings(INCIDENT_TABLE_COLUMNS=["not_real"]) def test_it_should_raise_exception_if_settings_contain_name_for_nonexisting_builtin(self): @@ -25,4 +25,4 @@ def test_it_should_raise_exception_if_settings_contain_name_for_nonexisting_buil ) def test_it_should_return_custom_column(self): columns = get_incident_table_columns("default") - assert columns[0].name == "custom_column" + self.assertEqual(columns[0].name, "custom_column") diff --git a/tests/htmx/incident/test_filter.py b/tests/htmx/incident/test_filter.py index 13ff0e2cb..45752ab99 100644 --- a/tests/htmx/incident/test_filter.py +++ b/tests/htmx/incident/test_filter.py @@ -37,42 +37,42 @@ def tearDown(self): def test_if_form_is_valid_then_filterblob_should_contain_correct_open_value(self): filterblob = self.valid_form.to_filterblob() - assert filterblob["open"] is True + self.assertTrue(filterblob["open"]) def test_if_form_is_valid_then_filterblob_should_contain_correct_acked_value(self): filterblob = self.valid_form.to_filterblob() - assert filterblob["acked"] is True + self.assertTrue(filterblob["acked"]) def test_if_form_is_valid_then_filterblob_should_contain_correct_sourcesystemids_value(self): filterblob = self.valid_form.to_filterblob() - assert len(filterblob["sourceSystemIds"]) == 1 + self.assertEqual(len(filterblob["sourceSystemIds"]), 1) # sourceSystemIds seem to be represented as a list of strings sometimes - assert int(filterblob["sourceSystemIds"][0]) == int(self.valid_field_values["sourceSystemIds"][0]) + self.assertEqual(int(filterblob["sourceSystemIds"][0]), int(self.valid_field_values["sourceSystemIds"][0])) def test_if_form_is_valid_then_filterblob_should_contain_correct_sourcesystem_types_value(self): filterblob = self.valid_form.to_filterblob() - assert len(filterblob["source_types"]) == 1 + self.assertEqual(len(filterblob["source_types"]), 1) def test_if_form_is_valid_then_filterblob_should_contain_correct_tags_value(self): filterblob = self.valid_form.to_filterblob() - assert filterblob["tags"] == self.valid_field_values["tags"] + self.assertEqual(filterblob["tags"], self.valid_field_values["tags"]) def test_if_form_is_not_valid_then_to_filterblob_should_return_an_empty_dict(self): form = IncidentFilterForm({"tags": "invalidtags"}) filterblob = form.to_filterblob() - assert filterblob == {} + self.assertEqual(filterblob, {}) def test_lack_of_tags_should_not_cause_an_error(self): """Tests clean_tags via is_valid""" form = IncidentFilterForm({}) - assert form.is_valid() - assert "tags" not in form.errors + self.assertTrue(form.is_valid()) + self.assertNotIn("tags", form.errors) def test_if_tags_have_wrong_format_then_it_should_create_an_error(self): """Tests clean_tags via is_valid""" form = IncidentFilterForm({"tags": "invalidtags"}) - assert not form.is_valid() - assert "tags" in form.errors + self.assertFalse(form.is_valid()) + self.assertIn("tags", form.errors) class TestIncidentListFilter(TestCase): @@ -107,36 +107,36 @@ def tearDown(self): def test_nonexistent_filter_should_return_unfiltered_queryset_and_repair_session(self): self.request.session["selected_filter"] = -1 _, qs = incident_list_filter(self.request, self.qs) - self.assertEqual(qs, self.qs) + self.assertQuerySetEqual(qs, self.qs) self.assertNotIn("selected_filter", self.request.session) def test_valid_request_should_return_filtered_queryset(self): self.request.session["selected_filter"] = self.valid_filter.pk _, qs = incident_list_filter(self.request, self.qs) - assert self.incident in self.qs - assert self.incident not in qs + self.assertIn(self.incident, self.qs) + self.assertNotIn(self.incident, qs) def test_invalid_request_should_return_unfiltered_queryset(self): self.request.session["selected_filter"] = self.invalid_filter.pk _, qs = incident_list_filter(self.request, self.qs) - assert qs == self.qs + self.assertEqual(qs, self.qs) def test_valid_request_should_return_form_with_correct_values(self): self.request.session["selected_filter"] = self.valid_filter.pk form, _ = incident_list_filter(self.request, self.qs) - assert form.to_filterblob()["maxlevel"] == self.valid_filter.filter["maxlevel"] + self.assertEqual(form.to_filterblob()["maxlevel"], self.valid_filter.filter["maxlevel"]) def test_invalid_request_should_return_form_with_errors(self): self.request.session["selected_filter"] = self.invalid_filter.pk form, _ = incident_list_filter(self.request, self.qs) - assert form.errors + self.assertTrue(form.errors) def test_get_request_without_selected_filter_should_use_get_parameters_as_form_data(self): maxlevel = 3 self.request.session["selected_filter"] = None self.request.GET = QueryDict(f"tags=&maxlevel={maxlevel}") form, _ = incident_list_filter(self.request, self.qs) - assert form.to_filterblob()["maxlevel"] == maxlevel + self.assertEqual(form.to_filterblob()["maxlevel"], maxlevel) def test_when_saved_filter_is_selected_and_request_has_form_params_then_it_should_use_the_form_params(self): # The saved filter (maxlevel=1) would exclude the level-5 incident. Form params @@ -145,9 +145,12 @@ def test_when_saved_filter_is_selected_and_request_has_form_params_then_it_shoul self.request.session["selected_filter"] = self.valid_filter.pk self.request.GET = QueryDict("maxlevel=5&tags=") form, qs = incident_list_filter(self.request, self.qs) - assert form.is_valid() # guard against the wrong-reason pass: an invalid form filters nothing - assert form.to_filterblob()["maxlevel"] == 5 # form param won over saved maxlevel=1 - assert self.incident in qs # ...and the override actually reaches the queryset + # guard against the wrong-reason pass: an invalid form filters nothing + self.assertTrue(form.is_valid()) + # form param won over saved maxlevel=1 + self.assertEqual(form.to_filterblob()["maxlevel"], 5) + # ...and the override actually reaches the queryset + self.assertIn(self.incident, qs) def test_when_saved_filter_is_selected_and_request_has_no_form_params_then_it_should_use_the_saved_filter(self): # sort/sort_order are always present as hidden inputs but are not form params, @@ -155,8 +158,8 @@ def test_when_saved_filter_is_selected_and_request_has_no_form_params_then_it_sh self.request.session["selected_filter"] = self.valid_filter.pk self.request.GET = QueryDict("sort=start_time&sort_order=desc") form, qs = incident_list_filter(self.request, self.qs) - assert form.to_filterblob()["maxlevel"] == 1 # saved filter value, not overridden - assert self.incident not in qs # saved filter still filters the queryset + self.assertEqual(form.to_filterblob()["maxlevel"], 1) # saved filter value, not overridden + self.assertNotIn(self.incident, qs) # saved filter still filters the queryset def test_post_request_without_selected_filter_should_use_post_parameters_as_form_data(self): maxlevel = 3 @@ -165,7 +168,7 @@ def test_post_request_without_selected_filter_should_use_post_parameters_as_form SessionMiddleware(lambda x: x).process_request(request) MessageMiddleware(lambda x: x).process_request(request) form, _ = incident_list_filter(request, self.qs) - assert form.to_filterblob()["maxlevel"] == maxlevel + self.assertEqual(form.to_filterblob()["maxlevel"], maxlevel) class TestCreateNamedFilter(TestCase): @@ -184,17 +187,17 @@ def tearDown(self): def test_if_input_is_valid_it_should_return_a_filter(self): filter_name = "myfilter" _, filter_obj = create_named_filter(self.request, filter_name, self.filterblob) - assert isinstance(filter_obj, Filter) + self.assertTrue(isinstance(filter_obj, Filter)) def test_if_input_is_invalid_it_should_not_return_a_filter(self): invalid_name = "" _, filter_obj = create_named_filter(self.request, invalid_name, self.filterblob) - assert not filter_obj + self.assertFalse(filter_obj) def test_should_return_a_named_filter_form(self): filter_name = "myfilter" form, _ = create_named_filter(self.request, filter_name, self.filterblob) - assert isinstance(form, NamedFilterForm) + self.assertTrue(isinstance(form, NamedFilterForm)) class TestFilterPreference(TestCase): @@ -221,9 +224,9 @@ def test_use_empty_filter_uses_default_values(self): form, _ = incident_list_filter(request, self.qs, use_empty_filter=True) - assert form.is_valid() + self.assertTrue(form.is_valid()) filterblob = form.to_filterblob() - assert filterblob.get("maxlevel") == IncidentFilterForm.DEFAULT_VALUES["maxlevel"] + self.assertEqual(filterblob.get("maxlevel"), IncidentFilterForm.DEFAULT_VALUES["maxlevel"]) def test_stored_preference_is_loaded_when_no_url_params(self): """When no URL filter params, stored preference should be loaded""" @@ -236,7 +239,7 @@ def test_stored_preference_is_loaded_when_no_url_params(self): form, _ = incident_list_filter(request, self.qs) - assert form.to_filterblob().get("maxlevel") == 2 + self.assertEqual(form.to_filterblob().get("maxlevel"), 2) def test_empty_form_when_no_preference_and_no_url_params(self): """When no stored preference and no URL params, unbound empty form is used""" @@ -249,8 +252,10 @@ def test_empty_form_when_no_preference_and_no_url_params(self): form, _ = incident_list_filter(request, self.qs) - # An unbound form (None passed to constructor) is not bound - assert not form.is_bound + # In order to work around a problem in maxlevel caused by a quirk in + # the HTML inpout type range, maxlevel is set to 5 if missing from + # input. This means the form is never unbound. + self.assertTrue(form.is_bound) def test_invalid_form_with_get_params_shows_error_messages(self): """Invalid filter with GET params should add error messages""" @@ -259,8 +264,8 @@ def test_invalid_form_with_get_params_shows_error_messages(self): form, _ = incident_list_filter(request, self.qs) - assert not form.is_valid() - assert "tags" in form.errors + self.assertFalse(form.is_valid()) + self.assertIn("tags", form.errors) class TestCreateFilterView(TestCase): @@ -409,9 +414,9 @@ def test_search_tags_without_query_returns_empty_results(self): request = self.factory.get("/search-tags/") response = search_tags(request) - assert response.status_code == 200 + self.assertEqual(response.status_code, 200) data = json.loads(response.content) - assert data == {"results": []} + self.assertEqual(data, {"results": []}) def test_search_tags_with_key_value_query_filters_by_key_and_value(self): # Create test tags @@ -423,11 +428,11 @@ def test_search_tags_with_key_value_query_filters_by_key_and_value(self): request = self.factory.get("/search-tags/", {"q": query}) response = search_tags(request) - assert response.status_code == 200 + self.assertEqual(response.status_code, 200) data = json.loads(response.content) - assert len(data["results"]) == 1 - assert data["results"][0]["id"] == str(tag1) - assert data["results"][0]["text"] == str(tag1) + self.assertEqual(len(data["results"]), 1) + self.assertEqual(data["results"][0]["id"], str(tag1)) + self.assertEqual(data["results"][0]["text"], str(tag1)) def test_search_tags_with_key_only_query_filters_by_key(self): from argus.incident.models import Tag @@ -440,12 +445,12 @@ def test_search_tags_with_key_only_query_filters_by_key(self): request = self.factory.get("/search-tags/", {"q": "env"}) response = search_tags(request) - assert response.status_code == 200 + self.assertEqual(response.status_code, 200) data = json.loads(response.content) - assert len(data["results"]) == 2 + self.assertEqual(len(data["results"]), 2) tag_ids = [result["id"] for result in data["results"]] - assert str(tag1) in tag_ids - assert str(tag2) in tag_ids + self.assertIn(str(tag1), tag_ids) + self.assertIn(str(tag2), tag_ids) def test_search_tags_limits_results_to_20(self): from argus.incident.models import Tag @@ -457,9 +462,9 @@ def test_search_tags_limits_results_to_20(self): request = self.factory.get("/search-tags/", {"q": "test"}) response = search_tags(request) - assert response.status_code == 200 + self.assertEqual(response.status_code, 200) data = json.loads(response.content) - assert len(data["results"]) == 20 + self.assertEqual(len(data["results"]), 20) def test_search_tags_returns_correct_json_format(self): from argus.incident.models import Tag @@ -468,12 +473,12 @@ def test_search_tags_returns_correct_json_format(self): request = self.factory.get("/search-tags/", {"q": "service"}) response = search_tags(request) - assert response.status_code == 200 + self.assertEqual(response.status_code, 200) data = json.loads(response.content) - assert "results" in data - assert len(data["results"]) == 1 + self.assertIn("results", data) + self.assertEqual(len(data["results"]), 1) result = data["results"][0] - assert "id" in result - assert "text" in result - assert result["id"] == str(tag) - assert result["text"] == str(tag) + self.assertIn("id", result) + self.assertIn("text", result) + self.assertEqual(result["id"], str(tag)) + self.assertEqual(result["text"], str(tag)) diff --git a/tests/htmx/test_utils.py b/tests/htmx/test_utils.py index a6aef067d..25f852e08 100644 --- a/tests/htmx/test_utils.py +++ b/tests/htmx/test_utils.py @@ -36,22 +36,22 @@ def test_when_given_ids_that_exist_in_default_queryset_it_should_return_all_matc created_incidents = [StatelessIncidentFactory(source=self.source) for _ in range(5)] created_ids = [incident.id for incident in created_incidents] queryset, missing_ids = get_qs_for_incident_ids(created_ids) - assert set(created_incidents) == set(queryset) - assert len(missing_ids) == 0 + self.assertEqual(set(created_incidents), set(queryset)) + self.assertEqual(len(missing_ids), 0) def test_when_given_ids_that_exist_in_specified_queryset_it_should_return_all_matching_incidents(self): created_incidents = [StatelessIncidentFactory(source=self.source) for _ in range(5)] created_ids = [incident.id for incident in created_incidents] queryset, missing_ids = get_qs_for_incident_ids(created_ids, Incident.objects.filter(source=self.source)) - assert set(created_incidents) == set(queryset) - assert len(missing_ids) == 0 + self.assertEqual(set(created_incidents), set(queryset)) + self.assertEqual(len(missing_ids), 0) def test_when_given_ids_that_does_not_exist_in_queryset_it_should_include_them_in_missing_ids(self): created_incidents = [StatelessIncidentFactory(source=self.source) for _ in range(5)] created_ids = [incident.id for incident in created_incidents] queryset, missing_ids = get_qs_for_incident_ids(created_ids, Incident.objects.none()) - assert len(queryset) == 0 - assert set(created_ids) == missing_ids + self.assertEqual(len(queryset), 0) + self.assertEqual(set(created_ids), missing_ids) class TestBulkAckQueryset(test.TestCase): @@ -70,10 +70,10 @@ def test_incidents_in_queryset_should_be_acked(self): now = timezone.now() expiration = now + timedelta(hours=1) queryset = Incident.objects.filter(source=self.source) - assert set(queryset.not_acked()) == set(created_incidents) + self.assertEqual(set(queryset.not_acked()), set(created_incidents)) data = {"timestamp": now, "description": "test description", "expiration": expiration} bulk_ack_queryset(self.request, queryset, data) - assert set(queryset.acked()) == set(created_incidents) + self.assertEqual(set(queryset.acked()), set(created_incidents)) def test_should_return_acked_incidents(self): created_incidents = [StatefulIncidentFactory(source=self.source) for _ in range(5)] @@ -82,7 +82,7 @@ def test_should_return_acked_incidents(self): queryset = Incident.objects.filter(source=self.source) data = {"timestamp": now, "description": "test description", "expiration": expiration} acked_incidents = bulk_ack_queryset(self.request, queryset, data) - assert set(acked_incidents) == set(created_incidents) + self.assertEqual(set(acked_incidents), set(created_incidents)) class TestBulkCloseQueryset(test.TestCase): @@ -100,10 +100,10 @@ def test_incidents_in_queryset_should_be_closed(self): created_incidents = [StatefulIncidentFactory(source=self.source) for _ in range(5)] now = timezone.now() queryset = Incident.objects.filter(source=self.source) - assert set(queryset.open()) == set(created_incidents) + self.assertEqual(set(queryset.open()), set(created_incidents)) data = {"timestamp": now, "description": "test description"} bulk_close_queryset(self.request, queryset, data) - assert set(queryset.closed()) == set(created_incidents) + self.assertEqual(set(queryset.closed()), set(created_incidents)) def test_should_return_closed_incidents(self): created_incidents = [StatefulIncidentFactory(source=self.source) for _ in range(5)] @@ -111,7 +111,7 @@ def test_should_return_closed_incidents(self): queryset = Incident.objects.filter(source=self.source) data = {"timestamp": now, "description": "test description"} closed_incidents = bulk_close_queryset(self.request, queryset, data) - assert set(closed_incidents) == set(created_incidents) + self.assertEqual(set(closed_incidents), set(created_incidents)) class TestBulkReopenQueryset(test.TestCase): @@ -129,10 +129,10 @@ def test_incidents_in_queryset_should_be_reopened(self): now = timezone.now() created_incidents = [StatefulIncidentFactory(source=self.source, end_time=now) for _ in range(5)] queryset = Incident.objects.filter(source=self.source) - assert set(queryset.closed()) == set(created_incidents) + self.assertEqual(set(queryset.closed()), set(created_incidents)) data = {"timestamp": now, "description": "test description"} bulk_reopen_queryset(self.request, queryset, data) - assert set(queryset.open()) == set(created_incidents) + self.assertEqual(set(queryset.open()), set(created_incidents)) def test_should_return_reopened_incidents(self): now = timezone.now() @@ -140,7 +140,7 @@ def test_should_return_reopened_incidents(self): queryset = Incident.objects.filter(source=self.source) data = {"timestamp": now, "description": "test description"} reopened_incidents = bulk_reopen_queryset(self.request, queryset, data) - assert set(reopened_incidents) == set(created_incidents) + self.assertEqual(set(reopened_incidents), set(created_incidents)) class TestBulkChangeTicketUrlQueryset(test.TestCase): @@ -163,7 +163,7 @@ def test_ticket_url_for_incidents_in_queryset_should_be_changed_to_new_url(self) data = {"timestamp": now, "ticket_url": new_ticket_url} bulk_change_ticket_url_queryset(self.request, queryset, data) for incident in queryset: - assert incident.ticket_url == new_ticket_url + self.assertEqual(incident.ticket_url, new_ticket_url) def test_should_return_incidents_in_queryset(self): now = timezone.now() @@ -175,7 +175,7 @@ def test_should_return_incidents_in_queryset(self): queryset = Incident.objects.filter(source=self.source) data = {"timestamp": now, "ticket_url": new_ticket_url} returned_incidents = bulk_change_ticket_url_queryset(self.request, queryset, data) - assert set(returned_incidents) == set(created_incidents) + self.assertEqual(set(returned_incidents), set(created_incidents)) class TestSingleAutocreateTicketUrlQueryset(test.TestCase): @@ -199,7 +199,7 @@ def test_should_set_url_for_created_ticket(self): mock_plugin.create_ticket.return_value = mocked_url with patch("argus.incident.ticket.utils.get_autocreate_ticket_plugin", return_value=mock_plugin): incident = single_autocreate_ticket_url_queryset(self.request, queryset, data) - assert incident.ticket_url == initial_url + self.assertEqual(incident.ticket_url, initial_url) def test_should_not_update_url_if_incident_already_has_a_ticket_url(self): StatefulIncidentFactory(source=self.source, ticket_url="") @@ -210,7 +210,7 @@ def test_should_not_update_url_if_incident_already_has_a_ticket_url(self): mock_plugin.create_ticket.return_value = mocked_url with patch("argus.incident.ticket.utils.get_autocreate_ticket_plugin", return_value=mock_plugin): incident = single_autocreate_ticket_url_queryset(self.request, queryset, data) - assert incident.ticket_url == mocked_url + self.assertEqual(incident.ticket_url, mocked_url) def test_should_raise_exception_if_queryset_contains_more_than_one_result(self): [StatefulIncidentFactory(source=self.source) for _ in range(5)] diff --git a/tests/incident/management_commands/test_create_source.py b/tests/incident/management_commands/test_create_source.py index 487deb253..bfbd5b19b 100644 --- a/tests/incident/management_commands/test_create_source.py +++ b/tests/incident/management_commands/test_create_source.py @@ -35,6 +35,21 @@ def test_create_source_will_create_source_with_set_type(self): self.assertTrue(SourceSystem.objects.exclude(id__in=previous_source_pks).filter(type_id=source_type).exists()) + def test_create_source_will_create_source_with_set_base_url(self): + previous_source_pks = [source.id for source in SourceSystem.objects.all()] + source_name = "source name" + base_url = "https://example.com" + call_command("create_source", source_name, base_url=base_url) + + self.assertTrue(SourceSystem.objects.exclude(id__in=previous_source_pks).filter(base_url=base_url).exists()) + + def test_create_source_will_create_source_with_base_url_empty_string_if_not_set(self): + previous_source_pks = [source.id for source in SourceSystem.objects.all()] + source_name = "source name" + call_command("create_source", source_name) + + self.assertTrue(SourceSystem.objects.exclude(id__in=previous_source_pks).filter(base_url="").exists()) + def test_create_source_will_use_already_existing_source_system_type(self): previous_source_pks = [source.id for source in SourceSystem.objects.all()] source_name = "source name" diff --git a/tests/notificationprofile/destinations/test_apprise.py b/tests/notificationprofile/destinations/test_apprise.py index 17e88c87e..85ed7de57 100644 --- a/tests/notificationprofile/destinations/test_apprise.py +++ b/tests/notificationprofile/destinations/test_apprise.py @@ -1,6 +1,10 @@ +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import threading from unittest.mock import patch -from django.test import TestCase, tag +from django.conf import settings +from django.test import TestCase, override_settings, tag from rest_framework import status from rest_framework.test import APIClient, APIRequestFactory, APITestCase @@ -13,6 +17,9 @@ from argus.util.testing import connect_signals, disconnect_signals +# Destination configuration tests not involving Apprise itself + + @tag("integration") class AppriseDestinationConfigSerializerTests(TestCase): def setUp(self): @@ -236,8 +243,12 @@ def test_given_duplicate_urls_should_not_allow_creating_apprise_destination(self ) +# Tests for mocked Apprise send() + + @tag("integration") -class AppriseMediumBehaviorTests(TestCase): +@override_settings(SEND_NOTIFICATIONS=True) +class AppriseMediumMockedLibraryTests(TestCase): def setUp(self): disconnect_signals() self.user = PersonUserFactory() @@ -253,57 +264,145 @@ def tearDown(self): connect_signals() def test_given_no_destinations_should_return_false(self): - with self.settings(SEND_NOTIFICATIONS=True): - self.assertFalse(AppriseMedium.send(self.event, [])) - - def test_given_disabled_notifications_should_return_false(self): - with self.settings(SEND_NOTIFICATIONS=False): - self.assertFalse(AppriseMedium.send(self.event, [])) - - @patch("argus.notificationprofile.media.base.Apprise") - def test_given_single_destination_should_send_notification(self, mock_apprise): - instance = mock_apprise.return_value - instance.notify.return_value = True - - with self.settings(SEND_NOTIFICATIONS=True): - self.assertTrue(AppriseMedium.send(self.event, [self.destination])) - instance.add.assert_called_once_with("https://example.com/hook") + self.assertFalse(AppriseMedium.send(self.event, [])) + @override_settings(SEND_NOTIFICATIONS=False) @patch("argus.notificationprofile.media.base.Apprise") - def test_when_send_fails_should_return_false(self, mock_apprise): - instance = mock_apprise.return_value - instance.notify.return_value = False - - with self.settings(SEND_NOTIFICATIONS=True): - self.assertFalse(AppriseMedium.send(self.event, [self.destination])) - instance.add.assert_called_once_with("https://example.com/hook") + def test_given_disabled_notifications_should_return_false(self, mock_apprise): + self.assertFalse(AppriseMedium.send(self.event, [self.destination])) + mock_apprise.assert_not_called() @patch("argus.notificationprofile.media.base.Apprise", None) - def test_when_apprise_not_installed_then_it_should_log_error_and_return_false(self): - with self.settings(SEND_NOTIFICATIONS=True): - with self.assertLogs("argus.notificationprofile.media.base", level="ERROR") as cm: - result = AppriseMedium.send(self.event, [self.destination]) - self.assertFalse(result) - self.assertIn("not installed", cm.output[0]) + def test_when_apprise_not_installed_should_return_false(self): + self.assertFalse(AppriseMedium.send(self.event, [self.destination])) @patch("argus.notificationprofile.media.base.Apprise") def test_given_no_notify_type_notifier_should_be_called_without_notify_type(self, mock_apprise): - instance = mock_apprise.return_value - instance.notify.return_value = True + mock_apprise.return_value.notify.return_value = True - with self.settings(SEND_NOTIFICATIONS=True): - AppriseMedium.send(self.event, [self.destination]) + AppriseMedium.send(self.event, [self.destination]) - _, call_kwargs = instance.notify.call_args + _, call_kwargs = mock_apprise.return_value.notify.call_args self.assertNotIn("notify_type", call_kwargs) @patch("argus.notificationprofile.media.base.Apprise") def test_given_notify_type_notifier_should_be_called_with_notify_type(self, mock_apprise): - instance = mock_apprise.return_value - instance.notify.return_value = True + mock_apprise.return_value.notify.return_value = True - with self.settings(SEND_NOTIFICATIONS=True): - AppriseMedium.send(self.event, [self.destination], notify_type="warning") + AppriseMedium.send(self.event, [self.destination], notify_type="warning") - _, call_kwargs = instance.notify.call_args + _, call_kwargs = mock_apprise.return_value.notify.call_args self.assertEqual(call_kwargs["notify_type"], "warning") + + +# Tests using the real Apprise library, posting to a local webhook + + +class _CapturingRequestHandler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + self.server.payloads.append(json.loads(self.rfile.read(length))) + self.send_response(self.server.response_status) + self.end_headers() + + def log_message(self, *args): + pass + + +class LocalAppriseWebhook(ThreadingHTTPServer): + def __init__(self, response_status=200): + super().__init__(("127.0.0.1", 0), _CapturingRequestHandler) + self.payloads = [] + self.response_status = response_status + # Without a short poll interval shutdown() blocks for up to 0.5s per server + threading.Thread(target=self.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True).start() + + def stop(self): + self.shutdown() + self.server_close() + + @property + def url(self): + host, port = self.server_address[:2] + return f"json://{host}:{port}/hook" + + +@tag("integration") +@override_settings(SEND_NOTIFICATIONS=True) +class AppriseMediumRealLibraryTests(TestCase): + def setUp(self): + disconnect_signals() + self.user = PersonUserFactory() + self.incident = IncidentFactory() + self.event = EventFactory(incident=self.incident) + + def tearDown(self): + connect_signals() + + def _destination_for(self, url): + return DestinationConfigFactory( + user=self.user, + media=Media.objects.get(slug="apprise"), + settings={"destination_url": url}, + ) + + def _webhook_destination(self, response_status=200): + webhook = LocalAppriseWebhook(response_status) + self.addCleanup(webhook.stop) + return webhook, self._destination_for(webhook.url) + + def test_given_single_destination_should_post_notification_to_webhook(self): + webhook, destination = self._webhook_destination() + + self.assertTrue(AppriseMedium.send(self.event, [destination])) + + self.assertEqual(len(webhook.payloads), 1) + payload = webhook.payloads[0] + self.assertTrue(payload["title"].startswith(settings.NOTIFICATION_SUBJECT_PREFIX)) + self.assertIn(self.incident.description, payload["title"]) + self.assertIn(f"Status: {self.event.type}", payload["message"]) # the template uses the raw code + self.assertIn(f"Actor: {self.event.actor.username}", payload["message"]) + self.assertIn(self.incident.description, payload["message"]) + + def test_given_notify_type_should_post_it_as_the_payload_type(self): + webhook, destination = self._webhook_destination() + + AppriseMedium.send(self.event, [destination], notify_type="warning") + + self.assertEqual(webhook.payloads[0]["type"], "warning") + + def test_given_multiple_destinations_should_post_to_all_of_them(self): + webhook1, destination1 = self._webhook_destination() + webhook2, destination2 = self._webhook_destination() + + self.assertTrue(AppriseMedium.send(self.event, [destination1, destination2])) + + self.assertEqual(len(webhook1.payloads), 1) + self.assertEqual(len(webhook2.payloads), 1) + + def test_when_the_destination_errors_should_return_false(self): + webhook, destination = self._webhook_destination(response_status=500) + + self.assertFalse(AppriseMedium.send(self.event, [destination])) + + self.assertEqual(len(webhook.payloads), 1) + + def test_when_only_some_destinations_error_should_return_true(self): + good, good_destination = self._webhook_destination() + bad, bad_destination = self._webhook_destination(response_status=500) + + self.assertTrue(AppriseMedium.send(self.event, [good_destination, bad_destination])) + + self.assertEqual(len(good.payloads), 1) + self.assertEqual(len(bad.payloads), 1) + + def test_given_a_url_apprise_cannot_route_should_return_false(self): + destination = self._destination_for("https://example.com/hook") + + self.assertFalse(AppriseMedium.send(self.event, [destination])) + + def test_when_the_destination_refuses_the_connection_should_return_false(self): + webhook, destination = self._webhook_destination() + webhook.stop() + + self.assertFalse(AppriseMedium.send(self.event, [destination])) diff --git a/tox.ini b/tox.ini index f6e5a0788..ab9f6bdb4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,4 +1,6 @@ [tox] +# 4.22 is required for PEP 735 dependency groups +min_version = 4.22 envlist = clean py{310,311}-django{52} @@ -120,9 +122,10 @@ commands = [testenv] passenv = DATABASE_URL +dependency_groups = + test + deps = - coverage - unittest-xml-reporting -r requirements-django{env:DJANGO_VER}.txt setenv =