feat(integrations): align notion authorization
This commit is contained in:
@@ -20,6 +20,23 @@ func NewNotionIntegrationHandler(svc *service.NotionIntegrationService) *NotionI
|
||||
return &NotionIntegrationHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Authorization creates a Notion OAuth authorization URL.
|
||||
// POST /api/v1/accounts/:account_id/notion/authorization
|
||||
func (h *NotionIntegrationHandler) Authorization(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
result, svcErr := h.svc.BuildAuthorizationURL(accountID)
|
||||
if svcErr != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// Delete removes a Notion integration for an account.
|
||||
// DELETE /api/v1/accounts/:account_id/integrations/notion
|
||||
func (h *NotionIntegrationHandler) Delete(c *gin.Context) {
|
||||
|
||||
@@ -35,7 +35,9 @@ func setupNotionIntegrationRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
|
||||
handler := NewNotionIntegrationHandler(service.NewNotionIntegrationService(repository.NewIntegrationHookRepo(db)))
|
||||
|
||||
integrations := r.Group("/api/v1/accounts/:account_id/integrations")
|
||||
account := r.Group("/api/v1/accounts/:account_id")
|
||||
account.POST("/notion/authorization", handler.Authorization)
|
||||
integrations := account.Group("/integrations")
|
||||
RegisterNotionIntegrationRoutes(integrations, handler)
|
||||
|
||||
return r, db
|
||||
@@ -60,6 +62,41 @@ func TestNotionIntegration_Delete_BadAccountID(t *testing.T) {
|
||||
assert.Contains(t, errBody["message"], "invalid account_id")
|
||||
}
|
||||
|
||||
func TestNotionIntegration_Authorization_BadAccountID(t *testing.T) {
|
||||
r, _ := setupNotionIntegrationRouter(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/notion/authorization", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
errBody := resp["error"].(map[string]interface{})
|
||||
assert.Contains(t, errBody["message"], "invalid account_id")
|
||||
}
|
||||
|
||||
func TestNotionIntegration_Authorization_ReturnsChatwootPayload(t *testing.T) {
|
||||
t.Setenv("NOTION_CLIENT_ID", "notion-client")
|
||||
t.Setenv("NOTION_CLIENT_SECRET", "notion-secret")
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test/")
|
||||
r, db := setupNotionIntegrationRouter(t)
|
||||
account := &model.Account{Name: "Test Account"}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/notion/authorization", account.ID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, true, resp["success"])
|
||||
assert.Contains(t, resp["url"], "https://api.notion.com/v1/oauth/authorize")
|
||||
assert.Contains(t, resp["url"], "redirect_uri=https%3A%2F%2Fapp.example.test%2Fnotion%2Fcallback")
|
||||
}
|
||||
|
||||
func TestNotionIntegration_Delete_NoTrailingSlashReturnsEmptyOK(t *testing.T) {
|
||||
r, db := setupNotionIntegrationRouter(t)
|
||||
account := &model.Account{Name: "Test Account"}
|
||||
|
||||
@@ -1579,6 +1579,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
macros.POST("/:macro_id/toggle_active", h.Macro.ToggleActive)
|
||||
}
|
||||
|
||||
// Notion OAuth authorization (ref: Chatwoot namespace :notion resource :authorization)
|
||||
accountScoped.POST("/notion/authorization", middleware.RoleCheck("administrator"), h.NotionIntegration.Authorization)
|
||||
|
||||
// G16: Third-party Integrations — IntegrationHook CRUD + Slack/Shopify/Linear/Notion
|
||||
// Reference: Chatwoot namespace :integrations under :account
|
||||
integrations := accountScoped.Group("/integrations")
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// LinearProviderError mirrors Chatwoot's `{ error: ... }`, 422 provider failures.
|
||||
@@ -594,11 +595,48 @@ type NotionIntegrationService struct {
|
||||
hookRepo *repository.IntegrationHookRepo
|
||||
}
|
||||
|
||||
// NotionAuthorizationResponse mirrors Chatwoot's Notion authorization payload.
|
||||
type NotionAuthorizationResponse struct {
|
||||
Success bool `json:"success"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// NewNotionIntegrationService creates a new NotionIntegrationService.
|
||||
func NewNotionIntegrationService(hookRepo *repository.IntegrationHookRepo) *NotionIntegrationService {
|
||||
return &NotionIntegrationService{hookRepo: hookRepo}
|
||||
}
|
||||
|
||||
// BuildAuthorizationURL returns the Notion OAuth authorize URL for an account.
|
||||
func (s *NotionIntegrationService) BuildAuthorizationURL(accountID uint) (*NotionAuthorizationResponse, error) {
|
||||
clientID := strings.TrimSpace(os.Getenv("NOTION_CLIENT_ID"))
|
||||
clientSecret := strings.TrimSpace(os.Getenv("NOTION_CLIENT_SECRET"))
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return nil, fmt.Errorf("Notion 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 Notion 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("owner", "user")
|
||||
params.Set("redirect_uri", frontendURL+"/notion/callback")
|
||||
params.Set("response_type", "code")
|
||||
params.Set("state", state)
|
||||
|
||||
return &NotionAuthorizationResponse{Success: true, URL: "https://api.notion.com/v1/oauth/authorize?" + params.Encode()}, nil
|
||||
}
|
||||
|
||||
// Delete removes a Notion integration hook for an account.
|
||||
func (s *NotionIntegrationService) Delete(ctx context.Context, accountID uint) error {
|
||||
hooks, err := s.findNotionHooks(ctx, accountID)
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -370,6 +372,48 @@ func TestLinearIntegrationService_GetLinkedIssues_UsesConversationDisplayID(t *t
|
||||
// NotionIntegrationService tests
|
||||
// ========================================
|
||||
|
||||
func TestNotionIntegrationService_BuildAuthorizationURL(t *testing.T) {
|
||||
t.Setenv("NOTION_CLIENT_ID", "notion-client")
|
||||
t.Setenv("NOTION_CLIENT_SECRET", "notion-secret")
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test/")
|
||||
svc, _ := setupNotionService(t)
|
||||
|
||||
resp, err := svc.BuildAuthorizationURL(42)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.True(t, resp.Success)
|
||||
|
||||
parsed, err := url.Parse(resp.URL)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https", parsed.Scheme)
|
||||
assert.Equal(t, "api.notion.com", parsed.Host)
|
||||
assert.Equal(t, "/v1/oauth/authorize", parsed.Path)
|
||||
query := parsed.Query()
|
||||
assert.Equal(t, "notion-client", query.Get("client_id"))
|
||||
assert.Equal(t, "code", query.Get("response_type"))
|
||||
assert.Equal(t, "user", query.Get("owner"))
|
||||
assert.Equal(t, "https://app.example.test/notion/callback", query.Get("redirect_uri"))
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
token, err := jwt.ParseWithClaims(query.Get("state"), claims, func(token *jwt.Token) (any, error) {
|
||||
return []byte("notion-secret"), nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, token.Valid)
|
||||
assert.Equal(t, float64(42), claims["sub"])
|
||||
}
|
||||
|
||||
func TestNotionIntegrationService_BuildAuthorizationURL_NotConfigured(t *testing.T) {
|
||||
t.Setenv("NOTION_CLIENT_ID", "")
|
||||
t.Setenv("NOTION_CLIENT_SECRET", "")
|
||||
svc, _ := setupNotionService(t)
|
||||
|
||||
resp, err := svc.BuildAuthorizationURL(42)
|
||||
assert.Nil(t, resp)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Notion OAuth is not configured")
|
||||
}
|
||||
|
||||
func TestNotionIntegrationService_Delete_Success(t *testing.T) {
|
||||
svc, db := setupNotionService(t)
|
||||
accountID := seedLinearNotionAccount(db, t)
|
||||
|
||||
Reference in New Issue
Block a user