311 lines
10 KiB
Go
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(¤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)
|
|
}
|
|
}
|