Ruby on Rails Tutorial

Изучение Rails на Примерах

Майкл Хартл

Содержание

  1. Предисловие к русскому изданию
  2. Глава 1 От нуля к развертыванию
    1. 1.1 Введение
      1. 1.1.1 Комментарии для разных читателей
      2. 1.1.2 “Масштабирование” Rails
      3. 1.1.3 Соглашения в этой книге
    2. 1.2 За работу
      1. 1.2.1 Среда разработки
        1. Интегрированные Среды Разработки
        2. Текстовые редакторы и инструменты командной строки
        3. Браузеры
        4. Примечание об инструментах
      2. 1.2.2 Ruby, RubyGems, Rails и Git
        1. Rails Installer (Windows)
        2. Установка Git
        3. Установка Ruby
        4. Установка RubyGems
        5. Установка Rails
      3. 1.2.3 Первое приложение
      4. 1.2.4 Bundler
      5. 1.2.5 rails server
      6. 1.2.6 Модель-представление-контроллер (MVC)
    3. 1.3 Управление версиями с Git
      1. 1.3.1 Установка и настройка
        1. Первоначальная настройка системы
        2. Первоначальная настройка репозитория
      2. 1.3.2 Добавление и фиксация
      3. 1.3.3 Что хорошего Git делает для вас?
      4. 1.3.4 GitHub
      5. 1.3.5 Ветвление, редактирование, фиксация, объединение
        1. Ветвление
        2. Редактирование
        3. Фиксация
        4. Объединение
        5. Отправка
    4. 1.4 Развертывание
      1. 1.4.1 Установка Heroku
      2. 1.4.2 Развертывание на Heroku, шаг первый
      3. 1.4.3 Развертывание на Heroku, шаг второй
      4. 1.4.4 Команды Heroku
    5. 1.5 Заключение
  3. Глава 2 demo app
    1. 2.1 Планирование приложения
      1. 2.1.1 Моделирование пользователей
      2. 2.1.2 Моделирование микросообщений
    2. 2.2 Ресурс Users
      1. 2.2.1 Обзор пользователя
      2. 2.2.2 MVC в действии
      3. 2.2.3 Недостатки данного ресурса Users
    3. 2.3 Ресурс Microposts
      1. 2.3.1 Микрообзор микросообщений
      2. 2.3.2 Помещение micro в микросообщения
      3. 2.3.3 Пользователь has_many микросообщений
      4. 2.3.4 Иерархия наследования
      5. 2.3.5 Развертывание демонстрационного приложения
    4. 2.4 Заключение
  4. Глава 3 В основном статические страницы
    1. 3.1 Статические страницы
    2. 3.2 Наши первые тесты
      1. 3.2.1 Разработка через тестирование
      2. 3.2.2 Добавление страницы
        1. Красный
        2. Зеленый
        3. Refactor (реорганизация)
    3. 3.3 Немного динамические страницы
      1. 3.3.1 Тестирование изменения заголовка
      2. 3.3.2 Прохождение тестов заголовка
      3. 3.3.3 Встроенный Ruby
      4. 3.3.4 Устранение дублирования шаблонами
    4. 3.4 Заключение
    5. 3.5 Упражнения
    6. 3.6 Продвинутые настройки
      1. 3.6.1 Избавляемся от bundle exec
        1. RVM Bundler интеграция
        2. binstubs
      2. 3.6.2 Автоматизируем тесты с Guard
      3. 3.6.3 Ускоряем тесты с помощью Spork
        1. Guard и Spork
      4. 3.6.4 Запускаем тесты внутри Sublime Text
  5. Глава 4 Rails-приправленный Ruby
    1. 4.1 Причины
    2. 4.2 Строки и методы
      1. 4.2.1 Комментарии
      2. 4.2.2 Строки
        1. Вывод на экран
        2. Строки в одинарных кавычках
      3. 4.2.3 Объекты и передача сообщений
      4. 4.2.4 Определение методов
      5. 4.2.5 Возвращение к title хелперу
    3. 4.3 Другие структуры данных
      1. 4.3.1 Массивы и диапазоны
      2. 4.3.2 Блоки
      3. 4.3.3 Хэши и символы
      4. 4.3.4 Вновь CSS
    4. 4.4 Ruby классы
      1. 4.4.1 Конструкторы
      2. 4.4.2 Наследование классов
      3. 4.4.3 Изменение встроенных классов
      4. 4.4.4 Класс контроллер
      5. 4.4.5 Класс User
    5. 4.5 Заключение
    6. 4.6 Упражнения
  6. Глава 5 Заполнение шаблона
    1. 5.1 Добавление некоторых структур
      1. 5.1.1 Навигация по сайту
      2. 5.1.2 Bootstrap и кастомные CSS
      3. 5.1.3 Частичные шаблоны (partials)
    2. 5.2 Sass и файлопровод (asset pipeline)
      1. 5.2.1 Файлопровод
        1. Директории ассетов
        2. Файлы-манифесты
        3. Препроцессоры
        4. Производительность в продакшен
      2. 5.2.2 Синтаксически обалденные таблицы стилей
        1. Вложение
        2. Переменные
    3. 5.3 Ссылки в шаблоне
      1. 5.3.1 Тестирование маршрутов
      2. 5.3.2 Rails маршруты
      3. 5.3.3 Именованные маршруты
      4. 5.3.4 Приятный RSpec
    4. 5.4 Регистрация пользователей: Первый шаг
      1. 5.4.1 Контроллер Users
      2. 5.4.2 URL для регистрации
    5. 5.5 Заключение
    6. 5.6 Упражнения
  7. Глава 6 Моделирование пользователей
    1. 6.1 Модель User
      1. 6.1.1 Миграции базы данных
      2. 6.1.2 Файл модели
      3. 6.1.3 Создание объектов user
      4. 6.1.4 Поиск объектов user
      5. 6.1.5 Обновление объектов user
    2. 6.2 Валидации User
      1. 6.2.1 Начальные тесты для пользователей
      2. 6.2.2 Валидация наличия
      3. 6.2.3 Валидация длины
      4. 6.2.4 Валидация формата
      5. 6.2.5 Валидация уникальности
        1. Предостережение уникальности
    3. 6.3 Добавление безопасного пароля
      1. 6.3.1 Зашифрованный пароль
      2. 6.3.2 Пароль и подтверждение
      3. 6.3.3 Аутентификация пользователя
      4. 6.3.4 У пользователя есть безопасный пароль
      5. 6.3.5 Создание пользователя
    4. 6.4 Заключение
    5. 6.5 Упражнения
  8. Chapter 7 Регистрация
    1. 7.1 Демонстрация пользователей
      1. 7.1.1 Отладка и окружения Rails
      2. 7.1.2 A Ресурс Users
      3. 7.1.3 Тестирование страницы показывающей пользователя (с фабриками)
      4. 7.1.4 Изображение Gravatar и боковая панель
    2. 7.2 Форма регистрации
      1. 7.2.1 Тесты для регистрации пользователя
      2. 7.2.2 Применение form_for
      3. 7.2.3 HTML формы
    3. 7.3 Провальная регистрация
      1. 7.3.1 Рабочая форма
      2. 7.3.2 Строгие параметры
      3. 7.3.3 Сообщения об ошибках при регистрации
    4. 7.4 Успешная регистрация
      1. 7.4.1 Завершенная форма регистрации
      2. 7.4.2 Флэш
      3. 7.4.3 Первая регистрация
      4. 7.4.4 Развертывание приложения на сервере с SSL
    5. 7.5 Заключение
    6. 7.6 Упражнения
  9. Глава 8 Войти, выйти
    1. 8.1 Сессии и провальный вход
      1. 8.1.1 Sessions контроллер
      2. 8.1.2 Тестирование входа
      3. 8.1.3 Форма для входа
      4. 8.1.4 Обзор отправки формы
      5. 8.1.5 Рендеринг с флэш сообщением
    2. 8.2 Успешный вход
      1. 8.2.1 Запомнить меня
      2. 8.2.2 Рабочий метод sign_in
      3. 8.2.3 Текущий пользователь
      4. 8.2.4 Изменение ссылок шаблона
      5. 8.2.5 Вход после регистрации
      6. 8.2.6 Выход
    3. 8.3 Введение в Cucumber (опционально)
      1. 8.3.1 Установка и настройка
      2. 8.3.2 Фичи и шаги
      3. 8.3.3 Контрапункт: кастомные проверки RSpec
    4. 8.4 Заключение
    5. 8.5 Упражнения
  10. Глава 9 Обновление, демонстрация и удаление пользователей
    1. 9.1 Обновление пользователей
      1. 9.1.1 Форма для редактирования
      2. 9.1.2 Провальное редактирование
      3. 9.1.3 Успешное редактирование
    2. 9.2 Авторизация
      1. 9.2.1 Требование входа пользователей
      2. 9.2.2 Требование правильного пользователя
      3. 9.2.3 Дружелюбная переадресация
    3. 9.3 Отображение всех пользователей
      1. 9.3.1 Список пользователей
      2. 9.3.2 Образцы пользователей
      3. 9.3.3 Пагинация
      4. 9.3.4 Частичный рефакторинг
    4. 9.4 Уничтожение пользователей
      1. 9.4.1 Административные пользователи
        1. Возвращение к строгим параметрам
      2. 9.4.2 Destroy действие
    5. 9.5 Заключение
    6. 9.6 Упражнения
  11. Глава 10 Микросообщения пользователей
    1. 10.1 Модель Micropost
      1. 10.1.1 Базовая модель
      2. 10.1.2 Первая валидация
      3. 10.1.3 Ассоциации Пользователь/Микросообщения
      4. 10.1.4 Улучшение микросообщений
        1. Дефолтное пространство (scope)
        2. Dependent: destroy
      5. 10.1.5 Валидации контента
    2. 10.2 Просмотр микросообщений
      1. 10.2.1 Дополнение страницы показывающей пользователя
      2. 10.2.2 Образцы микросообщений
    3. 10.3 Манипулирование микросообщениями
      1. 10.3.1 Контроль доступа
      2. 10.3.2 Создание микросообщений
      3. 10.3.3 Предварительная реализация потока сообщений
      4. 10.3.4 Уничтожение микросообщений
    4. 10.4 Заключение
    5. 10.5 Упражнения
  12. Глава 11 Слежение за сообщениями пользователей
    1. 11.1 Модель Relationship
      1. 11.1.1 Проблема с моделью данных (и ее решение)
      2. 11.1.2 Ассоциации пользователь/взаимоотношение
      3. 11.1.3 Валидации
      4. 11.1.4 Читаемые пользователи
      5. 11.1.5 Читатели пользователя
    2. 11.2 Веб-интерфейс для читаемых пользователей
      1. 11.2.1 Образцы данных
      2. 11.2.2 Статистика и форма для слежения за сообщениями пользователя
      3. 11.2.3 Страницы с читаемыми и читателями
      4. 11.2.4 Стандартный способ реализации кнопки "читать" (follow)
      5. 11.2.5 Реализация кнопки "читать" (follow) с Ajax
    3. 11.3 Поток сообщений
      1. 11.3.1 Мотивация и стратегия
      2. 11.3.2 Первая реализация потока сообщений
      3. 11.3.3 Подзапросы
      4. 11.3.4 Новый поток сообщений
    4. 11.4 Заключение
      1. 11.4.1 Расширения к примеру приложения
        1. Реплики
        2. Обмен сообщениями
        3. Уведомления о новых читателях
        4. Напоминание пароля
        5. Подтверждение регистрации
        6. RSS канал
        7. REST API
        8. Поиск
      2. 11.4.2 Руководство по дальнейшим ресурсам
    5. 11.5 Упражнения

