From f684a6aa378efff970c5be33aca51c63655204da Mon Sep 17 00:00:00 2001 From: Pablo Herrero Date: Sat, 7 Jan 2017 08:16:02 -0300 Subject: [PATCH 1/3] Add user admin features --- .env.development.sample | 1 + .env.test.sample | 1 + .rspec | 3 + Gemfile | 8 +- Gemfile.lock | 38 +++++---- application/api.rb | 32 +++++++- application/api/auth.rb | 29 +++++++ application/api/users.rb | 69 +++++++++++++++- application/api_helpers/api_response.rb | 4 + application/api_helpers/auth.rb | 20 ++++- application/config/grape.rb | 6 ++ application/config/hanami.rb | 2 +- application/config/mail.rb | 8 ++ application/config/variables.rb | 1 + application/config/yaml/errors.yml | 1 + application/config/yaml/sidekiq.yml | 8 -- application/entities/login.rb | 10 +++ application/entities/password_reset.rb | 7 ++ application/entities/user.rb | 23 ++++++ application/jobs/confirm_new_user_job.rb | 12 +++ .../jobs/confirm_reset_password_job.rb | 12 +++ .../jobs/send_reset_password_code_job.rb | 12 +++ .../migrate/1482815196_add_initial_tables.rb | 16 ++-- application/models/user.rb | 12 +++ application/spec/api/auth/login_spec.rb | 41 ++++++++++ .../spec/api/auth/reset_password_code_spec.rb | 18 +++++ application/spec/api/users/create_spec.rb | 50 ++++++++++++ application/spec/api/users/get_spec.rb | 8 +- .../spec/api/users/reset_password_spec.rb | 79 +++++++++++++++++++ application/spec/api/users/update_spec.rb | 72 +++++++++++++++++ application/spec/factories/user.rb | 2 +- .../spec/jobs/confirm_new_user_job_spec.rb | 13 +++ .../jobs/confirm_reset_password_job_spec.rb | 13 +++ .../jobs/send_reset_password_code_job_spec.rb | 14 ++++ application/spec/spec_helper.rb | 35 +++++--- .../validators/reset_password_validator.rb | 16 ++++ application/validators/user_validator.rb | 15 ++++ 37 files changed, 652 insertions(+), 59 deletions(-) create mode 100644 .rspec create mode 100644 application/api/auth.rb create mode 100644 application/config/mail.rb delete mode 100644 application/config/yaml/sidekiq.yml create mode 100644 application/entities/login.rb create mode 100644 application/entities/password_reset.rb create mode 100644 application/entities/user.rb create mode 100644 application/jobs/confirm_new_user_job.rb create mode 100644 application/jobs/confirm_reset_password_job.rb create mode 100644 application/jobs/send_reset_password_code_job.rb create mode 100644 application/spec/api/auth/login_spec.rb create mode 100644 application/spec/api/auth/reset_password_code_spec.rb create mode 100644 application/spec/api/users/create_spec.rb create mode 100644 application/spec/api/users/reset_password_spec.rb create mode 100644 application/spec/api/users/update_spec.rb create mode 100644 application/spec/jobs/confirm_new_user_job_spec.rb create mode 100644 application/spec/jobs/confirm_reset_password_job_spec.rb create mode 100644 application/spec/jobs/send_reset_password_code_job_spec.rb create mode 100644 application/validators/reset_password_validator.rb create mode 100644 application/validators/user_validator.rb diff --git a/.env.development.sample b/.env.development.sample index 7864698..e89ab02 100644 --- a/.env.development.sample +++ b/.env.development.sample @@ -3,3 +3,4 @@ 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=57f1bcf21caed1930fba8ac4ef74b1636d80bb9347b5af3863e8897fe10a98eded469734909903c5a3c166fec8d536b81b3636eda644c5c6a1a79b83a193d59e diff --git a/.env.test.sample b/.env.test.sample index 67326c8..15dbe98 100644 --- a/.env.test.sample +++ b/.env.test.sample @@ -3,3 +3,4 @@ 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=a53d4a36f4bf1e08e14a0cbd24401856aaeac17f96311d199f51c90c100bfb4f175e679017dd9125b903cc97aa0561e7b533154f76681df00bd97336ec6c9edc diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..78fc58f --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--color +--require spec_helper +-I ./application/spec diff --git a/Gemfile b/Gemfile index b654c5a..d583ce6 100644 --- a/Gemfile +++ b/Gemfile @@ -14,22 +14,24 @@ gem 'grape-swagger-entity', '0.1.5' # parse entities in api gem 'rack-indifferent', '1.1' # makes param keys symbols gem 'mysql2', '0.4.5' gem 'sequel', '4.40.0' +gem 'sequel_secure_password' gem 'mail', '2.6.4' gem 'uuidtools', ' 2.1.5' 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 'sucker_punch' +gem 'jwt' group :development, :test do gem 'awesome_print', '1.7.0' gem 'pry', '0.10.4' + gem 'pry-doc' + gem 'pry-byebug' end group :test do - gem 'webmock', '2.1.0' - gem 'vcr', '3.0.3' - gem 'database_cleaner', '1.5.3' gem 'factory_girl', '4.7.0' gem 'faker', '1.6.6' gem 'rack-test', '0.6.3' diff --git a/Gemfile.lock b/Gemfile.lock index ad129a5..73fd4dd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,22 +7,19 @@ GEM i18n (~> 0.7) minitest (~> 5.1) tzinfo (~> 1.1) - addressable (2.5.0) - public_suffix (~> 2.0, >= 2.0.2) awesome_print (1.7.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.11) builder (3.2.2) + byebug (9.0.6) coderay (1.1.1) coercible (1.0.0) descendants_tracker (~> 0.0.1) concurrent-ruby (1.0.4) - crack (0.4.3) - safe_yaml (~> 1.0.0) daemons (1.2.4) - database_cleaner (1.5.3) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) diff-lcs (1.2.5) @@ -31,7 +28,7 @@ GEM dry-container (0.6.0) concurrent-ruby (~> 1.0) dry-configurable (~> 0.1, >= 0.1.3) - dry-core (0.2.1) + dry-core (0.2.3) concurrent-ruby (~> 1.0) dry-equalizer (0.2.0) dry-logic (0.4.0) @@ -85,11 +82,11 @@ GEM hanami-validations (0.6.0) dry-validation (~> 0.9) hanami-utils (~> 0.8) - hashdiff (0.3.1) hashie (3.4.6) i18n (0.7.0) ice_nine (0.11.2) inflecto (0.0.2) + jwt (1.5.6) listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) @@ -112,7 +109,12 @@ GEM coderay (~> 1.1.0) method_source (~> 0.8.1) slop (~> 3.4) - public_suffix (2.0.4) + pry-byebug (3.4.2) + byebug (~> 9.0) + pry (~> 0.10) + pry-doc (0.9.0) + pry (~> 0.9) + yard (~> 0.8) rack (1.6.4) rack-accept (0.4.5) rack (>= 0.4) @@ -140,9 +142,13 @@ GEM rspec-support (~> 3.5.0) rspec-support (3.5.0) ruby_dep (1.5.0) - safe_yaml (1.0.4) sequel (4.40.0) + sequel_secure_password (0.2.12) + bcrypt (>= 3.1, < 4.0) + sequel (>= 4.1.0, < 5.0) slop (3.6.0) + sucker_punch (2.0.2) + concurrent-ruby (~> 1.0.0) thin (1.7.0) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) @@ -152,16 +158,12 @@ GEM tzinfo (1.2.2) thread_safe (~> 0.1) uuidtools (2.1.5) - vcr (3.0.3) virtus (1.0.5) axiom-types (~> 0.1) coercible (~> 1.0) descendants_tracker (~> 0.0, >= 0.0.3) equalizer (~> 0.0, >= 0.0.9) - webmock (2.1.0) - addressable (>= 2.3.6) - crack (>= 0.3.2) - hashdiff + yard (0.9.5) PLATFORMS ruby @@ -170,7 +172,6 @@ DEPENDENCIES ability_list (= 0.0.4) activesupport (= 5.0.0) awesome_print (= 1.7.0) - database_cleaner (= 1.5.3) dry-validation (= 0.10.4) factory_girl (= 4.7.0) faker (= 1.6.6) @@ -180,9 +181,12 @@ DEPENDENCIES grape-swagger (= 0.25.1) grape-swagger-entity (= 0.1.5) hanami-validations (= 0.6.0) + jwt mail (= 2.6.4) mysql2 (= 0.4.5) pry (= 0.10.4) + pry-byebug + pry-doc rack (= 1.6.4) rack-indifferent (= 1.1) rack-test (= 0.6.3) @@ -190,10 +194,10 @@ DEPENDENCIES rerun (= 0.11.0) rspec (= 3.5.0) sequel (= 4.40.0) + sequel_secure_password + sucker_punch thin (= 1.7.0) uuidtools (= 2.1.5) - vcr (= 3.0.3) - webmock (= 2.1.0) RUBY VERSION ruby 2.3.3p222 diff --git a/application/api.rb b/application/api.rb index 7ddc2d9..fea1a75 100644 --- a/application/api.rb +++ b/application/api.rb @@ -15,6 +15,9 @@ require 'rack/indifferent' require 'grape' require 'grape/batch' +require 'sucker_punch' +require 'mail' +require 'jwt' # Initialize the application so we can add all our components to it class Api < Grape::API; end @@ -22,25 +25,35 @@ class Api < Grape::API; end require 'config/sequel' require 'config/hanami' require 'config/grape' +require 'config/mail' # 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 all models +# require application classes Dir['./application/models/*.rb'].each { |rb| require rb } +Dir['./application/entities/*.rb'].each { |rb| require rb } +Dir['./application/jobs/*.rb'].each { |rb| require rb } +Dir['./application/validators/*.rb'].each { |rb| require rb } Dir['./application/api_helpers/**/*.rb'].each { |rb| require rb } + class Api < Grape::API version 'v1.0', using: :path content_type :json, 'application/json' + content_type :txt, 'text/plain' default_format :json prefix :api + + logger PrettyLogger.logger + rescue_from Grape::Exceptions::ValidationErrors do |e| ret = { error_type: 'validation', errors: {} } e.each do |x, err| @@ -50,17 +63,30 @@ class Api < Grape::API error! ret, 400 end + rescue_from Sequel::NoMatchingRow do |e| + error!({ error_type: 'not_found' }, 404) + end + + rescue_from :all do |e| + Api.logger.error(e.class) + Api.logger.error(e.message) + Api.logger.error(e.backtrace.join("\n")) + error!({ error_type: 'internal' }, 404) + end + helpers SharedParams helpers ApiResponse include Auth before do + header['Access-Control-Allow-Origin'] = '*' + header['Access-Control-Request-Method'] = '*' + authenticate! end Dir['./application/api_entities/**/*.rb'].each { |rb| require rb } Dir['./application/api/**/*.rb'].each { |rb| require rb } - add_swagger_documentation \ - mount_path: '/docs' + add_swagger_documentation mount_path: '/docs' end diff --git a/application/api/auth.rb b/application/api/auth.rb new file mode 100644 index 0000000..63e470e --- /dev/null +++ b/application/api/auth.rb @@ -0,0 +1,29 @@ +class Api + namespace :auth do + + desc 'Generates a new authentication token', + entity: Models::Login::Authorization, + params: Models::Login::Input.documentation_in_body, + failure: [ { code: 403, message: 'Unauthorized' } ] + post 'login' do + user = Models::User[email: params[:email]] + if user.authenticate(params[:password]) + { token: auth_token_for(user) } + else + api_response(error_type: :unauthorized, errors: { reason: "Invalid credentials" }) + end + end + + desc 'Generates a new reset password code', + success: { code: 204 } + post 'reset_password_code/:user_id' do + user = Models::User.with_pk!(params[:user_id]) + code = user.update_reset_password_code! + + SendResetPasswordCodeJob.perform_async(user.email, code) + + body false + end + + end +end diff --git a/application/api/users.rb b/application/api/users.rb index 40de636..f309442 100644 --- a/application/api/users.rb +++ b/application/api/users.rb @@ -3,11 +3,72 @@ class Api params do includes :basic_search end + get do - users = SEQUEL_DB[:users].all - { - data: users - } + users = Models::User.all + present :data, users, with: Models::User::Entity + end + + + desc 'Creates a new user', + entity: Models::User::Entity, + params: Models::User::Input.documentation_in_body, + failure: [ { code: 422, message: 'Invalid input' } ] + post do + result = UserValidator.new(params).validate + + if result.success? + @user = Models::User.create(result.output) + ConfirmNewUserJob.perform_async(@user.email) + + present @user + else + api_response(error_type: :invalid, errors: result.messages) + end + end + + route_param :id do + before do + @user = Models::User.with_pk!(params[:id]) + end + + desc "Resets a user's password", + params: Models::PasswordReset::Input.documentation_in_body, + success: { code: 204 }, + failure: [ { code: 422, message: 'Invalid input' }, { code: 401, message: 'Invalid verification code' } ] + patch :reset_password do + result = ResetPasswordValidator.new(params).validate + + if !@user.valid_reset_password_code?(result.output[:verification_code]) + api_response(error_type: :unauthorized, errors: { reason: "Invalid code" }) + elsif result.failure? + api_response(error_type: :invalid, errors: result.messages) + else + @user.update(password: result.output[:new_password]) + ConfirmResetPasswordJob.perform_async(@user.email) + + body false + end + end + + desc 'Updates an existing user', + entity: Models::User::Entity, + params: Models::User::Input.documentation_in_body, + failure: [ { code: 422, message: 'Invalid input' }, { code: 403, message: 'Unauthorized operation attempt' } ], + headers: { 'Authorization' => { description: 'JWT Authorization Token', required: true } } + put do + result = UserValidator.new(params).validate + + if current_user.nil? || current_user.cannot?(:edit, @user) + api_response(error_type: :forbidden, errors: { reason: "Permission denied" }) + elsif result.failure? + api_response(error_type: :invalid, errors: result.messages) + else + @user.update(result.output) + + present @user + end + end end end end diff --git a/application/api_helpers/api_response.rb b/application/api_helpers/api_response.rb index 4613eca..dd8e1c8 100644 --- a/application/api_helpers/api_response.rb +++ b/application/api_helpers/api_response.rb @@ -9,6 +9,10 @@ def api_response response status 404 when :forbidden status 403 + when :unauthorized + status 401 + when :unprocessable_entity, :invalid + status 422 else status 400 end diff --git a/application/api_helpers/auth.rb b/application/api_helpers/auth.rb index b46a936..0b6eb14 100644 --- a/application/api_helpers/auth.rb +++ b/application/api_helpers/auth.rb @@ -8,12 +8,30 @@ module Auth module HelperMethods def authenticate! - # Library to authenticate user can go here + if token = headers['Authorization'] + @current_user = Models::User.with_pk(extract_user_id(token)) + end + rescue JWT::DecodeError + error!({error_type: :unauthorized}, :unauthorized) end def current_user @current_user end + + def extract_user_id(token) + JWT.decode(token, HMAC_SECRET, true, algorithm: 'HS256')[0]['user_id'] + end + + def auth_token_for(user) + payload = { + iss: "ruby-api-example", + exp: Time.now.to_i + 4.hours, + user_id: user.id + } + + JWT.encode(payload, HMAC_SECRET, 'HS256') + end end end end diff --git a/application/config/grape.rb b/application/config/grape.rb index 822837f..0990174 100644 --- a/application/config/grape.rb +++ b/application/config/grape.rb @@ -9,3 +9,9 @@ config.logger = Logger.new(STDOUT) config.session_proc = Proc.new { } end + +class Grape::Entity + def self.documentation_in_body + documentation.transform_values { |v| v.merge(in: 'body') } + end +end diff --git a/application/config/hanami.rb b/application/config/hanami.rb index 6d2aaf0..f594a02 100644 --- a/application/config/hanami.rb +++ b/application/config/hanami.rb @@ -18,6 +18,6 @@ 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})?$/) + current.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..6102c6b --- /dev/null +++ b/application/config/mail.rb @@ -0,0 +1,8 @@ +Mail.defaults do + mail_uri = URI(MAIL_URL) + delivery_method :smtp, + user_name: mail_uri.user, + password: mail_uri.password, + address: mail_uri.host, + port: mail_uri.port +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..bbf9b21 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) + password_confirmation: passwords don't match diff --git a/application/config/yaml/sidekiq.yml b/application/config/yaml/sidekiq.yml deleted file mode 100644 index 071bd80..0000000 --- a/application/config/yaml/sidekiq.yml +++ /dev/null @@ -1,8 +0,0 @@ -:concurrency: 20 -:queues: - - [priority, 7] - - [default, 5] - - [seldom, 3] -staging: - :concurrency: 10 -:require: ./application/api.rb diff --git a/application/entities/login.rb b/application/entities/login.rb new file mode 100644 index 0000000..9c95140 --- /dev/null +++ b/application/entities/login.rb @@ -0,0 +1,10 @@ +class Api::Models::Login + class Authorization < Grape::Entity + expose :token, documentation: { desc: 'JWT Authorization Token' } + end + + class Input < Grape::Entity + expose :email, documentation: { desc: "User's Email", required: true } + expose :password, documentation: { desc: "User's Password", required: true } + end +end diff --git a/application/entities/password_reset.rb b/application/entities/password_reset.rb new file mode 100644 index 0000000..212e3bc --- /dev/null +++ b/application/entities/password_reset.rb @@ -0,0 +1,7 @@ +class Api::Models::PasswordReset + class Input < Grape::Entity + expose :new_password, documentation: { desc: "User's password", required: true } + expose :confirm_password, documentation: { desc: "Password confirmation", required: true } + expose :verification_code, documentation: { desc: "Verification code", required: true } + end +end diff --git a/application/entities/user.rb b/application/entities/user.rb new file mode 100644 index 0000000..479b18f --- /dev/null +++ b/application/entities/user.rb @@ -0,0 +1,23 @@ +class Api::Models::User + class Entity < Grape::Entity + format_with(:iso_timestamp) do |date| + date.utc.iso8601 if date + end + + expose :id, documentation: { type: "Integer", desc: "Identifier" } + expose :first_name, documentation: { desc: "First Name", required: true } + expose :last_name, documentation: { desc: "Last Name", required: true } + expose :email, documentation: { desc: "Email", required: true } + + with_options(format_with: :iso_timestamp) do + expose :born_on, documentation: { type: "dateTime", desc: "Birthdate" } + expose :created_at, documentation: { type: "dateTime", desc: "Created at" } + expose :updated_at, documentation: { type: "dateTime", desc: "Updated at" } + end + end + + class Input < Entity + unexpose :id, :created_at, :updated_at + expose :password, documentation: { desc: "Password", required: true } + end +end diff --git a/application/jobs/confirm_new_user_job.rb b/application/jobs/confirm_new_user_job.rb new file mode 100644 index 0000000..f9fda3b --- /dev/null +++ b/application/jobs/confirm_new_user_job.rb @@ -0,0 +1,12 @@ +class ConfirmNewUserJob + include SuckerPunch::Job + + def perform(email) + Mail.deliver do + from 'admin@sample.com' + to email + subject "Api Example: new user" + body "Your account was successfully created" + end + end +end diff --git a/application/jobs/confirm_reset_password_job.rb b/application/jobs/confirm_reset_password_job.rb new file mode 100644 index 0000000..5cdda6d --- /dev/null +++ b/application/jobs/confirm_reset_password_job.rb @@ -0,0 +1,12 @@ +class ConfirmResetPasswordJob + include SuckerPunch::Job + + def perform(email) + Mail.deliver do + from 'admin@sample.com' + to email + subject "Api Example: password reset" + body "Your password was succesfully updated" + end + end +end diff --git a/application/jobs/send_reset_password_code_job.rb b/application/jobs/send_reset_password_code_job.rb new file mode 100644 index 0000000..e6dafd1 --- /dev/null +++ b/application/jobs/send_reset_password_code_job.rb @@ -0,0 +1,12 @@ +class SendResetPasswordCodeJob + include SuckerPunch::Job + + def perform(email, code) + Mail.deliver do + from 'admin@sample.com' + to email + subject "Api Example: password reset code" + body "You will be able to reset your password within the next 30 minutes using this code: #{code}" + end + end +end diff --git a/application/migrate/1482815196_add_initial_tables.rb b/application/migrate/1482815196_add_initial_tables.rb index cda9bb7..b13d687 100644 --- a/application/migrate/1482815196_add_initial_tables.rb +++ b/application/migrate/1482815196_add_initial_tables.rb @@ -2,13 +2,17 @@ change do create_table(:users) do primary_key :id - String :first_name - String :last_name - String :password - String :email + + String :first_name, null: false + String :last_name, null: false + String :password_digest, null: false + String :email, null: false DateTime :born_on - DateTime :created_at - DateTime :updated_at + String :reset_password + DateTime :reset_password_expiration + + DateTime :created_at, null: false + DateTime :updated_at, null: false end end end diff --git a/application/models/user.rb b/application/models/user.rb index 802a14b..ab478e8 100644 --- a/application/models/user.rb +++ b/application/models/user.rb @@ -3,6 +3,8 @@ class Api module Models class User < Sequel::Model(:users) + plugin :secure_password, include_validations: false + include AbilityList::Helpers def abilities @@ -12,6 +14,16 @@ def abilities def full_name "#{self.first_name} #{self.last_name}" end + + def update_reset_password_code! + SecureRandom.hex(5).upcase.tap do |code| + update(reset_password: code, reset_password_expiration: Time.now + 30.minutes) + end + end + + def valid_reset_password_code?(code) + reset_password && code == reset_password && Time.now <= reset_password_expiration + end end end end diff --git a/application/spec/api/auth/login_spec.rb b/application/spec/api/auth/login_spec.rb new file mode 100644 index 0000000..e20a8e3 --- /dev/null +++ b/application/spec/api/auth/login_spec.rb @@ -0,0 +1,41 @@ +describe 'POST /api/auth/login' do + let(:password) { '123456' } + let(:user) { create(:user, password: password) } + + context "when the password is correct" do + let(:body) do + { + email: user.email, + password: password + } + end + + let(:token_payload) { JWT.decode(response_body[:token], HMAC_SECRET, true, algorithm: 'HS256')[0] } + let!(:send_time) { Time.now } + + it 'returns a JWT token', :aggregate_failures do + post "api/v1.0/auth/login", body + + expect(response).to be_successful + expect(token_payload).to include('iss' => 'ruby-api-example', 'user_id' => user.id) + expect(token_payload['exp']).to be_within(5).of(send_time.to_i + 4.hours) + end + end + + context "when the password is not correct" do + let(:body) do + { + email: user.email, + password: "wrong password" + } + end + + it 'returns a JWT token', :aggregate_failures do + post "api/v1.0/auth/login", body + + expect(response).to be_unauthorized + expect(response_body).to include(error_type: 'unauthorized', errors: {reason: 'Invalid credentials'}) + end + + end +end diff --git a/application/spec/api/auth/reset_password_code_spec.rb b/application/spec/api/auth/reset_password_code_spec.rb new file mode 100644 index 0000000..7afcbc5 --- /dev/null +++ b/application/spec/api/auth/reset_password_code_spec.rb @@ -0,0 +1,18 @@ +describe 'POST /api/auth/reset_password_code/:user_id' do + before { stub_const("SendResetPasswordCodeJob", double(perform_async: nil)) } + let!(:user) { create(:user) } + let!(:send_time) { Time.now } + + it 'sets a reset password verification code', :aggregate_failures do + expect(SendResetPasswordCodeJob).to receive(:perform_async).with(user.email, match(/[A-F0-9]{10}/)) + + post "api/v1.0/auth/reset_password_code/#{user.id}" + + user.refresh + expect(user.reset_password).to match(/[A-F0-9]{10}/) + expect(user.reset_password_expiration).to be_within(5.seconds).of(send_time + 30.minutes) + + expect(response.status).to eq(204) + expect(response.body).to be_empty + end +end diff --git a/application/spec/api/users/create_spec.rb b/application/spec/api/users/create_spec.rb new file mode 100644 index 0000000..28e29cc --- /dev/null +++ b/application/spec/api/users/create_spec.rb @@ -0,0 +1,50 @@ +describe 'POST /api/users' do + before { stub_const("ConfirmNewUserJob", double(perform_async: nil)) } + + context 'when using valid attributes' do + let(:body) do + { + first_name: "John", + last_name: "Doe", + password: "mypassword", + email: "john@doe.com", + born_on: "1982-01-01 08:00:00 +0000" + } + end + + it 'creates a new user', :aggregate_failures do + expect(ConfirmNewUserJob).to receive(:perform_async).with("john@doe.com") + + expect { post "api/v1.0/users", body }.to change(Api::Models::User, :count).by(1) + + expect(response).to be_created + expect(response_body).to match(id: a_kind_of(Integer), + first_name: "John", + last_name: "Doe", + email: "john@doe.com", + born_on: "1982-01-01T08:00:00Z", + created_at: match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/), + updated_at: match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/)) + end + end + + context 'when using invalid attributes' do + let(:body) do + { + first_name: "John", + last_name: "Doe", + password: "mypassword", + email: "not an email" + } + end + + it 'does not create a new user', :aggregate_failures do + expect(ConfirmNewUserJob).to_not receive(:perform_async) + + expect { post "api/v1.0/users", body }.to_not change(Api::Models::User, :count) + + expect(response).to be_unprocessable + expect(response_body).to include(error_type: 'invalid') + end + end +end diff --git a/application/spec/api/users/get_spec.rb b/application/spec/api/users/get_spec.rb index 95899aa..a02153e 100644 --- a/application/spec/api/users/get_spec.rb +++ b/application/spec/api/users/get_spec.rb @@ -1,15 +1,13 @@ -require 'spec_helper' - describe 'GET /api/users' do before :all do @u1 = create :user @u2 = create :user end - it 'should pull all users' do + it 'lists all users' do get "api/v1.0/users" - body = response_body - emails = body[:data].map{ |x| x[:email] } + + emails = response_body[:data].map{ |x| x[:email] } expect(emails).to include @u1.email expect(emails).to include @u2.email 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..f694f4e --- /dev/null +++ b/application/spec/api/users/reset_password_spec.rb @@ -0,0 +1,79 @@ +describe 'PATCH /api/users/:id/reset_password' do + before { stub_const("ConfirmResetPasswordJob", double(perform_async: nil)) } + let(:user) { create(:user, reset_password: code, reset_password_expiration: expiration) } + let(:expiration) { Time.now + 30.minutes } + let(:code) { '0123456789' } + + context 'when using valid attributes and code' do + let(:body) do + { + new_password: "mynewpassword", + confirm_password: "mynewpassword", + verification_code: code + } + end + + it 'resets the password', :aggregate_failures do + expect(ConfirmResetPasswordJob).to receive(:perform_async).with(user.email) + + expect { patch "api/v1.0/users/#{user.id}/reset_password", body }.to change { user.refresh.password_digest } + + expect(response.status).to eq(204) + expect(response.body).to be_empty + end + end + + context 'when using an invalid code' do + let(:body) do + { + new_password: "mynewpassword", + confirm_password: "mynewpassword", + verification_code: "FFFFFFFF" + } + end + + it "doesn't reset the password", :aggregate_failures do + patch "api/v1.0/users/#{user.id}/reset_password", body + + expect(response).to be_unauthorized + expect(response_body).to include(error_type: 'unauthorized') + end + end + + context 'when using an expired code' do + let(:body) do + { + new_password: "mynewpassword", + confirm_password: "mynewpassword", + verification_code: code + } + end + let(:expiration) { Time.now - 1.hour } + + it "doesn't reset the password", :aggregate_failures do + patch "api/v1.0/users/#{user.id}/reset_password", body + + expect(response).to be_unauthorized + expect(response_body).to include(error_type: 'unauthorized', errors: { reason: "Invalid code" }) + end + end + + context 'when using invalid attributes' do + let(:body) do + { + new_password: "mynewpassword", + confirm_password: "otherpassword", + verification_code: code + } + end + + it "doesn't reset the password", :aggregate_failures do + expect(ConfirmResetPasswordJob).to_not receive(:perform_async) + + expect { patch "api/v1.0/users/#{user.id}/reset_password", body }.to_not change { user.refresh.password_digest } + + expect(response).to be_unprocessable + expect(response_body).to include(error_type: 'invalid') + end + end +end diff --git a/application/spec/api/users/update_spec.rb b/application/spec/api/users/update_spec.rb new file mode 100644 index 0000000..f709de9 --- /dev/null +++ b/application/spec/api/users/update_spec.rb @@ -0,0 +1,72 @@ +describe 'PUT /api/users/:id' do + let!(:user) { create(:user) } + + context 'when not logged in' do + it 'does not update existing user', :aggregate_failures do + put "api/v1.0/users/#{user.id}", {} + + expect(response).to be_forbidden + expect(response_body).to include(error_type: 'forbidden', errors: { reason: "Permission denied" }) + end + end + + context 'when logged in, editing your own user' do + before { login_as user } + + context 'and using valid attributes' do + let(:body) do + { + first_name: "Jane", + last_name: "Doe", + password: "mypassword", + email: "jane@doe.com", + born_on: "" + } + end + + it 'updates existing user', :aggregate_failures do + put "api/v1.0/users/#{user.id}", body + + expect(response).to be_ok + expect(response_body).to match(id: user.id, + first_name: "Jane", + last_name: "Doe", + email: "jane@doe.com", + born_on: nil, + created_at: match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/), + updated_at: match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/)) + end + end + + context 'and using invalid attributes' do + let(:body) do + { + first_name: "Jane", + last_name: "", + password: "mypassword", + email: "jane@doe.com", + born_on: "1984-01-01 08:00:00 +0000" + } + end + + it 'does not update existing user', :aggregate_failures do + put "api/v1.0/users/#{user.id}", body + + expect(response).to be_unprocessable + expect(response_body).to include(error_type: 'invalid') + end + end + end + + context 'when logged in and editing another user' do + before { login_as user } + let!(:other_user) { create(:user) } + + it 'does not create a new user', :aggregate_failures do + put "api/v1.0/users/#{other_user.id}", {} + + expect(response).to be_forbidden + expect(response_body).to include(error_type: 'forbidden', errors: { reason: "Permission denied" }) + end + end +end diff --git a/application/spec/factories/user.rb b/application/spec/factories/user.rb index 199c292..9b8e69f 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' + password 'test' born_on Date.new(2000, 1, 1) end end diff --git a/application/spec/jobs/confirm_new_user_job_spec.rb b/application/spec/jobs/confirm_new_user_job_spec.rb new file mode 100644 index 0000000..1327ce4 --- /dev/null +++ b/application/spec/jobs/confirm_new_user_job_spec.rb @@ -0,0 +1,13 @@ +describe ConfirmNewUserJob do + describe "#perform" do + let(:email) { "user@test.com" } + before { ConfirmNewUserJob.perform_async(email) } + + it "sends an email notifying the new user" do + is_expected.to have_sent_email.to(email) + .from("admin@sample.com") + .with_subject("Api Example: new user") + .with_body("Your account was successfully created") + end + end +end diff --git a/application/spec/jobs/confirm_reset_password_job_spec.rb b/application/spec/jobs/confirm_reset_password_job_spec.rb new file mode 100644 index 0000000..4fa2c64 --- /dev/null +++ b/application/spec/jobs/confirm_reset_password_job_spec.rb @@ -0,0 +1,13 @@ +describe ConfirmResetPasswordJob do + describe "#perform" do + let(:email) { "user@test.com" } + before { ConfirmResetPasswordJob.perform_async(email) } + + it "sends an email notifying the password change" do + is_expected.to have_sent_email.to(email) + .from("admin@sample.com") + .with_subject("Api Example: password reset") + .with_body("Your password was succesfully updated") + end + end +end diff --git a/application/spec/jobs/send_reset_password_code_job_spec.rb b/application/spec/jobs/send_reset_password_code_job_spec.rb new file mode 100644 index 0000000..a9684e7 --- /dev/null +++ b/application/spec/jobs/send_reset_password_code_job_spec.rb @@ -0,0 +1,14 @@ +describe SendResetPasswordCodeJob do + describe "#perform" do + let(:email) { "user@test.com" } + let(:code) { "0011AABBCC" } + before { SendResetPasswordCodeJob.perform_async(email, code) } + + it "sends an email notifying the password reset code" do + is_expected.to have_sent_email.to(email) + .from("admin@sample.com") + .with_subject("Api Example: password reset code") + .with_body("You will be able to reset your password within the next 30 minutes using this code: #{code}") + end + end +end diff --git a/application/spec/spec_helper.rb b/application/spec/spec_helper.rb index 4b54421..c63adc5 100644 --- a/application/spec/spec_helper.rb +++ b/application/spec/spec_helper.rb @@ -4,19 +4,13 @@ require './application/api' require 'faker' require 'factory_girl' - -# Load up all application files that we'll be testing in the suites -Dir['./application/models/**/*.rb'].sort.each { |rb| require rb } +require 'sucker_punch/testing/inline' FactoryGirl.definition_file_paths = %w{./application/spec/factories} FactoryGirl.find_definitions -# Factory Girl is expecting ActiveRecord -class Sequel::Model - alias_method :save!, :save -end - -class RSpecConstants +FactoryGirl.define do + to_create(&:save) # Sequel support end module RSpecHelpers @@ -30,8 +24,12 @@ def app Api end + def response + last_response + end + def response_body - JSON.parse(last_response.body, symbolize_names: true) + JSON.parse(response.body, symbolize_names: true) end def get_scope opts = {} @@ -62,14 +60,31 @@ def current_user Faker::Config.locale = 'en-US' +Mail.defaults do + delivery_method :test +end + RSpec.configure do |config| 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 config.formatter = :documentation + config.order = :random + Kernel.srand config.seed + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.before(:each) do + SuckerPunch::Queue.clear + Mail::TestMailer.deliveries.clear + login_as(nil) + end config.around(:all) do |example| Sequel.transaction [SEQUEL_DB], rollback: :always do diff --git a/application/validators/reset_password_validator.rb b/application/validators/reset_password_validator.rb new file mode 100644 index 0000000..b0be7ce --- /dev/null +++ b/application/validators/reset_password_validator.rb @@ -0,0 +1,16 @@ +class Api + class ResetPasswordValidator + include Hanami::Validations::Form + predicates FormPredicates + + validations do + required(:new_password).filled(:str?) + required(:confirm_password).filled(:str?) + required(:verification_code).filled(:str?) + + rule(password_confirmation: [:new_password, :confirm_password]) do |new_password, confirm_password| + new_password.eql?(confirm_password) + end + end + end +end diff --git a/application/validators/user_validator.rb b/application/validators/user_validator.rb new file mode 100644 index 0000000..8637404 --- /dev/null +++ b/application/validators/user_validator.rb @@ -0,0 +1,15 @@ +class Api + class UserValidator + include Hanami::Validations::Form + predicates FormPredicates + + validations do + required(:first_name).filled(:str?) + required(:last_name).filled(:str?) + required(:password).filled(:str?) + required(:email).filled(:str?, :email?) + + optional(:born_on).maybe(:str?, :datetime_str?) + end + end +end From 7f3b17438cb241b6ef1d0ec0d829792f078670b1 Mon Sep 17 00:00:00 2001 From: Pablo Herrero Date: Thu, 12 Jan 2017 16:50:47 -0300 Subject: [PATCH 2/3] Offload logic to operations --- application/api.rb | 7 ++- application/api/auth.rb | 14 ++--- application/api/users.rb | 43 +++---------- application/lib/operation.rb | 62 +++++++++++++++++++ application/models/user.rb | 10 --- application/operations/create_user.rb | 15 +++++ application/operations/login.rb | 13 ++++ .../operations/new_reset_password_code.rb | 18 ++++++ application/operations/reset_password.rb | 24 +++++++ application/operations/update_operation.rb | 27 ++++++++ application/operations/user_operation.rb | 7 +++ application/spec/api/auth/login_spec.rb | 4 +- .../spec/api/auth/reset_password_code_spec.rb | 4 +- .../spec/api/users/reset_password_spec.rb | 4 +- application/spec/api/users/update_spec.rb | 4 +- .../validators/reset_password_validator.rb | 5 +- application/validators/user_validator.rb | 5 +- 17 files changed, 194 insertions(+), 72 deletions(-) create mode 100644 application/lib/operation.rb create mode 100644 application/operations/create_user.rb create mode 100644 application/operations/login.rb create mode 100644 application/operations/new_reset_password_code.rb create mode 100644 application/operations/reset_password.rb create mode 100644 application/operations/update_operation.rb create mode 100644 application/operations/user_operation.rb diff --git a/application/api.rb b/application/api.rb index fea1a75..10306ca 100644 --- a/application/api.rb +++ b/application/api.rb @@ -32,17 +32,20 @@ class Api < Grape::API; end require 'lib/time_formats' require 'lib/io' require 'lib/pretty_logger' +require 'lib/operation' # load active support helpers require 'active_support' require 'active_support/core_ext' # require application classes +require './application/operations/user_operation' + Dir['./application/models/*.rb'].each { |rb| require rb } Dir['./application/entities/*.rb'].each { |rb| require rb } Dir['./application/jobs/*.rb'].each { |rb| require rb } Dir['./application/validators/*.rb'].each { |rb| require rb } - +Dir['./application/operations/*.rb'].each { |rb| require rb } Dir['./application/api_helpers/**/*.rb'].each { |rb| require rb } class Api < Grape::API @@ -71,7 +74,7 @@ class Api < Grape::API Api.logger.error(e.class) Api.logger.error(e.message) Api.logger.error(e.backtrace.join("\n")) - error!({ error_type: 'internal' }, 404) + error!({ error_type: 'internal' }, 500) end helpers SharedParams diff --git a/application/api/auth.rb b/application/api/auth.rb index 63e470e..aa7b1c6 100644 --- a/application/api/auth.rb +++ b/application/api/auth.rb @@ -6,22 +6,16 @@ class Api params: Models::Login::Input.documentation_in_body, failure: [ { code: 403, message: 'Unauthorized' } ] post 'login' do - user = Models::User[email: params[:email]] - if user.authenticate(params[:password]) - { token: auth_token_for(user) } - else - api_response(error_type: :unauthorized, errors: { reason: "Invalid credentials" }) + Login.(params) do + ok { |user| { token: auth_token_for(user) } } + fail { |errors| api_response errors } end end desc 'Generates a new reset password code', success: { code: 204 } post 'reset_password_code/:user_id' do - user = Models::User.with_pk!(params[:user_id]) - code = user.update_reset_password_code! - - SendResetPasswordCodeJob.perform_async(user.email, code) - + NewResetPasswordCode.(params) body false end diff --git a/application/api/users.rb b/application/api/users.rb index f309442..4fd3916 100644 --- a/application/api/users.rb +++ b/application/api/users.rb @@ -15,39 +15,21 @@ class Api params: Models::User::Input.documentation_in_body, failure: [ { code: 422, message: 'Invalid input' } ] post do - result = UserValidator.new(params).validate - - if result.success? - @user = Models::User.create(result.output) - ConfirmNewUserJob.perform_async(@user.email) - - present @user - else - api_response(error_type: :invalid, errors: result.messages) + CreateUser.(params) do + ok { |user| present user } + fail { |errors| api_response errors } end end route_param :id do - before do - @user = Models::User.with_pk!(params[:id]) - end - desc "Resets a user's password", params: Models::PasswordReset::Input.documentation_in_body, success: { code: 204 }, failure: [ { code: 422, message: 'Invalid input' }, { code: 401, message: 'Invalid verification code' } ] patch :reset_password do - result = ResetPasswordValidator.new(params).validate - - if !@user.valid_reset_password_code?(result.output[:verification_code]) - api_response(error_type: :unauthorized, errors: { reason: "Invalid code" }) - elsif result.failure? - api_response(error_type: :invalid, errors: result.messages) - else - @user.update(password: result.output[:new_password]) - ConfirmResetPasswordJob.perform_async(@user.email) - - body false + ResetPassword.(params) do + ok { body false } + fail { |errors| api_response errors } end end @@ -57,16 +39,9 @@ class Api failure: [ { code: 422, message: 'Invalid input' }, { code: 403, message: 'Unauthorized operation attempt' } ], headers: { 'Authorization' => { description: 'JWT Authorization Token', required: true } } put do - result = UserValidator.new(params).validate - - if current_user.nil? || current_user.cannot?(:edit, @user) - api_response(error_type: :forbidden, errors: { reason: "Permission denied" }) - elsif result.failure? - api_response(error_type: :invalid, errors: result.messages) - else - @user.update(result.output) - - present @user + UpdateUser.(current_user, params) do + ok { |user| present user } + fail { |errors| api_response errors } end end end diff --git a/application/lib/operation.rb b/application/lib/operation.rb new file mode 100644 index 0000000..f8c265f --- /dev/null +++ b/application/lib/operation.rb @@ -0,0 +1,62 @@ +class Operation + class Validator + include Hanami::Validations::Form + predicates FormPredicates + end + + class Result + attr_reader :value, :errors + + def initialize(value: nil, errors: {}) + @value, @errors = value, errors + end + + def succesfull? + @errors.empty? + end + + def failure? + !succesful? + end + end + + class Responder + def self.respond(response, &bl) + new(response, &bl).respond + end + + def initialize(response, &bl) + instance_eval(&bl) + @response, @context = response, bl.binding.receiver + end + + def ok(&bl) + @ok = bl + end + + def fail(&bl) + @fail = bl + end + + def respond + if @response.succesfull? + @context.instance_exec(@response.value, &@ok) + else + @context.instance_exec(@response.errors, &@fail) + end + end + end + + def self.call(*args, params, &bl) + result = new(*args).call(params) + block_given? ? Responder.respond(result, &bl) : result + end + + def success(value) + Result.new(value: value) + end + + def failure(errors) + Result.new(errors: errors) + end +end diff --git a/application/models/user.rb b/application/models/user.rb index ab478e8..139da58 100644 --- a/application/models/user.rb +++ b/application/models/user.rb @@ -14,16 +14,6 @@ def abilities def full_name "#{self.first_name} #{self.last_name}" end - - def update_reset_password_code! - SecureRandom.hex(5).upcase.tap do |code| - update(reset_password: code, reset_password_expiration: Time.now + 30.minutes) - end - end - - def valid_reset_password_code?(code) - reset_password && code == reset_password && Time.now <= reset_password_expiration - end end end end diff --git a/application/operations/create_user.rb b/application/operations/create_user.rb new file mode 100644 index 0000000..0200d11 --- /dev/null +++ b/application/operations/create_user.rb @@ -0,0 +1,15 @@ +class Api + class CreateUser < UserOperation + def call(params) + val = UserValidator.new(params).validate + + if val.success? + user = Models::User.create(val.output) + ConfirmNewUserJob.perform_async(user.email) + success(user) + else + failure(error_type: :invalid, messages: val.messages) + end + end + end +end diff --git a/application/operations/login.rb b/application/operations/login.rb new file mode 100644 index 0000000..21b9d3e --- /dev/null +++ b/application/operations/login.rb @@ -0,0 +1,13 @@ +class Api + class Login < Operation + def call(params) + user = Models::User.first!(email: params[:email]) + + if user.authenticate(params[:password]) + success(user) + else + failure(error_type: :unauthorized) + end + end + end +end diff --git a/application/operations/new_reset_password_code.rb b/application/operations/new_reset_password_code.rb new file mode 100644 index 0000000..59955ba --- /dev/null +++ b/application/operations/new_reset_password_code.rb @@ -0,0 +1,18 @@ +class Api + class NewResetPasswordCode < Operation + def call(params) + user = Models::User.with_pk!(params[:user_id]) + code = new_reset_password_code!(user) + SendResetPasswordCodeJob.perform_async(user.email, code) + success(user) + end + + private + + def new_reset_password_code!(user) + SecureRandom.hex(4).upcase.tap do |code| + user.update(reset_password: code, reset_password_expiration: Time.now + 30.minutes) + end + end + end +end diff --git a/application/operations/reset_password.rb b/application/operations/reset_password.rb new file mode 100644 index 0000000..c6e4d58 --- /dev/null +++ b/application/operations/reset_password.rb @@ -0,0 +1,24 @@ +class Api + class ResetPassword < UserOperation + def call(params) + user = user_for(params) + val = ResetPasswordValidator.new(params).validate + + if !valid_reset_password_code?(user, val.output[:verification_code]) + failure(error_type: :unauthorized) + elsif val.failure? + failure(error_type: :invalid, errors: val.messages) + else + user.update(password: val.output[:new_password]) + ConfirmResetPasswordJob.perform_async(user.email) + success(user) + end + end + + private + + def valid_reset_password_code?(user, code) + code && code == user.reset_password && Time.now <= user.reset_password_expiration + end + end +end diff --git a/application/operations/update_operation.rb b/application/operations/update_operation.rb new file mode 100644 index 0000000..e4369fc --- /dev/null +++ b/application/operations/update_operation.rb @@ -0,0 +1,27 @@ +class Api + class UpdateUser < UserOperation + def initialize(current_user) + @current_user = current_user + end + + def call(params) + user = user_for(params) + val = UserValidator.new(params).validate + + if !can_edit?(user) + failure(error_type: :forbidden) + elsif val.failure? + failure(error_type: :invalid, messages: val.messages) + else + user.update(val.output) + success(user) + end + end + + private + + def can_edit?(user) + @current_user&.can?(:edit, user) + end + end +end diff --git a/application/operations/user_operation.rb b/application/operations/user_operation.rb new file mode 100644 index 0000000..4ccaeb0 --- /dev/null +++ b/application/operations/user_operation.rb @@ -0,0 +1,7 @@ +class Api + class UserOperation < Operation + def user_for(params) + Models::User.with_pk!(params[:id]) + end + end +end diff --git a/application/spec/api/auth/login_spec.rb b/application/spec/api/auth/login_spec.rb index e20a8e3..43cdf8a 100644 --- a/application/spec/api/auth/login_spec.rb +++ b/application/spec/api/auth/login_spec.rb @@ -30,11 +30,11 @@ } end - it 'returns a JWT token', :aggregate_failures do + it 'does not return a JWT token', :aggregate_failures do post "api/v1.0/auth/login", body expect(response).to be_unauthorized - expect(response_body).to include(error_type: 'unauthorized', errors: {reason: 'Invalid credentials'}) + expect(response_body).to include(error_type: 'unauthorized') end end diff --git a/application/spec/api/auth/reset_password_code_spec.rb b/application/spec/api/auth/reset_password_code_spec.rb index 7afcbc5..ca5ba92 100644 --- a/application/spec/api/auth/reset_password_code_spec.rb +++ b/application/spec/api/auth/reset_password_code_spec.rb @@ -4,12 +4,12 @@ let!(:send_time) { Time.now } it 'sets a reset password verification code', :aggregate_failures do - expect(SendResetPasswordCodeJob).to receive(:perform_async).with(user.email, match(/[A-F0-9]{10}/)) + expect(SendResetPasswordCodeJob).to receive(:perform_async).with(user.email, match(/[A-F0-9]{8}/)) post "api/v1.0/auth/reset_password_code/#{user.id}" user.refresh - expect(user.reset_password).to match(/[A-F0-9]{10}/) + expect(user.reset_password).to match(/[A-F0-9]{8}/) expect(user.reset_password_expiration).to be_within(5.seconds).of(send_time + 30.minutes) expect(response.status).to eq(204) diff --git a/application/spec/api/users/reset_password_spec.rb b/application/spec/api/users/reset_password_spec.rb index f694f4e..4da3097 100644 --- a/application/spec/api/users/reset_password_spec.rb +++ b/application/spec/api/users/reset_password_spec.rb @@ -2,7 +2,7 @@ before { stub_const("ConfirmResetPasswordJob", double(perform_async: nil)) } let(:user) { create(:user, reset_password: code, reset_password_expiration: expiration) } let(:expiration) { Time.now + 30.minutes } - let(:code) { '0123456789' } + let(:code) { '12345678' } context 'when using valid attributes and code' do let(:body) do @@ -54,7 +54,7 @@ patch "api/v1.0/users/#{user.id}/reset_password", body expect(response).to be_unauthorized - expect(response_body).to include(error_type: 'unauthorized', errors: { reason: "Invalid code" }) + expect(response_body).to include(error_type: 'unauthorized') end end diff --git a/application/spec/api/users/update_spec.rb b/application/spec/api/users/update_spec.rb index f709de9..89f9777 100644 --- a/application/spec/api/users/update_spec.rb +++ b/application/spec/api/users/update_spec.rb @@ -6,7 +6,7 @@ put "api/v1.0/users/#{user.id}", {} expect(response).to be_forbidden - expect(response_body).to include(error_type: 'forbidden', errors: { reason: "Permission denied" }) + expect(response_body).to include(error_type: 'forbidden') end end @@ -66,7 +66,7 @@ put "api/v1.0/users/#{other_user.id}", {} expect(response).to be_forbidden - expect(response_body).to include(error_type: 'forbidden', errors: { reason: "Permission denied" }) + expect(response_body).to include(error_type: 'forbidden') end end end diff --git a/application/validators/reset_password_validator.rb b/application/validators/reset_password_validator.rb index b0be7ce..072da8f 100644 --- a/application/validators/reset_password_validator.rb +++ b/application/validators/reset_password_validator.rb @@ -1,8 +1,5 @@ class Api - class ResetPasswordValidator - include Hanami::Validations::Form - predicates FormPredicates - + class ResetPasswordValidator < Operation::Validator validations do required(:new_password).filled(:str?) required(:confirm_password).filled(:str?) diff --git a/application/validators/user_validator.rb b/application/validators/user_validator.rb index 8637404..ad1066b 100644 --- a/application/validators/user_validator.rb +++ b/application/validators/user_validator.rb @@ -1,8 +1,5 @@ class Api - class UserValidator - include Hanami::Validations::Form - predicates FormPredicates - + class UserValidator < Operation::Validator validations do required(:first_name).filled(:str?) required(:last_name).filled(:str?) From dd8b4db406c2b7365db98f848e4a4219b4c30dad Mon Sep 17 00:00:00 2001 From: Pablo Herrero Date: Sat, 1 Apr 2017 23:17:42 -0300 Subject: [PATCH 3/3] Update ruby version --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index d583ce6..0ab9892 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,4 @@ -ruby '2.3.3' +ruby '2.3.4' source 'https://rubygems.org'