diff --git a/.env b/.env index da8ea0c..8a3d47d 100644 --- a/.env +++ b/.env @@ -2,3 +2,5 @@ HOST=lvh.me:3000 GMAIL_DOMAIN=gmail.com GMAIL_USERNAME=dontreply1357@gmail.com GMAIL_PASSWORD=13579rty +AWS_SECRET_ACCESS_KEY=xbnXDiIvtqVAmoaFT5yYZWu4Wye/l+CvQt6Cp2bm +AWS_ACCESS_KEY=AKIAIQ2MGPRTWCHJKK5A diff --git a/Gemfile b/Gemfile index d946c42..d0e36a4 100644 --- a/Gemfile +++ b/Gemfile @@ -16,11 +16,13 @@ gem 'coffee-rails', '~> 4.1.0' # Use apartment for multi-tenant databases gem 'apartment', '~> 1.0.1' +gem 'apartment-sidekiq', '~> 0.2.0' #bootstrap CSS #gem 'bootstrap-sass' # Devise is a flexible authentication solution for Rails based on Warden gem 'devise', '~> 3.5.1' +gem 'devise-async', '~>0.10.1' # simple authorization solution for Rails which is decoupled from user roles gem 'cancancan', '~> 1.12.0' @@ -36,6 +38,11 @@ gem 'sdoc', '~> 0.4.0', group: :doc gem 'slim-rails', '~> 3.0.1' +# Simple, efficient background processing +gem 'sidekiq', '~> 3.4.2' +# For Sidekiq monitor +gem 'sinatra', '~> 1.4.6' + # Use ActiveModel has_secure_password # gem 'bcrypt', '~> 3.1.7' @@ -48,6 +55,9 @@ gem 'unicorn', '~> 4.9.0' # Shim to load environment variables from .env into ENV in development. gem 'dotenv-rails', '~> 2.0.2', :groups => [:development, :test] +# Official Amazon AWS SDK +gem 'aws-sdk', '~> 2.1.7' + group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'byebug' @@ -64,11 +74,13 @@ group :test do gem 'capybara', '~> 2.4.4' gem 'database_cleaner', '~> 1.4.1' gem 'rspec-rails', '~> 3.2.3' + gem 'rspec-its', '~> 1.2.0' gem 'factory_girl', '~> 4.5.0' gem 'rspec-expectations', '~> 3.2.1' gem 'factory_girl_rails', '~> 4.0' gem 'shoulda-matchers', '~> 2.8.0' gem 'faker', '~> 1.4.3' + gem 'rspec-activejob', '~> 0.4.1' end # Deployment diff --git a/Gemfile.lock b/Gemfile.lock index ebc053a..9559476 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -36,10 +36,19 @@ GEM minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) - apartment (1.0.1) + apartment (1.0.2) activerecord (>= 3.1.2, < 5.0) rack (>= 1.3.6) - arel (6.0.0) + apartment-sidekiq (0.2.0) + apartment (~> 1.0) + sidekiq (>= 2.11) + arel (6.0.2) + aws-sdk (2.1.8) + aws-sdk-resources (= 2.1.8) + aws-sdk-core (2.1.8) + jmespath (~> 1.0) + aws-sdk-resources (2.1.8) + aws-sdk-core (= 2.1.8) bcrypt (3.1.10) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) @@ -83,7 +92,7 @@ GEM colorize (0.7.7) columnize (0.9.0) connection_pool (2.2.0) - cucumber (1.3.19) + cucumber (1.3.20) builder (>= 2.1.2) diff-lcs (>= 1.1.3) gherkin (~> 2.12) @@ -104,6 +113,8 @@ GEM responders thread_safe (~> 0.1) warden (~> 1.2.3) + devise-async (0.10.1) + devise (~> 3.2) diff-lcs (1.2.5) dotenv (2.0.2) dotenv-rails (2.0.2) @@ -124,10 +135,12 @@ GEM activesupport (>= 4.1.0) hitimes (1.2.2) i18n (0.7.0) - jbuilder (2.3.0) + jbuilder (2.3.1) activesupport (>= 3.0.0, < 5) multi_json (~> 1.2) - jquery-rails (4.0.3) + jmespath (1.0.2) + multi_json (~> 1.0) + jquery-rails (4.0.4) rails-dom-testing (~> 1.0) railties (>= 4.2.0) thor (>= 0.14, < 2.0) @@ -140,7 +153,7 @@ GEM mime-types (2.6.1) mini_portile (0.6.2) minitest (5.7.0) - multi_json (1.11.1) + multi_json (1.11.2) multi_test (0.1.2) net-scp (1.2.1) net-ssh (>= 2.6.5) @@ -149,7 +162,9 @@ GEM mini_portile (~> 0.6.0) orm_adapter (0.5.0) pg (0.18.2) - rack (1.6.1) + rack (1.6.4) + rack-protection (1.5.3) + rack rack-test (0.6.3) rack (>= 1.0) rails (4.2.1) @@ -184,11 +199,17 @@ GEM redis (~> 3.0, >= 3.0.4) responders (2.1.0) railties (>= 4.2.0, < 5) + rspec-activejob (0.4.1) + activejob (>= 4.2) + rspec-mocks rspec-core (3.2.3) rspec-support (~> 3.2.0) rspec-expectations (3.2.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.2.0) + rspec-its (1.2.0) + rspec-core (>= 3.0.0) + rspec-expectations (>= 3.0.0) rspec-mocks (3.2.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.2.0) @@ -201,8 +222,8 @@ GEM rspec-mocks (~> 3.2.0) rspec-support (~> 3.2.0) rspec-support (3.2.2) - sass (3.4.14) - sass-rails (5.0.2) + sass (3.4.16) + sass-rails (5.0.3) railties (>= 4.0.0, < 5.0) sass (~> 3.1) sprockets (>= 2.8, < 4.0) @@ -219,6 +240,10 @@ GEM json (~> 1.0) redis (~> 3.2, >= 3.2.1) redis-namespace (~> 1.5, >= 1.5.2) + sinatra (1.4.6) + rack (~> 1.4) + rack-protection (~> 1.4) + tilt (>= 1.3, < 3) slim (3.0.6) temple (~> 0.7.3) tilt (>= 1.3.3, < 2.1) @@ -231,7 +256,7 @@ GEM spring (1.3.6) sprockets (3.2.0) rack (~> 1.0) - sprockets-rails (2.3.1) + sprockets-rails (2.3.2) actionpack (>= 3.0) activesupport (>= 3.0) sprockets (>= 2.8, < 4.0) @@ -258,7 +283,7 @@ GEM raindrops (~> 0.7) warden (1.2.3) rack (>= 1.0) - web-console (2.1.2) + web-console (2.2.1) activemodel (>= 4.0) binding_of_caller (>= 0.7.2) railties (>= 4.0) @@ -271,6 +296,8 @@ PLATFORMS DEPENDENCIES apartment (~> 1.0.1) + apartment-sidekiq (~> 0.2.0) + aws-sdk (~> 2.1.7) byebug cancancan (~> 1.12.0) capistrano (~> 3.4.0) @@ -284,6 +311,7 @@ DEPENDENCIES cucumber-rails (~> 1.4.2) database_cleaner (~> 1.4.1) devise (~> 3.5.1) + devise-async (~> 0.10.1) dotenv-rails (~> 2.0.2) factory_girl (~> 4.5.0) factory_girl_rails (~> 4.0) @@ -292,14 +320,21 @@ DEPENDENCIES jquery-rails pg (~> 0.18.2) rails (= 4.2.1) + rspec-activejob (~> 0.4.1) rspec-expectations (~> 3.2.1) + rspec-its (~> 1.2.0) rspec-rails (~> 3.2.3) sass-rails (~> 5.0) sdoc (~> 0.4.0) shoulda-matchers (~> 2.8.0) + sidekiq (~> 3.4.2) + sinatra (~> 1.4.6) slim-rails (~> 3.0.1) spring turbolinks uglifier (>= 1.3.0) unicorn (~> 4.9.0) web-console (~> 2.0) + +BUNDLED WITH + 1.10.3 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 618ab77..06f83ef 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,8 +2,16 @@ class ApplicationController < ActionController::Base # Prevent CSRF attacks by raising an exception. # For APIs, you may want to use :null_session instead. protect_from_forgery with: :exception - rescue_from CanCan::AccessDenied do - redirect_to root_path + rescue_from CanCan::AccessDenied do |exception| + redirect_to root_path, alert: exception.message end + protected + + # Helper method used in layout to render model errors, should be overriden in controllers + def errors + {} + end + helper_method :errors + end diff --git a/app/controllers/themes_controller.rb b/app/controllers/themes_controller.rb new file mode 100644 index 0000000..d9b9235 --- /dev/null +++ b/app/controllers/themes_controller.rb @@ -0,0 +1,30 @@ +class ThemesController < ApplicationController + before_action :authenticate_user! + load_and_authorize_resource + + # GET#index + def index + @themes = Theme.all + end + + # GET#create_completed redirect from AWS + def create_completed + key = params.require(:key) + theme_file_name = key.sub(/[^_]*_/, '') # theme zip file name (theme name) is located after the first _ (underscore) + theme_name = File.basename theme_file_name, File.extname(theme_file_name) + theme = Theme.new name: theme_name, zip_file_url: key, status: :processing + redirect_to action: (theme.save ? 'index' : 'new') + if theme.errors.blank? + flash.notice = "Theme '#{theme_name}' uploaded successfully" + else + flash.alert = theme.errors.full_messages + end + end + + # DELETE#destroy + def destroy + @theme.destroy + flash.notice = "Theme '#{@theme.name}' successfully deleted" + redirect_to themes_path + end +end diff --git a/app/helpers/themes_helper.rb b/app/helpers/themes_helper.rb new file mode 100644 index 0000000..20a1ac5 --- /dev/null +++ b/app/helpers/themes_helper.rb @@ -0,0 +1,32 @@ +module ThemesHelper + UPLOADS_FOLDER = 'uploads/' + + def theme_upload_params + return @theme_upload_params if @theme_upload_params + + bucket_name = Rails.application.config.aws_public_bucket_name + redirect_url = request.protocol + request.host_with_port + '/themes/create_completed' + + @theme_upload_params = { form_action: "https://#{bucket_name}.s3.amazonaws.com/", + form_method: 'post', + form_enclosure_type: 'multipart/form-data', + acl: 'private', + redirect_url: redirect_url, + file_id: "#{UPLOADS_FOLDER}#{SecureRandom.uuid}_${filename}", + access_key: Rails.application.config.aws_access_key } + + conditions = [[:eq, :$bucket, bucket_name], + [:eq, :$acl, :private], + [:"content-length-range", 1, Rails.application.config.aws_max_theme_zip_file_length], + [:"starts-with", :$key, UPLOADS_FOLDER], + [:eq, :$success_action_redirect, redirect_url]] + policy = { conditions: conditions, expiration: (Time.now + 10.hours).utc.iso8601 } + policy = Base64.strict_encode64(policy.to_json) + + @theme_upload_params.merge! policy: policy + @theme_upload_params.merge! signature: + Base64.strict_encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'), + Rails.application.config.aws_secret_access_key, + policy)) + end +end diff --git a/app/jobs/delete_theme_job.rb b/app/jobs/delete_theme_job.rb new file mode 100644 index 0000000..1f372ba --- /dev/null +++ b/app/jobs/delete_theme_job.rb @@ -0,0 +1,11 @@ +class DeleteThemeJob < ActiveJob::Base + queue_as :low_priority + + rescue_from(Aws::S3::Errors::RequestTimeout) do + retry_job wait: 5.minutes, queue: :low_priority + end + + def perform(zip_file_url, is_public_bucket) + AmazonAwsClient.try (is_public_bucket ? :delete_from_public_bucket : :delete_from_private_bucket), zip_file_url + end +end diff --git a/app/jobs/transfer_theme_job.rb b/app/jobs/transfer_theme_job.rb new file mode 100644 index 0000000..29dd699 --- /dev/null +++ b/app/jobs/transfer_theme_job.rb @@ -0,0 +1,12 @@ +class TransferThemeJob < ActiveJob::Base + queue_as :default + + rescue_from(Aws::S3::Errors::RequestTimeout) do + retry_job wait: 5.minutes, queue: :default + end + + def perform(theme) + AmazonAwsClient.transfer_from_public_to_private_bucket theme.zip_file_url + theme.update! status: :uploaded + end +end diff --git a/app/libs/amazon_aws_client.rb b/app/libs/amazon_aws_client.rb new file mode 100644 index 0000000..2537785 --- /dev/null +++ b/app/libs/amazon_aws_client.rb @@ -0,0 +1,28 @@ +module AmazonAwsClient + def self.client + credentials = Aws::Credentials.new Rails.application.config.aws_access_key, Rails.application.config.aws_secret_access_key + Aws::S3::Client.new region: Rails.application.config.aws_region, credentials: credentials + end + + def self.transfer_from_public_to_private_bucket key + client = self.client + client.copy_object(bucket: Rails.application.config.aws_private_bucket_name, + key: key, + copy_source: Rails.application.config.aws_public_bucket_name + '/' + key) + client.delete_object bucket: Rails.application.config.aws_public_bucket_name, key: key + end + + def self.delete_from_public_bucket key + self.delete_from_bucket Rails.application.config.aws_public_bucket_name, key + end + + def self.delete_from_private_bucket key + self.delete_from_bucket Rails.application.config.aws_private_bucket_name, key + end + + private + + def self.delete_from_bucket bucket, key + self.client.delete_object bucket: bucket, key: key + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index a755be1..de6e7b6 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -10,5 +10,9 @@ def initialize(user) can :destroy, User if user.role.can_delete_users? can :update_users_role, User if user.role.can_update_users_role? can :update_users_password, User if user.role.can_update_users_password? + can :read, Theme if user.role.can_create_themes? || user.role.can_delete_themes? + can :create, Theme if user.role.can_create_themes? + can :create_completed, Theme if user.role.can_create_themes? + can :destroy, Theme if user.role.can_delete_themes? end end \ No newline at end of file diff --git a/app/models/theme.rb b/app/models/theme.rb new file mode 100644 index 0000000..18fb3c2 --- /dev/null +++ b/app/models/theme.rb @@ -0,0 +1,24 @@ +class Theme < ActiveRecord::Base + after_validation :schedule_theme_delete, on: :create, if: "errors.present?" + before_destroy :schedule_theme_delete + after_create :schedule_theme_transfer + + NAME_LIMIT = 100 + ZIP_FILE_URL_LIMIT = 2000 + + enum status: { processing: 0, uploaded: 1 } + + validates :name, presence: true, length: { maximum: NAME_LIMIT }, uniqueness: { case_sensitive: false } + validates :zip_file_url, presence: true, format: { with: /\.(zip)\z/i }, length: { maximum: ZIP_FILE_URL_LIMIT }, uniqueness: { case_sensitive: false } + validates :status, presence: true + + private + + def schedule_theme_transfer + TransferThemeJob.perform_later(self) + end + + def schedule_theme_delete + DeleteThemeJob.perform_later(zip_file_url, processing?) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 96c5366..8cf1db7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,7 +1,7 @@ class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable, :rememberable, :trackable - devise :database_authenticatable, :registerable, :recoverable, :validatable + devise :database_authenticatable, :registerable, :recoverable, :validatable, :async belongs_to :role validates_presence_of :role diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 5595040..fdecce5 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -8,6 +8,32 @@ +
+ <% if flash.notice %> +

