Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/travis/api/app/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,4 @@ def auth_for_repo(id, type)
require 'travis/api/app/endpoint/slow'
require 'travis/api/app/endpoint/uptime'
require 'travis/api/app/endpoint/users'
require 'travis/api/app/endpoint/assembla'
65 changes: 65 additions & 0 deletions lib/travis/api/app/endpoint/assembla.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
require 'travis/api/app'
require 'jwt'
require 'travis/remote_vcs/user'
require 'travis/remote_vcs/repository'
require 'travis/api/v3/billing_client'
require 'travis/services/assembla_user_service'
require_relative '../jwt_utils'

class Travis::Api::App
class Endpoint
# Assembla integration endpoint for handling user authentication and organization setup
class Assembla < Endpoint
include Travis::Api::App::JWTUtils

REQUIRED_JWT_FIELDS = %w[name email login space_id id refresh_token].freeze
CLUSTER_HEADER = 'HTTP_X_ASSEMBLA_CLUSTER'.freeze

set prefix: '/assembla'
set :check_auth, false

before do
validate_request!
end

post '/login' do
service = Travis::Services::AssemblaUserService.new(@jwt_payload)

user = service.find_or_create_user
org = service.find_or_create_organization(user)
service.create_org_subscription(user, org.id)

{
user_id: user.id,
login: user.login,
token: user.token
}
end

private

def validate_request!
halt 403, { error: 'Deep integration not enabled' } unless deep_integration_enabled?
halt 403, { error: 'Invalid ASM cluster' } unless valid_asm_cluster?
@jwt_payload = verify_jwt(request)
check_required_fields
end

def check_required_fields
missing = REQUIRED_JWT_FIELDS.select { |f| @jwt_payload[f].nil? || @jwt_payload[f].to_s.strip.empty? }
unless missing.empty?
halt 400, { error: 'Missing required fields', missing: missing }
Comment on lines +43 to +52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe create a config and add all error messages to that so it is easy to see all errors this integration can generate and then use that config where you are raising the error.

end
end

def deep_integration_enabled?
Travis.config.deep_integration_enabled
end

def valid_asm_cluster?
allowed = Travis.config.assembla_clusters.to_s.split(',')
allowed.include?(request.env[CLUSTER_HEADER])
end
end
end
end
20 changes: 20 additions & 0 deletions lib/travis/api/app/jwt_utils.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class Travis::Api::App
module JWTUtils
def extract_jwt_token(request)
request.env['HTTP_AUTHORIZATION']&.split&.last
end

def verify_jwt(request)
token = extract_jwt_token(request)

halt 401, { error: "Missing JWT" } unless token

begin
decoded, = JWT.decode(token, Travis.config.assembla_jwt_secret, true, algorithm: 'HS256')
decoded
rescue JWT::DecodeError => e
halt 401, { error: "Invalid JWT: #{e.message}" }
end
end
end
end
6 changes: 5 additions & 1 deletion lib/travis/config/defaults.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ def fallback_logs_api_auth_token
recaptcha: { endpoint: 'https://www.google.com', secret: ENV['RECAPTCHA_SECRET_KEY'] || '' },
antifraud: { captcha_max_failed_attempts: 3, captcha_block_duration: 24, credit_card_max_failed_attempts: 3, credit_card_block_duration: 24 },
legacy_roles: false,
internal_users: [{id: 0, login: 'cron'}]
internal_users: [{id: 0, login: 'cron'}],
assembla_clusters: 'eu, us',
deep_integration_enabled: false,
assembla_jwt_secret: 'assembla_jwt_secret',
deep_integration_plan_name: 'beta_plan'

default :_access => [:key]

Expand Down
4 changes: 3 additions & 1 deletion lib/travis/model/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ class User < Travis::Model
after_create :create_the_tokens
before_save :track_previous_changes

serialize :github_scopes
alias_attribute :vcs_oauth_token, :github_oauth_token

serialize :github_scopes
serialize :vcs_oauth_token, EncryptedColumn.new
serialize :github_oauth_token, Travis::Model::EncryptedColumn.new

before_save do
Expand Down
68 changes: 68 additions & 0 deletions lib/travis/services/assembla_user_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
module Travis
module Services
class AssemblaUserService
class SyncError < StandardError; end

