mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	feat: Linear OAuth 2.0 (#10851)
Fixes https://linear.app/chatwoot/issue/CW-3417/oauth-20-authentication We are planning to publish the Chatwoot app in the Linear [integration list](https://linear.app/docs/integration-directory). While we currently use token-based authentication, Linear recommends OAuth2 authentication. This PR implements OAuth2 support. --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
		| @@ -155,6 +155,10 @@ TWITTER_ENVIRONMENT= | ||||
| SLACK_CLIENT_ID= | ||||
| SLACK_CLIENT_SECRET= | ||||
|  | ||||
| #Linear Integration | ||||
| LINEAR_CLIENT_ID= | ||||
| LINEAR_CLIENT_SECRET= | ||||
|  | ||||
| # Google OAuth | ||||
| GOOGLE_OAUTH_CLIENT_ID= | ||||
| GOOGLE_OAUTH_CLIENT_SECRET= | ||||
|   | ||||
| @@ -1,5 +1,11 @@ | ||||
| class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController | ||||
|   before_action :fetch_conversation, only: [:link_issue, :linked_issues] | ||||
|   before_action :fetch_hook, only: [:destroy] | ||||
|  | ||||
|   def destroy | ||||
|     @hook.destroy! | ||||
|     head :ok | ||||
|   end | ||||
|  | ||||
|   def teams | ||||
|     teams = linear_processor_service.teams | ||||
| @@ -90,4 +96,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas | ||||
|   def permitted_params | ||||
|     params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) | ||||
|   end | ||||
|  | ||||
|   def fetch_hook | ||||
|     @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear') | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										70
									
								
								app/controllers/linear/callbacks_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								app/controllers/linear/callbacks_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| class Linear::CallbacksController < ApplicationController | ||||
