Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
1114 lines
49 KiB
Go
1114 lines
49 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gochat/gochat/internal/auth"
|
|
"github.com/gochat/gochat/internal/config"
|
|
v1 "github.com/gochat/gochat/internal/handler/api/v1"
|
|
"github.com/gochat/gochat/internal/middleware"
|
|
"github.com/gochat/gochat/internal/model"
|
|
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/service"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
engine := gin.New()
|
|
|
|
RegisterRoutes(
|
|
engine,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
&Handlers{},
|
|
nil,
|
|
nil,
|
|
&config.JWTConfig{},
|
|
middleware.CORSConfig{},
|
|
nil,
|
|
)
|
|
|
|
routes := map[string]bool{}
|
|
for _, route := range engine.Routes() {
|
|
routes[route.Method+" "+route.Path] = true
|
|
}
|
|
|
|
expected := []string{
|
|
"GET /.well-known/assetlinks.json",
|
|
"GET /.well-known/apple-app-site-association",
|
|
"GET /.well-known/microsoft-identity-association.json",
|
|
"GET /.well-known/cf-custom-hostname-challenge/:id",
|
|
"GET /linear/callback",
|
|
"GET /shopify/callback",
|
|
"GET /notion/callback",
|
|
"GET /twitter/callback",
|
|
"GET /google/callback",
|
|
"GET /microsoft/callback",
|
|
"GET /instagram/callback",
|
|
"GET /tiktok/callback",
|
|
"GET /app",
|
|
"GET /app/*params",
|
|
"GET /api/v1/accounts/:account_id/captain/assistants/tools",
|
|
"GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id",
|
|
"GET /api/v1/widget/conversations",
|
|
"GET /api/v1/widget/conversations/toggle_status",
|
|
"PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id",
|
|
"GET /hc/:slug",
|
|
"GET /hc/:slug/sitemap.xml",
|
|
"GET /hc/:slug/:locale",
|
|
"GET /hc/:slug/:locale/search",
|
|
"GET /hc/:slug/:locale/articles.json",
|
|
"GET /hc/:slug/:locale/categories.json",
|
|
"GET /hc/:slug/:locale/categories/:category_slug",
|
|
"GET /hc/:slug/articles/:article_slug",
|
|
"GET /api/v2/accounts/:account_id/reports/summary",
|
|
"GET /api/v2/accounts/:account_id/year_in_review",
|
|
"GET /api/v2/accounts/:account_id/live_reports/grouped_conversation_metrics",
|
|
"POST /api/v1/accounts",
|
|
"GET /webhooks/twitter",
|
|
"POST /webhooks/twitter",
|
|
"POST /webhooks/telegram/:bot_token",
|
|
"POST /webhooks/line/:line_channel_id",
|
|
"POST /webhooks/sms/:phone_number",
|
|
"GET /webhooks/whatsapp/:phone_number",
|
|
"POST /webhooks/whatsapp/:phone_number",
|
|
"POST /webhooks/tiktok",
|
|
"POST /webhooks/shopify",
|
|
"POST /twilio/callback",
|
|
"POST /twilio/delivery_status",
|
|
"POST /twilio/voice/call/:phone",
|
|
"POST /twilio/voice/status/:phone",
|
|
"POST /twilio/voice/conference_status/:phone",
|
|
"POST /twilio/voice/recording_status/:phone",
|
|
}
|
|
|
|
for _, key := range expected {
|
|
if !routes[key] {
|
|
t.Fatalf("expected route %s to be registered", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPIV2LiveReportsRouterAuthAndAccountScope(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("db handle: %v", err)
|
|
}
|
|
defer sqlDB.Close()
|
|
if err := db.AutoMigrate(&model.Conversation{}, &model.ReportingEvent{}, &model.ReportingEventsRollup{}); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
|
|
analyticsSvc := service.NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db))
|
|
jwtCfg := &config.JWTConfig{Secret: "live-report-router-secret", ExpiryHours: 1, RefreshExpiryHours: 24, AccessExpiryMinutes: 60}
|
|
jwtSvc := auth.NewJWTService(jwtCfg)
|
|
user := &model.User{Base: model.Base{ID: 7}, Provider: "email", Email: "agent@example.com"}
|
|
tokenPair, err := jwtSvc.GenerateTokenPair(user, 1, "agent")
|
|
if err != nil {
|
|
t.Fatalf("generate token: %v", err)
|
|
}
|
|
refreshStore := auth.NewRefreshTokenStore(nil, jwtCfg)
|
|
if err := refreshStore.Store(context.Background(), user.ID, tokenPair.RefreshToken); err != nil {
|
|
t.Fatalf("store refresh token: %v", err)
|
|
}
|
|
|
|
open := model.Conversation{AccountID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"}
|
|
otherAccountOpen := model.Conversation{AccountID: 2, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"}
|
|
if err := db.Create(&open).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
if err := db.Create(&otherAccountOpen).Error; err != nil {
|
|
t.Fatalf("create other conversation: %v", err)
|
|
}
|
|
|
|
engine := gin.New()
|
|
RegisterRoutes(
|
|
engine,
|
|
jwtSvc,
|
|
refreshStore,
|
|
nil,
|
|
&Handlers{LiveReport: v1.NewLiveReportHandler(analyticsSvc)},
|
|
nil,
|
|
nil,
|
|
jwtCfg,
|
|
middleware.CORSConfig{},
|
|
db,
|
|
)
|
|
|
|
unauthorized := httptest.NewRecorder()
|
|
engine.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/live_reports/conversation_metrics", nil))
|
|
if unauthorized.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected no-token request to be unauthorized, got %d: %s", unauthorized.Code, unauthorized.Body.String())
|
|
}
|
|
|
|
authorized := httptest.NewRecorder()
|
|
authorizedReq := httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/live_reports/conversation_metrics", nil)
|
|
authorizedReq.Header.Set("access-token", tokenPair.AccessToken)
|
|
engine.ServeHTTP(authorized, authorizedReq)
|
|
if authorized.Code != http.StatusOK {
|
|
t.Fatalf("expected Chatwoot access-token request to pass, got %d: %s", authorized.Code, authorized.Body.String())
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(authorized.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode authorized body: %v", err)
|
|
}
|
|
if body["open"] != float64(1) || body["unattended"] != float64(1) || body["pending"] != float64(0) {
|
|
t.Fatalf("expected account-scoped live metrics, got %#v", body)
|
|
}
|
|
|
|
forbidden := httptest.NewRecorder()
|
|
forbiddenReq := httptest.NewRequest(http.MethodGet, "/api/v2/accounts/2/live_reports/conversation_metrics", nil)
|
|
forbiddenReq.Header.Set("access-token", tokenPair.AccessToken)
|
|
engine.ServeHTTP(forbidden, forbiddenReq)
|
|
if forbidden.Code != http.StatusForbidden {
|
|
t.Fatalf("expected token scoped to account 1 to be forbidden from account 2, got %d: %s", forbidden.Code, forbidden.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebhookNilHandlerReturnsProviderUnavailable(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
engine := gin.New()
|
|
|
|
RegisterRoutes(
|
|
engine,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
&Handlers{},
|
|
nil,
|
|
nil,
|
|
&config.JWTConfig{},
|
|
middleware.CORSConfig{},
|
|
nil,
|
|
)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/telegram/bot-token", nil)
|
|
engine.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, w.Code)
|
|
}
|
|
if strings.Contains(w.Body.String(), "not implemented") || strings.Contains(w.Body.String(), "placeholder") {
|
|
t.Fatalf("nil webhook fallback returned placeholder body: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestTwilioVoiceRoutesServeConferenceAndPersistCallbacks(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db, call := setupRouterTwilioVoiceDB(t)
|
|
|
|
engine := gin.New()
|
|
engine.POST("/twilio/voice/call/:phone", twilioVoiceCallTwiML(db))
|
|
engine.POST("/twilio/voice/status/:phone", twilioVoiceStatus(db))
|
|
engine.POST("/twilio/voice/conference_status/:phone", twilioVoiceConferenceStatus(db))
|
|
engine.POST("/twilio/voice/recording_status/:phone", twilioVoiceRecordingStatus(db))
|
|
|
|
twiml := performFormPost(engine, "/twilio/voice/call/15551234567", url.Values{
|
|
"CallSid": {call.ProviderCallID},
|
|
"Direction": {"outbound-api"},
|
|
"From": {"+15550990000"},
|
|
"ParentCallSid": {""},
|
|
})
|
|
if twiml.Code != http.StatusOK || !strings.Contains(twiml.Body.String(), "<Conference") || !strings.Contains(twiml.Body.String(), call.ConferenceSID) {
|
|
t.Fatalf("expected conference TwiML, got %d %q", twiml.Code, twiml.Body.String())
|
|
}
|
|
if !strings.Contains(twiml.Body.String(), `participantLabel="contact"`) {
|
|
t.Fatalf("expected contact participant label, got %s", twiml.Body.String())
|
|
}
|
|
|
|
status := performFormPost(engine, "/twilio/voice/status/15551234567", url.Values{
|
|
"CallSid": {call.ProviderCallID},
|
|
"CallStatus": {"completed"},
|
|
"CallDuration": {"42"},
|
|
})
|
|
if status.Code != http.StatusNoContent {
|
|
t.Fatalf("expected status callback 204, got %d", status.Code)
|
|
}
|
|
|
|
conference := performFormPost(engine, "/twilio/voice/conference_status/15551234567", url.Values{
|
|
"CallSid": {call.ProviderCallID},
|
|
"FriendlyName": {call.ConferenceSID},
|
|
"ConferenceSid": {"CF123"},
|
|
"StatusCallbackEvent": {"participant-join"},
|
|
})
|
|
if conference.Code != http.StatusNoContent {
|
|
t.Fatalf("expected conference callback 204, got %d", conference.Code)
|
|
}
|
|
|
|
recording := performFormPost(engine, "/twilio/voice/recording_status/15551234567", url.Values{
|
|
"CallSid": {call.ProviderCallID},
|
|
"RecordingUrl": {"https://api.twilio.com/recording.mp3"},
|
|
"RecordingDuration": {"43"},
|
|
"RecordingStatus": {"completed"},
|
|
"RecordingSource": {"Conference"},
|
|
"RecordingChannels": {"1"},
|
|
"RecordingStartTime": {"Sat, 06 Jun 2026 09:00:00 +0000"},
|
|
})
|
|
if recording.Code != http.StatusNoContent {
|
|
t.Fatalf("expected recording callback 204, got %d", recording.Code)
|
|
}
|
|
|
|
var updated model.Call
|
|
if err := db.First(&updated, call.ID).Error; err != nil {
|
|
t.Fatalf("failed to reload call: %v", err)
|
|
}
|
|
if updated.Status != string(model.CallStatusOngoing) || updated.Duration != 43 || updated.RecordingURL != "https://api.twilio.com/recording.mp3" {
|
|
t.Fatalf("expected persisted Twilio callback state, got status=%s duration=%d recording=%s", updated.Status, updated.Duration, updated.RecordingURL)
|
|
}
|
|
if !strings.Contains(string(updated.AdditionalAttributes), "twilio_conference_sid") || !strings.Contains(string(updated.AdditionalAttributes), "twilio_recording_payload") {
|
|
t.Fatalf("expected callback payload attrs, got %s", string(updated.AdditionalAttributes))
|
|
}
|
|
}
|
|
|
|
func TestTwilioVoiceCallRejectsUnknownOrDisabledInbox(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db, call := setupRouterTwilioVoiceDB(t)
|
|
if err := db.Model(&model.Inbox{}).Where("id = ?", call.InboxID).Update("channel_config", `{"voice_enabled":false}`).Error; err != nil {
|
|
t.Fatalf("failed to disable voice inbox: %v", err)
|
|
}
|
|
|
|
engine := gin.New()
|
|
engine.POST("/twilio/voice/call/:phone", twilioVoiceCallTwiML(db))
|
|
|
|
disabled := performFormPost(engine, "/twilio/voice/call/15551234567", url.Values{"CallSid": {call.ProviderCallID}})
|
|
if disabled.Code != http.StatusNotFound {
|
|
t.Fatalf("expected disabled voice inbox 404, got %d", disabled.Code)
|
|
}
|
|
missing := performFormPost(engine, "/twilio/voice/call/15550000000", url.Values{"CallSid": {call.ProviderCallID}})
|
|
if missing.Code != http.StatusNotFound {
|
|
t.Fatalf("expected missing voice inbox 404, got %d", missing.Code)
|
|
}
|
|
}
|
|
|
|
func TestCustomDomainChallengeMatchesChatwootVerification(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupRouterPortalDB(t)
|
|
portal := &model.Portal{
|
|
AccountID: 1,
|
|
Name: "Help Center",
|
|
Slug: "help-center",
|
|
CustomDomain: "help.example.com",
|
|
SSLSettings: json.RawMessage(`{"cf_verification_id":"challenge-token","cf_verification_body":"cloudflare-body"}`),
|
|
}
|
|
if err := db.Create(portal).Error; err != nil {
|
|
t.Fatalf("failed to create portal: %v", err)
|
|
}
|
|
|
|
engine := gin.New()
|
|
engine.GET("/.well-known/cf-custom-hostname-challenge/:id", customDomainChallenge(db))
|
|
|
|
matched := performHostGet(engine, "/.well-known/cf-custom-hostname-challenge/challenge-token", "help.example.com:3000")
|
|
if matched.Code != http.StatusOK || matched.Body.String() != "cloudflare-body" {
|
|
t.Fatalf("expected matching challenge body, got %d %q", matched.Code, matched.Body.String())
|
|
}
|
|
|
|
missingDomain := performHostGet(engine, "/.well-known/cf-custom-hostname-challenge/challenge-token", "missing.example.com")
|
|
if missingDomain.Code != http.StatusNotFound || missingDomain.Body.String() != "Domain not found" {
|
|
t.Fatalf("expected domain 404, got %d %q", missingDomain.Code, missingDomain.Body.String())
|
|
}
|
|
|
|
wrongChallenge := performHostGet(engine, "/.well-known/cf-custom-hostname-challenge/wrong-token", "help.example.com")
|
|
if wrongChallenge.Code != http.StatusNotFound || wrongChallenge.Body.String() != "Challenge ID not found" {
|
|
t.Fatalf("expected challenge 404, got %d %q", wrongChallenge.Code, wrongChallenge.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestIntegrationCallbacksCreateHooksAndRedirect(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
|
t.Setenv("LINEAR_CLIENT_ID", "linear-client")
|
|
t.Setenv("LINEAR_CLIENT_SECRET", "linear-secret")
|
|
t.Setenv("SHOPIFY_CLIENT_ID", "shopify-client")
|
|
t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret")
|
|
t.Setenv("NOTION_CLIENT_ID", "notion-client")
|
|
t.Setenv("NOTION_CLIENT_SECRET", "notion-secret")
|
|
|
|
t.Setenv("LINEAR_OAUTH_TOKEN_URL", "https://oauth.example.test/linear")
|
|
t.Setenv("SHOPIFY_OAUTH_TOKEN_URL", "https://oauth.example.test/shopify")
|
|
t.Setenv("NOTION_OAUTH_TOKEN_URL", "https://oauth.example.test/notion")
|
|
withFakeOAuthTransport(t, map[string]map[string]any{
|
|
"/linear": {"access_token": "linear-token", "refresh_token": "linear-refresh", "token_type": "bearer", "expires_in": 3600, "scope": "read,write"},
|
|
"/shopify": {"access_token": "shopify-token", "scope": "read_orders"},
|
|
"/notion": {"access_token": "notion-token", "token_type": "bearer", "workspace_name": "Docs", "workspace_id": "workspace-1", "bot_id": "bot-1"},
|
|
})
|
|
|
|
db, account := setupRouterIntegrationCallbackDB(t)
|
|
engine := gin.New()
|
|
engine.GET("/linear/callback", linearIntegrationCallback(db))
|
|
engine.GET("/shopify/callback", shopifyIntegrationCallback(db))
|
|
engine.GET("/notion/callback", notionIntegrationCallback(db))
|
|
|
|
linearState := signedCallbackState(t, account.ID, "linear-secret")
|
|
linear := performGet(engine, "/linear/callback?code=linear-code&state="+url.QueryEscape(linearState))
|
|
if linear.Code != http.StatusFound || linear.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/integrations/linear" {
|
|
t.Fatalf("expected linear integration redirect, got %d %q", linear.Code, linear.Header().Get("Location"))
|
|
}
|
|
|
|
shopifyState := signedCallbackState(t, account.ID, "shopify-secret")
|
|
shopify := performGet(engine, "/shopify/callback?code=shopify-code&shop=store.myshopify.com&state="+url.QueryEscape(shopifyState))
|
|
if shopify.Code != http.StatusFound || shopify.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/integrations/shopify" {
|
|
t.Fatalf("expected shopify integration redirect, got %d %q", shopify.Code, shopify.Header().Get("Location"))
|
|
}
|
|
|
|
notionState := signedCallbackState(t, account.ID, "notion-secret")
|
|
notion := performGet(engine, "/notion/callback?code=notion-code&state="+url.QueryEscape(notionState))
|
|
if notion.Code != http.StatusFound || notion.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/integrations/notion" {
|
|
t.Fatalf("expected notion integration redirect, got %d %q", notion.Code, notion.Header().Get("Location"))
|
|
}
|
|
|
|
var hooks []model.IntegrationHook
|
|
if err := db.Order("app_id ASC").Find(&hooks).Error; err != nil {
|
|
t.Fatalf("failed to load hooks: %v", err)
|
|
}
|
|
if len(hooks) != 3 {
|
|
t.Fatalf("expected three integration hooks, got %d", len(hooks))
|
|
}
|
|
|
|
seen := map[string]model.IntegrationHook{}
|
|
for _, hook := range hooks {
|
|
seen[hook.AppID] = hook
|
|
}
|
|
if seen["linear"].AccessToken != "linear-token" || seen["linear"].Status != model.HookStatusActive {
|
|
t.Fatalf("expected active linear hook with token, got %+v", seen["linear"])
|
|
}
|
|
if seen["shopify"].AccessToken != "shopify-token" || seen["shopify"].ReferenceID != "store.myshopify.com" {
|
|
t.Fatalf("expected shopify hook with reference shop, got %+v", seen["shopify"])
|
|
}
|
|
if seen["notion"].AccessToken != "notion-token" || !strings.Contains(string(seen["notion"].Settings), "workspace_name") {
|
|
t.Fatalf("expected notion hook with workspace settings, got %+v settings=%s", seen["notion"], string(seen["notion"].Settings))
|
|
}
|
|
}
|
|
|
|
func TestIntegrationCallbacksRedirectSafelyOnInvalidState(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
|
t.Setenv("LINEAR_CLIENT_SECRET", "linear-secret")
|
|
t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret")
|
|
db, _ := setupRouterIntegrationCallbackDB(t)
|
|
|
|
engine := gin.New()
|
|
engine.GET("/linear/callback", linearIntegrationCallback(db))
|
|
engine.GET("/shopify/callback", shopifyIntegrationCallback(db))
|
|
engine.GET("/notion/callback", notionIntegrationCallback(db))
|
|
|
|
linear := performGet(engine, "/linear/callback?code=linear-code&state=bad-state")
|
|
if linear.Code != http.StatusFound || linear.Header().Get("Location") != "https://app.example.test" {
|
|
t.Fatalf("expected linear safe redirect, got %d %q", linear.Code, linear.Header().Get("Location"))
|
|
}
|
|
shopify := performGet(engine, "/shopify/callback?code=shopify-code&shop=store.myshopify.com&state=bad-state")
|
|
if shopify.Code != http.StatusFound || shopify.Header().Get("Location") != "https://app.example.test?error=true" {
|
|
t.Fatalf("expected shopify error redirect, got %d %q", shopify.Code, shopify.Header().Get("Location"))
|
|
}
|
|
notion := performGet(engine, "/notion/callback?code=notion-code&state=bad-state")
|
|
if notion.Code != http.StatusFound || notion.Header().Get("Location") != "https://app.example.test" {
|
|
t.Fatalf("expected notion safe redirect, got %d %q", notion.Code, notion.Header().Get("Location"))
|
|
}
|
|
|
|
var count int64
|
|
if err := db.Model(&model.IntegrationHook{}).Count(&count).Error; err != nil {
|
|
t.Fatalf("failed to count hooks: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected invalid callbacks not to create hooks, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestIntegrationCallbacksRedirectProviderErrorsWithoutCreatingHooks(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
|
t.Setenv("LINEAR_CLIENT_ID", "linear-client")
|
|
t.Setenv("LINEAR_CLIENT_SECRET", "linear-secret")
|
|
t.Setenv("SHOPIFY_CLIENT_ID", "shopify-client")
|
|
t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret")
|
|
t.Setenv("NOTION_CLIENT_ID", "notion-client")
|
|
t.Setenv("NOTION_CLIENT_SECRET", "notion-secret")
|
|
t.Setenv("LINEAR_OAUTH_TOKEN_URL", "https://oauth.example.test/linear")
|
|
t.Setenv("SHOPIFY_OAUTH_TOKEN_URL", "https://oauth.example.test/shopify")
|
|
t.Setenv("NOTION_OAUTH_TOKEN_URL", "https://oauth.example.test/notion")
|
|
withFakeOAuthTransport(t, map[string]map[string]any{
|
|
"/linear": {"token_type": "bearer", "scope": "read,write"},
|
|
"/shopify": {"scope": "read_orders"},
|
|
"/notion": {"token_type": "bearer", "workspace_name": "Docs"},
|
|
})
|
|
|
|
db, account := setupRouterIntegrationCallbackDB(t)
|
|
engine := gin.New()
|
|
engine.GET("/linear/callback", linearIntegrationCallback(db))
|
|
engine.GET("/shopify/callback", shopifyIntegrationCallback(db))
|
|
engine.GET("/notion/callback", notionIntegrationCallback(db))
|
|
|
|
checks := []struct {
|
|
name string
|
|
path string
|
|
secret string
|
|
location string
|
|
}{
|
|
{name: "linear", path: "/linear/callback?code=linear-code", secret: "linear-secret", location: "https://app.example.test/app/accounts/1/settings/integrations/linear"},
|
|
{name: "shopify", path: "/shopify/callback?code=shopify-code&shop=store.myshopify.com", secret: "shopify-secret", location: "https://app.example.test/app/accounts/1/settings/integrations/shopify?error=true"},
|
|
{name: "notion", path: "/notion/callback?code=notion-code", secret: "notion-secret", location: "https://app.example.test"},
|
|
}
|
|
for _, check := range checks {
|
|
resp := performGet(engine, check.path+"&state="+url.QueryEscape(signedCallbackState(t, account.ID, check.secret)))
|
|
if resp.Code != http.StatusFound || resp.Header().Get("Location") != check.location {
|
|
t.Fatalf("expected %s provider-error redirect to %q, got %d %q", check.name, check.location, resp.Code, resp.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
var count int64
|
|
if err := db.Model(&model.IntegrationHook{}).Count(&count).Error; err != nil {
|
|
t.Fatalf("failed to count hooks: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected provider-error callbacks not to create hooks, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestChannelCallbacksCreateInboxesAndRedirect(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
|
t.Setenv("GOOGLE_OAUTH_CLIENT_ID", "google-client")
|
|
t.Setenv("GOOGLE_OAUTH_CLIENT_SECRET", "google-secret")
|
|
t.Setenv("AZURE_APP_ID", "microsoft-client")
|
|
t.Setenv("AZURE_APP_SECRET", "microsoft-secret")
|
|
t.Setenv("INSTAGRAM_APP_ID", "instagram-client")
|
|
t.Setenv("INSTAGRAM_APP_SECRET", "instagram-secret")
|
|
t.Setenv("TIKTOK_APP_ID", "tiktok-client")
|
|
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
|
|
t.Setenv("TWITTER_CONSUMER_SECRET", "twitter-secret")
|
|
t.Setenv("GOOGLE_OAUTH_TOKEN_URL", "https://oauth.example.test/google")
|
|
t.Setenv("MICROSOFT_OAUTH_TOKEN_URL", "https://oauth.example.test/microsoft")
|
|
t.Setenv("INSTAGRAM_OAUTH_TOKEN_URL", "https://oauth.example.test/instagram")
|
|
t.Setenv("TIKTOK_OAUTH_TOKEN_URL", "https://oauth.example.test/tiktok")
|
|
t.Setenv("TWITTER_OAUTH_TOKEN_URL", "https://oauth.example.test/twitter")
|
|
|
|
withFakeCallbackTransport(t, map[string]string{
|
|
"/google": jsonOAuthBody(t, map[string]any{"access_token": "google-token", "refresh_token": "google-refresh", "id_token": idToken(t, map[string]any{"email": "gmail@example.com", "name": "Gmail Support"})}),
|
|
"/microsoft": jsonOAuthBody(t, map[string]any{"access_token": "microsoft-token", "refresh_token": "microsoft-refresh", "id_token": idToken(t, map[string]any{"email": "outlook@example.com", "preferred_username": "agent@contoso.com", "name": "Outlook Support"})}),
|
|
"/instagram": jsonOAuthBody(t, map[string]any{"access_token": "instagram-token", "instagram_account_id": "ig-1", "username": "insta_support", "connected_fb_page_id": "page-1"}),
|
|
"/tiktok": jsonOAuthBody(t, map[string]any{"access_token": "tiktok-token", "refresh_token": "tiktok-refresh", "business_id": "biz-1", "display_name": "TikTok Support", "expires_in": 3600, "scope": "user.info.basic,user.info.username,user.info.stats,user.info.profile,user.account.type,user.insights,message.list.read,message.list.send,message.list.manage"}),
|
|
"/twitter": "oauth_token=twitter-token&oauth_token_secret=twitter-secret&user_id=tw-1&screen_name=tw_support",
|
|
})
|
|
|
|
db, account := setupRouterChannelCallbackDB(t)
|
|
engine := gin.New()
|
|
engine.GET("/google/callback", googleEmailCallback(db))
|
|
engine.GET("/microsoft/callback", microsoftEmailCallback(db))
|
|
engine.GET("/instagram/callback", instagramChannelCallback(db))
|
|
engine.GET("/tiktok/callback", tiktokChannelCallback(db))
|
|
engine.GET("/twitter/callback", twitterChannelCallback(db))
|
|
|
|
checks := []struct {
|
|
path string
|
|
secret string
|
|
}{
|
|
{"/google/callback?code=google-code", "google-secret"},
|
|
{"/microsoft/callback?code=microsoft-code", "microsoft-secret"},
|
|
{"/instagram/callback?code=instagram-code", "instagram-secret"},
|
|
{"/tiktok/callback?code=tiktok-code", "tiktok-secret"},
|
|
{"/twitter/callback?oauth_token=request-token&oauth_verifier=verifier", "twitter-secret"},
|
|
}
|
|
for _, check := range checks {
|
|
state := signedCallbackState(t, account.ID, check.secret)
|
|
separator := "&"
|
|
if !strings.Contains(check.path, "?") {
|
|
separator = "?"
|
|
}
|
|
resp := performGet(engine, check.path+separator+"state="+url.QueryEscape(state))
|
|
if resp.Code != http.StatusFound || !strings.Contains(resp.Header().Get("Location"), "/settings/inboxes/new/") {
|
|
t.Fatalf("expected new inbox agents redirect for %s, got %d %q", check.path, resp.Code, resp.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
var inboxes []model.Inbox
|
|
if err := db.Order("id ASC").Find(&inboxes).Error; err != nil {
|
|
t.Fatalf("failed to load channel callback inboxes: %v", err)
|
|
}
|
|
if len(inboxes) != 5 {
|
|
t.Fatalf("expected five callback-created inboxes, got %d", len(inboxes))
|
|
}
|
|
expectedTypes := []string{"email", "email", "instagram", "tiktok", "twitter"}
|
|
for i, expected := range expectedTypes {
|
|
if inboxes[i].ChannelType != expected {
|
|
t.Fatalf("expected inbox %d channel type %s, got %s", i, expected, inboxes[i].ChannelType)
|
|
}
|
|
}
|
|
|
|
var email channelmodel.ChannelEmail
|
|
if err := db.Where("email = ?", "gmail@example.com").First(&email).Error; err != nil || email.IMAPAddress != "imap.gmail.com" || !email.IMAPEnabled {
|
|
t.Fatalf("expected google email channel with IMAP config, channel=%+v err=%v", email, err)
|
|
}
|
|
var microsoft channelmodel.ChannelEmail
|
|
if err := db.Where("email = ?", "outlook@example.com").First(µsoft).Error; err != nil || microsoft.IMAPLogin != "agent@contoso.com" || microsoft.IMAPAddress != "outlook.office365.com" {
|
|
t.Fatalf("expected microsoft email channel with login, channel=%+v err=%v", microsoft, err)
|
|
}
|
|
var instagram channelmodel.ChannelInstagram
|
|
if err := db.Where("instagram_account_id = ?", "ig-1").First(&instagram).Error; err != nil || instagram.PageAccessToken != "instagram-token" {
|
|
t.Fatalf("expected instagram channel, channel=%+v err=%v", instagram, err)
|
|
}
|
|
instagramInboxConfig := callbackTestInboxConfig(t, db, "instagram", instagram.ID)
|
|
if instagramInboxConfig["instagram_id"] != "ig-1" || instagramInboxConfig["connected_fb_page_id"] != "page-1" || instagramInboxConfig["page_access_token"] != "instagram-token" {
|
|
t.Fatalf("expected instagram inbox channel config, got %+v", instagramInboxConfig)
|
|
}
|
|
var tiktok channelmodel.ChannelTikTok
|
|
if err := db.Where("tiktok_business_id = ?", "biz-1").First(&tiktok).Error; err != nil || tiktok.AccessToken != "tiktok-token" {
|
|
t.Fatalf("expected tiktok channel, channel=%+v err=%v", tiktok, err)
|
|
}
|
|
tiktokInboxConfig := callbackTestInboxConfig(t, db, "tiktok", tiktok.ID)
|
|
if tiktokInboxConfig["tiktok_business_id"] != "biz-1" || tiktokInboxConfig["access_token"] != "tiktok-token" || tiktokInboxConfig["refresh_token"] != "tiktok-refresh" {
|
|
t.Fatalf("expected tiktok inbox channel config, got %+v", tiktokInboxConfig)
|
|
}
|
|
var twitter channelmodel.ChannelTwitter
|
|
if err := db.Where("twitter_user_id = ?", "tw-1").First(&twitter).Error; err != nil || twitter.TwitterAccessToken != "twitter-token" {
|
|
t.Fatalf("expected twitter channel, channel=%+v err=%v", twitter, err)
|
|
}
|
|
twitterInboxConfig := callbackTestInboxConfig(t, db, "twitter", twitter.ID)
|
|
if twitterInboxConfig["twitter_user_id"] != "tw-1" || twitterInboxConfig["screen_name"] != "tw_support" || twitterInboxConfig["tweets_enabled"] != true {
|
|
t.Fatalf("expected twitter inbox channel config, got %+v", twitterInboxConfig)
|
|
}
|
|
}
|
|
|
|
func TestChannelCallbacksUpdateExistingInboxesAndRedirectToSettings(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
|
t.Setenv("GOOGLE_OAUTH_CLIENT_ID", "google-client")
|
|
t.Setenv("GOOGLE_OAUTH_CLIENT_SECRET", "google-secret")
|
|
t.Setenv("INSTAGRAM_APP_ID", "instagram-client")
|
|
t.Setenv("INSTAGRAM_APP_SECRET", "instagram-secret")
|
|
t.Setenv("TIKTOK_APP_ID", "tiktok-client")
|
|
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
|
|
t.Setenv("TWITTER_CONSUMER_SECRET", "twitter-secret")
|
|
t.Setenv("GOOGLE_OAUTH_TOKEN_URL", "https://oauth.example.test/google")
|
|
t.Setenv("INSTAGRAM_OAUTH_TOKEN_URL", "https://oauth.example.test/instagram")
|
|
t.Setenv("TIKTOK_OAUTH_TOKEN_URL", "https://oauth.example.test/tiktok")
|
|
t.Setenv("TWITTER_OAUTH_TOKEN_URL", "https://oauth.example.test/twitter")
|
|
|
|
withFakeCallbackTransport(t, map[string]string{
|
|
"/google": jsonOAuthBody(t, map[string]any{"access_token": "google-new", "refresh_token": "google-refresh-new", "id_token": idToken(t, map[string]any{"email": "gmail@example.com", "name": "Gmail Renamed"})}),
|
|
"/instagram": jsonOAuthBody(t, map[string]any{"access_token": "instagram-new", "instagram_account_id": "ig-existing", "username": "insta_renamed", "connected_fb_page_id": "page-new"}),
|
|
"/tiktok": jsonOAuthBody(t, map[string]any{"access_token": "tiktok-new", "refresh_token": "tiktok-refresh-new", "business_id": "biz-existing", "display_name": "TikTok Renamed", "expires_in": 3600}),
|
|
"/twitter": "oauth_token=twitter-new&oauth_token_secret=twitter-secret-new&user_id=tw-existing&screen_name=tw_renamed",
|
|
})
|
|
|
|
db, account := setupRouterChannelCallbackDB(t)
|
|
googleChannel := channelmodel.ChannelEmail{AccountID: account.ID, Email: "gmail@example.com", MailboxName: "Old Gmail", Domain: "example.com", IMAPLogin: "gmail@example.com", IMAPAddress: "old.imap", IMAPPort: 993, IMAPEnabled: true}
|
|
requireRouterCreate(t, db, &googleChannel)
|
|
googleInbox := model.Inbox{AccountID: account.ID, Name: "Old Gmail", ChannelType: "email", ChannelID: googleChannel.ID, Enabled: true, ChannelConfig: `{"provider":"google","provider_config":{"access_token":"old"}}`}
|
|
requireRouterCreate(t, db, &googleInbox)
|
|
googleChannel.InboxID = googleInbox.ID
|
|
requireRouterSave(t, db, &googleChannel)
|
|
|
|
instagramChannel := channelmodel.ChannelInstagram{AccountID: account.ID, InboxID: 1, InstagramAccountID: "ig-existing", PageAccessToken: "instagram-old", ConnectedFBPageID: "page-old", InstagramAccountName: "insta_old"}
|
|
requireRouterCreate(t, db, &instagramChannel)
|
|
instagramInbox := model.Inbox{AccountID: account.ID, Name: "insta_old", ChannelType: "instagram", ChannelID: instagramChannel.ID, Enabled: true, ChannelConfig: instagramInboxConfig(instagramChannel)}
|
|
requireRouterCreate(t, db, &instagramInbox)
|
|
instagramChannel.InboxID = instagramInbox.ID
|
|
requireRouterSave(t, db, &instagramChannel)
|
|
|
|
tiktokChannel := channelmodel.ChannelTikTok{AccountID: account.ID, InboxID: 1, TikTokBusinessID: "biz-existing", AccessToken: "tiktok-old", RefreshToken: "tiktok-refresh-old"}
|
|
requireRouterCreate(t, db, &tiktokChannel)
|
|
tiktokInbox := model.Inbox{AccountID: account.ID, Name: "TikTok Old", ChannelType: "tiktok", ChannelID: tiktokChannel.ID, Enabled: true, ChannelConfig: tiktokInboxConfig(tiktokChannel)}
|
|
requireRouterCreate(t, db, &tiktokInbox)
|
|
tiktokChannel.InboxID = tiktokInbox.ID
|
|
requireRouterSave(t, db, &tiktokChannel)
|
|
|
|
twitterChannel := channelmodel.ChannelTwitter{AccountID: account.ID, InboxID: 1, TwitterUserID: "tw-existing", TwitterAccessToken: "twitter-old", TwitterAccessTokenSecret: "twitter-secret-old", ScreenName: "tw_old", Name: "tw_old", AccessToken: "twitter-old"}
|
|
requireRouterCreate(t, db, &twitterChannel)
|
|
twitterInbox := model.Inbox{AccountID: account.ID, Name: "tw_old", ChannelType: "twitter", ChannelID: twitterChannel.ID, Enabled: true, ChannelConfig: twitterInboxConfig(twitterChannel)}
|
|
requireRouterCreate(t, db, &twitterInbox)
|
|
twitterChannel.InboxID = twitterInbox.ID
|
|
requireRouterSave(t, db, &twitterChannel)
|
|
|
|
engine := gin.New()
|
|
engine.GET("/google/callback", googleEmailCallback(db))
|
|
engine.GET("/instagram/callback", instagramChannelCallback(db))
|
|
engine.GET("/tiktok/callback", tiktokChannelCallback(db))
|
|
engine.GET("/twitter/callback", twitterChannelCallback(db))
|
|
|
|
checks := []struct {
|
|
path string
|
|
secret string
|
|
inboxID uint
|
|
}{
|
|
{"/google/callback?code=google-code", "google-secret", googleInbox.ID},
|
|
{"/instagram/callback?code=instagram-code", "instagram-secret", instagramInbox.ID},
|
|
{"/tiktok/callback?code=tiktok-code", "tiktok-secret", tiktokInbox.ID},
|
|
{"/twitter/callback?oauth_token=request-token&oauth_verifier=verifier", "twitter-secret", twitterInbox.ID},
|
|
}
|
|
for _, check := range checks {
|
|
state := signedCallbackState(t, account.ID, check.secret)
|
|
resp := performGet(engine, check.path+"&state="+url.QueryEscape(state))
|
|
expectedLocation := inboxSettingsURL(account.ID, check.inboxID)
|
|
if resp.Code != http.StatusFound || resp.Header().Get("Location") != expectedLocation {
|
|
t.Fatalf("expected settings redirect %q for %s, got %d %q", expectedLocation, check.path, resp.Code, resp.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
var updatedGoogle channelmodel.ChannelEmail
|
|
requireRouterFirst(t, db.Where("email = ?", "gmail@example.com"), &updatedGoogle)
|
|
if updatedGoogle.MailboxName != "Gmail Renamed" || updatedGoogle.IMAPAddress != "imap.gmail.com" {
|
|
t.Fatalf("expected updated google channel, got %+v", updatedGoogle)
|
|
}
|
|
googleConfig := callbackTestInboxConfig(t, db, "email", updatedGoogle.ID)
|
|
if providerConfig := googleConfig["provider_config"].(map[string]any); googleConfig["provider"] != "google" || providerConfig["access_token"] != "google-new" {
|
|
t.Fatalf("expected updated google inbox config, got %+v", googleConfig)
|
|
}
|
|
|
|
var updatedInstagram channelmodel.ChannelInstagram
|
|
requireRouterFirst(t, db.Where("instagram_account_id = ?", "ig-existing"), &updatedInstagram)
|
|
if updatedInstagram.PageAccessToken != "instagram-new" || updatedInstagram.InstagramAccountName != "insta_renamed" {
|
|
t.Fatalf("expected updated instagram channel, got %+v", updatedInstagram)
|
|
}
|
|
instagramConfig := callbackTestInboxConfig(t, db, "instagram", updatedInstagram.ID)
|
|
if instagramConfig["page_access_token"] != "instagram-new" || instagramConfig["instagram_account_name"] != "insta_renamed" {
|
|
t.Fatalf("expected updated instagram inbox config, got %+v", instagramConfig)
|
|
}
|
|
|
|
var updatedTikTok channelmodel.ChannelTikTok
|
|
requireRouterFirst(t, db.Where("tiktok_business_id = ?", "biz-existing"), &updatedTikTok)
|
|
if updatedTikTok.AccessToken != "tiktok-new" || updatedTikTok.RefreshToken != "tiktok-refresh-new" {
|
|
t.Fatalf("expected updated tiktok channel, got %+v", updatedTikTok)
|
|
}
|
|
tiktokConfig := callbackTestInboxConfig(t, db, "tiktok", updatedTikTok.ID)
|
|
if tiktokConfig["access_token"] != "tiktok-new" || tiktokConfig["refresh_token"] != "tiktok-refresh-new" {
|
|
t.Fatalf("expected updated tiktok inbox config, got %+v", tiktokConfig)
|
|
}
|
|
|
|
var updatedTwitter channelmodel.ChannelTwitter
|
|
requireRouterFirst(t, db.Where("twitter_user_id = ?", "tw-existing"), &updatedTwitter)
|
|
if updatedTwitter.TwitterAccessToken != "twitter-new" || updatedTwitter.ScreenName != "tw_renamed" {
|
|
t.Fatalf("expected updated twitter channel, got %+v", updatedTwitter)
|
|
}
|
|
twitterConfig := callbackTestInboxConfig(t, db, "twitter", updatedTwitter.ID)
|
|
if twitterConfig["twitter_access_token"] != "twitter-new" || twitterConfig["screen_name"] != "tw_renamed" {
|
|
t.Fatalf("expected updated twitter inbox config, got %+v", twitterConfig)
|
|
}
|
|
}
|
|
|
|
func requireRouterCreate(t *testing.T, db *gorm.DB, value any) {
|
|
t.Helper()
|
|
if err := db.Create(value).Error; err != nil {
|
|
t.Fatalf("create %T: %v", value, err)
|
|
}
|
|
}
|
|
|
|
func requireRouterSave(t *testing.T, db *gorm.DB, value any) {
|
|
t.Helper()
|
|
if err := db.Save(value).Error; err != nil {
|
|
t.Fatalf("save %T: %v", value, err)
|
|
}
|
|
}
|
|
|
|
func requireRouterFirst(t *testing.T, query *gorm.DB, value any) {
|
|
t.Helper()
|
|
if err := query.First(value).Error; err != nil {
|
|
t.Fatalf("load %T: %v", value, err)
|
|
}
|
|
}
|
|
|
|
func callbackTestInboxConfig(t *testing.T, db *gorm.DB, channelType string, channelID uint) map[string]any {
|
|
t.Helper()
|
|
var inbox model.Inbox
|
|
if err := db.Where("channel_type = ? AND channel_id = ?", channelType, channelID).First(&inbox).Error; err != nil {
|
|
t.Fatalf("failed to load %s callback inbox: %v", channelType, err)
|
|
}
|
|
var config map[string]any
|
|
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
|
t.Fatalf("invalid %s callback inbox channel config %q: %v", channelType, inbox.ChannelConfig, err)
|
|
}
|
|
return config
|
|
}
|
|
|
|
func TestChannelCallbacksRedirectErrorsToNewInbox(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
|
t.Setenv("INSTAGRAM_APP_SECRET", "instagram-secret")
|
|
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
|
|
t.Setenv("TWITTER_CONSUMER_SECRET", "twitter-secret")
|
|
db, account := setupRouterChannelCallbackDB(t)
|
|
engine := gin.New()
|
|
engine.GET("/instagram/callback", instagramChannelCallback(db))
|
|
engine.GET("/tiktok/callback", tiktokChannelCallback(db))
|
|
engine.GET("/twitter/callback", twitterChannelCallback(db))
|
|
|
|
instagram := performGet(engine, "/instagram/callback?error=access_denied&error_description=nope&state="+url.QueryEscape(signedCallbackState(t, account.ID, "instagram-secret")))
|
|
if instagram.Code != http.StatusFound || !strings.Contains(instagram.Header().Get("Location"), "/settings/inboxes/new/instagram") || !strings.Contains(instagram.Header().Get("Location"), "error_message=nope") {
|
|
t.Fatalf("expected instagram error redirect, got %d %q", instagram.Code, instagram.Header().Get("Location"))
|
|
}
|
|
tiktok := performGet(engine, "/tiktok/callback?error=access_denied&error_description=nope&state="+url.QueryEscape(signedCallbackState(t, account.ID, "tiktok-secret")))
|
|
if tiktok.Code != http.StatusFound || !strings.Contains(tiktok.Header().Get("Location"), "/settings/inboxes/new/tiktok") || !strings.Contains(tiktok.Header().Get("Location"), "error_message=nope") {
|
|
t.Fatalf("expected tiktok error redirect, got %d %q", tiktok.Code, tiktok.Header().Get("Location"))
|
|
}
|
|
twitter := performGet(engine, "/twitter/callback?denied=true&state="+url.QueryEscape(signedCallbackState(t, account.ID, "twitter-secret")))
|
|
if twitter.Code != http.StatusFound || twitter.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/inboxes/new/twitter" {
|
|
t.Fatalf("expected twitter denied redirect, got %d %q", twitter.Code, twitter.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
func TestEmailOAuthCallbacksRedirectProviderErrorsWithoutCreatingInboxes(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
|
t.Setenv("GOOGLE_OAUTH_CLIENT_ID", "google-client")
|
|
t.Setenv("GOOGLE_OAUTH_CLIENT_SECRET", "google-secret")
|
|
t.Setenv("AZURE_APP_ID", "microsoft-client")
|
|
t.Setenv("AZURE_APP_SECRET", "microsoft-secret")
|
|
t.Setenv("GOOGLE_OAUTH_TOKEN_URL", "https://oauth.example.test/google")
|
|
t.Setenv("MICROSOFT_OAUTH_TOKEN_URL", "https://oauth.example.test/microsoft")
|
|
withFakeCallbackTransport(t, map[string]string{
|
|
"/google": jsonOAuthBody(t, map[string]any{"access_token": "google-token"}),
|
|
"/microsoft": jsonOAuthBody(t, map[string]any{"access_token": "microsoft-token", "id_token": idToken(t, map[string]any{"name": "No Email"})}),
|
|
})
|
|
|
|
db, account := setupRouterChannelCallbackDB(t)
|
|
engine := gin.New()
|
|
engine.GET("/google/callback", googleEmailCallback(db))
|
|
engine.GET("/microsoft/callback", microsoftEmailCallback(db))
|
|
|
|
checks := []struct {
|
|
path string
|
|
secret string
|
|
}{
|
|
{"/google/callback?code=google-code", "google-secret"},
|
|
{"/microsoft/callback?code=microsoft-code", "microsoft-secret"},
|
|
}
|
|
for _, check := range checks {
|
|
resp := performGet(engine, check.path+"&state="+url.QueryEscape(signedCallbackState(t, account.ID, check.secret)))
|
|
if resp.Code != http.StatusFound || resp.Header().Get("Location") != "https://app.example.test" {
|
|
t.Fatalf("expected frontend fallback for %s, got %d %q", check.path, resp.Code, resp.Header().Get("Location"))
|
|
}
|
|
}
|
|
var inboxCount int64
|
|
if err := db.Model(&model.Inbox{}).Count(&inboxCount).Error; err != nil {
|
|
t.Fatalf("count inboxes: %v", err)
|
|
}
|
|
if inboxCount != 0 {
|
|
t.Fatalf("expected no inboxes after failed email OAuth callbacks, got %d", inboxCount)
|
|
}
|
|
}
|
|
|
|
func TestWellKnownRoutesServeMobileAssociationPayloads(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("ANDROID_BUNDLE_ID", "com.example.gochat")
|
|
t.Setenv("ANDROID_SHA256_CERT_FINGERPRINT", "AA:BB:CC")
|
|
t.Setenv("IOS_APP_ID", "TEAMID.com.example.gochat")
|
|
t.Setenv("AZURE_APP_ID", "azure-application-client-id")
|
|
|
|
engine := gin.New()
|
|
engine.GET("/.well-known/assetlinks.json", androidAssetlinks)
|
|
engine.GET("/.well-known/apple-app-site-association", appleAppSiteAssociation)
|
|
engine.GET("/.well-known/microsoft-identity-association.json", microsoftIdentityAssociation)
|
|
|
|
assetlinks := performGet(engine, "/.well-known/assetlinks.json")
|
|
if assetlinks.Code != http.StatusOK {
|
|
t.Fatalf("expected assetlinks 200, got %d", assetlinks.Code)
|
|
}
|
|
var androidPayload []map[string]any
|
|
if err := json.Unmarshal(assetlinks.Body.Bytes(), &androidPayload); err != nil {
|
|
t.Fatalf("invalid android assetlinks JSON: %v", err)
|
|
}
|
|
if androidPayload[0]["target"].(map[string]any)["package_name"] != "com.example.gochat" {
|
|
t.Fatalf("expected android package name, got %s", assetlinks.Body.String())
|
|
}
|
|
|
|
apple := performGet(engine, "/.well-known/apple-app-site-association")
|
|
if apple.Code != http.StatusOK {
|
|
t.Fatalf("expected apple association 200, got %d", apple.Code)
|
|
}
|
|
if !strings.Contains(apple.Body.String(), `"appID":"TEAMID.com.example.gochat"`) ||
|
|
!strings.Contains(apple.Body.String(), `/app/accounts/*/conversations/*`) {
|
|
t.Fatalf("expected apple app association payload, got %s", apple.Body.String())
|
|
}
|
|
|
|
microsoft := performGet(engine, "/.well-known/microsoft-identity-association.json")
|
|
if microsoft.Code != http.StatusOK {
|
|
t.Fatalf("expected microsoft association 200, got %d", microsoft.Code)
|
|
}
|
|
if !strings.Contains(microsoft.Body.String(), `"applicationId":"azure-application-client-id"`) {
|
|
t.Fatalf("expected microsoft application ID, got %s", microsoft.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDashboardIndexServesChatwootShell(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("INSTALLATION_NAME", "GoChat Test")
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test/")
|
|
t.Setenv("HELPCENTER_URL", "https://help.example.test/")
|
|
|
|
engine := gin.New()
|
|
engine.GET("/app", dashboardIndex)
|
|
engine.GET("/app/*params", dashboardIndex)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/app/accounts/1/conversations/42", nil)
|
|
req.Header.Set("Accept", "text/html")
|
|
engine.ServeHTTP(recorder, req)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", recorder.Code)
|
|
}
|
|
body := recorder.Body.String()
|
|
if !strings.Contains(body, `<div id="app"></div>`) {
|
|
t.Fatalf("expected dashboard app mount in response: %s", body)
|
|
}
|
|
if !strings.Contains(body, `"hostURL":"https://app.example.test"`) {
|
|
t.Fatalf("expected frontend URL in chatwoot config: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestDashboardIndexRejectsJSONLikeChatwoot(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
engine := gin.New()
|
|
engine.GET("/app", dashboardIndex)
|
|
engine.GET("/app/*params", dashboardIndex)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, "/app/accounts/1/conversations/42", nil)
|
|
req.Header.Set("Accept", "application/json")
|
|
engine.ServeHTTP(recorder, req)
|
|
|
|
if recorder.Code != http.StatusNotAcceptable {
|
|
t.Fatalf("expected 406, got %d", recorder.Code)
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "Please use API routes instead of dashboard routes for JSON requests") {
|
|
t.Fatalf("expected Chatwoot dashboard JSON error, got %s", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func performGet(engine *gin.Engine, path string) *httptest.ResponseRecorder {
|
|
recorder := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, path, nil)
|
|
engine.ServeHTTP(recorder, req)
|
|
return recorder
|
|
}
|
|
|
|
func performHostGet(engine *gin.Engine, path string, host string) *httptest.ResponseRecorder {
|
|
recorder := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodGet, path, nil)
|
|
req.Host = host
|
|
engine.ServeHTTP(recorder, req)
|
|
return recorder
|
|
}
|
|
|
|
func performFormPost(engine *gin.Engine, path string, form url.Values) *httptest.ResponseRecorder {
|
|
recorder := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
engine.ServeHTTP(recorder, req)
|
|
return recorder
|
|
}
|
|
|
|
func setupRouterPortalDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to open sqlite: %v", err)
|
|
}
|
|
if err := db.AutoMigrate(&model.Portal{}); err != nil {
|
|
t.Fatalf("failed to migrate portal: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func setupRouterIntegrationCallbackDB(t *testing.T) (*gorm.DB, *model.Account) {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to open sqlite: %v", err)
|
|
}
|
|
if err := db.AutoMigrate(&model.Account{}, &model.IntegrationHook{}); err != nil {
|
|
t.Fatalf("failed to migrate integration callback models: %v", err)
|
|
}
|
|
account := &model.Account{Name: "Integration Account", Locale: "en"}
|
|
if err := db.Create(account).Error; err != nil {
|
|
t.Fatalf("failed to create account: %v", err)
|
|
}
|
|
return db, account
|
|
}
|
|
|
|
func setupRouterChannelCallbackDB(t *testing.T) (*gorm.DB, *model.Account) {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to open sqlite: %v", err)
|
|
}
|
|
if err := db.AutoMigrate(
|
|
&model.Account{},
|
|
&model.Inbox{},
|
|
&channelmodel.ChannelEmail{},
|
|
&channelmodel.ChannelInstagram{},
|
|
&channelmodel.ChannelTikTok{},
|
|
&channelmodel.ChannelTwitter{},
|
|
); err != nil {
|
|
t.Fatalf("failed to migrate channel callback models: %v", err)
|
|
}
|
|
account := &model.Account{Name: "Channel Callback Account", Locale: "en"}
|
|
if err := db.Create(account).Error; err != nil {
|
|
t.Fatalf("failed to create account: %v", err)
|
|
}
|
|
return db, account
|
|
}
|
|
|
|
func signedCallbackState(t *testing.T, accountID uint, secret string) string {
|
|
t.Helper()
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"sub": accountID,
|
|
"iat": time.Now().Unix(),
|
|
})
|
|
signed, err := token.SignedString([]byte(secret))
|
|
if err != nil {
|
|
t.Fatalf("failed to sign callback state: %v", err)
|
|
}
|
|
return signed
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return fn(req)
|
|
}
|
|
|
|
func withFakeOAuthTransport(t *testing.T, payloads map[string]map[string]any) {
|
|
t.Helper()
|
|
original := http.DefaultClient
|
|
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.Method != http.MethodPost {
|
|
t.Fatalf("expected token exchange POST, got %s", req.Method)
|
|
}
|
|
if err := req.ParseForm(); err != nil {
|
|
t.Fatalf("failed to parse token exchange form: %v", err)
|
|
}
|
|
if req.Form.Get("code") == "" || req.Form.Get("redirect_uri") == "" {
|
|
t.Fatalf("expected code and redirect_uri in token exchange form: %v", req.Form)
|
|
}
|
|
payload, ok := payloads[req.URL.Path]
|
|
if !ok {
|
|
t.Fatalf("unexpected OAuth token URL path: %s", req.URL.Path)
|
|
}
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("failed to encode fake OAuth payload: %v", err)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(string(encoded))),
|
|
Request: req,
|
|
}, nil
|
|
})}
|
|
t.Cleanup(func() { http.DefaultClient = original })
|
|
}
|
|
|
|
func withFakeCallbackTransport(t *testing.T, bodies map[string]string) {
|
|
t.Helper()
|
|
original := http.DefaultClient
|
|
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.Method != http.MethodPost {
|
|
t.Fatalf("expected callback token exchange POST, got %s", req.Method)
|
|
}
|
|
body, ok := bodies[req.URL.Path]
|
|
if !ok {
|
|
t.Fatalf("unexpected callback token URL path: %s", req.URL.Path)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
Request: req,
|
|
}, nil
|
|
})}
|
|
t.Cleanup(func() { http.DefaultClient = original })
|
|
}
|
|
|
|
func jsonOAuthBody(t *testing.T, payload map[string]any) string {
|
|
t.Helper()
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("failed to encode OAuth payload: %v", err)
|
|
}
|
|
return string(encoded)
|
|
}
|
|
|
|
func idToken(t *testing.T, claims map[string]any) string {
|
|
t.Helper()
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims))
|
|
signed, err := token.SignedString([]byte("test-id-token-secret"))
|
|
if err != nil {
|
|
t.Fatalf("failed to sign id token: %v", err)
|
|
}
|
|
return signed
|
|
}
|
|
|
|
func setupRouterTwilioVoiceDB(t *testing.T) (*gorm.DB, *model.Call) {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to open sqlite: %v", err)
|
|
}
|
|
if err := db.AutoMigrate(&model.Account{}, &model.Inbox{}, &channelmodel.ChannelTwilioSMS{}, &model.Call{}); err != nil {
|
|
t.Fatalf("failed to migrate twilio voice models: %v", err)
|
|
}
|
|
account := &model.Account{Name: "Voice Account", Locale: "en"}
|
|
if err := db.Create(account).Error; err != nil {
|
|
t.Fatalf("failed to create account: %v", err)
|
|
}
|
|
inbox := &model.Inbox{AccountID: account.ID, Name: "Voice", ChannelType: "twilio_sms", ChannelID: 1, ChannelConfig: `{"voice_enabled":true}`}
|
|
if err := db.Create(inbox).Error; err != nil {
|
|
t.Fatalf("failed to create inbox: %v", err)
|
|
}
|
|
channel := &channelmodel.ChannelTwilioSMS{AccountID: account.ID, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567"}
|
|
if err := db.Create(channel).Error; err != nil {
|
|
t.Fatalf("failed to create twilio channel: %v", err)
|
|
}
|
|
call := &model.Call{
|
|
AccountID: account.ID,
|
|
InboxID: inbox.ID,
|
|
ConversationID: 1,
|
|
Provider: "twilio",
|
|
ProviderCallID: "CA123",
|
|
ConferenceSID: "conf_account_1_call_1",
|
|
CallerType: "User",
|
|
CallerID: 1,
|
|
Status: string(model.CallStatusRinging),
|
|
CallDirection: "outbound",
|
|
Direction: "outgoing",
|
|
AdditionalAttributes: json.RawMessage(`{}`),
|
|
AcceptedByAgentID: nil,
|
|
ContactID: 1,
|
|
MessageID: nil,
|
|
RecordingURL: "",
|
|
Duration: 0,
|
|
}
|
|
if err := db.Create(call).Error; err != nil {
|
|
t.Fatalf("failed to create call: %v", err)
|
|
}
|
|
return db, call
|
|
}
|