Files
3x-ui/internal/database/inbound_exclude_from_sub_migration_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

59 lines
2.0 KiB
Go

package database
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// Legacy inbounds schema without exclude_from_sub — the upgrade path AddColumn must cover.
const legacyInboundNoExcludeFromSubDDL = "CREATE TABLE `inbounds` (`id` integer PRIMARY KEY AUTOINCREMENT,`user_id` integer,`up` integer,`down` integer,`total` integer,`remark` text,`enable` numeric,`expiry_time` integer,`listen` text,`port` integer,`protocol` text,`settings` text,`stream_settings` text,`tag` text UNIQUE,`sniffing` text)"
func TestMigrateInboundExcludeFromSubColumn(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
legacy, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
t.Fatalf("open legacy db: %v", err)
}
if err := legacy.Exec(legacyInboundNoExcludeFromSubDDL).Error; err != nil {
t.Fatalf("create legacy inbounds: %v", err)
}
if err := legacy.Exec(
`INSERT INTO inbounds (user_id, remark, enable, port, protocol, settings, stream_settings, tag, sniffing)
VALUES (1, 'preexisting', 1, 443, 'vless', '{"clients":[]}', '{}', 'in-443-tcp', '{}')`,
).Error; err != nil {
t.Fatalf("seed legacy inbound: %v", err)
}
sqlDB, err := legacy.DB()
if err != nil {
t.Fatalf("legacy db handle: %v", err)
}
if err := sqlDB.Close(); err != nil {
t.Fatalf("close legacy db: %v", err)
}
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB over legacy schema: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if !GetDB().Migrator().HasColumn(&model.Inbound{}, "exclude_from_sub") {
t.Fatal("exclude_from_sub column missing after migrateInboundExcludeFromSubColumn")
}
var row model.Inbound
if err := GetDB().Where("tag = ?", "in-443-tcp").First(&row).Error; err != nil {
t.Fatalf("preexisting inbound lost: %v", err)
}
if row.ExcludeFromSub {
t.Fatal("preexisting row must default exclude_from_sub to false, got true")
}
if err := migrateInboundExcludeFromSubColumn(); err != nil {
t.Fatalf("idempotent migrate: %v", err)
}
}