|
@@ -0,0 +1,205 @@
|
|
|
|
|
+package service
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
|
|
|
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+func TestUpdate_PersistsFields_NoInbound(t *testing.T) {
|
|
|
|
|
+ cases := []struct {
|
|
|
|
|
+ name string
|
|
|
|
|
+ mutate func(c *model.Client)
|
|
|
|
|
+ readBack func(rec *model.ClientRecord) any
|
|
|
|
|
+ want any
|
|
|
|
|
+ }{
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "subId",
|
|
|
|
|
+ mutate: func(c *model.Client) { c.SubID = "new-sub-id" },
|
|
|
|
|
+ readBack: func(rec *model.ClientRecord) any { return rec.SubID },
|
|
|
|
|
+ want: "new-sub-id",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "totalGB cleared to zero",
|
|
|
|
|
+ mutate: func(c *model.Client) { c.TotalGB = 0 },
|
|
|
|
|
+ readBack: func(rec *model.ClientRecord) any { return rec.TotalGB },
|
|
|
|
|
+ want: int64(0),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "expiryTime",
|
|
|
|
|
+ mutate: func(c *model.Client) { c.ExpiryTime = 1700000000 },
|
|
|
|
|
+ readBack: func(rec *model.ClientRecord) any { return rec.ExpiryTime },
|
|
|
|
|
+ want: int64(1700000000),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "limitIp",
|
|
|
|
|
+ mutate: func(c *model.Client) { c.LimitIP = 7 },
|
|
|
|
|
+ readBack: func(rec *model.ClientRecord) any { return rec.LimitIP },
|
|
|
|
|
+ want: 7,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "tgId",
|
|
|
|
|
+ mutate: func(c *model.Client) { c.TgID = 9876543210 },
|
|
|
|
|
+ readBack: func(rec *model.ClientRecord) any { return rec.TgID },
|
|
|
|
|
+ want: int64(9876543210),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "comment cleared to empty",
|
|
|
|
|
+ mutate: func(c *model.Client) { c.Comment = "" },
|
|
|
|
|
+ readBack: func(rec *model.ClientRecord) any { return rec.Comment },
|
|
|
|
|
+ want: "",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "reset",
|
|
|
|
|
+ mutate: func(c *model.Client) { c.Reset = 30 },
|
|
|
|
|
+ readBack: func(rec *model.ClientRecord) any { return rec.Reset },
|
|
|
|
|
+ want: 30,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "flow",
|
|
|
|
|
+ mutate: func(c *model.Client) { c.Flow = "xtls-rprx-vision" },
|
|
|
|
|
+ readBack: func(rec *model.ClientRecord) any { return rec.Flow },
|
|
|
|
|
+ want: "xtls-rprx-vision",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "security",
|
|
|
|
|
+ mutate: func(c *model.Client) { c.Security = "aes-128-gcm" },
|
|
|
|
|
+ readBack: func(rec *model.ClientRecord) any { return rec.Security },
|
|
|
|
|
+ want: "aes-128-gcm",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "uuid rotated",
|
|
|
|
|
+ mutate: func(c *model.Client) { c.ID = "22222222-2222-2222-2222-222222222222" },
|
|
|
|
|
+ readBack: func(rec *model.ClientRecord) any { return rec.UUID },
|
|
|
|
|
+ want: "22222222-2222-2222-2222-222222222222",
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for _, tc := range cases {
|
|
|
|
|
+ t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
+ setupBulkDB(t)
|
|
|
|
|
+ svc := &ClientService{}
|
|
|
|
|
+ inboundSvc := &InboundService{}
|
|
|
|
|
+
|
|
|
|
|
+ email := "noib-" + strings.ReplaceAll(strings.ToLower(tc.name), " ", "-") + "@x"
|
|
|
|
|
+ rec := &model.ClientRecord{
|
|
|
|
|
+ Email: email,
|
|
|
|
|
+ UUID: "11111111-1111-1111-1111-111111111111",
|
|
|
|
|
+ SubID: email,
|
|
|
|
|
+ TotalGB: 5,
|
|
|
|
|
+ ExpiryTime: 1000,
|
|
|
|
|
+ LimitIP: 1,
|
|
|
|
|
+ TgID: 1,
|
|
|
|
|
+ Comment: "seeded",
|
|
|
|
|
+ Reset: 1,
|
|
|
|
|
+ Flow: "seeded-flow",
|
|
|
|
|
+ Security: "seeded-sec",
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := database.GetDB().Create(rec).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("create record: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updated := rec.ToClient()
|
|
|
|
|
+ tc.mutate(updated)
|
|
|
|
|
+ if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
|
|
|
|
+ t.Fatalf("Update: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ got, err := svc.GetByID(rec.Id)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("GetByID: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if tc.readBack(got) != tc.want {
|
|
|
|
|
+ t.Fatalf("%s: not persisted for no-inbound client, got %v, want %v", tc.name, tc.readBack(got), tc.want)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestUpdate_NoInbound_PreservesCredentialsWhenOmitted(t *testing.T) {
|
|
|
|
|
+ setupBulkDB(t)
|
|
|
|
|
+ svc := &ClientService{}
|
|
|
|
|
+ inboundSvc := &InboundService{}
|
|
|
|
|
+
|
|
|
|
|
+ email := "noib-preserve@x"
|
|
|
|
|
+ rec := &model.ClientRecord{
|
|
|
|
|
+ Email: email,
|
|
|
|
|
+ UUID: "11111111-1111-1111-1111-111111111111",
|
|
|
|
|
+ SubID: email,
|
|
|
|
|
+ Password: "seeded-pw",
|
|
|
|
|
+ Auth: "seeded-auth",
|
|
|
|
|
+ Secret: "seeded-secret",
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := database.GetDB().Create(rec).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("create record: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updated := rec.ToClient()
|
|
|
|
|
+ updated.ID = ""
|
|
|
|
|
+ updated.Password = ""
|
|
|
|
|
+ updated.Auth = ""
|
|
|
|
|
+ updated.Secret = ""
|
|
|
|
|
+ updated.Comment = "only comment changed"
|
|
|
|
|
+ if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
|
|
|
|
+ t.Fatalf("Update: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ got, err := svc.GetByID(rec.Id)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("GetByID: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got.UUID != "11111111-1111-1111-1111-111111111111" {
|
|
|
|
|
+ t.Fatalf("uuid wiped on partial update, got %q", got.UUID)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got.Password != "seeded-pw" {
|
|
|
|
|
+ t.Fatalf("password wiped on partial update, got %q", got.Password)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got.Auth != "seeded-auth" {
|
|
|
|
|
+ t.Fatalf("auth wiped on partial update, got %q", got.Auth)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got.Secret != "seeded-secret" {
|
|
|
|
|
+ t.Fatalf("secret wiped on partial update, got %q", got.Secret)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got.Comment != "only comment changed" {
|
|
|
|
|
+ t.Fatalf("comment not persisted, got %q", got.Comment)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestApplyClientRecordMerge_MirrorsSyncInboundRules(t *testing.T) {
|
|
|
|
|
+ row := &model.ClientRecord{
|
|
|
|
|
+ UUID: "kept-uuid",
|
|
|
|
|
+ Password: "kept-pw",
|
|
|
|
|
+ Flow: "kept-flow",
|
|
|
|
|
+ TotalGB: 9,
|
|
|
|
|
+ Group: "kept-group",
|
|
|
|
|
+ Comment: "kept-comment",
|
|
|
|
|
+ }
|
|
|
|
|
+ incoming := &model.ClientRecord{
|
|
|
|
|
+ Password: "new-pw",
|
|
|
|
|
+ TotalGB: 0,
|
|
|
|
|
+ Comment: "new-comment",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ applyClientRecordMerge(row, incoming)
|
|
|
|
|
+
|
|
|
|
|
+ if row.UUID != "kept-uuid" {
|
|
|
|
|
+ t.Fatalf("empty incoming UUID should preserve stored UUID, got %q", row.UUID)
|
|
|
|
|
+ }
|
|
|
|
|
+ if row.Password != "new-pw" {
|
|
|
|
|
+ t.Fatalf("non-empty incoming Password should overwrite, got %q", row.Password)
|
|
|
|
|
+ }
|
|
|
|
|
+ if row.Flow != "" {
|
|
|
|
|
+ t.Fatalf("incoming Flow is unconditional and should overwrite with empty, got %q", row.Flow)
|
|
|
|
|
+ }
|
|
|
|
|
+ if row.TotalGB != 0 {
|
|
|
|
|
+ t.Fatalf("incoming TotalGB is unconditional and should overwrite with zero, got %v", row.TotalGB)
|
|
|
|
|
+ }
|
|
|
|
|
+ if row.Group != "kept-group" {
|
|
|
|
|
+ t.Fatalf("empty incoming Group should preserve stored group, got %q", row.Group)
|
|
|
|
|
+ }
|
|
|
|
|
+ if row.Comment != "new-comment" {
|
|
|
|
|
+ t.Fatalf("incoming Comment is unconditional and should overwrite, got %q", row.Comment)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|