Files
3x-ui/internal/web/service/inbound_migration_test.go
T
Sanaei 7605902324 Test-quality audit: fix 2 prod bugs, strengthen weak tests, add mutation/fuzz/CI tooling (#5345)
* test(audit): add gremlins/rapid/coverage tooling + AUDIT.md scaffold

* test(audit): hygiene sweep (race-clean except logger global; Finding #2) + smell inventory

* test(audit): cover untested error/edge branches (TLS proxy+pin, migration tag cleanup=Finding #1)

* test(audit): strengthen internal/sub link tests (dedup key, TLS/Reality mapping, clash well-formedness)

* test(audit): property (rapid) + fuzz tests for joinHostPort/userinfo/pin/ParseLink

* test(audit): tighten frontend subSortIndex rejection assertions + wire coverage

* ci(audit): add shuffle gate + non-blocking race job (Finding #2) + fuzz-smoke; document mutation policy

* chore(audit): gitignore frontend coverage output

* test(audit): exhaustive whole-repo pass — strengthen 5 weak/fake tests (netproxy, CSP, modal per-protocol loops, schema coercions)

* docs(contributing): add Testing section (conventions, race/shuffle, fuzz, mutation policy); drop AUDIT.md ledger

* fix(logger,migration): guard logBuffer with mutex; execute legacy tag cleanup (tx.Exec); make CI race gate blocking

* ci(mutation): add nightly scoped gremlins workflow (informational artifacts)

* test(audit): strengthen runtime tests — baseURL scheme/port bounds, isNonEmptySlice, trafficReset

* test(audit): strengthen clash tests — reality field mapping + tcp-header validation

* test(audit): runtime — egress-proxy + content-type tests; drop redundant bp=='' branch

* test(audit): strengthen link parser/helper tests (defaultPort, splitComma, base64, canonicalQuery, tls/reality/transport mapping)

* test(audit): strengthen sub/xray/common/netsafe/mtproto/config/middleware tests (kill surviving mutants)

* test(audit): raise timeout on protocol-iteration modal tests (heavy re-renders, slow on CI)

* fix(logger): GetLogs returns at most c entries (off-by-one fix; addresses PR review)

* perf(logger): snapshot logBuffer under lock so GetLogs doesn't block logging; clarify fuzz-seed docs (addresses PR review)
2026-06-15 15:17:03 +02:00

218 lines
8.2 KiB
Go

package service
import (
"path/filepath"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound guards the
// PostgreSQL fix where the externalProxy detection query (executed via .Scan) errored on
// json_extract and rolled back the whole transaction — including the client_traffics
// backfill at inbound.go:3093-3106, leaving clients with no traffic rows. A MultiDomain
// inbound is present so that query returns rows and the function runs to completion; both
// the backfill and the MultiDomain→ExternalProxy migration must then commit.
func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(t *testing.T) {
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() })
db := database.GetDB()
const backfillEmail = "needsbackfill@example.com"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c010"
// Inbound A: a client present only in settings.clients, with no client_traffics row.
clientInbound := &model.Inbound{
UserId: 1,
Tag: "a-tag",
Enable: true,
Port: 30001,
Protocol: model.VLESS,
Settings: `{"clients":[{"email":"` + backfillEmail + `","id":"` + uid + `","enable":true}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(clientInbound).Error; err != nil {
t.Fatalf("create client inbound: %v", err)
}
// Inbound B: a legacy MultiDomain inbound whose tag carries the 0.0.0.0: prefix.
// Its presence makes the externalProxy query return rows, so the function does not
// early-return and reaches the tag-cleanup statement.
multiDomainInbound := &model.Inbound{
UserId: 1,
Tag: "inbound-0.0.0.0:30002",
Enable: true,
Port: 30002,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`,
}
if err := db.Create(multiDomainInbound).Error; err != nil {
t.Fatalf("create multidomain inbound: %v", err)
}
var before int64
if err := db.Model(xray.ClientTraffic{}).Count(&before).Error; err != nil {
t.Fatalf("count client_traffics before: %v", err)
}
if before != 0 {
t.Fatalf("expected no client_traffics before migration, got %d", before)
}
svc := InboundService{}
svc.MigrationRequirements()
// The backfill must have committed: the settings-only client now owns a row.
// Before the fix this was rolled back whenever the externalProxy detection query
// errored (it does on Postgres via json_extract), so the MultiDomain inbound below
// is deliberately present to make that query return rows and run to completion.
var ct xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", backfillEmail).First(&ct).Error; err != nil {
t.Fatalf("client_traffics row not backfilled for %s: %v", backfillEmail, err)
}
// The MultiDomain→ExternalProxy migration must have committed too: the detection
// query ran (.Scan executes it) and the loop rewrote the inbound's streamSettings.
var refreshed model.Inbound
if err := db.First(&refreshed, multiDomainInbound.Id).Error; err != nil {
t.Fatalf("reload multidomain inbound: %v", err)
}
if !strings.Contains(refreshed.StreamSettings, "externalProxy") {
t.Errorf("MultiDomain migration did not commit; streamSettings = %q", refreshed.StreamSettings)
}
}
// TestMigrationRequirements_CleansLegacyZeroAddrTag guards the legacy tag cleanup that
// strips the auto-generated "0.0.0.0:" prefix. The inbound is MultiDomain TLS so the
// externalProxy detection query returns rows and the cleanup is reached (it early-returns
// at len(externalProxy)==0 otherwise). The cleanup must use tx.Exec, not tx.Raw, which
// only builds a non-SELECT statement without running it.
func TestMigrationRequirements_CleansLegacyZeroAddrTag(t *testing.T) {
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() })
db := database.GetDB()
legacy := &model.Inbound{
UserId: 1,
Tag: "inbound-0.0.0.0:30002",
Enable: true,
Port: 30002,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`,
}
if err := db.Create(legacy).Error; err != nil {
t.Fatalf("create legacy inbound: %v", err)
}
svc := InboundService{}
svc.MigrationRequirements()
var got model.Inbound
if err := db.First(&got, legacy.Id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
if got.Tag != "inbound-30002" {
t.Fatalf("legacy 0.0.0.0: tag not stripped: got %q, want %q", got.Tag, "inbound-30002")
}
}
func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
invalidStrategy := &model.Inbound{
UserId: 1,
Tag: "invalid-share-strategy",
Enable: true,
Port: 31001,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
paddedStrategy := &model.Inbound{
UserId: 1,
Tag: "padded-share-strategy",
Enable: true,
Port: 31002,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
invalidAddress := &model.Inbound{
UserId: 1,
Tag: "invalid-share-address",
Enable: true,
Port: 31003,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(invalidStrategy).Error; err != nil {
t.Fatalf("create invalid strategy inbound: %v", err)
}
if err := db.Create(paddedStrategy).Error; err != nil {
t.Fatalf("create padded strategy inbound: %v", err)
}
if err := db.Create(invalidAddress).Error; err != nil {
t.Fatalf("create invalid address inbound: %v", err)
}
if err := db.Model(&model.Inbound{}).Where("id = ?", invalidStrategy.Id).Updates(map[string]any{
"share_addr_strategy": " auto ",
"share_addr": " edge.example.com ",
}).Error; err != nil {
t.Fatalf("seed invalid share fields: %v", err)
}
if err := db.Model(&model.Inbound{}).Where("id = ?", paddedStrategy.Id).Updates(map[string]any{
"share_addr_strategy": " listen ",
"share_addr": " 10.0.0.1 ",
}).Error; err != nil {
t.Fatalf("seed padded share fields: %v", err)
}
if err := db.Model(&model.Inbound{}).Where("id = ?", invalidAddress.Id).Updates(map[string]any{
"share_addr_strategy": "custom",
"share_addr": "edge.example.com:8443",
}).Error; err != nil {
t.Fatalf("seed invalid address share fields: %v", err)
}
svc := InboundService{}
svc.MigrationRequirements()
var gotInvalid model.Inbound
if err := db.First(&gotInvalid, invalidStrategy.Id).Error; err != nil {
t.Fatalf("reload invalid strategy inbound: %v", err)
}
if gotInvalid.ShareAddrStrategy != "node" || gotInvalid.ShareAddr != "edge.example.com" {
t.Fatalf("invalid share fields = (%q, %q), want (node, edge.example.com)", gotInvalid.ShareAddrStrategy, gotInvalid.ShareAddr)
}
var gotPadded model.Inbound
if err := db.First(&gotPadded, paddedStrategy.Id).Error; err != nil {
t.Fatalf("reload padded strategy inbound: %v", err)
}
if gotPadded.ShareAddrStrategy != "listen" || gotPadded.ShareAddr != "10.0.0.1" {
t.Fatalf("padded share fields = (%q, %q), want (listen, 10.0.0.1)", gotPadded.ShareAddrStrategy, gotPadded.ShareAddr)
}
var gotInvalidAddress model.Inbound
if err := db.First(&gotInvalidAddress, invalidAddress.Id).Error; err != nil {
t.Fatalf("reload invalid address inbound: %v", err)
}
if gotInvalidAddress.ShareAddrStrategy != "node" || gotInvalidAddress.ShareAddr != "" {
t.Fatalf("invalid address share fields = (%q, %q), want (node, empty)", gotInvalidAddress.ShareAddrStrategy, gotInvalidAddress.ShareAddr)
}
}