api_token_test.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. package panel
  2. import (
  3. "errors"
  4. "strings"
  5. "testing"
  6. "gorm.io/gorm"
  7. "github.com/mhsanaei/3x-ui/v3/internal/config"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
  10. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  11. )
  12. var errInjectedTokenCreate = errors.New("injected token create failure")
  13. func TestApiTokenCreatedAtSeconds(t *testing.T) {
  14. tests := []struct {
  15. name string
  16. in int64
  17. want int64
  18. }{
  19. {name: "seconds", in: 1_782_485_394, want: 1_782_485_394},
  20. {name: "legacy milliseconds", in: 1_782_485_394_270, want: 1_782_485_394},
  21. {name: "unset", in: 0, want: 0},
  22. }
  23. for _, tt := range tests {
  24. t.Run(tt.name, func(t *testing.T) {
  25. if got := apiTokenCreatedAtSeconds(tt.in); got != tt.want {
  26. t.Fatalf("apiTokenCreatedAtSeconds(%d) = %d, want %d", tt.in, got, tt.want)
  27. }
  28. })
  29. }
  30. }
  31. func TestRecreateByNamePreservesTokenWhenReplacementFails(t *testing.T) {
  32. t.Setenv("XUI_DB_FOLDER", t.TempDir())
  33. dbtest.InitDB(t, config.GetDBPath())
  34. svc := ApiTokenService{}
  35. first, err := svc.RecreateByName("cli-fallback", "")
  36. if err != nil {
  37. t.Fatalf("first recreate: %v", err)
  38. }
  39. db := database.GetDB()
  40. const callback = "test:fail-token-replacement"
  41. if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) {
  42. if token, ok := tx.Statement.Dest.(*model.ApiToken); ok && token.Name == "cli-fallback" {
  43. tx.AddError(errInjectedTokenCreate)
  44. }
  45. }); err != nil {
  46. t.Fatalf("register callback: %v", err)
  47. }
  48. t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
  49. if _, err := svc.RecreateByName("cli-fallback", ""); !errors.Is(err, errInjectedTokenCreate) {
  50. t.Fatalf("recreate error = %v, want %v", err, errInjectedTokenCreate)
  51. }
  52. var row model.ApiToken
  53. if err := db.Where("name = ?", "cli-fallback").First(&row).Error; err != nil {
  54. t.Fatalf("load preserved token: %v", err)
  55. }
  56. if !svc.Match(first.Token) {
  57. t.Fatal("original token was revoked after replacement failure")
  58. }
  59. }
  60. // Create caps the name at 64 characters; RecreateByName writes the same column
  61. // and now takes operator input from -tokenName, so it must cap it too.
  62. func TestRecreateByNameRejectsOverlongName(t *testing.T) {
  63. t.Setenv("XUI_DB_FOLDER", t.TempDir())
  64. dbtest.InitDB(t, config.GetDBPath())
  65. const wantErr = "token name must be 64 characters or fewer"
  66. svc := ApiTokenService{}
  67. _, err := svc.RecreateByName(strings.Repeat("n", 65), "")
  68. if err == nil {
  69. t.Fatal("expected a 65-character token name to be rejected")
  70. }
  71. if got := strings.TrimSpace(err.Error()); got != wantErr {
  72. t.Fatalf("error = %q, want %q — any other error would pass a bare nil check", got, wantErr)
  73. }
  74. if _, err := svc.RecreateByName(strings.Repeat("n", 64), ""); err != nil {
  75. t.Fatalf("64 characters is the documented limit, got: %v", err)
  76. }
  77. }
  78. // Rotating a monitor token through the CLI silently reissued it as admin,
  79. // because the replacement row took the column default instead of the old scope.
  80. func TestRecreateByNameKeepsReplacedTokenScope(t *testing.T) {
  81. t.Setenv("XUI_DB_FOLDER", t.TempDir())
  82. dbtest.InitDB(t, config.GetDBPath())
  83. svc := ApiTokenService{}
  84. if _, err := svc.Create("grafana", model.ApiScopeMonitor, 0); err != nil {
  85. t.Fatalf("seed grafana: %v", err)
  86. }
  87. rotated, err := svc.RecreateByName("grafana", "")
  88. if err != nil {
  89. t.Fatalf("recreate: %v", err)
  90. }
  91. var row model.ApiToken
  92. if err := database.GetDB().Where("name = ?", "grafana").First(&row).Error; err != nil {
  93. t.Fatalf("load grafana: %v", err)
  94. }
  95. if row.Scope != model.ApiScopeMonitor {
  96. t.Fatalf("stored scope = %q, want %q", row.Scope, model.ApiScopeMonitor)
  97. }
  98. if rotated.Scope != model.ApiScopeMonitor {
  99. t.Fatalf("returned scope = %q, want %q", rotated.Scope, model.ApiScopeMonitor)
  100. }
  101. }
  102. // An explicit scope wins over the replaced token's, and a bad one is refused
  103. // before the old token is touched.
  104. func TestRecreateByNameAppliesGivenScope(t *testing.T) {
  105. tests := []struct {
  106. name string
  107. seedScope string
  108. scope string
  109. want string
  110. wantErr string
  111. }{
  112. {name: "replaces a monitor token as node-sync", seedScope: model.ApiScopeMonitor, scope: model.ApiScopeNodeSync, want: model.ApiScopeNodeSync},
  113. {name: "replaces a monitor token as node-admin", seedScope: model.ApiScopeMonitor, scope: model.ApiScopeNodeAdmin, want: model.ApiScopeNodeAdmin},
  114. {name: "creates a new token as monitor", scope: model.ApiScopeMonitor, want: model.ApiScopeMonitor},
  115. {name: "refuses an unknown scope", seedScope: model.ApiScopeMonitor, scope: "root", want: model.ApiScopeMonitor, wantErr: "scope must be 'admin', 'monitor', 'node-sync', or 'node-admin'"},
  116. }
  117. for _, tt := range tests {
  118. t.Run(tt.name, func(t *testing.T) {
  119. t.Setenv("XUI_DB_FOLDER", t.TempDir())
  120. dbtest.InitDB(t, config.GetDBPath())
  121. svc := ApiTokenService{}
  122. var seeded *ApiTokenView
  123. if tt.seedScope != "" {
  124. var err error
  125. if seeded, err = svc.Create("bot", tt.seedScope, 0); err != nil {
  126. t.Fatalf("seed bot: %v", err)
  127. }
  128. }
  129. _, err := svc.RecreateByName("bot", tt.scope)
  130. if tt.wantErr != "" {
  131. if err == nil || strings.TrimSpace(err.Error()) != tt.wantErr {
  132. t.Fatalf("error = %v, want %q", err, tt.wantErr)
  133. }
  134. if !svc.Match(seeded.Token) {
  135. t.Fatal("the old token was revoked by a refused rotation")
  136. }
  137. } else if err != nil {
  138. t.Fatalf("recreate: %v", err)
  139. }
  140. var row model.ApiToken
  141. if err := database.GetDB().Where("name = ?", "bot").First(&row).Error; err != nil {
  142. t.Fatalf("load bot: %v", err)
  143. }
  144. if row.Scope != tt.want {
  145. t.Fatalf("stored scope = %q, want %q", row.Scope, tt.want)
  146. }
  147. })
  148. }
  149. }
  150. // A scope this build does not know, as after a downgrade, must not be guessed
  151. // as admin; the rotation fails and the stored row stays untouched.
  152. func TestRecreateByNameRefusesUnknownStoredScope(t *testing.T) {
  153. t.Setenv("XUI_DB_FOLDER", t.TempDir())
  154. dbtest.InitDB(t, config.GetDBPath())
  155. db := database.GetDB()
  156. stored := model.ApiToken{Name: "remote", Token: "stored-hash", Enabled: true, Scope: "future-node-scope"}
  157. if err := db.Create(&stored).Error; err != nil {
  158. t.Fatalf("seed remote: %v", err)
  159. }
  160. const wantErr = `token "remote" has unknown scope "future-node-scope"`
  161. _, err := (&ApiTokenService{}).RecreateByName("remote", "")
  162. if err == nil || strings.TrimSpace(err.Error()) != wantErr {
  163. t.Fatalf("error = %v, want %q", err, wantErr)
  164. }
  165. var row model.ApiToken
  166. if err := db.Where("name = ?", "remote").First(&row).Error; err != nil {
  167. t.Fatalf("load remote: %v", err)
  168. }
  169. if row.Id != stored.Id || row.Token != stored.Token || row.Scope != stored.Scope {
  170. t.Fatalf("row = %+v, want the stored row %+v unchanged", row, stored)
  171. }
  172. }
  173. // Rotating a token issued with an expiry through the API handed back one that
  174. // never expires, since the replacement row took ExpiresAt 0.
  175. func TestRecreateByNameKeepsReplacedTokenExpiry(t *testing.T) {
  176. for _, scope := range []string{"", model.ApiScopeNodeSync} {
  177. t.Run("scope="+scope, func(t *testing.T) {
  178. t.Setenv("XUI_DB_FOLDER", t.TempDir())
  179. dbtest.InitDB(t, config.GetDBPath())
  180. svc := ApiTokenService{}
  181. expiresAt := nowMilli() + 30*24*60*60*1000
  182. if _, err := svc.Create("grafana", model.ApiScopeMonitor, expiresAt); err != nil {
  183. t.Fatalf("seed grafana: %v", err)
  184. }
  185. rotated, err := svc.RecreateByName("grafana", scope)
  186. if err != nil {
  187. t.Fatalf("recreate: %v", err)
  188. }
  189. var row model.ApiToken
  190. if err := database.GetDB().Where("name = ?", "grafana").First(&row).Error; err != nil {
  191. t.Fatalf("load grafana: %v", err)
  192. }
  193. if row.ExpiresAt != expiresAt || rotated.ExpiresAt != expiresAt {
  194. t.Fatalf("stored expiresAt = %d, returned %d, want %d", row.ExpiresAt, rotated.ExpiresAt, expiresAt)
  195. }
  196. })
  197. }
  198. }
  199. // An expired token must not come back to life without an expiry; the rotation
  200. // is refused and the expired row is left as it was.
  201. func TestRecreateByNameRefusesExpiredToken(t *testing.T) {
  202. for _, scope := range []string{"", model.ApiScopeAdmin} {
  203. t.Run("scope="+scope, func(t *testing.T) {
  204. t.Setenv("XUI_DB_FOLDER", t.TempDir())
  205. dbtest.InitDB(t, config.GetDBPath())
  206. db := database.GetDB()
  207. stored := model.ApiToken{Name: "grafana", Token: "stored-hash", Enabled: true, Scope: model.ApiScopeMonitor, ExpiresAt: nowMilli() - 1000}
  208. if err := db.Create(&stored).Error; err != nil {
  209. t.Fatalf("seed grafana: %v", err)
  210. }
  211. const wantErr = `token "grafana" has expired; create a new token from the panel or the API instead`
  212. _, err := (&ApiTokenService{}).RecreateByName("grafana", scope)
  213. if err == nil || strings.TrimSpace(err.Error()) != wantErr {
  214. t.Fatalf("error = %v, want %q", err, wantErr)
  215. }
  216. var row model.ApiToken
  217. if err := db.Where("name = ?", "grafana").First(&row).Error; err != nil {
  218. t.Fatalf("load grafana: %v", err)
  219. }
  220. if row.Id != stored.Id || row.Token != stored.Token || row.ExpiresAt != stored.ExpiresAt {
  221. t.Fatalf("row = %+v, want the stored row %+v unchanged", row, stored)
  222. }
  223. })
  224. }
  225. }
  226. func TestRecreateByNameKeepsOneToken(t *testing.T) {
  227. t.Setenv("XUI_DB_FOLDER", t.TempDir())
  228. dbtest.InitDB(t, config.GetDBPath())
  229. svc := ApiTokenService{}
  230. first, err := svc.RecreateByName("cli-fallback", "")
  231. if err != nil {
  232. t.Fatalf("first recreate: %v", err)
  233. }
  234. second, err := svc.RecreateByName("cli-fallback", "")
  235. if err != nil {
  236. t.Fatalf("second recreate: %v", err)
  237. }
  238. if first.Token == second.Token {
  239. t.Fatal("second call returned the same plaintext, want a rotated token")
  240. }
  241. var count int64
  242. if err := database.GetDB().Model(model.ApiToken{}).Where("name = ?", "cli-fallback").Count(&count).Error; err != nil {
  243. t.Fatalf("count: %v", err)
  244. }
  245. if count != 1 {
  246. t.Fatalf("token rows = %d, want 1", count)
  247. }
  248. }