10.2 密码重设

最后更新于:2022-04-01 22:30:06

# 10.2 密码重设 完成账户激活功能后(从而确认了用户的电子邮件地址可用),我们要处理一种常见的问题:用户忘记密码。我们会看到,密码重设的很多步骤和账户激活类似,所以这里会用到 [10.1 节](#account-activation)学到的知识。不过,开头不一样,和账户激活功能不同的是,密码重设要修改一个视图,还要创建两个表单(处理电子邮件地址提交和设定新密码)。 编写代码之前,我们先构思要实现的重设密码步骤。首先,我们要在演示应用的登录表单中添加“Forgot Password”(忘记密码)链接,如[图 10.7](#fig-login-forgot-password-mockup) 所示。 ![login forgot password mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306a5efc8.png)图 10.7:“Forgot Password”链接的构思图 点击“Forgot Password”链接后打开一个页面,这个页面中有一个表单,要求输入电子邮件地址,提交后向这个地址发送一封包含密码重设链接的邮件,如[图 10.8](#fig-forgot-password-form-mockup) 所示。 ![forgot password form mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306a74aac.png)图 10.8:“Forgot Password”表单的构思图 点击密码重设链接会打开一个表单,用户在这个表单中重设密码(还要填写密码确认),如[图 10.9](#fig-reset-password-form-mockup) 所示。 ![reset password form mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306a84c37.png)图 10.9:重设密码表单的构思图 和账户激活一样,我们要把“密码重设”看做一个资源,每个重设密码操作都有一个重设令牌和对应的摘要。主要的步骤如下: 1. 用户请求重设密码时,使用提交的电子邮件地址查找用户; 2. 如果数据库中有这个电子邮件地址,生成一个重设令牌和对应的摘要; 3. 把重设摘要保存在数据库中,然后给用户发送一封邮件,其中有一包含重设令牌和用户电子邮件地址的链接; 4. 用户点击这个链接后,使用电子邮件地址查找用户,然后对比令牌和摘要; 5. 如果匹配,显示重设密码的表单。 ## 10.2.1 资源 和账户激活一样([10.1.1 节](#account-activations-resource)),第一步要为资源生成控制器: ``` $ rails generate controller PasswordResets new edit --no-test-framework ``` 注意,我们指定了不生成测试的参数,因为我们不需要控制器测试(和 [10.1.4 节](#activation-test-and-refactoring)一样,要使用集成测试),所以最好不生成。 我们需要两个表单,一个请求重设密码([图 10.8](#fig-forgot-password-form-mockup)),一个修改用户模型中的密码([图 10.9](#fig-reset-password-form-mockup)),所以需要为 `new`、`create`、`edit` 和 `update` 四个动作制定路由——通过[代码清单 10.37](#listing-password-resets-resource) 中高亮显示的那行 `resources` 规则实现。 ##### 代码清单 10.37:添加“密码重设”资源的路由 config/routes.rb ``` Rails.application.routes.draw do root 'static_pages#home' get 'help' => 'static_pages#help' get 'about' => 'static_pages#about' get 'contact' => 'static_pages#contact' get 'signup' => 'users#new' get 'login' => 'sessions#new' post 'login' => 'sessions#create' delete 'logout' => 'sessions#destroy' resources :users resources :account_activations, only: [:edit] resources :password_resets, only: [:new, :create, :edit, :update] end ``` 添加这个规则后,得到了[表 10.2](#table-restful-password-resets) 中的 REST 路由。 表 10.2:定义“密码重设”资源后得到的 REST 路由 | HTTP 请求 | URL | 动作 | 具名路由 | | --- | --- | --- | --- | | `GET` | /password_resets/new | `new` | `new_password_reset_path` | | `POST` | /password_resets | `create` | `password_resets_path` | | `GET` | /password_resets/<token>/edit | `edit` | `edit_password_reset_path(token)` | | `PATCH` | /password_resets/<token> | `update` | `password_reset_path(token)` | 通过表中第一个路由可以得到指向“Forgot Password”表单的链接: ``` new_password_reset_path ``` 把这个链接添加到登录表单,如[代码清单 10.38](#listing-log-in-password-reset) 所示。添加后的效果如[图 10.10](#fig-forgot-password-link) 所示。 ##### 代码清单 10.38:添加打开忘记密码表单的链接 app/views/sessions/new.html.erb ``` <% provide(:title, "Log in") %>

