mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 22:47:40 +00:00
wip: reimplement caldav from scratch
This commit is contained in:
51
go.mod
51
go.mod
@@ -59,7 +59,6 @@ require (
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/redis/go-redis/v9 v9.8.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/samedi/caldav-go v3.0.0+incompatible
|
||||
github.com/spf13/afero v1.14.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
@@ -67,7 +66,6 @@ require (
|
||||
github.com/swaggo/swag v1.16.4
|
||||
github.com/tkuchiki/go-timezone v0.2.3
|
||||
github.com/typesense/typesense-go/v2 v2.0.0
|
||||
github.com/typesense/typesense-go/v3 v3.2.0
|
||||
github.com/ulule/limiter/v3 v3.11.2
|
||||
github.com/wneessen/go-mail v0.6.2
|
||||
github.com/yuin/goldmark v1.7.11
|
||||
@@ -87,71 +85,42 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.23.1 // indirect
|
||||
cloud.google.com/go v0.120.1 // indirect
|
||||
cloud.google.com/go/auth v0.16.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.6.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.2 // indirect
|
||||
cloud.google.com/go/longrunning v0.6.7 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||
cloud.google.com/go/spanner v1.79.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.2 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beevik/etree v1.1.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v3 v3.2.2 // indirect
|
||||
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/envoyproxy/go-control-plane v0.13.4 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-chi/chi/v5 v5.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.3 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/googleapis/go-sql-spanner v1.13.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/huandu/go-clone v1.7.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef // indirect
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
@@ -161,7 +130,6 @@ require (
|
||||
github.com/onsi/gomega v1.16.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
@@ -169,7 +137,6 @@ require (
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sony/gobreaker v1.0.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
@@ -180,29 +147,13 @@ require (
|
||||
github.com/urfave/cli/v2 v2.3.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.28.0 // indirect
|
||||
google.golang.org/api v0.229.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250414145226-207652e42e2e // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
|
||||
google.golang.org/grpc v1.71.1 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||
|
||||
@@ -26,158 +26,237 @@ import (
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
|
||||
caldav2 "code.vikunja.io/api/pkg/caldav"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samedi/caldav-go"
|
||||
"github.com/samedi/caldav-go/lib"
|
||||
)
|
||||
|
||||
func getBasicAuthUserFromContext(c echo.Context) (*user.User, error) {
|
||||
u, is := c.Get("userBasicAuth").(*user.User)
|
||||
if !is {
|
||||
return &user.User{}, fmt.Errorf("user is not user element, is %s", reflect.TypeOf(c.Get("userBasicAuth")))
|
||||
return nil, fmt.Errorf("user is not user.User element, is %s", reflect.TypeOf(c.Get("userBasicAuth")))
|
||||
}
|
||||
if u == nil {
|
||||
return nil, fmt.Errorf("userBasicAuth from context is nil")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ProjectHandler returns all tasks from a project
|
||||
// ProjectHandler handles requests related to project collections and individual project resources (calendars).
|
||||
func ProjectHandler(c echo.Context) error {
|
||||
project, err := getProjectFromParam(c)
|
||||
if err != nil && models.IsErrProjectDoesNotExist(err) {
|
||||
return c.String(http.StatusNotFound, "Project not found")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u, err := getBasicAuthUserFromContext(c)
|
||||
if err != nil {
|
||||
return echo.ErrInternalServerError.SetInternal(err)
|
||||
log.Errorf("Error getting user from basic auth context: %v", err)
|
||||
return echo.ErrUnauthorized.SetInternal(fmt.Errorf("invalid user context: %w", err))
|
||||
}
|
||||
|
||||
storage := &VikunjaCaldavProjectStorage{
|
||||
project: project,
|
||||
user: u,
|
||||
}
|
||||
projectIDParam := c.Param("project")
|
||||
|
||||
// Try to parse a task from the request payload
|
||||
body, _ := io.ReadAll(c.Request().Body)
|
||||
// Restore the io.ReadCloser to its original state
|
||||
c.Request().Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
// Parse it
|
||||
vtodo := string(body)
|
||||
if vtodo != "" && strings.HasPrefix(vtodo, `BEGIN:VCALENDAR`) {
|
||||
storage.task, err = caldav2.ParseTaskFromVTODO(vtodo)
|
||||
if err != nil {
|
||||
log.Warningf("[CALDAV] Failed to parse task: %v", err)
|
||||
return models.ErrInvalidData{Message: "Invalid task"}
|
||||
// Log CalDAV request details
|
||||
bodyBytes, _ := io.ReadAll(c.Request().Body)
|
||||
c.Request().Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore body for further processing if any
|
||||
log.Debugf("[CALDAV] ProjectHandler: Method=%s, Path=%s, ProjectIDParam=%s, User=%s, Headers=%v, Body=%s",
|
||||
c.Request().Method, c.Path(), projectIDParam, u.Username, c.Request().Header, string(bodyBytes))
|
||||
|
||||
|
||||
if projectIDParam == "" {
|
||||
// Request to /dav/projects/ or /dav/projects
|
||||
// This should list all accessible calendars (projects) for the user.
|
||||
switch c.Request().Method {
|
||||
case "PROPFIND":
|
||||
return ListCalendars(c, u)
|
||||
case "REPORT":
|
||||
// CalDAV sync clients might send REPORT to the calendar collection.
|
||||
// For now, we can treat it like a PROPFIND or return not implemented.
|
||||
// Depending on the REPORT body, it could be a sync-collection report.
|
||||
// Let's assume for now it's asking for a list of resources.
|
||||
log.Debugf("[CALDAV] ProjectHandler (collection) received REPORT, treating as PROPFIND for now.")
|
||||
return ListCalendars(c, u)
|
||||
default:
|
||||
log.Warningf("[CALDAV] ProjectHandler (collection) received unhandled method %s", c.Request().Method)
|
||||
c.Response().Header().Set("Allow", "PROPFIND, REPORT, OPTIONS")
|
||||
return c.NoContent(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("[CALDAV] Request Body: %v\n", string(body))
|
||||
log.Debugf("[CALDAV] Request Headers: %v\n", c.Request().Header)
|
||||
|
||||
caldav.SetupStorage(storage)
|
||||
caldav.SetupUser("dav/projects")
|
||||
caldav.SetupSupportedComponents([]string{lib.VCALENDAR, lib.VTODO})
|
||||
response := caldav.HandleRequest(c.Request())
|
||||
response.Write(c.Response())
|
||||
return nil
|
||||
}
|
||||
|
||||
// TaskHandler is the handler which manages updating/deleting a single task
|
||||
func TaskHandler(c echo.Context) error {
|
||||
project, err := getProjectFromParam(c)
|
||||
// Request to /dav/projects/:projectid/
|
||||
projectID, err := strconv.ParseInt(projectIDParam, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
log.Errorf("Invalid project ID parameter '%s': %v", projectIDParam, err)
|
||||
return c.String(http.StatusBadRequest, "Invalid project ID")
|
||||
}
|
||||
|
||||
// Verify project existence and user access (basic check, ListTasksInProject will do more)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
proj := models.Project{ID: projectID}
|
||||
canRead, _, errDb := proj.CanRead(s, u)
|
||||
if errDb != nil {
|
||||
log.Errorf("Error checking read permission for project %d by user %d: %v", projectID, u.ID, errDb)
|
||||
return c.NoContent(http.StatusInternalServerError)
|
||||
}
|
||||
if !canRead {
|
||||
log.Warningf("User %s (ID: %d) forbidden to access project %d", u.Username, u.ID, projectID)
|
||||
return c.NoContent(http.StatusForbidden)
|
||||
}
|
||||
if err := s.Commit(); err != nil { // commit the read-only transaction
|
||||
log.Errorf("Error committing after project read check: %v", err)
|
||||
// Not returning error here as it's a read check, but logging is important.
|
||||
}
|
||||
|
||||
|
||||
switch c.Request().Method {
|
||||
case "PROPFIND":
|
||||
return ListTasksInProject(c, u, projectID)
|
||||
case "REPORT":
|
||||
// REPORT on a specific calendar. This is often a calendar-multiget or calendar-query.
|
||||
// The current ListTasksInProject doesn't handle specific REPORT bodies.
|
||||
// For simplicity, we can treat it as a PROPFIND for all tasks in the project.
|
||||
// A more advanced implementation would parse the REPORT XML.
|
||||
log.Debugf("[CALDAV] ProjectHandler (specific project) received REPORT, treating as PROPFIND for tasks.")
|
||||
return ListTasksInProject(c, u, projectID)
|
||||
case "OPTIONS":
|
||||
c.Response().Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, PUT, DELETE") // Methods for a calendar resource
|
||||
c.Response().Header().Set("DAV", "1, 3, calendar-access") // Basic DAV + CalDAV calendar access
|
||||
// Add other capabilities like calendar-schedule if supported
|
||||
return c.NoContent(http.StatusOK)
|
||||
default:
|
||||
log.Warningf("[CALDAV] ProjectHandler (specific project) received unhandled method %s for project %d", c.Request().Method, projectID)
|
||||
c.Response().Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, PUT, DELETE") // Set Allow for unhandled methods too
|
||||
return c.NoContent(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// TaskHandler manages operations on individual task resources (.ics files).
|
||||
func TaskHandler(c echo.Context) error {
|
||||
u, err := getBasicAuthUserFromContext(c)
|
||||
if err != nil {
|
||||
return echo.ErrInternalServerError.SetInternal(err)
|
||||
log.Errorf("Error getting user from basic auth context: %v", err)
|
||||
return echo.ErrUnauthorized.SetInternal(fmt.Errorf("invalid user context: %w", err))
|
||||
}
|
||||
|
||||
// Get the task uid
|
||||
taskUID := strings.TrimSuffix(c.Param("task"), ".ics")
|
||||
projectIDParam := c.Param("project")
|
||||
taskUIDParam := c.Param("task") // Includes .ics suffix
|
||||
|
||||
storage := &VikunjaCaldavProjectStorage{
|
||||
project: project,
|
||||
task: &models.Task{UID: taskUID},
|
||||
user: u,
|
||||
// Log CalDAV request details
|
||||
bodyBytes, _ := io.ReadAll(c.Request().Body)
|
||||
c.Request().Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore body
|
||||
log.Debugf("[CALDAV] TaskHandler: Method=%s, Path=%s, ProjectIDParam=%s, TaskUIDParam=%s, User=%s, Headers=%v, Body=%s",
|
||||
c.Request().Method, c.Path(), projectIDParam, taskUIDParam, u.Username, c.Request().Header, string(bodyBytes))
|
||||
|
||||
projectID, err := strconv.ParseInt(projectIDParam, 10, 64)
|
||||
if err != nil {
|
||||
log.Errorf("Invalid project ID parameter '%s' in TaskHandler: %v", projectIDParam, err)
|
||||
return c.String(http.StatusBadRequest, "Invalid project ID")
|
||||
}
|
||||
|
||||
caldav.SetupStorage(storage)
|
||||
response := caldav.HandleRequest(c.Request())
|
||||
response.Write(c.Response())
|
||||
return nil
|
||||
taskUID := strings.TrimSuffix(taskUIDParam, ".ics")
|
||||
if taskUID == "" {
|
||||
log.Errorf("Empty task UID in TaskHandler (param was '%s')", taskUIDParam)
|
||||
return c.String(http.StatusBadRequest, "Invalid task UID")
|
||||
}
|
||||
|
||||
switch c.Request().Method {
|
||||
case http.MethodGet:
|
||||
return FetchTaskAsICS(c, u, projectID, taskUID)
|
||||
case http.MethodPut:
|
||||
return UpsertTaskFromICS(c, u, projectID, taskUID)
|
||||
case http.MethodDelete:
|
||||
return RemoveTaskICS(c, u, projectID, taskUID)
|
||||
case "PROPFIND":
|
||||
// PROPFIND on a specific task. We can fetch it and return its properties.
|
||||
// This is a simplified PROPFIND for a single resource.
|
||||
// FetchTaskAsICS already sets ETag and ContentType headers.
|
||||
// We need to wrap this in a multistatus response.
|
||||
// For now, let's delegate to FetchTaskAsICS which should return 200 OK with headers.
|
||||
// A full PROPFIND would require XML response.
|
||||
// TODO: Implement proper PROPFIND for single task resource.
|
||||
// As a temporary measure, try fetching it. If it exists, client might get headers.
|
||||
// This is not fully CalDAV compliant for PROPFIND on task.
|
||||
log.Debugf("[CALDAV] TaskHandler received PROPFIND for specific task, attempting simplified handling.")
|
||||
return GetTaskPropertiesAsXML(c, u, projectID, taskUID)
|
||||
default:
|
||||
log.Warningf("[CALDAV] TaskHandler received unhandled method %s", c.Request().Method)
|
||||
c.Response().Header().Set("Allow", "GET, PUT, DELETE, PROPFIND, OPTIONS")
|
||||
return c.NoContent(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// PrincipalHandler handles all request to principal resources
|
||||
// PrincipalHandler handles requests to principal URLs.
|
||||
// Typically /dav/principals/users/<username>/ or /.well-known/caldav
|
||||
func PrincipalHandler(c echo.Context) error {
|
||||
u, err := getBasicAuthUserFromContext(c)
|
||||
if err != nil {
|
||||
return echo.ErrInternalServerError.SetInternal(err)
|
||||
log.Errorf("Error getting user from basic auth context: %v", err)
|
||||
return echo.ErrUnauthorized.SetInternal(fmt.Errorf("invalid user context: %w", err))
|
||||
}
|
||||
|
||||
storage := &VikunjaCaldavProjectStorage{
|
||||
user: u,
|
||||
isPrincipal: true,
|
||||
// Log CalDAV request details
|
||||
bodyBytes, _ := io.ReadAll(c.Request().Body)
|
||||
c.Request().Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
log.Debugf("[CALDAV] PrincipalHandler: Method=%s, Path=%s, User=%s, Headers=%v, Body=%s",
|
||||
c.Request().Method, c.Path(), u.Username, c.Request().Header, string(bodyBytes))
|
||||
|
||||
switch c.Request().Method {
|
||||
case "PROPFIND":
|
||||
// PROPFIND on a principal URL should list properties of the principal,
|
||||
// including the calendar-home-set, which points to where calendars are.
|
||||
// For Vikunja, ProjectBasePath (/dav/projects/) can be considered the calendar-home-set.
|
||||
// It should also list the calendars themselves (projects).
|
||||
return ListPrincipalPropertiesAndCalendars(c, u)
|
||||
case "REPORT":
|
||||
log.Debugf("[CALDAV] PrincipalHandler received REPORT, which is not typically handled at this level. Path: %s", c.Path())
|
||||
// Reports are usually on calendar or task resources.
|
||||
// If it's a sync-collection on the principal, it's more complex.
|
||||
return c.NoContent(http.StatusNotImplemented)
|
||||
|
||||
default:
|
||||
log.Warningf("[CALDAV] PrincipalHandler received unhandled method %s", c.Request().Method)
|
||||
// Key principal properties for PROPFIND:
|
||||
// <d:current-user-principal>, <d:principal-URL>, <c:calendar-home-set>
|
||||
c.Response().Header().Set("Allow", "PROPFIND, OPTIONS, REPORT")
|
||||
return c.NoContent(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// Try to parse a task from the request payload
|
||||
body, _ := io.ReadAll(c.Request().Body)
|
||||
// Restore the io.ReadCloser to its original state
|
||||
c.Request().Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
log.Debugf("[CALDAV] Request Body: %v\n", string(body))
|
||||
log.Debugf("[CALDAV] Request Headers: %v\n", c.Request().Header)
|
||||
|
||||
caldav.SetupStorage(storage)
|
||||
caldav.SetupUser("dav/principals/" + u.Username)
|
||||
caldav.SetupSupportedComponents([]string{lib.VCALENDAR, lib.VTODO})
|
||||
|
||||
response := caldav.HandleRequest(c.Request())
|
||||
response.Write(c.Response())
|
||||
return nil
|
||||
}
|
||||
|
||||
// EntryHandler handles all request to principal resources
|
||||
// EntryHandler handles requests to the CalDAV service root (e.g., /dav/ or /dav).
|
||||
func EntryHandler(c echo.Context) error {
|
||||
u, err := getBasicAuthUserFromContext(c)
|
||||
if err != nil {
|
||||
return echo.ErrInternalServerError.SetInternal(err)
|
||||
log.Errorf("Error getting user from basic auth context: %v", err)
|
||||
return echo.ErrUnauthorized.SetInternal(fmt.Errorf("invalid user context: %w", err))
|
||||
}
|
||||
|
||||
storage := &VikunjaCaldavProjectStorage{
|
||||
user: u,
|
||||
isEntry: true,
|
||||
// Log CalDAV request details
|
||||
bodyBytes, _ := io.ReadAll(c.Request().Body)
|
||||
c.Request().Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
log.Debugf("[CALDAV] EntryHandler: Method=%s, Path=%s, User=%s, Headers=%v, Body=%s",
|
||||
c.Request().Method, c.Path(), u.Username, c.Request().Header, string(bodyBytes))
|
||||
|
||||
switch c.Request().Method {
|
||||
case "PROPFIND":
|
||||
// PROPFIND on the service root should discover the user's principal
|
||||
// and their calendar home set.
|
||||
// For Vikunja, this essentially means listing their calendars.
|
||||
return ListPrincipalPropertiesAndCalendars(c, u)
|
||||
case "REPORT":
|
||||
log.Debugf("[CALDAV] EntryHandler received REPORT, which is not typically handled at this level. Path: %s", c.Path())
|
||||
return c.NoContent(http.StatusNotImplemented)
|
||||
default:
|
||||
log.Warningf("[CALDAV] EntryHandler received unhandled method %s", c.Request().Method)
|
||||
c.Response().Header().Set("Allow", "PROPFIND, OPTIONS, REPORT")
|
||||
return c.NoContent(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// Try to parse a task from the request payload
|
||||
body, _ := io.ReadAll(c.Request().Body)
|
||||
// Restore the io.ReadCloser to its original state
|
||||
c.Request().Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
log.Debugf("[CALDAV] Request Body: %v\n", string(body))
|
||||
log.Debugf("[CALDAV] Request Headers: %v\n", c.Request().Header)
|
||||
|
||||
caldav.SetupStorage(storage)
|
||||
caldav.SetupUser("dav/principals/" + u.Username)
|
||||
caldav.SetupSupportedComponents([]string{lib.VCALENDAR, lib.VTODO})
|
||||
|
||||
response := caldav.HandleRequest(c.Request())
|
||||
response.Write(c.Response())
|
||||
return nil
|
||||
}
|
||||
|
||||
func getProjectFromParam(c echo.Context) (project *models.ProjectWithTasksAndBuckets, err error) {
|
||||
func getProjectFromParam(c echo.Context) (*models.ProjectWithTasksAndBuckets, error) {
|
||||
param := c.Param("project")
|
||||
if param == "" {
|
||||
return &models.ProjectWithTasksAndBuckets{}, nil
|
||||
// This case should ideally be handled by a different route/handler (e.g., for /dav/projects/)
|
||||
// If ProjectHandler gets called with no :project param, it means it's the collection.
|
||||
// Returning nil, nil here and letting the caller (ProjectHandler) decide.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
@@ -185,29 +264,27 @@ func getProjectFromParam(c echo.Context) (project *models.ProjectWithTasksAndBuc
|
||||
|
||||
intParam, err := strconv.ParseInt(param, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("getProjectFromParam: invalid project ID '%s': %w", param, err)
|
||||
}
|
||||
|
||||
if intParam == models.FavoritesPseudoProjectID {
|
||||
return &models.ProjectWithTasksAndBuckets{Project: models.FavoritesPseudoProject}, nil
|
||||
}
|
||||
|
||||
if intParam < models.FavoritesPseudoProjectID {
|
||||
var sf *models.SavedFilter
|
||||
sf, err = models.GetSavedFilterSimpleByID(s, models.GetSavedFilterIDFromProjectID(intParam))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
project = &models.ProjectWithTasksAndBuckets{Project: *sf.ToProject()}
|
||||
return
|
||||
}
|
||||
// No need to handle FavoritesPseudoProjectID or SavedFilter here as ProjectHandler
|
||||
// will use ListCalendars or ListTasksInProject which work with actual project IDs.
|
||||
// CalDAV paths should resolve to actual calendar resources.
|
||||
|
||||
p, err := models.GetProjectSimpleByID(s, intParam)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if models.IsErrProjectDoesNotExist(err) {
|
||||
return nil, err // Return specific error for not found
|
||||
}
|
||||
return nil, fmt.Errorf("getProjectFromParam: db error getting project %d: %w", intParam, err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("getProjectFromParam: error committing after GetProjectSimpleByID: %w", err)
|
||||
}
|
||||
|
||||
project = &models.ProjectWithTasksAndBuckets{Project: *p}
|
||||
return
|
||||
|
||||
// We need ProjectWithTasksAndBuckets for consistency, though only Project might be used by caller
|
||||
// before calling ListTasksInProject which re-fetches with tasks.
|
||||
// For basic validation, Project info is enough.
|
||||
return &models.ProjectWithTasksAndBuckets{Project: *p}, nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,28 +19,44 @@ package caldav
|
||||
// This file tests logic related to handling tasks in CALDAV format
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/files"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/samedi/caldav-go/data"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helper to create a new echo.Context for testing
|
||||
func newTestContext(method, path string, body string) (echo.Context, *httptest.ResponseRecorder) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set(echo.HeaderContentType, "text/calendar; charset=utf-8") // Common for PUT/POST
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// Check logic related to creating sub-tasks
|
||||
func TestSubTask_Create(t *testing.T) {
|
||||
u := &user.User{
|
||||
currentUser := &user.User{ // Renamed from u to currentUser for clarity
|
||||
ID: 15,
|
||||
Username: "user15",
|
||||
Email: "user15@example.com",
|
||||
}
|
||||
|
||||
config.InitDefaultConfig()
|
||||
log.InitLogger()
|
||||
files.InitTests()
|
||||
user.InitTests()
|
||||
models.SetupTests()
|
||||
@@ -54,7 +70,9 @@ func TestSubTask_Create(t *testing.T) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
const projectID = 36
|
||||
const taskUID = "uid_child1"
|
||||
var taskURL = "/dav/projects/" + strconv.FormatInt(projectID, 10) + "/" + taskUID + ".ics"
|
||||
const taskContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
METHOD:PUBLISH
|
||||
@@ -71,24 +89,35 @@ RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task
|
||||
END:VTODO
|
||||
END:VCALENDAR`
|
||||
|
||||
storage := &VikunjaCaldavProjectStorage{
|
||||
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
|
||||
task: &models.Task{UID: taskUID},
|
||||
user: u,
|
||||
}
|
||||
c, rec := newTestContext(http.MethodPut, taskURL, taskContent)
|
||||
// Set user in context, similar to how middleware would
|
||||
c.Set("userBasicAuth", currentUser)
|
||||
c.SetParamNames("project", "task")
|
||||
c.SetParamValues(strconv.FormatInt(projectID, 10), taskUID+".ics")
|
||||
|
||||
// Create the subtask:
|
||||
taskResource, err := storage.CreateResource(taskUID, taskContent)
|
||||
|
||||
err := UpsertTaskFromICS(c, currentUser, projectID, taskUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusCreated, rec.Code) // Or StatusNoContent if update
|
||||
|
||||
// Check that the result CALDAV contains the relation:
|
||||
content, _ := taskResource.GetContentData()
|
||||
// To check the CalDAV content, we would now need to call FetchTaskAsICS
|
||||
fetchCtx, fetchRec := newTestContext(http.MethodGet, taskURL, "")
|
||||
fetchCtx.Set("userBasicAuth", currentUser)
|
||||
fetchCtx.SetParamNames("project", "task")
|
||||
fetchCtx.SetParamValues(strconv.FormatInt(projectID, 10), taskUID+".ics")
|
||||
|
||||
err = FetchTaskAsICS(fetchCtx, currentUser, projectID, taskUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, fetchRec.Code)
|
||||
|
||||
content := fetchRec.Body.String()
|
||||
assert.Contains(t, content, "UID:"+taskUID)
|
||||
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task")
|
||||
|
||||
// Get the task from the DB:
|
||||
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
|
||||
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, currentUser)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tasks, 1, "Task should be found in DB")
|
||||
task := tasks[0]
|
||||
|
||||
// Check that the parent-child relationship is present:
|
||||
@@ -105,8 +134,11 @@ END:VCALENDAR`
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
const taskUIDChild = "uid_child1"
|
||||
const taskContentChild = `BEGIN:VCALENDAR
|
||||
const projectID = 36
|
||||
const parentTaskUID = "uid-caldav-test-parent-task" // Assuming this exists from fixtures
|
||||
const childTaskUID = "uid_child1"
|
||||
var childTaskURL = "/dav/projects/" + strconv.FormatInt(projectID, 10) + "/" + childTaskUID + ".ics"
|
||||
var childTaskContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
METHOD:PUBLISH
|
||||
X-PUBLISHED-TTL:PT4H
|
||||
@@ -118,22 +150,23 @@ DTSTAMP:20230301T073337Z
|
||||
SUMMARY:Caldav child task 1
|
||||
CREATED:20230301T073337Z
|
||||
LAST-MODIFIED:20230301T073337Z
|
||||
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task
|
||||
RELATED-TO;RELTYPE=PARENT:` + parentTaskUID + `
|
||||
END:VTODO
|
||||
END:VCALENDAR`
|
||||
|
||||
storage := &VikunjaCaldavProjectStorage{
|
||||
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
|
||||
task: &models.Task{UID: taskUIDChild},
|
||||
user: u,
|
||||
}
|
||||
|
||||
// Create the subtask:
|
||||
_, err := storage.CreateResource(taskUIDChild, taskContentChild)
|
||||
// Create the child task first
|
||||
cChild, recChild := newTestContext(http.MethodPut, childTaskURL, childTaskContent)
|
||||
cChild.Set("userBasicAuth", currentUser)
|
||||
cChild.SetParamNames("project", "task")
|
||||
cChild.SetParamValues(strconv.FormatInt(projectID, 10), childTaskUID+".ics")
|
||||
err := UpsertTaskFromICS(cChild, currentUser, projectID, childTaskUID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, recChild.Code == http.StatusCreated || recChild.Code == http.StatusNoContent)
|
||||
|
||||
const taskUID = "uid_grand_child1"
|
||||
const taskContent = `BEGIN:VCALENDAR
|
||||
|
||||
const grandChildTaskUID = "uid_grand_child1"
|
||||
var grandChildTaskURL = "/dav/projects/" + strconv.FormatInt(projectID, 10) + "/" + grandChildTaskUID + ".ics"
|
||||
var grandChildTaskContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
METHOD:PUBLISH
|
||||
X-PUBLISHED-TTL:PT4H
|
||||
@@ -145,46 +178,53 @@ DTSTAMP:20230301T073337Z
|
||||
SUMMARY:Caldav grand child task 1
|
||||
CREATED:20230301T073337Z
|
||||
LAST-MODIFIED:20230301T073337Z
|
||||
RELATED-TO;RELTYPE=PARENT:uid_child1
|
||||
RELATED-TO;RELTYPE=PARENT:` + childTaskUID + `
|
||||
END:VTODO
|
||||
END:VCALENDAR`
|
||||
|
||||
storage = &VikunjaCaldavProjectStorage{
|
||||
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
|
||||
task: &models.Task{UID: taskUID},
|
||||
user: u,
|
||||
}
|
||||
|
||||
// Create the task:
|
||||
var taskResource *data.Resource
|
||||
taskResource, err = storage.CreateResource(taskUID, taskContent)
|
||||
cGChild, recGChild := newTestContext(http.MethodPut, grandChildTaskURL, grandChildTaskContent)
|
||||
cGChild.Set("userBasicAuth", currentUser)
|
||||
cGChild.SetParamNames("project", "task")
|
||||
cGChild.SetParamValues(strconv.FormatInt(projectID, 10), grandChildTaskUID+".ics")
|
||||
err = UpsertTaskFromICS(cGChild, currentUser, projectID, grandChildTaskUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusCreated, recGChild.Code)
|
||||
|
||||
|
||||
// Check that the result CALDAV contains the relation:
|
||||
content, _ := taskResource.GetContentData()
|
||||
assert.Contains(t, content, "UID:"+taskUID)
|
||||
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid_child1")
|
||||
|
||||
// Get the task from the DB:
|
||||
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
|
||||
fetchGCtx, fetchGRec := newTestContext(http.MethodGet, grandChildTaskURL, "")
|
||||
fetchGCtx.Set("userBasicAuth", currentUser)
|
||||
fetchGCtx.SetParamNames("project", "task")
|
||||
fetchGCtx.SetParamValues(strconv.FormatInt(projectID, 10), grandChildTaskUID+".ics")
|
||||
err = FetchTaskAsICS(fetchGCtx, currentUser, projectID, grandChildTaskUID)
|
||||
require.NoError(t, err)
|
||||
gContent := fetchGRec.Body.String()
|
||||
assert.Contains(t, gContent, "UID:"+grandChildTaskUID)
|
||||
assert.Contains(t, gContent, "RELATED-TO;RELTYPE=PARENT:"+childTaskUID)
|
||||
|
||||
// Get the grandchild task from the DB:
|
||||
tasks, err := models.GetTasksByUIDs(s, []string{grandChildTaskUID}, currentUser)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tasks, 1)
|
||||
task := tasks[0]
|
||||
|
||||
// Check that the parent-child relationship of the grandchildren is present:
|
||||
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
|
||||
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
|
||||
assert.Equal(t, "uid_child1", parentTask.UID)
|
||||
assert.Equal(t, childTaskUID, parentTask.UID)
|
||||
|
||||
// Get the child task and check that it now has a parent and a child:
|
||||
tasks, err = models.GetTasksByUIDs(s, []string{"uid_child1"}, u)
|
||||
childTasks, err := models.GetTasksByUIDs(s, []string{childTaskUID}, currentUser)
|
||||
require.NoError(t, err)
|
||||
task = tasks[0]
|
||||
require.Len(t, childTasks, 1)
|
||||
task = childTasks[0]
|
||||
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
|
||||
parentTask = task.RelatedTasks[models.RelationKindParenttask][0]
|
||||
assert.Equal(t, "uid-caldav-test-parent-task", parentTask.UID)
|
||||
assert.Equal(t, parentTaskUID, parentTask.UID)
|
||||
|
||||
assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 1)
|
||||
childTask := task.RelatedTasks[models.RelationKindSubtask][0]
|
||||
assert.Equal(t, taskUID, childTask.UID)
|
||||
gcTask := task.RelatedTasks[models.RelationKindSubtask][0]
|
||||
assert.Equal(t, grandChildTaskUID, gcTask.UID)
|
||||
})
|
||||
|
||||
//
|
||||
@@ -195,64 +235,83 @@ END:VCALENDAR`
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Create a subtask:
|
||||
const taskUID = "uid_child1"
|
||||
const taskContent = `BEGIN:VCALENDAR
|
||||
const projectID = 36
|
||||
const taskUID = "uid_child1_unknown_parent" // Make UID unique for this test run
|
||||
const unknownParentUID = "uid-caldav-test-parent-doesnt-exist-yet"
|
||||
var taskURL = "/dav/projects/" + strconv.FormatInt(projectID, 10) + "/" + taskUID + ".ics"
|
||||
var taskContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
METHOD:PUBLISH
|
||||
X-PUBLISHED-TTL:PT4H
|
||||
X-WR-CALNAME:Project 36 for Caldav tests
|
||||
PRODID:-//Vikunja Todo App//EN
|
||||
BEGIN:VTODO
|
||||
UID:uid_child1
|
||||
UID:` + taskUID + `
|
||||
DTSTAMP:20230301T073337Z
|
||||
SUMMARY:Caldav child task 1
|
||||
CREATED:20230301T073337Z
|
||||
LAST-MODIFIED:20230301T073337Z
|
||||
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-doesnt-exist-yet
|
||||
RELATED-TO;RELTYPE=PARENT:` + unknownParentUID + `
|
||||
END:VTODO
|
||||
END:VCALENDAR`
|
||||
|
||||
storage := &VikunjaCaldavProjectStorage{
|
||||
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
|
||||
task: &models.Task{UID: taskUID},
|
||||
user: u,
|
||||
}
|
||||
c, rec := newTestContext(http.MethodPut, taskURL, taskContent)
|
||||
c.Set("userBasicAuth", currentUser)
|
||||
c.SetParamNames("project", "task")
|
||||
c.SetParamValues(strconv.FormatInt(projectID, 10), taskUID+".ics")
|
||||
|
||||
// Create the task:
|
||||
taskResource, err := storage.CreateResource(taskUID, taskContent)
|
||||
err := UpsertTaskFromICS(c, currentUser, projectID, taskUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusCreated, rec.Code)
|
||||
|
||||
// Check that the result CALDAV contains the relation:
|
||||
content, _ := taskResource.GetContentData()
|
||||
fetchCtx, fetchRec := newTestContext(http.MethodGet, taskURL, "")
|
||||
fetchCtx.Set("userBasicAuth", currentUser)
|
||||
fetchCtx.SetParamNames("project", "task")
|
||||
fetchCtx.SetParamValues(strconv.FormatInt(projectID, 10), taskUID+".ics")
|
||||
err = FetchTaskAsICS(fetchCtx, currentUser, projectID, taskUID)
|
||||
require.NoError(t, err)
|
||||
content := fetchRec.Body.String()
|
||||
|
||||
assert.Contains(t, content, "UID:"+taskUID)
|
||||
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-doesnt-exist-yet")
|
||||
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:"+unknownParentUID)
|
||||
|
||||
// Get the task from the DB:
|
||||
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
|
||||
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, currentUser)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tasks, 1)
|
||||
task := tasks[0]
|
||||
|
||||
// Check that the parent-child relationship is present:
|
||||
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
|
||||
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
|
||||
assert.Equal(t, "uid-caldav-test-parent-doesnt-exist-yet", parentTask.UID)
|
||||
assert.Equal(t, unknownParentUID, parentTask.UID)
|
||||
|
||||
// Check that the non-existent parent task was created in the process:
|
||||
tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-parent-doesnt-exist-yet"}, u)
|
||||
parentTasks, err := models.GetTasksByUIDs(s, []string{unknownParentUID}, currentUser)
|
||||
require.NoError(t, err)
|
||||
task = tasks[0]
|
||||
assert.Equal(t, "uid-caldav-test-parent-doesnt-exist-yet", task.UID)
|
||||
require.Len(t, parentTasks, 1, "Dummy parent task should have been created")
|
||||
createdParentTask := parentTasks[0]
|
||||
assert.Equal(t, unknownParentUID, createdParentTask.UID)
|
||||
assert.Equal(t, projectID, int(createdParentTask.ProjectID)) // Should be in the same project
|
||||
assert.Equal(t, "DUMMY-UID-"+unknownParentUID, createdParentTask.Title)
|
||||
})
|
||||
}
|
||||
|
||||
// Logic related to editing tasks and subtasks
|
||||
func TestSubTask_Update(t *testing.T) {
|
||||
u := &user.User{
|
||||
currentUser := &user.User{ // Renamed from u to currentUser
|
||||
ID: 15,
|
||||
Username: "user15",
|
||||
Email: "user15@example.com",
|
||||
}
|
||||
// Init calls are already in TestSubTask_Create, if tests run in sequence they might not be needed
|
||||
// but it's safer to have them if tests can run independently.
|
||||
// config.InitDefaultConfig()
|
||||
// files.InitTests()
|
||||
// user.InitTests()
|
||||
// models.SetupTests()
|
||||
|
||||
|
||||
//
|
||||
// Edit a subtask and check that the relations are not gone
|
||||
@@ -262,50 +321,65 @@ func TestSubTask_Update(t *testing.T) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Edit the subtask:
|
||||
const taskUID = "uid-caldav-test-child-task"
|
||||
const taskContent = `BEGIN:VCALENDAR
|
||||
const projectID = 36
|
||||
const taskUID = "uid-caldav-test-child-task" // Exists in fixtures
|
||||
const parentUID = "uid-caldav-test-parent-task" // Exists in fixtures
|
||||
var taskURL = "/dav/projects/" + strconv.FormatInt(projectID, 10) + "/" + taskUID + ".ics"
|
||||
var taskContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
METHOD:PUBLISH
|
||||
X-PUBLISHED-TTL:PT4H
|
||||
X-WR-CALNAME:Project 36 for Caldav tests
|
||||
PRODID:-//Vikunja Todo App//EN
|
||||
BEGIN:VTODO
|
||||
UID:uid-caldav-test-child-task
|
||||
UID:` + taskUID + `
|
||||
DTSTAMP:20230301T073337Z
|
||||
SUMMARY:Child task for Caldav Test (edited)
|
||||
CREATED:20230301T073337Z
|
||||
LAST-MODIFIED:20230301T073337Z
|
||||
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task
|
||||
RELATED-TO;RELTYPE=PARENT:` + parentUID + `
|
||||
END:VTODO
|
||||
END:VCALENDAR`
|
||||
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
|
||||
require.NoError(t, err)
|
||||
task := tasks[0]
|
||||
storage := &VikunjaCaldavProjectStorage{
|
||||
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
|
||||
task: task,
|
||||
user: u,
|
||||
}
|
||||
// Fetch existing task to pass to Upsert (not strictly needed by Upsert but good for context)
|
||||
// tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, currentUser)
|
||||
// require.NoError(t, err)
|
||||
// require.Len(t, tasks, 1)
|
||||
// existingTask := tasks[0]
|
||||
|
||||
// Edit the task:
|
||||
taskResource, err := storage.UpdateResource(taskUID, taskContent)
|
||||
c, rec := newTestContext(http.MethodPut, taskURL, taskContent)
|
||||
c.Set("userBasicAuth", currentUser)
|
||||
c.SetParamNames("project", "task")
|
||||
c.SetParamValues(strconv.FormatInt(projectID, 10), taskUID+".ics")
|
||||
|
||||
err := UpsertTaskFromICS(c, currentUser, projectID, taskUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code) // Update should be 204
|
||||
|
||||
// Check that the result CALDAV still contains the relation:
|
||||
content, _ := taskResource.GetContentData()
|
||||
fetchCtx, fetchRec := newTestContext(http.MethodGet, taskURL, "")
|
||||
fetchCtx.Set("userBasicAuth", currentUser)
|
||||
fetchCtx.SetParamNames("project", "task")
|
||||
fetchCtx.SetParamValues(strconv.FormatInt(projectID, 10), taskUID+".ics")
|
||||
err = FetchTaskAsICS(fetchCtx, currentUser, projectID, taskUID)
|
||||
require.NoError(t, err)
|
||||
content := fetchRec.Body.String()
|
||||
|
||||
assert.Contains(t, content, "UID:"+taskUID)
|
||||
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task")
|
||||
assert.Contains(t, content, "SUMMARY:Child task for Caldav Test (edited)")
|
||||
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:"+parentUID)
|
||||
|
||||
// Get the task from the DB:
|
||||
tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u)
|
||||
tasksDB, err := models.GetTasksByUIDs(s, []string{taskUID}, currentUser)
|
||||
require.NoError(t, err)
|
||||
task = tasks[0]
|
||||
require.Len(t, tasksDB, 1)
|
||||
taskFromDB := tasksDB[0]
|
||||
assert.Equal(t, "Child task for Caldav Test (edited)", taskFromDB.Title)
|
||||
|
||||
|
||||
// Check that the parent-child relationship is still present:
|
||||
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
|
||||
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
|
||||
assert.Equal(t, "uid-caldav-test-parent-task", parentTask.UID)
|
||||
assert.Len(t, taskFromDB.RelatedTasks[models.RelationKindParenttask], 1)
|
||||
parentTask := taskFromDB.RelatedTasks[models.RelationKindParenttask][0]
|
||||
assert.Equal(t, parentUID, parentTask.UID)
|
||||
})
|
||||
|
||||
//
|
||||
@@ -316,48 +390,60 @@ END:VCALENDAR`
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Edit the parent task:
|
||||
const taskUID = "uid-caldav-test-parent-task"
|
||||
const taskContent = `BEGIN:VCALENDAR
|
||||
const projectID = 36
|
||||
const taskUID = "uid-caldav-test-parent-task" // Exists in fixtures
|
||||
const childUID1 = "uid-caldav-test-child-task" // Exists in fixtures
|
||||
const childUID2 = "uid-caldav-test-child-task-2" // Exists in fixtures
|
||||
var taskURL = "/dav/projects/" + strconv.FormatInt(projectID, 10) + "/" + taskUID + ".ics"
|
||||
var taskContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
METHOD:PUBLISH
|
||||
X-PUBLISHED-TTL:PT4H
|
||||
X-WR-CALNAME:Project 36 for Caldav tests
|
||||
PRODID:-//Vikunja Todo App//EN
|
||||
BEGIN:VTODO
|
||||
UID:uid-caldav-test-parent-task
|
||||
UID:` + taskUID + `
|
||||
DTSTAMP:20230301T073337Z
|
||||
SUMMARY:Parent task for Caldav Test (edited)
|
||||
CREATED:20230301T073337Z
|
||||
LAST-MODIFIED:20230301T073337Z
|
||||
RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task
|
||||
RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task-2
|
||||
RELATED-TO;RELTYPE=CHILD:` + childUID1 + `
|
||||
RELATED-TO;RELTYPE=CHILD:` + childUID2 + `
|
||||
END:VTODO
|
||||
END:VCALENDAR`
|
||||
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
|
||||
require.NoError(t, err)
|
||||
task := tasks[0]
|
||||
storage := &VikunjaCaldavProjectStorage{
|
||||
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
|
||||
task: task,
|
||||
user: u,
|
||||
}
|
||||
|
||||
// Edit the task:
|
||||
_, err = storage.UpdateResource(taskUID, taskContent)
|
||||
c, rec := newTestContext(http.MethodPut, taskURL, taskContent)
|
||||
c.Set("userBasicAuth", currentUser)
|
||||
c.SetParamNames("project", "task")
|
||||
c.SetParamValues(strconv.FormatInt(projectID, 10), taskUID+".ics")
|
||||
|
||||
err := UpsertTaskFromICS(c, currentUser, projectID, taskUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
|
||||
// Get the task from the DB:
|
||||
tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u)
|
||||
tasksDB, err := models.GetTasksByUIDs(s, []string{taskUID}, currentUser)
|
||||
require.NoError(t, err)
|
||||
task = tasks[0]
|
||||
require.Len(t, tasksDB, 1)
|
||||
taskFromDB := tasksDB[0]
|
||||
assert.Equal(t, "Parent task for Caldav Test (edited)", taskFromDB.Title)
|
||||
|
||||
// Check that the subtasks are still linked:
|
||||
assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 2)
|
||||
existingSubTask := task.RelatedTasks[models.RelationKindSubtask][0]
|
||||
assert.Equal(t, "uid-caldav-test-child-task", existingSubTask.UID)
|
||||
existingSubTask = task.RelatedTasks[models.RelationKindSubtask][1]
|
||||
assert.Equal(t, "uid-caldav-test-child-task-2", existingSubTask.UID)
|
||||
assert.Len(t, taskFromDB.RelatedTasks[models.RelationKindSubtask], 2)
|
||||
|
||||
foundChild1 := false
|
||||
foundChild2 := false
|
||||
for _, subTask := range taskFromDB.RelatedTasks[models.RelationKindSubtask] {
|
||||
if subTask.UID == childUID1 {
|
||||
foundChild1 = true
|
||||
}
|
||||
if subTask.UID == childUID2 {
|
||||
foundChild2 = true
|
||||
}
|
||||
}
|
||||
assert.True(t, foundChild1, "Child task 1 should be linked")
|
||||
assert.True(t, foundChild2, "Child task 2 should be linked")
|
||||
})
|
||||
|
||||
//
|
||||
@@ -368,58 +454,79 @@ END:VCALENDAR`
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Edit the subtask:
|
||||
const taskUID = "uid-caldav-test-child-task"
|
||||
const taskContent = `BEGIN:VCALENDAR
|
||||
const projectID = 36
|
||||
const taskUID = "uid-caldav-test-child-task" // Exists in fixtures
|
||||
const originalParentUID = "uid-caldav-test-parent-task" // Exists in fixtures
|
||||
const newParentUID = "uid-caldav-test-parent-task-2" // Exists in fixtures
|
||||
var taskURL = "/dav/projects/" + strconv.FormatInt(projectID, 10) + "/" + taskUID + ".ics"
|
||||
|
||||
var taskContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
METHOD:PUBLISH
|
||||
X-PUBLISHED-TTL:PT4H
|
||||
X-WR-CALNAME:Project 36 for Caldav tests
|
||||
PRODID:-//Vikunja Todo App//EN
|
||||
BEGIN:VTODO
|
||||
UID:uid-caldav-test-child-task
|
||||
UID:` + taskUID + `
|
||||
DTSTAMP:20230301T073337Z
|
||||
SUMMARY:Child task for Caldav Test (edited)
|
||||
SUMMARY:Child task for Caldav Test (parent changed)
|
||||
CREATED:20230301T073337Z
|
||||
LAST-MODIFIED:20230301T073337Z
|
||||
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-2
|
||||
RELATED-TO;RELTYPE=PARENT:` + newParentUID + `
|
||||
END:VTODO
|
||||
END:VCALENDAR`
|
||||
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
|
||||
require.NoError(t, err)
|
||||
task := tasks[0]
|
||||
storage := &VikunjaCaldavProjectStorage{
|
||||
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
|
||||
task: task,
|
||||
user: u,
|
||||
}
|
||||
|
||||
// Edit the task:
|
||||
taskResource, err := storage.UpdateResource(taskUID, taskContent)
|
||||
c, rec := newTestContext(http.MethodPut, taskURL, taskContent)
|
||||
c.Set("userBasicAuth", currentUser)
|
||||
c.SetParamNames("project", "task")
|
||||
c.SetParamValues(strconv.FormatInt(projectID, 10), taskUID+".ics")
|
||||
|
||||
err := UpsertTaskFromICS(c, currentUser, projectID, taskUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
// Check that the result CALDAV contains the new relation:
|
||||
content, _ := taskResource.GetContentData()
|
||||
fetchCtx, fetchRec := newTestContext(http.MethodGet, taskURL, "")
|
||||
fetchCtx.Set("userBasicAuth", currentUser)
|
||||
fetchCtx.SetParamNames("project", "task")
|
||||
fetchCtx.SetParamValues(strconv.FormatInt(projectID, 10), taskUID+".ics")
|
||||
err = FetchTaskAsICS(fetchCtx, currentUser, projectID, taskUID)
|
||||
require.NoError(t, err)
|
||||
content := fetchRec.Body.String()
|
||||
|
||||
assert.Contains(t, content, "UID:"+taskUID)
|
||||
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-2")
|
||||
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:"+newParentUID)
|
||||
assert.NotContains(t, content, "RELATED-TO;RELTYPE=PARENT:"+originalParentUID)
|
||||
|
||||
|
||||
// Get the task from the DB:
|
||||
tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u)
|
||||
tasksDB, err := models.GetTasksByUIDs(s, []string{taskUID}, currentUser)
|
||||
require.NoError(t, err)
|
||||
task = tasks[0]
|
||||
require.Len(t, tasksDB, 1)
|
||||
taskFromDB := tasksDB[0]
|
||||
|
||||
// Check that the parent-child relationship has changed to the new parent:
|
||||
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
|
||||
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
|
||||
assert.Equal(t, "uid-caldav-test-parent-task-2", parentTask.UID)
|
||||
assert.Len(t, taskFromDB.RelatedTasks[models.RelationKindParenttask], 1)
|
||||
parentTask := taskFromDB.RelatedTasks[models.RelationKindParenttask][0]
|
||||
assert.Equal(t, newParentUID, parentTask.UID)
|
||||
|
||||
// Get the previous parent from the DB and check that its previous child is gone:
|
||||
tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-parent-task"}, u)
|
||||
originalParentTasks, err := models.GetTasksByUIDs(s, []string{originalParentUID}, currentUser)
|
||||
require.NoError(t, err)
|
||||
task = tasks[0]
|
||||
assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 1)
|
||||
// We're gone, but our former sibling is still there:
|
||||
formerSiblingSubTask := task.RelatedTasks[models.RelationKindSubtask][0]
|
||||
require.Len(t, originalParentTasks, 1)
|
||||
originalParentFromDB := originalParentTasks[0]
|
||||
|
||||
foundOriginalChild := false
|
||||
for _, subTask := range originalParentFromDB.RelatedTasks[models.RelationKindSubtask] {
|
||||
if subTask.UID == taskUID {
|
||||
foundOriginalChild = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.False(t, foundOriginalChild, "Task should no longer be a child of the original parent")
|
||||
// Check that the sibling is still there
|
||||
assert.Len(t, originalParentFromDB.RelatedTasks[models.RelationKindSubtask], 1)
|
||||
formerSiblingSubTask := originalParentFromDB.RelatedTasks[models.RelationKindSubtask][0]
|
||||
assert.Equal(t, "uid-caldav-test-child-task-2", formerSiblingSubTask.UID)
|
||||
})
|
||||
|
||||
@@ -431,55 +538,72 @@ END:VCALENDAR`
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Edit the subtask:
|
||||
const taskUID = "uid-caldav-test-child-task"
|
||||
const taskContent = `BEGIN:VCALENDAR
|
||||
const projectID = 36
|
||||
const taskUID = "uid-caldav-test-child-task" // Exists in fixtures
|
||||
const originalParentUID = "uid-caldav-test-parent-task" // Exists in fixtures
|
||||
var taskURL = "/dav/projects/" + strconv.FormatInt(projectID, 10) + "/" + taskUID + ".ics"
|
||||
var taskContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
METHOD:PUBLISH
|
||||
X-PUBLISHED-TTL:PT4H
|
||||
X-WR-CALNAME:Project 36 for Caldav tests
|
||||
PRODID:-//Vikunja Todo App//EN
|
||||
BEGIN:VTODO
|
||||
UID:uid-caldav-test-child-task
|
||||
UID:` + taskUID + `
|
||||
DTSTAMP:20230301T073337Z
|
||||
SUMMARY:Child task for Caldav Test (edited)
|
||||
SUMMARY:Child task for Caldav Test (no parent)
|
||||
CREATED:20230301T073337Z
|
||||
LAST-MODIFIED:20230301T073337Z
|
||||
END:VTODO
|
||||
END:VCALENDAR`
|
||||
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
|
||||
require.NoError(t, err)
|
||||
task := tasks[0]
|
||||
storage := &VikunjaCaldavProjectStorage{
|
||||
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
|
||||
task: task,
|
||||
user: u,
|
||||
}
|
||||
|
||||
c, rec := newTestContext(http.MethodPut, taskURL, taskContent)
|
||||
c.Set("userBasicAuth", currentUser)
|
||||
c.SetParamNames("project", "task")
|
||||
c.SetParamValues(strconv.FormatInt(projectID, 10), taskUID+".ics")
|
||||
|
||||
// Edit the task:
|
||||
taskResource, err := storage.UpdateResource(taskUID, taskContent)
|
||||
err := UpsertTaskFromICS(c, currentUser, projectID, taskUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
// Check that the result CALDAV contains no parent relation
|
||||
fetchCtx, fetchRec := newTestContext(http.MethodGet, taskURL, "")
|
||||
fetchCtx.Set("userBasicAuth", currentUser)
|
||||
fetchCtx.SetParamNames("project", "task")
|
||||
fetchCtx.SetParamValues(strconv.FormatInt(projectID, 10), taskUID+".ics")
|
||||
err = FetchTaskAsICS(fetchCtx, currentUser, projectID, taskUID)
|
||||
require.NoError(t, err)
|
||||
content := fetchRec.Body.String()
|
||||
|
||||
// Check that the result CALDAV contains the new relation:
|
||||
content, _ := taskResource.GetContentData()
|
||||
assert.Contains(t, content, "UID:"+taskUID)
|
||||
assert.NotContains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task")
|
||||
assert.NotContains(t, content, "RELATED-TO;RELTYPE=PARENT")
|
||||
|
||||
// Get the task from the DB:
|
||||
tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u)
|
||||
tasksDB, err := models.GetTasksByUIDs(s, []string{taskUID}, currentUser)
|
||||
require.NoError(t, err)
|
||||
task = tasks[0]
|
||||
require.Len(t, tasksDB, 1)
|
||||
taskFromDB := tasksDB[0]
|
||||
|
||||
// Check that the parent-child relationship is gone:
|
||||
assert.Empty(t, task.RelatedTasks[models.RelationKindParenttask])
|
||||
assert.Empty(t, taskFromDB.RelatedTasks[models.RelationKindParenttask])
|
||||
|
||||
// Get the previous parent from the DB and check that its child is gone:
|
||||
tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-parent-task"}, u)
|
||||
originalParentTasks, err := models.GetTasksByUIDs(s, []string{originalParentUID}, currentUser)
|
||||
require.NoError(t, err)
|
||||
task = tasks[0]
|
||||
require.Len(t, originalParentTasks, 1)
|
||||
originalParentFromDB := originalParentTasks[0]
|
||||
|
||||
foundOriginalChild := false
|
||||
for _, subTask := range originalParentFromDB.RelatedTasks[models.RelationKindSubtask] {
|
||||
if subTask.UID == taskUID {
|
||||
foundOriginalChild = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.False(t, foundOriginalChild, "Task should no longer be a child of the original parent")
|
||||
// We're gone, but our former sibling is still there:
|
||||
assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 1)
|
||||
formerSiblingSubTask := task.RelatedTasks[models.RelationKindSubtask][0]
|
||||
assert.Len(t, originalParentFromDB.RelatedTasks[models.RelationKindSubtask], 1)
|
||||
formerSiblingSubTask := originalParentFromDB.RelatedTasks[models.RelationKindSubtask][0]
|
||||
assert.Equal(t, "uid-caldav-test-child-task-2", formerSiblingSubTask.UID)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user