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()) }