Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion integ_tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ class Config:
user_username="max@domain.tld",
)

SHARING_HTGROUP = Config(
name="sharing_htgroup",
auth_type=AuthType.HTPASSWD,
sharing_type=SharingType.SHARING,
extra_config="[group]\ntype = htgroup\nhtgroup_filename = {group_path}\n",
)

SHARING_HTGROUP_USERSWITHDOMAIN = Config(
name="sharing_htgroup_userswithdomain",
auth_type=AuthType.HTPASSWD,
sharing_type=SharingType.SHARING,
extra_config="[group]\ntype = htgroup\nhtgroup_filename = {group_path}\n",
admin_username="admin@domain.tld",
user_username="max@domain.tld",
)

SHARING_XREMOTE = Config(
name="sharing_xremote",
auth_type=AuthType.XREMOTE,
Expand All @@ -90,10 +106,19 @@ def start_radicale_server(
port = get_free_port()
config_path = tmp_path / "config"
user_path = tmp_path / "users"
group_path = tmp_path / "groups"
storage_path = tmp_path / "collections"

sharing_path = tmp_path / "sharing.csv"

# Set up test htgroup file
with open(group_path, "w") as f:
f.write("group1: max user max@domain.tld user@domain.tld\n")
f.write("group2: max user max@domain.tld user@domain.tld\n")
f.write("editors: admin max admin@domain.tld max@domain.tld\n")

extra_config = config.extra_config.replace("{group_path}", str(group_path))

with open(config_path, "w") as f:
f.write(
f"""[server]
Expand Down Expand Up @@ -128,7 +153,7 @@ def start_radicale_server(
"""
)

f.write(f"\n{config.extra_config}\n")
f.write(f"\n{extra_config}\n")

if config.auth_type == AuthType.HTPASSWD:
with open(user_path, "w") as f:
Expand Down
211 changes: 211 additions & 0 deletions integ_tests/test_sharing_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# 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 group and realm sharing support in the Web UI.
"""

import pathlib
import re
from typing import Any, Generator

import pytest
from playwright.sync_api import Page, expect

from integ_tests.common import (SHARING_HTGROUP,
SHARING_HTGROUP_USERSWITHDOMAIN, Config,
create_collection, login,
start_radicale_server)


@pytest.fixture
def radicale_server(
tmp_path: pathlib.Path, radicale_server_config: Config
) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path, radicale_server_config)


@pytest.mark.parametrize(
"radicale_server_config,share_user,share_href",
[
(SHARING_HTGROUP, ":group1", "shared_group"),
(SHARING_HTGROUP_USERSWITHDOMAIN, "@domain.tld", "shared_realm"),
],
ids=["group", "realm"],
)
def test_sharing_by_group_or_realm_in_ui(
page: Page,
radicale_server: str,
radicale_server_config: Config,
share_user: str,
share_href: str,
) -> None:
"""Test sharing-by-group and sharing-by-realm in UI:
User contains group reference ':group1' or realm '@domain.tld',
and the backend creates the map share with PathOrToken prefix '/{user}/'.
"""
# 1. Admin logs in and creates a collection
login(page, radicale_server, radicale_server_config)
create_collection(page, radicale_server)

# 2. Admin opens share scene and creates share-by-group or share-by-realm
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(share_user)
page.locator('input[data-name="sharehref"]').fill(share_href)
page.click('#createeditsharescene button[data-name="submit"]')

# Verify share map row is displayed
expect(
page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)")
).to_have_count(1)
page.click('#sharecollectionscene button[data-name="cancel"]')

# 3. Admin logs out
page.click('a[data-name="logout"]')

# 4. Member user (max) logs in
page.fill(
'#loginscene input[data-name="user"]', radicale_server_config.user_username
)
page.fill('#loginscene input[data-name="password"]', "userpassword")
page.click('button:has-text("Next")')

# 5. User checks incoming shares scene
page.click('a[data-name="incomingshares"]')
expect(page.locator("#incomingsharingscene")).to_be_visible()
row = page.locator("tr[data-name='incomingsharerowtemplate']:not(.hidden)")
expect(row).to_have_count(1)

expect(
row.locator("input[data-name='pathortoken']")
).to_have_value(re.compile(rf".*{share_href}/"))

# a) Check for the emoji and title in the share type column
expected_emoji = "👥" if share_user.startswith(":") else "🌐"
expected_title = "Group share" if share_user.startswith(":") else "Domain share"
expect(row.locator("td[data-name='sharetype']")).to_have_text(expected_emoji)
expect(row.locator("td[data-name='sharetype']")).to_have_attribute(
"title", expected_title
)
expect(row.locator("td[data-name='owner']")).to_have_attribute(
"title", radicale_server_config.admin_username
)
expect(row.locator("td[data-name='owner']")).to_contain_text(
radicale_server_config.admin_username[:10]
)

# b) Check that the enabled and shown buttons (checkboxes) are disabled
enabled_cb = row.locator("input[data-name='enabled']")
shown_cb = row.locator("input[data-name='shown']")
expect(enabled_cb).to_be_disabled()
expect(shown_cb).to_be_disabled()
expect(enabled_cb).to_have_attribute(
"title", "Group and domain shares cannot be disabled"
)
expect(shown_cb).to_have_attribute(
"title", "Group and domain shares cannot be hidden"
)

page.click('#incomingsharingscene button[data-name="close"]')
expect(page.locator("#incomingsharingscene")).to_be_hidden()

# 6. Verify shared collection is displayed on user's collections page
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(
radicale_server_config.admin_username
)


@pytest.mark.parametrize(
"radicale_server_config,share_user,share_href",
[
(SHARING_HTGROUP, ":group1", "shared_group_edit"),
(SHARING_HTGROUP_USERSWITHDOMAIN, "@domain.tld", "shared_realm_edit"),
],
ids=["group", "realm"],
)
def test_update_sharing_by_group_or_realm_in_ui(
page: Page,
radicale_server: str,
radicale_server_config: Config,
share_user: str,
share_href: str,
) -> None:
"""Test updating an existing group or realm share in UI (changing RO to RW)."""
# 1. Admin logs in and creates a collection
login(page, radicale_server, radicale_server_config)
create_collection(page, radicale_server)

# 2. Admin creates a share-by-group or realm (initially readonly)
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(share_user)
page.locator('input[data-name="sharehref"]').fill(share_href)
page.click('#createeditsharescene button[data-name="submit"]')

# Verify initial share is readonly
map_row = page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)")
expect(map_row).to_have_count(1)
expect(map_row.locator('[data-name="ro"]')).to_be_visible()
expect(map_row.locator('[data-name="rw"]')).to_be_hidden()

# 3. Admin edits the share and changes to Read/Write
map_row.locator('button[data-name="edit"]').click()
expect(page.locator("#createeditsharescene")).to_be_visible()
expect(page.locator('input[data-name="shareuser"]')).to_be_disabled()
expect(page.locator('input[data-name="shareuser"]')).to_have_value(share_user)
expect(page.locator("#newshare_attr_permissions_ro")).to_be_checked()

page.locator("label[for='newshare_attr_permissions_rw']").click()
expect(page.locator("#newshare_attr_permissions_rw")).to_be_checked()
page.click('#createeditsharescene button[data-name="submit"]')

# Verify updated share now displays rw
expect(map_row.locator('[data-name="rw"]')).to_be_visible()
expect(map_row.locator('[data-name="ro"]')).to_be_hidden()
page.click('#sharecollectionscene button[data-name="cancel"]')

# 4. Admin logs out
page.click('a[data-name="logout"]')

# 5. Member user logs in
page.fill(
'#loginscene input[data-name="user"]', radicale_server_config.user_username
)
page.fill('#loginscene input[data-name="password"]', "userpassword")
page.click('button:has-text("Next")')

# 6. User verifies incoming share permissions
page.click('a[data-name="incomingshares"]')
expect(page.locator("#incomingsharingscene")).to_be_visible()
incoming_row = page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden)"
)
expect(incoming_row).to_have_count(1)
expect(incoming_row.locator('[data-name="rw"]')).to_be_visible()
expect(incoming_row.locator('[data-name="ro"]')).to_be_hidden()
page.click('#incomingsharingscene button[data-name="close"]')

# 7. Member user has write access on the collection (edit button visible)
article = page.locator("article:not(.hidden)").first
article.hover()
expect(article.locator('a[data-name="edit"]')).to_be_visible()
expect(article.locator('a[data-name="share"]')).to_be_hidden()
expect(article.locator('a[data-name="delete"]')).to_be_hidden()
13 changes: 7 additions & 6 deletions integ_tests/test_sharing_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,16 @@ def test_incoming_shares(
# 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)
row = page.locator("tr[data-name='incomingsharerowtemplate']:not(.hidden)")
expect(row).to_have_count(1)

expect(
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='pathortoken']"
)
row.locator("input[data-name='pathortoken']")
).to_have_value(re.compile(r".*mapped/"))
expect(row.locator("td[data-name='sharetype']")).to_have_text("👤")
expect(row.locator("td[data-name='sharetype']")).to_have_attribute(
"title", "Direct share"
)

# 5. Max enables and shows the share
# Initially, it's disabled and not shown (security by default)
Expand Down
2 changes: 2 additions & 0 deletions radicale/web/internal_data/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ <h1>Incoming Shares</h1>
<tr>
<th>Path</th>
<th>Owner</th>
<th title="Share Type">🔗</th>
<th title="Permissions">🔑</th>
<th title="Enabled">✔️</th>
<th title="Shown">👁️</th>
Expand All @@ -252,6 +253,7 @@ <h1>Incoming Shares</h1>
</div>
</td>
<td data-name="owner"></td>
<td data-name="sharetype"></td>
<td data-name="permissions"><span class="pill" data-name="rw">rw</span><span class="pill"
data-name="ro">ro</span><span data-name="conversion">⟿</span></td>
<td><input type="checkbox" data-name="enabled"></td>
Expand Down
5 changes: 3 additions & 2 deletions radicale/web/internal_data/js/scenes/CollectionsScene.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,10 @@ export class CollectionsScene {

let share_info = get_element(node, "[data-name=shared-by]");
let transformed_from = get_element(node, "[data-name=transformed-from]");
let collHref = decodeURIComponent(collection.href || "").replace(/\/+$/, "");
let share = (shares || []).find(
s => (s.ShareType === "map") &&
decodeURIComponent(s.PathOrToken || "").replace(/\/+$/, "") === decodeURIComponent(collection.href || "").replace(/\/+$/, ""));
s => s.ShareType === "map" &&
collHref.endsWith(decodeURIComponent(s.PathOrToken || "").replace(/\/+$/, "")));
if (share) {
if (share.Owner !== this._user) {
share_info.classList.remove("hidden");
Expand Down
43 changes: 35 additions & 8 deletions radicale/web/internal_data/js/scenes/CreateEditShareScene.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,12 +271,16 @@ export class CreateEditShareScene {
let is_conversion = conversion != "none";
let enabled_by_owner = is_conversion ? true : this._enabled_checkbox.checked;
let hidden_by_owner = is_conversion ? false : this._hidden_checkbox.checked;
let userVal = (this._edit && this._share) ? (this._share.User || "") : this._shareuser_input.value.trim();
let isGroupOrRealm = userVal.startsWith(":") || userVal.startsWith("@");
let permissions = is_conversion ? "r" : (this._permissions_rw_radio.checked ? "rw" : "r");
let allowPropertiesWrite = this._properties_write_allow.checked;
if (allowPropertiesWrite) {
permissions = permissions + "P";
} else {
permissions = permissions + "p";
if (!isGroupOrRealm) {
let allowPropertiesWrite = this._properties_write_allow.checked;
if (allowPropertiesWrite) {
permissions = permissions + "P";
} else {
permissions = permissions + "p";
}
}
/** @type {string} */ let conversion_value = conversion;

Expand Down Expand Up @@ -336,6 +340,12 @@ export class CreateEditShareScene {
new_actions.config = new_config;
}

let cleanHref = this._sharehref_input.value.trim().replace(/^\/+/, '').replace(/\/+$/, '');
let userPrefix = isGroupOrRealm ? "{user}" : userVal;
let pathOrToken = (this._edit && this._share)
? this._share.PathOrToken
: (this._shareType === "map" ? "/" + userPrefix + "/" + cleanHref + "/" : "");

let new_share = new Share({
ShareType: this._shareType,
PathMapped: this._pathMapped,
Expand All @@ -345,8 +355,8 @@ export class CreateEditShareScene {
HiddenByOwner: hidden_by_owner,
HiddenByUser: (this._edit && this._share) ? this._share.HiddenByUser : null,
Properties: properties,
User: (this._edit && this._share) ? this._share.User : this._shareuser_input.value,
PathOrToken: (this._edit && this._share) ? this._share.PathOrToken : (this._shareType === "map" ? "/" + this._shareuser_input.value + "/" + this._sharehref_input.value + "/" : ""),
User: (this._edit && this._share) ? this._share.User : userVal,
PathOrToken: pathOrToken,
Conversion: conversion_value,
Actions: new_actions,
});
Expand Down Expand Up @@ -402,7 +412,24 @@ export class CreateEditShareScene {

let hasWriteProperties = this._collection.has_permission(Permission.WRITE_PROPERTIES);

if (this._edit || this._shareType === "map") {
let initial_permissions = (this._edit && this._share && this._share.Permissions) ? this._share.Permissions : "";
if (initial_permissions.toLowerCase().includes("w")) {
this._permissions_rw_radio.checked = true;
} else {
this._permissions_ro_radio.checked = true;
}

if (this._edit && this._share) {
if (initial_permissions.includes("P")) {
this._properties_write_allow.checked = true;
} else if (initial_permissions.includes("p")) {
this._properties_write_deny.checked = true;
} else if (hasWriteProperties) {
this._properties_write_allow.checked = true;
} else {
this._properties_write_deny.checked = true;
}
} else if (this._shareType === "map") {
if (hasWriteProperties) {
this._properties_write_allow.checked = true;
} else {
Expand Down
Loading