| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459 |
- package service
- import (
- "encoding/json"
- "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"
- )
- // TestExportImportPreservesDisabledEnable covers #6478: ExportAll keeps the
- // real enable flag; ImportClients must not force enable=true.
- func TestExportImportPreservesDisabledEnable(t *testing.T) {
- setupBulkDB(t)
- svc := &ClientService{}
- inboundSvc := &InboundService{}
- ib := mkInbound(t, 26001, model.VLESS, `{"clients":[]}`)
- const email = "portable@disabled"
- const subID = "sub-portable-disabled"
- if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
- Client: model.Client{
- Email: email, SubID: subID, Enable: true,
- ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
- },
- InboundIds: []int{ib.Id},
- }); err != nil {
- t.Fatalf("Create: %v", err)
- }
- rec := lookupClientRecord(t, email)
- updated := rec.ToClient()
- updated.Enable = false
- if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
- t.Fatalf("Update disable: %v", err)
- }
- assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
- exported, err := svc.ExportAll()
- if err != nil {
- t.Fatalf("ExportAll: %v", err)
- }
- if len(exported) != 1 {
- t.Fatalf("ExportAll len=%d, want 1", len(exported))
- }
- if exported[0].Client.Enable {
- t.Fatal("ExportAll should carry enable=false for a disabled client")
- }
- raw, err := json.Marshal(exported)
- if err != nil {
- t.Fatalf("marshal export: %v", err)
- }
- var roundTrip []ClientCreatePayload
- if err := json.Unmarshal(raw, &roundTrip); err != nil {
- t.Fatalf("unmarshal export: %v", err)
- }
- if roundTrip[0].Client.Enable {
- t.Fatal("JSON round-trip lost enable=false")
- }
- if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil {
- t.Fatalf("Delete: %v", err)
- }
- res, _, err := svc.ImportClients(inboundSvc, roundTrip)
- if err != nil {
- t.Fatalf("ImportClients: %v", err)
- }
- if res.Created != 1 || len(res.Skipped) != 0 {
- t.Fatalf("ImportClients result=%+v", res)
- }
- assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
- }
- func TestImportClientsPreservesOrphanDisabledEnable(t *testing.T) {
- setupBulkDB(t)
- svc := &ClientService{}
- items := []ClientCreatePayload{{
- Client: model.Client{
- Email: "orphan@disabled", SubID: "sub-orphan-disabled", Enable: false,
- ID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
- },
- InboundIds: nil,
- }}
- res, _, err := svc.ImportClients(&InboundService{}, items)
- if err != nil {
- t.Fatalf("ImportClients orphan: %v", err)
- }
- if res.Created != 1 {
- t.Fatalf("created=%d, want 1; skipped=%v", res.Created, res.Skipped)
- }
- if got := recordEnableOf(t, svc, "orphan@disabled"); got {
- t.Fatal("orphan import forced enable=true; want false")
- }
- }
- func TestBulkCreatePreservesExplicitDisable(t *testing.T) {
- setupBulkDB(t)
- svc := &ClientService{}
- inboundSvc := &InboundService{}
- ib := mkInbound(t, 26002, model.VLESS, `{"clients":[]}`)
- const email = "bulk@disabled"
- res, _, err := svc.BulkCreate(inboundSvc, []ClientCreatePayload{{
- Client: model.Client{
- Email: email, SubID: "sub-bulk-disabled", Enable: false,
- ID: "cccccccc-cccc-cccc-cccc-cccccccccccc",
- },
- InboundIds: []int{ib.Id},
- }})
- if err != nil {
- t.Fatalf("BulkCreate: %v", err)
- }
- if res.Created != 1 {
- t.Fatalf("BulkCreate result=%+v", res)
- }
- assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
- }
- func TestClientCreatePayload_OmitEnableDefaultsTrue(t *testing.T) {
- raw := []byte(`{"client":{"email":"omit@x","id":"dddddddd-dddd-dddd-dddd-dddddddddddd","subId":"sub-omit"},"inboundIds":[1]}`)
- var p ClientCreatePayload
- if err := json.Unmarshal(raw, &p); err != nil {
- t.Fatalf("unmarshal: %v", err)
- }
- if !p.Client.Enable {
- t.Fatal("omitted enable must default to true")
- }
- rawFalse := []byte(`{"client":{"email":"off@x","id":"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee","subId":"sub-off","enable":false},"inboundIds":[1]}`)
- var pFalse ClientCreatePayload
- if err := json.Unmarshal(rawFalse, &pFalse); err != nil {
- t.Fatalf("unmarshal false: %v", err)
- }
- if pFalse.Client.Enable {
- t.Fatal("explicit enable:false must stay false")
- }
- }
- func TestBulkCreate_DisabledOnNodeSkipsAddClient(t *testing.T) {
- setupBulkDB(t)
- nodeID, fake := setupNodeRuntime(t)
- ib := nodeInbound(t, nodeID, 26003, nil)
- svc := &ClientService{}
- inboundSvc := &InboundService{}
- const email = "node@disabled"
- res, _, err := svc.BulkCreate(inboundSvc, []ClientCreatePayload{{
- Client: model.Client{
- Email: email, SubID: "sub-node-disabled", Enable: false,
- ID: "ffffffff-ffff-ffff-ffff-ffffffffffff",
- },
- InboundIds: []int{ib.Id},
- }})
- if err != nil {
- t.Fatalf("BulkCreate: %v", err)
- }
- if res.Created != 1 {
- t.Fatalf("BulkCreate result=%+v", res)
- }
- if got := fake.addClient.Load(); got != 0 {
- t.Fatalf("AddClient RPCs = %d, want 0 for enable=false", got)
- }
- assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
- if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(nodeID); err != nil {
- t.Fatalf("NodeSyncState: %v", err)
- } else if !dirty {
- t.Fatal("disabled node create must leave node dirty for reconcile")
- }
- }
- func TestExportImportPreservesTrafficCounters(t *testing.T) {
- setupBulkDB(t)
- svc := &ClientService{}
- inboundSvc := &InboundService{}
- ib := mkInbound(t, 25001, model.VLESS, `{"clients":[]}`)
- const email = "portable@traffic"
- const subID = "sub-portable-traffic"
- if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
- Client: model.Client{
- Email: email, SubID: subID, Enable: true,
- TotalGB: 10 << 30, ExpiryTime: 1_700_000_000_000,
- },
- InboundIds: []int{ib.Id},
- }); err != nil {
- t.Fatalf("Create: %v", err)
- }
- db := database.GetDB()
- if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Updates(map[string]any{
- "up": 111, "down": 222, "reset_count": 3, "last_online": 999,
- }).Error; err != nil {
- t.Fatalf("seed traffic: %v", err)
- }
- exported, err := svc.ExportAll()
- if err != nil {
- t.Fatalf("ExportAll: %v", err)
- }
- if len(exported) != 1 {
- t.Fatalf("ExportAll len=%d, want 1", len(exported))
- }
- if exported[0].Traffic == nil {
- t.Fatal("ExportAll missing traffic snapshot")
- }
- if exported[0].Traffic.Up != 111 || exported[0].Traffic.Down != 222 || exported[0].Traffic.ResetCount != 3 {
- t.Fatalf("exported traffic = %+v, want up=111 down=222 resetCount=3", exported[0].Traffic)
- }
- raw, err := json.Marshal(exported)
- if err != nil {
- t.Fatalf("marshal export: %v", err)
- }
- var roundTrip []ClientCreatePayload
- if err := json.Unmarshal(raw, &roundTrip); err != nil {
- t.Fatalf("unmarshal export: %v", err)
- }
- if roundTrip[0].Traffic == nil || roundTrip[0].Traffic.Up != 111 {
- t.Fatalf("JSON round-trip lost traffic: %+v", roundTrip[0].Traffic)
- }
- rec := lookupClientRecord(t, email)
- if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil {
- t.Fatalf("Delete: %v", err)
- }
- var gone int64
- if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Count(&gone).Error; err != nil {
- t.Fatalf("count after delete: %v", err)
- }
- if gone != 0 {
- t.Fatalf("client_traffics still present after delete: %d", gone)
- }
- res, _, err := svc.ImportClients(inboundSvc, roundTrip)
- if err != nil {
- t.Fatalf("ImportClients: %v", err)
- }
- if res.Created != 1 || len(res.Skipped) != 0 {
- t.Fatalf("ImportClients result=%+v", res)
- }
- var restored xray.ClientTraffic
- if err := db.Where("email = ?", email).First(&restored).Error; err != nil {
- t.Fatalf("lookup restored traffic: %v", err)
- }
- if restored.Up != 111 || restored.Down != 222 || restored.ResetCount != 3 || restored.LastOnline != 999 {
- t.Fatalf("restored traffic = %+v, want up=111 down=222 resetCount=3 lastOnline=999", restored)
- }
- if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Updates(map[string]any{
- "up": 5000, "down": 6000,
- }).Error; err != nil {
- t.Fatalf("bump live traffic: %v", err)
- }
- // Same email+subId is a BulkCreate reuse (may count as Created), not a hard
- // skip — traffic apply must still refuse to overwrite the live counters.
- if _, _, err := svc.ImportClients(inboundSvc, roundTrip); err != nil {
- t.Fatalf("second ImportClients: %v", err)
- }
- var live xray.ClientTraffic
- if err := db.Where("email = ?", email).First(&live).Error; err != nil {
- t.Fatalf("lookup live traffic: %v", err)
- }
- if live.Up != 5000 || live.Down != 6000 {
- t.Fatalf("re-import of existing email must leave live traffic alone, got up=%d down=%d", live.Up, live.Down)
- }
- }
- func TestImportClientsAppliesTrafficForOrphans(t *testing.T) {
- setupBulkDB(t)
- svc := &ClientService{}
- items := []ClientCreatePayload{{
- Client: model.Client{
- Email: "orphan@traffic", SubID: "sub-orphan-traffic", Enable: true,
- TotalGB: 1 << 30,
- },
- InboundIds: nil,
- Traffic: &ClientPortableTraffic{
- Up: 7, Down: 8, ResetCount: 1,
- },
- }}
- res, _, err := svc.ImportClients(&InboundService{}, items)
- if err != nil {
- t.Fatalf("ImportClients orphan: %v", err)
- }
- if res.Created != 1 {
- t.Fatalf("created=%d, want 1", res.Created)
- }
- var traf xray.ClientTraffic
- if err := database.GetDB().Where("email = ?", "orphan@traffic").First(&traf).Error; err != nil {
- t.Fatalf("orphan traffic row missing: %v", err)
- }
- if traf.Up != 7 || traf.Down != 8 || traf.ResetCount != 1 {
- t.Fatalf("orphan traffic = %+v", traf)
- }
- }
- // An orphan's restored row must carry its weekly schedule, or depletedClientsClause
- // treats the over-quota renewing client as depleted and DelDepleted deletes it.
- func TestImportClientsOrphanTrafficKeepsWeeklyRenewal(t *testing.T) {
- const email = "weekly@orphan"
- cases := []struct {
- name string
- seed func(t *testing.T, svc *ClientService, inboundSvc *InboundService)
- }{
- {name: "no prior row", seed: func(*testing.T, *ClientService, *InboundService) {}},
- {
- name: "row kept by keepTraffic delete",
- seed: func(t *testing.T, svc *ClientService, inboundSvc *InboundService) {
- ib := mkInbound(t, 25003, model.VLESS, `{"clients":[]}`)
- if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
- Client: model.Client{Email: email, SubID: "sub-weekly-old", Enable: true, TotalGB: 1 << 30},
- InboundIds: []int{ib.Id},
- }); err != nil {
- t.Fatalf("Create: %v", err)
- }
- if _, err := svc.Delete(inboundSvc, lookupClientRecord(t, email).Id, true); err != nil {
- t.Fatalf("Delete keepTraffic: %v", err)
- }
- },
- },
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- setupBulkDB(t)
- svc := &ClientService{}
- inboundSvc := &InboundService{}
- tc.seed(t, svc, inboundSvc)
- items := []ClientCreatePayload{{
- Client: model.Client{
- Email: email, SubID: "sub-weekly-orphan", Enable: true,
- TotalGB: 1 << 30, ResetWeekday: 3,
- },
- Traffic: &ClientPortableTraffic{Up: 1 << 30, Down: 1},
- }}
- if res, _, err := svc.ImportClients(inboundSvc, items); err != nil || res.Created != 1 {
- t.Fatalf("ImportClients result=%+v err=%v, want 1 created", res, err)
- }
- deleted, _, err := svc.DelDepleted(inboundSvc)
- if err != nil {
- t.Fatalf("DelDepleted: %v", err)
- }
- if deleted != 0 {
- t.Fatalf("DelDepleted deleted %d weekly-renewing client(s), want 0", deleted)
- }
- lookupClientRecord(t, email)
- })
- }
- }
- // Restored counters are usage from before the import, so they must not move the
- // group total at import time: a delete+re-import would otherwise count them twice.
- func TestImportClientsTrafficLeavesGroupTotalUnchanged(t *testing.T) {
- t.Run("re-import after delete on the same panel", func(t *testing.T) {
- setupBulkDB(t)
- svc := &ClientService{}
- inboundSvc := &InboundService{}
- ib := mkInbound(t, 25004, model.VLESS, `{"clients":[]}`)
- const email = "grouped@traffic"
- if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
- Client: model.Client{Email: email, SubID: "sub-grouped", Enable: true, Group: "g"},
- InboundIds: []int{ib.Id},
- }); err != nil {
- t.Fatalf("Create: %v", err)
- }
- if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", email).
- Updates(map[string]any{"up": 100, "down": 200}).Error; err != nil {
- t.Fatalf("seed traffic: %v", err)
- }
- exported, err := svc.ExportAll()
- if err != nil {
- t.Fatalf("ExportAll: %v", err)
- }
- if _, err := svc.Delete(inboundSvc, lookupClientRecord(t, email).Id, false); err != nil {
- t.Fatalf("Delete: %v", err)
- }
- if g := groupByName(t, svc, "g"); g.TrafficUsed != 300 {
- t.Fatalf("group after delete = %d, want the kept 300", g.TrafficUsed)
- }
- if res, _, err := svc.ImportClients(inboundSvc, exported); err != nil || res.Created != 1 {
- t.Fatalf("ImportClients result=%+v err=%v, want 1 created", res, err)
- }
- if g := groupByName(t, svc, "g"); g.Up != 100 || g.Down != 200 {
- t.Fatalf("group after re-import up=%d down=%d, want unchanged 100/200", g.Up, g.Down)
- }
- })
- t.Run("new panel starts the group at zero", func(t *testing.T) {
- setupBulkDB(t)
- svc := &ClientService{}
- ib := mkInbound(t, 25005, model.VLESS, `{"clients":[]}`)
- items := []ClientCreatePayload{
- {Client: model.Client{Email: "attached@g", SubID: "sub-attached-g", Enable: true, Group: "g"}, InboundIds: []int{ib.Id}, Traffic: &ClientPortableTraffic{Up: 100, Down: 200}},
- {Client: model.Client{Email: "orphan@g", SubID: "sub-orphan-g", Enable: true, Group: "g"}, Traffic: &ClientPortableTraffic{Up: 10, Down: 20}},
- }
- if res, _, err := svc.ImportClients(&InboundService{}, items); err != nil || res.Created != 2 {
- t.Fatalf("ImportClients result=%+v err=%v, want 2 created", res, err)
- }
- if g := groupByName(t, svc, "g"); g.TrafficUsed != 0 {
- t.Fatalf("group after import = %d (up=%d down=%d), want 0", g.TrafficUsed, g.Up, g.Down)
- }
- })
- }
- // A duplicate email in the file is skipped, but the copy that was created must
- // still get its own counters rather than none or the skipped copy's.
- func TestImportClientsDuplicateEmailRestoresCreatedCopy(t *testing.T) {
- cases := []struct {
- name string
- items func(ibID int) []ClientCreatePayload
- }{
- {
- name: "second attached copy skipped",
- items: func(ibID int) []ClientCreatePayload {
- return []ClientCreatePayload{
- {Client: model.Client{Email: "dup@traffic", SubID: "sub-dup-a", Enable: true}, InboundIds: []int{ibID}, Traffic: &ClientPortableTraffic{Up: 11, Down: 12}},
- {Client: model.Client{Email: "dup@traffic", SubID: "sub-dup-b", Enable: true}, InboundIds: []int{ibID}, Traffic: &ClientPortableTraffic{Up: 99, Down: 99}},
- }
- },
- },
- {
- name: "earlier orphan copy skipped",
- items: func(ibID int) []ClientCreatePayload {
- return []ClientCreatePayload{
- {Client: model.Client{Email: "dup@traffic", SubID: "sub-dup-b", Enable: true}, Traffic: &ClientPortableTraffic{Up: 99, Down: 99}},
- {Client: model.Client{Email: "dup@traffic", SubID: "sub-dup-a", Enable: true}, InboundIds: []int{ibID}, Traffic: &ClientPortableTraffic{Up: 11, Down: 12}},
- }
- },
- },
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- setupBulkDB(t)
- svc := &ClientService{}
- ib := mkInbound(t, 25002, model.VLESS, `{"clients":[]}`)
- res, _, err := svc.ImportClients(&InboundService{}, tc.items(ib.Id))
- if err != nil {
- t.Fatalf("ImportClients: %v", err)
- }
- if res.Created != 1 || len(res.Skipped) != 1 {
- t.Fatalf("ImportClients result=%+v, want 1 created and 1 skipped", res)
- }
- var row xray.ClientTraffic
- if err := database.GetDB().Where("email = ?", "dup@traffic").First(&row).Error; err != nil {
- t.Fatalf("lookup traffic: %v", err)
- }
- if row.Up != 11 || row.Down != 12 {
- t.Fatalf("traffic up=%d down=%d, want the created copy's 11/12", row.Up, row.Down)
- }
- })
- }
- }
|