P8(2): inbound node reassignment (move) with backend re-homing

An inbound's 部署到 node can now be changed after creation; the row,
clients, client_traffics and subscription output stay untouched (same
inbound id throughout).

Contract: the update payload's nodeId counts only when the key was
explicitly sent — the controller probes the raw JSON body and sets
Inbound.NodeIDProvided; the wire adapter now always emits nodeId (null =
local) so clearing the picker is an explicit move-to-local, while
callers that omit nodeId keep the stored assignment (legacy behaviour).

UpdateInbound resolves the target before the port-conflict check (so
conflicts are scoped to the target node), rejects moves to a missing
node, re-derives auto tags under the target node's prefix, validates
the tag against the target node's namespace, and marks BOTH nodes
dirty: the source node's next reconcile sweeps the old tag (no silent
ghost — the flag persists until a reconcile succeeds, so an offline
side just defers), the target's pushes the inbound. Agent-bound nodes
converge via GetXrayConfigForAgent on the agent's next config poll.
A local side has no reconcile loop, so it is applied directly
post-commit (Del/Add on the local runtime, restart fallback).

Frontend: the deploy-to Select is no longer disabled in edit mode
(protocol stays locked); candidate rules unchanged (online nodes only).

Tests: panel->panel move with reconcile sweep/push assertions,
auto-tag prefix regeneration, agent-config flip in both directions,
omitted-nodeId legacy contract, same-node no-op, missing-node
rejection, node->local move.
This commit is contained in:
Muse
2026-10-21 13:49:57 +00:00
parent 261a1f6787
commit 7284fa6cf0
7 changed files with 572 additions and 13 deletions
+7
View File
@@ -87,6 +87,13 @@ type Inbound struct {
// the master's externally reachable endpoint instead of the child's
// loopback listen. Not persisted.
FallbackParent *FallbackParentInfo `json:"fallbackParent,omitempty" gorm:"-"`
// NodeIDProvided reports whether an update payload explicitly carried a
// nodeId key, as opposed to omitting it. The inbound controller sets it
// from the raw request body; UpdateInbound only treats the payload's
// NodeID as a (re)assignment target when it is set — an omitted nodeId
// keeps the stored assignment. Not persisted, not serialized.
NodeIDProvided bool `json:"-" gorm:"-"`
}
// FallbackParentInfo carries everything the frontend needs to rewrite a
+30 -4
View File
@@ -1,7 +1,9 @@
package controller
import (
"bytes"
"encoding/json"
"io"
"net"
"strconv"
"strings"
@@ -222,6 +224,27 @@ func (a *InboundController) bulkDelInbounds(c *gin.Context) {
notifyClientsChanged()
}
// requestHasJSONKey reports whether the request body is a JSON object that
// explicitly carries key — present with a null value counts. The body is
// restored so the normal bind can read it again. Non-JSON bodies (form
// posts) report false for every key.
func requestHasJSONKey(c *gin.Context, key string) bool {
if c.Request == nil || c.Request.Body == nil {
return false
}
body, err := io.ReadAll(c.Request.Body)
if err != nil || len(body) == 0 {
return false
}
c.Request.Body = io.NopCloser(bytes.NewReader(body))
var probe map[string]json.RawMessage
if json.Unmarshal(body, &probe) != nil {
return false
}
_, ok := probe[key]
return ok
}
// updateInbound updates an existing inbound configuration.
func (a *InboundController) updateInbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
@@ -232,13 +255,16 @@ func (a *InboundController) updateInbound(c *gin.Context) {
inbound := &model.Inbound{
Id: id,
}
// An explicitly sent nodeId (even null) is a (re)assignment request —
// UpdateInbound migrates the inbound when it differs from the stored
// assignment. An omitted nodeId keeps the stored assignment, so detect
// the key's presence on the raw body before binding consumes it.
nodeIDProvided := requestHasJSONKey(c, "nodeId")
if !middleware.BindAndValidateInto(c, inbound) {
return
}
// Same NodeID=0 → nil normalisation as addInbound. UpdateInbound
// loads the existing row's NodeID from DB anyway (Phase 1 doesn't
// support migrating an inbound between nodes), but normalising here
// keeps the wire shape consistent.
inbound.NodeIDProvided = nodeIDProvided
// Same NodeID=0 → nil normalisation as addInbound.
if inbound.NodeID != nil && *inbound.NodeID == 0 {
inbound.NodeID = nil
}
+95 -4
View File
@@ -1494,9 +1494,26 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
if err != nil {
return inbound, false, err
}
// Restore the stored NodeID before the port-conflict check so a node inbound
// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
inbound.NodeID = oldInbound.NodeID
// Resolve the assignment target before the port-conflict check so the
// inbound is scoped to the node it will live on. The payload's nodeId
// only counts when it was explicitly sent (NodeIDProvided): callers
// often omit it, and an omission must keep the stored assignment rather
// than yank the inbound back to the local panel. An explicit nodeId
// that differs from the stored one is a move — the transaction below
// re-homes the inbound (both nodes marked dirty, local side applied
// directly) without touching its id, clients or traffic rows.
storedNodeID := oldInbound.NodeID
targetNodeID := storedNodeID
if inbound.NodeIDProvided {
targetNodeID = inbound.NodeID
}
nodeChanged := !sameNode(storedNodeID, targetNodeID)
if nodeChanged && targetNodeID != nil {
if _, err := (&NodeService{}).GetById(*targetNodeID); err != nil {
return inbound, false, common.NewErrorf("cannot move inbound to node %d: node not found", *targetNodeID)
}
}
inbound.NodeID = targetNodeID
if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol)
}
@@ -1505,6 +1522,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
// overwritten with the new values further down, then ensure a routed
// inbound keeps a stable egress port (reusing the one already stored).
oldProtocol := oldInbound.Protocol
oldEnable := oldInbound.Enable
oldRoutedMtproto := mtprotoRoutesThroughXray(oldInbound)
if err := s.normalizeMtprotoXrayPort(inbound, oldInbound.Settings); err != nil {
return inbound, false, err
@@ -1645,7 +1663,71 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
oldInbound.Tag = resolvedTag
inbound.Tag = oldInbound.Tag
if oldInbound.NodeID == nil {
// Persist the resolved assignment with the rest of the row. From
// here on oldInbound.NodeID holds the target; the branch below keys
// the move handling off nodeChanged, not off the stored value.
oldInbound.NodeID = targetNodeID
if nodeChanged {
// Move: validate the resolved tag against the target node's
// namespace, exactly like a same-node edit does for its node.
if targetNodeID != nil {
if err := (&NodeService{}).EnsureInboundTagAllowedTx(tx, *targetNodeID, oldInbound.Tag); err != nil {
return err
}
}
// Node sides converge through the dirty marks written at the
// end of this transaction (source + target): the source node's
// next reconcile sweeps the inbound's old tag, the target's
// pushes the inbound — the same backstop DelInbound relies on,
// so an unreachable side defers (pending) instead of silently
// stranding a ghost. A local side has no reconcile loop, so it
// is applied directly post-commit like a normal local edit.
if storedNodeID == nil || targetNodeID == nil {
probe := *oldInbound
probe.NodeID = nil
rt, push, _, perr := s.nodePushPlan(&probe)
if perr != nil {
return perr
}
if !push {
needRestart = true
} else {
var runtimeInbound *model.Inbound
if targetNodeID == nil && inbound.Enable {
built, bErr := s.buildInboundForLocalRuntime(tx, oldInbound)
if bErr != nil {
logger.Debug("Unable to prepare runtime inbound config:", bErr)
needRestart = true
} else {
runtimeInbound = built
}
}
oldSnapshot := *oldInbound
oldSnapshot.Tag = tag
oldSnapshot.NodeID = storedNodeID
removeFromLocal := storedNodeID == nil && oldEnable
postCommitApply = func() {
if removeFromLocal {
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
logger.Debug("Moved inbound deleted on", rt.Name(), ":", tag)
} else {
logger.Debug("Unable to delete moved inbound on", rt.Name(), ":", err2)
needRestart = true
}
}
if runtimeInbound == nil {
return
}
if err2 := rt.AddInbound(context.Background(), runtimeInbound); err2 == nil {
logger.Debug("Moved inbound added on", rt.Name(), ":", oldInbound.Tag)
} else {
logger.Debug("Unable to add moved inbound on", rt.Name(), ":", err2)
needRestart = true
}
}
}
}
} else if oldInbound.NodeID == nil {
rt, push, _, perr := s.nodePushPlan(oldInbound)
if perr != nil {
return perr
@@ -1728,6 +1810,15 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
return err
}
}
if nodeChanged && storedNodeID != nil {
// The source node must reconcile too: its sweep is what removes
// the moved inbound's old tag from that node. The flag stays set
// until a reconcile succeeds, so an offline source defers the
// cleanup instead of dropping it.
if err := (&NodeService{}).MarkNodeDirtyTx(tx, *storedNodeID); err != nil {
return err
}
}
// (Re)generate the Xray config whenever routing was or is now enabled, so
// the egress SOCKS bridge is added, moved, or dropped to match the new
// settings.
@@ -0,0 +1,432 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strconv"
"strings"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// statefulFakePanel is a node panel double with real state: added inbounds
// appear in its list, deleted ones disappear. It records every mutation so
// move tests can assert what reconcile actually pushed where.
type statefulFakePanel struct {
mu sync.Mutex
byTag map[string]int
byID map[int]string
nextID int
added []string
updated []int
deleted []int
}
func newStatefulFakePanel(t *testing.T, seed map[string]int) (*httptest.Server, *statefulFakePanel) {
t.Helper()
p := &statefulFakePanel{byTag: map[string]int{}, byID: map[int]string{}, nextID: 1000}
for tag, id := range seed {
p.byTag[tag] = id
p.byID[id] = tag
}
writeOK := func(w http.ResponseWriter, obj any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
}
mux := http.NewServeMux()
mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
type row struct {
Id int `json:"id"`
Tag string `json:"tag"`
Port int `json:"port"`
Protocol string `json:"protocol"`
Listen string `json:"listen"`
}
p.mu.Lock()
rows := make([]row, 0, len(p.byTag))
for tag, id := range p.byTag {
rows = append(rows, row{Id: id, Tag: tag})
}
p.mu.Unlock()
writeOK(w, rows)
})
mux.HandleFunc("/panel/api/inbounds/add", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
tag := r.Form.Get("tag")
p.mu.Lock()
p.nextID++
id := p.nextID
p.byTag[tag] = id
p.byID[id] = tag
p.added = append(p.added, tag)
p.mu.Unlock()
writeOK(w, map[string]any{"id": id, "tag": tag})
})
mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/update/"))
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
p.mu.Lock()
p.updated = append(p.updated, id)
p.mu.Unlock()
writeOK(w, nil)
})
mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
p.mu.Lock()
if tag, ok := p.byID[id]; ok {
delete(p.byID, id)
delete(p.byTag, tag)
}
p.deleted = append(p.deleted, id)
p.mu.Unlock()
writeOK(w, nil)
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts, p
}
func (p *statefulFakePanel) tags() []string {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]string, 0, len(p.byTag))
for tag := range p.byTag {
out = append(out, tag)
}
sort.Strings(out)
return out
}
func (p *statefulFakePanel) addedTags() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.added...)
}
func (p *statefulFakePanel) deletedIDs() []int {
p.mu.Lock()
defer p.mu.Unlock()
return append([]int(nil), p.deleted...)
}
const moveTestSettings = `{"clients":[{"id":"11111111-2222-3333-4444-555555555555","email":"mv@example.com","enable":true}]}`
func seedMoveInbound(t *testing.T, tag string, port int, nodeID *int) model.Inbound {
t.Helper()
seedInboundConflictNode(t, tag, "", port, model.VLESS, `{"network":"tcp"}`, moveTestSettings, nodeID)
var ib model.Inbound
if err := database.GetDB().Where("tag = ?", tag).First(&ib).Error; err != nil {
t.Fatalf("read seeded inbound: %v", err)
}
ct := &xray.ClientTraffic{InboundId: ib.Id, Enable: true, Email: "mv@example.com", Up: 111, Down: 222}
if err := database.GetDB().Create(ct).Error; err != nil {
t.Fatalf("seed client traffic: %v", err)
}
return ib
}
func reloadInbound(t *testing.T, id int) model.Inbound {
t.Helper()
var ib model.Inbound
if err := database.GetDB().First(&ib, id).Error; err != nil {
t.Fatalf("reload inbound %d: %v", id, err)
}
return ib
}
func nodeDirty(t *testing.T, id int) bool {
t.Helper()
var n model.Node
if err := database.GetDB().First(&n, id).Error; err != nil {
t.Fatalf("reload node %d: %v", id, err)
}
return n.ConfigDirty
}
func clientTrafficTotals(t *testing.T, email string) (int64, int64) {
t.Helper()
var ct xray.ClientTraffic
if err := database.GetDB().Where("email = ?", email).First(&ct).Error; err != nil {
t.Fatalf("read client traffic: %v", err)
}
return ct.Up, ct.Down
}
// The headline move: panel node → panel node. The row keeps its id, clients
// and traffic; both nodes are marked dirty; reconcile then deletes the
// inbound from the source panel and pushes it to the target panel.
func TestUpdateInbound_MoveBetweenPanelNodes(t *testing.T) {
setupConflictDB(t)
tsA, panelA := newStatefulFakePanel(t, map[string]int{"movable": 11})
tsB, panelB := newStatefulFakePanel(t, nil)
nodeA := reconcileTestNode(t, tsA, "move-a", "all", nil)
nodeB := reconcileTestNode(t, tsB, "move-b", "all", nil)
seeded := seedMoveInbound(t, "movable", 2443, &nodeA.Id)
svc := &InboundService{}
update := seeded
update.NodeID = &nodeB.Id
update.NodeIDProvided = true
if _, _, err := svc.UpdateInbound(&update); err != nil {
t.Fatalf("UpdateInbound move: %v", err)
}
after := reloadInbound(t, seeded.Id)
if after.NodeID == nil || *after.NodeID != nodeB.Id {
t.Fatalf("node_id after move = %v, want %d", after.NodeID, nodeB.Id)
}
if after.Tag != "movable" {
t.Fatalf("custom tag changed across move: %q", after.Tag)
}
if !nodeDirty(t, nodeA.Id) || !nodeDirty(t, nodeB.Id) {
t.Fatalf("both nodes must be dirty after a move (A=%v B=%v)", nodeDirty(t, nodeA.Id), nodeDirty(t, nodeB.Id))
}
if up, down := clientTrafficTotals(t, "mv@example.com"); up != 111 || down != 222 {
t.Fatalf("client traffic changed across move: up=%d down=%d, want 111/222", up, down)
}
// Reconcile the source: the moved inbound must be swept from panel A.
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(nodeA, nil), nodeA); err != nil {
t.Fatalf("ReconcileNode source: %v", err)
}
if got := panelA.deletedIDs(); len(got) != 1 || got[0] != 11 {
t.Fatalf("source panel deletions = %v, want [11]", got)
}
if tags := panelA.tags(); len(tags) != 0 {
t.Fatalf("source panel still holds tags %v after reconcile", tags)
}
// Reconcile the target: the inbound must be pushed to panel B.
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(nodeB, nil), nodeB); err != nil {
t.Fatalf("ReconcileNode target: %v", err)
}
if got := panelB.addedTags(); len(got) != 1 || got[0] != "movable" {
t.Fatalf("target panel additions = %v, want [movable]", got)
}
if tags := panelB.tags(); len(tags) != 1 || tags[0] != "movable" {
t.Fatalf("target panel tags = %v, want [movable]", tags)
}
}
// An auto-generated tag carries the node prefix: a move re-derives it under
// the target node's prefix (the row — and its clients — stay the same).
func TestUpdateInbound_MoveRegeneratesAutoTagPrefix(t *testing.T) {
setupConflictDB(t)
tsA, _ := newStatefulFakePanel(t, nil)
tsB, _ := newStatefulFakePanel(t, nil)
nodeA := reconcileTestNode(t, tsA, "tag-a", "all", nil)
nodeB := reconcileTestNode(t, tsB, "tag-b", "all", nil)
oldTag := fmt.Sprintf("n%d-in-2444-tcp", nodeA.Id)
seeded := seedMoveInbound(t, oldTag, 2444, &nodeA.Id)
svc := &InboundService{}
update := seeded
update.NodeID = &nodeB.Id
update.NodeIDProvided = true
if _, _, err := svc.UpdateInbound(&update); err != nil {
t.Fatalf("UpdateInbound move: %v", err)
}
after := reloadInbound(t, seeded.Id)
want := fmt.Sprintf("n%d-in-2444-tcp", nodeB.Id)
if after.Tag != want {
t.Fatalf("auto tag after move = %q, want %q", after.Tag, want)
}
}
// Agent-bound nodes take their whole config from GetXrayConfigForAgent, so a
// move must flip which node's generated config carries the inbound — in
// both directions (agent → panel as well as panel → agent).
func TestUpdateInbound_MoveFlipsAgentConfig(t *testing.T) {
setupConflictDB(t)
tsA, _ := newStatefulFakePanel(t, nil)
nodePanel := reconcileTestNode(t, tsA, "cfg-panel", "all", nil)
agentNode := &model.Node{Name: "cfg-agent", Enable: true, Status: "online", InboundsAdoptedAt: 1}
if err := database.GetDB().Create(agentNode).Error; err != nil {
t.Fatalf("create agent node row: %v", err)
}
agentHasTag := func() bool {
cfg, err := (&XrayService{}).GetXrayConfigForAgent(agentNode.Id, "", "")
if err != nil {
t.Fatalf("GetXrayConfigForAgent: %v", err)
}
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal agent config: %v", err)
}
return strings.Contains(string(raw), `"movable"`)
}
seeded := seedMoveInbound(t, "movable", 2445, &agentNode.Id)
if !agentHasTag() {
t.Fatal("agent config should contain the inbound before the move")
}
svc := &InboundService{}
// agent → panel
update := seeded
update.NodeID = &nodePanel.Id
update.NodeIDProvided = true
if _, _, err := svc.UpdateInbound(&update); err != nil {
t.Fatalf("UpdateInbound agent->panel: %v", err)
}
if agentHasTag() {
t.Fatal("agent config still contains the inbound after agent->panel move")
}
if !nodeDirty(t, agentNode.Id) || !nodeDirty(t, nodePanel.Id) {
t.Fatal("both nodes must be dirty after agent->panel move")
}
// panel → agent (fresh read so the payload matches the stored row)
current := reloadInbound(t, seeded.Id)
current.NodeID = &agentNode.Id
current.NodeIDProvided = true
if _, _, err := svc.UpdateInbound(&current); err != nil {
t.Fatalf("UpdateInbound panel->agent: %v", err)
}
if !agentHasTag() {
t.Fatal("agent config should contain the inbound after panel->agent move")
}
if up, down := clientTrafficTotals(t, "mv@example.com"); up != 111 || down != 222 {
t.Fatalf("client traffic changed across moves: up=%d down=%d, want 111/222", up, down)
}
}
// Without NodeIDProvided the payload's nodeId is ignored entirely — both an
// omission (nil) and a stray different value keep the stored assignment.
// This is the legacy contract every non-form caller relies on.
func TestUpdateInbound_OmittedNodeIdKeepsAssignment(t *testing.T) {
setupConflictDB(t)
nodeA := &model.Node{Name: "keep-a", Enable: true, Status: "online", InboundsAdoptedAt: 1}
nodeB := &model.Node{Name: "keep-b", Enable: true, Status: "online", InboundsAdoptedAt: 1}
if err := database.GetDB().Create(nodeA).Error; err != nil {
t.Fatalf("create node A: %v", err)
}
if err := database.GetDB().Create(nodeB).Error; err != nil {
t.Fatalf("create node B: %v", err)
}
seeded := seedMoveInbound(t, "movable", 2446, &nodeA.Id)
svc := &InboundService{}
omitted := seeded
omitted.NodeID = nil
if _, _, err := svc.UpdateInbound(&omitted); err != nil {
t.Fatalf("UpdateInbound omitted nodeId: %v", err)
}
if after := reloadInbound(t, seeded.Id); after.NodeID == nil || *after.NodeID != nodeA.Id {
t.Fatalf("omitted nodeId moved the inbound: node_id=%v", after.NodeID)
}
stray := reloadInbound(t, seeded.Id)
stray.NodeID = &nodeB.Id // payload value without the provided flag
if _, _, err := svc.UpdateInbound(&stray); err != nil {
t.Fatalf("UpdateInbound stray nodeId: %v", err)
}
if after := reloadInbound(t, seeded.Id); after.NodeID == nil || *after.NodeID != nodeA.Id {
t.Fatalf("unflagged nodeId moved the inbound: node_id=%v", after.NodeID)
}
}
// An explicit nodeId equal to the stored one is a plain edit, not a move.
func TestUpdateInbound_ExplicitSameNodeIsPlainEdit(t *testing.T) {
setupConflictDB(t)
nodeA := &model.Node{Name: "same-a", Enable: true, Status: "online", InboundsAdoptedAt: 1}
if err := database.GetDB().Create(nodeA).Error; err != nil {
t.Fatalf("create node: %v", err)
}
seeded := seedMoveInbound(t, "movable", 2447, &nodeA.Id)
svc := &InboundService{}
update := seeded
update.NodeIDProvided = true
update.Remark = "edited"
if _, _, err := svc.UpdateInbound(&update); err != nil {
t.Fatalf("UpdateInbound same-node edit: %v", err)
}
after := reloadInbound(t, seeded.Id)
if after.NodeID == nil || *after.NodeID != nodeA.Id || after.Remark != "edited" {
t.Fatalf("same-node edit misapplied: node=%v remark=%q", after.NodeID, after.Remark)
}
}
// An explicit move to a node that does not exist is rejected and the row is
// left untouched.
func TestUpdateInbound_MoveToMissingNodeRejected(t *testing.T) {
setupConflictDB(t)
nodeA := &model.Node{Name: "miss-a", Enable: true, Status: "online", InboundsAdoptedAt: 1}
if err := database.GetDB().Create(nodeA).Error; err != nil {
t.Fatalf("create node: %v", err)
}
seeded := seedMoveInbound(t, "movable", 2448, &nodeA.Id)
svc := &InboundService{}
update := seeded
missing := 99999
update.NodeID = &missing
update.NodeIDProvided = true
if _, _, err := svc.UpdateInbound(&update); err == nil {
t.Fatal("move to a missing node must be rejected")
}
after := reloadInbound(t, seeded.Id)
if after.NodeID == nil || *after.NodeID != nodeA.Id {
t.Fatalf("rejected move changed the row: node_id=%v", after.NodeID)
}
}
// An explicit null nodeId moves the inbound back to the local panel: the row
// becomes local and the source node is marked dirty so its next reconcile
// sweeps the inbound there.
func TestUpdateInbound_MoveNodeToLocal(t *testing.T) {
setupConflictDB(t)
nodeA := &model.Node{Name: "loc-a", Enable: true, Status: "online", InboundsAdoptedAt: 1}
if err := database.GetDB().Create(nodeA).Error; err != nil {
t.Fatalf("create node: %v", err)
}
seeded := seedMoveInbound(t, "movable", 2449, &nodeA.Id)
svc := &InboundService{}
update := seeded
update.NodeID = nil
update.NodeIDProvided = true
if _, _, err := svc.UpdateInbound(&update); err != nil {
t.Fatalf("UpdateInbound node->local: %v", err)
}
after := reloadInbound(t, seeded.Id)
if after.NodeID != nil {
t.Fatalf("node_id after move to local = %v, want nil", *after.NodeID)
}
if !nodeDirty(t, nodeA.Id) {
t.Fatal("source node must be dirty after a move to local (sweep pending)")
}
if up, down := clientTrafficTotals(t, "mv@example.com"); up != 111 || down != 222 {
t.Fatalf("client traffic changed across move to local: up=%d down=%d", up, down)
}
}