mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
fa689aedbc
自定义 Webhook 在 HTTP 200 但业务 errcode 非零时记为失败,任务日志记录上游响应。 OTel Resource 改为 NewSchemaless,避免 semconv 与 SDK 版本冲突。 前端用 next/font 自托管 Inter,并忽略浏览器扩展改写 body 引起的 hydration 警告。
765 lines
24 KiB
Go
765 lines
24 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package push
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
|
pkgpush "github.com/Rain-kl/Wavelet/pkg/push"
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/hibiken/asynq"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
|
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
|
)
|
|
|
|
var adminLoginEvent = EventMetadata{
|
|
Key: "admin_login",
|
|
Name: "管理员登录",
|
|
DefaultTemplate: NotificationMessage{
|
|
Title: "管理员登录提醒",
|
|
Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。",
|
|
Level: "INFO",
|
|
},
|
|
Description: "当管理员成功登录系统时触发此通知",
|
|
}
|
|
|
|
func init() {
|
|
RegisterBuiltInEvent(adminLoginEvent)
|
|
}
|
|
|
|
// mockPusher mock implementation of pkgpush.Pusher
|
|
type mockPusher struct {
|
|
mu sync.Mutex
|
|
sentBody map[string]any
|
|
sentTgt string
|
|
}
|
|
|
|
func (m *mockPusher) Send(ctx context.Context, cfg pkgpush.Config, target string, body map[string]any, template string, ext map[string]any) (string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.sentBody = body
|
|
m.sentTgt = target
|
|
return "", nil
|
|
}
|
|
|
|
func (m *mockPusher) ValidateConfig(cfg pkgpush.Config) error {
|
|
return nil
|
|
}
|
|
|
|
func setupPushTest(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) {
|
|
dbConn, mr, cleanup := testhelper.SetupTestEnvironment(t)
|
|
|
|
// AutoMigrate push tables in SQLite test environment
|
|
err := dbConn.AutoMigrate(&model.PushEvent{}, &model.PushHistory{}, &model.User{}, &model.PushChannel{}, &model.SystemConfig{})
|
|
require.NoError(t, err)
|
|
|
|
// 写入数据库系统默认用户 Seed 记录
|
|
sysUser := &model.User{
|
|
ID: 999,
|
|
Username: "system",
|
|
Nickname: "系统",
|
|
Password: "*",
|
|
IsActive: true,
|
|
}
|
|
err = dbConn.Create(sysUser).Error
|
|
require.NoError(t, err)
|
|
|
|
// Initialize AsynqClient pointing to miniredis
|
|
task.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{
|
|
Addr: mr.Addr(),
|
|
})
|
|
|
|
// Register the task handler and metadata
|
|
task.RegisterHandler(SendNotificationTask, &PushHandler{})
|
|
task.RegisterTaskMeta(SendNotificationMeta)
|
|
|
|
return dbConn, mr, func() {
|
|
cleanup()
|
|
if task.AsynqClient != nil {
|
|
task.AsynqClient.Close()
|
|
task.AsynqClient = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func setupTestRouter(authUser *model.User) *gin.Engine {
|
|
r := testhelper.NewTestGinEngine()
|
|
adminGroup := r.Group("/api/v1/admin/push")
|
|
|
|
adminGroup.Use(func(c *gin.Context) {
|
|
if authUser != nil {
|
|
oauth.SetToContext(c, "user_obj", authUser)
|
|
}
|
|
c.Next()
|
|
})
|
|
|
|
adminGroup.GET("/events", ListEvents)
|
|
adminGroup.GET("/events/builtin", ListBuiltInEvents)
|
|
adminGroup.POST("/events", CreateEvent)
|
|
adminGroup.PUT("/events/:id", UpdateEvent)
|
|
adminGroup.DELETE("/events/:id", DeleteEvent)
|
|
adminGroup.POST("/events/:id/toggle", ToggleEvent)
|
|
adminGroup.GET("/histories", ListHistories)
|
|
adminGroup.POST("/test", TestPush)
|
|
|
|
return r
|
|
}
|
|
|
|
func TestSyncEvents(t *testing.T) {
|
|
dbConn, _, cleanup := setupPushTest(t)
|
|
defer cleanup()
|
|
|
|
// 1. SyncEvents first time
|
|
err := SyncEvents(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
// Verify event exists in DB
|
|
var event model.PushEvent
|
|
err = dbConn.Where("event_key = ?", "admin_login").First(&event).Error
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "管理员登录", event.Name)
|
|
assert.False(t, event.Enabled)
|
|
|
|
// Verify DefaultTemplate matches GORM template field
|
|
var defaultMsg NotificationMessage
|
|
err = json.Unmarshal([]byte(event.Template), &defaultMsg)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, adminLoginEvent.DefaultTemplate.Title, defaultMsg.Title)
|
|
assert.Equal(t, adminLoginEvent.DefaultTemplate.Content, defaultMsg.Content)
|
|
}
|
|
|
|
func TestEventTrigger(t *testing.T) {
|
|
dbConn, _, cleanup := setupPushTest(t)
|
|
defer cleanup()
|
|
|
|
// SyncEvents
|
|
err := SyncEvents(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
t.Run("trigger disabled event silently ignored", func(t *testing.T) {
|
|
body := map[string]any{
|
|
"user": map[string]any{"username": "test_admin"},
|
|
"ip": "127.0.0.1",
|
|
}
|
|
DefaultTrigger.Trigger(context.Background(), adminLoginEvent, body)
|
|
|
|
// Sleep briefly since Trigger runs in goroutine
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Verify no tasks enqueued in TaskExecution GORM table
|
|
var count int64
|
|
dbConn.Model(&model.TaskExecution{}).Count(&count)
|
|
assert.Equal(t, int64(0), count)
|
|
})
|
|
|
|
t.Run("trigger enabled event enqueues task", func(t *testing.T) {
|
|
// Create an enabled custom channel in GORM
|
|
customChan := &model.PushChannel{
|
|
Name: "mock_channel",
|
|
Type: "custom",
|
|
URL: "https://webhook.site/trigger",
|
|
Other: `{"text": "$content"}`,
|
|
Enabled: true,
|
|
}
|
|
err = dbConn.Create(customChan).Error
|
|
require.NoError(t, err)
|
|
defer dbConn.Delete(customChan)
|
|
|
|
// Enable the push event in DB using struct to trigger JSON serializer
|
|
var event model.PushEvent
|
|
err = dbConn.Where("event_key = ?", "admin_login").First(&event).Error
|
|
require.NoError(t, err)
|
|
|
|
event.Enabled = true
|
|
event.Channels = []string{"mock_channel"}
|
|
event.Targets = []string{"admin_user"}
|
|
err = dbConn.Save(&event).Error
|
|
require.NoError(t, err)
|
|
repository.DeleteActivePushEventCache(context.Background(), "admin_login")
|
|
|
|
// Trigger
|
|
body := map[string]any{
|
|
"user": map[string]any{
|
|
"username": "super_admin",
|
|
},
|
|
"ip": "1.1.1.1",
|
|
"time": "2026-06-14 18:00:00",
|
|
}
|
|
DefaultTrigger.Trigger(context.Background(), adminLoginEvent, body)
|
|
|
|
// Wait for goroutine execution
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Verify TaskExecution enqueued record
|
|
var execution model.TaskExecution
|
|
err = dbConn.Where("task_type = ?", SendNotificationTask).First(&execution).Error
|
|
require.NoError(t, err)
|
|
|
|
// Verify enqueued payload structure
|
|
var payload SendPayload
|
|
err = json.Unmarshal([]byte(execution.Payload), &payload)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "admin_login", payload.EventKey)
|
|
assert.Equal(t, "custom", payload.Config.Channel)
|
|
assert.Equal(t, "https://webhook.site/trigger", payload.Config.URL)
|
|
assert.Equal(t, "admin_user", payload.Target)
|
|
assert.Equal(t, "管理员登录提醒", payload.Body.Title)
|
|
assert.Contains(t, payload.Body.Content, "super_admin")
|
|
assert.Contains(t, payload.Body.Content, "1.1.1.1")
|
|
})
|
|
|
|
t.Run("trigger without user injects virtual system user", func(t *testing.T) {
|
|
// Create an enabled custom channel in GORM
|
|
customChan := &model.PushChannel{
|
|
Name: "mock_channel",
|
|
Type: "custom",
|
|
URL: "https://webhook.site/trigger",
|
|
Other: `{"text": "$content"}`,
|
|
Enabled: true,
|
|
}
|
|
err = dbConn.Create(customChan).Error
|
|
require.NoError(t, err)
|
|
defer dbConn.Delete(customChan)
|
|
|
|
// Enable the push event in DB
|
|
var event model.PushEvent
|
|
err = dbConn.Where("event_key = ?", "admin_login").First(&event).Error
|
|
require.NoError(t, err)
|
|
|
|
// 清理旧任务执行记录
|
|
dbConn.Where("task_type = ?", SendNotificationTask).Delete(&model.TaskExecution{})
|
|
|
|
event.Enabled = true
|
|
event.Channels = []string{"mock_channel"}
|
|
event.Targets = []string{"user.username"} // 动态目标
|
|
err = dbConn.Save(&event).Error
|
|
require.NoError(t, err)
|
|
repository.DeleteActivePushEventCache(context.Background(), "admin_login")
|
|
|
|
// Trigger with empty body (simulates cron scheduler triggering)
|
|
DefaultTrigger.Trigger(context.Background(), adminLoginEvent, nil)
|
|
|
|
// Wait for goroutine execution
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Verify TaskExecution enqueued record
|
|
var execution model.TaskExecution
|
|
err = dbConn.Where("task_type = ?", SendNotificationTask).First(&execution).Error
|
|
require.NoError(t, err)
|
|
|
|
var payload SendPayload
|
|
err = json.Unmarshal([]byte(execution.Payload), &payload)
|
|
require.NoError(t, err)
|
|
|
|
// 检查 payload 是否将 target (user.username) 成功替换为 "system"
|
|
assert.Equal(t, "system", payload.Target)
|
|
// 检查 payload 中的 Content,应当被替换为 "system" 变量
|
|
assert.Contains(t, payload.Body.Content, "system")
|
|
})
|
|
}
|
|
|
|
func TestPushHandler(t *testing.T) {
|
|
dbConn, _, cleanup := setupPushTest(t)
|
|
defer cleanup()
|
|
|
|
mPusher := &mockPusher{}
|
|
pkgpush.Register("mock_channel", mPusher)
|
|
|
|
handler := &PushHandler{}
|
|
|
|
payload := SendPayload{
|
|
EventKey: "admin_login",
|
|
Config: pkgpush.Config{
|
|
Channel: "mock_channel",
|
|
URL: "http://mock-url",
|
|
},
|
|
Target: "admin_user",
|
|
Body: NotificationMessage{
|
|
Title: "Structured Alert",
|
|
Content: "Hello World",
|
|
Level: "WARNING",
|
|
Ext: map[string]any{"extra_val": 42},
|
|
},
|
|
}
|
|
payloadBytes, err := json.Marshal(payload)
|
|
require.NoError(t, err)
|
|
|
|
t.Run("validate payload", func(t *testing.T) {
|
|
validated, valErr := handler.ValidatePayload(payloadBytes)
|
|
require.NoError(t, valErr)
|
|
assert.NotEmpty(t, validated)
|
|
})
|
|
|
|
t.Run("execute task successfully", func(t *testing.T) {
|
|
res, execErr := handler.Execute(context.Background(), payloadBytes)
|
|
require.NoError(t, execErr)
|
|
assert.Contains(t, res.Message, "推送成功")
|
|
|
|
// Verify mock pusher received flattened variables
|
|
mPusher.mu.Lock()
|
|
assert.Equal(t, "admin_user", mPusher.sentTgt)
|
|
assert.Equal(t, "Structured Alert", mPusher.sentBody["title"])
|
|
assert.Equal(t, "Hello World", mPusher.sentBody["content"])
|
|
assert.Equal(t, "WARNING", mPusher.sentBody["level"])
|
|
assert.Equal(t, float64(42), mPusher.sentBody["extra_val"]) // unmarshaled json numbers are float64 by default
|
|
mPusher.mu.Unlock()
|
|
|
|
// Verify PushHistory recorded
|
|
var history model.PushHistory
|
|
err = dbConn.First(&history).Error
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "admin_login", history.EventKey)
|
|
assert.Equal(t, "mock_channel", history.Channel)
|
|
assert.Equal(t, "success", history.Status)
|
|
assert.Equal(t, "Structured Alert", history.Title)
|
|
})
|
|
}
|
|
|
|
func TestPushRouters(t *testing.T) {
|
|
dbConn, _, cleanup := setupPushTest(t)
|
|
defer cleanup()
|
|
|
|
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
|
r := setupTestRouter(adminUser)
|
|
|
|
// Sync events to populate db
|
|
err := SyncEvents(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
t.Run("list events", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", "/api/v1/admin/push/events", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp response.Any
|
|
err = json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
|
|
dataBytes, _ := json.Marshal(resp.Data)
|
|
var events []model.PushEvent
|
|
err = json.Unmarshal(dataBytes, &events)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, events, 1)
|
|
assert.Equal(t, "admin_login", events[0].EventKey)
|
|
})
|
|
|
|
t.Run("toggle event status", func(t *testing.T) {
|
|
var event model.PushEvent
|
|
dbConn.First(&event)
|
|
|
|
// 1. 未配置任何渠道时开启,应该被拒绝
|
|
req, _ := http.NewRequest("POST", "/api/v1/admin/push/events/"+strconv.FormatUint(event.ID, 10)+"/toggle", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
// 2. 为该事件关联渠道后,再切换开启,应当成功
|
|
event.Channels = []string{"email"}
|
|
dbConn.Save(&event)
|
|
repository.DeleteActivePushEventCache(context.Background(), event.EventKey)
|
|
|
|
req2, _ := http.NewRequest("POST", "/api/v1/admin/push/events/"+strconv.FormatUint(event.ID, 10)+"/toggle", nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
assert.Equal(t, http.StatusOK, w2.Code)
|
|
|
|
var updated model.PushEvent
|
|
dbConn.First(&updated)
|
|
assert.True(t, updated.Enabled)
|
|
})
|
|
|
|
t.Run("update event", func(t *testing.T) {
|
|
var event model.PushEvent
|
|
dbConn.First(&event)
|
|
|
|
updateReq := UpdateEventRequest{
|
|
Channels: []string{"email"},
|
|
Targets: []string{"user@test.com"},
|
|
Template: `{"title": "Custom Login Alert", "content": "Alert", "level": "WARNING"}`,
|
|
Enabled: true,
|
|
}
|
|
bodyBytes, _ := json.Marshal(updateReq)
|
|
req, _ := http.NewRequest("PUT", "/api/v1/admin/push/events/"+strconv.FormatUint(event.ID, 10), bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var updated model.PushEvent
|
|
dbConn.First(&updated)
|
|
assert.Equal(t, []string{"email"}, updated.Channels)
|
|
assert.Equal(t, []string{"user@test.com"}, updated.Targets)
|
|
assert.Contains(t, updated.Template, "Custom Login Alert")
|
|
})
|
|
|
|
t.Run("list push histories", func(t *testing.T) {
|
|
// Populate history record
|
|
hist := model.PushHistory{
|
|
EventKey: "admin_login",
|
|
Channel: "email",
|
|
Target: "user@test.com",
|
|
Title: "Custom Login Alert",
|
|
Content: "Alert",
|
|
Level: "WARNING",
|
|
Status: "success",
|
|
}
|
|
dbConn.Create(&hist)
|
|
|
|
req, _ := http.NewRequest("GET", "/api/v1/admin/push/histories?page=1&page_size=10", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp response.Any
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
|
|
dataMap, ok := resp.Data.(map[string]any)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, float64(1), dataMap["total"])
|
|
})
|
|
|
|
t.Run("test push endpoint", func(t *testing.T) {
|
|
mPusher := &mockPusher{}
|
|
pkgpush.Register("test_channel", mPusher)
|
|
|
|
testReq := TestPushRequest{
|
|
Config: pkgpush.Config{
|
|
Channel: "test_channel",
|
|
URL: "http://test-url",
|
|
},
|
|
Target: "test_target",
|
|
}
|
|
bodyBytes, _ := json.Marshal(testReq)
|
|
req, _ := http.NewRequest("POST", "/api/v1/admin/push/test", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
})
|
|
|
|
t.Run("list built-in events", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", "/api/v1/admin/push/events/builtin", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp response.Any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
|
|
builtins, ok := resp.Data.([]any)
|
|
assert.True(t, ok)
|
|
assert.NotEmpty(t, builtins)
|
|
})
|
|
|
|
t.Run("create and delete push event", func(t *testing.T) {
|
|
// Clean up any existing admin_login event first
|
|
dbConn.Where("event_key = ?", "admin_login").Delete(&model.PushEvent{})
|
|
|
|
// 1. Create event
|
|
createReq := CreateEventRequest{
|
|
EventKey: "admin_login",
|
|
Channels: []string{"email"},
|
|
Targets: []string{"admin@test.com"},
|
|
Enabled: true,
|
|
}
|
|
bodyBytes, _ := json.Marshal(createReq)
|
|
req, _ := http.NewRequest("POST", "/api/v1/admin/push/events", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify created in DB
|
|
var event model.PushEvent
|
|
err := dbConn.Where("event_key = ?", "admin_login").First(&event).Error
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "admin_login", event.EventKey)
|
|
assert.Equal(t, "管理员登录", event.Name)
|
|
assert.True(t, event.Enabled)
|
|
|
|
// 2. Try creating again (should fail)
|
|
w2 := httptest.NewRecorder()
|
|
req2, _ := http.NewRequest("POST", "/api/v1/admin/push/events", bytes.NewBuffer(bodyBytes))
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w2, req2)
|
|
assert.Equal(t, http.StatusBadRequest, w2.Code)
|
|
|
|
// 3. Delete event
|
|
w3 := httptest.NewRecorder()
|
|
req3, _ := http.NewRequest("DELETE", "/api/v1/admin/push/events/"+strconv.FormatUint(event.ID, 10), nil)
|
|
r.ServeHTTP(w3, req3)
|
|
assert.Equal(t, http.StatusOK, w3.Code)
|
|
|
|
// Verify deleted from DB
|
|
var count int64
|
|
dbConn.Model(&model.PushEvent{}).Where("event_key = ?", "admin_login").Count(&count)
|
|
assert.Equal(t, int64(0), count)
|
|
})
|
|
}
|
|
|
|
func TestResolveTarget(t *testing.T) {
|
|
dbConn, _, cleanup := setupPushTest(t)
|
|
defer cleanup()
|
|
|
|
// 1. 创建测试用户与管理员用户
|
|
testUser := &model.User{
|
|
ID: 9999,
|
|
Username: "target_user",
|
|
Email: "target@test.com",
|
|
IsAdmin: false,
|
|
}
|
|
err := dbConn.Create(testUser).Error
|
|
require.NoError(t, err)
|
|
|
|
adminUser := &model.User{
|
|
ID: 8888,
|
|
Username: "admin_user",
|
|
Email: "admin@test.com",
|
|
IsAdmin: true,
|
|
}
|
|
err = dbConn.Create(adminUser).Error
|
|
require.NoError(t, err)
|
|
|
|
flatBody := map[string]any{
|
|
"user.id": float64(9999), // JSON 反序列化后一般是 float64
|
|
"user.username": "target_user",
|
|
"user.email": "target@test.com",
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
t.Run("dynamic user.id resolved and converted for email channel", func(t *testing.T) {
|
|
res := resolveTarget(ctx, "user.id", flatBody, "email")
|
|
assert.Equal(t, "target@test.com", res)
|
|
})
|
|
|
|
t.Run("dynamic user.username resolved and converted for email channel", func(t *testing.T) {
|
|
res := resolveTarget(ctx, "user.username", flatBody, "email")
|
|
assert.Equal(t, "target@test.com", res)
|
|
})
|
|
|
|
t.Run("dynamic user.email resolved directly for email channel", func(t *testing.T) {
|
|
res := resolveTarget(ctx, "user.email", flatBody, "email")
|
|
assert.Equal(t, "target@test.com", res)
|
|
})
|
|
|
|
t.Run("fixed user id resolved and converted for email channel", func(t *testing.T) {
|
|
res := resolveTarget(ctx, "9999", flatBody, "email")
|
|
assert.Equal(t, "target@test.com", res)
|
|
})
|
|
|
|
t.Run("fixed username resolved and converted for email channel", func(t *testing.T) {
|
|
res := resolveTarget(ctx, "target_user", flatBody, "email")
|
|
assert.Equal(t, "target@test.com", res)
|
|
})
|
|
|
|
t.Run("fixed email address resolved directly for email channel", func(t *testing.T) {
|
|
res := resolveTarget(ctx, "fixed@example.com", flatBody, "email")
|
|
assert.Equal(t, "fixed@example.com", res)
|
|
})
|
|
|
|
t.Run("fixed username resolved for non-email channel", func(t *testing.T) {
|
|
res := resolveTarget(ctx, "target_user", flatBody, "lark")
|
|
assert.Equal(t, "target_user", res)
|
|
})
|
|
|
|
t.Run("non-exist user resolved as fallback", func(t *testing.T) {
|
|
res := resolveTarget(ctx, "non_exist_user", flatBody, "email")
|
|
assert.Equal(t, "non_exist_user", res)
|
|
})
|
|
|
|
t.Run("system target resolves to admin email for email channel", func(t *testing.T) {
|
|
res := resolveTarget(ctx, "系统", flatBody, "email")
|
|
assert.Equal(t, "admin@test.com", res)
|
|
|
|
res2 := resolveTarget(ctx, "system", flatBody, "email")
|
|
assert.Equal(t, "admin@test.com", res2)
|
|
|
|
res3 := resolveTarget(ctx, "0", flatBody, "email")
|
|
assert.Equal(t, "admin@test.com", res3)
|
|
})
|
|
|
|
t.Run("system target resolves to admin username for lark channel", func(t *testing.T) {
|
|
res := resolveTarget(ctx, "系统", flatBody, "lark")
|
|
assert.Equal(t, "admin_user", res)
|
|
})
|
|
}
|
|
|
|
func TestPushChannelAPI(t *testing.T) {
|
|
// 1. 模型校验测试
|
|
t.Run("validate push channel model constraints", func(t *testing.T) {
|
|
// 校验名称合法性
|
|
c1 := &model.PushChannel{Name: "invalid-name!", URL: "https://hook.com", Other: "{}"}
|
|
assert.Error(t, c1.Validate())
|
|
|
|
// 校验 URL 安全前缀 HTTPS
|
|
c2 := &model.PushChannel{Name: "custom_channel", URL: "http://insecure-hook.com", Other: "{}"}
|
|
assert.Error(t, c2.Validate())
|
|
|
|
// 校验 JSON 格式
|
|
c3 := &model.PushChannel{Name: "custom_channel", URL: "https://hook.com", Other: "{invalid-json}"}
|
|
assert.Error(t, c3.Validate())
|
|
|
|
// 正确配置
|
|
c4 := &model.PushChannel{Name: "custom_channel", URL: "https://hook.com", Other: "{\"content\":\"$content\"}"}
|
|
assert.NoError(t, c4.Validate())
|
|
|
|
// 飞书渠道校验:非 HTTPS 地址报错
|
|
c5 := &model.PushChannel{Name: "lark_channel", Type: "lark", URL: "http://open.feishu.cn", Other: ""}
|
|
assert.Error(t, c5.Validate())
|
|
|
|
// 飞书正确配置
|
|
c6 := &model.PushChannel{Name: "lark_channel", Type: "lark", URL: "https://open.feishu.cn", Other: ""}
|
|
assert.NoError(t, c6.Validate())
|
|
|
|
// Telegram 渠道校验
|
|
cTelegramErr := &model.PushChannel{Name: "tg_channel", Type: "telegram", URL: "https://api.telegram.org", Token: "", Other: ""}
|
|
assert.Error(t, cTelegramErr.Validate())
|
|
|
|
cTelegramErr2 := &model.PushChannel{Name: "tg_channel", Type: "telegram", URL: "http://api.telegram.org", Token: "123:abc", Other: ""}
|
|
assert.Error(t, cTelegramErr2.Validate())
|
|
|
|
cTelegramOk := &model.PushChannel{Name: "tg_channel", Type: "telegram", URL: "", Token: "123:abc", Other: "-100123"}
|
|
assert.NoError(t, cTelegramOk.Validate())
|
|
assert.Equal(t, "https://api.telegram.org", cTelegramOk.URL)
|
|
|
|
// 邮件配置校验:允许空配置以复用系统全局设置
|
|
c7 := &model.PushChannel{Name: "email_channel", Type: "email", URL: "", Token: "", Other: ""}
|
|
assert.NoError(t, c7.Validate())
|
|
|
|
// 邮件正确配置 (非 HTTPS 协议 URL 允许)
|
|
c8 := &model.PushChannel{Name: "email_channel", Type: "email", URL: "smtp.exmail.qq.com:465", Token: "user@example.com", Other: "authcode"}
|
|
assert.NoError(t, c8.Validate())
|
|
})
|
|
|
|
// 2. HTTP CRUD & 触发鉴权测试
|
|
dbConn, _, cleanup := setupPushTest(t)
|
|
defer cleanup()
|
|
|
|
// 构建路由以进行 HTTP 模拟请求
|
|
r := testhelper.NewTestGinEngine()
|
|
adminGroup := r.Group("/api/v1/admin")
|
|
{
|
|
adminGroup.GET("/push/channels", ListChannels)
|
|
adminGroup.POST("/push/channels", CreateChannel)
|
|
adminGroup.PUT("/push/channels/:id", UpdateChannel)
|
|
adminGroup.DELETE("/push/channels/:id", DeleteChannel)
|
|
adminGroup.POST("/push/channels/test", TestChannel)
|
|
}
|
|
|
|
var createdID uint64
|
|
|
|
t.Run("admin create channel", func(t *testing.T) {
|
|
reqBody := CreateChannelRequest{
|
|
Name: "my_custom_channel",
|
|
Description: "My custom channel webhook",
|
|
Type: "custom",
|
|
Token: "my_chan_token",
|
|
URL: "https://webhook.site/test",
|
|
Other: `{"title": "$title", "body": "$content"}`,
|
|
Enabled: true,
|
|
}
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req, _ := http.NewRequest("POST", "/api/v1/admin/push/channels", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp response.Any
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
|
|
dataMap, ok := resp.Data.(map[string]any)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "my_custom_channel", dataMap["name"])
|
|
createdID = uint64(dataMap["id"].(float64))
|
|
})
|
|
|
|
t.Run("admin list channels", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", "/api/v1/admin/push/channels", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp response.Any
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
list, ok := resp.Data.([]any)
|
|
assert.True(t, ok)
|
|
assert.Len(t, list, 1)
|
|
})
|
|
|
|
t.Run("admin update channel", func(t *testing.T) {
|
|
updateReq := UpdateChannelRequest{
|
|
Description: "Updated remark",
|
|
Type: "custom",
|
|
Token: "new_chan_token",
|
|
URL: "https://webhook.site/updated",
|
|
Other: `{"text": "$content"}`,
|
|
Enabled: true,
|
|
}
|
|
bodyBytes, _ := json.Marshal(updateReq)
|
|
req, _ := http.NewRequest("PUT", "/api/v1/admin/push/channels/"+strconv.FormatUint(createdID, 10), bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var updated model.PushChannel
|
|
dbConn.First(&updated, createdID)
|
|
assert.Equal(t, "Updated remark", updated.Description)
|
|
assert.Equal(t, "new_chan_token", updated.Token)
|
|
assert.Equal(t, `{"text": "$content"}`, updated.Other)
|
|
})
|
|
|
|
t.Run("admin test channel endpoint", func(t *testing.T) {
|
|
testReq := TestChannelRequest{
|
|
Name: "my_custom_channel",
|
|
Target: "test_target",
|
|
}
|
|
bodyBytes, _ := json.Marshal(testReq)
|
|
req, _ := http.NewRequest("POST", "/api/v1/admin/push/channels/test", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
})
|
|
|
|
t.Run("admin delete channel", func(t *testing.T) {
|
|
req, _ := http.NewRequest("DELETE", "/api/v1/admin/push/channels/"+strconv.FormatUint(createdID, 10), nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var count int64
|
|
dbConn.Model(&model.PushChannel{}).Where("id = ?", createdID).Count(&count)
|
|
assert.Equal(t, int64(0), count)
|
|
})
|
|
}
|