diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index a37e483d..0ff07720 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -261,6 +261,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", @@ -707,15 +719,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,9 @@ 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', ], @@ -1266,8 +1265,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,10 +1400,9 @@ 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', - ## Known, not a breach of standard 'no_supported_components_support', @@ -1448,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..70a68cf5 100644 --- a/tests/caldav_test_servers.yaml.example +++ b/tests/caldav_test_servers.yaml.example @@ -60,6 +60,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 @@ -150,13 +161,49 @@ test-servers: # RFC6638 scheduling test users (optional) # ========================================================================= # -# For testing calendar scheduling (meeting invites, etc.), define -# multiple users that can send invites to each other: - +# Preferred: add scheduling_users inside a server block (as shown for Cyrus +# above). The registry merges it into the already-registered server, and +# pytest generates a TestSchedulingForServer class automatically. +# +# Baikal (user1-user3 in pre-seeded db.sqlite, passwords testpass1-3): +# +# baikal: +# scheduling_users: +# - url: http://localhost:8800/dav.php/ +# username: user1 +# password: testpass1 +# - url: http://localhost:8800/dav.php/ +# username: user2 +# password: testpass2 +# - url: http://localhost:8800/dav.php/ +# username: user3 +# password: testpass3 +# +# SOGo (user1-user3 from init-sogo-users.sql, passwords testpass1-3): +# +# sogo: +# scheduling_users: +# - url: http://localhost:8803/SOGo/dav/user1 +# username: user1 +# password: testpass1 +# - url: http://localhost:8803/SOGo/dav/user2 +# username: user2 +# password: testpass2 +# - url: http://localhost:8803/SOGo/dav/user3 +# username: user3 +# password: testpass3 +# +# Legacy: top-level rfc6638_users creates a single TestScheduling class +# that is not tied to any specific server in the test run. Prefer the +# per-server scheduling_users approach above. +# # rfc6638_users: -# - url: https://caldav.example.com/dav/user1/ +# - url: http://localhost:8802/dav/calendars/user/user1 # username: user1 -# password: pass1 -# - url: https://caldav.example.com/dav/user2/ +# password: x +# - url: http://localhost:8802/dav/calendars/user/user2 # username: user2 -# password: pass2 +# password: x +# - url: http://localhost:8802/dav/calendars/user/user3 +# username: user3 +# password: x diff --git a/tests/docker-test-servers/baikal/Specific/db/db.sqlite b/tests/docker-test-servers/baikal/Specific/db/db.sqlite index f8af389a..781d5444 100644 Binary files a/tests/docker-test-servers/baikal/Specific/db/db.sqlite and b/tests/docker-test-servers/baikal/Specific/db/db.sqlite differ diff --git a/tests/docker-test-servers/baikal/create_baikal_db.py b/tests/docker-test-servers/baikal/create_baikal_db.py index 13b2f155..bdf83dd1 100755 --- a/tests/docker-test-servers/baikal/create_baikal_db.py +++ b/tests/docker-test-servers/baikal/create_baikal_db.py @@ -232,6 +232,49 @@ def create_baikal_db(db_path: Path, username: str = "testuser", password: str = print(f" Digest A1: {ha1}") +def add_baikal_user(db_path: Path, username: str, password: str) -> None: + """Add an additional user to an existing Baikal SQLite database.""" + realm = "BaikalDAV" + ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest() + principal_uri = f"principals/{username}" + + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + cursor.execute( + "INSERT OR REPLACE INTO users (username, digesta1) VALUES (?, ?)", (username, ha1) + ) + + cursor.execute( + "INSERT OR IGNORE INTO principals (uri, email, displayname) VALUES (?, ?, ?)", + (principal_uri, f"{username}@baikal.test", f"Test User ({username})"), + ) + + cursor.execute( + "INSERT INTO calendars (synctoken, components) VALUES (?, ?)", + (1, "VEVENT,VTODO,VJOURNAL"), + ) + calendar_id = cursor.lastrowid + + cursor.execute( + """INSERT INTO calendarinstances + (calendarid, principaluri, access, displayname, uri, calendarorder, calendarcolor) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (calendar_id, principal_uri, 1, "Default Calendar", "default", 0, "#3a87ad"), + ) + + cursor.execute( + """INSERT INTO addressbooks + (principaluri, displayname, uri, synctoken) + VALUES (?, ?, ?, ?)""", + (principal_uri, "Default Address Book", "default", 1), + ) + + conn.commit() + conn.close() + print(f"✓ Added user '{username}' to Baikal database") + + def create_baikal_config(config_path: Path) -> None: """Create Baikal config.php file.""" @@ -358,10 +401,14 @@ def create_baikal_yaml(yaml_path: Path) -> None: if __name__ == "__main__": script_dir = Path(__file__).parent - # Create database + # Create database with primary test user db_path = script_dir / "Specific" / "db" / "db.sqlite" create_baikal_db(db_path, username="testuser", password="testpass") + # Add extra users for RFC6638 scheduling tests (need at least 3) + for i in range(1, 4): + add_baikal_user(db_path, username=f"user{i}", password=f"testpass{i}") + # Create legacy PHP config files (for older Baikal versions) config_path = script_dir / "Specific" / "config.php" create_baikal_config(config_path) @@ -380,4 +427,5 @@ def create_baikal_yaml(yaml_path: Path) -> None: print("\nCredentials:") print(" Admin: admin / admin") print(" User: testuser / testpass") + print(" RFC6638 users: user1/testpass1, user2/testpass2, user3/testpass3") print(" CalDAV URL: http://localhost:8800/dav.php/") diff --git a/tests/docker-test-servers/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/test_caldav.py b/tests/test_caldav.py index 07359431..94e8516e 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() @@ -3679,3 +3682,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..18c4c50d 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: @@ -88,14 +97,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 +116,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: diff --git a/tests/test_servers/registry.py b/tests/test_servers/registry.py index ed794a23..384b340f 100644 --- a/tests/test_servers/registry.py +++ b/tests/test_servers/registry.py @@ -183,14 +183,38 @@ def load_from_config(self, config: dict) -> None: for name, server_config in config.items(): if not isinstance(server_config, dict): - raise ValueError( - f"Server '{name}': configuration must be a dict, " - f"got {type(server_config).__name__}" - ) + # Skip non-server entries (e.g. rfc6638_users is a list, not a server) + continue if not server_config.get("enabled", True): continue + # Keys that only carry test-specific metadata, not connection config. + _TEST_ONLY_KEYS = frozenset({"scheduling_users"}) + _META_KEYS = frozenset({"type", "enabled", "name"}) + + # If an auto-discovered server with the same name (case-insensitive) is + # already registered, merge extra test-only fields (like scheduling_users) + # into it instead of registering a duplicate. + existing_key = next((k for k in self._servers if k.lower() == name.lower()), None) + if existing_key is not None: + if "scheduling_users" in server_config: + self._servers[existing_key].config["scheduling_users"] = server_config[ + "scheduling_users" + ] + continue + + # If the config only contains test-only metadata (no real connection + # params) and there is no existing server to merge into, skip: the + # entry is intended only to augment a running server, not to register + # a new one (e.g. a config written for CI that references Cyrus + # scheduling users but Cyrus isn't started locally). + non_connection_keys = { + k for k in server_config if k not in _META_KEYS | _TEST_ONLY_KEYS + } + if not non_connection_keys: + continue + server_type = server_config.get("type", name) server_class = get_server_class(server_type) diff --git a/tests/test_servers/test_config_loader.py b/tests/test_servers/test_config_loader.py index 1e51ed09..97230ba9 100644 --- a/tests/test_servers/test_config_loader.py +++ b/tests/test_servers/test_config_loader.py @@ -88,3 +88,39 @@ def test_empty_yaml_raises_error(self, tmp_path: Path) -> None: with pytest.raises(ConfigParseError) as exc_info: load_test_server_config(str(config_file)) assert "could not be parsed" in str(exc_info.value) + + def test_rfc6638_users_preserved_alongside_test_servers(self, tmp_path: Path) -> None: + """rfc6638_users at top level is preserved when test-servers is unwrapped.""" + config_file = tmp_path / "test_servers.yaml" + config_file.write_text(""" +test-servers: + radicale: + type: embedded + enabled: true + +rfc6638_users: + - url: http://localhost:8802/dav/calendars/user/user1 + username: user1 + password: x + - url: http://localhost:8802/dav/calendars/user/user2 + username: user2 + password: x +""") + cfg = load_test_server_config(str(config_file)) + assert "radicale" in cfg + assert "rfc6638_users" in cfg + assert len(cfg["rfc6638_users"]) == 2 + assert cfg["rfc6638_users"][0]["username"] == "user1" + + def test_rfc6638_users_only_config(self, tmp_path: Path) -> None: + """Config with only rfc6638_users (no test-servers) works correctly.""" + config_file = tmp_path / "test_servers.yaml" + config_file.write_text(""" +rfc6638_users: + - url: http://localhost:8802/dav/calendars/user/user1 + username: user1 + password: x +""") + cfg = load_test_server_config(str(config_file)) + assert "rfc6638_users" in cfg + assert cfg["rfc6638_users"][0]["url"] == "http://localhost:8802/dav/calendars/user/user1"