diff --git a/.env.development.sample b/.env.development.sample index 7864698..0b2a1ec 100644 --- a/.env.development.sample +++ b/.env.development.sample @@ -3,3 +3,5 @@ DATABASE_URL=mysql2://root:@127.0.0.1:3306/sample?reconnect=true MAIL_URL=smtp://127.0.0.1:1025 SYSTEM_EMAIL=support@sample.com SITE_URL=http://localhost:3000/ +HMAC_SECRET=ZxW8go9hz3ETCSfxFxpwSkYg_602gOPKearsf6DsxgY +REDIS_URL=redis://localhost:6379 diff --git a/.env.test.sample b/.env.test.sample index 67326c8..b1f614e 100644 --- a/.env.test.sample +++ b/.env.test.sample @@ -3,3 +3,5 @@ DATABASE_URL=mysql2://root:@127.0.0.1:3306/sample_test?reconnect=true MAIL_URL=smtp://127.0.0.1:1025 SYSTEM_EMAIL=support@sample.com SITE_URL=http://localhost:3000/ +HMAC_SECRET=ZxW8go9hz3ETCSfxFxpwSkYg_602gOPKearsf6DsxgY +REDIS_URL=redis://localhost:6379 diff --git a/Gemfile b/Gemfile index b654c5a..80ad15c 100644 --- a/Gemfile +++ b/Gemfile @@ -20,10 +20,15 @@ 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 'bcrypt', '3.1.11' # encryption +gem 'jwt', '1.5.6' # Json Web Token +gem 'sidekiq', '4.2.9' # handle background jobs +gem 'redis', '3.3.3' group :development, :test do gem 'awesome_print', '1.7.0' gem 'pry', '0.10.4' + gem 'letter_opener', '1.4.1' end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index ad129a5..df1ab75 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,11 +14,13 @@ GEM descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) + bcrypt (3.1.11) builder (3.2.2) coderay (1.1.1) 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) @@ -90,6 +92,11 @@ GEM i18n (0.7.0) ice_nine (0.11.2) inflecto (0.0.2) + jwt (1.5.6) + launchy (2.4.3) + addressable (~> 2.3) + letter_opener (1.4.1) + launchy (~> 2.2) listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) @@ -118,12 +125,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 +152,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) @@ -170,6 +185,7 @@ DEPENDENCIES ability_list (= 0.0.4) activesupport (= 5.0.0) awesome_print (= 1.7.0) + bcrypt (= 3.1.11) database_cleaner (= 1.5.3) dry-validation (= 0.10.4) factory_girl (= 4.7.0) @@ -180,6 +196,8 @@ DEPENDENCIES grape-swagger (= 0.25.1) grape-swagger-entity (= 0.1.5) hanami-validations (= 0.6.0) + jwt (= 1.5.6) + letter_opener (= 1.4.1) mail (= 2.6.4) mysql2 (= 0.4.5) pry (= 0.10.4) @@ -187,9 +205,11 @@ DEPENDENCIES rack-indifferent (= 1.1) rack-test (= 0.6.3) rake (= 11.2.2) + redis (= 3.3.3) 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 +219,4 @@ RUBY VERSION ruby 2.3.3p222 BUNDLED WITH - 1.13.6 + 1.14.5 diff --git a/Makefile b/Makefile index 3f7042b..7e24daf 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,9 @@ install: run: bundle exec rerun -b --pattern '{Gemfile,Gemfile.lock,.gems,.bundle,.env*,config.ru,**/*.{rb,ru,yml}}' -- thin start --port=3000 --threaded +worker: + bundle exec sidekiq -C ./application/config/yaml/sidekiq.yml + console: bundle exec pry -r ./application/api diff --git a/application/api.rb b/application/api.rb index 7ddc2d9..7bcfa5b 100644 --- a/application/api.rb +++ b/application/api.rb @@ -13,6 +13,7 @@ require 'bundler' Bundler.setup :default, RACK_ENV require 'rack/indifferent' +require 'sidekiq' require 'grape' require 'grape/batch' # Initialize the application so we can add all our components to it @@ -21,20 +22,34 @@ class Api < Grape::API; end # Include all config files require 'config/sequel' require 'config/hanami' +require 'config/mail' require 'config/grape' # require some global libs require 'lib/core_ext' require 'lib/time_formats' require 'lib/io' +require 'lib/pretty_logger' # load active support helpers require 'active_support' require 'active_support/core_ext' +# require authentication libs +require 'jwt' +require 'bcrypt' + # require all models Dir['./application/models/*.rb'].each { |rb| require rb } +# require all validators +Dir['./application/api_validators/**/*.rb'].each { |rb| require rb } + +# require all mailers +require './application/mailers/mailer_base.rb' +Dir['./application/mailers/**/*.rb'].each { |rb| require rb } + + Dir['./application/api_helpers/**/*.rb'].each { |rb| require rb } class Api < Grape::API version 'v1.0', using: :path @@ -50,14 +65,13 @@ class Api < Grape::API error! ret, 400 end + logger PrettyLogger.new + helpers SharedParams helpers ApiResponse + helpers ApiFormat include Auth - before do - authenticate! - end - Dir['./application/api_entities/**/*.rb'].each { |rb| require rb } Dir['./application/api/**/*.rb'].each { |rb| require rb } diff --git a/application/api/users.rb b/application/api/users.rb index 40de636..d207a6b 100644 --- a/application/api/users.rb +++ b/application/api/users.rb @@ -1,13 +1,106 @@ class Api resource :users do + params do includes :basic_search end + get do users = SEQUEL_DB[:users].all - { - data: users - } + present users, with: Api::Entities::User end + + desc "Creates a user" + params do + requires :first_name, type: String, desc: "User's first name" + requires :last_name, type: String, desc: "User's last name" + requires :email, type: String, desc: "User's email name" + requires :password, type: String, desc: "User's password" + optional :date_of_birth, type: String, desc: "User's date of birth / Format: YYYY:MM:DD HH:MM:SS TZ" + end + + post do + result = Api::Validators::CreateUser.new(declared(params)).validate + error!({ errors: result.messages } , 422) unless result.success? + + user = Api::Models::User.new(result.output) + user.save + + Api::WelcomeUserMailer.perform_async(user.id) + + present user, with: Api::Entities::User + end + + desc "Updates a user" + params do + optional :first_name, type: String, desc: "User's first name" + optional :last_name, type: String, desc: "User's last name" + optional :email, type: String, desc: "User's email name" + optional :date_of_birth, type: String, desc: "User's date of birth / Format: YYYY:MM:DD HH:MM:SS TZ" + end + + put '/:id' do + authenticate! + + result = Api::Validators::UpdateUser.new(declared(params)).validate + error!({ errors: result.messages } , 422) unless result.success? + + unless user = Api::Models::User.where(id: params[:id]).first + return api_response({error_type: :not_found}) + end + + unless current_user.can?(:edit, user) + return api_response({error_type: :forbidden}) + end + + user.update(result.output) + + present user, with: Api::Entities::User + end + + desc "Resets a user's password" + params do + requires :new_password, type: String, desc: "New password" + requires :confirm_password, type: String, desc: "Password confirmation" + end + + patch '/:id/reset_password' do + result = Api::Validators::ResetUserPassword.new(declared(params)).validate + error!({ errors: result.messages } , 422) unless result.success? + + unless user = Api::Models::User.where(id: params[:id]).first + return api_response({error_type: :not_found}) + end + + user.password = result.output['new_password'] + user.save + + Api::ResetPasswordMailer.perform_async(user.id) + + api_response({status: :ok}) + end + + desc "Signin a user" + params do + requires :email, type: String, desc: "User's email" + requires :password, type: String, desc: "User's password" + end + + post '/signin' do + result = Api::Validators::UserSignin.new(declared(params)).validate + error!({ errors: result.messages } , 422) unless result.success? + + unless user = Api::Models::User.where(email: result.output['email']).first + return api_response({error_type: :unauthorized}) + end + + unless user.password == result.output['password'] + return api_response({error_type: :unauthorized}) + end + + token = generate_token_for_user(user) + api_response({token: token}) + end + end end diff --git a/application/api_entities/user.rb b/application/api_entities/user.rb new file mode 100644 index 0000000..07a570d --- /dev/null +++ b/application/api_entities/user.rb @@ -0,0 +1,15 @@ +class Api + module Entities + class User < Grape::Entity + root 'users', 'user' + + expose :id + expose :full_name + expose :first_name + expose :last_name + expose :email + expose :date_of_birth, format_with: :datetime_string + + end + end +end diff --git a/application/api_helpers/api_format.rb b/application/api_helpers/api_format.rb new file mode 100644 index 0000000..c0983f8 --- /dev/null +++ b/application/api_helpers/api_format.rb @@ -0,0 +1,10 @@ +class Api + module ApiFormat + extend Grape::API::Helpers + + Grape::Entity.format_with :datetime_string do |date| + date.to_time.to_s if date + end + + end +end diff --git a/application/api_helpers/api_response.rb b/application/api_helpers/api_response.rb index 4613eca..fd87a43 100644 --- a/application/api_helpers/api_response.rb +++ b/application/api_helpers/api_response.rb @@ -9,6 +9,8 @@ def api_response response status 404 when :forbidden status 403 + when :unauthorized + status 401 else status 400 end diff --git a/application/api_helpers/auth.rb b/application/api_helpers/auth.rb index b46a936..dd2553b 100644 --- a/application/api_helpers/auth.rb +++ b/application/api_helpers/auth.rb @@ -8,12 +8,25 @@ module Auth module HelperMethods def authenticate! - # Library to authenticate user can go here + token = request.env['HTTP_AUTHORIZATION'] + payload = JWT.decode(token, HMAC_SECRET)[0] + if @current_user = ::Api::Models::User.where(email: payload['email']).first + true + else + error!({error_type: 'not_authorized'}, 401) + end + rescue JWT::DecodeError, JWT::VerificationError + error!({error_type: 'not_authorized'}, 401) end def current_user @current_user end + + def generate_token_for_user(user) + payload = { email: user.email } + JWT.encode(payload, HMAC_SECRET) + end end end end diff --git a/application/api_validators/create_user.rb b/application/api_validators/create_user.rb new file mode 100644 index 0000000..d51697e --- /dev/null +++ b/application/api_validators/create_user.rb @@ -0,0 +1,16 @@ +class Api + module Validators + class CreateUser + include Hanami::Validations + predicates FormPredicates + + validations do + required('first_name') { filled? & str? } + required('last_name') { filled? & str? } + required('email') { email? } + required('password') { filled? & str? } + optional('date_of_birth') { datetime_str? } + end + end + end +end diff --git a/application/api_validators/reset_user_password.rb b/application/api_validators/reset_user_password.rb new file mode 100644 index 0000000..eb38c48 --- /dev/null +++ b/application/api_validators/reset_user_password.rb @@ -0,0 +1,19 @@ +class Api + module Validators + class ResetUserPassword + + include Hanami::Validations + predicates FormPredicates + + validations do + required('new_password').filled(:str?) + required('confirm_password').filled(:str?) + + rule(confirm_password: ['new_password', 'confirm_password']) do |new_password, confirm_password| + new_password.eql?(confirm_password) + end + end + + end + end +end diff --git a/application/api_validators/update_user.rb b/application/api_validators/update_user.rb new file mode 100644 index 0000000..e988719 --- /dev/null +++ b/application/api_validators/update_user.rb @@ -0,0 +1,15 @@ +class Api + module Validators + class UpdateUser + include Hanami::Validations + predicates FormPredicates + + validations do + optional('first_name') { filled? & str? } + optional('last_name') { filled? & str? } + optional('email') { email? } + optional('date_of_birth') { datetime_str? } + end + end + end +end diff --git a/application/api_validators/user_signin.rb b/application/api_validators/user_signin.rb new file mode 100644 index 0000000..28e5c09 --- /dev/null +++ b/application/api_validators/user_signin.rb @@ -0,0 +1,13 @@ +class Api + module Validators + class UserSignin + include Hanami::Validations + predicates FormPredicates + + validations do + required('email') { email? } + required('password') { filled? & str? } + end + end + end +end diff --git a/application/config/mail.rb b/application/config/mail.rb new file mode 100644 index 0000000..bfc9490 --- /dev/null +++ b/application/config/mail.rb @@ -0,0 +1,11 @@ +require 'mail' + +Mail.defaults do + if %w(development test).include?(RACK_ENV) + require 'letter_opener' + + delivery_method LetterOpener::DeliveryMethod, :location => File.expand_path('../../../tmp/letter_opener', __FILE__) + else + delivery_method :sendmail + end +end diff --git a/application/config/variables.rb b/application/config/variables.rb index 4916a70..cec202e 100644 --- a/application/config/variables.rb +++ b/application/config/variables.rb @@ -23,3 +23,4 @@ MAIL_URL = ENV.fetch('MAIL_URL').freeze SYSTEM_EMAIL = ENV.fetch('SYSTEM_EMAIL').freeze SITE_URL = ENV.fetch('SITE_URL').freeze +HMAC_SECRET = ENV.fetch('HMAC_SECRET').freeze diff --git a/application/config/yaml/errors.yml b/application/config/yaml/errors.yml index 4128a88..92ea01e 100644 --- a/application/config/yaml/errors.yml +++ b/application/config/yaml/errors.yml @@ -3,3 +3,4 @@ en: email?: must be an email datetime_str?: must be in format YYYY-MM-DD HH:MM:SS phone?: must be valid format (ex - 8005556262) + confirm_password: doesn't match diff --git a/application/config/yaml/sidekiq.yml b/application/config/yaml/sidekiq.yml index 071bd80..f233d4b 100644 --- a/application/config/yaml/sidekiq.yml +++ b/application/config/yaml/sidekiq.yml @@ -2,6 +2,7 @@ :queues: - [priority, 7] - [default, 5] + - [mailers, 4] - [seldom, 3] staging: :concurrency: 10 diff --git a/application/mailers/mailer_base.rb b/application/mailers/mailer_base.rb new file mode 100644 index 0000000..e14a9a8 --- /dev/null +++ b/application/mailers/mailer_base.rb @@ -0,0 +1,54 @@ +class Api::MailerBase + include ERB::Util + + include Sidekiq::Worker + sidekiq_options queue: 'mailers' + + def mail(from:, to:, subject:) + mailer = Mail.new do + to to + from from + subject subject + end + + if template?(:text) + mailer.text_part = Mail::Part.new + mailer.text_part.body = render_template(:text) + end + + if template?(:html) + mailer.html_part = Mail::Part.new + mailer.html_part.content_type = 'text/html; charset=UTF-8' + mailer.html_part.body = render_template(:html) + end + + mailer.deliver + end + + def template_name + raise NotImplementedError + end + + protected + + def template?(extension) + template_exists?(template_path(extension)) + end + + def render_template(extension) + render(File.read(template_path(extension))) + end + + def template_path(extension) + File.expand_path("../templates/#{template_name}.#{extension}.erb", __FILE__) + end + + def template_exists?(path) + File.exists?(path) + end + + def render(template) + ERB.new(template).result(binding) + end + +end diff --git a/application/mailers/reset_password_mailer.rb b/application/mailers/reset_password_mailer.rb new file mode 100644 index 0000000..85ebc40 --- /dev/null +++ b/application/mailers/reset_password_mailer.rb @@ -0,0 +1,17 @@ +class Api::ResetPasswordMailer < Api::MailerBase + + def perform(user_id) + @user = Api::Models::User.where(id: user_id).first + + mail( + to: @user.email, + from: 'no-reply@sample.com', + subject: 'Your password was reset!' + ) + end + + def template_name + 'reset_password' + end + +end diff --git a/application/mailers/templates/reset_password.html.erb b/application/mailers/templates/reset_password.html.erb new file mode 100644 index 0000000..6a58f74 --- /dev/null +++ b/application/mailers/templates/reset_password.html.erb @@ -0,0 +1,7 @@ +

