Run all integ tests against different configs

This commit is contained in:
Max Berger
2026-03-26 21:03:20 +01:00
parent d56b87196a
commit 78cf5f7fe3
12 changed files with 500 additions and 424 deletions

View File

@@ -24,9 +24,28 @@ import socket
import subprocess import subprocess
import sys import sys
import time import time
from typing import Any, Generator from dataclasses import dataclass
from typing import Any, Generator, Optional
from playwright.sync_api import Page from playwright.sync_api import BrowserContext, Page
@dataclass(frozen=True)
class Config:
name: str
auth_type: str
extra_config: str = ""
SHARING_HTPASSWD = Config(
name="sharing_htpasswd",
auth_type="htpasswd",
)
SHARING_XREMOTE = Config(
name="sharing_xremote",
auth_type="http_x_remote_user",
)
def get_free_port(): def get_free_port():
@@ -35,13 +54,16 @@ def get_free_port():
return s.getsockname()[1] return s.getsockname()[1]
def start_radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: def start_radicale_server(
tmp_path: pathlib.Path, config: Config = SHARING_HTPASSWD
) -> Generator[str, Any, None]:
port = get_free_port() port = get_free_port()
config_path = tmp_path / "config" config_path = tmp_path / "config"
user_path = tmp_path / "users" user_path = tmp_path / "users"
storage_path = tmp_path / "collections" storage_path = tmp_path / "collections"
# Create a local config file sharing_path = tmp_path / "sharing.csv"
with open(config_path, "w") as f: with open(config_path, "w") as f:
f.write( f.write(
f"""[server] f"""[server]
@@ -49,9 +71,15 @@ hosts = 127.0.0.1:{port}
[storage] [storage]
filesystem_folder = {storage_path} filesystem_folder = {storage_path}
[auth] [auth]
type = htpasswd type = {config.auth_type}
htpasswd_filename = {user_path} """
[web] )
if config.auth_type == "htpasswd":
f.write(f"htpasswd_filename = {user_path}\n")
f.write("htpasswd_encryption = plain\n")
f.write(
f"""[web]
type = internal type = internal
[headers] [headers]
Content-Security-Policy = default-src 'self'; object-src 'none' Content-Security-Policy = default-src 'self'; object-src 'none'
@@ -64,16 +92,20 @@ permit_create_map = true
permit_properties_overlay = true permit_properties_overlay = true
collection_by_bday = true collection_by_bday = true
permit_create_bday = true permit_create_bday = true
database_path = {sharing_path}
{config.extra_config}
""" """
) )
with open(user_path, "w") as f:
f.write( if config.auth_type == "htpasswd":
"""admin:adminpassword with open(user_path, "w") as f:
f.write(
"""admin:adminpassword
max:maxpassword max:maxpassword
""" """
) )
env = os.environ.copy() env = os.environ.copy()
# Ensure the radicale package is in PYTHONPATH # Ensure the radicale package is in PYTHONPATH
@@ -114,11 +146,28 @@ max:maxpassword
process.wait() process.wait()
def login(page: Page, radicale_server: str) -> None: from playwright.sync_api import BrowserContext, Page, expect
def login(
page: Page,
radicale_server: str,
config: Config = SHARING_HTPASSWD,
context: Optional[BrowserContext] = None,
) -> None:
if config.auth_type == "http_x_remote_user":
if context is None:
raise ValueError("context is required for http_x_remote_user login")
context.set_extra_http_headers({"X-Remote-User": "admin"})
page.goto(radicale_server) page.goto(radicale_server)
page.fill('#loginscene input[data-name="user"]', "admin")
page.fill('#loginscene input[data-name="password"]', "adminpassword") if config.auth_type == "htpasswd":
page.click('button:has-text("Next")') page.fill('#loginscene input[data-name="user"]', "admin")
page.fill('#loginscene input[data-name="password"]', "adminpassword")
page.click('button:has-text("Next")')
expect(page.locator("#collectionsscene")).to_be_visible()
def create_collection(page: Page, radicale_server: str) -> None: def create_collection(page: Page, radicale_server: str) -> None:

View File