def initialize(payload)
@payload = payload
end

def find_or_create_user
user = ::User.find_or_initialize_by(
name: @payload['name'],
vcs_id: @payload['id'],
email: @payload['email'],
login: @payload['login'],
vcs_type: 'AssemblaUser'
)
user.vcs_oauth_token = @payload['refresh_token']
user.save!
sync_user(user.id)
user
end

def find_or_create_organization(user)
user.organizations.find_or_create_by!(
vcs_id: @payload['space_id'],
vcs_type: 'AssemblaOrganization'
)
end

def create_org_subscription(user, organization_id)
billing_client = Travis::API::V3::BillingClient.new(user.id)
billing_client.create_v2_subscription(subscription_params(user, organization_id))
rescue => e
{ error: true, details: e.message }
end

private

def sync_user(user_id)
Travis::RemoteVCS::User.new.sync(user_id: user_id)
rescue => e

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we rescue only specific errors?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It think we should know what went wrong while sycning, filtering can overlook other services related errors.

raise SyncError, "Failed to sync user: #{e.message}"
end

def subscription_params(user, organization_id)
{
'plan' => Travis.config.deep_integration_plan_name,
'organization_id' => organization_id,
'billing_info' => billing_info(user),
'credit_card_info' => { 'token' => nil }
}
end

def billing_info(user)
{
'address' => 'Dummy Address',
'city' => 'Dummy City',
'country' => 'Dummy Country',
'first_name' => user.name&.split&.first,
'last_name' => user.name&.split&.last,
'zip_code' => 'DUMMY ZIP',
'billing_email' => user.email
}
end
end
end
end
89 changes: 89 additions & 0 deletions spec/lib/services/assembla_user_service_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
require 'spec_helper'

RSpec.describe Travis::Services::AssemblaUserService do
let(:payload) do
{
'id' => '12345',
'name' => 'Test User',
'email' => '[email protected]',
'login' => 'testuser',
'refresh_token' => 'refresh123',
'space_id' => '67890'
}
end

let(:service) { described_class.new(payload) }
let(:user) { FactoryBot.create(:user, vcs_id: payload['id'], email: payload['email'], login: payload['login'], name: payload['name']) }
let(:organization) { FactoryBot.create(:org, vcs_id: payload['space_id'], vcs_type: 'AssemblaOrganization') }

describe '#find_or_create_user' do
let(:expected_attrs) do
{
vcs_id: payload['id'],
email: payload['email'],
name: payload['name'],
login: payload['login'],
vcs_type: 'AssemblaUser'
}
end

before do
allow(Travis::RemoteVCS::User).to receive(:new).and_return(double(sync: true))
end

it 'finds or creates a user with correct attributes' do
service_user = service.find_or_create_user
expect(service_user.login).to eq(expected_attrs[:login])
expect(service_user.email).to eq(expected_attrs[:email])
expect(service_user.name).to eq(expected_attrs[:name])
expect(service_user.vcs_id).to eq(expected_attrs[:vcs_id])
end

context 'when sync fails' do
it 'raises SyncError' do
allow(Travis::RemoteVCS::User).to receive(:new).and_raise(StandardError.new('Sync failed'))

expect { service.find_or_create_user }.to raise_error(
Travis::Services::AssemblaUserService::SyncError,
'Failed to sync user: Sync failed'
)
end
end
end

describe '#find_or_create_organization' do
let(:expected_attrs) do
{
vcs_id: payload['space_id'],
vcs_type: 'AssemblaOrganization'
}
end

it 'finds or creates organization with correct attributes' do
service_org = service.find_or_create_organization(user)

expect(service_org.vcs_type).to eq(expected_attrs[:vcs_type])
expect(service_org.vcs_id).to eq(expected_attrs[:vcs_id])
end
end

describe '#create_org_subscription' do
let(:billing_client) { double('BillingClient') }

before do
allow(Travis::API::V3::BillingClient).to receive(:new).with(user.id).and_return(billing_client)
end

context 'when billing client raises an error' do
let(:error) { StandardError.new('Billing error') }

