| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510 |
- package discord
- import (
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "github.com/mhsanaei/3x-ui/v3/internal/eventbus"
- )
- func TestFormatEmbed_OutboundDownAndUp(t *testing.T) {
- settingService := setupTestDB(t)
- discordService := NewDiscordService(settingService)
- sub := NewSubscriber(settingService, discordService)
- now := time.Date(2026, 9, 12, 12, 0, 0, 0, time.UTC)
- // Outbound down
- downEvent := eventbus.Event{
- Type: eventbus.EventOutboundDown,
- Source: "proxy-1",
- Timestamp: now,
- Data: &eventbus.OutboundHealthData{
- Delay: 500,
- Error: "timeout connecting",
- },
- }
- embed, ok := sub.FormatEmbed(downEvent)
- if !ok {
- t.Fatal("expected embed to be formatted")
- }
- if embed.Color != ColorRed {
- t.Errorf("expected ColorRed, got 0x%X", embed.Color)
- }
- if embed.Timestamp != "2026-09-12T12:00:00Z" {
- t.Errorf("expected RFC3339 UTC timestamp, got %s", embed.Timestamp)
- }
- if len(embed.Fields) != 3 {
- t.Fatalf("expected 3 fields, got %d", len(embed.Fields))
- }
- // Outbound up
- upEvent := eventbus.Event{
- Type: eventbus.EventOutboundUp,
- Source: "proxy-1",
- Timestamp: now,
- Data: &eventbus.OutboundHealthData{
- Delay: 120,
- },
- }
- embedUp, ok := sub.FormatEmbed(upEvent)
- if !ok {
- t.Fatal("expected embed to be formatted")
- }
- if embedUp.Color != ColorGreen {
- t.Errorf("expected ColorGreen, got 0x%X", embedUp.Color)
- }
- }
- func TestFormatEmbed_NodeDownAndUp(t *testing.T) {
- settingService := setupTestDB(t)
- discordService := NewDiscordService(settingService)
- sub := NewSubscriber(settingService, discordService)
- now := time.Now().UTC()
- // Node down
- downEvent := eventbus.Event{
- Type: eventbus.EventNodeDown,
- Source: "node-us",
- Timestamp: now,
- Data: &eventbus.NodeHealthData{
- XrayError: "connection refused",
- },
- }
- embed, ok := sub.FormatEmbed(downEvent)
- if !ok {
- t.Fatal("expected embed to be formatted")
- }
- if embed.Color != ColorRed {
- t.Errorf("expected ColorRed, got 0x%X", embed.Color)
- }
- // Node up
- upEvent := eventbus.Event{
- Type: eventbus.EventNodeUp,
- Source: "node-us",
- Timestamp: now,
- Data: &eventbus.NodeHealthData{
- LatencyMs: 45,
- },
- }
- embedUp, ok := sub.FormatEmbed(upEvent)
- if !ok {
- t.Fatal("expected embed to be formatted")
- }
- if embedUp.Color != ColorGreen {
- t.Errorf("expected ColorGreen, got 0x%X", embedUp.Color)
- }
- }
- func TestFormatEmbed_XrayCrash(t *testing.T) {
- settingService := setupTestDB(t)
- discordService := NewDiscordService(settingService)
- sub := NewSubscriber(settingService, discordService)
- crashEvent := eventbus.Event{
- Type: eventbus.EventXrayCrash,
- Timestamp: time.Now().UTC(),
- Data: "panic: core dump",
- }
- embed, ok := sub.FormatEmbed(crashEvent)
- if !ok {
- t.Fatal("expected embed to be formatted")
- }
- if embed.Color != ColorRed {
- t.Errorf("expected ColorRed, got 0x%X", embed.Color)
- }
- }
- func TestFormatEmbed_CpuAndMemoryThresholds(t *testing.T) {
- settingService := setupTestDB(t)
- _ = settingService.SetDiscordCpu(80)
- _ = settingService.SetDiscordMemory(75)
- discordService := NewDiscordService(settingService)
- sub := NewSubscriber(settingService, discordService)
- now := time.Now().UTC()
- // CPU below threshold -> no embed
- _, ok := sub.FormatEmbed(eventbus.Event{
- Type: eventbus.EventCPUHigh,
- Timestamp: now,
- Data: &eventbus.SystemMetricData{Percent: 79.5},
- })
- if ok {
- t.Error("expected no embed when CPU is below threshold")
- }
- // CPU above threshold -> Orange embed
- embedCpu, ok := sub.FormatEmbed(eventbus.Event{
- Type: eventbus.EventCPUHigh,
- Timestamp: now,
- Data: &eventbus.SystemMetricData{Percent: 85.2},
- })
- if !ok {
- t.Fatal("expected embed when CPU is above threshold")
- }
- if embedCpu.Color != ColorOrange {
- t.Errorf("expected ColorOrange (0x%X), got 0x%X", ColorOrange, embedCpu.Color)
- }
- // Memory below threshold -> no embed
- _, ok = sub.FormatEmbed(eventbus.Event{
- Type: eventbus.EventMemoryHigh,
- Timestamp: now,
- Data: &eventbus.SystemMetricData{Percent: 70.0},
- })
- if ok {
- t.Error("expected no embed when Memory is below threshold")
- }
- // Memory above threshold -> Orange embed
- embedMem, ok := sub.FormatEmbed(eventbus.Event{
- Type: eventbus.EventMemoryHigh,
- Timestamp: now,
- Data: &eventbus.SystemMetricData{Percent: 90.0},
- })
- if !ok {
- t.Fatal("expected embed when Memory is above threshold")
- }
- if embedMem.Color != ColorOrange {
- t.Errorf("expected ColorOrange (0x%X), got 0x%X", ColorOrange, embedMem.Color)
- }
- }
- func TestFormatEmbed_LoginAttempt(t *testing.T) {
- settingService := setupTestDB(t)
- discordService := NewDiscordService(settingService)
- sub := NewSubscriber(settingService, discordService)
- now := time.Now().UTC()
- // Login success -> Green
- successEvent := eventbus.Event{
- Type: eventbus.EventLoginAttempt,
- Timestamp: now,
- Data: &eventbus.LoginEventData{
- Username: "admin",
- IP: "1.2.3.4",
- Time: "2026-09-12 12:00:00",
- Status: "success",
- },
- }
- embedSuccess, ok := sub.FormatEmbed(successEvent)
- if !ok {
- t.Fatal("expected embed for login success")
- }
- if embedSuccess.Color != ColorGreen {
- t.Errorf("expected ColorGreen, got 0x%X", embedSuccess.Color)
- }
- // Login fail -> Red
- failEvent := eventbus.Event{
- Type: eventbus.EventLoginAttempt,
- Timestamp: now,
- Data: &eventbus.LoginEventData{
- Username: "attacker",
- IP: "5.6.7.8",
- Time: "2026-09-12 12:01:00",
- Status: "fail",
- Reason: "wrong password",
- },
- }
- embedFail, ok := sub.FormatEmbed(failEvent)
- if !ok {
- t.Fatal("expected embed for login failure")
- }
- if embedFail.Color != ColorRed {
- t.Errorf("expected ColorRed, got 0x%X", embedFail.Color)
- }
- // Fallback when data is nil
- fallbackEvent := eventbus.Event{
- Type: eventbus.EventLoginAttempt,
- Source: "unknown-source",
- Timestamp: now,
- }
- embedFallback, ok := sub.FormatEmbed(fallbackEvent)
- if !ok {
- t.Fatal("expected embed for fallback login")
- }
- if embedFallback.Color != ColorRed {
- t.Errorf("expected ColorRed, got 0x%X", embedFallback.Color)
- }
- }
- func TestCleanField_Protection(t *testing.T) {
- field := cleanField("", " ", true)
- if field.Name != "-" || field.Value != "-" {
- t.Errorf("expected '-' for empty field name/value, got name=%q, value=%q", field.Name, field.Value)
- }
- field2 := cleanField(" Name ", " Value ", false)
- if field2.Name != "Name" || field2.Value != "Value" {
- t.Errorf("expected trimmed name/value, got name=%q, value=%q", field2.Name, field2.Value)
- }
- }
- func TestHandleEvent_EndToEndWithServer(t *testing.T) {
- settingService := setupTestDB(t)
- _ = settingService.SetDiscordBotEnable(true)
- _ = settingService.SetDiscordBotToken("test-token")
- _ = settingService.SetDiscordChannelId("ch-test")
- _ = settingService.SetDiscordEnabledEvents("login.attempt,outbound.down")
- receivedCh := make(chan MessagePayload, 10)
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body, _ := io.ReadAll(r.Body)
- var p MessagePayload
- _ = json.Unmarshal(body, &p)
- receivedCh <- p
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
- discordService := NewDiscordService(settingService)
- discordService.SetBaseURL(server.URL)
- discordService.SetHTTPClient(server.Client())
- sub := NewSubscriber(settingService, discordService)
- // 1. Send enabled event (outbound.down)
- sub.HandleEvent(eventbus.Event{
- Type: eventbus.EventOutboundDown,
- Source: "out-1",
- Timestamp: time.Now().UTC(),
- })
- select {
- case p := <-receivedCh:
- if len(p.Embeds) != 1 || p.Embeds[0].Color != ColorRed {
- t.Errorf("unexpected payload: %+v", p)
- }
- case <-time.After(2 * time.Second):
- t.Fatal("timed out waiting for outbound.down message")
- }
- // 2. Duplicate outbound.down within rate limit -> should be suppressed
- sub.HandleEvent(eventbus.Event{
- Type: eventbus.EventOutboundDown,
- Source: "out-1",
- Timestamp: time.Now().UTC(),
- })
- select {
- case p := <-receivedCh:
- t.Fatalf("rate limited event was unexpectedly sent: %+v", p)
- case <-time.After(150 * time.Millisecond):
- // OK
- }
- // 3. Login attempt bypasses rate limit
- sub.HandleEvent(eventbus.Event{
- Type: eventbus.EventLoginAttempt,
- Timestamp: time.Now().UTC(),
- Data: &eventbus.LoginEventData{
- Username: "admin",
- IP: "1.1.1.1",
- Status: "success",
- },
- })
- sub.HandleEvent(eventbus.Event{
- Type: eventbus.EventLoginAttempt,
- Timestamp: time.Now().UTC(),
- Data: &eventbus.LoginEventData{
- Username: "admin",
- IP: "1.1.1.1",
- Status: "success",
- },
- })
- // Both should arrive
- for i := 0; i < 2; i++ {
- select {
- case <-receivedCh:
- // OK
- case <-time.After(2 * time.Second):
- t.Fatalf("timed out waiting for login attempt message %d", i+1)
- }
- }
- // 4. Disabled event type (cpu.high is not in discordEnabledEvents)
- _ = settingService.SetDiscordCpu(50)
- sub.HandleEvent(eventbus.Event{
- Type: eventbus.EventCPUHigh,
- Timestamp: time.Now().UTC(),
- Data: &eventbus.SystemMetricData{Percent: 99.0},
- })
- select {
- case p := <-receivedCh:
- t.Fatalf("disabled event was unexpectedly sent: %+v", p)
- case <-time.After(150 * time.Millisecond):
- // OK
- }
- // 5. Bot disabled entirely
- _ = settingService.SetDiscordBotEnable(false)
- sub.HandleEvent(eventbus.Event{
- Type: eventbus.EventLoginAttempt,
- Timestamp: time.Now().UTC(),
- Data: &eventbus.LoginEventData{
- Username: "admin",
- IP: "1.1.1.1",
- Status: "success",
- },
- })
- select {
- case p := <-receivedCh:
- t.Fatalf("event sent while bot disabled: %+v", p)
- case <-time.After(150 * time.Millisecond):
- // OK
- }
- }
- func TestCleanField_Truncation(t *testing.T) {
- longName := strings.Repeat("А", 300) // 300 runes of 2-byte UTF-8
- longValue := strings.Repeat("🔥", 1200) // 1200 runes of 4-byte UTF-8
- field := cleanField(longName, longValue, false)
- nameRunes := []rune(field.Name)
- valRunes := []rune(field.Value)
- if len(nameRunes) > 256 {
- t.Errorf("expected name runes <= 256, got %d", len(nameRunes))
- }
- if !strings.HasSuffix(field.Name, "...") {
- t.Errorf("expected truncated name to end with '...', got %s", field.Name)
- }
- if len(valRunes) > 1024 {
- t.Errorf("expected value runes <= 1024, got %d", len(valRunes))
- }
- if !strings.HasSuffix(field.Value, "...") {
- t.Errorf("expected truncated value to end with '...', got %s", field.Value)
- }
- }
- func TestHandleEvent_BelowThresholdDoesNotBurnRateLimiter(t *testing.T) {
- settingService := setupTestDB(t)
- _ = settingService.SetDiscordBotEnable(true)
- _ = settingService.SetDiscordBotToken("test-token")
- _ = settingService.SetDiscordChannelId("ch-test")
- _ = settingService.SetDiscordEnabledEvents("cpu.high")
- _ = settingService.SetDiscordCpu(80)
- receivedCh := make(chan MessagePayload, 5)
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body, _ := io.ReadAll(r.Body)
- var p MessagePayload
- _ = json.Unmarshal(body, &p)
- receivedCh <- p
- w.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
- discordService := NewDiscordService(settingService)
- discordService.SetBaseURL(server.URL)
- discordService.SetHTTPClient(server.Client())
- sub := NewSubscriber(settingService, discordService)
- // 1. CPU at 50% (below 80% threshold) - must NOT be sent and must NOT burn rate limiter
- sub.HandleEvent(eventbus.Event{
- Type: eventbus.EventCPUHigh,
- Timestamp: time.Now().UTC(),
- Data: &eventbus.SystemMetricData{Percent: 50.0},
- })
- select {
- case p := <-receivedCh:
- t.Fatalf("sub-threshold CPU event was unexpectedly sent: %+v", p)
- case <-time.After(150 * time.Millisecond):
- // OK
- }
- // 2. CPU immediately spikes to 95% (above 80% threshold) - MUST be sent!
- sub.HandleEvent(eventbus.Event{
- Type: eventbus.EventCPUHigh,
- Timestamp: time.Now().UTC(),
- Data: &eventbus.SystemMetricData{Percent: 95.0},
- })
- select {
- case p := <-receivedCh:
- if len(p.Embeds) != 1 || p.Embeds[0].Color != ColorOrange {
- t.Errorf("unexpected payload for critical CPU alert: %+v", p)
- }
- case <-time.After(2 * time.Second):
- t.Fatal("critical CPU alert was incorrectly suppressed by rate limiter after below-threshold event")
- }
- }
- func TestFormatEmbed_ValueTypes(t *testing.T) {
- settingService := setupTestDB(t)
- _ = settingService.SetDiscordCpu(80)
- _ = settingService.SetDiscordMemory(80)
- sub := NewSubscriber(settingService, NewDiscordService(settingService))
- now := time.Now().UTC()
- // OutboundHealthData by value
- embed, ok := sub.FormatEmbed(eventbus.Event{
- Type: eventbus.EventOutboundDown,
- Source: "out-val",
- Timestamp: now,
- Data: eventbus.OutboundHealthData{
- Delay: 350,
- Error: "connection lost",
- },
- })
- if !ok || len(embed.Fields) != 3 {
- t.Fatalf("expected 3 fields for OutboundDown value type, got ok=%v, fields=%d", ok, len(embed.Fields))
- }
- // NodeHealthData by value
- embedNode, ok := sub.FormatEmbed(eventbus.Event{
- Type: eventbus.EventNodeUp,
- Source: "node-val",
- Timestamp: now,
- Data: eventbus.NodeHealthData{
- LatencyMs: 25,
- },
- })
- if !ok || len(embedNode.Fields) != 2 {
- t.Fatalf("expected 2 fields for NodeUp value type, got ok=%v, fields=%d", ok, len(embedNode.Fields))
- }
- // SystemMetricData by value
- embedCPU, ok := sub.FormatEmbed(eventbus.Event{
- Type: eventbus.EventCPUHigh,
- Timestamp: now,
- Data: eventbus.SystemMetricData{
- Percent: 90.0,
- },
- })
- if !ok || embedCPU.Color != ColorOrange {
- t.Fatalf("expected orange embed for CPU high value type, got ok=%v", ok)
- }
- // LoginEventData by value
- embedLogin, ok := sub.FormatEmbed(eventbus.Event{
- Type: eventbus.EventLoginAttempt,
- Timestamp: now,
- Data: eventbus.LoginEventData{
- Username: "admin",
- IP: "127.0.0.1",
- Status: "success",
- },
- })
- if !ok || embedLogin.Color != ColorGreen {
- t.Fatalf("expected green embed for Login success value type, got ok=%v", ok)
- }
- }
|