Files
3x-ui/internal/web/service/client_renewal_write_validation_test.go
T
MHSanaei 823db05966 fix(inbounds): keep the stored client list and enable on inbound save
Invariant: saving an inbound's configuration never changes which clients it
holds nor whether it is enabled; both have their own endpoints. The edit
modal posts back the clients and the enable flag it loaded when it opened.
A client added meanwhile (another admin, the bot, the API, LDAP) was
detached and its stats deleted; a client deleted meanwhile came back with
its credentials, restoring access that had been revoked; an inbound
switched off meanwhile was switched back on.

For every save but a master's node-sync push, UpdateInbound now takes the
client list and enable from the row it re-reads inside the writer; this
replaces the lifecycle-only carry from the previous commit. Client
validation (renewal schedule, Hysteria auth, TUIC credentials) moves after
that swap so it judges the clients actually saved: a protocol switch keeps
the stored clients, and #6268's refusal must apply to them.

The edit form no longer loads or sends clients, so neither the JSON editor
nor validation sees a copy the server ignores, and the enable switch shows
only when adding; the list toggle (/setEnable) covers existing inbounds.

Tests that added or re-keyed clients through a panel inbound save pinned
the old rule; they now drive the master-push path, where payload clients
still apply.
2026-09-28 13:23:48 +02:00

133 lines
5.9 KiB
Go

package service
import (
"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/xray"
)
func TestClientRenewalWriteValidation(t *testing.T) {
for _, operation := range []string{"add inbound", "update inbound", "add inbound client", "update inbound client", "sync inbound", "client delta", "add stat", "update stat", "import stat"} {
for _, schedule := range []struct {
name string
reset, day, weekday int
errorMessage string
}{
{"weekly and interval", 7, 0, 3, "client weekly renewal cannot be combined with reset or resetDay"},
{"weekly and monthly", 0, 1, 3, "client weekly renewal cannot be combined with reset or resetDay"},
{"invalid weekday", 0, 0, 8, "client resetWeekday must be between 0 and 7, got: 8"},
} {
t.Run(operation+"/"+schedule.name, func(t *testing.T) {
setupConflictDB(t)
nodeID, fake := setupNodeRuntime(t)
svc, inboundSvc := &ClientService{}, &InboundService{}
client := model.Client{Email: "renewal-boundary", ID: "11111111-1111-1111-1111-111111111111", SubID: "renewal-boundary-sub", Enable: true, ResetWeekday: 3, ResetMax: 4, ExpiryTime: time.Now().Add(time.Hour).UnixMilli()}
inbound := nodeInbound(t, nodeID, 41759, []model.Client{client})
if err := inboundSvc.AddClientStat(database.GetDB(), inbound.Id, &client); err != nil {
t.Fatal(err)
}
record, err := svc.GetRecordByEmail(nil, client.Email)
if err != nil {
t.Fatal(err)
}
beforeRecord := *record
beforeTraffic := readTraffic(t, database.GetDB(), client.Email)
beforeSettings := inbound.Settings
client.Reset, client.ResetDay, client.ResetWeekday = schedule.reset, schedule.day, schedule.weekday
update := *inbound
update.Settings = clientsSettings(t, []model.Client{client})
switch operation {
case "add inbound", "import stat":
update.Id, update.Port, update.Tag = 0, 41760, "renewal-boundary-new"
if operation == "import stat" {
update.Settings = beforeSettings
update.ClientStats = []xray.ClientTraffic{{Email: "invalid-import-stat", Reset: schedule.reset, ResetDay: schedule.day, ResetWeekday: schedule.weekday}}
}
_, _, err = inboundSvc.AddInbound(&update)
case "update inbound":
// A panel save keeps the stored clients; only a master's push writes them.
_, _, err = (&InboundService{FromNodeSync: true}).UpdateInbound(&update)
case "add inbound client":
client.Email = "invalid-new-client"
update.Settings = clientsSettings(t, []model.Client{client})
_, err = svc.AddInboundClient(inboundSvc, &update)
case "update inbound client":
_, err = svc.UpdateInboundClient(inboundSvc, &update, client.Email)
case "sync inbound":
err = svc.SyncInbound(nil, inbound.Id, []model.Client{client})
case "client delta":
err = svc.ApplyInboundClientDelta(nil, inbound.Id, []model.Client{client}, nil)
case "add stat":
err = inboundSvc.AddClientStat(database.GetDB(), inbound.Id, &client)
case "update stat":
err = inboundSvc.UpdateClientStat(database.GetDB(), client.Email, &client)
}
if err == nil || err.Error() != schedule.errorMessage+"\n" {
t.Fatalf("write error = %v, want %q", err, schedule.errorMessage)
}
var persisted model.Inbound
if err := database.GetDB().First(&persisted, inbound.Id).Error; err != nil {
t.Fatal(err)
}
if persisted.Settings != beforeSettings {
t.Fatal("rejected write changed inbound settings")
}
record, err = svc.GetRecordByEmail(nil, beforeRecord.Email)
if err != nil || *record != beforeRecord || readTraffic(t, database.GetDB(), beforeRecord.Email) != beforeTraffic {
t.Fatalf("rejected write changed client/traffic: record=%+v error=%v", record, err)
}
for _, table := range []string{"inbounds", "clients", "client_traffics"} {
var count int64
if err := database.GetDB().Table(table).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("%s count/error = %d/%v, want 1/nil", table, count, err)
}
}
if fake.addInbound.Load() != 0 || fake.updateInbound.Load() != 0 || fake.delInbound.Load() != 0 || fake.addClient.Load() != 0 || fake.updateUser.Load() != 0 {
t.Fatal("rejected write dispatched to the runtime")
}
})
}
}
}
func TestInboundRenewalModesRemainEditable(t *testing.T) {
for _, weekly := range []bool{false, true} {
name := "legacy monthly with interval"
if weekly {
name = "weekly"
}
t.Run(name, func(t *testing.T) {
setupConflictDB(t)
nodeID, _ := setupNodeRuntime(t)
svc, inboundSvc := &ClientService{}, &InboundService{}
client := model.Client{Email: "renewal-editable", ID: "11111111-1111-1111-1111-111111111111", Enable: true, Reset: 7, ResetDay: 1, ResetMax: 4, ExpiryTime: time.Now().Add(time.Hour).UnixMilli()}
if weekly {
client.Reset, client.ResetDay, client.ResetWeekday = 0, 0, 3
}
inbound := &model.Inbound{Tag: "renewal-editable", NodeID: &nodeID, Port: 41761, Protocol: model.VLESS, Enable: true, Settings: clientsSettings(t, []model.Client{client})}
if _, _, err := inboundSvc.AddInbound(inbound); err != nil {
t.Fatal(err)
}
if _, _, err := inboundSvc.UpdateInbound(inbound); err != nil {
t.Fatal(err)
}
record, err := svc.GetRecordByEmail(nil, client.Email)
if err != nil {
t.Fatal(err)
}
toggle := *record.ToClient()
toggle.Enable = false
if _, err := svc.Update(inboundSvc, record.Id, toggle, 0); err != nil {
t.Fatalf("valid inbound client could not be toggled: %v", err)
}
record, err = svc.GetRecordByEmail(nil, client.Email)
if err != nil || record.Enable || record.Reset != client.Reset || record.ResetDay != client.ResetDay || record.ResetWeekday != client.ResetWeekday || record.ResetMax != 4 {
t.Fatalf("valid schedule/toggle not preserved: %+v/%v", record, err)
}
})
}
}