Fix WebDAV/CalDAV/CardDAV bugs, drop username from URLs, harden concurrency

- 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>
This commit is contained in:
2026-08-18 12:49:57 +02:00
co-authored by Copilot
parent 7a11b5bbbf
commit b4644bc590
11 changed files with 465 additions and 100 deletions
+7
View File
@@ -95,6 +95,13 @@ func FromContext(ctx context.Context) *Principal {
return p
}
// NewContext returns a copy of ctx carrying p, retrievable via FromContext.
// This is primarily useful for tests of downstream packages that need an
// authenticated context without going through the Basic Auth handshake.
func NewContext(ctx context.Context, p *Principal) context.Context {
return context.WithValue(ctx, userContextKey, p)
}
var errUnauthorized = &authError{msg: "invalid credentials"}
type authError struct{ msg string }
+31 -16
View File
@@ -38,19 +38,23 @@ func NewHandler(cfg *config.Config, st *store.Store, logger *slog.Logger) http.H
// -------- caldav.Backend interface --------
func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
p := auth.FromContext(ctx)
if p == nil {
if auth.FromContext(ctx) == nil {
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
return principalPath(p.Username), nil
// Must resolve to a path served by this same handler (i.e. under /cal/)
// at exactly one path segment of depth, since go-webdav's caldav server
// classifies resources purely by path depth relative to Handler.Prefix:
// depth 1 = principal, depth 2 = home-set, depth 3 = calendar, depth 4 =
// calendar object. A /principals/<user>/ path would never be reached
// (nothing is mounted there) and would break discovery.
return calPrincipalPath(), nil
}
func (b *Backend) CalendarHomeSetPath(ctx context.Context) (string, error) {
p := auth.FromContext(ctx)
if p == nil {
if auth.FromContext(ctx) == nil {
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
return calHomePath(p.Username), nil
return calHomePath(), nil
}
func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
@@ -132,7 +136,7 @@ func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *
if err != nil {
continue
}
obj, err := b.decodeObject(calObjectPath(user, calName, id), data)
obj, err := b.decodeObject(calObjectPath(calName, id), data)
if err != nil {
b.logger.Warn("decoding calendar object", "id", id, "error", err)
continue
@@ -204,7 +208,7 @@ func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) erro
func (b *Backend) calendarMeta(user, name string) caldav.Calendar {
return caldav.Calendar{
Path: calHomePath(user) + name + "/",
Path: calHomePath() + name + "/",
Name: name,
Description: fmt.Sprintf("%s's %s calendar", user, name),
SupportedComponentSet: []string{"VEVENT", "VTODO", "VJOURNAL"},
@@ -236,7 +240,7 @@ func (b *Backend) parseCalPath(ctx context.Context, calPath string) (user, calNa
}
user = p.Username
parts := strings.Split(strings.Trim(calPath, "/"), "/")
// expected: cal/<user>/<calname>/
// expected: cal/home/<calname>/
if len(parts) < 3 {
return "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid calendar path"))
}
@@ -251,7 +255,7 @@ func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, calNa
}
user = p.Username
parts := strings.Split(strings.Trim(objPath, "/"), "/")
// expected: cal/<user>/<calname>/<objid>
// expected: cal/home/<calname>/<objid>
if len(parts) < 4 {
return "", "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid object path"))
}
@@ -260,16 +264,27 @@ func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, calNa
return user, calName, objID, nil
}
func principalPath(user string) string {
return fmt.Sprintf("/principals/%s/", user)
// calPrincipalPath, calHomePath, and calObjectPath are the same for every
// user: authorization is resolved from the Basic Auth identity, not from
// the URL, so no username segment is needed in the path.
//
// The fixed "home" segment (in place of a username) is required, not
// cosmetic: go-webdav's caldav server classifies a request purely by how
// many path segments it has relative to the handler's mount point — 1
// segment is treated as the principal, 2 as the calendar-home-set, 3 as a
// calendar, 4 as a calendar object. Removing that segment entirely would
// make the home-set and calendar paths misclassified as principal/home-set
// respectively, breaking discovery (empty PROPFIND responses).
func calPrincipalPath() string {
return "/cal/"
}
func calHomePath(user string) string {
return fmt.Sprintf("/cal/%s/", user)
func calHomePath() string {
return "/cal/home/"
}
func calObjectPath(user, calName, objID string) string {
return fmt.Sprintf("/cal/%s/%s/%s", user, calName, objID)
func calObjectPath(calName, objID string) string {
return fmt.Sprintf("/cal/home/%s/%s", calName, objID)
}
func hashBytes(data []byte) uint64 {
+28 -16
View File
@@ -38,19 +38,19 @@ func NewHandler(cfg *config.Config, st *store.Store, logger *slog.Logger) http.H
// -------- carddav.Backend interface --------
func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
p := auth.FromContext(ctx)
if p == nil {
if auth.FromContext(ctx) == nil {
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
return principalPath(p.Username), nil
// Must resolve to a path served by this same handler (i.e. under
// /card/) at exactly one path segment of depth — see cardPrincipalPath.
return cardPrincipalPath(), nil
}
func (b *Backend) AddressBookHomeSetPath(ctx context.Context) (string, error) {
p := auth.FromContext(ctx)
if p == nil {
if auth.FromContext(ctx) == nil {
return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
return cardHomePath(p.Username), nil
return cardHomePath(), nil
}
func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, error) {
@@ -132,7 +132,7 @@ func (b *Backend) ListAddressObjects(ctx context.Context, bookPath string, req *
if err != nil {
continue
}
obj, err := b.decodeObject(cardObjectPath(user, bookName, id), data)
obj, err := b.decodeObject(cardObjectPath(bookName, id), data)
if err != nil {
b.logger.Warn("decoding vcard object", "id", id, "error", err)
continue
@@ -202,7 +202,7 @@ func (b *Backend) DeleteAddressObject(ctx context.Context, objPath string) error
func (b *Backend) bookMeta(user, name string) carddav.AddressBook {
return carddav.AddressBook{
Path: cardHomePath(user) + name + "/",
Path: cardHomePath() + name + "/",
Name: name,
Description: fmt.Sprintf("%s's %s address book", user, name),
MaxResourceSize: 10 * 1024 * 1024,
@@ -234,7 +234,7 @@ func (b *Backend) parseBookPath(ctx context.Context, bookPath string) (user, boo
}
user = p.Username
parts := strings.Split(strings.Trim(bookPath, "/"), "/")
// expected: card/<user>/<bookname>/
// expected: card/home/<bookname>/
if len(parts) < 3 {
return "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid address book path"))
}
@@ -249,7 +249,7 @@ func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, bookN
}
user = p.Username
parts := strings.Split(strings.Trim(objPath, "/"), "/")
// expected: card/<user>/<bookname>/<objid>
// expected: card/home/<bookname>/<objid>
if len(parts) < 4 {
return "", "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid object path"))
}
@@ -258,16 +258,28 @@ func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, bookN
return user, bookName, objID, nil
}
func principalPath(user string) string {
return fmt.Sprintf("/principals/%s/", user)
// cardPrincipalPath, cardHomePath, and cardObjectPath are the same for
// every user: authorization is resolved from the Basic Auth identity, not
// from the URL, so no username segment is needed in the path.
//
// The fixed "home" segment (in place of a username) is required, not
// cosmetic: go-webdav's carddav server classifies a request purely by how
// many path segments it has relative to the handler's mount point — 1
// segment is treated as the principal, 2 as the addressbook-home-set, 3 as
// an address book, 4 as an address object. Removing that segment entirely
// would make the home-set and address-book paths misclassified as
// principal/home-set respectively, breaking discovery (empty PROPFIND
// responses).
func cardPrincipalPath() string {
return "/card/"
}
func cardHomePath(user string) string {
return fmt.Sprintf("/card/%s/", user)
func cardHomePath() string {
return "/card/home/"
}
func cardObjectPath(user, bookName, objID string) string {
return fmt.Sprintf("/card/%s/%s/%s", user, bookName, objID)
func cardObjectPath(bookName, objID string) string {
return fmt.Sprintf("/card/home/%s/%s", bookName, objID)
}
func hashBytes(data []byte) uint64 {
+10 -10
View File
@@ -9,12 +9,12 @@ import (
// Config is the top-level server configuration.
type Config struct {
Server ServerConfig `yaml:"server"`
Auth AuthConfig `yaml:"auth"`
Storage StorageConfig `yaml:"storage"`
Users map[string]UserConfig `yaml:"users"`
TLS TLSConfig `yaml:"tls"`
Logging LoggingConfig `yaml:"logging"`
Server ServerConfig `yaml:"server"`
Auth AuthConfig `yaml:"auth"`
Storage StorageConfig `yaml:"storage"`
Users map[string]UserConfig `yaml:"users"`
TLS TLSConfig `yaml:"tls"`
Logging LoggingConfig `yaml:"logging"`
}
type ServerConfig struct {
@@ -36,10 +36,10 @@ type StorageConfig struct {
type UserConfig struct {
// bcrypt-hashed password (use `htpasswd -nB <user>`)
Password string `yaml:"password"`
DisplayName string `yaml:"display_name"`
Email string `yaml:"email"`
Calendars []string `yaml:"calendars"`
Password string `yaml:"password"`
DisplayName string `yaml:"display_name"`
Email string `yaml:"email"`
Calendars []string `yaml:"calendars"`
AddressBooks []string `yaml:"address_books"`
}
+47 -20
View File
@@ -17,9 +17,15 @@ var ErrConflict = errors.New("conflict")
// Store is a filesystem-backed key/value store for DAV objects.
// Each "collection" maps to a directory; each "object" maps to a file.
//
// Locking is sharded per-user (rather than one global mutex) so that
// concurrent requests from different users don't serialize against each
// other; operations within a single user's data still block one another.
type Store struct {
rootDir string
mu sync.RWMutex
locksMu sync.Mutex
locks map[string]*sync.RWMutex
}
// NewStore creates or opens a Store rooted at rootDir.
@@ -27,7 +33,19 @@ func NewStore(rootDir string) (*Store, error) {
if err := os.MkdirAll(rootDir, 0o755); err != nil {
return nil, fmt.Errorf("creating store root %q: %w", rootDir, err)
}
return &Store{rootDir: rootDir}, nil
return &Store{rootDir: rootDir, locks: make(map[string]*sync.RWMutex)}, nil
}
// lockFor returns the per-user lock, creating it on first use.
func (s *Store) lockFor(user string) *sync.RWMutex {
s.locksMu.Lock()
defer s.locksMu.Unlock()
l, ok := s.locks[user]
if !ok {
l = &sync.RWMutex{}
s.locks[user] = l
}
return l
}
// collectionPath returns the filesystem path for a collection.
@@ -42,16 +60,18 @@ func (s *Store) objectPath(user, collection, objectID string) string {
// EnsureCollection creates the collection directory if it does not exist.
func (s *Store) EnsureCollection(user, collection string) error {
s.mu.Lock()
defer s.mu.Unlock()
l := s.lockFor(user)
l.Lock()
defer l.Unlock()
dir := s.collectionPath(user, collection)
return os.MkdirAll(dir, 0o755)
}
// ListCollections returns all collection names for a user.
func (s *Store) ListCollections(user string) ([]string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
l := s.lockFor(user)
l.RLock()
defer l.RUnlock()
userDir := filepath.Join(s.rootDir, sanitize(user))
entries, err := os.ReadDir(userDir)
@@ -73,8 +93,9 @@ func (s *Store) ListCollections(user string) ([]string, error) {
// GetCollection returns metadata about a collection.
func (s *Store) GetCollection(user, collection string) (os.FileInfo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
l := s.lockFor(user)
l.RLock()
defer l.RUnlock()
info, err := os.Stat(s.collectionPath(user, collection))
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
@@ -84,8 +105,9 @@ func (s *Store) GetCollection(user, collection string) (os.FileInfo, error) {
// PutObject writes data to an object, creating or replacing it.
func (s *Store) PutObject(user, collection, objectID string, data []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
l := s.lockFor(user)
l.Lock()
defer l.Unlock()
dir := s.collectionPath(user, collection)
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -103,8 +125,9 @@ func (s *Store) PutObject(user, collection, objectID string, data []byte) error
// GetObject reads an object's raw bytes.
func (s *Store) GetObject(user, collection, objectID string) ([]byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
l := s.lockFor(user)
l.RLock()
defer l.RUnlock()
data, err := os.ReadFile(s.objectPath(user, collection, objectID))
if errors.Is(err, os.ErrNotExist) {
@@ -115,8 +138,9 @@ func (s *Store) GetObject(user, collection, objectID string) ([]byte, error) {
// DeleteObject removes an object.
func (s *Store) DeleteObject(user, collection, objectID string) error {
s.mu.Lock()
defer s.mu.Unlock()
l := s.lockFor(user)
l.Lock()
defer l.Unlock()
err := os.Remove(s.objectPath(user, collection, objectID))
if errors.Is(err, os.ErrNotExist) {
@@ -127,8 +151,9 @@ func (s *Store) DeleteObject(user, collection, objectID string) error {
// ListObjects returns all object filenames in a collection.
func (s *Store) ListObjects(user, collection string) ([]string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
l := s.lockFor(user)
l.RLock()
defer l.RUnlock()
dir := s.collectionPath(user, collection)
entries, err := os.ReadDir(dir)
@@ -150,8 +175,9 @@ func (s *Store) ListObjects(user, collection string) ([]string, error) {
// StatObject returns FileInfo for an object.
func (s *Store) StatObject(user, collection, objectID string) (os.FileInfo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
l := s.lockFor(user)
l.RLock()
defer l.RUnlock()
info, err := os.Stat(s.objectPath(user, collection, objectID))
if errors.Is(err, os.ErrNotExist) {
@@ -162,8 +188,9 @@ func (s *Store) StatObject(user, collection, objectID string) (os.FileInfo, erro
// DeleteCollection removes an entire collection directory.
func (s *Store) DeleteCollection(user, collection string) error {
s.mu.Lock()
defer s.mu.Unlock()
l := s.lockFor(user)
l.Lock()
defer l.Unlock()
err := os.RemoveAll(s.collectionPath(user, collection))
return err
}
+54
View File
@@ -1,7 +1,9 @@
package store_test
import (
"fmt"
"os"
"sync"
"testing"
"github.com/yourusername/caldav-server/internal/store"
@@ -77,3 +79,55 @@ func TestSanitizePath(t *testing.T) {
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))
}
}
}
+46 -23
View File
@@ -5,15 +5,28 @@ import (
"net/http"
"os"
"path/filepath"
"sync"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
xwebdav "golang.org/x/net/webdav"
)
// NewHandler returns an http.Handler that provides standard WebDAV file access
// per-user under dataDir/files/<username>/.
// NewHandler returns an http.Handler that provides standard WebDAV file access,
// mounted at the fixed URL /files/ for every user and rooted at
// dataDir/files/<username>/ on disk. The URL is the same for all users —
// which user's directory is served is resolved from the Basic Auth identity
// in the request context, not from the URL.
//
// A dedicated xwebdav.Handler (with its own persistent LockSystem) is created
// once per user and cached, so LOCK/UNLOCK state survives across requests
// instead of being reset on every call.
func NewHandler(cfg *config.Config, dataDir string, logger *slog.Logger) http.Handler {
var (
mu sync.Mutex
handlers = make(map[string]http.Handler)
)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := auth.FromContext(r.Context())
if p == nil {
@@ -21,29 +34,39 @@ func NewHandler(cfg *config.Config, dataDir string, logger *slog.Logger) http.Ha
return
}
userDir := filepath.Join(dataDir, "files", p.Username)
logger.Debug(userDir)
if err := os.MkdirAll(userDir, 0o755); err != nil {
logger.Error("creating user WebDAV dir", "user", p.Username, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
mu.Lock()
h, ok := handlers[p.Username]
if !ok {
username := p.Username
userDir := filepath.Join(dataDir, "files", username)
if err := os.MkdirAll(userDir, 0o755); err != nil {
mu.Unlock()
logger.Error("creating user WebDAV dir", "user", username, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Each user gets their own isolated WebDAV handler so paths don't bleed.
h := &xwebdav.Handler{
FileSystem: xwebdav.Dir(userDir),
LockSystem: xwebdav.NewMemLS(),
Logger: func(r *http.Request, err error) {
if err != nil {
logger.Warn("WebDAV error",
"user", p.Username,
"method", r.Method,
"path", r.URL.Path,
"error", err)
}
},
Prefix: "/", //fmt.Sprintf("/files/%s", p.Username),
// Each user gets their own isolated WebDAV handler (and lock
// system) so paths and locks don't bleed between users, even
// though they all share the same "/files/" URL.
h = &xwebdav.Handler{
FileSystem: xwebdav.Dir(userDir),
LockSystem: xwebdav.NewMemLS(),
Logger: func(r *http.Request, err error) {
if err != nil {
logger.Warn("WebDAV error",
"user", username,
"method", r.Method,
"path", r.URL.Path,
"error", err)
}
},
Prefix: "/files",
}
handlers[username] = h
}
mu.Unlock()
h.ServeHTTP(w, r)
})
}
+100
View File
@@ -0,0 +1,100 @@
package filewebdav_test
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/yourusername/caldav-server/internal/auth"
filewebdav "github.com/yourusername/caldav-server/internal/webdav"
)
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func doAs(t *testing.T, h http.Handler, user, method, path string, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
ctx := auth.NewContext(req.Context(), &auth.Principal{Username: user})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// TestPerUserIsolationAndPrefix verifies that all users share the same
// "/files/" URL, but each is served from (and can only see) their own
// directory on disk, resolved from the Basic Auth identity.
func TestPerUserIsolationAndPrefix(t *testing.T) {
dir := t.TempDir()
h := filewebdav.NewHandler(nil, dir, testLogger())
putRec := doAs(t, h, "alice", http.MethodPut, "/files/note.txt", "hello alice")
if putRec.Code != http.StatusCreated && putRec.Code != http.StatusNoContent {
t.Fatalf("PUT as alice: unexpected status %d: %s", putRec.Code, putRec.Body.String())
}
getRec := doAs(t, h, "alice", http.MethodGet, "/files/note.txt", "")
if getRec.Code != http.StatusOK {
t.Fatalf("GET as alice: unexpected status %d", getRec.Code)
}
if getRec.Body.String() != "hello alice" {
t.Fatalf("unexpected body: %q", getRec.Body.String())
}
// bob hits the exact same URL, but must not see alice's file — his own
// directory on disk is empty.
bobRec := doAs(t, h, "bob", http.MethodGet, "/files/note.txt", "")
if bobRec.Code != http.StatusNotFound {
t.Fatalf("expected bob to get 404 for alice's file, got %d", bobRec.Code)
}
// Confirm the file physically landed under dataDir/files/alice/, not
// nested under an extra files/files/... path.
if _, err := os.Stat(dir + "/files/alice/note.txt"); err != nil {
t.Fatalf("expected file at dataDir/files/alice/note.txt: %v", err)
}
}
// TestLockPersistsAcrossRequests ensures the LockSystem used by the handler
// is not recreated (and thus reset) on every request.
func TestLockPersistsAcrossRequests(t *testing.T) {
dir := t.TempDir()
h := filewebdav.NewHandler(nil, dir, testLogger())
// Create the file first.
doAs(t, h, "alice", http.MethodPut, "/files/locked.txt", "v1")
lockBody := `<?xml version="1.0" encoding="utf-8" ?>
<D:lockinfo xmlns:D="DAV:">
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
<D:owner><D:href>test</D:href></D:owner>
</D:lockinfo>`
lockRec := doAs(t, h, "alice", "LOCK", "/files/locked.txt", lockBody)
if lockRec.Code != http.StatusOK {
t.Fatalf("LOCK: unexpected status %d: %s", lockRec.Code, lockRec.Body.String())
}
locktoken := lockRec.Header().Get("Lock-Token")
if locktoken == "" {
t.Fatal("expected Lock-Token header in LOCK response")
}
// A second, unrelated request must still see the lock as active,
// proving the LockSystem instance was reused rather than reset.
req := httptest.NewRequest(http.MethodPut, "/files/locked.txt", strings.NewReader("v2 without token"))
ctx := auth.NewContext(req.Context(), &auth.Principal{Username: "alice"})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusLocked {
t.Fatalf("expected 423 Locked for PUT without lock token, got %d: %s", rec.Code, rec.Body.String())
}
}