Files
2026-06-22 08:55:57 +08:00

178 lines
5.8 KiB
Go

package notify
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
// reverseDecryptor 是测试用 Decryptor:把密文逐字节取反作为 "解密",与
// reverseEncrypt 配对,确保 IMNotifier 走解密路径而非裸读明文。
type reverseDecryptor struct{ err error }
func reverseBytes(b []byte) []byte {
out := make([]byte, len(b))
for i, c := range b {
out[i] = ^c
}
return out
}
func (d reverseDecryptor) Decrypt(ct []byte) ([]byte, error) {
if d.err != nil {
return nil, d.err
}
return reverseBytes(ct), nil
}
// captureDoer 记录最后一次 HTTP 请求并返回固定响应码。
type captureDoer struct {
mu sync.Mutex
calls int
url string
body []byte
ctype string
status int
err error
}
func (c *captureDoer) Do(req *http.Request) (*http.Response, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.err != nil {
return nil, c.err
}
c.calls++
c.url = req.URL.String()
c.ctype = req.Header.Get("Content-Type")
if req.Body != nil {
c.body, _ = io.ReadAll(req.Body)
}
st := c.status
if st == 0 {
st = http.StatusOK
}
return &http.Response{StatusCode: st, Body: io.NopCloser(strings.NewReader("ok"))}, nil
}
func imPrefs(uid uuid.UUID, enabled bool, sev Severity, webhookEnc []byte) Prefs {
return Prefs{UserID: uid, IMEnabled: enabled, MinSeverity: sev, ImWebhookURLEnc: webhookEnc}
}
func TestIMNotifier_PostsDecryptedWebhookWithSlackPayload(t *testing.T) {
uid := uuid.New()
enc := reverseBytes([]byte("https://hooks.slack.test/abc"))
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityWarning, enc))
doer := &captureDoer{}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
err := n.Send(context.Background(), Message{
UserID: uid, Topic: "acp.permission_pending", Severity: SeverityWarning,
Title: "Approval needed", Body: "run rm", Link: "/approvals",
})
require.NoError(t, err)
require.Equal(t, 1, doer.calls)
require.Equal(t, "https://hooks.slack.test/abc", doer.url, "应 POST 到解密后的 URL")
require.Equal(t, "application/json", doer.ctype)
var payload map[string]any
require.NoError(t, json.Unmarshal(doer.body, &payload))
require.Equal(t, "acp.permission_pending", payload["topic"])
require.Equal(t, "warning", payload["severity"])
require.Equal(t, "Approval needed", payload["title"])
text, _ := payload["text"].(string)
require.Contains(t, text, "[WARNING] Approval needed")
require.Contains(t, text, "run rm")
require.Contains(t, text, "/approvals")
}
func TestIMNotifier_DisabledChannelSkipped(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, false, SeverityInfo, reverseBytes([]byte("https://x"))))
doer := &captureDoer{}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 0, doer.calls)
}
func TestIMNotifier_NoWebhookSkipped(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityInfo, nil))
doer := &captureDoer{}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 0, doer.calls, "未配置 webhook 应跳过")
}
func TestIMNotifier_BelowMinSeveritySkipped(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityError, reverseBytes([]byte("https://x"))))
doer := &captureDoer{}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityWarning}))
require.Equal(t, 0, doer.calls)
}
func TestIMNotifier_GloballyDisabledSkipped(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x"))))
doer := &captureDoer{}
n := NewIMNotifier(IMConfig{Enabled: false}, prefs, reverseDecryptor{}, doer, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 0, doer.calls)
}
func TestIMNotifier_DecryptErrorReturnsErr(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityInfo, []byte("garbage")))
doer := &captureDoer{}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{err: errors.New("bad key")}, doer, newTestLogger())
require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 0, doer.calls)
}
func TestIMNotifier_Non2xxReturnsErr(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x"))))
doer := &captureDoer{status: http.StatusInternalServerError}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 1, doer.calls)
}
func TestIMNotifier_HTTPErrorReturnsErr(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x"))))
doer := &captureDoer{err: errors.New("dial fail")}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
}
func TestIMNotifier_NameIsIMChannel(t *testing.T) {
n := NewIMNotifier(IMConfig{Enabled: true}, newFakePrefs(), reverseDecryptor{}, &captureDoer{}, newTestLogger())
require.Equal(t, ChannelIM, n.Name())
}