Files
3x-ui/internal/web/service/discord/subscriber_test.go
T
MHSanaei cc3aff2434 fix(discord): escape Markdown in alert text the panel did not write
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.
2026-11-01 13:21:08 +01:00

552 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}