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
286 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|