diff --git a/.env.development.sample b/.env.development.sample index 7864698..d897257 100644 --- a/.env.development.sample +++ b/.env.development.sample @@ -3,3 +3,8 @@ 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/ +SYSTEM_EMAIL=support@sample.com +SITE_URL=http://localhost:3000/ +SMTP_SERVER=mail.example.com +MAIL_SMTP_USER=user@example.com +MAIL_SMTP_PASSWORD=example diff --git a/.env.test.sample b/.env.test.sample index 67326c8..83d91c5 100644 --- a/.env.test.sample +++ b/.env.test.sample @@ -1,5 +1,7 @@ -RACK_ENV=development +RACK_ENV=test 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/ +SMTP_SERVER=mail.example.com +MAIL_SMTP_USER=user@example.com +MAIL_SMTP_PASSWORD=example diff --git a/application/api.rb b/application/api.rb index 7ddc2d9..2667d04 100644 --- a/application/api.rb +++ b/application/api.rb @@ -31,10 +31,11 @@ class Api < Grape::API; end # load active support helpers require 'active_support' require 'active_support/core_ext' +require 'mail' # require all models Dir['./application/models/*.rb'].each { |rb| require rb } - +Dir['./application/validations/*.rb'].each { |rb| require rb } Dir['./application/api_helpers/**/*.rb'].each { |rb| require rb } class Api < Grape::API version 'v1.0', using: :path @@ -63,4 +64,17 @@ class Api < Grape::API add_swagger_documentation \ mount_path: '/docs' + + Mail.defaults do + delivery_method :smtp, { + address: SMTP_SERVER, + port: 465, + user_name: MAIL_SMTP_USER, + password: MAIL_SMTP_PASSWORD, + authentication: :login, + ssl: true, + tls: true, + enable_starttls_auto: true + } + end end diff --git a/application/api/users.rb b/application/api/users.rb index 40de636..64f2070 100644 --- a/application/api/users.rb +++ b/application/api/users.rb @@ -9,5 +9,57 @@ class Api data: users } end + + post do + result = CreateUserValidation.new(params).validate + if result.success? && user = Models::User.create(result.output) + mail = ::Mail.new do + from SYSTEM_EMAIL + to user.email + subject 'Your account has been created successfully' + body 'Need a nice body' + end.deliver! + Entities::User.represent(user) + else + error!(result.messages, 400) + end + end + + put ':id' do + authenticate! + user = Models::User[params[:id]] + + return error!('Unauthorized', 401) unless current_user.can?(:edit, user) + + result = EditUserValidation.new(params).validate + if result.success? + user.update(result.output) + Entities::User.represent(user) + else + error!(result.messages, 400) + end + end + + patch ':id/reset_password' do + authenticate! + user = Models::User[params[:id]] + + return error!('Unauthorized', 401) unless current_user.can?(:edit, user) + + result = ResetPasswordUserValidation.new(params).validate + + if result.success? + user.update(password: result.output[:new_password]) + mail = ::Mail.new do + from SYSTEM_EMAIL + to user.email + subject 'Your password has been reset successfully' + body 'Need a nice body' + end.deliver! + Entities::User.represent(user) + else + error!(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..eb11447 --- /dev/null +++ b/application/api_entities/user.rb @@ -0,0 +1,18 @@ +class Api + module Entities + class User < Grape::Entity + format_with(:iso_timestamp) { |dt| dt.iso8601 } + + expose :id + expose :first_name + expose :last_name + expose :email + + with_options(format_with: :iso_timestamp) do + expose :born_on, if: :born_on + expose :created_at + expose :updated_at + end + end + end +end diff --git a/application/api_helpers/auth.rb b/application/api_helpers/auth.rb index b46a936..1aa044e 100644 --- a/application/api_helpers/auth.rb +++ b/application/api_helpers/auth.rb @@ -8,11 +8,18 @@ module Auth module HelperMethods def authenticate! - # Library to authenticate user can go here + error!('Unauthorized. Invalid or expired token.', 401) unless set_current_user end - def current_user - @current_user + private + + def set_current_user + token = Api::Models::UserToken.first(access_token: headers['Token']) + if token && !token.expired? + Api.class_variable_set(:@@current_user, Api::Models::User[token.user_id]) + else + false + end end end end diff --git a/application/config/variables.rb b/application/config/variables.rb index 4916a70..b280f0d 100644 --- a/application/config/variables.rb +++ b/application/config/variables.rb @@ -20,6 +20,8 @@ SUPPORT_EMAIL = ENV.fetch('SUPPORT_EMAIL').freeze INTERNAL_EMAIL = ENV.fetch('INTERNAL_EMAIL').freeze DATABASE_URL = ENV.fetch('DATABASE_URL').freeze -MAIL_URL = ENV.fetch('MAIL_URL').freeze +SMTP_SERVER = ENV.fetch('SMTP_SERVER').freeze +MAIL_SMTP_USER = ENV.fetch('MAIL_SMTP_USER').freeze +MAIL_SMTP_PASSWORD = ENV.fetch('MAIL_SMTP_PASSWORD').freeze SYSTEM_EMAIL = ENV.fetch('SYSTEM_EMAIL').freeze SITE_URL = ENV.fetch('SITE_URL').freeze diff --git a/application/migrate/1488320988_add_user_tokens_table.rb b/application/migrate/1488320988_add_user_tokens_table.rb new file mode 100755 index 0000000..b4b64bf --- /dev/null +++ b/application/migrate/1488320988_add_user_tokens_table.rb @@ -0,0 +1,15 @@ +Sequel.migration do + change do + create_table(:user_tokens) do + primary_key :id + + String :access_token + DateTime :expires_at + Integer :user_id + Boolean :active + + index :user_id, unique: false + index :access_token, unique: true + end + end +end diff --git a/application/models/user.rb b/application/models/user.rb index 802a14b..0621807 100644 --- a/application/models/user.rb +++ b/application/models/user.rb @@ -5,6 +5,12 @@ module Models class User < Sequel::Model(:users) include AbilityList::Helpers + one_to_many :user_tokens + + def after_create + update_token! + end + def abilities @abilities ||= Abilities.new(self) end @@ -12,6 +18,18 @@ def abilities def full_name "#{self.first_name} #{self.last_name}" end + + def token + user_tokens.last.access_token + end + + def update_token! + user_tokens << UserToken.create(user_id: self.id) + end + + def password_confirmation=(pass) + nil + end end end end diff --git a/application/models/user_token.rb b/application/models/user_token.rb new file mode 100755 index 0000000..c5a6e2d --- /dev/null +++ b/application/models/user_token.rb @@ -0,0 +1,25 @@ +class Api + module Models + class UserToken < Sequel::Model(:user_tokens) + many_to_one :user + + def before_create + generate_access_token + set_expiration + end + + def expired? + DateTime.now >= self.expires_at + end + + private + def generate_access_token + self.access_token = SecureRandom.hex + end + + def set_expiration + self.expires_at = DateTime.now + 1.day + end + end + end +end diff --git a/application/spec/api/users/patch_spec.rb b/application/spec/api/users/patch_spec.rb new file mode 100644 index 0000000..516dbcb --- /dev/null +++ b/application/spec/api/users/patch_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe 'PATCH /api/users' do + before :all do + @user = create :user + password = Faker::Internet.password + @user_attributes = { + new_password: password, + new_password_confirmation: password + } + end + + it 'should update user password' do + header('token', @user.token) + patch("api/v1.0/users/#{@user.id}/reset_password", @user_attributes) + expect(last_response.status).to eq(200) + end + + it 'should NOT update user password, invalid token' do + header('token', @user.token) + @user2 = create :user + patch("api/v1.0/users/#{@user2.id}/reset_password", @user_attributes) + expect(last_response.status).to eq(401) + end + + it 'missing params, should NOT update user password' do + header('token', @user.token) + patch("api/v1.0/users/#{@user.id}/reset_password", {}) + body = response_body + expect(last_response.status).to eq(400) + expect(body.keys).to eq([:new_password]) + end + + it 'mismatch passwords, should NOT update user password' do + @user_attributes[:new_password] = Faker::Internet.password + header('token', @user.token) + patch("api/v1.0/users/#{@user.id}/reset_password", @user_attributes) + body = response_body + expect(last_response.status).to eq(400) + expect(body.keys).to eq([:new_password_confirmation]) + 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..b87cf53 --- /dev/null +++ b/application/spec/api/users/post_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe 'POST /api/users' do + before :all do + password = Faker::Internet.password + @user_attributes = { + first_name: Faker::Name.first_name, + last_name: Faker::Name.last_name, + email: Faker::Internet.email, + password: password, + password_confirmation: password, + born_on: Faker::Date.birthday.to_time + } + end + + it 'should create a new user' do + post('api/v1.0/users', @user_attributes) + expect(last_response.status).to eq(201) + end + + it 'should create a new user without born_on' do + @user_attributes.delete(:born_on) + post('api/v1.0/users', @user_attributes) + expect(last_response.status).to eq(201) + end + + it 'missing params, should NOT create a new user' do + post('api/v1.0/users', {}) + body = response_body + expect(last_response.status).to eq(400) + expect(body.keys).to eq([:first_name, :last_name, :email, :password]) + end + + it 'wrong born_on format, should NOT create a new user' do + @user_attributes[:born_on] = Faker::Date.birthday.to_s + post('api/v1.0/users', @user_attributes) + body = response_body + expect(last_response.status).to eq(400) + expect(body).to eq(born_on: ['must be in format YYYY-MM-DD HH:MM:SS']) + 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..d21eaac --- /dev/null +++ b/application/spec/api/users/put_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe 'PUT /api/users' do + before :all do + @user = create :user + @user_attributes = { + first_name: Faker::Name.first_name, + last_name: Faker::Name.last_name, + email: Faker::Internet.email, + born_on: Faker::Date.birthday.to_time + } + end + + it 'should update user attributes' do + header('token', @user.token) + put("api/v1.0/users/#{@user.id}", @user_attributes) + expect(last_response.status).to eq(200) + end + + it 'should NOT update user attributes, invalid token' do + header('token', @user.token) + @user2 = create :user + put("api/v1.0/users/#{@user2.id}", @user_attributes) + expect(last_response.status).to eq(401) + end + + it 'missing params, should NOT update user attributes' do + header('token', @user.token) + put("api/v1.0/users/#{@user.id}", {}) + body = response_body + expect(last_response.status).to eq(400) + expect(body.keys).to eq([:first_name, :last_name, :email]) + end + + it 'wrong born_on format, should NOT update user attributes' do + @user_attributes[:born_on] = Faker::Date.birthday.to_s + header('token', @user.token) + put("api/v1.0/users/#{@user.id}", @user_attributes) + body = response_body + expect(last_response.status).to eq(400) + expect(body).to eq(born_on: ['must be in format YYYY-MM-DD HH:MM:SS']) + end +end diff --git a/application/spec/spec_helper.rb b/application/spec/spec_helper.rb index 4b54421..bdefb7c 100644 --- a/application/spec/spec_helper.rb +++ b/application/spec/spec_helper.rb @@ -5,6 +5,10 @@ require 'faker' require 'factory_girl' +Mail.defaults do + delivery_method :test +end + # Load up all application files that we'll be testing in the suites Dir['./application/models/**/*.rb'].sort.each { |rb| require rb } diff --git a/application/validations/create_user_validation.rb b/application/validations/create_user_validation.rb new file mode 100644 index 0000000..953a578 --- /dev/null +++ b/application/validations/create_user_validation.rb @@ -0,0 +1,12 @@ +class CreateUserValidation + include Hanami::Validations::Form + predicates FormPredicates + + validations do + required(:first_name).filled(:str?) + required(:last_name).filled(:str?) + required(:email).filled(:str?, :email?) + required(:password).filled.confirmation + optional(:born_on).filled(:datetime_str?) + end +end diff --git a/application/validations/edit_user_validation.rb b/application/validations/edit_user_validation.rb new file mode 100644 index 0000000..689c906 --- /dev/null +++ b/application/validations/edit_user_validation.rb @@ -0,0 +1,11 @@ +class EditUserValidation + include Hanami::Validations::Form + predicates FormPredicates + + validations do + required(:first_name).maybe(:str?) + required(:last_name).maybe(:str?) + required(:email).maybe(:str?, :email?) + optional(:born_on).maybe(:datetime_str?) + end +end diff --git a/application/validations/reset_password_user_validation.rb b/application/validations/reset_password_user_validation.rb new file mode 100644 index 0000000..b5e1f43 --- /dev/null +++ b/application/validations/reset_password_user_validation.rb @@ -0,0 +1,7 @@ +class ResetPasswordUserValidation + include Hanami::Validations::Form + + validations do + required(:new_password).filled.confirmation + end +end