diff --git a/readthedocs/core/tests/test_operations_views.py b/readthedocs/core/tests/test_operations_views.py new file mode 100644 index 00000000000..79a2597cfad --- /dev/null +++ b/readthedocs/core/tests/test_operations_views.py @@ -0,0 +1,93 @@ +"""Tests for operations views.""" + +import django_dynamic_fixture as fixture +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse + + +class OperationsViewTest(TestCase): + """Test operations views for Sentry and New Relic.""" + + def setUp(self): + """Set up test users.""" + self.staff_user = fixture.get(User, username="staff", is_staff=True) + self.normal_user = fixture.get(User, username="normal", is_staff=False) + + def test_sentry_view_requires_authentication(self): + """Test that anonymous users cannot access Sentry operations view.""" + response = self.client.get(reverse("operations_sentry")) + # Should return 403 Forbidden for anonymous users + assert response.status_code == 403 + data = response.json() + assert "error" in data + + def test_sentry_view_requires_staff(self): + """Test that normal users cannot access Sentry operations view.""" + self.client.force_login(self.normal_user) + response = self.client.get(reverse("operations_sentry")) + # Should return 403 Forbidden + assert response.status_code == 403 + + def test_sentry_view_staff_access(self): + """Test that staff users can access Sentry operations view.""" + self.client.force_login(self.staff_user) + response = self.client.get(reverse("operations_sentry")) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["service"] == "sentry" + assert "sentry" in data["message"] + + def test_newrelic_view_requires_authentication(self): + """Test that anonymous users cannot access New Relic operations view.""" + response = self.client.get(reverse("operations_newrelic")) + # Should return 403 Forbidden for anonymous users + assert response.status_code == 403 + data = response.json() + assert "error" in data + + def test_newrelic_view_requires_staff(self): + """Test that normal users cannot access New Relic operations view.""" + self.client.force_login(self.normal_user) + response = self.client.get(reverse("operations_newrelic")) + # Should return 403 Forbidden + assert response.status_code == 403 + + def test_newrelic_view_staff_access(self): + """Test that staff users can access New Relic operations view.""" + self.client.force_login(self.staff_user) + response = self.client.get(reverse("operations_newrelic")) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["service"] == "newrelic" + assert "newrelic" in data["message"] + + def test_sentry_view_generates_log_messages(self): + """Test that Sentry operations view generates log messages.""" + self.client.force_login(self.staff_user) + with self.assertLogs("readthedocs.core.views", level="INFO") as logs: + response = self.client.get(reverse("operations_sentry")) + assert response.status_code == 200 + # Check that both info and error logs were generated + log_output = "\n".join(logs.output) + assert "sentry logging check" in log_output + assert "sentry error logging check" in log_output + # Check that both INFO and ERROR levels were used + assert any("INFO" in log for log in logs.output) + assert any("ERROR" in log for log in logs.output) + + def test_newrelic_view_generates_log_messages(self): + """Test that New Relic operations view generates log messages.""" + self.client.force_login(self.staff_user) + with self.assertLogs("readthedocs.core.views", level="INFO") as logs: + response = self.client.get(reverse("operations_newrelic")) + assert response.status_code == 200 + # Check that both info and error logs were generated + log_output = "\n".join(logs.output) + assert "newrelic logging check" in log_output + assert "newrelic error logging check" in log_output + # Check that both INFO and ERROR levels were used + assert any("INFO" in log for log in logs.output) + assert any("ERROR" in log for log in logs.output) diff --git a/readthedocs/core/views/__init__.py b/readthedocs/core/views/__init__.py index 368bb6851a0..0dd677bb242 100644 --- a/readthedocs/core/views/__init__.py +++ b/readthedocs/core/views/__init__.py @@ -7,6 +7,7 @@ import structlog from django.conf import settings +from django.contrib.auth.mixins import UserPassesTestMixin from django.http import Http404 from django.http import JsonResponse from django.shortcuts import redirect @@ -168,3 +169,67 @@ def do_not_track(request): }, content_type="application/tracking-status+json", ) + + +class OperationsLogView(PrivateViewMixin, UserPassesTestMixin, View): + """ + Operations view for testing logging to external services. + + This view is used to verify that logging to services like Sentry and New Relic + is working correctly after deployments or upgrades. It requires staff access + and generates both info and error level log messages. + """ + + service_name = None + raise_exception = True + + def test_func(self): + """Only allow staff users to access this view.""" + return self.request.user.is_staff + + def handle_no_permission(self): + """Return a JSON 403 response instead of rendering a template.""" + return JsonResponse( + {"error": "Access denied. Staff privileges required."}, + status=403, + ) + + def get(self, request, *args, **kwargs): + """Generate log messages for the configured service.""" + if not self.service_name: + return JsonResponse( + {"error": "Service name not configured"}, + status=500, + ) + + log.info( + f"Operations test: {self.service_name} logging check", + service=self.service_name, + user=request.user.username, + ) + log.error( + f"Operations test error: {self.service_name} error logging check", + service=self.service_name, + user=request.user.username, + ) + + return JsonResponse( + { + "status": "ok", + "service": self.service_name, + "message": f"Log messages sent to {self.service_name}", + }, + status=200, + ) + + +class SentryOperationsView(OperationsLogView): + """Operations view for testing Sentry logging.""" + + service_name = "sentry" + + +class NewRelicOperationsView(OperationsLogView): + """Operations view for testing New Relic logging.""" + + service_name = "newrelic" diff --git a/readthedocs/urls.py b/readthedocs/urls.py index 7819b2d467e..b658b74781b 100644 --- a/readthedocs/urls.py +++ b/readthedocs/urls.py @@ -15,6 +15,8 @@ from readthedocs.core.views import ErrorView from readthedocs.core.views import HomepageView +from readthedocs.core.views import NewRelicOperationsView +from readthedocs.core.views import SentryOperationsView from readthedocs.core.views import SupportView from readthedocs.core.views import WelcomeView from readthedocs.core.views import do_not_track @@ -144,6 +146,11 @@ ), ] +operations_urls = [ + path("operations/sentry/", SentryOperationsView.as_view(), name="operations_sentry"), + path("operations/newrelic/", NewRelicOperationsView.as_view(), name="operations_newrelic"), +] + debug_urls = [] for build_format in ("epub", "htmlzip", "json", "pdf"): debug_urls += static( @@ -189,6 +196,7 @@ if settings.ALLOW_ADMIN: groups.append(admin_urls) groups.append(impersonate_urls) + groups.append(operations_urls) if settings.SHOW_DEBUG_TOOLBAR: import debug_toolbar