Merge pull request #117 from travis-ci/ps-ssh-keys

Add ssh keys to settings API
This commit is contained in:
Piotr Sarnacki 2014-04-15 10:59:59 +02:00
commit c42335a286
17 changed files with 542 additions and 18 deletions

View File

@ -9,6 +9,7 @@ gem 'travis-sidekiqs', github: 'travis-ci/travis-sidekiqs', require: nil, ref: '
gem 'sinatra'
gem 'sinatra-contrib', require: nil #github: 'sinatra/sinatra-contrib', require: nil
gem 'active_model_serializers'
gem 'unicorn'
gem 'sentry-raven', github: 'getsentry/raven-ruby'
gem 'yard-sinatra', github: 'rkh/yard-sinatra'
@ -33,7 +34,6 @@ end
group :development do
gem 'foreman'
gem 'rerun'
# gem 'debugger'
gem 'rb-fsevent', '~> 0.9.1'
end

View File

@ -31,12 +31,13 @@ GIT
GIT
remote: git://github.com/travis-ci/travis-core.git
revision: dd94b9d19ac4cc33f383f740194ba1b3c6a73d3a
revision: d2a19ee99f543eb2db376390847b682caa960b7f
specs:
travis-core (0.0.1)
actionmailer (~> 3.2.12)
activerecord (~> 3.2.12)
coder (~> 0.4.0)
coercible (~> 1.0.0)
data_migrations (~> 0.0.1)
gh
hashr (~> 0.0.19)
@ -103,6 +104,8 @@ GEM
rack-cache (~> 1.2)
rack-test (~> 0.6.1)
sprockets (~> 2.2.1)
active_model_serializers (0.8.1)
activemodel (>= 3.0)
activemodel (3.2.17)
activesupport (= 3.2.17)
builder (~> 3.0.0)
@ -114,9 +117,9 @@ GEM
activesupport (3.2.17)
i18n (~> 0.6, >= 0.6.4)
multi_json (~> 1.0)
addressable (2.3.5)
addressable (2.3.6)
arel (3.0.3)
atomic (1.1.15)
atomic (1.1.16)
avl_tree (1.1.3)
backports (2.8.2)
builder (3.0.4)
@ -126,6 +129,8 @@ GEM
timers (>= 1.0.0)
coder (0.4.0)
coderay (1.1.0)
coercible (1.0.0)
descendants_tracker (~> 0.0.1)
connection_pool (0.9.3)
daemons (1.1.9)
dalli (2.6.4)
@ -133,6 +138,8 @@ GEM
activerecord
rake
database_cleaner (0.8.0)
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
diff-lcs (1.2.5)
dotenv (0.9.0)
erubis (2.7.0)
@ -178,7 +185,7 @@ GEM
mime-types (1.25.1)
mocha (0.14.0)
metaclass (~> 0.0.1)
multi_json (1.9.0)
multi_json (1.9.2)
multipart-post (2.0.0)
net-http-persistent (2.9.4)
net-http-pipeline (1.0.1)
@ -199,7 +206,7 @@ GEM
rack (>= 0.4)
rack-protection (1.5.1)
rack
rack-ssl (1.3.3)
rack-ssl (1.3.4)
rack
rack-test (0.6.2)
rack (>= 1.0)
@ -268,6 +275,7 @@ GEM
eventmachine (>= 1.0.0)
rack (>= 1.0.0)
thor (0.14.6)
thread_safe (0.3.3)
tilt (1.4.1)
timers (1.1.0)
treetop (1.4.15)
@ -285,6 +293,7 @@ PLATFORMS
ruby
DEPENDENCIES
active_model_serializers
bunny (~> 0.8.0)
dalli
database_cleaner (~> 0.8.0)

View File

@ -18,6 +18,7 @@ require 'sidekiq'
require 'metriks/reporter/logger'
require 'travis/support/log_subscriber/active_record_metrics'
require 'fileutils'
require 'travis/api/v2/http'
# Rack class implementing the HTTP API.
# Instances respond to #call.
@ -113,7 +114,13 @@ module Travis::Api
use Travis::Api::App::Middleware::Metriks
use Travis::Api::App::Middleware::Rewrite
Endpoint.subclasses.each { |e| map(e.prefix) { run(e.new) } }
SettingsEndpoint.subclass :ssh_keys
Endpoint.subclasses.each do |e|
next if e == SettingsEndpoint # TODO: add something like abstract? method to check if
# class should be registered
map(e.prefix) { run(e.new) }
end
end
end

