"""Tests for the Channel model and API."""
# pylint: disable=redefined-outer-name,missing-function-docstring,no-member
import uuid
from unittest.mock import patch
from django.core.exceptions import ValidationError
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
CHANNELS_URL = "/api/v1.0/channels/"
@pytest.fixture
def authenticated_client():
"""Return an (APIClient, User) pair with forced authentication."""
user = factories.UserFactory()
client = APIClient()
client.force_authenticate(user=user)
return client, user
# ---------------------------------------------------------------------------
# Model tests
# ---------------------------------------------------------------------------
class TestChannelModel:
"""Tests for the Channel model."""
def test_verify_token(self):
channel = factories.ChannelFactory()
token = channel.encrypted_settings["token"]
assert channel.verify_token(token)
assert not channel.verify_token("wrong-token")
def test_scope_validation_requires_at_least_one(self):
"""Channel with no scope should fail validation."""
channel = models.Channel(name="no-scope")
with pytest.raises(ValidationError):
channel.full_clean()
def test_role_property(self):
"""Role is stored in settings and accessible via property."""
user = factories.UserFactory()
channel = models.Channel(
name="test",
user=user,
settings={"role": "editor"},
)
assert channel.role == "editor"
channel.role = "admin"
assert channel.settings["role"] == "admin"
def test_role_default(self):
"""Role defaults to reader when not set."""
user = factories.UserFactory()
channel = models.Channel(name="test", user=user)
assert channel.role == "reader"
# ---------------------------------------------------------------------------
# API tests
# ---------------------------------------------------------------------------
class TestChannelAPI:
"""Tests for the Channel CRUD API."""
def test_create_channel(self, authenticated_client):
client, user = authenticated_client
response = client.post(
CHANNELS_URL,
{"name": "My Channel"},
format="json",
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "My Channel"
assert "token" in data # token revealed on creation
assert len(data["token"]) >= 20
assert data["role"] == "reader"
assert data["user"] == str(user.pk)
def test_create_channel_with_caldav_path(self, authenticated_client):
client, user = authenticated_client
caldav_path = f"/calendars/users/{user.email}/my-cal/"
response = client.post(
CHANNELS_URL,
{"name": "Cal Channel", "caldav_path": caldav_path},
format="json",
)
assert response.status_code == 201
assert response.json()["caldav_path"] == caldav_path
def test_create_channel_wrong_caldav_path(self, authenticated_client):
client, _user = authenticated_client
response = client.post(
CHANNELS_URL,
{
"name": "Bad",
"caldav_path": "/calendars/users/other@example.com/cal/",
},
format="json",
)
assert response.status_code == 403
def test_list_channels(self, authenticated_client):
client, _user = authenticated_client
# Create 2 channels
for i in range(2):
client.post(
CHANNELS_URL,
{"name": f"Channel {i}"},
format="json",
)
response = client.get(CHANNELS_URL)
assert response.status_code == 200
assert len(response.json()) == 2
def test_list_channels_only_own(self, authenticated_client):
"""Users should only see their own channels."""
client, _user = authenticated_client
# Create a channel for another user
factories.ChannelFactory()
response = client.get(CHANNELS_URL)
assert response.status_code == 200
assert len(response.json()) == 0
def test_retrieve_channel(self, authenticated_client):
client, _user = authenticated_client
create_resp = client.post(
CHANNELS_URL,
{"name": "Retrieve Me"},
format="json",
)
channel_id = create_resp.json()["id"]
response = client.get(f"{CHANNELS_URL}{channel_id}/")
assert response.status_code == 200
assert response.json()["name"] == "Retrieve Me"
assert "token" not in response.json() # token NOT in retrieve
def test_delete_channel(self, authenticated_client):
client, _user = authenticated_client
create_resp = client.post(
CHANNELS_URL,
{"name": "Delete Me"},
format="json",
)
channel_id = create_resp.json()["id"]
response = client.delete(f"{CHANNELS_URL}{channel_id}/")
assert response.status_code == 204
assert not models.Channel.objects.filter(pk=channel_id).exists()
def test_regenerate_token(self, authenticated_client):
client, _user = authenticated_client
create_resp = client.post(
CHANNELS_URL,
{"name": "Regen"},
format="json",
)
old_token = create_resp.json()["token"]
channel_id = create_resp.json()["id"]
response = client.post(f"{CHANNELS_URL}{channel_id}/regenerate-token/")
assert response.status_code == 200
new_token = response.json()["token"]
assert new_token != old_token
assert len(new_token) >= 20
def test_unauthenticated(self):
client = APIClient()
response = client.get(CHANNELS_URL)
assert response.status_code in (401, 403)
# ---------------------------------------------------------------------------
# CalDAV proxy channel auth tests
# ---------------------------------------------------------------------------
class TestCalDAVProxyChannelAuth:
"""Tests for channel token authentication in the CalDAV proxy."""
@patch("core.api.viewsets_caldav.CalDAVHTTPClient")
def test_channel_token_auth_propfind(self, mock_http_cls):
"""A reader channel token should allow PROPFIND."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
)
token = channel.encrypted_settings["token"]
mock_response = type(
"R",
(),
{
"status_code": 207,
"content": b"",
"headers": {"Content-Type": "application/xml"},
},
)()
mock_http_cls.build_base_headers.return_value = {
"X-Api-Key": "test",
"X-Forwarded-User": user.email,
}
client = APIClient()
with patch(
"core.api.viewsets_caldav.requests.request", return_value=mock_response
):
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
HTTP_DEPTH="1",
)
assert response.status_code == 207
@patch("core.api.viewsets_caldav.CalDAVHTTPClient")
def test_channel_token_reader_cannot_put(self, _mock_http_cls):
"""A reader channel should NOT allow PUT."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.put(
f"/caldav/calendars/users/{user.email}/cal/event.ics",
data=b"BEGIN:VCALENDAR",
content_type="text/calendar",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
@patch("core.api.viewsets_caldav.CalDAVHTTPClient")
def test_channel_token_editor_can_put(self, mock_http_cls):
"""An editor channel should allow PUT."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "editor"},
)
token = channel.encrypted_settings["token"]
mock_response = type(
"R",
(),
{
"status_code": 201,
"content": b"",
"headers": {"Content-Type": "text/plain"},
},
)()
mock_http_cls.build_base_headers.return_value = {
"X-Api-Key": "test",
"X-Forwarded-User": user.email,
}
client = APIClient()
with patch(
"core.api.viewsets_caldav.requests.request", return_value=mock_response
):
response = client.put(
f"/caldav/calendars/users/{user.email}/cal/event.ics",
data=b"BEGIN:VCALENDAR",
content_type="text/calendar",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 201
def test_channel_token_wrong_path(self):
"""Channel should not access paths outside its user scope."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.generic(
"PROPFIND",
"/caldav/calendars/users/other@example.com/cal/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
def test_invalid_token(self):
"""Invalid token should return 401."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
)
client = APIClient()
response = client.generic(
"PROPFIND",
"/caldav/calendars/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN="invalid-token-12345",
)
assert response.status_code == 401
def test_missing_channel_id(self):
"""Token without channel ID should return 401."""
client = APIClient()
response = client.generic(
"PROPFIND",
"/caldav/calendars/",
HTTP_X_CHANNEL_TOKEN="some-token",
)
assert response.status_code == 401
def test_nonexistent_channel_id(self):
"""Non-existent channel ID should return 401."""
client = APIClient()
response = client.generic(
"PROPFIND",
"/caldav/calendars/",
HTTP_X_CHANNEL_ID=str(uuid.uuid4()),
HTTP_X_CHANNEL_TOKEN="some-token",
)
assert response.status_code == 401
def test_inactive_channel_id(self):
"""Inactive channel should return 401."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
is_active=False,
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 401
@patch("core.api.viewsets_caldav.CalDAVHTTPClient")
def test_caldav_path_scoped_channel(self, mock_http_cls):
"""Channel with caldav_path scope restricts to that path."""
user = factories.UserFactory()
scoped_path = f"/calendars/users/{user.email}/specific-cal/"
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
caldav_path=scoped_path,
)
token = channel.encrypted_settings["token"]
mock_response = type(
"R",
(),
{
"status_code": 207,
"content": b"",
"headers": {"Content-Type": "application/xml"},
},
)()
mock_http_cls.build_base_headers.return_value = {
"X-Api-Key": "test",
"X-Forwarded-User": user.email,
}
client = APIClient()
# Allowed: within scoped path
with patch(
"core.api.viewsets_caldav.requests.request", return_value=mock_response
):
response = client.generic(
"PROPFIND",
f"/caldav{scoped_path}",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
HTTP_DEPTH="1",
)
assert response.status_code == 207
# Denied: different calendar
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/other-cal/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
def test_caldav_path_boundary_no_prefix_leak(self):
"""Scoped path /cal1/ must NOT match /cal1-secret/ (trailing slash boundary)."""
user = factories.UserFactory()
scoped_path = f"/calendars/users/{user.email}/cal1/"
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
caldav_path=scoped_path,
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/cal1-secret/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
@patch("core.api.viewsets_caldav.get_user_entitlements")
def test_channel_mkcalendar_checks_entitlements(self, mock_entitlements):
"""MKCALENDAR via channel token must still check entitlements."""
mock_entitlements.return_value = {"can_access": False}
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "admin"},
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.generic(
"MKCALENDAR",
f"/caldav/calendars/users/{user.email}/new-cal/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
mock_entitlements.assert_called_once_with(user.sub, user.email)