Files
Wang Defa dbba1f4905
CI / test (push) Successful in 30s
Release / release (push) Successful in 49s
发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
2026-07-10 17:38:34 +08:00

340 lines
11 KiB
Go

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{}
}