Files
l-ui/internal/web/service/inbound_node_move_test.go
T

311 lines
10 KiB
Go

package service
import (
"encoding/json"
"fmt"
"strings"
"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/xray"
)
// moveTestNode creates a plain online node row for move tests. Nodes
// are never dialed anymore, so no fake panel stands behind the row.
func moveTestNode(t *testing.T, name string) *model.Node {
t.Helper()
n := &model.Node{
Name: name,
Scheme: "http",
Address: "127.0.0.1",
Port: 2053,
BasePath: "/",
Enable: true,
Status: "online",
InboundsAdoptedAt: 1,
}
if err := database.GetDB().Create(n).Error; err != nil {
t.Fatalf("create node: %v", err)
}
return n
}
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)
nodeA := moveTestNode(t, "move-a")
nodeB := moveTestNode(t, "move-b")
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)
}
// Convergence itself is the nodes' next config pull (both sides are
// dirty-marked above); the master no longer pushes to node panels.
}
// 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)
nodeA := moveTestNode(t, "tag-a")
nodeB := moveTestNode(t, "tag-b")
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)
nodePanel := moveTestNode(t, "cfg-panel")
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)
}
}