Предисловие

Моя компания (CD Baby) была одной из первых громко перешедших на Ruby on Rails, а затем еще громче вернувшейся обратно на PHP (Google расскажет вам об этой драме). Эту книгу, написанную Майклом Хартлом так высоко рекомендовали, что я должен был попробовать её, и Ruby on Rails Tutorial это всё, что я использовал, чтобы вернуться к Rails.

Хотя я уже прошел через много книг по Rails, это одна из немногих, что, наконец, зацепила меня. Было много написано книг типа «Путь Rails» — после которых я чувствовал себя неестественно, но после этой книги я наконец почувствовал себя естественно. Это также единственная книга по Rails, которая соблюдает методику «разработка через тестирование» на всем своем протяжении, этот подход строго рекомендуется специалистами, но он никогда не был так чётко продемонстрирован ранее. Наконец, Git, GitHub и Heroku присутствуют в демо-примерах, автор действительно дает вам почувствовать, что он хотел сделать реальный проект. Учебный код примеров не изолирован.

Линейное повествование — отличный формат. Лично я прошел Rails Tutorial в течении трёх долгих дней, делая все примеры и задачи в конце каждой главы. Делайте всё от начала до конца, не прыгая, и вы получите максимальную пользу.

Наслаждайтесь!

Derek Sivers (sivers.org)
Основатель CD Baby

