diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index e7300b525..ca77fa13d 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -53,6 +53,10 @@ class Integrations::Hook < ApplicationRecord app_id == 'dialogflow' end + def notion? + app_id == 'notion' + end + def disable update(status: 'disabled') end diff --git a/config/integration/apps.yml b/config/integration/apps.yml index 1faf35670..e97c37a1b 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -63,6 +63,13 @@ linear: action: https://linear.app/oauth/authorize hook_type: account allow_multiple_hooks: false +notion: + id: notion + logo: notion.png + i18n_key: notion + action: https://api.notion.com/v1/oauth/authorize + hook_type: account + allow_multiple_hooks: false slack: id: slack logo: slack.png diff --git a/config/locales/en.yml b/config/locales/en.yml index 8b392f47f..7d36d168d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -255,6 +255,10 @@ en: name: 'Linear' short_description: 'Create and link Linear issues directly from conversations.' description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.' + notion: + name: 'Notion' + short_description: 'Integrate databases, documents and pages directly with Captain.' + description: 'Connect your Notion workspace to allow captain to generate responses from your databases, documents and pages.' shopify: name: 'Shopify' short_description: 'Access order details and customer data from your Shopify store.' diff --git a/public/dashboard/images/integrations/notion-dark.png b/public/dashboard/images/integrations/notion-dark.png new file mode 100644 index 000000000..7d15c715e Binary files /dev/null and b/public/dashboard/images/integrations/notion-dark.png differ diff --git a/public/dashboard/images/integrations/notion.png b/public/dashboard/images/integrations/notion.png new file mode 100644 index 000000000..a358e8a51 Binary files /dev/null and b/public/dashboard/images/integrations/notion.png differ diff --git a/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb new file mode 100644 index 000000000..ac4bc2841 --- /dev/null +++ b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe 'Notion Authorization API', type: :request do + let(:account) { create(:account) } + + describe 'POST /api/v1/accounts/{account.id}/notion/authorization' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/notion/authorization" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + it 'returns unauthorized for agent' do + post "/api/v1/accounts/#{account.id}/notion/authorization", + headers: agent.create_new_auth_token, + params: { email: administrator.email }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'creates a new authorization and returns the redirect url' do + post "/api/v1/accounts/#{account.id}/notion/authorization", + headers: administrator.create_new_auth_token, + params: { email: administrator.email }, + as: :json + + expect(response).to have_http_status(:success) + + # Validate URL components + url = response.parsed_body['url'] + uri = URI.parse(url) + params = CGI.parse(uri.query) + + expect(url).to start_with('https://api.notion.com/v1/oauth/authorize') + expect(params['response_type']).to eq(['code']) + expect(params['owner']).to eq(['user']) + expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback"]) + + # Validate state parameter exists and can be decoded back to the account + expect(params['state']).to be_present + decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default') + expect(decoded_account).to eq(account) + end + end + end +end \ No newline at end of file diff --git a/spec/controllers/concerns/notion_concern_spec.rb b/spec/controllers/concerns/notion_concern_spec.rb new file mode 100644 index 000000000..927d38a3d --- /dev/null +++ b/spec/controllers/concerns/notion_concern_spec.rb @@ -0,0 +1,62 @@ +require 'rails_helper' + +RSpec.describe NotionConcern, type: :concern do + let(:controller_class) do + Class.new do + include NotionConcern + end + end + + let(:controller) { controller_class.new } + + describe '#notion_client' do + let(:client_id) { 'test_notion_client_id' } + let(:client_secret) { 'test_notion_client_secret' } + + before do + allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil).and_return(client_id) + allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil).and_return(client_secret) + end + + it 'creates OAuth2 client with correct configuration' do + expect(OAuth2::Client).to receive(:new).with( + client_id, + client_secret, + { + site: 'https://api.notion.com', + authorize_url: 'https://api.notion.com/v1/oauth/authorize', + token_url: 'https://api.notion.com/v1/oauth/token', + auth_scheme: :basic_auth + } + ) + + controller.notion_client + end + + it 'loads client credentials from GlobalConfigService' do + expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil) + expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil) + + controller.notion_client + end + + it 'returns OAuth2::Client instance' do + client = controller.notion_client + expect(client).to be_an_instance_of(OAuth2::Client) + end + + it 'configures client with Notion-specific endpoints' do + client = controller.notion_client + expect(client.site).to eq('https://api.notion.com') + expect(client.options[:authorize_url]).to eq('https://api.notion.com/v1/oauth/authorize') + expect(client.options[:token_url]).to eq('https://api.notion.com/v1/oauth/token') + expect(client.options[:auth_scheme]).to eq(:basic_auth) + end + end + + describe '#scope' do + it 'returns read scope for Notion API' do + expect(controller.send(:scope)).to eq('read') + end + end +end \ No newline at end of file diff --git a/spec/controllers/notion/callbacks_controller_spec.rb b/spec/controllers/notion/callbacks_controller_spec.rb new file mode 100644 index 000000000..39366eaf6 --- /dev/null +++ b/spec/controllers/notion/callbacks_controller_spec.rb @@ -0,0 +1,122 @@ +require 'rails_helper' + +RSpec.describe Notion::CallbacksController, type: :controller do + let(:account) { create(:account) } + let(:state) { account.to_sgid.to_s } + let(:oauth_code) { 'test_oauth_code' } + + let(:mock_oauth_client) { instance_double(OAuth2::Client) } + let(:mock_auth_code) { instance_double(OAuth2::Strategy::AuthCode) } + let(:mock_token_response) { instance_double(OAuth2::AccessToken) } + let(:mock_response) { instance_double(Faraday::Response) } + + let(:notion_response_body) do + { + 'access_token' => 'notion_access_token_123', + 'token_type' => 'bearer', + 'workspace_name' => 'Test Workspace', + 'workspace_id' => 'workspace_123', + 'workspace_icon' => 'https://notion.so/icon.png', + 'bot_id' => 'bot_123', + 'owner' => { + 'type' => 'user', + 'user' => { + 'id' => 'user_123', + 'name' => 'Test User' + } + } + } + end + + before do + allow(controller).to receive(:notion_client).and_return(mock_oauth_client) + allow(mock_oauth_client).to receive(:auth_code).and_return(mock_auth_code) + allow(mock_auth_code).to receive(:get_token).and_return(mock_token_response) + allow(mock_token_response).to receive(:response).and_return(mock_response) + allow(mock_response).to receive(:parsed).and_return(notion_response_body) + end + + describe 'GET #show' do + context 'when OAuth callback is successful' do + it 'creates a new integration hook' do + expect do + get :show, params: { code: oauth_code, state: state } + end.to change(Integrations::Hook, :count).by(1) + end + + it 'sets correct hook attributes' do + get :show, params: { code: oauth_code, state: state } + + hook = Integrations::Hook.last + expect(hook.account).to eq(account) + expect(hook.app_id).to eq('notion') + expect(hook.access_token).to eq('notion_access_token_123') + expect(hook.status).to eq('enabled') + end + + it 'stores notion workspace data in settings' do + get :show, params: { code: oauth_code, state: state } + + hook = Integrations::Hook.last + expect(hook.settings['token_type']).to eq('bearer') + expect(hook.settings['workspace_name']).to eq('Test Workspace') + expect(hook.settings['workspace_id']).to eq('workspace_123') + expect(hook.settings['workspace_icon']).to eq('https://notion.so/icon.png') + expect(hook.settings['bot_id']).to eq('bot_123') + expect(hook.settings['owner']).to eq(notion_response_body['owner']) + end + + it 'redirects to integration settings page' do + get :show, params: { code: oauth_code, state: state } + + expect(response).to redirect_to("#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion") + end + + it 'calls the OAuth client with correct parameters' do + expect(mock_auth_code).to receive(:get_token).with( + oauth_code, + redirect_uri: "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback" + ) + + get :show, params: { code: oauth_code, state: state } + end + end + + context 'when hook save fails during handle_response' do + it 'raises error and uses parent exception handling' do + mock_hook = instance_double(Integrations::Hook) + allow(account.hooks).to receive(:new).and_return(mock_hook) + allow(mock_hook).to receive(:save!).and_raise(StandardError, 'Save failed') + + # Parent class handles exceptions with ChatwootExceptionTracker and redirects to '/' + expect(ChatwootExceptionTracker).to receive(:new).and_call_original + + get :show, params: { code: oauth_code, state: state } + + expect(response).to redirect_to('/') + end + end + end + + describe 'provider-specific methods' do + describe '#provider_name' do + it 'returns notion' do + expect(controller.send(:provider_name)).to eq('notion') + end + end + + describe '#oauth_client' do + it 'returns notion_client' do + expect(controller.send(:oauth_client)).to eq(mock_oauth_client) + end + end + + describe '#notion_redirect_uri' do + it 'returns correct redirect URI' do + allow(controller).to receive(:account).and_return(account) + expected_uri = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion" + expect(controller.send(:notion_redirect_uri)).to eq(expected_uri) + end + end + end +end \ No newline at end of file diff --git a/spec/models/integrations/hook_spec.rb b/spec/models/integrations/hook_spec.rb index 098e8b8c3..e6f1beba0 100644 --- a/spec/models/integrations/hook_spec.rb +++ b/spec/models/integrations/hook_spec.rb @@ -78,6 +78,46 @@ RSpec.describe Integrations::Hook do end end + describe 'app type methods' do + let(:account) { create(:account) } + + describe '#slack?' do + it 'returns true for slack integration' do + hook = create(:integrations_hook, account: account, app_id: 'slack') + expect(hook.slack?).to be true + end + + it 'returns false for non-slack integrations' do + hook = create(:integrations_hook, account: account, app_id: 'notion') + expect(hook.slack?).to be false + end + end + + describe '#dialogflow?' do + it 'returns true for dialogflow integration' do + hook = create(:integrations_hook, account: account, app_id: 'dialogflow') + expect(hook.dialogflow?).to be true + end + + it 'returns false for non-dialogflow integrations' do + hook = create(:integrations_hook, account: account, app_id: 'notion') + expect(hook.dialogflow?).to be false + end + end + + describe '#notion?' do + it 'returns true for notion integration' do + hook = create(:integrations_hook, account: account, app_id: 'notion') + expect(hook.notion?).to be true + end + + it 'returns false for non-notion integrations' do + hook = create(:integrations_hook, account: account, app_id: 'slack') + expect(hook.notion?).to be false + end + end + end + describe '#crm_integration?' do let(:account) { create(:account) }