Files
3x-ui/internal/sub/service_exclude_from_sub_test.go
T
mrchatam 6f40a75909 feat(inbound): excludeFromSub hides links without disabling (#6463)
* feat(inbound): excludeFromSub hides links without disabling

Add a per-inbound flag that omits subscription output while keeping the
inbound enabled for Xray, auth, and traffic accounting. Fixes #6435.

* fix(inbound): excludeFromSub review follow-ups

gofumpt model.go, sync docs OpenAPI, keep excludeFromSub master-authored
on node mirror, and exercise the legacy add-column migration path in tests.

* fix(sub): keep excluded inbounds' clients in the usage header

The excludeFromSub filter sat in getInboundsBySubId's SQL, so an excluded
inbound's clients never reached seenEmails in the raw, Clash or JSON
renderer. A client that lives only on a hidden inbound (one client per
inbound sharing a subId) dropped out of the Subscription-Userinfo usage,
quota and expiry and out of the info-node state, while the inbound kept
serving it and counting its traffic.

The query returns every enabled inbound again; each renderer skips an
excluded inbound's links but still counts its clients, the same rule the
Clash renderer already applies to external links it cannot express.

---------

Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-27 12:26:37 +02:00

78 lines
2.5 KiB
Go

package sub
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// An excluded inbound keeps serving its clients, so every renderer must drop
// its links yet still count its clients' usage in the Subscription-Userinfo header.
func TestExcludedInboundHidesLinksButKeepsUsage(t *testing.T) {
seedSubDB(t)
db := database.GetDB()
shown := seedSubInbound(t, "sub-excl", "shown", 24401, 1, `{"network":"tcp","security":"none"}`)
hidden := seedSubInbound(t, "sub-excl", "hidden", 24402, 2, `{"network":"tcp","security":"none"}`)
if err := db.Model(hidden).Update("exclude_from_sub", true).Error; err != nil {
t.Fatalf("mark excluded: %v", err)
}
for _, row := range []*xray.ClientTraffic{
{InboundId: shown.Id, Email: "shown@e", Up: 100, Down: 200, Enable: true},
{InboundId: hidden.Id, Email: "hidden@e", Up: 1000, Down: 2000, Enable: true},
} {
if err := db.Create(row).Error; err != nil {
t.Fatalf("seed traffic %s: %v", row.Email, err)
}
}
const wantHeader = "upload=1100; download=2200; "
assertOnlyShown := func(t *testing.T, out string) {
t.Helper()
if !strings.Contains(out, "24401") {
t.Fatalf("output lost the shown inbound:\n%s", out)
}
if strings.Contains(out, "24402") {
t.Fatalf("output leaked the excluded inbound:\n%s", out)
}
}
t.Run("raw", func(t *testing.T) {
links, _, _, traffic, err := NewSubService("").GetSubs("sub-excl", "req.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
if len(links) != 1 {
t.Fatalf("links = %q, want only the shown inbound's link", links)
}
assertOnlyShown(t, links[0])
if traffic.Up != 1100 || traffic.Down != 2200 {
t.Fatalf("usage = up %d/down %d, want 1100/2200 including the excluded inbound's client", traffic.Up, traffic.Down)
}
})
t.Run("clash", func(t *testing.T) {
out, header, err := NewSubClashService(false, "", NewSubService("")).GetClash("sub-excl", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
assertOnlyShown(t, out)
if !strings.HasPrefix(header, wantHeader) {
t.Fatalf("header = %q, want prefix %q", header, wantHeader)
}
})
t.Run("json", func(t *testing.T) {
out, header, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("sub-excl", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
assertOnlyShown(t, out)
if !strings.HasPrefix(header, wantHeader) {
t.Fatalf("header = %q, want prefix %q", header, wantHeader)
}
})
}