diff --git a/Gemfile b/Gemfile index b654c5a..93a7d35 100644 --- a/Gemfile +++ b/Gemfile @@ -20,6 +20,7 @@ gem 'hanami-validations', '0.6.0' # form validation gem 'dry-validation', '0.10.4' # validation methods for reform gem 'ability_list', '0.0.4' gem 'activesupport', '5.0.0' +gem 'sidekiq', '4.2.9' group :development, :test do gem 'awesome_print', '1.7.0' diff --git a/Gemfile.lock b/Gemfile.lock index ad129a5..5ffd997 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -19,6 +19,7 @@ GEM coercible (1.0.0) descendants_tracker (~> 0.0.1) concurrent-ruby (1.0.4) + connection_pool (2.2.1) crack (0.4.3) safe_yaml (~> 1.0.0) daemons (1.2.4) @@ -118,12 +119,15 @@ GEM rack (>= 0.4) rack-indifferent (1.1.0) rack (>= 1.5) + rack-protection (1.5.3) + rack rack-test (0.6.3) rack (>= 1.0) rake (11.2.2) rb-fsevent (0.9.8) rb-inotify (0.9.7) ffi (>= 0.5.0) + redis (3.3.3) rerun (0.11.0) listen (~> 3.0) rspec (3.5.0) @@ -142,6 +146,11 @@ GEM ruby_dep (1.5.0) safe_yaml (1.0.4) sequel (4.40.0) + sidekiq (4.2.9) + concurrent-ruby (~> 1.0) + connection_pool (~> 2.2, >= 2.2.0) + rack-protection (>= 1.5.0) + redis (~> 3.2, >= 3.2.1) slop (3.6.0) thin (1.7.0) daemons (~> 1.0, >= 1.0.9) @@ -190,6 +199,7 @@ DEPENDENCIES rerun (= 0.11.0) rspec (= 3.5.0) sequel (= 4.40.0) + sidekiq (= 4.2.9) thin (= 1.7.0) uuidtools (= 2.1.5) vcr (= 3.0.3) @@ -199,4 +209,4 @@ RUBY VERSION ruby 2.3.3p222 BUNDLED WITH - 1.13.6 + 1.14.3 diff --git a/application/api.rb b/application/api.rb index 7ddc2d9..c463d91 100644 --- a/application/api.rb +++ b/application/api.rb @@ -22,6 +22,7 @@ class Api < Grape::API; end require 'config/sequel' require 'config/hanami' require 'config/grape' +require 'config/sidekiq' # require some global libs require 'lib/core_ext' @@ -54,11 +55,9 @@ class Api < Grape::API helpers ApiResponse include Auth - before do - authenticate! - end - Dir['./application/api_entities/**/*.rb'].each { |rb| require rb } + Dir['./application/validators/**/*.rb'].each { |rb| require rb } + Dir['./application/workers/**/*.rb'].each { |rb| require rb } Dir['./application/api/**/*.rb'].each { |rb| require rb } add_swagger_documentation \ diff --git a/application/api/users.rb b/application/api/users.rb index 40de636..c499558 100644 --- a/application/api/users.rb +++ b/application/api/users.rb @@ -9,5 +9,92 @@ class Api data: users } end + + params do + group :user, type: Hash do + requires(:email, type: String, desc: "Users email address") + requires(:last_name, type: String, desc: "Users last name") + requires(:first_name, type: String, desc: "Users first name") + requires(:password, type: String, desc: "Users password") + optional(:born_on, type: DateTime, desc: "Users birth date (YYYY-MM-DD)") + end + end + + post do + user_validator = UserCreationValidator.new(params[:user]) + validation_result = user_validator.validate + if validation_result.success? + user = Api::Models::User.create(params[:user]) + WelcomeEmailWorker.perform_async(user.email) + + present :user, user, with: API::Entities::User + else + error!({ errors: validation_result.messages }, 400) + end + end + + params do + group :user, type: Hash do + optional(:email, type: String, desc: "Users email address") + optional(:last_name, type: String, desc: "Users last name") + optional(:first_name, type: String, desc: "Users first name") + optional(:born_on, type: DateTime, desc: "Users birth date (YYYY-MM-DD)") + end + end + + desc "updates a user" do + headers XAuthToken: { + description: 'Valdates your identity', + required: true + } + end + + put ':id' do + authenticate! + user = Api::Models::User.find(id: params[:id]) + unauthorized! unless current_user.can? :edit, user + + user_validator = UserUpdateValidator.new(params[:user]) + validation_result = user_validator.validate + + if validation_result.success? + user.update(params[:user]) + + present :user, user, with: API::Entities::User + else + error!({ errors: validation_result.messages }, 400) + end + end + + params do + optional(:new_password, type: String, desc: "password to set") + optional(:new_password_confirmation, type: String, desc: "confirmation of the new password") + end + + desc "Changes a user password" do + headers XAuthToken: { + description: 'Valdates your identity', + required: true + } + end + + patch ':id/reset_password' do + authenticate! + user = Api::Models::User.find(id: params[:id]) + unauthorized! unless current_user.can? :change_password, user + + password_validator = ChangePasswordValidator.new(params.symbolize_keys) + validation_result = password_validator.validate + + if validation_result.success? + user.update(password: params[:new_password]) + PasswordChangedEmailWorker.perform_async(user.email) + + present :success, true + else + error!({ errors: validation_result.messages }, 400) + end + end + end end diff --git a/application/api_entities/user.rb b/application/api_entities/user.rb new file mode 100644 index 0000000..bcfbe1e --- /dev/null +++ b/application/api_entities/user.rb @@ -0,0 +1,10 @@ +module API + module Entities + class User < Grape::Entity + expose :first_name + expose :last_name + expose :full_name + expose :born_on + end + end +end \ No newline at end of file diff --git a/application/api_helpers/auth.rb b/application/api_helpers/auth.rb index b46a936..62e9984 100644 --- a/application/api_helpers/auth.rb +++ b/application/api_helpers/auth.rb @@ -8,12 +8,20 @@ module Auth module HelperMethods def authenticate! - # Library to authenticate user can go here + token = env["X-Auth-Token"] + unauthorize! unless token.present? + + @current_user = Api::Models::User.find(token: token) + unauthorized! unless @current_user.present? end def current_user @current_user end + + def unauthorized! + error!({ error: "Unauthorized request" }, 401) + end end end end diff --git a/application/config/hanami.rb b/application/config/hanami.rb index 6d2aaf0..1611fd6 100644 --- a/application/config/hanami.rb +++ b/application/config/hanami.rb @@ -18,6 +18,7 @@ module FormPredicates predicate(:datetime_str?) do |current| # Format: YYYY-MM-DD HH:MM:SS TZ - ex: 2016-01-01 02:03:04 -0800 # Timezone is optional - current.match(/^\d{4}-\d{2}-\d{2} \d{1,2}\:\d{1,2}\:\d{1,2}( \-\d{4})?$/) + return true if current.kind_of? DateTime + current.to_s.match(/^\d{4}-\d{2}-\d{2} \d{1,2}\:\d{1,2}\:\d{1,2}( \-\d{4})?$/) end end diff --git a/application/config/mail.rb b/application/config/mail.rb new file mode 100644 index 0000000..f37fa61 --- /dev/null +++ b/application/config/mail.rb @@ -0,0 +1,5 @@ +require 'mail' + +Mail.defaults do + delivery_method :smtp, address: "localhost", port: 1025 +end \ No newline at end of file diff --git a/application/config/sidekiq.rb b/application/config/sidekiq.rb new file mode 100644 index 0000000..e711339 --- /dev/null +++ b/application/config/sidekiq.rb @@ -0,0 +1,9 @@ +require 'sidekiq' + +Sidekiq.configure_server do |config| + config.redis = { db: 1 } +end + +Sidekiq.configure_client do |config| + config.redis = { db: 1 } +end \ No newline at end of file diff --git a/application/lib/abilities.rb b/application/lib/abilities.rb index 2556055..94d4ace 100644 --- a/application/lib/abilities.rb +++ b/application/lib/abilities.rb @@ -12,6 +12,10 @@ def user_permissions(user) can :edit, Models::User do |check_user| next true if user.id == check_user.id end + + can :change_password, Models::User do |check_user| + next true if user.id == check_user.id + end end end -end +end \ No newline at end of file diff --git a/application/migrate/1487335421_add_token_to_users.rb b/application/migrate/1487335421_add_token_to_users.rb new file mode 100644 index 0000000..30ef7c2 --- /dev/null +++ b/application/migrate/1487335421_add_token_to_users.rb @@ -0,0 +1,5 @@ +Sequel.migration do + change do + add_column :users, :token, String + end +end diff --git a/application/models/user.rb b/application/models/user.rb index 802a14b..7bca1ec 100644 --- a/application/models/user.rb +++ b/application/models/user.rb @@ -5,6 +5,11 @@ module Models class User < Sequel::Model(:users) include AbilityList::Helpers + def before_create + self.token = SecureRandom.hex(18) + super + end + def abilities @abilities ||= Abilities.new(self) end diff --git a/application/spec/api/users/post_spec.rb b/application/spec/api/users/post_spec.rb new file mode 100644 index 0000000..fd56554 --- /dev/null +++ b/application/spec/api/users/post_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe 'POST /api/users' do + let(:user_attributes){ attributes_for(:user) } + + it 'should create a new user' do + expect { post "api/v1.0/users", user: user_attributes }.to change{ Api::Models::User.count }.by(1) + end + + it "should return the created user" do + post "api/v1.0/users", user: user_attributes + user = response_body[:user] + expect(user[:first_name]).to eq(user_attributes[:first_name]) + expect(user[:last_name]).to eq(user_attributes[:last_name]) + expect(user[:full_name]).to eq("#{user_attributes[:first_name]} #{user_attributes[:last_name]}") + end + + it "should return validation errors" do + user_attributes[:email] = "juanchopolo@" + post "api/v1.0/users", user: user_attributes + + errors = response_body[:errors] + expect(errors).not_to be_empty + expect(errors[:email].first).to eq "must be an email" + end + + it "should add a job to send welcome email" do + expect{ post "api/v1.0/users", user: user_attributes }.to change{ WelcomeEmailWorker.jobs.size }.by(1) + end + + it "should queue a job to send an email to the user's email" do + post "api/v1.0/users", user: user_attributes + + expect(WelcomeEmailWorker.jobs.last["args"]).to eq [user_attributes[:email]] + end +end diff --git a/application/spec/api/users/put_spec.rb b/application/spec/api/users/put_spec.rb new file mode 100644 index 0000000..eb853d0 --- /dev/null +++ b/application/spec/api/users/put_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe 'PUT /api/users/:id' do + before do + @user = create(:user, email: 'test_user@test.com', password: 'password') + end + + it "should update a user" do + put "api/v1.0/users/#{@user.id}", { user: {first_name: "Lionel", last_name: "Messi"} }, {'X-Auth-Token' => @user.token } + expect(response_body[:user][:full_name]).to eq "Lionel Messi" + expect(Api::Models::User.find(id: @user.id).full_name).to eq "Lionel Messi" + end + + it "should return validation errors" do + put "api/v1.0/users/#{@user.id}", { user: {email: "wrong_email"} }, {'X-Auth-Token' => @user.token } + + errors = response_body[:errors] + expect(errors).not_to be_empty + expect(errors[:email].first).to eq "must be an email" + end + + it "should reject a request with the wrong token" do + put "api/v1.0/users/#{@user.id}", { user: {first_name: "Lionel", last_name: "Messi"} }, {'X-Auth-Token' => '123345677' } + expect(last_response.status).to eq 401 + expect(response_body[:error]).to eq "Unauthorized request" + end + + it "should reject a request with another user's token" do + another_user = create(:user) + put "api/v1.0/users/#{@user.id}", { user: {first_name: "Lionel", last_name: "Messi"} }, {'X-Auth-Token' => another_user.token } + expect(last_response.status).to eq 401 + expect(response_body[:error]).to eq "Unauthorized request" + end +end diff --git a/application/spec/api/users/reset_password_spec.rb b/application/spec/api/users/reset_password_spec.rb new file mode 100644 index 0000000..480f483 --- /dev/null +++ b/application/spec/api/users/reset_password_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe 'PATCH /api/users/:id/reset_password' do + before do + @user = create(:user, password: '123456') + end + + it "should update a user" do + patch "api/v1.0/users/#{@user.id}/reset_password", { new_password: 'password', new_password_confirmation: 'password' }, {'X-Auth-Token' => @user.token } + expect(response_body[:success]).to eq true + expect(Api::Models::User.find(id: @user.id).password).to eq "password" + end + + it "should return validation errors" do + patch "api/v1.0/users/#{@user.id}/reset_password", { new_password: 'password', new_password_confirmation: 'different' }, {'X-Auth-Token' => @user.token } + + errors = response_body[:errors] + expect(errors).not_to be_empty + expect(errors[:new_password_confirmation].first).to eq "must be equal to password" + end + + it "should reject a request with the wrong token" do + patch "api/v1.0/users/#{@user.id}/reset_password", { new_password: 'password', new_password_confirmation: 'password' }, {'X-Auth-Token' => '123345677' } + expect(response_body[:error]).to eq "Unauthorized request" + end + + it "should reject a request with another user's token" do + another_user = create(:user) + patch "api/v1.0/users/#{@user.id}/reset_password", { new_password: 'password', new_password_confirmation: 'password' }, {'X-Auth-Token' => another_user.token } + expect(last_response.status).to eq 401 + expect(response_body[:error]).to eq "Unauthorized request" + end + + it "should add a job to send welcome email" do + expect{ patch "api/v1.0/users/#{@user.id}/reset_password", { new_password: 'password', new_password_confirmation: 'password' }, {'X-Auth-Token' => @user.token } }.to change{ PasswordChangedEmailWorker.jobs.size }.by(1) + end + + it "should queue a job to send an email to the user's email" do + patch "api/v1.0/users/#{@user.id}/reset_password", { new_password: 'password', new_password_confirmation: 'password' }, {'X-Auth-Token' => @user.token } + + expect(PasswordChangedEmailWorker.jobs.last["args"]).to eq [@user.email] + end +end diff --git a/application/spec/spec_helper.rb b/application/spec/spec_helper.rb index 4b54421..87df03c 100644 --- a/application/spec/spec_helper.rb +++ b/application/spec/spec_helper.rb @@ -4,6 +4,7 @@ require './application/api' require 'faker' require 'factory_girl' +require 'sidekiq/testing' # Load up all application files that we'll be testing in the suites Dir['./application/models/**/*.rb'].sort.each { |rb| require rb } @@ -41,18 +42,6 @@ def get_scope opts = {} end end -class Api - helpers do - def current_user - begin - @current_user = Api.class_variable_get(:@@current_user) - rescue - nil - end - end - end -end - SEQUEL_DB = Api::SEQUEL_DB # Clear old test data SEQUEL_DB.tables.each do |t| @@ -82,4 +71,8 @@ def current_user example.run end end + + config.before(:each) do + Sidekiq::Testing.fake! + end end diff --git a/application/spec/workers/password_changed_email_worker_spec.rb b/application/spec/workers/password_changed_email_worker_spec.rb new file mode 100644 index 0000000..a5755a7 --- /dev/null +++ b/application/spec/workers/password_changed_email_worker_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe PasswordChangedEmailWorker do + before do + Mail.defaults do + delivery_method :test + end + Sidekiq::Testing.inline! + end + + it "should send an email" do + PasswordChangedEmailWorker.perform_async("juancho@test.com") + mail = Mail::TestMailer.deliveries.last + expect(mail.to).to eq ["juancho@test.com"] + expect(mail.subject).to eq "password changed" + expect(mail.body.to_s).to eq "Your password has been changed" + end +end \ No newline at end of file diff --git a/application/spec/workers/welcome_email_worker_spec.rb b/application/spec/workers/welcome_email_worker_spec.rb new file mode 100644 index 0000000..c2ad257 --- /dev/null +++ b/application/spec/workers/welcome_email_worker_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe WelcomeEmailWorker do + before do + Mail.defaults do + delivery_method :test + end + Sidekiq::Testing.inline! + end + + it "should send an email" do + WelcomeEmailWorker.perform_async("juancho@test.com") + mail = Mail::TestMailer.deliveries.last + expect(mail.to).to eq ["juancho@test.com"] + expect(mail.subject).to eq "Welcome to our app" + expect(mail.body.to_s).to eq "Welcome to this app" + end +end \ No newline at end of file diff --git a/application/validators/change_password_validator.rb b/application/validators/change_password_validator.rb new file mode 100644 index 0000000..9ee727e --- /dev/null +++ b/application/validators/change_password_validator.rb @@ -0,0 +1,8 @@ +class ChangePasswordValidator + include Hanami::Validations + predicates FormPredicates + + validations do + required(:new_password).filled.confirmation + end +end \ No newline at end of file diff --git a/application/validators/user_creation_validator.rb b/application/validators/user_creation_validator.rb new file mode 100644 index 0000000..963eb53 --- /dev/null +++ b/application/validators/user_creation_validator.rb @@ -0,0 +1,12 @@ +class UserCreationValidator + include Hanami::Validations + predicates FormPredicates + + validations do + required("first_name"){ str? } + required("first_name"){ str? } + required("password"){ str? } + required("email"){ email? } + optional("born_on"){ datetime_str? } + end +end \ No newline at end of file diff --git a/application/validators/user_update_validator.rb b/application/validators/user_update_validator.rb new file mode 100644 index 0000000..6f5f2b6 --- /dev/null +++ b/application/validators/user_update_validator.rb @@ -0,0 +1,11 @@ +class UserUpdateValidator + include Hanami::Validations + predicates FormPredicates + + validations do + optional("first_name"){ str? } + optional("last_name"){ str? } + optional("born_on"){ datetime_str? } + optional("email"){ email? } + end +end \ No newline at end of file diff --git a/application/workers/password_changed_email_worker.rb b/application/workers/password_changed_email_worker.rb new file mode 100644 index 0000000..15f1ffb --- /dev/null +++ b/application/workers/password_changed_email_worker.rb @@ -0,0 +1,22 @@ +require 'sidekiq' +require_relative '../config/mail.rb' + +Sidekiq.configure_server do |config| + config.redis = { db: 1 } +end + +Sidekiq.configure_client do |config| + config.redis = { db: 1 } +end + +class PasswordChangedEmailWorker + include Sidekiq::Worker + def perform(email) + Mail.deliver do + from 'no-reply@testapp.com' + to email + subject 'password changed' + body "Your password has been changed" + end + end +end \ No newline at end of file diff --git a/application/workers/welcome_email_worker.rb b/application/workers/welcome_email_worker.rb new file mode 100644 index 0000000..657843d --- /dev/null +++ b/application/workers/welcome_email_worker.rb @@ -0,0 +1,22 @@ +require 'sidekiq' +require_relative '../config/mail.rb' + +Sidekiq.configure_server do |config| + config.redis = { db: 1 } +end + +Sidekiq.configure_client do |config| + config.redis = { db: 1 } +end + +class WelcomeEmailWorker + include Sidekiq::Worker + def perform(email) + Mail.deliver do + from 'no-reply@testapp.com' + to email + subject 'Welcome to our app' + body "Welcome to this app" + end + end +end \ No newline at end of file