发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderWebhookBody(t *testing.T) {
|
||||
msg := notifyMsg{Title: "任务失败", Text: "line1\nline\"2\""}
|
||||
tests := []struct {
|
||||
name string
|
||||
tpl string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "空模板用缺省 Slack 形状",
|
||||
tpl: "",
|
||||
want: `{"text": "任务失败\nline1\nline\"2\""}`,
|
||||
},
|
||||
{
|
||||
name: "自定义模板占位替换并转义",
|
||||
tpl: `{"msgtype":"text","text":{"content":"{{text}}"}}`,
|
||||
want: `{"msgtype":"text","text":{"content":"line1\nline\"2\""}}`,
|
||||
},
|
||||
{
|
||||
name: "无占位原样输出",
|
||||
tpl: `{"fixed":1}`,
|
||||
want: `{"fixed":1}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := renderWebhookBody(tt.tpl, msg)
|
||||
if got != tt.want {
|
||||
t.Errorf("renderWebhookBody = %s, want %s", got, tt.want)
|
||||
}
|
||||
if !json.Valid([]byte(got)) {
|
||||
t.Errorf("渲染结果不是合法 JSON: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// jsonCapture 收集一次 HTTP 请求的路径、认证头与 JSON body。
|
||||
type jsonCapture struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
auth string
|
||||
fields map[string]string
|
||||
}
|
||||
|
||||
// newCaptureServer 起假渠道服务端,固定返回 status。
|
||||
func newCaptureServer(t *testing.T, status int) (*httptest.Server, *jsonCapture) {
|
||||
t.Helper()
|
||||
rec := &jsonCapture{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
rec.path = r.URL.Path
|
||||
rec.auth = r.Header.Get("Authorization")
|
||||
rec.fields = map[string]string{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&rec.fields)
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, rec
|
||||
}
|
||||
|
||||
func TestSendNtfy(t *testing.T) {
|
||||
srv, rec := newCaptureServer(t, http.StatusOK)
|
||||
cfg := NtfyChannel{Server: srv.URL, Topic: "oci", Token: "tk-1"}
|
||||
msg := notifyMsg{Title: "标题", Text: "正文"}
|
||||
if err := sendNtfy(context.Background(), srv.Client(), cfg, msg); err != nil {
|
||||
t.Fatalf("sendNtfy: %v", err)
|
||||
}
|
||||
if rec.auth != "Bearer tk-1" {
|
||||
t.Errorf("Authorization = %q, want Bearer tk-1", rec.auth)
|
||||
}
|
||||
want := map[string]string{"topic": "oci", "title": "标题", "message": "正文"}
|
||||
for k, v := range want {
|
||||
if rec.fields[k] != v {
|
||||
t.Errorf("body[%s] = %q, want %q", k, rec.fields[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendBark(t *testing.T) {
|
||||
srv, rec := newCaptureServer(t, http.StatusOK)
|
||||
cfg := BarkChannel{Server: srv.URL, DeviceKey: "dk-1"}
|
||||
if err := sendBark(context.Background(), srv.Client(), cfg, notifyMsg{Title: "标题", Text: "正文"}); err != nil {
|
||||
t.Fatalf("sendBark: %v", err)
|
||||
}
|
||||
if rec.path != "/push" {
|
||||
t.Errorf("path = %q, want /push", rec.path)
|
||||
}
|
||||
want := map[string]string{"title": "标题", "body": "正文", "device_key": "dk-1"}
|
||||
for k, v := range want {
|
||||
if rec.fields[k] != v {
|
||||
t.Errorf("body[%s] = %q, want %q", k, rec.fields[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJSONNon2xx(t *testing.T) {
|
||||
srv, _ := newCaptureServer(t, http.StatusBadGateway)
|
||||
err := postJSON(context.Background(), srv.Client(), srv.URL, "", []byte(`{}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "502") {
|
||||
t.Fatalf("postJSON err = %v, want contains 502", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidNotifyURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
raw string
|
||||
wantErr bool
|
||||
}{
|
||||
{"https://example.com/hook", false},
|
||||
{"http://10.0.0.1:8080/x", false},
|
||||
{"ftp://example.com", true},
|
||||
{"not-a-url", true},
|
||||
{"", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if err := validNotifyURL(tt.raw); (err != nil) != tt.wantErr {
|
||||
t.Errorf("validNotifyURL(%q) err = %v, wantErr %v", tt.raw, err, tt.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeSMTP 起一个最小 SMTP 服务端,按标准会话应答并收集 DATA 报文。
|
||||
func fakeSMTP(t *testing.T) (addr string, got *strings.Builder) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
got = &strings.Builder{}
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
fmt.Fprint(conn, "220 fake ESMTP\r\n")
|
||||
r := bufio.NewReader(conn)
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.ToUpper(strings.TrimSpace(line))
|
||||
switch {
|
||||
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
|
||||
fmt.Fprint(conn, "250-fake\r\n250 AUTH PLAIN\r\n")
|
||||
case strings.HasPrefix(cmd, "AUTH"):
|
||||
fmt.Fprint(conn, "235 ok\r\n")
|
||||
case strings.HasPrefix(cmd, "MAIL"), strings.HasPrefix(cmd, "RCPT"):
|
||||
fmt.Fprint(conn, "250 ok\r\n")
|
||||
case cmd == "DATA":
|
||||
fmt.Fprint(conn, "354 go\r\n")
|
||||
readSMTPData(r, got)
|
||||
fmt.Fprint(conn, "250 ok\r\n")
|
||||
case cmd == "QUIT":
|
||||
fmt.Fprint(conn, "221 bye\r\n")
|
||||
return
|
||||
default:
|
||||
fmt.Fprint(conn, "250 ok\r\n")
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String(), got
|
||||
}
|
||||
|
||||
// readSMTPData 读取 DATA 段直到单独一行 "."。
|
||||
func readSMTPData(r *bufio.Reader, got *strings.Builder) {
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil || strings.TrimRight(line, "\r\n") == "." {
|
||||
return
|
||||
}
|
||||
got.WriteString(line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSMTP(t *testing.T) {
|
||||
addr, got := fakeSMTP(t)
|
||||
host, portStr, _ := net.SplitHostPort(addr)
|
||||
var port int
|
||||
fmt.Sscanf(portStr, "%d", &port)
|
||||
cfg := SMTPChannel{
|
||||
Host: host, Port: port,
|
||||
Username: "u", Password: "p",
|
||||
From: "a@x.com", To: "b@y.com",
|
||||
}
|
||||
err := sendSMTP(context.Background(), cfg, notifyMsg{Title: "任务失败", Text: "第一行\n第二行"})
|
||||
if err != nil {
|
||||
t.Fatalf("sendSMTP: %v", err)
|
||||
}
|
||||
mail := got.String()
|
||||
for _, want := range []string{"From: a@x.com", "To: b@y.com", "Subject: =?utf-8?q?", "第一行\r\n第二行"} {
|
||||
if !strings.Contains(mail, want) {
|
||||
t.Errorf("mail 缺少 %q:\n%s", want, mail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchFanout 验证多渠道并发投递:一个渠道失败不影响其他渠道送达。
|
||||
func TestDispatchFanout(t *testing.T) {
|
||||
okSrv, okRec := newCaptureServer(t, http.StatusOK)
|
||||
badSrv, badRec := newCaptureServer(t, http.StatusInternalServerError)
|
||||
settings, _ := newSettingEnv(t)
|
||||
ctx := context.Background()
|
||||
mustUpdateChannel(t, settings, ChannelWebhook, UpdateNotifyChannelInput{Enabled: true, URL: badSrv.URL})
|
||||
mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Enabled: true, Server: okSrv.URL, Topic: "oci"})
|
||||
n := NewNotifier(settings)
|
||||
|
||||
err := n.dispatch(ctx, notifyMsg{Title: "T", Text: "body"})
|
||||
if err == nil || !strings.Contains(err.Error(), "webhook") {
|
||||
t.Fatalf("dispatch err = %v, want webhook 失败", err)
|
||||
}
|
||||
if badRec.fields == nil {
|
||||
t.Error("webhook 渠道未收到请求")
|
||||
}
|
||||
if okRec.fields["message"] != "body" {
|
||||
t.Errorf("ntfy 渠道未正常送达: %v", okRec.fields)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchSkipsDisabled 验证未启用/未配置的渠道静默跳过且不报错。
|
||||
func TestDispatchSkipsDisabled(t *testing.T) {
|
||||
srv, rec := newCaptureServer(t, http.StatusOK)
|
||||
settings, _ := newSettingEnv(t)
|
||||
mustUpdateChannel(t, settings, ChannelWebhook, UpdateNotifyChannelInput{Enabled: false, URL: srv.URL})
|
||||
n := NewNotifier(settings)
|
||||
if err := n.dispatch(context.Background(), notifyMsg{Title: "T", Text: "x"}); err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if rec.fields != nil {
|
||||
t.Error("未启用渠道不应收到请求")
|
||||
}
|
||||
}
|
||||
|
||||
// mustUpdateChannel 保存渠道配置,失败即终止测试。
|
||||
func mustUpdateChannel(t *testing.T, s *SettingService, chType string, in UpdateNotifyChannelInput) {
|
||||
t.Helper()
|
||||
if err := s.UpdateNotifyChannel(context.Background(), chType, in); err != nil {
|
||||
t.Fatalf("update channel %s: %v", chType, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNotifyChannelValidation(t *testing.T) {
|
||||
secret := "sec-123456"
|
||||
tests := []struct {
|
||||
name string
|
||||
chType string
|
||||
in UpdateNotifyChannelInput
|
||||
wantErr string
|
||||
}{
|
||||
{name: "未知类型", chType: "pigeon", wantErr: "未知通知渠道"},
|
||||
{name: "启用 webhook 缺 URL", chType: ChannelWebhook, in: UpdateNotifyChannelInput{Enabled: true}, wantErr: "URL"},
|
||||
{name: "webhook 非法协议", chType: ChannelWebhook, in: UpdateNotifyChannelInput{Enabled: true, URL: "ftp://x"}, wantErr: "http(s)"},
|
||||
{name: "启用 ntfy 缺 topic", chType: ChannelNtfy, in: UpdateNotifyChannelInput{Enabled: true}, wantErr: "topic"},
|
||||
{name: "启用 smtp 缺主机", chType: ChannelSMTP, in: UpdateNotifyChannelInput{Enabled: true, Port: 587}, wantErr: "SMTP"},
|
||||
{name: "smtp 端口越界", chType: ChannelSMTP, in: UpdateNotifyChannelInput{Enabled: true, Host: "h", Port: 70000, From: "a@x", To: "b@y"}, wantErr: "端口"},
|
||||
{name: "未启用可存草稿", chType: ChannelNtfy, in: UpdateNotifyChannelInput{Enabled: false}},
|
||||
{name: "配置齐全可启用", chType: ChannelBark, in: UpdateNotifyChannelInput{Enabled: true, Secret: &secret}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
settings, _ := newSettingEnv(t)
|
||||
err := settings.UpdateNotifyChannel(context.Background(), tt.chType, tt.in)
|
||||
if tt.wantErr == "" && err != nil {
|
||||
t.Fatalf("UpdateNotifyChannel: %v", err)
|
||||
}
|
||||
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyChannelSecretLifecycle 验证密文字段的存取语义:
|
||||
// 保存后视图只回 set/tail,nil 沿用旧值,空串清除。
|
||||
func TestNotifyChannelSecretLifecycle(t *testing.T) {
|
||||
settings, _ := newSettingEnv(t)
|
||||
ctx := context.Background()
|
||||
secret := "ntfy-token-9876"
|
||||
mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Topic: "t", Secret: &secret})
|
||||
|
||||
view := mustChannelView(t, settings, ChannelNtfy)
|
||||
if !view.SecretSet || view.SecretTail != "9876" {
|
||||
t.Fatalf("view = %+v, want SecretSet 且尾号 9876", view)
|
||||
}
|
||||
cfg, err := settings.ntfyChannel(ctx)
|
||||
if err != nil || cfg.Token != secret {
|
||||
t.Fatalf("ntfyChannel token = %q err = %v, want 原文", cfg.Token, err)
|
||||
}
|
||||
|
||||
// nil 沿用
|
||||
mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Topic: "t2"})
|
||||
if cfg, _ := settings.ntfyChannel(ctx); cfg.Token != secret || cfg.Topic != "t2" {
|
||||
t.Fatalf("nil Secret 应沿用旧 token,got %+v", cfg)
|
||||
}
|
||||
|
||||
// 空串清除
|
||||
empty := ""
|
||||
mustUpdateChannel(t, settings, ChannelNtfy, UpdateNotifyChannelInput{Topic: "t2", Secret: &empty})
|
||||
if cfg, _ := settings.ntfyChannel(ctx); cfg.Token != "" {
|
||||
t.Fatalf("空串 Secret 应清除,got %q", cfg.Token)
|
||||
}
|
||||
if view := mustChannelView(t, settings, ChannelNtfy); view.SecretSet {
|
||||
t.Fatal("清除后 SecretSet 应为 false")
|
||||
}
|
||||
}
|
||||
|
||||
// mustChannelView 取单渠道脱敏视图。
|
||||
func mustChannelView(t *testing.T, s *SettingService, chType string) NotifyChannelView {
|
||||
t.Helper()
|
||||
views, err := s.NotifyChannels(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("NotifyChannels: %v", err)
|
||||
}
|
||||
for _, v := range views {
|
||||
if v.Type == chType {
|
||||
return v
|
||||
}
|
||||
}
|
||||
t.Fatalf("view %s not found", chType)
|
||||
return NotifyChannelView{}
|
||||
}
|
||||
Reference in New Issue
Block a user