it 'returns error hash' do
allow(billing_client).to receive(:create_v2_subscription).and_raise(error)

result = service.create_org_subscription(user, organization.id)
expect(result[:error]).to be_truthy
expect(result[:details]).to eq(error.message)
end
end
end
end
118 changes: 118 additions & 0 deletions spec/unit/endpoint/assembla_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
require 'spec_helper'
require 'rack/test'
require 'jwt'

RSpec.describe Travis::Api::App::Endpoint::Assembla, set_app: true do
include Rack::Test::Methods

let(:jwt_secret) { 'assembla_jwt_secret' }
let(:payload) do
{
'name' => 'Test User',
'email' => '[email protected]',
'login' => 'testuser',
'space_id' => 'space123',
'id' => 'assembla_vcs_user_id',
'access_token' => 'test_access_token',
'refresh_token' => 'test_refresh_token'
}
end
let(:token) { JWT.encode(payload, jwt_secret, 'HS256') }
let(:user) { double('User', id: 1, login: 'testuser', token: 'abc123', name: 'Test User', email: '[email protected]', organizations: organizations) }
let(:organization) { double('Organization', id: 1) }
let(:organizations) { double('Organizations') }
let(:subscription_response) { { 'status' => 'subscribed' } }
let(:assembla_cluster) { 'eu' }
let!(:original_deep_integration_enabled) { Travis.config[:deep_integration_enabled] }

before do
Travis.config[:deep_integration_enabled] = true

header 'X_ASSEMBLA_CLUSTER', assembla_cluster
end

after do
Travis.config[:deep_integration_enabled] = original_deep_integration_enabled
end

describe 'POST /assembla/login' do
context 'with valid JWT' do
let(:service) { instance_double(Travis::Services::AssemblaUserService) }
let(:remote_vcs_user) { instance_double(Travis::RemoteVCS::User) }
let(:billing_client) { instance_double(Travis::API::V3::BillingClient) }

before do
allow(Travis::Services::AssemblaUserService).to receive(:new).with(payload).and_return(service)
allow(service).to receive(:find_or_create_user).and_return(user)
allow(service).to receive(:find_or_create_organization).with(user).and_return(organization)
allow(service).to receive(:create_org_subscription).with(user, organization.id).and_return(subscription_response)
end

it 'creates user, organization and subscription' do
header 'Authorization', "Bearer #{token}"
post '/assembla/login'

expect(last_response.status).to eq(200)
body = JSON.parse(last_response.body)
expect(body['login']).to eq(user.login)
expect(body['token']).to eq(user.token)
end
end

context 'with missing JWT' do
it 'returns 401' do
post '/assembla/login'
expect(last_response.status).to eq(401)
expect(last_response.body).to include('Missing JWT')
end
end

context 'with invalid JWT' do
it 'returns 401' do
header 'Authorization', 'Bearer invalidtoken'
post '/assembla/login'
expect(last_response.status).to eq(401)
expect(last_response.body).to include('Invalid JWT')
end
end

context 'with missing required fields' do
let(:invalid_payload) { payload.tap { |p| p.delete('email') } }
let(:invalid_token) { JWT.encode(invalid_payload, jwt_secret, 'HS256') }

it 'returns 400 with missing fields' do
header 'Authorization', "Bearer #{invalid_token}"
post '/assembla/login'

expect(last_response.status).to eq(400)
body = JSON.parse(last_response.body)
expect(body['error']).to eq('Missing required fields')
expect(body['missing']).to include('email')
end
end

context 'when integration is not enabled' do

before { Travis.config[:deep_integration_enabled] = original_deep_integration_enabled }
after { Travis.config[:deep_integration_enabled] = true }

it 'returns 403' do
header 'Authorization', "Bearer #{token}"
post '/assembla/login'
expect(last_response.status).to eq(403)
expect(last_response.body).to include('Deep integration not enabled')
end
end

context 'when cluster is invalid' do
before { header 'X_ASSEMBLA_CLUSTER', 'invalid-cluster' }

it 'returns 403' do
header 'Authorization', "Bearer #{token}"
post '/assembla/login'
expect(last_response.status).to eq(403)
expect(last_response.body).to include('Invalid ASM cluster')
end
end
end
end