Log in

<%= form_for(:session, url: login_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= link_to "(forgot password)", new_password_reset_path %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :remember_me, class: "checkbox inline" do %> <%= f.check_box :remember_me %> Remember me on this computer <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %>

New user? <%= link_to "Sign up now!", signup_path %>

``` ![forgot password link](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306aa41d7.png)图 10.10:添加“Forgot Password”链接后的登录页面 密码重设所需的数据模型和账户激活的类似([图 10.1](#fig-user-model-account-activation))。参照“记住我”功能([8.4 节](chapter8.html#remember-me))和账户激活功能([10.1 节](#account-activation)),密码重设需要一个虚拟的重设令牌属性,在重设密码的邮件中使用,以及一个重设摘要属性,用来取回用户。 如果存储未哈希的令牌,能访问数据库的攻击者就能发送一封重设密码邮件给用户,然后使用令牌和邮件地址访问对应的密码重设链接,从而获得账户控制权。因此,必须存储令牌的摘要。为了进一步保障安全,我们还计划过几个小时后让重设链接失效,所以要记录重设邮件发送的时间。据此,我们要添加两个属性:`reset_digest` 和 `reset_sent_at`,如[图 10.11](#fig-user-model-password-reset) 所示。 ![user model password reset](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306abc0c6.png)图 10.11:添加密码重设相关属性后的用户模型 执行下面的命令,创建添加这两个属性的迁移: ``` $ rails generate migration add_reset_to_users reset_digest:string \ > reset_sent_at:datetime ``` 然后像之前一样执行迁移: ``` $ bundle exec rake db:migrate ``` ## 10.2.2 控制器和表单 我们要参照前面为没有模型的资源编写表单的方法,即创建新会话的登录表单([代码清单 8.2](chapter8.html#listing-login-form)),编写请求重设密码的表单。为了便于参考,我们再把这个表单列出来,如[代码清单 10.39](#listing-login-form-redux) 所示。 ##### 代码清单 10.39:登录表单的代码 app/views/sessions/new.html.erb ``` <% provide(:title, "Log in") %>

Log in

<%= form_for(:session, url: login_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :remember_me, class: "checkbox inline" do %> <%= f.check_box :remember_me %> Remember me on this computer <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %>

New user? <%= link_to "Sign up now!", signup_path %>

``` 请求重设密码的表单和[代码清单 10.39](#listing-login-form-redux) 有很多共通之处,最大的区别是,`form_for` 中的资源和地址不一样,而且也没有密码字段。请求重设密码的表单如[代码清单 10.40](#listing-new-password-reset) 所示,渲染的结果如[图 10.12](#fig-forgot-password-form) 所示。 ##### 代码清单 10.40:请求重设密码页面的视图 app/views/password_resets/new.html.erb ``` <% provide(:title, "Forgot password") %>

Forgot password

<%= form_for(:password_reset, url: password_resets_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.submit "Submit", class: "btn btn-primary" %> <% end %>
``` ![forgot password form](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306ad1c8f.png)图 10.12:“Forgot Password”表单 提交[图 10.12](#fig-forgot-password-form) 中的表单后,我们要通过电子邮件地址查找用户,更新这个用户的 `reset_token`、`reset_digest` 和 `reset_sent_at` 属性,然后重定向到根地址,并显示一个闪现消息。和登录一样([代码清单 8.9](chapter8.html#listing-correct-login-failure)),如果提交的数据无效,我们要重新渲染这个页面,并且显示一个 `flash.now` 消息。据此,写出的 `create` 动作如[代码清单 10.41](#listing-create-password-reset) 所示。 ##### 代码清单 10.41:`PasswordResetsController` 的 `create` 动作 app/controllers/password_resets_controller.rb ``` class PasswordResetsController < ApplicationController def new end def create @user = User.find_by(email: params[:password_reset][:email].downcase) if @user @user.create_reset_digest @user.send_password_reset_email flash[:info] = "Email sent with password reset instructions" redirect_to root_url else flash.now[:danger] = "Email address not found" render 'new' end end def edit end end ``` 然后要在用户模型中定义 `create_reset_digest` 方法,如[代码清单 10.42](#listing-user-model-password-reset) 所示。 ##### 代码清单 10.42:在用户模型中添加重设密码所需的方法 app/models/user.rb ``` class User < ActiveRecord::Base attr_accessor :remember_token, :activation_token, :reset_token before_save :downcase_email before_create :create_activation_digest . . . # 激活账户 def activate update_attribute(:activated, true) update_attribute(:activated_at, Time.zone.now) end # 发送激活邮件 def send_activation_email UserMailer.account_activation(self).deliver_now end # 设置密码重设相关的属性 def create_reset_digest self.reset_token = User.new_token update_attribute(:reset_digest, User.digest(reset_token)) update_attribute(:reset_sent_at, Time.zone.now) end # 发送密码重设邮件 def send_password_reset_email UserMailer.password_reset(self).deliver_now end private # 把电子邮件地址转换成小写 def downcase_email self.email = email.downcase end # 创建并赋值激活令牌和摘要 def create_activation_digest self.activation_token = User.new_token self.activation_digest = User.digest(activation_token) end end ``` 如[图 10.13](#fig-invalid-email-password-reset) 所示,提交无效电子邮件地址时,应用的表现正常。为了让提交有效地址时应用也能正常运行,我们要定义发送密码重设邮件的方法,这一步会在 [10.2.3 节](#password-reset-mailer-method)完成。 ![invalid email password reset](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306b0bd8a.png)图 10.13:提交无效电子邮件地址后显示的“Forgot Password”表单 ## 10.2.3 邮件程序 [代码清单 10.42](#listing-user-model-password-reset) 中发送密码重设邮件的代码是: ``` UserMailer.password_reset(self).deliver_now ``` 让这个邮件程序运作起来所需的代码几乎和 [10.1.2 节](#account-activation-mailer-method)的账户激活邮件程序一样。我们首先在 `UserMailer` 中定义 `password_reset` 方法([代码清单 10.43](#listing-mail-password-reset)),然后再编写邮件的纯文本视图([代码清单 10.44](#listing-password-reset-text))和 HTML 视图([代码清单 10.45](#listing-password-reset-html))。 ##### 代码清单 10.43:发送密码重设链接 app/mailers/user_mailer.rb ``` class UserMailer < ApplicationMailer default from: "noreply@example.com" def account_activation(user) @user = user mail to: user.email, subject: "Account activation" end def password_reset(user) @user = user mail to: user.email, subject: "Password reset" end end ``` ##### 代码清单 10.44:密码重设邮件的纯文本视图 app/views/user_mailer/password_reset.text.erb ``` To reset your password click the link below: <%= edit_password_reset_url(@user.reset_token, email: @user.email) %> This link will expire in two hours. If you did not request your password to be reset, please ignore this email and your password will stay as it is. ``` ##### 代码清单 10.45:密码重设邮件的 HTML 视图 app/views/user_mailer/password_reset.html.erb ```

Password reset

To reset your password click the link below:

<%= link_to "Reset password", edit_password_reset_url(@user.reset_token, email: @user.email) %>

This link will expire in two hours.

If you did not request your password to be reset, please ignore this email and your password will stay as it is.

``` 和账户激活邮件一样([10.1.2 节](#account-activation-mailer-method)),我们可以使用 Rails 提供的邮件预览程序预览密码重设邮件。参照[代码清单 10.16](#listing-account-activation-preview),密码重设的邮件预览程序如[代码清单 10.46](#listing-password-reset-preview) 所示。 ##### 代码清单 10.46:预览密码重设邮件所需的方法 test/mailers/previews/user_mailer_preview.rb ``` # Preview all emails at http://localhost:3000/rails/mailers/user_mailer class UserMailerPreview < ActionMailer::Preview # Preview this email at # http://localhost:3000/rails/mailers/user_mailer/account_activation def account_activation user = User.first user.activation_token = User.new_token UserMailer.account_activation(user) end # Preview this email at # http://localhost:3000/rails/mailers/user_mailer/password_reset def password_reset user = User.first user.reset_token = User.new_token UserMailer.password_reset(user) end end ``` 然后就可以预览密码重设邮件了,HTML 格式和纯文本格式分别如[图 10.14](#fig-password-reset-html-preview) 和[图 10.15](#fig-password-reset-text-preview) 所示。 ![password reset html preview](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306b4195c.png)图 10.14:预览 HTML 格式的密码重设邮件![password reset text preview](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306b7b091.png)图 10.15:预览纯文本格式的密码重设邮件 参照账户激活邮件程序的测试([代码清单 10.18](#listing-real-account-activation-test)),密码重设邮件程序的测试如[代码清单 10.47](#listing-password-reset-mailer-test) 所示。注意,我们要创建密码重设令牌,以便在视图中使用。这一点和激活令牌不一样,激活令牌使用 `before_create` 回调创建([代码清单 10.3](#listing-user-model-activation-code)),但是密码重设令牌只会在用户成功提交“Forgot Password”表单后创建。在集成测试中很容易创建密码重设令牌(参见[代码清单 10.54](#listing-password-reset-integration-test)),但在邮件程序的测试中必须手动创建。 ##### 代码清单 10.47:添加密码重设邮件程序的测试 GREEN test/mailers/user_mailer_test.rb ``` require 'test_helper' class UserMailerTest < ActionMailer::TestCase test "account_activation" do user = users(:michael) user.activation_token = User.new_token mail = UserMailer.account_activation(user) assert_equal "Account activation", mail.subject assert_equal [user.email], mail.to assert_equal ["noreply@example.com"], mail.from assert_match user.name, mail.body.encoded assert_match user.activation_token, mail.body.encoded assert_match CGI::escape(user.email), mail.body.encoded end test "password_reset" do user = users(:michael) user.reset_token = User.new_token mail = UserMailer.password_reset(user) assert_equal "Password reset", mail.subject assert_equal [user.email], mail.to assert_equal ["noreply@example.com"], mail.from assert_match user.reset_token, mail.body.encoded assert_match CGI::escape(user.email), mail.body.encoded end end ``` 现在,测试组件应该能通过: ##### 代码清单 10.48:**GREEN** ``` $ bundle exec rake test ``` 有了[代码清单 10.43](#listing-mail-password-reset)、[代码清单 10.44](#listing-password-reset-text) 和[代码清单 10.45](#listing-password-reset-html) 之后,提交有效电子邮件地址后显示的页面如[图 10.16](#fig-valid-email-password-reset) 所示。服务器日志中记录的邮件类似于[代码清单 10.49](#listing-password-reset-email)。 ![valid email password reset](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306b96cce.png)图 10.16:提交有效电子邮件地址后显示的页面 ##### 代码清单 10.49:服务器日志中记录的一封密码重设邮件 ``` Sent mail to michael@michaelhartl.com (66.8ms) Date: Thu, 04 Sep 2014 01:04:59 +0000 From: noreply@example.com To: michael@michaelhartl.com Message-ID: <5407babbee139_8722b257d04576a@mhartl-rails-tutorial-953753.mail> Subject: Password reset Mime-Version: 1.0 Content-Type: multipart/alternative; boundary="--==_mimepart_5407babbe3505_8722b257d045617"; charset=UTF-8 Content-Transfer-Encoding: 7bit ----==_mimepart_5407babbe3505_8722b257d045617 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 7bit To reset your password click the link below: http://rails-tutorial-c9-mhartl.c9.io/password_resets/3BdBrXeQZSWqFIDRN8cxHA/ edit?email=michael%40michaelhartl.com This link will expire in two hours. If you did not request your password to be reset, please ignore this email and your password will stay as it is. ----==_mimepart_5407babbe3505_8722b257d045617 Content-Type: text/html; charset=UTF-8 Content-Transfer-Encoding: 7bit

Password reset

To reset your password click the link below:

Reset password

This link will expire in two hours.

If you did not request your password to be reset, please ignore this email and your password will stay as it is.

----==_mimepart_5407babbe3505_8722b257d045617-- ``` ## 10.2.4 重设密码 为了让下面这种形式的链接生效,我们要编写一个表单,重设密码。 ``` http://example.com/password_resets/3BdBrXeQZSWqFIDRN8cxHA/edit?email=foo%40bar.com ``` 这个表单的目的和编辑用户资料的表单([代码清单 9.2](chapter9.html#listing-user-edit-view))类似,不过现在只需更新密码和密码确认字段。而且处理起来有点复杂,因为我们希望通过电子邮件地址查找用户,也就是说,在 `edit` 动作和 `update` 动作中都需要使用邮件地址。在 `edit` 动作中可以轻易的获取邮件地址,因为链接中有。可是提交表单后,邮件地址就没有了。为了解决这个问题,我们可以使用一个“隐藏字段”,把这个字段的值设为邮件地址(不会显示),和表单中的其他数据一起提交给 `update` 动作,如[代码清单 10.50](#listing-password-reset-form) 所示。 ##### 代码清单 10.50:重设密码的表单 app/views/password_resets/edit.html.erb ``` <% provide(:title, 'Reset password') %>

Reset password

<%= form_for(@user, url: password_reset_path(params[:id])) do |f| %> <%= render 'shared/error_messages' %> <%= hidden_field_tag :email, @user.email %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :password_confirmation, "Confirmation" %> <%= f.password_field :password_confirmation, class: 'form-control' %> <%= f.submit "Update password", class: "btn btn-primary" %> <% end %>
``` 注意,在[代码清单 10.50](#listing-password-reset-form) 中,使用的表单字段辅助方法是 ``` hidden_field_tag :email, @user.email ``` 而不是 ``` f.hidden_field :email, @user.email ``` 因为在重设密码的链接中,邮件地址在 `params[:email]` 中,如果使用后者,就会把邮件地址放入 `params[:user][:email]` 中。 为了正确渲染这个表单,我们要在 `PasswordResetsController` 的 `edit` 控制器中定义 `@user` 变量。和账户激活一样([代码清单 10.29](#listing-account-activation-edit-action)),我们要找到 `params[:email]` 中电子邮件地址对应的用户,确认这个用户已经激活,然后使用[代码清单 10.24](#listing-generalized-authenticated-p) 中的 `authenticated?` 方法认证 `params[:id]` 中的令牌。因为在 `edit` 和 `update` 动作中都要使用 `@user`,所以我们要把查找用户和认证令牌的代码写入一个事前过滤器中,如[代码清单 10.51](#listing-password-reset-edit-action) 所示。 ##### 代码清单 10.51:重设密码的 `edit` 动作 app/controllers/password_resets_controller.rb ``` class PasswordResetsController < ApplicationController before_action :get_user, only: [:edit, :update] before_action :valid_user, only: [:edit, :update] . . . def edit end private def get_user @user = User.find_by(email: params[:email]) end # 确保是有效用户 def valid_user unless (@user && @user.activated? && @user.authenticated?(:reset, params[:id])) redirect_to root_url end end end ``` [代码清单 10.51](#listing-password-reset-edit-action) 中的 `authenticated?(:reset, params[:id])`,[代码清单 10.26](#listing-generalized-current-user) 中的 `authenticated?(:remember, cookies[:remember_token])`,以及[代码清单 10.29](#listing-account-activation-edit-action) 中的 `authenticated?(:activation, params[:id])`,就是[表 10.1](#table-password-token-digest) 中 `authenticated?` 方法的三个用例。 现在,点击[代码清单 10.49](#listing-password-reset-email) 中的链接后,会显示密码重设表单,如[图 10.17](#fig-password-reset-form) 所示。 ![password reset form](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306bb4777.png)图 10.17:密码重设表单 `edit` 动作对应的 `update` 动作要考虑四种情况:密码重设超时失效,重设成功,密码无效导致的重设失败,密码和密码确认为空值时导致的密码重设失败(此时看起来像是成功了)。前三种情况对应[代码清单 10.52](#listing-password-reset-update-action) 中外层 `if` 语句的三个分支。因为这个表单会修改 Active Record 模型(用户模型),所以我们可以使用共用的局部视图渲染错误消息。密码为空值的情况比较特殊,因为用户模型的验证允许出现这种情况(参见[代码清单 9.10](chapter9.html#listing-allow-blank-password)),所以要特别处理,直接在 `@user` 对象的错误消息中添加一个错误:[[8](#fn-8)] ``` @user.errors.add(:password, "can't be empty") ``` ##### 代码清单 10.52:重设密码的 `update` 动作 app/controllers/password_resets_controller.rb ``` class PasswordResetsController < ApplicationController before_action :get_user, only: [:edit, :update] before_action :valid_user, only: [:edit, :update] before_action :check_expiration, only: [:edit, :update] def new end def create @user = User.find_by(email: params[:password_reset][:email].downcase) if @user @user.create_reset_digest @user.send_password_reset_email flash[:info] = "Email sent with password reset instructions" redirect_to root_url else flash.now[:danger] = "Email address not found" render 'new' end end def edit end def update if params[:user][:password].empty? @user.errors.add(:password, "can't be empty") render 'edit' elsif @user.update_attributes(user_params) log_in @user flash[:success] = "Password has been reset." redirect_to @user else render 'edit' end end private def user_params params.require(:user).permit(:password, :password_confirmation) end # 事前过滤器 def get_user @user = User.find_by(email: params[:email]) end # 确保是有效用户 def valid_user unless (@user && @user.activated? && @user.authenticated?(:reset, params[:id])) redirect_to root_url end end # 检查重设令牌是否过期 def check_expiration if @user.password_reset_expired? flash[:danger] = "Password reset has expired." redirect_to new_password_reset_url end end end ``` 我们把密码重设是否超时失效交给用户模型判断: ``` @user.password_reset_expired? ``` 所以,我们要定义 `password_reset_expired?` 方法。如 [10.2.3 节](#password-reset-mailer-method)的邮件模板所示,如果邮件发出后两个小时内没重设密码,就认为此次请求超时失效了。这个设想可以通过下面的 Ruby 代码实现: ``` reset_sent_at < 2.hours.ago ``` 如果你把 `<` 当成小于号,读成“密码重设邮件发出少于两小时”就错了,和想表达的意思正好相反。 这里,最好把 `<` 理解成“超过”,读成“密码重设邮件已经发出超过两小时”,这才是我们想表达的意思。`password_reset_expired?` 方法的定义如[代码清单 10.53](#listing-user-model-password-reset-expired) 所示。(对这个比较算式的证明参见 [10.6 节](#proof-of-expiration-comparison)。) ##### 代码清单 10.53:在用户模型中定义 `password_reset_expired?` 方法 app/models/user.rb ``` class User < ActiveRecord::Base . . . # 如果密码重设超时失效了,返回 true def password_reset_expired? reset_sent_at < 2.hours.ago end private . . . end ``` 现在,[代码清单 10.52](#listing-password-reset-update-action) 中的 `update` 动作可以使用了。密码重设失败和成功后显示的页面分别如[图 10.18](#fig-password-reset-failure) 和[图 10.19](#fig-password-reset-success) 所示。(稍等一会,[10.5 节](#account-activation-and-password-reset-exercises)中有一题,为第三个分支编写测试。) ![password reset failure](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306bd376d.png)图 10.18:密码重设失败![password reset success](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306bebaae.png)图 10.19:密码重设成功 ## 10.2.5 测试 本节,我们要编写一个集成测试,覆盖[代码清单 10.52](#listing-password-reset-update-action) 中的两个分支:重设失败和重设成功。(前面说过,第三个分支的测试留作练习。)首先,为重设密码生成一个测试文件: ``` $ rails generate integration_test password_resets invoke test_unit create test/integration/password_resets_test.rb ``` 这个测试的步骤大致和[代码清单 10.31](#listing-signup-with-account-activation-test) 中的账户激活测试差不多,不过开头有点不同。首先访问“Forgot Password”表单,分别提交有效和无效的电子邮件地址,电子邮件地址有效时要创建密码重设令牌,并且发送重设邮件。然后,访问邮件中的链接,分别提交无效和有效的密码,验证各自的表现是否正确。最终写出的测试如[代码清单 10.54](#listing-password-reset-integration-test) 所示。这是一个不错的练习,可以锻炼阅读代码的能力。 ##### 代码清单 10.54:密码重设的集成测试 test/integration/password_resets_test.rb ``` require 'test_helper' class PasswordResetsTest < ActionDispatch::IntegrationTest def setup ActionMailer::Base.deliveries.clear @user = users(:michael) end test "password resets" do get new_password_reset_path assert_template 'password_resets/new' # 电子邮件地址无效 post password_resets_path, password_reset: { email: "" } assert_not flash.empty? assert_template 'password_resets/new' # 电子邮件地址有效 post password_resets_path, password_reset: { email: @user.email } assert_not_equal @user.reset_digest, @user.reload.reset_digest assert_equal 1, ActionMailer::Base.deliveries.size assert_not flash.empty? assert_redirected_to root_url # 密码重设表单 user = assigns(:user) # 电子邮件地址错误 get edit_password_reset_path(user.reset_token, email: "") assert_redirected_to root_url # 用户未激活 user.toggle!(:activated) get edit_password_reset_path(user.reset_token, email: user.email) assert_redirected_to root_url user.toggle!(:activated) # 电子邮件地址正确,令牌不对 get edit_password_reset_path('wrong token', email: user.email) assert_redirected_to root_url # 电子邮件地址正确,令牌也对 get edit_password_reset_path(user.reset_token, email: user.email) assert_template 'password_resets/edit' assert_select "input[name=email][type=hidden][value=?]", user.email # 密码和密码确认不匹配 patch password_reset_path(user.reset_token), email: user.email, user: { password: "foobaz", password_confirmation: "barquux" } assert_select 'div#error_explanation' # 密码为空值 patch password_reset_path(user.reset_token), email: user.email, user: { password: "", password_confirmation: "" } assert_select 'div#error_explanation' # 密码和密码确认有效 patch password_reset_path(user.reset_token), email: user.email, user: { password: "foobaz", password_confirmation: "foobaz" } assert is_logged_in? assert_not flash.empty? assert_redirected_to user end end ``` [代码清单 10.54](#listing-password-reset-integration-test) 中的大多数用法前面都见过,但是针对 `input` 标签的测试有点陌生: ``` assert_select "input[name=email][type=hidden][value=?]", user.email ``` 这行代码的意思是,页面中有 `name` 属性、类型(隐藏)和电子邮件地址都正确的 `input` 标签: ``` ``` 现在,测试组件应该能通过: ##### 代码清单 10.55:**GREEN** ``` $ bundle exec rake test ```
';