Files
3x-ui/internal/web/service/client_partial_update_test.go
T
Aleksey Fedorov e4935be861 fix(api): preserve omitted client fields in partial updates (#7042)
Keep omitted client fields and per-inbound flows unchanged while honoring explicit clears. Support the query aliases used to recover legacy dot-name clients and regenerate the API reference. Add an HTTP regression covering partial updates through those aliases.
2026-11-05 18:06:16 +01:00

161 lines
6.2 KiB
Go

package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
const partialTestPSK = "Pm4Hn0f8i8b6v3gJ0vZQ3yHq4mUe7qkq0m3o2l9sY9c="
func updateByEmailJSON(t *testing.T, svc *ClientService, email, body string) {
t.Helper()
if _, err := svc.UpdateByEmailJSON(&InboundService{}, email, []byte(body)); err != nil {
t.Fatalf("UpdateByEmailJSON(%s): %v", body, err)
}
}
func inboundClientByEmail(t *testing.T, inboundId int, email string) model.Client {
t.Helper()
inboundSvc := &InboundService{}
ib, err := inboundSvc.GetInbound(inboundId)
if err != nil {
t.Fatalf("GetInbound(%d): %v", inboundId, err)
}
clients, err := inboundSvc.GetClients(ib)
if err != nil {
t.Fatalf("GetClients(%d): %v", inboundId, err)
}
for _, c := range clients {
if c.Email == email {
return c
}
}
t.Fatalf("inbound %d has no client %q", inboundId, email)
return model.Client{}
}
// A body carrying only email and expiryTime used to disable the client and zero its
// limits, Telegram id, comment, group, renewal settings and limitHwid.
func TestUpdateByEmailJSONKeepsOmittedFields(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
ib := mkInbound(t, 52111, model.AmneziaWG, `{"server":{"privateKey":"server","publicKey":"server-pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
seed := model.Client{
Email: "full@awg", Enable: true,
PrivateKey: "client-private", PublicKey: "client-public",
PreSharedKey: partialTestPSK,
AllowedIPs: []string{"10.8.1.8/32"},
LimitIP: 3, TotalGB: 50 << 30, ExpiryTime: 1700000000000,
TgID: 123456, Comment: "laptop", Group: "family",
Reset: 30, ResetMax: 4,
}
seed.AllowedIPsByInbound = map[int][]string{ib.Id: seed.AllowedIPs}
if _, err := svc.CreateOne(&InboundService{}, ib.Id, seed); err != nil {
t.Fatalf("seed client: %v", err)
}
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", seed.Email).
UpdateColumn("limit_hwid", 2).Error; err != nil {
t.Fatal(err)
}
updateByEmailJSON(t, svc, seed.Email, `{"email": "full@awg", "expiryTime": 1893456000000}`)
rec := lookupClientRecord(t, seed.Email)
if !rec.Enable {
t.Fatal("partial update disabled the client")
}
if rec.ExpiryTime != 1893456000000 {
t.Fatalf("expiry not applied: %d", rec.ExpiryTime)
}
if rec.LimitIP != 3 || rec.TotalGB != 50<<30 || rec.TgID != 123456 || rec.Comment != "laptop" ||
rec.Group != "family" || rec.Reset != 30 || rec.ResetMax != 4 || rec.LimitHwid != 2 {
t.Fatalf("partial update reset omitted fields: %+v", rec)
}
peer := inboundClientByEmail(t, ib.Id, seed.Email)
if !peer.Enable || peer.PreSharedKey != partialTestPSK || peer.PrivateKey != seed.PrivateKey {
t.Fatalf("inbound peer lost its state: %+v", peer)
}
updateByEmailJSON(t, svc, seed.Email, `{"enable": false, "comment": "", "totalGB": 0}`)
rec = lookupClientRecord(t, seed.Email)
if rec.Enable || rec.Comment != "" || rec.TotalGB != 0 {
t.Fatalf("explicit zeros were not applied: %+v", rec)
}
if rec.ExpiryTime != 1893456000000 || rec.LimitIP != 3 || rec.Group != "family" || rec.LimitHwid != 2 {
t.Fatalf("second partial update reset omitted fields: %+v", rec)
}
}
// encoding/json binds "limitIP" to limitIp; a case-sensitive presence check then
// overwrote the decoded zero with the stored value.
func TestUpdateByEmailJSONMatchesKeysCaseInsensitively(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
ib := mkInbound(t, 52112, model.VLESS, `{"clients":[]}`)
seed := model.Client{
Email: "case@vless", ID: "0d1b6f8e-3c2a-4f61-9a43-5b7f0c2e9d11", Enable: true,
LimitIP: 3, TgID: 123456, ExpiryTime: 1700000000000,
}
if _, err := svc.CreateOne(&InboundService{}, ib.Id, seed); err != nil {
t.Fatalf("seed client: %v", err)
}
updateByEmailJSON(t, svc, seed.Email, `{"limitIP": 0, "tgID": 0, "expirytime": 1893456000000}`)
rec := lookupClientRecord(t, seed.Email)
if rec.LimitIP != 0 || rec.TgID != 0 || rec.ExpiryTime != 1893456000000 {
t.Fatalf("differently-cased keys were not applied: limitIp=%d tgId=%d expiry=%d", rec.LimitIP, rec.TgID, rec.ExpiryTime)
}
if !rec.Enable {
t.Fatal("partial update disabled the client")
}
}
// clients.flow is unreliable for a multi-inbound client (#4792): an omitted flow
// must keep each inbound's own flow instead of copying that column everywhere.
func TestUpdateByEmailJSONOmittedFlowKeepsEachInboundsFlow(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
const vision = "xtls-rprx-vision"
reality := mkInboundStream(t, 52113, model.VLESS, `{"clients":[]}`, realityStream)
ws := mkInboundStream(t, 52114, model.VLESS, `{"clients":[]}`, wsStream)
seed := model.Client{
Email: "flow@vless", ID: "6a0f2c1e-8b4d-4e7a-9c35-1d2e3f4a5b6c", Enable: true, Flow: vision,
Reverse: &model.ClientReverse{Tag: "back"},
}
if _, err := svc.Create(&InboundService{}, &ClientCreatePayload{Client: seed, InboundIds: []int{reality.Id, ws.Id}}); err != nil {
t.Fatalf("seed client: %v", err)
}
if rec := lookupClientRecord(t, seed.Email); rec.Reverse == "" {
t.Fatal("seed did not store the reverse tag")
}
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", seed.Email).
UpdateColumn("flow", "").Error; err != nil {
t.Fatalf("zero clients.flow: %v", err)
}
updateByEmailJSON(t, svc, seed.Email, `{"expiryTime": 1893456000000}`)
if got := inboundClientByEmail(t, reality.Id, seed.Email); got.Flow != vision || got.ExpiryTime != 1893456000000 {
t.Fatalf("Reality inbound: flow=%q expiry=%d, want %q and the new expiry", got.Flow, got.ExpiryTime, vision)
}
if got := inboundClientByEmail(t, ws.Id, seed.Email); got.Flow != "" {
t.Fatalf("WS inbound gained flow %q", got.Flow)
}
if rec := lookupClientRecord(t, seed.Email); rec.Reverse == "" {
t.Fatal("an omitted reverse must keep the tag")
}
updateByEmailJSON(t, svc, seed.Email, `{"reverse": null}`)
if rec := lookupClientRecord(t, seed.Email); rec.Reverse != "" {
t.Fatalf("an explicit null reverse must clear the tag: %q", rec.Reverse)
}
updateByEmailJSON(t, svc, seed.Email, `{"flow": ""}`)
if got := inboundClientByEmail(t, reality.Id, seed.Email); got.Flow != "" {
t.Fatalf("an explicit empty flow must clear Vision, got %q", got.Flow)
}
}