diff --git a/database/db.go b/database/db.go index fba55a3c..10e729c3 100644 --- a/database/db.go +++ b/database/db.go @@ -142,11 +142,11 @@ func runSeeders(isUsersEmpty bool) error { } if empty && isUsersEmpty { - hashSeeder := &model.HistoryOfSeeders{ - SeederName: "UserPasswordHash", - } - if err := db.Create(hashSeeder).Error; err != nil { - return err + seeders := []string{"UserPasswordHash", "ClientsTable"} + for _, name := range seeders { + if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { + return err + } } return seedApiTokens() } @@ -237,6 +237,14 @@ func seedClientsFromInboundJSON() error { return db.Transaction(func(tx *gorm.DB) error { byEmail := map[string]*model.ClientRecord{} + var existing []model.ClientRecord + if err := tx.Find(&existing).Error; err != nil { + return err + } + for i := range existing { + byEmail[existing[i].Email] = &existing[i] + } + for _, inbound := range inbounds { if strings.TrimSpace(inbound.Settings) == "" { continue diff --git a/database/db_seed_test.go b/database/db_seed_test.go new file mode 100644 index 00000000..d648774b --- /dev/null +++ b/database/db_seed_test.go @@ -0,0 +1,71 @@ +package database + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/database/model" +) + +func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil { + t.Fatalf("InitDB failed: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + + settings, err := json.Marshal(map[string]any{ + "clients": []any{ + map[string]any{ + "id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001", + "email": "alice@example.com", + "enable": true, + "flow": "", + "subId": "alice-sub", + "comment": "from-inbound-json", + }, + }, + }) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + inbound := model.Inbound{ + UserId: 1, + Port: 12345, + Protocol: model.VLESS, + Settings: string(settings), + Tag: "test-inbound", + } + if err := db.Create(&inbound).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + + preExisting := &model.ClientRecord{ + Email: "alice@example.com", + UUID: "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001", + SubID: "alice-sub", + Enable: true, + Comment: "added-via-api", + } + if err := db.Create(preExisting).Error; err != nil { + t.Fatalf("seed client row: %v", err) + } + + if err := db.Where("seeder_name = ?", "ClientsTable").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatalf("clear ClientsTable history: %v", err) + } + + if err := seedClientsFromInboundJSON(); err != nil { + t.Fatalf("seedClientsFromInboundJSON should be idempotent against existing rows, got: %v", err) + } + + var count int64 + if err := db.Model(&model.ClientRecord{}).Where("email = ?", "alice@example.com").Count(&count).Error; err != nil { + t.Fatalf("count clients: %v", err) + } + if count != 1 { + t.Fatalf("alice@example.com should resolve to exactly one row, got %d", count) + } +} diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index 7e827ddb..eea4fe85 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/frontend/src/pages/clients/ClientInfoModal.tsx @@ -40,9 +40,16 @@ export default function ClientInfoModal({ onOpenChange, }: ClientInfoModalProps) { const { datepicker } = useDatepicker(); - const expiryLabel = (ts?: number) => (!ts || ts <= 0 ? '∞' : IntlUtil.formatDate(ts, datepicker)); - const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker)); const { t } = useTranslation(); + const expiryLabel = (ts?: number) => { + if (!ts) return '∞'; + if (ts < 0) { + const days = Math.round(ts / -86400000); + return `${t('pages.clients.delayedStart')}: ${days}d`; + } + return IntlUtil.formatDate(ts, datepicker); + }; + const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker)); const [messageApi, messageContextHolder] = message.useMessage(); const [links, setLinks] = useState([]); @@ -195,9 +202,9 @@ export default function ClientInfoModal({ {t('pages.inbounds.expireDate')} - {!client.expiryTime || client.expiryTime <= 0 + {!client.expiryTime ? - : {expiryLabel(client.expiryTime)}} + : {expiryLabel(client.expiryTime)}} {(client.expiryTime ?? 0) > 0 && ( {IntlUtil.formatRelativeTime(client.expiryTime)} )} diff --git a/sub/subClashService.go b/sub/subClashService.go index 68f0c3bc..a138faec 100644 --- a/sub/subClashService.go +++ b/sub/subClashService.go @@ -4,6 +4,7 @@ import ( "fmt" "maps" "strings" + "time" "github.com/goccy/go-json" yaml "github.com/goccy/go-yaml" @@ -63,14 +64,13 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e return "", "", nil } + now := time.Now().UnixMilli() for index, clientTraffic := range clientTraffics { if index == 0 { traffic.Up = clientTraffic.Up traffic.Down = clientTraffic.Down traffic.Total = clientTraffic.Total - if clientTraffic.ExpiryTime > 0 { - traffic.ExpiryTime = clientTraffic.ExpiryTime - } + traffic.ExpiryTime = subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) } else { traffic.Up += clientTraffic.Up traffic.Down += clientTraffic.Down @@ -79,7 +79,8 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e } else { traffic.Total += clientTraffic.Total } - if clientTraffic.ExpiryTime != traffic.ExpiryTime { + normalized := subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) + if normalized != traffic.ExpiryTime { traffic.ExpiryTime = 0 } } @@ -364,6 +365,53 @@ func (s *SubClashService) applyTransport(proxy map[string]any, network string, s proxy["grpc-opts"] = grpcOpts } return true + case "httpupgrade": + proxy["network"] = "httpupgrade" + hu, _ := stream["httpupgradeSettings"].(map[string]any) + opts := map[string]any{} + if hu != nil { + if path, ok := hu["path"].(string); ok && path != "" { + opts["path"] = path + } + host := "" + if v, ok := hu["host"].(string); ok && v != "" { + host = v + } else if headers, ok := hu["headers"].(map[string]any); ok { + host = searchHost(headers) + } + if host != "" { + opts["headers"] = map[string]any{"Host": host} + } + } + if len(opts) > 0 { + proxy["http-upgrade-opts"] = opts + } + return true + case "xhttp": + proxy["network"] = "xhttp" + xhttp, _ := stream["xhttpSettings"].(map[string]any) + opts := map[string]any{} + if xhttp != nil { + if path, ok := xhttp["path"].(string); ok && path != "" { + opts["path"] = path + } + host := "" + if v, ok := xhttp["host"].(string); ok && v != "" { + host = v + } else if headers, ok := xhttp["headers"].(map[string]any); ok { + host = searchHost(headers) + } + if host != "" { + opts["host"] = host + } + if mode, ok := xhttp["mode"].(string); ok && mode != "" { + opts["mode"] = mode + } + } + if len(opts) > 0 { + proxy["xhttp-opts"] = opts + } + return true default: return false } diff --git a/sub/subClashService_test.go b/sub/subClashService_test.go new file mode 100644 index 00000000..49e7788a --- /dev/null +++ b/sub/subClashService_test.go @@ -0,0 +1,81 @@ +package sub + +import ( + "reflect" + "testing" +) + +func TestApplyTransport_XHTTP(t *testing.T) { + svc := &SubClashService{} + proxy := map[string]any{} + stream := map[string]any{ + "xhttpSettings": map[string]any{ + "path": "/xh", + "host": "example.com", + "mode": "auto", + }, + } + + if !svc.applyTransport(proxy, "xhttp", stream) { + t.Fatalf("applyTransport returned false for xhttp (#4531: would drop the inbound and yield an empty Clash YAML)") + } + if proxy["network"] != "xhttp" { + t.Fatalf("network = %v, want xhttp", proxy["network"]) + } + opts, ok := proxy["xhttp-opts"].(map[string]any) + if !ok { + t.Fatalf("xhttp-opts missing or wrong type: %#v", proxy["xhttp-opts"]) + } + want := map[string]any{"path": "/xh", "host": "example.com", "mode": "auto"} + if !reflect.DeepEqual(opts, want) { + t.Fatalf("xhttp-opts = %#v, want %#v", opts, want) + } +} + +func TestApplyTransport_XHTTP_HostFromHeaders(t *testing.T) { + svc := &SubClashService{} + proxy := map[string]any{} + stream := map[string]any{ + "xhttpSettings": map[string]any{ + "path": "/xh", + "headers": map[string]any{"Host": "via-header.example.com"}, + }, + } + + if !svc.applyTransport(proxy, "xhttp", stream) { + t.Fatalf("applyTransport returned false for xhttp") + } + opts, _ := proxy["xhttp-opts"].(map[string]any) + if opts["host"] != "via-header.example.com" { + t.Fatalf("host should fall back to headers.Host, got %v", opts["host"]) + } +} + +func TestApplyTransport_HTTPUpgrade(t *testing.T) { + svc := &SubClashService{} + proxy := map[string]any{} + stream := map[string]any{ + "httpupgradeSettings": map[string]any{ + "path": "/hu", + "host": "example.com", + }, + } + + if !svc.applyTransport(proxy, "httpupgrade", stream) { + t.Fatalf("applyTransport returned false for httpupgrade") + } + if proxy["network"] != "httpupgrade" { + t.Fatalf("network = %v, want httpupgrade", proxy["network"]) + } + opts, ok := proxy["http-upgrade-opts"].(map[string]any) + if !ok { + t.Fatalf("http-upgrade-opts missing: %#v", proxy["http-upgrade-opts"]) + } + if opts["path"] != "/hu" { + t.Fatalf("path = %v, want /hu", opts["path"]) + } + headers, _ := opts["headers"].(map[string]any) + if headers["Host"] != "example.com" { + t.Fatalf("headers.Host = %v, want example.com", headers["Host"]) + } +} diff --git a/sub/subJsonService.go b/sub/subJsonService.go index 28a1a9ff..e26b60ea 100644 --- a/sub/subJsonService.go +++ b/sub/subJsonService.go @@ -6,6 +6,7 @@ import ( "fmt" "maps" "strings" + "time" "github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/logger" @@ -125,14 +126,13 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err } // Prepare statistics + now := time.Now().UnixMilli() for index, clientTraffic := range clientTraffics { if index == 0 { traffic.Up = clientTraffic.Up traffic.Down = clientTraffic.Down traffic.Total = clientTraffic.Total - if clientTraffic.ExpiryTime > 0 { - traffic.ExpiryTime = clientTraffic.ExpiryTime - } + traffic.ExpiryTime = subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) } else { traffic.Up += clientTraffic.Up traffic.Down += clientTraffic.Down @@ -141,7 +141,8 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err } else { traffic.Total += clientTraffic.Total } - if clientTraffic.ExpiryTime != traffic.ExpiryTime { + normalized := subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) + if normalized != traffic.ExpiryTime { traffic.ExpiryTime = 0 } } diff --git a/sub/subService.go b/sub/subService.go index 49a05938..306d724f 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -108,15 +108,13 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C } } - // Prepare statistics + now := time.Now().UnixMilli() for index, clientTraffic := range clientTraffics { if index == 0 { traffic.Up = clientTraffic.Up traffic.Down = clientTraffic.Down traffic.Total = clientTraffic.Total - if clientTraffic.ExpiryTime > 0 { - traffic.ExpiryTime = clientTraffic.ExpiryTime - } + traffic.ExpiryTime = subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) } else { traffic.Up += clientTraffic.Up traffic.Down += clientTraffic.Down @@ -125,7 +123,8 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C } else { traffic.Total += clientTraffic.Total } - if clientTraffic.ExpiryTime != traffic.ExpiryTime { + normalized := subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) + if normalized != traffic.ExpiryTime { traffic.ExpiryTime = 0 } } @@ -134,6 +133,16 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C return result, lastOnline, traffic, nil } +func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 { + if expiryTime > 0 { + return expiryTime + } + if expiryTime < 0 { + return nowMs + (-expiryTime) + } + return 0 +} + func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { db := database.GetDB() var inbounds []*model.Inbound diff --git a/sub/subService_test.go b/sub/subService_test.go index 91512d7f..d87a198c 100644 --- a/sub/subService_test.go +++ b/sub/subService_test.go @@ -9,6 +9,23 @@ import ( "github.com/mhsanaei/3x-ui/v3/database/model" ) +func TestSubscriptionExpiryFromClient(t *testing.T) { + const now = int64(1_700_000_000_000) + const oneDayMs = int64(86_400_000) + if got := subscriptionExpiryFromClient(now, 0); got != 0 { + t.Fatalf("zero expiry should stay zero, got %d", got) + } + if got := subscriptionExpiryFromClient(now, 1_700_000_000_000); got != 1_700_000_000_000 { + t.Fatalf("positive expiry should pass through, got %d", got) + } + if got := subscriptionExpiryFromClient(now, -oneDayMs); got != now+oneDayMs { + t.Fatalf("delayed-start expiry should be now+|value|, got %d, want %d", got, now+oneDayMs) + } + if a, b := subscriptionExpiryFromClient(now, -oneDayMs), subscriptionExpiryFromClient(now, -oneDayMs); a != b { + t.Fatalf("same now+value should be deterministic across calls, got %d vs %d (#4545 review)", a, b) + } +} + func TestFindClientIndex(t *testing.T) { clients := []model.Client{ {Email: "a@example.com"}, diff --git a/web/job/check_hash_storage.go b/web/job/check_hash_storage.go index 1489217e..ce836831 100644 --- a/web/job/check_hash_storage.go +++ b/web/job/check_hash_storage.go @@ -16,6 +16,9 @@ func NewCheckHashStorageJob() *CheckHashStorageJob { // Run removes expired hash entries from the Telegram bot's hash storage. func (j *CheckHashStorageJob) Run() { - // Remove expired hashes from storage - j.tgbotService.GetHashStorage().RemoveExpiredHashes() + storage := j.tgbotService.GetHashStorage() + if storage == nil { + return + } + storage.RemoveExpiredHashes() } diff --git a/web/job/check_hash_storage_test.go b/web/job/check_hash_storage_test.go new file mode 100644 index 00000000..e324bcb8 --- /dev/null +++ b/web/job/check_hash_storage_test.go @@ -0,0 +1,12 @@ +package job + +import "testing" + +func TestCheckHashStorageJob_RunWithoutPanicWhenStorageNil(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("CheckHashStorageJob.Run panicked when storage is nil: %v", r) + } + }() + NewCheckHashStorageJob().Run() +} diff --git a/web/job/node_traffic_sync_job.go b/web/job/node_traffic_sync_job.go index 63de5018..1cf32323 100644 --- a/web/job/node_traffic_sync_job.go +++ b/web/job/node_traffic_sync_job.go @@ -101,10 +101,6 @@ func (j *NodeTrafficSyncJob) Run() { j.structural.set() } - if !websocket.HasClients() { - return - } - lastOnline, err := j.inboundService.GetClientsLastOnline() if err != nil { logger.Warning("node traffic sync: get last-online failed:", err) @@ -115,6 +111,10 @@ func (j *NodeTrafficSyncJob) Run() { j.inboundService.RefreshOnlineClientsFromMap(lastOnline) + if !websocket.HasClients() { + return + } + online := j.inboundService.GetOnlineClients() if online == nil { online = []string{} diff --git a/web/job/xray_traffic_job.go b/web/job/xray_traffic_job.go index 7a471b4c..583c5995 100644 --- a/web/job/xray_traffic_job.go +++ b/web/job/xray_traffic_job.go @@ -65,18 +65,6 @@ func (j *XrayTrafficJob) Run() { j.xrayService.SetToNeedRestart() } - // If no frontend client is connected, skip all WebSocket broadcasting - // routines — including the active-client DB query and JSON marshaling. - if !websocket.HasClients() { - return - } - - // Online presence + traffic deltas — small payload, always fits in WS. - // Force non-nil slice/map so JSON marshalling produces [] / {} instead of - // `null` when everyone is offline. The frontend's traffic handler treats - // a missing/null onlineClients field as "no update", so without this the - // "everyone went offline" transition was silently dropped — stale online - // users lingered in the list and the online filter kept showing them. lastOnlineMap, err := j.inboundService.GetClientsLastOnline() if err != nil { logger.Warning("get clients last online failed:", err) @@ -84,13 +72,12 @@ func (j *XrayTrafficJob) Run() { if lastOnlineMap == nil { lastOnlineMap = make(map[string]int64) } - - // Determine online clients from lastOnline timestamps with a 5-second - // grace period instead of just the current 5-second traffic poll. This - // prevents idle-but-connected clients from randomly disappearing from - // the UI between polling windows. j.inboundService.RefreshOnlineClientsFromMap(lastOnlineMap) + if !websocket.HasClients() { + return + } + onlineClients := j.inboundService.GetOnlineClients() if onlineClients == nil { onlineClients = []string{} @@ -102,11 +89,6 @@ func (j *XrayTrafficJob) Run() { "lastOnlineMap": lastOnlineMap, }) - // Full snapshot every cycle: absolute per-client counters and inbound - // totals. Frontend overwrites both in place. The previous delta path - // (activeEmails -> GetActiveClientTraffics) silently omitted the - // clients array whenever nobody moved bytes in the cycle, leaving the - // client rows in the UI stuck at stale traffic/remained/all-time. clientStatsPayload := map[string]any{} if stats, err := j.inboundService.GetAllClientTraffics(); err != nil { logger.Warning("get all client traffics for websocket failed:", err) @@ -122,8 +104,6 @@ func (j *XrayTrafficJob) Run() { websocket.BroadcastClientStats(clientStatsPayload) } - // Outbounds list is small (one row per outbound, no per-client expansion) - // so the full snapshot still fits comfortably in WS. if updatedOutbounds, err := j.outboundService.GetOutboundsTraffic(); err == nil && updatedOutbounds != nil { websocket.BroadcastOutbounds(updatedOutbounds) } else if err != nil { diff --git a/web/service/client.go b/web/service/client.go index a464029e..0bdf6cc2 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -213,12 +213,22 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model. } row = incoming } else { - row.UUID = incoming.UUID - row.Password = incoming.Password - row.Auth = incoming.Auth + if incoming.UUID != "" { + row.UUID = incoming.UUID + } + if incoming.Password != "" { + row.Password = incoming.Password + } + if incoming.Auth != "" { + row.Auth = incoming.Auth + } row.Flow = incoming.Flow - row.Security = incoming.Security - row.Reverse = incoming.Reverse + if incoming.Security != "" { + row.Security = incoming.Security + } + if incoming.Reverse != "" { + row.Reverse = incoming.Reverse + } row.SubID = incoming.SubID row.LimitIP = incoming.LimitIP row.TotalGB = incoming.TotalGB diff --git a/web/service/client_sync_multiprotocol_test.go b/web/service/client_sync_multiprotocol_test.go new file mode 100644 index 00000000..cb4e1f37 --- /dev/null +++ b/web/service/client_sync_multiprotocol_test.go @@ -0,0 +1,113 @@ +package service + +import ( + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/database" + "github.com/mhsanaei/3x-ui/v3/database/model" +) + +func TestSyncInbound_PreservesCredentialsAcrossProtocols(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + db := database.GetDB() + + vlessInbound := &model.Inbound{Tag: "vless-in", Enable: true, Port: 10001, Protocol: model.VLESS} + if err := db.Create(vlessInbound).Error; err != nil { + t.Fatalf("create vless inbound: %v", err) + } + hysteriaInbound := &model.Inbound{Tag: "hy-in", Enable: true, Port: 10002, Protocol: model.Hysteria2} + if err := db.Create(hysteriaInbound).Error; err != nil { + t.Fatalf("create hysteria inbound: %v", err) + } + + svc := ClientService{} + const sharedEmail = "shared@example.com" + const wantUUID = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001" + const wantAuth = "h2-auth-token" + const wantFlow = "xtls-rprx-vision" + + vlessClient := model.Client{Email: sharedEmail, ID: wantUUID, Enable: true, Flow: wantFlow} + if err := svc.SyncInbound(nil, vlessInbound.Id, []model.Client{vlessClient}); err != nil { + t.Fatalf("vless SyncInbound: %v", err) + } + + hysteriaClient := model.Client{Email: sharedEmail, Auth: wantAuth, Enable: true} + if err := svc.SyncInbound(nil, hysteriaInbound.Id, []model.Client{hysteriaClient}); err != nil { + t.Fatalf("hysteria SyncInbound: %v", err) + } + + var row model.ClientRecord + if err := db.Where("email = ?", sharedEmail).First(&row).Error; err != nil { + t.Fatalf("lookup client row: %v", err) + } + if row.UUID != wantUUID { + t.Errorf("UUID was clobbered by Hysteria sync: got %q, want %q", row.UUID, wantUUID) + } + if row.Auth != wantAuth { + t.Errorf("Auth not persisted: got %q, want %q", row.Auth, wantAuth) + } + + vlessList, err := svc.ListForInbound(nil, vlessInbound.Id) + if err != nil { + t.Fatalf("ListForInbound(vless): %v", err) + } + if len(vlessList) != 1 || vlessList[0].Flow != wantFlow { + t.Errorf("VLESS inbound should still report flow=%q via FlowOverride, got %#v", wantFlow, vlessList) + } + + hysteriaList, err := svc.ListForInbound(nil, hysteriaInbound.Id) + if err != nil { + t.Fatalf("ListForInbound(hysteria): %v", err) + } + if len(hysteriaList) != 1 || hysteriaList[0].Flow != "" { + t.Errorf("Hysteria inbound should report empty flow, got %#v", hysteriaList) + } +} + +func TestSyncInbound_AllowsClearingFlow(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + db := database.GetDB() + + vless := &model.Inbound{Tag: "vless-in", Enable: true, Port: 10003, Protocol: model.VLESS} + if err := db.Create(vless).Error; err != nil { + t.Fatalf("create vless inbound: %v", err) + } + + svc := ClientService{} + const email = "alice@example.com" + const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c002" + + withFlow := model.Client{Email: email, ID: uid, Enable: true, Flow: "xtls-rprx-vision"} + if err := svc.SyncInbound(nil, vless.Id, []model.Client{withFlow}); err != nil { + t.Fatalf("vless SyncInbound (set flow): %v", err) + } + + cleared := model.Client{Email: email, ID: uid, Enable: true, Flow: ""} + if err := svc.SyncInbound(nil, vless.Id, []model.Client{cleared}); err != nil { + t.Fatalf("vless SyncInbound (clear flow): %v", err) + } + + list, err := svc.ListForInbound(nil, vless.Id) + if err != nil { + t.Fatalf("ListForInbound: %v", err) + } + if len(list) != 1 { + t.Fatalf("expected 1 client, got %d", len(list)) + } + if list[0].Flow != "" { + t.Errorf("flow should be clearable on the owning inbound, got %q (Copilot review on #4545)", list[0].Flow) + } +}