devise認証やってみる(4/12)モデルの関連付け、バリデーション、ユニットテスト、セキュリティ [rails3]
CommunityGuides 4/12 - Relations, Validations, Unit Tests, Security
(回を重ねるごとにキレイになるどころか、より自分メモ的になってしまって読みづらくなってきてます。。。)
モデルの関連付け
migratationファイルの修正
DBに空白が入らないように、いくつかの項目に :null => false で制約をかける。
db/migrate/…create_users.rb
class DeviseCreateUsers < ActiveRecord::Migration
def self.up
create_table(:users) do |t|
t.database_authenticatable :null => false
t.recoverable
t.rememberable
t.trackable
t.confirmable
t.lockable :lock_strategy => :failed_attempts, :unlock_strategy => :time
# t.token_authenticatable
+ # author information
+ t.string :fullname
+ t.text :shortbio
+ t.string :weburl
+ t.integer :country_id, :null => false, :default => 1 # foreign key to country table
t.timestamps
end
+ add_index :users, :fullname
+ add_index :users, :country_id
add_index :users, :email, :unique => true
add_index :users, :reset_password_token, :unique => true
add_index :users, :confirmation_token, :unique => true
# add_index :users, :unlock_token, :unique => true
end
def self.down
drop_table :users
end
end
( +の行を追加。バージョン差異で吐き出されているコードが多少違うので不安を感じつつ。。。 )
class CreateArticles < ActiveRecord::Migration
def change
create_table :articles do |t|
t.integer :user_id, :null => false # + foreign key
t.string :title, :null => false # +
t.text :teaser, :null => false # +
t.text :body, :null => false # +
t.string :version
t.text :changelog
t.string :message # 却下時のユーザへのメッセージ
t.text :freezebody # 記事を受け取ると同時にtextbodyをこのフィールドにコピー
t.integer :state, :null => false, :default => 0 # + 0...下書き, 1...送信済み, 2...却下, 3...全記事, 4...オススメ記事
t.date :submitted
t.date :accepted
t.timestamps
end
add_index :articles, :user_id # +
end
end
def self.down
drop_table :articles
end
はとりあえず入れないでおく。。。
% rake db:drop
% rake db:migrate
モデルの関連づけ ユーザと記事
ユーザは複数の記事を持っている。
ひとつの記事はひとりのユーザに属する。
user.rb
has_many :articles, :dependent => :destroy
と
attr_accessibleの行に :fullname, :shortbio, :weburl
を追加
article.rb
空だったのでまるまる追加。
class Article < ActiveRecord::Base
belongs_to :user
validates :user_id, :presence => true
validates :title, :presence => true, :length => { :maximum => 80 }
validates :teaser, :presence => true, :length => { :maximum => 500 }
validates :body, :presence => true
validates :version, :length => { :maximum => 120 }
validates :changelog, :length => { :maximum => 2000 }
validates :message, :length => { :maximum => 5000 }
validates :state, :presence => true, :numericality => true, :inclusion => { :in => 0..4 }
end
:dependent => :destroy
関連するユーザが削除されると記事も削除される
その他ヴァリデーションはこちらを見てね、と。
ここまでのテストではarticle controllerに対してfunctional testをやってきたが、ヴァリデーションのテストはunit testを使用。
test/unit/article_test.rb
require 'test_helper'
class ArticleTest < ActiveSupport::TestCase
test "should not have empty title teaser body" do
article = Article.new
article.user_id = 1
assert article.invalid?
assert article.errors[:title].any?
assert article.errors[:teaser].any?
assert article.errors[:body].any?
assert !article.save
end
test "must belong to a user" do
article = Article.new :title => "Title", :teaser => "Teaser", :body => "Body"
assert article.invalid?
assert !article.save
end
test "should not have a state outside boundaries" do
article = Article.new :title => "Title", :teaser => "Teaser", :body => "Body"
article.user_id = 1
article.state = -1
assert !article.save
article.state = 'a'
assert !article.save
article.state = 5
assert !article.save
article.state = 0
assert article.save
article.state = 2
assert article.save
article.state = 4
assert article.save
end
end
Rescue
などのように存在しない記事を呼び出された場合。
通常はエラー画面や public/404.html などだがflash messageをページ内に表示させて対応したほうが親切。ということで。
app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
...
rescue_from ActiveRecord::RecordNotFound, :with => :record_not_found
...
protected
def record_not_found
flash[:error] = 'The article you requested could not be found.'
redirect_to root_url
end
end # end of controller file
ActiveRecordからnotfoundのエラーをもらってrecord_not_foundメソッドに渡して、rootのflash message部分に表示させる、のような感じ?
rescue_fromの詳細は、
もっとテストする
ちなににユニットテストも rake ?
% rake
9 tests, 13 assertions, 0 failures, 0 errors, 0 skips
テストが通るということは「適切なテストがテストスートにない」ということを表している。
他ユーザによる記事編集、ログインしていない状況、など。。。
未登録ユーザのリダイレクト
test/functional/articles_controller_test.rb
require 'test_helper'
class ArticlesControllerTest < ActionController::TestCase
setup do
@article_user1 = articles(:one)
@article_user2 = articles(:three)
end
# index and all
test "should get index and all anonymous" do
get :index
assert_response :success
assert_not_nil assigns(:articles)
get :all
assert_response :success
assert_not_nil assigns(:articles)
end
test "should get index and all signed in" do
sign_in users(:user2)
get :index
assert_response :success
assert_not_nil assigns(:articles)
get :all
assert_response :success
assert_not_nil assigns(:articles)
end
# about
test "should get about anonymous" do
get :about
assert_response :success
end
test "should get about signed in" do
sign_in users(:user2)
get :about
assert_response :success
end
# show
test "should show article anonymous" do
get :show, :id => @article_user1.to_param
assert_response :success
end
test "should show article signed in" do
sign_in users(:user2)
get :show, :id => @article_user1.to_param
assert_response :success
end
# new and edit
test "should not get new and edit anonymous" do
get :new
assert_redirected_to new_user_session_path
get :edit, :id => @article_user1.to_param
assert_redirected_to new_user_session_path
end
test "should get new signed in" do
sign_in users(:user2)
get :new
assert_response :success
end
test "new article has to belong to current user" do
sign_in users(:user2)
get :new
assert assigns(:article).user_id == users(:user2).id, "Article does not belong to current user"
end
test "should get edit to own article signed in" do
sign_in users(:user2)
get :edit, :id => @article_user2.to_param
assert_response :success
end
test "edited article has to belong to current user" do
sign_in users(:user2)
get :edit, :id => @article_user1.to_param
assert_redirected_to root_url, "Should be redirected to root url if article of other user is requested"
assert_equal 'The article you requested could not be found.', flash[:error]
end
# create
test "should not create article anonymous" do
assert_no_difference('Article.count') do
post :create, :article => @article_user1.attributes
end
end
test "should not create article linked to other user" do
sign_in users(:user2)
post :create, :article => { :user_id => users(:user1).id, :title => 'Title', :teaser => 'Teaser', :body => 'Body' }
assert assigns(:article).user_id == users(:user2).id, "Article does not belong to current user"
end
test "should create article signed in" do
sign_in users(:user2)
assert_difference('Article.count', 1, "Article count has not changed") do
post :create, :article => { :user_id => users(:user2).id, :title => 'Title', :teaser => 'Teaser', :body => 'Body' }
end
assert_redirected_to article_path(assigns(:article))
assert_equal 'Article was successfully created.', flash[:notice]
end
# update
test "should not update article anonymous" do
put :update, :id => @article_user1.to_param, :article => @article_user1.attributes
assert_redirected_to new_user_session_path
end
test "should update article signed in" do
sign_in users(:user2)
put :update, :id => @article_user2.to_param, :article => @article_user2.attributes
assert_redirected_to article_path(assigns(:article))
end
test "should not update article linked to other user" do
sign_in users(:user2)
put :update, :id => @article_user1.to_param, :article => @article_user1.attributes
assert_redirected_to root_url, "Should be redirected to root url if article of other user is requested"
assert_equal 'The article you requested could not be found.', flash[:error]
end
# destroy
test "should not destroy article anonymous" do
assert_no_difference('Article.count') do
delete :destroy, :id => @article_user1.to_param
end
assert_redirected_to new_user_session_path
end
test "should destroy article signed in" do
sign_in users(:user2)
assert_difference('Article.count', -1) do
delete :destroy, :id => @article_user2.to_param
end
assert_redirected_to articles_path
end
test "should not destroy article linked to other user" do
sign_in users(:user2)
assert_no_difference('Article.count', "Article count has changed") do
delete :destroy, :id => @article_user1.to_param
end
assert_redirected_to root_url, "Should be redirected to root url if article of other user is requested"
assert_equal 'The article you requested could not be found.', flash[:error]
end
end
と
test/fixtures/articles.yml
one:
id: 1
user_id: 1
title: MyString
teaser: MyText
body: MyText
version: MyString
changelog: MyText
message: MyString
freezebody: MyText
state: 0
submitted: 2011-02-11
accepted: 2011-02-11
two:
id: 2
user_id: 1
title: MyString
teaser: MyText
body: MyText
version: MyString
changelog: MyText
message: MyString
freezebody: MyText
state: 3
submitted: 2011-02-11
accepted: 2011-02-11
three:
id: 3
user_id: 2
title: MyString
teaser: MyText
body: MyText
version: MyString
changelog: MyText
message: MyString
freezebody: MyText
state: 0
submitted: 2011-02-11
accepted: 2011-02-11
four:
id: 4
user_id: 2
title: MyString
teaser: MyText
body: MyText
version: MyString
changelog: MyText
message: MyString
freezebody: MyText
state: 1
submitted: 2011-02-11
accepted: 2011-02-11
five:
id: 5
user_id: 2
title: MyString
teaser: MyText
body: MyText
version: MyString
changelog: MyText
message: MyString
freezebody: MyText
state: 2
submitted: 2011-02-11
accepted: 2011-02-11
six:
id: 6
user_id: 2
title: MyString
teaser: MyText
body: MyText
version: MyString
changelog: MyText
message: MyString
freezebody: MyText
state: 3
submitted: 2011-02-11
accepted: 2011-02-11
seven:
id: 7
user_id: 2
title: MyString
teaser: MyText
body: MyText
version: MyString
changelog: MyText
message: MyString
freezebody: MyText
state: 4
submitted: 2011-02-11
accepted: 2011-02-11
% rake
1) Failure:
test_edited_article_has_to_belong_to_current_user(ArticlesControllerTest) [... ... .../test/functional/articles_controller_test.rb:76]:
Expected response to be a <:redirect>, but was <200>
2) Failure:
test_new_article_has_to_belong_to_current_user(ArticlesControllerTest) [... ... .../test/functional/articles_controller_test.rb:66]:
Article does not belong to current user
3) Failure:
test_should_not_create_article_linked_to_other_user(ArticlesControllerTest) [... ... .../test/functional/articles_controller_test.rb:89]:
Article does not belong to current user
4) Failure:
test_should_not_destroy_article_linked_to_other_user(ArticlesControllerTest) [... ... .../test/functional/articles_controller_test.rb:133]:
Article count has changed.
"Article.count" didn't change by 0.
<7> expected but was
<6>.
5) Failure:
test_should_not_update_article_linked_to_other_user(ArticlesControllerTest) [... ... .../test/functional/articles_controller_test.rb:113]:
20 tests, 32 assertions, 5 failures, 0 errors, 0 skips
失敗5つ。
articles controller の修正
@article = Article.find(params[:id])
をすべて以下に変更
@article = current_user.articles.find(params[:id])
現在ログイン中のユーザにひもづく記事だけを呼び出すようになる。
% rake
errorが出た。
6) Error:
test_should_show_article_anonymous(ArticlesControllerTest):
NoMethodError: undefined method `articles' for nil:NilClass
結局showだけ
@article = Article.find(params[:id])
のままにしておいて、 edit, update, destroy だけを変更したら通った。showは普通に考えたらcurrent_userに限定する必要はない気がするけど違うのかな?
20 tests, 35 assertions, 2 failures, 0 errors, 0 skips
new
@article = current_user.articles.new
and create
@article = current_user.articles.new(params[:article])
% rake
あとひとつ
1) Failure:
test_should_not_create_article_linked_to_other_user(ArticlesControllerTest) [... ... .../test/functional/articles_controller_test.rb:89]:
Article does not belong to current user
20 tests, 35 assertions, 1 failures, 0 errors, 0 skips
なぜか?
test "should not create article linked to other user" do
sign_in users(:user2)
post :create, :article => { :user_id => users(:user1).id, :title => 'Title', :teaser => 'Teaser', :body => 'Body' }
+ puts assigns(:article).user_id
assert assigns(:article).user_id == users(:user2).id, "Article does not belong to current user"
end
puts assigns(:article).user_idから出力されるのは「1」。つまりこの記事のuser_idはtestで指示した通りに1を持ってしまっている。もしuserがuser_idをparamに入力したらRailsはそのidをアサインする。ようになっている。mass asignment。
Railsはこのマスアサインメントの管理のために attr_accessible を使う。
article model:
app/models/article.rb
class Article < ActiveRecord::Base
belongs_to :user
+ attr_accessible :title, :teaser, :body, :version, :changelog
validates :user_id, :presence => true
validates :title, :presence => true, :length => { :maximum => 80 }
validates :teaser, :presence => true, :length => { :maximum => 500 }
validates :body, :presence => true
validates :version, :length => { :maximum => 120 }
validates :changelog, :length => { :maximum => 2000 }
validates :message, :length => { :maximum => 5000 }
validates :state, :presence => true, :numericality => true, :inclusion => { :in => 0..4 }
end
attr_accessibleではmass assignmentで有効なすべての属性?を定義する。もしパラメータがその他の属性を含む場合は、assignされない。
attr_protected という有効ではない属性を明示的に宣言するメソッドもある。がブラックリストよりもホワイトリストの方がより安全なのでお勧めしない。ホワイトは漏れがあった場合、それを追加すればいいが、ブラックの場合はすべてを追加する必要があり、なおかつ漏れがあるとセキュリティリスクが発生する。
エラー出るけどfailure 0 なら意図通りオッケー。(エラー3つ出るけどほんとにいいのか?)
1) Error:
test_should_create_article_signed_in(ArticlesControllerTest):
ActiveModel::MassAssignmentSecurity::Error: Can't mass-assign protected attributes: user_id
20 tests, 30 assertions, 0 failures, 3 errors, 0 skips
疑問点:
逆にmass assignさせてもいいケースはどんなケースなのか?
mass assignmentはどう便利なのか?
user_idとかはできるようにしとくと危ないのはわかるけど、全部できないようにしといたらどんな不便があるのか?
回答:
mass assignmentできるということはユーザが値をコントロールできるということ。
ユーザ入力を許すフィールドはattr_accessibleしてオッケーだけど、システムが振る番号や関連付けのためのuser_idなど、ユーザに任意で入力させたくない項目はmass assignの対象から外しておく、ということ。