Разработка приложений на Ruby on Rails

Войти, выйти

Разбить на страницы
Показывать лекцию целиком

Теперь, когда новые пользователи могут регистрироваться на нашем сайте (Глава 7), пришло время дать зарегистрированным пользователям возможность входить на сайт и выходить из него. Это позволит нам добавить настройки, зависящие от регистрационного статуса и личности текущего пользователя. Например, в этой главе мы добавим в header сайта ссылки войти/выйти и ссылку на профиль пользователя. В Главе 10 мы будем использовать идентификацию вошедшего в систему пользователя для создания микросообщений, связанных с этим пользователем и в Главе 11 мы позволим текущему пользователю следовать за другими пользователями приложения (тем самым получать поток (feed) их микросообщений).

Наличие функции входа пользователей в систему также позволит нам реализовать модель безопасности, ограничивающую доступ к определенным страницам, основываясь на идентификации вошедшего в систему пользователя. Например, как мы увидим в Главе 9, только вошедшие пользователи смогут получить доступ к странице, используемой для редактирования информации о пользователе. Система входа/выхода также позволит реализовать особые привилегии для пользователей с правами администратора, такие как возможность (также в Главе 9) удалять пользователей из базы данных.

После реализации ядра аутентификационного механизма мы немного отвлечемся от основной темы для того чтобы познакомиться с Cucumber - популярной системой предназначенной для разработки через поведение (Раздел 8.3). В частности, мы перепишем пару интеграционных RSpec тестов на Cucumber для сравнения этих двух методик.

Как и в предыдущих главах, мы будем делать нашу работу в новой ветке и объединим изменения в конце:

$ git checkout -b sign-in-out

Сессии и провальный вход

Сессия это полупостоянное соединение между двумя компьютерами, такими как клиентский компьютер с запущенным веб-браузером и сервер с запущенными на нем Rails. Есть несколько моделей поведения сессий, принятых в сети: "забывание" сессии при закрытии браузера, опциональное использование "запомнить меня" флажка для постоянных сессий, и запоминание сессий до явных признаков выхода пользователя из системы.1 Мы выберем последнюю из этих опций: когда пользователь войдет, мы запомним его статус вошедшего "навсегда" и очистим сесию только после явного выхода пользователя из системы. (Мы увидим в Разделе 8.2.1 насколько продолжительно это самое "навсегда".)

Удобно моделировать сессии как RESTful ресурс: у нас будет страница входа для новых сессий, вход будет создавать сессию и выход будет уничтожать ее. В отличие от ресурса Users который использует базу данных (через модель User) для сохранения данных, ресурс Sessions будет использовать куки, которые представляют собой небольшой фрагмент текста, помещаемого в браузер пользователя. Большая часть сложностей в разработке системы входа связана с построением этого, опирающегося на куки, аутентификационного механизма. В этом и последующих разделах мы будем заниматься подготовительной работой - создадим контроллер Sessions , форму входа и соответствующие действия контроллера. Затем мы завершим вход пользователей написав необходимый для манипуляций с куки код в Разделе 8.2.

Sessions контроллер

Элементы системы входа и выхода соответствуют определенным REST действиям Sessions контроллера: форма входа обрабатывается new действием (рассматривается в этом разделе), сам вход обрабатывается отправкой запроса POST к действию create (Раздел 8.1 и Раздел 8.2) и выход обрабатывается отправкой запроса DELETE к действию destroy (Раздел 8.2.6). (Вспомним о соответствии между глаголами HTTP и REST действиями из Таблицы 7.1.) Для начала мы сгенерируем контроллер Sessions и интеграционный тест для механизма аутентификации:

$ rails generate controller Sessions --no-test-framework
$ rails generate integration_test authentication_pages

Следуя модели из Раздела 7.2 для страницы регистрации, мы создадим форму входа для создания новых сессий (рис 8.1).

(рис 8.1) Набросок формы входа.

Страница входа живет по URL предоставленному signin_path (уже определенному) и, как обычно, мы начнем с минималистичного теста как это показано в Листинге 8.1. (Сравните его с аналогичным кодом для страницы регистрации из Листинга 7.6.)

require 'spec_helper'

describe "Authentication" do

  subject { page }

  describe "signin page" do
    before { visit signin_path }

    it { should have_content('Sign in') }
    it { should have_title('Sign in') }
  end
end

Изначально тест провальный, как и требуется:

$ bundle exec rspec spec/

Для того чтобы получить прохождение тестов из Листинга 8.1, в первую очередь нам необходимо определить маршруты для ресурса Sessions, совместно с кастомным именованным маршрутом для страницы входа (который мы направим к действию new контроллера Session). Как и с ресурсом Users, мы можем использовать метод resources для определения стандартных RESTful маршрутов:

resources :sessions, only: [:new, :create, :destroy]

Поскольку нам нет надобности показывать или редактировать сессии, мы ограничимся действиями new, create и destroy с помощью опции :only принимаемой resources. Конечный результат, включающий именованные маршруты для входа и выхода, представлен в Листинге 8.2.

SampleApp::Application.routes.draw do
  resources :users
  resources :sessions, only: [:new, :create, :destroy]
  root  'static_pages#home'
  match '/signup',  to: 'users#new',            via: 'get'
  match '/signin',  to: 'sessions#new',         via: 'get'
  match '/signout', to: 'sessions#destroy',     via: 'delete'
  .
  .
  .
end

Обратите внимание на использование via: ’delete’ для маршрута выхода, указывающее на то, что он должен быть вызван с помощью HTTP запроса DELETE.

Ресурсы, определенные в Листинге 8.2 обеспечивают URL-адреса и действия, аналогичные ресурсу Users (Таблица 7.1), как видно в Таблице 8.1. Обратите внимание на то что маршруты для входа и выхода являются кастомными, но маршрут для создания сессии остался дефолтным (т.е., [resource name]_path).

RESTful маршруты, обеспеченные правилами в Листинге 8.2.
HTTP запрос URL Именованный маршрут Действие Цель (назначение)
GET /signin signin_path new страница для новой сессии (вход)
POST /sessions sessions_path create создание новой сессии
DELETE /signout signout_path destroy удаление сессии (выход)

Кстати, для генерации полного списка маршрутов вашего приложения вы можете использовать команду rake routes:

$ rake routes
        Prefix Verb   URL Pattern                    Controller#Action
         users GET    /users(.:format)               users#index
               POST   /users(.:format)               users#create
      new_user GET    /users/new(.:format)           users#new
     edit_user GET    /users/:id/edit(.:format)      users#edit
          user GET    /users/:id(.:format)           users#show
               PATCH  /users/:id(.:format)           users#update
               PUT    /users/:id(.:format)           users#update
               DELETE /users/:id(.:format)           users#destroy
      sessions POST   /sessions(.:format)            sessions#create
   new_session GET    /sessions/new(.:format)        sessions#new
       session DELETE /sessions/:id(.:format)        sessions#destroy
          root GET    /                              static_pages#home
        signup GET    /signup(.:format)              users#new
        signin GET    /signin(.:format)              sessions#new
       signout DELETE /signout(.:format)             sessions#destroy
          help GET    /help(.:format)                static_pages#help
         about GET    /about(.:format)               static_pages#about
       contact GET    /contact(.:format)             static_pages#contact

Следующим шагом необходимым для прохождения тестов из Листинга 8.1 является добавление new действия к контроллеру Sessions, как это показано в Листинге 8.3 (который также определяет create и destroy действия для использования в будущем).

class SessionsController < ApplicationController

  def new
  end

  def create
  end

  def destroy
  end
end

Последний шаг это определение начальной версии страницы входа. Обратите внимание, что, поскольку это страница для новой сесии, она живет в файле app/views/sessions/new.html.erb, который мы должны создать. Содержимое, которое в настоящий момент определяет только заголовок страницы и заголовок первого уровня, представлено в Листинге 8.4.

<% provide(:title, "Sign in") %>
<h1>Sign in</h1>

Теперь тесты из Листинга 8.1 должны пройти и мы готовы к созданию самой формы входа.

$ bundle exec rspec spec/

Тестирование входа

Сравнивая рис 8.1 с рис 7.11, мы видим, что форма входа (или, что эквивалентно, форма новой сессии) выглядит аналогично форме регистрации, за исключением того что в ней два поля (email и пароль) вместо четырех. Как и с формой регистрации, мы можем протестировать форму входа используя Capybara для заполнения формы данными и последующего клика по кнопке.

В процессе написания тестов мы вынуждены обращать внимание на аспекты дизайна нашего приложения, что является одним из приятных побочных эффектов разработки через тестирование. Мы начнем с провального входа, как это показано на наброске в рис 8.2.

(рис 8.2) Набросок провального входа.

Как видно на рис 8.2, при предоставлении невалидной информации мы хотим вновь отрендерить страницу входа и вывести на экран сообщение об ошибке. Мы будем рендерить ошибку как флэш сообщение, что мы можем протестировать следующим образом:

it { should have_selector('div.alert.alert-error') }

Здесь используется предоставляемый Capybara метод have_selector который мы видели ранее в решениях для двух упражнений, Листинге 5.38 и Листинге 7.32. Метод have_selector проверяет наличие конкретного селектора (т.е. HTML тега, однако в Capybara 2.0 это работает только для видимых элементов). В данном случае мы ищем

div.alert.alert-error

который проверяет тег div. В частности, вспомнив что в CSS точка обозначает "класс" (Раздел 5.1.2), вы возможно догадались что это тест на наличие тега div с классами "alert" и "alert-error", вроде этого:

<div class="alert alert-error">Invalid...</div>

Комбинация тестов заголовка и флэша приводит нас к коду в Листинге 8.5. Как мы увидим, эти тесты упускают одну важную деталь, которой мы займемся в Разделе 8.1.5.

require 'spec_helper'

describe "Authentication" do
  .
  .
  .
  describe "signin" do
    before { visit signin_path }

    describe "with invalid information" do
      before { click_button "Sign in" }

      it { should have_title('Sign in') }
      it { should have_selector('div.alert.alert-error') }
    end
  end
end