@@ -22,17 +22,26 @@ import pathlib
from typing import Any, Generator from typing import Any, Generator
import pytest import pytest
from playwright.sync_api import Page, expect from playwright.sync_api import BrowserContext, Page, expect
from integ_tests.common import login, start_radicale_server from integ_tests.common import (
SHARING_HTPASSWD,
SHARING_XREMOTE,
Config,
login,
start_radicale_server,
)
@pytest.fixture @pytest.fixture
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: def radicale_server(
yield from start_radicale_server(tmp_path) tmp_path: pathlib.Path, config: Config
) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path, config)
def test_index_html_loads(page: Page, radicale_server: str) -> None: @pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE])
def test_index_html_loads(page: Page, radicale_server: str, config: Config) -> None:
"""Test that the index.html loads from the server.""" """Test that the index.html loads from the server."""
console_msgs: list[str] = [] console_msgs: list[str] = []
page.on("console", lambda msg: console_msgs.append(msg.text)) page.on("console", lambda msg: console_msgs.append(msg.text))
@@ -43,9 +52,12 @@ def test_index_html_loads(page: Page, radicale_server: str) -> None:
assert len(errors) == 0 assert len(errors) == 0
def test_user_login_works(page: Page, radicale_server: str) -> None: @pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE])
def test_user_login_works(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
"""Test that the login form works.""" """Test that the login form works."""
login(page, radicale_server) login(page, radicale_server, config, context=context)
# After login, we should see the collections list (which is empty) # After login, we should see the collections list (which is empty)
expect( expect(

View File

@@ -22,18 +22,30 @@ import pathlib
from typing import Any, Generator from typing import Any, Generator
import pytest import pytest
from playwright.sync_api import Page, expect from playwright.sync_api import BrowserContext, Page, expect
from integ_tests.common import create_collection, login, start_radicale_server from integ_tests.common import (
SHARING_HTPASSWD,
SHARING_XREMOTE,
Config,
create_collection,
login,
start_radicale_server,
)
@pytest.fixture @pytest.fixture
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: def radicale_server(
yield from start_radicale_server(tmp_path) tmp_path: pathlib.Path, config: Config
) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path, config)
def test_delete_wrong_confirmation(page: Page, radicale_server: str) -> None: @pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE])
login(page, radicale_server) def test_delete_wrong_confirmation(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server) create_collection(page, radicale_server)
# Open delete scene # Open delete scene
@@ -55,8 +67,11 @@ def test_delete_wrong_confirmation(page: Page, radicale_server: str) -> None:
expect(page.locator("#deleteconfirmationscene")).to_be_visible() expect(page.locator("#deleteconfirmationscene")).to_be_visible()
def test_delete_correct_confirmation(page: Page, radicale_server: str) -> None: @pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE])
login(page, radicale_server) def test_delete_correct_confirmation(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server) create_collection(page, radicale_server)
# Verify collection exists # Verify collection exists

View File

