✨(scheduling) add callback from caldav to django for imip
This commit is contained in:
246
src/backend/core/tests/test_caldav_scheduling.py
Normal file
246
src/backend/core/tests/test_caldav_scheduling.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Tests for CalDAV scheduling callback integration."""
|
||||
|
||||
import http.server
|
||||
import logging
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import pytest
|
||||
|
||||
from caldav.lib.error import NotFoundError
|
||||
from core import factories
|
||||
from core.services.caldav_service import CalendarService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""HTTP request handler for capturing CalDAV scheduling callbacks in tests."""
|
||||
|
||||
def __init__(self, callback_data, *args, **kwargs):
|
||||
self.callback_data = callback_data
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def do_POST(self):
|
||||
"""Handle POST requests (scheduling callbacks)."""
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length) if content_length > 0 else b""
|
||||
|
||||
# Store callback data
|
||||
self.callback_data["called"] = True
|
||||
self.callback_data["request_data"] = {
|
||||
"headers": dict(self.headers),
|
||||
"body": body.decode("utf-8", errors="ignore") if body else "",
|
||||
}
|
||||
|
||||
# Send success response
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"OK")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Suppress default logging."""
|
||||
pass
|
||||
|
||||
|
||||
def create_test_server() -> tuple:
|
||||
"""Create a test HTTP server that captures callbacks.
|
||||
|
||||
Returns:
|
||||
Tuple of (server, port, callback_data)
|
||||
"""
|
||||
callback_data = {"called": False, "request_data": None}
|
||||
|
||||
def handler_factory(*args, **kwargs):
|
||||
return CallbackHandler(callback_data, *args, **kwargs)
|
||||
|
||||
# Use fixed port 8001 - accessible from other Docker containers
|
||||
port = 8001
|
||||
|
||||
# Create server with SO_REUSEADDR to allow quick port reuse
|
||||
server = http.server.HTTPServer(("0.0.0.0", port), handler_factory)
|
||||
server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
actual_port = server.server_address[1]
|
||||
|
||||
return server, actual_port, callback_data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
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(self):
|
||||
"""Test that creating an event with an attendee triggers scheduling callback.
|
||||
|
||||
This test verifies that when an event is created with an attendee via CalDAV,
|
||||
the HttpCallbackIMipPlugin sends a scheduling message to the Django callback endpoint.
|
||||
|
||||
The test starts a local HTTP server to receive the callback, and passes the server URL
|
||||
to the CalDAV server via the X-CalDAV-Callback-URL header.
|
||||
"""
|
||||
# Create users: organizer
|
||||
# Note: attendee should be external (not in CalDAV server) to trigger scheduling
|
||||
organizer = factories.UserFactory(email="organizer@example.com")
|
||||
|
||||
# Create calendar for organizer
|
||||
service = CalendarService()
|
||||
calendar = service.create_calendar(
|
||||
organizer, name="Test Calendar", color="#ff0000"
|
||||
)
|
||||
|
||||
# Start test HTTP server to receive callbacks
|
||||
# Use fixed port 8001 - accessible from other Docker containers
|
||||
server, port, callback_data = create_test_server()
|
||||
|
||||
# Start server in a separate thread
|
||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
# Give the server a moment to start listening
|
||||
time.sleep(0.5)
|
||||
|
||||
# Verify server is actually listening
|
||||
test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
test_socket.connect(("127.0.0.1", port))
|
||||
test_socket.close()
|
||||
except Exception as e:
|
||||
pytest.fail(f"Test server failed to start on port {port}: {e}")
|
||||
|
||||
# Use the named test container hostname
|
||||
# The test container is created with --name backend-test in bin/pytest
|
||||
# Docker Compose networking allows containers to reach each other by name
|
||||
callback_url = f"http://backend-test:{port}/"
|
||||
|
||||
try:
|
||||
# Create an event with an attendee
|
||||
client = service.caldav._get_client(organizer)
|
||||
calendar_url = f"{settings.CALDAV_URL}{calendar.caldav_path}"
|
||||
|
||||
# Add custom callback URL header to the client
|
||||
# The CalDAV server will use this URL for the callback
|
||||
client.headers["X-CalDAV-Callback-URL"] = callback_url
|
||||
|
||||
try:
|
||||
caldav_calendar = client.calendar(url=calendar_url)
|
||||
|
||||
# Create event with attendee using iCalendar format
|
||||
# We need to create the event with attendees to trigger scheduling
|
||||
# Note: sabre/dav's scheduling plugin only sends messages for external attendees
|
||||
# (attendees that don't have a principal in the same CalDAV server)
|
||||
dtstart = datetime.now() + timedelta(days=1)
|
||||
dtend = dtstart + timedelta(hours=1)
|
||||
|
||||
# Use a clearly external attendee email (not in the CalDAV server)
|
||||
external_attendee = "external-attendee@external-domain.com"
|
||||
|
||||
# Create iCalendar event with attendee
|
||||
ical_content = f"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:test-event-{datetime.now().timestamp()}
|
||||
DTSTART:{dtstart.strftime("%Y%m%dT%H%M%SZ")}
|
||||
DTEND:{dtend.strftime("%Y%m%dT%H%M%SZ")}
|
||||
SUMMARY:Test Event with Attendee
|
||||
ORGANIZER;CN=Organizer:mailto:{organizer.email}
|
||||
ATTENDEE;CN=External Attendee;RSVP=TRUE:mailto:{external_attendee}
|
||||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
# Save event to trigger scheduling
|
||||
event = caldav_calendar.save_event(ical_content)
|
||||
|
||||
# Give the callback a moment to be called (scheduling may be async)
|
||||
# sabre/dav processes scheduling synchronously during the request
|
||||
time.sleep(2)
|
||||
|
||||
# Verify callback was called
|
||||
assert callback_data["called"], (
|
||||
"Scheduling callback was not called when creating event with attendee. "
|
||||
"This may indicate that sabre/dav's scheduling plugin is not working correctly. "
|
||||
"Check CalDAV server logs for scheduling errors."
|
||||
)
|
||||
|
||||
# Verify callback request details
|
||||
request_data = callback_data["request_data"]
|
||||
assert request_data is not None
|
||||
|
||||
# Verify API key authentication
|
||||
api_key = request_data["headers"].get("X-Api-Key", "")
|
||||
expected_key = settings.CALDAV_INBOUND_API_KEY
|
||||
assert expected_key and secrets.compare_digest(api_key, expected_key), (
|
||||
f"Callback request missing or invalid X-Api-Key header. "
|
||||
f"Expected: {expected_key[:10]}..., Got: {api_key[:10] if api_key else 'None'}..."
|
||||
)
|
||||
|
||||
# Verify scheduling headers
|
||||
assert "X-CalDAV-Sender" in request_data["headers"], (
|
||||
"Missing X-CalDAV-Sender header"
|
||||
)
|
||||
assert "X-CalDAV-Recipient" in request_data["headers"], (
|
||||
"Missing X-CalDAV-Recipient header"
|
||||
)
|
||||
assert "X-CalDAV-Method" in request_data["headers"], (
|
||||
"Missing X-CalDAV-Method header"
|
||||
)
|
||||
|
||||
# Verify sender is the organizer
|
||||
sender = request_data["headers"]["X-CalDAV-Sender"]
|
||||
assert (
|
||||
organizer.email in sender or f"mailto:{organizer.email}" in sender
|
||||
), f"Expected sender to be {organizer.email}, got {sender}"
|
||||
|
||||
# Verify recipient is the attendee
|
||||
recipient = request_data["headers"]["X-CalDAV-Recipient"]
|
||||
assert (
|
||||
external_attendee in recipient
|
||||
or f"mailto:{external_attendee}" in recipient
|
||||
), f"Expected recipient to be {external_attendee}, got {recipient}"
|
||||
|
||||
# Verify method is REQUEST (for new invitations)
|
||||
method = request_data["headers"]["X-CalDAV-Method"]
|
||||
assert method == "REQUEST", (
|
||||
f"Expected method to be REQUEST for new invitation, got {method}"
|
||||
)
|
||||
|
||||
# Verify iCalendar content is present
|
||||
assert request_data["body"], "Callback request body is empty"
|
||||
assert "BEGIN:VCALENDAR" in request_data["body"], (
|
||||
"Callback body should contain iCalendar content"
|
||||
)
|
||||
assert "VEVENT" in request_data["body"], (
|
||||
"Callback body should contain VEVENT"
|
||||
)
|
||||
|
||||
# Normalize iCalendar body to handle line folding (CRLF + space/tab)
|
||||
# iCalendar format folds long lines at 75 characters, so we need to remove folding
|
||||
# Line folding: CRLF followed by space or tab indicates continuation
|
||||
body = request_data["body"]
|
||||
# Remove line folding: replace CRLF+space and CRLF+tab with nothing
|
||||
normalized_body = body.replace("\r\n ", "").replace("\r\n\t", "")
|
||||
# Also handle Unix-style line endings
|
||||
normalized_body = normalized_body.replace("\n ", "").replace("\n\t", "")
|
||||
assert external_attendee in normalized_body, (
|
||||
f"Callback body should contain attendee email {external_attendee}. "
|
||||
f"Normalized body (first 500 chars): {normalized_body[:500]}"
|
||||
)
|
||||
|
||||
except NotFoundError:
|
||||
pytest.skip("Calendar not found - CalDAV server may not be running")
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed to create event with attendee: {str(e)}")
|
||||
finally:
|
||||
# Shutdown server
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
@@ -22,9 +22,9 @@ class TestCalDAVClient:
|
||||
dav_client = client._get_client(user)
|
||||
|
||||
# Verify the client is configured correctly
|
||||
assert dav_client.username == user.email
|
||||
# Password should be empty (None or empty string) for external auth
|
||||
assert not dav_client.password or dav_client.password == ""
|
||||
# Username and password should be None to prevent Basic auth
|
||||
assert dav_client.username is None
|
||||
assert dav_client.password is None
|
||||
|
||||
# Verify the X-Forwarded-User header is set
|
||||
# The caldav library stores headers as a CaseInsensitiveDict
|
||||
|
||||
Reference in New Issue
Block a user