mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-20 18:20:12 +00:00
feat: server transfer rotation
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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"`
|
||||
}
|
||||
Reference in New Issue
Block a user