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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/travis/api/app/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,4 @@ def auth_for_repo(id, type)
require 'travis/api/app/endpoint/slow'
require 'travis/api/app/endpoint/uptime'
require 'travis/api/app/endpoint/users'
require 'travis/api/app/endpoint/assembla'
87 changes: 87 additions & 0 deletions lib/travis/api/app/endpoint/assembla.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
require 'travis/api/app'
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?
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)
sync_user(user.id)
create_org_subscription(user.id, @jwt_payload[:space_id])

{
user_id: user.id,
login: user.login,
token: user.token,
status: 'signed_in'
}.to_json
end

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)
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.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
end
20 changes: 20 additions & 0 deletions lib/travis/api/app/jwt_utils.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class Travis::Api::App
module JWTUtils
def extract_jwt_token(request)
request.env['HTTP_AUTHORIZATION']&.split(' ')&.last
end

def verify_jwt(request, 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
5 changes: 4 additions & 1 deletion lib/travis/config/defaults.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
assembla_jwt_secret: ENV['ASSEMBLA_JWT_SECRET']

default :_access => [:key]

Expand Down
136 changes: 136 additions & 0 deletions spec/unit/endpoint/assembla_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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' => '[email protected]',
'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_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
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['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

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' => '[email protected]',
'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