Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca4ad148c | ||
|
|
b5cce56428 | ||
|
|
a10e9f6c2d |
@@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
|||||||
return unless @hook&.access_token
|
return unless @hook&.access_token
|
||||||
|
|
||||||
begin
|
begin
|
||||||
linear_client = Linear.new(@hook.access_token)
|
linear_client = Linear.new(@hook)
|
||||||
linear_client.revoke_token
|
linear_client.revoke_token
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
|
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
|
||||||
|
|||||||
@@ -38,10 +38,11 @@ class Linear::CallbacksController < ApplicationController
|
|||||||
settings: {
|
settings: {
|
||||||
token_type: parsed_body['token_type'],
|
token_type: parsed_body['token_type'],
|
||||||
expires_in: parsed_body['expires_in'],
|
expires_in: parsed_body['expires_in'],
|
||||||
scope: parsed_body['scope']
|
scope: parsed_body['scope'],
|
||||||
}
|
refresh_token: parsed_body['refresh_token'],
|
||||||
|
expires_at: calculate_expires_at(parsed_body['expires_in'])
|
||||||
|
}.compact
|
||||||
)
|
)
|
||||||
# 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!
|
hook.save!
|
||||||
redirect_to linear_redirect_uri
|
redirect_to linear_redirect_uri
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
@@ -70,4 +71,10 @@ class Linear::CallbacksController < ApplicationController
|
|||||||
def base_url
|
def base_url
|
||||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def calculate_expires_at(expires_in)
|
||||||
|
return nil unless expires_in
|
||||||
|
|
||||||
|
(Time.current + expires_in.to_i.seconds).iso8601
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -77,6 +77,6 @@ class Integrations::Linear::ProcessorService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def linear_client
|
def linear_client
|
||||||
@linear_client ||= Linear.new(linear_hook.access_token)
|
@linear_client ||= Linear.new(linear_hook)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+10
-5
@@ -3,9 +3,10 @@ class Linear
|
|||||||
REVOKE_URL = 'https://api.linear.app/oauth/revoke'.freeze
|
REVOKE_URL = 'https://api.linear.app/oauth/revoke'.freeze
|
||||||
PRIORITY_LEVELS = (0..4).to_a
|
PRIORITY_LEVELS = (0..4).to_a
|
||||||
|
|
||||||
def initialize(access_token)
|
def initialize(hook)
|
||||||
@access_token = access_token
|
@hook = hook
|
||||||
raise ArgumentError, 'Missing Credentials' if access_token.blank?
|
@token_refresh_service = Linear::TokenRefreshService.new(hook)
|
||||||
|
raise ArgumentError, 'Missing hook or access token' if hook.blank? || hook.access_token.blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
def teams
|
def teams
|
||||||
@@ -81,7 +82,7 @@ class Linear
|
|||||||
def revoke_token
|
def revoke_token
|
||||||
response = HTTParty.post(
|
response = HTTParty.post(
|
||||||
REVOKE_URL,
|
REVOKE_URL,
|
||||||
headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' }
|
headers: { 'Authorization' => "Bearer #{@hook.access_token}", 'Content-Type' => 'application/json' }
|
||||||
)
|
)
|
||||||
response.success?
|
response.success?
|
||||||
end
|
end
|
||||||
@@ -145,11 +146,15 @@ class Linear
|
|||||||
def post(payload)
|
def post(payload)
|
||||||
HTTParty.post(
|
HTTParty.post(
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' },
|
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||||
body: payload.to_json
|
body: payload.to_json
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def access_token
|
||||||
|
@token_refresh_service&.token || @hook.access_token
|
||||||
|
end
|
||||||
|
|
||||||
def process_response(response)
|
def process_response(response)
|
||||||
return response.parsed_response['data'].with_indifferent_access if response.success? && !response.parsed_response['data'].nil?
|
return response.parsed_response['data'].with_indifferent_access if response.success? && !response.parsed_response['data'].nil?
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Manages OAuth2 token lifecycle for Linear integration
|
||||||
|
# Handles automatic token refresh and migration from long-lived to short-lived tokens
|
||||||
|
class Linear::TokenRefreshService
|
||||||
|
TOKEN_URL = 'https://api.linear.app/oauth/token'.freeze
|
||||||
|
MIGRATE_TOKEN_URL = 'https://api.linear.app/oauth/migrate_old_token'.freeze
|
||||||
|
|
||||||
|
def initialize(hook)
|
||||||
|
@hook = hook
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns a valid access token, handling refresh/migration automatically
|
||||||
|
# This is the main entry point - call this whenever you need a valid token
|
||||||
|
# @return [String] Valid OAuth access token
|
||||||
|
def token
|
||||||
|
return nil unless @hook
|
||||||
|
|
||||||
|
# For existing accounts without refresh token, attempt migration first
|
||||||
|
# This migrates long-lived tokens to the new refresh token system, https://linear.app/developers/oauth-2-0-authentication#migrate-to-using-refresh-tokens
|
||||||
|
migrate_old_token unless refresh_token?
|
||||||
|
|
||||||
|
refresh_access_token if token_eligible_for_refresh?
|
||||||
|
|
||||||
|
@hook.access_token
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_access_token
|
||||||
|
return false unless @hook&.settings&.dig('refresh_token')
|
||||||
|
|
||||||
|
response = HTTParty.post(
|
||||||
|
TOKEN_URL,
|
||||||
|
headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
|
||||||
|
body: {
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: @hook.settings['refresh_token'],
|
||||||
|
client_id: GlobalConfigService.load('LINEAR_CLIENT_ID', nil),
|
||||||
|
client_secret: GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.success?
|
||||||
|
update_tokens(response.parsed_response)
|
||||||
|
true
|
||||||
|
else
|
||||||
|
Rails.logger.error("Linear token refresh failed: #{response.parsed_response}")
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def migrate_old_token
|
||||||
|
return false unless @hook
|
||||||
|
|
||||||
|
response = HTTParty.post(
|
||||||
|
MIGRATE_TOKEN_URL,
|
||||||
|
headers: {
|
||||||
|
'Authorization' => "Bearer #{@hook.access_token}",
|
||||||
|
'Content-Type' => 'application/json'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.success?
|
||||||
|
update_tokens(response.parsed_response)
|
||||||
|
true
|
||||||
|
else
|
||||||
|
Rails.logger.error("Linear token migration failed: #{response.parsed_response}")
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def token_expired?
|
||||||
|
return false unless @hook&.settings&.dig('expires_at')
|
||||||
|
|
||||||
|
Time.zone.parse(@hook.settings['expires_at']) <= Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def token_eligible_for_refresh?
|
||||||
|
return false unless required_token_data?
|
||||||
|
|
||||||
|
expires_at = Time.zone.parse(@hook.settings['expires_at'])
|
||||||
|
|
||||||
|
token_valid?(expires_at) && token_old_enough? && token_approaching_expiry?(expires_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_token?
|
||||||
|
@hook&.settings&.dig('refresh_token').present?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_tokens(response_data)
|
||||||
|
return unless @hook
|
||||||
|
|
||||||
|
@hook.update!(
|
||||||
|
access_token: response_data['access_token'],
|
||||||
|
settings: @hook.settings.merge(
|
||||||
|
token_type: response_data['token_type'],
|
||||||
|
expires_in: response_data['expires_in'],
|
||||||
|
scope: response_data['scope'],
|
||||||
|
refresh_token: response_data['refresh_token'] || @hook.settings['refresh_token'],
|
||||||
|
expires_at: calculate_expires_at(response_data['expires_in'])
|
||||||
|
).compact
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_expires_at(expires_in)
|
||||||
|
return nil unless expires_in
|
||||||
|
|
||||||
|
(Time.current + expires_in.to_i.seconds).iso8601
|
||||||
|
end
|
||||||
|
|
||||||
|
# Checks if hook has both expires_at and refresh_token data
|
||||||
|
def required_token_data?
|
||||||
|
@hook&.settings&.dig('expires_at') && @hook&.settings&.dig('refresh_token')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Checks if token is still valid (not expired yet)
|
||||||
|
def token_valid?(expires_at)
|
||||||
|
Time.current < expires_at
|
||||||
|
end
|
||||||
|
|
||||||
|
# Checks if token is at least 24 hours old (prevents excessive refresh attempts)
|
||||||
|
def token_old_enough?
|
||||||
|
@hook.updated_at.present? && Time.current - @hook.updated_at >= 24.hours
|
||||||
|
end
|
||||||
|
|
||||||
|
# Checks if token is approaching expiry (within 10 days)
|
||||||
|
def token_approaching_expiry?(expires_at)
|
||||||
|
expires_at < 10.days.from_now
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Linear::TokenRefreshService do
|
||||||
|
let(:access_token) { 'valid_access_token' }
|
||||||
|
let(:refresh_token) { 'valid_refresh_token' }
|
||||||
|
let(:expires_at) { 20.days.from_now.iso8601 }
|
||||||
|
let(:settings) do
|
||||||
|
{
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 3600,
|
||||||
|
scope: 'read,write',
|
||||||
|
refresh_token: refresh_token,
|
||||||
|
expires_at: expires_at
|
||||||
|
}
|
||||||
|
end
|
||||||
|
let(:hook) do
|
||||||
|
hook_double = instance_double(Integrations::Hook, access_token: access_token, updated_at: 2.days.ago)
|
||||||
|
allow(hook_double).to receive(:settings).and_return(settings)
|
||||||
|
hook_double
|
||||||
|
end
|
||||||
|
let(:service) { described_class.new(hook) }
|
||||||
|
|
||||||
|
describe '#token' do
|
||||||
|
context 'when hook is nil' do
|
||||||
|
let(:service) { described_class.new(nil) }
|
||||||
|
|
||||||
|
it 'returns nil access_token' do
|
||||||
|
expect(service.token).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when hook has no refresh token' do
|
||||||
|
let(:settings) { { token_type: 'Bearer' } }
|
||||||
|
let(:hook) do
|
||||||
|
hook_double = instance_double(Integrations::Hook, access_token: access_token, updated_at: 2.days.ago)
|
||||||
|
allow(hook_double).to receive(:settings).and_return(settings)
|
||||||
|
hook_double
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'attempts migration and returns access token' do
|
||||||
|
expect(service).to receive(:migrate_old_token).and_return(true)
|
||||||
|
expect(service.token).to eq(access_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when token is eligible for refresh' do
|
||||||
|
let(:expires_at) { 5.days.from_now.iso8601 }
|
||||||
|
|
||||||
|
it 'refreshes the token and returns access token' do
|
||||||
|
expect(service).to receive(:refresh_access_token).and_return(true)
|
||||||
|
expect(service.token).to eq(access_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when token is not eligible for refresh' do
|
||||||
|
it 'returns the current access token' do
|
||||||
|
expect(service.token).to eq(access_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#refresh_access_token' do
|
||||||
|
let(:token_url) { 'https://api.linear.app/oauth/token' }
|
||||||
|
let(:refresh_response) do
|
||||||
|
{
|
||||||
|
'access_token' => 'new_access_token',
|
||||||
|
'refresh_token' => 'new_refresh_token',
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
'scope' => 'read,write'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when refresh token is present' do
|
||||||
|
before do
|
||||||
|
allow(GlobalConfigService).to receive(:load).with('LINEAR_CLIENT_ID', nil).and_return('client_id')
|
||||||
|
allow(GlobalConfigService).to receive(:load).with('LINEAR_CLIENT_SECRET', nil).and_return('client_secret')
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when refresh is successful' do
|
||||||
|
before do
|
||||||
|
stub_request(:post, token_url)
|
||||||
|
.with(
|
||||||
|
headers: { 'Content-Type' => 'application/x-www-form-urlencoded' },
|
||||||
|
body: {
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refresh_token,
|
||||||
|
client_id: 'client_id',
|
||||||
|
client_secret: 'client_secret'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: refresh_response.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates tokens and returns true' do
|
||||||
|
expect(service).to receive(:update_tokens).with(refresh_response)
|
||||||
|
expect(service.refresh_access_token).to be(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when refresh fails' do
|
||||||
|
before do
|
||||||
|
stub_request(:post, token_url)
|
||||||
|
.to_return(status: 400, body: { error: 'invalid_grant' }.to_json)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs error and returns false' do
|
||||||
|
expect(Rails.logger).to receive(:error).with(match(/Linear token refresh failed/))
|
||||||
|
expect(service.refresh_access_token).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when refresh token is missing' do
|
||||||
|
let(:settings) { { token_type: 'Bearer' } }
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(service.refresh_access_token).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#migrate_old_token' do
|
||||||
|
let(:migrate_url) { 'https://api.linear.app/oauth/migrate_old_token' }
|
||||||
|
let(:migrate_response) do
|
||||||
|
{
|
||||||
|
'access_token' => 'new_access_token',
|
||||||
|
'refresh_token' => 'new_refresh_token',
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
'scope' => 'read,write'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when migration is successful' do
|
||||||
|
before do
|
||||||
|
stub_request(:post, migrate_url)
|
||||||
|
.with(
|
||||||
|
headers: {
|
||||||
|
'Authorization' => "Bearer #{access_token}",
|
||||||
|
'Content-Type' => 'application/json'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: migrate_response.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json' }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates tokens and returns true' do
|
||||||
|
expect(service).to receive(:update_tokens).with(migrate_response)
|
||||||
|
expect(service.migrate_old_token).to be(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when migration fails' do
|
||||||
|
before do
|
||||||
|
stub_request(:post, migrate_url)
|
||||||
|
.to_return(status: 400, body: { error: 'invalid_token' }.to_json)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs error and returns false' do
|
||||||
|
expect(Rails.logger).to receive(:error).with(match(/Linear token migration failed/))
|
||||||
|
expect(service.migrate_old_token).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#token_eligible_for_refresh?' do
|
||||||
|
context 'when token data is missing' do
|
||||||
|
let(:settings) { {} }
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(service.send(:token_eligible_for_refresh?)).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when all conditions are met' do
|
||||||
|
let(:expires_at) { 5.days.from_now.iso8601 }
|
||||||
|
|
||||||
|
it 'returns true' do
|
||||||
|
expect(service.send(:token_eligible_for_refresh?)).to be(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when token is expired' do
|
||||||
|
let(:expires_at) { 1.day.ago.iso8601 }
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(service.send(:token_eligible_for_refresh?)).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when token was updated recently' do
|
||||||
|
let(:expires_at) { 5.days.from_now.iso8601 }
|
||||||
|
let(:hook) do
|
||||||
|
instance_double(
|
||||||
|
Integrations::Hook,
|
||||||
|
access_token: access_token,
|
||||||
|
settings: settings,
|
||||||
|
updated_at: 1.hour.ago
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(service.send(:token_eligible_for_refresh?)).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when token is not approaching expiry' do
|
||||||
|
let(:expires_at) { 30.days.from_now.iso8601 }
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(service.send(:token_eligible_for_refresh?)).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#refresh_token?' do
|
||||||
|
context 'when refresh token is present' do
|
||||||
|
it 'returns true' do
|
||||||
|
expect(service.send(:refresh_token?)).to be(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when refresh token is missing' do
|
||||||
|
let(:settings) { { token_type: 'Bearer' } }
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(service.send(:refresh_token?)).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'private methods' do
|
||||||
|
describe '#update_tokens' do
|
||||||
|
let(:response_data) do
|
||||||
|
{
|
||||||
|
'access_token' => 'new_access_token',
|
||||||
|
'refresh_token' => 'new_refresh_token',
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
'scope' => 'read,write'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
let(:expected_expires_at) { (3600.seconds.from_now).iso8601 }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Time).to receive(:current).and_return(Time.parse('2025-01-01 12:00:00 UTC'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the hook with new token data' do
|
||||||
|
expect(hook).to receive(:update!).with(
|
||||||
|
access_token: 'new_access_token',
|
||||||
|
settings: settings.merge(
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 3600,
|
||||||
|
scope: 'read,write',
|
||||||
|
refresh_token: 'new_refresh_token',
|
||||||
|
expires_at: expected_expires_at
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
service.send(:update_tokens, response_data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#calculate_expires_at' do
|
||||||
|
before do
|
||||||
|
allow(Time).to receive(:current).and_return(Time.parse('2025-01-01 12:00:00 UTC'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calculates expiry time as ISO8601 string' do
|
||||||
|
result = service.send(:calculate_expires_at, 3600)
|
||||||
|
expect(result).to eq('2025-01-01T13:00:00Z')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns nil when expires_in is nil' do
|
||||||
|
result = service.send(:calculate_expires_at, nil)
|
||||||
|
expect(result).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+23
-3
@@ -3,11 +3,31 @@ require 'rails_helper'
|
|||||||
describe Linear do
|
describe Linear do
|
||||||
let(:access_token) { 'valid_access_token' }
|
let(:access_token) { 'valid_access_token' }
|
||||||
let(:url) { 'https://api.linear.app/graphql' }
|
let(:url) { 'https://api.linear.app/graphql' }
|
||||||
let(:linear_client) { described_class.new(access_token) }
|
let(:hook_settings) do
|
||||||
|
{
|
||||||
|
refresh_token: 'valid_refresh_token',
|
||||||
|
expires_at: 30.days.from_now.iso8601,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 3600,
|
||||||
|
scope: 'read,write'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
let(:hook) { instance_double(Integrations::Hook, access_token: access_token, settings: hook_settings, updated_at: 2.days.ago) }
|
||||||
|
let(:linear_client) { described_class.new(hook) }
|
||||||
let(:headers) { { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{access_token}" } }
|
let(:headers) { { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{access_token}" } }
|
||||||
|
|
||||||
it 'raises an exception if the API key is absent' do
|
before do
|
||||||
expect { described_class.new(nil) }.to raise_error(ArgumentError, 'Missing Credentials')
|
# Mock the TokenRefreshService to return the access token without making HTTP calls
|
||||||
|
allow_any_instance_of(Linear::TokenRefreshService).to receive(:token).and_return(access_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises an exception if the hook is absent' do
|
||||||
|
expect { described_class.new(nil) }.to raise_error(ArgumentError, 'Missing hook or access token')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises an exception if the access token is absent' do
|
||||||
|
hook_without_token = instance_double(Integrations::Hook, access_token: nil)
|
||||||
|
expect { described_class.new(hook_without_token) }.to raise_error(ArgumentError, 'Missing hook or access token')
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when querying teams' do
|
context 'when querying teams' do
|
||||||
|
|||||||
Reference in New Issue
Block a user