From 78cf5f7fe343133ddea4ed673313b3a39493928d Mon Sep 17 00:00:00 2001 From: Max Berger Date: Thu, 26 Mar 2026 21:03:20 +0100 Subject: [PATCH 1/5] Run all integ tests against different configs --- integ_tests/common.py | 79 +++++-- integ_tests/test_basic_operation.py | 26 ++- integ_tests/test_delete.py | 31 ++- integ_tests/test_download.py | 32 ++- integ_tests/test_edit.py | 34 ++- integ_tests/test_scenes.py | 67 +++--- integ_tests/test_scenes_login.py | 47 ++++ integ_tests/test_sharing.py | 210 +++++------------- integ_tests/test_sharing_login.py | 165 ++++++++++++++ integ_tests/test_upload.py | 43 +++- integ_tests/test_x_remote_user.py | 185 --------------- .../web/internal_data/js/scenes/LoginScene.js | 5 +- 12 files changed, 500 insertions(+), 424 deletions(-) create mode 100644 integ_tests/test_scenes_login.py create mode 100644 integ_tests/test_sharing_login.py delete mode 100644 integ_tests/test_x_remote_user.py diff --git a/integ_tests/common.py b/integ_tests/common.py index 06a6a4a3..9b344020 100644 --- a/integ_tests/common.py +++ b/integ_tests/common.py @@ -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: diff --git a/integ_tests/test_basic_operation.py b/integ_tests/test_basic_operation.py index ccc34024..1adc8731 100644 --- a/integ_tests/test_basic_operation.py +++ b/integ_tests/test_basic_operation.py @@ -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( diff --git a/integ_tests/test_delete.py b/integ_tests/test_delete.py index ccc86ee5..8cd40a61 100644 --- a/integ_tests/test_delete.py +++ b/integ_tests/test_delete.py @@ -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 diff --git a/integ_tests/test_download.py b/integ_tests/test_download.py index 594a24f8..e7adaaf4 100644 --- a/integ_tests/test_download.py +++ b/integ_tests/test_download.py @@ -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 diff --git a/integ_tests/test_edit.py b/integ_tests/test_edit.py index 39e6631b..c7fda8bd 100644 --- a/integ_tests/test_edit.py +++ b/integ_tests/test_edit.py @@ -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 diff --git a/integ_tests/test_scenes.py b/integ_tests/test_scenes.py index a048030d..16e5274a 100644 --- a/integ_tests/test_scenes.py +++ b/integ_tests/test_scenes.py @@ -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() diff --git a/integ_tests/test_scenes_login.py b/integ_tests/test_scenes_login.py new file mode 100644 index 00000000..6ccc08d6 --- /dev/null +++ b/integ_tests/test_scenes_login.py @@ -0,0 +1,47 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 Max Berger +# +# 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 . + +""" +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() diff --git a/integ_tests/test_sharing.py b/integ_tests/test_sharing.py index 48c37f9b..47dfb95d 100644 --- a/integ_tests/test_sharing.py +++ b/integ_tests/test_sharing.py @@ -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( diff --git a/integ_tests/test_sharing_login.py b/integ_tests/test_sharing_login.py new file mode 100644 index 00000000..bbabd895 --- /dev/null +++ b/integ_tests/test_sharing_login.py @@ -0,0 +1,165 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 Max Berger +# +# 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 . + +""" +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() diff --git a/integ_tests/test_upload.py b/integ_tests/test_upload.py index 66a1ca7d..fcb6fb9d 100644 --- a/integ_tests/test_upload.py +++ b/integ_tests/test_upload.py @@ -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" diff --git a/integ_tests/test_x_remote_user.py b/integ_tests/test_x_remote_user.py deleted file mode 100644 index 2bbd46b7..00000000 --- a/integ_tests/test_x_remote_user.py +++ /dev/null @@ -1,185 +0,0 @@ -# This file is part of Radicale - CalDAV and CardDAV server -# Copyright © 2026-2026 Max Berger -# -# 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 . - -""" -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") diff --git a/radicale/web/internal_data/js/scenes/LoginScene.js b/radicale/web/internal_data/js/scenes/LoginScene.js index a3b7479d..aff56d6a 100644 --- a/radicale/web/internal_data/js/scenes/LoginScene.js +++ b/radicale/web/internal_data/js/scenes/LoginScene.js @@ -67,6 +67,7 @@ export class LoginScene { */ function perform_login(p_user, p_password) { user = p_user; + fill_form(); // setup logout logout_view.classList.remove("hidden"); if (p_password === null) { @@ -165,7 +166,9 @@ export class LoginScene { let authenticated_user = principal_collection.displayname; if (!authenticated_user) { 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); } From bde188ff559813f5f268d07c52833d4b76e99ca8 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Thu, 26 Mar 2026 21:20:02 +0100 Subject: [PATCH 2/5] Add new test for non-sharing, fix non-sharing use case --- integ_tests/common.py | 50 ++++++++++++++----- integ_tests/test_basic_operation.py | 5 +- integ_tests/test_delete.py | 5 +- integ_tests/test_download.py | 5 +- integ_tests/test_edit.py | 5 +- integ_tests/test_scenes.py | 5 +- integ_tests/test_scenes_login.py | 25 +++++++--- integ_tests/test_upload.py | 5 +- radicale/web/internal_data/js/api/sharing.js | 5 +- .../js/scenes/ShareCollectionScene.js | 21 ++++++-- 10 files changed, 100 insertions(+), 31 deletions(-) diff --git a/integ_tests/common.py b/integ_tests/common.py index 9b344020..4f758238 100644 --- a/integ_tests/common.py +++ b/integ_tests/common.py @@ -30,21 +30,43 @@ from typing import Any, Generator, Optional from playwright.sync_api import BrowserContext, Page +from enum import Enum + + +class AuthType(Enum): + HTPASSWD = "htpasswd" + XREMOTE = "http_x_remote_user" + + +class SharingType(Enum): + SHARING = "sharing" + NOSHARING = "nosharing" + + @dataclass(frozen=True) class Config: name: str - auth_type: str + auth_type: AuthType + sharing_type: SharingType extra_config: str = "" SHARING_HTPASSWD = Config( name="sharing_htpasswd", - auth_type="htpasswd", + auth_type=AuthType.HTPASSWD, + sharing_type=SharingType.SHARING, ) SHARING_XREMOTE = Config( name="sharing_xremote", - auth_type="http_x_remote_user", + auth_type=AuthType.XREMOTE, + sharing_type=SharingType.SHARING, +) + +NOSHARE_HTPASSWD = Config( + name="noshare_htpasswd", + auth_type=AuthType.HTPASSWD, + sharing_type=SharingType.NOSHARING, ) @@ -71,10 +93,10 @@ hosts = 127.0.0.1:{port} [storage] filesystem_folder = {storage_path} [auth] -type = {config.auth_type} +type = {config.auth_type.value} """ ) - if config.auth_type == "htpasswd": + if config.auth_type == AuthType.HTPASSWD: f.write(f"htpasswd_filename = {user_path}\n") f.write("htpasswd_encryption = plain\n") @@ -83,7 +105,11 @@ type = {config.auth_type} type = internal [headers] Content-Security-Policy = default-src 'self'; object-src 'none' -[sharing] +""" + ) + if config.sharing_type == SharingType.SHARING: + f.write( + f"""[sharing] type = csv collection_by_map = true collection_by_token = true @@ -93,12 +119,12 @@ permit_properties_overlay = true collection_by_bday = true permit_create_bday = true database_path = {sharing_path} - -{config.extra_config} """ - ) + ) - if config.auth_type == "htpasswd": + f.write(f"\n{config.extra_config}\n") + + if config.auth_type == AuthType.HTPASSWD: with open(user_path, "w") as f: f.write( """admin:adminpassword @@ -155,14 +181,14 @@ def login( config: Config = SHARING_HTPASSWD, context: Optional[BrowserContext] = None, ) -> None: - if config.auth_type == "http_x_remote_user": + if config.auth_type == AuthType.XREMOTE: 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) - if config.auth_type == "htpasswd": + if config.auth_type == AuthType.HTPASSWD: page.fill('#loginscene input[data-name="user"]', "admin") page.fill('#loginscene input[data-name="password"]', "adminpassword") page.click('button:has-text("Next")') diff --git a/integ_tests/test_basic_operation.py b/integ_tests/test_basic_operation.py index 1adc8731..d7f55d58 100644 --- a/integ_tests/test_basic_operation.py +++ b/integ_tests/test_basic_operation.py @@ -25,6 +25,7 @@ import pytest from playwright.sync_api import BrowserContext, Page, expect from integ_tests.common import ( + NOSHARE_HTPASSWD, SHARING_HTPASSWD, SHARING_XREMOTE, Config, @@ -40,7 +41,7 @@ def radicale_server( yield from start_radicale_server(tmp_path, config) -@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE]) +@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD]) 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] = [] @@ -52,7 +53,7 @@ def test_index_html_loads(page: Page, radicale_server: str, config: Config) -> N assert len(errors) == 0 -@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE]) +@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD]) def test_user_login_works( context: BrowserContext, page: Page, radicale_server: str, config: Config ) -> None: diff --git a/integ_tests/test_delete.py b/integ_tests/test_delete.py index 8cd40a61..cb1b9c87 100644 --- a/integ_tests/test_delete.py +++ b/integ_tests/test_delete.py @@ -25,6 +25,7 @@ import pytest from playwright.sync_api import BrowserContext, Page, expect from integ_tests.common import ( + NOSHARE_HTPASSWD, SHARING_HTPASSWD, SHARING_XREMOTE, Config, @@ -41,7 +42,7 @@ def radicale_server( yield from start_radicale_server(tmp_path, config) -@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE]) +@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD]) def test_delete_wrong_confirmation( context: BrowserContext, page: Page, radicale_server: str, config: Config ) -> None: @@ -67,7 +68,7 @@ def test_delete_wrong_confirmation( expect(page.locator("#deleteconfirmationscene")).to_be_visible() -@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE]) +@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD]) def test_delete_correct_confirmation( context: BrowserContext, page: Page, radicale_server: str, config: Config ) -> None: diff --git a/integ_tests/test_download.py b/integ_tests/test_download.py index e7adaaf4..bcbd8544 100644 --- a/integ_tests/test_download.py +++ b/integ_tests/test_download.py @@ -24,6 +24,7 @@ import pytest from playwright.sync_api import BrowserContext, Page from integ_tests.common import ( + NOSHARE_HTPASSWD, SHARING_HTPASSWD, SHARING_XREMOTE, Config, @@ -32,7 +33,9 @@ from integ_tests.common import ( ) -@pytest.fixture(params=[SHARING_HTPASSWD, SHARING_XREMOTE], ids=lambda c: c.name) +@pytest.fixture( + params=[SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD], ids=lambda c: c.name +) def config(request: pytest.FixtureRequest) -> Config: return request.param diff --git a/integ_tests/test_edit.py b/integ_tests/test_edit.py index c7fda8bd..70b1b7f9 100644 --- a/integ_tests/test_edit.py +++ b/integ_tests/test_edit.py @@ -25,6 +25,7 @@ import pytest from playwright.sync_api import BrowserContext, Page, expect from integ_tests.common import ( + NOSHARE_HTPASSWD, SHARING_HTPASSWD, SHARING_XREMOTE, Config, @@ -34,7 +35,9 @@ from integ_tests.common import ( ) -@pytest.fixture(params=[SHARING_HTPASSWD, SHARING_XREMOTE], ids=lambda c: c.name) +@pytest.fixture( + params=[SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD], ids=lambda c: c.name +) def config(request: pytest.FixtureRequest) -> Config: return request.param diff --git a/integ_tests/test_scenes.py b/integ_tests/test_scenes.py index 16e5274a..67cb7d64 100644 --- a/integ_tests/test_scenes.py +++ b/integ_tests/test_scenes.py @@ -25,6 +25,7 @@ import pytest from playwright.sync_api import BrowserContext, Page, expect from integ_tests.common import ( + NOSHARE_HTPASSWD, SHARING_HTPASSWD, SHARING_XREMOTE, Config, @@ -34,7 +35,9 @@ from integ_tests.common import ( ) -@pytest.fixture(params=[SHARING_HTPASSWD, SHARING_XREMOTE], ids=lambda c: c.name) +@pytest.fixture( + params=[SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD], ids=lambda c: c.name +) def config(request: pytest.FixtureRequest) -> Config: return request.param diff --git a/integ_tests/test_scenes_login.py b/integ_tests/test_scenes_login.py index 6ccc08d6..fec13bf7 100644 --- a/integ_tests/test_scenes_login.py +++ b/integ_tests/test_scenes_login.py @@ -24,17 +24,30 @@ 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 +from integ_tests.common import ( + NOSHARE_HTPASSWD, + SHARING_HTPASSWD, + Config, + login, + start_radicale_server, +) + + +@pytest.fixture(params=[SHARING_HTPASSWD, NOSHARE_HTPASSWD], 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, SHARING_HTPASSWD) +def radicale_server( + tmp_path: pathlib.Path, config: Config +) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path, config) -def test_login_logout_login(page: Page, radicale_server: str) -> None: +def test_login_logout_login(page: Page, radicale_server: str, config: Config) -> None: # 1. First login - login(page, radicale_server, SHARING_HTPASSWD) + login(page, radicale_server, config) expect(page.locator("#collectionsscene")).to_be_visible() # 2. Logout @@ -43,5 +56,5 @@ def test_login_logout_login(page: Page, radicale_server: str) -> None: expect(page.locator("#collectionsscene")).to_be_hidden() # 3. Second login - login(page, radicale_server, SHARING_HTPASSWD) + login(page, radicale_server, config) expect(page.locator("#collectionsscene")).to_be_visible() diff --git a/integ_tests/test_upload.py b/integ_tests/test_upload.py index fcb6fb9d..876ae847 100644 --- a/integ_tests/test_upload.py +++ b/integ_tests/test_upload.py @@ -26,6 +26,7 @@ import pytest from playwright.sync_api import BrowserContext, Page, expect from integ_tests.common import ( + NOSHARE_HTPASSWD, SHARING_HTPASSWD, SHARING_XREMOTE, Config, @@ -34,7 +35,9 @@ from integ_tests.common import ( ) -@pytest.fixture(params=[SHARING_HTPASSWD, SHARING_XREMOTE], ids=lambda c: c.name) +@pytest.fixture( + params=[SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD], ids=lambda c: c.name +) def config(request: pytest.FixtureRequest) -> Config: return request.param diff --git a/radicale/web/internal_data/js/api/sharing.js b/radicale/web/internal_data/js/api/sharing.js index bdfa2587..4a9a3e9f 100644 --- a/radicale/web/internal_data/js/api/sharing.js +++ b/radicale/web/internal_data/js/api/sharing.js @@ -182,7 +182,10 @@ export function reload_sharing_list(user, password, collection, callback) { let shares = (parsed["Content"] || []).map((/** @type {ShareData} */ data) => new Share(data)); callback(shares, null); }, - null, // on_not_found + function () { + // sharing is disabled on the server + callback([], null); + }, function (error) { callback([], error); }, diff --git a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js index 617ede89..96dd459d 100644 --- a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js @@ -270,11 +270,24 @@ export function maybe_enable_sharing_options(features) { let map_is_enabled = features.sharing.FeatureEnabledCollectionByMap || false; let token_is_enabled = features.sharing.FeatureEnabledCollectionByToken || false; let bday_is_enabled = features.sharing.FeatureEnabledCollectionByBday || false; - if (map_is_enabled || token_is_enabled || bday_is_enabled) { - let share_options = document.querySelectorAll("[data-name=shareoption]"); - for (let i = 0; i < share_options.length; i++) { - let share_option = share_options[i]; + let any_sharing_enabled = map_is_enabled || token_is_enabled || bday_is_enabled; + + let share_options = document.querySelectorAll("[data-name=shareoption]"); + for (let i = 0; i < share_options.length; i++) { + let share_option = share_options[i]; + if (any_sharing_enabled) { share_option.classList.remove("hidden"); + } else { + share_option.classList.add("hidden"); + } + } + + let incomingshares_btn = document.querySelector("#collectionsscene [data-name=incomingshares]"); + if (incomingshares_btn) { + if (any_sharing_enabled) { + incomingshares_btn.classList.remove("hidden"); + } else { + incomingshares_btn.classList.add("hidden"); } } } From 99f8780463c75090dcc1f0037abfc086c8bba70b Mon Sep 17 00:00:00 2001 From: Max Berger Date: Thu, 26 Mar 2026 21:35:12 +0100 Subject: [PATCH 3/5] Fixed imports --- integ_tests/common.py | 11 +++-------- integ_tests/test_basic_operation.py | 19 +++++++++---------- integ_tests/test_delete.py | 20 +++++++++----------- integ_tests/test_download.py | 12 ++++-------- integ_tests/test_edit.py | 12 +++--------- integ_tests/test_scenes.py | 12 +++--------- integ_tests/test_scenes_login.py | 9 ++------- integ_tests/test_sharing.py | 11 +++-------- integ_tests/test_sharing_login.py | 8 ++------ integ_tests/test_upload.py | 11 +++-------- 10 files changed, 41 insertions(+), 84 deletions(-) diff --git a/integ_tests/common.py b/integ_tests/common.py index 4f758238..9813349d 100644 --- a/integ_tests/common.py +++ b/integ_tests/common.py @@ -25,12 +25,10 @@ import subprocess import sys import time from dataclasses import dataclass +from enum import Enum from typing import Any, Generator, Optional -from playwright.sync_api import BrowserContext, Page - - -from enum import Enum +from playwright.sync_api import BrowserContext, Page, expect class AuthType(Enum): @@ -101,7 +99,7 @@ type = {config.auth_type.value} f.write("htpasswd_encryption = plain\n") f.write( - f"""[web] + """[web] type = internal [headers] Content-Security-Policy = default-src 'self'; object-src 'none' @@ -172,9 +170,6 @@ max:maxpassword process.wait() -from playwright.sync_api import BrowserContext, Page, expect - - def login( page: Page, radicale_server: str, diff --git a/integ_tests/test_basic_operation.py b/integ_tests/test_basic_operation.py index d7f55d58..e802a325 100644 --- a/integ_tests/test_basic_operation.py +++ b/integ_tests/test_basic_operation.py @@ -24,14 +24,9 @@ from typing import Any, Generator import pytest from playwright.sync_api import BrowserContext, Page, expect -from integ_tests.common import ( - NOSHARE_HTPASSWD, - SHARING_HTPASSWD, - SHARING_XREMOTE, - Config, - login, - start_radicale_server, -) +from integ_tests.common import (NOSHARE_HTPASSWD, SHARING_HTPASSWD, + SHARING_XREMOTE, Config, login, + start_radicale_server) @pytest.fixture @@ -41,7 +36,9 @@ def radicale_server( yield from start_radicale_server(tmp_path, config) -@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD]) +@pytest.mark.parametrize( + "config", [SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD] +) 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] = [] @@ -53,7 +50,9 @@ def test_index_html_loads(page: Page, radicale_server: str, config: Config) -> N assert len(errors) == 0 -@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD]) +@pytest.mark.parametrize( + "config", [SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD] +) def test_user_login_works( context: BrowserContext, page: Page, radicale_server: str, config: Config ) -> None: diff --git a/integ_tests/test_delete.py b/integ_tests/test_delete.py index cb1b9c87..3db6ba8f 100644 --- a/integ_tests/test_delete.py +++ b/integ_tests/test_delete.py @@ -24,15 +24,9 @@ from typing import Any, Generator import pytest from playwright.sync_api import BrowserContext, Page, expect -from integ_tests.common import ( - NOSHARE_HTPASSWD, - SHARING_HTPASSWD, - SHARING_XREMOTE, - Config, - create_collection, - login, - start_radicale_server, -) +from integ_tests.common import (NOSHARE_HTPASSWD, SHARING_HTPASSWD, + SHARING_XREMOTE, Config, create_collection, + login, start_radicale_server) @pytest.fixture @@ -42,7 +36,9 @@ def radicale_server( yield from start_radicale_server(tmp_path, config) -@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD]) +@pytest.mark.parametrize( + "config", [SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD] +) def test_delete_wrong_confirmation( context: BrowserContext, page: Page, radicale_server: str, config: Config ) -> None: @@ -68,7 +64,9 @@ def test_delete_wrong_confirmation( expect(page.locator("#deleteconfirmationscene")).to_be_visible() -@pytest.mark.parametrize("config", [SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD]) +@pytest.mark.parametrize( + "config", [SHARING_HTPASSWD, SHARING_XREMOTE, NOSHARE_HTPASSWD] +) def test_delete_correct_confirmation( context: BrowserContext, page: Page, radicale_server: str, config: Config ) -> None: diff --git a/integ_tests/test_download.py b/integ_tests/test_download.py index bcbd8544..3d0d9c63 100644 --- a/integ_tests/test_download.py +++ b/integ_tests/test_download.py @@ -18,19 +18,15 @@ Integration tests for download page """ +import pathlib from typing import Any, Generator import pytest from playwright.sync_api import BrowserContext, Page -from integ_tests.common import ( - NOSHARE_HTPASSWD, - SHARING_HTPASSWD, - SHARING_XREMOTE, - Config, - login, - start_radicale_server, -) +from integ_tests.common import (NOSHARE_HTPASSWD, SHARING_HTPASSWD, + SHARING_XREMOTE, Config, login, + start_radicale_server) @pytest.fixture( diff --git a/integ_tests/test_edit.py b/integ_tests/test_edit.py index 70b1b7f9..a42172a9 100644 --- a/integ_tests/test_edit.py +++ b/integ_tests/test_edit.py @@ -24,15 +24,9 @@ from typing import Any, Generator import pytest from playwright.sync_api import BrowserContext, Page, expect -from integ_tests.common import ( - NOSHARE_HTPASSWD, - SHARING_HTPASSWD, - SHARING_XREMOTE, - Config, - create_collection, - login, - start_radicale_server, -) +from integ_tests.common import (NOSHARE_HTPASSWD, SHARING_HTPASSWD, + SHARING_XREMOTE, Config, create_collection, + login, start_radicale_server) @pytest.fixture( diff --git a/integ_tests/test_scenes.py b/integ_tests/test_scenes.py index 67cb7d64..1fd30061 100644 --- a/integ_tests/test_scenes.py +++ b/integ_tests/test_scenes.py @@ -24,15 +24,9 @@ from typing import Any, Generator import pytest from playwright.sync_api import BrowserContext, Page, expect -from integ_tests.common import ( - NOSHARE_HTPASSWD, - SHARING_HTPASSWD, - SHARING_XREMOTE, - Config, - create_collection, - login, - start_radicale_server, -) +from integ_tests.common import (NOSHARE_HTPASSWD, SHARING_HTPASSWD, + SHARING_XREMOTE, Config, create_collection, + login, start_radicale_server) @pytest.fixture( diff --git a/integ_tests/test_scenes_login.py b/integ_tests/test_scenes_login.py index fec13bf7..cd472603 100644 --- a/integ_tests/test_scenes_login.py +++ b/integ_tests/test_scenes_login.py @@ -24,13 +24,8 @@ from typing import Any, Generator import pytest from playwright.sync_api import Page, expect -from integ_tests.common import ( - NOSHARE_HTPASSWD, - SHARING_HTPASSWD, - Config, - login, - start_radicale_server, -) +from integ_tests.common import (NOSHARE_HTPASSWD, SHARING_HTPASSWD, Config, + login, start_radicale_server) @pytest.fixture(params=[SHARING_HTPASSWD, NOSHARE_HTPASSWD], ids=lambda c: c.name) diff --git a/integ_tests/test_sharing.py b/integ_tests/test_sharing.py index 47dfb95d..267a2580 100644 --- a/integ_tests/test_sharing.py +++ b/integ_tests/test_sharing.py @@ -24,14 +24,9 @@ from typing import Any, Generator import pytest from playwright.sync_api import BrowserContext, Page, expect -from integ_tests.common import ( - SHARING_HTPASSWD, - SHARING_XREMOTE, - Config, - 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) diff --git a/integ_tests/test_sharing_login.py b/integ_tests/test_sharing_login.py index bbabd895..fad534be 100644 --- a/integ_tests/test_sharing_login.py +++ b/integ_tests/test_sharing_login.py @@ -24,12 +24,8 @@ 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, -) +from integ_tests.common import (SHARING_HTPASSWD, create_collection, login, + start_radicale_server) @pytest.fixture diff --git a/integ_tests/test_upload.py b/integ_tests/test_upload.py index 876ae847..d37fb740 100644 --- a/integ_tests/test_upload.py +++ b/integ_tests/test_upload.py @@ -25,14 +25,9 @@ from typing import Any, Generator import pytest from playwright.sync_api import BrowserContext, Page, expect -from integ_tests.common import ( - NOSHARE_HTPASSWD, - SHARING_HTPASSWD, - SHARING_XREMOTE, - Config, - login, - start_radicale_server, -) +from integ_tests.common import (NOSHARE_HTPASSWD, SHARING_HTPASSWD, + SHARING_XREMOTE, Config, login, + start_radicale_server) @pytest.fixture( From 969d8976a1b70181bf483127ce197a78dab3862c Mon Sep 17 00:00:00 2001 From: Max Berger Date: Thu, 26 Mar 2026 22:29:37 +0100 Subject: [PATCH 4/5] Fix flakyness in basic integ test --- integ_tests/test_basic_operation.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/integ_tests/test_basic_operation.py b/integ_tests/test_basic_operation.py index e802a325..a9952aa9 100644 --- a/integ_tests/test_basic_operation.py +++ b/integ_tests/test_basic_operation.py @@ -45,8 +45,12 @@ def test_index_html_loads(page: Page, radicale_server: str, config: Config) -> N 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 auto-login check - errors = [msg for msg in console_msgs if "401 (Unauthorized)" not in msg] + # There should be no errors on the console, except for the expected 401/403 from auto-login check + errors = [ + msg + for msg in console_msgs + if "401 (Unauthorized)" not in msg and "403 (Forbidden)" not in msg + ] assert len(errors) == 0 From fb6a7abb9a18b4b6ce48ce795b5657d1af66ff04 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Fri, 27 Mar 2026 18:52:08 +0100 Subject: [PATCH 5/5] Basic Authorization: User proper encoding for password --- integ_tests/common.py | 4 ++-- radicale/web/internal_data/js/api/common.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/integ_tests/common.py b/integ_tests/common.py index 9813349d..eabbaf00 100644 --- a/integ_tests/common.py +++ b/integ_tests/common.py @@ -125,7 +125,7 @@ database_path = {sharing_path} if config.auth_type == AuthType.HTPASSWD: with open(user_path, "w") as f: f.write( - """admin:adminpassword + """admin:admi$pass#word max:maxpassword """ @@ -185,7 +185,7 @@ def login( if config.auth_type == AuthType.HTPASSWD: page.fill('#loginscene input[data-name="user"]', "admin") - page.fill('#loginscene input[data-name="password"]', "adminpassword") + page.fill('#loginscene input[data-name="password"]', "admi$pass#word") page.click('button:has-text("Next")') expect(page.locator("#collectionsscene")).to_be_visible() diff --git a/radicale/web/internal_data/js/api/common.js b/radicale/web/internal_data/js/api/common.js index 1f249936..014304ca 100644 --- a/radicale/web/internal_data/js/api/common.js +++ b/radicale/web/internal_data/js/api/common.js @@ -39,7 +39,7 @@ export function to_error_message(request) { */ export function get_auth_header(user, password) { if (user !== null && password !== null && password !== "") { - return 'Basic ' + btoa(user + ':' + encodeURIComponent(password)); + return 'Basic ' + btoa(user + ':' + password); } return null; }