diff --git a/internal/api/files_handler.go b/internal/api/files_handler.go index c11f553..65553e1 100644 --- a/internal/api/files_handler.go +++ b/internal/api/files_handler.go @@ -236,7 +236,7 @@ func (h *FilesHandler) UploadHandler(w http.ResponseWriter, r *http.Request) { } node = existingNode } - return nil + return tx.Nodes.TouchAncestors(r.Context(), user.ID, destPath) }) if err != nil { @@ -319,7 +319,7 @@ func (h *FilesHandler) MkdirHandler(w http.ResponseWriter, r *http.Request) { } return err } - return nil + return tx.Nodes.TouchAncestors(r.Context(), user.ID, dirPath) }) if err != nil { @@ -379,7 +379,10 @@ func (h *FilesHandler) DeleteHandler(w http.ResponseWriter, r *http.Request) { if node.IsDir { if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - return tx.Nodes.DeletePrefix(r.Context(), user.ID, targetPath) + if err := tx.Nodes.DeletePrefix(r.Context(), user.ID, targetPath); err != nil { + return err + } + return tx.Nodes.TouchAncestors(r.Context(), user.ID, targetPath) }); err != nil { h.logger.Error("API delete dir DB transaction failed", slog.String("path", targetPath), slog.Any("error", err)) writeJSONError(w, http.StatusInternalServerError, "Failed to delete directory metadata") @@ -389,7 +392,10 @@ func (h *FilesHandler) DeleteHandler(w http.ResponseWriter, r *http.Request) { audit.Record(r.Context(), "api_delete_dir", targetPath, fmt.Sprintf("User %q deleted directory %q", user.Username, targetPath)) } else { if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - return tx.Nodes.Delete(r.Context(), user.ID, targetPath) + if err := tx.Nodes.Delete(r.Context(), user.ID, targetPath); err != nil { + return err + } + return tx.Nodes.TouchAncestors(r.Context(), user.ID, targetPath) }); err != nil { h.logger.Error("API delete file DB transaction failed", slog.String("path", targetPath), slog.Any("error", err)) writeJSONError(w, http.StatusInternalServerError, "Failed to delete file metadata") @@ -435,7 +441,10 @@ func (h *FilesHandler) RenameHandler(w http.ResponseWriter, r *http.Request) { } if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - return tx.Nodes.MovePrefix(r.Context(), user.ID, oldPath, newPath) + if err := tx.Nodes.MovePrefix(r.Context(), user.ID, oldPath, newPath); err != nil { + return err + } + return tx.Nodes.TouchAncestors(r.Context(), user.ID, oldPath, newPath) }); err != nil { if revertErr := h.storage.Move(user.Username, newPath, oldPath); revertErr != nil { h.logger.Error("CRITICAL: compensating storage move failed, filesystem and database are out of sync", diff --git a/internal/api/files_handler_test.go b/internal/api/files_handler_test.go index 0575841..1400326 100644 --- a/internal/api/files_handler_test.go +++ b/internal/api/files_handler_test.go @@ -92,6 +92,18 @@ func TestFilesHandlerOperations(t *testing.T) { } }) + t.Run("Delete removes directory atomically", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/api/v1/files?path=/documents", nil) + req = req.WithContext(middleware.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + handler.DeleteHandler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200 OK, got %d: %s", w.Code, w.Body.String()) + } + }) + t.Run("Mkdir cleans up storage directory when DB transaction fails", func(t *testing.T) { failingTx := &failingMockTransactor{err: os.ErrInvalid} failHandler := NewFilesHandler(failingTx, nodeRepo, st, logger) @@ -193,6 +205,53 @@ func TestFilesHandlerOperations(t *testing.T) { t.Fatalf("expected 413 Request Entity Too Large, got %d: %s", w.Code, w.Body.String()) } }) + + t.Run("Delete returns 500 when DeletePrefix fails in DB", func(t *testing.T) { + nodeRepo.nodes["/del_dir_err"] = &domain.Node{UserID: 1, Path: "/del_dir_err", Name: "del_dir_err", IsDir: true} + nodeRepo.deletePrefixErr = os.ErrInvalid + defer func() { nodeRepo.deletePrefixErr = nil }() + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/files?path=/del_dir_err", nil) + req = req.WithContext(middleware.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + handler.DeleteHandler(w, req) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 when DeletePrefix fails, got %d", w.Code) + } + }) + + t.Run("Delete returns 500 when Delete file fails in DB", func(t *testing.T) { + nodeRepo.nodes["/del_file_err.txt"] = &domain.Node{UserID: 1, Path: "/del_file_err.txt", Name: "del_file_err.txt", IsDir: false} + nodeRepo.deleteErr = os.ErrInvalid + defer func() { nodeRepo.deleteErr = nil }() + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/files?path=/del_file_err.txt", nil) + req = req.WithContext(middleware.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + handler.DeleteHandler(w, req) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 when Delete file fails, got %d", w.Code) + } + }) + + t.Run("Rename returns 500 when MovePrefix fails in DB", func(t *testing.T) { + nodeRepo.nodes["/rename_err.txt"] = &domain.Node{UserID: 1, Path: "/rename_err.txt", Name: "rename_err.txt"} + _, _ = st.Write(user.Username, "/rename_err.txt", bytes.NewReader([]byte("data"))) + nodeRepo.movePrefixErr = os.ErrInvalid + defer func() { nodeRepo.movePrefixErr = nil }() + + reqBody, _ := json.Marshal(RenameRequest{OldPath: "/rename_err.txt", NewPath: "/renamed_target.txt"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/files/rename", bytes.NewReader(reqBody)) + req = req.WithContext(middleware.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + handler.RenameHandler(w, req) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 when MovePrefix fails, got %d", w.Code) + } + }) } type failingMockTransactor struct { diff --git a/internal/api/graph_handler_test.go b/internal/api/graph_handler_test.go index 977e2e6..c7a23a6 100644 --- a/internal/api/graph_handler_test.go +++ b/internal/api/graph_handler_test.go @@ -40,6 +40,9 @@ func (m *mockNodeRepoForGraph) MovePrefix(ctx context.Context, userID int64, old func (m *mockNodeRepoForGraph) EnsureParentDirectoryExists(ctx context.Context, userID int64, parentPath string) error { return nil } +func (m *mockNodeRepoForGraph) TouchAncestors(ctx context.Context, userID int64, paths ...string) error { + return nil +} func TestMeDrivesHandler(t *testing.T) { handler := api.NewGraphHandler(nil, &mockNodeRepoForGraph{}) diff --git a/internal/api/shares_handler_test.go b/internal/api/shares_handler_test.go index 711faa1..b298463 100644 --- a/internal/api/shares_handler_test.go +++ b/internal/api/shares_handler_test.go @@ -26,7 +26,10 @@ func (m *mockTransactor) WithTx(ctx context.Context, fn func(txRepo *repository. } type mockNodeRepo struct { - nodes map[string]*domain.Node + nodes map[string]*domain.Node + deleteErr error + deletePrefixErr error + movePrefixErr error } func (m *mockNodeRepo) GetByPath(ctx context.Context, userID int64, path string) (*domain.Node, error) { @@ -44,13 +47,23 @@ func (m *mockNodeRepo) Update(ctx context.Context, node *domain.Node) error { return nil } func (m *mockNodeRepo) Delete(ctx context.Context, userID int64, path string) error { + if m.deleteErr != nil { + return m.deleteErr + } delete(m.nodes, path) return nil } func (m *mockNodeRepo) DeletePrefix(ctx context.Context, userID int64, pathPrefix string) error { + if m.deletePrefixErr != nil { + return m.deletePrefixErr + } + delete(m.nodes, pathPrefix) return nil } func (m *mockNodeRepo) MovePrefix(ctx context.Context, userID int64, oldPrefix, newPrefix string) error { + if m.movePrefixErr != nil { + return m.movePrefixErr + } if n, ok := m.nodes[oldPrefix]; ok { delete(m.nodes, oldPrefix) n.Path = newPrefix @@ -61,6 +74,9 @@ func (m *mockNodeRepo) MovePrefix(ctx context.Context, userID int64, oldPrefix, func (m *mockNodeRepo) EnsureParentDirectoryExists(ctx context.Context, userID int64, parentPath string) error { return nil } +func (m *mockNodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...string) error { + return nil +} type mockShareRepo struct { shares map[string]*domain.Share diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 86a27bb..27559bb 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -190,6 +190,10 @@ func (r *Reindexer) ReindexUser(ctx context.Context, user *domain.User, removeOr stats.Deleted += orphanStats } + if !r.dryRun && (stats.Created > 0 || stats.Updated > 0 || stats.Deleted > 0) { + _ = r.nodeRepo.TouchAncestors(ctx, user.ID, "/") + } + return stats, nil } diff --git a/internal/pathutil/pathutil.go b/internal/pathutil/pathutil.go index 0a65b36..f4f0803 100644 --- a/internal/pathutil/pathutil.go +++ b/internal/pathutil/pathutil.go @@ -86,3 +86,27 @@ func GenerateFileID(userID int64) string { _, _ = rand.Read(b) return fmt.Sprintf("%08doc_%s", userID, hex.EncodeToString(b)) } + +// Ancestors returns all ancestor directory paths for a given path up to and including root "/". +// For example, for "/Temp/macroses.txt", it returns []string{"/Temp", "/"}. +// For "/file.txt", it returns []string{"/"}. +// For "/Temp" or "/", it returns []string{"/"}. +func Ancestors(p string) []string { + p = NormalizePath(p) + if p == "" || p == "/" { + return []string{"/"} + } + + var ancestors []string + curr := p + for { + parent, _ := SplitPath(curr) + ancestors = append(ancestors, parent) + if parent == "/" { + break + } + curr = parent + } + return ancestors +} + diff --git a/internal/pathutil/pathutil_test.go b/internal/pathutil/pathutil_test.go index 66a3a82..79404f1 100644 --- a/internal/pathutil/pathutil_test.go +++ b/internal/pathutil/pathutil_test.go @@ -100,6 +100,34 @@ func TestFormatContentDisposition(t *testing.T) { } } +func TestAncestors(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"", []string{"/"}}, + {"/", []string{"/"}}, + {"/file.txt", []string{"/"}}, + {"/Temp", []string{"/"}}, + {"/Temp/macroses.txt", []string{"/Temp", "/"}}, + {"/a/b/c/d.txt", []string{"/a/b/c", "/a/b", "/a", "/"}}, + {"/a/b/c/", []string{"/a/b", "/a", "/"}}, + } + + for _, tt := range tests { + got := Ancestors(tt.input) + if len(got) != len(tt.want) { + t.Errorf("Ancestors(%q) len = %d, want %d (%v vs %v)", tt.input, len(got), len(tt.want), got, tt.want) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("Ancestors(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + } +} + func TestValidatePathLength(t *testing.T) { shortPath := "/valid/short/path.txt" if err := ValidatePathLength(shortPath); err != nil { diff --git a/internal/repository/node_repository.go b/internal/repository/node_repository.go index 6f38e49..372bfe7 100644 --- a/internal/repository/node_repository.go +++ b/internal/repository/node_repository.go @@ -3,7 +3,9 @@ package repository import ( "context" "crypto/md5" + "crypto/rand" "database/sql" + "encoding/hex" "errors" "fmt" "strings" @@ -299,6 +301,53 @@ func (r *nodeRepo) ensureParentDirectoryExistsWithDepth(ctx context.Context, use return nil } +func (r *nodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...string) error { + seen := make(map[string]struct{}) + var ancestors []string + + for _, p := range paths { + for _, a := range pathutil.Ancestors(p) { + if _, exists := seen[a]; !exists { + seen[a] = struct{}{} + ancestors = append(ancestors, a) + } + } + } + + if len(ancestors) == 0 { + return nil + } + + now := time.Now() + for _, a := range ancestors { + b := make([]byte, 16) + _, _ = rand.Read(b) + newETag := hex.EncodeToString(b) + + query, args, err := dialect.Update("nodes"). + Set(goqu.Record{ + "etag": newETag, + "mtime": now, + "updated_at": now, + }). + Where(goqu.Ex{ + "user_id": userID, + "path": a, + "is_dir": 1, + }). + ToSQL() + if err != nil { + return fmt.Errorf("failed to build SQL for TouchAncestors: %w", err) + } + + if _, err := r.db.ExecContext(ctx, query, args...); err != nil { + return fmt.Errorf("failed to update ancestor %s: %w", a, err) + } + } + + return nil +} + func normalizePath(p string) string { return pathutil.NormalizePath(p) } diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 6e21dd4..32b1914 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -67,6 +67,7 @@ type NodeRepository interface { DeletePrefix(ctx context.Context, userID int64, pathPrefix string) error MovePrefix(ctx context.Context, userID int64, oldPrefix, newPrefix string) error EnsureParentDirectoryExists(ctx context.Context, userID int64, parentPath string) error + TouchAncestors(ctx context.Context, userID int64, paths ...string) error } type ShareRepository interface { diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go index 0721d78..c53b925 100644 --- a/internal/repository/repository_test.go +++ b/internal/repository/repository_test.go @@ -170,6 +170,29 @@ func TestRepositoryOperations(t *testing.T) { t.Errorf("Delete node failed: %v", err) } + // Test TouchAncestors + if err := repos.Nodes.TouchAncestors(ctx, fetchedUser.ID); err != nil { + t.Errorf("TouchAncestors with empty paths failed: %v", err) + } + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + if err := repos.Nodes.TouchAncestors(canceledCtx, fetchedUser.ID, "/folderB/child.txt"); err == nil { + t.Errorf("expected error with canceled context in TouchAncestors, got nil") + } + oldRoot, _ := repos.Nodes.GetByPath(ctx, fetchedUser.ID, "/") + oldFolderB, _ := repos.Nodes.GetByPath(ctx, fetchedUser.ID, "/folderB") + if err := repos.Nodes.TouchAncestors(ctx, fetchedUser.ID, "/folderB/child.txt"); err != nil { + t.Errorf("TouchAncestors failed: %v", err) + } + newRoot, _ := repos.Nodes.GetByPath(ctx, fetchedUser.ID, "/") + newFolderB, _ := repos.Nodes.GetByPath(ctx, fetchedUser.ID, "/folderB") + if newRoot.ETag == oldRoot.ETag { + t.Errorf("expected root ETag to change after TouchAncestors, remained %s", newRoot.ETag) + } + if newFolderB.ETag == oldFolderB.ETag { + t.Errorf("expected /folderB ETag to change after TouchAncestors, remained %s", newFolderB.ETag) + } + // 3. Share Repository with ExpiresAt & ListByUserID expiresAt := time.Now().Add(24 * time.Hour) share := &domain.Share{ diff --git a/internal/webdav/handler.go b/internal/webdav/handler.go index d22d6f3..8e471ad 100644 --- a/internal/webdav/handler.go +++ b/internal/webdav/handler.go @@ -316,14 +316,20 @@ func (h *WebDAVHandler) handlePut(w http.ResponseWriter, r *http.Request, user * MTime: mtime, ContentType: contentType, } - return tx.Nodes.Create(r.Context(), node) + if err := tx.Nodes.Create(r.Context(), node); err != nil { + return err + } + return tx.Nodes.TouchAncestors(r.Context(), user.ID, relPath) } existingNode.Size = size existingNode.ETag = etag existingNode.MTime = mtime existingNode.ContentType = contentType - return tx.Nodes.Update(r.Context(), existingNode) + if err := tx.Nodes.Update(r.Context(), existingNode); err != nil { + return err + } + return tx.Nodes.TouchAncestors(r.Context(), user.ID, relPath) }) if err != nil { @@ -384,7 +390,10 @@ func (h *WebDAVHandler) handleDelete(w http.ResponseWriter, r *http.Request, use childCount := len(children) if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - return tx.Nodes.DeletePrefix(r.Context(), user.ID, relPath) + if err := tx.Nodes.DeletePrefix(r.Context(), user.ID, relPath); err != nil { + return err + } + return tx.Nodes.TouchAncestors(r.Context(), user.ID, relPath) }); err != nil { h.logger.Error("DELETE dir database transaction failed", slog.String("path", relPath), slog.Any("error", err)) audit.RecordFailure(r.Context(), "dir_delete", relPath, @@ -403,7 +412,10 @@ func (h *WebDAVHandler) handleDelete(w http.ResponseWriter, r *http.Request, use ) } else { if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - return tx.Nodes.Delete(r.Context(), user.ID, relPath) + if err := tx.Nodes.Delete(r.Context(), user.ID, relPath); err != nil { + return err + } + return tx.Nodes.TouchAncestors(r.Context(), user.ID, relPath) }); err != nil { h.logger.Error("DELETE file database transaction failed", slog.String("path", relPath), slog.Any("error", err)) audit.RecordFailure(r.Context(), "file_delete", relPath, @@ -466,7 +478,7 @@ func (h *WebDAVHandler) handleMkcol(w http.ResponseWriter, r *http.Request, user } return err } - return nil + return tx.Nodes.TouchAncestors(r.Context(), user.ID, relPath) }) if err != nil { @@ -535,7 +547,10 @@ func (h *WebDAVHandler) handleMove(w http.ResponseWriter, r *http.Request, user } if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - return tx.Nodes.MovePrefix(r.Context(), user.ID, relPath, destRelPath) + if err := tx.Nodes.MovePrefix(r.Context(), user.ID, relPath, destRelPath); err != nil { + return err + } + return tx.Nodes.TouchAncestors(r.Context(), user.ID, relPath, destRelPath) }); err != nil { if revertErr := h.storage.Move(user.Username, destRelPath, relPath); revertErr != nil { h.logger.Error("CRITICAL: compensating storage move failed, filesystem and database are out of sync", @@ -657,9 +672,15 @@ func (h *WebDAVHandler) handleCopy(w http.ResponseWriter, r *http.Request, user } if existingDest != nil { node.ID = existingDest.ID - return tx.Nodes.Update(r.Context(), node) + if err := tx.Nodes.Update(r.Context(), node); err != nil { + return err + } + return tx.Nodes.TouchAncestors(r.Context(), user.ID, destRelPath) + } + if err := tx.Nodes.Create(r.Context(), node); err != nil { + return err } - return tx.Nodes.Create(r.Context(), node) + return tx.Nodes.TouchAncestors(r.Context(), user.ID, destRelPath) }) if err != nil { if delErr := h.storage.Delete(user.Username, destRelPath); delErr != nil { @@ -818,7 +839,7 @@ func (h *WebDAVHandler) handleCopyDir(w http.ResponseWriter, r *http.Request, us return err } } - return nil + return tx.Nodes.TouchAncestors(r.Context(), user.ID, destRelPath) }) if err != nil { diff --git a/internal/webdav/handler_test.go b/internal/webdav/handler_test.go index ffac2ba..f84e455 100644 --- a/internal/webdav/handler_test.go +++ b/internal/webdav/handler_test.go @@ -14,12 +14,13 @@ import ( "selfcloud/internal/domain" "selfcloud/internal/middleware" + "selfcloud/internal/pathutil" "selfcloud/internal/repository" "selfcloud/internal/storage" ) type mockTransactor struct { - repo *mockNodeRepo + repo repository.NodeRepository } func (m *mockTransactor) WithTx(ctx context.Context, fn func(txRepo *repository.Repositories) error) error { @@ -29,6 +30,50 @@ func (m *mockTransactor) WithTx(ctx context.Context, fn func(txRepo *repository. return fn(repos) } +type errorNodeRepo struct { + *mockNodeRepo + createErr error + updateErr error + deleteErr error + deletePrefixErr error + movePrefixErr error +} + +func (e *errorNodeRepo) Create(ctx context.Context, node *domain.Node) error { + if e.createErr != nil { + return e.createErr + } + return e.mockNodeRepo.Create(ctx, node) +} + +func (e *errorNodeRepo) Update(ctx context.Context, node *domain.Node) error { + if e.updateErr != nil { + return e.updateErr + } + return e.mockNodeRepo.Update(ctx, node) +} + +func (e *errorNodeRepo) Delete(ctx context.Context, userID int64, path string) error { + if e.deleteErr != nil { + return e.deleteErr + } + return e.mockNodeRepo.Delete(ctx, userID, path) +} + +func (e *errorNodeRepo) DeletePrefix(ctx context.Context, userID int64, pathPrefix string) error { + if e.deletePrefixErr != nil { + return e.deletePrefixErr + } + return e.mockNodeRepo.DeletePrefix(ctx, userID, pathPrefix) +} + +func (e *errorNodeRepo) MovePrefix(ctx context.Context, userID int64, oldPrefix, newPrefix string) error { + if e.movePrefixErr != nil { + return e.movePrefixErr + } + return e.mockNodeRepo.MovePrefix(ctx, userID, oldPrefix, newPrefix) +} + type mockNodeRepo struct { nodes map[string]*domain.Node } @@ -76,6 +121,15 @@ func (m *mockNodeRepo) DeletePrefix(ctx context.Context, userID int64, pathPrefi } func (m *mockNodeRepo) MovePrefix(ctx context.Context, userID int64, oldPrefix, newPrefix string) error { + for k, v := range m.nodes { + if k == oldPrefix || (len(k) > len(oldPrefix) && k[:len(oldPrefix)] == oldPrefix) { + newPath := newPrefix + k[len(oldPrefix):] + delete(m.nodes, k) + v.Path = newPath + v.Name = filepath.Base(newPath) + m.nodes[newPath] = v + } + } return nil } @@ -83,6 +137,18 @@ func (m *mockNodeRepo) EnsureParentDirectoryExists(ctx context.Context, userID i return nil } +func (m *mockNodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...string) error { + for _, p := range paths { + for _, a := range pathutil.Ancestors(p) { + if node, ok := m.nodes[a]; ok && node.IsDir { + node.ETag = fmt.Sprintf("touched_%d", time.Now().UnixNano()) + node.MTime = time.Now() + } + } + } + return nil +} + func TestWebDAVHandlerGetAndRange(t *testing.T) { tempDir, err := os.MkdirTemp("", "webdav_test_*") if err != nil { @@ -286,4 +352,184 @@ func TestWebDAVHandlerGetAndRange(t *testing.T) { t.Fatalf("expected d:getlastmodified to end with GMT according to HTTP-date specification, got: %s", body) } }) + + t.Run("MKCOL creates directory successfully", func(t *testing.T) { + req := withUserCtx(httptest.NewRequest("MKCOL", "/remote.php/webdav/testdir", nil)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201 Created, got %d: %s", rec.Code, rec.Body.String()) + } + if repo.nodes["/testdir"] == nil || !repo.nodes["/testdir"].IsDir { + t.Fatalf("expected /testdir to exist as directory in repo") + } + }) + + t.Run("MOVE file to new destination succeeds", func(t *testing.T) { + req := withUserCtx(httptest.NewRequest("MOVE", "/remote.php/webdav/hello.txt", nil)) + req.Header.Set("Destination", "http://localhost:8080/remote.php/webdav/hello_moved.txt") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201 Created, got %d", rec.Code) + } + if repo.nodes["/hello_moved.txt"] == nil { + t.Fatalf("expected /hello_moved.txt to exist in repo") + } + }) + + t.Run("COPY directory to new destination succeeds", func(t *testing.T) { + // Setup source dir and file in repo and storage + _ = st.Mkdir(user.Username, "/srcdir") + _, _ = st.Write(user.Username, "/srcdir/file.txt", bytes.NewReader([]byte("dir file"))) + repo.nodes["/srcdir"] = &domain.Node{ + UserID: 1, Path: "/srcdir", Name: "srcdir", IsDir: true, + } + repo.nodes["/srcdir/file.txt"] = &domain.Node{ + UserID: 1, Path: "/srcdir/file.txt", ParentPath: "/srcdir", Name: "file.txt", IsDir: false, + } + + req := withUserCtx(httptest.NewRequest("COPY", "/remote.php/webdav/srcdir", nil)) + req.Header.Set("Destination", "http://localhost:8080/remote.php/webdav/destdir") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201 Created, got %d", rec.Code) + } + if repo.nodes["/destdir"] == nil || !repo.nodes["/destdir"].IsDir { + t.Fatalf("expected /destdir directory to exist in repo") + } + if repo.nodes["/destdir/file.txt"] == nil { + t.Fatalf("expected /destdir/file.txt to exist in repo") + } + }) + + t.Run("DELETE file succeeds and updates ancestors", func(t *testing.T) { + // Setup file + _, _ = st.Write(user.Username, "/file_to_del.txt", bytes.NewReader([]byte("delete me"))) + repo.nodes["/file_to_del.txt"] = &domain.Node{ + UserID: 1, Path: "/file_to_del.txt", Name: "file_to_del.txt", IsDir: false, + } + + req := withUserCtx(httptest.NewRequest("DELETE", "/remote.php/webdav/file_to_del.txt", nil)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected status 204 No Content, got %d", rec.Code) + } + if repo.nodes["/file_to_del.txt"] != nil { + t.Fatalf("expected /file_to_del.txt to be removed from repo") + } + }) + + t.Run("DELETE directory succeeds and updates ancestors", func(t *testing.T) { + _ = st.Mkdir(user.Username, "/dir_to_del") + repo.nodes["/dir_to_del"] = &domain.Node{ + UserID: 1, Path: "/dir_to_del", Name: "dir_to_del", IsDir: true, + } + + req := withUserCtx(httptest.NewRequest("DELETE", "/remote.php/webdav/dir_to_del", nil)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected status 204 No Content, got %d", rec.Code) + } + if repo.nodes["/dir_to_del"] != nil { + t.Fatalf("expected /dir_to_del to be removed from repo") + } + }) + + t.Run("WebDAV operation DB transaction errors return 500", func(t *testing.T) { + errRepo := &errorNodeRepo{ + mockNodeRepo: repo, + } + errTx := &mockTransactor{repo: errRepo} + errHandler := NewWebDAVHandler(errTx, errRepo, st, logger) + + // 1. PUT create error + errRepo.createErr = fmt.Errorf("db create error") + req := withUserCtx(httptest.NewRequest(http.MethodPut, "/remote.php/webdav/create_err.txt", bytes.NewReader([]byte("data")))) + rec := httptest.NewRecorder() + errHandler.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for PUT create error, got %d", rec.Code) + } + errRepo.createErr = nil + + // 2. PUT update error + repo.nodes["/update_err.txt"] = &domain.Node{UserID: 1, Path: "/update_err.txt", Name: "update_err.txt"} + errRepo.updateErr = fmt.Errorf("db update error") + req = withUserCtx(httptest.NewRequest(http.MethodPut, "/remote.php/webdav/update_err.txt", bytes.NewReader([]byte("data")))) + rec = httptest.NewRecorder() + errHandler.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for PUT update error, got %d", rec.Code) + } + errRepo.updateErr = nil + + // 3. DELETE dir error + repo.nodes["/deldir_err"] = &domain.Node{UserID: 1, Path: "/deldir_err", Name: "deldir_err", IsDir: true} + errRepo.deletePrefixErr = fmt.Errorf("db delete dir error") + req = withUserCtx(httptest.NewRequest("DELETE", "/remote.php/webdav/deldir_err", nil)) + rec = httptest.NewRecorder() + errHandler.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for DELETE dir error, got %d", rec.Code) + } + errRepo.deletePrefixErr = nil + + // 4. DELETE file error + repo.nodes["/delfile_err.txt"] = &domain.Node{UserID: 1, Path: "/delfile_err.txt", Name: "delfile_err.txt", IsDir: false} + errRepo.deleteErr = fmt.Errorf("db delete file error") + req = withUserCtx(httptest.NewRequest("DELETE", "/remote.php/webdav/delfile_err.txt", nil)) + rec = httptest.NewRecorder() + errHandler.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for DELETE file error, got %d", rec.Code) + } + errRepo.deleteErr = nil + + // 5. MOVE error + repo.nodes["/move_err.txt"] = &domain.Node{UserID: 1, Path: "/move_err.txt", Name: "move_err.txt"} + _, _ = st.Write(user.Username, "/move_err.txt", bytes.NewReader([]byte("data"))) + errRepo.movePrefixErr = fmt.Errorf("db move error") + req = withUserCtx(httptest.NewRequest("MOVE", "/remote.php/webdav/move_err.txt", nil)) + req.Header.Set("Destination", "http://localhost:8080/remote.php/webdav/move_err_dest.txt") + rec = httptest.NewRecorder() + errHandler.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for MOVE error, got %d", rec.Code) + } + errRepo.movePrefixErr = nil + + // 6. COPY create error + errRepo.createErr = fmt.Errorf("db copy create error") + req = withUserCtx(httptest.NewRequest("COPY", "/remote.php/webdav/hello_moved.txt", nil)) + req.Header.Set("Destination", "http://localhost:8080/remote.php/webdav/copy_err_dest.txt") + rec = httptest.NewRecorder() + errHandler.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for COPY create error, got %d", rec.Code) + } + errRepo.createErr = nil + + // 7. COPY update error (with overwrite) + repo.nodes["/copy_dest_existing.txt"] = &domain.Node{UserID: 1, Path: "/copy_dest_existing.txt", Name: "copy_dest_existing.txt"} + _, _ = st.Write(user.Username, "/copy_dest_existing.txt", bytes.NewReader([]byte("old"))) + errRepo.updateErr = fmt.Errorf("db copy update error") + req = withUserCtx(httptest.NewRequest("COPY", "/remote.php/webdav/hello_moved.txt", nil)) + req.Header.Set("Destination", "http://localhost:8080/remote.php/webdav/copy_dest_existing.txt") + req.Header.Set("Overwrite", "T") + rec = httptest.NewRecorder() + errHandler.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Errorf("expected 500 for COPY update error, got %d", rec.Code) + } + errRepo.updateErr = nil + }) } diff --git a/test/functional/api_test.go b/test/functional/api_test.go index 322f60c..c8cbf45 100644 --- a/test/functional/api_test.go +++ b/test/functional/api_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "encoding/xml" "io" "log/slog" "mime/multipart" @@ -812,6 +813,18 @@ func TestOwnCloudSyncClientOAuthWorkflow(t *testing.T) { } // 8. WebDAV Operations with Bearer token + type davMultiStatus struct { + XMLName xml.Name `xml:"multistatus"` + Responses []struct { + HRef string `xml:"href"` + PropStat struct { + Prop struct { + GetETag string `xml:"getetag"` + } `xml:"prop"` + } `xml:"propstat"` + } `xml:"response"` + } + // 8a. PROPFIND /remote.php/webdav/ davPropReq, _ := http.NewRequest("PROPFIND", server.URL+"/remote.php/webdav/", nil) davPropReq.Header.Set("Authorization", "Bearer "+tokenData.AccessToken) @@ -820,7 +833,12 @@ func TestOwnCloudSyncClientOAuthWorkflow(t *testing.T) { if err != nil || davPropResp.StatusCode != http.StatusMultiStatus { t.Fatalf("PROPFIND /remote.php/webdav/ failed: %v, status: %d", err, davPropResp.StatusCode) } + var msBefore davMultiStatus + if err := xml.NewDecoder(davPropResp.Body).Decode(&msBefore); err != nil || len(msBefore.Responses) == 0 { + t.Fatalf("failed to decode MultiStatus XML: %v", err) + } davPropResp.Body.Close() + rootETagBefore := msBefore.Responses[0].PropStat.Prop.GetETag // 8b. PUT /remote.php/webdav/client_sync_test.txt putContent := "Hello from ownCloud Desktop Client sync engine!" @@ -832,7 +850,26 @@ func TestOwnCloudSyncClientOAuthWorkflow(t *testing.T) { } putResp.Body.Close() - // 8c. GET /remote.php/webdav/client_sync_test.txt + // 8c. Verify root ETag changed after file creation so sync client detects update + davPropReq2, _ := http.NewRequest("PROPFIND", server.URL+"/remote.php/webdav/", nil) + davPropReq2.Header.Set("Authorization", "Bearer "+tokenData.AccessToken) + davPropReq2.Header.Set("Depth", "0") + davPropResp2, err := client.Do(davPropReq2) + if err != nil || davPropResp2.StatusCode != http.StatusMultiStatus { + t.Fatalf("PROPFIND /remote.php/webdav/ after PUT failed: %v", err) + } + var msAfter davMultiStatus + if err := xml.NewDecoder(davPropResp2.Body).Decode(&msAfter); err != nil || len(msAfter.Responses) == 0 { + t.Fatalf("failed to decode MultiStatus XML after PUT: %v", err) + } + davPropResp2.Body.Close() + rootETagAfter := msAfter.Responses[0].PropStat.Prop.GetETag + + if rootETagBefore == "" || rootETagAfter == "" || rootETagBefore == rootETagAfter { + t.Fatalf("expected root ETag to change after PUT file (to notify sync client), before=%q, after=%q", rootETagBefore, rootETagAfter) + } + + // 8d. GET /remote.php/webdav/client_sync_test.txt getReq, _ := http.NewRequest("GET", server.URL+"/remote.php/webdav/client_sync_test.txt", nil) getReq.Header.Set("Authorization", "Bearer "+tokenData.AccessToken) getResp, err := client.Do(getReq)