Files
OpenList/server/mcp/handler_test.go
cff5597863 fix(webdav)!: correct COPY/MOVE semantics, dead property persistence, and lock enforcement (#3210)
* fix(webdav): correct COPY/MOVE semantics with named copy support

Signed-off-by: Lythen <intro-iu@outlook.com>

* feat(webdav): persist dead properties with MOVE migration

Signed-off-by: Lythen <intro-iu@outlook.com>

* fix(webdav): enforce locks on resolved paths, support shared locks

Signed-off-by: Lythen <intro-iu@outlook.com>

* fix(fs): fall back to transfer task for same-dir copy

Signed-off-by: Lythen <intro-iu@outlook.com>

* fix(webdav): escape LIKE patterns, check dstDir is dir, bound PROPFIND body

Signed-off-by: Lythen <intro-iu@outlook.com>

* fix(webdav): escape backslash in LIKE, reduce dead prop retry

Signed-off-by: Lythen <intro-iu@outlook.com>

* fix(webdav): isolate shared lock instances

- Track each lock token with independent details and expiry state.\n- Keep resource nodes for path conflict accounting only.\n- Cover independent shared-lock expiration in memLS tests.

* fix(webdav): preserve dead property lifecycle

- Copy and replace dead properties with COPY destinations.\n- Remove dead-property subtrees after successful DELETE.\n- Stage MOVE properties before clearing the destination subtree.\n- Add database-backed regression coverage for copy and delete.

* fix(webdav): protect overwrite copy and move

- Validate source objects before touching overwrite destinations.\n- Stage COPY content under a collision-free name and restore destinations on failure.\n- Preserve MOVE destinations with a backup until the resource operation succeeds.\n- Route named same-storage copies through the direct destination-name transfer path.

* fix(webdav): harden overwrite staging

- Skip NoOverwriteUpload backup handling for collision-free internal COPY staging.\n- Treat post-commit overwrite backup cleanup failures as warnings.

* test(webdav): update lock lookup expectations

- Compare lookup results with independent lock instances.\n- Keep invalid token expectations nil after the lock state split.

* fix(webdav): complete litmus compliance

- Preserve named same-storage collection copies with isolated native staging.\n- Evaluate WebDAV ETag conditions and distinguish 412 from 423 lock failures.\n- Return 201 for lock-null resources and cover the new condition behavior.

* fix(fs): harden named copy staging

- retry staging cleanup from the deferred path and report residual directories

- document hook context semantics and wrap staging operation errors

Signed-off-by: Lythen <intro-iu@outlook.com>

* fix(core): support Go 1.27 checks

- Replace dynamic format strings with explicit format verbs and guard optional network configuration.

- Stabilize multipart cleanup, proxy, search, and MCP tests.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

* fix(webdav): coordinate file and property transactions

- Keep MOVE, COPY, and DELETE file changes coordinated with dead-property transactions.

- Compensate filesystem changes when file operations or database commits fail.

- Add WebDAV coverage for transaction and recovery failures.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

* fix(webdav): reject writes without parent collections

- Require an existing parent collection for PUT.

- Reject MKCOL when its parent is missing or not a collection.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

* fix(webdav): refresh parent before delete checks

- Refresh the parent listing before checking whether a DELETE target exists.

- Return 404 for missing resources, including storage-not-found errors.

Co-authored-by: Codex <267193182+codex@users.noreply.github.com>

---------

Signed-off-by: Lythen <intro-iu@outlook.com>
Co-authored-by: Intro <Intro-iu@outlook.com>
Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
2026-10-20 11:12:10 +08:00

574 lines
16 KiB
Go

package mcp
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/gin-gonic/gin"
)
func TestInitializeCreatesSession(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(nil)
r := gin.New()
r.POST("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handlePost(c)
})
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
"jsonrpc":"2.0",
"id":1,
"method":"initialize",
"params":{
"protocolVersion":"2025-11-25",
"capabilities":{},
"clientInfo":{"name":"test-client","version":"1.0.0"}
}
}`))
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Origin", "http://example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK)
}
if got := w.Header().Get(SessionHeader); got == "" {
t.Fatal("expected session header to be set")
}
var resp response
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Error != nil {
t.Fatalf("unexpected error response: %+v", resp.Error)
}
result, ok := resp.Result.(map[string]any)
if !ok {
t.Fatalf("unexpected result type: %T", resp.Result)
}
if result["protocolVersion"] != ProtocolVersion {
t.Fatalf("unexpected protocol version: %v", result["protocolVersion"])
}
}
func TestInitializeNegotiatesSupportedOlderProtocolVersion(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(nil)
r := gin.New()
r.POST("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handlePost(c)
})
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
"jsonrpc":"2.0",
"id":1,
"method":"initialize",
"params":{
"protocolVersion":"2025-06-18",
"capabilities":{},
"clientInfo":{"name":"test-client","version":"1.0.0"}
}
}`))
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Origin", "http://example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK)
}
resp := decodeResponse(t, w)
if resp.Error != nil {
t.Fatalf("unexpected error response: %+v", resp.Error)
}
result, ok := resp.Result.(map[string]any)
if !ok {
t.Fatalf("unexpected result type: %T", resp.Result)
}
if result["protocolVersion"] != "2025-06-18" {
t.Fatalf("unexpected protocol version: %v", result["protocolVersion"])
}
}
func TestInitializeReusesExistingSession(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(nil)
currentSession := srv.createSession(1)
currentSession.initialized = true
r := gin.New()
r.POST("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handlePost(c)
})
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
"jsonrpc":"2.0",
"id":1,
"method":"initialize",
"params":{
"protocolVersion":"2025-06-18",
"capabilities":{},
"clientInfo":{"name":"test-client","version":"1.0.0"}
}
}`))
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Origin", "http://example.com")
req.Header.Set(SessionHeader, currentSession.id)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK)
}
if got := w.Header().Get(SessionHeader); got != currentSession.id {
t.Fatalf("unexpected session header: got %q want %q", got, currentSession.id)
}
if len(srv.sessions) != 1 {
t.Fatalf("unexpected session count: got %d want %d", len(srv.sessions), 1)
}
reusedSession, ok := srv.getSession(currentSession.id)
if !ok {
t.Fatal("expected existing session to be reused")
}
if reusedSession.initialized {
t.Fatal("expected reused session to require initialized notification again")
}
if reusedSession.protocolVersion != "2025-06-18" {
t.Fatalf("unexpected protocol version: got %q want %q", reusedSession.protocolVersion, "2025-06-18")
}
}
func TestInitializeNegotiatesUnsupportedProtocolVersion(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(nil)
r := gin.New()
r.POST("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handlePost(c)
})
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
"jsonrpc":"2.0",
"id":1,
"method":"initialize",
"params":{
"protocolVersion":"2026-01-01",
"capabilities":{},
"clientInfo":{"name":"test-client","version":"1.0.0"}
}
}`))
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Origin", "http://example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK)
}
resp := decodeResponse(t, w)
if resp.Error != nil {
t.Fatalf("unexpected error response: %+v", resp.Error)
}
result, ok := resp.Result.(map[string]any)
if !ok {
t.Fatalf("unexpected result type: %T", resp.Result)
}
if result["protocolVersion"] != ProtocolVersion {
t.Fatalf("unexpected protocol version: %v", result["protocolVersion"])
}
}
func TestCreateSessionPrunesUserSessionLimit(t *testing.T) {
srv := newTestServer(nil)
var firstSessionID string
baseTime := time.Now()
for i := range maxUserSessions {
currentSession := srv.createSession(1)
currentSession.lastUsedAt = baseTime.Add(time.Duration(i))
if i == 0 {
firstSessionID = currentSession.id
}
}
srv.createSession(2)
srv.createSession(1)
if _, ok := srv.sessions[firstSessionID]; ok {
t.Fatal("expected oldest user session to be pruned")
}
if got := countSessionsForUser(srv, 1); got != maxUserSessions {
t.Fatalf("unexpected user session count: got %d want %d", got, maxUserSessions)
}
if got := len(srv.sessions); got != maxUserSessions+1 {
t.Fatalf("unexpected total session count: got %d want %d", got, maxUserSessions+1)
}
}
func TestCreateSessionPrunesGlobalSessionLimit(t *testing.T) {
srv := newTestServer(nil)
var firstSessionID string
baseTime := time.Now()
for i := range maxSessions {
currentSession := srv.createSession(uint(i + 1))
currentSession.lastUsedAt = baseTime.Add(time.Duration(i))
if i == 0 {
firstSessionID = currentSession.id
}
}
srv.createSession(uint(maxSessions + 1))
if _, ok := srv.sessions[firstSessionID]; ok {
t.Fatal("expected oldest global session to be pruned")
}
if got := len(srv.sessions); got != maxSessions {
t.Fatalf("unexpected session count: got %d want %d", got, maxSessions)
}
}
func TestPostAcceptsJSONOnly(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(nil)
r := gin.New()
r.POST("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handlePost(c)
})
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
"jsonrpc":"2.0",
"id":1,
"method":"initialize"
}`))
req.Header.Set("Accept", "application/json")
req.Header.Set("Origin", "http://example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK)
}
resp := decodeResponse(t, w)
if resp.Error != nil {
t.Fatalf("unexpected error response: %+v", resp.Error)
}
}
func TestPostAcceptsWildcardAccept(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(nil)
r := gin.New()
r.POST("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handlePost(c)
})
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
"jsonrpc":"2.0",
"id":1,
"method":"initialize"
}`))
req.Header.Set("Accept", "*/*")
req.Header.Set("Origin", "http://example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK)
}
resp := decodeResponse(t, w)
if resp.Error != nil {
t.Fatalf("unexpected error response: %+v", resp.Error)
}
}
func TestPostInvalidAcceptReturnsJSONRPCError(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(nil)
r := gin.New()
r.POST("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handlePost(c)
})
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
"jsonrpc":"2.0",
"id":1,
"method":"initialize"
}`))
req.Header.Set("Accept", "image/png")
req.Header.Set("Origin", "http://example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotAcceptable {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusNotAcceptable)
}
resp := decodeResponse(t, w)
if resp.Error == nil || resp.Error.Code != -32000 {
t.Fatalf("unexpected error response: %+v", resp.Error)
}
}
func TestPostUsesNegotiatedProtocolVersionWhenHeaderMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(map[string]*session{
"s1": {id: "s1", userID: 1, protocolVersion: "2025-06-18", initialized: true},
})
r := gin.New()
r.POST("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handlePost(c)
})
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
"jsonrpc":"2.0",
"id":1,
"method":"tools/list"
}`))
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Origin", "http://example.com")
req.Header.Set(SessionHeader, "s1")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK)
}
resp := decodeResponse(t, w)
if resp.Error != nil {
t.Fatalf("unexpected error response: %+v", resp.Error)
}
}
func TestPostRejectsUnsupportedProtocolVersion(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(map[string]*session{
"s1": {id: "s1", userID: 1, initialized: true},
})
r := gin.New()
r.POST("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handlePost(c)
})
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
"jsonrpc":"2.0",
"id":1,
"method":"tools/list"
}`))
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Origin", "http://example.com")
req.Header.Set(ProtocolVersionHeader, "2025-03-26")
req.Header.Set(SessionHeader, "s1")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusBadRequest)
}
resp := decodeResponse(t, w)
if resp.Error == nil || resp.Error.Code != -32000 {
t.Fatalf("unexpected error response: %+v", resp.Error)
}
}
func TestPostRejectsProtocolVersionMismatch(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(map[string]*session{
"s1": {id: "s1", userID: 1, protocolVersion: "2025-06-18", initialized: true},
})
r := gin.New()
r.POST("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handlePost(c)
})
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
"jsonrpc":"2.0",
"id":1,
"method":"tools/list"
}`))
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Origin", "http://example.com")
req.Header.Set(ProtocolVersionHeader, ProtocolVersion)
req.Header.Set(SessionHeader, "s1")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusBadRequest)
}
resp := decodeResponse(t, w)
if resp.Error == nil || resp.Error.Code != -32000 {
t.Fatalf("unexpected error response: %+v", resp.Error)
}
}
func TestPostRejectsMissingSession(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(nil)
r := gin.New()
r.POST("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handlePost(c)
})
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
"jsonrpc":"2.0",
"id":1,
"method":"tools/list"
}`))
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Origin", "http://example.com")
req.Header.Set(ProtocolVersionHeader, ProtocolVersion)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusBadRequest)
}
resp := decodeResponse(t, w)
if resp.Error == nil || resp.Error.Code != -32000 {
t.Fatalf("unexpected error response: %+v", resp.Error)
}
}
func TestPostRejectsUnknownSessionWithNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(nil)
r := gin.New()
r.POST("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handlePost(c)
})
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
"jsonrpc":"2.0",
"id":1,
"method":"tools/list"
}`))
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Origin", "http://example.com")
req.Header.Set(ProtocolVersionHeader, ProtocolVersion)
req.Header.Set(SessionHeader, "unknown")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusNotFound)
}
resp := decodeResponse(t, w)
if resp.Error == nil || resp.Error.Code != -32001 {
t.Fatalf("unexpected error response: %+v", resp.Error)
}
}
func TestDeleteRemovesSession(t *testing.T) {
gin.SetMode(gin.TestMode)
srv := newTestServer(nil)
currentSession := srv.createSession(1)
r := gin.New()
r.DELETE("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
srv.handleDelete(c)
})
req := httptest.NewRequest(http.MethodDelete, "http://example.com/mcp", nil)
req.Header.Set("Origin", "http://example.com")
req.Header.Set(SessionHeader, currentSession.id)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusNoContent)
}
if _, ok := srv.getSession(currentSession.id); ok {
t.Fatal("expected session to be deleted")
}
}
func TestGetReturnsMethodNotAllowed(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/mcp", func(c *gin.Context) {
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
defaultServer.handleGet(c)
})
req := httptest.NewRequest(http.MethodGet, "http://example.com/mcp", nil)
req.Header.Set("Origin", "http://example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusMethodNotAllowed)
}
if allow := w.Header().Get("Allow"); allow != "POST, DELETE" {
t.Fatalf("unexpected Allow header: got %q want %q", allow, "POST, DELETE")
}
}
func newTestServer(sessions map[string]*session) *Server {
if sessions == nil {
sessions = map[string]*session{}
}
now := time.Now()
for _, currentSession := range sessions {
if currentSession.createdAt.IsZero() {
currentSession.createdAt = now
}
if currentSession.lastUsedAt.IsZero() {
currentSession.lastUsedAt = now
}
}
return &Server{sessions: sessions}
}
func countSessionsForUser(srv *Server, userID uint) int {
count := 0
for _, currentSession := range srv.sessions {
if currentSession != nil && currentSession.userID == userID {
count++
}
}
return count
}