mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 08:34:22 +00:00
554d85c2f7
* feat: select node inbounds for synchronization Allow node owners to import either all remote inbounds or an explicit tag-based selection. Add remote inbound discovery, persistence, snapshot filtering, API documentation, tests, and localized UI labels. * fix * fix: scope node reconcile and orphan sweep to selected inbound tags In 'selected' sync mode unselected inbounds never enter the panel DB, so ReconcileNode treated them as undesired and deleted them from the node the first time it went config-dirty. Reconcile now only sweeps remote tags that are part of the selection; everything else on the node is unmanaged. Panel-created or renamed inbounds on a selected-mode node also vanished: their tag was outside the selection, so the next traffic pull filtered them out of the snapshot and the orphan sweep silently dropped the central row. AddInbound/UpdateInbound now allow the tag on the node before committing. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
215 lines
5.6 KiB
Go
215 lines
5.6 KiB
Go
package service
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
|
)
|
|
|
|
func TestNormalizeBasePath(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want string
|
|
}{
|
|
{"", "/"},
|
|
{" ", "/"},
|
|
{"/", "/"},
|
|
{"/panel", "/panel/"},
|
|
{"panel", "/panel/"},
|
|
{"panel/", "/panel/"},
|
|
{"/panel/", "/panel/"},
|
|
{" /panel ", "/panel/"},
|
|
{"/a/b/c", "/a/b/c/"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.in, func(t *testing.T) {
|
|
got := normalizeBasePath(c.in)
|
|
if got != c.want {
|
|
t.Fatalf("normalizeBasePath(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNodeMetricKey(t *testing.T) {
|
|
cases := []struct {
|
|
id int
|
|
metric string
|
|
want string
|
|
}{
|
|
{1, "cpu", "node:1:cpu"},
|
|
{42, "mem", "node:42:mem"},
|
|
{0, "anything", "node:0:anything"},
|
|
}
|
|
for _, c := range cases {
|
|
got := nodeMetricKey(c.id, c.metric)
|
|
if got != c.want {
|
|
t.Fatalf("nodeMetricKey(%d, %q) = %q, want %q", c.id, c.metric, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHeartbeatPatch_ToUI_OnlineCopiesFields(t *testing.T) {
|
|
p := HeartbeatPatch{
|
|
Status: "ignored-source",
|
|
LatencyMs: 42,
|
|
XrayVersion: "1.8.4",
|
|
PanelVersion: "3.0.0",
|
|
CpuPct: 12.5,
|
|
MemPct: 33.3,
|
|
UptimeSecs: 12345,
|
|
LastError: "",
|
|
}
|
|
ui := p.ToUI(true)
|
|
if ui.Status != "online" {
|
|
t.Fatalf("Status = %q, want online", ui.Status)
|
|
}
|
|
if ui.LatencyMs != 42 || ui.XrayVersion != "1.8.4" || ui.PanelVersion != "3.0.0" {
|
|
t.Fatalf("scalar copy mismatch: %+v", ui)
|
|
}
|
|
if ui.CpuPct != 12.5 || ui.MemPct != 33.3 || ui.UptimeSecs != 12345 {
|
|
t.Fatalf("metric copy mismatch: %+v", ui)
|
|
}
|
|
if ui.Error != "" {
|
|
t.Fatalf("Error = %q, want empty", ui.Error)
|
|
}
|
|
}
|
|
|
|
func TestHeartbeatPatch_ToUI_OfflinePreservesError(t *testing.T) {
|
|
p := HeartbeatPatch{LastError: "connection refused"}
|
|
ui := p.ToUI(false)
|
|
if ui.Status != "offline" {
|
|
t.Fatalf("Status = %q, want offline", ui.Status)
|
|
}
|
|
if ui.Error != "connection refused" {
|
|
t.Fatalf("Error = %q, want %q", ui.Error, "connection refused")
|
|
}
|
|
}
|
|
|
|
func TestNodeService_Normalize_Valid(t *testing.T) {
|
|
s := &NodeService{}
|
|
n := &model.Node{
|
|
Name: " primary ",
|
|
ApiToken: " abc ",
|
|
Address: "example.com",
|
|
Port: 8443,
|
|
Scheme: "",
|
|
BasePath: "panel",
|
|
}
|
|
if err := s.normalize(n); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if n.Name != "primary" {
|
|
t.Fatalf("Name not trimmed: %q", n.Name)
|
|
}
|
|
if n.ApiToken != "abc" {
|
|
t.Fatalf("ApiToken not trimmed: %q", n.ApiToken)
|
|
}
|
|
if n.Scheme != "https" {
|
|
t.Fatalf("empty Scheme should default to https, got %q", n.Scheme)
|
|
}
|
|
if n.BasePath != "/panel/" {
|
|
t.Fatalf("BasePath = %q, want /panel/", n.BasePath)
|
|
}
|
|
}
|
|
|
|
func TestNodeService_Normalize_KeepsValidScheme(t *testing.T) {
|
|
s := &NodeService{}
|
|
n := &model.Node{Name: "n", Address: "example.com", Port: 80, Scheme: "http"}
|
|
if err := s.normalize(n); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if n.Scheme != "http" {
|
|
t.Fatalf("Scheme = %q, want http", n.Scheme)
|
|
}
|
|
}
|
|
|
|
func TestNodeService_Normalize_RejectsEmptyName(t *testing.T) {
|
|
s := &NodeService{}
|
|
n := &model.Node{Name: " ", Address: "example.com", Port: 443}
|
|
if err := s.normalize(n); err == nil {
|
|
t.Fatal("expected error for empty name")
|
|
}
|
|
}
|
|
|
|
func TestNodeService_Normalize_RejectsBadHost(t *testing.T) {
|
|
s := &NodeService{}
|
|
n := &model.Node{Name: "n", Address: "bad host name with spaces", Port: 443}
|
|
if err := s.normalize(n); err == nil {
|
|
t.Fatal("expected error for invalid host")
|
|
}
|
|
}
|
|
|
|
func TestNodeService_Normalize_RejectsOutOfRangePort(t *testing.T) {
|
|
s := &NodeService{}
|
|
for _, port := range []int{0, -1, 65536, 100000} {
|
|
n := &model.Node{Name: "n", Address: "example.com", Port: port}
|
|
if err := s.normalize(n); err == nil {
|
|
t.Fatalf("expected error for port %d", port)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNodeService_Normalize_OverridesUnknownScheme(t *testing.T) {
|
|
s := &NodeService{}
|
|
n := &model.Node{Name: "n", Address: "example.com", Port: 443, Scheme: "ftp"}
|
|
if err := s.normalize(n); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if n.Scheme != "https" {
|
|
t.Fatalf("Scheme = %q, want https", n.Scheme)
|
|
}
|
|
}
|
|
|
|
func TestNodeService_NormalizeInboundSelection(t *testing.T) {
|
|
s := &NodeService{}
|
|
n := &model.Node{
|
|
Name: "n",
|
|
Address: "example.com",
|
|
Port: 443,
|
|
InboundSyncMode: "selected",
|
|
InboundTags: []string{" alpha ", "", "beta", "alpha"},
|
|
}
|
|
if err := s.normalize(n); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if n.InboundSyncMode != "selected" {
|
|
t.Fatalf("InboundSyncMode = %q, want selected", n.InboundSyncMode)
|
|
}
|
|
if len(n.InboundTags) != 2 || n.InboundTags[0] != "alpha" || n.InboundTags[1] != "beta" {
|
|
t.Fatalf("InboundTags = %#v, want [alpha beta]", n.InboundTags)
|
|
}
|
|
}
|
|
|
|
func TestFilterNodeSnapshot(t *testing.T) {
|
|
snapshot := func() *runtime.TrafficSnapshot {
|
|
return &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
|
|
{Tag: "alpha"},
|
|
{Tag: "beta"},
|
|
{Tag: "gamma"},
|
|
}}
|
|
}
|
|
|
|
all := snapshot()
|
|
FilterNodeSnapshot(&model.Node{InboundSyncMode: "all"}, all)
|
|
if len(all.Inbounds) != 3 {
|
|
t.Fatalf("all mode kept %d inbounds, want 3", len(all.Inbounds))
|
|
}
|
|
|
|
selected := snapshot()
|
|
FilterNodeSnapshot(&model.Node{
|
|
InboundSyncMode: "selected",
|
|
InboundTags: []string{"beta"},
|
|
}, selected)
|
|
if len(selected.Inbounds) != 1 || selected.Inbounds[0].Tag != "beta" {
|
|
t.Fatalf("selected mode produced %#v, want only beta", selected.Inbounds)
|
|
}
|
|
|
|
none := snapshot()
|
|
FilterNodeSnapshot(&model.Node{InboundSyncMode: "selected"}, none)
|
|
if len(none.Inbounds) != 0 {
|
|
t.Fatalf("empty selection kept %d inbounds, want 0", len(none.Inbounds))
|
|
}
|
|
}
|