- Root handler now only serves the welcome page for GET/HEAD; all other methods (e.g. OPTIONS, PROPFIND) return 405 with an Allow header instead of always returning 200, fixing client capability probes and PROPFIND misbehavior. - Mount /files/ properly and cache one xwebdav.Handler per authenticated user so its LockSystem persists across requests instead of being recreated per-request (which broke LOCK/UNLOCK). - Remove the username segment from all DAV URLs (/cal/, /card/, /files/ are now identical for every account; the acting user is always resolved via Basic Auth, never the path). - Reintroduce a fixed literal "home" path segment (/cal/home/, /card/home/) to preserve the URL segment depth that go-webdav's caldav/carddav server relies on to classify resources (principal vs. home-set vs. collection vs. object). Removing the username had collapsed this depth, silently misclassifying requests and returning empty <multistatus> responses (DAVx5 "no resources found"). - Replace the store's single global mutex with per-user sharded locks so different users' requests no longer serialize against each other. - Add auth.NewContext test helper, WebDAV handler tests (per-user isolation, lock persistence across requests), and a concurrent multi-user store test. - Update README and copilot-instructions to document the new URL scheme and the go-webdav path-depth classification quirk. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
134 lines
3.2 KiB
Go
134 lines
3.2 KiB
Go
package store_test
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/yourusername/caldav-server/internal/store"
|
|
)
|
|
|
|
func TestStoreRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, err := store.NewStore(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
|
|
const (
|
|
user = "alice"
|
|
col = "cal-personal"
|
|
objectID = "event-001.ics"
|
|
)
|
|
|
|
data := []byte("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n")
|
|
|
|
// Write
|
|
if err := st.PutObject(user, col, objectID, data); err != nil {
|
|
t.Fatalf("PutObject: %v", err)
|
|
}
|
|
|
|
// Read
|
|
got, err := st.GetObject(user, col, objectID)
|
|
if err != nil {
|
|
t.Fatalf("GetObject: %v", err)
|
|
}
|
|
if string(got) != string(data) {
|
|
t.Errorf("data mismatch: got %q, want %q", got, data)
|
|
}
|
|
|
|
// List
|
|
ids, err := st.ListObjects(user, col)
|
|
if err != nil {
|
|
t.Fatalf("ListObjects: %v", err)
|
|
}
|
|
if len(ids) != 1 || ids[0] != objectID {
|
|
t.Errorf("unexpected ids: %v", ids)
|
|
}
|
|
|
|
// Delete
|
|
if err := st.DeleteObject(user, col, objectID); err != nil {
|
|
t.Fatalf("DeleteObject: %v", err)
|
|
}
|
|
|
|
// Not found
|
|
_, err = st.GetObject(user, col, objectID)
|
|
if err != store.ErrNotFound {
|
|
t.Errorf("expected ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSanitizePath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, err := store.NewStore(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
|
|
// Traversal attempts should not escape rootDir
|
|
dangerous := "../../../etc/passwd"
|
|
data := []byte("test")
|
|
if err := st.PutObject("user", "col", dangerous, data); err != nil {
|
|
t.Fatalf("PutObject with dangerous ID: %v", err)
|
|
}
|
|
|
|
// Verify file was NOT written outside rootDir
|
|
_, err = os.Stat("/etc/passwd.tmp")
|
|
if err == nil {
|
|
t.Fatal("path traversal succeeded — security issue!")
|
|
}
|
|
}
|
|
|
|
// TestConcurrentMultiUserAccess exercises the store from several users
|
|
// concurrently to make sure the per-user locking not only avoids data races
|
|
// (checked by -race) but also doesn't serialize unrelated users' operations
|
|
// incorrectly (e.g. deadlocks or cross-user data corruption).
|
|
func TestConcurrentMultiUserAccess(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, err := store.NewStore(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
|
|
const users = 8
|
|
const objectsPerUser = 20
|
|
|
|
var wg sync.WaitGroup
|
|
for u := 0; u < users; u++ {
|
|
user := fmt.Sprintf("user%d", u)
|
|
wg.Add(1)
|
|
go func(user string) {
|
|
defer wg.Done()
|
|
for i := 0; i < objectsPerUser; i++ {
|
|
id := fmt.Sprintf("obj-%d.ics", i)
|
|
data := []byte(fmt.Sprintf("DATA-%s-%d", user, i))
|
|
if err := st.PutObject(user, "cal-personal", id, data); err != nil {
|
|
t.Errorf("PutObject(%s, %d): %v", user, i, err)
|
|
return
|
|
}
|
|
got, err := st.GetObject(user, "cal-personal", id)
|
|
if err != nil {
|
|
t.Errorf("GetObject(%s, %d): %v", user, i, err)
|
|
return
|
|
}
|
|
if string(got) != string(data) {
|
|
t.Errorf("cross-user data corruption for %s obj %d: got %q want %q", user, i, got, data)
|
|
}
|
|
}
|
|
}(user)
|
|
}
|
|
wg.Wait()
|
|
|
|
for u := 0; u < users; u++ {
|
|
user := fmt.Sprintf("user%d", u)
|
|
ids, err := st.ListObjects(user, "cal-personal")
|
|
if err != nil {
|
|
t.Fatalf("ListObjects(%s): %v", user, err)
|
|
}
|
|
if len(ids) != objectsPerUser {
|
|
t.Errorf("user %s: expected %d objects, got %d", user, objectsPerUser, len(ids))
|
|
}
|
|
}
|
|
}
|