subscriber_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. package discord
  2. import (
  3. "encoding/json"
  4. "io"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "time"
  10. "github.com/mhsanaei/3x-ui/v3/internal/eventbus"
  11. )
  12. func TestFormatEmbed_OutboundDownAndUp(t *testing.T) {
  13. settingService := setupTestDB(t)
  14. discordService := NewDiscordService(settingService)
  15. sub := NewSubscriber(settingService, discordService)
  16. now := time.Date(2026, 9, 12, 12, 0, 0, 0, time.UTC)
  17. // Outbound down
  18. downEvent := eventbus.Event{
  19. Type: eventbus.EventOutboundDown,
  20. Source: "proxy-1",
  21. Timestamp: now,
  22. Data: &eventbus.OutboundHealthData{
  23. Delay: 500,
  24. Error: "timeout connecting",
  25. },
  26. }
  27. embed, ok := sub.FormatEmbed(downEvent)
  28. if !ok {
  29. t.Fatal("expected embed to be formatted")
  30. }
  31. if embed.Color != ColorRed {
  32. t.Errorf("expected ColorRed, got 0x%X", embed.Color)
  33. }
  34. if embed.Timestamp != "2026-09-12T12:00:00Z" {
  35. t.Errorf("expected RFC3339 UTC timestamp, got %s", embed.Timestamp)
  36. }
  37. if len(embed.Fields) != 3 {
  38. t.Fatalf("expected 3 fields, got %d", len(embed.Fields))
  39. }
  40. // Outbound up
  41. upEvent := eventbus.Event{
  42. Type: eventbus.EventOutboundUp,
  43. Source: "proxy-1",
  44. Timestamp: now,
  45. Data: &eventbus.OutboundHealthData{
  46. Delay: 120,
  47. },
  48. }
  49. embedUp, ok := sub.FormatEmbed(upEvent)
  50. if !ok {
  51. t.Fatal("expected embed to be formatted")
  52. }
  53. if embedUp.Color != ColorGreen {
  54. t.Errorf("expected ColorGreen, got 0x%X", embedUp.Color)
  55. }
  56. }
  57. func TestFormatEmbed_NodeDownAndUp(t *testing.T) {
  58. settingService := setupTestDB(t)
  59. discordService := NewDiscordService(settingService)
  60. sub := NewSubscriber(settingService, discordService)
  61. now := time.Now().UTC()
  62. // Node down
  63. downEvent := eventbus.Event{
  64. Type: eventbus.EventNodeDown,
  65. Source: "node-us",
  66. Timestamp: now,
  67. Data: &eventbus.NodeHealthData{
  68. XrayError: "connection refused",
  69. },
  70. }
  71. embed, ok := sub.FormatEmbed(downEvent)
  72. if !ok {
  73. t.Fatal("expected embed to be formatted")
  74. }
  75. if embed.Color != ColorRed {
  76. t.Errorf("expected ColorRed, got 0x%X", embed.Color)
  77. }
  78. // Node up
  79. upEvent := eventbus.Event{
  80. Type: eventbus.EventNodeUp,
  81. Source: "node-us",
  82. Timestamp: now,
  83. Data: &eventbus.NodeHealthData{
  84. LatencyMs: 45,
  85. },
  86. }
  87. embedUp, ok := sub.FormatEmbed(upEvent)
  88. if !ok {
  89. t.Fatal("expected embed to be formatted")
  90. }
  91. if embedUp.Color != ColorGreen {
  92. t.Errorf("expected ColorGreen, got 0x%X", embedUp.Color)
  93. }
  94. }
  95. func TestFormatEmbed_XrayCrash(t *testing.T) {
  96. settingService := setupTestDB(t)
  97. discordService := NewDiscordService(settingService)
  98. sub := NewSubscriber(settingService, discordService)
  99. crashEvent := eventbus.Event{
  100. Type: eventbus.EventXrayCrash,
  101. Timestamp: time.Now().UTC(),
  102. Data: "panic: core dump",
  103. }
  104. embed, ok := sub.FormatEmbed(crashEvent)
  105. if !ok {
  106. t.Fatal("expected embed to be formatted")
  107. }
  108. if embed.Color != ColorRed {
  109. t.Errorf("expected ColorRed, got 0x%X", embed.Color)
  110. }
  111. }
  112. func TestFormatEmbed_CpuAndMemoryThresholds(t *testing.T) {
  113. settingService := setupTestDB(t)
  114. _ = settingService.SetDiscordCpu(80)
  115. _ = settingService.SetDiscordMemory(75)
  116. discordService := NewDiscordService(settingService)
  117. sub := NewSubscriber(settingService, discordService)
  118. now := time.Now().UTC()
  119. // CPU below threshold -> no embed
  120. _, ok := sub.FormatEmbed(eventbus.Event{
  121. Type: eventbus.EventCPUHigh,
  122. Timestamp: now,
  123. Data: &eventbus.SystemMetricData{Percent: 79.5},
  124. })
  125. if ok {
  126. t.Error("expected no embed when CPU is below threshold")
  127. }
  128. // CPU above threshold -> Orange embed
  129. embedCpu, ok := sub.FormatEmbed(eventbus.Event{
  130. Type: eventbus.EventCPUHigh,
  131. Timestamp: now,
  132. Data: &eventbus.SystemMetricData{Percent: 85.2},
  133. })
  134. if !ok {
  135. t.Fatal("expected embed when CPU is above threshold")
  136. }
  137. if embedCpu.Color != ColorOrange {
  138. t.Errorf("expected ColorOrange (0x%X), got 0x%X", ColorOrange, embedCpu.Color)
  139. }
  140. // Memory below threshold -> no embed
  141. _, ok = sub.FormatEmbed(eventbus.Event{
  142. Type: eventbus.EventMemoryHigh,
  143. Timestamp: now,
  144. Data: &eventbus.SystemMetricData{Percent: 70.0},
  145. })
  146. if ok {
  147. t.Error("expected no embed when Memory is below threshold")
  148. }
  149. // Memory above threshold -> Orange embed
  150. embedMem, ok := sub.FormatEmbed(eventbus.Event{
  151. Type: eventbus.EventMemoryHigh,
  152. Timestamp: now,
  153. Data: &eventbus.SystemMetricData{Percent: 90.0},
  154. })
  155. if !ok {
  156. t.Fatal("expected embed when Memory is above threshold")
  157. }
  158. if embedMem.Color != ColorOrange {
  159. t.Errorf("expected ColorOrange (0x%X), got 0x%X", ColorOrange, embedMem.Color)
  160. }
  161. }
  162. func TestFormatEmbed_LoginAttempt(t *testing.T) {
  163. settingService := setupTestDB(t)
  164. discordService := NewDiscordService(settingService)
  165. sub := NewSubscriber(settingService, discordService)
  166. now := time.Now().UTC()
  167. // Login success -> Green
  168. successEvent := eventbus.Event{
  169. Type: eventbus.EventLoginAttempt,
  170. Timestamp: now,
  171. Data: &eventbus.LoginEventData{
  172. Username: "admin",
  173. IP: "1.2.3.4",
  174. Time: "2026-09-12 12:00:00",
  175. Status: "success",
  176. },
  177. }
  178. embedSuccess, ok := sub.FormatEmbed(successEvent)
  179. if !ok {
  180. t.Fatal("expected embed for login success")
  181. }
  182. if embedSuccess.Color != ColorGreen {
  183. t.Errorf("expected ColorGreen, got 0x%X", embedSuccess.Color)
  184. }
  185. // Login fail -> Red
  186. failEvent := eventbus.Event{
  187. Type: eventbus.EventLoginAttempt,
  188. Timestamp: now,
  189. Data: &eventbus.LoginEventData{
  190. Username: "attacker",
  191. IP: "5.6.7.8",
  192. Time: "2026-09-12 12:01:00",
  193. Status: "fail",
  194. Reason: "wrong password",
  195. },
  196. }
  197. embedFail, ok := sub.FormatEmbed(failEvent)
  198. if !ok {
  199. t.Fatal("expected embed for login failure")
  200. }
  201. if embedFail.Color != ColorRed {
  202. t.Errorf("expected ColorRed, got 0x%X", embedFail.Color)
  203. }
  204. // Fallback when data is nil
  205. fallbackEvent := eventbus.Event{
  206. Type: eventbus.EventLoginAttempt,
  207. Source: "unknown-source",
  208. Timestamp: now,
  209. }
  210. embedFallback, ok := sub.FormatEmbed(fallbackEvent)
  211. if !ok {
  212. t.Fatal("expected embed for fallback login")
  213. }
  214. if embedFallback.Color != ColorRed {
  215. t.Errorf("expected ColorRed, got 0x%X", embedFallback.Color)
  216. }
  217. }
  218. func TestCleanField_Protection(t *testing.T) {
  219. field := cleanField("", " ", true)
  220. if field.Name != "-" || field.Value != "-" {
  221. t.Errorf("expected '-' for empty field name/value, got name=%q, value=%q", field.Name, field.Value)
  222. }
  223. field2 := cleanField(" Name ", " Value ", false)
  224. if field2.Name != "Name" || field2.Value != "Value" {
  225. t.Errorf("expected trimmed name/value, got name=%q, value=%q", field2.Name, field2.Value)
  226. }
  227. }
  228. func TestHandleEvent_EndToEndWithServer(t *testing.T) {
  229. settingService := setupTestDB(t)
  230. _ = settingService.SetDiscordBotEnable(true)
  231. _ = settingService.SetDiscordBotToken("test-token")
  232. _ = settingService.SetDiscordChannelId("ch-test")
  233. _ = settingService.SetDiscordEnabledEvents("login.attempt,outbound.down")
  234. receivedCh := make(chan MessagePayload, 10)
  235. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  236. body, _ := io.ReadAll(r.Body)
  237. var p MessagePayload
  238. _ = json.Unmarshal(body, &p)
  239. receivedCh <- p
  240. w.WriteHeader(http.StatusOK)
  241. }))
  242. defer server.Close()
  243. discordService := NewDiscordService(settingService)
  244. discordService.SetBaseURL(server.URL)
  245. discordService.SetHTTPClient(server.Client())
  246. sub := NewSubscriber(settingService, discordService)
  247. // 1. Send enabled event (outbound.down)
  248. sub.HandleEvent(eventbus.Event{
  249. Type: eventbus.EventOutboundDown,
  250. Source: "out-1",
  251. Timestamp: time.Now().UTC(),
  252. })
  253. select {
  254. case p := <-receivedCh:
  255. if len(p.Embeds) != 1 || p.Embeds[0].Color != ColorRed {
  256. t.Errorf("unexpected payload: %+v", p)
  257. }
  258. case <-time.After(2 * time.Second):
  259. t.Fatal("timed out waiting for outbound.down message")
  260. }
  261. // 2. Duplicate outbound.down within rate limit -> should be suppressed
  262. sub.HandleEvent(eventbus.Event{
  263. Type: eventbus.EventOutboundDown,
  264. Source: "out-1",
  265. Timestamp: time.Now().UTC(),
  266. })
  267. select {
  268. case p := <-receivedCh:
  269. t.Fatalf("rate limited event was unexpectedly sent: %+v", p)
  270. case <-time.After(150 * time.Millisecond):
  271. // OK
  272. }
  273. // 3. Login attempt bypasses rate limit
  274. sub.HandleEvent(eventbus.Event{
  275. Type: eventbus.EventLoginAttempt,
  276. Timestamp: time.Now().UTC(),
  277. Data: &eventbus.LoginEventData{
  278. Username: "admin",
  279. IP: "1.1.1.1",
  280. Status: "success",
  281. },
  282. })
  283. sub.HandleEvent(eventbus.Event{
  284. Type: eventbus.EventLoginAttempt,
  285. Timestamp: time.Now().UTC(),
  286. Data: &eventbus.LoginEventData{
  287. Username: "admin",
  288. IP: "1.1.1.1",
  289. Status: "success",
  290. },
  291. })
  292. // Both should arrive
  293. for i := 0; i < 2; i++ {
  294. select {
  295. case <-receivedCh:
  296. // OK
  297. case <-time.After(2 * time.Second):
  298. t.Fatalf("timed out waiting for login attempt message %d", i+1)
  299. }
  300. }
  301. // 4. Disabled event type (cpu.high is not in discordEnabledEvents)
  302. _ = settingService.SetDiscordCpu(50)
  303. sub.HandleEvent(eventbus.Event{
  304. Type: eventbus.EventCPUHigh,
  305. Timestamp: time.Now().UTC(),
  306. Data: &eventbus.SystemMetricData{Percent: 99.0},
  307. })
  308. select {
  309. case p := <-receivedCh:
  310. t.Fatalf("disabled event was unexpectedly sent: %+v", p)
  311. case <-time.After(150 * time.Millisecond):
  312. // OK
  313. }
  314. // 5. Bot disabled entirely
  315. _ = settingService.SetDiscordBotEnable(false)
  316. sub.HandleEvent(eventbus.Event{
  317. Type: eventbus.EventLoginAttempt,
  318. Timestamp: time.Now().UTC(),
  319. Data: &eventbus.LoginEventData{
  320. Username: "admin",
  321. IP: "1.1.1.1",
  322. Status: "success",
  323. },
  324. })
  325. select {
  326. case p := <-receivedCh:
  327. t.Fatalf("event sent while bot disabled: %+v", p)
  328. case <-time.After(150 * time.Millisecond):
  329. // OK
  330. }
  331. }
  332. func TestCleanField_Truncation(t *testing.T) {
  333. longName := strings.Repeat("А", 300) // 300 runes of 2-byte UTF-8
  334. longValue := strings.Repeat("🔥", 1200) // 1200 runes of 4-byte UTF-8
  335. field := cleanField(longName, longValue, false)
  336. nameRunes := []rune(field.Name)
  337. valRunes := []rune(field.Value)
  338. if len(nameRunes) > 256 {
  339. t.Errorf("expected name runes <= 256, got %d", len(nameRunes))
  340. }
  341. if !strings.HasSuffix(field.Name, "...") {
  342. t.Errorf("expected truncated name to end with '...', got %s", field.Name)
  343. }
  344. if len(valRunes) > 1024 {
  345. t.Errorf("expected value runes <= 1024, got %d", len(valRunes))
  346. }
  347. if !strings.HasSuffix(field.Value, "...") {
  348. t.Errorf("expected truncated value to end with '...', got %s", field.Value)
  349. }
  350. }
  351. func TestHandleEvent_BelowThresholdDoesNotBurnRateLimiter(t *testing.T) {
  352. settingService := setupTestDB(t)
  353. _ = settingService.SetDiscordBotEnable(true)
  354. _ = settingService.SetDiscordBotToken("test-token")
  355. _ = settingService.SetDiscordChannelId("ch-test")
  356. _ = settingService.SetDiscordEnabledEvents("cpu.high")
  357. _ = settingService.SetDiscordCpu(80)
  358. receivedCh := make(chan MessagePayload, 5)
  359. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  360. body, _ := io.ReadAll(r.Body)
  361. var p MessagePayload
  362. _ = json.Unmarshal(body, &p)
  363. receivedCh <- p
  364. w.WriteHeader(http.StatusOK)
  365. }))
  366. defer server.Close()
  367. discordService := NewDiscordService(settingService)
  368. discordService.SetBaseURL(server.URL)
  369. discordService.SetHTTPClient(server.Client())
  370. sub := NewSubscriber(settingService, discordService)
  371. // 1. CPU at 50% (below 80% threshold) - must NOT be sent and must NOT burn rate limiter
  372. sub.HandleEvent(eventbus.Event{
  373. Type: eventbus.EventCPUHigh,
  374. Timestamp: time.Now().UTC(),
  375. Data: &eventbus.SystemMetricData{Percent: 50.0},
  376. })
  377. select {
  378. case p := <-receivedCh:
  379. t.Fatalf("sub-threshold CPU event was unexpectedly sent: %+v", p)
  380. case <-time.After(150 * time.Millisecond):
  381. // OK
  382. }
  383. // 2. CPU immediately spikes to 95% (above 80% threshold) - MUST be sent!
  384. sub.HandleEvent(eventbus.Event{
  385. Type: eventbus.EventCPUHigh,
  386. Timestamp: time.Now().UTC(),
  387. Data: &eventbus.SystemMetricData{Percent: 95.0},
  388. })
  389. select {
  390. case p := <-receivedCh:
  391. if len(p.Embeds) != 1 || p.Embeds[0].Color != ColorOrange {
  392. t.Errorf("unexpected payload for critical CPU alert: %+v", p)
  393. }
  394. case <-time.After(2 * time.Second):
  395. t.Fatal("critical CPU alert was incorrectly suppressed by rate limiter after below-threshold event")
  396. }
  397. }
  398. func TestFormatEmbed_ValueTypes(t *testing.T) {
  399. settingService := setupTestDB(t)
  400. _ = settingService.SetDiscordCpu(80)
  401. _ = settingService.SetDiscordMemory(80)
  402. sub := NewSubscriber(settingService, NewDiscordService(settingService))
  403. now := time.Now().UTC()
  404. // OutboundHealthData by value
  405. embed, ok := sub.FormatEmbed(eventbus.Event{
  406. Type: eventbus.EventOutboundDown,
  407. Source: "out-val",
  408. Timestamp: now,
  409. Data: eventbus.OutboundHealthData{
  410. Delay: 350,
  411. Error: "connection lost",
  412. },
  413. })
  414. if !ok || len(embed.Fields) != 3 {
  415. t.Fatalf("expected 3 fields for OutboundDown value type, got ok=%v, fields=%d", ok, len(embed.Fields))
  416. }
  417. // NodeHealthData by value
  418. embedNode, ok := sub.FormatEmbed(eventbus.Event{
  419. Type: eventbus.EventNodeUp,
  420. Source: "node-val",
  421. Timestamp: now,
  422. Data: eventbus.NodeHealthData{
  423. LatencyMs: 25,
  424. },
  425. })
  426. if !ok || len(embedNode.Fields) != 2 {
  427. t.Fatalf("expected 2 fields for NodeUp value type, got ok=%v, fields=%d", ok, len(embedNode.Fields))
  428. }
  429. // SystemMetricData by value
  430. embedCPU, ok := sub.FormatEmbed(eventbus.Event{
  431. Type: eventbus.EventCPUHigh,
  432. Timestamp: now,
  433. Data: eventbus.SystemMetricData{
  434. Percent: 90.0,
  435. },
  436. })
  437. if !ok || embedCPU.Color != ColorOrange {
  438. t.Fatalf("expected orange embed for CPU high value type, got ok=%v", ok)
  439. }
  440. // LoginEventData by value
  441. embedLogin, ok := sub.FormatEmbed(eventbus.Event{
  442. Type: eventbus.EventLoginAttempt,
  443. Timestamp: now,
  444. Data: eventbus.LoginEventData{
  445. Username: "admin",
  446. IP: "127.0.0.1",
  447. Status: "success",
  448. },
  449. })
  450. if !ok || embedLogin.Color != ColorGreen {
  451. t.Fatalf("expected green embed for Login success value type, got ok=%v", ok)
  452. }
  453. }