diff --git a/lib/travis/api/app/endpoint.rb b/lib/travis/api/app/endpoint.rb index 94ff8771a..cd981aa65 100644 --- a/lib/travis/api/app/endpoint.rb +++ b/lib/travis/api/app/endpoint.rb @@ -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' diff --git a/lib/travis/api/app/endpoint/assembla.rb b/lib/travis/api/app/endpoint/assembla.rb new file mode 100644 index 000000000..f8936bd0f --- /dev/null +++ b/lib/travis/api/app/endpoint/assembla.rb @@ -0,0 +1,66 @@ +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 repository_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) + access_token = AccessToken.create(user: user, app_id: 0).token + + { + user_id: user.id, + login: user.login, + token: access_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 } + end + end + + def deep_integration_enabled? + Travis.config.deep_integration_enabled + end + + def valid_asm_cluster? + allowed = Travis.config.assembla_clusters + allowed.include?(request.env[CLUSTER_HEADER]) + end + end + end +end diff --git a/lib/travis/api/app/jwt_utils.rb b/lib/travis/api/app/jwt_utils.rb new file mode 100644 index 000000000..caead2f0c --- /dev/null +++ b/lib/travis/api/app/jwt_utils.rb @@ -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 diff --git a/lib/travis/config/defaults.rb b/lib/travis/config/defaults.rb index a0ebc3f61..323a52586 100644 --- a/lib/travis/config/defaults.rb +++ b/lib/travis/config/defaults.rb @@ -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] diff --git a/lib/travis/model/user.rb b/lib/travis/model/user.rb index c6ae0bb5a..d28bd7230 100644 --- a/lib/travis/model/user.rb +++ b/lib/travis/model/user.rb @@ -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 diff --git a/lib/travis/remote_vcs/user.rb b/lib/travis/remote_vcs/user.rb index f95b6e0eb..88abd095d 100644 --- a/lib/travis/remote_vcs/user.rb +++ b/lib/travis/remote_vcs/user.rb @@ -35,9 +35,11 @@ def generate_token(provider: :github, token:, app_id: 1) end end - def sync(user_id:) + def sync(user_id:, space_id: nil, repository_id: nil) request(:post, __method__) do |req| req.url "users/#{user_id}/sync_data" + req.params['space_id'] = space_id if space_id + req.params['repository_id'] = repository_id if repository_id end && true end diff --git a/lib/travis/services/assembla_user_service.rb b/lib/travis/services/assembla_user_service.rb new file mode 100644 index 000000000..736dc3a0f --- /dev/null +++ b/lib/travis/services/assembla_user_service.rb @@ -0,0 +1,72 @@ +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.confirmed_at = DateTime.now if user.confirmed_at.nil? + user.save! + sync_user(user.id) + user + end + + def find_or_create_organization(user) + org = Organization.find_or_create_by!( + vcs_id: @payload['space_id'], + vcs_type: 'AssemblaOrganization' + ) + membership = org.memberships.find_or_create_by(user: user) + membership.update(role: 'admin') + org + 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, space_id: @payload['space_id'], repository_id: @payload['repository_id']) + rescue => e + 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 diff --git a/spec/lib/services/assembla_user_service_spec.rb b/spec/lib/services/assembla_user_service_spec.rb new file mode 100644 index 000000000..411061a87 --- /dev/null +++ b/spec/lib/services/assembla_user_service_spec.rb @@ -0,0 +1,95 @@ +require 'spec_helper' + +RSpec.describe Travis::Services::AssemblaUserService do + let(:payload) do + { + 'id' => '12345', + 'name' => 'Test User', + 'email' => 'test@example.com', + '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]) + expect(service_user.confirmed_at).to be_present + 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 + + it 'has admin membership' do + service_org = service.find_or_create_organization(user) + expect(service_org.memberships.find_by(user: user).role).to eq('admin') + 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 diff --git a/spec/travis/remote_vcs/user_spec.rb b/spec/travis/remote_vcs/user_spec.rb index 9c3c5f523..613719a7b 100644 --- a/spec/travis/remote_vcs/user_spec.rb +++ b/spec/travis/remote_vcs/user_spec.rb @@ -49,6 +49,32 @@ end end + describe '#sync' do + let(:user_id) { 123 } + let(:space_id) { 456 } + let(:repository_id) { 789 } + let(:instance) { described_class.new } + let(:req) { double(:request) } + let(:params) { double(:params) } + + subject { instance.sync(user_id: user_id, space_id: space_id, repository_id: repository_id) } + + before do + allow(req).to receive(:url) + allow(req).to receive(:params).and_return(params) + allow(params).to receive(:[]=) + end + + it 'performs POST to VCS with proper params' do + expect(instance).to receive(:request).with(:post, :sync).and_yield(req) + expect(req).to receive(:url).with("users/#{user_id}/sync_data") + expect(params).to receive(:[]=).with('space_id', space_id) + expect(params).to receive(:[]=).with('repository_id', repository_id) + + expect(subject).to be true + end + end + describe '#authenticate' do let(:user) { described_class.new } let(:provider) { 'assembla' } diff --git a/spec/unit/endpoint/assembla_spec.rb b/spec/unit/endpoint/assembla_spec.rb new file mode 100644 index 000000000..08145da87 --- /dev/null +++ b/spec/unit/endpoint/assembla_spec.rb @@ -0,0 +1,121 @@ +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' => 'test@example.com', + 'login' => 'testuser', + 'space_id' => 'space123', + 'id' => 'assembla_vcs_user_id', + 'access_token' => 'test_access_token', + 'repository_id' => 'repository123', + '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: 'test@example.com', organizations: organizations) } + let(:organization) { double('Organization', id: 1) } + let(:organizations) { double('Organizations') } + let(:subscription_response) { { 'status' => 'subscribed' } } + let(:assembla_cluster) { 'eu' } + let(:access_token) { double('AccessToken', token: 'mocked_access_token_123') } + 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) + allow(Travis::Api::App::AccessToken).to receive(:create).with(user: user, app_id: 0).and_return(access_token) + 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(access_token.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