+ <%= flash.notice %> +

+ <% end %> +
+ +
+ <% if flash.alert %> +

+ <%= flash.alert %> +

+ <% end %> + <% if errors.any? %> +
+

<%= pluralize(errors.count, "error") %> occured:

+ +
+ <% end %> +
+ <%= yield %> diff --git a/app/views/themes/index.html.slim b/app/views/themes/index.html.slim new file mode 100644 index 0000000..d033c23 --- /dev/null +++ b/app/views/themes/index.html.slim @@ -0,0 +1,17 @@ +- if can? :create, Theme + = link_to "Create New", new_theme_path + +table + thead + tr + th Name + th Status + th Actions + tbody + - @themes.each do |theme| + tr + td = theme.name + td = theme.status + td + - if can? :destroy, Theme + = link_to 'Delete', theme_path(theme), method: :delete diff --git a/app/views/themes/new.html.slim b/app/views/themes/new.html.slim new file mode 100644 index 0000000..47e40d1 --- /dev/null +++ b/app/views/themes/new.html.slim @@ -0,0 +1,11 @@ += form_tag(theme_upload_params[:form_action], multipart: true, enforce_utf8: false, authenticity_token: false) + + = hidden_field_tag('key', theme_upload_params[:file_id]) + = hidden_field_tag('AWSAccessKeyId', theme_upload_params[:access_key]) + = hidden_field_tag('acl', theme_upload_params[:acl]) + = hidden_field_tag('success_action_redirect', theme_upload_params[:redirect_url]) + = hidden_field_tag('policy', theme_upload_params[:policy]) + = hidden_field_tag('signature', theme_upload_params[:signature]) + + = file_field_tag('file', accept: '.zip') + = submit_tag('Upload Theme') diff --git a/config/application.rb b/config/application.rb index 9c3e7ed..c62ca08 100644 --- a/config/application.rb +++ b/config/application.rb @@ -32,18 +32,28 @@ class Application < Rails::Application # Do not swallow errors in after_commit/after_rollback callbacks. config.active_record.raise_in_transactional_callbacks = true - # ActionMailer config - config.action_mailer.default_url_options = { :host => ENV["HOST"] } - config.action_mailer.raise_delivery_errors = true - config.action_mailer.delivery_method = :smtp - config.action_mailer.perform_deliveries = true - config.action_mailer.smtp_settings = { - address: "smtp.gmail.com", - port: 587, - domain: ENV["GMAIL_DOMAIN"], - authentication: "plain", - enable_starttls_auto: true, - user_name: ENV["GMAIL_USERNAME"], - password: ENV["GMAIL_PASSWORD"]} + config.active_job.queue_adapter = :sidekiq + + # ActionMailer config + config.action_mailer.default_url_options = { :host => ENV["HOST"] } + config.action_mailer.raise_delivery_errors = true + config.action_mailer.delivery_method = :smtp + config.action_mailer.perform_deliveries = true + config.action_mailer.smtp_settings = { + address: "smtp.gmail.com", + port: 587, + domain: ENV["GMAIL_DOMAIN"], + authentication: "plain", + enable_starttls_auto: true, + user_name: ENV["GMAIL_USERNAME"], + password: ENV["GMAIL_PASSWORD"]} + + # AWS bucket config + config.aws_max_theme_zip_file_length = 1.gigabyte + config.aws_access_key = ENV["AWS_ACCESS_KEY"] + config.aws_secret_access_key = ENV["AWS_SECRET_ACCESS_KEY"] + config.aws_public_bucket_name = "flexcommerce-uploadedthemes" + config.aws_private_bucket_name = "flexcommerce-productionthemes" + config.aws_region = "us-east-1" end end diff --git a/config/environments/test.rb b/config/environments/test.rb index 1c19f08..f1b7e8e 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -39,4 +39,7 @@ # Raises error for missing translations # config.action_view.raise_on_missing_translations = true + + # For rspec-activejob gem + config.active_job.queue_adapter = :test end diff --git a/config/initializers/devise_async.rb b/config/initializers/devise_async.rb new file mode 100644 index 0000000..0084a27 --- /dev/null +++ b/config/initializers/devise_async.rb @@ -0,0 +1,5 @@ +# Supported options: :resque, :sidekiq, :delayed_job, :queue_classic, :torquebox, :backburner, :que, :sucker_punch +Devise::Async.setup do |config| + config.backend = :sidekiq + config.queue = :default +end diff --git a/config/routes.rb b/config/routes.rb index 003a52b..a1c4cef 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,10 +13,17 @@ # Put here routes to tenant-resource only constraints ->(request) { request.subdomain.present? } do get 'accounts/login_with_token' => 'accounts#login_with_token' + resources :themes, only: [:index, :new, :destroy] + get 'themes/create_completed' => "themes#create_completed" #redirect from AWS devise_for :users, skip: :registrations resources :users, except: :show end + require 'sidekiq/web' + authenticate :user do + mount Sidekiq::Web => '/sidekiq' + end + # Example of regular route: # get 'products/:id' => 'catalog#view' diff --git a/db/migrate/20150615091157_create_roles.rb b/db/migrate/20150615091157_create_roles.rb index ba0ab2c..fdc768a 100644 --- a/db/migrate/20150615091157_create_roles.rb +++ b/db/migrate/20150615091157_create_roles.rb @@ -6,6 +6,8 @@ def change t.boolean :can_update_users_password, null: false, default: false t.boolean :can_update_users_role, null: false, default: false t.boolean :can_delete_users, null: false, default: false + t.boolean :can_create_themes, null: false, default: false + t.boolean :can_delete_themes, null: false, default: false t.timestamps null: false end diff --git a/db/migrate/20150624111841_create_themes.rb b/db/migrate/20150624111841_create_themes.rb new file mode 100644 index 0000000..12a5b68 --- /dev/null +++ b/db/migrate/20150624111841_create_themes.rb @@ -0,0 +1,9 @@ +class CreateThemes < ActiveRecord::Migration + def change + create_table :themes do |t| + t.string :name, null:false, limit:100 + t.string :zip_file_url, null:false, limit:2000 + t.integer :status, null:false, default: 0 + end + end +end diff --git a/db/schema.rb b/db/schema.rb index cbb6880..87a94b2 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: 20150616064739) do +ActiveRecord::Schema.define(version: 20150624111841) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -32,12 +32,20 @@ t.boolean "can_update_users_password", default: false, null: false t.boolean "can_update_users_role", default: false, null: false t.boolean "can_delete_users", default: false, null: false + t.boolean "can_create_themes", default: false, null: false + t.boolean "can_delete_themes", default: false, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false end add_index "roles", ["name"], name: "index_roles_on_name", unique: true, using: :btree + create_table "themes", force: :cascade do |t| + t.string "name", limit: 100, null: false + t.string "zip_file_url", limit: 2000, null: false + t.integer "status", default: 0, null: false + end + create_table "users", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false diff --git a/spec/acceptance_tests/common_methods.rb b/spec/acceptance_tests/common_methods.rb new file mode 100644 index 0000000..b692da3 --- /dev/null +++ b/spec/acceptance_tests/common_methods.rb @@ -0,0 +1,66 @@ +module Common_methods +require './spec/acceptance_tests/strings.rb' + + def strings(str) + Strings[str] + end + + def visit_url(url) + Capybara.visit "http://#{url}" + end + + def expect_page_url_to_be(url) + expect(current_url).to eq("http://#{url}") + end + + def expect_error(message) + expect(page.find('#errors')).to have_content(message) + end + + def expect_no_errors + expect(page.find('#errors').text).to eq "" + end + + def expect_notification(message) + expect(page.find('#notifications')).to have_content(message) + end + + def expect_no_notifications + expect(page.find('#notifications').text).to eq "" + end + + def log_in_with(login, pass) + fill_in('E-mail', with: login) + fill_in('Password', with: pass) + click_button "Log in" + end + + def cleanup_database + Account.destroy_all +# Apartment::Tenant.drop 'MySubdomain1' rescue nil + Apartment::Tenant.reset + DatabaseCleaner.clean + end + + def create_roles + FactoryGirl.create(:account_owner_role) + FactoryGirl.create(:user_role) + FactoryGirl.create(:create_users_role) + FactoryGirl.create(:update_users_role_role) + FactoryGirl.create(:update_users_password_role) + FactoryGirl.create(:update_users_role_and_password_role) + FactoryGirl.create(:delete_users_role) + end + + def register_account(login, password, name, subdomain) + visit_url 'lvh.me:3000' + click_link (strings(:registration)) + fill_in(strings(:login), with: login) + fill_in(strings(:password), with: password) + fill_in(strings(:pass_confirm), with: password) + fill_in(strings(:name), with: name) + fill_in(strings(:subdomain), with: subdomain) + click_button(strings(:create_account_btn)) + end + +end diff --git a/spec/acceptance_tests/strings.rb b/spec/acceptance_tests/strings.rb new file mode 100644 index 0000000..95dc16c --- /dev/null +++ b/spec/acceptance_tests/strings.rb @@ -0,0 +1,39 @@ +module Strings + def self.[](key) + { + #UI elements + #start page + registration: 'Registration', + #create account page + login: 'Email', + password: 'Password', + pass_confirm: 'Confirm password', + subdomain: 'Subdomain', + name: 'Name', + create_account_btn: 'Create Account', + #themes index page + create_theme_btn: 'Create New', + status: 'Status', + actions: 'Actions', + delete: 'Delete', + open_file_btn: 'file', + upload_file_btn: 'Upload Theme', + + #Messages + # Common + authorization_error: 'You are not authorized to access this page.', + #Themes index page + theme_created: 'uploaded successfully', + theme_deleted: 'successfully deleted', + theme_blank_name_error: "Name can't be blank", + theme_file_ext_error: 'Zip file url is invalid', + + + #URLs + new_account_page: '/accounts/new', + themes_index_page: '/themes', + themes_new_page: '/themes/new', + themes_create_completed: '/themes/create_completed', + }[key] + end +end diff --git a/spec/acceptance_tests/themes_management/themes_index_page_spec.rb b/spec/acceptance_tests/themes_management/themes_index_page_spec.rb new file mode 100644 index 0000000..320c8f7 --- /dev/null +++ b/spec/acceptance_tests/themes_management/themes_index_page_spec.rb @@ -0,0 +1,143 @@ +require "rails_helper" +require './spec/acceptance_tests/common_methods.rb' + +RSpec.shared_context "visit page with permission" do + before(:each) do + User.last.update role: create(role) + visit_url url + end +end + +RSpec.feature "theme_management.start_page", :type => :feature do + let(:subdomain) { "testsubdomain" } + let(:host_url) { subdomain + ".lvh.me:3000" } + let(:themes_url) { host_url + strings(:themes_index_page) } + let(:themes_new_url) { host_url + strings(:themes_new_page) } + let(:theme) { create(:theme) } + + include Common_methods + + background do + cleanup_database + register_account('test@mail.com', 'password', 'Test Tenant', subdomain) + User.last.update role: create(:manage_themes_role) + end + + context "User without themes permissions" do + context "User without all themes permissions" do + include_context "visit page with permission" do + let(:role) { :role } + let(:url) { themes_url } + end + scenario "gets auth.error when visits themes index page" do + expect_page_url_to_be (host_url + "/") + expect_error(strings(:authorization_error)) + end + end + context "User without create themes permissions" do + include_context "visit page with permission" do + let(:role) { :delete_themes_role } + let(:url) { themes_url } + end + scenario "cannot create new theme" do + expect_page_url_to_be themes_url + expect(page).to have_no_link(strings(:create_theme_btn), href: new_theme_path) + end + end + context "User without delete themes permissions" do + before(:each) do + theme + User.last.update role: create(:create_themes_role) + visit_url themes_url + end + scenario "cannot delete theme" do + expect_page_url_to_be themes_url + expect(all('tr td')[2]).to have_no_link(strings(:delete), href: theme_path(1)) + end + end + end + + context "User with themes permissions" do + let(:url) { host_url + strings(:themes_create_completed) + param } + before(:each) do + theme + visit_url themes_url + end + context "User with all themes permissions visits themes index page" do + scenario "and page contains all fields" do + expect_page_url_to_be themes_url + + expect(page).to have_link(strings(:create_theme_btn), href: new_theme_path) + + expect(page).to have_table '' + expect(page).to have_selector 'table tr', count: 2 + expect(all('tr th')[0]).to have_text strings(:name) + expect(all('tr th')[1]).to have_text strings(:status) + expect(all('tr th')[2]).to have_text strings(:actions) + + expect(all('tr td')[0]).to have_text theme.name + expect(all('tr td')[1]).to have_text theme.status + expect(all('tr td')[2]).to have_link(strings(:delete), href: theme_path(1)) + end + end + context "User clicks Create New link and opens Create Theme page" do + scenario "and page contains all fields" do + click_link(strings(:create_theme_btn)) + expect_page_url_to_be themes_new_url + expect(page).to have_field(strings(:open_file_btn)) + expect(page).to have_button(strings(:upload_file_btn)) + end + end + context "clicks Create New link and selects theme zip file" do + let(:param) { "?key=12345_test theme.zip" } + scenario "clicks Upload Theme and successfully uploads new Theme" do + click_link(strings(:create_theme_btn)) + + # click_button(strings(:upload_file_btn)) + # Couldn't stub redirect from Amazon, just call action directly + visit_url url + + expect_page_url_to_be themes_url + expect_no_errors + expect_notification strings(:theme_created) + expect(all('tr td')[3]).to have_text Theme.last.name + expect(all('tr td')[4]).to have_text Theme.last.status + expect(all('tr td')[5]).to have_link(strings(:delete), href: theme_path(2)) + end + end + context "clicks Create New link and does not select theme file" do + let(:param) { "?key=12345_" } + scenario "clicks Upload Theme and gets an error" do + click_link(strings(:create_theme_btn)) + + visit_url url + + expect_page_url_to_be themes_new_url + expect_no_notifications + expect_error strings(:theme_blank_name_error) + end + end + context "clicks Create New link and select theme file not in ZIP format" do + let(:param) { "?key=12345_theme.exe" } + scenario "clicks Upload Theme and gets an error" do + click_link(strings(:create_theme_btn)) + + visit_url url + + expect_page_url_to_be themes_new_url + expect_no_notifications + expect_error strings(:theme_file_ext_error) + end + end + context "User with all themes permissions visits themes index page" do + scenario "clicks Delete link and successfully deletes theme" do + click_link(strings(:delete)) + + expect_page_url_to_be themes_url + expect(page).to have_selector 'table tr', count: 1 + expect_no_errors + expect_notification strings(:theme_deleted) + end + end + end +end diff --git a/spec/config/aws_spec.rb b/spec/config/aws_spec.rb new file mode 100644 index 0000000..2fb1ad2 --- /dev/null +++ b/spec/config/aws_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe Rails.application.config do + describe "has set Amazon S3 options" do + it {expect(subject.aws_max_theme_zip_file_length).to be_between(1, 5.gigabytes - 1).inclusive } + it { expect(subject.aws_access_key).to be_a String } + it { expect(subject.aws_secret_access_key).to be_a String } + it { expect(subject.aws_public_bucket_name).to be_a String } + it { expect(subject.aws_private_bucket_name).to be_a String } + it { expect(subject.aws_region).to be_a String } + end +end \ No newline at end of file diff --git a/spec/controllers/themes_controller_spec.rb b/spec/controllers/themes_controller_spec.rb new file mode 100644 index 0000000..a92dd27 --- /dev/null +++ b/spec/controllers/themes_controller_spec.rb @@ -0,0 +1,158 @@ +require 'rails_helper' + +RSpec.shared_examples "creating Theme in DB" do |key, theme_name| + before(:each) { get :create_completed, key: key } + subject(:theme) { Theme.find_by_name theme_name } + describe "Theme with name #{theme_name}" do + it { is_expected.to be } + its(:zip_file_url) { is_expected.to eq key } + it { is_expected.to be_processing } + it { expect(Theme.count).to eq(1) } + end +end + +RSpec.shared_examples "uncussessfull create_completed" do + it { expect(Theme.count).to be_zero } + it { is_expected.to redirect_to action: :new } + it "should set flash" do + subject + expect(controller).to set_flash[:alert] + end +end + +RSpec.describe ThemesController, type: :controller do + let(:account) { create :account } + let(:user_under_test) { create(:user) } + before(:each) do + Apartment::Tenant.switch! account.subdomain + user_under_test.update role: create(:manage_themes_role) + sign_in user_under_test + end + + describe "when user not logged in" do + before(:each) { sign_out user_under_test } + subject { response } + let(:params) { {} } + + { index: :get, new: :get, create_completed: :get, destroy: :delete }.each do |action, method| + describe "#{method}##{action}" do + let(:params) { {id: create(:theme).id} } if action == :destroy + before(:each) { send(method, action, params) } + it { is_expected.to have_http_status(:found) } + it { is_expected.to redirect_to(new_user_session_path) } + end + end + end + + describe "when user logged in" do + subject { response } + %w{ index new }.each do |action| + describe "GET##{action}" do + context "when user does not have permission" do + before(:each) do + case action + when 'index' then user_under_test.update role: create(:role) + when 'new' then user_under_test.role.update can_create_themes: false + end + get action + end + it { is_expected.to redirect_to(root_path) } + end + context "when user has permission" do + before(:each) do + user_under_test.update role: create(:create_themes_role) + get action + end + it { is_expected.to have_http_status(:ok) } + it { is_expected.to render_template(action) } + end + end + end + + describe "GET#create_completed (redirect from AWS)" do + let!(:theme_count){ Theme.count } + context "when user does not have permission" do + let(:key) { "1234_qwerty.zip" } + before(:each) do + user_under_test.role.update can_create_themes: false + get :create_completed, key: key + end + it { is_expected.to redirect_to(root_path) } + end + context "when user has permission" do + let(:theme_name) { "#{Faker::Internet.url}.zip" } + let(:key) {"#{SecureRandom.uuid}_#{theme_name}"} + subject { get :create_completed, key: key } + context "with valid parameters" do + {"_qwerty.zip" => "qwerty", "1234_qwerty.zip" => "qwerty", "12345_67890_qwerty.zip" => "67890_qwerty"}.each do |key, theme_name| + context "when parsing key: #{key}" do + it_behaves_like "creating Theme in DB", key, theme_name + it { is_expected.to redirect_to action: :index } + it "should set flash" do + subject + expect(controller).to set_flash[:notice] + end + end + end + context "when theme already exists" do + let(:existing_theme) { create(:theme) } + let(:key) {"#{SecureRandom.uuid}_#{existing_theme.name}.zip"} + it_behaves_like "uncussessfull create_completed" + end + end + context "when invalid parameters" do + context "when wrong file extension (not .zip)" do + let(:theme_name) { "#{Faker::Internet.url}.rar" } + subject { get :create_completed, key: key } + it_behaves_like "uncussessfull create_completed" + end + context "when theme name blank" do + let(:theme_name) { '' } + it_behaves_like "uncussessfull create_completed" + end + context "when params missing" do + it "raises exception" do + expect { get :create_completed }.to raise_error(ActionController::ParameterMissing) + end + end + end + end + end + + describe "DELETE#destroy" do + let(:theme) { create(:theme) } + let(:id) { theme.id } + context "when user does not have permission" do + before(:each) do + user_under_test.role.update can_delete_themes: false + delete :destroy, id: id + end + it { is_expected.to redirect_to(root_path) } + end + + context "when user has permission" do + context "when id wrong" do + let(:id) { theme.id+100 } + subject { delete :destroy, id: id } + it "raises exception" do + expect {subject}.to raise_error(ActiveRecord::RecordNotFound) + end + end + context "when successful" do + before(:each) do + id = theme.id + @count = Theme.count + delete :destroy, id: id + end + it "deletes theme" do + expect{theme.reload}.to raise_error(ActiveRecord::RecordNotFound) + end + it "Theme.count decreased by 1" do + expect(Theme.count).to eq(@count-1) + end + it { is_expected.to redirect_to(themes_path) } + end + end + end + end +end diff --git a/spec/factories/roles.rb b/spec/factories/roles.rb index 5ad8874..d6b56f3 100644 --- a/spec/factories/roles.rb +++ b/spec/factories/roles.rb @@ -31,6 +31,16 @@ can_update_users_role true can_delete_users true end + factory :create_themes_role do + can_create_themes true + end + factory :delete_themes_role do + can_delete_themes true + + factory :manage_themes_role do + can_create_themes true + end + end end factory :account_owner_role, parent: :manage_users_role do diff --git a/spec/factories/themes.rb b/spec/factories/themes.rb new file mode 100644 index 0000000..4a57f3d --- /dev/null +++ b/spec/factories/themes.rb @@ -0,0 +1,7 @@ +FactoryGirl.define do + factory :theme do + name { Faker::Internet.slug } + zip_file_url "#{Faker::Internet.url}.zip" + status { :processing } + end +end \ No newline at end of file diff --git a/spec/helpers/themes_helper_spec.rb b/spec/helpers/themes_helper_spec.rb new file mode 100644 index 0000000..9ebf2e3 --- /dev/null +++ b/spec/helpers/themes_helper_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe ThemesHelper, type: :helper do + describe "#theme_upload_params" do + let(:redirect_url) {"http://test.host/themes/create_completed" } + let (:policy) { + timestamp = Time.now + allow(Time).to receive(:now).and_return(timestamp) + conditions = [[:eq, :$bucket, Rails.application.config.aws_public_bucket_name], + [:eq, :$acl, :private], + [:"content-length-range", 1, Rails.application.config.aws_max_theme_zip_file_length], + [:"starts-with", :$key, "#{ThemesHelper::UPLOADS_FOLDER}"], + [:eq, :$success_action_redirect, redirect_url]] + policy = { conditions: conditions, expiration: (Time.now + 10.hours).utc.iso8601 } + policy = Base64.strict_encode64(policy.to_json) } + + subject {helper.theme_upload_params} + + it {is_expected.to be_a Hash} + + it "has form_action key with value" do + expect(subject[:form_action]).to eq "https://#{Rails.application.config.aws_public_bucket_name}.s3.amazonaws.com/" + end + it "has form_method key with value" do + expect(subject[:form_method]).to eq "post" + end + it "has form_enclosure_type key with value" do + expect(subject[:form_enclosure_type]).to eq "multipart/form-data" + end + it "has acl key with value" do + expect(subject[:acl]).to eq "private" + end + it "has access_key key with value" do + expect(subject[:access_key]).to eq Rails.application.config.aws_access_key + end + it "has redirect_url key with value" do + expect(subject[:redirect_url]).to eq redirect_url + end + it "has file_id key with value" do + uuid= SecureRandom.uuid + allow(SecureRandom).to receive(:uuid).and_return(uuid) + expect(subject[:file_id]).to eq "#{ThemesHelper::UPLOADS_FOLDER}#{SecureRandom.uuid}_${filename}" + end + it "has policy key with value" do + expect(subject[:policy]).to eq policy + end + + it "has signature key with value" do + expect(subject[:signature]).to eq Base64.strict_encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'), + Rails.application.config.aws_secret_access_key, policy)) + end + end +end \ No newline at end of file diff --git a/spec/jobs/delete_theme_job_spec.rb b/spec/jobs/delete_theme_job_spec.rb new file mode 100644 index 0000000..3ff0965 --- /dev/null +++ b/spec/jobs/delete_theme_job_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe DeleteThemeJob, type: :job do + include ActiveJob::TestHelper + let(:zip_file_url) { 'some_zip_file_url' } + let(:is_public_bucket) { true } + subject(:job) { described_class.perform_later(zip_file_url, is_public_bucket) } + + describe "when queued" do + it "queues the job" do + expect { job }.to change(ActiveJob::Base.queue_adapter.enqueued_jobs, :size).by(1) + end + it "is in low_priority queue" do + expect(DeleteThemeJob.new.queue_name).to eq('low_priority') + end + end + + describe "when executes perform" do + it "deletes theme from public bucket" do + expect(AmazonAwsClient).to receive(:delete_from_public_bucket).with(zip_file_url) + perform_enqueued_jobs { job } + end + describe "when theme in private bucket" do + let(:is_public_bucket) { false } + it "deletes theme from private bucket" do + expect(AmazonAwsClient).to receive(:delete_from_private_bucket).with(zip_file_url) + perform_enqueued_jobs { job } + end + end + it "handles request timeout error" do + allow(AmazonAwsClient).to receive(:delete_from_public_bucket).and_raise(Aws::S3::Errors::RequestTimeout.new('','')) + perform_enqueued_jobs do + expect_any_instance_of(DeleteThemeJob).to receive(:retry_job).with(wait: 5.minutes, queue: :low_priority) + job + end + end + end +end diff --git a/spec/jobs/transfer_theme_job_spec.rb b/spec/jobs/transfer_theme_job_spec.rb new file mode 100644 index 0000000..edd50fa --- /dev/null +++ b/spec/jobs/transfer_theme_job_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +RSpec.describe TransferThemeJob, type: :job do + include ActiveJob::TestHelper + + let(:theme) { create(:theme) } + + describe "when queued" do + it "queues the job" do + expect { theme }.to change(ActiveJob::Base.queue_adapter.enqueued_jobs, :size).by(1) + end + + it "is in default queue" do + expect(TransferThemeJob.new.queue_name).to eq('default') + end + end + + describe "when executes perform" do + subject(:job) { described_class.perform_later(theme) } + it "transfer theme from public to private bucket" do + expect(AmazonAwsClient).to receive(:transfer_from_public_to_private_bucket).with(theme.zip_file_url) + perform_enqueued_jobs { job } + end + it "updates theme status" do + allow(AmazonAwsClient).to receive(:transfer_from_public_to_private_bucket) + perform_enqueued_jobs { job } + expect(theme.reload.uploaded?).to be_truthy + end + it "handles request timeout error" do + allow(AmazonAwsClient).to receive(:transfer_from_public_to_private_bucket).and_raise(Aws::S3::Errors::RequestTimeout.new('','')) + perform_enqueued_jobs do + expect_any_instance_of(TransferThemeJob).to receive(:retry_job).with(wait: 5.minutes, queue: :default) + theme + end + end + end +end diff --git a/spec/models/role_spec.rb b/spec/models/role_spec.rb index a5a3d76..09bfe1c 100644 --- a/spec/models/role_spec.rb +++ b/spec/models/role_spec.rb @@ -8,7 +8,7 @@ it {expect(build(:role)).to validate_uniqueness_of(:name)} it {is_expected.to have_db_column(:name).with_options null: false, limit: Role::NAME_LIMIT_MAX } - %w{ can_create_users can_update_users_password can_update_users_role can_delete_users }.each do |column_name| + %w{ can_create_users can_update_users_password can_update_users_role can_delete_users can_create_themes can_delete_themes }.each do |column_name| it {is_expected.to have_db_column(column_name).with_options null: false, default: false } end end diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb new file mode 100644 index 0000000..0cff823 --- /dev/null +++ b/spec/models/theme_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +RSpec.describe Theme, type: :model do + let(:theme) { create :theme} + subject { theme } + + context "when validate" do + it {is_expected.to validate_presence_of :name} + it {expect(build(:theme)).to validate_uniqueness_of(:name).case_insensitive } + it {expect(build(:theme)).to validate_length_of(:name).is_at_most Theme::NAME_LIMIT } + it {is_expected.to have_db_column(:name).with_options limit: Theme::NAME_LIMIT, null: false } + + it {is_expected.to validate_presence_of :zip_file_url} + it {expect(build(:theme)).to validate_uniqueness_of(:zip_file_url).case_insensitive} + it {is_expected.to validate_length_of(:zip_file_url).is_at_most Theme::ZIP_FILE_URL_LIMIT } + it {is_expected.to have_db_column(:zip_file_url).with_options limit: Theme::ZIP_FILE_URL_LIMIT, null: false } + %w{filename.zip filename.ZIP filename.ZiP}.each do |filename| + it {is_expected.to allow_value(filename).for(:zip_file_url) } + end + %w{filename filename. filename.z filename.zi filename.abc filename.ziip}.each do |filename| + it {is_expected.to_not allow_value(filename).for(:zip_file_url) } + end + it {is_expected.to validate_presence_of :status} + it {is_expected.to have_db_column(:status).with_options null: false, default: Theme.statuses[:processing]} + end + + context "when create" do + context "when successfull" do + it "schedules transfer theme from public to private bucket" do + expect { subject }.to enqueue_a(TransferThemeJob).with(global_id(Theme)) + end + it "does not schedules deletion theme from public bucket" do + expect { subject }.to_not enqueue_a(DeleteThemeJob).with(String, TrueClass) + end + end + context "when unsuccessfull" do + let(:theme) { build :theme, zip_file_url: "wrong_url" } + it "schedules deletion theme from public bucket" do + expect { theme.save }.to enqueue_a(DeleteThemeJob).with(String, TrueClass) + end + it "schedules transfer theme from public to private bucket" do + expect { theme.save }.to_not enqueue_a(TransferThemeJob).with(global_id(Theme)) + end + end + end + + context "when destroy" do + it "schedules deletion theme from public bucket" do + expect { theme.destroy }.to enqueue_a(DeleteThemeJob).with(String, TrueClass) + end + it "schedules deletion theme from private bucket" do + theme.update status: :uploaded + expect { theme.destroy }.to enqueue_a(DeleteThemeJob).with(String, FalseClass) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5009e7a..df8eaa6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,4 +1,5 @@ require 'factory_girl' +require 'rspec/active_job' # This file was generated by the `rails generate rspec:install` command. Conventionally, all # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. @@ -19,6 +20,8 @@ # # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration RSpec.configure do |config| + config.include(RSpec::ActiveJob) + # rspec-expectations config goes here. You can use an alternate # assertion/expectation library such as wrong or the stdlib/minitest # assertions if you prefer. @@ -109,6 +112,8 @@ config.before(:each) do Account.destroy_all Apartment::Tenant.reset + ActiveJob::Base.queue_adapter.enqueued_jobs = [] + ActiveJob::Base.queue_adapter.performed_jobs = [] end config.around(:each) do |example| @@ -116,5 +121,4 @@ example.run end end - end