Files
gochat/channels/shangwutong/internal/httpapi/server_test.go
T

422 lines
18 KiB
Go

package httpapi
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/gochat/gochat/channels/shangwutong/internal/account"
"github.com/gochat/gochat/channels/shangwutong/internal/gochat"
"github.com/gochat/gochat/channels/shangwutong/internal/store"
)
func TestUnknownInboxIsPersistedOnlyAfterValidBootstrapSignature(t *testing.T) {
server, database, now := newTestServer(t)
body := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
response := doWebhook(t, server, body, now, "wrong")
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("invalid signature status = %d", response.StatusCode)
}
if _, err := database.Reader().GetAccountByInboxID(context.Background(), 10); err == nil {
t.Fatal("invalid bootstrap created an account")
}
response = doWebhook(t, server, body, now, "secret")
if response.StatusCode != http.StatusAccepted {
t.Fatalf("valid signature status = %d body=%s", response.StatusCode, readBody(response))
}
if _, err := database.Reader().GetAccountByInboxID(context.Background(), 10); err != nil {
t.Fatal(err)
}
}
func TestMessageWebhookIsDurableAndIdempotent(t *testing.T) {
server, database, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{
Message: gochat.WebhookMessage{
ID: 77, MessageType: "outgoing", ContentType: "text", Content: "hello", Status: "progress",
Attachments: []gochat.WebhookAttachment{{
ID: 5, FileType: "audio", DataURL: "https://gochat.test/voice", FileSize: 100,
Extension: "amr", Metadata: map[string]any{"is_voice_message": true},
}},
},
Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
Contact: gochat.WebhookContact{ID: 99, SourceID: "visitor"},
})
first := doWebhook(t, server, message, now, "secret")
if first.StatusCode != http.StatusAccepted {
t.Fatalf("first status = %d body=%s", first.StatusCode, readBody(first))
}
var firstAck map[string]any
if err := json.NewDecoder(first.Body).Decode(&firstAck); err != nil {
t.Fatal(err)
}
_ = first.Body.Close()
if firstAck["accepted"] != true || firstAck["duplicate"] != false || firstAck["event_id"] != "message:77:created" || firstAck["delivery_id"] != "delivery" || firstAck["queue_id"] == nil {
t.Fatalf("first ACK = %#v", firstAck)
}
second := doWebhook(t, server, message, now, "secret")
if second.StatusCode != http.StatusOK {
t.Fatalf("duplicate status = %d body=%s", second.StatusCode, readBody(second))
}
var duplicateAck map[string]any
if err := json.NewDecoder(second.Body).Decode(&duplicateAck); err != nil {
t.Fatal(err)
}
_ = second.Body.Close()
if duplicateAck["duplicate"] != true || duplicateAck["queue_id"] != firstAck["queue_id"] {
t.Fatalf("duplicate ACK = %#v", duplicateAck)
}
conflicting := webhookBody(t, now, "message_created", gochat.MessageWebhookData{
Message: gochat.WebhookMessage{ID: 77, MessageType: "outgoing", ContentType: "text", Content: "changed", Status: "progress"},
Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
})
conflict := doWebhook(t, server, conflicting, now, "secret")
if body := readBody(conflict); conflict.StatusCode != http.StatusConflict || !strings.Contains(body, `"code":"idempotency_conflict"`) {
t.Fatalf("conflict status=%d body=%s", conflict.StatusCode, body)
}
queued, err := database.Writer().GetOutboundByGoChatMessageID(context.Background(), 77)
if err != nil || queued.SwtSid != "visitor" {
t.Fatalf("queued = %#v, %v", queued, err)
}
parts, err := database.Reader().ListOutboundParts(context.Background(), queued.ID)
if err != nil || len(parts) != 2 || parts[0].PartType != "text" || parts[1].PartType != "audio" || parts[1].Voice != 1 {
t.Fatalf("parts = %#v, %v", parts, err)
}
}
func TestKnownInboxRejectsBadExpiredAndUnsupportedSchemaWebhooks(t *testing.T) {
server, _, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{
Message: gochat.WebhookMessage{ID: 77, MessageType: "outgoing", ContentType: "text", Content: "hello", Status: "progress"},
Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
})
for name, response := range map[string]*http.Response{
"bad_signature": doWebhook(t, server, message, now, "wrong"),
"expired": doWebhook(t, server, message, now.Add(-6*time.Minute), "secret"),
} {
if body := readBody(response); response.StatusCode != http.StatusUnauthorized || !strings.Contains(body, `"code":"invalid_signature"`) {
t.Fatalf("%s status=%d body=%s", name, response.StatusCode, body)
}
}
var unsupported map[string]any
if err := json.Unmarshal(message, &unsupported); err != nil {
t.Fatal(err)
}
unsupported["schema_version"] = 2
unsupportedBody, _ := json.Marshal(unsupported)
response := doWebhook(t, server, unsupportedBody, now, "secret")
if body := readBody(response); response.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, `"code":"unsupported_schema_version"`) {
t.Fatalf("unsupported schema status=%d body=%s", response.StatusCode, body)
}
}
func TestMessageWebhookRejectsMoreThanResultContractAllows(t *testing.T) {
server, _, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
attachments := make([]gochat.WebhookAttachment, maxOutboundParts+1)
for index := range attachments {
attachments[index] = gochat.WebhookAttachment{
ID: int64(index + 1), FileType: "image", DataURL: "https://gochat.test/image.png",
}
}
message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{
Message: gochat.WebhookMessage{
ID: 77, MessageType: "outgoing", ContentType: "text", Status: "progress", Attachments: attachments,
},
Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
})
response := doWebhook(t, server, message, now, "secret")
body := readBody(response)
if response.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, "too_many_parts") {
t.Fatalf("status = %d body=%s", response.StatusCode, body)
}
}
func TestUnsupportedCardWebhookQueuesExplicitFailurePart(t *testing.T) {
server, database, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{
Message: gochat.WebhookMessage{ID: 77, MessageType: "outgoing", ContentType: "cards", Content: `{"title":"card"}`, Status: "progress"},
Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
})
if response := doWebhook(t, server, message, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("message status = %d body=%s", response.StatusCode, readBody(response))
}
queued, err := database.Reader().GetOutboundByGoChatMessageID(context.Background(), 77)
if err != nil {
t.Fatal(err)
}
parts, err := database.Reader().ListOutboundParts(context.Background(), queued.ID)
if err != nil || len(parts) != 1 || parts[0].PartType != "unsupported" || parts[0].Content != nil {
t.Fatalf("parts = %#v, %v", parts, err)
}
}
func TestLocationAndContactAttachmentsBecomeReadableText(t *testing.T) {
tests := []struct {
name string
contentType string
metadata map[string]any
contains []string
}{
{name: "location", contentType: "location", metadata: map[string]any{
"fallback_title": "天安门", "coordinates_lat": 39.9, "coordinates_long": 116.4,
}, contains: []string{"位置:天安门", "https://maps.google.com/?q=39.9,116.4"}},
{name: "contact", contentType: "contact", metadata: map[string]any{
"fallback_title": "+86 13800138000", "meta": map[string]any{"firstName": "张", "lastName": "三"},
}, contains: []string{"联系人:张 三", "电话:+86 13800138000"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server, database, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
message := webhookBody(t, now, "message_created", gochat.MessageWebhookData{
Message: gochat.WebhookMessage{
ID: 77, MessageType: "outgoing", ContentType: test.contentType, Status: "progress",
Attachments: []gochat.WebhookAttachment{{ID: 5, FileType: test.contentType, Metadata: test.metadata}},
},
Conversation: gochat.WebhookConversation{ID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
})
if response := doWebhook(t, server, message, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("message status = %d body=%s", response.StatusCode, readBody(response))
}
queued, err := database.Reader().GetOutboundByGoChatMessageID(context.Background(), 77)
if err != nil {
t.Fatal(err)
}
parts, err := database.Reader().ListOutboundParts(context.Background(), queued.ID)
if err != nil || len(parts) != 1 || parts[0].PartType != "text" || parts[0].Content == nil {
t.Fatalf("parts = %#v, %v", parts, err)
}
for _, expected := range test.contains {
if !strings.Contains(*parts[0].Content, expected) {
t.Fatalf("fallback %q does not contain %q", *parts[0].Content, expected)
}
}
})
}
}
func TestTypingWebhookUpdatesNextSupervisorHeartbeatState(t *testing.T) {
server, _, now := newTestServer(t)
manager := &typingManagerRecorder{}
server.manager = manager
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
body := webhookBody(t, now, "conversation_typing_on", gochat.TypingWebhookData{
Conversation: gochat.WebhookConversation{ID: 100, DisplayID: 88, CustomAttributes: map[string]any{"swt_sid": "visitor"}},
Actor: gochat.WebhookActor{ID: 7, Type: "user"},
})
response := doWebhook(t, server, body, now, "secret")
if response.StatusCode != http.StatusAccepted || manager.accountID == 0 || manager.sid != "visitor" || !manager.typing {
t.Fatalf("response=%d manager=%#v body=%s", response.StatusCode, manager, readBody(response))
}
}
func TestConversationResolveWebhookQueuesDurableEndOperation(t *testing.T) {
server, database, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
body := webhookBody(t, now, "conversation_status_changed", gochat.ConversationStatusWebhookData{
Conversation: gochat.WebhookConversation{
ID: 100, DisplayID: 88, Status: "resolved", PreviousStatus: "open",
CustomAttributes: map[string]any{"swt_sid": "visitor"},
},
Actor: gochat.WebhookActor{ID: 7, Type: "user"},
})
first := doWebhook(t, server, body, now, "secret")
if first.StatusCode != http.StatusAccepted {
t.Fatalf("first status = %d body=%s", first.StatusCode, readBody(first))
}
second := doWebhook(t, server, body, now, "secret")
if second.StatusCode != http.StatusOK {
t.Fatalf("duplicate status = %d body=%s", second.StatusCode, readBody(second))
}
operation, err := database.Reader().GetOutboundOperationByEventID(context.Background(), "conversation_status_changed:1")
if err != nil || operation.Operation != "end_conversation" || operation.SwtSid != "visitor" {
t.Fatalf("operation = %#v, %v", operation, err)
}
}
func TestHealthMiddlewareAddsRequestIDAndRejectsLargeBodies(t *testing.T) {
server, _, _ := newTestServer(t)
request, _ := http.NewRequest(http.MethodGet, "/healthz", nil)
response, err := server.App().Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK || response.Header.Get("X-Request-ID") == "" {
t.Fatalf("health response = %d, request-id=%q", response.StatusCode, response.Header.Get("X-Request-ID"))
}
request, _ = http.NewRequest(http.MethodPost, "/webhooks/gochat/v1", bytes.NewReader(make([]byte, maxWebhookBodyBytes+1)))
response, err = server.App().Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("large body status = %d", response.StatusCode)
}
}
func TestMetricsExposeQueueAndSupervisorGauges(t *testing.T) {
server, database, now := newTestServer(t)
server.manager = &typingManagerRecorder{}
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
account, err := database.Reader().GetAccountByInboxID(context.Background(), 10)
if err != nil {
t.Fatal(err)
}
content := "hello"
if _, _, err := database.EnqueueOutbound(context.Background(), store.OutboundInput{
AccountID: account.ID, SWTSessionID: "visitor", EventID: "message:77:created", OccurredAt: time.Now(),
GoChatMessageID: 77, MessageType: "text", Content: &content, Payload: `{}`,
}, false); err != nil {
t.Fatal(err)
}
if err := server.RefreshMetrics(context.Background()); err != nil {
t.Fatal(err)
}
if err := database.Close(); err != nil {
t.Fatal(err)
}
request, _ := http.NewRequest(http.MethodGet, "/metrics", nil)
response, err := server.App().Test(request)
if err != nil {
t.Fatal(err)
}
payload := readBody(response)
if response.StatusCode != http.StatusOK || !strings.Contains(payload, "swt_connector_supervisors 1") ||
!strings.Contains(payload, `swt_connector_outbound_queue_depth{status="pending"} 1`) {
t.Fatalf("status=%d metrics=%s", response.StatusCode, payload)
}
}
func newTestServer(t *testing.T) (*Server, *store.Store, time.Time) {
t.Helper()
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "connector.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
config := gochat.InboxConfig{
SchemaVersion: 1, AccountID: 1, InboxID: 10, InboxIdentifier: "identifier",
Enabled: true, DesiredPresence: "online", ConfigVersion: 1,
Credentials: gochat.Credentials{
SessionID: "BYT99917999", Username: "agent", Password: "password",
HMACToken: "hmac", WebhookSecret: "secret",
},
}
source := &testConfigSource{config: config, configs: []gochat.InboxConfig{config}}
reconciler := account.NewReconciler(source, database, nil)
server, err := NewServer(database, reconciler, nil, nil)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1785483001, 0).UTC()
server.now = func() time.Time { return now }
return server, database, now
}
type testConfigSource struct {
config gochat.InboxConfig
configs []gochat.InboxConfig
}
type typingManagerRecorder struct {
accountID int64
sid string
typing bool
}
func (*typingManagerRecorder) Running() int { return 1 }
func (m *typingManagerRecorder) SetTyping(accountID int64, sid string, typing bool) error {
m.accountID, m.sid, m.typing = accountID, sid, typing
return nil
}
func (s *testConfigSource) ListInboxConfigs(context.Context) ([]gochat.InboxConfig, error) {
return s.configs, nil
}
func (s *testConfigSource) GetInboxConfig(context.Context, int64) (gochat.InboxConfig, error) {
return s.config, nil
}
func webhookBody(t *testing.T, now time.Time, event string, data any) []byte {
t.Helper()
eventID := event + ":1"
if event == "message_created" {
eventID = "message:77:created"
}
body, err := json.Marshal(map[string]any{
"schema_version": 1, "event": event, "event_id": eventID, "occurred_at": now,
"account_id": 1, "inbox_id": 10, "data": data,
})
if err != nil {
t.Fatal(err)
}
return body
}
func doWebhook(t *testing.T, server *Server, body []byte, now time.Time, secret string) *http.Response {
t.Helper()
timestamp := strconv.FormatInt(now.Unix(), 10)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(body)
request, _ := http.NewRequest(http.MethodPost, "/webhooks/gochat/v1", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Chatwoot-Timestamp", timestamp)
request.Header.Set("X-Chatwoot-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
request.Header.Set("X-Chatwoot-Delivery", "delivery")
if err := gochat.VerifyWebhookSignature(secret, timestamp, request.Header.Get("X-Chatwoot-Signature"), body, now); err != nil {
t.Fatalf("test signature: %v", err)
}
response, err := server.App().Test(request)
if err != nil {
t.Fatal(err)
}
return response
}
func readBody(response *http.Response) string {
payload, _ := io.ReadAll(response.Body)
_ = response.Body.Close()
return string(payload)
}