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) }