You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user