Password Reset Confirmation

+ +

+ Hi <%= @user.full_name %>, + + Your password was reset. +

diff --git a/application/mailers/templates/reset_password.text.erb b/application/mailers/templates/reset_password.text.erb new file mode 100644 index 0000000..5520052 --- /dev/null +++ b/application/mailers/templates/reset_password.text.erb @@ -0,0 +1,5 @@ +Password Reset Confirmation + +Hi <%= @user.full_name %>, + +Your password was reset. diff --git a/application/mailers/templates/welcome_user.html.erb b/application/mailers/templates/welcome_user.html.erb new file mode 100644 index 0000000..5288a72 --- /dev/null +++ b/application/mailers/templates/welcome_user.html.erb @@ -0,0 +1 @@ +

Welcome <%= @user.full_name %>!

diff --git a/application/mailers/templates/welcome_user.text.erb b/application/mailers/templates/welcome_user.text.erb new file mode 100644 index 0000000..8e0c013 --- /dev/null +++ b/application/mailers/templates/welcome_user.text.erb @@ -0,0 +1 @@ +Welcome <%= @user.full_name %>! diff --git a/application/mailers/welcome_user_mailer.rb b/application/mailers/welcome_user_mailer.rb new file mode 100644 index 0000000..d766445 --- /dev/null +++ b/application/mailers/welcome_user_mailer.rb @@ -0,0 +1,17 @@ +class Api::WelcomeUserMailer < Api::MailerBase + + def perform(user_id) + @user = Api::Models::User.where(id: user_id).first + + mail( + to: @user.email, + from: 'no-reply@sample.com', + subject: 'Welcome!' + ) + end + + def template_name + 'welcome_user' + end + +end diff --git a/application/migrate/1488562598_rename_user_columns.rb b/application/migrate/1488562598_rename_user_columns.rb new file mode 100644 index 0000000..a49e50e --- /dev/null +++ b/application/migrate/1488562598_rename_user_columns.rb @@ -0,0 +1,6 @@ +Sequel.migration do + change do + rename_column :users, :password, :encrypted_password + rename_column :users, :born_on, :date_of_birth + end +end diff --git a/application/models/user.rb b/application/models/user.rb index 802a14b..2973bbc 100644 --- a/application/models/user.rb +++ b/application/models/user.rb @@ -12,6 +12,19 @@ def abilities def full_name "#{self.first_name} #{self.last_name}" end + + def password + return if self.encrypted_password.nil? + + @password ||= BCrypt::Password.new(self.encrypted_password) + end + + def password=(new_password) + return if new_password.nil? + + @password = BCrypt::Password.create(new_password) + self.encrypted_password = @password + end end end end diff --git a/application/spec/api/models/user_spec.rb b/application/spec/api/models/user_spec.rb new file mode 100644 index 0000000..7697b77 --- /dev/null +++ b/application/spec/api/models/user_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe Api::Models::User do + + describe "#password" do + + let(:user) { create(:user, password: 'test123') } + + it "should set encrypted_password" do + expect(user.encrypted_password).to be_present + expect(user.encrypted_password).not_to eq('test123') + end + + it "should delegate comparison to BCrypt library" do + expect(user.password.class).to eq(BCrypt::Password) + expect(user.password).to eq('test123') + end + + end + + +end diff --git a/application/spec/api/users/get_spec.rb b/application/spec/api/users/get_spec.rb index 95899aa..56edaa4 100644 --- a/application/spec/api/users/get_spec.rb +++ b/application/spec/api/users/get_spec.rb @@ -9,7 +9,7 @@ it 'should pull all users' do get "api/v1.0/users" body = response_body - emails = body[:data].map{ |x| x[:email] } + emails = body[:users].map{ |x| x[:email] } expect(emails).to include @u1.email expect(emails).to include @u2.email end diff --git a/application/spec/api/users/post_signin_spec.rb b/application/spec/api/users/post_signin_spec.rb new file mode 100644 index 0000000..f243154 --- /dev/null +++ b/application/spec/api/users/post_signin_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +describe 'POST /api/users/signin' do + + let(:user) { create(:user, password: 'mypass123') } + + context "with valid params" do + + let(:credentials) { { email: user.email, password: 'mypass123' } } + + it "should deliver user's token" do + post "api/v1.0/users/signin", credentials + expect(response_status).to eq(200) + expect(response_body[:token]).to be_present + end + + end + + context "with invalid params" do + + let(:user_email) { user.email } + let(:user_password) { 'mypass123' } + let(:bad_credentials) { { email:user_email, password: user_password } } + + context "wrong password" do + + let(:user_password) { 'anotherpass' } + + it 'should return unauthorized request' do + post "api/v1.0/users/signin", bad_credentials + expect(response_status).to eq(401) + expect(response_body[:error_type]).to eq('unauthorized') + end + + end + + context "wrong email" do + + let(:user_email) { 'another@email.com' } + + it 'should return unauthorized request' do + post "api/v1.0/users/signin", bad_credentials + expect(response_status).to eq(401) + expect(response_body[:error_type]).to eq('unauthorized') + end + + end + + end +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..52e9680 --- /dev/null +++ b/application/spec/api/users/post_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe 'POST /api/users' do + + context "with valid params" do + + let(:user_params) { FactoryGirl.attributes_for(:user) } + + it do + expect { + post "api/v1.0/users", user_params + }.to change{ Api::Models::User.count }.by(1) + end + + it 'should create the user' do + post "api/v1.0/users", user_params + user = response_body[:user] + expect(user[:id]).to be_present + expect(user[:email]).to eq(user_params[:email]) + end + + it 'should enqueue welcome user mailer' do + expect { + post "api/v1.0/users", user_params + }.to change(Api::WelcomeUserMailer.jobs, :size).by(1) + end + + end + + context "with invalid params" do + + let(:user_params) { { } } + + it do + expect { + post "api/v1.0/users", user_params + }.not_to change{ Api::Models::User.count } + end + + it 'should not create the user' do + post "api/v1.0/users", user_params + errors = response_body[:errors] + expect(errors).to be_present + expect(errors[:first_name]).to be_present + end + + it 'should not enqueue welcome user mailer' do + expect { + post "api/v1.0/users", user_params + }.to change(Api::WelcomeUserMailer.jobs, :size).by(0) + end + + 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..438b6d4 --- /dev/null +++ b/application/spec/api/users/put_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe 'PUT /api/users/:id', :type => :request do + + let(:user) { create(:user) } + let(:user_id) { user.id } + let(:params) { FactoryGirl.attributes_for(:user, except: [:password]) } + + context "Protected endpoint" do + it 'doesnt allow unauthorized requests' do + put "api/v1.0/users/#{user_id}", params + expect(response_body[:error_type]).to eq('not_authorized') + end + end + + context "with valid params" do + + let(:token) do + payload = { email: user.email } + JWT.encode(payload, HMAC_SECRET) + end + + let(:headers) { { 'HTTP_AUTHORIZATION' => token} } + + it 'should update the user' do + put "api/v1.0/users/#{user_id}", params, headers + user = response_body[:user] + expect(user[:id]).to eq(user_id) + expect(user[:first_name]).to eq(params[:first_name]) + expect(user[:last_name]).to eq(params[:last_name]) + expect(user[:email]).to eq(params[:email]) + expect(user[:date_of_birth]).to eq(params[:date_of_birth]) + end + + end + + context "update another user" do + + let(:another_user) { create(:user) } + let(:user_id) { another_user.id } + + let(:token) do + payload = { email: user.email } + JWT.encode(payload, HMAC_SECRET) + end + + let(:headers) { { 'HTTP_AUTHORIZATION' => token} } + + it 'should NOT update another user' do + put "api/v1.0/users/#{user_id}", params, headers + expect(response_body[:error_type]).to eq('forbidden') + end + + 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..2f2ca71 --- /dev/null +++ b/application/spec/api/users/reset_password_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe 'PATCH /api/users/:id/reset_password' do + + let!(:user) { create(:user, password: 'oldpass') } + let(:user_id) { user.id } + + context "with valid params" do + + let(:params) { { new_password: 'new_pass123', confirm_password: 'new_pass123'} } + + it "should reset password" do + patch "api/v1.0/users/#{user_id}/reset_password", params + + user = Api::Models::User.where(id: user_id).first + + expect(response_status).to eq(200) + expect(response_body[:errors]).not_to be_present + expect(user.reload.password).not_to eq('oldpass') + expect(user.password).to eq(params[:new_password]) + end + + it 'should enqueue reset password mailer' do + expect { + patch "api/v1.0/users/#{user_id}/reset_password", params + }.to change(Api::ResetPasswordMailer.jobs, :size).by(1) + end + + end + + context "with invalid params" do + + let(:params) { { new_password: 'notmatch', confirm_password: 'new_pass123'} } + + it "should return an error" do + patch "api/v1.0/users/#{user.id}/reset_password", params + + errors = response_body[:errors] + expect(response_status).to eq(422) + expect(errors).to be_present + expect(errors[:confirm_password]).to be_present + expect(user.password).to eq('oldpass') + end + + it 'should not enqueue reset password mailer' do + expect { + patch "api/v1.0/users/#{user_id}/reset_password", params + }.to change(Api::ResetPasswordMailer.jobs, :size).by(0) + end + + end + + +end diff --git a/application/spec/factories/user.rb b/application/spec/factories/user.rb index 199c292..b02e6aa 100644 --- a/application/spec/factories/user.rb +++ b/application/spec/factories/user.rb @@ -3,7 +3,7 @@ first_name { Faker::Name.first_name } last_name { Faker::Name.last_name } email { Faker::Internet.email } - password Digest::MD5.hexdigest 'test' - born_on Date.new(2000, 1, 1) + password 'test' + date_of_birth Date.new(2000, 1, 1).to_time.to_s end end diff --git a/application/spec/mailers/reset_password_mailer_spec.rb b/application/spec/mailers/reset_password_mailer_spec.rb new file mode 100644 index 0000000..ff331d6 --- /dev/null +++ b/application/spec/mailers/reset_password_mailer_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +RSpec.describe Api::ResetPasswordMailer do + + before { Sidekiq::Testing.inline! } + after { Sidekiq::Testing.fake! } + + let!(:user) { create(:user) } + + before do + described_class.perform_async(user.id) + end + + it 'should send the email properly' do + expect(last_mail).to have_sent_email.to(user.email).matching_subject(/password/i) + end + + it "should send html part" do + expect(last_mail.html_part).to be_present + end + + it "should send text part" do + expect(last_mail.text_part).to be_present + end + +end diff --git a/application/spec/mailers/welcome_user_mailer_spec.rb b/application/spec/mailers/welcome_user_mailer_spec.rb new file mode 100644 index 0000000..9e65fa4 --- /dev/null +++ b/application/spec/mailers/welcome_user_mailer_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +RSpec.describe Api::WelcomeUserMailer do + + before { Sidekiq::Testing.inline! } + after { Sidekiq::Testing.fake! } + + let!(:user) { create(:user) } + + before do + described_class.perform_async(user.id) + end + + it 'should send the email properly' do + expect(last_mail).to have_sent_email.to(user.email).matching_subject(/welcome/i) + end + + it "should send html part" do + expect(last_mail.html_part).to be_present + end + + it "should send text part" do + expect(last_mail.text_part).to be_present + end + +end diff --git a/application/spec/spec_helper.rb b/application/spec/spec_helper.rb index 4b54421..66b86a2 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 } @@ -11,6 +12,12 @@ FactoryGirl.definition_file_paths = %w{./application/spec/factories} FactoryGirl.find_definitions +Sidekiq::Testing.fake! + +Mail.defaults do + delivery_method :test +end + # Factory Girl is expecting ActiveRecord class Sequel::Model alias_method :save!, :save @@ -34,11 +41,19 @@ def response_body JSON.parse(last_response.body, symbolize_names: true) end + def response_status + last_response.status + end + def get_scope opts = {} scope = Api.new scope.instance_variable_set(:@current_user, opts[:as_user]) scope end + + def last_mail + Mail::TestMailer.deliveries.last + end end class Api @@ -49,6 +64,7 @@ def current_user rescue nil end + super end end end @@ -66,6 +82,7 @@ def current_user config.extend RSpecHelpers config.include RSpecHelpers config.include FactoryGirl::Syntax::Methods + config.include Mail::Matchers config.filter_run_excluding :slow config.color = true config.tty = true @@ -82,4 +99,9 @@ def current_user example.run end end + + config.before(:each) do + Mail::TestMailer.deliveries.clear + Sidekiq::Worker.clear_all + end end