DAVx5/CalDAV clients displayed a shared calendar's DAV:displayname as "alice~work" (the synthetic owner~name local identifier nidus uses internally to keep a shared calendar's URL unique per subscriber). That internal name is still needed for routing (it's still what appears in the calendar's URL path), but there's no reason to show it to the end user as the calendar's label. calendarMeta() now always sets caldav.Calendar.Name to the plain calendar name (e.g. "work"), while Path still uses the "owner~name" local name. The web UI already showed the plain name via a separate "shared by <owner>" entry, so it's unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
274 lines
9.1 KiB
Go
274 lines
9.1 KiB
Go
package caldav
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
ical "github.com/emersion/go-ical"
|
|
"github.com/yourusername/caldav-server/internal/auth"
|
|
"github.com/yourusername/caldav-server/internal/config"
|
|
"github.com/yourusername/caldav-server/internal/db"
|
|
"github.com/yourusername/caldav-server/internal/store"
|
|
)
|
|
|
|
func newTestBackend(t *testing.T) (*Backend, *db.DB) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
|
|
st, err := store.NewStore(filepath.Join(dir, "data"))
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
dbase, err := db.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("db.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { dbase.Close() })
|
|
|
|
cfg := &config.Config{}
|
|
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
|
|
t.Fatalf("CreateUser alice: %v", err)
|
|
}
|
|
if err := dbase.CreateUser("bob", "pw", "", ""); err != nil {
|
|
t.Fatalf("CreateUser bob: %v", err)
|
|
}
|
|
if err := dbase.CreateCalendar("alice", "work"); err != nil {
|
|
t.Fatalf("CreateCalendar alice/work: %v", err)
|
|
}
|
|
if err := dbase.CreateCalendar("bob", "personal"); err != nil {
|
|
t.Fatalf("CreateCalendar bob/personal: %v", err)
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
return NewBackend(cfg, st, dbase, logger), dbase
|
|
}
|
|
|
|
func ctxFor(username string) context.Context {
|
|
return auth.NewContext(context.Background(), &auth.Principal{Username: username})
|
|
}
|
|
|
|
// minimalEvent returns a minimal, valid VCALENDAR/VEVENT for use in tests.
|
|
func minimalEvent() *ical.Calendar {
|
|
const raw = "BEGIN:VCALENDAR\r\n" +
|
|
"VERSION:2.0\r\n" +
|
|
"PRODID:-//nidus//test//EN\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:event1@nidus.test\r\n" +
|
|
"DTSTAMP:20240101T000000Z\r\n" +
|
|
"DTSTART:20240101T100000Z\r\n" +
|
|
"SUMMARY:Test Event\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
|
|
if err != nil {
|
|
panic(fmt.Sprintf("minimalEvent: %v", err))
|
|
}
|
|
return cal
|
|
}
|
|
|
|
func TestListCalendarsIncludesSharedCalendar(t *testing.T) {
|
|
b, dbase := newTestBackend(t)
|
|
|
|
if err := dbase.ShareCalendar("alice", "work", "bob", db.PermRead); err != nil {
|
|
t.Fatalf("ShareCalendar: %v", err)
|
|
}
|
|
// The shared calendar must exist on disk for it to be listed; normally
|
|
// this happens when alice's own ListCalendars runs and ensures it.
|
|
if _, err := b.ListCalendars(ctxFor("alice")); err != nil {
|
|
t.Fatalf("ListCalendars(alice): %v", err)
|
|
}
|
|
|
|
cals, err := b.ListCalendars(ctxFor("bob"))
|
|
if err != nil {
|
|
t.Fatalf("ListCalendars: %v", err)
|
|
}
|
|
|
|
var found bool
|
|
wantPath := calHomePath() + sharedCalendarName("alice", "work") + "/"
|
|
for _, c := range cals {
|
|
if c.Path == wantPath && c.Name == "work" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("shared calendar with path %q and display name %q not found in ListCalendars result: %+v", wantPath, "work", cals)
|
|
}
|
|
}
|
|
|
|
func TestSharedCalendarReadOnlyRejectsWrite(t *testing.T) {
|
|
b, dbase := newTestBackend(t)
|
|
|
|
if err := dbase.ShareCalendar("alice", "work", "bob", db.PermRead); err != nil {
|
|
t.Fatalf("ShareCalendar: %v", err)
|
|
}
|
|
|
|
localName := sharedCalendarName("alice", "work")
|
|
objPath := calObjectPath(localName, "event1.ics")
|
|
|
|
_, err := b.PutCalendarObject(ctxFor("bob"), objPath, minimalEvent(), nil)
|
|
if err == nil {
|
|
t.Fatal("expected error writing to read-only shared calendar, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSharedCalendarWriteAllowed(t *testing.T) {
|
|
b, dbase := newTestBackend(t)
|
|
|
|
if err := dbase.ShareCalendar("alice", "work", "bob", db.PermWrite); err != nil {
|
|
t.Fatalf("ShareCalendar: %v", err)
|
|
}
|
|
|
|
localName := sharedCalendarName("alice", "work")
|
|
objPath := calObjectPath(localName, "event1.ics")
|
|
|
|
if _, err := b.PutCalendarObject(ctxFor("bob"), objPath, minimalEvent(), nil); err != nil {
|
|
t.Fatalf("PutCalendarObject with write share: %v", err)
|
|
}
|
|
|
|
// The object should now be visible under alice's own calendar too,
|
|
// since it's stored in her namespace.
|
|
obj, err := b.GetCalendarObject(ctxFor("alice"), calObjectPath("work", "event1.ics"), nil)
|
|
if err != nil {
|
|
t.Fatalf("GetCalendarObject as owner: %v", err)
|
|
}
|
|
if obj == nil {
|
|
t.Fatal("expected non-nil object")
|
|
}
|
|
}
|
|
|
|
func TestUnauthorizedUserCannotAccessUnsharedCalendar(t *testing.T) {
|
|
b, _ := newTestBackend(t)
|
|
|
|
localName := sharedCalendarName("alice", "work")
|
|
_, err := b.GetCalendar(ctxFor("bob"), calHomePath()+localName+"/")
|
|
if err == nil {
|
|
t.Fatal("expected error accessing unshared calendar, got nil")
|
|
}
|
|
}
|
|
|
|
// TestPropFindEmitsCalendarColor verifies that a PROPFIND on a calendar
|
|
// with a color set returns the Apple/DAVx5 calendar-color property, and
|
|
// that a calendar without a color doesn't (since a client should fall
|
|
// back to its own default in that case).
|
|
func TestPropFindEmitsCalendarColor(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, err := store.NewStore(filepath.Join(dir, "data"))
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
dbase, err := db.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("db.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { dbase.Close() })
|
|
|
|
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
|
|
t.Fatalf("CreateUser alice: %v", err)
|
|
}
|
|
if err := dbase.CreateCalendarWithColor("alice", "work", "#3b82f6"); err != nil {
|
|
t.Fatalf("CreateCalendarWithColor: %v", err)
|
|
}
|
|
if err := dbase.CreateCalendar("alice", "personal"); err != nil {
|
|
t.Fatalf("CreateCalendar: %v", err)
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
handler := NewHandler(&config.Config{}, st, dbase, logger)
|
|
|
|
req := httptest.NewRequest("PROPFIND", "/cal/home/", strings.NewReader(
|
|
`<?xml version="1.0"?><a:propfind xmlns:a="DAV:"><a:allprop/></a:propfind>`))
|
|
req.SetBasicAuth("alice", "pw")
|
|
req.Header.Set("Content-Type", "text/xml")
|
|
req.Header.Set("Depth", "1")
|
|
req = req.WithContext(ctxFor("alice"))
|
|
|
|
rr := httptest.NewRecorder()
|
|
req = req.WithContext(auth.NewContext(context.Background(), &auth.Principal{Username: "alice"}))
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusMultiStatus {
|
|
t.Fatalf("expected 207, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, `<calendar-color xmlns="http://apple.com/ns/ical/">#3b82f6FF</calendar-color>`) {
|
|
t.Fatalf("expected calendar-color for work calendar, got: %s", body)
|
|
}
|
|
|
|
// The "personal" calendar has no color, so its <response> block
|
|
// shouldn't contain the property at all.
|
|
personalIdx := strings.Index(body, "/cal/home/personal/")
|
|
if personalIdx < 0 {
|
|
t.Fatalf("expected personal calendar in response, got: %s", body)
|
|
}
|
|
// Find personal's response block boundaries loosely by looking for the
|
|
// nearest calendar-color occurrence and ensuring it isn't right next to
|
|
// the personal href (colors are per-block, checked via count instead).
|
|
if strings.Count(body, "<calendar-color ") != 1 {
|
|
t.Fatalf("expected exactly one calendar-color element (only for work), got: %s", body)
|
|
}
|
|
}
|
|
|
|
// TestPropFindExplicitCalendarColorRequest verifies that when a client
|
|
// (like DAVx5) explicitly asks for the Apple calendar-color property by
|
|
// name, it gets back a single 200 OK propstat with the color — not a
|
|
// duplicate/conflicting 404 propstat alongside it, which is what
|
|
// go-webdav's stock property map produces on its own for an unknown
|
|
// property and which real clients (dav4jvm) were observed to prefer over
|
|
// the injected 200, hiding the color entirely.
|
|
func TestPropFindExplicitCalendarColorRequest(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, err := store.NewStore(filepath.Join(dir, "data"))
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
dbase, err := db.Open(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("db.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { dbase.Close() })
|
|
|
|
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
|
|
t.Fatalf("CreateUser alice: %v", err)
|
|
}
|
|
if err := dbase.CreateCalendarWithColor("alice", "work", "#3b82f6"); err != nil {
|
|
t.Fatalf("CreateCalendarWithColor: %v", err)
|
|
}
|
|
if err := st.EnsureCollection("alice", "cal-work"); err != nil {
|
|
t.Fatalf("EnsureCollection: %v", err)
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
handler := NewHandler(&config.Config{}, st, dbase, logger)
|
|
|
|
req := httptest.NewRequest("PROPFIND", "/cal/home/work/", strings.NewReader(
|
|
`<?xml version="1.0"?><D:propfind xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">`+
|
|
`<D:prop><D:displayname/><A:calendar-color/></D:prop></D:propfind>`))
|
|
req.SetBasicAuth("alice", "pw")
|
|
req.Header.Set("Content-Type", "text/xml")
|
|
req.Header.Set("Depth", "0")
|
|
req = req.WithContext(auth.NewContext(context.Background(), &auth.Principal{Username: "alice"}))
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusMultiStatus {
|
|
t.Fatalf("expected 207, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
body := rr.Body.String()
|
|
if strings.Count(body, "<propstat") != 1 {
|
|
t.Fatalf("expected exactly one propstat (no leftover 404 for calendar-color), got: %s", body)
|
|
}
|
|
if !strings.Contains(body, `<calendar-color xmlns="http://apple.com/ns/ical/">#3b82f6FF</calendar-color>`) {
|
|
t.Fatalf("expected calendar-color in the single 200 OK propstat, got: %s", body)
|
|
}
|
|
if !strings.Contains(body, "200 OK") || strings.Contains(body, "404") {
|
|
t.Fatalf("expected a single 200 OK propstat with no 404, got: %s", body)
|
|
}
|
|
}
|