Благодарности

Ruby On Rails Учебник во многом обязан моей предыдущей книге по Rails, RailsSpace и, следовательно, моему соавтору Aurelius Prochazka. Я хотел бы поблагодарить Aure как за работу, которую он проделал над прошлой книгой, так и за поддержку этой. Я также хотел бы поблагодарить Debra Williams Cauley, редактора обеих книг RailsSpace и Rails Tutorial; до тех пор, пока она не прекратит брать меня на бейсбол, я буду продолжать писать книги для нее.

Я хотел бы поблагодарить огромное количество Рубистов учивших и вдохновлявших меня на протяжении многих лет: David Heinemeier Hansson, Yehuda Katz, Carl Lerche, Jeremy Kemper, Xavier Noria, Ryan Bates, Geoffrey Grosenbach, Peter Cooper, Matt Aimonetti, Gregg Pollack, Wayne E. Seguin, Amy Hoy, Dave Chelimsky, Pat Maddox, Tom Preston-Werner, Chris Wanstrath, Chad Fowler, Josh Susser, Obie Fernandez, Ian McFarland, Steven Bristol, Pratik Naik, Sarah Mei, Sarah Allen, Wolfram Arnold, Alex Chaffee, Giles Bowkett, Evan Dorn, Long Nguyen, James Lindenbaum, Adam Wiggins, Tikhon Bernstam, Ron Evans, Wyatt Greene, Miles Forrest, хороших людей из Pivotal Labs, команду Heroku, thoughtbot ребят, и команду GitHub. Наконец, многих, многих читателей - слишком много чтобы перечислять их здесь - внёсших большое количество предложений по улучшению и сообщивших об ошибках во время написания этой книги, и я с благодарностью признаю их помощь в написании ее настолько хорошей, насколько это было возможно.