View File

@ -43,7 +43,7 @@ class Travis::Api::App
# Get settings for a given repository
#
get '/:id/settings' do
get '/:id/settings', scope: :private do
settings = service(:find_repo_settings, params).run
if settings
respond_with({ settings: settings.obfuscated }, version: :v2)
@ -52,7 +52,7 @@ class Travis::Api::App
end
end
patch '/:id/settings' do
patch '/:id/settings', scope: :private do
payload = JSON.parse request.body.read
if payload['settings'].blank? || !payload['settings'].is_a?(Hash)

View File

@ -0,0 +1,94 @@
require 'travis/api/app'
class Travis::Api::App
class SettingsEndpoint < Endpoint
set(:prefix) { "/settings/" << name[/[^:]+$/].underscore }
class << self
# This method checks if class based on a given name exists or creates
# a new SettingsEndpoint subclass, which will be then used as an endpoint
def subclass(name)
class_name = name.to_s.camelize
if Travis::Api::App.const_defined?(class_name)
Travis::Api::App.const_get(class_name)
else
klass = create_settings_class(name)
Travis::Api::App.const_set(class_name, klass)
klass
end
end
def create_settings_class(name)
klass = Class.new(self) do
define_method(:name) { name }
get("/", scope: :private) do index end
get("/:id", scope: :private) do show end
post("/", scope: :private) do create end
patch("/:id", scope: :private) do update end
delete("/:id", scope: :private) do destroy end
end
end
end
# Rails style methods for easy overriding
def index
respond_with(collection, type: name, version: :v2)
end
def show
respond_with(record, type: singular_name, version: :v2)
end
def update
record.update(JSON.parse(request.body.read)[singular_name])
if record.valid?
repo_settings.save
respond_with(record, type: singular_name, version: :v2)
else
status 422
respond_with(record, type: :validation_error, version: :v2)
end
end
def create
record = collection.create(JSON.parse(request.body.read)[singular_name])
if record.valid?
repo_settings.save
respond_with(record, type: singular_name, version: :v2)
else
status 422
respond_with(record, type: :validation_error, version: :v2)
end
end
def destroy
record = collection.destroy(params[:id]) || record_not_found
repo_settings.save
respond_with(record, type: singular_name, version: :v2)
end
def singular_name
name.to_s.singularize
end
def collection
@collection ||= repo_settings.send(name)
end
# This method can't be called "settings" because it clashes with
# Sinatra's method
def repo_settings
@settings ||= begin
service(:find_repo_settings, id: params['repository_id'].to_i).run
end || halt(404, error: "Couldn't find repository")
end
def record
collection.find(params[:id]) || record_not_found
end
def record_not_found
halt(404, { error: "Could not find a requested setting" })
end
end
end

View File

@ -37,7 +37,9 @@ class Travis::Api::App
end
def redirect_v1_named_repo_path
force_redirect("/repositories#{$1}.#{env['travis.format']}") if request.path =~ V1_REPO_URL
if request.path =~ V1_REPO_URL
force_redirect("/repositories#{$1}.#{env['travis.format']}")
end
end
def force_redirect(path)

View File

@ -27,7 +27,13 @@ class Travis::Api::App
end
def result
builder ? builder.new(resource, params).data : basic_type_resource
if builder
p = params
p[:root] = options[:type] if options[:type]
builder.new(resource, p).data
else
basic_type_resource
end
end
def builder

View File

@ -0,0 +1,23 @@
require 'active_model_serializers'
module Travis
module Api
class Serializer < ActiveModel::Serializer
def data
as_json
end
end
class ArraySerializer < ActiveModel::ArraySerializer
def data
as_json
end
def initialize(resource, options)
options[:each_serializer] ||= Travis::Api::V2::Http.const_get(options[:root].to_s.singularize.camelize)
super(resource, options)
end
end
end
end

View File

@ -1,3 +1,5 @@
require 'travis/api/serializer'
module Travis
module Api
module V2
@ -20,7 +22,10 @@ module Travis
require 'travis/api/v2/http/requests'
require 'travis/api/v2/http/request'
require 'travis/api/v2/http/ssl_key'
require 'travis/api/v2/http/ssh_key'
require 'travis/api/v2/http/ssh_keys'
require 'travis/api/v2/http/user'
require 'travis/api/v2/http/validation_error'
end
end
end

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,11 @@
module Travis
module Api
module V2
module Http
class SshKey < Travis::Api::Serializer
attributes :id, :name
end
end
end
end
end