|   include Linear::IntegrationHelper | ||||
|  | ||||
|   def show | ||||
|     @response = oauth_client.auth_code.get_token( | ||||
|       params[:code], | ||||
|       redirect_uri: "#{base_url}/linear/callback" | ||||
|     ) | ||||
|  | ||||
|     handle_response | ||||
|   rescue StandardError => e | ||||
|     Rails.logger.error("Linear callback error: #{e.message}") | ||||
|     redirect_to linear_redirect_uri | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def oauth_client | ||||
|     OAuth2::Client.new( | ||||
|       ENV.fetch('LINEAR_CLIENT_ID', nil), | ||||
|       ENV.fetch('LINEAR_CLIENT_SECRET', nil), | ||||
|       { | ||||
|         site: 'https://api.linear.app', | ||||
|         token_url: '/oauth/token', | ||||
|         authorize_url: '/oauth/authorize' | ||||
|       } | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def handle_response | ||||
|     hook = account.hooks.new( | ||||
|       access_token: parsed_body['access_token'], | ||||
|       status: 'enabled', | ||||
|       app_id: 'linear', | ||||
|       settings: { | ||||
|         token_type: parsed_body['token_type'], | ||||
|         expires_in: parsed_body['expires_in'], | ||||
|         scope: parsed_body['scope'] | ||||
|       } | ||||
|     ) | ||||
|     # You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251 | ||||
|     hook.save! | ||||
|     redirect_to linear_redirect_uri | ||||
|   rescue StandardError => e | ||||
|     Rails.logger.error("Linear callback error: #{e.message}") | ||||
|     redirect_to linear_redirect_uri | ||||
|   end | ||||
|  | ||||
|   def account | ||||
|     @account ||= Account.find(account_id) | ||||
|   end | ||||
|  | ||||
|   def account_id | ||||
|     return unless params[:state] | ||||
|  | ||||
|     verify_linear_token(params[:state]) | ||||
|   end | ||||
|  | ||||
|   def linear_redirect_uri | ||||
|     "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear" | ||||
|   end | ||||
|  | ||||
|   def parsed_body | ||||
|     @parsed_body ||= @response.response.parsed | ||||
|   end | ||||
|  | ||||
|   def base_url | ||||
|     ENV.fetch('FRONTEND_URL', 'http://localhost:3000') | ||||
|   end | ||||
| end | ||||
							
								
								
									
										47
									
								
								app/helpers/linear/integration_helper.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								app/helpers/linear/integration_helper.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| module Linear::IntegrationHelper | ||||
|   # Generates a signed JWT token for Linear integration | ||||
|   # | ||||
|   # @param account_id [Integer] The account ID to encode in the token | ||||
|   # @return [String, nil] The encoded JWT token or nil if client secret is missing | ||||
|   def generate_linear_token(account_id) | ||||
|     return if client_secret.blank? | ||||
|  | ||||
|     JWT.encode(token_payload(account_id), client_secret, 'HS256') | ||||
|   rescue StandardError => e | ||||
|     Rails.logger.error("Failed to generate Linear token: #{e.message}") | ||||
|     nil | ||||
|   end | ||||
|  | ||||
|   def token_payload(account_id) | ||||
|     { | ||||
|       sub: account_id, | ||||
|       iat: Time.current.to_i | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   # Verifies and decodes a Linear JWT token | ||||
|   # | ||||
|   # @param token [String] The JWT token to verify | ||||
|   # @return [Integer, nil] The account ID from the token or nil if invalid | ||||
|   def verify_linear_token(token) | ||||
|     return if token.blank? || client_secret.blank? | ||||
|  | ||||
|     decode_token(token, client_secret) | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def client_secret | ||||
|     @client_secret ||= ENV.fetch('LINEAR_CLIENT_SECRET', nil) | ||||
|   end | ||||
|  | ||||
|   def decode_token(token, secret) | ||||
|     JWT.decode(token, secret, true, { | ||||
|                  algorithm: 'HS256', | ||||
|                  verify_expiration: true | ||||
|                }).first['sub'] | ||||
|   rescue StandardError => e | ||||
|     Rails.logger.error("Unexpected error verifying Linear token: #{e.message}") | ||||
|     nil | ||||
|   end | ||||
| end | ||||
| @@ -297,6 +297,12 @@ | ||||
|         "TITLE": "Unlink", | ||||
|         "SUCCESS": "Issue unlinked successfully", | ||||
|         "ERROR": "There was an error unlinking the issue, please try again" | ||||
|       }, | ||||
|       "DELETE": { | ||||
|         "TITLE": "Are you sure you want to delete the integration?", | ||||
|         "MESSAGE": "Are you sure you want to delete the integration?", | ||||
|         "CONFIRM": "Yes, delete", | ||||
|         "CANCEL": "Cancel" | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   | ||||
| @@ -0,0 +1,66 @@ | ||||
| <script setup> | ||||
| import { ref, computed, onMounted } from 'vue'; | ||||
| import { | ||||
|   useFunctionGetter, | ||||
|   useMapGetter, | ||||
|   useStore, | ||||
| } from 'dashboard/composables/store'; | ||||
|  | ||||
| import Integration from './Integration.vue'; | ||||
| import Spinner from 'shared/components/Spinner.vue'; | ||||
|  | ||||
| const store = useStore(); | ||||
|  | ||||
| const integrationLoaded = ref(false); | ||||
|  | ||||
| const integration = useFunctionGetter('integrations/getIntegration', 'linear'); | ||||
|  | ||||
| const uiFlags = useMapGetter('integrations/getUIFlags'); | ||||
|  | ||||
| const integrationAction = computed(() => { | ||||
|   if (integration.value.enabled) { | ||||
|     return 'disconnect'; | ||||
|   } | ||||
|   return integration.value.action; | ||||
| }); | ||||
|  | ||||
| const initializeLinearIntegration = async () => { | ||||
|   await store.dispatch('integrations/get', 'linear'); | ||||
|   integrationLoaded.value = true; | ||||
| }; | ||||
|  | ||||
| onMounted(() => { | ||||
|   initializeLinearIntegration(); | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div class="flex-grow flex-shrink p-4 overflow-auto"> | ||||
|     <div class="flex flex-col"> | ||||
|       <div class="flex flex-col"> | ||||
|         <div> | ||||
|           <div | ||||
|             v-if="integrationLoaded && !uiFlags.isCreatingLinear" | ||||
|             class="p-4 mb-4 bg-white border border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50" | ||||
|           > | ||||
|             <Integration | ||||
|               :integration-id="integration.id" | ||||
|               :integration-logo="integration.logo" | ||||
|               :integration-name="integration.name" | ||||
|               :integration-description="integration.description" | ||||
|               :integration-enabled="integration.enabled" | ||||
|               :integration-action="integrationAction" | ||||
|               :delete-confirmation-text="{ | ||||
|                 title: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.TITLE'), | ||||
|                 message: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.MESSAGE'), | ||||
|               }" | ||||
|             /> | ||||
|           </div> | ||||
|           <div v-else class="flex items-center justify-center flex-1"> | ||||
|             <Spinner size="" color-scheme="primary" /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -7,7 +7,7 @@ import Webhook from './Webhooks/Index.vue'; | ||||
| import DashboardApps from './DashboardApps/Index.vue'; | ||||
| import Slack from './Slack.vue'; | ||||
| import SettingsContent from '../Wrapper.vue'; | ||||
|  | ||||
| import Linear from './Linear.vue'; | ||||
| export default { | ||||
|   routes: [ | ||||
|     { | ||||
| @@ -71,6 +71,15 @@ export default { | ||||
|           }, | ||||
|           props: route => ({ code: route.query.code }), | ||||
|         }, | ||||
|         { | ||||
|           path: 'linear', | ||||
|           name: 'settings_integrations_linear', | ||||
|           component: Linear, | ||||
|           meta: { | ||||
|             permissions: ['administrator'], | ||||
|           }, | ||||
|           props: route => ({ code: route.query.code }), | ||||
|         }, | ||||
|         { | ||||
|           path: ':integration_id', | ||||
|           name: 'settings_applications_integration', | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| class Integrations::App | ||||
|   include Linear::IntegrationHelper | ||||
|   attr_accessor :params | ||||
|  | ||||
|   def initialize(params) | ||||
| @@ -25,10 +26,18 @@ class Integrations::App | ||||
|     params[:fields] | ||||
|   end | ||||
|  | ||||
|   # There is no way to get the account_id from the linear callback | ||||
|   # so we are using the generate_linear_token method to generate a token and encode it in the state parameter | ||||
|   def encode_state | ||||
|     generate_linear_token(Current.account.id) | ||||
|   end | ||||
|  | ||||
|   def action | ||||
|     case params[:id] | ||||
|     when 'slack' | ||||
|       "#{params[:action]}&client_id=#{ENV.fetch('SLACK_CLIENT_ID', nil)}&redirect_uri=#{self.class.slack_integration_url}" | ||||
|     when 'linear' | ||||
|       build_linear_action | ||||
|     else | ||||
|       params[:action] | ||||
|     end | ||||
| @@ -45,6 +54,17 @@ class Integrations::App | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def build_linear_action | ||||
|     [ | ||||
|       "#{params[:action]}?response_type=code", | ||||
|       "client_id=#{ENV.fetch('LINEAR_CLIENT_ID', nil)}", | ||||
|       "redirect_uri=#{self.class.linear_integration_url}", | ||||
|       "state=#{encode_state}", | ||||
|       'scope=read,write', | ||||
|       'prompt=consent' | ||||
|     ].join('&') | ||||
|   end | ||||
|  | ||||
|   def enabled?(account) | ||||
|     case params[:id] | ||||
|     when 'webhook' | ||||
| @@ -64,6 +84,10 @@ class Integrations::App | ||||
|     "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/integrations/slack" | ||||
|   end | ||||
|  | ||||
|   def self.linear_integration_url | ||||
|     "#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback" | ||||
|   end | ||||
|  | ||||
|   class << self | ||||
|     def apps | ||||
|       Hashie::Mash.new(APPS_CONFIG) | ||||
|   | ||||
| @@ -0,0 +1 @@ | ||||
| json.partial! 'api/v1/models/app', formats: [:json], resource: @hook.app | ||||
| @@ -59,26 +59,9 @@ linear: | ||||
|   id: linear | ||||
|   logo: linear.png | ||||
|   i18n_key: linear | ||||
|   action: /linear | ||||
|   action: https://linear.app/oauth/authorize | ||||
|   hook_type: account | ||||
|   allow_multiple_hooks: false | ||||
|   settings_json_schema: | ||||
|     { | ||||
|       'type': 'object', | ||||
|       'properties': { 'api_key': { 'type': 'string' } }, | ||||
|       'required': ['api_key'], | ||||
|       'additionalProperties': false, | ||||
|     } | ||||
|   settings_form_schema: | ||||
|     [ | ||||
|       { | ||||
|         'label': 'API Key', | ||||
|         'type': 'text', | ||||
|         'name': 'api_key', | ||||
|         'validation': 'required', | ||||
|       }, | ||||
|     ] | ||||
|   visible_properties: [] | ||||
| slack: | ||||
|   id: slack | ||||
|   logo: slack.png | ||||
|   | ||||
| @@ -233,6 +233,7 @@ Rails.application.routes.draw do | ||||
|             end | ||||
|             resource :linear, controller: 'linear', only: [] do | ||||
|               collection do | ||||
|                 delete :destroy | ||||
|                 get :teams | ||||
|                 get :team_entities | ||||
|                 post :create_issue | ||||
| @@ -444,6 +445,10 @@ Rails.application.routes.draw do | ||||
|     resource :callback, only: [:show] | ||||
|   end | ||||
|  | ||||
|   namespace :linear do | ||||
|     resource :callback, only: [:show] | ||||
|   end | ||||
|  | ||||
|   namespace :twilio do | ||||
|     resources :callback, only: [:create] | ||||
|     resources :delivery_status, only: [:create] | ||||
|   | ||||
| @@ -76,7 +76,6 @@ class Integrations::Linear::ProcessorService | ||||
|   end | ||||
|  | ||||
|   def linear_client | ||||
|     credentials = linear_hook.settings | ||||
|     @linear_client ||= Linear.new(credentials['api_key']) | ||||
|     @linear_client ||= Linear.new(linear_hook.access_token) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -2,9 +2,9 @@ class Linear | ||||
|   BASE_URL = 'https://api.linear.app/graphql'.freeze | ||||
|   PRIORITY_LEVELS = (0..4).to_a | ||||
|  | ||||
|   def initialize(api_key) | ||||
|     @api_key = api_key | ||||
|     raise ArgumentError, 'Missing Credentials' if api_key.blank? | ||||
|   def initialize(access_token) | ||||
|     @access_token = access_token | ||||
|     raise ArgumentError, 'Missing Credentials' if access_token.blank? | ||||
|   end | ||||
|  | ||||
|   def teams | ||||
| @@ -108,7 +108,7 @@ class Linear | ||||
|   def post(payload) | ||||
|     HTTParty.post( | ||||
|       BASE_URL, | ||||
|       headers: { 'Authorization' => @api_key, 'Content-Type' => 'application/json' }, | ||||
|       headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' }, | ||||
|       body: payload.to_json | ||||
|     ) | ||||
|   end | ||||
|   | ||||
| @@ -12,6 +12,16 @@ RSpec.describe 'Linear Integration API', type: :request do | ||||
|     allow(Integrations::Linear::ProcessorService).to receive(:new).with(account: account).and_return(processor_service) | ||||
|   end | ||||
|  | ||||
|   describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do | ||||
|     it 'deletes the linear integration' do | ||||
|       delete "/api/v1/accounts/#{account.id}/integrations/linear", | ||||
|              headers: agent.create_new_auth_token, | ||||
|              as: :json | ||||
|       expect(response).to have_http_status(:ok) | ||||
|       expect(account.hooks.count).to eq(0) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do | ||||
|     context 'when it is an authenticated user' do | ||||
|       context 'when data is retrieved successfully' do | ||||
|   | ||||
							
								
								
									
										88
									
								
								spec/controllers/linear/callbacks_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								spec/controllers/linear/callbacks_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe Linear::CallbacksController, type: :request do | ||||
|   let(:account) { create(:account) } | ||||
|   let(:code) { SecureRandom.hex(10) } | ||||
|   let(:state) { SecureRandom.hex(10) } | ||||
|   let(:linear_redirect_uri) { "#{ENV.fetch('FRONTEND_URL', '')}/app/accounts/#{account.id}/settings/integrations/linear" } | ||||
|  | ||||
|   describe 'GET /linear/callback' do | ||||
|     let(:access_token) { SecureRandom.hex(10) } | ||||
|     let(:response_body) do | ||||
|       { | ||||
|         'access_token' => access_token, | ||||
|         'token_type' => 'Bearer', | ||||
|         'expires_in' => 7200, | ||||
|         'scope' => 'read,write' | ||||
|       } | ||||
|     end | ||||
|  | ||||
|     before do | ||||
|       stub_const('ENV', ENV.to_hash.merge('FRONTEND_URL' => 'http://www.example.com')) | ||||
|  | ||||
|       controller = described_class.new | ||||
|       allow(controller).to receive(:verify_linear_token).with(state).and_return(account.id) | ||||
|       allow(described_class).to receive(:new).and_return(controller) | ||||
|     end | ||||
|  | ||||
|     context 'when successful' do | ||||
|       before do | ||||
|         stub_request(:post, 'https://api.linear.app/oauth/token') | ||||
|           .to_return( | ||||
|             status: 200, | ||||
|             body: response_body.to_json, | ||||
|             headers: { 'Content-Type' => 'application/json' } | ||||
|           ) | ||||
|       end | ||||
|  | ||||
|       it 'creates a new integration hook' do | ||||
|         expect do | ||||
|           get linear_callback_path, params: { code: code, state: state } | ||||
|         end.to change(Integrations::Hook, :count).by(1) | ||||
|  | ||||
|         hook = Integrations::Hook.last | ||||
|         expect(hook.access_token).to eq(access_token) | ||||
|         expect(hook.app_id).to eq('linear') | ||||
|         expect(hook.status).to eq('enabled') | ||||
|         expect(hook.settings).to eq( | ||||
|           'token_type' => 'Bearer', | ||||
|           'expires_in' => 7200, | ||||
|           'scope' => 'read,write' | ||||
|         ) | ||||
|         expect(response).to redirect_to(linear_redirect_uri) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when the code is missing' do | ||||
|       before do | ||||
|         stub_request(:post, 'https://api.linear.app/oauth/token') | ||||
|           .to_return( | ||||
|             status: 200, | ||||
|             body: response_body.to_json, | ||||
|             headers: { 'Content-Type' => 'application/json' } | ||||
|           ) | ||||
|       end | ||||
|  | ||||
|       it 'redirects to the linear_redirect_uri' do | ||||
|         get linear_callback_path, params: { state: state } | ||||
|         expect(response).to redirect_to(linear_redirect_uri) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when the token is invalid' do | ||||
|       before do | ||||
|         stub_request(:post, 'https://api.linear.app/oauth/token') | ||||
|           .to_return( | ||||
|             status: 400, | ||||
|             body: { error: 'invalid_grant' }.to_json, | ||||
|             headers: { 'Content-Type' => 'application/json' } | ||||
|           ) | ||||
|       end | ||||
|  | ||||
|       it 'redirects to the linear_redirect_uri' do | ||||
|         get linear_callback_path, params: { code: code, state: state } | ||||
|         expect(response).to redirect_to(linear_redirect_uri) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -29,7 +29,7 @@ FactoryBot.define do | ||||
|  | ||||
|     trait :linear do | ||||
|       app_id { 'linear' } | ||||
|       settings { { api_key: 'api_key' } } | ||||
|       access_token { SecureRandom.hex } | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										81
									
								
								spec/helpers/linear/integration_helper_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								spec/helpers/linear/integration_helper_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe Linear::IntegrationHelper do | ||||
|   include described_class | ||||
|  | ||||
|   describe '#generate_linear_token' do | ||||
|     let(:account_id) { 1 } | ||||
|     let(:client_secret) { 'test_secret' } | ||||
|     let(:current_time) { Time.current } | ||||
|  | ||||
|     before do | ||||
|       allow(ENV).to receive(:fetch).with('LINEAR_CLIENT_SECRET', nil).and_return(client_secret) | ||||
|       allow(Time).to receive(:current).and_return(current_time) | ||||
|     end | ||||
|  | ||||
|     it 'generates a valid JWT token with correct payload' do | ||||
|       token = generate_linear_token(account_id) | ||||
|       decoded_token = JWT.decode(token, client_secret, true, algorithm: 'HS256').first | ||||
|  | ||||
|       expect(decoded_token['sub']).to eq(account_id) | ||||
|       expect(decoded_token['iat']).to eq(current_time.to_i) | ||||
|     end | ||||
|  | ||||
|     context 'when client secret is not configured' do | ||||
|       let(:client_secret) { nil } | ||||
|  | ||||
|       it 'returns nil' do | ||||
|         expect(generate_linear_token(account_id)).to be_nil | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when an error occurs' do | ||||
|       before do | ||||
|         allow(JWT).to receive(:encode).and_raise(StandardError.new('Test error')) | ||||
|       end | ||||
|  | ||||
|       it 'logs the error and returns nil' do | ||||
|         expect(Rails.logger).to receive(:error).with('Failed to generate Linear token: Test error') | ||||
|         expect(generate_linear_token(account_id)).to be_nil | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe '#verify_linear_token' do | ||||
|     let(:account_id) { 1 } | ||||
|     let(:client_secret) { 'test_secret' } | ||||
|     let(:valid_token) do | ||||
|       JWT.encode({ sub: account_id, iat: Time.current.to_i }, client_secret, 'HS256') | ||||
|     end | ||||
|  | ||||
|     before do | ||||
|       allow(ENV).to receive(:fetch).with('LINEAR_CLIENT_SECRET', nil).and_return(client_secret) | ||||
|     end | ||||
|  | ||||
|     it 'successfully verifies and returns account_id from valid token' do | ||||
|       expect(verify_linear_token(valid_token)).to eq(account_id) | ||||
|     end | ||||
|  | ||||
|     context 'when token is blank' do | ||||
|       it 'returns nil' do | ||||
|         expect(verify_linear_token('')).to be_nil | ||||
|         expect(verify_linear_token(nil)).to be_nil | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when client secret is not configured' do | ||||
|       let(:client_secret) { nil } | ||||
|  | ||||
|       it 'returns nil' do | ||||
|         expect(verify_linear_token(valid_token)).to be_nil | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when token is invalid' do | ||||
|       it 'logs the error and returns nil' do | ||||
|         expect(Rails.logger).to receive(:error).with(/Unexpected error verifying Linear token:/) | ||||
|         expect(verify_linear_token('invalid_token')).to be_nil | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -1,10 +1,10 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| describe Linear do | ||||
|   let(:api_key) { 'valid_api_key' } | ||||
|   let(:access_token) { 'valid_access_token' } | ||||
|   let(:url) { 'https://api.linear.app/graphql' } | ||||
|   let(:linear_client) { described_class.new(api_key) } | ||||
|   let(:headers) { { 'Content-Type' => 'application/json', 'Authorization' => api_key } } | ||||
|   let(:linear_client) { described_class.new(access_token) } | ||||
|   let(:headers) { { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{access_token}" } } | ||||
|  | ||||
|   it 'raises an exception if the API key is absent' do | ||||
|     expect { described_class.new(nil) }.to raise_error(ArgumentError, 'Missing Credentials') | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Muhsin Keloth
					Muhsin Keloth