host_migration_test.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. package database
  2. import (
  3. "os"
  4. "path/filepath"
  5. "strings"
  6. "testing"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  8. )
  9. func initMigrateDB(t *testing.T) {
  10. t.Helper()
  11. if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
  12. t.Fatalf("InitDB: %v", err)
  13. }
  14. t.Cleanup(func() { _ = CloseDB() })
  15. }
  16. func seedInboundWithStream(t *testing.T, tag string, port int, stream string) *model.Inbound {
  17. t.Helper()
  18. ib := &model.Inbound{
  19. UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS,
  20. Remark: tag, Settings: `{"clients":[]}`, StreamSettings: stream,
  21. }
  22. if err := GetDB().Create(ib).Error; err != nil {
  23. t.Fatalf("create inbound %s: %v", tag, err)
  24. }
  25. return ib
  26. }
  27. const epMigrationStream = `{"network":"ws","security":"tls","externalProxy":[
  28. {"forceTls":"tls","dest":"a.cdn.com","port":8443,"remark":"A","sni":"a.sni","fingerprint":"chrome","alpn":["h2","h3"],"pinnedPeerCertSha256":["AAAA"],"echConfigList":"ECHV"},
  29. {"forceTls":"none","dest":"b.cdn.com","port":80,"remark":"B"}
  30. ]}`
  31. // #1 — each externalProxy entry becomes one host row with the exact field
  32. // mapping; sort_order is the entry index; inbound_id is correct.
  33. func TestMigrate_ExternalProxyToHosts(t *testing.T) {
  34. initMigrateDB(t)
  35. ib := seedInboundWithStream(t, "m1", 5551, epMigrationStream)
  36. if err := seedHostsFromExternalProxy(); err != nil {
  37. t.Fatalf("migrate: %v", err)
  38. }
  39. var hosts []model.Host
  40. if err := GetDB().Where("inbound_id = ?", ib.Id).Order("sort_order asc").Find(&hosts).Error; err != nil {
  41. t.Fatalf("load hosts: %v", err)
  42. }
  43. if len(hosts) != 2 {
  44. t.Fatalf("hosts = %d, want 2", len(hosts))
  45. }
  46. a := hosts[0]
  47. if a.InboundId != ib.Id || a.SortOrder != 0 || a.Security != "tls" || a.Address != "a.cdn.com" ||
  48. a.Port != 8443 || a.Remark != "A" || a.Sni != "a.sni" || a.Fingerprint != "chrome" || a.EchConfigList != "ECHV" {
  49. t.Fatalf("host A mapping wrong: %+v", a)
  50. }
  51. if len(a.Alpn) != 2 || a.Alpn[0] != "h2" || a.Alpn[1] != "h3" {
  52. t.Fatalf("host A alpn = %v, want [h2 h3]", a.Alpn)
  53. }
  54. if len(a.PinnedPeerCertSha256) != 1 || a.PinnedPeerCertSha256[0] != "AAAA" {
  55. t.Fatalf("host A pins = %v, want [AAAA]", a.PinnedPeerCertSha256)
  56. }
  57. b := hosts[1]
  58. if b.InboundId != ib.Id || b.SortOrder != 1 || b.Security != "none" || b.Address != "b.cdn.com" ||
  59. b.Port != 80 || b.Remark != "B" {
  60. t.Fatalf("host B mapping wrong: %+v", b)
  61. }
  62. if a.GroupId == "" || b.GroupId == "" {
  63. t.Fatalf("group ids must be assigned at creation: a=%q b=%q", a.GroupId, b.GroupId)
  64. }
  65. if a.GroupId == b.GroupId {
  66. t.Fatalf("each entry must get its own group id, both = %q", a.GroupId)
  67. }
  68. }
  69. // #1b — a hosts row that entered the DB without a group_id (older-build import
  70. // or restored backup) is repaired on every start, so it stays addressable by
  71. // the group-scoped update/delete API instead of surfacing as fallback_<id>.
  72. func TestBackfillEmptyHostGroupIds_RepairsLegacyRows(t *testing.T) {
  73. initMigrateDB(t)
  74. ib := seedInboundWithStream(t, "m1b", 5556, `{"network":"tcp","security":"none"}`)
  75. legacy := &model.Host{InboundId: ib.Id, Remark: "legacy", Address: "c.cdn.com", Port: 443, Security: "tls"}
  76. if err := GetDB().Create(legacy).Error; err != nil {
  77. t.Fatalf("create legacy host: %v", err)
  78. }
  79. if err := backfillEmptyHostGroupIds(); err != nil {
  80. t.Fatalf("backfill: %v", err)
  81. }
  82. var got model.Host
  83. if err := GetDB().First(&got, legacy.Id).Error; err != nil {
  84. t.Fatalf("reload host: %v", err)
  85. }
  86. if got.GroupId == "" {
  87. t.Fatal("group_id still empty after backfill")
  88. }
  89. if err := backfillEmptyHostGroupIds(); err != nil {
  90. t.Fatalf("second backfill: %v", err)
  91. }
  92. var again model.Host
  93. if err := GetDB().First(&again, legacy.Id).Error; err != nil {
  94. t.Fatalf("reload host after second run: %v", err)
  95. }
  96. if again.GroupId != got.GroupId {
  97. t.Fatalf("second run must not touch repaired rows: %q -> %q", got.GroupId, again.GroupId)
  98. }
  99. }
  100. // #2 — a second run is a no-op (the HistoryOfSeeders gate).
  101. func TestMigrate_Idempotent(t *testing.T) {
  102. initMigrateDB(t)
  103. seedInboundWithStream(t, "m2", 5552, epMigrationStream)
  104. if err := seedHostsFromExternalProxy(); err != nil {
  105. t.Fatalf("first run: %v", err)
  106. }
  107. if err := seedHostsFromExternalProxy(); err != nil {
  108. t.Fatalf("second run: %v", err)
  109. }
  110. var count int64
  111. GetDB().Model(&model.Host{}).Count(&count)
  112. if count != 2 {
  113. t.Fatalf("host count = %d, want 2 (second run must be a no-op)", count)
  114. }
  115. }
  116. // #3 — inbounds without externalProxy create no hosts.
  117. func TestMigrate_NoExternalProxy_NoHosts(t *testing.T) {
  118. initMigrateDB(t)
  119. seedInboundWithStream(t, "m3", 5553, `{"network":"tcp","security":"none"}`)
  120. if err := seedHostsFromExternalProxy(); err != nil {
  121. t.Fatalf("migrate: %v", err)
  122. }
  123. var count int64
  124. GetDB().Model(&model.Host{}).Count(&count)
  125. if count != 0 {
  126. t.Fatalf("host count = %d, want 0", count)
  127. }
  128. }
  129. // #4 — externalProxy stays in StreamSettings (additive, rollback-safe).
  130. func TestMigrate_KeepsExternalProxyIntact(t *testing.T) {
  131. initMigrateDB(t)
  132. ib := seedInboundWithStream(t, "m4", 5554, epMigrationStream)
  133. if err := seedHostsFromExternalProxy(); err != nil {
  134. t.Fatalf("migrate: %v", err)
  135. }
  136. var got model.Inbound
  137. if err := GetDB().First(&got, ib.Id).Error; err != nil {
  138. t.Fatalf("reload inbound: %v", err)
  139. }
  140. if !strings.Contains(got.StreamSettings, "externalProxy") || !strings.Contains(got.StreamSettings, "a.cdn.com") {
  141. t.Fatalf("externalProxy must remain in StreamSettings: %s", got.StreamSettings)
  142. }
  143. }
  144. // #5 — same against a real Postgres DSN (sequence resync); skips without a DSN.
  145. func TestMigrate_Postgres(t *testing.T) {
  146. if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
  147. t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres migration test")
  148. }
  149. if err := InitDB(""); err != nil {
  150. t.Fatalf("InitDB: %v", err)
  151. }
  152. t.Cleanup(func() { _ = CloseDB() })
  153. // Clean slate so this run owns the migration regardless of prior tests.
  154. GetDB().Exec("TRUNCATE TABLE hosts, inbounds RESTART IDENTITY CASCADE")
  155. GetDB().Where("seeder_name = ?", "HostsFromExternalProxy").Delete(&model.HistoryOfSeeders{})
  156. seedInboundWithStream(t, "mpg", 5555, epMigrationStream)
  157. if err := seedHostsFromExternalProxy(); err != nil {
  158. t.Fatalf("migrate pg: %v", err)
  159. }
  160. var count int64
  161. GetDB().Model(&model.Host{}).Count(&count)
  162. if count != 2 {
  163. t.Fatalf("pg host count = %d, want 2", count)
  164. }
  165. if err := seedHostsFromExternalProxy(); err != nil {
  166. t.Fatalf("migrate pg (2nd): %v", err)
  167. }
  168. GetDB().Model(&model.Host{}).Count(&count)
  169. if count != 2 {
  170. t.Fatalf("pg host count after 2nd run = %d, want 2 (idempotent)", count)
  171. }
  172. }