View File

@ -0,0 +1,2 @@
class Travis::Api::V2::Http::SshKeys < Travis::Api::ArraySerializer
end

View File

@ -0,0 +1,39 @@
module Travis
module Api
module V2
module Http
class ValidationError
attr_reader :resource
def initialize(resource, options = {})
@resource = resource
end
def data
response = {
message: 'Validation failed'
}
resource.errors.to_hash.each do |name, errors|
response['errors'] ||= []
errors.each do |error_code|
response['errors'] << { field: name, code: code(error_code) }
end
end
response
end
def code(error_code)
case error_code
when :blank
'missing_field'
else
error_code.to_s
end
end
end
end
end
end
end

View File

@ -0,0 +1,177 @@
require 'spec_helper'
describe Travis::Api::App::SettingsEndpoint do
let(:repo) { Repository.by_slug('svenfuchs/minimal').first }
let(:headers) { { 'HTTP_ACCEPT' => 'application/vnd.travis-ci.2+json' } }
before do
model_class = Class.new(Repository::Settings::Model) do
field :name
field :secret, encrypted: true
validates :name, presence: true
validates :secret, presence: true
end
collection_class = Class.new(Repository::Settings::Collection) do
model model_class
end
Repository::Settings.class_eval do
register :items, collection_class
end
serializer_class = Class.new(Travis::Api::Serializer) do
attributes :id, :name
end
Travis::Api::V2::Http.const_set(:Item, serializer_class)
Travis::Api::V2::Http.const_set(:Items, Travis::Api::ArraySerializer)
add_settings_endpoint :items
end
after do
Travis::Api::App.send :remove_const, :Items
Travis::Api::V2::Http.send :remove_const, :Items
Travis::Api::V2::Http.send :remove_const, :Item
end
describe 'with authenticated user' do
let(:user) { User.where(login: 'svenfuchs').first }
let(:token) { Travis::Api::App::AccessToken.create(user: user, app_id: -1) }
let(:headers) { { 'HTTP_ACCEPT' => 'application/vnd.travis-ci.2+json', 'HTTP_AUTHORIZATION' => "token #{token}" } }
before { user.permissions.create!(:repository_id => repo.id, :admin => true, :push => true) }
describe 'GET /items/:id' do
it 'returns an item' do
settings = repo.settings
item = settings.items.create(name: 'an item', secret: 'TEH SECRET')
settings.save
response = get '/settings/items/' + item.id, { repository_id: repo.id }, headers
json = JSON.parse(response.body)
json['item']['name'].should == 'an item'
json['item']['id'].should == item.id
json['item'].should_not have_key('secret')
end
it 'returns 404 if item can\'t be found' do
response = get '/settings/items/123', { repository_id: repo.id }, headers
json = JSON.parse(response.body)
json['error'].should == "Could not find a requested setting"
end
end
describe 'GET /items' do
it 'returns items list' do
settings = repo.settings
settings.items.create(name: 'an item', secret: 'TEH SECRET')
settings.save
response = get '/settings/items', { repository_id: repo.id }, headers
json = JSON.parse(response.body)
json['items'].should have(1).items
item = json['items'].first
item['name'].should == 'an item'
item['id'].should_not be_nil
item.should_not have_key('secret')
end
end
describe 'POST /items' do
it 'creates a new item' do
body = { item: { name: 'foo', secret: 'TEH SECRET' } }.to_json
response = post "/settings/items?repository_id=#{repo.id}", body, headers
json = JSON.parse(response.body)
json['item']['name'].should == 'foo'
json['item']['id'].should_not be_nil
json['item'].should_not have_key('secret')
item = repo.reload.settings.items.first
item.id.should_not be_nil
item.name.should == 'foo'
item.secret.decrypt.should == 'TEH SECRET'
end
it 'returns error message if item is invalid' do
response = post "/settings/items?repository_id=#{repo.id}", '{}', headers
response.status.should == 422
json = JSON.parse(response.body)
json['message'].should == 'Validation failed'
json['errors'].should == [{
'field' => 'name',
'code' => 'missing_field'
}, {
'field' => 'secret',
'code' => 'missing_field'
}]
repo.reload.settings.items.length.should == 0
end
end
describe 'PATCH /items/:id' do
it 'should update an item' do
settings = repo.settings
item = settings.items.create(name: 'an item', secret: 'TEH SECRET')
settings.save
body = { item: { name: 'a new name', secret: 'a new secret' } }.to_json
response = patch "/settings/items/#{item.id}?repository_id=#{repo.id}", body, headers
json = JSON.parse(response.body)
json['item']['name'].should == 'a new name'
json['item']['id'].should == item.id
json['item'].should_not have_key('secret')
updated_item = repo.reload.settings.items.find(item.id)
updated_item.id.should == item.id
updated_item.name.should == 'a new name'
updated_item.secret.decrypt.should == 'a new secret'
end
it 'returns an error message if item is invalid' do
settings = repo.settings
item = settings.items.create(name: 'an item', secret: 'TEH SECRET')
settings.save
body = { item: { name: '' } }.to_json
response = patch "/settings/items/#{item.id}?repository_id=#{repo.id}", body, headers
response.status.should == 422
json = JSON.parse(response.body)
json['message'].should == 'Validation failed'
json['errors'].should == [{
'field' => 'name',
'code' => 'missing_field'
}]
updated_item = repo.reload.settings.items.find(item.id)
updated_item.id.should == item.id
updated_item.name.should == 'an item'
updated_item.secret.decrypt.should == 'TEH SECRET'
end
end
describe 'DELETE /items/:id' do
it 'should delete an item' do
settings = repo.settings
item = settings.items.create(name: 'an item', secret: 'TEH SECRET')
settings.save
params = { repository_id: repo.id }
response = delete '/settings/items/' + item.id, params, headers
json = JSON.parse(response.body)
json['item']['name'].should == 'an item'
json['item']['id'].should == item.id
json['item'].should_not have_key('secret')
repo.reload.settings.items.should have(0).items
end
it 'returns 404 if item can\'t be found' do
response = delete '/settings/items/123', { repository_id: repo.id }, headers
json = JSON.parse(response.body)
json['error'].should == "Could not find a requested setting"
end
end
end
end

