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(¤t); 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) } }