diff --git a/pkg/caldavtests/propfind_test.go b/pkg/caldavtests/propfind_test.go new file mode 100644 index 000000000..c2b4483d5 --- /dev/null +++ b/pkg/caldavtests/propfind_test.go @@ -0,0 +1,239 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "net/http" + "strings" + "testing" + + ics "github.com/arran4/golang-ical" + "github.com/stretchr/testify/assert" +) + +func TestPropfindCollection(t *testing.T) { + // RFC 4918 §9.1 (rfc4918.txt line 1939): + // "The PROPFIND method retrieves properties defined on the resource + // identified by the Request-URI." + + t.Run("Depth 0 on project returns collection properties", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + // Depth 0 should return exactly 1 response (the collection itself) + assert.Len(t, ms.Responses, 1, + "Depth 0 should return exactly the collection") + + r := ms.Responses[0] + prop := getSuccessfulProp(t, r) + + // displayname should be the project title + assert.Equal(t, "Project 36 for Caldav tests", prop.DisplayName, + "displayname should match project title") + + // resourcetype should include both DAV:collection and CALDAV:calendar + assert.Contains(t, prop.ResourceType.InnerXML, "collection", + "resourcetype should include DAV:collection") + }) + + t.Run("Depth 1 on project returns collection plus tasks", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36", "1", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + // Project 36 has 5 tasks in fixtures (tasks 40-43, 45) + // Depth 1 should return the collection + all tasks = 6 responses + assert.GreaterOrEqual(t, len(ms.Responses), 6, + "Depth 1 should return collection + all tasks") + + // First response should be the collection itself + // Subsequent responses should be individual tasks + body := rec.Body.String() + assert.Contains(t, body, ".ics", + "Task responses should have .ics hrefs") + }) + + t.Run("Depth 1 on project returns ETags for each resource", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36", "1", PropfindResourceProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + for _, r := range ms.Responses { + prop := getSuccessfulProp(t, r) + // Every resource should have an ETag + // RFC 4918 §15.6: "strong ETags MUST be used" + assert.NotEmpty(t, prop.GetETag, + "Every resource in PROPFIND should have an ETag. Href: %s", r.Href) + } + }) + + t.Run("PROPFIND on nonexistent project returns 404", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/99999", "0", PropfindCalendarCollectionProperties) + + assert.Equal(t, http.StatusNotFound, rec.Code, + "PROPFIND on nonexistent project should return 404") + }) + + t.Run("Depth 1 includes calendar-data for each task", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36", "1", PropfindResourceProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + taskCount := 0 + for _, r := range ms.Responses { + prop := getSuccessfulProp(t, r) + if prop.CalendarData != "" { + taskCount++ + // Each calendar-data should be valid iCalendar + cal := parseICalFromString(t, prop.CalendarData) + vtodo := getVTodo(t, cal) + uid := getVTodoProperty(vtodo, ics.ComponentPropertyUniqueId) + assert.NotEmpty(t, uid, "Each VTODO should have a UID") + } + } + assert.Greater(t, taskCount, 0, "Should have at least one task with calendar-data") + }) +} + +func TestPropfindResource(t *testing.T) { + t.Run("Depth 0 on task returns task properties", func(t *testing.T) { + e := setupTestEnv(t) + + // Task 40 has UID "uid-caldav-test" in project 36 + rec := caldavPROPFIND(t, e, "/dav/projects/36/uid-caldav-test.ics", "0", PropfindResourceProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + assert.Len(t, ms.Responses, 1, + "Depth 0 on a task should return exactly 1 response") + + r := ms.Responses[0] + prop := getSuccessfulProp(t, r) + + assert.NotEmpty(t, prop.GetETag, "Task should have an ETag") + assert.NotEmpty(t, prop.CalendarData, "Task should have calendar-data") + + // Parse and validate the calendar data + cal := parseICalFromString(t, prop.CalendarData) + vtodo := getVTodo(t, cal) + assert.Equal(t, "uid-caldav-test", getVTodoProperty(vtodo, ics.ComponentPropertyUniqueId)) + assert.Equal(t, "Title Caldav Test", getVTodoProperty(vtodo, ics.ComponentPropertySummary)) + }) + + t.Run("PROPFIND on nonexistent task returns 404", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36/nonexistent-uid.ics", "0", PropfindResourceProperties) + + assert.Equal(t, http.StatusNotFound, rec.Code, + "PROPFIND on nonexistent task should return 404") + }) + + t.Run("ETag format is quoted string", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36/uid-caldav-test.ics", "0", PropfindResourceProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + r := ms.Responses[0] + prop := getSuccessfulProp(t, r) + + // RFC 4918 requires ETags to be quoted strings + assert.True(t, len(prop.GetETag) > 2 && + prop.GetETag[0] == '"' && prop.GetETag[len(prop.GetETag)-1] == '"', + "ETag should be a quoted string, got: %s", prop.GetETag) + }) +} + +func TestPropfindCalendarHome(t *testing.T) { + t.Run("Depth 1 on /dav/projects lists all accessible calendars", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + // testuser15 should see at least projects 36 and 38 + projectFound36 := false + projectFound38 := false + for _, r := range ms.Responses { + if strings.Contains(r.Href, "36") { + projectFound36 = true + } + if strings.Contains(r.Href, "38") { + projectFound38 = true + } + } + assert.True(t, projectFound36, "Should list project 36 in calendar home") + assert.True(t, projectFound38, "Should list project 38 in calendar home") + }) + + t.Run("Each calendar has displayname matching project title", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + for _, r := range ms.Responses { + prop := getSuccessfulProp(t, r) + if prop.DisplayName != "" { + // Every calendar with a displayname should have a reasonable title + assert.NotEmpty(t, prop.DisplayName, + "Calendar at %s should have a displayname", r.Href) + } + } + }) + + t.Run("User only sees projects they have access to", func(t *testing.T) { + e := setupTestEnv(t) + + // testuser1 should NOT see testuser15's projects (36, 38) + rec := caldavRequest(t, e, "PROPFIND", "/dav/projects", PropfindCalendarCollectionProperties, map[string]string{ + "Depth": "1", + "Authorization": basicAuthHeader(testuser1.Username, fixturePassword), + }) + + assertResponseStatus(t, rec, 207) + + body := rec.Body.String() + // user1 should not see project 36 or 38 (owned by user15) + assert.NotContains(t, body, "Project 36 for Caldav tests", + "user1 should not see user15's project 36") + assert.NotContains(t, body, "Project 38 for Caldav tests", + "user1 should not see user15's project 38") + }) +}