ThinkChat2.0新版上线,更智能更精彩,支持会话、画图、阅读、搜索等,送10W Token,即刻开启你的AI之旅 广告
# 10.2 密码重设 完成账户激活功能后(从而确认了用户的电子邮件地址可用),我们要处理一种常见的问题:用户忘记密码。我们会看到,密码重设的很多步骤和账户激活类似,所以这里会用到 [10.1 节](#account-activation)学到的知识。不过,开头不一样,和账户激活功能不同的是,密码重设要修改一个视图,还要创建两个表单(处理电子邮件地址提交和设定新密码)。 编写代码之前,我们先构思要实现的重设密码步骤。首先,我们要在演示应用的登录表单中添加“Forgot Password”(忘记密码)链接,如[图 10.7](#fig-login-forgot-password-mockup) 所示。 ![login forgot password mockup](https://box.kancloud.cn/2016-05-11_5733306a5efc8.png)图 10.7:“Forgot Password”链接的构思图 点击“Forgot Password”链接后打开一个页面,这个页面中有一个表单,要求输入电子邮件地址,提交后向这个地址发送一封包含密码重设链接的邮件,如[图 10.8](#fig-forgot-password-form-mockup) 所示。 ![forgot password form mockup](https://box.kancloud.cn/2016-05-11_5733306a74aac.png)图 10.8:“Forgot Password”表单的构思图 点击密码重设链接会打开一个表单,用户在这个表单中重设密码(还要填写密码确认),如[图 10.9](#fig-reset-password-form-mockup) 所示。 ![reset password form mockup](https://box.kancloud.cn/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/&lt;token&gt;/edit | `edit` | `edit_password_reset_path(token)` | | `PATCH` | /password_resets/&lt;token&gt; | `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") %> <h1>Log in</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= 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 %> <span>Remember me on this computer</span> <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %> <p>New user? <%= link_to "Sign up now!", signup_path %></p> </div> </div> ``` ![forgot password link](https://box.kancloud.cn/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://box.kancloud.cn/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") %> <h1>Log in</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= 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 %> <span>Remember me on this computer</span> <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %> <p>New user? <%= link_to "Sign up now!", signup_path %></p> </div> </div> ``` 请求重设密码的表单和[代码清单 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") %> <h1>Forgot password</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= 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 %> </div> </div> ``` ![forgot password form](https://box.kancloud.cn/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://box.kancloud.cn/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 ``` <h1>Password reset</h1> <p>To reset your password click the link below:</p> <%= link_to "Reset password", edit_password_reset_url(@user.reset_token, email: @user.email) %> <p>This link will expire in two hours.</p> <p> If you did not request your password to be reset, please ignore this email and your password will stay as it is. </p> ``` 和账户激活邮件一样([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://box.kancloud.cn/2016-05-11_5733306b4195c.png)图 10.14:预览 HTML 格式的密码重设邮件![password reset text preview](https://box.kancloud.cn/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://box.kancloud.cn/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 <h1>Password reset</h1> <p>To reset your password click the link below:</p> <a href="http://rails-tutorial-c9-mhartl.c9.io/ password_resets/3BdBrXeQZSWqFIDRN8cxHA/ edit?email=michael%40michaelhartl.com">Reset password</a> <p>This link will expire in two hours.</p> <p> If you did not request your password to be reset, please ignore this email and your password will stay as it is. </p> ----==_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') %> <h1>Reset password</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= 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 %> </div> </div> ``` 注意,在[代码清单 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://box.kancloud.cn/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 ``` 如果你把 `&lt;` 当成小于号,读成“密码重设邮件发出少于两小时”就错了,和想表达的意思正好相反。 这里,最好把 `&lt;` 理解成“超过”,读成“密码重设邮件已经发出超过两小时”,这才是我们想表达的意思。`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://box.kancloud.cn/2016-05-11_5733306bd376d.png)图 10.18:密码重设失败![password reset success](https://box.kancloud.cn/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` 标签: ``` <input id="email" name="email" type="hidden" value="michael@example.com" /> ``` 现在,测试组件应该能通过: ##### 代码清单 10.55:**GREEN** ``` $ bundle exec rake test ```