Теперь, написав тесты для провального входа, мы возвращаемся к успешному входу. Изменениями которые мы хотим протестировать являются рендеринг страницы профиля пользователя (что предопределено заголовком страницы, который должен быть именем пользователя), совместно с тремя запланированными изменениями в навигации сайта:

  • Появление ссылки на страницу профиля пользователя
  • Появление ссылки "Sign out"
  • Исчезновение ссылки "Sign in"
  • (Мы отложим тесты для ссылки "Settings" до Раздела 9.1 и для ссылки "Users" до Раздела 9.3.) Набросок этих изменений представлен на рис 8.3.Изображение взято с http://www.flickr.com/photos/hermanusbackpackers/3343254977/ Обратите внимание на то, что ссылки на выход и на профиль пользователя появляются в выпадающем меню "Account"; в Разделе 8.2.4, мы увидим как сделать такое меню с помощью Bootstrap.

    (рис 8.3) Набросок профиля пользователя после успешного входа.

    Код тестов для успешного входа представлен в Листинге 8.6.

    require 'spec_helper'
    
    describe "Authentication" do
      .
      .
      .
      describe "signin" do
        before { visit signin_path }
        .
        .
        .
        describe "with valid information" do
          let(:user) { FactoryGirl.create(:user) }
          before do
            fill_in "Email",    with: user.email.upcase
            fill_in "Password", with: user.password
            click_button "Sign in"
          end
    
          it { should have_title(user.name) }
          it { should have_link('Profile',     href: user_path(user)) }
          it { should have_link('Sign out',    href: signout_path) }
          it { should_not have_link('Sign in', href: signin_path) }
        end
      end
    end

    Здесь мы использовали метод have_link. Он принимает в качестве аргументов текст ссылки и необязательный параметр :href, таким образом

    it { should have_link('Profile', href: user_path(user)) }

    убеждается в том что якорный тег a имеет правильный атрибут href (URL) — в данном случае, ссылку на страницу профиля пользователя. Обратите также внимание на то что мы позаботились upcase email адрес пользователя для того чтобы быть уверенными в том что наша способность находить пользователя в базе данных не зависит от регистра.

    Форма для входа

    После написания тестов мы готовы приступить к разработке формы для входа. Вспомним из Листинга 7.17 что форма регистрации использует вспомогательный метод form_for, принимающий в качестве аргумента переменную экземпляра @user:

    <%= form_for(@user) do |f| %>
      .
      .
      .
    <% end %>

    Основное отличие между этим и формой для входа заключается в том что у нас нет модели Session, и, следовательно, нет аналога для переменной @user. Это означает, что при конструировании формы для новой сессии нам необходимо предоставить методу form_for чуть больше информации; в частности, тогда как

    form_for(@user)

    позволяет Rails сделать вывод о том, что действием формы должно быть POST к URL /users, в случае с сессиями мы должны явно указать имя ресурса и соответствующий URL:

    form_for(:session, url: sessions_path)

    (Вторым возможным способом является использование form_tag вместо form_for; это было бы даже более идеоматически корректным решением с точки зрения Rails, но оно бы имело мало общего с формой регистрации, а на этом этапе я хочу подчеркнуть параллельность структуры. Создание рабочей формы с помощью form_tag оставлено в качестве упражнения (Раздел 8.5).)

    Имея на руках правильный используя форму регистрации (Листинг 7.17) в качестве модели, как это показано в Листинге 8.7.

    <% provide(:title, "Sign in") %>
    <h1>Sign in</h1>
    
    <div class="row">
      <div class="span6 offset3">
        <%= form_for(:session, url: sessions_path) do |f| %>
    
          <%= f.label :email %>
          <%= f.text_field :email %>
    
          <%= f.label :password %>
          <%= f.password_field :password %>
    
          <%= f.submit "Sign in", class: "btn btn-large btn-primary" %>
        <% end %>
    
        <p>New user? <%= link_to "Sign up now!", signup_path %></p>
      </div>
    </div>

    Обратите внимание на то, что мы для удобства добавили ссылку на страницу входа. С кодом в Листинге 8.7, форма для входа выглядит как на рис 8.7" type="example">Листинге 8.7, форма для входа выглядит как на (рис 8.4) Форма для входа (/signin).

    Несмотря на то, что вы вскоре избавитесь от привычки смотреть на HTML генерируемый Rails (вместо этого доверив хелперам, делать свою работу), пока все же давайте взглянем на него (Листинге 8.8).

    <form accept-charset="UTF-8" action="/sessions" method="post">
      <div>
        <label for="session_email">Email</label>
        <input id="session_email" name="session[email]" type="text" />
      </div>
      <div>
        <label for="session_password">Password</label>
        <input id="session_password" name="session[password]"
               type="password" />
      </div>
      <input class="btn btn-large btn-primary" name="commit" type="submit"
           value="Sign in" />
    </form>

    Сравнивая Листинге 8.8 с Листингом 7.20, вы, возможно, догадались, что отправка этой формы приведет к хэшу params, где params[:session][:email] и params[:session][:password] соответствуют email и password полям.

    Обзор отправки формы

    Как и в случае создания пользователей (регистрации), первый шаг в создании сессий (вход) состоит в обработке неверного ввода. У нас уже есть тесты для провальной регистрации (Листинг 8.5) и код приложения довольно прост за исключением пары тонкостей. Мы начнем с разбора того что происходит при отправке формы, а затем прикрутим полезное сообщение об ошибке появляющееся в случае провального входа (как это показано на наброске из рис 8.5" type="example">Листинг 8.5) и код приложения довольно прост за исключением пары тонкостей. Мы начнем с разбора того что происходит при отправке формы, а затем прикрутим полезное сообщение об ошибке появляющееся в случае провального входа (как это показано на наброске из Давайте начнем с определения минималистичного действия .

    class SessionsController < ApplicationController
      .
      .
      .
      def create
        render 'new'
      end
      .
      .
      .
    end
    (рис 8.5) Начальный провальный вход с create из Листинга 8.9.

    Тщательное изучение отладочной информации на рис 8.5 показывает, что, как намекалось в конце Раздела 8.1.3, отправка формы приводит к хэшу params содержащему email и password под ключом :session:

    ---
    session:
      email: ''
      password: ''
    commit: Sign in
    action: create
    controller: sessions

    Как и в случае регистрации пользователя (рис 7.15) эти параметры образуют вложенный хэш, как тот, что мы видели в Листинге 4.6. В частности, params содержит вложенный хэш формы

    { session: { password: "", email: "" } }

    Это означает что

    params[:session]

    само является хэшем:

    { password: "", email: "" }

    Как результат,

    params[:session][:email]

    является предоставленным адресом электронной почты и

    params[:session][:password]

    является предоставленным паролем.

    Иными словами, внутри create действия хэш params имеет всю информацию, необходимую для аутентификации пользователей по электронной почте и паролю. Совершенно не случайно у нас уже как раз есть необходимый нам метод: User.find_by_email предоставленный Active Record (Раздел 6.1.4) и метод authenticate предоставляемый has_secure_password (Раздел 6.3.3). Вспомните что authenticate возвращает false для невалидной аутентификации, наша стратегия для входа пользователя может быть резюмирована следующим образом:

    def create
      user = User.find_by(email: params[:session][:email].downcase)
      if user  user.authenticate(params[:session][:password])
        # Sign the user in and redirect to the user's show page.
      else
        # Create an error message and re-render the signin form.
      end
    end

    Здесь первая строка вытягивает пользователя из базы данных с помощью предоставленного адреса электронной почты. (Вспомните из Раздела 6.2.5 что email адреса сохраняются в нижнем регистре, поэтому здесь мы используем метод downcase для обеспечения соответствия когда предоставленный адрес валиден.) Следующая строка может немного смутить, но она довольна распространена в идеоматическом Rails программировании:

    user  user.authenticate(params[:session][:password])

    Здесь используется (логическое и) для определения валидности полученного пользователя. Принимая в расчет что любой объект кроме nil и самой false является true в булевом контексте (Раздел 4.2.3), возможные результаты выглядят как Таблица 8.2. Мы видим в Таблице 8.2 что выражение if является true только если пользователь с данным адресом электронной почты и существует в базе данных и имеет данный пароль, что нам и было необходимо.

    Возможные результаты user user.authenticate(…).
    Пользователь Пароль a b
    не существует что-нибудь nil [anything] == false
    валидный пользователь неправильный пароль true false == false
    валидный пользователь правильный пароль true true == true

    Рендеринг с флэш сообщением

    Напомним из Раздела 7.3.3, что мы отображали ошибки регистрации используя сообщения об ошибках модели User. Эти ошибки связаны с конкретным объектом Active Record, но эта стратегия здесь не сработает, поскольку сессии не являются моделью Active Record. Вместо этого, мы поместим сообщение во флеш так чтобы оно отображалось при провальном входе. Первая, немного некорректная попытка представлена в Листинге 8.10.

    class SessionsController < ApplicationController
    
      def new
      end
    
      def create
        user = User.find_by(email: params[:session][:email].downcase)
        if user  user.authenticate(params[:session][:password])
          # Sign the user in and redirect to the user's show page.
        else
          flash[:error] = 'Invalid email/password combination' # Not quite right!
          render 'new'
        end
      end
    
      def destroy
      end
    end

    Поскольку сообщение об ошибке отображается в шаблоне сайта (Листинге 7.27), сообщение ).

    (рис 8.6) Флэш сообщение для провального входа.

    К сожалению, как было отмечено в тексте и в комментарии к Листингу 8.10, этот код не совсем верный. Однако страница выглядит нормально, так в чем же подвох? Проблема заключается в том, что содержимое флэша существует в течение одного запроса, но, в отличие от редиректа (перенаправления) который мы использовали в Листинге 7.28 — повторный рендеринг шаблона с ); если мы кликнем на другую страницу, такую как Home, что будет первым запросом после отправки формы, то флэш сообщение будет вновь отображено (рис 8.10" type="example">Листингу 8.10, этот код не совсем верный. Однако страница выглядит нормально, так в чем же подвох? Проблема заключается в том, что содержимое флэша существует в течение одного запроса, но, в отличие от редиректа (перенаправления) который мы использовали в Листинге 7.28 — повторный рендеринг шаблона с ); если мы кликнем на другую страницу, такую как Home, что будет первым запросом после отправки формы, то флэш сообщение будет вновь отображено ((рис 8.7) Пример ненужного постоянства флэш сообщения.

    Это постоянство флэша является багом нашего приложения и прежде чем приступить к его исправлению, было бы неплохо написать тест отлавливающий эту ошибку. В частности, тест провального входа в данный момент проходит:

    $ bundle exec rspec spec/requests/authentication_pages_spec.rb \
    > -e "signin with invalid information"

    Но тесты ни в коем случае не должны проходить при наличии известной ошибки приложения, так что мы должны добавить провальный тест для ее отлова. К счастью, работа с проблемами вроде неисчезающего флэша, это одна из тех областей, где интеграционные тесты блестяще справляются с поставленной задачей; они позволяют нам сказать именно то, что мы имеем в виду:

    describe "after visiting another page" do
      before { click_link "Home" }
      it { should_not have_selector('div.alert.alert-error') }
    end

    После отправки невалидных данных, этот тест переходит по Home ссылке, а затем требует отсутствия флэш сообщения об ошибке. Обновленный код с модифицированным тестом флэша показан в Листинге 8.11.

    require 'spec_helper'
    
    describe "Authentication" do
      .
      .
      .
      describe "signin" do
    
        before { visit signin_path }
    
        describe "with invalid information" do
          before { click_button "Sign in" }
    
          it { should have_title('Sign in') }
          it { should have_selector('div.alert.alert-error') }
    
          describe "after visiting another page" do
            before { click_link "Home" }
            it { should_not have_selector('div.alert.alert-error') }
          end
        end
        .
        .
        .
      end
    end

    Новый тест не проходит, как и требуется:

    $ bundle exec rspec spec/requests/authentication_pages_spec.rb \
    > -e "signin with invalid information"

    Для того чтобы получить прохождение этого провального теста, мы заменим flash на flash.now, который специально создан для отображения флэш сообщения на отрендеренных страницах; в отличие от содержимого flash, его содержимое исчезает сразу после дополнительного запроса. Исправленный код приложения представлен в Листинге 8.12.

    class SessionsController < ApplicationController
    
      def new
      end
    
      def create
        user = User.find_by(email: params[:session][:email].downcase)
        if user  user.authenticate(params[:session][:password])
          # Sign the user in and redirect to the user's show page.
        else
          flash.now[:error] = 'Invalid email/password combination'
          render 'new'
        end
      end
    
      def destroy
      end
    end

    Теперь набор тестов для пользователей предоставивших невалидные данные для входа должен быть зеленым:

    $ bundle exec rspec spec/requests/authentication_pages_spec.rb \
    > -e "with invalid information"

    Успешный вход

    Получив обработку неудачного входа, теперь нам нужно на самом деле впустить пользователя. Получение этого результата потребует самого сложного Ruby программирования, которое мы когда либо встречали в этом учебнике, так что держитесь до конца и будьте готовы к небольшому количеству тяжелой работы. К счастью, первый шаг прост — завершение create действия контроллера Sessions — простая задача. К сожалению, эта легкость обманчива.

    Заполнить область, занятую в настоящее время комментарием (Листинг 8.12) легко: после успешного входа, мы впускаем пользователя, используя функцию sign_in, а затем перенаправляем его на страницу профиля (Листинг 8.13). Мы видим теперь, почему это обманчивая легкость: увы, sign_in в настоящее время не существует. Написание этой функции займет оставшуюся часть этого раздела.

    class SessionsController < ApplicationController
      .
      .
      .
      def create
        user = User.find_by(email: params[:session][:email].downcase)
        if user  user.authenticate(params[:session][:password])
          sign_in user
          redirect_to user
        else
          flash.now[:error] = 'Invalid email/password combination'
          render 'new'
        end
      end
      .
      .
      .
    end

    Запомнить меня

    Мы теперь в состоянии приступить к реализации нашей модели входа, а именно, запоминанию статуса вошедшего пользователя "навсегда" и очистке сессии только тогда, когда пользователь явно покинет наш сайт. Сами функции входа, в конечном итоге, пересекают традиционное Модель-Представление-Контроллер; в частности, несколько функций входа должны быть доступны и в контроллерах и в представлениях. Вы можете вспомнить из Раздела 4.2.5, что Ruby предоставляет модули для упаковки функций вместе и включения их в нескольких местах и это наш план для функций аутентификации. Мы могли бы сделать совершенно новый модуль для аутентификации, но контроллер Sessions уже оснащен модулем, а именно, SessionsHelper. Кроме того, помощники автоматически включаются в Rails представления, так что все что мы должны сделать для того чтобы использовать функции Sessions хелпера в контроллерах, это включить соответствующий модуль в Application контроллер (Листинг 8.14).

    class ApplicationController < ActionController::Base
      protect_from_forgery with: :exception
      include SessionsHelper
    end

    По умолчанию, все помощники доступны во views, но не в контроллерах. Нам нужны методы Sessions хелпера в обоих местах, поэтому мы должны явно включить его.

    Поскольку HTTP является протоколом, не сохраняющим своего состояния, веб-приложения, требующие входа пользователей, должны реализовывать способ, позволяющий отслеживать прогресс каждого пользователя от страницы к странице. Один из методов для поддержания статуса вошедшего пользователя, является использование традиционных Rails сессий (с помощью специальной session функции) для хранения remember token, равного пользовательскому id:

    session[:remember_token] = user.id

    Этот session объект делает идентификатор пользователя доступным от страницы к странице, сохраняя его в cookie, которые истекают при закрытии браузера. На каждой странице приложения можно просто вызвать

    User.find(session[:remember_token])

    для получения пользователя. Из-за способа, которым Rails обрабатывает сессии, этот процесс является безопасным, если злоумышленник попытается подменить идентификатор пользователя, Rails обнаружит несоответствие, основываясь на специальном session id, генерируемом для каждой сессии.

    Для выбранного нами способа, который подразумевает постоянные сессии — то есть статус вошедшего пользователя, сохраняющийся даже после того, как браузер закрыт — нам необходимо использовать постоянный идентификатор для вошедшего пользователя. Для того чтобы достигнуть этого, мы будем генерировать уникальный, безопасный remember token для каждого пользователя и мы будем хранить его в качестве постоянной куки отличающейся от обычной тем, что она не истекает при закрытии браузера.

    Remember token должен быть связан с пользователем и должен сохраняться для последующего использования, поэтому мы добавим его в качестве атрибута модели User, как это показано на рис 8.8 .

    (рис 8.8) Модель User с добавленным атрибутом remember_token.

    Мы начнем с небольшого дополнения к спекам модели User (Листинг 8.15).

    require 'spec_helper'
    
    describe User do
      .
      .
      .
      it { should respond_to(:password_confirmation) }
      it { should respond_to(:remember_token) }
      it { should respond_to(:authenticate) }
      .
      .
      .
    end

    Мы можем получить прохождение этого теста сгенерировав remember token в командной строке:

    $ rails generate migration add_remember_token_to_users

    Затем мы заполняем получившуюся миграциюю кодом из Листинга 8.16. Это дает нам код показанный в Листинге 8.16. Обратите внимание, что, посколку мы планируем искать пользователей в базе данных по remember token, мы должны добавить индекс (Блок 6.2) к столбцу remember_token

    class AddRememberTokenToUsers < ActiveRecord::Migration
      def change
        add_column :users, :remember_token, :string
        add_index  :users, :remember_token
      end
    end

    Затем мы, как обычно, обновляем тестовую и рабочую базы данных:

    $ bundle exec rake db:migrate
    $ bundle exec rake test:prepare

    В этой точке спеки модели User должны проходить:

    $ bundle exec rspec spec/models/user_spec.rb

    Теперь мы должны выбрать, что именно использовать в качестве remember token. Существует множество, в основном эквивалентных способов, по сути, подойдет любая длинная случайная строка если она будет уникальной. Метод Этот выбор опирается на RailsCast on remember me. он возвращает случайную строку длиной в 16 символов составленную из знаков A–Z, a–z, 0–9, "-" и "_" (в общей сложности 64 возможности, т.е. "base64"). Это означает, что вероятность того, что два remember токена совпадут пренебрежительно мала: $1/64^{16} = 2^{-96} \approx 10^{-29}$.

    Мы планируем хранить сам base64 токен в браузере, а его зашифрованную версию - в базе данных приложения. После чего мы сможем осуществлять логин пользователей вытягивая токен из куки, шифруя его, а затем ища ему соответствие в зашифрованных токенах хранимых в базе данных. Причина по которой мы храним только зашифрованные токены заключается в том, что, даже если вся наша база данных будет скомпрометирована, атакер все равно не сможет использовать токены для входа. Для того чтобы сделать наш токен еще более безопасными, мы планируем менять его каждый раз когда пользователь создает новую сессию, а это означает что любые похищенные сессии—когда атакер использует украденные куки для входа от лица определенного пользователя—истекут при следующем входе пользователя. (Похищение сессий получило широкую огласку с помощью приложения Firesheep, которое показывало что токены на множестве знаменитых сайтов были видимы при подключении к публичным Wi-Fi сетям. Решение заключается в использовании SSL повсеместно на сайте, как это было описано в Разделе 7.4.4.)

    Хотя в реальном приложении мы будем немедленно 'входить' вновь созданного пользователя (тем самым создавая новый токен в качестве побочного эффекта), мы не будем полагаться на такое поведение; более надежная практика заключается в обеспечении каждого пользователя валидным токеном с самого начала. Для того чтобы достигнуть этого мы будем создавать начальный токен с помощью функции обратного вызова - техники впервые представленной в Разделе 6.2.5 в контексте уникальности адресов электронной почты. В том разделе мы использовали коллбэк Более подробно о видах коллбэков, поддерживаемых библиотекой Active Record см. в обсуждении коллбэков в Rails Guides (# см. перевод на rusrails.ru).

    Для того чтобы протестировать remember token, мы вначале сохраним тестового пользователя, а затем проверим, что атрибут пользовательского remember_token не является пустым. Что позволит нам при необходимости изменять случайную строку, если это нам когда-либо потребуется. Результат представлен в Листинге 8.17.

    require 'spec_helper'
    
    describe User do
    
      before do
        @user = User.new(name: "Example User", email: "user@example.com",
                         password: "foobar", password_confirmation: "foobar")
      end
    
      subject { @user }
      .
      .
      .
      describe "remember token" do
        before { @user.save }
        its(:remember_token) { should_not be_blank }
      end
    end

    Листинг 8.17 вводит метод its, который похож на it но относит следующий за ним тест к данному атрибуту, а не к субъекту теста. Другими словами,

    its(:remember_token) { should_not be_blank }

    является эквивалентом

    it { expect(@user.remember_token).not_to be_blank }

    Код приложения вводит несколько новых элементов в модель User (app/models/user.rb). Во-первых, мы добавили метод обратного вызова для создания remember token непосредственно перед созданием нового пользователя в базе данных:

    before_create :create_remember_token

    Этот код, называемый method reference понужает Rails искать метод с названием create_remember_token и выполнять его перед сохранением пользователя. (В Листинге 6.20 мы явно передавали блок в before_save, но техника ссылки на метод более предпочтительна в общем случае.) Во-вторых, сам метод используется тольку внутри модели User, так что нам нет необходимости выставлять его на показ сторонним пользователям. Как мы видели в Разделе 7.3.2, Ruby предлагает использовать для этих целей ключевое слово private:

    private
    
      def create_remember_token
        # Create the token.
      end

    Все методы, определенные в классе после private автоматически становятся скрытыми, таким образом

    $ rails console
    >> User.first.create_remember_token

    вызовет исключение NoMethodError.

    Наконец, метод create_remember_token необходимо присвоить одному из атрибутов пользователей и в этом контексте необходимо использовать ключевое слово self перед remember_token:

     def User.new_remember_token
        SecureRandom.urlsafe_base64
      end
    
      def User.encrypt(token)
        Digest::SHA1.hexdigest(token.to_s)
      end
    
      private
    
        def create_remember_token
          self.remember_token = User.encrypt(User.new_remember_token)
        end

    Из-за способа которым Ruby обрабатывает назначения внутри объектов, без self назначение создаст локальную переменную с именем remember_token, а это совсем не то что нам нужно. Использование self обеспечивает установку назначением пользовательского remember_token таким образом, что он будет записан в базу данных вместе с другими атрибутами при сохранении пользователя. (Теперь вы знаете почему остальные before_save коллбэки из Листинга 6.20 используют self.email вместо просто email.)

    Обратите внимание: мы шифровали токен с помощью SHA1 - хэширующего алгоритма который намного быстрее чем алгоритм Bcrypt используемый нами для шифрования паролей пользователей в Разделе 6.3.1, что важно, поскольку (как мы увидим в Разделе 8.2.2) для вошедших пользователей он будет выполняться на каждой странице. SHA1 является менее безопасным чем Bcrypt, но в данном случае его более чем достаточно так как шифруемый токен уже является 16-значной случайной строкой; SHA1 hexdigest такой строки по сути является невзламываемым. (Вызов to_s нужен для того чтобы мы имели возможность работать с nil токенами - этого не должно происходить в браузерах, но иногда может случаться в тестах.)

    Методы Если методу не нужен экземпляр объекта, он должен быть методом класса. и они являются публичными методами (выше строки private) поскольку в Разделе 8.2.3 мы будем их использовать за пределами модели User.

    Собрав все это воедино мы приходим модели User показанной в Листинге 8.18.

    class User < ActiveRecord::Base
      before_save { self.email = email.downcase }
      before_create :create_remember_token
      .
      .
      .
      def User.new_remember_token
        SecureRandom.urlsafe_base64
      end
    
      def User.encrypt(token)
        Digest::SHA1.hexdigest(token.to_s)
      end
    
      private
    
        def create_remember_token
          self.remember_token = User.encrypt(User.new_remember_token)
        end
    end

    Кстати, дополнительный уровень отступа на create_remember_token сделан для того, чтобы визуально отделить методы определенные после private. (Практика показала что это мудрая практика.)

    Поскольку шифрованная строка SecureRandom.urlsafe_base64 определенно не пустая, тесты для модели User теперь должны пройти:

    $ bundle exec rspec spec/models/user_spec.rb

    Рабочий метод sign_in

    Теперь мы готовы к написанию первого элемента входа - самой sign_in функции. Как было отмечено выше, выбранный нами метод аутентификации заключается в помещении (вновь созданного) remember token в качестве куки в браузер пользователя и последующем использовании токена для поиска записи пользователя в базе данных при перемещении пользователя от страницы к странице (реализовано в Разделе 8.2.3). Результирующий Листинг 8.19 вводит новый для нас метод current_user который мы будем реализовывать в Разделе 8.2.3.

    module SessionsHelper
    
      def sign_in(user)
        remember_token = User.new_remember_token
        cookies.permanent[:remember_token] = remember_token
        user.update_attribute(:remember_token, User.encrypt(remember_token))
        self.current_user = user
      end
    end

    Здесь мы следуем избранной тактике: во-первых, создаем новый токен; во-вторых, помещаем зашифрованный токен в куки браузера; в-третьих, сохраняем зашифрованный токен в базе данных; в-четвертых, устанавливаем текущего пользователя равным данному пользователю (Раздел 8.2.3). Как мы увидим в Разделе 8.2.3, установка текущего пользователя равным user в данный момент не нужна из-за незамедлительного редиректа в create действии (Листинг 8.13), но все же это хорошая идея - на тот случай если мы когда-нибудь захотим использовать sign_in без редиректа.

    В Листинге 8.19 обратите внимание на использование update_attribute для сохранения токена. Как вкратце упоминалось в Разделе 6.1.5), этот метод позволяет обновлять один атрибут в обход валидаций — в данном случае это необходимо так как у нас нет пароля пользователя. Листинг 8.19 также вводит утилиту cookies которая позволяет нам манипулировать куками браузера как если бы они были хэшем; каждый элемент в куки представляет из себя хэш из двух элементов: value и (необязательный) expires дата (# дата истечения). Например, мы могли бы осуществить вход пользователя путем размещения куки со значением, равным пользовательскому токену, которая истекает через 20 лет:

    cookies[:remember_token] = { value:   remember_token,
                                 expires: 20.years.from_now.utc }

    (Этот код использует один из удобных Rails помощников, о чем говорится в Блоке 8.1.)

    ______________________________________________________________

    Блок 8.1.Куки истекают через 20.years.from_now

    Вы можете вспомнить из Раздела 4.4.2, что Ruby позволяет добавлять методы к любому, даже встроенному классу. В том разделе мы добавляли palindrome? метод к String классу (и в результате обнаружили, что "deified" является палиндромом), и мы также видели, как Rails добавляет blank? метод к классу Object (таким образом, "".blank?, " ".blank?, и nil.blank? все являются true). Код куки в Листинге 8.19 (который внутренне устанавливает срок действия cookie в 20.years.from_now) дает еще один пример из этой практики, посредством одного из Rails’ временных хелперов, которые являются методами добавленными к Fixnum (базовый класс для чисел):

    $ rails console
      >> 1.year.from_now
      => Sun, 13 Mar 2011 03:38:55 UTC +00:00
      >> 10.weeks.ago
      => Sat, 02 Jan 2010 03:39:14 UTC +00:00

    Rails добавляет и другие помощники:

      >> 1.kilobyte
      => 1024
      >> 5.megabytes
      => 5242880

    Они полезны для валидации загрузки, что позволяет легко ограничить, например, загрузку изображений размером в 5.megabytes.

    Хотя она должна использоваться с осторожностью, возможность добавлять методы к встроенным классам позволяет создавать черезвычайно естественные добавления к обычному Ruby. Действительно, большая часть элегантности Rails в конечном счете, является производной от податливости лежащего в его основе языка Ruby.

    ______________________________________________________________

    Паттерн установки куки истекающей через 20 лет стал настолько общепринятым, что Rails добавил специальный метод permanent для его реализации, так что мы можем просто написать

    cookies.permanent[:remember_token] = remember_token

    Под капотом, применение permanent приводит к автоматической установке даты истечения куки через 20 лет (20.years.from_now).

    После того как куки установлены, на последующих представлениях страниц мы можем извлекать пользователя с кодом вроде

    User.find_by(remember_token: remember_token)

    (Как мы увидим в Листинге 8.22, на самом деле мы вначале должны захэшировать токен.) Конечно, cookies это на самом деле не хэш, поскольку назначение cookies действительно сохраняет кусочек текста в браузере, но частью красоты Rails является то, что он позволяет вам забыть о деталях и сконцентрироваться на написании приложения.

    Текущий пользователь

    Обсудив способ хранения пользовательского remember token в куки для последующего использования, теперь нам необходимо узнать как извлекать пользователя при последующем просмотре страниц. Давайте еще раз взглянем на функцию sign_in для того чтобы понять где мы находимся:

    module SessionsHelper
    
      def sign_in(user)
        remember_token = User.new_remember_token
        cookies.permanent[:remember_token] = remember_token
        user.update_attribute(:remember_token, User.encrypt(remember_token))
        self.current_user = user
      end
    end

    Единственный участок кода который в данный момент не работает это:

    self.current_user = user

    Как было отмечено сразу после Листинга 8.19, этот код никогда не будет использоваться в данном приложении из-за немедленного редиректа в Листинге 8.13, но для метода sign_in было бы опасным полагаться на это.

    Целью current_user, доступного и в контроллерах и в представлениях является возможность создания конструкции подобные этой:

    <%= current_user.name %<

    и

    redirect_to current_user

    Использование self в назначении является необходимым по тем же причинам что были отмечены в обсуждении приведшем к Листингу 8.18: без self Ruby будет просто создавать локальную переменную с названием current_user.

    Для того, чтобы начать писать код для current_user, обратите внимание, что строка

    self.current_user = user

    это назначение, которое мы должны определить. В Ruby есть специальный синтаксис для определения таких назначаемых функций, показанный в Листинге 8.20.

    module SessionsHelper
    
      def sign_in(user)
        .
        .
        .
      end
    
      def current_user=(user)
        @current_user = user
      end
    end

    Это может выглядеть сбивающим с толку — большинство языков не позволит вам использовать знак равенства в определении метода, но это просто определение метода current_user= специально разработанного для обработки назначения current_user. Другими словами, код

    self.current_user = ...

    автоматически конвертируется в

    current_user=(...)

    тем самым вызывая метод current_user=. Его единственный аргумент это то, что находится справа от назначения, в данном случае - пользователь который войдет. Однострочный метод в теле просто устанавливает переменную экземпляра @current_user, эффективно хранящую пользователя для дальнейшего использования.

    В обычном Ruby, мы могли бы определить второй метод, current_user, предназначенный для возвращения значения @current_user, как это показано в Листинге 8.21.

    module SessionsHelper
    
      def sign_in(user)
        .
        .
        .
      end
    
      def current_user=(user)
        @current_user = user
      end
    
      def current_user
        @current_user     # Useless! Don't use this line.
      end
    end

    Если бы мы сделали это, мы бы фактически повторили функциональность На самом деле, эти двое абсолютно эквивалентны; attr_accessor это просто удобный способ создавать такие getter/setter методы автоматически. Проблема в том, что он совершенно не в состоянии решить наши проблемы: с кодом в Листинге 8.21, статус вошедшего пользователя будет забыт: как только пользователь перейдет на другую страницу — poof! — сессия закончится и пользователь автоматически выйдет. Это связано с тем что в HTTP отсутствует сохранение промежуточного состояния между парами "запрос-ответ" (Раздел 8.2.1) — когда пользователь делает второй запрос, все переменные устанавливаются к своим дефолтным значениям, в случае переменных экземпляра вроде @current_user это nil. Таким образом, когда пользователь обратится к еще одной странице, даже находясь в том же приложении, Rails установит @current_user равным nil и код в Листинге 8.21 не сделает то чего вы от него ожидали.

    Для того чтобы избежать этой проблемы мы можем искать пользователя соответствующего remember token созданному кодом в Листинге 8.19, как это показано в Листинге 8.22. Обратите внимание: поскольку токен хранимый в базе данных зашифрован, нам нужно зашифровать токен полученный из куки прежде чем использовать его для поиска пользователя в базе данных. Мы достигним этого с помощью метода User.encrypt определенного в Листинге 8.18.

    module SessionsHelper
      .
      .
      .
      def current_user=(user)
        @current_user = user
      end
    
      def current_user
        remember_token = User.encrypt(cookies[:remember_token])
        @current_user ||= User.find_by(remember_token: remember_token)
      end
    end

    Листинг 8.22 использует общепринятый, но изначально обескураживающий ||= ("или равно") оператор присваиваивания (Блок 8.2). Его эффект заключается в установке переменной экземпляра @current_user пользователю, соответствующему remember token, но только если Как правило, это означает присвоение переменных, которые изначально nil, но обратите внимание - ложные (false) значения также будут переписаны оператором ||= Иными словами, конструкция

    @current_user ||= User.find_by(remember_token: remember_token)

    вызывает метод то является примером мемоизации, которая обсуждалась ранее в Блоке 6.3. Это полезно лишь в случае если current_user используется чаще чем один раз для запроса отдельно взятого пользователя; в любом случае, find_by будет вызван по крайней мере один раз при каждом посещении страницы на этом сайте.

    ______________________________________________________________

    Блок 8.2.Что за *$@! этот ваш ||= ?

    Конструкция ||= - очень Рубишная — то есть, она очень характерна для языка Ruby — и, следовательно, важно ее знать, если вы планируете много программировать на Ruby. Хотя на первый взгляд она может показаться таинственной, или равно легко понять по аналогии.

    Начнем с общепринятой идиомы для изменения определенной в настоящее время переменной. Многие компьютерные программы включают приращение переменной, как в

    x = x + 1

    Большинство языков обеспечивают синтаксическое сокращение для этой операции; в Ruby (и в C, C++, Perl, Python, Java, и т.д.), это выглядит следующим образом:

    x += 1

    Аналогичные конструкции существуют и для других операторов:

    $ rails console
      >> x = 1
      => 1
      >> x += 1
      => 2
      >> x *= 3
      => 6
      >> x -= 7
      => -1

    В каждом случае, паттерном является то, что x = x O y и x O= y эквивалентны для любого оператора O.

    Другим распространенным Ruby паттерном является назначение переменной, если она nil но в противном случае оставляя ее в покое. Вспоминая or оператор || из Раздела 4.2.3, мы можем записать это следующим образом:

    >> @user
      => nil
      >> @user = @user || "the user"
      => "the user"
      >> @user = @user || "another user"
      => "the user"

    Поскольку nil ложно в булевом контексте, первое присвоение это nil || "the user", что оценивается как "the user"; аналогично, второе присвоение является "the user" || "another user", которое также оценивается как "the user" — так как строки true в булевом контексте, серия || выражений прекращается после оценки первого выражения. (Эта практика оценки выражений || слева направо и остановки на первом истинном значении, известна как оценка короткого замыкания (short-circuit evaluation).)

    Сравнивая в консольной сессии различные операторы, мы видим, что @user = @user || value следует x = x O y паттерну с || вместо O, что позволяет предположить следующую эквивалентную конструкцию:

    >> @user ||= "the user"
      => "the user"

    Вуаля!

    ______________________________________________________________

    Изменение ссылок шаблона

    Мы подходим, наконец, к практическому применению всей нашей войти/выйти работы: мы сделаем ссылки в шаблоне меняющимися в зависимости от статуса пользователя. В частности, как показано на рис 8.3, мы организуем изменение ссылок при входе и выходе пользователей из системы, а также мы добавим ссылки на список всех пользователей, на страницу настроек пользователя (будет закончена в Главе 9) и одну для профиля текущего пользователя. При этом, мы получим прохождение тестов из Листинга 8.6, а это означает, что наш набор тестов станет польностью зеленым впервые с начала этой главы.

    Смена ссылок в шаблоне сайта подразумевает использование если-иначе ветвящихся структур внутри Embedded Ruby:

    <% if signed_in? %>
      # Ссылки для вошедших пользователей
    <% else %>
      # Ссылки для не вошедших пользователей
    <% end %>

    Такой код требует наличия булевого метода signed_in?, который мы сейчас и реализуем.

    Пользователь является вошедшим если в сессии существует текущий пользователь, т.e., если current_user не является nil. Это требует использования оператора "not", написанного с помощью восклицательного знака ! и обычно читаемого как "bang". В данном контексте пользователь является вошедшим если current_user является не nil, как это показано в Листинге 8.23.

    module SessionsHelper
    
      def sign_in(user)
        remember_token = User.new_remember_token
        cookies.permanent[:remember_token] = remember_token
        user.update_attribute(:remember_token, User.encrypt(remember_token))
        self.current_user = user
      end
    
      def signed_in?
        !current_user.nil?
      end
      .
      .
      .
    end

    Имея на руках метод signed_in?, мы готовы закончить ссылки шаблона. Это будут четыре новых ссылки, две из которых пока останутся заглушками (мы их доработаем в Главе 9):

    <%= link_to "Users", '#' %>
    <%= link_to "Settings", '#' %>

    Ссылка на выход, между прочим, использует путь выхода, определенный в Листинге 8.2:

    <%= link_to "Sign out", signout_path, method: "delete" %>

    (Обратите внимание на то, что ссылка на выход передает хэш аргументов указывающий на то, что она должна отправить HTTP запрос DELETE.Веб браузеры на самом деле не могут выдавать запрос DELETE; Rails подделывает его с помошью JavaScript. ) Наконец, мы добавим ссылку на профиль следующим образом:

    <%= link_to "Profile", current_user %>

    Здесь мы могли бы написать

    <%= link_to "Profile", user_path(current_user) %>

    но Rails позволяет нам ссылаться непосредственно на пользователя и в этом контексте current_user будет автоматически конвертирован в user_path(current_user).

    В процессе размещения новых ссылок в шаблон, мы воспользуемся случаем и создадим выпадающее меню с помощью Bootstrap, вы можете более подробно почитать об этом на странице компонентов Bootstrap. Полный результат представлен в Листинге 8.24. В частности, обратите внимание на CSS id и классы связанные с выпадающим меню Bootstrap.

    <header class="navbar navbar-fixed-top navbar-inverse">
      <div class="navbar-inner">
        <div class="container">
          <%= link_to "sample app", root_path, id: "logo" %>
          <nav>
            <ul class="nav pull-right">
              <li><%= link_to "Home", root_path %></li>
              <li><%= link_to "Help", help_path %></li>
              <% if signed_in? %>
                <li><%= link_to "Users", '#' %></li>
                <li id="fat-menu" class="dropdown">
                  <a href="#" class="dropdown-toggle" data-toggle="dropdown">
                    Account <b class="caret"></b>
                  </a>
                  <ul class="dropdown-menu">
                    <li><%= link_to "Profile", current_user %></li>
                    <li><%= link_to "Settings", '#' %></li>
                    <li class="divider"></li>
                    <li>
                      <%= link_to "Sign out", signout_path, method: "delete" %>
                    </li>
                  </ul>
                </li>
              <% else %>
                <li><%= link_to "Sign in", signin_path %></li>
              <% end %>
            </ul>
          </nav>
        </div>
      </div>
    </header>

    Выпадающее меню требует применения JavaScript библиотеки Bootstrap, которую мы можем включить с помощью Рельсового файлопровода, отредактировав файл application.js, как это показано в Листинге 8.25.

    //= require jquery
    //= require jquery_ujs
    //= require bootstrap
    //= require turbolinks
    //= require_tree .

    Здесь используется библиотека Sprockets для включения Bootstrap JavaScript, которая в свою очередь доступна благодаря гему bootstrap-sass из Раздела 5.1.2.

    С кодом в Листинге 8.24 все тесты должны пройти:

    $ bundle exec rspec spec/

    Вошедший пользователь теперь видит новые ссылки и выпадающее меню определенное Листингом 8.24, as shown in рис 8.24" type="example">Листингом 8.24, as shown in (рис 8.9) Вошедший пользователь с новыми ссылками и выпадающим меню.

    В этой точке вам следует проверить, что вы можете войти, закрыть браузер и быть по-прежнему вошедшими при повторном посещении примера приложения. Если хотите, вы можете даже проверить куки браузера для того чтобы посмотреть на результат непосредственно (рис 8.10).

    (рис 8.10) Куки remember token в локальном браузере.

    Вход после регистрации

    В принципе, хотя мы закончили с аутентификацией, вновь зарегистрированные пользователи могут оказаться сбитыми с толку, так как они не вошли в систему по умолчанию. Реализация этого - последний штрих который мы добавим прежде чем позволим пользователям входить на наш сайт. Мы начнем с добавления строки к тестам аутентификации (Листинг 8.26). Это включает "after saving the user" describe блок из Листинга 7.32 (Раздел 7.6), который вы должны добавить в тест, если вы не сделали этого в соответствующем упражнении

    require 'spec_helper'
    
    describe "User pages" do
        .
        .
        .
        describe "with valid information" do
          .
          .
          .
          describe "after saving the user" do
            before { click_button submit }
            let(:user) { User.find_by(email: 'user@example.com') }
    
            it { should have_link('Sign out') }
            it { should have_title(user.name) }
            it { should have_selector('div.alert.alert-success', text: 'Welcome') }
          end
        end
      end
    end

    Здесь мы протестировали появление ссылки на выход для того чтобы убедиться что пользователь успешно вошел после регистрации.

    С методом sign_in из Раздела 8.2, получение прохождения этого теста фактически впустив пользователя в систему легко: просто добавим sign_in @user сразу после сохранения пользователя в базе данных (Листинг 8.27).

    class UsersController < ApplicationController
      .
      .
      .
      def create
        @user = User.new(user_params)
        if @user.save
          sign_in @user
          flash[:success] = "Welcome to the Sample App!"
          redirect_to @user
        else
          render 'new'
        end
      end
      .
      .
      .
    end

    Выход

    Как обсуждалось в Разделе 8.1, наша аутентификационная модель предполагает сохранение пользователей вошедшими до тех пор, пока они явно не выйдут из системы. В этом разделе мы добавим эту, необходимую нам и пользователям, возможность выхода.

    До сих пор действия контроллера Sessions следовали RESTful конвенции, используя new для страницы входа и create для его завершения. Мы продолжим эту тему используя действие destroy для удаления сессий, т.е., для выхода. Для того чтобы протестировать это, мы кликнем по ссылке "Sign out" а затем попробуем найти вновь появившуюся ссылку на вход (Листинг 8.28).

    require 'spec_helper'
    
    describe "Authentication" do
      .
      .
      .
      describe "signin" do
        .
        .
        .
        describe "with valid information" do
          .
          .
          .
          describe "followed by signout" do
            before { click_link "Sign out" }
            it { should have_link('Sign in') }
          end
        end
      end
    end

    Как и со входом пользователя, основанном на функции sign_in, выход пользователя просто перекладывает работу на функцию sign_out (Листинг 8.29).

    class SessionsController < ApplicationController
      .
      .
      .
      def destroy
        sign_out
        redirect_to root_url
      end
    end

    Как и другие элементы аутентификации, мы поместим nil в настоящий момент не является строгой необходимостью из-за незамедлительного редиректа в действии destroy, но все же это хорошая идея - на случай если мы когда-либо захотим использовать sign_out без редиректа.)

    module SessionsHelper
    
      def sign_in(user)
        remember_token = User.new_remember_token
        cookies.permanent[:remember_token] = remember_token
        user.update_attribute(:remember_token, User.encrypt(remember_token))
        self.current_user = user
      end
      .
      .
      .
      def sign_out
        current_user.update_attribute(:remember_token,
                                      User.encrypt(User.new_remember_token))
        cookies.delete(:remember_token)
        self.current_user = nil
      end
    end

    Это завершает триумвират регистрация/вход/выход и набор тестов должен пройти:

    $ bundle exec rspec spec/

    Стоит отметить, что наш набор тестов покрывает большую часть механизма аутентификации, но не все же не полностью. Например, мы не тестируем то как долго живет "remember me" куки и даже не тестируем устанавливается ли она вообще. Это возможно сделать, но практика показывает, что непосредственное тестирование значения куки является хрупким и имеет тенденцию зависеть от деталей реализации которые иногда меняются от одного релиза Rails к другому. Результатом служат рухнувшие тесты вполне себе рабочего кода. Фокусируясь на функционале верхнего уровня - проверяя что пользователи могут войти, оставаться вошедшими при переходе от страницы к странице и могут выйти - мы тестируем ядро кода приложения не заморачиваясь менее важными деталями.

    Введение в Cucumber (опционально)

    Закончив основу системы аутентификации примера приложения, мы собираемся воспользоваться подвернувшейся возможностью для того чтобы показать как написать тесты входа с помощью Cucumber - популярного инструмента для разработки через поведение, который пользуется значительной популярностью в сообществе Ruby. Этот раздел необязателен и может быть пропущен без потери целостности повествования.

    Cucumber позволяет определять текстовые истории описывающие поведение приложения. Множество Rails программистов находят Cucumber особенно полезным при работе над клиентскими проектами; поскольку они могут быть прочитаны даже технически не подкованными пользователями, тесты на Cucumber могут быть прочитаны (а иногда даже написаны) клиентом. Конечно же, применение тестового фреймворка, который не является чистым Ruby, имеет и оборотную сторону, и я считаю что текстовые истории зачастую могут быть излишне многословными. Тем не менее, Cucumber занял прочные позиции в Ruby-инструментарии тестирования и мне особенно нравится его акцент на поведении верхнего уровня, а не на деталях реализации.

    Поскольку акценты в этой книге смещены в сторону RSpec и Capybara, последующая презентация совершенно не претендует на полноту и исчерпывающее раскрытие темы. Ее цель - просто дать вам возможность ощутить вкус Cucumber-а (несомненно свежий и сочный) — если он поразит ваше воображение, существуют целые книги на эту тему готовые удовлетворить ваш аппетит. (Я особенно рекомендую The RSpec Book (David Chelimsky) и Rails 3 in Action (Ryan Bigg и Yehuda Katz), и The Cucumber Book (Matt Wynne и Aslak Hellesoy).)

    Установка и настройка

    Для того чтобы установить Cucumber, во-первых, добавьте гем cucumber-rails и служебный гем database_cleaner в группу :test в Gemfile (Листинг 8.31).

    .
    .
    .
    group :test do
      .
      .
      .
      gem 'cucumber-rails', '1.4.0', :require => false
      gem 'database_cleaner', github: 'bmabey/database_cleaner'
    end
    .
    .
    .

    Затем установите как обычно:

    $ bundle install

    Для того чтобы настроить приложение для использования Cucumber, мы затем генерируем несколько необходимых, поддерживающих его работу файлов и директорий:

    $ rails generate cucumber:install

    Это создает директорию features/ где будут жить файлы связанные с Cucumber.

    Фичи и шаги

    Огурцовые фичи это описания ожидаемого поведения с помощью plain-text языка называемого Gherkin. Gherkin тесты читаются во многом как хорошо написанные примеры RSpec, но, поскольку они написаны простым текстом, они более доступны для тех, кому комфортнее читать английский, а не код Руби.

    Наши Огурцовые фичи будут реализовывать небольшое количество примеров входа в Листинге 8.5 и Листинге 8.6. Для того чтобы начать, мы создадим файл signing_in.feature в директории features/.

    Огурцовые фичи начинаются с короткого описания функционала:

    Feature: Signing in

    Затем они добавляют индивидуальные сценарии. Например, для того, чтобы протестировать провальный вход, мы можем написать следующий сценарий:

      Scenario: Unsuccessful signin
        Given a user visits the signin page
        When they submit invalid signin information
        Then they should see an error message

    Аналогично, для того, чтобы протестировать успешный вход, мы можем добавить следующее:

      Scenario: Successful signin
        Given a user visits the signin page
          And the user has an account
        When the user submits valid signin information
        Then they should see their profile page
          And they should see a signout link

    Собрав все это вместе мы приходим к файлу Огурцовой фичи показанному в Листинге 8.32.

    Feature: Signing in
    
      Scenario: Unsuccessful signin
        Given a user visits the signin page
        When they submit invalid signin information
        Then they should see an error message
    
      Scenario: Successful signin
        Given a user visits the signin page
          And the user has an account
        When the user submits valid signin information
        Then they should see their profile page
          And they should see a signout link

    Для запуска фич мы используем исполняемую команду cucumber:

    $ bundle exec cucumber features/

    Сравните это с

    $ bundle exec rspec spec/

    В данном контексте стоит отметить, что, как и RSpec, Cucumber может быть вызван с помощью Rake-задачи:

    $ bundle exec rake cucumber

    (По непонятным для меня причинам, это иногда пишут как rake cucumber:ok.)

    Все что мы пока сделали, это лишь написали немного простого текста, так что не думаю что для вас стало сюрпризом что Огурцовые сценарии пока не проходят. Для того чтобы получить зеленый набор тестов, нам необходима добавить файл step, который свяжет строки простого текста с Руби-кодом. Файл отправляется в директорию features/step_definitions; мы назовем его authentication_steps.rb.

    Строки Feature и Scenario нужны в основном для документации, но каждой последующей строке нужен соответствующий Ruby. Например, строка

    Given a user visits the signin page

    в файле фич будет обработана соответствующим определением шага

    Given /^a user visits the signin page$/ do
      visit signin_path
    end

    В фиче, Given это просто строка, но в файле с шагами Given является методом который принимает регулярное выражение и блок. Регулярное выражение соответствует тексту строки в сценарии, а содержимое блока является чистым Руби кодом, необходимым для реализации шага. В данном случае, "a user visits the signin page" реализуется посредством

    visit signin_path

    Если это выглядит знакомым, все правильно: это просто Capybara, которая включена по умолчанию в файлы с Огурцовыми шагами. Следующие две строки тоже должны выглядеть знакомо; шаги сценария

    When they submit invalid signin information
    Then they should see an error message

    в файле фич обрабатываются следующими шагами:

    When /^they submit invalid signin information$/ do
      click_button "Sign in"
    end
    
    Then /^they should see an error message$/ do
      expect(page).to have_selector('div.alert.alert-error')
    end

    Первый шаг также использует Capybara, при этом второй использует объект Capybara page вместе с RSpec. Очевидно, вся работа с тестами, которую мы проделали с RSpec и Capybara также полезна с Cucumber.

    Остальные шаги обрабатываются аналогично. Конечный файл определения шагов преставлен в Листинге 8.33. Попробуйте добавлять шаги по одному, запуская

    $ bundle exec cucumber features/

    каждый раз до тех пор пока все тесты не пройдут.

    Given /^a user visits the signin page$/ do
      visit signin_path
    end
    
    When /^they submit invalid signin information$/ do
      click_button "Sign in"
    end
    
    Then /^they should see an error message$/ do
      expect(page).to have_selector('div.alert.alert-error')
    end
    
    Given /^the user has an account$/ do
      @user = User.create(name: "Example User", email: "user@example.com",
                          password: "foobar", password_confirmation: "foobar")
    end
    
    When /^the user submits valid signin information$/ do
      fill_in "Email",    with: @user.email
      fill_in "Password", with: @user.password
      click_button "Sign in"
    end
    
    Then /^they should see their profile page$/ do
      expect(page).to have_title(@user.name)
    end
    
    Then /^they should see a signout link$/ do
      expect(page).to have_link('Sign out', href: signout_path)
    end

    С кодом в Листинге 8.33, Огурцовые тесты должны пройти:

    $ bundle exec cucumber features/

    Контрапункт: кастомные проверки RSpec

    Написав несколько простых Огурцовых сценариев, стоит сравнить результат с эквивалентным примером на RSpec. Для начала, взглянем на Огурцовую фичу в Листинг 8.32 и соответствующее определение шагов в Листинге 8.33. Затем взглянем на RSpec request specs (интеграционные тесты):

    describe "Authentication" do
    
      subject { page }
    
      describe "signin" do
        before { visit signin_path }
    
        describe "with invalid information" do
          before { click_button "Sign in" }
    
          it { should have_title('Sign in') }
          it { should have_selector('div.alert.alert-error') }
        end
    
        describe "with valid information" do
          let(:user) { FactoryGirl.create(:user) }
          before do
            fill_in "Email",    with: user.email.upcase
            fill_in "Password", with: user.password
            click_button "Sign in"
          end
    
          it { should have_title(user.name) }
          it { should have_link('Profile',     href: user_path(user)) }
          it { should have_link('Sign out',    href: signout_path) }
          it { should_not have_link('Sign in', href: signin_path) }
        end
      end
    end

    Вы можете видеть, что дело может быть сделано как с помощью Огурца, так и с помощью интеграционных тестов. Огурцовые фичи легкочитаемы, но они полностью отделены от кода который их реализует, а это палка о двух концах. Я считаю что Огурец легок в чтении, но сложен в написании, в то время как интеграционные тесты (для программистов) немного более сложны в чтении и намного более просты в написании.

    Один из замечательных эффектов Огурцового разделения является то, что он работает на более высоком уровне абстракции. Например, мы пишем

    Then they should see an error message

    для того, чтобы выразить ожидание увидеть сообщение об ошибке, и

    Then /^they should see an error message$/ do
      expect(page).to have_selector('div.alert.alert-error')
    end

    для того чтобы реализовать тест. Что особенно удобно в этом, так это то, что только второй элемент (шаг) зависит от реализации, таким образом, если мы изменим, например, класс CSS используемый для сообщений об ошибках, файл фич останется неизменным.

    В этом случае можно запечалиться переписывая

    should have_selector('div.alert.alert-error')

    в куче мест, в то время как вы на самом деле хотели лишь указать на то, что на странице должно присутствовать сообщение об ошибке. Эта практика тесно связывает тест с реализацией и мы должны были бы изменить это повсеместно при изменении реализации. В контексте чистого RSpec для этого есть решение, которое заключается в использовании кастомных проверок, позволяющих нам писать вместо этого следующее:

    should have_error_message('Invalid')

    Мы можем определить такие проверки в том же вспомогательном файле, в который мы поместили тестовый хелпер full_title в Разделе 5.3.4. Сам код выглядит примерно следующим образом:

    RSpec::Matchers.define :have_error_message do |message|
      match do |page|
        expect(page).to have_selector('div.alert.alert-error', text: message)
      end
    end

    Мы можем также определить вспомогательные функции для общепринятых операций:

    def valid_signin(user)
      fill_in "Email",    with: user.email
      fill_in "Password", with: user.password
      click_button "Sign in"
    end

    Получившийся в результате вспомогательный код показан в Листинге 8.34 (который включает в себя результаты Листинга 5.41 и Листинга 5.42 из Раздела 5.6). Я нахожу этот подход более гибким, нежели Огурцовые определения шагов, в особенности когда проверки или помощники долженствования натурально принимают аргумент, такой как valid_signin(user). Определения шагов может повторить эту функциональность с помощью проверок регулярных выражений, но я считаю такой подход гораздо более громоздким (# в оригинале - (cu)cumbersome).

    include ApplicationHelper
    
    def valid_signin(user)
      fill_in "Email",    with: user.email
      fill_in "Password", with: user.password
      click_button "Sign in"
    end
    
    RSpec::Matchers.define :have_error_message do |message|
      match do |page|
        expect(page).to have_selector('div.alert.alert-error', text: message)
      end
    end

    С кодом из Листинга 8.34, мы можем написать

    it { should have_error_message('Invalid') }

    и

    describe "with valid information" do
      let(:user) { FactoryGirl.create(:user) }
      before { valid_signin(user) }
      .
      .
      .

    В наших тестах есть множество примеров тесной связи между тестами и реализацией сайта. Прохождение по текущему набору тестов и разрыв связей между тестами и деталями реализации с помощью создания кастомных проверок и методов остается в качестве упражнения (Раздел 8.5).

    Заключение

    Мы очень многое узнали в этой главе, трансформируя наше многообещающее, но не сформированное приложение в сайт, обладающий полным набором функций для регистрации и входа/выхода пользователей. Все что нам необходимо для завершения аутентификационной функциональности, это ограничить доступ к страницам по статусу и идентификации пользователей. Мы выполним эту задачу, по пути дав пользователям возможность редактировать их информацию, а также дав администраторам возможность удалять пользователей из системы.

    Прежде чем двигаться далее, объедините изменения с мастер веткой:

    $ git add .
    $ git commit -m "Finish sign in"
    $ git checkout master
    $ git merge sign-in-out

    Затем отправьте изменения на удаленный репозиторий GitHub и продакшен сервер Heroku

    $ git push
    $ git push heroku
    $ heroku run rake db:migrate

    Упражнения

  • Реорганизуйте форму входа для использования form_tag вместо form_for. Убедитесь что набор тестов по-прежнему проходит. Подсказка: см.RailsCast on authentication in Rails 3.1, особенно обратите внимание на изменения в структуре хэша params.
  • Следуя примеру в Разделе 8.3.3, пройдитесь по интеграционным тестам пользователя и аутентификации (т.e., по файлам в директории spec/requests) и добавьте методы в spec/support/utilities.rb для отделения тестов от реализации. Факультативно: Организуйте служебный код в отдельные файлы и модули, и заставьте все работать, правильно включив модули в файле spec_helper.rb.
  • Страницы:

    Теперь, когда новые пользователи могут регистрироваться на нашем сайте (Глава 7), пришло время дать зарегистрированным пользователям возможность входить на сайт и выходить из него. Это позволит нам добавить настройки, зависящие от регистрационного статуса и личности текущего пользователя. Например, в этой главе мы добавим в header сайта ссылки войти/выйти и ссылку на профиль пользователя. В Главе 10 мы будем использовать идентификацию вошедшего в систему пользователя для создания микросообщений, связанных с этим пользователем и в Главе 11 мы позволим текущему пользователю следовать за другими пользователями приложения (тем самым получать поток (feed) их микросообщений).

    Наличие функции входа пользователей в систему также позволит нам реализовать модель безопасности, ограничивающую доступ к определенным страницам, основываясь на идентификации вошедшего в систему пользователя. Например, как мы увидим в Главе 9, только вошедшие пользователи смогут получить доступ к странице, используемой для редактирования информации о пользователе. Система входа/выхода также позволит реализовать особые привилегии для пользователей с правами администратора, такие как возможность (также в Главе 9) удалять пользователей из базы данных.

    После реализации ядра аутентификационного механизма мы немного отвлечемся от основной темы для того чтобы познакомиться с Cucumber - популярной системой предназначенной для разработки через поведение (Раздел 8.3). В частности, мы перепишем пару интеграционных RSpec тестов на Cucumber для сравнения этих двух методик.

    Как и в предыдущих главах, мы будем делать нашу работу в новой ветке и объединим изменения в конце:

    $ git checkout -b sign-in-out

    Сессии и провальный вход

    Сессия это полупостоянное соединение между двумя компьютерами, такими как клиентский компьютер с запущенным веб-браузером и сервер с запущенными на нем Rails. Есть несколько моделей поведения сессий, принятых в сети: "забывание" сессии при закрытии браузера, опциональное использование "запомнить меня" флажка для постоянных сессий, и запоминание сессий до явных признаков выхода пользователя из системы.1 Мы выберем последнюю из этих опций: когда пользователь войдет, мы запомним его статус вошедшего "навсегда" и очистим сесию только после явного выхода пользователя из системы. (Мы увидим в Разделе 8.2.1 насколько продолжительно это самое "навсегда".)

    Удобно моделировать сессии как RESTful ресурс: у нас будет страница входа для новых сессий, вход будет создавать сессию и выход будет уничтожать ее. В отличие от ресурса Users который использует базу данных (через модель User) для сохранения данных, ресурс Sessions будет использовать куки, которые представляют собой небольшой фрагмент текста, помещаемого в браузер пользователя. Большая часть сложностей в разработке системы входа связана с построением этого, опирающегося на куки, аутентификационного механизма. В этом и последующих разделах мы будем заниматься подготовительной работой - создадим контроллер Sessions , форму входа и соответствующие действия контроллера. Затем мы завершим вход пользователей написав необходимый для манипуляций с куки код в Разделе 8.2.

    Sessions контроллер

    Элементы системы входа и выхода соответствуют определенным REST действиям Sessions контроллера: форма входа обрабатывается new действием (рассматривается в этом разделе), сам вход обрабатывается отправкой запроса POST к действию create (Раздел 8.1 и Раздел 8.2) и выход обрабатывается отправкой запроса DELETE к действию destroy (Раздел 8.2.6). (Вспомним о соответствии между глаголами HTTP и REST действиями из Таблицы 7.1.) Для начала мы сгенерируем контроллер Sessions и интеграционный тест для механизма аутентификации:

    $ rails generate controller Sessions --no-test-framework
    $ rails generate integration_test authentication_pages

    Следуя модели из Раздела 7.2 для страницы регистрации, мы создадим форму входа для создания новых сессий (рис 8.1).

    (рис 8.1) Набросок формы входа.

    Страница входа живет по URL предоставленному signin_path (уже определенному) и, как обычно, мы начнем с минималистичного теста как это показано в Листинге 8.1. (Сравните его с аналогичным кодом для страницы регистрации из Листинга 7.6.)

    require 'spec_helper'
    
    describe "Authentication" do
    
      subject { page }
    
      describe "signin page" do
        before { visit signin_path }
    
        it { should have_content('Sign in') }
        it { should have_title('Sign in') }
      end
    end

    Изначально тест провальный, как и требуется:

    $ bundle exec rspec spec/

    Для того чтобы получить прохождение тестов из Листинга 8.1, в первую очередь нам необходимо определить маршруты для ресурса Sessions, совместно с кастомным именованным маршрутом для страницы входа (который мы направим к действию new контроллера Session). Как и с ресурсом Users, мы можем использовать метод resources для определения стандартных RESTful маршрутов:

    resources :sessions, only: [:new, :create, :destroy]

    Поскольку нам нет надобности показывать или редактировать сессии, мы ограничимся действиями new, create и destroy с помощью опции :only принимаемой resources. Конечный результат, включающий именованные маршруты для входа и выхода, представлен в Листинге 8.2.

    SampleApp::Application.routes.draw do
      resources :users
      resources :sessions, only: [:new, :create, :destroy]
      root  'static_pages#home'
      match '/signup',  to: 'users#new',            via: 'get'
      match '/signin',  to: 'sessions#new',         via: 'get'
      match '/signout', to: 'sessions#destroy',     via: 'delete'
      .
      .
      .
    end
    

    Обратите внимание на использование via: ’delete’ для маршрута выхода, указывающее на то, что он должен быть вызван с помощью HTTP запроса DELETE.

    Ресурсы, определенные в Листинге 8.2 обеспечивают URL-адреса и действия, аналогичные ресурсу Users (Таблица 7.1), как видно в Таблице 8.1. Обратите внимание на то что маршруты для входа и выхода являются кастомными, но маршрут для создания сессии остался дефолтным (т.е., [resource name]_path).

    RESTful маршруты, обеспеченные правилами в Листинге 8.2.
    HTTP запрос URL Именованный маршрут Действие Цель (назначение)
    GET /signin signin_path new страница для новой сессии (вход)
    POST /sessions sessions_path create создание новой сессии
    DELETE /signout signout_path destroy удаление сессии (выход)

    Кстати, для генерации полного списка маршрутов вашего приложения вы можете использовать команду rake routes:

    $ rake routes
            Prefix Verb   URL Pattern                    Controller#Action
             users GET    /users(.:format)               users#index
                   POST   /users(.:format)               users#create
          new_user GET    /users/new(.:format)           users#new
         edit_user GET    /users/:id/edit(.:format)      users#edit
              user GET    /users/:id(.:format)           users#show
                   PATCH  /users/:id(.:format)           users#update
                   PUT    /users/:id(.:format)           users#update
                   DELETE /users/:id(.:format)           users#destroy
          sessions POST   /sessions(.:format)            sessions#create
       new_session GET    /sessions/new(.:format)        sessions#new
           session DELETE /sessions/:id(.:format)        sessions#destroy
              root GET    /                              static_pages#home
            signup GET    /signup(.:format)              users#new
            signin GET    /signin(.:format)              sessions#new
           signout DELETE /signout(.:format)             sessions#destroy
              help GET    /help(.:format)                static_pages#help
             about GET    /about(.:format)               static_pages#about
           contact GET    /contact(.:format)             static_pages#contact

    Следующим шагом необходимым для прохождения тестов из Листинга 8.1 является добавление new действия к контроллеру Sessions, как это показано в Листинге 8.3 (который также определяет create и destroy действия для использования в будущем).

    class SessionsController < ApplicationController
    
      def new
      end
    
      def create
      end
    
      def destroy
      end
    end

    Последний шаг это определение начальной версии страницы входа. Обратите внимание, что, поскольку это страница для новой сесии, она живет в файле app/views/sessions/new.html.erb, который мы должны создать. Содержимое, которое в настоящий момент определяет только заголовок страницы и заголовок первого уровня, представлено в Листинге 8.4.

    <% provide(:title, "Sign in") %>
    <h1>Sign in</h1>

    Теперь тесты из Листинга 8.1 должны пройти и мы готовы к созданию самой формы входа.

    $ bundle exec rspec spec/

    Тестирование входа

    Сравнивая рис 8.1 с рис 7.11, мы видим, что форма входа (или, что эквивалентно, форма новой сессии) выглядит аналогично форме регистрации, за исключением того что в ней два поля (email и пароль) вместо четырех. Как и с формой регистрации, мы можем протестировать форму входа используя Capybara для заполнения формы данными и последующего клика по кнопке.

    В процессе написания тестов мы вынуждены обращать внимание на аспекты дизайна нашего приложения, что является одним из приятных побочных эффектов разработки через тестирование. Мы начнем с провального входа, как это показано на наброске в рис 8.2.

    (рис 8.2) Набросок провального входа.

    Как видно на рис 8.2, при предоставлении невалидной информации мы хотим вновь отрендерить страницу входа и вывести на экран сообщение об ошибке. Мы будем рендерить ошибку как флэш сообщение, что мы можем протестировать следующим образом:

    it { should have_selector('div.alert.alert-error') }

    Здесь используется предоставляемый Capybara метод have_selector который мы видели ранее в решениях для двух упражнений, Листинге 5.38 и Листинге 7.32. Метод have_selector проверяет наличие конкретного селектора (т.е. HTML тега, однако в Capybara 2.0 это работает только для видимых элементов). В данном случае мы ищем

    div.alert.alert-error

    который проверяет тег div. В частности, вспомнив что в CSS точка обозначает "класс" (Раздел 5.1.2), вы возможно догадались что это тест на наличие тега div с классами "alert" и "alert-error", вроде этого:

    <div class="alert alert-error">Invalid...</div>

    Комбинация тестов заголовка и флэша приводит нас к коду в Листинге 8.5. Как мы увидим, эти тесты упускают одну важную деталь, которой мы займемся в Разделе 8.1.5.

    require 'spec_helper'
    
    describe "Authentication" do
      .
      .
      .
      describe "signin" do
        before { visit signin_path }
    
        describe "with invalid information" do
          before { click_button "Sign in" }
    
          it { should have_title('Sign in') }
          it { should have_selector('div.alert.alert-error') }
        end
      end
    end

    Теперь, написав тесты для провального входа, мы возвращаемся к успешному входу. Изменениями которые мы хотим протестировать являются рендеринг страницы профиля пользователя (что предопределено заголовком страницы, который должен быть именем пользователя), совместно с тремя запланированными изменениями в навигации сайта:

  • Появление ссылки на страницу профиля пользователя
  • Появление ссылки "Sign out"
  • Исчезновение ссылки "Sign in"
  • (Мы отложим тесты для ссылки "Settings" до Раздела 9.1 и для ссылки "Users" до Раздела 9.3.) Набросок этих изменений представлен на рис 8.3.Изображение взято с http://www.flickr.com/photos/hermanusbackpackers/3343254977/ Обратите внимание на то, что ссылки на выход и на профиль пользователя появляются в выпадающем меню "Account"; в Разделе 8.2.4, мы увидим как сделать такое меню с помощью Bootstrap.

    (рис 8.3) Набросок профиля пользователя после успешного входа.

    Код тестов для успешного входа представлен в Листинге 8.6.

    require 'spec_helper'
    
    describe "Authentication" do
      .
      .
      .
      describe "signin" do
        before { visit signin_path }
        .
        .
        .
        describe "with valid information" do
          let(:user) { FactoryGirl.create(:user) }
          before do
            fill_in "Email",    with: user.email.upcase
            fill_in "Password", with: user.password
            click_button "Sign in"
          end
    
          it { should have_title(user.name) }
          it { should have_link('Profile',     href: user_path(user)) }
          it { should have_link('Sign out',    href: signout_path) }
          it { should_not have_link('Sign in', href: signin_path) }
        end
      end
    end

    Здесь мы использовали метод have_link. Он принимает в качестве аргументов текст ссылки и необязательный параметр :href, таким образом

    it { should have_link('Profile', href: user_path(user)) }

    убеждается в том что якорный тег a имеет правильный атрибут href (URL) — в данном случае, ссылку на страницу профиля пользователя. Обратите также внимание на то что мы позаботились upcase email адрес пользователя для того чтобы быть уверенными в том что наша способность находить пользователя в базе данных не зависит от регистра.

    Форма для входа

    После написания тестов мы готовы приступить к разработке формы для входа. Вспомним из Листинга 7.17 что форма регистрации использует вспомогательный метод form_for, принимающий в качестве аргумента переменную экземпляра @user:

    <%= form_for(@user) do |f| %>
      .
      .
      .
    <% end %>

    Основное отличие между этим и формой для входа заключается в том что у нас нет модели Session, и, следовательно, нет аналога для переменной @user. Это означает, что при конструировании формы для новой сессии нам необходимо предоставить методу form_for чуть больше информации; в частности, тогда как

    form_for(@user)

    позволяет Rails сделать вывод о том, что действием формы должно быть POST к URL /users, в случае с сессиями мы должны явно указать имя ресурса и соответствующий URL:

    form_for(:session, url: sessions_path)

    (Вторым возможным способом является использование form_tag вместо form_for; это было бы даже более идеоматически корректным решением с точки зрения Rails, но оно бы имело мало общего с формой регистрации, а на этом этапе я хочу подчеркнуть параллельность структуры. Создание рабочей формы с помощью form_tag оставлено в качестве упражнения (Раздел 8.5).)

    Имея на руках правильный используя форму регистрации (Листинг 7.17) в качестве модели, как это показано в Листинге 8.7.

    <% provide(:title, "Sign in") %>
    <h1>Sign in</h1>
    
    <div class="row">
      <div class="span6 offset3">
        <%= form_for(:session, url: sessions_path) do |f| %>
    
          <%= f.label :email %>
          <%= f.text_field :email %>
    
          <%= f.label :password %>
          <%= f.password_field :password %>
    
          <%= f.submit "Sign in", class: "btn btn-large btn-primary" %>
        <% end %>
    
        <p>New user? <%= link_to "Sign up now!", signup_path %></p>
      </div>
    </div>

    Обратите внимание на то, что мы для удобства добавили ссылку на страницу входа. С кодом в Листинге 8.7, форма для входа выглядит как на рис 8.7" type="example">Листинге 8.7, форма для входа выглядит как на (рис 8.4) Форма для входа (/signin).

    Несмотря на то, что вы вскоре избавитесь от привычки смотреть на HTML генерируемый Rails (вместо этого доверив хелперам, делать свою работу), пока все же давайте взглянем на него (Листинге 8.8).

    <form accept-charset="UTF-8" action="/sessions" method="post">
      <div>
        <label for="session_email">Email</label>
        <input id="session_email" name="session[email]" type="text" />
      </div>
      <div>
        <label for="session_password">Password</label>
        <input id="session_password" name="session[password]"
               type="password" />
      </div>
      <input class="btn btn-large btn-primary" name="commit" type="submit"
           value="Sign in" />
    </form>

    Сравнивая Листинге 8.8 с Листингом 7.20, вы, возможно, догадались, что отправка этой формы приведет к хэшу params, где params[:session][:email] и params[:session][:password] соответствуют email и password полям.

    Обзор отправки формы

    Как и в случае создания пользователей (регистрации), первый шаг в создании сессий (вход) состоит в обработке неверного ввода. У нас уже есть тесты для провальной регистрации (Листинг 8.5) и код приложения довольно прост за исключением пары тонкостей. Мы начнем с разбора того что происходит при отправке формы, а затем прикрутим полезное сообщение об ошибке появляющееся в случае провального входа (как это показано на наброске из рис 8.5" type="example">Листинг 8.5) и код приложения довольно прост за исключением пары тонкостей. Мы начнем с разбора того что происходит при отправке формы, а затем прикрутим полезное сообщение об ошибке появляющееся в случае провального входа (как это показано на наброске из Давайте начнем с определения минималистичного действия .

    class SessionsController < ApplicationController
      .
      .
      .
      def create
        render 'new'
      end
      .
      .
      .
    end
    (рис 8.5) Начальный провальный вход с create из Листинга 8.9.

    Тщательное изучение отладочной информации на рис 8.5 показывает, что, как намекалось в конце Раздела 8.1.3, отправка формы приводит к хэшу params содержащему email и password под ключом :session:

    ---
    session:
      email: ''
      password: ''
    commit: Sign in
    action: create
    controller: sessions

    Как и в случае регистрации пользователя (рис 7.15) эти параметры образуют вложенный хэш, как тот, что мы видели в Листинге 4.6. В частности, params содержит вложенный хэш формы

    { session: { password: "", email: "" } }

    Это означает что

    params[:session]

    само является хэшем:

    { password: "", email: "" }

    Как результат,

    params[:session][:email]

    является предоставленным адресом электронной почты и

    params[:session][:password]

    является предоставленным паролем.

    Иными словами, внутри create действия хэш params имеет всю информацию, необходимую для аутентификации пользователей по электронной почте и паролю. Совершенно не случайно у нас уже как раз есть необходимый нам метод: User.find_by_email предоставленный Active Record (Раздел 6.1.4) и метод authenticate предоставляемый has_secure_password (Раздел 6.3.3). Вспомните что authenticate возвращает false для невалидной аутентификации, наша стратегия для входа пользователя может быть резюмирована следующим образом:

    def create
      user = User.find_by(email: params[:session][:email].downcase)
      if user  user.authenticate(params[:session][:password])
        # Sign the user in and redirect to the user's show page.
      else
        # Create an error message and re-render the signin form.
      end
    end

    Здесь первая строка вытягивает пользователя из базы данных с помощью предоставленного адреса электронной почты. (Вспомните из Раздела 6.2.5 что email адреса сохраняются в нижнем регистре, поэтому здесь мы используем метод downcase для обеспечения соответствия когда предоставленный адрес валиден.) Следующая строка может немного смутить, но она довольна распространена в идеоматическом Rails программировании:

    user  user.authenticate(params[:session][:password])

    Здесь используется (логическое и) для определения валидности полученного пользователя. Принимая в расчет что любой объект кроме nil и самой false является true в булевом контексте (Раздел 4.2.3), возможные результаты выглядят как Таблица 8.2. Мы видим в Таблице 8.2 что выражение if является true только если пользователь с данным адресом электронной почты и существует в базе данных и имеет данный пароль, что нам и было необходимо.

    Возможные результаты user user.authenticate(…).
    Пользователь Пароль a b
    не существует что-нибудь nil [anything] == false
    валидный пользователь неправильный пароль true false == false
    валидный пользователь правильный пароль true true == true

    Рендеринг с флэш сообщением

    Напомним из Раздела 7.3.3, что мы отображали ошибки регистрации используя сообщения об ошибках модели User. Эти ошибки связаны с конкретным объектом Active Record, но эта стратегия здесь не сработает, поскольку сессии не являются моделью Active Record. Вместо этого, мы поместим сообщение во флеш так чтобы оно отображалось при провальном входе. Первая, немного некорректная попытка представлена в Листинге 8.10.

    class SessionsController < ApplicationController
    
      def new
      end
    
      def create
        user = User.find_by(email: params[:session][:email].downcase)
        if user  user.authenticate(params[:session][:password])
          # Sign the user in and redirect to the user's show page.
        else
          flash[:error] = 'Invalid email/password combination' # Not quite right!
          render 'new'
        end
      end
    
      def destroy
      end
    end

    Поскольку сообщение об ошибке отображается в шаблоне сайта (Листинге 7.27), сообщение ).

    (рис 8.6) Флэш сообщение для провального входа.

    К сожалению, как было отмечено в тексте и в комментарии к Листингу 8.10, этот код не совсем верный. Однако страница выглядит нормально, так в чем же подвох? Проблема заключается в том, что содержимое флэша существует в течение одного запроса, но, в отличие от редиректа (перенаправления) который мы использовали в Листинге 7.28 — повторный рендеринг шаблона с ); если мы кликнем на другую страницу, такую как Home, что будет первым запросом после отправки формы, то флэш сообщение будет вновь отображено (рис 8.10" type="example">Листингу 8.10, этот код не совсем верный. Однако страница выглядит нормально, так в чем же подвох? Проблема заключается в том, что содержимое флэша существует в течение одного запроса, но, в отличие от редиректа (перенаправления) который мы использовали в Листинге 7.28 — повторный рендеринг шаблона с ); если мы кликнем на другую страницу, такую как Home, что будет первым запросом после отправки формы, то флэш сообщение будет вновь отображено ((рис 8.7) Пример ненужного постоянства флэш сообщения.

    Это постоянство флэша является багом нашего приложения и прежде чем приступить к его исправлению, было бы неплохо написать тест отлавливающий эту ошибку. В частности, тест провального входа в данный момент проходит:

    $ bundle exec rspec spec/requests/authentication_pages_spec.rb \
    > -e "signin with invalid information"

    Но тесты ни в коем случае не должны проходить при наличии известной ошибки приложения, так что мы должны добавить провальный тест для ее отлова. К счастью, работа с проблемами вроде неисчезающего флэша, это одна из тех областей, где интеграционные тесты блестяще справляются с поставленной задачей; они позволяют нам сказать именно то, что мы имеем в виду:

    describe "after visiting another page" do
      before { click_link "Home" }
      it { should_not have_selector('div.alert.alert-error') }
    end

    После отправки невалидных данных, этот тест переходит по Home ссылке, а затем требует отсутствия флэш сообщения об ошибке. Обновленный код с модифицированным тестом флэша показан в Листинге 8.11.

    require 'spec_helper'
    
    describe "Authentication" do
      .
      .
      .
      describe "signin" do
    
        before { visit signin_path }
    
        describe "with invalid information" do
          before { click_button "Sign in" }
    
          it { should have_title('Sign in') }
          it { should have_selector('div.alert.alert-error') }
    
          describe "after visiting another page" do
            before { click_link "Home" }
            it { should_not have_selector('div.alert.alert-error') }
          end
        end
        .
        .
        .
      end
    end

    Новый тест не проходит, как и требуется:

    $ bundle exec rspec spec/requests/authentication_pages_spec.rb \
    > -e "signin with invalid information"

    Для того чтобы получить прохождение этого провального теста, мы заменим flash на flash.now, который специально создан для отображения флэш сообщения на отрендеренных страницах; в отличие от содержимого flash, его содержимое исчезает сразу после дополнительного запроса. Исправленный код приложения представлен в Листинге 8.12.

    class SessionsController < ApplicationController
    
      def new
      end
    
      def create
        user = User.find_by(email: params[:session][:email].downcase)
        if user  user.authenticate(params[:session][:password])
          # Sign the user in and redirect to the user's show page.
        else
          flash.now[:error] = 'Invalid email/password combination'
          render 'new'
        end
      end
    
      def destroy
      end
    end

    Теперь набор тестов для пользователей предоставивших невалидные данные для входа должен быть зеленым:

    $ bundle exec rspec spec/requests/authentication_pages_spec.rb \
    > -e "with invalid information"

    Успешный вход

    Получив обработку неудачного входа, теперь нам нужно на самом деле впустить пользователя. Получение этого результата потребует самого сложного Ruby программирования, которое мы когда либо встречали в этом учебнике, так что держитесь до конца и будьте готовы к небольшому количеству тяжелой работы. К счастью, первый шаг прост — завершение create действия контроллера Sessions — простая задача. К сожалению, эта легкость обманчива.

    Заполнить область, занятую в настоящее время комментарием (Листинг 8.12) легко: после успешного входа, мы впускаем пользователя, используя функцию sign_in, а затем перенаправляем его на страницу профиля (Листинг 8.13). Мы видим теперь, почему это обманчивая легкость: увы, sign_in в настоящее время не существует. Написание этой функции займет оставшуюся часть этого раздела.

    class SessionsController < ApplicationController
      .
      .
      .
      def create
        user = User.find_by(email: params[:session][:email].downcase)
        if user  user.authenticate(params[:session][:password])
          sign_in user
          redirect_to user
        else
          flash.now[:error] = 'Invalid email/password combination'
          render 'new'
        end
      end
      .
      .
      .
    end

    Запомнить меня

    Мы теперь в состоянии приступить к реализации нашей модели входа, а именно, запоминанию статуса вошедшего пользователя "навсегда" и очистке сессии только тогда, когда пользователь явно покинет наш сайт. Сами функции входа, в конечном итоге, пересекают традиционное Модель-Представление-Контроллер; в частности, несколько функций входа должны быть доступны и в контроллерах и в представлениях. Вы можете вспомнить из Раздела 4.2.5, что Ruby предоставляет модули для упаковки функций вместе и включения их в нескольких местах и это наш план для функций аутентификации. Мы могли бы сделать совершенно новый модуль для аутентификации, но контроллер Sessions уже оснащен модулем, а именно, SessionsHelper. Кроме того, помощники автоматически включаются в Rails представления, так что все что мы должны сделать для того чтобы использовать функции Sessions хелпера в контроллерах, это включить соответствующий модуль в Application контроллер (Листинг 8.14).

    class ApplicationController < ActionController::Base
      protect_from_forgery with: :exception
      include SessionsHelper
    end

    По умолчанию, все помощники доступны во views, но не в контроллерах. Нам нужны методы Sessions хелпера в обоих местах, поэтому мы должны явно включить его.

    Поскольку HTTP является протоколом, не сохраняющим своего состояния, веб-приложения, требующие входа пользователей, должны реализовывать способ, позволяющий отслеживать прогресс каждого пользователя от страницы к странице. Один из методов для поддержания статуса вошедшего пользователя, является использование традиционных Rails сессий (с помощью специальной session функции) для хранения remember token, равного пользовательскому id:

    session[:remember_token] = user.id

    Этот session объект делает идентификатор пользователя доступным от страницы к странице, сохраняя его в cookie, которые истекают при закрытии браузера. На каждой странице приложения можно просто вызвать

    User.find(session[:remember_token])

    для получения пользователя. Из-за способа, которым Rails обрабатывает сессии, этот процесс является безопасным, если злоумышленник попытается подменить идентификатор пользователя, Rails обнаружит несоответствие, основываясь на специальном session id, генерируемом для каждой сессии.

    Для выбранного нами способа, который подразумевает постоянные сессии — то есть статус вошедшего пользователя, сохраняющийся даже после того, как браузер закрыт — нам необходимо использовать постоянный идентификатор для вошедшего пользователя. Для того чтобы достигнуть этого, мы будем генерировать уникальный, безопасный remember token для каждого пользователя и мы будем хранить его в качестве постоянной куки отличающейся от обычной тем, что она не истекает при закрытии браузера.

    Remember token должен быть связан с пользователем и должен сохраняться для последующего использования, поэтому мы добавим его в качестве атрибута модели User, как это показано на рис 8.8 .

    (рис 8.8) Модель User с добавленным атрибутом remember_token.

    Мы начнем с небольшого дополнения к спекам модели User (Листинг 8.15).

    require 'spec_helper'
    
    describe User do
      .
      .
      .
      it { should respond_to(:password_confirmation) }
      it { should respond_to(:remember_token) }
      it { should respond_to(:authenticate) }
      .
      .
      .
    end

    Мы можем получить прохождение этого теста сгенерировав remember token в командной строке:

    $ rails generate migration add_remember_token_to_users

    Затем мы заполняем получившуюся миграциюю кодом из Листинга 8.16. Это дает нам код показанный в Листинге 8.16. Обратите внимание, что, посколку мы планируем искать пользователей в базе данных по remember token, мы должны добавить индекс (Блок 6.2) к столбцу remember_token

    class AddRememberTokenToUsers < ActiveRecord::Migration
      def change
        add_column :users, :remember_token, :string
        add_index  :users, :remember_token
      end
    end

    Затем мы, как обычно, обновляем тестовую и рабочую базы данных:

    $ bundle exec rake db:migrate
    $ bundle exec rake test:prepare

    В этой точке спеки модели User должны проходить:

    $ bundle exec rspec spec/models/user_spec.rb

    Теперь мы должны выбрать, что именно использовать в качестве remember token. Существует множество, в основном эквивалентных способов, по сути, подойдет любая длинная случайная строка если она будет уникальной. Метод Этот выбор опирается на RailsCast on remember me. он возвращает случайную строку длиной в 16 символов составленную из знаков A–Z, a–z, 0–9, "-" и "_" (в общей сложности 64 возможности, т.е. "base64"). Это означает, что вероятность того, что два remember токена совпадут пренебрежительно мала: $1/64^{16} = 2^{-96} \approx 10^{-29}$.

    Мы планируем хранить сам base64 токен в браузере, а его зашифрованную версию - в базе данных приложения. После чего мы сможем осуществлять логин пользователей вытягивая токен из куки, шифруя его, а затем ища ему соответствие в зашифрованных токенах хранимых в базе данных. Причина по которой мы храним только зашифрованные токены заключается в том, что, даже если вся наша база данных будет скомпрометирована, атакер все равно не сможет использовать токены для входа. Для того чтобы сделать наш токен еще более безопасными, мы планируем менять его каждый раз когда пользователь создает новую сессию, а это означает что любые похищенные сессии—когда атакер использует украденные куки для входа от лица определенного пользователя—истекут при следующем входе пользователя. (Похищение сессий получило широкую огласку с помощью приложения Firesheep, которое показывало что токены на множестве знаменитых сайтов были видимы при подключении к публичным Wi-Fi сетям. Решение заключается в использовании SSL повсеместно на сайте, как это было описано в Разделе 7.4.4.)

    Хотя в реальном приложении мы будем немедленно 'входить' вновь созданного пользователя (тем самым создавая новый токен в качестве побочного эффекта), мы не будем полагаться на такое поведение; более надежная практика заключается в обеспечении каждого пользователя валидным токеном с самого начала. Для того чтобы достигнуть этого мы будем создавать начальный токен с помощью функции обратного вызова - техники впервые представленной в Разделе 6.2.5 в контексте уникальности адресов электронной почты. В том разделе мы использовали коллбэк Более подробно о видах коллбэков, поддерживаемых библиотекой Active Record см. в обсуждении коллбэков в Rails Guides (# см. перевод на rusrails.ru).

    Для того чтобы протестировать remember token, мы вначале сохраним тестового пользователя, а затем проверим, что атрибут пользовательского remember_token не является пустым. Что позволит нам при необходимости изменять случайную строку, если это нам когда-либо потребуется. Результат представлен в Листинге 8.17.

    require 'spec_helper'
    
    describe User do
    
      before do
        @user = User.new(name: "Example User", email: "user@example.com",
                         password: "foobar", password_confirmation: "foobar")
      end
    
      subject { @user }
      .
      .
      .
      describe "remember token" do
        before { @user.save }
        its(:remember_token) { should_not be_blank }
      end
    end

    Листинг 8.17 вводит метод its, который похож на it но относит следующий за ним тест к данному атрибуту, а не к субъекту теста. Другими словами,

    its(:remember_token) { should_not be_blank }

    является эквивалентом

    it { expect(@user.remember_token).not_to be_blank }

    Код приложения вводит несколько новых элементов в модель User (app/models/user.rb). Во-первых, мы добавили метод обратного вызова для создания remember token непосредственно перед созданием нового пользователя в базе данных:

    before_create :create_remember_token

    Этот код, называемый method reference понужает Rails искать метод с названием create_remember_token и выполнять его перед сохранением пользователя. (В Листинге 6.20 мы явно передавали блок в before_save, но техника ссылки на метод более предпочтительна в общем случае.) Во-вторых, сам метод используется тольку внутри модели User, так что нам нет необходимости выставлять его на показ сторонним пользователям. Как мы видели в Разделе 7.3.2, Ruby предлагает использовать для этих целей ключевое слово private:

    private
    
      def create_remember_token
        # Create the token.
      end

    Все методы, определенные в классе после private автоматически становятся скрытыми, таким образом

    $ rails console
    >> User.first.create_remember_token

    вызовет исключение NoMethodError.

    Наконец, метод create_remember_token необходимо присвоить одному из атрибутов пользователей и в этом контексте необходимо использовать ключевое слово self перед remember_token:

     def User.new_remember_token
        SecureRandom.urlsafe_base64
      end
    
      def User.encrypt(token)
        Digest::SHA1.hexdigest(token.to_s)
      end
    
      private
    
        def create_remember_token
          self.remember_token = User.encrypt(User.new_remember_token)
        end

    Из-за способа которым Ruby обрабатывает назначения внутри объектов, без self назначение создаст локальную переменную с именем remember_token, а это совсем не то что нам нужно. Использование self обеспечивает установку назначением пользовательского remember_token таким образом, что он будет записан в базу данных вместе с другими атрибутами при сохранении пользователя. (Теперь вы знаете почему остальные before_save коллбэки из Листинга 6.20 используют self.email вместо просто email.)

    Обратите внимание: мы шифровали токен с помощью SHA1 - хэширующего алгоритма который намного быстрее чем алгоритм Bcrypt используемый нами для шифрования паролей пользователей в Разделе 6.3.1, что важно, поскольку (как мы увидим в Разделе 8.2.2) для вошедших пользователей он будет выполняться на каждой странице. SHA1 является менее безопасным чем Bcrypt, но в данном случае его более чем достаточно так как шифруемый токен уже является 16-значной случайной строкой; SHA1 hexdigest такой строки по сути является невзламываемым. (Вызов to_s нужен для того чтобы мы имели возможность работать с nil токенами - этого не должно происходить в браузерах, но иногда может случаться в тестах.)

    Методы Если методу не нужен экземпляр объекта, он должен быть методом класса. и они являются публичными методами (выше строки private) поскольку в Разделе 8.2.3 мы будем их использовать за пределами модели User.

    Собрав все это воедино мы приходим модели User показанной в Листинге 8.18.

    class User < ActiveRecord::Base
      before_save { self.email = email.downcase }
      before_create :create_remember_token
      .
      .
      .
      def User.new_remember_token
        SecureRandom.urlsafe_base64
      end
    
      def User.encrypt(token)
        Digest::SHA1.hexdigest(token.to_s)
      end
    
      private
    
        def create_remember_token
          self.remember_token = User.encrypt(User.new_remember_token)
        end
    end

    Кстати, дополнительный уровень отступа на create_remember_token сделан для того, чтобы визуально отделить методы определенные после private. (Практика показала что это мудрая практика.)

    Поскольку шифрованная строка SecureRandom.urlsafe_base64 определенно не пустая, тесты для модели User теперь должны пройти:

    $ bundle exec rspec spec/models/user_spec.rb

    Рабочий метод sign_in

    Теперь мы готовы к написанию первого элемента входа - самой sign_in функции. Как было отмечено выше, выбранный нами метод аутентификации заключается в помещении (вновь созданного) remember token в качестве куки в браузер пользователя и последующем использовании токена для поиска записи пользователя в базе данных при перемещении пользователя от страницы к странице (реализовано в Разделе 8.2.3). Результирующий Листинг 8.19 вводит новый для нас метод current_user который мы будем реализовывать в Разделе 8.2.3.

    module SessionsHelper
    
      def sign_in(user)
        remember_token = User.new_remember_token
        cookies.permanent[:remember_token] = remember_token
        user.update_attribute(:remember_token, User.encrypt(remember_token))
        self.current_user = user
      end
    end

    Здесь мы следуем избранной тактике: во-первых, создаем новый токен; во-вторых, помещаем зашифрованный токен в куки браузера; в-третьих, сохраняем зашифрованный токен в базе данных; в-четвертых, устанавливаем текущего пользователя равным данному пользователю (Раздел 8.2.3). Как мы увидим в Разделе 8.2.3, установка текущего пользователя равным user в данный момент не нужна из-за незамедлительного редиректа в create действии (Листинг 8.13), но все же это хорошая идея - на тот случай если мы когда-нибудь захотим использовать sign_in без редиректа.

    В Листинге 8.19 обратите внимание на использование update_attribute для сохранения токена. Как вкратце упоминалось в Разделе 6.1.5), этот метод позволяет обновлять один атрибут в обход валидаций — в данном случае это необходимо так как у нас нет пароля пользователя. Листинг 8.19 также вводит утилиту cookies которая позволяет нам манипулировать куками браузера как если бы они были хэшем; каждый элемент в куки представляет из себя хэш из двух элементов: value и (необязательный) expires дата (# дата истечения). Например, мы могли бы осуществить вход пользователя путем размещения куки со значением, равным пользовательскому токену, которая истекает через 20 лет:

    cookies[:remember_token] = { value:   remember_token,
                                 expires: 20.years.from_now.utc }

    (Этот код использует один из удобных Rails помощников, о чем говорится в Блоке 8.1.)

    ______________________________________________________________

    Блок 8.1.Куки истекают через 20.years.from_now

    Вы можете вспомнить из Раздела 4.4.2, что Ruby позволяет добавлять методы к любому, даже встроенному классу. В том разделе мы добавляли palindrome? метод к String классу (и в результате обнаружили, что "deified" является палиндромом), и мы также видели, как Rails добавляет blank? метод к классу Object (таким образом, "".blank?, " ".blank?, и nil.blank? все являются true). Код куки в Листинге 8.19 (который внутренне устанавливает срок действия cookie в 20.years.from_now) дает еще один пример из этой практики, посредством одного из Rails’ временных хелперов, которые являются методами добавленными к Fixnum (базовый класс для чисел):

    $ rails console
      >> 1.year.from_now
      => Sun, 13 Mar 2011 03:38:55 UTC +00:00
      >> 10.weeks.ago
      => Sat, 02 Jan 2010 03:39:14 UTC +00:00

    Rails добавляет и другие помощники:

      >> 1.kilobyte
      => 1024
      >> 5.megabytes
      => 5242880

    Они полезны для валидации загрузки, что позволяет легко ограничить, например, загрузку изображений размером в 5.megabytes.

    Хотя она должна использоваться с осторожностью, возможность добавлять методы к встроенным классам позволяет создавать черезвычайно естественные добавления к обычному Ruby. Действительно, большая часть элегантности Rails в конечном счете, является производной от податливости лежащего в его основе языка Ruby.

    ______________________________________________________________

    Паттерн установки куки истекающей через 20 лет стал настолько общепринятым, что Rails добавил специальный метод permanent для его реализации, так что мы можем просто написать

    cookies.permanent[:remember_token] = remember_token

    Под капотом, применение permanent приводит к автоматической установке даты истечения куки через 20 лет (20.years.from_now).

    После того как куки установлены, на последующих представлениях страниц мы можем извлекать пользователя с кодом вроде

    User.find_by(remember_token: remember_token)

    (Как мы увидим в Листинге 8.22, на самом деле мы вначале должны захэшировать токен.) Конечно, cookies это на самом деле не хэш, поскольку назначение cookies действительно сохраняет кусочек текста в браузере, но частью красоты Rails является то, что он позволяет вам забыть о деталях и сконцентрироваться на написании приложения.

    Текущий пользователь

    Обсудив способ хранения пользовательского remember token в куки для последующего использования, теперь нам необходимо узнать как извлекать пользователя при последующем просмотре страниц. Давайте еще раз взглянем на функцию sign_in для того чтобы понять где мы находимся:

    module SessionsHelper
    
      def sign_in(user)
        remember_token = User.new_remember_token
        cookies.permanent[:remember_token] = remember_token
        user.update_attribute(:remember_token, User.encrypt(remember_token))
        self.current_user = user
      end
    end

    Единственный участок кода который в данный момент не работает это:

    self.current_user = user

    Как было отмечено сразу после Листинга 8.19, этот код никогда не будет использоваться в данном приложении из-за немедленного редиректа в Листинге 8.13, но для метода sign_in было бы опасным полагаться на это.

    Целью current_user, доступного и в контроллерах и в представлениях является возможность создания конструкции подобные этой:

    <%= current_user.name %<

    и

    redirect_to current_user

    Использование self в назначении является необходимым по тем же причинам что были отмечены в обсуждении приведшем к Листингу 8.18: без self Ruby будет просто создавать локальную переменную с названием current_user.

    Для того, чтобы начать писать код для current_user, обратите внимание, что строка

    self.current_user = user

    это назначение, которое мы должны определить. В Ruby есть специальный синтаксис для определения таких назначаемых функций, показанный в Листинге 8.20.

    module SessionsHelper
    
      def sign_in(user)
        .
        .
        .
      end
    
      def current_user=(user)
        @current_user = user
      end
    end

    Это может выглядеть сбивающим с толку — большинство языков не позволит вам использовать знак равенства в определении метода, но это просто определение метода current_user= специально разработанного для обработки назначения current_user. Другими словами, код

    self.current_user = ...

    автоматически конвертируется в

    current_user=(...)

    тем самым вызывая метод current_user=. Его единственный аргумент это то, что находится справа от назначения, в данном случае - пользователь который войдет. Однострочный метод в теле просто устанавливает переменную экземпляра @current_user, эффективно хранящую пользователя для дальнейшего использования.

    В обычном Ruby, мы могли бы определить второй метод, current_user, предназначенный для возвращения значения @current_user, как это показано в Листинге 8.21.

    module SessionsHelper
    
      def sign_in(user)
        .
        .
        .
      end
    
      def current_user=(user)
        @current_user = user
      end
    
      def current_user
        @current_user     # Useless! Don't use this line.
      end
    end

    Если бы мы сделали это, мы бы фактически повторили функциональность На самом деле, эти двое абсолютно эквивалентны; attr_accessor это просто удобный способ создавать такие getter/setter методы автоматически. Проблема в том, что он совершенно не в состоянии решить наши проблемы: с кодом в Листинге 8.21, статус вошедшего пользователя будет забыт: как только пользователь перейдет на другую страницу — poof! — сессия закончится и пользователь автоматически выйдет. Это связано с тем что в HTTP отсутствует сохранение промежуточного состояния между парами "запрос-ответ" (Раздел 8.2.1) — когда пользователь делает второй запрос, все переменные устанавливаются к своим дефолтным значениям, в случае переменных экземпляра вроде @current_user это nil. Таким образом, когда пользователь обратится к еще одной странице, даже находясь в том же приложении, Rails установит @current_user равным nil и код в Листинге 8.21 не сделает то чего вы от него ожидали.

    Для того чтобы избежать этой проблемы мы можем искать пользователя соответствующего remember token созданному кодом в Листинге 8.19, как это показано в Листинге 8.22. Обратите внимание: поскольку токен хранимый в базе данных зашифрован, нам нужно зашифровать токен полученный из куки прежде чем использовать его для поиска пользователя в базе данных. Мы достигним этого с помощью метода User.encrypt определенного в Листинге 8.18.

    module SessionsHelper
      .
      .
      .
      def current_user=(user)
        @current_user = user
      end
    
      def current_user
        remember_token = User.encrypt(cookies[:remember_token])
        @current_user ||= User.find_by(remember_token: remember_token)
      end
    end

    Листинг 8.22 использует общепринятый, но изначально обескураживающий ||= ("или равно") оператор присваиваивания (Блок 8.2). Его эффект заключается в установке переменной экземпляра @current_user пользователю, соответствующему remember token, но только если Как правило, это означает присвоение переменных, которые изначально nil, но обратите внимание - ложные (false) значения также будут переписаны оператором ||= Иными словами, конструкция

    @current_user ||= User.find_by(remember_token: remember_token)

    вызывает метод то является примером мемоизации, которая обсуждалась ранее в Блоке 6.3. Это полезно лишь в случае если current_user используется чаще чем один раз для запроса отдельно взятого пользователя; в любом случае, find_by будет вызван по крайней мере один раз при каждом посещении страницы на этом сайте.

    ______________________________________________________________

    Блок 8.2.Что за *$@! этот ваш ||= ?

    Конструкция ||= - очень Рубишная — то есть, она очень характерна для языка Ruby — и, следовательно, важно ее знать, если вы планируете много программировать на Ruby. Хотя на первый взгляд она может показаться таинственной, или равно легко понять по аналогии.

    Начнем с общепринятой идиомы для изменения определенной в настоящее время переменной. Многие компьютерные программы включают приращение переменной, как в

    x = x + 1

    Большинство языков обеспечивают синтаксическое сокращение для этой операции; в Ruby (и в C, C++, Perl, Python, Java, и т.д.), это выглядит следующим образом:

    x += 1

    Аналогичные конструкции существуют и для других операторов:

    $ rails console
      >> x = 1
      => 1
      >> x += 1
      => 2
      >> x *= 3
      => 6
      >> x -= 7
      => -1

    В каждом случае, паттерном является то, что x = x O y и x O= y эквивалентны для любого оператора O.

    Другим распространенным Ruby паттерном является назначение переменной, если она nil но в противном случае оставляя ее в покое. Вспоминая or оператор || из Раздела 4.2.3, мы можем записать это следующим образом:

    >> @user
      => nil
      >> @user = @user || "the user"
      => "the user"
      >> @user = @user || "another user"
      => "the user"

    Поскольку nil ложно в булевом контексте, первое присвоение это nil || "the user", что оценивается как "the user"; аналогично, второе присвоение является "the user" || "another user", которое также оценивается как "the user" — так как строки true в булевом контексте, серия || выражений прекращается после оценки первого выражения. (Эта практика оценки выражений || слева направо и остановки на первом истинном значении, известна как оценка короткого замыкания (short-circuit evaluation).)

    Сравнивая в консольной сессии различные операторы, мы видим, что @user = @user || value следует x = x O y паттерну с || вместо O, что позволяет предположить следующую эквивалентную конструкцию:

    >> @user ||= "the user"
      => "the user"

    Вуаля!

    ______________________________________________________________

    Изменение ссылок шаблона

    Мы подходим, наконец, к практическому применению всей нашей войти/выйти работы: мы сделаем ссылки в шаблоне меняющимися в зависимости от статуса пользователя. В частности, как показано на рис 8.3, мы организуем изменение ссылок при входе и выходе пользователей из системы, а также мы добавим ссылки на список всех пользователей, на страницу настроек пользователя (будет закончена в Главе 9) и одну для профиля текущего пользователя. При этом, мы получим прохождение тестов из Листинга 8.6, а это означает, что наш набор тестов станет польностью зеленым впервые с начала этой главы.

    Смена ссылок в шаблоне сайта подразумевает использование если-иначе ветвящихся структур внутри Embedded Ruby:

    <% if signed_in? %>
      # Ссылки для вошедших пользователей
    <% else %>
      # Ссылки для не вошедших пользователей
    <% end %>

    Такой код требует наличия булевого метода signed_in?, который мы сейчас и реализуем.

    Пользователь является вошедшим если в сессии существует текущий пользователь, т.e., если current_user не является nil. Это требует использования оператора "not", написанного с помощью восклицательного знака ! и обычно читаемого как "bang". В данном контексте пользователь является вошедшим если current_user является не nil, как это показано в Листинге 8.23.

    module SessionsHelper
    
      def sign_in(user)
        remember_token = User.new_remember_token
        cookies.permanent[:remember_token] = remember_token
        user.update_attribute(:remember_token, User.encrypt(remember_token))
        self.current_user = user
      end
    
      def signed_in?
        !current_user.nil?
      end
      .
      .
      .
    end

    Имея на руках метод signed_in?, мы готовы закончить ссылки шаблона. Это будут четыре новых ссылки, две из которых пока останутся заглушками (мы их доработаем в Главе 9):

    <%= link_to "Users", '#' %>
    <%= link_to "Settings", '#' %>

    Ссылка на выход, между прочим, использует путь выхода, определенный в Листинге 8.2:

    <%= link_to "Sign out", signout_path, method: "delete" %>

    (Обратите внимание на то, что ссылка на выход передает хэш аргументов указывающий на то, что она должна отправить HTTP запрос DELETE.Веб браузеры на самом деле не могут выдавать запрос DELETE; Rails подделывает его с помошью JavaScript. ) Наконец, мы добавим ссылку на профиль следующим образом:

    <%= link_to "Profile", current_user %>

    Здесь мы могли бы написать

    <%= link_to "Profile", user_path(current_user) %>

    но Rails позволяет нам ссылаться непосредственно на пользователя и в этом контексте current_user будет автоматически конвертирован в user_path(current_user).

    В процессе размещения новых ссылок в шаблон, мы воспользуемся случаем и создадим выпадающее меню с помощью Bootstrap, вы можете более подробно почитать об этом на странице компонентов Bootstrap. Полный результат представлен в Листинге 8.24. В частности, обратите внимание на CSS id и классы связанные с выпадающим меню Bootstrap.

    <header class="navbar navbar-fixed-top navbar-inverse">
      <div class="navbar-inner">
        <div class="container">
          <%= link_to "sample app", root_path, id: "logo" %>
          <nav>
            <ul class="nav pull-right">
              <li><%= link_to "Home", root_path %></li>
              <li><%= link_to "Help", help_path %></li>
              <% if signed_in? %>
                <li><%= link_to "Users", '#' %></li>
                <li id="fat-menu" class="dropdown">
                  <a href="#" class="dropdown-toggle" data-toggle="dropdown">
                    Account <b class="caret"></b>
                  </a>
                  <ul class="dropdown-menu">
                    <li><%= link_to "Profile", current_user %></li>
                    <li><%= link_to "Settings", '#' %></li>
                    <li class="divider"></li>
                    <li>
                      <%= link_to "Sign out", signout_path, method: "delete" %>
                    </li>
                  </ul>
                </li>
              <% else %>
                <li><%= link_to "Sign in", signin_path %></li>
              <% end %>
            </ul>
          </nav>
        </div>
      </div>
    </header>

    Выпадающее меню требует применения JavaScript библиотеки Bootstrap, которую мы можем включить с помощью Рельсового файлопровода, отредактировав файл application.js, как это показано в Листинге 8.25.

    //= require jquery
    //= require jquery_ujs
    //= require bootstrap
    //= require turbolinks
    //= require_tree .

    Здесь используется библиотека Sprockets для включения Bootstrap JavaScript, которая в свою очередь доступна благодаря гему bootstrap-sass из Раздела 5.1.2.

    С кодом в Листинге 8.24 все тесты должны пройти:

    $ bundle exec rspec spec/

    Вошедший пользователь теперь видит новые ссылки и выпадающее меню определенное Листингом 8.24, as shown in рис 8.24" type="example">Листингом 8.24, as shown in (рис 8.9) Вошедший пользователь с новыми ссылками и выпадающим меню.

    В этой точке вам следует проверить, что вы можете войти, закрыть браузер и быть по-прежнему вошедшими при повторном посещении примера приложения. Если хотите, вы можете даже проверить куки браузера для того чтобы посмотреть на результат непосредственно (рис 8.10).

    (рис 8.10) Куки remember token в локальном браузере.

    Вход после регистрации

    В принципе, хотя мы закончили с аутентификацией, вновь зарегистрированные пользователи могут оказаться сбитыми с толку, так как они не вошли в систему по умолчанию. Реализация этого - последний штрих который мы добавим прежде чем позволим пользователям входить на наш сайт. Мы начнем с добавления строки к тестам аутентификации (Листинг 8.26). Это включает "after saving the user" describe блок из Листинга 7.32 (Раздел 7.6), который вы должны добавить в тест, если вы не сделали этого в соответствующем упражнении

    require 'spec_helper'
    
    describe "User pages" do
        .
        .
        .
        describe "with valid information" do
          .
          .
          .
          describe "after saving the user" do
            before { click_button submit }
            let(:user) { User.find_by(email: 'user@example.com') }
    
            it { should have_link('Sign out') }
            it { should have_title(user.name) }
            it { should have_selector('div.alert.alert-success', text: 'Welcome') }
          end
        end
      end
    end

    Здесь мы протестировали появление ссылки на выход для того чтобы убедиться что пользователь успешно вошел после регистрации.

    С методом sign_in из Раздела 8.2, получение прохождения этого теста фактически впустив пользователя в систему легко: просто добавим sign_in @user сразу после сохранения пользователя в базе данных (Листинг 8.27).

    class UsersController < ApplicationController
      .
      .
      .
      def create
        @user = User.new(user_params)
        if @user.save
          sign_in @user
          flash[:success] = "Welcome to the Sample App!"
          redirect_to @user
        else
          render 'new'
        end
      end
      .
      .
      .
    end

    Выход

    Как обсуждалось в Разделе 8.1, наша аутентификационная модель предполагает сохранение пользователей вошедшими до тех пор, пока они явно не выйдут из системы. В этом разделе мы добавим эту, необходимую нам и пользователям, возможность выхода.

    До сих пор действия контроллера Sessions следовали RESTful конвенции, используя new для страницы входа и create для его завершения. Мы продолжим эту тему используя действие destroy для удаления сессий, т.е., для выхода. Для того чтобы протестировать это, мы кликнем по ссылке "Sign out" а затем попробуем найти вновь появившуюся ссылку на вход (Листинг 8.28).

    require 'spec_helper'
    
    describe "Authentication" do
      .
      .
      .
      describe "signin" do
        .
        .
        .
        describe "with valid information" do
          .
          .
          .
          describe "followed by signout" do
            before { click_link "Sign out" }
            it { should have_link('Sign in') }
          end
        end
      end
    end

    Как и со входом пользователя, основанном на функции sign_in, выход пользователя просто перекладывает работу на функцию sign_out (Листинг 8.29).

    class SessionsController < ApplicationController
      .
      .
      .
      def destroy
        sign_out
        redirect_to root_url
      end
    end

    Как и другие элементы аутентификации, мы поместим nil в настоящий момент не является строгой необходимостью из-за незамедлительного редиректа в действии destroy, но все же это хорошая идея - на случай если мы когда-либо захотим использовать sign_out без редиректа.)

    module SessionsHelper
    
      def sign_in(user)
        remember_token = User.new_remember_token
        cookies.permanent[:remember_token] = remember_token
        user.update_attribute(:remember_token, User.encrypt(remember_token))
        self.current_user = user
      end
      .
      .
      .
      def sign_out
        current_user.update_attribute(:remember_token,
                                      User.encrypt(User.new_remember_token))
        cookies.delete(:remember_token)
        self.current_user = nil
      end
    end

    Это завершает триумвират регистрация/вход/выход и набор тестов должен пройти:

    $ bundle exec rspec spec/

    Стоит отметить, что наш набор тестов покрывает большую часть механизма аутентификации, но не все же не полностью. Например, мы не тестируем то как долго живет "remember me" куки и даже не тестируем устанавливается ли она вообще. Это возможно сделать, но практика показывает, что непосредственное тестирование значения куки является хрупким и имеет тенденцию зависеть от деталей реализации которые иногда меняются от одного релиза Rails к другому. Результатом служат рухнувшие тесты вполне себе рабочего кода. Фокусируясь на функционале верхнего уровня - проверяя что пользователи могут войти, оставаться вошедшими при переходе от страницы к странице и могут выйти - мы тестируем ядро кода приложения не заморачиваясь менее важными деталями.

    Введение в Cucumber (опционально)

    Закончив основу системы аутентификации примера приложения, мы собираемся воспользоваться подвернувшейся возможностью для того чтобы показать как написать тесты входа с помощью Cucumber - популярного инструмента для разработки через поведение, который пользуется значительной популярностью в сообществе Ruby. Этот раздел необязателен и может быть пропущен без потери целостности повествования.

    Cucumber позволяет определять текстовые истории описывающие поведение приложения. Множество Rails программистов находят Cucumber особенно полезным при работе над клиентскими проектами; поскольку они могут быть прочитаны даже технически не подкованными пользователями, тесты на Cucumber могут быть прочитаны (а иногда даже написаны) клиентом. Конечно же, применение тестового фреймворка, который не является чистым Ruby, имеет и оборотную сторону, и я считаю что текстовые истории зачастую могут быть излишне многословными. Тем не менее, Cucumber занял прочные позиции в Ruby-инструментарии тестирования и мне особенно нравится его акцент на поведении верхнего уровня, а не на деталях реализации.

    Поскольку акценты в этой книге смещены в сторону RSpec и Capybara, последующая презентация совершенно не претендует на полноту и исчерпывающее раскрытие темы. Ее цель - просто дать вам возможность ощутить вкус Cucumber-а (несомненно свежий и сочный) — если он поразит ваше воображение, существуют целые книги на эту тему готовые удовлетворить ваш аппетит. (Я особенно рекомендую The RSpec Book (David Chelimsky) и Rails 3 in Action (Ryan Bigg и Yehuda Katz), и The Cucumber Book (Matt Wynne и Aslak Hellesoy).)

    Установка и настройка

    Для того чтобы установить Cucumber, во-первых, добавьте гем cucumber-rails и служебный гем database_cleaner в группу :test в Gemfile (Листинг 8.31).

    .
    .
    .
    group :test do
      .
      .
      .
      gem 'cucumber-rails', '1.4.0', :require => false
      gem 'database_cleaner', github: 'bmabey/database_cleaner'
    end
    .
    .
    .

    Затем установите как обычно:

    $ bundle install

    Для того чтобы настроить приложение для использования Cucumber, мы затем генерируем несколько необходимых, поддерживающих его работу файлов и директорий:

    $ rails generate cucumber:install

    Это создает директорию features/ где будут жить файлы связанные с Cucumber.

    Фичи и шаги

    Огурцовые фичи это описания ожидаемого поведения с помощью plain-text языка называемого Gherkin. Gherkin тесты читаются во многом как хорошо написанные примеры RSpec, но, поскольку они написаны простым текстом, они более доступны для тех, кому комфортнее читать английский, а не код Руби.

    Наши Огурцовые фичи будут реализовывать небольшое количество примеров входа в Листинге 8.5 и Листинге 8.6. Для того чтобы начать, мы создадим файл signing_in.feature в директории features/.

    Огурцовые фичи начинаются с короткого описания функционала:

    Feature: Signing in

    Затем они добавляют индивидуальные сценарии. Например, для того, чтобы протестировать провальный вход, мы можем написать следующий сценарий:

      Scenario: Unsuccessful signin
        Given a user visits the signin page
        When they submit invalid signin information
        Then they should see an error message

    Аналогично, для того, чтобы протестировать успешный вход, мы можем добавить следующее:

      Scenario: Successful signin
        Given a user visits the signin page
          And the user has an account
        When the user submits valid signin information
        Then they should see their profile page
          And they should see a signout link

    Собрав все это вместе мы приходим к файлу Огурцовой фичи показанному в Листинге 8.32.

    Feature: Signing in
    
      Scenario: Unsuccessful signin
        Given a user visits the signin page
        When they submit invalid signin information
        Then they should see an error message
    
      Scenario: Successful signin
        Given a user visits the signin page
          And the user has an account
        When the user submits valid signin information
        Then they should see their profile page
          And they should see a signout link

    Для запуска фич мы используем исполняемую команду cucumber:

    $ bundle exec cucumber features/

    Сравните это с

    $ bundle exec rspec spec/

    В данном контексте стоит отметить, что, как и RSpec, Cucumber может быть вызван с помощью Rake-задачи:

    $ bundle exec rake cucumber

    (По непонятным для меня причинам, это иногда пишут как rake cucumber:ok.)

    Все что мы пока сделали, это лишь написали немного простого текста, так что не думаю что для вас стало сюрпризом что Огурцовые сценарии пока не проходят. Для того чтобы получить зеленый набор тестов, нам необходима добавить файл step, который свяжет строки простого текста с Руби-кодом. Файл отправляется в директорию features/step_definitions; мы назовем его authentication_steps.rb.

    Строки Feature и Scenario нужны в основном для документации, но каждой последующей строке нужен соответствующий Ruby. Например, строка

    Given a user visits the signin page

    в файле фич будет обработана соответствующим определением шага

    Given /^a user visits the signin page$/ do
      visit signin_path
    end

    В фиче, Given это просто строка, но в файле с шагами Given является методом который принимает регулярное выражение и блок. Регулярное выражение соответствует тексту строки в сценарии, а содержимое блока является чистым Руби кодом, необходимым для реализации шага. В данном случае, "a user visits the signin page" реализуется посредством

    visit signin_path

    Если это выглядит знакомым, все правильно: это просто Capybara, которая включена по умолчанию в файлы с Огурцовыми шагами. Следующие две строки тоже должны выглядеть знакомо; шаги сценария

    When they submit invalid signin information
    Then they should see an error message

    в файле фич обрабатываются следующими шагами:

    When /^they submit invalid signin information$/ do
      click_button "Sign in"
    end
    
    Then /^they should see an error message$/ do
      expect(page).to have_selector('div.alert.alert-error')
    end

    Первый шаг также использует Capybara, при этом второй использует объект Capybara page вместе с RSpec. Очевидно, вся работа с тестами, которую мы проделали с RSpec и Capybara также полезна с Cucumber.

    Остальные шаги обрабатываются аналогично. Конечный файл определения шагов преставлен в Листинге 8.33. Попробуйте добавлять шаги по одному, запуская

    $ bundle exec cucumber features/

    каждый раз до тех пор пока все тесты не пройдут.

    Given /^a user visits the signin page$/ do
      visit signin_path
    end
    
    When /^they submit invalid signin information$/ do
      click_button "Sign in"
    end
    
    Then /^they should see an error message$/ do
      expect(page).to have_selector('div.alert.alert-error')
    end
    
    Given /^the user has an account$/ do
      @user = User.create(name: "Example User", email: "user@example.com",
                          password: "foobar", password_confirmation: "foobar")
    end
    
    When /^the user submits valid signin information$/ do
      fill_in "Email",    with: @user.email
      fill_in "Password", with: @user.password
      click_button "Sign in"
    end
    
    Then /^they should see their profile page$/ do
      expect(page).to have_title(@user.name)
    end
    
    Then /^they should see a signout link$/ do
      expect(page).to have_link('Sign out', href: signout_path)
    end

    С кодом в Листинге 8.33, Огурцовые тесты должны пройти:

    $ bundle exec cucumber features/

    Контрапункт: кастомные проверки RSpec

    Написав несколько простых Огурцовых сценариев, стоит сравнить результат с эквивалентным примером на RSpec. Для начала, взглянем на Огурцовую фичу в Листинг 8.32 и соответствующее определение шагов в Листинге 8.33. Затем взглянем на RSpec request specs (интеграционные тесты):

    describe "Authentication" do
    
      subject { page }
    
      describe "signin" do
        before { visit signin_path }
    
        describe "with invalid information" do
          before { click_button "Sign in" }
    
          it { should have_title('Sign in') }
          it { should have_selector('div.alert.alert-error') }
        end
    
        describe "with valid information" do
          let(:user) { FactoryGirl.create(:user) }
          before do
            fill_in "Email",    with: user.email.upcase
            fill_in "Password", with: user.password
            click_button "Sign in"
          end
    
          it { should have_title(user.name) }
          it { should have_link('Profile',     href: user_path(user)) }
          it { should have_link('Sign out',    href: signout_path) }
          it { should_not have_link('Sign in', href: signin_path) }
        end
      end
    end

    Вы можете видеть, что дело может быть сделано как с помощью Огурца, так и с помощью интеграционных тестов. Огурцовые фичи легкочитаемы, но они полностью отделены от кода который их реализует, а это палка о двух концах. Я считаю что Огурец легок в чтении, но сложен в написании, в то время как интеграционные тесты (для программистов) немного более сложны в чтении и намного более просты в написании.

    Один из замечательных эффектов Огурцового разделения является то, что он работает на более высоком уровне абстракции. Например, мы пишем

    Then they should see an error message

    для того, чтобы выразить ожидание увидеть сообщение об ошибке, и

    Then /^they should see an error message$/ do
      expect(page).to have_selector('div.alert.alert-error')
    end

    для того чтобы реализовать тест. Что особенно удобно в этом, так это то, что только второй элемент (шаг) зависит от реализации, таким образом, если мы изменим, например, класс CSS используемый для сообщений об ошибках, файл фич останется неизменным.

    В этом случае можно запечалиться переписывая

    should have_selector('div.alert.alert-error')

    в куче мест, в то время как вы на самом деле хотели лишь указать на то, что на странице должно присутствовать сообщение об ошибке. Эта практика тесно связывает тест с реализацией и мы должны были бы изменить это повсеместно при изменении реализации. В контексте чистого RSpec для этого есть решение, которое заключается в использовании кастомных проверок, позволяющих нам писать вместо этого следующее:

    should have_error_message('Invalid')

    Мы можем определить такие проверки в том же вспомогательном файле, в который мы поместили тестовый хелпер full_title в Разделе 5.3.4. Сам код выглядит примерно следующим образом:

    RSpec::Matchers.define :have_error_message do |message|
      match do |page|
        expect(page).to have_selector('div.alert.alert-error', text: message)
      end
    end

    Мы можем также определить вспомогательные функции для общепринятых операций:

    def valid_signin(user)
      fill_in "Email",    with: user.email
      fill_in "Password", with: user.password
      click_button "Sign in"
    end

    Получившийся в результате вспомогательный код показан в Листинге 8.34 (который включает в себя результаты Листинга 5.41 и Листинга 5.42 из Раздела 5.6). Я нахожу этот подход более гибким, нежели Огурцовые определения шагов, в особенности когда проверки или помощники долженствования натурально принимают аргумент, такой как valid_signin(user). Определения шагов может повторить эту функциональность с помощью проверок регулярных выражений, но я считаю такой подход гораздо более громоздким (# в оригинале - (cu)cumbersome).

    include ApplicationHelper
    
    def valid_signin(user)
      fill_in "Email",    with: user.email
      fill_in "Password", with: user.password
      click_button "Sign in"
    end
    
    RSpec::Matchers.define :have_error_message do |message|
      match do |page|
        expect(page).to have_selector('div.alert.alert-error', text: message)
      end
    end

    С кодом из Листинга 8.34, мы можем написать

    it { should have_error_message('Invalid') }

    и

    describe "with valid information" do
      let(:user) { FactoryGirl.create(:user) }
      before { valid_signin(user) }
      .
      .
      .

    В наших тестах есть множество примеров тесной связи между тестами и реализацией сайта. Прохождение по текущему набору тестов и разрыв связей между тестами и деталями реализации с помощью создания кастомных проверок и методов остается в качестве упражнения (Раздел 8.5).

    Заключение

    Мы очень многое узнали в этой главе, трансформируя наше многообещающее, но не сформированное приложение в сайт, обладающий полным набором функций для регистрации и входа/выхода пользователей. Все что нам необходимо для завершения аутентификационной функциональности, это ограничить доступ к страницам по статусу и идентификации пользователей. Мы выполним эту задачу, по пути дав пользователям возможность редактировать их информацию, а также дав администраторам возможность удалять пользователей из системы.

    Прежде чем двигаться далее, объедините изменения с мастер веткой:

    $ git add .
    $ git commit -m "Finish sign in"
    $ git checkout master
    $ git merge sign-in-out

    Затем отправьте изменения на удаленный репозиторий GitHub и продакшен сервер Heroku

    $ git push
    $ git push heroku
    $ heroku run rake db:migrate

    Упражнения

  • Реорганизуйте форму входа для использования form_tag вместо form_for. Убедитесь что набор тестов по-прежнему проходит. Подсказка: см.RailsCast on authentication in Rails 3.1, особенно обратите внимание на изменения в структуре хэша params.
  • Следуя примеру в Разделе 8.3.3, пройдитесь по интеграционным тестам пользователя и аутентификации (т.e., по файлам в директории spec/requests) и добавьте методы в spec/support/utilities.rb для отделения тестов от реализации. Факультативно: Организуйте служебный код в отдельные файлы и модули, и заставьте все работать, правильно включив модули в файле spec_helper.rb.
  • Вернуться к учебному плану