|
|
@@ -0,0 +1,211 @@
|
|
|
+package service
|
|
|
+
|
|
|
+import (
|
|
|
+ "encoding/json"
|
|
|
+ "path/filepath"
|
|
|
+ "testing"
|
|
|
+
|
|
|
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
|
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
|
+
|
|
|
+ "gorm.io/gorm"
|
|
|
+)
|
|
|
+
|
|
|
+const visionTest = "xtls-rprx-vision"
|
|
|
+
|
|
|
+func clientFlowsInSettings(t *testing.T, settings string) map[string]string {
|
|
|
+ t.Helper()
|
|
|
+ var parsed map[string]any
|
|
|
+ if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
|
|
+ t.Fatalf("parse settings: %v", err)
|
|
|
+ }
|
|
|
+ out := map[string]string{}
|
|
|
+ clients, _ := parsed["clients"].([]any)
|
|
|
+ for _, c := range clients {
|
|
|
+ cm, ok := c.(map[string]any)
|
|
|
+ if !ok {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ email, _ := cm["email"].(string)
|
|
|
+ flow, _ := cm["flow"].(string)
|
|
|
+ out[email] = flow
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+func TestStripClientFlows(t *testing.T) {
|
|
|
+ cases := []struct {
|
|
|
+ name string
|
|
|
+ in string
|
|
|
+ wantChanged bool
|
|
|
+ wantFlows map[string]string
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "clears vision on all clients",
|
|
|
+ in: `{"clients":[{"email":"a","flow":"` + visionTest + `"},{"email":"b","flow":"` + visionTest + `"}]}`,
|
|
|
+ wantChanged: true,
|
|
|
+ wantFlows: map[string]string{"a": "", "b": ""},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "mixed flows: clears only the non-empty",
|
|
|
+ in: `{"clients":[{"email":"a","flow":"` + visionTest + `"},{"email":"b","flow":""}]}`,
|
|
|
+ wantChanged: true,
|
|
|
+ wantFlows: map[string]string{"a": "", "b": ""},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "no flows: unchanged",
|
|
|
+ in: `{"clients":[{"email":"a","flow":""},{"email":"b"}]}`,
|
|
|
+ wantChanged: false,
|
|
|
+ wantFlows: map[string]string{"a": "", "b": ""},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "no clients: unchanged",
|
|
|
+ in: `{"decryption":"none"}`,
|
|
|
+ wantChanged: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "malformed json: unchanged",
|
|
|
+ in: `{not json`,
|
|
|
+ wantChanged: false,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ for _, tc := range cases {
|
|
|
+ t.Run(tc.name, func(t *testing.T) {
|
|
|
+ out, changed := stripClientFlows(tc.in)
|
|
|
+ if changed != tc.wantChanged {
|
|
|
+ t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
|
|
|
+ }
|
|
|
+ if !changed {
|
|
|
+ if out != tc.in {
|
|
|
+ t.Fatalf("unchanged input must be returned verbatim, got %q", out)
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+ got := clientFlowsInSettings(t, out)
|
|
|
+ for email, want := range tc.wantFlows {
|
|
|
+ if got[email] != want {
|
|
|
+ t.Errorf("flow[%s] = %q, want %q", email, got[email], want)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func initFlowTestDB(t *testing.T) *gorm.DB {
|
|
|
+ t.Helper()
|
|
|
+ dbDir := t.TempDir()
|
|
|
+ t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
|
+ if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
|
|
+ t.Fatalf("InitDB: %v", err)
|
|
|
+ }
|
|
|
+ t.Cleanup(func() { _ = database.CloseDB() })
|
|
|
+ return database.GetDB()
|
|
|
+}
|
|
|
+
|
|
|
+func TestAddInbound_DisableFlowClampsClientFlow(t *testing.T) {
|
|
|
+ initFlowTestDB(t)
|
|
|
+ ibSvc := &InboundService{}
|
|
|
+
|
|
|
+ in := &model.Inbound{
|
|
|
+ Tag: "dis-add", Enable: true, Port: 52001, Protocol: model.VLESS,
|
|
|
+ StreamSettings: `{"network":"tcp","security":"reality"}`,
|
|
|
+ Settings: `{"clients":[{"id":"u1","email":"a@x","flow":"` + visionTest + `","subId":"s1","enable":true}]}`,
|
|
|
+ DisableFlow: true,
|
|
|
+ }
|
|
|
+ if _, _, err := ibSvc.AddInbound(in); err != nil {
|
|
|
+ t.Fatalf("AddInbound: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ got, err := ibSvc.GetInbound(in.Id)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("GetInbound: %v", err)
|
|
|
+ }
|
|
|
+ if !got.DisableFlow {
|
|
|
+ t.Error("DisableFlow not persisted on created inbound")
|
|
|
+ }
|
|
|
+ if f := clientFlowsInSettings(t, got.Settings)["a@x"]; f != "" {
|
|
|
+ t.Errorf("settings flow = %q, want empty (clamped at creation)", f)
|
|
|
+ }
|
|
|
+ list, err := ibSvc.clientService.ListForInbound(nil, in.Id)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("ListForInbound: %v", err)
|
|
|
+ }
|
|
|
+ if len(list) != 1 || list[0].Flow != "" {
|
|
|
+ t.Errorf("flow_override = %#v, want empty (xray must not expect Vision)", list)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestUpdateInbound_DisableFlowPersistsStripsAndResistsRestore(t *testing.T) {
|
|
|
+ db := initFlowTestDB(t)
|
|
|
+ ibSvc := &InboundService{}
|
|
|
+ cs := &ClientService{}
|
|
|
+
|
|
|
+ const email = "shared@x"
|
|
|
+ const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d001"
|
|
|
+
|
|
|
+ sibling := &model.Inbound{
|
|
|
+ Tag: "sib", Enable: true, Port: 52101, Protocol: model.VLESS,
|
|
|
+ StreamSettings: `{"network":"tcp","security":"reality"}`,
|
|
|
+ Settings: `{"clients":[{"id":"` + uid + `","email":"` + email + `","flow":"` + visionTest + `","subId":"s1","enable":true}]}`,
|
|
|
+ }
|
|
|
+ if err := db.Create(sibling).Error; err != nil {
|
|
|
+ t.Fatalf("create sibling: %v", err)
|
|
|
+ }
|
|
|
+ sc, _ := ibSvc.GetClients(sibling)
|
|
|
+ if err := cs.SyncInbound(nil, sibling.Id, sc); err != nil {
|
|
|
+ t.Fatalf("sync sibling: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ target := &model.Inbound{
|
|
|
+ Tag: "tgt", Enable: true, Port: 52102, Protocol: model.VLESS,
|
|
|
+ StreamSettings: `{"network":"tcp","security":"reality"}`,
|
|
|
+ Settings: `{"clients":[{"id":"` + uid + `","email":"` + email + `","flow":"` + visionTest + `","subId":"s1","enable":true}]}`,
|
|
|
+ }
|
|
|
+ if err := db.Create(target).Error; err != nil {
|
|
|
+ t.Fatalf("create target: %v", err)
|
|
|
+ }
|
|
|
+ tc, _ := ibSvc.GetClients(target)
|
|
|
+ if err := cs.SyncInbound(nil, target.Id, tc); err != nil {
|
|
|
+ t.Fatalf("sync target: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ upd := *target
|
|
|
+ upd.DisableFlow = true
|
|
|
+ if _, _, err := ibSvc.UpdateInbound(&upd); err != nil {
|
|
|
+ t.Fatalf("UpdateInbound: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ reloaded, err := ibSvc.GetInbound(target.Id)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("GetInbound: %v", err)
|
|
|
+ }
|
|
|
+ if !reloaded.DisableFlow {
|
|
|
+ t.Fatal("DisableFlow did not persist through UpdateInbound (blocking regression)")
|
|
|
+ }
|
|
|
+ if f := clientFlowsInSettings(t, reloaded.Settings)["shared@x"]; f != "" {
|
|
|
+ t.Errorf("target settings flow = %q, want empty after disable", f)
|
|
|
+ }
|
|
|
+ list, err := cs.ListForInbound(nil, target.Id)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("ListForInbound(target): %v", err)
|
|
|
+ }
|
|
|
+ if len(list) != 1 || list[0].Flow != "" {
|
|
|
+ t.Errorf("target flow_override = %#v, want empty", list)
|
|
|
+ }
|
|
|
+
|
|
|
+ ibSvc.MigrationRestoreVisionFlow()
|
|
|
+ reloaded2, err := ibSvc.GetInbound(target.Id)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("GetInbound after restore: %v", err)
|
|
|
+ }
|
|
|
+ if f := clientFlowsInSettings(t, reloaded2.Settings)["shared@x"]; f != "" {
|
|
|
+ t.Errorf("after MigrationRestoreVisionFlow target flow = %q, want empty (must not self-revert)", f)
|
|
|
+ }
|
|
|
+ sList, err := cs.ListForInbound(nil, sibling.Id)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("ListForInbound(sibling): %v", err)
|
|
|
+ }
|
|
|
+ if len(sList) != 1 || sList[0].Flow != visionTest {
|
|
|
+ t.Errorf("sibling flow_override = %#v, want Vision preserved", sList)
|
|
|
+ }
|
|
|
+}
|