@@ -18,22 +18,36 @@
Integration tests for download page Integration tests for download page
""" """
import pathlib
from typing import Any, Generator from typing import Any, Generator
import pytest import pytest
from playwright.sync_api import Page from playwright.sync_api import BrowserContext, Page
from integ_tests.common import login, start_radicale_server from integ_tests.common import (
SHARING_HTPASSWD,
SHARING_XREMOTE,
Config,
login,
start_radicale_server,
)
@pytest.fixture(params=[SHARING_HTPASSWD, SHARING_XREMOTE], ids=lambda c: c.name)
def config(request: pytest.FixtureRequest) -> Config:
return request.param
@pytest.fixture @pytest.fixture
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: def radicale_server(
yield from start_radicale_server(tmp_path) tmp_path: pathlib.Path, config: Config
) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path, config)
def test_download_addressbook(page: Page, radicale_server: str) -> None: def test_download_addressbook(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
page.click('.fabcontainer a[data-name="new"]') page.click('.fabcontainer a[data-name="new"]')
# an address book is created # an address book is created
@@ -52,9 +66,9 @@ def test_download_addressbook(page: Page, radicale_server: str) -> None:
def test_download_calendar_uses_displayname_ics( def test_download_calendar_uses_displayname_ics(
page: Page, radicale_server: str context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None: ) -> None:
login(page, radicale_server) login(page, radicale_server, config, context=context)
page.click('.fabcontainer a[data-name="new"]') page.click('.fabcontainer a[data-name="new"]')
# a calendar is created # a calendar is created

View File

@@ -22,18 +22,34 @@ import pathlib
from typing import Any, Generator from typing import Any, Generator
import pytest import pytest
from playwright.sync_api import Page, expect from playwright.sync_api import BrowserContext, Page, expect
from integ_tests.common import create_collection, login, start_radicale_server from integ_tests.common import (
SHARING_HTPASSWD,
SHARING_XREMOTE,
Config,
create_collection,
login,
start_radicale_server,
)
@pytest.fixture(params=[SHARING_HTPASSWD, SHARING_XREMOTE], ids=lambda c: c.name)
def config(request: pytest.FixtureRequest) -> Config:
return request.param
@pytest.fixture @pytest.fixture
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: def radicale_server(
yield from start_radicale_server(tmp_path) tmp_path: pathlib.Path, config: Config
) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path, config)
def test_edit_save(page: Page, radicale_server: str) -> None: def test_edit_save(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server) create_collection(page, radicale_server)
# Get original values # Get original values
@@ -55,8 +71,10 @@ def test_edit_save(page: Page, radicale_server: str) -> None:
expect(article.locator('[data-name="description"]')).to_have_text(new_description) expect(article.locator('[data-name="description"]')).to_have_text(new_description)
def test_edit_cancel(page: Page, radicale_server: str) -> None: def test_edit_cancel(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server) create_collection(page, radicale_server)
# Get original values # Get original values

View File

@@ -22,18 +22,34 @@ import pathlib
from typing import Any, Generator from typing import Any, Generator
import pytest import pytest
from playwright.sync_api import Page, expect from playwright.sync_api import BrowserContext, Page, expect
from integ_tests.common import create_collection, login, start_radicale_server from integ_tests.common import (
SHARING_HTPASSWD,
SHARING_XREMOTE,
Config,
create_collection,
login,
start_radicale_server,
)
@pytest.fixture(params=[SHARING_HTPASSWD, SHARING_XREMOTE], ids=lambda c: c.name)
def config(request: pytest.FixtureRequest) -> Config:
return request.param
@pytest.fixture @pytest.fixture
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: def radicale_server(
yield from start_radicale_server(tmp_path) tmp_path: pathlib.Path, config: Config
) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path, config)
def test_navigation_create_collection_cancel(page: Page, radicale_server: str) -> None: def test_navigation_create_collection_cancel(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
expect(page.locator("#collectionsscene")).to_be_visible() expect(page.locator("#collectionsscene")).to_be_visible()
page.click('a[data-name="new"]') page.click('a[data-name="new"]')
@@ -44,8 +60,10 @@ def test_navigation_create_collection_cancel(page: Page, radicale_server: str) -
expect(page.locator("#collectionsscene")).to_be_visible() expect(page.locator("#collectionsscene")).to_be_visible()
def test_navigation_create_collection_submit(page: Page, radicale_server: str) -> None: def test_navigation_create_collection_submit(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
expect(page.locator("#collectionsscene")).to_be_visible() expect(page.locator("#collectionsscene")).to_be_visible()
page.click('a[data-name="new"]') page.click('a[data-name="new"]')
@@ -61,8 +79,10 @@ def test_navigation_create_collection_submit(page: Page, radicale_server: str) -
expect(page.locator("article:has-text('Nav Test Col')")).to_be_visible() expect(page.locator("article:has-text('Nav Test Col')")).to_be_visible()
def test_navigation_delete_collection_cancel(page: Page, radicale_server: str) -> None: def test_navigation_delete_collection_cancel(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server) create_collection(page, radicale_server)
expect(page.locator("#collectionsscene")).to_be_visible() expect(page.locator("#collectionsscene")).to_be_visible()
@@ -75,8 +95,10 @@ def test_navigation_delete_collection_cancel(page: Page, radicale_server: str) -
expect(page.locator("#collectionsscene")).to_be_visible() expect(page.locator("#collectionsscene")).to_be_visible()
def test_navigation_delete_collection_confirm(page: Page, radicale_server: str) -> None: def test_navigation_delete_collection_confirm(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server) create_collection(page, radicale_server)
expect(page.locator("#collectionsscene")).to_be_visible() expect(page.locator("#collectionsscene")).to_be_visible()
@@ -98,25 +120,12 @@ def test_navigation_delete_collection_confirm(page: Page, radicale_server: str)
expect(page.locator("article:not(.hidden)")).to_have_count(0) expect(page.locator("article:not(.hidden)")).to_have_count(0)
def test_navigation_refresh_button(page: Page, radicale_server: str) -> None: def test_navigation_refresh_button(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
expect(page.locator("#collectionsscene")).to_be_visible() expect(page.locator("#collectionsscene")).to_be_visible()
page.click('#logoutview a[data-name="refresh"]') page.click('#logoutview a[data-name="refresh"]')
# It shows LoadingScene briefly then back to CollectionsScene # It shows LoadingScene briefly then back to CollectionsScene
expect(page.locator("#collectionsscene")).to_be_visible() expect(page.locator("#collectionsscene")).to_be_visible()
def test_login_logout_login(page: Page, radicale_server: str) -> None:
# 1. First login
login(page, radicale_server)
expect(page.locator("#collectionsscene")).to_be_visible()
# 2. Logout
page.click('#logoutview a[data-name="logout"]')
expect(page.locator("#loginscene")).to_be_visible()
expect(page.locator("#collectionsscene")).to_be_hidden()
# 3. Second login
login(page, radicale_server)
expect(page.locator("#collectionsscene")).to_be_visible()

View File

@@ -0,0 +1,47 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2026-2026 Max Berger <max@berger.name>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Integration tests for scene navigation (login/logout specific)
"""
import pathlib
from typing import Any, Generator
import pytest
from playwright.sync_api import Page, expect
from integ_tests.common import SHARING_HTPASSWD, login, start_radicale_server
@pytest.fixture
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path, SHARING_HTPASSWD)
def test_login_logout_login(page: Page, radicale_server: str) -> None:
# 1. First login
login(page, radicale_server, SHARING_HTPASSWD)
expect(page.locator("#collectionsscene")).to_be_visible()
# 2. Logout
page.click('#logoutview a[data-name="logout"]')
expect(page.locator("#loginscene")).to_be_visible()
expect(page.locator("#collectionsscene")).to_be_hidden()
# 3. Second login
login(page, radicale_server, SHARING_HTPASSWD)
expect(page.locator("#collectionsscene")).to_be_visible()

View File

@@ -22,18 +22,34 @@ import pathlib
from typing import Any, Generator from typing import Any, Generator
import pytest import pytest
from playwright.sync_api import Page, expect from playwright.sync_api import BrowserContext, Page, expect
from integ_tests.common import create_collection, login, start_radicale_server from integ_tests.common import (
SHARING_HTPASSWD,
SHARING_XREMOTE,
Config,
create_collection,
login,
start_radicale_server,
)
@pytest.fixture(params=[SHARING_HTPASSWD, SHARING_XREMOTE], ids=lambda c: c.name)
def config(request: pytest.FixtureRequest) -> Config:
return request.param
@pytest.fixture @pytest.fixture
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: def radicale_server(
yield from start_radicale_server(tmp_path) tmp_path: pathlib.Path, config: Config
) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path, config)
def test_create_and_delete_share_by_key(page: Page, radicale_server: str) -> None: def test_create_and_delete_share_by_key(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server) create_collection(page, radicale_server)
page.hover("article:not(.hidden)") page.hover("article:not(.hidden)")
page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True)
@@ -75,8 +91,10 @@ def test_create_and_delete_share_by_key(page: Page, radicale_server: str) -> Non
).to_have_count(0) ).to_have_count(0)
def test_create_and_delete_share_by_map(page: Page, radicale_server: str) -> None: def test_create_and_delete_share_by_map(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server) create_collection(page, radicale_server)
page.hover("article:not(.hidden)") page.hover("article:not(.hidden)")
page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True)
@@ -122,8 +140,10 @@ def test_create_and_delete_share_by_map(page: Page, radicale_server: str) -> Non
).to_have_count(0) ).to_have_count(0)
def test_share_with_property_overrides(page: Page, radicale_server: str) -> None: def test_share_with_property_overrides(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
# Create a collection with specific details # Create a collection with specific details
page.click('a[data-name="new"]') page.click('a[data-name="new"]')
page.locator('#createcollectionscene input[data-name="displayname"]').fill( page.locator('#createcollectionscene input[data-name="displayname"]').fill(
@@ -177,8 +197,10 @@ def test_share_with_property_overrides(page: Page, radicale_server: str) -> None
).to_have_count(1) ).to_have_count(1)
def test_share_journal_no_overrides(page: Page, radicale_server: str) -> None: def test_share_journal_no_overrides(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
# Create a collection of type JOURNAL # Create a collection of type JOURNAL
page.click('a[data-name="new"]') page.click('a[data-name="new"]')
page.locator('#createcollectionscene select[data-name="type"]').select_option( page.locator('#createcollectionscene select[data-name="type"]').select_option(
@@ -220,8 +242,10 @@ def test_share_journal_no_overrides(page: Page, radicale_server: str) -> None:
).to_have_count(1) ).to_have_count(1)
def test_edit_share_by_token(page: Page, radicale_server: str) -> None: def test_edit_share_by_token(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server) create_collection(page, radicale_server)
page.hover("article:not(.hidden)") page.hover("article:not(.hidden)")
page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True)
@@ -249,8 +273,10 @@ def test_edit_share_by_token(page: Page, radicale_server: str) -> None:
).to_be_visible() ).to_be_visible()
def test_edit_share_by_map(page: Page, radicale_server: str) -> None: def test_edit_share_by_map(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server) create_collection(page, radicale_server)
page.hover("article:not(.hidden)") page.hover("article:not(.hidden)")
page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True)
@@ -290,8 +316,10 @@ def test_edit_share_by_map(page: Page, radicale_server: str) -> None:
page.click('#newshare button[data-name="cancel"]') page.click('#newshare button[data-name="cancel"]')
def test_share_by_map_validation(page: Page, radicale_server: str) -> None: def test_share_by_map_validation(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server) create_collection(page, radicale_server)
page.hover("article:not(.hidden)") page.hover("article:not(.hidden)")
page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True)
@@ -323,136 +351,10 @@ def test_share_by_map_validation(page: Page, radicale_server: str) -> None:
).to_have_count(1) ).to_have_count(1)
@pytest.mark.parametrize("permissions", ["ro", "rw"]) def test_create_and_delete_share_by_bday(
def test_incoming_shares(page: Page, radicale_server: str, permissions: str) -> None: context: BrowserContext, page: Page, radicale_server: str, config: Config
# 1. Admin logs in and creates a map share for 'max' ) -> None:
login(page, radicale_server) login(page, radicale_server, config, context=context)
create_collection(page, radicale_server)
page.hover("article:not(.hidden)")
page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True)
page.click('button[data-name="sharebymap"]')
page.locator('input[data-name="shareuser"]').fill("max")
page.locator('input[data-name="sharehref"]').fill("mapped")
if permissions == "rw":
page.check("#newshare_attr_permissions_rw")
page.click('#newshare button[data-name="submit"]')
expect(
page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)")
).to_have_count(1)
page.click('#sharecollectionscene button[data-name="cancel"]')
# 2. Admin logs out
page.click('a[data-name="logout"]')
# 3. Max logs in
page.fill('#loginscene input[data-name="user"]', "max")
page.fill('#loginscene input[data-name="password"]', "maxpassword")
page.click('button:has-text("Next")')
# 4. Max sees the incoming share
page.click('a[data-name="incomingshares"]')
expect(page.locator("#incomingsharingscene")).to_be_visible()
expect(
page.locator("tr[data-name='incomingsharerowtemplate']:not(.hidden)")
).to_have_count(1)
expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='pathortoken']"
)
).to_have_value("mapped")
# 5. Max enables and shows the share
# Initially, it's disabled and not shown (security by default)
expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='enabled']"
)
).not_to_be_checked()
expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
)
).not_to_be_checked()
expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
)
).to_be_disabled()
# Enable it
page.check(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='enabled']"
)
expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
)
).not_to_be_disabled()
# Show it
page.check(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
)
expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
)
).to_be_checked()
# 6. Verify "shared by admin" and button visibility in the collection article
page.click('#incomingsharingscene button[data-name="cancel"]')
expect(page.locator("#incomingsharingscene")).to_be_hidden()
article = page.locator("article:not(.hidden)").first
expect(article.locator('[data-name="shared-by"]')).to_be_visible()
expect(article.locator('[data-name="shared-by-owner"]')).to_have_text("admin")
# Action buttons are only visible on mouseover
article.hover()
# Share and delete buttons should be hidden for all incoming shares
expect(article.locator('a[data-name="share"]')).to_be_hidden()
expect(article.locator('[data-name="shareoption"]')).to_be_hidden()
expect(article.locator('a[data-name="delete"]')).to_be_hidden()
# Edit button depends on permissions
if permissions == "rw":
expect(article.locator('a[data-name="edit"]')).to_be_visible()
else:
expect(article.locator('a[data-name="edit"]')).to_be_hidden()
# 7. Assert no error was shown
expect(page.locator('#incomingsharingscene span[data-name="error"]')).to_be_hidden()
def test_no_incoming_shares_message(page: Page, radicale_server: str) -> None:
# 1. Max logs in
page.goto(radicale_server)
page.fill('#loginscene input[data-name="user"]', "max")
page.fill('#loginscene input[data-name="password"]', "maxpassword")
page.click('button:has-text("Next")')
# 2. Max goes to incoming shares scene
page.click('a[data-name="incomingshares"]')
expect(page.locator("#incomingsharingscene")).to_be_visible()
# 3. Verify that the table is hidden and the message is visible
expect(page.locator("#incomingsharingscene table")).to_be_hidden()
expect(
page.locator('#incomingsharingscene [data-name="nosharesmessage"]')
).to_be_visible()
expect(
page.locator('#incomingsharingscene [data-name="nosharesmessage"]')
).to_have_text("No incoming shares")
page.click('#incomingsharingscene button[data-name="cancel"]')
expect(page.locator("#incomingsharingscene")).to_be_hidden()
def test_create_and_delete_share_by_bday(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
# create collection of type ADDRESSBOOK for bday (bday only works with ADDRESSBOOK) # create collection of type ADDRESSBOOK for bday (bday only works with ADDRESSBOOK)
page.click('a[data-name="new"]') page.click('a[data-name="new"]')
page.locator('#createcollectionscene select[data-name="type"]').select_option( page.locator('#createcollectionscene select[data-name="type"]').select_option(
@@ -513,9 +415,11 @@ def test_create_and_delete_share_by_bday(page: Page, radicale_server: str) -> No
).to_have_count(0) ).to_have_count(0)
def test_bday_section_hidden_for_calendar(page: Page, radicale_server: str) -> None: def test_bday_section_hidden_for_calendar(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
"""Verify the bday calendar section is hidden for CALENDAR collections.""" """Verify the bday calendar section is hidden for CALENDAR collections."""
login(page, radicale_server) login(page, radicale_server, config, context=context)
page.click('a[data-name="new"]') page.click('a[data-name="new"]')
page.locator('#createcollectionscene select[data-name="type"]').select_option( page.locator('#createcollectionscene select[data-name="type"]').select_option(
@@ -534,9 +438,11 @@ def test_bday_section_hidden_for_calendar(page: Page, radicale_server: str) -> N
page.click('#sharecollectionscene button[data-name="cancel"]') page.click('#sharecollectionscene button[data-name="cancel"]')
def test_bday_section_visible_for_addressbook(page: Page, radicale_server: str) -> None: def test_bday_section_visible_for_addressbook(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
"""Verify the bday calendar section is visible for ADDRESSBOOK collections.""" """Verify the bday calendar section is visible for ADDRESSBOOK collections."""
login(page, radicale_server) login(page, radicale_server, config, context=context)
page.click('a[data-name="new"]') page.click('a[data-name="new"]')
page.locator('#createcollectionscene select[data-name="type"]').select_option( page.locator('#createcollectionscene select[data-name="type"]').select_option(

View File

@@ -0,0 +1,165 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2026-2026 Max Berger <max@berger.name>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Integration tests for sharing (login/logout specific)
"""
import pathlib
from typing import Any, Generator
import pytest
from playwright.sync_api import Page, expect
from integ_tests.common import (
SHARING_HTPASSWD,
create_collection,
login,
start_radicale_server,
)
@pytest.fixture
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path, SHARING_HTPASSWD)
@pytest.mark.parametrize("permissions", ["ro", "rw"])
def test_incoming_shares(page: Page, radicale_server: str, permissions: str) -> None:
# 1. Admin logs in and creates a map share for 'max'
login(page, radicale_server, SHARING_HTPASSWD)
create_collection(page, radicale_server)
page.hover("article:not(.hidden)")
page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True)
page.click('button[data-name="sharebymap"]')
page.locator('input[data-name="shareuser"]').fill("max")
page.locator('input[data-name="sharehref"]').fill("mapped")
if permissions == "rw":
page.check("#newshare_attr_permissions_rw")
page.click('#newshare button[data-name="submit"]')
expect(
page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)")
).to_have_count(1)
page.click('#sharecollectionscene button[data-name="cancel"]')
# 2. Admin logs out
page.click('a[data-name="logout"]')
# 3. Max logs in
page.fill('#loginscene input[data-name="user"]', "max")
page.fill('#loginscene input[data-name="password"]', "maxpassword")
page.click('button:has-text("Next")')
# 4. Max sees the incoming share
page.click('a[data-name="incomingshares"]')
expect(page.locator("#incomingsharingscene")).to_be_visible()
expect(
page.locator("tr[data-name='incomingsharerowtemplate']:not(.hidden)")
).to_have_count(1)
expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='pathortoken']"
)
).to_have_value("mapped")
# 5. Max enables and shows the share
# Initially, it's disabled and not shown (security by default)
expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='enabled']"
)
).not_to_be_checked()
expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
)
).not_to_be_checked()
expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
)
).to_be_disabled()
# Enable it
page.check(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='enabled']"
)
expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
)
).not_to_be_disabled()
# Show it
page.check(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
)
expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
)
).to_be_checked()
# 6. Verify "shared by admin" and button visibility in the collection article
page.click('#incomingsharingscene button[data-name="cancel"]')
expect(page.locator("#incomingsharingscene")).to_be_hidden()
article = page.locator("article:not(.hidden)").first
expect(article.locator('[data-name="shared-by"]')).to_be_visible()
expect(article.locator('[data-name="shared-by-owner"]')).to_have_text("admin")
# Action buttons are only visible on mouseover
article.hover()
# Share and delete buttons should be hidden for all incoming shares
expect(article.locator('a[data-name="share"]')).to_be_hidden()
expect(article.locator('[data-name="shareoption"]')).to_be_hidden()
expect(article.locator('a[data-name="delete"]')).to_be_hidden()
# Edit button depends on permissions
if permissions == "rw":
expect(article.locator('a[data-name="edit"]')).to_be_visible()
else:
expect(article.locator('a[data-name="edit"]')).to_be_hidden()
# 7. Assert no error was shown
expect(page.locator('#incomingsharingscene span[data-name="error"]')).to_be_hidden()
def test_no_incoming_shares_message(page: Page, radicale_server: str) -> None:
# 1. Max logs in
page.goto(radicale_server)
page.fill('#loginscene input[data-name="user"]', "max")
page.fill('#loginscene input[data-name="password"]', "maxpassword")
page.click('button:has-text("Next")')
# 2. Max goes to incoming shares scene
page.click('a[data-name="incomingshares"]')
expect(page.locator("#incomingsharingscene")).to_be_visible()
# 3. Verify that the table is hidden and the message is visible
expect(page.locator("#incomingsharingscene table")).to_be_hidden()
expect(
page.locator('#incomingsharingscene [data-name="nosharesmessage"]')
).to_be_visible()
expect(
page.locator('#incomingsharingscene [data-name="nosharesmessage"]')
).to_have_text("No incoming shares")
page.click('#incomingsharingscene button[data-name="cancel"]')
expect(page.locator("#incomingsharingscene")).to_be_hidden()

