package service import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "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" ) // fakeNodePanel serves just enough of the node API for ReconcileNode: the // inbound list plus update/del endpoints, recording which remote ids get // deleted. func fakeNodePanel(t *testing.T, tagToID map[string]int) (*httptest.Server, func() []int) { t.Helper() var mu sync.Mutex var deleted []int 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"` } rows := make([]row, 0, len(tagToID)) for tag, id := range tagToID { rows = append(rows, row{Id: id, Tag: tag}) } writeOK(w, rows) }) mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, _ *http.Request) { 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 } mu.Lock() deleted = append(deleted, id) mu.Unlock() writeOK(w, nil) }) ts := httptest.NewServer(mux) t.Cleanup(ts.Close) return ts, func() []int { mu.Lock() defer mu.Unlock() out := append([]int(nil), deleted...) sort.Ints(out) return out } } func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tags []string) *model.Node { t.Helper() u, err := url.Parse(ts.URL) if err != nil { t.Fatalf("parse test server URL: %v", err) } port, err := strconv.Atoi(u.Port()) if err != nil { t.Fatalf("parse test server port: %v", err) } n := &model.Node{ Name: name, Scheme: "http", Address: u.Hostname(), Port: port, BasePath: "/", ApiToken: "tok", Enable: true, AllowPrivateAddress: true, Status: "online", InboundSyncMode: mode, InboundTags: tags, } if err := database.GetDB().Create(n).Error; err != nil { t.Fatalf("create node: %v", err) } return n } // In "selected" sync mode the panel never imports the unselected inbounds, so // reconcile must not treat their absence from the local DB as a deletion: only // a *selected* tag missing locally may be swept from the node. func TestReconcileNode_SelectedModeLeavesUnselectedRemoteInbounds(t *testing.T) { setupConflictDB(t) ts, deletedIDs := fakeNodePanel(t, map[string]int{ "keep": 1, "selected-gone": 2, "unmanaged": 3, }) node := reconcileTestNode(t, ts, "sel-node", "selected", []string{"keep", "selected-gone"}) seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id) svc := InboundService{} if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil { t.Fatalf("ReconcileNode: %v", err) } got := deletedIDs() if len(got) != 1 || got[0] != 2 { t.Fatalf("deleted remote ids = %v, want [2] (unmanaged inbound 3 must survive)", got) } } // "all" mode keeps the original anti-entropy contract: every remote inbound // missing from the local DB is deleted on the node. func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) { setupConflictDB(t) ts, deletedIDs := fakeNodePanel(t, map[string]int{ "keep": 1, "gone-a": 2, "gone-b": 3, }) node := reconcileTestNode(t, ts, "all-node", "all", nil) seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id) svc := InboundService{} if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil { t.Fatalf("ReconcileNode: %v", err) } got := deletedIDs() if len(got) != 2 || got[0] != 2 || got[1] != 3 { t.Fatalf("deleted remote ids = %v, want [2 3]", got) } } func TestEnsureInboundTagAllowed(t *testing.T) { setupConflictDB(t) db := database.GetDB() svc := NodeService{} selected := &model.Node{ Name: "ensure-sel", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", InboundSyncMode: "selected", InboundTags: []string{"a"}, } if err := db.Create(selected).Error; err != nil { t.Fatalf("create node: %v", err) } if err := svc.EnsureInboundTagAllowed(selected.Id, "b"); err != nil { t.Fatalf("EnsureInboundTagAllowed add: %v", err) } var got model.Node if err := db.First(&got, selected.Id).Error; err != nil { t.Fatalf("reload node: %v", err) } if len(got.InboundTags) != 2 || got.InboundTags[0] != "a" || got.InboundTags[1] != "b" { t.Fatalf("InboundTags = %#v, want [a b]", got.InboundTags) } if err := svc.EnsureInboundTagAllowed(selected.Id, "a"); err != nil { t.Fatalf("EnsureInboundTagAllowed existing: %v", err) } if err := db.First(&got, selected.Id).Error; err != nil { t.Fatalf("reload node: %v", err) } if len(got.InboundTags) != 2 { t.Fatalf("existing tag must not duplicate, got %#v", got.InboundTags) } all := &model.Node{ Name: "ensure-all", Address: "127.0.0.1", Port: 2097, ApiToken: "tok", InboundSyncMode: "all", } if err := db.Create(all).Error; err != nil { t.Fatalf("create node: %v", err) } if err := svc.EnsureInboundTagAllowed(all.Id, "x"); err != nil { t.Fatalf("EnsureInboundTagAllowed all-mode: %v", err) } var gotAll model.Node if err := db.First(&gotAll, all.Id).Error; err != nil { t.Fatalf("reload node: %v", err) } if len(gotAll.InboundTags) != 0 { t.Fatalf("all-mode node must stay without tags, got %#v", gotAll.InboundTags) } }