diff --git a/.ruby-version b/.ruby-version index 2078687..c1026d2 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-2.3-head +ruby-2.3.1 diff --git a/Gemfile b/Gemfile index d96735c..82d12eb 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,7 @@ source 'https://rubygems.org' gem 'rails', '4.2.6' gem 'pg', '~> 0.15' +gem 'sprockets', '3.6.3' gem 'sass-rails', '~> 5.0' gem 'uglifier', '>= 1.3.0' gem 'coffee-rails', '~> 4.1.0' @@ -22,6 +23,9 @@ gem 'private_pub' gem 'skim' gem 'gon' gem 'responders' +gem 'omniauth' +gem 'omniauth-facebook' +gem 'omniauth-twitter' # gem 'unicorn' gem 'thin' @@ -43,6 +47,8 @@ group :test do gem 'capybara-webkit' end +gem 'capybara-email' + group :development do gem 'web-console', '~> 2.0' gem 'pry' @@ -57,4 +63,6 @@ group :development do gem 'rails_best_practices' gem 'spring' + + gem 'letter_opener' end diff --git a/Gemfile.lock b/Gemfile.lock index 5730254..81b80b3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -39,7 +39,7 @@ GEM addressable (2.4.0) arel (6.0.3) ast (2.3.0) - autoprefixer-rails (6.3.7) + autoprefixer-rails (6.4.0.3) execjs bcrypt (3.1.11) better_errors (2.1.1) @@ -61,6 +61,9 @@ GEM rack (>= 1.0.0) rack-test (>= 0.5.4) xpath (~> 2.0) + capybara-email (2.5.0) + capybara (~> 2.4) + mail capybara-webkit (1.11.1) capybara (>= 2.3.0, < 2.8.0) json @@ -71,7 +74,7 @@ GEM mime-types (>= 1.16) mimemagic (>= 0.3.0) cocoon (1.2.9) - code_analyzer (0.4.5) + code_analyzer (0.4.7) sexp_processor coderay (1.1.1) coffee-rails (4.1.1) @@ -83,7 +86,7 @@ GEM coffee-script-source (1.10.0) concurrent-ruby (1.0.2) cookiejar (0.3.3) - daemons (1.2.3) + daemons (1.2.4) database_cleaner (1.5.3) debug_inspector (0.0.2) devise (4.2.0) @@ -111,6 +114,8 @@ GEM railties (>= 3.0.0) faker (1.6.6) i18n (~> 0.5) + faraday (0.9.2) + multipart-post (>= 1.2, < 3) faye (1.2.2) cookiejar (>= 0.3.0) em-http-request (>= 0.3.0) @@ -142,22 +147,26 @@ GEM shellany (~> 0.0) thor (>= 0.18.1) guard-compat (1.2.1) - guard-rspec (4.7.2) + guard-rspec (4.7.3) guard (~> 2.1) guard-compat (~> 1.1) rspec (>= 2.99.0, < 4.0) + hashie (3.4.4) http_parser.rb (0.6.0) i18n (0.7.0) jbuilder (2.6.0) activesupport (>= 3.0.0, < 5.1) multi_json (~> 1.2) - jquery-rails (4.1.1) + jquery-rails (4.2.1) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) json (1.8.3) + jwt (1.5.4) 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) @@ -175,17 +184,40 @@ GEM mime-types (3.1) mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) - mimemagic (0.3.1) + mimemagic (0.3.2) mini_portile2 (2.1.0) minitest (5.9.0) multi_json (1.12.1) + multi_xml (0.5.5) + multipart-post (2.0.0) nenv (0.3.0) nokogiri (1.6.8) mini_portile2 (~> 2.1.0) pkg-config (~> 1.1.7) - notiffany (0.1.0) + notiffany (0.1.1) nenv (~> 0.1) shellany (~> 0.0) + oauth (0.5.1) + oauth2 (1.2.0) + faraday (>= 0.8, < 0.10) + jwt (~> 1.0) + multi_json (~> 1.3) + multi_xml (~> 0.5) + rack (>= 1.2, < 3) + omniauth (1.3.1) + hashie (>= 1.2, < 4) + rack (>= 1.0, < 3) + omniauth-facebook (4.0.0) + omniauth-oauth2 (~> 1.2) + omniauth-oauth (1.1.0) + oauth + omniauth (~> 1.0) + omniauth-oauth2 (1.4.0) + oauth2 (~> 1.0) + omniauth (~> 1.2) + omniauth-twitter (1.2.1) + json (~> 1.3) + omniauth-oauth (~> 1.1) orm_adapter (0.5.0) parser (2.3.1.2) ast (~> 2.2) @@ -253,13 +285,13 @@ GEM remotipart (1.2.1) request_store (1.3.1) require_all (1.3.3) - responders (2.2.0) + responders (2.3.0) railties (>= 4.2.0, < 5.1) rspec (3.5.0) rspec-core (~> 3.5.0) rspec-expectations (~> 3.5.0) rspec-mocks (~> 3.5.0) - rspec-core (3.5.1) + rspec-core (3.5.2) rspec-support (~> 3.5.0) rspec-expectations (3.5.0) diff-lcs (>= 1.2.0, < 2.0) @@ -267,7 +299,7 @@ GEM rspec-mocks (3.5.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.5.0) - rspec-rails (3.5.1) + rspec-rails (3.5.2) actionpack (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) @@ -283,7 +315,7 @@ GEM ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) ruby-progressbar (1.8.1) - ruby_dep (1.3.1) + ruby_dep (1.4.0) sass (3.4.22) sass-rails (5.0.6) railties (>= 4.0.0, < 6) @@ -314,7 +346,7 @@ GEM spring (1.7.2) spring-commands-rspec (1.0.4) spring (>= 0.9.1) - sprockets (3.7.0) + sprockets (3.6.3) concurrent-ruby (~> 1.0) rack (> 1, < 3) sprockets-rails (3.1.1) @@ -329,12 +361,12 @@ GEM thor (0.19.1) thread_safe (0.3.5) tilt (2.0.5) - turbolinks (5.0.0) + turbolinks (5.0.1) turbolinks-source (~> 5) turbolinks-source (5.0.0) tzinfo (1.2.2) thread_safe (~> 0.1) - uglifier (3.0.0) + uglifier (3.0.2) execjs (>= 0.3.0, < 3) unicode-display_width (1.1.0) warden (1.2.6) @@ -359,6 +391,7 @@ DEPENDENCIES bootstrap-sass (~> 3.3.6) byebug (~> 8.0) capybara + capybara-email capybara-webkit carrierwave cocoon @@ -372,7 +405,11 @@ DEPENDENCIES jbuilder (~> 2.0) jquery-rails launchy + letter_opener meta_request + omniauth + omniauth-facebook + omniauth-twitter pg (~> 0.15) private_pub pry @@ -393,6 +430,7 @@ DEPENDENCIES slim-rails spring spring-commands-rspec + sprockets (= 3.6.3) thin turbolinks uglifier (>= 1.3.0) diff --git a/app/controllers/account_controller.rb b/app/controllers/account_controller.rb new file mode 100644 index 0000000..6d8c034 --- /dev/null +++ b/app/controllers/account_controller.rb @@ -0,0 +1,47 @@ +class AccountController < ApplicationController + before_action :check_already_signed_in, only: [:confirm_email] + before_action :check_oauth_in_session, only: [:confirm_email] + + def confirm_email + if request.patch? + email = nil + email = params[:user][:email] if params[:user] && params[:user][:email] + user = User.where(email: email).first + provider = session['devise.oauth_provider'] + if user.present? + set_flash :alert, 'Email already taken.' + else + User.transaction do + @user = User.create(email: email, password: Devise.friendly_token[0, 20]) + if @user.persisted? + @user.identities.create!({ + provider: provider, + uid: session['devise.oauth_uid'].to_s}) + end + end + if @user.persisted? + set_flash :notice, t('devise.omniauth_callbacks.success', kind: provider.to_s.capitalize) + sign_in_and_redirect @user, event: :authentication + end + end + end + end + + private + + def check_already_signed_in + redirect_to questions_path if signed_in? + end + + def check_oauth_in_session + if !session['devise.oauth_provider'] && !session['devise.oauth_uid'] + set_flash :notice, 'Registration done' + redirect_to questions_path + elsif !session['devise.oauth_provider'] || !session['devise.oauth_uid'] + session['devise.oauth_provider'] = nil + session['devise.oauth_uid'] = nil + set_flash :alert, 'Authentication error, try again' + redirect_to new_user_session_path + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1b8dc55..9056e36 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,6 +10,12 @@ class ApplicationController < ActionController::Base before_action :set_js_current_user + protected + + def set_flash(scope, message) + flash[scope] = message if is_navigational_format? + end + private def set_js_current_user diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb new file mode 100644 index 0000000..5fc32bf --- /dev/null +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -0,0 +1,43 @@ +class OmniauthCallbacksController < Devise::OmniauthCallbacksController + before_action :oauth + + def facebook + end + + def twitter + end + + private + + def oauth + email = nil + email = params[:user][:email] if params[:user] && params[:user][:email] + auth = request.env['omniauth.auth'] || OmniAuth::AuthHash.new( + provider: session['oauth_provider'], + uid: session['oauth_uid'], + info: {email: email}) + if auth + @user = User.find_for_oauth(auth) + if @user.nil? && auth && auth.provider && auth.uid + store_auth auth + redirect_to account_confirm_email_path + elsif @user&.persisted? + set_flash_message :notice, :success, kind: auth.provider.to_s.capitalize if is_navigational_format? + sign_in_and_redirect @user, event: :authentication + else + failure_redirect + end + else + failure_redirect + end + end + + def store_auth(auth) + session['devise.oauth_provider'] = auth.provider + session['devise.oauth_uid'] = auth.uid + end + + def failure_redirect + redirect_to new_user_registration_path, alert: 'Authentication failure' + end +end diff --git a/app/models/identity.rb b/app/models/identity.rb new file mode 100644 index 0000000..0937cd3 --- /dev/null +++ b/app/models/identity.rb @@ -0,0 +1,6 @@ +class Identity < ActiveRecord::Base + belongs_to :user + + validates :user_id, :provider, presence: true + validates :uid, presence: true, uniqueness: {scope: :provider} +end diff --git a/app/models/user.rb b/app/models/user.rb index 3aab7c2..545e96a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,9 +2,34 @@ class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :trackable, :validatable + :recoverable, :rememberable, :trackable, :validatable, + :confirmable, + :omniauthable, omniauth_providers: [:facebook, :twitter] + has_many :identities, dependent: :destroy has_many :questions, dependent: :destroy has_many :answers, dependent: :destroy has_many :comments, dependent: :destroy + + class << self + def find_for_oauth(auth) + return nil unless auth + + identity = Identity.where(provider: auth.provider, uid: auth.uid.to_s).first + return identity.user if identity + + email = auth.info[:email] if auth.info + return nil unless email + + user = User.where(email: email).first + unless user + password = Devise.friendly_token(20) + user = User.new(email: email, password: password, password_confirmation: password) + user.save + end + + user.identities.create(provider: auth.provider, uid: auth.uid) if user + user + end + end end diff --git a/app/views/account/confirm_email.slim b/app/views/account/confirm_email.slim new file mode 100644 index 0000000..f5bb479 --- /dev/null +++ b/app/views/account/confirm_email.slim @@ -0,0 +1,15 @@ +h2 Enter your email to proceed + +.row + .col-sm-12 + = render 'shared/form_errors', object: @user + +.row + .col-sm-12 + = form_for :user, url: account_confirm_email_path, method: :patch do |f| + .form-group + = f.label :email + = f.email_field :email, class: 'form-control', placeholder: 'enter@email.here' + /= email_field_tag :email, params[:email], class: 'form-control', placeholder: 'enter@email.here' + .form-group + = f.submit 'Validate Email', class: 'btn btn-primary pull-right' diff --git a/app/views/shared/_form_errors.slim b/app/views/shared/_form_errors.slim index 4df8c99..dc05cf1 100644 --- a/app/views/shared/_form_errors.slim +++ b/app/views/shared/_form_errors.slim @@ -1,4 +1,4 @@ -- if object.errors.any? +- if object&.errors&.any? #error_explanation .alert.alert-danger p.lead Form has an error(s): diff --git a/bin/comet b/bin/comet index e600183..4053693 100755 --- a/bin/comet +++ b/bin/comet @@ -1,5 +1,5 @@ #!/bin/sh #rackup private_pub.ru -s thin -E development -rackup private_pub.ru -s thin -E production +rackup -D private_pub.ru -s thin -E production diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..46f7575 --- /dev/null +++ b/circle.yml @@ -0,0 +1,11 @@ +machine: + timezone: + Europe/Moscow + ruby: + version: 2.3.0 + +dependencies: + pre: + - gem install bundler --pre + post: + - bundle exec ./bin/comet diff --git a/config/environments/development.rb b/config/environments/development.rb index b2a1347..c469ec5 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -15,7 +15,8 @@ config.action_mailer.perform_deliveries = true config.action_mailer.raise_delivery_errors = true - config.action_mailer.delivery_method = :sendmail + # config.action_mailer.delivery_method = :sendmail + config.action_mailer.delivery_method = :letter_opener config.action_mailer.default_options = { from: 'e@varnakov.ru' } # Print deprecation notices to the Rails logger. diff --git a/config/environments/test.rb b/config/environments/test.rb index 1c19f08..aecc6eb 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -30,6 +30,8 @@ # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test + config.action_mailer.perform_deliveries = true + config.action_mailer.default_url_options = { host: 'localhost', port: 3001 } # Randomize the order test cases are executed. config.active_support.test_order = :random @@ -39,4 +41,10 @@ # Raises error for missing translations # config.action_view.raise_on_missing_translations = true + + # Enable stdout logger + config.logger = Logger.new(STDOUT) + + # Set log level + config.log_level = :DEBUG end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 09f665c..bc19a1e 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -12,13 +12,13 @@ # Configure the e-mail address which will be shown in Devise::Mailer, # note that it will be overwritten if you use your own mailer class # with default "from" parameter. - config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com' + config.mailer_sender = 'e@varnakov.ru' # Configure the class responsible to send e-mails. - # config.mailer = 'Devise::Mailer' + config.mailer = 'Devise::Mailer' # Configure the parent class responsible to send e-mails. - # config.parent_mailer = 'ActionMailer::Base' + config.parent_mailer = 'ActionMailer::Base' # ==> ORM configuration # Load and configure the ORM. Supports :active_record (default) and @@ -114,6 +114,7 @@ # access will be blocked just in the third day. Default is 0.days, meaning # the user cannot access the website without confirming their account. # config.allow_unconfirmed_access_for = 2.days + config.allow_unconfirmed_access_for = 2.days if Rails.env.test? # A period that the user is allowed to confirm their account before their # token becomes invalid. For example, if set to 3.days, the user can confirm @@ -242,6 +243,10 @@ # Add a new OmniAuth provider. Check the wiki for more information on setting # up on your models and hooks. # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' + config.omniauth :facebook, Rails.application.secrets.facebook_app_id, + Rails.application.secrets.facebook_app_secret, scope: [:email] + config.omniauth :twitter, Rails.application.secrets.twitter_app_id, + Rails.application.secrets.twitter_app_secret # ==> Warden configuration # If you want to use other strategies, that are not supported by Devise, or diff --git a/config/routes.rb b/config/routes.rb index ba20d7a..0b311d3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,8 @@ Rails.application.routes.draw do root 'questions#index' - devise_for :users + devise_for :users, controllers: { omniauth_callbacks: :omniauth_callbacks} + match 'account/confirm_email', via: [:get, :patch] concern :rateable do member do diff --git a/config/secrets.yml b/config/secrets.yml index ff4b40e..bae4c49 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -12,6 +12,10 @@ development: secret_key_base: b95a307aeef7b76a6805c4f196f9f228bd8b7e40d78dcae5b003e55b851b299189b273e11148c542a8a5e06b44c1b97fcabb2922a1a02e515e498998a228766f + facebook_app_id: 1111783555576872 + facebook_app_secret: cc941c1c69c4df0cd160e2bca79fce00 + twitter_app_id: 1BeipKxAypv1y4DDbzLN30kja + twitter_app_secret: tAJ0d57JXZpyITrYy8f7kUvlFpcghzav4KwZBthTv2WfKX7IUH test: secret_key_base: 409050a6e334ef505b4403e78afbe40e18007505904d27227ce6302b311a1b2084c3f0028f4a06fa86a718e7e4846e492a20d26f768c7c9672828e9c5fef391b diff --git a/db/migrate/20160822233027_create_identities.rb b/db/migrate/20160822233027_create_identities.rb new file mode 100644 index 0000000..2634a1a --- /dev/null +++ b/db/migrate/20160822233027_create_identities.rb @@ -0,0 +1,13 @@ +class CreateIdentities < ActiveRecord::Migration + def change + create_table :identities do |t| + t.references :user, index: true, foreign_key: true + t.string :provider, null: false + t.string :uid, null: false + + t.timestamps null: false + end + + add_index :identities, [:provider, :uid] + end +end diff --git a/db/migrate/20160829190531_add_confirmable_to_devise.rb b/db/migrate/20160829190531_add_confirmable_to_devise.rb new file mode 100644 index 0000000..f94d2a6 --- /dev/null +++ b/db/migrate/20160829190531_add_confirmable_to_devise.rb @@ -0,0 +1,18 @@ +class AddConfirmableToDevise < ActiveRecord::Migration + def up + add_column :users, :confirmation_token, :string + add_column :users, :confirmed_at, :datetime + add_column :users, :confirmation_sent_at, :datetime + add_column :users, :unconfirmed_email, :string + + add_index :users, :confirmation_token, unique: true + + User.all.update_all confirmed_at: Time.now + end + + def down + remove_index :users, :confirmation_token + + remove_columns :users, :confirmation_token, :confirmed_at, :confirmation_sent_at, :unconfirmed_email + end +end diff --git a/db/schema.rb b/db/schema.rb index 42a97fd..de4641d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160806125740) do +ActiveRecord::Schema.define(version: 20160829190531) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -48,6 +48,17 @@ t.datetime "updated_at", null: false end + create_table "identities", force: :cascade do |t| + t.integer "user_id" + t.string "provider", null: false + t.string "uid", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "identities", ["provider", "uid"], name: "index_identities_on_provider_and_uid", using: :btree + add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree + create_table "questions", force: :cascade do |t| t.string "topic", limit: 200, null: false t.text "body", null: false @@ -84,14 +95,20 @@ t.datetime "last_sign_in_at" t.inet "current_sign_in_ip" t.inet "last_sign_in_ip" + t.string "confirmation_token" + t.datetime "confirmed_at" + t.datetime "confirmation_sent_at" + t.string "unconfirmed_email" end + add_index "users", ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree add_foreign_key "answers", "questions" add_foreign_key "answers", "users" add_foreign_key "comments", "users" + add_foreign_key "identities", "users" add_foreign_key "questions", "users" add_foreign_key "ratings", "users" end diff --git a/spec/controllers/account_controller_spec.rb b/spec/controllers/account_controller_spec.rb new file mode 100644 index 0000000..40ede5c --- /dev/null +++ b/spec/controllers/account_controller_spec.rb @@ -0,0 +1,130 @@ +require 'rails_helper' + +describe AccountController do + let!(:user) { create(:user) } + + describe 'GET #confirm_email' do + subject { get :confirm_email } + + context 'as unauthenticated user' do + context 'with valid OAuth data in session' do + before do + session['devise.oauth_provider'] = generate(:provider) + session['devise.oauth_uid'] = generate(:uid) + end + + it { is_expected.to have_http_status :success } + it { is_expected.to render_template :confirm_email } + end + + context 'with invalid OAuth data in session' do + before do + session['devise.oauth_provider'] = generate(:provider) + session['devise.uid'] = nil + end + + it { is_expected.to have_http_status :redirect } + it { is_expected.to redirect_to new_user_session_path } + end + + context 'with no OAuth data in session' do + it { is_expected.to have_http_status :redirect } + it { is_expected.to redirect_to questions_path } + end + end + + context 'as authenticated user' do + sign_in_user + + before { get :confirm_email } + + it { is_expected.to have_http_status :redirect } + it { is_expected.to redirect_to questions_path } + end + end + + describe 'PATCH #confirm_email' do + let(:email) { generate(:email) } + subject (:confirm_email) { patch :confirm_email, user: {email: email} } + + context 'as unauthenticated user' do + context 'with valid OAuth data in session' do + before do + session['devise.oauth_provider'] = generate(:provider) + session['devise.oauth_uid'] = generate(:uid) + end + + context 'with correct email' do + it { is_expected.to have_http_status :redirect } + + it 'should assign User object to @user' do + confirm_email + expect(assigns(:user)).to be_a User + end + + context 'for new user' do + it 'should create new User instance' do + expect { confirm_email }.to change(User, :count).by(1) + end + + it 'should create new Identity for the User' do + expect { confirm_email }.to change(Identity, :count).by(1) + end + + it 'should set given email for new User' do + confirm_email + expect(assigns(:user).email).to eq email + end + end + + context 'for existing user' do + subject(:confirm_email) { patch :confirm_email, email: user.email } + + it 'shouldn\'t create new User instance' do + expect { confirm_email }.not_to change(User, :count) + end + + it 'shouldn\'t create new Identitities' do + expect { confirm_email }.not_to change(Identity, :count) + end + end + end + + context 'with invalid (empty) email' do + subject(:confirm_email) { patch :confirm_email } + + it { is_expected.to have_http_status :success } + it { is_expected.to render_template :confirm_email } + + it 'shouldn\'t create new Identitities' do + expect { confirm_email }.not_to change(Identity, :count) + end + end + end + + context 'with invalid OAuth data in session' do + before do + session['devise.oauth_provider'] = generate(:provider) + session['devise.uid'] = nil + end + + it { is_expected.to have_http_status :redirect } + it { is_expected.to redirect_to new_user_session_path } + end + + context 'with no OAuth data in session' do + it { is_expected.to have_http_status :redirect } + it { is_expected.to redirect_to questions_path } + end + end + + context 'as authenticated user' do + sign_in_user + + before { get :confirm_email } + + it { is_expected.to have_http_status :redirect } + it { is_expected.to redirect_to questions_path } + end + end +end diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb new file mode 100644 index 0000000..9f4e5ff --- /dev/null +++ b/spec/controllers/omniauth_callbacks_controller_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +shared_examples :oauth_callback do + let(:user) { create(:user) } + before { request.env['devise.mapping'] = Devise.mappings[:user] } + + + context 'empty request' do + subject { get provider } + it { is_expected.to redirect_to new_user_registration_path } + end + + context 'email not given' do + before do + request.env['omniauth.auth'] = OmniAuth::AuthHash.new(provider: provider.to_s, uid: '123456', info: {email: nil}) + get provider + end + + it 'stores data in session' do + expect(session['devise.oauth_provider']).to eq provider.to_s + expect(session['devise.oauth_uid']).to eq '123456' + end + + it { should_not be_user_signed_in } + it { expect(response).to redirect_to account_confirm_email_path } + end + + context 'user does not exist' do + before do + request.env['omniauth.auth'] = OmniAuth::AuthHash.new(provider: provider.to_s, uid: '123456', info: {email: 'new-user@email.com'}) + get provider + end + + it 'assigns user to @user' do + expect(assigns(:user)).to be_a(User) + end + end + + context 'nulled credentials' do + before do + request.env['omniauth.auth'] = OmniAuth::AuthHash.new(provider: nil, uid: nil) + get provider + end + + it 'redirects to registration' do + expect(response).to redirect_to new_user_registration_path + end + end +end + +describe OmniauthCallbacksController do + [:facebook, :twitter].each do |provider| + describe "GET ##{provider}" do + include_examples :oauth_callback do + let(:provider) { provider } + end + end + end +end diff --git a/spec/factories/identities.rb b/spec/factories/identities.rb new file mode 100644 index 0000000..9da8a38 --- /dev/null +++ b/spec/factories/identities.rb @@ -0,0 +1,21 @@ +FactoryGirl.define do + sequence :uid do |n| + "#{Time.now.to_i}-#{Faker::Lorem.word}-#{n}" + end + + sequence :provider do |n| + "deeprec-provider-#{n}" + end + + factory :identity do + user + provider + uid + end + + factory :invalid_identity, class: 'Identity' do + user nil + provider nil + uid nil + end +end diff --git a/spec/features/confirm_oauth_registration_spec.rb b/spec/features/confirm_oauth_registration_spec.rb new file mode 100644 index 0000000..708a85e --- /dev/null +++ b/spec/features/confirm_oauth_registration_spec.rb @@ -0,0 +1,43 @@ +require 'features_helper' + +shared_examples :oauth_registration_confirm do + let(:email) { '1234@ab536356c.com' } + + background do + clear_emails + mock_auth_hash(provider, info: nil) + visit new_user_session_path + click_on "Sign in with #{provider.to_s.capitalize}" + sleep 1 + fill_in 'Email', with: email + click_on "Validate Email" + sleep 1 + save_and_open_page + puts '--------------------------------' + p ActionMailer::Base.deliveries + p all_emails + p current_email + open_email "test@example.com" + end + + # scenario 'check email' do + # current_email.click_link 'Confirm my account' + # expect(page).to have_content 'Your email address has been successfully confirmed' + # end +end + +feature 'Confirm OAuth registration', %q( + To became regular user + As a guest having Facebook or Twitter account + I want to set and confirm my email +) do + + [:facebook, :twitter].each do |prov| + context "Logging in with #{prov.to_s.capitalize} OAuth" do + given!(:provider) { prov } + # given!(:identity) { create(:identity, provider: provider) } + + it_behaves_like :oauth_registration_confirm + end + end +end diff --git a/spec/features/devise_email_spec.rb b/spec/features/devise_email_spec.rb new file mode 100644 index 0000000..20ec06a --- /dev/null +++ b/spec/features/devise_email_spec.rb @@ -0,0 +1,26 @@ +require 'features_helper' + +feature 'Check devise confirmation emails works' do + background do + # will clear the message queue + clear_emails + p Devise::Mailer.logger + p Devise::Mailer.perform_deliveries + p Devise::Mailer.delivery_method + + User.create(email: 'test@example.com', password: '123456') + # Will find an email sent to test@example.com + # and set `current_email` + sleep 1 + p ActionMailer::Base.deliveries + p all_emails + open_email('test@example.com') + p current_email + end + + scenario 'following a link' do + p all_emails + # current_email.click_link 'your profile' + # expect(page).to have_content 'Profile page' + end +end diff --git a/spec/features/sign_in_oauth_spec.rb b/spec/features/sign_in_oauth_spec.rb new file mode 100644 index 0000000..0d539bd --- /dev/null +++ b/spec/features/sign_in_oauth_spec.rb @@ -0,0 +1,118 @@ +require 'features_helper' + +shared_examples :oauth_sign_in do + given(:user) { create(:user) } + given(:provider_name) { provider.to_s.capitalize } + + let(:link_text) { "Sign in with #{provider_name}" } + let(:success_auth_text) { "GLYPH:info-sign Successfully authenticated from #{provider_name} account." } + + subject(:sign_in_with_oauth) do + visit new_user_session_path + expect(page).to have_link link_text + click_link link_text + end + + context 'when email is sent by OAuth provider' do + scenario 'when User and Identity are new' do + mock_auth_hash(provider, {info: {email: user.email}}) + sign_in_with_oauth + + expect(page).to have_content success_auth_text + end + + scenario 'when User with given email is already registered' do + mock_auth_hash(identity.provider, info: {email: user.email}) + sign_in_with_oauth + + expect(page).to have_content success_auth_text + expect(page).not_to have_content 'Enter your email to proceed' + end + + scenario 'when Identity is already registered with given email' do + mock_auth_hash(identity.provider, uid: identity.uid, info: {email: identity.user.email}) + sign_in_with_oauth + + expect(page).to have_content success_auth_text + end + end + + context 'when no email is sent by OAuth provider' do + context 'when User and Identity are new' do + before do + mock_auth_hash(provider, info: nil) + sign_in_with_oauth + + expect(page).not_to have_content success_auth_text + expect(page).to have_content 'Enter your email to proceed' + expect(page).to have_button 'Validate Email' + end + + scenario 'with new valid email' do + fill_in 'Email', with: generate(:email) + click_on 'Validate Email' + + expect(page).to have_content success_auth_text + end + + scenario 'with email that already exists' do + fill_in 'Email', with: user.email + click_on 'Validate Email' + + expect(page).not_to have_content success_auth_text + expect(page).to have_content 'GLYPH:alert Email already taken' + end + + scenario 'with empty email' do + click_on 'Validate Email' + + expect(page).not_to have_content success_auth_text + expect(page).to have_content 'Form has an error(s)' + expect(page).to have_content 'Email can\'t be blank' + end + + scenario 'with invalid email' do + fill_in 'Email', with: 'this_is_really_not_an_email' + click_on 'Validate Email' + + expect(page).not_to have_content success_auth_text + expect(page).to have_content 'Form has an error(s)' + expect(page).to have_content 'Email is invalid' + end + end + end + + scenario 'with invalid credentials' do + OmniAuth.config.mock_auth[provider] = :invalid_credentials + sign_in_with_oauth + + expect(page).not_to have_content success_auth_text + expect(page).to have_content 'GLYPH:alert Authentication failure' + end + + scenario 'when User already logged in' do + sign_in user + + visit new_user_session_path + + expect(page).not_to have_content success_auth_text + expect(page).not_to have_link link_text + expect(page).to have_content 'GLYPH:alert You are already signed in.' + end +end + +feature 'Signing in using OAuth', %q( + To ask questions + As a user + I want to sign in using Facebook or Twitter +) do + + [:facebook, :twitter].each do |prov| + context "Logging in with #{prov.to_s.capitalize} OAuth" do + given!(:provider) { prov } + given!(:identity) { create(:identity, provider: provider) } + + it_behaves_like :oauth_sign_in + end + end +end diff --git a/spec/features_helper.rb b/spec/features_helper.rb index 1302751..5fada1b 100644 --- a/spec/features_helper.rb +++ b/spec/features_helper.rb @@ -2,6 +2,8 @@ require 'tilt/coffee' RSpec.configure do |config| + config.include(OmniauthMacros) + Capybara.javascript_driver = :webkit Capybara.default_max_wait_time = 10 @@ -9,6 +11,9 @@ config.block_unknown_urls end + Capybara.server_port = 3001 + Capybara.app_host = 'http://localhost:3001' + config.include FeaturesMacros, type: :feature config.use_transactional_fixtures = false @@ -33,3 +38,5 @@ DatabaseCleaner.clean end end + +OmniAuth.config.test_mode = true diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb new file mode 100644 index 0000000..e065c24 --- /dev/null +++ b/spec/models/identity_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +describe Identity do + it { is_expected.to belong_to :user } + + it { is_expected.to validate_presence_of :user_id } + it { is_expected.to validate_presence_of :uid } + it { is_expected.to validate_presence_of :provider } + + describe 'should validate uniqueness of uid for the provider' do + subject { create(:identity) } + it { is_expected.to validate_uniqueness_of(:uid).scoped_to(:provider) } + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 25718f3..4da733a 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,9 +1,79 @@ require 'rails_helper' RSpec.describe User do + it { is_expected.to have_many(:identities).dependent(:destroy) } it { is_expected.to have_many(:questions).dependent(:destroy) } it { is_expected.to have_many(:answers).dependent(:destroy) } + it { is_expected.to have_many(:comments).dependent(:destroy) } it { is_expected.to validate_presence_of :email } it { is_expected.to validate_presence_of :password } + + describe '.find_for_oauth' do + let!(:user) { create(:user) } + let(:auth) { OmniAuth::AuthHash.new(provider: 'facebook', uid: '42') } + + context 'User\'s Identity already exists' do + it 'returns valid user' do + user.identities.create(provider: 'facebook', uid: '42') + expect(User.find_for_oauth(auth)).to eq user + end + end + + context 'User\'s Identity doesn\'t exists' do + context 'User elready exists' do + let(:auth) { OmniAuth::AuthHash.new(provider: 'facebook', uid: '42', + info: {email: user.email}) } + + it 'doesn\'t create new User' do + expect { User.find_for_oauth(auth) }.to_not change(User, :count) + end + + it 'creates Identity for the User' do + expect { User.find_for_oauth(auth) }.to change(user.identities, :count).by(1) + end + + it 'creates Identity with provider and uid' do + identity = User.find_for_oauth(auth).identities.first + + expect(identity.provider).to eq auth.provider + expect(identity.uid).to eq auth.uid + end + + it 'returns User object' do + expect(User.find_for_oauth(auth)).to eq user + end + end + + context 'User doesn\'t exists' do + let(:auth) { OmniAuth::AuthHash.new(provider: 'facebook', uid: '42', + info: {email: 'notexistent@user.com'}) } + + it 'creates new User' do + expect { User.find_for_oauth(auth) }.to change(User, :count).by(1) + end + + it 'returns new User' do + expect(User.find_for_oauth(auth)).to be_a(User) + end + + it 'fills User\'s email' do + user = User.find_for_oauth(auth) + expect(user.email).to eq auth.info.email + end + + it 'creates Identity for the User' do + user = User.find_for_oauth(auth) + expect(user.identities).to_not be_empty + end + + it 'creates Identity with provider and uid' do + identity = User.find_for_oauth(auth).identities.first + + expect(identity.provider).to eq auth.provider + expect(identity.uid).to eq auth.uid + end + end + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 8358237..bf5e6b8 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -6,6 +6,7 @@ require 'spec_helper' require 'rspec/rails' # Add additional requires below this line. Rails is not loaded until this point! +require 'capybara/email/rspec' # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are diff --git a/spec/support/omniauth_macros.rb b/spec/support/omniauth_macros.rb new file mode 100644 index 0000000..056fc9a --- /dev/null +++ b/spec/support/omniauth_macros.rb @@ -0,0 +1,9 @@ +module OmniauthMacros + def mock_auth_hash(provider, oauth_params = {}) + OmniAuth.config.mock_auth[provider] = OmniAuth::AuthHash.new( + { + provider: provider, + uid: '123545' + }.merge oauth_params) + end +end