Files
nezha_domains/cmd/dashboard/controller/transfer_retry_authz_test.go
2026-05-25 10:17:34 +00:00

220 lines
8.2 KiB
Go

package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/i18n"
"github.com/nezhahq/nezha/service/singleton"
)
// retryServerTransfer previously gated on prev.HasPermission(c), which honours
// the historical transfer row (FromUserID, ToUserID, InitiatorID). That lets
// any of those original parties re-initiate a transfer of the server long
// after ownership has moved on. Concretely: a stale "alice -> bob" Failed row
// stays visible to alice forever — even after she's transferred the server
// off to charlie — and the original endpoint would happily move it from
// charlie to bob without ever consulting the current owner.
//
// Authorization for an action that mutates the live server must use the
// live server, not a historical audit row.
func TestRetryServerTransferRejectsCallerWhoNoLongerOwnsServer(t *testing.T) {
cleanup := setupRetryServerTransferFixture(t)
defer cleanup()
// Seed: server originally owned by user 100 (alice). Failed transfer to
// 200 (bob) is recorded but server ownership has since moved to 300
// (charlie) — e.g. alice transferred elsewhere afterwards. Alice is no
// longer the owner, so retrying the stale row would be an unauthorized
// grab.
seedServer(t, 1, 300)
staleID := seedFailedTransfer(t, 1, 100 /*from*/, 200 /*to*/, 100 /*initiator*/)
resp, status := callRetryServerTransfer(t, staleID, 100, model.RoleMember)
assert.Equal(t, http.StatusOK, status)
assert.False(t, resp.Success, "alice no longer owns server 1; retry must be rejected")
assert.Contains(t, resp.Error, "permission denied")
var s model.Server
assert.NoError(t, singleton.DB.First(&s, 1).Error)
assert.Equal(t, uint64(300), s.UserID, "rejected retry must not flip ownership")
var count int64
assert.NoError(t, singleton.DB.Model(&model.ServerTransfer{}).Where("status = ?", model.ServerTransferStatusPending).Count(&count).Error)
assert.Equal(t, int64(0), count, "rejected retry must not create a Pending row")
}
// The historical ToUserID must also not be able to grab the server back via
// the stale row. Same root cause; this is the explicit assertion that the
// fix covers the To side, not just the From side.
func TestRetryServerTransferRejectsHistoricalTargetWhoNeverOwnedServer(t *testing.T) {
cleanup := setupRetryServerTransferFixture(t)
defer cleanup()
seedServer(t, 1, 300)
staleID := seedFailedTransfer(t, 1, 100, 200, 100)
resp, status := callRetryServerTransfer(t, staleID, 200, model.RoleMember)
assert.Equal(t, http.StatusOK, status)
assert.False(t, resp.Success, "bob was the failed transfer's target; he never owned server 1")
assert.Contains(t, resp.Error, "permission denied")
}
// Members never retry: batchMoveServer's "ToUser == self" policy means a
// member can only RECEIVE a server, not give one away. Retry of a failed
// "alice -> bob" by alice (member, current owner) is exactly the give-away
// case batch-move would refuse. Retry is admin-only.
func TestRetryServerTransferRejectsCurrentOwnerWhoIsMember(t *testing.T) {
cleanup := setupRetryServerTransferFixture(t)
defer cleanup()
seedServer(t, 1, 100)
failedID := seedFailedTransfer(t, 1, 100, 200, 100)
resp, status := callRetryServerTransfer(t, failedID, 100, model.RoleMember)
assert.Equal(t, http.StatusOK, status)
assert.False(t, resp.Success, "member retry is forbidden — the give-away semantics bypass batchMoveServer's ToUser==self policy")
assert.Contains(t, resp.Error, "permission denied")
}
// batchMoveServer enforces "non-admin caller may only move a server TO
// themselves" (controller/server.go: ToUser != getUid(c) returns permission
// denied). retryServerTransfer historically only checked the live owner
// and not the transfer's ToUserID, which let the current owner re-push the
// server to ANY historical ToUserID — bypassing the batch-move policy.
//
// Concretely: alice (member) currently owns server 1; she finds a Failed
// transfer whose ToUserID is bob and retries it. The server lands on bob
// even though batch-move would have refused "alice -> bob" from her.
func TestRetryServerTransferRejectsNonAdminPushingToForeignToUserID(t *testing.T) {
cleanup := setupRetryServerTransferFixture(t)
defer cleanup()
seedServer(t, 1, 100)
staleID := seedFailedTransfer(t, 1, 100 /*from*/, 200 /*to*/, 100 /*initiator*/)
resp, status := callRetryServerTransfer(t, staleID, 100, model.RoleMember)
assert.Equal(t, http.StatusOK, status)
assert.False(t, resp.Success, "non-admin owner cannot push their server to a historical foreign ToUserID — that would bypass batchMoveServer's ToUser==self policy")
assert.Contains(t, resp.Error, "permission denied")
var s model.Server
assert.NoError(t, singleton.DB.First(&s, 1).Error)
assert.Equal(t, uint64(100), s.UserID, "rejected retry must not flip ownership")
var count int64
assert.NoError(t, singleton.DB.Model(&model.ServerTransfer{}).Where("status = ?", model.ServerTransferStatusPending).Count(&count).Error)
assert.Equal(t, int64(0), count, "rejected retry must not create a Pending row")
}
// Admins must always be able to retry — they are the last-resort recovery
// path when an operator-cancelled transfer needs to be re-pushed.
func TestRetryServerTransferAllowsAdmin(t *testing.T) {
cleanup := setupRetryServerTransferFixture(t)
defer cleanup()
seedServer(t, 1, 300)
failedID := seedFailedTransfer(t, 1, 100, 200, 100)
resp, status := callRetryServerTransfer(t, failedID, 999, model.RoleAdmin)
assert.Equal(t, http.StatusOK, status)
assert.True(t, resp.Success, "admin must be able to retry any transfer: error=%s", resp.Error)
}
func setupRetryServerTransferFixture(t *testing.T) func() {
t.Helper()
if singleton.Localizer == nil {
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
}
originalDB := singleton.DB
originalShared := singleton.ServerShared
originalTransferShared := singleton.ServerTransferShared
originalUserMap := singleton.UserInfoMap
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&model.Server{}, &model.ServerTransfer{}))
singleton.DB = db
singleton.ServerShared = singleton.NewServerClass()
singleton.UserInfoMap = map[uint64]model.UserInfo{
100: {Role: model.RoleMember, AgentSecret: "alice-secret"},
200: {Role: model.RoleMember, AgentSecret: "bob-secret"},
300: {Role: model.RoleMember, AgentSecret: "charlie-secret"},
}
singleton.ServerTransferShared = singleton.NewServerTransferClass()
return func() {
if singleton.ServerTransferShared != nil {
singleton.ServerTransferShared.Stop()
}
singleton.DB = originalDB
singleton.ServerShared = originalShared
singleton.ServerTransferShared = originalTransferShared
singleton.UserInfoMap = originalUserMap
}
}
func seedServer(t *testing.T, id, ownerID uint64) {
t.Helper()
s := &model.Server{
Common: model.Common{ID: id, UserID: ownerID},
UUID: "uuid-" + strconv.FormatUint(id, 10),
Name: "seeded",
}
assert.NoError(t, singleton.DB.Create(s).Error)
model.InitServer(s)
singleton.ServerShared.Update(s, s.UUID)
}
func seedFailedTransfer(t *testing.T, serverID, fromUserID, toUserID, initiatorID uint64) uint64 {
t.Helper()
tr := &model.ServerTransfer{
ServerID: serverID,
FromUserID: fromUserID,
ToUserID: toUserID,
InitiatorID: initiatorID,
Status: model.ServerTransferStatusFailed,
LastError: "seeded",
}
assert.NoError(t, singleton.DB.Create(tr).Error)
return tr.ID
}
func callRetryServerTransfer(t *testing.T, transferID, callerID uint64, role model.Role) (commonResponseShape, int) {
t.Helper()
r := gin.New()
r.Use(func(c *gin.Context) {
setAuthUser(c, callerID, role)
c.Next()
})
r.POST("/transfer/:id/retry", commonHandler(retryServerTransfer))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/transfer/"+strconv.FormatUint(transferID, 10)+"/retry", bytes.NewReader(nil))
r.ServeHTTP(w, req)
var resp commonResponseShape
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
return resp, w.Code
}
type commonResponseShape struct {
Success bool `json:"success"`
Error string `json:"error"`
}