Merge branch 'Kozea:master' into toxinteg
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
55
integ_tests/test_scenes_login.py
Normal file
55
integ_tests/test_scenes_login.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2026-2026 Max Berger <max@berger.name>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This library is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Integration tests for scene navigation (login/logout specific)
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
from typing import Any, Generator
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
from integ_tests.common import (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()
|
||||
@@ -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(
|
||||
|
||||
161
integ_tests/test_sharing_login.py
Normal file
161
integ_tests/test_sharing_login.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2026-2026 Max Berger <max@berger.name>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This library is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Integration tests for sharing (login/logout specific)
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
from typing import Any, Generator
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
from integ_tests.common import (SHARING_HTPASSWD, create_collection, login,
|
||||
start_radicale_server)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
|
||||
yield from start_radicale_server(tmp_path, SHARING_HTPASSWD)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("permissions", ["ro", "rw"])
|
||||
def test_incoming_shares(page: Page, radicale_server: str, permissions: str) -> None:
|
||||
# 1. Admin logs in and creates a map share for 'max'
|
||||
login(page, radicale_server, SHARING_HTPASSWD)
|
||||
create_collection(page, radicale_server)
|
||||
|
||||
page.hover("article:not(.hidden)")
|
||||
page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True)
|
||||
page.click('button[data-name="sharebymap"]')
|
||||
page.locator('input[data-name="shareuser"]').fill("max")
|
||||
page.locator('input[data-name="sharehref"]').fill("mapped")
|
||||
if permissions == "rw":
|
||||
page.check("#newshare_attr_permissions_rw")
|
||||
page.click('#newshare button[data-name="submit"]')
|
||||
expect(
|
||||
page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)")
|
||||
).to_have_count(1)
|
||||
page.click('#sharecollectionscene button[data-name="cancel"]')
|
||||
|
||||
# 2. Admin logs out
|
||||
page.click('a[data-name="logout"]')
|
||||
|
||||
# 3. Max logs in
|
||||
page.fill('#loginscene input[data-name="user"]', "max")
|
||||
page.fill('#loginscene input[data-name="password"]', "maxpassword")
|
||||
page.click('button:has-text("Next")')
|
||||
|
||||
# 4. Max sees the incoming share
|
||||
page.click('a[data-name="incomingshares"]')
|
||||
expect(page.locator("#incomingsharingscene")).to_be_visible()
|
||||
expect(
|
||||
page.locator("tr[data-name='incomingsharerowtemplate']:not(.hidden)")
|
||||
).to_have_count(1)
|
||||
|
||||
expect(
|
||||
page.locator(
|
||||
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='pathortoken']"
|
||||
)
|
||||
).to_have_value("mapped")
|
||||
|
||||
# 5. Max enables and shows the share
|
||||
# Initially, it's disabled and not shown (security by default)
|
||||
expect(
|
||||
page.locator(
|
||||
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='enabled']"
|
||||
)
|
||||
).not_to_be_checked()
|
||||
expect(
|
||||
page.locator(
|
||||
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
|
||||
)
|
||||
).not_to_be_checked()
|
||||
expect(
|
||||
page.locator(
|
||||
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
|
||||
)
|
||||
).to_be_disabled()
|
||||
|
||||
# Enable it
|
||||
page.check(
|
||||
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='enabled']"
|
||||
)
|
||||
expect(
|
||||
page.locator(
|
||||
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
|
||||
)
|
||||
).not_to_be_disabled()
|
||||
|
||||
# Show it
|
||||
page.check(
|
||||
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
|
||||
)
|
||||
expect(
|
||||
page.locator(
|
||||
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='shown']"
|
||||
)
|
||||
).to_be_checked()
|
||||
|
||||
# 6. Verify "shared by admin" and button visibility in the collection article
|
||||
page.click('#incomingsharingscene button[data-name="cancel"]')
|
||||
expect(page.locator("#incomingsharingscene")).to_be_hidden()
|
||||
|
||||
article = page.locator("article:not(.hidden)").first
|
||||
expect(article.locator('[data-name="shared-by"]')).to_be_visible()
|
||||
expect(article.locator('[data-name="shared-by-owner"]')).to_have_text("admin")
|
||||
|
||||
# Action buttons are only visible on mouseover
|
||||
article.hover()
|
||||
|
||||
# Share and delete buttons should be hidden for all incoming shares
|
||||
expect(article.locator('a[data-name="share"]')).to_be_hidden()
|
||||
expect(article.locator('[data-name="shareoption"]')).to_be_hidden()
|
||||
expect(article.locator('a[data-name="delete"]')).to_be_hidden()
|
||||
|
||||
# Edit button depends on permissions
|
||||
if permissions == "rw":
|
||||
expect(article.locator('a[data-name="edit"]')).to_be_visible()
|
||||
else:
|
||||
expect(article.locator('a[data-name="edit"]')).to_be_hidden()
|
||||
|
||||
# 7. Assert no error was shown
|
||||
expect(page.locator('#incomingsharingscene span[data-name="error"]')).to_be_hidden()
|
||||
|
||||
|
||||
def test_no_incoming_shares_message(page: Page, radicale_server: str) -> None:
|
||||
# 1. Max logs in
|
||||
page.goto(radicale_server)
|
||||
page.fill('#loginscene input[data-name="user"]', "max")
|
||||
page.fill('#loginscene input[data-name="password"]', "maxpassword")
|
||||
page.click('button:has-text("Next")')
|
||||
|
||||
# 2. Max goes to incoming shares scene
|
||||
page.click('a[data-name="incomingshares"]')
|
||||
expect(page.locator("#incomingsharingscene")).to_be_visible()
|
||||
|
||||
# 3. Verify that the table is hidden and the message is visible
|
||||
expect(page.locator("#incomingsharingscene table")).to_be_hidden()
|
||||
expect(
|
||||
page.locator('#incomingsharingscene [data-name="nosharesmessage"]')
|
||||
).to_be_visible()
|
||||
expect(
|
||||
page.locator('#incomingsharingscene [data-name="nosharesmessage"]')
|
||||
).to_have_text("No incoming shares")
|
||||
|
||||
page.click('#incomingsharingscene button[data-name="cancel"]')
|
||||
expect(page.locator("#incomingsharingscene")).to_be_hidden()
|
||||
@@ -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"
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2026-2026 Max Berger <max@berger.name>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This library is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Integration test for X-Remote-User authentication
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Generator
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import BrowserContext, Page, expect
|
||||
|
||||
from integ_tests.common import create_collection, get_free_port
|
||||
|
||||
|
||||
def start_radicale_server_remote(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
|
||||
port = get_free_port()
|
||||
config_path = tmp_path / "config"
|
||||
storage_path = tmp_path / "collections"
|
||||
|
||||
# Create a local config file with http_x_remote_user auth
|
||||
with open(config_path, "w") as f:
|
||||
f.write(
|
||||
f"""[server]
|
||||
hosts = 127.0.0.1:{port}
|
||||
[storage]
|
||||
filesystem_folder = {storage_path}
|
||||
[auth]
|
||||
type = http_x_remote_user
|
||||
[web]
|
||||
type = internal
|
||||
[headers]
|
||||
Content-Security-Policy = default-src 'self'; object-src 'none'
|
||||
[sharing]
|
||||
type = csv
|
||||
collection_by_map = true
|
||||
collection_by_token = true
|
||||
permit_create_token = true
|
||||
permit_create_map = true
|
||||
permit_properties_overlay = true
|
||||
collection_by_bday = true
|
||||
permit_create_bday = true
|
||||
|
||||
"""
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
# Ensure the radicale package is in PYTHONPATH
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "")
|
||||
|
||||
# Run the server
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-m", "radicale", "--config", str(config_path)],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
# Wait for the server to start listening
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < 10:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
|
||||
break
|
||||
except (OSError, ConnectionRefusedError):
|
||||
if process.poll() is not None:
|
||||
_stdout, stderr = process.communicate()
|
||||
raise RuntimeError(
|
||||
f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}"
|
||||
)
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
process.terminate()
|
||||
process.wait()
|
||||
raise RuntimeError("Timeout waiting for Radicale to start")
|
||||
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
|
||||
# Cleanup
|
||||
process.terminate()
|
||||
process.wait()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
|
||||
yield from start_radicale_server_remote(tmp_path)
|
||||
|
||||
|
||||
def test_index_html_loads(
|
||||
context: BrowserContext, page: Page, radicale_server: str
|
||||
) -> None:
|
||||
"""Test that the index.html loads from the server with remote user."""
|
||||
context.set_extra_http_headers({"X-Remote-User": "admin"})
|
||||
console_msgs: list[str] = []
|
||||
page.on("console", lambda msg: console_msgs.append(msg.text))
|
||||
page.goto(radicale_server)
|
||||
expect(page).to_have_title("Radicale Web Interface")
|
||||
# There should be no errors on the console, except for the expected 401 from initial auto-login check
|
||||
errors = [msg for msg in console_msgs if "401 (Unauthorized)" not in msg]
|
||||
assert len(errors) == 0
|
||||
|
||||
|
||||
def test_user_authenticated(
|
||||
context: BrowserContext, page: Page, radicale_server: str
|
||||
) -> None:
|
||||
"""Test that the user is automatically authenticated via X-Remote-User."""
|
||||
context.set_extra_http_headers({"X-Remote-User": "admin"})
|
||||
page.goto(radicale_server)
|
||||
|
||||
# The login page should be skipped entirely if authenticated.
|
||||
# After auto-login, we should see the collections list (which is empty)
|
||||
expect(
|
||||
page.locator(
|
||||
'#logoutview span[data-name="user"]', has_text="admin's Collections"
|
||||
)
|
||||
).to_be_visible()
|
||||
expect(page.locator('#logoutview a[data-name="logout"]')).to_be_hidden()
|
||||
|
||||
|
||||
def test_create_collection_works(
|
||||
context: BrowserContext, page: Page, radicale_server: str
|
||||
) -> None:
|
||||
"""Test creating a collection with remote user."""
|
||||
context.set_extra_http_headers({"X-Remote-User": "admin"})
|
||||
page.goto(radicale_server)
|
||||
|
||||
# Wait for auto-login
|
||||
expect(
|
||||
page.locator(
|
||||
'#logoutview span[data-name="user"]', has_text="admin's Collections"
|
||||
)
|
||||
).to_be_visible()
|
||||
|
||||
create_collection(page, radicale_server)
|
||||
|
||||
expect(page.locator("article:not(.hidden)")).to_have_count(1)
|
||||
expect(page.locator("article:not(.hidden) .title")).to_be_visible()
|
||||
|
||||
|
||||
def test_download_works_with_remote_user(
|
||||
context: BrowserContext, page: Page, radicale_server: str
|
||||
) -> None:
|
||||
"""Test downloading a collection with remote user."""
|
||||
context.set_extra_http_headers({"X-Remote-User": "admin"})
|
||||
page.goto(radicale_server)
|
||||
|
||||
# Wait for auto-login
|
||||
expect(
|
||||
page.locator(
|
||||
'#logoutview span[data-name="user"]', has_text="admin's Collections"
|
||||
)
|
||||
).to_be_visible()
|
||||
|
||||
create_collection(page, radicale_server)
|
||||
|
||||
# Start waiting for the download
|
||||
with page.expect_download() as download_info:
|
||||
# Perform the action that initiates download
|
||||
page.hover("article:not(.hidden)")
|
||||
page.click('article:not(.hidden) a[data-name="download"]')
|
||||
|
||||
download = download_info.value
|
||||
assert download.suggested_filename.endswith(
|
||||
".ics"
|
||||
) or download.suggested_filename.endswith(".vcf")
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user