mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
cc3aff2434
Every Discord alert field went through cleanField, which only trimmed and truncated, so a login username, a node's error text, a remark or an email reached Discord as live Markdown: masked links, bold, headings, mentions. A failed login with a crafted username could post a working phishing link in the admins' channel. Foreign text is now Markdown-escaped in every field and command reply, and values shown in code spans can no longer close their span.
552 lines
15 KiB
Go
552 lines
15 KiB
Go
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 TestFormatEmbed_ForeignTextIsNotMarkdown(t *testing.T) {
|
||
sub := NewSubscriber(setupTestDB(t), nil)
|
||
const text = "a_b\n**x** [reset](https://attacker.example/) <@1> `c`"
|
||
const want = "a\\_b\n\\*\\*x\\*\\* \\[reset\\](https\\://attacker.example/) \\<@1\\> \\`c\\`"
|
||
|
||
for name, tc := range map[string]struct {
|
||
event eventbus.Event
|
||
field int
|
||
}{
|
||
"failed login username": {eventbus.Event{
|
||
Type: eventbus.EventLoginAttempt,
|
||
Data: &eventbus.LoginEventData{Username: text, IP: "2001:db8::1", Status: "fail", Time: "2026-10-15 02:52:22"},
|
||
}, 0},
|
||
"successful login username": {eventbus.Event{
|
||
Type: eventbus.EventLoginAttempt,
|
||
Data: &eventbus.LoginEventData{Username: text, IP: "2001:db8::1", Status: "success", Time: "2026-10-15 02:52:22"},
|
||
}, 0},
|
||
"node-reported xray error": {eventbus.Event{
|
||
Type: eventbus.EventNodeDown,
|
||
Source: "node-1",
|
||
Data: &eventbus.NodeHealthData{XrayError: text},
|
||
}, 1},
|
||
} {
|
||
t.Run(name, func(t *testing.T) {
|
||
embed, ok := sub.FormatEmbed(tc.event)
|
||
if !ok || len(embed.Fields) <= tc.field {
|
||
t.Fatalf("expected an alert with field %d, got ok=%v fields=%d", tc.field, ok, len(embed.Fields))
|
||
}
|
||
if got := embed.Fields[tc.field].Value; got != want {
|
||
t.Errorf("field value = %q, want %q", got, want)
|
||
}
|
||
// Only markup is escaped: the IP and time fields go out as written.
|
||
for _, f := range embed.Fields[tc.field+1:] {
|
||
if strings.Contains(f.Value, `\`) {
|
||
t.Errorf("field %q = %q, want it untouched", f.Name, f.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)
|
||
}
|
||
}
|