diff --git a/integ_tests/common.py b/integ_tests/common.py index 06a6a4a3..eabbaf00 100644 --- a/integ_tests/common.py +++ b/integ_tests/common.py @@ -24,9 +24,48 @@ import socket import subprocess import sys import time -from typing import Any, Generator +from dataclasses import dataclass +from enum import Enum +from typing import Any, Generator, Optional -from playwright.sync_api import Page +from playwright.sync_api import BrowserContext, Page, expect + + +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: AuthType + sharing_type: SharingType + extra_config: str = "" + + +SHARING_HTPASSWD = Config( + name="sharing_htpasswd", + auth_type=AuthType.HTPASSWD, + sharing_type=SharingType.SHARING, +) + +SHARING_XREMOTE = Config( + name="sharing_xremote", + auth_type=AuthType.XREMOTE, + sharing_type=SharingType.SHARING, +) + +NOSHARE_HTPASSWD = Config( + name="noshare_htpasswd", + auth_type=AuthType.HTPASSWD, + sharing_type=SharingType.NOSHARING, +) def get_free_port(): @@ -35,13 +74,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,13 +91,23 @@ hosts = 127.0.0.1:{port} [storage] filesystem_folder = {storage_path} [auth] -type = htpasswd -htpasswd_filename = {user_path} -[web] +type = {config.auth_type.value} +""" + ) + if config.auth_type == AuthType.HTPASSWD: + f.write(f"htpasswd_filename = {user_path}\n") + f.write("htpasswd_encryption = plain\n") + + f.write( + """[web] 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 @@ -64,16 +116,20 @@ permit_create_map = true permit_properties_overlay = true collection_by_bday = true permit_create_bday = true - +database_path = {sharing_path} """ - ) - with open(user_path, "w") as f: - f.write( - """admin:adminpassword + ) + + f.write(f"\n{config.extra_config}\n") + + if config.auth_type == AuthType.HTPASSWD: + with open(user_path, "w") as f: + f.write( + """admin:admi$pass#word max:maxpassword """ - ) + ) env = os.environ.copy() # Ensure the radicale package is in PYTHONPATH @@ -114,11 +170,25 @@ max:maxpassword process.wait() -def login(page: Page, radicale_server: str) -> None: +def login( + page: Page, + radicale_server: str, + config: Config = SHARING_HTPASSWD, + context: Optional[BrowserContext] = None, +) -> None: + 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) - 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 == AuthType.HTPASSWD: + page.fill('#loginscene input[data-name="user"]', "admin") + page.fill('#loginscene input[data-name="password"]', "admi$pass#word") + 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..a9952aa9 100644 --- a/integ_tests/test_basic_operation.py +++ b/integ_tests/test_basic_operation.py @@ -22,30 +22,46 @@ 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 (NOSHARE_HTPASSWD, 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, 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] = [] 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 -def test_user_login_works(page: Page, radicale_server: str) -> None: +@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: """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..3db6ba8f 100644 --- a/integ_tests/test_delete.py +++ b/integ_tests/test_delete.py @@ -22,18 +22,27 @@ 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 (NOSHARE_HTPASSWD, 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, NOSHARE_HTPASSWD] +) +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 +64,13 @@ 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, NOSHARE_HTPASSWD] +) +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..3d0d9c63 100644 --- a/integ_tests/test_download.py +++ b/integ_tests/test_download.py @@ -22,18 +22,31 @@ 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 (NOSHARE_HTPASSWD, SHARING_HTPASSWD, + SHARING_XREMOTE, Config, login, + start_radicale_server) + + +@pytest.fixture( + params=[SHARING_HTPASSWD, SHARING_XREMOTE, 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) +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 +65,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..a42172a9 100644 --- a/integ_tests/test_edit.py +++ b/integ_tests/test_edit.py @@ -22,18 +22,31 @@ 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 (NOSHARE_HTPASSWD, SHARING_HTPASSWD, + SHARING_XREMOTE, Config, create_collection, + login, start_radicale_server) + + +@pytest.fixture( + params=[SHARING_HTPASSWD, SHARING_XREMOTE, 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) +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 +68,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..1fd30061 100644 --- a/integ_tests/test_scenes.py +++ b/integ_tests/test_scenes.py @@ -22,18 +22,31 @@ 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 (NOSHARE_HTPASSWD, SHARING_HTPASSWD, + SHARING_XREMOTE, Config, create_collection, + login, start_radicale_server) + + +@pytest.fixture( + params=[SHARING_HTPASSWD, SHARING_XREMOTE, 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) +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 +57,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 +76,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 +92,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 +117,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..cd472603 --- /dev/null +++ b/integ_tests/test_scenes_login.py @@ -0,0 +1,55 @@ +# 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 (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, config: Config +) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path, config) + + +def test_login_logout_login(page: Page, radicale_server: str, config: Config) -> None: + # 1. First login + login(page, radicale_server, config) + 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, config) + expect(page.locator("#collectionsscene")).to_be_visible() diff --git a/integ_tests/test_sharing.py b/integ_tests/test_sharing.py index 48c37f9b..267a2580 100644 --- a/integ_tests/test_sharing.py +++ b/integ_tests/test_sharing.py @@ -22,18 +22,29 @@ 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 +86,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 +135,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 +192,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 +237,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 +268,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 +311,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 +346,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 +410,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 +433,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..fad534be --- /dev/null +++ b/integ_tests/test_sharing_login.py @@ -0,0 +1,161 @@ +# 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..d37fb740 100644 --- a/integ_tests/test_upload.py +++ b/integ_tests/test_upload.py @@ -23,18 +23,31 @@ 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 (NOSHARE_HTPASSWD, SHARING_HTPASSWD, + SHARING_XREMOTE, Config, login, + start_radicale_server) + + +@pytest.fixture( + params=[SHARING_HTPASSWD, SHARING_XREMOTE, 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) +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 +60,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 +97,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/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; } 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/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); } 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"); } } }