Files
ai-customer-service/test/e2e/security_e2e_test.go
Your Name 142b991334 fix(config+app): production fail-fast + readiness收紧
1. config.go: AI_CS_ENV runtime mode with production restriction
   - New RuntimeConfig.Env field (AI_CS_ENV / AI_CS_RUNTIME_ENV)
   - production + Postgres.Enabled=false → Load() returns error
   - production + empty webhook secret → Load() returns error
   - normalizeRuntimeEnv: dev/dev/ → development, prod/production → production, test → test

2. app.go: probe.SetReady only when store is confirmed ready
   - Postgres.Enabled: probe.SetReady(true) after DB+migration OK
   - Memory mode: probe.SetReady(false) — not production-ready

3. health_handler_test.go: add probe live+ready state transition tests

4. config_test.go: add TestLoad_RejectsProdWhenPostgresDisabled,
   TestLoad_RejectsProdWhenWebhookSecretMissing

5. app_test.go: add TestNew_RejectsMemoryModeWithoutExplicitNonProdEnv,
   TestNew_AllowsMemoryModeInTestEnv, TestNew_WithPostgresEnabled_*
   for invalid DSN and migration-failure paths

Phase 1 (code gate) objectives met:
 prod cannot fall back to memory store
 readiness reflects actual store readiness
 both changes have test coverage
2026-05-04 07:38:10 +08:00

286 lines
9.3 KiB
Go

