昨日はsymfonyで最初にフォームを作りました。 Jobeetで新しい求人を投稿できますが、テストを追加する前に時間切れになりました。
これが今日行うことです。 この先、フォームフレームワークについてさらに詳しく学びます。
フォームを投稿する
求人作成とバリデーション処理用の機能テストを追加するためにjobActionsTest
ファイルを開きましょう。
ファイルの終わりに、求人作成ページを取得するために次のコードを追加します:
// test/functional/frontend/jobActionsTest.php $browser->info('3 - Post a Job page')-> info(' 3.1 - Submit a Job')-> get('/job/new')-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'new')-> end() ;
すでにリンクのクリックをシミュレートするためにclick()
メソッドを使いました。
同じclick()
メソッドはフォームを投稿するために使うことができます。
フォームに関して、メソッドの2番目の引数としてそれぞれのフィールドに対して投稿する値を渡すことができます。
実際のブラウザーのように、ブラウザーオブジェクトはフォームのデフォルト値と投稿された値をマージします。
フィールドの値を渡すために、これらの名前を知る必要があります。
ソースコードを開くもしくはFirefoxのWeb Developer Toolbarの"Forms > Display Form Details"機能を使う場合、company
フィールドの名前がjobeet_job[company]
であることがわかります。
note
PHPがjobeet_job[company]
のような名前を持つ入力フィールドに遭遇するとき、自動的にこれを名前がjobeet_job
である配列に変換します。
より明確にするために、JobeetJobForm
のconfigure()
メソッドの終わりで次のコードを追加することでフォーマットをjob[%s]
に変更してみましょう:
// lib/form/doctrine/JobeetJobForm.class.php $this->widgetSchema->setNameFormat('job[%s]');
この変更の後で、名前のcompany
はブラウザーでjob[company]
になります。
"Preview your job"ボタンを実際にクリックしてフォームに有効な値を渡しましょう:
// test/functional/frontend/jobActionsTest.php $browser->info('3 - Post a Job page')-> info(' 3.1 - Submit a Job')-> get('/job/new')-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'new')-> end()-> click('Preview your job', array('job' => array( 'company' => 'Sensio Labs', 'url' => 'http://www.sensio.com/', 'logo' => sfConfig::get('sf_upload_dir').'/jobs/sensio-labs.gif', 'position' => 'Developer', 'location' => 'Atlanta, USA', 'description' => 'You will work with symfony to develop websites for our customers.', 'how_to_apply' => 'Send me an email', 'email' => 'for.a.job@example.com', 'is_public' => false, )))-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'create')-> end()-> ;
アップロードするファイルの絶対パスを渡すとブラウザーはファイルのアップロードもシミュレートします。
フォームを投稿した後で、実行されたアクションがcreate
であることを確認しました。
フォームテスター
投稿したフォームは有効になります。 フォームテスター(form tester)を使ってこれをテストできます:
with('form')->begin()-> hasErrors(false)-> end()->
フォームテスターはエラーのような現在のフォームステータスをテストするためのメソッドをいくつか持ちます。
テストに間違いがあると、テストは通らないので、9日目で見たように~with('response')->debug()|デバッグ~
ステートメントを利用できます。
しかし、エラーメッセージを確認するために生成されるHTMLを徹底的に調べなければなりません。
これは本当に便利ではありません。
フォームテスターはフォームのステータスとこれに関連するすべてのエラーメッセージを出力するdebug()
メソッドも提供します:
with('form')->debug()
リダイレクトのテスト
フォームが有効なので、求人は作成されユーザーはshow
ページにリダイレクトされます:
isRedirected()-> followRedirect()-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'show')-> end() ;
isRedirected()
はページがリダイレクトされるかどうかをテストしfollowRedirect()
メソッドはリダイレクトに従います。
note
ブラウザークラスは自動的にリダイレクトに従いません。 リダイレクトの前にオブジェクトをイントロスペクトするとよいでしょう。
Doctrineテスター
結局、求人がデータベースに作成されたことをテストしユーザーがまだ公開していないので
is_activated
カラムがfalse
にセットされていることをチェックしたい場合を考えます。
これは別のテスター、Doctrineテスターを使うことで簡単にできます。 Doctrineテスターは登録されていないので、今追加してみましょう:
$browser->setTester('doctrine', 'sfTesterDoctrine');
Doctrineテスターはcheck()
メソッドを提供します。
このメソッドはデータベースの1つもしくは複数のオブジェクトが引数として渡される基準を満たすことをチェックします。
with('doctrine')->begin()-> check('JobeetJob', array( 'location' => 'Atlanta, USA', 'is_activated' => false, 'is_public' => false, ))-> end()
基準は上記のような値の配列もしくはより複雑なクエリに対するDoctrine_Query
インスタンスになります。
3番目の引数としてブール値を持つ基準を満たすオブジェクトの存在(デフォルトはtrue
)、もしくは整数として渡されることで基準を満たすオブジェクトの数をテストできます。
エラーをテストする
有効な値を投稿するときに求人フォーム作成は期待どおりに動作します。 有効ではないデータを投稿するときにふるまいをチェックするテストを追加してみましょう:
$browser-> info(' 3.2 - Submit a Job with invalid values')-> get('/job/new')-> click('Preview your job', array('job' => array( 'company' => 'Sensio Labs', 'position' => 'Developer', 'location' => 'Atlanta, USA', 'email' => 'not.an.email', )))-> with('form')->begin()-> hasErrors(3)-> isError('description', 'required')-> isError('how_to_apply', 'required')-> isError('email', 'invalid')-> end() ;
hasErrors()
メソッドは整数として渡される場合にエラーの数をテストできます。
isError()
メソッドは渡されたフィールド用のエラーコードをテストします。
tip
テストにおいて有効ではないデータの投稿のためにテストを書き、フォーム全体を繰り返し再テストしませんでした。 特定の内容に対してのみテストを追加しました。
エラーメッセージが含まれるかどうか確認するために生成されたHTMLもテストできますが、フォームのレイアウトをカスタマイズしていないので、私たちの場合は必要ありません。
これで、求人プレビューページで見つかるadminバーをテストする必要があります。
求人がまだアクティベートされていないとき、jobを編集、削除もしくは公開できます。
これら3つのリンクをテストするには、最初に求人を作成する必要があります。
しかし、これはたくさんのコピー&ペーストが行われます。
電子ツリーを無駄遣いしたくないので、JobeetTestFunctional
クラスに求人作成メソッドを追加しましょう:
// lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function createJob($values = array()) { return $this-> get('/job/new')-> click('Preview your job', array('job' => array_merge(array( 'company' => 'Sensio Labs', 'url' => 'http://www.sensio.com/', 'position' => 'Developer', 'location' => 'Atlanta, USA', 'description' => 'You will work with symfony to develop websites for our customers.', 'how_to_apply' => 'Send me an email', 'email' => 'for.a.job@example.com', 'is_public' => false, ), $values)))-> followRedirect() ; } // ... }
createJob()
メソッドは求人を作成し、リダイレクトに従い流れるようなインターフェイスを壊さないようにブラウザーを返します。
デフォルトの値にマージされる値の配列を渡すことができます。
リンクのHTTPメソッドを強制する
"Publish"リンクのテストはよりシンプルです:
$browser->info(' 3.3 - On the preview page, you can publish the job')-> createJob(array('position' => 'FOO1'))-> click('Publish', array(), array('method' => 'put', '_with_csrf' => true))-> with('doctrine')->begin()-> check('JobeetJob', array( 'position' => 'FOO1', 'is_activated' => true, ))-> end() ;
10日目を覚えていれば、"Publish"リンクはHTTP ~PUT|PUT(HTTPメソッド)~
メソッドで呼び出せるように設定できます。
ブラウザーはPUT
リクエストを理解しないので、link_to()
ヘルパーはリンクをJavaScriptつきのフォームに変換します。
テストブラウザーはJavaScriptを実行しないので、click()
メソッドの3番目のオプションとしてメソッドを渡すことで、メソッドにPUT
を強制する必要があります。
さらに、link_to()
ヘルパーはCSRFトークンも埋め込みます。
1日目にCSRFの保護を有効にしたので; _with_csrf
オプションはこのトークンをシミュレートします。
"Delete"リンクのテストはよく似ています:
$browser->info(' 3.4 - On the preview page, you can delete the job')-> createJob(array('position' => 'FOO2'))-> click('Delete', array(), array('method' => 'delete', '_with_csrf' => true))-> with('doctrine')->begin()-> check('JobeetJob', array( 'position' => 'FOO2', ), false)-> end() ;
SafeGuardとしてのテスト
求人が公開されるとき、もはや編集できません。 以前のページで"Edit"リンクがもはや表示されなくても、この要件用のテストを追加しましょう。
最初に、求人が自動的に公開されるように、別の引数をcreateJob()
メソッドに追加し、職業(position)の値に渡される求人を返すgetJobByPosition()
メソッドを作ります:
// lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function createJob($values = array(), $publish = false) { $this-> get('/job/new')-> click('Preview your job', array('job' => array_merge(array( 'company' => 'Sensio Labs', 'url' => 'http://www.sensio.com/', 'position' => 'Developer', 'location' => 'Atlanta, USA', 'description' => 'You will work with symfony to develop websites for our customers.', 'how_to_apply' => 'Send me an email', 'email' => 'for.a.job@example.com', 'is_public' => false, ), $values)))-> followRedirect() ; if ($publish) { $this-> click('Publish', array(), array('method' => 'put', '_with_csrf' => true))-> followRedirect() ; } return $this; } public function getJobByPosition($position) { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.position = ?', $position); return $q->fetchOne(); } // ... }
求人が公開される場合、編集ページは404ステータスコードを返さなければなりません:
$browser->info(' 3.5 - When a job is published, it cannot be edited anymore')-> createJob(array('position' => 'FOO3'), true)-> get(sprintf('/job/%s/edit', $browser->getJobByPosition('FOO3')->getToken()))-> with('response')->begin()-> isStatusCode(404)-> end() ;
しかしテストを実行する場合、昨日はこのセキュリティの測定を実装するのを忘れたので、期待した結果は得られません。 すべてのエッジケースを考える必要があるので、テストを書くのはバグを発見するためのすばらしい方法でもあります。
求人がアクティブである場合、必要なのは404エラーページにリダイレクトだけで、バグの修正はとてもシンプルです:
// apps/frontend/modules/job/actions/actions.class.php public function executeEdit(sfWebRequest $request) { $job = $this->getRoute()->getObject(); $this->forward404If($job->getIsActivated()); $this->form = new JobeetJobForm($job); }
修正はささいなことですが、すべてがまだ期待どおりに動作すると思っていますか? ブラウザーを開き編集ページにアクセスする可能な組み合わせのテストすべてを始めることができます。 しかしよりシンプルな方法があります: テストスイートを実行します; 回帰テストを導入していれば、symfonyはすぐに教えてくれます。
テストで未来に戻る
求人が5日以内に期限切れするとき、もしくはすでに期限切れしている場合、ユーザーは現在の日付から30日後の期間に求人のバリデーションを拡張できます。
ブラウザーでこの要件をテストするのは簡単ではありません。 将来の30日に求人が作成されるとき期限の日付が自動的に設定されるからです。 ですので、求人ページを取得するとき、求人期間を延長するリンクは存在しません。 もちろん、データベースで期限日をハックする、もしくはリンクを表示するためにテンプレートを調整できますが、これは退屈でエラーになりがちです。 ご明察のとおり、テストを書くことで時間の節約になります。
常に、最初にextend
メソッド用の新しいルートを追加する必要があります:
# apps/frontend/config/routing.yml job: class: sfDoctrineRouteCollection options: model: JobeetJob column: token object_actions: { publish: PUT, extend: PUT } requirements: token: \w+
それから、_admin
パーシャルの"Extend"リンクコードを更新します:
<!-- apps/frontend/modules/job/templates/_admin.php --> <?php if ($job->expiresSoon()): ?> - <?php echo link_to('Extend', 'job_extend', $job, array('method' => 'put')) ?> for another <?php echo sfConfig::get('app_active_days') ?> days <?php endif; ?>
それから、extend
アクションを作ります:
// apps/frontend/modules/job/actions/actions.class.php public function executeExtend(sfWebRequest $request) { $request->checkCSRFProtection(); $job = $this->getRoute()->getObject(); $this->forward404Unless($job->extend()); $this->getUser()->setFlash('notice', sprintf('Your job validity has been extended until %s.', date('m/d/Y', strtotime($job->getExpiresAt())))); $this->redirect($this->generateUrl('job_show_user', $job)); }
アクションに期待されるように求人期間が延長される場合JobeetJob
のextend()
メソッドはtrue
を返し、そうでなければfalse
を返します:
// lib/model/doctrine/JobeetJob.class.php class JobeetJob extends BaseJobeetJob { public function extend() { if (!$this->expiresSoon()) { return false; } $this->setExpiresAt(date('Y-m-d', time() + 86400 * sfConfig::get('app_active_days'))); $this->save(); return true; } // ... }
最終的に、テストのシナリオを追加します:
$browser->info(' 3.6 - A job validity cannot be extended before the job expires soon')-> createJob(array('position' => 'FOO4'), true)-> call(sprintf('/job/%s/extend', $browser->getJobByPosition('FOO4')->getToken()), 'put', array('_with_csrf' => true))-> with('response')->begin()-> isStatusCode(404)-> end() ; $browser->info(' 3.7 - A job validity can be extended when the job expires soon')-> createJob(array('position' => 'FOO5'), true) ; $job = $browser->getJobByPosition('FOO5'); $job->setExpiresAt(date('Y-m-d')); $job->save(); $browser-> call(sprintf('/job/%s/extend', $job->getToken()), 'put', array('_with_csrf' => true))-> with('response')->isRedirected() ; $job->refresh(); $browser->test()->is( date('y/m/d', strtotime($job->getExpiresAt())), date('y/m/d', time() + 86400 * sfConfig::get('app_active_days')) );
このテストのシナリオは少数の内容を導入します:
call()
メソッドはGET
もしくはPOST
からのメソッドでURLを読み取ります。- アクションで求人情報を更新した後で、
$job->refresh()
でローカルオブジェクトをリロードする必要があります。 - 最後に、新しい有効期間をテストするために埋め込みの
lime
オブジェクトを直接使います。
フォームのセキュリティ
フォームのシリアライゼーションマジック!
Doctrineフォームは多くの作業を自動化するのでとても便利です。
たとえば、フォームをデータベースにシリアライズするのに$form->save()
を呼び出すだけです。
しかしどのように動作するのでしょうか?
基本的に、save()
メソッドは次のステップに従います:
- トランザクションを始める(入れ子のDoctrineフォームは一度にすべて保存される)
- 投稿された値を処理する(値が存在する場合に
updateCOLUMNColumn()
メソッドを呼び出す) - カラムの値を更新するためにDoctrineオブジェクトの
fromArray()
メソッドを呼び出す - オブジェクトをデータベースに保存する
- トランザクションをコミットする
組み込みのセキュリティ機能
fromArray()
メソッドは値の配列を受け取り対応するカラムの値を更新します。
これはセキュリティ問題を表すのでしょうか?
認証されていない人がカラムに対して値を投稿しようとしたらどうなるでしょうか?
たとえば、token
カラムを強制できるでしょうか?
token
フィールドで求人投稿をシミュレートするテストを書いてみましょう:
// test/functional/frontend/jobActionsTest.php $browser-> get('/job/new')-> click('Preview your job', array('job' => array( 'token' => 'fake_token', )))-> with('form')->begin()-> hasErrors(7)-> hasGlobalError('extra_fields')-> end() ;
フォームを投稿するとき、extra_fields
グローバルエラーを用意しなければなりません。
デフォルトのフォームは追加フィールドが投稿される値に存在することを許可しないからです。
すべてのフォームフィールドは関連するバリデーターを持たなければならない理由でもあります。
tip
FirefoxのWeb Developer Toolbarのようなツールを利用して追加フィールドもブラウザーから楽に投稿できます。
allow_extra_fields
オプションをtrue
にセットすることでこのセキュリティ対策を回避できます:
class MyForm extends sfForm { public function configure() { // ... $this->validatorSchema->setOption('allow_extra_fields', true); } }
テストはパスしなければなりませんがtoken
の値は値からフィルタリングされました。
ですので、またセキュリティ対策を回避できません。
しかし本当に値が欲しい場合、filter_extra_fields
オプションをfalse
にセットします:
$this->validatorSchema->setOption('filter_extra_fields', false);
note
このセクションで書かれたテストの目的はデモンストレーションのみです。 テストはsymfonyの機能をバリデートする必要がないのでこれらをJobeetプロジェクトから削除できます。
XSSとCSRFの保護
1日目において、次のコマンドラインでfrontend
アプリケーションを作りました:
$ php symfony generate:app --escaping-strategy=on --csrf-secret=Unique$ecret frontend
--escaping-strategy
オプションはXSSに対する保護を有効にします。
これはテンプレートで使われるすべての変数がデフォルトでエスケープされることを意味します。
HTMLタグ内部で求人の説明を投稿しようとすると、symfonyが求人ページをレンダリングするとき、説明文からのHTMLタグがインタープリターで処理されず、プレーンなテキストとしてレンダリングされていることがわかります。
--csrf-secret
オプションはCSRFの保護を有効にしました。
このオプションを提供するとき、すべてのフォームは_csrf_token
隠しフィールドを埋め込みます。
tip
apps/frontend/config/settings.yml
設定ファイルを編集することで、escaping_strategy
とcsrf_secret
はいつでも変更できます。
databases.yml
ファイルに関して、個々のコンフィギュレーションは環境ごとに設定可能です:
all: .settings: # Form security secret (CSRF protection) csrf_secret: Unique$ecret # Output escaping settings escaping_strategy: on escaping_method: ESC_SPECIALCHARS
メンテナンスタスク
Webフレームワークではありますが、symfonyにはコマンドラインツールが付属しています。 プロジェクトとアプリケーションのデフォルトのディレクトリ構造を作るおよびモデル用のさまざまなファイルを生成するためにも使ってきました。 symfonyコマンドラインで使われるツールはフレームワークでパッケージとしてまとめられているので、新しいタスクを追加するのはとても簡単です。
ユーザーが求人を作成するとき、オンラインに設置するためにこれをアクティベートしなければなりません。 しかしそうでなければ、データベースは古い求人で膨れ上がります。 データベースから古い求人を削除するタスクを作りましょう。 このタスクはcronジョブで定期的に実行しなければなりません。
// lib/task/JobeetCleanupTask.class.php class JobeetCleanupTask extends sfBaseTask { protected function configure() { $this->addOptions(array( new sfCommandOption('application', null, sfCommandOption::PARAMETER_REQUIRED, 'The application', 'frontend'), new sfCommandOption('env', null, sfCommandOption::PARAMETER_REQUIRED, 'The environement', 'prod'), new sfCommandOption('days', null, sfCommandOption::PARAMETER_REQUIRED, '', 90), )); $this->namespace = 'jobeet'; $this->name = 'cleanup'; $this->briefDescription = 'Cleanup Jobeet database'; $this->detailedDescription = <<<EOF The [jobeet:cleanup|INFO] task cleans up the Jobeet database: [./symfony jobeet:cleanup --env=prod --days=90|INFO] EOF; } protected function execute($arguments = array(), $options = array()) { $databaseManager = new sfDatabaseManager($this->configuration); $nb = Doctrine::getTable('JobeetJob')->cleanup($options['days']); $this->logSection('doctrine', sprintf('Removed %d stale jobs', $nb)); } }
タスクの設定はconfigure()
メソッドで行われます。
それぞれのタスクは一意的な名前(namespace
:name
)と引数とオプションを持たなければなりません。
tip
使い方の例はsymfony組み込みのタスク(lib/task/
)を眺めてください。
jobeet:cleanup
タスクは良識のあるデフォルトを伴う2つのオプション: --env
と--days
を定義します。
タスクの実行はsymfony組み込みの他のタスクと同じです:
$ php symfony jobeet:cleanup --days=10 --env=dev
常に、データベースのクリーンナップコードはJobeetJobTable
クラスで取り除かれました:
// lib/model/doctrine/JobeetJobTable.class.php public function cleanup($days) { $q = $this->createQuery('a') ->delete() ->andWhere('a.is_activated = ?', 0) ->andWhere('a.created_at < ?', date('Y-m-d', time() - 86400 * $days)); return $q->execute(); }
note
タスクの成功に応じて値を返すのでsymfonyのタスクは環境に応じたふるまいをします。 タスクの最後で明示的に整数を返すことで戻り値を強制できます。
また明日
テストはsymfonyの哲学とツールの中心です。 開発プロセスを簡単で、速く、より重要で、安全にするために今日は、symfonyのツールの活用方法を再び学びました。
symfonyフォームフレームワークはウィジェットバリデーター以外にもたくさんの機能を提供します: フォームをテストする方法を提供しフォームがデフォルトでセキュアであることを保証します。
symfonyの偉大な機能のツアーは今日で終わりません。 明日は、Jobeet用のバックエンドアプリケーションを作ります。 バックエンドインターフェイスはたいていのWebプロジェクトで必須であり、Jobeetは難しくありません。 しかし1時間以内にこのようなインターフェイスを開発する方法は? シンプルです。symfonyのadminジェネレーターフレームワークを使います。 それまでは、お元気で。
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.