Tóm tắt
Hôm qua chúng ta đã tạo các form với symfony. Bây giờ, người dùng đã có thể gửi một công việc mới lên Jobeet, nhưng chúng ta đã không đủ thời gian để thực hiện test.
Chúng ta sẽ thực hiện việc này vào hôm nay. Trong quá trình thực hiện, chúng ta sẽ học nhiều hơn về form framework.
Submit một Form
Mở file jobActionsTest
và thêm functional tests cho quá trình tạo công việc và validation.
Thêm đoạn code sau vào cuối file:
// 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() ;
Chúng ta đã dùng phương thức click()
để giả lập việc click vào một link. Phương thức
click()
cũng có thể dùng để submit một form. Với một form, bạn có thể cung cấp
các giá trị cho mỗi field ở tham số thứ 2 của phương thức. Giống như
một trình duyệt thật, trình duyệt sẽ bổ sung các giá trị mặc định cho form
khi cần thiết.
Nhưng để cung cấp giá trị cho các field, chúng ta cần biết tên của chúng. Nếu bạn mở
source code hay sử dụng Firefox Web Developer Toolbar "Forms > Display Form
Details", bạn sẽ thấy rằng tên của field company
là jobeet_job[company]
.
note
Khi PHP gặp một input field có tên là jobeet_job[company]
, nó
sẽ tự động chuyển thành một mảng jobeet_job
.
Để dễ nhìn, chúng ta sẽ chuyển thành job[%s]
bằng cách thêm đoạn
code sau vào cuối phương thức configure()
của JobeetJobForm
:
// lib/form/doctrine/JobeetJobForm.class.php $this->widgetSchema->setNameFormat('job[%s]');
Sau khi thay đổi, tên của field company
sẽ là job[company]
. Bây giờ chúng ta thực hiện việc click vào nút "Preview your job" và cung cấp giá trị cho form:
// 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, ))) ;
Form phải được submit tới action create
:
with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'create')-> end()->
Trình duyệt cũng giả lập việc upload file bằng cách cung cấp đường dẫn tuyệt đối tới file cần upload.
Form Tester
Form chúng ta đã submit cần được valid. Chúng ta có thể kiểm tra điều này bằng cách sử dụng form tester:
with('form')->begin()-> hasErrors(false)-> end()->
Form tester có vài phương thức để kiểm tra trạng thái hiện tại của form, như errors...
Nếu bạn có lỗi trong test, và test không pass, bạn có thể sử dụng lệnh with('response')->debug()
đã học trong ngày 9 để debug. Nhưng bạn sẽ phải mò trong đống HTML sinh ra để kiểm tra thông báo lỗi. Điều đó không được tiện lợi cho lắm. Vì thế, form tester cũng cung cấp phương thức debug()
trả về trạng thái của form và tất cả thông báo lỗi liên quan đến nó:
with('form')->debug()
Redirection Test
Khi form được valid, công việc được tạo và user chuyển sang trang show
:
isRedirected()-> followRedirect()-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'show')-> end()->
isRedirected()
kiểm tra xem trang đã được redirect hay chưa và
phương thức followRedirect()
chuyển đến trang redirect.
Doctrine Tester
Cuối cùng, chúng ta muốn test rằng công việc đã được tạo trong database và
kiểm tra rằng cột is_public
có giá trị là false
khi user chưa published nó.
Điều này có thể thực hiện dễ dàng nhờ một tester khác, Doctrine tester. Mặc định, Doctrine tester không được đăng kí, chúng ta cần thêm nó:
$browser->setTester('doctrine', 'sfTesterDoctrine');
Doctrine tester cung cấp phương thức check()
để kiểm tra xem một hay nhiều
object trong database có match với các tham số được cung cấp.
with('doctrine')->begin()-> check('JobeetJob', array( 'location' => 'Atlanta, USA', 'is_activated' => false, 'is_public' => false, ))-> end()
Criteria có thể là một mảng các giá trị như ở trên, hoặc là một instance của Doctrine_Query
đối với các câu truy vấn phức tạp. Bạn có thể kiểm tra các objects có
match với criteria với tham số thứ 3 là Boolean (mặc định là
true
), hoặc số các matching objects (kiểu integer).
Test các lỗi
Job form đã tạo công việc đúng như mong đợi khi chúng ta submit các giá trị hợp lệ. Hãy thêm một test để kiểm tra khi chúng ta cung cấp dữ liệu không hợp lệ:
$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() ;
Phương thức hasErrors()
có thể kiểm tra số lỗi xảy ra.
Phương thức isError()
kiểm tra error code với từng field.
tip
Trong khi test chúng ta viết các giá trị không hợp lệ, chứ không test lại toàn bộ form. Chúng ta chỉ thêm các test cho các trường cụ thể.
Bạn cũng có thể test mã HTML sinh ra để kiểm tra rằng nó chứa thông báo lỗi, nhưng việc này là không cần thiết do chúng ta không chỉnh sửa form layout.
Bây giờ, chúng ta cần test thanh admin bar ở trang job preview. Khi một công việc
chưa được activated, bạn có thể edit, delete, hoặc publish công việc. Để test
3 link này, trước tiên chúng ta cần tạo một công việc. Chúng ta có thể copy
& paste code đã có. Để tránh việc lặp lại này, hãy thêm một phương thức tạo công việc trong lớp
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, 'type' => 'full-time', ), $values)))-> followRedirect() ; } // ... }
Bắt buộc HTTP Method với một link
Test link "Publish" trở nên đơn giản:
$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() ;
Nếu bạn nhớ lại trong ngày 10, link "Publish" đã được cấu hình để gọi với
HTTP PUT
method. Do trình duyệt không hiểu PUT
requests, helper link_to()
chuyển link thành một form với một vài
JavaScript. Do test browser không thực thi JavaScript, chúng ta cần bắt buộc
method là PUT
bằng cách cung cấp nó như là tham số thứ 3 của phương thức click()
.
Thêm vào đó, helper link_to()
cũng nhúng một CSRF token do chúng ta đã enable
CSRF protection từ ngày 1; _with_csrf
option mô phỏng token này.
Test link "Delete" hoàn toàn tương tự:
$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() ;
Tests SafeGuard
Khi một công việc được publish, bạn không thể sửa nó nữa. Link "Edit" sẽ không xuất hiện ở trang preview, ta thêm một vài test cho yêu cầu này.
Đầu tiên, thêm một tham số khác cho phương thức createJob()
để tự động
publication công việc, và tạo phương thức getJobByPosition()
để trả về công việc
theo giá trị position cung cấp:
// 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, 'type' => 'full-time', ), $values)))-> followRedirect() ; if ($publish) { $this->click('Publish', array(), array('method' => 'put', '_with_csrf' => true)); } return $this; } public function getJobByPosition($position) { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.position = ?', $position); return $q->fetchOne(); } // ... }
Nếu một công việc được publish, trang edit phải trả về mã trạng thái 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() ;
Nhưng nếu bạn chạy test, bạn sẽ không thu được kết quả như mong đợi, do chúng ta đã quên thực hiện vấn đề này ngày hôm qua. Viết test cũng là một cách tốt để tìm ra các bug, và bạn cần nghĩ tới tất cả các trường hợp có thể.
Sửa lỗi này khá đơn giản, chúng ta cần forward trang edit tới trang 404 khi một công việc đã activated:
// apps/frontend/modules/job/actions/actions.class.php public function executeEdit(sfWebRequest $request) { $jobeet_job = $this->getRoute()->getObject(); $this->form = new JobeetJobForm($jobeet_job); $this->forward404If($jobeet_job->getIsActivated()); }
Cách sửa thật đơn giản, nhưng bạn có chắc rằng mọi thứ sẽ làm việc như mong đợi? Bạn có thể mở trình duyệt và bắt đầu test mọi cách có thể để truy cập trang edit. Nhưng có một cách đơn giản hơn: chạy test suite của bạn;
Chuyển đến tương lai trong một Test
Khi một công việc hết hạn sau ít nhất 5 ngày, user có thể gia hạn cho công việc thêm 30 ngày nữa kể từ ngày hiện tại.
Test yêu cầu này từ trình duyệt không dễ dàng do ngày hết hạn được tự động tạo là sau 30 ngày từ ngày tạo công việc. Vì thế, khi chuyển đến trang công việc, link để extend cho công việc chưa tồn tại. Tất nhiên, bạn có thể sửa lại ngày hết hạn trong database, hoặc chỉnh lại template để hiển thị link này, nhưng việc này thật chán ngắt và dễ sinh ra lỗi. Bạn cũng có thể đoán được, viết một vài test sẽ giúp chúng ta thực hiện dễ dàng.
Trước tiên, chúng ta cần thêm một route mới cho extend
:
# apps/frontend/config/routing.yml job: class: sfDoctrineRouteCollection options: model: JobeetJob column: token object_actions: { publish: PUT, extend: PUT } requirements: token: \w+
Sau đó, sửa lại link "Extend" trong partial _admin
:
<!-- 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; ?>
Sau đó, tạo action 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 extend until %s.', date('m/d/Y', strtotime($job->getExpiresAt())))); $this->redirect($this->generateUrl('job_show_user', $job)); }
Phương thức extend()
của JobeetJob
trả về true
nếu công việc đã được gia hạn và false
trong trường hợp ngược lại:
// 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; } // ... }
Cuối cùng, thêm một kịch bản test:
$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')) );
Kịch bản test này có một vài thứ mới:
- Phương thức
call()
nhận một URL với một method khácGET
hayPOST
- Sau khi công việc đã được update bởi action, chúng ta cần nạp lại local
object với
$job->reload()
- Cuối cùng, chúng ta dùng đối tượng
lime
nhúng trực tiếp để test ngày hết hạn mới.
Forms Security
Form Serialization Magic!
Doctrine forms rất dễ sử dụng do chúng đã tự động thực hiện nhiều công việc.
Ví dụ, lưu một form vào database đơn giản là gọi $form->save()
. Nó hoạt động như thế nào?
Bình thường, phương thức save()
tiến hành qua các bước sau:
- Bắt đầu một transaction (because nested Doctrine forms are all saved in one fell swoop)
- Thực hiện việc submit giá trị (bằng cách gọi phương thức
updateCOLUMNColumn()
nếu chúng tồn tại) - Gọi Doctrine object
fromArray()
method để update giá trị các cột - Lưu object vào database
- Commit transaction
Tính năng bảo mật có sẵn
Phương thức fromArray()
nhận một mảng các giá trị và updates giá trị các cột
tương ứng. Vấn đề bảo mật ở đây là gì? Điều gì xảy ra nếu ai đó thử
submit một giá trị cho một cột mà anh ta không được phép?
Ví dụ cột token
chẳng hạn?
Hãy viết một test để mô phỏng việc submit một công việc với field token
:
// test/functional/frontend/jobActionsTest.php $browser-> get('/job/new')-> click('Preview your job', array('job' => array( 'token' => 'fake_token', )))-> with('form')->begin()-> hasErrors(8)-> hasGlobalError('extra_fields')-> end() ;
Khi submit form, bạn phải có một extra_fields
global error.
Đó là bởi vì mặc định, form không cho phép các field mở rộng để chứa các giá trị
submit. Đó cũng là lí do tại sao tất cả các form field phải có một validator.
tip
Bạn cũng có thể submit thêm các field từ trình duyệt bằng các sử dụng các công cụ như Firefox Web Developer Toolbar.
Bạn có thể bypass giới hạn bảo mật này bằng cách setting allow_extra_fields
thành true
:
class MyForm extends sfForm { public function configure() { // ... $this->validatorSchema->setOption('allow_extra_fields', true); } }
Test bây giờ phải pass nhưng giá trị token
được filtered out of the
values. Vì thế bạn vẫn không thể bypass giới hạn bảo mật này. Nếu bạn vẫn muốn lưu giá trị này, set filter_extra_fields
thành false
:
$this->validatorSchema->setOption('filter_extra_fields', false);
note
Test viết trong mục này chỉ mang tính chất giới thiệu. Bạn có thể xoá nó khỏi Jobeet project do ta không cần test các tính năng của symfony.
XSS và CSRF Protection
Trong ngày 1, chúng ta đã tạo application frontend
với lệnh sau:
$ php symfony generate:app --escaping-strategy=on --csrf-secret=Unique$ecret frontend
Option --escaping-strategy
cho phép chống lại tấn công XSS. Điều đó có nghĩa là
tất cả các giá trị dùng trong templates đều được escaped. Nếu bạn thử
submit một công việc với mô tả chứa một vài tag HTML, bạn sẽ thấy rằng khi
symfony render trang job, các HTML tag trong phần mô tả không được thực thi, mà hiển thị như plain text.
Option --csrf-secret
giúp bảo vệ ứng dụng trước tấn công CSRF. Khi bạn cung cấp lựa chọn này, tất cả các form đều nhúng thêm một field ẩn _csrf_token
.
tip
Escaping strategy và CSRF secret có thể thay đổi bằng cách chỉnh sửa
trong file cấu hình apps/frontend/config/settings.yml
.
Tương tự như file databases.yml
, cấu hình cũng phụ thuộc môi trường:
all: .settings: # Form security secret (CSRF protection) csrf_secret: Unique$ecret # Output escaping settings escaping_strategy: on escaping_method: ESC_SPECIALCHARS
Maintenance Tasks
Symfony là một web framework nhưng lại sử dụng rất nhiều công cụ dòng lệnh. Bạn đã dùng nó để tạo cấu trúc thư mục mặc định của project. Bạn cũng có thể thêm các lệnh mới một cách dễ dàng.
Trong ứng dụng Jobeet, database sẽ phình to với rất nhiều công việc cũ. Hãy tạo thêm một task để xóa các công việc cũ khỏi database. Task này sẽ được chạy thường xuyên trong một cron job.
// 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)); } }
Task được cấu hình trong phương thức configure()
. Mỗi task phải có một tên
duy nhất (namespace
:name
), và có thể có các tham số (argument) và lựa chọn (option).
tip
Mở thư mục lib/task/
để tham khảo các task có sẵn của symfony.
Lệnh jobeet:cleanup
có 2 lựa chọn: --env
và --days
với một vài giá trị mặc định.
Chạy lệnh này tương tự như chạy các lệnh khác trong symfony:
$ php symfony jobeet:cleanup --days=10 --env=dev
Để code trở nên sáng sủa ta chuyển phương thức cleanup vào model:
// 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
The symfony tasks behave nicely with their environment as they return a value according to the success of the task. You can force a return value by returning an integer explicitly at the end of the task.
Hẹn gặp lại ngày mai
Test là một phần quan trọng trong symfony. Hôm nay, chúng ta đã học thêm về cách sử dụng các công cụ của symfony khiến cho việc phát triển ứng dụng trở nên nhanh chóng, dễ dàng, và an toàn hơn.
Symfony form framework cung cấp nhiều widget và validator: nó giúp bạn dễ dàng test form để đảm bảo rằng form của bạn hoàn toàn bảo mật.
Chuyến hành trình khám phá những tính năng tuyệt vời của symfony vẫn chưa kết thúc. Ngày mai, chúng ta sẽ tạo backend application cho Jobeet. Tạo backend interface là yêu cầu bắt buộc với hầu hết các dự án web, và Jobeet cũng không là ngoại lệ. Nhưng chúng ta có thể phát triển toàn bộ chúng trong một giờ? Với symfony admin generator framework, việc đó trở nên đơn giản. Hãy đón xem vào ngày mai :)).
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.