Об авторе

Майкл Хартл – автор Ruby on Rails Tutorial, лидирующего введения в веб разработку на Ruby on Rails. Его предыдущий опыт включает в себя написание и разработку RailsSpace - чрезвычайно устаревшего учебника по Rails и разработку Insoshi - некогда популярной, а ныне устаревшей платформы для социальных сетей написанной на Ruby on Rails. В 2011, Майкл получил Ruby Hero Award за его вклад в Ruby сообщество. Он закончил Harvard College, имеет степень Кандидата Физических Наук присвоенную в Caltech и является выпускником предпринимательских курсов Y Combinator.

Копирайт и лицензия

Ruby on Rails Tutorial: Learn Web Development with Rails. Copyright © 2012 by Michael Hartl. Весь исходный код в Ruby on Rails Tutorial доступен под MIT License и Beerware License.

Лицензия MIT

Copyright (c) 2013 Michael Hartl

Данная лицензия разрешает лицам, получившим копию данного программного
обеспечения и сопутствующей документации (в дальнейшем именуемыми
«Программное Обеспечение»), безвозмездно использовать Программное
Обеспечение без ограничений, включая неограниченное право на использование,
копирование, изменение, добавление, публикацию, распространение,
сублицензирование и/или продажу копий Программного Обеспечения, также
как и лицам, которым предоставляется данное Программное Обеспечение,
при соблюдении следующих условий:

Указанное выше уведомление об авторском праве и данные условия должны быть
включены во все копии или значимые части данного Программного Обеспечения.

