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 sys
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():
@@ -35,13 +54,16 @@ def get_free_port():
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()
config_path = tmp_path / "config"
user_path = tmp_path / "users"
storage_path = tmp_path / "collections"
# Create a local config file
sharing_path = tmp_path / "sharing.csv"
with open(config_path, "w") as f:
f.write(
f"""[server]
@@ -49,9 +71,15 @@ hosts = 127.0.0.1:{port}
[storage]
filesystem_folder = {storage_path}
[auth]
type = htpasswd
htpasswd_filename = {user_path}
[web]
type = {config.auth_type}
"""
)
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
[headers]
Content-Security-Policy = default-src 'self'; object-src 'none'
@@ -64,16 +92,20 @@ permit_create_map = true
permit_properties_overlay = true
collection_by_bday = true
permit_create_bday = true
database_path = {sharing_path}
{config.extra_config}
"""
)
with open(user_path, "w") as f:
f.write(
"""admin:adminpassword
if config.auth_type == "htpasswd":
with open(user_path, "w") as f:
f.write(
"""admin:adminpassword
max:maxpassword
"""
)
)
env = os.environ.copy()
# Ensure the radicale package is in PYTHONPATH
@@ -114,11 +146,28 @@ max:maxpassword
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.fill('#loginscene input[data-name="user"]', "admin")
page.fill('#loginscene input[data-name="password"]', "adminpassword")
page.click('button:has-text("Next")')
if config.auth_type == "htpasswd":
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:

View File

@@ -22,17 +22,26 @@ import pathlib
from typing import Any, Generator
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
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path)
def radicale_server(
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."""
console_msgs: list[str] = []
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
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."""
login(page, radicale_server)
login(page, radicale_server, config, context=context)
# After login, we should see the collections list (which is empty)
expect(

View File

@@ -22,18 +22,30 @@ import pathlib
from typing import Any, Generator
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
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path)
def radicale_server(
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:
login(page, radicale_server)
@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE])
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)
# 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()
def test_delete_correct_confirmation(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE])
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)
# Verify collection exists

View File

@@ -18,22 +18,36 @@
Integration tests for download page
"""
import pathlib
from typing import Any, Generator
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
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path)
def radicale_server(
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:
login(page, radicale_server)
def test_download_addressbook(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
page.click('.fabcontainer a[data-name="new"]')
# 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(
page: Page, radicale_server: str
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server)
login(page, radicale_server, config, context=context)
page.click('.fabcontainer a[data-name="new"]')
# a calendar is created

View File

@@ -22,18 +22,34 @@ import pathlib
from typing import Any, Generator
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
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path)
def radicale_server(
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:
login(page, radicale_server)
def test_edit_save(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server)
# 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)
def test_edit_cancel(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
def test_edit_cancel(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server)
# Get original values

View File

@@ -22,18 +22,34 @@ import pathlib
from typing import Any, Generator
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
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path)
def radicale_server(
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:
login(page, radicale_server)
def test_navigation_create_collection_cancel(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
expect(page.locator("#collectionsscene")).to_be_visible()
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()
def test_navigation_create_collection_submit(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
def test_navigation_create_collection_submit(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
expect(page.locator("#collectionsscene")).to_be_visible()
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()
def test_navigation_delete_collection_cancel(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
def test_navigation_delete_collection_cancel(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server)
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()
def test_navigation_delete_collection_confirm(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
def test_navigation_delete_collection_confirm(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
create_collection(page, radicale_server)
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)
def test_navigation_refresh_button(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
def test_navigation_refresh_button(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
expect(page.locator("#collectionsscene")).to_be_visible()
page.click('#logoutview a[data-name="refresh"]')
# It shows LoadingScene briefly then back to CollectionsScene
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
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
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path)
def radicale_server(
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:
login(page, radicale_server)
def test_create_and_delete_share_by_key(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
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)
@@ -75,8 +91,10 @@ def test_create_and_delete_share_by_key(page: Page, radicale_server: str) -> Non
).to_have_count(0)
def test_create_and_delete_share_by_map(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
def test_create_and_delete_share_by_map(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
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)
@@ -122,8 +140,10 @@ def test_create_and_delete_share_by_map(page: Page, radicale_server: str) -> Non
).to_have_count(0)
def test_share_with_property_overrides(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
def test_share_with_property_overrides(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
# Create a collection with specific details
page.click('a[data-name="new"]')
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)
def test_share_journal_no_overrides(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
def test_share_journal_no_overrides(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
# Create a collection of type JOURNAL
page.click('a[data-name="new"]')
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)
def test_edit_share_by_token(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
def test_edit_share_by_token(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
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)
@@ -249,8 +273,10 @@ def test_edit_share_by_token(page: Page, radicale_server: str) -> None:
).to_be_visible()
def test_edit_share_by_map(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
def test_edit_share_by_map(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
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)
@@ -290,8 +316,10 @@ def test_edit_share_by_map(page: Page, radicale_server: str) -> None:
page.click('#newshare button[data-name="cancel"]')
def test_share_by_map_validation(page: Page, radicale_server: str) -> None:
login(page, radicale_server)
def test_share_by_map_validation(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
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)
@@ -323,136 +351,10 @@ def test_share_by_map_validation(page: Page, radicale_server: str) -> None:
).to_have_count(1)
@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)
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)
def test_create_and_delete_share_by_bday(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
# create collection of type ADDRESSBOOK for bday (bday only works with ADDRESSBOOK)
page.click('a[data-name="new"]')
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)
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."""
login(page, radicale_server)
login(page, radicale_server, config, context=context)
page.click('a[data-name="new"]')
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"]')
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."""
login(page, radicale_server)
login(page, radicale_server, config, context=context)
page.click('a[data-name="new"]')
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
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
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path)
def radicale_server(
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:
login(page, radicale_server)
def test_upload_zero_files(
context: BrowserContext, page: Page, radicale_server: str, config: Config
) -> None:
login(page, radicale_server, config, context=context)
page.click('.fabcontainer a[data-name="upload"]')
# 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(
page: Page, radicale_server: str, tmp_path: pathlib.Path
context: BrowserContext,
page: Page,
radicale_server: str,
config: Config,
tmp_path: pathlib.Path,
) -> None:
login(page, radicale_server)
login(page, radicale_server, config, context=context)
# Create a fake file to upload
test_file = tmp_path / "test.ics"
@@ -80,9 +99,13 @@ def test_upload_one_file_custom_href(
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:
login(page, radicale_server)
login(page, radicale_server, config, context=context)
# Create two fake files
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")