282 lines
9.3 KiB
Go
282 lines
9.3 KiB
Go
package caldav
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.arnef.de/arnef/nidus/internal/auth"
|
|
"git.arnef.de/arnef/nidus/internal/config"
|
|
"git.arnef.de/arnef/nidus/internal/db"
|
|
"git.arnef.de/arnef/nidus/internal/icssub"
|
|
"git.arnef.de/arnef/nidus/internal/store"
|
|
ical "github.com/emersion/go-ical"
|
|
)
|
|
|
|
// icsSample is a minimal valid ICS feed with one VEVENT.
|
|
const icsSample = "BEGIN:VCALENDAR\r\n" +
|
|
"VERSION:2.0\r\n" +
|
|
"PRODID:-//nidus//test//EN\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:shared1@nidus.test\r\n" +
|
|
"DTSTAMP:20260101T000000Z\r\n" +
|
|
"DTSTART:20260805T090000Z\r\n" +
|
|
"DTEND:20260805T100000Z\r\n" +
|
|
"SUMMARY:Shared event\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
|
|
// mustCalEvent parses raw and returns its first VEVENT (panic on error;
|
|
// safe in tests).
|
|
func mustCalEvent(t *testing.T, raw string) ical.Event {
|
|
t.Helper()
|
|
_ = t
|
|
cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
|
|
if err != nil {
|
|
panic("mustCalEvent: " + err.Error())
|
|
}
|
|
evs := cal.Events()
|
|
if len(evs) == 0 {
|
|
panic("mustCalEvent: no events")
|
|
}
|
|
return evs[0]
|
|
}
|
|
|
|
// icssubSeriesID returns the stable ID the CalDAV backend advertises for the
|
|
// (single) series in raw. All tests here use single-series feeds, so this is
|
|
// just GroupSeries(raw)[0].ID().
|
|
func icssubSeriesID(t *testing.T, raw string) string {
|
|
t.Helper()
|
|
_ = t
|
|
cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
|
|
if err != nil {
|
|
panic("icssubSeriesID: " + err.Error())
|
|
}
|
|
series := icssub.GroupSeries(cal)
|
|
if len(series) == 0 {
|
|
panic("icssubSeriesID: no series")
|
|
}
|
|
return series[0].ID()
|
|
}
|
|
|
|
// TestICSSubscriptionListAndGetObject verifies the full CalDAV read path
|
|
// for an ICS subscription: ListCalendarObjects returns one synthetic
|
|
// stand-alone object whose Path is derived from icssub.EventID (not from
|
|
// the event's position), and the same object is returned via
|
|
// GetCalendarObject by the same Path.
|
|
func TestICSSubscriptionListAndGetObject(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(icsSample))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
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)
|
|
}
|
|
defer dbase.Close()
|
|
|
|
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil {
|
|
t.Fatalf("CreateICSSubscription: %v", err)
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
b := NewBackend(&config.Config{}, st, dbase, logger, icssub.NewCache(time.Hour))
|
|
|
|
ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})
|
|
|
|
objs, err := b.ListCalendarObjects(ctx, "/cal/home/holidays/", nil)
|
|
if err != nil {
|
|
t.Fatalf("ListCalendarObjects: %v", err)
|
|
}
|
|
if len(objs) != 1 {
|
|
t.Fatalf("expected 1 object, got %d", len(objs))
|
|
}
|
|
|
|
// The Path must be derived from the event's stable identity (its UID) —
|
|
// i.e. the series ID — not from the event's position in the feed.
|
|
wantID := icssubSeriesID(t, icsSample)
|
|
wantPath := calObjectPath("holidays", wantID)
|
|
if objs[0].Path != wantPath {
|
|
t.Fatalf("Path = %q, want %q (series-ID-based, not position-based)", objs[0].Path, wantPath)
|
|
}
|
|
|
|
// GetCalendarObject by the same Path should return an object that has
|
|
// a valid VEVENT whose UID matches our source ICS (round-trip
|
|
// correctness).
|
|
found, err := b.GetCalendarObject(ctx, objs[0].Path, nil)
|
|
if err != nil {
|
|
t.Fatalf("GetCalendarObject: %v", err)
|
|
}
|
|
if found.Path != objs[0].Path {
|
|
t.Fatalf("round-trip path mismatch: %q vs %q", found.Path, objs[0].Path)
|
|
}
|
|
if found.Data == nil || len(found.Data.Events()) == 0 {
|
|
t.Fatalf("expected found.Data to have >=1 event, got %+v", found.Data)
|
|
}
|
|
uid := found.Data.Events()[0].Props.Get(ical.PropUID)
|
|
if uid == nil || uid.Value != "shared1@nidus.test" {
|
|
t.Fatalf("expected UID shared1@nidus.test in round-tripped event, got %+v", uid)
|
|
}
|
|
}
|
|
|
|
// TestIcssubCacheSharedBetweenConsumersInCalDav verifies that one
|
|
// *icssub.Cache shared by two different callers of the same backend does
|
|
// not re-fetch the upstream twice (the second caller sees the cached
|
|
// copy via the singleflight guard), proving the "return cached value and
|
|
// update in the background" design is reachable from the CalDAV API.
|
|
func TestIcssubCacheSharedBetweenConsumersInCalDav(t *testing.T) {
|
|
var hits atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
// Sleep long enough that *if* the second Get blocked, this
|
|
// test's wall clock would obviously exceed the bound below.
|
|
time.Sleep(30 * time.Millisecond)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(icsSample))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
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)
|
|
}
|
|
defer dbase.Close()
|
|
|
|
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil {
|
|
t.Fatalf("CreateICSSubscription: %v", err)
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
// One cache, shared by two backends on the same DB — mirrors the
|
|
// production wiring (cmd/server/main.go) where the CalDAV and web UI
|
|
// share one instance.
|
|
shared := icssub.NewCache(time.Hour)
|
|
b1 := NewBackend(&config.Config{}, st, dbase, logger, shared)
|
|
b2 := NewBackend(&config.Config{}, st, dbase, logger, shared)
|
|
|
|
ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})
|
|
|
|
if _, err := b1.ListCalendarObjects(ctx, "/cal/home/holidays/", nil); err != nil {
|
|
t.Fatalf("backend 1 list: %v", err)
|
|
}
|
|
after1 := hits.Load()
|
|
if _, err := b2.ListCalendarObjects(ctx, "/cal/home/holidays/", nil); err != nil {
|
|
t.Fatalf("backend 2 list: %v", err)
|
|
}
|
|
after2 := hits.Load()
|
|
if after2 > after1+1 {
|
|
t.Fatalf("expected shared cache to coalesce (hits %d → %d)", after1, after2)
|
|
}
|
|
}
|
|
|
|
// TestIcssubCacheStaleReturnsWithBackgroundRefresh verifies the
|
|
// stale-while-revalidate path through the public Cache API: a cached
|
|
// entry past its TTL is still returned immediately (never blocking the
|
|
// caller for the slow 30 ms fetch), while a background refresh updates
|
|
// the entry for the next Get.
|
|
func TestIcssubCacheStaleReturnsWithBackgroundRefresh(t *testing.T) {
|
|
var hits atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
time.Sleep(50 * time.Millisecond)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(icsSample))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := icssub.NewCache(2 * time.Millisecond) // very short TTL
|
|
if _, err := c.Get(srv.URL); err != nil {
|
|
t.Fatalf("first Get: %v", err)
|
|
}
|
|
firstHits := hits.Load()
|
|
|
|
time.Sleep(5 * time.Millisecond) // force staleness
|
|
|
|
st := time.Now()
|
|
if _, err := c.Get(srv.URL); err != nil {
|
|
t.Fatalf("stale Get: %v", err)
|
|
}
|
|
if time.Since(st) > 40*time.Millisecond {
|
|
t.Fatalf("stale Get blocked on the network for %v — should have returned the cached copy", time.Since(st))
|
|
}
|
|
|
|
// Wait for the background refresh to land.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for hits.Load() == firstHits && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
if hits.Load() == firstHits {
|
|
t.Fatalf("background refresh did not fire (hits stayed at %d)", firstHits)
|
|
}
|
|
}
|
|
|
|
// TestICSSubscriptionEventIDMatchesEventInCalDav proves that the object
|
|
// path the CalDAV backend advertises (in ListCalendarObjects) is exactly
|
|
// the same string the EventDetailPage-style lookup would use on the web
|
|
// side — i.e. both surfaces agree on what icssub.EventID(ev) produces.
|
|
func TestICSSubscriptionEventIDMatchesEventInCalDav(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(icsSample))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
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)
|
|
}
|
|
defer dbase.Close()
|
|
|
|
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil {
|
|
t.Fatalf("CreateICSSubscription: %v", err)
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
b := NewBackend(&config.Config{}, st, dbase, logger, icssub.NewCache(time.Hour))
|
|
ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})
|
|
|
|
objs, err := b.ListCalendarObjects(ctx, "/cal/home/holidays/", nil)
|
|
if err != nil || len(objs) == 0 {
|
|
t.Fatalf("list: objs=%d err=%v", len(objs), err)
|
|
}
|
|
gotPath := objs[0].Path
|
|
wantID := icssubSeriesID(t, icsSample)
|
|
wantPath := calObjectPath("holidays", wantID)
|
|
if gotPath != wantPath {
|
|
t.Fatalf("CalDAV object path %q does not match web-side EventID-derived %q", gotPath, wantPath)
|
|
}
|
|
}
|