Files
ai-customer-service/internal/http/handlers/webhook_security_test.go
Your Name cf46b27610 fix: P0-1 RateLimiter并发写安全 + P0-2工单操作错误码区分 + P1 rows.Close修复
P0-1 (limits.go): Allow()方法改为全程使用写锁保护counters map读写,避免RLock写入时的data race
P0-2 (ticket_workflow.go+ticket_handler.go): Assign/Resolve/Close操作先查询ticket存在性和状态,返回明确的CS_TICKET_4001/CS_TKT_4002/CS_TICKET_4092/CS_TICKET_4093错误码,handler根据错误前缀路由HTTP状态码
P1-1 (ticket_store.go): 移除GetStats中3处手动rows.Close(),只保留defer Close()
2026-05-01 20:56:25 +08:00

216 lines
8.5 KiB
Go

package handlers
import (
"bytes"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
)
// TestWebhookSecurity_InvalidTimestampFormat covers CS_AUTH_4032:
// strconv.ParseInt fails on non-numeric timestamp → 403.
func TestWebhookSecurity_InvalidTimestampFormat(t *testing.T) {
auditRecorder := &stubAuditRecorder{}
secured := WebhookSecurity{Secret: "secret", TimestampHeader: "X-CS-Timestamp", SignatureHeader: "X-CS-Signature", MaxSkew: 5 * time.Minute, Audit: auditRecorder}
handler := secured.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{}`))
req.Header.Set("X-CS-Timestamp", "not-a-number")
req.Header.Set("X-CS-Signature", "abc123")
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (invalid timestamp format)", resp.Code)
}
if len(auditRecorder.events) != 1 {
t.Fatalf("audit count = %d, want 1", len(auditRecorder.events))
}
if auditRecorder.events[0].Type != "webhook_security_rejected" {
t.Fatalf("audit type = %s", auditRecorder.events[0].Type)
}
}
// TestWebhookSecurity_TimestampSkewTooLarge covers CS_AUTH_4033:
// timestamp is too old or too far in the future → 403.
func TestWebhookSecurity_TimestampSkewTooLarge(t *testing.T) {
auditRecorder := &stubAuditRecorder{}
secured := WebhookSecurity{Secret: "secret", TimestampHeader: "X-CS-Timestamp", SignatureHeader: "X-CS-Signature", MaxSkew: 5 * time.Minute, Audit: auditRecorder}
handler := secured.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
// Timestamp 10 minutes ago → skew > 5 min MaxSkew
oldTimestamp := time.Now().Add(-10 * time.Minute).Unix()
body := []byte(`{}`)
timestampStr := formatUnix(oldTimestamp)
signature := signBody("secret", timestampStr, body)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("X-CS-Timestamp", timestampStr)
req.Header.Set("X-CS-Signature", signature)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (timestamp skew too large)", resp.Code)
}
}
// TestWebhookSecurity_BodyReadError documents CS_REQ_4004 coverage gap:
// io.ReadAll error is not reachable in unit tests (httptest always provides a valid body reader).
// This test validates the handler does NOT panic on empty body with valid signature.
func TestWebhookSecurity_EmptyBodyWithValidSignature(t *testing.T) {
secured := WebhookSecurity{Secret: "secret", TimestampHeader: "X-CS-Timestamp", SignatureHeader: "X-CS-Signature", MaxSkew: 5 * time.Minute}
handler := secured.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
body := []byte(`{}`)
timestampStr := formatUnix(time.Now().Unix())
signature := signBody("secret", timestampStr, body)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("X-CS-Timestamp", timestampStr)
req.Header.Set("X-CS-Signature", signature)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
// Empty body {} with valid HMAC passes all security checks
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (valid signature on empty body)", resp.Code)
}
}
// TestWebhookSecurity_InvalidSignature covers CS_AUTH_4034:
// HMAC signature mismatch → 403.
func TestWebhookSecurity_InvalidSignature(t *testing.T) {
auditRecorder := &stubAuditRecorder{}
secured := WebhookSecurity{Secret: "secret", TimestampHeader: "X-CS-Timestamp", SignatureHeader: "X-CS-Signature", MaxSkew: 5 * time.Minute, Audit: auditRecorder}
handler := secured.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
body := []byte(`{"ok":true}`)
timestampStr := formatUnix(time.Now().Unix())
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("X-CS-Timestamp", timestampStr)
req.Header.Set("X-CS-Signature", "wrong-signature")
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (invalid signature)", resp.Code)
}
if len(auditRecorder.events) != 1 {
t.Fatalf("audit count = %d, want 1", len(auditRecorder.events))
}
if auditRecorder.events[0].Type != "webhook_security_rejected" {
t.Fatalf("audit type = %s", auditRecorder.events[0].Type)
}
}
// TestWebhookSecurity_EmptyTimestampAndSignature covers CS_AUTH_4031:
// both timestamp and signature missing → 403.
func TestWebhookSecurity_EmptyTimestampAndSignature(t *testing.T) {
auditRecorder := &stubAuditRecorder{}
secured := WebhookSecurity{Secret: "secret", TimestampHeader: "X-CS-Timestamp", SignatureHeader: "X-CS-Signature", MaxSkew: 5 * time.Minute, Audit: auditRecorder}
handler := secured.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{}`))
// Neither header set
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (missing timestamp+signature)", resp.Code)
}
if len(auditRecorder.events) != 1 {
t.Fatalf("audit count = %d, want 1", len(auditRecorder.events))
}
}
// TestWebhookSecurity_EmptySignatureOnly covers CS_AUTH_4031:
// signature missing but timestamp present → 403.
func TestWebhookSecurity_EmptySignatureOnly(t *testing.T) {
secured := WebhookSecurity{Secret: "secret", TimestampHeader: "X-CS-Timestamp", SignatureHeader: "X-CS-Signature", MaxSkew: 5 * time.Minute}
handler := secured.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{}`))
req.Header.Set("X-CS-Timestamp", formatUnix(time.Now().Unix()))
// signature header missing
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (signature missing)", resp.Code)
}
}
// TestWebhookSecurity_EmptyTimestampOnly covers CS_AUTH_4031:
// timestamp missing but signature present → 403.
func TestWebhookSecurity_EmptyTimestampOnly(t *testing.T) {
secured := WebhookSecurity{Secret: "secret", TimestampHeader: "X-CS-Timestamp", SignatureHeader: "X-CS-Signature", MaxSkew: 5 * time.Minute}
handler := secured.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{}`))
req.Header.Set("X-CS-Signature", "some-signature")
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (timestamp missing)", resp.Code)
}
}
// TestWebhookSecurity_NonPostMethod bypasses security check for non-POST methods.
func TestWebhookSecurity_NonPostMethod(t *testing.T) {
secured := WebhookSecurity{Secret: "secret", MaxSkew: 5 * time.Minute}
handler := secured.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("expected GET passthrough, got %s", r.Method)
}
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (non-POST passthrough)", resp.Code)
}
}
// TestWebhookSecurity_DisabledWhenNoSecret verifies security middleware is
// a no-op when Secret is not configured.
func TestWebhookSecurity_DisabledWhenNoSecret(t *testing.T) {
hit := false
handler := WebhookSecurity{}.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hit = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{}`))
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if !hit {
t.Fatalf("wrapped handler was not called when secret is empty")
}
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (security disabled)", resp.Code)
}
}
// --- helpers ---
func formatUnix(unix int64) string {
return strconv.FormatInt(unix, 10)
}
func signBody(secret, timestamp string, body []byte) string {
return computeWebhookSignature(secret, timestamp, body)
}
// stubAuditRecorder is defined in webhook_handler_test.go and reused here.
// This file is in the same package so it can access stubAuditRecorder directly.