ДАННОЕ ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ «КАК ЕСТЬ», БЕЗ КАКИХ-ЛИБО
ГАРАНТИЙ, ЯВНО ВЫРАЖЕННЫХ ИЛИ ПОДРАЗУМЕВАЕМЫХ, ВКЛЮЧАЯ, НО НЕ ОГРАНИЧИВАЯСЬ
ГАРАНТИЯМИ ТОВАРНОЙ ПРИГОДНОСТИ, СООТВЕТСТВИЯ ПО ЕГО КОНКРЕТНОМУ НАЗНАЧЕНИЮ И
ОТСУТСТВИЯ НАРУШЕНИЙ ПРАВ. НИ В КАКОМ СЛУЧАЕ АВТОРЫ ИЛИ ПРАВООБЛАДАТЕЛИ НЕ
НЕСУТ ОТВЕТСТВЕННОСТИ ПО ИСКАМ О ВОЗМЕЩЕНИИ УЩЕРБА, УБЫТКОВ ИЛИ ДРУГИХ
ТРЕБОВАНИЙ ПО ДЕЙСТВУЮЩИМ КОНТРАКТАМ, ДЕЛИКТАМ ИЛИ ИНОМУ, ВОЗНИКШИМ ИЗ,
ИМЕЮЩИМ ПРИЧИНОЙ ИЛИ СВЯЗАННЫМ С ПРОГРАММНЫМ ОБЕСПЕЧЕНИЕМ ИЛИ ИСПОЛЬЗОВАНИЕМ
ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ ИЛИ ИНЫМИ ДЕЙСТВИЯМИ С ПРОГРАММНЫМ ОБЕСПЕЧЕНИЕМ.
/*
 * ----------------------------------------------------------------------------
 * "ПИВНАЯ ЛИЦЕНЗИЯ" (Ревизия 42):
 * Весь код написан Майклом Хартлом. До тех пор пока вы осознаете это,
 * вы можете делать с ним все что захотите. Если мы когда нибудь
 * встретимся, и если это того стоило, вы можете купить мне
 * пиво в ответ.
 * ----------------------------------------------------------------------------
 */

Глава 8 Войти, выйти

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

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

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

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

$ git checkout -b sign-in-out

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

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

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

8.1.1 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).

signin_mockup_bootstrap
Рис. 8.1: Набросок формы входа. (полный размер)

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

Листинг 8.1. Тесты для new session действия и представления.
spec/requests/authentication_pages_spec.rb
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.

Листинг 8.2. Добавление ресурса для получения стандартных RESTful действий для сессий.
config/routes.rb
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).

HTTP запросURLИменованный маршрутДействиеЦель (назначение)
GET/signinsignin_pathnewстраница для новой сессии (вход)
POST/sessionssessions_pathcreateсоздание новой сессии
DELETE/signoutsignout_pathdestroyудаление сессии (выход)
Таблица 8.1: RESTful маршруты, обеспеченные правилами в Листинге 8.2.

Кстати, для генерации полного списка маршрутов вашего приложения вы можете использовать команду 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 действия для использования в будущем).

Листинг 8.3. Начальный контроллер Sessions.
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
  end

  def destroy
  end
end

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

Листинг 8.4. Начальное представление входа.
app/views/sessions/new.html.erb
<% provide(:title, "Sign in") %>
<h1>Sign in</h1>

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

$ bundle exec rspec spec/

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

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

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

signin_failure_mockup_bootstrap
Рис. 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.

Листинг 8.5. Тесты для провального входа.
spec/requests/authentication_pages_spec.rb
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

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

  1. Появление ссылки на страницу профиля пользователя
  2. Появление ссылки “Sign out”
  3. Исчезновение ссылки “Sign in”

(Мы отложим тесты для ссылки “Settings” до Раздела 9.1 и для ссылки “Users” до Раздела 9.3.) Набросок этих изменений представлен на Рис. 8.3.2 Обратите внимание на то, что ссылки на выход и на профиль пользователя появляются в выпадающем меню “Account”; в Разделе 8.2.4, мы увидим как сделать такое меню с помощью Bootstrap.

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

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

Листинг 8.6. Тесты успешного входа.
spec/requests/authentication_pages_spec.rb
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 адрес пользователя для того чтобы быть уверенными в том что наша способность находить пользователя в базе данных не зависит от регистра.

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

После написания тестов мы готовы приступить к разработке формы для входа. Вспомним из Листинга 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).)

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

Листинг 8.7. Код для формы входа.
app/views/sessions/new.html.erb
<% 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.4.

signin_form_bootstrap
Рис. 8.4: Форма для входа (/signin). (полный размер)

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

Листинг 8.8. HTML для формы входа произведеный Листингом 8.7.
<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.1.4 Обзор отправки формы

Как и в случае создания пользователей (регистрации), первый шаг в создании сессий (вход) состоит в обработке неверного ввода. У нас уже есть тесты для провальной регистрации (Листинг 8.5) и код приложения довольно прост за исключением пары тонкостей. Мы начнем с разбора того что происходит при отправке формы, а затем прикрутим полезное сообщение об ошибке появляющееся в случае провального входа (как это показано на наброске из Рис. 8.2.) Затем мы заложим основу для успешного входа (Раздел 8.2) научив наше приложение оценивать каждую попытку входа, опираясь на валидность предоставленной комбинации email/password.

