diff --git a/.gitignore b/.gitignore index 587c808837..fc2c51a9b3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ config/travis.yml config/travis/* config/database.yml config/nginx.conf - +vendor db/ tmp/ diff --git a/Dockerfile b/Dockerfile index b4644f3650..1ebacc6174 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,12 +9,11 @@ RUN ( \ && rm -rf /var/lib/apt/lists/* \ ) -ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 +# ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 # throw errors if Gemfile has been modified since Gemfile.lock RUN bundle config --global frozen 1 RUN bundle config set deployment 'true' -RUN bundle config set without 'development test' RUN mkdir -p /app WORKDIR /app diff --git a/Gemfile b/Gemfile index 91f5c6d79e..48eecfef2c 100644 --- a/Gemfile +++ b/Gemfile @@ -78,6 +78,7 @@ gem 'hashr' gem 'pusher', '~> 2.0.3' gem 'multi_json' gem 'closeio', '~> 3.15' +gem 'pry-rails' group :test do gem 'rspec' diff --git a/Gemfile.lock b/Gemfile.lock index 4c5b1db420..588519b1ad 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -291,6 +291,7 @@ GEM google-cloud-trace-v2 (0.6.1) gapic-common (>= 0.19.1, < 2.a) google-cloud-errors (~> 1.0) + google-protobuf (3.23.4-aarch64-linux) google-protobuf (3.23.4-arm64-darwin) google-protobuf (3.23.4-x86_64-linux) googleapis-common-protos (1.4.0) @@ -386,6 +387,8 @@ GEM pry-byebug (3.10.1) byebug (~> 11.0) pry (>= 0.13, < 0.15) + pry-rails (0.3.11) + pry (>= 0.13.0) public_suffix (5.0.3) pusher (2.0.3) httpclient (~> 2.8) @@ -547,6 +550,7 @@ GEM zeitwerk (2.6.8) PLATFORMS + aarch64-linux arm64-darwin-22 x86_64-linux @@ -591,6 +595,7 @@ DEPENDENCIES pg (~> 1.5) pry pry-byebug + pry-rails pusher (~> 2.0.3) rack (~> 2.2) rack-attack (~> 6) @@ -639,4 +644,4 @@ RUBY VERSION ruby 3.2.2p53 BUNDLED WITH - 2.4.14 + 2.6.9 diff --git a/config/puma-config.rb b/config/puma-config.rb index 1c15535dcf..a2bf5cbeaa 100644 --- a/config/puma-config.rb +++ b/config/puma-config.rb @@ -6,4 +6,4 @@ bind "unix://#{tmp_dir}/nginx.socket" environment ENV['RACK_ENV'] || 'development' -threads 0, 16 +threads 1, 1 diff --git a/config/unicorn.rb b/config/unicorn.rb index cf7797cded..e56a3a105c 100644 --- a/config/unicorn.rb +++ b/config/unicorn.rb @@ -11,7 +11,7 @@ listen File.expand_path("nginx.socket", tmp_dir), backlog: 1024 else if ENV['DOCKER'] - listen "#{Integer(ENV.fetch('PORT'))}", backlog: 1024 + listen "0.0.0.0:#{Integer(ENV.fetch('PORT'))}", backlog: 1024 else listen "127.0.0.1:#{Integer(ENV.fetch('PORT'))}", backlog: 1024 end diff --git a/lib/travis/api/app/endpoint.rb b/lib/travis/api/app/endpoint.rb index 94ff8771ad..cd981aa65d 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 0000000000..33d6b991cd --- /dev/null +++ b/lib/travis/api/app/endpoint/assembla.rb @@ -0,0 +1,70 @@ +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 = generate_access_token(user: user, app_id: 0) + + { + user_id: user.id, + login: user.login, + token: access_token + } + end + + private + + def generate_access_token(options) + AccessToken.create(options).token + end + + 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 0000000000..eeaa4e6c91 --- /dev/null +++ b/lib/travis/api/app/jwt_utils.rb @@ -0,0 +1,79 @@ +class Travis::Api::App + module JWTUtils + def extract_jwt_token(request) + request.env['HTTP_AUTHORIZATION']&.split&.last + end + + def verify_jwt(request) + # payload = { + # email: 'viktorija.krivokapic1+1@gmail.com', + # login: 'viktorijaTravisAssembla380', + # id: 'cs0JUKBgSr8ioDLJtkgGFV', + # name: 'viktorija devtactics', + # space_id: 'crofDwBRKr8kdd0NKLjkMA', + # repository_id: 'cajjvcB_qr8iot_O0clYmL', + # access_token: 'e6dea10f2ea91562f8fcf44eff6588e9', + # refresh_token: '5dfe4814cea729ea4b0ce97ba8d4aa41', + # exp: Time.now.to_i + (3600 * 100) # 7 days expiration + # } + + # secret = Travis.config.assembla_jwt_secret + # token = JWT.encode(payload, secret, 'HS256') + # puts "Generated JWT: #{token}" + + # Generated JWT at 3rd aug 2025 at 4:15 am For staging secret: + # eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InZpa3RvcmlqYS5rcml2b2thcGljMSsxQGdtYWlsLmNvbSIsImxvZ2luIjoidmlrdG9yaWphVHJhdmlzQXNzZW1ibGEzODAiLCJpZCI6ImNzMEpVS0JnU3I4aW9ETEp0a2dHRlYiLCJuYW1lIjoidmlrdG9yaWphIGRldnRhY3RpY3MiLCJzcGFjZV9pZCI6ImNyb2ZEd0JSS3I4a2RkME5LTGprTUEiLCJyZXBvc2l0b3J5X2lkIjoiY2FqanZjQl9xcjhpb3RfTzBjbFltTCIsImFjY2Vzc190b2tlbiI6ImU2ZGVhMTBmMmVhOTE1NjJmOGZjZjQ0ZWZmNjU4OGU5IiwicmVmcmVzaF90b2tlbiI6IjVkZmU0ODE0Y2VhNzI5ZWE0YjBjZTk3YmE4ZDRhYTQxIiwiZXhwIjoxNzU0NTM2NTc0fQ.IoNhjpl3DwK5HVqO5FiQiylqKkKiRDV8qaLiFgcv01k + + + # payload = { + # email: 'viktorija.krivokapic1+1@gmail.com', + # login: 'viktorijaTravisAssembla380', + # id: 'cs0JUKBgSr8ioDLJtkgGFV', + # name: 'viktorija devtactics', + # space_id: 'crofDwBRKr8kdd0NKLjkMA', + # repository_id: 'cajjvcB_qr8iot_O0clYmL', + # access_token: 'e6dea10f2ea91562f8fcf44eff6588e9', + # refresh_token: '5dfe4814cea729ea4b0ce97ba8d4aa41', + # exp: Time.now.to_i + (3600 * 100) # 7 days expiration + # } + + # secret = 'N3jANvlyDRvzYPAXlZi90zlow8kzgmMKFCBnZ0sB7mxGmmyVYF0vF0V7Go23Of4T' + # token = JWT.encode(payload, secret, 'HS256') + # puts "Generated JWT: #{token}" + # Generated JWT at 3rd aug 2025 at 4:15 am For staging dev secret: + # eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InZpa3RvcmlqYS5rcml2b2thcGljMSsxQGdtYWlsLmNvbSIsImxvZ2luIjoidmlrdG9yaWphVHJhdmlzQXNzZW1ibGEzODAiLCJpZCI6ImNzMEpVS0JnU3I4aW9ETEp0a2dHRlYiLCJuYW1lIjoidmlrdG9yaWphIGRldnRhY3RpY3MiLCJzcGFjZV9pZCI6ImNyb2ZEd0JSS3I4a2RkME5LTGprTUEiLCJyZXBvc2l0b3J5X2lkIjoiY2FqanZjQl9xcjhpb3RfTzBjbFltTCIsImFjY2Vzc190b2tlbiI6ImU2ZGVhMTBmMmVhOTE1NjJmOGZjZjQ0ZWZmNjU4OGU5IiwicmVmcmVzaF90b2tlbiI6IjVkZmU0ODE0Y2VhNzI5ZWE0YjBjZTk3YmE4ZDRhYTQxIiwiZXhwIjoxNzU0NTM3MTUyfQ.CnEsqTvFrSSfW23D6qdDynCRB51ea2kTXNiHLPh8w-I + + + + # for staging dev, user ID:125840 on 5th Aug 2025 + # payload = { + # email: 'oksana.hinailo+user-0408@devtactics.net', + # login: 'user-0408', + # id: 'aZjjsYCrir8ikSdMBSqNIq', + # name: 'user-0408 test', + # space_id: 'bkNxmOCC0r8j7dtK6LbPF5', + # repository_id: 'cldIxeCC0r8j7dtK6LbPF5', + # refresh_token: '1adee72d172bd230203188153dca302d', + # exp: Time.now.to_i + (3600 * 100) # 7 days expiration + # } + + # secret = 'N3jANvlyDRvzYPAXlZi90zlow8kzgmMKFCBnZ0sB7mxGmmyVYF0vF0V7Go23Of4T' + # token = JWT.encode(payload, secret, 'HS256') + # puts "Generated JWT: #{token}" + # Generated JWT: eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im9rc2FuYS5oaW5haWxvK3VzZXItMDQwOEBkZXZ0YWN0aWNzLm5ldCIsImxvZ2luIjoidXNlci0wNDA4IiwiaWQiOiJhWmpqc1lDcmlyOGlrU2RNQlNxTklxIiwibmFtZSI6InVzZXItMDQwOCB0ZXN0Iiwic3BhY2VfaWQiOiJia054bU9DQzByOGo3ZHRLNkxiUEY1IiwicmVwb3NpdG9yeV9pZCI6ImNsZEl4ZUNDMHI4ajdkdEs2TGJQRjUiLCJyZWZyZXNoX3Rva2VuIjoiMWFkZWU3MmQxNzJiZDIzMDIwMzE4ODE1M2RjYTMwMmQiLCJleHAiOjE3NTQ3NDc4NDd9.ArsRunhJTHsC6KeRjvThC4gynSWtvPsnqqxgn7Eorf4 + + + + 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 a0ebc3f614..cfaf183ba0 100644 --- a/lib/travis/config/defaults.rb +++ b/lib/travis/config/defaults.rb @@ -54,7 +54,7 @@ def fallback_logs_api_auth_token amqp: { username: 'guest', password: 'guest', host: 'localhost', prefetch: 1 }, closeio: { key: 'key' }, gdpr: {}, - database: { adapter: 'postgresql', database: "travis_#{Travis.env}", encoding: 'unicode', min_messages: 'warning', variables: { statement_timeout: ENV['TRAVIS_DB_STATEMENT_TIMEOUT'] || 10000 } }, + database: {host: 'localhost', port: 5432, username: 'postgres', password: 'postgres', adapter: 'postgresql', database: "travis_#{Travis.env}", encoding: 'unicode', min_messages: 'warning', variables: { statement_timeout: ENV['TRAVIS_DB_STATEMENT_TIMEOUT'] || 10000 } }, db: { max_statement_timeout_in_seconds: ENV['TRAVIS_MAX_DB_STATEMENT_TIMEOUT'] || 15, slow_host_max_statement_timeout_in_seconds: ENV['TRAVIS_MAX_DB_STATEMENT_TIMEOUT'] || 60}, log_options: { s3: { access_key_id: '', secret_access_key: ''}}, s3: { access_key_id: '', secret_access_key: ''}, @@ -95,18 +95,22 @@ def fallback_logs_api_auth_token force_authentication: false, read_only: ENV['READ_ONLY'] || false, job_log_access_permissions: { time_based_limit: false, access_based_limit: false, older_than_days: 365, max_days_value: 730, min_days_value: 30 }, - billing: {}, - vcs: {}, + billing: {url: 'http://0.0.0.0:9292', auth_key: 'billing-auth-key'}, + vcs: {url: 'http://localhost:4000', token: 'secure-vcs-token', auth_key: 'vcs'}, yml: { url: 'https://yml.travis-ci.org', token: 'secret', auth_key: 'abc123' }, logs_api: { url: logs_api_url, token: logs_api_auth_token }, fallback_logs_api: { url: fallback_logs_api_auth_url, token: fallback_logs_api_auth_token }, scanner: {}, insights: { endpoint: 'https://insights.travis-ci.dev/', auth_token: 'secret' }, - authorizer: { url: 'http://authorizer', auth_key: 'secret' }, + authorizer: { url: 'http://0.0.0.0:3434/', auth_key: 'secret' }, 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', 'cluster1'], + deep_integration_enabled: true, + 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 c6ae0bb5ab..d28bd7230d 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 f95b6e0ebd..88abd095d6 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 0000000000..0e5fff5a6f --- /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 + 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) + 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, 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/script/server-buildpacks b/script/server-buildpacks index 6fdc5a7462..d39e64de3b 100755 --- a/script/server-buildpacks +++ b/script/server-buildpacks @@ -3,6 +3,9 @@ # $PGBOUNCER_ENABLED variable is set to '1' or 'true' # or if the space-delimited list # $PGBOUNCER_ENABLED_FOR_DYNOS contains $DYNO +export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES +export DATABASE_URL=postgres://postgres:postgres@localhost:5432/travis_development +export TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/travis_test cd "$(dirname "$0")/.." 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 0000000000..3e3317ed81 --- /dev/null +++ b/spec/lib/services/assembla_user_service_spec.rb @@ -0,0 +1,90 @@ +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 + 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 9c3c5f5237..613719a7b7 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 0000000000..08145da87b --- /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