From 447f8e3f52d49eb8575f7dcfcc3c9be4beaf47f2 Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Mon, 7 Jul 2025 04:43:55 +0500 Subject: [PATCH 01/14] [TBT-381] Integrated assembla JWT login endpoint --- lib/travis/api/app/endpoint.rb | 1 + lib/travis/api/app/endpoint/assembla.rb | 76 +++++++++++++++++++ lib/travis/config/defaults.rb | 5 +- spec/unit/endpoint/assembla_spec.rb | 99 +++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 lib/travis/api/app/endpoint/assembla.rb create mode 100644 spec/unit/endpoint/assembla_spec.rb 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..89fd483f9 --- /dev/null +++ b/lib/travis/api/app/endpoint/assembla.rb @@ -0,0 +1,76 @@ +require 'travis/api/app' +require 'jwt' +require 'travis/remote_vcs/user' +require 'travis/remote_vcs/repository' + +class Travis::Api::App + class Endpoint + class Assembla < Endpoint + set prefix: '/assembla' + set :check_auth, false + + before do + 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 + end + + # POST /assembla/login + # Accepts a JWT, finds or creates a user, and signs them in + post '/login' do + user = find_or_create_user(@jwt_payload) + begin + Travis::RemoteVCS::User.new.sync(user_id: user.id) + rescue => e + halt 500, { error: 'User sync failed', details: e.message }.to_json + end + { user_id: user.id, login: user.login, token: user.token, status: 'signed_in' }.to_json + end + + private + + def verify_jwt + token = extract_jwt_token + halt 401, { error: 'Missing JWT' } unless token + secret = Travis.config.assembla_jwt_secret + begin + decoded, = JWT.decode(token, secret, true, { algorithm: 'HS256' }) + decoded + rescue JWT::DecodeError => e + halt 401, { error: 'Invalid JWT', details: e.message } + end + end + + def extract_jwt_token + request.env['HTTP_AUTHORIZATION']&.split(' ')&.last + end + + def deep_integration_enabled? + Travis.config.deep_integration_enabled + end + + def valid_asm_cluster? + allowed = Array(Travis.config.assembla_clusters) + cluster = request.env['HTTP_X_ASSEMBLA_CLUSTER'] + allowed.include?(cluster) + end + + # Finds or creates a user based on the payload + def find_or_create_user(payload) + required_fields = %w[name email login space_id] + missing = required_fields.select { |f| payload[f].nil? || payload[f].to_s.strip.empty? } + unless missing.empty? + halt 400, { error: 'Missing required fields', missing: missing }.to_json + end + attrs = { + name: payload['name'], + email: payload['email'], + login: payload['login'], + org_id: payload['space_id'], + vcs_type: 'AssemblaUser' + } + ::User.first_or_create!(attrs) + end + end + end +end \ No newline at end of file diff --git a/lib/travis/config/defaults.rb b/lib/travis/config/defaults.rb index a0ebc3f61..a4672080f 100644 --- a/lib/travis/config/defaults.rb +++ b/lib/travis/config/defaults.rb @@ -106,7 +106,10 @@ 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'}], + deep_integration_enabled: ENV['DEEP_INTEGRATION_ENABLED'], + assembla_clusters: ENV['ASSEMBLA_CLUSTERS'].split(','), + assembla_jwt_secret: ENV['ASSEMBLA_JWT_SECRET'] default :_access => [:key] diff --git a/spec/unit/endpoint/assembla_spec.rb b/spec/unit/endpoint/assembla_spec.rb new file mode 100644 index 000000000..0efa33d8d --- /dev/null +++ b/spec/unit/endpoint/assembla_spec.rb @@ -0,0 +1,99 @@ +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) { 'testsecret' } + let(:payload) do + { + 'name' => 'Test User', + 'email' => 'test@example.com', + 'login' => 'testuser', + 'space_id' => 'space123' + } + end + let(:token) { JWT.encode(payload, jwt_secret, 'HS256') } + + before do + Travis.config[:deep_integration_enabled] = true + Travis.config[:assembla_clusters] = ['cluster1'] + Travis.config[:assembla_jwt_secret] = jwt_secret + + header 'X_ASSEMBLA_CLUSTER', 'cluster1' + end + + describe 'POST /assembla/login' do + context 'with valid JWT' do + before do + allow(::User).to receive(:first_or_create!).and_return(double('User', id: 1, login: 'testuser', token: 'abc123')) + allow_any_instance_of(Travis::RemoteVCS::User).to receive(:sync).and_return(true) + end + + it 'returns user info and token' do + binding.pry + header 'Authorization', "Bearer #{token}" + post '/assembla/login' + expect(last_response.status).to eq(200) + body = JSON.parse(last_response.body) + expect(body['user_id']).to eq(1) + expect(body['login']).to eq('testuser') + expect(body['token']).to eq('abc123') + expect(body['status']).to eq('signed_in') + 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 'when user sync fails' do + before do + allow(::User).to receive(:first_or_create!).and_return(double('User', id: 1, login: 'testuser', token: 'abc123')) + allow_any_instance_of(Travis::RemoteVCS::User).to receive(:sync).and_raise(StandardError.new('sync error')) + end + + it 'returns 500 with error message' do + header 'Authorization', "Bearer #{token}" + post '/assembla/login' + expect(last_response.status).to eq(500) + expect(last_response.body).to include('User sync failed') + expect(last_response.body).to include('sync error') + end + end + + context 'when integration is not enabled' do + before { Travis.config[:deep_integration_enabled] = false } + 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 From 1934be60a434b68fc522534051617dd3b9f6d197 Mon Sep 17 00:00:00 2001 From: mshahzaib-travis Date: Mon, 7 Jul 2025 04:48:12 +0500 Subject: [PATCH 02/14] [TBT-381] Fixed assembla spec --- spec/unit/endpoint/assembla_spec.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/unit/endpoint/assembla_spec.rb b/spec/unit/endpoint/assembla_spec.rb index 0efa33d8d..328a57d1e 100644 --- a/spec/unit/endpoint/assembla_spec.rb +++ b/spec/unit/endpoint/assembla_spec.rb @@ -32,7 +32,6 @@ end it 'returns user info and token' do - binding.pry header 'Authorization', "Bearer #{token}" post '/assembla/login' expect(last_response.status).to eq(200) From e461de264de2b39159915f7f4e5251155f69c18e Mon Sep 17 00:00:00 2001 From: mshahzaib-travis Date: Mon, 7 Jul 2025 05:02:33 +0500 Subject: [PATCH 03/14] [TBT-381] Fixed build failure Env issue --- lib/travis/api/app/endpoint/assembla.rb | 2 +- lib/travis/config/defaults.rb | 2 +- spec/unit/endpoint/assembla_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/travis/api/app/endpoint/assembla.rb b/lib/travis/api/app/endpoint/assembla.rb index 89fd483f9..2a8623af9 100644 --- a/lib/travis/api/app/endpoint/assembla.rb +++ b/lib/travis/api/app/endpoint/assembla.rb @@ -50,7 +50,7 @@ def deep_integration_enabled? end def valid_asm_cluster? - allowed = Array(Travis.config.assembla_clusters) + allowed = Array(Travis.config.assembla_clusters.split(',')) cluster = request.env['HTTP_X_ASSEMBLA_CLUSTER'] allowed.include?(cluster) end diff --git a/lib/travis/config/defaults.rb b/lib/travis/config/defaults.rb index a4672080f..9ffc230f3 100644 --- a/lib/travis/config/defaults.rb +++ b/lib/travis/config/defaults.rb @@ -108,7 +108,7 @@ def fallback_logs_api_auth_token legacy_roles: false, internal_users: [{id: 0, login: 'cron'}], deep_integration_enabled: ENV['DEEP_INTEGRATION_ENABLED'], - assembla_clusters: ENV['ASSEMBLA_CLUSTERS'].split(','), + assembla_clusters: ENV['ASSEMBLA_CLUSTERS'], assembla_jwt_secret: ENV['ASSEMBLA_JWT_SECRET'] default :_access => [:key] diff --git a/spec/unit/endpoint/assembla_spec.rb b/spec/unit/endpoint/assembla_spec.rb index 328a57d1e..2a8f4dcd7 100644 --- a/spec/unit/endpoint/assembla_spec.rb +++ b/spec/unit/endpoint/assembla_spec.rb @@ -18,7 +18,7 @@ before do Travis.config[:deep_integration_enabled] = true - Travis.config[:assembla_clusters] = ['cluster1'] + Travis.config[:assembla_clusters] = 'cluster1' Travis.config[:assembla_jwt_secret] = jwt_secret header 'X_ASSEMBLA_CLUSTER', 'cluster1' From 0526c13f8b46ff87c1dc3960c75c37abc72e1f42 Mon Sep 17 00:00:00 2001 From: mshahzaib-travis Date: Tue, 8 Jul 2025 04:09:46 +0500 Subject: [PATCH 04/14] [TBT-381] Create beta_plan for org --- lib/travis/api/app/endpoint/assembla.rb | 59 +++++++++++++++---------- lib/travis/api/app/jwt_utils.rb | 20 +++++++++ spec/unit/endpoint/assembla_spec.rb | 44 ++++++++++++++++-- 3 files changed, 96 insertions(+), 27 deletions(-) create mode 100644 lib/travis/api/app/jwt_utils.rb diff --git a/lib/travis/api/app/endpoint/assembla.rb b/lib/travis/api/app/endpoint/assembla.rb index 2a8623af9..9f0bc70fc 100644 --- a/lib/travis/api/app/endpoint/assembla.rb +++ b/lib/travis/api/app/endpoint/assembla.rb @@ -2,48 +2,42 @@ require 'jwt' require 'travis/remote_vcs/user' require 'travis/remote_vcs/repository' +require 'travis/api/v3/billing_client' +require_relative '../jwt_utils' class Travis::Api::App class Endpoint class Assembla < Endpoint + include Travis::Api::App::JWTUtils set prefix: '/assembla' set :check_auth, false before do 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 + begin + @jwt_payload = verify_jwt(request, Travis.config.assembla_jwt_secret) + rescue JWTUtils::UnauthorizedError => e + halt 401, { error: e.message }.to_json + end end # POST /assembla/login # Accepts a JWT, finds or creates a user, and signs them in post '/login' do user = find_or_create_user(@jwt_payload) - begin - Travis::RemoteVCS::User.new.sync(user_id: user.id) - rescue => e - halt 500, { error: 'User sync failed', details: e.message }.to_json - end - { user_id: user.id, login: user.login, token: user.token, status: 'signed_in' }.to_json - end - - private + sync_user(user.id) + create_org_subscription(user.id, @jwt_payload[:space_id]) - def verify_jwt - token = extract_jwt_token - halt 401, { error: 'Missing JWT' } unless token - secret = Travis.config.assembla_jwt_secret - begin - decoded, = JWT.decode(token, secret, true, { algorithm: 'HS256' }) - decoded - rescue JWT::DecodeError => e - halt 401, { error: 'Invalid JWT', details: e.message } - end + { + user_id: user.id, + login: user.login, + token: user.token, + status: 'signed_in' + }.to_json end - def extract_jwt_token - request.env['HTTP_AUTHORIZATION']&.split(' ')&.last - end + private def deep_integration_enabled? Travis.config.deep_integration_enabled @@ -69,7 +63,24 @@ def find_or_create_user(payload) org_id: payload['space_id'], vcs_type: 'AssemblaUser' } - ::User.first_or_create!(attrs) + ::User.find_or_create_by!(attrs) + end + + def sync_user(user_id) + Travis::RemoteVCS::User.new.sync(user_id: user_id) + rescue => e + halt 500, { error: 'User sync failed', details: e.message }.to_json + end + + def create_org_subscription(user_id, space_id) + plan = 'beta_plan' + client = Travis::API::V3::BillingClient.new(user_id) + client.create_v2_subscription({ + 'plan' => plan, + 'organization_id' => space_id, + }) + rescue => e + halt 500, { error: 'Subscription creation failed', details: e.message }.to_json 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..deb29dc8d --- /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, secret) + token = extract_jwt_token(request) + raise UnauthorizedError, 'Missing JWT' unless token + begin + decoded, = JWT.decode(token, secret, true, { algorithm: 'HS256' }) + decoded + rescue JWT::DecodeError => e + raise UnauthorizedError, "Invalid JWT: #{e.message}" + end + end + + class UnauthorizedError < StandardError; end + end +end diff --git a/spec/unit/endpoint/assembla_spec.rb b/spec/unit/endpoint/assembla_spec.rb index 2a8f4dcd7..7ba75300c 100644 --- a/spec/unit/endpoint/assembla_spec.rb +++ b/spec/unit/endpoint/assembla_spec.rb @@ -27,8 +27,8 @@ describe 'POST /assembla/login' do context 'with valid JWT' do before do - allow(::User).to receive(:first_or_create!).and_return(double('User', id: 1, login: 'testuser', token: 'abc123')) allow_any_instance_of(Travis::RemoteVCS::User).to receive(:sync).and_return(true) + allow_any_instance_of(Travis::API::V3::BillingClient).to receive(:create_v2_subscription).and_return(true) end it 'returns user info and token' do @@ -36,9 +36,8 @@ post '/assembla/login' expect(last_response.status).to eq(200) body = JSON.parse(last_response.body) - expect(body['user_id']).to eq(1) expect(body['login']).to eq('testuser') - expect(body['token']).to eq('abc123') + expect(body['token']).to be_present expect(body['status']).to eq('signed_in') end end @@ -94,5 +93,44 @@ expect(last_response.body).to include('Invalid ASM cluster') end end + + context 'with missing required fields in JWT payload' do + let(:payload) do + { + 'name' => 'Test User', + 'login' => 'testuser', + 'space_id' => 'space123' # 'email' is missing + } + end + let(:token) { JWT.encode(payload, jwt_secret, 'HS256') } + + it 'returns 400 with missing fields' do + header 'Authorization', "Bearer #{token}" + post '/assembla/login' + expect(last_response.status).to eq(400) + expect(last_response.body).to include('Missing required fields') + expect(last_response.body).to include('email') + end + end + + context 'with expired JWT token' do + let(:payload) do + { + 'name' => 'Test User', + 'email' => 'test@example.com', + 'login' => 'testuser', + 'space_id' => 'space123', + 'exp' => (Time.now.to_i - 60) + } + end + let(:token) { JWT.encode(payload, jwt_secret, 'HS256') } + + it 'returns 401 with expired error' do + header 'Authorization', "Bearer #{token}" + post '/assembla/login' + expect(last_response.status).to eq(401) + expect(last_response.body).to match(/expired|exp/i) + end + end end end From 7b5948a9bb090170c36f5da86638b1c22b102f9b Mon Sep 17 00:00:00 2001 From: mshahzaib-travis Date: Fri, 11 Jul 2025 04:27:27 +0500 Subject: [PATCH 05/14] [TBT-381] - Sync and Create Subscription --- lib/travis/api/app/endpoint/assembla.rb | 74 ++++----- lib/travis/api/app/jwt_utils.rb | 11 +- lib/travis/model/user.rb | 4 +- lib/travis/services/assembla_user_service.rb | 69 +++++++++ .../services/assembla_user_service_spec.rb | 144 ++++++++++++++++++ spec/unit/endpoint/assembla_spec.rb | 85 ++++------- 6 files changed, 281 insertions(+), 106 deletions(-) create mode 100644 lib/travis/services/assembla_user_service.rb create mode 100644 spec/lib/services/assembla_user_service_spec.rb diff --git a/lib/travis/api/app/endpoint/assembla.rb b/lib/travis/api/app/endpoint/assembla.rb index 9f0bc70fc..d9d53a10b 100644 --- a/lib/travis/api/app/endpoint/assembla.rb +++ b/lib/travis/api/app/endpoint/assembla.rb @@ -3,33 +3,35 @@ 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 access_token refresh_token].freeze + CLUSTER_HEADER = 'HTTP_X_ASSEMBLA_CLUSTER'.freeze + + set prefix: '/assembla' set :check_auth, false before do - halt 403, { error: 'Deep integration not enabled' } unless deep_integration_enabled? - halt 403, { error: 'Invalid ASM cluster' } unless valid_asm_cluster? - begin - @jwt_payload = verify_jwt(request, Travis.config.assembla_jwt_secret) - rescue JWTUtils::UnauthorizedError => e - halt 401, { error: e.message }.to_json - end + validate_request! end - # POST /assembla/login - # Accepts a JWT, finds or creates a user, and signs them in post '/login' do - user = find_or_create_user(@jwt_payload) - sync_user(user.id) - create_org_subscription(user.id, @jwt_payload[:space_id]) + 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) + - { + response = { user_id: user.id, login: user.login, token: user.token, @@ -39,48 +41,28 @@ class Assembla < Endpoint private - def deep_integration_enabled? - Travis.config.deep_integration_enabled - end - - def valid_asm_cluster? - allowed = Array(Travis.config.assembla_clusters.split(',')) - cluster = request.env['HTTP_X_ASSEMBLA_CLUSTER'] - allowed.include?(cluster) + 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 - # Finds or creates a user based on the payload - def find_or_create_user(payload) - required_fields = %w[name email login space_id] - missing = required_fields.select { |f| payload[f].nil? || payload[f].to_s.strip.empty? } + 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 }.to_json end - attrs = { - name: payload['name'], - email: payload['email'], - login: payload['login'], - org_id: payload['space_id'], - vcs_type: 'AssemblaUser' - } - ::User.find_or_create_by!(attrs) end - def sync_user(user_id) - Travis::RemoteVCS::User.new.sync(user_id: user_id) - rescue => e - halt 500, { error: 'User sync failed', details: e.message }.to_json + def deep_integration_enabled? + Travis.config.deep_integration_enabled end - def create_org_subscription(user_id, space_id) - plan = 'beta_plan' - client = Travis::API::V3::BillingClient.new(user_id) - client.create_v2_subscription({ - 'plan' => plan, - 'organization_id' => space_id, - }) - rescue => e - halt 500, { error: 'Subscription creation failed', details: e.message }.to_json + def valid_asm_cluster? + allowed = Array(Travis.config.assembla_clusters.to_s.split(',')) + cluster = request.env[CLUSTER_HEADER] + !cluster.nil? && allowed.include?(cluster) end end end diff --git a/lib/travis/api/app/jwt_utils.rb b/lib/travis/api/app/jwt_utils.rb index deb29dc8d..ec3ccdf78 100644 --- a/lib/travis/api/app/jwt_utils.rb +++ b/lib/travis/api/app/jwt_utils.rb @@ -4,17 +4,18 @@ def extract_jwt_token(request) request.env['HTTP_AUTHORIZATION']&.split(' ')&.last end - def verify_jwt(request, secret) + def verify_jwt(request) + secret = Travis.config.assembla_jwt_secret token = extract_jwt_token(request) - raise UnauthorizedError, 'Missing JWT' unless token + + halt 401, { error: "Missing JWT" }.to_json unless token + begin decoded, = JWT.decode(token, secret, true, { algorithm: 'HS256' }) decoded rescue JWT::DecodeError => e - raise UnauthorizedError, "Invalid JWT: #{e.message}" + halt 401, { error: "Invalid JWT: #{e.message}" }.to_json end end - - class UnauthorizedError < StandardError; end end end 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/services/assembla_user_service.rb b/lib/travis/services/assembla_user_service.rb new file mode 100644 index 000000000..2b56cb083 --- /dev/null +++ b/lib/travis/services/assembla_user_service.rb @@ -0,0 +1,69 @@ +module Travis + module Services + class AssemblaUserService + class SyncError < StandardError; end + + def initialize(payload) + @payload = payload + end + + def find_or_create_user + attrs = { + vcs_id: @payload['id'], + email: @payload['email'], + login: @payload['login'], + vcs_type: 'AssemblaUser' + } + + user = ::User.find_or_create_by!(attrs) + user.update(vcs_oauth_token: @payload['refresh_token']) + sync_user(user.id) + user + end + + def find_or_create_organization(user) + attrs = { + vcs_id: @payload['space_id'], + vcs_type: 'AssemblaOrganization' + } + user.organizations.find_or_create_by(attrs) + end + + def create_org_subscription(user, organization_id) + client = Travis::API::V3::BillingClient.new(user.id) + 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 + raise SyncError, "Failed to sync user: #{e.message}" + end + + def subscription_params(user, organization_id) + { + 'plan' => 'beta_plan', + 'organization_id' => organization_id, + 'billing_info' => billing_info(user), + 'credit_card_info' => { 'token' => nil } + } + end + + def billing_info(user) + { + 'address' => "System-generated for user #{user.login} (#{user.id})", + 'city' => "AutoCity-#{user.id}", + 'country' => 'Poland', + 'first_name' => user.name&.split&.first, + 'last_name' => user.name&.split&.last, + 'zip_code' => "000#{user.id}", + '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..739407862 --- /dev/null +++ b/spec/lib/services/assembla_user_service_spec.rb @@ -0,0 +1,144 @@ +require 'spec_helper' + +RSpec.describe Travis::Services::AssemblaUserService do + let(:payload) do + { + 'id' => '12345', + 'email' => 'test@example.com', + 'login' => 'testuser', + 'refresh_token' => 'refresh123', + 'space_id' => '67890' + } + end + + let(:service) { described_class.new(payload) } + let(:user) { double('User', id: 1, login: 'testuser', email: 'test@example.com', name: 'Test User') } + let(:organization) { double('Organization', id: 2) } + + describe '#initialize' do + it 'stores the payload' do + expect(service.instance_variable_get(:@payload)).to eq(payload) + end + end + + describe '#find_or_create_user' do + let(:expected_attrs) do + { + vcs_id: '12345', + email: 'test@example.com', + login: 'testuser', + vcs_type: 'AssemblaUser' + } + end + + before do + allow(::User).to receive(:find_or_create_by!).with(expected_attrs).and_return(user) + allow(user).to receive(:update) + allow(Travis::RemoteVCS::User).to receive(:new).and_return(double(sync: true)) + end + + it 'finds or creates a user with correct attributes' do + expect(::User).to receive(:find_or_create_by!).with(expected_attrs) + service.find_or_create_user + end + + it 'returns the user' do + result = service.find_or_create_user + expect(result).to eq(user) + 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(:organizations_relation) { double('organizations') } + let(:expected_attrs) do + { + vcs_id: '67890', + vcs_type: 'AssemblaOrganization' + } + end + + before do + allow(user).to receive(:organizations).and_return(organizations_relation) + end + + it 'finds or creates organization with correct attributes' do + expect(organizations_relation).to receive(:find_or_create_by).with(expected_attrs).and_return(organization) + + result = service.find_or_create_organization(user) + expect(result).to eq(organization) + end + end + + describe '#create_org_subscription' do + let(:billing_client) { double('BillingClient') } + let(:expected_subscription_params) do + { + 'plan' => 'beta_plan', + 'organization_id' => 2, + 'billing_info' => { + 'address' => 'System-generated for user testuser (1)', + 'city' => 'AutoCity-1', + 'country' => 'Poland', + 'first_name' => 'Test', + 'last_name' => 'User', + 'zip_code' => '0001', + 'billing_email' => 'test@example.com' + }, + 'credit_card_info' => { 'token' => nil } + } + end + + before do + allow(Travis::API::V3::BillingClient).to receive(:new).with(1).and_return(billing_client) + end + + it 'creates a billing client with user id' do + expect(Travis::API::V3::BillingClient).to receive(:new).with(1) + allow(billing_client).to receive(:create_v2_subscription) + + service.create_org_subscription(user, 2) + end + + it 'calls create_v2_subscription with correct params' do + expect(billing_client).to receive(:create_v2_subscription).with(expected_subscription_params) + + service.create_org_subscription(user, 2) + 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, 2) + expect(result).to eq({ error: true, details: 'Billing error' }) + end + end + + context 'when user has no name' do + let(:user_without_name) { double('User', id: 1, login: 'testuser', email: 'test@example.com', name: nil) } + + it 'handles nil name gracefully' do + expected_params = expected_subscription_params.dup + expected_params['billing_info']['first_name'] = nil + expected_params['billing_info']['last_name'] = nil + + expect(billing_client).to receive(:create_v2_subscription).with(expected_params) + + service.create_org_subscription(user_without_name, 2) + end + end + end +end diff --git a/spec/unit/endpoint/assembla_spec.rb b/spec/unit/endpoint/assembla_spec.rb index 7ba75300c..88dcfa1ec 100644 --- a/spec/unit/endpoint/assembla_spec.rb +++ b/spec/unit/endpoint/assembla_spec.rb @@ -11,10 +11,17 @@ 'name' => 'Test User', 'email' => 'test@example.com', 'login' => 'testuser', - 'space_id' => 'space123' + '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: 'test@example.com', organizations: organizations) } + let(:organization) { double('Organization', id: 1) } + let(:organizations) { double('Organizations') } + let(:subscription_response) { { 'status' => 'subscribed' } } before do Travis.config[:deep_integration_enabled] = true @@ -26,18 +33,25 @@ 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_any_instance_of(Travis::RemoteVCS::User).to receive(:sync).and_return(true) - allow_any_instance_of(Travis::API::V3::BillingClient).to receive(:create_v2_subscription).and_return(true) + 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 'returns user info and token' do + 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('testuser') - expect(body['token']).to be_present + expect(body['token']).to eq('abc123') expect(body['status']).to eq('signed_in') end end @@ -59,23 +73,24 @@ end end - context 'when user sync fails' do - before do - allow(::User).to receive(:first_or_create!).and_return(double('User', id: 1, login: 'testuser', token: 'abc123')) - allow_any_instance_of(Travis::RemoteVCS::User).to receive(:sync).and_raise(StandardError.new('sync error')) - 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 500 with error message' do - header 'Authorization', "Bearer #{token}" + it 'returns 400 with missing fields' do + header 'Authorization', "Bearer #{invalid_token}" post '/assembla/login' - expect(last_response.status).to eq(500) - expect(last_response.body).to include('User sync failed') - expect(last_response.body).to include('sync error') + + 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] = false } + it 'returns 403' do header 'Authorization', "Bearer #{token}" post '/assembla/login' @@ -86,6 +101,7 @@ context 'when cluster is invalid' do before { header 'X_ASSEMBLA_CLUSTER', 'invalid-cluster' } + it 'returns 403' do header 'Authorization', "Bearer #{token}" post '/assembla/login' @@ -93,44 +109,5 @@ expect(last_response.body).to include('Invalid ASM cluster') end end - - context 'with missing required fields in JWT payload' do - let(:payload) do - { - 'name' => 'Test User', - 'login' => 'testuser', - 'space_id' => 'space123' # 'email' is missing - } - end - let(:token) { JWT.encode(payload, jwt_secret, 'HS256') } - - it 'returns 400 with missing fields' do - header 'Authorization', "Bearer #{token}" - post '/assembla/login' - expect(last_response.status).to eq(400) - expect(last_response.body).to include('Missing required fields') - expect(last_response.body).to include('email') - end - end - - context 'with expired JWT token' do - let(:payload) do - { - 'name' => 'Test User', - 'email' => 'test@example.com', - 'login' => 'testuser', - 'space_id' => 'space123', - 'exp' => (Time.now.to_i - 60) - } - end - let(:token) { JWT.encode(payload, jwt_secret, 'HS256') } - - it 'returns 401 with expired error' do - header 'Authorization', "Bearer #{token}" - post '/assembla/login' - expect(last_response.status).to eq(401) - expect(last_response.body).to match(/expired|exp/i) - end - end end end From ab5b72776c6edb9ff6277a0861a6f8a6a3914ca7 Mon Sep 17 00:00:00 2001 From: mshahzaib-travis Date: Fri, 11 Jul 2025 16:17:02 +0500 Subject: [PATCH 06/14] [TBT-381] Feedback incorporated --- lib/travis/api/app/endpoint/assembla.rb | 15 ++- lib/travis/api/app/jwt_utils.rb | 9 +- lib/travis/config/defaults.rb | 7 +- lib/travis/services/assembla_user_service.rb | 34 ++++--- .../services/assembla_user_service_spec.rb | 98 +++++-------------- spec/unit/endpoint/assembla_spec.rb | 12 ++- 6 files changed, 63 insertions(+), 112 deletions(-) diff --git a/lib/travis/api/app/endpoint/assembla.rb b/lib/travis/api/app/endpoint/assembla.rb index d9d53a10b..9537459a4 100644 --- a/lib/travis/api/app/endpoint/assembla.rb +++ b/lib/travis/api/app/endpoint/assembla.rb @@ -12,10 +12,9 @@ class Endpoint class Assembla < Endpoint include Travis::Api::App::JWTUtils - REQUIRED_JWT_FIELDS = %w[name email login space_id id access_token refresh_token].freeze + 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 @@ -30,13 +29,11 @@ class Assembla < Endpoint org = service.find_or_create_organization(user) service.create_org_subscription(user, org.id) - - response = { + { user_id: user.id, login: user.login, - token: user.token, - status: 'signed_in' - }.to_json + token: user.token + } end private @@ -51,7 +48,7 @@ def validate_request! 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 }.to_json + halt 400, { error: 'Missing required fields', missing: missing } end end @@ -62,7 +59,7 @@ def deep_integration_enabled? def valid_asm_cluster? allowed = Array(Travis.config.assembla_clusters.to_s.split(',')) cluster = request.env[CLUSTER_HEADER] - !cluster.nil? && allowed.include?(cluster) + allowed.include?(cluster) end end end diff --git a/lib/travis/api/app/jwt_utils.rb b/lib/travis/api/app/jwt_utils.rb index ec3ccdf78..f1a832723 100644 --- a/lib/travis/api/app/jwt_utils.rb +++ b/lib/travis/api/app/jwt_utils.rb @@ -1,20 +1,19 @@ class Travis::Api::App module JWTUtils def extract_jwt_token(request) - request.env['HTTP_AUTHORIZATION']&.split(' ')&.last + request.env['HTTP_AUTHORIZATION']&.split&.last end def verify_jwt(request) - secret = Travis.config.assembla_jwt_secret token = extract_jwt_token(request) - halt 401, { error: "Missing JWT" }.to_json unless token + halt 401, { error: "Missing JWT" } unless token begin - decoded, = JWT.decode(token, secret, true, { algorithm: 'HS256' }) + decoded, = JWT.decode(token, Travis.config.assembla_jwt_secret, true, algorithm: 'HS256' ) decoded rescue JWT::DecodeError => e - halt 401, { error: "Invalid JWT: #{e.message}" }.to_json + halt 401, { error: "Invalid JWT: #{e.message}" } end end end diff --git a/lib/travis/config/defaults.rb b/lib/travis/config/defaults.rb index 9ffc230f3..03ee13ff4 100644 --- a/lib/travis/config/defaults.rb +++ b/lib/travis/config/defaults.rb @@ -107,9 +107,10 @@ def fallback_logs_api_auth_token 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'}], - deep_integration_enabled: ENV['DEEP_INTEGRATION_ENABLED'], - assembla_clusters: ENV['ASSEMBLA_CLUSTERS'], - assembla_jwt_secret: ENV['ASSEMBLA_JWT_SECRET'] + assembla_clusters: 'eu, us', + deep_integration_enabled: false, + assembla_jwt_secret: 'assembla_jwt_secret', + beta_plan_name: 'beta_plan' default :_access => [:key] diff --git a/lib/travis/services/assembla_user_service.rb b/lib/travis/services/assembla_user_service.rb index 2b56cb083..671e6a110 100644 --- a/lib/travis/services/assembla_user_service.rb +++ b/lib/travis/services/assembla_user_service.rb @@ -3,35 +3,39 @@ module Services class AssemblaUserService class SyncError < StandardError; end + BILLING_COUNTRY = 'Poland' + BILLING_ADDRESS = "System-generated for user %{login} (%{id})" + BILLING_CITY = "AutoCity-%{id}" + BILLING_ZIP = "000%{id}" + def initialize(payload) @payload = payload end def find_or_create_user - attrs = { + user = ::User.find_or_initialize_by( + name: @payload['name'], vcs_id: @payload['id'], email: @payload['email'], login: @payload['login'], vcs_type: 'AssemblaUser' - } - - user = ::User.find_or_create_by!(attrs) - user.update(vcs_oauth_token: @payload['refresh_token']) + ) + user.vcs_oauth_token = @payload['refresh_token'] + user.save! sync_user(user.id) user end def find_or_create_organization(user) - attrs = { + user.organizations.find_or_create_by!( vcs_id: @payload['space_id'], vcs_type: 'AssemblaOrganization' - } - user.organizations.find_or_create_by(attrs) + ) end def create_org_subscription(user, organization_id) - client = Travis::API::V3::BillingClient.new(user.id) - client.create_v2_subscription(subscription_params(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 @@ -46,7 +50,7 @@ def sync_user(user_id) def subscription_params(user, organization_id) { - 'plan' => 'beta_plan', + 'plan' => Travis.config.beta_plan_name, 'organization_id' => organization_id, 'billing_info' => billing_info(user), 'credit_card_info' => { 'token' => nil } @@ -55,12 +59,12 @@ def subscription_params(user, organization_id) def billing_info(user) { - 'address' => "System-generated for user #{user.login} (#{user.id})", - 'city' => "AutoCity-#{user.id}", - 'country' => 'Poland', + 'address' => BILLING_ADDRESS % { login: user.login, id: user.id }, + 'city' => BILLING_CITY % { id: user.id }, + 'country' => BILLING_COUNTRY, 'first_name' => user.name&.split&.first, 'last_name' => user.name&.split&.last, - 'zip_code' => "000#{user.id}", + 'zip_code' => BILLING_ZIP % { id: user.id }, 'billing_email' => user.email } end diff --git a/spec/lib/services/assembla_user_service_spec.rb b/spec/lib/services/assembla_user_service_spec.rb index 739407862..4e4836dc8 100644 --- a/spec/lib/services/assembla_user_service_spec.rb +++ b/spec/lib/services/assembla_user_service_spec.rb @@ -1,9 +1,11 @@ require 'spec_helper' +require 'factory_bot' RSpec.describe Travis::Services::AssemblaUserService do let(:payload) do { 'id' => '12345', + 'name' => 'Test User', 'email' => 'test@example.com', 'login' => 'testuser', 'refresh_token' => 'refresh123', @@ -12,39 +14,30 @@ end let(:service) { described_class.new(payload) } - let(:user) { double('User', id: 1, login: 'testuser', email: 'test@example.com', name: 'Test User') } - let(:organization) { double('Organization', id: 2) } - - describe '#initialize' do - it 'stores the payload' do - expect(service.instance_variable_get(:@payload)).to eq(payload) - end - end + 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: '12345', - email: 'test@example.com', - login: 'testuser', + vcs_id: payload['id'], + email: payload['email'], + name: payload['name'], + login: payload['login'], vcs_type: 'AssemblaUser' } end before do - allow(::User).to receive(:find_or_create_by!).with(expected_attrs).and_return(user) - allow(user).to receive(:update) allow(Travis::RemoteVCS::User).to receive(:new).and_return(double(sync: true)) end it 'finds or creates a user with correct attributes' do - expect(::User).to receive(:find_or_create_by!).with(expected_attrs) - service.find_or_create_user - end - - it 'returns the user' do - result = service.find_or_create_user - expect(result).to eq(user) + 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 @@ -60,60 +53,26 @@ end describe '#find_or_create_organization' do - let(:organizations_relation) { double('organizations') } let(:expected_attrs) do { - vcs_id: '67890', + vcs_id: payload['space_id'], vcs_type: 'AssemblaOrganization' } end - before do - allow(user).to receive(:organizations).and_return(organizations_relation) - end - it 'finds or creates organization with correct attributes' do - expect(organizations_relation).to receive(:find_or_create_by).with(expected_attrs).and_return(organization) + service_org = service.find_or_create_organization(user) - result = service.find_or_create_organization(user) - expect(result).to eq(organization) + 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') } - let(:expected_subscription_params) do - { - 'plan' => 'beta_plan', - 'organization_id' => 2, - 'billing_info' => { - 'address' => 'System-generated for user testuser (1)', - 'city' => 'AutoCity-1', - 'country' => 'Poland', - 'first_name' => 'Test', - 'last_name' => 'User', - 'zip_code' => '0001', - 'billing_email' => 'test@example.com' - }, - 'credit_card_info' => { 'token' => nil } - } - end before do - allow(Travis::API::V3::BillingClient).to receive(:new).with(1).and_return(billing_client) - end - - it 'creates a billing client with user id' do - expect(Travis::API::V3::BillingClient).to receive(:new).with(1) - allow(billing_client).to receive(:create_v2_subscription) - - service.create_org_subscription(user, 2) - end - - it 'calls create_v2_subscription with correct params' do - expect(billing_client).to receive(:create_v2_subscription).with(expected_subscription_params) - - service.create_org_subscription(user, 2) + allow(Travis::API::V3::BillingClient).to receive(:new).with(user.id).and_return(billing_client) end context 'when billing client raises an error' do @@ -122,23 +81,10 @@ it 'returns error hash' do allow(billing_client).to receive(:create_v2_subscription).and_raise(error) - result = service.create_org_subscription(user, 2) - expect(result).to eq({ error: true, details: 'Billing error' }) - end - end - - context 'when user has no name' do - let(:user_without_name) { double('User', id: 1, login: 'testuser', email: 'test@example.com', name: nil) } - - it 'handles nil name gracefully' do - expected_params = expected_subscription_params.dup - expected_params['billing_info']['first_name'] = nil - expected_params['billing_info']['last_name'] = nil - - expect(billing_client).to receive(:create_v2_subscription).with(expected_params) - - service.create_org_subscription(user_without_name, 2) + result = service.create_org_subscription(user, organization.id) + expect(result[:error]).to be_truthy + expect(result[:details]).to be_present end end end -end +end \ No newline at end of file diff --git a/spec/unit/endpoint/assembla_spec.rb b/spec/unit/endpoint/assembla_spec.rb index 88dcfa1ec..7ecc25567 100644 --- a/spec/unit/endpoint/assembla_spec.rb +++ b/spec/unit/endpoint/assembla_spec.rb @@ -22,13 +22,18 @@ let(:organization) { double('Organization', id: 1) } let(:organizations) { double('Organizations') } let(:subscription_response) { { 'status' => 'subscribed' } } + let(:assembla_cluster) { 'cluster1' } before do Travis.config[:deep_integration_enabled] = true - Travis.config[:assembla_clusters] = 'cluster1' + Travis.config[:assembla_clusters] = assembla_cluster Travis.config[:assembla_jwt_secret] = jwt_secret - header 'X_ASSEMBLA_CLUSTER', 'cluster1' + header 'X_ASSEMBLA_CLUSTER', assembla_cluster + end + + after do + Travis.config[:deep_integration_enabled] = false end describe 'POST /assembla/login' do @@ -50,9 +55,8 @@ expect(last_response.status).to eq(200) body = JSON.parse(last_response.body) - expect(body['login']).to eq('testuser') + expect(body['login']).to eq(user.login) expect(body['token']).to eq('abc123') - expect(body['status']).to eq('signed_in') end end From a1bf5c32cc408b6f2718cf9f5662d065c530aa16 Mon Sep 17 00:00:00 2001 From: mshahzaib-travis Date: Fri, 11 Jul 2025 17:10:59 +0500 Subject: [PATCH 07/14] [TBT-381] Feedback incorporated --- lib/travis/api/app/endpoint/assembla.rb | 5 ++--- lib/travis/api/app/jwt_utils.rb | 2 +- lib/travis/config/defaults.rb | 2 +- lib/travis/services/assembla_user_service.rb | 15 +++++---------- spec/lib/services/assembla_user_service_spec.rb | 5 ++--- 5 files changed, 11 insertions(+), 18 deletions(-) diff --git a/lib/travis/api/app/endpoint/assembla.rb b/lib/travis/api/app/endpoint/assembla.rb index 9537459a4..a6e273744 100644 --- a/lib/travis/api/app/endpoint/assembla.rb +++ b/lib/travis/api/app/endpoint/assembla.rb @@ -57,9 +57,8 @@ def deep_integration_enabled? end def valid_asm_cluster? - allowed = Array(Travis.config.assembla_clusters.to_s.split(',')) - cluster = request.env[CLUSTER_HEADER] - allowed.include?(cluster) + allowed = Travis.config.assembla_clusters.to_s.split(',') + allowed.include?(request.env[CLUSTER_HEADER]) end end end diff --git a/lib/travis/api/app/jwt_utils.rb b/lib/travis/api/app/jwt_utils.rb index f1a832723..caead2f0c 100644 --- a/lib/travis/api/app/jwt_utils.rb +++ b/lib/travis/api/app/jwt_utils.rb @@ -10,7 +10,7 @@ def verify_jwt(request) halt 401, { error: "Missing JWT" } unless token begin - decoded, = JWT.decode(token, Travis.config.assembla_jwt_secret, true, algorithm: 'HS256' ) + decoded, = JWT.decode(token, Travis.config.assembla_jwt_secret, true, algorithm: 'HS256') decoded rescue JWT::DecodeError => e halt 401, { error: "Invalid JWT: #{e.message}" } diff --git a/lib/travis/config/defaults.rb b/lib/travis/config/defaults.rb index 03ee13ff4..f3e421ded 100644 --- a/lib/travis/config/defaults.rb +++ b/lib/travis/config/defaults.rb @@ -110,7 +110,7 @@ def fallback_logs_api_auth_token assembla_clusters: 'eu, us', deep_integration_enabled: false, assembla_jwt_secret: 'assembla_jwt_secret', - beta_plan_name: 'beta_plan' + deep_integration_beta_plan_name: 'beta_plan' default :_access => [:key] diff --git a/lib/travis/services/assembla_user_service.rb b/lib/travis/services/assembla_user_service.rb index 671e6a110..884f33bb3 100644 --- a/lib/travis/services/assembla_user_service.rb +++ b/lib/travis/services/assembla_user_service.rb @@ -3,11 +3,6 @@ module Services class AssemblaUserService class SyncError < StandardError; end - BILLING_COUNTRY = 'Poland' - BILLING_ADDRESS = "System-generated for user %{login} (%{id})" - BILLING_CITY = "AutoCity-%{id}" - BILLING_ZIP = "000%{id}" - def initialize(payload) @payload = payload end @@ -50,7 +45,7 @@ def sync_user(user_id) def subscription_params(user, organization_id) { - 'plan' => Travis.config.beta_plan_name, + 'plan' => Travis.config.deep_integration_plan_name, 'organization_id' => organization_id, 'billing_info' => billing_info(user), 'credit_card_info' => { 'token' => nil } @@ -59,12 +54,12 @@ def subscription_params(user, organization_id) def billing_info(user) { - 'address' => BILLING_ADDRESS % { login: user.login, id: user.id }, - 'city' => BILLING_CITY % { id: user.id }, - 'country' => BILLING_COUNTRY, + 'address' => 'Dummy Address', + 'city' => 'Dummy City', + 'country' => 'Dummy Country', 'first_name' => user.name&.split&.first, 'last_name' => user.name&.split&.last, - 'zip_code' => BILLING_ZIP % { id: user.id }, + 'zip_code' => 'DUMMY ZIP', 'billing_email' => user.email } end diff --git a/spec/lib/services/assembla_user_service_spec.rb b/spec/lib/services/assembla_user_service_spec.rb index 4e4836dc8..77ece8c18 100644 --- a/spec/lib/services/assembla_user_service_spec.rb +++ b/spec/lib/services/assembla_user_service_spec.rb @@ -1,5 +1,4 @@ require 'spec_helper' -require 'factory_bot' RSpec.describe Travis::Services::AssemblaUserService do let(:payload) do @@ -83,8 +82,8 @@ result = service.create_org_subscription(user, organization.id) expect(result[:error]).to be_truthy - expect(result[:details]).to be_present + expect(result[:details]).to eq(error.message) end end end -end \ No newline at end of file +end From 1a2c6026b65637b6b07acf227c50218546c70f1c Mon Sep 17 00:00:00 2001 From: mshahzaib-travis Date: Fri, 11 Jul 2025 18:01:04 +0500 Subject: [PATCH 08/14] [TBT-381] used default values in spec --- lib/travis/api/app/endpoint/assembla.rb | 2 +- lib/travis/config/defaults.rb | 2 +- spec/unit/endpoint/assembla_spec.rb | 15 ++++++++------- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/travis/api/app/endpoint/assembla.rb b/lib/travis/api/app/endpoint/assembla.rb index a6e273744..5b079da79 100644 --- a/lib/travis/api/app/endpoint/assembla.rb +++ b/lib/travis/api/app/endpoint/assembla.rb @@ -62,4 +62,4 @@ def valid_asm_cluster? end end end -end \ No newline at end of file +end diff --git a/lib/travis/config/defaults.rb b/lib/travis/config/defaults.rb index f3e421ded..dfb3cd680 100644 --- a/lib/travis/config/defaults.rb +++ b/lib/travis/config/defaults.rb @@ -110,7 +110,7 @@ def fallback_logs_api_auth_token assembla_clusters: 'eu, us', deep_integration_enabled: false, assembla_jwt_secret: 'assembla_jwt_secret', - deep_integration_beta_plan_name: 'beta_plan' + deep_integration_plan_name: 'beta_plan' default :_access => [:key] diff --git a/spec/unit/endpoint/assembla_spec.rb b/spec/unit/endpoint/assembla_spec.rb index 7ecc25567..a541f7834 100644 --- a/spec/unit/endpoint/assembla_spec.rb +++ b/spec/unit/endpoint/assembla_spec.rb @@ -5,7 +5,7 @@ RSpec.describe Travis::Api::App::Endpoint::Assembla, set_app: true do include Rack::Test::Methods - let(:jwt_secret) { 'testsecret' } + let(:jwt_secret) { 'assembla_jwt_secret' } let(:payload) do { 'name' => 'Test User', @@ -22,18 +22,17 @@ let(:organization) { double('Organization', id: 1) } let(:organizations) { double('Organizations') } let(:subscription_response) { { 'status' => 'subscribed' } } - let(:assembla_cluster) { 'cluster1' } + let(:assembla_cluster) { 'eu' } + let!(:original_deep_integration_enabled) { Travis.config[:deep_integration_enabled] } before do Travis.config[:deep_integration_enabled] = true - Travis.config[:assembla_clusters] = assembla_cluster - Travis.config[:assembla_jwt_secret] = jwt_secret header 'X_ASSEMBLA_CLUSTER', assembla_cluster end after do - Travis.config[:deep_integration_enabled] = false + Travis.config[:deep_integration_enabled] = original_deep_integration_enabled end describe 'POST /assembla/login' do @@ -56,7 +55,7 @@ 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('abc123') + expect(body['token']).to eq(user.token) end end @@ -93,7 +92,9 @@ end context 'when integration is not enabled' do - before { Travis.config[:deep_integration_enabled] = false } + + 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}" From 5f79e3b1bb50cc99585bfd9565b96cd86bcbe86c Mon Sep 17 00:00:00 2001 From: mshahzaib-travis Date: Fri, 11 Jul 2025 18:21:38 +0500 Subject: [PATCH 09/14] [TBT-381] changed config to use array --- lib/travis/api/app/endpoint/assembla.rb | 2 +- lib/travis/config/defaults.rb | 2 +- spec/unit/endpoint/assembla_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/travis/api/app/endpoint/assembla.rb b/lib/travis/api/app/endpoint/assembla.rb index 5b079da79..8601332c4 100644 --- a/lib/travis/api/app/endpoint/assembla.rb +++ b/lib/travis/api/app/endpoint/assembla.rb @@ -57,7 +57,7 @@ def deep_integration_enabled? end def valid_asm_cluster? - allowed = Travis.config.assembla_clusters.to_s.split(',') + allowed = Travis.config.assembla_clusters allowed.include?(request.env[CLUSTER_HEADER]) end end diff --git a/lib/travis/config/defaults.rb b/lib/travis/config/defaults.rb index dfb3cd680..323a52586 100644 --- a/lib/travis/config/defaults.rb +++ b/lib/travis/config/defaults.rb @@ -107,7 +107,7 @@ def fallback_logs_api_auth_token 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'}], - assembla_clusters: 'eu, us', + assembla_clusters: ['eu', 'us'], deep_integration_enabled: false, assembla_jwt_secret: 'assembla_jwt_secret', deep_integration_plan_name: 'beta_plan' diff --git a/spec/unit/endpoint/assembla_spec.rb b/spec/unit/endpoint/assembla_spec.rb index a541f7834..b0fa48704 100644 --- a/spec/unit/endpoint/assembla_spec.rb +++ b/spec/unit/endpoint/assembla_spec.rb @@ -92,8 +92,8 @@ 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 From fb82a6ad6b93861c7a9afc22b4d0d62479bd9952 Mon Sep 17 00:00:00 2001 From: Stanislav Colotinschi Date: Fri, 11 Jul 2025 23:14:38 +0300 Subject: [PATCH 10/14] Sync only passed space and repository --- lib/travis/api/app/endpoint/assembla.rb | 2 +- lib/travis/remote_vcs/user.rb | 4 +++- lib/travis/services/assembla_user_service.rb | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/travis/api/app/endpoint/assembla.rb b/lib/travis/api/app/endpoint/assembla.rb index 8601332c4..d42f6e725 100644 --- a/lib/travis/api/app/endpoint/assembla.rb +++ b/lib/travis/api/app/endpoint/assembla.rb @@ -12,7 +12,7 @@ class Endpoint class Assembla < Endpoint include Travis::Api::App::JWTUtils - REQUIRED_JWT_FIELDS = %w[name email login space_id id refresh_token].freeze + 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' 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 index 884f33bb3..c2de21b8e 100644 --- a/lib/travis/services/assembla_user_service.rb +++ b/lib/travis/services/assembla_user_service.rb @@ -38,7 +38,7 @@ def create_org_subscription(user, organization_id) private def sync_user(user_id) - Travis::RemoteVCS::User.new.sync(user_id: 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 From daaafb79e6e0bdfd83e8d839385361daec2c950f Mon Sep 17 00:00:00 2001 From: mshahzaib-travis Date: Tue, 15 Jul 2025 03:50:14 +0500 Subject: [PATCH 11/14] [TBT-382] specs fixed --- spec/travis/remote_vcs/user_spec.rb | 26 ++++++++++++++++++++++++++ spec/unit/endpoint/assembla_spec.rb | 1 + 2 files changed, 27 insertions(+) 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 index b0fa48704..7ec4575f6 100644 --- a/spec/unit/endpoint/assembla_spec.rb +++ b/spec/unit/endpoint/assembla_spec.rb @@ -14,6 +14,7 @@ 'space_id' => 'space123', 'id' => 'assembla_vcs_user_id', 'access_token' => 'test_access_token', + 'repository_id' => 'repository123', 'refresh_token' => 'test_refresh_token' } end From db9cadb667d962bf97005d6127b81da83f3214c0 Mon Sep 17 00:00:00 2001 From: mshahzaib-travis Date: Thu, 7 Aug 2025 03:58:42 +0500 Subject: [PATCH 12/14] Generated access token --- lib/travis/api/app/endpoint/assembla.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/travis/api/app/endpoint/assembla.rb b/lib/travis/api/app/endpoint/assembla.rb index 8601332c4..47b5e5c24 100644 --- a/lib/travis/api/app/endpoint/assembla.rb +++ b/lib/travis/api/app/endpoint/assembla.rb @@ -28,11 +28,12 @@ class Assembla < Endpoint 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: user.token + token: access_token } end From bf4eb6023b6035185b4342af82abc9a95db16af3 Mon Sep 17 00:00:00 2001 From: mshahzaib-travis Date: Thu, 7 Aug 2025 04:46:45 +0500 Subject: [PATCH 13/14] Confirm user and fix spec --- lib/travis/services/assembla_user_service.rb | 1 + spec/lib/services/assembla_user_service_spec.rb | 1 + spec/unit/endpoint/assembla_spec.rb | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/travis/services/assembla_user_service.rb b/lib/travis/services/assembla_user_service.rb index 884f33bb3..6c2e47d8b 100644 --- a/lib/travis/services/assembla_user_service.rb +++ b/lib/travis/services/assembla_user_service.rb @@ -16,6 +16,7 @@ def find_or_create_user 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 diff --git a/spec/lib/services/assembla_user_service_spec.rb b/spec/lib/services/assembla_user_service_spec.rb index 77ece8c18..3e3317ed8 100644 --- a/spec/lib/services/assembla_user_service_spec.rb +++ b/spec/lib/services/assembla_user_service_spec.rb @@ -37,6 +37,7 @@ 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 diff --git a/spec/unit/endpoint/assembla_spec.rb b/spec/unit/endpoint/assembla_spec.rb index b0fa48704..7009bb5ec 100644 --- a/spec/unit/endpoint/assembla_spec.rb +++ b/spec/unit/endpoint/assembla_spec.rb @@ -23,6 +23,7 @@ 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 @@ -46,6 +47,7 @@ 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 @@ -55,7 +57,7 @@ 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) + expect(body['token']).to eq(access_token.token) end end From 1390b5bd5619ffba926c5ae41d9901e264ebaac0 Mon Sep 17 00:00:00 2001 From: mshahzaib-travis Date: Thu, 7 Aug 2025 17:20:22 +0500 Subject: [PATCH 14/14] Fixed membership admin --- lib/travis/services/assembla_user_service.rb | 5 ++++- spec/lib/services/assembla_user_service_spec.rb | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/travis/services/assembla_user_service.rb b/lib/travis/services/assembla_user_service.rb index 0e5fff5a6..4fabe485d 100644 --- a/lib/travis/services/assembla_user_service.rb +++ b/lib/travis/services/assembla_user_service.rb @@ -23,10 +23,13 @@ def find_or_create_user end def find_or_create_organization(user) - user.organizations.find_or_create_by!( + org = user.organizations.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) diff --git a/spec/lib/services/assembla_user_service_spec.rb b/spec/lib/services/assembla_user_service_spec.rb index 3e3317ed8..411061a87 100644 --- a/spec/lib/services/assembla_user_service_spec.rb +++ b/spec/lib/services/assembla_user_service_spec.rb @@ -66,6 +66,11 @@ 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