340 lines
11 KiB
Go
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{}
|
|
}
|