Давайте начнем с определения минималистичного действия create для контроллера Sessions (Листинг 8.9), которое пока не будет делать ничего кроме рендеринга представления new. После чего, отправка формы /sessions/new с пустыми полями, будет приводить к результату показанному на Рис. 8.5.

Листинг 8.9. Предварительная версия Sessions create действия.
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def create
    render 'new'
  end
  .
  .
  .
end
initial_failed_signin_rails_3_bootstrap
Рис. 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 только если пользователь с данным адресом электронной почты и существует в базе данных и имеет данный пароль, что нам и было необходимо.

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

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

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

Листинг 8.10. (Неудачная) попытка обработки провального входа.
app/controllers/sessions_controller.rb
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), сообщение flash[:error] будет автоматически отображено; благодаря Bootstrap CSS, оно, к тому же, будет иметь приятный стиль (Рис. 8.6).

failed_signin_flash_bootstrap
Рис. 8.6: Флэш сообщение для провального входа. (полный размер)

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

flash_persistence_bootstrap
Рис. 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.

Листинг 8.11. Правильный тест на провальный вход.
spec/requests/authentication_pages_spec.rb
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.

Листинг 8.12. Исправленный код для провального входа.
app/controllers/sessions_controller.rb
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"

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

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

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

Листинг 8.13. Завершенное действие create контроллера Sessions (пока не рабочее).
app/controllers/sessions_controller.rb
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

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

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

Листинг 8.14. Включение модуля SessionsHelper в контроллер Application.
app/controllers/application_controller.rb
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.

user_model_remember_token_31
Рисунок 8.8: Модель User с добавленным атрибутом remember_token.

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

Листинг 8.15. Первый тест для remember token.
spec/models/user_spec.rb
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.

Листинг 8.16. Миграция для добавления remember_token к таблице users.
db/migrate/[ts]_add_remember_token_to_users.rb
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. Существует множество, в основном эквивалентных способов, по сути, подойдет любая длинная случайная строка если она будет уникальной. Метод urlsafe_base64 из модуля SecureRandom стандартной библиотеки Ruby вполне соответствует нашим требованиям:3 он возвращает случайную строку длиной в 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 в контексте уникальности адресов электронной почты. В том разделе мы использовали коллбэк before_save; в этот раз для создания remember_token непосредственно перед сохранением пользователя мы будем использовать очень похожий коллбэк before_create.4

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

Листинг 8.17. Тест на валидный (не пустой) remember token.
spec/models/user_spec.rb
require 'spec_helper'

describe User do

  before do
    @user = User.new(name: "Example User", email: "[email protected]",
                     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 токенами - этого не должно происходить в браузерах, но иногда может случаться в тестах.)

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

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

Листинг 8.18. Обратный вызов before_create для создания remember_token.
app/models/user.rb
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

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

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

Листинг 8.19. Завершенная (но пока-еще-не-работающая) функция sign_in.
app/helpers/sessions_helper.rb
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.)

Паттерн установки куки истекающей через 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 является то, что он позволяет вам забыть о деталях и сконцентрироваться на написании приложения.

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

Обсудив способ хранения пользовательского 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.

Листинг 8.20. Определение назначения current_user.
app/helpers/sessions_helper.rb
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.

Листинг 8.21. Заманчивое, но бесполезное определение current_user.
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, который мы видели в Разделе 4.4.5.6 Проблема в том, что он совершенно не в состоянии решить наши проблемы: с кодом в Листинге 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.

Листинг 8.22. Поиск текущего пользователя с помощью remember_token.
app/helpers/sessions_helper.rb
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, но только если @current_user не определен.7 Иными словами, конструкция

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

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

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

Мы подходим, наконец, к практическому применению всей нашей войти/выйти работы: мы сделаем ссылки в шаблоне меняющимися в зависимости от статуса пользователя. В частности, как показано на Рис. 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.

Листинг 8.23. Вспомогательный метод signed_in?.
app/helpers/sessions_helper.rb
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.9) Наконец, мы добавим ссылку на профиль следующим образом:

<%= 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.

Листинг 8.24. Изменение ссылок для вошедших пользователей.
app/views/layouts/_header.html.erb
<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.

Листинг 8.25. Добавление Bootstrap JavaScript библиотеки в application.js.
app/assets/javascripts/application.js
//= 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.9.

profile_with_signout_link_bootstrap
Рис. 8.9: Вошедший пользователь с новыми ссылками и выпадающим меню. (полный размер)

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

