diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index cfb20413..19d06680 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -149,6 +149,11 @@ jobs: # Create test user docker exec -e OC_PASS="testpass" ${{ job.services.nextcloud.id }} php occ user:add --password-from-env --display-name="Test User" testuser || echo "User may already exist" + # Create scheduling test users (user1-user3) + for i in 1 2 3; do + docker exec -e OC_PASS="testpass${i}" ${{ job.services.nextcloud.id }} php occ user:add --password-from-env --display-name="User ${i}" "user${i}" || echo "user${i} may already exist" + done + # Enable calendar and contacts apps docker exec ${{ job.services.nextcloud.id }} php occ app:enable calendar || true docker exec ${{ job.services.nextcloud.id }} php occ app:enable contacts || true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5f1d96c2..456d698c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,6 +24,6 @@ repos: rev: lychee-v0.22.0 hooks: - id: lychee - args: ["--no-progress", "--timeout", "10", "--exclude-path", ".lycheeignore"] + args: ["--no-progress", "--timeout", "10", "--exclude-path", ".lycheeignore", "--max-cache-age=30d", "--cache"] stages: [pre-push] exclude: ^tests/test_caldav_unit\.py$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f2da59f..0a97a3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0 * `Calendar.get_supported_components()` * raised `KeyError` when the server did not include the `supported-calendar-component-set` property in its response. RFC 4791 section 5.2.3 states this property is optional and that its absence means all component types are accepted; the method now returns the RFC default `["VEVENT", "VTODO", "VJOURNAL"]` in that case, trimmed by any known server limitations from the compatibility hints (e.g. if `save-load.todo` is `unsupported`, `VTODO` is excluded). Fixes https://github.com/python-caldav/caldav/issues/653 * async path returned an unawaited coroutine instead of the actual result. +* `accept_invite()` (and `decline_invite()`, `tentatively_accept_invite()`) now fall back to the client username as the attendee email address when the server does not expose the `calendar-user-address-set` property (RFC6638 §2.4.1). A `NotFoundError` with a descriptive message is raised when the username is also not an email address. Fixes https://github.com/python-caldav/caldav/issues/399 ## [3.1.0] - 2026-03-19 diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index cc28f735..2f9ef1f2 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -168,18 +168,42 @@ def set_end(self, end, move_dtstart=False): i.add(self._ENDPARAM, end) - def add_organizer(self) -> None: + def add_organizer(self, organizer=None) -> None: """ - goes via self.client, finds the principal, figures out the right attendee-format and adds an - organizer line to the event + Add (or replace) the ORGANIZER field on the calendar component. + + If *organizer* is omitted the current principal is used (requires + ``self.client`` to be set). The *organizer* argument accepts the + same types as :meth:`add_attendee`: + + * A :class:`~caldav.Principal` object + * A :class:`icalendar.vCalAddress` object + * A ``"mailto:user@example.com"`` string + * A plain email address string (``"mailto:"`` is prepended automatically) + + Any pre-existing ORGANIZER field is removed before the new one is added. """ - if self.client is None: - raise ValueError("Unexpected value None for self.client") + from .collection import Principal as _Principal ## avoid circular import - principal = self.client.principal() - ## TODO: remove Organizer-field, if exists - ## TODO: what if walk returns more than one vevent? - self.icalendar_component.add("organizer", principal.get_vcal_address()) + if organizer is None: + if self.client is None: + raise ValueError("Unexpected value None for self.client") + organizer_obj = self.client.principal().get_vcal_address() + elif isinstance(organizer, _Principal): + organizer_obj = organizer.get_vcal_address() + elif isinstance(organizer, vCalAddress): + organizer_obj = organizer + elif isinstance(organizer, str): + if organizer.startswith("mailto:"): + organizer_obj = vCalAddress(organizer) + else: + organizer_obj = vCalAddress("mailto:" + organizer) + else: + raise ValueError(f"Unsupported organizer type: {type(organizer)!r}") + + ievent = self.icalendar_component + ievent.pop("organizer", None) + ievent.add("organizer", organizer_obj) def split_expanded(self) -> list[Self]: """This was used internally for processing search results. @@ -696,7 +720,7 @@ def is_invite_request(self) -> bool: def is_invite_reply(self) -> bool: """ Returns True if the object is a reply, see - :rfc:`2446#section-3.2.3`. + :rfc:`5546#section-3.2`. """ self.load(only_if_unloaded=True) return self.icalendar_instance.get("method", None) == "REPLY" @@ -1031,7 +1055,22 @@ def change_attendee_status(self, attendee: Any | None = None, **kwargs) -> None: cnt = 0 if isinstance(attendee, Principal): - attendee_emails = attendee.calendar_user_address_set() + try: + attendee_emails = attendee.calendar_user_address_set() + except error.NotFoundError: + ## Server does not expose calendar-user-address-set (RFC6638 §2.4.1). + ## Fall back to client.username if it looks like an email address. + ## See https://github.com/python-caldav/caldav/issues/399 + username = getattr(self.client, "username", None) + if username and "@" in str(username): + attendee_emails = ["mailto:" + username] + else: + raise error.NotFoundError( + "Server does not provide the calendar-user-address-set property " + "(RFC6638 §2.4.1) and the client username is not an email address. " + "Cannot determine which attendee to update. " + "Pass the attendee email address explicitly to change_attendee_status()." + ) from None for addr in attendee_emails: try: self.change_attendee_status(addr, **kwargs) diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 1e8f9938..194d4084 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -264,6 +264,25 @@ class FeatureSet: "sync-token.delete": { "description": "Server correctly handles sync-collection reports after objects have been deleted from the calendar (solved in Nextcloud in https://github.com/nextcloud/server/pull/44130)" }, + "scheduling": { + "description": "Server supports CalDAV Scheduling (RFC6638). Detected via the presence of 'calendar-auto-schedule' in the DAV response header.", + "links": ["https://datatracker.ietf.org/doc/html/rfc6638"], + }, + "scheduling.mailbox": { + "description": "Server provides schedule-inbox and schedule-outbox collections for the principal (RFC6638 sections 2.1-2.2). When unsupported, calls to schedule_inbox() or schedule_outbox() raise NotFoundError.", + "links": ["https://datatracker.ietf.org/doc/html/rfc6638#section-2.1"], + "default": {"support": "full"}, + }, + "scheduling.calendar-user-address-set": { + "description": "Server provides the calendar-user-address-set property on the principal (RFC6638 section 2.4.1), used to identify a user's email/URI for scheduling purposes. When unsupported, calendar_user_address_set() raises NotFoundError.", + "links": ["https://datatracker.ietf.org/doc/html/rfc6638#section-2.4.1"], + }, + "scheduling.mailbox.inbox-delivery": { + "description": "Server delivers incoming scheduling REQUEST messages to the attendee's schedule-inbox (RFC6638 section 4.1). When unsupported, the server implements automatic scheduling: invitations are auto-processed and placed directly on the attendee's calendar without appearing in the inbox. Clients should check this feature to know whether to look for inbox items after sending an invite, or check the attendee calendar directly.", + "links": [ + "https://datatracker.ietf.org/doc/html/rfc6638#section-4.1", + ], + }, 'freebusy-query': {'description': "freebusy queries come in two flavors, one query can be done towards a CalDAV server as defined in RFC4791, another query can be done through the scheduling framework, RFC 6638. Only RFC4791 is tested for as today"}, "freebusy-query.rfc4791": { "description": "Server supports free/busy-query REPORT as specified in RFC4791 section 7.10. The REPORT allows clients to query for free/busy time information for a time range. Servers without this support will typically return an error (often 500 Internal Server Error or 501 Not Implemented). Note: RFC6638 defines a different freebusy mechanism for scheduling", @@ -710,15 +729,6 @@ def dotted_feature_set_list(self, compact=False): ## * Perhaps some more readable format should be considered (yaml?). ## * Consider how to get this into the documentation incompatibility_description = { - 'no_scheduling': - """RFC6833 is not supported""", - - 'no_scheduling_mailbox': - """Parts of RFC6833 is supported, but not the existence of inbox/mailbox""", - - 'no_scheduling_calendar_user_address_set': - """Parts of RFC6833 is supported, but not getting the calendar users addresses""", - 'no_default_calendar': """The given user starts without an assigned default calendar """ """(or without pre-defined calendars at all)""", @@ -837,14 +847,12 @@ def dotted_feature_set_list(self, compact=False): "search.text.category.substring": {"support": "unsupported"}, 'principal-search': {'support': 'unsupported'}, 'freebusy-query.rfc4791': {'support': 'ungraceful', 'behaviour': '500 internal server error'}, + "scheduling": {"support": "unsupported"}, "old_flags": [ ## https://github.com/jelmer/xandikos/issues/8 'date_todo_search_ignores_duration', 'vtodo_datesearch_nostart_future_tasks_delivered', - ## scheduling is not supported - "no_scheduling", - ## The test with an rrule and an overridden event passes as ## long as it's with timestamps. With dates, xandikos gets ## into troubles. I've chosen to edit the test to use timestamp @@ -872,13 +880,11 @@ def dotted_feature_set_list(self, compact=False): ## this only applies for very simple installations "auto-connect.url": {"domain": "localhost", "scheme": "http", "basepath": "/"}, + "scheduling": {"support": "unsupported"}, "old_flags": [ ## https://github.com/jelmer/xandikos/issues/8 'date_todo_search_ignores_duration', 'vtodo_datesearch_nostart_future_tasks_delivered', - - ## scheduling is not supported - "no_scheduling", ] } @@ -895,11 +901,11 @@ def dotted_feature_set_list(self, compact=False): ## this only applies for very simple installations "auto-connect.url": {"domain": "localhost", "scheme": "http", "basepath": "/"}, ## freebusy is not supported yet, but on the long-term road map + "scheduling": {"support": "unsupported"}, 'old_flags': [ ## calendar listings and calendar creation works a bit ## "weird" on radicale - 'no_scheduling', 'no_search_openended', #'text_search_is_exact_match_sometimes', @@ -978,6 +984,10 @@ def dotted_feature_set_list(self, compact=False): 'search.comp-type.optional': {'support': 'fragile'}, ## TODO: more research on this, looks like a bug in the checker, 'search.time-range.alarm': {'support': 'unsupported'}, 'principal-search': "unsupported", + ## Zimbra implements server-side automatic scheduling: invitations are + ## auto-processed into the attendee's calendar; no iTIP notification appears in the inbox. + "scheduling.mailbox": True, + "scheduling.mailbox.inbox-delivery": {"support": "unsupported"}, "old_flags": [ ## apparently, zimbra has no journal support @@ -1005,7 +1015,7 @@ def dotted_feature_set_list(self, compact=False): bedework = { ## If tests are yielding unexpected results, try to increase this: - 'search-cache': {'behaviour': 'delay', 'delay': 1.5}, + 'search-cache': {'behaviour': 'delay', 'delay': 3}, 'test-calendar': {'cleanup-regime': 'wipe-calendar'}, 'auto-connect.url': {'basepath': '/ucaldav/'}, @@ -1025,7 +1035,15 @@ def dotted_feature_set_list(self, compact=False): 'search.comp-type.optional': {'support': 'ungraceful'}, 'search.is-not-defined.dtend': False, "principal-search": { "support": "ungraceful" }, - "search.unlimited-time-range": {"support": "broken"}, + ## Bedework hides past non-recurring events from REPORT without a time-range filter, + ## but still returns recurring events that have future occurrences. The unlimited-time-range + ## check probe is a past-only non-recurring event; it is not returned even though the + ## PrepareCalendar recurring event (RRULE:FREQ=MONTHLY since 2000) is returned. + ## Result: objects is non-empty but the probe event is absent → "broken". + #"search.unlimited-time-range": {"support": "broken"}, + ## Bedework uses a pre-built Docker image with no easy way to add users, so + ## cross-user scheduling tests cannot be run; inbox-delivery behaviour is unknown. + "scheduling.mailbox": {"support": "unknown"}, ## TODO: play with this and see if it's needed 'old_flags': [ @@ -1050,6 +1068,10 @@ def dotted_feature_set_list(self, compact=False): } baikal = { ## version 0.10.1 + # Baikal (sabre/dav) delivers iTIP notifications to the attendee inbox AND auto-schedules + # into their calendar (quirk: both delivery modes happen simultaneously). + "scheduling.mailbox.inbox-delivery": {"support": "quirk", "behaviour": "server delivers iTIP notification to inbox AND auto-schedules into calendar"}, + "scheduling.mailbox": True, "http.multiplexing": "fragile", ## ref https://github.com/python-caldav/caldav/issues/564 'search.comp-type.optional': {'support': 'ungraceful'}, 'search.recurrences.expanded.todo': {'support': 'unsupported'}, @@ -1090,6 +1112,16 @@ def dotted_feature_set_list(self, compact=False): 'support': 'fragile', 'behaviour': 'Deleting a recently created calendar fails'}, # Cyrus may not properly reject wrong passwords in some configurations + # Cyrus implements server-side automatic scheduling: for cross-user + # invites, the server both auto-processes the invite into the attendee's calendar + # AND delivers an iTIP notification copy to the attendee's schedule-inbox. + # Clients do not need to explicitly accept from the inbox (auto-accept is done), + # but inbox items do appear. This is "quirk" behaviour: both delivery modes happen. + "scheduling.mailbox": True, + "scheduling.mailbox.inbox-delivery": { + "support": "quirk", + "behaviour": "server delivers iTIP notification to inbox AND auto-schedules into calendar", + }, 'old_flags': [] } @@ -1110,6 +1142,10 @@ def dotted_feature_set_list(self, compact=False): # Disable HTTP/2 multiplexing - davical doesn't support it well and niquests # lazy responses cause MultiplexingError when accessing status_code "http.multiplexing": { "support": "unsupported" }, + # DAViCal delivers iTIP notifications to the attendee inbox AND auto-schedules + # into their calendar (quirk: both delivery modes happen simultaneously). + "scheduling.mailbox": True, + "scheduling.mailbox.inbox-delivery": {"support": "quirk", "behaviour": "server delivers iTIP notification to inbox AND auto-schedules into calendar"}, "search.comp-type.optional": { "support": "fragile" }, "search.recurrences.expanded.exception": { "support": "unsupported" }, "search.time-range.alarm": { "support": "unsupported" }, @@ -1129,6 +1165,8 @@ def dotted_feature_set_list(self, compact=False): } sogo = { + ## scheduling.mailbox.inbox-delivery behaviour unknown until cross-user scheduling tests run + "scheduling.mailbox.inbox-delivery": {"support": "unknown"}, ## I'm surprised, I'm quite sure this was passing earlier. reported unsupported with caldav commit a98d50490b872e9b9d8e93e2e401c936ad193003, caldav server checker commit 3cae24cf99da1702b851b5a74a9b88c8e5317dad 2026-02-15 "search.text.category": False, "search.time-range.event.old-dates": False, @@ -1224,9 +1262,10 @@ def dotted_feature_set_list(self, compact=False): 'search.recurrences.includes-implicit.todo': {'support': 'unsupported'}, 'principal-search': {'support': 'ungraceful'}, 'freebusy-query.rfc4791': {'support': 'ungraceful'}, + "scheduling": {"support": "unsupported"}, 'old_flags': [ 'non_existing_raises_other', ## AuthorizationError instead of NotFoundError - 'no_scheduling', + 'no_supported_components_support', 'no_relships', ], 'test-calendar': {'cleanup-regime': 'wipe-calendar'}, @@ -1266,8 +1305,8 @@ def dotted_feature_set_list(self, compact=False): 'search.combined-is-logical-and': {'support': 'unsupported'}, 'sync-token': {'support': 'ungraceful'}, 'principal-search': {'support': 'unsupported'}, + "scheduling": {"support": "unsupported"}, 'old_flags': [ - 'no_scheduling', #'no_recurring_todo', ## todo ] } @@ -1289,6 +1328,10 @@ def dotted_feature_set_list(self, compact=False): ## Davis uses sabre/dav (same backend as Baikal), so hints are similar. ## TODO: consolidate, make a sabredav dict and let davis/baikal build on it davis = { + # Davis uses sabre/dav (same backend as Baikal): delivers iTIP notifications to the + # attendee inbox AND auto-schedules into their calendar (quirk behaviour). + "scheduling.mailbox": True, + "scheduling.mailbox.inbox-delivery": {"support": "quirk", "behaviour": "server delivers iTIP notification to inbox AND auto-schedules into calendar"}, "search.recurrences.expanded.todo": {"support": "unsupported"}, "search.recurrences.expanded.exception": {"support": "unsupported"}, "search.recurrences.includes-implicit.todo": {"support": "unsupported"}, @@ -1310,6 +1353,8 @@ def dotted_feature_set_list(self, compact=False): ## cannot be changed. The pre-provisioned "tasks" calendar supports VTODO only. ## VJOURNAL is not supported at all. ccs = { + ## scheduling.mailbox.inbox-delivery behaviour unknown until cross-user scheduling tests run + "scheduling.mailbox.inbox-delivery": {"support": "unknown"}, "save-load.journal": {"support": "unsupported"}, "save-load.todo.mixed-calendar": {"support": "unsupported"}, # CCS enforces unique UIDs across ALL calendars for a user @@ -1343,6 +1388,8 @@ def dotted_feature_set_list(self, compact=False): ## CalDAV served at /dav/cal// over HTTP on port 8080. ## Feature support mostly unknown until tested; starting with empty hints. stalwart = { + ## scheduling.mailbox.inbox-delivery behaviour unknown until cross-user scheduling tests run + "scheduling.mailbox.inbox-delivery": {"support": "unknown"}, 'rate-limit': { 'enable': True, 'default_sleep': 3, @@ -1401,9 +1448,10 @@ def dotted_feature_set_list(self, compact=False): 'basepath': '/webdav/', 'domain': 'purelymail.com', }, + ## Known, work in progress + "scheduling": {"support": "unsupported"}, 'old_flags': [ - ## Known, work in progress - 'no_scheduling', + 'no_supported_components_support', ], ## Known, not a breach of standard "get-supported-components": {"support": "unsupported"}, @@ -1445,11 +1493,14 @@ def dotted_feature_set_list(self, compact=False): ## was apparently observed working for a while, possibly due to the master/more_checks split-brain git branching incident in the server-checker project. ## unsupported in be26d42b1ca3ff3b4fd183761b4a9b024ce12b84 / 537a23b145487006bb987dee5ab9e00cdebb0492 2026-02-19. Supported when testing again short time after. Either I'm confused or it's "fragile". #'search.time-range.alarm': {'support': 'unsupported'}, + ## GMX advertises calendar-auto-schedule but inbox/mailbox and + ## calendar-user-address-set are not functional (RFC6638 sub-features). + "scheduling": {"support": "full"}, + "scheduling.mailbox": {"support": "unsupported"}, + "scheduling.calendar-user-address-set": {"support": "unsupported"}, "old_flags": [ - "no_scheduling_mailbox", #"text_search_is_case_insensitive", "no_search_openended", - "no_scheduling_calendar_user_address_set", "vtodo-cannot-be-uncompleted", ] } @@ -1495,6 +1546,8 @@ def dotted_feature_set_list(self, compact=False): 'old_flags': [ 'no_relships', ], + ## OX App Suite has complex user provisioning; cross-user scheduling tests not yet set up. + "scheduling.mailbox.inbox-delivery": {"support": "unknown"}, } # fmt: on diff --git a/examples/calendar_owner_examples.py b/examples/calendar_owner_examples.py new file mode 100644 index 00000000..2f7f6684 --- /dev/null +++ b/examples/calendar_owner_examples.py @@ -0,0 +1,101 @@ +""" +Examples for finding the owner of a calendar and looking up their address. + +Use case: when a calendar is shared with you, you may want to know who owns +it and how to reach them. + +See also: https://github.com/python-caldav/caldav/issues/544 +""" + +import sys + +sys.path.insert(0, "..") +sys.path.insert(0, ".") + +import caldav +from caldav import get_davclient +from caldav.elements import dav + + +def find_calendar_owner(calendar): + """ + Return the owner URL of a calendar. + + Uses the DAV:owner property (WebDAV RFC 4918, section 14.17). The owner + is returned as a URL string pointing to the owner's principal resource. + Returns None if the server does not expose the property. + + Args: + calendar: a :class:`caldav.Calendar` object + + Returns: + str | None: the owner's principal URL, or None + """ + return calendar.get_property(dav.Owner()) + + +def find_calendar_owner_address(calendar): + """ + Return the calendar-user-address (typically an e-mail URI like + ``mailto:user@example.com``) of a calendar's owner. + + This is a two-step operation: + + 1. Fetch the DAV:owner property of the calendar to get the owner's + principal URL. + 2. Construct a :class:`caldav.Principal` from that URL and call + :meth:`~caldav.Principal.get_vcal_address` to retrieve the + ``calendar-user-address-set`` property (RFC 6638 section 2.4.1). + + Requires the server to support both the DAV:owner property and the + ``CALDAV:calendar-user-address-set`` principal property. Returns None + when either piece of information is unavailable. + + Args: + calendar: a :class:`caldav.Calendar` object + + Returns: + icalendar.vCalAddress | None: the owner's calendar address, or None + """ + owner_url = find_calendar_owner(calendar) + if owner_url is None: + return None + + owner_principal = caldav.Principal(client=calendar.client, url=owner_url) + try: + return owner_principal.get_vcal_address() + except Exception: + return None + + +def run_examples(): + """ + Run the calendar-owner examples against a live server. + + Connects via :func:`caldav.get_davclient` (reads credentials from the + environment or config file), creates a temporary calendar, and + demonstrates how to retrieve its owner URL and calendar-user address. + """ + with get_davclient() as client: + principal = client.principal() + calendar = principal.make_calendar(name="Owner example calendar") + try: + owner_url = find_calendar_owner(calendar) + if owner_url is not None: + print(f"Calendar owner URL: {owner_url}") + + owner_address = find_calendar_owner_address(calendar) + if owner_address is not None: + print(f"Calendar owner address: {owner_address}") + else: + print( + "Calendar owner address: not available (server may not support calendar-user-address-set)" + ) + else: + print("DAV:owner property not exposed by this server") + finally: + calendar.delete() + + +if __name__ == "__main__": + run_examples() diff --git a/tests/caldav_test_servers.yaml.example b/tests/caldav_test_servers.yaml.example index dabef575..52825365 100644 --- a/tests/caldav_test_servers.yaml.example +++ b/tests/caldav_test_servers.yaml.example @@ -52,6 +52,7 @@ test-servers: port: ${NEXTCLOUD_PORT:-8801} username: ${NEXTCLOUD_USERNAME:-testuser} password: ${NEXTCLOUD_PASSWORD:-testpass} + # setup_nextcloud.sh creates user1-user3 (passwords testpass1-3) for scheduling tests. cyrus: type: docker @@ -60,6 +61,17 @@ test-servers: port: ${CYRUS_PORT:-8802} username: ${CYRUS_USERNAME:-testuser@test.local} password: ${CYRUS_PASSWORD:-testpassword} + # Cyrus pre-creates user1-user5 (password 'x'), enabling scheduling tests. + scheduling_users: + - url: http://${CYRUS_HOST:-localhost}:${CYRUS_PORT:-8802}/dav/calendars/user/user1 + username: user1@test.local + password: x + - url: http://${CYRUS_HOST:-localhost}:${CYRUS_PORT:-8802}/dav/calendars/user/user2 + username: user2@test.local + password: x + - url: http://${CYRUS_HOST:-localhost}:${CYRUS_PORT:-8802}/dav/calendars/user/user3 + username: user3@test.local + password: x sogo: type: docker @@ -84,6 +96,7 @@ test-servers: port: ${DAVICAL_PORT:-8805} username: ${DAVICAL_USERNAME:-testuser} password: ${DAVICAL_PASSWORD:-testpass} + # setup_davical.sh creates user1-user3 (passwords testpass1-3) for scheduling tests. davis: type: docker @@ -108,6 +121,7 @@ test-servers: port: ${ZIMBRA_PORT:-8808} username: ${ZIMBRA_USERNAME:-testuser@zimbra.io} password: ${ZIMBRA_PASSWORD:-testpass} + # start.sh creates testuser/testuser2/testuser3@zimbra.io (password testpass) for scheduling tests. stalwart: type: docker @@ -150,13 +164,49 @@ test-servers: # RFC6638 scheduling test users (optional) # ========================================================================= # -# For testing calendar scheduling (meeting invites, etc.), define -# multiple users that can send invites to each other: - +# Preferred: add scheduling_users inside a server block (as shown for Cyrus +# above). The registry merges it into the already-registered server, and +# pytest generates a TestSchedulingForServer class automatically. +# +# Baikal (user1-user3 in pre-seeded db.sqlite, passwords testpass1-3): +# +# baikal: +# scheduling_users: +# - url: http://localhost:8800/dav.php/ +# username: user1 +# password: testpass1 +# - url: http://localhost:8800/dav.php/ +# username: user2 +# password: testpass2 +# - url: http://localhost:8800/dav.php/ +# username: user3 +# password: testpass3 +# +# SOGo (user1-user3 from init-sogo-users.sql, passwords testpass1-3): +# +# sogo: +# scheduling_users: +# - url: http://localhost:8803/SOGo/dav/user1 +# username: user1 +# password: testpass1 +# - url: http://localhost:8803/SOGo/dav/user2 +# username: user2 +# password: testpass2 +# - url: http://localhost:8803/SOGo/dav/user3 +# username: user3 +# password: testpass3 +# +# Legacy: top-level rfc6638_users creates a single TestScheduling class +# that is not tied to any specific server in the test run. Prefer the +# per-server scheduling_users approach above. +# # rfc6638_users: -# - url: https://caldav.example.com/dav/user1/ +# - url: http://localhost:8802/dav/calendars/user/user1 # username: user1 -# password: pass1 -# - url: https://caldav.example.com/dav/user2/ +# password: x +# - url: http://localhost:8802/dav/calendars/user/user2 # username: user2 -# password: pass2 +# password: x +# - url: http://localhost:8802/dav/calendars/user/user3 +# username: user3 +# password: x diff --git a/tests/docker-test-servers/baikal/Specific/db/db.sqlite b/tests/docker-test-servers/baikal/Specific/db/db.sqlite index f8af389a..781d5444 100644 Binary files a/tests/docker-test-servers/baikal/Specific/db/db.sqlite and b/tests/docker-test-servers/baikal/Specific/db/db.sqlite differ diff --git a/tests/docker-test-servers/baikal/create_baikal_db.py b/tests/docker-test-servers/baikal/create_baikal_db.py index 13b2f155..bdf83dd1 100755 --- a/tests/docker-test-servers/baikal/create_baikal_db.py +++ b/tests/docker-test-servers/baikal/create_baikal_db.py @@ -232,6 +232,49 @@ def create_baikal_db(db_path: Path, username: str = "testuser", password: str = print(f" Digest A1: {ha1}") +def add_baikal_user(db_path: Path, username: str, password: str) -> None: + """Add an additional user to an existing Baikal SQLite database.""" + realm = "BaikalDAV" + ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest() + principal_uri = f"principals/{username}" + + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + cursor.execute( + "INSERT OR REPLACE INTO users (username, digesta1) VALUES (?, ?)", (username, ha1) + ) + + cursor.execute( + "INSERT OR IGNORE INTO principals (uri, email, displayname) VALUES (?, ?, ?)", + (principal_uri, f"{username}@baikal.test", f"Test User ({username})"), + ) + + cursor.execute( + "INSERT INTO calendars (synctoken, components) VALUES (?, ?)", + (1, "VEVENT,VTODO,VJOURNAL"), + ) + calendar_id = cursor.lastrowid + + cursor.execute( + """INSERT INTO calendarinstances + (calendarid, principaluri, access, displayname, uri, calendarorder, calendarcolor) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (calendar_id, principal_uri, 1, "Default Calendar", "default", 0, "#3a87ad"), + ) + + cursor.execute( + """INSERT INTO addressbooks + (principaluri, displayname, uri, synctoken) + VALUES (?, ?, ?, ?)""", + (principal_uri, "Default Address Book", "default", 1), + ) + + conn.commit() + conn.close() + print(f"✓ Added user '{username}' to Baikal database") + + def create_baikal_config(config_path: Path) -> None: """Create Baikal config.php file.""" @@ -358,10 +401,14 @@ def create_baikal_yaml(yaml_path: Path) -> None: if __name__ == "__main__": script_dir = Path(__file__).parent - # Create database + # Create database with primary test user db_path = script_dir / "Specific" / "db" / "db.sqlite" create_baikal_db(db_path, username="testuser", password="testpass") + # Add extra users for RFC6638 scheduling tests (need at least 3) + for i in range(1, 4): + add_baikal_user(db_path, username=f"user{i}", password=f"testpass{i}") + # Create legacy PHP config files (for older Baikal versions) config_path = script_dir / "Specific" / "config.php" create_baikal_config(config_path) @@ -380,4 +427,5 @@ def create_baikal_yaml(yaml_path: Path) -> None: print("\nCredentials:") print(" Admin: admin / admin") print(" User: testuser / testpass") + print(" RFC6638 users: user1/testpass1, user2/testpass2, user3/testpass3") print(" CalDAV URL: http://localhost:8800/dav.php/") diff --git a/tests/docker-test-servers/baikal/setup_baikal.sh b/tests/docker-test-servers/baikal/setup_baikal.sh index f06864e1..b3421912 100755 --- a/tests/docker-test-servers/baikal/setup_baikal.sh +++ b/tests/docker-test-servers/baikal/setup_baikal.sh @@ -1,52 +1,60 @@ #!/bin/bash -# Setup script for Baikal CalDAV server for testing +# Setup script for Baikal CalDAV server for testing. # -# This script helps configure a fresh Baikal installation for testing. -# It can be used both locally and in CI environments. +# Baikal is pre-configured via the committed Specific/ directory which contains +# a pre-seeded db.sqlite with testuser and user1-user3 for scheduling tests. +# This script verifies connectivity and re-seeds users if needed (e.g. if the +# DB was replaced or users were deleted). set -e +CONTAINER_NAME="baikal-test" BAIKAL_URL="${BAIKAL_URL:-http://localhost:8800}" -BAIKAL_ADMIN_PASSWORD="${BAIKAL_ADMIN_PASSWORD:-admin}" -BAIKAL_USERNAME="${BAIKAL_USERNAME:-testuser}" -BAIKAL_PASSWORD="${BAIKAL_PASSWORD:-testpass}" +DB_PATH="/var/www/baikal/Specific/db/db.sqlite" +REALM="BaikalDAV" -echo "Setting up Baikal CalDAV server at $BAIKAL_URL" - -# Wait for Baikal to be ready echo "Waiting for Baikal to be ready..." -timeout 60 bash -c "until curl -f $BAIKAL_URL/ 2>/dev/null; do echo 'Waiting...'; sleep 2; done" || { - echo "Error: Baikal did not become ready in time" - exit 1 -} +max_attempts=30 +for i in $(seq 1 $max_attempts); do + if curl -sf "$BAIKAL_URL/" -o /dev/null 2>/dev/null; then + echo "✓ Baikal is ready" + break + fi + if [ $i -eq $max_attempts ]; then + echo "✗ Baikal did not become ready in time" + exit 1 + fi + echo -n "." + sleep 2 +done -echo "Baikal is ready!" +echo "" +echo "Seeding test users (idempotent)..." -# Note: Baikal requires initial configuration through web interface or config files -# For automated testing, you may need to: -# 1. Pre-configure Baikal by mounting a pre-configured config directory -# 2. Use the Baikal API if available -# 3. Manually configure once and export the config +add_user() { + local username="$1" + local password="$2" + local email="$3" + local displayname="$4" + local ha1 + ha1=$(python3 -c "import hashlib; print(hashlib.md5('${username}:${REALM}:${password}'.encode()).hexdigest())") + docker exec "$CONTAINER_NAME" sqlite3 "$DB_PATH" \ + "INSERT OR IGNORE INTO users (username, digesta1) VALUES ('${username}', '${ha1}'); + INSERT OR IGNORE INTO principals (uri, email, displayname) VALUES ('principals/${username}', '${email}', '${displayname}');" + echo " ${username}: OK" +} + +add_user "testuser" "testpass" "testuser@example.com" "Test User" +add_user "user1" "testpass1" "user1@example.com" "User 1" +add_user "user2" "testpass2" "user2@example.com" "User 2" +add_user "user3" "testpass3" "user3@example.com" "User 3" echo "" -echo "================================================================" -echo "IMPORTANT: Baikal Initial Configuration Required" -echo "================================================================" -echo "" -echo "Baikal requires initial setup through the web interface or" -echo "by providing pre-configured files." -echo "" -echo "For automated testing, you have several options:" -echo "" -echo "1. Access $BAIKAL_URL in your browser and complete the setup" -echo " - Set admin password to: $BAIKAL_ADMIN_PASSWORD" -echo " - Create a test user: $BAIKAL_USERNAME / $BAIKAL_PASSWORD" -echo "" -echo "2. Mount a pre-configured Baikal config directory:" -echo " - Configure Baikal once" -echo " - Export the config directory" -echo " - Mount it in docker-compose.yml or CI" +echo "✓ Baikal setup complete!" echo "" -echo "3. For CI/CD: See tests/baikal-config/ for sample configs" +echo "Credentials:" +echo " Test user: testuser / testpass" +echo " Scheduling users: user1/testpass1, user2/testpass2, user3/testpass3" +echo " CalDAV URL: $BAIKAL_URL/dav.php" +echo " Auth type: Digest (realm: $REALM)" echo "" -echo "================================================================" diff --git a/tests/docker-test-servers/cyrus/docker-compose.yml b/tests/docker-test-servers/cyrus/docker-compose.yml index 5e0aa3ad..05423e5f 100644 --- a/tests/docker-test-servers/cyrus/docker-compose.yml +++ b/tests/docker-test-servers/cyrus/docker-compose.yml @@ -11,7 +11,13 @@ services: environment: - DEFAULTDOMAIN=example.com - SERVERNAME=cyrus-test - # No volumes - let data be ephemeral for testing + volumes: + # Override the imapd.conf template to set virtdomains: off. + # This fixes iTIP scheduling delivery: with virtdomains: userid the + # caladdress_lookup() function preserves user2@example.com as the + # userid, but mailbox ACLs use the short form user2, causing 403. + - ./imapd.conf:/srv/cyrus-docker-test-server.git/imapd.conf:ro + # Other data remains ephemeral for testing # This ensures each start is fresh with newly created users healthcheck: test: ["CMD", "curl", "-s", "http://localhost:8080/"] diff --git a/tests/docker-test-servers/cyrus/imapd.conf b/tests/docker-test-servers/cyrus/imapd.conf new file mode 100644 index 00000000..ba157cb1 --- /dev/null +++ b/tests/docker-test-servers/cyrus/imapd.conf @@ -0,0 +1,70 @@ +admins: admin +allowplaintext: yes +altnamespace: no +auditlog: yes +caldav_allowattach: yes +caldav_create_attach: yes +caldav_realm: test +configdirectory: /var/imap/config +conversations_counted_flags: \Draft \Flagged +conversations_max_thread: 1000 +conversations: yes +crossdomains: yes +debug: yes +defaultacl: admin lrswipkxtecdan +defaultdomain: {{DEFAULTDOMAIN}} +defaultpartition: default +defaultsearchtier: t1 +delete_mode: delayed +expunge_mode: delayed +httpmodules: caldav carddav jmap +httpallowcompress: no +httpprettytelemetry: yes +imipnotifier: imip +improved_mboxlist_sort: yes +jmapauth_allowsasl: yes +jmap_max_calls_in_request: 256 +jmap_max_size_request: 51200 +jmap_max_size_upload: 209715200 +jmap_nonstandard_extensions: yes +jmap_preview_annot: /shared/vendor/messagingengine.com/preview +jmap_querycache_max_age: 5m +jmap_set_has_attachment: no +# this is buggy, turn it off +jmap_vacation: no +maxheaderlines: 4096 +maxmessagesize: 209715200 +maxquoted: 8388608 +maxword: 8388608 +partition-default: /var/imap/spool +reverseacls: yes +rfc3028_strict: no +sasl_mech_list: PLAIN LOGIN +sasl_pwcheck_method: saslauthd +sasl_saslauthd_path: /var/run/cyrus/saslauthd.sock +savedate: yes +search_engine: xapian +search_index_headers: no +search_batchsize: 512 +search_fuzzy_always: yes +search_maxtime: 30 +search_snippet_length: 160 +search_normalisation_max: 20000 +search_index_language: yes +servername: {{SERVERNAME}} +sievedir: /var/imap/sieve +smtp_backend: host +smtp_host: localhost:25 +sync_log: yes +sync_log_channels: squatter +t1searchpartition-default: /var/imap/search +unixhierarchysep: no +# virtdomains: userid is intentionally disabled here. +# With virtdomains: userid, caladdress_lookup() preserves the full email +# form (user2@example.com) as sparam->userid. But mailbox ACLs and +# mbname_userid() use the short form (user2) for default-domain users. +# This mismatch causes caldav_store_preprocess() to fail the ACL check +# with 403 Forbidden when delivering iTIP invites to the attendee's calendar. +# Setting virtdomains: off makes caladdress_lookup() strip the domain for +# local users, so the userid matches the ACL entry. +virtdomains: off diff --git a/tests/docker-test-servers/davical/setup_davical.sh b/tests/docker-test-servers/davical/setup_davical.sh index 20cfd6b1..857a29eb 100755 --- a/tests/docker-test-servers/davical/setup_davical.sh +++ b/tests/docker-test-servers/davical/setup_davical.sh @@ -34,25 +34,41 @@ for i in $(seq 1 $max_attempts); do sleep 3 done +create_user() { + local username="$1" + local password="$2" + local fullname="$3" + EXISTING=$(run_sql "SELECT username FROM usr WHERE username='${username}'") + if [ -n "$EXISTING" ]; then + echo "User '${username}' already exists, skipping creation" + else + run_sql "INSERT INTO usr (username, password, fullname, email) VALUES ('${username}', '**${password}', '${fullname}', '${username}@example.com')" + echo "User '${username}' created" + fi + EXISTING_PRINCIPAL=$(run_sql "SELECT principal_id FROM principal p JOIN usr u ON p.user_no = u.user_no WHERE u.username='${username}'") + if [ -n "$EXISTING_PRINCIPAL" ]; then + echo "Principal for '${username}' already exists, skipping" + else + # default_privileges: schedule-deliver (7168 = bits for schedule-deliver-invite + + # schedule-deliver-reply + schedule-query-freebusy) so other users can send + # scheduling invites/replies to this principal's inbox. + run_sql "INSERT INTO principal (type_id, user_no, displayname, default_privileges) SELECT 1, user_no, fullname, 7168::BIT(24) FROM usr WHERE username='${username}'" + echo "Principal for '${username}' created" + fi + # Ensure default_privileges is set even for existing principals (idempotent update) + run_sql "UPDATE principal SET default_privileges = 7168::BIT(24) FROM usr WHERE principal.user_no = usr.user_no AND usr.username = '${username}' AND (default_privileges IS NULL OR default_privileges = 0::BIT(24))" +} + echo "" -echo "Creating test user..." -# Check if user already exists -EXISTING=$(run_sql "SELECT username FROM usr WHERE username='${TEST_USER}'") -if [ -n "$EXISTING" ]; then - echo "User '${TEST_USER}' already exists, skipping creation" -else - run_sql "INSERT INTO usr (username, password, fullname, email) VALUES ('${TEST_USER}', '**${TEST_PASSWORD}', 'Test User', '${TEST_USER}@example.com')" - echo "User created" -fi +echo "Creating test users..." +create_user "${TEST_USER}" "${TEST_PASSWORD}" "Test User" +create_user "user1" "testpass1" "User One" +create_user "user2" "testpass2" "User Two" +create_user "user3" "testpass3" "User Three" -echo "Creating principal entry..." -EXISTING_PRINCIPAL=$(run_sql "SELECT principal_id FROM principal p JOIN usr u ON p.user_no = u.user_no WHERE u.username='${TEST_USER}'") -if [ -n "$EXISTING_PRINCIPAL" ]; then - echo "Principal already exists, skipping" -else - run_sql "INSERT INTO principal (type_id, user_no, displayname) SELECT 1, user_no, fullname FROM usr WHERE username='${TEST_USER}'" - echo "Principal created" -fi +echo "" +echo "Enabling local scheduling in DAViCal config..." +docker exec "$DAVICAL_CONTAINER" sed -i 's|// \$c->enable_scheduling = true;|\$c->enable_scheduling = true;|' /etc/davical/config.php || true echo "" echo "Verifying CalDAV access..." @@ -79,4 +95,5 @@ echo "" echo "Credentials:" echo " Admin: admin / testpass" echo " Test user: ${TEST_USER} / ${TEST_PASSWORD}" +echo " Scheduling users: user1/testpass1, user2/testpass2, user3/testpass3" echo " CalDAV URL: http://localhost:8805/caldav.php/${TEST_USER}/" diff --git a/tests/docker-test-servers/davis/docker-compose.yml b/tests/docker-test-servers/davis/docker-compose.yml index f5c0fd1b..87ea5b3c 100644 --- a/tests/docker-test-servers/davis/docker-compose.yml +++ b/tests/docker-test-servers/davis/docker-compose.yml @@ -17,6 +17,10 @@ services: - AUTH_METHOD=Basic - APP_SECRET=testserversecret123 - INVITE_FROM_ADDRESS=test@example.com + # Disable SMTP: without a null mailer, sabre/dav tries to send email + # notifications on every PUT/DELETE of a scheduled event and returns 500 + # when it cannot connect to the SMTP server. + - MAILER_DSN=null://null tmpfs: - /data:size=100m healthcheck: diff --git a/tests/docker-test-servers/davis/setup_davis.sh b/tests/docker-test-servers/davis/setup_davis.sh index 009bf6e9..f2a9a879 100755 --- a/tests/docker-test-servers/davis/setup_davis.sh +++ b/tests/docker-test-servers/davis/setup_davis.sh @@ -16,6 +16,17 @@ TEST_PASSWORD="testpass" AUTH_REALM="SabreDAV" CONSOLE="php /var/www/davis/bin/console" +create_user() { + local username="$1" + local password="$2" + local digest + digest=$(echo -n "${username}:${AUTH_REALM}:${password}" | md5sum | awk '{print $1}') + run_sql "INSERT OR IGNORE INTO users (username, digesta1) VALUES ('${username}', '${digest}')" + run_sql "INSERT OR IGNORE INTO principals (uri, email, displayname, is_main, is_admin) VALUES ('principals/${username}', '${username}@example.com', '${username}', 1, 0)" + run_sql "INSERT OR IGNORE INTO principals (uri, email, displayname, is_main, is_admin) VALUES ('principals/${username}/calendar-proxy-read', NULL, NULL, 0, 0)" + run_sql "INSERT OR IGNORE INTO principals (uri, email, displayname, is_main, is_admin) VALUES ('principals/${username}/calendar-proxy-write', NULL, NULL, 0, 0)" +} + run_sql() { docker exec "$CONTAINER_NAME" $CONSOLE dbal:run-sql "$1" 2>&1 } @@ -46,22 +57,13 @@ echo "Running database migrations..." docker exec "$CONTAINER_NAME" $CONSOLE doctrine:migrations:migrate --no-interaction 2>&1 echo "" -echo "Computing digest hash..." -DIGEST=$(echo -n "${TEST_USER}:${AUTH_REALM}:${TEST_PASSWORD}" | md5sum | awk '{print $1}') -echo "Digest: ${DIGEST}" - -echo "" -echo "Creating test user in database..." -run_sql "INSERT INTO users (username, digesta1) VALUES ('${TEST_USER}', '${DIGEST}')" - -echo "Creating principal entries..." -# sabre/dav requires principal entries for CalDAV to work -# The principals table has is_main and is_admin boolean columns -run_sql "INSERT INTO principals (uri, email, displayname, is_main, is_admin) VALUES ('principals/${TEST_USER}', '${TEST_USER}@example.com', 'Test User', 1, 0)" +echo "Creating test users in database..." +create_user "${TEST_USER}" "${TEST_PASSWORD}" -# Calendar-proxy principals that sabre/dav expects for delegation -run_sql "INSERT INTO principals (uri, email, displayname, is_main, is_admin) VALUES ('principals/${TEST_USER}/calendar-proxy-read', NULL, NULL, 0, 0)" -run_sql "INSERT INTO principals (uri, email, displayname, is_main, is_admin) VALUES ('principals/${TEST_USER}/calendar-proxy-write', NULL, NULL, 0, 0)" +# Additional users for RFC6638 scheduling tests +create_user "user1" "testpass1" +create_user "user2" "testpass2" +create_user "user3" "testpass3" echo "" echo "Verifying CalDAV access..." diff --git a/tests/docker-test-servers/nextcloud/setup_nextcloud.sh b/tests/docker-test-servers/nextcloud/setup_nextcloud.sh index 6e2f0a8e..5d5382e6 100755 --- a/tests/docker-test-servers/nextcloud/setup_nextcloud.sh +++ b/tests/docker-test-servers/nextcloud/setup_nextcloud.sh @@ -27,9 +27,15 @@ echo "" echo "Disabling password policy for testing..." docker exec $CONTAINER_NAME php occ app:disable password_policy || true -echo "Creating test user..." +echo "Creating test users..." # Create test user (ignore error if already exists) docker exec -e OC_PASS="$TEST_PASSWORD" $CONTAINER_NAME php occ user:add --password-from-env --display-name="Test User" $TEST_USER 2>/dev/null || echo "User may already exist" +# Create scheduling test users +for i in 1 2 3; do + docker exec -e OC_PASS="testpass${i}" $CONTAINER_NAME php occ user:add --password-from-env --display-name="User ${i}" "user${i}" 2>/dev/null || echo "user${i} may already exist" + # Set email address — required for CalDAV scheduling (calendar-user-address-set) + docker exec $CONTAINER_NAME php occ user:setting "user${i}" settings email "user${i}@localhost" || true +done echo "Enabling calendar app..." docker exec $CONTAINER_NAME php occ app:enable calendar || true @@ -37,8 +43,21 @@ docker exec $CONTAINER_NAME php occ app:enable calendar || true echo "Enabling contacts app..." docker exec $CONTAINER_NAME php occ app:enable contacts || true -echo "Disabling rate limiting for testing..." -#docker exec $CONTAINER_NAME php occ config:system:set ratelimit.enabled --value=false --type=boolean || true +echo "Configuring bruteforce protection..." +# Temporarily enable bruteforce protection so we can reset accumulated failed +# auth attempts (which pile up while the server is starting before users exist). +docker exec $CONTAINER_NAME php occ config:system:set auth.bruteforce.protection.enabled --value=true --type=boolean || true +for ip in 127.0.0.1 ::1; do + docker exec $CONTAINER_NAME php occ security:bruteforce:reset "$ip" 2>/dev/null || true +done +# Detect the Docker gateway IP and reset it too +GATEWAY_IP=$(docker exec $CONTAINER_NAME sh -c "ip route | awk '/default/{print \$3}'" 2>/dev/null || true) +if [ -n "$GATEWAY_IP" ]; then + docker exec $CONTAINER_NAME php occ security:bruteforce:reset "$GATEWAY_IP" 2>/dev/null || true +fi +# Now disable bruteforce protection — the caldav library handles 429 via +# rate_limit_handle, but Nextcloud's bruteforce gives no Retry-After header +# and would make tests slow. docker exec $CONTAINER_NAME php occ app:disable bruteforcesettings || true docker exec $CONTAINER_NAME php occ config:system:set auth.bruteforce.protection.enabled --value=false --type=boolean || true @@ -47,8 +66,9 @@ docker exec $CONTAINER_NAME php occ config:app:set dav rateLimitCalendarCreation docker exec $CONTAINER_NAME php occ config:app:set dav maximumCalendarsSubscriptions --value=-1 || true echo "Adding IP whitelist for rate limiting..." -docker exec $CONTAINER_NAME php occ config:system:set ratelimit.whitelist.0 --value='172.19.0.0/16' || true -docker exec $CONTAINER_NAME php occ config:system:set ratelimit.whitelist.1 --value='127.0.0.1' || true +# Service is test-only and never exposed externally, so whitelist everything +docker exec $CONTAINER_NAME php occ config:system:set ratelimit.whitelist.0 --value='0.0.0.0/0' || true +docker exec $CONTAINER_NAME php occ config:system:set ratelimit.whitelist.1 --value='::/0' || true echo "Clearing rate limit cache..." docker exec $CONTAINER_NAME php -r " @@ -64,5 +84,6 @@ echo "" echo "Credentials:" echo " Admin: admin / admin" echo " Test user: $TEST_USER / $TEST_PASSWORD" +echo " Scheduling users: user1/testpass1, user2/testpass2, user3/testpass3" echo " CalDAV URL: http://localhost:8801/remote.php/dav" echo "" diff --git a/tests/docker-test-servers/sogo/init-sogo-users.sql b/tests/docker-test-servers/sogo/init-sogo-users.sql index 227dfeb7..dc8f68c1 100644 --- a/tests/docker-test-servers/sogo/init-sogo-users.sql +++ b/tests/docker-test-servers/sogo/init-sogo-users.sql @@ -14,3 +14,16 @@ CREATE TABLE IF NOT EXISTS sogo_users ( INSERT INTO sogo_users (c_uid, c_name, c_password, c_cn, mail) VALUES ('testuser', 'testuser', MD5('testpass'), 'Test User', 'testuser@example.com') ON DUPLICATE KEY UPDATE c_password=MD5('testpass'); + +-- Additional users for RFC6638 scheduling tests (need at least 3 users) +INSERT INTO sogo_users (c_uid, c_name, c_password, c_cn, mail) +VALUES ('user1', 'user1', MD5('testpass1'), 'Test User 1', 'user1@example.com') +ON DUPLICATE KEY UPDATE c_password=MD5('testpass1'); + +INSERT INTO sogo_users (c_uid, c_name, c_password, c_cn, mail) +VALUES ('user2', 'user2', MD5('testpass2'), 'Test User 2', 'user2@example.com') +ON DUPLICATE KEY UPDATE c_password=MD5('testpass2'); + +INSERT INTO sogo_users (c_uid, c_name, c_password, c_cn, mail) +VALUES ('user3', 'user3', MD5('testpass3'), 'Test User 3', 'user3@example.com') +ON DUPLICATE KEY UPDATE c_password=MD5('testpass3'); diff --git a/tests/docker-test-servers/stalwart/setup_stalwart.sh b/tests/docker-test-servers/stalwart/setup_stalwart.sh index 4fddb0d6..e71b2b6b 100755 --- a/tests/docker-test-servers/stalwart/setup_stalwart.sh +++ b/tests/docker-test-servers/stalwart/setup_stalwart.sh @@ -29,6 +29,28 @@ api_post() { -d "${body}" } +create_user() { + local username="$1" + local password="$2" + local result + result=$(api_post "/principal" "{ + \"type\": \"individual\", + \"name\": \"${username}\", + \"secrets\": [\"${password}\"], + \"emails\": [\"${username}@${DOMAIN}\"], + \"roles\": [\"user\"] + }") + if echo "$result" | grep -q '"error"'; then + if echo "$result" | grep -q '"fieldAlreadyExists"'; then + echo "User '${username}' already exists (OK)" + else + echo "Warning: user '${username}' creation returned: $result" + fi + else + echo "User '${username}' created" + fi +} + echo "Waiting for Stalwart HTTP endpoint to be ready..." max_attempts=60 for i in $(seq 1 $max_attempts); do @@ -75,6 +97,11 @@ else echo "User created: $RESULT" fi +# Additional users for RFC6638 scheduling tests +create_user "user1" "testpass1" +create_user "user2" "testpass2" +create_user "user3" "testpass3" + echo "" echo "Verifying CalDAV access..." max_caldav_attempts=15 diff --git a/tests/docker-test-servers/zimbra/start.sh b/tests/docker-test-servers/zimbra/start.sh index 014809ed..2e0f267c 100755 --- a/tests/docker-test-servers/zimbra/start.sh +++ b/tests/docker-test-servers/zimbra/start.sh @@ -53,6 +53,8 @@ docker exec zimbra-test su - zimbra -c "zmprov ca testuser@$ZIMBRA_DOMAIN testpa echo " testuser already exists (or creation failed)" docker exec zimbra-test su - zimbra -c "zmprov ca testuser2@$ZIMBRA_DOMAIN testpass" 2>/dev/null || \ echo " testuser2 already exists (or creation failed)" +docker exec zimbra-test su - zimbra -c "zmprov ca testuser3@$ZIMBRA_DOMAIN testpass" 2>/dev/null || \ + echo " testuser3 already exists (or creation failed)" # Verify CalDAV is responding echo "Verifying CalDAV endpoint..." @@ -68,6 +70,7 @@ echo "" echo "Zimbra is running on https://$ZIMBRA_FQDN:8808/" echo " Users: testuser@$ZIMBRA_DOMAIN / testpass" echo " testuser2@$ZIMBRA_DOMAIN / testpass" +echo " testuser3@$ZIMBRA_DOMAIN / testpass" echo "" echo "Run tests from project root:" echo " cd ../../.." diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 86967532..e7a24de2 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -670,15 +670,11 @@ def test_multi_server_meta_section(self) -> None: assert len(clients) == 2 -@pytest.mark.skipif( - not rfc6638_users, reason="need rfc6638_users to be set in order to run this test" -) -@pytest.mark.skipif( - len(rfc6638_users) < 3, - reason="need at least three users in rfc6638_users to be set in order to run this test", -) -class TestScheduling: - """Testing support of RFC6638. +class _TestSchedulingBase: + """ + Base class for RFC6638 scheduling tests. Not collected directly by + pytest (no ``Test`` prefix); concrete subclasses supply ``_users``. + TODO: work in progress. Stalled a bit due to lack of proper testing accounts. I haven't managed to get this test to pass at any systems yet, but I believe the problem is not on the library side. * icloud: cannot really test much with only one test account available. I did some testing forth and back with emails sent @@ -701,19 +697,28 @@ class TestScheduling: RFC6638. """ + ## Subclasses set this to the list of user connection dicts to use. + _users: list[dict] = [] + def _getCalendar(self, i): calendar_id = "schedulingnosetestcalendar%i" % i calendar_name = "caldav scheduling test %i" % i try: - self.principals[i].calendar(name=calendar_name).delete() + cal = self.principals[i].calendar(name=calendar_name) + ## Calendar already exists — clear its contents rather than delete+recreate, + ## since some servers (e.g. Nextcloud) move deleted calendars to a trash bin + ## and block re-creation with the same cal_id. + for obj in cal.objects(): + obj.delete() + return cal except error.NotFoundError: - pass - return self.principals[i].make_calendar(name=calendar_name, cal_id=calendar_id) + return self.principals[i].make_calendar(name=calendar_name, cal_id=calendar_id) def setup_method(self): self.clients = [] self.principals = [] - for foo in rfc6638_users: + self._auto_scheduled_event_uids = [] + for foo in self._users: c = client(**foo) if not c.check_scheduling_support(): continue ## ignoring user because server does not support scheduling. @@ -724,9 +729,28 @@ def teardown_method(self): for i in range(0, len(self.principals)): calendar_name = "caldav scheduling test %i" % i try: - self.principals[i].calendar(name=calendar_name).delete() + cal = self.principals[i].calendar(name=calendar_name) + ## Clear events rather than deleting the calendar: some servers + ## (e.g. Nextcloud) soft-delete calendars to a trash bin, blocking + ## re-creation with the same cal_id on the next test run. + for obj in cal.objects(): + try: + obj.delete() + except Exception: + pass except error.NotFoundError: pass + ## Clean up any auto-scheduled events that the server placed in non-test + ## calendars (e.g. Cyrus delivers to the Default calendar). + if self._auto_scheduled_event_uids: + for principal in self.principals: + for cal in principal.calendars(): + for event in cal.get_events(): + try: + if event.id in self._auto_scheduled_event_uids: + event.delete() + except Exception: + pass for c in self.clients: c.__exit__() @@ -745,39 +769,144 @@ def testInviteAndRespond(self): ## self.principal[0] is the organizer, and invites self.principal[1] organizers_calendar = self._getCalendar(0) attendee_calendar = self._getCalendar(1) - organizers_calendar.save_with_invites( + saved_event = organizers_calendar.save_with_invites( sched, [self.principals[0], self.principals[1].get_vcal_address()] ) + event_uid = saved_event.id + self._auto_scheduled_event_uids.append(event_uid) assert len(organizers_calendar.get_events()) == 1 - ## no new inbox items expected for principals[0] + ## Check attendee's inbox and calendars. Some servers (e.g. Zimbra, CCS) + ## process scheduling asynchronously, so poll with backoff before giving up. + new_attendee_inbox_items = [] + auto_scheduled = False + for _ in range(30): + new_attendee_inbox_items = [ + item + for item in self.principals[1].schedule_inbox().get_items() + if item.url not in inbox_items + ] + ## Check whether the server auto-scheduled the event directly into + ## the attendee's calendar (server-side automatic scheduling). + ## The event may land in any calendar (e.g. Cyrus uses Default, not the + ## test calendar), so search all attendee calendars for the event UID. + auto_scheduled = any( + event.id == event_uid + for cal in self.principals[1].calendars() + for event in cal.get_events() + ) + if new_attendee_inbox_items or auto_scheduled: + break + time.sleep(1) + + if len(new_attendee_inbox_items) == 0 or auto_scheduled: + ## Server implements automatic scheduling. Some servers (e.g. + ## Cyrus) may additionally deliver an iTIP copy to the inbox as + ## a notification, but the acceptance is already done. + assert auto_scheduled, ( + "Expected invite in attendee inbox OR event auto-added to attendee calendar, got neither" + ) + return + + ## Normal inbox-delivery flow (RFC6638 section 4.1). + + ## no new inbox items expected for principals[0] yet for item in self.principals[0].schedule_inbox().get_items(): assert item.url in inbox_items - ## principals[1] should have one new inbox item - new_inbox_items = [] - for item in self.principals[1].schedule_inbox().get_items(): - if item.url not in inbox_items: - new_inbox_items.append(item) - assert len(new_inbox_items) == 1 + assert len(new_attendee_inbox_items) == 1 ## ... and the new inbox item should be an invite request - assert new_inbox_items[0].is_invite_request() + assert new_attendee_inbox_items[0].is_invite_request() ## Approving the invite - new_inbox_items[0].accept_invite(calendar=attendee_calendar) + new_attendee_inbox_items[0].accept_invite(calendar=attendee_calendar) ## (now, this item should probably appear on a calendar somewhere ... ## TODO: make asserts on that) ## TODO: what happens if we delete that invite request now? ## principals[0] should now have a notification in the inbox that the ## calendar invite was accepted - new_inbox_items = [] - for item in self.principals[0].schedule_inbox().get_items(): - if item.url not in inbox_items: - new_inbox_items.append(item) - assert len(new_inbox_items) == 1 - assert new_inbox_items[0].is_invite_reply() - new_inbox_items[0].delete() + new_organizer_inbox_items = [ + item + for item in self.principals[0].schedule_inbox().get_items() + if item.url not in inbox_items + ] + assert len(new_organizer_inbox_items) == 1 + assert new_organizer_inbox_items[0].is_invite_reply() + new_organizer_inbox_items[0].delete() + + def testAcceptInviteUsernameEmailFallback(self): + """accept_invite() works when the invite was built with username-as-email (issue #399). + + The invite is constructed using the attendee's login username directly + instead of get_vcal_address(), mirroring what a client must do when the + server does not expose calendar-user-address-set. + + On servers that expose calendar-user-address-set the normal code path + runs inside accept_invite(); on servers that do not, the username-email + fallback introduced by the fix kicks in. Both paths produce the same + observable outcome (PARTSTAT updated, invite accepted), so this test is + valid regardless of whether calendar-user-address-set is available. + + Only runs when: + - two principals are available + - the server delivers iTIP requests to the inbox + - the attendee's login username is an email address + """ + if len(self.principals) < 2: + pytest.skip("need 2 principals to do the invite and respond test") + + attendee_client = self.clients[1] + if not attendee_client.features.is_supported("scheduling.mailbox.inbox-delivery"): + pytest.skip("server does not deliver iTIP requests to the inbox") + attendee_username = getattr(attendee_client, "username", None) + if not attendee_username or "@" not in str(attendee_username): + pytest.skip( + "Attendee username %r is not an email address; " + "cannot build a matching ATTENDEE line" % attendee_username + ) + attendee_email = "mailto:" + attendee_username + + inbox_items = set(x.url for x in self.principals[0].schedule_inbox().get_items()) + inbox_items.update(x.url for x in self.principals[1].schedule_inbox().get_items()) + + organizers_calendar = self._getCalendar(0) + attendee_calendar = self._getCalendar(1) + ## Build the invite using the attendee's email directly, since + ## get_vcal_address() would also fail without calendar-user-address-set. + ## Use a fresh UUID so Zimbra (and other servers) don't treat this as a + ## duplicate of the event sent by testInviteAndRespond. Both tests use + ## the module-level `sched` which has the same UID for the whole test + ## session, so reusing it causes Zimbra to silently skip inbox delivery. + fresh_sched = sched_template % ( + str(uuid.uuid4()), + "%2i%2i%2i" % (random.randint(0, 23), random.randint(0, 59), random.randint(0, 59)), + random.randint(1, 28), + "%2i%2i%2i" % (random.randint(0, 23), random.randint(0, 59), random.randint(0, 59)), + ) + saved_event = organizers_calendar.save_with_invites( + fresh_sched, [self.principals[0], attendee_email] + ) + self._auto_scheduled_event_uids.append(saved_event.id) + + new_attendee_inbox_items = [] + for _ in range(30): + new_attendee_inbox_items = [ + item + for item in self.principals[1].schedule_inbox().get_items() + if item.url not in inbox_items + ] + if new_attendee_inbox_items: + break + time.sleep(1) + + assert len(new_attendee_inbox_items) == 1, ( + "expected exactly one new inbox item for attendee" + ) + assert new_attendee_inbox_items[0].is_invite_request() + + ## accept_invite() must work via the username-email fallback. + new_attendee_inbox_items[0].accept_invite(calendar=attendee_calendar) ## TODO. Invite two principals, let both of them load the ## invitation, and then let them respond in order. Lacks both @@ -790,6 +919,15 @@ def testInviteAndRespond(self): ## inbox/outbox? +## Legacy: run TestScheduling against the top-level rfc6638_users config. +if rfc6638_users: + TestScheduling = type( + "TestScheduling", + (_TestSchedulingBase,), + {"_users": rfc6638_users}, + ) + + def _delay_decorator(f, t=20): def foo(*a, **kwa): time.sleep(t) @@ -1050,7 +1188,32 @@ def testCheckCompatibility(self, request) -> None: # Use pdb debug mode if pytest was run with --pdb, otherwise use logging debug_mode = "pdb" if request.config.option.usepdb else "logging" - checker = ServerQuirkChecker(self.caldav, debug_mode=debug_mode) + + ## Build extra clients from scheduling_users (skip index 0; main client covers that user) + extra_clients = [] + for user_params in self.server_params.get("scheduling_users", [])[1:]: + params = { + k: v for k, v in user_params.items() if k not in ("name", "setup", "teardown") + } + try: + ec = client(**params) + ec.__enter__() + extra_clients.append(ec) + except Exception: + pass + + try: + checker = ServerQuirkChecker( + self.caldav, debug_mode=debug_mode, extra_clients=extra_clients + ) + checker.check_all() + checker.cleanup(force=False) + finally: + for ec in extra_clients: + try: + ec.__exit__(None, None, None) + except Exception: + pass checker.check_all() checker.cleanup(force=False) @@ -1093,27 +1256,79 @@ def testSupport(self): self.skip_on_compatibility_flag("dav_not_supported") assert self.caldav.check_dav_support() assert self.caldav.check_cdav_support() - if self.check_compatibility_flag("no_scheduling"): - assert not self.caldav.check_scheduling_support() - else: - assert self.caldav.check_scheduling_support() + assert self.caldav.check_scheduling_support() == self.is_supported("scheduling") def testSchedulingInfo(self): - self.skip_on_compatibility_flag("no_scheduling") - self.skip_on_compatibility_flag("no_scheduling_calendar_user_address_set") + self.skip_unless_support("scheduling.calendar-user-address-set") calendar_user_address_set = self.principal.calendar_user_address_set() me_a_participant = self.principal.get_vcal_address() + def testIssue399ChangeAttendeeStatusUsernameEmailFallback(self): + """change_attendee_status() works when the attendee is identified + by the client username rather than calendar_user_address_set() (issue #399). + + On servers that expose calendar-user-address-set the normal path runs; + on servers that do not, the username-email fallback introduced by the + fix kicks in. Either way the PARTSTAT update must succeed. + Only skipped when the login username is not an email address. + """ + self.skip_unless_support("scheduling") + username = getattr(self.caldav, "username", None) + if not username or "@" not in str(username): + pytest.skip( + "Client username %r is not an email address; " + "cannot build a matching ATTENDEE line" % username + ) + my_email = "mailto:" + username + + invite_data = """\ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +METHOD:REQUEST +BEGIN:VEVENT +UID:test-issue-399-%s@test.example +DTSTAMP:%s +DTSTART:%s +DTEND:%s +SUMMARY:Test invite for issue 399 +ORGANIZER:mailto:organizer@test.example +ATTENDEE;PARTSTAT=NEEDS-ACTION:%s +END:VEVENT +END:VCALENDAR +""" % ( + uuid.uuid4(), + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"), + (datetime.now(timezone.utc) + timedelta(days=10)).strftime("%Y%m%dT%H%M%SZ"), + (datetime.now(timezone.utc) + timedelta(days=10, hours=1)).strftime("%Y%m%dT%H%M%SZ"), + my_email, + ) + + ev = Event(client=self.caldav, data=invite_data) + ev.change_attendee_status(partstat="ACCEPTED") + + attendee = ev.icalendar_component["attendee"] + assert attendee.params.get("PARTSTAT") == "ACCEPTED" + def testSchedulingMailboxes(self): - self.skip_on_compatibility_flag("no_scheduling") - self.skip_on_compatibility_flag("no_scheduling_mailbox") + self.skip_unless_support("scheduling.mailbox") inbox = self.principal.schedule_inbox() outbox = self.principal.schedule_outbox() def testFindCalendarOwner(self): cal = self._fixCalendar() owner = cal.get_property(dav.Owner()) - ## TODO: something should probably be asserted about the Owner + ## Not all servers expose the DAV:owner property; None is acceptable. + if owner is None: + return + + ## The owner URL should point to a principal resource. + ## Constructing a Principal from it and fetching the vcal address + ## demonstrates the full workflow from issue #544. + if self.is_supported("scheduling.calendar-user-address-set"): + owner_principal = Principal(client=self.caldav, url=owner) + address = owner_principal.get_vcal_address() + assert address is not None def testIssue397(self): self.skip_unless_support("save-load.event.recurrences.exception") @@ -3671,3 +3886,13 @@ def testWithEnvironment(self): (RepeatedFunctionalTestsBaseClass,), {"server_params": _caldav_server}, ) + + # If the server has scheduling_users configured, also generate a + # TestSchedulingForServer* class so scheduling tests run per-server. + if "scheduling_users" in _caldav_server: + _sched_classname = "TestSchedulingForServer" + _servername + vars()[_sched_classname] = type( + _sched_classname, + (_TestSchedulingBase,), + {"_users": _caldav_server["scheduling_users"]}, + ) diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index 4d1d4801..6b669f7e 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -2787,3 +2787,139 @@ def test_resolve_properties_unmatched_paths_production_mode(self): {"/other/path/": {"foo": "bar"}, "/yet/another/": {"baz": "qux"}} ) assert result == {} + + +class TestAddOrganizer: + """Unit tests for CalendarObjectResource.add_organizer() (issue #524).""" + + _ev = """\ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:test-add-organizer@example.com +DTSTAMP:20240101T000000Z +DTSTART:20240601T100000Z +DTEND:20240601T110000Z +SUMMARY:Test event +END:VEVENT +END:VCALENDAR +""" + + def _make_event(self): + return Event(data=self._ev) + + def test_add_organizer_email_string(self): + """Passing a plain email string sets the ORGANIZER field.""" + ev = self._make_event() + ev.add_organizer("organizer@example.com") + organizer = ev.icalendar_component.get("organizer") + assert organizer is not None + assert "organizer@example.com" in str(organizer) + + def test_add_organizer_mailto_string(self): + """Passing a mailto: URI sets the ORGANIZER field.""" + ev = self._make_event() + ev.add_organizer("mailto:organizer@example.com") + organizer = ev.icalendar_component.get("organizer") + assert str(organizer) == "mailto:organizer@example.com" + + def test_add_organizer_vcal_address(self): + """Passing a vCalAddress directly sets the ORGANIZER field.""" + from icalendar import vCalAddress + + ev = self._make_event() + addr = vCalAddress("mailto:organizer@example.com") + ev.add_organizer(addr) + organizer = ev.icalendar_component.get("organizer") + assert str(organizer) == "mailto:organizer@example.com" + + def test_add_organizer_replaces_existing(self): + """Calling add_organizer twice replaces the first value, no duplicate.""" + ev = self._make_event() + ev.add_organizer("first@example.com") + ev.add_organizer("second@example.com") + comp = ev.icalendar_component + ## icalendar stores repeated properties as a list; ORGANIZER should be + ## a single value, not a list. + organizer = comp.get("organizer") + assert not isinstance(organizer, list), "ORGANIZER should not be duplicated" + assert "second@example.com" in str(organizer) + + def test_add_organizer_no_arg_uses_principal(self): + """Calling add_organizer() without arguments uses the current principal.""" + from icalendar import vCalAddress + + ev = self._make_event() + mock_client = mock.MagicMock() + mock_principal = mock.MagicMock() + mock_principal.get_vcal_address.return_value = vCalAddress("mailto:me@example.com") + mock_client.principal.return_value = mock_principal + ev.client = mock_client + ev.add_organizer() + organizer = ev.icalendar_component.get("organizer") + assert str(organizer) == "mailto:me@example.com" + + def test_add_organizer_no_arg_no_client_raises(self): + """Calling add_organizer() without arguments and no client raises ValueError.""" + ev = self._make_event() + ev.client = None + with pytest.raises(ValueError): + ev.add_organizer() + + +class TestChangeAttendeeStatusFallback: + """Unit tests for change_attendee_status() fallback when calendar_user_address_set() is unavailable. + + Covers issue https://github.com/python-caldav/caldav/issues/399: accept_invite() fails on servers + that do not expose the calendar-user-address-set property (RFC6638 §2.4.1). + """ + + _invite = """\ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +METHOD:REQUEST +BEGIN:VEVENT +UID:test-invite-399@example.com +DTSTAMP:20240101T000000Z +DTSTART:20240601T100000Z +DTEND:20240601T110000Z +SUMMARY:Test invite +ORGANIZER:mailto:organizer@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:attendee@example.com +END:VEVENT +END:VCALENDAR +""" + + def _make_event_with_mock_client(self, username): + from caldav.collection import Principal + from caldav.lib import error as caldav_error + + ev = Event(data=self._invite) + mock_client = mock.MagicMock() + mock_client.username = username + mock_principal = mock.MagicMock(spec=Principal) + mock_principal.calendar_user_address_set.side_effect = caldav_error.NotFoundError( + "calendar-user-address-set not supported" + ) + mock_client.principal.return_value = mock_principal + ev.client = mock_client + return ev + + def test_change_attendee_status_falls_back_to_email_username(self): + """When calendar_user_address_set() raises NotFoundError and username is an email, + change_attendee_status() should use the username as the attendee address.""" + ev = self._make_event_with_mock_client("attendee@example.com") + ev.change_attendee_status(partstat="ACCEPTED") + attendee = ev.icalendar_component["attendee"] + assert attendee.params.get("PARTSTAT") == "ACCEPTED" + + def test_change_attendee_status_raises_when_username_not_email(self): + """When calendar_user_address_set() raises NotFoundError and username is not an email, + change_attendee_status() should re-raise NotFoundError with a descriptive message.""" + from caldav.lib import error as caldav_error + + ev = self._make_event_with_mock_client("just_a_username") + with pytest.raises(caldav_error.NotFoundError): + ev.change_attendee_status(partstat="ACCEPTED") diff --git a/tests/test_examples.py b/tests/test_examples.py index ad9d63bc..03c1adc8 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -56,3 +56,8 @@ def test_collation(self): def test_rfc8764_test_conf(self): pass + + def test_calendar_owner_examples(self): + from examples import calendar_owner_examples + + calendar_owner_examples.run_examples() diff --git a/tests/test_servers.yaml.example b/tests/test_servers.yaml.example index f28008f7..c07af076 100644 --- a/tests/test_servers.yaml.example +++ b/tests/test_servers.yaml.example @@ -109,13 +109,30 @@ test-servers: # RFC6638 scheduling test users (optional) # ========================================================================= # -# For testing calendar scheduling (meeting invites, etc.), define -# multiple users that can send invites to each other: - +# For testing calendar scheduling (meeting invites, etc.), define at least +# three users on the same CalDAV server that can send invites to each other. +# This section lives at the TOP LEVEL (not under test-servers). +# +# Cyrus (pre-creates user1-user5 with password 'x'): +# rfc6638_users: +# - url: http://localhost:8802/dav/calendars/user/user1 +# username: user1 +# password: x +# - url: http://localhost:8802/dav/calendars/user/user2 +# username: user2 +# password: x +# - url: http://localhost:8802/dav/calendars/user/user3 +# username: user3 +# password: x +# +# Baikal (user1-user3 are in the pre-seeded db.sqlite, passwords testpass1-3): # rfc6638_users: -# - url: https://caldav.example.com/dav/user1/ +# - url: http://localhost:8800/dav.php/ # username: user1 -# password: pass1 -# - url: https://caldav.example.com/dav/user2/ +# password: testpass1 +# - url: http://localhost:8800/dav.php/ # username: user2 -# password: pass2 +# password: testpass2 +# - url: http://localhost:8800/dav.php/ +# username: user3 +# password: testpass3 diff --git a/tests/test_servers/base.py b/tests/test_servers/base.py index 5c1edbde..4a61327b 100644 --- a/tests/test_servers/base.py +++ b/tests/test_servers/base.py @@ -201,6 +201,9 @@ def get_server_params(self) -> dict[str, Any]: # Pass through SSL verification setting if configured if "ssl_verify_cert" in self.config: params["ssl_verify_cert"] = self.config["ssl_verify_cert"] + # Pass through scheduling_users if configured (for TestScheduling generation) + if "scheduling_users" in self.config: + params["scheduling_users"] = self.config["scheduling_users"] # Check if server is already running (either started by us or externally) already_running = self._started or self.is_accessible() if already_running: diff --git a/tests/test_servers/config_loader.py b/tests/test_servers/config_loader.py index 982e8158..c7467e46 100644 --- a/tests/test_servers/config_loader.py +++ b/tests/test_servers/config_loader.py @@ -91,7 +91,11 @@ def _load_config_file(path: str) -> dict[str, dict[str, Any]]: # Unwrap the "test-servers" key if present (the example YAML # uses this as a top-level namespace). Also support configs # where server dicts are at the top level directly. + # Preserve top-level non-server keys (e.g. rfc6638_users) by merging + # them back into the servers dict after unwrapping. if "test-servers" in cfg: + top_level_extras = {k: v for k, v in cfg.items() if k != "test-servers"} cfg = cfg["test-servers"] + cfg.update(top_level_extras) return cfg diff --git a/tests/test_servers/docker.py b/tests/test_servers/docker.py index 7c221cd8..66f3777e 100644 --- a/tests/test_servers/docker.py +++ b/tests/test_servers/docker.py @@ -30,13 +30,22 @@ class BaikalTestServer(DockerTestServer): def __init__(self, config: dict[str, Any] | None = None) -> None: config = config or {} - config.setdefault("host", os.environ.get("BAIKAL_HOST", "localhost")) - config.setdefault("port", int(os.environ.get("BAIKAL_PORT", "8800"))) + host = config.get("host") or os.environ.get("BAIKAL_HOST", "localhost") + port = int(config.get("port") or os.environ.get("BAIKAL_PORT", "8800")) + config.setdefault("host", host) + config.setdefault("port", port) config.setdefault("username", os.environ.get("BAIKAL_USERNAME", "testuser")) config.setdefault("password", os.environ.get("BAIKAL_PASSWORD", "testpass")) # Set up Baikal-specific compatibility hints if "features" not in config: config["features"] = compatibility_hints.baikal.copy() + # user1-user3 are pre-seeded in the committed db.sqlite for scheduling tests + if "scheduling_users" not in config: + base = f"http://{host}:{port}/dav.php" + config["scheduling_users"] = [ + {"url": base, "username": f"user{i}", "password": f"testpass{i}"} + for i in range(1, 4) + ] super().__init__(config) def _default_port(self) -> int: @@ -58,13 +67,22 @@ class NextcloudTestServer(DockerTestServer): def __init__(self, config: dict[str, Any] | None = None) -> None: config = config or {} - config.setdefault("host", os.environ.get("NEXTCLOUD_HOST", "localhost")) - config.setdefault("port", int(os.environ.get("NEXTCLOUD_PORT", "8801"))) + host = config.get("host") or os.environ.get("NEXTCLOUD_HOST", "localhost") + port = int(config.get("port") or os.environ.get("NEXTCLOUD_PORT", "8801")) + config.setdefault("host", host) + config.setdefault("port", port) config.setdefault("username", os.environ.get("NEXTCLOUD_USERNAME", "testuser")) config.setdefault("password", os.environ.get("NEXTCLOUD_PASSWORD", "testpass")) # Set up Nextcloud-specific compatibility hints if "features" not in config: config["features"] = compatibility_hints.nextcloud.copy() + # user1-user3 are created by setup_nextcloud.sh for scheduling tests + if "scheduling_users" not in config: + base = f"http://{host}:{port}/remote.php/dav" + config["scheduling_users"] = [ + {"url": base, "username": f"user{i}", "password": f"testpass{i}"} + for i in range(1, 4) + ] super().__init__(config) def _default_port(self) -> int: @@ -88,14 +106,18 @@ class CyrusTestServer(DockerTestServer): Cyrus IMAP server with CalDAV support in Docker. Cyrus is a mail server that also supports CalDAV/CardDAV. + The ghcr.io/cyrusimap/cyrus-docker-test-server image pre-creates + user1-user5 (password 'x') with CalDAV scheduling support. """ name = "Cyrus" def __init__(self, config: dict[str, Any] | None = None) -> None: config = config or {} - config.setdefault("host", os.environ.get("CYRUS_HOST", "localhost")) - config.setdefault("port", int(os.environ.get("CYRUS_PORT", "8802"))) + host = config.get("host") or os.environ.get("CYRUS_HOST", "localhost") + port = int(config.get("port") or os.environ.get("CYRUS_PORT", "8802")) + config.setdefault("host", host) + config.setdefault("port", port) config.setdefault("username", os.environ.get("CYRUS_USERNAME", "user1")) config.setdefault( "password", os.environ.get("CYRUS_PASSWORD", "any-password-seems-to-work") @@ -103,6 +125,16 @@ def __init__(self, config: dict[str, Any] | None = None) -> None: # Set up Cyrus-specific compatibility hints if "features" not in config: config["features"] = compatibility_hints.cyrus.copy() + # The docker image pre-creates user1-user5 with password 'x' + if "scheduling_users" not in config: + config["scheduling_users"] = [ + { + "url": f"http://{host}:{port}/dav/calendars/user/user{i}", + "username": f"user{i}", + "password": "x", + } + for i in range(1, 4) + ] super().__init__(config) def _default_port(self) -> int: @@ -136,13 +168,22 @@ class SOGoTestServer(DockerTestServer): def __init__(self, config: dict[str, Any] | None = None) -> None: config = config or {} - config.setdefault("host", os.environ.get("SOGO_HOST", "localhost")) - config.setdefault("port", int(os.environ.get("SOGO_PORT", "8803"))) + host = config.get("host") or os.environ.get("SOGO_HOST", "localhost") + port = int(config.get("port") or os.environ.get("SOGO_PORT", "8803")) + config.setdefault("host", host) + config.setdefault("port", port) config.setdefault("username", os.environ.get("SOGO_USERNAME", "testuser")) config.setdefault("password", os.environ.get("SOGO_PASSWORD", "testpass")) # Set up SOGo-specific compatibility hints if "features" not in config: config["features"] = compatibility_hints.sogo.copy() + # user1-user3 are pre-seeded in init-sogo-users.sql for scheduling tests + if "scheduling_users" not in config: + base = f"http://{host}:{port}/SOGo/dav" + config["scheduling_users"] = [ + {"url": f"{base}/user{i}", "username": f"user{i}", "password": f"testpass{i}"} + for i in range(1, 4) + ] super().__init__(config) def _default_port(self) -> int: @@ -217,12 +258,24 @@ class DavicalTestServer(DockerTestServer): def __init__(self, config: dict[str, Any] | None = None) -> None: config = config or {} - config.setdefault("host", os.environ.get("DAVICAL_HOST", "localhost")) - config.setdefault("port", int(os.environ.get("DAVICAL_PORT", "8805"))) + host = config.get("host") or os.environ.get("DAVICAL_HOST", "localhost") + port = int(config.get("port") or os.environ.get("DAVICAL_PORT", "8805")) + config.setdefault("host", host) + config.setdefault("port", port) config.setdefault("username", os.environ.get("DAVICAL_USERNAME", "testuser")) config.setdefault("password", os.environ.get("DAVICAL_PASSWORD", "testpass")) if "features" not in config: config["features"] = compatibility_hints.davical.copy() + # user1-user3 are created by setup_davical.sh for scheduling tests + if "scheduling_users" not in config: + config["scheduling_users"] = [ + { + "url": f"http://{host}:{port}/caldav.php/user{i}/", + "username": f"user{i}", + "password": f"testpass{i}", + } + for i in range(1, 4) + ] super().__init__(config) def _default_port(self) -> int: @@ -257,12 +310,21 @@ class DavisTestServer(DockerTestServer): def __init__(self, config: dict[str, Any] | None = None) -> None: config = config or {} - config.setdefault("host", os.environ.get("DAVIS_HOST", "localhost")) - config.setdefault("port", int(os.environ.get("DAVIS_PORT", "8806"))) + host = config.get("host") or os.environ.get("DAVIS_HOST", "localhost") + port = int(config.get("port") or os.environ.get("DAVIS_PORT", "8806")) + config.setdefault("host", host) + config.setdefault("port", port) config.setdefault("username", os.environ.get("DAVIS_USERNAME", "testuser")) config.setdefault("password", os.environ.get("DAVIS_PASSWORD", "testpass")) if "features" not in config: config["features"] = compatibility_hints.davis.copy() + # user1-user3 are created by setup_davis.sh for scheduling tests + if "scheduling_users" not in config: + base = f"http://{host}:{port}/dav/" + config["scheduling_users"] = [ + {"url": base, "username": f"user{i}", "password": f"testpass{i}"} + for i in range(1, 4) + ] super().__init__(config) def _default_port(self) -> int: @@ -285,12 +347,20 @@ class CCSTestServer(DockerTestServer): def __init__(self, config: dict[str, Any] | None = None) -> None: config = config or {} - config.setdefault("host", os.environ.get("CCS_HOST", "localhost")) - config.setdefault("port", int(os.environ.get("CCS_PORT", "8807"))) + host = config.get("host") or os.environ.get("CCS_HOST", "localhost") + port = int(config.get("port") or os.environ.get("CCS_PORT", "8807")) + config.setdefault("host", host) + config.setdefault("port", port) config.setdefault("username", os.environ.get("CCS_USERNAME", "user01")) config.setdefault("password", os.environ.get("CCS_PASSWORD", "user01")) if "features" not in config: config["features"] = compatibility_hints.ccs.copy() + # user01 and user02 are pre-defined in conf/auth/accounts.xml for scheduling tests + if "scheduling_users" not in config: + base = f"http://{host}:{port}/principals/" + config["scheduling_users"] = [ + {"url": base, "username": f"user0{i}", "password": f"user0{i}"} for i in range(1, 3) + ] super().__init__(config) def _default_port(self) -> int: @@ -313,8 +383,10 @@ class ZimbraTestServer(DockerTestServer): def __init__(self, config: dict[str, Any] | None = None) -> None: config = config or {} - config.setdefault("host", os.environ.get("ZIMBRA_HOST", "zimbra-docker.zimbra.io")) - config.setdefault("port", int(os.environ.get("ZIMBRA_PORT", "8808"))) + host = config.get("host") or os.environ.get("ZIMBRA_HOST", "zimbra-docker.zimbra.io") + port = int(config.get("port") or os.environ.get("ZIMBRA_PORT", "8808")) + config.setdefault("host", host) + config.setdefault("port", port) config.setdefault( "username", os.environ.get("ZIMBRA_USERNAME", "testuser@zimbra.io"), @@ -323,6 +395,18 @@ def __init__(self, config: dict[str, Any] | None = None) -> None: config.setdefault("ssl_verify_cert", False) if "features" not in config: config["features"] = compatibility_hints.zimbra.copy() + # testuser/testuser2/testuser3 are created by start.sh for scheduling tests + if "scheduling_users" not in config: + domain = os.environ.get("ZIMBRA_DOMAIN", "zimbra.io") + config["scheduling_users"] = [ + { + "url": f"https://{host}:{port}/dav/", + "username": f"testuser{'' if i == 1 else i}@{domain}", + "password": "testpass", + "ssl_verify_cert": False, + } + for i in range(1, 4) + ] super().__init__(config) def _default_port(self) -> int: @@ -362,12 +446,21 @@ class StalwartTestServer(DockerTestServer): def __init__(self, config: dict[str, Any] | None = None) -> None: config = config or {} - config.setdefault("host", os.environ.get("STALWART_HOST", "localhost")) - config.setdefault("port", int(os.environ.get("STALWART_PORT", "8809"))) + host = config.get("host") or os.environ.get("STALWART_HOST", "localhost") + port = int(config.get("port") or os.environ.get("STALWART_PORT", "8809")) + config.setdefault("host", host) + config.setdefault("port", port) config.setdefault("username", os.environ.get("STALWART_USERNAME", "testuser")) config.setdefault("password", os.environ.get("STALWART_PASSWORD", "testpass")) if "features" not in config: config["features"] = compatibility_hints.stalwart.copy() + # user1-user3 are created by setup_stalwart.sh for scheduling tests + if "scheduling_users" not in config: + base = f"http://{host}:{port}/dav/cal" + config["scheduling_users"] = [ + {"url": f"{base}/user{i}/", "username": f"user{i}", "password": f"testpass{i}"} + for i in range(1, 4) + ] super().__init__(config) def _default_port(self) -> int: diff --git a/tests/test_servers/registry.py b/tests/test_servers/registry.py index ed794a23..384b340f 100644 --- a/tests/test_servers/registry.py +++ b/tests/test_servers/registry.py @@ -183,14 +183,38 @@ def load_from_config(self, config: dict) -> None: for name, server_config in config.items(): if not isinstance(server_config, dict): - raise ValueError( - f"Server '{name}': configuration must be a dict, " - f"got {type(server_config).__name__}" - ) + # Skip non-server entries (e.g. rfc6638_users is a list, not a server) + continue if not server_config.get("enabled", True): continue + # Keys that only carry test-specific metadata, not connection config. + _TEST_ONLY_KEYS = frozenset({"scheduling_users"}) + _META_KEYS = frozenset({"type", "enabled", "name"}) + + # If an auto-discovered server with the same name (case-insensitive) is + # already registered, merge extra test-only fields (like scheduling_users) + # into it instead of registering a duplicate. + existing_key = next((k for k in self._servers if k.lower() == name.lower()), None) + if existing_key is not None: + if "scheduling_users" in server_config: + self._servers[existing_key].config["scheduling_users"] = server_config[ + "scheduling_users" + ] + continue + + # If the config only contains test-only metadata (no real connection + # params) and there is no existing server to merge into, skip: the + # entry is intended only to augment a running server, not to register + # a new one (e.g. a config written for CI that references Cyrus + # scheduling users but Cyrus isn't started locally). + non_connection_keys = { + k for k in server_config if k not in _META_KEYS | _TEST_ONLY_KEYS + } + if not non_connection_keys: + continue + server_type = server_config.get("type", name) server_class = get_server_class(server_type) diff --git a/tests/test_servers/test_config_loader.py b/tests/test_servers/test_config_loader.py index 1e51ed09..97230ba9 100644 --- a/tests/test_servers/test_config_loader.py +++ b/tests/test_servers/test_config_loader.py @@ -88,3 +88,39 @@ def test_empty_yaml_raises_error(self, tmp_path: Path) -> None: with pytest.raises(ConfigParseError) as exc_info: load_test_server_config(str(config_file)) assert "could not be parsed" in str(exc_info.value) + + def test_rfc6638_users_preserved_alongside_test_servers(self, tmp_path: Path) -> None: + """rfc6638_users at top level is preserved when test-servers is unwrapped.""" + config_file = tmp_path / "test_servers.yaml" + config_file.write_text(""" +test-servers: + radicale: + type: embedded + enabled: true + +rfc6638_users: + - url: http://localhost:8802/dav/calendars/user/user1 + username: user1 + password: x + - url: http://localhost:8802/dav/calendars/user/user2 + username: user2 + password: x +""") + cfg = load_test_server_config(str(config_file)) + assert "radicale" in cfg + assert "rfc6638_users" in cfg + assert len(cfg["rfc6638_users"]) == 2 + assert cfg["rfc6638_users"][0]["username"] == "user1" + + def test_rfc6638_users_only_config(self, tmp_path: Path) -> None: + """Config with only rfc6638_users (no test-servers) works correctly.""" + config_file = tmp_path / "test_servers.yaml" + config_file.write_text(""" +rfc6638_users: + - url: http://localhost:8802/dav/calendars/user/user1 + username: user1 + password: x +""") + cfg = load_test_server_config(str(config_file)) + assert "rfc6638_users" in cfg + assert cfg["rfc6638_users"][0]["url"] == "http://localhost:8802/dav/calendars/user/user1"