View File

@@ -23,18 +23,33 @@ import re
from typing import Any, Generator from typing import Any, Generator
import pytest import pytest
from playwright.sync_api import Page, expect from playwright.sync_api import BrowserContext, Page, expect
from integ_tests.common import login, start_radicale_server from integ_tests.common import (
SHARING_HTPASSWD,
SHARING_XREMOTE,
Config,
login,
start_radicale_server,
)
@pytest.fixture(params=[SHARING_HTPASSWD, SHARING_XREMOTE], ids=lambda c: c.name)
def config(request: pytest.FixtureRequest) -> Config:
return request.param
@pytest.fixture @pytest.fixture
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: def radicale_server(
yield from start_radicale_server(tmp_path) tmp_path: pathlib.Path, config: Config
) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path, config)
def test_upload_zero_files(page: Page, radicale_server: str) -> None: def test_upload_zero_files(
login(page, radicale_server) context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
page.click('.fabcontainer a[data-name="upload"]') page.click('.fabcontainer a[data-name="upload"]')
# Click upload without selecting files # Click upload without selecting files
@@ -47,9 +62,13 @@ def test_upload_zero_files(page: Page, radicale_server: str) -> None:
def test_upload_one_file_custom_href( def test_upload_one_file_custom_href(
page: Page, radicale_server: str, tmp_path: pathlib.Path context: BrowserContext,
page: Page,
radicale_server: str,
config: Config,
tmp_path: pathlib.Path,
) -> None: ) -> None:
login(page, radicale_server) login(page, radicale_server, config, context=context)
# Create a fake file to upload # Create a fake file to upload
test_file = tmp_path / "test.ics" test_file = tmp_path / "test.ics"
@@ -80,9 +99,13 @@ def test_upload_one_file_custom_href(
def test_upload_two_files( def test_upload_two_files(
page: Page, radicale_server: str, tmp_path: pathlib.Path context: BrowserContext,
page: Page,
radicale_server: str,
config: Config,
tmp_path: pathlib.Path,
) -> None: ) -> None:
login(page, radicale_server) login(page, radicale_server, config, context=context)
# Create two fake files # Create two fake files
file1 = tmp_path / "test1.ics" file1 = tmp_path / "test1.ics"

View File

@@ -1,185 +0,0 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2026-2026 Max Berger <max@berger.name>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Integration test for X-Remote-User authentication
"""
import os
import pathlib
import socket
import subprocess
import sys
import time
from typing import Any, Generator
import pytest
from playwright.sync_api import BrowserContext, Page, expect
from integ_tests.common import create_collection, get_free_port
def start_radicale_server_remote(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
port = get_free_port()
config_path = tmp_path / "config"
storage_path = tmp_path / "collections"
# Create a local config file with http_x_remote_user auth
with open(config_path, "w") as f:
f.write(
f"""[server]
hosts = 127.0.0.1:{port}
[storage]
filesystem_folder = {storage_path}
[auth]
type = http_x_remote_user
[web]
type = internal
[headers]
Content-Security-Policy = default-src 'self'; object-src 'none'
[sharing]
type = csv
collection_by_map = true
collection_by_token = true
permit_create_token = true
permit_create_map = true
permit_properties_overlay = true
collection_by_bday = true
permit_create_bday = true
"""
)
env = os.environ.copy()
# Ensure the radicale package is in PYTHONPATH
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "")
# Run the server
process = subprocess.Popen(
[sys.executable, "-m", "radicale", "--config", str(config_path)],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Wait for the server to start listening
start_time = time.time()
while time.time() - start_time < 10:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
break
except (OSError, ConnectionRefusedError):
if process.poll() is not None:
_stdout, stderr = process.communicate()
raise RuntimeError(
f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}"
)
time.sleep(0.1)
else:
process.terminate()
process.wait()
raise RuntimeError("Timeout waiting for Radicale to start")
yield f"http://127.0.0.1:{port}"
# Cleanup
process.terminate()
process.wait()
@pytest.fixture
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
yield from start_radicale_server_remote(tmp_path)
def test_index_html_loads(
context: BrowserContext, page: Page, radicale_server: str
) -> None:
"""Test that the index.html loads from the server with remote user."""
context.set_extra_http_headers({"X-Remote-User": "admin"})
console_msgs: list[str] = []
page.on("console", lambda msg: console_msgs.append(msg.text))
page.goto(radicale_server)
expect(page).to_have_title("Radicale Web Interface")
# There should be no errors on the console, except for the expected 401 from initial auto-login check
errors = [msg for msg in console_msgs if "401 (Unauthorized)" not in msg]
assert len(errors) == 0
def test_user_authenticated(
context: BrowserContext, page: Page, radicale_server: str
) -> None:
"""Test that the user is automatically authenticated via X-Remote-User."""
context.set_extra_http_headers({"X-Remote-User": "admin"})
page.goto(radicale_server)
# The login page should be skipped entirely if authenticated.
# After auto-login, we should see the collections list (which is empty)
expect(
page.locator(
'#logoutview span[data-name="user"]', has_text="admin's Collections"
)
).to_be_visible()
expect(page.locator('#logoutview a[data-name="logout"]')).to_be_hidden()
def test_create_collection_works(
context: BrowserContext, page: Page, radicale_server: str
) -> None:
"""Test creating a collection with remote user."""
context.set_extra_http_headers({"X-Remote-User": "admin"})
page.goto(radicale_server)
# Wait for auto-login
expect(
page.locator(
'#logoutview span[data-name="user"]', has_text="admin's Collections"
)
).to_be_visible()
create_collection(page, radicale_server)
expect(page.locator("article:not(.hidden)")).to_have_count(1)
expect(page.locator("article:not(.hidden) .title")).to_be_visible()
def test_download_works_with_remote_user(
context: BrowserContext, page: Page, radicale_server: str
) -> None:
"""Test downloading a collection with remote user."""
context.set_extra_http_headers({"X-Remote-User": "admin"})
page.goto(radicale_server)
# Wait for auto-login
expect(
page.locator(
'#logoutview span[data-name="user"]', has_text="admin's Collections"
)
).to_be_visible()
create_collection(page, radicale_server)
# Start waiting for the download
with page.expect_download() as download_info:
# Perform the action that initiates download
page.hover("article:not(.hidden)")
page.click('article:not(.hidden) a[data-name="download"]')
download = download_info.value
assert download.suggested_filename.endswith(
".ics"
) or download.suggested_filename.endswith(".vcf")

View File

@@ -67,6 +67,7 @@ export class LoginScene {
*/ */
function perform_login(p_user, p_password) { function perform_login(p_user, p_password) {
user = p_user; user = p_user;
fill_form();
// setup logout // setup logout
logout_view.classList.remove("hidden"); logout_view.classList.remove("hidden");
if (p_password === null) { if (p_password === null) {
@@ -165,7 +166,9 @@ export class LoginScene {
let authenticated_user = principal_collection.displayname; let authenticated_user = principal_collection.displayname;
if (!authenticated_user) { if (!authenticated_user) {
let href = principal_collection.href.replace(/\/+$/, ""); let href = principal_collection.href.replace(/\/+$/, "");
authenticated_user = href.substring(href.lastIndexOf("/") + 1); if (href && href !== ROOT_PATH.replace(/\/+$/, "")) {
authenticated_user = href.substring(href.lastIndexOf("/") + 1);
}
} }
perform_login(authenticated_user, null); perform_login(authenticated_user, null);
} }