package router import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "strconv" "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 /", "GET /widget", "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 /runtime-config.js", "GET /app", "GET /app/*params", "GET /api/v1/connector/shangwutong/inboxes", "GET /api/v1/connector/shangwutong/inboxes/:inbox_id", "PUT /api/v1/connector/shangwutong/inboxes/:inbox_id/status", "PUT /api/v1/connector/shangwutong/inboxes/:inbox_id/messages/:message_id/status", "GET /api/v1/accounts/:account_id/captain/assistants/tools", "GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id", "PATCH /api/v1/accounts/:account_id/captain/assistants/:assistant_id", "GET /api/v1/accounts/:account_id/captain/skills", "POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/skills/:skill_id", "DELETE /api/v1/accounts/:account_id/captain/assistants/:assistant_id/skills/:skill_id", "PATCH /api/v1/accounts/:account_id/captain/assistant_responses/:response_id", "PATCH /api/v1/accounts/:account_id/automation_rules/:automation_id", "PATCH /api/v1/accounts/:account_id/labels/:tag_id", "PATCH /api/v1/accounts/:account_id/macros/:macro_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 /api/v1/accounts/:account_id/events", "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 TestAccountManagementRoutesRejectUntrustedIdentityAndAgentAccess(t *testing.T) { gin.SetMode(gin.TestMode) jwtCfg := &config.JWTConfig{Secret: "route-security-test-secret", ExpiryHours: 1, RefreshExpiryHours: 24} jwtSvc := auth.NewJWTService(jwtCfg) tokens, err := jwtSvc.GenerateTokenPair(&model.User{ Base: model.Base{ID: 7}, Provider: "email", }, 1, "agent") if err != nil { t.Fatalf("generate token: %v", err) } engine := gin.New() RegisterRoutes(engine, jwtSvc, nil, nil, &Handlers{}, nil, nil, jwtCfg, middleware.CORSConfig{}, nil) cases := []struct { name string method string path string token string headers bool wantStatus int }{ {name: "release headers only", method: http.MethodGet, path: "/api/v1/accounts/all", headers: true, wantStatus: http.StatusUnauthorized}, {name: "agent lists all accounts", method: http.MethodGet, path: "/api/v1/accounts/all", token: tokens.AccessToken, wantStatus: http.StatusForbidden}, {name: "agent uses platform administration", method: http.MethodGet, path: "/platform/api/v1/apps", token: tokens.AccessToken, wantStatus: http.StatusForbidden}, {name: "agent updates account", method: http.MethodPatch, path: "/api/v1/accounts/1", token: tokens.AccessToken, wantStatus: http.StatusForbidden}, {name: "agent updates settings", method: http.MethodPut, path: "/api/v1/accounts/1/settings", token: tokens.AccessToken, wantStatus: http.StatusForbidden}, {name: "agent deletes account", method: http.MethodDelete, path: "/api/v1/accounts/1", token: tokens.AccessToken, wantStatus: http.StatusForbidden}, {name: "cross tenant update", method: http.MethodPatch, path: "/api/v1/accounts/2", token: tokens.AccessToken, wantStatus: http.StatusForbidden}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest(tc.method, tc.path, nil) if tc.token != "" { req.Header.Set("access-token", tc.token) } if tc.headers { req.Header.Set("X-User-ID", "7") req.Header.Set("X-Account-ID", "1") } engine.ServeHTTP(w, req) if w.Code != tc.wantStatus { t.Fatalf("expected %d, got %d: %s", tc.wantStatus, w.Code, w.Body.String()) } }) } } func TestPlatformListsAcceptSuperAdminOrPlatformAppButWritesRequirePlatformApp(t *testing.T) { gin.SetMode(gin.TestMode) 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("open db: %v", err) } if err := db.AutoMigrate(&model.User{}, &model.Account{}, &model.PlatformApp{}, &model.AccessToken{}, &model.Permissible{}); err != nil { t.Fatalf("migrate: %v", err) } accountRepo := repository.NewAccountRepo(db) userRepo := repository.NewUserRepo(db) permissibleRepo := repository.NewPermissibleRepo(db) accountHandler := v1.NewPlatformAccountHandler(accountRepo, permissibleRepo, service.NewAccountService(accountRepo)) userHandler := v1.NewPlatformUserHandler(service.NewPlatformUserService(userRepo, permissibleRepo)) accounts := []model.Account{{Name: "Permitted"}, {Name: "Hidden"}} for i := range accounts { requireRouterCreate(t, db, &accounts[i]) } users := []model.User{ {Name: "Super Admin", Email: "admin@example.test", Provider: "email", Role: "super_admin", Active: true}, {Name: "Agent", Email: "agent@example.test", Provider: "email", Active: true}, {Name: "Permitted", Email: "permitted@example.test", Provider: "email", Active: true}, {Name: "Hidden", Email: "hidden@example.test", Provider: "email", Active: true}, } for i := range users { requireRouterCreate(t, db, &users[i]) } active := true platformApp := model.PlatformApp{Name: "Test App", Active: &active, Status: "active"} requireRouterCreate(t, db, &platformApp) rawPlatformToken := "platform-route-token" hash := sha256.Sum256([]byte(rawPlatformToken)) requireRouterCreate(t, db, &model.AccessToken{ OwnerType: model.AccessTokenOwnerTypePlatformApp, OwnerID: platformApp.ID, Token: hex.EncodeToString(hash[:]), TokenPrefix: rawPlatformToken[:8], }) for _, permissible := range []model.Permissible{ {PlatformAppID: platformApp.ID, PermissibleType: model.PermissibleTypeAccount, PermissibleID: accounts[0].ID}, {PlatformAppID: platformApp.ID, PermissibleType: model.PermissibleTypeUser, PermissibleID: users[2].ID}, } { requireRouterCreate(t, db, &permissible) } jwtCfg := &config.JWTConfig{Secret: "platform-route-secret", ExpiryHours: 1, RefreshExpiryHours: 24} jwtSvc := auth.NewJWTService(jwtCfg) superAdminToken, err := jwtSvc.GenerateTokenPair(&users[0], 1, "super_admin") if err != nil { t.Fatalf("generate super admin token: %v", err) } agentToken, err := jwtSvc.GenerateTokenPair(&users[1], 1, "agent") if err != nil { t.Fatalf("generate agent token: %v", err) } engine := gin.New() RegisterRoutes(engine, jwtSvc, nil, nil, &Handlers{ PlatformAccount: accountHandler, PlatformUser: userHandler, }, nil, nil, jwtCfg, middleware.CORSConfig{}, db) for _, path := range []string{"/platform/api/v1/accounts", "/platform/api/v1/users"} { t.Run(path, func(t *testing.T) { globalCount, permissibleID := len(users), users[2].ID if path == "/platform/api/v1/accounts" { globalCount, permissibleID = len(accounts), accounts[0].ID } for _, tc := range []struct { name, header, token string wantStatus int wantCount int wantID uint }{ {name: "super admin", header: "access-token", token: superAdminToken.AccessToken, wantStatus: http.StatusOK, wantCount: globalCount}, {name: "platform app", header: "api_access_token", token: rawPlatformToken, wantStatus: http.StatusOK, wantCount: 1, wantID: permissibleID}, {name: "regular user", header: "access-token", token: agentToken.AccessToken, wantStatus: http.StatusForbidden}, {name: "no credentials", wantStatus: http.StatusUnauthorized}, } { t.Run(tc.name, func(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, path, nil) if tc.header != "" { req.Header.Set(tc.header, tc.token) } engine.ServeHTTP(w, req) if w.Code != tc.wantStatus { t.Fatalf("expected %d, got %d: %s", tc.wantStatus, w.Code, w.Body.String()) } if tc.wantStatus != http.StatusOK { return } var body struct { Data []struct { ID uint `json:"id"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } if len(body.Data) != tc.wantCount { t.Fatalf("expected %d resources, got %d: %s", tc.wantCount, len(body.Data), w.Body.String()) } if tc.wantID != 0 && body.Data[0].ID != tc.wantID { t.Fatalf("expected permissible resource %d, got %d", tc.wantID, body.Data[0].ID) } }) } }) } var before int64 if err := db.Model(&model.Account{}).Count(&before).Error; err != nil { t.Fatalf("count accounts: %v", err) } w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/platform/api/v1/accounts", strings.NewReader(`{"name":"Must Not Exist"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("access-token", superAdminToken.AccessToken) engine.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("expected Super Admin write to require PlatformApp auth, got %d: %s", w.Code, w.Body.String()) } var after int64 if err := db.Model(&model.Account{}).Count(&after).Error; err != nil { t.Fatalf("count accounts after rejected write: %v", err) } if after != before { t.Fatalf("rejected Super Admin write created an account: before=%d after=%d", before, after) } } 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.User{}, &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}, Name: "Agent", Provider: "email", Email: "agent@example.com", Active: true} if err := db.Create(user).Error; err != nil { t.Fatalf("create user: %v", err) } 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, ) cases := []struct { method string path string }{ {method: http.MethodPost, path: "/webhooks/telegram/bot-token"}, {method: http.MethodGet, path: "/api/v1/connector/shangwutong/inboxes"}, } for _, tc := range cases { w := httptest.NewRecorder() req := httptest.NewRequest(tc.method, tc.path, nil) engine.ServeHTTP(w, req) if w.Code != http.StatusServiceUnavailable && w.Code != http.StatusUnauthorized { t.Fatalf("%s %s expected unavailable/auth failure, got %d", tc.method, tc.path, 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(), "`), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dist, "assets", "app.css"), []byte("body{}"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dist, "assets", "app.js"), []byte(`document.documentElement.dataset.runtimeHost = window.__GOCHAT_CONFIG__?.hostURL || "missing";`), 0o644); err != nil { t.Fatal(err) } favicon := []byte("gochat favicon") if err := os.WriteFile(filepath.Join(dist, "favicon.ico"), favicon, 0o644); err != nil { t.Fatal(err) } t.Setenv("GOCHAT_FRONTEND_DIST", dist) 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("/runtime-config.js", dashboardRuntimeConfig) engine.GET("/app", dashboardIndex) engine.GET("/app/*params", dashboardIndex) engine.NoRoute(dashboardStatic) 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, `
`) { t.Fatalf("expected dashboard app mount in response: %s", body) } if !strings.Contains(body, `src="/runtime-config.js"`) || !strings.Contains(body, `href="/assets/app.css"`) { t.Fatalf("expected external config and built asset links: %s", body) } runtimeConfig := performGet(engine, "/runtime-config.js") if runtimeConfig.Code != http.StatusOK || !strings.Contains(runtimeConfig.Header().Get("Content-Type"), "application/javascript") { t.Fatalf("expected JavaScript runtime config, got %d %q", runtimeConfig.Code, runtimeConfig.Header().Get("Content-Type")) } if !strings.Contains(runtimeConfig.Body.String(), `"hostURL":"https://app.example.test"`) || !strings.Contains(runtimeConfig.Body.String(), `"INSTALLATION_NAME":"GoChat Test"`) || !strings.Contains(runtimeConfig.Body.String(), `"BRAND_NAME":"GoChat Test"`) || !strings.Contains(runtimeConfig.Body.String(), `"LOGO":"/brand-assets/logo.svg"`) { t.Fatalf("expected environment-backed runtime config: %s", runtimeConfig.Body.String()) } if strings.Contains(runtimeConfig.Body.String(), "