Files
calendars/src/backend/core/tests/test_channels.py
Sylvain Zimmer 9c18f96090 (all) add organizations, resources, channels, and infra migration (#34)
Add multi-tenant organization model populated from OIDC claims with
org-scoped user discovery, CalDAV principal filtering, and cross-org
isolation at the SabreDAV layer.

Add bookable resource principals (rooms, equipment) with CalDAV
auto-scheduling that handles conflict detection, auto-accept/decline,
and org-scoped booking enforcement. Fixes #14.

Replace CalendarSubscriptionToken with a unified Channel model
supporting CalDAV integration tokens and iCal feed URLs, with
encrypted token storage and role-based access control. Fixes #16.

Migrate task queue from Celery to Dramatiq with async ICS import,
progress tracking, and task status polling endpoint.

Replace nginx with Caddy for both the reverse proxy and frontend
static serving. Switch frontend package manager from yarn/pnpm to
npm and upgrade Node to 24, Next.js to 16, TypeScript to 5.9.

Harden security with fail-closed entitlements, RSVP rate limiting
and token expiry, CalDAV proxy path validation blocking internal
API routes, channel path scope enforcement, and ETag-based
conflict prevention.

Add frontend pages for resource management and integration channel
CRUD, with resource booking in the event modal.

Restructure CalDAV paths to /calendars/users/ and
/calendars/resources/ with nested principal collections in SabreDAV.
2026-03-09 09:09:34 +01:00

457 lines
15 KiB
Python

"""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"<xml/>",
"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"<xml/>",
"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)