diff --git a/src/backend/core/tests/test_calendar_subscription_api.py b/src/backend/core/tests/test_calendar_subscription_api.py new file mode 100644 index 0000000..0aa6229 --- /dev/null +++ b/src/backend/core/tests/test_calendar_subscription_api.py @@ -0,0 +1,449 @@ +"""Tests for calendar subscription token API.""" + +from urllib.parse import quote + +from django.urls import reverse + +import pytest +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, +) +from rest_framework.test import APIClient + +from core import factories +from core.models import CalendarSubscriptionToken + + +@pytest.mark.django_db +class TestSubscriptionTokenViewSet: + """Tests for the new standalone SubscriptionTokenViewSet.""" + + def test_create_subscription_token(self): + """Test creating a subscription token for a calendar.""" + user = factories.UserFactory() + caldav_path = f"/calendars/{user.email}/test-calendar-uuid/" + client = APIClient() + client.force_login(user) + + url = reverse("subscription-tokens-list") + response = client.post( + url, + { + "caldav_path": caldav_path, + "calendar_name": "My Test Calendar", + }, + format="json", + ) + + assert response.status_code == HTTP_201_CREATED + assert "token" in response.data + assert "url" in response.data + assert "/ical/" in response.data["url"] + assert ".ics" in response.data["url"] + assert response.data["caldav_path"] == caldav_path + assert response.data["calendar_name"] == "My Test Calendar" + + # Verify token was created in database + assert CalendarSubscriptionToken.objects.filter( + owner=user, caldav_path=caldav_path + ).exists() + + def test_create_subscription_token_normalizes_path(self): + """Test that caldav_path is normalized to have leading/trailing slashes.""" + user = factories.UserFactory() + caldav_path = f"calendars/{user.email}/test-uuid" # No leading/trailing slash + client = APIClient() + client.force_login(user) + + url = reverse("subscription-tokens-list") + response = client.post( + url, + {"caldav_path": caldav_path}, + format="json", + ) + + assert response.status_code == HTTP_201_CREATED + # Path should be normalized + assert response.data["caldav_path"] == f"/calendars/{user.email}/test-uuid/" + + def test_create_subscription_token_returns_existing(self): + """Test that creating a token when one exists returns the existing one.""" + subscription = factories.CalendarSubscriptionTokenFactory() + client = APIClient() + client.force_login(subscription.owner) + + url = reverse("subscription-tokens-list") + response = client.post( + url, + { + "caldav_path": subscription.caldav_path, + "calendar_name": "Updated Name", + }, + format="json", + ) + + assert response.status_code == HTTP_200_OK + assert response.data["token"] == str(subscription.token) + # Name should be updated + subscription.refresh_from_db() + assert subscription.calendar_name == "Updated Name" + + def test_get_subscription_token_by_path(self): + """Test retrieving an existing subscription token by CalDAV path.""" + subscription = factories.CalendarSubscriptionTokenFactory() + client = APIClient() + client.force_login(subscription.owner) + + url = reverse("subscription-tokens-by-path") + response = client.get(url, {"caldav_path": subscription.caldav_path}) + + assert response.status_code == HTTP_200_OK + assert response.data["token"] == str(subscription.token) + assert "url" in response.data + + def test_get_subscription_token_not_found(self): + """Test retrieving token when none exists.""" + user = factories.UserFactory() + caldav_path = f"/calendars/{user.email}/nonexistent/" + client = APIClient() + client.force_login(user) + + url = reverse("subscription-tokens-by-path") + response = client.get(url, {"caldav_path": caldav_path}) + + assert response.status_code == HTTP_404_NOT_FOUND + + def test_get_subscription_token_missing_path(self): + """Test that missing caldav_path query param returns 400.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + url = reverse("subscription-tokens-by-path") + response = client.get(url) + + assert response.status_code == HTTP_400_BAD_REQUEST + + def test_delete_subscription_token(self): + """Test revoking a subscription token.""" + subscription = factories.CalendarSubscriptionTokenFactory() + client = APIClient() + client.force_login(subscription.owner) + + base_url = reverse("subscription-tokens-by-path") + url = f"{base_url}?caldav_path={quote(subscription.caldav_path, safe='')}" + response = client.delete(url) + + assert response.status_code == HTTP_204_NO_CONTENT + assert not CalendarSubscriptionToken.objects.filter(pk=subscription.pk).exists() + + def test_delete_subscription_token_not_found(self): + """Test deleting token when none exists.""" + user = factories.UserFactory() + caldav_path = f"/calendars/{user.email}/nonexistent/" + client = APIClient() + client.force_login(user) + + base_url = reverse("subscription-tokens-by-path") + url = f"{base_url}?caldav_path={quote(caldav_path, safe='')}" + response = client.delete(url) + + assert response.status_code == HTTP_404_NOT_FOUND + + def test_non_owner_cannot_create_token(self): + """Test that users cannot create tokens for other users' calendars.""" + user = factories.UserFactory() + other_user = factories.UserFactory() + caldav_path = f"/calendars/{other_user.email}/test-calendar/" + client = APIClient() + client.force_login(user) + + url = reverse("subscription-tokens-list") + response = client.post( + url, + {"caldav_path": caldav_path}, + format="json", + ) + + assert response.status_code == HTTP_403_FORBIDDEN + + def test_non_owner_cannot_get_token(self): + """Test that users cannot get tokens for other users' calendars.""" + subscription = factories.CalendarSubscriptionTokenFactory() + other_user = factories.UserFactory() + client = APIClient() + client.force_login(other_user) + + url = reverse("subscription-tokens-by-path") + response = client.get(url, {"caldav_path": subscription.caldav_path}) + + assert response.status_code == HTTP_403_FORBIDDEN + + def test_non_owner_cannot_delete_token(self): + """Test that users cannot delete tokens for other users' calendars.""" + subscription = factories.CalendarSubscriptionTokenFactory() + other_user = factories.UserFactory() + client = APIClient() + client.force_login(other_user) + + base_url = reverse("subscription-tokens-by-path") + url = f"{base_url}?caldav_path={quote(subscription.caldav_path, safe='')}" + response = client.delete(url) + + assert response.status_code == HTTP_403_FORBIDDEN + # Token should still exist + assert CalendarSubscriptionToken.objects.filter(pk=subscription.pk).exists() + + def test_unauthenticated_cannot_create_token(self): + """Test that unauthenticated users cannot create tokens.""" + user = factories.UserFactory() + caldav_path = f"/calendars/{user.email}/test-calendar/" + client = APIClient() + + url = reverse("subscription-tokens-list") + response = client.post( + url, + {"caldav_path": caldav_path}, + format="json", + ) + + assert response.status_code == HTTP_401_UNAUTHORIZED + + def test_unauthenticated_cannot_get_token(self): + """Test that unauthenticated users cannot get tokens.""" + subscription = factories.CalendarSubscriptionTokenFactory() + client = APIClient() + + url = reverse("subscription-tokens-by-path") + response = client.get(url, {"caldav_path": subscription.caldav_path}) + + assert response.status_code == HTTP_401_UNAUTHORIZED + + def test_regenerate_token(self): + """Test regenerating a token by delete + create.""" + subscription = factories.CalendarSubscriptionTokenFactory() + old_token = subscription.token + client = APIClient() + client.force_login(subscription.owner) + + base_by_path_url = reverse("subscription-tokens-by-path") + by_path_url = f"{base_by_path_url}?caldav_path={quote(subscription.caldav_path, safe='')}" + create_url = reverse("subscription-tokens-list") + + # Delete old token + response = client.delete(by_path_url) + assert response.status_code == HTTP_204_NO_CONTENT + + # Create new token + response = client.post( + create_url, + {"caldav_path": subscription.caldav_path}, + format="json", + ) + assert response.status_code == HTTP_201_CREATED + assert response.data["token"] != str(old_token) + + def test_unique_constraint_per_owner_calendar(self): + """Test that only one token can exist per owner+caldav_path.""" + subscription = factories.CalendarSubscriptionTokenFactory() + + # Try to create another token for the same path - should return existing + client = APIClient() + client.force_login(subscription.owner) + + url = reverse("subscription-tokens-list") + response = client.post( + url, + {"caldav_path": subscription.caldav_path}, + format="json", + ) + + # Should return the existing token, not create a new one + assert response.status_code == HTTP_200_OK + assert response.data["token"] == str(subscription.token) + assert ( + CalendarSubscriptionToken.objects.filter(owner=subscription.owner).count() + == 1 + ) + + +@pytest.mark.django_db +class TestPathInjectionProtection: + """ + Security tests for CalDAV path injection protection. + + These tests verify that malicious paths are rejected to prevent: + - Path traversal attacks (../) + - Query parameter injection + - Fragment injection + - Access to other users' calendars via path manipulation + """ + + @pytest.mark.parametrize( + "malicious_suffix", + [ + # Path traversal attacks + "../other-calendar/", + "../../etc/passwd/", + "..%2F..%2Fetc%2Fpasswd/", # URL-encoded traversal + # Query parameter injection + "uuid?export=true/", + "uuid?admin=true/", + # Fragment injection + "uuid#malicious/", + # Special characters that shouldn't be in calendar IDs + "uuid;rm -rf/", + "uuid|cat /etc/passwd/", + "uuid$(whoami)/", + "uuid`whoami`/", + # Double slashes + "uuid//", + "/uuid/", + # Spaces and other whitespace + "uuid with spaces/", + "uuid\ttab/", + # Unicode tricks + "uuid\u002e\u002e/", # Unicode dots + ], + ) + def test_create_token_rejects_malicious_calendar_id(self, malicious_suffix): + """Test that malicious calendar IDs in paths are rejected.""" + user = factories.UserFactory() + caldav_path = f"/calendars/{user.email}/{malicious_suffix}" + client = APIClient() + client.force_login(user) + + url = reverse("subscription-tokens-list") + response = client.post( + url, + {"caldav_path": caldav_path}, + format="json", + ) + + # Should be rejected - either 403 (invalid format) or path doesn't normalize + assert response.status_code == HTTP_403_FORBIDDEN, ( + f"Path '{caldav_path}' should be rejected but got {response.status_code}" + ) + + @pytest.mark.parametrize( + "malicious_path", + [ + # Completely wrong structure + "/etc/passwd/", + "/admin/calendars/user@test.com/uuid/", + "/../calendars/user@test.com/uuid/", + # Missing segments + "/calendars/", + "/calendars/user@test.com/", + # Path traversal to access another user's calendar + "/calendars/victim@test.com/../attacker@test.com/uuid/", + ], + ) + def test_create_token_rejects_malformed_paths(self, malicious_path): + """Test that malformed CalDAV paths are rejected.""" + user = factories.UserFactory(email="attacker@test.com") + client = APIClient() + client.force_login(user) + + url = reverse("subscription-tokens-list") + response = client.post( + url, + {"caldav_path": malicious_path}, + format="json", + ) + + # Should be rejected + assert response.status_code == HTTP_403_FORBIDDEN, ( + f"Path '{malicious_path}' should be rejected but got {response.status_code}" + ) + + def test_path_traversal_to_other_user_calendar_rejected(self): + """Test that path traversal to access another user's calendar is blocked.""" + attacker = factories.UserFactory(email="attacker@example.com") + victim = factories.UserFactory(email="victim@example.com") + client = APIClient() + client.force_login(attacker) + + # Try to access victim's calendar via path traversal + malicious_paths = [ + f"/calendars/{attacker.email}/../{victim.email}/secret-calendar/", + f"/calendars/{victim.email}/secret-calendar/", # Direct access + ] + + url = reverse("subscription-tokens-list") + for path in malicious_paths: + response = client.post( + url, + {"caldav_path": path}, + format="json", + ) + assert response.status_code == HTTP_403_FORBIDDEN, ( + f"Attacker should not access victim's calendar via '{path}'" + ) + + def test_valid_uuid_path_accepted(self): + """Test that valid UUID-style calendar IDs are accepted.""" + user = factories.UserFactory() + # Standard UUID format + caldav_path = f"/calendars/{user.email}/550e8400-e29b-41d4-a716-446655440000/" + client = APIClient() + client.force_login(user) + + url = reverse("subscription-tokens-list") + response = client.post( + url, + {"caldav_path": caldav_path}, + format="json", + ) + + assert response.status_code == HTTP_201_CREATED + + def test_valid_alphanumeric_path_accepted(self): + """Test that valid alphanumeric calendar IDs are accepted.""" + user = factories.UserFactory() + # Alphanumeric with hyphens (allowed by regex) + caldav_path = f"/calendars/{user.email}/my-calendar-2024/" + client = APIClient() + client.force_login(user) + + url = reverse("subscription-tokens-list") + response = client.post( + url, + {"caldav_path": caldav_path}, + format="json", + ) + + assert response.status_code == HTTP_201_CREATED + + def test_get_token_with_malicious_path_rejected(self): + """Test that GET requests with malicious paths are rejected.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + malicious_path = f"/calendars/{user.email}/../../../etc/passwd/" + + url = reverse("subscription-tokens-by-path") + response = client.get(url, {"caldav_path": malicious_path}) + + assert response.status_code == HTTP_403_FORBIDDEN + + def test_delete_token_with_malicious_path_rejected(self): + """Test that DELETE requests with malicious paths are rejected.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + malicious_path = f"/calendars/{user.email}/../../../etc/passwd/" + + base_url = reverse("subscription-tokens-by-path") + url = f"{base_url}?caldav_path={quote(malicious_path, safe='')}" + response = client.delete(url) + + assert response.status_code == HTTP_403_FORBIDDEN diff --git a/src/backend/core/tests/test_ical_export.py b/src/backend/core/tests/test_ical_export.py new file mode 100644 index 0000000..df41cd1 --- /dev/null +++ b/src/backend/core/tests/test_ical_export.py @@ -0,0 +1,227 @@ +"""Tests for iCal export endpoint.""" + +import uuid + +from django.conf import settings +from django.urls import reverse + +import pytest +import responses +from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_502_BAD_GATEWAY +from rest_framework.test import APIClient + +from core import factories +from core.models import CalendarSubscriptionToken + + +@pytest.mark.django_db +class TestICalExport: + """Tests for ICalExportView.""" + + def test_export_with_valid_token_returns_ics(self): + """Test that a valid token returns iCal data.""" + subscription = factories.CalendarSubscriptionTokenFactory() + client = APIClient() + + # Mock CalDAV server response + with responses.RequestsMock() as rsps: + caldav_url = settings.CALDAV_URL + caldav_path = subscription.caldav_path.lstrip("/") + target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export" + + ics_content = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//SabreDAV//SabreDAV//EN +BEGIN:VEVENT +UID:test-event-123 +DTSTART:20240101T100000Z +DTEND:20240101T110000Z +SUMMARY:Test Event +END:VEVENT +END:VCALENDAR""" + + rsps.add( + responses.GET, + target_url, + body=ics_content, + status=HTTP_200_OK, + content_type="text/calendar", + ) + + url = reverse("ical-export", kwargs={"token": subscription.token}) + response = client.get(url) + + assert response.status_code == HTTP_200_OK + assert response["Content-Type"] == "text/calendar; charset=utf-8" + assert "BEGIN:VCALENDAR" in response.content.decode() + assert response["Content-Disposition"] is not None + assert ".ics" in response["Content-Disposition"] + + def test_export_with_invalid_token_returns_404(self): + """Test that an invalid token returns 404.""" + client = APIClient() + invalid_token = uuid.uuid4() + + url = reverse("ical-export", kwargs={"token": invalid_token}) + response = client.get(url) + + assert response.status_code == HTTP_404_NOT_FOUND + + def test_export_with_inactive_token_returns_404(self): + """Test that an inactive token returns 404.""" + subscription = factories.CalendarSubscriptionTokenFactory(is_active=False) + client = APIClient() + + url = reverse("ical-export", kwargs={"token": subscription.token}) + response = client.get(url) + + assert response.status_code == HTTP_404_NOT_FOUND + + def test_export_updates_last_accessed_at(self): + """Test that accessing the export updates last_accessed_at.""" + subscription = factories.CalendarSubscriptionTokenFactory() + assert subscription.last_accessed_at is None + + client = APIClient() + + with responses.RequestsMock() as rsps: + caldav_url = settings.CALDAV_URL + caldav_path = subscription.caldav_path.lstrip("/") + target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export" + + rsps.add( + responses.GET, + target_url, + body=b"BEGIN:VCALENDAR\nEND:VCALENDAR", + status=HTTP_200_OK, + content_type="text/calendar", + ) + + url = reverse("ical-export", kwargs={"token": subscription.token}) + client.get(url) + + subscription.refresh_from_db() + assert subscription.last_accessed_at is not None + + def test_export_does_not_require_authentication(self): + """Test that the endpoint is accessible without authentication.""" + subscription = factories.CalendarSubscriptionTokenFactory() + client = APIClient() + # Not logging in - should still work + + with responses.RequestsMock() as rsps: + caldav_url = settings.CALDAV_URL + caldav_path = subscription.caldav_path.lstrip("/") + target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export" + + rsps.add( + responses.GET, + target_url, + body=b"BEGIN:VCALENDAR\nEND:VCALENDAR", + status=HTTP_200_OK, + content_type="text/calendar", + ) + + url = reverse("ical-export", kwargs={"token": subscription.token}) + response = client.get(url) + + assert response.status_code == HTTP_200_OK + + def test_export_sends_correct_headers_to_caldav(self): + """Test that the proxy sends correct authentication headers to CalDAV.""" + subscription = factories.CalendarSubscriptionTokenFactory() + client = APIClient() + + with responses.RequestsMock() as rsps: + caldav_url = settings.CALDAV_URL + caldav_path = subscription.caldav_path.lstrip("/") + target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export" + + rsps.add( + responses.GET, + target_url, + body=b"BEGIN:VCALENDAR\nEND:VCALENDAR", + status=HTTP_200_OK, + content_type="text/calendar", + ) + + url = reverse("ical-export", kwargs={"token": subscription.token}) + client.get(url) + + # Verify headers sent to CalDAV + assert len(rsps.calls) == 1 + request = rsps.calls[0].request + assert request.headers["X-Forwarded-User"] == subscription.owner.email + assert request.headers["X-Api-Key"] == settings.CALDAV_OUTBOUND_API_KEY + + def test_export_handles_caldav_error(self): + """Test that CalDAV server errors are handled gracefully.""" + subscription = factories.CalendarSubscriptionTokenFactory() + client = APIClient() + + with responses.RequestsMock() as rsps: + caldav_url = settings.CALDAV_URL + caldav_path = subscription.caldav_path.lstrip("/") + target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export" + + rsps.add( + responses.GET, + target_url, + body=b"Internal Server Error", + status=500, + ) + + url = reverse("ical-export", kwargs={"token": subscription.token}) + response = client.get(url) + + assert response.status_code == HTTP_502_BAD_GATEWAY + + def test_export_sets_security_headers(self): + """Test that security headers are set correctly.""" + subscription = factories.CalendarSubscriptionTokenFactory() + client = APIClient() + + with responses.RequestsMock() as rsps: + caldav_url = settings.CALDAV_URL + caldav_path = subscription.caldav_path.lstrip("/") + target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export" + + rsps.add( + responses.GET, + target_url, + body=b"BEGIN:VCALENDAR\nEND:VCALENDAR", + status=HTTP_200_OK, + content_type="text/calendar", + ) + + url = reverse("ical-export", kwargs={"token": subscription.token}) + response = client.get(url) + + # Verify security headers + assert response["Cache-Control"] == "no-store, private" + assert response["Referrer-Policy"] == "no-referrer" + + def test_export_uses_calendar_name_in_filename(self): + """Test that the export filename uses the calendar_name.""" + subscription = factories.CalendarSubscriptionTokenFactory( + calendar_name="My Test Calendar" + ) + client = APIClient() + + with responses.RequestsMock() as rsps: + caldav_url = settings.CALDAV_URL + caldav_path = subscription.caldav_path.lstrip("/") + target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export" + + rsps.add( + responses.GET, + target_url, + body=b"BEGIN:VCALENDAR\nEND:VCALENDAR", + status=HTTP_200_OK, + content_type="text/calendar", + ) + + url = reverse("ical-export", kwargs={"token": subscription.token}) + response = client.get(url) + + assert "My Test Calendar.ics" in response["Content-Disposition"]