Files
3x-ui/internal/web/service/mtproto_quota_reset_test.go
T
Jay Hemnani 1dbb37432c fix(traffic): re-enable quota-disabled clients on inbound and reset-all resets (#6833)
Running out of quota switches a client off in client_traffics, in its client
record and in the inbound settings JSON. The inbound's periodic reset and the
Reset All Client Traffic button only set client_traffics.enable back, so the
client showed zero usage but stayed out of the running core every period.

Both paths now read the disabled, depleted emails inside the transaction that
zeroes the counters, on the traffic writer, and re-enable them through
BulkSetEnable after the commit, like every other reset path. Reading them inside
that transaction keeps a traffic tick from disabling a client between the read
and the reset.
2026-10-24 04:21:11 +02:00

138 lines
5.6 KiB
Go

package service
import (
"slices"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// startQuotaSidecar runs a local MTProto inbound for mtga and mtgb under the fake
// mtg and returns its API log once the sidecar answers.
func startQuotaSidecar(t *testing.T, port int, mtga model.Client) (*model.Inbound, string) {
t.Helper()
setupConflictDB(t)
pidFile, logPath := installFakeMtgAPI(t)
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
t.Cleanup(func() { runtime.SetManager(nil) })
mtga.Email, mtga.Secret = "mtga", mtprotoTestSecretA
clients := []model.Client{mtga, {Email: "mtgb", Secret: mtprotoTestSecretB, Enable: true}}
ib := &model.Inbound{Tag: "mt-quota", Enable: true, Port: port, Protocol: model.MTProto, Settings: clientsSettings(t, clients)}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
for _, c := range clients {
row := xray.ClientTraffic{InboundId: ib.Id, Email: c.Email, Enable: true, Up: 5, Total: c.TotalGB, ExpiryTime: c.ExpiryTime, Reset: c.Reset}
if err := database.GetDB().Create(&row).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
}
// A running sidecar needs a served client, so prime with the healthy set.
inst, ok := mtproto.InstanceFromInbound(&model.Inbound{
Id: ib.Id, Tag: ib.Tag, Port: port, Protocol: model.MTProto,
Settings: clientsSettings(t, []model.Client{{Email: "mtgb", Secret: mtprotoTestSecretB, Enable: true}}),
})
if !ok {
t.Fatal("seed inbound must produce an mtg instance")
}
if err := mtproto.GetManager().Ensure(inst); err != nil {
t.Fatalf("start mtg: %v", err)
}
t.Cleanup(func() { mtproto.GetManager().Remove(ib.Id) })
waitForSpawns(t, pidFile, 1)
waitFakeMtgLog(t, logPath, "ready")
return ib, logPath
}
func quotaResets(t *testing.T, logPath string) []string {
t.Helper()
var out []string
for _, line := range fakeMtgLog(t, logPath) {
if name, ok := strings.CutPrefix(line, "reset:"); ok {
out = append(out, name)
}
}
slices.Sort(out)
return out
}
// Every path that zeroes a client's panel counters must zero the sidecar's own
// quota counter too, or the sidecar keeps refusing the client.
func TestPanelResetsZeroSidecarQuota(t *testing.T) {
t.Run("bulk reset", func(t *testing.T) {
_, logPath := startQuotaSidecar(t, 46201, model.Client{Enable: true})
if _, err := (&ClientService{}).BulkResetTraffic(&InboundService{}, []string{"mtga"}); err != nil {
t.Fatalf("BulkResetTraffic: %v", err)
}
if got := quotaResets(t, logPath); !slices.Equal(got, []string{"mtga"}) {
t.Fatalf("sidecar quota resets %v, want [mtga]", got)
}
})
t.Run("inbound clients", func(t *testing.T) {
ib, logPath := startQuotaSidecar(t, 46207, model.Client{Enable: true})
if _, err := (&ClientService{}).ResetAllClientTraffics(&InboundService{}, ib.Id); err != nil {
t.Fatalf("ResetAllClientTraffics: %v", err)
}
if got := quotaResets(t, logPath); !slices.Equal(got, []string{"mtga", "mtgb"}) {
t.Fatalf("sidecar quota resets %v, want [mtga mtgb]", got)
}
})
t.Run("reset all", func(t *testing.T) {
_, logPath := startQuotaSidecar(t, 46202, model.Client{Enable: true})
if _, err := (&ClientService{}).ResetAllTraffics(); err != nil {
t.Fatalf("ResetAllTraffics: %v", err)
}
if got := quotaResets(t, logPath); !slices.Equal(got, []string{"mtga", "mtgb"}) {
t.Fatalf("sidecar quota resets %v, want [mtga mtgb]", got)
}
})
t.Run("auto renew", func(t *testing.T) {
expired := time.Now().Add(-time.Hour).UnixMilli()
_, logPath := startQuotaSidecar(t, 46203, model.Client{Enable: true, Reset: 30, ExpiryTime: expired})
if _, _, err := (&InboundService{}).AddTraffic(nil, nil); err != nil {
t.Fatalf("AddTraffic: %v", err)
}
if got := quotaResets(t, logPath); !slices.Equal(got, []string{"mtga"}) {
t.Fatalf("sidecar quota resets %v, want [mtga]", got)
}
})
}
// Resetting inbound counters leaves every client's usage in place, so the
// sidecar's quota counters must stay too or clients get their quota again free.
func TestInboundResetAllKeepsSidecarQuota(t *testing.T) {
_, logPath := startQuotaSidecar(t, 46204, model.Client{Enable: true})
if err := (&InboundService{}).ResetAllTraffics(); err != nil {
t.Fatalf("ResetAllTraffics: %v", err)
}
if got := quotaResets(t, logPath); len(got) != 0 {
t.Fatalf("inbound reset zeroed sidecar quotas %v, want none", got)
}
}
// Resetting one inbound's clients zeroes only their sidecar quotas, not those
// of MTProto clients whose usage the reset left in place.
func TestInboundClientResetKeepsOtherSidecarQuotas(t *testing.T) {
_, logPath := startQuotaSidecar(t, 46205, model.Client{Enable: true})
other := mkInbound(t, 46206, model.VLESS, clientsSettings(t, []model.Client{{Email: "vless-only", ID: "11111111-1111-1111-1111-1111111111ab", Enable: true}}))
if err := (&ClientService{}).SyncInbound(nil, other.Id, []model.Client{{Email: "vless-only", ID: "11111111-1111-1111-1111-1111111111ab", Enable: true}}); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if _, err := (&ClientService{}).ResetAllClientTraffics(&InboundService{}, other.Id); err != nil {
t.Fatalf("ResetAllClientTraffics: %v", err)
}
if got := quotaResets(t, logPath); len(got) != 0 {
t.Fatalf("resetting another inbound zeroed sidecar quotas %v, want none", got)
}
}