caldav package:
- Todo carries URL, ETag, Raw so ListTodos rows can be PUT/DELETEd in place
- BuildVTodoICS for new VTODOs, ApplyVTodoEdit for in-place edits that
preserve unknown properties (DESCRIPTION, CATEGORIES, X-*)
- PutTodo/DeleteTodo with If-Match optimistic concurrency
- ErrPreconditionFailed/ErrNotFound for 412/404
- RFC 5545 fold-at-75 + CRLF + text escape, hand-rolled UUID v4
- httptest round-trip (create -> list -> complete -> delete) plus 412 path
web:
- POST /i/{path}/caldav/todo/{complete,reopen,edit,delete,todo-create}
- Re-fetches the live ETag before each PUT/DELETE so ordinary use never
trips 412; on actual 412 the section reloads with a banner
- Calendar URL must already be linked to the item (anti-forgery guard)
- tasks_section partial drives both the initial page render and HTMX
swaps; detail.tmpl reduces to a one-liner template call
docs/design.md §5: rewrite for full read/write semantics + ETag concurrency.
441 lines
13 KiB
Go
441 lines
13 KiB
Go
package caldav
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// newFakeServer stubs the slice of CalDAV that the client exercises. Each
|
|
// handler asserts the method and returns a canned XML body so the parser can
|
|
// be exercised end-to-end without a real DAV server.
|
|
func newFakeServer(t *testing.T) (*Client, *httptest.Server) {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/dav/calendars/m/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "PROPFIND" {
|
|
t.Errorf("unexpected method %q on collection", r.Method)
|
|
}
|
|
w.WriteHeader(207)
|
|
_, _ = io.WriteString(w, propfindBody)
|
|
})
|
|
|
|
mux.HandleFunc("/dav/calendars/m/Work/", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case "REPORT":
|
|
w.WriteHeader(207)
|
|
_, _ = io.WriteString(w, reportBody)
|
|
case "MKCALENDAR":
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
default:
|
|
t.Errorf("unexpected method %q on Work/", r.Method)
|
|
}
|
|
})
|
|
|
|
mux.HandleFunc("/dav/calendars/m/new-list/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "MKCALENDAR" {
|
|
t.Errorf("unexpected method %q on new-list/", r.Method)
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
if !strings.Contains(string(body), "<d:displayname>Paliad</d:displayname>") {
|
|
t.Errorf("MKCALENDAR body missing display name: %s", body)
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
})
|
|
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
return New(srv.URL+"/dav/calendars/m/", "u", "p"), srv
|
|
}
|
|
|
|
func TestListCalendars(t *testing.T) {
|
|
c, _ := newFakeServer(t)
|
|
cals, err := c.ListCalendars(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListCalendars: %v", err)
|
|
}
|
|
want := map[string]bool{"mCalendar": true, "Birthdays": true, "Work": true}
|
|
got := map[string]bool{}
|
|
for _, cal := range cals {
|
|
got[cal.DisplayName] = true
|
|
}
|
|
for k := range want {
|
|
if !got[k] {
|
|
t.Errorf("expected calendar %q in result, got %v", k, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListTodos(t *testing.T) {
|
|
c, _ := newFakeServer(t)
|
|
todos, err := c.ListTodos(context.Background(), c.BaseURL+"Work/")
|
|
if err != nil {
|
|
t.Fatalf("ListTodos: %v", err)
|
|
}
|
|
if len(todos) != 2 {
|
|
t.Fatalf("expected 2 todos, got %d (%v)", len(todos), todos)
|
|
}
|
|
if todos[0].UID != "todo-1@example" {
|
|
t.Errorf("todos[0].UID = %q", todos[0].UID)
|
|
}
|
|
if todos[0].Summary != "Pick up bread" {
|
|
t.Errorf("todos[0].Summary = %q", todos[0].Summary)
|
|
}
|
|
if todos[0].Status != "NEEDS-ACTION" {
|
|
t.Errorf("todos[0].Status = %q", todos[0].Status)
|
|
}
|
|
if todos[0].ETag != `"abc"` {
|
|
t.Errorf("todos[0].ETag = %q, want \"abc\"", todos[0].ETag)
|
|
}
|
|
if !strings.HasSuffix(todos[0].URL, "/dav/calendars/m/Work/todo-1.ics") {
|
|
t.Errorf("todos[0].URL = %q, want suffix /dav/calendars/m/Work/todo-1.ics", todos[0].URL)
|
|
}
|
|
if !strings.Contains(todos[0].Raw, "BEGIN:VTODO") {
|
|
t.Errorf("todos[0].Raw missing BEGIN:VTODO: %q", todos[0].Raw)
|
|
}
|
|
if todos[1].Status != "COMPLETED" {
|
|
t.Errorf("todos[1].Status = %q", todos[1].Status)
|
|
}
|
|
if todos[1].ETag != `"def"` {
|
|
t.Errorf("todos[1].ETag = %q", todos[1].ETag)
|
|
}
|
|
}
|
|
|
|
func TestCreateCalendar(t *testing.T) {
|
|
c, _ := newFakeServer(t)
|
|
if err := c.CreateCalendar(context.Background(), c.BaseURL+"new-list/", "Paliad", "#bff355"); err != nil {
|
|
t.Fatalf("CreateCalendar: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateCalendarExists(t *testing.T) {
|
|
c, _ := newFakeServer(t)
|
|
err := c.CreateCalendar(context.Background(), c.BaseURL+"Work/", "Work", "")
|
|
if err != ErrCalendarExists {
|
|
t.Fatalf("expected ErrCalendarExists, got %v", err)
|
|
}
|
|
}
|
|
|
|
// fakeCalendarStore is an in-memory calendar collection that implements just
|
|
// enough CalDAV for round-trip tests: REPORT for listing, PUT for create /
|
|
// edit (with If-Match), DELETE for removal (with If-Match). ETags are
|
|
// per-resource and bump on every write.
|
|
type fakeCalendarStore struct {
|
|
mu sync.Mutex
|
|
rows map[string]*fakeResource // path → resource
|
|
calBase string // server-relative path prefix, e.g. /dav/calendars/m/Work/
|
|
}
|
|
|
|
type fakeResource struct {
|
|
ics string
|
|
etag string
|
|
}
|
|
|
|
func (s *fakeCalendarStore) report() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var b strings.Builder
|
|
b.WriteString(`<?xml version="1.0"?>` + "\n")
|
|
b.WriteString(`<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
|
|
for href, r := range s.rows {
|
|
b.WriteString(fmt.Sprintf(`<d:response><d:href>%s</d:href><d:propstat><d:prop><d:getetag>%s</d:getetag><cal:calendar-data>%s</cal:calendar-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`, href, r.etag, r.ics))
|
|
}
|
|
b.WriteString(`</d:multistatus>`)
|
|
return b.String()
|
|
}
|
|
|
|
func (s *fakeCalendarStore) handler(t *testing.T) http.HandlerFunc {
|
|
t.Helper()
|
|
var nextETag int
|
|
bump := func() string {
|
|
nextETag++
|
|
return fmt.Sprintf(`"etag-%d"`, nextETag)
|
|
}
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case "REPORT":
|
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
|
w.WriteHeader(207)
|
|
_, _ = io.WriteString(w, s.report())
|
|
case "PUT":
|
|
body, _ := io.ReadAll(r.Body)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
ifMatch := r.Header.Get("If-Match")
|
|
ifNone := r.Header.Get("If-None-Match")
|
|
existing, ok := s.rows[r.URL.Path]
|
|
if ifNone == "*" && ok {
|
|
w.WriteHeader(http.StatusPreconditionFailed)
|
|
return
|
|
}
|
|
if ifMatch != "" && (!ok || existing.etag != ifMatch) {
|
|
w.WriteHeader(http.StatusPreconditionFailed)
|
|
return
|
|
}
|
|
tag := bump()
|
|
s.rows[r.URL.Path] = &fakeResource{ics: string(body), etag: tag}
|
|
w.Header().Set("ETag", tag)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusCreated)
|
|
} else {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
case "DELETE":
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
ifMatch := r.Header.Get("If-Match")
|
|
existing, ok := s.rows[r.URL.Path]
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
if ifMatch != "" && existing.etag != ifMatch {
|
|
w.WriteHeader(http.StatusPreconditionFailed)
|
|
return
|
|
}
|
|
delete(s.rows, r.URL.Path)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
t.Errorf("unexpected method %q on %s", r.Method, r.URL.Path)
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
}
|
|
|
|
func newRoundTripServer(t *testing.T) (*Client, *fakeCalendarStore) {
|
|
t.Helper()
|
|
store := &fakeCalendarStore{rows: map[string]*fakeResource{}, calBase: "/dav/calendars/m/RT/"}
|
|
srv := httptest.NewServer(store.handler(t))
|
|
t.Cleanup(srv.Close)
|
|
c := New(srv.URL+"/dav/calendars/m/", "u", "p")
|
|
return c, store
|
|
}
|
|
|
|
func TestRoundTripCreateListCompleteDelete(t *testing.T) {
|
|
c, _ := newRoundTripServer(t)
|
|
calURL := c.BaseURL + "RT/"
|
|
uid := NewUID()
|
|
url := TodoURLFor(calURL, uid)
|
|
|
|
// Create
|
|
summary := "Buy onions"
|
|
ics := BuildVTodoICS(uid, VTodoEdit{Summary: &summary})
|
|
etag, err := c.PutTodo(context.Background(), url, ics, "", "*")
|
|
if err != nil {
|
|
t.Fatalf("PUT create: %v", err)
|
|
}
|
|
if etag == "" {
|
|
t.Fatal("PUT create: empty new ETag")
|
|
}
|
|
|
|
// List → must contain the new UID
|
|
todos, err := c.ListTodos(context.Background(), calURL)
|
|
if err != nil {
|
|
t.Fatalf("ListTodos: %v", err)
|
|
}
|
|
if len(todos) != 1 {
|
|
t.Fatalf("expected 1 todo after create, got %d", len(todos))
|
|
}
|
|
got := todos[0]
|
|
if got.UID != uid {
|
|
t.Errorf("UID = %q, want %q", got.UID, uid)
|
|
}
|
|
if got.Summary != summary {
|
|
t.Errorf("Summary = %q, want %q", got.Summary, summary)
|
|
}
|
|
if got.Status != "NEEDS-ACTION" {
|
|
t.Errorf("Status = %q, want NEEDS-ACTION", got.Status)
|
|
}
|
|
if got.ETag == "" {
|
|
t.Error("listed Todo missing ETag")
|
|
}
|
|
|
|
// Complete in-place — preserve unknown fields by edit, not rebuild.
|
|
completed := "COMPLETED"
|
|
updated := ApplyVTodoEdit(got.Raw, VTodoEdit{Status: &completed})
|
|
if !strings.Contains(updated, "STATUS:COMPLETED") {
|
|
t.Fatalf("edited ICS missing STATUS:COMPLETED:\n%s", updated)
|
|
}
|
|
if !strings.Contains(updated, "COMPLETED:") {
|
|
t.Fatalf("edited ICS missing COMPLETED line:\n%s", updated)
|
|
}
|
|
newETag, err := c.PutTodo(context.Background(), got.URL, updated, got.ETag, "")
|
|
if err != nil {
|
|
t.Fatalf("PUT complete: %v", err)
|
|
}
|
|
if newETag == got.ETag {
|
|
t.Errorf("ETag did not bump on complete (still %q)", newETag)
|
|
}
|
|
|
|
// Re-list — status should reflect.
|
|
todos, err = c.ListTodos(context.Background(), calURL)
|
|
if err != nil {
|
|
t.Fatalf("ListTodos post-complete: %v", err)
|
|
}
|
|
if len(todos) != 1 || todos[0].Status != "COMPLETED" {
|
|
t.Fatalf("expected single COMPLETED todo, got %+v", todos)
|
|
}
|
|
|
|
// Stale ETag on edit must yield ErrPreconditionFailed.
|
|
if _, err := c.PutTodo(context.Background(), got.URL, updated, "stale-etag", ""); err != ErrPreconditionFailed {
|
|
t.Errorf("expected ErrPreconditionFailed on stale ETag, got %v", err)
|
|
}
|
|
|
|
// Delete with current ETag.
|
|
if err := c.DeleteTodo(context.Background(), todos[0].URL, todos[0].ETag); err != nil {
|
|
t.Fatalf("DELETE: %v", err)
|
|
}
|
|
|
|
todos, err = c.ListTodos(context.Background(), calURL)
|
|
if err != nil {
|
|
t.Fatalf("ListTodos post-delete: %v", err)
|
|
}
|
|
if len(todos) != 0 {
|
|
t.Fatalf("expected empty list after delete, got %d", len(todos))
|
|
}
|
|
}
|
|
|
|
func TestApplyVTodoEditPreservesUnknown(t *testing.T) {
|
|
raw := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:keep-me\r\nSUMMARY:Old\r\nSTATUS:NEEDS-ACTION\r\nDESCRIPTION:Long context that must not vanish\r\nCATEGORIES:home,errands\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
|
newSummary := "New"
|
|
got := ApplyVTodoEdit(raw, VTodoEdit{Summary: &newSummary})
|
|
if !strings.Contains(got, "SUMMARY:New") {
|
|
t.Errorf("expected SUMMARY:New, got:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "SUMMARY:Old") {
|
|
t.Errorf("old SUMMARY still present:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "DESCRIPTION:Long context") {
|
|
t.Errorf("DESCRIPTION dropped during edit:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "CATEGORIES:home,errands") {
|
|
t.Errorf("CATEGORIES dropped during edit:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "UID:keep-me") {
|
|
t.Errorf("UID dropped during edit:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildVTodoICSFolding(t *testing.T) {
|
|
long := strings.Repeat("ABCDEFGH", 20) // 160 chars — must fold across multiple lines.
|
|
ics := BuildVTodoICS("uid-fold", VTodoEdit{Summary: &long})
|
|
// Every physical line must be ≤ 75 octets after fold.
|
|
for _, ln := range strings.Split(ics, "\r\n") {
|
|
if len(ln) > 75 {
|
|
t.Errorf("line exceeds 75 octets after fold (%d): %q", len(ln), ln)
|
|
}
|
|
}
|
|
// Unfolding should recover the original SUMMARY value.
|
|
parsed := parseVTodos(ics)
|
|
if len(parsed) != 1 || parsed[0].Summary != long {
|
|
t.Fatalf("round-trip SUMMARY mismatch, got %+v", parsed)
|
|
}
|
|
}
|
|
|
|
func TestEscapeText(t *testing.T) {
|
|
in := "Pick up, eggs; cheese\\bread\nnext line"
|
|
got := escapeText(in)
|
|
want := `Pick up\, eggs\; cheese\\bread\nnext line`
|
|
if got != want {
|
|
t.Errorf("escapeText(%q) = %q, want %q", in, got, want)
|
|
}
|
|
if back := unescapeText(got); back != in {
|
|
t.Errorf("unescape round-trip: %q != %q", back, in)
|
|
}
|
|
}
|
|
|
|
const propfindBody = `<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/default/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>mCalendar</d:displayname>
|
|
<d:resourcetype><d:collection/><cal:calendar/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/birthday-calendar/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>Birthdays</d:displayname>
|
|
<d:resourcetype><d:collection/><cal:calendar/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/Work/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>Work</d:displayname>
|
|
<d:resourcetype><d:collection/><cal:calendar/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/inbox/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>inbox</d:displayname>
|
|
<d:resourcetype><d:collection/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>`
|
|
|
|
const reportBody = `<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/Work/todo-1.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:getetag>"abc"</d:getetag>
|
|
<cal:calendar-data>BEGIN:VCALENDAR
|
|
BEGIN:VTODO
|
|
UID:todo-1@example
|
|
SUMMARY:Pick up bread
|
|
STATUS:NEEDS-ACTION
|
|
PRIORITY:5
|
|
DUE:20260601T120000Z
|
|
END:VTODO
|
|
END:VCALENDAR</cal:calendar-data>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/Work/todo-2.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:getetag>"def"</d:getetag>
|
|
<cal:calendar-data>BEGIN:VCALENDAR
|
|
BEGIN:VTODO
|
|
UID:todo-2@example
|
|
SUMMARY:Filed paperwork
|
|
STATUS:COMPLETED
|
|
END:VTODO
|
|
END:VCALENDAR</cal:calendar-data>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>`
|