cookie_in_browser
Рис. 8.10: Куки remember token в локальном браузере. (полный размер)

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

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

Листинг 8.26. Тестирование того, что вновь зарегистрированные пользователи также являются вошедшими.
spec/requests/user_pages_spec.rb
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: '[email protected]') }

        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).

Листинг 8.27. Вход пользователя сразу после регистрации.
app/controllers/users_controller.rb
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.2.6 Выход

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

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

Листинг 8.28. Тест выхода пользователя.
spec/requests/authentication_pages_spec.rb
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).

Листинг 8.29. Уничтожение сессии (выход пользователя).
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def destroy
    sign_out
    redirect_to root_url
  end
end

Как и другие элементы аутентификации, мы поместим sign_out в вспомогательный модуль Sessions. Листинг 8.30 показывает шаги: мы вначале меняем remember token пользователя в базе данных (на тот случай если куки были украдены, поскольку в этом случае они могут быть использованы для авторизации пользователя), затем мы вызываем метод delete на куках для удаления remember token из сессии; в качестве необязательного шага, мы устанавливаем текущего пользователя равным nil. (Как и назначение в методе sign_in (Листинг 8.19), установка текущего пользователя равным nil в настоящий момент не является строгой необходимостью из-за незамедлительного редиректа в действии destroy, но все же это хорошая идея - на случай если мы когда-либо захотим использовать sign_out без редиректа.)

Листинг 8.30. Метод sign_out в модуле Sessions хелпер.
app/helpers/sessions_helper.rb
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 к другому. Результатом служат рухнувшие тесты вполне себе рабочего кода. Фокусируясь не функционале верхнего уровня - проверяя что пользователи могут войти, оставаться вошедшими при переходе от страницы к странице и могут выйти - мы тестируем ядро кода приложения не заморачиваясь менее важными деталями.

8.3 Введение в 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 Hellesøy).)

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

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

Листинг 8.31. Добавление гема cucumber-rails в Gemfile.
.
.
.
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.

8.3.2 Фичи и шаги

Огурцовые фичи это описания ожидаемого поведения с помощью 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.

Листинг 8.32. Огурцовые фичи для тестирования входа.
features/signing_in.feature
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/

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

Листинг 8.33. Завершенные шаги, необходимые для прохождения фич входа.
features/step_definitions/authentication_steps.rb
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: "[email protected]",
                      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/

8.3.3 Контрапункт: кастомные проверки 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).

Листинг 8.34. Добавление вспомогательного метода и кастомной RSpec проверки.
spec/support/utilities.rb
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).

8.4 Заключение

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

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

$ 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

8.5 Упражнения

  1. Реорганизуйте форму входа для использования form_tag вместо form_for. Убедитесь что набор тестов по-прежнему проходит. Подсказка: см. RailsCast on authentication in Rails 3.1, особенно обратите внимание на изменения в структуре хэша params.
  2. Следуя примеру в Разделе 8.3.3, пройдитесь по интеграционным тестам пользователя и аутентификации (т.e., по файлам в директории spec/requests) и добавьте методы в spec/support/utilities.rb для отделения тестов от реализации. Факультативно: Организуйте служебный код в отдельные файлы и модули, и заставьте все работать, правильно включив модули в файле spec_helper.rb.
  1. Другой распространенной моделью является завершение сессии после истечения определенного количества времени. Это особенно уместно на сайтах, содержащих конфиденциальную информацию, такую как банковские и финансово-торговые операции. 
  2. Изображение взято с http://www.flickr.com/photos/hermanusbackpackers/3343254977/
  3. Этот выбор опирается на RailsCast on remember me
  4. Более подробно о видах коллбэков, поддерживаемых библиотекой Active Record см. в обсуждении коллбэков в Rails Guides (# см. перевод на rusrails.ru). 
  5. Если методу не нужен экземпляр объекта, он должен быть методом класса. 
  6. На самом деле, эти двое абсолютно эквивалентны; attr_accessor это просто удобный способ создавать такие getter/setter методы автоматически. 
  7. Как правило, это означает присвоение переменных, которые изначально nil, но обратите внимание - ложные (false) значения также будут переписаны оператором ||=
  8. Это является примером мемоизации, которая обсуждалась ранее в Блоке 6.3
  9. Веб браузеры на самом деле не могут выдавать запрос DELETE; Rails подделывает его с помошью JavaScript.