From 3e182a155054e91c15c4b915bfedba33cb97c9d8 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 20 Mar 2026 20:38:10 +0100 Subject: [PATCH 1/6] feat: add RFC6638 scheduling feature support and test infrastructure Add scheduling support to FeatureSet compatibility hints: - New 'scheduling' feature and sub-features (scheduling.mailbox, scheduling.calendar-user-address-set) - Migrate legacy no_scheduling old flags to the new feature system - Add scheduling_users defaults for Nextcloud, DAViCal, and Zimbra Add RFC6638 scheduling test infrastructure: - Enable multi-user scheduling tests via docker server setups - Pre-populate scheduling_users in Cyrus and Baikal server classes - Fix test collection and server config merging for scheduling_users --- .github/workflows/tests.yaml | 5 ++ caldav/compatibility_hints.py | 48 ++++++----- tests/caldav_test_servers.yaml.example | 64 ++++++++++++-- .../baikal/Specific/db/db.sqlite | Bin 110592 -> 110592 bytes .../baikal/create_baikal_db.py | 48 ++++++++++- .../davical/setup_davical.sh | 44 ++++++---- .../nextcloud/setup_nextcloud.sh | 7 +- .../sogo/init-sogo-users.sql | 13 +++ tests/docker-test-servers/zimbra/start.sh | 3 + tests/test_caldav.py | 49 +++++++---- tests/test_servers.yaml.example | 31 +++++-- tests/test_servers/base.py | 3 + tests/test_servers/config_loader.py | 4 + tests/test_servers/docker.py | 78 +++++++++++++++--- tests/test_servers/registry.py | 32 ++++++- tests/test_servers/test_config_loader.py | 36 ++++++++ 16 files changed, 377 insertions(+), 88 deletions(-) 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/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 1e8f9938..644b1bc1 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -264,6 +264,18 @@ 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.2-2.3). When unsupported, calls to schedule_inbox() or schedule_outbox() raise NotFoundError.", + "links": ["https://datatracker.ietf.org/doc/html/rfc6638#section-2.2"], + }, + "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"], + }, '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 +722,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 +840,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 +873,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 +894,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', @@ -1224,9 +1223,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 +1266,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 ] } @@ -1401,9 +1401,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 +1446,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", ] } 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 f8af389afd02d0cf54f841286baee06013064d65..781d544497ea8b4d97c163716b98227b321aa18a 100644 GIT binary patch delta 1157 zcmaKrO-LI-6vtn~0!d=**=s0g;M{~&k5StjttxoIWHqPfFJM?mN(ijKZD6G zLXcvnN7JL@Q+?^FiIIuE^xcUE_s56Ey71YOe5O=B$VFO7+X;eASb%}HlS=8ht#^xxD}Ks(5S-!4d4h7g!VREyJB4k<{i|iR*5v4ZH59TI3K> z^zu%m^UW#|PaGon3xD7@9B#Bn_uxu^NTQv@Jur3;Stdk5$|sFzYedsTPzQo7cn)LG zQ7K{QHc1Oo`}@6ZaJO}(8j(W1t{F42AIEAU`_HkMDf@A>#!;6GxMpl+D8vC8 z1wY{{tcy0igW*oBHqC_iI2rbFQaOm-{(ROX@<`l1iC|aEdjTdZr6DNB#EOURxdrir z#vD_(6NY9QG#lqU9?zQ8PG#b$WRe*%vGR44IjJnC7By|%$m))nG`L2YmEc;ISzJ$A zG4Tu3QEKT6%Q%*8CG8aBPEt1$lxI>IO-`6AyYD3?65B&!aK})EJu$lPa81D> X$<2$LS`43BRKA3JlsUUFS0nxbx@}NP delta 228 zcmW;Ev1-C#6o%pRn`0nY_$U@jP)WhP{Ip{ipUO}4TmUL}K zvp5uLG6>q)L2&Wl_&$g4j52eSnO;?uC$-07nQmVXL3<8UAt5B;<;^$3#Cd26YzP~H zUleTR2OWFbWzmQbiH7i_;gfVu$fZ~C^KnCZOV~>Pfv?%R_m|Gqbk3}Bq@eqTiRTuX zEwVl}>krmi7BL}~#TRC=qgnzx!cJffqhg#tEVSZhIi!SC4nLTs@9IuqPuL6m 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 +399,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 +425,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/davical/setup_davical.sh b/tests/docker-test-servers/davical/setup_davical.sh index 20cfd6b1..4e224c6e 100755 --- a/tests/docker-test-servers/davical/setup_davical.sh +++ b/tests/docker-test-servers/davical/setup_davical.sh @@ -34,25 +34,32 @@ for i in $(seq 1 $max_attempts); do sleep 3 done -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 +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 + run_sql "INSERT INTO principal (type_id, user_no, displayname) SELECT 1, user_no, fullname FROM usr WHERE username='${username}'" + echo "Principal for '${username}' created" + fi +} -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 "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 "" echo "Verifying CalDAV access..." @@ -79,4 +86,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/nextcloud/setup_nextcloud.sh b/tests/docker-test-servers/nextcloud/setup_nextcloud.sh index 6e2f0a8e..e2f812c3 100755 --- a/tests/docker-test-servers/nextcloud/setup_nextcloud.sh +++ b/tests/docker-test-servers/nextcloud/setup_nextcloud.sh @@ -27,9 +27,13 @@ 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" +done echo "Enabling calendar app..." docker exec $CONTAINER_NAME php occ app:enable calendar || true @@ -64,5 +68,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/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..2a38b696 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,6 +697,9 @@ 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 @@ -713,7 +712,7 @@ def _getCalendar(self, i): def setup_method(self): self.clients = [] self.principals = [] - for foo in rfc6638_users: + for foo in self._users: c = client(**foo) if not c.check_scheduling_support(): continue ## ignoring user because server does not support scheduling. @@ -790,6 +789,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) @@ -1093,20 +1101,15 @@ 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 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() @@ -3671,3 +3674,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_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..0bc2edd7 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: @@ -217,12 +249,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: @@ -313,8 +357,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 +369,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: diff --git a/tests/test_servers/registry.py b/tests/test_servers/registry.py index ed794a23..b36cd8fb 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" From 289cbe04a52298ad32fffdba19b584b4a1b4c8b9 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sat, 21 Mar 2026 06:35:02 +0100 Subject: [PATCH 2/6] test: implement RFC6638 testInviteAndRespond with per-server fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement testInviteAndRespond integration test covering invite/accept workflows (RFC6638), with support for both auto-scheduling and inbox-delivery modes, and per-server fixes for all supported servers: - Handle auto-scheduling servers (Cyrus, Nextcloud) that deliver to the calendar directly without requiring inbox delivery - Restructure test to properly handle both scheduling modes - Cyrus: fix virtdomains config for cross-user scheduling - Nextcloud, DAViCal, Baikal, Zimbra, SOGo, Davis, CCS, Stalwart: fixes - Add scheduling.mailbox.inbox-delivery compatibility hints per server - Rename scheduling.inbox-delivery → scheduling.mailbox.inbox-delivery - Fix wrong RFC6638 section references in compatibility hints - Pass scheduling_users as extra_clients in testCheckCompatibility --- caldav/calendarobjectresource.py | 2 +- caldav/compatibility_hints.py | 53 +++++++- .../baikal/create_baikal_db.py | 4 +- .../baikal/setup_baikal.sh | 84 ++++++------ .../cyrus/docker-compose.yml | 8 +- tests/docker-test-servers/cyrus/imapd.conf | 70 ++++++++++ .../davical/setup_davical.sh | 11 +- .../davis/docker-compose.yml | 4 + .../docker-test-servers/davis/setup_davis.sh | 32 ++--- .../nextcloud/setup_nextcloud.sh | 24 +++- .../stalwart/setup_stalwart.sh | 27 ++++ tests/test_caldav.py | 126 +++++++++++++++--- tests/test_servers/docker.py | 51 +++++-- tests/test_servers/registry.py | 8 +- 14 files changed, 405 insertions(+), 99 deletions(-) create mode 100644 tests/docker-test-servers/cyrus/imapd.conf diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index cc28f735..9d23205a 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -696,7 +696,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" diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 644b1bc1..da8b32d1 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -269,13 +269,19 @@ class FeatureSet: "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.2-2.3). When unsupported, calls to schedule_inbox() or schedule_outbox() raise NotFoundError.", - "links": ["https://datatracker.ietf.org/doc/html/rfc6638#section-2.2"], + "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"], }, "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", @@ -977,6 +983,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": True, + "scheduling.mailbox.inbox-delivery": {"support": "unsupported"}, "old_flags": [ ## apparently, zimbra has no journal support @@ -1004,7 +1014,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/'}, @@ -1024,7 +1034,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.inbox-delivery": {"support": "unknown"}, ## TODO: play with this and see if it's needed 'old_flags': [ @@ -1049,6 +1067,9 @@ 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"}, "http.multiplexing": "fragile", ## ref https://github.com/python-caldav/caldav/issues/564 'search.comp-type.optional': {'support': 'ungraceful'}, 'search.recurrences.expanded.todo': {'support': 'unsupported'}, @@ -1089,6 +1110,15 @@ 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.inbox-delivery": { + "support": "quirk", + "behaviour": "server delivers iTIP notification to inbox AND auto-schedules into calendar", + }, 'old_flags': [] } @@ -1109,6 +1139,9 @@ 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.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" }, @@ -1128,6 +1161,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, @@ -1289,6 +1324,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": 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 +1349,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 +1384,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, @@ -1499,6 +1542,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/tests/docker-test-servers/baikal/create_baikal_db.py b/tests/docker-test-servers/baikal/create_baikal_db.py index 7205c59b..bdf83dd1 100755 --- a/tests/docker-test-servers/baikal/create_baikal_db.py +++ b/tests/docker-test-servers/baikal/create_baikal_db.py @@ -241,7 +241,9 @@ def add_baikal_user(db_path: Path, username: str, password: str) -> None: 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 REPLACE INTO users (username, digesta1) VALUES (?, ?)", (username, ha1) + ) cursor.execute( "INSERT OR IGNORE INTO principals (uri, email, displayname) VALUES (?, ?, ?)", 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 4e224c6e..857a29eb 100755 --- a/tests/docker-test-servers/davical/setup_davical.sh +++ b/tests/docker-test-servers/davical/setup_davical.sh @@ -49,9 +49,14 @@ create_user() { if [ -n "$EXISTING_PRINCIPAL" ]; then echo "Principal for '${username}' already exists, skipping" else - run_sql "INSERT INTO principal (type_id, user_no, displayname) SELECT 1, user_no, fullname FROM usr WHERE username='${username}'" + # 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 "" @@ -61,6 +66,10 @@ create_user "user1" "testpass1" "User One" create_user "user2" "testpass2" "User Two" create_user "user3" "testpass3" "User Three" +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..." max_caldav_attempts=10 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 e2f812c3..5d5382e6 100755 --- a/tests/docker-test-servers/nextcloud/setup_nextcloud.sh +++ b/tests/docker-test-servers/nextcloud/setup_nextcloud.sh @@ -33,6 +33,8 @@ docker exec -e OC_PASS="$TEST_PASSWORD" $CONTAINER_NAME php occ user:add --passw # 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..." @@ -41,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 @@ -51,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 " 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/test_caldav.py b/tests/test_caldav.py index 2a38b696..ea7a180f 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -704,14 +704,20 @@ 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 = [] + self._auto_scheduled_event_uids = [] for foo in self._users: c = client(**foo) if not c.check_scheduling_support(): @@ -723,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__() @@ -744,39 +769,71 @@ 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() ## TODO. Invite two principals, let both of them load the ## invitation, and then let them respond in order. Lacks both @@ -1058,7 +1115,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) diff --git a/tests/test_servers/docker.py b/tests/test_servers/docker.py index 0bc2edd7..66f3777e 100644 --- a/tests/test_servers/docker.py +++ b/tests/test_servers/docker.py @@ -168,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: @@ -301,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: @@ -329,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: @@ -420,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 b36cd8fb..384b340f 100644 --- a/tests/test_servers/registry.py +++ b/tests/test_servers/registry.py @@ -196,9 +196,7 @@ def load_from_config(self, config: dict) -> None: # 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 - ) + 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[ @@ -211,7 +209,9 @@ def load_from_config(self, config: dict) -> None: # 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} + non_connection_keys = { + k for k in server_config if k not in _META_KEYS | _TEST_ONLY_KEYS + } if not non_connection_keys: continue From 9e7ccb5dbccbb019a4e00d3d29d0cb63decbb237 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 30 Mar 2026 01:16:05 +0200 Subject: [PATCH 3/6] feat: add calendar owner example and improve testFindCalendarOwner (closes https://github.com/python-caldav/caldav/issues/544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add examples/calendar_owner_examples.py demonstrating how to get the owner of a calendar via DAV:owner and look up their calendar-user address via Principal.get_vcal_address() - Improve testFindCalendarOwner to actually exercise the owner→principal →get_vcal_address() chain when the server supports both properties - Add test_calendar_owner_examples to tests/test_examples.py - Fix FeatureSet collapse regression: add explicit default to scheduling.mailbox in FEATURES so that _derive_from_subfeatures() does not roll the child quirk status up to the parent when CheckSchedulingDetails has not run Co-Authored-By: Claude Sonnet 4.6 --- caldav/compatibility_hints.py | 10 ++- examples/calendar_owner_examples.py | 101 ++++++++++++++++++++++++++++ tests/test_caldav.py | 12 +++- tests/test_examples.py | 5 ++ 4 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 examples/calendar_owner_examples.py diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index da8b32d1..194d4084 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -271,6 +271,7 @@ class FeatureSet: "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.", @@ -985,7 +986,7 @@ def dotted_feature_set_list(self, compact=False): '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": True, + "scheduling.mailbox": True, "scheduling.mailbox.inbox-delivery": {"support": "unsupported"}, "old_flags": [ @@ -1042,7 +1043,7 @@ def dotted_feature_set_list(self, compact=False): #"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.inbox-delivery": {"support": "unknown"}, + "scheduling.mailbox": {"support": "unknown"}, ## TODO: play with this and see if it's needed 'old_flags': [ @@ -1070,6 +1071,7 @@ def dotted_feature_set_list(self, compact=False): # 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'}, @@ -1115,6 +1117,7 @@ def dotted_feature_set_list(self, compact=False): # 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", @@ -1141,6 +1144,7 @@ def dotted_feature_set_list(self, compact=False): "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" }, @@ -1326,7 +1330,7 @@ def dotted_feature_set_list(self, compact=False): 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": True, + "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"}, 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/test_caldav.py b/tests/test_caldav.py index ea7a180f..24273f64 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -1198,7 +1198,17 @@ def testSchedulingMailboxes(self): 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") 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() From 241f94aadfb92ee6b10e9fdcb631ea0b5ab556a9 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 30 Mar 2026 01:28:11 +0200 Subject: [PATCH 4/6] fix: add_organizer() accepts argument and updates existing field --- caldav/calendarobjectresource.py | 42 +++++++++++++---- tests/test_caldav_unit.py | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 9 deletions(-) diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index 9d23205a..f636837a 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. diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index 4d1d4801..86c55363 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -2787,3 +2787,82 @@ 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() From 2dddd6f63c2ddf9b25ca1160ab75527a74d6edf3 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 30 Mar 2026 19:35:10 +0200 Subject: [PATCH 5/6] fix: accept_invite() falls back to client username when calendar-user-address-set is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the server does not expose the calendar-user-address-set property (RFC6638 §2.4.1), accept_invite() (and decline_invite(), tentatively_accept_invite()) now fall back to the client username as the attendee email address. 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 --- CHANGELOG.md | 1 + caldav/calendarobjectresource.py | 17 ++++- tests/test_caldav.py | 120 +++++++++++++++++++++++++++++++ tests/test_caldav_unit.py | 57 +++++++++++++++ 4 files changed, 194 insertions(+), 1 deletion(-) 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 f636837a..2f9ef1f2 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -1055,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/tests/test_caldav.py b/tests/test_caldav.py index 24273f64..e7a24de2 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -835,6 +835,79 @@ def testInviteAndRespond(self): 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 ## tests and the implementation also apparently doesn't work as @@ -1190,6 +1263,53 @@ def testSchedulingInfo(self): 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_unless_support("scheduling.mailbox") inbox = self.principal.schedule_inbox() diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index 86c55363..6b669f7e 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -2866,3 +2866,60 @@ def test_add_organizer_no_arg_no_client_raises(self): 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") From a015b199856ee9ed84fdbcc98f319c982eafe5dc Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 5 Apr 2026 21:39:20 +0200 Subject: [PATCH 6/6] ci: lychee should cache things more agressively --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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$