422 lines
18 KiB
Go
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)
|
|
}
|