View File

@ -25,21 +25,18 @@ describe 'Repos' do
end
it 'allows to update settings' do
json = { 'settings' => { 'a-new-setting' => 'value' } }.to_json
json = { 'settings' => { 'build_pushes' => false } }.to_json
response = patch "repos/#{repo.id}/settings", json, headers
repo.reload.settings['a-new-setting'].should == 'value'
repo.reload.settings['build_pushes'].should == false
body = JSON.parse(response.body)
body['settings']['a-new-setting'].should == 'value'
body['settings']['build_pushes'].should == false
end
it 'allows to get settings' do
repo.settings.replace('foo' => { 'type' => 'password', 'value' => 'abc123' })
repo.save
response = get "repos/#{repo.id}/settings", {}, headers
settings = Repository::Settings.defaults.deep_merge({ 'foo' => { 'type' => 'password', 'value' => '' } })
settings = Repository::Settings.defaults
JSON.parse(response.body).should == { 'settings' => settings }
end
end

View File

@ -0,0 +1,146 @@
require 'spec_helper'
describe Travis::Api::App::SettingsEndpoint do
let(:repo) { Repository.by_slug('svenfuchs/minimal').first }
let(:headers) { { 'HTTP_ACCEPT' => 'application/vnd.travis-ci.2+json' } }
describe 'with authenticated user' do
let(:user) { User.where(login: 'svenfuchs').first }
let(:token) { Travis::Api::App::AccessToken.create(user: user, app_id: -1) }
let(:headers) { { 'HTTP_ACCEPT' => 'application/vnd.travis-ci.2+json', 'HTTP_AUTHORIZATION' => "token #{token}" } }
before { user.permissions.create!(:repository_id => repo.id, :admin => true, :push => true) }
describe 'GET /ssh_keys/:id' do
it 'returns an item' do
settings = repo.settings
record = settings.ssh_keys.create(name: 'key for my repo', content: 'the key')
settings.save
response = get '/settings/ssh_keys/' + record.id, { repository_id: repo.id }, headers
json = JSON.parse(response.body)
json['ssh_key']['name'].should == 'key for my repo'
json['ssh_key']['id'].should == record.id
json['ssh_key'].should_not have_key('content')
end
it 'returns 404 if ssh_key can\'t be found' do
response = get '/settings/ssh_keys/123', { repository_id: repo.id }, headers
json = JSON.parse(response.body)
json['error'].should == "Could not find a requested setting"
end
end
describe 'GET /settings/ssh_keys' do
it 'returns a list of ssh_keys' do
settings = repo.settings
record = settings.ssh_keys.create(name: 'key for my repo', content: 'the key')
settings.save
response = get '/settings/ssh_keys', { repository_id: repo.id }, headers
response.should be_successful
json = JSON.parse(response.body)
key = json['ssh_keys'].first
key['name'].should == 'key for my repo'
key['id'].should == record.id
key.should_not have_key('content')
end
end
describe 'POST /settings/ssh_keys' do
it 'creates a new key' do
body = { ssh_key: { name: 'foo', content: 'content' } }.to_json
response = post "/settings/ssh_keys?repository_id=#{repo.id}", body, headers
json = JSON.parse(response.body)
json['ssh_key']['name'].should == 'foo'
json['ssh_key']['id'].should_not be_nil
json['ssh_key'].should_not have_key('content')
ssh_key = repo.reload.settings.ssh_keys.first
ssh_key.id.should_not be_nil
ssh_key.name.should == 'foo'
ssh_key.content.decrypt.should == 'content'
end
it 'returns error message if a key is invalid' do
response = post "/settings/ssh_keys?repository_id=#{repo.id}", '{}', headers
response.status.should == 422
json = JSON.parse(response.body)
json['message'].should == 'Validation failed'
json['errors'].should == [{
'field' => 'name',
'code' => 'missing_field'
}]
repo.reload.settings.ssh_keys.length.should == 0
end
end
describe 'PATCH /settings/ssh_keys/:id' do
it 'should update a key' do
settings = repo.settings
ssh_key = settings.ssh_keys.create(name: 'foo', content: 'content')
settings.save
body = { ssh_key: { name: 'bar', content: 'a new content' } }.to_json
response = patch "/settings/ssh_keys/#{ssh_key.id}?repository_id=#{repo.id}", body, headers
json = JSON.parse(response.body)
json['ssh_key']['name'].should == 'bar'
json['ssh_key']['id'].should == ssh_key.id
json['ssh_key'].should_not have_key('content')
updated_ssh_key = repo.reload.settings.ssh_keys.find(ssh_key.id)
updated_ssh_key.id.should == ssh_key.id
updated_ssh_key.name.should == 'bar'
updated_ssh_key.content.decrypt.should == 'a new content'
end
it 'returns an error message if ssh_key is invalid' do
settings = repo.settings
ssh_key = settings.ssh_keys.create(name: 'foo', content: 'content')
settings.save
body = { ssh_key: { name: '' } }.to_json
response = patch "/settings/ssh_keys/#{ssh_key.id}?repository_id=#{repo.id}", body, headers
response.status.should == 422
json = JSON.parse(response.body)
json['message'].should == 'Validation failed'
json['errors'].should == [{
'field' => 'name',
'code' => 'missing_field'
}]
updated_ssh_key = repo.reload.settings.ssh_keys.find(ssh_key.id)
updated_ssh_key.id.should == ssh_key.id
updated_ssh_key.name.should == 'foo'
updated_ssh_key.content.decrypt.should == 'content'
end
end
describe 'DELETE /ssh_keys/:id' do
it 'should delete an ssh_key' do
settings = repo.settings
ssh_key = settings.ssh_keys.create(name: 'foo', content: 'content')
settings.save
params = { repository_id: repo.id }
response = delete '/settings/ssh_keys/' + ssh_key.id, params, headers
json = JSON.parse(response.body)
json['ssh_key']['name'].should == 'foo'
json['ssh_key']['id'].should == ssh_key.id
json['ssh_key'].should_not have_key('content')
repo.reload.settings.ssh_keys.should have(0).ssh_keys
end
it 'returns 404 if ssh_key can\'t be found' do
response = delete '/settings/ssh_keys/123', { repository_id: repo.id }, headers
json = JSON.parse(response.body)
json['error'].should == "Could not find a requested setting"
end
end
end
end

View File

@ -26,6 +26,11 @@ module TestHelpers
@custom_endpoints ||= []
end
def add_settings_endpoint(name)
Travis::Api::App::SettingsEndpoint.subclass(name)
set_app Travis::Api::App.new
end
def add_endpoint(prefix, &block)
endpoint = Sinatra.new(Travis::Api::App::Endpoint, &block)
endpoint.set(prefix: prefix)