Skip to content

Commit 21c3d1a

Browse files
Merge pull request #1380 from travis-ci/TBT-381-assembla-jwt-login-endpoint
Assembla Deep Integration: Travis API Endpoint
2 parents 0a6e9db + e4227ba commit 21c3d1a

File tree

10 files changed

+412
-3
lines changed

10 files changed

+412
-3
lines changed

lib/travis/api/app/endpoint.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,4 @@ def auth_for_repo(id, type)
149149
require 'travis/api/app/endpoint/slow'
150150
require 'travis/api/app/endpoint/uptime'
151151
require 'travis/api/app/endpoint/users'
152+
require 'travis/api/app/endpoint/assembla'
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
require 'travis/api/app'
2+
require 'jwt'
3+
require 'travis/remote_vcs/user'
4+
require 'travis/remote_vcs/repository'
5+
require 'travis/api/v3/billing_client'
6+
require 'travis/services/assembla_user_service'
7+
require_relative '../jwt_utils'
8+
9+
class Travis::Api::App
10+
class Endpoint
11+
# Assembla integration endpoint for handling user authentication and organization setup
12+
class Assembla < Endpoint
13+
include Travis::Api::App::JWTUtils
14+
15+
REQUIRED_JWT_FIELDS = %w[name email login space_id repository_id id refresh_token].freeze
16+
CLUSTER_HEADER = 'HTTP_X_ASSEMBLA_CLUSTER'.freeze
17+
18+
set prefix: '/assembla'
19+
set :check_auth, false
20+
21+
before do
22+
validate_request!
23+
end
24+
25+
post '/login' do
26+
service = Travis::Services::AssemblaUserService.new(@jwt_payload)
27+
28+
user = service.find_or_create_user
29+
org = service.find_or_create_organization(user)
30+
service.create_org_subscription(user, org.id)
31+
access_token = AccessToken.create(user: user, app_id: 0).token
32+
33+
{
34+
user_id: user.id,
35+
login: user.login,
36+
token: access_token
37+
}
38+
end
39+
40+
private
41+
42+
def validate_request!
43+
halt 403, { error: 'Deep integration not enabled' } unless deep_integration_enabled?
44+
halt 403, { error: 'Invalid ASM cluster' } unless valid_asm_cluster?
45+
@jwt_payload = verify_jwt(request)
46+
check_required_fields
47+
end
48+
49+
def check_required_fields
50+
missing = REQUIRED_JWT_FIELDS.select { |f| @jwt_payload[f].nil? || @jwt_payload[f].to_s.strip.empty? }
51+
unless missing.empty?
52+
halt 400, { error: 'Missing required fields', missing: missing }
53+
end
54+
end
55+
56+
def deep_integration_enabled?
57+
Travis.config.deep_integration_enabled
58+
end
59+
60+
def valid_asm_cluster?
61+
allowed = Travis.config.assembla_clusters
62+
allowed.include?(request.env[CLUSTER_HEADER])
63+
end
64+
end
65+
end
66+
end

lib/travis/api/app/jwt_utils.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
class Travis::Api::App
2+
module JWTUtils
3+
def extract_jwt_token(request)
4+
request.env['HTTP_AUTHORIZATION']&.split&.last
5+
end
6+
7+
def verify_jwt(request)
8+
token = extract_jwt_token(request)
9+
10+
halt 401, { error: "Missing JWT" } unless token
11+
12+
begin
13+
decoded, = JWT.decode(token, Travis.config.assembla_jwt_secret, true, algorithm: 'HS256')
14+
decoded
15+
rescue JWT::DecodeError => e
16+
halt 401, { error: "Invalid JWT: #{e.message}" }
17+
end
18+
end
19+
end
20+
end

lib/travis/config/defaults.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,11 @@ def fallback_logs_api_auth_token
106106
recaptcha: { endpoint: 'https://www.google.com', secret: ENV['RECAPTCHA_SECRET_KEY'] || '' },
107107
antifraud: { captcha_max_failed_attempts: 3, captcha_block_duration: 24, credit_card_max_failed_attempts: 3, credit_card_block_duration: 24 },
108108
legacy_roles: false,
109-
internal_users: [{id: 0, login: 'cron'}]
109+
internal_users: [{id: 0, login: 'cron'}],
110+
assembla_clusters: ['eu', 'us'],
111+
deep_integration_enabled: false,
112+
assembla_jwt_secret: 'assembla_jwt_secret',
113+
deep_integration_plan_name: 'beta_plan'
110114

