9.4 删除用户
最后更新于:2022-04-01 22:29:55
# 9.4 删除用户
至此,用户列表页面完成了。符合 REST 架构的用户资源只剩下最后一个了——`destroy` 动作。本节,我们会先添加删除用户的链接(构思图如[图 9.13](#fig-user-index-delete-links-mockup) 所示),然后再编写 `destroy` 动作,完成删除操作。不过,首先我们要先创建管理员级别的用户,并授权这些用户执行删除操作。
![user index delete links mockup bootstrap](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333060d1889.png)图 9.13:显示有删除链接的用户列表页面构思图
## 9.4.1 管理员
我们要通过用户模型中一个名为 `admin` 的属性来判断用户是否具有管理员权限。`admin` 属性的类型为布尔值,Active Record 会自动生成一个 `admin?` 方法,返回布尔值,判断用户是否为管理员。添加 `admin` 属性后,用户数据模型如[图 9.14](#fig-user-model-admin) 所示。
![user model admin 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333060ea6bc.png)图 9.14:添加 `admin` 布尔值属性后的用户模型
和之前一样,我们要使用迁移添加 `admin` 属性,并且在命令行中指定其类型为 `boolean`:
```
$ rails generate migration add_admin_to_users admin:boolean
```
这个迁移会在 `users` 表中添加 `admin` 列,如[代码清单 9.50](#listing-admin-migration) 所示。注意,在[代码清单 9.50](#listing-admin-migration) 中我们在 `add_column` 方法中指定了 `default: false` 参数,意思是默认情况下用户不是管理员。(如果不指定 `default: false` 参数,`admin` 的默认值是 `nil`,也是假值,所以这个参数并不是必须的。不过,指定这个参数,可以更明确地向 Rails 以及代码的阅读者表明这段代码的意图。)
##### 代码清单 9.50:向用户模型中添加 `admin` 属性的迁移
db/migrate/[timestamp]_add_admin_to_users.rb
```
class AddAdminToUsers < ActiveRecord::Migration
def change
add_column :users, :admin, :boolean, default: false end
end
```
然后,像往常一样,执行迁移:
```
$ bundle exec rake db:migrate
```
和预想的一样,Rails 能自动识别 `admin` 属性的类型为布尔值,自动生成 `admin?` 方法:
```
$ rails console --sandbox
>> user = User.first
>> user.admin?
=> false
>> user.toggle!(:admin)
=> true
>> user.admin?
=> true
```
这里,我们使用 `toggle!` 方法把 `admin` 属性的值由 `false` 改为 `true`。
最后,我们要修改生成示例用户的代码,把第一个用户设为管理员,如[代码清单 9.51](#listing-populator-with-admin) 所示。
##### 代码清单 9.51:在生成示例用户的代码中把第一个用户设为管理员
db/seeds.rb
```
User.create!(name: "Example User",
email: "example@railstutorial.org",
password: "foobar",
password_confirmation: "foobar",
admin: true)
99.times do |n|
name = Faker::Name.name
email = "example-#{n+1}@railstutorial.org"
password = "password"
User.create!(name: name,
email: email,
password: password,
password_confirmation: password)
end
```
然后重新创建数据库:
```
$ bundle exec rake db:migrate:reset
$ bundle exec rake db:seed
```
### “健壮参数”再探
你可能注意到了,在[代码清单 9.51](#listing-populator-with-admin) 中,我们在初始化哈希参数中指定了 `admin: true`,把用户设为管理员。这么做的后果是,用户对象暴露在网络中了,如果在请求中提供初始化参数,恶意用户就可以发送如下的 `PATCH` 请求:[[10](#fn-10)]
```
patch /users/17?admin=1
```
这个请求会把 17 号用户设为管理员——这是个严重的潜在安全隐患。
鉴于此,必须只允许通过请求传入可安全编辑的属性。我们在 [7.3.2 节](chapter7.html#strong-parameters)说过,可以使用“健壮参数”实现这一限制,即在 `params` 哈希上调用 `require` 和 `permit` 方法:
```
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
```
注意,`admin` 并不在允许使用的属性列表中。这样就可以避免用户取得网站的管理权。因为这一步很重要,最好再为不可编辑的属性编写一个测试。针对 `:admin` 属性的测试留作[练习](#updating-showing-and-deleting-users-exercises)。
## 9.4.2 `destroy` 动作
完成用户资源的最后一步是,添加删除链接和 `destroy` 动作。我们先在用户列表页面每个用户后面加入一个删除链接,而且限制只有管理员才能执行删除操作。只有当前用户是管理员才能看到删除链接。视图如[代码清单 9.52](#listing-delete-links) 所示。
##### 代码清单 9.52:删除用户的链接(只有管理员能看到)
app/views/users/_user.html.erb
```
<%= gravatar_for user, size: 50 %>
<%= link_to user.name, user %>
<% if current_user.admin? && !current_user?(user) %>
| <%= link_to "delete", user, method: :delete,
data: { confirm: "You sure?" } %><% end %>
```
注意 `method: delete` 参数,它指明点击链接后发送的是 `DELETE` 请求。我们还把链接放在了 `if` 语句中,这样就只有管理员才能看到删除用户的链接。管理员看到的页面如[图 9.15](#fig-index-delete-links-rails-3) 所示。
![index delete links 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306116909.png)图 9.15:显示有删除链接的用户列表页面
浏览器不能发送 `DELETE` 请求,Rails 通过 JavaScript 模拟。也就是说,如果用户禁用了 JavaScript,那么删除用户的链接就不可用了。如果必须要支持没启用 JavaScript 的浏览器,可以使用一个发送 `POST` 请求的表单来模拟 `DELETE` 请求,这样即使禁用了 JavaScript,删除用户的链接仍能使用。[[11](#fn-11)]
为了让删除链接起作用,我们要定义 `destroy` 动作([表 7.1](chapter7.html#table-restful-users))。在 `destroy` 动作中,先找到要删除的用户,然后使用 Active Record 提供的 `destroy` 方法将其删除,最后再重定向到用户列表页面,如[代码清单 9.53](#listing-destroy-action) 所示。因为登录后才能删除用户,所以[代码清单 9.53](#listing-destroy-action) 还在 `logged_in_user` 事前过滤器中添加了 `:destroy`。
##### 代码清单 9.53:添加 `destroy` 动作
app/controllers/users_controller.rb
```
class UsersController < ApplicationController
before_action :logged_in_user, only: [:index, :edit, :update, :destroy] before_action :correct_user, only: [:edit, :update]
.
.
.
def destroy
User.find(params[:id]).destroy flash[:success] = "User deleted" redirect_to users_url end
.
.
.
end
```
注意,在 `destroy` 动作中,我们把 `find` 方法和 `destroy` 方法连在一起调用,只占了一行:
```
User.find(params[:id]).destroy
```
理论上,只有管理员才能看到删除用户的链接,所以只有管理员才能删除用户。但实际上还是存在一个严重的安全漏洞:只要攻击者有足够的经验,就可以在命令行中发送 `DELETE` 请求,删除网站中的任何用户。为了保障网站的安全,我们还要限制对 `destroy` 动作的访问,只让管理员删除用户。
和 [9.2.1 节](#requiring-logged-in-users)和 [9.2.2 节](#requiring-the-right-user)的做法一样,我们要使用事前过滤器限制访问。这一次,我们要限制只有管理员才能访问 `destroy` 动作。我们要定义一个名为 `admin_user` 的事前过滤器,如[代码清单 9.54](#listing-admin-destroy-before-filter) 所示。
##### 代码清单 9.54:限制只有管理员才能访问 `destroy` 动作的事前过滤器
app/controllers/users_controller.rb
```
class UsersController < ApplicationController
before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
before_action :correct_user, only: [:edit, :update]
before_action :admin_user, only: :destroy .
.
.
private
.
.
.
# 确保是管理员
def admin_user
redirect_to(root_url) unless current_user.admin? end
end
```
## 9.4.3 删除用户的测试
像删除用户这种危险的操作,一定要编写测试,确保表现和预期一样。首先,我们把一个用户固件设为管理员,如[代码清单 9.55](#listing-fixture-user-admin) 所示。
##### 代码清单 9.55:把一个用户固件设为管理员
test/fixtures/users.yml
```
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
admin: true
archer:
name: Sterling Archer
email: duchess@example.gov
password_digest: <%= User.digest('password') %>
lana:
name: Lana Kane
email: hands@example.gov
password_digest: <%= User.digest('password') %>
malory:
name: Malory Archer
email: boss@example.gov
password_digest: <%= User.digest('password') %>
<% 30.times do |n| %>
user_<%= n %>:
name: <%= "User #{n}" %>
email: <%= "user-#{n}@example.com" %>
password_digest: <%= User.digest('password') %>
<% end %>
```
按照 [9.2.1 节](#requiring-logged-in-users)的做法,我们会把限制访问动作的测试放在用户控制器的测试文件中。和[代码清单 8.28](chapter8.html#listing-user-logout-test) 一样,我们要使用 `delete` 方法直接向 `destroy` 动作发送 `DELETE` 请求。我们要检查两种情况:其一,没登录的用户会重定向到登录页面;其二,已经登录的用户,但不是管理员,会重定向到首页。测试如[代码清单 9.56](#listing-action-tests-admin) 所示。
##### 代码清单 9.56:测试只有管理员能访问的动作 GREEN
test/controllers/users_controller_test.rb
```
require 'test_helper'
class UsersControllerTest < ActionController::TestCase
def setup
@user = users(:michael)
@other_user = users(:archer)
end
.
.
.
test "should redirect destroy when not logged in" do
assert_no_difference 'User.count' do
delete :destroy, id: @user
end
assert_redirected_to login_url
end
test "should redirect destroy when logged in as a non-admin" do
log_in_as(@other_user)
assert_no_difference 'User.count' do
delete :destroy, id: @user
end
assert_redirected_to root_url
end
end
```
注意,在[代码清单 9.56](#listing-action-tests-admin) 中,我们使用 `assert_no_difference` 方法([代码清单 7.21](chapter7.html#listing-a-test-for-invalid-submission) 中用过)确认用户的数量没有变化。
[代码清单 9.56](#listing-action-tests-admin) 中的测试确认了未授权的用户(非管理员)不能删除用户,不过我们还要确认管理员点击删除链接后能成功删除用户。因为删除链接在用户列表页面,所以我们要把这个测试添加到用户列表页面的测试中([代码清单 9.44](#listing-user-index-test))。这个测试唯一需要一点技巧的代码是,管理员点击删除链接后如何确认用户被删除了。我们可以使用下面的代码实现:
```
assert_difference 'User.count', -1 do
delete user_path(@other_user)
end
```
我们使用[代码清单 7.26](chapter7.html#listing-a-test-for-valid-submission) 中检查创建了一个用户的 `assert_difference` 方法,不过这一次我们要确认向相应的地址发送 `DELETE` 请求后,`User.count` 的变化量是 `-1`,从而确认用户被删除了。
综上所述,针对分页和删除操作的测试如[代码清单 9.57](#listing-delete-link-integration-test) 所示,这段代码既测试了管理员执行的删除操作,也测试了非管理员执行的删除操作。
##### 代码清单 9.57:删除链接和删除用户操作的集成测试 GREEN
test/integration/users_index_test.rb
```
require 'test_helper'
class UsersIndexTest < ActionDispatch::IntegrationTest
def setup
@admin = users(:michael)
@non_admin = users(:archer)
end
test "index as admin including pagination and delete links" do
log_in_as(@admin)
get users_path
assert_template 'users/index'
assert_select 'div.pagination'
first_page_of_users = User.paginate(page: 1)
first_page_of_users.each do |user|
assert_select 'a[href=?]', user_path(user), text: user.name
unless user == @admin
assert_select 'a[href=?]', user_path(user), text: 'delete',
method: :delete
end
end
assert_difference 'User.count', -1 do
delete user_path(@non_admin)
end
end
test "index as non-admin" do
log_in_as(@non_admin)
get users_path
assert_select 'a', text: 'delete', count: 0
end
end
```
注意,[代码清单 9.57](#listing-delete-link-integration-test) 检查了每个用户旁都有删除链接,而且如果用户是管理员,就不做这个检查(因为管理员旁不会显示删除链接,参见[代码清单 9.52](#listing-delete-links))。
现在,删除用户的代码有了良好的测试,而且测试组件应该能通过:
##### 代码清单 9.58:**GREEN**
```
$ bundle exec rake test
```
';