diff --git a/.gitignore b/.gitignore index d7675e1b..4571040c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ vendor # Ignore all logfiles and tempfiles. /log/*.log -/tmp +tmp/ #Ignore swapfiles created by vim *~ diff --git a/Gemfile b/Gemfile index b60d8b84..f68b7f75 100644 --- a/Gemfile +++ b/Gemfile @@ -59,6 +59,8 @@ gem 'cancancan', '~> 1.10' # ruby implementation of git-http-backend gem 'gitlab-grack', '~> 2.0.2', require: 'grack' +# used for api used by gitlab-shell +gem 'grape' group :development, :test do # https://github.com/rspec/rspec-rails/issues/1273 @@ -83,6 +85,7 @@ group :test do gem 'launchy' gem 'rubocop', require: false gem 'haml-lint', require: false + gem 'shoulda-matchers' end # To use ActiveModel has_secure_password @@ -92,7 +95,7 @@ end # gem 'jbuilder' # Use unicorn as the app server -# gem 'unicorn' +gem 'unicorn' # Deploy with Capistrano # gem 'capistrano' diff --git a/Gemfile.lock b/Gemfile.lock index 10f34fe6..d32752e2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -49,6 +49,10 @@ GEM ast (2.0.0) astrolabe (1.3.0) parser (>= 2.2.0.pre.3, < 3.0) + axiom-types (0.1.1) + descendants_tracker (~> 0.0.4) + ice_nine (~> 0.11.0) + thread_safe (~> 0.3, >= 0.3.1) bcrypt (3.1.10) better_errors (2.1.1) coderay (>= 1.0.0) @@ -70,6 +74,8 @@ GEM celluloid (0.16.0) timers (~> 4.0.0) coderay (1.1.0) + coercible (1.0.0) + descendants_tracker (~> 0.0.1) coffee-rails (4.1.0) coffee-script (>= 2.2.0) railties (>= 4.0.0, < 5.0) @@ -84,6 +90,8 @@ GEM delayed_job_active_record (4.0.3) activerecord (>= 3.0, < 5.0) delayed_job (>= 3.0, < 4.1) + descendants_tracker (0.0.4) + thread_safe (~> 0.3, >= 0.3.1) devise (3.4.1) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -92,6 +100,7 @@ GEM thread_safe (~> 0.1) warden (~> 1.2.3) diff-lcs (1.2.5) + equalizer (0.0.11) erubis (2.7.0) escape (0.0.4) execjs (2.4.0) @@ -108,6 +117,16 @@ GEM formatador (0.2.5) gitlab-grack (2.0.2) rack (~> 1.5.1) + grape (0.12.0) + activesupport + builder + hashie (>= 2.1.0) + multi_json (>= 1.3.2) + multi_xml (>= 0.5.2) + rack (>= 1.3.0) + rack-accept + rack-mount + virtus (>= 1.0.0) guard (2.12.4) formatador (>= 0.2.4) listen (~> 2.7) @@ -135,6 +154,7 @@ GEM hike (1.2.3) hitimes (1.2.2) i18n (0.7.0) + ice_nine (0.11.1) jquery-rails (3.1.2) railties (>= 3.0, < 5.0) thor (>= 0.14, < 2.0) @@ -143,6 +163,7 @@ GEM turbolinks json (1.8.2) jwt (1.4.1) + kgio (2.9.3) launchy (2.4.3) addressable (~> 2.3) libv8 (3.16.14.7) @@ -216,6 +237,10 @@ GEM method_source (~> 0.8.1) slop (~> 3.4) rack (1.5.2) + rack-accept (0.4.5) + rack (>= 0.4) + rack-mount (0.8.3) + rack (>= 1.0.0) rack-openid (1.3.1) rack (>= 1.1.0) ruby-openid (>= 2.1.8) @@ -239,6 +264,7 @@ GEM rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) rainbow (2.0.0) + raindrops (0.15.0) rake (10.4.2) rb-fsevent (0.9.4) rb-inotify (0.9.5) @@ -293,6 +319,8 @@ GEM sass-rails (~> 5.0.0.beta1) sprockets (>= 2.3.0) shellany (0.0.1) + shoulda-matchers (2.8.0) + activesupport (>= 3.0.0) sketchily (4.0.1) nokogiri rails (>= 3.1) @@ -325,6 +353,15 @@ GEM uglifier (2.7.1) execjs (>= 0.3.0) json (>= 1.8.0) + unicorn (4.9.0) + kgio (~> 2.6) + rack + raindrops (~> 0.7) + virtus (1.0.5) + axiom-types (~> 0.1) + coercible (~> 1.0) + descendants_tracker (~> 0.0, >= 0.0.3) + equalizer (~> 0.0, >= 0.0.9) warden (1.2.3) rack (>= 1.0) will_paginate (3.0.7) @@ -352,6 +389,7 @@ DEPENDENCIES factory_girl_rails faker gitlab-grack (~> 2.0.2) + grape guard-rspec guard-rubocop (~> 1.2.0) haml @@ -381,13 +419,15 @@ DEPENDENCIES rugged (~> 0.21) sass-rails sass-rails-source-maps + shoulda-matchers sketchily sqlite3 test-unit therubyracer turbolinks uglifier + unicorn will_paginate BUNDLED WITH - 1.10.2 + 1.10.5 diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 44252806..d9c01894 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -9,7 +9,7 @@ $("document").ready( function(){ if(articleContent === "login"){ $(".wrapper nav").css("display", "none"); } - var toolbarDivs = $("section.toolbar div, section.action div"); + var toolbarDivs = $("section.toolbar div, section.action div, aside nav ul li"); toolbarDivs.each(function(){ if($(this).attr("data") === articleContent){ $(this).addClass("active"); diff --git a/app/assets/stylesheets/_main.scss b/app/assets/stylesheets/_main.scss index 172059a4..474ad76b 100644 --- a/app/assets/stylesheets/_main.scss +++ b/app/assets/stylesheets/_main.scss @@ -65,7 +65,9 @@ $nprogress-color: red; } } } - + table { + width: 100%; + } section{ background-color: white; padding: 0.25em; @@ -100,9 +102,6 @@ $nprogress-color: red; } } } - table { - width: 100%; - } } } aside{ diff --git a/app/controllers/keys_controller.rb b/app/controllers/keys_controller.rb new file mode 100644 index 00000000..e570f801 --- /dev/null +++ b/app/controllers/keys_controller.rb @@ -0,0 +1,38 @@ +class KeysController < ApplicationController + before_filter :authenticate_user! + + def index + @key = current_user.keys.new + current_user.keys.delete @key # remove new key from collection + @keys = current_user.keys + end + + def create + @key = current_user.keys.new(key_params) + + if @key.save + redirect_to keys_path + else + # remove unsaved key from collection + current_user.keys.delete @key + @keys = current_user.keys + render 'index' + end + end + + def destroy + @key = current_user.keys.find(params[:id]) + if @key.destroy + redirect_to :back + else + flash[:alert] = 'Something went wrong. Please retry after some time.' + redirect_to :back + end + end + + private + + def key_params + params.require(:key).permit(:title, :key) + end +end diff --git a/app/models/key.rb b/app/models/key.rb new file mode 100644 index 00000000..194e8bd7 --- /dev/null +++ b/app/models/key.rb @@ -0,0 +1,49 @@ +require 'digest/md5' + +class Key < ActiveRecord::Base + + belongs_to :user + + before_validation :strip_white_space, :generate_fingerprint + + validates :title, presence: true, length: { within: 0..255 } + validates :key, presence: true, + length: { within: 0..5000 }, + format: { with: /\A(ssh|ecdsa)-.*\Z/ }, + uniqueness: true + validates :key, format: { without: /\n|\r/, message: 'not a single line' } + validates :fingerprint, uniqueness: true, + presence: { message: 'cannot be generated' } + + after_create :add_to_shell + after_destroy :remove_from_shell + + def shell_id + "key-#{id}" + end + + private + + def generate_fingerprint + self.fingerprint = nil + + return unless key.present? + + self.fingerprint = Gg::KeyFingerprint.new(key).fingerprint + end + + # add the key to authorized file and limit access to git + # only commands + def add_to_shell + Gg::Shell.add_key(shell_id, key) + end + + # remove key from authorized files after key is deleted + def remove_from_shell + Gg::Shell.remove_key(shell_id, key) + end + + def strip_white_space + self.key = key.strip unless key.blank? + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 7bbfa4d9..753dfa65 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -419,6 +419,7 @@ def init if parent.nil? Rugged::Repository.init_at barerepopath, :bare Rugged::Repository.clone_at barerepopath, satelliterepopath + sym_hook else # it's a fork, therefore: Rugged::Repository.init_at barerepopath, :bare Rugged::Repository.clone_at parent.satelliterepopath, satelliterepopath @@ -429,4 +430,17 @@ def init pushtobare unless satelliterepo.empty? end + + # makes a symlink to hooks in gitlab-shell in each project + # dir structure: + # |--home/username + # |--GlitterGallery + # |--gitlab-shell + def sym_hook + local_hooks_directory = File.join(barerepopath, 'hooks') + shell_hook_dir = File.join(Glitter::Application.config.shell_path, 'hooks') + new_dir_name = "#{local_hooks_directory}.old.#{Time.now.to_i}" + FileUtils.mv(local_hooks_directory, new_dir_name) + FileUtils.ln_s(shell_hook_dir, local_hooks_directory) + end end diff --git a/app/models/user.rb b/app/models/user.rb index 9b0ff817..5c67c493 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -25,6 +25,8 @@ class User < ActiveRecord::Base has_many :member_projects, through: :project_members, source: :member_project has_many :issues + # authorization of ssh access + has_many :keys VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i diff --git a/app/views/devise/registrations/_user_aside.html.haml b/app/views/devise/registrations/_user_aside.html.haml new file mode 100644 index 00000000..27238f7b --- /dev/null +++ b/app/views/devise/registrations/_user_aside.html.haml @@ -0,0 +1,16 @@ +%aside + %h1 + User Settings + %nav + %ul + %li{ data: 'profile' } + = link_to 'Profile', edit_user_registration_path + %li{ data: 'identities' } + = link_to 'Identities', identities_path + %li{ data: 'keys' } + = link_to 'SSH Keys', keys_path + -# + %li + = link_to 'Passwords' + %li + = link_to 'Projects', identities_path \ No newline at end of file diff --git a/app/views/devise/registrations/edit.html.haml b/app/views/devise/registrations/edit.html.haml index 611cc0dd..52ec27bd 100644 --- a/app/views/devise/registrations/edit.html.haml +++ b/app/views/devise/registrations/edit.html.haml @@ -1,7 +1,7 @@ - content_for :title do = current_user.username.titleize -%article +%article.user{ data: 'profile' } %header %h1 = avatar current_user.email @@ -37,19 +37,4 @@ %div = f.submit 'Update' - %aside - %h1 - User Settings - %nav - %ul - %li.active - = link_to 'Profile', edit_user_registration_path - %li - = link_to 'Identities', identities_path - -# - %li - = link_to 'Passwords' - %li - = link_to 'SSH Keys', identities_path - %li - = link_to 'Projects', identities_path \ No newline at end of file + = render 'user_aside' diff --git a/app/views/identities/index.html.haml b/app/views/identities/index.html.haml index 5d6feb2d..26fc10ab 100644 --- a/app/views/identities/index.html.haml +++ b/app/views/identities/index.html.haml @@ -1,7 +1,7 @@ - content_for :title do = current_user.username.titleize -%article.user{ data: 'settings' } +%article.user{ data: 'identities' } %header %h1 = avatar current_user.email @@ -29,17 +29,4 @@ - @allowedmethods.each do |method| %div = link_to(method.to_s.capitalize, "/auth/#{method}") - %aside - %h1 - User Settings - %nav - %ul - %li - = link_to 'Profile', edit_user_registration_path - %li.active - = link_to 'Identities', identities_path - -# - %li - = link_to 'SSH Keys', identities_path - %li - = link_to 'Projects', identities_path + = render 'devise/registrations/user_aside' diff --git a/app/views/keys/_new.html.haml b/app/views/keys/_new.html.haml new file mode 100644 index 00000000..4e560bf6 --- /dev/null +++ b/app/views/keys/_new.html.haml @@ -0,0 +1,11 @@ +%p + %strong Add keys to your account += form_for @key do |f| + - @key.errors.full_messages.each do |msg| + %p.error_message + = msg + = f.label :title + = f.text_field :title, placeholder: 'Title', autofocus: true + = f.label :key + = f.text_area :key, rows: 8 + = f.submit 'Add key' diff --git a/app/views/keys/index.html.haml b/app/views/keys/index.html.haml new file mode 100644 index 00000000..46b04302 --- /dev/null +++ b/app/views/keys/index.html.haml @@ -0,0 +1,46 @@ +- content_for :title do + = current_user.username.titleize + +%article.user{ data: 'keys' } + %header + %h1 + = avatar current_user.email + = current_user.username + %h2 + = precede '@' do + = current_user.username + + - @user = current_user + = render 'users/user_toolbar' + = render 'shared/messages' + + %section + %div + .option + = render 'new' + - if @keys.any? + %table + %thead + %tr + %th Title + %th Fingerprint + %th Added + %th + %tbody + - @keys.each do |key| + %tr + %td + %strong= key.title + %td + %code= key.fingerprint + %td + %span + #{distance_of_time_in_words_to_now key.created_at} ago + %td + = link_to 'Remove', + key_path(key), + data: { confirm: 'Are you sure?' }, + method: :delete + - else + There are no SSH keys with access to your account. + = render 'devise/registrations/user_aside' diff --git a/app/views/notifications/index.html.haml b/app/views/notifications/index.html.haml index 7aa18c74..8c5817c7 100644 --- a/app/views/notifications/index.html.haml +++ b/app/views/notifications/index.html.haml @@ -1,7 +1,7 @@ - content_for :title do Notifications -%article.user{ data: 'settings' } +%article.user{ data: 'notifications' } %header %h1 My Notifications %h2 diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 3ff0569a..50a32afb 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -1,7 +1,7 @@ - content_for :title do = @user.username.titleize -%article.user +%article.user{ data: 'userpage' } %header %h1 = avatar @user.email diff --git a/config/application.rb b/config/application.rb index 60ffa885..ae112bf8 100644 --- a/config/application.rb +++ b/config/application.rb @@ -11,6 +11,8 @@ class Application < Rails::Application # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. + + config.autoload_paths << Rails.root.join('lib') # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. diff --git a/config/initializers/0_glitterconfig.rb b/config/initializers/0_glitterconfig.rb index 98ff42b3..969a20e4 100644 --- a/config/initializers/0_glitterconfig.rb +++ b/config/initializers/0_glitterconfig.rb @@ -7,11 +7,6 @@ # used for overriding the grack::auth module of grack gem require Rails.root.join("lib", "rack", "grack_auth") -# used for overriding the grack::server module of grack gem -# so that files for satellite and commit can be generated/updated -# after every push -require Rails.root.join("lib", "rack", "overload_server") - Glitter::Application.config.thumbnail_geometry=[50,50] Glitter::Application.config.inspire_geometry=[230,130] Glitter::Application.config.mobile_inspire_geometry=[600,340] @@ -32,6 +27,9 @@ # Don't forget to set the respective Environment variables for the auth methods. For ex, FACEBOOK_KEY and FACEBOOK_SECRET Glitter::Application.config.auth_methods=[:facebook,:twitter,:open_id,:linkedIn,:github] +# location of gitlab-shell/hooks +Glitter::Application.config.shell_path = File.join(Rails.root,'..','gitlab-shell') + # This is the ActionMailer configuration # # Example Environment variables : diff --git a/config/routes.rb b/config/routes.rb index d6074841..83bc063a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,8 @@ +require 'api/api' + Glitter::Application.routes.draw do + # handles git-* requests mount Grack::Bundle.new({ git_path: Glitter::Application.config.git_path, project_root: Glitter::Application.config.repo_path, @@ -7,6 +10,11 @@ receive_pack: true }), at: '/', constraints: lambda { |request| /[-\/\w\.]+\.git\//.match(request.path_info) }, via: [:get, :post] + # API + API::API.logger Rails.logger + mount API::API => '/api' + + # Web app post '/rate' => 'rater#create', :as => 'rate' devise_for :users,:controllers => { :registrations => 'registrations' } devise_scope :user do @@ -18,6 +26,7 @@ resources :projects + resources :keys, only: [:create, :index, :destroy] resources :identities, only: [:destroy,:index] resources :comments, only: [:new, :create, :destroy] resources :glitterposts diff --git a/config/unicorn.rb b/config/unicorn.rb new file mode 100644 index 00000000..21864ad9 --- /dev/null +++ b/config/unicorn.rb @@ -0,0 +1,41 @@ +# Set the working application directory +# working_directory "/path/to/your/app" + +app_path = File.expand_path(File.dirname(__FILE__) + '/..') + +working_directory app_path + +# Unicorn PID file location +pid app_path + "/tmp/pids/unicorn.pid" + +# Unicorn socket +listen app_path + '/tmp/sockets/unicorn.sock', backlog: 64 + +listen(3000, backlog: 64) if ENV['RAILS_ENV'] == 'development' + + + +worker_processes 2 +timeout 300 +preload_app true + +before_fork do |server, worker| + + Signal.trap 'TERM' do + puts 'Unicorn master intercepting TERM and sending myself QUIT instead' + Process.kill 'QUIT', Process.pid + end + + defined?(ActiveRecord::Base) and + ActiveRecord::Base.connection.disconnect! +end + +after_fork do |server, worker| + + Signal.trap 'TERM' do + puts 'Unicorn worker intercepting TERM and doing nothing. Wait for master to sent QUIT' + end + + defined?(ActiveRecord::Base) and + ActiveRecord::Base.establish_connection +end diff --git a/db/migrate/20150718043105_create_keys.rb b/db/migrate/20150718043105_create_keys.rb new file mode 100644 index 00000000..af4a86d9 --- /dev/null +++ b/db/migrate/20150718043105_create_keys.rb @@ -0,0 +1,14 @@ +class CreateKeys < ActiveRecord::Migration + def change + create_table :keys do |t| + t.integer :user_id + t.text :key + t.string :title + t.string :fingerprint + + t.timestamps + end + + add_index :keys, [:user_id], name: 'index_keys_on_user_id' + end +end diff --git a/db/schema.rb b/db/schema.rb index 5ec0315b..f399e35a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20150623095804) do +ActiveRecord::Schema.define(version: 20150718043105) do create_table "comments", force: true do |t| t.text "body" @@ -67,6 +67,17 @@ t.datetime "updated_at" end + create_table "keys", force: true do |t| + t.integer "user_id" + t.text "key" + t.string "title" + t.string "fingerprint" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "keys", ["user_id"], name: "index_keys_on_user_id" + create_table "notification_statuses", force: true do |t| t.integer "victim_id" t.integer "notification_id" diff --git a/lib/api/api.rb b/lib/api/api.rb new file mode 100644 index 00000000..8bae0153 --- /dev/null +++ b/lib/api/api.rb @@ -0,0 +1,28 @@ +module API + class API < Grape::API + version 'v1', using: :path + + rescue_from ActiveRecord::RecordNotFound do + rack_response({ 'message' => '404 Not found' }.to_json, 404) + end + + rescue_from :all do |exception| + # lifted from https://github.com/rails/rails/blob/master/ + + # actionpack/lib/action_dispatch/middleware/debug_exceptions.rb#L60 + trace = exception.backtrace + + message = "\n#{exception.class} (#{exception.message}):\n" + message << exception.annoted_source_code.to_s if exception + .respond_to?(:annoted_source_code) + message << " " << trace.join("\n ") + + API.logger.add Logger::FATAL, message + rack_response({ 'message' => '500 Internal Server Error' }.to_json, 500) + end + + format :json + content_type :txt, 'text/plain' + + mount Internal + end +end diff --git a/lib/api/internal.rb b/lib/api/internal.rb new file mode 100644 index 00000000..59689d6e --- /dev/null +++ b/lib/api/internal.rb @@ -0,0 +1,43 @@ +module API + # Internal access API + class Internal < Grape::API + + namespace 'internal' do + # Check if git command is allowed to project + # + # Params: + # key_id - ssh key id for Git over SSH + # user_id - user id for Git over HTTP + # project - project path with namespace + # action - git action (git-upload-pack or git-receive-pack) + # ref - branch name + # forced_push - forced_push + # + post '/allowed' do + status 200 + actor = + if params[:key_id] + key = Key.find_by(id: params[:key_id]) + key.user + elsif params[:user_id] + User.find_by(id: params[:user_id]) + end + ids = params[:project].split('/') + user = User.find_by(username: ids.first.to_s.downcase) + project = Project.with_deleted.find_by user_id: user.id, + name: ids.last.to_s.downcase + access = Gg::GitAccess.new(actor, project) + access.check(params[:action]) + end + + post '/sync' do + status 200 + Gg::Sync.new(params[:project], params[:changes]).sync_satellite + end + + get '/check' do + status 200 + end + end + end +end diff --git a/lib/gg/git_access.rb b/lib/gg/git_access.rb new file mode 100644 index 00000000..061e5334 --- /dev/null +++ b/lib/gg/git_access.rb @@ -0,0 +1,49 @@ +module Gg + class GitAccess + DOWNLOAD_COMMANDS = %w{ git-upload-pack git-upload-archive } + PUSH_COMMANDS = %w{ git-receive-pack } + + attr_reader :actor, :project + + def initialize(actor, project) + @actor = actor + @project = project + end + + + def check(cmd) + unless actor + return build_status_object(false, "No user or key was provided.") + end + + unless project + return build_status_object(false, 'The project you were looking for could not be found.') + end + + case cmd + when *DOWNLOAD_COMMANDS + download_access_check + when *PUSH_COMMANDS + push_access_check + else + build_status_object(false, "The command you're trying to execute is not allowed.") + end + end + + private + + def download_access_check + return build_status_object(true) if ProjectMember.member?(project, actor) or !project.private + build_status_object(false, "You are not allowed to download code from this project.") + end + + def push_access_check + return build_status_object(true) if ProjectMember.write_acess(project, actor) + build_status_object(false, "You are not allowed to push code to this project.") + end + + def build_status_object(status, message = '') + GitAccessStatus.new(status, message) + end + end +end diff --git a/lib/gg/git_access_status.rb b/lib/gg/git_access_status.rb new file mode 100644 index 00000000..60e424a9 --- /dev/null +++ b/lib/gg/git_access_status.rb @@ -0,0 +1,15 @@ +module Gg + class GitAccessStatus + attr_accessor :status, :message + alias_method :allowed?, :status + + def initialize(status, message = '') + @status = status + @message = message + end + + def to_json + { status: @status, message: @message }.to_json + end + end +end diff --git a/lib/gg/key_fingerprint.rb b/lib/gg/key_fingerprint.rb new file mode 100644 index 00000000..e671af8b --- /dev/null +++ b/lib/gg/key_fingerprint.rb @@ -0,0 +1,54 @@ +module Gg + class KeyFingerprint + include Gg::Popen + + attr_accessor :key + + def initialize(key) + @key = key + end + + def fingerprint + cmd_status = 0 + cmd_output = '' + + Tempfile.open('gitlab_key_file') do |file| + file.puts key + file.rewind + + cmd = [] + cmd.push *%W(ssh-keygen) + cmd.push *%W(-E md5) if explicit_fingerprint_algorithm? + cmd.push *%W(-lf #{file.path}) + + cmd_output, cmd_status = popen(cmd, '/tmp') + end + return nil unless cmd_status.zero? + + # 16 hex bytes separated by ':', optionally starting with "MD5:" + fingerprint_matches = cmd_output.match(/(MD5:)?(?(\h{2}:){15}\h{2})/) + return nil unless fingerprint_matches + + fingerprint_matches[:fingerprint] + end + + private + + def explicit_fingerprint_algorithm? + # OpenSSH 6.8 introduces a new default output format for fingerprints. + # Check the version and decide which command to use. + + version_output, version_status = popen(%W(ssh -V)) + return false unless version_status.zero? + + version_matches = version_output.match(/OpenSSH_(?\d+)\.(?\d+)/) + return false unless version_matches + + if version_matches[:major].to_i > 6 && version_matches[:minor].to_i > 8 + return true + else + return false + end + end + end +end diff --git a/lib/gg/popen.rb b/lib/gg/popen.rb new file mode 100644 index 00000000..d93d2289 --- /dev/null +++ b/lib/gg/popen.rb @@ -0,0 +1,35 @@ +require 'fileutils' +require 'open3' + +module Gg + module Popen + extend self + + def popen(cmd, path=nil) + unless cmd.is_a?(Array) + raise 'System commands must be given as an array of strings' + end + + path ||= Dir.pwd + vars = { 'PWD' => path } + options = { chdir: path } + + unless File.directory?(path) + FileUtils.mkdir_p(path) + end + + @cmd_output = '' + @cmd_status = 0 + Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr| + # We are not using stdin so we should close it, in case the command we + # are running waits for input. + stdin.close + @cmd_output << stdout.read + @cmd_output << stderr.read + @cmd_status = wait_thr.value.exitstatus + end + + [@cmd_output, @cmd_status] + end + end +end diff --git a/lib/gg/shell.rb b/lib/gg/shell.rb new file mode 100644 index 00000000..c426240c --- /dev/null +++ b/lib/gg/shell.rb @@ -0,0 +1,27 @@ +module Gg + class Shell + class AccessDenied < StandardError; end + + class KeyAdder < Struct.new(:io) + def add_key(id, key) + io.puts("#{id}\t#{key.strip}") + end + end + + def self.add_key(key_id, key_content) + Gg::Utils.system_silent([gg_shell_keys_path, + 'add-key', key_id, key_content]) + end + + def self.remove_key(key_id, key_content) + Gg::Utils.system_silent([gg_shell_keys_path, + 'rm-key', key_id, key_content]) + end + + protected + + def self.gg_shell_keys_path + File.join(Glitter::Application.config.shell_path, 'bin', 'gitlab-keys') + end + end +end diff --git a/lib/gg/shell_env.rb b/lib/gg/shell_env.rb new file mode 100644 index 00000000..0337c280 --- /dev/null +++ b/lib/gg/shell_env.rb @@ -0,0 +1,17 @@ +module Gg + # This module provide 2 methods + # to set specific ENV variables for GitLab Shell + module ShellEnv + extend self + + def set_env(user) + # Set GL_ID env variable + ENV['GL_ID'] = "user-#{user.id}" + end + + def reset_env + # Reset GL_ID env variable + ENV['GL_ID'] = nil + end + end +end diff --git a/lib/rack/overload_server.rb b/lib/gg/sync.rb similarity index 50% rename from lib/rack/overload_server.rb rename to lib/gg/sync.rb index caba664b..96882bf3 100644 --- a/lib/rack/overload_server.rb +++ b/lib/gg/sync.rb @@ -1,76 +1,44 @@ -# used to overload the service_rpc function. Read_body -# returns the useful bit. One can extract the commits -# and the branch name from there, which will be further -# used to update satellite folder and commits thumbnails +# used to sync satellite and bare repo after push +# triggerd by api call from post-recieve +module Gg + class Sync -# TODO: Find a better way to achieve the same -require 'zlib' -require 'rack/request' -require 'rack/response' -require 'rack/utils' -require 'time' + attr_reader :changes, :repo_path -require 'grack/git' - -module Grack - class Server - attr_reader :git - - def service_rpc - return render_no_access unless has_access?(@rpc, true) - - input = read_body - # old commit SHA starts from the fourth position - commits_sha = input[4, 120].split(' ') - old_sha = commits_sha.first - new_sha = commits_sha.second - branch = commits_sha.third.split(/\W+/).last - - # take out username and project from path - path = /^([\w\.\/-]+)\.git/.match(@req.path).to_a - - @res = Rack::Response.new - @res.status = 200 - @res["Content-Type"] = "application/x-git-%s-result" % @rpc - @res["Transfer-Encoding"] = "chunked" - @res["Cache-Control"] = "no-cache" - - @res.finish do - git.execute([@rpc, '--stateless-rpc', git.repo]) do |pipe| - pipe.write(input) - pipe.close_write - - while block = pipe.read(8192) # 8KB at a time - @res.write encode_chunk(block) # stream it to the client - end + def initialize(repo_path, changes) + @repo_path = repo_path + @changes = changes + end - @res.write terminating_chunk - end - if @rpc == 'receive-pack' - update_working_dir(path, branch) - generate_images_between(old_sha, new_sha, branch) - end - end + def sync_satellite + refs = @changes.split(' ') + branch = refs.last.split('/').last + update_working_dir(@repo_path, branch) + old_sha = refs.first + new_sha = refs.second + generate_images_between(old_sha, new_sha, branch) end # working dir or satellite folder needs to be in sync with bare repo # path is used to find the user and project # branch is used to get context of branch to sync def update_working_dir(path, branch) - username_projectname = path.last - ids = username_projectname.split('/') - project_owner = User.find_by(username: ids.second.to_s.downcase) + # path is of form username/project_name + ids = path.split('/') + project_owner = User.find_by(username: ids.first.to_s.downcase) @project = Project.with_deleted.find_by user_id: project_owner.id, name: ids.last.to_s.downcase # update of username/project/satellite with rugged @sat_repo = @project.satelliterepo bare_remote = @project.satelliterepo.remotes['bare'] + bare_remote = @sat_repo.remotes.create 'bare', @project.barerepo.path unless bare_remote # fetch from the bare remote bare_remote.fetch remote_branch = @sat_repo.branches["refs/remotes/bare/#{branch}"] local_branch = @sat_repo.branches["#{branch}"] - local_branch = @sat_repo.create_branch "#{branch}" unless local_branch + local_branch = @sat_repo + .create_branch("#{branch}", "remotes/bare/#{branch}") unless local_branch # checkout the branch, sync the refs and force checkout head to keep # working dir clean @@ -84,14 +52,20 @@ def update_working_dir(path, branch) # pass the head after push for new inspire image if push branch is master def generate_images_between(old_sha, new_sha, branch) head = @sat_repo.lookup("#{new_sha}") - tail = @sat_repo.lookup("#{old_sha}") + # tails won't exist if previously repo was empty + tail = @sat_repo.lookup("#{old_sha}") unless old_sha[0..5] == '000000' walker = Rugged::Walker.new(@sat_repo) walker.push(head) - walker.hide(tail) + walker.hide(tail) unless old_sha[0..5] == '000000' # find diff for each parent child pair walker.each do |commit| - commit.parents.each do|p| - generate_for('thumbnail', commit, p) + # very first commit has no parent + if commit.parents.empty? + generate_for('thumbnail', commit, nil) + else + commit.parents.each do|p| + generate_for('thumbnail', commit, p) + end end end generate_for('inspire', head, head.parents.first) if branch == 'master' diff --git a/lib/gg/utils.rb b/lib/gg/utils.rb new file mode 100644 index 00000000..51ad30e1 --- /dev/null +++ b/lib/gg/utils.rb @@ -0,0 +1,17 @@ +module Gg + module Utils + extend self + + # Run system command without outputting to stdout. + # + # @param cmd [Array] + # @return [Boolean] + def system_silent(cmd) + Popen::popen(cmd).last.zero? + end + + def force_utf8(str) + str.force_encoding(Encoding::UTF_8) + end + end +end diff --git a/lib/rack/grack_auth.rb b/lib/rack/grack_auth.rb index 8ad7c462..bebd4499 100644 --- a/lib/rack/grack_auth.rb +++ b/lib/rack/grack_auth.rb @@ -31,6 +31,8 @@ def auth! # Authentication with username and password login, password = @auth.credentials @user = authenticate_user(login, password) + + Gg::ShellEnv.set_env(@user) if @user end # return nil if user is not found else return @@ -38,7 +40,7 @@ def auth! def authenticate_user(login, password) user = User.find_by(username: login.to_s.downcase) if user.nil? - return nil + return nil else user if user.valid_password?(password) end @@ -67,7 +69,7 @@ def project_by_path(path) def authorized_request? case git_cmd - when *%w{ git-upload-pack git-upload-archive } + when *%w( git-upload-pack git-upload-archive ) if user ProjectMember.member?(project, user) elsif !project.private @@ -76,9 +78,11 @@ def authorized_request? else false end - when *%w{ git-receive-pack } + when *%w( git-receive-pack ) if user - ProjectMember.write_acess(project, user) + # Skip user authorization on upload request. + # It will be done by the pre-receive hook in the repository. + true else false end @@ -98,7 +102,7 @@ def git_cmd end def render_not_found - [404, { "Content-Type" => "text/plain" }, ["Not Found"]] + [404, { 'Content-Type' => 'text/plain' }, ['Not Found']] end end end diff --git a/spec/controllers/keys_controller_spec.rb b/spec/controllers/keys_controller_spec.rb new file mode 100644 index 00000000..9a38c65e --- /dev/null +++ b/spec/controllers/keys_controller_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe KeysController, type: :controller do + let(:user) { create(:user) } + before { sign_in(user) } + + describe 'GET index' do + it 'renders the index template' do + get :index + expect(response).to render_template('index') + end + end + + describe 'POST create' do + before do + allow_any_instance_of(Key).to receive(:add_to_shell).and_return(true) + end + it 'adds key and redirects to key path' do + ssh_key = + 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCx3ke+rnMT/ILY81K1un1CWf9ghcP' + + 'glIlV7pMV2H5AwyC/Dx5x+DyKmNmhBmvCYJ+1we8f0pPXLx2QpyAXw8s0s+sBL/gkiz' + + 'sqqwrUzK9Rlkj58kvNFl8gLQk3qqs8dR6bODP9LQqCGhMFErQtDQTvBq91jhWuIIunu' + + 'mK7T+0GWDMf7O9CNdr/aprYrUfuGLggOdz0oPja792V+ay1xWAHEOueKfGvOGFDbQlc' + + 'TT2uI9wYz9RGkLhDNOo4S74W59xMwMpf77XsoTYxcdrAT7WpTlzaj2usbbGBgcBKx5k' + + 'b0dPBOQ3rQadtZnLjN2dZAeapUO2MElyX0lxt1nrbIKC2 addie@host.localdomain' + post :create, key: { title: 'test', key: ssh_key } + expect(Key.last.title).to eq('test') + expect(response).to redirect_to(keys_path) + end + end + + describe 'DELETE destroy' do + before do + allow_any_instance_of(Key).to receive(:add_to_shell).and_return(true) + allow_any_instance_of(Key).to receive(:remove_from_shell).and_return(true) + @request.env['HTTP_REFERER'] = 'http://test.host/keys' + end + it 'removes key and redirects to key path' do + key = create(:key, user: user) + delete :destroy, id: key.id + expect(Key.all.count).to be(0) + expect(response).to redirect_to(keys_path) + end + end +end diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb new file mode 100644 index 00000000..42eb37fd --- /dev/null +++ b/spec/factories/keys.rb @@ -0,0 +1,15 @@ +FactoryGirl.define do + + factory :key do + association :user + sequence :key, (0..9).cycle do |n| + 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCx3ke+rnMT/ILY81K1un1CWf9ghcPgl' + + 'IlV7pMV2H5AwyC/Dx5x+DyKmNmhBmvCYJ+1we8f0pPXLx2QpyAXw8s0s+sBL/gkizsqqw' + + 'rUzK9Rlkj58kvNFl8gLQk3qqs8dR6bODP9LQqCGhMFErQtDQTvBq91jhWuIIunumK7T+0' + + 'GWDMf7O9CNdr/aprYrUfuGLggOdz0oPja792V+ay1xWAHEOueKfGvOGFDbQlcTT2uI9wY' + + 'z9RGkLhDNOo4S74W59xMwMpf77XsoTYxcdrAT7WpTlzaj2usbbGBgcBKx5kb0dPBOQ3rQ' + + "adtZnLjN2dZAeapUO2MElyX0lxt1nrbIKC#{n} addie@localhost.localdomain" + end + title 'MyString' + end +end diff --git a/spec/features/users_spec.rb b/spec/features/users_spec.rb index 18f70db8..401354b5 100644 --- a/spec/features/users_spec.rb +++ b/spec/features/users_spec.rb @@ -51,4 +51,44 @@ expect(page.current_path).to eq(new_user_session_path) end end + + describe 'keys' do + let(:user) { create(:user) } + before do + login_as(user) + allow_any_instance_of(Key).to receive(:add_to_shell).and_return(true) + end + + scenario 'adds key to profile' do + key = + 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCx3ke+rnMT/ILY81K1un1CWf9ghcP' + + 'glIlV7pMV2H5AwyC/Dx5x+DyKmNmhBmvCYJ+1we8f0pPXLx2QpyAXw8s0s+sBL/gkiz' + + 'sqqwrUzK9Rlkj58kvNFl8gLQk3qqs8dR6bODP9LQqCGhMFErQtDQTvBq91jhWuIIunu' + + 'mK7T+0GWDMf7O9CNdr/aprYrUfuGLggOdz0oPja792V+ay1xWAHEOueKfGvOGFDbQlc' + + 'TT2uI9wYz9RGkLhDNOo4S74W59xMwMpf77XsoTYxcdrAT7WpTlzaj2usbbGBgcBKx5k' + + 'b0dPBOQ3rQadtZnLjN2dZAeapUO2MElyX0lxt1nrbIKC2 addie@host.localdomain' + visit '/keys' + fill_in 'key[title]', with: 'test' + fill_in 'key[key]', with: key + expect{click_button 'Add key'}.to change{Key.all.count}.by(1) + end + + context 'key page' do + before do + @key = create(:key, user: user) + visit '/keys' + end + + it 'sees key' do + expect(find('table')).to have_content(@key.title) + end + + it 'removes key' do + allow_any_instance_of(Key).to receive(:remove_from_shell) + .and_return(true) + find('table').click_link 'Remove' + expect(find('.option')).not_to have_content(@key.title) + end + end + end end diff --git a/spec/lib/api_spec.rb b/spec/lib/api_spec.rb new file mode 100644 index 00000000..d976401b --- /dev/null +++ b/spec/lib/api_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper' +include Models::ProjectMembersHelper + +describe API::API, type: :request do + let(:user) { create(:user) } + let(:key) { create(:key, user: user) } + let(:project) { create(:project) } + let(:secret_token) { '02eb56d97afc267a58e4a01e8a2f4c6a' } + + describe 'GET /internal/check', no_db: true do + it do + get 'api/v1/internal/check', secret_token: secret_token + expect(response.status).to eq(200) + end + end + + describe 'POST /internal/allowed' do + before do + allow_any_instance_of(Key).to receive(:add_to_shell).and_return(true) + end + + context 'access granted' do + before { make_member project, user} + + context 'git pull' do + it do + pull(key, project) + + expect(response.status).to eq(200) + expect(JSON.parse(response.body)['status']).to be_truthy + end + end + + context 'git push' do + it do + push(key, project) + + expect(response.status).to eq(200) + expect(JSON.parse(response.body)['status']).to be_truthy + end + end + end + + context 'access denied' do + context 'git pull' do + before { project.update_attribute(:private, true) } + it do + pull(key, project) + + expect(response.status).to eq(200) + expect(JSON.parse(response.body)['status']).to be_falsey + end + end + + context 'git push' do + it do + push(key, project) + + expect(response.status).to eq(200) + expect(JSON.parse(response.body)['status']).to be_falsey + end + end + end + end + + def pull(key, project) + post( + 'api/v1/internal/allowed', + key_id: key.id, + project: "#{project.user.username}/#{project.name}", + action: 'git-upload-pack', + secret_token: secret_token + ) + end + + def push(key, project) + post( + 'api/v1/internal/allowed', + changes: 'd14d6c0abdd253381df51a723d58691b2ee1ab08' + + '570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/master', + key_id: key.id, + project: "#{project.user.username}/#{project.name}", + action: 'git-receive-pack', + secret_token: secret_token + ) + end +end diff --git a/spec/lib/git_access_spec.rb b/spec/lib/git_access_spec.rb new file mode 100644 index 00000000..1a1a927e --- /dev/null +++ b/spec/lib/git_access_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' +include Models::ProjectMembersHelper + +describe Gg::GitAccess do + let(:user) { create(:user) } + let(:project) { create(:project, private: true) } + let(:access) { Gg::GitAccess.new(user, project) } + + context 'user is member' do + describe 'push' do + shared_examples 'has write access' do |role| + before { make_member project, user, role } + it 'returns true' do + response = access.check('git-receive-pack') + expect(response.status).to be_truthy + end + end + + it_behaves_like 'has write access', 'collaborator' + it_behaves_like 'has write access', 'owner' + + context 'does not have write access' do + before { make_member project, user, 'reporter' } + it 'returns false' do + response = access.check('git-receive-pack') + expect(response.status).to be_falsey + end + end + end + + describe 'pull' do + shared_examples 'has read access' do |role| + before { make_member project, user, role } + it 'returns true' do + response = access.check('git-upload-pack') + expect(response.status).to be_truthy + end + end + + it_behaves_like 'has read access', 'collaborator' + it_behaves_like 'has read access', 'owner' + it_behaves_like 'has read access', 'reporter' + end + end + + context 'user is not member' do + it 'returns false for read access' do + response = access.check('git-receive-pack') + expect(response.status).to be_falsey + end + + it 'returns false for write access' do + response = access.check('git-receive-pack') + expect(response.status).to be_falsey + end + end +end diff --git a/spec/lib/grack_auth.rb b/spec/lib/grack_auth_spec.rb similarity index 88% rename from spec/lib/grack_auth.rb rename to spec/lib/grack_auth_spec.rb index dfc28187..04b5f297 100644 --- a/spec/lib/grack_auth.rb +++ b/spec/lib/grack_auth_spec.rb @@ -158,26 +158,6 @@ it_behaves_like 'has write access', 'collaborator' it_behaves_like 'has write access', 'owner' - - shared_examples 'doesn not have write access' do |role| - unless role.nil? - before do - create( - :project_member, - member: user, - member_project: project, - role: role - ) - end - end - - it 'responds with status 401' do - expect(status).to eq(401) - end - end - - it_behaves_like 'doesn not have write access', 'reporter' - it_behaves_like 'doesn not have write access', nil end context "when the user doesn't have access to the project" do diff --git a/spec/lib/key_fingerprint_spec.rb b/spec/lib/key_fingerprint_spec.rb new file mode 100644 index 00000000..51794455 --- /dev/null +++ b/spec/lib/key_fingerprint_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Gg::KeyFingerprint do + let(:key) do + 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCx3ke+rnMT/ILY81K1un1CW' + + 'f9ghcPglIlV7pMV2H5AwyC/Dx5x+DyKmNmhBmvCYJ+1we8f0pPXLx2QpyAXw8s0s+sBL/gk' + + 'izsqqwrUzK9Rlkj58kvNFl8gLQk3qqs8dR6bODP9LQqCGhMFErQtDQTvBq91jhWuIIunumK' + + '7T+0GWDMf7O9CNdr/aprYrUfuGLggOdz0oPja792V+ay1xWAHEOueKfGvOGFDbQlcTT2uI9' + + 'wYz9RGkLhDNOo4S74W59xMwMpf77XsoTYxcdrAT7WpTlzaj2usbbGBgcBKx5kb0dPBOQ3rQ' + + 'adtZnLjN2dZAeapUO2MElyX0lxt1nrbIKCZ addie@localhost.localdomain' + end + + let(:fingerprint) { 'a1:c2:2b:79:2f:48:99:8d:99:52:91:35:3c:8e:25:82' } + + describe '#fingerprint' do + it "generates the key's fingerprint" do + expect(Gg::KeyFingerprint.new(key).fingerprint).to eq(fingerprint) + end + end +end diff --git a/spec/lib/popen_spec.rb b/spec/lib/popen_spec.rb new file mode 100644 index 00000000..cac4a02e --- /dev/null +++ b/spec/lib/popen_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe 'Gg::Popen', no_db: true do + let(:path) { Rails.root.join('tmp').to_s } + + before do + @klass = Class.new(Object) + @klass.send(:include, Gg::Popen) + end + + context 'zero status' do + before do + @output, @status = @klass.new.popen(%W(ls), path) + end + + it { expect(@status).to be_zero } + it { expect(@output).to include('cache') } + end + + context 'non-zero status' do + before do + @output, @status = @klass.new.popen(%W(cat NOTHING), path) + end + + it { expect(@status).to eq(1) } + it { expect(@output).to include('No such file or directory') } + end + + context 'unsafe string command' do + it 'raises an error when it gets called with a string argument' do + expect { @klass.new.popen('ls', path) }.to raise_error(RuntimeError) + end + end + + context 'without a directory argument' do + before do + @output, @status = @klass.new.popen(%W(ls)) + end + + it { expect(@status).to be_zero } + it { expect(@output).to include('spec') } + end +end diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb new file mode 100644 index 00000000..a4c0a773 --- /dev/null +++ b/spec/models/key_spec.rb @@ -0,0 +1,101 @@ +require 'spec_helper' + +describe Key do + describe 'Associations' do + it { is_expected.to belong_to(:user) } + end + + describe 'Validation' do + it { is_expected.to validate_presence_of(:title) } + it { is_expected.to validate_presence_of(:key) } + it { is_expected.to validate_length_of(:title) } + it { is_expected.to validate_length_of(:key) } + end + + describe 'Methods' do + before do + allow_any_instance_of(Key).to receive(:add_to_shell).and_return(true) + end + + context 'shell_id' do + let(:key) { create(:key) } + + it 'formats id properly' do + expect(key.shell_id).to eq("key-#{key.id}") + end + end + end + + describe 'validation of' do + before do + allow_any_instance_of(Key).to receive(:add_to_shell).and_return(true) + end + + context 'uniqueness' do + let(:user) { create(:user) } + let(:dummy_key) { create(:key, user: user) } + + it 'accepts the key once' do + expect(build(:key, user: user)).to be_valid + end + + it 'does not accept the exact same key twice' do + expect(build(:key, key: dummy_key.key, user: user)).not_to be_valid + end + + it 'does not accept a duplicate key with a different comment' do + duplicate = build(:key, key: dummy_key.key, user: user) + duplicate.key << ' extra comment' + expect(duplicate).not_to be_valid + end + end + + context 'fingerprintable key' do + it 'accepts the fingerprintable key' do + expect(build(:key)).to be_valid + end + + it 'rejects an unfingerprintable key that contains a space' do + key = build(:key) + + # Not always the middle, but close enough + key.key = key.key[0..100] + ' ' + key.key[101..-1] + + expect(key).not_to be_valid + end + + it 'rejects the unfingerprintable key (not a key)' do + expect(build(:key, key: 'ssh-rsa an-invalid-key==')).not_to be_valid + end + + it 'rejects the multiple line key' do + key = build(:key) + key.key.gsub!(' ', '\n') + expect(key).not_to be_valid + end + end + end + + describe 'callbacks' do + context 'add new key' do + it 'should add new key to authorized_file' do + key = build(:key, id: 7) + expect(Gg::Shell).to receive(:add_key).with(key.shell_id, key.key) + key.save + end + end + + context 'remove key from authorized_file' do + before do + allow(Gg::Shell).to receive(:remove_key).and_return(true) + allow_any_instance_of(Key).to receive(:add_to_shell).and_return(true) + end + + it 'should remove key from authorized_file' do + key = create(:key) + expect(Gg::Shell).to receive(:remove_key).with(key.shell_id, key.key) + key.destroy + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6ae33367..21d4cfef 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,7 @@ require File.expand_path('../../config/environment', __FILE__) require 'rspec/rails' require 'capybara/rspec' +require 'shoulda/matchers' # Requires supporting ruby files with custom matchers and macros, etc, # in spec/support/ and its subdirectories. diff --git a/tmp/pids/.gitkeep b/tmp/pids/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tmp/sockets/.gitkeep b/tmp/sockets/.gitkeep new file mode 100644 index 00000000..e69de29b