package e2e
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/bridge/ai-customer-service/internal/app"
"github.com/bridge/ai-customer-service/internal/config"
"github.com/bridge/ai-customer-service/internal/http/handlers"
"github.com/bridge/ai-customer-service/internal/platform/logging"
)
func newTestAppWithSecret(t *testing.T) *app.App {
t.Helper()
cfg := &config.Config{}
cfg.HTTP.Addr = ":0"
cfg.HTTP.ReadHeaderTimeout = 5
cfg.HTTP.ReadTimeout = 10
cfg.HTTP.WriteTimeout = 15
cfg.HTTP.IdleTimeout = 60
cfg.HTTP.MaxHeaderBytes = 1 << 20
cfg.HTTP.MaxBodyBytes = 1 << 20
cfg.Webhook.Secret = "e2e-test-secret"
cfg.Webhook.TimestampHeader = "X-CS-Timestamp"
cfg.Webhook.SignatureHeader = "X-CS-Signature"
cfg.Webhook.MaxSkewSeconds = 300
cfg.Runtime.Env = "test"
application, err := app.New(cfg, logging.New())
if err != nil {
t.Fatalf("app.New() error = %v", err)
}
return application
}
// TestSecurity_InvalidSignature verifies that a request with a wrong signature
// is rejected with 403 and error code CS_AUTH_4034.
func TestSecurity_InvalidSignature(t *testing.T) {
application := newTestAppWithSecret(t)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
body := []byte(`{"message_id":"m-sec-1","channel":"widget","open_id":"u_sec","content":"查询额度"}`)
timestamp, _, err := handlers.SignWebhookRequest("e2e-test-secret", time.Now().Unix(), body)
if err != nil {
t.Fatalf("SignWebhookRequest error = %v", err)
}
// Use a deliberately wrong signature value
wrongSig := "deadbeefcafebabe0000000000000000000000000000000000000000000000"
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/customer-service/webhook", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request error = %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CS-Timestamp", timestamp)
req.Header.Set("X-CS-Signature", wrongSig)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
bodyOut, _ := io.ReadAll(resp.Body)
var errPayload map[string]any
if err := json.Unmarshal(bodyOut, &errPayload); err != nil {
t.Fatalf("decode error response error = %v", err)
}
errObj := errPayload["error"].(map[string]any)
code := errObj["code"].(string)
if code != "CS_AUTH_4034" {
t.Fatalf("error code = %s, want CS_AUTH_4034", code)
}
}
// TestSecurity_MissingSignature verifies that a request without the signature
// header is rejected with 403 and error code CS_AUTH_4031.
func TestSecurity_MissingSignature(t *testing.T) {
application := newTestAppWithSecret(t)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
body := []byte(`{"message_id":"m-sec-2","channel":"widget","open_id":"u_sec","content":"查询额度"}`)
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/customer-service/webhook", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request error = %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CS-Timestamp", timestamp)
// Intentionally omit X-CS-Signature
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
bodyOut, _ := io.ReadAll(resp.Body)
var errPayload map[string]any
if err := json.Unmarshal(bodyOut, &errPayload); err != nil {
t.Fatalf("decode error response error = %v", err)
}
errObj := errPayload["error"].(map[string]any)
code := errObj["code"].(string)
if code != "CS_AUTH_4031" {
t.Fatalf("error code = %s, want CS_AUTH_4031", code)
}
}
// TestSecurity_ExpiredTimestamp verifies that a request with a stale timestamp
// is rejected with 403 and error code CS_AUTH_4033.
func TestSecurity_ExpiredTimestamp(t *testing.T) {
application := newTestAppWithSecret(t)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
body := []byte(`{"message_id":"m-sec-3","channel":"widget","open_id":"u_sec","content":"查询额度"}`)
// Timestamp 10 minutes in the past — beyond the 5-minute MaxSkew
staleUnix := time.Now().Add(-10 * time.Minute).Unix()
timestamp, signature, err := handlers.SignWebhookRequest("e2e-test-secret", staleUnix, body)
if err != nil {
t.Fatalf("SignWebhookRequest error = %v", err)
}
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/customer-service/webhook", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request error = %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CS-Timestamp", timestamp)
req.Header.Set("X-CS-Signature", signature)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
bodyOut, _ := io.ReadAll(resp.Body)
var errPayload map[string]any
if err := json.Unmarshal(bodyOut, &errPayload); err != nil {
t.Fatalf("decode error response error = %v", err)
}
errObj := errPayload["error"].(map[string]any)
code := errObj["code"].(string)
if code != "CS_AUTH_4033" {
t.Fatalf("error code = %s, want CS_AUTH_4033", code)
}
}
// TestSecurity_InvalidJSONBody verifies that a request with malformed JSON body
// is rejected with 400 and error code CS_REQ_4001.
func TestSecurity_InvalidJSONBody(t *testing.T) {
application := newTestAppWithSecret(t)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
// Malformed JSON — missing closing brace and invalid value
malformedBody := []byte(`{"message_id":"m-sec-4","channel":"widget","open_id":"u_sec","content":}`)
timestamp, signature, err := handlers.SignWebhookRequest("e2e-test-secret", time.Now().Unix(), malformedBody)
if err != nil {
t.Fatalf("SignWebhookRequest error = %v", err)
}
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/customer-service/webhook", bytes.NewReader(malformedBody))
if err != nil {
t.Fatalf("new request error = %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CS-Timestamp", timestamp)
req.Header.Set("X-CS-Signature", signature)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
bodyOut, _ := io.ReadAll(resp.Body)
var errPayload map[string]any
if err := json.Unmarshal(bodyOut, &errPayload); err != nil {
t.Fatalf("decode error response error = %v", err)
}
errObj := errPayload["error"].(map[string]any)
code := errObj["code"].(string)
if code != "CS_REQ_4001" {
t.Fatalf("error code = %s, want CS_REQ_4001", code)
}
}
// TestSecurity_EmptyBody verifies that a request with an empty body is rejected
// with 400.
func TestSecurity_EmptyBody(t *testing.T) {
application := newTestAppWithSecret(t)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
timestamp, signature, err := handlers.SignWebhookRequest("e2e-test-secret", time.Now().Unix(), []byte{})
if err != nil {
t.Fatalf("SignWebhookRequest error = %v", err)
}
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/customer-service/webhook", bytes.NewReader([]byte{}))
if err != nil {
t.Fatalf("new request error = %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CS-Timestamp", timestamp)
req.Header.Set("X-CS-Signature", signature)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
// TestSecurity_InvalidTimestampFormat verifies that a request with a
// non-numeric timestamp is rejected with 403 and code CS_AUTH_4032.
func TestSecurity_InvalidTimestampFormat(t *testing.T) {
application := newTestAppWithSecret(t)
server := httptest.NewServer(application.Server.Handler)
defer server.Close()
body := []byte(`{"message_id":"m-sec-5","channel":"widget","open_id":"u_sec","content":"查询额度"}`)
timestamp := "not-a-number"
signature := "somesig"
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/customer-service/webhook", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request error = %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CS-Timestamp", timestamp)
req.Header.Set("X-CS-Signature", signature)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
bodyOut, _ := io.ReadAll(resp.Body)
var errPayload map[string]any
if err := json.Unmarshal(bodyOut, &errPayload); err != nil {
t.Fatalf("decode error response error = %v", err)
}
errObj := errPayload["error"].(map[string]any)
code := errObj["code"].(string)
if code != "CS_AUTH_4032" {
t.Fatalf("error code = %s, want CS_AUTH_4032", code)
}
}