diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 8e84d807..fbf218c4 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -14,7 +14,18 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python: ${{ github.event_name == 'pull_request' && fromJSON('["3.9", "3.14"]') || fromJSON('["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]') }} + services: + baikal: + image: ckulka/baikal:nginx + ports: + - 8800:80 + options: >- + --health-cmd "curl -f http://localhost/ || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + --health-start-period 30s steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -25,7 +36,24 @@ jobs: path: ~/.cache/pip key: pip|${{ hashFiles('setup.py') }}|${{ hashFiles('tox.ini') }} - run: pip install tox + - name: Configure Baikal with pre-seeded database + run: | + # Copy pre-configured database and config to Baikal container + docker cp tests/docker-test-servers/baikal/Specific/. ${{ job.services.baikal.id }}:/var/www/baikal/Specific/ + docker cp tests/docker-test-servers/baikal/config/. ${{ job.services.baikal.id }}:/var/www/baikal/config/ + # Fix permissions for SQLite + docker exec ${{ job.services.baikal.id }} chown -R nginx:nginx /var/www/baikal/Specific /var/www/baikal/config + docker exec ${{ job.services.baikal.id }} chmod -R 770 /var/www/baikal/Specific + # Restart to pick up configuration + docker restart ${{ job.services.baikal.id }} + - name: Wait for Baikal to be ready + run: | + sleep 5 + timeout 60 bash -c 'until curl -f http://localhost:8800/ 2>/dev/null; do echo "Waiting..."; sleep 2; done' + echo "Baikal is ready!" - run: tox -e py + env: + BAIKAL_URL: http://localhost:8800 docs: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index ca48debb..b0c9a9ac 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ tests/conf_private.py .eggs .venv caldav/_version.py +tests/docker-test-servers/baikal/baikal-backup/ +tests/docker-test-servers/*/baikal-backup/ +# But keep the pre-configured Specific directory for Baikal +!tests/docker-test-servers/baikal/Specific/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa277f6..1f1a94a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ Also, the RFC6764 discovery may not always be robust, causing fallbacks and henc ### Deprecations * `Event.expand_rrule` will be removed in some future release, unless someone protests. -* `Event.split_expanded` too. Both of them were used internally, now it's not. It's dead code, most lkely nobody and nothing is using them. +* `Event.split_expanded` too. Both of them were used internally, now it's not. It's dead code, most likely nobody and nothing is using them. ### Changed @@ -50,15 +50,13 @@ Also, the RFC6764 discovery may not always be robust, causing fallbacks and henc ### Added -* **RFC 6764 DNS-based service discovery**: Automatic CalDAV/CardDAV service discovery using DNS SRV/TXT records and well-known URIs. Users can now provide just a domain name or email address (e.g., `DAVClient(username='user@example.com')`) and the library will automatically discover the CalDAV service endpoint. The discovery process follows RFC 6764 specification. This involves a new required dependency: `dnspython` for DNS queries. DNS-based discovery can be disabled in the davclient connection settings, but I've opted against implementing a fallback if the dns library is not installed. - - **SECURITY**: DNS-based discovery has security implications. By default, `require_tls=True` prevents downgrade attacks by only accepting HTTPS connections. See security documentation for details. +* **New ways to configure the client connection, new parameters** + - **RFC 6764 DNS-based service discovery**: Automatic CalDAV/CardDAV service discovery using DNS SRV/TXT records and well-known URIs. Users can now provide just a domain name or email address (e.g., `DAVClient(username='user@example.com')`) and the library will automatically discover the CalDAV service endpoint. The discovery process follows RFC 6764 specification. This involves a new required dependency: `dnspython` for DNS queries. DNS-based discovery can be disabled in the davclient connection settings, but I've opted against implementing a fallback if the dns library is not installed. - New `require_tls` parameter (default: `True`) prevents DNS-based downgrade attacks - - Username extraction from email addresses (`user@example.com` → username: `user`) - - Discovery from username parameter when URL is omitted -* The client connection parameter `features` may now simply be a string label referencing a well-known server or cloud solution - like `features: posteo`. https://github.com/python-caldav/caldav/pull/561 -* The client connection parameter `url` is no longer needed when referencing a well-known cloud solution. https://github.com/python-caldav/caldav/pull/561 -* The client connection parameter `url` may contain just the domain name (without any slashes) and the URL will be constructed, if referencing a well-known caldav server implementation. https://github.com/python-caldav/caldav/pull/561 -* New interface for searches. `mysearcher = caldav.CalDAVSearcher(...) ; mysearcher.add_property_filter(...) ; mysearcher.search(calendar)`. May be useful for complicated searches. + - The client connection parameter `features` may now simply be a string label referencing a well-known server or cloud solution - like `features: posteo`. https://github.com/python-caldav/caldav/pull/561 + - The client connection parameter `url` is no longer needed when referencing a well-known cloud solution. https://github.com/python-caldav/caldav/pull/561 + * The client connection parameter `url` may contain just the domain name (without any slashes). It may then either look up the URL path in the known caldav server database, or through RFC6764 +* **New interface for searches** `mysearcher = caldav.CalDAVSearcher(...) ; mysearcher.add_property_filter(...) ; mysearcher.search(calendar)`. It's a bit harder to use, but opens up the possibility to do more complicated searches. * **Collation support for CalDAV text-match queries (RFC 4791 § 9.7.5)**: CalDAV searches now properly pass collation attributes to the server, enabling case-insensitive searches and Unicode-aware text matching. The `CalDAVSearcher.add_property_filter()` method now accepts `case_sensitive` and `collation` parameters. Supported collations include: - `i;octet` (case-sensitive, binary comparison) - default - `i;ascii-casemap` (case-insensitive for ASCII characters, RFC 4790) @@ -66,6 +64,11 @@ Also, the RFC6764 discovery may not always be robust, causing fallbacks and henc * Client-side filtering method: `CalDAVSearcher.filter()` provides comprehensive client-side filtering, expansion, and sorting of calendar objects with full timezone preservation support. * Example code: New `examples/collation_usage.py` demonstrates case-sensitive and case-insensitive calendar searches. +### Test suite + +* **Automated Baikal Docker testing framework** using Docker containers. Will only run if docker is available. +* Since the new search code now can work around different server quirks, quite some of the test code has been simplified. Many cases of "make a search, if server supports this, then assert correct number of events returned" could be collapsed to "make a search, then assert correct number of events returned" - meaning that **the library is tested rather than the server**. + ## [2.1.2] - [2025-11-08] Version 2.1.0 comes without niquests in the dependency file. Version 2.1.2 come with niquests in the dependency file. Also fixed up some minor mistakes in the CHANGELOG. diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 5eb1989d..2197576a 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -812,10 +812,7 @@ def dotted_feature_set_list(self, compact=False): 'duplicate_in_other_calendar_with_same_uid_is_lost' ] -baikal = { - 'create-calendar': {'support': 'quirk', 'behaviour': 'mkcol-required'}, - 'create-calendar.auto': {'support': 'unsupported'}, ## this is the default, but the "quirk" from create-calendar overwrites it. Hm. - +baikal = { ## version 0.10.1 #'search.comp-type-optional': {'support': 'ungraceful'}, ## Possibly this has been fixed? 'search.recurrences.expanded.todo': {'support': 'unsupported'}, 'search.recurrences.expanded.exception': {'support': 'unsupported'}, @@ -833,6 +830,13 @@ def dotted_feature_set_list(self, compact=False): ] } ## TODO: testPrincipals, testWrongAuthType, testTodoDatesearch fails +## Some unknown version of baikal has this +baikal_old = baikal | { + 'create-calendar': {'support': 'quirk', 'behaviour': 'mkcol-required'}, + 'create-calendar.auto': {'support': 'unsupported'}, ## this is the default, but the "quirk" from create-calendar overwrites it. Hm. + +} + ## See comments on https://github.com/python-caldav/caldav/issues/3 #icloud = [ # 'unique_calendar_ids', diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..0739f90f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,139 @@ +# CalDAV Library Tests + +This directory contains the test suite for the caldav Python library. + +## Running Tests + +### Quick Start + +Run all tests using pytest: + +```bash +pytest +``` + +Or using tox (recommended): + +```bash +tox -e py +``` + +### Running Specific Tests + +```bash +# Run a specific test file +pytest tests/test_cdav.py + +# Run a specific test function +pytest tests/test_cdav.py::test_element + +# Run tests matching a pattern +pytest -k "test_to_utc" +``` + +## Test Configuration + +Test configuration is managed through several files: + +- `conf.py` - Main test configuration, includes setup for Xandikos and Radicale servers +- `conf_private.py.EXAMPLE` - Example private configuration for custom CalDAV servers +- `conf_private.py` - Your personal test server configuration (gitignored) +- `conf_baikal.py` - Configuration for Baikal Docker test server + +### Testing Against Your Own Server + +1. Copy the example configuration: + ```bash + cp tests/conf_private.py.EXAMPLE tests/conf_private.py + ``` + +2. Edit `conf_private.py` and add your server details: + ```python + caldav_servers = [ + { + 'name': 'MyServer', + 'url': 'https://caldav.example.com', + 'username': 'testuser', + 'password': 'password', + 'features': [], + } + ] + ``` + +3. Run tests: + ```bash + pytest + ``` + +## Docker Test Servers + +The `docker-test-servers/` directory contains Docker configurations for running tests against various CalDAV server implementations: + +- **Baikal** - Lightweight CalDAV/CardDAV server + +See [docker-test-servers/README.md](docker-test-servers/README.md) for details. + +### Quick Start with Baikal + +```bash +cd tests/docker-test-servers/baikal +docker-compose up -d +# Configure Baikal through web interface at http://localhost:8800 +# Then run tests from project root +cd ../../.. +pytest +``` + +## Test Structure + +- `test_cdav.py` - Tests for CalDAV elements +- `test_vcal.py` - Tests for calendar/event handling +- `test_utils.py` - Utility function tests +- `test_docs.py` - Documentation tests +- `test_caldav.py` - Main CalDAV client tests +- `test_caldav_unit.py` - Unit tests for CalDAV client +- `test_search.py` - Search functionality tests + +## Continuous Integration + +Tests run automatically on GitHub Actions for: +- Python versions: 3.9, 3.10, 3.11, 3.12, 3.13, 3.14 +- With Baikal CalDAV server as a service container + +See `.github/workflows/tests.yaml` for the full CI configuration. + +## Coverage + +Generate a coverage report: + +```bash +coverage run -m pytest +coverage report +coverage html # Generate HTML report +``` + +## Troubleshooting + +### No test servers configured + +If you see warnings about no test servers being configured: +1. Either set up `conf_private.py` with your server details +2. Or use the Docker test servers (recommended) +3. Tests will still run against embedded servers (Xandikos, Radicale) if available + +### Docker test server won't start + +```bash +cd tests/docker-test-servers/baikal +docker-compose logs +docker-compose down -v # Reset everything +docker-compose up -d +``` + +### Tests timing out + +Some CalDAV servers may be slow to respond. You can increase timeouts in your test configuration or skip slow tests: + +```bash +pytest -m "not slow" +``` diff --git a/tests/conf.py b/tests/conf.py index 50038488..ea58c5ec 100644 --- a/tests/conf.py +++ b/tests/conf.py @@ -88,6 +88,38 @@ except ImportError: rfc6638_users = [] +try: + from .conf_private import baikal_host, baikal_port +except ImportError: + baikal_host = "localhost" + baikal_port = 8800 + +try: + from .conf_private import test_baikal +except ImportError: + import os + import subprocess + + ## Test Baikal if BAIKAL_URL is set OR if docker-compose is available + if os.environ.get("BAIKAL_URL") is not None: + test_baikal = True + else: + # Check if docker-compose is available + try: + subprocess.run( + ["docker-compose", "--version"], + capture_output=True, + check=True, + timeout=5, + ) + test_baikal = True + except ( + subprocess.CalledProcessError, + FileNotFoundError, + subprocess.TimeoutExpired, + ): + test_baikal = False + ##################### # Public test servers ##################### @@ -246,6 +278,173 @@ def silly_request(): } ) +## Baikal - Docker container with automated setup +if test_baikal: + import os + import subprocess + from pathlib import Path + + baikal_base_url = os.environ.get( + "BAIKAL_URL", f"http://{baikal_host}:{baikal_port}" + ) + # Ensure the URL includes /dav.php/ for CalDAV endpoint + if not baikal_base_url.endswith("/dav.php") and not baikal_base_url.endswith( + "/dav.php/" + ): + baikal_url = f"{baikal_base_url}/dav.php" + else: + baikal_url = baikal_base_url.rstrip("/") + + baikal_username = os.environ.get("BAIKAL_USERNAME", "testuser") + baikal_password = os.environ.get("BAIKAL_PASSWORD", "testpass") + + def is_baikal_accessible() -> bool: + """Check if Baikal server is accessible.""" + try: + # Check the dav.php endpoint + response = requests.get(f"{baikal_url}/", timeout=5) + return response.status_code in (200, 401, 403, 404) + except Exception: + return False + + def setup_baikal(self) -> None: + """Start Baikal Docker container with pre-configured database.""" + import subprocess + import time + from pathlib import Path + + # Check if docker-compose is available + try: + subprocess.run( + ["docker-compose", "--version"], + capture_output=True, + check=True, + timeout=5, + ) + except ( + subprocess.CalledProcessError, + FileNotFoundError, + subprocess.TimeoutExpired, + ) as e: + raise RuntimeError( + "docker-compose is not available. Baikal tests require Docker. " + "Please install Docker or skip Baikal tests by setting " + "test_baikal=False in tests/conf_private.py" + ) from e + + # Get the docker-compose directory + baikal_dir = Path(__file__).parent / "docker-test-servers" / "baikal" + + # Check if docker-compose.yml exists + if not (baikal_dir / "docker-compose.yml").exists(): + raise FileNotFoundError(f"docker-compose.yml not found in {baikal_dir}") + + # Start the container but don't wait for full startup + print(f"Starting Baikal container from {baikal_dir}...") + subprocess.run( + ["docker-compose", "up", "--no-start"], + cwd=baikal_dir, + check=True, + capture_output=True, + ) + + # Copy pre-configured files BEFORE starting the container + # This way the entrypoint script will fix permissions properly + print("Copying pre-configured files into container...") + specific_dir = baikal_dir / "Specific" + config_dir = baikal_dir / "config" + + subprocess.run( + [ + "docker", + "cp", + f"{specific_dir}/.", + "baikal-test:/var/www/baikal/Specific/", + ], + check=True, + capture_output=True, + ) + + # Copy YAML config for newer Baikal versions + if config_dir.exists(): + subprocess.run( + [ + "docker", + "cp", + f"{config_dir}/.", + "baikal-test:/var/www/baikal/config/", + ], + check=True, + capture_output=True, + ) + + # Now start the container - the entrypoint will fix permissions + print("Starting container...") + subprocess.run( + ["docker", "start", "baikal-test"], + check=True, + capture_output=True, + ) + + # Wait for Baikal to be ready + print("Waiting for Baikal to be ready...") + max_attempts = 30 + for i in range(max_attempts): + try: + response = requests.get(f"{baikal_url}/", timeout=2) + if response.status_code in (200, 401, 403): + print(f"✓ Baikal is ready at {baikal_url}") + return + except Exception: + pass + time.sleep(1) + + raise TimeoutError(f"Baikal did not become ready after {max_attempts} seconds") + + def teardown_baikal(self) -> None: + """Stop Baikal Docker container.""" + import subprocess + from pathlib import Path + + baikal_dir = Path(__file__).parent / "docker-test-servers" / "baikal" + + print("Stopping Baikal container...") + subprocess.run( + ["docker-compose", "down"], + cwd=baikal_dir, + check=True, + capture_output=True, + ) + print("✓ Baikal container stopped") + + # Only add Baikal to test servers if accessible OR if we can start it + if is_baikal_accessible(): + # Already running, just use it + features = compatibility_hints.baikal.copy() + caldav_servers.append( + { + "name": "Baikal", + "url": baikal_url, + "username": baikal_username, + "password": baikal_password, + "features": features, + } + ) + else: + # Not running, add with setup/teardown to auto-start + features = compatibility_hints.baikal.copy() + caldav_servers.append( + { + "name": "Baikal", + "url": baikal_url, + "username": baikal_username, + "password": baikal_password, + "features": features, + "setup": setup_baikal, + "teardown": teardown_baikal, + } + ) + ################################################################### # Convenience - get a DAVClient object from the caldav_servers list diff --git a/tests/conf_baikal.py b/tests/conf_baikal.py new file mode 100644 index 00000000..82f5adf0 --- /dev/null +++ b/tests/conf_baikal.py @@ -0,0 +1,68 @@ +""" +Configuration for running tests against Baikal CalDAV server in Docker. + +This module provides configuration for testing against the Baikal CalDAV +server running in a Docker container. It can be used both locally (via +docker-compose) and in CI/CD pipelines (GitHub Actions). + +Usage: + Local testing: + docker-compose up -d + export BAIKAL_URL=http://localhost:8800 + pytest + + CI testing: + The GitHub Actions workflow automatically sets up the Baikal service + and exports the BAIKAL_URL environment variable. +""" +import os + +from caldav import compatibility_hints + +# Get Baikal URL from environment, default to local docker-compose setup +BAIKAL_URL = os.environ.get("BAIKAL_URL", "http://localhost:8800") + +# Baikal default credentials (these need to be configured after first start) +# Note: Baikal requires initial setup through the web interface +# For CI, you may need to pre-configure or use API/config file approach +BAIKAL_USERNAME = os.environ.get("BAIKAL_USERNAME", "testuser") +BAIKAL_PASSWORD = os.environ.get("BAIKAL_PASSWORD", "testpass") + +# Configuration for Baikal server +baikal_config = { + "name": "BaikalDocker", + "url": BAIKAL_URL, + "username": BAIKAL_USERNAME, + "password": BAIKAL_PASSWORD, + "features": compatibility_hints.baikal + if hasattr(compatibility_hints, "baikal") + else {}, +} + + +def is_baikal_available() -> bool: + """ + Check if Baikal server is available and configured. + + Returns: + bool: True if Baikal is running and accessible, False otherwise. + """ + try: + import requests + + response = requests.get(BAIKAL_URL, timeout=5) + return response.status_code in (200, 401, 403) # Server is responding + except Exception: + return False + + +def get_baikal_config(): + """ + Get Baikal configuration if the server is available. + + Returns: + dict or None: Configuration dict if available, None otherwise. + """ + if is_baikal_available(): + return baikal_config + return None diff --git a/tests/docker-test-servers/README.md b/tests/docker-test-servers/README.md new file mode 100644 index 00000000..8718ea34 --- /dev/null +++ b/tests/docker-test-servers/README.md @@ -0,0 +1,50 @@ +# Docker Test Servers + +This directory contains Docker-based test server configurations for running integration tests against various CalDAV server implementations. + +## Available Test Servers + +### Baikal + +A lightweight CalDAV and CardDAV server. + +- **Location**: `baikal/` +- **Documentation**: [baikal/README.md](baikal/README.md) +- **Quick Start**: + ```bash + cd baikal + docker-compose up -d + ``` + +## Adding New Test Servers + +To add a new CalDAV server for testing: + +1. Create a new directory under `tests/docker-test-servers/` +2. Add a `docker-compose.yml` file +3. Create a `README.md` with setup instructions +4. Add configuration to `tests/conf_.py` +5. Update `.github/workflows/tests.yaml` to include the new service + +## General Usage + +### Local Testing + +1. Navigate to the specific server directory +2. Start the container: `docker-compose up -d` +3. Configure the server (see server-specific README) +4. Run tests from project root with appropriate environment variables + +### CI/CD Testing + +Test servers are automatically started as GitHub Actions services. See `.github/workflows/tests.yaml` for configuration. + +## Environment Variables + +Each server may use different environment variables. Common ones include: + +- `_URL`: URL of the test server +- `_USERNAME`: Test user username +- `_PASSWORD`: Test user password + +See individual server READMEs for specific variables. diff --git a/tests/docker-test-servers/baikal/README.md b/tests/docker-test-servers/baikal/README.md new file mode 100644 index 00000000..742286e0 --- /dev/null +++ b/tests/docker-test-servers/baikal/README.md @@ -0,0 +1,200 @@ +# Testing with Baikal CalDAV Server + +This project includes a framework for running tests against a Baikal CalDAV server in a Docker container. This setup works both locally and in CI/CD pipelines (GitHub Actions). + +## Requirements + +- **Docker** and **docker-compose** must be installed +- If Docker is not available, Baikal tests will be automatically skipped + +## Automatic Setup + +**Tests automatically start Baikal if Docker is available!** Just run: + +```bash +pytest tests/ +# or +tox -e py +``` + +The test framework will: +1. Detect if docker-compose is available +2. Automatically start the Baikal container if needed +3. Configure it with the pre-seeded database +4. Run tests against it +5. Clean up after tests complete + +## Manual Setup (Optional) + +If you prefer to start Baikal manually: + +```bash +cd tests/docker-test-servers/baikal +./start.sh +``` + +This will: +1. Create the Baikal CalDAV server container +2. Copy the pre-configured database and config files +3. Start the container (entrypoint fixes permissions automatically) +4. Wait for Baikal to be ready on `http://localhost:8800` + +## Pre-configured Setup + +This Baikal instance comes **pre-configured** with: +- Admin user: `admin` / `admin` +- Test user: `testuser` / `testpass` +- Default calendar and addressbook already created +- Digest authentication enabled +- CalDAV URL: `http://localhost:8800/dav.php` + +**No manual configuration needed!** The container will start ready to use. + +**Note:** The test framework automatically appends `/dav.php` to the base URL, so you can set `BAIKAL_URL=http://localhost:8800` and it will work correctly. + +## Disabling Baikal Tests + +If you want to skip Baikal tests, create `tests/conf_private.py`: + +```python +test_baikal = False +``` + +Or simply don't install Docker - the tests will automatically skip Baikal if Docker is not available. + +## GitHub Actions (CI/CD) + +The GitHub Actions workflow in `.github/workflows/tests.yaml` automatically: + +1. Spins up a Baikal container as a service +2. Waits for Baikal to be healthy +3. Runs the test suite + +**Note:** For CI to work properly, you need to either: +- Pre-configure Baikal and commit the config (if appropriate for your project) +- Modify tests to skip Baikal-specific tests if not configured +- Use automated configuration scripts + +### Configuring CI + +The workflow sets these environment variables: +- `BAIKAL_URL=http://localhost:8800` + +You can add more secrets in GitHub Actions settings for credentials. + +## Configuration + +### Environment Variables + +- `BAIKAL_URL`: URL of the Baikal server (default: `http://localhost:8800`) +- `BAIKAL_USERNAME`: Test user username (default: `testuser`) +- `BAIKAL_PASSWORD`: Test user password (default: `testpass`) +- `BAIKAL_ADMIN_PASSWORD`: Admin password for initial setup (default: `admin`) + +### Test Configuration + +The test suite will automatically detect and use Baikal if configured. Configuration is in: +- `tests/conf_baikal.py` - Baikal-specific configuration +- `tests/conf.py` - Main test configuration (add Baikal to `caldav_servers` list) + +To enable Baikal testing, add to `tests/conf_private.py`: + +```python +from tests.conf_baikal import get_baikal_config + +# Add Baikal to test servers if available +baikal_conf = get_baikal_config() +if baikal_conf: + caldav_servers.append(baikal_conf) +``` + +## Troubleshooting + +### Container won't start +```bash +# Check container logs +docker-compose logs baikal + +# Restart container +docker-compose restart baikal +``` + +### Tests can't connect to Baikal +```bash +# Check if Baikal is accessible +curl -v http://localhost:8800/ + +# Check if container is running +docker-compose ps + +# Check container health +docker inspect baikal-test | grep -A 10 Health +``` + +### Reset Baikal +```bash +# Stop and remove container with volumes +docker-compose down -v + +# Start fresh +docker-compose up -d +``` + +## Docker Compose Commands + +```bash +# Start Baikal in background +docker-compose up -d + +# View logs +docker-compose logs -f baikal + +# Stop Baikal +docker-compose stop + +# Stop and remove (keeps volumes) +docker-compose down + +# Stop and remove everything including data +docker-compose down -v + +# Restart Baikal +docker-compose restart baikal +``` + +## Architecture + +The Baikal testing framework consists of: + +1. **tests/docker-test-servers/baikal/docker-compose.yml** - Defines the Baikal container service +2. **tests/docker-test-servers/baikal/Specific/** - Pre-configured database and config files +3. **tests/docker-test-servers/baikal/create_baikal_db.py** - Script to regenerate config (if needed) +4. **.github/workflows/tests.yaml** - GitHub Actions workflow with Baikal service +5. **tests/conf.py** - Auto-detects Baikal when BAIKAL_URL is set + +## Regenerating Configuration + +If you need to recreate the pre-configured database: + +```bash +cd tests/docker-test-servers/baikal +python3 create_baikal_db.py +``` + +This will regenerate: +- `Specific/db/db.sqlite` - Database with testuser +- `Specific/config.php` - Main configuration +- `Specific/config.system.php` - System configuration + +## Contributing + +When adding Baikal-specific tests: +- Check if Baikal is available before running tests +- Use `tests/conf_baikal.is_baikal_available()` to check availability +- Mark Baikal-specific tests with appropriate markers or skips + +## References + +- Baikal Docker Image: https://hub.docker.com/r/ckulka/baikal +- Baikal Project: https://sabre.io/baikal/ +- CalDAV Protocol: RFC 4791 diff --git a/tests/docker-test-servers/baikal/Specific/config.php b/tests/docker-test-servers/baikal/Specific/config.php new file mode 100644 index 00000000..b7684daa --- /dev/null +++ b/tests/docker-test-servers/baikal/Specific/config.php @@ -0,0 +1,51 @@ + None: + """Create Baikal configuration files.""" + config_path.mkdir(parents=True, exist_ok=True) + + # Create config.php + config_php = config_path / "config.php" + config_content = f""" None: + """Create Baikal SQLite database with test user.""" + db_path.parent.mkdir(parents=True, exist_ok=True) + + # Create database and user table + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + # Create users table + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT UNIQUE, + digesta1 TEXT + ) + """ + ) + + # Create calendars table + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS calendars ( + id INTEGER PRIMARY KEY, + principaluri TEXT, + displayname TEXT, + uri TEXT, + description TEXT, + components TEXT, + ctag INTEGER + ) + """ + ) + + # Create addressbooks table + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS addressbooks ( + id INTEGER PRIMARY KEY, + principaluri TEXT, + displayname TEXT, + uri TEXT, + description TEXT, + ctag INTEGER + ) + """ + ) + + # Create test user with digest auth + realm = "BaikalDAV" + ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest() + + cursor.execute( + "INSERT OR REPLACE INTO users (username, digesta1) VALUES (?, ?)", + (username, ha1), + ) + + # Create default calendar for user + principal_uri = f"principals/{username}" + cursor.execute( + """INSERT OR REPLACE INTO calendars + (principaluri, displayname, uri, components, ctag) + VALUES (?, ?, ?, ?, ?)""", + (principal_uri, "Default Calendar", "default", "VEVENT,VTODO,VJOURNAL", 1), + ) + + # Create default addressbook for user + cursor.execute( + """INSERT OR REPLACE INTO addressbooks + (principaluri, displayname, uri, ctag) + VALUES (?, ?, ?, ?)""", + (principal_uri, "Default Address Book", "default", 1), + ) + + conn.commit() + conn.close() + print(f"Created database with user '{username}': {db_path}") + + +def main() -> int: + """Main function.""" + # Get configuration from environment + baikal_url = os.environ.get("BAIKAL_URL", "http://localhost:8800") + admin_password = os.environ.get("BAIKAL_ADMIN_PASSWORD", "admin") + username = os.environ.get("BAIKAL_USERNAME", "testuser") + password = os.environ.get("BAIKAL_PASSWORD", "testpass") + + print(f"Configuring Baikal at {baikal_url}") + print(f"Test user: {username}") + + # Check if Baikal is accessible + try: + response = requests.get(baikal_url, timeout=5) + print(f"Baikal is accessible (status: {response.status_code})") + except Exception as e: + print(f"Warning: Cannot access Baikal at {baikal_url}: {e}") + print("Make sure Baikal is running (e.g., docker-compose up -d)") + + # Note: Direct file configuration requires access to Baikal's container filesystem + print("\n" + "=" * 70) + print("NOTE: Direct configuration requires container filesystem access") + print("=" * 70) + print("\nFor Docker-based setup, you can:") + print("1. Use docker exec to run this script inside the container") + print("2. Mount a pre-configured volume with config and database") + print("3. Use the web interface for initial setup") + print("\nExample for docker exec:") + print(f" docker cp scripts/configure_baikal.py baikal-test:/tmp/") + print(f" docker exec baikal-test python3 /tmp/configure_baikal.py") + print("=" * 70 + "\n") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/docker-test-servers/baikal/create_baikal_db.py b/tests/docker-test-servers/baikal/create_baikal_db.py new file mode 100755 index 00000000..da731a22 --- /dev/null +++ b/tests/docker-test-servers/baikal/create_baikal_db.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +""" +Create a pre-configured Baikal SQLite database for automated testing. + +This creates a database with: +- A test user: testuser / testpass +- Digest authentication configured +- Default calendar and addressbook + +Usage: + python create_baikal_db.py +""" +import hashlib +import os +import sqlite3 +from pathlib import Path + + +def create_baikal_db( + db_path: Path, username: str = "testuser", password: str = "testpass" +) -> None: + """Create a Baikal SQLite database with a test user using official schema.""" + + # Ensure directory exists + db_path.parent.mkdir(parents=True, exist_ok=True) + + # Remove existing database if present + if db_path.exists(): + db_path.unlink() + + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + # Use the official Baikal SQLite schema + # This schema is from Baikal's Core/Resources/Db/SQLite/db.sql + schema_sql = """ +CREATE TABLE addressbooks ( + id integer primary key asc NOT NULL, + principaluri text NOT NULL, + displayname text, + uri text NOT NULL, + description text, + synctoken integer DEFAULT 1 NOT NULL +); + +CREATE TABLE cards ( + id integer primary key asc NOT NULL, + addressbookid integer NOT NULL, + carddata blob, + uri text NOT NULL, + lastmodified integer, + etag text, + size integer +); + +CREATE TABLE addressbookchanges ( + id integer primary key asc NOT NULL, + uri text, + synctoken integer NOT NULL, + addressbookid integer NOT NULL, + operation integer NOT NULL +); + +CREATE INDEX addressbookid_synctoken ON addressbookchanges (addressbookid, synctoken); + +CREATE TABLE calendarobjects ( + id integer primary key asc NOT NULL, + calendardata blob NOT NULL, + uri text NOT NULL, + calendarid integer NOT NULL, + lastmodified integer NOT NULL, + etag text NOT NULL, + size integer NOT NULL, + componenttype text, + firstoccurence integer, + lastoccurence integer, + uid text +); + +CREATE TABLE calendars ( + id integer primary key asc NOT NULL, + synctoken integer DEFAULT 1 NOT NULL, + components text NOT NULL +); + +CREATE TABLE calendarinstances ( + id integer primary key asc NOT NULL, + calendarid integer, + principaluri text, + access integer, + displayname text, + uri text NOT NULL, + description text, + calendarorder integer, + calendarcolor text, + timezone text, + transparent bool, + share_href text, + share_displayname text, + share_invitestatus integer DEFAULT '2', + UNIQUE (principaluri, uri), + UNIQUE (calendarid, principaluri), + UNIQUE (calendarid, share_href) +); + +CREATE TABLE calendarchanges ( + id integer primary key asc NOT NULL, + uri text, + synctoken integer NOT NULL, + calendarid integer NOT NULL, + operation integer NOT NULL +); + +CREATE INDEX calendarid_synctoken ON calendarchanges (calendarid, synctoken); + +CREATE TABLE calendarsubscriptions ( + id integer primary key asc NOT NULL, + uri text NOT NULL, + principaluri text NOT NULL, + source text NOT NULL, + displayname text, + refreshrate text, + calendarorder integer, + calendarcolor text, + striptodos bool, + stripalarms bool, + stripattachments bool, + lastmodified int +); + +CREATE TABLE schedulingobjects ( + id integer primary key asc NOT NULL, + principaluri text NOT NULL, + calendardata blob, + uri text NOT NULL, + lastmodified integer, + etag text NOT NULL, + size integer NOT NULL +); + +CREATE INDEX principaluri_uri ON calendarsubscriptions (principaluri, uri); + +CREATE TABLE locks ( + id integer primary key asc NOT NULL, + owner text, + timeout integer, + created integer, + token text, + scope integer, + depth integer, + uri text +); + +CREATE TABLE principals ( + id INTEGER PRIMARY KEY ASC NOT NULL, + uri TEXT NOT NULL, + email TEXT, + displayname TEXT, + UNIQUE(uri) +); + +CREATE TABLE groupmembers ( + id INTEGER PRIMARY KEY ASC NOT NULL, + principal_id INTEGER NOT NULL, + member_id INTEGER NOT NULL, + UNIQUE(principal_id, member_id) +); + +CREATE TABLE propertystorage ( + id integer primary key asc NOT NULL, + path text NOT NULL, + name text NOT NULL, + valuetype integer NOT NULL, + value string +); + +CREATE UNIQUE INDEX path_property ON propertystorage (path, name); + +CREATE TABLE users ( + id integer primary key asc NOT NULL, + username TEXT NOT NULL, + digesta1 TEXT NOT NULL, + UNIQUE(username) +); +""" + + # Execute the schema + cursor.executescript(schema_sql) + + # Create test user with digest auth + # Digest A1 = MD5(username:BaikalDAV:password) + realm = "BaikalDAV" + ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest() + + cursor.execute( + "INSERT INTO users (username, digesta1) VALUES (?, ?)", (username, ha1) + ) + + # Create principal for user + principal_uri = f"principals/{username}" + cursor.execute( + "INSERT INTO principals (uri, email, displayname) VALUES (?, ?, ?)", + (principal_uri, f"{username}@baikal.test", f"Test User ({username})"), + ) + + # Create default calendar + cursor.execute( + "INSERT INTO calendars (synctoken, components) VALUES (?, ?)", + (1, "VEVENT,VTODO,VJOURNAL"), + ) + calendar_id = cursor.lastrowid + + # Create calendar instance for the user + cursor.execute( + """INSERT INTO calendarinstances + (calendarid, principaluri, access, displayname, uri, calendarorder, calendarcolor) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (calendar_id, principal_uri, 1, "Default Calendar", "default", 0, "#3a87ad"), + ) + + # Create default addressbook for user + cursor.execute( + """INSERT INTO addressbooks + (principaluri, displayname, uri, synctoken) + VALUES (?, ?, ?, ?)""", + (principal_uri, "Default Address Book", "default", 1), + ) + + conn.commit() + conn.close() + + print(f"✓ Created Baikal database at {db_path}") + print(f" User: {username}") + print(f" Password: {password}") + print(f" Realm: {realm}") + print(f" Digest A1: {ha1}") + + +def create_baikal_config(config_path: Path) -> None: + """Create Baikal config.php file.""" + + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Admin password hash (MD5 of 'admin') + admin_hash = hashlib.md5(b"admin").hexdigest() + + config_content = f""" None: + """Create Baikal config.system.php file.""" + + system_path.parent.mkdir(parents=True, exist_ok=True) + + system_content = """ None: + """Create Baikal baikal.yaml configuration file for newer Baikal versions.""" + + yaml_path.parent.mkdir(parents=True, exist_ok=True) + + # Admin password hash (MD5 of 'admin') + admin_hash = "21232f297a57a5a743894a0e4a801fc3" + + yaml_content = """system: + configured_version: '0.10.1' + timezone: 'UTC' + card_enabled: true + cal_enabled: true + invite_from: 'noreply@baikal.test' + dav_auth_type: 'Digest' + admin_passwordhash: {admin_hash} + failed_access_message: 'user %u authentication failure for Baikal' + auth_realm: BaikalDAV + base_uri: '' + +database: + encryption_key: 'test-encryption-key-for-automated-testing' + backend: 'sqlite' + sqlite_file: '/var/www/baikal/Specific/db/db.sqlite' + mysql_host: 'localhost' + mysql_dbname: 'baikal' + mysql_username: 'baikal' + mysql_password: 'baikal' +""".format( + admin_hash=admin_hash + ) + + yaml_path.write_text(yaml_content) + print(f"✓ Created Baikal YAML config at {yaml_path}") + + +if __name__ == "__main__": + script_dir = Path(__file__).parent + + # Create database + db_path = script_dir / "Specific" / "db" / "db.sqlite" + create_baikal_db(db_path, username="testuser", password="testpass") + + # Create legacy PHP config files (for older Baikal versions) + config_path = script_dir / "Specific" / "config.php" + create_baikal_config(config_path) + + system_path = script_dir / "Specific" / "config.system.php" + create_system_config(system_path) + + # Create YAML config file (for newer Baikal versions 0.7.0+) + yaml_path = script_dir / "config" / "baikal.yaml" + create_baikal_yaml(yaml_path) + + print("\n" + "=" * 70) + print("Baikal pre-configuration complete!") + print("=" * 70) + print("\nYou can now start Baikal with: docker-compose up -d") + print("\nCredentials:") + print(" Admin: admin / admin") + print(" User: testuser / testpass") + print(" CalDAV URL: http://localhost:8800/dav.php/") diff --git a/tests/docker-test-servers/baikal/docker-compose.yml b/tests/docker-test-servers/baikal/docker-compose.yml new file mode 100644 index 00000000..ec1774cc --- /dev/null +++ b/tests/docker-test-servers/baikal/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3.3' + +services: + baikal: + image: ckulka/baikal:nginx + container_name: baikal-test + ports: + - "8800:80" + environment: + - BAIKAL_SERVERNAME=localhost diff --git a/tests/docker-test-servers/baikal/setup_baikal.sh b/tests/docker-test-servers/baikal/setup_baikal.sh new file mode 100755 index 00000000..f06864e1 --- /dev/null +++ b/tests/docker-test-servers/baikal/setup_baikal.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# 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. + +set -e + +BAIKAL_URL="${BAIKAL_URL:-http://localhost:8800}" +BAIKAL_ADMIN_PASSWORD="${BAIKAL_ADMIN_PASSWORD:-admin}" +BAIKAL_USERNAME="${BAIKAL_USERNAME:-testuser}" +BAIKAL_PASSWORD="${BAIKAL_PASSWORD:-testpass}" + +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 +} + +echo "Baikal is ready!" + +# 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 + +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 "" +echo "3. For CI/CD: See tests/baikal-config/ for sample configs" +echo "" +echo "================================================================" diff --git a/tests/docker-test-servers/baikal/start.sh b/tests/docker-test-servers/baikal/start.sh new file mode 100755 index 00000000..f13a184b --- /dev/null +++ b/tests/docker-test-servers/baikal/start.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Quick start script for Baikal test server with pre-configuration +# +# Usage: ./start.sh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "Creating container (not started yet)..." +docker-compose up --no-start + +echo "Copying pre-configured files into container..." +docker cp Specific/. baikal-test:/var/www/baikal/Specific/ +docker cp config/. baikal-test:/var/www/baikal/config/ + +echo "Starting Baikal (entrypoint will fix permissions)..." +docker start baikal-test + +echo "" +echo "Waiting for Baikal to be ready..." +sleep 5 +timeout 60 bash -c 'until curl -f http://localhost:8800/dav.php/ 2>/dev/null; do echo -n "."; sleep 2; done' || { + echo "" + echo "Error: Baikal did not become ready in time" + echo "Check logs with: docker-compose logs baikal" + exit 1 +} + +echo "" +echo "✓ Baikal is ready and pre-configured!" +echo "" +echo "Pre-configured credentials:" +echo " Admin: admin / admin" +echo " Test user: testuser / testpass" +echo " CalDAV URL: http://localhost:8800/dav.php/" +echo "" +echo "Run tests from project root:" +echo " cd ../../.." +echo " export BAIKAL_URL=http://localhost:8800" +echo " export BAIKAL_USERNAME=testuser" +echo " export BAIKAL_PASSWORD=testpass" +echo " pytest" +echo "" +echo "To stop Baikal: ./stop.sh" +echo "To view logs: docker-compose logs -f baikal" diff --git a/tests/docker-test-servers/baikal/stop.sh b/tests/docker-test-servers/baikal/stop.sh new file mode 100755 index 00000000..ba1fa916 --- /dev/null +++ b/tests/docker-test-servers/baikal/stop.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Stop script for Baikal test server +# +# Usage: ./stop.sh [--clean] + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +if [ "$1" = "--clean" ] || [ "$1" = "-c" ]; then + echo "Stopping Baikal and removing all data..." + docker-compose down -v + echo "✓ Baikal stopped and all data removed" +else + echo "Stopping Baikal (data will be preserved)..." + docker-compose down + echo "✓ Baikal stopped" + echo "" + echo "To remove all data: ./stop.sh --clean" +fi diff --git a/tox.ini b/tox.ini index b3e498bc..704f8292 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,10 @@ envlist = y39,py310,py311,py312,py313,py314,docs,style [testenv] deps = --editable .[test] +passenv = + BAIKAL_URL + BAIKAL_USERNAME + BAIKAL_PASSWORD commands = coverage run -m pytest [testenv:docs]