feat(integrations): expose oauth callbacks
This commit is contained in:
@@ -36,7 +36,7 @@ func (h *ShopifyIntegrationHandler) Delete(c *gin.Context) {
|
||||
response.OK(c, gin.H{"message": "Shopify integration deleted"})
|
||||
}
|
||||
|
||||
// Auth creates/updates Shopify OAuth credentials.
|
||||
// Auth returns the Shopify OAuth authorize URL.
|
||||
// POST /api/v1/accounts/:account_id/integrations/shopify/auth
|
||||
func (h *ShopifyIntegrationHandler) Auth(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
@@ -51,12 +51,12 @@ func (h *ShopifyIntegrationHandler) Auth(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
hook, svcErr := h.svc.Auth(c.Request.Context(), accountID, req)
|
||||
redirect, svcErr := h.svc.BuildAuthRedirect(c.Request.Context(), accountID, req)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
response.OK(c, hook)
|
||||
response.OK(c, redirect)
|
||||
}
|
||||
|
||||
// GetOrders retrieves Shopify orders for an account.
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func linearIntegrationCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("LINEAR_CLIENT_SECRET"))
|
||||
redirectURL := integrationRedirectURL("linear", accountID)
|
||||
if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
|
||||
TokenURL: envOrDefault("LINEAR_OAUTH_TOKEN_URL", "https://api.linear.app/oauth/token"),
|
||||
ClientID: os.Getenv("LINEAR_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("LINEAR_CLIENT_SECRET"),
|
||||
Code: c.Query("code"),
|
||||
RedirectURI: frontendBaseURL() + "/linear/callback",
|
||||
})
|
||||
if err != nil || strings.TrimSpace(body["access_token"]) == "" {
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
settings := map[string]any{
|
||||
"token_type": body["token_type"],
|
||||
"scope": body["scope"],
|
||||
"refresh_token": body["refresh_token"],
|
||||
}
|
||||
if body["expires_in"] != "" {
|
||||
settings["expires_in"] = body["expires_in"]
|
||||
if seconds, err := strconv.Atoi(body["expires_in"]); err == nil {
|
||||
settings["expires_on"] = time.Now().UTC().Add(time.Duration(seconds) * time.Second).Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
if err := upsertIntegrationHook(c, db, accountID, model.HookTypeLinear, "", body["access_token"], settings); err != nil {
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
func shopifyIntegrationCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("SHOPIFY_CLIENT_SECRET"))
|
||||
redirectURL := integrationRedirectURL("shopify", accountID)
|
||||
shop := strings.TrimSpace(c.Query("shop"))
|
||||
if !ok || db == nil || c.Query("code") == "" || shop == "" || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, redirectWithError(redirectURL))
|
||||
return
|
||||
}
|
||||
|
||||
tokenURL := os.Getenv("SHOPIFY_OAUTH_TOKEN_URL")
|
||||
if tokenURL == "" {
|
||||
tokenURL = fmt.Sprintf("https://%s/admin/oauth/access_token", shop)
|
||||
}
|
||||
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
|
||||
TokenURL: tokenURL,
|
||||
ClientID: os.Getenv("SHOPIFY_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("SHOPIFY_CLIENT_SECRET"),
|
||||
Code: c.Query("code"),
|
||||
RedirectURI: frontendBaseURL() + "/shopify/callback",
|
||||
})
|
||||
if err != nil || strings.TrimSpace(body["access_token"]) == "" {
|
||||
c.Redirect(http.StatusFound, redirectWithError(redirectURL))
|
||||
return
|
||||
}
|
||||
|
||||
if err := createIntegrationHook(c, db, accountID, model.HookTypeShopify, shop, body["access_token"], map[string]any{"scope": body["scope"]}); err != nil {
|
||||
c.Redirect(http.StatusFound, redirectWithError(redirectURL))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
func notionIntegrationCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("NOTION_CLIENT_SECRET"))
|
||||
redirectURL := integrationRedirectURL("notion", accountID)
|
||||
if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
|
||||
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
|
||||
TokenURL: envOrDefault("NOTION_OAUTH_TOKEN_URL", "https://api.notion.com/v1/oauth/token"),
|
||||
ClientID: os.Getenv("NOTION_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("NOTION_CLIENT_SECRET"),
|
||||
Code: c.Query("code"),
|
||||
RedirectURI: frontendBaseURL() + "/notion/callback",
|
||||
BasicAuth: true,
|
||||
})
|
||||
if err != nil || strings.TrimSpace(body["access_token"]) == "" {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
|
||||
settings := map[string]any{
|
||||
"token_type": body["token_type"],
|
||||
"workspace_name": body["workspace_name"],
|
||||
"workspace_id": body["workspace_id"],
|
||||
"workspace_icon": body["workspace_icon"],
|
||||
"bot_id": body["bot_id"],
|
||||
}
|
||||
if owner := strings.TrimSpace(body["owner"]); owner != "" {
|
||||
settings["owner"] = owner
|
||||
}
|
||||
if err := createIntegrationHook(c, db, accountID, model.HookTypeNotion, "", body["access_token"], settings); err != nil {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
type oauthTokenExchangeRequest struct {
|
||||
TokenURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Code string
|
||||
RedirectURI string
|
||||
BasicAuth bool
|
||||
}
|
||||
|
||||
func exchangeOAuthToken(c *gin.Context, req oauthTokenExchangeRequest) (map[string]string, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", req.Code)
|
||||
form.Set("redirect_uri", req.RedirectURI)
|
||||
if !req.BasicAuth {
|
||||
form.Set("client_id", req.ClientID)
|
||||
form.Set("client_secret", req.ClientSecret)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, req.TokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
if req.BasicAuth {
|
||||
httpReq.SetBasicAuth(req.ClientID, req.ClientSecret)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil, fmt.Errorf("oauth token exchange failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]string, len(raw))
|
||||
for key, value := range raw {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
result[key] = typed
|
||||
case float64:
|
||||
result[key] = strconv.FormatInt(int64(typed), 10)
|
||||
case nil:
|
||||
result[key] = ""
|
||||
default:
|
||||
encoded, _ := json.Marshal(typed)
|
||||
result[key] = string(encoded)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func callbackAccountID(state string, secret string) (uint, bool) {
|
||||
state = strings.TrimSpace(state)
|
||||
if state == "" {
|
||||
return 0, false
|
||||
}
|
||||
if id, err := strconv.ParseUint(state, 10, 64); err == nil && id > 0 {
|
||||
return uint(id), true
|
||||
}
|
||||
if secret == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
token, err := jwt.ParseWithClaims(state, claims, func(token *jwt.Token) (any, error) {
|
||||
if token.Method != jwt.SigningMethodHS256 {
|
||||
return nil, fmt.Errorf("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return 0, false
|
||||
}
|
||||
if sub, ok := claims["sub"].(float64); ok && sub > 0 {
|
||||
return uint(sub), true
|
||||
}
|
||||
if sub, ok := claims["sub"].(string); ok {
|
||||
id, err := strconv.ParseUint(sub, 10, 64)
|
||||
return uint(id), err == nil && id > 0
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func accountExists(c *gin.Context, db *gorm.DB, accountID uint) bool {
|
||||
var count int64
|
||||
if err := db.WithContext(c.Request.Context()).Model(&model.Account{}).Where("id = ?", accountID).Count(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func upsertIntegrationHook(c *gin.Context, db *gorm.DB, accountID uint, hookType model.HookType, referenceID string, accessToken string, settings map[string]any) error {
|
||||
settingsJSON, err := json.Marshal(compactMap(settings))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var hook model.IntegrationHook
|
||||
err = db.WithContext(c.Request.Context()).
|
||||
Where("account_id = ? AND (app_id = ? OR hook_type = ?)", accountID, string(hookType), hookType).
|
||||
First(&hook).Error
|
||||
if err == nil {
|
||||
hook.AppID = string(hookType)
|
||||
hook.HookType = hookType
|
||||
hook.Status = model.HookStatusActive
|
||||
hook.ReferenceID = referenceID
|
||||
hook.AccessToken = accessToken
|
||||
hook.Settings = datatypes.JSON(settingsJSON)
|
||||
return db.WithContext(c.Request.Context()).Save(&hook).Error
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
return createIntegrationHook(c, db, accountID, hookType, referenceID, accessToken, settings)
|
||||
}
|
||||
|
||||
func createIntegrationHook(c *gin.Context, db *gorm.DB, accountID uint, hookType model.HookType, referenceID string, accessToken string, settings map[string]any) error {
|
||||
settingsJSON, err := json.Marshal(compactMap(settings))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hook := &model.IntegrationHook{
|
||||
AccountID: accountID,
|
||||
AppID: string(hookType),
|
||||
HookType: hookType,
|
||||
Status: model.HookStatusActive,
|
||||
AccessToken: accessToken,
|
||||
ReferenceID: referenceID,
|
||||
Settings: datatypes.JSON(settingsJSON),
|
||||
}
|
||||
return db.WithContext(c.Request.Context()).Create(hook).Error
|
||||
}
|
||||
|
||||
func compactMap(values map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
if value == nil || value == "" {
|
||||
continue
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func integrationRedirectURL(app string, accountID uint) string {
|
||||
if accountID == 0 {
|
||||
return frontendBaseURL()
|
||||
}
|
||||
return fmt.Sprintf("%s/app/accounts/%d/settings/integrations/%s", frontendBaseURL(), accountID, app)
|
||||
}
|
||||
|
||||
func redirectWithError(location string) string {
|
||||
if strings.Contains(location, "?") {
|
||||
return location + "&error=true"
|
||||
}
|
||||
return location + "?error=true"
|
||||
}
|
||||
|
||||
func frontendBaseURL() string {
|
||||
return strings.TrimRight(envOrDefault("FRONTEND_URL", "http://localhost:3000"), "/")
|
||||
}
|
||||
|
||||
func envOrDefault(key string, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -207,6 +207,12 @@ func RegisterRoutes(
|
||||
engine.GET("/.well-known/microsoft-identity-association.json", microsoftIdentityAssociation)
|
||||
engine.GET("/.well-known/cf-custom-hostname-challenge/:id", customDomainChallenge(db))
|
||||
|
||||
// Root OAuth callbacks used by Chatwoot integration app redirects.
|
||||
// Reference: Chatwoot routes.rb linear/shopify/notion callback routes.
|
||||
engine.GET("/linear/callback", linearIntegrationCallback(db))
|
||||
engine.GET("/shopify/callback", shopifyIntegrationCallback(db))
|
||||
engine.GET("/notion/callback", notionIntegrationCallback(db))
|
||||
|
||||
// Dashboard shell routes used by Chatwoot mailer and push deep links.
|
||||
// Reference: Chatwoot routes.rb `get '/app'`, `get '/app/*params'` -> DashboardController#index.
|
||||
engine.GET("/app", dashboardIndex)
|
||||
|
||||
@@ -2,17 +2,20 @@ package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
"github.com/gochat/gochat/internal/middleware"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -45,6 +48,9 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
|
||||
"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 /app",
|
||||
"GET /app/*params",
|
||||
"GET /api/v1/accounts/:account_id/captain/assistants/tools",
|
||||
@@ -206,6 +212,106 @@ func TestCustomDomainChallengeMatchesChatwootVerification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 TestWellKnownRoutesServeMobileAssociationPayloads(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("ANDROID_BUNDLE_ID", "com.example.gochat")
|
||||
@@ -331,6 +437,74 @@ func setupRouterPortalDB(t *testing.T) *gorm.DB {
|
||||
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 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 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{
|
||||
|
||||
@@ -4,10 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// ShopifyIntegrationService implements Shopify integration business logic.
|
||||
@@ -28,6 +33,10 @@ type CreateShopifyAuthRequest struct {
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
}
|
||||
|
||||
type ShopifyAuthResponse struct {
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
}
|
||||
|
||||
// Delete removes a Shopify integration hook for an account.
|
||||
func (s *ShopifyIntegrationService) Delete(ctx context.Context, accountID uint) error {
|
||||
hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeShopify)
|
||||
@@ -93,6 +102,40 @@ func (s *ShopifyIntegrationService) Auth(ctx context.Context, accountID uint, re
|
||||
return hook, nil
|
||||
}
|
||||
|
||||
// BuildAuthRedirect returns the Chatwoot-compatible Shopify OAuth authorize URL.
|
||||
// Reference: Api::V1::Accounts::Integrations::ShopifyController#auth.
|
||||
func (s *ShopifyIntegrationService) BuildAuthRedirect(_ context.Context, accountID uint, req CreateShopifyAuthRequest) (*ShopifyAuthResponse, error) {
|
||||
shopDomain := strings.TrimSpace(req.ShopDomain)
|
||||
if shopDomain == "" {
|
||||
return nil, fmt.Errorf("Shop domain is required")
|
||||
}
|
||||
clientID := strings.TrimSpace(os.Getenv("SHOPIFY_CLIENT_ID"))
|
||||
clientSecret := strings.TrimSpace(os.Getenv("SHOPIFY_CLIENT_SECRET"))
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return nil, fmt.Errorf("Shopify OAuth is not configured")
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": accountID,
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
state, err := token.SignedString([]byte(clientSecret))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate Shopify state: %w", err)
|
||||
}
|
||||
|
||||
frontendURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/")
|
||||
if frontendURL == "" {
|
||||
frontendURL = "http://localhost:3000"
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("client_id", clientID)
|
||||
params.Set("scope", "read_customers,read_orders,read_fulfillments")
|
||||
params.Set("redirect_uri", frontendURL+"/shopify/callback")
|
||||
params.Set("state", state)
|
||||
return &ShopifyAuthResponse{RedirectURL: fmt.Sprintf("https://%s/admin/oauth/authorize?%s", shopDomain, params.Encode())}, nil
|
||||
}
|
||||
|
||||
// GetOrders retrieves Shopify orders for an account (proxy to Shopify API).
|
||||
// GET /api/v1/accounts/:account_id/integrations/shopify/orders
|
||||
func (s *ShopifyIntegrationService) GetOrders(ctx context.Context, accountID uint) ([]map[string]interface{}, error) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -87,6 +89,27 @@ func TestShopifyIntegrationService_Auth_CreateNew(t *testing.T) {
|
||||
assert.Len(t, hooks, 1, "one Shopify hook should exist for the account")
|
||||
}
|
||||
|
||||
func TestShopifyIntegrationService_BuildAuthRedirect_ChatwootPayload(t *testing.T) {
|
||||
svc, db := setupShopifyIntegrationService(t)
|
||||
accountID := seedShopifyAccount(db, t)
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test/")
|
||||
t.Setenv("SHOPIFY_CLIENT_ID", "shopify-client")
|
||||
t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret")
|
||||
|
||||
result, err := svc.BuildAuthRedirect(context.Background(), accountID, CreateShopifyAuthRequest{ShopDomain: "my-store.myshopify.com"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, strings.HasPrefix(result.RedirectURL, "https://my-store.myshopify.com/admin/oauth/authorize?"))
|
||||
|
||||
parsed, err := url.Parse(result.RedirectURL)
|
||||
require.NoError(t, err)
|
||||
query := parsed.Query()
|
||||
assert.Equal(t, "shopify-client", query.Get("client_id"))
|
||||
assert.Equal(t, "read_customers,read_orders,read_fulfillments", query.Get("scope"))
|
||||
assert.Equal(t, "https://app.example.test/shopify/callback", query.Get("redirect_uri"))
|
||||
assert.NotEmpty(t, query.Get("state"))
|
||||
}
|
||||
|
||||
func TestShopifyIntegrationService_Auth_UpdateExisting(t *testing.T) {
|
||||
svc, db := setupShopifyIntegrationService(t)
|
||||
accountID := seedShopifyAccount(db, t)
|
||||
@@ -167,4 +190,4 @@ func TestShopifyIntegrationService_Delete_NotFound(t *testing.T) {
|
||||
err := svc.Delete(ctx, accountID)
|
||||
assert.Error(t, err, "Delete should fail when no Shopify integration exists")
|
||||
assert.Contains(t, err.Error(), "not found", "error should indicate integration not found")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user