Files
mrlee 862442e77f
CI / verify (push) Canceled after 0s
feat: 配置版本历史/对比/回滚、国内 IM 通知渠道、阈值告警与 API Token(v1.2.9)
- 配置版本:fsutil 版本化备份(内容相同自动去重),主 nginx.conf 与 VPS vhost 的版本列表/逐行差异/一键回滚,回滚先校验语法与 nginx -t 再写回

- agent 0.12.2:写 vhost 前留版本;nginx -t 校验失败改为恢复上一个可用版本(此前直接删文件),新增域名 vhost 版本端点,双架构二进制已重编

- 通知:补企业微信 / 钉钉 / 飞书机器人,切换预设时服务地址跟随该预设默认值

- 阈值告警:新增 internal/monitor 巡检(CPU/内存/磁盘持续超阈值、隧道连续 N 次未注册),默认关闭,带静默期与恢复通知

- API Token:api_tokens 表 + Authorization Bearer 认证,只读/读写两种范围;令牌管理与改密/退出仅限登录会话
2026-09-24 20:50:17 +08:00

164 lines
4.9 KiB
Go

package notify
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSendCustomWebhook(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
body = string(raw)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := RuntimeConfig{
Config: Config{
Type: NotifyTypeWebhook,
Webhook: WebhookConfig{
Provider: WebhookCustom,
URL: server.URL,
},
},
}
content := FormatAlert("test", "title", "message")
if err := SendWebhook(context.Background(), server.Client(), cfg, content); err != nil {
t.Fatalf("send custom webhook: %v", err)
}
if !strings.Contains(body, `"title":"title"`) || !strings.Contains(body, `"message":"message"`) {
t.Fatalf("unexpected body: %s", body)
}
if !strings.Contains(body, `"formatted":`) {
t.Fatalf("missing formatted body: %s", body)
}
}
func TestSendNtfyUsesTopic(t *testing.T) {
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := RuntimeConfig{
Config: Config{
Type: NotifyTypeWebhook,
Webhook: WebhookConfig{
Provider: WebhookNtfy,
Server: server.URL,
Topic: "alerts",
},
},
}
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "title", "message")); err != nil {
t.Fatalf("send ntfy: %v", err)
}
if gotPath != "/alerts" {
t.Fatalf("expected /alerts got %s", gotPath)
}
}
func TestJoinURL(t *testing.T) {
got := joinURL("https://ntfy.sh", "topic")
if got != "https://ntfy.sh/topic" {
t.Fatalf("unexpected url: %s", got)
}
}
// 国内 IM 机器人:固定路径 + 固定 payload 形状,这里把三家的端点与请求体锁住
func TestSendWecomUsesKeyAndMarkdown(t *testing.T) {
var path, query, body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
query = r.URL.RawQuery
raw, _ := io.ReadAll(r.Body)
body = string(raw)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := RuntimeConfig{Config: Config{
Type: NotifyTypeWebhook,
Webhook: WebhookConfig{Provider: WebhookWecom, Server: server.URL, Key: "abc"},
}}
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
t.Fatalf("send wecom: %v", err)
}
if path != "/cgi-bin/webhook/send" || query != "key=abc" {
t.Fatalf("unexpected endpoint: %s?%s", path, query)
}
if !strings.Contains(body, `"msgtype":"markdown"`) || !strings.Contains(body, "标题") {
t.Fatalf("unexpected body: %s", body)
}
}
func TestSendDingtalkUsesAccessTokenAndKeyword(t *testing.T) {
var path, query, body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
query = r.URL.RawQuery
raw, _ := io.ReadAll(r.Body)
body = string(raw)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := RuntimeConfig{Config: Config{
Type: NotifyTypeWebhook,
Webhook: WebhookConfig{Provider: WebhookDingtalk, Server: server.URL, Key: "tok"},
}}
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
t.Fatalf("send dingtalk: %v", err)
}
if path != "/robot/send" || query != "access_token=tok" {
t.Fatalf("unexpected endpoint: %s?%s", path, query)
}
// 「自定义关键词」安全设置靠正文里的 Fonu 命中(PushTitle 前面还有事件图标)
if !strings.Contains(body, "Fonu · ") || !strings.Contains(body, "标题") {
t.Fatalf("missing keyword prefix: %s", body)
}
}
func TestSendFeishuUsesHookPath(t *testing.T) {
var path, body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
raw, _ := io.ReadAll(r.Body)
body = string(raw)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := RuntimeConfig{Config: Config{
Type: NotifyTypeWebhook,
Webhook: WebhookConfig{Provider: WebhookFeishu, Server: server.URL, Key: "hookkey"},
}}
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
t.Fatalf("send feishu: %v", err)
}
if path != "/open-apis/bot/v2/hook/hookkey" {
t.Fatalf("unexpected path: %s", path)
}
if !strings.Contains(body, `"msg_type":"text"`) {
t.Fatalf("unexpected body: %s", body)
}
}
func TestProviderUsesKeyCoversKeyBasedPresets(t *testing.T) {
for _, provider := range []WebhookProvider{WebhookBark, WebhookNtfy, WebhookGotify, WebhookWecom, WebhookDingtalk, WebhookFeishu} {
if !providerUsesKey(provider) {
t.Fatalf("%s 的凭据存在 Webhook.Key,密钥落库路径必须覆盖它", provider)
}
}
if providerUsesKey(WebhookCustom) {
t.Fatal("自定义 Webhook 的凭据来自 URL,不应走 Key 分支")
}
}