Files
nezha_domains/cmd/dashboard/controller/mcp_tools_server_test.go
T
naiba 083bc985c5 feat(auth): split inventory scope out of server scope
Carve a new nezha:inventory:{read,delete,*} resource family out of
nezha:server:*. Listing and deleting servers/server-groups (GET /server,
/server-group, /ws/server, batch-delete/server[-group], MCP server.list)
now require nezha:inventory:*, while nezha:server:* covers per-server
runtime operations (get, exec, fs, config, metrics, batch-move,
force-update).

Also declare MCP tool OutputSchema for exec/fs/meta/server/transfer and
wrap server.list output in {servers,count} so strict MCP clients accept
the structured result. Correct the /file all-of scope entry and the stale
server:read documentation.
2026-05-31 15:14:41 +00:00

123 lines
3.9 KiB
Go

package controller
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func TestServerList_FiltersByPermission(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
srv2 := &model.Server{}
srv2.ID = 8
srv2.Name = "beta"
srv2.SetUserID(999)
singleton.ServerShared.InsertForTest(srv2)
tok, _ := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil)
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: json.RawMessage("{}")}),
})
mcpEndpoint(c)
_, tcr := decodeRPC(w)
require.False(t, tcr.IsError)
rows := decodeServerListRows(t, tcr.StructuredContent)
require.Len(t, rows, 1, "must filter out non-owned server")
require.EqualValues(t, 7, rows[0]["id"])
}
// decodeServerListRows unwraps the {servers,count} object that server.list now
// returns (MCP requires structuredContent to be an object, not a bare array)
// and asserts count stays in sync with the servers slice length.
func decodeServerListRows(t *testing.T, structured any) []map[string]any {
t.Helper()
rb, _ := json.Marshal(structured)
var res struct {
Servers []map[string]any `json:"servers"`
Count int `json:"count"`
}
require.NoError(t, json.Unmarshal(rb, &res))
require.Equal(t, len(res.Servers), res.Count, "count must match servers length")
return res.Servers
}
func TestServerList_ServerWhitelistFurtherFiltering(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
srv2 := &model.Server{}
srv2.ID = 8
srv2.Name = "beta"
srv2.SetUserID(uid)
singleton.ServerShared.InsertForTest(srv2)
tok, _ := mkToken(t, uid, []string{model.ScopeInventoryRead}, []uint64{8})
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: json.RawMessage("{}")}),
})
mcpEndpoint(c)
_, tcr := decodeRPC(w)
require.False(t, tcr.IsError)
rows := decodeServerListRows(t, tcr.StructuredContent)
require.Len(t, rows, 1)
require.EqualValues(t, 8, rows[0]["id"])
}
func TestServerList_OnlineOnlyFilter(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
srv, _ := singleton.ServerShared.Get(7)
require.NotNil(t, srv)
srv.LastActive = time.Now()
tok, _ := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil)
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: jsonRaw(map[string]any{"online_only": true})}),
})
mcpEndpoint(c)
_, tcr := decodeRPC(w)
require.False(t, tcr.IsError)
rows := decodeServerListRows(t, tcr.StructuredContent)
require.Len(t, rows, 1)
}
func TestServerGet_RequiresServerID(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
Params: jsonObj(t, toolCallParams{Name: "server.get", Arguments: json.RawMessage("{}")}),
})
mcpEndpoint(c)
_, tcr := decodeRPC(w)
require.True(t, tcr.IsError)
require.Contains(t, tcr.Content[0].Text, "server_id required")
}
func TestServerExec_ScopeMissing(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
Params: jsonObj(t, toolCallParams{Name: "server.exec", Arguments: jsonRaw(map[string]any{"server_id": 7, "cmd": "echo"})}),
})
mcpEndpoint(c)
_, tcr := decodeRPC(w)
require.True(t, tcr.IsError)
require.Contains(t, tcr.Content[0].Text, "nezha:server:exec")
}