(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.
This commit is contained in:
Sylvain Zimmer
2026-03-09 09:09:34 +01:00
committed by GitHub
parent cd2b15b3b5
commit 9c18f96090
176 changed files with 26903 additions and 12108 deletions

View File

@@ -94,9 +94,6 @@ WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that

View File

@@ -1,5 +1 @@
"""Calendars package. Import the celery app early to load shared task form dependencies."""
from .celery_app import app as celery_app
__all__ = ["celery_app"]
"""Calendars Django project package."""

View File

@@ -1,26 +0,0 @@
"""Calendars celery configuration file."""
import os
from celery import Celery
from configurations.importer import install
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "calendars.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
install(check_options=True)
# Can be loaded only after install call.
from django.conf import settings # pylint: disable=wrong-import-position
app = Celery("calendars")
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object("django.conf:settings", namespace="CELERY")
# Load task modules from all registered Django apps.
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)

View File

@@ -14,8 +14,6 @@ import os
import tomllib
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
@@ -74,13 +72,24 @@ class Base(Configuration):
# CalDAV API keys for bidirectional authentication
# INBOUND: API key for authenticating requests FROM CalDAV server TO Django
CALDAV_INBOUND_API_KEY = values.Value(
CALDAV_INBOUND_API_KEY = SecretFileValue(
None, environ_name="CALDAV_INBOUND_API_KEY", environ_prefix=None
)
# OUTBOUND: API key for authenticating requests FROM Django TO CalDAV server
CALDAV_OUTBOUND_API_KEY = values.Value(
CALDAV_OUTBOUND_API_KEY = SecretFileValue(
None, environ_name="CALDAV_OUTBOUND_API_KEY", environ_prefix=None
)
# INTERNAL: API key for Django → CalDAV internal API (resource provisioning, import)
CALDAV_INTERNAL_API_KEY = SecretFileValue(
None, environ_name="CALDAV_INTERNAL_API_KEY", environ_prefix=None
)
# Salt for django-fernet-encrypted-fields (Channel tokens, etc.)
# Used with SECRET_KEY to derive Fernet encryption keys via PBKDF2
SALT_KEY = values.Value(
"calendars-default-salt-change-in-production",
environ_name="SALT_KEY",
environ_prefix=None,
)
# Base URL for CalDAV scheduling callbacks (must be accessible from CalDAV container)
# In Docker environments, use the internal Docker network URL (e.g., http://backend:8000)
CALDAV_CALLBACK_BASE_URL = values.Value(
@@ -117,7 +126,7 @@ class Base(Configuration):
CALENDAR_INVITATION_FROM_EMAIL = values.Value(
None, environ_name="CALENDAR_INVITATION_FROM_EMAIL", environ_prefix=None
)
APP_NAME = values.Value("Calendrier", environ_name="APP_NAME", environ_prefix=None)
APP_NAME = values.Value("Calendars", environ_name="APP_NAME", environ_prefix=None)
APP_URL = values.Value("", environ_name="APP_URL", environ_prefix=None)
CALENDAR_ITIP_ENABLED = values.BooleanValue(
False, environ_name="CALENDAR_ITIP_ENABLED", environ_prefix=None
@@ -133,6 +142,18 @@ class Base(Configuration):
environ_prefix=None,
)
# Organizations
OIDC_USERINFO_ORGANIZATION_CLAIM = values.Value(
"",
environ_name="OIDC_USERINFO_ORGANIZATION_CLAIM",
environ_prefix=None,
)
RESOURCE_EMAIL_DOMAIN = values.Value(
"",
environ_name="RESOURCE_EMAIL_DOMAIN",
environ_prefix=None,
)
# Entitlements
ENTITLEMENTS_BACKEND = values.Value(
"core.entitlements.backends.local.LocalEntitlementsBackend",
@@ -212,7 +233,7 @@ class Base(Configuration):
# This is used to limit the size of the request body in memory.
# This also limits the size of the file that can be uploaded to the server.
DATA_UPLOAD_MAX_MEMORY_SIZE = values.PositiveIntegerValue(
2 * (2**30), # 2GB
20 * (2**20), # 20MB
environ_name="DATA_UPLOAD_MAX_MEMORY_SIZE",
environ_prefix=None,
)
@@ -234,15 +255,13 @@ class Base(Configuration):
# fallback/default languages throughout the app.
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
("nl-nl", _("Dutch")),
("en-us", "English"),
("fr-fr", "French"),
("de-de", "German"),
("nl-nl", "Dutch"),
)
)
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
@@ -275,7 +294,6 @@ class Base(Configuration):
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
@@ -296,7 +314,7 @@ class Base(Configuration):
"drf_standardized_errors",
# Third party apps
"corsheaders",
"django_celery_beat",
"django_dramatiq",
"django_filters",
"rest_framework",
"rest_framework_api_key",
@@ -415,7 +433,11 @@ class Base(Configuration):
)
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
RSVP_TOKEN_MAX_AGE_RECURRING = values.PositiveIntegerValue(
7776000, # 90 days
environ_name="RSVP_TOKEN_MAX_AGE_RECURRING",
environ_prefix=None,
)
# CORS
CORS_ALLOW_CREDENTIALS = True
@@ -514,10 +536,40 @@ class Base(Configuration):
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
# Dramatiq
DRAMATIQ_BROKER = {
"BROKER": "dramatiq.brokers.redis.RedisBroker",
"OPTIONS": {
"url": values.Value(
"redis://redis:6379/0",
environ_name="DRAMATIQ_BROKER_URL",
environ_prefix=None,
),
},
"MIDDLEWARE": [
"dramatiq.middleware.AgeLimit",
"dramatiq.middleware.TimeLimit",
"dramatiq.middleware.Callbacks",
"dramatiq.middleware.Retries",
"dramatiq.middleware.CurrentMessage",
"django_dramatiq.middleware.DbConnectionsMiddleware",
"django_dramatiq.middleware.AdminMiddleware",
],
}
DRAMATIQ_RESULT_BACKEND = {
"BACKEND": "dramatiq.results.backends.redis.RedisBackend",
"BACKEND_OPTIONS": {
"url": values.Value(
"redis://redis:6379/1",
environ_name="DRAMATIQ_RESULT_BACKEND_URL",
environ_prefix=None,
),
},
"MIDDLEWARE_OPTIONS": {
"result_ttl": 1000 * 60 * 60 * 24 * 30, # 30 days
},
}
DRAMATIQ_AUTODISCOVER_MODULES = ["tasks"]
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
@@ -635,12 +687,6 @@ class Base(Configuration):
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
environ_prefix=None,
)
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
default="first_name",
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None,
)
# OIDC Resource Server
OIDC_RESOURCE_SERVER_ENABLED = values.BooleanValue(
@@ -870,7 +916,7 @@ class Development(Base):
ALLOWED_HOSTS = ["*"]
CORS_ALLOW_ALL_ORIGINS = True
CSRF_TRUSTED_ORIGINS = [
"http://localhost:8920",
"http://localhost:8930",
"http://localhost:3000",
]
DEBUG = True
@@ -887,8 +933,8 @@ class Development(Base):
EMAIL_USE_SSL = False
DEFAULT_FROM_EMAIL = "calendars@calendars.world"
CALENDAR_INVITATION_FROM_EMAIL = "calendars@calendars.world"
APP_NAME = "Calendrier (Dev)"
APP_URL = "http://localhost:8921"
APP_NAME = "Calendars (dev)"
APP_URL = "http://localhost:8931"
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
@@ -919,7 +965,18 @@ class Test(Base):
]
USE_SWAGGER = True
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
DRAMATIQ_BROKER = {
"BROKER": "core.task_utils.EagerBroker",
"OPTIONS": {},
"MIDDLEWARE": [
"dramatiq.middleware.CurrentMessage",
],
}
DRAMATIQ_RESULT_BACKEND = {
"BACKEND": "dramatiq.results.backends.stub.StubBackend",
"BACKEND_OPTIONS": {},
"MIDDLEWARE_OPTIONS": {"result_ttl": 1000 * 60 * 10},
}
OIDC_STORE_ACCESS_TOKEN = False
OIDC_STORE_REFRESH_TOKEN = False
@@ -977,6 +1034,7 @@ class Production(Base):
"^__lbheartbeat__",
"^__heartbeat__",
r"^api/v1\.0/caldav-scheduling-callback/",
r"^caldav/",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`

View File

@@ -2,7 +2,6 @@
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from . import models
@@ -23,20 +22,19 @@ class UserAdmin(auth_admin.UserAdmin):
},
),
(
_("Personal info"),
"Personal info",
{
"fields": (
"sub",
"email",
"full_name",
"short_name",
"language",
"timezone",
)
},
),
(
_("Permissions"),
"Permissions",
{
"fields": (
"is_active",
@@ -48,7 +46,7 @@ class UserAdmin(auth_admin.UserAdmin):
),
},
),
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
("Important dates", {"fields": ("created_at", "updated_at")}),
)
add_fieldsets = (
(
@@ -86,27 +84,27 @@ class UserAdmin(auth_admin.UserAdmin):
"sub",
"email",
"full_name",
"short_name",
"created_at",
"updated_at",
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.CalendarSubscriptionToken)
class CalendarSubscriptionTokenAdmin(admin.ModelAdmin):
"""Admin class for CalendarSubscriptionToken model."""
@admin.register(models.Channel)
class ChannelAdmin(admin.ModelAdmin):
"""Admin class for Channel model."""
list_display = (
"calendar_name",
"owner",
"name",
"type",
"organization",
"user",
"caldav_path",
"token",
"is_active",
"last_accessed_at",
"last_used_at",
"created_at",
)
list_filter = ("is_active",)
search_fields = ("calendar_name", "owner__email", "caldav_path", "token")
readonly_fields = ("id", "token", "created_at", "last_accessed_at")
raw_id_fields = ("owner",)
list_filter = ("type", "is_active")
search_fields = ("name", "user__email", "caldav_path")
readonly_fields = ("id", "created_at", "updated_at", "last_used_at")
raw_id_fields = ("user", "organization")

View File

@@ -2,19 +2,12 @@
import logging
from django.core import exceptions
from rest_framework import permissions
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
logger = logging.getLogger(__name__)
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
"children": {"GET": "children_list", "POST": "children_create"},
}
class IsAuthenticated(permissions.BasePermission):
"""
@@ -26,15 +19,6 @@ class IsAuthenticated(permissions.BasePermission):
return bool(request.auth) or request.user.is_authenticated
class IsAuthenticatedOrSafe(IsAuthenticated):
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return super().has_permission(request, view)
class IsSelf(IsAuthenticated):
"""
Allows access only to authenticated users. Alternative method checking the presence
@@ -46,27 +30,7 @@ class IsSelf(IsAuthenticated):
return obj == request.user
class IsOwnedOrPublic(IsAuthenticated):
"""
Allows access to authenticated users only for objects that are owned or not related
to any user via the "owner" field.
"""
def has_object_permission(self, request, view, obj):
"""Unsafe permissions are only allowed for the owner of the object."""
if obj.owner == request.user:
return True
if request.method in permissions.SAFE_METHODS and obj.owner is None:
return True
try:
return obj.user == request.user
except exceptions.ObjectDoesNotExist:
return False
class IsEntitled(IsAuthenticated):
class IsEntitledToAccess(IsAuthenticated):
"""Allows access only to users with can_access entitlement.
Fail-closed: denies access when the entitlements service is
@@ -78,25 +42,31 @@ class IsEntitled(IsAuthenticated):
return False
try:
entitlements = get_user_entitlements(request.user.sub, request.user.email)
return entitlements.get("can_access", True)
return entitlements.get("can_access", False)
except EntitlementsUnavailableError:
logger.warning(
"Entitlements unavailable, denying access for user %s",
request.user.pk,
)
return False
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
class IsOrgAdmin(IsAuthenticated):
"""Allows access only to users with can_admin entitlement.
Fail-closed: denies access when the entitlements service is
unavailable and no cached value exists.
"""
def has_permission(self, request, view):
return request.user.is_authenticated or view.action not in [
"create",
]
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
action = view.action
if not super().has_permission(request, view):
return False
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
return abilities.get(action, False)
entitlements = get_user_entitlements(request.user.sub, request.user.email)
return entitlements.get("can_admin", False)
except EntitlementsUnavailableError:
logger.warning(
"Entitlements unavailable, denying admin for user %s",
request.user.pk,
)
return False

View File

@@ -1,13 +1,22 @@
"""Client serializers for the calendars core app."""
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django.utils.text import slugify
from rest_framework import exceptions, serializers
from rest_framework import serializers
from core import models
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import uuid_to_urlsafe
class OrganizationSerializer(serializers.ModelSerializer):
"""Serialize organizations."""
class Meta:
model = models.Organization
fields = ["id", "name"]
read_only_fields = ["id", "name"]
class UserLiteSerializer(serializers.ModelSerializer):
@@ -15,171 +24,174 @@ class UserLiteSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "full_name", "short_name"]
read_only_fields = ["id", "full_name", "short_name"]
class BaseAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_abilities(self, access) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return access.get_abilities(request.user)
return {}
def validate(self, attrs):
"""
Check access rights specific to writing (create/update)
"""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to set this role for this template."
)
raise exceptions.PermissionDenied(message)
# Create
else:
try:
resource_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a resource ID in kwargs to create a new access."
) from exc
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
)
if (
role == models.RoleChoices.OWNER
and not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role=models.RoleChoices.OWNER,
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a resource can assign other users as owners."
)
# pylint: disable=no-member
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
return attrs
fields = ["id", "full_name"]
read_only_fields = ["id", "full_name"]
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
email = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = [
"id",
"email",
"full_name",
"short_name",
"language",
]
read_only_fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name"]
def get_email(self, user) -> str | None:
"""Return OIDC email, falling back to admin_email for staff users."""
return user.email or user.admin_email
class UserMeSerializer(UserSerializer):
"""Serialize users for me endpoint."""
can_access = serializers.SerializerMethodField(read_only=True)
can_admin = serializers.SerializerMethodField(read_only=True)
organization = OrganizationSerializer(read_only=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._entitlements_cache = {}
class Meta:
model = models.User
fields = [*UserSerializer.Meta.fields, "can_access"]
read_only_fields = [*UserSerializer.Meta.read_only_fields, "can_access"]
fields = [
*UserSerializer.Meta.fields,
"can_access",
"can_admin",
"organization",
]
read_only_fields = [
*UserSerializer.Meta.read_only_fields,
"can_access",
"can_admin",
"organization",
]
def _get_entitlements(self, user):
"""Get cached entitlements for the user, keyed by user.sub.
Cache is per-serializer-instance (request-scoped) to avoid
duplicate calls when both can_access and can_admin are serialized.
"""
if user.sub not in self._entitlements_cache:
try:
self._entitlements_cache[user.sub] = get_user_entitlements(
user.sub, user.email
)
except EntitlementsUnavailableError:
self._entitlements_cache[user.sub] = None
return self._entitlements_cache[user.sub]
def get_can_access(self, user) -> bool:
"""Check entitlements for the current user."""
try:
entitlements = get_user_entitlements(user.sub, user.email)
return entitlements.get("can_access", True)
except EntitlementsUnavailableError:
return True # fail-open
entitlements = self._get_entitlements(user)
if entitlements is None:
return False # fail-closed
return entitlements.get("can_access", False)
def get_can_admin(self, user) -> bool:
"""Check admin entitlement for the current user."""
entitlements = self._get_entitlements(user)
if entitlements is None:
return False # fail-closed
return entitlements.get("can_admin", False)
class CalendarSubscriptionTokenSerializer(serializers.ModelSerializer):
"""Serializer for CalendarSubscriptionToken model."""
class ChannelSerializer(serializers.ModelSerializer):
"""Read serializer for Channel model."""
role = serializers.SerializerMethodField()
url = serializers.SerializerMethodField()
class Meta:
model = models.CalendarSubscriptionToken
model = models.Channel
fields = [
"token",
"url",
"id",
"name",
"type",
"organization",
"user",
"caldav_path",
"calendar_name",
"role",
"is_active",
"last_accessed_at",
"created_at",
]
read_only_fields = [
"token",
"settings",
"url",
"caldav_path",
"calendar_name",
"is_active",
"last_accessed_at",
"last_used_at",
"created_at",
"updated_at",
]
read_only_fields = fields
def get_role(self, obj):
"""Get role from settings."""
return obj.role
def get_url(self, obj) -> str | None:
"""Build iCal subscription URL for ical-feed channels, None otherwise."""
if obj.type != "ical-feed":
return None
token = obj.encrypted_settings.get("token", "")
if not token:
return None
short_id = uuid_to_urlsafe(obj.pk)
calendar_name = obj.settings.get("calendar_name", "")
filename = slugify(calendar_name)[:50] or "feed"
ical_path = f"/ical/{short_id}/{token}/{filename}.ics"
def get_url(self, obj) -> str:
"""Build the full subscription URL, enforcing HTTPS in production."""
request = self.context.get("request")
if request:
url = request.build_absolute_uri(f"/ical/{obj.token}.ics")
url = request.build_absolute_uri(ical_path)
else:
# Fallback to APP_URL if no request context
app_url = getattr(settings, "APP_URL", "")
url = f"{app_url.rstrip('/')}/ical/{obj.token}.ics"
app_url = settings.APP_URL
url = f"{app_url.rstrip('/')}{ical_path}"
# Force HTTPS in production to protect the token in transit
if not settings.DEBUG and url.startswith("http://"):
url = url.replace("http://", "https://", 1)
return url
class CalendarSubscriptionTokenCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Serializer for creating a CalendarSubscriptionToken."""
class ChannelCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Write serializer for creating a Channel."""
caldav_path = serializers.CharField(max_length=512)
name = serializers.CharField(max_length=255)
type = serializers.CharField(max_length=255, default="caldav")
caldav_path = serializers.CharField(max_length=512, required=False, default="")
calendar_name = serializers.CharField(max_length=255, required=False, default="")
role = serializers.ChoiceField(
choices=[(r, r) for r in models.Channel.VALID_ROLES],
default=models.Channel.ROLE_READER,
)
def validate_caldav_path(self, value):
"""Validate and normalize the caldav_path."""
# Normalize path to always have trailing slash
if not value.endswith("/"):
value = value + "/"
# Normalize path to always start with /
if not value.startswith("/"):
value = "/" + value
"""Normalize caldav_path if provided."""
if value:
if not value.endswith("/"):
value = value + "/"
if not value.startswith("/"):
value = "/" + value
return value
def validate_type(self, value):
"""Validate channel type."""
if value == "ical-feed":
return value
return "caldav"
class ChannelWithTokenSerializer(ChannelSerializer):
"""Serializer that includes the plaintext token (used only on creation)."""
token = serializers.CharField(read_only=True)
class Meta(ChannelSerializer.Meta):
fields = [*ChannelSerializer.Meta.fields, "token"]

View File

@@ -1,5 +1,4 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import json
import logging
@@ -8,9 +7,7 @@ from django.conf import settings
from django.core.cache import cache
from django.utils.text import slugify
import rest_framework as drf
from rest_framework import response as drf_response
from rest_framework import status, viewsets
from rest_framework import mixins, pagination, response, status, views, viewsets
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
@@ -21,7 +18,8 @@ from core.services.caldav_service import (
normalize_caldav_path,
verify_caldav_access,
)
from core.services.import_service import MAX_FILE_SIZE, ICSImportService
from core.services.import_service import MAX_FILE_SIZE
from core.services.resource_service import ResourceProvisioningError, ResourceService
from . import permissions, serializers
@@ -31,60 +29,6 @@ logger = logging.getLogger(__name__)
# pylint: disable=too-many-ancestors
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
It allows to define all url kwargs and lookup fields to perform the lookup.
"""
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, item):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if item in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, item + "s", [None])[-1]
return super().__getattribute__(item)
def get_queryset(self):
"""
Get the list of items for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
queryset = super().get_queryset()
# The last lookup field is removed to perform the nested lookup as it corresponds
# to the object pk, it is used within get_object method.
lookup_url_kwargs = (
self.lookup_url_kwargs[:-1]
if self.lookup_url_kwargs
else self.lookup_fields[:-1]
)
filter_kwargs = {}
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
if lookup_url_kwarg not in self.kwargs:
raise KeyError(
f"Expected view {self.__class__.__name__} to be called with a URL "
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
"set the `.lookup_fields` attribute on the view correctly."
)
filter_kwargs.update(
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
)
return queryset.filter(**filter_kwargs)
class SerializerPerActionMixin:
"""
A mixin to allow to define serializer classes for each action.
@@ -110,10 +54,10 @@ class SerializerPerActionMixin:
return super().get_serializer_class()
class Pagination(drf.pagination.PageNumberPagination):
class Pagination(pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
ordering = "-created_at"
max_page_size = settings.MAX_PAGE_SIZE
page_size_query_param = "page_size"
@@ -132,9 +76,9 @@ class UserListThrottleSustained(UserRateThrottle):
class UserViewSet(
SerializerPerActionMixin,
drf.mixins.UpdateModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.ListModelMixin,
mixins.ListModelMixin,
):
"""User ViewSet"""
@@ -142,7 +86,7 @@ class UserViewSet(
queryset = models.User.objects.all().filter(is_active=True)
serializer_class = serializers.UserSerializer
get_me_serializer_class = serializers.UserMeSerializer
pagination_class = None
pagination_class = Pagination
throttle_classes = []
def get_throttles(self):
@@ -155,6 +99,7 @@ class UserViewSet(
def get_queryset(self):
"""
Limit listed users by querying the email field.
Scoped to the requesting user's organization.
If query contains "@", search exactly. Otherwise return empty.
"""
queryset = self.queryset
@@ -162,19 +107,22 @@ class UserViewSet(
if self.action != "list":
return queryset
# Scope to same organization; users without an org see no results
if not self.request.user.organization_id:
return queryset.none()
queryset = queryset.filter(organization_id=self.request.user.organization_id)
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none()
# For emails, match exactly
if "@" in query:
return queryset.filter(email__iexact=query).order_by("email")[
: settings.API_USERS_LIST_LIMIT
]
return queryset.filter(email__iexact=query).order_by("email")
# For non-email queries, return empty (no fuzzy search)
return queryset.none()
@drf.decorators.action(
@action(
detail=False,
methods=["get"],
url_name="me",
@@ -185,12 +133,12 @@ class UserViewSet(
Return information on currently logged user
"""
context = {"request": request}
return drf.response.Response(
return response.Response(
self.get_serializer(request.user, context=context).data
)
class ConfigView(drf.views.APIView):
class ConfigView(views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
@@ -224,7 +172,7 @@ class ConfigView(drf.views.APIView):
dict_settings["theme_customization"] = self._load_theme_customization()
return drf.response.Response(dict_settings)
return response.Response(dict_settings)
def _load_theme_customization(self):
if not settings.THEME_CUSTOMIZATION_FILE_PATH:
@@ -272,7 +220,7 @@ class CalendarViewSet(viewsets.GenericViewSet):
def get_permissions(self):
if self.action == "import_events":
return [permissions.IsEntitled()]
return [permissions.IsEntitledToAccess()]
return super().get_permissions()
@action(
@@ -286,12 +234,18 @@ class CalendarViewSet(viewsets.GenericViewSet):
"""Import events from an ICS file into a calendar.
POST /api/v1.0/calendars/import-events/
Body (multipart): file=<ics>, caldav_path=/calendars/user@.../uuid/
Body (multipart): file=<ics>, caldav_path=/calendars/users/user@.../uuid/
Returns a task_id that can be polled at GET /api/v1.0/tasks/{task_id}/
"""
from core.tasks import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
import_events_task,
)
caldav_path = request.data.get("caldav_path", "")
if not caldav_path:
return drf_response.Response(
{"error": "caldav_path is required"},
return response.Response(
{"detail": "caldav_path is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -299,15 +253,15 @@ class CalendarViewSet(viewsets.GenericViewSet):
# Verify user access
if not verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
return response.Response(
{"detail": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN,
)
# Validate file presence
if "file" not in request.FILES:
return drf_response.Response(
{"error": "No file provided"},
return response.Response(
{"detail": "No file provided"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -315,124 +269,81 @@ class CalendarViewSet(viewsets.GenericViewSet):
# Validate file size
if uploaded_file.size > MAX_FILE_SIZE:
return drf_response.Response(
{"error": "File too large. Maximum size is 10 MB."},
return response.Response(
{"detail": "File too large. Maximum size is 10 MB."},
status=status.HTTP_400_BAD_REQUEST,
)
ics_data = uploaded_file.read()
service = ICSImportService()
result = service.import_events(request.user, caldav_path, ics_data)
response_data = {
"total_events": result.total_events,
"imported_count": result.imported_count,
"duplicate_count": result.duplicate_count,
"skipped_count": result.skipped_count,
}
if result.errors:
response_data["errors"] = result.errors
# Queue the import task
task = import_events_task.delay(
str(request.user.id),
caldav_path,
ics_data.hex(),
)
task.track_owner(request.user.id)
return drf_response.Response(response_data, status=status.HTTP_200_OK)
return response.Response(
{"task_id": task.id},
status=status.HTTP_202_ACCEPTED,
)
class SubscriptionTokenViewSet(viewsets.GenericViewSet):
"""
ViewSet for managing subscription tokens independently of Django Calendar model.
class ResourceViewSet(viewsets.ViewSet):
"""ViewSet for resource provisioning (create/delete).
This viewset operates directly with CalDAV paths, without requiring a Django
Calendar record. The backend verifies that the user has access to the calendar
by checking that their email is in the CalDAV path.
Endpoints:
- POST /api/v1.0/subscription-tokens/ - Create or get existing token
- GET /api/v1.0/subscription-tokens/by-path/ - Get token by CalDAV path
- DELETE /api/v1.0/subscription-tokens/by-path/ - Delete token by CalDAV path
Resources are CalDAV principals — this endpoint only handles
provisioning. All metadata, sharing, and discovery goes through CalDAV.
"""
permission_classes = [IsAuthenticated]
serializer_class = serializers.CalendarSubscriptionTokenSerializer
permission_classes = [permissions.IsOrgAdmin]
def create(self, request):
"""Create a resource principal and its default calendar.
POST /api/v1.0/resources/
Body: {"name": "Room 101", "resource_type": "ROOM"}
"""
Create or get existing subscription token.
name = request.data.get("name", "").strip()
resource_type = request.data.get("resource_type", "ROOM").strip().upper()
POST body:
- caldav_path: The CalDAV path (e.g., /calendars/user@example.com/uuid/)
- calendar_name: Display name of the calendar (optional)
"""
create_serializer = serializers.CalendarSubscriptionTokenCreateSerializer(
data=request.data
)
create_serializer.is_valid(raise_exception=True)
caldav_path = create_serializer.validated_data["caldav_path"]
calendar_name = create_serializer.validated_data.get("calendar_name", "")
# Verify user has access to this calendar
if not verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN,
)
# Get or create token
token, created = models.CalendarSubscriptionToken.objects.get_or_create(
owner=request.user,
caldav_path=caldav_path,
defaults={"calendar_name": calendar_name},
)
# Update calendar_name if provided and different
if not created and calendar_name and token.calendar_name != calendar_name:
token.calendar_name = calendar_name
token.save(update_fields=["calendar_name"])
serializer = self.get_serializer(token, context={"request": request})
return drf_response.Response(
serializer.data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
@action(detail=False, methods=["get", "delete"], url_path="by-path")
def by_path(self, request):
"""
Get or delete subscription token by CalDAV path.
Query parameter:
- caldav_path: The CalDAV path (e.g., /calendars/user@example.com/uuid/)
"""
caldav_path = request.query_params.get("caldav_path")
if not caldav_path:
return drf_response.Response(
{"error": "caldav_path query parameter is required"},
if not name:
return response.Response(
{"detail": "name is required."},
status=status.HTTP_400_BAD_REQUEST,
)
caldav_path = normalize_caldav_path(caldav_path)
# Verify user has access to this calendar
if not verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN,
)
service = ResourceService()
try:
token = models.CalendarSubscriptionToken.objects.get(
owner=request.user,
caldav_path=caldav_path,
)
except models.CalendarSubscriptionToken.DoesNotExist:
return drf_response.Response(
{"error": "No subscription token exists for this calendar"},
status=status.HTTP_404_NOT_FOUND,
result = service.create_resource(request.user, name, resource_type)
except ResourceProvisioningError as e:
return response.Response(
{"detail": str(e)},
status=status.HTTP_400_BAD_REQUEST,
)
if request.method == "GET":
serializer = self.get_serializer(token, context={"request": request})
return drf_response.Response(serializer.data)
return response.Response(result, status=status.HTTP_201_CREATED)
# DELETE
token.delete()
return drf_response.Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, pk=None):
"""Delete a resource principal and its calendar.
DELETE /api/v1.0/resources/{resource_id}/
"""
resource_id = pk
if not resource_id:
return response.Response(
{"detail": "Resource ID is required."},
status=status.HTTP_400_BAD_REQUEST,
)
service = ResourceService()
try:
service.delete_resource(request.user, resource_id)
except ResourceProvisioningError as e:
return response.Response(
{"detail": str(e)},
status=status.HTTP_400_BAD_REQUEST,
)
return response.Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1,11 +1,14 @@
"""CalDAV proxy views for forwarding requests to CalDAV server."""
import logging
import re
import secrets
from django.conf import settings
from django.core.validators import validate_email
from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
@@ -13,6 +16,7 @@ from django.views.decorators.csrf import csrf_exempt
import requests
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import Channel
from core.services.caldav_service import CalDAVHTTPClient, validate_caldav_proxy_path
from core.services.calendar_invitation_service import calendar_invitation_service
@@ -30,6 +34,73 @@ class CalDAVProxyView(View):
Authentication is handled via session cookies instead.
"""
# HTTP methods allowed per Channel role
READER_METHODS = frozenset({"GET", "PROPFIND", "REPORT", "OPTIONS"})
EDITOR_METHODS = READER_METHODS | frozenset({"PUT", "POST", "DELETE", "PROPPATCH"})
ADMIN_METHODS = EDITOR_METHODS | frozenset({"MKCALENDAR", "MKCOL"})
ROLE_METHODS = {
Channel.ROLE_READER: READER_METHODS,
Channel.ROLE_EDITOR: EDITOR_METHODS,
Channel.ROLE_ADMIN: ADMIN_METHODS,
}
@staticmethod
def _authenticate_channel_token(request):
"""Try to authenticate via X-Channel-Id + X-Channel-Token headers.
Returns (channel, user) on success, (None, None) on failure.
"""
channel_id = request.headers.get("X-Channel-Id", "").strip()
token = request.headers.get("X-Channel-Token", "").strip()
if not channel_id or not token:
return None, None
try:
channel = Channel.objects.get(pk=channel_id, is_active=True, type="caldav")
except (ValueError, Channel.DoesNotExist):
return None, None
if not channel.verify_token(token):
return None, None
user = channel.user
if not user:
logger.warning("Channel %s has no user", channel.id)
return None, None
# Update last_used_at (fire-and-forget, no extra query on critical path)
Channel.objects.filter(pk=channel.pk).update(last_used_at=timezone.now())
return channel, user
@staticmethod
def _check_channel_path_access(channel, path):
"""Check that the CalDAV path is within the channel's scope.
Returns True if allowed, False if denied.
"""
# Ensure path starts with /
full_path = "/" + path.lstrip("/") if path else "/"
# caldav_path scope: request must be within the scoped calendar
# The trailing slash on caldav_path (enforced by serializer) ensures
# /cal1/ won't match /cal1-secret/
if channel.caldav_path:
if not channel.caldav_path.endswith("/"):
logger.error(
"caldav_path %r missing trailing slash", channel.caldav_path
)
return False
return full_path.startswith(channel.caldav_path)
# user scope: request must be under the user's calendars
if channel.user:
user_prefix = f"/calendars/users/{channel.user.email}/"
return full_path.startswith(user_prefix)
return False
@staticmethod
def _check_entitlements_for_creation(user):
"""Check if user is entitled to create calendars.
@@ -39,7 +110,7 @@ class CalDAVProxyView(View):
"""
try:
entitlements = get_user_entitlements(user.sub, user.email)
if not entitlements.get("can_access", True):
if not entitlements.get("can_access", False):
return HttpResponse(
status=403,
content="Calendar creation not allowed",
@@ -51,27 +122,42 @@ class CalDAVProxyView(View):
)
return None
def dispatch(self, request, *args, **kwargs): # noqa: PLR0912, PLR0911, PLR0915 # pylint: disable=too-many-branches,too-many-return-statements,too-many-statements
def dispatch(self, request, *args, **kwargs): # noqa: PLR0912, PLR0911, PLR0915 # pylint: disable=too-many-branches,too-many-return-statements,too-many-statements,too-many-locals
"""Forward all HTTP methods to CalDAV server."""
# Handle CORS preflight requests
if request.method == "OPTIONS":
response = HttpResponse(status=200)
response["Access-Control-Allow-Methods"] = (
"GET, OPTIONS, PROPFIND, PROPPATCH, REPORT, MKCOL, MKCALENDAR, PUT, DELETE, POST"
"GET, OPTIONS, PROPFIND, PROPPATCH, REPORT,"
" MKCOL, MKCALENDAR, PUT, DELETE, POST"
)
response["Access-Control-Allow-Headers"] = (
"Content-Type, depth, authorization, if-match, if-none-match, prefer"
"Content-Type, depth, x-channel-id, x-channel-token,"
" if-match, if-none-match, prefer"
)
return response
# Try channel token auth first (for external services like Messages)
channel = None
effective_user = None
if not request.user.is_authenticated:
return HttpResponse(status=401)
channel, effective_user = self._authenticate_channel_token(request)
if not channel:
return HttpResponse(status=401)
else:
effective_user = request.user
# Block calendar creation (MKCALENDAR/MKCOL) for non-entitled users.
# Other methods (GET, PROPFIND, REPORT, PUT, DELETE, etc.) are allowed
# so that users invited to shared calendars can still use them.
if channel:
# Enforce role-based method restrictions
allowed = self.ROLE_METHODS.get(channel.role, self.READER_METHODS)
if request.method not in allowed:
return HttpResponse(
status=403, content="Method not allowed for this role"
)
# Check entitlements for calendar creation (all auth methods)
if request.method in ("MKCALENDAR", "MKCOL"):
if denied := self._check_entitlements_for_creation(request.user):
if denied := self._check_entitlements_for_creation(effective_user):
return denied
# Build the CalDAV server URL
@@ -81,8 +167,9 @@ class CalDAVProxyView(View):
if not validate_caldav_proxy_path(path):
return HttpResponse(status=400, content="Invalid path")
# Use user email as the principal (CalDAV server uses email as username)
user_principal = request.user.email
# Enforce channel path scope
if channel and not self._check_channel_path_access(channel, path):
return HttpResponse(status=403, content="Path not allowed for this channel")
http = CalDAVHTTPClient()
@@ -95,7 +182,7 @@ class CalDAVProxyView(View):
# Prepare headers — start with shared auth headers, add proxy-specific ones
try:
headers = CalDAVHTTPClient.build_base_headers(user_principal)
headers = CalDAVHTTPClient.build_base_headers(effective_user)
except ValueError:
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
return HttpResponse(
@@ -112,7 +199,7 @@ class CalDAVProxyView(View):
# Use CALDAV_CALLBACK_BASE_URL if configured (for Docker environments where
# the CalDAV container needs to reach Django via internal network)
callback_path = reverse("caldav-scheduling-callback")
callback_base_url = getattr(settings, "CALDAV_CALLBACK_BASE_URL", None)
callback_base_url = settings.CALDAV_CALLBACK_BASE_URL
if callback_base_url:
# Use configured internal URL (e.g., http://backend:8000)
headers["X-CalDAV-Callback-URL"] = (
@@ -145,7 +232,7 @@ class CalDAVProxyView(View):
"Forwarding %s request to CalDAV server: %s (user: %s)",
request.method,
target_url,
user_principal,
effective_user.email,
)
response = requests.request(
method=request.method,
@@ -161,7 +248,7 @@ class CalDAVProxyView(View):
if response.status_code == 401:
logger.warning(
"CalDAV server returned 401 for user %s at %s",
user_principal,
effective_user.email,
target_url,
)
@@ -182,7 +269,7 @@ class CalDAVProxyView(View):
except requests.exceptions.RequestException as e:
logger.error("CalDAV server proxy error: %s", str(e))
return HttpResponse(
content=f"CalDAV server error: {str(e)}",
content="CalDAV server is unavailable",
status=502,
content_type="text/plain",
)
@@ -216,9 +303,8 @@ class CalDAVDiscoveryView(View):
# Clients need to discover the CalDAV URL before authenticating
# Return redirect to CalDAV server base URL
caldav_base_url = f"/api/{settings.API_VERSION}/caldav/"
response = HttpResponse(status=301)
response["Location"] = caldav_base_url
response["Location"] = "/caldav/"
return response
@@ -239,24 +325,25 @@ class CalDAVSchedulingCallbackView(View):
See: https://sabre.io/dav/scheduling/
"""
def dispatch(self, request, *args, **kwargs):
http_method_names = ["post"]
def post(self, request, *args, **kwargs): # noqa: PLR0911 # pylint: disable=too-many-return-statements
"""Handle scheduling messages from CalDAV server."""
# Authenticate via API key
api_key = request.headers.get("X-Api-Key", "").strip()
expected_key = settings.CALDAV_INBOUND_API_KEY
if not expected_key or not secrets.compare_digest(api_key, expected_key):
logger.warning(
"CalDAV scheduling callback request with invalid API key. "
"Expected: %s..., Got: %s...",
expected_key[:10] if expected_key else "None",
api_key[:10] if api_key else "None",
)
logger.warning("CalDAV scheduling callback request with invalid API key.")
return HttpResponse(status=401)
# Extract headers
sender = request.headers.get("X-CalDAV-Sender", "")
recipient = request.headers.get("X-CalDAV-Recipient", "")
# Extract and validate sender/recipient emails
sender = re.sub(
r"^mailto:", "", request.headers.get("X-CalDAV-Sender", "")
).strip()
recipient = re.sub(
r"^mailto:", "", request.headers.get("X-CalDAV-Recipient", "")
).strip()
method = request.headers.get("X-CalDAV-Method", "").upper()
# Validate required fields
@@ -275,6 +362,22 @@ class CalDAVSchedulingCallbackView(View):
content_type="text/plain",
)
# Validate email format
try:
validate_email(sender)
validate_email(recipient)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
logger.warning(
"CalDAV scheduling callback with invalid email: sender=%s, recipient=%s",
sender,
recipient,
)
return HttpResponse(
status=400,
content="Invalid sender or recipient email",
content_type="text/plain",
)
# Get iCalendar data from request body
icalendar_data = (
request.body.decode("utf-8", errors="replace") if request.body else ""
@@ -332,6 +435,6 @@ class CalDAVSchedulingCallbackView(View):
logger.exception("Error processing CalDAV scheduling callback: %s", e)
return HttpResponse(
status=500,
content=f"Internal error: {str(e)}",
content="Internal server error",
content_type="text/plain",
)

View File

@@ -0,0 +1,148 @@
"""Channel API for managing integration tokens."""
import logging
import secrets
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from core import models
from core.api import serializers
from core.services.caldav_service import verify_caldav_access
logger = logging.getLogger(__name__)
class ChannelViewSet(viewsets.GenericViewSet):
"""CRUD for integration channels.
Endpoints:
GET /api/v1.0/channels/ — list (filterable by ?type=)
POST /api/v1.0/channels/ — create (returns token once)
GET /api/v1.0/channels/{id}/ — retrieve
DELETE /api/v1.0/channels/{id}/ — delete
POST /api/v1.0/channels/{id}/regenerate-token/ — regenerate token
"""
permission_classes = [IsAuthenticated]
serializer_class = serializers.ChannelSerializer
def get_queryset(self):
return models.Channel.objects.filter(user=self.request.user).select_related(
"organization", "user"
)
def list(self, request):
"""List channels created by the current user, optionally filtered by type."""
queryset = self.get_queryset()
channel_type = request.query_params.get("type")
if channel_type:
queryset = queryset.filter(type=channel_type)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def create(self, request):
"""Create a new channel and return the token (once).
For type="ical-feed", returns an existing channel if one already
exists for the same user + caldav_path (get-or-create semantics).
"""
create_serializer = serializers.ChannelCreateSerializer(data=request.data)
create_serializer.is_valid(raise_exception=True)
data = create_serializer.validated_data
caldav_path = data.get("caldav_path", "")
channel_type = data.get("type", "caldav")
calendar_name = data.get("calendar_name", "")
# If a caldav_path is specified, verify the user has access
if caldav_path and not verify_caldav_access(request.user, caldav_path):
return Response(
{"detail": "You don't have access to this calendar."},
status=status.HTTP_403_FORBIDDEN,
)
# For ical-feed, return existing channel if one exists
if channel_type == "ical-feed" and caldav_path:
existing = (
self.get_queryset()
.filter(caldav_path=caldav_path, type="ical-feed")
.first()
)
if existing:
# Update calendar_name if provided and different
current_name = existing.settings.get("calendar_name", "")
if calendar_name and current_name != calendar_name:
existing.settings["calendar_name"] = calendar_name
existing.name = calendar_name
existing.save(update_fields=["settings", "name", "updated_at"])
serializer = self.get_serializer(existing, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
token = secrets.token_urlsafe(16)
channel_settings = {"role": data.get("role", models.Channel.ROLE_READER)}
if calendar_name:
channel_settings["calendar_name"] = calendar_name
channel = models.Channel(
name=data.get("name") or calendar_name or caldav_path or "Channel",
type=channel_type,
user=request.user,
caldav_path=caldav_path,
organization=request.user.organization,
settings=channel_settings,
encrypted_settings={"token": token},
)
channel.save()
# Attach plaintext token for the response (not persisted)
channel.token = token
serializer = serializers.ChannelWithTokenSerializer(
channel, context={"request": request}
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def retrieve(self, request, pk=None):
"""Retrieve a channel (without token)."""
channel = self._get_owned_channel(pk)
if channel is None:
return Response(status=status.HTTP_404_NOT_FOUND)
serializer = self.get_serializer(channel, context={"request": request})
return Response(serializer.data)
def destroy(self, request, pk=None):
"""Delete a channel."""
channel = self._get_owned_channel(pk)
if channel is None:
return Response(status=status.HTTP_404_NOT_FOUND)
channel.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["post"], url_path="regenerate-token")
def regenerate_token(self, request, pk=None):
"""Regenerate the token for an existing channel."""
channel = self._get_owned_channel(pk)
if channel is None:
return Response(status=status.HTTP_404_NOT_FOUND)
token = secrets.token_urlsafe(16)
channel.encrypted_settings = {
**channel.encrypted_settings,
"token": token,
}
channel.save(update_fields=["encrypted_settings", "updated_at"])
channel.token = token
serializer = serializers.ChannelWithTokenSerializer(
channel, context={"request": request}
)
return Response(serializer.data)
def _get_owned_channel(self, pk):
"""Get a channel owned by the current user, or None."""
try:
return self.get_queryset().get(pk=pk)
except models.Channel.DoesNotExist:
return None

View File

@@ -2,19 +2,24 @@
import logging
from django.core.cache import cache
from django.http import Http404, HttpResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.text import slugify
from django.views import View
from django.views.decorators.csrf import csrf_exempt
import requests
from core.models import CalendarSubscriptionToken
from core.models import Channel, urlsafe_to_uuid
from core.services.caldav_service import CalDAVHTTPClient
logger = logging.getLogger(__name__)
ICAL_RATE_LIMIT = 5 # requests per minute per channel
ICAL_RATE_WINDOW = 60 # seconds
@method_decorator(csrf_exempt, name="dispatch")
class ICalExportView(View):
@@ -22,40 +27,49 @@ class ICalExportView(View):
Public endpoint for iCal calendar exports.
This view serves calendar data in iCal format without requiring authentication.
The token in the URL path acts as the authentication mechanism.
The channel_id in the URL is used for lookup, and the token for authentication.
URL format: /ical/<uuid:token>.ics
URL format: /ical/<short_id>/<token>/<slug>.ics
The view proxies the request to SabreDAV's ICSExportPlugin, which generates
RFC 5545 compliant iCal data.
Looks up a Channel by base64url-encoded ID, verifies the token, then
proxies the request to SabreDAV's ICSExportPlugin.
"""
def get(self, request, token):
def get(self, request, short_id, token):
"""Handle GET requests for iCal export."""
# Lookup token
subscription = (
CalendarSubscriptionToken.objects.filter(token=token, is_active=True)
.select_related("owner")
.first()
)
try:
channel_id = urlsafe_to_uuid(short_id)
channel = Channel.objects.get(pk=channel_id, is_active=True)
except (ValueError, Channel.DoesNotExist) as exc:
raise Http404("Calendar not found") from exc
if not subscription:
logger.warning("Invalid or inactive subscription token: %s", token)
if channel.type != "ical-feed":
raise Http404("Calendar not found")
# Update last_accessed_at atomically to avoid race conditions
# when multiple calendar clients poll simultaneously
CalendarSubscriptionToken.objects.filter(token=token, is_active=True).update(
last_accessed_at=timezone.now()
)
if not channel.verify_token(token):
raise Http404("Calendar not found")
if not channel.user:
logger.warning("ical-feed channel %s has no user", channel.id)
raise Http404("Calendar not found")
# Rate limit: 5 requests per minute per channel
rate_key = f"ical_rate:{channel_id}"
hits = cache.get(rate_key, 0)
if hits >= ICAL_RATE_LIMIT:
return HttpResponse(status=429, content="Too many requests")
cache.set(rate_key, hits + 1, ICAL_RATE_WINDOW)
# Update last_used_at
Channel.objects.filter(pk=channel.pk).update(last_used_at=timezone.now())
# Proxy to SabreDAV
http = CalDAVHTTPClient()
try:
caldav_path = subscription.caldav_path.lstrip("/")
caldav_path = channel.caldav_path.lstrip("/")
response = http.request(
"GET",
subscription.owner.email,
channel.user,
caldav_path,
query="export",
)
@@ -88,15 +102,12 @@ class ICalExportView(View):
status=200,
content_type="text/calendar; charset=utf-8",
)
# Set filename for download (use calendar_name or fallback to "calendar")
display_name = subscription.calendar_name or "calendar"
safe_name = display_name.replace('"', '\\"')
calendar_name = channel.settings.get("calendar_name", "")
filename = slugify(calendar_name)[:50] or "feed"
django_response["Content-Disposition"] = (
f'attachment; filename="{safe_name}.ics"'
f'attachment; filename="{filename}.ics"'
)
# Prevent caching of potentially sensitive data
django_response["Cache-Control"] = "no-store, private"
# Prevent token leakage via referrer
django_response["Referrer-Policy"] = "no-referrer"
return django_response

View File

@@ -1,16 +1,28 @@
"""RSVP view for handling invitation responses from email links."""
"""RSVP view for handling invitation responses from email links.
GET /rsvp/?token=...&action=accepted -> renders a confirmation page that
auto-submits via JavaScript (no extra click for the user).
POST /api/v1.0/rsvp/ -> processes the RSVP and returns a
result page. Link previewers / prefetchers only issue GET, so the
state-changing work is safely behind POST.
"""
import logging
import re
from datetime import timezone as dt_timezone
from django.core.signing import BadSignature, Signer
from django.conf import settings
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.shortcuts import render
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from rest_framework.throttling import AnonRateThrottle
from rest_framework.views import APIView
from core.models import User
from core.services.caldav_service import CalDAVHTTPClient
from core.services.translation_service import TranslationService
@@ -35,7 +47,7 @@ PARTSTAT_VALUES = {
}
def _render_error(request, message, lang="fr"):
def _render_error(request, message, lang="en"):
"""Render the RSVP error page."""
t = TranslationService.t
return render(
@@ -85,69 +97,169 @@ def _is_event_past(icalendar_data):
return False
@method_decorator(csrf_exempt, name="dispatch")
class RSVPView(View):
"""Handle RSVP responses from invitation email links."""
def _validate_token(token, max_age=None):
"""Unsign and validate an RSVP token.
def get(self, request): # noqa: PLR0911 # pylint: disable=too-many-return-statements
"""Process an RSVP response."""
Returns (payload, error_key). On success error_key is None.
"""
ts_signer = TimestampSigner(salt="rsvp")
try:
payload = ts_signer.unsign_object(token, max_age=max_age)
except SignatureExpired:
return None, "token_expired"
except BadSignature:
return None, "invalid_token"
uid = payload.get("uid")
recipient_email = payload.get("email")
organizer_email = payload.get("organizer", "")
# Strip mailto: prefix (case-insensitive) in case it leaked into the token
organizer_email = re.sub(r"^mailto:", "", organizer_email, flags=re.IGNORECASE)
if not uid or not recipient_email or not organizer_email:
return None, "invalid_payload"
payload["organizer"] = organizer_email
return payload, None
_TOKEN_ERROR_KEYS = {
"token_expired": "rsvp.error.tokenExpired",
"invalid_token": "rsvp.error.invalidToken",
"invalid_payload": "rsvp.error.invalidPayload",
}
def _validate_and_render_error(request, token, action, lang):
"""Validate action + token; return (payload, error_response).
On success error_response is None.
"""
t = TranslationService.t
if action not in PARTSTAT_VALUES:
return None, _render_error(request, t("rsvp.error.invalidAction", lang), lang)
payload, error = _validate_token(
token, max_age=settings.RSVP_TOKEN_MAX_AGE_RECURRING
)
if error:
return None, _render_error(request, t(_TOKEN_ERROR_KEYS[error], lang), lang)
return payload, None
@method_decorator(csrf_exempt, name="dispatch")
class RSVPConfirmView(View):
"""GET handler: render auto-submitting confirmation page.
This page is safe for link previewers / prefetchers because it
doesn't change any state — only the POST endpoint does.
"""
def get(self, request):
"""Render a page that auto-submits the RSVP via POST."""
token = request.GET.get("token", "")
action = request.GET.get("action", "")
lang = TranslationService.resolve_language(request=request)
_, error_response = _validate_and_render_error(request, token, action, lang)
if error_response:
return error_response
# Render auto-submit page
label = TranslationService.t(f"rsvp.{action}", lang)
return render(
request,
"rsvp/confirm.html",
{
"page_title": label,
"token": token,
"action": action,
"lang": lang,
"heading": label,
"status_icon": PARTSTAT_ICONS[action],
"header_color": PARTSTAT_COLORS[action],
"submit_label": label,
"post_url": f"/api/{settings.API_VERSION}/rsvp/",
},
)
class RSVPThrottle(AnonRateThrottle):
"""Throttle RSVP POST requests: 30/min per IP."""
rate = "30/minute"
def _process_rsvp(request, payload, action, lang):
"""Execute the RSVP: find event, update PARTSTAT, PUT back.
Returns an error response on failure, or the updated calendar data
string on success.
"""
t = TranslationService.t
http = CalDAVHTTPClient()
try:
organizer = User.objects.get(email=payload["organizer"])
except User.DoesNotExist:
return _render_error(request, t("rsvp.error.eventNotFound", lang), lang)
calendar_data, href, etag = http.find_event_by_uid(organizer, payload["uid"])
if not calendar_data or not href:
return _render_error(request, t("rsvp.error.eventNotFound", lang), lang)
if _is_event_past(calendar_data):
return _render_error(request, t("rsvp.error.eventPast", lang), lang)
updated_data = CalDAVHTTPClient.update_attendee_partstat(
calendar_data, payload["email"], PARTSTAT_VALUES[action]
)
if not updated_data:
return _render_error(request, t("rsvp.error.notAttendee", lang), lang)
if not http.put_event(organizer, href, updated_data, etag=etag):
return _render_error(request, t("rsvp.error.updateFailed", lang), lang)
return calendar_data
class RSVPProcessView(APIView):
"""POST handler: actually process the RSVP.
Uses DRF's AnonRateThrottle for rate limiting. No authentication
required — the signed token acts as authorization.
"""
authentication_classes = []
permission_classes = []
throttle_classes = [RSVPThrottle]
def post(self, request):
"""Process the RSVP response."""
token = request.data.get("token", "")
action = request.data.get("action", "")
lang = TranslationService.resolve_language(request=request)
t = TranslationService.t
# Validate action
if action not in PARTSTAT_VALUES:
return _render_error(request, t("rsvp.error.invalidAction", lang), lang)
# Unsign token — tokens don't have a built-in expiry,
# but RSVPs are rejected once the event has ended (_is_event_past).
signer = Signer(salt="rsvp")
try:
payload = signer.unsign_object(token)
except BadSignature:
return _render_error(request, t("rsvp.error.invalidToken", lang), lang)
uid = payload.get("uid")
recipient_email = payload.get("email")
# Strip mailto: prefix (case-insensitive) in case it leaked into the token
organizer_email = re.sub(
r"^mailto:", "", payload.get("organizer", ""), flags=re.IGNORECASE
payload, error_response = _validate_and_render_error(
request, token, action, lang
)
if error_response:
return error_response
if not uid or not recipient_email or not organizer_email:
return _render_error(request, t("rsvp.error.invalidPayload", lang), lang)
result = _process_rsvp(request, payload, action, lang)
http = CalDAVHTTPClient()
# result is either an error HttpResponse or calendar data string
if not isinstance(result, str):
return result
# Find the event in the organizer's CalDAV calendars
calendar_data, href = http.find_event_by_uid(organizer_email, uid)
if not calendar_data or not href:
return _render_error(request, t("rsvp.error.eventNotFound", lang), lang)
# Check if the event is already over
if _is_event_past(calendar_data):
return _render_error(request, t("rsvp.error.eventPast", lang), lang)
# Update the attendee's PARTSTAT
partstat = PARTSTAT_VALUES[action]
updated_data = CalDAVHTTPClient.update_attendee_partstat(
calendar_data, recipient_email, partstat
)
if not updated_data:
return _render_error(request, t("rsvp.error.notAttendee", lang), lang)
# PUT the updated event back to CalDAV
success = http.put_event(organizer_email, href, updated_data)
if not success:
return _render_error(request, t("rsvp.error.updateFailed", lang), lang)
# Extract event summary for display
from core.services.calendar_invitation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
ICalendarParser,
)
summary = ICalendarParser.extract_property(calendar_data, "SUMMARY") or ""
summary = ICalendarParser.extract_property(result, "SUMMARY") or ""
label = t(f"rsvp.{action}", lang)
return render(

View File

@@ -0,0 +1,104 @@
"""API endpoint for polling async task status."""
import logging
import uuid
import dramatiq
from dramatiq.results import ResultFailure, ResultMissing
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from core.task_utils import get_task_progress, get_task_tracking
logger = logging.getLogger(__name__)
class TaskDetailView(APIView):
"""View to retrieve the status of an async task."""
permission_classes = [IsAuthenticated]
def get(self, request, task_id): # noqa: PLR0911 # pylint: disable=too-many-return-statements
"""Get the status of a task."""
try:
uuid.UUID(task_id)
except ValueError:
return Response(
{"status": "FAILURE", "result": None, "error": "Not found"},
status=404,
)
tracking = get_task_tracking(task_id)
if tracking is None:
return Response(
{"status": "FAILURE", "result": None, "error": "Not found"},
status=404,
)
if str(request.user.id) != tracking["owner"]:
return Response(
{"status": "FAILURE", "result": None, "error": "Forbidden"},
status=403,
)
# Try to fetch the result from dramatiq's result backend
message = dramatiq.Message(
queue_name=tracking["queue_name"],
actor_name=tracking["actor_name"],
args=(),
kwargs={},
options={},
message_id=task_id,
)
try:
result_data = message.get_result(block=False)
except ResultMissing:
result_data = None
except ResultFailure as exc:
logger.error("Task %s failed: %s", task_id, exc)
return Response(
{
"status": "FAILURE",
"result": None,
"error": "Task failed",
}
)
if result_data is not None:
resp = {
"status": "SUCCESS",
"result": result_data,
"error": None,
}
# Unpack {status, result, error} convention
if (
isinstance(result_data, dict)
and {"status", "result", "error"} <= result_data.keys()
):
resp["status"] = result_data["status"]
resp["result"] = result_data["result"]
resp["error"] = result_data["error"]
return Response(resp)
# Check for progress data
progress_data = get_task_progress(task_id)
if progress_data:
return Response(
{
"status": "PROGRESS",
"result": None,
"error": None,
"progress": progress_data.get("progress"),
"message": progress_data.get("metadata", {}).get("message"),
"timestamp": progress_data.get("timestamp"),
}
)
# Default to pending
return Response(
{
"status": "PENDING",
"result": None,
"error": None,
}
)

View File

@@ -1,7 +1,6 @@
"""Calendars Core application"""
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig):
@@ -9,7 +8,7 @@ class CoreConfig(AppConfig):
name = "core"
app_label = "core"
verbose_name = _("calendars core application")
verbose_name = "calendars core application"
def ready(self):
"""

View File

@@ -10,11 +10,51 @@ from lasuite.oidc_login.backends import (
)
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import DuplicateEmailError
from core.models import DuplicateEmailError, Organization
logger = logging.getLogger(__name__)
def _resolve_org_external_id(claims, email=None):
"""Extract the organization external_id from OIDC claims or email domain."""
claim_key = settings.OIDC_USERINFO_ORGANIZATION_CLAIM
if claim_key:
return claims.get(claim_key)
email = email or claims.get("email")
return email.split("@")[-1] if email and "@" in email else None
def resolve_organization(user, claims, entitlements=None):
"""Resolve and assign the user's organization.
The org identifier (external_id) comes from the OIDC claim configured via
OIDC_USERINFO_ORGANIZATION_CLAIM, or falls back to the email domain.
The org name comes from the entitlements response.
"""
entitlements = entitlements or {}
external_id = _resolve_org_external_id(claims, email=user.email)
if not external_id:
logger.error(
"Cannot resolve organization for user %s: no org claim or email domain",
user.email,
)
return
org_name = entitlements.get("organization_name", "") or external_id
org, created = Organization.objects.get_or_create(
external_id=external_id,
defaults={"name": org_name},
)
if not created and org_name and org.name != org_name:
org.name = org_name
org.save(update_fields=["name"])
if user.organization_id != org.id:
user.organization = org
user.save(update_fields=["organization"])
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -23,39 +63,46 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""
def get_extra_claims(self, user_info):
"""
Return extra claims from user_info.
Args:
user_info (dict): The user information dictionary.
Returns:
dict: A dictionary of extra claims.
"""
# We need to add the claims that we want to store so that they are
# available in the post_get_or_create_user method.
"""Return extra claims from user_info."""
claims_to_store = {
claim: user_info.get(claim) for claim in settings.OIDC_STORE_CLAIMS
}
return {
"full_name": self.compute_full_name(user_info),
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
"claims": claims_to_store,
}
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
def create_user(self, claims):
"""Create a new user, resolving their organization first.
Organization is NOT NULL, so we must resolve it before the initial save.
"""
external_id = _resolve_org_external_id(claims)
if not external_id:
raise SuspiciousOperation(
"Cannot create user without an organization "
"(no org claim and no email domain)"
)
org, _ = Organization.objects.get_or_create(
external_id=external_id,
defaults={"name": external_id},
)
claims["organization"] = org
return super().create_user(claims)
def post_get_or_create_user(self, user, claims, is_new_user):
"""Warm the entitlements cache on login (force_refresh)."""
"""Warm the entitlements cache and resolve organization on login."""
entitlements = {}
try:
get_user_entitlements(
entitlements = get_user_entitlements(
user_sub=user.sub,
user_email=user.email,
user_info=claims,
@@ -66,3 +113,5 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"Entitlements unavailable for %s during login",
user.email,
)
resolve_organization(user, claims, entitlements)

View File

@@ -17,7 +17,7 @@ def get_user_entitlements(user_sub, user_email, user_info=None, force_refresh=Fa
force_refresh: If True, bypass backend cache and fetch fresh data.
Returns:
dict: {"can_access": bool}
dict: {"can_access": bool, "can_admin": bool, ...}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached

View File

@@ -20,7 +20,11 @@ class EntitlementsBackend(ABC):
force_refresh: If True, bypass any cache and fetch fresh data.
Returns:
dict: {"can_access": bool}
dict: {
"can_access": bool,
"can_admin": bool,
"organization_name": str, # optional, extracted from response
}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached.

View File

@@ -114,7 +114,14 @@ class DeployCenterEntitlementsBackend(EntitlementsBackend):
entitlements = data.get("entitlements", {})
result = {
"can_access": entitlements.get("can_access", False),
"can_admin": entitlements.get("can_admin", False),
}
# Organization name from DeployCenter response (if present)
org = data.get("organization") or {}
org_name = org.get("name", "")
if org_name:
result["organization_name"] = org_name
cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT)
return result

View File

@@ -4,9 +4,9 @@ from core.entitlements.backends.base import EntitlementsBackend
class LocalEntitlementsBackend(EntitlementsBackend):
"""Local backend that always grants access."""
"""Local backend that always grants access and admin."""
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
return {"can_access": True}
return {"can_access": True, "can_admin": True}

View File

@@ -1,12 +1,3 @@
"""
Core application enums declaration
"""
from django.conf import global_settings
from django.utils.translation import gettext_lazy as _
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
# We can use it for the choice of languages which should not be limited to the few languages
# active in the app.
# pylint: disable=no-member
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}

View File

@@ -4,7 +4,7 @@ from django.conf import settings
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from core.api.permissions import AccessPermission, IsSelf
from core.api.permissions import IsSelf
from core.api.viewsets import UserViewSet
from core.external_api.permissions import ResourceServerClientPermission

View File

@@ -2,6 +2,8 @@
Core application factories
"""
import secrets
from django.conf import settings
from django.contrib.auth.hashers import make_password
@@ -13,6 +15,16 @@ from core import models
fake = Faker()
class OrganizationFactory(factory.django.DjangoModelFactory):
"""A factory to create organizations for testing purposes."""
class Meta:
model = models.Organization
name = factory.Faker("company")
external_id = factory.Sequence(lambda n: f"org-{n}")
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to random users for testing purposes."""
@@ -23,20 +35,32 @@ class UserFactory(factory.django.DjangoModelFactory):
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
full_name = factory.Faker("name")
short_name = factory.Faker("first_name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
organization = factory.SubFactory(OrganizationFactory)
class CalendarSubscriptionTokenFactory(factory.django.DjangoModelFactory):
"""A factory to create calendar subscription tokens for testing purposes."""
class ChannelFactory(factory.django.DjangoModelFactory):
"""A factory to create channels for testing purposes."""
class Meta:
model = models.CalendarSubscriptionToken
model = models.Channel
owner = factory.SubFactory(UserFactory)
caldav_path = factory.LazyAttribute(
lambda obj: f"/calendars/{obj.owner.email}/{fake.uuid4()}/"
name = factory.Faker("sentence", nb_words=3)
user = factory.SubFactory(UserFactory)
settings = factory.LazyFunction(lambda: {"role": "reader"})
encrypted_settings = factory.LazyFunction(
lambda: {"token": secrets.token_urlsafe(16)}
)
class ICalFeedChannelFactory(ChannelFactory):
"""A factory to create ical-feed channels."""
type = "ical-feed"
caldav_path = factory.LazyAttribute(
lambda obj: f"/calendars/users/{obj.user.email}/{fake.uuid4()}/"
)
settings = factory.LazyAttribute(
lambda obj: {"role": "reader", "calendar_name": fake.sentence(nb_words=3)}
)
calendar_name = factory.Faker("sentence", nb_words=3)
is_active = True

View File

@@ -1,8 +1,9 @@
# Generated by Django 5.2.9 on 2026-01-11 00:45
# Generated by Django 5.2.9 on 2026-03-08 21:40
import core.models
import django.core.validators
import django.db.models.deletion
import encrypted_fields.fields
import timezone_field.fields
import uuid
from django.conf import settings
@@ -18,6 +19,21 @@ class Migration(migrations.Migration):
]
operations = [
migrations.CreateModel(
name='Organization',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('name', models.CharField(blank=True, default='', max_length=200)),
('external_id', models.CharField(db_index=True, help_text='Organization identifier from OIDC claim or email domain.', max_length=128, unique=True)),
],
options={
'verbose_name': 'organization',
'verbose_name_plural': 'organizations',
'db_table': 'calendars_organization',
},
),
migrations.CreateModel(
name='User',
fields=[
@@ -29,8 +45,7 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub')),
('full_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='full name')),
('short_name', models.CharField(blank=True, max_length=20, null=True, verbose_name='short name')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(blank=True, choices=[('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'), ('nl-nl', 'Dutch')], default=None, help_text='The language in which the user wants to see the interface.', max_length=10, null=True, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
@@ -40,6 +55,7 @@ class Migration(migrations.Migration):
('claims', models.JSONField(blank=True, default=dict, help_text='Claims from the OIDC token.')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
('organization', models.ForeignKey(help_text='The organization this user belongs to.', on_delete=django.db.models.deletion.PROTECT, related_name='members', to='core.organization')),
],
options={
'verbose_name': 'user',
@@ -51,40 +67,26 @@ class Migration(migrations.Migration):
],
),
migrations.CreateModel(
name='Calendar',
name='Channel',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('color', models.CharField(default='#3174ad', max_length=7)),
('description', models.TextField(blank=True, default='')),
('is_default', models.BooleanField(default=False)),
('is_visible', models.BooleanField(default=True)),
('caldav_path', models.CharField(max_length=512, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calendars', to=settings.AUTH_USER_MODEL)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('name', models.CharField(help_text='Human-readable name for this channel.', max_length=255)),
('type', models.CharField(default='caldav', help_text='Type of channel.', max_length=255)),
('caldav_path', models.CharField(blank=True, default='', help_text='CalDAV path scope (e.g. /calendars/users/user@ex.com/cal/).', max_length=512)),
('is_active', models.BooleanField(default=True)),
('settings', models.JSONField(blank=True, default=dict, help_text='Channel-specific configuration settings (e.g. role).', verbose_name='settings')),
('encrypted_settings', encrypted_fields.fields.EncryptedJSONField(blank=True, default=dict, help_text='Encrypted channel settings (e.g. token).', verbose_name='encrypted settings')),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(blank=True, help_text='User who created this channel (used for permissions and auditing).', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='channels', to=settings.AUTH_USER_MODEL)),
('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='channels', to='core.organization')),
],
options={
'ordering': ['-is_default', 'name'],
'verbose_name': 'channel',
'verbose_name_plural': 'channels',
'db_table': 'calendars_channel',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='CalendarShare',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('permission', models.CharField(choices=[('read', 'Read only'), ('write', 'Read and write')], default='read', max_length=10)),
('is_visible', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('calendar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='core.calendar')),
('shared_with', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shared_calendars', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddConstraint(
model_name='calendar',
constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('owner',), name='unique_default_calendar_per_user'),
),
migrations.AlterUniqueTogether(
name='calendarshare',
unique_together={('calendar', 'shared_with')},
),
]

View File

@@ -1,34 +0,0 @@
# Generated by Django 5.2.9 on 2026-01-25 14:21
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CalendarSubscriptionToken',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('caldav_path', models.CharField(help_text='CalDAV path of the calendar', max_length=512)),
('calendar_name', models.CharField(blank=True, default='', help_text='Display name of the calendar', max_length=255)),
('token', models.UUIDField(db_index=True, default=uuid.uuid4, help_text='Secret token used in the subscription URL', unique=True)),
('is_active', models.BooleanField(default=True, help_text='Whether this subscription token is active')),
('last_accessed_at', models.DateTimeField(blank=True, help_text='Last time this subscription URL was accessed', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscription_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'calendar subscription token',
'verbose_name_plural': 'calendar subscription tokens',
'constraints': [models.UniqueConstraint(fields=('owner', 'caldav_path'), name='unique_token_per_owner_calendar')],
},
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.2.9 on 2026-01-25 15:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_calendarsubscriptiontoken'),
]
operations = [
migrations.AddIndex(
model_name='calendarsubscriptiontoken',
index=models.Index(fields=['token', 'is_active'], name='token_active_idx'),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.2.9 on 2026-02-09 18:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0003_calendarsubscriptiontoken_token_active_idx'),
]
operations = [
migrations.DeleteModel(
name='CalendarShare',
),
migrations.DeleteModel(
name='Calendar',
),
]

View File

@@ -2,6 +2,8 @@
Declare and configure the models for the calendars core application
"""
import base64
import secrets
import uuid
from logging import getLogger
@@ -10,47 +12,13 @@ from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.core import mail, validators
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from encrypted_fields.fields import EncryptedJSONField
from timezone_field import TimeZoneField
logger = getLogger(__name__)
class LinkRoleChoices(models.TextChoices):
"""Defines the possible roles a link can offer on a item."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
class RoleChoices(models.TextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(models.TextChoices):
"""Defines types of access for links"""
RESTRICTED = (
"restricted",
_("Restricted"),
) # Only users with a specific access can read/edit the item
AUTHENTICATED = (
"authenticated",
_("Authenticated"),
) # Any authenticated user can access the item
PUBLIC = "public", _("Public") # Even anonymous users can access the item
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
@@ -70,21 +38,21 @@ class BaseModel(models.Model):
"""
id = models.UUIDField(
verbose_name=_("id"),
help_text=_("primary key for the record as UUID"),
verbose_name="id",
help_text="primary key for the record as UUID",
primary_key=True,
default=uuid.uuid4,
editable=False,
)
created_at = models.DateTimeField(
verbose_name=_("created on"),
help_text=_("date and time at which a record was created"),
verbose_name="created on",
help_text="date and time at which a record was created",
auto_now_add=True,
editable=False,
)
updated_at = models.DateTimeField(
verbose_name=_("updated on"),
help_text=_("date and time at which a record was last updated"),
verbose_name="updated on",
help_text="date and time at which a record was last updated",
auto_now=True,
editable=False,
)
@@ -98,6 +66,46 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
class Organization(BaseModel):
"""Organization model, populated from OIDC claims and entitlements.
Every user belongs to exactly one organization, determined by their
email domain (default) or a configurable OIDC claim. Orgs are
created automatically on first login.
"""
name = models.CharField(max_length=200, blank=True, default="")
external_id = models.CharField(
max_length=128,
unique=True,
db_index=True,
help_text="Organization identifier from OIDC claim or email domain.",
)
class Meta:
db_table = "calendars_organization"
verbose_name = "organization"
verbose_name_plural = "organizations"
def __str__(self):
return self.name or self.external_id
def delete(self, *args, **kwargs):
"""Delete org after cleaning up members' CalDAV data.
Must run before super().delete() because the User FK uses
on_delete=PROTECT, which blocks deletion while members exist.
The pre_delete signal would never fire with PROTECT, so the
cleanup logic lives here instead.
"""
from core.services.caldav_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
cleanup_organization_caldav_data,
)
cleanup_organization_caldav_data(self)
super().delete(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
@@ -119,10 +127,8 @@ class UserManager(auth_models.UserManager):
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise DuplicateEmailError(
_(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
) from err
return None
@@ -132,16 +138,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-:]+\Z",
message=_(
message=(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
"sub",
help_text=(
"Required. 255 characters or fewer."
" Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255,
unique=True,
@@ -150,23 +157,24 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
null=True,
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
full_name = models.CharField("full name", max_length=100, null=True, blank=True)
email = models.EmailField(_("identity email address"), blank=True, null=True)
email = models.EmailField(
"identity email address", blank=True, null=True, db_index=True
)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
# stores the email used by staff users to login to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
"admin email address", unique=True, blank=True, null=True
)
language = models.CharField(
max_length=10,
choices=settings.LANGUAGES,
default=None,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
verbose_name="language",
help_text="The language in which the user wants to see the interface.",
null=True,
blank=True,
)
@@ -174,31 +182,38 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
choices_display="WITH_GMT_OFFSET",
use_pytz=False,
default=settings.TIME_ZONE,
help_text=_("The timezone in which the user wants to see times."),
help_text="The timezone in which the user wants to see times.",
)
is_device = models.BooleanField(
_("device"),
"device",
default=False,
help_text=_("Whether the user is a device or a real user."),
help_text="Whether the user is a device or a real user.",
)
is_staff = models.BooleanField(
_("staff status"),
"staff status",
default=False,
help_text=_("Whether the user can log into this admin site."),
help_text="Whether the user can log into this admin site.",
)
is_active = models.BooleanField(
_("active"),
"active",
default=True,
help_text=_(
help_text=(
"Whether this user should be treated as active. "
"Unselect this instead of deleting accounts."
),
)
organization = models.ForeignKey(
Organization,
on_delete=models.PROTECT,
related_name="members",
help_text="The organization this user belongs to.",
)
claims = models.JSONField(
blank=True,
default=dict,
help_text=_("Claims from the OIDC token."),
help_text="Claims from the OIDC token.",
)
objects = UserManager()
@@ -208,8 +223,8 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
class Meta:
db_table = "calendars_user"
verbose_name = _("user")
verbose_name_plural = _("users")
verbose_name = "user"
verbose_name_plural = "users"
def __str__(self):
return self.email or self.admin_email or str(self.id)
@@ -220,152 +235,123 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
@cached_property
def teams(self):
"""
Get list of teams in which the user is, as a list of strings.
Must be cached if retrieved remotely.
"""
return []
def uuid_to_urlsafe(u):
"""Encode a UUID as unpadded base64url (22 chars)."""
return base64.urlsafe_b64encode(u.bytes).rstrip(b"=").decode()
class BaseAccess(BaseModel):
"""Base model for accesses to handle resources."""
def urlsafe_to_uuid(s):
"""Decode an unpadded base64url string back to a UUID."""
padded = s + "=" * (-len(s) % 4)
return uuid.UUID(bytes=base64.urlsafe_b64decode(padded))
class Channel(BaseModel):
"""Integration channel for external service access to calendars.
Follows the same pattern as the Messages Channel model. Allows external
services (e.g. Messages) to access CalDAV on behalf of a user via a
bearer token.
Configuration is split between ``settings`` (public, non-sensitive) and
``encrypted_settings`` (sensitive data like tokens). The ``role`` for
CalDAV access control lives in ``settings``.
For iCal feeds, the URL contains the base64url-encoded channel ID (for
lookup) and a base64url token (for authentication):
``/ical/<short_id>/<token>/<slug>.ics``.
"""
ROLE_READER = "reader"
ROLE_EDITOR = "editor"
ROLE_ADMIN = "admin"
VALID_ROLES = {ROLE_READER, ROLE_EDITOR, ROLE_ADMIN}
name = models.CharField(
max_length=255,
help_text="Human-readable name for this channel.",
)
type = models.CharField(
max_length=255,
help_text="Type of channel.",
default="caldav",
)
user = models.ForeignKey(
User,
"User",
on_delete=models.CASCADE,
null=True,
blank=True,
)
team = models.CharField(max_length=100, blank=True)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
related_name="channels",
help_text="User who created this channel (used for permissions and auditing).",
)
class Meta:
abstract = True
def _get_abilities(self, resource, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = []
if user.is_authenticated:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
if self.role == RoleChoices.OWNER:
can_delete = (
RoleChoices.OWNER in roles
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(roles),
"set_role_to": set_role_to,
}
class CalendarSubscriptionToken(models.Model):
"""
Stores subscription tokens for iCal export.
Each calendar can have one token that allows unauthenticated read-only access
via a public URL for use in external calendar applications.
This model is standalone and stores the CalDAV path directly,
without requiring a foreign key to the Calendar model.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# Owner of the calendar (for permission verification)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="subscription_tokens",
null=True,
blank=True,
related_name="channels",
)
# CalDAV path stored directly (e.g., /calendars/user@example.com/uuid/)
caldav_path = models.CharField(
max_length=512,
help_text=_("CalDAV path of the calendar"),
)
# Calendar display name (for UI display)
calendar_name = models.CharField(
max_length=255,
blank=True,
default="",
help_text=_("Display name of the calendar"),
help_text="CalDAV path scope (e.g. /calendars/users/user@ex.com/cal/).",
)
token = models.UUIDField(
unique=True,
db_index=True,
default=uuid.uuid4,
help_text=_("Secret token used in the subscription URL"),
)
is_active = models.BooleanField(
default=True,
help_text=_("Whether this subscription token is active"),
)
last_accessed_at = models.DateTimeField(
null=True,
is_active = models.BooleanField(default=True)
settings = models.JSONField(
"settings",
default=dict,
blank=True,
help_text=_("Last time this subscription URL was accessed"),
help_text="Channel-specific configuration settings (e.g. role).",
)
created_at = models.DateTimeField(auto_now_add=True)
encrypted_settings = EncryptedJSONField(
"encrypted settings",
default=dict,
blank=True,
help_text="Encrypted channel settings (e.g. token).",
)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("calendar subscription token")
verbose_name_plural = _("calendar subscription tokens")
constraints = [
models.UniqueConstraint(
fields=["owner", "caldav_path"],
name="unique_token_per_owner_calendar",
)
]
indexes = [
# Composite index for the public iCal endpoint query:
# CalendarSubscriptionToken.objects.filter(token=..., is_active=True)
models.Index(fields=["token", "is_active"], name="token_active_idx"),
]
db_table = "calendars_channel"
verbose_name = "channel"
verbose_name_plural = "channels"
ordering = ["-created_at"]
def __str__(self):
return f"Subscription token for {self.calendar_name or self.caldav_path}"
return self.name
@property
def role(self):
"""Get the role from settings, defaulting to reader."""
return self.settings.get("role", self.ROLE_READER)
@role.setter
def role(self, value):
"""Set the role in settings."""
self.settings["role"] = value
def clean(self):
"""Validate that at least one scope is set."""
from django.core.exceptions import ValidationError # noqa: PLC0415, I001 # pylint: disable=C0415
if not self.organization and not self.user and not self.caldav_path:
raise ValidationError(
"At least one scope must be set: organization, user, or caldav_path."
)
def verify_token(self, token):
"""Check that *token* matches the stored encrypted token."""
stored = self.encrypted_settings.get("token", "")
if not token or not stored:
return False
return secrets.compare_digest(token, stored)

View File

@@ -1,8 +1,10 @@
"""Services for CalDAV integration."""
import json
import logging
import re
from datetime import date, datetime, timedelta
from datetime import timezone as dt_timezone
from typing import Optional
from urllib.parse import unquote
from uuid import uuid4
@@ -30,7 +32,7 @@ class CalDAVHTTPClient:
and HTTP requests. All higher-level CalDAV consumers delegate to this.
"""
BASE_URI_PATH = "/api/v1.0/caldav"
BASE_URI_PATH = "/caldav"
DEFAULT_TIMEOUT = 30
def __init__(self):
@@ -45,11 +47,21 @@ class CalDAVHTTPClient:
return key
@classmethod
def build_base_headers(cls, email: str) -> dict:
"""Build authentication headers for CalDAV requests."""
def build_base_headers(cls, user) -> dict:
"""Build authentication headers for CalDAV requests.
Args:
user: Object with .email and .organization_id attributes.
Raises:
ValueError: If user.email is not set.
"""
if not user.email:
raise ValueError("User has no email address")
return {
"X-Api-Key": cls.get_api_key(),
"X-Forwarded-User": email,
"X-Forwarded-User": user.email,
"X-CalDAV-Organization": str(user.organization_id),
}
def build_url(self, path: str, query: str = "") -> str:
@@ -70,7 +82,7 @@ class CalDAVHTTPClient:
def request( # noqa: PLR0913 # pylint: disable=too-many-arguments
self,
method: str,
email: str,
user,
path: str,
*,
query: str = "",
@@ -80,7 +92,7 @@ class CalDAVHTTPClient:
content_type: str | None = None,
) -> requests.Response:
"""Make an authenticated HTTP request to the CalDAV server."""
headers = self.build_base_headers(email)
headers = self.build_base_headers(user)
if content_type:
headers["Content-Type"] = content_type
if extra_headers:
@@ -95,9 +107,13 @@ class CalDAVHTTPClient:
timeout=timeout or self.DEFAULT_TIMEOUT,
)
def get_dav_client(self, email: str) -> DAVClient:
"""Return a configured caldav.DAVClient for the given user email."""
headers = self.build_base_headers(email)
def get_dav_client(self, user) -> DAVClient:
"""Return a configured caldav.DAVClient for the given user.
Args:
user: Object with .email and .organization_id attributes.
"""
headers = self.build_base_headers(user)
caldav_url = f"{self.base_url}{self.BASE_URI_PATH}/"
return DAVClient(
url=caldav_url,
@@ -107,38 +123,58 @@ class CalDAVHTTPClient:
headers=headers,
)
def find_event_by_uid(self, email: str, uid: str) -> tuple[str | None, str | None]:
def find_event_by_uid(
self, user, uid: str
) -> tuple[str | None, str | None, str | None]:
"""Find an event by UID across all of the user's calendars.
Returns (ical_data, href) or (None, None).
Returns (ical_data, href, etag) or (None, None, None).
"""
client = self.get_dav_client(email)
client = self.get_dav_client(user)
try:
principal = client.principal()
for cal in principal.calendars():
try:
event = cal.object_by_uid(uid)
return event.data, str(event.url.path)
etag = getattr(event, "props", {}).get("{DAV:}getetag") or getattr(
event, "etag", None
)
return event.data, str(event.url.path), etag
except caldav_lib.error.NotFoundError:
continue
logger.warning("Event UID %s not found in user %s calendars", uid, email)
return None, None
logger.warning(
"Event UID %s not found in user %s calendars", uid, user.email
)
return None, None, None
except Exception: # pylint: disable=broad-exception-caught
logger.exception("CalDAV error looking up event %s", uid)
return None, None
return None, None, None
def put_event(self, email: str, href: str, ical_data: str) -> bool:
"""PUT updated iCalendar data back to CalDAV. Returns True on success."""
def put_event(
self, user, href: str, ical_data: str, etag: str | None = None
) -> bool:
"""PUT updated iCalendar data back to CalDAV. Returns True on success.
If *etag* is provided, the request includes an If-Match header to
prevent lost updates from concurrent modifications.
"""
try:
extra_headers = {}
if etag:
extra_headers["If-Match"] = etag
response = self.request(
"PUT",
email,
user,
href,
data=ical_data.encode("utf-8"),
content_type="text/calendar; charset=utf-8",
extra_headers=extra_headers or None,
)
if response.status_code in (200, 201, 204):
return True
if response.status_code == 412:
logger.warning("CalDAV PUT conflict (ETag mismatch) for %s", href)
return False
logger.error(
"CalDAV PUT failed: %s %s",
response.status_code,
@@ -160,10 +196,10 @@ class CalDAVHTTPClient:
cal = icalendar.Calendar.from_ical(ical_data)
updated = False
target = f"mailto:{email.lower()}"
for component in cal.walk("VEVENT"):
for _name, attendee in component.property_items("ATTENDEE"):
attendee_val = str(attendee).lower()
if email.lower() in attendee_val:
if str(attendee).lower().strip() == target:
attendee.params["PARTSTAT"] = icalendar.vText(new_partstat)
updated = True
@@ -182,14 +218,19 @@ class CalDAVClient:
self._http = CalDAVHTTPClient()
self.base_url = self._http.base_url
def _calendar_url(self, calendar_path: str) -> str:
"""Build a full URL for a calendar path, including the BASE_URI_PATH."""
return f"{self.base_url}{CalDAVHTTPClient.BASE_URI_PATH}{calendar_path}"
def _get_client(self, user) -> DAVClient:
"""
Get a CalDAV client for the given user.
The CalDAV server requires API key authentication via Authorization header
and X-Forwarded-User header for user identification.
Includes X-CalDAV-Organization when the user has an org.
"""
return self._http.get_dav_client(user.email)
return self._http.get_dav_client(user)
def get_calendar_info(self, user, calendar_path: str) -> dict | None:
"""
@@ -197,7 +238,7 @@ class CalDAVClient:
Returns dict with name, color, description or None if not found.
"""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
try:
calendar = client.calendar(url=calendar_url)
@@ -227,37 +268,53 @@ class CalDAVClient:
logger.error("Failed to get calendar info from CalDAV: %s", str(e))
return None
def create_calendar(
self, user, calendar_name: str, calendar_id: str, color: str = ""
def create_calendar( # pylint: disable=too-many-arguments
self,
user,
calendar_name: str = "",
calendar_id: str = "",
color: str = "",
*,
name: str = "",
) -> str:
"""
Create a new calendar in CalDAV server for the given user.
Returns the CalDAV server path for the calendar.
"""
calendar_name = calendar_name or name
if not calendar_id:
calendar_id = str(uuid4())
if not color:
color = settings.DEFAULT_CALENDAR_COLOR
client = self._get_client(user)
principal = client.principal()
try:
# Create calendar using caldav library
calendar = principal.make_calendar(name=calendar_name)
# Pass cal_id so the library uses our UUID for the path.
calendar = principal.make_calendar(name=calendar_name, cal_id=calendar_id)
# Set calendar color if provided
if color:
calendar.set_properties([CalendarColor(color)])
# CalDAV server calendar path format: /calendars/{username}/{calendar_id}/
# The caldav library returns a URL object, convert to string and extract path
# Extract CalDAV-relative path from the calendar URL
calendar_url = str(calendar.url)
# Extract path from full URL
if calendar_url.startswith(self.base_url):
path = calendar_url[len(self.base_url) :]
else:
# Fallback: construct path manually based on standard CalDAV structure
# CalDAV servers typically create calendars under /calendars/{principal}/
path = f"/calendars/{user.email}/{calendar_id}/"
path = f"/calendars/users/{user.email}/{calendar_id}/"
base_prefix = CalDAVHTTPClient.BASE_URI_PATH
if path.startswith(base_prefix):
path = path[len(base_prefix) :]
if not path.startswith("/"):
path = "/" + path
path = unquote(path)
logger.info(
"Created calendar in CalDAV server: %s at %s", calendar_name, path
"Created calendar in CalDAV server: %s at %s",
calendar_name,
path,
)
return path
except Exception as e:
@@ -285,7 +342,7 @@ class CalDAVClient:
client = self._get_client(user)
# Get calendar by URL
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
try:
@@ -323,7 +380,7 @@ class CalDAVClient:
Returns the event UID.
"""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
try:
@@ -342,7 +399,7 @@ class CalDAVClient:
"""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
# Extract event data
@@ -385,27 +442,11 @@ class CalDAVClient:
"""Update an existing event in CalDAV server."""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
try:
# Search for the event by UID
events = calendar.search(event=True)
target_event = None
for event in events:
event_uid_value = None
if hasattr(event, "icalendar_component"):
event_uid_value = str(event.icalendar_component.get("uid", ""))
elif hasattr(event, "vobject_instance"):
event_uid_value = event.vobject_instance.vevent.uid.value
if event_uid_value == event_uid:
target_event = event
break
if not target_event:
raise ValueError(f"Event with UID {event_uid} not found")
target_event = calendar.object_by_uid(event_uid)
# Update event properties
dtstart = event_data.get("start")
@@ -432,6 +473,8 @@ class CalDAVClient:
target_event.save()
logger.info("Updated event in CalDAV server: %s", event_uid)
except NotFoundError:
raise ValueError(f"Event with UID {event_uid} not found") from None
except Exception as e:
logger.error("Failed to update event in CalDAV server: %s", str(e))
raise
@@ -440,36 +483,44 @@ class CalDAVClient:
"""Delete an event from CalDAV server."""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
try:
# Search for the event by UID
events = calendar.search(event=True)
target_event = None
for event in events:
event_uid_value = None
if hasattr(event, "icalendar_component"):
event_uid_value = str(event.icalendar_component.get("uid", ""))
elif hasattr(event, "vobject_instance"):
event_uid_value = event.vobject_instance.vevent.uid.value
if event_uid_value == event_uid:
target_event = event
break
if not target_event:
raise ValueError(f"Event with UID {event_uid} not found")
# Delete the event
target_event = calendar.object_by_uid(event_uid)
target_event.delete()
logger.info("Deleted event from CalDAV server: %s", event_uid)
except NotFoundError:
raise ValueError(f"Event with UID {event_uid} not found") from None
except Exception as e:
logger.error("Failed to delete event from CalDAV server: %s", str(e))
raise
def get_user_calendar_paths(self, user) -> list[str]:
"""Return a list of CalDAV-relative calendar paths for the user."""
client = self._get_client(user)
principal = client.principal()
paths = []
base = f"{self.base_url}{CalDAVHTTPClient.BASE_URI_PATH}"
for cal in principal.calendars():
url = str(cal.url)
if url.startswith(base):
paths.append(unquote(url[len(base) :]))
return paths
def create_default_calendar(self, user) -> str:
"""Create a default calendar for a user. Returns the caldav_path."""
from core.services.translation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
TranslationService,
)
calendar_id = str(uuid4())
lang = TranslationService.resolve_language(email=user.email)
calendar_name = TranslationService.t("calendar.list.defaultCalendarName", lang)
return self.create_calendar(
user, calendar_name, calendar_id, color=settings.DEFAULT_CALENDAR_COLOR
)
def _parse_event(self, event) -> Optional[dict]:
"""
Parse a caldav Event object and return event data as dictionary.
@@ -491,13 +542,15 @@ class CalDAVClient:
# Convert datetime to string format for consistency
if event_data["start"]:
if isinstance(event_data["start"], datetime):
event_data["start"] = event_data["start"].strftime("%Y%m%dT%H%M%SZ")
utc_start = event_data["start"].astimezone(dt_timezone.utc)
event_data["start"] = utc_start.strftime("%Y%m%dT%H%M%SZ")
elif isinstance(event_data["start"], date):
event_data["start"] = event_data["start"].strftime("%Y%m%d")
if event_data["end"]:
if isinstance(event_data["end"], datetime):
event_data["end"] = event_data["end"].strftime("%Y%m%dT%H%M%SZ")
utc_end = event_data["end"].astimezone(dt_timezone.utc)
event_data["end"] = utc_end.strftime("%Y%m%dT%H%M%SZ")
elif isinstance(event_data["end"], date):
event_data["end"] = event_data["end"].strftime("%Y%m%d")
@@ -507,60 +560,19 @@ class CalDAVClient:
return None
class CalendarService:
"""
High-level service for managing calendars and events.
"""
def __init__(self):
self.caldav = CalDAVClient()
def create_default_calendar(self, user) -> str:
"""Create a default calendar for a user. Returns the caldav_path."""
from core.services.translation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
TranslationService,
)
calendar_id = str(uuid4())
lang = TranslationService.resolve_language(email=user.email)
calendar_name = TranslationService.t("calendar.list.defaultCalendarName", lang)
return self.caldav.create_calendar(
user, calendar_name, calendar_id, color=settings.DEFAULT_CALENDAR_COLOR
)
def create_calendar(self, user, name: str, color: str = "") -> str:
"""Create a new calendar for a user. Returns the caldav_path."""
calendar_id = str(uuid4())
return self.caldav.create_calendar(
user, name, calendar_id, color=color or settings.DEFAULT_CALENDAR_COLOR
)
def get_events(self, user, caldav_path: str, start=None, end=None) -> list:
"""Get events from a calendar. Returns parsed event data."""
return self.caldav.get_events(user, caldav_path, start, end)
def create_event(self, user, caldav_path: str, event_data: dict) -> str:
"""Create a new event."""
return self.caldav.create_event(user, caldav_path, event_data)
def update_event(
self, user, caldav_path: str, event_uid: str, event_data: dict
) -> None:
"""Update an existing event."""
self.caldav.update_event(user, caldav_path, event_uid, event_data)
def delete_event(self, user, caldav_path: str, event_uid: str) -> None:
"""Delete an event."""
self.caldav.delete_event(user, caldav_path, event_uid)
# CalendarService is kept as an alias for backwards compatibility
# with tests and signals that reference it.
CalendarService = CalDAVClient
# ---------------------------------------------------------------------------
# CalDAV path utilities
# ---------------------------------------------------------------------------
# Pattern: /calendars/<email-or-encoded>/<calendar-id>/
# Pattern: /calendars/users/<email-or-encoded>/<calendar-id>/
# or /calendars/resources/<resource-id>/<calendar-id>/
CALDAV_PATH_PATTERN = re.compile(
r"^/calendars/[^/]+/[a-zA-Z0-9-]+/$",
r"^/calendars/(users|resources)/[^/]+/[a-zA-Z0-9-]+/$",
)
@@ -568,8 +580,8 @@ def normalize_caldav_path(caldav_path):
"""Normalize CalDAV path to consistent format.
Strips the CalDAV API prefix (e.g. /api/v1.0/caldav/) if present,
so that paths like /api/v1.0/caldav/calendars/user@ex.com/uuid/
become /calendars/user@ex.com/uuid/.
so that paths like /api/v1.0/caldav/calendars/users/user@ex.com/uuid/
become /calendars/users/user@ex.com/uuid/.
"""
if not caldav_path.startswith("/"):
caldav_path = "/" + caldav_path
@@ -582,19 +594,60 @@ def normalize_caldav_path(caldav_path):
return caldav_path
def _resource_belongs_to_org(resource_id: str, org_id: str) -> bool:
"""Check whether a resource principal belongs to the given organization.
Queries the CalDAV internal API. Returns False on any error (fail-closed).
"""
api_key = settings.CALDAV_INTERNAL_API_KEY
caldav_url = settings.CALDAV_URL
if not api_key or not caldav_url:
return False
try:
resp = requests.get(
f"{caldav_url.rstrip('/')}/caldav/internal-api/resources/{resource_id}",
headers={"X-Internal-Api-Key": api_key},
timeout=10,
)
if resp.status_code != 200:
return False
data = resp.json()
return data.get("org_id") == org_id
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Failed to verify resource org for %s", resource_id)
return False
def verify_caldav_access(user, caldav_path):
"""Verify that the user has access to the CalDAV calendar.
Checks that:
1. The path matches the expected pattern (prevents path injection)
2. The user's email matches the email in the path
2. For user calendars: the user's email matches the email in the path
3. For resource calendars: the user has an organization
Note: Fine-grained org-to-resource authorization is enforced by SabreDAV
itself (via X-CalDAV-Organization header). This check only gates access
for Django-level features (subscription tokens, imports).
"""
if not CALDAV_PATH_PATTERN.match(caldav_path):
return False
parts = caldav_path.strip("/").split("/")
if len(parts) >= 2 and parts[0] == "calendars":
path_email = unquote(parts[1])
if len(parts) < 3 or parts[0] != "calendars":
return False
# User calendars: calendars/users/<email>/<calendar-id>
if parts[1] == "users":
if not user.email:
return False
path_email = unquote(parts[2])
return path_email.lower() == user.email.lower()
# Resource calendars: calendars/resources/<resource-id>/<calendar-id>
# Org membership is required. Fine-grained org-to-resource authorization
# is enforced by SabreDAV via the X-CalDAV-Organization header on every
# proxied request. For subscription tokens / imports, callers should
# additionally use _resource_belongs_to_org() to verify ownership.
if parts[1] == "resources":
return bool(getattr(user, "organization_id", None))
return False
@@ -605,10 +658,16 @@ def validate_caldav_proxy_path(path):
- Directory traversal sequences (../)
- Null bytes
- Paths that don't start with expected prefixes
URL-decodes the path first so that encoded payloads like
``%2e%2e`` or ``%00`` cannot bypass the checks.
"""
if not path:
return True # Empty path is fine (root request)
# Decode percent-encoded characters before validation
path = unquote(path)
# Block directory traversal
if ".." in path:
return False
@@ -617,10 +676,60 @@ def validate_caldav_proxy_path(path):
if "\x00" in path:
return False
clean = path.lstrip("/")
# Explicitly block internal-api/ paths — these must never be proxied.
# The allowlist below already rejects them, but an explicit block makes
# the intent clear and survives future allowlist additions.
blocked_prefixes = ("internal-api/",)
if clean and any(clean.startswith(prefix) for prefix in blocked_prefixes):
return False
# Path must start with a known CalDAV resource prefix
allowed_prefixes = ("calendars/", "principals/", ".well-known/")
clean = path.lstrip("/")
if clean and not any(clean.startswith(prefix) for prefix in allowed_prefixes):
return False
return True
def cleanup_organization_caldav_data(org):
"""Clean up CalDAV data for all members of an organization.
Deletes each member's CalDAV data via the SabreDAV internal API,
then deletes the Django User objects so the PROTECT foreign key
on User.organization doesn't block org deletion.
Called from Organization.delete() — NOT a signal, because the
PROTECT FK raises ProtectedError before pre_delete fires.
"""
if not settings.CALDAV_INTERNAL_API_KEY:
return
http = CalDAVHTTPClient()
members = list(org.members.all())
for user in members:
if not user.email:
continue
try:
http.request(
"POST",
user,
"internal-api/users/delete",
data=json.dumps({"email": user.email}).encode("utf-8"),
content_type="application/json",
extra_headers={
"X-Internal-Api-Key": settings.CALDAV_INTERNAL_API_KEY,
},
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception(
"Failed to clean up CalDAV data for user %s (org %s)",
user.email,
org.external_id,
)
# Delete all members so the PROTECT FK doesn't block org deletion.
# CalDAV cleanup is best-effort; orphaned CalDAV data is acceptable.
org.members.all().delete()

View File

@@ -20,7 +20,7 @@ from urllib.parse import urlencode
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.core.signing import Signer
from django.core.signing import TimestampSigner
from django.template.loader import render_to_string
from core.services.translation_service import TranslationService
@@ -424,8 +424,8 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"time_str": time_str,
"is_update": event.sequence > 0,
"is_cancel": method == self.METHOD_CANCEL,
"app_name": getattr(settings, "APP_NAME", "Calendrier"),
"app_url": getattr(settings, "APP_URL", ""),
"app_name": settings.APP_NAME,
"app_url": settings.APP_URL,
# Translated content blocks
"content": {
"title": t(f"email.{type_key}.title", lang),
@@ -457,13 +457,13 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"footer": t(
f"email.footer.{'invitation' if type_key == 'invitation' else 'notification'}",
lang,
appName=getattr(settings, "APP_NAME", "Calendrier"),
appName=settings.APP_NAME,
),
}
# Add RSVP links for REQUEST method (invitations and updates)
if method == self.METHOD_REQUEST:
signer = Signer(salt="rsvp")
signer = TimestampSigner(salt="rsvp")
# Strip mailto: prefix (case-insensitive) for shorter tokens
organizer = re.sub(
r"^mailto:", "", event.organizer_email, flags=re.IGNORECASE
@@ -475,7 +475,7 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"organizer": organizer,
}
)
app_url = getattr(settings, "APP_URL", "")
app_url = settings.APP_URL
base = f"{app_url}/rsvp/"
for action in ("accept", "tentative", "decline"):
partstat = {
@@ -498,7 +498,7 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
When False (default), strips METHOD so the ICS is treated as a plain
calendar object — our own RSVP web links handle responses instead.
"""
itip_enabled = getattr(settings, "CALENDAR_ITIP_ENABLED", False)
itip_enabled = settings.CALENDAR_ITIP_ENABLED
if itip_enabled:
if "METHOD:" not in icalendar_data.upper():
@@ -549,10 +549,8 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"""
try:
# Get email settings
from_addr = getattr(
settings,
"CALENDAR_INVITATION_FROM_EMAIL",
getattr(settings, "DEFAULT_FROM_EMAIL", "noreply@example.com"),
from_addr = (
settings.CALENDAR_INVITATION_FROM_EMAIL or settings.DEFAULT_FROM_EMAIL
)
# Create the email message
@@ -571,7 +569,7 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
ics_attachment = MIMEBase("text", "calendar")
ics_attachment.set_payload(ics_content.encode("utf-8"))
encoders.encode_base64(ics_attachment)
itip_enabled = getattr(settings, "CALENDAR_ITIP_ENABLED", False)
itip_enabled = settings.CALENDAR_ITIP_ENABLED
content_type = "text/calendar; charset=utf-8"
if itip_enabled:
content_type += f"; method={ics_method}"

View File

@@ -3,6 +3,8 @@
import logging
from dataclasses import dataclass, field
from django.conf import settings
import requests
from core.services.caldav_service import CalDAVHTTPClient
@@ -41,37 +43,43 @@ class ICSImportService:
def import_events(self, user, caldav_path: str, ics_data: bytes) -> ImportResult:
"""Import events from ICS data into a calendar.
Sends the raw ICS bytes to SabreDAV's ?import endpoint which
handles all ICS parsing, splitting by UID, VALARM repair, and
per-event insertion.
Sends the raw ICS bytes to the SabreDAV internal API import
endpoint which handles all ICS parsing, splitting by UID,
VALARM repair, and per-event insertion.
Args:
user: The authenticated user performing the import.
caldav_path: CalDAV path of the calendar
(e.g. /calendars/user@example.com/uuid/).
(e.g. /calendars/users/user@example.com/uuid/).
ics_data: Raw ICS file content.
"""
result = ImportResult()
try:
api_key = CalDAVHTTPClient.get_api_key()
except ValueError:
result.errors.append("CALDAV_OUTBOUND_API_KEY is not configured")
api_key = settings.CALDAV_INTERNAL_API_KEY
if not api_key:
result.errors.append("CALDAV_INTERNAL_API_KEY is not configured")
return result
# Timeout scales with file size: 60s base + 30s per MB of ICS data.
# 8000 events (~4MB) took ~70s in practice.
timeout = 60 + int(len(ics_data) / 1024 / 1024) * 30
# Extract calendar URI from caldav_path
# Path format: /calendars/users/<email>/<calendar-uri>/
parts = caldav_path.strip("/").split("/")
if len(parts) == 4 and parts[0] == "calendars" and parts[1] == "users":
principal_user = parts[2]
calendar_uri = parts[3]
else:
result.errors.append("Invalid calendar path")
return result
# import runs in a background task so we can wait a decent amount of time
timeout = 1200 # 20 minutes
try:
response = self._http.request(
"POST",
user.email,
caldav_path,
query="import",
user,
f"internal-api/import/{principal_user}/{calendar_uri}",
data=ics_data,
content_type="text/calendar",
extra_headers={"X-Calendars-Import": api_key},
extra_headers={"X-Internal-Api-Key": api_key},
timeout=timeout,
)
except requests.RequestException as exc:

View File

@@ -0,0 +1,182 @@
"""Service for managing calendar resource provisioning via CalDAV."""
import json
import logging
from uuid import UUID, uuid4
from django.conf import settings
from core.services.caldav_service import CalDAVHTTPClient
logger = logging.getLogger(__name__)
class ResourceProvisioningError(Exception):
"""Raised when resource provisioning fails."""
class ResourceService:
"""Provisions and deletes resource principals in SabreDAV.
Resources are CalDAV principals — this service creates them by
making HTTP requests to the SabreDAV internal API. No Django model
is created; the CalDAV principal IS the resource.
"""
def __init__(self):
self._http = CalDAVHTTPClient()
def _resource_email(self, resource_id):
"""Generate a resource scheduling address."""
domain = settings.RESOURCE_EMAIL_DOMAIN
if not domain:
domain = "resource.invalid"
return f"{resource_id}@{domain}"
def create_resource(self, user, name, resource_type="ROOM"):
"""Provision a resource principal and its default calendar.
Args:
user: The admin user creating the resource (provides auth context).
name: Display name for the resource.
resource_type: "ROOM" or "RESOURCE".
Returns:
dict with resource info: id, email, principal_uri, calendar_uri.
Raises:
ResourceProvisioningError on failure.
"""
if resource_type not in ("ROOM", "RESOURCE"):
raise ResourceProvisioningError(
"resource_type must be 'ROOM' or 'RESOURCE'."
)
if not settings.CALDAV_INTERNAL_API_KEY:
raise ResourceProvisioningError(
"CALDAV_INTERNAL_API_KEY is not configured."
)
resource_id = str(uuid4())
email = self._resource_email(resource_id)
org_id = str(user.organization_id)
try:
response = self._http.request(
"POST",
user,
"internal-api/resources/",
data=self._json_bytes(
{
"resource_id": resource_id,
"name": name,
"email": email,
"resource_type": resource_type,
"org_id": org_id,
}
),
content_type="application/json",
extra_headers={
"X-Internal-Api-Key": settings.CALDAV_INTERNAL_API_KEY,
},
)
except Exception as e:
logger.error("Failed to create resource principal: %s", e)
raise ResourceProvisioningError(
"Failed to create resource principal."
) from e
if response.status_code == 409:
raise ResourceProvisioningError(f"Resource '{resource_id}' already exists.")
if response.status_code != 201:
logger.error(
"InternalApi create resource returned %s: %s",
response.status_code,
response.text[:500],
)
raise ResourceProvisioningError("Failed to create resource principal.")
principal_uri = f"principals/resources/{resource_id}"
calendar_uri = f"calendars/resources/{resource_id}/default/"
return {
"id": resource_id,
"email": email,
"name": name,
"resource_type": resource_type,
"principal_uri": principal_uri,
"calendar_uri": calendar_uri,
}
@staticmethod
def _validate_resource_id(resource_id):
"""Validate that resource_id is a proper UUID.
Raises ResourceProvisioningError if the ID is not a valid UUID,
preventing path traversal via crafted IDs.
"""
try:
UUID(str(resource_id))
except (ValueError, AttributeError) as e:
raise ResourceProvisioningError(
"Invalid resource ID: must be a valid UUID."
) from e
def delete_resource(self, user, resource_id):
"""Delete a resource principal and its calendar.
Events in user calendars that reference this resource are left
as-is — the resource address becomes unresolvable.
Args:
user: The admin user requesting deletion.
resource_id: The resource UUID.
Raises:
ResourceProvisioningError on failure.
"""
self._validate_resource_id(resource_id)
if not settings.CALDAV_INTERNAL_API_KEY:
raise ResourceProvisioningError(
"CALDAV_INTERNAL_API_KEY is not configured."
)
try:
response = self._http.request(
"DELETE",
user,
f"internal-api/resources/{resource_id}",
extra_headers={
"X-Internal-Api-Key": settings.CALDAV_INTERNAL_API_KEY,
},
)
except Exception as e:
logger.error("Failed to delete resource: %s", e)
raise ResourceProvisioningError("Failed to delete resource.") from e
if response.status_code == 404:
raise ResourceProvisioningError(f"Resource '{resource_id}' not found.")
if response.status_code == 403:
try:
error_msg = response.json().get("error", "")
except ValueError:
error_msg = ""
raise ResourceProvisioningError(
error_msg or "Cannot delete a resource from a different organization."
)
if response.status_code not in (200, 204):
logger.error(
"InternalApi delete resource returned %s: %s",
response.status_code,
response.text[:500],
)
raise ResourceProvisioningError("Failed to delete resource.")
@staticmethod
def _json_bytes(data):
"""Serialize a dict to JSON bytes."""
return json.dumps(data).encode("utf-8")

View File

@@ -2,6 +2,7 @@
import json
import logging
import threading
from datetime import datetime
from typing import Optional
@@ -40,6 +41,7 @@ class TranslationService:
"""Lightweight translation service backed by translations.json."""
_translations = None
_load_lock = threading.Lock()
@classmethod
def _load(cls):
@@ -47,12 +49,17 @@ class TranslationService:
if cls._translations is not None:
return
path = getattr(settings, "TRANSLATIONS_JSON_PATH", "")
if not path:
raise RuntimeError("TRANSLATIONS_JSON_PATH setting is not configured")
with cls._load_lock:
# Double-check after acquiring lock
if cls._translations is not None:
return
with open(path, encoding="utf-8") as f:
cls._translations = json.load(f)
path = settings.TRANSLATIONS_JSON_PATH
if not path:
raise RuntimeError("TRANSLATIONS_JSON_PATH setting is not configured")
with open(path, encoding="utf-8") as f:
cls._translations = json.load(f)
@classmethod
def _get_nested(cls, data: dict, dotted_key: str):
@@ -104,15 +111,15 @@ class TranslationService:
if email:
try:
from core.models import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
User,
from django.contrib.auth import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
get_user_model,
)
user = User.objects.filter(email=email).first()
user = get_user_model().objects.filter(email=email).first()
if user and user.language:
return cls.normalize_lang(user.language)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Failed to resolve language for email %s", email)
logger.exception("Failed to resolve language for recipient")
return "fr"

View File

@@ -2,15 +2,17 @@
Declare and configure the signals for the calendars core application
"""
import json
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.db import transaction
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.services.caldav_service import CalendarService
from core.services.caldav_service import CalDAVHTTPClient, CalendarService
logger = logging.getLogger(__name__)
User = get_user_model()
@@ -32,7 +34,7 @@ def provision_default_calendar(sender, instance, created, **kwargs): # pylint:
# never create a calendar if we can't confirm access.
try:
entitlements = get_user_entitlements(instance.sub, instance.email)
if not entitlements.get("can_access", True):
if not entitlements.get("can_access", False):
logger.info(
"Skipped calendar creation for %s (not entitled)",
instance.email,
@@ -48,22 +50,45 @@ def provision_default_calendar(sender, instance, created, **kwargs): # pylint:
try:
service = CalendarService()
service.create_default_calendar(instance)
logger.info("Created default calendar for user %s", instance.email)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# In tests, CalDAV server may not be available, so fail silently
# Check if it's a database error that suggests we're in tests
error_str = str(e).lower()
if "does not exist" in error_str or "relation" in error_str:
# Likely in test environment, fail silently
logger.debug(
"Skipped calendar creation for user %s (likely test environment): %s",
instance.email,
str(e),
logger.info("Created default calendar for user %s", instance.pk)
except Exception: # pylint: disable=broad-exception-caught
logger.exception(
"Failed to create default calendar for user %s",
instance.pk,
)
@receiver(pre_delete, sender=User)
def delete_user_caldav_data(sender, instance, **kwargs): # pylint: disable=unused-argument
"""Schedule CalDAV data cleanup when a user is deleted.
Uses on_commit so the external CalDAV call only fires after
the DB transaction commits — avoids orphaned state on rollback.
"""
email = instance.email
if not email:
return
if not settings.CALDAV_INTERNAL_API_KEY:
return
api_key = settings.CALDAV_INTERNAL_API_KEY
def _cleanup():
try:
http = CalDAVHTTPClient()
http.request(
"POST",
instance,
"internal-api/users/delete",
data=json.dumps({"email": email}).encode("utf-8"),
content_type="application/json",
extra_headers={"X-Internal-Api-Key": api_key},
)
else:
# Real error, log it
logger.error(
"Failed to create default calendar for user %s: %s",
instance.email,
str(e),
except Exception: # pylint: disable=broad-exception-caught
logger.exception(
"Failed to clean up CalDAV data for user %s",
email,
)
transaction.on_commit(_cleanup)

View File

@@ -0,0 +1,170 @@
"""Task queue utilities.
Provides decorators and helpers that abstract away the underlying task
queue library (currently Dramatiq). Application code should import from
here instead of importing dramatiq directly.
"""
import json
import logging
from typing import Any, Optional
from django.core.cache import cache
from django.utils import timezone
import dramatiq
from dramatiq.brokers.stub import StubBroker
from dramatiq.middleware import CurrentMessage
logger = logging.getLogger(__name__)
TASK_PROGRESS_CACHE_TIMEOUT = 86400 # 24 hours
TASK_TRACKING_CACHE_TTL = 86400 * 30 # 30 days
# ---------------------------------------------------------------------------
# Task wrapper (Celery-compatible API)
# ---------------------------------------------------------------------------
class Task:
"""Wrapper around a Dramatiq Message with a Celery-like API."""
def __init__(self, message):
self._message = message
@property
def id(self):
"""Celery-compatible task ID (maps to message_id)."""
return self._message.message_id
def track_owner(self, user_id):
"""Register tracking metadata for permission checks."""
cache.set(
f"task_tracking:{self.id}",
json.dumps(
{
"owner": str(user_id),
"actor_name": self._message.actor_name,
"queue_name": self._message.queue_name,
}
),
timeout=TASK_TRACKING_CACHE_TTL,
)
def __getattr__(self, name):
return getattr(self._message, name)
class CeleryCompatActor(dramatiq.Actor):
"""Actor subclass that adds a .delay() method returning a Task."""
def delay(self, *args, **kwargs):
"""Dispatch the task asynchronously, returning a Task wrapper."""
message = self.send(*args, **kwargs)
return Task(message)
# ---------------------------------------------------------------------------
# Decorators
# ---------------------------------------------------------------------------
def register_task(*args, **kwargs):
"""Decorator to register a task (wraps dramatiq.actor).
Usage::
@register_task(queue="import")
def my_task(arg):
...
"""
kwargs.setdefault("store_results", True)
if "queue" in kwargs:
kwargs.setdefault("queue_name", kwargs.pop("queue"))
kwargs.setdefault("actor_class", CeleryCompatActor)
def decorator(fn):
return dramatiq.actor(fn, **kwargs)
if args and callable(args[0]):
return decorator(args[0])
return decorator
# ---------------------------------------------------------------------------
# Task tracking & progress
# ---------------------------------------------------------------------------
def get_task_tracking(task_id: str) -> Optional[dict]:
"""Get tracking metadata for a task, or None if not found."""
raw = cache.get(f"task_tracking:{task_id}")
if raw is None:
return None
return json.loads(raw)
def set_task_progress(progress: int, metadata: Optional[dict[str, Any]] = None) -> None:
"""Set the progress of the currently executing task."""
current_message = CurrentMessage.get_current_message()
if not current_message:
logger.warning("set_task_progress called outside of a task")
return
task_id = current_message.message_id
try:
progress = max(0, min(100, int(progress)))
except (TypeError, ValueError):
progress = 0
cache.set(
f"task_progress:{task_id}",
{
"progress": progress,
"timestamp": timezone.now().timestamp(),
"metadata": metadata or {},
},
timeout=TASK_PROGRESS_CACHE_TIMEOUT,
)
def get_task_progress(task_id: str) -> Optional[dict[str, Any]]:
"""Get the progress of a task by ID."""
return cache.get(f"task_progress:{task_id}")
# ---------------------------------------------------------------------------
# EagerBroker for tests
# ---------------------------------------------------------------------------
class EagerBroker(StubBroker):
"""Broker that executes tasks synchronously (for tests).
Equivalent to Celery's CELERY_TASK_ALWAYS_EAGER mode.
Only runs CurrentMessage and Results middleware.
"""
def enqueue(self, message, *, delay=None):
from dramatiq.results import Results # noqa: PLC0415 # pylint: disable=C0415
actor = self.get_actor(message.actor_name)
cm = next(
(m for m in self.middleware if isinstance(m, CurrentMessage)),
None,
)
rm = next((m for m in self.middleware if isinstance(m, Results)), None)
prev = CurrentMessage.get_current_message() if cm else None
if cm:
cm.before_process_message(self, message)
try:
result = actor.fn(*message.args, **message.kwargs)
if rm:
rm.after_process_message(self, message, result=result)
finally:
if cm:
cm.after_process_message(self, message)
if prev is not None:
cm.before_process_message(self, prev)
return message

50
src/backend/core/tasks.py Normal file
View File

@@ -0,0 +1,50 @@
"""Background tasks for the calendars core application."""
# pylint: disable=import-outside-toplevel
import logging
from dataclasses import asdict
from core.services.import_service import ICSImportService
from core.task_utils import register_task, set_task_progress
logger = logging.getLogger(__name__)
@register_task(queue="import")
def import_events_task(user_id, caldav_path, ics_data_hex):
"""Import events from ICS data in the background.
Parameters are kept JSON-serialisable:
- user_id: pk of the User who triggered the import
- caldav_path: target CalDAV calendar path
- ics_data_hex: ICS bytes encoded as hex string
"""
from core.models import User # noqa: PLC0415
set_task_progress(0, {"message": "Starting import..."})
try:
user = User.objects.get(pk=user_id)
except User.DoesNotExist:
logger.error("import_events_task: user %s not found", user_id)
return {
"status": "FAILURE",
"result": None,
"error": "User not found",
}
ics_data = bytes.fromhex(ics_data_hex)
set_task_progress(10, {"message": "Sending to CalDAV server..."})
service = ICSImportService()
result = service.import_events(user, caldav_path, ics_data)
set_task_progress(100, {"message": "Import complete"})
result_dict = asdict(result)
return {
"status": "SUCCESS",
"result": result_dict,
"error": None,
}

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="{{ lang|default:'fr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title }}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
}
h1 {
color: {{ header_color }};
font-size: 24px;
margin: 0 0 16px;
}
.message {
font-size: 16px;
color: #555;
margin-bottom: 24px;
}
.submit-btn {
display: inline-block;
padding: 12px 32px;
background-color: {{ header_color }};
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
}
.submit-btn:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">{{ status_icon|safe }}</div>
<h1>{{ heading }}</h1>
<form id="rsvp-form" method="post" action="{{ post_url }}">
<input type="hidden" name="token" value="{{ token }}">
<input type="hidden" name="action" value="{{ action }}">
<noscript>
<button type="submit" class="submit-btn">{{ submit_label }}</button>
</noscript>
<p class="message" id="loading-msg">...</p>
</form>
</div>
<script>
document.getElementById('rsvp-form').submit();
</script>
</body>
</html>

View File

@@ -1,7 +1,7 @@
"""Unit tests for the Authentication Backends."""
import random
import re
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
@@ -17,7 +17,14 @@ from core.factories import UserFactory
pytestmark = pytest.mark.django_db
# Patch org resolution out by default in this module.
# Tests for org resolution are in test_organizations.py.
_no_org_resolve = mock.patch(
"core.authentication.backends.resolve_organization", lambda *a, **kw: None
)
@_no_org_resolve
def test_authentication_getter_existing_user_no_email(
django_assert_num_queries, monkeypatch
):
@@ -41,6 +48,7 @@ def test_authentication_getter_existing_user_no_email(
assert user == db_user
@_no_org_resolve
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
@@ -57,7 +65,7 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(4): # user by sub, user by mail, update sub
with django_assert_num_queries(5): # user by sub, user by mail, update sub, org
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -65,30 +73,24 @@ def test_authentication_getter_existing_user_via_email(
assert user == db_user
def test_authentication_getter_email_none(monkeypatch):
def test_authentication_getter_email_none_rejected(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
If no user is found with the sub and no email is provided,
user creation is rejected (organization requires email domain).
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email=None)
UserFactory() # existing user with different sub
def get_userinfo_mocked(*args):
user_info = {"sub": "123"}
if random.choice([True, False]):
user_info["email"] = None
return user_info
return {"sub": "123"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub and email didn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
with pytest.raises(
SuspiciousOperation, match="Cannot create user without an organization"
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
@@ -154,6 +156,7 @@ def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
assert models.User.objects.count() == 1
@_no_org_resolve
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
@@ -161,7 +164,7 @@ def test_authentication_getter_existing_user_with_email(
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(full_name="John Doe", short_name="John")
user = UserFactory(full_name="John Doe")
def get_userinfo_mocked(*args):
return {
@@ -182,6 +185,7 @@ def test_authentication_getter_existing_user_with_email(
assert user == authenticated_user
@_no_org_resolve
@pytest.mark.parametrize(
"first_name, last_name, email",
[
@@ -199,9 +203,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
and the user was identified by its "sub".
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
user = UserFactory(full_name="John Doe", email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {
@@ -213,8 +215,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
with django_assert_num_queries(4):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -223,9 +224,9 @@ def test_authentication_getter_existing_user_change_fields_sub(
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
@_no_org_resolve
@pytest.mark.parametrize(
"first_name, last_name, email",
[
@@ -241,9 +242,7 @@ def test_authentication_getter_existing_user_change_fields_email(
and the user was identified by its "email" as fallback.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
user = UserFactory(full_name="John Doe", email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {
@@ -255,8 +254,7 @@ def test_authentication_getter_existing_user_change_fields_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(4):
with django_assert_num_queries(5):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -265,13 +263,12 @@ def test_authentication_getter_existing_user_change_fields_email(
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
def test_authentication_getter_new_user_no_email(monkeypatch):
def test_authentication_getter_new_user_no_email_rejected(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's info doesn't contain an email, created user's email should be empty.
If no user matches the sub and no email is provided,
user creation is rejected (organization requires email domain).
"""
klass = OIDCAuthenticationBackend()
@@ -280,16 +277,10 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "123"
assert user.email is None
assert user.full_name is None
assert user.short_name is None
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
with pytest.raises(
SuspiciousOperation, match="Cannot create user without an organization"
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
def test_authentication_getter_new_user_with_email(monkeypatch):
@@ -314,7 +305,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@@ -458,6 +449,7 @@ def test_authentication_getter_existing_disabled_user_via_email(
assert models.User.objects.count() == 1
@_no_org_resolve
@responses.activate
def test_authentication_session_tokens(
django_assert_num_queries, monkeypatch, rf, settings
@@ -498,7 +490,7 @@ def test_authentication_session_tokens(
status=200,
)
with django_assert_num_queries(6):
with django_assert_num_queries(12):
user = klass.authenticate(
request,
code="test-code",
@@ -538,7 +530,7 @@ def test_authentication_store_claims_new_user(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.has_usable_password() is False
assert user.claims == {"iss": "https://example.com"}
assert models.User.objects.count() == 1

View File

@@ -1,10 +1,9 @@
"""Fixtures for tests in the calendars core application"""
import base64
from unittest import mock
from django.conf import settings
from django.core.cache import cache
from django.db import connection
import pytest
import responses
@@ -13,43 +12,86 @@ from core import factories
from core.tests.utils.urls import reload_urls
USER = "user"
TEAM = "team"
VIA = [USER, TEAM]
def _has_caldav_marker(request):
"""Check if the test has the xdist_group('caldav') marker."""
marker = request.node.get_closest_marker("xdist_group")
return marker is not None and marker.args and marker.args[0] == "caldav"
@pytest.fixture(autouse=True)
def truncate_caldav_tables(django_db_setup, django_db_blocker): # pylint: disable=unused-argument
"""Fixture to truncate CalDAV server tables at the start of each test.
def truncate_caldav_tables(request, django_db_setup, django_db_blocker): # pylint: disable=unused-argument
"""Truncate CalDAV tables before each CalDAV E2E test.
CalDAV server tables are created by the CalDAV server container migrations, not Django.
We just truncate them to ensure clean state for each test.
Only runs for tests marked with @pytest.mark.xdist_group("caldav").
Non-CalDAV tests don't touch the SabreDAV database, so truncating
from their worker would corrupt state for CalDAV tests running
concurrently on another xdist worker.
"""
with django_db_blocker.unblock():
with connection.cursor() as cursor:
# Truncate CalDAV server tables if they exist (created by CalDAV server container)
cursor.execute("""
DO $$
BEGIN
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'principals') THEN
TRUNCATE TABLE principals CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'users') THEN
TRUNCATE TABLE users CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendars') THEN
TRUNCATE TABLE calendars CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendarinstances') THEN
TRUNCATE TABLE calendarinstances CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendarobjects') THEN
TRUNCATE TABLE calendarobjects CASCADE;
END IF;
END $$;
""")
if not _has_caldav_marker(request):
yield
return
import psycopg # noqa: PLC0415 # pylint: disable=import-outside-toplevel
db_settings = settings.DATABASES["default"]
conn = psycopg.connect(
host=db_settings["HOST"],
port=db_settings["PORT"],
dbname="calendars", # SabreDAV always uses this DB
user=db_settings["USER"],
password=db_settings["PASSWORD"],
)
conn.autocommit = True
try:
with conn.cursor() as cur: # pylint: disable=no-member
for table in [
"calendarobjects",
"calendarinstances",
"calendars",
"principals",
]:
cur.execute(f"TRUNCATE TABLE {table} CASCADE")
finally:
conn.close() # pylint: disable=no-member
yield
@pytest.fixture(autouse=True)
def disconnect_caldav_signals_for_unit_tests(request):
"""Disconnect CalDAV signal handlers for non-CalDAV tests.
Prevents non-CalDAV tests from hitting the real SabreDAV server
(e.g. via post_save signal when UserFactory creates a user),
which would interfere with CalDAV E2E tests running concurrently
on another xdist worker.
"""
if _has_caldav_marker(request):
yield
return
from django.contrib.auth import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
get_user_model,
)
from django.db.models.signals import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
post_save,
pre_delete,
)
from core.signals import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
delete_user_caldav_data,
provision_default_calendar,
)
user_model = get_user_model()
post_save.disconnect(provision_default_calendar, sender=user_model)
pre_delete.disconnect(delete_user_caldav_data, sender=user_model)
yield
post_save.connect(provision_default_calendar, sender=user_model)
pre_delete.connect(delete_user_caldav_data, sender=user_model)
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache after each test."""
@@ -58,16 +100,7 @@ def clear_cache():
# Clear functools.cache for functions decorated with @functools.cache
@pytest.fixture
def mock_user_teams():
"""Mock for the "teams" property on the User model."""
with mock.patch(
"core.models.User.teams", new_callable=mock.PropertyMock
) as mock_teams:
yield mock_teams
def resource_server_backend_setup(settings):
def resource_server_backend_setup(settings): # pylint: disable=redefined-outer-name
"""
A fixture to create a user token for testing.
"""
@@ -91,7 +124,7 @@ def resource_server_backend_setup(settings):
@pytest.fixture
def resource_server_backend_conf(settings):
def resource_server_backend_conf(settings): # pylint: disable=redefined-outer-name
"""
A fixture to create a user token for testing.
"""
@@ -100,7 +133,7 @@ def resource_server_backend_conf(settings):
@pytest.fixture
def resource_server_backend(settings):
def resource_server_backend(settings): # pylint: disable=redefined-outer-name
"""
A fixture to create a user token for testing.
Including a mocked introspection endpoint.

View File

@@ -43,29 +43,32 @@ def test_api_users_list_authenticated():
"/api/v1.0/users/",
)
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory()
org = user.organization
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com", is_active=False)
lennon = factories.UserFactory(email="john.lennon@example.com")
factories.UserFactory(
email="john.doe@example.com", is_active=False, organization=org
)
lennon = factories.UserFactory(email="john.lennon@example.com", organization=org)
# Use email query to get exact match
response = client.get("/api/v1.0/users/?q=john.lennon@example.com")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(lennon.id)]
# Inactive user should not be returned even with exact match
response = client.get("/api/v1.0/users/?q=john.doe@example.com")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == []
@@ -83,16 +86,16 @@ def test_api_users_list_query_short_queries():
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
# Non-email queries (without @) return empty
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
def test_api_users_list_limit(settings):
@@ -101,6 +104,7 @@ def test_api_users_list_limit(settings):
should be limited to 10.
"""
user = factories.UserFactory()
org = user.organization
client = APIClient()
client.force_login(user)
@@ -108,14 +112,14 @@ def test_api_users_list_limit(settings):
# Use a base name with a length equal 5 to test that the limit is applied
base_name = "alice"
for i in range(15):
factories.UserFactory(email=f"{base_name}.{i}@example.com")
factories.UserFactory(email=f"{base_name}.{i}@example.com", organization=org)
# Non-email queries (without @) return empty
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
# Email queries require exact match
settings.API_USERS_LIST_LIMIT = 100
@@ -123,7 +127,7 @@ def test_api_users_list_limit(settings):
"/api/v1.0/users/?q=alice.0@example.com",
)
assert response.status_code == 200
assert len(response.json()) == 1
assert len(response.json()["results"]) == 1
def test_api_users_list_throttling_authenticated(settings):
@@ -157,19 +161,20 @@ def test_api_users_list_query_email(settings):
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "9999/minute"
user = factories.UserFactory()
org = user.organization
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="david.bowman@work.com")
factories.UserFactory(email="nicole.bowman@work.com")
dave = factories.UserFactory(email="david.bowman@work.com", organization=org)
factories.UserFactory(email="nicole.bowman@work.com", organization=org)
# Exact match works
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
# Case-insensitive match works
@@ -177,7 +182,7 @@ def test_api_users_list_query_email(settings):
"/api/v1.0/users/?q=David.Bowman@Work.COM",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
# Typos don't match (exact match only)
@@ -185,43 +190,48 @@ def test_api_users_list_query_email(settings):
"/api/v1.0/users/?q=davig.bovman@worm.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == []
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.cop",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == []
def test_api_users_list_query_email_matching():
"""Email queries return exact matches only (case-insensitive)."""
user = factories.UserFactory()
org = user.organization
client = APIClient()
client.force_login(user)
user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr")
factories.UserFactory(email="alice.johnnson@example.gouv.fr")
factories.UserFactory(email="alice.kohlson@example.gouv.fr")
user4 = factories.UserFactory(email="alicia.johnnson@example.gouv.fr")
factories.UserFactory(email="alicia.johnnson@example.gov.uk")
factories.UserFactory(email="alice.thomson@example.gouv.fr")
user1 = factories.UserFactory(
email="alice.johnson@example.gouv.fr", organization=org
)
factories.UserFactory(email="alice.johnnson@example.gouv.fr", organization=org)
factories.UserFactory(email="alice.kohlson@example.gouv.fr", organization=org)
user4 = factories.UserFactory(
email="alicia.johnnson@example.gouv.fr", organization=org
)
factories.UserFactory(email="alicia.johnnson@example.gov.uk", organization=org)
factories.UserFactory(email="alice.thomson@example.gouv.fr", organization=org)
# Exact match returns only that user
response = client.get(
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(user1.id)]
# Different email returns different user
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(user4.id)]
@@ -260,9 +270,13 @@ def test_api_users_retrieve_me_authenticated():
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
"language": user.language,
"can_access": True,
"can_admin": True,
"organization": {
"id": str(user.organization.id),
"name": user.organization.name,
},
}

View File

@@ -27,7 +27,7 @@ class TestCalDAVProxy:
def test_proxy_requires_authentication(self):
"""Test that unauthenticated requests return 401."""
client = APIClient()
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
response = client.generic("PROPFIND", "/caldav/")
assert response.status_code == HTTP_401_UNAUTHORIZED
@responses.activate
@@ -49,7 +49,7 @@ class TestCalDAVProxy:
)
)
client.generic("PROPFIND", "/api/v1.0/caldav/")
client.generic("PROPFIND", "/caldav/")
# Verify request was made to CalDAV server
assert len(responses.calls) == 1
@@ -77,7 +77,7 @@ class TestCalDAVProxy:
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
url=f"{caldav_url}/caldav/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
@@ -88,7 +88,7 @@ class TestCalDAVProxy:
malicious_email = "attacker@example.com"
client.generic(
"PROPFIND",
"/api/v1.0/caldav/",
"/caldav/",
HTTP_X_FORWARDED_USER=malicious_email,
)
@@ -107,10 +107,6 @@ class TestCalDAVProxy:
"X-Forwarded-User should NOT use client-sent header value"
)
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured - integration test requires real server",
)
def test_proxy_propfind_response_contains_prefixed_urls(self):
"""PROPFIND responses should contain URLs with proxy prefix.
@@ -130,7 +126,7 @@ class TestCalDAVProxy:
)
response = client.generic(
"PROPFIND",
"/api/v1.0/caldav/",
"/caldav/",
data=propfind_body,
content_type="application/xml",
)
@@ -154,8 +150,8 @@ class TestCalDAVProxy:
if href and (
href.startswith("/principals/") or href.startswith("/calendars/")
):
assert href.startswith("/api/v1.0/caldav/"), (
f"Expected URL to start with /api/v1.0/caldav/, "
assert href.startswith("/caldav/"), (
f"Expected URL to start with /caldav/, "
f"got {href}. BaseUriPlugin is not using "
f"X-Forwarded-Prefix correctly. Full response: "
f"{response.content.decode('utf-8', errors='ignore')}"
@@ -178,7 +174,7 @@ class TestCalDAVProxy:
propfind_xml = """<?xml version="1.0"?>
<multistatus xmlns="DAV:">
<response>
<href>/api/v1.0/caldav/calendars/test@example.com/calendar-id/</href>
<href>/caldav/calendars/users/test@example.com/calendar-id/</href>
<propstat>
<prop>
<resourcetype>
@@ -193,14 +189,14 @@ class TestCalDAVProxy:
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
url=f"{caldav_url}/caldav/",
status=HTTP_207_MULTI_STATUS,
body=propfind_xml,
headers={"Content-Type": "application/xml"},
)
)
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
response = client.generic("PROPFIND", "/caldav/")
assert response.status_code == HTTP_207_MULTI_STATUS
@@ -213,7 +209,7 @@ class TestCalDAVProxy:
# Verify the URL is passed through unchanged (sabre/dav should generate it with prefix)
href = href_elem.text
assert href == "/api/v1.0/caldav/calendars/test@example.com/calendar-id/", (
assert href == "/caldav/calendars/users/test@example.com/calendar-id/", (
f"Expected URL to be passed through unchanged, got {href}"
)
@@ -234,7 +230,7 @@ class TestCalDAVProxy:
propfind_xml = """<?xml version="1.0"?>
<multistatus xmlns="DAV:" xmlns:D="DAV:">
<response>
<D:href>/api/v1.0/caldav/principals/test@example.com/</D:href>
<D:href>/caldav/principals/users/test@example.com/</D:href>
<propstat>
<prop>
<resourcetype>
@@ -248,14 +244,14 @@ class TestCalDAVProxy:
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
url=f"{caldav_url}/caldav/",
status=HTTP_207_MULTI_STATUS,
body=propfind_xml,
headers={"Content-Type": "application/xml"},
)
)
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
response = client.generic("PROPFIND", "/caldav/")
assert response.status_code == HTTP_207_MULTI_STATUS
@@ -268,7 +264,7 @@ class TestCalDAVProxy:
# Verify the URL is passed through unchanged (sabre/dav should generate it with prefix)
href = href_elem.text
assert href == "/api/v1.0/caldav/principals/test@example.com/", (
assert href == "/caldav/principals/users/test@example.com/", (
f"Expected URL to be passed through unchanged, got {href}"
)
@@ -283,7 +279,7 @@ class TestCalDAVProxy:
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/principals/test@example.com/",
url=f"{caldav_url}/caldav/principals/users/test@example.com/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
@@ -291,14 +287,12 @@ class TestCalDAVProxy:
)
# Request a specific path
client.generic("PROPFIND", "/api/v1.0/caldav/principals/test@example.com/")
client.generic("PROPFIND", "/caldav/principals/users/test@example.com/")
# Verify the request was made to the correct URL
assert len(responses.calls) == 1
request = responses.calls[0].request
assert (
request.url == f"{caldav_url}/api/v1.0/caldav/principals/test@example.com/"
)
assert request.url == f"{caldav_url}/caldav/principals/users/test@example.com/"
@responses.activate
def test_proxy_handles_options_request(self):
@@ -307,7 +301,7 @@ class TestCalDAVProxy:
client = APIClient()
client.force_login(user)
response = client.options("/api/v1.0/caldav/")
response = client.options("/caldav/")
assert response.status_code == HTTP_200_OK
assert "Access-Control-Allow-Methods" in response
@@ -319,9 +313,7 @@ class TestCalDAVProxy:
client = APIClient()
client.force_login(user)
response = client.generic(
"PROPFIND", "/api/v1.0/caldav/calendars/../../etc/passwd"
)
response = client.generic("PROPFIND", "/caldav/calendars/../../etc/passwd")
assert response.status_code == HTTP_400_BAD_REQUEST
def test_proxy_rejects_non_caldav_path(self):
@@ -330,7 +322,16 @@ class TestCalDAVProxy:
client = APIClient()
client.force_login(user)
response = client.generic("PROPFIND", "/api/v1.0/caldav/etc/passwd")
response = client.generic("PROPFIND", "/caldav/etc/passwd")
assert response.status_code == HTTP_400_BAD_REQUEST
def test_proxy_rejects_internal_api_path(self):
"""Test that proxy explicitly blocks /internal-api/ paths."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
response = client.generic("POST", "/caldav/internal-api/resources/")
assert response.status_code == HTTP_400_BAD_REQUEST
@@ -343,11 +344,11 @@ class TestValidateCaldavProxyPath:
def test_calendars_path_is_valid(self):
"""Standard calendars path should be valid."""
assert validate_caldav_proxy_path("calendars/user@ex.com/uuid/") is True
assert validate_caldav_proxy_path("calendars/users/user@ex.com/uuid/") is True
def test_principals_path_is_valid(self):
"""Standard principals path should be valid."""
assert validate_caldav_proxy_path("principals/user@ex.com/") is True
assert validate_caldav_proxy_path("principals/users/user@ex.com/") is True
def test_traversal_is_rejected(self):
"""Directory traversal attempts should be rejected."""
@@ -363,4 +364,24 @@ class TestValidateCaldavProxyPath:
def test_leading_slash_calendars_is_valid(self):
"""Paths with leading slash should still be valid."""
assert validate_caldav_proxy_path("/calendars/user@ex.com/uuid/") is True
assert validate_caldav_proxy_path("/calendars/users/user@ex.com/uuid/") is True
def test_internal_api_is_rejected(self):
"""Internal API paths should be explicitly blocked."""
assert validate_caldav_proxy_path("internal-api/resources/") is False
def test_internal_api_with_leading_slash_is_rejected(self):
"""Internal API paths with leading slash should be blocked."""
assert validate_caldav_proxy_path("/internal-api/import/user/cal") is False
def test_encoded_traversal_is_rejected(self):
"""URL-encoded directory traversal should be rejected."""
assert validate_caldav_proxy_path("calendars/%2e%2e/%2e%2e/etc/passwd") is False
def test_encoded_internal_api_is_rejected(self):
"""URL-encoded internal-api path should be blocked."""
assert validate_caldav_proxy_path("%69nternal-api/resources/") is False
def test_encoded_null_byte_is_rejected(self):
"""URL-encoded null byte should be rejected."""
assert validate_caldav_proxy_path("calendars/user%00/") is False

View File

@@ -72,13 +72,10 @@ def create_test_server() -> tuple:
@pytest.mark.django_db
@pytest.mark.xdist_group("caldav")
class TestCalDAVScheduling:
"""Tests for CalDAV scheduling callback when creating events with attendees."""
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured - integration test requires real server",
)
def test_scheduling_callback_received_when_creating_event_with_attendee( # noqa: PLR0915 # pylint: disable=too-many-locals,too-many-statements
self,
):
@@ -125,8 +122,8 @@ class TestCalDAVScheduling:
try:
# Create an event with an attendee
client = service.caldav._get_client(organizer) # pylint: disable=protected-access
calendar_url = f"{settings.CALDAV_URL}{caldav_path}"
client = service._get_client(organizer) # pylint: disable=protected-access
calendar_url = service._calendar_url(caldav_path) # pylint: disable=protected-access
# Add custom callback URL header to the client
# The CalDAV server will use this URL for the callback

View File

@@ -1,7 +1,5 @@
"""Tests for CalDAV service integration."""
from django.conf import settings
import pytest
from core import factories
@@ -9,6 +7,7 @@ from core.services.caldav_service import CalDAVClient, CalendarService
@pytest.mark.django_db
@pytest.mark.xdist_group("caldav")
class TestCalDAVClient:
"""Tests for CalDAVClient authentication and communication."""
@@ -30,10 +29,6 @@ class TestCalDAVClient:
assert "X-Forwarded-User" in dav_client.headers
assert dav_client.headers["X-Forwarded-User"] == user.email
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
def test_create_calendar_authenticates_with_caldav_server(self):
"""Test that calendar creation authenticates successfully with CalDAV server."""
user = factories.UserFactory(email="test@example.com")
@@ -65,10 +60,6 @@ class TestCalDAVClient:
assert isinstance(caldav_path, str)
assert "calendars/" in caldav_path
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
def test_create_calendar_with_color_persists(self):
"""Test that creating a calendar with a color saves it in CalDAV."""
user = factories.UserFactory(email="color-test@example.com")
@@ -79,7 +70,7 @@ class TestCalDAVClient:
caldav_path = service.create_calendar(user, name="Red Calendar", color=color)
# Fetch the calendar info and verify the color was persisted
info = service.caldav.get_calendar_info(user, caldav_path)
info = service.get_calendar_info(user, caldav_path)
assert info is not None
assert info["color"] == color
assert info["name"] == "Red Calendar"

View File

@@ -1,40 +1,37 @@
"""Tests for calendar subscription token API."""
from urllib.parse import quote
from django.urls import reverse
"""Tests for iCal feed channel creation via the channels API."""
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
from core.models import Channel
CHANNELS_URL = "/api/v1.0/channels/"
@pytest.mark.django_db
class TestSubscriptionTokenViewSet:
"""Tests for the new standalone SubscriptionTokenViewSet."""
class TestICalFeedChannels:
"""Tests for ical-feed channel creation via ChannelViewSet."""
def test_create_subscription_token(self):
"""Test creating a subscription token for a calendar."""
def test_create_ical_feed_channel(self):
"""Test creating an ical-feed channel for a calendar."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/test-calendar-uuid/"
caldav_path = f"/calendars/users/{user.email}/test-calendar-uuid/"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
CHANNELS_URL,
{
"name": "My Test Calendar",
"type": "ical-feed",
"caldav_path": caldav_path,
"calendar_name": "My Test Calendar",
},
@@ -47,233 +44,210 @@ class TestSubscriptionTokenViewSet:
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"
assert response.data["type"] == "ical-feed"
# Verify token was created in database
assert CalendarSubscriptionToken.objects.filter(
owner=user, caldav_path=caldav_path
# Verify channel was created in database
assert Channel.objects.filter(
user=user, caldav_path=caldav_path, type="ical-feed"
).exists()
def test_create_subscription_token_normalizes_path(self):
def test_create_ical_feed_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
caldav_path = f"calendars/users/{user.email}/test-uuid"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{"name": "Cal", "type": "ical-feed", "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/"
assert (
response.data["caldav_path"] == f"/calendars/users/{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()
def test_create_ical_feed_returns_existing(self):
"""Test that creating an ical-feed channel when one exists returns it."""
channel = factories.ICalFeedChannelFactory()
client = APIClient()
client.force_login(subscription.owner)
client.force_login(channel.user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
CHANNELS_URL,
{
"caldav_path": subscription.caldav_path,
"name": "Updated Name",
"type": "ical-feed",
"caldav_path": channel.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"
channel.refresh_from_db()
assert channel.settings["calendar_name"] == "Updated Name"
def test_get_subscription_token_by_path(self):
"""Test retrieving an existing subscription token by CalDAV path."""
subscription = factories.CalendarSubscriptionTokenFactory()
def test_list_ical_feed_channels(self):
"""Test filtering channels by type=ical-feed."""
user = factories.UserFactory()
client = APIClient()
client.force_login(subscription.owner)
client.force_login(user)
url = reverse("subscription-tokens-by-path")
response = client.get(url, {"caldav_path": subscription.caldav_path})
# Create one ical-feed and one caldav channel
client.post(
CHANNELS_URL,
{
"name": "Feed",
"type": "ical-feed",
"caldav_path": f"/calendars/users/{user.email}/cal1/",
},
format="json",
)
client.post(
CHANNELS_URL,
{"name": "CalDAV Channel"},
format="json",
)
# Filter by type
response = client.get(CHANNELS_URL, {"type": "ical-feed"})
assert response.status_code == HTTP_200_OK
assert response.data["token"] == str(subscription.token)
assert "url" in response.data
assert len(response.data) == 1
assert response.data[0]["type"] == "ical-feed"
def test_get_subscription_token_not_found(self):
"""Test retrieving token when none exists."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/nonexistent/"
# Without filter, both show up
response = client.get(CHANNELS_URL)
assert len(response.data) == 2
def test_delete_ical_feed_channel(self):
"""Test deleting an ical-feed channel."""
channel = factories.ICalFeedChannelFactory()
client = APIClient()
client.force_login(user)
client.force_login(channel.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)
response = client.delete(f"{CHANNELS_URL}{channel.pk}/")
assert response.status_code == HTTP_204_NO_CONTENT
assert not CalendarSubscriptionToken.objects.filter(pk=subscription.pk).exists()
assert not Channel.objects.filter(pk=channel.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."""
def test_non_owner_cannot_create_ical_feed(self):
"""Test that users cannot create ical-feed channels for others' calendars."""
user = factories.UserFactory()
other_user = factories.UserFactory()
caldav_path = f"/calendars/{other_user.email}/test-calendar/"
caldav_path = f"/calendars/users/{other_user.email}/test-calendar/"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Stolen",
"type": "ical-feed",
"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()
def test_non_owner_cannot_list_others_channels(self):
"""Test that users only see their own channels."""
factories.ICalFeedChannelFactory()
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})
response = client.get(CHANNELS_URL, {"type": "ical-feed"})
assert response.status_code == HTTP_200_OK
assert len(response.data) == 0
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."""
def test_unauthenticated_cannot_create(self):
"""Test that unauthenticated users cannot create channels."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/test-calendar/"
caldav_path = f"/calendars/users/{user.email}/test-calendar/"
client = APIClient()
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Feed",
"type": "ical-feed",
"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
channel = factories.ICalFeedChannelFactory()
old_token = channel.encrypted_settings["token"]
client = APIClient()
client.force_login(subscription.owner)
client.force_login(channel.user)
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)
# Delete old channel
response = client.delete(f"{CHANNELS_URL}{channel.pk}/")
assert response.status_code == HTTP_204_NO_CONTENT
# Create new token
# Create new one for the same path
response = client.post(
create_url,
{"caldav_path": subscription.caldav_path},
CHANNELS_URL,
{
"name": "Feed",
"type": "ical-feed",
"caldav_path": channel.caldav_path,
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
assert response.data["token"] != str(old_token)
assert response.data["token"] != 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
"""Test that only one ical-feed channel exists per owner+caldav_path."""
channel = factories.ICalFeedChannelFactory()
client = APIClient()
client.force_login(subscription.owner)
client.force_login(channel.user)
url = reverse("subscription-tokens-list")
# Try to create another - should return existing
response = client.post(
url,
{"caldav_path": subscription.caldav_path},
CHANNELS_URL,
{
"name": "Duplicate",
"type": "ical-feed",
"caldav_path": channel.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
assert Channel.objects.filter(user=channel.user, type="ical-feed").count() == 1
def test_url_contains_slugified_calendar_name(self):
"""Test that the URL contains the slugified calendar name."""
user = factories.UserFactory()
caldav_path = f"/calendars/users/{user.email}/cal/"
client = APIClient()
client.force_login(user)
response = client.post(
CHANNELS_URL,
{
"name": "My Awesome Calendar",
"type": "ical-feed",
"caldav_path": caldav_path,
"calendar_name": "My Awesome Calendar",
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
assert "my-awesome-calendar.ics" in response.data["url"]
@pytest.mark.django_db
class TestPathInjectionProtection:
@@ -290,45 +264,40 @@ class TestPathInjectionProtection:
@pytest.mark.parametrize(
"malicious_suffix",
[
# Path traversal attacks
"../other-calendar/",
"../../etc/passwd/",
"..%2F..%2Fetc%2Fpasswd/", # URL-encoded traversal
# Query parameter injection
"..%2F..%2Fetc%2Fpasswd/",
"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
"uuid\u002e\u002e/",
],
)
def test_create_token_rejects_malicious_calendar_id(self, malicious_suffix):
def test_create_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}"
caldav_path = f"/calendars/users/{user.email}/{malicious_suffix}"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Bad",
"type": "ical-feed",
"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}"
)
@@ -336,31 +305,30 @@ class TestPathInjectionProtection:
@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):
def test_create_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},
CHANNELS_URL,
{
"name": "Bad",
"type": "ical-feed",
"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}"
)
@@ -368,21 +336,23 @@ class TestPathInjectionProtection:
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")
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
f"/calendars/{attacker.email}/../victim@example.com/secret-calendar/",
"/calendars/victim@example.com/secret-calendar/",
]
url = reverse("subscription-tokens-list")
for path in malicious_paths:
response = client.post(
url,
{"caldav_path": path},
CHANNELS_URL,
{
"name": "Bad",
"type": "ical-feed",
"caldav_path": path,
},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN, (
@@ -392,15 +362,19 @@ class TestPathInjectionProtection:
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/"
caldav_path = (
f"/calendars/users/{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},
CHANNELS_URL,
{
"name": "Good",
"type": "ical-feed",
"caldav_path": caldav_path,
},
format="json",
)
@@ -409,43 +383,18 @@ class TestPathInjectionProtection:
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/"
caldav_path = f"/calendars/users/{user.email}/my-calendar-2024/"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Good",
"type": "ical-feed",
"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

View File

@@ -0,0 +1,456 @@
"""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)

View File

@@ -0,0 +1,709 @@
"""End-to-end cross-organization isolation tests against real SabreDAV.
These tests verify that org-scoped resources, calendars, and operations
are properly isolated between organizations. They hit the real SabreDAV
server (no mocks) to validate the full stack: Django -> SabreDAV -> DB.
Requires: CalDAV server running (skipped otherwise).
"""
# pylint: disable=no-member,broad-exception-caught,unused-variable
from datetime import datetime, timedelta
from types import SimpleNamespace
import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_204_NO_CONTENT,
HTTP_207_MULTI_STATUS,
HTTP_400_BAD_REQUEST,
)
from rest_framework.test import APIClient
from core import factories
from core.entitlements.factory import get_entitlements_backend
from core.models import Organization, User
from core.services.caldav_service import CalDAVHTTPClient, CalendarService
from core.services.resource_service import ResourceProvisioningError, ResourceService
pytestmark = [
pytest.mark.django_db,
pytest.mark.xdist_group("caldav"),
]
@pytest.fixture(autouse=True)
def _local_entitlements(settings):
"""Use local entitlements backend for all tests in this module."""
settings.ENTITLEMENTS_BACKEND = (
"core.entitlements.backends.local.LocalEntitlementsBackend"
)
settings.ENTITLEMENTS_BACKEND_PARAMETERS = {}
get_entitlements_backend.cache_clear()
yield
get_entitlements_backend.cache_clear()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_org_admin(org):
"""Create a user in the given org and return (user, api_client).
Uses force_login (not force_authenticate) so that session-based auth
works for the CalDAV proxy view, which checks request.user.is_authenticated
via Django's session middleware rather than DRF's token auth.
"""
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_login(user)
return user, client
def _create_resource_via_internal_api(user, name="Room 1", resource_type="ROOM"):
"""Create a resource using ResourceService (hits real SabreDAV)."""
service = ResourceService()
return service.create_resource(user, name, resource_type)
def _get_dav_client_with_org(user):
"""Get a DAVClient with org header via CalDAVHTTPClient."""
http = CalDAVHTTPClient()
return http.get_dav_client(user)
def _propfind_resource_principals(api_client):
"""PROPFIND /caldav/principals/resources/ and return parsed XML root."""
body = (
'<?xml version="1.0"?>'
'<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
"<prop>"
"<displayname/>"
"<C:calendar-user-type/>"
"</prop>"
"</propfind>"
)
response = api_client.generic(
"PROPFIND",
"/caldav/principals/resources/",
data=body,
content_type="application/xml",
HTTP_DEPTH="1",
)
return response
def _propfind_resource_calendar(api_client, resource_id):
"""PROPFIND a specific resource's calendar collection."""
body = (
'<?xml version="1.0"?>'
'<propfind xmlns="DAV:"><prop><resourcetype/></prop></propfind>'
)
response = api_client.generic(
"PROPFIND",
f"/caldav/calendars/resources/{resource_id}/",
data=body,
content_type="application/xml",
HTTP_DEPTH="1",
)
return response
def _put_event_on_resource(api_client, resource_id, event_uid, organizer_email):
"""PUT an event directly onto a resource's default calendar."""
dtstart = datetime.now() + timedelta(days=1)
dtend = dtstart + timedelta(hours=1)
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
f"UID:{event_uid}\r\n"
f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n"
"SUMMARY:Cross-org test event\r\n"
f"ORGANIZER:mailto:{organizer_email}\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
return api_client.generic(
"PUT",
f"/caldav/calendars/resources/{resource_id}/default/{event_uid}.ics",
data=ical,
content_type="text/calendar",
)
# ---------------------------------------------------------------------------
# 1. Resource provisioning — cross-org isolation via real SabreDAV
# ---------------------------------------------------------------------------
class TestResourceProvisioningE2E:
"""Resource creation and deletion hit real SabreDAV internal API."""
def test_create_resource_e2e(self):
"""POST /resources/ creates a principal in SabreDAV."""
org = factories.OrganizationFactory(external_id="res-e2e-org")
admin, client = _create_org_admin(org)
response = client.post(
"/api/v1.0/resources/",
{"name": "Meeting Room A", "resource_type": "ROOM"},
format="json",
)
assert response.status_code == HTTP_201_CREATED, response.json()
data = response.json()
assert data["name"] == "Meeting Room A"
assert data["resource_type"] == "ROOM"
resource_id = data["id"]
# Verify the principal actually exists in SabreDAV via PROPFIND
propfind = _propfind_resource_calendar(client, resource_id)
assert propfind.status_code == HTTP_207_MULTI_STATUS, (
f"Resource calendar not found in SabreDAV: {propfind.status_code}"
)
def test_delete_resource_e2e_same_org(self):
"""Admin can delete a resource belonging to their own org."""
org = factories.OrganizationFactory(external_id="del-same-org")
admin, client = _create_org_admin(org)
resource = _create_resource_via_internal_api(admin, "Doomed Room")
resource_id = resource["id"]
response = client.delete(f"/api/v1.0/resources/{resource_id}/")
assert response.status_code == HTTP_204_NO_CONTENT
# Verify the principal is gone from SabreDAV
propfind = _propfind_resource_calendar(client, resource_id)
# Should be 404 or 207 with empty result — the principal was deleted
assert propfind.status_code != HTTP_207_MULTI_STATUS or (
b"<response>" not in propfind.content
)
def test_delete_resource_e2e_cross_org_blocked(self):
"""Admin from org A CANNOT delete a resource belonging to org B.
This is enforced by SabreDAV's InternalApiPlugin, not Django.
"""
org_a = factories.OrganizationFactory(external_id="del-org-a")
org_b = factories.OrganizationFactory(external_id="del-org-b")
# Create resource in org B
admin_b = factories.UserFactory(organization=org_b)
resource = _create_resource_via_internal_api(admin_b, "Org B Room")
resource_id = resource["id"]
# Admin from org A tries to delete it
_, client_a = _create_org_admin(org_a)
response = client_a.delete(f"/api/v1.0/resources/{resource_id}/")
# Django returns 400 because SabreDAV returned 403
assert response.status_code == HTTP_400_BAD_REQUEST
assert "different organization" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# 2. CalDAV proxy — org header forwarding verified E2E
# ---------------------------------------------------------------------------
class TestCalDAVProxyOrgHeaderE2E:
"""Verify org header reaches SabreDAV and affects responses."""
def test_user_can_propfind_own_calendar(self):
"""User can PROPFIND their own calendar home."""
org = factories.OrganizationFactory(external_id="proxy-own")
user, client = _create_org_admin(org)
# Create a calendar for the user
service = CalendarService()
service.create_calendar(user, name="My Cal")
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/",
data='<?xml version="1.0"?><propfind xmlns="DAV:"><prop>'
"<displayname/></prop></propfind>",
content_type="application/xml",
HTTP_DEPTH="1",
)
assert response.status_code == HTTP_207_MULTI_STATUS
def test_user_cannot_read_other_users_calendar_objects(self):
"""User from org A cannot read events from user B's calendar.
SabreDAV allows PROPFIND on calendar homes (auto-creates principals),
but blocks reading actual calendar objects via ACLs. The key isolation
is that event data (REPORT/GET on .ics) is protected.
"""
org_a = factories.OrganizationFactory(external_id="proxy-org-a")
org_b = factories.OrganizationFactory(external_id="proxy-org-b")
user_a, client_a = _create_org_admin(org_a)
user_b = factories.UserFactory(organization=org_b)
# Create a calendar with an event for user B
service = CalendarService()
caldav_path = service.create_calendar(user_b, name="B's Calendar")
parts = caldav_path.strip("/").split("/")
cal_id = parts[-1] if len(parts) >= 4 else "default"
# Add an event to user B's calendar
dav_b = CalDAVHTTPClient().get_dav_client(user_b)
principal_b = dav_b.principal()
cals_b = principal_b.calendars()
dtstart = datetime.now() + timedelta(days=10)
dtend = dtstart + timedelta(hours=1)
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:private-event-uid\r\n"
f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n"
"SUMMARY:Private Event\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
cals_b[0].save_event(ical)
# User A tries to GET user B's event
response = client_a.generic(
"GET",
f"/caldav/calendars/users/{user_b.email}/{cal_id}/private-event-uid.ics",
)
# SabreDAV should block with 403 (ACL) or 404
assert response.status_code in (403, 404), (
f"Expected 403/404 for cross-user GET, got {response.status_code}"
)
# ---------------------------------------------------------------------------
# 3. Resource calendar access — cross-org isolation
# ---------------------------------------------------------------------------
class TestResourceCalendarAccessE2E:
"""Verify that resource calendar data is org-scoped in SabreDAV.
Users cannot PUT events directly on resource calendars — only
SabreDAV's scheduling plugin writes there via iTIP. These tests
verify that direct PUT is blocked by ACLs for both same-org and
cross-org users.
"""
def test_direct_put_on_resource_calendar_blocked(self):
"""Users cannot PUT events directly on resource calendars.
Resource calendars are managed by the auto-schedule plugin.
Direct writes are blocked by SabreDAV ACLs.
"""
org = factories.OrganizationFactory(external_id="put-direct-org")
user, client = _create_org_admin(org)
resource = _create_resource_via_internal_api(user, "ACL Room")
response = _put_event_on_resource(
client, resource["id"], "direct-put-uid", user.email
)
# Direct PUT on resource calendar is blocked by ACLs
assert response.status_code in (403, 404), (
f"Expected 403/404 for direct PUT on resource calendar, "
f"got {response.status_code}: "
f"{response.content.decode('utf-8', errors='ignore')[:500]}"
)
def test_cross_org_put_on_resource_calendar_blocked(self):
"""Cross-org direct PUT on resource calendar is also blocked."""
org_a = factories.OrganizationFactory(external_id="put-org-a")
org_b = factories.OrganizationFactory(external_id="put-org-b")
_, client_a = _create_org_admin(org_a)
admin_b = factories.UserFactory(organization=org_b)
resource_b = _create_resource_via_internal_api(admin_b, "Org B Room")
response = _put_event_on_resource(
client_a, resource_b["id"], "cross-org-event-uid", "attacker@test.com"
)
assert response.status_code in (403, 404), (
f"Expected 403/404 for cross-org PUT on resource calendar, "
f"got {response.status_code}"
)
# ---------------------------------------------------------------------------
# 4. Resource auto-scheduling — cross-org booking rejection
# ---------------------------------------------------------------------------
class TestResourceAutoScheduleCrossOrgE2E:
"""Verify that cross-org resource bookings are declined by SabreDAV.
The ResourceAutoSchedulePlugin checks X-CalDAV-Organization against
the resource's org_id and declines cross-org booking requests.
NOTE: SabreDAV's scheduling plugin resolves attendees via the principal
backend. Resource principals live under principals/resources/, and the
scheduling plugin must find them by email for iTIP delivery to work.
If SCHEDULE-STATUS=5.x appears, it means the principal wasn't resolved,
which blocks both auto-accept and auto-decline. These tests verify the
org-scoping logic in InternalApiPlugin (create/delete) rather than
iTIP scheduling, since iTIP requires searchPrincipals() support for
the resources collection.
"""
def test_resource_create_stores_org_id(self):
"""Resource creation stores org_id in SabreDAV for later scoping."""
org = factories.OrganizationFactory(external_id="sched-orgid")
user, _ = _create_org_admin(org)
resource = _create_resource_via_internal_api(user, "Org Room")
# Verify the resource exists by PROPFIND on its calendar
propfind = _propfind_resource_calendar(
_create_org_admin(org)[1], resource["id"]
)
assert propfind.status_code == HTTP_207_MULTI_STATUS
def test_resource_delete_cross_org_rejected_by_sabredav(self):
"""SabreDAV's InternalApiPlugin rejects cross-org resource deletion.
This is the core org-scoping enforcement: the org_id stored on creation
is checked on deletion.
"""
org_a = factories.OrganizationFactory(external_id="sched-del-a")
org_b = factories.OrganizationFactory(external_id="sched-del-b")
admin_a = factories.UserFactory(organization=org_a)
admin_b = factories.UserFactory(organization=org_b)
# Create resource in org B
resource = _create_resource_via_internal_api(admin_b, "Org B Room")
# Attempt delete from org A — should fail
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="different organization"):
service.delete_resource(admin_a, resource["id"])
# Verify resource still exists
_, client_b = _create_org_admin(org_b)
propfind = _propfind_resource_calendar(client_b, resource["id"])
assert propfind.status_code == HTTP_207_MULTI_STATUS
# ---------------------------------------------------------------------------
# 5. Resource principal discovery — all orgs see resource list
# but data is org-scoped
# ---------------------------------------------------------------------------
class TestResourceDiscoveryE2E:
"""Verify resource principal discovery behavior across orgs."""
def test_resource_principals_visible_to_authenticated_users(self):
"""Any authenticated user can PROPFIND /principals/resources/.
ResourcePrincipal grants {DAV:}read to {DAV:}authenticated.
This allows resource discovery for scheduling.
"""
org = factories.OrganizationFactory(external_id="disc-org")
user, client = _create_org_admin(org)
resource = _create_resource_via_internal_api(user, "Discoverable Room")
response = _propfind_resource_principals(client)
assert response.status_code == HTTP_207_MULTI_STATUS
# The response should contain the resource we just created
content = response.content.decode("utf-8", errors="ignore")
assert resource["id"] in content or "Discoverable Room" in content
# ---------------------------------------------------------------------------
# 6. User deletion cleanup — real SabreDAV
# ---------------------------------------------------------------------------
class TestUserDeletionCleanupE2E:
"""Verify user deletion cleans up CalDAV data in SabreDAV."""
def test_deleting_user_removes_caldav_principal(self):
"""When a Django user is deleted, their SabreDAV principal is cleaned up."""
user = factories.UserFactory(email="doomed-user@test-e2e.com")
# Create a calendar for the user (creates principal in SabreDAV)
service = CalendarService()
service.create_calendar(user, name="Soon Deleted")
# Verify calendar exists
dav = CalDAVHTTPClient().get_dav_client(user)
principal = dav.principal()
assert len(principal.calendars()) > 0
# Capture org before delete (Python obj persists but be explicit)
org_id = user.organization_id
# Delete the user (signal triggers CalDAV cleanup)
user.delete()
# Verify the principal's calendars are gone
# After deletion, the principal shouldn't exist, but due to
# auto-create behavior, just check calendars are empty
ghost = SimpleNamespace(
email="doomed-user@test-e2e.com", organization_id=org_id
)
dav2 = CalDAVHTTPClient().get_dav_client(ghost)
try:
principal2 = dav2.principal()
cals = principal2.calendars()
# Either no calendars or the principal doesn't exist
assert len(cals) == 0, (
f"Expected 0 calendars after deletion, found {len(cals)}"
)
except Exception: # noqa: BLE001
# Principal not found — expected
pass
# ---------------------------------------------------------------------------
# 7. Organization deletion cleanup — real SabreDAV
# ---------------------------------------------------------------------------
class TestOrgDeletionCleanupE2E:
"""Verify org deletion cleans up all member CalDAV data."""
def test_deleting_org_removes_all_member_caldav_data(self):
"""Deleting an org cleans up CalDAV data for all its members."""
org = factories.OrganizationFactory(external_id="doomed-org-e2e")
alice = factories.UserFactory(
email="alice-doomed@test-e2e.com", organization=org
)
bob = factories.UserFactory(email="bob-doomed@test-e2e.com", organization=org)
# Create calendars for both users
service = CalendarService()
service.create_calendar(alice, name="Alice Cal")
service.create_calendar(bob, name="Bob Cal")
# Verify calendars exist
for user in [alice, bob]:
dav = CalDAVHTTPClient().get_dav_client(user)
assert len(dav.principal().calendars()) > 0
# Capture org_id before deletion
org_id = org.id
# Delete the org (cascades to member cleanup + user deletion)
org.delete()
# Verify both users' calendars are gone
emails = ["alice-doomed@test-e2e.com", "bob-doomed@test-e2e.com"]
for email in emails:
ghost = SimpleNamespace(email=email, organization_id=org_id)
dav = CalDAVHTTPClient().get_dav_client(ghost)
try:
cals = dav.principal().calendars()
assert len(cals) == 0
except Exception: # noqa: BLE001
pass # Principal not found — expected
assert not User.objects.filter(
email__in=[
"alice-doomed@test-e2e.com",
"bob-doomed@test-e2e.com",
]
).exists()
assert not Organization.objects.filter(external_id="doomed-org-e2e").exists()
# ---------------------------------------------------------------------------
# 8. Calendar creation isolation
# ---------------------------------------------------------------------------
class TestCalendarCreationIsolationE2E:
"""Verify calendar creation is scoped to the authenticated user."""
def test_mkcalendar_creates_for_authenticated_user_only(self):
"""MKCALENDAR via proxy creates calendar under the authenticated user's principal."""
org = factories.OrganizationFactory(external_id="mkcal-org")
user_a, client_a = _create_org_admin(org)
user_b = factories.UserFactory(organization=org)
# User A creates a calendar via proxy
response = client_a.generic(
"MKCALENDAR",
f"/caldav/calendars/users/{user_a.email}/new-cal-e2e/",
data=(
'<?xml version="1.0" encoding="utf-8" ?>'
'<C:mkcalendar xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:caldav">'
"<D:set><D:prop>"
"<D:displayname>E2E Test Calendar</D:displayname>"
"</D:prop></D:set>"
"</C:mkcalendar>"
),
content_type="application/xml",
)
assert response.status_code == 201, (
f"MKCALENDAR failed: {response.status_code} "
f"{response.content.decode('utf-8', errors='ignore')[:500]}"
)
# Verify user A has the calendar via CalDAV PROPFIND
dav_a = CalDAVHTTPClient().get_dav_client(user_a)
cal_names_a = [c.name for c in dav_a.principal().calendars()]
assert "E2E Test Calendar" in cal_names_a, (
f"Calendar 'E2E Test Calendar' not found for user A. Found: {cal_names_a}"
)
# Verify user B does NOT have this calendar
dav_b = CalDAVHTTPClient().get_dav_client(user_b)
try:
cal_names_b = [c.name for c in dav_b.principal().calendars()]
assert "E2E Test Calendar" not in cal_names_b
except Exception: # noqa: BLE001
pass # User B has no principal — that's fine
def test_cannot_create_calendar_under_other_user(self):
"""User A cannot MKCALENDAR under user B's principal."""
org = factories.OrganizationFactory(external_id="mkcal-cross")
user_a, client_a = _create_org_admin(org)
user_b = factories.UserFactory(
email="mkcal-victim@test-e2e.com", organization=org
)
response = client_a.generic(
"MKCALENDAR",
f"/caldav/calendars/users/{user_b.email}/hijacked-cal/",
data=(
'<?xml version="1.0" encoding="utf-8" ?>'
'<C:mkcalendar xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:caldav">'
"<D:set><D:prop>"
"<D:displayname>Hijacked Calendar</D:displayname>"
"</D:prop></D:set>"
"</C:mkcalendar>"
),
content_type="application/xml",
)
# SabreDAV should block this with 403 (ACL violation)
assert response.status_code in (403, 404, 409), (
f"Expected 403/404 for MKCALENDAR under another user, "
f"got {response.status_code}"
)
# ---------------------------------------------------------------------------
# 9. Event CRUD isolation
# ---------------------------------------------------------------------------
class TestEventCRUDIsolationE2E:
"""Verify event CRUD is scoped to the calendar owner."""
def test_user_cannot_put_event_in_other_users_calendar(self):
"""User A cannot PUT an event into user B's calendar."""
org = factories.OrganizationFactory(external_id="event-cross")
user_a, client_a = _create_org_admin(org)
user_b = factories.UserFactory(
email="event-victim@test-e2e.com", organization=org
)
# Create a calendar for user B
service = CalendarService()
caldav_path = service.create_calendar(user_b, name="B's Private Cal")
# Extract calendar ID from path
# Path format: calendars/users/email/cal-id/
parts = caldav_path.strip("/").split("/")
cal_id = parts[-1] if len(parts) >= 4 else "default"
dtstart = datetime.now() + timedelta(days=5)
dtend = dtstart + timedelta(hours=1)
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:malicious-event-uid\r\n"
f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n"
"SUMMARY:Malicious Event\r\n"
f"ORGANIZER:mailto:{user_a.email}\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
response = client_a.generic(
"PUT",
f"/caldav/calendars/users/{user_b.email}/{cal_id}/malicious.ics",
data=ical,
content_type="text/calendar",
)
# SabreDAV should block with 403 (ACL)
assert response.status_code in (403, 404, 409), (
f"Expected 403/404 for cross-user PUT, got {response.status_code}"
)
def test_user_cannot_delete_other_users_event(self):
"""User A cannot DELETE an event from user B's calendar."""
org = factories.OrganizationFactory(external_id="event-del-cross")
user_a, client_a = _create_org_admin(org)
user_b = factories.UserFactory(
email="event-del-victim@test-e2e.com", organization=org
)
# Create a calendar and event for user B
service = CalendarService()
caldav_path = service.create_calendar(user_b, name="B's Cal")
parts = caldav_path.strip("/").split("/")
cal_id = parts[-1] if len(parts) >= 4 else "default"
# Create event as user B
dav_b = CalDAVHTTPClient().get_dav_client(user_b)
principal_b = dav_b.principal()
cals_b = principal_b.calendars()
assert len(cals_b) > 0
dtstart = datetime.now() + timedelta(days=6)
dtend = dtstart + timedelta(hours=1)
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:victim-event-uid\r\n"
f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n"
"SUMMARY:Victim's Event\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
cals_b[0].save_event(ical)
# User A tries to DELETE user B's event
response = client_a.generic(
"DELETE",
f"/caldav/calendars/users/{user_b.email}/{cal_id}/victim-event-uid.ics",
)
assert response.status_code in (403, 404), (
f"Expected 403/404 for cross-user DELETE, got {response.status_code}"
)
# Verify event still exists via CalDAV API
http = CalDAVHTTPClient()
ical_data, _, _ = http.find_event_by_uid(user_b, "victim-event-uid")
assert ical_data is not None, (
"Victim's event should still exist after blocked deletion attempt"
)

View File

@@ -27,10 +27,10 @@ from core.entitlements.factory import get_entitlements_backend
def test_local_backend_always_grants_access():
"""The local backend should always return can_access=True."""
"""The local backend should always return can_access=True and can_admin=True."""
backend = LocalEntitlementsBackend()
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_access": True}
assert result == {"can_access": True, "can_admin": True}
def test_local_backend_ignores_parameters():
@@ -42,7 +42,7 @@ def test_local_backend_ignores_parameters():
user_info={"some": "claim"},
force_refresh=True,
)
assert result == {"can_access": True}
assert result == {"can_access": True, "can_admin": True}
# -- Factory --
@@ -99,7 +99,7 @@ def test_deploycenter_backend_grants_access():
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_access": True}},
json={"entitlements": {"can_access": True, "can_admin": True}},
status=200,
)
@@ -109,7 +109,7 @@ def test_deploycenter_backend_grants_access():
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_access": True}
assert result == {"can_access": True, "can_admin": True}
# Verify request was made with correct params and header
assert len(responses.calls) == 1
@@ -135,7 +135,7 @@ def test_deploycenter_backend_denies_access():
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_access": False}
assert result == {"can_access": False, "can_admin": False}
@responses.activate
@@ -157,12 +157,12 @@ def test_deploycenter_backend_uses_cache():
# First call hits the API
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result1 == {"can_access": True}
assert result1["can_access"] is True
assert len(responses.calls) == 1
# Second call should use cache
result2 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result2 == {"can_access": True}
assert result2["can_access"] is True
assert len(responses.calls) == 1 # No additional API call
@@ -230,7 +230,7 @@ def test_deploycenter_backend_fallback_to_stale_cache():
result = backend.get_user_entitlements(
"sub-123", "user@example.com", force_refresh=True
)
assert result == {"can_access": True}
assert result["can_access"] is True
@responses.activate
@@ -355,20 +355,21 @@ def test_user_me_serializer_includes_can_access_false():
assert data["can_access"] is False
def test_user_me_serializer_can_access_fail_open():
"""UserMeSerializer should return can_access=True when entitlements unavailable."""
def test_user_me_serializer_can_access_fail_closed():
"""UserMeSerializer should return can_access=False when entitlements unavailable."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
data = UserMeSerializer(user).data
assert data["can_access"] is True
assert data["can_access"] is False
# -- Signals integration --
@pytest.mark.xdist_group("caldav")
@override_settings(
CALDAV_URL="http://caldav:80",
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
@@ -392,6 +393,7 @@ def test_signal_skips_calendar_when_not_entitled():
get_entitlements_backend.cache_clear()
@pytest.mark.xdist_group("caldav")
@override_settings(
CALDAV_URL="http://caldav:80",
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
@@ -414,6 +416,7 @@ def test_signal_skips_calendar_when_entitlements_unavailable():
get_entitlements_backend.cache_clear()
@pytest.mark.xdist_group("caldav")
@override_settings(
CALDAV_URL="http://caldav:80",
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
@@ -456,7 +459,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
):
response = client.generic(
"MKCALENDAR",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
"/caldav/calendars/users/test@example.com/new-cal/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -474,7 +477,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
):
response = client.generic(
"MKCOL",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
"/caldav/calendars/users/test@example.com/new-cal/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -493,7 +496,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
):
response = client.generic(
"MKCALENDAR",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
"/caldav/calendars/users/test@example.com/new-cal/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -509,7 +512,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
responses.add(
responses.Response(
method="MKCALENDAR",
url=f"{caldav_url}/api/v1.0/caldav/calendars/test@example.com/new-cal/",
url=f"{caldav_url}/caldav/calendars/users/test@example.com/new-cal/",
status=201,
body="",
)
@@ -521,7 +524,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
):
response = client.generic(
"MKCALENDAR",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
"/caldav/calendars/users/test@example.com/new-cal/",
)
assert response.status_code == 201
@@ -538,7 +541,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
url=f"{caldav_url}/caldav/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
@@ -546,7 +549,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
)
# No entitlements mock needed — PROPFIND should not check entitlements
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
response = client.generic("PROPFIND", "/caldav/")
assert response.status_code == HTTP_207_MULTI_STATUS
@@ -561,7 +564,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
responses.add(
responses.Response(
method="REPORT",
url=f"{caldav_url}/api/v1.0/caldav/calendars/other@example.com/cal-id/",
url=f"{caldav_url}/caldav/calendars/users/other@example.com/cal-id/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
@@ -570,7 +573,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
response = client.generic(
"REPORT",
"/api/v1.0/caldav/calendars/other@example.com/cal-id/",
"/caldav/calendars/users/other@example.com/cal-id/",
)
assert response.status_code == HTTP_207_MULTI_STATUS
@@ -587,7 +590,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
responses.add(
responses.Response(
method="PUT",
url=f"{caldav_url}/api/v1.0/caldav/calendars/other@example.com/cal-id/event.ics",
url=f"{caldav_url}/caldav/calendars/users/other@example.com/cal-id/event.ics",
status=HTTP_200_OK,
body="",
)
@@ -595,7 +598,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
response = client.generic(
"PUT",
"/api/v1.0/caldav/calendars/other@example.com/cal-id/event.ics",
"/caldav/calendars/users/other@example.com/cal-id/event.ics",
data=b"BEGIN:VCALENDAR\nEND:VCALENDAR",
content_type="text/calendar",
)

View File

@@ -1,9 +1,6 @@
"""Tests for iCal export endpoint."""
import uuid
"""Tests for iCal export endpoint (using Channel type=ical-feed)."""
from django.conf import settings
from django.urls import reverse
import pytest
import responses
@@ -11,22 +8,28 @@ from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_502_BAD_
from rest_framework.test import APIClient
from core import factories
from core.models import uuid_to_urlsafe
@pytest.mark.django_db
class TestICalExport:
"""Tests for ICalExportView."""
def _ical_url(self, channel):
"""Build the iCal export URL for a channel."""
token = channel.encrypted_settings["token"]
short_id = uuid_to_urlsafe(channel.pk)
return f"/ical/{short_id}/{token}/calendar.ics"
def test_export_with_valid_token_returns_ics(self):
"""Test that a valid token returns iCal data."""
subscription = factories.CalendarSubscriptionTokenFactory()
channel = factories.ICalFeedChannelFactory()
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"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
ics_content = b"""BEGIN:VCALENDAR
VERSION:2.0
@@ -47,8 +50,7 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
assert response.status_code == HTTP_200_OK
assert response["Content-Type"] == "text/calendar; charset=utf-8"
@@ -58,35 +60,38 @@ END:VCALENDAR"""
def test_export_with_invalid_token_returns_404(self):
"""Test that an invalid token returns 404."""
channel = factories.ICalFeedChannelFactory()
short_id = uuid_to_urlsafe(channel.pk)
client = APIClient()
invalid_token = uuid.uuid4()
url = reverse("ical-export", kwargs={"token": invalid_token})
response = client.get(url)
response = client.get(f"/ical/{short_id}/WrongTokenHere123/calendar.ics")
assert response.status_code == HTTP_404_NOT_FOUND
def test_export_with_invalid_channel_id_returns_404(self):
"""Test that a nonexistent channel ID returns 404."""
client = APIClient()
# base62-encoded zero UUID
response = client.get("/ical/0/SomeToken123/calendar.ics")
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)
channel = factories.ICalFeedChannelFactory(is_active=False)
client = APIClient()
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
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
def test_export_updates_last_used_at(self):
"""Test that accessing the export updates last_used_at."""
channel = factories.ICalFeedChannelFactory()
assert channel.last_used_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"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -96,22 +101,20 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
client.get(url)
client.get(self._ical_url(channel))
subscription.refresh_from_db()
assert subscription.last_accessed_at is not None
channel.refresh_from_db()
assert channel.last_used_at is not None
def test_export_does_not_require_authentication(self):
"""Test that the endpoint is accessible without authentication."""
subscription = factories.CalendarSubscriptionTokenFactory()
channel = factories.ICalFeedChannelFactory()
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"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -121,20 +124,18 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
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()
channel = factories.ICalFeedChannelFactory()
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"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -144,24 +145,22 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
client.get(url)
client.get(self._ical_url(channel))
# 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-Forwarded-User"] == channel.user.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()
channel = factories.ICalFeedChannelFactory()
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"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -170,20 +169,18 @@ END:VCALENDAR"""
status=500,
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
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()
channel = factories.ICalFeedChannelFactory()
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"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -193,24 +190,22 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
# 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"
"""Test that the export filename uses the calendar_name from settings."""
channel = factories.ICalFeedChannelFactory(
settings={"role": "reader", "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"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -220,7 +215,15 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
assert "my-test-calendar.ics" in response["Content-Disposition"]
assert "My Test Calendar.ics" in response["Content-Disposition"]
def test_non_ical_feed_channel_returns_404(self):
"""Test that a valid token for a non-ical-feed channel returns 404."""
channel = factories.ChannelFactory() # type="caldav" (default)
token = channel.encrypted_settings["token"]
short_id = uuid_to_urlsafe(channel.pk)
client = APIClient()
response = client.get(f"/ical/{short_id}/{token}/calendar.ics")
assert response.status_code == HTTP_404_NOT_FOUND

View File

@@ -247,7 +247,7 @@ END:VCALENDAR"""
def _make_caldav_path(user):
"""Build a caldav_path string for a user (test helper)."""
return f"/calendars/{user.email}/{uuid.uuid4()}/"
return f"/calendars/users/{user.email}/{uuid.uuid4()}/"
def _make_sabredav_response( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
@@ -482,7 +482,7 @@ class TestICSImportService:
@patch("core.services.caldav_service.requests.request")
def test_import_passes_calendar_path(self, mock_post):
"""The import URL should include the caldav_path."""
"""The import URL should use the internal-api/import/ endpoint."""
mock_post.return_value = _make_sabredav_response(
total_events=1, imported_count=1
)
@@ -495,8 +495,11 @@ class TestICSImportService:
call_args = mock_post.call_args
url = call_args.args[0] if call_args.args else call_args.kwargs.get("url", "")
assert caldav_path in url
assert "?import" in url
assert "internal-api/import/" in url
# URL should contain the user email and calendar URI from the path
parts = caldav_path.strip("/").split("/")
assert parts[2] in url # user email
assert parts[3] in url # calendar URI
@patch("core.services.caldav_service.requests.request")
def test_import_sends_auth_headers(self, mock_post):
@@ -515,7 +518,7 @@ class TestICSImportService:
headers = call_kwargs["headers"]
assert headers["X-Api-Key"] == settings.CALDAV_OUTBOUND_API_KEY
assert headers["X-Forwarded-User"] == user.email
assert headers["X-Calendars-Import"] == settings.CALDAV_OUTBOUND_API_KEY
assert headers["X-Internal-Api-Key"] == settings.CALDAV_INTERNAL_API_KEY
assert headers["Content-Type"] == "text/calendar"
@patch("core.services.caldav_service.requests.request")
@@ -571,7 +574,7 @@ class TestImportEventsAPI:
"""Users cannot import to a calendar they don't own."""
owner = factories.UserFactory(email="owner@example.com")
other_user = factories.UserFactory(email="other@example.com")
caldav_path = f"/calendars/{owner.email}/some-uuid/"
caldav_path = f"/calendars/users/{owner.email}/some-uuid/"
client = APIClient()
client.force_login(other_user)
@@ -597,12 +600,12 @@ class TestImportEventsAPI:
)
response = client.post(self.IMPORT_URL, {"file": ics_file}, format="multipart")
assert response.status_code == 400
assert "caldav_path" in response.json()["error"]
assert "caldav_path" in response.json()["detail"]
def test_import_events_missing_file(self):
"""Request without a file should return 400."""
user = factories.UserFactory(email="nofile@example.com")
caldav_path = f"/calendars/{user.email}/some-uuid/"
caldav_path = f"/calendars/users/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
@@ -613,12 +616,12 @@ class TestImportEventsAPI:
format="multipart",
)
assert response.status_code == 400
assert "No file provided" in response.json()["error"]
assert "No file provided" in response.json()["detail"]
def test_import_events_file_too_large(self):
"""Files exceeding MAX_FILE_SIZE should be rejected."""
user = factories.UserFactory(email="largefile@example.com")
caldav_path = f"/calendars/{user.email}/some-uuid/"
caldav_path = f"/calendars/users/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
@@ -634,11 +637,11 @@ class TestImportEventsAPI:
format="multipart",
)
assert response.status_code == 400
assert "too large" in response.json()["error"]
assert "too large" in response.json()["detail"]
@patch.object(ICSImportService, "import_events")
def test_import_events_success(self, mock_import):
"""Successful import should return result data."""
def test_import_events_returns_task_id(self, mock_import):
"""Successful import should return a task_id for polling."""
mock_import.return_value = ImportResult(
total_events=3,
imported_count=3,
@@ -648,7 +651,7 @@ class TestImportEventsAPI:
)
user = factories.UserFactory(email="success@example.com")
caldav_path = f"/calendars/{user.email}/some-uuid/"
caldav_path = f"/calendars/users/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
@@ -662,51 +665,20 @@ class TestImportEventsAPI:
format="multipart",
)
assert response.status_code == 200
assert response.status_code == 202
data = response.json()
assert data["total_events"] == 3
assert data["imported_count"] == 3
assert data["skipped_count"] == 0
assert "errors" not in data
assert "task_id" in data
@patch.object(ICSImportService, "import_events")
def test_import_events_partial_success(self, mock_import):
"""Partial success should include errors in response."""
mock_import.return_value = ImportResult(
total_events=3,
imported_count=2,
duplicate_count=0,
skipped_count=1,
errors=["Planning session"],
)
user = factories.UserFactory(email="partial@example.com")
caldav_path = f"/calendars/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
ics_file = SimpleUploadedFile(
"events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar"
)
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 200
data = response.json()
assert data["total_events"] == 3
assert data["imported_count"] == 2
assert data["skipped_count"] == 1
assert len(data["errors"]) == 1
# With EagerBroker, the task runs synchronously — poll for result
task_response = client.get(f"/api/v1.0/tasks/{data['task_id']}/")
assert task_response.status_code == 200
task_data = task_response.json()
assert task_data["status"] == "SUCCESS"
assert task_data["result"]["total_events"] == 3
assert task_data["result"]["imported_count"] == 3
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
@pytest.mark.xdist_group("caldav")
class TestImportEventsE2E:
"""End-to-end tests that import ICS events through the real SabreDAV server."""
@@ -827,11 +799,16 @@ class TestImportEventsE2E:
format="multipart",
)
assert response.status_code == 200
data = response.json()
assert data["total_events"] == 3
assert data["imported_count"] == 3
assert data["skipped_count"] == 0
assert response.status_code == 202
task_id = response.json()["task_id"]
# With EagerBroker, poll for the synchronous result
task_response = client.get(f"/api/v1.0/tasks/{task_id}/")
assert task_response.status_code == 200
data = task_response.json()
assert data["status"] == "SUCCESS"
assert data["result"]["total_events"] == 3
assert data["result"]["imported_count"] == 3
# Verify events actually exist in SabreDAV
caldav = CalDAVClient()
@@ -955,7 +932,7 @@ class TestImportEventsE2E:
"""Fetch the raw ICS data of a single event from SabreDAV by UID."""
caldav_client = CalDAVClient()
client = caldav_client._get_client(user) # pylint: disable=protected-access
cal_url = f"{caldav_client.base_url}{caldav_path}"
cal_url = caldav_client._calendar_url(caldav_path) # pylint: disable=protected-access
cal = client.calendar(url=cal_url)
event = cal.event_by_uid(uid)
return event.data
@@ -1022,10 +999,7 @@ class TestImportEventsE2E:
assert "..." in raw
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
@pytest.mark.xdist_group("caldav")
class TestCalendarSanitizerE2E:
"""E2E tests for CalendarSanitizerPlugin on normal CalDAV PUT operations."""
@@ -1038,7 +1012,7 @@ class TestCalendarSanitizerE2E:
"""Fetch the raw ICS data of a single event from SabreDAV by UID."""
caldav_client = CalDAVClient()
client = caldav_client._get_client(user) # pylint: disable=protected-access
cal_url = f"{caldav_client.base_url}{caldav_path}"
cal_url = caldav_client._calendar_url(caldav_path) # pylint: disable=protected-access
cal = client.calendar(url=cal_url)
event = cal.event_by_uid(uid)
return event.data

View File

@@ -0,0 +1,196 @@
"""Tests for the organizations feature."""
from django.test import override_settings
import pytest
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories
from core.authentication.backends import resolve_organization
from core.entitlements.factory import get_entitlements_backend
from core.models import Organization
# -- Organization model --
@pytest.mark.django_db
def test_organization_str_with_name():
"""Organization.__str__ returns the name when set."""
org = factories.OrganizationFactory(name="Acme Corp", external_id="acme")
assert str(org) == "Acme Corp"
@pytest.mark.django_db
def test_organization_str_without_name():
"""Organization.__str__ falls back to external_id when name is empty."""
org = factories.OrganizationFactory(name="", external_id="acme")
assert str(org) == "acme"
@pytest.mark.django_db
def test_organization_unique_external_id():
"""external_id must be unique."""
factories.OrganizationFactory(external_id="org-1")
with pytest.raises(Exception): # noqa: B017
factories.OrganizationFactory(external_id="org-1")
# -- Org resolution on login --
@pytest.mark.django_db
def test_resolve_org_from_email_domain():
"""Without OIDC_USERINFO_ORGANIZATION_CLAIM, org is derived from email domain."""
user = factories.UserFactory(email="alice@ministry.gouv.fr")
resolve_organization(user, claims={}, entitlements={})
user.refresh_from_db()
assert user.organization is not None
assert user.organization.external_id == "ministry.gouv.fr"
assert user.organization.name == "ministry.gouv.fr"
@pytest.mark.django_db
@override_settings(OIDC_USERINFO_ORGANIZATION_CLAIM="siret")
def test_resolve_org_from_oidc_claim():
"""With OIDC_USERINFO_ORGANIZATION_CLAIM, org is derived from the claim."""
user = factories.UserFactory(email="alice@ministry.gouv.fr")
resolve_organization(
user,
claims={"siret": "13002526500013"},
entitlements={"organization_name": "Ministere X"},
)
user.refresh_from_db()
assert user.organization is not None
assert user.organization.external_id == "13002526500013"
assert user.organization.name == "Ministere X"
@pytest.mark.django_db
def test_resolve_org_updates_name():
"""Org name is updated from entitlements on subsequent logins."""
org = factories.OrganizationFactory(external_id="example.com", name="Old Name")
user = factories.UserFactory(email="alice@example.com", organization=org)
resolve_organization(
user,
claims={},
entitlements={"organization_name": "New Name"},
)
org.refresh_from_db()
assert org.name == "New Name"
@pytest.mark.django_db
def test_resolve_org_reuses_existing():
"""Existing org is reused, not duplicated."""
org = factories.OrganizationFactory(external_id="example.com")
user = factories.UserFactory(email="bob@example.com")
resolve_organization(user, claims={}, entitlements={})
user.refresh_from_db()
assert user.organization_id == org.id
assert Organization.objects.filter(external_id="example.com").count() == 1
# -- User API: /users/me/ --
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_users_me_includes_organization():
"""GET /users/me/ includes the user's organization."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(name="Test Org", external_id="test")
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
data = response.json()
assert data["organization"]["id"] == str(org.id)
assert data["organization"]["name"] == "Test Org"
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_users_me_includes_can_admin():
"""GET /users/me/ includes can_admin from entitlements."""
get_entitlements_backend.cache_clear()
user = factories.UserFactory()
client = APIClient()
client.force_authenticate(user=user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
data = response.json()
assert "can_admin" in data
# Local backend returns True for can_admin
assert data["can_admin"] is True
get_entitlements_backend.cache_clear()
# -- User list scoping --
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_user_list_scoped_by_org():
"""User list only returns users from the same org."""
get_entitlements_backend.cache_clear()
org_a = factories.OrganizationFactory(external_id="org-a")
org_b = factories.OrganizationFactory(external_id="org-b")
alice = factories.UserFactory(email="alice@example.com", organization=org_a)
factories.UserFactory(email="bob@other.com", organization=org_b)
client = APIClient()
client.force_authenticate(user=alice)
response = client.get("/api/v1.0/users/?q=bob@other.com")
assert response.status_code == HTTP_200_OK
# Bob should NOT be visible (different org)
assert len(response.json()["results"]) == 0
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_user_list_same_org_visible():
"""User list returns users from the same org."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="org-a")
alice = factories.UserFactory(email="alice@example.com", organization=org)
factories.UserFactory(email="carol@example.com", organization=org)
client = APIClient()
client.force_authenticate(user=alice)
response = client.get("/api/v1.0/users/?q=carol@example.com")
assert response.status_code == HTTP_200_OK
data = response.json()["results"]
assert len(data) == 1
assert data[0]["email"] == "carol@example.com"
get_entitlements_backend.cache_clear()

View File

@@ -0,0 +1,382 @@
"""Tests for permissions, access control, and security edge cases."""
from unittest import mock
from django.conf import settings
from django.test import override_settings
import pytest
import responses
from rest_framework.status import (
HTTP_200_OK,
HTTP_201_CREATED,
HTTP_207_MULTI_STATUS,
HTTP_403_FORBIDDEN,
)
from rest_framework.test import APIClient
from core import factories
from core.entitlements import EntitlementsUnavailableError
from core.entitlements.factory import get_entitlements_backend
from core.services.caldav_service import (
CALDAV_PATH_PATTERN,
normalize_caldav_path,
verify_caldav_access,
)
# ---------------------------------------------------------------------------
# verify_caldav_access — resource paths
# ---------------------------------------------------------------------------
class TestVerifyCaldavAccessResourcePaths:
"""Tests for verify_caldav_access() with resource calendar paths."""
def _make_user(self, email="alice@example.com", org_id=None):
"""Create a mock user object."""
user = mock.Mock()
user.email = email
user.organization_id = org_id
return user
def test_resource_path_allowed_when_user_has_org(self):
"""Users with an organization can access resource calendars.
Fine-grained org-to-resource authorization is enforced by SabreDAV
via the X-CalDAV-Organization header, not here.
"""
user = self._make_user(org_id="some-org-uuid")
path = "/calendars/resources/abc-123/default/"
assert verify_caldav_access(user, path) is True
def test_user_path_allowed_for_own_email(self):
"""Users can access their own calendar paths."""
user = self._make_user(email="alice@example.com")
path = "/calendars/users/alice@example.com/cal-uuid/"
assert verify_caldav_access(user, path) is True
def test_user_path_denied_for_other_email(self):
"""Users cannot access another user's calendar path."""
user = self._make_user(email="alice@example.com")
path = "/calendars/users/bob@example.com/cal-uuid/"
assert verify_caldav_access(user, path) is False
def test_invalid_path_denied(self):
"""Paths that don't match the expected pattern are rejected."""
user = self._make_user(org_id="some-org")
assert verify_caldav_access(user, "/etc/passwd") is False
assert verify_caldav_access(user, "/calendars/") is False
assert verify_caldav_access(user, "/calendars/unknown/x/y/") is False
def test_path_traversal_denied(self):
"""Path traversal attempts are rejected."""
user = self._make_user(email="alice@example.com")
path = "/calendars/users/alice@example.com/../../../etc/passwd/"
assert verify_caldav_access(user, path) is False
def test_resource_path_pattern_matches(self):
"""The CALDAV_PATH_PATTERN regex matches resource paths."""
assert CALDAV_PATH_PATTERN.match("/calendars/resources/abc-123/default/")
assert CALDAV_PATH_PATTERN.match(
"/calendars/resources/a1b2c3d4-e5f6-7890-abcd-ef1234567890/default/"
)
def test_resource_path_denied_when_user_has_no_org(self):
"""Users without an organization cannot access resource calendars."""
user = self._make_user(org_id=None)
path = "/calendars/resources/abc-123/default/"
assert verify_caldav_access(user, path) is False
def test_user_path_case_insensitive(self):
"""Email comparison in user paths should be case-insensitive."""
user = self._make_user(email="Alice@Example.COM")
path = "/calendars/users/alice@example.com/cal-uuid/"
assert verify_caldav_access(user, path) is True
# ---------------------------------------------------------------------------
# IsOrgAdmin permission
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestIsOrgAdminPermission:
"""Tests for the IsOrgAdmin permission class on the ResourceViewSet."""
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_admin_user_can_create_resource(self):
"""Users with can_admin=True can create resources."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="test-org")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 201
mock_response.text = '{"principal_uri": "principals/resources/x"}'
mock_request.return_value = mock_response
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_201_CREATED
get_entitlements_backend.cache_clear()
def test_non_admin_user_denied_resource_creation(self):
"""Users with can_admin=False are denied by IsOrgAdmin."""
org = factories.OrganizationFactory(external_id="test-org")
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=user)
# Patch where IsOrgAdmin actually looks up get_user_entitlements
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_access": True, "can_admin": False},
):
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_entitlements_unavailable_denies_access(self):
"""IsOrgAdmin is fail-closed: denies when entitlements service is down."""
org = factories.OrganizationFactory(external_id="test-org")
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
side_effect=EntitlementsUnavailableError("Service unavailable"),
):
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_unauthenticated_user_denied(self):
"""Unauthenticated users are denied by IsOrgAdmin (inherits IsAuthenticated)."""
client = APIClient()
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
# 401 or 403 depending on DRF config
assert response.status_code in (401, 403)
def test_non_admin_user_denied_resource_deletion(self):
"""Users with can_admin=False cannot delete resources either."""
org = factories.OrganizationFactory(external_id="test-org")
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_access": True, "can_admin": False},
):
response = client.delete("/api/v1.0/resources/some-uuid/")
assert response.status_code == HTTP_403_FORBIDDEN
# ---------------------------------------------------------------------------
# CalDAV proxy — X-CalDAV-Organization header forwarding
# ---------------------------------------------------------------------------
@pytest.mark.django_db
@pytest.mark.xdist_group("caldav")
class TestCalDAVProxyOrgHeader:
"""Tests that the CalDAV proxy forwards the org header correctly."""
@responses.activate
def test_proxy_sends_org_header(self):
"""CalDAV proxy sends X-CalDAV-Organization for users with an org."""
org = factories.OrganizationFactory(external_id="org-alpha")
user = factories.UserFactory(email="alice@example.com", organization=org)
client = APIClient()
client.force_login(user)
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/caldav/principals/resources/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
)
)
client.generic("PROPFIND", "/caldav/principals/resources/")
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.headers["X-CalDAV-Organization"] == str(org.id)
@responses.activate
def test_proxy_cannot_spoof_org_header(self):
"""Client-sent X-CalDAV-Organization is overwritten by the proxy."""
org = factories.OrganizationFactory(external_id="real-org")
user = factories.UserFactory(email="alice@example.com", organization=org)
client = APIClient()
client.force_login(user)
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/caldav/principals/resources/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
)
)
# Attempt to spoof the org header
client.generic(
"PROPFIND",
"/caldav/principals/resources/",
HTTP_X_CALDAV_ORGANIZATION="spoofed-org-id",
)
assert len(responses.calls) == 1
request = responses.calls[0].request
# The proxy should use the user's real org ID, not the spoofed one
assert request.headers["X-CalDAV-Organization"] == str(org.id)
assert request.headers["X-CalDAV-Organization"] != "spoofed-org-id"
# ---------------------------------------------------------------------------
# IsEntitledToAccess permission — fail-closed
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestIsEntitledToAccessFailClosed:
"""Tests that IsEntitledToAccess permission is fail-closed."""
def test_import_denied_when_entitlements_unavailable(self):
"""ICS import should be denied when entitlements service is down."""
user = factories.UserFactory(email="alice@example.com")
client = APIClient()
client.force_authenticate(user=user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
side_effect=EntitlementsUnavailableError("Service unavailable"),
):
response = client.post(
"/api/v1.0/calendars/import-events/",
format="multipart",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_import_denied_when_can_access_false(self):
"""ICS import should be denied when can_access=False."""
user = factories.UserFactory(email="alice@example.com")
client = APIClient()
client.force_authenticate(user=user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_access": False, "can_admin": False},
):
response = client.post(
"/api/v1.0/calendars/import-events/",
format="multipart",
)
assert response.status_code == HTTP_403_FORBIDDEN
# ---------------------------------------------------------------------------
# normalize_caldav_path
# ---------------------------------------------------------------------------
class TestNormalizeCaldavPath:
"""Tests for normalize_caldav_path helper."""
def test_strips_old_api_prefix(self):
"""Should strip any prefix before /calendars/."""
result = normalize_caldav_path(
"/api/v1.0/caldav/calendars/users/user@ex.com/uuid/"
)
assert result == "/calendars/users/user@ex.com/uuid/"
def test_strips_new_prefix(self):
"""Should strip /caldav prefix."""
result = normalize_caldav_path("/caldav/calendars/users/user@ex.com/uuid/")
assert result == "/calendars/users/user@ex.com/uuid/"
def test_adds_leading_slash(self):
"""Should add a leading slash if missing."""
result = normalize_caldav_path("calendars/users/user@ex.com/uuid/")
assert result == "/calendars/users/user@ex.com/uuid/"
def test_adds_trailing_slash(self):
"""Should add a trailing slash if missing."""
result = normalize_caldav_path("/calendars/users/user@ex.com/uuid")
assert result == "/calendars/users/user@ex.com/uuid/"
def test_resource_path_unchanged(self):
"""Resource paths should pass through unchanged."""
result = normalize_caldav_path("/calendars/resources/abc-123/default/")
assert result == "/calendars/resources/abc-123/default/"
# ---------------------------------------------------------------------------
# UserViewSet — user without org gets empty results
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestUserViewSetCrossOrg:
"""Tests that users from a different org see no users in the list."""
def test_user_from_other_org_gets_empty_list(self):
"""A user from a different org should get an empty user list."""
org_a = factories.OrganizationFactory(external_id="org-a")
org_b = factories.OrganizationFactory(external_id="org-b")
factories.UserFactory(email="orguser@example.com", organization=org_a)
other_org_user = factories.UserFactory(
email="other@example.com", organization=org_b
)
client = APIClient()
client.force_login(other_org_user)
response = client.get("/api/v1.0/users/", {"q": "orguser@example.com"})
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 0

View File

@@ -0,0 +1,309 @@
"""Tests for the resource provisioning API."""
import json
from unittest import mock
from django.test import override_settings
import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_204_NO_CONTENT,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
)
from rest_framework.test import APIClient
from core import factories
from core.entitlements.factory import get_entitlements_backend
from core.services.resource_service import ResourceProvisioningError, ResourceService
# -- Permission checks --
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_create_resource_requires_auth():
"""POST /resources/ requires authentication."""
get_entitlements_backend.cache_clear()
client = APIClient()
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_create_resource_success():
"""POST /resources/ creates a resource principal via the internal API."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="test-org")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
# Mock the internal API response for resource creation
mock_response = mock.Mock()
mock_response.status_code = 201
mock_response.json.return_value = {
"principal_uri": "principals/resources/some-uuid",
"email": "c_test@resource.calendar.localhost",
}
mock_response.text = '{"principal_uri": "principals/resources/some-uuid"}'
mock_request.return_value = mock_response
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 101", "resource_type": "ROOM"},
format="json",
)
assert response.status_code == HTTP_201_CREATED
data = response.json()
assert data["name"] == "Room 101"
assert data["resource_type"] == "ROOM"
assert "email" in data
assert "id" in data
# Principal URI uses the opaque UUID, not the slug
assert data["principal_uri"].startswith("principals/resources/")
assert data["principal_uri"] == f"principals/resources/{data['id']}"
# Verify the HTTP call went to the internal API
mock_request.assert_called_once()
call_kwargs = mock_request.call_args
url = call_kwargs.kwargs.get("url", "") or (
call_kwargs.args[1] if len(call_kwargs.args) > 1 else ""
)
headers = call_kwargs.kwargs.get("headers", {})
assert "internal-api/resources" in url
assert "X-Internal-Api-Key" in headers
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_delete_resource():
"""DELETE /resources/{resource_id}/ deletes the resource via internal API."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="test-org")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
resource_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"deleted": True}
mock_response.text = '{"deleted": true}'
mock_request.return_value = mock_response
response = client.delete(f"/api/v1.0/resources/{resource_id}/")
assert response.status_code == HTTP_204_NO_CONTENT
# Verify the HTTP call went to the internal API
mock_request.assert_called_once()
call_kwargs = mock_request.call_args
url = call_kwargs.kwargs.get("url", "") or (
call_kwargs.args[1] if len(call_kwargs.args) > 1 else ""
)
headers = call_kwargs.kwargs.get("headers", {})
assert f"internal-api/resources/{resource_id}" in url
assert "X-Internal-Api-Key" in headers
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_delete_resource_cross_org_blocked():
"""Cannot delete a resource from another organization."""
get_entitlements_backend.cache_clear()
org_a = factories.OrganizationFactory(external_id="org-a")
admin = factories.UserFactory(organization=org_a)
client = APIClient()
client.force_authenticate(user=admin)
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 403
mock_response.json.return_value = {
"error": "Cannot delete a resource from a different organization."
}
mock_response.text = (
'{"error": "Cannot delete a resource from a different organization."}'
)
mock_request.return_value = mock_response
response = client.delete(
"/api/v1.0/resources/b1b2c3d4-e5f6-7890-abcd-ef1234567890/"
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert "different organization" in response.json()["detail"]
get_entitlements_backend.cache_clear()
# -- Lateral access tests --
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_create_resource_sends_user_org_id():
"""Create resource always sends the authenticated user's org_id, not a caller-supplied one."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="org-alpha")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 201
mock_response.text = '{"principal_uri": "principals/resources/x"}'
mock_request.return_value = mock_response
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_201_CREATED
# Verify the JSON body sent to internal API contains the user's org
call_kwargs = mock_request.call_args
body = json.loads(call_kwargs.kwargs.get("data", b"{}"))
assert body["org_id"] == str(org.id)
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_delete_resource_sends_user_org_id():
"""Delete resource sends the authenticated user's org_id in the header."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="org-beta")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
resource_id = "a1b2c3d4-0000-0000-0000-000000000001"
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.text = '{"deleted": true}'
mock_request.return_value = mock_response
response = client.delete(f"/api/v1.0/resources/{resource_id}/")
assert response.status_code == HTTP_204_NO_CONTENT
call_kwargs = mock_request.call_args
headers = call_kwargs.kwargs.get("headers", {})
assert headers.get("X-CalDAV-Organization") == str(org.id)
get_entitlements_backend.cache_clear()
# -- Path traversal tests --
class TestResourceIdValidation:
"""Tests that resource_id is validated as a UUID to prevent path traversal."""
@pytest.fixture(autouse=True)
def _internal_api_key(self, settings):
settings.CALDAV_INTERNAL_API_KEY = "test-internal-key"
def test_delete_rejects_path_traversal(self):
"""A malicious resource_id like ../../users/victim is rejected."""
user = mock.Mock()
user.organization_id = "some-org"
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="Invalid resource ID"):
service.delete_resource(user, "../../users/victim@example.com")
def test_delete_rejects_non_uuid_string(self):
"""A non-UUID resource_id is rejected."""
user = mock.Mock()
user.organization_id = "some-org"
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="Invalid resource ID"):
service.delete_resource(user, "not-a-uuid")
def test_delete_accepts_valid_uuid(self):
"""A valid UUID resource_id passes validation."""
user = mock.Mock()
user.email = "admin@example.com"
user.organization_id = "some-org"
service = ResourceService()
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
# Should not raise
service.delete_resource(user, "a1b2c3d4-e5f6-7890-abcd-ef1234567890")
def test_create_resource_rejects_missing_api_key(self, settings):
"""create_resource raises when CALDAV_INTERNAL_API_KEY is empty."""
settings.CALDAV_INTERNAL_API_KEY = ""
user = mock.Mock()
user.organization_id = "some-org"
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="CALDAV_INTERNAL_API_KEY"):
service.create_resource(user, "Room 1", "ROOM")
def test_delete_resource_rejects_missing_api_key(self, settings):
"""delete_resource raises when CALDAV_INTERNAL_API_KEY is empty."""
settings.CALDAV_INTERNAL_API_KEY = ""
user = mock.Mock()
user.organization_id = "some-org"
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="CALDAV_INTERNAL_API_KEY"):
service.delete_resource(user, "a1b2c3d4-e5f6-7890-abcd-ef1234567890")

View File

@@ -8,7 +8,7 @@ from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
from django.core import mail
from django.core.signing import BadSignature, Signer
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.template.loader import render_to_string
from django.test import RequestFactory, TestCase, override_settings
from django.utils import timezone
@@ -16,7 +16,8 @@ from django.utils import timezone
import icalendar
import pytest
from core.api.viewsets_rsvp import RSVPView
from core import factories
from core.api.viewsets_rsvp import RSVPConfirmView, RSVPProcessView
from core.services.caldav_service import CalDAVHTTPClient
from core.services.calendar_invitation_service import (
CalendarInvitationService,
@@ -56,10 +57,10 @@ SAMPLE_CALDAV_RESPONSE = """\
<?xml version="1.0" encoding="UTF-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/api/v1.0/caldav/calendars/alice%40example.com/cal-uuid/test-uid-123.ics</d:href>
<d:href>/caldav/calendars/users/alice%40example.com/cal-uuid/test-uid-123.ics</d:href>
<d:propstat>
<d:prop>
<d:gethref>/api/v1.0/caldav/calendars/alice%40example.com/cal-uuid/test-uid-123.ics</d:gethref>
<d:gethref>/caldav/calendars/users/alice%40example.com/cal-uuid/test-uid-123.ics</d:gethref>
<cal:calendar-data>{ics_data}</cal:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
@@ -71,8 +72,8 @@ SAMPLE_CALDAV_RESPONSE = """\
def _make_token(
uid="test-uid-123", email="bob@example.com", organizer="alice@example.com"
):
"""Create a valid signed RSVP token."""
signer = Signer(salt="rsvp")
"""Create a valid signed RSVP token using TimestampSigner."""
signer = TimestampSigner(salt="rsvp")
return signer.sign_object(
{
"uid": uid,
@@ -88,7 +89,7 @@ class TestRSVPTokenGeneration:
def test_token_roundtrip(self):
"""A generated token can be unsigned to recover the payload."""
token = _make_token()
signer = Signer(salt="rsvp")
signer = TimestampSigner(salt="rsvp")
payload = signer.unsign_object(token)
assert payload["uid"] == "test-uid-123"
assert payload["email"] == "bob@example.com"
@@ -97,7 +98,7 @@ class TestRSVPTokenGeneration:
def test_tampered_token_fails(self):
"""A tampered token raises BadSignature."""
token = _make_token() + "tampered"
signer = Signer(salt="rsvp")
signer = TimestampSigner(salt="rsvp")
with pytest.raises(BadSignature):
signer.unsign_object(token)
@@ -194,7 +195,7 @@ class TestRSVPEmailTemplateRendering:
class TestUpdateAttendeePartstat:
"""Tests for the _update_attendee_partstat function."""
"""Tests for the update_attendee_partstat function."""
def test_update_existing_partstat(self):
result = CalDAVHTTPClient.update_attendee_partstat(
@@ -232,18 +233,50 @@ class TestUpdateAttendeePartstat:
assert "CN=Bob" in result
assert "mailto:bob@example.com" in result
def test_substring_email_does_not_match(self):
"""Emails that are substrings of the target should NOT match."""
# Create ICS with a similar-but-different email
ics = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:test-substring\r\n"
"DTSTART:20260401T100000Z\r\n"
"DTEND:20260401T110000Z\r\n"
"SUMMARY:Test\r\n"
"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:notbob@example.com\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR"
)
# "bob@example.com" should NOT match "notbob@example.com"
result = CalDAVHTTPClient.update_attendee_partstat(
ics, "bob@example.com", "ACCEPTED"
)
assert result is None
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
APP_URL="http://localhost:8921",
APP_URL="http://localhost:8931",
API_VERSION="v1.0",
)
class TestRSVPView(TestCase):
"""Tests for the RSVPView."""
class TestRSVPConfirmView(TestCase):
"""Tests for the RSVPConfirmView (GET handler)."""
def setUp(self):
self.factory = RequestFactory()
self.view = RSVPView.as_view()
self.view = RSVPConfirmView.as_view()
def test_valid_token_renders_confirm_page(self):
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
assert response.status_code == 200
content = response.content.decode()
assert 'method="post"' in content
assert token in content
def test_invalid_action_returns_400(self):
token = _make_token()
@@ -251,12 +284,6 @@ class TestRSVPView(TestCase):
response = self.view(request)
assert response.status_code == 400
def test_missing_action_returns_400(self):
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token})
response = self.view(request)
assert response.status_code == 400
def test_invalid_token_returns_400(self):
request = self.factory.get(
"/rsvp/", {"token": "bad-token", "action": "accepted"}
@@ -264,8 +291,46 @@ class TestRSVPView(TestCase):
response = self.view(request)
assert response.status_code == 400
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
APP_URL="http://localhost:8931",
API_VERSION="v1.0",
)
class TestRSVPProcessView(TestCase):
"""Tests for the RSVPProcessView (POST handler)."""
def setUp(self):
self.factory = RequestFactory()
self.view = RSVPProcessView.as_view()
# RSVP view looks up organizer from DB
self.organizer = factories.UserFactory(email="alice@example.com")
def _post(self, token, action):
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": token, "action": action},
)
return self.view(request)
def test_invalid_action_returns_400(self):
token = _make_token()
response = self._post(token, "invalid")
assert response.status_code == 400
def test_missing_action_returns_400(self):
token = _make_token()
request = self.factory.post("/api/v1.0/rsvp/", {"token": token})
response = self.view(request)
assert response.status_code == 400
def test_invalid_token_returns_400(self):
response = self._post("bad-token", "accepted")
assert response.status_code == 400
def test_missing_token_returns_400(self):
request = self.factory.get("/rsvp/", {"action": "accepted"})
request = self.factory.post("/api/v1.0/rsvp/", {"action": "accepted"})
response = self.view(request)
assert response.status_code == 400
@@ -275,33 +340,37 @@ class TestRSVPView(TestCase):
"""Full accept flow: find event, update partstat, put back."""
mock_find.return_value = (
SAMPLE_ICS,
"/api/v1.0/caldav/calendars/alice%40example.com/cal/event.ics",
"/caldav/calendars/users/alice%40example.com/cal/event.ics",
'"etag-123"',
)
mock_put.return_value = True
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 200
assert "accepted the invitation" in response.content.decode()
# Verify CalDAV calls
mock_find.assert_called_once_with("alice@example.com", "test-uid-123")
mock_find.assert_called_once()
find_args = mock_find.call_args[0]
assert find_args[0].email == "alice@example.com"
assert find_args[1] == "test-uid-123"
mock_put.assert_called_once()
# Check the updated data contains ACCEPTED
put_args = mock_put.call_args
assert "PARTSTAT=ACCEPTED" in put_args[0][2]
# Check ETag is passed
assert put_args[1]["etag"] == '"etag-123"'
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_decline_flow(self, mock_find, mock_put):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = True
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "declined"})
response = self.view(request)
response = self._post(token, "declined")
assert response.status_code == 200
assert "declined the invitation" in response.content.decode()
@@ -311,12 +380,11 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_tentative_flow(self, mock_find, mock_put):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = True
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "tentative"})
response = self.view(request)
response = self._post(token, "tentative")
assert response.status_code == 200
content = response.content.decode()
@@ -326,11 +394,10 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_event_not_found_returns_400(self, mock_find):
mock_find.return_value = (None, None)
mock_find.return_value = (None, None, None)
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 400
assert "not found" in response.content.decode().lower()
@@ -338,12 +405,11 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_put_failure_returns_400(self, mock_find, mock_put):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = False
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 400
assert "error occurred" in response.content.decode().lower()
@@ -351,12 +417,11 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_attendee_not_in_event_returns_400(self, mock_find):
"""If the attendee email is not in the event, return error."""
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
# Token with an email that's not in the event
token = _make_token(email="stranger@example.com")
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 400
assert "not listed" in response.content.decode().lower()
@@ -364,11 +429,10 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_past_event_returns_400(self, mock_find):
"""Cannot RSVP to an event that has already ended."""
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics", None)
token = _make_token(uid="test-uid-past")
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 400
assert "already passed" in response.content.decode().lower()
@@ -430,28 +494,29 @@ class TestItipSetting:
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
CALDAV_INBOUND_API_KEY="test-inbound-key",
APP_URL="http://localhost:8921",
APP_URL="http://localhost:8931",
API_VERSION="v1.0",
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
)
class TestRSVPEndToEndFlow(TestCase):
"""
Integration test: scheduling callback sends email extract RSVP links
follow link verify event is updated.
This tests the full flow from CalDAV scheduling callback to RSVP response,
using Django's in-memory email backend to intercept sent emails.
Integration test: scheduling callback sends email -> extract RSVP links
-> follow link (GET confirm -> POST process) -> verify event is updated.
"""
def setUp(self):
self.factory = RequestFactory()
self.rsvp_view = RSVPView.as_view()
self.confirm_view = RSVPConfirmView.as_view()
self.process_view = RSVPProcessView.as_view()
self.organizer = factories.UserFactory(email="alice@example.com")
def test_email_to_rsvp_accept_flow(self):
"""
1. CalDAV scheduling callback sends an invitation email
2. Extract RSVP accept link from the email HTML
3. Follow the RSVP link
4. Verify the event PARTSTAT is updated to ACCEPTED
3. GET the RSVP link (renders auto-submit form)
4. POST to process the RSVP
5. Verify the event PARTSTAT is updated to ACCEPTED
"""
# Step 1: Send invitation via the CalendarInvitationService
service = CalendarInvitationService()
@@ -488,22 +553,32 @@ class TestRSVPEndToEndFlow(TestCase):
assert "token" in params
assert params["action"] == ["accepted"]
# Step 4: Follow the RSVP link (mock CalDAV interactions)
# Step 3b: GET the confirm page
request = self.factory.get(
"/rsvp/",
{"token": params["token"][0], "action": "accepted"},
)
response = self.confirm_view(request)
assert response.status_code == 200
assert 'method="post"' in response.content.decode()
# Step 4: POST to process the RSVP (mock CalDAV interactions)
with (
patch.object(CalDAVHTTPClient, "find_event_by_uid") as mock_find,
patch.object(CalDAVHTTPClient, "put_event") as mock_put,
):
mock_find.return_value = (
SAMPLE_ICS,
"/api/v1.0/caldav/calendars/alice%40example.com/cal/event.ics",
"/caldav/calendars/users/alice%40example.com/cal/event.ics",
'"etag-abc"',
)
mock_put.return_value = True
request = self.factory.get(
"/rsvp/",
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": params["token"][0], "action": "accepted"},
)
response = self.rsvp_view(request)
response = self.process_view(request)
# Step 5: Verify success
assert response.status_code == 200
@@ -511,7 +586,10 @@ class TestRSVPEndToEndFlow(TestCase):
assert "accepted the invitation" in content
# Verify CalDAV was called with the right data
mock_find.assert_called_once_with("alice@example.com", "test-uid-123")
mock_find.assert_called_once()
find_args = mock_find.call_args[0]
assert find_args[0].email == "alice@example.com"
assert find_args[1] == "test-uid-123"
mock_put.assert_called_once()
put_data = mock_put.call_args[0][2]
assert "PARTSTAT=ACCEPTED" in put_data
@@ -542,14 +620,14 @@ class TestRSVPEndToEndFlow(TestCase):
patch.object(CalDAVHTTPClient, "find_event_by_uid") as mock_find,
patch.object(CalDAVHTTPClient, "put_event") as mock_put,
):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = True
request = self.factory.get(
"/rsvp/",
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": params["token"][0], "action": "declined"},
)
response = self.rsvp_view(request)
response = self.process_view(request)
assert response.status_code == 200
assert "declined the invitation" in response.content.decode()
@@ -612,13 +690,134 @@ class TestRSVPEndToEndFlow(TestCase):
params = parse_qs(parsed.query)
# The event is in the past
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics", None)
request = self.factory.get(
"/rsvp/",
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": params["token"][0], "action": "accepted"},
)
response = self.rsvp_view(request)
response = self.process_view(request)
assert response.status_code == 400
assert "already passed" in response.content.decode().lower()
def _make_recurring_ics(
uid="recurring-uid-1", summary="Weekly Standup", days_from_now=30
):
"""Build a recurring ICS string with an RRULE."""
dt = timezone.now() + timedelta(days=days_from_now)
dtstart = dt.strftime("%Y%m%dT%H%M%SZ")
dtend = (dt + timedelta(hours=1)).strftime("%Y%m%dT%H%M%SZ")
return (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
f"UID:{uid}\r\n"
f"DTSTART:{dtstart}\r\n"
f"DTEND:{dtend}\r\n"
f"SUMMARY:{summary}\r\n"
"RRULE:FREQ=WEEKLY;COUNT=52\r\n"
"ORGANIZER;CN=Alice:mailto:alice@example.com\r\n"
"ATTENDEE;CN=Bob;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:bob@example.com\r\n"
"SEQUENCE:0\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR"
)
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
APP_URL="http://localhost:8931",
API_VERSION="v1.0",
RSVP_TOKEN_MAX_AGE_RECURRING=7776000, # 90 days
)
class TestRSVPRecurringTokenExpiry(TestCase):
"""Tests for RSVP token max_age enforcement on recurring events.
Tokens are signed with TimestampSigner so that the max_age check
works correctly for recurring events.
"""
def setUp(self):
self.factory = RequestFactory()
self.view = RSVPProcessView.as_view()
self.organizer = factories.UserFactory(email="alice@example.com")
def _post(self, token, action):
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": token, "action": action},
)
return self.view(request)
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_recurring_event_with_fresh_token_succeeds(self, mock_find, mock_put):
"""A fresh token for a recurring event should be accepted."""
ics = _make_recurring_ics()
mock_find.return_value = (ics, "/path/to/event.ics", None)
mock_put.return_value = True
token = _make_token(uid="recurring-uid-1")
response = self._post(token, "accepted")
assert response.status_code == 200
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_recurring_event_with_expired_token_rejected(self, mock_find):
"""An expired token for a recurring event should be rejected."""
ics = _make_recurring_ics()
mock_find.return_value = (ics, "/path/to/event.ics", None)
token = _make_token(uid="recurring-uid-1")
# Simulate time passing beyond max_age by patching unsign_object
# to raise SignatureExpired on the second call (with max_age)
original_unsign = TimestampSigner.unsign_object
def side_effect(value, **kwargs):
if kwargs.get("max_age") is not None:
raise SignatureExpired("Signature age exceeds max_age")
return original_unsign(TimestampSigner(), value, **kwargs)
with patch.object(TimestampSigner, "unsign_object", side_effect=side_effect):
response = self._post(token, "accepted")
assert response.status_code == 400
content = response.content.decode().lower()
assert "expired" in content or "new invitation" in content
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_recurring_event_token_expired_via_freeze_time(self, mock_find):
"""Token created now should be rejected after max_age seconds."""
from freezegun import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
freeze_time,
)
ics = _make_recurring_ics()
mock_find.return_value = (ics, "/path/to/event.ics", None)
token = _make_token(uid="recurring-uid-1")
# Advance time beyond RSVP_TOKEN_MAX_AGE_RECURRING (90 days = 7776000s)
with freeze_time(timezone.now() + timedelta(days=91)):
response = self._post(token, "accepted")
assert response.status_code == 400
content = response.content.decode().lower()
assert "expired" in content or "new invitation" in content
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_non_recurring_event_ignores_token_age(self, mock_find, mock_put):
"""Non-recurring events should not enforce token max_age."""
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = True
token = _make_token()
response = self._post(token, "accepted")
assert response.status_code == 200

View File

@@ -0,0 +1,205 @@
"""Tests for Django signals (CalDAV cleanup on user/org deletion)."""
import json
from unittest import mock
from django.test import TestCase, override_settings
import pytest
from core import factories
from core.models import Organization, User
# Signal tests need real signal handlers (they mock at the requests.request
# level), so they must be in the caldav xdist group to skip the conftest
# fixture that disconnects signals for non-CalDAV tests.
pytestmark = pytest.mark.xdist_group("caldav")
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_INTERNAL_API_KEY="test-internal-key",
CALDAV_OUTBOUND_API_KEY="test-api-key",
)
class TestDeleteUserCaldavData(TestCase):
"""Tests for the delete_user_caldav_data pre_delete signal."""
def test_deleting_user_calls_internal_api(self):
"""Deleting a user triggers a POST to the SabreDAV internal API."""
user = factories.UserFactory(email="alice@example.com")
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
with self.captureOnCommitCallbacks(execute=True):
user.delete()
# Verify the internal API was called to clean up CalDAV data
mock_request.assert_called_once()
call_kwargs = mock_request.call_args
assert call_kwargs.kwargs["method"] == "POST"
url = call_kwargs.kwargs.get("url", "")
assert "internal-api/users/delete" in url
body = json.loads(call_kwargs.kwargs.get("data", b"{}"))
assert body["email"] == "alice@example.com"
headers = call_kwargs.kwargs.get("headers", {})
assert headers.get("X-Internal-Api-Key") == "test-internal-key"
def test_deleting_user_without_email_skips_cleanup(self):
"""Users without an email don't trigger CalDAV cleanup."""
user = factories.UserFactory(email="")
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
user.delete()
mock_request.assert_not_called()
@override_settings(CALDAV_INTERNAL_API_KEY="")
def test_deleting_user_without_api_key_skips_cleanup(self):
"""When CALDAV_INTERNAL_API_KEY is empty, cleanup is skipped."""
user = factories.UserFactory(email="alice@example.com")
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
user.delete()
mock_request.assert_not_called()
def test_deleting_user_handles_http_error_gracefully(self):
"""HTTP errors during cleanup don't prevent user deletion."""
user = factories.UserFactory(email="alice@example.com")
with mock.patch(
"core.services.caldav_service.requests.request",
side_effect=Exception("Connection refused"),
):
# Should not raise — the signal catches exceptions
with self.captureOnCommitCallbacks(execute=True):
user.delete()
assert not User.objects.filter(email="alice@example.com").exists()
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_INTERNAL_API_KEY="test-internal-key",
CALDAV_OUTBOUND_API_KEY="test-api-key",
)
class TestDeleteOrganizationCaldavData(TestCase):
"""Tests for the delete_organization_caldav_data pre_delete signal."""
def test_deleting_org_cleans_up_all_members(self):
"""Deleting an org triggers CalDAV cleanup for every member.
cleanup_organization_caldav_data calls DELETE for each member,
then members.delete() triggers the user pre_delete signal which
schedules on_commit callbacks. So we expect 2 calls from org
cleanup + 2 from user signals = 4 total.
"""
org = factories.OrganizationFactory(external_id="doomed-org")
factories.UserFactory(email="alice@example.com", organization=org)
factories.UserFactory(email="bob@example.com", organization=org)
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
with self.captureOnCommitCallbacks(execute=True):
org.delete()
# 2 members x 2 POST calls each (org cleanup + user signal on_commit)
assert mock_request.call_count == 4
bodies = [
json.loads(call.kwargs.get("data", b"{}"))
for call in mock_request.call_args_list
]
emails = [b.get("email", "") for b in bodies]
assert "alice@example.com" in emails
assert "bob@example.com" in emails
def test_deleting_org_deletes_member_users(self):
"""Deleting an org also deletes member Django User objects."""
org = factories.OrganizationFactory(external_id="doomed-org")
factories.UserFactory(email="alice@example.com", organization=org)
factories.UserFactory(email="bob@example.com", organization=org)
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
with self.captureOnCommitCallbacks(execute=True):
org.delete()
assert not User.objects.filter(email="alice@example.com").exists()
assert not User.objects.filter(email="bob@example.com").exists()
assert not Organization.objects.filter(external_id="doomed-org").exists()
def test_deleting_org_with_no_members(self):
"""Deleting an org with no members succeeds without errors."""
org = factories.OrganizationFactory(external_id="empty-org")
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
org.delete()
mock_request.assert_not_called()
def test_deleting_org_continues_after_member_cleanup_failure(self):
"""If CalDAV cleanup fails for one member, other members still cleaned up."""
org = factories.OrganizationFactory(external_id="doomed-org")
factories.UserFactory(email="alice@example.com", organization=org)
factories.UserFactory(email="bob@example.com", organization=org)
call_count = 0
def side_effect(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise Exception("Network error") # pylint: disable=broad-exception-raised
resp = mock.Mock()
resp.status_code = 200
return resp
with mock.patch(
"core.services.caldav_service.requests.request",
side_effect=side_effect,
):
with self.captureOnCommitCallbacks(execute=True):
org.delete()
# Org cleanup: 2 calls (1 fails, 1 succeeds), then user signal: 2 more
assert call_count == 4
assert not Organization.objects.filter(external_id="doomed-org").exists()
@override_settings(CALDAV_INTERNAL_API_KEY="")
def test_deleting_org_without_api_key_skips_caldav_cleanup(self):
"""When CALDAV_INTERNAL_API_KEY is empty, CalDAV cleanup is skipped."""
org = factories.OrganizationFactory(external_id="org-nokey")
factories.UserFactory(email="alice@example.com", organization=org)
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
# Without the API key, the signal skips CalDAV cleanup but
# also doesn't delete members, so PROTECT FK blocks deletion.
try:
org.delete()
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
pass
mock_request.assert_not_called()

View File

@@ -0,0 +1,422 @@
"""Tests for the task system: task functions, polling endpoint, and async dispatch."""
# pylint: disable=import-outside-toplevel
import uuid
from unittest.mock import MagicMock, patch
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from core import factories
from core.services.import_service import ImportResult
pytestmark = pytest.mark.django_db
# ---------------------------------------------------------------------------
# Test import_events_task function directly
# ---------------------------------------------------------------------------
class TestImportEventsTask:
"""Test the import_events_task function itself (via EagerBroker)."""
@patch("core.tasks.ICSImportService")
def test_task_returns_success_result(self, mock_service_cls):
"""Task should return a SUCCESS dict with import results."""
from core.tasks import import_events_task # noqa: PLC0415
mock_service = mock_service_cls.return_value
mock_service.import_events.return_value = ImportResult(
total_events=3,
imported_count=3,
duplicate_count=0,
skipped_count=0,
errors=[],
)
user = factories.UserFactory()
ics_data = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
caldav_path = f"/calendars/users/{user.email}/{uuid.uuid4()}/"
result = import_events_task(str(user.id), caldav_path, ics_data.hex())
assert result["status"] == "SUCCESS"
assert result["result"]["total_events"] == 3
assert result["result"]["imported_count"] == 3
assert result["error"] is None
mock_service.import_events.assert_called_once_with(user, caldav_path, ics_data)
@patch("core.tasks.ICSImportService")
def test_task_user_not_found(self, mock_service_cls):
"""Task should return FAILURE if user does not exist."""
from core.tasks import import_events_task # noqa: PLC0415
result = import_events_task(
str(uuid.uuid4()), # non-existent user
"/calendars/users/nobody@example.com/cal/",
b"dummy".hex(),
)
assert result["status"] == "FAILURE"
assert "User not found" in result["error"]
mock_service_cls.return_value.import_events.assert_not_called()
@patch("core.tasks.set_task_progress")
@patch("core.tasks.ICSImportService")
def test_task_reports_progress(self, mock_service_cls, mock_progress):
"""Task should call set_task_progress at 0%, 10%, and 100%."""
from core.tasks import import_events_task # noqa: PLC0415
mock_service_cls.return_value.import_events.return_value = ImportResult(
total_events=1,
imported_count=1,
duplicate_count=0,
skipped_count=0,
errors=[],
)
user = factories.UserFactory()
import_events_task(
str(user.id),
f"/calendars/users/{user.email}/{uuid.uuid4()}/",
b"data".hex(),
)
progress_values = [call.args[0] for call in mock_progress.call_args_list]
assert progress_values == [0, 10, 100]
@patch("core.tasks.ICSImportService")
def test_task_via_delay(self, mock_service_cls):
"""Calling .delay() should dispatch and return a Task with an id."""
from core.tasks import import_events_task # noqa: PLC0415
mock_service_cls.return_value.import_events.return_value = ImportResult(
total_events=1,
imported_count=1,
duplicate_count=0,
skipped_count=0,
errors=[],
)
user = factories.UserFactory()
task = import_events_task.delay(
str(user.id),
f"/calendars/users/{user.email}/{uuid.uuid4()}/",
b"data".hex(),
)
assert task.id is not None
assert isinstance(task.id, str)
# ---------------------------------------------------------------------------
# Test TaskDetailView polling endpoint
# ---------------------------------------------------------------------------
class TestTaskDetailView:
"""Test the /api/v1.0/tasks/<task_id>/ polling endpoint."""
TASK_URL = "/api/v1.0/tasks/{task_id}/"
def test_requires_authentication(self):
"""Unauthenticated requests should be rejected."""
client = APIClient()
response = client.get(self.TASK_URL.format(task_id="some-id"))
assert response.status_code == 401
def test_task_not_found(self):
"""Unknown task_id should return 404."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(self.TASK_URL.format(task_id=str(uuid.uuid4())))
assert response.status_code == 404
assert response.json()["status"] == "FAILURE"
def test_task_forbidden_for_other_user(self):
"""Users cannot poll tasks they don't own."""
owner = factories.UserFactory()
other = factories.UserFactory()
# Simulate a tracked task owned by `owner`
task_id = str(uuid.uuid4())
import json # noqa: PLC0415
cache.set(
f"task_tracking:{task_id}",
json.dumps(
{
"owner": str(owner.id),
"actor_name": "import_events_task",
"queue_name": "import",
}
),
)
client = APIClient()
client.force_login(other)
response = client.get(self.TASK_URL.format(task_id=task_id))
assert response.status_code == 403
@patch("core.tasks.ICSImportService")
def test_poll_completed_task(self, mock_service_cls):
"""Polling a completed task should return SUCCESS with results."""
from core.tasks import import_events_task # noqa: PLC0415
expected_result = ImportResult(
total_events=5,
imported_count=4,
duplicate_count=1,
skipped_count=0,
errors=[],
)
mock_service_cls.return_value.import_events.return_value = expected_result
user = factories.UserFactory()
caldav_path = f"/calendars/users/{user.email}/{uuid.uuid4()}/"
# Dispatch via .delay() — EagerBroker runs it synchronously
task = import_events_task.delay(str(user.id), caldav_path, b"data".hex())
task.track_owner(user.id)
client = APIClient()
client.force_login(user)
response = client.get(self.TASK_URL.format(task_id=task.id))
assert response.status_code == 200
data = response.json()
assert data["status"] == "SUCCESS"
assert data["result"]["total_events"] == 5
assert data["result"]["imported_count"] == 4
assert data["error"] is None
@patch("core.tasks.ICSImportService")
def test_poll_task_owner_matches(self, mock_service_cls):
"""Only the task owner can poll the task."""
from core.tasks import import_events_task # noqa: PLC0415
mock_service_cls.return_value.import_events.return_value = ImportResult(
total_events=1,
imported_count=1,
duplicate_count=0,
skipped_count=0,
errors=[],
)
owner = factories.UserFactory()
other = factories.UserFactory()
caldav_path = f"/calendars/users/{owner.email}/{uuid.uuid4()}/"
task = import_events_task.delay(str(owner.id), caldav_path, b"data".hex())
task.track_owner(owner.id)
client = APIClient()
# Other user gets 403
client.force_login(other)
response = client.get(self.TASK_URL.format(task_id=task.id))
assert response.status_code == 403
# Owner gets 200
client.force_login(owner)
response = client.get(self.TASK_URL.format(task_id=task.id))
assert response.status_code == 200
assert response.json()["status"] == "SUCCESS"
# ---------------------------------------------------------------------------
# Test API → task dispatch integration
# ---------------------------------------------------------------------------
class TestImportAPITaskDispatch:
"""Test that the import API correctly dispatches a task."""
IMPORT_URL = "/api/v1.0/calendars/import-events/"
@patch("core.tasks.import_events_task")
def test_api_calls_delay_with_correct_args(self, mock_task):
"""The API should call .delay() with user_id, caldav_path, ics_hex."""
mock_message = MagicMock()
mock_message.id = str(uuid.uuid4())
mock_task.delay.return_value = mock_message
user = factories.UserFactory(email="dispatch@example.com")
caldav_path = f"/calendars/users/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
from django.core.files.uploadedfile import ( # noqa: PLC0415
SimpleUploadedFile,
)
ics_file = SimpleUploadedFile(
"events.ics",
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
content_type="text/calendar",
)
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 202
assert response.json()["task_id"] == mock_message.id
mock_task.delay.assert_called_once()
call_args = mock_task.delay.call_args.args
assert call_args[0] == str(user.id)
assert call_args[1] == caldav_path
# Third arg is ics_data as hex
assert bytes.fromhex(call_args[2]) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
@patch("core.tasks.import_events_task")
def test_api_returns_202_with_task_id(self, mock_task):
"""Successful dispatch should return HTTP 202 with task_id."""
mock_message = MagicMock()
mock_message.id = "test-task-id-123"
mock_task.delay.return_value = mock_message
user = factories.UserFactory(email="dispatch202@example.com")
caldav_path = f"/calendars/users/{user.email}/cal-uuid/"
client = APIClient()
client.force_login(user)
from django.core.files.uploadedfile import ( # noqa: PLC0415
SimpleUploadedFile,
)
ics_file = SimpleUploadedFile(
"events.ics",
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
content_type="text/calendar",
)
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 202
data = response.json()
assert data["task_id"] == "test-task-id-123"
# ---------------------------------------------------------------------------
# Full round-trip: API dispatch → EagerBroker → poll result
# ---------------------------------------------------------------------------
class TestImportFullRoundTrip:
"""Full integration: POST import → task runs (EagerBroker) → poll result."""
IMPORT_URL = "/api/v1.0/calendars/import-events/"
TASK_URL = "/api/v1.0/tasks/{task_id}/"
@patch.object(
__import__(
"core.services.import_service", fromlist=["ICSImportService"]
).ICSImportService,
"import_events",
)
def test_full_round_trip(self, mock_import):
"""POST import → EagerBroker runs task → poll returns SUCCESS."""
mock_import.return_value = ImportResult(
total_events=2,
imported_count=2,
duplicate_count=0,
skipped_count=0,
errors=[],
)
user = factories.UserFactory(email="roundtrip@example.com")
caldav_path = f"/calendars/users/{user.email}/cal-uuid/"
client = APIClient()
client.force_login(user)
from django.core.files.uploadedfile import ( # noqa: PLC0415
SimpleUploadedFile,
)
ics_file = SimpleUploadedFile(
"events.ics",
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
content_type="text/calendar",
)
# Step 1: POST triggers task dispatch
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 202
task_id = response.json()["task_id"]
# Step 2: Poll for result
poll_response = client.get(self.TASK_URL.format(task_id=task_id))
assert poll_response.status_code == 200
data = poll_response.json()
assert data["status"] == "SUCCESS"
assert data["result"]["total_events"] == 2
assert data["result"]["imported_count"] == 2
assert data["error"] is None
@patch.object(
__import__(
"core.services.import_service", fromlist=["ICSImportService"]
).ICSImportService,
"import_events",
)
def test_full_round_trip_with_errors(self, mock_import):
"""Task that returns partial failure should surface errors via poll."""
mock_import.return_value = ImportResult(
total_events=3,
imported_count=1,
duplicate_count=0,
skipped_count=2,
errors=["Event A", "Event B"],
)
user = factories.UserFactory(email="roundtrip-err@example.com")
caldav_path = f"/calendars/users/{user.email}/cal-uuid/"
client = APIClient()
client.force_login(user)
from django.core.files.uploadedfile import ( # noqa: PLC0415
SimpleUploadedFile,
)
ics_file = SimpleUploadedFile(
"events.ics",
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
content_type="text/calendar",
)
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 202
task_id = response.json()["task_id"]
poll_response = client.get(self.TASK_URL.format(task_id=task_id))
assert poll_response.status_code == 200
data = poll_response.json()
assert data["status"] == "SUCCESS"
assert data["result"]["skipped_count"] == 2
assert data["result"]["errors"] == ["Event A", "Event B"]

View File

@@ -8,19 +8,18 @@ from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.api.viewsets_caldav import CalDAVProxyView, CalDAVSchedulingCallbackView
from core.api.viewsets_channels import ChannelViewSet
from core.api.viewsets_ical import ICalExportView
from core.api.viewsets_rsvp import RSVPView
from core.api.viewsets_rsvp import RSVPConfirmView, RSVPProcessView
from core.api.viewsets_task import TaskDetailView
from core.external_api import viewsets as external_api_viewsets
# - Main endpoints
router = DefaultRouter()
router.register("users", viewsets.UserViewSet, basename="users")
router.register("calendars", viewsets.CalendarViewSet, basename="calendars")
router.register(
"subscription-tokens",
viewsets.SubscriptionTokenViewSet,
basename="subscription-tokens",
)
router.register("resources", viewsets.ResourceViewSet, basename="resources")
router.register("channels", ChannelViewSet, basename="channels")
urlpatterns = [
path(
@@ -29,35 +28,46 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
# CalDAV proxy - root path (must come before catch-all to match /caldav exactly)
path("caldav", CalDAVProxyView.as_view(), name="caldav-root"),
path("caldav/", CalDAVProxyView.as_view(), name="caldav-root-slash"),
# CalDAV proxy - catch all paths with content
re_path(
r"^caldav/(?P<path>.+)$",
CalDAVProxyView.as_view(),
name="caldav-proxy",
),
# CalDAV scheduling callback endpoint (separate from caldav proxy)
# CalDAV scheduling callback endpoint
path(
"caldav-scheduling-callback/",
CalDAVSchedulingCallbackView.as_view(),
name="caldav-scheduling-callback",
),
# RSVP POST endpoint (state-changing, with DRF throttling)
path(
"rsvp/",
RSVPProcessView.as_view(),
name="rsvp-process",
),
# Task status polling endpoint
path(
"tasks/<str:task_id>/",
TaskDetailView.as_view(),
name="task-detail",
),
]
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
# CalDAV proxy - top-level stable path (not versioned)
path("caldav", CalDAVProxyView.as_view(), name="caldav-root"),
path("caldav/", CalDAVProxyView.as_view(), name="caldav-root-slash"),
re_path(
r"^caldav/(?P<path>.+)$",
CalDAVProxyView.as_view(),
name="caldav-proxy",
),
# Public iCal export endpoint (no authentication required)
# Token in URL acts as authentication
path(
"ical/<uuid:token>.ics",
# base64url channel ID for lookup, base64url token for auth, filename cosmetic
re_path(
r"^ical/(?P<short_id>[A-Za-z0-9_-]+)/(?P<token>[A-Za-z0-9_-]+)/[^/]+\.ics$",
ICalExportView.as_view(),
name="ical-export",
),
# RSVP endpoint (no authentication required)
# RSVP GET endpoint (renders auto-submitting confirmation page)
# Signed token in query string acts as authentication
path("rsvp/", RSVPView.as_view(), name="rsvp"),
path("rsvp/", RSVPConfirmView.as_view(), name="rsvp"),
]

View File

@@ -27,11 +27,15 @@ class UserAuthViewSet(drf.viewsets.ViewSet):
serializer.is_valid(raise_exception=True)
# Create user if doesn't exist
user = models.User.objects.filter(
email=serializer.validated_data["email"]
).first()
email = serializer.validated_data["email"]
user = models.User.objects.filter(email=email).first()
if not user:
user = models.User(email=serializer.validated_data["email"])
domain = email.split("@")[-1] if "@" in email else "e2e"
org, _ = models.Organization.objects.get_or_create(
external_id=domain,
defaults={"name": domain},
)
user = models.User(email=email, organization=org)
user.set_unusable_password()
user.save()

View File

@@ -1,455 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persönliche Daten"
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:51
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:129
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Title"
msgstr "Titel"
#: core/api/filters.py:28
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/filters.py:31
msgid "Favorite"
msgstr "Favorit"
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr ""
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr ""
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr ""
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr "Lesen"
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr "Bearbeiten"
#: core/models.py:62
msgid "Administrator"
msgstr ""
#: core/models.py:63
msgid "Owner"
msgstr "Besitzer"
#: core/models.py:74
msgid "Restricted"
msgstr "Beschränkt"
#: core/models.py:78
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:80
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:86
msgid "Folder"
msgstr ""
#: core/models.py:87
msgid "File"
msgstr ""
#: core/models.py:93
msgid "Pending"
msgstr ""
#: core/models.py:94
msgid "Uploaded"
msgstr ""
#: core/models.py:116
msgid "id"
msgstr ""
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:123
msgid "created on"
msgstr "Erstellt"
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
#: core/models.py:129
msgid "updated on"
msgstr "Aktualisiert"
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:179
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
"Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, "
"Zahlen und die @/./+/-/_/: Zeichen enthalten."
#: core/models.py:185
msgid "sub"
msgstr "unter"
#: core/models.py:187
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
"Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen "
"@/./+/-/_/:"
#: core/models.py:196
msgid "full name"
msgstr "Name"
#: core/models.py:197
msgid "short name"
msgstr "Kurzbezeichnung"
#: core/models.py:199
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:204
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: core/models.py:211
msgid "language"
msgstr "Sprache"
#: core/models.py:212
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
#: core/models.py:218
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
#: core/models.py:221
msgid "device"
msgstr "Gerät"
#: core/models.py:223
msgid "Whether the user is a device or a real user."
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
#: core/models.py:226
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: core/models.py:228
msgid "Whether the user can log into this admin site."
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
#: core/models.py:231
msgid "active"
msgstr "aktiviert"
#: core/models.py:234
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie "
"diese Option, anstatt Konten zu löschen."
#: core/models.py:246
msgid "user"
msgstr "Benutzer"
#: core/models.py:247
msgid "users"
msgstr "Benutzer"
#: core/models.py:269
msgid "Workspace"
msgstr ""
#: core/models.py:457
msgid "Only folders can have children."
msgstr ""
#: core/models.py:470
#, fuzzy
#| msgid "This team is already in this item."
msgid "title already exists in this folder."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: core/models.py:504
msgid "title"
msgstr "Titel"
#: core/models.py:549
msgid "Item"
msgstr ""
#: core/models.py:550
#, fuzzy
#| msgid "items"
msgid "Items"
msgstr "Dokumente"
#: core/models.py:815
#, fuzzy, python-brace-format
#| msgid "{name} shared a item with you!"
msgid "{name} shared an item with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: core/models.py:817
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr ""
"{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: core/models.py:820
#, fuzzy, python-brace-format
#| msgid "{name} shared a item with you: {title}"
msgid "{name} shared an item with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: core/models.py:872
#, fuzzy
#| msgid "This team is already in this template."
msgid "This item is already hard deleted."
msgstr "Dieses Team ist bereits in diesem Template."
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr ""
#: core/models.py:902
#, fuzzy
#| msgid "This team is already in this template."
msgid "This item is not deleted."
msgstr "Dieses Team ist bereits in diesem Template."
#: core/models.py:918
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:1021
#, fuzzy
#| msgid "item/user link trace"
msgid "Item/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:1022
#, fuzzy
#| msgid "item/user link traces"
msgid "Item/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:1051
#, fuzzy
#| msgid "item favorite"
msgid "Item favorite"
msgstr "Dokumentenfavorit"
#: core/models.py:1052
#, fuzzy
#| msgid "item favorites"
msgid "Item favorites"
msgstr "Dokumentfavoriten"
#: core/models.py:1058
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
"Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: core/models.py:1080
#, fuzzy
#| msgid "item/user relation"
msgid "Item/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: core/models.py:1081
#, fuzzy
#| msgid "item/user relations"
msgid "Item/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: core/models.py:1087
msgid "This user is already in this item."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: core/models.py:1093
msgid "This team is already in this item."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: core/models.py:1126
msgid "email address"
msgstr "E-Mail-Adresse"
#: core/models.py:1145
#, fuzzy
#| msgid "item invitation"
msgid "Item invitation"
msgstr "Einladung zum Dokument"
#: core/models.py:1146
#, fuzzy
#| msgid "item invitations"
msgid "Item invitations"
msgstr "Dokumenteinladungen"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Calendars, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: calendars/settings.py:250
msgid "English"
msgstr "Englisch"
#: calendars/settings.py:251
msgid "French"
msgstr "Französisch"
#: calendars/settings.py:252
msgid "German"
msgstr "Deutsch"
#~ msgid "Invalid response format or token verification failed"
#~ msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
#~ msgid "User account is disabled"
#~ msgstr "Benutzerkonto ist deaktiviert"
#, fuzzy
#~| msgid "Untitled item"
#~ msgid "Untitled Item"
#~ msgstr "Unbenanntes Dokument"
#~ msgid "This email is already associated to a registered user."
#~ msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#~ msgid "A new item was created on your behalf!"
#~ msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#~ msgid "You have been granted ownership of a new item:"
#~ msgstr "Sie sind Besitzer eines neuen Dokuments:"
#~ msgid "Body"
#~ msgstr "Inhalt"
#~ msgid "Body type"
#~ msgstr "Typ"
#~ msgid "item"
#~ msgstr "Dokument"
#~ msgid "description"
#~ msgstr "Beschreibung"
#~ msgid "code"
#~ msgstr "Code"
#~ msgid "css"
#~ msgstr "CSS"
#~ msgid "public"
#~ msgstr "öffentlich"
#~ msgid "Whether this template is public for anyone to use."
#~ msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#~ msgid "Template"
#~ msgstr "Vorlage"
#~ msgid "Templates"
#~ msgstr "Vorlagen"
#~ msgid "Template/user relation"
#~ msgstr "Vorlage/Benutzer-Beziehung"
#~ msgid "Template/user relations"
#~ msgstr "Vorlage/Benutzerbeziehungen"
#~ msgid "This user is already in this template."
#~ msgstr "Dieser Benutzer ist bereits in dieser Vorlage."

View File

@@ -1,362 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: en\n"
"X-Crowdin-File: backend-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr ""
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr ""
#: core/admin.py:51
msgid "Important dates"
msgstr ""
#: core/admin.py:129
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Title"
msgstr ""
#: core/api/filters.py:28
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:31
msgid "Favorite"
msgstr ""
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr ""
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr ""
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr ""
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr ""
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr ""
#: core/models.py:62
msgid "Administrator"
msgstr ""
#: core/models.py:63
msgid "Owner"
msgstr ""
#: core/models.py:74
msgid "Restricted"
msgstr ""
#: core/models.py:78
msgid "Authenticated"
msgstr ""
#: core/models.py:80
msgid "Public"
msgstr ""
#: core/models.py:86
msgid "Folder"
msgstr ""
#: core/models.py:87
msgid "File"
msgstr ""
#: core/models.py:93
msgid "Pending"
msgstr ""
#: core/models.py:94
msgid "Uploaded"
msgstr ""
#: core/models.py:116
msgid "id"
msgstr ""
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:123
msgid "created on"
msgstr ""
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:129
msgid "updated on"
msgstr ""
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:179
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
#: core/models.py:185
msgid "sub"
msgstr ""
#: core/models.py:187
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
#: core/models.py:196
msgid "full name"
msgstr ""
#: core/models.py:197
msgid "short name"
msgstr ""
#: core/models.py:199
msgid "identity email address"
msgstr ""
#: core/models.py:204
msgid "admin email address"
msgstr ""
#: core/models.py:211
msgid "language"
msgstr ""
#: core/models.py:212
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:218
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:221
msgid "device"
msgstr ""
#: core/models.py:223
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:226
msgid "staff status"
msgstr ""
#: core/models.py:228
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:231
msgid "active"
msgstr ""
#: core/models.py:234
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:246
msgid "user"
msgstr ""
#: core/models.py:247
msgid "users"
msgstr ""
#: core/models.py:269
msgid "Workspace"
msgstr ""
#: core/models.py:457
msgid "Only folders can have children."
msgstr ""
#: core/models.py:470
msgid "title already exists in this folder."
msgstr ""
#: core/models.py:504
msgid "title"
msgstr ""
#: core/models.py:549
msgid "Item"
msgstr ""
#: core/models.py:550
msgid "Items"
msgstr ""
#: core/models.py:815
#, python-brace-format
msgid "{name} shared an item with you!"
msgstr ""
#: core/models.py:817
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr ""
#: core/models.py:820
#, python-brace-format
msgid "{name} shared an item with you: {title}"
msgstr ""
#: core/models.py:872
msgid "This item is already hard deleted."
msgstr ""
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr ""
#: core/models.py:902
msgid "This item is not deleted."
msgstr ""
#: core/models.py:918
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:1021
msgid "Item/user link trace"
msgstr ""
#: core/models.py:1022
msgid "Item/user link traces"
msgstr ""
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:1051
msgid "Item favorite"
msgstr ""
#: core/models.py:1052
msgid "Item favorites"
msgstr ""
#: core/models.py:1058
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
#: core/models.py:1080
msgid "Item/user relation"
msgstr ""
#: core/models.py:1081
msgid "Item/user relations"
msgstr ""
#: core/models.py:1087
msgid "This user is already in this item."
msgstr ""
#: core/models.py:1093
msgid "This team is already in this item."
msgstr ""
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:1126
msgid "email address"
msgstr ""
#: core/models.py:1145
msgid "Item invitation"
msgstr ""
#: core/models.py:1146
msgid "Item invitations"
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Calendars, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: calendars/settings.py:250
msgid "English"
msgstr ""
#: calendars/settings.py:251
msgid "French"
msgstr ""
#: calendars/settings.py:252
msgid "German"
msgstr ""

View File

@@ -1,371 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: backend-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Infos Personnelles"
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr ""
#: core/admin.py:51
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:129
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Title"
msgstr ""
#: core/api/filters.py:28
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:31
msgid "Favorite"
msgstr ""
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr ""
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr ""
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr ""
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr "Lecteur"
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr "Éditeur"
#: core/models.py:62
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:63
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:74
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:78
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:80
msgid "Public"
msgstr ""
#: core/models.py:86
msgid "Folder"
msgstr ""
#: core/models.py:87
msgid "File"
msgstr ""
#: core/models.py:93
msgid "Pending"
msgstr ""
#: core/models.py:94
msgid "Uploaded"
msgstr ""
#: core/models.py:116
msgid "id"
msgstr ""
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:123
msgid "created on"
msgstr ""
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:129
msgid "updated on"
msgstr ""
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:179
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
#: core/models.py:185
msgid "sub"
msgstr ""
#: core/models.py:187
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
#: core/models.py:196
msgid "full name"
msgstr ""
#: core/models.py:197
msgid "short name"
msgstr ""
#: core/models.py:199
msgid "identity email address"
msgstr ""
#: core/models.py:204
msgid "admin email address"
msgstr ""
#: core/models.py:211
msgid "language"
msgstr ""
#: core/models.py:212
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:218
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:221
msgid "device"
msgstr ""
#: core/models.py:223
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:226
msgid "staff status"
msgstr ""
#: core/models.py:228
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:231
msgid "active"
msgstr ""
#: core/models.py:234
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:246
msgid "user"
msgstr ""
#: core/models.py:247
msgid "users"
msgstr ""
#: core/models.py:269
msgid "Workspace"
msgstr ""
#: core/models.py:457
msgid "Only folders can have children."
msgstr ""
#: core/models.py:470
msgid "title already exists in this folder."
msgstr ""
#: core/models.py:504
msgid "title"
msgstr ""
#: core/models.py:549
msgid "Item"
msgstr ""
#: core/models.py:550
msgid "Items"
msgstr ""
#: core/models.py:815
#, python-brace-format
msgid "{name} shared an item with you!"
msgstr "{name} a partagé un item avec vous!"
#: core/models.py:817
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le item suivant:"
#: core/models.py:820
#, python-brace-format
#| msgid "{name} shared an item with you: {title}"
msgid "{name} shared an item with you: {title}"
msgstr "{name} a partagé un item avec vous: {title}"
#: core/models.py:872
msgid "This item is already hard deleted."
msgstr ""
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr ""
#: core/models.py:902
msgid "This item is not deleted."
msgstr ""
#: core/models.py:918
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:1021
msgid "Item/user link trace"
msgstr ""
#: core/models.py:1022
msgid "Item/user link traces"
msgstr ""
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:1051
msgid "Item favorite"
msgstr ""
#: core/models.py:1052
msgid "Item favorites"
msgstr ""
#: core/models.py:1058
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
#: core/models.py:1080
msgid "Item/user relation"
msgstr ""
#: core/models.py:1081
msgid "Item/user relations"
msgstr ""
#: core/models.py:1087
msgid "This user is already in this item."
msgstr ""
#: core/models.py:1093
msgid "This team is already in this item."
msgstr ""
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:1126
msgid "email address"
msgstr ""
#: core/models.py:1145
msgid "Item invitation"
msgstr ""
#: core/models.py:1146
msgid "Item invitations"
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Calendars, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr ""
"Fichiers, votre outil essentiel pour organiser, partager et collaborer en "
"équipe."
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Proposé par %(brandname)s "
#: calendars/settings.py:250
msgid "English"
msgstr ""
#: calendars/settings.py:251
msgid "French"
msgstr ""
#: calendars/settings.py:252
msgid "German"
msgstr ""
#~ msgid "A new item was created on your behalf!"
#~ msgstr "Un nouveau item a été créé pour vous !"
#~ msgid "You have been granted ownership of a new item:"
#~ msgstr "Vous avez été déclaré propriétaire d'un nouveau item :"

View File

@@ -1,369 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: nl\n"
"X-Crowdin-File: backend-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persoonlijke gegevens"
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr "Machtigingen"
#: core/admin.py:51
msgid "Important dates"
msgstr "Belangrijke data"
#: core/admin.py:129
msgid "Tree structure"
msgstr "Boomstructuur"
#: core/api/filters.py:16
msgid "Title"
msgstr "Titel"
#: core/api/filters.py:28
msgid "Creator is me"
msgstr "Ik ben eigenaar"
#: core/api/filters.py:31
msgid "Favorite"
msgstr "Favoriet"
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr "Er bestaat al een item met deze titel in het huidige pad."
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr "Dit veld is verplicht voor bestanden"
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr "Dit veld is verplicht voor mappen."
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr "Lezer"
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr "Redacteur"
#: core/models.py:62
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:63
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:74
msgid "Restricted"
msgstr "Beperkt"
#: core/models.py:78
msgid "Authenticated"
msgstr "Ingelogd"
#: core/models.py:80
msgid "Public"
msgstr "Openbaar"
#: core/models.py:86
msgid "Folder"
msgstr "Map"
#: core/models.py:87
msgid "File"
msgstr "Bestand"
#: core/models.py:93
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:94
msgid "Uploaded"
msgstr "Geüpload"
#: core/models.py:116
msgid "id"
msgstr "id"
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:123
msgid "created on"
msgstr "gecreëerd op"
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record is gemaakt"
#: core/models.py:129
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst is bijgewerkt"
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr "We konden geen gebruiker vinden met deze id, maar het e-mailadres is al gekoppeld aan een geregistreerde gebruiker."
#: core/models.py:179
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr "Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/_/: "
"tekens bevatten."
#: core/models.py:185
msgid "sub"
msgstr "id"
#: core/models.py:187
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr "Verplicht. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_/: tekens."
#: core/models.py:196
msgid "full name"
msgstr "volledige naam"
#: core/models.py:197
msgid "short name"
msgstr "gebruikersnaam"
#: core/models.py:199
msgid "identity email address"
msgstr "identiteits e-mailadres"
#: core/models.py:204
msgid "admin email address"
msgstr "admin e-mailadres"
#: core/models.py:211
msgid "language"
msgstr "taal"
#: core/models.py:212
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:218
msgid "The timezone in which the user wants to see times."
msgstr "Tijdzone waarin de gebruiker de tijd wil zien."
#: core/models.py:221
msgid "device"
msgstr "apparaat"
#: core/models.py:223
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat of een echte gebruiker is."
#: core/models.py:226
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:228
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheer site."
#: core/models.py:231
msgid "active"
msgstr "actief"
#: core/models.py:234
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr "Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in plaats van "
"accounts te verwijderen."
#: core/models.py:246
msgid "user"
msgstr "gebruiker"
#: core/models.py:247
msgid "users"
msgstr "gebruikers"
#: core/models.py:269
msgid "Workspace"
msgstr "Werkruimte"
#: core/models.py:457
msgid "Only folders can have children."
msgstr "Alleen mappen kunnen subitems hebben."
#: core/models.py:470
msgid "title already exists in this folder."
msgstr "titel bestaat al in deze map."
#: core/models.py:504
msgid "title"
msgstr "titel"
#: core/models.py:549
msgid "Item"
msgstr "Item"
#: core/models.py:550
msgid "Items"
msgstr "Items"
#: core/models.py:815
#, python-brace-format
msgid "{name} shared an item with you!"
msgstr "{name} heeft een item met je gedeeld!"
#: core/models.py:817
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr "{name} heeft je uitgenodigd met de rol \"{role}\" voor het volgende item:"
#: core/models.py:820
#, python-brace-format
msgid "{name} shared an item with you: {title}"
msgstr "{name} heeft een item met je gedeeld: {title}"
#: core/models.py:872
msgid "This item is already hard deleted."
msgstr "Dit item is al permanent verwijderd."
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr "Om een item permanent te verwijderen, moet het eerst tijdelijk worden verwijderd."
#: core/models.py:902
msgid "This item is not deleted."
msgstr "Dit item is niet verwijderd."
#: core/models.py:918
msgid "This item was permanently deleted and cannot be restored."
msgstr "Dit item is permanent verwijderd en kan niet worden hersteld."
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr "Alleen mappen kunnen worden geselecteerd bij het verplaatsen van een item."
#: core/models.py:1021
msgid "Item/user link trace"
msgstr "Item/gebruiker link "
#: core/models.py:1022
msgid "Item/user link traces"
msgstr "Item/gebruiker link"
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr "Er bestaat al een link trace voor dit item/gebruiker."
#: core/models.py:1051
msgid "Item favorite"
msgstr "Item favoriet"
#: core/models.py:1052
msgid "Item favorites"
msgstr "Item favorieten"
#: core/models.py:1058
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr "Dit item is al het doel van een favorietrelatie voor dezelfde "
"gebruiker."
#: core/models.py:1080
msgid "Item/user relation"
msgstr "Item/gebruiker relatie"
#: core/models.py:1081
msgid "Item/user relations"
msgstr "Item/gebruiker relaties"
#: core/models.py:1087
msgid "This user is already in this item."
msgstr "Deze gebruiker bestaat al in dit item."
#: core/models.py:1093
msgid "This team is already in this item."
msgstr "Dit team bestaat al in dit item."
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:1126
msgid "email address"
msgstr "e-mailadres"
#: core/models.py:1145
msgid "Item invitation"
msgstr "Item uitnodiging"
#: core/models.py:1146
msgid "Item invitations"
msgstr "Item uitnodigingen"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Calendars, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr " Calendars, jouw nieuwe essentiële tool voor het organiseren, delen en samenwerken als team"
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Aangeboden door %(brandname)s "
#: calendars/settings.py:250
msgid "English"
msgstr "Engels"
#: calendars/settings.py:251
msgid "French"
msgstr "Frans"
#: calendars/settings.py:252
msgid "German"
msgstr "Duits"
#: calendars/settings.py:253
msgid "Dutch"
msgstr "Nederlands"

View File

@@ -28,10 +28,11 @@ dependencies = [
"Brotli==1.2.0",
"dj-database-url==3.0.1",
"caldav==2.2.3",
"celery[redis]==5.6.0",
"dramatiq[redis]==1.17.1",
"django==5.2.9",
"django-celery-beat==2.8.1",
"django-dramatiq==0.12.0",
"django-configurations==2.5.1",
"django-fernet-encrypted-fields>=0.2",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-filter==25.2",
@@ -138,6 +139,9 @@ addopts = [
"term-missing",
# Allow test files to have the same name in different directories.
"--import-mode=importlib",
# Group CalDAV E2E tests on a single worker to avoid concurrent
# access issues with the shared SabreDAV server.
"--dist=loadgroup",
]
python_files = [
"test_*.py",

110
src/backend/uv.lock generated
View File

@@ -101,13 +101,13 @@ source = { editable = "." }
dependencies = [
{ name = "brotli" },
{ name = "caldav" },
{ name = "celery", extra = ["redis"] },
{ name = "dj-database-url" },
{ name = "django" },
{ name = "django-celery-beat" },
{ name = "django-configurations" },
{ name = "django-cors-headers" },
{ name = "django-countries" },
{ name = "django-dramatiq" },
{ name = "django-fernet-encrypted-fields" },
{ name = "django-filter" },
{ name = "django-lasuite", extra = ["all"] },
{ name = "django-parler" },
@@ -115,6 +115,7 @@ dependencies = [
{ name = "django-timezone-field" },
{ name = "djangorestframework" },
{ name = "djangorestframework-api-key" },
{ name = "dramatiq", extra = ["redis"] },
{ name = "drf-spectacular" },
{ name = "drf-standardized-errors" },
{ name = "factory-boy" },
@@ -157,15 +158,15 @@ dev = [
requires-dist = [
{ name = "brotli", specifier = "==1.2.0" },
{ name = "caldav", specifier = "==2.2.3" },
{ name = "celery", extras = ["redis"], specifier = "==5.6.0" },
{ name = "dj-database-url", specifier = "==3.0.1" },
{ name = "django", specifier = "==5.2.9" },
{ name = "django-celery-beat", specifier = "==2.8.1" },
{ name = "django-configurations", specifier = "==2.5.1" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==8.2.0" },
{ name = "django-debug-toolbar", marker = "extra == 'dev'", specifier = "==6.1.0" },
{ name = "django-dramatiq", specifier = "==0.12.0" },
{ name = "django-extensions", marker = "extra == 'dev'", specifier = "==4.1" },
{ name = "django-fernet-encrypted-fields", specifier = ">=0.2" },
{ name = "django-filter", specifier = "==25.2" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.21" },
{ name = "django-parler", specifier = "==2.3" },
@@ -173,6 +174,7 @@ requires-dist = [
{ name = "django-timezone-field", specifier = ">=5.1" },
{ name = "djangorestframework", specifier = "==3.16.1" },
{ name = "djangorestframework-api-key", specifier = "==3.1.0" },
{ name = "dramatiq", extras = ["redis"], specifier = "==1.17.1" },
{ name = "drf-spectacular", specifier = "==0.29.0" },
{ name = "drf-spectacular-sidecar", marker = "extra == 'dev'", specifier = "==2025.12.1" },
{ name = "drf-standardized-errors", specifier = "==0.15.0" },
@@ -227,11 +229,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/4e/53a125038d6a814491a0ae3457435c13cf8821eb602292cf9db37ce35f62/celery-5.6.0-py3-none-any.whl", hash = "sha256:33cf01477b175017fc8f22c5ee8a65157591043ba8ca78a443fe703aa910f581", size = 444561, upload-time = "2025-11-30T17:39:44.314Z" },
]
[package.optional-dependencies]
redis = [
{ name = "kombu", extra = ["redis"] },
]
[[package]]
name = "certifi"
version = "2025.11.12"
@@ -382,18 +379,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
]
[[package]]
name = "cron-descriptor"
version = "2.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/31/0b21d1599656b2ffa6043e51ca01041cd1c0f6dacf5a3e2b620ed120e7d8/cron_descriptor-2.0.6.tar.gz", hash = "sha256:e39d2848e1d8913cfb6e3452e701b5eec662ee18bea8cc5aa53ee1a7bb217157", size = 49456, upload-time = "2025-09-03T16:30:22.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/cc/361326a54ad92e2e12845ad15e335a4e14b8953665007fb514d3393dfb0f/cron_descriptor-2.0.6-py3-none-any.whl", hash = "sha256:3a1c0d837c0e5a32e415f821b36cf758eb92d510e6beff8fbfe4fa16573d93d6", size = 74446, upload-time = "2025-09-03T16:30:21.397Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
@@ -479,23 +464,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" },
]
[[package]]
name = "django-celery-beat"
version = "2.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "celery" },
{ name = "cron-descriptor" },
{ name = "django" },
{ name = "django-timezone-field" },
{ name = "python-crontab" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/11/0c8b412869b4fda72828572068312b10aafe7ccef7b41af3633af31f9d4b/django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a", size = 175802, upload-time = "2025-05-13T06:58:29.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/e5/3a0167044773dee989b498e9a851fc1663bea9ab879f1179f7b8a827ac10/django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171", size = 104833, upload-time = "2025-05-13T06:58:27.309Z" },
]
[[package]]
name = "django-configurations"
version = "2.5.1"
@@ -547,6 +515,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/72/685c978af45ad08257e2c69687a873eda6b6531c79b6e6091794c41c5ff6/django_debug_toolbar-6.1.0-py3-none-any.whl", hash = "sha256:e214dea4494087e7cebdcea84223819c5eb97f9de3110a3665ad673f0ba98413", size = 269069, upload-time = "2025-10-30T19:50:37.71Z" },
]
[[package]]
name = "django-dramatiq"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "dramatiq" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/32/cd8b1394be24a5a1c0fb213c3ee3e575414159d03f3d09c3699b09162dc6/django_dramatiq-0.12.0.tar.gz", hash = "sha256:d4f4a6ecccb104b10b2b743052a703b7749cd671d492a0f6f2a7e13e846923a8", size = 14262, upload-time = "2024-12-29T12:46:56.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/26/bc518333e9c62bc9c6c11994342b07933537adcb2dbdf6b4fb7ac0a9d88a/django_dramatiq-0.12.0-py3-none-any.whl", hash = "sha256:f9b8fff9510e7e780e993fcd3b8fd31c503c3caa09a535cbead284fb4b636262", size = 12082, upload-time = "2024-12-29T12:46:55.093Z" },
]
[[package]]
name = "django-extensions"
version = "4.1"
@@ -559,6 +540,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
]
[[package]]
name = "django-fernet-encrypted-fields"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/aa/529af3888215b8a660fc3897d6d63eaf1de9aa0699c633ca0ec483d4361c/django_fernet_encrypted_fields-0.3.1.tar.gz", hash = "sha256:5ed328c7f9cc7f2d452bb2e125f3ea2bea3563a259fa943e5a1c626175889a71", size = 5265, upload-time = "2025-11-10T08:39:57.398Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/7f/4e0b7ed8413fa58e7a77017342e8ab0e977d41cfc376ab9180ae75f216ec/django_fernet_encrypted_fields-0.3.1-py3-none-any.whl", hash = "sha256:3bd2abab02556dc6e15a58a61161ee6c5cdf45a50a8a52d9e035009eb54c6442", size = 5484, upload-time = "2025-11-10T08:39:55.866Z" },
]
[[package]]
name = "django-filter"
version = "25.2"
@@ -664,6 +658,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "dramatiq"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prometheus-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/7a/6792ddc64a77d22bfd97261b751a7a76cf2f9d62edc59aafb679ac48b77d/dramatiq-1.17.1.tar.gz", hash = "sha256:2675d2f57e0d82db3a7d2a60f1f9c536365349db78c7f8d80a63e4c54697647a", size = 99071, upload-time = "2024-10-26T05:09:28.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/36/925c7afd5db4f1a3f00676b9c3c58f31ff7ae29a347282d86c8d429280a5/dramatiq-1.17.1-py3-none-any.whl", hash = "sha256:951cdc334478dff8e5150bb02a6f7a947d215ee24b5aedaf738eff20e17913df", size = 120382, upload-time = "2024-10-26T05:09:26.436Z" },
]
[package.optional-dependencies]
redis = [
{ name = "redis" },
]
[[package]]
name = "drf-spectacular"
version = "0.29.0"
@@ -1024,11 +1035,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/d6/943cf84117cd9ddecf6e1707a3f712a49fc64abdb8ac31b19132871af1dd/kombu-5.6.1-py3-none-any.whl", hash = "sha256:b69e3f5527ec32fc5196028a36376501682973e9620d6175d1c3d4eaf7e95409", size = 214141, upload-time = "2025-11-25T11:07:31.54Z" },
]
[package.optional-dependencies]
redis = [
{ name = "redis" },
]
[[package]]
name = "lxml"
version = "6.0.2"
@@ -1201,6 +1207,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/d1/e4ed95fdd3ef13b78630280d9e9e240aeb65cc7c544ec57106149c3942fb/pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d", size = 16952, upload-time = "2018-07-01T01:42:36.496Z" },
]
[[package]]
name = "prometheus-client"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
@@ -1414,15 +1429,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
]
[[package]]
name = "python-crontab"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"

204
src/backend/worker.py Normal file
View File

@@ -0,0 +1,204 @@
#!/usr/bin/env python
# pylint: disable=import-outside-toplevel
"""
Background task worker with sensible queue defaults.
Usage:
python worker.py # Process all queues
python worker.py --queues=import,default # Process only specific queues
python worker.py --exclude=sync # Process all except sync
python worker.py --concurrency=4 # Set worker concurrency
python worker.py -v 2 # Verbose logging
Queue priority order (highest to lowest):
1. default - General / high-priority tasks
2. import - File import processing
3. sync - Background sync tasks
"""
import argparse
import logging
import multiprocessing
import os
import sys
# Workaround for Dramatiq + Python 3.14: forkserver (the new default) breaks
# Dramatiq's Canteen shared-memory mechanism, causing worker processes to never
# consume messages. See https://github.com/Bogdanp/dramatiq/issues/701
# Must be set before dramatiq.cli.main() spawns worker processes.
multiprocessing.set_start_method("fork", force=True)
# Setup Django before importing the task runner
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "calendars.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
# Override $APP if set by the host (e.g. Scalingo)
os.environ.pop("APP", None)
from configurations.importer import install # pylint: disable=wrong-import-position
install(check_options=True)
import django # pylint: disable=wrong-import-position
django.setup()
# Queue definitions in priority order
ALL_QUEUES = ["default", "import", "sync"]
DEFAULT_QUEUES = ALL_QUEUES
def get_default_concurrency():
"""Get default concurrency from environment variables."""
env_value = os.environ.get("WORKER_CONCURRENCY")
if env_value:
try:
return int(env_value)
except ValueError:
return None
return None
def discover_tasks_modules():
"""Discover task modules the same way django_dramatiq does."""
import importlib # noqa: PLC0415 # pylint: disable=wrong-import-position
from django.apps import ( # noqa: PLC0415 # pylint: disable=wrong-import-position
apps,
)
from django.conf import ( # noqa: PLC0415 # pylint: disable=wrong-import-position
settings,
)
from django.utils.module_loading import ( # noqa: PLC0415 # pylint: disable=wrong-import-position
module_has_submodule,
)
task_module_names = settings.DRAMATIQ_AUTODISCOVER_MODULES
modules = ["django_dramatiq.setup"]
for conf in apps.get_app_configs():
if conf.name == "django_dramatiq":
module = conf.name + ".tasks"
importlib.import_module(module)
logging.getLogger(__name__).info("Discovered tasks module: %r", module)
modules.append(module)
else:
for task_module in task_module_names:
if module_has_submodule(conf.module, task_module):
module = conf.name + "." + task_module
importlib.import_module(module)
logging.getLogger(__name__).info(
"Discovered tasks module: %r", module
)
modules.append(module)
return modules
def parse_args():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Start a background task worker.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--queues",
"-Q",
type=str,
default=None,
help=(
"Comma-separated list of queues to process. "
f"Default: {','.join(DEFAULT_QUEUES)}"
),
)
parser.add_argument(
"--exclude",
"-X",
type=str,
default=None,
help="Comma-separated list of queues to exclude.",
)
parser.add_argument(
"--concurrency",
"-c",
type=int,
default=get_default_concurrency(),
help="Number of worker processes. Default: WORKER_CONCURRENCY env var.",
)
parser.add_argument(
"--verbosity",
"-v",
type=int,
default=1,
help="Verbosity level (0=minimal, 1=normal, 2=verbose). Default: 1",
)
return parser.parse_args()
def main():
"""Start the background task worker."""
logger = logging.getLogger(__name__)
args = parse_args()
# Determine which queues to process
if args.queues:
queues = [q.strip() for q in args.queues.split(",")]
invalid = set(queues) - set(ALL_QUEUES)
if invalid:
sys.stderr.write(f"Error: Unknown queues: {', '.join(invalid)}\n")
sys.stderr.write(f"Valid queues are: {', '.join(ALL_QUEUES)}\n")
sys.exit(1)
else:
queues = DEFAULT_QUEUES.copy()
# Apply exclusions
if args.exclude:
exclude = [q.strip() for q in args.exclude.split(",")]
invalid_exclude = set(exclude) - set(ALL_QUEUES)
if invalid_exclude:
sys.stderr.write(
f"Error: Unknown queues to exclude: {', '.join(invalid_exclude)}\n"
)
sys.stderr.write(f"Valid queues are: {', '.join(ALL_QUEUES)}\n")
sys.exit(1)
queues = [q for q in queues if q not in exclude]
if not queues:
sys.stderr.write("Error: No queues to process after exclusions.\n")
sys.exit(1)
# Discover task modules
tasks_modules = discover_tasks_modules()
# Build dramatiq CLI arguments and call main() directly.
# This avoids rundramatiq's os.execvp which replaces the process and
# discards our multiprocessing.set_start_method("fork") workaround.
dramatiq_args = [
"dramatiq",
"--path",
".",
"--processes",
str(args.concurrency or 4),
"--threads",
"1",
"--worker-shutdown-timeout",
"600000",
]
if args.verbosity > 1:
dramatiq_args.append("-v")
dramatiq_args.extend(tasks_modules)
dramatiq_args.extend(["--queues", *queues])
logger.info("Starting worker with queues: %s", ", ".join(queues))
import dramatiq.cli # noqa: PLC0415 # pylint: disable=wrong-import-position
sys.argv = dramatiq_args
dramatiq.cli.main()
if __name__ == "__main__":
main()