111115
default :_access => [:key]
112116

lib/travis/model/user.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ class User < Travis::Model
2222
after_create :create_the_tokens
2323
before_save :track_previous_changes
2424

25-
serialize :github_scopes
25+
alias_attribute :vcs_oauth_token, :github_oauth_token
2626

27+
serialize :github_scopes
28+
serialize :vcs_oauth_token, EncryptedColumn.new
2729
serialize :github_oauth_token, Travis::Model::EncryptedColumn.new
2830

2931
before_save do

lib/travis/remote_vcs/user.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ def generate_token(provider: :github, token:, app_id: 1)
3535
end
3636
end
3737

38-
def sync(user_id:)
38+
def sync(user_id:, space_id: nil, repository_id: nil)
3939
request(:post, __method__) do |req|
4040
req.url "users/#{user_id}/sync_data"
41+
req.params['space_id'] = space_id if space_id
42+
req.params['repository_id'] = repository_id if repository_id
4143
end && true
4244
end
4345

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
module Travis
2+
module Services
3+
class AssemblaUserService
4+
class SyncError < StandardError; end
5+
6+
def initialize(payload)
7+
@payload = payload
8+
end
9+
10+
def find_or_create_user
11+
user = ::User.find_or_initialize_by(
12+
name: @payload['name'],
13+
vcs_id: @payload['id'],
14+
email: @payload['email'],
15+
login: @payload['login'],
16+
vcs_type: 'AssemblaUser'
17+
)
18+
user.vcs_oauth_token = @payload['refresh_token']
19+
user.confirmed_at = DateTime.now if user.confirmed_at.nil?
20+
user.save!
21+
sync_user(user.id)
22+
user
23+
end
24+
25+
def find_or_create_organization(user)
26+
org = Organization.find_or_create_by!(
27+
vcs_id: @payload['space_id'],
28+
vcs_type: 'AssemblaOrganization'
29+
)
30+
membership = org.memberships.find_or_create_by(user: user)
31+
membership.update(role: 'admin')
32+
org
33+
end
34+
35+
def create_org_subscription(user, organization_id)
36+
billing_client = Travis::API::V3::BillingClient.new(user.id)
37+
billing_client.create_v2_subscription(subscription_params(user, organization_id))
38+
rescue => e
39+
{ error: true, details: e.message }
40+
end
41+
42+
private
43+
44+
def sync_user(user_id)
45+
Travis::RemoteVCS::User.new.sync(user_id: user_id, space_id: @payload['space_id'], repository_id: @payload['repository_id'])
46+
rescue => e
47+
raise SyncError, "Failed to sync user: #{e.message}"
48+
end
49+
50+
def subscription_params(user, organization_id)
51+
{
52+
'plan' => Travis.config.deep_integration_plan_name,
53+
'organization_id' => organization_id,
54+
'billing_info' => billing_info(user),
55+
'credit_card_info' => { 'token' => nil }
56+
}
57+
end
58+
59+
def billing_info(user)
60+
{
61+
'address' => 'Dummy Address',
62+
'city' => 'Dummy City',
63+
'country' => 'Dummy Country',
64+
'first_name' => user.name&.split&.first,
65+
'last_name' => user.name&.split&.last,
66+
'zip_code' => 'DUMMY ZIP',
67+
'billing_email' => user.email
68+
}
69+
end
70+
end
71+
end
72+
end
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
require 'spec_helper'
2+
3+
RSpec.describe Travis::Services::AssemblaUserService do
4+
let(:payload) do
5+
{
6+
'id' => '12345',
7+
'name' => 'Test User',
8+
'email' => '[email protected]',
9+
'login' => 'testuser',
10+
'refresh_token' => 'refresh123',
11+
'space_id' => '67890'
12+
}
13+
end
14+
15+
let(:service) { described_class.new(payload) }
16+
let(:user) { FactoryBot.create(:user, vcs_id: payload['id'], email: payload['email'], login: payload['login'], name: payload['name']) }
17+
let(:organization) { FactoryBot.create(:org, vcs_id: payload['space_id'], vcs_type: 'AssemblaOrganization') }
18+
19+
describe '#find_or_create_user' do
20+
let(:expected_attrs) do
21+
{
22+
vcs_id: payload['id'],
23+
email: payload['email'],
24+
name: payload['name'],
25+
login: payload['login'],
26+
vcs_type: 'AssemblaUser'
27+
}
28+
end
29+
30+
before do
31+
allow(Travis::RemoteVCS::User).to receive(:new).and_return(double(sync: true))
32+
end
33+
34+
it 'finds or creates a user with correct attributes' do
35+
service_user = service.find_or_create_user
36+
expect(service_user.login).to eq(expected_attrs[:login])
37+
expect(service_user.email).to eq(expected_attrs[:email])
38+
expect(service_user.name).to eq(expected_attrs[:name])
39+
expect(service_user.vcs_id).to eq(expected_attrs[:vcs_id])
40+
expect(service_user.confirmed_at).to be_present
41+
end
42+
43+
context 'when sync fails' do
44+
it 'raises SyncError' do
45+
allow(Travis::RemoteVCS::User).to receive(:new).and_raise(StandardError.new('Sync failed'))
46+
47+
expect { service.find_or_create_user }.to raise_error(
48+
Travis::Services::AssemblaUserService::SyncError,
49+
'Failed to sync user: Sync failed'
50+
)
51+
end
52+
end
53+
end
54+
55+
describe '#find_or_create_organization' do
56+
let(:expected_attrs) do
57+
{
58+
vcs_id: payload['space_id'],
59+
vcs_type: 'AssemblaOrganization'
60+
}
61+
end
62+
63+
it 'finds or creates organization with correct attributes' do
64+
service_org = service.find_or_create_organization(user)
65+
66+
expect(service_org.vcs_type).to eq(expected_attrs[:vcs_type])
67+
expect(service_org.vcs_id).to eq(expected_attrs[:vcs_id])
68+
end
69+
70+
it 'has admin membership' do
71+
service_org = service.find_or_create_organization(user)
72+
expect(service_org.memberships.find_by(user: user).role).to eq('admin')
73+
end
74+
end
75+
76+
describe '#create_org_subscription' do
77+
let(:billing_client) { double('BillingClient') }
78+
79+
before do
80+
allow(Travis::API::V3::BillingClient).to receive(:new).with(user.id).and_return(billing_client)
81+
end
82+
83+
context 'when billing client raises an error' do
84+
let(:error) { StandardError.new('Billing error') }
85+
86+
it 'returns error hash' do
87+
allow(billing_client).to receive(:create_v2_subscription).and_raise(error)
88+
89+
result = service.create_org_subscription(user, organization.id)
90+
expect(result[:error]).to be_truthy
91+
expect(result[:details]).to eq(error.message)
92+
end
93+
end
94+
end
95+
end

spec/travis/remote_vcs/user_spec.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,32 @@
4949
end
5050
end
5151

52+
describe '#sync' do
53+
let(:user_id) { 123 }
54+
let(:space_id) { 456 }
55+
let(:repository_id) { 789 }
56+
let(:instance) { described_class.new }
57+
let(:req) { double(:request) }
58+
let(:params) { double(:params) }
59+
60+
subject { instance.sync(user_id: user_id, space_id: space_id, repository_id: repository_id) }
61+
62+
before do
63+
allow(req).to receive(:url)
64+
allow(req).to receive(:params).and_return(params)
65+
allow(params).to receive(:[]=)
66+
end
67+
68+
it 'performs POST to VCS with proper params' do
69+
expect(instance).to receive(:request).with(:post, :sync).and_yield(req)
70+
expect(req).to receive(:url).with("users/#{user_id}/sync_data")
71+
expect(params).to receive(:[]=).with('space_id', space_id)
72+
expect(params).to receive(:[]=).with('repository_id', repository_id)
73+
74+
expect(subject).to be true
75+
end
76+
end
77+
5278
describe '#authenticate' do
5379
let(:user) { described_class.new }
5480
let(:provider) { 'assembla' }

0 commit comments

Comments
 (0)